mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 01:57:57 +00:00
* 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
239 lines
11 KiB
TypeScript
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
|
|
)
|
|
}
|