Files
orca/src/shared/runtime-terminal-contracts.ts
T
b3e0a33fa4 fix(runtime): agent-neutral wait-blocked reasons (#19749)
* fix(runtime): agent-neutral wait-blocked reasons and non-Gemini Antigravity readiness

Reported by a user via the in-app help menu (report "not captured", 1.4.198).

The trust/interactive/update/cwd prompt matchers are agent-agnostic - they
match on dialog wording and never inspect the pane's agent - yet emitted
hardcoded codex-* reasons. Those reached users verbatim in worker receipts
(local-worker-start, federation), two automation surfaces, and raw CLI
output, so an Antigravity user was told they had a Codex problem.

findAntigravityReadyPromptIndex also required the model line to start with
the literal "gemini". Antigravity CLI is not Gemini-only, so a non-Gemini
session never registered as ready, stale trust text was never superseded,
and the pane stayed blocked - which is why dispatch --inject answered
agent_prompt_blocked.

Add agent-neutral reasons additively (codex-* members kept on the wire per
docs/reference/remote-wire-compatibility.md, with a legacy alias for older
hosts) and decide Antigravity readiness structurally: header, then
model/account rows, then the prompt caret.

codex-model-migration-prompt and codex-hooks-review-prompt stay Codex-named
- both key on Codex's own wording.

* fix(runtime): finish the agent-neutral rename, revert the Antigravity readiness rewrite

Review follow-up on this branch. Splits the two halves of the original commit:
the reason rename lands, the Antigravity readiness detector goes back to
merge-base until someone captures a real transcript.

Rename half:

- 'hooks need review' + 'press enter to confirm' inspects no agent, so it now
  publishes agent-hooks-review-prompt. That was the last agent-agnostic codex-*
  emission left, and it is the one the original report was about: a Claude Code
  user hitting a hooks dialog still read "codex-hooks-review-prompt".
- The legacy alias is applied at all three surfaces that render a raw reason,
  not just the CLI. describeTerminalWaitBlockedReason() is the single formatter;
  the worker and federation "Agent startup blocked:" receipts use it too. Kept
  one-directional: nothing consumes agent-* -> codex-*, since an old client
  renders with its own shipped code.
- Restores the compat note deleted at the permission-choices site. The Rule 1
  citation is correct - remote-wire-compatibility.md names this enum by name.

Antigravity half, reverted:

findAntigravityReadyPromptIndex goes back to merge-base (header + a 'gemini'
model line + a lone '>' caret) and antigravity-ready-prompt-index.ts is removed.

Executing both builds against constructed tails, the rewrite read a live
startup dialog as ready. Adding the account row from this repo's own ready-screen
fixture to five silent startup dialogs (sign-in, model picker, theme picker,
privacy notice, update banner) flipped all five from unready to ready; so did any
narration line containing an email address, with no account row at all. Readiness
is what gates typing the task prompt into the pane, so that path types a task
prompt into a live authentication dialog. Merge-base returns unready for all ten.

The rewrite also did not reliably fix the wedge it targeted: with no account row
and a non-Gemini model - a personal or API-key user - it still returns unready.

No real Antigravity transcript exists in this repo. The cursor-agent rules are
derived from captures under src/main/runtime/__fixtures__; Antigravity has no
equivalent, and every attempt so far has been tuned against a hand-written
5-line fixture. A false negative (the agent waits) is safer than a false positive
(we type into an auth dialog), so this ships the known behaviour.

Reverting restores a pre-existing gap, not a regression: a non-Gemini Antigravity
session wedges on merge-base too. Closing it needs a captured ready screen and a
captured dismissed-dialog screen, for a personal/API-key account as well as a
Business one.

Tests:

- Ten ratchet fixtures pin the shapes any replacement detector must refuse - the
  five silent dialogs with an account row, and each with a narrated email. All
  ten fail against the reverted rewrite.
- Vacuous tests rewritten so they fail without the code they cover: the CLI
  alias tests asserted only the absence of a suffix, and the worker receipt test
  asserted the raw token. Tests that are characterization rather than a guard
  now say so on the line above.

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: Neil <neil@stably.ai>
2026-09-10 23:44:23 -07:00

360 lines
10 KiB
TypeScript

import type { AgentSessionPtyWriteRefusal } from './agent-session-pty-write-admission'
import type {
AgentProviderSessionMetadata,
SleepingAgentLaunchConfig
} 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 { RuntimeListingHostScope } from './runtime-listing-host-scope'
import type { RuntimeMobileSessionTabsResult } from './runtime-session-contracts'
import type { TabGroupLayoutNode } from './tab-types'
import type { TerminalExitCause } from './terminal-exit-cause'
import type { TerminalPaneLayoutNode } from './terminal-tab-types'
import type { TuiAgent } from './tui-agent'
export type RuntimeTerminalSummary = {
handle: string
ptyId: string | null
incarnationId?: string | null
orphaned?: boolean
worktreeId: string
worktreePath: string
branch: string
tabId: string
leafId: string
title: string | null
connected: boolean
writable: boolean
lastOutputAt: number | null
preview: string
/** Host-resolved agent identity for action consumers; absent when unknown or unsupported. */
agentIdentity?: TuiAgent
/** 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
}
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 RuntimeTerminalRead = {
handle: string
status: RuntimeTerminalState
tail: string[]
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 RuntimeTerminalSend = {
handle: string
accepted: boolean
bytesWritten: number
refusedReason?: 'no-agent' | 'permission'
/**
* Present only when a durable agent-session lease refused the write. Additive and optional: an
* old client sees the `accepted: false` it already handles and ignores this field.
*/
agentSessionRefusal?: AgentSessionPtyWriteRefusal
prompt?: RuntimeTerminalPromptDelivery
}
export type RuntimeTerminalPromptStage = 'input_accepted' | 'turn_started'
export type RuntimeTerminalPromptDelivery = {
requestId: string
stages: RuntimeTerminalPromptStage[]
provider: 'claude' | 'codex' | 'unsupported' | 'old-host'
observation: 'supported' | 'unsupported' | 'incarnation_replaced' | 'permission'
processIncarnation: string
generation: number
baselineWorkingSequence: number
/** Hook turn-start timestamp before this prompt was accepted. */
baselineExplicitWorkingStartedAt?: number | null
/** Permission observations seen before this prompt was accepted. */
baselinePermissionSequence?: number
}
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?: SleepingAgentLaunchConfig
resumeProviderSession?: AgentProviderSessionMetadata
launchToken?: string
launchAgent?: TuiAgent
viewMode?: 'terminal' | 'chat'
startupCommandDelivery?: StartupCommandDelivery
title?: string
activate?: boolean
presentation?: RuntimeTerminalPresentation
surfaceOwner?: false
}
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
}
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
}
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
}