fix(terminal): stop a hidden pane's unmeasured 80x24 from overwriting a live PTY's size on reattach (#18706)

* fix(terminal): stop a hidden pane's unmeasured 80x24 from overwriting a live PTY's size on reattach

A pane that mounts while display:none (app relaunch or update with the
floating terminal panel closed, a non-active floating tab, any
background tab) cannot fit its container, so it reattaches with xterm's
default 80x24. Main wrote those placeholder dims into `ptySizes`
unconditionally, both before and after the attach. A daemon attach never
resizes the live session, so the real PTY stayed at its wide grid while
main's hidden headless model was created (or reflowed after renderer
hydration) at 80 columns. Every byte the agent emitted while hidden was
parsed 80 wide; reveal restored that image into the pane: rows clamped
at column 80 with CHA fill, the status bar interleaved into response
text, and for alt-screen TUIs the whole screen stuck in an 80x24 corner
until a real resize forced a repaint. Scrollback damage was permanent.

Fix, main-side only:
- Pre-attach: seed `ptySizes` only for a genuinely fresh session id, or a
  measured request with nothing cached. A hidden reattach writes nothing.
- Commit: on reattach, record the provider's proven grid
  (`attachedGrid`, set only by the local provider whose attach really
  resizes), then the reply's `snapshotCols/Rows` (the daemon emulator's
  grid), then the size main already held, and only then the request.
- Reflow an already-created model to that grid after the seed block, so
  bytes that arrived before the reply no longer leave an 80x24 model.

Both the ipc and runtime spawn paths take the same authority module.
Renderer and wire formats are unchanged; `PtySpawnResult` is main-internal.

Reproduced deterministically: close the floating panel with Claude Code
streaming at 211x57, kill only the Electron main process so the daemon
survives, relaunch. Main's cache read 80x24 against an applied 211x57
and the reveal snapshot was 80 columns wide; replaying the recorded
bytes through an 80-column emulator reproduced the field screenshot.
Relaunch with the panel open, and a fresh spawn, keep the wide grid.

* fix(terminal): commit the adopted-claim reattach grid and reject non-integer provider grids

Review follow-ups. The runtime spawn path's adopted-claim branch returned before the size
commit, so an adoption attaching to a live session kept whatever the caller requested; it now
commits and reflows like every other reattach. The grid validator requires integers so a
malformed provider grid falls through to the cached size instead of reaching xterm.

* fix(terminal): reflow main's headless model onto the committed grid for every spawn, not only reattaches

A hidden attach whose daemon restarted comes back as a fresh session, and the
pre-attach seed is now withheld for unmeasured attaches, so a live byte that
created the model at 80x24 before the reply would have kept it there forever.

* refactor(terminal): let the provider's reattach flag pick the adopted-claim grid source

* fix(terminal): derive the adopted-claim reattach flag once for the size commit and the reservation

The SSH relay's adopted reply carries no isReattach, so the size commit
would have taken the request while the reservation was told it was an
attach. Normalize once so both agree.
This commit is contained in:
Jinwoo Hong
2026-09-04 21:24:54 -04:00
committed by GitHub
parent 38bde20121
commit 30d7542bc5
16 changed files with 608 additions and 12 deletions
@@ -0,0 +1,166 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
commitAttachedPtySize,
resolveCommittedPtySize,
shouldSeedPreAttachPtySize
} from './attached-pty-size'
import { ptySizes } from './visibility-state'
const REQUESTED = { cols: 80, rows: 24 }
const CACHED = { cols: 180, rows: 50 }
const LIVE = { cols: 211, rows: 57 }
describe('shouldSeedPreAttachPtySize', () => {
it('seeds a fresh session id even when the pane never measured itself', () => {
expect(
shouldSeedPreAttachPtySize({
isFreshSessionId: true,
hasCachedSize: true,
requestIsUnmeasured: true
})
).toBe(true)
})
it('never overwrites a size main already holds for the session', () => {
expect(
shouldSeedPreAttachPtySize({
isFreshSessionId: false,
hasCachedSize: true,
requestIsUnmeasured: false
})
).toBe(false)
})
it('refuses an unmeasured request on an attach even with nothing cached', () => {
expect(
shouldSeedPreAttachPtySize({
isFreshSessionId: false,
hasCachedSize: false,
requestIsUnmeasured: true
})
).toBe(false)
})
it('seeds a measured attach request when main holds nothing better', () => {
expect(
shouldSeedPreAttachPtySize({
isFreshSessionId: false,
hasCachedSize: false,
requestIsUnmeasured: false
})
).toBe(true)
})
})
describe('resolveCommittedPtySize', () => {
it('records the requested grid for a fresh spawn, ignoring any stale cache', () => {
expect(
resolveCommittedPtySize({
result: {},
requested: REQUESTED,
cachedBeforeAttach: CACHED
})
).toEqual(REQUESTED)
})
it('prefers a grid the provider applied on attach', () => {
expect(
resolveCommittedPtySize({
result: {
isReattach: true,
attachedGrid: { cols: 100, rows: 30 },
snapshotCols: LIVE.cols,
snapshotRows: LIVE.rows
},
requested: REQUESTED,
cachedBeforeAttach: CACHED
})
).toEqual({ cols: 100, rows: 30 })
})
it('falls back to the reattach snapshot grid', () => {
expect(
resolveCommittedPtySize({
result: { isReattach: true, snapshotCols: LIVE.cols, snapshotRows: LIVE.rows },
requested: REQUESTED,
cachedBeforeAttach: CACHED
})
).toEqual(LIVE)
})
it('falls back to the size main held when the provider proves nothing', () => {
expect(
resolveCommittedPtySize({
result: { isReattach: true },
requested: REQUESTED,
cachedBeforeAttach: CACHED
})
).toEqual(CACHED)
})
it('rejects a non-integer provider grid as unproven', () => {
expect(
resolveCommittedPtySize({
result: { isReattach: true, snapshotCols: 120.5, snapshotRows: 40 },
requested: REQUESTED,
cachedBeforeAttach: CACHED
})
).toEqual(CACHED)
})
it('takes the request only when nothing better exists', () => {
expect(
resolveCommittedPtySize({
result: { isReattach: true },
requested: REQUESTED,
cachedBeforeAttach: undefined
})
).toEqual(REQUESTED)
})
it('rejects a non-positive provider grid rather than publishing a zero-width model', () => {
expect(
resolveCommittedPtySize({
result: { isReattach: true, snapshotCols: 0, snapshotRows: 0 },
requested: REQUESTED,
cachedBeforeAttach: CACHED
})
).toEqual(CACHED)
})
})
describe('commitAttachedPtySize', () => {
afterEach(() => {
ptySizes.delete('pty-commit')
})
it('records the resolved grid and reflows the model onto it for a reattach', () => {
const reflow = vi.fn()
const committed = commitAttachedPtySize({
result: {
id: 'pty-commit',
isReattach: true,
snapshotCols: LIVE.cols,
snapshotRows: LIVE.rows
},
requested: REQUESTED,
cachedBeforeAttach: undefined,
reflowHeadlessTerminalToPtyGrid: reflow
})
expect(committed).toEqual(LIVE)
expect(ptySizes.get('pty-commit')).toEqual(LIVE)
expect(reflow).toHaveBeenCalledWith('pty-commit', LIVE.cols, LIVE.rows)
})
it('reflows a fresh spawn onto the request too: bytes can create the model before the reply', () => {
const reflow = vi.fn()
commitAttachedPtySize({
result: { id: 'pty-commit' },
requested: REQUESTED,
cachedBeforeAttach: CACHED,
reflowHeadlessTerminalToPtyGrid: reflow
})
expect(ptySizes.get('pty-commit')).toEqual(REQUESTED)
expect(reflow).toHaveBeenCalledWith('pty-commit', REQUESTED.cols, REQUESTED.rows)
})
})
@@ -0,0 +1,81 @@
import type { PtySpawnResult } from '../../../providers/types'
import { ptySizes } from './visibility-state'
export type PtyGrid = { cols: number; rows: number }
function positiveGrid(cols: unknown, rows: unknown): PtyGrid | undefined {
return typeof cols === 'number' &&
typeof rows === 'number' &&
Number.isInteger(cols) &&
Number.isInteger(rows) &&
cols > 0 &&
rows > 0
? { cols, rows }
: undefined
}
/** Pre-attach seed for `ptySizes`. Daemon PTYs can emit before spawn() resolves, so a genuinely
* fresh session must record its geometry now or early bytes parse at xterm's 80x24 default.
* An attach must not seed: a pane that mounted while hidden reports xterm's unmeasured default,
* and the live PTY's real grid is either already cached or arrives with the attach result. */
export function shouldSeedPreAttachPtySize(args: {
isFreshSessionId: boolean
hasCachedSize: boolean
requestIsUnmeasured: boolean
}): boolean {
return args.isFreshSessionId || (!args.hasCachedSize && !args.requestIsUnmeasured)
}
/** Grid to record for a settled spawn. Daemon and relay attach never resize the session they hand
* back, so on a reattach the requested grid describes the pane, not the live process — take the
* provider's proven grid, then the size main already held, before trusting the request. */
export function resolveCommittedPtySize(args: {
result: Pick<PtySpawnResult, 'isReattach' | 'attachedGrid' | 'snapshotCols' | 'snapshotRows'>
requested: PtyGrid
cachedBeforeAttach: PtyGrid | undefined
}): PtyGrid {
if (args.result.isReattach !== true) {
return args.requested
}
return (
positiveGrid(args.result.attachedGrid?.cols, args.result.attachedGrid?.rows) ??
positiveGrid(args.result.snapshotCols, args.result.snapshotRows) ??
positiveGrid(args.cachedBeforeAttach?.cols, args.cachedBeforeAttach?.rows) ??
args.requested
)
}
type HeadlessReflow = ((ptyId: string, cols: number, rows: number) => void) | undefined
/** Reflow main's model onto the committed grid, whatever the spawn was. Why unconditional: live
* bytes can lazily create the model at the 80x24 default before the reply arrives, a seed skips an
* existing model, and the pre-attach seed is now withheld for unmeasured attaches, so a session the
* daemon re-created instead of attaching would otherwise keep the default forever. */
export function reflowHeadlessTerminalToCommittedGrid(args: {
result: Pick<PtySpawnResult, 'id'>
committedSize: PtyGrid
reflowHeadlessTerminalToPtyGrid: HeadlessReflow
}): void {
args.reflowHeadlessTerminalToPtyGrid?.(
args.result.id,
args.committedSize.cols,
args.committedSize.rows
)
}
/** Record the settled grid, then reflow the model onto it. Callers that seed the model between the
* two steps (ipc spawn commit) call the halves separately. */
export function commitAttachedPtySize(args: {
result: Pick<
PtySpawnResult,
'id' | 'isReattach' | 'attachedGrid' | 'snapshotCols' | 'snapshotRows'
>
requested: PtyGrid
cachedBeforeAttach: PtyGrid | undefined
reflowHeadlessTerminalToPtyGrid: HeadlessReflow
}): PtyGrid {
const committedSize = resolveCommittedPtySize(args)
ptySizes.set(args.result.id, committedSize)
reflowHeadlessTerminalToCommittedGrid({ ...args, committedSize })
return committedSize
}
+9 -2
View File
@@ -11,12 +11,14 @@ import {
} from '../pane/serializer-state'
import { ptyOwnership, ptyIncarnationById, deletePtyOwnership } from '../provider/ownership-state'
import { ptySizes } from '../delivery/visibility-state'
import { resolveCommittedPtySize, type PtyGrid } from '../delivery/attached-pty-size'
import { clearProviderPtyState } from '../provider/state-cleanup'
import type { PtyIpcSpawnState } from './spawn-state'
export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{
rendererPreSignaled: boolean
rendererAlreadyRegistered: boolean
committedSize: PtyGrid
}> {
const args = ctx.args
try {
@@ -89,7 +91,12 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{
ctx.agentTeamsLeaderHandle = null
}
}
ptySizes.set(ctx.result.id, { cols: args.cols, rows: args.rows })
const committedSize = resolveCommittedPtySize({
result: ctx.result,
requested: { cols: args.cols, rows: args.rows },
cachedBeforeAttach: ctx.sessionSizeBeforeAttach
})
ptySizes.set(ctx.result.id, committedSize)
if (ctx.effectiveSessionAppId !== undefined && ctx.effectiveSessionAppId !== ctx.result.id) {
ptySizes.delete(ctx.effectiveSessionAppId)
}
@@ -157,5 +164,5 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{
pendingPtyIdBySerializerGeneration.set(pending.gen, ctx.result.id)
}
}
return { rendererPreSignaled, rendererAlreadyRegistered }
return { rendererPreSignaled, rendererAlreadyRegistered, committedSize }
}
+12 -1
View File
@@ -24,10 +24,12 @@ import {
} from '../pane/launch-authority'
import type { PtyIpcSpawnState } from './spawn-state'
import { persistPtyIpcSpawnCommit } from './spawn-commit-persist'
import { reflowHeadlessTerminalToCommittedGrid } from '../delivery/attached-pty-size'
export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawnResult> {
const args = ctx.args
const { rendererPreSignaled, rendererAlreadyRegistered } = await persistPtyIpcSpawnCommit(ctx)
const { rendererPreSignaled, rendererAlreadyRegistered, committedSize } =
await persistPtyIpcSpawnCommit(ctx)
// Why: seed the headless emulator before registerPty so concurrent live PTY data lands on top of the seed, not replacing it (mobile keeps the daemon-restored scrollback).
// Skip when the renderer will be authoritative — its xterm buffer is richer than the daemon snapshot.
@@ -71,6 +73,15 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawn
ctx.deps.runtime.seedHeadlessTerminal(ctx.result.id, ctx.result.replay)
}
}
// Why after the seed: a seed skips an existing model, and live bytes may have lazily created
// one at the 80x24 default before the spawn reply revealed the session's real grid.
reflowHeadlessTerminalToCommittedGrid({
result: ctx.result,
committedSize,
reflowHeadlessTerminalToPtyGrid: ctx.deps.runtime?.reflowHeadlessTerminalToPtyGrid?.bind(
ctx.deps.runtime
)
})
if (
typeof args.worktreeId === 'string' &&
args.worktreeId.length > 0 &&
+9 -1
View File
@@ -17,6 +17,7 @@ import {
pendingRuntimePaneCreatesByOwnerKey
} from '../pane/spawn-reservation'
import { ptySizes } from '../delivery/visibility-state'
import { shouldSeedPreAttachPtySize } from '../delivery/attached-pty-size'
import { getStartupTerminalColorQueryReplyColors } from '../../terminal-startup-color-query-replies'
import type { PtyIpcSpawnState } from './spawn-state'
@@ -97,7 +98,14 @@ export async function buildPtyIpcSpawnOptions(
ctx.effectiveSessionAppId !== undefined ? ptySizes.has(ctx.effectiveSessionAppId) : false
ctx.sessionSizeBeforeAttach =
ctx.effectiveSessionAppId !== undefined ? ptySizes.get(ctx.effectiveSessionAppId) : undefined
if (ctx.effectiveSessionId !== undefined) {
if (
ctx.effectiveSessionId !== undefined &&
shouldSeedPreAttachPtySize({
isFreshSessionId: ctx.isMintedSessionId,
hasCachedSize: ctx.hadSessionSizeBeforeAttach,
requestIsUnmeasured: args.initiallyHidden === true
})
) {
// Why: daemon PTYs can emit before spawn() resolves; set real geometry now or early bytes default to 80x24 and wrap TUIs.
ptySizes.set(ctx.effectiveSessionAppId ?? ctx.effectiveSessionId, {
cols: args.cols,
@@ -0,0 +1,136 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { PtySpawnResult } from '../../../providers/types'
import { ptySizes } from '../delivery/visibility-state'
import { buildPtyIpcSpawnOptions } from './spawn-options'
import { commitPtyIpcSpawn } from './spawn-commit'
import { createPtyIpcSpawnState, type PtyIpcSpawnState } from './spawn-state'
import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types'
const SESSION_ID = 'orca-pty-session-1'
/** What a pane that mounted while `display:none` reports: xterm's unmeasured default. */
const HIDDEN_PANE_REQUEST = { cols: 80, rows: 24 }
/** The grid the surviving daemon session is actually running at. */
const LIVE_GRID = { cols: 211, rows: 57 }
function makeRuntime() {
return {
seedHeadlessTerminal: vi.fn(),
reflowHeadlessTerminalToPtyGrid: vi.fn(),
registerPty: vi.fn(),
cancelPendingPtyRegistration: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
seedTerminalRestoreTail: vi.fn(),
registerPreAllocatedHandleForPty: vi.fn()
}
}
function makeCtx(args: PtySpawnIpcArgs, runtime: ReturnType<typeof makeRuntime>): PtyIpcSpawnState {
const deps = {
transitionSpawnHiddenRendererPtyDeliveryState: vi.fn(),
syncPtyBackgroundedDelivery: vi.fn(),
sendPtySpawnedToRenderer: vi.fn(),
runtime
} as unknown as PtySpawnIpcDeps
const ctx = createPtyIpcSpawnState(deps, args)
ctx.env = {}
ctx.isDaemonHostSpawn = true
ctx.effectiveSessionId = SESSION_ID
ctx.effectiveSessionAppId = SESSION_ID
// Mirrors spawn-preflight: a caller-supplied sessionId is an attach, never a fresh mint.
ctx.isMintedSessionId = args.sessionId === undefined
return ctx
}
async function runSpawn(
args: PtySpawnIpcArgs,
result: PtySpawnResult
): Promise<{
runtime: ReturnType<typeof makeRuntime>
preAttachSize: { cols: number; rows: number } | undefined
}> {
const runtime = makeRuntime()
const ctx = makeCtx(args, runtime)
await buildPtyIpcSpawnOptions(ctx)
const preAttachSize = ptySizes.get(SESSION_ID)
ctx.result = result
await commitPtyIpcSpawn(ctx)
return { runtime, preAttachSize }
}
describe('spawn size cache on reattach', () => {
afterEach(() => {
ptySizes.delete(SESSION_ID)
vi.restoreAllMocks()
})
it('records the reattached session real grid, not a hidden pane placeholder', async () => {
const { runtime, preAttachSize } = await runSpawn(
{ ...HIDDEN_PANE_REQUEST, sessionId: SESSION_ID, initiallyHidden: true },
{
id: SESSION_ID,
isReattach: true,
snapshotCols: LIVE_GRID.cols,
snapshotRows: LIVE_GRID.rows
}
)
// Pre-attach: an unmeasured request must not be published as the live PTY's size.
expect(preAttachSize).toBeUndefined()
expect(ptySizes.get(SESSION_ID)).toEqual(LIVE_GRID)
expect(runtime.reflowHeadlessTerminalToPtyGrid).toHaveBeenCalledWith(
SESSION_ID,
LIVE_GRID.cols,
LIVE_GRID.rows
)
})
it('records the requested grid for a genuinely fresh spawn', async () => {
const { runtime, preAttachSize } = await runSpawn({ cols: 120, rows: 40 }, { id: SESSION_ID })
expect(preAttachSize).toEqual({ cols: 120, rows: 40 })
expect(ptySizes.get(SESSION_ID)).toEqual({ cols: 120, rows: 40 })
expect(runtime.reflowHeadlessTerminalToPtyGrid).toHaveBeenCalledWith(SESSION_ID, 120, 40)
})
// Why: the pre-attach seed is withheld for an unmeasured attach, and a daemon that restarted
// re-creates the session instead of attaching, so only the commit can size the model.
it('reflows a hidden attach the daemon answered with a fresh session onto the request', async () => {
const { runtime, preAttachSize } = await runSpawn(
{ cols: 100, rows: 30, sessionId: SESSION_ID, initiallyHidden: true },
{ id: SESSION_ID }
)
expect(preAttachSize).toBeUndefined()
expect(ptySizes.get(SESSION_ID)).toEqual({ cols: 100, rows: 30 })
expect(runtime.reflowHeadlessTerminalToPtyGrid).toHaveBeenCalledWith(SESSION_ID, 100, 30)
})
it('keeps the size main already held when the reattach carries no snapshot grid', async () => {
ptySizes.set(SESSION_ID, { cols: 180, rows: 50 })
const { preAttachSize } = await runSpawn(
{ ...HIDDEN_PANE_REQUEST, sessionId: SESSION_ID, initiallyHidden: true },
{ id: SESSION_ID, isReattach: true }
)
expect(preAttachSize).toEqual({ cols: 180, rows: 50 })
expect(ptySizes.get(SESSION_ID)).toEqual({ cols: 180, rows: 50 })
})
it('prefers the grid the provider applied on attach over every other source', async () => {
ptySizes.set(SESSION_ID, { cols: 180, rows: 50 })
await runSpawn(
{ ...HIDDEN_PANE_REQUEST, sessionId: SESSION_ID, initiallyHidden: true },
{
id: SESSION_ID,
isReattach: true,
attachedGrid: { cols: 100, rows: 30 },
snapshotCols: LIVE_GRID.cols,
snapshotRows: LIVE_GRID.rows
}
)
expect(ptySizes.get(SESSION_ID)).toEqual({ cols: 100, rows: 30 })
})
})
@@ -0,0 +1,80 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ptySizes } from '../delivery/visibility-state'
import { commitRuntimePtySpawn } from './spawn-commit'
import { createRuntimePtySpawnState, type RuntimePtySpawnArgs } from './spawn-state'
import type { PtyRuntimeControllerDeps } from './controller-deps'
const PTY_ID = 'orca-pty-adopted'
const LIVE_GRID = { cols: 211, rows: 57 }
function makeRuntime() {
return {
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
reflowHeadlessTerminalToPtyGrid: vi.fn(),
seedHeadlessTerminal: vi.fn(),
noteTerminalSpawnCommand: vi.fn()
}
}
describe('runtime spawn commit: adopted agent-session claim', () => {
afterEach(() => {
ptySizes.delete(PTY_ID)
})
function makeAdoptedCtx(result: Record<string, unknown>) {
const runtime = makeRuntime()
const deps = { runtime, store: undefined, options: {} } as unknown as PtyRuntimeControllerDeps
const args = { cols: 120, rows: 40, worktreeId: 'wt-1' } as unknown as RuntimePtySpawnArgs
const ctx = createRuntimePtySpawnState(deps, args)
ctx.result = {
id: PTY_ID,
...result,
agentSessionEnsure: {
disposition: 'adopted',
owner: {
claim: { kind: 'terminal' },
generation: 'g1',
phase: 'live',
ptyId: PTY_ID,
surface: { worktreeId: 'wt-1', tabId: 'tab-1', leafId: 'leaf-1', terminalHandle: 'h1' }
}
}
} as unknown as typeof ctx.result
return { runtime, ctx }
}
it('commits the live grid from the adoption reply before the early return', async () => {
const { runtime, ctx } = makeAdoptedCtx({
isReattach: true,
snapshotCols: LIVE_GRID.cols,
snapshotRows: LIVE_GRID.rows
})
await commitRuntimePtySpawn(ctx)
expect(ptySizes.get(PTY_ID)).toEqual(LIVE_GRID)
expect(runtime.reflowHeadlessTerminalToPtyGrid).toHaveBeenCalledWith(
PTY_ID,
LIVE_GRID.cols,
LIVE_GRID.rows
)
})
// Why: the SSH relay's adopted reply carries neither isReattach nor snapshot dims; the
// adoption itself proves a live owner, so main keeps what it held rather than the request.
it('treats an adoption without a reattach flag as an attach and keeps the held size', async () => {
ptySizes.set(PTY_ID, LIVE_GRID)
const { runtime, ctx } = makeAdoptedCtx({})
ctx.sessionSizeBeforeAttach = LIVE_GRID
await commitRuntimePtySpawn(ctx)
expect(ptySizes.get(PTY_ID)).toEqual(LIVE_GRID)
expect(runtime.reflowHeadlessTerminalToPtyGrid).toHaveBeenCalledWith(
PTY_ID,
LIVE_GRID.cols,
LIVE_GRID.rows
)
})
})
@@ -0,0 +1,18 @@
import { commitAttachedPtySize } from '../delivery/attached-pty-size'
import type { RuntimePtySpawnState } from './spawn-state'
/** Record the settled grid for a runtime-path spawn; `result` is passed explicitly because the
* adopted-claim branch commits before it returns early. */
export function commitRuntimePtySize(
ctx: RuntimePtySpawnState,
result: RuntimePtySpawnState['result']
): void {
commitAttachedPtySize({
result,
requested: { cols: ctx.args.cols, rows: ctx.args.rows },
cachedBeforeAttach: ctx.sessionSizeBeforeAttach,
reflowHeadlessTerminalToPtyGrid: ctx.deps.runtime?.reflowHeadlessTerminalToPtyGrid?.bind(
ctx.deps.runtime
)
})
}
+13 -5
View File
@@ -1,6 +1,7 @@
import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id'
import { ptyOwnership, ptyIncarnationById, deletePtyOwnership } from '../provider/ownership-state'
import { ptySizes } from '../delivery/visibility-state'
import { commitRuntimePtySize } from './spawn-commit-pty-size'
import {
shouldSkipCodexHomeEnvForWindowsShell,
recordCodexPaneAccountForSpawn,
@@ -57,6 +58,9 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
})
}
if (ctx.result.agentSessionEnsure?.disposition === 'adopted') {
// Why: an adoption is an attach to a live owner by definition, but the SSH relay's adopted
// reply omits isReattach; derive it once so the size commit and the reservation agree.
const adoptedResult = { ...ctx.result, isReattach: true }
const owner = ctx.result.agentSessionEnsure.owner
ptyOwnership.set(ctx.result.id, args.connectionId ?? ptyOwnership.get(ctx.result.id) ?? null)
ctx.deps.runtime?.registerPreAllocatedHandleForPty(ctx.result.id, owner.surface.terminalHandle)
@@ -86,13 +90,17 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
...(ctx.env ? { launchEnv: ctx.env } : {})
})
}
// Why: this branch returns before the normal commit site; without this the cache keeps
// whatever the caller requested.
commitRuntimePtySize(ctx, adoptedResult)
// Why: the adopted branch returns before the normal settle site, so the
// reservation must be resolved here or every later spawn for this pane
// awaits a promise that never settles.
resolvePaneSpawnReservation(ctx.paneSpawnReservationKey, ctx.paneSpawnReservation, {
...ctx.result,
isReattach: true
})
resolvePaneSpawnReservation(
ctx.paneSpawnReservationKey,
ctx.paneSpawnReservation,
adoptedResult
)
return {
id: ctx.result.id,
...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}),
@@ -125,7 +133,7 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
if (!ctx.hostSessionBinding) {
persistSshLease()
}
ptySizes.set(ctx.result.id, { cols: args.cols, rows: args.rows })
commitRuntimePtySize(ctx, ctx.result)
if (ctx.effectiveSessionAppId !== undefined && ctx.effectiveSessionAppId !== ctx.result.id) {
ptySizes.delete(ctx.effectiveSessionAppId)
}
+12 -1
View File
@@ -3,6 +3,7 @@ import { LocalPtyProvider } from '../../../providers/local-pty-provider'
import { makePaneKey, isTerminalLeafId } from '../../../../shared/stable-pane-id'
import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id'
import { ptySizes } from '../delivery/visibility-state'
import { shouldSeedPreAttachPtySize } from '../delivery/attached-pty-size'
import { CODEX_HOME_ENV_KEYS } from '../host-env/codex-home'
import {
mergePtyEnvDeletions,
@@ -110,7 +111,17 @@ export async function buildRuntimePtySpawnOptions(
ctx.effectiveSessionAppId !== undefined ? ptySizes.get(ctx.effectiveSessionAppId) : undefined
if (ctx.sessionId !== undefined) {
ctx.spawnOptions.sessionId = ctx.sessionId
ptySizes.set(ctx.effectiveSessionAppId ?? ctx.sessionId, { cols: args.cols, rows: args.rows })
if (
shouldSeedPreAttachPtySize({
isFreshSessionId: ctx.isNewDaemonSession,
hasCachedSize: ctx.hadSessionSizeBeforeAttach,
// Why false: runtime callers (CLI, headless serve) have no hidden pane to report, so a
// cached size is the only source that can outrank their requested grid here.
requestIsUnmeasured: false
})
) {
ptySizes.set(ctx.effectiveSessionAppId ?? ctx.sessionId, { cols: args.cols, rows: args.rows })
}
}
ctx.materializedPaneKey = ctx.hostSessionBinding
? makePaneKey(ctx.hostSessionBinding.tabId, ctx.hostSessionBinding.leafId)
@@ -173,7 +173,10 @@ describe('LocalPtyProvider', () => {
expect(second).toEqual({
id: 'serve-session-1',
pid: 12345,
isReattach: true
isReattach: true,
// Why published: this attach really moved the PTY, unlike daemon/relay attach, so main
// must record 120x40 rather than preserving the size it held for the session.
attachedGrid: { cols: 120, rows: 40 }
})
expect(mockProc.resize).toHaveBeenCalledWith(120, 40)
expect(spawnMock).not.toHaveBeenCalled()
+5 -1
View File
@@ -51,8 +51,10 @@ export function reattachLocalPty(id: string, cols: number, rows: number): PtySpa
if (!existing) {
return null
}
let resized = false
try {
existing.resize(cols, rows)
resized = true
} catch {
/* Existing PTY may reject resize during teardown; still return the live handle. */
}
@@ -60,6 +62,8 @@ export function reattachLocalPty(id: string, cols: number, rows: number): PtySpa
id,
pid: existing.pid,
...(ptyWslDistroById.has(id) ? { wslDistro: ptyWslDistroById.get(id) ?? null } : {}),
isReattach: true
isReattach: true,
// Why: unlike daemon/relay attach, this one really moved the live PTY to the caller's grid.
...(resized ? { attachedGrid: { cols, rows } } : {})
}
}
+4
View File
@@ -57,6 +57,10 @@ export type PtySpawnResult = {
snapshotTerminalOwner?: TerminalOwner
/** True when the spawn reattached to an existing daemon session. */
isReattach?: boolean
/** Grid the PTY is proven to be at once this spawn settled. Only providers whose attach
* applies the requested size set it; daemon/relay attach leave the live grid alone, so main
* must not read the requested dims back as a measurement (see `resolveCommittedPtySize`). */
attachedGrid?: { cols: number; rows: number }
/** Last OSC title tracked by the daemon session the snapshot came from.
* Seeds main's terminal title records after a relaunch; never replayed
* into a terminal. */
@@ -163,6 +163,17 @@ export class OrcaRuntimeWithCreatePtyHeadlessTerminalState extends OrcaRuntimeWi
})
}
/** Public: reflow an already-created model onto a grid the PROVIDER proved — a reattach learns
* the live session's real size only from its spawn reply, after live bytes may have lazily
* created the model at the 80x24 default. Not onExternalPtyResize: nothing measured a pane
* here, so the renderer-geometry baselines behind mobile take-back must stay untouched. */
reflowHeadlessTerminalToPtyGrid(ptyId: string, cols: number, rows: number): void {
if (cols <= 0 || rows <= 0) {
return
}
this.resizeHeadlessTerminal(ptyId, cols, rows)
}
// Public: desktop-initiated clears (ipc/pty.ts) must also drop this mobile
// mirror or a resubscribing mobile client resurrects the cleared scrollback.
async clearHeadlessTerminalBuffer(ptyId: string): Promise<void> {
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { createRuntime, syncSinglePty } from '../orca-runtime-test-fixtures.spec'
describe('headless model grid after a reattach', () => {
it('reflows a model that live bytes created at the 80x24 default onto the PTY grid', async () => {
const runtime = createRuntime()
// No controller size: mirrors a reattach whose real grid main only learns from the reply.
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
getSize: () => null
})
syncSinglePty(runtime, 'pty-1')
runtime.onPtyData('pty-1', 'user@host % claude\r\n', 100)
await expect(
runtime.serializeMainTerminalBuffer('pty-1', { scrollbackRows: 100 })
).resolves.toMatchObject({ cols: 80, rows: 24 })
runtime.reflowHeadlessTerminalToPtyGrid('pty-1', 211, 57)
await expect(
runtime.serializeMainTerminalBuffer('pty-1', { scrollbackRows: 100 })
).resolves.toMatchObject({ cols: 211, rows: 57, source: 'headless' })
})
it('never creates a model for a PTY that has none', async () => {
const runtime = createRuntime()
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
getSize: () => null
})
syncSinglePty(runtime, 'pty-1')
runtime.reflowHeadlessTerminalToPtyGrid('pty-1', 211, 57)
runtime.onPtyData('pty-1', 'hello\r\n', 100)
// Why it matters: commit reflows every reattach, and pre-creating here would defeat the
// renderer-authority gate that deliberately leaves the model unseeded.
await expect(
runtime.serializeMainTerminalBuffer('pty-1', { scrollbackRows: 100 })
).resolves.toMatchObject({ cols: 80, rows: 24 })
})
})
+1
View File
@@ -34,6 +34,7 @@ await import('./orca-runtime-tests/terminal-side-effect-facts-part-03.spec')
await import('./orca-runtime-tests/decorative-title-fact-throttle.spec')
await import('./orca-runtime-tests/headless-snapshots.spec')
await import('./orca-runtime-tests/headless-snapshots-part-02.spec')
await import('./orca-runtime-tests/reattach-headless-grid.spec')
await import('./orca-runtime-tests/agent-status-and-waits.spec')
await import('./orca-runtime-tests/agent-status-and-waits-part-02.spec')
await import('./orca-runtime-tests/agent-status-and-waits-part-03.spec')