Files
orca/src/shared/runtime-terminal-contracts.ts
T
Brennan Benson 8174208436 fix(terminal): every start in a deleted folder opens at the workspace root and says so (#30747)
* fix(terminal): start every terminal in the workspace root when its saved folder is gone, and say so

A terminal whose start folder was deleted opened at the workspace root only
when the window started it; the CLI, phone restore, orchestration and other
host starts failed with the raw "Working directory ... does not exist" error,
because the renderer had to opt in with a cwdFallback flag.

The execution host now makes that decision for every start that may create a
process (anything but a known live pane), with no caller flag. The fallback is
reported only when a process was proven started at the requested folder, so a
reattach or a crash restore in its recovered folder never claims "workspace
root", and only then does the saved start folder heal. A recovered folder that
was itself deleted yields to the request's resolved folder.

The notice reaches each caller through surfaces it already has: the window's
terminal line (now also written when a host-created tab attaches, and on a
restored reattach), the agent.launch warning (also for callers that show the
tab themselves), and a toast when a phone tap restores the tab.

* fix: recover terminal startup folders without healing unreadable paths

* fix: preserve saved start folder when revealing fallback terminals

* Reuse saved-tab lookup in cwd persistence test

* Import mobile tab activation result from its contract

* Check startup fallback response without a test cast

* Update expectation to catch up with accepted saved-folder metadata

* Preserve inconclusive WSL directory evidence when saving startup folders

* fix: project restored terminal ids for handle consumers

* test: return void from launch parity cleanup

* test: retain saved folder in shared spawn expectation
2026-10-21 10:21:36 -07:00

370 lines
11 KiB
TypeScript

import type * as AgentSessionResume from './agent-session-resume'
import type { StartupCommandDelivery } from './codex-startup-delivery'
import type { ExecutionHostId } from './execution-host'
import type { PtyIncarnationId } from './pty-incarnation'
import type { PtyLivenessVerdict } from './pty-liveness-verdict'
import type { RuntimeListingHostScope } from './runtime-listing-host-scope'
import type { RuntimeMobileSessionTabsResult } from './runtime-session-contracts'
import type { StartupCwdFallback } from './startup-cwd-fallback'
import type { TabGroupLayoutNode } from './tab-types'
import type { TerminalExitCause } from './terminal-exit-cause'
import type { TerminalPaneLayoutNode } from './terminal-tab-types'
import type { TerminalAgent, TuiAgent } from './terminal-agent'
export type {
RuntimeTerminalSend,
RuntimeTerminalPromptStage,
RuntimeTerminalPromptDelivery
} from './runtime-terminal-send-contract'
export type RuntimeTerminalSummary = {
handle: string
ptyId: string | null
incarnationId?: string | null
orphaned?: boolean
/**
* Orphaned only: the pane the host last recorded for this PTY, which the renderer owning it can
* still hold even when its graph omitted that pane. Absent when none was recorded or the host
* predates the field.
*/
recordedPaneKey?: string
worktreeId: string
worktreePath: string
branch: string
tabId: string
leafId: string
title: string | null
/** A user's name for this pane from the host's saved layout; absent when unset or the host predates it. */
paneTitle?: string
connected: boolean
writable: boolean
lastOutputAt: number | null
preview: string
/** Host-resolved observed agent identity; absent when unknown. Does not imply launch support. */
agentIdentity?: TerminalAgent
/** Absent while running or when the host predates the field; never infer a clean finish. */
exitCause?: TerminalExitCause
/** Absent when the host predates the field or could not name the execution host. */
executionHostId?: ExecutionHostId
/**
* The execution host's word on the pane's terminal. Read this, not `connected`: `connected`
* keeps its old meaning, so an unreachable host reads connected. Absent from older hosts.
*/
verdict?: PtyLivenessVerdict['status']
}
export type RuntimeTerminalVisualTerminalNode = {
type: 'terminal'
handle: string
tabId: string
leafId: string
title: string | null
connected: boolean
active: boolean
}
export type RuntimeTerminalVisualPaneNode =
| RuntimeTerminalVisualTerminalNode
| {
type: 'pane-split'
direction: Extract<TerminalPaneLayoutNode, { type: 'split' }>['direction']
first: RuntimeTerminalVisualPaneNode
second: RuntimeTerminalVisualPaneNode
}
export type RuntimeTerminalVisualTab = {
tabId: string
title: string | null
activeLeafId: string | null
panes: RuntimeTerminalVisualPaneNode
}
export type RuntimeTerminalVisualGroupNode = {
type: 'group'
groupId: string | null
activeTabId: string | null
tabs: RuntimeTerminalVisualTab[]
}
export type RuntimeTerminalVisualLayoutNode =
| RuntimeTerminalVisualGroupNode
| {
type: 'split'
direction: Extract<TabGroupLayoutNode, { type: 'split' }>['direction']
first: RuntimeTerminalVisualLayoutNode
second: RuntimeTerminalVisualLayoutNode
}
export type RuntimeTerminalVisualLayout = {
worktreeId: string
worktreePath: string
root: RuntimeTerminalVisualLayoutNode
}
/** The shared listing-scope shape, kept under its incumbent name for existing consumers. */
export type RuntimeTerminalListHostScope = RuntimeListingHostScope
export type RuntimeTerminalListResult = {
terminals: RuntimeTerminalSummary[]
visualLayouts?: RuntimeTerminalVisualLayout[]
topologyRevisions?: Record<string, number>
totalCount: number
truncated: boolean
/** Absent from hosts that predate the field; treat that scope as unverifiable. */
hostScope?: RuntimeTerminalListHostScope
}
export type RuntimeTerminalOrphanAdoptionClaim = {
terminal: string
ptyId: string
incarnationId: PtyIncarnationId
tabId: string
leafId: string
}
export type RuntimeTerminalOrphanTopologyTab = {
tabId: string
root: TerminalPaneLayoutNode
activeLeafId: string
expandedLeafId: string | null
}
export type RuntimeTerminalOrphanTopologyGroup = {
id: string
activeTabId: string
tabOrder: string[]
recentTabIds?: string[]
}
export type RuntimeTerminalOrphanTopology = {
tabs: RuntimeTerminalOrphanTopologyTab[]
groups: RuntimeTerminalOrphanTopologyGroup[]
groupLayout?: TabGroupLayoutNode
}
export type RuntimeTerminalOrphanAdoptionRequest = {
worktree: string
expectedTopologyRevision: number
claims: RuntimeTerminalOrphanAdoptionClaim[]
activeTabId?: string
activeGroupId?: string
topology?: RuntimeTerminalOrphanTopology
}
export type RuntimeTerminalOrphanAdoptionResult = {
adopted: boolean
topologyRevision: number
snapshot: RuntimeMobileSessionTabsResult
}
export type RuntimeWorktreeTerminalSleepResult = {
stopped: number
stoppedPtyIds: string[]
livePtyIds: string[]
} & (
| { postStopVerified: true; postStopFailure?: never; remainingLivePtyIds?: never }
| {
postStopVerified: false
postStopFailure: 'terminal_liveness_unavailable'
remainingLivePtyIds?: never
}
| {
postStopVerified: false
postStopFailure: 'terminal_worktree_sleep_still_live'
remainingLivePtyIds: string[]
}
)
export type RuntimeWorktreeTerminalCloseResult = {
closed: number
stopped: number
retiredSurfaces: true
ptyStopVerdict?: 'live' | 'unverifiable'
ptyStopReason?: string
}
export type RuntimeTerminalInteractiveWaitSource = 'hook' | 'prompt-text' | 'title'
export type RuntimeTerminalInteractiveWait = {
source: RuntimeTerminalInteractiveWaitSource
reason?: RuntimeTerminalWaitBlockedReason
since?: number
}
export type RuntimeTerminalShow = RuntimeTerminalSummary & {
paneRuntimeId: number
ptyId: string | null
rendererGraphEpoch: number
/** Null means evaluated with no wait; absent means not evaluated. */
agentWait?: RuntimeTerminalInteractiveWait | null
}
export type RuntimeTerminalState = 'running' | 'exited' | 'unknown'
export type RuntimeTerminalRowMetadata = {
wrapped: boolean
trailingSpaces: number
}
export type RuntimeTerminalRead = {
handle: string
status: RuntimeTerminalState
tail: string[]
/** Execution-host wrap evidence, aligned with tail; absent on older peers. */
tailRows?: RuntimeTerminalRowMetadata[]
truncated: boolean
limited?: boolean
oldestCursor?: string
nextCursor: string | null
latestCursor?: string
returnedLineCount?: number
source?: 'stream' | 'screen' | 'screen-unavailable'
/** UI-only composer text, excluded from `tail`. */
draft?: string
}
export type RuntimeTerminalRename = {
handle: string
tabId: string
title: string | null
}
export type RuntimeTerminalSetPaneTitle = {
handle: string
tabId: string
leafId: string
title: string | null
}
export type RuntimeTerminalAgentStatusState = 'working' | 'permission' | 'idle' | null
export type RuntimeTerminalAgentStatus = {
handle: string
isRunningAgent: boolean
status: RuntimeTerminalAgentStatusState
}
export type RuntimeTerminalPresentation = 'background' | 'focused'
type RuntimeTerminalCreateBaseRequestPayload = {
requestId: string
worktreeId?: string
afterTabId?: string
targetGroupId?: string
command?: string
cwd?: string
env?: Record<string, string>
envToDelete?: string[]
launchConfig?: AgentSessionResume.SleepingAgentLaunchConfig
resumeProviderSession?: AgentSessionResume.AgentProviderSessionMetadata
launchToken?: string
launchAgent?: TuiAgent
viewMode?: 'terminal' | 'chat'
startupCommandDelivery?: StartupCommandDelivery
title?: string
activate?: boolean
presentation?: RuntimeTerminalPresentation
surfaceOwner?: false
/** Windows shell the created tab spawns AS, instead of the host default. */
shellOverride?: string
}
export type RuntimeTerminalCreateRequestPayload =
| (RuntimeTerminalCreateBaseRequestPayload & { source?: undefined })
| (RuntimeTerminalCreateBaseRequestPayload & {
worktreeId: string
source: 'runtime-session'
})
export type RuntimeTerminalCreate = {
handle: string
/** Host-owned PTY incarnation used to fence remote identity observations. */
incarnationId?: string | null
tabId?: string
paneKey?: string | null
ptyId?: string | null
worktreeId: string
title: string | null
executionHostId?: ExecutionHostId
hostPlatform?: NodeJS.Platform
surface?: 'background' | 'visible'
warning?: string
agentSessionDisposition?: 'created' | 'adopted'
isReattach?: true
/** Spawn process identity for host-internal ownership proof. */
processId?: number
/** Host-internal: the requested folder was gone, so the process started at the workspace root. */
startupCwdFallback?: StartupCwdFallback
/** Host-internal saved-folder decision; excluded by RPC result projection. */
savedStartupCwd?: string
}
export type RuntimeTerminalSplit = {
handle: string
tabId: string
paneRuntimeId: number
// Why: paired callers need the host-created leaf identity to focus the exact pane.
leafId?: string
}
export type RuntimeTerminalResolvePane = {
handle: string
/** Host-owned PTY incarnation used to fence remote identity observations. */
incarnationId?: string | null
tabId: string
leafId: string
ptyId: string | null
connected?: boolean
worktreeId?: string
executionHostId?: ExecutionHostId
hostPlatform?: NodeJS.Platform
}
export type RuntimeTerminalFocus = {
handle: string
tabId: string
worktreeId: string
navigated?: boolean
}
export type RuntimeTerminalClose = {
handle: string
tabId: string
closeMode?: 'tab'
ptyKilled: boolean
ptyStopVerdict?: 'live' | 'unverifiable'
ptyStopReason?: string
/** The host durably recorded a kill order it replays when the PTY's SSH host reconnects.
* Older hosts never send it, so a client promises no retry without it. */
pendingKillRecorded?: true
}
export type RuntimeTerminalWaitCondition = 'exit' | 'tui-idle'
// Why both spellings: the codex-* members were published by every host before the agent-neutral
// rename, so they are permanent — a client still has to read them off an older host. This build
// keeps a codex-* reason only where the matched wording is plausibly Codex's own; every matcher
// that inspects no agent publishes the agent-* spelling.
export type RuntimeTerminalWaitBlockedReason =
| 'codex-update-prompt'
| 'codex-trust-workspace'
| 'codex-cwd-prompt'
| 'codex-model-migration-prompt'
| 'codex-hooks-review-prompt'
| 'codex-interactive-prompt'
| 'agent-update-prompt'
| 'agent-trust-workspace'
| 'agent-cwd-prompt'
| 'agent-hooks-review-prompt'
| 'agent-interactive-prompt'
| 'agent-approval-prompt'
export type RuntimeTerminalWait = {
handle: string
condition: RuntimeTerminalWaitCondition
satisfied: boolean
status: RuntimeTerminalState
exitCode: number | null
exitCause?: TerminalExitCause
blockedReason?: RuntimeTerminalWaitBlockedReason
}