Files
orca/src/preload/api/pty-api.ts
T
Brennan Benson a0de43aa1f AI-button and OpenCode-model tabs start their agent in their own terminal (#28210)
* fix(agent-launch): an AI button plans its agent inside its own pane's spawn, with the host's planner and no launch record

The window queues the launch on the tab; the pane's reserved spawn plans it with the planner every
host launch uses and starts it at the pane's size, with its launch token. Every way the spawn ends
finishes the launch, and the window pastes the prompt only once this pane says the agent started.
An ordinary spawn reaches its provider without waiting on a plan. An OpenCode tab with a model
keeps main's host hop untouched (TEMPORARY, until a local producer of model options exists).

* fix(agent-launch): an AI button's prompt waits for its pane to start before the spawn budget runs, so a pane that settles slowly still gets its prompt; stale comments

* test(agent-launch): the late pane-start test's store double is typed without an assertion

* test(agent-launch): the late pane-start test pins the prompt written once and Enter once, and nothing when the pane didn't start

* fix(agent-launch): an AI button's prompt goes only to the PTY its pane started, never a split's shell that bound first; the pane's start answer names that PTY

* fix(agent-launch): an AI button's launch watches each split's readiness as it binds, so the launch pane's own ready output is seen even when a split bound first and it drained at once

* fix(agent-launch): an AI button's pane plans with the host's current planner and runs its runtime fact's shell, so its command is quoted for the shell it is typed into, as a host launch is; the tab's inferred shell is not sent as a request
2026-10-13 22:40:59 -07:00

288 lines
13 KiB
TypeScript

import type { SpawnIntent } from '../../shared/host-spawn-admission'
import type {
AgentProviderSessionMetadata,
SleepingAgentLaunchConfig
} from '../../shared/agent-session-resume'
import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery'
import type { TerminalInputKind } from '../../shared/terminal-input-kind'
import type {
TerminalLeafMoveRequest,
TerminalLeafMoveResult
} from '../../shared/terminal-leaf-move'
import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime'
import type { PtyListedSession, PtySessionListScope } from '../../shared/pty-listed-session'
import type { PtyMainDeliveryDiagnostics } from '../../shared/pty-delivery-diagnostics'
import type { PtyModelRestoreNeededEvent } from '../../shared/pty-model-restore-marker'
import type {
PtyRendererDeliveryHealthReply,
PtyRendererDeliveryStateReport
} from '../../shared/pty-renderer-delivery-health'
import type { AgentKind, LaunchSource, RequestKind } from '../../shared/telemetry-events'
import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts'
import type { TerminalViewAttributes } from '../../shared/terminal-view-attributes'
import type { TuiAgent } from '../../shared/tui-agent'
import type { TerminalPanePlacement } from '../../shared/terminal-pane-placement'
import type { AgentPaneLaunchRequest } from '../../shared/agent-pane-launch-request'
import type { PtyManagementApi } from './pty-management-api'
import type { TerminalProcessInspection } from '../../shared/terminal-process-inspection'
import type { CodexSharedServerStatus } from '../../shared/codex-shared-server-command'
import type { NewerDaemonHeldScreen, NewerDaemonReopenResult } from '../../shared/newer-daemon-hold'
export type PtyApi = {
spawn: (opts: {
cols: number
rows: number
cwd?: string
cwdFallback?: 'worktree'
env?: Record<string, string>
envToDelete?: string[]
command?: string
commandDelivery?: 'renderer' | 'provider'
launchConfig?: SleepingAgentLaunchConfig
resumeProviderSession?: AgentProviderSessionMetadata
launchToken?: string
launchAgent?: TuiAgent
startupCommandDelivery?: StartupCommandDelivery
connectionId?: string | null
worktreeId?: string
sessionId?: string
// Why: lets a single tab open in a different shell than the user's default.
shellOverride?: string
projectRuntime?: ProjectExecutionRuntimeResolution
terminalKittyKeyboardProtocol?: boolean
terminalColorQueryReplies?: { foreground?: string; background?: string }
// Why: mark the PTY hidden before its first byte so the delivery gate owns spawn-time queries (terminal-query-authority.md §races).
initiallyHidden?: boolean
// Why: main sync-flushes the (worktreeId,tabId,leafId→ptyId) binding before pty:spawn returns to close a SIGKILL race (INVESTIGATION.md).
tabId?: string
leafId?: string
// Why: a pane with a live owner is otherwise reattached; a restart names the PTY main must stop first.
replacesPtyId?: string
// Which tab and leaf a fresh PTY joins; older mains ignore it.
placement?: TerminalPanePlacement
// Visible restores are `user-resume`, hidden ones `automatic-restore`; the fork gate validates.
spawnIntent?: SpawnIntent
// The agent launch this pane's spawn starts; main admits and plans it.
agentLaunch?: AgentPaneLaunchRequest
// Why: main fires `agent_started` only on spawn success, so launch metadata rides this field (telemetry-plan.md §Agent launch semantics).
telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind }
}) => Promise<{
id: string
/** Which lifetime of `id` this reply named; absent when the execution host predates the field. */
incarnationId?: string
launchAgent?: TuiAgent
launchConfig?: SleepingAgentLaunchConfig
snapshot?: string
snapshotCols?: number
snapshotRows?: number
snapshotPrefixAnsi?: string
snapshotFrameAnsi?: string
snapshotFrameRestoreAnsi?: string
snapshotKittyKeyboardFlags?: number
snapshotTerminalOwner?: 'shell'
snapshotSeq?: number
isReattach?: boolean
isAlternateScreen?: boolean
replay?: string
sessionExpired?: boolean
coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number }
startupCwdFallback?: { kind: 'worktree'; cwd: string }
agentResumeUnavailable?: true
/** Host verdict on the shell-ready marker; absent when the execution host predates the field. */
shellReadyArmed?: boolean
}>
write: (id: string, data: string, inputKind: TerminalInputKind) => void
/** `requireWriteSettlement` waits for the provider's acknowledgment on any provider. */
writeAccepted: (
id: string,
data: string,
inputKind: TerminalInputKind,
options?: { requireWriteSettlement?: true }
) => Promise<boolean>
onWriteUnavailable?: (callback: (payload: { id: string }) => void) => () => void
resize: (id: string, cols: number, rows: number) => void
claimViewport: (id: string, cols: number, rows: number) => void
reportGeometry: (id: string, cols: number, rows: number) => void
signal: (id: string, signal: string) => void
clearBuffer: (id: string) => void
resetInputModes: (id: string) => void
kill: (id: string, opts?: { keepHistory?: boolean }) => Promise<void>
ackColdRestore: (id: string) => void
ackData: (id: string, charCount: number, processedChars?: number) => void
onDeliveryResyncRequest: (callback: (payload: { requestId: number }) => void) => () => void
respondDeliveryResync: (payload: {
requestId: number
processedCharsByPty: Record<string, number>
}) => void
/** Renderer-initiated delivery health/heal lane over invoke — reaches main
* even when every main→renderer push channel is dead (field wedge). */
reportRendererDeliveryState: (
report: PtyRendererDeliveryStateReport
) => Promise<PtyRendererDeliveryHealthReply>
/** Live pty:data listener count on the preload emitter (sync) — heal-time
* discriminator between a detached listener and a dead channel. */
getPtyDataListenerCount: () => number
/** One-shot signal that this page's pty:data dispatcher is registered, so
* main can release sends held during the load/reload boot window. */
rendererDispatcherReady: () => void
setActiveRendererPty: (id: string, active: boolean) => void
setRendererPtyVisible: (id: string, visible: boolean) => void
/** Hidden-delivery gate (Phase 4): hidden=true lets main drop renderer
* byte delivery after model ingestion; reveal restores from snapshots. */
setHiddenRendererPty: (id: string, hidden: boolean) => void
/** Ref-counted-on-the-renderer delivery-interest signal that suppresses
* the hidden-delivery gate while any raw-byte consumer is registered. */
setPtyDeliveryInterest: (id: string, interested: boolean) => void
/** View-attribute bridge (Phase 5 slice 2): app-global composed terminal
* appearance push backing main's hidden-PTY OSC/DSR color replies. */
publishTerminalViewAttributes: (attributes: TerminalViewAttributes) => void
hasChildProcesses: (id: string) => Promise<boolean>
getForegroundProcess: (id: string) => Promise<string | null>
inspectProcess: (
id: string,
options?: {
expectedIncarnationId?: string
scanChildProcesses?: boolean
steadyState?: boolean
}
) => Promise<TerminalProcessInspection>
confirmForegroundProcess: (id: string) => Promise<string | null>
/** Local panes only; never joined for any other pane. */
isCodexOnSharedServer: (id: string) => Promise<CodexSharedServerStatus>
/** Runs the fix with the pane's own Codex; true only once verified. Local panes only. */
disableCodexSharedServerAutoStart: (id: string) => Promise<boolean>
stopCodexSharedServer: (id: string) => Promise<boolean>
/** A local pane whose daemon lacks the claude function and whose claude runs another account
* than the one selected for the pane's runtime; false for any other pane. */
openedBeforeClaudeAccounts: (
id: string,
target: { runtime: 'host' } | { runtime: 'wsl'; wslDistro: string }
) => Promise<boolean>
/** A held local session's last saved screen, read without spawning. */
readNewerDaemonHeldScreen: (sessionId: string) => Promise<NewerDaemonHeldScreen | null>
/** Stops the newer daemons; reopened only once each is confirmed exited. */
reopenNewerDaemonTerminals: () => Promise<NewerDaemonReopenResult>
getCwd: (id: string) => Promise<string>
getSize: (id: string) => Promise<{ cols: number; rows: number } | null>
listSessions: (scope?: PtySessionListScope) => Promise<PtyListedSession[]>
getAuthoritativeBufferSnapshotCapabilities?: (
ids: string[]
) => Promise<{ id: string; authoritative: boolean | null }[]>
hasPty: (id: string) => Promise<boolean | null>
moveLeafToNewTab: (request: TerminalLeafMoveRequest) => Promise<TerminalLeafMoveResult>
getMainBufferSnapshot: (
id: string,
opts?: { scrollbackRows?: number }
) => Promise<{
data: string
frameRestoreAnsi?: string
cols: number
rows: number
cwd?: string | null
seq?: number
/** Start of main's pending renderer-delivery queue at snapshot time
* (equals `seq` when empty) — bounds the renderer's post-restore
* duplicate window. */
pendingDeliveryStartSeq?: number
source?: 'headless' | 'renderer'
alternateScreen?: boolean
/** Authoritative normal buffer paired with an alternate-screen frame. */
scrollbackAnsi?: string
/** Trailing incomplete escape the emulator ingested; the restorer must
* write it after its post-replay resets, last before live chunks. */
pendingEscapeTailAnsi?: string
/** Effective kitty flags the snapshot owner proved at `seq`. Absent means
* unknown; consumers must not turn that into a known `0`. */
kittyKeyboardFlags?: number
terminalOwner?: 'shell'
} | null>
getRendererDeliveryDebugSnapshot: () => Promise<{
pendingPtyCount: number
pendingChars: number
maxPendingCharsByPty: number
rendererInFlightPtyCount: number
rendererInFlightChars: number
maxRendererInFlightCharsByPty: number
activeRendererPtyCount: number
flushScheduled: boolean
peakPendingChars: number
peakMaxPendingCharsByPty: number
peakRendererInFlightChars: number
peakMaxRendererInFlightCharsByPty: number
ackGatedFlushSkipCount: number
hiddenDeliveryGatedPtyCount: number
hiddenDeliveryGatedVisiblePtyCount: number
hiddenDeliveryGatedActivePtyCount: number
deliveryInterestPtyCount: number
hiddenDeliveryDroppedChars: number
hiddenDeliveryDroppedChunks: number
pendingDroppedChars: number
diagnostics: PtyMainDeliveryDiagnostics
rendererLifecycleResetCount: number
lastLifecycleResetClearedChars: number
rendererPtyDispatcherReady: boolean
rendererDispatcherReadyForcedCount: number
}>
resetRendererDeliveryDebug: () => Promise<void>
onData: (
callback: (data: {
id: string
data: string
seq?: number
rawLength?: number
transformed?: boolean
background?: boolean
droppedOutput?: boolean
}) => void
) => () => void
onReplay: (callback: (data: { id: string; data: string }) => void) => () => void
/** Out-of-band main→renderer signal that renderer-bound bytes were
* dropped (hidden-delivery gate / pending cap); the pane restores from
* the model snapshot. Never delivered in-band on pty:data. */
onModelRestoreNeeded: (callback: (event: PtyModelRestoreNeededEvent) => void) => () => void
/** Batched derived side-effect facts for PTYs whose bytes transit local
* main. */
onSideEffect: (callback: (batch: TerminalSideEffectBatch) => void) => () => void
/** Title-only replay snapshot for (re)attach; attention facts never replay. */
getSideEffectSnapshot: (id: string) => Promise<TerminalSideEffectBatch | null>
onExit: (
callback: (data: {
id: string
code: number
preserveRendererBinding?: boolean
/** Which lifetime of `id` died; absent when the execution host predates the field. */
incarnationId?: string
/** Set only when the owning relay disowned this id; never a claim that the process died. */
ptySourceDisowned?: true
/** Main stopped this PTY so a new process could take its pane; the pane is not dying. */
replacedByRestart?: true
}) => void
) => () => void
onSpawned: (callback: (data: { id: string }) => void) => () => void
onSerializeBufferRequest: (
callback: (data: {
requestId: string
ptyId: string
opts?: { scrollbackRows?: number }
}) => void
) => () => void
onClearBufferRequest: (callback: (data: { ptyId: string }) => void) => () => void
onResetInputModesRequest: (callback: (data: { ptyId: string }) => void) => () => void
sendSerializedBuffer: (
requestId: string,
snapshot: {
data: string
cols: number
rows: number
seq?: number
lastTitle?: string
kittyKeyboardFlags?: number
} | null
) => void
declarePendingPaneSerializer: (paneKey: string) => Promise<number>
settlePaneSerializer: (paneKey: string, gen: number) => Promise<void>
clearPendingPaneSerializer: (paneKey: string, gen: number) => Promise<void>
reportRendererSerializerReady?: (ptyId: string) => Promise<void>
management: PtyManagementApi
}