Files
orca/src/main/ipc/pty/ipc/spawn-commit.ts
T
Neil 469ae09c97 fix(agents): a resumed agent's chat is readable on the phone before its first new turn (#29664)
* fix(agents): a resumed agent's chat is readable on the phone before its first new turn (#10923)

* fix(agents): skip a dropped Codex resume and keep the seeded identity across restart

* fix(agents): seed resume identity before the spawn's first await and keep it through terminal evidence

* fix(agents): seed after the spawn commits, and name the session on host-built resumes
2026-10-16 05:39:25 -07:00

239 lines
11 KiB
TypeScript

import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id'
import { agentHookServer } from '../../../agent-hooks/server'
import { registerPty } from '../../../memory/pty-registry'
import type { PtySpawnResult } from '../../../providers/pty-provider-contract'
import { clearMigrationUnsupportedPtysForPaneKey } from '../../../agent-hooks/migration-unsupported-pty-state'
import { recordPtySpawnTelemetry } from '../pane/spawn-telemetry'
import {
shouldSkipCodexHomeEnvForWindowsShell,
codexReattachedHomeRouteField
} from '../host-env/codex-home'
import { rememberPaneKeyForPty } from '../pane/key-state'
import { resolvePaneSpawnReservation } from '../pane/spawn-reservation'
import { seedTerminalRestoreRecordsFromSpawnResult } from '../pane/agent-session-owners'
import { seedResumeLaunchIdentityFromSpawn } from '../pane/resume-launch-identity'
import {
admitProviderReattachLaunchIdentity,
admitRendererAgentLaunchAuthority
} from '../pane/launch-authority'
import type { PtyIpcSpawnState } from './spawn-state'
import { persistPtyIpcSpawnCommit, publishPtyIpcSpawnCommit } from './spawn-commit-persist'
import { admitPtyReattachOwnership, registerPersistedPtySpawn } from '../pane/spawn-registration'
import { reflowHeadlessTerminalToCommittedGrid } from '../delivery/attached-pty-size'
import { seedHeadlessTerminalFromSpawnResult } from '../pane/terminal-spawn-restore'
import { markNativeWindowsConptyPty } from '../../../runtime/terminal-model-query-authority'
import { commitPtyWithOpenCodePromptIntent } from '../../../opencode/opencode-startup-prompt-owner'
export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawnResult> {
return commitPtyWithOpenCodePromptIntent(ctx, () => commitReservedPtyIpcSpawn(ctx))
}
async function commitReservedPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawnResult> {
const args = ctx.args
admitPtyReattachOwnership(ctx.deps.runtime, ctx.result)
if (ctx.nativeWindowsConptySpawn) {
markNativeWindowsConptyPty(ctx.result.id)
}
// Seed before the first disk await so live output appends to the restored history.
seedHeadlessTerminalFromSpawnResult(ctx.deps.runtime, ctx.result, ctx.validatedPaneKey)
seedTerminalRestoreRecordsFromSpawnResult(ctx.deps.runtime, ctx.result)
const committedSize = await persistPtyIpcSpawnCommit(ctx)
if (
typeof args.worktreeId === 'string' &&
args.worktreeId.length > 0 &&
args.worktreeId.length <= 512
) {
const agentLaunchAuthority = admitRendererAgentLaunchAuthority({
launchToken: args.launchToken,
spawnEnv: ctx.spawnEnv,
launchAgent: args.launchAgent,
launchConfig: ctx.effectiveLaunchConfig,
isReattach: ctx.result.isReattach === true,
hasStablePaneOwner: ctx.stablePaneOwner !== null,
incarnationId: ctx.result.incarnationId
})
const providerReattachLaunchIdentity = admitProviderReattachLaunchIdentity({
isReattach: ctx.result.isReattach === true,
launchAgent: ctx.result.launchAgent,
incarnationId: ctx.result.incarnationId
})
const rejectedRegistration = registerPersistedPtySpawn(
ctx.deps.runtime,
ctx.deps.store,
ctx.result.id,
args.worktreeId,
null,
// Why: pass validated pane identity so a throttled mobile create publishes its surface main-side instead of destroying the live PTY (#7587); bound the untrusted tabId.
typeof args.tabId === 'string' &&
isValidTerminalTabId(args.tabId) &&
args.tabId.length <= 512 &&
ctx.metadataLeafId !== null
? {
tabId: args.tabId,
leafId: ctx.metadataLeafId,
...(ctx.preAllocatedHandle ? { terminalHandle: ctx.preAllocatedHandle } : {}),
...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}),
...(agentLaunchAuthority ? { agentLaunchAuthority } : {}),
...(providerReattachLaunchIdentity ? { providerReattachLaunchIdentity } : {})
}
: undefined,
shouldSkipCodexHomeEnvForWindowsShell(ctx.effectiveShellOverride, ctx.cwd)
)
if (rejectedRegistration) {
await rejectedRegistration.catch((error: unknown) => {
if (!(error instanceof Error) || error.message !== 'agent_session_exited_during_start') {
throw error
}
})
ctx.deps.runtime?.cancelPendingPtyRegistration?.(ctx.result.id, ctx.result.incarnationId)
ctx.pendingRegistrationPtyId = null
ctx.exitedBeforeRegistration = true
// The renderer drains this incarnation's buffered output and exit without publishing it live.
return resolvePaneSpawnReservation(
ctx.paneSpawnReservationKey,
ctx.paneSpawnReservation,
ctx.result
)
}
ctx.pendingRegistrationPtyId = null
} else if (ctx.pendingRegistrationPtyId) {
ctx.deps.runtime?.cancelPendingPtyRegistration?.(
ctx.pendingRegistrationPtyId,
ctx.result.incarnationId
)
ctx.pendingRegistrationPtyId = null
}
publishPtyIpcSpawnCommit(ctx, committedSize)
// Admission must precede reflow: a replaced spawn cannot resize its successor's model.
reflowHeadlessTerminalToCommittedGrid({
result: ctx.result,
committedSize,
reflowHeadlessTerminalToPtyGrid: ctx.deps.runtime?.reflowHeadlessTerminalToPtyGrid?.bind(
ctx.deps.runtime
)
})
// Why: seed after registerPty binds the worktree — including on
// desktop, where the renderer-authority gate above skips the emulator
// seed but the list/read records still live main-side.
seedTerminalRestoreRecordsFromSpawnResult(ctx.deps.runtime, ctx.result)
// Why: arm main's per-PTY Command Code output detector from the launch command (startupCommand parity); banner detection covers PTYs without one.
if (!ctx.stablePaneOwner) {
ctx.deps.runtime?.noteTerminalSpawnCommand?.(
ctx.result.id,
typeof ctx.launchCommand === 'string' ? ctx.launchCommand : null
)
}
// Why: record the paneKey mapping so clearProviderPtyState can clear the agent-hooks server's per-paneKey caches on exit.
// Why: args.env is untrusted IPC JSON (type unenforced); bound the paneKey so malformed/oversized values can't pollute ptyPaneKey or clearPaneState.
const rememberedPaneKey = ctx.validatedPaneKey
? rememberPaneKeyForPty(ctx.result.id, ctx.validatedPaneKey)
: null
if (ctx.legacySpawnPaneKey && ctx.migrationUnsupportedPaneKey) {
agentHookServer.registerPaneKeyAlias(
ctx.legacySpawnPaneKey.paneKey,
ctx.migrationUnsupportedPaneKey,
ctx.result.id,
Date.now(),
{ authorityVerified: true }
)
clearMigrationUnsupportedPtysForPaneKey(ctx.migrationUnsupportedPaneKey)
} else if (ctx.opaqueRemintedSpawnPaneKey && ctx.validatedPaneKey) {
// Reminted $$ tokens are spawn-physical; alias them to the metadata-proven tab:leaf key.
agentHookServer.registerPaneKeyAlias(
ctx.opaqueRemintedSpawnPaneKey,
ctx.validatedPaneKey,
ctx.result.id,
Date.now(),
{ authorityVerified: true }
)
} else if (ctx.validatedPaneKey) {
if (!ctx.result.isReattach) {
clearMigrationUnsupportedPtysForPaneKey(ctx.validatedPaneKey)
}
}
// Why after commit: a spawn rejected above must not leave a published identity behind.
seedResumeLaunchIdentityFromSpawn({
paneKey: ctx.validatedPaneKey,
launchAgent: args.launchAgent,
resumeProviderSession: args.resumeProviderSession,
connectionId: args.connectionId,
worktreeId: args.worktreeId,
launchedAt: ctx.codexHomeLaunchStartedAt,
resumeArgvDropped: ctx.codexResumeLaunch.droppedResumeArgv,
result: ctx.result
})
// Why: record the spawn-result pid once here so the memory module needn't reach back into ipc/pty on a hot path (works for in-process and daemon-hosted PTYs).
const spawnedPid = ctx.result.pid ?? null
// Why: args.worktreeId/sessionId arrive as untrusted IPC strings (type unenforced at the boundary); bound them so malformed/oversized values can't pollute registerPty's maps.
registerPty({
ptyId: ctx.result.id,
worktreeId:
typeof args.worktreeId === 'string' &&
args.worktreeId.length > 0 &&
args.worktreeId.length <= 512
? args.worktreeId
: null,
sessionId:
typeof args.sessionId === 'string' &&
args.sessionId.length > 0 &&
args.sessionId.length <= 256
? args.sessionId
: null,
paneKey: rememberedPaneKey,
pid:
typeof spawnedPid === 'number' && Number.isFinite(spawnedPid) && spawnedPid > 0
? spawnedPid
: null
})
// Why: telemetry-plan.md§Agent launch semantics — fire agent_started only after spawn resolved; safeParse each field so a spoofed IPC payload can't poison the event (missing required field skips it).
if (args.telemetry && !ctx.stablePaneOwner) {
recordPtySpawnTelemetry(args.telemetry)
}
const response = {
...ctx.result,
// Why both or neither: a pane can only adopt proven kitty flags together
// with the sequence boundary they describe.
...(typeof ctx.result.snapshotKittyKeyboardFlags === 'number' &&
ctx.reconciledSnapshotSeq !== null &&
ctx.snapshotKittyFlagsCoverReconciledSeq
? { snapshotSeq: ctx.reconciledSnapshotSeq }
: { snapshotKittyKeyboardFlags: undefined }),
...(!ctx.result.isReattach && ctx.effectiveLaunchConfig
? { launchConfig: ctx.effectiveLaunchConfig }
: {}),
// Why: a daemon-retry race can surface isReattach even for a minted session id, and a reattach must never claim its cwd was remapped.
...(ctx.startupCwdFallback && !ctx.result.isReattach
? { startupCwdFallback: ctx.startupCwdFallback }
: {}),
// Why: the pane asked to resume and got a fresh session instead; only the
// renderer can say so, and a reattach never ran this launch command.
...(ctx.codexResumeLaunch.notifyResumeUnavailable && !ctx.result.isReattach
? { agentResumeUnavailable: true as const }
: {})
}
// Why: renderer tab state cannot reliably infer background and reattached PTYs in the daemon inventory.
ctx.deps.sendPtySpawnedToRenderer(ctx.result.id)
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.baseEnv
? { launchEnv: ctx.baseEnv }
: {})
})
return resolvePaneSpawnReservation(
ctx.paneSpawnReservationKey,
ctx.paneSpawnReservation,
response
)
}