diff --git a/mobile/src/session/mobile-existing-agent-launch.ts b/mobile/src/session/mobile-existing-agent-launch.ts index 7cfb5cc2e47..3531838291b 100644 --- a/mobile/src/session/mobile-existing-agent-launch.ts +++ b/mobile/src/session/mobile-existing-agent-launch.ts @@ -7,19 +7,17 @@ */ import type { AgentLaunchPrompt, AgentLaunchResult } from '../../../src/shared/agent-launch-intent' -import { AGENT_LAUNCH_PANE_ALREADY_LIVE_CODE } from '../../../src/shared/agent-launch-pane-already-live' -import { AGENT_LAUNCH_SESSION_ALREADY_EXISTS_CODE } from '../../../src/shared/agent-launch-session-already-exists' import { isAgentSessionHandleProvider } from '../../../src/shared/agent-session-provider-handle' import { makePaneKey } from '../../../src/shared/stable-pane-id' import { createStructuredAgentSessionId } from '../../../src/shared/structured-agent-session-create' import type { TuiAgent } from '../../../src/shared/tui-agent' import type { RpcClient } from '../transport/rpc-client' import { agentLaunchReplayRun } from '../tasks/mobile-workspace-create-operations' +import { agentLaunchExistingParams, readAgentLaunchSupport } from '../tasks/agent-launch-request' import { - agentLaunchExistingParams, - isAgentLaunchReplayUnsupportedRefusal, - readAgentLaunchSupport -} from '../tasks/agent-launch-request' + classifyAgentLaunchReplayRefusal, + isAgentLaunchReservationTakenRefusal +} from '../../../src/shared/agent-launch-replay-refusal' import { sendReplayingAmbiguousDelivery } from '../tasks/replay-on-ambiguous-delivery' import { structuredSessionOperationId, @@ -152,29 +150,17 @@ function classifyLaunchRefusal( error: { code?: string; message?: string }, replayed: boolean ): MobileExistingAgentLaunch { - if (isAgentLaunchReplayUnsupportedRefusal(error)) { - // Only a refusal of the first send proves nothing ran; after a replay it may be a replacement - // connection whose capability list hasn't landed, answering for an attempt that did start. - return replayed - ? { kind: 'unknown', message: AGENT_LAUNCH_UNCONFIRMED_MESSAGE } - : { kind: 'unsupported' } + switch (classifyAgentLaunchReplayRefusal(error, replayed)) { + case 'unsupported': + return { kind: 'unsupported' } + case 'unknown': + return { kind: 'unknown', message: AGENT_LAUNCH_UNCONFIRMED_MESSAGE } + case 'failed': { + if (isAgentLaunchReservationTakenRefusal(error)) { + return { kind: 'failed', message: AGENT_LAUNCH_RESERVATION_TAKEN_MESSAGE } + } + const message = error.message?.trim() + return { kind: 'failed', message: message || "Couldn't start the agent." } + } } - if ( - error.code === 'agent_session_operation_unknown' || - error.code === 'agent_session_operation_expired' - ) { - return { kind: 'unknown', message: AGENT_LAUNCH_UNCONFIRMED_MESSAGE } - } - if ( - error.code === AGENT_LAUNCH_PANE_ALREADY_LIVE_CODE || - error.code === AGENT_LAUNCH_SESSION_ALREADY_EXISTS_CODE - ) { - // A taken reservation proves nothing started only on the first send; after a replay the pane - // or chat holding it may be this launch's own. - return replayed - ? { kind: 'unknown', message: AGENT_LAUNCH_UNCONFIRMED_MESSAGE } - : { kind: 'failed', message: AGENT_LAUNCH_RESERVATION_TAKEN_MESSAGE } - } - const message = error.message?.trim() - return { kind: 'failed', message: message || "Couldn't start the agent." } } diff --git a/mobile/src/tasks/agent-launch-request.ts b/mobile/src/tasks/agent-launch-request.ts index 0c3c71687f0..e6179ca9de3 100644 --- a/mobile/src/tasks/agent-launch-request.ts +++ b/mobile/src/tasks/agent-launch-request.ts @@ -142,11 +142,5 @@ export function isAgentLaunchUnsupportedRefusal(error: { return (error.message ?? '').includes('agent_launch_unsupported') } -/** The `agent.launchReplay` twin: an older host rejects the method rather than a field. */ -export function isAgentLaunchReplayUnsupportedRefusal(error: { code?: string }): boolean { - return ( - error.code === 'method_not_found' || - error.code === 'forbidden' || - error.code === 'agent_launch_replay_unsupported' - ) -} +// The `agent.launchReplay` twin lives in shared so the desktop classifies refusals the same way. +export { isAgentLaunchReplayUnsupportedRefusal } from '../../../src/shared/agent-launch-replay-refusal' diff --git a/src/main/agent-launch/__fixtures__/host-agent-startup-call-sites.txt b/src/main/agent-launch/__fixtures__/host-agent-startup-call-sites.txt index de078fd731e..350030683e9 100644 --- a/src/main/agent-launch/__fixtures__/host-agent-startup-call-sites.txt +++ b/src/main/agent-launch/__fixtures__/host-agent-startup-call-sites.txt @@ -5,6 +5,6 @@ # The resolve-worktree-removal-target call also serves a bare typed command, which stays unattributed. src/main/runtime/orca-runtime-create-agent-session.ts 2 attributes src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts 1 attributes -src/main/runtime/runtime-worktree-agent-startup.ts 3 attributes +src/main/runtime/runtime-worktree-agent-startup.ts 4 attributes src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts 1 resume src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts 1 mobile-followup diff --git a/src/main/agent-launch/agent-launch-executor.test.ts b/src/main/agent-launch/agent-launch-executor.test.ts index e7270287fb5..210289785ac 100644 --- a/src/main/agent-launch/agent-launch-executor.test.ts +++ b/src/main/agent-launch/agent-launch-executor.test.ts @@ -27,8 +27,12 @@ function harness(options: { structuredCreateError?: Error deliveredMessageId?: string | null terminalPromptDelivered?: boolean + /** Whether the surface reports that its typed line took the offered prompt. */ + lineCarriesPrompt?: boolean }) { const calls: string[] = [] + const carried = (startupPrompt: string | undefined) => + startupPrompt && (options.lineCarriesPrompt ?? true) ? { promptRodeLaunchCommand: true } : {} const createWorktree = vi.fn( async (args: { create: Record @@ -38,7 +42,8 @@ function harness(options: { calls.push(`createWorktree(startupAgent=${String(args.startupAgent)})`) return { worktreeId: 'wt-new', - startupTerminalHandle: args.startupAgent ? 'term_agent_first' : undefined + startupTerminalHandle: args.startupAgent ? 'term_agent_first' : undefined, + ...carried(args.startupPrompt) } } ) @@ -56,9 +61,9 @@ function harness(options: { } return { sessionId: 'sess-1', handle: 'handle_structured', fence: 4 } }) - const createTerminalAgent = vi.fn(async (_args: { startupPrompt?: string }) => { + const createTerminalAgent = vi.fn(async (args: { startupPrompt?: string }) => { calls.push('createTerminalAgent') - return { handle: 'term_1' } + return { handle: 'term_1', ...carried(args.startupPrompt) } }) const deliverStructuredPrompt = vi.fn(async () => { calls.push('deliverStructuredPrompt') @@ -297,10 +302,10 @@ describe('the prompt receipt', () => { }) /** - * A terminal takes its prompt one of two ways, and which one is not a preference: an agent whose - * CLI accepts a prompt argument must get it on argv, because that is the transport that survives - * multi-line and special-character text. Only an agent with no such argument is written to as - * keystrokes. `claude` is argv-mode, `aider` is `stdin-after-start` — the two halves of the table. + * A terminal takes its prompt one of two ways. An agent whose CLI accepts a prompt argument is + * offered it on the launch command, and the surface that types that line reports whether it rode; + * everything else is written as keystrokes once the agent is ready. `claude` is argv-mode, `aider` + * is `stdin-after-start` — the two halves of the table. */ describe('delivering a launch prompt to a terminal agent', () => { const SUBMIT = { text: 'do the thing', delivery: 'submit' } as const @@ -329,6 +334,8 @@ describe('delivering a launch prompt to a terminal agent', () => { expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'handed-to-terminal' }) expect(h.deliverTerminalPrompt).toHaveBeenCalledWith({ handle: 'term_1', + agent: 'aider', + freshLaunch: true, prompt: SUBMIT }) // Folding it into argv would have appended it as an argument the CLI does not accept. @@ -348,6 +355,39 @@ describe('delivering a launch prompt to a terminal agent', () => { expect(h.deliverTerminalPrompt).not.toHaveBeenCalled() }) + it('pastes an argv agent’s prompt after start when the surface reports its typed line could not carry it', async () => { + const h = harness({ + createSupport: { supported: false, reason: 'wsl' }, + lineCarriesPrompt: false + }) + const result = await h.run({ ...CREATE_INTENT, prompt: SUBMIT }) + + expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'handed-to-terminal' }) + // Offered to the launch command; the surface, not the executor, decided it did not ride. + expect(h.createTerminalAgent.mock.calls[0]?.[0]).toMatchObject({ + startupPrompt: 'do the thing' + }) + expect(h.deliverTerminalPrompt).toHaveBeenCalledWith({ + handle: 'term_1', + agent: 'claude', + freshLaunch: true, + prompt: SUBMIT + }) + }) + + it('pastes into an agent-first create’s startup terminal when its typed line could not carry the prompt', async () => { + const h = harness({ settings: null, lineCarriesPrompt: false }) + const result = await h.run({ ...CREATE_INTENT, prompt: SUBMIT }) + + expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'handed-to-terminal' }) + expect(h.deliverTerminalPrompt).toHaveBeenCalledWith({ + handle: 'term_agent_first', + agent: 'claude', + freshLaunch: true, + prompt: SUBMIT + }) + }) + it('writes into a reused terminal, whose process started before the launch existed', async () => { const h = harness({}) const result = await h.run({ @@ -361,6 +401,8 @@ describe('delivering a launch prompt to a terminal agent', () => { expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'handed-to-terminal' }) expect(h.deliverTerminalPrompt).toHaveBeenCalledWith({ handle: 'term_existing', + agent: 'claude', + freshLaunch: false, prompt: SUBMIT }) }) diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index 1d9bae4f45d..9e2841c8759 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -70,8 +70,12 @@ export type AgentLaunchExecution = { vocabulary?: AgentLaunchModeVocabulary /** Attributes a throw to the step that was running, the way a dispatch's own stages do. */ onStage?: (stage: 'worktree_create' | 'mode_settle' | 'surface_create') => void + /** The surface exists and its tab is published; runs before any prompt delivery. Must not throw. */ + onSurfacePublished?: (surface: AgentLaunchPublishedSurface) => void } +export type AgentLaunchPublishedSurface = Pick + export async function executeAgentLaunch( execution: AgentLaunchExecution ): Promise { @@ -95,13 +99,18 @@ export async function executeAgentLaunch( // A reused terminal already downgraded in the pre-flight; there is nothing to create. Its agent // was running before this launch existed, so argv is unreachable and the PTY is the only way in. if (intent.reuseTerminal) { - return { + const reused = published(execution, { outcome: { kind: 'terminal', handle: intent.reuseTerminal.handle }, - worktreeId: existingWorktreeId(intent.target), + worktreeId: existingWorktreeId(intent.target) + }) + return { + ...reused, receipt: preflight, ...promptReceipt( intent, - await deliverTerminalLaunchPrompt(execution, intent.reuseTerminal.handle) + await deliverTerminalLaunchPrompt(execution, intent.reuseTerminal.handle, { + freshLaunch: false + }) ) } } @@ -109,20 +118,25 @@ export async function executeAgentLaunch( const placed = await resolveWorkspace(execution, preflight) // Agent-first creation already produced the agent, so the pre-flight verdict is final. if (placed.startupTerminalHandle) { - return { + const startup = published(execution, { outcome: { kind: 'terminal', handle: placed.startupTerminalHandle, ...(placed.startupTerminalPaneKey ? { paneKey: placed.startupTerminalPaneKey } : {}) }, - worktreeId: placed.worktreeId, + worktreeId: placed.worktreeId + }) + return { + ...startup, receipt: preflight, ...(placed.warning ? { warning: placed.warning } : {}), ...promptReceipt( intent, placed.promptRodeLaunchCommand ? HANDED_TO_TERMINAL - : await deliverTerminalLaunchPrompt(execution, placed.startupTerminalHandle) + : await deliverTerminalLaunchPrompt(execution, placed.startupTerminalHandle, { + freshLaunch: true + }) ) } } @@ -165,15 +179,23 @@ export async function executeAgentLaunch( // not start while looking at it. Telling those apart needs `createManagedWorktree` to stop // multiplexing "couldn't copy untracked files" and "startup terminal failed" into one string. const warning = combineLaunchWarnings(placed.warning, created.warning) + const surface = published(execution, { outcome: created.outcome, worktreeId: placed.worktreeId }) return { - outcome: created.outcome, - worktreeId: placed.worktreeId, + ...surface, receipt: settled, ...(warning ? { warning } : {}), ...promptReceipt(intent, await settleLaunchPromptDisposal(execution, created)) } } +function published( + execution: AgentLaunchExecution, + surface: AgentLaunchPublishedSurface +): AgentLaunchPublishedSurface { + execution.onSurfacePublished?.(surface) + return surface +} + function downgradeAgentLaunchModeForStructuredRefusal( receipt: AgentLaunchModeReceipt, vocabulary: AgentLaunchModeVocabulary @@ -219,9 +241,10 @@ async function resolveWorkspace( }) // Only when a startup terminal actually came back: a create that produced none ran no command, // so nothing carried the prompt and the launch still owes it to whatever surface it builds next. - return created.startupTerminalHandle && startupPrompt - ? { ...created, promptRodeLaunchCommand: true } - : created + const { promptRodeLaunchCommand, ...rest } = created + return rest.startupTerminalHandle && promptRodeLaunchCommand + ? { ...rest, promptRodeLaunchCommand: true } + : rest } /** `structured` is the same surface `outcome` names, kept typed so prompt delivery reads the create's @@ -314,7 +337,8 @@ async function createTerminalSurface( worktreeId, agent: intent.agent, ...(startupPrompt ? { startupPrompt } : {}), - ...terminalLaunchInputs(intent) + ...terminalLaunchInputs(intent), + ...(intent.viewMode ? { viewMode: intent.viewMode } : {}) }) return { outcome: { @@ -323,7 +347,7 @@ async function createTerminalSurface( ...(terminal.paneKey ? { paneKey: terminal.paneKey } : {}) }, ...(terminal.warning ? { warning: terminal.warning } : {}), - ...(startupPrompt ? { promptRodeLaunchCommand: true } : {}) + ...(startupPrompt && terminal.promptRodeLaunchCommand ? { promptRodeLaunchCommand: true } : {}) } } diff --git a/src/main/agent-launch/agent-launch-prompt-delivery.ts b/src/main/agent-launch/agent-launch-prompt-delivery.ts index e77e0cabbe5..b3651901376 100644 --- a/src/main/agent-launch/agent-launch-prompt-delivery.ts +++ b/src/main/agent-launch/agent-launch-prompt-delivery.ts @@ -5,16 +5,15 @@ * surface exists and in what order; everything here decides how the text reaches whichever surface * that turned out to be, and each surface takes it differently: * - * structured session -> committed to the transcript, named by a message id -> journaled - * terminal, argv CLI -> folded into the command that execs the agent -> handed-to-terminal - * terminal, no argv -> bracketed paste into the live PTY -> handed-to-terminal - * anything unproven -> -> not-delivered + * structured session -> committed to the transcript, named by a message id -> journaled + * terminal, line fits -> folded into the command that execs the agent -> handed-to-terminal + * terminal, otherwise -> bracketed paste into the live PTY once it is ready -> handed-to-terminal + * anything unproven -> -> not-delivered * - * The argv/PTY fork is not a preference. `argv` exists so multi-line and special-character text - * reaches a CLI as one argument instead of keystrokes, and it has no readiness race because the - * text is in the process's arguments at exec time. So it is preferred wherever the agent's CLI - * takes a prompt argument, and `agentPromptRidesLaunchCommand` — derived from the same injection - * table `buildAgentStartupPlan` branches on — is the one place that question is asked. + * argv has no readiness race, so it is offered wherever the agent's CLI takes a prompt argument + * (`agentPromptRidesLaunchCommand`). But that command is TYPED into the user's shell, and a long or + * multi-line typed line fails in ways argv itself does not, so whether the offer was taken is decided + * where the line is built (`startup-line-prompt-carry`) and reported back, never predicted here. */ import type { @@ -39,11 +38,11 @@ export async function settleLaunchPromptDisposal( const messageId = await deliverStructuredLaunchPrompt(execution, created.structured) return messageId ? { outcome: 'journaled', messageId } : NOT_DELIVERED } - // The startup command already carries an argv agent's prompt; there is nothing left to write. + // The surface reported that the startup command carried the prompt; there is nothing left to write. if (created.promptRodeLaunchCommand) { return HANDED_TO_TERMINAL } - return deliverTerminalLaunchPrompt(execution, created.outcome.handle) + return deliverTerminalLaunchPrompt(execution, created.outcome.handle, { freshLaunch: true }) } /** @@ -82,13 +81,19 @@ async function deliverStructuredLaunchPrompt( */ export async function deliverTerminalLaunchPrompt( execution: AgentLaunchExecution, - handle: string + handle: string, + { freshLaunch }: { freshLaunch: boolean } ): Promise { const { intent, surfaces } = execution if (!intent.prompt || intent.prompt.delivery !== 'submit') { return NOT_DELIVERED } - const delivered = await surfaces.deliverTerminalPrompt?.({ handle, prompt: intent.prompt }) + const delivered = await surfaces.deliverTerminalPrompt?.({ + handle, + agent: intent.agent, + freshLaunch, + prompt: intent.prompt + }) return delivered ? HANDED_TO_TERMINAL : NOT_DELIVERED } @@ -98,8 +103,8 @@ function launchSubmitText(intent: AgentLaunchIntent): string | undefined { } /** - * The prompt a terminal's launch command should carry, which is an argv-mode agent's and only an - * argv-mode agent's. + * The prompt offered to a terminal's launch command, which is an argv-mode agent's and only an + * argv-mode agent's. An offer, not a decision: the surface reports whether the typed line took it. */ export function argvLaunchPrompt(intent: AgentLaunchIntent): string | undefined { const text = launchSubmitText(intent) diff --git a/src/main/agent-launch/agent-launch-surface-factories.ts b/src/main/agent-launch/agent-launch-surface-factories.ts index 867eac40ab6..39f8785bc72 100644 --- a/src/main/agent-launch/agent-launch-surface-factories.ts +++ b/src/main/agent-launch/agent-launch-surface-factories.ts @@ -25,8 +25,8 @@ export type AgentLaunchSurfaceFactory = { worktreeId: string agent: TuiAgent options?: Readonly> - /** Set only for an agent whose CLI takes the prompt on argv, so the text is in the process's - * arguments at exec time rather than raced into its composer afterwards. */ + /** Offered only for an agent whose CLI takes the prompt on argv. It rides the launch command + * only when the typed line can carry it; `promptRodeLaunchCommand` reports which happened. */ startupPrompt?: string /** Replaces the settings default for this launch only; `null` means no arguments at all. */ agentArgs?: string | null @@ -35,11 +35,15 @@ export type AgentLaunchSurfaceFactory = { launchSource?: string /** The caller-minted pane to create; refused with `AgentLaunchPaneAlreadyLiveError` if live. */ paneKey?: string + /** How the tab first shows, as the caller would have opened it. */ + viewMode?: 'terminal' | 'chat' }): Promise<{ handle: string /** The pane this create minted; a factory whose runtime reports none omits it, never invents. */ paneKey?: string warning?: string + /** Reported by the surface that built the typed line, never predicted by the executor. */ + promptRodeLaunchCommand?: boolean }> /** * Commits the launch text as the session's first turn, answering with the transcript row's id. @@ -56,12 +60,19 @@ export type AgentLaunchSurfaceFactory = { /** * Writes the launch text into a terminal agent's live PTY, answering whether it landed. * - * The other half of `startupPrompt`, for the two cases argv cannot serve: a `stdin-after-start` - * agent, whose CLI takes no prompt argument, and a reused terminal, whose process was already - * running before this launch existed. `false` for every failure, on the same rule the structured + * The other half of `startupPrompt`, for the cases the launch command cannot serve: a + * `stdin-after-start` agent, whose CLI takes no prompt argument; a prompt the typed line cannot + * carry; and a reused terminal, whose process was already running before this launch existed. `false` for every failure, on the same rule the structured * twin follows — a launch whose agent is running must not fail because its text did not land. */ - deliverTerminalPrompt?(args: { handle: string; prompt: AgentLaunchPrompt }): Promise + deliverTerminalPrompt?(args: { + handle: string + /** The launched agent, whose own readiness signal the write waits for. */ + agent: TuiAgent + /** False for a reused terminal, which has no fresh launch readiness to wait for. */ + freshLaunch: boolean + prompt: AgentLaunchPrompt + }): Promise } /** `fence` is the lease the create was admitted at, carried so the launch prompt's send can fill its @@ -95,8 +106,8 @@ export type AgentLaunchWorkspaceFactory = { * wait-for-setup gate for free. A structured launch has no startup command to sequence and * must await that gate explicitly instead. */ startupAgent: TuiAgent | undefined - /** Set only alongside a `startupAgent` whose CLI takes the prompt on argv: agent-first creation - * builds the startup command, so that is where an argv prompt belongs. */ + /** Offered only alongside a `startupAgent` whose CLI takes the prompt on argv: agent-first + * creation builds the startup command, so that is where the typed line is measured. */ startupPrompt?: string /** Inputs needed when this terminal is created as the worktree's startup surface. */ agentArgs?: string | null @@ -112,5 +123,7 @@ export type AgentLaunchWorkspaceFactory = { startupTerminalPaneKey?: string /** Created, but incomplete — surfaced on the launch result rather than dropped. */ warning?: string + /** Reported by the create that built the startup command's typed line. */ + promptRodeLaunchCommand?: boolean }> } diff --git a/src/main/agent-launch/host-agent-startup-attribution.test.ts b/src/main/agent-launch/host-agent-startup-attribution.test.ts index 612caab8001..d29c1cf7c0a 100644 --- a/src/main/agent-launch/host-agent-startup-attribution.test.ts +++ b/src/main/agent-launch/host-agent-startup-attribution.test.ts @@ -22,8 +22,9 @@ const LISTED: ReadonlyMap = new Map }) ) +// planStartupWithPromptCandidate wraps buildAgentStartupPlan in shared/, which this scan does not reach. const BUILDER_CALL = - /\b(?:buildAgentStartupPlan|buildAgentDraftLaunchPlan|buildAgentResumeStartupPlan)\s*\(/g + /\b(?:buildAgentStartupPlan|buildAgentDraftLaunchPlan|buildAgentResumeStartupPlan|planStartupWithPromptCandidate)\s*\(/g const DECIDE = 'Decide attribution: a fresh agent the host builds must carry ' + diff --git a/src/main/ipc/runtime-agent-launch-capability.test.ts b/src/main/ipc/runtime-agent-launch-capability.test.ts index b88a3ca8b24..247d19bc2f6 100644 --- a/src/main/ipc/runtime-agent-launch-capability.test.ts +++ b/src/main/ipc/runtime-agent-launch-capability.test.ts @@ -14,6 +14,8 @@ import { type AdvertisedClient = { clientKind?: 'mobile' | 'runtime' clientCapabilities?: readonly RuntimeCapability[] + trustedLocalCallerId?: string + pairedDeviceId?: string } const { handlers, advertised } = vi.hoisted( @@ -59,6 +61,7 @@ vi.mock('../runtime/rpc/dispatcher', () => ({ const { registerRuntimeHandlers } = await import('./runtime') const { supportsAgentLaunch } = await import('../runtime/rpc/methods/agent-launch') +const { agentLaunchOperationCallerKey } = await import('../runtime/rpc/methods/agent-launch-replay') function rendererEvent() { const mainFrame = {} @@ -128,4 +131,20 @@ describe('desktop renderer reaching agent.launch on its own main process', () => ) expect(supportsAgentLaunch(streaming)).toBe(true) }) + + // Why: the desktop has no paired device, so without its own host-set identity every + // replay-protected launch it sent was refused with agent_session_identity_required. + it('gives a replay-protected launch a caller identity of its own', () => { + invoke('runtime:call', { + method: 'agent.launchReplay', + params: { agent: 'claude', target: { kind: 'existing', worktree: 'id:wt-7' } } + }) + + const client = onlyAdvertisedClient(advertised.unary) + expect(agentLaunchOperationCallerKey(client)).toBe('trusted-local:desktop-renderer') + // Negative control: the same client kind with no host-set identity is still refused. + expect(() => + agentLaunchOperationCallerKey({ clientKind: client.clientKind, pairedDeviceId: undefined }) + ).toThrow('agent_session_identity_required') + }) }) diff --git a/src/main/ipc/runtime.ts b/src/main/ipc/runtime.ts index e768de463d8..94ee84c1fab 100644 --- a/src/main/ipc/runtime.ts +++ b/src/main/ipc/runtime.ts @@ -78,6 +78,7 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { }, { clientId: 'desktop-renderer', + trustedLocalCallerId: 'desktop-renderer', clientKind: 'runtime', connectionId: desktopSenders.connectionIdFor(event.sender), clientCapabilities: DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES @@ -120,6 +121,7 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { { signal: controller.signal, clientId: 'desktop-renderer', + trustedLocalCallerId: 'desktop-renderer', clientKind: 'runtime', connectionId, clientCapabilities: DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES diff --git a/src/main/runtime/__fixtures__/zsh-prompt-runs-command.meta.json b/src/main/runtime/__fixtures__/zsh-prompt-runs-command.meta.json new file mode 100644 index 00000000000..1e7291a03a8 --- /dev/null +++ b/src/main/runtime/__fixtures__/zsh-prompt-runs-command.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-30T12:00:00.000Z", + "platform": "darwin", + "command": ["/bin/zsh", "-f", "-i"], + "cols": 80, + "rows": 24, + "note": "zsh 5.9, macOS arm64, clean environment, PS1='%# '. The prompt turns bracketed paste on (ESC[?2004h), `sleep 1` is typed and run, which turns it off (ESC[?2004l), and the next prompt turns it on again: the bytes a launch sees from the shell before its agent starts, and after an agent exits", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/zsh-prompt-runs-command.txt b/src/main/runtime/__fixtures__/zsh-prompt-runs-command.txt new file mode 100644 index 00000000000..eacea47373f --- /dev/null +++ b/src/main/runtime/__fixtures__/zsh-prompt-runs-command.txt @@ -0,0 +1,2 @@ +% % [?2004hssleep 1[?2004l +% % [?2004h \ No newline at end of file diff --git a/src/main/runtime/agent-session-operation-admission.ts b/src/main/runtime/agent-session-operation-admission.ts index f16d48efb9d..c83c6d9f006 100644 --- a/src/main/runtime/agent-session-operation-admission.ts +++ b/src/main/runtime/agent-session-operation-admission.ts @@ -193,6 +193,23 @@ export function claimAgentSessionOperationInto( return claimed.claim } +/** Whether an admission left the right to run open, so the same transaction should claim it. */ +export type ClaimAfterAdmission = (decision: AgentSessionOperationDecision) => boolean + +/** Admission and, when `claimAfter` says so, the claim, in one transaction: the same swap as + * `claimAgentSessionOperationInto`, with one durable write instead of two. */ +export function admitAndClaimAgentSessionOperationInto( + state: { operations: Map }, + args: AgentSessionOperationAdmission, + claimAfter: ClaimAfterAdmission +): { decision: AgentSessionOperationDecision; claim: AgentSessionOperationClaim | null } { + const decision = admitAgentSessionOperationInto(state, args) + return { + decision, + claim: claimAfter(decision) ? claimAgentSessionOperationInto(state, args) : null + } +} + export function settleAgentSessionOperationInto( state: { operations: Map }, args: { callerKey?: string; operationId: string; outcome: AgentSessionOperationOutcome } diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 300e48d370d..2808a1a3833 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -18,6 +18,8 @@ import { evaluateAgentSessionMutationOperation, admitAgentSessionOperationInto, claimAgentSessionOperationInto, + admitAndClaimAgentSessionOperationInto, + type ClaimAfterAdmission, settleAgentSessionOperationInto, type AgentSessionMutationOperationAdmission, type AgentSessionOperationAdmission @@ -284,6 +286,12 @@ export class AgentSessionRecordStore { }): Promise => this.transact((draft) => claimAgentSessionOperationInto(draft, args)) + /** Admission and, when `claimAfter` allows, the claim: one durable write before the effect. */ + admitAndClaimOperation = ( + args: AgentSessionOperationAdmission, + claimAfter: ClaimAfterAdmission + ) => this.transact((draft) => admitAndClaimAgentSessionOperationInto(draft, args, claimAfter)) + async recordOperationOutcome(args: { callerKey?: string operationId: string diff --git a/src/main/runtime/agent-terminal-startup-prompt.test.ts b/src/main/runtime/agent-terminal-startup-prompt.test.ts index 8a9f9648147..c6b7370a30a 100644 --- a/src/main/runtime/agent-terminal-startup-prompt.test.ts +++ b/src/main/runtime/agent-terminal-startup-prompt.test.ts @@ -52,16 +52,49 @@ function spawnedCommand(spawn: ReturnType): string { } describe('a terminal create that is handed a launch prompt', () => { - it('folds an argv agent’s prompt into the command it spawns', async () => { + it('folds an argv agent’s prompt into the command it spawns, and says it did', async () => { const { runtime, spawn } = runtimeWithAgentLaunch() + const onStartupPromptCarry = vi.fn() await runtime.createTerminal('id:wt-1', { startupAgent: 'claude', - startupPrompt: 'summarize the diff' + startupPrompt: 'summarize the diff', + onStartupPromptCarry }) expect(spawnedCommand(spawn)).toContain('summarize the diff') expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ launchAgent: 'claude' })) + expect(onStartupPromptCarry).toHaveBeenCalledWith(true) + }) + + it('starts clean, and says so, when the typed line cannot carry a multi-line prompt', async () => { + const { runtime, spawn } = runtimeWithAgentLaunch() + const onStartupPromptCarry = vi.fn() + + await runtime.createTerminal('id:wt-1', { + startupAgent: 'claude', + startupPrompt: 'summarize the diff\nthen list the risks', + onStartupPromptCarry + }) + + // Typed into a shell, each newline would be Enter; the caller pastes it once the agent is up. + expect(spawnedCommand(spawn)).toContain('claude') + expect(spawnedCommand(spawn)).not.toContain('summarize') + expect(onStartupPromptCarry).toHaveBeenCalledWith(false) + }) + + it('starts Hermes clean instead of refusing when its env budget cannot hold the prompt', async () => { + const { runtime, spawn } = runtimeWithAgentLaunch() + const onStartupPromptCarry = vi.fn() + + await runtime.createTerminal('id:wt-1', { + startupAgent: 'hermes', + startupPrompt: 'x'.repeat(30_000), + onStartupPromptCarry + }) + + expect(spawn).toHaveBeenCalledTimes(1) + expect(onStartupPromptCarry).toHaveBeenCalledWith(false) }) it('still builds a bare agent launch when no prompt is handed to it', async () => { diff --git a/src/main/runtime/agent-transcript-pane-test-harness.ts b/src/main/runtime/agent-transcript-pane-test-harness.ts index 14ad56a8d9d..e57105472ee 100644 --- a/src/main/runtime/agent-transcript-pane-test-harness.ts +++ b/src/main/runtime/agent-transcript-pane-test-harness.ts @@ -2,6 +2,7 @@ import { vi } from 'vitest' import { OrcaRuntimeService } from './orca-runtime' import type { TuiAgent } from '../../shared/tui-agent' +import type { PtyProcessInspection } from '../providers/pty-process-inspection' const TRANSCRIPT_PANE_LEAF_ID = '11111111-1111-4111-8111-111111111111' const TRANSCRIPT_PANE_TAB_ID = 'tab-1' @@ -15,11 +16,22 @@ export type TranscriptPaneOptions = { launchAgent?: TuiAgent /** Set for a pane whose PTY lives on an SSH host or WSL distro rather than locally. */ connectionId?: string + /** The remote host of a `connectionId` pane is Windows. */ + remoteWindowsHost?: boolean /** Simulates a PTY controller whose foreground probe never settles. */ foregroundProbeHangs?: boolean onForegroundProbe?: () => void /** PTY grid the controller reports; the runtime's emulator otherwise defaults to 80x24. */ size?: { cols: number; rows: number } + /** What a fresh foreground scan finds, where it differs from the cached foreground read. */ + confirmedForegroundProcess?: string | null + onForegroundScan?: () => void + /** What the host's process inspection answers; absent for a host without one. */ + processInspection?: PtyProcessInspection + onProcessInspection?: () => void + /** What the host's shell-foreground check answers: the spawned shell holds the foreground. */ + shellForegroundProven?: boolean + onShellForegroundProof?: () => void } export async function createTranscriptPane( @@ -27,16 +39,21 @@ export async function createTranscriptPane( runtimeDeps?: ConstructorParameters[2] ): Promise<{ runtime: OrcaRuntimeService; handle: string }> { const runtime = new OrcaRuntimeService(null, undefined, runtimeDeps) + // The runtime reads a remote pane's host OS from its worktree path. + const worktreeId = options.remoteWindowsHost + ? 'repo-1::C:\\repo\\app' + : TRANSCRIPT_PANE_WORKTREE_ID const internals = runtime as unknown as { resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise } vi.spyOn(internals, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue({ - id: TRANSCRIPT_PANE_WORKTREE_ID, + id: worktreeId, path: '/repo/app', connectionId: options.connectionId ?? null, repo: null, folderWorkspace: null }) + const processInspection = options.processInspection runtime.setPtyController({ spawn: vi.fn().mockResolvedValue({ id: TRANSCRIPT_PANE_PTY_ID, incarnationId: 'inc-1' }), write: () => true, @@ -47,9 +64,33 @@ export async function createTranscriptPane( return options.foregroundProbeHangs === true ? new Promise(() => {}) : Promise.resolve(options.foregroundProcess) - } + }, + ...(options.confirmedForegroundProcess !== undefined + ? { + confirmForegroundProcess: async () => { + options.onForegroundScan?.() + return options.confirmedForegroundProcess ?? null + } + } + : {}), + ...(processInspection + ? { + inspectProcess: async () => { + options.onProcessInspection?.() + return processInspection + } + } + : {}), + ...(options.shellForegroundProven !== undefined + ? { + confirmShellForeground: async () => { + options.onShellForegroundProof?.() + return options.shellForegroundProven === true + } + } + : {}) }) - const terminal = await runtime.createTerminal(`id:${TRANSCRIPT_PANE_WORKTREE_ID}`, { + const terminal = await runtime.createTerminal(`id:${worktreeId}`, { tabId: TRANSCRIPT_PANE_TAB_ID, leafId: TRANSCRIPT_PANE_LEAF_ID, title: 'Terminal' @@ -59,7 +100,7 @@ export async function createTranscriptPane( tabs: [ { tabId: TRANSCRIPT_PANE_TAB_ID, - worktreeId: TRANSCRIPT_PANE_WORKTREE_ID, + worktreeId, title: 'Terminal', activeLeafId: TRANSCRIPT_PANE_LEAF_ID, layout: null @@ -68,7 +109,7 @@ export async function createTranscriptPane( leaves: [ { tabId: TRANSCRIPT_PANE_TAB_ID, - worktreeId: TRANSCRIPT_PANE_WORKTREE_ID, + worktreeId, leafId: TRANSCRIPT_PANE_LEAF_ID, paneRuntimeId: 1, ptyId: TRANSCRIPT_PANE_PTY_ID, @@ -77,7 +118,7 @@ export async function createTranscriptPane( ] }) if (options.launchAgent) { - runtime.registerPty(TRANSCRIPT_PANE_PTY_ID, TRANSCRIPT_PANE_WORKTREE_ID, null, { + runtime.registerPty(TRANSCRIPT_PANE_PTY_ID, worktreeId, options.connectionId ?? null, { tabId: TRANSCRIPT_PANE_TAB_ID, leafId: TRANSCRIPT_PANE_LEAF_ID, incarnationId: 'inc-1', diff --git a/src/main/runtime/launched-agent-composer-readiness-claude.test.ts b/src/main/runtime/launched-agent-composer-readiness-claude.test.ts new file mode 100644 index 00000000000..889f08a2ef6 --- /dev/null +++ b/src/main/runtime/launched-agent-composer-readiness-claude.test.ts @@ -0,0 +1,454 @@ +/** + * A freshly launched Claude's first input, replayed from captured transcripts + * (`__fixtures__/claude-dialog-trust-workspace*.txt`). + * + * The launch pastes on the signal the desktop's own paste used: bracketed paste turned on, then a + * quiet render. Claude's first-launch trust dialog renders in that same mode, so the quiet window + * also settles over it, and only the screen check keeps the prompt out of the dialog. + */ + +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' +import { + waitForLaunchedAgentComposer, + waitForWorkerStartComposer +} from './launched-agent-composer-readiness' +import { resolveRemoteForegroundEvidence } from '../providers/agent-foreground-process' +import type { ProcessTableRow } from '../../shared/process-table-snapshot' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +/** The desktop paste's quiet window after bracketed paste, which the launch now shares. */ +const QUIET_WINDOW_MS = 1_500 + +function readCapture(name: string): { data: string; size: { cols: number; rows: number } } { + const base = join(__dirname, '__fixtures__', name) + const meta: { cols: number; rows: number } = JSON.parse(readFileSync(`${base}.meta.json`, 'utf8')) + return { data: readFileSync(`${base}.txt`, 'utf8'), size: { cols: meta.cols, rows: meta.rows } } +} + +/** Starts the launch wait on an empty pane, then streams the capture in, as a live launch does. */ +async function launchAndStream( + name: string, + timeoutMs: number, + pane: Partial[0]> = {} +) { + const { data, size } = readCapture(name) + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Claude Code', + foregroundProcess: 'claude', + launchAgent: 'claude', + size, + data: '', + ...pane + }) + // Pane creation awaits real timers; the wait and its quiet window use the virtual clock. + vi.useFakeTimers() + const ready = waitForLaunchedAgentComposer(runtime, handle, 'claude', timeoutMs) + const settled = vi.fn() + ready.then(settled, settled) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, data, Date.now()) + return { ready, settled } +} + +describe('launch readiness for a freshly launched Claude', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('claude-dialog-trust-workspace-answered: reads the composer after the answered dialog as ready', async () => { + const { data } = readCapture('claude-dialog-trust-workspace-answered') + // Presence precondition: the capture turns bracketed paste on and ends on the idle composer. + expect(data).toContain('\x1b[?2004h') + const { ready, settled } = await launchAndStream( + 'claude-dialog-trust-workspace-answered', + 60_000 + ) + + await vi.advanceTimersByTimeAsync(QUIET_WINDOW_MS + 100) + expect(settled).toHaveBeenCalled() + await expect(ready).resolves.toMatchObject({ satisfied: true }) + }) + + it('claude-dialog-trust-workspace-answered: over SSH, settles on the quiet window, not the 8 s fallback', async () => { + const { ready, settled } = await launchAndStream( + 'claude-dialog-trust-workspace-answered', + 60_000, + // The relay offers no scan or shell check; either claiming a shell would refuse this signal. + { connectionId: 'ssh-1', confirmedForegroundProcess: 'zsh', shellForegroundProven: true } + ) + + await vi.advanceTimersByTimeAsync(QUIET_WINDOW_MS + 300) + expect(settled).toHaveBeenCalled() + await expect(ready).resolves.toMatchObject({ satisfied: true }) + }) + + it('claude-dialog-trust-workspace: never reads the trust dialog as the composer', async () => { + const { ready, settled } = await launchAndStream('claude-dialog-trust-workspace', 60_000) + + // Reported inside the desktop paste's budget: the quiet window settles over the dialog, the + // screen check refuses it, and the idle wait names it. + await vi.advanceTimersByTimeAsync(4_000) + expect(settled).toHaveBeenCalled() + await expect(ready).resolves.toMatchObject({ + satisfied: false, + blockedReason: 'agent-trust-workspace' + }) + }) +}) + +describe('a fresh orchestration worker start for Claude', () => { + afterEach(() => { + vi.useRealTimers() + }) + + // Main's worker start pasted on this cue; the launch's quiet window put the brief 1.6 s later. + it('settles on Claude’s ready title, before the launch paste’s quiet window', async () => { + const { data, size } = readCapture('claude-dialog-trust-workspace-answered') + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Claude Code', + foregroundProcess: 'claude', + launchAgent: 'claude', + size, + data: '' + }) + vi.useFakeTimers() + const ready = waitForWorkerStartComposer(runtime, handle, 'claude', 60_000) + const settled = vi.fn() + ready.then(settled, settled) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, data, Date.now()) + + await vi.advanceTimersByTimeAsync(QUIET_WINDOW_MS / 2) + expect(settled).toHaveBeenCalled() + await expect(ready).resolves.toMatchObject({ satisfied: true }) + }) +}) + +describe('whether a shell is proven in front of a launched agent’s terminal', () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')! + const setPlatform = (value: NodeJS.Platform): void => { + Object.defineProperty(process, 'platform', { configurable: true, value }) + } + afterEach(() => { + Object.defineProperty(process, 'platform', originalPlatform) + }) + + describe('macOS and Linux: a fresh foreground read', () => { + beforeEach(() => setPlatform('darwin')) + + it.each([ + // For its first 5 s the daemon's cached read names the launch agent while zsh is in front. + ['claude', 'zsh', true], + ['claude', 'claude', false], + ['zsh', 'zsh', true], + ['-zsh', '-zsh', true], + ['bash', 'bash', true], + [null, 'zsh', true], + [null, null, false] + ])('cached %s, scan %s: %s', async (foregroundProcess, scanned, shellInFront) => { + const scan = vi.fn() + const { runtime } = await createTranscriptPane({ + paneTitle: 'Claude Code', + foregroundProcess, + confirmedForegroundProcess: scanned, + onForegroundScan: scan, + launchAgent: 'claude', + data: '' + }) + + await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'claude')).resolves.toBe( + shellInFront + ) + expect(scan).toHaveBeenCalledOnce() + }) + + // The terminal daemon answers the shell-foreground check from its recovery state, which an + // agent that exits without leaving a full-screen mode up never sets. + it('an agent that exited: the scan names zsh while the daemon’s shell check says no', async () => { + const proof = vi.fn() + const { runtime } = await createTranscriptPane({ + paneTitle: 'copilot', + foregroundProcess: 'copilot', + confirmedForegroundProcess: 'zsh', + shellForegroundProven: false, + onShellForegroundProof: proof, + launchAgent: 'copilot', + data: '' + }) + + await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'copilot')).resolves.toBe( + true + ) + expect(proof).not.toHaveBeenCalled() + }) + + it('takes the cached read on a controller without a scan', async () => { + const { runtime } = await createTranscriptPane({ + paneTitle: 'Claude Code', + foregroundProcess: 'zsh', + launchAgent: 'claude', + data: '' + }) + + await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'claude')).resolves.toBe( + true + ) + }) + }) + + describe('Windows: only the shell-foreground check', () => { + beforeEach(() => setPlatform('win32')) + + // The scan names the pane's shell for an agent it cannot recognize (an npm agent as + // `node.exe`); only the shell alone in the pane's job proves one. + it.each([ + ['powershell.exe', false, false], + ['powershell.exe', true, true], + ['claude', true, true], + ['claude', false, false] + ])('cached %s, shell check %s: %s', async (foregroundProcess, proven, shellInFront) => { + const scan = vi.fn() + const { runtime } = await createTranscriptPane({ + paneTitle: 'Claude Code', + foregroundProcess, + confirmedForegroundProcess: 'powershell.exe', + onForegroundScan: scan, + shellForegroundProven: proven, + launchAgent: 'claude', + data: '' + }) + + await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'claude')).resolves.toBe( + shellInFront + ) + expect(scan).not.toHaveBeenCalled() + }) + + it('proves nothing on a controller without a shell check', async () => { + const { runtime } = await createTranscriptPane({ + paneTitle: 'Claude Code', + foregroundProcess: 'powershell.exe', + confirmedForegroundProcess: 'powershell.exe', + launchAgent: 'claude', + data: '' + }) + + await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'claude')).resolves.toBe( + false + ) + }) + }) + + // The stubs always disagree with the relay's name, so asking either would flip the answer. + it.each([ + ['claude', false], + ['bash', true] + ])( + 'SSH: takes the relay’s own read %s (shell %s), without a scan or check', + async (relayRead, shell) => { + const scan = vi.fn() + const proof = vi.fn() + const { runtime } = await createTranscriptPane({ + paneTitle: 'Claude Code', + foregroundProcess: relayRead, + confirmedForegroundProcess: shell ? 'claude' : 'zsh', + onForegroundScan: scan, + shellForegroundProven: !shell, + onShellForegroundProof: proof, + connectionId: 'ssh-1', + launchAgent: 'claude', + data: '' + }) + + await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'claude')).resolves.toBe( + shell + ) + expect(scan).not.toHaveBeenCalled() + expect(proof).not.toHaveBeenCalled() + } + ) + + describe('macOS and Linux: by the shell’s identity, from the host’s process inspection', () => { + beforeEach(() => setPlatform('darwin')) + + // Captured with `ps -axo pid=,ppid=,pgid=,tpgid=,stat=,tty=,lstart=,command=` on a pty running + // `zsh -f -i` (macOS 26): first while it ran `bash -c 'sleep 4; :'`, then back at its prompt. + const zsh = (tpgid: number, stat: string): ProcessTableRow => ({ + pid: 20894, + ppid: 20891, + pgid: 20894, + tpgid, + stat, + tty: 'ttys000', + startTime: 'Wed Sep 30 09:18:50 2026', + command: '/bin/zsh -f -i' + }) + const wrapperRunning: ProcessTableRow[] = [ + zsh(20996, 'Ss'), + { + pid: 20996, + ppid: 20894, + pgid: 20996, + tpgid: 20996, + stat: 'S+', + tty: 'ttys000', + startTime: 'Wed Sep 30 09:18:52 2026', + command: 'bash -c sleep 4; :' + }, + { + pid: 20997, + ppid: 20996, + pgid: 20996, + tpgid: 20996, + stat: 'S+', + tty: 'ttys000', + startTime: 'Wed Sep 30 09:18:52 2026', + command: 'sleep 4' + } + ] + const zshAtPrompt: ProcessTableRow[] = [zsh(20894, 'Ss+')] + const inspect = (rows: ProcessTableRow[]) => ({ + foregroundProcess: 'zsh', + hasChildProcesses: rows.length > 1, + foregroundProcessEvidence: resolveRemoteForegroundEvidence( + { rootPid: 20894, fallbackProcess: 'zsh' }, + { + ptyId: TRANSCRIPT_PANE_PTY_ID, + ptyIncarnationId: 'inc-1', + authorityGeneration: 'gen-1', + observationEpoch: 1, + capturedAgeMs: 0, + platform: 'darwin' + }, + rows + ) + }) + + it.each([ + // A wrapper script's bash runs as its own job: named like a shell, but not the pane's shell. + ['a bash wrapper under zsh is not the shell', wrapperRunning, 'bash', false, false], + ['zsh back at its prompt is the shell', zshAtPrompt, 'zsh', true, true] + ] as const)('%s', async (_label, rows, foregroundName, shell, scanned) => { + const inspection = inspect([...rows]) + // Presence precondition: the captured rows are a live observation, not an unreadable one. + expect(inspection.foregroundProcessEvidence).toMatchObject({ verdict: 'live' }) + const scan = vi.fn() + const { runtime } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: foregroundName, + processInspection: inspection, + confirmedForegroundProcess: foregroundName, + onForegroundScan: scan, + launchAgent: 'copilot', + data: '' + }) + + await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'copilot')).resolves.toBe( + shell + ) + expect(scan).toHaveBeenCalledTimes(scanned ? 1 : 0) + }) + + it('SSH: takes the relay’s inspection the same way', async () => { + const { runtime } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'bash', + processInspection: inspect([...wrapperRunning]), + connectionId: 'ssh-1', + launchAgent: 'copilot', + data: '' + }) + + await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'copilot')).resolves.toBe( + false + ) + }) + + it('an agent in the shell’s own group (a shell without job control) is not the shell', async () => { + const { runtime } = await createTranscriptPane({ + paneTitle: 'Copilot', + // node-pty names the group's leader, which is the shell itself here. + foregroundProcess: 'zsh', + processInspection: inspect([...zshAtPrompt]), + confirmedForegroundProcess: 'node', + launchAgent: 'copilot', + data: '' + }) + + await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'copilot')).resolves.toBe( + false + ) + }) + + it('an inspection that cannot observe proves nothing, and falls back to no name', async () => { + const scan = vi.fn() + const { runtime } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'zsh', + processInspection: inspect([]), + confirmedForegroundProcess: 'zsh', + onForegroundScan: scan, + launchAgent: 'claude', + data: '' + }) + + await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'claude')).resolves.toBe( + false + ) + expect(scan).not.toHaveBeenCalled() + }) + }) + + // Why: a Windows relay names the pane's shell for an agent its scan cannot recognize (node.exe). + it('SSH to a Windows host: the relay’s shell name proves nothing', async () => { + const { runtime } = await createTranscriptPane({ + paneTitle: 'Copilot', + foregroundProcess: 'powershell.exe', + connectionId: 'ssh-1', + remoteWindowsHost: true, + launchAgent: 'copilot', + data: '' + }) + + await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'copilot')).resolves.toBe( + false + ) + }) + + // Why no read: a native Claude names itself by its version, which the cached read gives at once. + it.each([ + ['darwin', '2.1.285'], + ['darwin', 'node'], + ['win32', 'node'] + ] as const)( + '%s: takes the non-shell name %s, other than the agent’s own, at once', + async (platform, foregroundProcess) => { + setPlatform(platform) + const scan = vi.fn() + const proof = vi.fn() + const { runtime } = await createTranscriptPane({ + paneTitle: 'Claude Code', + foregroundProcess, + confirmedForegroundProcess: 'zsh', + onForegroundScan: scan, + shellForegroundProven: true, + onShellForegroundProof: proof, + launchAgent: 'claude', + data: '' + }) + + await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'claude')).resolves.toBe( + false + ) + expect(scan).not.toHaveBeenCalled() + expect(proof).not.toHaveBeenCalled() + } + ) +}) diff --git a/src/main/runtime/launched-agent-composer-readiness-codex.test.ts b/src/main/runtime/launched-agent-composer-readiness-codex.test.ts new file mode 100644 index 00000000000..1c1209273c1 --- /dev/null +++ b/src/main/runtime/launched-agent-composer-readiness-codex.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, vi } from 'vitest' +import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' +import { readRuntimeFixture } from './agent-transcript-replay-test-harness' +import { waitForLaunchedAgentComposer } from './launched-agent-composer-readiness' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +// A source-control button's multi-line prompt reaches Codex only through this wait, so a Codex +// release that the launch wait cannot read ready reports "prompt wasn't sent". +async function launchedCodexPane(data: string) { + return createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'codex', + launchAgent: 'codex', + data, + size: { cols: 120, rows: 40 } + }) +} + +// Why cut: on an Astra model 0.158 sparkles braille stars in the empty composer, redrawing every +// 150 ms, then repaints it clean after 15 s. The capture ends on the first star frame, so replayed +// whole it holds a starred composer still, which the live stream never does. +function greetingBeforeStarfield(): string { + const data = readRuntimeFixture('codex-0158-fresh-home-greeting') + const firstStar = data.search(/38;2;\d+;\d+;\d+;48;2;30;30;30m[⠀-⣿]/) + return data.slice(0, data.lastIndexOf('\x1b[?2026h', firstStar)) +} + +describe('launch readiness for a freshly launched Codex', () => { + it('codex-0158-fresh-home-greeting: reads the live chat as ready, so the prompt is pasted', async () => { + const data = greetingBeforeStarfield() + // Presence precondition: the cut keeps the live status row and drops every star. + expect(data).toContain('GPT-6-Astra default') + expect(data).not.toMatch(/48;2;30;30;30m[⠀-⣿]/) + const { runtime, handle } = await launchedCodexPane(data) + await expect( + waitForLaunchedAgentComposer(runtime, handle, 'codex', 8_000) + ).resolves.toMatchObject({ condition: 'tui-idle', satisfied: true }) + }, 15_000) + + it('codex-0157-plain-ready: reads the live chat as ready, so the prompt is pasted', async () => { + const { runtime, handle } = await launchedCodexPane( + readRuntimeFixture('codex-0157-plain-ready') + ) + await expect( + waitForLaunchedAgentComposer(runtime, handle, 'codex', 8_000) + ).resolves.toMatchObject({ condition: 'tui-idle', satisfied: true }) + }, 15_000) + + it.each([ + ['codex-0158-update-available-dialog', 'agent-update-prompt'], + ['codex-0158-hooks-review-dialog', 'agent-hooks-review-prompt'], + ['codex-0158-model-retired-dialog', 'codex-model-migration-prompt'], + ['codex-0158-model-announcement-dialog', 'codex-model-migration-prompt'] + ])( + '%s: stops at the dialog as %s instead of pasting into it', + async (fixture, reason) => { + const { runtime, handle } = await launchedCodexPane(readRuntimeFixture(fixture)) + await expect( + waitForLaunchedAgentComposer(runtime, handle, 'codex', 2_500) + ).resolves.toMatchObject({ satisfied: false, blockedReason: reason }) + }, + 15_000 + ) + + // Why streamed: live, the dialog's `›` selector arrives after bracketed paste and fires the + // composer signal, so only the screen check keeps it from pasting; it hands the dialog to + // `tui-idle` to report, well inside the desktop paste's budget. + it.each([ + ['codex-0157-update-available-dialog', 'agent-update-prompt'], + ['codex-0158-update-available-dialog', 'agent-update-prompt'], + ['codex-0158-hooks-review-dialog', 'agent-hooks-review-prompt'], + ['codex-0158-model-retired-dialog', 'codex-model-migration-prompt'], + ['codex-0158-model-announcement-dialog', 'codex-model-migration-prompt'] + ])( + '%s streamed in after the launch: reports the dialog as %s, never as the composer', + async (fixture, reason) => { + const data = readRuntimeFixture(fixture) + // Presence precondition: the dialog draws Codex's composer glyph after bracketed paste. + expect(data.slice(data.indexOf('\x1b[?2004h'))).toContain('›') + const { runtime, handle } = await launchedCodexPane('') + const ready = waitForLaunchedAgentComposer(runtime, handle, 'codex', 2_500) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, data, Date.now()) + await expect(ready).resolves.toMatchObject({ satisfied: false, blockedReason: reason }) + }, + 15_000 + ) + + // Why: 0.157 discards input typed behind its provisional `model: loading` screen. + it('codex-0157-fresh-home-daemon-install: does not read the provisional screen as ready', async () => { + const full = readRuntimeFixture('codex-0157-fresh-home-daemon-install') + const install = full.indexOf('Installing daemon') + // Presence precondition: the cut keeps the provisional header and stops before the live chat. + expect(install).toBeGreaterThan(0) + const provisional = full.slice(0, full.indexOf('\n', install) + 1) + expect(provisional).toMatch(/model:.*loading/) + const { runtime, handle } = await launchedCodexPane(provisional) + await expect(waitForLaunchedAgentComposer(runtime, handle, 'codex', 5_000)).rejects.toThrow( + /timeout/ + ) + }, 15_000) +}) diff --git a/src/main/runtime/launched-agent-composer-readiness.test.ts b/src/main/runtime/launched-agent-composer-readiness.test.ts new file mode 100644 index 00000000000..cd8db4e06e8 --- /dev/null +++ b/src/main/runtime/launched-agent-composer-readiness.test.ts @@ -0,0 +1,157 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { TuiAgent } from '../../shared/tui-agent' +import type { RuntimeTerminalWait } from '../../shared/runtime-terminal-contracts' +import { GROK_STARTUP_PTY_TRACE } from '../../shared/__fixtures__/grok-startup-pty-trace' +import type { GrokStartupTraceChunk } from '../../shared/__fixtures__/grok-startup-pty-trace' +import { GROK_INLINE_STARTUP_PTY_TRACE } from '../../shared/__fixtures__/grok-inline-startup-pty-trace' +import { + waitForLaunchedAgentComposer, + type LaunchedAgentReadinessRuntime +} from './launched-agent-composer-readiness' +import { waitForWorktreeStartupDraft } from './runtime-worktree-startup-readiness' + +const READY: RuntimeTerminalWait = { + handle: 'term-1', + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null +} + +/** The runtime's composer wait over a replayed PTY stream, with its defaults. */ +function replayRuntime() { + let listener = (_data: string): void => {} + const waitForFreshWorkerComposer = vi.fn( + async ( + handle: string, + agent: TuiAgent, + timeoutMs: number, + { requireComposerMarker = true }: { requireComposerMarker?: boolean } = {} + ): Promise => { + const ptyId = await waitForWorktreeStartupDraft( + { + getPtyId: () => 'pty-1', + getForegroundProcess: async () => agent, + subscribeToData: (_ptyId, onData) => { + listener = onData + return () => { + listener = () => {} + } + }, + readRecentOutput: () => undefined, + write: vi.fn() + }, + handle, + agent, + { timeoutMs, requireComposerMarker } + ) + if (!ptyId) { + throw new Error('timeout') + } + return READY + } + ) + /** The `tui-idle` fallback: pending until a test settles it. */ + let settleIdle = (_wait: RuntimeTerminalWait): void => {} + const waitForTerminal = vi.fn( + (): Promise => + new Promise((resolve) => { + settleIdle = resolve + }) + ) + const runtime: LaunchedAgentReadinessRuntime = { + waitForTerminal, + waitForFreshWorkerComposer + } + const play = async (trace: GrokStartupTraceChunk[]): Promise => { + let now = 0 + for (const chunk of trace) { + await vi.advanceTimersByTimeAsync(chunk.t - now) + now = chunk.t + listener(chunk.data ?? 'x'.repeat(chunk.bytes ?? 0)) + } + } + return { + runtime, + waitForTerminal, + play, + feed: (data: string) => listener(data), + settleIdle: (wait: RuntimeTerminalWait) => settleIdle(wait) + } +} + +describe('launched grok composer readiness', () => { + afterEach(() => vi.useRealTimers()) + + it('opens on the composer frame in the default full-screen mode', async () => { + vi.useFakeTimers() + const h = replayRuntime() + const ready = waitForLaunchedAgentComposer(h.runtime, 'term-1', 'grok', 60_000) + await h.play(GROK_STARTUP_PTY_TRACE) + await expect(ready).resolves.toEqual(READY) + }) + + it('still opens in inline mode, which never switches to the alternate screen', async () => { + // `grok --no-alt-screen` / `screen_mode = "minimal"` paints its `❯` without the anchor the + // marker needs, so the quiet window after bracketed paste is its only readiness. + vi.useFakeTimers() + const h = replayRuntime() + const ready = waitForLaunchedAgentComposer(h.runtime, 'term-1', 'grok', 60_000) + const settled = vi.fn() + void ready.then(settled, settled) + await h.play(GROK_INLINE_STARTUP_PTY_TRACE) + await vi.advanceTimersByTimeAsync(5_000) + expect(settled).toHaveBeenCalledWith(READY) + }) + + it('keeps ZCode on its composer marker alone', async () => { + vi.useFakeTimers() + const h = replayRuntime() + const ready = waitForLaunchedAgentComposer(h.runtime, 'term-1', 'zcode', 1_000) + void ready.catch(() => {}) + expect(h.runtime.waitForFreshWorkerComposer).toHaveBeenCalledWith('term-1', 'zcode', 1_000, { + requireComposerMarker: true + }) + await vi.advanceTimersByTimeAsync(1_000) + await expect(ready).rejects.toThrow('timeout') + }) +}) + +describe('launched composer readiness for agents the idle evidence can also read', () => { + afterEach(() => vi.useRealTimers()) + + it('pastes on the quiet window after bracketed paste, as the desktop did, without the idle evidence', async () => { + vi.useFakeTimers() + const h = replayRuntime() + const ready = waitForLaunchedAgentComposer(h.runtime, 'term-1', 'claude', 60_000) + const settled = vi.fn() + void ready.then(settled, settled) + h.feed('\x1b[?2004h\x1b[?25l welcome to claude code \x1b[?25h') + + await vi.advanceTimersByTimeAsync(1_400) + expect(settled).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(200) + expect(settled).toHaveBeenCalledWith(READY) + expect(h.waitForTerminal).not.toHaveBeenCalled() + }) + + it('checks the idle evidence only once the desktop paste’s budget ran out, where it pasted blind', async () => { + vi.useFakeTimers() + const h = replayRuntime() + const ready = waitForLaunchedAgentComposer(h.runtime, 'term-1', 'claude', 60_000) + // Output, but bracketed paste never turns on, so the composer signal cannot fire. + h.feed('\x1b[?25l welcome to claude code \x1b[?25h') + + await vi.advanceTimersByTimeAsync(7_900) + expect(h.waitForTerminal).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(200) + expect(h.waitForTerminal).toHaveBeenCalledWith('term-1', { + condition: 'tui-idle', + timeoutMs: 52_000, + launchReadiness: true + }) + h.settleIdle(READY) + + await expect(ready).resolves.toEqual(READY) + }) +}) diff --git a/src/main/runtime/launched-agent-composer-readiness.ts b/src/main/runtime/launched-agent-composer-readiness.ts new file mode 100644 index 00000000000..06637fdfffa --- /dev/null +++ b/src/main/runtime/launched-agent-composer-readiness.ts @@ -0,0 +1,130 @@ +/** + * The one answer to "has the agent Orca just launched opened its composer?", shared by every host + * path that writes a first input into a fresh agent: `agent.launch`'s terminal prompt and, through + * `waitForWorkerStartComposer`, an orchestration worker's first dispatch. + * + * The signal is the one the desktop's own paste used: bracketed paste turned on (DECSET 2004) plus + * the agent's `draftPasteReadySignal` (its composer marker, or a quiet render after 2004), read by + * the shared `draft-paste-ready-scanner`, within the same per-agent budget. That signal cannot tell + * a composer from a startup dialog drawn in the same mode, so it counts only while the pane shows no + * startup dialog and no Codex provisional header (`readFreshComposerHold`). Unlike the desktop's + * paste it reads only output after the shell's last `?2004l`, and drops a signal while a shell is + * proven in front (`isLaunchShellInFront`). + * + * Where the desktop pasted blind once its budget ran out, the host falls back to the `tui-idle` + * evidence ranking (idle titles, known ready screens), which also reports a dialog left up. A few + * agents show readiness only in their composer, which that ranking cannot read: ZCode paints no + * title and repaints its banner forever, DSH's idle hook fires only after a turn, and Grok's only + * title is its bare name. They wait for their marker alone. + */ + +import type { TuiAgent } from '../../shared/tui-agent' +import type { RuntimeTerminalWait } from '../../shared/runtime-terminal-contracts' +import { resolveDraftPasteReadyTimeoutMs } from '../../shared/draft-paste-ready-timeout' +import type { OrcaRuntimeService } from './orca-runtime' +import { isCodexProvisionalStartupText } from './codex-terminal-readiness' +import { detectTerminalWaitBlockedReason } from './terminal-wait-detection' + +/** + * Agents whose launch readiness is a composer marker pinned by a captured transcript + * (`zcode-readiness-transcript.test.ts`, `dsh-readiness-transcript.test.ts`, + * `draft-paste-ready-scanner-grok-trace-replay.test.ts`). Grok is here because its only other + * evidence is its bare name, which a shell auto-title also writes, and its screen never quiets. + */ +const COMPOSER_MARKER_READINESS_AGENTS: ReadonlySet = new Set(['zcode', 'dsh', 'grok']) + +/** + * Composer-marker agents that can also render inline, where the marker's alternate-screen anchor + * never arrives (`grok-inline-startup-pty-trace.ts`). The quiet window after bracketed paste stays + * armed for them as the floor, as the desktop's own paste and worktree.create's draft paste use it. + */ +const INLINE_RENDERING_COMPOSER_AGENTS: ReadonlySet = new Set(['grok']) + +export type LaunchedAgentReadinessRuntime = Pick< + OrcaRuntimeService, + 'waitForTerminal' | 'waitForFreshWorkerComposer' +> + +/** + * What keeps a ready signal from counting: a startup dialog in the pane's text or on its screen, or + * Codex 0.157's provisional `model: loading` header, which discards input typed behind it. + */ +export function readFreshComposerHold( + waitText: string, + screenLines: readonly string[] | null +): 'dialog' | 'starting' | null { + if ( + detectTerminalWaitBlockedReason(waitText) !== null || + (screenLines !== null && detectTerminalWaitBlockedReason(screenLines.join('\n')) !== null) + ) { + return 'dialog' + } + return isCodexProvisionalStartupText(waitText.toLowerCase()) ? 'starting' : null +} + +/** + * A fresh orchestration worker's first dispatch. Marker agents wait for their marker, as a launch + * does; every other agent takes main's cue for this path, `tui-idle`, which settles on the agent's + * own ready title instead of a quiet window after it. That dispatch waits for the render to settle + * before Enter, so it never needed the desktop paste's later cue. + */ +export function waitForWorkerStartComposer( + runtime: LaunchedAgentReadinessRuntime, + handle: string, + agent: TuiAgent, + timeoutMs: number +): Promise { + if (COMPOSER_MARKER_READINESS_AGENTS.has(agent)) { + return waitForComposerMarker(runtime, handle, agent, timeoutMs) + } + return runtime.waitForTerminal(handle, { + condition: 'tui-idle', + timeoutMs, + launchReadiness: true + }) +} + +function waitForComposerMarker( + runtime: LaunchedAgentReadinessRuntime, + handle: string, + agent: TuiAgent, + timeoutMs: number +): Promise { + return runtime.waitForFreshWorkerComposer(handle, agent, timeoutMs, { + requireComposerMarker: !INLINE_RENDERING_COMPOSER_AGENTS.has(agent) + }) +} + +/** + * The composer signal's wait, then — if it did not settle within the desktop paste's budget, or a + * startup dialog is up — the `tui-idle` wait for what is left of `timeoutMs`, whose result says + * ready, blocked by a dialog, or not ready. Throws when that runs out too. + */ +export async function waitForLaunchedAgentComposer( + runtime: LaunchedAgentReadinessRuntime, + handle: string, + agent: TuiAgent, + timeoutMs: number +): Promise { + if (COMPOSER_MARKER_READINESS_AGENTS.has(agent)) { + return waitForComposerMarker(runtime, handle, agent, timeoutMs) + } + const startedAt = Date.now() + try { + return await runtime.waitForFreshWorkerComposer( + handle, + agent, + Math.min(timeoutMs, resolveDraftPasteReadyTimeoutMs(agent)), + { requireComposerMarker: false, stopOnDialog: true } + ) + } catch { + // Out of budget, a dialog up, or a pane it could not read: the idle wait answers each, and + // throws for a handle that is gone. + } + // Checked, where the desktop pasted blind: an agent that shows no readiness keeps its text. + return runtime.waitForTerminal(handle, { + condition: 'tui-idle', + timeoutMs: Math.max(1, timeoutMs - (Date.now() - startedAt)), + launchReadiness: true + }) +} diff --git a/src/main/runtime/launched-agent-shell-foreground.ts b/src/main/runtime/launched-agent-shell-foreground.ts new file mode 100644 index 00000000000..9b5c20bb7f1 --- /dev/null +++ b/src/main/runtime/launched-agent-shell-foreground.ts @@ -0,0 +1,92 @@ +import { + isExpectedAgentProcess, + recognizeAgentProcess +} from '../../shared/agent-process-recognition' +import { isShellProcess } from '../../shared/shell-process-detection' +import type { TuiAgent } from '../../shared/tui-agent' +import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' +import type { RuntimePtyController } from './runtime-pty-controller-contract' + +/** A login shell is reported as `-zsh`. */ +function isLaunchShell(processName: string): boolean { + return isShellProcess(processName.replace(/^-/, '')) +} + +/** + * Whether the pane's own shell, rather than the agent a launch started, is proven to be in the + * terminal's foreground: the launch line has not run yet, or the agent exited. A read that fails or + * cannot tell proves nothing, so it never delays a launch. + * + * On macOS and Linux identity decides first: the host's process inspection reports the terminal's + * foreground process group, and the pane's shell can be in front only when that group is the + * shell's own. A wrapper script's bash, or a conda hook, runs as its own job, so it is not the + * pane's shell even though it is named like one. A fresh read must then also name a shell. + * + * Windows has no foreground process group, and its scan names the pane's shell for any agent it + * cannot recognize (an npm agent running as `node.exe`), so it takes only the host's + * shell-foreground check (the shell alone in the pane's job). A daemon pane answers that from its + * recovery state, which a plain exit leaves unset, and an SSH pane has none, so there it proves + * nothing. + * + * Why no check for a non-shell name other than the agent's own: for its first 5 s the daemon's + * cached read names the launch agent whenever a shell is in front (measured on a zsh pane: cached + * `copilot`, scan `zsh`), so any other non-shell name it gives is the process itself. macOS + * reports the native Claude by its version (`2.1.258`), and a runtime such as node can front an + * agent. + */ +export async function isShellInFrontOfLaunchedAgent( + controller: Pick< + RuntimePtyController, + | 'getForegroundProcess' + | 'inspectProcess' + | 'confirmForegroundProcess' + | 'confirmShellForeground' + > | null, + host: { remote: boolean; windows: boolean }, + ptyId: string, + agent: TuiAgent +): Promise { + if (!controller) { + return false + } + try { + const cached = await controller.getForegroundProcess(ptyId) + if (cached && !isLaunchShell(cached) && recognizeAgentProcess(cached)?.agent !== agent) { + return false + } + if (host.windows) { + // An SSH pane has no such check, so on a Windows relay nothing proves a shell. + return (await controller.confirmShellForeground?.(ptyId)) ?? false + } + const evidence = (await controller.inspectProcess?.(ptyId))?.foregroundProcessEvidence + if ( + evidence && + !( + evidence.verdict === 'live' && + evidence.fence.platform === 'posix' && + evidence.fence.foregroundPgid === evidence.fence.shellPid + ) + ) { + return false + } + // Why the name too: a shell without job control runs its commands in its own group. + // A host that predates the evidence (an older daemon or relay, or a pane outside the daemon) + // has only the name, which reads a wrapper script's bash as the shell. A relay's cached name is + // read live, and it has no scan. + const foreground = + !host.remote && controller.confirmForegroundProcess + ? await controller.confirmForegroundProcess(ptyId) + : cached + return isShellName(foreground, agent) + } catch { + return false + } +} + +function isShellName(processName: string | null, agent: TuiAgent): boolean { + return ( + !!processName && + isLaunchShell(processName) && + !isExpectedAgentProcess(processName, TUI_AGENT_CONFIG[agent].expectedProcess) + ) +} diff --git a/src/main/runtime/launched-agent-shell-handoff-readiness.test.ts b/src/main/runtime/launched-agent-shell-handoff-readiness.test.ts new file mode 100644 index 00000000000..cace85e2859 --- /dev/null +++ b/src/main/runtime/launched-agent-shell-handoff-readiness.test.ts @@ -0,0 +1,249 @@ +/** + * A launched agent's ready signal must come from the agent, not from the shell that ran it. + * + * Replayed from captures: zsh turns bracketed paste on at its prompt and off when it runs the + * typed command, then on again at its next prompt (`zsh-prompt-runs-command.txt`); Claude turns it + * on when it draws (`claude-dialog-trust-workspace-answered.txt`). Read as the agent's, the shell's + * prompt settled the quiet window before Claude had drawn anything, and after an agent that exited. + */ + +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { TuiAgent } from '../../shared/tui-agent' +import { waitForWorktreeStartupDraft } from './runtime-worktree-startup-readiness' + +const QUIET_WINDOW_MS = 1_500 +const SHELL_HANDOFF = '\x1b[?2004l' + +function readFixture(name: string): string { + return readFileSync(join(__dirname, '__fixtures__', `${name}.txt`), 'utf8') +} + +/** The shell's prompt and the launch line it runs, up to where it hands the terminal over. */ +function shellRunsLaunchLine(): string { + const zsh = readFixture('zsh-prompt-runs-command') + const handoff = zsh.indexOf(SHELL_HANDOFF) + return zsh.slice(0, zsh.indexOf('\n', handoff) + 1) +} + +/** The prompt the shell draws again once the command it ran has exited. */ +function shellPromptAfterCommandExits(): string { + const zsh = readFixture('zsh-prompt-runs-command') + return zsh.slice(zsh.indexOf('\n', zsh.indexOf(SHELL_HANDOFF)) + 1) +} + +function launchedPane( + options: { + agent?: TuiAgent + /** Whether the check proves a shell for this foreground name; default: only `zsh` does. */ + provesShell?: (name: string) => boolean + checkDelayMs?: number + } = {} +) { + let listener = (_data: string): void => {} + let foreground = 'zsh' + let reads = 0 + const host = { + getPtyId: () => 'pty-1', + getForegroundProcess: async () => foreground, + subscribeToData: (_ptyId: string, onData: (data: string) => void) => { + listener = onData + return () => { + listener = () => {} + } + }, + readRecentOutput: () => undefined, + write: vi.fn() + } + const provesShell = options.provesShell ?? ((name: string) => name === 'zsh') + const ready = waitForWorktreeStartupDraft(host, 'term-1', options.agent ?? 'claude', { + timeoutMs: 8_000, + isShellInFront: async () => { + reads += 1 + const name = foreground + if (options.checkDelayMs) { + await new Promise((resolve) => setTimeout(resolve, options.checkDelayMs)) + } + return provesShell(name) + } + }) + const settled = vi.fn() + void ready.then(settled) + return { + settled, + get reads() { + return reads + }, + emit: (data: string) => listener(data), + setForeground: (name: string) => { + foreground = name + } + } +} + +describe('a launched agent’s ready signal, after the shell that ran it', () => { + afterEach(() => vi.useRealTimers()) + + it('waits for the agent’s own bracketed paste when the agent starts well after the shell’s prompt', async () => { + vi.useFakeTimers() + const pane = launchedPane() + const launchLine = shellRunsLaunchLine() + // Presence precondition: the shell enabled bracketed paste before it handed the terminal over. + expect(launchLine).toContain('\x1b[?2004h') + + pane.emit(launchLine) + // The agent's process takes the terminal, then draws nothing for 3 s while it starts. + pane.setForeground('claude') + await vi.advanceTimersByTimeAsync(3_000) + expect(pane.settled).not.toHaveBeenCalled() + + pane.emit(readFixture('claude-dialog-trust-workspace-answered')) + await vi.advanceTimersByTimeAsync(QUIET_WINDOW_MS - 100) + expect(pane.settled).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(200) + expect(pane.settled).toHaveBeenCalledWith('pty-1') + }) + + it('never reads the shell’s next prompt as the agent’s composer after the agent exits', async () => { + vi.useFakeTimers() + const pane = launchedPane() + + pane.emit(shellRunsLaunchLine()) + // The agent exits at startup: its shell is back in the foreground and draws its prompt. + pane.emit('claude: failed to start\r\n') + pane.emit(shellPromptAfterCommandExits()) + await vi.advanceTimersByTimeAsync(8_000) + + expect(pane.settled).toHaveBeenCalledWith(null) + // The refused signal is dropped, not retried until the budget ends. + expect(pane.reads).toBe(1) + }) + + // Why: a quiet agent never signals again, so only a proven shell may drop its signal. Requiring + // proof of the agent left Claude idle until the 8 s fallback wherever the read could not answer. + it('settles on the signal when the check cannot prove a shell, with one read', async () => { + vi.useFakeTimers() + const pane = launchedPane({ provesShell: () => false }) + + pane.emit(shellRunsLaunchLine()) + pane.setForeground('2.1.285') + pane.emit(readFixture('claude-dialog-trust-workspace-answered')) + await vi.advanceTimersByTimeAsync(QUIET_WINDOW_MS) + + expect(pane.settled).toHaveBeenCalledWith('pty-1') + expect(pane.reads).toBe(1) + }) + + it('settles a Claude that is in front the moment its signal fires, with one check', async () => { + vi.useFakeTimers() + const pane = launchedPane() + + pane.emit(shellRunsLaunchLine()) + pane.setForeground('2.1.285') + pane.emit(readFixture('claude-dialog-trust-workspace-answered')) + await vi.advanceTimersByTimeAsync(QUIET_WINDOW_MS) + + expect(pane.settled).toHaveBeenCalledWith('pty-1') + expect(pane.reads).toBe(1) + }) + + it('reads nothing before the signal when the shell never enables bracketed paste', async () => { + vi.useFakeTimers() + const pane = launchedPane() + + pane.emit('$ claude\r\n') + pane.setForeground('2.1.285') + await vi.advanceTimersByTimeAsync(300) + expect(pane.reads).toBe(0) + pane.emit(readFixture('claude-dialog-trust-workspace-answered')) + await vi.advanceTimersByTimeAsync(QUIET_WINDOW_MS) + + expect(pane.settled).toHaveBeenCalledWith('pty-1') + expect(pane.reads).toBe(1) + }) + + // A startup file's subprocess (a conda or pyenv hook) in front before the prompt proves nothing + // about the agent, so the shell's prompt after it must not settle the wait. + it('does not settle while Claude is still silent after a startup-file subprocess ran', async () => { + vi.useFakeTimers() + const pane = launchedPane() + + pane.setForeground('python3.11') + await vi.advanceTimersByTimeAsync(300) + pane.setForeground('zsh') + pane.emit(shellRunsLaunchLine()) + pane.setForeground('2.1.285') + await vi.advanceTimersByTimeAsync(3_000) + + expect(pane.settled).not.toHaveBeenCalled() + }) + + it('counts only what follows the last hand-off when the shell draws two prompts first', async () => { + vi.useFakeTimers() + const pane = launchedPane({ provesShell: () => false }) + + // A first command runs and the prompt comes back, as separate chunks, before the launch line. + pane.emit(shellRunsLaunchLine()) + pane.emit(shellPromptAfterCommandExits()) + await vi.advanceTimersByTimeAsync(100) + pane.emit(`claude${SHELL_HANDOFF}\r\r\n`) + pane.setForeground('2.1.285') + await vi.advanceTimersByTimeAsync(3_000) + expect(pane.settled).not.toHaveBeenCalled() + + pane.emit(readFixture('claude-dialog-trust-workspace-answered')) + await vi.advanceTimersByTimeAsync(QUIET_WINDOW_MS) + expect(pane.settled).toHaveBeenCalledWith('pty-1') + }) + + it('sees a hand-off split across two chunks', async () => { + vi.useFakeTimers() + const pane = launchedPane({ provesShell: () => false }) + const launchLine = shellRunsLaunchLine() + const cut = launchLine.indexOf(SHELL_HANDOFF) + 4 + + pane.emit(launchLine.slice(0, cut)) + pane.emit(launchLine.slice(cut)) + pane.setForeground('2.1.285') + await vi.advanceTimersByTimeAsync(3_000) + expect(pane.settled).not.toHaveBeenCalled() + + pane.emit(readFixture('claude-dialog-trust-workspace-answered')) + await vi.advanceTimersByTimeAsync(QUIET_WINDOW_MS) + expect(pane.settled).toHaveBeenCalledWith('pty-1') + }) + + it('never settles a shell signal whose check was still running when the shell handed over', async () => { + vi.useFakeTimers() + // The check answers after the launch line ran, when the agent is in front. + const pane = launchedPane({ provesShell: () => false, checkDelayMs: 400 }) + const zsh = readFixture('zsh-prompt-runs-command') + const handoff = zsh.indexOf(SHELL_HANDOFF) + + // The prompt with its launch line typed, then quiet long enough to fire on the shell's 2004. + pane.emit(zsh.slice(0, handoff)) + await vi.advanceTimersByTimeAsync(QUIET_WINDOW_MS + 100) + expect(pane.reads).toBe(1) + pane.emit(zsh.slice(handoff, zsh.indexOf('\n', handoff) + 1)) + pane.setForeground('2.1.285') + await vi.advanceTimersByTimeAsync(3_000) + + expect(pane.settled).not.toHaveBeenCalled() + }) + + it('settles Codex 0.157 on its marker although it turns bracketed paste off and on as it starts', async () => { + vi.useFakeTimers() + const codex = readFixture('codex-0157-plain-ready') + // Presence precondition: the capture hands bracketed paste off mid-startup. + expect(codex).toContain(SHELL_HANDOFF) + const pane = launchedPane({ agent: 'codex' }) + + pane.emit(shellRunsLaunchLine()) + pane.setForeground('codex') + pane.emit(codex) + await vi.advanceTimersByTimeAsync(50) + + expect(pane.settled).toHaveBeenCalledWith('pty-1') + }) +}) diff --git a/src/main/runtime/launched-agent-write-guard.test.ts b/src/main/runtime/launched-agent-write-guard.test.ts new file mode 100644 index 00000000000..4af5f64c3b7 --- /dev/null +++ b/src/main/runtime/launched-agent-write-guard.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from 'vitest' +import { + createLaunchedAgentWriteGuard, + type LaunchedAgentWriteGuardRuntime +} from './launched-agent-write-guard' + +function guardHarness(shellInFront: boolean[]) { + const answers = [...shellInFront] + let listener: ((data: string) => void) | null = null + const unsubscribe = vi.fn(() => { + listener = null + }) + const runtime: LaunchedAgentWriteGuardRuntime = { + isLaunchShellInFront: vi.fn(async () => answers.shift() ?? false), + subscribeToTerminalData: vi.fn((_ptyId: string, next: (data: string) => void) => { + listener = next + return unsubscribe + }) + } + const guard = createLaunchedAgentWriteGuard(runtime, 'claude') + return { runtime, guard, unsubscribe, emit: (data: string) => listener?.(data) } +} + +describe('the check before each write of a launch prompt', () => { + it('reuses a read that found no shell for the writes after it', async () => { + const { runtime, guard } = guardHarness([false]) + + await guard.beforeWrite('pty-1') + // The agent's own output after the paste says nothing about a shell. + await guard.beforeWrite('pty-1') + await guard.beforeWrite('pty-1') + + expect(runtime.isLaunchShellInFront).toHaveBeenCalledTimes(1) + }) + + it.each([ + ['a shell turning bracketed paste on', '$ \x1b[?2004h'], + ['a shell integration prompt mark', '\x1b]133;D;1\x07\x1b]133;A\x07$ '], + ['a mark split across two chunks', ['\x1b[?20', '04h']] + ] as const)('reads again after %s, and refuses a shell', async (_label, output) => { + const { runtime, guard, emit } = guardHarness([false, true]) + + await guard.beforeWrite('pty-1') + for (const chunk of typeof output === 'string' ? [output] : output) { + emit(chunk) + } + + await expect(guard.beforeWrite('pty-1')).rejects.toThrow('agent_not_in_foreground') + expect(runtime.isLaunchShellInFront).toHaveBeenCalledTimes(2) + }) + + it('catches a shell that returns while the first read is still running', async () => { + const { runtime, guard, emit } = guardHarness([]) + vi.mocked(runtime.isLaunchShellInFront) + .mockImplementationOnce(async () => { + emit('\x1b[?2004h') + return false + }) + .mockResolvedValueOnce(true) + + await guard.beforeWrite('pty-1') + + await expect(guard.beforeWrite('pty-1')).rejects.toThrow('agent_not_in_foreground') + }) + + it('stops watching when it refuses and when it is disposed', async () => { + const refused = guardHarness([true]) + await expect(refused.guard.beforeWrite('pty-1')).rejects.toThrow('agent_not_in_foreground') + expect(refused.unsubscribe).toHaveBeenCalledTimes(1) + + const cleared = guardHarness([false]) + await cleared.guard.beforeWrite('pty-1') + cleared.guard.dispose() + expect(cleared.unsubscribe).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/runtime/launched-agent-write-guard.ts b/src/main/runtime/launched-agent-write-guard.ts new file mode 100644 index 00000000000..80f0bf71d6b --- /dev/null +++ b/src/main/runtime/launched-agent-write-guard.ts @@ -0,0 +1,58 @@ +import type { TuiAgent } from '../../shared/tui-agent' +import type { OrcaRuntimeService } from './orca-runtime' + +/** A shell back at its prompt turns bracketed paste on, and Orca's shell integration marks it. */ +const SHELL_RETURN_MARKERS = ['\x1b[?2004', '\x1b]133;'] as const +const MARKER_CARRY_CHARS = Math.max(...SHELL_RETURN_MARKERS.map((marker) => marker.length)) - 1 + +export type LaunchedAgentWriteGuardRuntime = Pick< + OrcaRuntimeService, + 'isLaunchShellInFront' | 'subscribeToTerminalData' +> + +/** + * The check before each write of a launch prompt: the paste, its Enter, and Codex's second Enter. + * A shell proven in front refuses the write. Once a read finds none, later writes reuse it until the + * terminal shows a shell coming back to its prompt, so Enter follows the paste on the desktop's + * timing instead of waiting out another process read. + */ +export function createLaunchedAgentWriteGuard( + runtime: LaunchedAgentWriteGuardRuntime, + agent: TuiAgent +): { beforeWrite: (ptyId: string) => Promise; dispose: () => void } { + let cleared: { ptyId: string; shellMayHaveReturned: boolean; unsubscribe: () => void } | null = + null + const dispose = (): void => { + cleared?.unsubscribe() + cleared = null + } + const beforeWrite = async (ptyId: string): Promise => { + if (cleared?.ptyId === ptyId && !cleared.shellMayHaveReturned) { + return + } + dispose() + let carry = '' + const watch = { ptyId, shellMayHaveReturned: false, unsubscribe: (): void => {} } + // Subscribed before the read, so a shell that returns while it runs is not missed. + watch.unsubscribe = runtime.subscribeToTerminalData(ptyId, (data) => { + const window = carry + data + carry = window.slice(-MARKER_CARRY_CHARS) + if (SHELL_RETURN_MARKERS.some((marker) => window.includes(marker))) { + watch.shellMayHaveReturned = true + } + }) + let shellInFront: boolean + try { + shellInFront = await runtime.isLaunchShellInFront(ptyId, agent) + } catch (error) { + watch.unsubscribe() + throw error + } + if (shellInFront) { + watch.unsubscribe() + throw new Error('agent_not_in_foreground') + } + cleared = watch + } + return { beforeWrite, dispose } +} diff --git a/src/main/runtime/orca-runtime-activate-managed-worktree.ts b/src/main/runtime/orca-runtime-activate-managed-worktree.ts index 1629c529921..cecf3223b10 100644 --- a/src/main/runtime/orca-runtime-activate-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-activate-managed-worktree.ts @@ -5,6 +5,7 @@ import { navigationTargetsClients, navigationTargetsHost } from '../../shared/ru import { getRepoExecutionHostId } from '../../shared/execution-host' import type { Repo } from '../../shared/repo-types' import type { TuiAgent } from '../../shared/tui-agent' +import type { RuntimeTerminalWait } from '../../shared/runtime-types' import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' import type { WorktreeStartupDraftPaste, @@ -30,6 +31,9 @@ import { } from './runtime-worktree-startup-readiness' import type { CreateWorktreeResult } from '../../shared/worktree/create-types' import { provisionWorktreeTerminals } from './runtime-worktree-terminal-provisioning' +import { readFreshComposerHold } from './launched-agent-composer-readiness' +import { buildTerminalWaitText } from './terminal-wait-tail-state' +import { isShellInFrontOfLaunchedAgent } from './launched-agent-shell-foreground' export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListManagedWorktrees { async activateManagedWorktree( @@ -144,6 +148,7 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM launchInputs?: { agentArgs?: string | null launchSource?: string + onPromptCarry?: (carried: boolean) => void } ): { agent: TuiAgent; startup: WorktreeStartupLaunch; followup?: WorktreeStartupFollowup } { if (!this.store) { @@ -156,6 +161,7 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM ...(launchPreferences ? { launchPreferences } : {}), ...(launchInputs?.agentArgs !== undefined ? { agentArgs: launchInputs.agentArgs } : {}), ...(launchInputs?.launchSource ? { launchSource: launchInputs.launchSource } : {}), + ...(launchInputs?.onPromptCarry ? { onPromptCarry: launchInputs.onPromptCarry } : {}), settings: this.store.getSettings(), getLaunchPlatform: () => this.getAgentLaunchPlatformForRepo(repo), toSessionOptions: (preferences) => this.toAgentSessionOptions(preferences) @@ -177,27 +183,66 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM pasteWorktreeStartupDraftWhenReady(this.getWorktreeStartupReadinessHost(), handle, draft) } - /** Only for a newly launched worker, before its first dispatch input. */ + /** + * Only for a newly launched agent, before its first input. Settles when the agent's composer + * signal fires on a screen with no startup dialog and no Codex provisional header; with + * `stopOnDialog`, a dialog ends the wait so the caller's idle wait can report it. + */ async waitForFreshWorkerComposer( handle: string, agent: TuiAgent, - timeoutMs: number - ): Promise { + timeoutMs: number, + { + requireComposerMarker = true, + stopOnDialog = false + }: { requireComposerMarker?: boolean; stopOnDialog?: boolean } = {} + ): Promise { const initialPtyId = this.getLivePtyForHandle(handle)?.pty.ptyId ?? this.getLiveLeafForHandle(handle).leaf.ptyId + const stop = new AbortController() const ptyId = await waitForWorktreeStartupDraft( { ...this.getWorktreeStartupReadinessHost(), getPtyId: () => initialPtyId }, handle, agent, - { timeoutMs, requireComposerMarker: true } + { + timeoutMs, + requireComposerMarker, + signal: stop.signal, + isShellInFront: (ownerPtyId) => this.isLaunchShellInFront(ownerPtyId, agent), + accept: (readyPtyId) => { + const pty = this.ptysById.get(readyPtyId) + const hold = pty + ? readFreshComposerHold( + buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview), + this.readLiveTerminalScreenLines(readyPtyId) + ) + : null + if (hold === 'dialog' && stopOnDialog) { + stop.abort() + } + return hold === null + } + } ) if (!ptyId) { - throw new Error('timeout') + throw new Error(stop.signal.aborted ? 'agent_startup_dialog' : 'timeout') } this.assertLiveTerminalHandleTargetsPty(handle, ptyId) if (!this.ptysById.get(ptyId)?.connected) { throw new Error('terminal_handle_stale') } + return this.buildTuiIdleProbeResult(handle, null) + } + + /** Whether a shell, not the agent a launch started, is proven to be in the terminal's foreground. */ + isLaunchShellInFront(ptyId: string, agent: TuiAgent): Promise { + const pty = this.ptysById.get(ptyId) + return isShellInFrontOfLaunchedAgent( + this.ptyController, + { remote: !!pty?.connectionId, windows: this.pathFlavorForPty(pty) === 'win32' }, + ptyId, + agent + ) } protected sendStartupFollowupWhenReady(handle: string, followup: WorktreeStartupFollowup): void { diff --git a/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts b/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts index 3c9c8b7eab9..6eb3e7879f8 100644 --- a/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts +++ b/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts @@ -1,5 +1,6 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { OrcaRuntimeWithResolveTerminalPane } from './orca-runtime-resolve-terminal-pane' +import { wrapTerminalBracketedPasteText } from '../../shared/terminal-bracketed-paste-text' import { PROVEN_ABSENT_LEAF_PTY_TTL_MS } from './orca-runtime-core' import { pruneExpiredProvenAbsentLeafPtyVerdicts } from './proven-absent-leaf-pty-verdicts' import type { RuntimeTerminalSend } from '../../shared/runtime-types' @@ -158,6 +159,10 @@ export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithReso ): Promise { // Why the consuming agent: the foreground process reads the bytes; launchAgent covers startup. const payloadFor = (ptyId: string): string => { + // Why: a launch prompt replaced the desktop's draft paste, so it sends that paste's bytes. + if (options.inputKind === 'launch') { + return wrapTerminalBracketedPasteText(prompt) + } const pty = this.ptysById.get(ptyId) const agent = pty?.foregroundAgent ?? pty?.launchAgent return buildAgentPromptPasteBytes( diff --git a/src/main/runtime/orca-runtime-create-agent-prompt-render-gate.ts b/src/main/runtime/orca-runtime-create-agent-prompt-render-gate.ts index 6afde046830..2d537606aa4 100644 --- a/src/main/runtime/orca-runtime-create-agent-prompt-render-gate.ts +++ b/src/main/runtime/orca-runtime-create-agent-prompt-render-gate.ts @@ -134,6 +134,7 @@ export class OrcaRuntimeWithCreateAgentPromptRenderGate extends OrcaRuntimeWithW condition?: RuntimeTerminalWaitCondition timeoutMs?: number signal?: AbortSignal + launchReadiness?: boolean } ): Promise { return this.terminalWait.wait(handle, options) diff --git a/src/main/runtime/orca-runtime-create-managed-worktree.ts b/src/main/runtime/orca-runtime-create-managed-worktree.ts index d0885a1dfe1..fce08f1c053 100644 --- a/src/main/runtime/orca-runtime-create-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-create-managed-worktree.ts @@ -62,7 +62,8 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork args.startupLaunchPreferences, { ...(args.startupAgentArgs !== undefined ? { agentArgs: args.startupAgentArgs } : {}), - ...(args.startupLaunchSource ? { launchSource: args.startupLaunchSource } : {}) + ...(args.startupLaunchSource ? { launchSource: args.startupLaunchSource } : {}), + ...(args.onStartupPromptCarry ? { onPromptCarry: args.onStartupPromptCarry } : {}) } ) : null diff --git a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts index 0aeacdc0e99..2ec280f056b 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -19,7 +19,8 @@ import { terminalShellOverrideRefusal } from './terminal-shell-override-host-sup import { resolveTerminalStartupCwd } from '../../shared/terminal-startup-cwd' import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtime-resolution' import { resolveBareAgentLaunchCommand } from './runtime-agent-launch-resolution' -import { buildAgentStartupPlan } from '../../shared/tui-agent-startup' +import { agentPromptRidesLaunchCommand } from '../../shared/tui-agent-startup' +import { planStartupWithPromptCandidate } from '../../shared/startup-line-prompt-carry' import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry' @@ -215,8 +216,13 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith return opts } - const startupPlan = buildAgentStartupPlan({ - ...resolveAgentStartupPlanInputs({ + // A prompt this launch command cannot carry has nowhere to go from here — the create returns + // options, not a live PTY — so refuse rather than spawn the agent and drop the text. + if (opts.startupPrompt && !agentPromptRidesLaunchCommand(agent)) { + throw new Error(`Agent ${agent} does not take a startup prompt on its launch command.`) + } + const { plan: startupPlan, promptCarried } = planStartupWithPromptCandidate( + resolveAgentStartupPlanInputs({ agent, settings, platform, @@ -226,9 +232,8 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith windowsShellOverride: opts.shellOverride, sessionOptions: this.toAgentSessionOptions(opts.launchPreferences) }), - prompt: opts.startupPrompt ?? '', - allowEmptyPromptLaunch: true - }) + opts.startupPrompt ?? '' + ) if (!startupPlan) { // Why: an explicit agent that yields no plan would otherwise spawn a bare // shell that never reaches agent readiness. @@ -237,10 +242,8 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith } return opts } - // A prompt this launch command cannot carry has nowhere to go from here — the create returns - // options, not a live PTY — so refuse rather than spawn the agent and drop the text. - if (opts.startupPrompt && startupPlan.followupPrompt) { - throw new Error(`Agent ${agent} does not take a startup prompt on its launch command.`) + if (opts.startupPrompt) { + opts.onStartupPromptCarry?.(promptCarried) } return { diff --git a/src/main/runtime/orca-runtime-tests/agent-launch-prompt-submit.spec.ts b/src/main/runtime/orca-runtime-tests/agent-launch-prompt-submit.spec.ts new file mode 100644 index 00000000000..3cfc80e6040 --- /dev/null +++ b/src/main/runtime/orca-runtime-tests/agent-launch-prompt-submit.spec.ts @@ -0,0 +1,126 @@ +import { describe, expect, it, vi } from 'vitest' +import { + AGENT_PROMPT_BRACKETED_PASTE_END, + OrcaRuntimeService, + acknowledgeAgentPromptSubmit +} from '../orca-runtime-test-mocks.spec' +import { TEST_WORKTREE_PATH, store } from '../orca-runtime-test-fixtures.spec' +import { wrapTerminalBracketedPasteText } from '../../../shared/terminal-bracketed-paste-text' + +// A launch's first prompt goes in right after the caller saw the agent's composer accept input, as +// the desktop's own draft paste did: Enter one turn after the paste, not after the render settles. +async function launchedAgentPane(agent: 'claude' | 'codex' | 'qwen-code') { + const writes: { data: string; at: number }[] = [] + const runtime = new OrcaRuntimeService(store) + const startedAt = Date.now() + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: (_ptyId, data) => { + writes.push({ data, at: Date.now() - startedAt }) + if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) { + // A live Claude keeps repainting after a paste, so its render never settles. + runtime.onPtyData('pty-bg', '\x1b[?25h', Date.now()) + for (let delay = 500; delay <= 9_000; delay += 500) { + setTimeout(() => runtime.onPtyData('pty-bg', `frame ${delay}`, Date.now()), delay) + } + } + acknowledgeAgentPromptSubmit(runtime, 'pty-bg', data) + return true + }, + kill: () => true, + getForegroundProcess: async () => null + }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + launchAgent: agent + }) + return { runtime, handle, writes } +} + +describe('a launch prompt into a composer the caller saw ready', () => { + it('pastes the same bytes the desktop draft paste sent for the prompt', async () => { + vi.useFakeTimers() + try { + const { runtime, handle, writes } = await launchedAgentPane('claude') + const prompt = 'Explain this commit:\n- first line\r\n- second line with \x1b[31m colour' + + const send = runtime.sendTerminalAgentPrompt(handle, prompt, { + inputKind: 'launch', + composerReady: true + }) + await vi.advanceTimersByTimeAsync(100) + await send + + const pasted = writes + .map((write) => write.data) + .filter((data) => data !== '\r') + .join('') + expect(pasted).toBe(wrapTerminalBracketedPasteText(prompt)) + // Main's draft paste: every line break as CR, and an embedded ESC made inert. + expect(pasted).toBe( + '\x1b[200~Explain this commit:\r- first line\r- second line with \u241b[31m colour\x1b[201~' + ) + } finally { + vi.useRealTimers() + } + }) + + it('submits one turn after the paste instead of waiting out the render cap', async () => { + vi.useFakeTimers() + try { + const { runtime, handle, writes } = await launchedAgentPane('claude') + + const send = runtime.sendTerminalAgentPrompt(handle, 'fix the checks\nlog tail', { + inputKind: 'launch', + composerReady: true + }) + await vi.advanceTimersByTimeAsync(49) + expect(writes.map((write) => write.data)).not.toContain('\r') + await vi.advanceTimersByTimeAsync(2) + await send + // One Enter: Claude takes no second one. + expect(writes.filter((write) => write.data === '\r')).toHaveLength(1) + } finally { + vi.useRealTimers() + } + }) + + it.each(['codex', 'qwen-code'] as const)( + 'sends %s its second Enter after its retry delay, as the desktop paste did', + async (agent) => { + vi.useFakeTimers() + try { + const { runtime, handle, writes } = await launchedAgentPane(agent) + + const send = runtime.sendTerminalAgentPrompt(handle, 'explain this commit', { + inputKind: 'launch', + composerReady: true + }) + await vi.advanceTimersByTimeAsync(51) + expect(writes.filter((write) => write.data === '\r')).toHaveLength(1) + await vi.advanceTimersByTimeAsync(1_200) + await send + expect(writes.filter((write) => write.data === '\r')).toHaveLength(2) + } finally { + vi.useRealTimers() + } + } + ) + + it('keeps a prompt into a running agent behind the render settle, as before', async () => { + vi.useFakeTimers() + try { + const { runtime, handle, writes } = await launchedAgentPane('claude') + + const send = runtime.sendTerminalAgentPrompt(handle, 'fix the checks', { + inputKind: 'driving' + }) + await vi.advanceTimersByTimeAsync(7_000) + expect(writes.map((write) => write.data)).not.toContain('\r') + await vi.advanceTimersByTimeAsync(2_000) + await send + expect(writes.filter((write) => write.data === '\r')).toHaveLength(1) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts b/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts index 1597084eee1..4f4839678ac 100644 --- a/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts +++ b/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts @@ -8,6 +8,7 @@ import { waitForAgentPromptPromise } from './orca-runtime-core' import { + AGENT_PROMPT_POST_PASTE_SUBMIT_DELAY_MS, AGENT_PROMPT_SUBMIT, agentPromptSubmitJoinsPasteFrame, getAgentPromptSubmitDelayMs, @@ -20,6 +21,7 @@ import { resolveAgentPromptEffectTimeoutMs, verifyAgentPromptSubmission } from './agent-prompt-submission-verification' +import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission { protected async writeTerminalAgentPrompt( @@ -43,7 +45,11 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso ) const pasteByteLength = Buffer.byteLength(pastePayload, 'utf8') const pasteIngestMs = getTerminalPasteIngestMs(writeHostPlatform, pasteByteLength) - const renderGate = this.createAgentPromptRenderGate(ptyId, pasteIngestMs) + // Why no gate for a ready composer: a live Claude never settles it, so Enter always waited out + // its 8 s cap, where the desktop's own paste submitted in about 2 s. + const renderGate = options.composerReady + ? null + : this.createAgentPromptRenderGate(ptyId, pasteIngestMs) const waitTextCache: AgentPromptWaitTextCache = {} const preSubmitBaseline = submitWithPaste ? this.getAgentPromptActivity(handle, ptyId, waitTextCache) @@ -79,6 +85,11 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso } finally { renderGate.dispose() } + } else if (options.composerReady) { + await waitForAgentPromptDelay( + AGENT_PROMPT_POST_PASTE_SUBMIT_DELAY_MS + pasteIngestMs, + options.signal + ) } else { const agent = this.getPtyAgent(ptyId) const submitDelayMs = options.promptForSchedule @@ -107,6 +118,10 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso throw new Error(options.suffixFailureError ?? 'terminal_not_writable') } } + const submits = + options.composerReady && !submitWithPaste + ? 1 + (await this.resubmitAgentPromptAfterRetryDelay(ptyId, generation, options)) + : 1 const effectTimeoutMs = resolveAgentPromptEffectTimeoutMs(this.getPtyAgent(ptyId)) if (!options.acceptQueued || !options.requestId) { await verifyAgentPromptSubmission({ @@ -115,7 +130,7 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso timeoutMs: effectTimeoutMs, signal: options.signal }) - return { submits: 1 } + return { submits } } const binding = this.getTerminalPromptRequestBinding(handle) const foregroundAgent = this.ptysById.get(ptyId)?.foregroundAgent @@ -147,7 +162,7 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso // receipt; they must not fail a Dispatch merely because Orca cannot prove // submission through hooks. if (!settlementAgent) { - return { submits: 1, prompt: inputAccepted } + return { submits, prompt: inputAccepted } } this.registerAgentPromptRequest( ptyId, @@ -175,7 +190,7 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso }) this.forgetAgentPromptRequest(ptyId, generation, options.requestId) return { - submits: 1, + submits, prompt: { ...inputAccepted, stages: ['input_accepted', 'turn_started'] @@ -183,16 +198,40 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso } } catch (error) { if (error instanceof Error && error.message === 'agent_prompt_stalled') { - return { submits: 1, prompt: inputAccepted } + return { submits, prompt: inputAccepted } } if (error instanceof Error && error.message === 'agent_prompt_blocked') { this.forgetAgentPromptRequest(ptyId, generation, options.requestId) return { - submits: 1, + submits, prompt: { ...inputAccepted, observation: 'permission' } } } throw error } } + + /** + * The desktop draft paste's second Enter, for agents whose composer can render before Enter is + * live (`submitRetryDelayMs`). Best effort: it never fails a prompt the first Enter submitted. + */ + private async resubmitAgentPromptAfterRetryDelay( + ptyId: string, + generation: number, + options: RuntimeAgentPromptWriteOptions + ): Promise { + const agent = this.getPtyAgent(ptyId) + const retryDelayMs = agent ? TUI_AGENT_CONFIG[agent]?.submitRetryDelayMs : undefined + if (retryDelayMs === undefined) { + return 0 + } + try { + await waitForAgentPromptDelay(retryDelayMs, options.signal) + this.assertAgentPromptGeneration(ptyId, generation) + await options.beforeWrite?.(ptyId) + return this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT, options.inputKind) ? 1 : 0 + } catch { + return 0 + } + } } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 070363fdb3f..0548105af14 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -47,6 +47,7 @@ await import('./orca-runtime-tests/terminal-creation-and-readiness-part-05.spec' await import('./orca-runtime-tests/terminal-creation-and-readiness-part-06.spec') await import('./orca-runtime-tests/terminal-creation-and-readiness-part-07.spec') await import('./orca-runtime-tests/terminal-creation-and-readiness-part-08.spec') +await import('./orca-runtime-tests/agent-launch-prompt-submit.spec') await import('./orca-runtime-tests/terminal-creation-and-readiness-part-09.spec') await import('./orca-runtime-tests/terminal-creation-and-readiness-part-10.spec') await import('./orca-runtime-tests/terminal-creation-and-readiness-part-11.spec') diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index 1402f10b223..46529f592d7 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -75,6 +75,9 @@ export type RpcContext = { clientId?: string // Why: navigation is keyed by revocable device identity, never by the bearer credential or transient socket id. pairedDeviceId?: string + // Why: an in-process caller (the desktop renderer over IPC) has no paired device, yet its identity + // is fixed by the host, so replay-protected operations can key on it. + trustedLocalCallerId?: string // Why: lets handlers gate mobile payload truncation to phones only; undefined for in-process callers → treat as full-class (no clip). clientKind?: 'mobile' | 'runtime' // Why: negotiation is bound to the authenticated socket, never asserted by a destructive request. diff --git a/src/main/runtime/rpc/dispatcher-stream-options.ts b/src/main/runtime/rpc/dispatcher-stream-options.ts index e3151c66b0e..ca8c79ad0ac 100644 --- a/src/main/runtime/rpc/dispatcher-stream-options.ts +++ b/src/main/runtime/rpc/dispatcher-stream-options.ts @@ -8,6 +8,8 @@ export type RpcDispatchStreamingOptions = { signal?: AbortSignal clientId?: string pairedDeviceId?: string + /** Set only by an in-process transport (desktop IPC); never derived from anything a peer sends. */ + trustedLocalCallerId?: string clientKind?: 'mobile' | 'runtime' clientCapabilities?: readonly RuntimeCapability[] updateClientCapabilities?: (capabilities: readonly RuntimeCapability[]) => void diff --git a/src/main/runtime/rpc/dispatcher-trusted-local-caller.test.ts b/src/main/runtime/rpc/dispatcher-trusted-local-caller.test.ts new file mode 100644 index 00000000000..fa8011fdbdd --- /dev/null +++ b/src/main/runtime/rpc/dispatcher-trusted-local-caller.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import { RpcDispatcher } from './dispatcher' +import { defineMethod, type RpcRequest } from './core' +import type { OrcaRuntimeService } from '../orca-runtime' + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the dispatcher reads only getRuntimeId on this path; any other runtime member it touched would throw rather than read a wrong value. +const RUNTIME = { getRuntimeId: () => 'test-runtime' } as OrcaRuntimeService + +const INSPECT = defineMethod({ + name: 'test.inspectCaller', + params: z.object({}), + handler: (_params, { trustedLocalCallerId }) => ({ + trustedLocalCallerId: trustedLocalCallerId ?? null + }) +}) + +function request(): RpcRequest { + return { id: 'req-1', authToken: 'tok', method: 'test.inspectCaller', params: {} } +} + +describe('the in-process caller identity a transport vouches for', () => { + it('reaches a unary handler from the transport that set it', async () => { + const dispatcher = new RpcDispatcher({ runtime: RUNTIME, methods: [INSPECT] }) + + const response = await dispatcher.dispatch(request(), { + clientKind: 'runtime', + trustedLocalCallerId: 'desktop-renderer' + }) + + expect(response).toMatchObject({ + ok: true, + result: { trustedLocalCallerId: 'desktop-renderer' } + }) + }) + + it('is absent for a transport that set none, whatever the request carries', async () => { + const dispatcher = new RpcDispatcher({ runtime: RUNTIME, methods: [INSPECT] }) + + const response = await dispatcher.dispatch( + { ...request(), params: { trustedLocalCallerId: 'desktop-renderer' } }, + { clientKind: 'runtime' } + ) + + expect(response).toMatchObject({ ok: true, result: { trustedLocalCallerId: null } }) + }) +}) diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index 04600fcc4ec..871037ea63c 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -116,6 +116,7 @@ export class RpcDispatcher { : undefined, requestId: request.id, clientId: options?.clientId, + trustedLocalCallerId: options?.trustedLocalCallerId, clientKind: options?.clientKind, clientCapabilities: options?.clientCapabilities, updateClientCapabilities: options?.updateClientCapabilities, diff --git a/src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts b/src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts index 2e99f580125..584cd4f72e6 100644 --- a/src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts @@ -25,6 +25,14 @@ const createStructuredSession = vi.hoisted(() => vi.fn()) vi.mock('./structured-agent-session-create', () => ({ createStructuredAgentSessionForWorktree: createStructuredSession })) +const deliverTerminalPrompt = vi.hoisted(() => vi.fn(async () => true)) +vi.mock('./agent-launch-terminal-prompt', () => ({ + deliverTerminalAgentLaunchPrompt: deliverTerminalPrompt +})) +const commitChatPrompt = vi.hoisted(() => vi.fn(async () => 'message-1')) +vi.mock('./agent-launch-structured-prompt', () => ({ + commitStructuredAgentSessionLaunchPrompt: commitChatPrompt +})) const { AGENT_LAUNCH_METHODS } = await import('./agent-launch') const AGENT_LAUNCH = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launch') @@ -39,6 +47,8 @@ const CREATE_LAUNCH = { target: { kind: 'create-worktree', create: { repo: 'id:repo-1', name: 'task' } } } const CALLER = 'device-1' +const REVEAL_WARNING = + 'Terminal term_1 is running, but Orca could not make it discoverable. Run `orca terminal focus --terminal term_1` to reveal and focus it.' function selectionRuntime(options: Parameters[0]) { return Object.assign(runtimeStub(options), { @@ -59,6 +69,8 @@ function chatActivation(): unknown { } beforeEach(() => { + deliverTerminalPrompt.mockClear() + commitChatPrompt.mockClear() createStructuredSession .mockReset() .mockResolvedValue({ ok: true, value: { sessionId: 'sess-1' } }) @@ -91,6 +103,27 @@ describe('a paired client launching into an existing workspace', () => { ) }) + // Why: a pasted prompt waits up to a minute for the agent; the caller's view must not wait with it. + it.each([ + ['terminal', {}, deliverTerminalPrompt], + ['chat', STRUCTURED_PREFERENCE, commitChatPrompt] + ] as const)( + 'selects the new %s for the caller before its prompt is delivered', + async (_surface, settings, deliver) => { + const runtime = selectionRuntime({ settings, terminalPaneKey: PANE_KEY }) + + await launch( + { ...EXISTING_LAUNCH, prompt: { text: 'Fix it.\nLog:', delivery: 'submit' } }, + runtime + ) + + const selectedAt = runtime.selectCreatedMobileSessionTabForClient.mock.invocationCallOrder[0] + const deliveredAt = deliver.mock.invocationCallOrder[0] + expect(deliveredAt).toBeDefined() + expect(selectedAt).toBeLessThan(deliveredAt!) + } + ) + it('still reports the launch when selecting its tab fails', async () => { const runtime = selectionRuntime({ settings: {}, terminalPaneKey: PANE_KEY }) runtime.selectCreatedMobileSessionTabForClient.mockImplementationOnce(() => { @@ -104,6 +137,20 @@ describe('a paired client launching into an existing workspace', () => { warn.mockRestore() }) + // Why: a headless `--serve` host has no window to reveal into; the caller mirrors the tab anyway. + it('does not pass on the host’s own reveal warning, since the caller shows the tab itself', async () => { + const runtime = selectionRuntime({ + settings: {}, + terminalPaneKey: PANE_KEY, + terminalWarning: REVEAL_WARNING + }) + + const result = await launch(EXISTING_LAUNCH, runtime) + + expect(result.outcome).toMatchObject({ kind: 'terminal', handle: 'term_1' }) + expect(result.warning).toBeUndefined() + }) + it('selects nothing when the runtime reported no pane for the terminal', async () => { const runtime = selectionRuntime({ settings: {} }) @@ -123,6 +170,14 @@ describe('launches that keep the host-wide behaviour', () => { expect(runtime.selectCreatedMobileSessionTabForClient).not.toHaveBeenCalled() }) + it('an in-process caller still hears that the host could not reveal the tab', async () => { + const runtime = selectionRuntime({ settings: {}, terminalWarning: REVEAL_WARNING }) + + const result = await launch(EXISTING_LAUNCH, runtime, {}) + + expect(result.warning).toBe(REVEAL_WARNING) + }) + it("the host's own desktop window, a runtime client with no paired device, still activates the chat", async () => { const runtime = selectionRuntime({ settings: STRUCTURED_PREFERENCE }) diff --git a/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts b/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts index b5fe4fe31f4..a8d913526cb 100644 --- a/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts +++ b/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts @@ -8,7 +8,8 @@ * what runs the new workspace's setup. */ -import type { AgentLaunchResult, AgentLaunchTarget } from '../../../../shared/agent-launch-intent' +import type { AgentLaunchTarget } from '../../../../shared/agent-launch-intent' +import type { AgentLaunchPublishedSurface } from '../../../agent-launch/agent-launch-executor' import { parsePaneKey } from '../../../../shared/stable-pane-id' import type { OrcaRuntimeService } from '../../orca-runtime' import type { RpcContext } from '../core' @@ -27,25 +28,25 @@ export function agentLaunchCallerNavigationId( /** Bookkeeping, never a gate: the agent already runs, so a failure here only leaves the view as it was. */ export function selectAgentLaunchTabForCaller( runtime: Pick, - result: AgentLaunchResult, + surface: AgentLaunchPublishedSurface, clientNavigationId: string ): void { - const { outcome } = result + const { outcome } = surface // Found by pane or session, never by a predicted tab id. - const surface = + const selector = outcome.kind === 'terminal' ? outcome.paneKey ? parsePaneKey(outcome.paneKey) : null : { sessionId: outcome.sessionId } - if (!surface) { + if (!selector) { return } try { if ( !runtime.selectCreatedMobileSessionTabForClient( - result.worktreeId, - surface, + surface.worktreeId, + selector, clientNavigationId ) ) { diff --git a/src/main/runtime/rpc/methods/agent-launch-prompt-carry.test.ts b/src/main/runtime/rpc/methods/agent-launch-prompt-carry.test.ts new file mode 100644 index 00000000000..d1549af6a61 --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-launch-prompt-carry.test.ts @@ -0,0 +1,113 @@ +/** + * Whether `agent.launch` pastes an argv agent's prompt is the runtime's report about the line it + * typed, not the executor's guess: a carried prompt must not be pasted a second time, and an + * uncarried one must not be left undelivered. + */ + +import { describe, expect, it, vi } from 'vitest' +import { + CAPABLE_CLIENT, + methodNamed, + rpcContext, + runtimeStub, + type AgentLaunchRuntimeStub +} from './agent-launch.test-fixture' + +const { AGENT_LAUNCH_METHODS } = await import('./agent-launch') +const AGENT_LAUNCH = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launch') + +const SUBMIT = { text: 'fix the failing checks\nlog tail follows', delivery: 'submit' } + +function withPromptWriter(runtime: AgentLaunchRuntimeStub) { + const waitForTerminal = vi.fn(async () => ({ satisfied: true, status: 'idle' })) + // The idle evidence settles these launches; the composer signal never fires. + const waitForFreshWorkerComposer = vi.fn(async () => { + throw new Error('timeout') + }) + const sendTerminalAgentPrompt = vi.fn(async () => ({ + handle: 'term_1', + accepted: true, + bytesWritten: 1 + })) + return { + runtime: Object.assign(runtime, { + waitForTerminal, + waitForFreshWorkerComposer, + sendTerminalAgentPrompt + }), + sendTerminalAgentPrompt + } +} + +async function launch(params: unknown, runtime: AgentLaunchRuntimeStub) { + const parsed = AGENT_LAUNCH.params.safeParse(params) + if (!parsed.success) { + throw new Error(parsed.error.issues[0]?.message ?? 'invalid') + } + return AGENT_LAUNCH.handler(parsed.data, rpcContext(runtime, CAPABLE_CLIENT)) +} + +describe('an argv agent’s launch prompt, by what the runtime reports about its typed line', () => { + const EXISTING = { agent: 'claude', target: { kind: 'existing', worktree: 'id:wt-7' } } + + it('pastes it once the agent is ready when the line could not carry it', async () => { + const { runtime, sendTerminalAgentPrompt } = withPromptWriter( + runtimeStub({ settings: {}, lineCarriesPrompt: false }) + ) + + const result = await launch({ ...EXISTING, prompt: SUBMIT }, runtime) + + expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'handed-to-terminal' }) + expect(sendTerminalAgentPrompt).toHaveBeenCalledWith('term_1', SUBMIT.text, expect.anything()) + }) + + it('does not paste a prompt the line already carried', async () => { + const { runtime, sendTerminalAgentPrompt } = withPromptWriter( + runtimeStub({ settings: {}, lineCarriesPrompt: true }) + ) + + const result = await launch({ ...EXISTING, prompt: SUBMIT }, runtime) + + expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'handed-to-terminal' }) + expect(sendTerminalAgentPrompt).not.toHaveBeenCalled() + }) + + it('pastes into an agent-first create’s startup terminal when its line could not carry it', async () => { + const { runtime, sendTerminalAgentPrompt } = withPromptWriter( + runtimeStub({ settings: {}, lineCarriesPrompt: false }) + ) + + const result = await launch( + { + agent: 'claude', + target: { kind: 'create-worktree', create: { repo: 'id:repo-1', name: 'task' } }, + prompt: SUBMIT + }, + runtime + ) + + expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'handed-to-terminal' }) + expect(sendTerminalAgentPrompt).toHaveBeenCalledWith( + 'term_agent_first', + SUBMIT.text, + expect.anything() + ) + }) + + it('does not paste into an agent-first create’s startup terminal whose line carried it', async () => { + const { runtime, sendTerminalAgentPrompt } = withPromptWriter( + runtimeStub({ settings: {}, lineCarriesPrompt: true }) + ) + + await launch( + { + agent: 'claude', + target: { kind: 'create-worktree', create: { repo: 'id:repo-1', name: 'task' } }, + prompt: SUBMIT + }, + runtime + ) + + expect(sendTerminalAgentPrompt).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/agent-launch-replay.test.ts b/src/main/runtime/rpc/methods/agent-launch-replay.test.ts index 24d0a80b3d1..94e25298371 100644 --- a/src/main/runtime/rpc/methods/agent-launch-replay.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-replay.test.ts @@ -184,6 +184,49 @@ describe('exactly one execution per launch operation', () => { }) }) +describe('a fresh launch writes the ledger once before its effect', () => { + it('admits and claims in one durable transaction, claimed on disk when the effect starts', async () => { + const admitAndClaim = vi.spyOn(store, 'admitAndClaimOperation') + const admit = vi.spyOn(store, 'admitOperation') + const claimOnly = vi.spyOn(store, 'claimOperation') + const runtime = runtimeStub() + let statusAtEffect: string | undefined + runtime.createManagedWorktree.mockImplementationOnce(async () => { + const persisted = await readPersistedTestAgentSessionStore(directory) + statusAtEffect = + persisted.operations[agentSessionOperationKey('device-1', OPERATION_ID)]?.outcome.status + return { worktree: { id: 'wt-new' }, startupTerminal: undefined } + }) + + await launch(createLaunch({ operationId: OPERATION_ID }), runtime) + + expect(admitAndClaim).toHaveBeenCalledTimes(1) + expect(admit).not.toHaveBeenCalled() + expect(claimOnly).not.toHaveBeenCalled() + // A claim moves the row from `pending` to `unknown`: taken, not yet settled. + expect(statusAtEffect).toBe('unknown') + }) + + it('still lets exactly one of two concurrent admissions run the effect', async () => { + const admission = { + callerKey: 'device-1', + operationId: OPERATION_ID, + fingerprint: 'fp-1', + now: NOW + } + const claimAfter = (decision: { decision: string; row?: AgentSessionOperationRow }) => + decision.decision === 'admit' || + (decision.decision === 'replay' && decision.row?.outcome.status === 'pending') + + const results = await Promise.all([ + store.admitAndClaimOperation(admission, claimAfter), + store.admitAndClaimOperation(admission, claimAfter) + ]) + + expect(results.filter((result) => result.claim?.claim === 'won')).toHaveLength(1) + }) +}) + describe('stable replay identity', () => { it('replays across a bearer-credential change under the paired device subject', async () => { const params = createLaunch({ operationId: OPERATION_ID }) diff --git a/src/main/runtime/rpc/methods/agent-launch-replay.ts b/src/main/runtime/rpc/methods/agent-launch-replay.ts index ec9269f4efe..2bab7d12535 100644 --- a/src/main/runtime/rpc/methods/agent-launch-replay.ts +++ b/src/main/runtime/rpc/methods/agent-launch-replay.ts @@ -29,11 +29,16 @@ import type { AgentLaunchParams } from './agent-launch-schemas' /** Remote replay needs the paired-device subject because its bearer credential can rotate. */ export function agentLaunchOperationCallerKey( - context: Pick + context: Pick ): string { if (context.clientKind === undefined) { return 'trusted-local:runtime' } + // The desktop renderer is in-process like the CLI, but advertises capabilities as a client. + const trustedLocalCallerId = context.trustedLocalCallerId?.trim() + if (trustedLocalCallerId) { + return `trusted-local:${trustedLocalCallerId}` + } const pairedDeviceId = context.pairedDeviceId?.trim() if (!pairedDeviceId) { throw new Error('agent_session_identity_required') @@ -116,7 +121,7 @@ function answerFromRecordedRow( } /** - * Admit, then claim. + * Admit, then claim, in one durable transaction so a launch writes the ledger once before its effect. * * Two steps because they answer different questions — "is this id known and consistent?" and "may * *I* run it?" — and the second cannot be folded into the first. Admission hands two concurrent @@ -136,12 +141,14 @@ export async function admitAgentLaunchOperation( } const store = await requireLaunchOperationStore(context) const callerKey = agentLaunchOperationCallerKey(context) - const admitted = await store.admitOperation({ - callerKey, - operationId, - fingerprint, - now - }) + const { decision: admitted, claim } = await store.admitAndClaimOperation( + { callerKey, operationId, fingerprint, now }, + // A fresh row, or a replayed one no one has answered yet, leaves the right to run open. + (decision) => + decision.decision === 'admit' || + (decision.decision === 'replay' && + answerFromRecordedRow(operationId, decision.row.outcome) === null) + ) if (admitted.decision === 'refused') { return refusal(operationId, admitted.code, `was refused: ${admitted.code}`) } @@ -151,7 +158,14 @@ export async function admitAgentLaunchOperation( return answer } } - const claim = await store.claimOperation({ callerKey, operationId }) + // Unreachable with both steps in one transaction; answered as uncertain rather than run twice. + if (!claim || claim.claim === 'absent') { + return refusal( + operationId, + 'agent_session_operation_unknown', + 'has no claim; its outcome is unknown' + ) + } if (claim.claim === 'lost') { // The handler joins same-process retries before admission. Reaching a claimed row here means // this runtime did not start it, so treating it as restart uncertainty is the safe answer. @@ -160,15 +174,6 @@ export async function admitAgentLaunchOperation( refusal(operationId, 'agent_session_operation_unknown', 'is claimed but unsettled') ) } - if (claim.claim === 'absent') { - // Admitted a moment ago and gone already: the row cannot be re-admitted without reopening the - // duplicate-spawn window it exists to close, so this stays uncertain. - return refusal( - operationId, - 'agent_session_operation_unknown', - 'was pruned between admission and its claim; its outcome is unknown' - ) - } return { decision: 'execute', attachOperationId, diff --git a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts index 3f184cd50b5..2e383e876d2 100644 --- a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts +++ b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts @@ -42,8 +42,8 @@ export function agentLaunchSurfaceFactory( context: RpcContext, attachOperationId?: string, operationCallerKey?: string, - // False when the launch selects the chat for its paired caller instead of for everyone. - activateChat = true, + // True when the launch shows its surface to the paired caller itself rather than to everyone. + callerPresentsSurface = false, terminalSpawn: TerminalSpawnDispatch = trackTerminalSpawnDispatch() ): AgentLaunchSurfaceFactory { return { @@ -80,7 +80,7 @@ export function agentLaunchSurfaceFactory( ...(seeded ? { options: seeded } : {}), ...(tabId ? { tabId } : {}), // The user asked for this chat, so it takes the surface — unlike a dispatched worker. - activate: activateChat + activate: !callerPresentsSurface }) if (!created.ok) { // The caller named this session, so a taken id is its answer, not an opaque refusal; and not @@ -118,16 +118,25 @@ export function agentLaunchSurfaceFactory( cwd, launchSource, paneKey, + viewMode, options }) => { const launchPreferences = toAgentLaunchPreferences(options) + let promptRodeLaunchCommand = false const created = context.runtime.createTerminal(`id:${worktreeId}`, { // The agent id is not a shell command — `cursor` is the desktop app, its CLI is // `cursor-agent` — so the runtime builds the configured launcher. startupAgent: agent, - // Folded into that launcher by the same startup plan a new agent tab is built from, so an - // argv agent's prompt is in its argv at exec time rather than typed in afterwards. - ...(startupPrompt ? { startupPrompt } : {}), + // Offered to that launcher's startup plan; it rides only when the typed line can carry it, + // and the runtime reports which so an uncarried prompt is pasted once the agent is ready. + ...(startupPrompt + ? { + startupPrompt, + onStartupPromptCarry: (carried: boolean) => { + promptRodeLaunchCommand = carried + } + } + : {}), ...(agentArgs !== undefined ? { agentArgs } : {}), ...(cwd ? { cwd } : {}), // The model the user picked outranks configured args here too, as it does on a chat. @@ -135,6 +144,8 @@ export function agentLaunchSurfaceFactory( // A live reserved pane would be attached, not launched into, so the runtime refuses it. ...(paneKey ? { ...paneIdentity(paneKey), requireFreshPane: true } : {}), ...(launchSource ? { launchSource } : {}), + // Not `presentation`: a focused create takes the renderer path, which drops the pane identity. + ...(viewMode ? { viewMode } : {}), onPtySpawnDispatched: terminalSpawn.onPtySpawnDispatched }) const terminal = await created.catch(terminalSpawn.rethrow) @@ -143,13 +154,17 @@ export function agentLaunchSurfaceFactory( // The runtime already minted this pane and baked it into the PTY's env and its own reveal; // dropping it here was what left a client with no way to name the tab it just asked for. ...(terminal.paneKey ? { paneKey: terminal.paneKey } : {}), - ...(terminal.warning ? { warning: terminal.warning } : {}) + // Its only warning is that the host could not reveal the tab, which the caller shows itself. + ...(terminal.warning && !callerPresentsSurface ? { warning: terminal.warning } : {}), + ...(promptRodeLaunchCommand ? { promptRodeLaunchCommand } : {}) } }, - deliverTerminalPrompt: async ({ handle, prompt }) => + deliverTerminalPrompt: async ({ handle, agent, freshLaunch, prompt }) => deliverTerminalAgentLaunchPrompt({ runtime: context.runtime, handle, + agent, + freshLaunch, text: prompt.text }) } diff --git a/src/main/runtime/rpc/methods/agent-launch-terminal-preferences.test.ts b/src/main/runtime/rpc/methods/agent-launch-terminal-preferences.test.ts index f75fdc803f2..12945df369a 100644 --- a/src/main/runtime/rpc/methods/agent-launch-terminal-preferences.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-terminal-preferences.test.ts @@ -80,6 +80,16 @@ describe('a terminal launch into an existing workspace', () => { expect(terminalOptions(runtime)).toMatchObject({ launchPreferences: PICKS }) }) + it('opens the tab in the view mode the caller asked for, without asking for a focused create', async () => { + const runtime = runtimeStub({ settings: TERMINAL_ONLY }) + + await launch({ ...EXISTING_LAUNCH, viewMode: 'chat' }, runtime) + + // A focused create takes the renderer path, which drops the caller's pane identity. + expect(terminalOptions(runtime)).toMatchObject({ viewMode: 'chat' }) + expect(terminalOptions(runtime)).not.toHaveProperty('presentation') + }) + it('drops options that are not launch preferences', async () => { const runtime = runtimeStub({ settings: TERMINAL_ONLY }) diff --git a/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.test.ts b/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.test.ts index f29c2e81477..0c43c77b40d 100644 --- a/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.test.ts @@ -18,18 +18,46 @@ type SendFn = ( options: Record ) => Promise -function runtimeStub(overrides: { wait?: unknown; send?: SendFn }) { - const waitForTerminal = vi.fn(async () => overrides.wait ?? { satisfied: true, status: 'idle' }) +const COMPOSER_READY = { satisfied: true, status: 'running' } + +function runtimeStub(overrides: { + wait?: unknown + waits?: unknown[] + send?: SendFn + /** Whether the agent's composer signal fires within its budget; else the idle evidence decides. */ + composerSignal?: boolean + /** Whether a shell is proven in the terminal's foreground. */ + shellInFront?: boolean +}) { + const queued = [...(overrides.waits ?? [])] + const waitForTerminal = vi.fn( + async (_handle: string, _options?: { condition?: string; timeoutMs?: number }) => + queued.shift() ?? overrides.wait ?? { satisfied: true, status: 'idle' } + ) + const waitForFreshWorkerComposer = vi.fn(async () => { + if (!overrides.composerSignal) { + throw new Error('timeout') + } + return COMPOSER_READY + }) + const isLaunchShellInFront = vi.fn(async () => overrides.shellInFront ?? false) + const subscribeToTerminalData = vi.fn(() => () => {}) const sendTerminalAgentPrompt = vi.fn( overrides.send ?? (async () => ({ handle: 'term_1', accepted: true, bytesWritten: 12 })) ) return { waitForTerminal, + waitForFreshWorkerComposer, sendTerminalAgentPrompt, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the deliverer reaches exactly these two runtime methods; anything else would throw rather than read a wrong value. - runtime: { waitForTerminal, sendTerminalAgentPrompt } as unknown as Parameters< - typeof deliverTerminalAgentLaunchPrompt - >[0]['runtime'] + isLaunchShellInFront, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the deliverer reaches exactly these five runtime methods; anything else would throw rather than read a wrong value. + runtime: { + waitForTerminal, + waitForFreshWorkerComposer, + sendTerminalAgentPrompt, + isLaunchShellInFront, + subscribeToTerminalData + } as unknown as Parameters[0]['runtime'] } } @@ -48,13 +76,22 @@ describe('writing a launch prompt into a terminal agent', () => { const delivered = await deliverTerminalAgentLaunchPrompt({ runtime: stub.runtime, handle: 'term_1', + agent: 'claude', + freshLaunch: true, text: 'do the thing' }) expect(delivered).toBe(true) + // The composer signal did not fire within its budget, so the idle evidence decided. + expect(stub.waitForFreshWorkerComposer).toHaveBeenCalledWith('term_1', 'claude', 8_000, { + requireComposerMarker: false, + stopOnDialog: true + }) expect(stub.waitForTerminal).toHaveBeenCalledWith('term_1', { condition: 'tui-idle', - timeoutMs: 60_000 + timeoutMs: expect.any(Number), + // A name-only title proves nothing about a just-launched agent until its stream is quiet. + launchReadiness: true }) const [handle, text, options] = stub.sendTerminalAgentPrompt.mock.calls[0]! expect(handle).toBe('term_1') @@ -63,6 +100,8 @@ describe('writing a launch prompt into a terminal agent', () => { // agent would be reported as undelivered while its prompt sat in the pane. expect(options.acceptQueued).toBe(true) expect(options.requestId).toEqual(expect.any(String)) + // Enter follows the paste on the desktop draft paste's timing: this composer was just seen ready. + expect(options.composerReady).toBe(true) }) it('does not write when the composer never opened', async () => { @@ -70,6 +109,8 @@ describe('writing a launch prompt into a terminal agent', () => { const delivered = await deliverTerminalAgentLaunchPrompt({ runtime: stub.runtime, handle: 'term_1', + agent: 'claude', + freshLaunch: true, text: 'do the thing' }) @@ -87,6 +128,8 @@ describe('writing a launch prompt into a terminal agent', () => { const delivered = await deliverTerminalAgentLaunchPrompt({ runtime: stub.runtime, handle: 'term_1', + agent: 'claude', + freshLaunch: true, text: 'do the thing' }) @@ -103,6 +146,8 @@ describe('writing a launch prompt into a terminal agent', () => { const delivered = await deliverTerminalAgentLaunchPrompt({ runtime: stub.runtime, handle: 'term_1', + agent: 'claude', + freshLaunch: true, text: 'do the thing' }) @@ -115,6 +160,8 @@ describe('writing a launch prompt into a terminal agent', () => { const delivered = await deliverTerminalAgentLaunchPrompt({ runtime: stub.runtime, handle: 'term_1', + agent: 'claude', + freshLaunch: true, text: 'do the thing' }) @@ -128,10 +175,220 @@ describe('writing a launch prompt into a terminal agent', () => { await deliverTerminalAgentLaunchPrompt({ runtime: stub.runtime, handle: 'term_1', + agent: 'claude', + freshLaunch: true, text: ' ' }) ).toBe(false) expect(stub.waitForTerminal).not.toHaveBeenCalled() expect(stub.sendTerminalAgentPrompt).not.toHaveBeenCalled() }) + + it('waits out a blocking prompt the user dismisses, then writes', async () => { + const blocked = { satisfied: false, status: 'running', blockedReason: 'trust-prompt' } + const stub = runtimeStub({ waits: [blocked, blocked, { satisfied: true, status: 'running' }] }) + const clock = fakeClock() + + const delivered = await deliverTerminalAgentLaunchPrompt({ + runtime: stub.runtime, + handle: 'term_1', + agent: 'claude', + freshLaunch: true, + text: 'do the thing', + clock + }) + + expect(delivered).toBe(true) + expect(stub.waitForTerminal).toHaveBeenCalledTimes(3) + // Each re-wait spends only what is left of the one launch budget. + const lastTimeoutMs = stub.waitForTerminal.mock.calls.at(-1)?.[1]?.timeoutMs ?? 0 + expect(lastTimeoutMs).toBeLessThanOrEqual(58_000) + expect(lastTimeoutMs).toBeGreaterThan(57_000) + expect(stub.sendTerminalAgentPrompt).toHaveBeenCalledTimes(1) + }) + + it('writes nothing into a blocking prompt still up when the budget ends', async () => { + const stub = runtimeStub({ + wait: { satisfied: false, status: 'running', blockedReason: 'trust-prompt' } + }) + + const delivered = await deliverTerminalAgentLaunchPrompt({ + runtime: stub.runtime, + handle: 'term_1', + agent: 'claude', + freshLaunch: true, + text: 'do the thing', + clock: fakeClock() + }) + + expect(delivered).toBe(false) + expect(stub.sendTerminalAgentPrompt).not.toHaveBeenCalled() + // One check per second of a 60 s budget, then it stops rather than spinning. + expect(stub.waitForTerminal.mock.calls.length).toBeLessThanOrEqual(60) + }) + + it.each([ + ['refuses the write when the shell is back in front', true, false], + // An unread foreground is no proof the agent exited; the composer was just seen ready. + ['writes unless a shell is proven in front', false, true] + ])('%s', async (_label, shellInFront, delivered) => { + // An agent that exits at startup hands its shell back, which must never run the prompt. + const stub = runtimeStub({ composerSignal: true, shellInFront }) + stub.sendTerminalAgentPrompt.mockImplementation(async (handle, _text, options) => { + const { beforeWrite } = options + if (typeof beforeWrite === 'function') { + await beforeWrite('pty-1') + } + return { handle, accepted: true, bytesWritten: 12 } + }) + + await expect( + deliverTerminalAgentLaunchPrompt({ + runtime: stub.runtime, + handle: 'term_1', + agent: 'claude', + freshLaunch: true, + text: 'do the thing' + }) + ).resolves.toBe(delivered) + expect(stub.isLaunchShellInFront).toHaveBeenCalledWith('pty-1', 'claude') + }) + + it('checks the foreground once for the paste, its Enter and the second Enter', async () => { + const stub = runtimeStub({ composerSignal: true }) + stub.sendTerminalAgentPrompt.mockImplementation(async (handle, _text, options) => { + const { beforeWrite } = options + if (typeof beforeWrite === 'function') { + await beforeWrite('pty-1') + await beforeWrite('pty-1') + await beforeWrite('pty-1') + } + return { handle, accepted: true, bytesWritten: 12 } + }) + + await expect( + deliverTerminalAgentLaunchPrompt({ + runtime: stub.runtime, + handle: 'term_1', + agent: 'codex', + freshLaunch: true, + text: 'do the thing' + }) + ).resolves.toBe(true) + expect(stub.isLaunchShellInFront).toHaveBeenCalledTimes(1) + }) + + it('writes as soon as the composer signal fires, without consulting the idle evidence', async () => { + const stub = runtimeStub({ composerSignal: true }) + + const delivered = await deliverTerminalAgentLaunchPrompt({ + runtime: stub.runtime, + handle: 'term_1', + agent: 'claude', + freshLaunch: true, + text: 'do the thing' + }) + + expect(delivered).toBe(true) + expect(stub.waitForFreshWorkerComposer).toHaveBeenCalledWith('term_1', 'claude', 8_000, { + requireComposerMarker: false, + stopOnDialog: true + }) + expect(stub.waitForTerminal).not.toHaveBeenCalled() + }) + + it.each(['zcode', 'dsh', 'grok'] as const)('waits for %s’s composer readiness', async (agent) => { + const stub = runtimeStub({ composerSignal: true }) + + const delivered = await deliverTerminalAgentLaunchPrompt({ + runtime: stub.runtime, + handle: 'term_1', + agent, + freshLaunch: true, + text: 'do the thing' + }) + + expect(delivered).toBe(true) + expect(stub.waitForFreshWorkerComposer).toHaveBeenCalledWith( + 'term_1', + agent, + 60_000, + expect.anything() + ) + expect(stub.waitForTerminal).not.toHaveBeenCalled() + }) + + it('keeps the text when an agent shows no readiness evidence at all', async () => { + // Nothing is pasted blind. + const stub = runtimeStub({ wait: { satisfied: false, status: 'running' } }) + + const delivered = await deliverTerminalAgentLaunchPrompt({ + runtime: stub.runtime, + handle: 'term_1', + agent: 'goose', + freshLaunch: true, + text: 'do the thing' + }) + + expect(delivered).toBe(false) + expect(stub.sendTerminalAgentPrompt).not.toHaveBeenCalled() + }) + it('never lets a re-wait fall back to the terminal wait’s 5-minute default', async () => { + // A late 1 s re-check sleep can land past the deadline; 0 would read as "use the default". + const stub = runtimeStub({ + wait: { satisfied: false, status: 'running', blockedReason: 'trust-prompt' } + }) + let now = 0 + const lateClock = { + now: () => now, + sleep: async (ms: number) => { + now += ms + 1_000 + } + } + + await deliverTerminalAgentLaunchPrompt({ + runtime: stub.runtime, + handle: 'term_1', + agent: 'claude', + freshLaunch: true, + text: 'do the thing', + clock: lateClock + }) + + for (const [, options] of stub.waitForTerminal.mock.calls) { + expect(options?.timeoutMs).toBeGreaterThan(0) + } + }) + + it('waits on a reused terminal’s idle state, not a fresh launch’s composer marker', async () => { + // A long-running grok pane may no longer show its composer marker in recent output. + const stub = runtimeStub({}) + + const delivered = await deliverTerminalAgentLaunchPrompt({ + runtime: stub.runtime, + handle: 'term_existing', + agent: 'grok', + freshLaunch: false, + text: 'do the thing' + }) + + expect(delivered).toBe(true) + expect(stub.waitForFreshWorkerComposer).not.toHaveBeenCalled() + expect(stub.waitForTerminal).toHaveBeenCalledWith('term_existing', { + condition: 'tui-idle', + timeoutMs: 60_000 + }) + // A reused pane's render state is only inferred, so its Enter still waits for the render. + expect(stub.sendTerminalAgentPrompt.mock.calls[0]?.[2]?.composerReady).toBe(false) + }) }) + +function fakeClock() { + let now = 0 + return { + now: () => now, + sleep: async (ms: number) => { + now += ms + } + } +} diff --git a/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.ts b/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.ts index ac66912e747..5af709eb0b1 100644 --- a/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.ts +++ b/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.ts @@ -7,11 +7,11 @@ * Mobile, the CLI and orchestration got an agent and no prompt. The host owns the PTY, so it can * write into one whether or not any window is open on it. * - * This is the half argv cannot serve. An agent whose CLI takes the prompt as an argument gets it on - * the launch command instead (`agentPromptRidesLaunchCommand`), where it is in the process's argv - * at exec time and no readiness race exists. What reaches here is a `stdin-after-start` agent, - * whose CLI accepts no such argument, and a reused terminal, whose process was already running - * before this launch existed. + * This is the half the launch command cannot serve. An argv agent's prompt rides that command when + * its typed line can carry it (`startup-line-prompt-carry`), and then no readiness race exists. What + * reaches here is a `stdin-after-start` agent, whose CLI accepts no such argument; a prompt too long + * or multi-line for the typed line; and a reused terminal, whose process was already running before + * this launch existed. Readiness is `waitForLaunchedAgentComposer`, the one the worker start uses. * * Nothing here writes to a PTY itself. `sendTerminalAgentPrompt` is the runtime's one agent-prompt * writer: it frames the text as a bracketed paste so multi-line and special-character content is @@ -22,13 +22,64 @@ */ import { randomUUID } from 'node:crypto' +import type { TuiAgent } from '../../../../shared/tui-agent' +import type { RuntimeTerminalWait } from '../../../../shared/runtime-terminal-contracts' import { isAgentPromptStalledError } from '../../agent-prompt-submission-verification' +import { + waitForLaunchedAgentComposer, + type LaunchedAgentReadinessRuntime +} from '../../launched-agent-composer-readiness' import type { OrcaRuntimeService } from '../../orca-runtime' +import { + createLaunchedAgentWriteGuard, + type LaunchedAgentWriteGuardRuntime +} from '../../launched-agent-write-guard' /** The same budget orchestration gives a worker to reach its composer before dispatching to it. */ const AGENT_READY_TIMEOUT_MS = 60_000 +/** How often a launch re-checks a blocking prompt the user may still dismiss. */ +const BLOCKED_RECHECK_MS = 1_000 -type TerminalPromptRuntime = Pick +type TerminalPromptRuntime = LaunchedAgentReadinessRuntime & + LaunchedAgentWriteGuardRuntime & + Pick + +type ReadinessClock = { now: () => number; sleep: (ms: number) => Promise } + +const REAL_CLOCK: ReadinessClock = { + now: () => Date.now(), + sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)) +} + +/** + * The launched agent's readiness, waiting out a blocking prompt until the budget ends. + * + * A trust or update dialog is the user's to answer, and they often do within seconds; giving up on + * the first sight of it dropped a prompt the pane was about to accept. A dialog still up when the + * budget ends is reported as it is, and nothing is written into it. + */ +async function waitThroughBlockingPrompts( + runtime: TerminalPromptRuntime, + handle: string, + agent: TuiAgent, + freshLaunch: boolean, + clock: ReadinessClock +): Promise { + const deadline = clock.now() + AGENT_READY_TIMEOUT_MS + for (;;) { + // At least 1 ms: the terminal wait reads 0 as "use the 5-minute default", and a late sleep can + // land past the deadline. + const timeoutMs = Math.max(1, deadline - clock.now()) + const wait = freshLaunch + ? await waitForLaunchedAgentComposer(runtime, handle, agent, timeoutMs) + : // A reused pane was not freshly launched: its composer marker may be long gone. + await runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs }) + if (!wait?.blockedReason || wait.satisfied || deadline - clock.now() <= BLOCKED_RECHECK_MS) { + return wait + } + await clock.sleep(BLOCKED_RECHECK_MS) + } +} /** * Whether the text reached the pane. @@ -47,18 +98,28 @@ type TerminalPromptRuntime = Pick { if (args.text.trim().length === 0) { return false } + // Before the paste and again before Enter: an agent that exited in between leaves its shell in + // the foreground, which must never receive the prompt or its Enter. + const guard = args.freshLaunch ? createLaunchedAgentWriteGuard(args.runtime, args.agent) : null try { - const wait = await args.runtime.waitForTerminal(args.handle, { - condition: 'tui-idle', - timeoutMs: AGENT_READY_TIMEOUT_MS - }) - // An unsatisfied wait is a composer that never opened — a trust prompt, an update prompt, a - // dead process. Pasting anyway would answer whatever question is on screen with the prompt. + const wait = await waitThroughBlockingPrompts( + args.runtime, + args.handle, + args.agent, + args.freshLaunch, + args.clock ?? REAL_CLOCK + ) + // An unsatisfied wait is a composer that never opened — a dialog left up, a dead process, an + // agent that showed no readiness. Pasting anyway would answer whatever is on screen with it. if (wait && !wait.satisfied) { console.warn( `[agent-launch] the terminal agent did not become ready (${wait.status}); its launch prompt was not delivered` @@ -67,6 +128,9 @@ export async function deliverTerminalAgentLaunchPrompt(args: { } const sent = await args.runtime.sendTerminalAgentPrompt(args.handle, args.text, { inputKind: 'launch', + // A fresh launch's composer was just seen ready; a reused pane's state is only inferred. + composerReady: args.freshLaunch, + ...(guard ? { beforeWrite: guard.beforeWrite } : {}), // Paired: together these take the queued path, which settles an unobserved turn start into // an `input_accepted` receipt rather than raising it. Without the id the write is verified // strictly and a slow first turn throws. @@ -86,5 +150,7 @@ export async function deliverTerminalAgentLaunchPrompt(args: { error ) return false + } finally { + guard?.dispose() } } diff --git a/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts b/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts index e62117f83af..d9c9c40e469 100644 --- a/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts +++ b/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts @@ -47,6 +47,7 @@ export function agentLaunchWorkspaceFactory( options }) => { const startupLaunchPreferences = toAgentLaunchPreferences(options) + let promptRodeLaunchCommand = false // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: already validated by `AgentLaunch`; the executor only removed the reserved agent fields, so the rest of the payload is the parsed shape. const params = create as WorktreeCreateParams const { runtime } = context @@ -65,8 +66,8 @@ export function agentLaunchWorkspaceFactory( ...params, ...(startupAgent ? { startupAgent } : {}), // Only ever set alongside `startupAgent`, which is what the create requires; the - // executor sends it exclusively for an agent that takes its prompt on argv, so this - // is the startup command carrying the text rather than a second delivery path. + // executor offers it only to an agent that takes its prompt on argv, and it rides only + // when the typed line can carry it. ...(startupPrompt ? { startupPrompt } : {}) }, { @@ -80,6 +81,13 @@ export function agentLaunchWorkspaceFactory( context.clientKind ? { clientKind: context.clientKind } : {} ), ...(agentArgs !== undefined ? { startupAgentArgs: agentArgs } : {}), + ...(startupPrompt + ? { + onStartupPromptCarry: (carried: boolean) => { + promptRodeLaunchCommand = carried + } + } + : {}), ...(cwd ? { startupCwd: cwd } : {}), ...(launchSource ? { startupLaunchSource: launchSource } : {}), ...(paneKey ? { startupPaneKey: paneKey } : {}), @@ -100,6 +108,7 @@ export function agentLaunchWorkspaceFactory( return { worktreeId: result.worktree.id, startupTerminalHandle: result.startupTerminal?.handle, + ...(promptRodeLaunchCommand ? { promptRodeLaunchCommand } : {}), ...(result.startupTerminal?.paneKey ? { startupTerminalPaneKey: result.startupTerminal.paneKey } : {}), diff --git a/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts b/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts index 365c843c1ef..f0a3a7cf911 100644 --- a/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts +++ b/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts @@ -35,6 +35,18 @@ export type AgentLaunchRuntimeStubOptions = { startupTerminalPaneKey?: string /** The reserved pane is already live, so a create that requires a fresh pane is refused. */ terminalPaneAlreadyLive?: boolean + /** What the runtime reports about an offered prompt's typed line; unset reports nothing. */ + lineCarriesPrompt?: boolean +} + +function reportPromptCarry( + options: AgentLaunchRuntimeStubOptions, + report: unknown, + offered: unknown +): void { + if (typeof report === 'function' && offered && options.lineCarriesPrompt !== undefined) { + report(options.lineCarriesPrompt) + } } export function runtimeStub(options: AgentLaunchRuntimeStubOptions = {}) { @@ -66,22 +78,26 @@ export function runtimeStub(options: AgentLaunchRuntimeStubOptions = {}) { } ), showRepo: vi.fn(async () => ({ id: 'repo-1' })), - createManagedWorktree: vi.fn(async (args: Record) => ({ - worktree: { id: 'wt-new' }, - startupTerminal: args.startupAgent - ? { - handle: 'term_agent_first', - ...(options.startupTerminalPaneKey ? { paneKey: options.startupTerminalPaneKey } : {}) - } - : undefined, - ...(options.setupReceipt ? { setupReceipt: options.setupReceipt } : {}), - ...(options.createWarning ? { warning: options.createWarning } : {}) - })), + createManagedWorktree: vi.fn(async (args: Record) => { + reportPromptCarry(options, args.onStartupPromptCarry, args.startupPrompt) + return { + worktree: { id: 'wt-new' }, + startupTerminal: args.startupAgent + ? { + handle: 'term_agent_first', + ...(options.startupTerminalPaneKey ? { paneKey: options.startupTerminalPaneKey } : {}) + } + : undefined, + ...(options.setupReceipt ? { setupReceipt: options.setupReceipt } : {}), + ...(options.createWarning ? { warning: options.createWarning } : {}) + } + }), // Args are declared so a test can assert what the launch asked for, not merely that it asked. createTerminal: vi.fn(async (_selector: string, createOptions?: Record) => { if (options.terminalPaneAlreadyLive && createOptions?.requireFreshPane === true) { throw new AgentLaunchPaneAlreadyLiveError() } + reportPromptCarry(options, createOptions?.onStartupPromptCarry, createOptions?.startupPrompt) return { handle: 'term_1', ...(options.terminalPaneKey ? { paneKey: options.terminalPaneKey } : {}), diff --git a/src/main/runtime/rpc/methods/agent-launch.ts b/src/main/runtime/rpc/methods/agent-launch.ts index 1c7448e5ca2..dbb2f6a5cac 100644 --- a/src/main/runtime/rpc/methods/agent-launch.ts +++ b/src/main/runtime/rpc/methods/agent-launch.ts @@ -105,6 +105,7 @@ async function agentLaunchIntent( ...(params.cwd ? { cwd: params.cwd } : {}), ...(params.launchSource ? { launchSource: params.launchSource } : {}), ...(params.paneKey ? { paneKey: params.paneKey } : {}), + ...(params.viewMode ? { viewMode: params.viewMode } : {}), ...(params.sessionId ? { sessionId: params.sessionId } : {}) } } @@ -150,22 +151,25 @@ async function runAgentLaunch( terminalSpawn?: TerminalSpawnDispatch ): Promise { const callerNavigationId = agentLaunchCallerNavigationId(intent.target, context) - const result = await executeAgentLaunch({ + return executeAgentLaunch({ runtime: context.runtime, intent, surfaces: agentLaunchSurfaceFactory( context, attachOperationId, operationCallerKey, - callerNavigationId === null, + callerNavigationId !== null, terminalSpawn ), - workspaces: agentLaunchWorkspaceFactory(context, intent.agent) + workspaces: agentLaunchWorkspaceFactory(context, intent.agent), + // The tab is shown as it is published, not after a prompt that can take a minute to land. + ...(callerNavigationId !== null + ? { + onSurfacePublished: (surface) => + selectAgentLaunchTabForCaller(context.runtime, surface, callerNavigationId) + } + : {}) }) - if (callerNavigationId !== null) { - selectAgentLaunchTabForCaller(context.runtime, result, callerNavigationId) - } - return result } /** diff --git a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts index 7e10274435b..360225a16a0 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts @@ -29,6 +29,7 @@ import { tearDownFailedWorkerStart } from './failed-worker-start-teardown' import { requireWorkerAuthority, type WorkerEffect } from './worker-topology' import { prepareLocalWorkerStart } from './worker-start-validation' import { deliverAndSettleWorkerStartReadiness } from './worker-start-readiness-settlement' +import { waitForWorkerStartComposer } from '../../../../launched-agent-composer-readiness' type WorkerStartMutation = { callerFingerprint: string @@ -188,17 +189,18 @@ export async function startLocalWorker(args: { effects, timeoutMs: params.timeoutMs ?? 60_000 }) - : // ZCode emits SessionStart only after input; its first dispatch must wait for the composer. - agent === 'zcode' && !params.terminal - ? await runtime.waitForFreshWorkerComposer( + : // A caller-supplied terminal was not freshly launched, so its composer marker may be long gone. + params.terminal || !agent + ? await runtime.waitForTerminal(terminalHandle, { + condition: 'tui-idle', + timeoutMs: params.timeoutMs ?? 60_000 + }) + : await waitForWorkerStartComposer( + runtime, terminalHandle, agent, params.timeoutMs ?? 60_000 ) - : await runtime.waitForTerminal(terminalHandle, { - condition: 'tui-idle', - timeoutMs: params.timeoutMs ?? 60_000 - }) if (wait) { persistWorkerSetupWaitOutcome({ ...setupStage, wait }) if (!wait.satisfied) { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts index fc7c1f3b26c..5e3a22294c8 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts @@ -390,6 +390,25 @@ describe('orchestration new-worktree workers', () => { ) }) + // Why: a first dispatch the composer signal settles must record setup exactly as one the idle + // wait settles; the composer lane once returned nothing and skipped this record. + it('settles a fresh worker start on main’s idle wait, not the launch paste’s signal', async () => { + mockCreatedWorktree({ startupPolicy: 'wait-for-setup', state: 'running' }) + const composerSignal = vi.spyOn(runtime, 'waitForFreshWorkerComposer') + + const { result } = await startWorker() + + expect(composerSignal).not.toHaveBeenCalled() + expect(runtime.waitForTerminal).toHaveBeenCalledWith( + 'term_worker', + expect.objectContaining({ condition: 'tui-idle', launchReadiness: true }) + ) + expect(result).toMatchObject({ + state: 'ready', + setup: { startupPolicy: 'wait-for-setup', state: 'succeeded' } + }) + }) + it('does not inject task input when the gated setup terminal fails to start', async () => { mockCreatedWorktree({ startupPolicy: 'wait-for-setup', state: 'spawn_failed' }) vi.mocked(runtime.waitForTerminal).mockResolvedValue({ diff --git a/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts index 30d32c2ede8..711a8a878a6 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts @@ -1,28 +1,41 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeTerminalWait } from '../../../../../../shared/runtime-types' import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' -describe('ZCode first dispatch readiness', () => { +describe('composer-marker first dispatch readiness', () => { const h = createOrchestrationWorkerReleaseHarness() afterEach(() => h.cleanup()) - it('waits for the new composer before delivering exactly one dispatch', async () => { - h.setup() - const gate = h.deferred() - vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockReturnValue(gate.promise) - const pending = h.startWorker({ agent: 'zcode' }) - await vi.waitFor(() => - expect(h.runtime.waitForFreshWorkerComposer).toHaveBeenCalledWith( - 'term_worker', - 'zcode', - 60_000 + // DSH's idle hook fires only after a turn, and Grok's only other signal is its bare name, which a + // shell auto-title also writes; like ZCode, their captured composer is their readiness. + it.each(['zcode', 'dsh', 'grok'] as const)( + 'waits for %s’s new composer before delivering exactly one dispatch', + async (agent) => { + h.setup() + const gate = h.deferred() + vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockReturnValue(gate.promise) + const pending = h.startWorker({ agent }) + await vi.waitFor(() => + expect(h.runtime.waitForFreshWorkerComposer).toHaveBeenCalledWith( + 'term_worker', + agent, + 60_000, + expect.anything() + ) ) - ) - expect(h.runtime.waitForTerminal).not.toHaveBeenCalled() - expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() - gate.resolve() - await pending - expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledOnce() - }) + expect(h.runtime.waitForTerminal).not.toHaveBeenCalled() + expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + gate.resolve({ + handle: 'term_worker', + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null + }) + await pending + expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledOnce() + } + ) it('keeps reused terminals on the normal idle wait', async () => { h.setup() diff --git a/src/main/runtime/rpc/rpc-streaming-dispatcher.ts b/src/main/runtime/rpc/rpc-streaming-dispatcher.ts index 07706125c23..41847a0eb23 100644 --- a/src/main/runtime/rpc/rpc-streaming-dispatcher.ts +++ b/src/main/runtime/rpc/rpc-streaming-dispatcher.ts @@ -138,6 +138,7 @@ export class RpcStreamingDispatcher { subscriptionRegistrationVersion, clientId: options?.clientId, pairedDeviceId: options?.pairedDeviceId, + trustedLocalCallerId: options?.trustedLocalCallerId, clientKind: options?.clientKind, clientCapabilities: options?.clientCapabilities, updateClientCapabilities: options?.updateClientCapabilities, @@ -192,6 +193,7 @@ export class RpcStreamingDispatcher { connectionId: options?.connectionId, clientId: options?.clientId, pairedDeviceId: options?.pairedDeviceId, + trustedLocalCallerId: options?.trustedLocalCallerId, clientKind: options?.clientKind, clientCapabilities: options?.clientCapabilities, updateClientCapabilities: options?.updateClientCapabilities, diff --git a/src/main/runtime/runtime-managed-worktree-create-types.ts b/src/main/runtime/runtime-managed-worktree-create-types.ts index 9edd5b23836..e92b8beba92 100644 --- a/src/main/runtime/runtime-managed-worktree-create-types.ts +++ b/src/main/runtime/runtime-managed-worktree-create-types.ts @@ -51,6 +51,9 @@ export type RuntimeManagedWorktreeCreateArgs = { startupAgent?: TuiAgent startupLaunchPreferences?: AgentLaunchPreferences startupPrompt?: string + /** Main-internal: set by a caller that delivers an uncarried `startupPrompt` itself, so the text + * rides only a typed line that can carry it; reports whether it did. */ + onStartupPromptCarry?: (carried: boolean) => void /** Per-launch inputs used when `startupAgent` is the created terminal surface. */ startupAgentArgs?: string | null startupCwd?: string diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index d623a3f1fd1..0c60c11199a 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -42,12 +42,16 @@ export type TerminalCreateOptions = { launchAgent?: TuiAgent startupAgent?: TuiAgent /** - * Initial text folded into `startupAgent`'s launch command, for an agent whose CLI takes a prompt + * Initial text offered to `startupAgent`'s launch command, for an agent whose CLI takes a prompt * argument. Not a general prompt channel: an agent that takes its text only after start has no * launch command to carry it, and a caller that sets this for one is refused rather than having - * the prompt silently dropped. Post-start delivery belongs to whoever owns the live PTY. + * the prompt silently dropped. The text rides only when the typed line can carry it + * (`startup-line-prompt-carry`); otherwise the agent starts clean, `onStartupPromptCarry` says so, + * and post-start delivery belongs to whoever owns the live PTY. */ startupPrompt?: string + /** Main-internal: whether `startupPrompt` rode the launch command. Called once the plan is built. */ + onStartupPromptCarry?: (carried: boolean) => void /** * Replaces the Settings launch arguments for this `startupAgent` only; `null` means none at all. * @@ -200,6 +204,9 @@ export type TerminalWaiter = { /** Retires this waiter from the shared idle-poll sweep; null when not polling. */ cancelIdlePoll: (() => void) | null abortCleanup: (() => void) | null + /** Main-internal: waiting for a just-launched agent, so a name-only title must be held to a + * quiet stream (`TuiIdleEvaluationInput.launchReadiness`). */ + launchReadiness?: boolean } /** How a provider-held screen should be fetched when runtime bytes are absent. */ @@ -215,6 +222,9 @@ export type RuntimeAgentPromptWriteOptions = Omit /** Raw prompt text for submit scheduling; not written, only used for line-aware delays. */ promptForSchedule?: string + /** The caller just saw this agent's composer accept input, so Enter follows the paste on the + * desktop draft paste's timing instead of waiting for the render to settle. */ + composerReady?: boolean /** See buildAgentPromptPasteBytes. */ leadLine?: string /** Return an accepted receipt as soon as input lands, instead of waiting for the turn. */ diff --git a/src/main/runtime/runtime-terminal-idle-polls.ts b/src/main/runtime/runtime-terminal-idle-polls.ts index d63b59974ee..c5426fb43d8 100644 --- a/src/main/runtime/runtime-terminal-idle-polls.ts +++ b/src/main/runtime/runtime-terminal-idle-polls.ts @@ -133,7 +133,10 @@ export class RuntimeTerminalIdlePolls { const readWaitText = () => buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview) return { - verdict: evaluateTuiIdle(ptyTuiIdleEvidence(this.deps, pty, readWaitText)), + verdict: evaluateTuiIdle({ + ...ptyTuiIdleEvidence(this.deps, pty, readWaitText), + launchReadiness: entry.waiter.launchReadiness + }), ptyId: pty.ptyId, ready: () => buildPtyTerminalWaitResult(handle, 'tui-idle', pty), blocked: (reason) => buildPtyTerminalWaitBlockedResult(handle, 'tui-idle', pty, reason), @@ -152,7 +155,10 @@ export class RuntimeTerminalIdlePolls { buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview) const live = () => this.deps.getLiveLeaf(entry.leaf) return { - verdict: evaluateTuiIdle(leafTuiIdleEvidence(this.deps, leaf, readWaitText)), + verdict: evaluateTuiIdle({ + ...leafTuiIdleEvidence(this.deps, leaf, readWaitText), + launchReadiness: entry.waiter.launchReadiness + }), ptyId: leaf.ptyId, ready: () => buildTerminalWaitResult(handle, 'tui-idle', live()), blocked: (reason) => buildTerminalWaitBlockedResult(handle, 'tui-idle', live(), reason), diff --git a/src/main/runtime/runtime-terminal-wait.ts b/src/main/runtime/runtime-terminal-wait.ts index 240f5508581..fdb62642edc 100644 --- a/src/main/runtime/runtime-terminal-wait.ts +++ b/src/main/runtime/runtime-terminal-wait.ts @@ -58,6 +58,8 @@ export class RuntimeTerminalWait { condition?: RuntimeTerminalWaitCondition timeoutMs?: number signal?: AbortSignal + /** Main-internal, never on the wire: see `TerminalWaiter.launchReadiness`. */ + launchReadiness?: boolean } ): Promise { const condition = options?.condition ?? 'exit' @@ -94,7 +96,8 @@ export class RuntimeTerminalWait { reject, timeout: null, cancelIdlePoll: null, - abortCleanup: null + abortCleanup: null, + ...(options?.launchReadiness ? { launchReadiness: true } : {}) } if (!this.waiters.bindAbort(waiter, options?.signal)) { reject(new Error('request_aborted')) @@ -184,7 +187,8 @@ export class RuntimeTerminalWait { reject, timeout: null, cancelIdlePoll: null, - abortCleanup: null + abortCleanup: null, + ...(options?.launchReadiness ? { launchReadiness: true } : {}) } if (!this.waiters.bindAbort(waiter, options?.signal)) { diff --git a/src/main/runtime/runtime-worktree-agent-startup.test.ts b/src/main/runtime/runtime-worktree-agent-startup.test.ts index 5fa98186449..9d90ef4900f 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.test.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.test.ts @@ -108,6 +108,33 @@ describe('buildWorktreeStartupForAgent host resolution', () => { }) }) +describe('buildWorktreeStartupForAgent prompt carry', () => { + const build = (onPromptCarry?: (carried: boolean) => void) => + buildWorktreeStartupForAgent({ + repo: makeRepo({}), + settings, + agent: 'claude', + prompt: 'summarize the diff\nthen list the risks', + getLaunchPlatform: () => 'linux', + toSessionOptions: () => undefined, + ...(onPromptCarry ? { onPromptCarry } : {}) + }) + + it('starts clean and reports it when a caller that pastes offers a prompt the line cannot carry', () => { + const onPromptCarry = vi.fn() + const result = build(onPromptCarry) + + expect(result.startup.command).not.toContain('summarize') + expect(result.followup).toBeUndefined() + expect(onPromptCarry).toHaveBeenCalledWith(false) + }) + + it('keeps folding the prompt for a caller that delivers nothing afterwards', () => { + // `orca worktree create --prompt` has no post-start paste of its own for an argv agent. + expect(build().startup.command).toContain('summarize the diff') + }) +}) + describe('buildWorktreeStartupForDraft agent detection', () => { it('probes the SSH host named only by executionHostId instead of this client', async () => { mocks.detectRemoteAgents.mockResolvedValueOnce(['claude']) diff --git a/src/main/runtime/runtime-worktree-agent-startup.ts b/src/main/runtime/runtime-worktree-agent-startup.ts index a65f2527357..23069c0be03 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.ts @@ -9,6 +9,7 @@ import { isTuiAgent } from '../../shared/tui-agent-config' import { isTuiAgentEnabled, pickTuiAgent } from '../../shared/tui-agent-selection' import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '../../shared/tui-agent-startup' +import { planStartupWithPromptCandidate } from '../../shared/startup-line-prompt-carry' import { detectInstalledAgentsWithShellPathHydration, detectRemoteAgents @@ -127,6 +128,9 @@ export function buildWorktreeStartupForAgent( toSessionOptions: ( preferences?: AgentLaunchPreferences ) => Parameters[0]['sessionOptions'] | undefined + /** Set by a caller that delivers an uncarried prompt itself: the prompt then rides only a typed + * line that can carry it, and this reports whether it did. Absent keeps the CLI's fold. */ + onPromptCarry?: (carried: boolean) => void } ): { agent: TuiAgent @@ -137,18 +141,25 @@ export function buildWorktreeStartupForAgent( if (!isTuiAgentEnabled(agent, settings.disabledTuiAgents)) { throw new Error('Selected agent is disabled. Choose an enabled agent before creating.') } - const startupPlan = buildAgentStartupPlan({ - ...resolveAgentStartupPlanInputs({ - agent, - settings, - platform: environment.getLaunchPlatform(), - isRemote: repoIsRemote(repo), - ...(environment.agentArgs !== undefined ? { agentArgs: environment.agentArgs } : {}), - sessionOptions: environment.toSessionOptions(environment.launchPreferences) - }), - prompt: environment.prompt ?? '', - allowEmptyPromptLaunch: true + const planInputs = resolveAgentStartupPlanInputs({ + agent, + settings, + platform: environment.getLaunchPlatform(), + isRemote: repoIsRemote(repo), + ...(environment.agentArgs !== undefined ? { agentArgs: environment.agentArgs } : {}), + sessionOptions: environment.toSessionOptions(environment.launchPreferences) }) + const prompt = environment.prompt ?? '' + let startupPlan: ReturnType + if (environment.onPromptCarry) { + const offered = planStartupWithPromptCandidate(planInputs, prompt) + startupPlan = offered.plan + if (startupPlan && prompt.trim()) { + environment.onPromptCarry(offered.promptCarried) + } + } else { + startupPlan = buildAgentStartupPlan({ ...planInputs, prompt, allowEmptyPromptLaunch: true }) + } if (!startupPlan) { throw new Error(`Could not build launch command for ${agent}.`) } diff --git a/src/main/runtime/runtime-worktree-startup-readiness.ts b/src/main/runtime/runtime-worktree-startup-readiness.ts index 779f16ec6a4..fed0371836f 100644 --- a/src/main/runtime/runtime-worktree-startup-readiness.ts +++ b/src/main/runtime/runtime-worktree-startup-readiness.ts @@ -13,6 +13,9 @@ import type { const BRACKETED_PASTE_BEGIN = '\x1b[200~' const BRACKETED_PASTE_END = '\x1b[201~' const BRACKETED_PASTE_QUIET_MS = 1500 +// Why: an interactive shell turns bracketed paste on at its prompt and off when it runs the typed +// command (`zsh-prompt-runs-command.txt`), so a 2004 before the last `?2004l` is the shell's. +const DECRST_BRACKETED_PASTE = '\x1b[?2004l' export type WorktreeStartupReadinessHost = { getPtyId: (handle: string) => string | null @@ -87,24 +90,47 @@ export async function waitForWorktreeStartupFollowup( return null } +export type StartupDraftReadinessOptions = { + timeoutMs?: number + requireComposerMarker?: boolean + signal?: AbortSignal + /** Vetoes a ready signal whose screen still holds something the input must not answer; the + * scan continues, so the agent's next marker or quiet window asks again. */ + accept?: (ptyId: string) => boolean | Promise + /** + * For a launched agent. With it, only output after the shell's last `?2004l` counts, since the + * shell's own prompt enables bracketed paste too, and a ready signal is dropped while a shell is + * proven in front: the launch line has not run yet, or the agent exited. + */ + isShellInFront?: (ptyId: string) => Promise +} + export function waitForWorktreeStartupDraft( host: WorktreeStartupReadinessHost, handle: string, agent: TuiAgent, - options: { timeoutMs?: number; requireComposerMarker?: boolean } = {} + options: StartupDraftReadinessOptions = {} ): Promise { const ptyId = host.getPtyId(handle) - if (!ptyId) { + if (!ptyId || options.signal?.aborted) { return Promise.resolve(null) } const signal = TUI_AGENT_CONFIG[agent].draftPasteReadySignal ?? 'render-quiet-after-bracketed-paste' + const isShellInFront = options.isShellInFront return new Promise((resolve) => { let settled = false - const scanner = createDraftPasteReadyScanner(signal) + let scanner = createDraftPasteReadyScanner(signal) let quietTimer: NodeJS.Timeout | null = null let hardTimer: NodeJS.Timeout | null = null let unsubscribe: (() => void) | null = null + // Bumped at each shell hand-off: a check begun before one must not settle the wait after it. + let handoffs = 0 + let handoffCarry = '' + let checking = false + /** The hand-off count at a signal that fired while a check ran. */ + let recheckAt: number | null = null + const onAbort = (): void => finish(null) const finish = (value: string | null): void => { if (settled) { return @@ -117,23 +143,74 @@ export function waitForWorktreeStartupDraft( clearTimeout(hardTimer) } unsubscribe?.() + options.signal?.removeEventListener('abort', onAbort) resolve(value) } - const observe = (data: string): void => { + /** Settles a fired signal once its screen is clear and no shell is proven in front. */ + const settleSignal = async (signalHandoffs: number): Promise => { + checking = true + try { + if (options.accept && !(await options.accept(ptyId))) { + return + } + if (isShellInFront && (await isShellInFront(ptyId))) { + return + } + if (signalHandoffs === handoffs) { + finish(ptyId) + } + } catch { + // A check that failed is no settle; the agent's next signal asks again. + } finally { + checking = false + const next = recheckAt + recheckAt = null + if (next === handoffs && !settled) { + void settleSignal(next) + } + } + } + const onSignal = (): void => { + if (checking) { + recheckAt = handoffs + return + } + void settleSignal(handoffs) + } + /** The part of a chunk after the shell's last hand-off in it, resetting the scan at one. */ + const sinceShellHandoff = (chunk: string): string => { + const window = handoffCarry + chunk + // Why 7: one short of the sequence, so a split one is rejoined and never counted twice. + handoffCarry = window.slice(-(DECRST_BRACKETED_PASTE.length - 1)) + const handoff = window.lastIndexOf(DECRST_BRACKETED_PASTE) + if (handoff === -1) { + return chunk + } + handoffs += 1 + scanner = createDraftPasteReadyScanner(signal) + if (quietTimer) { + clearTimeout(quietTimer) + quietTimer = null + } + return window.slice(handoff + DECRST_BRACKETED_PASTE.length) + } + const observe = (chunk: string): void => { if (settled) { return } + const data = isShellInFront ? sinceShellHandoff(chunk) : chunk const result = scanner.observe(data) if (result.ready) { - return finish(ptyId) + return onSignal() } if (result.armQuietTimer && !options.requireComposerMarker) { if (quietTimer) { clearTimeout(quietTimer) } - quietTimer = setTimeout(() => finish(ptyId), BRACKETED_PASTE_QUIET_MS) + quietTimer = setTimeout(onSignal, BRACKETED_PASTE_QUIET_MS) } } + options.signal?.addEventListener('abort', onAbort) unsubscribe = host.subscribeToData(ptyId, observe) hardTimer = setTimeout( () => finish(null), diff --git a/src/main/runtime/tui-idle-evidence.ts b/src/main/runtime/tui-idle-evidence.ts index 3df42c82166..e4fdcf2f615 100644 --- a/src/main/runtime/tui-idle-evidence.ts +++ b/src/main/runtime/tui-idle-evidence.ts @@ -147,12 +147,15 @@ export function nameOnlyIdleNeedsCorroboration( export function hasSustainedTitleIdle( record: TuiIdleEvidenceRecord, agent: TuiAgent | null | undefined, - quiescenceMs: number + quiescenceMs: number, + launchReadiness = false ): boolean { if (record.lastAgentStatus !== 'idle') { return false } - if (!nameOnlyIdleNeedsCorroboration(agent, record.lastOscTitle)) { + // Why launch readiness always corroborates: a shell auto-title (`grok`, `gemini`) names the + // agent before its TUI mounts, and a paste then lands in a booting TUI or the shell itself. + if (!launchReadiness && !nameOnlyIdleNeedsCorroboration(agent, record.lastOscTitle)) { // The title is the only rest signal this agent emits, so there is nothing to wait for. return true } @@ -201,6 +204,9 @@ export type TuiIdleEvaluationInput = { agent: TuiAgent | null | undefined firstPartyStatus: FirstPartyAgentStatus quiescenceMs: number + /** Waiting for a just-launched agent to open its composer, where a name-only title proves + * nothing until the stream goes quiet. */ + launchReadiness?: boolean } export type TuiIdleVerdict = @@ -297,7 +303,7 @@ export function evaluateTuiIdle(input: TuiIdleEvaluationInput): TuiIdleVerdict { if (input.record.lastAgentStatus === 'working') { return WORKING } - if (hasSustainedTitleIdle(input.record, input.agent, input.quiescenceMs)) { + if (hasSustainedTitleIdle(input.record, input.agent, input.quiescenceMs, input.launchReadiness)) { return READY_WEAK } return { diff --git a/src/main/runtime/tui-idle-launch-readiness-shell-title.test.ts b/src/main/runtime/tui-idle-launch-readiness-shell-title.test.ts new file mode 100644 index 00000000000..ab2677a98f4 --- /dev/null +++ b/src/main/runtime/tui-idle-launch-readiness-shell-title.test.ts @@ -0,0 +1,95 @@ +/** + * A shell that titles the command it is about to run (zsh preexec auto-title) writes the agent's + * bare name before the agent's TUI has mounted. For an agent whose only rest signal is that name, + * the title alone used to settle `tui-idle`, so a launch pasted its prompt into a TUI still + * booting, or into the shell. A launch readiness wait holds that title to a quiet stream. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +const ESC = String.fromCharCode(27) +const BEL = String.fromCharCode(7) +const BOOT_FRAME = 'Loading…\r\n' + +/** The shell echoes the launch line, then its preexec hook titles the pane with the command. */ +async function launchedPaneTitledByShell(agent: 'gemini' | 'copilot') { + return createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: agent, + launchAgent: agent, + data: `$ ${agent}\r\n${ESC}]2;${agent}${BEL}` + }) +} + +/** The agent is still painting its boot screen: output keeps arriving each second. */ +async function keepBooting( + runtime: Awaited>['runtime'], + seconds: number +): Promise { + for (let second = 0; second < seconds; second += 1) { + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, BOOT_FRAME, Date.now()) + await vi.advanceTimersByTimeAsync(1000) + } +} + +describe('a launch waiting on an agent whose shell titled the pane with its name', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + // Gemini's bare name was once normalized into its rest glyph; Copilot's name is its only signal. + it.each(['gemini', 'copilot'] as const)( + 'does not treat the shell’s auto-title as readiness while %s is still booting', + async (agent) => { + const { runtime, handle } = await launchedPaneTitledByShell(agent) + const settled = vi.fn() + runtime + .waitForTerminal(handle, { + condition: 'tui-idle', + timeoutMs: 60_000, + launchReadiness: true + }) + .then(settled, settled) + + await keepBooting(runtime, 10) + + expect(settled).not.toHaveBeenCalled() + } + ) + + it('settles once the agent’s stream goes quiet under that title', async () => { + const { runtime, handle } = await launchedPaneTitledByShell('gemini') + const settled = vi.fn() + runtime + .waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 60_000, launchReadiness: true }) + .then(settled, settled) + + await keepBooting(runtime, 4) + await vi.advanceTimersByTimeAsync(8_000) + + expect(settled).toHaveBeenCalledWith(expect.objectContaining({ satisfied: true })) + }) + + it('keeps settling a later, non-launch wait on the name alone, as agents that never quiet need', async () => { + const { runtime, handle } = await launchedPaneTitledByShell('gemini') + const settled = vi.fn() + runtime + .waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 60_000 }) + .then(settled, settled) + + await keepBooting(runtime, 10) + + expect(settled).toHaveBeenCalledWith(expect.objectContaining({ satisfied: true })) + }) +}) diff --git a/src/main/runtime/zcode-readiness-transcript.test.ts b/src/main/runtime/zcode-readiness-transcript.test.ts index bd9909f89de..958f7c77220 100644 --- a/src/main/runtime/zcode-readiness-transcript.test.ts +++ b/src/main/runtime/zcode-readiness-transcript.test.ts @@ -28,9 +28,9 @@ describe('ZCode readiness from captured terminal bytes', () => { launchAgent: 'zcode', data: '\x1b[?1049h╭' }) - await expect( - runtime.waitForFreshWorkerComposer(handle, 'zcode', 1_000) - ).resolves.toBeUndefined() + await expect(runtime.waitForFreshWorkerComposer(handle, 'zcode', 1_000)).resolves.toMatchObject( + { condition: 'tui-idle', satisfied: true } + ) }) it('never emits an OSC title, so no title lane can settle its wait', () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-bracketed-paste.test.ts b/src/renderer/src/components/terminal-pane/terminal-bracketed-paste.test.ts index 4fb38ee662e..beb4c762f05 100644 --- a/src/renderer/src/components/terminal-pane/terminal-bracketed-paste.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-bracketed-paste.test.ts @@ -3,8 +3,10 @@ import { markTerminalBracketedPasteInterrupted, observeTerminalBracketedPasteModeOutput, pasteTerminalText, - sanitizeTerminalPasteText + sanitizeTerminalPasteText, + wrapTerminalBracketedPasteText } from './terminal-bracketed-paste' +import { wrapTerminalBracketedPasteText as hostPasteFrame } from '../../../../shared/terminal-bracketed-paste-text' function createTerminal(bracketedPasteMode = true) { const terminal = { @@ -21,6 +23,11 @@ function createTerminal(bracketedPasteMode = true) { } describe('terminal bracketed paste policy', () => { + // The host's launch prompt replaced the desktop's draft paste; one function keeps their bytes equal. + it('frames pasted text with the same function as the host launch prompt', () => { + expect(wrapTerminalBracketedPasteText).toBe(hostPasteFrame) + }) + it('temporarily ignores bracketed paste wrappers for single-line paste after Ctrl+C', () => { const terminal = createTerminal(true) const observedIgnoreValues: (boolean | undefined)[] = [] diff --git a/src/renderer/src/components/terminal-pane/terminal-bracketed-paste.ts b/src/renderer/src/components/terminal-pane/terminal-bracketed-paste.ts index ab0960a3ff7..abf90977ef1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-bracketed-paste.ts +++ b/src/renderer/src/components/terminal-pane/terminal-bracketed-paste.ts @@ -1,5 +1,20 @@ import type { Terminal } from '@xterm/xterm' import type { WindowsInputRecordNewline } from './terminal-paste-model' +import { + BRACKETED_PASTE_END, + BRACKETED_PASTE_START, + normalizeTerminalPasteLineEndings, + sanitizeBracketedPasteText, + wrapTerminalBracketedPasteText +} from '../../../../shared/terminal-bracketed-paste-text' + +export { + BRACKETED_PASTE_END, + BRACKETED_PASTE_START, + normalizeTerminalPasteLineEndings, + sanitizeBracketedPasteText, + wrapTerminalBracketedPasteText +} type BracketedPasteTerminal = { modes: { @@ -21,8 +36,6 @@ type PasteTerminalTextOptions = { const interruptedBracketedPasteTerminals = new WeakSet() const bracketedPasteModeOutputTail = new WeakMap() const ESCAPE = '\u001b' -export const BRACKETED_PASTE_START = `${ESCAPE}[200~` -export const BRACKETED_PASTE_END = `${ESCAPE}[201~` const BRACKETED_PASTE_MODE_SEQUENCE_RE = /^\[\?(?:\d+;)*2004(?:;\d+)*[hl]/ const BRACKETED_PASTE_MODE_TAIL_MAX = 128 const BRACKETED_PASTE_MODE_SEQUENCE_SCAN_MAX = BRACKETED_PASTE_MODE_TAIL_MAX @@ -45,40 +58,10 @@ function hasBracketedPasteModeSequence(data: string): boolean { return false } -// Why: an embedded ESC (e.g. a pasted `\x1b[201~` from scrollback) would close -// the bracketed-paste frame early and run the tail as keystrokes. Replacing ESC -// with its printable substitute (\u241b, U+241B) neutralizes every framing escape. -export function sanitizeBracketedPasteText(text: string): string { - let escapeIndex = text.indexOf(ESCAPE) - if (escapeIndex === -1) { - return text - } - - let sanitized = '' - let start = 0 - while (escapeIndex !== -1) { - sanitized += `${text.slice(start, escapeIndex)}\u241b` - start = escapeIndex + ESCAPE.length - escapeIndex = text.indexOf(ESCAPE, start) - } - return sanitized + text.slice(start) -} - export function sanitizeTerminalPasteText(text: string): string { return sanitizeBracketedPasteText(text) } -export function normalizeTerminalPasteLineEndings(text: string): string { - // Why: xterm's native paste path converts every clipboard newline to CR. - // Direct frames must match it or ConPTY TUIs can treat raw LF as submit. - return text.replace(/\r?\n/g, '\r') -} - -export function wrapTerminalBracketedPasteText(text: string): string { - const normalizedText = normalizeTerminalPasteLineEndings(text) - return `${BRACKETED_PASTE_START}${sanitizeBracketedPasteText(normalizedText)}${BRACKETED_PASTE_END}` -} - export function encodeWindowsInputRecordPasteText( text: string, newline: WindowsInputRecordNewline diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index feca855338a..de115c10f45 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -2,6 +2,7 @@ import type { GlobalSettings } from '../../../shared/global-settings-types' import type { TuiAgent } from '../../../shared/tui-agent' import type { TerminalInputKind } from '../../../shared/terminal-input-kind' import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' +import { AGENT_PROMPT_POST_PASTE_SUBMIT_DELAY_MS } from '../../../shared/agent-prompt-injection' import { resolveDraftPasteReadyTimeoutMs } from '../../../shared/draft-paste-ready-timeout' import { useAppStore } from '@/store' import { @@ -34,7 +35,7 @@ export { // line-edit shortcuts. Callers choose whether to append Enter after the paste. export const BRACKETED_PASTE_BEGIN = BRACKETED_PASTE_START export { BRACKETED_PASTE_END } -export const POST_PASTE_SUBMIT_DELAY_MS = 50 +export const POST_PASTE_SUBMIT_DELAY_MS = AGENT_PROMPT_POST_PASTE_SUBMIT_DELAY_MS // Why: "the tab has a PTY" and "the agent's composer accepts input" are separate // states with separate failure modes, so they get separate budgets. A PTY that diff --git a/src/shared/agent-launch-intent.ts b/src/shared/agent-launch-intent.ts index bffb93c1b9a..2f59619dc2c 100644 --- a/src/shared/agent-launch-intent.ts +++ b/src/shared/agent-launch-intent.ts @@ -93,6 +93,8 @@ export type AgentLaunchIntent = { /** The `tabId:leafId` a terminal launch creates its pane under, for a caller that places its own * tabs. Not a route input; refused when that pane is already live. */ paneKey?: string + /** How a terminal launched into an existing workspace first shows its tab. Not a route input. */ + viewMode?: 'terminal' | 'chat' /** The caller-minted id of the chat session a structured launch creates. Not a route input; * refused when that session already exists. */ sessionId?: string diff --git a/src/shared/agent-launch-replay-refusal.ts b/src/shared/agent-launch-replay-refusal.ts new file mode 100644 index 00000000000..c4cc52fc747 --- /dev/null +++ b/src/shared/agent-launch-replay-refusal.ts @@ -0,0 +1,52 @@ +/** + * What an `agent.launchReplay` refusal proves about the launch, for every client that replays one. + * + * Kept in one place because the three answers call for opposite actions: `unsupported` means nothing + * ran and the client may use its older launch path; `unknown` means the agent may be running and the + * client must not launch again on its own; `failed` means the host refused before starting anything. + */ + +import { AGENT_LAUNCH_PANE_ALREADY_LIVE_CODE } from './agent-launch-pane-already-live' +import { AGENT_LAUNCH_SESSION_ALREADY_EXISTS_CODE } from './agent-launch-session-already-exists' + +export type AgentLaunchReplayRefusal = 'unsupported' | 'unknown' | 'failed' + +/** The pane or chat the launch reserved is already held. */ +export function isAgentLaunchReservationTakenRefusal(error: { code?: string }): boolean { + return ( + error.code === AGENT_LAUNCH_PANE_ALREADY_LIVE_CODE || + error.code === AGENT_LAUNCH_SESSION_ALREADY_EXISTS_CODE + ) +} + +/** An older host rejects the method rather than a field. */ +export function isAgentLaunchReplayUnsupportedRefusal(error: { code?: string }): boolean { + return ( + error.code === 'method_not_found' || + error.code === 'forbidden' || + error.code === 'agent_launch_replay_unsupported' + ) +} + +export function classifyAgentLaunchReplayRefusal( + error: { code?: string }, + replayed: boolean +): AgentLaunchReplayRefusal { + if (isAgentLaunchReplayUnsupportedRefusal(error)) { + // Only a refusal of the first send proves nothing ran; after a replay it may be a replacement + // connection whose capability list hasn't landed, answering for an attempt that did start. + return replayed ? 'unknown' : 'unsupported' + } + if ( + error.code === 'agent_session_operation_unknown' || + error.code === 'agent_session_operation_expired' + ) { + return 'unknown' + } + if (isAgentLaunchReservationTakenRefusal(error)) { + // A taken reservation proves nothing started only on the first send; after a replay the pane + // or chat holding it may be this launch's own. + return replayed ? 'unknown' : 'failed' + } + return 'failed' +} diff --git a/src/shared/agent-prompt-injection.ts b/src/shared/agent-prompt-injection.ts index b085f6a9048..ba01cbba471 100644 --- a/src/shared/agent-prompt-injection.ts +++ b/src/shared/agent-prompt-injection.ts @@ -6,6 +6,9 @@ import { TUI_AGENT_CONFIG } from './tui-agent-config' export const AGENT_PROMPT_BRACKETED_PASTE_START = '\x1b[200~' export const AGENT_PROMPT_BRACKETED_PASTE_END = '\x1b[201~' export const AGENT_PROMPT_SUBMIT = '\r' +/** Why: Claude Code can leave a prompt as editable text when paste-end and Enter arrive in the + * same PTY write, so the desktop's draft paste sends Enter on the next turn, this long after. */ +export const AGENT_PROMPT_POST_PASTE_SUBMIT_DELAY_MS = 50 /** Why unknown agents keep the lead: an unidentified Claude still needs it, while known non-Claude * TUIs get pre-lead bytes because Codex drops typed text that shares the paste's write (STA-8200). */ diff --git a/src/shared/agent-title-status.ts b/src/shared/agent-title-status.ts index 7cd7a2c292c..364c599bd2e 100644 --- a/src/shared/agent-title-status.ts +++ b/src/shared/agent-title-status.ts @@ -148,7 +148,9 @@ export function normalizeTerminalTitle(title: string): string { if (status === 'working') { return `${GEMINI_WORKING} Gemini CLI` } - if (status === 'idle') { + // Why only with the glyph: a bare `gemini` title (a shell auto-title) reads idle by default, + // and stamping Gemini's rest glyph on it turned a name into explicit readiness. + if (status === 'idle' && title.includes(GEMINI_IDLE)) { return `${GEMINI_IDLE} Gemini CLI` } } diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index d3880313897..0b21ede5706 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -307,6 +307,10 @@ export const AGENT_LAUNCH_RUNTIME_CAPABILITY = 'agent.launch.v2' as const // Optional identity support on agent.launch; mobile replay across replacement hosts requires the new method. export const AGENT_LAUNCH_REPLAY_RUNTIME_CAPABILITY = 'agent.launch.replay.v1' as const +// A host that sends a launch prompt its typed startup line cannot carry to a paste after +// readiness; an older host folds any prompt into that line, so clients gate prompted launches on it. +export const AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY = 'agent.launch.prompt-carry.v1' as const + // agent.launchReplay requires the ledger; older replacement hosts must reject the method. export const AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY = 'agent.launch.replay-required.v1' as const @@ -445,7 +449,8 @@ export const RUNTIME_CAPABILITIES = [ NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY, AGENT_LAUNCH_RUNTIME_CAPABILITY, AGENT_LAUNCH_REPLAY_RUNTIME_CAPABILITY, - AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY + AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY, + AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY ] as const export type RuntimeCapability = (typeof RUNTIME_CAPABILITIES)[number] | (string & {}) diff --git a/src/shared/rpc-contract/agent-launch-params.ts b/src/shared/rpc-contract/agent-launch-params.ts index 3926c610708..0737a1d3e64 100644 --- a/src/shared/rpc-contract/agent-launch-params.ts +++ b/src/shared/rpc-contract/agent-launch-params.ts @@ -102,6 +102,12 @@ export const AgentLaunchFields = z.object({ ) }, 'Malformed launch pane key') .optional(), + /** + * How a terminal launch in an existing workspace first shows its tab, so the tab opens as the + * caller would have opened it. Presentation only; a chat and a new workspace's startup terminal + * ignore it, and an older host strips it. + */ + viewMode: z.enum(['terminal', 'chat']).optional(), /** * The id of the chat session a structured launch creates, minted by the caller so it knows which * conversation it started before the reply arrives. Refused when a session with this id already diff --git a/src/shared/startup-line-prompt-carry.test.ts b/src/shared/startup-line-prompt-carry.test.ts new file mode 100644 index 00000000000..c897edddc87 --- /dev/null +++ b/src/shared/startup-line-prompt-carry.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest' +import { + hasControlByte, + planStartupWithPromptCandidate, + TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES +} from './startup-line-prompt-carry' +import type { TuiAgent } from './tui-agent' +import { + AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY, + RUNTIME_CAPABILITIES +} from './protocol-version' + +function offer(agent: TuiAgent, prompt: string, extra: { cmdOverride?: string } = {}) { + return planStartupWithPromptCandidate( + { + agent, + cmdOverrides: extra.cmdOverride ? { [agent]: extra.cmdOverride } : {}, + platform: 'darwin' + }, + prompt + ) +} + +/** A prompt that brings the quoted `claude ''` line to exactly `bytes`. */ +function promptForClaudeLineOf(bytes: number): string { + const base = offer('claude', '').plan?.launchCommand ?? '' + // ` ''`: one space and two quotes around the text. + return 'x'.repeat(bytes - base.length - 3) +} + +describe('whether a launch prompt rides the typed startup line', () => { + it('carries a short single-line prompt on the launch command', () => { + const { plan, promptCarried } = offer('claude', 'explain this repo') + expect(promptCarried).toBe(true) + expect(plan?.launchCommand).toContain('explain this repo') + }) + + it('carries a line of exactly the budget and refuses one byte past it', () => { + const atBudget = offer('claude', promptForClaudeLineOf(TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES)) + expect(new TextEncoder().encode(atBudget.plan?.launchCommand ?? '').byteLength).toBe( + TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES + ) + expect(atBudget.promptCarried).toBe(true) + + const pastBudget = offer( + 'claude', + promptForClaudeLineOf(TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES + 1) + ) + expect(pastBudget.promptCarried).toBe(false) + }) + + it('measures the quoted line, so quote-heavy text under the budget raw can exceed it typed', () => { + // 200 quotes are 200 raw bytes but 600 once portable quoting expands each to `"'"`. + const quotes = "'".repeat(200) + expect(quotes.length).toBeLessThan(TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES) + const { plan, promptCarried } = offer('claude', quotes) + expect(promptCarried).toBe(false) + expect(plan?.launchCommand).not.toContain(`"'"`) + }) + + it.each([ + ['LF', 'first line\nsecond line'], + ['CRLF', 'first line\r\nsecond line'], + ['CR', 'first line\rsecond line'] + ])('never types a %s-bearing prompt, which a shell would read as Enter', (_label, prompt) => { + const { plan, promptCarried } = offer('codex', prompt) + expect(promptCarried).toBe(false) + // The clean launch: the prompt is left for the paste after start. + expect(plan?.launchCommand).not.toContain('first line') + expect(plan?.followupPrompt).toBeNull() + }) + + it.each([ + ['TAB', 'see\tthis'], + ['ESC', 'red \x1b[31mtext'], + ['^C', 'stop\x03here'], + ['^U', 'kill\x15line'], + ['DEL', 'erase\x7fme'] + ])( + 'never types a %s-bearing prompt, which a line editor would read as a key', + (_label, prompt) => { + const { plan, promptCarried } = offer('claude', prompt) + expect(promptCarried).toBe(false) + expect(hasControlByte(plan?.launchCommand ?? '')).toBe(false) + } + ) + + it('counts the launcher toward the line, so a long configured one leaves no room for the prompt', () => { + const launcher = `claude ${'--add-dir /very/long/path '.repeat(30)}`.trim() + expect(launcher.length).toBeGreaterThan(TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES) + const { plan, promptCarried } = offer('claude', 'hi', { cmdOverride: launcher }) + expect(promptCarried).toBe(false) + expect(plan?.launchCommand).toBe(launcher) + }) + + it('carries a Hermes prompt through its env transport, whose typed line never holds the text', () => { + const multiLine = 'line one\nline two' + const { plan, promptCarried } = offer('hermes', multiLine) + expect(promptCarried).toBe(true) + expect(plan?.launchCommand).not.toContain('line one') + expect(Object.values(plan?.env ?? {})).toContain(multiLine) + }) + + it('launches Hermes clean instead of refusing when its env budget cannot hold the prompt', () => { + const { plan, promptCarried } = offer('hermes', 'x'.repeat(30_000)) + expect(promptCarried).toBe(false) + expect(plan).not.toBeNull() + expect(Object.values(plan?.env ?? {}).join('')).not.toContain('xxxx') + }) + + it('never carries a stdin-after-start agent’s prompt, whose CLI takes none', () => { + const { plan, promptCarried } = offer('aider', 'fix it') + expect(promptCarried).toBe(false) + expect(plan?.followupPrompt).toBeNull() + }) + + it('reports nothing carried for an empty prompt', () => { + expect(offer('claude', ' ').promptCarried).toBe(false) + }) +}) + +describe('the capability clients gate a prompted launch on', () => { + it('is advertised by every host that applies the typed-line rule', () => { + // An older host folds any prompt into the typed line, so its absence is the gate. + expect(RUNTIME_CAPABILITIES).toContain(AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY) + }) +}) diff --git a/src/shared/startup-line-prompt-carry.ts b/src/shared/startup-line-prompt-carry.ts new file mode 100644 index 00000000000..94335922233 --- /dev/null +++ b/src/shared/startup-line-prompt-carry.ts @@ -0,0 +1,79 @@ +/** + * Whether a launch prompt rides the command line that gets TYPED into the user's shell, or the agent + * starts clean and the prompt is pasted once it is ready. + * + * Measured on the built line, not the raw prompt: quoting, the launcher, its arguments and session + * options all land on that line, and every failure a long or multi-line typed line has is a property + * of the line — macOS bash 3.2 reads each newline as Enter, a line editor reads any other control + * byte as a key, a canonical-mode write truncates a line + * past MAX_CANON (1024 on macOS), and cmd caps a line at 8191. Decided here, where the line exists, + * so the answer is a fact about what was built rather than a prediction of it. + */ + +import { TUI_AGENT_CONFIG } from './tui-agent-config' +import { buildAgentStartupPlan, type AgentStartupPlan } from './tui-agent-startup' +import type { TuiAgent } from './tui-agent' + +/** Half of macOS MAX_CANON: a single typed line this long survived every canonical-mode write + * measured, and 1 KiB did not. */ +export const TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES = 512 + +const encoder = new TextEncoder() + +function typedLineBytes(line: string): number { + return encoder.encode(line).byteLength +} + +export function startupLineCarriesPrompt(args: { + agent: TuiAgent + withPrompt: AgentStartupPlan | null +}): boolean { + const { withPrompt } = args + if (!withPrompt || withPrompt.followupPrompt !== null) { + return false + } + // Hermes types a fixed line that reads the prompt from the spawn env, so the line never grows + // with the text; its own env budget already returned null above when the text did not fit. + if (TUI_AGENT_CONFIG[args.agent].promptInjectionMode === 'hermes-query') { + return true + } + const line = withPrompt.launchCommand + return !hasControlByte(line) && typedLineBytes(line) <= TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES +} + +/** Any C0 byte or DEL, not just CR/LF: no quoter escapes them, and a single-line command is written + * raw, so a TAB completes, ESC starts a key sequence, and ^C/^U/^W kill or edit the line. */ +export function hasControlByte(line: string): boolean { + for (let i = 0; i < line.length; i += 1) { + const code = line.charCodeAt(i) + if (code < 0x20 || code === 0x7f) { + return true + } + } + return false +} + +type StartupPlanInputs = Omit< + Parameters[0], + 'prompt' | 'allowEmptyPromptLaunch' +> + +/** + * The startup plan for a launch that offers a prompt: the prompted plan when its typed line can + * carry the text, else the clean plan, with which one it was. + */ +export function planStartupWithPromptCandidate( + inputs: StartupPlanInputs, + prompt: string +): { plan: AgentStartupPlan | null; promptCarried: boolean } { + if (prompt.trim()) { + const withPrompt = buildAgentStartupPlan({ ...inputs, prompt, allowEmptyPromptLaunch: true }) + if (startupLineCarriesPrompt({ agent: inputs.agent, withPrompt })) { + return { plan: withPrompt, promptCarried: true } + } + } + return { + plan: buildAgentStartupPlan({ ...inputs, prompt: '', allowEmptyPromptLaunch: true }), + promptCarried: false + } +} diff --git a/src/shared/telemetry-property-schemas.ts b/src/shared/telemetry-property-schemas.ts index a45a2f8d9f2..8203f5b1730 100644 --- a/src/shared/telemetry-property-schemas.ts +++ b/src/shared/telemetry-property-schemas.ts @@ -141,6 +141,7 @@ export const launchSourceSchema = z.enum([ 'conflict_resolution', 'source_control_recovery', 'terminal_context_menu', + 'explain_commit', // Launches the host performs for a caller outside the desktop app. 'cli', 'mobile', diff --git a/src/shared/terminal-bracketed-paste-text.ts b/src/shared/terminal-bracketed-paste-text.ts new file mode 100644 index 00000000000..cb2cf40d8c7 --- /dev/null +++ b/src/shared/terminal-bracketed-paste-text.ts @@ -0,0 +1,39 @@ +/** + * The bracketed-paste frame Orca writes for pasted text: the renderer's terminal paste and draft + * paste, and the host's launch prompt, which replaced the desktop's draft paste and must send the + * same bytes. + */ + +const ESCAPE = '\u001b' +export const BRACKETED_PASTE_START = `${ESCAPE}[200~` +export const BRACKETED_PASTE_END = `${ESCAPE}[201~` + +// Why: an embedded ESC (e.g. a pasted `\x1b[201~` from scrollback) would close +// the bracketed-paste frame early and run the tail as keystrokes. Replacing ESC +// with its printable substitute (␛, U+241B) neutralizes every framing escape. +export function sanitizeBracketedPasteText(text: string): string { + let escapeIndex = text.indexOf(ESCAPE) + if (escapeIndex === -1) { + return text + } + + let sanitized = '' + let start = 0 + while (escapeIndex !== -1) { + sanitized += `${text.slice(start, escapeIndex)}␛` + start = escapeIndex + ESCAPE.length + escapeIndex = text.indexOf(ESCAPE, start) + } + return sanitized + text.slice(start) +} + +export function normalizeTerminalPasteLineEndings(text: string): string { + // Why: xterm's native paste path converts every clipboard newline to CR. + // Direct frames must match it or ConPTY TUIs can treat raw LF as submit. + return text.replace(/\r?\n/g, '\r') +} + +export function wrapTerminalBracketedPasteText(text: string): string { + const normalizedText = normalizeTerminalPasteLineEndings(text) + return `${BRACKETED_PASTE_START}${sanitizeBracketedPasteText(normalizedText)}${BRACKETED_PASTE_END}` +} diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index d9fdabb14c3..822e6db49ba 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -247,7 +247,10 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { 'qwen-code': { // Why: package is qwen-code but its installed CLI binary on PATH is `qwen`. detectCmd: 'qwen', - promptInjectionMode: 'stdin-after-start' + promptInjectionMode: 'stdin-after-start', + // Why: on Windows (and Node < 20) Qwen ignores Enter until 500 ms after a paste (0.24.7 + // `pasteWorkaround`), so the Enter 50 ms after it is dropped; on an empty composer it is a no-op. + submitRetryDelayMs: 1200 }, rovo: { detectCmd: 'rovo',