Files
orca/src/main/ipc/pty/runtime/spawn-commit.ts
T
Jinwoo Hong 30d7542bc5 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.
2026-09-04 21:24:54 -04:00

312 lines
13 KiB
TypeScript

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,
codexReattachedHomeRouteField
} from '../host-env/codex-home'
import { markClaudePtySpawned } from '../../../claude-accounts/live-pty-gate'
import { registerPty } from '../../../memory/pty-registry'
import { rememberPaneKeyForPty } from '../pane/key-state'
import {
pendingByPaneKey,
pendingPtyIdBySerializerGeneration,
rendererSerializerReadiness
} from '../pane/serializer-state'
import { seedTerminalRestoreRecordsFromSpawnResult } from '../pane/agent-session-owners'
import { track } from '../../../telemetry/client'
import { getCohortAtEmit } from '../../../telemetry/cohort-classifier'
import {
agentKindSchema,
launchSourceSchema,
requestKindSchema
} from '../../../../shared/telemetry-events'
import { persistAdmittedStablePaneBinding } from '../pane/stable-owner'
import { claimSshPaneLease } from '../pane/ssh-pane-lease-claim'
import {
isNativeWindowsLocalPtySpawn,
markNativeWindowsConptyPty
} from '../../../runtime/terminal-model-query-authority'
import { toSshExecutionHostId } from '../../../../shared/execution-host'
import { createTerminalSessionStateSaveFailureMessage } from '../../../../shared/terminal-session-state-save-failure'
import { clearProviderPtyState } from '../provider/state-cleanup'
import { resolvePaneSpawnReservation } from '../pane/spawn-reservation'
import { admitProviderReattachLaunchIdentity } from '../pane/launch-authority'
import type { RuntimePtySpawnState } from './spawn-state'
export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
const args = ctx.args
const providerReattachLaunchIdentity = admitProviderReattachLaunchIdentity(ctx.result)
try {
ctx.stablePaneBindingPersisted = persistAdmittedStablePaneBinding({
store: ctx.hostSessionBinding?.store,
owner: ctx.stablePaneOwner,
result: ctx.result,
worktreeId: ctx.hostSessionBinding?.worktreeId,
startupCwd: ctx.cwd,
connectionId: args.connectionId
})
} catch (error) {
if (error instanceof Error && error.message === 'terminal_pane_owner_changed') {
throw error
}
console.error('[pty] failed to persist runtime PTY binding after attach:', error)
throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), {
agentSessionOperationOutcome: 'unknown' as const
})
}
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)
if (ctx.result.incarnationId) {
ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId)
}
ctx.deps.runtime?.registerPty(
ctx.result.id,
owner.surface.worktreeId,
args.connectionId ?? null,
{
tabId: owner.surface.tabId,
leafId: owner.surface.leafId,
terminalHandle: owner.surface.terminalHandle,
...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}),
...(providerReattachLaunchIdentity ? { providerReattachLaunchIdentity } : {})
}
)
if (!args.connectionId) {
ctx.deps.options?.onCodexHomePtySpawned?.({
id: ctx.result.id,
codexHomePath: ctx.selectedCodexHomePath,
reattached: true,
startedAt: ctx.codexHomeLaunchStartedAt,
startedSequence: ctx.codexHomeLaunchStartedSequence,
...codexReattachedHomeRouteField(ctx.reattachedCodexHomeRoutes, ctx.result.id, true),
...(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,
adoptedResult
)
return {
id: ctx.result.id,
...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}),
agentSessionEnsure: ctx.result.agentSessionEnsure
}
}
ptyOwnership.set(ctx.result.id, args.connectionId ?? null)
if (ctx.result.incarnationId) {
ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId)
}
// Why: record the native-Windows-local-PTY determination before any byte reaches the emulator, so its ConPTY DA1 override exists from byte zero.
if (
isNativeWindowsLocalPtySpawn({
connectionId: args.connectionId,
cwd: args.cwd,
shellOverride: ctx.daemonShellOverride
})
) {
markNativeWindowsConptyPty(ctx.result.id)
}
const persistSshLease = (): void =>
claimSshPaneLease({
store: ctx.deps.store,
connectionId: args.connectionId,
ptyId: ctx.result.id,
worktreeId: args.worktreeId,
tabId: args.tabId,
leafId: args.leafId
})
if (!ctx.hostSessionBinding) {
persistSshLease()
}
commitRuntimePtySize(ctx, ctx.result)
if (ctx.effectiveSessionAppId !== undefined && ctx.effectiveSessionAppId !== ctx.result.id) {
ptySizes.delete(ctx.effectiveSessionAppId)
}
recordCodexPaneAccountForSpawn({
ptyId: ctx.result.id,
isDaemonHostSpawn: ctx.isDaemonHostSpawn,
isReattach: ctx.result.isReattach === true,
pinnedByResume: ctx.codexResumeHomeSelected,
launchCodexHomePath: ctx.selectedCodexHomePath,
launchEnv: args.env,
target: ctx.codexSelectionTarget,
settings: ctx.deps.getSettings?.()
})
if (ctx.hostSessionBinding && !ctx.stablePaneBindingPersisted) {
try {
const binding = {
worktreeId: ctx.hostSessionBinding.worktreeId,
tabId: ctx.hostSessionBinding.tabId,
leafId: ctx.hostSessionBinding.leafId,
ptyId: ctx.result.id,
hostAdmittedMembership: true,
...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}),
...(ctx.cwd ? { startupCwd: ctx.cwd } : {}),
...(ctx.hostSessionBinding.expectedSourceBinding
? { expectedSourceBinding: ctx.hostSessionBinding.expectedSourceBinding }
: {})
}
const persisted = args.connectionId
? ctx.hostSessionBinding.store.persistPtyBinding(
binding,
toSshExecutionHostId(args.connectionId)
)
: ctx.hostSessionBinding.store.persistPtyBinding(binding)
if (persisted === false) {
throw new Error('terminal_split_source_not_found')
}
} catch (err) {
console.error('[pty] failed to persist runtime PTY binding after spawn:', err)
if (!ctx.result.isReattach) {
deletePtyOwnership(ctx.result.id)
try {
await ctx.provider.shutdown(ctx.result.id, { immediate: true })
} catch (shutdownErr) {
console.warn('[pty] failed to clean up PTY after persistence failure:', shutdownErr)
}
clearProviderPtyState(ctx.result.id)
}
if (err instanceof Error && err.message === 'terminal_split_source_not_found') {
throw err
}
throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), {
agentSessionOperationOutcome: 'unknown' as const
})
}
persistSshLease()
}
if (args.preAllocatedHandle && !ctx.stablePaneOwner?.handle) {
ctx.deps.runtime?.registerPreAllocatedHandleForPty(ctx.result.id, args.preAllocatedHandle)
}
if (args.worktreeId) {
ctx.deps.runtime?.registerPty(
ctx.result.id,
args.worktreeId,
args.connectionId ?? null,
// Why: thread validated pane identity so main can back a pending mobile create even if graph-sync stalls (#7587).
typeof args.tabId === 'string' &&
isValidTerminalTabId(args.tabId) &&
args.tabId.length <= 512 &&
ctx.metadataLeafId !== null
? {
tabId: args.tabId,
leafId: ctx.metadataLeafId,
...(args.preAllocatedHandle ? { terminalHandle: args.preAllocatedHandle } : {}),
...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}),
...(providerReattachLaunchIdentity ? { providerReattachLaunchIdentity } : {})
}
: undefined,
!args.connectionId
? shouldSkipCodexHomeEnvForWindowsShell(ctx.daemonShellOverride, ctx.cwd)
: undefined
)
} else {
// Why: non-worktree PTYs have no later surface-registration phase to clear admission intent.
ctx.deps.runtime?.cancelPendingPtyRegistration?.(ctx.result.id, ctx.result.incarnationId)
}
// Why: runtime-controller creates (headless serve, CLI, splits) adopt surviving daemon sessions too; without this seed their records stay blank.
seedTerminalRestoreRecordsFromSpawnResult(ctx.deps.runtime, ctx.result)
// Why: arms main's per-PTY Command Code output detector from the launch command (renderer startupCommand parity).
if (!ctx.stablePaneOwner) {
ctx.deps.runtime?.noteTerminalSpawnCommand?.(ctx.result.id, ctx.launchCommand ?? null)
}
if (ctx.isClaudeLaunch && !ctx.stablePaneOwner) {
markClaudePtySpawned(ctx.result.id)
}
if (args.telemetry && !ctx.stablePaneOwner) {
const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind)
const launchSourceParse = launchSourceSchema.safeParse(args.telemetry.launch_source)
const requestKindParse = requestKindSchema.safeParse(args.telemetry.request_kind)
if (agentKindParse.success && launchSourceParse.success && requestKindParse.success) {
track('agent_started', {
agent_kind: agentKindParse.data,
launch_source: launchSourceParse.data,
request_kind: requestKindParse.data,
...getCohortAtEmit()
})
}
}
// Why: runtime-owned CLI PTYs bypass the renderer pty:spawn handler; record paneKey here too since hook titles and cache cleanup need this reverse lookup.
const paneKey = rememberPaneKeyForPty(ctx.result.id, ctx.env?.ORCA_PANE_KEY)
const pendingSerializer = paneKey ? pendingByPaneKey.get(paneKey) : undefined
const inheritRendererReadiness =
ctx.result.isReattach === true &&
!pendingSerializer &&
rendererSerializerReadiness.has(ctx.result.id)
rendererSerializerReadiness.beginIncarnation(ctx.result.id, inheritRendererReadiness)
if (paneKey && pendingSerializer) {
pendingPtyIdBySerializerGeneration.set(pendingSerializer.gen, ctx.result.id)
}
if (!args.connectionId) {
registerPty({
ptyId: ctx.result.id,
worktreeId: args.worktreeId ?? null,
sessionId: ctx.sessionId ?? null,
paneKey,
pid:
typeof ctx.result.pid === 'number' && Number.isFinite(ctx.result.pid) && ctx.result.pid > 0
? ctx.result.pid
: null
})
}
// Why: runtime-owned/background spawns bypass mounted-pane state, so inventory consumers need an explicit signal.
ctx.deps.sendPtySpawnedToRenderer(ctx.result.id)
if (!args.connectionId) {
ctx.deps.options?.onCodexHomePtySpawned?.({
id: ctx.result.id,
codexHomePath: ctx.selectedCodexHomePath,
startedAt: ctx.codexHomeLaunchStartedAt,
startedSequence: ctx.codexHomeLaunchStartedSequence,
...codexReattachedHomeRouteField(
ctx.reattachedCodexHomeRoutes,
ctx.result.id,
ctx.result.isReattach === true
),
...(ctx.result.isReattach === true
? { reattached: true }
: ctx.env
? { launchEnv: ctx.env }
: {})
})
}
const response = {
id: ctx.result.id,
...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}),
...(ctx.stablePaneOwner && (ctx.stablePaneOwner.handle || args.preAllocatedHandle)
? {
stablePaneOwner: {
handle: ctx.stablePaneOwner.handle ?? args.preAllocatedHandle!,
tabId: ctx.stablePaneOwner.tabId,
leafId: ctx.stablePaneOwner.leafId
}
}
: {}),
...(ctx.result.agentSessionEnsure ? { agentSessionEnsure: ctx.result.agentSessionEnsure } : {})
}
resolvePaneSpawnReservation(ctx.paneSpawnReservationKey, ctx.paneSpawnReservation, {
...ctx.result,
...(typeof ctx.result.snapshotKittyKeyboardFlags === 'number' &&
ctx.reconciledSnapshotSeq !== null &&
ctx.snapshotKittyFlagsCoverReconciledSeq
? { snapshotSeq: ctx.reconciledSnapshotSeq }
: { snapshotKittyKeyboardFlags: undefined }),
isReattach: true
})
return response
}