From d2680896f72d6b50953430a2b02fbbf33fc9ec08 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:34:51 -0700 Subject: [PATCH 01/42] feat(agent-launch): host-side prompt delivery for agent.launch The host's agent.launch typed any launch prompt into the shell as part of the launch command. A long or multi-line prompt then ran line by line in the shell, and an agent that never showed readiness or crashed at startup had nothing guarding where its text went. agent.launch now carries a prompt on the typed line only when the line stays one line, control-free and at most 512 bytes; otherwise the agent starts clean and the host pastes the prompt once the agent's own ready signal fires (bracketed paste plus its composer marker or a quiet render, read only after the shell's last hand-off, never while the pane's own shell is proven in front), with main's draft-paste bytes and an Enter 50 ms later. Orchestration worker starts wait on tui-idle as before. A replay-safe launch admits and claims its ledger row in one write, Qwen Code gets a second Enter, the desktop and phone share one launch-refusal classifier, and hosts advertise agent.launch.prompt-carry.v1. Split out of #23748, which moves the desktop source-control buttons onto this path. --- .../session/mobile-existing-agent-launch.ts | 46 +- mobile/src/tasks/agent-launch-request.ts | 10 +- .../host-agent-startup-call-sites.txt | 2 +- .../agent-launch-executor.test.ts | 56 ++- .../agent-launch/agent-launch-executor.ts | 50 +- .../agent-launch-prompt-delivery.ts | 35 +- .../agent-launch-surface-factories.ts | 29 +- .../host-agent-startup-attribution.test.ts | 3 +- .../runtime-agent-launch-capability.test.ts | 19 + src/main/ipc/runtime.ts | 2 + .../zsh-prompt-runs-command.meta.json | 9 + .../__fixtures__/zsh-prompt-runs-command.txt | 2 + .../agent-session-operation-admission.ts | 17 + .../runtime/agent-session-record-store.ts | 8 + .../agent-terminal-startup-prompt.test.ts | 37 +- .../agent-transcript-pane-test-harness.ts | 53 +- ...ed-agent-composer-readiness-claude.test.ts | 454 ++++++++++++++++++ ...hed-agent-composer-readiness-codex.test.ts | 107 +++++ .../launched-agent-composer-readiness.test.ts | 157 ++++++ .../launched-agent-composer-readiness.ts | 130 +++++ .../launched-agent-shell-foreground.ts | 92 ++++ ...ched-agent-shell-handoff-readiness.test.ts | 249 ++++++++++ .../launched-agent-write-guard.test.ts | 76 +++ .../runtime/launched-agent-write-guard.ts | 58 +++ .../orca-runtime-activate-managed-worktree.ts | 55 ++- ...ca-runtime-controller-knows-pty-is-live.ts | 5 + ...runtime-create-agent-prompt-render-gate.ts | 1 + .../orca-runtime-create-managed-worktree.ts | 3 +- ...runtime-resolve-worktree-removal-target.ts | 23 +- .../agent-launch-prompt-submit.spec.ts | 126 +++++ ...rca-runtime-write-terminal-agent-prompt.ts | 51 +- src/main/runtime/orca-runtime.test.ts | 1 + src/main/runtime/rpc/core.ts | 3 + .../runtime/rpc/dispatcher-stream-options.ts | 2 + .../dispatcher-trusted-local-caller.test.ts | 47 ++ src/main/runtime/rpc/dispatcher.ts | 1 + .../agent-launch-caller-selection.test.ts | 55 +++ .../methods/agent-launch-caller-selection.ts | 15 +- .../methods/agent-launch-prompt-carry.test.ts | 113 +++++ .../rpc/methods/agent-launch-replay.test.ts | 43 ++ .../rpc/methods/agent-launch-replay.ts | 41 +- .../rpc/methods/agent-launch-surfaces.ts | 31 +- .../agent-launch-terminal-preferences.test.ts | 10 + .../agent-launch-terminal-prompt.test.ts | 271 ++++++++++- .../methods/agent-launch-terminal-prompt.ts | 90 +++- .../methods/agent-launch-worktree-creation.ts | 13 +- .../rpc/methods/agent-launch.test-fixture.ts | 38 +- src/main/runtime/rpc/methods/agent-launch.ts | 18 +- .../worker/local-worker-start.ts | 16 +- .../worker/workers-new-worktree.test.ts | 19 + .../worker/zcode-worker-readiness.test.ts | 49 +- .../runtime/rpc/rpc-streaming-dispatcher.ts | 2 + .../runtime-managed-worktree-create-types.ts | 3 + .../runtime/runtime-terminal-contracts.ts | 14 +- .../runtime/runtime-terminal-idle-polls.ts | 10 +- src/main/runtime/runtime-terminal-wait.ts | 8 +- .../runtime-worktree-agent-startup.test.ts | 27 ++ .../runtime/runtime-worktree-agent-startup.ts | 33 +- .../runtime-worktree-startup-readiness.ts | 89 +++- src/main/runtime/tui-idle-evidence.ts | 12 +- ...-idle-launch-readiness-shell-title.test.ts | 95 ++++ .../zcode-readiness-transcript.test.ts | 6 +- .../terminal-bracketed-paste.test.ts | 9 +- .../terminal-pane/terminal-bracketed-paste.ts | 47 +- src/renderer/src/lib/agent-paste-draft.ts | 3 +- src/shared/agent-launch-intent.ts | 2 + src/shared/agent-launch-replay-refusal.ts | 52 ++ src/shared/agent-prompt-injection.ts | 3 + src/shared/agent-title-status.ts | 4 +- src/shared/protocol-version.ts | 7 +- .../rpc-contract/agent-launch-params.ts | 6 + src/shared/startup-line-prompt-carry.test.ts | 127 +++++ src/shared/startup-line-prompt-carry.ts | 79 +++ src/shared/telemetry-property-schemas.ts | 1 + src/shared/terminal-bracketed-paste-text.ts | 39 ++ src/shared/tui-agent-config.ts | 5 +- 76 files changed, 3248 insertions(+), 276 deletions(-) create mode 100644 src/main/runtime/__fixtures__/zsh-prompt-runs-command.meta.json create mode 100644 src/main/runtime/__fixtures__/zsh-prompt-runs-command.txt create mode 100644 src/main/runtime/launched-agent-composer-readiness-claude.test.ts create mode 100644 src/main/runtime/launched-agent-composer-readiness-codex.test.ts create mode 100644 src/main/runtime/launched-agent-composer-readiness.test.ts create mode 100644 src/main/runtime/launched-agent-composer-readiness.ts create mode 100644 src/main/runtime/launched-agent-shell-foreground.ts create mode 100644 src/main/runtime/launched-agent-shell-handoff-readiness.test.ts create mode 100644 src/main/runtime/launched-agent-write-guard.test.ts create mode 100644 src/main/runtime/launched-agent-write-guard.ts create mode 100644 src/main/runtime/orca-runtime-tests/agent-launch-prompt-submit.spec.ts create mode 100644 src/main/runtime/rpc/dispatcher-trusted-local-caller.test.ts create mode 100644 src/main/runtime/rpc/methods/agent-launch-prompt-carry.test.ts create mode 100644 src/main/runtime/tui-idle-launch-readiness-shell-title.test.ts create mode 100644 src/shared/agent-launch-replay-refusal.ts create mode 100644 src/shared/startup-line-prompt-carry.test.ts create mode 100644 src/shared/startup-line-prompt-carry.ts create mode 100644 src/shared/terminal-bracketed-paste-text.ts diff --git a/mobile/src/session/mobile-existing-agent-launch.ts b/mobile/src/session/mobile-existing-agent-launch.ts index 7cfb5cc2e473..3531838291b2 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 0c3c71687f07..e6179ca9de30 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 de078fd731e9..350030683e99 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 e7270287fb53..210289785ace 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 1d9bae4f45d3..9e2841c87591 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 e77e0cabbe56..b36519013761 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 867eac40ab61..39f8785bc725 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 612caab80017..d29c1cf7c0a9 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 b88a3ca8b245..247d19bc2f6a 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 e768de463d85..94ee84c1fab3 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 000000000000..1e7291a03a86 --- /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 000000000000..eacea47373f8 --- /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 f16d48efb9d8..c83c6d9f0067 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 300e48d370d9..2808a1a38337 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 8a9f9648147b..c6b7370a30a5 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 14ad56a8d9dd..e57105472eee 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 000000000000..889f08a2ef68 --- /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 000000000000..1c1209273c1c --- /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 000000000000..cd8db4e06e84 --- /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 000000000000..06637fdfffa5 --- /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 000000000000..9b5c20bb7f1d --- /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 000000000000..cace85e2859f --- /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 000000000000..4af5f64c3b70 --- /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 000000000000..80f0bf71d6b7 --- /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 1629c5299216..cecf3223b10b 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 3c9c8b7eab94..6eb3e7879f84 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 6afde0468301..2d537606aa49 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 d0885a1dfe1f..fce08f1c0537 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 0aeacdc0e99e..2ec280f056bf 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 000000000000..3cfc80e60403 --- /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 1597084eee15..4f4839678ace 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 070363fdb3fb..0548105af149 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 1402f10b2231..46529f592d7f 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 e3151c66b0e7..ca8c79ad0ac8 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 000000000000..fa8011fdbdd7 --- /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 04600fcc4ecd..871037ea63c4 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 2e99f5801254..584cd4f72e6c 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 b5fe4fe31f4c..a8d913526cb0 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 000000000000..d1549af6a610 --- /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 24d0a80b3d1d..94e25298371f 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 ec9269f4efea..2bab7d125359 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 3f184cd50b5c..2e383e876d25 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 f75fdc803f20..12945df369a4 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 f29c2e814772..0c43c77b40d0 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 ac66912e7475..5af709eb0b1f 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 e62117f83afa..d9c9c40e4696 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 365c843c1ef5..f0a3a7cf9118 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 1c7448e5ca2e..dbb2f6a5cac9 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 7e10274435b5..360225a16a05 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 fc7c1f3b26cf..5e3a22294c8f 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 30d32c2ede83..711a8a878a6f 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 07706125c235..41847a0eb23a 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 9edd5b23836b..e92b8beba928 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 d623a3f1fd14..0c60c11199a6 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 d63b59974ee5..c5426fb43d81 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 240f55085812..fdb62642edcb 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 5fa981864490..9d90ef4900f2 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 a65f25273573..23069c0be03e 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 779f16ec6a40..fed0371836fc 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 3df42c82166c..e4fdcf2f6154 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 000000000000..ab2677a98f48 --- /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 bd9909f89de7..958f7c772200 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 4fb38ee662ec..beb4c762f05c 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 ab0960a3ff71..abf90977ef1a 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 feca855338a2..de115c10f453 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 bffb93c1b9a2..2f59619dc2c1 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 000000000000..c4cc52fc747e --- /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 b085f6a90489..ba01cbba471e 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 7cd7a2c292cd..364c599bd2e8 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 d3880313897a..0b21ede5706f 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 3926c610708a..0737a1d3e647 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 000000000000..c897edddc874 --- /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 000000000000..943359222330 --- /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 a45a2f8d9f20..8203f5b17305 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 000000000000..cb2cf40d8c70 --- /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 d9fdabb14c34..822e6db49ba7 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', From e463aea99c8057aa607cf1f1c4e2e9e26d3eecc6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:18:37 -0700 Subject: [PATCH 02/42] fix(agent-launch): keep a short-lined multi-line prompt on a local zsh launch line, as main did #24257 moved every multi-line or over-512-byte prompt off the typed launch line and pasted it after readiness. The phone's AI buttons and review notes, whose multi-line prompts main typed whole into zsh, then reached Claude 0.5-3 s later and their RPC reply waited for the paste. The host now names the shell a local macOS or Linux line is typed into, the way the spawn picks it, and a multi-line prompt rides a zsh line when every line is at most 512 bytes and the whole line at most 8 KB. A real-zsh test types such a line through Orca's own ready barrier and startup write, including when a slow user config makes the write land early. Elsewhere the measured unsafe cases keep the paste: bash 3.2 runs multi-line lines piecemeal, fish drops an early multi-line write, and any shell loses a line over 1 KB written early. --- ...artup-line-typed-length.live-shell.test.ts | 148 ++++++++++++++++++ .../agent-launch-typed-line-shell.test.ts | 30 ++++ .../runtime/agent-launch-typed-line-shell.ts | 27 ++++ .../agent-terminal-startup-prompt.test.ts | 42 ++++- ...runtime-resolve-worktree-removal-target.ts | 10 +- src/main/runtime/runtime-store-contract.ts | 2 + .../runtime-worktree-agent-startup.test.ts | 12 +- .../runtime/runtime-worktree-agent-startup.ts | 12 +- src/shared/startup-line-prompt-carry.test.ts | 62 +++++++- src/shared/startup-line-prompt-carry.ts | 43 ++++- 10 files changed, 374 insertions(+), 14 deletions(-) create mode 100644 src/main/providers/startup-line-typed-length.live-shell.test.ts create mode 100644 src/main/runtime/agent-launch-typed-line-shell.test.ts create mode 100644 src/main/runtime/agent-launch-typed-line-shell.ts diff --git a/src/main/providers/startup-line-typed-length.live-shell.test.ts b/src/main/providers/startup-line-typed-length.live-shell.test.ts new file mode 100644 index 000000000000..8f6c91063547 --- /dev/null +++ b/src/main/providers/startup-line-typed-length.live-shell.test.ts @@ -0,0 +1,148 @@ +/** + * Real-zsh proof for the typed launch lines `startup-line-prompt-carry.ts` lets a prompt ride: a + * multi-line line whose every line fits the per-line budget, up to the whole-line budget, reaches + * zsh intact through Orca's own wrapper, ready marker and startup write. + * + * Why a slow user config too: the write is released 1.5 s after spawn even without the marker, and + * then lands while the terminal is still line-buffered, where macOS keeps at most MAX_CANON bytes of + * one line. The per-line budget is what survives that; a single 1.1 KB line did not. + */ +import { spawnSync } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import * as pty from 'node-pty' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + createShellStartupOutputScanState, + scanShellStartupOutput +} from '../shell-startup-output-scanner' +import { selectShellStartupFeatures } from '../shell-startup-features' +import { isBracketedPasteSafeShell } from '../../shared/startup-command-submission' +import { + TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES, + ZSH_MULTI_LINE_STARTUP_LINE_BUDGET_BYTES +} from '../../shared/startup-line-prompt-carry' +import { + restoreUserDataPathAfterEach, + setTestUserDataPath +} from './local-pty-shell-ready-test-harness' + +const ZSH_PATH = + process.platform === 'win32' + ? '' + : (spawnSync('sh', ['-c', 'command -v zsh'], { encoding: 'utf8' }).stdout ?? '').trim() + +/** Lines of prompt text, each `perLine` bytes. */ +function promptLines(lineCount: number, perLine: number): string { + const words = 'Fix the failing checks on this branch, then explain what changed and why.' + return Array.from({ length: lineCount }, (_unused, index) => { + let line = `L${index}:` + while (line.length < perLine) { + line += ` ${words}` + } + return line.slice(0, perLine) + }).join('\n') +} + +let home = '' + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'orca-typed-line-home-')) + setTestUserDataPath(mkdtempSync(join(tmpdir(), 'orca-typed-line-ud-'))) +}) + +afterEach(() => { + rmSync(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) +}) + +restoreUserDataPathAfterEach() + +/** Types `printf '%s' '' > out` the way a local pane types a launch line; returns what ran. */ +async function typeIntoZsh(text: string, userConfigSeconds: number): Promise { + if (userConfigSeconds > 0) { + writeFileSync(join(home, '.zshrc'), `sleep ${userConfigSeconds}\n`) + } + const out = join(home, 'out.txt') + const done = join(home, 'done.txt') + const { ensureShellReadyWrappers } = await import('./local-pty-shell-ready-wrapper-generation') + const { getShellLaunchConfig } = await import('./local-pty-shell-ready') + const { writeStartupCommandWhenShellReady, STARTUP_COMMAND_READY_MAX_WAIT_MS } = + await import('./local-pty-shell-ready-startup-command') + ensureShellReadyWrappers() + const env: Record = { PATH: '/usr/bin:/bin', HOME: home } + const launch = getShellLaunchConfig( + ZSH_PATH, + selectShellStartupFeatures({ + shellPath: ZSH_PATH, + env, + hasStartupCommand: true, + waitsForShellReady: true, + emitsStartupIdentity: false + }) + ) + expect(launch.supportsReadyMarker).toBe(true) + const proc = pty.spawn(ZSH_PATH, launch.args ?? [], { + name: 'xterm-256color', + cols: 200, + rows: 40, + cwd: home, + // Why ORCA_ORIG_ZDOTDIR: the user's config is read from the sandbox home, not the real one. + env: { ...env, ...launch.env, ORCA_ORIG_ZDOTDIR: home, TERM: 'xterm-256color' } + }) + const scan = createShellStartupOutputScanState() + let resolveReady: ((signal: { postMarkerBytesObserved: boolean }) => void) | null = null + const ready = new Promise<{ postMarkerBytesObserved: boolean }>((resolve) => { + resolveReady = resolve + }) + // The provider's cap (local-pty-shell-readiness-session.ts): a later marker releases the write. + const cap = setTimeout(() => { + resolveReady?.({ postMarkerBytesObserved: false }) + resolveReady = null + }, STARTUP_COMMAND_READY_MAX_WAIT_MS) + proc.onData((data) => { + if (resolveReady && scanShellStartupOutput(scan, data).ready) { + resolveReady({ postMarkerBytesObserved: true }) + resolveReady = null + } + }) + writeStartupCommandWhenShellReady( + ready, + proc, + `printf '%s' '${text}' > '${out}'; printf ok > '${done}'`, + () => {}, + { + bracketedPasteSafe: isBracketedPasteSafeShell({ + shellName: 'zsh', + waitsForShellReady: launch.supportsReadyMarker === true + }) + } + ) + const deadline = Date.now() + 20_000 + userConfigSeconds * 1000 + while (!existsSync(done) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 50)) + } + clearTimeout(cap) + const ran = existsSync(out) ? readFileSync(out, 'utf8') : null + const exited = new Promise((resolve) => proc.onExit(() => resolve())) + proc.kill() + await Promise.race([exited, new Promise((resolve) => setTimeout(resolve, 2000))]) + return ran +} + +describe.skipIf(!ZSH_PATH)('a multi-line launch line typed into zsh', () => { + // Lines at the per-line budget, as many as the whole-line budget leaves room for. + const perLine = TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES - 1 + const text = promptLines( + Math.floor((ZSH_MULTI_LINE_STARTUP_LINE_BUDGET_BYTES - 200) / (perLine + 1)), + perLine + ) + + it('arrives whole through the ready barrier', async () => { + expect(await typeIntoZsh(text, 0)).toBe(text) + }, 60_000) + + it('arrives whole when the user config outlasts the barrier and the write lands early', async () => { + expect(await typeIntoZsh(text, 3)).toBe(text) + }, 60_000) +}) diff --git a/src/main/runtime/agent-launch-typed-line-shell.test.ts b/src/main/runtime/agent-launch-typed-line-shell.test.ts new file mode 100644 index 000000000000..1ef35b852927 --- /dev/null +++ b/src/main/runtime/agent-launch-typed-line-shell.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { nameLocalTypedLineShell } from './agent-launch-typed-line-shell' + +describe('naming the shell a local launch line is typed into', () => { + it('takes the request’s shell first, then the setting, then SHELL, as the spawn does', () => { + const base = { isRemote: false, platform: 'darwin' as const, envShell: '/bin/bash' } + expect( + nameLocalTypedLineShell({ + ...base, + shellOverride: '/opt/homebrew/bin/fish', + defaultShellSetting: '/bin/zsh' + }) + ).toBe('fish') + expect(nameLocalTypedLineShell({ ...base, defaultShellSetting: ' /bin/zsh ' })).toBe('zsh') + expect(nameLocalTypedLineShell(base)).toBe('bash') + expect(nameLocalTypedLineShell({ ...base, envShell: '' })).toBe('zsh') + }) + + it('names none for a remote host, whose relay picks its own login shell', () => { + expect( + nameLocalTypedLineShell({ isRemote: true, platform: 'darwin', envShell: '/bin/zsh' }) + ).toBeUndefined() + }) + + it('names none on Windows, where the pane may be cmd, PowerShell, Git Bash or WSL', () => { + expect( + nameLocalTypedLineShell({ isRemote: false, platform: 'win32', envShell: '/bin/zsh' }) + ).toBeUndefined() + }) +}) diff --git a/src/main/runtime/agent-launch-typed-line-shell.ts b/src/main/runtime/agent-launch-typed-line-shell.ts new file mode 100644 index 000000000000..6a7f83c048b4 --- /dev/null +++ b/src/main/runtime/agent-launch-typed-line-shell.ts @@ -0,0 +1,27 @@ +import { basename } from 'node:path' + +/** + * The shell a local pane's startup line gets typed into, named before the spawn the way the spawn + * picks it: the request's shell, the default-shell setting, then `SHELL` + * (`ipc/pty/runtime/spawn-preflight.ts`, then the local or daemon launch plan). + * + * Undefined where the host cannot name it: a remote host, whose relay picks its own login shell, + * and Windows, whose pane may be cmd, PowerShell, Git Bash or a WSL distro. + */ +export function nameLocalTypedLineShell(args: { + isRemote: boolean + shellOverride?: string + defaultShellSetting?: string + platform?: NodeJS.Platform + envShell?: string +}): string | undefined { + if (args.isRemote || (args.platform ?? process.platform) === 'win32') { + return undefined + } + const shellPath = + args.shellOverride?.trim() || + args.defaultShellSetting?.trim() || + (args.envShell ?? process.env.SHELL) || + '/bin/zsh' + return basename(shellPath).toLowerCase() +} diff --git a/src/main/runtime/agent-terminal-startup-prompt.test.ts b/src/main/runtime/agent-terminal-startup-prompt.test.ts index c6b7370a30a5..cdf6cd16dbb5 100644 --- a/src/main/runtime/agent-terminal-startup-prompt.test.ts +++ b/src/main/runtime/agent-terminal-startup-prompt.test.ts @@ -17,7 +17,10 @@ vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp') } })) -function runtimeWithAgentLaunch(): { +// The shell the host names for a local line; bash takes no multi-line line, zsh does. +function runtimeWithAgentLaunch( + options: { terminalDefaultShell?: string; connectionId?: string } = {} +): { runtime: OrcaRuntimeService spawn: ReturnType } { @@ -27,11 +30,13 @@ function runtimeWithAgentLaunch(): { store: { getSettings: () => Record } resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise } - internal.store = { getSettings: () => ({}) } + internal.store = { + getSettings: () => ({ terminalDefaultShell: options.terminalDefaultShell ?? '/bin/bash' }) + } vi.spyOn(internal, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue({ id: 'wt-1', path: '/repo/app', - connectionId: null, + connectionId: options.connectionId ?? null, repo: null, folderWorkspace: null }) @@ -83,6 +88,37 @@ describe('a terminal create that is handed a launch prompt', () => { expect(onStartupPromptCarry).toHaveBeenCalledWith(false) }) + it('carries a short-lined multi-line prompt on a local zsh line, so the agent starts with it', async () => { + const { runtime, spawn } = runtimeWithAgentLaunch({ terminalDefaultShell: '/bin/zsh' }) + const onStartupPromptCarry = vi.fn() + + await runtime.createTerminal('id:wt-1', { + startupAgent: 'claude', + startupPrompt: 'summarize the diff\nthen list the risks', + onStartupPromptCarry + }) + + expect(spawnedCommand(spawn)).toContain('summarize the diff\nthen list the risks') + expect(onStartupPromptCarry).toHaveBeenCalledWith(true) + }) + + it('starts clean on a remote host, whose shell this host cannot name', async () => { + const { runtime, spawn } = runtimeWithAgentLaunch({ + terminalDefaultShell: '/bin/zsh', + connectionId: 'ssh-1' + }) + const onStartupPromptCarry = vi.fn() + + await runtime.createTerminal('id:wt-1', { + startupAgent: 'claude', + startupPrompt: 'summarize the diff\nthen list the risks', + onStartupPromptCarry + }) + + 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() 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 2ec280f056bf..415a1b82964f 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -21,6 +21,7 @@ import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtim import { resolveBareAgentLaunchCommand } from './runtime-agent-launch-resolution' import { agentPromptRidesLaunchCommand } from '../../shared/tui-agent-startup' import { planStartupWithPromptCandidate } from '../../shared/startup-line-prompt-carry' +import { nameLocalTypedLineShell } from './agent-launch-typed-line-shell' import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry' @@ -232,7 +233,14 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith windowsShellOverride: opts.shellOverride, sessionOptions: this.toAgentSessionOptions(opts.launchPreferences) }), - opts.startupPrompt ?? '' + opts.startupPrompt ?? '', + nameLocalTypedLineShell({ + isRemote, + ...(opts.shellOverride ? { shellOverride: opts.shellOverride } : {}), + ...(settings.terminalDefaultShell + ? { defaultShellSetting: settings.terminalDefaultShell } + : {}) + }) ) if (!startupPlan) { // Why: an explicit agent that yields no plan would otherwise spawn a bare diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index 43fe59b34e1a..a39e7797583c 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -86,6 +86,8 @@ export type RuntimeStore = { agentDefaultArgs?: GlobalSettings['agentDefaultArgs'] agentDefaultEnv?: GlobalSettings['agentDefaultEnv'] terminalWindowsShell?: GlobalSettings['terminalWindowsShell'] + // Read by the launch-line carry rule to name the shell a local line is typed into. + terminalDefaultShell?: GlobalSettings['terminalDefaultShell'] floatingTerminalEnabled?: GlobalSettings['floatingTerminalEnabled'] agentStatusHooksEnabled?: GlobalSettings['agentStatusHooksEnabled'] terminalCopyTrimsGutter?: GlobalSettings['terminalCopyTrimsGutter'] diff --git a/src/main/runtime/runtime-worktree-agent-startup.test.ts b/src/main/runtime/runtime-worktree-agent-startup.test.ts index 9d90ef4900f2..fe82665de5d7 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.test.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.test.ts @@ -109,10 +109,10 @@ describe('buildWorktreeStartupForAgent host resolution', () => { }) describe('buildWorktreeStartupForAgent prompt carry', () => { - const build = (onPromptCarry?: (carried: boolean) => void) => + const build = (onPromptCarry?: (carried: boolean) => void, terminalDefaultShell = '/bin/bash') => buildWorktreeStartupForAgent({ repo: makeRepo({}), - settings, + settings: Object.assign({}, settings, { terminalDefaultShell }), agent: 'claude', prompt: 'summarize the diff\nthen list the risks', getLaunchPlatform: () => 'linux', @@ -129,6 +129,14 @@ describe('buildWorktreeStartupForAgent prompt carry', () => { expect(onPromptCarry).toHaveBeenCalledWith(false) }) + it('carries a short-lined multi-line prompt on a local zsh line, as main typed it', () => { + const onPromptCarry = vi.fn() + const result = build(onPromptCarry, '/bin/zsh') + + expect(result.startup.command).toContain('summarize the diff\nthen list the risks') + expect(onPromptCarry).toHaveBeenCalledWith(true) + }) + 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') diff --git a/src/main/runtime/runtime-worktree-agent-startup.ts b/src/main/runtime/runtime-worktree-agent-startup.ts index 23069c0be03e..139679ba7059 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.ts @@ -10,6 +10,7 @@ import { isTuiAgentEnabled, pickTuiAgent } from '../../shared/tui-agent-selectio 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 { nameLocalTypedLineShell } from './agent-launch-typed-line-shell' import { detectInstalledAgentsWithShellPathHydration, detectRemoteAgents @@ -152,7 +153,16 @@ export function buildWorktreeStartupForAgent( const prompt = environment.prompt ?? '' let startupPlan: ReturnType if (environment.onPromptCarry) { - const offered = planStartupWithPromptCandidate(planInputs, prompt) + const offered = planStartupWithPromptCandidate( + planInputs, + prompt, + nameLocalTypedLineShell({ + isRemote: repoIsRemote(repo), + ...(settings.terminalDefaultShell + ? { defaultShellSetting: settings.terminalDefaultShell } + : {}) + }) + ) startupPlan = offered.plan if (startupPlan && prompt.trim()) { environment.onPromptCarry(offered.promptCarried) diff --git a/src/shared/startup-line-prompt-carry.test.ts b/src/shared/startup-line-prompt-carry.test.ts index c897edddc874..0d1b92932005 100644 --- a/src/shared/startup-line-prompt-carry.test.ts +++ b/src/shared/startup-line-prompt-carry.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest' import { hasControlByte, planStartupWithPromptCandidate, - TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES + TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES, + ZSH_MULTI_LINE_STARTUP_LINE_BUDGET_BYTES } from './startup-line-prompt-carry' import type { TuiAgent } from './tui-agent' import { @@ -10,14 +11,26 @@ import { RUNTIME_CAPABILITIES } from './protocol-version' -function offer(agent: TuiAgent, prompt: string, extra: { cmdOverride?: string } = {}) { +function offer( + agent: TuiAgent, + prompt: string, + extra: { cmdOverride?: string; shellName?: string | undefined } = {} +) { return planStartupWithPromptCandidate( { agent, cmdOverrides: extra.cmdOverride ? { [agent]: extra.cmdOverride } : {}, platform: 'darwin' }, - prompt + prompt, + extra.shellName + ) +} + +/** `count` prompt lines of `bytes` each, as a multi-line source-control prompt is. */ +function linesOf(count: number, bytes: number): string { + return Array.from({ length: count }, (_unused, index) => `${index}:`.padEnd(bytes, 'x')).join( + '\n' ) } @@ -119,6 +132,49 @@ describe('whether a launch prompt rides the typed startup line', () => { }) }) +// Pinned live by startup-line-typed-length.live-shell.test.ts; the refusals below were measured on +// macOS through the same write: a 1.1 KB single line lost to zsh's late write, every multi-line line +// lost to bash 3.2, and to fish whenever its config outlasted the ready barrier. +describe('a multi-line prompt typed into a shell the host names', () => { + it('rides a zsh line as main typed it when each line is short, so the agent starts with it', () => { + const prompt = linesOf(21, 100) + const { plan, promptCarried } = offer('claude', prompt, { shellName: 'zsh' }) + expect(promptCarried).toBe(true) + expect(plan?.launchCommand).toContain(prompt) + }) + + it('keeps a zsh line off the launch command when any one line exceeds the per-line budget', () => { + const prompt = `${linesOf(3, 100)}\n${'y'.repeat(TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES + 1)}` + expect(offer('claude', prompt, { shellName: 'zsh' }).promptCarried).toBe(false) + }) + + it('keeps a zsh line off the launch command past the whole-line budget', () => { + const prompt = linesOf(Math.ceil(ZSH_MULTI_LINE_STARTUP_LINE_BUDGET_BYTES / 400) + 1, 400) + expect(offer('claude', prompt, { shellName: 'zsh' }).promptCarried).toBe(false) + }) + + it('keeps a long single line off a zsh launch command, which a late write truncates', () => { + const prompt = promptForClaudeLineOf(TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES + 1) + expect(offer('claude', prompt, { shellName: 'zsh' }).promptCarried).toBe(false) + }) + + it.each([['bash'], ['fish'], ['pwsh'], [undefined]])( + 'never types a multi-line line into %s', + (shellName) => { + const { plan, promptCarried } = offer('claude', linesOf(3, 50), { shellName }) + expect(promptCarried).toBe(false) + expect(plan?.launchCommand).not.toContain('0:') + } + ) + + it.each([ + ['TAB', 'see\tthis\nnext line'], + ['CR', 'first\r\nsecond'] + ])('never types a %s-bearing multi-line prompt into zsh', (_label, prompt) => { + expect(offer('claude', prompt, { shellName: 'zsh' }).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. diff --git a/src/shared/startup-line-prompt-carry.ts b/src/shared/startup-line-prompt-carry.ts index 943359222330..65e70e2d11db 100644 --- a/src/shared/startup-line-prompt-carry.ts +++ b/src/shared/startup-line-prompt-carry.ts @@ -8,6 +8,11 @@ * 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. + * + * `startup-line-typed-length.live-shell.test.ts` types lines through Orca's own ready barrier and + * submission into real shells, including one whose config outlasts the barrier's 1.5 s cap so the + * line lands while the terminal is still line-buffered. Only zsh read a multi-line line whole in + * both cases, and only while each of its lines stayed short; a long single line was lost there. */ import { TUI_AGENT_CONFIG } from './tui-agent-config' @@ -15,9 +20,12 @@ import { buildAgentStartupPlan, type AgentStartupPlan } from './tui-agent-startu 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. */ + * measured, and 1 KiB did not. Also the cap on each line of a multi-line one. */ export const TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES = 512 +/** A multi-line line typed into zsh: the largest measured intact behind a late write (8 KiB). */ +export const ZSH_MULTI_LINE_STARTUP_LINE_BUDGET_BYTES = 8192 + const encoder = new TextEncoder() function typedLineBytes(line: string): number { @@ -27,6 +35,8 @@ function typedLineBytes(line: string): number { export function startupLineCarriesPrompt(args: { agent: TuiAgent withPrompt: AgentStartupPlan | null + /** The shell the line is typed into, when the host can name it before the spawn. */ + shellName?: string }): boolean { const { withPrompt } = args if (!withPrompt || withPrompt.followupPrompt !== null) { @@ -38,7 +48,25 @@ export function startupLineCarriesPrompt(args: { return true } const line = withPrompt.launchCommand - return !hasControlByte(line) && typedLineBytes(line) <= TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES + if (!hasControlByte(line)) { + return typedLineBytes(line) <= TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES + } + return args.shellName === 'zsh' && zshReadsMultiLineWhole(line) +} + +/** + * zsh takes a multi-line line as one bracketed paste, and a write that beats its line editor still + * reaches it line by line, so each line must fit the terminal's line buffer. bash 3.2 has no + * bracketed paste, and fish drops a paste written before its reader is up. + */ +function zshReadsMultiLineWhole(line: string): boolean { + const lines = line.split('\n') + return ( + lines.every( + (part) => + !hasControlByte(part) && typedLineBytes(part) <= TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES + ) && typedLineBytes(line) <= ZSH_MULTI_LINE_STARTUP_LINE_BUDGET_BYTES + ) } /** Any C0 byte or DEL, not just CR/LF: no quoter escapes them, and a single-line command is written @@ -64,11 +92,18 @@ type StartupPlanInputs = Omit< */ export function planStartupWithPromptCandidate( inputs: StartupPlanInputs, - prompt: string + prompt: string, + shellName?: string ): { plan: AgentStartupPlan | null; promptCarried: boolean } { if (prompt.trim()) { const withPrompt = buildAgentStartupPlan({ ...inputs, prompt, allowEmptyPromptLaunch: true }) - if (startupLineCarriesPrompt({ agent: inputs.agent, withPrompt })) { + if ( + startupLineCarriesPrompt({ + agent: inputs.agent, + withPrompt, + ...(shellName ? { shellName } : {}) + }) + ) { return { plan: withPrompt, promptCarried: true } } } From 90099140635823ed9bcc0b43531279f88e87c03d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:53:50 -0700 Subject: [PATCH 03/42] test(agent-launch): keep the real-zsh launch-line test out of the Windows lane's gate scan The Windows lane registration check read `const ZSH_PATH = process.platform === 'win32'` (the head of a multi-line ternary) as a Windows-true flag, so `describe.skipIf(!ZSH_PATH)` looked like a Windows-only suite. The file is POSIX-only; the zsh lookup is now a function. --- .../startup-line-typed-length.live-shell.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/main/providers/startup-line-typed-length.live-shell.test.ts b/src/main/providers/startup-line-typed-length.live-shell.test.ts index 8f6c91063547..e74816351b4c 100644 --- a/src/main/providers/startup-line-typed-length.live-shell.test.ts +++ b/src/main/providers/startup-line-typed-length.live-shell.test.ts @@ -28,10 +28,14 @@ import { setTestUserDataPath } from './local-pty-shell-ready-test-harness' -const ZSH_PATH = - process.platform === 'win32' - ? '' - : (spawnSync('sh', ['-c', 'command -v zsh'], { encoding: 'utf8' }).stdout ?? '').trim() +function findZsh(): string { + if (process.platform === 'win32') { + return '' + } + return (spawnSync('sh', ['-c', 'command -v zsh'], { encoding: 'utf8' }).stdout ?? '').trim() +} + +const ZSH_PATH = findZsh() /** Lines of prompt text, each `perLine` bytes. */ function promptLines(lineCount: number, perLine: number): string { From c798d08381e3cdaf44989b466a5af05f408fe801 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:04:10 -0700 Subject: [PATCH 04/42] refactor(protocol): move the agent.launch capabilities into their own module Main's protocol-version.ts sits at the 300-line cap, so the prompt-carry capability pushed it over. The four agent.launch capabilities and their doc move to agent-launch-runtime-capability.ts, re-exported by name and spread into RUNTIME_CAPABILITIES at the same position; the advertised lists and every export are unchanged. --- src/shared/agent-launch-runtime-capability.ts | 37 ++++++++++++++++++ src/shared/protocol-version.ts | 38 +++---------------- 2 files changed, 43 insertions(+), 32 deletions(-) create mode 100644 src/shared/agent-launch-runtime-capability.ts diff --git a/src/shared/agent-launch-runtime-capability.ts b/src/shared/agent-launch-runtime-capability.ts new file mode 100644 index 000000000000..063db8f98fb2 --- /dev/null +++ b/src/shared/agent-launch-runtime-capability.ts @@ -0,0 +1,37 @@ +// Split out of protocol-version.ts, which re-exports every name here; the list below is spread +// into RUNTIME_CAPABILITIES in this order. + +/** + * `agent.launch` exists: one host-side method that decides structured-vs-terminal and creates the + * surface, instead of each client routing for itself. + * + * Negotiated rather than assumed because a client that cannot see it must keep using + * `worktree.create` + `startupAgent`, which stays supported verbatim. The reverse skew is the + * dangerous one: `worktree.create` returns `agentTerminalHandle` only when a startup agent was + * requested, so a host that quietly routed that call to a structured session would hand an old + * client a response with no handle and no error. + * + * Advertising it is a statement that the client understands EITHER outcome, since the host is what + * picks: a structured session it can open, or a terminal agent. A client that renders only one of + * the two keeps using the surface-specific methods. + */ +// v2 makes prompt delivery an outcome union and top-level warnings the only supported shape. +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 + +export const AGENT_LAUNCH_RUNTIME_CAPABILITIES = [ + AGENT_LAUNCH_RUNTIME_CAPABILITY, + AGENT_LAUNCH_REPLAY_RUNTIME_CAPABILITY, + AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY, + AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY +] as const diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 02bbd93c9436..e04848af6690 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -12,6 +12,11 @@ import { SKILL_UPLOAD_CAPABILITY } from './skill-install-capability' export { SKILL_INSTALL_RESULT_V2_CAPABILITY } from './skill-install-capability' +import { + AGENT_LAUNCH_RUNTIME_CAPABILITIES, + AGENT_LAUNCH_RUNTIME_CAPABILITY +} from './agent-launch-runtime-capability' +export * from './agent-launch-runtime-capability' // Why: declares the Orca runtime RPC compatibility contract. Desktop, // headless server, CLI, and mobile builds may drift in app version, but @@ -293,34 +298,6 @@ export const AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY = // Hosts without this capability have no notifications.registerPush RPC. export const NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY = 'notifications.remote-push.v1' as const -/** - * `agent.launch` exists: one host-side method that decides structured-vs-terminal and creates the - * surface, instead of each client routing for itself. - * - * Negotiated rather than assumed because a client that cannot see it must keep using - * `worktree.create` + `startupAgent`, which stays supported verbatim. The reverse skew is the - * dangerous one: `worktree.create` returns `agentTerminalHandle` only when a startup agent was - * requested, so a host that quietly routed that call to a structured session would hand an old - * client a response with no handle and no error. - * - * Advertising it is a statement that the client understands EITHER outcome, since the host is what - * picks: a structured session it can open, or a terminal agent. A client that renders only one of - * the two keeps using the surface-specific methods. - */ -// v2 makes prompt delivery an outcome union and top-level warnings the only supported shape. -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 - // Generic native clients include the CLI and must not claim Electron-only page // placement support. export const NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ @@ -456,10 +433,7 @@ export const RUNTIME_CAPABILITIES = [ AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY, NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY, - AGENT_LAUNCH_RUNTIME_CAPABILITY, - AGENT_LAUNCH_REPLAY_RUNTIME_CAPABILITY, - AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY, - AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY + ...AGENT_LAUNCH_RUNTIME_CAPABILITIES ] as const export type RuntimeCapability = (typeof RUNTIME_CAPABILITIES)[number] | (string & {}) From 900a8ecba065194667a66b173ba9b056a4c81f41 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:43:41 -0700 Subject: [PATCH 05/42] refactor(protocol): import the agent.launch capabilities from their own module `export *` from protocol-version left the four names undefined under the mobile recording loader, which resolves a relative import through a Proxy with no own keys, so 37 phone recordings lost agent.launchReplay. Importers now name agent-launch-runtime-capability directly; protocol-version only spreads its list. --- mobile/src/tasks/agent-launch-mobile-replay.test.ts | 2 +- mobile/src/tasks/agent-launch-request.ts | 2 +- mobile/src/tasks/mobile-agent-launch-architecture.test.ts | 2 +- mobile/src/tasks/worktree-create-capability.test.ts | 2 +- .../transport/mobile-runtime-client-capabilities.test.ts | 2 +- mobile/src/transport/mobile-runtime-client-capabilities.ts | 2 +- src/main/ipc/desktop-renderer-runtime-capabilities.test.ts | 2 +- src/main/ipc/desktop-renderer-runtime-capabilities.ts | 2 +- src/main/ipc/runtime-agent-launch-capability.test.ts | 6 ++---- src/main/runtime/rpc/methods/agent-launch-replay.test.ts | 2 +- src/main/runtime/rpc/methods/agent-launch.test-fixture.ts | 2 +- src/main/runtime/rpc/methods/agent-launch.test.ts | 2 +- src/main/runtime/rpc/methods/agent-launch.ts | 2 +- src/shared/agent-launch-runtime-capability.ts | 4 ++-- src/shared/protocol-version.ts | 1 - src/shared/startup-line-prompt-carry.test.ts | 6 ++---- 16 files changed, 18 insertions(+), 23 deletions(-) diff --git a/mobile/src/tasks/agent-launch-mobile-replay.test.ts b/mobile/src/tasks/agent-launch-mobile-replay.test.ts index c68fb4ff7c17..515cf353f3b4 100644 --- a/mobile/src/tasks/agent-launch-mobile-replay.test.ts +++ b/mobile/src/tasks/agent-launch-mobile-replay.test.ts @@ -20,7 +20,7 @@ import { runtimeStub } from '../../../src/main/runtime/rpc/methods/agent-launch. import { AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY, AGENT_LAUNCH_RUNTIME_CAPABILITY -} from '../../../src/shared/protocol-version' +} from '../../../src/shared/agent-launch-runtime-capability' import { launchAgentInExistingWorkspace, reserveMobileAgentLaunch diff --git a/mobile/src/tasks/agent-launch-request.ts b/mobile/src/tasks/agent-launch-request.ts index e6179ca9de30..b6d730d6e2ee 100644 --- a/mobile/src/tasks/agent-launch-request.ts +++ b/mobile/src/tasks/agent-launch-request.ts @@ -24,7 +24,7 @@ import { import { AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY, AGENT_LAUNCH_RUNTIME_CAPABILITY -} from '../../../src/shared/protocol-version' +} from '../../../src/shared/agent-launch-runtime-capability' import type { TuiAgent } from '../../../src/shared/tui-agent' import type { RpcSendParams } from '../transport/rpc-params-contract' import type { WorkspaceCreateParams } from './workspace-create-params' diff --git a/mobile/src/tasks/mobile-agent-launch-architecture.test.ts b/mobile/src/tasks/mobile-agent-launch-architecture.test.ts index 3fc2ce2a4ca2..60f09c83a184 100644 --- a/mobile/src/tasks/mobile-agent-launch-architecture.test.ts +++ b/mobile/src/tasks/mobile-agent-launch-architecture.test.ts @@ -23,7 +23,7 @@ import { readNewWorktreeRuntimeCapabilities } from './worktree-create-capability import { AGENT_LAUNCH_RUNTIME_CAPABILITY, AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY -} from '../../../src/shared/protocol-version' +} from '../../../src/shared/agent-launch-runtime-capability' const createStructuredSession = vi.fn() vi.mock('../../../src/main/runtime/rpc/methods/structured-agent-session-create', () => ({ diff --git a/mobile/src/tasks/worktree-create-capability.test.ts b/mobile/src/tasks/worktree-create-capability.test.ts index 830e1a618054..78fa42be9f8d 100644 --- a/mobile/src/tasks/worktree-create-capability.test.ts +++ b/mobile/src/tasks/worktree-create-capability.test.ts @@ -3,7 +3,7 @@ import { AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY, AGENT_LAUNCH_REPLAY_RUNTIME_CAPABILITY, AGENT_LAUNCH_RUNTIME_CAPABILITY -} from '../../../src/shared/protocol-version' +} from '../../../src/shared/agent-launch-runtime-capability' import type { RpcClient } from '../transport/rpc-client' import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client' import { readNewWorktreeRuntimeCapabilities } from './worktree-create-capability' diff --git a/mobile/src/transport/mobile-runtime-client-capabilities.test.ts b/mobile/src/transport/mobile-runtime-client-capabilities.test.ts index b0c38166dff4..45502e1f028e 100644 --- a/mobile/src/transport/mobile-runtime-client-capabilities.test.ts +++ b/mobile/src/transport/mobile-runtime-client-capabilities.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from 'vitest' import { - AGENT_LAUNCH_RUNTIME_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' +import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../../src/shared/agent-launch-runtime-capability' import { MOBILE_RUNTIME_CLIENT_CAPABILITIES } from './mobile-runtime-client-capabilities' /** Mirrors the host's `parseRuntimeClientCapabilities`, which returns an EMPTY list — silently diff --git a/mobile/src/transport/mobile-runtime-client-capabilities.ts b/mobile/src/transport/mobile-runtime-client-capabilities.ts index e388c7ff2d8c..32dc88a6280e 100644 --- a/mobile/src/transport/mobile-runtime-client-capabilities.ts +++ b/mobile/src/transport/mobile-runtime-client-capabilities.ts @@ -1,5 +1,4 @@ import { - AGENT_LAUNCH_RUNTIME_CAPABILITY, AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, @@ -7,6 +6,7 @@ import { STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' +import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../../src/shared/agent-launch-runtime-capability' import { remoteRuntimeClientCapabilities } from '../../../src/shared/remote-runtime-client-capabilities' export const MOBILE_RUNTIME_CLIENT_CAPABILITIES = remoteRuntimeClientCapabilities([ diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts index a8adaacc0a3d..05d88861d0a0 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts @@ -7,7 +7,6 @@ import { describe, expect, it } from 'vitest' import { - AGENT_LAUNCH_RUNTIME_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, @@ -27,6 +26,7 @@ import { WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../shared/protocol-version' +import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../shared/agent-launch-runtime-capability' import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from '../../shared/agent-session-background-task-child-views-capability' import { supportsAgentLaunch } from '../runtime/rpc/methods/agent-launch' import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from './desktop-renderer-runtime-capabilities' diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.ts index 8fe881e00614..15a0e6c2529b 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.ts @@ -1,5 +1,4 @@ import { - AGENT_LAUNCH_RUNTIME_CAPABILITY, AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, @@ -9,6 +8,7 @@ import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../shared/protocol-version' +import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../shared/agent-launch-runtime-capability' import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from '../../shared/agent-session-background-task-child-views-capability' /** diff --git a/src/main/ipc/runtime-agent-launch-capability.test.ts b/src/main/ipc/runtime-agent-launch-capability.test.ts index 247d19bc2f6a..dd36727e8b3e 100644 --- a/src/main/ipc/runtime-agent-launch-capability.test.ts +++ b/src/main/ipc/runtime-agent-launch-capability.test.ts @@ -6,10 +6,8 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - AGENT_LAUNCH_RUNTIME_CAPABILITY, - type RuntimeCapability -} from '../../shared/protocol-version' +import type { RuntimeCapability } from '../../shared/protocol-version' +import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../shared/agent-launch-runtime-capability' type AdvertisedClient = { clientKind?: 'mobile' | 'runtime' 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 94e25298371f..fec5423fbccc 100644 --- a/src/main/runtime/rpc/methods/agent-launch-replay.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-replay.test.ts @@ -11,7 +11,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../../../shared/agent-launch-runtime-capability' import { computeAgentLaunchFingerprint, deriveAgentLaunchChildOperationId, 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 f0a3a7cf9118..909118c883fc 100644 --- a/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts +++ b/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts @@ -6,7 +6,7 @@ */ import { vi } from 'vitest' -import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../../../shared/agent-launch-runtime-capability' import { AgentLaunchPaneAlreadyLiveError } from '../../../../shared/agent-launch-pane-already-live' import type { RpcContext } from '../core' diff --git a/src/main/runtime/rpc/methods/agent-launch.test.ts b/src/main/runtime/rpc/methods/agent-launch.test.ts index 75045cf74c48..e939ed081106 100644 --- a/src/main/runtime/rpc/methods/agent-launch.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch.test.ts @@ -9,7 +9,7 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../../../shared/agent-launch-runtime-capability' import type { RpcContext } from '../core' import { CAPABLE_CLIENT, diff --git a/src/main/runtime/rpc/methods/agent-launch.ts b/src/main/runtime/rpc/methods/agent-launch.ts index dbb2f6a5cac9..0ee010666744 100644 --- a/src/main/runtime/rpc/methods/agent-launch.ts +++ b/src/main/runtime/rpc/methods/agent-launch.ts @@ -19,7 +19,7 @@ * harmless, and does nothing to reunite a caller with a surface a dead attempt left behind. */ -import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../../../shared/agent-launch-runtime-capability' import { computeAgentLaunchFingerprint } from '../../../../shared/agent-launch-operation' import type { AgentLaunchIntent, diff --git a/src/shared/agent-launch-runtime-capability.ts b/src/shared/agent-launch-runtime-capability.ts index 063db8f98fb2..2094f705dc7b 100644 --- a/src/shared/agent-launch-runtime-capability.ts +++ b/src/shared/agent-launch-runtime-capability.ts @@ -1,5 +1,5 @@ -// Split out of protocol-version.ts, which re-exports every name here; the list below is spread -// into RUNTIME_CAPABILITIES in this order. +// Split out of protocol-version.ts, which spreads the list below into RUNTIME_CAPABILITIES in this +// order. Import these names from here: the mobile recording loader cannot follow `export *`. /** * `agent.launch` exists: one host-side method that decides structured-vs-terminal and creates the diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index e04848af6690..d2246de033d1 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -16,7 +16,6 @@ import { AGENT_LAUNCH_RUNTIME_CAPABILITIES, AGENT_LAUNCH_RUNTIME_CAPABILITY } from './agent-launch-runtime-capability' -export * from './agent-launch-runtime-capability' // Why: declares the Orca runtime RPC compatibility contract. Desktop, // headless server, CLI, and mobile builds may drift in app version, but diff --git a/src/shared/startup-line-prompt-carry.test.ts b/src/shared/startup-line-prompt-carry.test.ts index 0d1b92932005..360fd2b484ce 100644 --- a/src/shared/startup-line-prompt-carry.test.ts +++ b/src/shared/startup-line-prompt-carry.test.ts @@ -6,10 +6,8 @@ import { ZSH_MULTI_LINE_STARTUP_LINE_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' +import { RUNTIME_CAPABILITIES } from './protocol-version' +import { AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY } from './agent-launch-runtime-capability' function offer( agent: TuiAgent, From aa5db2d9dc3e7baedec6a7657fbadbec49e51d2f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:07:08 -0700 Subject: [PATCH 06/42] refactor(agent-launch): drop the unshipped viewMode field and trusted local caller id Both were inert in step 1 and existed only for step 2. agent.launch will become a public plugin API, so every wire field is permanent once shipped; a top-level viewMode reads as "choose terminal vs chat", which the host decides. Step 2 introduces placement and view intent under a placement object instead. --- .../agent-launch/agent-launch-executor.ts | 3 +- .../agent-launch-surface-factories.ts | 2 - .../runtime-agent-launch-capability.test.ts | 19 -------- src/main/ipc/runtime.ts | 2 - src/main/runtime/rpc/core.ts | 3 -- .../runtime/rpc/dispatcher-stream-options.ts | 2 - .../dispatcher-trusted-local-caller.test.ts | 47 ------------------- src/main/runtime/rpc/dispatcher.ts | 1 - .../rpc/methods/agent-launch-replay.ts | 7 +-- .../rpc/methods/agent-launch-surfaces.ts | 3 -- .../agent-launch-terminal-preferences.test.ts | 10 ---- src/main/runtime/rpc/methods/agent-launch.ts | 1 - .../runtime/rpc/rpc-streaming-dispatcher.ts | 2 - src/shared/agent-launch-intent.ts | 2 - .../rpc-contract/agent-launch-params.ts | 6 --- 15 files changed, 2 insertions(+), 108 deletions(-) delete mode 100644 src/main/runtime/rpc/dispatcher-trusted-local-caller.test.ts diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index 9e2841c87591..551ba1695adc 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -337,8 +337,7 @@ async function createTerminalSurface( worktreeId, agent: intent.agent, ...(startupPrompt ? { startupPrompt } : {}), - ...terminalLaunchInputs(intent), - ...(intent.viewMode ? { viewMode: intent.viewMode } : {}) + ...terminalLaunchInputs(intent) }) return { outcome: { diff --git a/src/main/agent-launch/agent-launch-surface-factories.ts b/src/main/agent-launch/agent-launch-surface-factories.ts index 39f8785bc725..e7d36f05ea1c 100644 --- a/src/main/agent-launch/agent-launch-surface-factories.ts +++ b/src/main/agent-launch/agent-launch-surface-factories.ts @@ -35,8 +35,6 @@ 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. */ diff --git a/src/main/ipc/runtime-agent-launch-capability.test.ts b/src/main/ipc/runtime-agent-launch-capability.test.ts index dd36727e8b3e..6c85455a324d 100644 --- a/src/main/ipc/runtime-agent-launch-capability.test.ts +++ b/src/main/ipc/runtime-agent-launch-capability.test.ts @@ -12,8 +12,6 @@ import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../shared/agent-launch-runti type AdvertisedClient = { clientKind?: 'mobile' | 'runtime' clientCapabilities?: readonly RuntimeCapability[] - trustedLocalCallerId?: string - pairedDeviceId?: string } const { handlers, advertised } = vi.hoisted( @@ -59,7 +57,6 @@ 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 = {} @@ -129,20 +126,4 @@ 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 94ee84c1fab3..e768de463d85 100644 --- a/src/main/ipc/runtime.ts +++ b/src/main/ipc/runtime.ts @@ -78,7 +78,6 @@ 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 @@ -121,7 +120,6 @@ 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/rpc/core.ts b/src/main/runtime/rpc/core.ts index 46529f592d7f..1402f10b2231 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -75,9 +75,6 @@ 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 ca8c79ad0ac8..e3151c66b0e7 100644 --- a/src/main/runtime/rpc/dispatcher-stream-options.ts +++ b/src/main/runtime/rpc/dispatcher-stream-options.ts @@ -8,8 +8,6 @@ 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 deleted file mode 100644 index fa8011fdbdd7..000000000000 --- a/src/main/runtime/rpc/dispatcher-trusted-local-caller.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -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 871037ea63c4..04600fcc4ecd 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -116,7 +116,6 @@ 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-replay.ts b/src/main/runtime/rpc/methods/agent-launch-replay.ts index 2bab7d125359..bb87d8098a9b 100644 --- a/src/main/runtime/rpc/methods/agent-launch-replay.ts +++ b/src/main/runtime/rpc/methods/agent-launch-replay.ts @@ -29,16 +29,11 @@ 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') diff --git a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts index 2e383e876d25..7be7fe8034b9 100644 --- a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts +++ b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts @@ -118,7 +118,6 @@ export function agentLaunchSurfaceFactory( cwd, launchSource, paneKey, - viewMode, options }) => { const launchPreferences = toAgentLaunchPreferences(options) @@ -144,8 +143,6 @@ 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) 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 12945df369a4..f75fdc803f20 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,16 +80,6 @@ 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.ts b/src/main/runtime/rpc/methods/agent-launch.ts index 0ee010666744..27178d13874a 100644 --- a/src/main/runtime/rpc/methods/agent-launch.ts +++ b/src/main/runtime/rpc/methods/agent-launch.ts @@ -105,7 +105,6 @@ 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 } : {}) } } diff --git a/src/main/runtime/rpc/rpc-streaming-dispatcher.ts b/src/main/runtime/rpc/rpc-streaming-dispatcher.ts index 41847a0eb23a..07706125c235 100644 --- a/src/main/runtime/rpc/rpc-streaming-dispatcher.ts +++ b/src/main/runtime/rpc/rpc-streaming-dispatcher.ts @@ -138,7 +138,6 @@ export class RpcStreamingDispatcher { subscriptionRegistrationVersion, clientId: options?.clientId, pairedDeviceId: options?.pairedDeviceId, - trustedLocalCallerId: options?.trustedLocalCallerId, clientKind: options?.clientKind, clientCapabilities: options?.clientCapabilities, updateClientCapabilities: options?.updateClientCapabilities, @@ -193,7 +192,6 @@ 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/shared/agent-launch-intent.ts b/src/shared/agent-launch-intent.ts index 2f59619dc2c1..bffb93c1b9a2 100644 --- a/src/shared/agent-launch-intent.ts +++ b/src/shared/agent-launch-intent.ts @@ -93,8 +93,6 @@ 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/rpc-contract/agent-launch-params.ts b/src/shared/rpc-contract/agent-launch-params.ts index 0737a1d3e647..3926c610708a 100644 --- a/src/shared/rpc-contract/agent-launch-params.ts +++ b/src/shared/rpc-contract/agent-launch-params.ts @@ -102,12 +102,6 @@ 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 From 71f9a2147e43971615a4b35b501dfef9c6b8738f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:19:31 -0700 Subject: [PATCH 07/42] fix(agent-launch): read Codex's provisional startup from the rule files' hold anchor Main (#24375) moved Codex's provisional-header check into codex.json's provisional_startup hold anchor and deleted codex-terminal-readiness.ts, so the launch readiness hold now asks showsHoldAnchor, as main's own settled check does. --- src/main/runtime/launched-agent-composer-readiness.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/runtime/launched-agent-composer-readiness.ts b/src/main/runtime/launched-agent-composer-readiness.ts index 06637fdfffa5..7c8fa6871a0e 100644 --- a/src/main/runtime/launched-agent-composer-readiness.ts +++ b/src/main/runtime/launched-agent-composer-readiness.ts @@ -22,7 +22,7 @@ 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 { showsHoldAnchor } from './agent-state-rules/agent-state-text-anchors' import { detectTerminalWaitBlockedReason } from './terminal-wait-detection' /** @@ -47,7 +47,7 @@ export type LaunchedAgentReadinessRuntime = Pick< /** * 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. + * a rule file's hold anchor (Codex 0.157's provisional `model: loading` header, which discards input). */ export function readFreshComposerHold( waitText: string, @@ -59,7 +59,7 @@ export function readFreshComposerHold( ) { return 'dialog' } - return isCodexProvisionalStartupText(waitText.toLowerCase()) ? 'starting' : null + return showsHoldAnchor(waitText.toLowerCase()) ? 'starting' : null } /** From 39419d2cbdd9bb05350200d81fb4d9a199ef5508 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:27:24 -0700 Subject: [PATCH 08/42] fix(agent-launch): hold rule-file name titles to quiet for a launch, and census the zsh fixture Main (#24375) answers a name-only title from each agent's rule file ahead of the sustained-title lane, so gemini.json's name_title settled a launch readiness wait on the shell's auto-title while Gemini was still booting. A launch now asks quiet of every weak idle verdict, as that lane did. Main's readiness census requires a recorder for every runtime fixture; the zsh prompt recording is a non-agent control. Gemini's synthetic baseline is regenerated for this PR's stated change: a bare gemini title is no longer its rest mark, so name-only rows settle weak, and a fresh working or blocked status is no longer overridden. --- .../readiness-census/synthetic--gemini.json | 48 +++++++++---------- ...ript--zsh-prompt-runs-command@unknown.json | 7 +++ .../readiness-census-transcript-catalog.ts | 4 +- src/main/runtime/tui-idle-evidence.ts | 9 ++-- 4 files changed, 40 insertions(+), 28 deletions(-) create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--zsh-prompt-runs-command@unknown.json diff --git a/src/main/runtime/__fixtures__/readiness-census/synthetic--gemini.json b/src/main/runtime/__fixtures__/readiness-census/synthetic--gemini.json index dc869633ad6f..322410731f32 100644 --- a/src/main/runtime/__fixtures__/readiness-census/synthetic--gemini.json +++ b/src/main/runtime/__fixtures__/readiness-census/synthetic--gemini.json @@ -29,20 +29,20 @@ "title=working-spinner status=blocked-fresh screen=present fg=agent clock=clockless": "verdict=pending:closed wait=pending", "title=working-spinner status=blocked-stale screen=present fg=agent clock=clocked": "now=working edge=working quiet=working wait=pending", "title=working-spinner status=blocked-stale screen=present fg=agent clock=clockless": "verdict=working wait=pending", - "title=name-only status=none screen=present fg=agent clock=clocked": "now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start", - "title=name-only status=none screen=present fg=agent clock=clockless": "verdict=ready-strong wait=ready@start", - "title=name-only status=done-fresh screen=present fg=agent clock=clocked": "now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start", - "title=name-only status=done-fresh screen=present fg=agent clock=clockless": "verdict=ready-strong wait=ready@start", - "title=name-only status=done-stale screen=present fg=agent clock=clocked": "now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start", - "title=name-only status=done-stale screen=present fg=agent clock=clockless": "verdict=ready-strong wait=ready@start", - "title=name-only status=working-fresh screen=present fg=agent clock=clocked": "now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start", - "title=name-only status=working-fresh screen=present fg=agent clock=clockless": "verdict=ready-strong wait=ready@start", - "title=name-only status=working-stale screen=present fg=agent clock=clocked": "now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start", - "title=name-only status=working-stale screen=present fg=agent clock=clockless": "verdict=ready-strong wait=ready@start", - "title=name-only status=blocked-fresh screen=present fg=agent clock=clocked": "now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start", - "title=name-only status=blocked-fresh screen=present fg=agent clock=clockless": "verdict=ready-strong wait=ready@start", - "title=name-only status=blocked-stale screen=present fg=agent clock=clocked": "now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start", - "title=name-only status=blocked-stale screen=present fg=agent clock=clockless": "verdict=ready-strong wait=ready@start", + "title=name-only status=none screen=present fg=agent clock=clocked": "now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start", + "title=name-only status=none screen=present fg=agent clock=clockless": "verdict=ready-weak wait=ready@start", + "title=name-only status=done-fresh screen=present fg=agent clock=clocked": "now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start", + "title=name-only status=done-fresh screen=present fg=agent clock=clockless": "verdict=ready-weak wait=ready@start", + "title=name-only status=done-stale screen=present fg=agent clock=clocked": "now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start", + "title=name-only status=done-stale screen=present fg=agent clock=clockless": "verdict=ready-weak wait=ready@start", + "title=name-only status=working-fresh screen=present fg=agent clock=clocked": "now=working edge=working quiet=working wait=pending", + "title=name-only status=working-fresh screen=present fg=agent clock=clockless": "verdict=working wait=pending", + "title=name-only status=working-stale screen=present fg=agent clock=clocked": "now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start", + "title=name-only status=working-stale screen=present fg=agent clock=clockless": "verdict=ready-weak wait=ready@start", + "title=name-only status=blocked-fresh screen=present fg=agent clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "title=name-only status=blocked-fresh screen=present fg=agent clock=clockless": "verdict=pending:closed wait=pending", + "title=name-only status=blocked-stale screen=present fg=agent clock=clocked": "now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start", + "title=name-only status=blocked-stale screen=present fg=agent clock=clockless": "verdict=ready-weak wait=ready@start", "title=none status=none screen=present fg=agent clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", "title=none status=none screen=present fg=agent clock=clockless": "verdict=pending:closed wait=pending", "title=none status=done-fresh screen=present fg=agent clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", @@ -57,16 +57,16 @@ "title=none status=blocked-fresh screen=present fg=agent clock=clockless": "verdict=pending:closed wait=pending", "title=none status=blocked-stale screen=present fg=agent clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", "title=none status=blocked-stale screen=present fg=agent clock=clockless": "verdict=pending:closed wait=pending", - "title=name-only status=none screen=present fg=shell clock=clocked": "now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start", - "title=name-only status=none screen=present fg=shell clock=clockless": "verdict=ready-strong wait=ready@start", - "title=name-only status=none screen=untrusted fg=agent clock=clocked": "now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start", - "title=name-only status=none screen=untrusted fg=agent clock=clockless": "verdict=ready-strong wait=ready@start", - "title=name-only status=none screen=untrusted fg=shell clock=clocked": "now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start", - "title=name-only status=none screen=untrusted fg=shell clock=clockless": "verdict=ready-strong wait=ready@start", - "title=name-only status=none screen=absent fg=agent clock=clocked": "now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start", - "title=name-only status=none screen=absent fg=agent clock=clockless": "verdict=ready-strong wait=ready@start", - "title=name-only status=none screen=absent fg=shell clock=clocked": "now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start", - "title=name-only status=none screen=absent fg=shell clock=clockless": "verdict=ready-strong wait=ready@start", + "title=name-only status=none screen=present fg=shell clock=clocked": "now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start", + "title=name-only status=none screen=present fg=shell clock=clockless": "verdict=ready-weak wait=ready@start", + "title=name-only status=none screen=untrusted fg=agent clock=clocked": "now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start", + "title=name-only status=none screen=untrusted fg=agent clock=clockless": "verdict=ready-weak wait=ready@start", + "title=name-only status=none screen=untrusted fg=shell clock=clocked": "now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start", + "title=name-only status=none screen=untrusted fg=shell clock=clockless": "verdict=ready-weak wait=ready@start", + "title=name-only status=none screen=absent fg=agent clock=clocked": "now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start", + "title=name-only status=none screen=absent fg=agent clock=clockless": "verdict=ready-weak wait=ready@start", + "title=name-only status=none screen=absent fg=shell clock=clocked": "now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start", + "title=name-only status=none screen=absent fg=shell clock=clockless": "verdict=ready-weak wait=ready@start", "title=none status=none screen=present fg=shell clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", "title=none status=none screen=present fg=shell clock=clockless": "verdict=pending:closed wait=pending", "title=none status=none screen=untrusted fg=agent clock=clocked": "now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--zsh-prompt-runs-command@unknown.json b/src/main/runtime/__fixtures__/readiness-census/transcript--zsh-prompt-runs-command@unknown.json new file mode 100644 index 000000000000..6b069e103508 --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--zsh-prompt-runs-command@unknown.json @@ -0,0 +1,7 @@ +{ + "description": "non-agent recording at 80x24 replayed on the unknown pane, one entry per chunk", + "observations": { + "clocked": ["0-4: now=pending:open edge=pending:open quiet=pending:open wait=pending"], + "clockless": ["0-4: verdict=pending:open wait=pending"] + } +} diff --git a/src/main/runtime/readiness-census-transcript-catalog.ts b/src/main/runtime/readiness-census-transcript-catalog.ts index 3ba056ef1e60..d3c345a2b83e 100644 --- a/src/main/runtime/readiness-census-transcript-catalog.ts +++ b/src/main/runtime/readiness-census-transcript-catalog.ts @@ -45,7 +45,9 @@ const RUNTIME_RECORDERS: readonly (readonly [string, Recorder])[] = [ ['omp-', { agent: 'omp', foregroundProcess: 'omp' }], ['prime-agent-', { agent: 'prime-agent', foregroundProcess: 'prime-agent' }], ['qoder-', { agent: 'qoder', foregroundProcess: 'qodercli' }], - ['zcode-', { agent: 'zcode', foregroundProcess: 'zcode' }] + ['zcode-', { agent: 'zcode', foregroundProcess: 'zcode' }], + // A bare shell's prompt, a non-agent control like the daemon's less/nano/vim. + ['zsh-', { agent: null, foregroundProcess: 'zsh' }] ] // less, nano and vim are non-agent controls for the agent-unknown pane. diff --git a/src/main/runtime/tui-idle-evidence.ts b/src/main/runtime/tui-idle-evidence.ts index 72af78bb39eb..b2616b14436b 100644 --- a/src/main/runtime/tui-idle-evidence.ts +++ b/src/main/runtime/tui-idle-evidence.ts @@ -315,7 +315,7 @@ export function evaluateTuiIdle(input: TuiIdleEvaluationInput): TuiIdleVerdict { // refused, and an agent's own idle-title rule replaces the sustained-title lane below. const ruled = input.readAgentRuleVerdict() if (ruled !== null) { - return isSettledWeakIdle(ruled, input.record, input.quiescenceMs) + return isSettledWeakIdle(ruled, input.record, input.quiescenceMs, input.launchReadiness) ? READY_WEAK : { kind: 'pending', quietForeground: 'closed' } } @@ -329,15 +329,18 @@ export function evaluateTuiIdle(input: TuiIdleEvaluationInput): TuiIdleVerdict { } } +// Why launch readiness asks quiet of every weak idle: those rules read a name-only title, which a +// shell auto-title writes before the TUI mounts, as in the sustained-title lane. function isSettledWeakIdle( verdict: AgentStateVerdict, record: TuiIdleEvidenceRecord, - quiescenceMs: number + quiescenceMs: number, + launchReadiness = false ): boolean { return ( verdict.state === 'idle' && verdict.strength === 'weak' && - (!verdict.requiresQuiet || hasQuietOutput(record, quiescenceMs)) + ((!verdict.requiresQuiet && !launchReadiness) || hasQuietOutput(record, quiescenceMs)) ) } From 55fd9fecf5598baa902da8797e18733421c101a2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:27:12 -0700 Subject: [PATCH 09/42] fix(launch): stage long or multi-line launch lines so they arrive whole A launch line that is long or has several lines could lose lines or wedge a shell that is still starting, because it was typed into the terminal before the shell could read it whole. The terminal host (local daemon, and the SSH relay) now writes such a line to a script file and types one short line that runs it, for every POSIX shell; a shell that cannot read Orca's quoting runs Orca's own agent lines through /bin/sh. If the script cannot be written, the terminal says so. The SSH background launch path no longer types the line from the renderer; the relay stages it like any other terminal. Bumps the terminal daemon protocol to 40 (main took 39); a v39 daemon keeps its sessions and gets no staged line. Restacked onto #24257 from the version reviewed on top of #23748; carries none of #23748's changes. --- ...n-foreground-confirmation-protocol.test.ts | 3 +- .../daemon/daemon-protocol-version.test.ts | 4 +- src/main/daemon/daemon-protocol-version.ts | 6 +- src/main/daemon/daemon-pty-spawn-result.ts | 27 +- .../daemon-pty-startup-delivery.test.ts | 33 ++- .../daemon/daemon-spawn-result-identity.ts | 13 + src/main/daemon/session.ts | 3 +- .../daemon/terminal-host-session-create.ts | 37 ++- .../terminal-host-startup-staging.test.ts | 123 ++++++++ src/main/daemon/terminal-host.test.ts | 9 +- ...local-pty-provider-shell-readiness.test.ts | 43 +++ .../providers/local-pty-session-activation.ts | 22 +- ...ty-handler-startup-command-staging.test.ts | 114 ++++++++ src/relay/pty-handler.ts | 37 ++- ...ch-agent-background-session-remote.test.ts | 266 +++--------------- .../lib/launch-agent-background-session.ts | 27 +- .../ssh-background-startup-delivery.test.ts | 196 ------------- .../lib/ssh-background-startup-delivery.ts | 196 ------------- .../local-build-compatibility-contract.json | 4 +- .../local-build-compatibility-contract.ts | 4 +- .../startup-command-staging.node-pty.test.ts | 158 +++++++++++ src/shared/startup-command-staging.test.ts | 213 ++++++++++++++ src/shared/startup-command-staging.ts | 184 ++++++++++++ 23 files changed, 1034 insertions(+), 688 deletions(-) create mode 100644 src/main/daemon/daemon-spawn-result-identity.ts create mode 100644 src/main/daemon/terminal-host-startup-staging.test.ts create mode 100644 src/relay/pty-handler-startup-command-staging.test.ts delete mode 100644 src/renderer/src/lib/ssh-background-startup-delivery.test.ts delete mode 100644 src/renderer/src/lib/ssh-background-startup-delivery.ts create mode 100644 src/shared/startup-command-staging.node-pty.test.ts create mode 100644 src/shared/startup-command-staging.test.ts create mode 100644 src/shared/startup-command-staging.ts diff --git a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts index 8a7421b35c19..3d87fa463af0 100644 --- a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts +++ b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts @@ -3,7 +3,7 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types' describe('foreground-confirmation daemon protocol', () => { it('rejects daemons from before the fresh-confirmation RPC', () => { - expect(PROTOCOL_VERSION).toBe(39) + expect(PROTOCOL_VERSION).toBe(40) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23) @@ -22,5 +22,6 @@ describe('foreground-confirmation daemon protocol', () => { expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(36) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(37) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(38) + expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(39) }) }) diff --git a/src/main/daemon/daemon-protocol-version.test.ts b/src/main/daemon/daemon-protocol-version.test.ts index 15b7cb8068c0..1b77b8c20b5e 100644 --- a/src/main/daemon/daemon-protocol-version.test.ts +++ b/src/main/daemon/daemon-protocol-version.test.ts @@ -22,7 +22,7 @@ import { describe('daemon protocol version', () => { it('ships bounded history transfer after the 2031-unsubscribe fact', () => { - expect(PROTOCOL_VERSION).toBe(39) + expect(PROTOCOL_VERSION).toBe(40) expect(COLOR_QUERY_REPLY_COLORS_DAEMON_PROTOCOL_VERSION).toBe(38) expect(CODEX_NO_DAEMON_SHELL_LAUNCH_DAEMON_PROTOCOL_VERSION).toBe(37) expect(CONTENT_ADDRESSED_SHELL_WRAPPER_DAEMON_PROTOCOL_VERSION).toBe(36) @@ -38,7 +38,7 @@ describe('daemon protocol version', () => { expect(AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION).toBe(26) expect(AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION).toBe(26) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toEqual( - Array.from({ length: 38 }, (_, index) => index + 1) + Array.from({ length: 39 }, (_, index) => index + 1) ) }) diff --git a/src/main/daemon/daemon-protocol-version.ts b/src/main/daemon/daemon-protocol-version.ts index 42f87382d624..6dfec710e8b7 100644 --- a/src/main/daemon/daemon-protocol-version.ts +++ b/src/main/daemon/daemon-protocol-version.ts @@ -1,6 +1,6 @@ // Why: daemons survive app updates, so wire behavior must be version-gated. -// v39 gives plain fish panes Orca's codex function through XDG_DATA_DIRS; older owners stay attachable. -export const PROTOCOL_VERSION = 39 +// v40 stages long startup commands as sourced scripts; older owners stay attachable. +export const PROTOCOL_VERSION = 40 // Why: older daemons reject `setColorQueryReplyColors` as an unknown request type. export const COLOR_QUERY_REPLY_COLORS_DAEMON_PROTOCOL_VERSION = 38 export const CODEX_NO_DAEMON_SHELL_LAUNCH_DAEMON_PROTOCOL_VERSION = 37 @@ -33,7 +33,7 @@ export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24 export const MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION = 29 export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, - 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38 + 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39 ] as const export function supportsColorQueryReplyColors(protocolVersion: number): boolean { diff --git a/src/main/daemon/daemon-pty-spawn-result.ts b/src/main/daemon/daemon-pty-spawn-result.ts index afc7315abcd3..876c54caae67 100644 --- a/src/main/daemon/daemon-pty-spawn-result.ts +++ b/src/main/daemon/daemon-pty-spawn-result.ts @@ -1,5 +1,6 @@ import { isAgentSessionClaimedSpawnResult } from '../../shared/agent-session-host-authority' import { parseTerminalKittyKeyboardFlags } from '../../shared/terminal-kitty-keyboard-flags' +import { daemonSpawnResultIdentity } from './daemon-spawn-result-identity' import { retireUnexpectedAttachOnlySpawn } from './daemon-attach-only-retirement' import { DaemonPtySpawnRequest, type DaemonPtySpawnContext } from './daemon-pty-spawn-request' import { providerSequenceFromCreateOrAttach } from './daemon-pty-provider-sequence' @@ -78,10 +79,6 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest { if (result.incarnationId) { this.sessionIncarnations.set(sessionId, result.incarnationId) } - const claimResult = (): Pick | Record => - result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {} - const incarnationResult = (): Pick | Record => - result.incarnationId ? { incarnationId: result.incarnationId } : {} let providerWslDistro = result.wslDistro === undefined ? wslDistro : result.wslDistro // Why: explicit null from a current daemon overrides the caller's WSL preference; undefined keeps compatibility with older daemons. wslDistro = providerWslDistro ?? undefined @@ -91,8 +88,6 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest { } else if (providerWslDistro === null || result.isNew) { this.wslDistrosBySessionId.delete(sessionId) } - const launchIdentity = (): { launchAgent?: NonNullable } => - result.launchAgent ? { launchAgent: result.launchAgent } : {} if (effectiveCwd) { this.initialCwds.set(sessionId, effectiveCwd) @@ -116,10 +111,8 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest { } return finalizeSpawnResult({ id: sessionId, - ...incarnationResult(), + ...daemonSpawnResultIdentity(result), pid, - ...claimResult(), - ...launchIdentity(), coldRestore: cachedRestore, ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), ...(!result.isNew ? { isReattach: true } : {}) @@ -210,10 +203,8 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest { this.coldRestoreCache.set(sessionId, coldRestore) return finalizeSpawnResult({ id: sessionId, - ...incarnationResult(), + ...daemonSpawnResultIdentity(result), pid, - ...claimResult(), - ...launchIdentity(), coldRestore, ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), ...(providerSequence ? { providerSequence } : {}), @@ -222,10 +213,8 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest { } return finalizeSpawnResult({ id: sessionId, - ...incarnationResult(), + ...daemonSpawnResultIdentity(result), pid, - ...claimResult(), - ...launchIdentity(), ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), ...(providerSequence ? { providerSequence } : {}) }) @@ -270,10 +259,8 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest { if (!isReattach || !result.snapshot) { return finalizeSpawnResult({ id: sessionId, - ...incarnationResult(), + ...daemonSpawnResultIdentity(result), pid, - ...claimResult(), - ...launchIdentity(), ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), ...(providerSequence ? { providerSequence } : {}), ...(isReattach ? { isReattach: true } : {}) @@ -297,10 +284,8 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest { ) return finalizeSpawnResult({ id: sessionId, - ...incarnationResult(), + ...daemonSpawnResultIdentity(result), pid, - ...claimResult(), - ...launchIdentity(), ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), snapshot: snapshotPayload, snapshotCols: reattachSnapshot.cols, diff --git a/src/main/daemon/daemon-pty-startup-delivery.test.ts b/src/main/daemon/daemon-pty-startup-delivery.test.ts index 4b72070bb425..0039bccbce20 100644 --- a/src/main/daemon/daemon-pty-startup-delivery.test.ts +++ b/src/main/daemon/daemon-pty-startup-delivery.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { rmSync } from 'node:fs' +import { mkdirSync, readdirSync, rmSync } from 'node:fs' import { basename, join } from 'node:path' import * as localPtyUtils from '../providers/local-pty-utils' import { @@ -18,12 +18,17 @@ describe('DaemonPtyAdapter startup delivery', () => { let dir: string let lastSubprocess: ReturnType let lastSpawnOpts: Parameters[0] | null + let nextShellPath: string | undefined beforeEach(async () => { lastSpawnOpts = null + nextShellPath = undefined harness = await startDaemonAdapterHarness((opts) => { lastSpawnOpts = opts - lastSubprocess = createMockSubprocess() + lastSubprocess = Object.assign( + createMockSubprocess(), + nextShellPath ? { shellPath: nextShellPath } : {} + ) return lastSubprocess }) adapter = harness.adapter @@ -104,4 +109,28 @@ describe('DaemonPtyAdapter startup delivery', () => { await waitFor(() => vi.mocked(lastSubprocess.write).mock.calls.length > 0) expect(lastSubprocess.write).toHaveBeenCalledExactlyOnceWith(`${startup.command}\n`) }) + + itOnPosix('types only the short line that sources a staged launch line', async () => { + const stagingDir = join(dir, 'tmp') + mkdirSync(stagingDir) + vi.stubEnv('TMPDIR', stagingDir) + nextShellPath = '/bin/zsh' + try { + const command = `claude '${'x'.repeat(600)}'` + await adapter.spawn({ + cols: 80, + rows: 24, + command, + env: { SHELL: '/bin/zsh' } + }) + lastSubprocess._simulateData('\x1b]777;orca-shell-ready\x07\r\nuser@host $ ') + await waitFor(() => vi.mocked(lastSubprocess.write).mock.calls.length > 0) + const [script] = readdirSync(stagingDir) + expect(lastSubprocess.write).toHaveBeenCalledExactlyOnceWith( + `. '${join(stagingDir, script)}'\n` + ) + } finally { + vi.unstubAllEnvs() + } + }) }) diff --git a/src/main/daemon/daemon-spawn-result-identity.ts b/src/main/daemon/daemon-spawn-result-identity.ts new file mode 100644 index 000000000000..d6efeaf9b2e9 --- /dev/null +++ b/src/main/daemon/daemon-spawn-result-identity.ts @@ -0,0 +1,13 @@ +import type { PtySpawnResult } from '../providers/types' +import type { CreateOrAttachResult } from './types' + +/** The daemon's identity and launch facts every spawn result carries forward from its reply. */ +export function daemonSpawnResultIdentity( + result: CreateOrAttachResult +): Pick { + return { + ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), + ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}), + ...(result.launchAgent ? { launchAgent: result.launchAgent } : {}) + } +} diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 7c63dbd1d3f2..f74e3c1236db 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -32,7 +32,8 @@ export class Session { private readonly producerPause: SessionProducerPause private readonly shellReady: SessionShellReadyBarrier private readonly termination: SessionTerminationController - private readonly startupIngress: PtyStartupIngress + /** Public so the creating host can print its own notice as terminal output. */ + readonly startupIngress: PtyStartupIngress private readonly recoveryBarrier: TerminalShellRecoveryBarrier constructor(opts: SessionOptions) { diff --git a/src/main/daemon/terminal-host-session-create.ts b/src/main/daemon/terminal-host-session-create.ts index 15e86a70a72c..cba1be962a7f 100644 --- a/src/main/daemon/terminal-host-session-create.ts +++ b/src/main/daemon/terminal-host-session-create.ts @@ -1,4 +1,10 @@ import { buildStartupCommandSubmission } from '../../shared/startup-command-submission' +import { + discardStagedStartupCommand, + stageStartupCommand, + startupStagingFailureNotice, + type StartupCommandStaging +} from '../../shared/startup-command-staging' import { resolvePtyOwnerBackend } from '../../shared/pty-owner-backend' import { getDaemonSessionResultMetadata } from './daemon-create-or-attach-result' import { enumerateDirectoryOnce } from './directory-enumeration-probe' @@ -131,6 +137,7 @@ async function spawnAndPublishSession( ...(opts.cancelSignal ? { cancelSignal: opts.cancelSignal } : {}) }) + let staging: StartupCommandStaging | undefined // Why: a fallback shell does not emit the preferred shell's ready marker; // retaining the stale capability would indefinitely queue its first command. const shellReadySupported = @@ -156,7 +163,8 @@ async function spawnAndPublishSession( onExit: createSessionExitHandler( deps.onSessionExit, opts.sessionId, - opts.agentSessionGeneration + opts.agentSessionGeneration, + () => discardStagedStartupCommand(staging) ), ...(deps.reportReadinessEvent ? { reportReadinessEvent: deps.reportReadinessEvent } : {}), ...(opts.shellReadyTimeoutMs !== undefined @@ -197,10 +205,27 @@ async function spawnAndPublishSession( // Diagnostics must never turn a live PTY into a failed create. } if (startupCommandWritten && opts.command) { + staging = stageStartupCommand({ + command: opts.command, + shellPath: subprocess.shellPath, + orcaBuiltLine: opts.launchAgent !== undefined + }) + const notice = startupStagingFailureNotice(staging) + if (notice) { + session.startupIngress.accept(notice) + try { + deps.reportReadinessEvent?.('startup-command-stage-failed', { + sessionId: opts.sessionId, + reason: staging.failure + }) + } catch { + // Diagnostics must never turn a live PTY into a failed create. + } + } const submit = process.platform === 'win32' ? '\r' : '\n' // Why: only Orca-wrapped shells advertise the paste-safe startup barrier. session.write( - buildStartupCommandSubmission(opts.command, { + buildStartupCommandSubmission(staging.command, { submit, bracketedPasteSafe: shellReadySupported }) @@ -222,9 +247,13 @@ async function spawnAndPublishSession( function createSessionExitHandler( onSessionExit: TerminalHostSessionCreateDependencies['onSessionExit'], sessionId: string, - generation: string | undefined + generation: string | undefined, + discardStagedCommand: () => void ): () => void { - return () => onSessionExit(sessionId, generation) + return () => { + discardStagedCommand() + onSessionExit(sessionId, generation) + } } // Why enumeration: a shell's cwd listing is what TCC withholds, and it can withhold it while diff --git a/src/main/daemon/terminal-host-startup-staging.test.ts b/src/main/daemon/terminal-host-startup-staging.test.ts new file mode 100644 index 000000000000..f7ae1d5b786a --- /dev/null +++ b/src/main/daemon/terminal-host-startup-staging.test.ts @@ -0,0 +1,123 @@ +import './mock-descendant-sweep' +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TerminalHost } from './terminal-host' +import type { SubprocessHandle } from './session-subprocess-handle' + +let tempDir: string +let exitSubprocess: ((code: number) => void) | undefined + +function mockSubprocess(shellPath: string): SubprocessHandle { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handle stubs only what session creation calls. + return { + pid: 1, + shellPath, + getForegroundProcess: vi.fn(() => null), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + terminateOwnedTree: () => 'unavailable' as const, + forceKill: vi.fn(), + signal: vi.fn(), + onData: () => {}, + onExit: (callback: (code: number) => void) => { + exitSubprocess = callback + }, + dispose: vi.fn() + } as SubprocessHandle +} + +beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'orca-daemon-staging-')) + vi.stubEnv('TMPDIR', tempDir) + exitSubprocess = undefined +}) + +afterEach(() => { + vi.unstubAllEnvs() + rmSync(tempDir, { recursive: true, force: true }) +}) + +const describePosix = process.platform === 'win32' ? describe.skip : describe + +describePosix('daemon startup command staging', () => { + async function create(command: string, shellPath = '/bin/zsh') { + const sub = mockSubprocess(shellPath) + const host = new TerminalHost({ spawnSubprocess: () => sub }) + const result = await host.createOrAttach({ + sessionId: `s-${command.length}`, + cols: 80, + rows: 24, + command, + shellReadySupported: false, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + return { sub, result } + } + + it('types a short launch line and reports it typed', async () => { + const { sub, result } = await create(`claude 'fix it'`) + expect(sub.write).toHaveBeenCalledWith(`claude 'fix it'\n`) + expect(result.isNew).toBe(true) + }) + + it('types only a sourcing line for a launch past 512 bytes', async () => { + const command = `claude '${'x'.repeat(600)}'` + const { sub, result } = await create(command) + const [script] = readdirSync(tempDir) + expect(script).toMatch(/^orca-launch-[0-9a-f]+\.sh$/) + const scriptPath = join(tempDir, script) + expect(sub.write).toHaveBeenCalledWith(`. '${scriptPath}'\n`) + expect(readFileSync(scriptPath, 'utf8').split('\n')[1]).toBe(command) + expect(result).not.toHaveProperty('startupDelivery') + }) + + it('stages a multi-line launch instead of bracket-pasting it', async () => { + const { sub } = await create(`claude 'one\ntwo'`) + expect(vi.mocked(sub.write).mock.calls[0][0]).toMatch(/^\. '.*orca-launch-[0-9a-f]+\.sh'\n$/) + }) + + it('deletes a script the shell never sourced when the session exits', async () => { + await create(`claude '${'x'.repeat(600)}'`) + const scriptPath = join(tempDir, readdirSync(tempDir)[0]) + exitSubprocess?.(0) + expect(existsSync(scriptPath)).toBe(false) + }) + + it('types nothing when there is no startup command', async () => { + const sub = mockSubprocess('/bin/zsh') + const host = new TerminalHost({ spawnSubprocess: () => sub }) + await host.createOrAttach({ + sessionId: 's-none', + cols: 80, + rows: 24, + shellReadySupported: false, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + expect(sub.write).not.toHaveBeenCalled() + }) + + it('prints a notice in the terminal when it types a line it could not stage', async () => { + vi.stubEnv('TMPDIR', join(tempDir, 'missing')) + const command = `claude '${'x'.repeat(600)}'` + const onData = vi.fn() + const sub = mockSubprocess('/bin/zsh') + const host = new TerminalHost({ spawnSubprocess: () => sub }) + await host.createOrAttach({ + sessionId: 's-stage-failed', + cols: 80, + rows: 24, + command, + shellReadySupported: false, + streamClient: { onData, onExit: vi.fn() } + }) + expect(sub.write).toHaveBeenCalledWith(`${command}\n`) + await vi.waitFor(() => { + expect(onData.mock.calls.map(([data]) => String(data)).join('')).toContain( + '[orca] Could not stage the launch command (ENOENT' + ) + }) + }) +}) diff --git a/src/main/daemon/terminal-host.test.ts b/src/main/daemon/terminal-host.test.ts index 7ffaadec06da..b2737142a734 100644 --- a/src/main/daemon/terminal-host.test.ts +++ b/src/main/daemon/terminal-host.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { readFileSync, rmSync } from 'node:fs' import { Session } from './session' import { IMMEDIATE_KILL_PHYSICAL_EXIT_TIMEOUT_MS } from './session-termination-controller' import type { SubprocessHandle } from './session-subprocess-handle' @@ -247,7 +248,7 @@ describe('TerminalHost', () => { ) }) - it('does not bracketed-paste-wrap multiline commands for a fallback shell without paste mode', async () => { + it('stages multiline commands for a fallback shell without paste mode', async () => { spawnFn = vi.fn(() => { const sub = createMockSubprocess({ shellPath: '/bin/sh' }) as ReturnType< typeof createMockSubprocess @@ -271,8 +272,10 @@ describe('TerminalHost', () => { }) const written = (lastSubprocess.write as ReturnType).mock.calls[0]?.[0] - expect(written).not.toContain('\x1b[200~') - expect(written).toContain('line one\nline two') + const scriptPath = /^\. '(.*orca-launch-[0-9a-f]+\.sh)'\n$/.exec(written)?.[1] + expect(scriptPath).toBeDefined() + expect(readFileSync(scriptPath!, 'utf8')).toContain('claude "line one\nline two"\n') + rmSync(scriptPath!, { force: true }) }) it('keeps the shell-ready barrier when the spawned shell supports the marker', async () => { diff --git a/src/main/providers/local-pty-provider-shell-readiness.test.ts b/src/main/providers/local-pty-provider-shell-readiness.test.ts index 6a46261e7931..168bc309a023 100644 --- a/src/main/providers/local-pty-provider-shell-readiness.test.ts +++ b/src/main/providers/local-pty-provider-shell-readiness.test.ts @@ -223,6 +223,49 @@ describe('LocalPtyProvider', () => { } }) + it('stages a long startup command and types only the line that sources it', async () => { + vi.useFakeTimers() + try { + process.env.SHELL = '/bin/sh' + const command = `claude '${'x'.repeat(600)}'` + + const result = await provider.spawn({ cols: 80, rows: 24, command }) + + expect(result).not.toHaveProperty('startupDelivery') + const staged = writeFileSyncMock.mock.calls.find(([path]) => + String(path).includes('orca-launch-') + ) + expect(staged?.[1]).toContain(`\n${command}\n`) + await vi.advanceTimersByTimeAsync(200) + expect(mockProc.write).toHaveBeenCalledWith(`. '${staged?.[0]}'\n`) + } finally { + vi.useRealTimers() + } + }) + + it('prints a notice in the terminal when it types a line it could not stage', async () => { + vi.useFakeTimers() + try { + process.env.SHELL = '/bin/sh' + const received: string[] = [] + provider.configure({ onData: (_id, data) => received.push(data) }) + writeFileSyncMock.mockImplementationOnce(() => { + throw new Error('ENOSPC: no space left on device') + }) + const command = `claude '${'x'.repeat(600)}'` + + await provider.spawn({ cols: 80, rows: 24, command }) + + expect(received.join('')).toContain( + '[orca] Could not stage the launch command (ENOSPC: no space left on device)' + ) + await vi.advanceTimersByTimeAsync(200) + expect(mockProc.write).toHaveBeenCalledWith(`${command}\n`) + } finally { + vi.useRealTimers() + } + }) + it('verifies shell identity against the exact spawn PATH', async () => { provider.configure({ buildSpawnEnv: (_id, env) => ({ ...env, PATH: '/post-hook/bin' }) diff --git a/src/main/providers/local-pty-session-activation.ts b/src/main/providers/local-pty-session-activation.ts index e89f0c433162..8b8abd54fd07 100644 --- a/src/main/providers/local-pty-session-activation.ts +++ b/src/main/providers/local-pty-session-activation.ts @@ -1,5 +1,11 @@ import type * as pty from 'node-pty' import { isBracketedPasteSafeShell } from '../../shared/startup-command-submission' +import { + discardStagedStartupCommand, + stageStartupCommand, + startupStagingFailureNotice, + type StartupCommandStaging +} from '../../shared/startup-command-staging' import { PtyStartupIngress, type PtyIngressEmission } from '../../shared/pty-startup-ingress' import { resolvePtyOwnerBackend } from '../../shared/pty-owner-backend' import { resolveProcessExitCause } from '../../shared/terminal-exit-cause' @@ -125,8 +131,10 @@ export function activateLocalPtySession(args: { ptyDisposables.set(id, disposables) let exitedBeforeSpawnReply = false + let staging: StartupCommandStaging | undefined const onExitDisposable = proc.onExit(({ exitCode, signal }) => { exitedBeforeSpawnReply = true + discardStagedStartupCommand(staging) // Why: node-pty reports a signalled death as {exitCode: 0, signal: N}; the // cause is built here, where the signal and the spawn's trustworthiness // are both still in hand. @@ -178,10 +186,22 @@ export function activateLocalPtySession(args: { shellName: spawnedShellName, waitsForShellReady: plan.shellReadyLaunch?.supportsReadyMarker === true }) + staging = stageStartupCommand({ + command: spawn.command, + shellPath: plan.shellPath, + orcaBuiltLine: spawn.launchAgent !== undefined + }) + const notice = startupStagingFailureNotice(staging) + if (notice) { + startupIngress.accept(notice) + console.warn(`[pty] Could not stage startup command for ${id}; typing it in full`, { + reason: staging.failure + }) + } writeStartupCommandWhenShellReady( readiness.shellReadyPromise, proc, - spawn.command, + staging.command, (cleanup) => { readiness.setStartupCommandCleanup(cleanup) }, diff --git a/src/relay/pty-handler-startup-command-staging.test.ts b/src/relay/pty-handler-startup-command-staging.test.ts new file mode 100644 index 000000000000..a058dfb7d726 --- /dev/null +++ b/src/relay/pty-handler-startup-command-staging.test.ts @@ -0,0 +1,114 @@ +import './mock-descendant-sweep' +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({ + mockPtySpawn: vi.fn(), + mockCreateShellPromptReadinessProbe: vi.fn(), + mockPtyInstance: { + pid: process.pid, + onData: vi.fn(), + onExit: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + clear: vi.fn(), + pause: vi.fn(), + resume: vi.fn() + } +})) + +vi.mock('node-pty', () => ({ + spawn: mockPtySpawn +})) + +vi.mock('../main/pty/posix-pty-process-groups', () => ({ + forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback()) +})) + +vi.mock('../main/shell-prompt-readiness-probe', () => ({ + createShellPromptReadinessProbe: mockCreateShellPromptReadinessProbe +})) + +import type { PtyHandler } from './pty-handler' +import { beginPtyHandlerTest, endPtyHandlerTest } from './pty-handler-test-harness' +import type { MockDispatcher } from './pty-handler-test-harness' + +const describePosix = process.platform === 'win32' ? describe.skip : describe + +describePosix('relay startup command staging', () => { + let dispatcher: MockDispatcher + let handler: PtyHandler + let originalPlatform: PropertyDescriptor | undefined + let stagingDir: string + + beforeEach(() => { + stagingDir = mkdtempSync(join(tmpdir(), 'orca-relay-staging-')) + vi.stubEnv('TMPDIR', stagingDir) + ;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest({ + mockPtySpawn, + mockPtyInstance, + mockCreateShellPromptReadinessProbe + })) + }) + + afterEach(async () => { + await endPtyHandlerTest(handler, originalPlatform) + vi.unstubAllEnvs() + rmSync(stagingDir, { recursive: true, force: true }) + }) + + async function spawn(command: string): Promise { + return await dispatcher.callRequest('pty.spawn', { + command, + commandDelivery: 'provider', + env: { SHELL: '/bin/zsh' } + }) + } + + it('types a short provider-delivered command as is', async () => { + await spawn('echo short') + await vi.advanceTimersByTimeAsync(50) + expect(mockPtySpawn.mock.results[0]?.value.write).toHaveBeenCalledWith('echo short\n') + }) + + it('stages a long provider-delivered command and types only the sourcing line', async () => { + const command = `claude '${'x'.repeat(600)}'` + await spawn(command) + const [script] = readdirSync(stagingDir) + const scriptPath = join(stagingDir, script) + expect(readFileSync(scriptPath, 'utf8').split('\n')[1]).toBe(command) + await vi.advanceTimersByTimeAsync(50) + expect(mockPtySpawn.mock.results[0]?.value.write).toHaveBeenCalledWith(`. '${scriptPath}'\n`) + }) + + it('deletes a script the shell never sourced when the PTY exits', async () => { + await spawn(`claude '${'x'.repeat(600)}'`) + const scriptPath = join(stagingDir, readdirSync(stagingDir)[0]) + mockPtyInstance.onExit.mock.calls.at(-1)?.[0]?.({ exitCode: 0 }) + expect(existsSync(scriptPath)).toBe(false) + }) + + it('stages nothing for a renderer-delivered command it only holds as a hint', async () => { + await dispatcher.callRequest('pty.spawn', { + command: `claude '${'x'.repeat(600)}'`, + env: { SHELL: '/bin/zsh' } + }) + expect(readdirSync(stagingDir)).toEqual([]) + }) + + it('prints a notice in the terminal when it types a line it could not stage', async () => { + vi.stubEnv('TMPDIR', join(stagingDir, 'missing')) + const command = `claude '${'x'.repeat(600)}'` + await spawn(command) + await vi.advanceTimersByTimeAsync(50) + expect(mockPtySpawn.mock.results[0]?.value.write).toHaveBeenCalledWith(`${command}\n`) + const output = dispatcher._notifications + .filter((notification) => notification.method === 'pty.data') + .map((notification) => String(notification.params?.data)) + .join('') + expect(output).toContain('[orca] Could not stage the launch command (ENOENT') + }) +}) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index cb88537de449..5210e9ba50f8 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -33,6 +33,12 @@ import { SHELL_STARTUP_FEATURE_ENV } from '../main/shell-startup-features' import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types' import { shouldUseShellReadyStartupDelivery } from '../shared/codex-startup-delivery' import { buildStartupCommandSubmission } from '../shared/startup-command-submission' +import { + discardStagedStartupCommand, + stageStartupCommand, + startupStagingFailureNotice, + type StartupCommandStaging +} from '../shared/startup-command-staging' import { resolveSetupAgentSequenceLaunchCommand } from '../shared/setup-agent-sequencing' import { isPathInsideOrEqual, @@ -249,6 +255,8 @@ type ManagedPty = { gitCredentialPromptGuarded: boolean historyIsolationEnabled?: boolean startupCommand?: ManagedStartupCommand + /** Kept past delivery: the typed line may never run if the shell dies first. */ + stagedStartupCommand?: StartupCommandStaging /** Whether this host armed the shell-ready marker for a renderer-delivered startup command. * Kept off `startupCommand`, which is dropped once delivered; the client reads it from the * spawn reply to skip waiting for a marker that will never come (fish, sh, Windows). */ @@ -328,6 +336,7 @@ function disposeManagedPty(managed: ManagedPty): void { return } managed.disposed = true + discardStagedStartupCommand(managed.stagedStartupCommand) // Why: clear the SIGKILL fallback timer so it can't fire pty.kill on an already-disposed instance. if (managed.killTimer) { clearTimeout(managed.killTimer) @@ -957,10 +966,13 @@ export class PtyHandler { } const submit = process.platform === 'win32' ? '\r' : '\n' // Why: only the shell-ready wrapper arms bracketed-paste; other shells use raw submit so ESC[200~ markers aren't echoed. - const payload = buildStartupCommandSubmission(startup.command, { - submit, - bracketedPasteSafe: startup.waitForShellReady - }) + const payload = buildStartupCommandSubmission( + managed.stagedStartupCommand?.command ?? startup.command, + { + submit, + bracketedPasteSafe: startup.waitForShellReady + } + ) managed.startupCommand = undefined managed.pty.write(payload) } @@ -2128,11 +2140,28 @@ export class PtyHandler { } : {}) } + if (managed.startupCommand?.providerDelivery && managed.startupCommand.command) { + managed.stagedStartupCommand = stageStartupCommand({ + command: managed.startupCommand.command, + shellPath: shell, + orcaBuiltLine: launchAgent !== undefined + }) + if (managed.stagedStartupCommand.failure) { + process.stderr.write( + `[pty-handler] Could not stage startup command for ${id}; typing it in full: ${managed.stagedStartupCommand.failure}\n` + ) + } + } this.retiredIncarnations.delete(id) this.sourcePublication?.activate(id, managed.incarnationId, context) const sourceActivation = context && this.sourcePublication?.receivingActivation?.(id, context.clientId) this.wireAndStore(managed) + const stagingNotice = + managed.stagedStartupCommand && startupStagingFailureNotice(managed.stagedStartupCommand) + if (stagingNotice) { + managed.startupIngress?.accept(stagingNotice) + } if (context?.isStale() && !params.agentSessionEnsure && !params.agentSessionCreateOperationId) { // Why: if the client reconnected while pty.spawn was in flight, the // response is discarded and no renderer can own this PTY. Shut it down diff --git a/src/renderer/src/lib/launch-agent-background-session-remote.test.ts b/src/renderer/src/lib/launch-agent-background-session-remote.test.ts index 77dfc46c349c..e6480d197f7a 100644 --- a/src/renderer/src/lib/launch-agent-background-session-remote.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session-remote.test.ts @@ -153,242 +153,50 @@ describe('launchAgentBackgroundSession remote runtime and SSH startup delivery', ) }) - it('injects fast startup commands into SSH background sessions after shell output arrives', async () => { - vi.useFakeTimers() - try { - state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] - const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') - - await launchAgentBackgroundSession({ - agent: 'claude', - worktreeId: 'wt-1', - prompt: 'run the automation', - title: 'Nightly audit' - }) - - expect(mockSpawn.mock.calls[0]?.[0]?.command).toBe( - "claude '--dangerously-skip-permissions' 'run the automation'" - ) - expect(mockSpawn.mock.calls[0]?.[0]?.startupCommandDelivery).toBeUndefined() - const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void - dataSidecar('user@remote repo % ') - vi.advanceTimersByTime(50) - - expect(mockWrite).toHaveBeenCalledWith( - 'pty-1', - "claude '--dangerously-skip-permissions' 'run the automation'\r", - 'launch' - ) - } finally { - vi.useRealTimers() - } - }) - - it('waits for shell-ready before injecting payload-bearing SSH background commands', async () => { - vi.useFakeTimers() - try { - state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] - const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') - - await launchAgentBackgroundSession({ - agent: 'codex', - worktreeId: 'wt-1', - prompt: 'run the automation', - title: 'Nightly audit' - }) - - expect(mockSpawn.mock.calls[0]?.[0]).toEqual( - expect.objectContaining({ - command: "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'", - startupCommandDelivery: 'shell-ready' + it.each(['claude', 'codex'] as const)( + 'has the relay type the %s launch line instead of writing it from the renderer', + async (agent) => { + vi.useFakeTimers() + try { + state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await launchAgentBackgroundSession({ + agent, + worktreeId: 'wt-1', + prompt: 'run the automation' }) - ) - const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void - dataSidecar('user@remote repo % ') - vi.advanceTimersByTime(50) - expect(mockWrite).not.toHaveBeenCalled() - - dataSidecar('\x1b]777;orca-shell-ready\x07user@remote repo % ') - vi.advanceTimersByTime(50) - - expect(mockWrite).toHaveBeenCalledWith( - 'pty-1', - "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'\r", - 'launch' - ) - } finally { - vi.useRealTimers() - } - }) - - // #18767: a plain Codex launch carries no shell-ready hint, but the remote host - // still arms the marker for it, so writing early would display the launch twice. - it('waits for shell-ready for a promptless SSH background Codex launch', async () => { - vi.useFakeTimers() - try { - state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] - const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') - - await launchAgentBackgroundSession({ agent: 'codex', worktreeId: 'wt-1' }) - const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void - dataSidecar('user@remote repo % ') - vi.advanceTimersByTime(1_400) - - expect(mockWrite).not.toHaveBeenCalled() - - dataSidecar('\x1b]777;orca-shell-ready\x07user@remote repo % ') - vi.advanceTimersByTime(50) - - expect(mockWrite).toHaveBeenCalledWith( - 'pty-1', - "codex '--dangerously-bypass-approvals-and-sandbox'\r", - 'launch' - ) - } finally { - vi.useRealTimers() - } - }) - - it('skips the shell-ready wait when the host reports it did not arm the marker', async () => { - vi.useFakeTimers() - try { - state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] - mockSpawn.mockResolvedValue({ id: 'pty-1', shellReadyArmed: false }) - const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') - - await launchAgentBackgroundSession({ agent: 'codex', worktreeId: 'wt-1' }) - const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void - dataSidecar('user@remote repo % ') - vi.advanceTimersByTime(50) - - expect(mockWrite).toHaveBeenCalledWith( - 'pty-1', - "codex '--dangerously-bypass-approvals-and-sandbox'\r", - 'launch' - ) - } finally { - vi.useRealTimers() - } - }) - - it('falls back when an SSH shell produces no observable startup data', async () => { - vi.useFakeTimers() - try { - state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] - const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') - await launchAgentBackgroundSession({ - agent: 'codex', - worktreeId: 'wt-1', - prompt: 'run the automation' - }) - // Why the longer wait: a shell that has emitted nothing is still booting, - // so the fallback holds off rather than pasting before readline arms. - vi.advanceTimersByTime(1_550) - expect(mockWrite).not.toHaveBeenCalled() - vi.advanceTimersByTime(15_050) - - expect(mockWrite).toHaveBeenCalledWith( - 'pty-1', - "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'\r", - 'launch' - ) - } finally { - vi.useRealTimers() - } - }) - - it('keeps the short fallback for an SSH shell that talks but cannot emit the marker', async () => { - vi.useFakeTimers() - try { - state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] - const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') - - await launchAgentBackgroundSession({ - agent: 'codex', - worktreeId: 'wt-1', - prompt: 'run the automation' - }) - const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void - dataSidecar('user@remote repo % ') - vi.advanceTimersByTime(1_550) - - expect(mockWrite).toHaveBeenCalledWith( - 'pty-1', - "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'\r", - 'launch' - ) - } finally { - vi.useRealTimers() - } - }) - - it('waits for shell-ready for SSH background Codex native prefill commands without a hint', async () => { - vi.useFakeTimers() - try { - state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] - state.settings = { - agentCmdOverrides: { codex: "codex --prefill 'draft from override'" }, - activeRuntimeEnvironmentId: null, - terminalMainSideEffectAuthority: undefined + expect(mockSpawn.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + command: expect.stringContaining("'run the automation'"), + connectionId: 'ssh-1', + commandDelivery: 'provider', + startupCommandDelivery: 'shell-ready' + }) + ) + const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void + dataSidecar('\x1b]777;orca-shell-ready\x07user@remote repo % ') + vi.advanceTimersByTime(20_000) + expect(mockWrite).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() } - const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') - - await launchAgentBackgroundSession({ - agent: 'codex', - worktreeId: 'wt-1', - title: 'Nightly audit' - }) - - expect(mockSpawn.mock.calls[0]?.[0]).toEqual( - expect.objectContaining({ - command: - "codex --prefill 'draft from override' '--dangerously-bypass-approvals-and-sandbox'" - }) - ) - expect(mockSpawn.mock.calls[0]?.[0]).not.toHaveProperty('startupCommandDelivery') - const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void - dataSidecar('user@remote repo % ') - vi.advanceTimersByTime(50) - expect(mockWrite).not.toHaveBeenCalled() - - dataSidecar('\x1b]777;orca-shell-ready\x07user@remote repo % ') - vi.advanceTimersByTime(50) - - expect(mockWrite).toHaveBeenCalledWith( - 'pty-1', - "codex --prefill 'draft from override' '--dangerously-bypass-approvals-and-sandbox'\r", - 'launch' - ) - } finally { - vi.useRealTimers() } - }) - - it('does not rearm SSH background startup delivery after exit cleanup', async () => { - vi.useFakeTimers() - try { - state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] - const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') - - await launchAgentBackgroundSession({ - agent: 'codex', - worktreeId: 'wt-1', - prompt: 'run the automation', - title: 'Nightly audit' - }) + ) - const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void - const exitSidecar = mockSubscribeToPtyExit.mock.calls[0]?.[1] as (code: number) => void - exitSidecar(0) + it('has the relay type a promptless SSH background launch too', async () => { + state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') - dataSidecar('\x1b]777;orca-shell-ready\x07user@remote repo % ') - vi.advanceTimersByTime(50) + await launchAgentBackgroundSession({ agent: 'codex', worktreeId: 'wt-1' }) - expect(mockWrite).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } + expect(mockSpawn.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + commandDelivery: 'provider', + startupCommandDelivery: 'shell-ready' + }) + ) }) it.each([true, false])( diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index 35960f749d15..3e997d32e51e 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -28,10 +28,6 @@ import { subscribeToRuntimeTerminalData, toRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' -import { - createSshBackgroundStartupDelivery, - sshBackgroundLaunchWaitsForShellReady -} from '@/lib/ssh-background-startup-delivery' import { isMainTerminalSideEffectAuthorityForPty } from '@/components/terminal-pane/terminal-side-effect-facts-handler' import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell' import { runBestEffortAgentBackgroundCleanups } from '@/lib/agent-background-session-cleanup' @@ -102,12 +98,6 @@ export async function launchAgentBackgroundSession( }) let paneKey = makePaneKey(reservedTabId, leafId) const sshConnectionId = launchHost.connectionId - const sshStartupDelivery = createSshBackgroundStartupDelivery({ - command: sshConnectionId ? startupPlan.launchCommand : null, - waitForShellReady: - Boolean(sshConnectionId) && sshBackgroundLaunchWaitsForShellReady(startupPlan), - write: (ptyId, data) => window.api.pty.write(ptyId, data, 'launch') - }) // Route by the worktree's owner host, not the focused runtime. const runtimeTarget = getActiveRuntimeTarget( getSettingsForWorktreeRuntimeOwner(store, worktreeId) @@ -130,7 +120,6 @@ export async function launchAgentBackgroundSession( exitHandled = true unsubscribeExit() unsubscribeData() - sshStartupDelivery.clear() if (tab) { settleTabPtyBinding(tab.id, exitPtyId, code) } @@ -153,9 +142,7 @@ export async function launchAgentBackgroundSession( onAgentStatus }) const handleData = (data: string): void => { - data = sshStartupDelivery.handleData(data) onData?.(data) - sshStartupDelivery.schedule(ptyId) agentStatusConsumer.consume(data) } try { @@ -190,9 +177,12 @@ export async function launchAgentBackgroundSession( cwd: worktree.path, command: startupPlan.launchCommand, ...(!sshConnectionId && isWslUncPath(worktree.path) ? { shellOverride: 'wsl.exe' } : {}), - ...(!startupPlan.startupCommandDelivery - ? {} - : { startupCommandDelivery: startupPlan.startupCommandDelivery }), + // Why: the relay types, waits for the shell and stages long lines on the host that owns the PTY. + ...(sshConnectionId + ? { commandDelivery: 'provider' as const, startupCommandDelivery: 'shell-ready' as const } + : startupPlan.startupCommandDelivery + ? { startupCommandDelivery: startupPlan.startupCommandDelivery } + : {}), env: paneEnv, launchConfig: startupPlan.launchConfig, launchToken, @@ -209,7 +199,6 @@ export async function launchAgentBackgroundSession( }) ptyId = result.id spawned = result - sshStartupDelivery.applyHostShellReadyArmed(result.shellReadyArmed) } const adopted = await adoptAgentBackgroundSessionTab({ store, @@ -223,7 +212,6 @@ export async function launchAgentBackgroundSession( runtimeTerminalHandle, onRetire: () => { exitHandled = true - sshStartupDelivery.clear() store.clearAgentLaunchConfig(paneKey) }, ...(title ? { title } : {}) @@ -279,8 +267,6 @@ export async function launchAgentBackgroundSession( // alive regardless of whether the tab is hidden or mounted. unsubscribeExit = subscribeToPtyExit(ptyId, (code) => handleExit(ptyId, code)) } - sshStartupDelivery.armFallback(ptyId) - // Why: bind the explicit PTY and ownership before mount; an earlier mount // can double-spawn, while later tracking can miss user takeover. requestBackgroundTerminalWorktreeMount({ worktreeId, tabIds: [tab.id] }) @@ -298,7 +284,6 @@ export async function launchAgentBackgroundSession( const createdTab = tab runBestEffortAgentBackgroundCleanups(unsubscribeExit, unsubscribeData) runBestEffortAgentBackgroundCleanups(() => eagerPtyBuffer?.dispose()) - runBestEffortAgentBackgroundCleanups(() => sshStartupDelivery.clear()) if (createdTab) { runBestEffortAgentBackgroundCleanups(() => store.clearTabPtyId(createdTab.id, ptyId)) } diff --git a/src/renderer/src/lib/ssh-background-startup-delivery.test.ts b/src/renderer/src/lib/ssh-background-startup-delivery.test.ts deleted file mode 100644 index 427d1dbdb8f9..000000000000 --- a/src/renderer/src/lib/ssh-background-startup-delivery.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createSshBackgroundStartupDelivery } from './ssh-background-startup-delivery' - -const SHELL_READY = '\x1b]777;orca-shell-ready\x07' - -function createDelivery(): { - delivery: ReturnType - write: ReturnType -} { - const write = vi.fn() - return { - delivery: createSshBackgroundStartupDelivery({ - command: 'codex "run the automation"', - waitForShellReady: true, - write - }), - write - } -} - -// Bracketed paste only wraps multiline submissions, so the marker's effect on it -// is only observable through a command that carries a newline. -const MULTILINE_COMMAND = 'codex "run the\nautomation"' - -function createMultilineDelivery(waitForShellReady: boolean): { - delivery: ReturnType - write: ReturnType -} { - const write = vi.fn() - return { - delivery: createSshBackgroundStartupDelivery({ - command: MULTILINE_COMMAND, - waitForShellReady, - write - }), - write - } -} - -beforeEach(() => { - vi.useFakeTimers() -}) - -afterEach(() => { - vi.useRealTimers() -}) - -describe('createSshBackgroundStartupDelivery shell-ready fallback', () => { - it('does not force delivery at the short deadline while the remote shell is still silent', () => { - const { delivery, write } = createDelivery() - - // Armed at spawn, before any byte arrives (launch-agent-background-session). - delivery.armFallback('pty-1') - // A cold host sourcing /etc/profile plus nvm/pyenv has not prompted yet. - vi.advanceTimersByTime(3_000) - - expect(write).not.toHaveBeenCalled() - - // The prompt finally lands with the marker; delivery follows normally. - delivery.handleData(`${SHELL_READY}user@remote repo % `) - vi.advanceTimersByTime(50) - - expect(write).toHaveBeenCalledTimes(1) - expect(write.mock.calls[0]?.[1]).toContain('codex "run the automation"') - }) - - it('still delivers eventually when a shell can never emit the marker, and not before 15s', () => { - const { delivery, write } = createDelivery() - - delivery.armFallback('pty-1') - // Pin the boundary: asserting only eventual delivery would let the budget - // silently shrink back toward the short deadline this fix moved off. - vi.advanceTimersByTime(14_999) - - expect(write).not.toHaveBeenCalled() - - vi.advanceTimersByTime(1) - vi.advanceTimersByTime(50) - - expect(write).toHaveBeenCalledTimes(1) - }) - - it('keeps the short post-output deadline once the shell has started talking', () => { - const { delivery, write } = createDelivery() - - delivery.armFallback('pty-1') - // Output without the marker: the shell is alive but cannot emit it. - delivery.handleData('user@remote repo % ') - vi.advanceTimersByTime(1_550) - vi.advanceTimersByTime(50) - - expect(write).toHaveBeenCalledTimes(1) - }) - - // The long budget exists to protect the bracketed paste from landing before - // readline arms it. 'fast' delivery waits for no marker and pastes nothing - // prompt-sensitive, so stretching it there is latency with nothing bought. - it('keeps the short deadline for fast delivery, which waits for no marker', () => { - const write = vi.fn() - const delivery = createSshBackgroundStartupDelivery({ - command: 'codex "run the automation"', - waitForShellReady: false, - write - }) - - delivery.armFallback('pty-1') - vi.advanceTimersByTime(1_550) - - expect(write).toHaveBeenCalledTimes(1) - }) - - // #18767: the marker is what proves the host wrapped the shell and armed - // bracketed paste. A fallback release means it never did. - it('uses bracketed paste only after the marker actually arrived', () => { - const { delivery, write } = createMultilineDelivery(true) - - delivery.armFallback('pty-1') - delivery.handleData(`${SHELL_READY}user@remote repo % `) - vi.advanceTimersByTime(50) - - expect(write.mock.calls[0]?.[1]).toContain('\x1b[200~') - }) - - // The host answers in the spawn reply whether it armed the marker. `false` means - // none will ever come, so the pre-#18796 fast path applies; `true` keeps the wait; - // absent is an older host and leaves the client-side prediction alone. - describe('host shell-ready verdict', () => { - it('delivers immediately and raw when the host reports the marker was not armed', () => { - const { delivery, write } = createMultilineDelivery(true) - - delivery.applyHostShellReadyArmed(false) - delivery.armFallback('pty-1') - // The launch flow schedules on every data chunk (launch-agent-background-session). - delivery.handleData('user@remote repo % ') - delivery.schedule('pty-1') - vi.advanceTimersByTime(50) - - expect(write).toHaveBeenCalledTimes(1) - expect(write.mock.calls[0]?.[1]).not.toContain('\x1b[200~') - }) - - it('keeps the short silent-shell budget once the host says no marker is coming', () => { - const { delivery, write } = createDelivery() - - delivery.applyHostShellReadyArmed(false) - delivery.armFallback('pty-1') - vi.advanceTimersByTime(1_550) - - expect(write).toHaveBeenCalledTimes(1) - }) - - it('still waits for the marker when the host reports it armed one', () => { - const { delivery, write } = createDelivery() - - delivery.applyHostShellReadyArmed(true) - delivery.armFallback('pty-1') - delivery.handleData('user@remote repo % ') - vi.advanceTimersByTime(1_400) - - expect(write).not.toHaveBeenCalled() - - delivery.handleData(`${SHELL_READY}user@remote repo % `) - vi.advanceTimersByTime(50) - - expect(write).toHaveBeenCalledTimes(1) - }) - - it('keeps the client-side prediction when an older host omits the verdict', () => { - const { delivery, write } = createDelivery() - - delivery.applyHostShellReadyArmed(undefined) - delivery.armFallback('pty-1') - delivery.handleData('user@remote repo % ') - vi.advanceTimersByTime(1_400) - - expect(write).not.toHaveBeenCalled() - - vi.advanceTimersByTime(150) - vi.advanceTimersByTime(50) - - expect(write).toHaveBeenCalledTimes(1) - }) - }) - - it('submits raw when the wait ends at the fallback instead of the marker', () => { - const { delivery, write } = createMultilineDelivery(true) - - delivery.armFallback('pty-1') - delivery.handleData('user@remote repo % ') - vi.advanceTimersByTime(1_550) - vi.advanceTimersByTime(50) - - expect(write).toHaveBeenCalledTimes(1) - expect(write.mock.calls[0]?.[1]).not.toContain('\x1b[200~') - }) -}) diff --git a/src/renderer/src/lib/ssh-background-startup-delivery.ts b/src/renderer/src/lib/ssh-background-startup-delivery.ts deleted file mode 100644 index 0e1c08c70ff7..000000000000 --- a/src/renderer/src/lib/ssh-background-startup-delivery.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { - createShellReadyMarkerScanState, - scanForShellReadyMarker -} from '@/components/terminal-pane/shell-ready-marker-scan' -import { - isCodexStartupCommand, - shouldUseShellReadyStartupDelivery, - type StartupCommandDelivery -} from '../../../shared/codex-startup-delivery' -import { buildStartupCommandSubmission } from '../../../shared/startup-command-submission' - -/** - * Why every Codex launch waits and not only the prompt-carrying ones: the remote - * shell is the host's to know, and it arms the ready marker for plain Codex too - * (#18767). On such a host the wait ends at the prompt and costs nothing. On one - * that never publishes a marker -- fish, sh, Windows, or a host predating #18767 -- - * the fallback below releases instead, at the same price prompt-carrying Codex - * already paid there. - */ -export function sshBackgroundLaunchWaitsForShellReady(startupPlan: { - launchCommand: string | null | undefined - startupCommandDelivery?: StartupCommandDelivery -}): boolean { - return ( - isCodexStartupCommand(startupPlan.launchCommand) || - shouldUseShellReadyStartupDelivery({ - command: startupPlan.launchCommand, - startupCommandDelivery: startupPlan.startupCommandDelivery - }) - ) -} - -const SSH_SHELL_READY_STARTUP_FALLBACK_MS = 1500 -// Why: a remote shell that has not emitted a single byte is still booting — -// /etc/profile plus nvm/conda/pyenv over a cold link routinely needs more than -// the post-output deadline. Force-delivering there writes the bracketed-paste -// command before readline arms it, so a silent-since-spawn shell gets a longer -// budget; the short deadline applies once output proves the shell is talking. -const SSH_SHELL_READY_NO_OUTPUT_FALLBACK_MS = 15_000 - -type SshBackgroundStartupDeliveryOptions = { - command: string | null - waitForShellReady: boolean - write: (ptyId: string, data: string) => void -} - -export type SshBackgroundStartupDelivery = { - handleData(data: string): string - armFallback(ptyId: string): void - schedule(ptyId: string): void - /** - * The host's verdict from the spawn reply, which lands after this delivery was built. - * `false` releases the wait: no marker will ever come. `true` keeps it. `undefined` is - * a host that predates the field, so the constructor-time prediction stands. - */ - applyHostShellReadyArmed(armed: boolean | undefined): void - clear(): void -} - -export function createSshBackgroundStartupDelivery( - options: SshBackgroundStartupDeliveryOptions -): SshBackgroundStartupDelivery { - let pendingCommand = options.command - let lastPtyId: string | null = null - let waitForShellReady = options.waitForShellReady - let startupShellReady = !waitForShellReady - // Why tracked apart from `startupShellReady`: only an observed marker proves the - // host wrapped the shell and armed bracketed paste. A fallback release means the - // host shell never published one, so the raw submit is the only safe form. - let markerObserved = false - let markerScan = waitForShellReady ? createShellReadyMarkerScanState() : null - let injectTimer: ReturnType | null = null - let fallbackTimer: ReturnType | null = null - let sawOutput = false - - const clearInjectTimer = (): void => { - if (injectTimer !== null) { - clearTimeout(injectTimer) - injectTimer = null - } - } - const clearFallbackTimer = (): void => { - if (fallbackTimer !== null) { - clearTimeout(fallbackTimer) - fallbackTimer = null - } - } - function markShellReady(): void { - if (startupShellReady) { - return - } - startupShellReady = true - markerObserved = true - clearFallbackTimer() - if (pendingCommand && lastPtyId) { - schedule(lastPtyId) - } - } - - const armFallback = (ptyId: string): void => { - lastPtyId = ptyId - if (!pendingCommand || fallbackTimer !== null) { - return - } - // The long budget only buys time for the shell-ready marker; the fast path - // pastes nothing prompt-sensitive, so delaying it there is pure latency. - const waitingForSilentShell = waitForShellReady && !sawOutput - fallbackTimer = setTimeout( - () => { - fallbackTimer = null - startupShellReady = true - schedule(ptyId) - }, - waitingForSilentShell - ? SSH_SHELL_READY_NO_OUTPUT_FALLBACK_MS - : SSH_SHELL_READY_STARTUP_FALLBACK_MS - ) - } - - const schedule = (ptyId: string): void => { - lastPtyId = ptyId - if (!pendingCommand) { - return - } - if (!startupShellReady) { - armFallback(ptyId) - return - } - clearFallbackTimer() - clearInjectTimer() - injectTimer = setTimeout(() => { - injectTimer = null - const command = pendingCommand - if (!command) { - return - } - pendingCommand = null - // Why: the SSH relay treats spawn.command as metadata for interactive - // PTYs; hidden automation tabs still submit the command themselves. - // Why bracketed paste: multiline prompts are pasted literally only when we - // synchronized on the Orca shell-ready marker — that is the bash/zsh overlay - // with bracketed-paste mode armed. Submit with CR since the relay drives a - // remote shell. - options.write( - ptyId, - buildStartupCommandSubmission(command, { - submit: '\r', - bracketedPasteSafe: markerObserved - }) - ) - }, 50) - } - - return { - handleData(data) { - // First byte proves the shell is talking, so the spawn-time long budget - // collapses back to the original post-output deadline. - if (!sawOutput && data.length > 0) { - sawOutput = true - if (fallbackTimer !== null && !startupShellReady && lastPtyId) { - clearFallbackTimer() - armFallback(lastPtyId) - } - } - if (!markerScan) { - return data - } - const scanned = scanForShellReadyMarker(markerScan, data) - if (scanned.matched) { - markShellReady() - } - return scanned.output - }, - armFallback, - schedule, - applyHostShellReadyArmed(armed) { - if (armed !== false || !waitForShellReady) { - return - } - // Not a marker sighting: bracketed paste stays unproven, so the submit stays raw. - waitForShellReady = false - startupShellReady = true - markerScan = null - clearFallbackTimer() - if (lastPtyId) { - schedule(lastPtyId) - } - }, - clear() { - clearInjectTimer() - clearFallbackTimer() - pendingCommand = null - lastPtyId = null - } - } -} diff --git a/src/shared/local-build-compatibility-contract.json b/src/shared/local-build-compatibility-contract.json index 205171615ca2..0e90a402914c 100644 --- a/src/shared/local-build-compatibility-contract.json +++ b/src/shared/local-build-compatibility-contract.json @@ -3,9 +3,9 @@ "appId": "com.stablyai.orca", "stateSchemaVersion": 1, "readableStateSchemaVersions": [1], - "daemonProtocolVersion": 39, + "daemonProtocolVersion": 40, "attachableDaemonProtocolVersions": [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, - 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39 + 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 ] } diff --git a/src/shared/local-build-compatibility-contract.ts b/src/shared/local-build-compatibility-contract.ts index 441dba09c4b7..75a414b2a5f3 100644 --- a/src/shared/local-build-compatibility-contract.ts +++ b/src/shared/local-build-compatibility-contract.ts @@ -3,9 +3,9 @@ export const LOCAL_BUILD_COMPATIBILITY_CONTRACT = { appId: 'com.stablyai.orca', stateSchemaVersion: 1, readableStateSchemaVersions: [1], - daemonProtocolVersion: 39, + daemonProtocolVersion: 40, attachableDaemonProtocolVersions: [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, - 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39 + 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 ] } as const diff --git a/src/shared/startup-command-staging.node-pty.test.ts b/src/shared/startup-command-staging.node-pty.test.ts new file mode 100644 index 000000000000..72f41c58a662 --- /dev/null +++ b/src/shared/startup-command-staging.node-pty.test.ts @@ -0,0 +1,158 @@ +/** + * Real shells in real PTYs: the line a host types for a long or multi-line launch + * must reach the agent's argv byte for byte, and leave no script behind. + * + * The line is written the moment the shell spawns, while the TTY is still in + * canonical mode — the window where a typed line past MAX_CANON is truncated + * and a raw newline submits early. + */ +import { execFileSync } from 'node:child_process' +import { + chmodSync, + existsSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import * as pty from 'node-pty' +import { afterAll, describe, expect, it } from 'vitest' +import { resolveFishBinary } from './fish-binary-requirement' +import { stageStartupCommand } from './startup-command-staging' +import { buildStartupCommandSubmission } from './startup-command-submission' +import { quoteStartupArg } from './tui-agent-startup-shell' + +type LiveShell = { name: string; path: string; args: string[] } + +function discoverShells(): LiveShell[] { + if (process.platform === 'win32') { + return [] + } + const shells: LiveShell[] = [] + const add = (name: string, path: string, args: string[]): void => { + if (existsSync(path) && !shells.some((shell) => shell.name === name)) { + shells.push({ name, path, args }) + } + } + add('bash', '/bin/bash', ['--noprofile', '--norc', '-i']) + add('zsh', '/bin/zsh', ['-f', '-i']) + add('dash', '/bin/dash', ['-i']) + add('dash', '/usr/bin/dash', ['-i']) + add('sh', '/bin/sh', ['-i']) + add('ksh', '/bin/ksh', ['-i']) + // Why tcsh: no staging shell; its line runs the POSIX script through /bin/sh. + add('tcsh', '/bin/tcsh', ['-f', '-i']) + const fish = resolveFishBinary(3) + if (fish.available) { + // Why absolute: the sandbox PATH would not resolve a bare `fish`. + const fishPath = fish.path.includes('/') + ? fish.path + : execFileSync('/bin/sh', ['-c', `command -v ${fish.path}`], { encoding: 'utf8' }).trim() + add('fish', fishPath, ['--no-config', '-i']) + } + return shells +} + +const SANDBOX = mkdtempSync(join(tmpdir(), 'orca-staging-pty-')) +const AGENT = join(SANDBOX, 'agent') +writeFileSync( + AGENT, + `#!/bin/sh\nfor arg in "$@"; do printf '%s\\0' "$arg"; done > "$ORCA_TEST_CAPTURE"\n` +) +chmodSync(AGENT, 0o755) + +afterAll(() => { + rmSync(SANDBOX, { recursive: true, force: true }) +}) + +const HOSTILE = `it's "quoted" $HOME \`id\` $(id) \\\\server\\share %PATH% !! #` + +const PROMPTS: [string, string][] = [ + ['a 600-byte prompt', `${HOSTILE} ${'x'.repeat(600)}`], + // Why 5 KB: past Linux's 4096-byte canonical buffer as well as macOS's 1024. + ['a 5 KB prompt', `${HOSTILE} ${'y'.repeat(5000)}`], + ['a multi-line prompt with a trailing newline', `first line\n${HOSTILE}\n\nlast line\n`], + // Why: typed raw, a line editor reads the TAB as completion and mangles the argument. + ['a prompt with a tab', 'before\tafter'] +] + +async function launchInRealShell(shell: LiveShell, prompt: string): Promise { + const caseDir = mkdtempSync(join(SANDBOX, `${shell.name}-`)) + const capture = join(caseDir, 'argv') + const command = `${quoteStartupArg(AGENT, 'posix')} ${quoteStartupArg(prompt, 'posix')}` + const staging = stageStartupCommand({ + command, + shellPath: shell.path, + orcaBuiltLine: true, + directory: caseDir + }) + const proc = pty.spawn(shell.path, shell.args, { + name: 'xterm-256color', + cols: 120, + rows: 30, + cwd: caseDir, + env: { + PATH: '/usr/bin:/bin:/usr/sbin:/sbin', + HOME: caseDir, + TERM: 'xterm-256color', + XDG_CONFIG_HOME: join(caseDir, 'config'), + XDG_DATA_HOME: join(caseDir, 'data'), + ORCA_TEST_CAPTURE: capture + } + }) + const typeLine = (): void => + proc.write( + buildStartupCommandSubmission(staging.command, { submit: '\n', bracketedPasteSafe: false }) + ) + // Why fish differs: it blocks on terminal queries and drops input typed before its prompt, which + // is why hosts wait for its ready marker; the other shells get the harsher at-spawn write. + let typed = false + const onData = proc.onData((data) => { + if (shell.name !== 'fish') { + return + } + proc.write('\x1b[?62;22c'.repeat(data.split('\x1b[0c').length - 1)) + proc.write('\x1b[1;1R'.repeat(data.split('\x1b[6n').length - 1)) + if (!typed && data.includes('\x1b]133;A')) { + typed = true + typeLine() + } + }) + try { + if (shell.name !== 'fish') { + typeLine() + } + const deadline = Date.now() + 10_000 + while (Date.now() < deadline) { + if (existsSync(capture)) { + // Why a beat: the agent's redirect creates the file before printf fills it. + await new Promise((resolve) => setTimeout(resolve, 100)) + return readFileSync(capture, 'utf8') + } + await new Promise((resolve) => setTimeout(resolve, 50)) + } + return null + } finally { + onData.dispose() + proc.kill() + expect(readdirSync(caseDir).filter((name) => name.startsWith('orca-launch-'))).toEqual([]) + } +} + +const SHELLS = discoverShells() +const describeShells = SHELLS.length > 0 ? describe : describe.skip + +describeShells('a staged launch line in a real shell', () => { + for (const shell of SHELLS) { + it.each(PROMPTS)( + `reaches the agent byte for byte in ${shell.name}: %s`, + async (_, prompt) => { + expect(await launchInRealShell(shell, prompt)).toBe(`${prompt}\0`) + }, + 20_000 + ) + } +}) diff --git a/src/shared/startup-command-staging.test.ts b/src/shared/startup-command-staging.test.ts new file mode 100644 index 000000000000..8a7568c17d14 --- /dev/null +++ b/src/shared/startup-command-staging.test.ts @@ -0,0 +1,213 @@ +import { + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + utimesSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + STAGED_STARTUP_COMMAND_STALE_MS, + discardStagedStartupCommand, + stageStartupCommand, + startupStagingFailureNotice, + sweepStaleStagedStartupCommands +} from './startup-command-staging' + +let directory: string + +beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'orca-staging-test-')) +}) + +afterEach(() => { + rmSync(directory, { recursive: true, force: true }) +}) + +function stage(command: string, shellPath = '/bin/zsh', platform: NodeJS.Platform = 'darwin') { + return stageStartupCommand({ command, shellPath, platform, directory }) +} + +describe('stageStartupCommand', () => { + it('types a short single-line command as is', () => { + expect(stage(`claude 'fix it'`)).toEqual({ command: `claude 'fix it'`, delivery: 'typed' }) + expect(readdirSync(directory)).toEqual([]) + }) + + it('types exactly 512 bytes, stages 513', () => { + expect(stage('a'.repeat(512)).delivery).toBe('typed') + expect(stage('a'.repeat(513)).delivery).toBe('staged') + }) + + it('measures bytes, not characters', () => { + expect(stage('é'.repeat(257)).delivery).toBe('staged') + }) + + it('ignores a trailing submit byte when measuring', () => { + expect(stage(`${'a'.repeat(512)}\n`).delivery).toBe('typed') + expect(stage(`${'a'.repeat(512)}\r\n`).delivery).toBe('typed') + }) + + it.each([['\n'], ['\r'], ['\t'], ['\x1b'], ['\x03'], ['\x7f']])( + 'stages a short line carrying control byte %j', + (byte) => { + expect(stage(`claude 'a${byte}b'`).delivery).toBe('staged') + } + ) + + it('writes a 0600 script whose body is exactly the command, and types a sourcing line', () => { + const command = `claude '${'x'.repeat(600)}'` + const staged = stage(`${command}\n`) + expect(staged.delivery).toBe('staged') + expect(staged.scriptPath).toBeDefined() + const scriptPath = staged.scriptPath! + expect(staged.command).toBe(`. '${scriptPath}'`) + expect(statSync(scriptPath).mode & 0o777).toBe(0o600) + expect(readFileSync(scriptPath, 'utf8')).toBe(`command rm -f -- '${scriptPath}'\n${command}\n`) + }) + + it('sources with `source` in fish', () => { + const staged = stage('a'.repeat(600), '/opt/homebrew/bin/fish') + expect(staged.command).toBe(`source '${staged.scriptPath}'`) + }) + + it('recognizes a login shell name', () => { + expect(stage('a'.repeat(600), '-zsh').delivery).toBe('staged') + }) + + it('sources the script in ksh like sh', () => { + const staged = stage('a'.repeat(600), '/bin/ksh') + expect(staged.command).toBe(`. '${staged.scriptPath}'`) + }) + + // Why: typed raw, tcsh runs a prompt's second line as a command and MAX_CANON cuts a long one. + it.each([['/usr/bin/nu'], ['/bin/tcsh'], ['/usr/local/bin/pwsh'], [undefined]])( + 'runs an Orca-built launch line through /bin/sh for a shell that cannot source it (%s)', + (shellPath) => { + const staged = stageStartupCommand({ + command: `claude 'one\ntwo'`, + shellPath, + orcaBuiltLine: true, + platform: 'darwin', + directory + }) + expect(staged.delivery).toBe('staged') + expect(staged.command).toBe(`/bin/sh '${staged.scriptPath}'`) + } + ) + + // Measured: tcsh typed `'fix C:'"\\"'dir'` as `fix C:\\dir` and expanded `!!` in `'wow!! great'`. + it.each([['/bin/tcsh'], ['/usr/bin/nu'], [undefined]])( + 'stages even a short Orca-built line for a shell that cannot source it (%s)', + (shellPath) => { + const command = `claude 'wow!! great'` + const staged = stageStartupCommand({ + command, + shellPath, + orcaBuiltLine: true, + platform: 'darwin', + directory + }) + expect(staged.command).toBe(`/bin/sh '${staged.scriptPath}'`) + expect(readFileSync(staged.scriptPath!, 'utf8')).toContain(`\n${command}\n`) + } + ) + + it('types a short Orca-built line in a shell that reads its quoting literally', () => { + const command = `claude 'wow!! great'` + expect( + stageStartupCommand({ command, shellPath: '/bin/zsh', orcaBuiltLine: true, directory }) + ).toEqual({ command, delivery: 'typed' }) + }) + + // Why: a quick command or --command written for tcsh or nu is not sh syntax. + it.each([['/bin/tcsh'], ['/usr/bin/nu'], [undefined]])( + 'types a command the user wrote as it always was in a shell that cannot source it (%s)', + (shellPath) => { + const command = `foreach f (*.log)\n echo $f\nend` + const staged = stageStartupCommand({ command, shellPath, platform: 'darwin', directory }) + expect(staged).toEqual({ command, delivery: 'typed' }) + expect(readdirSync(directory)).toEqual([]) + } + ) + + it('leaves Windows hosts alone', () => { + expect(stage('a'.repeat(600), 'C:\\Program Files\\Git\\bin\\bash.exe', 'win32').delivery).toBe( + 'typed' + ) + expect(stage('a'.repeat(600), '/bin/bash', 'win32').delivery).toBe('typed') + }) + + it('types the full command and says so when the script cannot be written', () => { + const command = 'a'.repeat(600) + const staged = stageStartupCommand({ + command, + shellPath: '/bin/bash', + platform: 'linux', + directory: join(directory, 'missing') + }) + expect(staged.command).toBe(command) + expect(staged.delivery).toBe('typed-after-stage-failed') + expect(staged.failure).toMatch(/ENOENT/) + expect(staged.scriptPath).toBeUndefined() + }) + + it('gives every launch its own script', () => { + const first = stage('a'.repeat(600)) + const second = stage('a'.repeat(600)) + expect(first.scriptPath).not.toBe(second.scriptPath) + }) +}) + +describe('discardStagedStartupCommand', () => { + it('removes a script the shell never sourced', () => { + const staged = stage('a'.repeat(600)) + discardStagedStartupCommand(staged) + expect(readdirSync(directory)).toEqual([]) + }) + + it('is a no-op once the script deleted itself', () => { + const staged = stage('a'.repeat(600)) + rmSync(staged.scriptPath!) + expect(() => discardStagedStartupCommand(staged)).not.toThrow() + }) +}) + +describe('sweepStaleStagedStartupCommands', () => { + it('removes only staged scripts older than an hour', () => { + const now = Date.now() + const stale = join(directory, 'orca-launch-aaaa.sh') + const fresh = join(directory, 'orca-launch-bbbb.sh') + const unrelated = join(directory, 'something-else.sh') + for (const path of [stale, fresh, unrelated]) { + writeFileSync(path, '') + } + const staleSeconds = (now - STAGED_STARTUP_COMMAND_STALE_MS - 1000) / 1000 + utimesSync(stale, staleSeconds, staleSeconds) + utimesSync(unrelated, staleSeconds, staleSeconds) + sweepStaleStagedStartupCommands({ directory, now }) + expect(readdirSync(directory).sort()).toEqual(['orca-launch-bbbb.sh', 'something-else.sh']) + }) +}) + +describe('startupStagingFailureNotice', () => { + it('says nothing when staging did not fail', () => { + expect(startupStagingFailureNotice(stage(`claude 'fix it'`))).toBeNull() + expect(startupStagingFailureNotice(stage(`claude '${'x'.repeat(600)}'`))).toBeNull() + }) + + it('prints one line naming the failure, with no control bytes from the reason', () => { + const notice = startupStagingFailureNotice({ + command: 'x', + delivery: 'typed-after-stage-failed', + failure: 'EACCES: permission denied\nsecond line' + }) + expect(notice).toBe( + '\r\n[orca] Could not stage the launch command (EACCES: permission denied second line); typed it in full. If the shell shows quote> or waits for more input, press Ctrl-C and launch again.\r\n' + ) + }) +}) diff --git a/src/shared/startup-command-staging.ts b/src/shared/startup-command-staging.ts new file mode 100644 index 000000000000..18f78fa84443 --- /dev/null +++ b/src/shared/startup-command-staging.ts @@ -0,0 +1,184 @@ +/** + * Stages a long or multi-line startup command as a self-deleting script so the + * host types only a short line that sources it. + * + * Why: the shell-ready marker fires before the line editor takes the TTY out of + * canonical mode, so a typed line past MAX_CANON (1024 bytes on macOS) is + * silently truncated, and macOS bash 3.2 reads each raw newline as Enter. Run + * by the host that owns the PTY, at the moment it accepts the spawn. + */ +import { randomBytes } from 'node:crypto' +import { readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { quoteStartupArg } from './tui-agent-startup-shell' + +/** Half of macOS MAX_CANON: a typed line this long survived every canonical-mode write measured. */ +export const TYPED_STARTUP_COMMAND_BUDGET_BYTES = 512 + +export const STAGED_STARTUP_COMMAND_PREFIX = 'orca-launch-' + +/** Which delivery ran for a spawn's typed startup line, decided by the host that owns the PTY. */ +export type StartupLineDelivery = 'typed' | 'staged' | 'typed-after-stage-failed' + +/** Age past which a staged script is a crash leftover rather than another host's in-flight launch. */ +export const STAGED_STARTUP_COMMAND_STALE_MS = 60 * 60 * 1000 + +export type StartupCommandStaging = { + /** What the host types: the command itself, or a short line sourcing the staged script. */ + command: string + delivery: StartupLineDelivery + /** Present while the script exists; the host unlinks it if the PTY exits before sourcing it. */ + scriptPath?: string + /** Why staging failed, for the host's log. */ + failure?: string +} + +// Why only these source the script: the staged body is Orca's portable quoting, verified literal in +// these. Any other shell (tcsh, nu, xonsh, pwsh...) runs an agent launch line Orca built through +// `/bin/sh` instead; a command the user wrote for that shell is typed as it always was. +const STAGING_SHELLS = new Set(['bash', 'zsh', 'sh', 'dash', 'fish', 'ksh', 'mksh']) + +const encoder = new TextEncoder() + +export function stagingShellName(shellPath: string | undefined): string | null { + const name = shellPath?.split('/').pop()?.replace(/^-/, '').toLowerCase() + return name && STAGING_SHELLS.has(name) ? name : null +} + +/** Any C0 byte or DEL: no quoter escapes them, so a line editor reads them as keys. */ +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 +} + +function stripSubmitTerminator(command: string): string { + return command.replace(/(\r\n|\r|\n)$/, '') +} + +export function shouldStageStartupCommand(args: { + command: string + shellPath: string | undefined + platform: NodeJS.Platform + /** An agent launch line Orca built with POSIX quoting, which sh can run in any shell. */ + orcaBuiltLine?: boolean +}): boolean { + if (args.platform === 'win32') { + return false + } + if (stagingShellName(args.shellPath) === null) { + // Why every length: Orca's POSIX quoting is literal only in the shells above (tcsh doubles a + // quoted backslash and expands `!!`), so another shell only ever sees the script's inert path. + return args.orcaBuiltLine === true + } + const body = stripSubmitTerminator(args.command) + return ( + hasControlByte(body) || encoder.encode(body).byteLength > TYPED_STARTUP_COMMAND_BUDGET_BYTES + ) +} + +let staleSweepStarted = false + +export function stageStartupCommand(args: { + command: string + shellPath: string | undefined + orcaBuiltLine?: boolean + platform?: NodeJS.Platform + directory?: string +}): StartupCommandStaging { + const platform = args.platform ?? process.platform + if (!shouldStageStartupCommand({ ...args, platform })) { + return { command: args.command, delivery: 'typed' } + } + const directory = args.directory ?? tmpdir() + if (!staleSweepStarted) { + staleSweepStarted = true + // Why deferred: the sweep is crash recovery and must never delay this launch. + setTimeout(() => sweepStaleStagedStartupCommands({ directory }), 0).unref?.() + } + const shellName = stagingShellName(args.shellPath) + const scriptPath = join( + directory, + `${STAGED_STARTUP_COMMAND_PREFIX}${randomBytes(8).toString('hex')}.sh` + ) + const quotedPath = quoteStartupArg(scriptPath, 'posix') + try { + // Why rm first: the shell keeps reading the open file, so the prompt-bearing script is gone + // before the agent starts, however long it runs. + writeFileSync( + scriptPath, + `command rm -f -- ${quotedPath}\n${stripSubmitTerminator(args.command)}\n`, + { mode: 0o600, flag: 'wx' } + ) + } catch (error) { + return { + command: args.command, + delivery: 'typed-after-stage-failed', + failure: error instanceof Error ? error.message : String(error) + } + } + return { + command: + shellName === null + ? `/bin/sh ${quotedPath}` + : `${shellName === 'fish' ? 'source' : '.'} ${quotedPath}`, + delivery: 'staged', + scriptPath + } +} + +/** + * The line the host prints to the terminal when staging failed: the full line it types instead may + * leave the shell waiting for more input, and nothing else would tell the user why. + */ +export function startupStagingFailureNotice(staging: StartupCommandStaging): string | null { + if (staging.failure === undefined) { + return null + } + const reason = [...staging.failure] + .map((char) => (char < ' ' || char === '\x7f' ? ' ' : char)) + .join('') + return `\r\n[orca] Could not stage the launch command (${reason}); typed it in full. If the shell shows quote> or waits for more input, press Ctrl-C and launch again.\r\n` +} + +/** For a PTY that exited, or was never typed into, before it sourced its script. */ +export function discardStagedStartupCommand(staging: StartupCommandStaging | undefined): void { + if (!staging?.scriptPath) { + return + } + try { + rmSync(staging.scriptPath, { force: true }) + } catch { + // The age-gated sweep is the fallback. + } +} + +/** Removes scripts a crashed host left behind; age-gated so another instance's fresh launch survives. */ +export function sweepStaleStagedStartupCommands(args: { directory?: string; now?: number }): void { + const directory = args.directory ?? tmpdir() + const now = args.now ?? Date.now() + let names: string[] + try { + names = readdirSync(directory) + } catch { + return + } + for (const name of names) { + if (!name.startsWith(STAGED_STARTUP_COMMAND_PREFIX) || !name.endsWith('.sh')) { + continue + } + const path = join(directory, name) + try { + if (now - statSync(path).mtimeMs > STAGED_STARTUP_COMMAND_STALE_MS) { + rmSync(path, { force: true }) + } + } catch { + // Raced another instance's sweep. + } + } +} From 865328e1ea5d9c01dd0b486d642b2af57ac580f4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:25:27 -0700 Subject: [PATCH 10/42] fix(launch): run a staged launch line as its own job in fish and ksh fish and ksh run the commands of a sourced file in the shell's own process group, so a staged agent was not a job: Ctrl-Z was dropped (a typed line stops), and the shell-in-front check saw the shell's group in the foreground while the agent ran. fish now runs the script with `eval (string collect < '')`, which runs it as if typed and leaves the shell's job-control mode alone. ksh and mksh run a long Orca-built line through `/bin/sh ''`, a real job; a long command the user wrote is typed as before. The real-shell test now asserts the agent's process group is its own and is the terminal's foreground group. The staging rule also reuses hasControlByte and the 512-byte budget from startup-line-prompt-carry so the carry and staging limits cannot drift. --- .../startup-command-staging.node-pty.test.ts | 26 +++++++--- src/shared/startup-command-staging.test.ts | 41 +++++++++++++--- src/shared/startup-command-staging.ts | 47 ++++++++++--------- 3 files changed, 79 insertions(+), 35 deletions(-) diff --git a/src/shared/startup-command-staging.node-pty.test.ts b/src/shared/startup-command-staging.node-pty.test.ts index 72f41c58a662..9a11175fe29b 100644 --- a/src/shared/startup-command-staging.node-pty.test.ts +++ b/src/shared/startup-command-staging.node-pty.test.ts @@ -1,6 +1,7 @@ /** * Real shells in real PTYs: the line a host types for a long or multi-line launch - * must reach the agent's argv byte for byte, and leave no script behind. + * must reach the agent's argv byte for byte, run it as its own foreground job as a + * typed line would, and leave no script behind. * * The line is written the moment the shell spawns, while the TTY is still in * canonical mode — the window where a typed line past MAX_CANON is truncated @@ -60,7 +61,7 @@ const SANDBOX = mkdtempSync(join(tmpdir(), 'orca-staging-pty-')) const AGENT = join(SANDBOX, 'agent') writeFileSync( AGENT, - `#!/bin/sh\nfor arg in "$@"; do printf '%s\\0' "$arg"; done > "$ORCA_TEST_CAPTURE"\n` + `#!/bin/sh\nps -o pgid=,tpgid= -p $$ > "$ORCA_TEST_CAPTURE.job"\nfor arg in "$@"; do printf '%s\\0' "$arg"; done > "$ORCA_TEST_CAPTURE"\n` ) chmodSync(AGENT, 0o755) @@ -79,7 +80,9 @@ const PROMPTS: [string, string][] = [ ['a prompt with a tab', 'before\tafter'] ] -async function launchInRealShell(shell: LiveShell, prompt: string): Promise { +type AgentRun = { argv: string; pgid: number; foregroundPgid: number; shellPid: number } + +async function launchInRealShell(shell: LiveShell, prompt: string): Promise { const caseDir = mkdtempSync(join(SANDBOX, `${shell.name}-`)) const capture = join(caseDir, 'argv') const command = `${quoteStartupArg(AGENT, 'posix')} ${quoteStartupArg(prompt, 'posix')}` @@ -130,7 +133,13 @@ async function launchInRealShell(shell: LiveShell, prompt: string): Promise setTimeout(resolve, 100)) - return readFileSync(capture, 'utf8') + const [pgid, foregroundPgid] = readFileSync(`${capture}.job`, 'utf8').trim().split(/\s+/) + return { + argv: readFileSync(capture, 'utf8'), + pgid: Number(pgid), + foregroundPgid: Number(foregroundPgid), + shellPid: proc.pid + } } await new Promise((resolve) => setTimeout(resolve, 50)) } @@ -148,9 +157,14 @@ const describeShells = SHELLS.length > 0 ? describe : describe.skip describeShells('a staged launch line in a real shell', () => { for (const shell of SHELLS) { it.each(PROMPTS)( - `reaches the agent byte for byte in ${shell.name}: %s`, + `reaches the agent byte for byte, as its own job, in ${shell.name}: %s`, async (_, prompt) => { - expect(await launchInRealShell(shell, prompt)).toBe(`${prompt}\0`) + const run = await launchInRealShell(shell, prompt) + expect(run?.argv).toBe(`${prompt}\0`) + // Why: in the shell's own group, Ctrl-Z never stops the agent and the shell-in-front check + // cannot tell the two apart. + expect(run?.pgid).not.toBe(run?.shellPid) + expect(run?.pgid).toBe(run?.foregroundPgid) }, 20_000 ) diff --git a/src/shared/startup-command-staging.test.ts b/src/shared/startup-command-staging.test.ts index 8a7568c17d14..98397d0265fa 100644 --- a/src/shared/startup-command-staging.test.ts +++ b/src/shared/startup-command-staging.test.ts @@ -70,19 +70,48 @@ describe('stageStartupCommand', () => { expect(readFileSync(scriptPath, 'utf8')).toBe(`command rm -f -- '${scriptPath}'\n${command}\n`) }) - it('sources with `source` in fish', () => { + it('evals the script in fish, which runs a sourced file without job control', () => { const staged = stage('a'.repeat(600), '/opt/homebrew/bin/fish') - expect(staged.command).toBe(`source '${staged.scriptPath}'`) + expect(staged.command).toBe(`eval (string collect < '${staged.scriptPath}')`) }) it('recognizes a login shell name', () => { expect(stage('a'.repeat(600), '-zsh').delivery).toBe('staged') }) - it('sources the script in ksh like sh', () => { - const staged = stage('a'.repeat(600), '/bin/ksh') - expect(staged.command).toBe(`. '${staged.scriptPath}'`) - }) + // Why: ksh runs a sourced file's commands in the shell's own process group, so Ctrl-Z is lost. + it.each([['/bin/ksh'], ['/usr/bin/mksh']])( + 'runs a long Orca-built line through /bin/sh in %s, and types a short one as is', + (shellPath) => { + const staged = stageStartupCommand({ + command: `claude '${'x'.repeat(600)}'`, + shellPath, + orcaBuiltLine: true, + platform: 'darwin', + directory + }) + expect(staged.command).toBe(`/bin/sh '${staged.scriptPath}'`) + const command = `claude 'wow!! great'` + expect( + stageStartupCommand({ + command, + shellPath, + orcaBuiltLine: true, + platform: 'darwin', + directory + }) + ).toEqual({ command, delivery: 'typed' }) + } + ) + + it.each([['/bin/ksh'], ['/usr/bin/mksh']])( + 'types a long command the user wrote as it always was in %s', + (shellPath) => { + const command = 'a'.repeat(600) + expect(stage(command, shellPath)).toEqual({ command, delivery: 'typed' }) + expect(readdirSync(directory)).toEqual([]) + } + ) // Why: typed raw, tcsh runs a prompt's second line as a command and MAX_CANON cuts a long one. it.each([['/usr/bin/nu'], ['/bin/tcsh'], ['/usr/local/bin/pwsh'], [undefined]])( diff --git a/src/shared/startup-command-staging.ts b/src/shared/startup-command-staging.ts index 18f78fa84443..ff640f516d94 100644 --- a/src/shared/startup-command-staging.ts +++ b/src/shared/startup-command-staging.ts @@ -11,11 +11,9 @@ import { randomBytes } from 'node:crypto' import { readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { hasControlByte, TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES } from './startup-line-prompt-carry' import { quoteStartupArg } from './tui-agent-startup-shell' -/** Half of macOS MAX_CANON: a typed line this long survived every canonical-mode write measured. */ -export const TYPED_STARTUP_COMMAND_BUDGET_BYTES = 512 - export const STAGED_STARTUP_COMMAND_PREFIX = 'orca-launch-' /** Which delivery ran for a spawn's typed startup line, decided by the host that owns the PTY. */ @@ -34,11 +32,15 @@ export type StartupCommandStaging = { failure?: string } -// Why only these source the script: the staged body is Orca's portable quoting, verified literal in -// these. Any other shell (tcsh, nu, xonsh, pwsh...) runs an agent launch line Orca built through -// `/bin/sh` instead; a command the user wrote for that shell is typed as it always was. +// Why only these type a short line as is: Orca's portable quoting is verified literal in these. Any +// other shell (tcsh, nu, xonsh, pwsh...) runs an agent launch line Orca built through `/bin/sh` +// instead; a command the user wrote for that shell is typed as it always was. const STAGING_SHELLS = new Set(['bash', 'zsh', 'sh', 'dash', 'fish', 'ksh', 'mksh']) +// Why not ksh: it runs a sourced file's commands in the shell's own process group, so Ctrl-Z could +// not stop the agent (mksh untested, kept with it); their long Orca-built lines use `/bin/sh`. +const SOURCING_SHELLS = new Set(['bash', 'zsh', 'sh', 'dash', 'fish']) + const encoder = new TextEncoder() export function stagingShellName(shellPath: string | undefined): string | null { @@ -46,17 +48,6 @@ export function stagingShellName(shellPath: string | undefined): string | null { return name && STAGING_SHELLS.has(name) ? name : null } -/** Any C0 byte or DEL: no quoter escapes them, so a line editor reads them as keys. */ -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 -} - function stripSubmitTerminator(command: string): string { return command.replace(/(\r\n|\r|\n)$/, '') } @@ -71,17 +62,30 @@ export function shouldStageStartupCommand(args: { if (args.platform === 'win32') { return false } - if (stagingShellName(args.shellPath) === null) { + const shellName = stagingShellName(args.shellPath) + if (shellName === null) { // Why every length: Orca's POSIX quoting is literal only in the shells above (tcsh doubles a // quoted backslash and expands `!!`), so another shell only ever sees the script's inert path. return args.orcaBuiltLine === true } const body = stripSubmitTerminator(args.command) return ( - hasControlByte(body) || encoder.encode(body).byteLength > TYPED_STARTUP_COMMAND_BUDGET_BYTES + (hasControlByte(body) || + encoder.encode(body).byteLength > TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES) && + (SOURCING_SHELLS.has(shellName) || args.orcaBuiltLine === true) ) } +function stagedScriptLine(shellName: string | null, quotedPath: string): string { + if (shellName === 'fish') { + // Why eval: fish runs a sourced file without job control; eval runs it as if typed. + return `eval (string collect < ${quotedPath})` + } + return shellName !== null && SOURCING_SHELLS.has(shellName) + ? `. ${quotedPath}` + : `/bin/sh ${quotedPath}` +} + let staleSweepStarted = false export function stageStartupCommand(args: { @@ -123,10 +127,7 @@ export function stageStartupCommand(args: { } } return { - command: - shellName === null - ? `/bin/sh ${quotedPath}` - : `${shellName === 'fish' ? 'source' : '.'} ${quotedPath}`, + command: stagedScriptLine(shellName, quotedPath), delivery: 'staged', scriptPath } From de94a21fcaf02e2fd912ce834a4f2d8ba821113c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:05:33 -0700 Subject: [PATCH 11/42] fix(agent-launch): carry an agent's first prompt on its own command line A launch prompt for an agent whose CLI takes one could still be pasted into its screen once it looked ready: the host pasted any prompt whose typed line was over 512 bytes or held a newline (outside short-lined zsh), and the desktop's generated-prompt launches started the agent empty and pasted. A paste can lose text, land its Enter on a startup dialog (Codex's update prompt), or wait seconds for the agent to look ready. Where a launch prompt rides is now decided in one place, the startup-plan builder (carryLaunchPrompt), for every launch path: - on the agent's command line, which the previous step stages when the line is too long or multi-line to type; - in a private launch file the line points at, when the prompt is past 16,384 characters, sensitive, or one a Windows shell would damage (a control byte; on PowerShell a ", a %NAME% pair or a trailing \; past cmd's 8,191-character cap). The execution host writes it before typing the line, quoted for its shell, and grants Claude a read of its directory. A failed write refuses the spawn with a Copy-prompt notice; - pasted once the agent is ready only for an agent that takes text after start, a prompt needing a launch file its agent is not measured reading (only Claude and Codex are), a reused terminal, and a paired host, which is sent a command and never a file. This replaces the host's typed-line check (512 B, or zsh with every line <=512 B and <=8 KiB), which existed because nothing staged a long line. WSL writes its staged line and launch file into the distro and runs the line in the account's own login shell (sourced in bash, zsh and sh, eval'd in fish, through /bin/sh in ksh), with ~/.cache/orca made 0700 first. The "prompt wasn't sent" notice with Copy prompt lives here, shared by the new agent tab, background draft, paste timeout, work item and new workspace. Rebuilt on the restacked staging step; the earlier version sat on a base that carried #23748. --- .github/workflows/pr.yml | 1 + config/scripts/pr-code-change-scope.mjs | 1 + .../first-work-branch-rename.test.ts | 28 ++ .../agent-hooks/first-work-branch-rename.ts | 12 +- .../agent-hooks/first-work-rename-runtime.ts | 2 + .../agent-hooks/launch-file-prompt-by-pane.ts | 33 +++ .../host-agent-startup-call-sites.txt | 4 +- .../agent-launch-executor.test.ts | 24 +- .../agent-launch/agent-launch-executor.ts | 4 +- .../agent-launch-prompt-delivery.ts | 25 +- .../agent-launch-surface-factories.ts | 13 +- .../host-agent-startup-attribution.test.ts | 4 +- .../daemon/daemon-create-or-attach-request.ts | 55 ++++ src/main/daemon/daemon-pty-session-spawn.ts | 7 + src/main/daemon/daemon-pty-spawn-request.ts | 4 + .../daemon-pty-startup-delivery.test.ts | 33 ++- src/main/daemon/daemon-terminal-admission.ts | 4 + .../daemon/terminal-host-create-contract.ts | 4 + .../daemon/terminal-host-session-create.ts | 68 +++-- ...terminal-host-wsl-launch-directory.test.ts | 67 +++++ src/main/daemon/types.ts | 51 +--- .../pty/ipc/spawn-options-launch-file.test.ts | 82 ++++++ src/main/ipc/pty/ipc/spawn-options.ts | 13 + src/main/ipc/pty/ipc/spawn-types.ts | 2 + src/main/ipc/pty/runtime/spawn-options.ts | 3 + src/main/ipc/pty/runtime/spawn-state.ts | 2 + src/main/ipc/worktree-remote.ts | 4 + src/main/providers/local-pty-launch-plan.ts | 39 ++- ...local-pty-provider-shell-readiness.test.ts | 24 ++ .../providers/local-pty-session-activation.ts | 7 +- ...local-pty-spawn-wsl-shutdown-order.test.ts | 64 +++++ src/main/providers/local-pty-spawn.ts | 49 +++- src/main/providers/pty-provider-contract.ts | 7 + src/main/providers/ssh-pty-spawn-request.ts | 1 + ...artup-line-typed-length.live-shell.test.ts | 152 ----------- .../wsl-launch-directory-resolution.test.ts | 108 ++++++++ .../wsl-launch-directory-resolution.ts | 63 +++++ .../agent-launch-typed-line-shell.test.ts | 30 --- .../runtime/agent-launch-typed-line-shell.ts | 27 -- .../agent-terminal-startup-prompt.test.ts | 93 ++++--- .../claude-agent-teams-shim-env.test.ts | 26 ++ .../runtime/claude-agent-teams-shim-env.ts | 10 +- ...ca-runtime-agent-session-operation.test.ts | 15 ++ .../orca-runtime-agent-teams-launch-plan.ts | 31 +-- .../orca-runtime-create-agent-session.ts | 28 +- ...e-runtime-owned-mobile-session-terminal.ts | 2 + .../orca-runtime-create-terminal-desktop.ts | 7 + .../runtime/orca-runtime-create-terminal.ts | 7 +- ...resolve-mobile-session-terminal-command.ts | 4 +- ...runtime-resolve-worktree-removal-target.ts | 67 +++-- ...time-run-create-mobile-session-terminal.ts | 3 + .../agent-launch-caller-selection.test.ts | 9 +- .../methods/agent-launch-prompt-carry.test.ts | 68 ++--- .../rpc/methods/agent-launch-surfaces.ts | 4 +- .../methods/agent-launch-terminal-prompt.ts | 11 +- .../methods/agent-launch-worktree-creation.ts | 4 +- .../rpc/methods/agent-launch.test-fixture.ts | 7 +- .../runtime/runtime-folder-worktree-create.ts | 1 + ...runtime-local-worktree-terminal-startup.ts | 1 + .../runtime-managed-worktree-create-types.ts | 4 +- .../runtime-pty-controller-contract.ts | 3 + .../runtime-remote-managed-worktree-create.ts | 1 + src/main/runtime/runtime-store-contract.ts | 2 - .../runtime/runtime-terminal-contracts.ts | 13 +- .../runtime-worktree-agent-startup.test.ts | 67 +++-- .../runtime/runtime-worktree-agent-startup.ts | 37 +-- src/preload/api/pty-api.ts | 2 + src/preload/api/pty-bridge-session-control.ts | 2 + ...ty-handler-startup-command-staging.test.ts | 27 +- src/relay/pty-handler.ts | 37 ++- .../folder-workspace-composer-submit.ts | 5 + .../terminal-pane/ipc-pty-connect.ts | 8 + .../terminal-pane/ipc-pty-spawn-request.ts | 3 + .../terminal-pane/pty-connection-types.ts | 2 + .../pty-connection/pty-input-recovery.ts | 6 +- .../terminal-pane/pty-transport-types.ts | 3 + .../composer-submit-orchestration.ts | 1 + .../full-creation-startup.test.ts | 33 +++ .../composer-state/full-creation-startup.ts | 2 + .../composer-state/full-submit-preparation.ts | 8 +- .../quick-creation-execution.ts | 2 + .../composer-state/quick-startup-plan.ts | 3 + .../ipc-events/terminal-request-ipc-bridge.ts | 3 +- src/renderer/src/i18n/locales/en.json | 17 ++ src/renderer/src/i18n/locales/es.json | 17 ++ src/renderer/src/i18n/locales/fr.json | 17 ++ src/renderer/src/i18n/locales/ja.json | 17 ++ src/renderer/src/i18n/locales/ko.json | 17 ++ src/renderer/src/i18n/locales/zh.json | 17 ++ .../lib/agent-background-draft-delivery.ts | 4 +- .../agent-launch-caller-permissions.test.ts | 7 +- ...ent-launch-caller-prompt-transport.test.ts | 30 +-- .../src/lib/agent-launch-prompt-delivery.ts | 29 +- ...launch-prompt-not-delivered-notice.test.ts | 41 +++ ...gent-launch-prompt-not-delivered-notice.ts | 55 ++++ src/renderer/src/lib/agent-paste-draft.ts | 14 +- .../lib/command-code-prompt-status-seed.ts | 56 ---- ...ch-agent-background-session-remote.test.ts | 76 ++++++ .../lib/launch-agent-background-session.ts | 21 +- .../src/lib/launch-agent-in-new-tab.test.ts | 155 +++++------ .../src/lib/launch-agent-in-new-tab.ts | 58 ++-- .../lib/launch-agent-paste-timeout-notice.ts | 82 ++++-- .../lib/launch-agent-startup-prompt-plan.ts | 44 ++-- src/renderer/src/lib/launch-file-host.test.ts | 36 +++ src/renderer/src/lib/launch-file-host.ts | 14 + .../launch-work-item-direct-agent-routing.ts | 4 +- .../src/lib/launch-work-item-direct-agent.ts | 43 ++- ...unch-work-item-direct-route-preparation.ts | 51 ++-- .../src/lib/launch-work-item-direct.test.ts | 60 ++--- .../src/lib/launch-work-item-direct.ts | 35 ++- src/renderer/src/lib/new-workspace.test.ts | 36 ++- src/renderer/src/lib/new-workspace.ts | 35 ++- .../lib/runtime-agent-background-create.ts | 17 +- .../source-control-agent-action-plan.test.ts | 36 ++- .../lib/source-control-agent-action-plan.ts | 10 +- .../src/lib/worktree-creation-flow-startup.ts | 1 + .../src/lib/worktree-startup-payload.ts | 3 + .../src/store/terminals/terminal-actions.ts | 3 + .../src/store/terminals/terminal-state.ts | 2 + src/shared/agent-launch-runtime-capability.ts | 4 +- src/shared/claude-agent-teams-tmux-compat.ts | 22 +- .../cmd-startup-arg-quoting.win32.test.ts | 125 +++++++++ .../launch-file-directory-grant.test.ts | 107 ++++++++ src/shared/launch-file-writing.test.ts | 214 +++++++++++++++ src/shared/launch-file-writing.ts | 228 ++++++++++++++++ src/shared/launch-prompt-carrier.test.ts | 43 +++ src/shared/launch-prompt-file.ts | 114 ++++++++ src/shared/runtime-terminal-contracts.ts | 37 --- src/shared/runtime-terminal-create-request.ts | 40 +++ src/shared/runtime-types.ts | 6 +- .../startup-command-staging.node-pty.test.ts | 30 ++- src/shared/startup-command-staging.ts | 46 ++-- src/shared/startup-line-prompt-carry.test.ts | 248 +++++++++--------- src/shared/startup-line-prompt-carry.ts | 120 ++------- src/shared/startup-plan-launch-file.ts | 126 +++++++++ src/shared/tui-agent-config-types.ts | 7 + src/shared/tui-agent-config.ts | 5 + src/shared/tui-agent-startup-hermes.test.ts | 12 +- src/shared/tui-agent-startup-shell.ts | 5 +- src/shared/tui-agent-startup.test.ts | 65 ++++- src/shared/tui-agent-startup.ts | 68 ++++- src/shared/typed-startup-line.ts | 30 +++ .../windows-shell-prompt-damage.test.ts | 107 ++++++++ src/shared/worktree/launch-types.ts | 3 + src/shared/wsl-launch-directory.test.ts | 151 +++++++++++ src/shared/wsl-launch-directory.ts | 35 +++ src/shared/wsl-login-shell-command.ts | 27 +- 147 files changed, 3768 insertions(+), 1210 deletions(-) create mode 100644 src/main/agent-hooks/launch-file-prompt-by-pane.ts create mode 100644 src/main/daemon/daemon-create-or-attach-request.ts create mode 100644 src/main/daemon/terminal-host-wsl-launch-directory.test.ts create mode 100644 src/main/ipc/pty/ipc/spawn-options-launch-file.test.ts create mode 100644 src/main/providers/local-pty-spawn-wsl-shutdown-order.test.ts delete mode 100644 src/main/providers/startup-line-typed-length.live-shell.test.ts create mode 100644 src/main/providers/wsl-launch-directory-resolution.test.ts create mode 100644 src/main/providers/wsl-launch-directory-resolution.ts delete mode 100644 src/main/runtime/agent-launch-typed-line-shell.test.ts delete mode 100644 src/main/runtime/agent-launch-typed-line-shell.ts create mode 100644 src/renderer/src/hooks/composer-state/full-creation-startup.test.ts create mode 100644 src/renderer/src/lib/agent-launch-prompt-not-delivered-notice.test.ts create mode 100644 src/renderer/src/lib/agent-launch-prompt-not-delivered-notice.ts delete mode 100644 src/renderer/src/lib/command-code-prompt-status-seed.ts create mode 100644 src/renderer/src/lib/launch-file-host.test.ts create mode 100644 src/renderer/src/lib/launch-file-host.ts create mode 100644 src/shared/cmd-startup-arg-quoting.win32.test.ts create mode 100644 src/shared/launch-file-directory-grant.test.ts create mode 100644 src/shared/launch-file-writing.test.ts create mode 100644 src/shared/launch-file-writing.ts create mode 100644 src/shared/launch-prompt-carrier.test.ts create mode 100644 src/shared/launch-prompt-file.ts create mode 100644 src/shared/runtime-terminal-create-request.ts create mode 100644 src/shared/startup-plan-launch-file.ts create mode 100644 src/shared/typed-startup-line.ts create mode 100644 src/shared/windows-shell-prompt-damage.test.ts create mode 100644 src/shared/wsl-launch-directory.test.ts create mode 100644 src/shared/wsl-launch-directory.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 32a167cf0716..3093cdf8ecbe 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1125,6 +1125,7 @@ jobs: src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts src/shared/child-process/windows-command-line.win32.test.ts + src/shared/cmd-startup-arg-quoting.win32.test.ts src/shared/child-process/windows-cmd-shim-resolution.test.ts src/shared/child-process/windows-cmd-shim-resolution.win32.test.ts src/main/agent-hooks/windows-hook-payload-delivery.test.ts diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 7f34a8304a97..0e2d12ceab51 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -322,6 +322,7 @@ const WINDOWS_PACKAGE_TESTS = [ 'src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts', 'src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts', 'src/shared/child-process/windows-command-line.win32.test.ts', + 'src/shared/cmd-startup-arg-quoting.win32.test.ts', 'src/shared/child-process/windows-cmd-shim-resolution.test.ts', 'src/shared/child-process/windows-cmd-shim-resolution.win32.test.ts', 'src/main/agent-hooks/windows-hook-payload-delivery.test.ts', diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts index 0e17feb98747..d1fad66280fb 100644 --- a/src/main/agent-hooks/first-work-branch-rename.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -1,3 +1,4 @@ +import { buildLaunchFilePointer } from '../../shared/launch-prompt-file' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { GlobalSettings } from '../../shared/global-settings-types' import type { Repo } from '../../shared/repo-types' @@ -257,6 +258,33 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { expect(onRenamed).toHaveBeenCalledWith(REPO_ID) }) + it('never names the branch after a launch-file pointer, and still renames on a later prompt', async () => { + const { deps, onRenamed } = makeDeps() + await maybeAutoRenameBranchOnFirstWork( + workingEvent({ prompt: buildLaunchFilePointer('/tmp/orca-launch-file-a1/task-context.md') }), + deps + ) + expect(generateBranchNameMock).not.toHaveBeenCalled() + expect(onRenamed).not.toHaveBeenCalled() + + await maybeAutoRenameBranchOnFirstWork(workingEvent({ prompt: 'Fix auth from note #1' }), deps) + expect(generateBranchNameMock).toHaveBeenCalledTimes(1) + }) + + it('names the branch from the prompt a launch file carried, when main kept it', async () => { + const { deps, onRenamed } = makeDeps({ getLaunchFilePrompt: () => 'Fix auth from note #1' }) + await maybeAutoRenameBranchOnFirstWork( + workingEvent({ prompt: buildLaunchFilePointer('/tmp/orca-launch-file-a1/task-context.md') }), + deps + ) + expect(generateBranchNameMock).toHaveBeenCalledWith( + expect.objectContaining({ firstPrompt: 'Fix auth from note #1' }), + expect.anything(), + expect.anything() + ) + expect(onRenamed).toHaveBeenCalled() + }) + it('asks to align the on-disk folder with the generated slug after renaming', async () => { const { deps, renameWorktreeFolder } = makeDeps() await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps) diff --git a/src/main/agent-hooks/first-work-branch-rename.ts b/src/main/agent-hooks/first-work-branch-rename.ts index 355a0a35dc9a..e5abfe9cc664 100644 --- a/src/main/agent-hooks/first-work-branch-rename.ts +++ b/src/main/agent-hooks/first-work-branch-rename.ts @@ -1,4 +1,5 @@ // On first agent work in a fresh workspace, replace the auto-generated creature branch (e.g. `you/Nautilus`) with a short work-derived name. +import { isLaunchFilePointer } from '../../shared/launch-prompt-file' import type { GlobalSettings } from '../../shared/global-settings-types' import type { Repo } from '../../shared/repo-types' import { isFolderRepo } from '../../shared/repo-kind' @@ -67,6 +68,8 @@ export type FirstWorkBranchRenameDeps = { resolveWorktreeIdForTab: (tabId: string) => string | undefined /** Invalidate caches + notify the renderer so the new branch name surfaces. */ onRenamed: (repoId: string) => void + /** The prompt a launch file carried for this pane, when its hook reports only the pointer. */ + getLaunchFilePrompt?: (paneKey: string) => string | undefined } // inFlight blocks concurrent generation; settled caches definitive verdicts (transient bails stay unsettled to retry later). @@ -114,7 +117,14 @@ export async function maybeAutoRenameBranchOnFirstWork( if (settledWorktreeIds.has(worktreeId) || inFlightWorktreeIds.has(worktreeId)) { return } - const prompt = event.prompt?.trim() + const hookPrompt = event.prompt?.trim() + // Why the pointer: a launch that carried its prompt in a file names only that file here, which + // would name the branch after "read the task file". Its real prompt, when main kept it, names it + // instead; otherwise (a worker brief) a later prompt of the user's own can. + const prompt = + hookPrompt && isLaunchFilePointer(hookPrompt) + ? deps.getLaunchFilePrompt?.(event.paneKey)?.trim() + : hookPrompt if (!prompt) { return } diff --git a/src/main/agent-hooks/first-work-rename-runtime.ts b/src/main/agent-hooks/first-work-rename-runtime.ts index ebc9484071bc..8c35b802bf67 100644 --- a/src/main/agent-hooks/first-work-rename-runtime.ts +++ b/src/main/agent-hooks/first-work-rename-runtime.ts @@ -1,3 +1,4 @@ +import { launchFilePromptForPane } from './launch-file-prompt-by-pane' import { existsSync } from 'node:fs' import { parseWorkspaceKey } from '../../shared/workspace-scope' import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' @@ -21,6 +22,7 @@ export function firstWorkRenameDeps( > ): FirstWorkBranchRenameDeps { return { + getLaunchFilePrompt: launchFilePromptForPane, getSettings: () => store.getSettings(), getRepo: (repoId) => store.getRepo(repoId), getAgentEnvResolvers: () => runtime.getCommitMessageAgentEnvironmentResolvers(), diff --git a/src/main/agent-hooks/launch-file-prompt-by-pane.ts b/src/main/agent-hooks/launch-file-prompt-by-pane.ts new file mode 100644 index 000000000000..5229bc2d29c4 --- /dev/null +++ b/src/main/agent-hooks/launch-file-prompt-by-pane.ts @@ -0,0 +1,33 @@ +import type { LaunchFile } from '../../shared/launch-prompt-file' + +// Why bounded: an entry outlives its launch until newer ones push it out; the rename reads only a +// prompt's opening, and a pane's rename runs within minutes of its launch. +const KEPT_PANES = 50 +const KEPT_CHARS = 4_000 +const promptByPane = new Map() + +/** + * Keeps a launch file's prompt for the pane it launched, for the first-work rename: the agent's hook + * reports only the pointer to the file. A sensitive file (a worker brief with its capability) is + * never kept. + */ +export function rememberLaunchFilePrompt( + paneKey: string, + launchFile: LaunchFile | undefined +): void { + if (!launchFile || launchFile.sensitive) { + return + } + promptByPane.delete(paneKey) + promptByPane.set(paneKey, launchFile.content.slice(0, KEPT_CHARS)) + for (const oldest of promptByPane.keys()) { + if (promptByPane.size <= KEPT_PANES) { + break + } + promptByPane.delete(oldest) + } +} + +export function launchFilePromptForPane(paneKey: string): string | undefined { + return promptByPane.get(paneKey) +} 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 350030683e99..c05556a22055 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 @@ -4,7 +4,7 @@ # mobile-followup the phone's session-tab launch; attribution is a separate follow-up # 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 4 attributes +src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts 2 attributes +src/main/runtime/runtime-worktree-agent-startup.ts 3 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 210289785ace..fa4dd29e4aca 100644 --- a/src/main/agent-launch/agent-launch-executor.test.ts +++ b/src/main/agent-launch/agent-launch-executor.test.ts @@ -27,7 +27,7 @@ function harness(options: { structuredCreateError?: Error deliveredMessageId?: string | null terminalPromptDelivered?: boolean - /** Whether the surface reports that its typed line took the offered prompt. */ + /** Whether the surface reports the offered prompt rode the launch command. */ lineCarriesPrompt?: boolean }) { const calls: string[] = [] @@ -355,7 +355,17 @@ 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 () => { + it('never pastes an argv agent’s prompt, however long: its launch command carries it', async () => { + const h = harness({ createSupport: { supported: false, reason: 'wsl' } }) + const long = { delivery: 'submit' as const, text: `line one\n${'x'.repeat(20_000)}` } + const result = await h.run({ ...CREATE_INTENT, prompt: long }) + + expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'handed-to-terminal' }) + expect(h.createTerminalAgent.mock.calls[0]?.[0]).toMatchObject({ startupPrompt: long.text }) + expect(h.deliverTerminalPrompt).not.toHaveBeenCalled() + }) + + it('pastes after start when the surface reports the prompt needed a file the agent cannot read', async () => { const h = harness({ createSupport: { supported: false, reason: 'wsl' }, lineCarriesPrompt: false @@ -375,7 +385,7 @@ describe('delivering a launch prompt to a terminal agent', () => { }) }) - it('pastes into an agent-first create’s startup terminal when its typed line could not carry the prompt', async () => { + it('pastes into an agent-first create’s startup terminal that did not take the prompt', async () => { const h = harness({ settings: null, lineCarriesPrompt: false }) const result = await h.run({ ...CREATE_INTENT, prompt: SUBMIT }) @@ -388,6 +398,14 @@ describe('delivering a launch prompt to a terminal agent', () => { }) }) + it('never pastes into an agent-first create’s startup terminal that took the prompt', async () => { + const h = harness({ settings: null }) + const result = await h.run({ ...CREATE_INTENT, prompt: SUBMIT }) + + expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'handed-to-terminal' }) + expect(h.deliverTerminalPrompt).not.toHaveBeenCalled() + }) + it('writes into a reused terminal, whose process started before the launch existed', async () => { const h = harness({}) const result = await h.run({ diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index 551ba1695adc..6d71dad5ac8b 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -216,7 +216,7 @@ async function resolveWorkspace( startupTerminalHandle: string | undefined startupTerminalPaneKey?: string warning?: string - /** True when this create folded the prompt into the agent's startup command. */ + /** True when this create put the prompt on the agent's startup command. */ promptRodeLaunchCommand?: boolean }> { const { intent } = execution @@ -253,7 +253,7 @@ export type CreatedSurface = { outcome: AgentLaunchResult['outcome'] warning?: string structured?: AgentLaunchStructuredSurface - /** True when this create folded the prompt into the agent's launch command. */ + /** True when this create put the prompt on the agent's launch command. */ promptRodeLaunchCommand?: boolean } diff --git a/src/main/agent-launch/agent-launch-prompt-delivery.ts b/src/main/agent-launch/agent-launch-prompt-delivery.ts index b36519013761..7a730848394b 100644 --- a/src/main/agent-launch/agent-launch-prompt-delivery.ts +++ b/src/main/agent-launch/agent-launch-prompt-delivery.ts @@ -5,15 +5,16 @@ * 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, 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 + * structured session -> committed to the transcript, named by a message id -> journaled + * terminal, argv CLI -> on 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 * - * 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. + * argv has no readiness race, so it carries the prompt wherever the agent's CLI takes a prompt + * argument (`agentPromptRidesLaunchCommand`). Where it rides is decided once, where the line is built + * (`carryLaunchPrompt`): on the line, which the host stages when it is too long or multi-line to + * type, or in a launch file the line points at. Only a prompt that needs a file an agent is not + * known to read is left for the paste, and the surface reports that back. */ import type { @@ -38,7 +39,7 @@ export async function settleLaunchPromptDisposal( const messageId = await deliverStructuredLaunchPrompt(execution, created.structured) return messageId ? { outcome: 'journaled', messageId } : NOT_DELIVERED } - // The surface reported that the startup command carried the prompt; there is nothing left to write. + // The startup command carried the prompt; there is nothing left to write. if (created.promptRodeLaunchCommand) { return HANDED_TO_TERMINAL } @@ -102,10 +103,8 @@ function launchSubmitText(intent: AgentLaunchIntent): string | undefined { return intent.prompt?.delivery === 'submit' && intent.prompt.text ? intent.prompt.text : undefined } -/** - * 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. - */ +/** The prompt a terminal's launch command carries, which is an argv-mode agent's and only an + * argv-mode agent's. */ export function argvLaunchPrompt(intent: AgentLaunchIntent): string | undefined { const text = launchSubmitText(intent) return text && agentPromptRidesLaunchCommand(intent.agent) ? text : undefined diff --git a/src/main/agent-launch/agent-launch-surface-factories.ts b/src/main/agent-launch/agent-launch-surface-factories.ts index e7d36f05ea1c..3bb6554bea21 100644 --- a/src/main/agent-launch/agent-launch-surface-factories.ts +++ b/src/main/agent-launch/agent-launch-surface-factories.ts @@ -26,7 +26,8 @@ export type AgentLaunchSurfaceFactory = { agent: TuiAgent options?: Readonly> /** 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. */ + * unless it needs a launch file the agent is not known to read; `promptRodeLaunchCommand` + * reports which happened. */ startupPrompt?: string /** Replaces the settings default for this launch only; `null` means no arguments at all. */ agentArgs?: string | null @@ -40,7 +41,7 @@ export type AgentLaunchSurfaceFactory = { /** 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. */ + /** Reported by the surface that built the launch command, never predicted by the executor. */ promptRodeLaunchCommand?: boolean }> /** @@ -59,8 +60,8 @@ 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 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 + * `stdin-after-start` agent, whose CLI takes no prompt argument; a prompt needing a launch file + * the agent is not known to read; 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: { @@ -105,7 +106,7 @@ export type AgentLaunchWorkspaceFactory = { * must await that gate explicitly instead. */ startupAgent: TuiAgent | undefined /** 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. */ + * creation builds the startup command, so that is where the carry is decided. */ startupPrompt?: string /** Inputs needed when this terminal is created as the worktree's startup surface. */ agentArgs?: string | null @@ -121,7 +122,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. */ + /** Reported by the create that built the startup command. */ 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 d29c1cf7c0a9..d9d15c5e61de 100644 --- a/src/main/agent-launch/host-agent-startup-attribution.test.ts +++ b/src/main/agent-launch/host-agent-startup-attribution.test.ts @@ -22,9 +22,9 @@ const LISTED: ReadonlyMap = new Map }) ) -// planStartupWithPromptCandidate wraps buildAgentStartupPlan in shared/, which this scan does not reach. +// planStartupWithLaunchPrompt wraps buildAgentStartupPlan in shared/, which this scan does not reach. const BUILDER_CALL = - /\b(?:buildAgentStartupPlan|buildAgentDraftLaunchPlan|buildAgentResumeStartupPlan|planStartupWithPromptCandidate)\s*\(/g + /\b(?:buildAgentStartupPlan|buildAgentDraftLaunchPlan|buildAgentResumeStartupPlan|planStartupWithLaunchPrompt)\s*\(/g const DECIDE = 'Decide attribution: a fresh agent the host builds must carry ' + diff --git a/src/main/daemon/daemon-create-or-attach-request.ts b/src/main/daemon/daemon-create-or-attach-request.ts new file mode 100644 index 000000000000..653281f9c769 --- /dev/null +++ b/src/main/daemon/daemon-create-or-attach-request.ts @@ -0,0 +1,55 @@ +import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' +import type { TuiAgent } from '../../shared/tui-agent' +import type { LaunchFile } from '../../shared/launch-prompt-file' +import type { WslLaunchDirectory } from '../../shared/wsl-launch-directory' +import type { PtyStartupIngressIntent } from '../../shared/pty-startup-ingress' +import type { + AgentSessionExecutionClaim, + AgentSessionSurfaceBinding +} from '../../shared/agent-session-host-authority' +import type * as HistorySeedProtocol from './terminal-history-seed-transfer-protocol' + +export type CreateOrAttachRequest = { + id: string + type: 'createOrAttach' + payload: HistorySeedProtocol.CreateOrAttachHistorySeedPayload & { + sessionId: string + cols: number + rows: number + cwd?: string + env?: Record + envToDelete?: string[] + command?: string + startupCommandDelivery?: StartupCommandDelivery + /** v39+: written by the daemon before it types `command`. */ + launchFile?: LaunchFile + /** Where the daemon writes a WSL session's staged line and launch file; main resolves it. */ + wslLaunchDirectory?: WslLaunchDirectory + launchAgent?: TuiAgent + /** Rejects an absent session instead of interpreting mount uncertainty as create permission. */ + attachOnly?: boolean + /** Explicit Windows shell override selected by the user (e.g. 'wsl.exe'). + * The daemon forwards this to its subprocess spawner so each tab honors + * the shell picked in the "+" menu or the persisted default-shell setting, + * instead of defaulting to COMSPEC (which is always cmd.exe on Windows) + * or the hard-coded powershell.exe fallback. */ + shellOverride?: string + terminalShellArgs?: string[] + /** Preferred WSL distro for generic `wsl.exe` launches. */ + terminalWindowsWslDistro?: string | null + /** Why: the UI keeps PowerShell as one shell family, but the runtime may + * need to substitute pwsh.exe for powershell.exe when the user selected + * PowerShell 7+. Forward the persisted implementation choice so the daemon + * PTY path resolves the same effective executable as LocalPtyProvider. */ + terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' + shellReadySupported?: boolean + shellReadyTimeoutMs?: number + /** Server-side fence that prevents a client timeout from publishing an orphan PTY. */ + cancelAfterMs?: number + startupIngress?: PtyStartupIngressIntent + agentSessionEnsure?: { + claim: AgentSessionExecutionClaim + surface: AgentSessionSurfaceBinding + } + } +} diff --git a/src/main/daemon/daemon-pty-session-spawn.ts b/src/main/daemon/daemon-pty-session-spawn.ts index e7d04f5851e2..bade2bcc3e20 100644 --- a/src/main/daemon/daemon-pty-session-spawn.ts +++ b/src/main/daemon/daemon-pty-session-spawn.ts @@ -18,6 +18,7 @@ import { getRecoveredHistorySeedSegments } from './terminal-history-seed-segment import { AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, type CreateOrAttachResult } from './types' import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd' import { resolveWslSessionContext } from './wsl-session-context' +import { resolveWslLaunchDirectory } from '../providers/wsl-launch-directory-resolution' import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd' import { resolveUnixShellPath } from '../providers/local-pty-utils' import type { PtySpawnOptions, PtySpawnResult } from '../providers/types' @@ -193,6 +194,12 @@ export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult { } await this.ensureConnected() + const wslLaunchDirectory = + !attachOnly && opts.command ? await resolveWslLaunchDirectory(wslDistro) : undefined + // Pins the daemon's spawn to the distro the daemon writes the line and file into. + if (wslLaunchDirectory) { + opts = { ...opts, wslLaunchDirectory, terminalWindowsWslDistro: wslLaunchDirectory.distro } + } // Why before createOrAttach: a preserved daemon may still think this session is backgrounded — from // a v19 that thins without a recoverable seq, or (#9993) from a pre-v29 that a previous desktop // handed 2031 scan authority to and can never retract it. Clear it before any bytes are attached. diff --git a/src/main/daemon/daemon-pty-spawn-request.ts b/src/main/daemon/daemon-pty-spawn-request.ts index 1da17c63da95..22222c8b206b 100644 --- a/src/main/daemon/daemon-pty-spawn-request.ts +++ b/src/main/daemon/daemon-pty-spawn-request.ts @@ -115,6 +115,10 @@ export abstract class DaemonPtySpawnRequest extends DaemonPtyRuntimeState { envToDelete: context.attachOnly ? undefined : opts.envToDelete, command: context.attachOnly ? undefined : opts.command, startupCommandDelivery: context.attachOnly ? undefined : opts.startupCommandDelivery, + ...(!context.attachOnly && opts.launchFile ? { launchFile: opts.launchFile } : {}), + ...(!context.attachOnly && opts.wslLaunchDirectory + ? { wslLaunchDirectory: opts.wslLaunchDirectory } + : {}), launchAgent: context.attachOnly ? undefined : opts.launchAgent, ...(context.attachOnly && !context.emulateLegacyAttachOnly ? { attachOnly: true } : {}), shellOverride: context.attachOnly ? undefined : opts.shellOverride, diff --git a/src/main/daemon/daemon-pty-startup-delivery.test.ts b/src/main/daemon/daemon-pty-startup-delivery.test.ts index 0039bccbce20..b5ae04c5d1e4 100644 --- a/src/main/daemon/daemon-pty-startup-delivery.test.ts +++ b/src/main/daemon/daemon-pty-startup-delivery.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, readdirSync, rmSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync } from 'node:fs' import { basename, join } from 'node:path' import * as localPtyUtils from '../providers/local-pty-utils' import { @@ -9,6 +9,7 @@ import { type DaemonAdapterHarness, type SpawnSubprocess } from './daemon-pty-adapter-test-harness' +import { buildLaunchFilePointer, planLaunchPrompt } from '../../shared/launch-prompt-file' const itOnPosix = process.platform === 'win32' ? it.skip : it @@ -133,4 +134,34 @@ describe('DaemonPtyAdapter startup delivery', () => { vi.unstubAllEnvs() } }) + + itOnPosix('writes a launch file before typing the line that names it', async () => { + const stagingDir = join(dir, 'tmp') + mkdirSync(stagingDir) + vi.stubEnv('TMPDIR', stagingDir) + nextShellPath = '/bin/zsh' + try { + const { prompt, launchFile } = planLaunchPrompt('secret brief', { sensitive: true }) + await adapter.spawn({ + cols: 80, + rows: 24, + command: `claude '${prompt}'`, + launchFile, + env: { SHELL: '/bin/zsh' } + }) + const launchDir = readdirSync(stagingDir).find((name) => name.startsWith('orca-launch-file-')) + const path = join(realpathSync(stagingDir), launchDir ?? '', 'task-context.md') + expect(readFileSync(path, 'utf8')).toBe('secret brief') + expect(lastSpawnOpts?.command).toBe(`claude '${buildLaunchFilePointer(path)}'`) + lastSubprocess._simulateData('\x1b]777;orca-shell-ready\x07\r\nuser@host $ ') + await waitFor(() => vi.mocked(lastSubprocess.write).mock.calls.length > 0) + expect(lastSubprocess.write).toHaveBeenCalledExactlyOnceWith( + `claude '${buildLaunchFilePointer(path)}'\n` + ) + lastSubprocess._simulateExit(0) + await waitFor(() => !existsSync(path)) + } finally { + vi.unstubAllEnvs() + } + }) }) diff --git a/src/main/daemon/daemon-terminal-admission.ts b/src/main/daemon/daemon-terminal-admission.ts index 984cbc87dec4..d7f3fe6a9ccc 100644 --- a/src/main/daemon/daemon-terminal-admission.ts +++ b/src/main/daemon/daemon-terminal-admission.ts @@ -15,6 +15,8 @@ import type { TerminalHistorySeedTransferRegistry } from './terminal-history-see import type { CreateOrAttachOptions, CreateOrAttachResult, TerminalHost } from './terminal-host' import { isTuiAgent } from '../../shared/tui-agent-config' import { parsePtyStartupIngressIntent } from '../../shared/pty-startup-ingress' +import { parseLaunchFile } from '../../shared/launch-prompt-file' +import { parseWslLaunchDirectory } from '../../shared/wsl-launch-directory' import { isAgentSessionExecutionClaim, isAgentSessionSurfaceBinding @@ -102,6 +104,8 @@ export class DaemonTerminalAdmission { envToDelete: payload.envToDelete, command: payload.command, startupCommandDelivery: payload.startupCommandDelivery, + launchFile: parseLaunchFile(payload.launchFile), + wslLaunchDirectory: parseWslLaunchDirectory(payload.wslLaunchDirectory), ...(attachOnly ? { attachOnly: true } : {}), ...(isTuiAgent(payload.launchAgent) ? { launchAgent: payload.launchAgent } : {}), shellOverride: payload.shellOverride, diff --git a/src/main/daemon/terminal-host-create-contract.ts b/src/main/daemon/terminal-host-create-contract.ts index 654df23af816..59cb423df14c 100644 --- a/src/main/daemon/terminal-host-create-contract.ts +++ b/src/main/daemon/terminal-host-create-contract.ts @@ -9,6 +9,8 @@ import type { } from '../../shared/agent-session-host-authority' import type { PtyIncarnationId } from '../../shared/pty-incarnation' import type { TerminalExitCause } from '../../shared/terminal-exit-cause' +import type { LaunchFile } from '../../shared/launch-prompt-file' +import type { WslLaunchDirectory } from '../../shared/wsl-launch-directory' export type CreateOrAttachOptions = { sessionId: string @@ -19,6 +21,8 @@ export type CreateOrAttachOptions = { envToDelete?: string[] command?: string startupCommandDelivery?: StartupCommandDelivery + launchFile?: LaunchFile + wslLaunchDirectory?: WslLaunchDirectory launchAgent?: TuiAgent /** Missing ownership is not permission to create during stable-pane adoption. */ attachOnly?: boolean diff --git a/src/main/daemon/terminal-host-session-create.ts b/src/main/daemon/terminal-host-session-create.ts index cba1be962a7f..bcf0abddb668 100644 --- a/src/main/daemon/terminal-host-session-create.ts +++ b/src/main/daemon/terminal-host-session-create.ts @@ -6,6 +6,7 @@ import { type StartupCommandStaging } from '../../shared/startup-command-staging' import { resolvePtyOwnerBackend } from '../../shared/pty-owner-backend' +import { removeLaunchFile, writeSpawnLaunchFile } from '../../shared/launch-file-writing' import { getDaemonSessionResultMetadata } from './daemon-create-or-attach-result' import { enumerateDirectoryOnce } from './directory-enumeration-probe' import { normalizePtySize } from './daemon-pty-size' @@ -119,23 +120,40 @@ async function spawnAndPublishSession( // Why before the fork: the shell's own cwd may already have fallen back, so probe the requested path. const cwdReadableByDaemon = opts.cwd && !wslDistro ? await isCwdReadableByThisProcess(opts.cwd) : null - const subprocess = await deps.spawnSubprocess({ - sessionId: opts.sessionId, - cols: size.cols, - rows: size.rows, - cwd: opts.cwd, - env: opts.env, - envToDelete: opts.envToDelete, + const wslDirectory = + wslDistro && opts.wslLaunchDirectory?.distro === wslDistro ? opts.wslLaunchDirectory : undefined + const launchFile = writeSpawnLaunchFile({ + launchFile: opts.launchFile, command: opts.command, - startupCommandDelivery: opts.startupCommandDelivery, - ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), - shellOverride: opts.shellOverride, - terminalShellArgs: opts.terminalShellArgs, - terminalWindowsWslDistro: opts.terminalWindowsWslDistro, - terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation, - isCanceled: opts.isCanceled, - ...(opts.cancelSignal ? { cancelSignal: opts.cancelSignal } : {}) + env: opts.env, + orcaBuiltLine: opts.launchAgent !== undefined, + wslDistro, + wslDirectory }) + const command = launchFile?.command ?? opts.command + let subprocess: Awaited> + try { + subprocess = await deps.spawnSubprocess({ + sessionId: opts.sessionId, + cols: size.cols, + rows: size.rows, + cwd: opts.cwd, + env: launchFile?.env ?? opts.env, + envToDelete: opts.envToDelete, + command, + startupCommandDelivery: opts.startupCommandDelivery, + ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), + shellOverride: opts.shellOverride, + terminalShellArgs: opts.terminalShellArgs, + terminalWindowsWslDistro: opts.terminalWindowsWslDistro, + terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation, + isCanceled: opts.isCanceled, + ...(opts.cancelSignal ? { cancelSignal: opts.cancelSignal } : {}) + }) + } catch (error) { + removeLaunchFile(launchFile) + throw error + } let staging: StartupCommandStaging | undefined // Why: a fallback shell does not emit the preferred shell's ready marker; @@ -164,7 +182,10 @@ async function spawnAndPublishSession( deps.onSessionExit, opts.sessionId, opts.agentSessionGeneration, - () => discardStagedStartupCommand(staging) + () => { + discardStagedStartupCommand(staging) + removeLaunchFile(launchFile) + } ), ...(deps.reportReadinessEvent ? { reportReadinessEvent: deps.reportReadinessEvent } : {}), ...(opts.shellReadyTimeoutMs !== undefined @@ -176,6 +197,7 @@ async function spawnAndPublishSession( // Retain cleanup ownership if the native child refuses to exit. deps.sessions.set(opts.sessionId, session) await session.forceKillAndDisposeSubprocess() + removeLaunchFile(launchFile) if (deps.sessions.get(opts.sessionId) === session) { session.dispose() deps.sessions.delete(opts.sessionId) @@ -188,27 +210,27 @@ async function spawnAndPublishSession( deps.onSessionCreated(opts.sessionId, opts.agentSessionGeneration, session.isAlive) const token = session.attachClient(opts.streamClient) - const startupCommandWritten = - Boolean(opts.command) && !subprocess.startupCommandDeliveredInShellArgs + const startupCommandWritten = Boolean(command) && !subprocess.startupCommandDeliveredInShellArgs // Why: without this, a missing command and a lost one log identically. // Length, never the text -- launches can carry credentials. try { deps.reportReadinessEvent?.('startup-command-delivery', { sessionId: opts.sessionId, written: startupCommandWritten, - hasCommand: Boolean(opts.command), - commandLength: opts.command?.length ?? 0, + hasCommand: Boolean(command), + commandLength: command?.length ?? 0, viaShellArgs: subprocess.startupCommandDeliveredInShellArgs === true, queuedByShellReadyBarrier: shellReadySupported }) } catch { // Diagnostics must never turn a live PTY into a failed create. } - if (startupCommandWritten && opts.command) { + if (startupCommandWritten && command) { staging = stageStartupCommand({ - command: opts.command, + command, shellPath: subprocess.shellPath, - orcaBuiltLine: opts.launchAgent !== undefined + orcaBuiltLine: opts.launchAgent !== undefined, + wslDirectory }) const notice = startupStagingFailureNotice(staging) if (notice) { diff --git a/src/main/daemon/terminal-host-wsl-launch-directory.test.ts b/src/main/daemon/terminal-host-wsl-launch-directory.test.ts new file mode 100644 index 000000000000..2b395205216d --- /dev/null +++ b/src/main/daemon/terminal-host-wsl-launch-directory.test.ts @@ -0,0 +1,67 @@ +import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { createMockSubprocess } from './daemon-pty-adapter-test-harness' +import { TerminalHost } from './terminal-host' +import type { TerminalHostOptions } from './terminal-host-options' +import { buildLaunchFilePointer, planLaunchPrompt } from '../../shared/launch-prompt-file' +import type { WslLaunchDirectory } from '../../shared/wsl-launch-directory' + +vi.mock('../pty-descendant-termination', () => ({ killWithDescendantSweep: vi.fn() })) + +describe('a daemon WSL session with a launch file', () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform')! + let host: TerminalHost + let spawn: Mock + let windowsSide: string + + beforeEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + // Stands in for the distro's `\\wsl.localhost` cache directory. + windowsSide = mkdtempSync(join(tmpdir(), 'orca-daemon-wsl-test-')) + spawn = vi.fn(() => + Object.assign(createMockSubprocess(), { shellPath: 'C:\\Windows\\System32\\wsl.exe' }) + ) + host = new TerminalHost({ spawnSubprocess: spawn }) + }) + + afterEach(async () => { + Object.defineProperty(process, 'platform', platform) + await host.dispose() + rmSync(windowsSide, { recursive: true, force: true }) + }) + + function create(wslLaunchDirectory: WslLaunchDirectory | undefined) { + const { prompt, launchFile } = planLaunchPrompt('secret brief', { sensitive: true }) + return host.createOrAttach({ + sessionId: 'wsl-session', + cols: 80, + rows: 24, + command: `claude '${prompt}'`, + launchFile, + launchAgent: 'claude', + shellOverride: 'wsl.exe', + terminalWindowsWslDistro: 'Ubuntu', + ...(wslLaunchDirectory ? { wslLaunchDirectory } : {}), + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + } + + it('writes the file into the distro and types its Linux path', async () => { + await create({ distro: 'Ubuntu', windowsPath: windowsSide, linuxPath: '/home/ada/.cache/orca' }) + const command = spawn.mock.calls[0]?.[0].command ?? '' + const linuxPath = /\/home\/ada\/\.cache\/orca\/orca-launch-file-\w+\/task-context\.md/.exec( + command + )?.[0] + expect(command).toBe(`claude '${buildLaunchFilePointer(linuxPath ?? 'missing')}'`) + const written = join(windowsSide, linuxPath!.split('/').at(-2)!, 'task-context.md') + expect(readFileSync(written, 'utf8')).toBe('secret brief') + }) + + it('refuses the spawn when main could not reach the distro home', async () => { + await expect(create(undefined)).rejects.toThrow(/could not be reached/) + expect(spawn).not.toHaveBeenCalled() + expect(readdirSync(windowsSide)).toEqual([]) + }) +}) diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index adf1e8a17811..07b23ec9ea3f 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -13,14 +13,7 @@ export type { } from './daemon-foreground-process-protocol' // ─── Protocol Version ──────────────────────────────────────────────── -import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' -import type { TuiAgent } from '../../shared/tui-agent' -import type { PtyStartupIngressIntent } from '../../shared/pty-startup-ingress' -import type { - AgentSessionExecutionClaim, - AgentSessionOwnerBinding, - AgentSessionSurfaceBinding -} from '../../shared/agent-session-host-authority' +import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' import type * as HistorySeedProtocol from './terminal-history-seed-transfer-protocol' import type * as PtyOwnerQueryProtocol from './daemon-pty-owner-query-protocol' export type { TerminalModes } from './terminal-modes' @@ -59,46 +52,8 @@ export type { DaemonEndpointIdentity, HelloMessage, HelloResponse } from './daem // ─── RPC Requests (Client → Daemon, on control socket) ───────────── -export type CreateOrAttachRequest = { - id: string - type: 'createOrAttach' - payload: HistorySeedProtocol.CreateOrAttachHistorySeedPayload & { - sessionId: string - cols: number - rows: number - cwd?: string - env?: Record - envToDelete?: string[] - command?: string - startupCommandDelivery?: StartupCommandDelivery - launchAgent?: TuiAgent - /** Rejects an absent session instead of interpreting mount uncertainty as create permission. */ - attachOnly?: boolean - /** Explicit Windows shell override selected by the user (e.g. 'wsl.exe'). - * The daemon forwards this to its subprocess spawner so each tab honors - * the shell picked in the "+" menu or the persisted default-shell setting, - * instead of defaulting to COMSPEC (which is always cmd.exe on Windows) - * or the hard-coded powershell.exe fallback. */ - shellOverride?: string - terminalShellArgs?: string[] - /** Preferred WSL distro for generic `wsl.exe` launches. */ - terminalWindowsWslDistro?: string | null - /** Why: the UI keeps PowerShell as one shell family, but the runtime may - * need to substitute pwsh.exe for powershell.exe when the user selected - * PowerShell 7+. Forward the persisted implementation choice so the daemon - * PTY path resolves the same effective executable as LocalPtyProvider. */ - terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' - shellReadySupported?: boolean - shellReadyTimeoutMs?: number - /** Server-side fence that prevents a client timeout from publishing an orphan PTY. */ - cancelAfterMs?: number - startupIngress?: PtyStartupIngressIntent - agentSessionEnsure?: { - claim: AgentSessionExecutionClaim - surface: AgentSessionSurfaceBinding - } - } -} +export type { CreateOrAttachRequest } from './daemon-create-or-attach-request' +import type { CreateOrAttachRequest } from './daemon-create-or-attach-request' export type CancelCreateOrAttachRequest = { id: string diff --git a/src/main/ipc/pty/ipc/spawn-options-launch-file.test.ts b/src/main/ipc/pty/ipc/spawn-options-launch-file.test.ts new file mode 100644 index 000000000000..77e41293405e --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-options-launch-file.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../../shared/constants' +import { buildPtyIpcSpawnOptions } from './spawn-options' +import { launchFilePromptForPane } from '../../../agent-hooks/launch-file-prompt-by-pane' +import { createPtyIpcSpawnState } from './spawn-state' +import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' + +const LAUNCH_FILE = { + placeholder: `orca-launch-file-${'a'.repeat(32)}`, + content: 'the whole task', + sensitive: false +} + +async function spawnOptionsFor( + args: PtySpawnIpcArgs, + typedCommand: string | undefined, + paneKey?: string +) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: buildPtyIpcSpawnOptions only reads the members stubbed here; the rest belong to later spawn stages this test never runs. + const deps = { + transitionSpawnHiddenRendererPtyDeliveryState: vi.fn(), + syncPtyBackgroundedDelivery: vi.fn(), + sendPtySpawnedToRenderer: vi.fn(), + getSettings: () => getDefaultSettings('/tmp'), + runtime: { registerPreAllocatedHandleForPty: vi.fn() } + } as unknown as PtySpawnIpcDeps + const ctx = createPtyIpcSpawnState(deps, args) + ctx.env = {} + ctx.launchCommand = typedCommand + ctx.reservationPaneKey = paneKey ?? null + await buildPtyIpcSpawnOptions(ctx) + return ctx.spawnOptions +} + +describe('renderer pty spawn: launch file', () => { + const command = `claude 'The full task is in the file "${LAUNCH_FILE.placeholder}".'` + + it('hands the provider the launch file its command names', async () => { + const options = await spawnOptionsFor( + { cols: 80, rows: 24, command, launchFile: LAUNCH_FILE }, + command + ) + expect(options.launchFile).toEqual(LAUNCH_FILE) + }) + + it('drops a launch file whose placeholder Orca did not mint', async () => { + const options = await spawnOptionsFor( + { cols: 80, rows: 24, command, launchFile: { ...LAUNCH_FILE, placeholder: '../../etc' } }, + command + ) + expect(options.launchFile).toBeUndefined() + }) + + it('drops the launch file when main types a different line, such as a resume', async () => { + const options = await spawnOptionsFor( + { cols: 80, rows: 24, command, launchFile: LAUNCH_FILE }, + 'codex resume abc' + ) + expect(options.launchFile).toBeUndefined() + }) + + // Why: the agent's hook reports only the pointer, so the first-work rename reads this instead. + it('keeps the prompt a launch file carries for its pane, never a sensitive one', async () => { + await spawnOptionsFor( + { cols: 80, rows: 24, command, launchFile: LAUNCH_FILE }, + command, + 'tab-a:leaf-a' + ) + await spawnOptionsFor( + { + cols: 80, + rows: 24, + command, + launchFile: { ...LAUNCH_FILE, content: 'dcap brief', sensitive: true } + }, + command, + 'tab-b:leaf-b' + ) + expect(launchFilePromptForPane('tab-a:leaf-a')).toBe('the whole task') + expect(launchFilePromptForPane('tab-b:leaf-b')).toBeUndefined() + }) +}) diff --git a/src/main/ipc/pty/ipc/spawn-options.ts b/src/main/ipc/pty/ipc/spawn-options.ts index d0a3ba8cef16..328ae8f58297 100644 --- a/src/main/ipc/pty/ipc/spawn-options.ts +++ b/src/main/ipc/pty/ipc/spawn-options.ts @@ -1,3 +1,4 @@ +import { rememberLaunchFilePrompt } from '../../../agent-hooks/launch-file-prompt-by-pane' import { getAppEnvironment } from '../../../../shared/app-environment' import { getLegacyOpenCodeEnvKeysToDelete } from '../../../opencode/legacy-shared-config-dir' import { isTuiAgent } from '../../../../shared/tui-agent-config' @@ -26,6 +27,7 @@ import { resolveConfiguredTerminalShellArgs } from '../configured-terminal-shell import { withCodexTerminalServerIsolationEnv } from '../../../../shared/codex-terminal-server-isolation' import { planCodexNoDaemonLaunch } from '../../../pty/codex-no-daemon-launch-command' import type { PtyIpcSpawnState } from './spawn-state' +import { parseLaunchFile } from '../../../../shared/launch-prompt-file' import { applyAgentWorkspaceTrustToSpawn } from '../../../agent-workspace-trust-spawn' /** Carries deletions to provider-owned environments, including persistent older daemons. */ @@ -100,6 +102,17 @@ export async function buildPtyIpcSpawnOptions( if (args.startupCommandDelivery !== undefined) { ctx.spawnOptions.startupCommandDelivery = args.startupCommandDelivery } + // Only the renderer's own command names the file; a resume or adopted pane types another line. + const launchFile = + ctx.launchCommand !== undefined && ctx.launchCommand === args.command + ? parseLaunchFile(args.launchFile) + : undefined + if (launchFile) { + ctx.spawnOptions.launchFile = launchFile + if (ctx.reservationPaneKey) { + rememberLaunchFilePrompt(ctx.reservationPaneKey, launchFile) + } + } if (isTuiAgent(args.launchAgent)) { ctx.spawnOptions.launchAgent = args.launchAgent } diff --git a/src/main/ipc/pty/ipc/spawn-types.ts b/src/main/ipc/pty/ipc/spawn-types.ts index d1f340be17d6..2f3b3888116a 100644 --- a/src/main/ipc/pty/ipc/spawn-types.ts +++ b/src/main/ipc/pty/ipc/spawn-types.ts @@ -36,6 +36,8 @@ export type PtySpawnIpcArgs = { launchToken?: unknown launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery + /** Unvalidated here; spawn options keep it only when it parses. */ + launchFile?: unknown connectionId?: string | null worktreeId?: string sessionId?: string diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index 5f64779b364d..14dbded8201a 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -121,6 +121,9 @@ export async function buildRuntimePtySpawnOptions( if (args.startupCommandDelivery !== undefined) { ctx.spawnOptions.startupCommandDelivery = args.startupCommandDelivery } + if (args.launchFile) { + ctx.spawnOptions.launchFile = args.launchFile + } if (isTuiAgent(args.launchAgent)) { ctx.spawnOptions.launchAgent = args.launchAgent } diff --git a/src/main/ipc/pty/runtime/spawn-state.ts b/src/main/ipc/pty/runtime/spawn-state.ts index 581d335ecdbc..a743e5b9d3ff 100644 --- a/src/main/ipc/pty/runtime/spawn-state.ts +++ b/src/main/ipc/pty/runtime/spawn-state.ts @@ -14,6 +14,7 @@ import type { AgentSessionExecutionClaim, AgentSessionSurfaceBinding } from '../../../../shared/agent-session-host-authority' +import type { LaunchFile } from '../../../../shared/launch-prompt-file' import { localProvider } from '../provider/registry' export type RuntimePtySpawnState = { @@ -89,6 +90,7 @@ export type RuntimePtySpawnArgs = { launchAgent?: TuiAgent commandDelivery?: 'renderer' | 'provider' startupCommandDelivery?: StartupCommandDelivery + launchFile?: LaunchFile telemetry?: { agent_kind?: unknown launch_source?: unknown diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 256cb87fe9fc..149da6a011a7 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -170,6 +170,7 @@ import { createRetiredNameLookup } from '../../shared/worktree/retired-name-regi import { toLocalBaseRefRefreshResult } from '../../shared/worktree/local-base-branch-fast-forward' import { isSshRequestOutcomeUnverifiable } from '../ssh/ssh-channel-multiplexer' import { findPendingWorktreeRemovalConflict } from '../worktree-background-removal' +import { parseLaunchFile } from '../../shared/launch-prompt-file' const SSH_WORKTREE_CREATE_FETCH_FRESHNESS_MS = 30_000 const SSH_WORKTREE_CREATE_FETCH_CACHE_MAX = 512 @@ -410,6 +411,8 @@ async function spawnLocalStartupAndSetupTerminals(args: { try { // Why: only after `git worktree add` + metadata registration is the path safe for a runtime PTY to boot the agent while setup runs alongside. + // Renderer-built: keep only a launch file whose placeholder Orca minted. + const launchFile = parseLaunchFile(sequencedStartup.launchFile) const terminal = await runtime.createTerminal(`id:${worktree.id}`, { command: sequencedStartup.command, ...(setup ? { claudeAgentTeamsSourceCommand: startup.command } : {}), @@ -418,6 +421,7 @@ async function spawnLocalStartupAndSetupTerminals(args: { ...(isTuiAgent(createdWithAgent) ? { launchAgent: createdWithAgent } : {}), ...(sequencedStartup.viewMode ? { viewMode: sequencedStartup.viewMode } : {}), startupCommandDelivery: sequencedStartup.startupCommandDelivery, + ...(launchFile ? { launchFile } : {}), telemetry: sequencedStartup.telemetry, // Why: the submitting renderer decides whether to open the workspace; activating here yanked users who moved on (#9944). surfaceOwner: false diff --git a/src/main/providers/local-pty-launch-plan.ts b/src/main/providers/local-pty-launch-plan.ts index 455cee3c4653..f70a01bd4c9f 100644 --- a/src/main/providers/local-pty-launch-plan.ts +++ b/src/main/providers/local-pty-launch-plan.ts @@ -109,13 +109,7 @@ function createWindowsLocalPtyLaunchPlan( getOptions: () => LocalPtyProviderOptions ): LocalPtyLaunchPlan | DeferredLocalPtyLaunchPlan { const { args, cwd, defaultCwd, worktreeWslContext } = seed - // Why: shellOverride opens one tab in a non-default shell without changing the user's setting; it wins over the setting. - const requestedShellFamily = - args.shellOverride || - getOptions().getWindowsShell?.() || - process.env.COMSPEC || - 'powershell.exe' - const shellFamily = worktreeWslContext ? 'wsl.exe' : requestedShellFamily + const shellFamily = worktreeWslContext ? 'wsl.exe' : requestedWindowsShellFamily(args, getOptions) if (!seed.launchWslContext && pathWin32.basename(shellFamily).toLowerCase() === 'wsl.exe') { seed.launchWslContext = getWslContextFromPreferredDistro(getDefaultWslDistro()) } @@ -190,6 +184,37 @@ function createWindowsLocalPtyLaunchPlan( : finish(false) } +function requestedWindowsShellFamily( + args: PtySpawnOptions, + getOptions: () => LocalPtyProviderOptions +): string { + // Why: shellOverride opens one tab in a non-default shell without changing the user's setting; it wins over the setting. + return ( + args.shellOverride || + getOptions().getWindowsShell?.() || + process.env.COMSPEC || + 'powershell.exe' + ) +} + +/** The distro the plan below launches this spawn's shell in, or undefined when it is not WSL. */ +export function resolveLocalPtyWslDistro( + args: PtySpawnOptions, + getOptions: () => LocalPtyProviderOptions +): string | undefined { + if (process.platform !== 'win32') { + return undefined + } + const context = + getWslContextFromPreferredDistro(parseWslPath(args.cwd || getDefaultCwd())?.distro) ?? + getWslContextFromWorktreeId(args.worktreeId) ?? + (pathWin32.basename(requestedWindowsShellFamily(args, getOptions)).toLowerCase() === 'wsl.exe' + ? (getWslContextFromPreferredDistro(args.terminalWindowsWslDistro) ?? + getWslContextFromPreferredDistro(getDefaultWslDistro())) + : undefined) + return context?.distro +} + export function createLocalPtyLaunchPlan( args: PtySpawnOptions, getOptions: () => LocalPtyProviderOptions diff --git a/src/main/providers/local-pty-provider-shell-readiness.test.ts b/src/main/providers/local-pty-provider-shell-readiness.test.ts index 168bc309a023..6b1c0b64614f 100644 --- a/src/main/providers/local-pty-provider-shell-readiness.test.ts +++ b/src/main/providers/local-pty-provider-shell-readiness.test.ts @@ -37,6 +37,8 @@ vi.mock('fs', () => ({ accessSync: accessSyncMock, mkdirSync: mkdirSyncMock, writeFileSync: writeFileSyncMock, + mkdtempSync: vi.fn((prefix: string) => `${prefix}abc123`), + realpathSync: vi.fn((path: string) => path), chmodSync: vi.fn(), renameSync: vi.fn(), rmSync: vi.fn(), @@ -116,6 +118,7 @@ vi.mock('../shell-prompt-readiness-probe', () => ({ import { LocalPtyProvider } from './local-pty-provider' import { POSIX_SHELL_STARTUP_COMMAND_ENV } from '../pty/posix-shell-startup-command' +import { buildLaunchFilePointer, planLaunchPrompt } from '../../shared/launch-prompt-file' import { applyLocalPtyProviderMockDefaults, createLocalPtyMockProcess, @@ -266,6 +269,27 @@ describe('LocalPtyProvider', () => { } }) + it('writes a launch file before the line that names it is typed', async () => { + vi.useFakeTimers() + try { + process.env.SHELL = '/bin/sh' + const { prompt, launchFile } = planLaunchPrompt('secret brief', { sensitive: true }) + + await provider.spawn({ cols: 80, rows: 24, command: `claude '${prompt}'`, launchFile }) + + const written = writeFileSyncMock.mock.calls.find(([path]) => + String(path).endsWith('task-context.md') + ) + expect(written?.[1]).toBe('secret brief') + await vi.advanceTimersByTimeAsync(200) + expect(mockProc.write).toHaveBeenCalledWith( + `claude '${buildLaunchFilePointer(String(written?.[0]))}'\n` + ) + } finally { + vi.useRealTimers() + } + }) + it('verifies shell identity against the exact spawn PATH', async () => { provider.configure({ buildSpawnEnv: (_id, env) => ({ ...env, PATH: '/post-hook/bin' }) diff --git a/src/main/providers/local-pty-session-activation.ts b/src/main/providers/local-pty-session-activation.ts index 8b8abd54fd07..9507711bd374 100644 --- a/src/main/providers/local-pty-session-activation.ts +++ b/src/main/providers/local-pty-session-activation.ts @@ -6,6 +6,7 @@ import { startupStagingFailureNotice, type StartupCommandStaging } from '../../shared/startup-command-staging' +import { removeLaunchFile, type WrittenLaunchFile } from '../../shared/launch-file-writing' import { PtyStartupIngress, type PtyIngressEmission } from '../../shared/pty-startup-ingress' import { resolvePtyOwnerBackend } from '../../shared/pty-owner-backend' import { resolveProcessExitCause } from '../../shared/terminal-exit-cause' @@ -51,6 +52,7 @@ export function activateLocalPtySession(args: { proc: pty.IPty reportsChildExitStatus: boolean spawnedWslDistro: string | null | undefined + launchFile?: WrittenLaunchFile }): PtySpawnResult { const { id, incarnationId, spawn, getOptions, plan, env, proc, spawnedWslDistro } = args createPtyPhysicalExit(id) @@ -135,6 +137,7 @@ export function activateLocalPtySession(args: { const onExitDisposable = proc.onExit(({ exitCode, signal }) => { exitedBeforeSpawnReply = true discardStagedStartupCommand(staging) + removeLaunchFile(args.launchFile) // Why: node-pty reports a signalled death as {exitCode: 0, signal: N}; the // cause is built here, where the signal and the spawn's trustworthiness // are both still in hand. @@ -189,7 +192,9 @@ export function activateLocalPtySession(args: { staging = stageStartupCommand({ command: spawn.command, shellPath: plan.shellPath, - orcaBuiltLine: spawn.launchAgent !== undefined + orcaBuiltLine: spawn.launchAgent !== undefined, + wslDirectory: + spawn.wslLaunchDirectory?.distro === spawnedWslDistro ? spawn.wslLaunchDirectory : undefined }) const notice = startupStagingFailureNotice(staging) if (notice) { diff --git a/src/main/providers/local-pty-spawn-wsl-shutdown-order.test.ts b/src/main/providers/local-pty-spawn-wsl-shutdown-order.test.ts new file mode 100644 index 000000000000..dab27b9967e6 --- /dev/null +++ b/src/main/providers/local-pty-spawn-wsl-shutdown-order.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as LaunchPlanModule from './local-pty-launch-plan' +import type * as SpawnStateModule from './local-pty-spawn-state' +import { ptyShutdownOperations, type PtyShutdownOperation } from './local-pty-provider-state' +import type { WslLaunchDirectory } from '../../shared/wsl-launch-directory' + +const { resolveWslLaunchDirectory, reattachLocalPty } = vi.hoisted(() => ({ + resolveWslLaunchDirectory: vi.fn(), + reattachLocalPty: vi.fn() +})) +vi.mock('./wsl-launch-directory-resolution', () => ({ resolveWslLaunchDirectory })) +vi.mock('./local-pty-launch-plan', async (importOriginal) => ({ + ...(await importOriginal()), + resolveLocalPtyWslDistro: () => 'Ubuntu' +})) +vi.mock('./local-pty-spawn-state', async (importOriginal) => ({ + ...(await importOriginal()), + reattachLocalPty +})) + +import { spawnLocalPty } from './local-pty-spawn' + +afterEach(() => { + ptyShutdownOperations.clear() + vi.clearAllMocks() +}) + +describe('a WSL respawn racing a shutdown of the same session id', () => { + // Why: the distro lookup awaits, so it must finish before the shutdown check; a shutdown that + // starts during it would otherwise be missed and race the new PTY. + it('waits for a shutdown that began while the distro directory was being resolved', async () => { + let resolveDirectory!: (directory: WslLaunchDirectory | undefined) => void + resolveWslLaunchDirectory.mockReturnValue( + new Promise((resolve) => { + resolveDirectory = resolve + }) + ) + reattachLocalPty.mockReturnValue({ id: 'wsl-session' }) + const spawned = spawnLocalPty( + { cols: 80, rows: 24, sessionId: 'wsl-session', command: 'claude' }, + () => ({}) + ) + await vi.waitFor(() => expect(resolveWslLaunchDirectory).toHaveBeenCalledWith('Ubuntu')) + + let finishShutdown!: () => void + const shutdown: PtyShutdownOperation = { + promise: new Promise((resolve) => { + finishShutdown = resolve + }), + immediate: false, + rootSignalled: false, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the spawn under test only awaits `promise`; it never touches the shutting-down PTY. + proc: {} as PtyShutdownOperation['proc'] + } + ptyShutdownOperations.set('wsl-session', shutdown) + resolveDirectory(undefined) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(reattachLocalPty).not.toHaveBeenCalled() + + finishShutdown() + await expect(spawned).resolves.toEqual({ id: 'wsl-session' }) + expect(reattachLocalPty).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/providers/local-pty-spawn.ts b/src/main/providers/local-pty-spawn.ts index fc9e13a5e0dc..48739c4ba3f9 100644 --- a/src/main/providers/local-pty-spawn.ts +++ b/src/main/providers/local-pty-spawn.ts @@ -4,7 +4,11 @@ import { SessionNotFoundError } from '../daemon/daemon-errors' import { prepareMacosTccLoginShell } from './macos-tcc-login-shell' import { finalizeLocalPtySpawnEnvironment } from './local-pty-finalize-environment' import { normalizeLocalCallerSessionId } from './local-pty-launch-helpers' -import { createLocalPtyLaunchPlan, DeferredLocalPtyLaunchPlan } from './local-pty-launch-plan' +import { + createLocalPtyLaunchPlan, + DeferredLocalPtyLaunchPlan, + resolveLocalPtyWslDistro +} from './local-pty-launch-plan' import type { LocalPtyProviderOptions } from './local-pty-provider-types' import { allocatePtyId, ptyShutdownOperations } from './local-pty-provider-state' import { activateLocalPtySession } from './local-pty-session-activation' @@ -21,12 +25,22 @@ import { loadLocalPtyRuntimeSpawn } from './local-pty-runtime-spawn' import { destroyPtyProcess } from './local-pty-termination' import { updateHistoryEnvForFallback } from '../terminal-history' import type { PtySpawnOptions, PtySpawnResult } from './types' +import { + removeLaunchFile, + writeSpawnLaunchFile, + type WrittenLaunchFile +} from '../../shared/launch-file-writing' +import { resolveWslLaunchDirectory } from './wsl-launch-directory-resolution' export async function spawnLocalPty( args: PtySpawnOptions, getOptions: () => LocalPtyProviderOptions ): Promise { const reattachId = normalizeLocalCallerSessionId(args.sessionId, args.attachOnly === true) + const wslDistro = + args.command && !args.attachOnly ? resolveLocalPtyWslDistro(args, getOptions) : undefined + // Why before the shutdown check: no await may sit between it and this spawn's registration. + const wslLaunchDirectory = wslDistro ? await resolveWslLaunchDirectory(wslDistro) : undefined if (reattachId) { const pendingShutdown = ptyShutdownOperations.get(reattachId) if (pendingShutdown) { @@ -40,6 +54,36 @@ export async function spawnLocalPty( if (args.attachOnly) { throw new SessionNotFoundError(args.sessionId ?? '') } + const launchFile = writeSpawnLaunchFile({ + launchFile: args.launchFile, + command: args.command, + env: args.env, + orcaBuiltLine: args.launchAgent !== undefined, + wslDistro, + wslDirectory: wslLaunchDirectory + }) + args = { + ...args, + ...(launchFile ? { command: launchFile.command, env: launchFile.env } : {}), + // Pins the plan to the distro the file was written into. + ...(wslLaunchDirectory + ? { wslLaunchDirectory, terminalWindowsWslDistro: wslLaunchDirectory.distro } + : {}) + } + try { + return await spawnFreshLocalPty(args, getOptions, reattachId, launchFile) + } catch (error) { + removeLaunchFile(launchFile) + throw error + } +} + +async function spawnFreshLocalPty( + args: PtySpawnOptions, + getOptions: () => LocalPtyProviderOptions, + reattachId: string | null, + launchFile: WrittenLaunchFile | undefined +): Promise { const id = allocatePtyId(reattachId ?? undefined) return runCancelableLocalPtySpawn(id, async (throwIfCanceled, cancellation) => { const incarnationId = randomUUID() @@ -141,7 +185,8 @@ export async function spawnLocalPty( env: finalEnv, proc, reportsChildExitStatus: spawnResult.reportsChildExitStatus !== false, - spawnedWslDistro + spawnedWslDistro, + launchFile }) }) }) diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index e10fd44df46d..f4046d8bc6f5 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -15,6 +15,8 @@ import type { PtyProcessInfo } from './pty-process-info' import type { TerminalExitCause } from '../../shared/terminal-exit-cause' import type { TerminalOwner } from '../../shared/terminal-owner' import type { WriteSettlement } from '../../shared/pty-write-settlement' +import type { LaunchFile } from '../../shared/launch-prompt-file' +import type { WslLaunchDirectory } from '../../shared/wsl-launch-directory' export type { PtyBackgroundStreamEvent, @@ -58,6 +60,11 @@ export type PtySpawnOptions = { command?: string commandDelivery?: 'renderer' | 'provider' startupCommandDelivery?: StartupCommandDelivery + /** Written by the host before it types `command`, which names it by placeholder. Fresh + * spawns whose host types the command only; over SSH that means provider delivery. */ + launchFile?: LaunchFile + /** Set by main's providers for a WSL spawn that types a line; never by callers. */ + wslLaunchDirectory?: WslLaunchDirectory /** Minimal allowlisted launch ownership preserved by daemon reattach. */ launchAgent?: TuiAgent /** Orca worktree identity. When present, the local provider scopes shell diff --git a/src/main/providers/ssh-pty-spawn-request.ts b/src/main/providers/ssh-pty-spawn-request.ts index 170a50bb7bd4..cdf39b97646c 100644 --- a/src/main/providers/ssh-pty-spawn-request.ts +++ b/src/main/providers/ssh-pty-spawn-request.ts @@ -21,6 +21,7 @@ export function buildSshPtySpawnRequest(args: { ...(options.envToDelete?.length ? { envToDelete: options.envToDelete } : {}), // Why: the relay needs launch identity for plugin env overlays and provider-side delivery. ...(options.command ? { command: options.command } : {}), + ...(options.launchFile ? { launchFile: options.launchFile } : {}), ...(options.launchAgent ? { launchAgent: options.launchAgent } : {}), ...(options.worktreeId ? { worktreeId: options.worktreeId } : {}), ...(options.historyIsolationEnabled !== undefined diff --git a/src/main/providers/startup-line-typed-length.live-shell.test.ts b/src/main/providers/startup-line-typed-length.live-shell.test.ts deleted file mode 100644 index e74816351b4c..000000000000 --- a/src/main/providers/startup-line-typed-length.live-shell.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * Real-zsh proof for the typed launch lines `startup-line-prompt-carry.ts` lets a prompt ride: a - * multi-line line whose every line fits the per-line budget, up to the whole-line budget, reaches - * zsh intact through Orca's own wrapper, ready marker and startup write. - * - * Why a slow user config too: the write is released 1.5 s after spawn even without the marker, and - * then lands while the terminal is still line-buffered, where macOS keeps at most MAX_CANON bytes of - * one line. The per-line budget is what survives that; a single 1.1 KB line did not. - */ -import { spawnSync } from 'node:child_process' -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import * as pty from 'node-pty' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { - createShellStartupOutputScanState, - scanShellStartupOutput -} from '../shell-startup-output-scanner' -import { selectShellStartupFeatures } from '../shell-startup-features' -import { isBracketedPasteSafeShell } from '../../shared/startup-command-submission' -import { - TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES, - ZSH_MULTI_LINE_STARTUP_LINE_BUDGET_BYTES -} from '../../shared/startup-line-prompt-carry' -import { - restoreUserDataPathAfterEach, - setTestUserDataPath -} from './local-pty-shell-ready-test-harness' - -function findZsh(): string { - if (process.platform === 'win32') { - return '' - } - return (spawnSync('sh', ['-c', 'command -v zsh'], { encoding: 'utf8' }).stdout ?? '').trim() -} - -const ZSH_PATH = findZsh() - -/** Lines of prompt text, each `perLine` bytes. */ -function promptLines(lineCount: number, perLine: number): string { - const words = 'Fix the failing checks on this branch, then explain what changed and why.' - return Array.from({ length: lineCount }, (_unused, index) => { - let line = `L${index}:` - while (line.length < perLine) { - line += ` ${words}` - } - return line.slice(0, perLine) - }).join('\n') -} - -let home = '' - -beforeEach(() => { - home = mkdtempSync(join(tmpdir(), 'orca-typed-line-home-')) - setTestUserDataPath(mkdtempSync(join(tmpdir(), 'orca-typed-line-ud-'))) -}) - -afterEach(() => { - rmSync(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) -}) - -restoreUserDataPathAfterEach() - -/** Types `printf '%s' '' > out` the way a local pane types a launch line; returns what ran. */ -async function typeIntoZsh(text: string, userConfigSeconds: number): Promise { - if (userConfigSeconds > 0) { - writeFileSync(join(home, '.zshrc'), `sleep ${userConfigSeconds}\n`) - } - const out = join(home, 'out.txt') - const done = join(home, 'done.txt') - const { ensureShellReadyWrappers } = await import('./local-pty-shell-ready-wrapper-generation') - const { getShellLaunchConfig } = await import('./local-pty-shell-ready') - const { writeStartupCommandWhenShellReady, STARTUP_COMMAND_READY_MAX_WAIT_MS } = - await import('./local-pty-shell-ready-startup-command') - ensureShellReadyWrappers() - const env: Record = { PATH: '/usr/bin:/bin', HOME: home } - const launch = getShellLaunchConfig( - ZSH_PATH, - selectShellStartupFeatures({ - shellPath: ZSH_PATH, - env, - hasStartupCommand: true, - waitsForShellReady: true, - emitsStartupIdentity: false - }) - ) - expect(launch.supportsReadyMarker).toBe(true) - const proc = pty.spawn(ZSH_PATH, launch.args ?? [], { - name: 'xterm-256color', - cols: 200, - rows: 40, - cwd: home, - // Why ORCA_ORIG_ZDOTDIR: the user's config is read from the sandbox home, not the real one. - env: { ...env, ...launch.env, ORCA_ORIG_ZDOTDIR: home, TERM: 'xterm-256color' } - }) - const scan = createShellStartupOutputScanState() - let resolveReady: ((signal: { postMarkerBytesObserved: boolean }) => void) | null = null - const ready = new Promise<{ postMarkerBytesObserved: boolean }>((resolve) => { - resolveReady = resolve - }) - // The provider's cap (local-pty-shell-readiness-session.ts): a later marker releases the write. - const cap = setTimeout(() => { - resolveReady?.({ postMarkerBytesObserved: false }) - resolveReady = null - }, STARTUP_COMMAND_READY_MAX_WAIT_MS) - proc.onData((data) => { - if (resolveReady && scanShellStartupOutput(scan, data).ready) { - resolveReady({ postMarkerBytesObserved: true }) - resolveReady = null - } - }) - writeStartupCommandWhenShellReady( - ready, - proc, - `printf '%s' '${text}' > '${out}'; printf ok > '${done}'`, - () => {}, - { - bracketedPasteSafe: isBracketedPasteSafeShell({ - shellName: 'zsh', - waitsForShellReady: launch.supportsReadyMarker === true - }) - } - ) - const deadline = Date.now() + 20_000 + userConfigSeconds * 1000 - while (!existsSync(done) && Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 50)) - } - clearTimeout(cap) - const ran = existsSync(out) ? readFileSync(out, 'utf8') : null - const exited = new Promise((resolve) => proc.onExit(() => resolve())) - proc.kill() - await Promise.race([exited, new Promise((resolve) => setTimeout(resolve, 2000))]) - return ran -} - -describe.skipIf(!ZSH_PATH)('a multi-line launch line typed into zsh', () => { - // Lines at the per-line budget, as many as the whole-line budget leaves room for. - const perLine = TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES - 1 - const text = promptLines( - Math.floor((ZSH_MULTI_LINE_STARTUP_LINE_BUDGET_BYTES - 200) / (perLine + 1)), - perLine - ) - - it('arrives whole through the ready barrier', async () => { - expect(await typeIntoZsh(text, 0)).toBe(text) - }, 60_000) - - it('arrives whole when the user config outlasts the barrier and the write lands early', async () => { - expect(await typeIntoZsh(text, 3)).toBe(text) - }, 60_000) -}) diff --git a/src/main/providers/wsl-launch-directory-resolution.test.ts b/src/main/providers/wsl-launch-directory-resolution.test.ts new file mode 100644 index 000000000000..be4fb992a50e --- /dev/null +++ b/src/main/providers/wsl-launch-directory-resolution.test.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createLocalPtyLaunchPlan, resolveLocalPtyWslDistro } from './local-pty-launch-plan' +import type { LocalPtyProviderOptions } from './local-pty-provider-types' +import type { PtySpawnOptions } from './types' +import { resolveWslLaunchDirectory } from './wsl-launch-directory-resolution' +import type * as WslModule from '../wsl' + +const { runWslProcess } = vi.hoisted(() => ({ runWslProcess: vi.fn() })) +vi.mock('../wsl', async (importOriginal) => ({ + ...(await importOriginal()), + getDefaultWslDistro: () => 'Debian' +})) +vi.mock('../wsl/wsl-runner', () => ({ runWslProcess })) +vi.mock('./local-pty-utils', () => ({ + ensureNodePtySpawnHelperExecutable: vi.fn(), + validateWorkingDirectory: vi.fn() +})) + +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')! +beforeEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) +}) +afterEach(() => { + Object.defineProperty(process, 'platform', originalPlatform) + vi.clearAllMocks() +}) + +function answers(stdout: string, code = 0) { + return { environmentResolved: true, code, stdout, stderr: '', timedOut: false } +} + +describe('resolveWslLaunchDirectory', () => { + it("names a cache directory in the distro's home both ways, with the pane's login shell", async () => { + runWslProcess.mockResolvedValue(answers('/home/ada\n/usr/bin/zsh\n')) + await expect(resolveWslLaunchDirectory('Ubuntu')).resolves.toEqual({ + distro: 'Ubuntu', + windowsPath: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.cache\\orca', + linuxPath: '/home/ada/.cache/orca', + shell: '/usr/bin/zsh' + }) + expect(runWslProcess).toHaveBeenCalledWith( + expect.objectContaining({ distro: 'Ubuntu', loginPath: 'none', shell: 'sh' }) + ) + }) + + // Why: files written over the UNC share take 9P's default mode, so only the directory's mode + // keeps a worker brief from the distro's other users. + it('makes the cache directory private to the distro user before anything is written there', async () => { + runWslProcess.mockResolvedValue(answers('/home/ada\n/bin/bash\n')) + await resolveWslLaunchDirectory('Kali') + expect(runWslProcess).toHaveBeenCalledWith( + expect.objectContaining({ + script: expect.stringContaining( + `_orca_root="$HOME"/'.cache/orca'\nmkdir -p "$_orca_root" && chmod 700 "$_orca_root" || exit 1` + ) + }) + ) + }) + + it('probes a distro once, and asks again only after a failure', async () => { + runWslProcess.mockResolvedValueOnce(answers('', 1)) + await expect(resolveWslLaunchDirectory('Arch')).resolves.toBeUndefined() + runWslProcess.mockResolvedValue(answers('/home/ada\n/bin/bash\n')) + await expect(resolveWslLaunchDirectory('Arch')).resolves.toMatchObject({ shell: '/bin/bash' }) + await resolveWslLaunchDirectory('Arch') + expect(runWslProcess).toHaveBeenCalledTimes(2) + }) + + it('has none for a spawn outside WSL or a distro whose home cannot be read', async () => { + await expect(resolveWslLaunchDirectory(undefined)).resolves.toBeUndefined() + runWslProcess.mockResolvedValue(answers('not-a-path\n')) + await expect(resolveWslLaunchDirectory('Alpine')).resolves.toBeUndefined() + runWslProcess.mockRejectedValue(new Error('wsl.exe missing')) + await expect(resolveWslLaunchDirectory('Fedora')).resolves.toBeUndefined() + }) +}) + +describe('resolveLocalPtyWslDistro', () => { + // Why: the launch file goes into this distro, so it must be the one the plan starts. + it.each<[string, Partial, string]>([ + ['a WSL worktree path', { cwd: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\repo' }, 'Ubuntu'], + [ + 'a WSL worktree id', + { cwd: 'C:\\work', worktreeId: 'repo::\\\\wsl.localhost\\Arch\\home\\ada\\repo' }, + 'Arch' + ], + [ + 'a WSL tab with a chosen distro', + { cwd: 'C:\\work', shellOverride: 'wsl.exe', terminalWindowsWslDistro: 'Alpine' }, + 'Alpine' + ], + ['a WSL tab on the default distro', { cwd: 'C:\\work', shellOverride: 'wsl.exe' }, 'Debian'], + ['WSL as the default shell', { cwd: 'C:\\work' }, 'Debian'] + ])('agrees with the launch plan for %s', (_label, spawn, distro) => { + const args = { cols: 80, rows: 24, ...spawn } + const getOptions = (): LocalPtyProviderOptions => ({ getWindowsShell: () => 'wsl.exe' }) + expect(resolveLocalPtyWslDistro(args, getOptions)).toBe(distro) + const plan = createLocalPtyLaunchPlan(args, getOptions) + expect('launchWslDistro' in plan && plan.launchWslDistro).toBe(distro) + }) + + it('has none for a PowerShell tab', () => { + const getOptions = (): LocalPtyProviderOptions => ({ getWindowsShell: () => 'powershell.exe' }) + expect(resolveLocalPtyWslDistro({ cols: 80, rows: 24, cwd: 'C:\\work' }, getOptions)).toBe( + undefined + ) + }) +}) diff --git a/src/main/providers/wsl-launch-directory-resolution.ts b/src/main/providers/wsl-launch-directory-resolution.ts new file mode 100644 index 000000000000..f357a627d721 --- /dev/null +++ b/src/main/providers/wsl-launch-directory-resolution.ts @@ -0,0 +1,63 @@ +import { posix } from 'node:path' +import { quotePosixShell, RESOLVE_WSL_LOGIN_SHELL } from '../../shared/wsl-login-shell-command' +import type { WslLaunchDirectory } from '../../shared/wsl-launch-directory' +import { toWindowsWslUncPath } from '../../shared/wsl-paths' +import { runWslProcess } from '../wsl/wsl-runner' + +const ORCA_CACHE_RELATIVE = '.cache/orca' + +const probes = new Map>() + +/** + * The distro directory a WSL spawn's staged line and launch file are written to, and the login + * shell the pane runs, which decides how a staged line is sourced; undefined when the distro cannot + * be asked, and the write site then refuses a launch that needs one. Probed once per distro, and + * only a success is kept. + */ +export async function resolveWslLaunchDirectory( + distro: string | null | undefined +): Promise { + if (process.platform !== 'win32' || !distro) { + return undefined + } + let probe = probes.get(distro) + if (!probe) { + probe = probeWslLaunchDirectory(distro) + probes.set(distro, probe) + void probe.then((found) => found ?? probes.delete(distro)) + } + return await probe +} + +async function probeWslLaunchDirectory(distro: string): Promise { + const script = [ + ...RESOLVE_WSL_LOGIN_SHELL, + `_orca_root="$HOME"/${quotePosixShell(ORCA_CACHE_RELATIVE)}`, + // Why chmod in the distro: files written over the UNC share take 9P's default mode, so the + // 0700 directory is what keeps a worker brief from other distro users. + 'mkdir -p "$_orca_root" && chmod 700 "$_orca_root" || exit 1', + `printf '%s\\n%s\\n' "$HOME" "$_orca_wsl_shell"` + ].join('\n') + try { + const result = await runWslProcess({ + distro, + loginPath: 'none', + script, + shell: 'sh', + timeoutMs: 10_000 + }) + const [home, shell] = result.stdout.split('\n').map((line) => line.trim()) + if (result.code !== 0 || !home?.startsWith('/') || home.includes('\\')) { + return undefined + } + const linuxPath = posix.join(home, ORCA_CACHE_RELATIVE) + return { + distro, + windowsPath: toWindowsWslUncPath(linuxPath, distro), + linuxPath, + ...(shell?.startsWith('/') ? { shell } : {}) + } + } catch { + return undefined + } +} diff --git a/src/main/runtime/agent-launch-typed-line-shell.test.ts b/src/main/runtime/agent-launch-typed-line-shell.test.ts deleted file mode 100644 index 1ef35b852927..000000000000 --- a/src/main/runtime/agent-launch-typed-line-shell.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { nameLocalTypedLineShell } from './agent-launch-typed-line-shell' - -describe('naming the shell a local launch line is typed into', () => { - it('takes the request’s shell first, then the setting, then SHELL, as the spawn does', () => { - const base = { isRemote: false, platform: 'darwin' as const, envShell: '/bin/bash' } - expect( - nameLocalTypedLineShell({ - ...base, - shellOverride: '/opt/homebrew/bin/fish', - defaultShellSetting: '/bin/zsh' - }) - ).toBe('fish') - expect(nameLocalTypedLineShell({ ...base, defaultShellSetting: ' /bin/zsh ' })).toBe('zsh') - expect(nameLocalTypedLineShell(base)).toBe('bash') - expect(nameLocalTypedLineShell({ ...base, envShell: '' })).toBe('zsh') - }) - - it('names none for a remote host, whose relay picks its own login shell', () => { - expect( - nameLocalTypedLineShell({ isRemote: true, platform: 'darwin', envShell: '/bin/zsh' }) - ).toBeUndefined() - }) - - it('names none on Windows, where the pane may be cmd, PowerShell, Git Bash or WSL', () => { - expect( - nameLocalTypedLineShell({ isRemote: false, platform: 'win32', envShell: '/bin/zsh' }) - ).toBeUndefined() - }) -}) diff --git a/src/main/runtime/agent-launch-typed-line-shell.ts b/src/main/runtime/agent-launch-typed-line-shell.ts deleted file mode 100644 index 6a7f83c048b4..000000000000 --- a/src/main/runtime/agent-launch-typed-line-shell.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { basename } from 'node:path' - -/** - * The shell a local pane's startup line gets typed into, named before the spawn the way the spawn - * picks it: the request's shell, the default-shell setting, then `SHELL` - * (`ipc/pty/runtime/spawn-preflight.ts`, then the local or daemon launch plan). - * - * Undefined where the host cannot name it: a remote host, whose relay picks its own login shell, - * and Windows, whose pane may be cmd, PowerShell, Git Bash or a WSL distro. - */ -export function nameLocalTypedLineShell(args: { - isRemote: boolean - shellOverride?: string - defaultShellSetting?: string - platform?: NodeJS.Platform - envShell?: string -}): string | undefined { - if (args.isRemote || (args.platform ?? process.platform) === 'win32') { - return undefined - } - const shellPath = - args.shellOverride?.trim() || - args.defaultShellSetting?.trim() || - (args.envShell ?? process.env.SHELL) || - '/bin/zsh' - return basename(shellPath).toLowerCase() -} diff --git a/src/main/runtime/agent-terminal-startup-prompt.test.ts b/src/main/runtime/agent-terminal-startup-prompt.test.ts index cdf6cd16dbb5..065db00d3fc6 100644 --- a/src/main/runtime/agent-terminal-startup-prompt.test.ts +++ b/src/main/runtime/agent-terminal-startup-prompt.test.ts @@ -9,6 +9,7 @@ import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from './orca-runtime' +import { carryInLaunchFile } from '../../shared/launch-prompt-file' vi.mock('electron', () => ({ BrowserWindow: { fromId: vi.fn(() => null) }, @@ -17,10 +18,7 @@ vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp') } })) -// The shell the host names for a local line; bash takes no multi-line line, zsh does. -function runtimeWithAgentLaunch( - options: { terminalDefaultShell?: string; connectionId?: string } = {} -): { +function runtimeWithAgentLaunch(): { runtime: OrcaRuntimeService spawn: ReturnType } { @@ -30,13 +28,11 @@ function runtimeWithAgentLaunch( store: { getSettings: () => Record } resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise } - internal.store = { - getSettings: () => ({ terminalDefaultShell: options.terminalDefaultShell ?? '/bin/bash' }) - } + internal.store = { getSettings: () => ({}) } vi.spyOn(internal, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue({ id: 'wt-1', path: '/repo/app', - connectionId: options.connectionId ?? null, + connectionId: null, repo: null, folderWorkspace: null }) @@ -57,80 +53,93 @@ 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, and says it did', async () => { + it('puts an argv agent’s prompt on the command it spawns', async () => { const { runtime, spawn } = runtimeWithAgentLaunch() - const onStartupPromptCarry = vi.fn() await runtime.createTerminal('id:wt-1', { startupAgent: 'claude', - startupPrompt: 'summarize the diff', - onStartupPromptCarry + startupPrompt: 'summarize the diff' }) expect(spawnedCommand(spawn)).toContain('summarize the diff') expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ launchAgent: 'claude' })) - expect(onStartupPromptCarry).toHaveBeenCalledWith(true) + expect(spawn.mock.calls[0]?.[0]?.launchFile).toBeUndefined() }) - it('starts clean, and says so, when the typed line cannot carry a multi-line prompt', async () => { + it('keeps a multi-line prompt on the command too: the host stages the typed line', 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 + startupPrompt: 'summarize the diff\nthen list the risks' }) - // 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) + expect(spawnedCommand(spawn)).toContain('summarize the diff\nthen list the risks') }) - it('carries a short-lined multi-line prompt on a local zsh line, so the agent starts with it', async () => { - const { runtime, spawn } = runtimeWithAgentLaunch({ terminalDefaultShell: '/bin/zsh' }) - const onStartupPromptCarry = vi.fn() + it('hands a prompt past the argv ceiling to the host as a launch file', async () => { + const { runtime, spawn } = runtimeWithAgentLaunch() + const prompt = 'x'.repeat(20_000) + + await runtime.createTerminal('id:wt-1', { startupAgent: 'claude', startupPrompt: prompt }) + + const launchFile = spawn.mock.calls[0]?.[0]?.launchFile + expect(launchFile).toMatchObject({ content: prompt, sensitive: false, quoting: 'posix' }) + expect(spawnedCommand(spawn)).toContain(launchFile.placeholder) + expect(spawnedCommand(spawn)).not.toContain('xxxx') + }) + + it('tells the host how the line quoted a launch file its caller wrote', async () => { + const { runtime, spawn } = runtimeWithAgentLaunch() + const { prompt, launchFile } = carryInLaunchFile('worker brief', true) await runtime.createTerminal('id:wt-1', { startupAgent: 'claude', - startupPrompt: 'summarize the diff\nthen list the risks', - onStartupPromptCarry + startupPrompt: prompt, + launchFile }) - expect(spawnedCommand(spawn)).toContain('summarize the diff\nthen list the risks') - expect(onStartupPromptCarry).toHaveBeenCalledWith(true) + expect(spawn.mock.calls[0]?.[0]?.launchFile).toEqual({ ...launchFile, quoting: 'posix' }) }) - it('starts clean on a remote host, whose shell this host cannot name', async () => { - const { runtime, spawn } = runtimeWithAgentLaunch({ - terminalDefaultShell: '/bin/zsh', - connectionId: 'ssh-1' - }) + it('starts an agent clean and says so when its prompt needs a file it is not known to read', 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', + startupAgent: 'gemini', + startupPrompt: 'x'.repeat(30_000), onStartupPromptCarry }) - expect(spawnedCommand(spawn)).not.toContain('summarize') - expect(onStartupPromptCarry).toHaveBeenCalledWith(false) + expect(onStartupPromptCarry).toHaveBeenCalledExactlyOnceWith(false) + expect(spawn.mock.calls[0]?.[0]?.launchFile).toBeUndefined() + expect(spawnedCommand(spawn)).not.toContain('x'.repeat(100)) }) - it('starts Hermes clean instead of refusing when its env budget cannot hold the prompt', async () => { + it('refuses such a prompt when the caller cannot paste it, rather than dropping it', async () => { const { runtime, spawn } = runtimeWithAgentLaunch() + + await expect( + runtime.createTerminal('id:wt-1', { + startupAgent: 'hermes', + startupPrompt: 'x'.repeat(30_000) + }) + ).rejects.toThrow(/cannot take this prompt on its command line/) + expect(spawn).not.toHaveBeenCalled() + }) + + it('reports a prompt carried once it rides the command or a launch file', async () => { + const { runtime } = runtimeWithAgentLaunch() const onStartupPromptCarry = vi.fn() await runtime.createTerminal('id:wt-1', { - startupAgent: 'hermes', - startupPrompt: 'x'.repeat(30_000), + startupAgent: 'claude', + startupPrompt: 'y'.repeat(30_000), onStartupPromptCarry }) - expect(spawn).toHaveBeenCalledTimes(1) - expect(onStartupPromptCarry).toHaveBeenCalledWith(false) + expect(onStartupPromptCarry).toHaveBeenCalledExactlyOnceWith(true) }) it('still builds a bare agent launch when no prompt is handed to it', async () => { diff --git a/src/main/runtime/claude-agent-teams-shim-env.test.ts b/src/main/runtime/claude-agent-teams-shim-env.test.ts index b0978ae25f8d..a7e7e45c63e5 100644 --- a/src/main/runtime/claude-agent-teams-shim-env.test.ts +++ b/src/main/runtime/claude-agent-teams-shim-env.test.ts @@ -20,6 +20,32 @@ afterEach(async () => { }) describe('claude agent teams shim env', () => { + // Why: a launch-file pointer puts backticks on every such line, which the typed-line check refuses. + it('turns agent teams on for a Claude launch Orca built, whatever its prompt holds', async () => { + const pointer = + "claude '--add-dir=/tmp/d' 'The full task is in the file `/tmp/d/task-context.md`.'" + const plan = await buildClaudeAgentTeamsLaunchPlan({ + command: pointer, + launchAgent: 'claude', + mode: 'in-process', + baseEnv: {}, + createTeamEnv: () => ({}) + }) + expect(plan?.command).toBe( + "claude --teammate-mode in-process '--add-dir=/tmp/d' 'The full task is in the file `/tmp/d/task-context.md`.'" + ) + expect(plan?.env).toEqual({ CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' }) + await expect( + buildClaudeAgentTeamsLaunchPlan({ + command: pointer, + launchAgent: 'codex', + mode: 'in-process', + baseEnv: {}, + createTeamEnv: () => ({}) + }) + ).resolves.toBeNull() + }) + it('writes a private tmux shim that calls the Orca shim command', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-agent-teams-shim-')) roots.push(root) diff --git a/src/main/runtime/claude-agent-teams-shim-env.ts b/src/main/runtime/claude-agent-teams-shim-env.ts index b8ff1b5a5c01..5af4f3051f01 100644 --- a/src/main/runtime/claude-agent-teams-shim-env.ts +++ b/src/main/runtime/claude-agent-teams-shim-env.ts @@ -5,9 +5,11 @@ import { delimiter, dirname, isAbsolute, join } from 'node:path' import { addClaudeTeammateModeAuto, addClaudeTeammateModeInProcess, + isClaudeLaunchLine, isDirectClaudeCommand, type ClaudeAgentTeamsMode } from '../../shared/claude-agent-teams-tmux-compat' +import type { TuiAgent } from '../../shared/tui-agent' import { getOrcaCliCommandNameForPlatform } from '../../shared/orca-cli-command-name' import { resolvePathEnvKey } from '../pty/windows-path-segment-merge' @@ -28,12 +30,18 @@ export async function ensureClaudeAgentTeamsShimDir(root = defaultShimRoot()): P export async function buildClaudeAgentTeamsLaunchPlan(args: { command: string | undefined + /** The agent Orca launched, when it built the line; decides eligibility over parsing the line. */ + launchAgent?: TuiAgent mode: ClaudeAgentTeamsMode | undefined baseEnv: Record createTeamEnv: (shimDir: string, shimBin: string) => Record }): Promise { const mode = args.mode ?? 'off' - if (!args.command || mode === 'off' || !isDirectClaudeCommand(args.command)) { + const eligible = + args.launchAgent === undefined + ? isDirectClaudeCommand(args.command) + : args.launchAgent === 'claude' && isClaudeLaunchLine(args.command) + if (!args.command || mode === 'off' || !eligible) { return null } if (mode === 'in-process' || process.platform === 'win32') { diff --git a/src/main/runtime/orca-runtime-agent-session-operation.test.ts b/src/main/runtime/orca-runtime-agent-session-operation.test.ts index 37b329670d27..f8ef09234db7 100644 --- a/src/main/runtime/orca-runtime-agent-session-operation.test.ts +++ b/src/main/runtime/orca-runtime-agent-session-operation.test.ts @@ -213,6 +213,21 @@ describe('agent-session create operation ledger', () => { ) }) + it('refuses a prompt it could only paste, having no paste after ready', async () => { + const runtime = createRuntime({ + supportsAgentSessionClaims: () => true, + supportsAgentSessionCreateOperations: () => true + }) + const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue(terminal()) + + await expect( + runtime.createAgentSession( + request(operationId(), { agent: 'gemini', prompt: 'x'.repeat(20_000) }) + ) + ).rejects.toThrow(/gemini cannot take this prompt on its command line here/) + expect(createTerminal).not.toHaveBeenCalled() + }) + it('requests exact client legacy fallback before nested SSH side effects', async () => { const runtime = createRuntime() const internal = runtime as unknown as { diff --git a/src/main/runtime/orca-runtime-agent-teams-launch-plan.ts b/src/main/runtime/orca-runtime-agent-teams-launch-plan.ts index 0d5808970072..f6bfe883fc7b 100644 --- a/src/main/runtime/orca-runtime-agent-teams-launch-plan.ts +++ b/src/main/runtime/orca-runtime-agent-teams-launch-plan.ts @@ -9,9 +9,10 @@ import { } from './orca-runtime-create-terminal-dependencies' export async function buildRuntimeAgentTeamsLaunchPlan(args: { - launchConfig: TerminalCreateOptions['launchConfig'] - command?: string - claudeAgentTeamsSourceCommand?: string + launch: Pick< + TerminalCreateOptions, + 'launchConfig' | 'command' | 'claudeAgentTeamsSourceCommand' | 'launchAgent' + > claudeAgentTeamsMode?: ClaudeAgentTeamsMode baseEnv: Record adoptedBeforeLaunch: boolean @@ -21,10 +22,11 @@ export async function buildRuntimeAgentTeamsLaunchPlan(args: { sequencedStartupCommand?: string effectiveLaunchConfig: TerminalCreateOptions['launchConfig'] }> { + const { launchConfig, command, launchAgent } = args.launch const sourceCommand = - args.claudeAgentTeamsSourceCommand?.trim() || args.command?.trim() || undefined + args.launch.claudeAgentTeamsSourceCommand?.trim() || command?.trim() || undefined const mode = inferCapturedClaudeAgentTeamsMode( - args.launchConfig, + launchConfig, sourceCommand, args.claudeAgentTeamsMode ) @@ -32,25 +34,24 @@ export async function buildRuntimeAgentTeamsLaunchPlan(args: { ? undefined : await buildClaudeAgentTeamsLaunchPlan({ command: sourceCommand, + ...(launchAgent ? { launchAgent } : {}), mode, baseEnv: args.baseEnv, createTeamEnv: args.createTeamEnv }) const sequencedStartupCommand = - plan && sourceCommand && args.command && sourceCommand !== args.command - ? plan.command - : undefined + plan && sourceCommand && command && sourceCommand !== command ? plan.command : undefined const effectiveLaunchConfig = - args.launchConfig && plan + launchConfig && plan ? { - ...args.launchConfig, - agentCommand: args.launchConfig.agentCommand + ...launchConfig, + agentCommand: launchConfig.agentCommand ? mode === 'in-process' || process.platform === 'win32' - ? addClaudeTeammateModeInProcess(args.launchConfig.agentCommand) - : addClaudeTeammateModeAuto(args.launchConfig.agentCommand) + ? addClaudeTeammateModeInProcess(launchConfig.agentCommand) + : addClaudeTeammateModeAuto(launchConfig.agentCommand) : plan.command, - agentEnv: { ...args.launchConfig.agentEnv, ...plan.env } + agentEnv: { ...launchConfig.agentEnv, ...plan.env } } - : args.launchConfig + : launchConfig return { plan, sequencedStartupCommand, effectiveLaunchConfig } } diff --git a/src/main/runtime/orca-runtime-create-agent-session.ts b/src/main/runtime/orca-runtime-create-agent-session.ts index 36e4d3f41a1b..91fa61c85b9a 100644 --- a/src/main/runtime/orca-runtime-create-agent-session.ts +++ b/src/main/runtime/orca-runtime-create-agent-session.ts @@ -17,7 +17,13 @@ import { } from './orca-runtime-core' import { isTuiAgentEnabled } from '../../shared/tui-agent-selection' import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' -import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '../../shared/tui-agent-startup' +import { + agentPromptRidesLaunchCommand, + buildAgentDraftLaunchPlan, + buildAgentStartupPlan +} from '../../shared/tui-agent-startup' +import { windowsDraftRefusal } from '../../shared/startup-plan-launch-file' +import { resolveStartupShell } from '../../shared/tui-agent-startup-shell' import type { RuntimeTerminalCreate } from '../../shared/runtime-types' import type { AgentSessionCreateOperation, @@ -168,7 +174,22 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe allowEmptyPromptLaunch: true }) if (!startup) { - throw new Error('agent_session_identity_required') + const refusal = + request.promptDelivery === 'draft' + ? windowsDraftRefusal( + request.prompt ?? '', + resolveStartupShell(startupArgs.platform, startupArgs.shell) + ) + : null + throw new Error(refusal ?? 'agent_session_identity_required') + } + if (startup.followupPrompt && agentPromptRidesLaunchCommand(request.agent)) { + // Why: this create has no paste after ready, so a prompt left for one would be dropped. + throw new Error( + `${request.agent} cannot take this prompt on its command line here (too long, private, or ` + + "broken by the host's Windows shell), so the session was not started. Start it without " + + 'the prompt and paste the prompt once it opens.' + ) } if (caller.signal?.aborted) { throw new Error('client_disconnected') @@ -201,6 +222,9 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe launchAgent: request.agent, terminalKittyKeyboardProtocol: request.terminalKittyKeyboardProtocol, startupCommandDelivery: startup.startupCommandDelivery, + ...('launchFile' in startup && startup.launchFile + ? { launchFile: startup.launchFile } + : {}), // A fresh agent this host built; the request has no surface field, so it counts as `unknown`. telemetry: agentStartedTelemetry(request.agent, undefined), cwd: startupCwd, diff --git a/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts b/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts index f1b8fa16af5c..06760e3d5728 100644 --- a/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts +++ b/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts @@ -28,6 +28,7 @@ export class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal extends Orca env?: Record envToDelete?: string[] startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] + launchFile?: WorktreeStartupLaunch['launchFile'] identity?: { tabId: string; leafId: string; sessionId?: string } launchAgent?: TuiAgent viewMode?: 'terminal' | 'chat' @@ -53,6 +54,7 @@ export class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal extends Orca ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), startupCommandDelivery: opts.startupCommandDelivery, + ...(opts.launchFile ? { launchFile: opts.launchFile } : {}), ...(opts.identity ? { tabId: opts.identity.tabId, diff --git a/src/main/runtime/orca-runtime-create-terminal-desktop.ts b/src/main/runtime/orca-runtime-create-terminal-desktop.ts index 9cd955428eef..54f5104f0aca 100644 --- a/src/main/runtime/orca-runtime-create-terminal-desktop.ts +++ b/src/main/runtime/orca-runtime-create-terminal-desktop.ts @@ -2,6 +2,7 @@ import * as dependencies from './orca-runtime-create-terminal-dependencies' import type { OrcaRuntimeWithCreateTerminal } from './orca-runtime-create-terminal' import type { RuntimeTerminalPresentation } from '../../shared/runtime-types' +import { removeLaunchFile, writeLaunchFile } from '../../shared/launch-file-writing' export async function createDesktopTerminal( runtime: OrcaRuntimeWithCreateTerminal, @@ -25,6 +26,11 @@ export async function createDesktopTerminal( `--shell ${opts.shellOverride} needs a workspace, because the shell is resolved on the workspace's execution host. No terminal was created.` ) } + if (launchOpts.launchFile && !workspace?.connectionId) { + // Why: a pane whose host refuses the launch-file write never registers a handle, so this create + // would only time out; failing the same local write here reports the refusal at once. + removeLaunchFile(writeLaunchFile({ launchFile: launchOpts.launchFile })) + } const worktreeId = workspace?.id const cwd = workspace ? runtime.resolveWorkspaceTerminalStartupCwd(workspace, launchOpts.cwd) @@ -70,6 +76,7 @@ export async function createDesktopTerminal( ...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}), ...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}), startupCommandDelivery: launchOpts.startupCommandDelivery, + ...(launchOpts.launchFile ? { launchFile: launchOpts.launchFile } : {}), ...(launchOpts.shellOverride ? { shellOverride: launchOpts.shellOverride } : {}), title: launchOpts.title, activate: presentation === 'focused', diff --git a/src/main/runtime/orca-runtime-create-terminal.ts b/src/main/runtime/orca-runtime-create-terminal.ts index 242e0f7f4544..e73dfdb979d5 100644 --- a/src/main/runtime/orca-runtime-create-terminal.ts +++ b/src/main/runtime/orca-runtime-create-terminal.ts @@ -5,6 +5,7 @@ import { createDesktopTerminal } from './orca-runtime-create-terminal-desktop' import { buildRuntimeAgentTeamsLaunchPlan } from './orca-runtime-agent-teams-launch-plan' import { createPtySpawnCommitReporter } from './orca-runtime-report-pty-spawn-commit' import { recordPtySurface, spawnSurfaceClaimSequence } from './pty-recorded-surface-topology' +import { rememberLaunchFilePrompt } from '../agent-hooks/launch-file-prompt-by-pane' export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreateDeduplication { async createTerminal( @@ -89,9 +90,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate let effectiveLaunchConfig = launchOpts.launchConfig try { const agentTeams = await buildRuntimeAgentTeamsLaunchPlan({ - launchConfig: launchOpts.launchConfig, - command: launchOpts.command, - claudeAgentTeamsSourceCommand: launchOpts.claudeAgentTeamsSourceCommand, + launch: launchOpts, claudeAgentTeamsMode: this.store?.getSettings?.().claudeAgentTeamsMode, baseEnv: { ...process.env, ...baseEnv }, adoptedBeforeLaunch, @@ -129,6 +128,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate let result: Awaited>> try { launchOpts.onPtySpawnDispatched?.() + rememberLaunchFilePrompt(dependencies.makePaneKey(tabId, leafId), launchOpts.launchFile) result = await this.ptyController.spawn({ cols: 120, rows: 40, @@ -139,6 +139,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate launchAgent: launchOpts.launchAgent, commandDelivery: 'provider', startupCommandDelivery: launchOpts.startupCommandDelivery, + ...(launchOpts.launchFile ? { launchFile: launchOpts.launchFile } : {}), env, envToDelete: dependencies.mergeTerminalEnvDeletionKeys( launchOpts.envToDelete, diff --git a/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts b/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts index 095ee58a37f6..54ad4104fd63 100644 --- a/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts +++ b/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts @@ -28,6 +28,7 @@ export class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends OrcaRunt startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent + launchFile?: WorktreeStartupLaunch['launchFile'] }> { if (opts.command || !opts.agent) { return { @@ -72,7 +73,8 @@ export class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends OrcaRunt envToDelete: opts.envToDelete, launchConfig: startupPlan.launchConfig, launchAgent: opts.agent, - startupCommandDelivery: startupPlan.startupCommandDelivery + startupCommandDelivery: startupPlan.startupCommandDelivery, + ...(startupPlan.launchFile ? { launchFile: startupPlan.launchFile } : {}) } } } 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 cbbf85aa37c8..e6ac7d117481 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -19,9 +19,14 @@ 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 { agentPromptRidesLaunchCommand } from '../../shared/tui-agent-startup' -import { planStartupWithPromptCandidate } from '../../shared/startup-line-prompt-carry' -import { nameLocalTypedLineShell } from './agent-launch-typed-line-shell' +import { + agentPromptRidesLaunchCommand, + buildAgentStartupPlan +} from '../../shared/tui-agent-startup' +import { + planStartupWithLaunchPrompt, + type LaunchPromptStartupPlan +} from '../../shared/startup-line-prompt-carry' import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' @@ -261,26 +266,31 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith 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, - isRemote, - ...(opts.agentArgs !== undefined ? { agentArgs: opts.agentArgs } : {}), - // A requested shell is the one this PTY will actually be, so it owns the quoting family. - windowsShellOverride: opts.shellOverride, - sessionOptions: this.toAgentSessionOptions(opts.launchPreferences) - }), - opts.startupPrompt ?? '', - nameLocalTypedLineShell({ - isRemote, - ...(opts.shellOverride ? { shellOverride: opts.shellOverride } : {}), - ...(settings.terminalDefaultShell - ? { defaultShellSetting: settings.terminalDefaultShell } - : {}) + const planInputs = resolveAgentStartupPlanInputs({ + agent, + settings, + platform, + isRemote, + ...(opts.agentArgs !== undefined ? { agentArgs: opts.agentArgs } : {}), + // A requested shell is the one this PTY will actually be, so it owns the quoting family. + windowsShellOverride: opts.shellOverride, + sessionOptions: this.toAgentSessionOptions(opts.launchPreferences) + }) + // A caller that wrote its own launch file already passes the pointer to it as the prompt. + const prompt = opts.startupPrompt ?? '' + let planned: LaunchPromptStartupPlan + if (opts.launchFile) { + const plan = buildAgentStartupPlan({ + ...planInputs, + prompt, + allowEmptyPromptLaunch: true, + launchFile: opts.launchFile }) - ) + planned = { plan, launchFile: plan?.launchFile ?? opts.launchFile } + } else { + planned = planStartupWithLaunchPrompt(planInputs, prompt) + } + const { plan: startupPlan, launchFile } = planned if (!startupPlan) { // Why: an explicit agent that yields no plan would otherwise spawn a bare // shell that never reaches agent readiness. @@ -289,8 +299,16 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith } return opts } - if (opts.startupPrompt) { - opts.onStartupPromptCarry?.(promptCarried) + if (opts.startupPrompt?.trim()) { + const carried = startupPlan.followupPrompt === null + if (!carried && !opts.onStartupPromptCarry) { + // Why: this create returns options, not a live PTY, so the prompt would be dropped. + throw new Error( + `${agent} cannot take this prompt on its command line here, so no terminal was created. ` + + 'Start the agent without it and paste the prompt once it opens.' + ) + } + opts.onStartupPromptCarry?.(carried) } return { @@ -300,6 +318,7 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith launchConfig: startupPlan.launchConfig, launchAgent: agent, startupCommandDelivery: startupPlan.startupCommandDelivery, + ...(launchFile ? { launchFile } : {}), // A bare command the user typed stays out of launch accounting, as before. ...(opts.startupAgent ? { telemetry: agentStartedTelemetry(agent, opts.launchSource) } : {}) } diff --git a/src/main/runtime/orca-runtime-run-create-mobile-session-terminal.ts b/src/main/runtime/orca-runtime-run-create-mobile-session-terminal.ts index 193bf3cb12dd..d6277614a1b7 100644 --- a/src/main/runtime/orca-runtime-run-create-mobile-session-terminal.ts +++ b/src/main/runtime/orca-runtime-run-create-mobile-session-terminal.ts @@ -78,6 +78,7 @@ export class OrcaRuntimeWithRunCreateMobileSessionTerminal extends OrcaRuntimeWi env: startupCommand.env, envToDelete: startupCommand.envToDelete, startupCommandDelivery: startupCommand.startupCommandDelivery, + ...(startupCommand.launchFile ? { launchFile: startupCommand.launchFile } : {}), launchAgent: startupCommand.launchAgent, viewMode: opts.viewMode, targetGroupId: opts.targetGroupId, @@ -140,6 +141,7 @@ export class OrcaRuntimeWithRunCreateMobileSessionTerminal extends OrcaRuntimeWi ...(startupCommand.launchAgent ? { launchAgent: startupCommand.launchAgent } : {}), ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), startupCommandDelivery: startupCommand.startupCommandDelivery, + ...(startupCommand.launchFile ? { launchFile: startupCommand.launchFile } : {}), source: 'runtime-session', activate: opts.activate }) @@ -203,6 +205,7 @@ export class OrcaRuntimeWithRunCreateMobileSessionTerminal extends OrcaRuntimeWi env: startupCommand.env, envToDelete: startupCommand.envToDelete, startupCommandDelivery: startupCommand.startupCommandDelivery, + ...(startupCommand.launchFile ? { launchFile: startupCommand.launchFile } : {}), identity: { tabId: pendingSurface.tab.parentTabId, leafId: pendingSurface.tab.leafId }, launchAgent: startupCommand.launchAgent, viewMode: opts.viewMode, 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 584cd4f72e6c..bab0c83fd2df 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 @@ -104,16 +104,17 @@ 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. + // Only an agent that takes its text after start is pasted into; the rest carry it at launch. it.each([ - ['terminal', {}, deliverTerminalPrompt], - ['chat', STRUCTURED_PREFERENCE, commitChatPrompt] + ['terminal', {}, deliverTerminalPrompt, 'aider'], + ['chat', STRUCTURED_PREFERENCE, commitChatPrompt, EXISTING_LAUNCH.agent] ] as const)( 'selects the new %s for the caller before its prompt is delivered', - async (_surface, settings, deliver) => { + async (_surface, settings, deliver, agent) => { const runtime = selectionRuntime({ settings, terminalPaneKey: PANE_KEY }) await launch( - { ...EXISTING_LAUNCH, prompt: { text: 'Fix it.\nLog:', delivery: 'submit' } }, + { ...EXISTING_LAUNCH, agent, prompt: { text: 'Fix it.\nLog:', delivery: 'submit' } }, runtime ) 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 index d1549af6a610..89693f151d4a 100644 --- a/src/main/runtime/rpc/methods/agent-launch-prompt-carry.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-prompt-carry.test.ts @@ -1,7 +1,7 @@ /** - * 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. + * `agent.launch` puts an argv agent's prompt on the command that starts it and never pastes it into + * the running agent, however long or multi-line; only an agent that takes its text after start is + * pasted into. */ import { describe, expect, it, vi } from 'vitest' @@ -47,67 +47,39 @@ async function launch(params: unknown, runtime: AgentLaunchRuntimeStub) { 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', () => { +describe('a launch prompt reaches the agent on its command line', () => { const EXISTING = { agent: 'claude', target: { kind: 'existing', worktree: 'id:wt-7' } } + const CREATE = { kind: 'create-worktree', create: { repo: 'id:repo-1', name: 'task' } } - it('pastes it once the agent is ready when the line could not carry it', async () => { - const { runtime, sendTerminalAgentPrompt } = withPromptWriter( - runtimeStub({ settings: {}, lineCarriesPrompt: false }) - ) + it('hands a multi-line prompt to the terminal create and never pastes it', async () => { + const { runtime, sendTerminalAgentPrompt } = withPromptWriter(runtimeStub({ settings: {} })) 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()) + expect(runtime.createTerminal.mock.calls[0]?.[1]).toMatchObject({ startupPrompt: SUBMIT.text }) + expect(sendTerminalAgentPrompt).not.toHaveBeenCalled() }) - it('does not paste a prompt the line already carried', async () => { - const { runtime, sendTerminalAgentPrompt } = withPromptWriter( - runtimeStub({ settings: {}, lineCarriesPrompt: true }) - ) + it('hands it to an agent-first create’s startup terminal and never pastes it', async () => { + const { runtime, sendTerminalAgentPrompt } = withPromptWriter(runtimeStub({ settings: {} })) - const result = await launch({ ...EXISTING, prompt: SUBMIT }, runtime) + const result = await launch({ agent: 'claude', target: CREATE, prompt: SUBMIT }, runtime) expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'handed-to-terminal' }) + expect(runtime.createManagedWorktree.mock.calls[0]?.[0]).toMatchObject({ + startupPrompt: SUBMIT.text + }) 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 }) - ) + it('still pastes for an agent that takes its text only after start', async () => { + const { runtime, sendTerminalAgentPrompt } = withPromptWriter(runtimeStub({ settings: {} })) - const result = await launch( - { - agent: 'claude', - target: { kind: 'create-worktree', create: { repo: 'id:repo-1', name: 'task' } }, - prompt: SUBMIT - }, - runtime - ) + const result = await launch({ ...EXISTING, agent: 'aider', 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() + expect(runtime.createTerminal.mock.calls[0]?.[1]?.startupPrompt).toBeUndefined() + expect(sendTerminalAgentPrompt).toHaveBeenCalledWith('term_1', SUBMIT.text, expect.anything()) }) }) diff --git a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts index 7be7fe8034b9..79eb2e6af113 100644 --- a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts +++ b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts @@ -126,8 +126,8 @@ export function agentLaunchSurfaceFactory( // 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, - // 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. + // Offered to that launcher's startup plan; it rides unless it needs a launch file the agent + // is not known to read, and the runtime reports which so that prompt is pasted once ready. ...(startupPrompt ? { startupPrompt, 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 5af709eb0b1f..4c9c66c9fee2 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,12 @@ * 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 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. + * This is the half the launch command cannot serve. An argv agent's prompt rides that command, on + * the line or in a launch file (`carryLaunchPrompt`), so no readiness race exists for it. What + * reaches here is a `stdin-after-start` agent, whose CLI accepts no such argument; a prompt that + * needs a launch file its agent is not known to read; 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 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 d9c9c40e4696..0c923cfa130e 100644 --- a/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts +++ b/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts @@ -66,8 +66,8 @@ export function agentLaunchWorkspaceFactory( ...params, ...(startupAgent ? { startupAgent } : {}), // Only ever set alongside `startupAgent`, which is what the create requires; the - // executor offers it only to an agent that takes its prompt on argv, and it rides only - // when the typed line can carry it. + // executor offers it only to an agent that takes its prompt on argv, and it rides + // unless it needs a launch file the agent is not known to read. ...(startupPrompt ? { startupPrompt } : {}) }, { 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 909118c883fc..29f4500f3a37 100644 --- a/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts +++ b/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts @@ -35,7 +35,8 @@ 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. */ + /** Whether the runtime reports an offered prompt carried; unset reports it carried, as for an + * agent that reads its launch file. */ lineCarriesPrompt?: boolean } @@ -44,8 +45,8 @@ function reportPromptCarry( report: unknown, offered: unknown ): void { - if (typeof report === 'function' && offered && options.lineCarriesPrompt !== undefined) { - report(options.lineCarriesPrompt) + if (typeof report === 'function' && offered) { + report(options.lineCarriesPrompt ?? true) } } diff --git a/src/main/runtime/runtime-folder-worktree-create.ts b/src/main/runtime/runtime-folder-worktree-create.ts index 1b3cd2a829e4..876a3078637f 100644 --- a/src/main/runtime/runtime-folder-worktree-create.ts +++ b/src/main/runtime/runtime-folder-worktree-create.ts @@ -139,6 +139,7 @@ export async function createRuntimeFolderWorktree(args: { ...(args.createdWithAgent ? { launchAgent: args.createdWithAgent } : {}), ...(args.startup.viewMode ? { viewMode: args.startup.viewMode } : {}), startupCommandDelivery: args.startup.startupCommandDelivery, + ...(args.startup.launchFile ? { launchFile: args.startup.launchFile } : {}), telemetry: args.startup.telemetry, ...(shouldActivate ? {} : { surfaceOwner: false }) }) diff --git a/src/main/runtime/runtime-local-worktree-terminal-startup.ts b/src/main/runtime/runtime-local-worktree-terminal-startup.ts index e878eaa4b16d..ce332360730b 100644 --- a/src/main/runtime/runtime-local-worktree-terminal-startup.ts +++ b/src/main/runtime/runtime-local-worktree-terminal-startup.ts @@ -103,6 +103,7 @@ export async function startRuntimeLocalWorktreeTerminals(args: { ...(args.createdWithAgent ? { launchAgent: args.createdWithAgent } : {}), ...(sequencedStartup.viewMode ? { viewMode: sequencedStartup.viewMode } : {}), startupCommandDelivery: sequencedStartup.startupCommandDelivery, + ...(sequencedStartup.launchFile ? { launchFile: sequencedStartup.launchFile } : {}), telemetry: sequencedStartup.telemetry, ...ownerSurfacing(shouldActivate) }) diff --git a/src/main/runtime/runtime-managed-worktree-create-types.ts b/src/main/runtime/runtime-managed-worktree-create-types.ts index e92b8beba928..bd0e1d1c91ef 100644 --- a/src/main/runtime/runtime-managed-worktree-create-types.ts +++ b/src/main/runtime/runtime-managed-worktree-create-types.ts @@ -51,8 +51,8 @@ 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. */ + /** Main-internal: set by a caller that delivers an uncarried `startupPrompt` itself (one needing a + * launch file the agent is not known to read); reports whether the prompt rode the command. */ onStartupPromptCarry?: (carried: boolean) => void /** Per-launch inputs used when `startupAgent` is the created terminal surface. */ startupAgentArgs?: string | null diff --git a/src/main/runtime/runtime-pty-controller-contract.ts b/src/main/runtime/runtime-pty-controller-contract.ts index 60c1e18cf10a..899d5354310f 100644 --- a/src/main/runtime/runtime-pty-controller-contract.ts +++ b/src/main/runtime/runtime-pty-controller-contract.ts @@ -12,6 +12,7 @@ import type { ExecutionHostId } from '../../shared/execution-host' import type { PtyProviderBufferSnapshot, PtyProcessInfo, PtySpawnResult } from '../providers/types' import type { PtyProcessInspection } from '../providers/pty-process-inspection' import type { WriteSettlement } from '../../shared/pty-write-settlement' +import type { LaunchFile } from '../../shared/launch-prompt-file' import type { TerminalInputKind } from '../../shared/terminal-input-kind' export type RuntimePtyController = { @@ -49,6 +50,8 @@ export type RuntimePtyController = { launchAgent?: TuiAgent commandDelivery?: 'renderer' | 'provider' startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] + /** Written by the execution host before it types `command`; see PtySpawnOptions. */ + launchFile?: LaunchFile env?: Record envToDelete?: string[] resumeProviderSession?: AgentProviderSessionMetadata diff --git a/src/main/runtime/runtime-remote-managed-worktree-create.ts b/src/main/runtime/runtime-remote-managed-worktree-create.ts index 8d5b4a60a3d4..0f55d3db59c3 100644 --- a/src/main/runtime/runtime-remote-managed-worktree-create.ts +++ b/src/main/runtime/runtime-remote-managed-worktree-create.ts @@ -112,6 +112,7 @@ export async function createRuntimeRemoteManagedWorktree( ...(args.createdWithAgent ? { launchAgent: args.createdWithAgent } : {}), ...(sequencedStartup.viewMode ? { viewMode: sequencedStartup.viewMode } : {}), startupCommandDelivery: sequencedStartup.startupCommandDelivery, + ...(sequencedStartup.launchFile ? { launchFile: sequencedStartup.launchFile } : {}), telemetry: sequencedStartup.telemetry, ...ownerSurfacing(shouldActivate) }) diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index a39e7797583c..43fe59b34e1a 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -86,8 +86,6 @@ export type RuntimeStore = { agentDefaultArgs?: GlobalSettings['agentDefaultArgs'] agentDefaultEnv?: GlobalSettings['agentDefaultEnv'] terminalWindowsShell?: GlobalSettings['terminalWindowsShell'] - // Read by the launch-line carry rule to name the shell a local line is typed into. - terminalDefaultShell?: GlobalSettings['terminalDefaultShell'] floatingTerminalEnabled?: GlobalSettings['floatingTerminalEnabled'] agentStatusHooksEnabled?: GlobalSettings['agentStatusHooksEnabled'] terminalCopyTrimsGutter?: GlobalSettings['terminalCopyTrimsGutter'] diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index 0c60c11199a6..1c4d22826b62 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -13,6 +13,7 @@ import type { RuntimeTerminalWaitCondition } from '../../shared/runtime-types' import type { TuiAgent } from '../../shared/tui-agent' +import type { LaunchFile } from '../../shared/launch-prompt-file' import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' import type { RuntimeTerminalSend } from '../../shared/runtime-terminal-contracts' import type { RuntimeTerminalWriteOptions } from './runtime-terminal-writer' @@ -42,16 +43,20 @@ export type TerminalCreateOptions = { launchAgent?: TuiAgent startupAgent?: TuiAgent /** - * Initial text offered to `startupAgent`'s launch command, for an agent whose CLI takes a prompt + * Initial text for `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. 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. + * the prompt silently dropped. It rides that command, directly or as a pointer to a launch file + * (`startup-line-prompt-carry`), unless it needs a launch file the agent is not known to read: + * then the agent starts clean, `onStartupPromptCarry` says so, and the caller pastes it once + * ready. A caller with no such callback is refused instead. */ startupPrompt?: string /** Main-internal: whether `startupPrompt` rode the launch command. Called once the plan is built. */ onStartupPromptCarry?: (carried: boolean) => void + /** Written by the execution host before the launch line naming it is typed (`startupPrompt` + * carries its pointer). */ + launchFile?: LaunchFile /** * Replaces the Settings launch arguments for this `startupAgent` only; `null` means none at all. * diff --git a/src/main/runtime/runtime-worktree-agent-startup.test.ts b/src/main/runtime/runtime-worktree-agent-startup.test.ts index fe82665de5d7..0812841832b9 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.test.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.test.ts @@ -109,37 +109,64 @@ describe('buildWorktreeStartupForAgent host resolution', () => { }) describe('buildWorktreeStartupForAgent prompt carry', () => { - const build = (onPromptCarry?: (carried: boolean) => void, terminalDefaultShell = '/bin/bash') => + const build = (prompt: string, platform: NodeJS.Platform = 'linux') => buildWorktreeStartupForAgent({ repo: makeRepo({}), - settings: Object.assign({}, settings, { terminalDefaultShell }), + settings, agent: 'claude', - prompt: 'summarize the diff\nthen list the risks', - getLaunchPlatform: () => 'linux', - toSessionOptions: () => undefined, - ...(onPromptCarry ? { onPromptCarry } : {}) + prompt, + getLaunchPlatform: () => platform, + toSessionOptions: () => undefined }) - 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') + it('carries a multi-line prompt on the startup command, which the host stages', () => { + const result = build('summarize the diff\nthen list the risks') + expect(result.startup.command).toContain('summarize the diff\nthen list the risks') + expect(result.startup.launchFile).toBeUndefined() expect(result.followup).toBeUndefined() - expect(onPromptCarry).toHaveBeenCalledWith(false) }) - it('carries a short-lined multi-line prompt on a local zsh line, as main typed it', () => { - const onPromptCarry = vi.fn() - const result = build(onPromptCarry, '/bin/zsh') + it('hands a prompt past the argv ceiling to the host as a launch file', () => { + const prompt = 'x'.repeat(20_000) + const result = build(prompt) + expect(result.startup.launchFile?.content).toBe(prompt) + expect(result.startup.command).toContain(result.startup.launchFile?.placeholder) + expect(result.startup.command).not.toContain('xxxx') + }) - expect(result.startup.command).toContain('summarize the diff\nthen list the risks') - expect(onPromptCarry).toHaveBeenCalledWith(true) + it('points a multi-line prompt at a launch file on a Windows host, which cannot stage', () => { + const result = build('summarize the diff\nthen list the risks', 'win32') + expect(result.startup.launchFile?.content).toBe('summarize the diff\nthen list the risks') + expect(result.startup.command).not.toContain('summarize') + }) + + it('leaves a prompt needing a file the agent is not known to read as the follow-up paste', () => { + const prompt = 'x'.repeat(20_000) + const result = buildWorktreeStartupForAgent({ + repo: makeRepo({}), + settings, + agent: 'gemini', + prompt, + getLaunchPlatform: () => 'linux', + toSessionOptions: () => undefined + }) + expect(result.startup.launchFile).toBeUndefined() + expect(result.followup?.prompt).toBe(prompt) }) - 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') + it('reports that prompt uncarried, with no follow-up, to a caller that pastes it itself', () => { + const onPromptCarry = vi.fn() + const result = buildWorktreeStartupForAgent({ + repo: makeRepo({}), + settings, + agent: 'gemini', + prompt: 'x'.repeat(20_000), + getLaunchPlatform: () => 'linux', + toSessionOptions: () => undefined, + onPromptCarry + }) + expect(onPromptCarry).toHaveBeenCalledExactlyOnceWith(false) + expect(result.followup).toBeUndefined() }) }) diff --git a/src/main/runtime/runtime-worktree-agent-startup.ts b/src/main/runtime/runtime-worktree-agent-startup.ts index 139679ba7059..a8565e594e9e 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.ts @@ -9,8 +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 { nameLocalTypedLineShell } from './agent-launch-typed-line-shell' +import { planStartupWithLaunchPrompt } from '../../shared/startup-line-prompt-carry' import { detectInstalledAgentsWithShellPathHydration, detectRemoteAgents @@ -129,8 +128,8 @@ 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. */ + /** Set by a caller that pastes an uncarried prompt itself; reports whether it rode the command, + * and no follow-up is returned for it to paste twice. */ onPromptCarry?: (carried: boolean) => void } ): { @@ -150,29 +149,16 @@ export function buildWorktreeStartupForAgent( ...(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, - nameLocalTypedLineShell({ - isRemote: repoIsRemote(repo), - ...(settings.terminalDefaultShell - ? { defaultShellSetting: settings.terminalDefaultShell } - : {}) - }) - ) - startupPlan = offered.plan - if (startupPlan && prompt.trim()) { - environment.onPromptCarry(offered.promptCarried) - } - } else { - startupPlan = buildAgentStartupPlan({ ...planInputs, prompt, allowEmptyPromptLaunch: true }) - } + const { plan: startupPlan, launchFile } = planStartupWithLaunchPrompt( + planInputs, + environment.prompt ?? '' + ) if (!startupPlan) { throw new Error(`Could not build launch command for ${agent}.`) } + if (environment.onPromptCarry && environment.prompt?.trim()) { + environment.onPromptCarry(startupPlan.followupPrompt === null) + } return { agent, startup: { @@ -182,9 +168,10 @@ export function buildWorktreeStartupForAgent( ? { startupCommandDelivery: startupPlan.startupCommandDelivery } : {}), ...(startupPlan.env ? { env: startupPlan.env } : {}), + ...(launchFile ? { launchFile } : {}), telemetry: agentStartedTelemetry(agent, environment.launchSource) }, - ...(startupPlan.followupPrompt + ...(startupPlan.followupPrompt && !environment.onPromptCarry ? { followup: { expectedProcess: startupPlan.expectedProcess, diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index c7c46c7b0045..4bc489eb62e5 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -3,6 +3,7 @@ import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' +import type { LaunchFile } from '../../shared/launch-prompt-file' import type { TerminalInputKind } from '../../shared/terminal-input-kind' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import type { PtyListedSession, PtySessionListScope } from '../../shared/pty-listed-session' @@ -34,6 +35,7 @@ export type PtyApi = { launchToken?: string launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery + launchFile?: LaunchFile connectionId?: string | null worktreeId?: string sessionId?: string diff --git a/src/preload/api/pty-bridge-session-control.ts b/src/preload/api/pty-bridge-session-control.ts index 1f8639c58be0..5a6fa4cb46f4 100644 --- a/src/preload/api/pty-bridge-session-control.ts +++ b/src/preload/api/pty-bridge-session-control.ts @@ -1,6 +1,7 @@ import { ipcRenderer } from 'electron' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' +import type { LaunchFile } from '../../shared/launch-prompt-file' import type { TerminalInputKind } from '../../shared/terminal-input-kind' import type { AgentProviderSessionMetadata, @@ -32,6 +33,7 @@ export const ptySessionControlApi = { launchToken?: string launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery + launchFile?: LaunchFile connectionId?: string | null worktreeId?: string sessionId?: string diff --git a/src/relay/pty-handler-startup-command-staging.test.ts b/src/relay/pty-handler-startup-command-staging.test.ts index a058dfb7d726..0d750d5a69f2 100644 --- a/src/relay/pty-handler-startup-command-staging.test.ts +++ b/src/relay/pty-handler-startup-command-staging.test.ts @@ -1,5 +1,5 @@ import './mock-descendant-sweep' -import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -34,6 +34,7 @@ vi.mock('../main/shell-prompt-readiness-probe', () => ({ import type { PtyHandler } from './pty-handler' import { beginPtyHandlerTest, endPtyHandlerTest } from './pty-handler-test-harness' +import { buildLaunchFilePointer, planLaunchPrompt } from '../shared/launch-prompt-file' import type { MockDispatcher } from './pty-handler-test-harness' const describePosix = process.platform === 'win32' ? describe.skip : describe @@ -45,7 +46,7 @@ describePosix('relay startup command staging', () => { let stagingDir: string beforeEach(() => { - stagingDir = mkdtempSync(join(tmpdir(), 'orca-relay-staging-')) + stagingDir = realpathSync(mkdtempSync(join(tmpdir(), 'orca-relay-staging-'))) vi.stubEnv('TMPDIR', stagingDir) ;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest({ mockPtySpawn, @@ -111,4 +112,26 @@ describePosix('relay startup command staging', () => { .join('') expect(output).toContain('[orca] Could not stage the launch command (ENOENT') }) + + it('writes a launch file before typing the line that names it, and removes it on exit', async () => { + const { prompt, launchFile } = planLaunchPrompt('secret brief', { sensitive: true }) + await dispatcher.callRequest('pty.spawn', { + command: `claude '${prompt}'`, + commandDelivery: 'provider', + launchFile, + env: { SHELL: '/bin/zsh', ORCA_BRIEF: prompt } + }) + const [launchDir] = readdirSync(stagingDir) + const path = join(stagingDir, launchDir, 'task-context.md') + expect(readFileSync(path, 'utf8')).toBe('secret brief') + expect(mockPtySpawn.mock.calls[0]?.[2]?.env).toMatchObject({ + ORCA_BRIEF: buildLaunchFilePointer(path) + }) + await vi.advanceTimersByTimeAsync(50) + expect(mockPtySpawn.mock.results[0]?.value.write).toHaveBeenCalledWith( + `claude '${buildLaunchFilePointer(path)}'\n` + ) + mockPtyInstance.onExit.mock.calls.at(-1)?.[0]?.({ exitCode: 0 }) + expect(existsSync(join(stagingDir, launchDir))).toBe(false) + }) }) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 5210e9ba50f8..19e13a8062f4 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -39,6 +39,13 @@ import { startupStagingFailureNotice, type StartupCommandStaging } from '../shared/startup-command-staging' +import { parseLaunchFile } from '../shared/launch-prompt-file' +import { + LaunchFileUnavailableError, + removeLaunchFile, + writeLaunchFile, + type WrittenLaunchFile +} from '../shared/launch-file-writing' import { resolveSetupAgentSequenceLaunchCommand } from '../shared/setup-agent-sequencing' import { isPathInsideOrEqual, @@ -257,6 +264,8 @@ type ManagedPty = { startupCommand?: ManagedStartupCommand /** Kept past delivery: the typed line may never run if the shell dies first. */ stagedStartupCommand?: StartupCommandStaging + /** The agent may read its task file at any point in its life, so it goes with the PTY. */ + launchFile?: WrittenLaunchFile /** Whether this host armed the shell-ready marker for a renderer-delivered startup command. * Kept off `startupCommand`, which is dropped once delivered; the client reads it from the * spawn reply to skip waiting for a marker that will never come (fish, sh, Windows). */ @@ -311,6 +320,23 @@ type ManagedStartupCommand = { timer: ReturnType | null } +function writeRelayLaunchFile( + value: unknown, + command: string | undefined, + env: Record, + shell: string +): WrittenLaunchFile | undefined { + const launchFile = parseLaunchFile(value) + if (!launchFile) { + return undefined + } + if (isRelayWslShell(shell)) { + // Why: an agent inside the distro cannot read a path in the Windows temp directory. + throw new LaunchFileUnavailableError('not supported for WSL sessions') + } + return writeLaunchFile({ launchFile, command, env }) +} + // Why: Windows ConPTY rejects signals; forward them only on POSIX. function killPtyProcess(pty: IPty, signal: string): void { if (process.platform === 'win32') { @@ -337,6 +363,7 @@ function disposeManagedPty(managed: ManagedPty): void { } managed.disposed = true discardStagedStartupCommand(managed.stagedStartupCommand) + removeLaunchFile(managed.launchFile) // Why: clear the SIGKILL fallback timer so it can't fire pty.kill on an already-disposed instance. if (managed.killTimer) { clearTimeout(managed.killTimer) @@ -1976,7 +2003,7 @@ export class PtyHandler { // Why: kept so a restarted runtime can re-adopt this PTY under its original handle (survives revive). const terminalHandle = typeof env?.ORCA_TERMINAL_HANDLE === 'string' ? env.ORCA_TERMINAL_HANDLE : undefined - const command = typeof params.command === 'string' ? params.command : undefined + let command = typeof params.command === 'string' ? params.command : undefined const launchAgent = isTuiAgent(params.launchAgent) ? params.launchAgent : undefined const terminalWindowsWslDistro = typeof params.terminalWindowsWslDistro === 'string' ? params.terminalWindowsWslDistro : null @@ -1987,6 +2014,11 @@ export class PtyHandler { { id, paneKey, shell, command, launchAgent }, envToDelete ) + const launchFile = writeRelayLaunchFile(params.launchFile, command, spawnEnv, shell) + if (launchFile) { + command = launchFile.command + Object.assign(spawnEnv, launchFile.env) + } await applyRelayAgentWorkspaceTrust(params.agentWorkspaceTrust, launchAgent, spawnEnv, { wslShell: isRelayWslShell(shell) }) @@ -2038,6 +2070,7 @@ export class PtyHandler { !shouldProviderDeliverCommand && shellLaunch.supportsReadyMarker if (context?.signal?.aborted || context?.isStale()) { + removeLaunchFile(launchFile) // Why: cancellation remains side-effect-free until the exact native spawn seam. throw new Error('client_disconnected') } @@ -2065,6 +2098,7 @@ export class PtyHandler { ...this.conptyDllSpawnOptions() }) } catch (error) { + removeLaunchFile(launchFile) // Why: Windows loads conpty.node only on first spawn, so handle that late binding failure here. if (isMissingNodePtyNativeBinding(error)) { this.invalidatePtyModuleAfterBindingFailure() @@ -2109,6 +2143,7 @@ export class PtyHandler { gitCredentialPromptGuarded, ...(historyIsolationEnabled ? { historyIsolationEnabled: true } : {}), shellPath: shell, + ...(launchFile ? { launchFile } : {}), // Why the resolved one gates it: on a POSIX relay an override is rejected // outright, and storing one revive would only reject again is noise. ...(resolvedShellOverride ? { shellOverride } : {}), diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index c4857877e111..1712f42f0a79 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -21,6 +21,7 @@ import { planAgentSessionLaunch } from '@/lib/agent-session-launch-plan' import { beginStructuredAgentSessionProvisionalLaunch } from '@/lib/structured-agent-session-provisional-tab' import { getNewWorkspaceProjectGroupHostId } from '@/lib/new-workspace-project-options' import { useAppStore } from '@/store' +import { launchHostWritesLaunchFile } from '@/lib/launch-file-host' import { buildFolderWorkspaceLinkedStartupPlan, getFolderWorkspaceAgentLaunchPlatform, @@ -123,6 +124,9 @@ export async function submitFolderWorkspaceCreate({ platform: launchPlatform, shell: launchShell, isRemote: launchIsRemote, + hostWritesLaunchFile: launchHostWritesLaunchFile({ + activeRuntimeEnvironmentId: runtimeEnvironmentId + }), allowEmptyPromptLaunch: true }) : null @@ -189,6 +193,7 @@ export async function submitFolderWorkspaceCreate({ ...(startupPlan.startupCommandDelivery ? { startupCommandDelivery: startupPlan.startupCommandDelivery } : {}), + ...(startupPlan.launchFile ? { launchFile: startupPlan.launchFile } : {}), telemetry: { agent_kind: tuiAgentToAgentKind(quickAgent), launch_source: launchSource, diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts index ea5a4dda3aae..15e857f239b9 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts @@ -14,6 +14,8 @@ import { waitAtTerminalPtyPreSpawnE2EBarrier } from './terminal-pty-pre-spawn-e2 import type { IpcPtySessionHandlers } from './ipc-pty-session-handlers' import { isSshSessionGoneError } from './pty-connection/pty-connect-limits' import { spawnIpcPty } from './ipc-pty-spawn-request' +import { isLaunchFileUnavailableMessage } from '../../../../shared/launch-prompt-file' +import { showAgentLaunchNotStartedNotice } from '@/lib/agent-launch-prompt-not-delivered-notice' import type { IpcPtyTransportOptions, PtyConnectResult, PtyTransport } from './pty-transport-types' const SSH_PTY_CONNECTION_MISMATCH_MARKER = 'belongs to SSH connection' @@ -189,6 +191,12 @@ function handleConnectError( if (message.includes('was explicitly killed')) { return undefined } + // Why the transport's file: the spawn sends it unless the connect call named its own command. + const { launchFile } = context.transportOptions + if (launchFile && !options.command && isLaunchFileUnavailableMessage(message)) { + // The pane gets no shell; the notice holds the only copy of the prompt. + showAgentLaunchNotStartedNotice({ prompt: launchFile.content }) + } if (connectionId && options.sessionId && message.includes(SSH_PTY_CONNECTION_MISMATCH_MARKER)) { // Why not `sessionExpired`: this string is minted by `toRelaySshPtyId`/`toAppSshPtyId` from a // pure client-side id comparison, before any relay is contacted — it reports that the id is not diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-spawn-request.ts b/src/renderer/src/components/terminal-pane/ipc-pty-spawn-request.ts index ff69ba35a860..283dd140fc6c 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-spawn-request.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-spawn-request.ts @@ -27,6 +27,7 @@ export async function spawnIpcPty( launchToken, launchAgent, startupCommandDelivery, + launchFile, connectionId, worktreeId, tabId, @@ -71,6 +72,8 @@ export async function spawnIpcPty( startupCommandDelivery: connectOptions.startupCommandDelivery ?? startupCommandDelivery } : {}), + // Only alongside the queued command that names it by placeholder. + ...(launchFile && !connectOptions.command ? { launchFile } : {}), ...(connectionId ? { connectionId } : {}), ...(admittedSessionId ? { sessionId: admittedSessionId } : {}), ...(connectOptions.initiallyHidden ? { initiallyHidden: true } : {}), diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index 27f377f30c4c..9fc0f528040d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -7,6 +7,7 @@ import type { AgentCompletionStatusSnapshot } from './agent-completion-coordinat import type { EventProps } from '../../../../shared/telemetry-events' import type { TerminalColorSchemeMode } from '../../../../shared/terminal-color-scheme-protocol' import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' +import type { LaunchFile } from '../../../../shared/launch-prompt-file' import type { TuiAgent } from '../../../../shared/tui-agent' import type { SetupSplitDirection } from '../../../../shared/worktree/launch-types' import type { @@ -25,6 +26,7 @@ export type PtyPaneStartup = { * semantics before the submit Enter. */ delivery?: 'terminal-paste' startupCommandDelivery?: StartupCommandDelivery + launchFile?: LaunchFile env?: Record envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts index e3731a85e64b..84b98317b549 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts @@ -62,9 +62,9 @@ export function installPtyInputRecovery(session: ConnectPanePtySession): void { : {}), env: session.paneEnv, ...(session.paneStartup?.envToDelete ? { envToDelete: session.paneStartup.envToDelete } : {}), - command: session.shouldDeliverStartupViaTerminalPaste - ? undefined - : session.paneStartup?.command, + ...(session.shouldDeliverStartupViaTerminalPaste + ? {} + : { command: session.paneStartup?.command, launchFile: session.paneStartup?.launchFile }), ...(session.shouldUseProviderSshStartupDelivery ? { commandDelivery: 'provider' as const } : {}), diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index dce25fd547cc..887a2b6ff439 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -8,6 +8,7 @@ import type { AgentPromptDelivery } from '../../../../shared/agent-session-host-authority' import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' +import type { LaunchFile } from '../../../../shared/launch-prompt-file' import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' import type { EventProps } from '../../../../shared/telemetry-events' import type { TerminalInputKind } from '../../../../shared/terminal-input-kind' @@ -267,6 +268,8 @@ export type IpcPtyTransportOptions = { launchToken?: string launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery + /** Local spawns only: a remote runtime writes no file the renderer names. */ + launchFile?: LaunchFile connectionId?: string | null executionHostId?: ExecutionHostId | null worktreeId?: string diff --git a/src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts b/src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts index 83fd13e78117..f0505958f964 100644 --- a/src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts +++ b/src/renderer/src/hooks/composer-state/composer-submit-orchestration.ts @@ -81,6 +81,7 @@ export function useComposerSubmitOrchestration( selectedRepoExecutionHostId: target.runtimeTargetSelection.selectedRepoExecutionHostId, selectedRepoIsGit: target.runtimeTargetSelection.selectedRepoIsGit, selectedRepoIsRemote: target.runtimeTargetSelection.selectedRepoIsRemote, + selectedRepoSettings: target.runtimeTargetSelection.selectedRepoSettings, selectedRepoStartupShell: target.runtimeTargetSelection.selectedRepoStartupShell, settings: target.composerTargetStore.settings, smartNameMode: target.workspaceIdentityState.smartNameMode, diff --git a/src/renderer/src/hooks/composer-state/full-creation-startup.test.ts b/src/renderer/src/hooks/composer-state/full-creation-startup.test.ts new file mode 100644 index 000000000000..75b08c0d9ec8 --- /dev/null +++ b/src/renderer/src/hooks/composer-state/full-creation-startup.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { buildAgentStartupPlan } from '@/lib/tui-agent-startup' +import { buildFullCreationStartup } from './full-creation-startup' +import { tuiAgentToAgentKind } from '../../../../shared/agent-kind' + +describe('the full composer’s renderer-spawned startup', () => { + // Why: SSH repos, folder repos, repos with default tabs and a failed backend spawn all take this + // path; without the file the agent is pointed at nothing and the prompt is lost. + it('carries the launch file its command points at', () => { + const startupPlan = buildAgentStartupPlan({ + agent: 'claude', + prompt: 'x'.repeat(200_000), + cmdOverrides: {}, + platform: 'linux', + isRemote: true + }) + const startup = buildFullCreationStartup({ + startupPlan, + backendSpawnedStartup: false, + agent: 'claude', + shouldSeedInitialAgentStatus: false, + prompt: 'x'.repeat(200_000), + telemetry: { + agent_kind: tuiAgentToAgentKind('claude'), + launch_source: 'new_workspace_composer', + request_kind: 'new' + } + }) + expect(startupPlan?.launchFile).toBeDefined() + expect(startup?.launchFile).toEqual(startupPlan?.launchFile) + expect(startup?.command).toContain(startupPlan?.launchFile?.placeholder) + }) +}) diff --git a/src/renderer/src/hooks/composer-state/full-creation-startup.ts b/src/renderer/src/hooks/composer-state/full-creation-startup.ts index 43c21661a763..a00666c2112c 100644 --- a/src/renderer/src/hooks/composer-state/full-creation-startup.ts +++ b/src/renderer/src/hooks/composer-state/full-creation-startup.ts @@ -23,6 +23,8 @@ export function buildFullCreationStartup(args: { ...(args.startupPlan.startupCommandDelivery ? { startupCommandDelivery: args.startupPlan.startupCommandDelivery } : {}), + // Why: the command points at this file; without it the agent is told to read nothing. + ...(args.startupPlan.launchFile ? { launchFile: args.startupPlan.launchFile } : {}), ...(args.shouldSeedInitialAgentStatus ? { initialAgentStatus: { agent: args.agent, prompt: args.prompt.trim() } } : {}), diff --git a/src/renderer/src/hooks/composer-state/full-submit-preparation.ts b/src/renderer/src/hooks/composer-state/full-submit-preparation.ts index a1120fccf4e0..e3136a48b398 100644 --- a/src/renderer/src/hooks/composer-state/full-submit-preparation.ts +++ b/src/renderer/src/hooks/composer-state/full-submit-preparation.ts @@ -16,6 +16,7 @@ type FullSubmitPreparationInput = Pick< | 'selectedRepoExecutionHostId' | 'selectedRepoIsGit' | 'selectedRepoIsRemote' + | 'selectedRepoSettings' | 'selectedRepoStartupShell' | 'settings' | 'smartNameMode' @@ -38,6 +39,7 @@ import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import type { AgentStartedTelemetry } from '@/lib/worktree-startup-payload' import { tuiAgentToAgentKind } from '@/lib/telemetry' +import { launchHostWritesLaunchFile } from '@/lib/launch-file-host' import type { PendingSmartGitHubSubmitResolution } from './source-selection-decisions' export function useFullSubmitPreparation(input: FullSubmitPreparationInput) { @@ -56,6 +58,7 @@ export function useFullSubmitPreparation(input: FullSubmitPreparationInput) { selectedRepoExecutionHostId, selectedRepoIsGit, selectedRepoIsRemote, + selectedRepoSettings, selectedRepoStartupShell, settings, smartNameMode, @@ -199,7 +202,8 @@ export function useFullSubmitPreparation(input: FullSubmitPreparationInput) { ), platform: selectedRepoAgentLaunchPlatform, shell: selectedRepoStartupShell, - isRemote: selectedRepoIsRemote + isRemote: selectedRepoIsRemote, + hostWritesLaunchFile: launchHostWritesLaunchFile(selectedRepoSettings) }) const shouldSeedInitialAgentStatus = @@ -222,6 +226,7 @@ export function useFullSubmitPreparation(input: FullSubmitPreparationInput) { ...(startupPlan.startupCommandDelivery ? { startupCommandDelivery: startupPlan.startupCommandDelivery } : {}), + ...(startupPlan.launchFile ? { launchFile: startupPlan.launchFile } : {}), telemetry: composerTelemetry } : undefined @@ -256,6 +261,7 @@ export function useFullSubmitPreparation(input: FullSubmitPreparationInput) { selectedRepoExecutionHostId, selectedRepoIsGit, selectedRepoIsRemote, + selectedRepoSettings, selectedRepoStartupShell, settings, smartNameMode, diff --git a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts index 324bf694025f..edca524131bd 100644 --- a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts +++ b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts @@ -40,6 +40,7 @@ import { useAppStore } from '@/store' import { settleComposerSubmit } from '@/lib/composer-submit-cancellation' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { launchHostWritesLaunchFile } from '@/lib/launch-file-host' import { runBackgroundWorktreeCreation } from '@/lib/worktree-creation-flow' import { translate } from '@/i18n/i18n' import { resolveQuickCreateLinkedWorkItemPrompt } from '@/lib/linked-work-item-context' @@ -139,6 +140,7 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) { platform: selectedRepoAgentLaunchPlatform, shell: selectedRepoStartupShell, isRemote: selectedRepoIsRemote, + hostWritesLaunchFile: launchHostWritesLaunchFile(selectedRepoSettings), telemetrySource }) diff --git a/src/renderer/src/hooks/composer-state/quick-startup-plan.ts b/src/renderer/src/hooks/composer-state/quick-startup-plan.ts index 486550197814..30adf55de722 100644 --- a/src/renderer/src/hooks/composer-state/quick-startup-plan.ts +++ b/src/renderer/src/hooks/composer-state/quick-startup-plan.ts @@ -22,6 +22,7 @@ export type QuickComposerStartupInput = { platform: NodeJS.Platform shell: AgentStartupShell | null | undefined isRemote: boolean + hostWritesLaunchFile: boolean telemetrySource: WorktreeCreationRequest['telemetrySource'] } @@ -91,6 +92,7 @@ export function buildQuickComposerStartup(input: QuickComposerStartupInput): Qui platform: input.platform, shell: input.shell ?? undefined, isRemote: input.isRemote, + hostWritesLaunchFile: input.hostWritesLaunchFile, allowEmptyPromptLaunch: true }) if (startupPlan && draftPrompt) { @@ -116,6 +118,7 @@ export function buildQuickComposerStartup(input: QuickComposerStartupInput): Qui ...(startupPlan.startupCommandDelivery ? { startupCommandDelivery: startupPlan.startupCommandDelivery } : {}), + ...(startupPlan.launchFile ? { launchFile: startupPlan.launchFile } : {}), ...(telemetry ? { telemetry } : {}) } : undefined diff --git a/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge.ts index 7a0190d7f96a..78649ba84359 100644 --- a/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge.ts @@ -115,7 +115,8 @@ export function registerTerminalRequestIpcBridge(unsubs: (() => void)[]): void { ...(data.launchAgent ? { launchAgent: data.launchAgent } : {}), ...(data.startupCommandDelivery ? { startupCommandDelivery: data.startupCommandDelivery } - : {}) + : {}), + ...(data.launchFile ? { launchFile: data.launchFile } : {}) }) } window.api.ui.replyTerminalCreate({ diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 48f6eec3cb18..a79fb2990695 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -642,6 +642,23 @@ "refreshCliRegistration": "Refresh CLI registration status and try again." } } + }, + "launch": { + "prompt": { + "not": { + "delivered": { + "notice": { + "message": "The agent started, but your prompt wasn't sent. Copy it and paste it once the agent is ready.", + "copy": "Copy prompt" + } + }, + "started": { + "notice": { + "message": "The agent wasn't started: Orca couldn't write the file that carries your prompt. Copy the prompt and launch again." + } + } + } + } } }, "remotePairingCopy": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 84dffe050fdf..555445f4816a 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -401,6 +401,23 @@ "refreshCliRegistration": "Actualiza el estado de registro de la CLI e inténtalo de nuevo." } } + }, + "launch": { + "prompt": { + "not": { + "delivered": { + "notice": { + "message": "El agente se inició, pero tu prompt no se envió. Cópialo y pégalo cuando el agente esté listo.", + "copy": "Copiar prompt" + } + }, + "started": { + "notice": { + "message": "El agente no se inició: Orca no pudo escribir el archivo que contiene tu prompt. Copia el prompt e inícialo de nuevo." + } + } + } + } } }, "remotePairingCopy": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index d8086183f219..34857cb55bf0 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -611,6 +611,23 @@ "refreshCliRegistration": "Actualisez le statut d'enregistrement du CLI et réessayez." } } + }, + "launch": { + "prompt": { + "not": { + "delivered": { + "notice": { + "message": "L’agent a démarré, mais votre prompt n’a pas été envoyé. Copiez-le et collez-le quand l’agent est prêt.", + "copy": "Copier le prompt" + } + }, + "started": { + "notice": { + "message": "L’agent n’a pas démarré : Orca n’a pas pu écrire le fichier qui contient votre prompt. Copiez le prompt et relancez." + } + } + } + } } }, "remotePairingCopy": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 0997b8b616bb..e1b10ffa8c9c 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -512,6 +512,23 @@ "refreshCliRegistration": "CLI の登録状態を更新してから、もう一度お試しください。" } } + }, + "launch": { + "prompt": { + "not": { + "delivered": { + "notice": { + "message": "Agent は起動しましたが、プロンプトは送信されませんでした。Agent の準備ができたらコピーして貼り付けてください。", + "copy": "プロンプトをコピー" + } + }, + "started": { + "notice": { + "message": "Agent は起動しませんでした。プロンプトを渡すファイルを Orca が書き込めませんでした。プロンプトをコピーして、もう一度起動してください。" + } + } + } + } } }, "remotePairingCopy": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 29149cd5a652..02a7b6200f45 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -512,6 +512,23 @@ "refreshCliRegistration": "CLI 등록 상태를 새로 고친 후 다시 시도하세요." } } + }, + "launch": { + "prompt": { + "not": { + "delivered": { + "notice": { + "message": "에이전트가 시작되었지만 프롬프트는 전송되지 않았습니다. 에이전트가 준비되면 복사해서 붙여 넣으세요.", + "copy": "프롬프트 복사" + } + }, + "started": { + "notice": { + "message": "에이전트가 시작되지 않았습니다. Orca가 프롬프트를 담을 파일을 쓰지 못했습니다. 프롬프트를 복사한 뒤 다시 시작하세요." + } + } + } + } } }, "remotePairingCopy": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index d907237dea88..033b8e741c6e 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -512,6 +512,23 @@ "refreshCliRegistration": "请刷新 CLI 注册状态后重试。" } } + }, + "launch": { + "prompt": { + "not": { + "delivered": { + "notice": { + "message": "代理已启动,但你的提示词未发送。请复制提示词,并在代理就绪后粘贴。", + "copy": "复制提示词" + } + }, + "started": { + "notice": { + "message": "代理未启动:Orca 无法写入承载你的提示词的文件。请复制提示词并重新启动。" + } + } + } + } } }, "remotePairingCopy": { diff --git a/src/renderer/src/lib/agent-background-draft-delivery.ts b/src/renderer/src/lib/agent-background-draft-delivery.ts index 6976745c30db..af53ef83f48b 100644 --- a/src/renderer/src/lib/agent-background-draft-delivery.ts +++ b/src/renderer/src/lib/agent-background-draft-delivery.ts @@ -1,6 +1,6 @@ import type { TuiAgent } from '../../../shared/tui-agent' import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft' -import { showAutomationPromptNotSentToast } from '@/lib/agent-background-session-timeout-toast' +import { showAgentLaunchPromptNotDeliveredNotice } from '@/lib/agent-launch-prompt-not-delivered-notice' export function scheduleAgentBackgroundDraft( tabId: string, @@ -12,6 +12,6 @@ export function scheduleAgentBackgroundDraft( content, agent, submit: true, - onTimeout: () => showAutomationPromptNotSentToast(agent) + onTimeout: () => showAgentLaunchPromptNotDeliveredNotice({ agent, prompt: content }) }) } diff --git a/src/renderer/src/lib/agent-launch-caller-permissions.test.ts b/src/renderer/src/lib/agent-launch-caller-permissions.test.ts index 829932b6e476..7e7ffee91828 100644 --- a/src/renderer/src/lib/agent-launch-caller-permissions.test.ts +++ b/src/renderer/src/lib/agent-launch-caller-permissions.test.ts @@ -79,9 +79,10 @@ describe('agent launch caller arguments and permission bypass', () => { // A call site that names arguments replaces the shipped default outright; one that names none // inherits it. Both shapes must stay visible on the queued command. const args = profile.args.agentArgs === undefined ? `'${CODEX_BYPASS}'` : "'--model' 'gpt-5.5'" - // Why: quick-command is the ONE call site that carries a prompt and names no delivery mode, so - // it takes the default auto-submit path and folds the prompt into argv for an argv agent. - const argvPrompt = profile.id === 'quick-command' ? ` '${profile.args.prompt}'` : '' + // Why: Codex takes its prompt on argv, so every call site that submits one carries it there; + // only a draft, which Codex cannot prefill, is left for the paste. + const ridesArgv = profile.args.prompt !== undefined && profile.args.promptDelivery !== 'draft' + const argvPrompt = ridesArgv ? ` '${profile.args.prompt}'` : '' expect(command).toBe(`codex ${args}${argvPrompt}`) }) diff --git a/src/renderer/src/lib/agent-launch-caller-prompt-transport.test.ts b/src/renderer/src/lib/agent-launch-caller-prompt-transport.test.ts index 4b03a9867be0..e1a3d474de97 100644 --- a/src/renderer/src/lib/agent-launch-caller-prompt-transport.test.ts +++ b/src/renderer/src/lib/agent-launch-caller-prompt-transport.test.ts @@ -69,11 +69,12 @@ const TRANSPORT_TABLE: readonly { }[] = [ { agent: 'codex', mode: 'argv', promptDelivery: 'auto-submit', transport: 'argv', submits: true }, { agent: 'codex', mode: 'argv', promptDelivery: 'draft', transport: 'paste', submits: false }, + // Waiting for readiness is only for an agent that cannot take the prompt at launch. { agent: 'codex', mode: 'argv', promptDelivery: 'submit-after-ready', - transport: 'paste', + transport: 'argv', submits: true }, // Claude is the argv agent with a native draft flag, so its draft rides argv instead of pasting. @@ -141,7 +142,7 @@ describe('agent launch caller prompt transport', () => { it.each(cases)( 'carries the prompt %s sends on the transport its mode picks', - async (id, profile) => { + async (_id, profile) => { const result = await launch(profile) if (profile.args.prompt === undefined) { @@ -150,30 +151,22 @@ describe('agent launch caller prompt transport', () => { expect(queuedStartupCommand(store)).not.toContain(PROMPT) return } - // quick-command is the only prompt-carrying call site that names no delivery mode, so its text - // rides argv; every other one asks for draft or submit-after-ready and pastes. - const ridesArgv = id === 'quick-command' + // Codex takes its prompt on argv, so only a draft, which it cannot prefill, is pasted. + const ridesArgv = profile.args.promptDelivery !== 'draft' expect(result?.pasteDraftAfterLaunch).toBe(!ridesArgv) expect(queuedStartupCommand(store)?.includes(PROMPT)).toBe(ridesArgv) } ) it.each(cases)( - 'exposes a delivery promise to %s only for submit-after-ready', + 'exposes no delivery promise to %s, whose Codex prompt rides argv or is a draft', async (_id, profile) => { const result = await launch(profile) - // Why: three call sites branch on this promise being present; a draft launch must NOT get one, - // because the composer owns the text until the user sends it. - const expectsPromise = - profile.args.prompt !== undefined && profile.args.promptDelivery === 'submit-after-ready' - expect(result?.promptDeliveryResult !== undefined).toBe(expectsPromise) - if (expectsPromise) { - await expect(result?.promptDeliveryResult).resolves.toEqual({ - delivered: true, - failureNotified: false - }) - } + // Why: three call sites branch on this promise being present. A draft launch must NOT get one, + // because the composer owns the text until the user sends it, and a prompt the launch command + // carries has no paste to wait for. + expect(result?.promptDeliveryResult).toBeUndefined() } ) @@ -256,8 +249,9 @@ describe('agent launch caller prompt transport', () => { const onPromptDelivered = vi.fn() const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + // Amp takes its text only after start, so it is the agent a submit-after-ready launch pastes into. const result = launchAgentInNewTab({ - agent: 'codex', + agent: 'amp', worktreeId: 'wt-1', prompt: PROMPT, promptDelivery: 'submit-after-ready', diff --git a/src/renderer/src/lib/agent-launch-prompt-delivery.ts b/src/renderer/src/lib/agent-launch-prompt-delivery.ts index ad9e1f1d8c84..5010a5b27fb2 100644 --- a/src/renderer/src/lib/agent-launch-prompt-delivery.ts +++ b/src/renderer/src/lib/agent-launch-prompt-delivery.ts @@ -25,6 +25,24 @@ export function seedNativeChatLaunchDraftForAgentTab(args: { }) } +/** Seed the chat's copy of a submitted launch prompt; no-op for agents without a native-chat renderer. */ +export function seedNativeChatLaunchPromptForAgentTab(args: { + tabId: string + agent: TuiAgent + text: string +}): boolean { + if (args.text.trim().length === 0 || !isNativeChatSupportedAgent(args.agent)) { + return false + } + useAppStore.getState().seedNativeChatLaunchPrompt({ + tabId: args.tabId, + agent: args.agent, + text: args.text, + createdAt: Date.now() + }) + return true +} + export function deliverLaunchPromptToAgentTab(args: { tabId: string agent: TuiAgent @@ -39,16 +57,9 @@ export function deliverLaunchPromptToAgentTab(args: { const { tabId, agent, content, submit, forcePaste, timeoutMs, onTimeout, onUnconfirmedDelivery } = args const shouldSeed = - submit === true && content.trim().length > 0 && isNativeChatSupportedAgent(agent) + submit === true && seedNativeChatLaunchPromptForAgentTab({ tabId, agent, text: content }) - if (shouldSeed) { - useAppStore.getState().seedNativeChatLaunchPrompt({ - tabId, - agent, - text: content, - createdAt: Date.now() - }) - } else if (submit !== true) { + if (submit !== true) { // Why: an unsubmitted draft lives only in the TUI input buffer; seed the // chat-composer copy so the context isn't invisible in the GUI view. seedNativeChatLaunchDraftForAgentTab({ tabId, agent, text: content }) diff --git a/src/renderer/src/lib/agent-launch-prompt-not-delivered-notice.test.ts b/src/renderer/src/lib/agent-launch-prompt-not-delivered-notice.test.ts new file mode 100644 index 000000000000..ca0a77940e6f --- /dev/null +++ b/src/renderer/src/lib/agent-launch-prompt-not-delivered-notice.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ message: vi.fn(), track: vi.fn() })) + +vi.mock('sonner', () => ({ toast: { message: mocks.message } })) +vi.mock('@/lib/telemetry', () => ({ + track: mocks.track, + tuiAgentToAgentKind: (agent: string) => agent +})) + +import { + showAgentLaunchNotStartedNotice, + showAgentLaunchPromptNotDeliveredNotice +} from './agent-launch-prompt-not-delivered-notice' + +describe('showAgentLaunchPromptNotDeliveredNotice', () => { + beforeEach(() => { + mocks.message.mockReset() + }) + + it('stays up until dismissed, since its Copy prompt action is the only copy of the prompt', () => { + showAgentLaunchPromptNotDeliveredNotice({ agent: 'claude', prompt: 'fix the hook' }) + + const options = mocks.message.mock.calls[0]?.[1] + expect(options?.duration).toBe(Infinity) + expect(options?.action?.label).toBe('Copy prompt') + }) + + it('tells a user whose agent never started that it did not, with the prompt to copy', async () => { + const writeClipboardText = vi.fn(async () => {}) + vi.stubGlobal('window', { api: { ui: { writeClipboardText } } }) + showAgentLaunchNotStartedNotice({ prompt: 'fix the hook' }) + + const [message, options] = mocks.message.mock.calls[0] ?? [] + expect(message).toMatch(/wasn't started/) + expect(options?.duration).toBe(Infinity) + options?.action?.onClick() + expect(writeClipboardText).toHaveBeenCalledWith('fix the hook') + vi.unstubAllGlobals() + }) +}) diff --git a/src/renderer/src/lib/agent-launch-prompt-not-delivered-notice.ts b/src/renderer/src/lib/agent-launch-prompt-not-delivered-notice.ts new file mode 100644 index 000000000000..06f1afddd301 --- /dev/null +++ b/src/renderer/src/lib/agent-launch-prompt-not-delivered-notice.ts @@ -0,0 +1,55 @@ +import { toast } from 'sonner' +import { track, tuiAgentToAgentKind } from '@/lib/telemetry' +import { translate } from '@/i18n/i18n' +import type { TuiAgent } from '../../../shared/tui-agent' + +/** + * The agent started but the host kept its prompt: the agent never showed it was ready for input, + * or a dialog it opened was still up when the wait ran out. Nothing was typed into the pane, so the + * user is handed the text to paste themselves. + */ +export function showAgentLaunchPromptNotDeliveredNotice(args: { + agent: TuiAgent + prompt: string +}): void { + showPromptCopyNotice( + translate( + 'auto.lib.agent.launch.prompt.not.delivered.notice.message', + "The agent started, but your prompt wasn't sent. Copy it and paste it once the agent is ready." + ), + args.prompt + ) + track('agent_error', { + error_class: 'paste_readiness_timeout', + agent_kind: tuiAgentToAgentKind(args.agent) + }) +} + +/** + * The host refused to start the agent because it could not write the launch file that carries the + * prompt. Nothing ran, so the user is handed the text to launch again with. + */ +export function showAgentLaunchNotStartedNotice(args: { prompt: string }): void { + showPromptCopyNotice( + translate( + 'auto.lib.agent.launch.prompt.not.started.notice.message', + "The agent wasn't started: Orca couldn't write the file that carries your prompt. Copy the prompt and launch again." + ), + args.prompt + ) +} + +function showPromptCopyNotice(message: string, prompt: string): void { + toast.message(message, { + // The action is the only copy of the prompt, so the notice stays until the user dismisses it. + duration: Infinity, + action: { + label: translate('auto.lib.agent.launch.prompt.not.delivered.notice.copy', 'Copy prompt'), + onClick: () => { + void window.api.ui.writeClipboardText(prompt).catch((error: unknown) => { + console.error('Could not copy the launch prompt', error) + }) + } + } + }) +} diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index de115c10f453..f0df326b3160 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -133,7 +133,7 @@ export async function pasteDraftWhenAgentReady(args: { onUnconfirmedDelivery?.() } - return await sendBracketedPasteToAgent({ + const sent = await sendBracketedPasteToAgent({ settings, ptyId, content, @@ -142,6 +142,11 @@ export async function pasteDraftWhenAgentReady(args: { // Why launch: this delivers the prompt or draft an agent is started with. inputKind: 'launch' }) + if (!sent) { + // Why: a write that never landed drops the text as surely as a readiness timeout. + onTimeout?.() + } + return sent } export async function pasteDraftToAgentPtyWhenReady(args: { @@ -187,7 +192,7 @@ export async function pasteDraftToAgentPtyWhenReady(args: { onUnconfirmedDelivery?.() } - return await sendBracketedPasteToAgent({ + const sent = await sendBracketedPasteToAgent({ settings, ptyId, content, @@ -196,6 +201,11 @@ export async function pasteDraftToAgentPtyWhenReady(args: { // Why launch: this delivers the prompt or draft an agent is started with. inputKind: 'launch' }) + if (!sent) { + // Why: a write that never landed drops the text as surely as a readiness timeout. + onTimeout?.() + } + return sent } export async function submitPromptToAgentPty(args: { diff --git a/src/renderer/src/lib/command-code-prompt-status-seed.ts b/src/renderer/src/lib/command-code-prompt-status-seed.ts deleted file mode 100644 index 5157c8c05833..000000000000 --- a/src/renderer/src/lib/command-code-prompt-status-seed.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { useAppStore } from '@/store' -import { makePaneKey } from '../../../shared/stable-pane-id' -import { getConnectionIdFromState } from './connection-owner-resolution' -import { resolveLiveAgentStatusConnectionRouting } from './agent-status-connection-ownership' -import { rendererAgentStatusObservations } from './renderer-agent-status-observations' - -/** - * Why: Command Code has no prompt-submit hook, so when Orca submits a generated - * prompt after the TUI is ready, seed `working` at delivery time so sidebar and - * activity surfaces don't stay idle until the first real hook event arrives. - */ -export function seedCommandCodeSubmittedPromptStatus( - worktreeId: string, - tabId: string, - prompt: string -): void { - const state = useAppStore.getState() - const leafId = state.terminalLayoutsByTabId[tabId]?.activeLeafId - if (!leafId || !(state.tabsByWorktree[worktreeId] ?? []).some((tab) => tab.id === tabId)) { - return - } - const paneKey = makePaneKey(tabId, leafId) - const ptyId = state.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId?.[leafId] - if (!ptyId) { - return - } - const routing = resolveLiveAgentStatusConnectionRouting({ - state, - paneKey, - ptyId, - expectedConnectionId: getConnectionIdFromState(state, worktreeId) - }) - if (!routing) { - return - } - try { - state.setAgentStatus( - paneKey, - { - state: 'working', - prompt, - agentType: 'command-code', - observation: rendererAgentStatusObservations.observe(paneKey, { - origin: 'process', - observedAt: Date.now(), - kind: 'transition' - }) - }, - undefined, - undefined, - routing - ) - } catch { - // Best-effort UI seed. Real hooks still own refinement/completion. - } -} diff --git a/src/renderer/src/lib/launch-agent-background-session-remote.test.ts b/src/renderer/src/lib/launch-agent-background-session-remote.test.ts index e6480d197f7a..d2a54bf3d7a9 100644 --- a/src/renderer/src/lib/launch-agent-background-session-remote.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session-remote.test.ts @@ -153,6 +153,20 @@ describe('launchAgentBackgroundSession remote runtime and SSH startup delivery', ) }) + // Why: past the argv ceiling an automation prompt failed with "Argument list too long". + it('hands an automation prompt past the argv ceiling to the SSH host as a launch file', async () => { + state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + const prompt = 'z'.repeat(140_055) + + await launchAgentBackgroundSession({ agent: 'claude', worktreeId: 'wt-1', prompt }) + + const spawned = mockSpawn.mock.calls[0]?.[0] + expect(spawned?.launchFile?.content).toBe(prompt) + expect(spawned?.command).toContain(spawned?.launchFile?.placeholder) + expect(spawned?.command.length).toBeLessThan(1_000) + }) + it.each(['claude', 'codex'] as const)( 'has the relay type the %s launch line instead of writing it from the renderer', async (agent) => { @@ -357,6 +371,68 @@ describe('launchAgentBackgroundSession remote runtime and SSH startup delivery', ) }) + // Why: a paired host is sent a command and never a launch file, so a pointer would name nothing. + it('pastes a prompt an old paired host would need a launch file for, instead of pointing at one', async () => { + useRemoteAgentBackgroundRuntime(state) + mockGetAgentLaunchPlatformForRepo.mockReturnValue('win32') + mockRuntimeEnvironmentTransportCall.mockImplementation((request: { method: string }) => { + if (request.method === 'status.get') { + return Promise.resolve({ + id: 'status', + ok: true, + result: { + runtimeId: 'old-runtime', + graphStatus: 'ready', + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: [] + } + }) + } + return Promise.resolve({ + id: 'create', + ok: true, + result: { terminal: { handle: 'legacy-terminal-1' } } + }) + }) + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await launchAgentBackgroundSession({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'fix the build\nthen run the tests' + }) + + const create = mockRuntimeEnvironmentTransportCall.mock.calls.find( + ([request]) => request.method === 'terminal.create' + )?.[0] + expect(create?.params?.command).not.toContain('orca-launch-file') + expect(create?.params?.command).not.toContain('fix the build') + expect(mockPasteDraftWhenAgentReady).toHaveBeenCalledWith( + expect.objectContaining({ content: 'fix the build\nthen run the tests', submit: true }) + ) + }) + + it('leaves that prompt to a paired host that takes the prompt itself, without a second paste', async () => { + useRemoteAgentBackgroundRuntime(state) + mockGetAgentLaunchPlatformForRepo.mockReturnValue('win32') + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await launchAgentBackgroundSession({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'fix the build\nthen run the tests' + }) + + expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'terminal.createAgentSession', + params: expect.objectContaining({ prompt: 'fix the build\nthen run the tests' }) + }) + ) + expect(mockPasteDraftWhenAgentReady).not.toHaveBeenCalled() + }) + it('closes a created runtime terminal when its data subscription fails', async () => { useRemoteAgentBackgroundRuntime(state) mockRuntimeEnvironmentSubscribe.mockRejectedValueOnce(new Error('subscription failed')) diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index 3e997d32e51e..76e776c6be53 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -1,4 +1,5 @@ import { useAppStore } from '@/store' +import { launchHostWritesLaunchFile } from '@/lib/launch-file-host' import { buildAgentStartupPlan } from '@/lib/tui-agent-startup' import type { LaunchAgentBackgroundSessionArgs, @@ -71,7 +72,9 @@ export async function launchAgentBackgroundSession( const hasPrompt = trimmedPrompt.length > 0 const isFollowupPath = requireTuiAgentConfig(agent).promptInjectionMode === 'stdin-after-start' - const pasteDraftAfterLaunch = hasPrompt && isFollowupPath ? trimmedPrompt : null + // Route by the worktree's owner host, not the focused runtime. + const ownerSettings = getSettingsForWorktreeRuntimeOwner(store, worktreeId) + const runtimeTarget = getActiveRuntimeTarget(ownerSettings) const startupPlan = buildAgentStartupPlan({ agent, prompt: hasPrompt && !isFollowupPath ? trimmedPrompt : '', @@ -81,11 +84,15 @@ export async function launchAgentBackgroundSession( platform: launchPlatform, shell: startupShell, isRemote, + hostWritesLaunchFile: launchHostWritesLaunchFile(ownerSettings), allowEmptyPromptLaunch: !hasPrompt || isFollowupPath }) if (!startupPlan) { return null } + // A prompt the host could not be sent in a launch file waits for the paste, as a stdin agent's does. + const promptLeftForPaste = hasPrompt && !isFollowupPath ? startupPlan.followupPrompt : null + let pasteDraftAfterLaunch = hasPrompt && isFollowupPath ? trimmedPrompt : promptLeftForPaste // A hidden run tab must never be store-visible without its PTY (#2989). const { reservedTabId, leafId, launchToken, launchRegistration, paneEnv } = @@ -98,10 +105,6 @@ export async function launchAgentBackgroundSession( }) let paneKey = makePaneKey(reservedTabId, leafId) const sshConnectionId = launchHost.connectionId - // Route by the worktree's owner host, not the focused runtime. - const runtimeTarget = getActiveRuntimeTarget( - getSettingsForWorktreeRuntimeOwner(store, worktreeId) - ) let ptyId = '', runtimeTerminalHandle: string | null = null // What the local spawn answered and later steps still need: which lifetime of `ptyId` this launch @@ -165,9 +168,14 @@ export async function launchAgentBackgroundSession( : {}), launchConfig: startupPlan.launchConfig, launchToken, - ...(title ? { title } : {}) + ...(title ? { title } : {}), + ...(promptLeftForPaste !== null ? { promptLeftForPaste: true } : {}) } }) + if (promptLeftForPaste !== null && !created.promptLeftForPaste) { + // The host was sent the prompt itself and delivered it. + pasteDraftAfterLaunch = null + } runtimeTerminalHandle = created.terminal.handle ptyId = toRemoteRuntimePtyId(runtimeTerminalHandle, runtimeTarget.environmentId) } else { @@ -184,6 +192,7 @@ export async function launchAgentBackgroundSession( ? { startupCommandDelivery: startupPlan.startupCommandDelivery } : {}), env: paneEnv, + ...(startupPlan.launchFile ? { launchFile: startupPlan.launchFile } : {}), launchConfig: startupPlan.launchConfig, launchToken, launchAgent: agent, diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index 375620b9311e..1352314224ca 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { toAppSshPtyId } from '../../../shared/ssh-pty-id' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' const mockCreateTab = vi.fn() @@ -16,8 +15,6 @@ const mockTrack = vi.fn() const mockToastMessage = vi.fn() const mockWaitForAgentReady = vi.fn() -const LEAF_ID = '11111111-1111-4111-8111-111111111111' - const store = { activeRepoId: 'repo-1', activeWorktreeId: 'wt-1', @@ -243,12 +240,14 @@ describe('launchAgentInNewTab', () => { launchAgent: 'codex', viewMode: 'chat' }) + // The launch line submits the prompt; the chat still shows it from the start. expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ - command: expect.not.stringContaining('large generated prompt') + command: expect.stringContaining('large generated prompt') }) ) + expect(mockPasteDraftWhenAgentReady).not.toHaveBeenCalled() expect(mockSeedNativeChatLaunchPrompt).toHaveBeenCalledWith({ tabId: 'tab-1', agent: 'codex', @@ -290,6 +289,29 @@ describe('launchAgentInNewTab', () => { }) }) + it('seeds no chat copy of a prompt that rides a launch file, which only its pointer would match', async () => { + store.settings = { + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {}, + activeRuntimeEnvironmentId: null, + experimentalNativeChat: true, + experimentalStructuredNativeChat: true, + openAgentTabsInChatByDefault: true + } + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ + agent: 'codex', + worktreeId: 'wt-1', + prompt: 'y'.repeat(20_000), + promptDelivery: 'submit-after-ready' + }) + + expect(mockQueueTabStartupCommand.mock.calls[0]?.[1]?.launchFile).toBeDefined() + expect(mockSeedNativeChatLaunchPrompt).not.toHaveBeenCalled() + }) + it('keeps Model-A SSH Grok launches in terminal mode', async () => { store.settings = { agentCmdOverrides: {}, @@ -667,8 +689,7 @@ describe('launchAgentInNewTab', () => { } }) - it('seeds working after Command Code submit-after-ready prompt delivery', async () => { - store.repos = [{ id: 'repo-1', connectionId: 'ssh-a', path: '/repo' }] + it('submits Command Code’s generated prompt on its launch line and seeds working from it', async () => { const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') const result = launchAgentInNewTab({ @@ -677,102 +698,42 @@ describe('launchAgentInNewTab', () => { prompt: 'large generated prompt', promptDelivery: 'submit-after-ready' }) - store.terminalLayoutsByTabId = { - 'tab-1': { - activeLeafId: LEAF_ID, - ptyIdsByLeafId: { [LEAF_ID]: toAppSshPtyId('ssh-a', 'pty-1') } - } - } - store.ptyIdsByTabId = { 'tab-1': [toAppSshPtyId('ssh-a', 'pty-1')] } - await expect(result?.promptDeliveryResult).resolves.toEqual({ - delivered: true, - failureNotified: false - }) - await Promise.resolve() - await Promise.resolve() + expect(result?.promptDeliveryResult).toBeUndefined() + expect(mockPasteDraftWhenAgentReady).not.toHaveBeenCalled() + // Why: Command Code has no prompt-submit hook, so the spawn seeds working from this prompt. expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ - command: "command-code --trust '--yolo'" + command: "command-code --trust '--yolo' 'large generated prompt'", + initialAgentStatus: { agent: 'command-code', prompt: 'large generated prompt' } }) ) - expect(mockPasteDraftWhenAgentReady).toHaveBeenCalledWith( - expect.objectContaining({ - tabId: 'tab-1', - content: 'large generated prompt', - agent: 'command-code', - submit: true, - forcePaste: true - }) - ) - expect(mockSetAgentStatus).toHaveBeenCalledWith( - `tab-1:${LEAF_ID}`, - { - state: 'working', - prompt: 'large generated prompt', - agentType: 'command-code', - // Why: seeded from Orca's own prompt delivery, not a provider hook (STA-4293). - observation: expect.objectContaining({ origin: 'process', kind: 'transition' }) - }, - undefined, - undefined, - { connectionId: 'ssh-a' } - ) - expect(mockTrack).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything()) }) - it('does not recreate SSH status when clear arrives before disconnect state', async () => { - let finishDelivery: ((delivered: boolean) => void) | undefined - mockPasteDraftWhenAgentReady.mockReturnValue( - new Promise((resolve) => { - finishDelivery = resolve - }) - ) - store.repos = [{ id: 'repo-1', connectionId: 'ssh-a', path: '/repo' }] - const ptyId = toAppSshPtyId('ssh-a', 'pty-1') - const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - - const result = launchAgentInNewTab({ - agent: 'command-code', - worktreeId: 'wt-1', - prompt: 'pending prompt', - promptDelivery: 'submit-after-ready' - }) - store.terminalLayoutsByTabId = { - 'tab-1': { activeLeafId: LEAF_ID, ptyIdsByLeafId: { [LEAF_ID]: ptyId } } - } - store.ptyIdsByTabId = { 'tab-1': [ptyId] } - - // Why: explicit disconnect sends the transient clear before its state - // event, while the old connection can still appear connected and bound. - store.transientClearedAgentStatusConnectionIds = { 'ssh-a': true } - finishDelivery?.(true) - await expect(result?.promptDeliveryResult).resolves.toEqual({ - delivered: true, - failureNotified: false - }) - - expect(mockSetAgentStatus).not.toHaveBeenCalled() - }) - - it('does not track prompt-sent when submit-after-ready delivery fails', async () => { + // Why: a paste that fails without reaching the readiness-timeout branch was silent (live on WSL: + // an empty composer, delivered false, nothing shown). + it('tells the user, with the prompt to copy, when a paste fails outside the timeout branch', async () => { mockPasteDraftWhenAgentReady.mockResolvedValue(false) const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') const result = launchAgentInNewTab({ - agent: 'command-code', + agent: 'amp', worktreeId: 'wt-1', prompt: 'large generated prompt', promptDelivery: 'submit-after-ready' }) await expect(result?.promptDeliveryResult).resolves.toEqual({ delivered: false, - failureNotified: false + failureNotified: true }) await Promise.resolve() expect(mockTrack).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything()) + expect(mockToastMessage).toHaveBeenCalledWith( + expect.stringContaining("wasn't sent"), + expect.objectContaining({ action: expect.objectContaining({ label: 'Copy prompt' }) }) + ) }) it('marks failed submit-after-ready delivery as notified after readiness timeout toast', async () => { @@ -784,7 +745,7 @@ describe('launchAgentInNewTab', () => { const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') const result = launchAgentInNewTab({ - agent: 'command-code', + agent: 'amp', worktreeId: 'wt-1', prompt: 'large generated prompt', promptDelivery: 'submit-after-ready' @@ -795,7 +756,8 @@ describe('launchAgentInNewTab', () => { failureNotified: true }) expect(mockToastMessage).toHaveBeenCalledWith( - "Your prompt wasn't sent — paste it once the agent is ready." + "The agent started, but your prompt wasn't sent. Copy it and paste it once the agent is ready.", + expect.objectContaining({ action: expect.objectContaining({ label: 'Copy prompt' }) }) ) }) @@ -809,7 +771,7 @@ describe('launchAgentInNewTab', () => { const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') const result = launchAgentInNewTab({ - agent: 'command-code', + agent: 'amp', worktreeId: 'wt-1', prompt: 'large generated prompt', promptDelivery: 'submit-after-ready' @@ -831,7 +793,7 @@ describe('launchAgentInNewTab', () => { const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') const result = launchAgentInNewTab({ - agent: 'command-code', + agent: 'amp', worktreeId: 'wt-1', prompt: 'large generated prompt', promptDelivery: 'submit-after-ready' @@ -853,7 +815,7 @@ describe('launchAgentInNewTab', () => { const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') const result = launchAgentInNewTab({ - agent: 'command-code', + agent: 'amp', worktreeId: 'wt-1', prompt: 'large generated prompt', promptDelivery: 'submit-after-ready' @@ -866,7 +828,26 @@ describe('launchAgentInNewTab', () => { expect(mockToastMessage).not.toHaveBeenCalled() }) - it('queues per-launch CLI arguments without putting generated prompts in argv', async () => { + it('hands a prompt past the argv ceiling to the host as a launch file the command points at', async () => { + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + const prompt = `Session context:\n${'x'.repeat(20_000)}` + + const result = launchAgentInNewTab({ + agent: 'codex', + worktreeId: 'wt-1', + prompt, + promptDelivery: 'submit-after-ready' + }) + + expect(result?.promptDeliveryResult).toBeUndefined() + expect(mockPasteDraftWhenAgentReady).not.toHaveBeenCalled() + const queued = mockQueueTabStartupCommand.mock.calls[0]?.[1] + expect(queued?.launchFile).toMatchObject({ content: prompt, sensitive: false }) + expect(queued?.command).toContain(queued?.launchFile?.placeholder) + expect(queued?.command).not.toContain('xxxx') + }) + + it('queues per-launch CLI arguments ahead of the generated prompt on argv', async () => { const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') launchAgentInNewTab({ @@ -880,7 +861,7 @@ describe('launchAgentInNewTab', () => { expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ - command: "codex '--model' 'gpt-5.5'" + command: "codex '--model' 'gpt-5.5' 'large generated prompt'" }) ) }) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index ce28d3c8ad18..208c51a9e455 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -6,7 +6,8 @@ import { tuiAgentToAgentKind } from '@/lib/telemetry' import { createPasteReadinessTimeoutNotice } from '@/lib/launch-agent-paste-timeout-notice' import { deliverLaunchPromptToAgentTab, - seedNativeChatLaunchDraftForAgentTab + seedNativeChatLaunchDraftForAgentTab, + seedNativeChatLaunchPromptForAgentTab } from '@/lib/agent-launch-prompt-delivery' import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mode' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' @@ -18,7 +19,6 @@ import { resolveTuiAgentLaunchEnv } from '../../../shared/tui-agent-launch-defaults' import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' -import { seedCommandCodeSubmittedPromptStatus } from '@/lib/command-code-prompt-status-seed' import type { TuiAgent } from '../../../shared/tui-agent' import type { LaunchSource } from '../../../shared/telemetry-events' import { resolveAgentLaunchExecutionContext } from '@/lib/launch-agent-execution-context' @@ -156,20 +156,23 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent agentEnv, sessionOptions: resolveInitialNativeChatSessionOptions(store.settings, initialViewModeOptions) } - const { startupPlan, pasteDraftAfterLaunch, submitPastedPrompt } = planLaunchAgentStartupPrompt({ - base: startupPlanBase, - prompt: trimmedPrompt, - promptDelivery, - isFollowupPath - }) + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(store, worktreeId) + const launchesOnPairedHost = isWebRuntimeSessionActive(runtimeEnvironmentId) + const { startupPlan, launchFile, pasteDraftAfterLaunch, submitPastedPrompt } = + planLaunchAgentStartupPrompt({ + base: startupPlanBase, + prompt: trimmedPrompt, + promptDelivery, + isFollowupPath, + launchesOnPairedHost + }) let promptDeliveryResult: Promise<{ delivered: boolean; failureNotified: boolean }> | undefined if (!startupPlan) { return null } - const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(store, worktreeId) - if (isWebRuntimeSessionActive(runtimeEnvironmentId)) { + if (launchesOnPairedHost) { if (beforeSurfaceOpen?.({ kind: 'host-published' }) === false) { return null } @@ -266,7 +269,12 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent ...(startupPlan.startupCommandDelivery ? { startupCommandDelivery: startupPlan.startupCommandDelivery } : {}), - ...(agent === 'command-code' && hasPrompt && promptDelivery === 'auto-submit' + ...(launchFile ? { launchFile } : {}), + // Why: Command Code has no prompt-submit hook, so a prompt its launch line submits seeds working. + ...(agent === 'command-code' && + hasPrompt && + promptDelivery !== 'draft' && + pasteDraftAfterLaunch === null ? { initialAgentStatus: { agent, prompt: trimmedPrompt } } : {}), telemetry: { @@ -277,17 +285,23 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent }) // Why: fire-and-forget the paste-after-ready delivery so callers keep the synchronous { tabId, startupPlan } signature. // Why: safe to call unconditionally — the helper short-circuits (no paste) for native-prefill agents already holding the draft. - if (hasPrompt && promptDelivery === 'draft' && pasteDraftAfterLaunch === null) { - // Why: the draft rode in on argv (Claude --prefill etc.), so no paste runs - // and deliverLaunchPromptToAgentTab never seeds. Mirror it into chat here. - seedNativeChatLaunchDraftForAgentTab({ tabId: tab.id, agent, text: trimmedPrompt }) + // Why: no paste runs, so no paste seeds the chat's copy: a draft rode in on argv (Claude --prefill + // etc.), and a submitted prompt rode the launch line. Not with a launch file: the transcript then + // shows the pointer sentence, which would never prune this copy. + if (hasPrompt && pasteDraftAfterLaunch === null && !launchFile) { + if (promptDelivery === 'draft') { + seedNativeChatLaunchDraftForAgentTab({ tabId: tab.id, agent, text: trimmedPrompt }) + } else if (promptDelivery === 'submit-after-ready') { + seedNativeChatLaunchPromptForAgentTab({ tabId: tab.id, agent, text: trimmedPrompt }) + } } if (pasteDraftAfterLaunch !== null) { const timeoutNotice = createPasteReadinessTimeoutNotice({ worktreeId, tabId: tab.id, agent, - submitted: submitPastedPrompt + submitted: submitPastedPrompt, + content: pasteDraftAfterLaunch }) const deliveryPromise = deliverLaunchPromptToAgentTab({ tabId: tab.id, @@ -297,17 +311,7 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent forcePaste: true, onTimeout: timeoutNotice.onTimeout, ...(onPromptDeliveryUnconfirmed ? { onUnconfirmedDelivery: onPromptDeliveryUnconfirmed } : {}) - }).then((delivered) => { - if (delivered) { - if (agent === 'command-code' && submitPastedPrompt) { - // Why: Command Code has no prompt-submit hook; when Orca submits a - // generated prompt after readiness, seed working at delivery time. - seedCommandCodeSubmittedPromptStatus(worktreeId, tab.id, trimmedPrompt) - } - onPromptDelivered?.() - } - return { delivered, failureNotified: !delivered && timeoutNotice.wasNotified() } - }) + }).then((delivered) => timeoutNotice.settle(delivered, onPromptDelivered)) if (promptDelivery === 'submit-after-ready') { promptDeliveryResult = deliveryPromise } else { diff --git a/src/renderer/src/lib/launch-agent-paste-timeout-notice.ts b/src/renderer/src/lib/launch-agent-paste-timeout-notice.ts index f72f5b8b3b7a..39890a79b290 100644 --- a/src/renderer/src/lib/launch-agent-paste-timeout-notice.ts +++ b/src/renderer/src/lib/launch-agent-paste-timeout-notice.ts @@ -3,6 +3,7 @@ import { useAppStore } from '@/store' import { track, tuiAgentToAgentKind } from '@/lib/telemetry' import { translate } from '@/i18n/i18n' import type { TuiAgent } from '../../../shared/tui-agent' +import { showAgentLaunchPromptNotDeliveredNotice } from '@/lib/agent-launch-prompt-not-delivered-notice' /** * Notice for a post-launch paste that never landed — a stalled readiness wait @@ -16,36 +17,65 @@ export function createPasteReadinessTimeoutNotice(args: { tabId: string agent: TuiAgent submitted: boolean -}): { onTimeout: () => void; wasNotified: () => boolean } { + /** The text that was not delivered, handed back through the notice's Copy action. */ + content: string +}): { + onTimeout: () => void + wasNotified: () => boolean + /** Reports the paste's outcome, telling the user about any undelivered one, not only a timeout. */ + settle: ( + delivered: boolean, + onDelivered?: () => void + ) => { delivered: boolean; failureNotified: boolean } +} { let notified = false + const onTimeout = (): void => { + if (notified) { + return + } + notifyUndelivered() + } return { wasNotified: () => notified, - onTimeout: () => { - const state = useAppStore.getState() - const currentTab = (state.tabsByWorktree[args.worktreeId] ?? []).find( - (tab) => tab.id === args.tabId - ) - if (currentTab?.ptyId === null) { - // Why: PTY never spawned = genuine launch failure; stay silent so the caller emits the sole notice. - return + onTimeout, + settle: (delivered, onDelivered) => { + if (delivered) { + onDelivered?.() + } else { + onTimeout() } - if (!currentTab || state.activeWorktreeId !== args.worktreeId) { - // Why: user cancelled (closed tab / switched worktrees); mark notified so the deferred caller suppresses its toast too. - notified = true - return - } - toast.message( - translate( - 'auto.lib.launch.agent.in.new.tab.a5a1f7033f', - "Your {{value0}} wasn't sent — paste it once the agent is ready.", - { value0: args.submitted ? 'prompt' : 'notes' } - ) - ) - notified = true - track('agent_error', { - error_class: 'paste_readiness_timeout', - agent_kind: tuiAgentToAgentKind(args.agent) - }) + return { delivered, failureNotified: !delivered && notified } } } + function notifyUndelivered(): void { + const state = useAppStore.getState() + const currentTab = (state.tabsByWorktree[args.worktreeId] ?? []).find( + (tab) => tab.id === args.tabId + ) + if (currentTab?.ptyId === null) { + // Why: PTY never spawned = genuine launch failure; stay silent so the caller emits the sole notice. + return + } + notified = true + if (!currentTab) { + // Why: the user closed the tab; mark notified so the deferred caller suppresses its toast too. + return + } + if (args.submitted) { + // Why a persistent Copy action, even from another worktree: the notice holds the only copy. + showAgentLaunchPromptNotDeliveredNotice({ agent: args.agent, prompt: args.content }) + return + } + toast.message( + translate( + 'auto.lib.launch.agent.in.new.tab.a5a1f7033f', + "Your {{value0}} wasn't sent — paste it once the agent is ready.", + { value0: 'notes' } + ) + ) + track('agent_error', { + error_class: 'paste_readiness_timeout', + agent_kind: tuiAgentToAgentKind(args.agent) + }) + } } diff --git a/src/renderer/src/lib/launch-agent-startup-prompt-plan.ts b/src/renderer/src/lib/launch-agent-startup-prompt-plan.ts index 0723d3ede9a8..cd50ddff2914 100644 --- a/src/renderer/src/lib/launch-agent-startup-prompt-plan.ts +++ b/src/renderer/src/lib/launch-agent-startup-prompt-plan.ts @@ -3,6 +3,8 @@ import { buildAgentStartupPlan, type AgentStartupPlan } from '@/lib/tui-agent-startup' +import { planStartupWithLaunchPrompt } from '../../../shared/startup-line-prompt-carry' +import type { LaunchFile } from '../../../shared/launch-prompt-file' type StartupPlanBase = Omit< Parameters[0], @@ -11,15 +13,16 @@ type StartupPlanBase = Omit< export type LaunchAgentStartupPromptPlan = { startupPlan: AgentStartupPlan | null + /** Written by the host before it types the launch line naming it. */ + launchFile?: LaunchFile /** Text to paste once the TUI is ready; null when the launch command already carries it. */ pasteDraftAfterLaunch: string | null submitPastedPrompt: boolean } /** - * Decide how a new-tab launch delivers its prompt: argv/flag agents fold it - * into the launch command, while followup and generated launches start clean - * and paste after the TUI is ready. + * Decide how a new-tab launch delivers its prompt: agents whose CLI takes one get it on the launch + * command, while agents that take text only after start launch clean and paste once ready. */ export function planLaunchAgentStartupPrompt(args: { base: StartupPlanBase @@ -27,21 +30,17 @@ export function planLaunchAgentStartupPrompt(args: { prompt: string promptDelivery: 'auto-submit' | 'draft' | 'submit-after-ready' isFollowupPath: boolean + /** A paired host of unknown version may neither stage a long line nor write a launch file. */ + launchesOnPairedHost: boolean }): LaunchAgentStartupPromptPlan { const { base, prompt, promptDelivery, isFollowupPath } = args const hasPrompt = prompt.length > 0 - const launchEmpty = (): AgentStartupPlan | null => - buildAgentStartupPlan({ ...base, prompt: '', allowEmptyPromptLaunch: true }) const pasteAfterReady = (submit: boolean): LaunchAgentStartupPromptPlan => ({ - startupPlan: launchEmpty(), + startupPlan: buildAgentStartupPlan({ ...base, prompt: '', allowEmptyPromptLaunch: true }), pasteDraftAfterLaunch: prompt, submitPastedPrompt: submit }) - if (hasPrompt && promptDelivery === 'submit-after-ready') { - // Why: multi-line generated prompts are too large for a shell argv, so launch clean then paste+submit in the TUI. - return pasteAfterReady(true) - } if (hasPrompt && promptDelivery === 'draft') { const draftLaunchPlan = buildAgentDraftLaunchPlan({ ...base, draft: prompt }) if (!draftLaunchPlan) { @@ -66,15 +65,28 @@ export function planLaunchAgentStartupPrompt(args: { submitPastedPrompt: false } } - if (hasPrompt && isFollowupPath) { - return pasteAfterReady(false) + // Temporary, until paired hosts advertise staging: keep their paste after readiness. + if ( + hasPrompt && + (isFollowupPath || (args.launchesOnPairedHost && promptDelivery === 'submit-after-ready')) + ) { + return pasteAfterReady(promptDelivery === 'submit-after-ready') } - return { - startupPlan: buildAgentStartupPlan({ + if (!hasPrompt || args.launchesOnPairedHost) { + const startupPlan = buildAgentStartupPlan({ ...base, - prompt: hasPrompt ? prompt : '', + prompt, allowEmptyPromptLaunch: !hasPrompt - }), + }) + // A paired host is sent a command, never a launch file, so a prompt that needs one is pasted. + return startupPlan?.launchFile + ? pasteAfterReady(true) + : { startupPlan, pasteDraftAfterLaunch: null, submitPastedPrompt: false } + } + const carried = planStartupWithLaunchPrompt(base, prompt) + return { + startupPlan: carried.plan, + ...(carried.launchFile ? { launchFile: carried.launchFile } : {}), pasteDraftAfterLaunch: null, submitPastedPrompt: false } diff --git a/src/renderer/src/lib/launch-file-host.test.ts b/src/renderer/src/lib/launch-file-host.test.ts new file mode 100644 index 000000000000..c9fe3fa233a1 --- /dev/null +++ b/src/renderer/src/lib/launch-file-host.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { launchHostWritesLaunchFile } from './launch-file-host' +import { buildQuickComposerStartup } from '@/hooks/composer-state/quick-startup-plan' + +describe('whether a launch host writes its launch file', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('is true for this machine and false for a paired host or a web client', () => { + expect(launchHostWritesLaunchFile({ activeRuntimeEnvironmentId: null })).toBe(true) + expect(launchHostWritesLaunchFile({ activeRuntimeEnvironmentId: 'env-1' })).toBe(false) + vi.stubGlobal('window', { __ORCA_WEB_CLIENT__: true }) + expect(launchHostWritesLaunchFile({ activeRuntimeEnvironmentId: null })).toBe(false) + }) +}) + +describe('the quick composer on a paired host', () => { + it('sends no pointer to a paired host and leaves the prompt for the renderer to paste', () => { + const startup = buildQuickComposerStartup({ + agent: 'claude', + prompt: 'fix the build\nthen run the tests', + draftPrompt: null, + settings: null, + repoConnectionId: null, + platform: 'win32', + shell: 'powershell', + isRemote: false, + hostWritesLaunchFile: false, + telemetrySource: 'sidebar' + }) + expect(startup.backendStartup).toBeUndefined() + expect(startup.startupPlan?.launchFile).toBeUndefined() + expect(startup.startupPlan?.followupPrompt).toBe('fix the build\nthen run the tests') + }) +}) diff --git a/src/renderer/src/lib/launch-file-host.ts b/src/renderer/src/lib/launch-file-host.ts new file mode 100644 index 000000000000..087e0e1402b6 --- /dev/null +++ b/src/renderer/src/lib/launch-file-host.ts @@ -0,0 +1,14 @@ +import type { GlobalSettings } from '../../../shared/global-settings-types' +import { getActiveRuntimeTarget } from '@/runtime/runtime-client-target' +import { isWebClientLocation } from './web-client-location' + +/** + * Whether the host a launch lands on writes the launch file its line names. A paired host (another + * Orca this client drives) is sent a command and never the file, so a prompt that would need one is + * pasted after the agent is ready instead. Temporary, until paired hosts are sent the prompt itself. + */ +export function launchHostWritesLaunchFile( + ownerSettings: Pick | null | undefined +): boolean { + return !isWebClientLocation() && getActiveRuntimeTarget(ownerSettings).kind === 'local' +} diff --git a/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts b/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts index cbf080f12565..639bce4be6f6 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts @@ -18,6 +18,7 @@ export function buildDirectWorkItemStartup(args: { launchConnectionId: string | null worktreePath: string repoProjectRuntime?: Parameters[0]['projectRuntime'] + launchesOnPairedHost: boolean }): ReturnType { const launchPlatform = args.launchPlatform ?? @@ -37,7 +38,8 @@ export function buildDirectWorkItemStartup(args: { args.launchConnectionId ), // Why: SSH hosts run the plain `orca` shim, so the Linux-only `orca-ide` rename is not applied. - isRemote: typeof args.launchConnectionId === 'string' + isRemote: typeof args.launchConnectionId === 'string', + launchesOnPairedHost: args.launchesOnPairedHost }) } diff --git a/src/renderer/src/lib/launch-work-item-direct-agent.ts b/src/renderer/src/lib/launch-work-item-direct-agent.ts index e5595c0674be..2d3e50cee4b4 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent.ts @@ -17,6 +17,9 @@ import { import { translate } from '@/i18n/i18n' import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat/native-chat-launch-session-options' import type { PersistedNativeChatSessionOptions } from '../../../shared/native-chat-session-options' +import type { LaunchFile } from '../../../shared/launch-prompt-file' +import { planStartupWithLaunchPrompt } from '../../../shared/startup-line-prompt-carry' +import { agentPromptRidesLaunchCommand } from '../../../shared/tui-agent-startup' export function buildDirectWorkItemAgentStartupPlan(args: { agent: TuiAgent | null @@ -39,13 +42,17 @@ export function buildDirectWorkItemAgentStartupPlan(args: { /** Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only * `orca-ide` rename must not be applied for remote launches. */ isRemote?: boolean + /** A paired host of unknown version may neither stage a long line nor write a launch file. */ + launchesOnPairedHost?: boolean }): { startupPlan: AgentStartupPlan | null - draftLaunchedNatively: boolean + launchFile?: LaunchFile + /** The launch command carries the content (a native draft or a submitted prompt); no paste runs. */ + promptOnLaunchCommand: boolean startupPlanFailed: boolean } { if (args.agent === null) { - return { startupPlan: null, draftLaunchedNatively: false, startupPlanFailed: false } + return { startupPlan: null, promptOnLaunchCommand: false, startupPlanFailed: false } } const effectiveAgentArgs = @@ -60,6 +67,29 @@ export function buildDirectWorkItemAgentStartupPlan(args: { : {}), nativeChatTranscriptIsLocalReadable: args.nativeChatTranscriptIsLocalReadable }) + const planInputs = { + agent: args.agent, + cmdOverrides: args.settings?.agentCmdOverrides ?? {}, + platform: args.launchPlatform, + isRemote: args.isRemote, + agentArgs: effectiveAgentArgs, + agentEnv: effectiveAgentEnv, + sessionOptions + } + // Temporary, until paired hosts advertise staging: they keep the paste after readiness. + if ( + args.promptDelivery === 'submit-after-ready' && + !args.launchesOnPairedHost && + agentPromptRidesLaunchCommand(args.agent) + ) { + const carried = planStartupWithLaunchPrompt(planInputs, args.draftContent) + return { + startupPlan: carried.plan, + ...(carried.launchFile ? { launchFile: carried.launchFile } : {}), + promptOnLaunchCommand: carried.plan !== null, + startupPlanFailed: carried.plan === null + } + } const draftLaunchPlan = args.promptDelivery === 'submit-after-ready' ? null @@ -90,7 +120,7 @@ export function buildDirectWorkItemAgentStartupPlan(args: { : {}), ...(draftLaunchPlan.env ? { env: draftLaunchPlan.env } : {}) }, - draftLaunchedNatively: true, + promptOnLaunchCommand: true, startupPlanFailed: false } } @@ -111,7 +141,7 @@ export function buildDirectWorkItemAgentStartupPlan(args: { } return { startupPlan, - draftLaunchedNatively: false, + promptOnLaunchCommand: false, startupPlanFailed: startupPlan === null } } @@ -122,7 +152,8 @@ export function buildDirectWorkItemStartupOpts( launchSource: LaunchSource, /** Unsent launch context, for the view-mode decision only. Set it for every * draft launch — a natively-prefilled plan carries no `draftPrompt`. */ - launchDraftText?: string + launchDraftText?: string, + launchFile?: LaunchFile ): { startup?: { command: string @@ -133,6 +164,7 @@ export function buildDirectWorkItemStartupOpts( launchDraftText?: string sessionOptions?: AgentStartupPlan['sessionOptions'] startupCommandDelivery?: StartupCommandDelivery + launchFile?: LaunchFile telemetry?: AgentStartedTelemetry } } { @@ -155,6 +187,7 @@ export function buildDirectWorkItemStartupOpts( ...(plan.startupCommandDelivery ? { startupCommandDelivery: plan.startupCommandDelivery } : {}), + ...(launchFile ? { launchFile } : {}), ...(telemetry ? { telemetry } : {}) } } diff --git a/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts b/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts index 96ea10d91ec2..5a4a38a40ce8 100644 --- a/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts +++ b/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts @@ -3,6 +3,9 @@ import type { AppState } from '@/store/types' import { getConnectionId } from '@/lib/connection-context' import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { isWebRuntimeSessionActive } from '@/runtime/web-runtime-session' +import type { LaunchFile } from '../../../shared/launch-prompt-file' import type { AgentSessionLaunchPlan, planAgentSessionLaunch @@ -17,7 +20,8 @@ export type DirectWorkItemAgentLaunchPreparation = { unavailable: boolean effectiveAgent: TuiAgent | null startupPlan: ReturnType['startupPlan'] - draftLaunchedNatively: boolean + launchFile?: LaunchFile + promptOnLaunchCommand: boolean startupPlanFailed: boolean structuredLaunch: boolean /** Null when no agent was selected. */ @@ -54,7 +58,7 @@ export async function prepareDirectWorkItemAgentLaunch(args: { unavailable: true, effectiveAgent: null, startupPlan: null, - draftLaunchedNatively: false, + promptOnLaunchCommand: false, startupPlanFailed: false, structuredLaunch: false, plan: null @@ -70,24 +74,28 @@ export async function prepareDirectWorkItemAgentLaunch(args: { // Non-critical: activation still has the explicit startup below. }) } - const { startupPlan, draftLaunchedNatively, startupPlanFailed } = buildDirectWorkItemStartup({ - agent: effectiveAgent, - agentArgs: args.agentArgs, - draftContent: args.draftContent, - promptDelivery: args.promptDelivery, - settings: args.settings, - launchPlatform: args.launchPlatform, - launchConnectionId, - worktreePath: args.worktreePath, - repoProjectRuntime: - launchConnectionId === null - ? (getLocalProjectExecutionRuntimeContext( - args.latestStore, - args.worktreeId, - CLIENT_PLATFORM - ) ?? args.repoProjectRuntime) - : undefined - }) + const { startupPlan, launchFile, promptOnLaunchCommand, startupPlanFailed } = + buildDirectWorkItemStartup({ + agent: effectiveAgent, + agentArgs: args.agentArgs, + draftContent: args.draftContent, + promptDelivery: args.promptDelivery, + settings: args.settings, + launchPlatform: args.launchPlatform, + launchConnectionId, + worktreePath: args.worktreePath, + repoProjectRuntime: + launchConnectionId === null + ? (getLocalProjectExecutionRuntimeContext( + args.latestStore, + args.worktreeId, + CLIENT_PLATFORM + ) ?? args.repoProjectRuntime) + : undefined, + launchesOnPairedHost: isWebRuntimeSessionActive( + getRuntimeEnvironmentIdForWorktree(args.latestStore, args.worktreeId) + ) + }) const plan = effectiveAgent === null @@ -106,7 +114,8 @@ export async function prepareDirectWorkItemAgentLaunch(args: { unavailable: false, effectiveAgent, startupPlan, - draftLaunchedNatively, + ...(launchFile ? { launchFile } : {}), + promptOnLaunchCommand, startupPlanFailed, structuredLaunch, plan diff --git a/src/renderer/src/lib/launch-work-item-direct.test.ts b/src/renderer/src/lib/launch-work-item-direct.test.ts index 5b6686774bc0..ee14844e6126 100644 --- a/src/renderer/src/lib/launch-work-item-direct.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AppState } from '@/store' import type * as TuiAgentSelectionModule from '../../../shared/tui-agent-selection' import type * as TuiAgentStartupModule from '@/lib/tui-agent-startup' +import type * as PromptCarryModule from '../../../shared/startup-line-prompt-carry' import type * as DirectAgentRoutingModule from '@/lib/launch-work-item-direct-agent-routing' const mocks = vi.hoisted(() => ({ @@ -107,6 +108,16 @@ vi.mock('@/lib/tui-agent-startup', async () => { } }) +vi.mock('../../../shared/startup-line-prompt-carry', async () => { + const actual = await vi.importActual( + '../../../shared/startup-line-prompt-carry' + ) + return { + ...actual, + planStartupWithLaunchPrompt: vi.fn(actual.planStartupWithLaunchPrompt) + } +}) + vi.mock('../../../shared/tui-agent-selection', async () => { const actual = await vi.importActual( '../../../shared/tui-agent-selection' @@ -132,6 +143,7 @@ import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft' import { beginDirectWorkItemStructuredLaunch } from '@/lib/launch-work-item-direct-agent-routing' import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agent-startup' import { pickTuiAgent } from '../../../shared/tui-agent-selection' +import { planStartupWithLaunchPrompt } from '../../../shared/startup-line-prompt-carry' const mockApi = { worktrees: { @@ -414,26 +426,14 @@ describe('launchWorkItemDirect', () => { platform: 'win32', isRemote: false }) - expect(buildAgentStartupPlan).not.toHaveBeenCalledWith( - expect.objectContaining({ - agent: 'claude', - prompt: '', - allowEmptyPromptLaunch: true - }) - ) - expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith( - 'repo-1::/repo/worktree', - expect.objectContaining({ - startup: expect.objectContaining({ - command: expect.stringContaining('Linked Linear issue: ENG-42') - }) - }) - ) - const startupCommand = mocks.activateAndRevealWorktree.mock.calls[0]?.[1]?.startup?.command - expect(startupCommand).toContain('https://linear.app/acme/issue/ENG-42/ship-linear-parity') - expect(startupCommand).not.toContain('The distinctive Linear body text is here.') - expect(startupCommand).not.toContain('--- BEGIN LINKED WORK ITEM CONTEXT ---') - expect(pasteDraftWhenAgentReady).not.toHaveBeenCalled() + // Windows shells have no bracketed paste, so a multi-line draft is never typed onto the launch + // line; it is pasted into the agent's own composer instead. + const startup = mocks.activateAndRevealWorktree.mock.calls[0]?.[1]?.startup + expect(startup?.command).not.toContain('Linked Linear issue') + const pasted: string = startup?.draftPrompt ?? '' + expect(pasted).toContain(expectedDraft) + expect(pasted).not.toContain('The distinctive Linear body text is here.') + expect(pasted).not.toContain('--- BEGIN LINKED WORK ITEM CONTEXT ---') }) it('seeds the chat-composer launch draft for a GitHub issue draft launch', async () => { @@ -530,16 +530,11 @@ describe('launchWorkItemDirect', () => { ).resolves.toBe(true) expect(buildAgentDraftLaunchPlan).not.toHaveBeenCalled() - expect(pasteDraftWhenAgentReady).toHaveBeenCalledWith( - expect.objectContaining({ - tabId: 'tab-1', - content: 'Use this explicit user prompt.', - agent: 'claude', - submit: true, - forcePaste: true, - onTimeout: expect.any(Function) - }) - ) + // The explicit prompt is submitted by the launch line, never pasted into the running agent. + expect(pasteDraftWhenAgentReady).not.toHaveBeenCalled() + const startup = mocks.activateAndRevealWorktree.mock.calls.at(-1)?.[1]?.startup + expect(startup?.command).toContain('Use this explicit user prompt.') + expect(startup?.command).not.toContain('generated Linear source') expect(mocks.seedNativeChatLaunchPrompt).toHaveBeenCalledWith({ tabId: 'tab-1', agent: 'claude', @@ -818,11 +813,12 @@ describe('launchWorkItemDirect', () => { }) ).resolves.toBe(true) - expect(buildAgentStartupPlan).toHaveBeenCalledWith( + expect(planStartupWithLaunchPrompt).toHaveBeenCalledWith( expect.objectContaining({ agent: 'codex', platform: 'linux' - }) + }), + 'Fix the failing checks.' ) }) }) diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index 6b027e8303e3..ac4d58a7b587 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -1,8 +1,10 @@ +import { showAgentLaunchPromptNotDeliveredNotice } from '@/lib/agent-launch-prompt-not-delivered-notice' import { toast } from 'sonner' import { useAppStore } from '@/store' import { deliverLaunchPromptToAgentTab, - seedNativeChatLaunchDraftForAgentTab + seedNativeChatLaunchDraftForAgentTab, + seedNativeChatLaunchPromptForAgentTab } from '@/lib/agent-launch-prompt-delivery' import { planAgentCliArgsSuffix } from '@/lib/tui-agent-startup' import { activateAndRevealWorktree } from '@/lib/worktree-activation' @@ -163,7 +165,8 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom let primaryTabId: string | null let startupPlan = null as ReturnType['startupPlan'] let effectiveAgent: TuiAgent | null = null - let draftLaunchedNatively = false + let promptOnLaunchCommand = false, + promptInLaunchFile = false let plan: AgentSessionLaunchPlan | null = null let structuredLaunchCompleted = false const draftContent = await getDirectWorkItemDraftContent(item, repoConnectionId) @@ -226,7 +229,8 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom } effectiveAgent = launchPreparation.effectiveAgent startupPlan = launchPreparation.startupPlan - draftLaunchedNatively = launchPreparation.draftLaunchedNatively + promptOnLaunchCommand = launchPreparation.promptOnLaunchCommand + promptInLaunchFile = Boolean(launchPreparation.launchFile) startupPlanFailed = launchPreparation.startupPlanFailed plan = launchPreparation.plan @@ -244,7 +248,8 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom effectiveAgent, startupPlan, launchSource, - promptDelivery === 'draft' ? draftContent : undefined + promptDelivery === 'draft' ? draftContent : undefined, + launchPreparation.launchFile )) }) return activationHolder.value !== false @@ -294,10 +299,25 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom text: draftContent }) } + if ( + primaryTabId && + effectiveAgent && + promptDelivery === 'submit-after-ready' && + promptOnLaunchCommand && + // Why: the transcript then shows the pointer sentence, which would never prune this copy. + !promptInLaunchFile + ) { + // Why: the launch line submits it, so no paste seeds the chat's copy of the prompt. + seedNativeChatLaunchPromptForAgentTab({ + tabId: primaryTabId, + agent: effectiveAgent, + text: draftContent + }) + } if ( primaryTabId && startupPlan && - !draftLaunchedNatively && + !promptOnLaunchCommand && !(promptDelivery === 'draft' && startupPlan.draftPrompt) ) { const submit = promptDelivery === 'submit-after-ready' @@ -308,7 +328,10 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom content: draftContent, submit, forcePaste: submit, - onTimeout: () => notifyDirectWorkItemAgentStartTimeout(agent, submit) + onTimeout: () => + submit + ? showAgentLaunchPromptNotDeliveredNotice({ agent, prompt: draftContent }) + : notifyDirectWorkItemAgentStartTimeout(agent, submit) }) } return true diff --git a/src/renderer/src/lib/new-workspace.test.ts b/src/renderer/src/lib/new-workspace.test.ts index d3ca4fa01e63..28fab8326ff2 100644 --- a/src/renderer/src/lib/new-workspace.test.ts +++ b/src/renderer/src/lib/new-workspace.test.ts @@ -90,8 +90,8 @@ vi.mock('@/lib/telemetry', () => ({ track: mockTrack })) -vi.mock('@/lib/agent-background-session-timeout-toast', () => ({ - showAutomationPromptNotSentToast: mockShowAutomationPromptNotSentToast +vi.mock('@/lib/agent-launch-prompt-not-delivered-notice', () => ({ + showAgentLaunchPromptNotDeliveredNotice: mockShowAutomationPromptNotSentToast })) import { @@ -339,7 +339,32 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { }) expect(mockSendRuntimePtyInputVerified).not.toHaveBeenCalled() - expect(mockShowAutomationPromptNotSentToast).toHaveBeenCalledWith('aider') + expect(mockShowAutomationPromptNotSentToast).toHaveBeenCalledWith({ + agent: 'aider', + prompt: 'fix the spinner' + }) + }) + + // Why: an argv agent's prompt left for paste (a WSL line, a paired host) was typed raw once the + // process name showed, the early write the launch line exists to avoid. + it('pastes an argv agent’s left-over prompt once its composer is ready, as one bracketed paste', async () => { + mockPasteDraftToAgentPtyWhenReady.mockResolvedValue(true) + await ensureAgentStartupInTerminal({ + worktreeId: 'wt-1', + startup: { + agent: 'claude', + launchCommand: 'claude', + expectedProcess: 'claude', + followupPrompt: 'fix the spinner', + launchConfig: { agentArgs: '', agentEnv: {} } + } + }) + + expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledWith( + expect.objectContaining({ content: 'fix the spinner', submit: true, forcePaste: true }) + ) + expect(mockSendRuntimePtyInputVerified).not.toHaveBeenCalled() + expect(mockShowAutomationPromptNotSentToast).not.toHaveBeenCalled() }) it('does not toast when a follow-up prompt is delivered', async () => { @@ -375,7 +400,10 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { | undefined expect(call?.onTimeout).toBeTypeOf('function') call?.onTimeout?.() - expect(mockShowAutomationPromptNotSentToast).toHaveBeenCalledWith('claude') + expect(mockShowAutomationPromptNotSentToast).toHaveBeenCalledWith({ + agent: 'claude', + prompt: 'review this before sending' + }) }) it('does not track when follow-up prompt delivery rejects', async () => { diff --git a/src/renderer/src/lib/new-workspace.ts b/src/renderer/src/lib/new-workspace.ts index d19da5e5de42..8a8d13f26527 100644 --- a/src/renderer/src/lib/new-workspace.ts +++ b/src/renderer/src/lib/new-workspace.ts @@ -1,10 +1,11 @@ +import { agentPromptRidesLaunchCommand } from '../../../shared/tui-agent-startup' import { useAppStore } from '@/store' import { getSettingsForAgentTabRuntimeOwner, pasteDraftToAgentPtyWhenReady } from '@/lib/agent-paste-draft' import { sendFollowupPromptWhenAgentReady } from '@/lib/agent-followup-delivery' -import { showAutomationPromptNotSentToast } from '@/lib/agent-background-session-timeout-toast' +import { showAgentLaunchPromptNotDeliveredNotice } from '@/lib/agent-launch-prompt-not-delivered-notice' import type { AgentStartupPlan } from '@/lib/tui-agent-startup' import type { LinkedWorkItemContext } from '@/lib/linked-work-item-context' import { @@ -287,16 +288,27 @@ async function deliverAgentStartupToTerminal( // (aider, goose, etc.) that need their initial prompt typed into the live // session and submitted. Wait until the agent owns the PTY before writing. if (startup.followupPrompt) { - const delivered = await sendFollowupPromptWhenAgentReady({ - ptyId, - expectedProcess: startup.expectedProcess, - prompt: startup.followupPrompt, - settings: runtimeSettings - }) - // Why: a dropped follow-up is otherwise silent — surface the same toast the - // draft path uses so the user knows to open the workspace and paste it. + const prompt = startup.followupPrompt + // Why: an argv agent's prompt left for paste (a WSL line too long to type, a paired host) waits + // for the agent's composer and lands as one bracketed paste, not raw keys once the process shows. + const delivered = agentPromptRidesLaunchCommand(startup.agent) + ? await pasteDraftToAgentPtyWhenReady({ + tabId, + ptyId, + content: prompt, + agent: startup.agent, + submit: true, + forcePaste: true + }) + : await sendFollowupPromptWhenAgentReady({ + ptyId, + expectedProcess: startup.expectedProcess, + prompt, + settings: runtimeSettings + }) + // Why: a dropped follow-up is otherwise silent; the notice hands the prompt back. if (!delivered) { - showAutomationPromptNotSentToast(startup.agent) + showAgentLaunchPromptNotDeliveredNotice({ agent: startup.agent, prompt }) } } @@ -313,7 +325,8 @@ async function deliverAgentStartupToTerminal( // planning is unavailable, so this paste is the first delivery attempt. forcePaste: true, // Why: surface a dropped draft instead of silently losing it. - onTimeout: () => showAutomationPromptNotSentToast(startup.agent) + onTimeout: () => + showAgentLaunchPromptNotDeliveredNotice({ agent: startup.agent, prompt: draftPrompt }) }) } } diff --git a/src/renderer/src/lib/runtime-agent-background-create.ts b/src/renderer/src/lib/runtime-agent-background-create.ts index 4733be96db5a..db44cdc16222 100644 --- a/src/renderer/src/lib/runtime-agent-background-create.ts +++ b/src/renderer/src/lib/runtime-agent-background-create.ts @@ -29,13 +29,18 @@ export async function createRuntimeAgentBackgroundTerminal(args: { launchConfig: SleepingAgentLaunchConfig launchToken: string title?: string + /** The command launches clean; the caller pastes the prompt once this legacy path ran. */ + promptLeftForPaste?: true } -}): Promise<{ terminal: RuntimeTerminalCreate }> { +}): Promise<{ terminal: RuntimeTerminalCreate; promptLeftForPaste?: true }> { const keyboardProtocol = buildDefaultTerminalOptions().vtExtensions?.kittyKeyboard const keyboardOptions = createAgentSessionKeyboardOptions(keyboardProtocol) const operation = createAgentSessionCreateOperation() const launchPreferences = toAgentLaunchPreferences(args.sessionOptions) - return await runRemoteAgentSessionLaunch({ + return await runRemoteAgentSessionLaunch<{ + terminal: RuntimeTerminalCreate + promptLeftForPaste?: true + }>({ environmentId: args.environmentId, hostAuthority: () => operation.run(async (clientOperationId) => @@ -60,8 +65,8 @@ export async function createRuntimeAgentBackgroundTerminal(args: { { timeoutMs: 15_000 } ) ), - legacy: ({ skipCompatibilityCheck }) => - callRuntimeRpc<{ terminal: RuntimeTerminalCreate }>( + legacy: async ({ skipCompatibilityCheck }) => ({ + ...(await callRuntimeRpc<{ terminal: RuntimeTerminalCreate }>( { kind: 'environment', environmentId: args.environmentId }, 'terminal.create', { @@ -81,6 +86,8 @@ export async function createRuntimeAgentBackgroundTerminal(args: { presentation: 'background' }, { timeoutMs: 15_000, skipCompatibilityCheck } - ) + )), + ...(args.legacy.promptLeftForPaste ? { promptLeftForPaste: true as const } : {}) + }) }) } diff --git a/src/renderer/src/lib/source-control-agent-action-plan.test.ts b/src/renderer/src/lib/source-control-agent-action-plan.test.ts index a1cb9b6d3edd..5441f2cde685 100644 --- a/src/renderer/src/lib/source-control-agent-action-plan.test.ts +++ b/src/renderer/src/lib/source-control-agent-action-plan.test.ts @@ -27,7 +27,7 @@ describe('planSourceControlAgentActionLaunch', () => { ).toEqual({ ok: false, error: 'The selected agent was not detected on this workspace host.' }) }) - it('mirrors submit-after-ready delivery without embedding the prompt in the command', () => { + it('carries a submit-after-ready prompt on the command line of an agent that takes one', () => { const result = planSourceControlAgentActionLaunch({ agent: 'codex', commandInput: 'Fix checks', @@ -36,12 +36,25 @@ describe('planSourceControlAgentActionLaunch', () => { platform: 'linux' }) - expect(result.ok && result.delivery).toBe('paste-submit') - expect(result.ok && result.commandLabel).toBe('codex') - expect(result.ok && result.summary).toContain('pastes and submits') + expect(result.ok && result.delivery).toBe('argv') + expect(result.ok && result.commandLabel).toBe("codex 'Fix checks'") + expect(result.ok && result.summary).toContain('included in the launch command') expect(result.ok && result.caveat).toContain('PATH') }) + it('says an input past the argv ceiling goes in a private file, not on the command line', () => { + const result = planSourceControlAgentActionLaunch({ + agent: 'codex', + commandInput: 'z'.repeat(200_000), + promptDelivery: 'submit-after-ready', + detectedAgents: ['codex'], + platform: 'linux' + }) + + expect(result.ok && result.summary).toContain('private file') + expect(result.ok && result.summary).not.toContain('included in the launch command') + }) + it('includes per-action CLI arguments in submit-after-ready launch plans', () => { const result = planSourceControlAgentActionLaunch({ agent: 'codex', @@ -52,7 +65,20 @@ describe('planSourceControlAgentActionLaunch', () => { platform: 'linux' }) - expect(result.ok && result.commandLabel).toBe("codex '--model' 'gpt-5.5'") + expect(result.ok && result.commandLabel).toBe("codex '--model' 'gpt-5.5' 'Fix checks'") + }) + + it('still pastes and submits for an agent that takes its text only after start', () => { + const result = planSourceControlAgentActionLaunch({ + agent: 'amp', + commandInput: 'Fix checks', + promptDelivery: 'submit-after-ready', + detectedAgents: ['amp'], + platform: 'linux' + }) + + expect(result.ok && result.delivery).toBe('paste-submit') + expect(result.ok && result.summary).toContain('pastes and submits') }) it.each([ diff --git a/src/renderer/src/lib/source-control-agent-action-plan.ts b/src/renderer/src/lib/source-control-agent-action-plan.ts index 5e6681975e6a..223b3b879f03 100644 --- a/src/renderer/src/lib/source-control-agent-action-plan.ts +++ b/src/renderer/src/lib/source-control-agent-action-plan.ts @@ -100,7 +100,11 @@ export function planSourceControlAgentActionLaunch(args: { let startupPlan: AgentStartupPlan | null = null let delivery: SourceControlLaunchPlanDelivery - if (args.promptDelivery === 'submit-after-ready') { + // Only an agent that takes no launch prompt waits for readiness; the rest carry it on argv. + if ( + args.promptDelivery === 'submit-after-ready' && + TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start' + ) { startupPlan = buildAgentStartupPlan({ agent, prompt: '', @@ -199,7 +203,9 @@ export function planSourceControlAgentActionLaunch(args: { ? 'The command input is prefilled as an editable draft by the agent launch command.' : delivery === 'draft-paste' ? 'The agent starts with no prompt, then Orca pastes the command input as an editable draft after the TUI is ready.' - : 'The command input is included in the launch command and submitted as the first turn.' + : startupPlan.launchFile + ? 'The command input goes in a private file the host writes; the launch command tells the agent to read it, submitted as the first turn.' + : 'The command input is included in the launch command and submitted as the first turn.' return { ok: true, diff --git a/src/renderer/src/lib/worktree-creation-flow-startup.ts b/src/renderer/src/lib/worktree-creation-flow-startup.ts index c1268d34a294..5201719192a9 100644 --- a/src/renderer/src/lib/worktree-creation-flow-startup.ts +++ b/src/renderer/src/lib/worktree-creation-flow-startup.ts @@ -27,6 +27,7 @@ export function buildWorktreeCreationStartupOpt( // the sole signal that this launch starts with unsent context in the TUI. ...(request.launchDraftPrompt ? { launchDraftText: request.launchDraftPrompt } : {}), ...(plan.startupCommandDelivery ? { startupCommandDelivery: plan.startupCommandDelivery } : {}), + ...(plan.launchFile ? { launchFile: plan.launchFile } : {}), // Why: command-code shows its prompt in the tab status before the first // hook fires, so the prompt is threaded through here. ...(request.agent === 'command-code' && request.quickPrompt.trim().length > 0 diff --git a/src/renderer/src/lib/worktree-startup-payload.ts b/src/renderer/src/lib/worktree-startup-payload.ts index afb29b980dde..ab3b3b44d5b9 100644 --- a/src/renderer/src/lib/worktree-startup-payload.ts +++ b/src/renderer/src/lib/worktree-startup-payload.ts @@ -6,6 +6,7 @@ import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-resume' import type { SessionOptionValue } from '../../../shared/native-chat-session-options' +import type { LaunchFile } from '../../../shared/launch-prompt-file' /** Telemetry threaded from the launch site to `pty:spawn`; main fires `agent_started` * only after the spawn succeeds. See telemetry-plan.md§Agent launch semantics. */ @@ -30,6 +31,8 @@ export type WorktreeStartupPayload = { */ launchDraftText?: string startupCommandDelivery?: StartupCommandDelivery + /** Written by the host before it types `command`, which names it by placeholder. */ + launchFile?: LaunchFile initialAgentStatus?: { agent: TuiAgent; prompt: string } sessionOptions?: Record telemetry?: AgentStartedTelemetry diff --git a/src/renderer/src/store/terminals/terminal-actions.ts b/src/renderer/src/store/terminals/terminal-actions.ts index d1bc838346d7..c0448be5ee3a 100644 --- a/src/renderer/src/store/terminals/terminal-actions.ts +++ b/src/renderer/src/store/terminals/terminal-actions.ts @@ -11,6 +11,7 @@ import type { } from '../../../../shared/agent-session-resume' import type { DirectSshAuthority } from '../../../../shared/ssh-types' import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' +import type { LaunchFile } from '../../../../shared/launch-prompt-file' import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' import type { AgentStartedTelemetry } from '../../lib/worktree-startup-payload' import type { AiVaultSessionTitle } from '../../../../shared/ai-vault-session-title' @@ -205,6 +206,7 @@ export type TerminalActions = { command: string delivery?: 'terminal-paste' startupCommandDelivery?: StartupCommandDelivery + launchFile?: LaunchFile env?: Record envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig @@ -231,6 +233,7 @@ export type TerminalActions = { command: string delivery?: 'terminal-paste' startupCommandDelivery?: StartupCommandDelivery + launchFile?: LaunchFile env?: Record envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig diff --git a/src/renderer/src/store/terminals/terminal-state.ts b/src/renderer/src/store/terminals/terminal-state.ts index d0595b8721f8..a91dfbdbd26d 100644 --- a/src/renderer/src/store/terminals/terminal-state.ts +++ b/src/renderer/src/store/terminals/terminal-state.ts @@ -9,6 +9,7 @@ import type { } from '../../../../shared/agent-session-resume' import type { ExecutionHostId } from '../../../../shared/execution-host' import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' +import type { LaunchFile } from '../../../../shared/launch-prompt-file' import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' import type { AgentStartedTelemetry } from '../../lib/worktree-startup-payload' import type { @@ -65,6 +66,7 @@ export type TerminalState = { command: string delivery?: 'terminal-paste' startupCommandDelivery?: StartupCommandDelivery + launchFile?: LaunchFile env?: Record envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig diff --git a/src/shared/agent-launch-runtime-capability.ts b/src/shared/agent-launch-runtime-capability.ts index 2094f705dc7b..e7b9b1ed2aa4 100644 --- a/src/shared/agent-launch-runtime-capability.ts +++ b/src/shared/agent-launch-runtime-capability.ts @@ -21,8 +21,8 @@ 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. +// A host that delivers a launch prompt its typed startup line cannot carry as typed (staged, a launch +// file, or pasted); an older host types 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. diff --git a/src/shared/claude-agent-teams-tmux-compat.ts b/src/shared/claude-agent-teams-tmux-compat.ts index 6d1f5eafb939..c85fe501e52b 100644 --- a/src/shared/claude-agent-teams-tmux-compat.ts +++ b/src/shared/claude-agent-teams-tmux-compat.ts @@ -167,14 +167,20 @@ function tmuxSpecialKeyText(token: string): string | null { } export function isDirectClaudeCommand(command: string | undefined): boolean { - const trimmed = command?.trim() ?? '' - if (!trimmed) { - return false - } - if (/[;&|<>`]/.test(trimmed)) { - return false - } - const first = trimmed.match(/^\S+/)?.[0] ?? '' + return !/[;&|<>`]/.test(command ?? '') && startsWithClaudeExecutable(command) +} + +/** + * Whether a launch Orca built for Claude can take the teammate flag after its executable. The rest + * of the line is Orca's own quoting, so a metacharacter inside the prompt (a launch-file pointer's + * backticks, a user's `&`) says nothing about the command; only the first token matters. + */ +export function isClaudeLaunchLine(command: string | undefined): boolean { + return startsWithClaudeExecutable(command) +} + +function startsWithClaudeExecutable(command: string | undefined): boolean { + const first = command?.trim().match(/^\S+/)?.[0] ?? '' return first === 'claude' || first.endsWith('/claude') } diff --git a/src/shared/cmd-startup-arg-quoting.win32.test.ts b/src/shared/cmd-startup-arg-quoting.win32.test.ts new file mode 100644 index 000000000000..1e7dd8424b4e --- /dev/null +++ b/src/shared/cmd-startup-arg-quoting.win32.test.ts @@ -0,0 +1,125 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { runProcess } from './child-process/run-process' +import { + WINDOWS_ARGUMENT_CORPUS, + WINDOWS_ARGUMENT_CORPUS_ENV +} from './child-process/__fixtures__/windows-argument-corpus' +import { removeTreeSync } from './windows-transient-lock-removal' +import { quoteStartupArg } from './tui-agent-startup-shell' +import { buildAgentStartupPlan } from './tui-agent-startup' +import { removeLaunchFile, writeLaunchFile } from './launch-file-writing' + +/** + * A launch line typed into a cmd pane is read by cmd's command-line parser, as stdin is here, then + * by the agent's `.cmd` shim. Runs only on win32; skipped elsewhere. + */ +const describeOnWindows = process.platform === 'win32' ? describe : describe.skip + +describeOnWindows('cmd launch-line prompt quoting', () => { + let dir: string + let shim: string + + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-cmd-startup-arg-')) + shim = join(dir, 'echoargs.cmd') + writeFileSync(shim, '@echo off\r\nnode "%~dp0echoargs.js" %*\r\n') + writeFileSync( + join(dir, 'echoargs.js'), + 'process.stdout.write(process.argv.slice(2).map((a) => `ARG<${a}>`).join("\\n") + "\\nEND\\n")\n' + ) + }) + + afterAll(() => { + removeTreeSync(dir) + }) + + async function typeIntoCmd(line: string): Promise { + const result = await runProcess({ + program: 'cmd.exe', + // cmd decodes piped stdin in the console code page; a real pane's input arrives as UTF-16. + args: ['/d', '/q', '/k', 'chcp', '65001'], + input: `${line}\r\nexit\r\n`, + env: { ...process.env, ...WINDOWS_ARGUMENT_CORPUS_ENV }, + timeoutMs: 30_000 + }) + const block = /((?:ARG<[\s\S]*?>\n?)*)END/.exec(result.stdout.replace(/\r\n/g, '\n'))?.[1] ?? '' + return [...block.matchAll(/ARG<([\s\S]*?)>(?=\nARG<|\n?$)/g)].map((match) => match[1]!) + } + + it('delivers a short prompt with no stray carets', async () => { + const prompt = 'Fix (the) build & ship 100% of it! ^_^' + expect(await typeIntoCmd(`"${shim}" ${quoteStartupArg(prompt, 'cmd')}`)).toEqual([prompt]) + }) + + it.each(WINDOWS_ARGUMENT_CORPUS.filter(({ value }) => value !== ''))( + 'delivers $name unchanged', + async ({ value }) => { + expect(await typeIntoCmd(`"${shim}" ${quoteStartupArg(value, 'cmd')}`)).toEqual([value]) + } + ) + + it('never lets a multi-line prompt reach cmd as commands: the line names a launch file', async () => { + const marker = join(dir, 'pwned.txt') + const prompt = `Fix the build\r\n& echo PWNED> "${marker}"\nthen run the tests` + const plan = buildAgentStartupPlan({ + agent: 'claude', + prompt, + cmdOverrides: { claude: shim }, + platform: 'win32', + shell: 'cmd' + }) + const launchFile = plan?.launchFile + if (!plan || !launchFile) { + throw new Error('a multi-line cmd prompt must ride a launch file') + } + // What the host does before typing: write the file, put its path where the placeholder was. + const written = writeLaunchFile({ + launchFile, + command: plan.launchCommand, + platform: 'win32' + }) + try { + const typed = written.command ?? '' + expect(typed).not.toMatch(/[\r\n]/) + const args = await typeIntoCmd(typed) + expect(args).toHaveLength(1) + expect(args[0]).toContain(written.path) + expect(readFileSync(written.path, 'utf8')).toBe(prompt) + expect(existsSync(marker)).toBe(false) + } finally { + removeLaunchFile(written) + } + }) + + it('names a launch file under a home with an apostrophe, accents, CJK and `%`', async () => { + const home = join(dir, "O'Brien José 张伟 100%") + mkdirSync(home) + const plan = buildAgentStartupPlan({ + agent: 'claude', + prompt: 'Fix the build\nthen run the tests', + cmdOverrides: { claude: shim }, + platform: 'win32', + shell: 'cmd' + }) + const launchFile = plan?.launchFile + if (!plan || !launchFile) { + throw new Error('a multi-line cmd prompt must ride a launch file') + } + const written = writeLaunchFile({ + launchFile, + command: plan.launchCommand, + platform: 'win32', + baseDirectory: home + }) + try { + const args = await typeIntoCmd(written.command ?? '') + expect(args).toHaveLength(1) + expect(args[0]).toContain(written.path) + } finally { + removeLaunchFile(written) + } + }) +}) diff --git a/src/shared/launch-file-directory-grant.test.ts b/src/shared/launch-file-directory-grant.test.ts new file mode 100644 index 000000000000..bf50b3e03dc5 --- /dev/null +++ b/src/shared/launch-file-directory-grant.test.ts @@ -0,0 +1,107 @@ +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { writeLaunchFile } from './launch-file-writing' +import { + buildLaunchFilePointer, + carryInLaunchFile, + launchFileDirectoryPlaceholder +} from './launch-prompt-file' +import { buildAgentStartupPlan } from './tui-agent-startup' +import type { TuiAgent } from './tui-agent' + +function planWithLaunchFile(agent: TuiAgent, shell: 'posix' | 'cmd' | 'powershell' = 'posix') { + const { prompt, launchFile } = carryInLaunchFile('the brief', true) + const plan = buildAgentStartupPlan({ + agent, + prompt, + cmdOverrides: {}, + platform: shell === 'posix' ? 'darwin' : 'win32', + shell, + launchFile + }) + return { plan, pointer: prompt, dir: launchFileDirectoryPlaceholder(launchFile.placeholder) } +} + +describe('the launch-file directory grant on the agent command line', () => { + // Claude 2.1.280 parses `--add-dir ` variadically: in `--add-dir ''` + // the prompt would become a second directory. Its parser keeps `--add-dir=` to one value. + it('grants Claude the directory as one `--add-dir=` token, before its positional prompt', () => { + const { plan, pointer, dir } = planWithLaunchFile('claude') + expect(plan?.launchCommand).toBe(`claude '--add-dir=${dir}' '${pointer}'`) + }) + + it('grants nothing to an agent not measured reading its launch file', () => { + const { plan, pointer } = planWithLaunchFile('gemini') + expect(plan?.launchCommand).toBe(`gemini --prompt-interactive '${pointer}'`) + }) + + it.each([ + [ + 'cmd', + (dir: string, pointer: string) => + `claude "--add-dir=${dir}" "${pointer.replaceAll('"', '""')}"` + ], + ['powershell', (dir: string, pointer: string) => `claude '--add-dir=${dir}' '${pointer}'`] + ] as const)('quotes the grant for %s like the prompt beside it', (shell, expected) => { + const { plan, pointer, dir } = planWithLaunchFile('claude', shell) + expect(plan?.launchCommand).toBe(expected(dir, pointer)) + }) + + it('adds nothing for an agent that reads outside its workspace freely, or without a file', () => { + const { plan, pointer } = planWithLaunchFile('codex') + expect(plan?.launchCommand).toBe(`codex '${pointer}'`) + expect( + buildAgentStartupPlan({ + agent: 'claude', + prompt: 'fix it', + cmdOverrides: {}, + platform: 'darwin' + })?.launchCommand + ).toBe(`claude 'fix it'`) + }) +}) + +describe('the host writing a launch file whose directory is granted', () => { + let baseDirectory: string + + beforeEach(() => { + baseDirectory = mkdtempSync(join(tmpdir(), 'orca-launch-grant-test-')) + }) + + afterEach(() => { + rmSync(baseDirectory, { recursive: true, force: true }) + }) + + it("names only the launch file's own private directory, never the shared temp root", () => { + const { plan } = planWithLaunchFile('claude') + const written = writeLaunchFile({ + launchFile: plan!.launchFile!, + command: plan!.launchCommand, + baseDirectory + }) + expect(written.directory).not.toBe(baseDirectory) + expect(written.directory).toBe(realpathSync(written.directory)) + expect(written.command).toBe( + `claude '--add-dir=${written.directory}' '${buildLaunchFilePointer(written.path)}'` + ) + }) + + it('names the resolved directory when the temp root is reached through a symlink', () => { + const real = join(baseDirectory, 'private-var') + mkdirSync(real) + const linked = join(baseDirectory, 'var') + symlinkSync(real, linked) + const { plan } = planWithLaunchFile('claude') + const written = writeLaunchFile({ + launchFile: plan!.launchFile!, + command: plan!.launchCommand, + baseDirectory: linked + }) + expect(written.directory.startsWith(realpathSync(real))).toBe(true) + expect(written.command).toBe( + `claude '--add-dir=${written.directory}' '${buildLaunchFilePointer(written.path)}'` + ) + }) +}) diff --git a/src/shared/launch-file-writing.test.ts b/src/shared/launch-file-writing.test.ts new file mode 100644 index 000000000000..5f8620e36cd6 --- /dev/null +++ b/src/shared/launch-file-writing.test.ts @@ -0,0 +1,214 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + utimesSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { runProcess } from './child-process/run-process' +import { + LAUNCH_FILE_STALE_MS, + LaunchFileUnavailableError, + launchFilePathInQuotedRun, + removeLaunchFile, + sweepStaleLaunchFiles, + writeLaunchFile +} from './launch-file-writing' +import { + MAX_INLINE_LAUNCH_PROMPT_CHARS, + buildLaunchFilePointer, + parseLaunchFile, + planLaunchPrompt +} from './launch-prompt-file' +import { quoteStartupArg } from './tui-agent-startup-shell' + +let baseDirectory: string + +beforeEach(() => { + baseDirectory = mkdtempSync(join(tmpdir(), 'orca-launchfile-test-')) +}) + +afterEach(() => { + rmSync(baseDirectory, { recursive: true, force: true }) +}) + +describe('planLaunchPrompt', () => { + it('keeps a prompt at the ceiling inline', () => { + const prompt = 'a'.repeat(MAX_INLINE_LAUNCH_PROMPT_CHARS) + expect(planLaunchPrompt(prompt)).toEqual({ prompt }) + }) + + it('moves a 20,000-character prompt into a launch file', () => { + const prompt = 'b'.repeat(20_000) + const planned = planLaunchPrompt(prompt) + expect(planned.launchFile).toMatchObject({ content: prompt, sensitive: false }) + expect(planned.prompt).toBe(buildLaunchFilePointer(planned.launchFile!.placeholder)) + }) + + it('moves a short sensitive prompt into a launch file', () => { + const planned = planLaunchPrompt('token dcap_secret', { sensitive: true }) + expect(planned.prompt).not.toContain('dcap_secret') + expect(planned.launchFile).toMatchObject({ content: 'token dcap_secret', sensitive: true }) + }) + + it('mints a fresh placeholder each time', () => { + const first = planLaunchPrompt('x', { sensitive: true }).launchFile!.placeholder + const second = planLaunchPrompt('x', { sensitive: true }).launchFile!.placeholder + expect(first).not.toBe(second) + }) +}) + +describe('parseLaunchFile', () => { + it('accepts what planLaunchPrompt mints and rejects anything else', () => { + const { launchFile } = planLaunchPrompt('x', { sensitive: true }) + expect(parseLaunchFile(launchFile)).toEqual(launchFile) + expect(parseLaunchFile({ ...launchFile, placeholder: 'HOME' })).toBeUndefined() + expect(parseLaunchFile({ ...launchFile, sensitive: 'yes' })).toBeUndefined() + expect(parseLaunchFile({ ...launchFile, quoting: 'cmd' })).toEqual({ + ...launchFile, + quoting: 'cmd' + }) + expect(parseLaunchFile({ ...launchFile, quoting: 'fish' })).toBeUndefined() + expect(parseLaunchFile(null)).toBeUndefined() + }) +}) + +describe('writeLaunchFile', () => { + it('writes a 0600 file in a private 0700 directory and puts its path in the line and env', () => { + const { prompt, launchFile } = planLaunchPrompt('c'.repeat(20_000)) + const written = writeLaunchFile({ + launchFile: launchFile!, + command: `claude '${prompt}'`, + env: { ORCA_HERMES_STARTUP_QUERY: prompt, OTHER: 'kept' }, + baseDirectory + }) + expect(dirname(written.path)).toBe(written.directory) + expect(statSync(written.directory).mode & 0o777).toBe(0o700) + expect(statSync(written.path).mode & 0o777).toBe(0o600) + expect(readFileSync(written.path, 'utf8')).toBe('c'.repeat(20_000)) + expect(written.command).toBe(`claude '${buildLaunchFilePointer(written.path)}'`) + expect(written.env).toEqual({ + ORCA_HERMES_STARTUP_QUERY: buildLaunchFilePointer(written.path), + OTHER: 'kept' + }) + }) + + it("refuses an apostrophe in the path when the line's quoting is unknown, leaving nothing behind", () => { + const quoted = join(baseDirectory, "it's") + mkdirSync(quoted) + const { launchFile } = planLaunchPrompt('x', { sensitive: true }) + expect(() => writeLaunchFile({ launchFile: launchFile!, baseDirectory: quoted })).toThrow( + LaunchFileUnavailableError + ) + expect(readdirSync(quoted)).toEqual([]) + }) + + it('refuses when the temp directory cannot be written', () => { + const { launchFile } = planLaunchPrompt('x', { sensitive: true }) + expect(() => + writeLaunchFile({ launchFile: launchFile!, baseDirectory: join(baseDirectory, 'missing') }) + ).toThrow( + /^Orca could not write the file that carries the agent's prompt \(.+\), so the agent was not started\./ + ) + }) + + it('removes the whole directory', () => { + const { launchFile } = planLaunchPrompt('x', { sensitive: true }) + const written = writeLaunchFile({ launchFile: launchFile!, baseDirectory }) + removeLaunchFile(written) + expect(existsSync(written.directory)).toBe(false) + }) +}) + +describe('launchFilePathInQuotedRun', () => { + const temp = (user: string): string => + `C:\\Users\\${user}\\AppData\\Local\\Temp\\orca-launch-file-a1\\task-context.md` + + it('carries letters of any script as they are, whatever the quoting', () => { + for (const path of [temp('José'), temp('张伟')]) { + expect(launchFilePathInQuotedRun(path, 'cmd', 'win32')).toBe(path) + expect(launchFilePathInQuotedRun(path, 'powershell', 'win32')).toBe(path) + expect(launchFilePathInQuotedRun(path, undefined, 'win32')).toBe(path) + } + }) + + it('writes an apostrophe the way each quoting reads it, and refuses it unquoted', () => { + const path = temp("O'Brien") + expect(launchFilePathInQuotedRun(path, 'cmd', 'win32')).toBe(path) + expect(launchFilePathInQuotedRun(path, 'powershell', 'win32')).toBe(temp("O''Brien")) + expect(launchFilePathInQuotedRun("/home/O'Brien/x", 'posix', 'linux')).toBe( + `/home/O'"'"'Brien/x` + ) + expect(launchFilePathInQuotedRun(path, undefined, 'win32')).toBeNull() + }) + + it("breaks `%` out of cmd's quoted run, where cmd would expand it", () => { + expect(launchFilePathInQuotedRun(temp('100%'), 'cmd', 'win32')).toBe(temp('100"^%"')) + expect(launchFilePathInQuotedRun(temp('100%'), 'powershell', 'win32')).toBe(temp('100%')) + expect(launchFilePathInQuotedRun(temp('100%'), undefined, 'win32')).toBeNull() + }) + + it('refuses a control character in any quoting', () => { + for (const quoting of ['posix', 'cmd', 'powershell', undefined] as const) { + expect(launchFilePathInQuotedRun('/tmp/a\nb/x', quoting, 'linux')).toBeNull() + } + }) +}) + +const describePosix = process.platform === 'win32' ? describe.skip : describe + +describePosix('a launch line naming a file under an unusual home directory', () => { + const shells = [ + ['/bin/sh', '-c'], + ['/bin/bash', '-c'], + ['/bin/zsh', '-f', '-c'], + ['/bin/dash', '-c'], + ['/opt/homebrew/bin/fish', '--no-config', '-c'], + ['/usr/bin/fish', '--no-config', '-c'] + ].filter(([shell]) => existsSync(shell)) + + it.each(["O'Brien", 'José', '张伟', 'back\\slash', "it's $'x' 100%"])( + 'reaches the agent as the exact path under %s', + async (user) => { + const home = join(baseDirectory, user) + mkdirSync(home) + const { prompt, launchFile } = planLaunchPrompt('brief', { sensitive: true }) + const written = writeLaunchFile({ + launchFile: { ...launchFile!, quoting: 'posix' }, + command: `printf '%s' ${quoteStartupArg(prompt, 'posix')}`, + baseDirectory: home + }) + for (const [shell, ...flags] of shells) { + const result = await runProcess({ + program: shell, + args: [...flags, written.command!], + timeoutMs: 10_000 + }) + expect(result.stdout, shell).toBe(buildLaunchFilePointer(written.path)) + } + } + ) +}) + +describe('sweepStaleLaunchFiles', () => { + it('removes only launch file directories older than a day', () => { + const now = Date.now() + const stale = join(baseDirectory, 'orca-launch-file-aaaaaa') + const fresh = join(baseDirectory, 'orca-launch-file-bbbbbb') + const unrelated = join(baseDirectory, 'unrelated') + for (const directory of [stale, fresh, unrelated]) { + mkdirSync(directory) + } + const staleSeconds = (now - LAUNCH_FILE_STALE_MS - 1000) / 1000 + utimesSync(stale, staleSeconds, staleSeconds) + utimesSync(unrelated, staleSeconds, staleSeconds) + sweepStaleLaunchFiles({ baseDirectory, now }) + expect(readdirSync(baseDirectory).sort()).toEqual(['orca-launch-file-bbbbbb', 'unrelated']) + }) +}) diff --git a/src/shared/launch-file-writing.ts b/src/shared/launch-file-writing.ts new file mode 100644 index 000000000000..b857163344a1 --- /dev/null +++ b/src/shared/launch-file-writing.ts @@ -0,0 +1,228 @@ +/** + * Writes a launch file on the host that owns the PTY and puts its path where the + * launch line and env name the placeholder. The file exists before any line + * naming it is typed. + */ +import { + mkdirSync, + mkdtempSync, + readdirSync, + realpathSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, join, posix } from 'node:path' +import { + describeLaunchFileUnavailable, + launchFileDirectoryPlaceholder, + type LaunchFile +} from './launch-prompt-file' +import { quoteStartupArg, type AgentStartupShell } from './tui-agent-startup-shell' +import { typedStartupLineFits } from './typed-startup-line' +import type { WslLaunchDirectory } from './wsl-launch-directory' + +const LAUNCH_FILE_DIR_PREFIX = 'orca-launch-file-' +const LAUNCH_FILE_NAME = 'task-context.md' + +/** A running agent may re-read its task file, so leftovers get a day, not an hour. */ +export const LAUNCH_FILE_STALE_MS = 24 * 60 * 60 * 1000 + +// Characters one of the three quotings ends or expands on: `'` (POSIX, PowerShell, which also +// honours the typographic single quotes), `"` and `%` (cmd). +const QUOTE_SENSITIVE_PATH_CHARACTER = /['"%\u2018-\u201b]/ + +function hasControlCharacter(value: string): boolean { + return [...value].some((char) => char < ' ' || char === '\x7f') +} + +/** + * The path as it must read inside the quoted run that held the placeholder, or null when that + * quoting cannot carry it. Letters in any script are literal in all three quotings. + */ +export function launchFilePathInQuotedRun( + path: string, + quoting: AgentStartupShell | undefined, + platform: NodeJS.Platform +): string | null { + // No quoting keeps a typed line break or other control byte literal. + if (hasControlCharacter(path)) { + return null + } + if (quoting === 'posix') { + // The same escapes the POSIX quoter uses between its single-quoted runs. + return [...path] + .map((char) => (char === "'" ? `'"'"'` : char === '\\' ? `'"\\\\"'` : char)) + .join('') + } + if (quoting === 'cmd') { + // No Windows path holds `"`; the quoter breaks `%` out of the run. + return path.includes('"') ? null : quoteStartupArg(path, 'cmd').slice(1, -1) + } + if (quoting === 'powershell') { + return quoteStartupArg(path, 'powershell').slice(1, -1) + } + const sensitive = + QUOTE_SENSITIVE_PATH_CHARACTER.test(path) || (platform !== 'win32' && path.includes('\\')) + return sensitive ? null : path +} + +export class LaunchFileUnavailableError extends Error { + constructor(reason: string) { + super(describeLaunchFileUnavailable(reason)) + } +} + +export type WrittenLaunchFile = { + /** Where the host wrote it, and removes it from. */ + directory: string + /** The file as the line and the agent name it (a Linux path for a WSL session). */ + path: string + command?: string + env?: Record +} + +const sweptBaseDirectories = new Set() + +/** Throws LaunchFileUnavailableError rather than type a line naming a file that is not there. */ +export function writeLaunchFile(args: { + launchFile: LaunchFile + command?: string + env?: Record + platform?: NodeJS.Platform + baseDirectory?: string + /** A WSL session's: written over UNC, named in the line by its Linux path. */ + wslDirectory?: WslLaunchDirectory +}): WrittenLaunchFile { + const wsl = args.wslDirectory + const platform = wsl ? 'linux' : (args.platform ?? process.platform) + const baseDirectory = wsl?.windowsPath ?? args.baseDirectory ?? tmpdir() + if (!sweptBaseDirectories.has(baseDirectory)) { + sweptBaseDirectories.add(baseDirectory) + // Why deferred: the sweep is crash recovery and must never delay this launch. + setTimeout(() => sweepStaleLaunchFiles({ baseDirectory }), 0).unref?.() + } + let directory: string | undefined + try { + if (wsl) { + mkdirSync(baseDirectory, { recursive: true }) + } + // Why realpath: an agent matches its read grant against the resolved path (macOS $TMPDIR sits + // under /var -> /private/var), so the pointer and the granted directory both name that form. + // A UNC path into WSL is named by its Linux path instead, which the distro resolves itself. + const created = mkdtempSync(join(baseDirectory, LAUNCH_FILE_DIR_PREFIX)) + directory = wsl ? created : realpathSync(created) + const launchDirectory = wsl ? posix.join(wsl.linuxPath, basename(created)) : directory + const path = (wsl ? posix.join : join)(launchDirectory, LAUNCH_FILE_NAME) + const quotedPath = launchFilePathInQuotedRun(path, args.launchFile.quoting, platform) + const quotedDirectory = launchFilePathInQuotedRun( + launchDirectory, + args.launchFile.quoting, + platform + ) + if (quotedPath === null || quotedDirectory === null) { + throw new LaunchFileUnavailableError('temp directory path cannot be quoted') + } + writeFileSync(join(directory, LAUNCH_FILE_NAME), args.launchFile.content, { + mode: 0o600, + flag: 'wx' + }) + const directoryPlaceholder = launchFileDirectoryPlaceholder(args.launchFile.placeholder) + // Why functions: a string replacement would expand `$'` and `$&` inside the path. + const substitute = (value: string, filePath: string, directoryPath: string): string => + value + .replaceAll(args.launchFile.placeholder, () => filePath) + .replaceAll(directoryPlaceholder, () => directoryPath) + return { + directory, + path, + ...(args.command !== undefined + ? { command: substitute(args.command, quotedPath, quotedDirectory) } + : {}), + ...(args.env + ? { + env: Object.fromEntries( + Object.entries(args.env).map(([key, value]) => [ + key, + substitute(value, path, launchDirectory) + ]) + ) + } + : {}) + } + } catch (error) { + if (directory) { + rmSync(directory, { recursive: true, force: true }) + } + throw error instanceof LaunchFileUnavailableError + ? error + : new LaunchFileUnavailableError(error instanceof Error ? error.message : String(error)) + } +} + +/** + * The write site's launch file for a spawn. A WSL session writes it into the distro, and refuses + * when the distro directory is unknown and a file or a staged line needs it: a Windows path means + * nothing to an agent in the distro, and a long line typed raw is cut or submitted early. + */ +export function writeSpawnLaunchFile(args: { + launchFile?: LaunchFile + command?: string + env?: Record + orcaBuiltLine: boolean + wslDistro: string | null | undefined + wslDirectory: WslLaunchDirectory | undefined +}): WrittenLaunchFile | undefined { + const wslDirectory = args.wslDistro ? args.wslDirectory : undefined + const needsDirectory = + args.launchFile !== undefined || + (args.orcaBuiltLine && args.command !== undefined && !typedStartupLineFits(args.command)) + if (args.wslDistro && needsDirectory && wslDirectory?.distro !== args.wslDistro) { + throw new LaunchFileUnavailableError("the WSL distro's home directory could not be reached") + } + return args.launchFile + ? writeLaunchFile({ + launchFile: args.launchFile, + command: args.command, + env: args.env, + wslDirectory + }) + : undefined +} + +export function removeLaunchFile(written: WrittenLaunchFile | undefined): void { + if (!written) { + return + } + try { + rmSync(written.directory, { recursive: true, force: true }) + } catch { + // The age-gated sweep is the fallback. + } +} + +/** Removes launch files a crashed host left behind; age-gated so a live agent's file survives. */ +export function sweepStaleLaunchFiles(args: { baseDirectory?: string; now?: number }): void { + const baseDirectory = args.baseDirectory ?? tmpdir() + const now = args.now ?? Date.now() + let names: string[] + try { + names = readdirSync(baseDirectory) + } catch { + return + } + for (const name of names) { + if (!name.startsWith(LAUNCH_FILE_DIR_PREFIX)) { + continue + } + const directory = join(baseDirectory, name) + try { + if (now - statSync(directory).mtimeMs > LAUNCH_FILE_STALE_MS) { + rmSync(directory, { recursive: true, force: true }) + } + } catch { + // Raced another instance's sweep. + } + } +} diff --git a/src/shared/launch-prompt-carrier.test.ts b/src/shared/launch-prompt-carrier.test.ts new file mode 100644 index 000000000000..3f73193b1643 --- /dev/null +++ b/src/shared/launch-prompt-carrier.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { buildAgentStartupPlan } from './tui-agent-startup' + +const base = { agent: 'claude' as const, cmdOverrides: {} } + +describe('the one launch-prompt decision every launch path builds through', () => { + // Why: the composer, phone quick commands and background sessions call the builder directly. + it('points a single-line prompt past cmd’s line cap at a launch file', () => { + const prompt = 'y'.repeat(9_000) + const plan = buildAgentStartupPlan({ ...base, prompt, platform: 'win32', shell: 'cmd' }) + expect(plan?.launchFile?.content).toBe(prompt) + expect(plan?.launchCommand.length).toBeLessThan(8_191) + }) + + it('points a prompt past the argv ceiling at a launch file on a POSIX host too', () => { + const prompt = 'z'.repeat(20_000) + expect(buildAgentStartupPlan({ ...base, prompt, platform: 'linux' })?.launchFile?.content).toBe( + prompt + ) + }) + + it('keeps a sensitive prompt off the line however short it is', () => { + const plan = buildAgentStartupPlan({ + ...base, + prompt: 'token dcap_secret', + platform: 'linux', + sensitive: true + }) + expect(plan?.launchFile).toMatchObject({ content: 'token dcap_secret', sensitive: true }) + expect(plan?.launchCommand).not.toContain('dcap_secret') + }) + + // Why: the host writes a WSL session's staged line and launch file into the distro. + it('plans a WSL launch like any Linux launch: long lines stay typed, huge ones get a file', () => { + const typed = buildAgentStartupPlan({ ...base, platform: 'linux', prompt: 'one\ntwo' }) + expect(typed?.launchCommand).toContain('two') + expect(typed?.followupPrompt).toBeNull() + const huge = 'c'.repeat(20_000) + const pointed = buildAgentStartupPlan({ ...base, platform: 'linux', prompt: huge }) + expect(pointed?.launchFile?.content).toBe(huge) + expect(pointed?.followupPrompt).toBeNull() + }) +}) diff --git a/src/shared/launch-prompt-file.ts b/src/shared/launch-prompt-file.ts new file mode 100644 index 000000000000..4c563404fc7f --- /dev/null +++ b/src/shared/launch-prompt-file.ts @@ -0,0 +1,114 @@ +/** + * A launch prompt too long for a command line, or carrying a secret, rides in a + * file the execution host writes; the command line carries only a sentence + * pointing at it. + */ + +import type { AgentStartupShell } from './tui-agent-startup-shell' + +/** Keeps agent argv comfortably below the lowest practical OS command-line limit. */ +export const MAX_INLINE_LAUNCH_PROMPT_CHARS = 16_384 + +/** Content the host that owns the PTY writes to a private file before anything names it. */ +export type LaunchFile = { + /** Stands in for the file's path in the command and env until the host substitutes it. */ + placeholder: string + content: string + /** Set by the caller that minted a secret in `content`; keeps it out of argv and shell history. */ + sensitive: boolean + /** How the launch line quoted the placeholder, set where the line is built; the host writes the + * path inside that quoting. Absent, the host accepts only a path every quoting carries as is. */ + quoting?: AgentStartupShell +} + +const LAUNCH_LINE_QUOTINGS: readonly AgentStartupShell[] = ['posix', 'powershell', 'cmd'] + +const PLACEHOLDER_PATTERN = /^orca-launch-file-[0-9a-f]{32}$/ + +/** Stands in for the launch file's directory, which some agents must be granted to read it. */ +export function launchFileDirectoryPlaceholder(placeholder: string): string { + return placeholder.replace(/^orca-launch-file-/, 'orca-launch-dir-') +} + +const POINTER_LEAD = 'The full task is in the file `' +const POINTER_TAIL = '`. Read it and complete the task it describes.' + +// Why backticks, not quotes: PowerShell's legacy native-argument passing splits an argument at an +// inner `"`, while a backtick is literal in every shell's quoting and to the agent's argv parser. +export function buildLaunchFilePointer(path: string): string { + return `${POINTER_LEAD}${path}${POINTER_TAIL}` +} + +/** Whether an agent's prompt is Orca's pointer to a launch file rather than the user's own words. */ +export function isLaunchFilePointer(prompt: string): boolean { + const trimmed = prompt.trim() + return ( + trimmed.startsWith(POINTER_LEAD) && + trimmed.endsWith(POINTER_TAIL) && + trimmed.length > POINTER_LEAD.length + POINTER_TAIL.length + ) +} + +/** + * The prompt a launch line carries, and the file behind it when the prompt is too + * long or sensitive. The pointer names a placeholder the execution host replaces. + */ +export function planLaunchPrompt( + prompt: string, + options: { sensitive?: boolean } = {} +): { prompt: string; launchFile?: LaunchFile } { + const sensitive = options.sensitive === true + return sensitive || prompt.length > MAX_INLINE_LAUNCH_PROMPT_CHARS + ? carryInLaunchFile(prompt, sensitive) + : { prompt } +} + +export function carryInLaunchFile( + content: string, + sensitive: boolean +): { prompt: string; launchFile: LaunchFile } { + const bytes = crypto.getRandomValues(new Uint8Array(16)) + const placeholder = `orca-launch-file-${Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')}` + return { + prompt: buildLaunchFilePointer(placeholder), + launchFile: { placeholder, content, sensitive } + } +} + +/** Validates a launch file received over a wire; the placeholder must be one Orca minted. */ +export function parseLaunchFile(value: unknown): LaunchFile | undefined { + if ( + typeof value !== 'object' || + value === null || + !('placeholder' in value) || + !('content' in value) || + !('sensitive' in value) + ) { + return undefined + } + const { placeholder, content, sensitive } = value + const quoting = 'quoting' in value ? value.quoting : undefined + const knownQuoting = LAUNCH_LINE_QUOTINGS.find((candidate) => candidate === quoting) + if ( + typeof placeholder !== 'string' || + !PLACEHOLDER_PATTERN.test(placeholder) || + typeof content !== 'string' || + typeof sensitive !== 'boolean' || + (quoting !== undefined && knownQuoting === undefined) + ) { + return undefined + } + return { placeholder, content, sensitive, ...(knownQuoting ? { quoting: knownQuoting } : {}) } +} + +const LAUNCH_FILE_UNAVAILABLE_CODE = 'launch_file_unavailable' + +/** The refusal a host sends when it could not write a launch file; it reaches the user as is. */ +export function describeLaunchFileUnavailable(reason: string): string { + return `Orca could not write the file that carries the agent's prompt (${reason}), so the agent was not started. [${LAUNCH_FILE_UNAVAILABLE_CODE}]` +} + +/** Also matches the `launch_file_unavailable: ` form earlier builds sent. */ +export function isLaunchFileUnavailableMessage(message: string): boolean { + return message.includes(LAUNCH_FILE_UNAVAILABLE_CODE) +} diff --git a/src/shared/runtime-terminal-contracts.ts b/src/shared/runtime-terminal-contracts.ts index cb59809a0722..9e17b2a02819 100644 --- a/src/shared/runtime-terminal-contracts.ts +++ b/src/shared/runtime-terminal-contracts.ts @@ -1,8 +1,3 @@ -import type { - AgentProviderSessionMetadata, - SleepingAgentLaunchConfig -} from './agent-session-resume' -import type { StartupCommandDelivery } from './codex-startup-delivery' import type { ExecutionHostId } from './execution-host' import type { PtyIncarnationId } from './pty-incarnation' import type { RuntimeListingHostScope } from './runtime-listing-host-scope' @@ -242,38 +237,6 @@ export type RuntimeTerminalAgentStatus = { status: RuntimeTerminalAgentStatusState } -export type RuntimeTerminalPresentation = 'background' | 'focused' - -type RuntimeTerminalCreateBaseRequestPayload = { - requestId: string - worktreeId?: string - afterTabId?: string - targetGroupId?: string - command?: string - cwd?: string - env?: Record - envToDelete?: string[] - launchConfig?: SleepingAgentLaunchConfig - resumeProviderSession?: AgentProviderSessionMetadata - launchToken?: string - launchAgent?: TuiAgent - viewMode?: 'terminal' | 'chat' - startupCommandDelivery?: StartupCommandDelivery - title?: string - activate?: boolean - presentation?: RuntimeTerminalPresentation - surfaceOwner?: false - /** Windows shell the created tab spawns AS, instead of the host default. */ - shellOverride?: string -} - -export type RuntimeTerminalCreateRequestPayload = - | (RuntimeTerminalCreateBaseRequestPayload & { source?: undefined }) - | (RuntimeTerminalCreateBaseRequestPayload & { - worktreeId: string - source: 'runtime-session' - }) - export type RuntimeTerminalCreate = { handle: string /** Host-owned PTY incarnation used to fence remote identity observations. */ diff --git a/src/shared/runtime-terminal-create-request.ts b/src/shared/runtime-terminal-create-request.ts new file mode 100644 index 000000000000..e69141a64283 --- /dev/null +++ b/src/shared/runtime-terminal-create-request.ts @@ -0,0 +1,40 @@ +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from './agent-session-resume' +import type { StartupCommandDelivery } from './codex-startup-delivery' +import type { LaunchFile } from './launch-prompt-file' +import type { TuiAgent } from './tui-agent' + +export type RuntimeTerminalPresentation = 'background' | 'focused' + +type RuntimeTerminalCreateBaseRequestPayload = { + requestId: string + worktreeId?: string + afterTabId?: string + targetGroupId?: string + command?: string + cwd?: string + env?: Record + envToDelete?: string[] + launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata + launchToken?: string + launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' + startupCommandDelivery?: StartupCommandDelivery + launchFile?: LaunchFile + title?: string + activate?: boolean + presentation?: RuntimeTerminalPresentation + surfaceOwner?: false + /** Windows shell the created tab spawns AS, instead of the host default. */ + shellOverride?: string +} + +export type RuntimeTerminalCreateRequestPayload = + | (RuntimeTerminalCreateBaseRequestPayload & { source?: undefined }) + | (RuntimeTerminalCreateBaseRequestPayload & { + worktreeId: string + source: 'runtime-session' + }) diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index b236308438d4..0e9992da98ed 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -147,7 +147,6 @@ export type { RuntimeTerminalAgentStatusState, RuntimeTerminalClose, RuntimeTerminalCreate, - RuntimeTerminalCreateRequestPayload, RuntimeTerminalFocus, RuntimeTerminalInteractiveWait, RuntimeTerminalInteractiveWaitSource, @@ -159,7 +158,6 @@ export type { RuntimeTerminalOrphanTopology, RuntimeTerminalOrphanTopologyGroup, RuntimeTerminalOrphanTopologyTab, - RuntimeTerminalPresentation, RuntimeTerminalPromptDelivery, RuntimeTerminalPromptStage, RuntimeTerminalRead, @@ -182,6 +180,10 @@ export type { RuntimeWorktreeTerminalCloseResult, RuntimeWorktreeTerminalSleepResult } from './runtime-terminal-contracts' +export type { + RuntimeTerminalCreateRequestPayload, + RuntimeTerminalPresentation +} from './runtime-terminal-create-request' export type { RuntimeGitCheckoutResult, RuntimeGitLocalBranches, diff --git a/src/shared/startup-command-staging.node-pty.test.ts b/src/shared/startup-command-staging.node-pty.test.ts index 9a11175fe29b..2619a92c805d 100644 --- a/src/shared/startup-command-staging.node-pty.test.ts +++ b/src/shared/startup-command-staging.node-pty.test.ts @@ -22,6 +22,8 @@ import { join } from 'node:path' import * as pty from 'node-pty' import { afterAll, describe, expect, it } from 'vitest' import { resolveFishBinary } from './fish-binary-requirement' +import { writeLaunchFile, type WrittenLaunchFile } from './launch-file-writing' +import { buildLaunchFilePointer, planLaunchPrompt } from './launch-prompt-file' import { stageStartupCommand } from './startup-command-staging' import { buildStartupCommandSubmission } from './startup-command-submission' import { quoteStartupArg } from './tui-agent-startup-shell' @@ -82,10 +84,14 @@ const PROMPTS: [string, string][] = [ type AgentRun = { argv: string; pgid: number; foregroundPgid: number; shellPid: number } -async function launchInRealShell(shell: LiveShell, prompt: string): Promise { +async function launchInRealShell( + shell: LiveShell, + prompt: string, + prepare: (command: string) => string = (command) => command +): Promise { const caseDir = mkdtempSync(join(SANDBOX, `${shell.name}-`)) const capture = join(caseDir, 'argv') - const command = `${quoteStartupArg(AGENT, 'posix')} ${quoteStartupArg(prompt, 'posix')}` + const command = prepare(`${quoteStartupArg(AGENT, 'posix')} ${quoteStartupArg(prompt, 'posix')}`) const staging = stageStartupCommand({ command, shellPath: shell.path, @@ -170,3 +176,23 @@ describeShells('a staged launch line in a real shell', () => { ) } }) + +describeShells('a launch file named on a real command line', () => { + for (const shell of SHELLS) { + it(`hands ${shell.name}'s agent a pointer to the full prompt`, async () => { + const prompt = `${HOSTILE}\n${'z'.repeat(20_000)}\n` + const planned = planLaunchPrompt(prompt) + let written: WrittenLaunchFile | undefined + const run = await launchInRealShell(shell, planned.prompt, (command) => { + written = writeLaunchFile({ + launchFile: planned.launchFile!, + command, + baseDirectory: SANDBOX + }) + return written.command! + }) + expect(run?.argv).toBe(`${buildLaunchFilePointer(written!.path)}\0`) + expect(readFileSync(written!.path, 'utf8')).toBe(prompt) + }, 20_000) + } +}) diff --git a/src/shared/startup-command-staging.ts b/src/shared/startup-command-staging.ts index ff640f516d94..84f66c3d89b8 100644 --- a/src/shared/startup-command-staging.ts +++ b/src/shared/startup-command-staging.ts @@ -8,11 +8,12 @@ * by the host that owns the PTY, at the moment it accepts the spawn. */ import { randomBytes } from 'node:crypto' -import { readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { hasControlByte, TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES } from './startup-line-prompt-carry' +import { join, posix } from 'node:path' import { quoteStartupArg } from './tui-agent-startup-shell' +import { typedStartupLineFits } from './typed-startup-line' +import type { WslLaunchDirectory } from './wsl-launch-directory' export const STAGED_STARTUP_COMMAND_PREFIX = 'orca-launch-' @@ -41,8 +42,6 @@ const STAGING_SHELLS = new Set(['bash', 'zsh', 'sh', 'dash', 'fish', 'ksh', 'mks // not stop the agent (mksh untested, kept with it); their long Orca-built lines use `/bin/sh`. const SOURCING_SHELLS = new Set(['bash', 'zsh', 'sh', 'dash', 'fish']) -const encoder = new TextEncoder() - export function stagingShellName(shellPath: string | undefined): string | null { const name = shellPath?.split('/').pop()?.replace(/^-/, '').toLowerCase() return name && STAGING_SHELLS.has(name) ? name : null @@ -68,10 +67,8 @@ export function shouldStageStartupCommand(args: { // quoted backslash and expands `!!`), so another shell only ever sees the script's inert path. return args.orcaBuiltLine === true } - const body = stripSubmitTerminator(args.command) return ( - (hasControlByte(body) || - encoder.encode(body).byteLength > TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES) && + !typedStartupLineFits(stripSubmitTerminator(args.command)) && (SOURCING_SHELLS.has(shellName) || args.orcaBuiltLine === true) ) } @@ -86,7 +83,7 @@ function stagedScriptLine(shellName: string | null, quotedPath: string): string : `/bin/sh ${quotedPath}` } -let staleSweepStarted = false +const sweptDirectories = new Set() export function stageStartupCommand(args: { command: string @@ -94,24 +91,35 @@ export function stageStartupCommand(args: { orcaBuiltLine?: boolean platform?: NodeJS.Platform directory?: string + /** A WSL session stages like a POSIX host in its login shell: written over UNC, sourced by its + * Linux path. */ + wslDirectory?: WslLaunchDirectory }): StartupCommandStaging { - const platform = args.platform ?? process.platform - if (!shouldStageStartupCommand({ ...args, platform })) { + const wsl = args.wslDirectory + const platform = wsl ? 'linux' : (args.platform ?? process.platform) + // Why the distro's shell: the host sees only wsl.exe, and /bin/sh would skip the pane's own + // functions (Orca's codex wrapper, the user's aliases). + const shellPath = wsl ? wsl.shell : args.shellPath + if (!shouldStageStartupCommand({ ...args, shellPath, platform })) { return { command: args.command, delivery: 'typed' } } - const directory = args.directory ?? tmpdir() - if (!staleSweepStarted) { - staleSweepStarted = true + const directory = wsl?.windowsPath ?? args.directory ?? tmpdir() + if (!sweptDirectories.has(directory)) { + sweptDirectories.add(directory) // Why deferred: the sweep is crash recovery and must never delay this launch. setTimeout(() => sweepStaleStagedStartupCommands({ directory }), 0).unref?.() } - const shellName = stagingShellName(args.shellPath) - const scriptPath = join( - directory, - `${STAGED_STARTUP_COMMAND_PREFIX}${randomBytes(8).toString('hex')}.sh` + const shellName = stagingShellName(shellPath) + const scriptName = `${STAGED_STARTUP_COMMAND_PREFIX}${randomBytes(8).toString('hex')}.sh` + const scriptPath = join(directory, scriptName) + const quotedPath = quoteStartupArg( + wsl ? posix.join(wsl.linuxPath, scriptName) : scriptPath, + 'posix' ) - const quotedPath = quoteStartupArg(scriptPath, 'posix') try { + if (wsl) { + mkdirSync(directory, { recursive: true }) + } // Why rm first: the shell keeps reading the open file, so the prompt-bearing script is gone // before the agent starts, however long it runs. writeFileSync( diff --git a/src/shared/startup-line-prompt-carry.test.ts b/src/shared/startup-line-prompt-carry.test.ts index 360fd2b484ce..cbcc56b871e5 100644 --- a/src/shared/startup-line-prompt-carry.test.ts +++ b/src/shared/startup-line-prompt-carry.test.ts @@ -1,181 +1,175 @@ import { describe, expect, it } from 'vitest' -import { - hasControlByte, - planStartupWithPromptCandidate, - TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES, - ZSH_MULTI_LINE_STARTUP_LINE_BUDGET_BYTES -} from './startup-line-prompt-carry' +import { MAX_INLINE_LAUNCH_PROMPT_CHARS } from './launch-prompt-file' +import { planStartupWithLaunchPrompt } from './startup-line-prompt-carry' import type { TuiAgent } from './tui-agent' import { RUNTIME_CAPABILITIES } from './protocol-version' import { AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY } from './agent-launch-runtime-capability' +import { + TYPED_STARTUP_LINE_BUDGET_BYTES, + WINDOWS_TYPED_STARTUP_LINE_MAX_CHARS, + typedStartupLineFits, + windowsTypedStartupLineFits +} from './typed-startup-line' -function offer( +function plan( agent: TuiAgent, prompt: string, - extra: { cmdOverride?: string; shellName?: string | undefined } = {} + extra: { + platform?: NodeJS.Platform + shell?: 'cmd' | 'powershell' + sensitive?: boolean + } = {} ) { - return planStartupWithPromptCandidate( + return planStartupWithLaunchPrompt( { agent, - cmdOverrides: extra.cmdOverride ? { [agent]: extra.cmdOverride } : {}, - platform: 'darwin' + cmdOverrides: {}, + platform: extra.platform ?? 'darwin', + ...(extra.shell ? { shell: extra.shell } : {}) }, prompt, - extra.shellName - ) -} - -/** `count` prompt lines of `bytes` each, as a multi-line source-control prompt is. */ -function linesOf(count: number, bytes: number): string { - return Array.from({ length: count }, (_unused, index) => `${index}:`.padEnd(bytes, 'x')).join( - '\n' + extra.sensitive ? { sensitive: true } : {} ) } -/** 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') +describe('a launch prompt on the command line', () => { + it('carries a short prompt inline', () => { + const { plan: startup, launchFile } = plan('claude', 'explain this repo') + expect(launchFile).toBeUndefined() + expect(startup?.launchCommand).toContain('explain this repo') + expect(startup?.followupPrompt).toBeNull() }) - 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.each([ + ['a 600-byte', 'x'.repeat(600)], + ['a multi-line', 'first line\nsecond line'], + ['a key-bearing', 'see\tthis \x1b[31mred'] + ])('carries %s prompt whole on a POSIX host, which stages the typed line', (_label, prompt) => { + const { plan: startup, launchFile } = plan('codex', prompt) + expect(launchFile).toBeUndefined() + expect(startup?.launchCommand).toContain(prompt) + expect(startup?.followupPrompt).toBeNull() }) - 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('points at a launch file past the argv ceiling, with the full text in the file', () => { + const prompt = `${'y'.repeat(MAX_INLINE_LAUNCH_PROMPT_CHARS)}z` + const { plan: startup, launchFile } = plan('claude', prompt) + expect(launchFile?.content).toBe(prompt) + expect(launchFile?.sensitive).toBe(false) + expect(startup?.launchCommand).toContain(launchFile?.placeholder) + expect(startup?.launchCommand).not.toContain('yyyy') }) - 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('points a sensitive prompt at a launch file whatever its size', () => { + const { plan: startup, launchFile } = plan('claude', 'token dcap_abc', { sensitive: true }) + expect(launchFile).toMatchObject({ content: 'token dcap_abc', sensitive: true }) + expect(startup?.launchCommand).not.toContain('dcap_abc') }) 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) + ['cmd', 'first line\nsecond line'], + ['powershell', 'first line\r\nsecond line'] + ] as const)( + 'points a multi-line prompt at a launch file on a Windows %s host, which cannot stage', + (shell, prompt) => { + const { plan: startup, launchFile } = plan('codex', prompt, { platform: 'win32', shell }) + expect(launchFile?.content).toBe(prompt) + expect(startup?.launchCommand).not.toContain('first line') + expect(typedStartupLineFits(startup?.launchCommand ?? '\n')).toBe(true) } ) - 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('keeps a long single-line prompt inline on Windows up to cmd’s line cap', () => { + const inline = plan('claude', 'x'.repeat(600), { platform: 'win32', shell: 'cmd' }) + expect(inline.launchFile).toBeUndefined() + expect(inline.plan?.launchCommand).toContain('x'.repeat(600)) + + const pastCap = plan('claude', 'x'.repeat(WINDOWS_TYPED_STARTUP_LINE_MAX_CHARS), { + platform: 'win32', + shell: 'cmd' + }) + expect(pastCap.launchFile?.content).toBe('x'.repeat(WINDOWS_TYPED_STARTUP_LINE_MAX_CHARS)) }) - it('carries a Hermes prompt through its env transport, whose typed line never holds the text', () => { + it('carries a multi-line Hermes prompt in its spawn env, whose typed line never holds it', () => { 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) + const { plan: startup, launchFile } = plan('hermes', multiLine, { platform: 'win32' }) + expect(launchFile).toBeUndefined() + expect(startup?.launchCommand).not.toContain('line one') + expect(Object.values(startup?.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('leaves a Hermes prompt past its env budget for the paste after ready', () => { + const { plan: startup, launchFile } = plan('hermes', 'x'.repeat(30_000)) + expect(launchFile).toBeUndefined() + expect(startup?.launchCommand).toBe('hermes --tui') + expect(startup?.followupPrompt).toBe('x'.repeat(30_000)) }) - 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('leaves CJK Hermes text under 16,384 chars but past its env bytes for the paste', () => { + const cjk = '修'.repeat(9_000) + const { plan: startup, launchFile } = plan('hermes', cjk) + expect(launchFile).toBeUndefined() + expect(startup?.followupPrompt).toBe(cjk) }) - it('reports nothing carried for an empty prompt', () => { - expect(offer('claude', ' ').promptCarried).toBe(false) + it('puts a prompt that needs a launch file there only for an agent measured reading it', () => { + const long = 'y'.repeat(MAX_INLINE_LAUNCH_PROMPT_CHARS + 1) + expect(plan('claude', long).launchFile?.content).toBe(long) + expect(plan('codex', long).launchFile?.content).toBe(long) + for (const agent of ['gemini', 'cursor', 'antigravity', 'grok'] as const) { + const { plan: startup, launchFile } = plan(agent, long) + expect(launchFile).toBeUndefined() + expect(startup?.followupPrompt).toBe(long) + expect(startup?.launchCommand).not.toContain(long) + } }) -}) -// Pinned live by startup-line-typed-length.live-shell.test.ts; the refusals below were measured on -// macOS through the same write: a 1.1 KB single line lost to zsh's late write, every multi-line line -// lost to bash 3.2, and to fish whenever its config outlasted the ready barrier. -describe('a multi-line prompt typed into a shell the host names', () => { - it('rides a zsh line as main typed it when each line is short, so the agent starts with it', () => { - const prompt = linesOf(21, 100) - const { plan, promptCarried } = offer('claude', prompt, { shellName: 'zsh' }) - expect(promptCarried).toBe(true) - expect(plan?.launchCommand).toContain(prompt) + it('pastes a Windows-damaged prompt for an agent not measured reading its launch file', () => { + const { plan: startup, launchFile } = plan('gemini', 'line one\nline two', { + platform: 'win32', + shell: 'powershell' + }) + expect(launchFile).toBeUndefined() + expect(startup?.followupPrompt).toBe('line one\nline two') }) - it('keeps a zsh line off the launch command when any one line exceeds the per-line budget', () => { - const prompt = `${linesOf(3, 100)}\n${'y'.repeat(TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES + 1)}` - expect(offer('claude', prompt, { shellName: 'zsh' }).promptCarried).toBe(false) + it('leaves a stdin-after-start agent’s prompt for its caller to paste', () => { + const { plan: startup, launchFile } = plan('aider', 'fix it') + expect(launchFile).toBeUndefined() + expect(startup?.followupPrompt).toBe('fix it') }) - it('keeps a zsh line off the launch command past the whole-line budget', () => { - const prompt = linesOf(Math.ceil(ZSH_MULTI_LINE_STARTUP_LINE_BUDGET_BYTES / 400) + 1, 400) - expect(offer('claude', prompt, { shellName: 'zsh' }).promptCarried).toBe(false) + it('launches clean for an empty prompt', () => { + const { plan: startup, launchFile } = plan('claude', ' ') + expect(launchFile).toBeUndefined() + expect(startup?.launchCommand).toBe('claude') }) +}) - it('keeps a long single line off a zsh launch command, which a late write truncates', () => { - const prompt = promptForClaudeLineOf(TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES + 1) - expect(offer('claude', prompt, { shellName: 'zsh' }).promptCarried).toBe(false) +describe('whether a line can be typed as it is', () => { + it('holds a POSIX line to half of macOS MAX_CANON', () => { + expect(typedStartupLineFits('x'.repeat(TYPED_STARTUP_LINE_BUDGET_BYTES))).toBe(true) + expect(typedStartupLineFits('x'.repeat(TYPED_STARTUP_LINE_BUDGET_BYTES + 1))).toBe(false) + expect(typedStartupLineFits('é'.repeat(TYPED_STARTUP_LINE_BUDGET_BYTES / 2 + 1))).toBe(false) }) - it.each([['bash'], ['fish'], ['pwsh'], [undefined]])( - 'never types a multi-line line into %s', - (shellName) => { - const { plan, promptCarried } = offer('claude', linesOf(3, 50), { shellName }) - expect(promptCarried).toBe(false) - expect(plan?.launchCommand).not.toContain('0:') - } - ) + it('holds a Windows line only to cmd’s cap', () => { + expect(windowsTypedStartupLineFits('x'.repeat(WINDOWS_TYPED_STARTUP_LINE_MAX_CHARS))).toBe(true) + expect(windowsTypedStartupLineFits('x'.repeat(WINDOWS_TYPED_STARTUP_LINE_MAX_CHARS + 1))).toBe( + false + ) + }) - it.each([ - ['TAB', 'see\tthis\nnext line'], - ['CR', 'first\r\nsecond'] - ])('never types a %s-bearing multi-line prompt into zsh', (_label, prompt) => { - expect(offer('claude', prompt, { shellName: 'zsh' }).promptCarried).toBe(false) + it.each(['\n', '\r', '\t', '\x1b', '\x03', '\x7f'])('never types a line with %j', (byte) => { + expect(typedStartupLineFits(`a${byte}b`)).toBe(false) + expect(windowsTypedStartupLineFits(`a${byte}b`)).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. + it('is advertised by every host that delivers a prompt its typed line cannot carry as typed', () => { + // An older host types any prompt into the 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 index 65e70e2d11db..cdf89e5978c1 100644 --- a/src/shared/startup-line-prompt-carry.ts +++ b/src/shared/startup-line-prompt-carry.ts @@ -1,114 +1,32 @@ /** - * 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. - * - * `startup-line-typed-length.live-shell.test.ts` types lines through Orca's own ready barrier and - * submission into real shells, including one whose config outlasts the barrier's 1.5 s cap so the - * line lands while the terminal is still line-buffered. Only zsh read a multi-line line whole in - * both cases, and only while each of its lines stayed short; a long single line was lost there. + * The launch-prompt plan for callers that route sensitive prompts: the decision itself (line, launch + * file, or paste) is the builder's (`carryLaunchPrompt`), so every launch path shares it. */ -import { TUI_AGENT_CONFIG } from './tui-agent-config' +import type { LaunchFile } from './launch-prompt-file' 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. Also the cap on each line of a multi-line one. */ -export const TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES = 512 - -/** A multi-line line typed into zsh: the largest measured intact behind a late write (8 KiB). */ -export const ZSH_MULTI_LINE_STARTUP_LINE_BUDGET_BYTES = 8192 - -const encoder = new TextEncoder() - -function typedLineBytes(line: string): number { - return encoder.encode(line).byteLength -} - -export function startupLineCarriesPrompt(args: { - agent: TuiAgent - withPrompt: AgentStartupPlan | null - /** The shell the line is typed into, when the host can name it before the spawn. */ - shellName?: string -}): 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 - if (!hasControlByte(line)) { - return typedLineBytes(line) <= TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES - } - return args.shellName === 'zsh' && zshReadsMultiLineWhole(line) -} - -/** - * zsh takes a multi-line line as one bracketed paste, and a write that beats its line editor still - * reaches it line by line, so each line must fit the terminal's line buffer. bash 3.2 has no - * bracketed paste, and fish drops a paste written before its reader is up. - */ -function zshReadsMultiLineWhole(line: string): boolean { - const lines = line.split('\n') - return ( - lines.every( - (part) => - !hasControlByte(part) && typedLineBytes(part) <= TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES - ) && typedLineBytes(line) <= ZSH_MULTI_LINE_STARTUP_LINE_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( +export type LaunchPromptStartupPlan = { + plan: AgentStartupPlan | null + /** Written by the execution host before it types the line naming it. */ + launchFile?: LaunchFile +} + +export function planStartupWithLaunchPrompt( inputs: StartupPlanInputs, prompt: string, - shellName?: string -): { plan: AgentStartupPlan | null; promptCarried: boolean } { - if (prompt.trim()) { - const withPrompt = buildAgentStartupPlan({ ...inputs, prompt, allowEmptyPromptLaunch: true }) - if ( - startupLineCarriesPrompt({ - agent: inputs.agent, - withPrompt, - ...(shellName ? { shellName } : {}) - }) - ) { - return { plan: withPrompt, promptCarried: true } - } - } - return { - plan: buildAgentStartupPlan({ ...inputs, prompt: '', allowEmptyPromptLaunch: true }), - promptCarried: false - } + options: { sensitive?: boolean } = {} +): LaunchPromptStartupPlan { + const plan = buildAgentStartupPlan({ + ...inputs, + prompt, + allowEmptyPromptLaunch: true, + ...(options.sensitive ? { sensitive: true } : {}) + }) + return { plan, ...(plan?.launchFile ? { launchFile: plan.launchFile } : {}) } } diff --git a/src/shared/startup-plan-launch-file.ts b/src/shared/startup-plan-launch-file.ts new file mode 100644 index 000000000000..6cff85965090 --- /dev/null +++ b/src/shared/startup-plan-launch-file.ts @@ -0,0 +1,126 @@ +/** How a startup plan carries a launch file: which prompts move into one, and what the line adds. */ +import { + MAX_INLINE_LAUNCH_PROMPT_CHARS, + carryInLaunchFile, + launchFileDirectoryPlaceholder, + type LaunchFile +} from './launch-prompt-file' +import { TUI_AGENT_CONFIG } from './tui-agent-config' +import { + isPosixStartupShell, + quoteStartupArg, + resolveStartupShell, + type AgentStartupShell +} from './tui-agent-startup-shell' +import { hasControlByte, windowsTypedStartupLineFits } from './typed-startup-line' +import type { TuiAgent } from './tui-agent' + +/** + * Whether a Windows shell would damage `prompt` as one quoted argument; the closed rule is this plus + * cmd's 8,191-character line cap (`carryLaunchPrompt`). No quoting keeps a control byte literal: cmd + * types the line and a line break submits it early. PowerShell runs a short line from its argv, so + * its damage is the hand-off to the agent (measured): legacy argument passing (5.1 always, 7.x into a + * `.cmd` shim) splits at an inner `"` and turns a trailing `\` into `"`, and a shim's cmd.exe expands + * `%NAME%`; a lone `%` stays literal. + */ +export function windowsShellDamagesPrompt(prompt: string, shell: AgentStartupShell): boolean { + if (isPosixStartupShell(shell)) { + return false + } + return ( + hasControlByte(prompt) || + (shell === 'powershell' && + (prompt.includes('"') || /%[^%]+%/.test(prompt) || prompt.endsWith('\\'))) + ) +} + +/** Why a prefill draft could not be launched, in the user's words, when the Windows shell is why. */ +export function windowsDraftRefusal(draft: string, shell: AgentStartupShell): string | null { + return windowsShellDamagesPrompt(draft.trim(), shell) + ? "The host's Windows shell would break this draft on the agent's command line (it has a line " + + 'break or other control character, or on PowerShell a double quote, a %NAME% pair or a ' + + 'trailing backslash), so the agent was not started. Start it without the draft and paste ' + + 'the draft once it opens.' + : null +} + +type CarriedPlanArgs = { + agent: TuiAgent + prompt: string + platform: NodeJS.Platform + shell?: AgentStartupShell + allowEmptyPromptLaunch?: boolean + launchFile?: LaunchFile + hostWritesLaunchFile?: boolean + sensitive?: boolean +} + +export function agentReadsLaunchFile(agent: TuiAgent): boolean { + return TUI_AGENT_CONFIG[agent].readsLaunchFile === true +} + +/** + * Where a launch prompt rides, decided once for every launch path: on the line, which a POSIX host + * stages when it is long or multi-line; in a launch file named by a pointer when the prompt is past + * the argv ceiling, sensitive, damaged by a Windows shell, or past cmd's line cap; or, where no + * launch file can be written (a paired host) or the agent is not known to read one, left in + * `followupPrompt` for the paste after ready. + */ +export function carryLaunchPrompt< + A extends CarriedPlanArgs, + P extends { launchCommand: string; followupPrompt: string | null } +>(args: A, buildOnLine: (args: A) => P | null): P | null { + const text = args.prompt.trim() + const mode = TUI_AGENT_CONFIG[args.agent].promptInjectionMode + if (!text || args.launchFile || mode === 'stdin-after-start') { + return buildOnLine(args) + } + const readsEnv = mode === 'hermes-query' + const pasteAfterReady = (): P | null => { + const clean = buildOnLine({ ...args, prompt: '', allowEmptyPromptLaunch: true }) + return clean && { ...clean, followupPrompt: text } + } + const viaLaunchFile = (): P | null => { + // Why: an agent not measured reading the file would stop on an approval or refuse the path. + if (args.hostWritesLaunchFile === false || !agentReadsLaunchFile(args.agent)) { + return pasteAfterReady() + } + const pointer = carryInLaunchFile(text, args.sensitive === true) + return buildOnLine({ ...args, prompt: pointer.prompt, launchFile: pointer.launchFile }) + } + const shell = resolveStartupShell(args.platform, args.shell) + if ( + args.sensitive === true || + text.length > MAX_INLINE_LAUNCH_PROMPT_CHARS || + (!readsEnv && windowsShellDamagesPrompt(text, shell)) + ) { + return viaLaunchFile() + } + const onLine = buildOnLine(args) + if (!onLine || readsEnv) { + // Hermes reads its prompt from the env and refuses one past that budget, counted in bytes; a + // one-character query building proves the budget, not the command, refused it. + return !onLine && readsEnv && buildOnLine({ ...args, prompt: '.' }) ? viaLaunchFile() : onLine + } + return args.platform === 'win32' && !windowsTypedStartupLineFits(onLine.launchCommand) + ? viaLaunchFile() + : onLine +} + +/** The host writes the path inside this line's quoting, so the file carries which one it is. */ +export function launchFileProps(launchFile: LaunchFile | undefined, shell: AgentStartupShell) { + return launchFile ? { launchFile: { ...launchFile, quoting: shell } } : {} +} + +/** The host puts the launch file's private directory where the placeholder is, like its path. */ +export function launchFileDirectoryGrant( + agent: TuiAgent, + launchFile: LaunchFile | undefined, + shell: AgentStartupShell +): string { + const flag = TUI_AGENT_CONFIG[agent].launchFileDirectoryFlag + if (!launchFile || !flag) { + return '' + } + return ` ${quoteStartupArg(`${flag}=${launchFileDirectoryPlaceholder(launchFile.placeholder)}`, shell)}` +} diff --git a/src/shared/tui-agent-config-types.ts b/src/shared/tui-agent-config-types.ts index aa393fd522b7..ca70601d5ca8 100644 --- a/src/shared/tui-agent-config-types.ts +++ b/src/shared/tui-agent-config-types.ts @@ -31,6 +31,13 @@ export type TuiAgentConfig = { promptInjectionMode: AgentPromptInjectionMode /** Option terminator required before positional prompts that may look like CLI syntax. */ argvPromptSeparator?: '--' + /** Grants the agent reads in the private directory holding its launch file, which sits outside + * the workspace. Always written `--flag=`: Claude's `--add-dir` is variadic and would take a + * following positional prompt as a second directory. */ + launchFileDirectoryFlag?: '--add-dir' + /** Measured reading its launch file (through `launchFileDirectoryFlag` when set) without asking. + * Any other agent keeps the paste after ready for a prompt that would need one. */ + readsLaunchFile?: boolean /** Native CLI flag that seeds the input without submitting (e.g. Claude's `--prefill `); preferred over the paste-after-ready path. */ draftPromptFlag?: string /** Startup env var that seeds the input without submitting, for agents with no `--prefill`-style flag (e.g. pi); avoids the paste-after-ready race. */ diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index 822e6db49ba7..351bc4cc6671 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -27,6 +27,9 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { claude: { detectCmd: 'claude', promptInjectionMode: 'argv', + // Why: default permission mode asks before reading outside the working directories. + launchFileDirectoryFlag: '--add-dir', + readsLaunchFile: true, pasteNeedsTypedRequest: true, // Why: `claude --prefill ` seeds the input without submitting, avoiding the paste-after-ready race (PR https://github.com/stablyai/orca/pull/926). draftPromptFlag: '--prefill', @@ -63,6 +66,8 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { codex: { detectCmd: 'codex', promptInjectionMode: 'argv', + // Why no grant: every sandbox policy reads the whole disk. + readsLaunchFile: true, windowsInputRecordPasteNewline: 'alt-enter', preflightTrust: 'codex', draftPasteReadySignal: 'codex-composer-prompt', diff --git a/src/shared/tui-agent-startup-hermes.test.ts b/src/shared/tui-agent-startup-hermes.test.ts index bcf5f43750c1..4cb34a4ab7fe 100644 --- a/src/shared/tui-agent-startup-hermes.test.ts +++ b/src/shared/tui-agent-startup-hermes.test.ts @@ -163,7 +163,7 @@ describe('hermes startup plans', () => { ).toBeNull() }) - it('rejects Hermes queries that exceed the safe Windows environment limit', () => { + it('leaves a Hermes query past the safe Windows environment limit for the paste', () => { const plan = buildAgentStartupPlan({ agent: 'hermes', prompt: 'x'.repeat(24_000), @@ -171,7 +171,8 @@ describe('hermes startup plans', () => { platform: 'win32' }) - expect(plan).toBeNull() + expect(plan?.launchFile).toBeUndefined() + expect(plan?.followupPrompt).toBe('x'.repeat(24_000)) }) it.each(['quote "this"', 'print %PATH%', 'toggle !feature!', 'inspect C:\\repo\\'])( @@ -204,15 +205,16 @@ describe('hermes startup plans', () => { ) }) - it('measures POSIX Hermes query limits in UTF-8 bytes', () => { + it('measures POSIX Hermes query limits in UTF-8 bytes, pasting past them', () => { const plan = buildAgentStartupPlan({ agent: 'hermes', - prompt: '界'.repeat(50_000), + prompt: '界'.repeat(9_000), cmdOverrides: {}, platform: 'linux' }) - expect(plan).toBeNull() + expect(plan?.launchFile).toBeUndefined() + expect(plan?.followupPrompt).toBe('界'.repeat(9_000)) }) it('keeps empty Hermes launches on the interactive TUI command', () => { diff --git a/src/shared/tui-agent-startup-shell.ts b/src/shared/tui-agent-startup-shell.ts index 83bfbd584d28..3ed099756d48 100644 --- a/src/shared/tui-agent-startup-shell.ts +++ b/src/shared/tui-agent-startup-shell.ts @@ -1,5 +1,6 @@ import { tokenizeCustomCommandTemplate, type CommandTokenSpan } from './commit-message-prompt' import { quotePowerShellLiteral } from './powershell-native-argument' +import { quoteWindowsCmdArgument } from './child-process/windows-command-line' /** * `'posix'` covers every Unix shell Orca can type into, fish included — not @@ -221,7 +222,9 @@ export function quoteStartupArg(value: string, shell: AgentStartupShell): string return quotePowerShellLiteral(value) } if (shell === 'cmd') { - return `"${value.replace(/([\^&|<>()%!"])/g, '^$1')}"` + // Why: cmd keeps `^` literal inside double quotes, so caret-escaping a quoted run reached the + // agent as stray carets; this encoding keeps cmd's quote count even and breaks only `%` out. + return quoteWindowsCmdArgument(value) } return quotePortableUnixArg(value) } diff --git a/src/shared/tui-agent-startup.test.ts b/src/shared/tui-agent-startup.test.ts index 31690889e9f3..27b487ba598c 100644 --- a/src/shared/tui-agent-startup.test.ts +++ b/src/shared/tui-agent-startup.test.ts @@ -77,12 +77,13 @@ describe('tui agent startup plans', () => { it('uses PowerShell quoting by default when the target shell is Windows', () => { const plan = buildAgentStartupPlan({ agent: 'claude', - prompt: 'fix Bob\'s "quoted" branch', + prompt: "fix Bob's \u2018quoted\u2019 branch", cmdOverrides: {}, platform: 'win32' }) - expect(plan?.launchCommand).toBe("claude 'fix Bob''s \"quoted\" branch'") + // A `"` would move the prompt into a launch file (windows-shell-prompt-damage.test.ts). + expect(plan?.launchCommand).toBe("claude 'fix Bob''s \u2018\u2018quoted\u2019\u2019 branch'") }) it('invokes fully quoted argv commands in PowerShell', () => { @@ -100,7 +101,65 @@ describe('tui agent startup plans', () => { shell: 'cmd' }) - expect(plan?.launchCommand).toBe('claude "fix ^"quoted^" ^& ^%PATH^%"') + expect(plan?.launchCommand).toBe('claude "fix ""quoted"" & "^%"PATH"^%""') + }) + + it.each(['cmd', 'powershell'] as const)( + 'never types a line break into %s: a multi-line prompt rides a launch file', + (shell) => { + const prompt = 'fix the build\r\n& echo PWNED\nthen run tests' + const plan = buildAgentStartupPlan({ + agent: 'claude', + prompt, + cmdOverrides: {}, + platform: 'win32', + shell + }) + + expect(plan?.launchCommand).not.toMatch(/[\r\n]/) + expect(plan?.launchCommand).not.toContain('PWNED') + expect(plan?.launchFile).toMatchObject({ content: prompt, sensitive: false }) + expect(plan?.launchCommand).toContain(plan?.launchFile?.placeholder) + } + ) + + it('keeps a multi-line prompt inline for a POSIX shell on a POSIX host, which stages it', () => { + const plan = buildAgentStartupPlan({ + agent: 'claude', + prompt: 'line one\nline two', + cmdOverrides: {}, + platform: 'linux', + shell: 'posix' + }) + + expect(plan?.launchFile).toBeUndefined() + expect(plan?.launchCommand).toContain('line one\nline two') + }) + + // Why: a Windows host stages nothing, so Git Bash would get the raw line break typed. + it('points Git Bash on a Windows host at a launch file for a multi-line prompt', () => { + const plan = buildAgentStartupPlan({ + agent: 'claude', + prompt: 'line one\nline two', + cmdOverrides: {}, + platform: 'win32', + shell: 'posix' + }) + + expect(plan?.launchFile?.content).toBe('line one\nline two') + expect(plan?.launchCommand).not.toContain('line one') + }) + + it('leaves a multi-line draft for the paste on a Windows shell instead of typing it', () => { + expect( + buildAgentDraftLaunchPlan({ + agent: 'claude', + draft: 'line one\nline two', + cmdOverrides: {}, + platform: 'win32', + shell: 'cmd' + }) + ).toBeNull() }) it('terminates Grok options before a flag-shaped POSIX prompt', () => { diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index c381454e9c41..99009b63ffae 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -9,6 +9,13 @@ import { resolveStartupShell, type AgentStartupShell } from './tui-agent-startup-shell' +import type { LaunchFile } from './launch-prompt-file' +import { + carryLaunchPrompt, + launchFileDirectoryGrant, + launchFileProps, + windowsShellDamagesPrompt +} from './startup-plan-launch-file' import { TUI_AGENT_CONFIG } from './tui-agent-config' import type { StartupCommandDelivery } from './codex-startup-delivery' import { buildSleepingAgentLaunchConfig } from './sleeping-agent-launch-config' @@ -33,13 +40,15 @@ export type AgentStartupPlan = { /** Values actually emitted into this launch command, kept as base model ids * so the native-chat surface can render only launch-backed state. */ sessionOptions?: Record + /** Holds the prompt the command points at; the host writes it before typing the command. */ + launchFile?: LaunchFile } function appliedSessionOptionProps(values: Record) { return Object.keys(values).length > 0 ? { sessionOptions: { ...values } } : {} } -export function buildAgentStartupPlan(args: { +export type AgentStartupPlanArgs = { agent: TuiAgent prompt: string cmdOverrides: Partial> @@ -53,7 +62,24 @@ export function buildAgentStartupPlan(args: { /** Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only * `orca-ide` rename must be skipped for remote launches. */ isRemote?: boolean -}): AgentStartupPlan | null { + /** The file `prompt` points at, when the caller already moved the prompt into one. */ + launchFile?: LaunchFile + /** False for a paired host, which is sent a command and never a launch file: a prompt that would + * need one launches clean and is left in `followupPrompt` for the paste after ready. */ + hostWritesLaunchFile?: boolean + /** The caller minted a secret in the prompt: it rides a launch file, never argv or history. */ + sensitive?: boolean +} + +/** + * The one place that decides whether a launch prompt rides the line, a launch file, or the paste + * after ready (`carryLaunchPrompt`); every launch path builds through it. + */ +export function buildAgentStartupPlan(args: AgentStartupPlanArgs): AgentStartupPlan | null { + return carryLaunchPrompt(args, buildPlanWithPromptOnLine) +} + +function buildPlanWithPromptOnLine(args: AgentStartupPlanArgs): AgentStartupPlan | null { const { agent, prompt, cmdOverrides, platform, allowEmptyPromptLaunch = false } = args const shell = resolveStartupShell(platform, args.shell) const trimmedPrompt = prompt.trim() @@ -96,7 +122,10 @@ export function buildAgentStartupPlan(args: { } } + const launchFile = args.launchFile const quotedPrompt = quoteStartupArg(trimmedPrompt, shell) + const fileProps = launchFileProps(launchFile, shell) + const grant = launchFileDirectoryGrant(agent, launchFile, shell) if (config.promptInjectionMode === 'argv') { const promptSeparator = config.argvPromptSeparator ? ` ${config.argvPromptSeparator}` : '' @@ -104,26 +133,32 @@ export function buildAgentStartupPlan(args: { agent, launchCommand: agent === 'omp' - ? withFreshOmpLaunch(baseCommand.command, shell, `${promptSeparator} ${quotedPrompt}`) - : `${launchCommand}${promptSeparator} ${quotedPrompt}`, + ? withFreshOmpLaunch( + baseCommand.command, + shell, + `${grant}${promptSeparator} ${quotedPrompt}` + ) + : `${launchCommand}${grant}${promptSeparator} ${quotedPrompt}`, expectedProcess: config.expectedProcess, followupPrompt: null, launchConfig, ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), ...(agent === 'codex' ? { startupCommandDelivery: 'shell-ready' as const } : {}), - ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) + ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}), + ...fileProps } } if (config.promptInjectionMode === 'flag-prompt') { return { agent, - launchCommand: `${launchCommand} --prompt ${quotedPrompt}`, + launchCommand: `${launchCommand}${grant} --prompt ${quotedPrompt}`, expectedProcess: config.expectedProcess, followupPrompt: null, launchConfig, ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), - ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) + ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}), + ...fileProps } } @@ -149,31 +184,35 @@ export function buildAgentStartupPlan(args: { followupPrompt: null, launchConfig, ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), - ...(queryPlan.env ? { env: queryPlan.env } : {}) + ...(queryPlan.env ? { env: queryPlan.env } : {}), + // Hermes reads its prompt from the env, where a line break is harmless. + ...launchFileProps(args.launchFile, shell) } } if (config.promptInjectionMode === 'flag-prompt-interactive') { return { agent, - launchCommand: `${launchCommand} --prompt-interactive ${quotedPrompt}`, + launchCommand: `${launchCommand}${grant} --prompt-interactive ${quotedPrompt}`, expectedProcess: config.expectedProcess, followupPrompt: null, launchConfig, ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), - ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) + ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}), + ...fileProps } } if (config.promptInjectionMode === 'flag-interactive') { return { agent, - launchCommand: `${launchCommand} -i ${quotedPrompt}`, + launchCommand: `${launchCommand}${grant} -i ${quotedPrompt}`, expectedProcess: config.expectedProcess, followupPrompt: null, launchConfig, ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), - ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) + ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}), + ...fileProps } } @@ -255,6 +294,11 @@ export function buildAgentDraftLaunchPlan(args: { agentCommand: baseCommand.commandWithoutSessionOptions }) let plan: AgentDraftLaunchPlan | null = null + // Why: the shell would damage the draft (see windowsShellDamagesPrompt); callers paste it into + // the agent instead, and a pointer sentence is no draft to edit. + if (config.draftPromptFlag && windowsShellDamagesPrompt(trimmed, shell)) { + return null + } if (config.draftPromptFlag) { const quoted = quoteStartupArg(trimmed, shell) plan = { diff --git a/src/shared/typed-startup-line.ts b/src/shared/typed-startup-line.ts new file mode 100644 index 000000000000..409b6bc03a3f --- /dev/null +++ b/src/shared/typed-startup-line.ts @@ -0,0 +1,30 @@ +/** Half of macOS MAX_CANON: a typed line this long survived every canonical-mode write measured, + * and 1 KiB did not. */ +export const TYPED_STARTUP_LINE_BUDGET_BYTES = 512 + +/** cmd.exe's documented line cap, the smallest of the Windows shells Orca types into. */ +export const WINDOWS_TYPED_STARTUP_LINE_MAX_CHARS = 8191 + +const encoder = new TextEncoder() + +/** Any C0 byte or DEL: no quoter escapes them, so a line editor reads them as keys. */ +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 +} + +/** Whether a startup line can be typed into a shell as it is: every failure a long or multi-line + * typed line has (Enter mid-line, keys, MAX_CANON truncation) is a property of the line. */ +export function typedStartupLineFits(line: string): boolean { + return !hasControlByte(line) && encoder.encode(line).byteLength <= TYPED_STARTUP_LINE_BUDGET_BYTES +} + +/** The same question for a Windows host, which cannot stage: only its own line cap applies. */ +export function windowsTypedStartupLineFits(line: string): boolean { + return !hasControlByte(line) && line.length <= WINDOWS_TYPED_STARTUP_LINE_MAX_CHARS +} diff --git a/src/shared/windows-shell-prompt-damage.test.ts b/src/shared/windows-shell-prompt-damage.test.ts new file mode 100644 index 000000000000..2ae358455039 --- /dev/null +++ b/src/shared/windows-shell-prompt-damage.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest' +import { buildLaunchFilePointer, isLaunchFilePointer } from './launch-prompt-file' +import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from './tui-agent-startup' +import type { AgentStartupShell } from './tui-agent-startup-shell' +import { windowsDraftRefusal } from './startup-plan-launch-file' + +function plan(prompt: string, shell: AgentStartupShell) { + return buildAgentStartupPlan({ + agent: 'claude', + prompt, + cmdOverrides: {}, + platform: shell === 'posix' ? 'darwin' : 'win32', + shell + }) +} + +describe('a prompt a Windows shell would damage on the launch line', () => { + // Measured (ps-quoting-matrix.md): PowerShell 5.1, and 7.x through a .cmd shim, split a `"` out of + // the plain literal and turn a trailing backslash into `"`; the legacy-passing escape would let a + // .cmd shim's cmd.exe run `&` and `<>` from inside the user's quotes. So these ride a launch file. + it.each([ + ['P1', 'fix the "foo bar" bug'], + ['P2', '"leading quote" then text'], + ['P3', 'trailing "quote"'], + ['P4', 'a \\"backslash-quote\\" b'], + ['P5', 'back\\slash\\\\ "q" end\\'], + ['P7', 'replace "" with "a & b"'], + ['a bare path', 'see C:\\dir\\'] + ])('moves %s into a launch file on PowerShell', (_, prompt) => { + const startup = plan(prompt, 'powershell') + expect(startup?.launchFile?.content).toBe(prompt) + expect(startup?.launchCommand).not.toContain('"') + expect(startup?.launchCommand).not.toContain('Legacy') + }) + + // Measured form F-A: a pointer to a spaced path passed in every PowerShell and target. + it('types P6, the backtick pointer, and quote-free prompts as a plain literal', () => { + const prompt = 'The full task is in the file `C:\\Users\\John Smith\\t.md`. Read it.' + expect(plan(prompt, 'powershell')?.launchCommand).toBe(`claude '${prompt}'`) + expect(plan("fix Bob's build", 'powershell')?.launchCommand).toBe("claude 'fix Bob''s build'") + }) + + // Measured (QA-WIN R0-R2): through a `.cmd` shim, `%PATH%` reached the agent as 1,795 characters. + it('moves a %NAME% pair into a launch file on PowerShell and keeps a lone percent typed', () => { + expect(plan('echo %PATH% for me', 'powershell')?.launchFile?.content).toBe('echo %PATH% for me') + expect(plan('coverage is 80% now', 'powershell')?.launchCommand).toBe( + "claude 'coverage is 80% now'" + ) + expect(plan('echo %PATH% for me', 'cmd')?.launchFile).toBeUndefined() + }) + + it('keeps `"` on the line for cmd and POSIX, whose quoting carries it', () => { + expect(plan('fix the "foo bar" bug', 'cmd')?.launchFile).toBeUndefined() + expect(plan('fix the "foo bar" bug', 'posix')?.launchFile).toBeUndefined() + }) + + it('pastes a PowerShell draft holding `"` or ending in `\\` instead of prefilling it', () => { + const draft = (text: string) => + buildAgentDraftLaunchPlan({ + agent: 'claude', + draft: text, + cmdOverrides: {}, + platform: 'win32', + shell: 'powershell' + }) + expect(draft('say "hi"')).toBeNull() + expect(draft('see C:\\dir\\')).toBeNull() + expect(draft('say hi')?.launchCommand).toBe("claude --prefill 'say hi'") + }) + + it('points at the file with no double quote in the sentence', () => { + const path = 'C:\\Users\\John Smith\\AppData\\Local\\Temp\\orca-launch-file-a1\\task-context.md' + expect(buildLaunchFilePointer(path)).toBe( + `The full task is in the file \`${path}\`. Read it and complete the task it describes.` + ) + }) + + it('launches clean and leaves the prompt for the paste when the host writes no launch file', () => { + const startup = buildAgentStartupPlan({ + agent: 'claude', + prompt: 'fix the build\nthen run the tests', + cmdOverrides: {}, + platform: 'win32', + shell: 'powershell', + hostWritesLaunchFile: false + }) + expect(startup?.launchFile).toBeUndefined() + expect(startup?.launchCommand).toBe('claude') + expect(startup?.followupPrompt).toBe('fix the build\nthen run the tests') + }) + + it('tells its own pointer apart from a prompt that merely mentions one', () => { + expect(isLaunchFilePointer(buildLaunchFilePointer('C:\\Temp\\a b\\task-context.md'))).toBe(true) + expect(isLaunchFilePointer('The full task is in the file `x`. Read it and do it.')).toBe(false) + expect(isLaunchFilePointer('fix the build')).toBe(false) + }) + + it('says in plain words why a Windows shell draft was not launched', () => { + expect(windowsDraftRefusal('line one\nline two', 'powershell')).toMatch( + /Windows shell would break this draft on the agent's command line \(it has a line break or other control character, or on PowerShell a double quote, a %NAME% pair or a trailing backslash\), so the agent was not started/ + ) + expect(windowsDraftRefusal('see C:\\dir\\', 'powershell')).not.toBeNull() + expect(windowsDraftRefusal('say "hi"', 'powershell')).not.toBeNull() + expect(windowsDraftRefusal('say "hi"', 'cmd')).toBeNull() + expect(windowsDraftRefusal('line one\nline two', 'posix')).toBeNull() + }) +}) diff --git a/src/shared/worktree/launch-types.ts b/src/shared/worktree/launch-types.ts index 5fc9f18d3971..c91df3ac7f28 100644 --- a/src/shared/worktree/launch-types.ts +++ b/src/shared/worktree/launch-types.ts @@ -4,6 +4,7 @@ import type { SleepingAgentLaunchConfig } from '../agent-session-resume' import type { SetupRunnerShell } from '../setup-runner-command' import type { OrcaDefaultTabTemplate } from '../orca-yaml-hook-types' import type { TuiAgent } from '../tui-agent' +import type { LaunchFile } from '../launch-prompt-file' export type WorktreeSetupLaunch = { runnerScriptPath: string @@ -21,6 +22,8 @@ export type WorktreeStartupLaunch = { launchAgent?: TuiAgent viewMode?: 'terminal' | 'chat' startupCommandDelivery?: StartupCommandDelivery + /** Written by the execution host before it types `command`, which names it by placeholder. */ + launchFile?: LaunchFile telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind } } diff --git a/src/shared/wsl-launch-directory.test.ts b/src/shared/wsl-launch-directory.test.ts new file mode 100644 index 000000000000..c14dee379a22 --- /dev/null +++ b/src/shared/wsl-launch-directory.test.ts @@ -0,0 +1,151 @@ +import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { LaunchFileUnavailableError, writeSpawnLaunchFile } from './launch-file-writing' +import { buildLaunchFilePointer, planLaunchPrompt, type LaunchFile } from './launch-prompt-file' +import { stageStartupCommand } from './startup-command-staging' +import { parseWslLaunchDirectory, type WslLaunchDirectory } from './wsl-launch-directory' + +let windowsSide: string +let directory: WslLaunchDirectory + +beforeEach(() => { + // Stands in for `\\wsl.localhost\Ubuntu\home\ada\.cache\orca`, which only Windows can open. + windowsSide = join(mkdtempSync(join(tmpdir(), 'orca-wsl-dir-test-')), 'cache') + directory = { distro: 'Ubuntu', windowsPath: windowsSide, linuxPath: '/home/ada/.cache/orca' } +}) + +afterEach(() => { + rmSync(join(windowsSide, '..'), { recursive: true, force: true }) +}) + +function launchFile(content = 'fix the build'): LaunchFile { + const planned = planLaunchPrompt(content, { sensitive: true }) + return { ...planned.launchFile!, quoting: 'posix' } +} + +describe('a WSL launch file', () => { + it('is written through the Windows side and named by its Linux path in the line', () => { + const file = launchFile() + const written = writeSpawnLaunchFile({ + launchFile: file, + command: `claude '${buildLaunchFilePointer(file.placeholder)}'`, + orcaBuiltLine: true, + wslDistro: 'Ubuntu', + wslDirectory: directory + })! + const [created] = readdirSync(windowsSide) + expect(written.directory).toBe(join(windowsSide, created!)) + expect(readFileSync(join(written.directory, 'task-context.md'), 'utf8')).toBe('fix the build') + expect(written.path).toBe(`/home/ada/.cache/orca/${created}/task-context.md`) + expect(written.command).toContain(written.path) + expect(written.command).not.toContain(windowsSide) + }) + + it("refuses rather than name a Windows path the distro's agent cannot read", () => { + const write = (wslDirectory: WslLaunchDirectory | undefined) => + writeSpawnLaunchFile({ + launchFile: launchFile(), + command: 'claude', + orcaBuiltLine: true, + wslDistro: 'Ubuntu', + wslDirectory + }) + expect(() => write(undefined)).toThrow(LaunchFileUnavailableError) + expect(() => write({ ...directory, distro: 'Debian' })).toThrow(LaunchFileUnavailableError) + expect(readdirSync(join(windowsSide, '..'))).toEqual([]) + }) + + it('refuses a long agent line it would otherwise type raw, and types a short one', () => { + const line = (command: string) => () => + writeSpawnLaunchFile({ + command, + orcaBuiltLine: true, + wslDistro: 'Ubuntu', + wslDirectory: undefined + }) + expect(line(`claude '${'x'.repeat(600)}'`)).toThrow(LaunchFileUnavailableError) + expect(line(`claude 'one\ntwo'`)).toThrow(LaunchFileUnavailableError) + expect(line(`claude 'fix it'`)()).toBeUndefined() + }) +}) + +describe('a WSL staged line', () => { + it('writes the script through the Windows side and runs it by its Linux path', () => { + const command = `claude '${'x'.repeat(600)}'` + const staging = stageStartupCommand({ + command, + shellPath: 'C:\\Windows\\System32\\wsl.exe', + orcaBuiltLine: true, + platform: 'win32', + wslDirectory: directory + }) + const [script] = readdirSync(windowsSide) + expect(staging).toMatchObject({ delivery: 'staged', scriptPath: join(windowsSide, script!) }) + // With no login shell from the probe, `/bin/sh` runs it whatever that shell is. + expect(staging.command).toBe(`/bin/sh '/home/ada/.cache/orca/${script}'`) + expect(readFileSync(join(windowsSide, script!), 'utf8')).toBe( + `command rm -f -- '/home/ada/.cache/orca/${script}'\n${command}\n` + ) + }) + + // Why: /bin/sh would skip the pane's own functions, such as Orca's codex wrapper. + it.each([ + ['/bin/bash', (path: string) => `. ${path}`], + ['/usr/bin/zsh', (path: string) => `. ${path}`], + ['/usr/bin/fish', (path: string) => `eval (string collect < ${path})`], + // ksh would run a sourced file in its own process group, so Ctrl-Z could not stop the agent. + ['/bin/ksh', (path: string) => `/bin/sh ${path}`] + ])('runs the script for the distro login shell %s', (shell, line) => { + const staging = stageStartupCommand({ + command: `codex '${'x'.repeat(600)}'`, + shellPath: 'C:\\Windows\\System32\\wsl.exe', + orcaBuiltLine: true, + platform: 'win32', + wslDirectory: { ...directory, shell } + }) + const [script] = readdirSync(windowsSide) + expect(staging.command).toBe(line(`'/home/ada/.cache/orca/${script}'`)) + }) + + it("types a short line as is once the distro's shell is one Orca's quoting is literal in", () => { + const staging = stageStartupCommand({ + command: `claude 'fix it'`, + shellPath: 'C:\\Windows\\System32\\wsl.exe', + orcaBuiltLine: true, + platform: 'win32', + wslDirectory: { ...directory, shell: '/bin/bash' } + }) + expect(staging).toEqual({ command: `claude 'fix it'`, delivery: 'typed' }) + }) +}) + +describe('parseWslLaunchDirectory', () => { + it('accepts a UNC and Linux pair and rejects anything else', () => { + const valid = { + distro: 'Ubuntu', + windowsPath: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.cache\\orca', + linuxPath: '/home/ada/.cache/orca' + } + expect(parseWslLaunchDirectory(valid)).toEqual(valid) + expect(parseWslLaunchDirectory({ ...valid, windowsPath: 'C:\\Users\\ada' })).toBeUndefined() + expect(parseWslLaunchDirectory({ ...valid, linuxPath: 'home/ada' })).toBeUndefined() + expect(parseWslLaunchDirectory({ distro: 'Ubuntu' })).toBeUndefined() + expect(parseWslLaunchDirectory(null)).toBeUndefined() + }) + + it("keeps the distro's login shell only when it is an absolute path", () => { + const valid = { + distro: 'Ubuntu', + windowsPath: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.cache\\orca', + linuxPath: '/home/ada/.cache/orca' + } + expect(parseWslLaunchDirectory({ ...valid, shell: '/bin/bash' })).toEqual({ + ...valid, + shell: '/bin/bash' + }) + expect(parseWslLaunchDirectory({ ...valid, shell: 'bash' })).toEqual(valid) + expect(parseWslLaunchDirectory({ ...valid, shell: 7 })).toEqual(valid) + }) +}) diff --git a/src/shared/wsl-launch-directory.ts b/src/shared/wsl-launch-directory.ts new file mode 100644 index 000000000000..35c80785cf88 --- /dev/null +++ b/src/shared/wsl-launch-directory.ts @@ -0,0 +1,35 @@ +/** + * Where the Windows host writes a WSL session's staged launch lines and launch files: a directory + * in the distro's home, written through `\\wsl.localhost`, and named inside the distro by its Linux + * path. Resolved once in main, so the daemon never has to ask `wsl.exe`. + */ +export type WslLaunchDirectory = { + distro: string + /** The UNC form the Windows host writes through. */ + windowsPath: string + /** The same directory as the distro's shell and agent read it. */ + linuxPath: string + /** The login shell the pane runs; the host sees only wsl.exe, and this decides how to source. */ + shell?: string +} + +export function parseWslLaunchDirectory(value: unknown): WslLaunchDirectory | undefined { + if ( + typeof value !== 'object' || + value === null || + !('distro' in value) || + !('windowsPath' in value) || + !('linuxPath' in value) + ) { + return undefined + } + const { distro, windowsPath, linuxPath } = value + const shell = 'shell' in value && typeof value.shell === 'string' ? value.shell : undefined + return typeof distro === 'string' && + typeof windowsPath === 'string' && + windowsPath.startsWith('\\\\') && + typeof linuxPath === 'string' && + linuxPath.startsWith('/') + ? { distro, windowsPath, linuxPath, ...(shell?.startsWith('/') ? { shell } : {}) } + : undefined +} diff --git a/src/shared/wsl-login-shell-command.ts b/src/shared/wsl-login-shell-command.ts index fd0f0b505c98..9769c8e51d79 100644 --- a/src/shared/wsl-login-shell-command.ts +++ b/src/shared/wsl-login-shell-command.ts @@ -19,16 +19,21 @@ export function buildWslExecArgs( return [...(distro ? ['-d', distro] : []), '--exec', ...shellArgs] } +/** Sets `$_orca_wsl_shell` to the shell a WSL pane runs: the account's login shell, else $SHELL. */ +export const RESOLVE_WSL_LOGIN_SHELL: readonly string[] = [ + '_orca_wsl_shell=$(getent passwd "$(id -un)" 2>/dev/null | cut -d: -f7)', + 'if [ -z "$_orca_wsl_shell" ] || [ ! -x "$_orca_wsl_shell" ]; then', + ' _orca_wsl_shell="${SHELL:-/bin/bash}"', + 'fi', + 'if [ -z "$_orca_wsl_shell" ] || [ ! -x "$_orca_wsl_shell" ]; then', + ' _orca_wsl_shell=/bin/sh', + 'fi' +] + export function buildWslLoginShellCommand(command: string): string { const quotedCommand = quotePosixShell(command) return [ - '_orca_wsl_shell=$(getent passwd "$(id -un)" 2>/dev/null | cut -d: -f7)', - 'if [ -z "$_orca_wsl_shell" ] || [ ! -x "$_orca_wsl_shell" ]; then', - ' _orca_wsl_shell="${SHELL:-/bin/bash}"', - 'fi', - 'if [ -z "$_orca_wsl_shell" ] || [ ! -x "$_orca_wsl_shell" ]; then', - ' _orca_wsl_shell=/bin/sh', - 'fi', + ...RESOLVE_WSL_LOGIN_SHELL, '_orca_wsl_shell_name=$(basename "$_orca_wsl_shell" | tr "[:upper:]" "[:lower:]")', 'case "$_orca_wsl_shell_name" in', ` sh|dash) exec "$_orca_wsl_shell" -lc ${quotedCommand} ;;`, @@ -106,13 +111,7 @@ export function buildWslCapturedLoginShellCommand( export function buildWslInteractiveLoginShellCommand(): string { return [ - '_orca_wsl_shell=$(getent passwd "$(id -un)" 2>/dev/null | cut -d: -f7)', - 'if [ -z "$_orca_wsl_shell" ] || [ ! -x "$_orca_wsl_shell" ]; then', - ' _orca_wsl_shell="${SHELL:-/bin/bash}"', - 'fi', - 'if [ -z "$_orca_wsl_shell" ] || [ ! -x "$_orca_wsl_shell" ]; then', - ' _orca_wsl_shell=/bin/sh', - 'fi', + ...RESOLVE_WSL_LOGIN_SHELL, '_orca_shell_ready_root=""', // Why the explicit root first: the wrapper tree is content-addressed, so its // path carries a hash the guest cannot derive. The host publishes the From d96b1e75f27d6bcede9f63d28381c29dcc41990f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 01:26:36 -0700 Subject: [PATCH 12/42] fix(agent-launch): paste a launch prompt only when the launched agent is proven in front A launch pasted its prompt unless a shell was proven in the terminal's foreground, so any read that could not prove one let the prompt through. After an agent exited at startup, its shell turned bracketed paste on at the next prompt, readiness fired on it, and the prompt was typed into the shell: - macOS: a pane runs its shell under login, so the process-group fence's root was never the shell's group and never proved it; the cached foreground name could also still name the exited process. - Windows Git Bash and WSL: the shell-alone-in-its-job check never answers. Now one fresh read of the terminal's foreground decides: agent, shell or unknown. Only 'agent' lets a write through (paste, Enter, second Enter, reused panes too); 'shell' still drops a ready signal. A Windows host never proves the agent, so there the launch line carries the prompt at any size, as on main. --- .../agent-launch-typed-line-shell.test.ts | 19 +- .../runtime/agent-launch-typed-line-shell.ts | 15 + ...ed-agent-composer-readiness-claude.test.ts | 361 ++++++------------ .../launched-agent-composer-readiness.ts | 3 +- ...nched-agent-crash-guard.live-shell.test.ts | 174 +++++++++ .../launched-agent-exit-delivery.test.ts | 192 ++++++++++ src/main/runtime/launched-agent-foreground.ts | 60 +++ .../launched-agent-shell-foreground.ts | 92 ----- .../launched-agent-write-guard.test.ts | 41 +- .../runtime/launched-agent-write-guard.ts | 16 +- .../orca-runtime-activate-managed-worktree.ts | 21 +- ...runtime-resolve-worktree-removal-target.ts | 22 +- .../agent-launch-terminal-prompt.test.ts | 46 ++- .../methods/agent-launch-terminal-prompt.ts | 10 +- .../runtime/runtime-worktree-agent-startup.ts | 17 +- src/shared/startup-line-prompt-carry.test.ts | 32 +- src/shared/startup-line-prompt-carry.ts | 15 +- 17 files changed, 744 insertions(+), 392 deletions(-) create mode 100644 src/main/runtime/launched-agent-crash-guard.live-shell.test.ts create mode 100644 src/main/runtime/launched-agent-exit-delivery.test.ts create mode 100644 src/main/runtime/launched-agent-foreground.ts delete mode 100644 src/main/runtime/launched-agent-shell-foreground.ts diff --git a/src/main/runtime/agent-launch-typed-line-shell.test.ts b/src/main/runtime/agent-launch-typed-line-shell.test.ts index 1ef35b852927..9d17d07a49bc 100644 --- a/src/main/runtime/agent-launch-typed-line-shell.test.ts +++ b/src/main/runtime/agent-launch-typed-line-shell.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { nameLocalTypedLineShell } from './agent-launch-typed-line-shell' +import { + launchHostProvesAgentInFront, + nameLocalTypedLineShell +} from './agent-launch-typed-line-shell' describe('naming the shell a local launch line is typed into', () => { it('takes the request’s shell first, then the setting, then SHELL, as the spawn does', () => { @@ -28,3 +31,17 @@ describe('naming the shell a local launch line is typed into', () => { ).toBeUndefined() }) }) + +describe('whether a launch host can prove its agent is in front before a paste', () => { + it.each([ + ['a local macOS host', false, 'darwin', 'darwin', true], + ['a local Linux host', false, 'linux', 'linux', true], + ['a local Windows host', false, 'win32', 'win32', false], + // The pane runs in the distro, but the reads run on the Windows host. + ['a local WSL pane', false, 'linux', 'win32', false], + ['an SSH Linux host from Windows', true, 'linux', 'win32', true], + ['an SSH Windows host', true, 'win32', 'darwin', false] + ] as const)('%s: %s', (_label, isRemote, launchPlatform, hostPlatform, proves) => { + expect(launchHostProvesAgentInFront({ isRemote, launchPlatform, hostPlatform })).toBe(proves) + }) +}) diff --git a/src/main/runtime/agent-launch-typed-line-shell.ts b/src/main/runtime/agent-launch-typed-line-shell.ts index 6a7f83c048b4..f733ade33859 100644 --- a/src/main/runtime/agent-launch-typed-line-shell.ts +++ b/src/main/runtime/agent-launch-typed-line-shell.ts @@ -25,3 +25,18 @@ export function nameLocalTypedLineShell(args: { '/bin/zsh' return basename(shellPath).toLowerCase() } + +/** + * Whether the execution host can prove a launched agent holds its terminal before a paste + * (`launched-agent-foreground`). A Windows host cannot, and a local WSL pane runs on one; an SSH + * host is judged by its own platform. + */ +export function launchHostProvesAgentInFront(args: { + isRemote: boolean + launchPlatform: NodeJS.Platform + hostPlatform?: NodeJS.Platform +}): boolean { + return args.isRemote + ? args.launchPlatform !== 'win32' + : (args.hostPlatform ?? process.platform) !== 'win32' +} diff --git a/src/main/runtime/launched-agent-composer-readiness-claude.test.ts b/src/main/runtime/launched-agent-composer-readiness-claude.test.ts index 889f08a2ef68..146479062660 100644 --- a/src/main/runtime/launched-agent-composer-readiness-claude.test.ts +++ b/src/main/runtime/launched-agent-composer-readiness-claude.test.ts @@ -131,7 +131,7 @@ describe('a fresh orchestration worker start for Claude', () => { }) }) -describe('whether a shell is proven in front of a launched agent’s terminal', () => { +describe('what holds a launched agent’s terminal', () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')! const setPlatform = (value: NodeJS.Platform): void => { Object.defineProperty(process, 'platform', { configurable: true, value }) @@ -140,315 +140,206 @@ describe('whether a shell is proven in front of a launched agent’s terminal', Object.defineProperty(process, 'platform', originalPlatform) }) - describe('macOS and Linux: a fresh foreground read', () => { + describe('macOS and Linux: one fresh foreground read decides', () => { 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) => { + ['claude', 'zsh', 'shell'], + // A stub agent that crashed: the cached read can still name it after zsh took the terminal. + ['python3', 'zsh', 'shell'], + ['claude', 'claude', 'agent'], + // A native Claude names itself by its version. + ['zsh', '2.1.285', 'agent'], + ['zsh', 'node', 'agent'], + ['-zsh', '-zsh', 'shell'], + ['bash', 'bash', 'shell'], + [null, 'zsh', 'shell'], + ['claude', null, 'unknown'] + ] as const)('cached %s, scan %s: %s', async (foregroundProcess, scanned, found) => { const scan = vi.fn() + const proof = vi.fn() const { runtime } = await createTranscriptPane({ paneTitle: 'Claude Code', foregroundProcess, confirmedForegroundProcess: scanned, onForegroundScan: scan, + shellForegroundProven: false, + onShellForegroundProof: proof, launchAgent: 'claude', data: '' }) - await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'claude')).resolves.toBe( - shellInFront - ) + await expect( + runtime.readLaunchedAgentForeground(TRANSCRIPT_PANE_PTY_ID, 'claude') + ).resolves.toBe(found) expect(scan).toHaveBeenCalledOnce() + expect(proof).not.toHaveBeenCalled() }) - // 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() + it('proves nothing on a controller without a fresh scan', async () => { const { runtime } = await createTranscriptPane({ - paneTitle: 'copilot', - foregroundProcess: 'copilot', - confirmedForegroundProcess: 'zsh', - shellForegroundProven: false, - onShellForegroundProof: proof, - launchAgent: 'copilot', + paneTitle: 'Claude Code', + foregroundProcess: 'claude', + launchAgent: 'claude', data: '' }) - await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'copilot')).resolves.toBe( - true - ) - expect(proof).not.toHaveBeenCalled() + await expect( + runtime.readLaunchedAgentForeground(TRANSCRIPT_PANE_PTY_ID, 'claude') + ).resolves.toBe('unknown') }) - it('takes the cached read on a controller without a scan', async () => { - const { runtime } = await createTranscriptPane({ - paneTitle: 'Claude Code', + // Captured with `ps -o pid=,ppid=,pgid=,tpgid=,stat=,command=` on a pane spawned the way a macOS + // pane is, under `login`: zsh holds the terminal in its own group, never the root's. + it('reads a login-wrapped pane’s zsh at its prompt as the shell', async () => { + const rows: ProcessTableRow[] = [ + { + pid: 60404, + ppid: 60394, + pgid: 60404, + tpgid: 60406, + stat: 'Ss', + tty: 'ttys004', + startTime: 'Fri Oct 2 01:20:11 2026', + command: '/usr/bin/login -flpq user /bin/bash --noprofile --norc -p -c' + }, + { + pid: 60406, + ppid: 60404, + pgid: 60406, + tpgid: 60406, + stat: 'S+', + tty: 'ttys004', + startTime: 'Fri Oct 2 01:20:11 2026', + command: '-/bin/zsh -f' + } + ] + const inspection = { foregroundProcess: 'zsh', + hasChildProcesses: true, + foregroundProcessEvidence: resolveRemoteForegroundEvidence( + { rootPid: 60404, fallbackProcess: 'zsh' }, + { + ptyId: TRANSCRIPT_PANE_PTY_ID, + ptyIncarnationId: 'inc-1', + authorityGeneration: 'gen-1', + observationEpoch: 1, + capturedAgeMs: 0, + platform: 'darwin' + }, + rows + ) + } + // Presence precondition: the fence is live, and its foreground group is not the root's. + expect(inspection.foregroundProcessEvidence).toMatchObject({ + verdict: 'live', + fence: { shellPid: 60404, foregroundPgid: 60406 } + }) + const { runtime } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'python3', + processInspection: inspection, + confirmedForegroundProcess: '-zsh', launchAgent: 'claude', data: '' }) - await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'claude')).resolves.toBe( - true - ) + await expect( + runtime.readLaunchedAgentForeground(TRANSCRIPT_PANE_PTY_ID, 'claude') + ).resolves.toBe('shell') + }) + + // A wrapper that does not `exec` an agent the scan cannot recognize keeps its own name in front, + // which reads as a shell: the prompt stays undelivered rather than risk the user's shell. + it('reads a bash wrapper still in front of an unrecognized agent as a shell', async () => { + const { runtime } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'bash', + confirmedForegroundProcess: 'bash', + launchAgent: 'copilot', + data: '' + }) + + await expect( + runtime.readLaunchedAgentForeground(TRANSCRIPT_PANE_PTY_ID, 'copilot') + ).resolves.toBe('shell') }) }) - describe('Windows: only the shell-foreground check', () => { + // The scan names the pane's shell for an agent it cannot recognize (an npm agent as `node.exe`), + // and Git Bash and WSL keep other processes in the shell's job, so nothing proves the agent. + describe('Windows: only the shell-foreground check, and never the agent', () => { 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) => { + ['powershell.exe', false, 'unknown'], + ['powershell.exe', true, 'shell'], + ['claude', true, 'shell'], + ['claude', false, 'unknown'], + ['node', false, 'unknown'] + ] as const)('cached %s, shell check %s: %s', async (foregroundProcess, proven, found) => { const scan = vi.fn() const { runtime } = await createTranscriptPane({ paneTitle: 'Claude Code', foregroundProcess, - confirmedForegroundProcess: 'powershell.exe', + confirmedForegroundProcess: 'claude', onForegroundScan: scan, shellForegroundProven: proven, launchAgent: 'claude', data: '' }) - await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'claude')).resolves.toBe( - shellInFront - ) + await expect( + runtime.readLaunchedAgentForeground(TRANSCRIPT_PANE_PTY_ID, 'claude') + ).resolves.toBe(found) 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) => { + ['claude', 'agent'], + ['bash', 'shell'] + ] as const)( + 'SSH: takes the relay’s own read %s (%s), without a scan or check', + async (relayRead, found) => { + setPlatform('darwin') const scan = vi.fn() const proof = vi.fn() const { runtime } = await createTranscriptPane({ paneTitle: 'Claude Code', foregroundProcess: relayRead, - confirmedForegroundProcess: shell ? 'claude' : 'zsh', + confirmedForegroundProcess: found === 'shell' ? 'claude' : 'zsh', onForegroundScan: scan, - shellForegroundProven: !shell, + shellForegroundProven: found !== 'shell', onShellForegroundProof: proof, connectionId: 'ssh-1', launchAgent: 'claude', data: '' }) - await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'claude')).resolves.toBe( - shell - ) + await expect( + runtime.readLaunchedAgentForeground(TRANSCRIPT_PANE_PTY_ID, 'claude') + ).resolves.toBe(found) 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 () => { + it('SSH to a Windows host: never the agent', async () => { const { runtime } = await createTranscriptPane({ paneTitle: 'Copilot', - foregroundProcess: 'powershell.exe', + foregroundProcess: 'node', connectionId: 'ssh-1', remoteWindowsHost: true, launchAgent: 'copilot', data: '' }) - await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'copilot')).resolves.toBe( - false - ) + await expect( + runtime.readLaunchedAgentForeground(TRANSCRIPT_PANE_PTY_ID, 'copilot') + ).resolves.toBe('unknown') }) - - // 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.ts b/src/main/runtime/launched-agent-composer-readiness.ts index 7c8fa6871a0e..49db277d0f32 100644 --- a/src/main/runtime/launched-agent-composer-readiness.ts +++ b/src/main/runtime/launched-agent-composer-readiness.ts @@ -9,7 +9,8 @@ * 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`). + * proven in front (`readLaunchedAgentForeground`). A signal is never proof by itself: a shell back + * at its prompt turns bracketed paste on too, so the write still needs the agent found in front. * * 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 diff --git a/src/main/runtime/launched-agent-crash-guard.live-shell.test.ts b/src/main/runtime/launched-agent-crash-guard.live-shell.test.ts new file mode 100644 index 000000000000..8dd13f8543ca --- /dev/null +++ b/src/main/runtime/launched-agent-crash-guard.live-shell.test.ts @@ -0,0 +1,174 @@ +/** + * A launched agent that exits at startup hands the terminal back to its shell, which turns bracketed + * paste on at its next prompt just as an agent's composer does. Real zsh with a slow user config, + * spawned the way a pane is (under `login` on macOS), read through the terminal daemon's own + * foreground tracker: the launch must find the shell and write nothing, and must still find a live + * agent. + */ +import { spawnSync } from 'node:child_process' +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import * as pty from 'node-pty' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { recognizeAgentProcess } from '../../shared/agent-process-recognition' +import { getStrictProcessTableSnapshotWithAge } from '../../shared/process-table-snapshot-reader' +import { createPtyForegroundProcessTracker } from '../daemon/pty-subprocess/foreground-process-tracker' +import { resolveRemoteForegroundEvidence } from '../providers/agent-foreground-process' +import { + prepareMacosTccLoginShell, + wrapShellSpawnForMacosTccAttribution +} from '../providers/macos-tcc-login-shell' +import { readLaunchedAgentForeground } from './launched-agent-foreground' +import { createLaunchedAgentWriteGuard } from './launched-agent-write-guard' +import { waitForWorktreeStartupDraft } from './runtime-worktree-startup-readiness' + +const PTY_ID = 'pty-1' +const ZSH_PATH = + process.platform === 'win32' + ? '' + : (spawnSync('sh', ['-c', 'command -v zsh'], { encoding: 'utf8' }).stdout ?? '').trim() +const describeWithZsh = ZSH_PATH ? describe : describe.skip + +const STUBS = { + // Exits at startup, as an agent with a bad config or a missing dependency does. + crash: "#!/bin/sh\nprintf 'stub: crashing at startup\\r\\n'\nexit 1\n", + // Opens its composer the way an agent does, then stays up. + ready: "#!/bin/sh\nprintf '\\033[?2004hstub agent\\r\\n> '\nexec sleep 30\n" +} as const + +let home = '' +let savedLoginOptOut: string | undefined + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'orca-crash-guard-')) + // The production spawn: a pane on macOS runs under `login` unless the user opted out. + savedLoginOptOut = process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL + delete process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL +}) + +afterEach(() => { + if (savedLoginOptOut !== undefined) { + process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL = savedLoginOptOut + } + rmSync(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) +}) + +/** Launches the `claude` stub in a fresh zsh pane and runs the launch's own readiness wait. */ +async function launchStub(stub: keyof typeof STUBS, readyTimeoutMs: number) { + const bin = join(home, 'bin') + mkdirSync(bin) + // Typed by absolute path: `login` resets HOME and PATH, so a bare name would find a real agent. + const stubPath = join(bin, 'claude') + writeFileSync(stubPath, STUBS[stub]) + chmodSync(stubPath, 0o755) + writeFileSync(join(home, '.zshrc'), 'sleep 2\n') + const env = { PATH: '/usr/bin:/bin', HOME: home, ZDOTDIR: home, TERM: 'xterm-256color' } + await prepareMacosTccLoginShell() + const spawn = wrapShellSpawnForMacosTccAttribution(ZSH_PATH, ['-i'], env) + const proc = pty.spawn(spawn.file, spawn.args, { cols: 80, rows: 24, cwd: home, env }) + let dead = false + proc.onExit(() => { + dead = true + }) + const listeners = new Set<(data: string) => void>() + let output = '' + const tracker = createPtyForegroundProcessTracker({ + process: proc, + shellPath: ZSH_PATH, + cwd: home, + sessionId: 'wt-crash-guard@@pane', + startupAgentRecognition: recognizeAgentProcess('claude'), + isDead: () => dead + }) + proc.onData((data) => { + tracker.recordOutput(data) + output += data + for (const listener of listeners) { + listener(data) + } + }) + // The daemon's answers, each from the same pieces the daemon uses. + const controller = { + getForegroundProcess: async () => tracker.getForegroundProcess(), + confirmForegroundProcess: () => tracker.confirmForegroundProcess(), + confirmShellForeground: () => tracker.confirmShellForeground(), + inspectProcess: async () => { + const snapshot = await getStrictProcessTableSnapshotWithAge() + return { + foregroundProcess: tracker.getForegroundProcess(), + hasChildProcesses: true, + foregroundProcessEvidence: resolveRemoteForegroundEvidence( + { rootPid: proc.pid, fallbackProcess: tracker.getForegroundProcess() }, + { + ptyId: PTY_ID, + ptyIncarnationId: 'inc-1', + authorityGeneration: 'gen-1', + observationEpoch: 1, + capturedAgeMs: snapshot.capturedAgeMs, + platform: process.platform + }, + snapshot.rows + ) + } + } + } + const readForeground = (ptyId: string) => + readLaunchedAgentForeground(controller, { remote: false, windows: false }, ptyId, 'claude') + const subscribe = (_ptyId: string, listener: (data: string) => void) => { + listeners.add(listener) + return () => listeners.delete(listener) + } + const ready = waitForWorktreeStartupDraft( + { + getPtyId: () => PTY_ID, + getForegroundProcess: controller.getForegroundProcess, + subscribeToData: subscribe, + readRecentOutput: () => undefined, + write: () => {} + }, + 'term-1', + 'claude', + { + timeoutMs: readyTimeoutMs, + isShellInFront: async (ptyId) => (await readForeground(ptyId)) === 'shell' + } + ) + proc.write(`'${stubPath}'\r`) + const guard = createLaunchedAgentWriteGuard( + { readLaunchedAgentForeground: readForeground, subscribeToTerminalData: subscribe }, + 'claude' + ) + const close = async (): Promise => { + guard.dispose() + proc.kill() + await new Promise((resolve) => setTimeout(resolve, 200)) + } + return { ready, guard, close, output: () => output } +} + +describeWithZsh('a launch prompt after the launched agent exits at startup', () => { + it('drops the shell’s prompt as a ready signal and refuses the write', async () => { + const launch = await launchStub('crash', 9_000) + try { + // The shell's prompt after the crash turned bracketed paste on and went quiet, yet no agent did. + await expect(launch.ready).resolves.toBeNull() + // Presence precondition: the stub, not anything else on the host, is what ran and exited. + expect(launch.output()).toContain('stub: crashing at startup') + await expect(launch.guard.beforeWrite(PTY_ID)).rejects.toThrow('agent_not_in_foreground') + } finally { + await launch.close() + } + }, 30_000) + + it('still finds an agent that stays up, and lets the write through', async () => { + const launch = await launchStub('ready', 15_000) + try { + await expect(launch.ready).resolves.toBe(PTY_ID) + expect(launch.output()).toContain('stub agent') + await expect(launch.guard.beforeWrite(PTY_ID)).resolves.toBeUndefined() + } finally { + await launch.close() + } + }, 30_000) +}) diff --git a/src/main/runtime/launched-agent-exit-delivery.test.ts b/src/main/runtime/launched-agent-exit-delivery.test.ts new file mode 100644 index 000000000000..37c90e4354f5 --- /dev/null +++ b/src/main/runtime/launched-agent-exit-delivery.test.ts @@ -0,0 +1,192 @@ +/** + * An agent that exits at startup leaves its shell at a prompt that turns bracketed paste on, which + * is the same signal an agent's composer gives. Driven through the launch's own readiness wait, + * foreground read and write guard, with each host answering the way it does after the exit. + */ + +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { deliverTerminalAgentLaunchPrompt } from './rpc/methods/agent-launch-terminal-prompt' +import { readLaunchedAgentForeground } from './launched-agent-foreground' +import { waitForWorktreeStartupDraft } from './runtime-worktree-startup-readiness' + +const PTY_ID = 'pty-1' +const SHELL_HANDOFF = '\x1b[?2004l' + +/** zsh's prompt, the launch line it runs, then its prompt again once that command exited. */ +function zshRunsLaunchLineThatExits(): string[] { + const zsh = readFileSync(join(__dirname, '__fixtures__', 'zsh-prompt-runs-command.txt'), 'utf8') + const split = zsh.indexOf('\n', zsh.indexOf(SHELL_HANDOFF)) + 1 + return [zsh.slice(0, split), 'stub: crashing at startup\r\n', zsh.slice(split)] +} + +/** Git Bash and WSL's bash: readline turns bracketed paste on at each prompt. */ +function bashRunsLaunchLineThatExits(): string[] { + return [ + '\x1b[?2004hqa@host:~$ claude\r\n\x1b[?2004l\r', + 'stub: crashing at startup\r\n', + '\x1b[?2004hqa@host:~$ ' + ] +} + +type HostAnswers = { + host: { remote: boolean; windows: boolean } + cached: string | null + scanned: string | null + shellAlone: boolean +} + +const HOSTS: [string, HostAnswers, () => string[]][] = [ + // The shell keeps other processes in its job, so the shell-alone check never answers. + [ + 'Windows Git Bash', + { + host: { remote: false, windows: true }, + cached: 'claude', + scanned: 'bash', + shellAlone: false + }, + bashRunsLaunchLineThatExits + ], + [ + 'Windows WSL', + { host: { remote: false, windows: true }, cached: 'claude', scanned: 'wsl', shellAlone: false }, + bashRunsLaunchLineThatExits + ], + // A pane under `login`, whose cached name is still the exited stub's. + [ + 'macOS zsh', + { + host: { remote: false, windows: false }, + cached: 'python3', + scanned: 'zsh', + shellAlone: false + }, + zshRunsLaunchLineThatExits + ] +] + +function launchedPane(answers: HostAnswers) { + const listeners = new Set<(data: string) => void>() + const subscribe = (_ptyId: string, listener: (data: string) => void) => { + listeners.add(listener) + return () => listeners.delete(listener) + } + const controller = { + getForegroundProcess: async () => answers.cached, + confirmForegroundProcess: async () => answers.scanned, + confirmShellForeground: async () => answers.shellAlone + } + const readForeground = (ptyId: string) => + readLaunchedAgentForeground(controller, answers.host, ptyId, 'claude') + const writes: string[] = [] + const runtime = { + waitForFreshWorkerComposer: async (_handle: string, agent: 'claude', timeoutMs: number) => { + const ptyId = await waitForWorktreeStartupDraft( + { + getPtyId: () => PTY_ID, + getForegroundProcess: controller.getForegroundProcess, + subscribeToData: subscribe, + readRecentOutput: () => undefined, + write: () => {} + }, + 'term-1', + agent, + { + timeoutMs, + requireComposerMarker: false, + isShellInFront: async (id) => (await readForeground(id)) === 'shell' + } + ) + if (!ptyId) { + throw new Error('timeout') + } + return { handle: 'term-1', satisfied: true, status: 'running' } + }, + // The idle evidence after the budget: the shell's prompt can look settled too. + waitForTerminal: vi.fn(async () => ({ handle: 'term-1', satisfied: true, status: 'idle' })), + readLaunchedAgentForeground: (ptyId: string) => readForeground(ptyId), + subscribeToTerminalData: subscribe, + sendTerminalAgentPrompt: vi.fn( + async ( + handle: string, + text: string, + options: { beforeWrite?: (id: string) => Promise } + ) => { + await options.beforeWrite?.(PTY_ID) + writes.push(text) + return { handle, accepted: true, bytesWritten: text.length } + } + ) + } + return { + runtime, + writes, + emit: (data: string) => { + for (const listener of listeners) { + listener(data) + } + } + } +} + +describe('a launch prompt after the launched agent exits at startup', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it.each(HOSTS)( + '%s: types nothing and reports it undelivered', + async (_label, answers, transcript) => { + const pane = launchedPane(answers) + const delivery = deliverTerminalAgentLaunchPrompt({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the deliverer reaches only the methods built above. + runtime: pane.runtime as unknown as Parameters< + typeof deliverTerminalAgentLaunchPrompt + >[0]['runtime'], + handle: 'term-1', + agent: 'claude', + freshLaunch: true, + text: 'QA prompt that must never run as a shell command' + }) + for (const chunk of transcript()) { + pane.emit(chunk) + } + await vi.advanceTimersByTimeAsync(61_000) + + await expect(delivery).resolves.toBe(false) + expect(pane.writes).toEqual([]) + } + ) + + it('macOS zsh: still writes into an agent that stays up', async () => { + const pane = launchedPane({ + host: { remote: false, windows: false }, + cached: 'claude', + scanned: 'claude', + shellAlone: false + }) + const delivery = deliverTerminalAgentLaunchPrompt({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the deliverer reaches only the methods built above. + runtime: pane.runtime as unknown as Parameters< + typeof deliverTerminalAgentLaunchPrompt + >[0]['runtime'], + handle: 'term-1', + agent: 'claude', + freshLaunch: true, + text: 'QA prompt' + }) + pane.emit(zshRunsLaunchLineThatExits()[0]) + pane.emit('\x1b[?2004hClaude Code\r\n> ') + await vi.advanceTimersByTimeAsync(2_000) + + await expect(delivery).resolves.toBe(true) + expect(pane.writes).toEqual(['QA prompt']) + }) +}) diff --git a/src/main/runtime/launched-agent-foreground.ts b/src/main/runtime/launched-agent-foreground.ts new file mode 100644 index 000000000000..2d1abb1f9bf7 --- /dev/null +++ b/src/main/runtime/launched-agent-foreground.ts @@ -0,0 +1,60 @@ +import { isExpectedAgentProcess } 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' + +/** + * What holds a launched agent's terminal: the agent (anything but the pane's shell), the shell + * (the launch line has not run yet, or the agent exited), or `unknown` when the host cannot tell. + * Only `agent` lets a launch write its prompt; only `shell` drops a ready signal. + */ +export type LaunchedAgentForeground = 'agent' | 'shell' | 'unknown' + +/** A login shell is reported as `-zsh`. */ +function isLaunchShell(processName: string): boolean { + return isShellProcess(processName.replace(/^-/, '')) +} + +/** + * One fresh read of the terminal's foreground process: on a local POSIX host the scan behind + * `confirmForegroundProcess`, and on an SSH host the relay's name, which it reads from the terminal + * when asked. Never the cached name a tab icon uses: it can still name a process that already + * exited. Not the process-group fence either: a macOS pane runs its shell under `login`, so the + * fence's root is never the shell's group. + * + * Windows has no foreground process group, and its scan names the pane's shell for an agent it + * cannot recognize, while Git Bash and WSL keep other processes in the shell's job, so nothing + * there proves the agent. A Windows host can still prove its shell alone (`confirmShellForeground`). + */ +export async function readLaunchedAgentForeground( + controller: Pick< + RuntimePtyController, + 'getForegroundProcess' | 'confirmForegroundProcess' | 'confirmShellForeground' + > | null, + host: { remote: boolean; windows: boolean }, + ptyId: string, + agent: TuiAgent +): Promise { + if (!controller) { + return 'unknown' + } + try { + if (host.windows) { + // An SSH pane has no such check, so on a Windows relay nothing proves a shell. + return (await controller.confirmShellForeground?.(ptyId)) ? 'shell' : 'unknown' + } + const foreground = host.remote + ? await controller.getForegroundProcess(ptyId) + : ((await controller.confirmForegroundProcess?.(ptyId)) ?? null) + if (!foreground) { + return 'unknown' + } + return isLaunchShell(foreground) && + !isExpectedAgentProcess(foreground, TUI_AGENT_CONFIG[agent].expectedProcess) + ? 'shell' + : 'agent' + } catch { + return 'unknown' + } +} diff --git a/src/main/runtime/launched-agent-shell-foreground.ts b/src/main/runtime/launched-agent-shell-foreground.ts deleted file mode 100644 index 9b5c20bb7f1d..000000000000 --- a/src/main/runtime/launched-agent-shell-foreground.ts +++ /dev/null @@ -1,92 +0,0 @@ -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-write-guard.test.ts b/src/main/runtime/launched-agent-write-guard.test.ts index 4af5f64c3b70..29d6aed51ca7 100644 --- a/src/main/runtime/launched-agent-write-guard.test.ts +++ b/src/main/runtime/launched-agent-write-guard.test.ts @@ -1,17 +1,20 @@ import { describe, expect, it, vi } from 'vitest' +import type { LaunchedAgentForeground } from './launched-agent-foreground' import { createLaunchedAgentWriteGuard, type LaunchedAgentWriteGuardRuntime } from './launched-agent-write-guard' -function guardHarness(shellInFront: boolean[]) { - const answers = [...shellInFront] +function guardHarness(foregrounds: LaunchedAgentForeground[]) { + const answers = [...foregrounds] let listener: ((data: string) => void) | null = null const unsubscribe = vi.fn(() => { listener = null }) const runtime: LaunchedAgentWriteGuardRuntime = { - isLaunchShellInFront: vi.fn(async () => answers.shift() ?? false), + readLaunchedAgentForeground: vi.fn( + async (): Promise => answers.shift() ?? 'agent' + ), subscribeToTerminalData: vi.fn((_ptyId: string, next: (data: string) => void) => { listener = next return unsubscribe @@ -22,23 +25,35 @@ function guardHarness(shellInFront: boolean[]) { } 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]) + it('reuses a read that found the agent for the writes after it', async () => { + const { runtime, guard } = guardHarness(['agent']) 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) + expect(runtime.readLaunchedAgentForeground).toHaveBeenCalledTimes(1) }) + // Why: a ready signal can come from a shell whose agent exited, so only a read that finds the + // agent may let the text through; an unanswered read is not that. + it.each(['shell', 'unknown'] as const)( + 'refuses a write when the read finds %s', + async (found) => { + const { guard, unsubscribe } = guardHarness([found]) + + await expect(guard.beforeWrite('pty-1')).rejects.toThrow('agent_not_in_foreground') + expect(unsubscribe).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]) + const { runtime, guard, emit } = guardHarness(['agent', 'shell']) await guard.beforeWrite('pty-1') for (const chunk of typeof output === 'string' ? [output] : output) { @@ -46,17 +61,17 @@ describe('the check before each write of a launch prompt', () => { } await expect(guard.beforeWrite('pty-1')).rejects.toThrow('agent_not_in_foreground') - expect(runtime.isLaunchShellInFront).toHaveBeenCalledTimes(2) + expect(runtime.readLaunchedAgentForeground).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) + vi.mocked(runtime.readLaunchedAgentForeground) .mockImplementationOnce(async () => { emit('\x1b[?2004h') - return false + return 'agent' }) - .mockResolvedValueOnce(true) + .mockResolvedValueOnce('shell') await guard.beforeWrite('pty-1') @@ -64,11 +79,11 @@ describe('the check before each write of a launch prompt', () => { }) it('stops watching when it refuses and when it is disposed', async () => { - const refused = guardHarness([true]) + const refused = guardHarness(['shell']) await expect(refused.guard.beforeWrite('pty-1')).rejects.toThrow('agent_not_in_foreground') expect(refused.unsubscribe).toHaveBeenCalledTimes(1) - const cleared = guardHarness([false]) + const cleared = guardHarness(['agent']) 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 index 80f0bf71d6b7..7e29f3008c42 100644 --- a/src/main/runtime/launched-agent-write-guard.ts +++ b/src/main/runtime/launched-agent-write-guard.ts @@ -1,5 +1,6 @@ import type { TuiAgent } from '../../shared/tui-agent' import type { OrcaRuntimeService } from './orca-runtime' +import type { LaunchedAgentForeground } from './launched-agent-foreground' /** 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 @@ -7,14 +8,15 @@ const MARKER_CARRY_CHARS = Math.max(...SHELL_RETURN_MARKERS.map((marker) => mark export type LaunchedAgentWriteGuardRuntime = Pick< OrcaRuntimeService, - 'isLaunchShellInFront' | 'subscribeToTerminalData' + 'readLaunchedAgentForeground' | '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. + * A write needs a fresh read that finds the agent in front; a shell, or a host that cannot tell, + * refuses it, since a ready signal alone can come from a shell back at its prompt. Once a read finds + * the agent, 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, @@ -41,14 +43,14 @@ export function createLaunchedAgentWriteGuard( watch.shellMayHaveReturned = true } }) - let shellInFront: boolean + let foreground: LaunchedAgentForeground try { - shellInFront = await runtime.isLaunchShellInFront(ptyId, agent) + foreground = await runtime.readLaunchedAgentForeground(ptyId, agent) } catch (error) { watch.unsubscribe() throw error } - if (shellInFront) { + if (foreground !== 'agent') { watch.unsubscribe() throw new Error('agent_not_in_foreground') } diff --git a/src/main/runtime/orca-runtime-activate-managed-worktree.ts b/src/main/runtime/orca-runtime-activate-managed-worktree.ts index cecf3223b10b..7a17aadfc3c1 100644 --- a/src/main/runtime/orca-runtime-activate-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-activate-managed-worktree.ts @@ -33,7 +33,10 @@ 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' +import { + readLaunchedAgentForeground, + type LaunchedAgentForeground +} from './launched-agent-foreground' export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListManagedWorktrees { async activateManagedWorktree( @@ -208,7 +211,8 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM timeoutMs, requireComposerMarker, signal: stop.signal, - isShellInFront: (ownerPtyId) => this.isLaunchShellInFront(ownerPtyId, agent), + isShellInFront: async (ownerPtyId) => + (await this.readLaunchedAgentForeground(ownerPtyId, agent)) === 'shell', accept: (readyPtyId) => { const pty = this.ptysById.get(readyPtyId) const hold = pty @@ -234,12 +238,17 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM 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 { + /** What holds the terminal a launch started its agent in, read fresh from the execution host. */ + readLaunchedAgentForeground(ptyId: string, agent: TuiAgent): Promise { const pty = this.ptysById.get(ptyId) - return isShellInFrontOfLaunchedAgent( + const remote = !!pty?.connectionId + return readLaunchedAgentForeground( this.ptyController, - { remote: !!pty?.connectionId, windows: this.pathFlavorForPty(pty) === 'win32' }, + // A local WSL pane still runs on a Windows host, whose process reads cannot see into it. + { + remote, + windows: remote ? this.pathFlavorForPty(pty) === 'win32' : process.platform === 'win32' + }, ptyId, agent ) 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 cbbf85aa37c8..643441467ec3 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -21,7 +21,10 @@ import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtim import { resolveBareAgentLaunchCommand } from './runtime-agent-launch-resolution' import { agentPromptRidesLaunchCommand } from '../../shared/tui-agent-startup' import { planStartupWithPromptCandidate } from '../../shared/startup-line-prompt-carry' -import { nameLocalTypedLineShell } from './agent-launch-typed-line-shell' +import { + launchHostProvesAgentInFront, + nameLocalTypedLineShell +} from './agent-launch-typed-line-shell' import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' @@ -273,13 +276,16 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith sessionOptions: this.toAgentSessionOptions(opts.launchPreferences) }), opts.startupPrompt ?? '', - nameLocalTypedLineShell({ - isRemote, - ...(opts.shellOverride ? { shellOverride: opts.shellOverride } : {}), - ...(settings.terminalDefaultShell - ? { defaultShellSetting: settings.terminalDefaultShell } - : {}) - }) + { + shellName: nameLocalTypedLineShell({ + isRemote, + ...(opts.shellOverride ? { shellOverride: opts.shellOverride } : {}), + ...(settings.terminalDefaultShell + ? { defaultShellSetting: settings.terminalDefaultShell } + : {}) + }), + provesAgentInFront: launchHostProvesAgentInFront({ isRemote, launchPlatform: platform }) + } ) if (!startupPlan) { // Why: an explicit agent that yields no plan would otherwise spawn a bare 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 0c43c77b40d0..fc58a875377b 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 @@ -26,8 +26,8 @@ function runtimeStub(overrides: { 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 + /** What a fresh read finds in the terminal's foreground; default: the agent. */ + foreground?: 'agent' | 'shell' | 'unknown' }) { const queued = [...(overrides.waits ?? [])] const waitForTerminal = vi.fn( @@ -40,7 +40,7 @@ function runtimeStub(overrides: { } return COMPOSER_READY }) - const isLaunchShellInFront = vi.fn(async () => overrides.shellInFront ?? false) + const readLaunchedAgentForeground = vi.fn(async () => overrides.foreground ?? 'agent') const subscribeToTerminalData = vi.fn(() => () => {}) const sendTerminalAgentPrompt = vi.fn( overrides.send ?? (async () => ({ handle: 'term_1', accepted: true, bytesWritten: 12 })) @@ -49,13 +49,13 @@ function runtimeStub(overrides: { waitForTerminal, waitForFreshWorkerComposer, sendTerminalAgentPrompt, - isLaunchShellInFront, + readLaunchedAgentForeground, // 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, + readLaunchedAgentForeground, subscribeToTerminalData } as unknown as Parameters[0]['runtime'] } @@ -228,12 +228,13 @@ describe('writing a launch prompt into a terminal agent', () => { }) 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) => { + ['writes when a fresh read finds the agent in front', 'agent', true], + ['refuses the write when the shell is back in front', 'shell', false], + // A ready signal can be a shell's own prompt, so a read that cannot tell proves nothing. + ['refuses the write when the host cannot tell what is in front', 'unknown', false] + ] as const)('%s', async (_label, foreground, delivered) => { // An agent that exits at startup hands its shell back, which must never run the prompt. - const stub = runtimeStub({ composerSignal: true, shellInFront }) + const stub = runtimeStub({ composerSignal: true, foreground }) stub.sendTerminalAgentPrompt.mockImplementation(async (handle, _text, options) => { const { beforeWrite } = options if (typeof beforeWrite === 'function') { @@ -251,7 +252,7 @@ describe('writing a launch prompt into a terminal agent', () => { text: 'do the thing' }) ).resolves.toBe(delivered) - expect(stub.isLaunchShellInFront).toHaveBeenCalledWith('pty-1', 'claude') + expect(stub.readLaunchedAgentForeground).toHaveBeenCalledWith('pty-1', 'claude') }) it('checks the foreground once for the paste, its Enter and the second Enter', async () => { @@ -275,7 +276,7 @@ describe('writing a launch prompt into a terminal agent', () => { text: 'do the thing' }) ).resolves.toBe(true) - expect(stub.isLaunchShellInFront).toHaveBeenCalledTimes(1) + expect(stub.readLaunchedAgentForeground).toHaveBeenCalledTimes(1) }) it('writes as soon as the composer signal fires, without consulting the idle evidence', async () => { @@ -381,6 +382,27 @@ describe('writing a launch prompt into a terminal agent', () => { // 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) }) + + it('writes nothing into a reused terminal whose agent is no longer in front', async () => { + const stub = runtimeStub({ foreground: 'shell' }) + 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_existing', + agent: 'claude', + freshLaunch: false, + text: 'do the thing' + }) + ).resolves.toBe(false) + }) }) function fakeClock() { 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 5af709eb0b1f..8cffda5cf012 100644 --- a/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.ts +++ b/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.ts @@ -107,9 +107,9 @@ export async function deliverTerminalAgentLaunchPrompt(args: { 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 + // Before the paste and again before Enter, for a reused pane too: a ready signal can come from a + // shell whose agent exited, so only a read that finds the agent in front lets the text through. + const guard = createLaunchedAgentWriteGuard(args.runtime, args.agent) try { const wait = await waitThroughBlockingPrompts( args.runtime, @@ -130,7 +130,7 @@ export async function deliverTerminalAgentLaunchPrompt(args: { 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 } : {}), + 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. @@ -151,6 +151,6 @@ export async function deliverTerminalAgentLaunchPrompt(args: { ) return false } finally { - guard?.dispose() + guard.dispose() } } diff --git a/src/main/runtime/runtime-worktree-agent-startup.ts b/src/main/runtime/runtime-worktree-agent-startup.ts index 139679ba7059..26fd683074fb 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.ts @@ -10,7 +10,10 @@ import { isTuiAgentEnabled, pickTuiAgent } from '../../shared/tui-agent-selectio 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 { nameLocalTypedLineShell } from './agent-launch-typed-line-shell' +import { + launchHostProvesAgentInFront, + nameLocalTypedLineShell +} from './agent-launch-typed-line-shell' import { detectInstalledAgentsWithShellPathHydration, detectRemoteAgents @@ -153,16 +156,18 @@ export function buildWorktreeStartupForAgent( const prompt = environment.prompt ?? '' let startupPlan: ReturnType if (environment.onPromptCarry) { - const offered = planStartupWithPromptCandidate( - planInputs, - prompt, - nameLocalTypedLineShell({ + const offered = planStartupWithPromptCandidate(planInputs, prompt, { + shellName: nameLocalTypedLineShell({ isRemote: repoIsRemote(repo), ...(settings.terminalDefaultShell ? { defaultShellSetting: settings.terminalDefaultShell } : {}) + }), + provesAgentInFront: launchHostProvesAgentInFront({ + isRemote: repoIsRemote(repo), + launchPlatform: environment.getLaunchPlatform() }) - ) + }) startupPlan = offered.plan if (startupPlan && prompt.trim()) { environment.onPromptCarry(offered.promptCarried) diff --git a/src/shared/startup-line-prompt-carry.test.ts b/src/shared/startup-line-prompt-carry.test.ts index 360fd2b484ce..4520b3635d11 100644 --- a/src/shared/startup-line-prompt-carry.test.ts +++ b/src/shared/startup-line-prompt-carry.test.ts @@ -12,16 +12,24 @@ import { AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY } from './agent-launch-run function offer( agent: TuiAgent, prompt: string, - extra: { cmdOverride?: string; shellName?: string | undefined } = {} + extra: { + cmdOverride?: string + shellName?: string | undefined + platform?: NodeJS.Platform + provesAgentInFront?: boolean + } = {} ) { return planStartupWithPromptCandidate( { agent, cmdOverrides: extra.cmdOverride ? { [agent]: extra.cmdOverride } : {}, - platform: 'darwin' + platform: extra.platform ?? 'darwin' }, prompt, - extra.shellName + { + ...(extra.shellName ? { shellName: extra.shellName } : {}), + provesAgentInFront: extra.provesAgentInFront ?? true + } ) } @@ -173,6 +181,24 @@ describe('a multi-line prompt typed into a shell the host names', () => { }) }) +// Why: a host that cannot prove the agent took the terminal could paste into the shell of one that +// exited, so there the line carries the prompt whatever its size, as it did before. +describe('on a host that cannot prove the launched agent is in front', () => { + it.each([ + ['a long single line', 'x'.repeat(TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES * 4)], + ['a multi-line prompt', linesOf(5, 40)] + ])('carries %s on the launch line', (_label, prompt) => { + const { plan, promptCarried } = offer('claude', prompt, { + platform: 'win32', + provesAgentInFront: false + }) + expect(promptCarried).toBe(true) + expect(plan?.launchCommand).toContain(prompt.split('\n')[0]) + // Control: the same prompt is pasted where the host can prove the agent. + expect(offer('claude', prompt, { platform: 'win32' }).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. diff --git a/src/shared/startup-line-prompt-carry.ts b/src/shared/startup-line-prompt-carry.ts index 65e70e2d11db..e808cb9c74d9 100644 --- a/src/shared/startup-line-prompt-carry.ts +++ b/src/shared/startup-line-prompt-carry.ts @@ -1,6 +1,7 @@ /** * 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. + * starts clean and the prompt is pasted once it is ready. A paste needs a host that can prove the + * agent holds its terminal (`launched-agent-foreground`); elsewhere the line carries it, as on main. * * 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 @@ -37,11 +38,18 @@ export function startupLineCarriesPrompt(args: { withPrompt: AgentStartupPlan | null /** The shell the line is typed into, when the host can name it before the spawn. */ shellName?: string + /** Whether the host can prove the launched agent holds its terminal before it pastes. */ + hostProvesAgentInFront: boolean }): boolean { const { withPrompt } = args if (!withPrompt || withPrompt.followupPrompt !== null) { return false } + // Where nothing can prove the agent took the terminal, a paste could land in the shell of an + // agent that exited, so the line carries the prompt at any size, as it always did. + if (!args.hostProvesAgentInFront) { + return true + } // 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') { @@ -93,7 +101,7 @@ type StartupPlanInputs = Omit< export function planStartupWithPromptCandidate( inputs: StartupPlanInputs, prompt: string, - shellName?: string + host: { shellName?: string; provesAgentInFront: boolean } ): { plan: AgentStartupPlan | null; promptCarried: boolean } { if (prompt.trim()) { const withPrompt = buildAgentStartupPlan({ ...inputs, prompt, allowEmptyPromptLaunch: true }) @@ -101,7 +109,8 @@ export function planStartupWithPromptCandidate( startupLineCarriesPrompt({ agent: inputs.agent, withPrompt, - ...(shellName ? { shellName } : {}) + ...(host.shellName ? { shellName: host.shellName } : {}), + hostProvesAgentInFront: host.provesAgentInFront }) ) { return { plan: withPrompt, promptCarried: true } From 2603d2cda182e3de57aa6d64b48dcf7a577b1c50 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 01:28:09 -0700 Subject: [PATCH 13/42] test(agent-launch): cover the Windows QA stub, a grok override that exits at once --- .../launched-agent-exit-delivery.test.ts | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/main/runtime/launched-agent-exit-delivery.test.ts b/src/main/runtime/launched-agent-exit-delivery.test.ts index 37c90e4354f5..09c92103ce19 100644 --- a/src/main/runtime/launched-agent-exit-delivery.test.ts +++ b/src/main/runtime/launched-agent-exit-delivery.test.ts @@ -31,6 +31,7 @@ function bashRunsLaunchLineThatExits(): string[] { } type HostAnswers = { + agent?: 'claude' | 'grok' host: { remote: boolean; windows: boolean } cached: string | null scanned: string | null @@ -49,6 +50,18 @@ const HOSTS: [string, HostAnswers, () => string[]][] = [ }, bashRunsLaunchLineThatExits ], + // The QA stub: a `grok` override that exits at once, which takes the composer-marker wait. + [ + 'Windows Git Bash, grok', + { + agent: 'grok', + host: { remote: false, windows: true }, + cached: 'node', + scanned: 'bash', + shellAlone: false + }, + bashRunsLaunchLineThatExits + ], [ 'Windows WSL', { host: { remote: false, windows: true }, cached: 'claude', scanned: 'wsl', shellAlone: false }, @@ -79,10 +92,14 @@ function launchedPane(answers: HostAnswers) { confirmShellForeground: async () => answers.shellAlone } const readForeground = (ptyId: string) => - readLaunchedAgentForeground(controller, answers.host, ptyId, 'claude') + readLaunchedAgentForeground(controller, answers.host, ptyId, answers.agent ?? 'claude') const writes: string[] = [] const runtime = { - waitForFreshWorkerComposer: async (_handle: string, agent: 'claude', timeoutMs: number) => { + waitForFreshWorkerComposer: async ( + _handle: string, + agent: 'claude' | 'grok', + timeoutMs: number + ) => { const ptyId = await waitForWorktreeStartupDraft( { getPtyId: () => PTY_ID, @@ -151,7 +168,7 @@ describe('a launch prompt after the launched agent exits at startup', () => { typeof deliverTerminalAgentLaunchPrompt >[0]['runtime'], handle: 'term-1', - agent: 'claude', + agent: answers.agent ?? 'claude', freshLaunch: true, text: 'QA prompt that must never run as a shell command' }) From d3fdcac605f3d26f780bb0ad489ca37739873d67 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 01:30:25 -0700 Subject: [PATCH 14/42] fix(agent-launch): make where a launch prompt rides explicit, and delivery mean the agent got it Round-1 review of the rebuilt step 3. - One outcome, switched on by every caller. planLaunchPrompt answers none, on-line, launch-file or paste-after-ready; only the carried outcomes hold the plan to launch, so no caller can read "a plan exists" as "carried". buildAgentStartupPlan now builds only a prompt-less launch. The new agent tab and work items had dropped a prompt an agent could not take in a file (any agent but Claude and Codex) and reported it delivered; they now paste it. The composer paths, background sessions, agent.launch, agentSession.create and phone quick commands switch on the same outcome. - One rule for hosts that type the line raw. A Windows host or a paired Orca keeps a line to #24257's 512-byte typed budget; anything longer, multi-line or damaged by the shell rides a launch file for Claude and Codex and is pasted for others. A paired host is one predicate (launchHostIsPaired). - Delivered means the agent got it. A prompt the new tab's line carried resolves its delivery when the agent's own hook reports a turn or the agent shows its composer, and false when the PTY never spawns (a refused launch file) or exits first, so callers that resolve review threads or mark notes delivered wait for that, not for the tab. - Daemon protocol v41 writes launch files. A spawn routed to an older daemon is refused with the launch-file reason instead of typing a pointer to a file nobody wrote. - A refused launch file or WSL staged line reaches agent.launch as its reason instead of a 10 s handle timeout, and hands the prompt back with Copy prompt for a staged line too. - A WSL distro is probed only when the spawn needs its directory, and a failed probe is remembered for 30 s. - Every hook status row shows the prompt a launch file carries instead of its pointer sentence. --- .../first-work-branch-rename.test.ts | 2 +- .../agent-hooks/first-work-branch-rename.ts | 12 +- .../agent-hooks/first-work-rename-runtime.ts | 4 +- .../agent-hooks/launch-file-prompt-by-pane.ts | 14 +- .../server-launch-file-prompt.test.ts | 39 ++++ .../server/server-status-update.ts | 7 + .../host-agent-startup-call-sites.txt | 2 +- .../host-agent-startup-attribution.test.ts | 4 +- .../daemon/daemon-create-or-attach-request.ts | 2 +- ...n-foreground-confirmation-protocol.test.ts | 3 +- .../daemon-launch-file-protocol.test.ts | 78 ++++++++ .../daemon/daemon-protocol-version.test.ts | 4 +- src/main/daemon/daemon-protocol-version.ts | 8 +- src/main/daemon/daemon-pty-session-spawn.ts | 6 +- src/main/daemon/daemon-pty-spawn-request.ts | 15 +- .../daemon-pty-startup-delivery.test.ts | 4 +- ...terminal-host-wsl-launch-directory.test.ts | 4 +- .../ipc/pty/ipc/spawn-launch-refusal.test.ts | 39 ++++ .../pty/ipc/spawn-options-launch-file.test.ts | 13 +- src/main/ipc/pty/ipc/spawn.ts | 13 +- ...local-pty-provider-shell-readiness.test.ts | 4 +- ...local-pty-spawn-wsl-shutdown-order.test.ts | 12 +- src/main/providers/local-pty-spawn.ts | 5 +- .../ssh-plain-shell-pty-provider.test.ts | 6 + .../providers/ssh-plain-shell-pty-provider.ts | 8 +- .../wsl-launch-directory-resolution.test.ts | 46 ++++- .../wsl-launch-directory-resolution.ts | 43 ++++- .../agent-terminal-startup-prompt.test.ts | 6 +- .../orca-runtime-create-agent-session.ts | 69 ++++--- .../orca-runtime-create-terminal-desktop.ts | 6 - ...resolve-mobile-session-terminal-command.ts | 28 ++- ...runtime-resolve-worktree-removal-target.ts | 64 ++++--- ...renderer-session-owned-mobile-terminals.ts | 30 ++- .../mobile-session-tabs-part-12.spec.ts | 2 +- .../runtime/runtime-terminal-contracts.ts | 8 +- .../runtime/runtime-worktree-agent-startup.ts | 48 +++-- .../runtime/terminal-launch-refusals.test.ts | 46 +++++ src/main/runtime/terminal-launch-refusals.ts | 37 ++++ ...ty-handler-startup-command-staging.test.ts | 4 +- .../buildSourceControlAgentDeliveryPlan.ts | 7 +- .../useSourceControlAgentActionStart.ts | 11 +- .../sidebar/folder-workspace-agent-startup.ts | 1 - .../folder-workspace-composer-submit.test.ts | 3 +- .../folder-workspace-composer-submit.ts | 41 ++-- .../terminal-pane/ipc-pty-connect.ts | 10 +- .../terminal-pane/pty-connection-types.ts | 2 + .../pane-startup-command-options.ts | 18 ++ .../pty-connection/pty-input-recovery.ts | 5 +- .../pty-transport-launch-refusal.test.ts | 49 +++++ .../terminal-pane/pty-transport-types.ts | 2 + .../full-creation-startup.test.ts | 23 ++- .../composer-state/full-creation-startup.ts | 5 +- .../composer-state/full-submit-preparation.ts | 58 +++--- .../quick-creation-execution.ts | 4 +- .../composer-state/quick-startup-plan.ts | 45 +++-- .../agent-launch-caller-permissions.test.ts | 3 + .../lib/agent-launch-caller-placement.test.ts | 3 + ...ent-launch-caller-prompt-transport.test.ts | 48 ++++- .../lib/agent-launch-prompt-receipt.test.ts | 117 ++++++++++++ .../src/lib/agent-launch-prompt-receipt.ts | 98 ++++++++++ src/renderer/src/lib/agent-paste-draft.ts | 4 +- .../src/lib/agent-startup-delayed-delivery.ts | 6 +- .../src/lib/composer-agent-startup-plan.ts | 31 ++++ .../lib/launch-agent-background-session.ts | 36 +++- .../src/lib/launch-agent-in-new-tab.test.ts | 39 +++- .../src/lib/launch-agent-in-new-tab.ts | 71 +++---- .../launch-agent-new-tab-prompt-delivery.ts | 83 +++++++++ .../lib/launch-agent-startup-prompt-plan.ts | 88 +++++---- src/renderer/src/lib/launch-file-host.test.ts | 16 +- src/renderer/src/lib/launch-file-host.ts | 16 +- .../launch-work-item-direct-agent-routing.ts | 4 +- .../lib/launch-work-item-direct-agent.test.ts | 41 +++- .../src/lib/launch-work-item-direct-agent.ts | 86 +++++---- ...unch-work-item-direct-route-preparation.ts | 8 +- .../src/lib/launch-work-item-direct.test.ts | 30 ++- .../src/lib/launch-work-item-direct.ts | 2 +- src/renderer/src/lib/new-workspace.test.ts | 23 +-- src/renderer/src/lib/new-workspace.ts | 16 +- .../src/lib/pending-worktree-creation.ts | 4 +- .../lib/source-control-agent-action-plan.ts | 30 ++- .../src/lib/tui-agent-startup.test.ts | 88 +++++---- src/renderer/src/lib/tui-agent-startup.ts | 5 +- ...worktree-creation-background-mount.test.ts | 1 - ...worktree-creation-flow-ready-toast.test.ts | 1 - .../src/lib/worktree-creation-flow-startup.ts | 1 + ...ree-creation-flow-stranded-surface.test.ts | 2 +- .../src/lib/worktree-creation-flow.test.ts | 6 - .../src/lib/worktree-startup-payload.ts | 2 + .../src/store/terminals/terminal-actions.ts | 2 + .../src/store/terminals/terminal-state.ts | 1 + .../cmd-startup-arg-quoting.win32.test.ts | 6 +- src/shared/freebuff-launch.test.ts | 11 +- src/shared/freebuff-startup.test.ts | 3 +- .../launch-file-directory-grant.test.ts | 6 +- src/shared/launch-file-writing.test.ts | 48 ++--- src/shared/launch-file-writing.ts | 33 +++- src/shared/launch-prompt-carrier.test.ts | 36 ++-- src/shared/launch-prompt-carry.test.ts | 130 +++++++++++++ src/shared/launch-prompt-carry.ts | 167 +++++++++++++++++ src/shared/launch-prompt-file.ts | 28 ++- src/shared/launch-prompt-plan.test-fixture.ts | 44 +++++ .../local-build-compatibility-contract.json | 4 +- .../local-build-compatibility-contract.ts | 4 +- src/shared/omp-fresh-launch-shell.test.ts | 4 +- src/shared/omp-fresh-launch.test.ts | 6 +- src/shared/qoder-agent.test.ts | 6 +- .../startup-command-staging.node-pty.test.ts | 4 +- src/shared/startup-command-staging.ts | 7 +- src/shared/startup-line-prompt-carry.test.ts | 175 ------------------ src/shared/startup-line-prompt-carry.ts | 32 ---- src/shared/startup-plan-launch-file.ts | 126 ------------- src/shared/tui-agent-prompt-transport.test.ts | 15 +- src/shared/tui-agent-resume-startup.ts | 1 - src/shared/tui-agent-startup-hermes.test.ts | 40 ++-- src/shared/tui-agent-startup.test.ts | 95 +++++----- src/shared/tui-agent-startup.ts | 90 ++++----- src/shared/typed-startup-line.ts | 8 - .../windows-shell-prompt-damage.test.ts | 13 +- src/shared/wsl-launch-directory.test.ts | 4 +- .../zcode-orchestration-contract.test.ts | 9 +- 120 files changed, 2095 insertions(+), 1101 deletions(-) create mode 100644 src/main/agent-hooks/server-launch-file-prompt.test.ts create mode 100644 src/main/daemon/daemon-launch-file-protocol.test.ts create mode 100644 src/main/ipc/pty/ipc/spawn-launch-refusal.test.ts create mode 100644 src/main/runtime/terminal-launch-refusals.test.ts create mode 100644 src/main/runtime/terminal-launch-refusals.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/pane-startup-command-options.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-transport-launch-refusal.test.ts create mode 100644 src/renderer/src/lib/agent-launch-prompt-receipt.test.ts create mode 100644 src/renderer/src/lib/agent-launch-prompt-receipt.ts create mode 100644 src/renderer/src/lib/composer-agent-startup-plan.ts create mode 100644 src/renderer/src/lib/launch-agent-new-tab-prompt-delivery.ts create mode 100644 src/shared/launch-prompt-carry.test.ts create mode 100644 src/shared/launch-prompt-carry.ts create mode 100644 src/shared/launch-prompt-plan.test-fixture.ts delete mode 100644 src/shared/startup-line-prompt-carry.test.ts delete mode 100644 src/shared/startup-line-prompt-carry.ts delete mode 100644 src/shared/startup-plan-launch-file.ts diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts index d1fad66280fb..b7aef25e138b 100644 --- a/src/main/agent-hooks/first-work-branch-rename.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -272,7 +272,7 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { }) it('names the branch from the prompt a launch file carried, when main kept it', async () => { - const { deps, onRenamed } = makeDeps({ getLaunchFilePrompt: () => 'Fix auth from note #1' }) + const { deps, onRenamed } = makeDeps({ showLaunchPrompt: () => 'Fix auth from note #1' }) await maybeAutoRenameBranchOnFirstWork( workingEvent({ prompt: buildLaunchFilePointer('/tmp/orca-launch-file-a1/task-context.md') }), deps diff --git a/src/main/agent-hooks/first-work-branch-rename.ts b/src/main/agent-hooks/first-work-branch-rename.ts index e5abfe9cc664..3576334b368a 100644 --- a/src/main/agent-hooks/first-work-branch-rename.ts +++ b/src/main/agent-hooks/first-work-branch-rename.ts @@ -68,8 +68,8 @@ export type FirstWorkBranchRenameDeps = { resolveWorktreeIdForTab: (tabId: string) => string | undefined /** Invalidate caches + notify the renderer so the new branch name surfaces. */ onRenamed: (repoId: string) => void - /** The prompt a launch file carried for this pane, when its hook reports only the pointer. */ - getLaunchFilePrompt?: (paneKey: string) => string | undefined + /** The prompt Orca shows for a pane's reported one (`launchPromptShownForPane`). */ + showLaunchPrompt?: (paneKey: string, prompt: string) => string } // inFlight blocks concurrent generation; settled caches definitive verdicts (transient bails stay unsettled to retry later). @@ -121,10 +121,10 @@ export async function maybeAutoRenameBranchOnFirstWork( // Why the pointer: a launch that carried its prompt in a file names only that file here, which // would name the branch after "read the task file". Its real prompt, when main kept it, names it // instead; otherwise (a worker brief) a later prompt of the user's own can. - const prompt = - hookPrompt && isLaunchFilePointer(hookPrompt) - ? deps.getLaunchFilePrompt?.(event.paneKey)?.trim() - : hookPrompt + const shown = hookPrompt + ? (deps.showLaunchPrompt?.(event.paneKey, hookPrompt) ?? hookPrompt).trim() + : undefined + const prompt = shown && !isLaunchFilePointer(shown) ? shown : undefined if (!prompt) { return } diff --git a/src/main/agent-hooks/first-work-rename-runtime.ts b/src/main/agent-hooks/first-work-rename-runtime.ts index 8c35b802bf67..82229db59a28 100644 --- a/src/main/agent-hooks/first-work-rename-runtime.ts +++ b/src/main/agent-hooks/first-work-rename-runtime.ts @@ -1,4 +1,4 @@ -import { launchFilePromptForPane } from './launch-file-prompt-by-pane' +import { launchPromptShownForPane } from './launch-file-prompt-by-pane' import { existsSync } from 'node:fs' import { parseWorkspaceKey } from '../../shared/workspace-scope' import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' @@ -22,7 +22,7 @@ export function firstWorkRenameDeps( > ): FirstWorkBranchRenameDeps { return { - getLaunchFilePrompt: launchFilePromptForPane, + showLaunchPrompt: launchPromptShownForPane, getSettings: () => store.getSettings(), getRepo: (repoId) => store.getRepo(repoId), getAgentEnvResolvers: () => runtime.getCommitMessageAgentEnvironmentResolvers(), diff --git a/src/main/agent-hooks/launch-file-prompt-by-pane.ts b/src/main/agent-hooks/launch-file-prompt-by-pane.ts index 5229bc2d29c4..8f8815255802 100644 --- a/src/main/agent-hooks/launch-file-prompt-by-pane.ts +++ b/src/main/agent-hooks/launch-file-prompt-by-pane.ts @@ -1,4 +1,4 @@ -import type { LaunchFile } from '../../shared/launch-prompt-file' +import { isLaunchFilePointer, type LaunchFile } from '../../shared/launch-prompt-file' // Why bounded: an entry outlives its launch until newer ones push it out; the rename reads only a // prompt's opening, and a pane's rename runs within minutes of its launch. @@ -7,9 +7,9 @@ const KEPT_CHARS = 4_000 const promptByPane = new Map() /** - * Keeps a launch file's prompt for the pane it launched, for the first-work rename: the agent's hook - * reports only the pointer to the file. A sensitive file (a worker brief with its capability) is - * never kept. + * Keeps a launch file's prompt for the pane it launched: the agent's hook reports only the pointer + * to the file, and Orca shows the prompt instead (`launchPromptShownForPane`). A sensitive file (a + * worker brief with its capability) is never kept. */ export function rememberLaunchFilePrompt( paneKey: string, @@ -28,6 +28,8 @@ export function rememberLaunchFilePrompt( } } -export function launchFilePromptForPane(paneKey: string): string | undefined { - return promptByPane.get(paneKey) +/** The one place Orca reads a pane's reported prompt: a launch file's pointer reads as the prompt + * it points at, when this host kept it. */ +export function launchPromptShownForPane(paneKey: string, prompt: string): string { + return isLaunchFilePointer(prompt) ? (promptByPane.get(paneKey) ?? prompt) : prompt } diff --git a/src/main/agent-hooks/server-launch-file-prompt.test.ts b/src/main/agent-hooks/server-launch-file-prompt.test.ts new file mode 100644 index 000000000000..31fcfea9f1a9 --- /dev/null +++ b/src/main/agent-hooks/server-launch-file-prompt.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { makePaneKey } from '../../shared/stable-pane-id' +import { buildLaunchFilePointer, carryInLaunchFile } from '../../shared/launch-prompt-file' +import { AgentHookServer } from './server' +import { rememberLaunchFilePrompt } from './launch-file-prompt-by-pane' + +const LAUNCHED = makePaneKey('tab-launched', '55555555-5555-4555-8555-555555555555') +const POINTER = buildLaunchFilePointer('/tmp/orca-launch-file-a1/task-context.md') + +describe('a status row whose agent reports its launch file’s pointer', () => { + // Why: the agent's own hook sees only the pointer sentence; every Orca reader of the row (status + // row, dashboard, phone) must see the prompt the user wrote. + it('shows the prompt the file carries', () => { + rememberLaunchFilePrompt(LAUNCHED, carryInLaunchFile('fix the flaky test', false).launchFile) + const server = new AgentHookServer() + server.ingestTerminalStatus({ + paneKey: LAUNCHED, + tabId: 'tab-launched', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: POINTER } + }) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ paneKey: LAUNCHED, prompt: 'fix the flaky test' }) + ]) + }) + + it('leaves a sensitive file’s pointer, whose content is never kept', () => { + const pane = makePaneKey('tab-sensitive', '66666666-6666-4666-8666-666666666666') + rememberLaunchFilePrompt(pane, carryInLaunchFile('token dcap_secret', true).launchFile) + const server = new AgentHookServer() + server.ingestTerminalStatus({ + paneKey: pane, + tabId: 'tab-sensitive', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: POINTER } + }) + expect(server.getStatusSnapshot()).toEqual([expect.objectContaining({ prompt: POINTER })]) + }) +}) diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index ebb87c3c9c21..f9e2e6c75575 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -19,6 +19,7 @@ import { import { isStaleGrokTurnEnd } from './server-grok-status-rules' import { resolveCancelVerdictLatch } from './server-cancel-verdict-latch' import { AgentHookServerStatusApplication } from './server-status-application' +import { launchPromptShownForPane } from '../launch-file-prompt-by-pane' export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusApplication { protected applyNormalizedStatus( @@ -28,6 +29,12 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA observedAt?: number, mutationBefore?: EnrichedAgentHookEventPayload ): EnrichedAgentHookEventPayload | undefined { + // Why here: every status row (local, relayed, replayed) passes through, so no reader of the + // prompt (status row, dashboard, mobile) shows a launch file's pointer instead of the prompt. + const prompt = launchPromptShownForPane(incoming.paneKey, incoming.payload.prompt) + if (prompt !== incoming.payload.prompt) { + incoming = { ...incoming, payload: { ...incoming.payload, prompt } } + } const transitioned = transitionHookPresence( incoming, this.state.lastStatusByPaneKey.get(incoming.paneKey) 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 c05556a22055..de078fd731e9 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 @@ -4,7 +4,7 @@ # mobile-followup the phone's session-tab launch; attribution is a separate follow-up # 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 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/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/host-agent-startup-attribution.test.ts b/src/main/agent-launch/host-agent-startup-attribution.test.ts index d9d15c5e61de..60851c0dd1eb 100644 --- a/src/main/agent-launch/host-agent-startup-attribution.test.ts +++ b/src/main/agent-launch/host-agent-startup-attribution.test.ts @@ -22,9 +22,9 @@ const LISTED: ReadonlyMap = new Map }) ) -// planStartupWithLaunchPrompt wraps buildAgentStartupPlan in shared/, which this scan does not reach. +// planLaunchPrompt plans every prompted launch; buildAgentStartupPlan only a prompt-less one. const BUILDER_CALL = - /\b(?:buildAgentStartupPlan|buildAgentDraftLaunchPlan|buildAgentResumeStartupPlan|planStartupWithLaunchPrompt)\s*\(/g + /\b(?:buildAgentStartupPlan|buildAgentDraftLaunchPlan|buildAgentResumeStartupPlan|planLaunchPrompt)\s*\(/g const DECIDE = 'Decide attribution: a fresh agent the host builds must carry ' + diff --git a/src/main/daemon/daemon-create-or-attach-request.ts b/src/main/daemon/daemon-create-or-attach-request.ts index 653281f9c769..c74f2099152b 100644 --- a/src/main/daemon/daemon-create-or-attach-request.ts +++ b/src/main/daemon/daemon-create-or-attach-request.ts @@ -21,7 +21,7 @@ export type CreateOrAttachRequest = { envToDelete?: string[] command?: string startupCommandDelivery?: StartupCommandDelivery - /** v39+: written by the daemon before it types `command`. */ + /** v41+ (`LAUNCH_FILE_DAEMON_PROTOCOL_VERSION`): written by the daemon before it types `command`. */ launchFile?: LaunchFile /** Where the daemon writes a WSL session's staged line and launch file; main resolves it. */ wslLaunchDirectory?: WslLaunchDirectory diff --git a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts index 3d87fa463af0..b1e76a88b323 100644 --- a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts +++ b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts @@ -3,7 +3,7 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types' describe('foreground-confirmation daemon protocol', () => { it('rejects daemons from before the fresh-confirmation RPC', () => { - expect(PROTOCOL_VERSION).toBe(40) + expect(PROTOCOL_VERSION).toBe(41) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23) @@ -23,5 +23,6 @@ describe('foreground-confirmation daemon protocol', () => { expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(37) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(38) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(39) + expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(40) }) }) diff --git a/src/main/daemon/daemon-launch-file-protocol.test.ts b/src/main/daemon/daemon-launch-file-protocol.test.ts new file mode 100644 index 000000000000..a15373cbe9ee --- /dev/null +++ b/src/main/daemon/daemon-launch-file-protocol.test.ts @@ -0,0 +1,78 @@ +import './mock-descendant-sweep' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { rmSync } from 'node:fs' +import { DaemonClient } from './client' +import { DaemonPtyAdapter } from './daemon-pty-adapter' +import { LAUNCH_FILE_DAEMON_PROTOCOL_VERSION, PROTOCOL_VERSION } from './daemon-protocol-version' +import type { DaemonServer } from './daemon-server' +import { createMockSubprocess, startDaemonAdapterHarness } from './daemon-pty-adapter-test-harness' +import { carryInLaunchFile } from '../../shared/launch-prompt-file' + +describe('a launch file sent to a terminal daemon', () => { + let dir: string + let socketPath: string + let tokenPath: string + let server: DaemonServer + let adapter: DaemonPtyAdapter + + beforeEach(async () => { + const harness = await startDaemonAdapterHarness(() => createMockSubprocess()) + ;({ dir, socketPath, tokenPath, server, adapter } = harness) + }) + + afterEach(async () => { + vi.restoreAllMocks() + adapter?.dispose() + await server?.shutdown() + rmSync(dir, { recursive: true, force: true }) + }) + + it('is a protocol the current daemon speaks', () => { + expect(PROTOCOL_VERSION).toBeGreaterThanOrEqual(LAUNCH_FILE_DAEMON_PROTOCOL_VERSION) + }) + + // Why: an older daemon drops `launchFile` and would type a line naming a file nobody wrote. + it('is refused, with the reason, by a daemon older than launch files', async () => { + vi.spyOn(DaemonClient.prototype, 'ensureConnected').mockResolvedValue() + const request = vi.spyOn(DaemonClient.prototype, 'request') + const legacy = new DaemonPtyAdapter({ + socketPath, + tokenPath, + protocolVersion: LAUNCH_FILE_DAEMON_PROTOCOL_VERSION - 1 + }) + const { prompt, launchFile } = carryInLaunchFile('the whole task', false) + try { + await expect( + legacy.spawn({ + sessionId: 'legacy-launch', + cols: 80, + rows: 24, + command: `claude '${prompt}'`, + launchAgent: 'claude', + launchFile + }) + ).rejects.toThrow(/launch_file_unavailable/) + expect(request).not.toHaveBeenCalledWith( + 'createOrAttach', + expect.anything(), + expect.anything() + ) + } finally { + legacy.dispose() + } + }) + + it('is sent to the current daemon, which writes it', async () => { + const request = vi.spyOn(DaemonClient.prototype, 'request') + const { prompt, launchFile } = carryInLaunchFile('the whole task', false) + await adapter.spawn({ + cols: 80, + rows: 24, + command: `claude '${prompt}'`, + launchAgent: 'claude', + launchFile + }) + const createCall = request.mock.calls.find(([method]) => method === 'createOrAttach') + expect(createCall?.[1]).toMatchObject({ launchFile: { placeholder: launchFile.placeholder } }) + }) +}) diff --git a/src/main/daemon/daemon-protocol-version.test.ts b/src/main/daemon/daemon-protocol-version.test.ts index 1b77b8c20b5e..aba47aeeea0f 100644 --- a/src/main/daemon/daemon-protocol-version.test.ts +++ b/src/main/daemon/daemon-protocol-version.test.ts @@ -22,7 +22,7 @@ import { describe('daemon protocol version', () => { it('ships bounded history transfer after the 2031-unsubscribe fact', () => { - expect(PROTOCOL_VERSION).toBe(40) + expect(PROTOCOL_VERSION).toBe(41) expect(COLOR_QUERY_REPLY_COLORS_DAEMON_PROTOCOL_VERSION).toBe(38) expect(CODEX_NO_DAEMON_SHELL_LAUNCH_DAEMON_PROTOCOL_VERSION).toBe(37) expect(CONTENT_ADDRESSED_SHELL_WRAPPER_DAEMON_PROTOCOL_VERSION).toBe(36) @@ -38,7 +38,7 @@ describe('daemon protocol version', () => { expect(AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION).toBe(26) expect(AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION).toBe(26) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toEqual( - Array.from({ length: 39 }, (_, index) => index + 1) + Array.from({ length: 40 }, (_, index) => index + 1) ) }) diff --git a/src/main/daemon/daemon-protocol-version.ts b/src/main/daemon/daemon-protocol-version.ts index 6dfec710e8b7..c5875e22821d 100644 --- a/src/main/daemon/daemon-protocol-version.ts +++ b/src/main/daemon/daemon-protocol-version.ts @@ -1,6 +1,8 @@ // Why: daemons survive app updates, so wire behavior must be version-gated. -// v40 stages long startup commands as sourced scripts; older owners stay attachable. -export const PROTOCOL_VERSION = 40 +// v41 writes an agent's launch file before it types the line naming it; older owners stay attachable. +export const PROTOCOL_VERSION = 41 +// Why: an older daemon drops `launchFile` and would type a line naming a file nobody wrote. +export const LAUNCH_FILE_DAEMON_PROTOCOL_VERSION = 41 // Why: older daemons reject `setColorQueryReplyColors` as an unknown request type. export const COLOR_QUERY_REPLY_COLORS_DAEMON_PROTOCOL_VERSION = 38 export const CODEX_NO_DAEMON_SHELL_LAUNCH_DAEMON_PROTOCOL_VERSION = 37 @@ -33,7 +35,7 @@ export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24 export const MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION = 29 export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, - 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39 + 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 ] as const export function supportsColorQueryReplyColors(protocolVersion: number): boolean { diff --git a/src/main/daemon/daemon-pty-session-spawn.ts b/src/main/daemon/daemon-pty-session-spawn.ts index bade2bcc3e20..b40907a9b728 100644 --- a/src/main/daemon/daemon-pty-session-spawn.ts +++ b/src/main/daemon/daemon-pty-session-spawn.ts @@ -18,7 +18,7 @@ import { getRecoveredHistorySeedSegments } from './terminal-history-seed-segment import { AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, type CreateOrAttachResult } from './types' import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd' import { resolveWslSessionContext } from './wsl-session-context' -import { resolveWslLaunchDirectory } from '../providers/wsl-launch-directory-resolution' +import { resolveSpawnWslLaunchDirectory } from '../providers/wsl-launch-directory-resolution' import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd' import { resolveUnixShellPath } from '../providers/local-pty-utils' import type { PtySpawnOptions, PtySpawnResult } from '../providers/types' @@ -194,8 +194,8 @@ export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult { } await this.ensureConnected() - const wslLaunchDirectory = - !attachOnly && opts.command ? await resolveWslLaunchDirectory(wslDistro) : undefined + const wslProbe = resolveSpawnWslLaunchDirectory(wslDistro, opts) + const wslLaunchDirectory = wslProbe ? await wslProbe : undefined // Pins the daemon's spawn to the distro the daemon writes the line and file into. if (wslLaunchDirectory) { opts = { ...opts, wslLaunchDirectory, terminalWindowsWslDistro: wslLaunchDirectory.distro } diff --git a/src/main/daemon/daemon-pty-spawn-request.ts b/src/main/daemon/daemon-pty-spawn-request.ts index 22222c8b206b..f453741535ff 100644 --- a/src/main/daemon/daemon-pty-spawn-request.ts +++ b/src/main/daemon/daemon-pty-spawn-request.ts @@ -7,7 +7,11 @@ import { type SnapshotCheckpointResult } from './daemon-pty-runtime-state' import { isDaemonGoneError } from './daemon-endpoint-errors' -import { HISTORY_SEED_TRANSFER_PROTOCOL_VERSION } from './daemon-protocol-version' +import { + HISTORY_SEED_TRANSFER_PROTOCOL_VERSION, + LAUNCH_FILE_DAEMON_PROTOCOL_VERSION +} from './daemon-protocol-version' +import { LaunchFileUnavailableError } from '../../shared/launch-file-writing' import type { ColdRestoreInfo } from './history-reader' import { NdjsonLineTooLongError } from './ndjson' import { @@ -106,6 +110,15 @@ export abstract class DaemonPtySpawnRequest extends DaemonPtyRuntimeState { if (opts.signal?.aborted) { throw new Error('client_disconnected') } + // Why refuse: an older daemon (a same-id respawn routed to it) would drop the file and type + // a line naming it. The refusal reaches the user with the prompt to copy. + if ( + !context.attachOnly && + opts.launchFile && + this.protocolVersion < LAUNCH_FILE_DAEMON_PROTOCOL_VERSION + ) { + throw new LaunchFileUnavailableError("this terminal's daemon is from an older Orca") + } const payload = { sessionId: context.sessionId, cols: context.effectiveCols, diff --git a/src/main/daemon/daemon-pty-startup-delivery.test.ts b/src/main/daemon/daemon-pty-startup-delivery.test.ts index b5ae04c5d1e4..eb0b8e366544 100644 --- a/src/main/daemon/daemon-pty-startup-delivery.test.ts +++ b/src/main/daemon/daemon-pty-startup-delivery.test.ts @@ -9,7 +9,7 @@ import { type DaemonAdapterHarness, type SpawnSubprocess } from './daemon-pty-adapter-test-harness' -import { buildLaunchFilePointer, planLaunchPrompt } from '../../shared/launch-prompt-file' +import { buildLaunchFilePointer, carryInLaunchFile } from '../../shared/launch-prompt-file' const itOnPosix = process.platform === 'win32' ? it.skip : it @@ -141,7 +141,7 @@ describe('DaemonPtyAdapter startup delivery', () => { vi.stubEnv('TMPDIR', stagingDir) nextShellPath = '/bin/zsh' try { - const { prompt, launchFile } = planLaunchPrompt('secret brief', { sensitive: true }) + const { prompt, launchFile } = carryInLaunchFile('secret brief', true) await adapter.spawn({ cols: 80, rows: 24, diff --git a/src/main/daemon/terminal-host-wsl-launch-directory.test.ts b/src/main/daemon/terminal-host-wsl-launch-directory.test.ts index 2b395205216d..6e6354f385bc 100644 --- a/src/main/daemon/terminal-host-wsl-launch-directory.test.ts +++ b/src/main/daemon/terminal-host-wsl-launch-directory.test.ts @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vite import { createMockSubprocess } from './daemon-pty-adapter-test-harness' import { TerminalHost } from './terminal-host' import type { TerminalHostOptions } from './terminal-host-options' -import { buildLaunchFilePointer, planLaunchPrompt } from '../../shared/launch-prompt-file' +import { buildLaunchFilePointer, carryInLaunchFile } from '../../shared/launch-prompt-file' import type { WslLaunchDirectory } from '../../shared/wsl-launch-directory' vi.mock('../pty-descendant-termination', () => ({ killWithDescendantSweep: vi.fn() })) @@ -33,7 +33,7 @@ describe('a daemon WSL session with a launch file', () => { }) function create(wslLaunchDirectory: WslLaunchDirectory | undefined) { - const { prompt, launchFile } = planLaunchPrompt('secret brief', { sensitive: true }) + const { prompt, launchFile } = carryInLaunchFile('secret brief', true) return host.createOrAttach({ sessionId: 'wsl-session', cols: 80, diff --git a/src/main/ipc/pty/ipc/spawn-launch-refusal.test.ts b/src/main/ipc/pty/ipc/spawn-launch-refusal.test.ts new file mode 100644 index 000000000000..659986dd26e8 --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-launch-refusal.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from 'vitest' +import { describeLaunchFileUnavailable } from '../../../../shared/launch-prompt-file' +import { takeTerminalLaunchRefusal } from '../../../runtime/terminal-launch-refusals' +import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' + +const mocks = vi.hoisted(() => ({ + handlers: new Map Promise>(), + runSpawn: vi.fn() +})) + +vi.mock('../../pty-host-bindings', () => ({ + getPtyIpc: () => ({ + handle: (channel: string, handler: (event: unknown, args: unknown) => Promise) => + mocks.handlers.set(channel, handler) + }) +})) +vi.mock('./spawn-run', () => ({ runPtyIpcSpawn: mocks.runSpawn })) + +import { installPtySpawnIpcHandler } from './spawn' + +describe('a pane spawn refused for what carries its prompt', () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only getLocalPtyStartupPromise before delegating to the mocked runner. + installPtySpawnIpcHandler({ getLocalPtyStartupPromise: () => null } as unknown as PtySpawnIpcDeps) + const spawn = (args: Partial): Promise => + mocks.handlers.get('pty:spawn')!({}, { cols: 80, rows: 24, ...args }) + + it('records the refusal for its tab, for a create waiting on that tab’s handle', async () => { + const refusal = describeLaunchFileUnavailable('disk full') + mocks.runSpawn.mockRejectedValueOnce(new Error(refusal)) + await expect(spawn({ tabId: 'tab-refused' })).rejects.toThrow(refusal) + expect(takeTerminalLaunchRefusal('tab-refused')).toBe(refusal) + }) + + it('records nothing for any other spawn failure', async () => { + mocks.runSpawn.mockRejectedValueOnce(new Error('spawn ENOENT')) + await expect(spawn({ tabId: 'tab-other' })).rejects.toThrow('spawn ENOENT') + expect(takeTerminalLaunchRefusal('tab-other')).toBeUndefined() + }) +}) diff --git a/src/main/ipc/pty/ipc/spawn-options-launch-file.test.ts b/src/main/ipc/pty/ipc/spawn-options-launch-file.test.ts index 77e41293405e..4656ee4f7651 100644 --- a/src/main/ipc/pty/ipc/spawn-options-launch-file.test.ts +++ b/src/main/ipc/pty/ipc/spawn-options-launch-file.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../../../shared/constants' import { buildPtyIpcSpawnOptions } from './spawn-options' -import { launchFilePromptForPane } from '../../../agent-hooks/launch-file-prompt-by-pane' +import { launchPromptShownForPane } from '../../../agent-hooks/launch-file-prompt-by-pane' +import { buildLaunchFilePointer } from '../../../../shared/launch-prompt-file' import { createPtyIpcSpawnState } from './spawn-state' import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' @@ -59,8 +60,8 @@ describe('renderer pty spawn: launch file', () => { expect(options.launchFile).toBeUndefined() }) - // Why: the agent's hook reports only the pointer, so the first-work rename reads this instead. - it('keeps the prompt a launch file carries for its pane, never a sensitive one', async () => { + // Why: the agent's hook reports only the pointer; Orca shows the prompt it points at instead. + it('shows the prompt a launch file carries for its pane, never a sensitive one', async () => { await spawnOptionsFor( { cols: 80, rows: 24, command, launchFile: LAUNCH_FILE }, command, @@ -76,7 +77,9 @@ describe('renderer pty spawn: launch file', () => { command, 'tab-b:leaf-b' ) - expect(launchFilePromptForPane('tab-a:leaf-a')).toBe('the whole task') - expect(launchFilePromptForPane('tab-b:leaf-b')).toBeUndefined() + const pointer = buildLaunchFilePointer('/tmp/orca-launch-file-a1/task-context.md') + expect(launchPromptShownForPane('tab-a:leaf-a', pointer)).toBe('the whole task') + expect(launchPromptShownForPane('tab-b:leaf-b', pointer)).toBe(pointer) + expect(launchPromptShownForPane('tab-a:leaf-a', 'a later prompt')).toBe('a later prompt') }) }) diff --git a/src/main/ipc/pty/ipc/spawn.ts b/src/main/ipc/pty/ipc/spawn.ts index 6fd5183a36d8..e35b40ee637e 100644 --- a/src/main/ipc/pty/ipc/spawn.ts +++ b/src/main/ipc/pty/ipc/spawn.ts @@ -1,6 +1,8 @@ import { getPtyIpc } from '../../pty-host-bindings' import { runPtyIpcSpawn } from './spawn-run' import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' +import { isLaunchFileUnavailableMessage } from '../../../../shared/launch-prompt-file' +import { recordTerminalLaunchRefusal } from '../../../runtime/terminal-launch-refusals' export function installPtySpawnIpcHandler(deps: PtySpawnIpcDeps): void { const ipcMain = getPtyIpc() @@ -11,6 +13,15 @@ export function installPtySpawnIpcHandler(deps: PtySpawnIpcDeps): void { if (startupPromise) { await startupPromise } - return runPtyIpcSpawn(deps, args) + try { + return await runPtyIpcSpawn(deps, args) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + // Why: a runtime create waits on this tab's handle; the refusal is the answer it waits for. + if (args.tabId && isLaunchFileUnavailableMessage(message)) { + recordTerminalLaunchRefusal(args.tabId, message) + } + throw error + } }) } diff --git a/src/main/providers/local-pty-provider-shell-readiness.test.ts b/src/main/providers/local-pty-provider-shell-readiness.test.ts index 6b1c0b64614f..141dde0ff945 100644 --- a/src/main/providers/local-pty-provider-shell-readiness.test.ts +++ b/src/main/providers/local-pty-provider-shell-readiness.test.ts @@ -118,7 +118,7 @@ vi.mock('../shell-prompt-readiness-probe', () => ({ import { LocalPtyProvider } from './local-pty-provider' import { POSIX_SHELL_STARTUP_COMMAND_ENV } from '../pty/posix-shell-startup-command' -import { buildLaunchFilePointer, planLaunchPrompt } from '../../shared/launch-prompt-file' +import { buildLaunchFilePointer, carryInLaunchFile } from '../../shared/launch-prompt-file' import { applyLocalPtyProviderMockDefaults, createLocalPtyMockProcess, @@ -273,7 +273,7 @@ describe('LocalPtyProvider', () => { vi.useFakeTimers() try { process.env.SHELL = '/bin/sh' - const { prompt, launchFile } = planLaunchPrompt('secret brief', { sensitive: true }) + const { prompt, launchFile } = carryInLaunchFile('secret brief', true) await provider.spawn({ cols: 80, rows: 24, command: `claude '${prompt}'`, launchFile }) diff --git a/src/main/providers/local-pty-spawn-wsl-shutdown-order.test.ts b/src/main/providers/local-pty-spawn-wsl-shutdown-order.test.ts index dab27b9967e6..528d6d6ab2f2 100644 --- a/src/main/providers/local-pty-spawn-wsl-shutdown-order.test.ts +++ b/src/main/providers/local-pty-spawn-wsl-shutdown-order.test.ts @@ -4,11 +4,11 @@ import type * as SpawnStateModule from './local-pty-spawn-state' import { ptyShutdownOperations, type PtyShutdownOperation } from './local-pty-provider-state' import type { WslLaunchDirectory } from '../../shared/wsl-launch-directory' -const { resolveWslLaunchDirectory, reattachLocalPty } = vi.hoisted(() => ({ - resolveWslLaunchDirectory: vi.fn(), +const { resolveSpawnWslLaunchDirectory, reattachLocalPty } = vi.hoisted(() => ({ + resolveSpawnWslLaunchDirectory: vi.fn(), reattachLocalPty: vi.fn() })) -vi.mock('./wsl-launch-directory-resolution', () => ({ resolveWslLaunchDirectory })) +vi.mock('./wsl-launch-directory-resolution', () => ({ resolveSpawnWslLaunchDirectory })) vi.mock('./local-pty-launch-plan', async (importOriginal) => ({ ...(await importOriginal()), resolveLocalPtyWslDistro: () => 'Ubuntu' @@ -30,7 +30,7 @@ describe('a WSL respawn racing a shutdown of the same session id', () => { // starts during it would otherwise be missed and race the new PTY. it('waits for a shutdown that began while the distro directory was being resolved', async () => { let resolveDirectory!: (directory: WslLaunchDirectory | undefined) => void - resolveWslLaunchDirectory.mockReturnValue( + resolveSpawnWslLaunchDirectory.mockReturnValue( new Promise((resolve) => { resolveDirectory = resolve }) @@ -40,7 +40,9 @@ describe('a WSL respawn racing a shutdown of the same session id', () => { { cols: 80, rows: 24, sessionId: 'wsl-session', command: 'claude' }, () => ({}) ) - await vi.waitFor(() => expect(resolveWslLaunchDirectory).toHaveBeenCalledWith('Ubuntu')) + await vi.waitFor(() => + expect(resolveSpawnWslLaunchDirectory).toHaveBeenCalledWith('Ubuntu', expect.anything()) + ) let finishShutdown!: () => void const shutdown: PtyShutdownOperation = { diff --git a/src/main/providers/local-pty-spawn.ts b/src/main/providers/local-pty-spawn.ts index 48739c4ba3f9..d76048640754 100644 --- a/src/main/providers/local-pty-spawn.ts +++ b/src/main/providers/local-pty-spawn.ts @@ -30,7 +30,7 @@ import { writeSpawnLaunchFile, type WrittenLaunchFile } from '../../shared/launch-file-writing' -import { resolveWslLaunchDirectory } from './wsl-launch-directory-resolution' +import { resolveSpawnWslLaunchDirectory } from './wsl-launch-directory-resolution' export async function spawnLocalPty( args: PtySpawnOptions, @@ -40,7 +40,8 @@ export async function spawnLocalPty( const wslDistro = args.command && !args.attachOnly ? resolveLocalPtyWslDistro(args, getOptions) : undefined // Why before the shutdown check: no await may sit between it and this spawn's registration. - const wslLaunchDirectory = wslDistro ? await resolveWslLaunchDirectory(wslDistro) : undefined + const wslProbe = resolveSpawnWslLaunchDirectory(wslDistro, args) + const wslLaunchDirectory = wslProbe ? await wslProbe : undefined if (reattachId) { const pendingShutdown = ptyShutdownOperations.get(reattachId) if (pendingShutdown) { diff --git a/src/main/providers/ssh-plain-shell-pty-provider.test.ts b/src/main/providers/ssh-plain-shell-pty-provider.test.ts index 064f37a5cef1..72a73c8db46f 100644 --- a/src/main/providers/ssh-plain-shell-pty-provider.test.ts +++ b/src/main/providers/ssh-plain-shell-pty-provider.test.ts @@ -2,6 +2,7 @@ import { EventEmitter } from 'node:events' import { describe, expect, it, vi } from 'vitest' import type { ClientChannel, PseudoTtyOptions } from 'ssh2' import { SshPlainShellPtyProvider } from './ssh-plain-shell-pty-provider' +import { carryInLaunchFile } from '../../shared/launch-prompt-file' class FakeShellChannel extends EventEmitter { readonly stderr = new EventEmitter() @@ -147,6 +148,11 @@ describe('SshPlainShellPtyProvider', () => { await expect(provider.spawn({ cols: 80, rows: 24, attachOnly: true })).rejects.toThrow( '(no_runtime)' ) + // Why: nothing here writes a launch file, so its line would name a file that is not there. + const { prompt, launchFile } = carryInLaunchFile('the whole task', false) + await expect( + provider.spawn({ cols: 80, rows: 24, command: `claude '${prompt}'`, launchFile }) + ).rejects.toThrow('needs the Orca remote server') expect(opened).toEqual([]) }) }) diff --git a/src/main/providers/ssh-plain-shell-pty-provider.ts b/src/main/providers/ssh-plain-shell-pty-provider.ts index c0fbe34372a1..5c4af1da90f5 100644 --- a/src/main/providers/ssh-plain-shell-pty-provider.ts +++ b/src/main/providers/ssh-plain-shell-pty-provider.ts @@ -72,7 +72,13 @@ export class SshPlainShellPtyProvider implements IPtyProvider { if (this.disposed) { throw new Error('SSH connection is not active') } - if (opts.agentSessionEnsure || opts.agentSessionCreateOperationId || opts.launchAgent) { + // Why launchFile too: no host here writes it, so the line would name a file that is not there. + if ( + opts.agentSessionEnsure || + opts.agentSessionCreateOperationId || + opts.launchAgent || + opts.launchFile + ) { throw new PlainSshUnsupportedError('Launching agents', this.mode) } if (opts.attachOnly) { diff --git a/src/main/providers/wsl-launch-directory-resolution.test.ts b/src/main/providers/wsl-launch-directory-resolution.test.ts index be4fb992a50e..78d4c18e5100 100644 --- a/src/main/providers/wsl-launch-directory-resolution.test.ts +++ b/src/main/providers/wsl-launch-directory-resolution.test.ts @@ -2,7 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createLocalPtyLaunchPlan, resolveLocalPtyWslDistro } from './local-pty-launch-plan' import type { LocalPtyProviderOptions } from './local-pty-provider-types' import type { PtySpawnOptions } from './types' -import { resolveWslLaunchDirectory } from './wsl-launch-directory-resolution' +import { + resolveSpawnWslLaunchDirectory, + resolveWslLaunchDirectory, + WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS +} from './wsl-launch-directory-resolution' import type * as WslModule from '../wsl' const { runWslProcess } = vi.hoisted(() => ({ runWslProcess: vi.fn() })) @@ -57,13 +61,41 @@ describe('resolveWslLaunchDirectory', () => { ) }) - it('probes a distro once, and asks again only after a failure', async () => { - runWslProcess.mockResolvedValueOnce(answers('', 1)) - await expect(resolveWslLaunchDirectory('Arch')).resolves.toBeUndefined() + it('probes a distro once, and remembers a failure only for its TTL', async () => { + vi.useFakeTimers() + try { + runWslProcess.mockResolvedValueOnce(answers('', 1)) + await expect(resolveWslLaunchDirectory('Arch')).resolves.toBeUndefined() + runWslProcess.mockResolvedValue(answers('/home/ada\n/bin/bash\n')) + // Why: a broken distro must not cost every spawn a wsl.exe round trip. + await expect(resolveWslLaunchDirectory('Arch')).resolves.toBeUndefined() + expect(runWslProcess).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS) + await expect(resolveWslLaunchDirectory('Arch')).resolves.toMatchObject({ + shell: '/bin/bash' + }) + await resolveWslLaunchDirectory('Arch') + expect(runWslProcess).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + + it('probes only for a spawn that needs the directory', async () => { runWslProcess.mockResolvedValue(answers('/home/ada\n/bin/bash\n')) - await expect(resolveWslLaunchDirectory('Arch')).resolves.toMatchObject({ shell: '/bin/bash' }) - await resolveWslLaunchDirectory('Arch') - expect(runWslProcess).toHaveBeenCalledTimes(2) + expect( + resolveSpawnWslLaunchDirectory('Gentoo', { command: 'claude', launchAgent: 'claude' }) + ).toBeUndefined() + expect( + resolveSpawnWslLaunchDirectory(undefined, { command: 'x'.repeat(600), launchAgent: 'claude' }) + ).toBeUndefined() + await expect( + resolveSpawnWslLaunchDirectory('Gentoo', { + command: `claude '${'x'.repeat(600)}'`, + launchAgent: 'claude' + }) + ).resolves.toMatchObject({ distro: 'Gentoo' }) + expect(runWslProcess).toHaveBeenCalledTimes(1) }) it('has none for a spawn outside WSL or a distro whose home cannot be read', async () => { diff --git a/src/main/providers/wsl-launch-directory-resolution.ts b/src/main/providers/wsl-launch-directory-resolution.ts index f357a627d721..87cf5c10cac6 100644 --- a/src/main/providers/wsl-launch-directory-resolution.ts +++ b/src/main/providers/wsl-launch-directory-resolution.ts @@ -1,6 +1,9 @@ import { posix } from 'node:path' import { quotePosixShell, RESOLVE_WSL_LOGIN_SHELL } from '../../shared/wsl-login-shell-command' import type { WslLaunchDirectory } from '../../shared/wsl-launch-directory' +import type { LaunchFile } from '../../shared/launch-prompt-file' +import { spawnNeedsWslLaunchDirectory } from '../../shared/launch-file-writing' +import type { TuiAgent } from '../../shared/tui-agent' import { toWindowsWslUncPath } from '../../shared/wsl-paths' import { runWslProcess } from '../wsl/wsl-runner' @@ -8,11 +11,36 @@ const ORCA_CACHE_RELATIVE = '.cache/orca' const probes = new Map>() +/** A failed probe is answered from memory this long, so a broken distro does not cost every spawn + * a `wsl.exe` round trip, yet a distro that comes back is found again. */ +export const WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS = 30_000 + +/** The directory for a spawn that needs one (`spawnNeedsWslLaunchDirectory`); no probe, and no + * await for the caller, otherwise. */ +export function resolveSpawnWslLaunchDirectory( + distro: string | null | undefined, + spawn: { + command?: string + launchFile?: LaunchFile + launchAgent?: TuiAgent + attachOnly?: boolean + } +): Promise | undefined { + const needed = + Boolean(distro) && + spawn.attachOnly !== true && + spawnNeedsWslLaunchDirectory({ + ...spawn, + orcaBuiltLine: spawn.launchAgent !== undefined + }) + return needed ? resolveWslLaunchDirectory(distro) : undefined +} + /** * The distro directory a WSL spawn's staged line and launch file are written to, and the login * shell the pane runs, which decides how a staged line is sourced; undefined when the distro cannot - * be asked, and the write site then refuses a launch that needs one. Probed once per distro, and - * only a success is kept. + * be asked, and the write site then refuses a launch that needs one. Probed once per distro; a + * success is kept, a failure for `WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS`. */ export async function resolveWslLaunchDirectory( distro: string | null | undefined @@ -24,7 +52,16 @@ export async function resolveWslLaunchDirectory( if (!probe) { probe = probeWslLaunchDirectory(distro) probes.set(distro, probe) - void probe.then((found) => found ?? probes.delete(distro)) + const pending = probe + void pending.then((found) => { + if (!found) { + setTimeout(() => { + if (probes.get(distro) === pending) { + probes.delete(distro) + } + }, WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS).unref?.() + } + }) } return await probe } diff --git a/src/main/runtime/agent-terminal-startup-prompt.test.ts b/src/main/runtime/agent-terminal-startup-prompt.test.ts index 065db00d3fc6..222f9c4bd385 100644 --- a/src/main/runtime/agent-terminal-startup-prompt.test.ts +++ b/src/main/runtime/agent-terminal-startup-prompt.test.ts @@ -2,9 +2,9 @@ * The launch command a terminal create builds when the launch hands it a prompt. * * This is the argv half of terminal prompt delivery, and the reason it is worth pinning is that - * its failure mode is silent: `buildAgentStartupPlan` answers a prompt it cannot fold by returning - * a bare command plus a `followupPrompt`, and this resolver returns options, not a live PTY, so a - * dropped `followupPrompt` would spawn the agent with no prompt and no error anywhere. + * its failure mode is silent: `planLaunchPrompt` answers a prompt it cannot fold with a clean plan + * and `paste-after-ready`, and this resolver returns options, not a live PTY, so a dropped paste + * would spawn the agent with no prompt and no error anywhere. */ import { describe, expect, it, vi } from 'vitest' diff --git a/src/main/runtime/orca-runtime-create-agent-session.ts b/src/main/runtime/orca-runtime-create-agent-session.ts index 91fa61c85b9a..4523e483d508 100644 --- a/src/main/runtime/orca-runtime-create-agent-session.ts +++ b/src/main/runtime/orca-runtime-create-agent-session.ts @@ -20,9 +20,12 @@ import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-i import { agentPromptRidesLaunchCommand, buildAgentDraftLaunchPlan, - buildAgentStartupPlan + planLaunchPrompt } from '../../shared/tui-agent-startup' -import { windowsDraftRefusal } from '../../shared/startup-plan-launch-file' +import { + launchPromptNeedsPasteRefusal, + windowsDraftRefusal +} from '../../shared/launch-prompt-carry' import { resolveStartupShell } from '../../shared/tui-agent-startup-shell' import type { RuntimeTerminalCreate } from '../../shared/runtime-types' import type { @@ -165,31 +168,39 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe ...(request.agentArgs !== undefined ? { agentArgs: request.agentArgs } : {}), sessionOptions: this.toAgentSessionOptions(request.launchPreferences) }) - const startup = - request.promptDelivery === 'draft' - ? buildAgentDraftLaunchPlan({ ...startupArgs, draft: request.prompt ?? '' }) - : buildAgentStartupPlan({ - ...startupArgs, - prompt: request.prompt ?? '', - allowEmptyPromptLaunch: true - }) - if (!startup) { - const refusal = - request.promptDelivery === 'draft' - ? windowsDraftRefusal( - request.prompt ?? '', - resolveStartupShell(startupArgs.platform, startupArgs.shell) - ) - : null - throw new Error(refusal ?? 'agent_session_identity_required') - } - if (startup.followupPrompt && agentPromptRidesLaunchCommand(request.agent)) { - // Why: this create has no paste after ready, so a prompt left for one would be dropped. - throw new Error( - `${request.agent} cannot take this prompt on its command line here (too long, private, or ` + - "broken by the host's Windows shell), so the session was not started. Start it without " + - 'the prompt and paste the prompt once it opens.' - ) + let startup + let launchFile + if (request.promptDelivery === 'draft') { + startup = buildAgentDraftLaunchPlan({ ...startupArgs, draft: request.prompt ?? '' }) + if (!startup) { + const refusal = windowsDraftRefusal( + request.prompt ?? '', + resolveStartupShell(startupArgs.platform, startupArgs.shell) + ) + throw new Error(refusal ?? 'agent_session_identity_required') + } + } else { + const planned = planLaunchPrompt({ ...startupArgs, prompt: request.prompt ?? '' }) + if (!planned) { + throw new Error('agent_session_identity_required') + } + switch (planned.carry) { + case 'none': + case 'on-line': + startup = planned.plan + break + case 'launch-file': + startup = planned.plan + launchFile = planned.launchFile + break + case 'paste-after-ready': + // Why: a stdin agent's prompt is the session's to submit; an argv agent's would be dropped. + if (agentPromptRidesLaunchCommand(request.agent)) { + throw new Error(launchPromptNeedsPasteRefusal(request.agent, 'session')) + } + startup = planned.cleanPlan + break + } } if (caller.signal?.aborted) { throw new Error('client_disconnected') @@ -222,9 +233,7 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe launchAgent: request.agent, terminalKittyKeyboardProtocol: request.terminalKittyKeyboardProtocol, startupCommandDelivery: startup.startupCommandDelivery, - ...('launchFile' in startup && startup.launchFile - ? { launchFile: startup.launchFile } - : {}), + ...(launchFile ? { launchFile } : {}), // A fresh agent this host built; the request has no surface field, so it counts as `unknown`. telemetry: agentStartedTelemetry(request.agent, undefined), cwd: startupCwd, diff --git a/src/main/runtime/orca-runtime-create-terminal-desktop.ts b/src/main/runtime/orca-runtime-create-terminal-desktop.ts index 54f5104f0aca..1bcf2d346bd7 100644 --- a/src/main/runtime/orca-runtime-create-terminal-desktop.ts +++ b/src/main/runtime/orca-runtime-create-terminal-desktop.ts @@ -2,7 +2,6 @@ import * as dependencies from './orca-runtime-create-terminal-dependencies' import type { OrcaRuntimeWithCreateTerminal } from './orca-runtime-create-terminal' import type { RuntimeTerminalPresentation } from '../../shared/runtime-types' -import { removeLaunchFile, writeLaunchFile } from '../../shared/launch-file-writing' export async function createDesktopTerminal( runtime: OrcaRuntimeWithCreateTerminal, @@ -26,11 +25,6 @@ export async function createDesktopTerminal( `--shell ${opts.shellOverride} needs a workspace, because the shell is resolved on the workspace's execution host. No terminal was created.` ) } - if (launchOpts.launchFile && !workspace?.connectionId) { - // Why: a pane whose host refuses the launch-file write never registers a handle, so this create - // would only time out; failing the same local write here reports the refusal at once. - removeLaunchFile(writeLaunchFile({ launchFile: launchOpts.launchFile })) - } const worktreeId = workspace?.id const cwd = workspace ? runtime.resolveWorkspaceTerminalStartupCwd(workspace, launchOpts.cwd) diff --git a/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts b/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts index 54ad4104fd63..26ff9f9d0f9a 100644 --- a/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts +++ b/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts @@ -5,7 +5,8 @@ import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' import type { TuiAgent } from '../../shared/tui-agent' import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' import { isTuiAgentEnabled } from '../../shared/tui-agent-selection' -import { buildAgentStartupPlan } from '../../shared/tui-agent-startup' +import { planLaunchPrompt } from '../../shared/tui-agent-startup' +import { launchPromptNeedsPasteRefusal } from '../../shared/launch-prompt-carry' import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' export class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends OrcaRuntimeWithRunCreateMobileSessionTerminal { @@ -47,7 +48,7 @@ export class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends OrcaRunt if (!isTuiAgentEnabled(opts.agent, settings.disabledTuiAgents)) { throw new Error('Selected agent is disabled. Choose an enabled agent before creating.') } - const startupPlan = buildAgentStartupPlan({ + const planned = planLaunchPrompt({ ...resolveAgentStartupPlanInputs({ agent: opts.agent, settings, @@ -56,14 +57,25 @@ export class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends OrcaRunt // Why: SSH runs the CLI through the relay shim (plain `orca`), so the Linux-only `orca-ide` rename must not apply. isRemote: Boolean(workspace.connectionId) }), - prompt: opts.agentPrompt ?? '', - allowEmptyPromptLaunch: true + prompt: opts.agentPrompt ?? '' }) - if (!startupPlan) { + if (!planned) { throw new Error(`Could not build launch command for ${opts.agent}.`) } - if (opts.agentPrompt && startupPlan.followupPrompt) { - throw new Error(`Agent ${opts.agent} does not support startup prompt quick commands.`) + let startupPlan + let launchFile + switch (planned.carry) { + case 'none': + case 'on-line': + startupPlan = planned.plan + break + case 'launch-file': + startupPlan = planned.plan + launchFile = planned.launchFile + break + case 'paste-after-ready': + // Why: a quick command has no paste after ready, so the prompt would be dropped. + throw new Error(launchPromptNeedsPasteRefusal(opts.agent, 'terminal')) } return { command: startupPlan.launchCommand, @@ -74,7 +86,7 @@ export class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends OrcaRunt launchConfig: startupPlan.launchConfig, launchAgent: opts.agent, startupCommandDelivery: startupPlan.startupCommandDelivery, - ...(startupPlan.launchFile ? { launchFile: startupPlan.launchFile } : {}) + ...(launchFile ? { launchFile } : {}) } } } 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 e6ac7d117481..01f5c0614201 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -19,14 +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 { - agentPromptRidesLaunchCommand, - buildAgentStartupPlan -} from '../../shared/tui-agent-startup' -import { - planStartupWithLaunchPrompt, - type LaunchPromptStartupPlan -} from '../../shared/startup-line-prompt-carry' +import { agentPromptRidesLaunchCommand, planLaunchPrompt } from '../../shared/tui-agent-startup' +import { launchPromptNeedsPasteRefusal } from '../../shared/launch-prompt-carry' import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' @@ -277,21 +271,12 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith sessionOptions: this.toAgentSessionOptions(opts.launchPreferences) }) // A caller that wrote its own launch file already passes the pointer to it as the prompt. - const prompt = opts.startupPrompt ?? '' - let planned: LaunchPromptStartupPlan - if (opts.launchFile) { - const plan = buildAgentStartupPlan({ - ...planInputs, - prompt, - allowEmptyPromptLaunch: true, - launchFile: opts.launchFile - }) - planned = { plan, launchFile: plan?.launchFile ?? opts.launchFile } - } else { - planned = planStartupWithLaunchPrompt(planInputs, prompt) - } - const { plan: startupPlan, launchFile } = planned - if (!startupPlan) { + const planned = planLaunchPrompt({ + ...planInputs, + prompt: opts.startupPrompt ?? '', + ...(opts.launchFile ? { launchFile: opts.launchFile } : {}) + }) + if (!planned) { // Why: an explicit agent that yields no plan would otherwise spawn a bare // shell that never reaches agent readiness. if (opts.startupAgent) { @@ -299,16 +284,29 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith } return opts } - if (opts.startupPrompt?.trim()) { - const carried = startupPlan.followupPrompt === null - if (!carried && !opts.onStartupPromptCarry) { - // Why: this create returns options, not a live PTY, so the prompt would be dropped. - throw new Error( - `${agent} cannot take this prompt on its command line here, so no terminal was created. ` + - 'Start the agent without it and paste the prompt once it opens.' - ) - } - opts.onStartupPromptCarry?.(carried) + let startupPlan + let launchFile + switch (planned.carry) { + case 'none': + startupPlan = planned.plan + break + case 'on-line': + startupPlan = planned.plan + opts.onStartupPromptCarry?.(true) + break + case 'launch-file': + startupPlan = planned.plan + launchFile = planned.launchFile + opts.onStartupPromptCarry?.(true) + break + case 'paste-after-ready': + if (!opts.onStartupPromptCarry) { + // Why: this create returns options, not a live PTY, so the prompt would be dropped. + throw new Error(launchPromptNeedsPasteRefusal(agent, 'terminal')) + } + startupPlan = planned.cleanPlan + opts.onStartupPromptCarry(false) + break } return { diff --git a/src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts b/src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts index 8e9ad65dc328..d540e6d497c1 100644 --- a/src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts +++ b/src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts @@ -2,6 +2,7 @@ import { OrcaRuntimeWithWaitForMobileTerminalSurface } from './orca-runtime-wait-for-mobile-terminal-surface' import { runtimeWorktreeIdsEqual } from './runtime-worktree-path-identity' import { parsePaneKey } from '../../shared/stable-pane-id' +import { onTerminalLaunchRefusal, takeTerminalLaunchRefusal } from './terminal-launch-refusals' import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import type { RuntimeMobileSessionCreateTerminalResult } from '../../shared/runtime-types' @@ -137,26 +138,43 @@ export class OrcaRuntimeWithRestoreLivePairedRendererSessionOwnedMobileTerminals } return new Promise((resolve, reject) => { - const timer = setTimeout(() => { + const stop = (): void => { + clearTimeout(timer) + stopRefusal() const idx = this.graphSyncCallbacks.indexOf(check) if (idx !== -1) { this.graphSyncCallbacks.splice(idx, 1) } + } + const timer = setTimeout(() => { + stop() reject(new Error('Timed out waiting for terminal handle after creation')) }, timeoutMs) + // Why: a host that refused the spawn's launch file never registers a handle. + const refuse = (message: string): void => { + stop() + reject(new Error(message)) + } + const stopRefusal = onTerminalLaunchRefusal((refusedTabId) => { + const message = refusedTabId === tabId ? takeTerminalLaunchRefusal(tabId) : undefined + if (message) { + refuse(message) + } + }) const check = (): void => { const handle = this.resolveHandleForTab(tabId) if (handle) { - clearTimeout(timer) - const idx = this.graphSyncCallbacks.indexOf(check) - if (idx !== -1) { - this.graphSyncCallbacks.splice(idx, 1) - } + stop() resolve(handle) } } this.graphSyncCallbacks.push(check) + const recorded = takeTerminalLaunchRefusal(tabId) + if (recorded) { + refuse(recorded) + return + } // Why: graph sync may have fired between the initial check and registration; re-check to avoid a missed wake-up. check() }) diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-12.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-12.spec.ts index 92b29a8b4496..28c9cc0dd2e1 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-12.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-12.spec.ts @@ -34,7 +34,7 @@ describe('OrcaRuntimeService', () => { agent: 'aider', agentPrompt: 'Review this diff' }) - ).rejects.toThrow('does not support startup prompt quick commands') + ).rejects.toThrow('aider takes its prompt only after it starts') expect(spawn).not.toHaveBeenCalled() }) diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index 1c4d22826b62..1af1b7673359 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -46,10 +46,10 @@ export type TerminalCreateOptions = { * Initial text for `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. It rides that command, directly or as a pointer to a launch file - * (`startup-line-prompt-carry`), unless it needs a launch file the agent is not known to read: - * then the agent starts clean, `onStartupPromptCarry` says so, and the caller pastes it once - * ready. A caller with no such callback is refused instead. + * the prompt silently dropped. It rides that command, directly or as a pointer to a launch file, + * unless `carryLaunchPrompt` says it must be pasted: then the agent starts clean, + * `onStartupPromptCarry` says so, and the caller pastes it once ready. A caller with no such + * callback is refused instead. */ startupPrompt?: string /** Main-internal: whether `startupPrompt` rode the launch command. Called once the plan is built. */ diff --git a/src/main/runtime/runtime-worktree-agent-startup.ts b/src/main/runtime/runtime-worktree-agent-startup.ts index a8565e594e9e..0e859f1641c1 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.ts @@ -8,8 +8,13 @@ import { getRepoSshConnectionId } from '../../shared/execution-host' 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 { planStartupWithLaunchPrompt } from '../../shared/startup-line-prompt-carry' +import { + buildAgentDraftLaunchPlan, + buildAgentStartupPlan, + planLaunchPrompt, + type AgentStartupPlan +} from '../../shared/tui-agent-startup' +import type { LaunchFile } from '../../shared/launch-prompt-file' import { detectInstalledAgentsWithShellPathHydration, detectRemoteAgents @@ -149,15 +154,31 @@ export function buildWorktreeStartupForAgent( ...(environment.agentArgs !== undefined ? { agentArgs: environment.agentArgs } : {}), sessionOptions: environment.toSessionOptions(environment.launchPreferences) }) - const { plan: startupPlan, launchFile } = planStartupWithLaunchPrompt( - planInputs, - environment.prompt ?? '' - ) - if (!startupPlan) { + const planned = planLaunchPrompt({ ...planInputs, prompt: environment.prompt ?? '' }) + if (!planned) { throw new Error(`Could not build launch command for ${agent}.`) } - if (environment.onPromptCarry && environment.prompt?.trim()) { - environment.onPromptCarry(startupPlan.followupPrompt === null) + let startupPlan: AgentStartupPlan + let launchFile: LaunchFile | undefined + let followup: WorktreeStartupFollowup | undefined + switch (planned.carry) { + case 'none': + startupPlan = planned.plan + break + case 'on-line': + case 'launch-file': + startupPlan = planned.plan + launchFile = planned.carry === 'launch-file' ? planned.launchFile : undefined + environment.onPromptCarry?.(true) + break + case 'paste-after-ready': + startupPlan = planned.cleanPlan + if (environment.onPromptCarry) { + environment.onPromptCarry(false) + } else { + followup = { expectedProcess: startupPlan.expectedProcess, prompt: planned.text } + } + break } return { agent, @@ -171,13 +192,6 @@ export function buildWorktreeStartupForAgent( ...(launchFile ? { launchFile } : {}), telemetry: agentStartedTelemetry(agent, environment.launchSource) }, - ...(startupPlan.followupPrompt && !environment.onPromptCarry - ? { - followup: { - expectedProcess: startupPlan.expectedProcess, - prompt: startupPlan.followupPrompt - } - } - : {}) + ...(followup ? { followup } : {}) } } diff --git a/src/main/runtime/terminal-launch-refusals.test.ts b/src/main/runtime/terminal-launch-refusals.test.ts new file mode 100644 index 000000000000..07c10e96e7bd --- /dev/null +++ b/src/main/runtime/terminal-launch-refusals.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { recordTerminalLaunchRefusal, takeTerminalLaunchRefusal } from './terminal-launch-refusals' +import { describeLaunchFileUnavailable } from '../../shared/launch-prompt-file' + +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') } +})) + +function waitForHandle(runtime: OrcaRuntimeService, tabId: string): Promise { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the wait under test is a protected member of the runtime; the assertion names only that method. + const internal = runtime as unknown as { + waitForTerminalHandle: (tabId: string, timeoutMs?: number) => Promise + } + return internal.waitForTerminalHandle(tabId, 5_000) +} + +describe('a pane spawn the host refused for its launch file', () => { + const refusal = describeLaunchFileUnavailable( + "the WSL distro's home directory could not be reached" + ) + + it('is taken once, by the tab it refused', () => { + recordTerminalLaunchRefusal('tab-taken', refusal) + expect(takeTerminalLaunchRefusal('tab-other')).toBeUndefined() + expect(takeTerminalLaunchRefusal('tab-taken')).toBe(refusal) + expect(takeTerminalLaunchRefusal('tab-taken')).toBeUndefined() + }) + + // Why: agent.launch waits on this handle; the refusal is its answer, not a 10 s timeout. + it('ends a create waiting on that tab’s handle with the reason', async () => { + const runtime = new OrcaRuntimeService() + const waiting = waitForHandle(runtime, 'tab-waiting') + recordTerminalLaunchRefusal('tab-waiting', refusal) + await expect(waiting).rejects.toThrow(refusal) + }) + + it('ends a create that starts waiting after the refusal arrived', async () => { + const runtime = new OrcaRuntimeService() + recordTerminalLaunchRefusal('tab-early', refusal) + await expect(waitForHandle(runtime, 'tab-early')).rejects.toThrow(refusal) + }) +}) diff --git a/src/main/runtime/terminal-launch-refusals.ts b/src/main/runtime/terminal-launch-refusals.ts new file mode 100644 index 000000000000..9ec35d90534b --- /dev/null +++ b/src/main/runtime/terminal-launch-refusals.ts @@ -0,0 +1,37 @@ +/** + * A pane spawn its host refused because what carries the agent's prompt (a launch file, or the + * staged script of a long line) could not be written. Kept by tab so a create waiting on that tab's + * handle reports the reason at once instead of timing out. Entries expire; nothing waits forever. + */ +const REFUSAL_TTL_MS = 60_000 +const MAX_REFUSALS = 64 + +const refusals = new Map() +const listeners = new Set<(tabId: string, message: string) => void>() + +export function recordTerminalLaunchRefusal(tabId: string, message: string): void { + const now = Date.now() + for (const [key, entry] of refusals) { + if (now - entry.at > REFUSAL_TTL_MS || refusals.size >= MAX_REFUSALS) { + refusals.delete(key) + } + } + refusals.set(tabId, { message, at: now }) + for (const listener of listeners) { + listener(tabId, message) + } +} + +/** The refusal recorded for `tabId`, consumed. */ +export function takeTerminalLaunchRefusal(tabId: string): string | undefined { + const entry = refusals.get(tabId) + refusals.delete(tabId) + return entry && Date.now() - entry.at <= REFUSAL_TTL_MS ? entry.message : undefined +} + +export function onTerminalLaunchRefusal( + listener: (tabId: string, message: string) => void +): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} diff --git a/src/relay/pty-handler-startup-command-staging.test.ts b/src/relay/pty-handler-startup-command-staging.test.ts index 0d750d5a69f2..a3ab9150bcba 100644 --- a/src/relay/pty-handler-startup-command-staging.test.ts +++ b/src/relay/pty-handler-startup-command-staging.test.ts @@ -34,7 +34,7 @@ vi.mock('../main/shell-prompt-readiness-probe', () => ({ import type { PtyHandler } from './pty-handler' import { beginPtyHandlerTest, endPtyHandlerTest } from './pty-handler-test-harness' -import { buildLaunchFilePointer, planLaunchPrompt } from '../shared/launch-prompt-file' +import { buildLaunchFilePointer, carryInLaunchFile } from '../shared/launch-prompt-file' import type { MockDispatcher } from './pty-handler-test-harness' const describePosix = process.platform === 'win32' ? describe.skip : describe @@ -114,7 +114,7 @@ describePosix('relay startup command staging', () => { }) it('writes a launch file before typing the line that names it, and removes it on exit', async () => { - const { prompt, launchFile } = planLaunchPrompt('secret brief', { sensitive: true }) + const { prompt, launchFile } = carryInLaunchFile('secret brief', true) await dispatcher.callRequest('pty.spawn', { command: `claude '${prompt}'`, commandDelivery: 'provider', diff --git a/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts b/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts index d8320dd10750..c5307266a259 100644 --- a/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts +++ b/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts @@ -16,6 +16,7 @@ type BuildSourceControlAgentDeliveryPlanArgs = { /** Why: keep the previewed command label in sync with the real remote launch, * which omits the Linux-only `orca-ide` rename for SSH hosts. */ isRemote?: boolean + launchHostIsPaired?: boolean } export function buildSourceControlAgentDeliveryPlan({ @@ -26,7 +27,8 @@ export function buildSourceControlAgentDeliveryPlan({ detectedAgents, connectionUnavailable, launchPlatform, - isRemote + isRemote, + launchHostIsPaired }: BuildSourceControlAgentDeliveryPlanArgs): SourceControlAgentActionDeliveryPlanState { if (connectionUnavailable) { return buildSourceControlAgentConnectionErrorPlan() @@ -50,7 +52,8 @@ export function buildSourceControlAgentDeliveryPlan({ cmdOverrides: settings?.agentCmdOverrides, terminalWindowsShell: settings?.terminalWindowsShell, platform: launchPlatform, - isRemote + isRemote, + launchHostIsPaired }) if (!result.ok) { return { status: 'error', error: result.error } diff --git a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts index 2209c1621fef..85fe301b8206 100644 --- a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts +++ b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts @@ -9,6 +9,9 @@ import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { Repo } from '../../../../shared/repo-types' import type { TuiAgent } from '../../../../shared/tui-agent' import { buildSourceControlAgentDeliveryPlan } from './buildSourceControlAgentDeliveryPlan' +import { useAppStore } from '@/store' +import { launchHostIsPaired } from '@/lib/launch-file-host' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import type { SourceControlAgentActionDeliveryPlanState } from './SourceControlAgentActionDialogForm' import { runSourceControlAgentActionStart } from './runSourceControlAgentActionStart' import { buildSourceControlAgentConnectionErrorPlan } from './source-control-agent-action-dialog-support' @@ -116,7 +119,10 @@ export function useSourceControlAgentActionStart({ detectedAgents: currentDetectedAgents, connectionUnavailable, launchPlatform, - isRemote + isRemote, + launchHostIsPaired: launchHostIsPaired( + getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), worktreeId) + ) }) }, [ @@ -128,7 +134,8 @@ export function useSourceControlAgentActionStart({ refreshDetectedAgents, selectedAgent, launchPlatform, - isRemote + isRemote, + worktreeId ] ) diff --git a/src/renderer/src/components/sidebar/folder-workspace-agent-startup.ts b/src/renderer/src/components/sidebar/folder-workspace-agent-startup.ts index d3746a38e826..9c9240011dcf 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-agent-startup.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-agent-startup.ts @@ -62,7 +62,6 @@ export function buildFolderWorkspaceLinkedStartupPlan(args: { agent: draftLaunchPlan.agent, launchCommand: draftLaunchPlan.launchCommand, expectedProcess: draftLaunchPlan.expectedProcess, - followupPrompt: null, launchConfig: draftLaunchPlan.launchConfig, ...(draftLaunchPlan.sessionOptions ? { sessionOptions: draftLaunchPlan.sessionOptions } : {}), ...(draftLaunchPlan.startupCommandDelivery diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts index 6976647ed8b9..3b9ebe5cd1e6 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts @@ -311,7 +311,6 @@ describe('submitFolderWorkspaceCreate', () => { startup: expect.objectContaining({ agent: 'codex', launchCommand: 'codex', - followupPrompt: null, draftPrompt: `Review this before starting\n\n${linkedWorkItem.url}` }) }) @@ -387,7 +386,7 @@ describe('submitFolderWorkspaceCreate', () => { startup: expect.objectContaining({ agent: 'aider', launchCommand: 'aider', - followupPrompt: 'Fix the failing folder prompt flow' + pastePromptAfterReady: 'Fix the failing folder prompt flow' }) }) }) diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index 1712f42f0a79..d942af2701f8 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -1,7 +1,7 @@ import { ensureAgentStartupInTerminal, type LinkedWorkItemSummary } from '@/lib/new-workspace' import { seedNativeChatLaunchDraftForAgentTab } from '@/lib/agent-launch-prompt-delivery' import { createBrowserUuid } from '@/lib/browser-uuid' -import { buildAgentStartupPlan } from '@/lib/tui-agent-startup' +import { planLaunchPrompt } from '@/lib/tui-agent-startup' import { tuiAgentToAgentKind } from '@/lib/telemetry' import { activateAndRevealFolderWorkspace } from '@/lib/worktree-activation' import { isWorkItemLookupText } from '@/lib/work-item-lookup-text' @@ -21,7 +21,11 @@ import { planAgentSessionLaunch } from '@/lib/agent-session-launch-plan' import { beginStructuredAgentSessionProvisionalLaunch } from '@/lib/structured-agent-session-provisional-tab' import { getNewWorkspaceProjectGroupHostId } from '@/lib/new-workspace-project-options' import { useAppStore } from '@/store' -import { launchHostWritesLaunchFile } from '@/lib/launch-file-host' +import { launchHostIsPaired } from '@/lib/launch-file-host' +import { + composerAgentStartupPlan, + type ComposerAgentStartupPlan +} from '@/lib/composer-agent-startup-plan' import { buildFolderWorkspaceLinkedStartupPlan, getFolderWorkspaceAgentLaunchPlatform, @@ -99,7 +103,7 @@ export async function submitFolderWorkspaceCreate({ isRemote: launchIsRemote, terminalWindowsShell }) - const startupPlan = + const startupPlan: ComposerAgentStartupPlan | null = quickAgent && linkedWorkItem ? buildFolderWorkspaceLinkedStartupPlan({ agent: quickAgent, @@ -114,21 +118,21 @@ export async function submitFolderWorkspaceCreate({ isRemote: launchIsRemote }) : quickAgent - ? buildAgentStartupPlan({ - agent: quickAgent, - prompt: note, - cmdOverrides: agentCmdOverrides ?? {}, - agentArgs, - agentEnv, - sessionOptions, - platform: launchPlatform, - shell: launchShell, - isRemote: launchIsRemote, - hostWritesLaunchFile: launchHostWritesLaunchFile({ - activeRuntimeEnvironmentId: runtimeEnvironmentId + ? composerAgentStartupPlan( + planLaunchPrompt({ + agent: quickAgent, + prompt: note, + cmdOverrides: agentCmdOverrides ?? {}, + agentArgs, + agentEnv, + sessionOptions, + platform: launchPlatform, + shell: launchShell, + isRemote: launchIsRemote, + launchHostIsPaired: launchHostIsPaired(runtimeEnvironmentId) }), - allowEmptyPromptLaunch: true - }) + note + ) : null // Why: the argv-prefill plan carries the draft inside `launchCommand`, so // `startupPlan.draftPrompt` alone can't tell whether this launch has one. @@ -194,6 +198,7 @@ export async function submitFolderWorkspaceCreate({ ? { startupCommandDelivery: startupPlan.startupCommandDelivery } : {}), ...(startupPlan.launchFile ? { launchFile: startupPlan.launchFile } : {}), + ...(startupPlan.launchPrompt ? { launchPrompt: startupPlan.launchPrompt } : {}), telemetry: { agent_kind: tuiAgentToAgentKind(quickAgent), launch_source: launchSource, @@ -246,7 +251,7 @@ export async function submitFolderWorkspaceCreate({ if ( !structuredLaunchAccepted && startupPlan && - (startupPlan.followupPrompt || startupPlan.draftPrompt) && + (startupPlan.pastePromptAfterReady || startupPlan.draftPrompt) && activation !== false ) { void ensureAgentStartupInTerminal({ diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts index 15e857f239b9..da09d847cfdb 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts @@ -191,11 +191,13 @@ function handleConnectError( if (message.includes('was explicitly killed')) { return undefined } - // Why the transport's file: the spawn sends it unless the connect call named its own command. - const { launchFile } = context.transportOptions - if (launchFile && !options.command && isLaunchFileUnavailableMessage(message)) { + // Why the transport's prompt: the spawn carries it unless the connect call named its own command. + // The host refuses a launch file, or a WSL line it cannot stage, with the same code. + const { launchFile, launchPrompt } = context.transportOptions + const refusedPrompt = launchFile?.content ?? launchPrompt + if (refusedPrompt && !options.command && isLaunchFileUnavailableMessage(message)) { // The pane gets no shell; the notice holds the only copy of the prompt. - showAgentLaunchNotStartedNotice({ prompt: launchFile.content }) + showAgentLaunchNotStartedNotice({ prompt: refusedPrompt }) } if (connectionId && options.sessionId && message.includes(SSH_PTY_CONNECTION_MISMATCH_MARKER)) { // Why not `sessionExpired`: this string is minted by `toRelaySshPtyId`/`toAppSshPtyId` from a diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index 9fc0f528040d..0f358a74cbed 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -27,6 +27,8 @@ export type PtyPaneStartup = { delivery?: 'terminal-paste' startupCommandDelivery?: StartupCommandDelivery launchFile?: LaunchFile + /** The submitted prompt `command` carries; handed back to copy if the host refuses the spawn. */ + launchPrompt?: string env?: Record envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-startup-command-options.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-startup-command-options.ts new file mode 100644 index 000000000000..d47777f74537 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-startup-command-options.ts @@ -0,0 +1,18 @@ +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +/** The startup command a pane spawn sends, with the launch file and prompt it carries; none when + * the startup is delivered by terminal paste instead. */ +export function paneStartupCommandOptions(session: ConnectPanePtySession): { + command?: string + launchFile?: NonNullable['launchFile'] + launchPrompt?: string +} { + if (session.shouldDeliverStartupViaTerminalPaste) { + return {} + } + return { + command: session.paneStartup?.command, + launchFile: session.paneStartup?.launchFile, + launchPrompt: session.paneStartup?.launchPrompt + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts index 84b98317b549..1a48b5a7a2bf 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts @@ -25,6 +25,7 @@ import { shouldRetainDisposedPaneSpawn } from './disposed-spawn-retention' import { buffersInputOnlyForSshReattach } from './ssh-reattach-input-buffering' import type { ConnectPanePtySession } from './connect-pane-pty-session' +import { paneStartupCommandOptions } from './pane-startup-command-options' import { resolveTerminalInlineImagesEnabled } from '../../../../../shared/terminal-inline-images-settings' /** Transport creation, terminal capability replies, viewport claims, and undeliverable-input recovery. */ @@ -62,9 +63,7 @@ export function installPtyInputRecovery(session: ConnectPanePtySession): void { : {}), env: session.paneEnv, ...(session.paneStartup?.envToDelete ? { envToDelete: session.paneStartup.envToDelete } : {}), - ...(session.shouldDeliverStartupViaTerminalPaste - ? {} - : { command: session.paneStartup?.command, launchFile: session.paneStartup?.launchFile }), + ...paneStartupCommandOptions(session), ...(session.shouldUseProviderSshStartupDelivery ? { commandDelivery: 'provider' as const } : {}), diff --git a/src/renderer/src/components/terminal-pane/pty-transport-launch-refusal.test.ts b/src/renderer/src/components/terminal-pane/pty-transport-launch-refusal.test.ts new file mode 100644 index 000000000000..6bc07f350fa2 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-transport-launch-refusal.test.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installIpcPtyWindow, restorePtySpecWindow } from './pty-transport-test-harness' +import { describeLaunchFileUnavailable } from '../../../../shared/launch-prompt-file' + +const mocks = vi.hoisted(() => ({ showNotStarted: vi.fn() })) +vi.mock('@/lib/agent-launch-prompt-not-delivered-notice', () => ({ + showAgentLaunchNotStartedNotice: mocks.showNotStarted +})) + +describe('a pane spawn the host refused for what carries its prompt', () => { + const originalWindow = globalThis.window + + beforeEach(() => { + vi.resetModules() + mocks.showNotStarted.mockReset() + installIpcPtyWindow(originalWindow, { data: () => {}, exit: () => {} }) + }) + + afterEach(() => { + restorePtySpecWindow(originalWindow) + }) + + // Why: a WSL launch with no launch file still needs its staged line written into the distro. + it('hands back a staged line’s prompt to copy, as it does a launch file’s', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + vi.mocked(window.api.pty.spawn).mockRejectedValueOnce( + new Error(describeLaunchFileUnavailable('unreachable', 'staged-line')) + ) + + await createIpcPtyTransport({ + command: "claude 'a long prompt'", + launchPrompt: 'a long prompt' + }).connect({ url: '', callbacks: { onError: vi.fn() } }) + + expect(mocks.showNotStarted).toHaveBeenCalledWith({ prompt: 'a long prompt' }) + }) + + it('stays quiet for any other spawn failure', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + vi.mocked(window.api.pty.spawn).mockRejectedValueOnce(new Error('spawn ENOENT')) + + await createIpcPtyTransport({ command: 'claude', launchPrompt: 'fix it' }).connect({ + url: '', + callbacks: { onError: vi.fn() } + }) + + expect(mocks.showNotStarted).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 887a2b6ff439..b7b3b5d6150c 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -270,6 +270,8 @@ export type IpcPtyTransportOptions = { startupCommandDelivery?: StartupCommandDelivery /** Local spawns only: a remote runtime writes no file the renderer names. */ launchFile?: LaunchFile + /** See `PtyPaneStartup.launchPrompt`. */ + launchPrompt?: string connectionId?: string | null executionHostId?: ExecutionHostId | null worktreeId?: string diff --git a/src/renderer/src/hooks/composer-state/full-creation-startup.test.ts b/src/renderer/src/hooks/composer-state/full-creation-startup.test.ts index 75b08c0d9ec8..1f48e5233e7b 100644 --- a/src/renderer/src/hooks/composer-state/full-creation-startup.test.ts +++ b/src/renderer/src/hooks/composer-state/full-creation-startup.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import { buildAgentStartupPlan } from '@/lib/tui-agent-startup' +import { planLaunchPrompt } from '@/lib/tui-agent-startup' +import { composerAgentStartupPlan } from '@/lib/composer-agent-startup-plan' import { buildFullCreationStartup } from './full-creation-startup' import { tuiAgentToAgentKind } from '../../../../shared/agent-kind' @@ -7,13 +8,17 @@ describe('the full composer’s renderer-spawned startup', () => { // Why: SSH repos, folder repos, repos with default tabs and a failed backend spawn all take this // path; without the file the agent is pointed at nothing and the prompt is lost. it('carries the launch file its command points at', () => { - const startupPlan = buildAgentStartupPlan({ - agent: 'claude', - prompt: 'x'.repeat(200_000), - cmdOverrides: {}, - platform: 'linux', - isRemote: true - }) + const prompt = 'x'.repeat(200_000) + const startupPlan = composerAgentStartupPlan( + planLaunchPrompt({ + agent: 'claude', + prompt, + cmdOverrides: {}, + platform: 'linux', + isRemote: true + }), + prompt + ) const startup = buildFullCreationStartup({ startupPlan, backendSpawnedStartup: false, @@ -29,5 +34,7 @@ describe('the full composer’s renderer-spawned startup', () => { expect(startupPlan?.launchFile).toBeDefined() expect(startup?.launchFile).toEqual(startupPlan?.launchFile) expect(startup?.command).toContain(startupPlan?.launchFile?.placeholder) + // Handed back to copy if the host refuses the spawn. + expect(startup?.launchPrompt).toBe(prompt) }) }) diff --git a/src/renderer/src/hooks/composer-state/full-creation-startup.ts b/src/renderer/src/hooks/composer-state/full-creation-startup.ts index a00666c2112c..c0ddc1bd2159 100644 --- a/src/renderer/src/hooks/composer-state/full-creation-startup.ts +++ b/src/renderer/src/hooks/composer-state/full-creation-startup.ts @@ -1,9 +1,9 @@ import type { TuiAgent } from '../../../../shared/tui-agent' -import type { AgentStartupPlan } from '@/lib/tui-agent-startup' +import type { ComposerAgentStartupPlan } from '@/lib/composer-agent-startup-plan' import type { WorktreeStartupPayload } from '@/lib/worktree-startup-payload' export function buildFullCreationStartup(args: { - startupPlan: AgentStartupPlan | null + startupPlan: ComposerAgentStartupPlan | null backendSpawnedStartup: boolean agent: TuiAgent shouldSeedInitialAgentStatus: boolean @@ -25,6 +25,7 @@ export function buildFullCreationStartup(args: { : {}), // Why: the command points at this file; without it the agent is told to read nothing. ...(args.startupPlan.launchFile ? { launchFile: args.startupPlan.launchFile } : {}), + ...(args.startupPlan.launchPrompt ? { launchPrompt: args.startupPlan.launchPrompt } : {}), ...(args.shouldSeedInitialAgentStatus ? { initialAgentStatus: { agent: args.agent, prompt: args.prompt.trim() } } : {}), diff --git a/src/renderer/src/hooks/composer-state/full-submit-preparation.ts b/src/renderer/src/hooks/composer-state/full-submit-preparation.ts index e3136a48b398..98c273cde24d 100644 --- a/src/renderer/src/hooks/composer-state/full-submit-preparation.ts +++ b/src/renderer/src/hooks/composer-state/full-submit-preparation.ts @@ -30,7 +30,7 @@ import { ensureHooksConfirmed, confirmRuntimeIssueCommandRead } from '@/lib/ensu import { useAppStore } from '@/store' import type { SetupDecision } from '../../../../shared/worktree/create-types' import { resolveComposerBranchNameOverrideForCreate } from '../composer-branch-selection' -import { buildAgentStartupPlan } from '@/lib/tui-agent-startup' +import { planLaunchPrompt } from '@/lib/tui-agent-startup' import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv @@ -39,7 +39,8 @@ import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import type { AgentStartedTelemetry } from '@/lib/worktree-startup-payload' import { tuiAgentToAgentKind } from '@/lib/telemetry' -import { launchHostWritesLaunchFile } from '@/lib/launch-file-host' +import { launchHostIsPaired } from '@/lib/launch-file-host' +import { composerAgentStartupPlan } from '@/lib/composer-agent-startup-plan' import type { PendingSmartGitHubSubmitResolution } from './source-selection-decisions' export function useFullSubmitPreparation(input: FullSubmitPreparationInput) { @@ -181,30 +182,33 @@ export function useFullSubmitPreparation(input: FullSubmitPreparationInput) { !effectiveBranchNameOverride && !createDisplayName - const startupPlan = buildAgentStartupPlan({ - agent: tuiAgent, - prompt: submitStartupPrompt, - cmdOverrides: settings?.agentCmdOverrides ?? {}, - agentArgs: resolveTuiAgentLaunchArgs(tuiAgent, settings?.agentDefaultArgs), - agentEnv: resolveTuiAgentLaunchEnv(tuiAgent, settings?.agentDefaultEnv), - sessionOptions: resolveInitialNativeChatSessionOptions( - { - experimentalNativeChat: settings?.experimentalNativeChat, - openAgentTabsInChatByDefault: settings?.openAgentTabsInChatByDefault, - nativeChatSessionOptions: settings?.nativeChatSessionOptions - }, - { - agent: tuiAgent, - nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( - selectedRepo?.connectionId - ) - } - ), - platform: selectedRepoAgentLaunchPlatform, - shell: selectedRepoStartupShell, - isRemote: selectedRepoIsRemote, - hostWritesLaunchFile: launchHostWritesLaunchFile(selectedRepoSettings) - }) + const startupPlan = composerAgentStartupPlan( + planLaunchPrompt({ + agent: tuiAgent, + prompt: submitStartupPrompt, + cmdOverrides: settings?.agentCmdOverrides ?? {}, + agentArgs: resolveTuiAgentLaunchArgs(tuiAgent, settings?.agentDefaultArgs), + agentEnv: resolveTuiAgentLaunchEnv(tuiAgent, settings?.agentDefaultEnv), + sessionOptions: resolveInitialNativeChatSessionOptions( + { + experimentalNativeChat: settings?.experimentalNativeChat, + openAgentTabsInChatByDefault: settings?.openAgentTabsInChatByDefault, + nativeChatSessionOptions: settings?.nativeChatSessionOptions + }, + { + agent: tuiAgent, + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( + selectedRepo?.connectionId + ) + } + ), + platform: selectedRepoAgentLaunchPlatform, + shell: selectedRepoStartupShell, + isRemote: selectedRepoIsRemote, + launchHostIsPaired: launchHostIsPaired(selectedRepoSettings?.activeRuntimeEnvironmentId) + }), + submitStartupPrompt + ) const shouldSeedInitialAgentStatus = tuiAgent === 'command-code' && submitStartupPrompt.trim().length > 0 @@ -217,7 +221,7 @@ export function useFullSubmitPreparation(input: FullSubmitPreparationInput) { } const backendStartup = - startupPlan && !startupPlan.draftPrompt && !startupPlan.followupPrompt + startupPlan && !startupPlan.draftPrompt && !startupPlan.pastePromptAfterReady ? { command: startupPlan.launchCommand, ...(startupPlan.env ? { env: startupPlan.env } : {}), diff --git a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts index edca524131bd..37a57f9056b5 100644 --- a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts +++ b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts @@ -40,7 +40,7 @@ import { useAppStore } from '@/store' import { settleComposerSubmit } from '@/lib/composer-submit-cancellation' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' -import { launchHostWritesLaunchFile } from '@/lib/launch-file-host' +import { launchHostIsPaired } from '@/lib/launch-file-host' import { runBackgroundWorktreeCreation } from '@/lib/worktree-creation-flow' import { translate } from '@/i18n/i18n' import { resolveQuickCreateLinkedWorkItemPrompt } from '@/lib/linked-work-item-context' @@ -140,7 +140,7 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) { platform: selectedRepoAgentLaunchPlatform, shell: selectedRepoStartupShell, isRemote: selectedRepoIsRemote, - hostWritesLaunchFile: launchHostWritesLaunchFile(selectedRepoSettings), + launchHostIsPaired: launchHostIsPaired(selectedRepoSettings?.activeRuntimeEnvironmentId), telemetrySource }) diff --git a/src/renderer/src/hooks/composer-state/quick-startup-plan.ts b/src/renderer/src/hooks/composer-state/quick-startup-plan.ts index 30adf55de722..2b2eed7538ff 100644 --- a/src/renderer/src/hooks/composer-state/quick-startup-plan.ts +++ b/src/renderer/src/hooks/composer-state/quick-startup-plan.ts @@ -3,8 +3,11 @@ import type { TuiAgent } from '../../../../shared/tui-agent' import type { AgentStartupShell } from '../../../../shared/tui-agent-startup-shell' import type { AgentStartedTelemetry } from '@/lib/worktree-startup-payload' import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' -import type { AgentStartupPlan } from '@/lib/tui-agent-startup' -import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agent-startup' +import { buildAgentDraftLaunchPlan, planLaunchPrompt } from '@/lib/tui-agent-startup' +import { + composerAgentStartupPlan, + type ComposerAgentStartupPlan +} from '@/lib/composer-agent-startup-plan' import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv @@ -22,12 +25,13 @@ export type QuickComposerStartupInput = { platform: NodeJS.Platform shell: AgentStartupShell | null | undefined isRemote: boolean - hostWritesLaunchFile: boolean + /** See `launchHostIsPaired`. */ + launchHostIsPaired: boolean telemetrySource: WorktreeCreationRequest['telemetrySource'] } export type QuickComposerStartup = { - startupPlan: AgentStartupPlan | null + startupPlan: ComposerAgentStartupPlan | null backendStartup: WorktreeCreationRequest['startup'] telemetry: AgentStartedTelemetry | null } @@ -67,13 +71,12 @@ export function buildQuickComposerStartup(input: QuickComposerStartupInput): Qui shell: input.shell ?? undefined, isRemote: input.isRemote }) - let startupPlan: AgentStartupPlan | null = null + let startupPlan: ComposerAgentStartupPlan | null = null if (draftLaunchPlan) { startupPlan = { agent: draftLaunchPlan.agent, launchCommand: draftLaunchPlan.launchCommand, expectedProcess: draftLaunchPlan.expectedProcess, - followupPrompt: null, launchConfig: draftLaunchPlan.launchConfig, ...(draftLaunchPlan.sessionOptions ? { sessionOptions: draftLaunchPlan.sessionOptions } : {}), ...(draftLaunchPlan.startupCommandDelivery @@ -82,19 +85,21 @@ export function buildQuickComposerStartup(input: QuickComposerStartupInput): Qui ...(draftLaunchPlan.env ? { env: draftLaunchPlan.env } : {}) } } else if (agent !== null) { - startupPlan = buildAgentStartupPlan({ - agent, - prompt, - cmdOverrides: settings?.agentCmdOverrides ?? {}, - agentArgs: resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs), - agentEnv: resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv), - sessionOptions, - platform: input.platform, - shell: input.shell ?? undefined, - isRemote: input.isRemote, - hostWritesLaunchFile: input.hostWritesLaunchFile, - allowEmptyPromptLaunch: true - }) + startupPlan = composerAgentStartupPlan( + planLaunchPrompt({ + agent, + prompt, + cmdOverrides: settings?.agentCmdOverrides ?? {}, + agentArgs: resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs), + agentEnv: resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv), + sessionOptions, + platform: input.platform, + shell: input.shell ?? undefined, + isRemote: input.isRemote, + launchHostIsPaired: input.launchHostIsPaired + }), + prompt + ) if (startupPlan && draftPrompt) { startupPlan.draftPrompt = draftPrompt } @@ -109,7 +114,7 @@ export function buildQuickComposerStartup(input: QuickComposerStartupInput): Qui request_kind: 'new' } const backendStartup = - startupPlan && !startupPlan.draftPrompt && !startupPlan.followupPrompt + startupPlan && !startupPlan.draftPrompt && !startupPlan.pastePromptAfterReady ? { command: startupPlan.launchCommand, ...(startupPlan.env ? { env: startupPlan.env } : {}), diff --git a/src/renderer/src/lib/agent-launch-caller-permissions.test.ts b/src/renderer/src/lib/agent-launch-caller-permissions.test.ts index 7e7ffee91828..9323a9aa5a4a 100644 --- a/src/renderer/src/lib/agent-launch-caller-permissions.test.ts +++ b/src/renderer/src/lib/agent-launch-caller-permissions.test.ts @@ -39,6 +39,9 @@ vi.mock('@/components/native-chat/native-chat-session-option-cache', () => ({ seedNativeChatAppliedSessionOptions: vi.fn() })) vi.mock('@/lib/agent-paste-draft', () => ({ pasteDraftWhenAgentReady: vi.fn(async () => true) })) +vi.mock('@/lib/agent-launch-prompt-receipt', () => ({ + waitForLaunchPromptReceipt: vi.fn(async () => true) +})) vi.mock('@/lib/agent-ready-wait', () => ({ waitForAgentReady: vi.fn(async () => ({ ready: true, reason: 'foreground-match' })) })) diff --git a/src/renderer/src/lib/agent-launch-caller-placement.test.ts b/src/renderer/src/lib/agent-launch-caller-placement.test.ts index 03db8a683671..d2721aa2805c 100644 --- a/src/renderer/src/lib/agent-launch-caller-placement.test.ts +++ b/src/renderer/src/lib/agent-launch-caller-placement.test.ts @@ -39,6 +39,9 @@ vi.mock('@/components/native-chat/native-chat-session-option-cache', () => ({ seedNativeChatAppliedSessionOptions: vi.fn() })) vi.mock('@/lib/agent-paste-draft', () => ({ pasteDraftWhenAgentReady: vi.fn(async () => true) })) +vi.mock('@/lib/agent-launch-prompt-receipt', () => ({ + waitForLaunchPromptReceipt: vi.fn(async () => true) +})) vi.mock('@/lib/agent-ready-wait', () => ({ waitForAgentReady: vi.fn(async () => ({ ready: true, reason: 'foreground-match' })) })) diff --git a/src/renderer/src/lib/agent-launch-caller-prompt-transport.test.ts b/src/renderer/src/lib/agent-launch-caller-prompt-transport.test.ts index e1a3d474de97..696d3e404236 100644 --- a/src/renderer/src/lib/agent-launch-caller-prompt-transport.test.ts +++ b/src/renderer/src/lib/agent-launch-caller-prompt-transport.test.ts @@ -48,6 +48,10 @@ vi.mock('@/lib/agent-paste-draft', () => ({ vi.mock('@/lib/agent-ready-wait', () => ({ waitForAgentReady: vi.fn(async () => ({ ready: true, reason: 'foreground-match' })) })) +const mockWaitForLaunchPromptReceipt = vi.fn(async () => true) +vi.mock('@/lib/agent-launch-prompt-receipt', () => ({ + waitForLaunchPromptReceipt: mockWaitForLaunchPromptReceipt +})) vi.mock('@/runtime/local-runtime-capabilities', () => ({ readLocalRuntimeCapabilitiesOrUnknown: () => [] })) @@ -159,14 +163,16 @@ describe('agent launch caller prompt transport', () => { ) it.each(cases)( - 'exposes no delivery promise to %s, whose Codex prompt rides argv or is a draft', + 'exposes a delivery result to %s only for a prompt it waits to see submitted', async (_id, profile) => { const result = await launch(profile) - // Why: three call sites branch on this promise being present. A draft launch must NOT get one, - // because the composer owns the text until the user sends it, and a prompt the launch command - // carries has no paste to wait for. - expect(result?.promptDeliveryResult).toBeUndefined() + // Why: call sites branch on this result being present. A draft launch must NOT get one, + // because the composer owns the text until the user sends it; a submit-after-ready prompt the + // launch command carries still waits on the agent's receipt before the caller acts. + const waitsForSubmit = + profile.args.prompt !== undefined && profile.args.promptDelivery === 'submit-after-ready' + expect(result?.promptDeliveryResult !== undefined).toBe(waitsForSubmit) } ) @@ -215,6 +221,38 @@ describe('agent launch caller prompt transport', () => { } }) + // The bug class the explicit carry outcome removes: a plan that exists but does not carry the + // prompt was treated as delivered, and the prompt was dropped. + it.each([ + ['past the argv ceiling', 'x'.repeat(20_000), 'darwin' as const], + ['a Windows-damaged prompt', 'say "hi"', 'win32' as const] + ])( + 'pastes %s for an agent not measured reading a launch file, and waits for that paste', + async (_label, prompt, launchPlatform) => { + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + const result = launchAgentInNewTab({ + agent: 'gemini', + worktreeId: 'wt-1', + prompt, + promptDelivery: 'submit-after-ready', + launchPlatform + }) + + await expect(result?.promptDeliveryResult).resolves.toEqual({ + delivered: true, + failureNotified: false + }) + expect(mockPasteDraftWhenAgentReady.mock.calls[0]?.[0]).toMatchObject({ + content: prompt, + submit: true + }) + expect(mockWaitForLaunchPromptReceipt).not.toHaveBeenCalled() + expect(queuedStartupCommand(store)).not.toContain(prompt.slice(0, 8)) + expect(queuedStartupPayload(store)?.launchFile).toBeUndefined() + } + ) + it('mirrors an argv-carried draft into the chat composer', async () => { const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') diff --git a/src/renderer/src/lib/agent-launch-prompt-receipt.test.ts b/src/renderer/src/lib/agent-launch-prompt-receipt.test.ts new file mode 100644 index 000000000000..475a77660a6a --- /dev/null +++ b/src/renderer/src/lib/agent-launch-prompt-receipt.test.ts @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +type ReceiptState = { + tabsByWorktree: Record + ptyIdsByTabId: Record + agentStatusByPaneKey: Record + settings: Record +} + +const mocks = vi.hoisted(() => { + let state: ReceiptState = { + tabsByWorktree: {}, + ptyIdsByTabId: {}, + agentStatusByPaneKey: {}, + settings: {} + } + const listeners = new Set<(next: ReceiptState) => void>() + return { + getState: () => state, + setState: (next: Partial) => { + state = { ...state, ...next } + for (const listener of listeners) { + listener(state) + } + }, + subscribe: (listener: (next: ReceiptState) => void) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + reset: () => { + listeners.clear() + state = { tabsByWorktree: {}, ptyIdsByTabId: {}, agentStatusByPaneKey: {}, settings: {} } + }, + readiness: vi.fn(), + waitForAgentReady: vi.fn() + } +}) + +vi.mock('@/store', () => ({ + useAppStore: { getState: mocks.getState, subscribe: mocks.subscribe } +})) +vi.mock('./agent-paste-draft', () => ({ + PTY_SPAWN_TIMEOUT_MS: 8000, + getSettingsForAgentTabRuntimeOwner: () => ({}), + waitForAgentDraftInputReadyOnTab: mocks.readiness +})) +vi.mock('./agent-ready-wait', () => ({ waitForAgentReady: mocks.waitForAgentReady })) + +import { waitForLaunchPromptReceipt } from './agent-launch-prompt-receipt' + +const never = (): Promise => new Promise(() => {}) + +describe('whether a prompt that rode the launch command reached the agent', () => { + beforeEach(() => { + mocks.reset() + mocks.setState({ tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] } }) + mocks.readiness.mockReset().mockImplementation(never) + mocks.waitForAgentReady.mockReset() + }) + + it('is delivered when the agent’s own hook reports a turn on the tab', async () => { + const receipt = waitForLaunchPromptReceipt({ tabId: 'tab-1', agent: 'claude', launchedAt: 100 }) + mocks.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } }) + mocks.setState({ + agentStatusByPaneKey: { 'tab-1:11111111-1111-4111-8111-111111111111': { updatedAt: 150 } } + }) + await expect(receipt).resolves.toBe(true) + }) + + it('ignores a status row older than the launch', async () => { + mocks.setState({ + agentStatusByPaneKey: { 'tab-1:11111111-1111-4111-8111-111111111111': { updatedAt: 50 } } + }) + mocks.readiness.mockResolvedValue(null) + const receipt = waitForLaunchPromptReceipt({ tabId: 'tab-1', agent: 'claude', launchedAt: 100 }) + await expect(receipt).resolves.toBe(false) + }) + + it('is not delivered when the PTY exits before the agent shows any sign of the prompt', async () => { + const receipt = waitForLaunchPromptReceipt({ tabId: 'tab-1', agent: 'claude', launchedAt: 100 }) + mocks.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } }) + mocks.setState({ ptyIdsByTabId: { 'tab-1': [] } }) + await expect(receipt).resolves.toBe(false) + }) + + it('is not delivered when the PTY never spawns, as when the host refused its launch file', async () => { + mocks.readiness.mockResolvedValue(null) + const receipt = waitForLaunchPromptReceipt({ tabId: 'tab-1', agent: 'codex', launchedAt: 100 }) + await expect(receipt).resolves.toBe(false) + }) + + it('is delivered when the agent shows the composer a paste would have waited for', async () => { + mocks.readiness.mockResolvedValue({ ptyId: 'pty-1', ready: true }) + const receipt = waitForLaunchPromptReceipt({ tabId: 'tab-1', agent: 'codex', launchedAt: 100 }) + await expect(receipt).resolves.toBe(true) + }) + + it('reports an unconfirmed delivery when only the agent process was seen', async () => { + mocks.readiness.mockResolvedValue({ ptyId: 'pty-1', ready: false }) + mocks.waitForAgentReady.mockResolvedValue({ ready: true, reason: 'foreground-match' }) + const onUnconfirmedDelivery = vi.fn() + const receipt = waitForLaunchPromptReceipt({ + tabId: 'tab-1', + agent: 'codex', + launchedAt: 100, + onUnconfirmedDelivery + }) + await expect(receipt).resolves.toBe(true) + expect(onUnconfirmedDelivery).toHaveBeenCalledOnce() + }) + + it('is not delivered when the tab closes first', async () => { + const receipt = waitForLaunchPromptReceipt({ tabId: 'tab-1', agent: 'claude', launchedAt: 100 }) + mocks.setState({ tabsByWorktree: { 'wt-1': [] } }) + await expect(receipt).resolves.toBe(false) + }) +}) diff --git a/src/renderer/src/lib/agent-launch-prompt-receipt.ts b/src/renderer/src/lib/agent-launch-prompt-receipt.ts new file mode 100644 index 000000000000..e2ef5c2c00a3 --- /dev/null +++ b/src/renderer/src/lib/agent-launch-prompt-receipt.ts @@ -0,0 +1,98 @@ +import type { TuiAgent } from '../../../shared/tui-agent' +import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' +import { resolveDraftPasteReadyTimeoutMs } from '../../../shared/draft-paste-ready-timeout' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import { useAppStore } from '@/store' +import { waitForAgentReady } from './agent-ready-wait' +import { + getSettingsForAgentTabRuntimeOwner, + PTY_SPAWN_TIMEOUT_MS, + waitForAgentDraftInputReadyOnTab +} from './agent-paste-draft' + +type AppState = ReturnType + +function hookReportedTurnOnTab(state: AppState, tabId: string, launchedAt: number): boolean { + return Object.entries(state.agentStatusByPaneKey).some( + ([paneKey, entry]) => parsePaneKey(paneKey)?.tabId === tabId && entry.updatedAt >= launchedAt + ) +} + +function tabExists(state: AppState, tabId: string): boolean { + return Object.values(state.tabsByWorktree).some((tabs) => tabs?.some((tab) => tab.id === tabId)) +} + +/** + * Whether a prompt that rode an agent's launch command reached the agent. True at the first real + * signal: the agent's own hook reports a turn on the tab, or the agent shows the composer a paste + * would have waited for. False when the PTY never spawns (a refused launch file included), exits + * first, or the tab closes. Never "the tab exists". + */ +export function waitForLaunchPromptReceipt(args: { + tabId: string + agent: TuiAgent + launchedAt: number + /** The agent process was seen, but neither its hook nor its composer. */ + onUnconfirmedDelivery?: () => void +}): Promise { + const { tabId, agent, launchedAt } = args + return new Promise((resolve) => { + let settled = false + let sawPty = false + let unsubscribe: (() => void) | null = null + const finish = (delivered: boolean): void => { + if (settled) { + return + } + settled = true + unsubscribe?.() + resolve(delivered) + } + let scannedStatus: AppState['agentStatusByPaneKey'] | null = null + const observe = (state: AppState): void => { + // Why the identity check: the store writes often and replaces this map only when it changes. + if (state.agentStatusByPaneKey !== scannedStatus) { + scannedStatus = state.agentStatusByPaneKey + if (hookReportedTurnOnTab(state, tabId, launchedAt)) { + finish(true) + return + } + } + if ((state.ptyIdsByTabId[tabId]?.length ?? 0) > 0) { + sawPty = true + } else if (sawPty || !tabExists(state, tabId)) { + finish(false) + } + } + unsubscribe = useAppStore.subscribe(observe) + observe(useAppStore.getState()) + const config = TUI_AGENT_CONFIG[agent] + void waitForAgentDraftInputReadyOnTab({ + tabId, + spawnTimeoutMs: PTY_SPAWN_TIMEOUT_MS, + readinessTimeoutMs: resolveDraftPasteReadyTimeoutMs(agent), + readySignal: config.draftPasteReadySignal ?? 'render-quiet-after-bracketed-paste', + settings: getSettingsForAgentTabRuntimeOwner(tabId) + }).then( + async (readiness) => { + if (settled) { + return + } + if (!readiness) { + finish(false) + return + } + if (readiness.ready) { + finish(true) + return + } + const fallback = await waitForAgentReady(tabId, config.expectedProcess, { timeoutMs: 1000 }) + if (fallback.ready && !settled) { + args.onUnconfirmedDelivery?.() + } + finish(fallback.ready) + }, + () => finish(false) + ) + }) +} diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index f0df326b3160..510ebb1aae0a 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -42,7 +42,7 @@ export const POST_PASTE_SUBMIT_DELAY_MS = AGENT_PROMPT_POST_PASTE_SUBMIT_DELAY_M // hasn't appeared in 8s means the launch itself failed — waiting the (longer) // composer budget on top would only delay that verdict. Keeping them distinct // also stops one slow step from spending the other's budget (STA-3367). -const PTY_SPAWN_TIMEOUT_MS = 8000 +export const PTY_SPAWN_TIMEOUT_MS = 8000 export function getSettingsForAgentTabRuntimeOwner( tabId: string @@ -273,7 +273,7 @@ async function sendBracketedPasteToAgent(args: { } } -function waitForAgentDraftInputReadyOnTab(args: { +export function waitForAgentDraftInputReadyOnTab(args: { tabId: string spawnTimeoutMs: number readinessTimeoutMs: number diff --git a/src/renderer/src/lib/agent-startup-delayed-delivery.ts b/src/renderer/src/lib/agent-startup-delayed-delivery.ts index 2e38fad41d19..3a1cde634f34 100644 --- a/src/renderer/src/lib/agent-startup-delayed-delivery.ts +++ b/src/renderer/src/lib/agent-startup-delayed-delivery.ts @@ -1,5 +1,5 @@ import { useAppStore } from '@/store' -import type { AgentStartupPlan } from '@/lib/tui-agent-startup' +import type { ComposerAgentStartupPlan } from '@/lib/composer-agent-startup-plan' import { parsePaneKey } from '../../../shared/stable-pane-id' import { agentStartupDeliveryKey as deliveryKey, @@ -15,8 +15,8 @@ type PendingAgentStartupDelivery = { worktreeId: string tabId: string launchToken: string - startup: AgentStartupPlan - deliver: (tabId: string, ptyId: string, startup: AgentStartupPlan) => Promise + startup: ComposerAgentStartupPlan + deliver: (tabId: string, ptyId: string, startup: ComposerAgentStartupPlan) => Promise } const pendingAgentStartupDeliveries = new Map() diff --git a/src/renderer/src/lib/composer-agent-startup-plan.ts b/src/renderer/src/lib/composer-agent-startup-plan.ts new file mode 100644 index 000000000000..18cfaa41df60 --- /dev/null +++ b/src/renderer/src/lib/composer-agent-startup-plan.ts @@ -0,0 +1,31 @@ +import type { AgentStartupPlan } from '@/lib/tui-agent-startup' +import type { LaunchPromptPlan } from '../../../shared/launch-prompt-carry' +import type { LaunchFile } from '../../../shared/launch-prompt-file' + +/** What a created workspace launches, and the submitted prompt it still owes the agent, if any. */ +export type ComposerAgentStartupPlan = AgentStartupPlan & { + launchFile?: LaunchFile + /** The submitted prompt the launch carries; handed back to copy if the host refuses the spawn. */ + launchPrompt?: string + /** A submitted prompt the launch could not carry; pasted once the agent is ready. */ + pastePromptAfterReady?: string +} + +export function composerAgentStartupPlan( + planned: LaunchPromptPlan | null, + prompt: string +): ComposerAgentStartupPlan | null { + if (!planned) { + return null + } + switch (planned.carry) { + case 'none': + return planned.plan + case 'on-line': + return { ...planned.plan, launchPrompt: prompt.trim() } + case 'launch-file': + return { ...planned.plan, launchFile: planned.launchFile, launchPrompt: prompt.trim() } + case 'paste-after-ready': + return { ...planned.cleanPlan, pastePromptAfterReady: planned.text } + } +} diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index 76e776c6be53..8ef6c3fe5561 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -1,6 +1,7 @@ import { useAppStore } from '@/store' -import { launchHostWritesLaunchFile } from '@/lib/launch-file-host' -import { buildAgentStartupPlan } from '@/lib/tui-agent-startup' +import { launchHostIsPaired } from '@/lib/launch-file-host' +import { planLaunchPrompt, type AgentStartupPlan } from '@/lib/tui-agent-startup' +import type { LaunchFile } from '../../../shared/launch-prompt-file' import type { LaunchAgentBackgroundSessionArgs, LaunchAgentBackgroundSessionResult @@ -75,23 +76,38 @@ export async function launchAgentBackgroundSession( // Route by the worktree's owner host, not the focused runtime. const ownerSettings = getSettingsForWorktreeRuntimeOwner(store, worktreeId) const runtimeTarget = getActiveRuntimeTarget(ownerSettings) - const startupPlan = buildAgentStartupPlan({ + const planned = planLaunchPrompt({ agent, - prompt: hasPrompt && !isFollowupPath ? trimmedPrompt : '', + prompt: trimmedPrompt, cmdOverrides, agentArgs, agentEnv, platform: launchPlatform, shell: startupShell, isRemote, - hostWritesLaunchFile: launchHostWritesLaunchFile(ownerSettings), - allowEmptyPromptLaunch: !hasPrompt || isFollowupPath + launchHostIsPaired: launchHostIsPaired(ownerSettings?.activeRuntimeEnvironmentId) }) - if (!startupPlan) { + if (!planned) { return null } - // A prompt the host could not be sent in a launch file waits for the paste, as a stdin agent's does. - const promptLeftForPaste = hasPrompt && !isFollowupPath ? startupPlan.followupPrompt : null + let startupPlan: AgentStartupPlan + let launchFile: LaunchFile | undefined + // An argv agent's prompt the line could not carry waits for the paste, as a stdin agent's does. + let promptLeftForPaste: string | null = null + switch (planned.carry) { + case 'none': + case 'on-line': + startupPlan = planned.plan + break + case 'launch-file': + startupPlan = planned.plan + launchFile = planned.launchFile + break + case 'paste-after-ready': + startupPlan = planned.cleanPlan + promptLeftForPaste = isFollowupPath ? null : planned.text + break + } let pasteDraftAfterLaunch = hasPrompt && isFollowupPath ? trimmedPrompt : promptLeftForPaste // A hidden run tab must never be store-visible without its PTY (#2989). @@ -192,7 +208,7 @@ export async function launchAgentBackgroundSession( ? { startupCommandDelivery: startupPlan.startupCommandDelivery } : {}), env: paneEnv, - ...(startupPlan.launchFile ? { launchFile: startupPlan.launchFile } : {}), + ...(launchFile ? { launchFile } : {}), launchConfig: startupPlan.launchConfig, launchToken, launchAgent: agent, diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index 1352314224ca..d44a7bb67397 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -14,6 +14,7 @@ const mockMarkNativeChatLaunchPromptFailed = vi.fn() const mockTrack = vi.fn() const mockToastMessage = vi.fn() const mockWaitForAgentReady = vi.fn() +const mockWaitForLaunchPromptReceipt = vi.fn() const store = { activeRepoId: 'repo-1', @@ -117,6 +118,10 @@ vi.mock('@/lib/agent-ready-wait', () => ({ waitForAgentReady: mockWaitForAgentReady })) +vi.mock('@/lib/agent-launch-prompt-receipt', () => ({ + waitForLaunchPromptReceipt: mockWaitForLaunchPromptReceipt +})) + vi.mock('@/lib/telemetry', () => ({ track: mockTrack, tuiAgentToAgentKind: (agent: string) => agent @@ -137,6 +142,7 @@ vi.mock('@/runtime/web-runtime-session', () => ({ describe('launchAgentInNewTab', () => { beforeEach(() => { vi.clearAllMocks() + mockWaitForLaunchPromptReceipt.mockResolvedValue(true) mockIsWebRuntimeSessionActive.mockReturnValue(false) mockCreateWebRuntimeSessionTerminal.mockResolvedValue({ status: 'created' }) mockCreateWebRuntimeAgentSessionTerminalWithLaunchDraft.mockResolvedValue({ status: 'created' }) @@ -699,7 +705,14 @@ describe('launchAgentInNewTab', () => { promptDelivery: 'submit-after-ready' }) - expect(result?.promptDeliveryResult).toBeUndefined() + // Why: the launch line carried it, so delivery is the agent's receipt, never the tab existing. + await expect(result?.promptDeliveryResult).resolves.toEqual({ + delivered: true, + failureNotified: false + }) + expect(mockWaitForLaunchPromptReceipt).toHaveBeenCalledWith( + expect.objectContaining({ tabId: 'tab-1', agent: 'command-code' }) + ) expect(mockPasteDraftWhenAgentReady).not.toHaveBeenCalled() // Why: Command Code has no prompt-submit hook, so the spawn seeds working from this prompt. expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( @@ -839,14 +852,36 @@ describe('launchAgentInNewTab', () => { promptDelivery: 'submit-after-ready' }) - expect(result?.promptDeliveryResult).toBeUndefined() + await expect(result?.promptDeliveryResult).resolves.toEqual({ + delivered: true, + failureNotified: false + }) expect(mockPasteDraftWhenAgentReady).not.toHaveBeenCalled() const queued = mockQueueTabStartupCommand.mock.calls[0]?.[1] + // Handed back to copy if the host refuses to write the file. + expect(queued?.launchPrompt).toBe(prompt) expect(queued?.launchFile).toMatchObject({ content: prompt, sensitive: false }) expect(queued?.command).toContain(queued?.launchFile?.placeholder) expect(queued?.command).not.toContain('xxxx') }) + it('reports a carried prompt undelivered when the agent never received it', async () => { + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + mockWaitForLaunchPromptReceipt.mockResolvedValue(false) + const onPromptDelivered = vi.fn() + + const result = launchAgentInNewTab({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'resolve these threads', + promptDelivery: 'submit-after-ready', + onPromptDelivered + }) + + await expect(result?.promptDeliveryResult).resolves.toMatchObject({ delivered: false }) + expect(onPromptDelivered).not.toHaveBeenCalled() + }) + it('queues per-launch CLI arguments ahead of the generated prompt on argv', async () => { const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 208c51a9e455..096a2241d5c1 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -3,12 +3,8 @@ import type { AgentStartupPlan } from '@/lib/tui-agent-startup' import { planLaunchAgentStartupPrompt } from '@/lib/launch-agent-startup-prompt-plan' import { persistAgentLaunchTabOrder } from '@/lib/launch-agent-tab-order' import { tuiAgentToAgentKind } from '@/lib/telemetry' -import { createPasteReadinessTimeoutNotice } from '@/lib/launch-agent-paste-timeout-notice' -import { - deliverLaunchPromptToAgentTab, - seedNativeChatLaunchDraftForAgentTab, - seedNativeChatLaunchPromptForAgentTab -} from '@/lib/agent-launch-prompt-delivery' +import { launchHostIsPaired } from '@/lib/launch-file-host' +import { deliverNewTabLaunchPrompt } from '@/lib/launch-agent-new-tab-prompt-delivery' import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mode' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' @@ -157,14 +153,14 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent sessionOptions: resolveInitialNativeChatSessionOptions(store.settings, initialViewModeOptions) } const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(store, worktreeId) - const launchesOnPairedHost = isWebRuntimeSessionActive(runtimeEnvironmentId) + const hostPublishesTab = isWebRuntimeSessionActive(runtimeEnvironmentId) const { startupPlan, launchFile, pasteDraftAfterLaunch, submitPastedPrompt } = planLaunchAgentStartupPrompt({ base: startupPlanBase, prompt: trimmedPrompt, promptDelivery, isFollowupPath, - launchesOnPairedHost + launchHostIsPaired: launchHostIsPaired(runtimeEnvironmentId) }) let promptDeliveryResult: Promise<{ delivered: boolean; failureNotified: boolean }> | undefined @@ -172,7 +168,7 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent return null } - if (launchesOnPairedHost) { + if (hostPublishesTab) { if (beforeSurfaceOpen?.({ kind: 'host-published' }) === false) { return null } @@ -246,6 +242,7 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent if (beforeSurfaceOpen?.({ kind: 'local-terminal' }) === false) { return null } + const launchedAt = Date.now() // Why: queue startup BEFORE TerminalPane mounts — it snapshots pendingStartupByTabId in useState on first render. // Why: followup path pastes an unsubmitted draft, so gate the initial chat view like a draft launch, not auto-submit. const tab = store.createTab(worktreeId, groupId, undefined, { @@ -270,6 +267,9 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent ? { startupCommandDelivery: startupPlan.startupCommandDelivery } : {}), ...(launchFile ? { launchFile } : {}), + ...(hasPrompt && pasteDraftAfterLaunch === null && promptDelivery !== 'draft' + ? { launchPrompt: trimmedPrompt } + : {}), // Why: Command Code has no prompt-submit hook, so a prompt its launch line submits seeds working. ...(agent === 'command-code' && hasPrompt && @@ -283,45 +283,20 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent request_kind: 'new' } }) - // Why: fire-and-forget the paste-after-ready delivery so callers keep the synchronous { tabId, startupPlan } signature. - // Why: safe to call unconditionally — the helper short-circuits (no paste) for native-prefill agents already holding the draft. - // Why: no paste runs, so no paste seeds the chat's copy: a draft rode in on argv (Claude --prefill - // etc.), and a submitted prompt rode the launch line. Not with a launch file: the transcript then - // shows the pointer sentence, which would never prune this copy. - if (hasPrompt && pasteDraftAfterLaunch === null && !launchFile) { - if (promptDelivery === 'draft') { - seedNativeChatLaunchDraftForAgentTab({ tabId: tab.id, agent, text: trimmedPrompt }) - } else if (promptDelivery === 'submit-after-ready') { - seedNativeChatLaunchPromptForAgentTab({ tabId: tab.id, agent, text: trimmedPrompt }) - } - } - if (pasteDraftAfterLaunch !== null) { - const timeoutNotice = createPasteReadinessTimeoutNotice({ - worktreeId, - tabId: tab.id, - agent, - submitted: submitPastedPrompt, - content: pasteDraftAfterLaunch - }) - const deliveryPromise = deliverLaunchPromptToAgentTab({ - tabId: tab.id, - content: pasteDraftAfterLaunch, - agent, - submit: submitPastedPrompt, - forcePaste: true, - onTimeout: timeoutNotice.onTimeout, - ...(onPromptDeliveryUnconfirmed ? { onUnconfirmedDelivery: onPromptDeliveryUnconfirmed } : {}) - }).then((delivered) => timeoutNotice.settle(delivered, onPromptDelivered)) - if (promptDelivery === 'submit-after-ready') { - promptDeliveryResult = deliveryPromise - } else { - void deliveryPromise.catch((error) => - console.error('Prompt delivery failed after launch', error) - ) - } - } else if (hasPrompt) { - onPromptDelivered?.() - } + // Why: fire-and-forget the delivery so callers keep the synchronous { tabId, startupPlan } signature. + promptDeliveryResult = deliverNewTabLaunchPrompt({ + worktreeId, + tabId: tab.id, + agent, + prompt: trimmedPrompt, + promptDelivery, + pasteDraftAfterLaunch, + submitPastedPrompt, + promptInLaunchFile: launchFile !== undefined, + launchedAt, + ...(onPromptDelivered ? { onPromptDelivered } : {}), + ...(onPromptDeliveryUnconfirmed ? { onPromptDeliveryUnconfirmed } : {}) + }) // Why: without setActiveTabType('terminal') an activated launch can stay hidden behind an editor. // Scoped to the launch's worktree so a floating or background launch leaves the main window's tab alone. diff --git a/src/renderer/src/lib/launch-agent-new-tab-prompt-delivery.ts b/src/renderer/src/lib/launch-agent-new-tab-prompt-delivery.ts new file mode 100644 index 000000000000..4bb2e43ab664 --- /dev/null +++ b/src/renderer/src/lib/launch-agent-new-tab-prompt-delivery.ts @@ -0,0 +1,83 @@ +import { createPasteReadinessTimeoutNotice } from '@/lib/launch-agent-paste-timeout-notice' +import { waitForLaunchPromptReceipt } from '@/lib/agent-launch-prompt-receipt' +import { + deliverLaunchPromptToAgentTab, + seedNativeChatLaunchDraftForAgentTab, + seedNativeChatLaunchPromptForAgentTab +} from '@/lib/agent-launch-prompt-delivery' +import type { TuiAgent } from '../../../shared/tui-agent' + +export type NewTabPromptDeliveryResult = Promise<{ delivered: boolean; failureNotified: boolean }> + +/** + * How a new agent tab's prompt is delivered, and when it counts as delivered: a paste once the agent + * is ready, or for a prompt the launch carried, the agent's receipt of it. Never the tab existing. + * Returns the result a `submit-after-ready` caller waits on; other callers fire and forget. + */ +export function deliverNewTabLaunchPrompt(args: { + worktreeId: string + tabId: string + agent: TuiAgent + /** Trimmed; empty for a launch with no prompt. */ + prompt: string + promptDelivery: 'auto-submit' | 'draft' | 'submit-after-ready' + pasteDraftAfterLaunch: string | null + submitPastedPrompt: boolean + promptInLaunchFile: boolean + launchedAt: number + onPromptDelivered?: () => void + onPromptDeliveryUnconfirmed?: () => void +}): NewTabPromptDeliveryResult | undefined { + const { tabId, agent, prompt, promptDelivery, pasteDraftAfterLaunch } = args + if (!prompt) { + return undefined + } + const unconfirmed = args.onPromptDeliveryUnconfirmed + ? { onUnconfirmedDelivery: args.onPromptDeliveryUnconfirmed } + : {} + // Why: no paste runs, so no paste seeds the chat's copy: a draft rode in on argv (Claude --prefill + // etc.), and a submitted prompt rode the launch line. Not with a launch file: the transcript then + // shows the pointer sentence, which would never prune this copy. + if (pasteDraftAfterLaunch === null && !args.promptInLaunchFile) { + if (promptDelivery === 'draft') { + seedNativeChatLaunchDraftForAgentTab({ tabId, agent, text: prompt }) + } else if (promptDelivery === 'submit-after-ready') { + seedNativeChatLaunchPromptForAgentTab({ tabId, agent, text: prompt }) + } + } + if (pasteDraftAfterLaunch === null && promptDelivery === 'draft') { + // A draft rode the launch command as an editable prefill; there is no turn to wait for. + args.onPromptDelivered?.() + return undefined + } + const content = pasteDraftAfterLaunch ?? prompt + const submitted = pasteDraftAfterLaunch === null || args.submitPastedPrompt + const timeoutNotice = createPasteReadinessTimeoutNotice({ + worktreeId: args.worktreeId, + tabId, + agent, + submitted, + content + }) + // Why the receipt for a carried prompt: callers post replies and resolve threads on this result. + const delivery = + pasteDraftAfterLaunch === null + ? waitForLaunchPromptReceipt({ tabId, agent, launchedAt: args.launchedAt, ...unconfirmed }) + : deliverLaunchPromptToAgentTab({ + tabId, + content, + agent, + submit: submitted, + forcePaste: true, + onTimeout: timeoutNotice.onTimeout, + ...unconfirmed + }) + const result = delivery.then((delivered) => + timeoutNotice.settle(delivered, args.onPromptDelivered) + ) + if (promptDelivery === 'submit-after-ready') { + return result + } + void result.catch((error) => console.error('Prompt delivery failed after launch', error)) + return undefined +} diff --git a/src/renderer/src/lib/launch-agent-startup-prompt-plan.ts b/src/renderer/src/lib/launch-agent-startup-prompt-plan.ts index cd50ddff2914..f82379f99a4e 100644 --- a/src/renderer/src/lib/launch-agent-startup-prompt-plan.ts +++ b/src/renderer/src/lib/launch-agent-startup-prompt-plan.ts @@ -1,16 +1,12 @@ import { buildAgentDraftLaunchPlan, buildAgentStartupPlan, - type AgentStartupPlan + planLaunchPrompt, + type AgentStartupPlan, + type AgentStartupPlanInputs } from '@/lib/tui-agent-startup' -import { planStartupWithLaunchPrompt } from '../../../shared/startup-line-prompt-carry' import type { LaunchFile } from '../../../shared/launch-prompt-file' -type StartupPlanBase = Omit< - Parameters[0], - 'prompt' | 'allowEmptyPromptLaunch' -> - export type LaunchAgentStartupPromptPlan = { startupPlan: AgentStartupPlan | null /** Written by the host before it types the launch line naming it. */ @@ -21,37 +17,41 @@ export type LaunchAgentStartupPromptPlan = { } /** - * Decide how a new-tab launch delivers its prompt: agents whose CLI takes one get it on the launch - * command, while agents that take text only after start launch clean and paste once ready. + * Decide how a new-tab launch delivers its prompt: on the agent's launch command or in a launch file + * where `carryLaunchPrompt` says it can ride, else pasted once the agent is ready. */ export function planLaunchAgentStartupPrompt(args: { - base: StartupPlanBase + base: AgentStartupPlanInputs /** Already trimmed. */ prompt: string promptDelivery: 'auto-submit' | 'draft' | 'submit-after-ready' isFollowupPath: boolean - /** A paired host of unknown version may neither stage a long line nor write a launch file. */ - launchesOnPairedHost: boolean + /** See `launchHostIsPaired`. */ + launchHostIsPaired: boolean }): LaunchAgentStartupPromptPlan { const { base, prompt, promptDelivery, isFollowupPath } = args - const hasPrompt = prompt.length > 0 - const pasteAfterReady = (submit: boolean): LaunchAgentStartupPromptPlan => ({ - startupPlan: buildAgentStartupPlan({ ...base, prompt: '', allowEmptyPromptLaunch: true }), - pasteDraftAfterLaunch: prompt, + const pasteAfterReady = ( + startupPlan: AgentStartupPlan | null, + text: string, + submit: boolean + ): LaunchAgentStartupPromptPlan => ({ + startupPlan, + pasteDraftAfterLaunch: text, submitPastedPrompt: submit }) + const cleanPlan = (): AgentStartupPlan | null => + buildAgentStartupPlan({ ...base, allowEmptyPromptLaunch: true }) - if (hasPrompt && promptDelivery === 'draft') { + if (prompt.length > 0 && promptDelivery === 'draft') { const draftLaunchPlan = buildAgentDraftLaunchPlan({ ...base, draft: prompt }) if (!draftLaunchPlan) { - return pasteAfterReady(false) + return pasteAfterReady(cleanPlan(), prompt, false) } return { startupPlan: { agent: draftLaunchPlan.agent, launchCommand: draftLaunchPlan.launchCommand, expectedProcess: draftLaunchPlan.expectedProcess, - followupPrompt: null, launchConfig: draftLaunchPlan.launchConfig, ...(draftLaunchPlan.sessionOptions ? { sessionOptions: draftLaunchPlan.sessionOptions } @@ -65,29 +65,35 @@ export function planLaunchAgentStartupPrompt(args: { submitPastedPrompt: false } } - // Temporary, until paired hosts advertise staging: keep their paste after readiness. - if ( - hasPrompt && - (isFollowupPath || (args.launchesOnPairedHost && promptDelivery === 'submit-after-ready')) - ) { - return pasteAfterReady(promptDelivery === 'submit-after-ready') + if (prompt.length > 0 && isFollowupPath) { + return pasteAfterReady(cleanPlan(), prompt, promptDelivery === 'submit-after-ready') + } + // Why: a caller that waits for delivery gets a verdict from a paste; this client cannot observe a + // paired host's carried prompt reach its agent. Temporary, until paired hosts report that receipt. + if (prompt.length > 0 && args.launchHostIsPaired && promptDelivery === 'submit-after-ready') { + return pasteAfterReady(cleanPlan(), prompt, true) } - if (!hasPrompt || args.launchesOnPairedHost) { - const startupPlan = buildAgentStartupPlan({ - ...base, - prompt, - allowEmptyPromptLaunch: !hasPrompt - }) - // A paired host is sent a command, never a launch file, so a prompt that needs one is pasted. - return startupPlan?.launchFile - ? pasteAfterReady(true) - : { startupPlan, pasteDraftAfterLaunch: null, submitPastedPrompt: false } + const planned = planLaunchPrompt({ + ...base, + prompt, + launchHostIsPaired: args.launchHostIsPaired + }) + if (!planned) { + return { startupPlan: null, pasteDraftAfterLaunch: null, submitPastedPrompt: false } } - const carried = planStartupWithLaunchPrompt(base, prompt) - return { - startupPlan: carried.plan, - ...(carried.launchFile ? { launchFile: carried.launchFile } : {}), - pasteDraftAfterLaunch: null, - submitPastedPrompt: false + switch (planned.carry) { + case 'none': + case 'on-line': + return { startupPlan: planned.plan, pasteDraftAfterLaunch: null, submitPastedPrompt: false } + case 'launch-file': + return { + startupPlan: planned.plan, + launchFile: planned.launchFile, + pasteDraftAfterLaunch: null, + submitPastedPrompt: false + } + case 'paste-after-ready': + // Every non-draft new-tab prompt is submitted; a draft returned above. + return pasteAfterReady(planned.cleanPlan, planned.text, true) } } diff --git a/src/renderer/src/lib/launch-file-host.test.ts b/src/renderer/src/lib/launch-file-host.test.ts index c9fe3fa233a1..299c4672db54 100644 --- a/src/renderer/src/lib/launch-file-host.test.ts +++ b/src/renderer/src/lib/launch-file-host.test.ts @@ -1,17 +1,17 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { launchHostWritesLaunchFile } from './launch-file-host' +import { launchHostIsPaired } from './launch-file-host' import { buildQuickComposerStartup } from '@/hooks/composer-state/quick-startup-plan' -describe('whether a launch host writes its launch file', () => { +describe('whether a launch lands on a paired host', () => { afterEach(() => { vi.unstubAllGlobals() }) - it('is true for this machine and false for a paired host or a web client', () => { - expect(launchHostWritesLaunchFile({ activeRuntimeEnvironmentId: null })).toBe(true) - expect(launchHostWritesLaunchFile({ activeRuntimeEnvironmentId: 'env-1' })).toBe(false) + it('is false for this machine and true for another Orca or a web client', () => { + expect(launchHostIsPaired(null)).toBe(false) + expect(launchHostIsPaired('env-1')).toBe(true) vi.stubGlobal('window', { __ORCA_WEB_CLIENT__: true }) - expect(launchHostWritesLaunchFile({ activeRuntimeEnvironmentId: null })).toBe(false) + expect(launchHostIsPaired(null)).toBe(true) }) }) @@ -26,11 +26,11 @@ describe('the quick composer on a paired host', () => { platform: 'win32', shell: 'powershell', isRemote: false, - hostWritesLaunchFile: false, + launchHostIsPaired: true, telemetrySource: 'sidebar' }) expect(startup.backendStartup).toBeUndefined() expect(startup.startupPlan?.launchFile).toBeUndefined() - expect(startup.startupPlan?.followupPrompt).toBe('fix the build\nthen run the tests') + expect(startup.startupPlan?.pastePromptAfterReady).toBe('fix the build\nthen run the tests') }) }) diff --git a/src/renderer/src/lib/launch-file-host.ts b/src/renderer/src/lib/launch-file-host.ts index 087e0e1402b6..cb49325168c9 100644 --- a/src/renderer/src/lib/launch-file-host.ts +++ b/src/renderer/src/lib/launch-file-host.ts @@ -1,14 +1,12 @@ -import type { GlobalSettings } from '../../../shared/global-settings-types' -import { getActiveRuntimeTarget } from '@/runtime/runtime-client-target' +import { isWebRuntimeSessionActive } from '@/runtime/web-runtime-session-environment' import { isWebClientLocation } from './web-client-location' /** - * Whether the host a launch lands on writes the launch file its line names. A paired host (another - * Orca this client drives) is sent a command and never the file, so a prompt that would need one is - * pasted after the agent is ready instead. Temporary, until paired hosts are sent the prompt itself. + * Whether a launch lands on a paired Orca: another Orca this client drives, which may be an older + * build that neither stages a long line nor writes a launch file. `carryLaunchPrompt` then keeps + * the line to the typed budget and pastes the rest after the agent is ready. Temporary, until paired + * hosts advertise both. */ -export function launchHostWritesLaunchFile( - ownerSettings: Pick | null | undefined -): boolean { - return !isWebClientLocation() && getActiveRuntimeTarget(ownerSettings).kind === 'local' +export function launchHostIsPaired(runtimeEnvironmentId: string | null | undefined): boolean { + return isWebClientLocation() || isWebRuntimeSessionActive(runtimeEnvironmentId) } diff --git a/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts b/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts index 639bce4be6f6..8dee872c816f 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts @@ -18,7 +18,7 @@ export function buildDirectWorkItemStartup(args: { launchConnectionId: string | null worktreePath: string repoProjectRuntime?: Parameters[0]['projectRuntime'] - launchesOnPairedHost: boolean + launchHostIsPaired: boolean }): ReturnType { const launchPlatform = args.launchPlatform ?? @@ -39,7 +39,7 @@ export function buildDirectWorkItemStartup(args: { ), // Why: SSH hosts run the plain `orca` shim, so the Linux-only `orca-ide` rename is not applied. isRemote: typeof args.launchConnectionId === 'string', - launchesOnPairedHost: args.launchesOnPairedHost + launchHostIsPaired: args.launchHostIsPaired }) } diff --git a/src/renderer/src/lib/launch-work-item-direct-agent.test.ts b/src/renderer/src/lib/launch-work-item-direct-agent.test.ts index b5358ec6b3a5..44b38b650663 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent.test.ts @@ -26,7 +26,6 @@ describe('buildDirectWorkItemStartupOpts', () => { agent: 'codex', launchCommand: "codex 'review linked issue'", expectedProcess: 'codex', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} }, startupCommandDelivery: 'shell-ready' } @@ -53,7 +52,6 @@ describe('buildDirectWorkItemStartupOpts', () => { agent: 'claude', launchCommand: "claude --prefill 'https://github.com/o/r/issues/12'", expectedProcess: 'claude', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} } } @@ -85,6 +83,7 @@ const settings = { describe('buildDirectWorkItemAgentStartupPlan', () => { it('omits native-chat preferences when the new workspace opens in terminal mode', () => { const result = buildDirectWorkItemAgentStartupPlan({ + launchHostIsPaired: false, agent: 'codex', draftContent: 'Review issue 42', promptDelivery: 'draft', @@ -99,6 +98,7 @@ describe('buildDirectWorkItemAgentStartupPlan', () => { it('applies native-chat preferences when the new workspace opens in chat', () => { const result = buildDirectWorkItemAgentStartupPlan({ + launchHostIsPaired: false, agent: 'codex', draftContent: 'Review issue 42', promptDelivery: 'draft', @@ -147,6 +147,7 @@ describe('buildDirectWorkItemAgentStartupPlan global arguments fallback', () => it('resolves the global Agents arguments when the launch names none', () => { const result = buildDirectWorkItemAgentStartupPlan({ + launchHostIsPaired: false, agent: 'codex', draftContent: 'Fix the broken checks', promptDelivery: 'draft', @@ -160,6 +161,7 @@ describe('buildDirectWorkItemAgentStartupPlan global arguments fallback', () => it('lets an explicit per-action value win over the global one', () => { const result = buildDirectWorkItemAgentStartupPlan({ + launchHostIsPaired: false, agent: 'codex', agentArgs: '--model gpt-5', draftContent: 'Fix the broken checks', @@ -173,3 +175,38 @@ describe('buildDirectWorkItemAgentStartupPlan global arguments fallback', () => expect(result.startupPlan?.launchCommand).not.toContain('danger-full-access') }) }) + +describe('buildDirectWorkItemAgentStartupPlan submitted prompts', () => { + const submit = (agent: 'claude' | 'gemini', draftContent: string, launchHostIsPaired = false) => + buildDirectWorkItemAgentStartupPlan({ + launchHostIsPaired, + agent, + draftContent, + promptDelivery: 'submit-after-ready', + settings: { ...settings, openAgentTabsInChatByDefault: false }, + launchPlatform: 'darwin', + nativeChatTranscriptIsLocalReadable: true + }) + + // The bug class the explicit carry outcome removes: a plan that exists but does not carry the + // prompt was reported as on the launch command, so no paste ran and the prompt was dropped. + it('leaves a file-sized prompt for the paste for an agent not measured reading the file', () => { + const result = submit('gemini', 'g'.repeat(20_000)) + expect(result.promptOnLaunchCommand).toBe(false) + expect(result.startupPlan?.launchCommand).not.toContain('gggg') + expect(result.launchFile).toBeUndefined() + }) + + it('hands Claude the same prompt in a launch file, with the prompt to copy if refused', () => { + const prompt = 'c'.repeat(20_000) + const result = submit('claude', prompt) + expect(result.promptOnLaunchCommand).toBe(true) + expect(result.launchFile?.content).toBe(prompt) + expect(result.launchPrompt).toBe(prompt) + }) + + it('pastes on a paired host what its line cannot carry typed', () => { + expect(submit('claude', 'fix it', true).promptOnLaunchCommand).toBe(true) + expect(submit('claude', 'fix it\nthen run the tests', true).promptOnLaunchCommand).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/launch-work-item-direct-agent.ts b/src/renderer/src/lib/launch-work-item-direct-agent.ts index 2d3e50cee4b4..cdd32ec8bed4 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent.ts @@ -3,6 +3,7 @@ import { track, tuiAgentToAgentKind } from '@/lib/telemetry' import { buildAgentDraftLaunchPlan, buildAgentStartupPlan, + planLaunchPrompt, type AgentStartupPlan } from '@/lib/tui-agent-startup' import type { AgentStartedTelemetry } from '@/lib/worktree-startup-payload' @@ -18,8 +19,6 @@ import { translate } from '@/i18n/i18n' import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat/native-chat-launch-session-options' import type { PersistedNativeChatSessionOptions } from '../../../shared/native-chat-session-options' import type { LaunchFile } from '../../../shared/launch-prompt-file' -import { planStartupWithLaunchPrompt } from '../../../shared/startup-line-prompt-carry' -import { agentPromptRidesLaunchCommand } from '../../../shared/tui-agent-startup' export function buildDirectWorkItemAgentStartupPlan(args: { agent: TuiAgent | null @@ -42,11 +41,13 @@ export function buildDirectWorkItemAgentStartupPlan(args: { /** Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only * `orca-ide` rename must not be applied for remote launches. */ isRemote?: boolean - /** A paired host of unknown version may neither stage a long line nor write a launch file. */ - launchesOnPairedHost?: boolean + /** See `launchHostIsPaired`. */ + launchHostIsPaired: boolean }): { startupPlan: AgentStartupPlan | null launchFile?: LaunchFile + /** The submitted prompt the launch command carries (on its line or in `launchFile`). */ + launchPrompt?: string /** The launch command carries the content (a native draft or a submitted prompt); no paste runs. */ promptOnLaunchCommand: boolean startupPlanFailed: boolean @@ -76,33 +77,51 @@ export function buildDirectWorkItemAgentStartupPlan(args: { agentEnv: effectiveAgentEnv, sessionOptions } - // Temporary, until paired hosts advertise staging: they keep the paste after readiness. - if ( - args.promptDelivery === 'submit-after-ready' && - !args.launchesOnPairedHost && - agentPromptRidesLaunchCommand(args.agent) - ) { - const carried = planStartupWithLaunchPrompt(planInputs, args.draftContent) - return { - startupPlan: carried.plan, - ...(carried.launchFile ? { launchFile: carried.launchFile } : {}), - promptOnLaunchCommand: carried.plan !== null, - startupPlanFailed: carried.plan === null + if (args.promptDelivery === 'submit-after-ready') { + const planned = planLaunchPrompt({ + ...planInputs, + prompt: args.draftContent, + launchHostIsPaired: args.launchHostIsPaired + }) + if (!planned) { + return { startupPlan: null, promptOnLaunchCommand: false, startupPlanFailed: true } + } + switch (planned.carry) { + case 'none': + return { startupPlan: planned.plan, promptOnLaunchCommand: true, startupPlanFailed: false } + case 'on-line': + return { + startupPlan: planned.plan, + launchPrompt: args.draftContent, + promptOnLaunchCommand: true, + startupPlanFailed: false + } + case 'launch-file': + return { + startupPlan: planned.plan, + launchFile: planned.launchFile, + launchPrompt: args.draftContent, + promptOnLaunchCommand: true, + startupPlanFailed: false + } + case 'paste-after-ready': + return { + startupPlan: planned.cleanPlan, + promptOnLaunchCommand: false, + startupPlanFailed: false + } } } - const draftLaunchPlan = - args.promptDelivery === 'submit-after-ready' - ? null - : buildAgentDraftLaunchPlan({ - agent: args.agent, - draft: args.draftContent, - cmdOverrides: args.settings?.agentCmdOverrides ?? {}, - platform: args.launchPlatform, - isRemote: args.isRemote, - agentArgs: effectiveAgentArgs, - agentEnv: effectiveAgentEnv, - sessionOptions - }) + const draftLaunchPlan = buildAgentDraftLaunchPlan({ + agent: args.agent, + draft: args.draftContent, + cmdOverrides: args.settings?.agentCmdOverrides ?? {}, + platform: args.launchPlatform, + isRemote: args.isRemote, + agentArgs: effectiveAgentArgs, + agentEnv: effectiveAgentEnv, + sessionOptions + }) if (draftLaunchPlan) { return { @@ -110,7 +129,6 @@ export function buildDirectWorkItemAgentStartupPlan(args: { agent: draftLaunchPlan.agent, launchCommand: draftLaunchPlan.launchCommand, expectedProcess: draftLaunchPlan.expectedProcess, - followupPrompt: null, launchConfig: draftLaunchPlan.launchConfig, ...(draftLaunchPlan.sessionOptions ? { sessionOptions: draftLaunchPlan.sessionOptions } @@ -153,7 +171,9 @@ export function buildDirectWorkItemStartupOpts( /** Unsent launch context, for the view-mode decision only. Set it for every * draft launch — a natively-prefilled plan carries no `draftPrompt`. */ launchDraftText?: string, - launchFile?: LaunchFile + /** What the launch command carries: its launch file, and the submitted prompt (see + * `PtyPaneStartup.launchPrompt`). */ + carried: { launchFile?: LaunchFile; launchPrompt?: string } = {} ): { startup?: { command: string @@ -165,6 +185,7 @@ export function buildDirectWorkItemStartupOpts( sessionOptions?: AgentStartupPlan['sessionOptions'] startupCommandDelivery?: StartupCommandDelivery launchFile?: LaunchFile + launchPrompt?: string telemetry?: AgentStartedTelemetry } } { @@ -187,7 +208,8 @@ export function buildDirectWorkItemStartupOpts( ...(plan.startupCommandDelivery ? { startupCommandDelivery: plan.startupCommandDelivery } : {}), - ...(launchFile ? { launchFile } : {}), + ...(carried.launchFile ? { launchFile: carried.launchFile } : {}), + ...(carried.launchPrompt ? { launchPrompt: carried.launchPrompt } : {}), ...(telemetry ? { telemetry } : {}) } } diff --git a/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts b/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts index 5a4a38a40ce8..f19f8632015d 100644 --- a/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts +++ b/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts @@ -4,7 +4,7 @@ import { getConnectionId } from '@/lib/connection-context' import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' -import { isWebRuntimeSessionActive } from '@/runtime/web-runtime-session' +import { launchHostIsPaired } from '@/lib/launch-file-host' import type { LaunchFile } from '../../../shared/launch-prompt-file' import type { AgentSessionLaunchPlan, @@ -21,6 +21,7 @@ export type DirectWorkItemAgentLaunchPreparation = { effectiveAgent: TuiAgent | null startupPlan: ReturnType['startupPlan'] launchFile?: LaunchFile + launchPrompt?: string promptOnLaunchCommand: boolean startupPlanFailed: boolean structuredLaunch: boolean @@ -74,7 +75,7 @@ export async function prepareDirectWorkItemAgentLaunch(args: { // Non-critical: activation still has the explicit startup below. }) } - const { startupPlan, launchFile, promptOnLaunchCommand, startupPlanFailed } = + const { startupPlan, launchFile, launchPrompt, promptOnLaunchCommand, startupPlanFailed } = buildDirectWorkItemStartup({ agent: effectiveAgent, agentArgs: args.agentArgs, @@ -92,7 +93,7 @@ export async function prepareDirectWorkItemAgentLaunch(args: { CLIENT_PLATFORM ) ?? args.repoProjectRuntime) : undefined, - launchesOnPairedHost: isWebRuntimeSessionActive( + launchHostIsPaired: launchHostIsPaired( getRuntimeEnvironmentIdForWorktree(args.latestStore, args.worktreeId) ) }) @@ -115,6 +116,7 @@ export async function prepareDirectWorkItemAgentLaunch(args: { effectiveAgent, startupPlan, ...(launchFile ? { launchFile } : {}), + ...(launchPrompt ? { launchPrompt } : {}), promptOnLaunchCommand, startupPlanFailed, structuredLaunch, diff --git a/src/renderer/src/lib/launch-work-item-direct.test.ts b/src/renderer/src/lib/launch-work-item-direct.test.ts index ee14844e6126..8c4c2cf34486 100644 --- a/src/renderer/src/lib/launch-work-item-direct.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct.test.ts @@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AppState } from '@/store' import type * as TuiAgentSelectionModule from '../../../shared/tui-agent-selection' import type * as TuiAgentStartupModule from '@/lib/tui-agent-startup' -import type * as PromptCarryModule from '../../../shared/startup-line-prompt-carry' import type * as DirectAgentRoutingModule from '@/lib/launch-work-item-direct-agent-routing' const mocks = vi.hoisted(() => ({ @@ -104,17 +103,8 @@ vi.mock('@/lib/tui-agent-startup', async () => { return { ...actual, buildAgentDraftLaunchPlan: vi.fn(actual.buildAgentDraftLaunchPlan), - buildAgentStartupPlan: vi.fn(actual.buildAgentStartupPlan) - } -}) - -vi.mock('../../../shared/startup-line-prompt-carry', async () => { - const actual = await vi.importActual( - '../../../shared/startup-line-prompt-carry' - ) - return { - ...actual, - planStartupWithLaunchPrompt: vi.fn(actual.planStartupWithLaunchPrompt) + buildAgentStartupPlan: vi.fn(actual.buildAgentStartupPlan), + planLaunchPrompt: vi.fn(actual.planLaunchPrompt) } }) @@ -141,9 +131,12 @@ vi.mock('@/lib/launch-work-item-direct-agent-routing', async () => { import { launchWorkItemDirect } from './launch-work-item-direct' import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft' import { beginDirectWorkItemStructuredLaunch } from '@/lib/launch-work-item-direct-agent-routing' -import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agent-startup' +import { + buildAgentDraftLaunchPlan, + buildAgentStartupPlan, + planLaunchPrompt +} from '@/lib/tui-agent-startup' import { pickTuiAgent } from '../../../shared/tui-agent-selection' -import { planStartupWithLaunchPrompt } from '../../../shared/startup-line-prompt-carry' const mockApi = { worktrees: { @@ -600,7 +593,6 @@ describe('launchWorkItemDirect', () => { agent: 'cursor', launchCommand: 'cursor-agent', expectedProcess: 'cursor-agent', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} } }) mocks.store.createWorktree.mockResolvedValue({ @@ -813,12 +805,12 @@ describe('launchWorkItemDirect', () => { }) ).resolves.toBe(true) - expect(planStartupWithLaunchPrompt).toHaveBeenCalledWith( + expect(planLaunchPrompt).toHaveBeenCalledWith( expect.objectContaining({ agent: 'codex', - platform: 'linux' - }), - 'Fix the failing checks.' + platform: 'linux', + prompt: 'Fix the failing checks.' + }) ) }) }) diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index ac4d58a7b587..fe1180d2a994 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -249,7 +249,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom startupPlan, launchSource, promptDelivery === 'draft' ? draftContent : undefined, - launchPreparation.launchFile + launchPreparation )) }) return activationHolder.value !== false diff --git a/src/renderer/src/lib/new-workspace.test.ts b/src/renderer/src/lib/new-workspace.test.ts index 28fab8326ff2..b6d130b6656a 100644 --- a/src/renderer/src/lib/new-workspace.test.ts +++ b/src/renderer/src/lib/new-workspace.test.ts @@ -288,7 +288,7 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { agent: 'aider', launchCommand: 'aider', expectedProcess: 'aider', - followupPrompt: 'fix the spinner', + pastePromptAfterReady: 'fix the spinner', launchConfig: { agentArgs: '', agentEnv: {} } } }) @@ -311,7 +311,7 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { agent: 'aider', launchCommand: 'aider', expectedProcess: 'aider', - followupPrompt: 'fix the spinner', + pastePromptAfterReady: 'fix the spinner', launchConfig: { agentArgs: '', agentEnv: {} } } }) @@ -333,7 +333,7 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { agent: 'aider', launchCommand: 'aider', expectedProcess: 'aider', - followupPrompt: 'fix the spinner', + pastePromptAfterReady: 'fix the spinner', launchConfig: { agentArgs: '', agentEnv: {} } } }) @@ -355,7 +355,7 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { agent: 'claude', launchCommand: 'claude', expectedProcess: 'claude', - followupPrompt: 'fix the spinner', + pastePromptAfterReady: 'fix the spinner', launchConfig: { agentArgs: '', agentEnv: {} } } }) @@ -374,7 +374,7 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { agent: 'aider', launchCommand: 'aider', expectedProcess: 'aider', - followupPrompt: 'fix the spinner', + pastePromptAfterReady: 'fix the spinner', launchConfig: { agentArgs: '', agentEnv: {} } } }) @@ -389,7 +389,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { agent: 'claude', launchCommand: 'claude', expectedProcess: 'claude', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} }, draftPrompt: 'review this before sending' } @@ -416,7 +415,7 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { agent: 'aider', launchCommand: 'aider', expectedProcess: 'aider', - followupPrompt: 'fix the spinner', + pastePromptAfterReady: 'fix the spinner', launchConfig: { agentArgs: '', agentEnv: {} } } }) @@ -432,7 +431,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { agent: 'claude', launchCommand: 'claude', expectedProcess: 'claude', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} }, draftPrompt: 'review this before sending' } @@ -476,7 +474,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { agent: 'codex', launchCommand: 'codex', expectedProcess: 'codex', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} }, draftPrompt: 'Linear context draft' } @@ -506,7 +503,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { agent: 'codex', launchCommand: 'codex', expectedProcess: 'codex', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} }, draftPrompt: 'https://github.com/stablyai/orca/pull/2051' } @@ -563,7 +559,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { agent: 'codex', launchCommand: 'codex', expectedProcess: 'codex', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} }, draftPrompt: 'linked draft' } @@ -645,7 +640,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { agent: 'codex', launchCommand: 'codex', expectedProcess: 'codex', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} }, draftPrompt: 'linked draft' } @@ -684,7 +678,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { agent: 'codex' as const, launchCommand: 'codex', expectedProcess: 'codex', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} }, draftPrompt: 'linked draft', launchToken: 'launch-token-1' @@ -714,7 +707,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { agent: 'codex' as const, launchCommand: 'codex', expectedProcess: 'codex', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} }, draftPrompt: 'linked draft', launchToken: 'launch-token-1' @@ -775,7 +767,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { agent: 'codex', launchCommand: 'codex', expectedProcess: 'codex', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} }, draftPrompt: 'old linked draft', launchToken: 'launch-token-old' @@ -827,7 +818,7 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { agent: 'aider', launchCommand: 'aider', expectedProcess: 'aider', - followupPrompt: 'fix the spinner', + pastePromptAfterReady: 'fix the spinner', launchConfig: { agentArgs: '', agentEnv: {} } } }) diff --git a/src/renderer/src/lib/new-workspace.ts b/src/renderer/src/lib/new-workspace.ts index 8a8d13f26527..e932c32811b6 100644 --- a/src/renderer/src/lib/new-workspace.ts +++ b/src/renderer/src/lib/new-workspace.ts @@ -7,6 +7,7 @@ import { import { sendFollowupPromptWhenAgentReady } from '@/lib/agent-followup-delivery' import { showAgentLaunchPromptNotDeliveredNotice } from '@/lib/agent-launch-prompt-not-delivered-notice' import type { AgentStartupPlan } from '@/lib/tui-agent-startup' +import type { ComposerAgentStartupPlan } from '@/lib/composer-agent-startup-plan' import type { LinkedWorkItemContext } from '@/lib/linked-work-item-context' import { beginAgentStartupDeliveryAttempt, @@ -228,11 +229,11 @@ export function getWorkspaceSeedName(args: { export async function ensureAgentStartupInTerminal(args: { worktreeId: string primaryTabId?: string | null - startup: AgentStartupPlan + startup: ComposerAgentStartupPlan }): Promise { const { worktreeId, primaryTabId, startup } = args const draftPrompt = startup.draftPrompt ?? null - if (startup.followupPrompt === null && draftPrompt === null) { + if (!startup.pastePromptAfterReady && draftPrompt === null) { return } const launchToken = ensureStartupLaunchToken(startup) @@ -280,15 +281,14 @@ export async function ensureAgentStartupInTerminal(args: { async function deliverAgentStartupToTerminal( tabId: string, ptyId: string, - startup: AgentStartupPlan + startup: ComposerAgentStartupPlan ): Promise { const draftPrompt = startup.draftPrompt ?? null const runtimeSettings = getSettingsForAgentTabRuntimeOwner(tabId) - // Why: followupPrompt is the legacy path for stdin-after-start agents - // (aider, goose, etc.) that need their initial prompt typed into the live - // session and submitted. Wait until the agent owns the PTY before writing. - if (startup.followupPrompt) { - const prompt = startup.followupPrompt + // Why: a stdin-after-start agent (aider, goose, etc.) needs its initial prompt typed into the + // live session and submitted. Wait until the agent owns the PTY before writing. + if (startup.pastePromptAfterReady) { + const prompt = startup.pastePromptAfterReady // Why: an argv agent's prompt left for paste (a WSL line too long to type, a paired host) waits // for the agent's composer and lands as one bracketed paste, not raw keys once the process shows. const delivered = agentPromptRidesLaunchCommand(startup.agent) diff --git a/src/renderer/src/lib/pending-worktree-creation.ts b/src/renderer/src/lib/pending-worktree-creation.ts index 1e703df742f8..ef7536292e0e 100644 --- a/src/renderer/src/lib/pending-worktree-creation.ts +++ b/src/renderer/src/lib/pending-worktree-creation.ts @@ -10,7 +10,7 @@ import type { WorkspaceLinkedItem, WorkspaceStatus } from '../../../shared/worktree/types' -import type { AgentStartupPlan } from '@/lib/tui-agent-startup' +import type { ComposerAgentStartupPlan } from '@/lib/composer-agent-startup-plan' import type { AgentStartedTelemetry } from '@/lib/worktree-startup-payload' import type { TaskSourceContext, WorkspaceRunContext } from '../../../shared/task-source-context' import type { AgentLaunchRoute } from '@/lib/agent-launch-routing' @@ -103,7 +103,7 @@ export type WorktreeCreationRequest = { note: string /** Renderer-side launch plan used to seed the first terminal when the backend * did not already spawn it. Null for blank-shell creates. */ - startupPlan: AgentStartupPlan | null + startupPlan: ComposerAgentStartupPlan | null quickPrompt: string /** Launch context delivered only as an unsent TUI-input draft (argv prefill or * startup paste); completion seeds the chat-composer copy from it. */ diff --git a/src/renderer/src/lib/source-control-agent-action-plan.ts b/src/renderer/src/lib/source-control-agent-action-plan.ts index 223b3b879f03..b32d6cbc6697 100644 --- a/src/renderer/src/lib/source-control-agent-action-plan.ts +++ b/src/renderer/src/lib/source-control-agent-action-plan.ts @@ -2,6 +2,7 @@ import { buildAgentDraftLaunchPlan, buildAgentStartupPlan, planAgentCliArgsSuffix, + planLaunchPrompt, type AgentStartupPlan } from '@/lib/tui-agent-startup' import { CLIENT_PLATFORM } from '@/lib/new-workspace' @@ -14,6 +15,7 @@ import type { SessionOptionValue } from '../../../shared/native-chat-session-opt export type SourceControlLaunchPlanDelivery = | 'argv' + | 'argv-launch-file' | 'draft-native' | 'draft-paste' | 'paste-submit' @@ -43,6 +45,8 @@ export function planSourceControlAgentActionLaunch(args: { /** Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only * `orca-ide` rename must not be applied for remote launches. */ isRemote?: boolean + /** See `launchHostIsPaired`. */ + launchHostIsPaired?: boolean }): SourceControlLaunchPlanResult { const agent = args.agent if (!agent) { @@ -133,7 +137,6 @@ export function planSourceControlAgentActionLaunch(args: { agent: draftLaunchPlan.agent, launchCommand: draftLaunchPlan.launchCommand, expectedProcess: draftLaunchPlan.expectedProcess, - followupPrompt: null, launchConfig: draftLaunchPlan.launchConfig, ...(draftLaunchPlan.sessionOptions ? { sessionOptions: draftLaunchPlan.sessionOptions } @@ -172,7 +175,7 @@ export function planSourceControlAgentActionLaunch(args: { }) delivery = 'draft-paste' } else { - startupPlan = buildAgentStartupPlan({ + const planned = planLaunchPrompt({ agent, prompt: trimmedInput, cmdOverrides, @@ -181,9 +184,26 @@ export function planSourceControlAgentActionLaunch(args: { isRemote, agentArgs: args.agentArgs, sessionOptions: args.sessionOptions, - allowEmptyPromptLaunch: false + launchHostIsPaired: args.launchHostIsPaired ?? false }) - delivery = 'argv' + switch (planned?.carry) { + case undefined: + delivery = 'argv' + break + case 'none': + case 'on-line': + startupPlan = planned.plan + delivery = 'argv' + break + case 'launch-file': + startupPlan = planned.plan + delivery = 'argv-launch-file' + break + case 'paste-after-ready': + startupPlan = planned.cleanPlan + delivery = 'paste-submit' + break + } } if (!startupPlan) { @@ -203,7 +223,7 @@ export function planSourceControlAgentActionLaunch(args: { ? 'The command input is prefilled as an editable draft by the agent launch command.' : delivery === 'draft-paste' ? 'The agent starts with no prompt, then Orca pastes the command input as an editable draft after the TUI is ready.' - : startupPlan.launchFile + : delivery === 'argv-launch-file' ? 'The command input goes in a private file the host writes; the launch command tells the agent to read it, submitted as the first turn.' : 'The command input is included in the launch command and submitted as the first turn.' diff --git a/src/renderer/src/lib/tui-agent-startup.test.ts b/src/renderer/src/lib/tui-agent-startup.test.ts index 7ce665544b32..095ac81920cf 100644 --- a/src/renderer/src/lib/tui-agent-startup.test.ts +++ b/src/renderer/src/lib/tui-agent-startup.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from 'vitest' -import { - buildAgentDraftLaunchPlan, - buildAgentStartupPlan, - isShellProcess -} from './tui-agent-startup' +import { planLaunchForTest } from '../../../shared/launch-prompt-plan.test-fixture' +import { buildAgentDraftLaunchPlan, isShellProcess } from './tui-agent-startup' import { resolveTuiAgentLaunchArgs } from '../../../shared/tui-agent-launch-defaults' const emptyLaunchConfig = (agentCommand: string) => ({ @@ -15,7 +12,7 @@ const emptyLaunchConfig = (agentCommand: string) => ({ describe('buildAgentStartupPlan', () => { it('passes Claude prompts as a positional interactive argument', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'claude', prompt: 'Fix the bug', cmdOverrides: {}, @@ -25,14 +22,15 @@ describe('buildAgentStartupPlan', () => { agent: 'claude', launchCommand: "claude 'Fix the bug'", expectedProcess: 'claude', - followupPrompt: null, + carry: 'on-line', + pasteAfterReady: null, launchConfig: emptyLaunchConfig('claude') }) }) it('uses Gemini interactive prompt mode instead of dropping the prompt', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'gemini', prompt: 'Investigate this regression', cmdOverrides: {}, @@ -42,14 +40,15 @@ describe('buildAgentStartupPlan', () => { agent: 'gemini', launchCommand: "gemini --prompt-interactive 'Investigate this regression'", expectedProcess: 'gemini', - followupPrompt: null, + carry: 'on-line', + pasteAfterReady: null, launchConfig: emptyLaunchConfig('gemini') }) }) it('uses Antigravity interactive prompt mode with the agy binary', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'antigravity', prompt: 'Investigate this regression', cmdOverrides: {}, @@ -59,14 +58,15 @@ describe('buildAgentStartupPlan', () => { agent: 'antigravity', launchCommand: "agy --prompt-interactive 'Investigate this regression'", expectedProcess: 'agy', - followupPrompt: null, + carry: 'on-line', + pasteAfterReady: null, launchConfig: emptyLaunchConfig('agy') }) }) it('launches aider first and injects the draft prompt after startup', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'aider', prompt: 'Refactor the parser', cmdOverrides: {}, @@ -76,14 +76,15 @@ describe('buildAgentStartupPlan', () => { agent: 'aider', launchCommand: 'aider', expectedProcess: 'aider', - followupPrompt: 'Refactor the parser', + carry: 'paste-after-ready', + pasteAfterReady: 'Refactor the parser', launchConfig: emptyLaunchConfig('aider') }) }) it('launches Autohand Code first and injects the draft prompt after startup', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'autohand', prompt: 'Add tests for the parser', cmdOverrides: {}, @@ -93,14 +94,15 @@ describe('buildAgentStartupPlan', () => { agent: 'autohand', launchCommand: 'autohand', expectedProcess: 'autohand', - followupPrompt: 'Add tests for the parser', + carry: 'paste-after-ready', + pasteAfterReady: 'Add tests for the parser', launchConfig: emptyLaunchConfig('autohand') }) }) it('launches Ante first and injects the draft prompt after startup', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'ante', prompt: 'Summarize the failing tests', cmdOverrides: {}, @@ -110,14 +112,15 @@ describe('buildAgentStartupPlan', () => { agent: 'ante', launchCommand: 'ante', expectedProcess: 'ante', - followupPrompt: 'Summarize the failing tests', + carry: 'paste-after-ready', + pasteAfterReady: 'Summarize the failing tests', launchConfig: emptyLaunchConfig('ante') }) }) it('passes the prompt to Trae as a positional argv behind a `--` separator', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'trae', prompt: 'Summarize the failing tests', cmdOverrides: {}, @@ -127,7 +130,8 @@ describe('buildAgentStartupPlan', () => { agent: 'trae', launchCommand: "traecli -- 'Summarize the failing tests'", expectedProcess: 'traecli', - followupPrompt: null, + carry: 'on-line', + pasteAfterReady: null, launchConfig: emptyLaunchConfig('traecli') }) }) @@ -135,7 +139,7 @@ describe('buildAgentStartupPlan', () => { // Why: without the separator these dispatch to Trae's `help`/`config` subcommands instead. it('keeps subcommand-shaped Trae prompts as the positional prompt', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'trae', prompt: 'help me name this config', cmdOverrides: {}, @@ -146,7 +150,7 @@ describe('buildAgentStartupPlan', () => { it('delivers the Muse prompt after its composer is ready', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'muse', prompt: 'Summarize the failing tests', cmdOverrides: {}, @@ -156,7 +160,8 @@ describe('buildAgentStartupPlan', () => { agent: 'muse', launchCommand: 'muse --trust-workspace', expectedProcess: 'muse', - followupPrompt: 'Summarize the failing tests', + carry: 'paste-after-ready', + pasteAfterReady: 'Summarize the failing tests', launchConfig: { ...emptyLaunchConfig('muse'), agentCommand: 'muse --trust-workspace' @@ -166,7 +171,7 @@ describe('buildAgentStartupPlan', () => { it('passes the prompt to Prime Agent as a positional argv behind a `--` separator', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'prime-agent', prompt: 'Summarize the failing tests', cmdOverrides: {}, @@ -176,7 +181,8 @@ describe('buildAgentStartupPlan', () => { agent: 'prime-agent', launchCommand: "prime-agent -- 'Summarize the failing tests'", expectedProcess: 'prime-agent', - followupPrompt: null, + carry: 'on-line', + pasteAfterReady: null, launchConfig: emptyLaunchConfig('prime-agent') }) }) @@ -184,7 +190,7 @@ describe('buildAgentStartupPlan', () => { // Why: without the separator these dispatch to Prime Agent's `help`/`agents` subcommands instead. it('keeps subcommand-shaped Prime Agent prompts as the positional prompt', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'prime-agent', prompt: 'help me name this config', cmdOverrides: {}, @@ -195,7 +201,7 @@ describe('buildAgentStartupPlan', () => { it('uses cursor-agent as the actual launch binary', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'cursor', prompt: 'Review this file', cmdOverrides: {}, @@ -205,14 +211,15 @@ describe('buildAgentStartupPlan', () => { agent: 'cursor', launchCommand: "cursor-agent 'Review this file'", expectedProcess: 'cursor-agent', - followupPrompt: null, + carry: 'on-line', + pasteAfterReady: null, launchConfig: emptyLaunchConfig('cursor-agent') }) }) it('applies command overrides without changing the prompt syntax contract', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'droid', prompt: 'Ship the fix', cmdOverrides: { droid: '/opt/factory/bin/droid' }, @@ -222,14 +229,15 @@ describe('buildAgentStartupPlan', () => { agent: 'droid', launchCommand: "/opt/factory/bin/droid 'Ship the fix'", expectedProcess: 'droid', - followupPrompt: null, + carry: 'on-line', + pasteAfterReady: null, launchConfig: emptyLaunchConfig('/opt/factory/bin/droid') }) }) it('passes Copilot prompts with the -i flag for an interactive session', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'copilot', prompt: 'Fix the bug', cmdOverrides: {}, @@ -239,14 +247,15 @@ describe('buildAgentStartupPlan', () => { agent: 'copilot', launchCommand: "copilot -i 'Fix the bug'", expectedProcess: 'copilot', - followupPrompt: null, + carry: 'on-line', + pasteAfterReady: null, launchConfig: emptyLaunchConfig('copilot') }) }) it('launches Grok with the prompt as a positional argv', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'grok', prompt: 'Trace the failing test', cmdOverrides: {}, @@ -256,14 +265,15 @@ describe('buildAgentStartupPlan', () => { agent: 'grok', launchCommand: "grok -- 'Trace the failing test'", expectedProcess: 'grok', - followupPrompt: null, + carry: 'on-line', + pasteAfterReady: null, launchConfig: emptyLaunchConfig('grok') }) }) it('launches Devin first and injects the prompt after startup', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'devin', prompt: 'Trace the failing test', cmdOverrides: {}, @@ -274,7 +284,8 @@ describe('buildAgentStartupPlan', () => { agent: 'devin', launchCommand: "devin '--permission-mode' 'bypass' '--respect-workspace-trust' 'false'", expectedProcess: 'devin', - followupPrompt: 'Trace the failing test', + carry: 'paste-after-ready', + pasteAfterReady: 'Trace the failing test', launchConfig: { agentCommand: "devin '--permission-mode' 'bypass' '--respect-workspace-trust' 'false'", agentArgs: '--permission-mode bypass --respect-workspace-trust false', @@ -285,7 +296,7 @@ describe('buildAgentStartupPlan', () => { it('launches Command Code by its unambiguous binary with a positional prompt', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'command-code', prompt: 'Fix the issue', cmdOverrides: {}, @@ -295,14 +306,15 @@ describe('buildAgentStartupPlan', () => { agent: 'command-code', launchCommand: "command-code --trust 'Fix the issue'", expectedProcess: 'command-code', - followupPrompt: null, + carry: 'on-line', + pasteAfterReady: null, launchConfig: emptyLaunchConfig('command-code --trust') }) }) it('returns null when there is no prompt to inject', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'codex', prompt: ' ', cmdOverrides: {}, diff --git a/src/renderer/src/lib/tui-agent-startup.ts b/src/renderer/src/lib/tui-agent-startup.ts index a61e9003d60f..7b51cdc27f20 100644 --- a/src/renderer/src/lib/tui-agent-startup.ts +++ b/src/renderer/src/lib/tui-agent-startup.ts @@ -3,6 +3,7 @@ export { buildAgentDraftLaunchPlan, buildAgentStartupPlan, planAgentCliArgsSuffix, + planLaunchPrompt, isShellProcess, quoteStartupArg, resolveStartupShell @@ -10,5 +11,7 @@ export { export type { AgentCliArgsPlan, AgentDraftLaunchPlan, - AgentStartupPlan + AgentLaunchPromptArgs, + AgentStartupPlan, + AgentStartupPlanInputs } from '../../../shared/tui-agent-startup' diff --git a/src/renderer/src/lib/worktree-creation-background-mount.test.ts b/src/renderer/src/lib/worktree-creation-background-mount.test.ts index b44e87687be7..955ed1779c85 100644 --- a/src/renderer/src/lib/worktree-creation-background-mount.test.ts +++ b/src/renderer/src/lib/worktree-creation-background-mount.test.ts @@ -40,7 +40,6 @@ function makeAgentRequest(): WorktreeCreationRequest { agent: 'codex', launchCommand: 'codex', expectedProcess: 'codex', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} } }, quickPrompt: '', diff --git a/src/renderer/src/lib/worktree-creation-flow-ready-toast.test.ts b/src/renderer/src/lib/worktree-creation-flow-ready-toast.test.ts index 68af47babfdc..d0de17e06812 100644 --- a/src/renderer/src/lib/worktree-creation-flow-ready-toast.test.ts +++ b/src/renderer/src/lib/worktree-creation-flow-ready-toast.test.ts @@ -206,7 +206,6 @@ describe('a creation that finishes after the user moved on (#9944)', () => { agent: 'claude', launchCommand: 'claude', expectedProcess: 'claude', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} } } }) diff --git a/src/renderer/src/lib/worktree-creation-flow-startup.ts b/src/renderer/src/lib/worktree-creation-flow-startup.ts index 5201719192a9..98698278abc2 100644 --- a/src/renderer/src/lib/worktree-creation-flow-startup.ts +++ b/src/renderer/src/lib/worktree-creation-flow-startup.ts @@ -28,6 +28,7 @@ export function buildWorktreeCreationStartupOpt( ...(request.launchDraftPrompt ? { launchDraftText: request.launchDraftPrompt } : {}), ...(plan.startupCommandDelivery ? { startupCommandDelivery: plan.startupCommandDelivery } : {}), ...(plan.launchFile ? { launchFile: plan.launchFile } : {}), + ...(plan.launchPrompt ? { launchPrompt: plan.launchPrompt } : {}), // Why: command-code shows its prompt in the tab status before the first // hook fires, so the prompt is threaded through here. ...(request.agent === 'command-code' && request.quickPrompt.trim().length > 0 diff --git a/src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts b/src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts index 7a678434a4ea..97db288744f5 100644 --- a/src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts +++ b/src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts @@ -232,7 +232,7 @@ describe('a throw after createWorktree succeeds no longer strands the creation s launchCommand: 'codex', expectedProcess: 'codex', draftPrompt: 'draft context', - followupPrompt: 'follow-up context', + pastePromptAfterReady: 'follow-up context', launchConfig: { agentArgs: '', agentEnv: {} } } }) diff --git a/src/renderer/src/lib/worktree-creation-flow.test.ts b/src/renderer/src/lib/worktree-creation-flow.test.ts index 92dea7e39910..cd1cd6c26663 100644 --- a/src/renderer/src/lib/worktree-creation-flow.test.ts +++ b/src/renderer/src/lib/worktree-creation-flow.test.ts @@ -626,7 +626,6 @@ describe('staged background worktree creation', () => { agent: 'claude', launchCommand: 'claude --prefill x', expectedProcess: 'claude', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} } }, launchDraftPrompt: 'https://github.com/o/r/issues/12' @@ -664,7 +663,6 @@ describe('staged background worktree creation', () => { agent: 'claude', launchCommand: 'claude --prefill x', expectedProcess: 'claude', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} } }, launchDraftPrompt: 'https://github.com/o/r/issues/12' @@ -697,7 +695,6 @@ describe('staged background worktree creation', () => { agent, launchCommand: `${agent} --prefill x`, expectedProcess: agent, - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} } }, launchDraftPrompt: draft @@ -732,7 +729,6 @@ describe('staged background worktree creation', () => { agent: 'claude', launchCommand: "claude --prefill 'https://github.com/o/r/issues/12'", expectedProcess: 'claude', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} } }, launchDraftPrompt: 'https://github.com/o/r/issues/12' @@ -761,7 +757,6 @@ describe('staged background worktree creation', () => { agent: 'claude', launchCommand: 'claude', expectedProcess: 'claude', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} } } }) @@ -789,7 +784,6 @@ describe('staged background worktree creation', () => { agent: 'codex', launchCommand: 'codex', expectedProcess: 'codex', - followupPrompt: null, launchConfig: { agent: 'codex', command: 'codex' }, draftPrompt: 'ship it' } as never diff --git a/src/renderer/src/lib/worktree-startup-payload.ts b/src/renderer/src/lib/worktree-startup-payload.ts index ab3b3b44d5b9..462444643153 100644 --- a/src/renderer/src/lib/worktree-startup-payload.ts +++ b/src/renderer/src/lib/worktree-startup-payload.ts @@ -33,6 +33,8 @@ export type WorktreeStartupPayload = { startupCommandDelivery?: StartupCommandDelivery /** Written by the host before it types `command`, which names it by placeholder. */ launchFile?: LaunchFile + /** The submitted prompt `command` carries; handed back to copy if the host refuses the spawn. */ + launchPrompt?: string initialAgentStatus?: { agent: TuiAgent; prompt: string } sessionOptions?: Record telemetry?: AgentStartedTelemetry diff --git a/src/renderer/src/store/terminals/terminal-actions.ts b/src/renderer/src/store/terminals/terminal-actions.ts index c0448be5ee3a..0b1306ec71ff 100644 --- a/src/renderer/src/store/terminals/terminal-actions.ts +++ b/src/renderer/src/store/terminals/terminal-actions.ts @@ -207,6 +207,7 @@ export type TerminalActions = { delivery?: 'terminal-paste' startupCommandDelivery?: StartupCommandDelivery launchFile?: LaunchFile + launchPrompt?: string env?: Record envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig @@ -234,6 +235,7 @@ export type TerminalActions = { delivery?: 'terminal-paste' startupCommandDelivery?: StartupCommandDelivery launchFile?: LaunchFile + launchPrompt?: string env?: Record envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig diff --git a/src/renderer/src/store/terminals/terminal-state.ts b/src/renderer/src/store/terminals/terminal-state.ts index a91dfbdbd26d..f1b9f0f38bbc 100644 --- a/src/renderer/src/store/terminals/terminal-state.ts +++ b/src/renderer/src/store/terminals/terminal-state.ts @@ -67,6 +67,7 @@ export type TerminalState = { delivery?: 'terminal-paste' startupCommandDelivery?: StartupCommandDelivery launchFile?: LaunchFile + launchPrompt?: string env?: Record envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig diff --git a/src/shared/cmd-startup-arg-quoting.win32.test.ts b/src/shared/cmd-startup-arg-quoting.win32.test.ts index 1e7dd8424b4e..132dbed105fe 100644 --- a/src/shared/cmd-startup-arg-quoting.win32.test.ts +++ b/src/shared/cmd-startup-arg-quoting.win32.test.ts @@ -2,6 +2,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { planLaunchForTest } from './launch-prompt-plan.test-fixture' import { runProcess } from './child-process/run-process' import { WINDOWS_ARGUMENT_CORPUS, @@ -9,7 +10,6 @@ import { } from './child-process/__fixtures__/windows-argument-corpus' import { removeTreeSync } from './windows-transient-lock-removal' import { quoteStartupArg } from './tui-agent-startup-shell' -import { buildAgentStartupPlan } from './tui-agent-startup' import { removeLaunchFile, writeLaunchFile } from './launch-file-writing' /** @@ -64,7 +64,7 @@ describeOnWindows('cmd launch-line prompt quoting', () => { it('never lets a multi-line prompt reach cmd as commands: the line names a launch file', async () => { const marker = join(dir, 'pwned.txt') const prompt = `Fix the build\r\n& echo PWNED> "${marker}"\nthen run the tests` - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'claude', prompt, cmdOverrides: { claude: shim }, @@ -97,7 +97,7 @@ describeOnWindows('cmd launch-line prompt quoting', () => { it('names a launch file under a home with an apostrophe, accents, CJK and `%`', async () => { const home = join(dir, "O'Brien José 张伟 100%") mkdirSync(home) - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'claude', prompt: 'Fix the build\nthen run the tests', cmdOverrides: { claude: shim }, diff --git a/src/shared/freebuff-launch.test.ts b/src/shared/freebuff-launch.test.ts index b58dc4bd0917..2714f36fdbe2 100644 --- a/src/shared/freebuff-launch.test.ts +++ b/src/shared/freebuff-launch.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from './tui-agent-startup' +import { planLaunchForTest } from './launch-prompt-plan.test-fixture' +import { buildAgentDraftLaunchPlan } from './tui-agent-startup' import { isExpectedAgentProcess, recognizeAgentProcess } from './agent-process-recognition' import { pickTuiAgent } from './tui-agent-selection' @@ -8,7 +9,7 @@ describe('Freebuff terminal launches', () => { 'delivers task text through the interactive terminal on %s', (platform) => { const prompt = '--help\nFix "quoted" paths and $HOME handling' - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'freebuff', prompt, cmdOverrides: {}, @@ -17,7 +18,7 @@ describe('Freebuff terminal launches', () => { expect(plan).toMatchObject({ launchCommand: 'freebuff', expectedProcess: 'freebuff', - followupPrompt: prompt + pasteAfterReady: prompt }) expect( buildAgentDraftLaunchPlan({ @@ -32,14 +33,14 @@ describe('Freebuff terminal launches', () => { it('uses the same launch contract on SSH hosts', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'freebuff', prompt: 'Inspect this folder', cmdOverrides: {}, platform: 'linux', isRemote: true }) - ).toMatchObject({ launchCommand: 'freebuff', followupPrompt: 'Inspect this folder' }) + ).toMatchObject({ launchCommand: 'freebuff', pasteAfterReady: 'Inspect this folder' }) }) it.each(['/usr/local/bin/freebuff', 'C:\\tools\\freebuff.exe'])( diff --git a/src/shared/freebuff-startup.test.ts b/src/shared/freebuff-startup.test.ts index 65806790af4a..34616b61eb21 100644 --- a/src/shared/freebuff-startup.test.ts +++ b/src/shared/freebuff-startup.test.ts @@ -23,8 +23,7 @@ describe('Freebuff startup', () => { expect(freebuff).toMatchObject({ launchCommand: 'freebuff --debug', - expectedProcess: 'freebuff', - followupPrompt: null + expectedProcess: 'freebuff' }) expect(codebuff).toMatchObject({ launchCommand: 'codebuff', expectedProcess: 'codebuff' }) }) diff --git a/src/shared/launch-file-directory-grant.test.ts b/src/shared/launch-file-directory-grant.test.ts index bf50b3e03dc5..5b5b1114342a 100644 --- a/src/shared/launch-file-directory-grant.test.ts +++ b/src/shared/launch-file-directory-grant.test.ts @@ -2,18 +2,18 @@ import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node: import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { planLaunchForTest } from './launch-prompt-plan.test-fixture' import { writeLaunchFile } from './launch-file-writing' import { buildLaunchFilePointer, carryInLaunchFile, launchFileDirectoryPlaceholder } from './launch-prompt-file' -import { buildAgentStartupPlan } from './tui-agent-startup' import type { TuiAgent } from './tui-agent' function planWithLaunchFile(agent: TuiAgent, shell: 'posix' | 'cmd' | 'powershell' = 'posix') { const { prompt, launchFile } = carryInLaunchFile('the brief', true) - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent, prompt, cmdOverrides: {}, @@ -53,7 +53,7 @@ describe('the launch-file directory grant on the agent command line', () => { const { plan, pointer } = planWithLaunchFile('codex') expect(plan?.launchCommand).toBe(`codex '${pointer}'`) expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'claude', prompt: 'fix it', cmdOverrides: {}, diff --git a/src/shared/launch-file-writing.test.ts b/src/shared/launch-file-writing.test.ts index 5f8620e36cd6..cbdf971d4c25 100644 --- a/src/shared/launch-file-writing.test.ts +++ b/src/shared/launch-file-writing.test.ts @@ -20,12 +20,7 @@ import { sweepStaleLaunchFiles, writeLaunchFile } from './launch-file-writing' -import { - MAX_INLINE_LAUNCH_PROMPT_CHARS, - buildLaunchFilePointer, - parseLaunchFile, - planLaunchPrompt -} from './launch-prompt-file' +import { buildLaunchFilePointer, carryInLaunchFile, parseLaunchFile } from './launch-prompt-file' import { quoteStartupArg } from './tui-agent-startup-shell' let baseDirectory: string @@ -38,35 +33,30 @@ afterEach(() => { rmSync(baseDirectory, { recursive: true, force: true }) }) -describe('planLaunchPrompt', () => { - it('keeps a prompt at the ceiling inline', () => { - const prompt = 'a'.repeat(MAX_INLINE_LAUNCH_PROMPT_CHARS) - expect(planLaunchPrompt(prompt)).toEqual({ prompt }) - }) - - it('moves a 20,000-character prompt into a launch file', () => { +describe('carryInLaunchFile', () => { + it('moves a prompt into a launch file and names it by a pointer', () => { const prompt = 'b'.repeat(20_000) - const planned = planLaunchPrompt(prompt) - expect(planned.launchFile).toMatchObject({ content: prompt, sensitive: false }) - expect(planned.prompt).toBe(buildLaunchFilePointer(planned.launchFile!.placeholder)) + const carried = carryInLaunchFile(prompt, false) + expect(carried.launchFile).toMatchObject({ content: prompt, sensitive: false }) + expect(carried.prompt).toBe(buildLaunchFilePointer(carried.launchFile.placeholder)) }) - it('moves a short sensitive prompt into a launch file', () => { - const planned = planLaunchPrompt('token dcap_secret', { sensitive: true }) - expect(planned.prompt).not.toContain('dcap_secret') - expect(planned.launchFile).toMatchObject({ content: 'token dcap_secret', sensitive: true }) + it('keeps a sensitive prompt off the pointer', () => { + const carried = carryInLaunchFile('token dcap_secret', true) + expect(carried.prompt).not.toContain('dcap_secret') + expect(carried.launchFile).toMatchObject({ content: 'token dcap_secret', sensitive: true }) }) it('mints a fresh placeholder each time', () => { - const first = planLaunchPrompt('x', { sensitive: true }).launchFile!.placeholder - const second = planLaunchPrompt('x', { sensitive: true }).launchFile!.placeholder + const first = carryInLaunchFile('x', true).launchFile.placeholder + const second = carryInLaunchFile('x', true).launchFile.placeholder expect(first).not.toBe(second) }) }) describe('parseLaunchFile', () => { - it('accepts what planLaunchPrompt mints and rejects anything else', () => { - const { launchFile } = planLaunchPrompt('x', { sensitive: true }) + it('accepts what carryInLaunchFile mints and rejects anything else', () => { + const { launchFile } = carryInLaunchFile('x', true) expect(parseLaunchFile(launchFile)).toEqual(launchFile) expect(parseLaunchFile({ ...launchFile, placeholder: 'HOME' })).toBeUndefined() expect(parseLaunchFile({ ...launchFile, sensitive: 'yes' })).toBeUndefined() @@ -81,7 +71,7 @@ describe('parseLaunchFile', () => { describe('writeLaunchFile', () => { it('writes a 0600 file in a private 0700 directory and puts its path in the line and env', () => { - const { prompt, launchFile } = planLaunchPrompt('c'.repeat(20_000)) + const { prompt, launchFile } = carryInLaunchFile('c'.repeat(20_000), false) const written = writeLaunchFile({ launchFile: launchFile!, command: `claude '${prompt}'`, @@ -102,7 +92,7 @@ describe('writeLaunchFile', () => { it("refuses an apostrophe in the path when the line's quoting is unknown, leaving nothing behind", () => { const quoted = join(baseDirectory, "it's") mkdirSync(quoted) - const { launchFile } = planLaunchPrompt('x', { sensitive: true }) + const { launchFile } = carryInLaunchFile('x', true) expect(() => writeLaunchFile({ launchFile: launchFile!, baseDirectory: quoted })).toThrow( LaunchFileUnavailableError ) @@ -110,7 +100,7 @@ describe('writeLaunchFile', () => { }) it('refuses when the temp directory cannot be written', () => { - const { launchFile } = planLaunchPrompt('x', { sensitive: true }) + const { launchFile } = carryInLaunchFile('x', true) expect(() => writeLaunchFile({ launchFile: launchFile!, baseDirectory: join(baseDirectory, 'missing') }) ).toThrow( @@ -119,7 +109,7 @@ describe('writeLaunchFile', () => { }) it('removes the whole directory', () => { - const { launchFile } = planLaunchPrompt('x', { sensitive: true }) + const { launchFile } = carryInLaunchFile('x', true) const written = writeLaunchFile({ launchFile: launchFile!, baseDirectory }) removeLaunchFile(written) expect(existsSync(written.directory)).toBe(false) @@ -178,7 +168,7 @@ describePosix('a launch line naming a file under an unusual home directory', () async (user) => { const home = join(baseDirectory, user) mkdirSync(home) - const { prompt, launchFile } = planLaunchPrompt('brief', { sensitive: true }) + const { prompt, launchFile } = carryInLaunchFile('brief', true) const written = writeLaunchFile({ launchFile: { ...launchFile!, quoting: 'posix' }, command: `printf '%s' ${quoteStartupArg(prompt, 'posix')}`, diff --git a/src/shared/launch-file-writing.ts b/src/shared/launch-file-writing.ts index b857163344a1..b3d55e20b98c 100644 --- a/src/shared/launch-file-writing.ts +++ b/src/shared/launch-file-writing.ts @@ -20,7 +20,7 @@ import { type LaunchFile } from './launch-prompt-file' import { quoteStartupArg, type AgentStartupShell } from './tui-agent-startup-shell' -import { typedStartupLineFits } from './typed-startup-line' +import { startupLineNeedsStaging } from './startup-command-staging' import type { WslLaunchDirectory } from './wsl-launch-directory' const LAUNCH_FILE_DIR_PREFIX = 'orca-launch-file-' @@ -69,8 +69,8 @@ export function launchFilePathInQuotedRun( } export class LaunchFileUnavailableError extends Error { - constructor(reason: string) { - super(describeLaunchFileUnavailable(reason)) + constructor(reason: string, carrier: 'file' | 'staged-line' = 'file') { + super(describeLaunchFileUnavailable(reason, carrier)) } } @@ -161,6 +161,19 @@ export function writeLaunchFile(args: { } } +/** Whether a WSL spawn needs its distro's launch directory: for a launch file, or to stage the + * long or multi-line agent line Orca built. Only then is the distro probed. */ +export function spawnNeedsWslLaunchDirectory(args: { + launchFile?: LaunchFile + command?: string + orcaBuiltLine: boolean +}): boolean { + return ( + args.launchFile !== undefined || + (args.orcaBuiltLine && args.command !== undefined && startupLineNeedsStaging(args.command)) + ) +} + /** * The write site's launch file for a spawn. A WSL session writes it into the distro, and refuses * when the distro directory is unknown and a file or a staged line needs it: a Windows path means @@ -175,11 +188,15 @@ export function writeSpawnLaunchFile(args: { wslDirectory: WslLaunchDirectory | undefined }): WrittenLaunchFile | undefined { const wslDirectory = args.wslDistro ? args.wslDirectory : undefined - const needsDirectory = - args.launchFile !== undefined || - (args.orcaBuiltLine && args.command !== undefined && !typedStartupLineFits(args.command)) - if (args.wslDistro && needsDirectory && wslDirectory?.distro !== args.wslDistro) { - throw new LaunchFileUnavailableError("the WSL distro's home directory could not be reached") + if ( + args.wslDistro && + spawnNeedsWslLaunchDirectory(args) && + wslDirectory?.distro !== args.wslDistro + ) { + throw new LaunchFileUnavailableError( + "the WSL distro's home directory could not be reached", + args.launchFile ? 'file' : 'staged-line' + ) } return args.launchFile ? writeLaunchFile({ diff --git a/src/shared/launch-prompt-carrier.test.ts b/src/shared/launch-prompt-carrier.test.ts index 3f73193b1643..81b690199706 100644 --- a/src/shared/launch-prompt-carrier.test.ts +++ b/src/shared/launch-prompt-carrier.test.ts @@ -1,43 +1,51 @@ import { describe, expect, it } from 'vitest' -import { buildAgentStartupPlan } from './tui-agent-startup' +import { planLaunchForTest } from './launch-prompt-plan.test-fixture' const base = { agent: 'claude' as const, cmdOverrides: {} } describe('the one launch-prompt decision every launch path builds through', () => { // Why: the composer, phone quick commands and background sessions call the builder directly. - it('points a single-line prompt past cmd’s line cap at a launch file', () => { + it('points a single-line Windows prompt past the typed budget at a launch file', () => { const prompt = 'y'.repeat(9_000) - const plan = buildAgentStartupPlan({ ...base, prompt, platform: 'win32', shell: 'cmd' }) + const plan = planLaunchForTest({ ...base, prompt, platform: 'win32', shell: 'cmd' }) expect(plan?.launchFile?.content).toBe(prompt) expect(plan?.launchCommand.length).toBeLessThan(8_191) }) it('points a prompt past the argv ceiling at a launch file on a POSIX host too', () => { const prompt = 'z'.repeat(20_000) - expect(buildAgentStartupPlan({ ...base, prompt, platform: 'linux' })?.launchFile?.content).toBe( + expect(planLaunchForTest({ ...base, prompt, platform: 'linux' })?.launchFile?.content).toBe( prompt ) }) - it('keeps a sensitive prompt off the line however short it is', () => { - const plan = buildAgentStartupPlan({ + it('keeps a paired host to the typed budget, pasting what it may neither stage nor read', () => { + const short = planLaunchForTest({ ...base, - prompt: 'token dcap_secret', + prompt: 'fix it', platform: 'linux', - sensitive: true + launchHostIsPaired: true }) - expect(plan?.launchFile).toMatchObject({ content: 'token dcap_secret', sensitive: true }) - expect(plan?.launchCommand).not.toContain('dcap_secret') + expect(short?.carry).toBe('on-line') + const long = planLaunchForTest({ + ...base, + prompt: 'fix it\nthen run the tests', + platform: 'linux', + launchHostIsPaired: true + }) + expect(long?.carry).toBe('paste-after-ready') + expect(long?.pasteAfterReady).toBe('fix it\nthen run the tests') + expect(long?.launchCommand).not.toContain('run the tests') }) // Why: the host writes a WSL session's staged line and launch file into the distro. it('plans a WSL launch like any Linux launch: long lines stay typed, huge ones get a file', () => { - const typed = buildAgentStartupPlan({ ...base, platform: 'linux', prompt: 'one\ntwo' }) + const typed = planLaunchForTest({ ...base, platform: 'linux', prompt: 'one\ntwo' }) expect(typed?.launchCommand).toContain('two') - expect(typed?.followupPrompt).toBeNull() + expect(typed?.pasteAfterReady).toBeNull() const huge = 'c'.repeat(20_000) - const pointed = buildAgentStartupPlan({ ...base, platform: 'linux', prompt: huge }) + const pointed = planLaunchForTest({ ...base, platform: 'linux', prompt: huge }) expect(pointed?.launchFile?.content).toBe(huge) - expect(pointed?.followupPrompt).toBeNull() + expect(pointed?.pasteAfterReady).toBeNull() }) }) diff --git a/src/shared/launch-prompt-carry.test.ts b/src/shared/launch-prompt-carry.test.ts new file mode 100644 index 000000000000..f058bfc7bb9f --- /dev/null +++ b/src/shared/launch-prompt-carry.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest' +import { MAX_INLINE_LAUNCH_PROMPT_CHARS, carryInLaunchFile } from './launch-prompt-file' +import { planLaunchPrompt, type AgentLaunchPromptArgs } from './tui-agent-startup' +import type { TuiAgent } from './tui-agent' +import { RUNTIME_CAPABILITIES } from './protocol-version' +import { AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY } from './agent-launch-runtime-capability' +import { TYPED_STARTUP_LINE_BUDGET_BYTES, typedStartupLineFits } from './typed-startup-line' + +function plan( + agent: TuiAgent, + prompt: string, + extra: Partial> = {} +) { + return planLaunchPrompt({ agent, prompt, cmdOverrides: {}, platform: 'darwin', ...extra }) +} + +describe('where a launch prompt rides', () => { + it('builds a plain launch for an empty prompt', () => { + expect(plan('claude', ' ')).toMatchObject({ carry: 'none' }) + }) + + it('carries a short prompt on the line', () => { + const planned = plan('claude', 'explain this repo') + expect(planned?.carry).toBe('on-line') + expect(planned?.carry === 'on-line' && planned.plan.launchCommand).toContain( + 'explain this repo' + ) + }) + + it.each([ + ['a 600-byte', 'x'.repeat(600)], + ['a multi-line', 'first line\nsecond line'], + ['a key-bearing', 'see\tthis \x1b[31mred'] + ])('carries %s prompt on the line of a POSIX host, which stages it', (_label, prompt) => { + const planned = plan('codex', prompt) + expect(planned?.carry).toBe('on-line') + expect(planned?.carry === 'on-line' && planned.plan.launchCommand).toContain(prompt) + }) + + it.each(['claude', 'codex'])( + 'points %s at a launch file past the argv ceiling, with the full text in the file', + (agent) => { + const prompt = `${'y'.repeat(MAX_INLINE_LAUNCH_PROMPT_CHARS)}z` + const planned = plan(agent, prompt) + if (planned?.carry !== 'launch-file') { + throw new Error(`expected a launch file, got ${planned?.carry}`) + } + expect(planned.launchFile).toMatchObject({ content: prompt, sensitive: false }) + expect(planned.plan.launchCommand).toContain(planned.launchFile.placeholder) + expect(planned.plan.launchCommand).not.toContain('yyyy') + } + ) + + // The bug class this outcome removes: a plan that exists but does not carry the prompt. + it('leaves a file-sized prompt for the paste for an agent not measured reading the file', () => { + const prompt = 'g'.repeat(MAX_INLINE_LAUNCH_PROMPT_CHARS + 1) + const planned = plan('gemini', prompt) + if (planned?.carry !== 'paste-after-ready') { + throw new Error(`expected the paste, got ${planned?.carry}`) + } + expect(planned.text).toBe(prompt) + expect(planned.cleanPlan.launchCommand).not.toContain('gggg') + }) + + it('leaves a stdin-after-start agent’s prompt for the paste', () => { + const planned = plan('aider', 'fix it') + expect(planned).toMatchObject({ carry: 'paste-after-ready', text: 'fix it' }) + }) + + it('names a launch file its caller wrote, quoted for the line', () => { + const { prompt, launchFile } = carryInLaunchFile('worker brief', true) + const planned = plan('claude', prompt, { launchFile }) + expect(planned).toMatchObject({ + carry: 'launch-file', + launchFile: { ...launchFile, quoting: 'posix' } + }) + }) +}) + +describe('a host that types the line raw', () => { + it.each<['cmd' | 'powershell']>([['cmd'], ['powershell']])( + 'keeps a %s line to the typed budget and points Claude at a file past it', + (shell) => { + const short = plan('claude', 'fix it', { platform: 'win32', shell }) + expect(short?.carry).toBe('on-line') + const long = plan('claude', 'y'.repeat(600), { platform: 'win32', shell }) + expect(long?.carry).toBe('launch-file') + } + ) + + it('points Codex at a file for a Windows-damaged prompt, and pastes it for Gemini', () => { + const prompt = 'fix the build\nthen run the tests' + expect(plan('codex', prompt, { platform: 'win32', shell: 'cmd' })?.carry).toBe('launch-file') + expect(plan('gemini', prompt, { platform: 'win32', shell: 'cmd' })).toMatchObject({ + carry: 'paste-after-ready', + text: prompt + }) + }) + + it('points a PowerShell prompt with a double quote at a file', () => { + expect(plan('claude', 'say "hi"', { platform: 'win32', shell: 'powershell' })?.carry).toBe( + 'launch-file' + ) + }) + + it('pastes on a paired host what its line cannot carry typed, even for Claude', () => { + const extra = { platform: 'linux' as const, launchHostIsPaired: true } + expect(plan('claude', 'fix it', extra)?.carry).toBe('on-line') + expect(plan('claude', 'first line\nsecond line', extra)?.carry).toBe('paste-after-ready') + expect(plan('claude', 'z'.repeat(20_000), extra)?.carry).toBe('paste-after-ready') + }) +}) + +describe('whether a line can be typed as it is', () => { + it('holds a line to half of macOS MAX_CANON', () => { + expect(typedStartupLineFits('x'.repeat(TYPED_STARTUP_LINE_BUDGET_BYTES))).toBe(true) + expect(typedStartupLineFits('x'.repeat(TYPED_STARTUP_LINE_BUDGET_BYTES + 1))).toBe(false) + }) + + it.each(['\n', '\r', '\t', '\x1b', '\x03', '\x7f'])('never types a line with %j', (byte) => { + expect(typedStartupLineFits(`a${byte}b`)).toBe(false) + }) +}) + +describe('the capability clients gate a prompted launch on', () => { + it('is advertised by every host that delivers a prompt its typed line cannot carry as typed', () => { + // An older host types any prompt into the line, so its absence is the gate. + expect(RUNTIME_CAPABILITIES).toContain(AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY) + }) +}) diff --git a/src/shared/launch-prompt-carry.ts b/src/shared/launch-prompt-carry.ts new file mode 100644 index 000000000000..c9e708f45f8e --- /dev/null +++ b/src/shared/launch-prompt-carry.ts @@ -0,0 +1,167 @@ +/** Where a launch prompt rides: decided once here, for every launch path, and switched on by each. */ +import { + MAX_INLINE_LAUNCH_PROMPT_CHARS, + carryInLaunchFile, + launchFileDirectoryPlaceholder, + type LaunchFile +} from './launch-prompt-file' +import { TUI_AGENT_CONFIG } from './tui-agent-config' +import { + isPosixStartupShell, + quoteStartupArg, + resolveStartupShell, + type AgentStartupShell +} from './tui-agent-startup-shell' +import { hasControlByte, typedStartupLineFits } from './typed-startup-line' +import type { TuiAgent } from './tui-agent' + +/** + * Whether a Windows shell would damage `prompt` as one quoted argument. No quoting keeps a control + * byte literal: cmd types the line and a line break submits it early. PowerShell runs a short line + * from its argv, so its damage is the hand-off to the agent (measured): legacy argument passing (5.1 + * always, 7.x into a `.cmd` shim) splits at an inner `"` and turns a trailing `\` into `"`, and a + * shim's cmd.exe expands `%NAME%`; a lone `%` stays literal. + */ +export function windowsShellDamagesPrompt(prompt: string, shell: AgentStartupShell): boolean { + if (isPosixStartupShell(shell)) { + return false + } + return ( + hasControlByte(prompt) || + (shell === 'powershell' && + (prompt.includes('"') || /%[^%]+%/.test(prompt) || prompt.endsWith('\\'))) + ) +} + +/** Why a prefill draft could not be launched, in the user's words, when the Windows shell is why. */ +export function windowsDraftRefusal(draft: string, shell: AgentStartupShell): string | null { + return windowsShellDamagesPrompt(draft.trim(), shell) + ? "The host's Windows shell would break this draft on the agent's command line (it has a line " + + 'break or other control character, or on PowerShell a double quote, a %NAME% pair or a ' + + 'trailing backslash), so the agent was not started. Start it without the draft and paste ' + + 'the draft once it opens.' + : null +} + +/** + * Where a launch prompt went. Only the carried outcomes hold the plan to launch, so a caller must + * switch on `carry` before it can launch anything, and cannot mistake a paste for a delivery. + */ +export type LaunchPromptPlan

= + | { carry: 'none'; plan: P } + | { carry: 'on-line'; plan: P } + | { carry: 'launch-file'; plan: P; launchFile: LaunchFile } + | { carry: 'paste-after-ready'; cleanPlan: P; text: string } + +export type LaunchPromptCarry = LaunchPromptPlan['carry'] + +/** Why a create that cannot paste after the agent is ready refuses a prompt that needs it. */ +export function launchPromptNeedsPasteRefusal( + agent: TuiAgent, + created: 'terminal' | 'session' +): string { + return TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start' + ? `${agent} takes its prompt only after it starts, so no ${created} was created. Start the ` + + 'agent and paste the prompt once it opens.' + : `${agent} cannot take this prompt on its command line here (too long, or broken by the ` + + `host's Windows shell), so no ${created} was created. Start the agent without it and ` + + 'paste the prompt once it opens.' +} + +export type CarriedPlanArgs = { + agent: TuiAgent + prompt: string + platform: NodeJS.Platform + shell?: AgentStartupShell + /** The file `prompt` already points at, when the caller wrote its own. */ + launchFile?: LaunchFile + /** Another Orca this client drives, possibly an older one: it is sent a command it may neither + * stage nor accompany with a launch file. Temporary, until paired hosts advertise both. */ + launchHostIsPaired?: boolean +} + +export function agentReadsLaunchFile(agent: TuiAgent): boolean { + return TUI_AGENT_CONFIG[agent].readsLaunchFile === true +} + +/** + * The one carry rule. The prompt rides the agent's line, which a host that stages (POSIX, SSH, WSL) + * stages when it is long or multi-line. It rides a launch file when it is past the argv ceiling, + * when a Windows shell would damage it, or when the host types the line raw (a Windows host, a + * paired Orca) and the line is past the typed budget. A launch file goes only to an agent measured + * reading one, on a host that writes it; otherwise the agent starts clean and the prompt is pasted + * once it is ready. + */ +export function carryLaunchPrompt( + args: A, + buildLine: (args: A) => P | null +): LaunchPromptPlan

| null { + const text = args.prompt.trim() + const shell = resolveStartupShell(args.platform, args.shell) + const clean = (): P | null => buildLine({ ...args, prompt: '', launchFile: undefined }) + if (!text) { + const plan = clean() + return plan && { carry: 'none', plan } + } + if (args.launchFile) { + const plan = buildLine(args) + return plan && { carry: 'launch-file', plan, launchFile: withQuoting(args.launchFile, shell) } + } + const pasteAfterReady = (): LaunchPromptPlan

| null => { + const cleanPlan = clean() + return cleanPlan && { carry: 'paste-after-ready', cleanPlan, text } + } + const mode = TUI_AGENT_CONFIG[args.agent].promptInjectionMode + if (mode === 'stdin-after-start') { + return pasteAfterReady() + } + const viaLaunchFile = (): LaunchPromptPlan

| null => { + // Why: an agent not measured reading the file would stop on an approval or refuse the path. + if (args.launchHostIsPaired === true || !agentReadsLaunchFile(args.agent)) { + return pasteAfterReady() + } + const pointer = carryInLaunchFile(text, false) + const plan = buildLine({ ...args, prompt: pointer.prompt, launchFile: pointer.launchFile }) + return ( + plan && { carry: 'launch-file', plan, launchFile: withQuoting(pointer.launchFile, shell) } + ) + } + const readsEnv = mode === 'hermes-query' + if ( + text.length > MAX_INLINE_LAUNCH_PROMPT_CHARS || + (!readsEnv && windowsShellDamagesPrompt(text, shell)) + ) { + return viaLaunchFile() + } + const plan = buildLine(args) + if (!plan) { + // Hermes reads its prompt from the env and refuses one past that budget, counted in bytes; a + // one-character query building proves the budget, not the command, refused it. + return readsEnv && buildLine({ ...args, prompt: '.' }) ? viaLaunchFile() : null + } + // Why the typed budget (#24257's): nothing stages a line the host types raw, and a long typed + // line is truncated or submitted early. Hermes's line never holds the text. + const hostTypesLineRaw = args.launchHostIsPaired === true || args.platform === 'win32' + if (!readsEnv && hostTypesLineRaw && !typedStartupLineFits(plan.launchCommand)) { + return viaLaunchFile() + } + return { carry: 'on-line', plan } +} + +/** The host writes the path inside this line's quoting, so the file carries which one it is. */ +function withQuoting(launchFile: LaunchFile, shell: AgentStartupShell): LaunchFile { + return { ...launchFile, quoting: shell } +} + +/** The host puts the launch file's private directory where the placeholder is, like its path. */ +export function launchFileDirectoryGrant( + agent: TuiAgent, + launchFile: LaunchFile | undefined, + shell: AgentStartupShell +): string { + const flag = TUI_AGENT_CONFIG[agent].launchFileDirectoryFlag + if (!launchFile || !flag) { + return '' + } + return ` ${quoteStartupArg(`${flag}=${launchFileDirectoryPlaceholder(launchFile.placeholder)}`, shell)}` +} diff --git a/src/shared/launch-prompt-file.ts b/src/shared/launch-prompt-file.ts index 4c563404fc7f..0c99fc1251ee 100644 --- a/src/shared/launch-prompt-file.ts +++ b/src/shared/launch-prompt-file.ts @@ -49,20 +49,6 @@ export function isLaunchFilePointer(prompt: string): boolean { ) } -/** - * The prompt a launch line carries, and the file behind it when the prompt is too - * long or sensitive. The pointer names a placeholder the execution host replaces. - */ -export function planLaunchPrompt( - prompt: string, - options: { sensitive?: boolean } = {} -): { prompt: string; launchFile?: LaunchFile } { - const sensitive = options.sensitive === true - return sensitive || prompt.length > MAX_INLINE_LAUNCH_PROMPT_CHARS - ? carryInLaunchFile(prompt, sensitive) - : { prompt } -} - export function carryInLaunchFile( content: string, sensitive: boolean @@ -103,9 +89,17 @@ export function parseLaunchFile(value: unknown): LaunchFile | undefined { const LAUNCH_FILE_UNAVAILABLE_CODE = 'launch_file_unavailable' -/** The refusal a host sends when it could not write a launch file; it reaches the user as is. */ -export function describeLaunchFileUnavailable(reason: string): string { - return `Orca could not write the file that carries the agent's prompt (${reason}), so the agent was not started. [${LAUNCH_FILE_UNAVAILABLE_CODE}]` +/** The refusal a host sends when it could not write what carries the prompt (a launch file, or the + * staged script holding a long line); it reaches the user as is. */ +export function describeLaunchFileUnavailable( + reason: string, + carrier: 'file' | 'staged-line' = 'file' +): string { + const what = + carrier === 'file' + ? "the file that carries the agent's prompt" + : "the script that carries the agent's launch line and prompt" + return `Orca could not write ${what} (${reason}), so the agent was not started. [${LAUNCH_FILE_UNAVAILABLE_CODE}]` } /** Also matches the `launch_file_unavailable: ` form earlier builds sent. */ diff --git a/src/shared/launch-prompt-plan.test-fixture.ts b/src/shared/launch-prompt-plan.test-fixture.ts new file mode 100644 index 000000000000..782eba000863 --- /dev/null +++ b/src/shared/launch-prompt-plan.test-fixture.ts @@ -0,0 +1,44 @@ +import type { LaunchFile } from './launch-prompt-file' +import type { LaunchPromptCarry } from './launch-prompt-carry' +import { + planLaunchPrompt, + type AgentLaunchPromptArgs, + type AgentStartupPlan +} from './tui-agent-startup' + +/** A prompted plan flattened for tests that assert on the line it builds. */ +export type PlannedLaunchForTest = AgentStartupPlan & { + carry: LaunchPromptCarry + launchFile?: LaunchFile + /** The prompt left for the paste after ready; null when the launch carried it. */ + pasteAfterReady: string | null +} + +/** `planLaunchPrompt`, flattened; null where it answers null, or for an empty prompt unless + * `allowEmptyPromptLaunch`, as the builder did before the outcome was explicit. */ +export function planLaunchForTest( + args: AgentLaunchPromptArgs & { allowEmptyPromptLaunch?: boolean } +): PlannedLaunchForTest | null { + const { allowEmptyPromptLaunch, ...launchArgs } = args + if (allowEmptyPromptLaunch !== true && !args.prompt.trim()) { + return null + } + const planned = planLaunchPrompt(launchArgs) + if (!planned) { + return null + } + switch (planned.carry) { + case 'none': + case 'on-line': + return { ...planned.plan, carry: planned.carry, pasteAfterReady: null } + case 'launch-file': + return { + ...planned.plan, + carry: planned.carry, + launchFile: planned.launchFile, + pasteAfterReady: null + } + case 'paste-after-ready': + return { ...planned.cleanPlan, carry: planned.carry, pasteAfterReady: planned.text } + } +} diff --git a/src/shared/local-build-compatibility-contract.json b/src/shared/local-build-compatibility-contract.json index 0e90a402914c..bfebe8c975c2 100644 --- a/src/shared/local-build-compatibility-contract.json +++ b/src/shared/local-build-compatibility-contract.json @@ -3,9 +3,9 @@ "appId": "com.stablyai.orca", "stateSchemaVersion": 1, "readableStateSchemaVersions": [1], - "daemonProtocolVersion": 40, + "daemonProtocolVersion": 41, "attachableDaemonProtocolVersions": [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, - 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 + 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41 ] } diff --git a/src/shared/local-build-compatibility-contract.ts b/src/shared/local-build-compatibility-contract.ts index 75a414b2a5f3..afda2d8f2275 100644 --- a/src/shared/local-build-compatibility-contract.ts +++ b/src/shared/local-build-compatibility-contract.ts @@ -3,9 +3,9 @@ export const LOCAL_BUILD_COMPATIBILITY_CONTRACT = { appId: 'com.stablyai.orca', stateSchemaVersion: 1, readableStateSchemaVersions: [1], - daemonProtocolVersion: 40, + daemonProtocolVersion: 41, attachableDaemonProtocolVersions: [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, - 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 + 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41 ] } as const diff --git a/src/shared/omp-fresh-launch-shell.test.ts b/src/shared/omp-fresh-launch-shell.test.ts index ebdc7581a9a6..5c41cdde06f3 100644 --- a/src/shared/omp-fresh-launch-shell.test.ts +++ b/src/shared/omp-fresh-launch-shell.test.ts @@ -3,8 +3,8 @@ import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' import { afterEach, expect, it } from 'vitest' +import { planLaunchForTest } from './launch-prompt-plan.test-fixture' import { runProcess } from './child-process/run-process' -import { buildAgentStartupPlan } from './tui-agent-startup' const roots: string[] = [] afterEach(async () => { @@ -27,7 +27,7 @@ it.each(shells)( const config = join(root, 'fresh settings.yml') const capture = join(root, 'argv') await writeFile(config, 'autoResume: false\n') - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'omp', prompt: 'task with spaces', cmdOverrides: {}, diff --git a/src/shared/omp-fresh-launch.test.ts b/src/shared/omp-fresh-launch.test.ts index eb8c94e2e5c8..f7c6bf68d22f 100644 --- a/src/shared/omp-fresh-launch.test.ts +++ b/src/shared/omp-fresh-launch.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' +import { planLaunchForTest } from './launch-prompt-plan.test-fixture' import { withFreshOmpLaunch } from './omp-fresh-launch' -import { buildAgentStartupPlan } from './tui-agent-startup' describe('OMP fresh launch intent', () => { it.each(['omp', 'omp launch', 'omp --model provider/model', 'omp --config user.yml'])( @@ -45,7 +45,7 @@ describe('OMP fresh launch intent', () => { expect(withFreshOmpLaunch('omp', 'cmd')).toContain('omp --config "%ORCA_OMP_FRESH_CONFIG%"') }) it('keeps fresh intent out of saved resume command and environment', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'omp', prompt: 'new task', cmdOverrides: {}, @@ -56,7 +56,7 @@ describe('OMP fresh launch intent', () => { expect(plan?.env).toBeUndefined() }) it('requires host-owned configuration for fresh SSH launches', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'omp', prompt: 'new task', cmdOverrides: {}, diff --git a/src/shared/qoder-agent.test.ts b/src/shared/qoder-agent.test.ts index 8db665e72094..2ad44aab1808 100644 --- a/src/shared/qoder-agent.test.ts +++ b/src/shared/qoder-agent.test.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' +import { planLaunchForTest } from './launch-prompt-plan.test-fixture' import { recognizeAgentProcessFromCommandLine, isExpectedAgentProcess @@ -11,7 +12,6 @@ import { normalizeTerminalTitle, detectAgentStatusFromTitle } from './agent-detection' -import { buildAgentStartupPlan } from './tui-agent-startup' import { getAgentResumeArgv } from './agent-session-resume' import { createHookListenerState } from './agent-hook-listener/listener-state' import { normalizeAndAccept } from './agent-hook-listener-test-harness' @@ -121,14 +121,14 @@ it('replays captured unauthenticated startup, prompt, idle and resume hooks', () it.each(['darwin', 'linux', 'win32'] as const)( 'launches a Qoder prompt safely on %s', (platform) => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'qoder', prompt: "fix Bob's branch; echo $HOME", cmdOverrides: {}, platform }) expect(plan?.expectedProcess).toBe('qodercli') - expect(plan?.followupPrompt).toBeNull() + expect(plan?.pasteAfterReady).toBeNull() expect(plan?.launchCommand).toBe( platform === 'win32' ? "qodercli --prompt-interactive 'fix Bob''s branch; echo $HOME'" diff --git a/src/shared/startup-command-staging.node-pty.test.ts b/src/shared/startup-command-staging.node-pty.test.ts index 2619a92c805d..f0e202b43935 100644 --- a/src/shared/startup-command-staging.node-pty.test.ts +++ b/src/shared/startup-command-staging.node-pty.test.ts @@ -23,7 +23,7 @@ import * as pty from 'node-pty' import { afterAll, describe, expect, it } from 'vitest' import { resolveFishBinary } from './fish-binary-requirement' import { writeLaunchFile, type WrittenLaunchFile } from './launch-file-writing' -import { buildLaunchFilePointer, planLaunchPrompt } from './launch-prompt-file' +import { buildLaunchFilePointer, carryInLaunchFile } from './launch-prompt-file' import { stageStartupCommand } from './startup-command-staging' import { buildStartupCommandSubmission } from './startup-command-submission' import { quoteStartupArg } from './tui-agent-startup-shell' @@ -181,7 +181,7 @@ describeShells('a launch file named on a real command line', () => { for (const shell of SHELLS) { it(`hands ${shell.name}'s agent a pointer to the full prompt`, async () => { const prompt = `${HOSTILE}\n${'z'.repeat(20_000)}\n` - const planned = planLaunchPrompt(prompt) + const planned = carryInLaunchFile(prompt, false) let written: WrittenLaunchFile | undefined const run = await launchInRealShell(shell, planned.prompt, (command) => { written = writeLaunchFile({ diff --git a/src/shared/startup-command-staging.ts b/src/shared/startup-command-staging.ts index 84f66c3d89b8..2f3129e9a4e4 100644 --- a/src/shared/startup-command-staging.ts +++ b/src/shared/startup-command-staging.ts @@ -68,11 +68,16 @@ export function shouldStageStartupCommand(args: { return args.orcaBuiltLine === true } return ( - !typedStartupLineFits(stripSubmitTerminator(args.command)) && + startupLineNeedsStaging(args.command) && (SOURCING_SHELLS.has(shellName) || args.orcaBuiltLine === true) ) } +/** Whether a line is too long or multi-line to type as it is, in a shell that can source a script. */ +export function startupLineNeedsStaging(command: string): boolean { + return !typedStartupLineFits(stripSubmitTerminator(command)) +} + function stagedScriptLine(shellName: string | null, quotedPath: string): string { if (shellName === 'fish') { // Why eval: fish runs a sourced file without job control; eval runs it as if typed. diff --git a/src/shared/startup-line-prompt-carry.test.ts b/src/shared/startup-line-prompt-carry.test.ts deleted file mode 100644 index cbcc56b871e5..000000000000 --- a/src/shared/startup-line-prompt-carry.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { MAX_INLINE_LAUNCH_PROMPT_CHARS } from './launch-prompt-file' -import { planStartupWithLaunchPrompt } from './startup-line-prompt-carry' -import type { TuiAgent } from './tui-agent' -import { RUNTIME_CAPABILITIES } from './protocol-version' -import { AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY } from './agent-launch-runtime-capability' -import { - TYPED_STARTUP_LINE_BUDGET_BYTES, - WINDOWS_TYPED_STARTUP_LINE_MAX_CHARS, - typedStartupLineFits, - windowsTypedStartupLineFits -} from './typed-startup-line' - -function plan( - agent: TuiAgent, - prompt: string, - extra: { - platform?: NodeJS.Platform - shell?: 'cmd' | 'powershell' - sensitive?: boolean - } = {} -) { - return planStartupWithLaunchPrompt( - { - agent, - cmdOverrides: {}, - platform: extra.platform ?? 'darwin', - ...(extra.shell ? { shell: extra.shell } : {}) - }, - prompt, - extra.sensitive ? { sensitive: true } : {} - ) -} - -describe('a launch prompt on the command line', () => { - it('carries a short prompt inline', () => { - const { plan: startup, launchFile } = plan('claude', 'explain this repo') - expect(launchFile).toBeUndefined() - expect(startup?.launchCommand).toContain('explain this repo') - expect(startup?.followupPrompt).toBeNull() - }) - - it.each([ - ['a 600-byte', 'x'.repeat(600)], - ['a multi-line', 'first line\nsecond line'], - ['a key-bearing', 'see\tthis \x1b[31mred'] - ])('carries %s prompt whole on a POSIX host, which stages the typed line', (_label, prompt) => { - const { plan: startup, launchFile } = plan('codex', prompt) - expect(launchFile).toBeUndefined() - expect(startup?.launchCommand).toContain(prompt) - expect(startup?.followupPrompt).toBeNull() - }) - - it('points at a launch file past the argv ceiling, with the full text in the file', () => { - const prompt = `${'y'.repeat(MAX_INLINE_LAUNCH_PROMPT_CHARS)}z` - const { plan: startup, launchFile } = plan('claude', prompt) - expect(launchFile?.content).toBe(prompt) - expect(launchFile?.sensitive).toBe(false) - expect(startup?.launchCommand).toContain(launchFile?.placeholder) - expect(startup?.launchCommand).not.toContain('yyyy') - }) - - it('points a sensitive prompt at a launch file whatever its size', () => { - const { plan: startup, launchFile } = plan('claude', 'token dcap_abc', { sensitive: true }) - expect(launchFile).toMatchObject({ content: 'token dcap_abc', sensitive: true }) - expect(startup?.launchCommand).not.toContain('dcap_abc') - }) - - it.each([ - ['cmd', 'first line\nsecond line'], - ['powershell', 'first line\r\nsecond line'] - ] as const)( - 'points a multi-line prompt at a launch file on a Windows %s host, which cannot stage', - (shell, prompt) => { - const { plan: startup, launchFile } = plan('codex', prompt, { platform: 'win32', shell }) - expect(launchFile?.content).toBe(prompt) - expect(startup?.launchCommand).not.toContain('first line') - expect(typedStartupLineFits(startup?.launchCommand ?? '\n')).toBe(true) - } - ) - - it('keeps a long single-line prompt inline on Windows up to cmd’s line cap', () => { - const inline = plan('claude', 'x'.repeat(600), { platform: 'win32', shell: 'cmd' }) - expect(inline.launchFile).toBeUndefined() - expect(inline.plan?.launchCommand).toContain('x'.repeat(600)) - - const pastCap = plan('claude', 'x'.repeat(WINDOWS_TYPED_STARTUP_LINE_MAX_CHARS), { - platform: 'win32', - shell: 'cmd' - }) - expect(pastCap.launchFile?.content).toBe('x'.repeat(WINDOWS_TYPED_STARTUP_LINE_MAX_CHARS)) - }) - - it('carries a multi-line Hermes prompt in its spawn env, whose typed line never holds it', () => { - const multiLine = 'line one\nline two' - const { plan: startup, launchFile } = plan('hermes', multiLine, { platform: 'win32' }) - expect(launchFile).toBeUndefined() - expect(startup?.launchCommand).not.toContain('line one') - expect(Object.values(startup?.env ?? {})).toContain(multiLine) - }) - - it('leaves a Hermes prompt past its env budget for the paste after ready', () => { - const { plan: startup, launchFile } = plan('hermes', 'x'.repeat(30_000)) - expect(launchFile).toBeUndefined() - expect(startup?.launchCommand).toBe('hermes --tui') - expect(startup?.followupPrompt).toBe('x'.repeat(30_000)) - }) - - it('leaves CJK Hermes text under 16,384 chars but past its env bytes for the paste', () => { - const cjk = '修'.repeat(9_000) - const { plan: startup, launchFile } = plan('hermes', cjk) - expect(launchFile).toBeUndefined() - expect(startup?.followupPrompt).toBe(cjk) - }) - - it('puts a prompt that needs a launch file there only for an agent measured reading it', () => { - const long = 'y'.repeat(MAX_INLINE_LAUNCH_PROMPT_CHARS + 1) - expect(plan('claude', long).launchFile?.content).toBe(long) - expect(plan('codex', long).launchFile?.content).toBe(long) - for (const agent of ['gemini', 'cursor', 'antigravity', 'grok'] as const) { - const { plan: startup, launchFile } = plan(agent, long) - expect(launchFile).toBeUndefined() - expect(startup?.followupPrompt).toBe(long) - expect(startup?.launchCommand).not.toContain(long) - } - }) - - it('pastes a Windows-damaged prompt for an agent not measured reading its launch file', () => { - const { plan: startup, launchFile } = plan('gemini', 'line one\nline two', { - platform: 'win32', - shell: 'powershell' - }) - expect(launchFile).toBeUndefined() - expect(startup?.followupPrompt).toBe('line one\nline two') - }) - - it('leaves a stdin-after-start agent’s prompt for its caller to paste', () => { - const { plan: startup, launchFile } = plan('aider', 'fix it') - expect(launchFile).toBeUndefined() - expect(startup?.followupPrompt).toBe('fix it') - }) - - it('launches clean for an empty prompt', () => { - const { plan: startup, launchFile } = plan('claude', ' ') - expect(launchFile).toBeUndefined() - expect(startup?.launchCommand).toBe('claude') - }) -}) - -describe('whether a line can be typed as it is', () => { - it('holds a POSIX line to half of macOS MAX_CANON', () => { - expect(typedStartupLineFits('x'.repeat(TYPED_STARTUP_LINE_BUDGET_BYTES))).toBe(true) - expect(typedStartupLineFits('x'.repeat(TYPED_STARTUP_LINE_BUDGET_BYTES + 1))).toBe(false) - expect(typedStartupLineFits('é'.repeat(TYPED_STARTUP_LINE_BUDGET_BYTES / 2 + 1))).toBe(false) - }) - - it('holds a Windows line only to cmd’s cap', () => { - expect(windowsTypedStartupLineFits('x'.repeat(WINDOWS_TYPED_STARTUP_LINE_MAX_CHARS))).toBe(true) - expect(windowsTypedStartupLineFits('x'.repeat(WINDOWS_TYPED_STARTUP_LINE_MAX_CHARS + 1))).toBe( - false - ) - }) - - it.each(['\n', '\r', '\t', '\x1b', '\x03', '\x7f'])('never types a line with %j', (byte) => { - expect(typedStartupLineFits(`a${byte}b`)).toBe(false) - expect(windowsTypedStartupLineFits(`a${byte}b`)).toBe(false) - }) -}) - -describe('the capability clients gate a prompted launch on', () => { - it('is advertised by every host that delivers a prompt its typed line cannot carry as typed', () => { - // An older host types any prompt into the 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 deleted file mode 100644 index cdf89e5978c1..000000000000 --- a/src/shared/startup-line-prompt-carry.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * The launch-prompt plan for callers that route sensitive prompts: the decision itself (line, launch - * file, or paste) is the builder's (`carryLaunchPrompt`), so every launch path shares it. - */ - -import type { LaunchFile } from './launch-prompt-file' -import { buildAgentStartupPlan, type AgentStartupPlan } from './tui-agent-startup' - -type StartupPlanInputs = Omit< - Parameters[0], - 'prompt' | 'allowEmptyPromptLaunch' -> - -export type LaunchPromptStartupPlan = { - plan: AgentStartupPlan | null - /** Written by the execution host before it types the line naming it. */ - launchFile?: LaunchFile -} - -export function planStartupWithLaunchPrompt( - inputs: StartupPlanInputs, - prompt: string, - options: { sensitive?: boolean } = {} -): LaunchPromptStartupPlan { - const plan = buildAgentStartupPlan({ - ...inputs, - prompt, - allowEmptyPromptLaunch: true, - ...(options.sensitive ? { sensitive: true } : {}) - }) - return { plan, ...(plan?.launchFile ? { launchFile: plan.launchFile } : {}) } -} diff --git a/src/shared/startup-plan-launch-file.ts b/src/shared/startup-plan-launch-file.ts deleted file mode 100644 index 6cff85965090..000000000000 --- a/src/shared/startup-plan-launch-file.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** How a startup plan carries a launch file: which prompts move into one, and what the line adds. */ -import { - MAX_INLINE_LAUNCH_PROMPT_CHARS, - carryInLaunchFile, - launchFileDirectoryPlaceholder, - type LaunchFile -} from './launch-prompt-file' -import { TUI_AGENT_CONFIG } from './tui-agent-config' -import { - isPosixStartupShell, - quoteStartupArg, - resolveStartupShell, - type AgentStartupShell -} from './tui-agent-startup-shell' -import { hasControlByte, windowsTypedStartupLineFits } from './typed-startup-line' -import type { TuiAgent } from './tui-agent' - -/** - * Whether a Windows shell would damage `prompt` as one quoted argument; the closed rule is this plus - * cmd's 8,191-character line cap (`carryLaunchPrompt`). No quoting keeps a control byte literal: cmd - * types the line and a line break submits it early. PowerShell runs a short line from its argv, so - * its damage is the hand-off to the agent (measured): legacy argument passing (5.1 always, 7.x into a - * `.cmd` shim) splits at an inner `"` and turns a trailing `\` into `"`, and a shim's cmd.exe expands - * `%NAME%`; a lone `%` stays literal. - */ -export function windowsShellDamagesPrompt(prompt: string, shell: AgentStartupShell): boolean { - if (isPosixStartupShell(shell)) { - return false - } - return ( - hasControlByte(prompt) || - (shell === 'powershell' && - (prompt.includes('"') || /%[^%]+%/.test(prompt) || prompt.endsWith('\\'))) - ) -} - -/** Why a prefill draft could not be launched, in the user's words, when the Windows shell is why. */ -export function windowsDraftRefusal(draft: string, shell: AgentStartupShell): string | null { - return windowsShellDamagesPrompt(draft.trim(), shell) - ? "The host's Windows shell would break this draft on the agent's command line (it has a line " + - 'break or other control character, or on PowerShell a double quote, a %NAME% pair or a ' + - 'trailing backslash), so the agent was not started. Start it without the draft and paste ' + - 'the draft once it opens.' - : null -} - -type CarriedPlanArgs = { - agent: TuiAgent - prompt: string - platform: NodeJS.Platform - shell?: AgentStartupShell - allowEmptyPromptLaunch?: boolean - launchFile?: LaunchFile - hostWritesLaunchFile?: boolean - sensitive?: boolean -} - -export function agentReadsLaunchFile(agent: TuiAgent): boolean { - return TUI_AGENT_CONFIG[agent].readsLaunchFile === true -} - -/** - * Where a launch prompt rides, decided once for every launch path: on the line, which a POSIX host - * stages when it is long or multi-line; in a launch file named by a pointer when the prompt is past - * the argv ceiling, sensitive, damaged by a Windows shell, or past cmd's line cap; or, where no - * launch file can be written (a paired host) or the agent is not known to read one, left in - * `followupPrompt` for the paste after ready. - */ -export function carryLaunchPrompt< - A extends CarriedPlanArgs, - P extends { launchCommand: string; followupPrompt: string | null } ->(args: A, buildOnLine: (args: A) => P | null): P | null { - const text = args.prompt.trim() - const mode = TUI_AGENT_CONFIG[args.agent].promptInjectionMode - if (!text || args.launchFile || mode === 'stdin-after-start') { - return buildOnLine(args) - } - const readsEnv = mode === 'hermes-query' - const pasteAfterReady = (): P | null => { - const clean = buildOnLine({ ...args, prompt: '', allowEmptyPromptLaunch: true }) - return clean && { ...clean, followupPrompt: text } - } - const viaLaunchFile = (): P | null => { - // Why: an agent not measured reading the file would stop on an approval or refuse the path. - if (args.hostWritesLaunchFile === false || !agentReadsLaunchFile(args.agent)) { - return pasteAfterReady() - } - const pointer = carryInLaunchFile(text, args.sensitive === true) - return buildOnLine({ ...args, prompt: pointer.prompt, launchFile: pointer.launchFile }) - } - const shell = resolveStartupShell(args.platform, args.shell) - if ( - args.sensitive === true || - text.length > MAX_INLINE_LAUNCH_PROMPT_CHARS || - (!readsEnv && windowsShellDamagesPrompt(text, shell)) - ) { - return viaLaunchFile() - } - const onLine = buildOnLine(args) - if (!onLine || readsEnv) { - // Hermes reads its prompt from the env and refuses one past that budget, counted in bytes; a - // one-character query building proves the budget, not the command, refused it. - return !onLine && readsEnv && buildOnLine({ ...args, prompt: '.' }) ? viaLaunchFile() : onLine - } - return args.platform === 'win32' && !windowsTypedStartupLineFits(onLine.launchCommand) - ? viaLaunchFile() - : onLine -} - -/** The host writes the path inside this line's quoting, so the file carries which one it is. */ -export function launchFileProps(launchFile: LaunchFile | undefined, shell: AgentStartupShell) { - return launchFile ? { launchFile: { ...launchFile, quoting: shell } } : {} -} - -/** The host puts the launch file's private directory where the placeholder is, like its path. */ -export function launchFileDirectoryGrant( - agent: TuiAgent, - launchFile: LaunchFile | undefined, - shell: AgentStartupShell -): string { - const flag = TUI_AGENT_CONFIG[agent].launchFileDirectoryFlag - if (!launchFile || !flag) { - return '' - } - return ` ${quoteStartupArg(`${flag}=${launchFileDirectoryPlaceholder(launchFile.placeholder)}`, shell)}` -} diff --git a/src/shared/tui-agent-prompt-transport.test.ts b/src/shared/tui-agent-prompt-transport.test.ts index f117493dc711..a10ee091a4ab 100644 --- a/src/shared/tui-agent-prompt-transport.test.ts +++ b/src/shared/tui-agent-prompt-transport.test.ts @@ -1,8 +1,8 @@ /** * The two answers to "how does this agent take its prompt" must stay one answer. * - * `buildAgentStartupPlan` decides by building a command and leaving `followupPrompt` null when the - * text went into it. `agentPromptRidesLaunchCommand` has to give the same verdict BEFORE a command + * `planLaunchPrompt` decides by building a command and answering `paste-after-ready` when the text + * did not go into it. `agentPromptRidesLaunchCommand` has to give the same verdict BEFORE a command * exists, because `agent.launch` picks a delivery while it is still choosing what to create. Two * readings of one table is exactly the shape that drifts, so this pins them together across every * agent: add an agent, or change its injection mode, and the disagreement fails here rather than @@ -10,8 +10,9 @@ */ import { describe, expect, it } from 'vitest' +import { planLaunchForTest } from './launch-prompt-plan.test-fixture' import { TUI_AGENT_CONFIG } from './tui-agent-config' -import { agentPromptRidesLaunchCommand, buildAgentStartupPlan } from './tui-agent-startup' +import { agentPromptRidesLaunchCommand } from './tui-agent-startup' import type { TuiAgent } from './tui-agent' // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the config is declared as a total record over TuiAgent, so its own keys are that union. @@ -20,7 +21,7 @@ const ALL_AGENTS = Object.keys(TUI_AGENT_CONFIG) as TuiAgent[] const PROMPT = 'summarize the diff' function planFor(agent: TuiAgent) { - return buildAgentStartupPlan({ + return planLaunchForTest({ agent, prompt: PROMPT, cmdOverrides: {}, @@ -39,9 +40,9 @@ describe('the prompt-transport predicate against the plan it predicts', () => { it.each(ALL_AGENTS)('agrees with the built plan for %s', (agent) => { const plan = planFor(agent) expect(plan).not.toBeNull() - // `followupPrompt` is the plan saying "the command does NOT carry this"; the predicate must + // `paste-after-ready` is the plan saying "the command does NOT carry this"; the predicate must // say the same thing, and it is read before any plan is built. - expect(plan!.followupPrompt === null).toBe(agentPromptRidesLaunchCommand(agent)) + expect(plan!.pasteAfterReady === null).toBe(agentPromptRidesLaunchCommand(agent)) }) it('puts the prompt in the launch command exactly when it says it does', () => { @@ -50,7 +51,7 @@ describe('the prompt-transport predicate against the plan it predicts', () => { if (!agentPromptRidesLaunchCommand(agent)) { // The command must not smuggle the text in some other way. expect(plan!.launchCommand).not.toContain(PROMPT) - expect(plan!.followupPrompt).toBe(PROMPT) + expect(plan!.pasteAfterReady).toBe(PROMPT) continue } // Hermes hands long text through an env var, so the command names the variable, not the text. diff --git a/src/shared/tui-agent-resume-startup.ts b/src/shared/tui-agent-resume-startup.ts index f4924b83c47f..659e6dadd49b 100644 --- a/src/shared/tui-agent-resume-startup.ts +++ b/src/shared/tui-agent-resume-startup.ts @@ -62,7 +62,6 @@ export function buildAgentResumeStartupPlan(args: { agent: args.agent, launchCommand, expectedProcess: TUI_AGENT_CONFIG[args.agent].expectedProcess, - followupPrompt: null, launchConfig, ...(args.agent === 'codex' ? { startupCommandDelivery: 'shell-ready' as const } : {}), ...(Object.keys(applied).length > 0 ? { sessionOptions: { ...applied } } : {}), diff --git a/src/shared/tui-agent-startup-hermes.test.ts b/src/shared/tui-agent-startup-hermes.test.ts index 4cb34a4ab7fe..401f7e8b6588 100644 --- a/src/shared/tui-agent-startup-hermes.test.ts +++ b/src/shared/tui-agent-startup-hermes.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { buildAgentStartupPlan } from './tui-agent-startup' +import { planLaunchForTest } from './launch-prompt-plan.test-fixture' import { unwrapPosixShellScript, unwrapPowerShellScript @@ -11,7 +11,7 @@ import { describe('hermes startup plans', () => { it('moves Hermes command override flags after the chat subcommand', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'hermes', prompt: 'run privately', cmdOverrides: { hermes: 'hermes --tui --provider anthropic' }, @@ -36,7 +36,7 @@ describe('hermes startup plans', () => { expected: "& 'C:\\Tools\\hermes.exe' 'chat'" } ])('preserves Windows paths in Hermes command overrides on $shell', (testCase) => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'hermes', prompt: 'run it', cmdOverrides: { hermes: testCase.override }, @@ -48,7 +48,7 @@ describe('hermes startup plans', () => { }) it('removes a configured duplicate chat subcommand', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'hermes', prompt: 'run it', cmdOverrides: { hermes: 'hermes --provider copilot chat --tui' }, @@ -62,7 +62,7 @@ describe('hermes startup plans', () => { }) it('preserves an option value named chat', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'hermes', prompt: 'run it', cmdOverrides: { hermes: 'hermes --profile chat --tui' }, @@ -73,7 +73,7 @@ describe('hermes startup plans', () => { }) it('keeps Orca ownership of the Hermes startup query and TUI mode', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'hermes', prompt: 'automation prompt', cmdOverrides: { hermes: 'hermes --query override --cli' }, @@ -90,7 +90,7 @@ describe('hermes startup plans', () => { }) it('preserves wrapper tokens before the Hermes executable', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'hermes', prompt: 'run it', cmdOverrides: { hermes: 'uv run hermes --tui' }, @@ -101,7 +101,7 @@ describe('hermes startup plans', () => { }) it('selects the final Hermes executable token in a wrapper', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'hermes', prompt: 'run it', cmdOverrides: { hermes: 'sudo -u hermes hermes --tui' }, @@ -114,7 +114,7 @@ describe('hermes startup plans', () => { }) it('selects the wrapped executable when the wrapper and command both name Hermes', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'hermes', prompt: 'run it', cmdOverrides: { hermes: 'sudo -u hermes hermes chat --tui' }, @@ -127,7 +127,7 @@ describe('hermes startup plans', () => { }) it('does not mistake a Hermes option value for a wrapped executable', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'hermes', prompt: 'run it', cmdOverrides: { hermes: 'hermes chat --resume hermes --tui' }, @@ -140,7 +140,7 @@ describe('hermes startup plans', () => { }) it('preserves POSIX environment-assignment command prefixes', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'hermes', prompt: 'run it', cmdOverrides: { hermes: 'HERMES_HOME=/tmp/test uv run hermes --tui' }, @@ -154,7 +154,7 @@ describe('hermes startup plans', () => { it('rejects a Hermes command override with no identifiable executable', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'hermes', prompt: 'run it', cmdOverrides: { hermes: 'custom-agent --tui' }, @@ -164,7 +164,7 @@ describe('hermes startup plans', () => { }) it('leaves a Hermes query past the safe Windows environment limit for the paste', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'hermes', prompt: 'x'.repeat(24_000), cmdOverrides: {}, @@ -172,13 +172,13 @@ describe('hermes startup plans', () => { }) expect(plan?.launchFile).toBeUndefined() - expect(plan?.followupPrompt).toBe('x'.repeat(24_000)) + expect(plan?.pasteAfterReady).toBe('x'.repeat(24_000)) }) it.each(['quote "this"', 'print %PATH%', 'toggle !feature!', 'inspect C:\\repo\\'])( 'keeps a complex cmd Hermes query out of command text: %s', (prompt) => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'hermes', prompt, cmdOverrides: {}, @@ -192,7 +192,7 @@ describe('hermes startup plans', () => { ) it('uses the Windows remote default shell for SSH Hermes queries', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'hermes', prompt: 'run remotely', cmdOverrides: {}, @@ -206,7 +206,7 @@ describe('hermes startup plans', () => { }) it('measures POSIX Hermes query limits in UTF-8 bytes, pasting past them', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'hermes', prompt: '界'.repeat(9_000), cmdOverrides: {}, @@ -214,11 +214,11 @@ describe('hermes startup plans', () => { }) expect(plan?.launchFile).toBeUndefined() - expect(plan?.followupPrompt).toBe('界'.repeat(9_000)) + expect(plan?.pasteAfterReady).toBe('界'.repeat(9_000)) }) it('keeps empty Hermes launches on the interactive TUI command', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'hermes', prompt: '', cmdOverrides: {}, @@ -227,6 +227,6 @@ describe('hermes startup plans', () => { }) expect(plan?.launchCommand).toBe('hermes --tui') - expect(plan?.followupPrompt).toBeNull() + expect(plan?.pasteAfterReady).toBeNull() }) }) diff --git a/src/shared/tui-agent-startup.test.ts b/src/shared/tui-agent-startup.test.ts index 27b487ba598c..5ba4245faf1b 100644 --- a/src/shared/tui-agent-startup.test.ts +++ b/src/shared/tui-agent-startup.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest' +import { planLaunchForTest } from './launch-prompt-plan.test-fixture' import { buildAgentDraftLaunchPlan, buildAgentResumeStartupPlan, - buildAgentStartupPlan, buildShellCommandFromArgv, planAgentCliArgsSuffix } from './tui-agent-startup' @@ -48,7 +48,7 @@ describe('tui agent startup plans', () => { // Structured native chat stopped reading the configured arguments; a terminal launch must // still spell every token of them, in order, exactly as the user wrote them. it('passes the whole configured argument string to a terminal launch', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'claude', prompt: '', agentArgs: resolveTuiAgentLaunchArgs('claude', { @@ -64,7 +64,7 @@ describe('tui agent startup plans', () => { }) it('uses POSIX quoting when the target shell is Linux', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'claude', prompt: "fix Bob's branch", cmdOverrides: {}, @@ -75,7 +75,7 @@ describe('tui agent startup plans', () => { }) it('uses PowerShell quoting by default when the target shell is Windows', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'claude', prompt: "fix Bob's \u2018quoted\u2019 branch", cmdOverrides: {}, @@ -93,7 +93,7 @@ describe('tui agent startup plans', () => { }) it('uses cmd escaping when requested explicitly', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'claude', prompt: 'fix "quoted" & %PATH%', cmdOverrides: {}, @@ -108,7 +108,7 @@ describe('tui agent startup plans', () => { 'never types a line break into %s: a multi-line prompt rides a launch file', (shell) => { const prompt = 'fix the build\r\n& echo PWNED\nthen run tests' - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'claude', prompt, cmdOverrides: {}, @@ -124,7 +124,7 @@ describe('tui agent startup plans', () => { ) it('keeps a multi-line prompt inline for a POSIX shell on a POSIX host, which stages it', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'claude', prompt: 'line one\nline two', cmdOverrides: {}, @@ -138,7 +138,7 @@ describe('tui agent startup plans', () => { // Why: a Windows host stages nothing, so Git Bash would get the raw line break typed. it('points Git Bash on a Windows host at a launch file for a multi-line prompt', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'claude', prompt: 'line one\nline two', cmdOverrides: {}, @@ -163,7 +163,7 @@ describe('tui agent startup plans', () => { }) it('terminates Grok options before a flag-shaped POSIX prompt', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'grok', prompt: '--version', cmdOverrides: {}, @@ -174,7 +174,7 @@ describe('tui agent startup plans', () => { }) it('terminates Grok options before a flag-shaped PowerShell prompt', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'grok', prompt: '-h', cmdOverrides: {}, @@ -185,7 +185,7 @@ describe('tui agent startup plans', () => { }) it('terminates Grok options before a subcommand-shaped cmd prompt', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'grok', prompt: 'help', cmdOverrides: {}, @@ -197,7 +197,7 @@ describe('tui agent startup plans', () => { }) it('places the Grok prompt separator after configured agent arguments', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'grok', prompt: '--version', cmdOverrides: {}, @@ -209,7 +209,7 @@ describe('tui agent startup plans', () => { }) it('does not add the Grok prompt separator to other argv agents', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'codex', prompt: '--version', cmdOverrides: {}, @@ -225,7 +225,7 @@ describe('tui agent startup plans', () => { { platform: 'win32' as const, shell: 'cmd' as const } ])('delivers multiline Hermes queries through a child-only expansion on $shell', (testCase) => { const prompt = 'first line\nsecond "quoted" line with %PATH%' - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'hermes', prompt, cmdOverrides: {}, @@ -235,7 +235,7 @@ describe('tui agent startup plans', () => { }) expect(plan?.launchCommand).not.toContain(prompt) - expect(plan?.followupPrompt).toBeNull() + expect(plan?.pasteAfterReady).toBeNull() expect(plan?.launchConfig.agentCommand).toBe('hermes --tui') expect(plan?.env?.ORCA_HERMES_STARTUP_QUERY).toBe(prompt) const script = @@ -259,7 +259,7 @@ describe('tui agent startup plans', () => { }) it('uses a sh invocation that POSIX-host PowerShell can parse', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'hermes', prompt: 'run it', cmdOverrides: {}, @@ -276,7 +276,7 @@ describe('tui agent startup plans', () => { }) it('does not launch Codex with the Orca profile when agent status hooks are enabled', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'codex', prompt: 'fix it', cmdOverrides: {}, @@ -288,7 +288,7 @@ describe('tui agent startup plans', () => { }) it('keeps plain empty Codex startup on the fast delivery path', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'codex', prompt: '', cmdOverrides: {}, @@ -300,13 +300,14 @@ describe('tui agent startup plans', () => { agent: 'codex', launchCommand: 'codex', expectedProcess: 'codex', - followupPrompt: null, + carry: 'none', + pasteAfterReady: null, launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} } }) }) it('launches Claude without Orca settings injection', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'claude', prompt: 'fix it', cmdOverrides: {}, @@ -318,7 +319,7 @@ describe('tui agent startup plans', () => { }) it('uses the Linux Orca CLI command for Claude Agent Teams launches', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'claude-agent-teams', prompt: '', cmdOverrides: {}, @@ -334,7 +335,7 @@ describe('tui agent startup plans', () => { // `orca-ide` GNOME-screen-reader workaround), so a remote launch must not // emit `orca-ide claude-teams` — that name is not on the remote PATH and // `claude-teams` is rejected by the relay's CLI switch (issue #6500). - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'claude-agent-teams', prompt: '', cmdOverrides: {}, @@ -349,7 +350,7 @@ describe('tui agent startup plans', () => { it('keeps the Windows orca.cmd shim for Claude Agent Teams on SSH remotes', () => { // Why: the Windows remote shim is also `orca.cmd`, matching the local // win32 override, so remoteness must not alter the Windows command. - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'claude-agent-teams', prompt: '', cmdOverrides: {}, @@ -365,7 +366,7 @@ describe('tui agent startup plans', () => { // Why: the `orca-ide` rename is still required for a local Linux desktop // install (avoids shadowing the GNOME Orca screen reader), so an explicit // isRemote:false must preserve it. - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'claude-agent-teams', prompt: '', cmdOverrides: {}, @@ -378,7 +379,7 @@ describe('tui agent startup plans', () => { }) it('launches OpenClaude as a distinct argv agent', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'openclaude', prompt: 'fix it', cmdOverrides: {}, @@ -389,13 +390,14 @@ describe('tui agent startup plans', () => { agent: 'openclaude', launchCommand: "openclaude 'fix it'", expectedProcess: 'openclaude', - followupPrompt: null, + carry: 'on-line', + pasteAfterReady: null, launchConfig: { agentCommand: 'openclaude', agentArgs: '', agentEnv: {} } }) }) it('launches Mistral Vibe through the installed vibe executable', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'mistral-vibe', prompt: 'fix it', cmdOverrides: {}, @@ -406,13 +408,14 @@ describe('tui agent startup plans', () => { agent: 'mistral-vibe', launchCommand: 'vibe', expectedProcess: 'vibe', - followupPrompt: 'fix it', + carry: 'paste-after-ready', + pasteAfterReady: 'fix it', launchConfig: { agentCommand: 'vibe', agentArgs: '', agentEnv: {} } }) }) it('launches Qwen Code through the installed qwen executable', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'qwen-code', prompt: 'fix it', cmdOverrides: {}, @@ -423,7 +426,8 @@ describe('tui agent startup plans', () => { agent: 'qwen-code', launchCommand: 'qwen', expectedProcess: 'qwen', - followupPrompt: 'fix it', + carry: 'paste-after-ready', + pasteAfterReady: 'fix it', launchConfig: { agentCommand: 'qwen', agentArgs: '', agentEnv: {} } }) }) @@ -440,7 +444,7 @@ describe('tui agent startup plans', () => { ] as const)( 'launches Muse in %s mode on %s/%s before delivering its prompt', (_, platform, shell, defaults, command) => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'muse', prompt: 'fix it', cmdOverrides: {}, @@ -453,7 +457,7 @@ describe('tui agent startup plans', () => { agent: 'muse', launchCommand: command, expectedProcess: 'muse', - followupPrompt: 'fix it' + pasteAfterReady: 'fix it' }) } ) @@ -461,19 +465,19 @@ describe('tui agent startup plans', () => { it.each(['exec', 'resume', '--help'])( 'delivers the reserved Muse prompt %s as text', (prompt) => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'muse', prompt, cmdOverrides: {}, platform: 'linux' }) expect(plan?.launchCommand).toBe('muse --trust-workspace') - expect(plan?.followupPrompt).toBe(prompt) + expect(plan?.pasteAfterReady).toBe(prompt) } ) it('leaves Claude command overrides untouched', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'claude', prompt: 'fix it', cmdOverrides: { claude: 'claude --dangerously-skip-permissions' }, @@ -484,7 +488,7 @@ describe('tui agent startup plans', () => { }) it('leaves Codex command overrides untouched', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'codex', prompt: 'fix it', cmdOverrides: { codex: 'codex --profile work' }, @@ -587,7 +591,7 @@ describe('tui agent startup plans', () => { }) it('appends shell-quoted CLI arguments before prompt delivery flags', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'claude', prompt: 'fix it', cmdOverrides: {}, @@ -601,7 +605,7 @@ describe('tui agent startup plans', () => { }) it('uses PowerShell quoting for CLI arguments on Windows', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'claude', prompt: 'fix it', cmdOverrides: {}, @@ -613,7 +617,7 @@ describe('tui agent startup plans', () => { }) it('carries agent launch environment defaults into startup plans', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'goose', prompt: '', cmdOverrides: {}, @@ -632,7 +636,7 @@ describe('tui agent startup plans', () => { }) it('captures empty args and env as explicit launch config values', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'claude', prompt: '', cmdOverrides: {}, @@ -649,7 +653,7 @@ describe('tui agent startup plans', () => { const agentDefaultArgs = normalizeTuiAgentArgsRecord({ opencode: '--dangerously-skip-permissions' }) - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'opencode', prompt: 'fix it', cmdOverrides: {}, @@ -709,7 +713,7 @@ describe('tui agent startup plans', () => { }) it('appends Kiro trust defaults to the chat subcommand that accepts them', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'kiro', prompt: 'fix it', cmdOverrides: {}, @@ -721,7 +725,7 @@ describe('tui agent startup plans', () => { }) it('launches Continue through the documented cn binary', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'continue', prompt: 'fix it', cmdOverrides: {}, @@ -795,7 +799,7 @@ describe('tui agent startup plans', () => { }) it('launches Devin with stdin-after-start prompt delivery', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'devin', prompt: 'fix the tests', cmdOverrides: {}, @@ -806,7 +810,8 @@ describe('tui agent startup plans', () => { agent: 'devin', launchCommand: "devin '--permission-mode' 'bypass' '--respect-workspace-trust' 'false'", expectedProcess: 'devin', - followupPrompt: 'fix the tests', + carry: 'paste-after-ready', + pasteAfterReady: 'fix the tests', launchConfig: { agentCommand: "devin '--permission-mode' 'bypass' '--respect-workspace-trust' 'false'", agentArgs: '--permission-mode bypass --respect-workspace-trust false', diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index 99009b63ffae..ee016edf1f55 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -13,9 +13,9 @@ import type { LaunchFile } from './launch-prompt-file' import { carryLaunchPrompt, launchFileDirectoryGrant, - launchFileProps, - windowsShellDamagesPrompt -} from './startup-plan-launch-file' + windowsShellDamagesPrompt, + type LaunchPromptPlan +} from './launch-prompt-carry' import { TUI_AGENT_CONFIG } from './tui-agent-config' import type { StartupCommandDelivery } from './codex-startup-delivery' import { buildSleepingAgentLaunchConfig } from './sleeping-agent-launch-config' @@ -31,7 +31,6 @@ export type AgentStartupPlan = { agent: TuiAgent launchCommand: string expectedProcess: string - followupPrompt: string | null launchConfig: SleepingAgentLaunchConfig launchToken?: string draftPrompt?: string | null @@ -40,21 +39,17 @@ export type AgentStartupPlan = { /** Values actually emitted into this launch command, kept as base model ids * so the native-chat surface can render only launch-backed state. */ sessionOptions?: Record - /** Holds the prompt the command points at; the host writes it before typing the command. */ - launchFile?: LaunchFile } function appliedSessionOptionProps(values: Record) { return Object.keys(values).length > 0 ? { sessionOptions: { ...values } } : {} } -export type AgentStartupPlanArgs = { +export type AgentStartupPlanInputs = { agent: TuiAgent - prompt: string cmdOverrides: Partial> platform: NodeJS.Platform shell?: AgentStartupShell - allowEmptyPromptLaunch?: boolean agentArgs?: string | null agentEnv?: Record | null sessionOptions?: Record @@ -62,25 +57,38 @@ export type AgentStartupPlanArgs = { /** Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only * `orca-ide` rename must be skipped for remote launches. */ isRemote?: boolean - /** The file `prompt` points at, when the caller already moved the prompt into one. */ +} + +/** An agent launch with no prompt; a launch that offers one is planned by `planLaunchPrompt`. */ +export function buildAgentStartupPlan( + args: AgentStartupPlanInputs & { prompt?: ''; allowEmptyPromptLaunch?: boolean } +): AgentStartupPlan | null { + return args.allowEmptyPromptLaunch === true + ? buildPlanWithPromptOnLine({ ...args, prompt: '' }) + : null +} + +export type AgentLaunchPromptArgs = AgentStartupPlanInputs & { + prompt: string + /** The file `prompt` already points at, when the caller wrote its own. */ launchFile?: LaunchFile - /** False for a paired host, which is sent a command and never a launch file: a prompt that would - * need one launches clean and is left in `followupPrompt` for the paste after ready. */ - hostWritesLaunchFile?: boolean - /** The caller minted a secret in the prompt: it rides a launch file, never argv or history. */ - sensitive?: boolean + /** Another Orca this client drives; see `CarriedPlanArgs`. */ + launchHostIsPaired?: boolean } /** - * The one place that decides whether a launch prompt rides the line, a launch file, or the paste - * after ready (`carryLaunchPrompt`); every launch path builds through it. + * A launch that offers a prompt: where it rides (`carryLaunchPrompt`) and the plan to launch. Every + * launch path plans its prompt here, and must switch on the outcome to reach the plan. */ -export function buildAgentStartupPlan(args: AgentStartupPlanArgs): AgentStartupPlan | null { +export function planLaunchPrompt( + args: AgentLaunchPromptArgs +): LaunchPromptPlan | null { return carryLaunchPrompt(args, buildPlanWithPromptOnLine) } -function buildPlanWithPromptOnLine(args: AgentStartupPlanArgs): AgentStartupPlan | null { - const { agent, prompt, cmdOverrides, platform, allowEmptyPromptLaunch = false } = args +/** Builds the line with `prompt` on it; the carry rule decides whether it should. */ +function buildPlanWithPromptOnLine(args: AgentLaunchPromptArgs): AgentStartupPlan | null { + const { agent, prompt, cmdOverrides, platform } = args const shell = resolveStartupShell(platform, args.shell) const trimmedPrompt = prompt.trim() const config = TUI_AGENT_CONFIG[agent] @@ -108,24 +116,18 @@ function buildPlanWithPromptOnLine(args: AgentStartupPlanArgs): AgentStartupPlan }) if (!trimmedPrompt) { - if (!allowEmptyPromptLaunch) { - return null - } return { agent, launchCommand, expectedProcess: config.expectedProcess, - followupPrompt: null, launchConfig, ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) } } - const launchFile = args.launchFile const quotedPrompt = quoteStartupArg(trimmedPrompt, shell) - const fileProps = launchFileProps(launchFile, shell) - const grant = launchFileDirectoryGrant(agent, launchFile, shell) + const grant = launchFileDirectoryGrant(agent, args.launchFile, shell) if (config.promptInjectionMode === 'argv') { const promptSeparator = config.argvPromptSeparator ? ` ${config.argvPromptSeparator}` : '' @@ -140,12 +142,10 @@ function buildPlanWithPromptOnLine(args: AgentStartupPlanArgs): AgentStartupPlan ) : `${launchCommand}${grant}${promptSeparator} ${quotedPrompt}`, expectedProcess: config.expectedProcess, - followupPrompt: null, launchConfig, ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), ...(agent === 'codex' ? { startupCommandDelivery: 'shell-ready' as const } : {}), - ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}), - ...fileProps + ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) } } @@ -154,11 +154,9 @@ function buildPlanWithPromptOnLine(args: AgentStartupPlanArgs): AgentStartupPlan agent, launchCommand: `${launchCommand}${grant} --prompt ${quotedPrompt}`, expectedProcess: config.expectedProcess, - followupPrompt: null, launchConfig, ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), - ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}), - ...fileProps + ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) } } @@ -181,12 +179,9 @@ function buildPlanWithPromptOnLine(args: AgentStartupPlanArgs): AgentStartupPlan // only bounds and quotes the native invocation before starting the TUI. launchCommand: queryPlan.command, expectedProcess: config.expectedProcess, - followupPrompt: null, launchConfig, ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), - ...(queryPlan.env ? { env: queryPlan.env } : {}), - // Hermes reads its prompt from the env, where a line break is harmless. - ...launchFileProps(args.launchFile, shell) + ...(queryPlan.env ? { env: queryPlan.env } : {}) } } @@ -195,11 +190,9 @@ function buildPlanWithPromptOnLine(args: AgentStartupPlanArgs): AgentStartupPlan agent, launchCommand: `${launchCommand}${grant} --prompt-interactive ${quotedPrompt}`, expectedProcess: config.expectedProcess, - followupPrompt: null, launchConfig, ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), - ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}), - ...fileProps + ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) } } @@ -208,29 +201,20 @@ function buildPlanWithPromptOnLine(args: AgentStartupPlanArgs): AgentStartupPlan agent, launchCommand: `${launchCommand}${grant} -i ${quotedPrompt}`, expectedProcess: config.expectedProcess, - followupPrompt: null, launchConfig, ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), - ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}), - ...fileProps + ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) } } - return { - agent, - launchCommand, - expectedProcess: config.expectedProcess, - followupPrompt: trimmedPrompt, - launchConfig, - ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), - ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) - } + // `stdin-after-start`: no line carries its prompt; `carryLaunchPrompt` pastes it instead. + return null } /** * Whether this agent's prompt rides the launch command rather than the live PTY. * - * The same question `buildAgentStartupPlan` answers by returning `followupPrompt: null`, asked + * The same question `planLaunchPrompt` answers with any outcome but `paste-after-ready`, asked * before a command exists — a caller deciding how to deliver a prompt has to know which half it is * getting while it is still choosing what to create. Derived from the one injection table rather * than restating it, and pinned against the builder for every agent by diff --git a/src/shared/typed-startup-line.ts b/src/shared/typed-startup-line.ts index 409b6bc03a3f..4829179e3033 100644 --- a/src/shared/typed-startup-line.ts +++ b/src/shared/typed-startup-line.ts @@ -2,9 +2,6 @@ * and 1 KiB did not. */ export const TYPED_STARTUP_LINE_BUDGET_BYTES = 512 -/** cmd.exe's documented line cap, the smallest of the Windows shells Orca types into. */ -export const WINDOWS_TYPED_STARTUP_LINE_MAX_CHARS = 8191 - const encoder = new TextEncoder() /** Any C0 byte or DEL: no quoter escapes them, so a line editor reads them as keys. */ @@ -23,8 +20,3 @@ export function hasControlByte(line: string): boolean { export function typedStartupLineFits(line: string): boolean { return !hasControlByte(line) && encoder.encode(line).byteLength <= TYPED_STARTUP_LINE_BUDGET_BYTES } - -/** The same question for a Windows host, which cannot stage: only its own line cap applies. */ -export function windowsTypedStartupLineFits(line: string): boolean { - return !hasControlByte(line) && line.length <= WINDOWS_TYPED_STARTUP_LINE_MAX_CHARS -} diff --git a/src/shared/windows-shell-prompt-damage.test.ts b/src/shared/windows-shell-prompt-damage.test.ts index 2ae358455039..579f2639981d 100644 --- a/src/shared/windows-shell-prompt-damage.test.ts +++ b/src/shared/windows-shell-prompt-damage.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it } from 'vitest' +import { planLaunchForTest } from './launch-prompt-plan.test-fixture' import { buildLaunchFilePointer, isLaunchFilePointer } from './launch-prompt-file' -import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from './tui-agent-startup' +import { buildAgentDraftLaunchPlan } from './tui-agent-startup' import type { AgentStartupShell } from './tui-agent-startup-shell' -import { windowsDraftRefusal } from './startup-plan-launch-file' +import { windowsDraftRefusal } from './launch-prompt-carry' function plan(prompt: string, shell: AgentStartupShell) { - return buildAgentStartupPlan({ + return planLaunchForTest({ agent: 'claude', prompt, cmdOverrides: {}, @@ -76,17 +77,17 @@ describe('a prompt a Windows shell would damage on the launch line', () => { }) it('launches clean and leaves the prompt for the paste when the host writes no launch file', () => { - const startup = buildAgentStartupPlan({ + const startup = planLaunchForTest({ agent: 'claude', prompt: 'fix the build\nthen run the tests', cmdOverrides: {}, platform: 'win32', shell: 'powershell', - hostWritesLaunchFile: false + launchHostIsPaired: true }) expect(startup?.launchFile).toBeUndefined() expect(startup?.launchCommand).toBe('claude') - expect(startup?.followupPrompt).toBe('fix the build\nthen run the tests') + expect(startup?.pasteAfterReady).toBe('fix the build\nthen run the tests') }) it('tells its own pointer apart from a prompt that merely mentions one', () => { diff --git a/src/shared/wsl-launch-directory.test.ts b/src/shared/wsl-launch-directory.test.ts index c14dee379a22..4b4c20687db4 100644 --- a/src/shared/wsl-launch-directory.test.ts +++ b/src/shared/wsl-launch-directory.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { LaunchFileUnavailableError, writeSpawnLaunchFile } from './launch-file-writing' -import { buildLaunchFilePointer, planLaunchPrompt, type LaunchFile } from './launch-prompt-file' +import { buildLaunchFilePointer, carryInLaunchFile, type LaunchFile } from './launch-prompt-file' import { stageStartupCommand } from './startup-command-staging' import { parseWslLaunchDirectory, type WslLaunchDirectory } from './wsl-launch-directory' @@ -21,7 +21,7 @@ afterEach(() => { }) function launchFile(content = 'fix the build'): LaunchFile { - const planned = planLaunchPrompt(content, { sensitive: true }) + const planned = carryInLaunchFile(content, true) return { ...planned.launchFile!, quoting: 'posix' } } diff --git a/src/shared/zcode-orchestration-contract.test.ts b/src/shared/zcode-orchestration-contract.test.ts index ba25cbceaef3..039dd522f288 100644 --- a/src/shared/zcode-orchestration-contract.test.ts +++ b/src/shared/zcode-orchestration-contract.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' +import { planLaunchForTest } from './launch-prompt-plan.test-fixture' import { getAgentSessionOptionCatalog } from './agent-session-option-catalog' -import { buildAgentStartupPlan, agentPromptRidesLaunchCommand } from './tui-agent-startup' +import { agentPromptRidesLaunchCommand } from './tui-agent-startup' import { TUI_AGENT_CONFIG } from './tui-agent-config' import { YOLO_TUI_AGENT_ARGS } from './tui-agent-permissions' import { recognizeAgentProcess } from './agent-process-recognition' @@ -32,7 +33,7 @@ describe('ZCode orchestration and send-to-agent contract', () => { }) it('keeps the prompt out of the launch command and hands it back as a follow-up', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'zcode', prompt: 'refactor the parser', cmdOverrides: {}, @@ -40,11 +41,11 @@ describe('ZCode orchestration and send-to-agent contract', () => { }) expect(plan?.launchCommand).toBe('zcode') expect(plan?.launchCommand).not.toContain('refactor the parser') - expect(plan?.followupPrompt).toBe('refactor the parser') + expect(plan?.pasteAfterReady).toBe('refactor the parser') }) it('carries a mode chosen through agent args onto the launch command', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'zcode', prompt: '', cmdOverrides: {}, From 4635a6406ff23fd85f73cbd5253f5f9e5b24c31b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 01:50:33 -0700 Subject: [PATCH 15/42] docs(daemon): mark the pre-v41 launch-file refusal temporary, with why and when it ends --- src/main/daemon/daemon-pty-spawn-request.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/daemon/daemon-pty-spawn-request.ts b/src/main/daemon/daemon-pty-spawn-request.ts index f453741535ff..3e224c244061 100644 --- a/src/main/daemon/daemon-pty-spawn-request.ts +++ b/src/main/daemon/daemon-pty-spawn-request.ts @@ -111,7 +111,9 @@ export abstract class DaemonPtySpawnRequest extends DaemonPtyRuntimeState { throw new Error('client_disconnected') } // Why refuse: an older daemon (a same-id respawn routed to it) would drop the file and type - // a line naming it. The refusal reaches the user with the prompt to copy. + // a line naming it. The refusal reaches the user with the prompt to copy. Temporary: main + // pastes here, but a paste needs a second, prompt-less plan and a guarded paste at every + // spawn site; only sessions an older daemon still holds route here, and they end when closed. if ( !context.attachOnly && opts.launchFile && From 9e5108af501c65d1591b6f17da29fd7b5af28e31 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 02:42:58 -0700 Subject: [PATCH 16/42] fix(agent-launch): keep the local socket alive while a prompted launch waits for its agent A launch with a prompt now waits up to 60 s for the terminal agent to be ready before it writes the prompt, and reports not-delivered when the agent never is. The local runtime socket closes a connection idle for 30 s unless the request is a long poll, so a launch whose agent exited at startup lost its reply and the caller saw 'runtime closed the connection' instead of not-delivered. Classify a prompted agent.launch and agent.launchReplay as a long poll, as orchestration.workerStart already is for the same wait. --- .../runtime-rpc-long-poll-transport.test.ts | 19 +++++++++++++++++++ .../runtime-rpc/runtime-rpc-long-poll.ts | 10 ++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/main/runtime/runtime-rpc-long-poll-transport.test.ts b/src/main/runtime/runtime-rpc-long-poll-transport.test.ts index c17e045b4664..c23b4e6b28be 100644 --- a/src/main/runtime/runtime-rpc-long-poll-transport.test.ts +++ b/src/main/runtime/runtime-rpc-long-poll-transport.test.ts @@ -44,6 +44,25 @@ describe('OrcaRuntimeRpcServer', () => { ).toBe('wait') }) + it.each(['agent.launch', 'agent.launchReplay'])( + 'keeps a prompted %s alive while it waits for the agent to be ready', + (method) => { + const prompt = { text: 'fix the build', delivery: 'submit' } + expect( + classifyRuntimeLongPoll({ + id: 'req_launch', + authToken: 'token', + method, + params: { prompt } + }) + ).toBe('wait') + // Without a prompt nothing waits on the agent, so the launch stays a short call. + expect( + classifyRuntimeLongPoll({ id: 'req_launch', authToken: 'token', method, params: {} }) + ).toBeNull() + } + ) + it('keeps agent-prompt submission sockets alive during verification', () => { expect( classifyRuntimeLongPoll({ diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-long-poll.ts b/src/main/runtime/runtime-rpc/runtime-rpc-long-poll.ts index 02d8422547ea..15fc3af44f26 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-long-poll.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-long-poll.ts @@ -26,6 +26,16 @@ export function classifyRuntimeLongPoll(request: RpcRequest): RuntimeLongPollCla if (request.method === 'orchestration.workerStart') { return 'wait' } + // A launch with a prompt waits for the agent's readiness before writing it, up to 60 s, and a + // reply lost to the 30 s idle timer reads as a dead runtime instead of an undelivered prompt. + if ( + (request.method === 'agent.launch' || request.method === 'agent.launchReplay') && + typeof request.params === 'object' && + request.params !== null && + (request.params as { prompt?: unknown }).prompt !== undefined + ) { + return 'wait' + } if (request.method === 'browser.clientHost.attach') { return 'browser-host' } From c9b4dc0578c3ff7dea91facdf71859140554abdd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 02:44:11 -0700 Subject: [PATCH 17/42] refactor(agent-launch): narrow the launch params by 'in' instead of a cast --- src/main/runtime/runtime-rpc/runtime-rpc-long-poll.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-long-poll.ts b/src/main/runtime/runtime-rpc/runtime-rpc-long-poll.ts index 15fc3af44f26..c1ddeb78a6ed 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-long-poll.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-long-poll.ts @@ -32,7 +32,8 @@ export function classifyRuntimeLongPoll(request: RpcRequest): RuntimeLongPollCla (request.method === 'agent.launch' || request.method === 'agent.launchReplay') && typeof request.params === 'object' && request.params !== null && - (request.params as { prompt?: unknown }).prompt !== undefined + 'prompt' in request.params && + request.params.prompt !== undefined ) { return 'wait' } From ea0c65d04191b68c00f1f4c00b69ab5fbc8e0156 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 02:50:06 -0700 Subject: [PATCH 18/42] fix(launch-prompt): one host-derived carry rule, receipts proven like the crash guard - The carried prompt's receipt is delivered only on the agent's hook turn or a fresh foreground read that finds the launched agent in front; readiness alone never counts and bracketed paste turned off revokes it. An unknown read is reported as unconfirmed. - Host facts (paired, proves the agent in front) come from describeLaunchHost; a caller that cannot paste keeps the prompt on the line as main did instead of refusing. - Windows keeps any exact line up to cmd's 8,191 cap on the line; only damaged or multi-line prompts ride a launch file. - A launch-file refusal is a "failed without effects" code for agent.launch, and the desktop closes the tab it opened for it. - Launch-file folders carry their owner's pid; the sweep never removes a live owner's. - The terminal-handle wait keeps waiting while that tab's spawn is still in flight. --- ...terminal-host-wsl-launch-directory.test.ts | 2 +- .../ipc/pty/ipc/launched-agent-foreground.ts | 19 +++ src/main/ipc/pty/ipc/spawn.ts | 9 +- src/main/ipc/pty/register-handlers.ts | 3 + .../agent-terminal-startup-prompt.test.ts | 2 +- .../launch-host-agent-in-front.test.ts | 16 --- .../runtime/launch-host-agent-in-front.ts | 14 -- ...ca-runtime-agent-session-operation.test.ts | 17 ++- .../orca-runtime-create-agent-session.ts | 12 +- ...ca-runtime-create-terminal-desktop.test.ts | 77 +++++++++++ .../orca-runtime-create-terminal-desktop.ts | 14 +- ...resolve-mobile-session-terminal-command.ts | 14 +- ...runtime-resolve-worktree-removal-target.ts | 12 +- ...renderer-session-owned-mobile-terminals.ts | 24 +++- .../mobile-session-tabs-part-12.spec.ts | 26 ++++ .../methods/agent-launch-failure-code.test.ts | 32 +++++ .../rpc/methods/agent-launch-failure-code.ts | 10 ++ .../runtime/runtime-worktree-agent-startup.ts | 8 +- .../runtime/terminal-launch-refusals.test.ts | 35 ++++- src/main/runtime/terminal-launch-refusals.ts | 25 ++++ src/main/runtime/this-orca-launch-host.ts | 9 ++ src/preload/api/pty-api.ts | 2 + .../pty-bridge-stream-and-serialization.ts | 5 + .../buildSourceControlAgentDeliveryPlan.ts | 6 +- .../useSourceControlAgentActionStart.ts | 5 +- .../folder-workspace-composer-submit.ts | 9 +- .../full-creation-startup.test.ts | 4 +- .../composer-state/full-submit-preparation.ts | 9 +- .../quick-creation-execution.ts | 8 +- .../composer-state/quick-startup-plan.ts | 8 +- src/renderer/src/i18n/locales/en.json | 5 + src/renderer/src/i18n/locales/es.json | 5 + src/renderer/src/i18n/locales/fr.json | 5 + src/renderer/src/i18n/locales/ja.json | 5 + src/renderer/src/i18n/locales/ko.json | 5 + src/renderer/src/i18n/locales/zh.json | 5 + .../src/lib/agent-draft-readiness.test.ts | 58 +++++++- src/renderer/src/lib/agent-draft-readiness.ts | 27 +++- .../agent-launch-caller-permissions.test.ts | 2 +- .../lib/agent-launch-caller-placement.test.ts | 2 +- ...ent-launch-caller-prompt-transport.test.ts | 2 +- ...gent-launch-prompt-not-delivered-notice.ts | 22 ++++ .../lib/agent-launch-prompt-receipt.test.ts | 124 +++++++++++------- .../src/lib/agent-launch-prompt-receipt.ts | 116 +++++++++------- src/renderer/src/lib/agent-paste-draft.ts | 2 +- ...ch-agent-background-session-remote.test.ts | 46 ++++++- .../lib/launch-agent-background-session.ts | 16 ++- .../src/lib/launch-agent-in-new-tab.test.ts | 4 +- .../src/lib/launch-agent-in-new-tab.ts | 8 +- .../launch-agent-new-tab-prompt-delivery.ts | 19 ++- .../lib/launch-agent-startup-prompt-plan.ts | 29 ++-- src/renderer/src/lib/launch-file-host.test.ts | 6 +- src/renderer/src/lib/launch-file-host.ts | 21 ++- .../launch-work-item-direct-agent-routing.ts | 9 +- .../lib/launch-work-item-direct-agent.test.ts | 12 +- .../src/lib/launch-work-item-direct-agent.ts | 8 +- ...unch-work-item-direct-route-preparation.ts | 5 +- .../lib/source-control-agent-action-plan.ts | 51 +++---- .../src/web/preload-api/web-terminal-api.ts | 1 + src/shared/launch-file-writing.test.ts | 51 ++++++- src/shared/launch-file-writing.ts | 30 ++++- src/shared/launch-host.test.ts | 21 +++ src/shared/launch-host.ts | 28 ++++ src/shared/launch-prompt-carrier.test.ts | 4 +- src/shared/launch-prompt-carry.test.ts | 83 ++++++++---- src/shared/launch-prompt-carry.ts | 67 ++++++---- src/shared/launch-prompt-file.ts | 7 +- src/shared/launch-prompt-plan.test-fixture.ts | 20 ++- src/shared/tui-agent-startup.ts | 11 +- .../windows-shell-prompt-damage.test.ts | 8 +- 70 files changed, 1043 insertions(+), 343 deletions(-) create mode 100644 src/main/ipc/pty/ipc/launched-agent-foreground.ts delete mode 100644 src/main/runtime/launch-host-agent-in-front.test.ts delete mode 100644 src/main/runtime/launch-host-agent-in-front.ts create mode 100644 src/main/runtime/orca-runtime-create-terminal-desktop.test.ts create mode 100644 src/main/runtime/rpc/methods/agent-launch-failure-code.test.ts create mode 100644 src/main/runtime/this-orca-launch-host.ts create mode 100644 src/shared/launch-host.test.ts create mode 100644 src/shared/launch-host.ts diff --git a/src/main/daemon/terminal-host-wsl-launch-directory.test.ts b/src/main/daemon/terminal-host-wsl-launch-directory.test.ts index 6e6354f385bc..5c54ebdccf5b 100644 --- a/src/main/daemon/terminal-host-wsl-launch-directory.test.ts +++ b/src/main/daemon/terminal-host-wsl-launch-directory.test.ts @@ -51,7 +51,7 @@ describe('a daemon WSL session with a launch file', () => { it('writes the file into the distro and types its Linux path', async () => { await create({ distro: 'Ubuntu', windowsPath: windowsSide, linuxPath: '/home/ada/.cache/orca' }) const command = spawn.mock.calls[0]?.[0].command ?? '' - const linuxPath = /\/home\/ada\/\.cache\/orca\/orca-launch-file-\w+\/task-context\.md/.exec( + const linuxPath = /\/home\/ada\/\.cache\/orca\/orca-launch-file-\d+-\w+\/task-context\.md/.exec( command )?.[0] expect(command).toBe(`claude '${buildLaunchFilePointer(linuxPath ?? 'missing')}'`) diff --git a/src/main/ipc/pty/ipc/launched-agent-foreground.ts b/src/main/ipc/pty/ipc/launched-agent-foreground.ts new file mode 100644 index 000000000000..0d8246cef57b --- /dev/null +++ b/src/main/ipc/pty/ipc/launched-agent-foreground.ts @@ -0,0 +1,19 @@ +import { getPtyIpc } from '../../pty-host-bindings' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import type { LaunchedAgentForeground } from '../../../runtime/launched-agent-foreground' + +/** The renderer's launch-prompt receipt asks the question #24257's crash guard asks before a paste. */ +export function installLaunchedAgentForegroundIpcHandler( + runtime: OrcaRuntimeService | undefined +): void { + getPtyIpc().handle( + 'pty:readLaunchedAgentForeground', + async (_event, args: { id: string; agent: string }): Promise => { + if (!runtime || typeof args?.id !== 'string' || !isTuiAgent(args.agent)) { + return 'unknown' + } + return runtime.readLaunchedAgentForeground(args.id, args.agent) + } + ) +} diff --git a/src/main/ipc/pty/ipc/spawn.ts b/src/main/ipc/pty/ipc/spawn.ts index e35b40ee637e..dcd8a89db733 100644 --- a/src/main/ipc/pty/ipc/spawn.ts +++ b/src/main/ipc/pty/ipc/spawn.ts @@ -2,7 +2,10 @@ import { getPtyIpc } from '../../pty-host-bindings' import { runPtyIpcSpawn } from './spawn-run' import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' import { isLaunchFileUnavailableMessage } from '../../../../shared/launch-prompt-file' -import { recordTerminalLaunchRefusal } from '../../../runtime/terminal-launch-refusals' +import { + noteTerminalPaneSpawn, + recordTerminalLaunchRefusal +} from '../../../runtime/terminal-launch-refusals' export function installPtySpawnIpcHandler(deps: PtySpawnIpcDeps): void { const ipcMain = getPtyIpc() @@ -13,6 +16,8 @@ export function installPtySpawnIpcHandler(deps: PtySpawnIpcDeps): void { if (startupPromise) { await startupPromise } + // Why: a create waiting on this tab's handle keeps waiting while the spawn still runs. + const settleSpawn = args.tabId ? noteTerminalPaneSpawn(args.tabId) : () => {} try { return await runPtyIpcSpawn(deps, args) } catch (error) { @@ -22,6 +27,8 @@ export function installPtySpawnIpcHandler(deps: PtySpawnIpcDeps): void { recordTerminalLaunchRefusal(args.tabId, message) } throw error + } finally { + settleSpawn() } }) } diff --git a/src/main/ipc/pty/register-handlers.ts b/src/main/ipc/pty/register-handlers.ts index 9ce1d4082a4d..6ca836bb5451 100644 --- a/src/main/ipc/pty/register-handlers.ts +++ b/src/main/ipc/pty/register-handlers.ts @@ -21,6 +21,7 @@ import { import { installPtyWriteIpcHandlers } from './ipc/write' import { installPtySpawnIpcHandler } from './ipc/spawn' import { installPtyRuntimeController } from './runtime/controller' +import { installLaunchedAgentForegroundIpcHandler } from './ipc/launched-agent-foreground' import { installPtySnapshotIpcHandlers } from './ipc/snapshot' import { assertFolderWorkspacePtyPathUsable as assertFolderWorkspacePtyPathUsableImpl, @@ -119,6 +120,7 @@ export function registerPtyHandlers( ipcMain.removeHandler('pty:getForegroundProcess') ipcMain.removeHandler('pty:inspectProcess') ipcMain.removeHandler('pty:confirmForegroundProcess') + ipcMain.removeHandler('pty:readLaunchedAgentForeground') ipcMain.removeHandler('pty:isCodexOnSharedServer') ipcMain.removeHandler('pty:disableCodexSharedServerAutoStart') ipcMain.removeHandler('pty:stopCodexSharedServer') @@ -282,6 +284,7 @@ export function registerPtyHandlers( installPtyWriteIpcHandlers({ mainWindow, runtime }) installPtyResizeVisibilityIpc(session) installPtyInspectIpcHandlers({ getLocalPtyProviderStartupPromise }) + installLaunchedAgentForegroundIpcHandler(runtime) installPtyCodexSharedServerIpcHandler({ getLocalPtyProviderStartupPromise }) installPtyKillIpcHandler(killDeps) } diff --git a/src/main/runtime/agent-terminal-startup-prompt.test.ts b/src/main/runtime/agent-terminal-startup-prompt.test.ts index 222f9c4bd385..492a289f10c2 100644 --- a/src/main/runtime/agent-terminal-startup-prompt.test.ts +++ b/src/main/runtime/agent-terminal-startup-prompt.test.ts @@ -161,7 +161,7 @@ describe('a terminal create that is handed a launch prompt', () => { startupAgent: 'aider', startupPrompt: 'summarize the diff' }) - ).rejects.toThrow(/does not take a startup prompt/) + ).rejects.toThrow(/takes its prompt only after it starts/) expect(spawn).not.toHaveBeenCalled() }) }) diff --git a/src/main/runtime/launch-host-agent-in-front.test.ts b/src/main/runtime/launch-host-agent-in-front.test.ts deleted file mode 100644 index 12c7f209ffc0..000000000000 --- a/src/main/runtime/launch-host-agent-in-front.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { launchHostProvesAgentInFront } from './launch-host-agent-in-front' - -describe('whether a launch host can prove its agent is in front before a paste', () => { - it.each([ - ['a local macOS host', false, 'darwin', 'darwin', true], - ['a local Linux host', false, 'linux', 'linux', true], - ['a local Windows host', false, 'win32', 'win32', false], - // The pane runs in the distro, but the reads run on the Windows host. - ['a local WSL pane', false, 'linux', 'win32', false], - ['an SSH Linux host from Windows', true, 'linux', 'win32', true], - ['an SSH Windows host', true, 'win32', 'darwin', false] - ] as const)('%s: %s', (_label, isRemote, launchPlatform, hostPlatform, proves) => { - expect(launchHostProvesAgentInFront({ isRemote, launchPlatform, hostPlatform })).toBe(proves) - }) -}) diff --git a/src/main/runtime/launch-host-agent-in-front.ts b/src/main/runtime/launch-host-agent-in-front.ts deleted file mode 100644 index 1badb1bc806c..000000000000 --- a/src/main/runtime/launch-host-agent-in-front.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Whether the execution host can prove a launched agent holds its terminal before a paste - * (`launched-agent-foreground`). A Windows host cannot, and a local WSL pane runs on one; an SSH - * host is judged by its own platform. - */ -export function launchHostProvesAgentInFront(args: { - isRemote: boolean - launchPlatform: NodeJS.Platform - hostPlatform?: NodeJS.Platform -}): boolean { - return args.isRemote - ? args.launchPlatform !== 'win32' - : (args.hostPlatform ?? process.platform) !== 'win32' -} diff --git a/src/main/runtime/orca-runtime-agent-session-operation.test.ts b/src/main/runtime/orca-runtime-agent-session-operation.test.ts index f8ef09234db7..91dd2658df20 100644 --- a/src/main/runtime/orca-runtime-agent-session-operation.test.ts +++ b/src/main/runtime/orca-runtime-agent-session-operation.test.ts @@ -213,19 +213,22 @@ describe('agent-session create operation ledger', () => { ) }) - it('refuses a prompt it could only paste, having no paste after ready', async () => { + // Why: main started this agent with the whole prompt on its line; with no paste after ready this + // create must not refuse what main started. + it('keeps a prompt it cannot paste on the agent’s line, as main did', async () => { const runtime = createRuntime({ supportsAgentSessionClaims: () => true, supportsAgentSessionCreateOperations: () => true }) const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue(terminal()) - await expect( - runtime.createAgentSession( - request(operationId(), { agent: 'gemini', prompt: 'x'.repeat(20_000) }) - ) - ).rejects.toThrow(/gemini cannot take this prompt on its command line here/) - expect(createTerminal).not.toHaveBeenCalled() + await runtime.createAgentSession( + request(operationId(), { agent: 'gemini', prompt: 'x'.repeat(20_000) }) + ) + expect(createTerminal).toHaveBeenCalledWith( + 'id:worktree-1', + expect.objectContaining({ command: expect.stringContaining('x'.repeat(20_000)) }) + ) }) it('requests exact client legacy fallback before nested SSH side effects', async () => { diff --git a/src/main/runtime/orca-runtime-create-agent-session.ts b/src/main/runtime/orca-runtime-create-agent-session.ts index 4523e483d508..63370145083f 100644 --- a/src/main/runtime/orca-runtime-create-agent-session.ts +++ b/src/main/runtime/orca-runtime-create-agent-session.ts @@ -27,6 +27,7 @@ import { windowsDraftRefusal } from '../../shared/launch-prompt-carry' import { resolveStartupShell } from '../../shared/tui-agent-startup-shell' +import { thisOrcaLaunchHost } from './this-orca-launch-host' import type { RuntimeTerminalCreate } from '../../shared/runtime-types' import type { AgentSessionCreateOperation, @@ -180,7 +181,16 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe throw new Error(refusal ?? 'agent_session_identity_required') } } else { - const planned = planLaunchPrompt({ ...startupArgs, prompt: request.prompt ?? '' }) + const planned = planLaunchPrompt({ + ...startupArgs, + prompt: request.prompt ?? '', + host: thisOrcaLaunchHost({ + launchPlatform: startupArgs.platform, + isRemote: Boolean(workspace.connectionId) + }), + // Why: this create returns before the agent is ready, so nothing pastes after it. + canPasteAfterReady: false + }) if (!planned) { throw new Error('agent_session_identity_required') } diff --git a/src/main/runtime/orca-runtime-create-terminal-desktop.test.ts b/src/main/runtime/orca-runtime-create-terminal-desktop.test.ts new file mode 100644 index 000000000000..03be15e69883 --- /dev/null +++ b/src/main/runtime/orca-runtime-create-terminal-desktop.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from 'vitest' +import { describeLaunchFileUnavailable } from '../../shared/launch-prompt-file' + +const surface = vi.hoisted(() => { + const handlers = new Map void>() + return { + handlers, + onIpc: (channel: string, handler: (event: unknown, response: unknown) => void) => + handlers.set(channel, handler), + removeIpcListener: vi.fn() + } +}) + +vi.mock('./orca-runtime-create-terminal-dependencies', () => ({ + randomUUID: () => 'request-1', + ownerSurfacing: () => ({}), + getRuntimeDesktopSurface: () => surface +})) + +import { createDesktopTerminal } from './orca-runtime-create-terminal-desktop' + +describe('a desktop terminal create whose pane the host refused', () => { + // Why: the pane never started, so closing its tab is what makes the refusal a launch without + // effects, and lets the phone's replay be told the reason instead of "outcome unknown". + it('closes the tab it opened and reports the refusal', async () => { + const refusal = new Error(describeLaunchFileUnavailable('disk full')) + const webContents = { + send: (_channel: string, request: { requestId: string }) => + surface.handlers.get('terminal:tabCreateReply')?.( + { sender: webContents }, + { requestId: request.requestId, tabId: 'tab-refused' } + ) + } + const closeTerminalTab = vi.fn(async () => {}) + const runtime = { + assertGraphReady: () => {}, + getAuthoritativeWindow: () => ({ webContents }), + resolveTerminalWorkspaceLaunchScope: async () => null, + waitForTerminalHandle: vi.fn(async () => { + throw refusal + }), + notifier: { closeTerminalTab } + } + + await expect( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the create reads only the members stubbed above. + createDesktopTerminal(runtime as never, undefined, { command: 'claude' }, undefined, null) + ).rejects.toBe(refusal) + expect(closeTerminalTab).toHaveBeenCalledWith('tab-refused', { force: true }) + }) + + it('leaves the tab of any other failure', async () => { + const webContents = { + send: (_channel: string, request: { requestId: string }) => + surface.handlers.get('terminal:tabCreateReply')?.( + { sender: webContents }, + { requestId: request.requestId, tabId: 'tab-slow' } + ) + } + const closeTerminalTab = vi.fn(async () => {}) + const runtime = { + assertGraphReady: () => {}, + getAuthoritativeWindow: () => ({ webContents }), + resolveTerminalWorkspaceLaunchScope: async () => null, + waitForTerminalHandle: vi.fn(async () => { + throw new Error('Timed out waiting for terminal handle after creation') + }), + notifier: { closeTerminalTab } + } + + await expect( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the create reads only the members stubbed above. + createDesktopTerminal(runtime as never, undefined, { command: 'claude' }, undefined, null) + ).rejects.toThrow('Timed out') + expect(closeTerminalTab).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/orca-runtime-create-terminal-desktop.ts b/src/main/runtime/orca-runtime-create-terminal-desktop.ts index 1bcf2d346bd7..f5bb03439b1a 100644 --- a/src/main/runtime/orca-runtime-create-terminal-desktop.ts +++ b/src/main/runtime/orca-runtime-create-terminal-desktop.ts @@ -2,6 +2,7 @@ import * as dependencies from './orca-runtime-create-terminal-dependencies' import type { OrcaRuntimeWithCreateTerminal } from './orca-runtime-create-terminal' import type { RuntimeTerminalPresentation } from '../../shared/runtime-types' +import { isLaunchFileRefusal } from '../../shared/launch-prompt-file' export async function createDesktopTerminal( runtime: OrcaRuntimeWithCreateTerminal, @@ -78,7 +79,18 @@ export async function createDesktopTerminal( ...dependencies.ownerSurfacing(opts.surfaceOwner !== false) }) }) - const handle = await runtime.waitForTerminalHandle(reply.tabId) + let handle: string + try { + handle = await runtime.waitForTerminalHandle(reply.tabId) + } catch (error) { + if (isLaunchFileRefusal(error)) { + // Why: the refused pane never started; closing its tab leaves the launch without effects. + await Promise.resolve( + runtime.notifier?.closeTerminalTab?.(reply.tabId, { force: true }) + ).catch((closeError: unknown) => console.error('Could not close a refused tab', closeError)) + } + throw error + } return { handle, tabId: reply.tabId, diff --git a/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts b/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts index 26ff9f9d0f9a..a619bbb6ee95 100644 --- a/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts +++ b/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts @@ -6,6 +6,7 @@ import type { TuiAgent } from '../../shared/tui-agent' import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' import { isTuiAgentEnabled } from '../../shared/tui-agent-selection' import { planLaunchPrompt } from '../../shared/tui-agent-startup' +import { thisOrcaLaunchHost } from './this-orca-launch-host' import { launchPromptNeedsPasteRefusal } from '../../shared/launch-prompt-carry' import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' @@ -48,16 +49,21 @@ export class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends OrcaRunt if (!isTuiAgentEnabled(opts.agent, settings.disabledTuiAgents)) { throw new Error('Selected agent is disabled. Choose an enabled agent before creating.') } + // Why: mobile may be iOS while the shell host is Windows/macOS/Linux or SSH Linux; quote for the host shell. + const launchPlatform = this.getAgentLaunchPlatformForWorkspace(workspace) + const isRemote = Boolean(workspace.connectionId) const planned = planLaunchPrompt({ ...resolveAgentStartupPlanInputs({ agent: opts.agent, settings, - // Why: mobile may be iOS while the shell host is Windows/macOS/Linux or SSH Linux; quote for the host shell. - platform: this.getAgentLaunchPlatformForWorkspace(workspace), + platform: launchPlatform, // Why: SSH runs the CLI through the relay shim (plain `orca`), so the Linux-only `orca-ide` rename must not apply. - isRemote: Boolean(workspace.connectionId) + isRemote }), - prompt: opts.agentPrompt ?? '' + prompt: opts.agentPrompt ?? '', + host: thisOrcaLaunchHost({ launchPlatform, isRemote }), + // Why: a quick command has no paste after ready. + canPasteAfterReady: false }) if (!planned) { throw new Error(`Could not build launch command for ${opts.agent}.`) 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 c5b3fee78540..dc9ef38d8136 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -19,9 +19,9 @@ 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 { agentPromptRidesLaunchCommand, planLaunchPrompt } from '../../shared/tui-agent-startup' +import { planLaunchPrompt } from '../../shared/tui-agent-startup' import { launchPromptNeedsPasteRefusal } from '../../shared/launch-prompt-carry' -import { launchHostProvesAgentInFront } from './launch-host-agent-in-front' +import { thisOrcaLaunchHost } from './this-orca-launch-host' import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' @@ -256,11 +256,6 @@ 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 && !agentPromptRidesLaunchCommand(agent)) { - throw new Error(`Agent ${agent} does not take a startup prompt on its launch command.`) - } const planInputs = resolveAgentStartupPlanInputs({ agent, settings, @@ -276,7 +271,8 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith ...planInputs, prompt: opts.startupPrompt ?? '', ...(opts.launchFile ? { launchFile: opts.launchFile } : {}), - hostProvesAgentInFront: launchHostProvesAgentInFront({ isRemote, launchPlatform: platform }) + host: thisOrcaLaunchHost({ launchPlatform: platform, isRemote }), + canPasteAfterReady: opts.onStartupPromptCarry !== undefined }) if (!planned) { // Why: an explicit agent that yields no plan would otherwise spawn a bare diff --git a/src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts b/src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts index d540e6d497c1..735b2cde640a 100644 --- a/src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts +++ b/src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts @@ -2,10 +2,17 @@ import { OrcaRuntimeWithWaitForMobileTerminalSurface } from './orca-runtime-wait-for-mobile-terminal-surface' import { runtimeWorktreeIdsEqual } from './runtime-worktree-path-identity' import { parsePaneKey } from '../../shared/stable-pane-id' -import { onTerminalLaunchRefusal, takeTerminalLaunchRefusal } from './terminal-launch-refusals' +import { + onTerminalLaunchRefusal, + takeTerminalLaunchRefusal, + terminalPaneSpawnInFlight +} from './terminal-launch-refusals' import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import type { RuntimeMobileSessionCreateTerminalResult } from '../../shared/runtime-types' +const TERMINAL_HANDLE_RECHECK_MS = 500 +const TERMINAL_HANDLE_SPAWN_IN_FLIGHT_CAP_MS = 60_000 + export class OrcaRuntimeWithRestoreLivePairedRendererSessionOwnedMobileTerminals extends OrcaRuntimeWithWaitForMobileTerminalSurface { protected restoreLivePairedRendererSessionOwnedMobileTerminals( worktreeId: string | null, @@ -146,10 +153,21 @@ export class OrcaRuntimeWithRestoreLivePairedRendererSessionOwnedMobileTerminals this.graphSyncCallbacks.splice(idx, 1) } } - const timer = setTimeout(() => { + const startedAt = Date.now() + const onTimeout = (): void => { + // Why: a spawn still running (a cold WSL probe, then the spawn) answers soon; only a tab + // with nothing in flight has timed out. Capped so a wedged spawn still ends the wait. + if ( + terminalPaneSpawnInFlight(tabId) && + Date.now() - startedAt < TERMINAL_HANDLE_SPAWN_IN_FLIGHT_CAP_MS + ) { + timer = setTimeout(onTimeout, TERMINAL_HANDLE_RECHECK_MS) + return + } stop() reject(new Error('Timed out waiting for terminal handle after creation')) - }, timeoutMs) + } + let timer = setTimeout(onTimeout, timeoutMs) // Why: a host that refused the spawn's launch file never registers a handle. const refuse = (message: string): void => { stop() diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-12.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-12.spec.ts index 28c9cc0dd2e1..5545ce19b792 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-12.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-12.spec.ts @@ -38,6 +38,32 @@ describe('OrcaRuntimeService', () => { expect(spawn).not.toHaveBeenCalled() }) + // Why: main started this agent with the prompt on its line; a quick command has no paste after + // ready, so it keeps the line rather than refuse what main started. + it('starts an agent with a prompt it cannot paste on its line, as main did', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-agent-long-prompt' }) + const runtime = new OrcaRuntimeService({ + ...store, + getSettings: () => ({ ...store.getSettings(), disabledTuiAgents: [], agentCmdOverrides: {} }) + } as never) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + + await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + agent: 'gemini', + agentPrompt: 'g'.repeat(20_000) + }) + + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ command: expect.stringContaining('g'.repeat(20_000)) }) + ) + }) + it('uses portable Unix quoting for mobile agent launch commands in WSL project runtimes', async () => { await withPlatform('win32', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'pty-agent' }) diff --git a/src/main/runtime/rpc/methods/agent-launch-failure-code.test.ts b/src/main/runtime/rpc/methods/agent-launch-failure-code.test.ts new file mode 100644 index 000000000000..d3b2c9751db1 --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-launch-failure-code.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { launchFailureWithoutEffectsCode } from './agent-launch-failure-code' +import { trackTerminalSpawnDispatch } from '../../../agent-launch/agent-launch-not-started' +import { describeLaunchFileUnavailable } from '../../../../shared/launch-prompt-file' + +describe('a launch that failed without effects', () => { + // Why: every host refuses a launch file before it spawns anything, so the phone's replay must + // get the reason, not "outcome unknown", even after the spawn request left this process. + it('includes a launch-file refusal for an existing workspace, as it arrives from any host', () => { + const dispatched = trackTerminalSpawnDispatch() + dispatched.onPtySpawnDispatched() + const refusal = new Error(describeLaunchFileUnavailable("the WSL distro's home is gone")) + expect(launchFailureWithoutEffectsCode(refusal, 'existing', dispatched)).toBe( + 'launch_file_unavailable' + ) + }) + + it('leaves it out for a created workspace, which exists whatever the agent did', () => { + const refusal = new Error(describeLaunchFileUnavailable('disk full')) + expect( + launchFailureWithoutEffectsCode(refusal, 'create-worktree', trackTerminalSpawnDispatch()) + ).toBeNull() + }) + + it('leaves out any other failure after the spawn request left', () => { + const dispatched = trackTerminalSpawnDispatch() + dispatched.onPtySpawnDispatched() + expect( + launchFailureWithoutEffectsCode(new Error('spawn ENOENT'), 'existing', dispatched) + ).toBeNull() + }) +}) diff --git a/src/main/runtime/rpc/methods/agent-launch-failure-code.ts b/src/main/runtime/rpc/methods/agent-launch-failure-code.ts index eac21c7c00e9..cba9f6045aeb 100644 --- a/src/main/runtime/rpc/methods/agent-launch-failure-code.ts +++ b/src/main/runtime/rpc/methods/agent-launch-failure-code.ts @@ -12,6 +12,10 @@ import { AGENT_LAUNCH_SESSION_ALREADY_EXISTS_CODE } from '../../../../shared/agent-launch-session-already-exists' import type { TerminalSpawnDispatch } from '../../../agent-launch/agent-launch-not-started' +import { + isLaunchFileRefusal, + LAUNCH_FILE_UNAVAILABLE_CODE +} from '../../../../shared/launch-prompt-file' /** Long enough for every code this path raises, with room for one a later guard adds. */ const LAUNCH_FAILURE_CODE_MAX_LENGTH = 128 @@ -53,5 +57,11 @@ export function launchFailureWithoutEffectsCode( if (terminalSpawn.failedBeforeDispatch(error) && targetKind === 'existing') { return agentLaunchFailureCode(error) } + // Why without effects: every host refuses before it spawns anything, and a desktop window closes + // the refused pane's tab before the error reaches here (`createDesktopTerminal`). The code, not + // the sentence: the sentence carries a reason long enough to lose its code to the bound. + if (isLaunchFileRefusal(error) && targetKind === 'existing') { + return LAUNCH_FILE_UNAVAILABLE_CODE + } return null } diff --git a/src/main/runtime/runtime-worktree-agent-startup.ts b/src/main/runtime/runtime-worktree-agent-startup.ts index 6e5d234536fa..447daa30d540 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.ts @@ -15,7 +15,7 @@ import { type AgentStartupPlan } from '../../shared/tui-agent-startup' import type { LaunchFile } from '../../shared/launch-prompt-file' -import { launchHostProvesAgentInFront } from './launch-host-agent-in-front' +import { thisOrcaLaunchHost } from './this-orca-launch-host' import { detectInstalledAgentsWithShellPathHydration, detectRemoteAgents @@ -158,10 +158,12 @@ export function buildWorktreeStartupForAgent( const planned = planLaunchPrompt({ ...planInputs, prompt: environment.prompt ?? '', - hostProvesAgentInFront: launchHostProvesAgentInFront({ + host: thisOrcaLaunchHost({ isRemote: repoIsRemote(repo), launchPlatform: environment.getLaunchPlatform() - }) + }), + // A caller that cannot paste takes a follow-up and sends it once the agent is ready. + canPasteAfterReady: true }) if (!planned) { throw new Error(`Could not build launch command for ${agent}.`) diff --git a/src/main/runtime/terminal-launch-refusals.test.ts b/src/main/runtime/terminal-launch-refusals.test.ts index 07c10e96e7bd..5ad04e356d2c 100644 --- a/src/main/runtime/terminal-launch-refusals.test.ts +++ b/src/main/runtime/terminal-launch-refusals.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from './orca-runtime' -import { recordTerminalLaunchRefusal, takeTerminalLaunchRefusal } from './terminal-launch-refusals' +import { + noteTerminalPaneSpawn, + recordTerminalLaunchRefusal, + takeTerminalLaunchRefusal +} from './terminal-launch-refusals' import { describeLaunchFileUnavailable } from '../../shared/launch-prompt-file' vi.mock('electron', () => ({ @@ -10,12 +14,16 @@ vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp') } })) -function waitForHandle(runtime: OrcaRuntimeService, tabId: string): Promise { +function waitForHandle( + runtime: OrcaRuntimeService, + tabId: string, + timeoutMs = 5_000 +): Promise { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the wait under test is a protected member of the runtime; the assertion names only that method. const internal = runtime as unknown as { waitForTerminalHandle: (tabId: string, timeoutMs?: number) => Promise } - return internal.waitForTerminalHandle(tabId, 5_000) + return internal.waitForTerminalHandle(tabId, timeoutMs) } describe('a pane spawn the host refused for its launch file', () => { @@ -43,4 +51,25 @@ describe('a pane spawn the host refused for its launch file', () => { recordTerminalLaunchRefusal('tab-early', refusal) await expect(waitForHandle(runtime, 'tab-early')).rejects.toThrow(refusal) }) + + // Why: a cold WSL probe plus the spawn can outlast the create's wait; the agent then starts + // anyway, so a timeout there would be false. + it('keeps waiting past its budget while that tab’s spawn still runs, then times out', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService() + const settleSpawn = noteTerminalPaneSpawn('tab-cold-wsl') + let outcome: string | null = null + waitForHandle(runtime, 'tab-cold-wsl', 100).catch((error: Error) => { + outcome = error.message + }) + await vi.advanceTimersByTimeAsync(5_000) + expect(outcome).toBeNull() + settleSpawn() + await vi.advanceTimersByTimeAsync(1_000) + expect(outcome).toMatch(/Timed out waiting for terminal handle/) + } finally { + vi.useRealTimers() + } + }) }) diff --git a/src/main/runtime/terminal-launch-refusals.ts b/src/main/runtime/terminal-launch-refusals.ts index 9ec35d90534b..c4419fef8658 100644 --- a/src/main/runtime/terminal-launch-refusals.ts +++ b/src/main/runtime/terminal-launch-refusals.ts @@ -8,6 +8,31 @@ const MAX_REFUSALS = 64 const refusals = new Map() const listeners = new Set<(tabId: string, message: string) => void>() +/** Pane spawns still running per tab; each `noteTerminalPaneSpawn` settles in a `finally`. */ +const spawnsInFlight = new Map() + +/** Marks a tab's pane spawn as running until the returned settle runs. */ +export function noteTerminalPaneSpawn(tabId: string): () => void { + spawnsInFlight.set(tabId, (spawnsInFlight.get(tabId) ?? 0) + 1) + let settled = false + return () => { + if (settled) { + return + } + settled = true + const remaining = (spawnsInFlight.get(tabId) ?? 1) - 1 + if (remaining > 0) { + spawnsInFlight.set(tabId, remaining) + } else { + spawnsInFlight.delete(tabId) + } + } +} + +/** Whether a pane spawn for the tab is still running, such as one probing a cold WSL distro. */ +export function terminalPaneSpawnInFlight(tabId: string): boolean { + return spawnsInFlight.has(tabId) +} export function recordTerminalLaunchRefusal(tabId: string, message: string): void { const now = Date.now() diff --git a/src/main/runtime/this-orca-launch-host.ts b/src/main/runtime/this-orca-launch-host.ts new file mode 100644 index 000000000000..3a2d28f3f995 --- /dev/null +++ b/src/main/runtime/this-orca-launch-host.ts @@ -0,0 +1,9 @@ +import { describeLaunchHost, type LaunchHost } from '../../shared/launch-host' + +/** The host facts for a launch this Orca runs itself, locally or over its own SSH connection. */ +export function thisOrcaLaunchHost(args: { + launchPlatform: NodeJS.Platform + isRemote: boolean +}): LaunchHost { + return describeLaunchHost({ ...args, hostPlatform: process.platform, paired: false }) +} diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index 4bc489eb62e5..c854cd3bd64f 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -128,6 +128,8 @@ export type PtyApi = { } ) => Promise confirmForegroundProcess: (id: string) => Promise + /** What holds a launched agent's terminal, read fresh on its execution host (#24257's guard). */ + readLaunchedAgentForeground: (id: string, agent: string) => Promise<'agent' | 'shell' | 'unknown'> /** Local panes only; false for any other pane. */ isCodexOnSharedServer: (id: string) => Promise /** Runs the fix with the pane's own Codex; true only once verified. Local panes only. */ diff --git a/src/preload/api/pty-bridge-stream-and-serialization.ts b/src/preload/api/pty-bridge-stream-and-serialization.ts index ec5b232acb57..22c8cf8bbf9c 100644 --- a/src/preload/api/pty-bridge-stream-and-serialization.ts +++ b/src/preload/api/pty-bridge-stream-and-serialization.ts @@ -16,6 +16,11 @@ export const ptyStreamAndSerializationApi = { ipcRenderer.invoke('pty:inspectProcess', { id, ...options }), confirmForegroundProcess: (id: string): Promise => ipcRenderer.invoke('pty:confirmForegroundProcess', { id }), + readLaunchedAgentForeground: ( + id: string, + agent: string + ): Promise<'agent' | 'shell' | 'unknown'> => + ipcRenderer.invoke('pty:readLaunchedAgentForeground', { id, agent }), isCodexOnSharedServer: (id: string): Promise => ipcRenderer.invoke('pty:isCodexOnSharedServer', { id }), disableCodexSharedServerAutoStart: (id: string): Promise => diff --git a/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts b/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts index c5307266a259..c906836cfa24 100644 --- a/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts +++ b/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts @@ -16,7 +16,7 @@ type BuildSourceControlAgentDeliveryPlanArgs = { /** Why: keep the previewed command label in sync with the real remote launch, * which omits the Linux-only `orca-ide` rename for SSH hosts. */ isRemote?: boolean - launchHostIsPaired?: boolean + runtimeEnvironmentId?: string | null } export function buildSourceControlAgentDeliveryPlan({ @@ -28,7 +28,7 @@ export function buildSourceControlAgentDeliveryPlan({ connectionUnavailable, launchPlatform, isRemote, - launchHostIsPaired + runtimeEnvironmentId }: BuildSourceControlAgentDeliveryPlanArgs): SourceControlAgentActionDeliveryPlanState { if (connectionUnavailable) { return buildSourceControlAgentConnectionErrorPlan() @@ -53,7 +53,7 @@ export function buildSourceControlAgentDeliveryPlan({ terminalWindowsShell: settings?.terminalWindowsShell, platform: launchPlatform, isRemote, - launchHostIsPaired + runtimeEnvironmentId }) if (!result.ok) { return { status: 'error', error: result.error } diff --git a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts index 85fe301b8206..08fadee4c257 100644 --- a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts +++ b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts @@ -10,7 +10,6 @@ import type { Repo } from '../../../../shared/repo-types' import type { TuiAgent } from '../../../../shared/tui-agent' import { buildSourceControlAgentDeliveryPlan } from './buildSourceControlAgentDeliveryPlan' import { useAppStore } from '@/store' -import { launchHostIsPaired } from '@/lib/launch-file-host' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import type { SourceControlAgentActionDeliveryPlanState } from './SourceControlAgentActionDialogForm' import { runSourceControlAgentActionStart } from './runSourceControlAgentActionStart' @@ -120,9 +119,7 @@ export function useSourceControlAgentActionStart({ connectionUnavailable, launchPlatform, isRemote, - launchHostIsPaired: launchHostIsPaired( - getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), worktreeId) - ) + runtimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), worktreeId) }) }, [ diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index d942af2701f8..960291aa1e35 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -21,7 +21,7 @@ import { planAgentSessionLaunch } from '@/lib/agent-session-launch-plan' import { beginStructuredAgentSessionProvisionalLaunch } from '@/lib/structured-agent-session-provisional-tab' import { getNewWorkspaceProjectGroupHostId } from '@/lib/new-workspace-project-options' import { useAppStore } from '@/store' -import { launchHostIsPaired } from '@/lib/launch-file-host' +import { clientLaunchHost } from '@/lib/launch-file-host' import { composerAgentStartupPlan, type ComposerAgentStartupPlan @@ -129,7 +129,12 @@ export async function submitFolderWorkspaceCreate({ platform: launchPlatform, shell: launchShell, isRemote: launchIsRemote, - launchHostIsPaired: launchHostIsPaired(runtimeEnvironmentId) + host: clientLaunchHost({ + runtimeEnvironmentId, + launchPlatform, + isRemote: launchIsRemote + }), + canPasteAfterReady: true }), note ) diff --git a/src/renderer/src/hooks/composer-state/full-creation-startup.test.ts b/src/renderer/src/hooks/composer-state/full-creation-startup.test.ts index 1f48e5233e7b..8bfefea7195f 100644 --- a/src/renderer/src/hooks/composer-state/full-creation-startup.test.ts +++ b/src/renderer/src/hooks/composer-state/full-creation-startup.test.ts @@ -15,7 +15,9 @@ describe('the full composer’s renderer-spawned startup', () => { prompt, cmdOverrides: {}, platform: 'linux', - isRemote: true + isRemote: true, + host: { paired: false, provesAgentInFront: true }, + canPasteAfterReady: true }), prompt ) diff --git a/src/renderer/src/hooks/composer-state/full-submit-preparation.ts b/src/renderer/src/hooks/composer-state/full-submit-preparation.ts index 98c273cde24d..68a1fa6df82d 100644 --- a/src/renderer/src/hooks/composer-state/full-submit-preparation.ts +++ b/src/renderer/src/hooks/composer-state/full-submit-preparation.ts @@ -39,7 +39,7 @@ import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import type { AgentStartedTelemetry } from '@/lib/worktree-startup-payload' import { tuiAgentToAgentKind } from '@/lib/telemetry' -import { launchHostIsPaired } from '@/lib/launch-file-host' +import { clientLaunchHost } from '@/lib/launch-file-host' import { composerAgentStartupPlan } from '@/lib/composer-agent-startup-plan' import type { PendingSmartGitHubSubmitResolution } from './source-selection-decisions' @@ -205,7 +205,12 @@ export function useFullSubmitPreparation(input: FullSubmitPreparationInput) { platform: selectedRepoAgentLaunchPlatform, shell: selectedRepoStartupShell, isRemote: selectedRepoIsRemote, - launchHostIsPaired: launchHostIsPaired(selectedRepoSettings?.activeRuntimeEnvironmentId) + host: clientLaunchHost({ + runtimeEnvironmentId: selectedRepoSettings?.activeRuntimeEnvironmentId, + launchPlatform: selectedRepoAgentLaunchPlatform, + isRemote: selectedRepoIsRemote + }), + canPasteAfterReady: true }), submitStartupPrompt ) diff --git a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts index 37a57f9056b5..104f8f24b757 100644 --- a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts +++ b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts @@ -40,7 +40,7 @@ import { useAppStore } from '@/store' import { settleComposerSubmit } from '@/lib/composer-submit-cancellation' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' -import { launchHostIsPaired } from '@/lib/launch-file-host' +import { clientLaunchHost } from '@/lib/launch-file-host' import { runBackgroundWorktreeCreation } from '@/lib/worktree-creation-flow' import { translate } from '@/i18n/i18n' import { resolveQuickCreateLinkedWorkItemPrompt } from '@/lib/linked-work-item-context' @@ -140,7 +140,11 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) { platform: selectedRepoAgentLaunchPlatform, shell: selectedRepoStartupShell, isRemote: selectedRepoIsRemote, - launchHostIsPaired: launchHostIsPaired(selectedRepoSettings?.activeRuntimeEnvironmentId), + host: clientLaunchHost({ + runtimeEnvironmentId: selectedRepoSettings?.activeRuntimeEnvironmentId, + launchPlatform: selectedRepoAgentLaunchPlatform, + isRemote: selectedRepoIsRemote + }), telemetrySource }) diff --git a/src/renderer/src/hooks/composer-state/quick-startup-plan.ts b/src/renderer/src/hooks/composer-state/quick-startup-plan.ts index 2b2eed7538ff..2a8feb0143ae 100644 --- a/src/renderer/src/hooks/composer-state/quick-startup-plan.ts +++ b/src/renderer/src/hooks/composer-state/quick-startup-plan.ts @@ -1,6 +1,7 @@ import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { TuiAgent } from '../../../../shared/tui-agent' import type { AgentStartupShell } from '../../../../shared/tui-agent-startup-shell' +import type { LaunchHost } from '../../../../shared/launch-host' import type { AgentStartedTelemetry } from '@/lib/worktree-startup-payload' import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' import { buildAgentDraftLaunchPlan, planLaunchPrompt } from '@/lib/tui-agent-startup' @@ -25,8 +26,8 @@ export type QuickComposerStartupInput = { platform: NodeJS.Platform shell: AgentStartupShell | null | undefined isRemote: boolean - /** See `launchHostIsPaired`. */ - launchHostIsPaired: boolean + /** The host the launch runs on (`clientLaunchHost`). */ + host: LaunchHost telemetrySource: WorktreeCreationRequest['telemetrySource'] } @@ -96,7 +97,8 @@ export function buildQuickComposerStartup(input: QuickComposerStartupInput): Qui platform: input.platform, shell: input.shell ?? undefined, isRemote: input.isRemote, - launchHostIsPaired: input.launchHostIsPaired + host: input.host, + canPasteAfterReady: true }), prompt ) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a79fb2990695..f0a26c2b4807 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -657,6 +657,11 @@ "message": "The agent wasn't started: Orca couldn't write the file that carries your prompt. Copy the prompt and launch again." } } + }, + "unconfirmed": { + "notice": { + "message": "Orca couldn't confirm the agent received your prompt. Check the agent before sending it again." + } } } } diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 555445f4816a..90f0660d6958 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -416,6 +416,11 @@ "message": "El agente no se inició: Orca no pudo escribir el archivo que contiene tu prompt. Copia el prompt e inícialo de nuevo." } } + }, + "unconfirmed": { + "notice": { + "message": "Orca no pudo confirmar que el agente recibió tu prompt. Revisa el agente antes de enviarlo de nuevo." + } } } } diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 34857cb55bf0..c70852ae2795 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -626,6 +626,11 @@ "message": "L’agent n’a pas démarré : Orca n’a pas pu écrire le fichier qui contient votre prompt. Copiez le prompt et relancez." } } + }, + "unconfirmed": { + "notice": { + "message": "Orca n’a pas pu confirmer que l’agent a reçu votre prompt. Vérifiez l’agent avant de le renvoyer." + } } } } diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index e1b10ffa8c9c..3e61e025a828 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -527,6 +527,11 @@ "message": "Agent は起動しませんでした。プロンプトを渡すファイルを Orca が書き込めませんでした。プロンプトをコピーして、もう一度起動してください。" } } + }, + "unconfirmed": { + "notice": { + "message": "Agent がプロンプトを受け取ったことを Orca で確認できませんでした。再送する前に Agent を確認してください。" + } } } } diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 02a7b6200f45..cc7615728b2d 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -527,6 +527,11 @@ "message": "에이전트가 시작되지 않았습니다. Orca가 프롬프트를 담을 파일을 쓰지 못했습니다. 프롬프트를 복사한 뒤 다시 시작하세요." } } + }, + "unconfirmed": { + "notice": { + "message": "에이전트가 프롬프트를 받았는지 Orca가 확인하지 못했습니다. 다시 보내기 전에 에이전트를 확인하세요." + } } } } diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 033b8e741c6e..ba7aaac7f852 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -527,6 +527,11 @@ "message": "代理未启动:Orca 无法写入承载你的提示词的文件。请复制提示词并重新启动。" } } + }, + "unconfirmed": { + "notice": { + "message": "Orca 无法确认代理已收到你的提示词。再次发送前请先检查代理。" + } } } } diff --git a/src/renderer/src/lib/agent-draft-readiness.test.ts b/src/renderer/src/lib/agent-draft-readiness.test.ts index fe0ce62c4a9c..6c25f3cf342c 100644 --- a/src/renderer/src/lib/agent-draft-readiness.test.ts +++ b/src/renderer/src/lib/agent-draft-readiness.test.ts @@ -4,7 +4,7 @@ import { clearPreHandlerPtyState, drainPreHandlerPtyData } from '@/components/terminal-pane/pty-pre-handler-buffer' -import { waitForAgentDraftInputReady } from './agent-draft-readiness' +import { bracketedPasteStateAfter, waitForAgentDraftInputReady } from './agent-draft-readiness' const testState = vi.hoisted(() => ({ observer: null as ((data: string) => void) | null, @@ -52,4 +52,60 @@ describe('waitForAgentDraftInputReady', () => { ]) expect(vi.getTimerCount()).toBe(0) }) + + // Why: a shell running the launch line turns bracketed paste off, so its earlier prompt must not + // read as the agent ready once the line is running. + describe('with bracketed paste turned off revoking the quiet window', () => { + const quietTimers = (): void => { + vi.useFakeTimers() + vi.stubGlobal('window', { + setTimeout: (handler: () => void, ms: number) => globalThis.setTimeout(handler, ms), + clearTimeout: (timer: ReturnType) => globalThis.clearTimeout(timer) + }) + } + afterEach(() => vi.unstubAllGlobals()) + + it('waits for the agent to turn it on again', async () => { + quietTimers() + let ready: boolean | null = null + void waitForAgentDraftInputReady( + PTY_ID, + 20_000, + 'render-quiet-after-bracketed-paste', + {}, + { + revokeOnBracketedPasteOff: true + } + ).then((value) => { + ready = value + }) + testState.observer?.('$ \x1b[?2004h') + testState.observer?.('claude "fix it"\r\n\x1b[?2004l') + await vi.advanceTimersByTimeAsync(3_000) + expect(ready).toBeNull() + testState.observer?.('\x1b[?2004h> ') + await vi.advanceTimersByTimeAsync(1_500) + expect(ready).toBe(true) + }) + + it('leaves a caller that did not ask for it on the shell prompt’s quiet window', async () => { + quietTimers() + const pending = waitForAgentDraftInputReady( + PTY_ID, + 20_000, + 'render-quiet-after-bracketed-paste', + {} + ) + testState.observer?.('$ \x1b[?2004h') + testState.observer?.('\x1b[?2004l') + await vi.advanceTimersByTimeAsync(1_500) + await expect(pending).resolves.toBe(true) + }) + + it('reads the last toggle in a chunk', () => { + expect(bracketedPasteStateAfter(true, 'a\x1b[?2004lb')).toBe(false) + expect(bracketedPasteStateAfter(false, '\x1b[?2004l\x1b[?2004h')).toBe(true) + expect(bracketedPasteStateAfter(false, 'plain')).toBe(false) + }) + }) }) diff --git a/src/renderer/src/lib/agent-draft-readiness.ts b/src/renderer/src/lib/agent-draft-readiness.ts index 25be94193da1..5f993edac296 100644 --- a/src/renderer/src/lib/agent-draft-readiness.ts +++ b/src/renderer/src/lib/agent-draft-readiness.ts @@ -7,6 +7,15 @@ import { subscribeToRuntimeTerminalData } from '@/runtime/runtime-terminal-strea import { createDraftPasteReadyScanner } from '../../../shared/draft-paste-ready-scanner' const BRACKETED_PASTE_QUIET_MS = 1500 +const BRACKETED_PASTE_ON = '\x1b[?2004h' +const BRACKETED_PASTE_OFF = '\x1b[?2004l' + +/** Whether bracketed paste is on after `data`, from the last toggle in it; `previous` if none. */ +export function bracketedPasteStateAfter(previous: boolean, data: string): boolean { + const on = data.lastIndexOf(BRACKETED_PASTE_ON) + const off = data.lastIndexOf(BRACKETED_PASTE_OFF) + return on === -1 && off === -1 ? previous : on > off +} /** * Tap the PTY data stream as a side-channel observer (does NOT take over @@ -22,7 +31,10 @@ export function waitForAgentDraftInputReady( ptyId: string, timeoutMs: number, readySignal: DraftPasteReadySignal, - settings: Pick | null | undefined + settings: Pick | null | undefined, + /** A quiet window counts only while bracketed paste is still on: a shell running the launch + * line turns it off (`2004l`), so its earlier prompt no longer reads as the agent ready. */ + options: { revokeOnBracketedPasteOff?: boolean } = {} ): Promise { return new Promise((resolve) => { let settled = false @@ -53,13 +65,24 @@ export function waitForAgentDraftInputReady( quietTimer = window.setTimeout(() => finish(true), BRACKETED_PASTE_QUIET_MS) } + let toggleCarry = '' + let bracketedPasteOn = true const observeData = (data: string): void => { const { ready, armQuietTimer: shouldArm } = scanner.observe(data) if (ready) { finish(true) return } - if (shouldArm) { + if (options.revokeOnBracketedPasteOff) { + const recent = toggleCarry + data + toggleCarry = recent.slice(-(BRACKETED_PASTE_OFF.length - 1)) + bracketedPasteOn = bracketedPasteStateAfter(bracketedPasteOn, recent) + if (!bracketedPasteOn && quietTimer !== null) { + window.clearTimeout(quietTimer) + quietTimer = null + } + } + if (shouldArm && bracketedPasteOn) { armQuietTimer() } } diff --git a/src/renderer/src/lib/agent-launch-caller-permissions.test.ts b/src/renderer/src/lib/agent-launch-caller-permissions.test.ts index 9323a9aa5a4a..b9bdf655892a 100644 --- a/src/renderer/src/lib/agent-launch-caller-permissions.test.ts +++ b/src/renderer/src/lib/agent-launch-caller-permissions.test.ts @@ -40,7 +40,7 @@ vi.mock('@/components/native-chat/native-chat-session-option-cache', () => ({ })) vi.mock('@/lib/agent-paste-draft', () => ({ pasteDraftWhenAgentReady: vi.fn(async () => true) })) vi.mock('@/lib/agent-launch-prompt-receipt', () => ({ - waitForLaunchPromptReceipt: vi.fn(async () => true) + waitForLaunchPromptReceipt: vi.fn(async () => 'delivered') })) vi.mock('@/lib/agent-ready-wait', () => ({ waitForAgentReady: vi.fn(async () => ({ ready: true, reason: 'foreground-match' })) diff --git a/src/renderer/src/lib/agent-launch-caller-placement.test.ts b/src/renderer/src/lib/agent-launch-caller-placement.test.ts index d2721aa2805c..282dc6eb7eff 100644 --- a/src/renderer/src/lib/agent-launch-caller-placement.test.ts +++ b/src/renderer/src/lib/agent-launch-caller-placement.test.ts @@ -40,7 +40,7 @@ vi.mock('@/components/native-chat/native-chat-session-option-cache', () => ({ })) vi.mock('@/lib/agent-paste-draft', () => ({ pasteDraftWhenAgentReady: vi.fn(async () => true) })) vi.mock('@/lib/agent-launch-prompt-receipt', () => ({ - waitForLaunchPromptReceipt: vi.fn(async () => true) + waitForLaunchPromptReceipt: vi.fn(async () => 'delivered') })) vi.mock('@/lib/agent-ready-wait', () => ({ waitForAgentReady: vi.fn(async () => ({ ready: true, reason: 'foreground-match' })) diff --git a/src/renderer/src/lib/agent-launch-caller-prompt-transport.test.ts b/src/renderer/src/lib/agent-launch-caller-prompt-transport.test.ts index 696d3e404236..6155a7e51ab7 100644 --- a/src/renderer/src/lib/agent-launch-caller-prompt-transport.test.ts +++ b/src/renderer/src/lib/agent-launch-caller-prompt-transport.test.ts @@ -48,7 +48,7 @@ vi.mock('@/lib/agent-paste-draft', () => ({ vi.mock('@/lib/agent-ready-wait', () => ({ waitForAgentReady: vi.fn(async () => ({ ready: true, reason: 'foreground-match' })) })) -const mockWaitForLaunchPromptReceipt = vi.fn(async () => true) +const mockWaitForLaunchPromptReceipt = vi.fn(async () => 'delivered') vi.mock('@/lib/agent-launch-prompt-receipt', () => ({ waitForLaunchPromptReceipt: mockWaitForLaunchPromptReceipt })) diff --git a/src/renderer/src/lib/agent-launch-prompt-not-delivered-notice.ts b/src/renderer/src/lib/agent-launch-prompt-not-delivered-notice.ts index 06f1afddd301..feb0af95c360 100644 --- a/src/renderer/src/lib/agent-launch-prompt-not-delivered-notice.ts +++ b/src/renderer/src/lib/agent-launch-prompt-not-delivered-notice.ts @@ -25,6 +25,28 @@ export function showAgentLaunchPromptNotDeliveredNotice(args: { }) } +/** + * The agent started with the prompt on its command line, but nothing proved it received it: no hook + * turn, and the host cannot tell what holds the terminal. Not a "wasn't sent", so the user is told + * to check the agent before sending the prompt again. + */ +export function showAgentLaunchPromptUnconfirmedNotice(args: { + agent: TuiAgent + prompt: string +}): void { + showPromptCopyNotice( + translate( + 'auto.lib.agent.launch.prompt.unconfirmed.notice.message', + "Orca couldn't confirm the agent received your prompt. Check the agent before sending it again." + ), + args.prompt + ) + track('agent_error', { + error_class: 'paste_readiness_timeout', + agent_kind: tuiAgentToAgentKind(args.agent) + }) +} + /** * The host refused to start the agent because it could not write the launch file that carries the * prompt. Nothing ran, so the user is handed the text to launch again with. diff --git a/src/renderer/src/lib/agent-launch-prompt-receipt.test.ts b/src/renderer/src/lib/agent-launch-prompt-receipt.test.ts index 475a77660a6a..25080f8854b0 100644 --- a/src/renderer/src/lib/agent-launch-prompt-receipt.test.ts +++ b/src/renderer/src/lib/agent-launch-prompt-receipt.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' type ReceiptState = { tabsByWorktree: Record @@ -8,12 +8,13 @@ type ReceiptState = { } const mocks = vi.hoisted(() => { - let state: ReceiptState = { + const empty = (): ReceiptState => ({ tabsByWorktree: {}, ptyIdsByTabId: {}, agentStatusByPaneKey: {}, settings: {} - } + }) + let state = empty() const listeners = new Set<(next: ReceiptState) => void>() return { getState: () => state, @@ -29,89 +30,114 @@ const mocks = vi.hoisted(() => { }, reset: () => { listeners.clear() - state = { tabsByWorktree: {}, ptyIdsByTabId: {}, agentStatusByPaneKey: {}, settings: {} } + state = empty() }, readiness: vi.fn(), - waitForAgentReady: vi.fn() + readForeground: vi.fn() } }) vi.mock('@/store', () => ({ useAppStore: { getState: mocks.getState, subscribe: mocks.subscribe } })) +vi.mock('./agent-draft-readiness', () => ({ waitForAgentDraftInputReady: mocks.readiness })) vi.mock('./agent-paste-draft', () => ({ PTY_SPAWN_TIMEOUT_MS: 8000, - getSettingsForAgentTabRuntimeOwner: () => ({}), - waitForAgentDraftInputReadyOnTab: mocks.readiness + getSettingsForAgentTabRuntimeOwner: () => ({}) })) -vi.mock('./agent-ready-wait', () => ({ waitForAgentReady: mocks.waitForAgentReady })) +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ isRemoteRuntimePtyId: () => false })) import { waitForLaunchPromptReceipt } from './agent-launch-prompt-receipt' -const never = (): Promise => new Promise(() => {}) +const PANE = 'tab-1:11111111-1111-4111-8111-111111111111' + +function receipt() { + return waitForLaunchPromptReceipt({ tabId: 'tab-1', agent: 'claude', launchedAt: 100 }) +} describe('whether a prompt that rode the launch command reached the agent', () => { beforeEach(() => { mocks.reset() mocks.setState({ tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] } }) - mocks.readiness.mockReset().mockImplementation(never) - mocks.waitForAgentReady.mockReset() + mocks.readiness.mockReset().mockResolvedValue(true) + mocks.readForeground.mockReset().mockResolvedValue('agent') + // Timers go through globalThis at call time, so fake timers reach them. + vi.stubGlobal('window', { + setTimeout: (handler: () => void, ms: number) => globalThis.setTimeout(handler, ms), + clearTimeout: (timer: ReturnType) => globalThis.clearTimeout(timer), + api: { pty: { readLaunchedAgentForeground: mocks.readForeground } } + }) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() }) it('is delivered when the agent’s own hook reports a turn on the tab', async () => { - const receipt = waitForLaunchPromptReceipt({ tabId: 'tab-1', agent: 'claude', launchedAt: 100 }) + mocks.readiness.mockReturnValue(new Promise(() => {})) + const pending = receipt() mocks.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } }) - mocks.setState({ - agentStatusByPaneKey: { 'tab-1:11111111-1111-4111-8111-111111111111': { updatedAt: 150 } } - }) - await expect(receipt).resolves.toBe(true) + mocks.setState({ agentStatusByPaneKey: { [PANE]: { updatedAt: 150 } } }) + await expect(pending).resolves.toBe('delivered') }) - it('ignores a status row older than the launch', async () => { - mocks.setState({ - agentStatusByPaneKey: { 'tab-1:11111111-1111-4111-8111-111111111111': { updatedAt: 50 } } - }) - mocks.readiness.mockResolvedValue(null) - const receipt = waitForLaunchPromptReceipt({ tabId: 'tab-1', agent: 'claude', launchedAt: 100 }) - await expect(receipt).resolves.toBe(false) + // Why: #24257's crash-guard predicate, asked once the agent looks ready. + it('is delivered when a fresh read finds the launched agent in front', async () => { + const pending = receipt() + mocks.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } }) + await expect(pending).resolves.toBe('delivered') + expect(mocks.readForeground).toHaveBeenCalledWith('pty-1', 'claude') + expect(mocks.readiness.mock.calls[0]?.[4]).toEqual({ revokeOnBracketedPasteOff: true }) }) - it('is not delivered when the PTY exits before the agent shows any sign of the prompt', async () => { - const receipt = waitForLaunchPromptReceipt({ tabId: 'tab-1', agent: 'claude', launchedAt: 100 }) + // Why: a shell back at its prompt looks ready too; readiness alone never counts. + it('is not delivered when the read finds the shell, however ready it looked', async () => { + mocks.readForeground.mockResolvedValue('shell') + mocks.setState({ agentStatusByPaneKey: { [PANE]: { updatedAt: 50 } } }) + const pending = receipt() mocks.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } }) - mocks.setState({ ptyIdsByTabId: { 'tab-1': [] } }) - await expect(receipt).resolves.toBe(false) + await expect(pending).resolves.toBe('not-delivered') }) - it('is not delivered when the PTY never spawns, as when the host refused its launch file', async () => { - mocks.readiness.mockResolvedValue(null) - const receipt = waitForLaunchPromptReceipt({ tabId: 'tab-1', agent: 'codex', launchedAt: 100 }) - await expect(receipt).resolves.toBe(false) + it('is unconfirmed when the host cannot tell and no hook turn arrives', async () => { + vi.useFakeTimers() + mocks.readForeground.mockResolvedValue('unknown') + const pending = receipt() + mocks.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } }) + await vi.advanceTimersByTimeAsync(2_000) + await expect(pending).resolves.toBe('unconfirmed') }) - it('is delivered when the agent shows the composer a paste would have waited for', async () => { - mocks.readiness.mockResolvedValue({ ptyId: 'pty-1', ready: true }) - const receipt = waitForLaunchPromptReceipt({ tabId: 'tab-1', agent: 'codex', launchedAt: 100 }) - await expect(receipt).resolves.toBe(true) + it('takes a hook turn that arrives after a read that could not tell', async () => { + vi.useFakeTimers() + mocks.readForeground.mockResolvedValue('unknown') + const pending = receipt() + mocks.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } }) + await vi.advanceTimersByTimeAsync(500) + mocks.setState({ agentStatusByPaneKey: { [PANE]: { updatedAt: 600 } } }) + await expect(pending).resolves.toBe('delivered') }) - it('reports an unconfirmed delivery when only the agent process was seen', async () => { - mocks.readiness.mockResolvedValue({ ptyId: 'pty-1', ready: false }) - mocks.waitForAgentReady.mockResolvedValue({ ready: true, reason: 'foreground-match' }) - const onUnconfirmedDelivery = vi.fn() - const receipt = waitForLaunchPromptReceipt({ - tabId: 'tab-1', - agent: 'codex', - launchedAt: 100, - onUnconfirmedDelivery - }) - await expect(receipt).resolves.toBe(true) - expect(onUnconfirmedDelivery).toHaveBeenCalledOnce() + it('is not delivered when the PTY exits first', async () => { + mocks.readiness.mockReturnValue(new Promise(() => {})) + const pending = receipt() + mocks.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } }) + mocks.setState({ ptyIdsByTabId: { 'tab-1': [] } }) + await expect(pending).resolves.toBe('not-delivered') + }) + + it('is not delivered when the PTY never spawns, as when the host refused its launch file', async () => { + vi.useFakeTimers() + const pending = receipt() + await vi.advanceTimersByTimeAsync(8_000) + await expect(pending).resolves.toBe('not-delivered') + expect(mocks.readForeground).not.toHaveBeenCalled() }) it('is not delivered when the tab closes first', async () => { - const receipt = waitForLaunchPromptReceipt({ tabId: 'tab-1', agent: 'claude', launchedAt: 100 }) + const pending = receipt() mocks.setState({ tabsByWorktree: { 'wt-1': [] } }) - await expect(receipt).resolves.toBe(false) + await expect(pending).resolves.toBe('not-delivered') }) }) diff --git a/src/renderer/src/lib/agent-launch-prompt-receipt.ts b/src/renderer/src/lib/agent-launch-prompt-receipt.ts index e2ef5c2c00a3..23d58bd026e2 100644 --- a/src/renderer/src/lib/agent-launch-prompt-receipt.ts +++ b/src/renderer/src/lib/agent-launch-prompt-receipt.ts @@ -3,15 +3,17 @@ import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' import { resolveDraftPasteReadyTimeoutMs } from '../../../shared/draft-paste-ready-timeout' import { parsePaneKey } from '../../../shared/stable-pane-id' import { useAppStore } from '@/store' -import { waitForAgentReady } from './agent-ready-wait' -import { - getSettingsForAgentTabRuntimeOwner, - PTY_SPAWN_TIMEOUT_MS, - waitForAgentDraftInputReadyOnTab -} from './agent-paste-draft' +import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' +import { waitForAgentDraftInputReady } from './agent-draft-readiness' +import { getSettingsForAgentTabRuntimeOwner, PTY_SPAWN_TIMEOUT_MS } from './agent-paste-draft' type AppState = ReturnType +export type LaunchPromptReceipt = 'delivered' | 'not-delivered' | 'unconfirmed' + +/** How long a hook turn may still arrive after a read that could not tell. */ +const HOOK_GRACE_AFTER_UNKNOWN_READ_MS = 2000 + function hookReportedTurnOnTab(state: AppState, tabId: string, launchedAt: number): boolean { return Object.entries(state.agentStatusByPaneKey).some( ([paneKey, entry]) => parsePaneKey(paneKey)?.tabId === tabId && entry.updatedAt >= launchedAt @@ -22,31 +24,68 @@ function tabExists(state: AppState, tabId: string): boolean { return Object.values(state.tabsByWorktree).some((tabs) => tabs?.some((tab) => tab.id === tabId)) } +async function readLaunchedAgentForeground( + ptyId: string, + agent: TuiAgent +): Promise<'agent' | 'shell' | 'unknown'> { + // A paired host's process reads do not reach this client. + if (isRemoteRuntimePtyId(ptyId)) { + return 'unknown' + } + return window.api.pty.readLaunchedAgentForeground(ptyId, agent).catch(() => 'unknown' as const) +} + /** - * Whether a prompt that rode an agent's launch command reached the agent. True at the first real - * signal: the agent's own hook reports a turn on the tab, or the agent shows the composer a paste - * would have waited for. False when the PTY never spawns (a refused launch file included), exits - * first, or the tab closes. Never "the tab exists". + * Whether a prompt that rode an agent's launch command reached the agent, proven the way #24257's + * crash guard proves an agent before a paste. Delivered on the agent's own hook turn on the tab, or + * a fresh read on the execution host that finds the launched agent, not its shell, in front of the + * terminal (`readLaunchedAgentForeground`). A ready signal never counts on its own: it only times + * that read, and bracketed paste turned off (`2004l`) revokes it. Not delivered when the PTY never + * spawns (a refused launch file included), exits first, the tab closes, or the read finds the + * shell. Unconfirmed when nothing proves either, as on a Windows host without hooks. */ export function waitForLaunchPromptReceipt(args: { tabId: string agent: TuiAgent launchedAt: number - /** The agent process was seen, but neither its hook nor its composer. */ - onUnconfirmedDelivery?: () => void -}): Promise { +}): Promise { const { tabId, agent, launchedAt } = args return new Promise((resolve) => { let settled = false - let sawPty = false + let boundPtyId: string | null = null let unsubscribe: (() => void) | null = null - const finish = (delivered: boolean): void => { + const timers: number[] = [] + const finish = (receipt: LaunchPromptReceipt): void => { if (settled) { return } settled = true unsubscribe?.() - resolve(delivered) + timers.forEach((timer) => window.clearTimeout(timer)) + resolve(receipt) + } + const readAfterReadiness = (ptyId: string): void => { + const config = TUI_AGENT_CONFIG[agent] + void waitForAgentDraftInputReady( + ptyId, + resolveDraftPasteReadyTimeoutMs(agent), + config.draftPasteReadySignal ?? 'render-quiet-after-bracketed-paste', + getSettingsForAgentTabRuntimeOwner(tabId), + { revokeOnBracketedPasteOff: true } + ) + .then(() => readLaunchedAgentForeground(ptyId, agent)) + .then((foreground) => { + if (foreground === 'agent') { + finish('delivered') + } else if (foreground === 'shell') { + finish('not-delivered') + } else { + timers.push( + window.setTimeout(() => finish('unconfirmed'), HOOK_GRACE_AFTER_UNKNOWN_READ_MS) + ) + } + }) + .catch(() => finish('unconfirmed')) } let scannedStatus: AppState['agentStatusByPaneKey'] | null = null const observe = (state: AppState): void => { @@ -54,45 +93,26 @@ export function waitForLaunchPromptReceipt(args: { if (state.agentStatusByPaneKey !== scannedStatus) { scannedStatus = state.agentStatusByPaneKey if (hookReportedTurnOnTab(state, tabId, launchedAt)) { - finish(true) + finish('delivered') return } } - if ((state.ptyIdsByTabId[tabId]?.length ?? 0) > 0) { - sawPty = true - } else if (sawPty || !tabExists(state, tabId)) { - finish(false) + const ptyId = state.ptyIdsByTabId[tabId]?.[0] + if (ptyId && !boundPtyId) { + boundPtyId = ptyId + readAfterReadiness(ptyId) + } else if ((boundPtyId && !ptyId) || !tabExists(state, tabId)) { + finish('not-delivered') } } unsubscribe = useAppStore.subscribe(observe) - observe(useAppStore.getState()) - const config = TUI_AGENT_CONFIG[agent] - void waitForAgentDraftInputReadyOnTab({ - tabId, - spawnTimeoutMs: PTY_SPAWN_TIMEOUT_MS, - readinessTimeoutMs: resolveDraftPasteReadyTimeoutMs(agent), - readySignal: config.draftPasteReadySignal ?? 'render-quiet-after-bracketed-paste', - settings: getSettingsForAgentTabRuntimeOwner(tabId) - }).then( - async (readiness) => { - if (settled) { - return - } - if (!readiness) { - finish(false) - return + timers.push( + window.setTimeout(() => { + if (!boundPtyId) { + finish('not-delivered') } - if (readiness.ready) { - finish(true) - return - } - const fallback = await waitForAgentReady(tabId, config.expectedProcess, { timeoutMs: 1000 }) - if (fallback.ready && !settled) { - args.onUnconfirmedDelivery?.() - } - finish(fallback.ready) - }, - () => finish(false) + }, PTY_SPAWN_TIMEOUT_MS) ) + observe(useAppStore.getState()) }) } diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index 510ebb1aae0a..2353e2688968 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -273,7 +273,7 @@ async function sendBracketedPasteToAgent(args: { } } -export function waitForAgentDraftInputReadyOnTab(args: { +function waitForAgentDraftInputReadyOnTab(args: { tabId: string spawnTimeoutMs: number readinessTimeoutMs: number diff --git a/src/renderer/src/lib/launch-agent-background-session-remote.test.ts b/src/renderer/src/lib/launch-agent-background-session-remote.test.ts index d2a54bf3d7a9..1334029413b4 100644 --- a/src/renderer/src/lib/launch-agent-background-session-remote.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session-remote.test.ts @@ -372,9 +372,9 @@ describe('launchAgentBackgroundSession remote runtime and SSH startup delivery', }) // Why: a paired host is sent a command and never a launch file, so a pointer would name nothing. - it('pastes a prompt an old paired host would need a launch file for, instead of pointing at one', async () => { + it('pastes a long prompt to an old paired Linux host, which may neither stage it nor read a file', async () => { useRemoteAgentBackgroundRuntime(state) - mockGetAgentLaunchPlatformForRepo.mockReturnValue('win32') + mockGetAgentLaunchPlatformForRepo.mockReturnValue('linux') mockRuntimeEnvironmentTransportCall.mockImplementation((request: { method: string }) => { if (request.method === 'status.get') { return Promise.resolve({ @@ -413,9 +413,49 @@ describe('launchAgentBackgroundSession remote runtime and SSH startup delivery', ) }) - it('leaves that prompt to a paired host that takes the prompt itself, without a second paste', async () => { + // Why: a Windows host cannot prove the agent is in front to paste into, so the line carries it. + it('keeps that prompt on an old paired Windows host’s line, as main typed it', async () => { useRemoteAgentBackgroundRuntime(state) mockGetAgentLaunchPlatformForRepo.mockReturnValue('win32') + mockRuntimeEnvironmentTransportCall.mockImplementation((request: { method: string }) => { + if (request.method === 'status.get') { + return Promise.resolve({ + id: 'status', + ok: true, + result: { + runtimeId: 'old-runtime', + graphStatus: 'ready', + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: [] + } + }) + } + return Promise.resolve({ + id: 'create', + ok: true, + result: { terminal: { handle: 'legacy-terminal-1' } } + }) + }) + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await launchAgentBackgroundSession({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'fix the build\nthen run the tests' + }) + + const create = mockRuntimeEnvironmentTransportCall.mock.calls.find( + ([request]) => request.method === 'terminal.create' + )?.[0] + expect(create?.params?.command).not.toContain('orca-launch-file') + expect(create?.params?.command).toContain('fix the build') + expect(mockPasteDraftWhenAgentReady).not.toHaveBeenCalled() + }) + + it('leaves that prompt to a paired host that takes the prompt itself, without a second paste', async () => { + useRemoteAgentBackgroundRuntime(state) + mockGetAgentLaunchPlatformForRepo.mockReturnValue('linux') const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') await launchAgentBackgroundSession({ diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index 8ef6c3fe5561..34839d549054 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -1,5 +1,5 @@ import { useAppStore } from '@/store' -import { launchHostIsPaired } from '@/lib/launch-file-host' +import { clientLaunchHost } from '@/lib/launch-file-host' import { planLaunchPrompt, type AgentStartupPlan } from '@/lib/tui-agent-startup' import type { LaunchFile } from '../../../shared/launch-prompt-file' import type { @@ -70,7 +70,6 @@ export async function launchAgentBackgroundSession( terminalWindowsShell: store.settings?.terminalWindowsShell }) const trimmedPrompt = prompt?.trim() ?? '' - const hasPrompt = trimmedPrompt.length > 0 const isFollowupPath = requireTuiAgentConfig(agent).promptInjectionMode === 'stdin-after-start' // Route by the worktree's owner host, not the focused runtime. @@ -85,7 +84,12 @@ export async function launchAgentBackgroundSession( platform: launchPlatform, shell: startupShell, isRemote, - launchHostIsPaired: launchHostIsPaired(ownerSettings?.activeRuntimeEnvironmentId) + host: clientLaunchHost({ + runtimeEnvironmentId: ownerSettings?.activeRuntimeEnvironmentId, + launchPlatform, + isRemote + }), + canPasteAfterReady: true }) if (!planned) { return null @@ -108,7 +112,7 @@ export async function launchAgentBackgroundSession( promptLeftForPaste = isFollowupPath ? null : planned.text break } - let pasteDraftAfterLaunch = hasPrompt && isFollowupPath ? trimmedPrompt : promptLeftForPaste + let pasteDraftAfterLaunch = trimmedPrompt && isFollowupPath ? trimmedPrompt : promptLeftForPaste // A hidden run tab must never be store-visible without its PTY (#2989). const { reservedTabId, leafId, launchToken, launchRegistration, paneEnv } = @@ -174,7 +178,7 @@ export async function launchAgentBackgroundSession( tabId: reservedTabId, leafId, agent, - ...(hasPrompt && !isFollowupPath ? { prompt: trimmedPrompt } : {}), + ...(trimmedPrompt && !isFollowupPath ? { prompt: trimmedPrompt } : {}), ...(startupPlan.sessionOptions ? { sessionOptions: startupPlan.sessionOptions } : {}), legacy: { command: startupPlan.launchCommand, @@ -247,7 +251,7 @@ export async function launchAgentBackgroundSession( tab = adopted.tab paneKey = adopted.paneKey terminalOwnership = adopted.terminalOwnership - if (agent === 'command-code' && hasPrompt && !isFollowupPath) { + if (agent === 'command-code' && trimmedPrompt && !isFollowupPath) { // Why: Command Code does not expose a prompt-start hook; seed working for // hidden prompt launches so sidebar/activity surfaces do not stay idle. const routing = agentStatusConsumer.resolveRouting() diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index d44a7bb67397..4a18aa3a3798 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -142,7 +142,7 @@ vi.mock('@/runtime/web-runtime-session', () => ({ describe('launchAgentInNewTab', () => { beforeEach(() => { vi.clearAllMocks() - mockWaitForLaunchPromptReceipt.mockResolvedValue(true) + mockWaitForLaunchPromptReceipt.mockResolvedValue('delivered') mockIsWebRuntimeSessionActive.mockReturnValue(false) mockCreateWebRuntimeSessionTerminal.mockResolvedValue({ status: 'created' }) mockCreateWebRuntimeAgentSessionTerminalWithLaunchDraft.mockResolvedValue({ status: 'created' }) @@ -867,7 +867,7 @@ describe('launchAgentInNewTab', () => { it('reports a carried prompt undelivered when the agent never received it', async () => { const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - mockWaitForLaunchPromptReceipt.mockResolvedValue(false) + mockWaitForLaunchPromptReceipt.mockResolvedValue('not-delivered') const onPromptDelivered = vi.fn() const result = launchAgentInNewTab({ diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 096a2241d5c1..6b0f5801809e 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -3,7 +3,7 @@ import type { AgentStartupPlan } from '@/lib/tui-agent-startup' import { planLaunchAgentStartupPrompt } from '@/lib/launch-agent-startup-prompt-plan' import { persistAgentLaunchTabOrder } from '@/lib/launch-agent-tab-order' import { tuiAgentToAgentKind } from '@/lib/telemetry' -import { launchHostIsPaired } from '@/lib/launch-file-host' +import { clientLaunchHost } from '@/lib/launch-file-host' import { deliverNewTabLaunchPrompt } from '@/lib/launch-agent-new-tab-prompt-delivery' import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mode' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' @@ -160,7 +160,11 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent prompt: trimmedPrompt, promptDelivery, isFollowupPath, - launchHostIsPaired: launchHostIsPaired(runtimeEnvironmentId) + host: clientLaunchHost({ + runtimeEnvironmentId, + launchPlatform: resolvedLaunchPlatform, + isRemote + }) }) let promptDeliveryResult: Promise<{ delivered: boolean; failureNotified: boolean }> | undefined diff --git a/src/renderer/src/lib/launch-agent-new-tab-prompt-delivery.ts b/src/renderer/src/lib/launch-agent-new-tab-prompt-delivery.ts index 4bb2e43ab664..bab1b8759d53 100644 --- a/src/renderer/src/lib/launch-agent-new-tab-prompt-delivery.ts +++ b/src/renderer/src/lib/launch-agent-new-tab-prompt-delivery.ts @@ -1,5 +1,6 @@ import { createPasteReadinessTimeoutNotice } from '@/lib/launch-agent-paste-timeout-notice' import { waitForLaunchPromptReceipt } from '@/lib/agent-launch-prompt-receipt' +import { showAgentLaunchPromptUnconfirmedNotice } from '@/lib/agent-launch-prompt-not-delivered-notice' import { deliverLaunchPromptToAgentTab, seedNativeChatLaunchDraftForAgentTab, @@ -60,9 +61,18 @@ export function deliverNewTabLaunchPrompt(args: { content }) // Why the receipt for a carried prompt: callers post replies and resolve threads on this result. - const delivery = + const result = pasteDraftAfterLaunch === null - ? waitForLaunchPromptReceipt({ tabId, agent, launchedAt: args.launchedAt, ...unconfirmed }) + ? waitForLaunchPromptReceipt({ tabId, agent, launchedAt: args.launchedAt }).then( + (receipt) => { + if (receipt !== 'unconfirmed') { + return timeoutNotice.settle(receipt === 'delivered', args.onPromptDelivered) + } + // The agent may have the prompt: say so, and never invite pasting it a second time. + showAgentLaunchPromptUnconfirmedNotice({ agent, prompt }) + return { delivered: false, failureNotified: true } + } + ) : deliverLaunchPromptToAgentTab({ tabId, content, @@ -71,10 +81,7 @@ export function deliverNewTabLaunchPrompt(args: { forcePaste: true, onTimeout: timeoutNotice.onTimeout, ...unconfirmed - }) - const result = delivery.then((delivered) => - timeoutNotice.settle(delivered, args.onPromptDelivered) - ) + }).then((delivered) => timeoutNotice.settle(delivered, args.onPromptDelivered)) if (promptDelivery === 'submit-after-ready') { return result } diff --git a/src/renderer/src/lib/launch-agent-startup-prompt-plan.ts b/src/renderer/src/lib/launch-agent-startup-prompt-plan.ts index f82379f99a4e..7e41a5fdf161 100644 --- a/src/renderer/src/lib/launch-agent-startup-prompt-plan.ts +++ b/src/renderer/src/lib/launch-agent-startup-prompt-plan.ts @@ -6,6 +6,7 @@ import { type AgentStartupPlanInputs } from '@/lib/tui-agent-startup' import type { LaunchFile } from '../../../shared/launch-prompt-file' +import type { LaunchHost } from '../../../shared/launch-host' export type LaunchAgentStartupPromptPlan = { startupPlan: AgentStartupPlan | null @@ -26,8 +27,8 @@ export function planLaunchAgentStartupPrompt(args: { prompt: string promptDelivery: 'auto-submit' | 'draft' | 'submit-after-ready' isFollowupPath: boolean - /** See `launchHostIsPaired`. */ - launchHostIsPaired: boolean + /** The host the launch runs on (`clientLaunchHost`). */ + host: LaunchHost }): LaunchAgentStartupPromptPlan { const { base, prompt, promptDelivery, isFollowupPath } = args const pasteAfterReady = ( @@ -65,19 +66,13 @@ export function planLaunchAgentStartupPrompt(args: { submitPastedPrompt: false } } - if (prompt.length > 0 && isFollowupPath) { - return pasteAfterReady(cleanPlan(), prompt, promptDelivery === 'submit-after-ready') - } - // Why: a caller that waits for delivery gets a verdict from a paste; this client cannot observe a - // paired host's carried prompt reach its agent. Temporary, until paired hosts report that receipt. - if (prompt.length > 0 && args.launchHostIsPaired && promptDelivery === 'submit-after-ready') { + // Why outside the rule: this is the caller's need, not a host fact. A caller that waits for + // delivery gets a verdict from a paste, and this client cannot observe a paired host's carried + // prompt reach its agent. Temporary, until paired hosts report that receipt. + if (prompt.length > 0 && args.host.paired && promptDelivery === 'submit-after-ready') { return pasteAfterReady(cleanPlan(), prompt, true) } - const planned = planLaunchPrompt({ - ...base, - prompt, - launchHostIsPaired: args.launchHostIsPaired - }) + const planned = planLaunchPrompt({ ...base, prompt, host: args.host, canPasteAfterReady: true }) if (!planned) { return { startupPlan: null, pasteDraftAfterLaunch: null, submitPastedPrompt: false } } @@ -93,7 +88,11 @@ export function planLaunchAgentStartupPrompt(args: { submitPastedPrompt: false } case 'paste-after-ready': - // Every non-draft new-tab prompt is submitted; a draft returned above. - return pasteAfterReady(planned.cleanPlan, planned.text, true) + // An agent that takes text only after start keeps an auto-submit prompt as an editable draft. + return pasteAfterReady( + planned.cleanPlan, + planned.text, + !isFollowupPath || promptDelivery === 'submit-after-ready' + ) } } diff --git a/src/renderer/src/lib/launch-file-host.test.ts b/src/renderer/src/lib/launch-file-host.test.ts index 299c4672db54..4dbdb125d40f 100644 --- a/src/renderer/src/lib/launch-file-host.test.ts +++ b/src/renderer/src/lib/launch-file-host.test.ts @@ -23,10 +23,10 @@ describe('the quick composer on a paired host', () => { draftPrompt: null, settings: null, repoConnectionId: null, - platform: 'win32', - shell: 'powershell', + platform: 'linux', + shell: null, isRemote: false, - launchHostIsPaired: true, + host: { paired: true, provesAgentInFront: true }, telemetrySource: 'sidebar' }) expect(startup.backendStartup).toBeUndefined() diff --git a/src/renderer/src/lib/launch-file-host.ts b/src/renderer/src/lib/launch-file-host.ts index cb49325168c9..c1b7e5ed77f4 100644 --- a/src/renderer/src/lib/launch-file-host.ts +++ b/src/renderer/src/lib/launch-file-host.ts @@ -1,12 +1,27 @@ import { isWebRuntimeSessionActive } from '@/runtime/web-runtime-session-environment' +import { CLIENT_PLATFORM } from '@/lib/new-workspace' +import { describeLaunchHost, type LaunchHost } from '../../../shared/launch-host' import { isWebClientLocation } from './web-client-location' /** * Whether a launch lands on a paired Orca: another Orca this client drives, which may be an older - * build that neither stages a long line nor writes a launch file. `carryLaunchPrompt` then keeps - * the line to the typed budget and pastes the rest after the agent is ready. Temporary, until paired - * hosts advertise both. + * build that neither stages a long line nor writes a launch file. Temporary, until paired hosts + * advertise both. */ export function launchHostIsPaired(runtimeEnvironmentId: string | null | undefined): boolean { return isWebClientLocation() || isWebRuntimeSessionActive(runtimeEnvironmentId) } + +/** The host facts for a launch this client starts, from the environment it targets. */ +export function clientLaunchHost(args: { + runtimeEnvironmentId: string | null | undefined + launchPlatform: NodeJS.Platform + isRemote: boolean +}): LaunchHost { + return describeLaunchHost({ + launchPlatform: args.launchPlatform, + isRemote: args.isRemote, + hostPlatform: CLIENT_PLATFORM, + paired: launchHostIsPaired(args.runtimeEnvironmentId) + }) +} diff --git a/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts b/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts index 8dee872c816f..7d30fdb2d043 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts @@ -3,6 +3,7 @@ import type { AppState } from '@/store/types' import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' import { isTuiAgentEnabled, pickTuiAgent } from '../../../shared/tui-agent-selection' import { buildDirectWorkItemAgentStartupPlan } from '@/lib/launch-work-item-direct-agent' +import { clientLaunchHost } from '@/lib/launch-file-host' import type { AgentSessionLaunchPlan } from '@/lib/agent-session-launch-plan' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform' @@ -18,7 +19,7 @@ export function buildDirectWorkItemStartup(args: { launchConnectionId: string | null worktreePath: string repoProjectRuntime?: Parameters[0]['projectRuntime'] - launchHostIsPaired: boolean + runtimeEnvironmentId: string | null }): ReturnType { const launchPlatform = args.launchPlatform ?? @@ -39,7 +40,11 @@ export function buildDirectWorkItemStartup(args: { ), // Why: SSH hosts run the plain `orca` shim, so the Linux-only `orca-ide` rename is not applied. isRemote: typeof args.launchConnectionId === 'string', - launchHostIsPaired: args.launchHostIsPaired + host: clientLaunchHost({ + runtimeEnvironmentId: args.runtimeEnvironmentId, + launchPlatform, + isRemote: typeof args.launchConnectionId === 'string' + }) }) } diff --git a/src/renderer/src/lib/launch-work-item-direct-agent.test.ts b/src/renderer/src/lib/launch-work-item-direct-agent.test.ts index 44b38b650663..1b90b6f6e8c1 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent.test.ts @@ -83,7 +83,7 @@ const settings = { describe('buildDirectWorkItemAgentStartupPlan', () => { it('omits native-chat preferences when the new workspace opens in terminal mode', () => { const result = buildDirectWorkItemAgentStartupPlan({ - launchHostIsPaired: false, + host: { paired: false, provesAgentInFront: true }, agent: 'codex', draftContent: 'Review issue 42', promptDelivery: 'draft', @@ -98,7 +98,7 @@ describe('buildDirectWorkItemAgentStartupPlan', () => { it('applies native-chat preferences when the new workspace opens in chat', () => { const result = buildDirectWorkItemAgentStartupPlan({ - launchHostIsPaired: false, + host: { paired: false, provesAgentInFront: true }, agent: 'codex', draftContent: 'Review issue 42', promptDelivery: 'draft', @@ -147,7 +147,7 @@ describe('buildDirectWorkItemAgentStartupPlan global arguments fallback', () => it('resolves the global Agents arguments when the launch names none', () => { const result = buildDirectWorkItemAgentStartupPlan({ - launchHostIsPaired: false, + host: { paired: false, provesAgentInFront: true }, agent: 'codex', draftContent: 'Fix the broken checks', promptDelivery: 'draft', @@ -161,7 +161,7 @@ describe('buildDirectWorkItemAgentStartupPlan global arguments fallback', () => it('lets an explicit per-action value win over the global one', () => { const result = buildDirectWorkItemAgentStartupPlan({ - launchHostIsPaired: false, + host: { paired: false, provesAgentInFront: true }, agent: 'codex', agentArgs: '--model gpt-5', draftContent: 'Fix the broken checks', @@ -177,9 +177,9 @@ describe('buildDirectWorkItemAgentStartupPlan global arguments fallback', () => }) describe('buildDirectWorkItemAgentStartupPlan submitted prompts', () => { - const submit = (agent: 'claude' | 'gemini', draftContent: string, launchHostIsPaired = false) => + const submit = (agent: 'claude' | 'gemini', draftContent: string, paired = false) => buildDirectWorkItemAgentStartupPlan({ - launchHostIsPaired, + host: { paired, provesAgentInFront: true }, agent, draftContent, promptDelivery: 'submit-after-ready', diff --git a/src/renderer/src/lib/launch-work-item-direct-agent.ts b/src/renderer/src/lib/launch-work-item-direct-agent.ts index cdd32ec8bed4..6a75ba2fc8ae 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent.ts @@ -19,6 +19,7 @@ import { translate } from '@/i18n/i18n' import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat/native-chat-launch-session-options' import type { PersistedNativeChatSessionOptions } from '../../../shared/native-chat-session-options' import type { LaunchFile } from '../../../shared/launch-prompt-file' +import type { LaunchHost } from '../../../shared/launch-host' export function buildDirectWorkItemAgentStartupPlan(args: { agent: TuiAgent | null @@ -41,8 +42,8 @@ export function buildDirectWorkItemAgentStartupPlan(args: { /** Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only * `orca-ide` rename must not be applied for remote launches. */ isRemote?: boolean - /** See `launchHostIsPaired`. */ - launchHostIsPaired: boolean + /** The host the launch runs on (`clientLaunchHost`). */ + host: LaunchHost }): { startupPlan: AgentStartupPlan | null launchFile?: LaunchFile @@ -81,7 +82,8 @@ export function buildDirectWorkItemAgentStartupPlan(args: { const planned = planLaunchPrompt({ ...planInputs, prompt: args.draftContent, - launchHostIsPaired: args.launchHostIsPaired + host: args.host, + canPasteAfterReady: true }) if (!planned) { return { startupPlan: null, promptOnLaunchCommand: false, startupPlanFailed: true } diff --git a/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts b/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts index f19f8632015d..b968b74fb0c5 100644 --- a/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts +++ b/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts @@ -4,7 +4,6 @@ import { getConnectionId } from '@/lib/connection-context' import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' -import { launchHostIsPaired } from '@/lib/launch-file-host' import type { LaunchFile } from '../../../shared/launch-prompt-file' import type { AgentSessionLaunchPlan, @@ -93,9 +92,7 @@ export async function prepareDirectWorkItemAgentLaunch(args: { CLIENT_PLATFORM ) ?? args.repoProjectRuntime) : undefined, - launchHostIsPaired: launchHostIsPaired( - getRuntimeEnvironmentIdForWorktree(args.latestStore, args.worktreeId) - ) + runtimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(args.latestStore, args.worktreeId) }) const plan = diff --git a/src/renderer/src/lib/source-control-agent-action-plan.ts b/src/renderer/src/lib/source-control-agent-action-plan.ts index b32d6cbc6697..712d065abdeb 100644 --- a/src/renderer/src/lib/source-control-agent-action-plan.ts +++ b/src/renderer/src/lib/source-control-agent-action-plan.ts @@ -6,6 +6,7 @@ import { type AgentStartupPlan } from '@/lib/tui-agent-startup' import { CLIENT_PLATFORM } from '@/lib/new-workspace' +import { clientLaunchHost } from '@/lib/launch-file-host' import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' import { isTuiAgentEnabled } from '../../../shared/tui-agent-selection' import type { TuiAgent } from '../../../shared/tui-agent' @@ -45,8 +46,8 @@ export function planSourceControlAgentActionLaunch(args: { /** Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only * `orca-ide` rename must not be applied for remote launches. */ isRemote?: boolean - /** See `launchHostIsPaired`. */ - launchHostIsPaired?: boolean + /** The runtime environment the launch targets, for its host facts (`clientLaunchHost`). */ + runtimeEnvironmentId?: string | null }): SourceControlLaunchPlanResult { const agent = args.agent if (!agent) { @@ -104,24 +105,7 @@ export function planSourceControlAgentActionLaunch(args: { let startupPlan: AgentStartupPlan | null = null let delivery: SourceControlLaunchPlanDelivery - // Only an agent that takes no launch prompt waits for readiness; the rest carry it on argv. - if ( - args.promptDelivery === 'submit-after-ready' && - TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start' - ) { - startupPlan = buildAgentStartupPlan({ - agent, - prompt: '', - cmdOverrides, - platform, - shell, - isRemote, - agentArgs: args.agentArgs, - sessionOptions: args.sessionOptions, - allowEmptyPromptLaunch: true - }) - delivery = 'paste-submit' - } else if (args.promptDelivery === 'draft') { + if (args.promptDelivery === 'draft') { const draftLaunchPlan = buildAgentDraftLaunchPlan({ agent, draft: trimmedInput, @@ -161,19 +145,6 @@ export function planSourceControlAgentActionLaunch(args: { }) delivery = 'draft-paste' } - } else if (TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start') { - startupPlan = buildAgentStartupPlan({ - agent, - prompt: '', - cmdOverrides, - platform, - shell, - isRemote, - agentArgs: args.agentArgs, - sessionOptions: args.sessionOptions, - allowEmptyPromptLaunch: true - }) - delivery = 'draft-paste' } else { const planned = planLaunchPrompt({ agent, @@ -184,7 +155,12 @@ export function planSourceControlAgentActionLaunch(args: { isRemote, agentArgs: args.agentArgs, sessionOptions: args.sessionOptions, - launchHostIsPaired: args.launchHostIsPaired ?? false + host: clientLaunchHost({ + runtimeEnvironmentId: args.runtimeEnvironmentId, + launchPlatform: platform, + isRemote + }), + canPasteAfterReady: true }) switch (planned?.carry) { case undefined: @@ -201,7 +177,12 @@ export function planSourceControlAgentActionLaunch(args: { break case 'paste-after-ready': startupPlan = planned.cleanPlan - delivery = 'paste-submit' + // An agent that takes text only after start keeps an auto-submit prompt as a draft. + delivery = + TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start' && + args.promptDelivery !== 'submit-after-ready' + ? 'draft-paste' + : 'paste-submit' break } } diff --git a/src/renderer/src/web/preload-api/web-terminal-api.ts b/src/renderer/src/web/preload-api/web-terminal-api.ts index c4bf707ea3ce..e6e95a3c5756 100644 --- a/src/renderer/src/web/preload-api/web-terminal-api.ts +++ b/src/renderer/src/web/preload-api/web-terminal-api.ts @@ -40,6 +40,7 @@ export function createPtyApi(): NonNullable['pty']> { inspectProcess: () => Promise.reject(new Error('terminal_liveness_unavailable')), // Why: paired web panes cannot provide a local post-boundary process scan. confirmForegroundProcess: () => Promise.resolve(null), + readLaunchedAgentForeground: () => Promise.resolve('unknown' as const), // Why: a paired client's terminals belong to the host, whose Codex settings this client does not own. isCodexOnSharedServer: () => Promise.resolve(false), disableCodexSharedServerAutoStart: () => Promise.resolve(false), diff --git a/src/shared/launch-file-writing.test.ts b/src/shared/launch-file-writing.test.ts index cbdf971d4c25..8e0ed8ed90bc 100644 --- a/src/shared/launch-file-writing.test.ts +++ b/src/shared/launch-file-writing.test.ts @@ -10,7 +10,7 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { runProcess } from './child-process/run-process' import { LAUNCH_FILE_STALE_MS, @@ -187,7 +187,39 @@ describePosix('a launch line naming a file under an unusual home directory', () }) describe('sweepStaleLaunchFiles', () => { - it('removes only launch file directories older than a day', () => { + const ageOut = (directory: string, now: number): void => { + const staleSeconds = (now - LAUNCH_FILE_STALE_MS - 1000) / 1000 + utimesSync(directory, staleSeconds, staleSeconds) + } + + // Why: a daemon session outlives app restarts; its agent may re-read its task file any time. + it('never removes a folder whose owning process is alive, however old', () => { + const now = Date.now() + const live = join(baseDirectory, `orca-launch-file-${process.pid}-aaaaaa`) + mkdirSync(live) + ageOut(live, now) + sweepStaleLaunchFiles({ baseDirectory, now }) + expect(readdirSync(baseDirectory)).toEqual([`orca-launch-file-${process.pid}-aaaaaa`]) + }) + + it('removes a folder whose owning process is gone, however new', () => { + const deadPid = 2_147_483_000 + const kill = vi.spyOn(process, 'kill').mockImplementation((pid) => { + if (pid === deadPid) { + throw Object.assign(new Error('no such process'), { code: 'ESRCH' }) + } + return true + }) + try { + mkdirSync(join(baseDirectory, `orca-launch-file-${deadPid}-bbbbbb`)) + sweepStaleLaunchFiles({ baseDirectory }) + expect(readdirSync(baseDirectory)).toEqual([]) + } finally { + kill.mockRestore() + } + }) + + it('falls back to the age backstop for a folder that names no owner', () => { const now = Date.now() const stale = join(baseDirectory, 'orca-launch-file-aaaaaa') const fresh = join(baseDirectory, 'orca-launch-file-bbbbbb') @@ -195,10 +227,19 @@ describe('sweepStaleLaunchFiles', () => { for (const directory of [stale, fresh, unrelated]) { mkdirSync(directory) } - const staleSeconds = (now - LAUNCH_FILE_STALE_MS - 1000) / 1000 - utimesSync(stale, staleSeconds, staleSeconds) - utimesSync(unrelated, staleSeconds, staleSeconds) + ageOut(stale, now) + ageOut(unrelated, now) sweepStaleLaunchFiles({ baseDirectory, now }) expect(readdirSync(baseDirectory).sort()).toEqual(['orca-launch-file-bbbbbb', 'unrelated']) }) + + it('names the owning process in each folder it writes', () => { + const { launchFile } = carryInLaunchFile('x', true) + const written = writeLaunchFile({ + launchFile: { ...launchFile, quoting: 'posix' }, + baseDirectory + }) + expect(written.directory).toMatch(new RegExp(`orca-launch-file-${process.pid}-[^/\\\\]+$`)) + removeLaunchFile(written) + }) }) diff --git a/src/shared/launch-file-writing.ts b/src/shared/launch-file-writing.ts index b3d55e20b98c..78b24dc2756e 100644 --- a/src/shared/launch-file-writing.ts +++ b/src/shared/launch-file-writing.ts @@ -25,10 +25,22 @@ import type { WslLaunchDirectory } from './wsl-launch-directory' const LAUNCH_FILE_DIR_PREFIX = 'orca-launch-file-' const LAUNCH_FILE_NAME = 'task-context.md' +/** `orca-launch-file--`: the process whose sessions read the file. */ +const OWNED_LAUNCH_FILE_DIR = /^orca-launch-file-(\d+)-/ -/** A running agent may re-read its task file, so leftovers get a day, not an hour. */ +/** Backstop for a folder that names no owner: a running agent may re-read its task file. */ export const LAUNCH_FILE_STALE_MS = 24 * 60 * 60 * 1000 +/** Whether `pid` is a live process; one this process may not signal still counts as live. */ +function processIsAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return error instanceof Error && 'code' in error && error.code === 'EPERM' + } +} + // Characters one of the three quotings ends or expands on: `'` (POSIX, PowerShell, which also // honours the typographic single quotes), `"` and `%` (cmd). const QUOTE_SENSITIVE_PATH_CHARACTER = /['"%\u2018-\u201b]/ @@ -111,7 +123,9 @@ export function writeLaunchFile(args: { // Why realpath: an agent matches its read grant against the resolved path (macOS $TMPDIR sits // under /var -> /private/var), so the pointer and the granted directory both name that form. // A UNC path into WSL is named by its Linux path instead, which the distro resolves itself. - const created = mkdtempSync(join(baseDirectory, LAUNCH_FILE_DIR_PREFIX)) + // Why the pid: this process owns the sessions that read the file, which it removes when they + // end; the sweep leaves it alone while this process lives, however long that is. + const created = mkdtempSync(join(baseDirectory, `${LAUNCH_FILE_DIR_PREFIX}${process.pid}-`)) directory = wsl ? created : realpathSync(created) const launchDirectory = wsl ? posix.join(wsl.linuxPath, basename(created)) : directory const path = (wsl ? posix.join : join)(launchDirectory, LAUNCH_FILE_NAME) @@ -219,7 +233,11 @@ export function removeLaunchFile(written: WrittenLaunchFile | undefined): void { } } -/** Removes launch files a crashed host left behind; age-gated so a live agent's file survives. */ +/** + * Removes launch files a crashed host left behind. A folder whose owner process is alive is never + * touched: that process removes it when the session reading it ends. A dead owner's sessions died + * with it. A folder that names no owner falls back to the age backstop. + */ export function sweepStaleLaunchFiles(args: { baseDirectory?: string; now?: number }): void { const baseDirectory = args.baseDirectory ?? tmpdir() const now = args.now ?? Date.now() @@ -234,8 +252,12 @@ export function sweepStaleLaunchFiles(args: { baseDirectory?: string; now?: numb continue } const directory = join(baseDirectory, name) + const owner = OWNED_LAUNCH_FILE_DIR.exec(name)?.[1] try { - if (now - statSync(directory).mtimeMs > LAUNCH_FILE_STALE_MS) { + const orphaned = owner + ? !processIsAlive(Number(owner)) + : now - statSync(directory).mtimeMs > LAUNCH_FILE_STALE_MS + if (orphaned) { rmSync(directory, { recursive: true, force: true }) } } catch { diff --git a/src/shared/launch-host.test.ts b/src/shared/launch-host.test.ts new file mode 100644 index 000000000000..7eb4de191b42 --- /dev/null +++ b/src/shared/launch-host.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { describeLaunchHost } from './launch-host' + +describe('whether a launch host can prove its agent is in front before a paste', () => { + it.each([ + ['a local macOS host', false, false, 'darwin', 'darwin', true], + ['a local Linux host', false, false, 'linux', 'linux', true], + ['a local Windows host', false, false, 'win32', 'win32', false], + // The pane runs in the distro, but the reads run on the Windows host. + ['a local WSL pane', false, false, 'linux', 'win32', false], + ['an SSH Linux host from Windows', true, false, 'linux', 'win32', true], + ['an SSH Windows host', true, false, 'win32', 'darwin', false], + ['a paired Linux Orca from Windows', false, true, 'linux', 'win32', true], + ['a paired Windows Orca from macOS', false, true, 'win32', 'darwin', false] + ] as const)('%s', (_label, isRemote, paired, launchPlatform, hostPlatform, proves) => { + expect(describeLaunchHost({ isRemote, paired, launchPlatform, hostPlatform })).toEqual({ + paired, + provesAgentInFront: proves + }) + }) +}) diff --git a/src/shared/launch-host.ts b/src/shared/launch-host.ts new file mode 100644 index 000000000000..01dcdaa8c91e --- /dev/null +++ b/src/shared/launch-host.ts @@ -0,0 +1,28 @@ +/** What the host a launch runs on can do with its prompt, derived in one place from where it runs. */ +export type LaunchHost = { + /** Another Orca this client drives, possibly an older one: it is sent a command it may neither + * stage nor accompany with a launch file. Temporary, until paired hosts advertise both. */ + paired: boolean + /** Whether the host can prove the launched agent holds its terminal before a paste + * (`launched-agent-foreground`). A Windows host cannot, so it refuses a paste. */ + provesAgentInFront: boolean +} + +/** + * The launch host's facts. An SSH or paired host is judged by its own platform, a local one (a WSL + * pane included, whose process reads run on Windows) by the machine running this Orca. + */ +export function describeLaunchHost(args: { + /** The platform of the shell that types the launch line. */ + launchPlatform: NodeJS.Platform + isRemote: boolean + /** The platform of the machine this Orca runs on. */ + hostPlatform: NodeJS.Platform + paired: boolean +}): LaunchHost { + const runsElsewhere = args.isRemote || args.paired + return { + paired: args.paired, + provesAgentInFront: (runsElsewhere ? args.launchPlatform : args.hostPlatform) !== 'win32' + } +} diff --git a/src/shared/launch-prompt-carrier.test.ts b/src/shared/launch-prompt-carrier.test.ts index 81b690199706..6714581d2665 100644 --- a/src/shared/launch-prompt-carrier.test.ts +++ b/src/shared/launch-prompt-carrier.test.ts @@ -24,14 +24,14 @@ describe('the one launch-prompt decision every launch path builds through', () = ...base, prompt: 'fix it', platform: 'linux', - launchHostIsPaired: true + host: { paired: true, provesAgentInFront: true } }) expect(short?.carry).toBe('on-line') const long = planLaunchForTest({ ...base, prompt: 'fix it\nthen run the tests', platform: 'linux', - launchHostIsPaired: true + host: { paired: true, provesAgentInFront: true } }) expect(long?.carry).toBe('paste-after-ready') expect(long?.pasteAfterReady).toBe('fix it\nthen run the tests') diff --git a/src/shared/launch-prompt-carry.test.ts b/src/shared/launch-prompt-carry.test.ts index 30e8a43d156b..d4a18be1e658 100644 --- a/src/shared/launch-prompt-carry.test.ts +++ b/src/shared/launch-prompt-carry.test.ts @@ -5,13 +5,31 @@ import type { TuiAgent } from './tui-agent' import { RUNTIME_CAPABILITIES } from './protocol-version' import { AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY } from './agent-launch-runtime-capability' import { TYPED_STARTUP_LINE_BUDGET_BYTES, typedStartupLineFits } from './typed-startup-line' +import { describeLaunchHost } from './launch-host' +import { WINDOWS_TYPED_LINE_MAX_CHARS } from './launch-prompt-carry' function plan( agent: TuiAgent, prompt: string, extra: Partial> = {} ) { - return planLaunchPrompt({ agent, prompt, cmdOverrides: {}, platform: 'darwin', ...extra }) + const platform = extra.platform ?? 'darwin' + // A local launch on that platform, by a caller that pastes, unless the test says otherwise. + const host = describeLaunchHost({ + launchPlatform: platform, + isRemote: false, + hostPlatform: platform, + paired: false + }) + return planLaunchPrompt({ + agent, + prompt, + cmdOverrides: {}, + platform, + host, + canPasteAfterReady: true, + ...extra + }) } describe('where a launch prompt rides', () => { @@ -62,6 +80,15 @@ describe('where a launch prompt rides', () => { expect(planned.cleanPlan.launchCommand).not.toContain('gggg') }) + // Why: main started such an agent with the whole prompt on its line; a caller with no paste + // (agentSession.create, a phone quick command) must not refuse it now. + it('keeps a file-sized prompt on the line for a caller that cannot paste', () => { + const prompt = 'g'.repeat(MAX_INLINE_LAUNCH_PROMPT_CHARS + 1) + const planned = plan('gemini', prompt, { canPasteAfterReady: false }) + expect(planned?.carry).toBe('on-line') + expect(planned?.carry === 'on-line' && planned.plan.launchCommand).toContain('gggg') + }) + it('leaves a stdin-after-start agent’s prompt for the paste', () => { const planned = plan('aider', 'fix it') expect(planned).toMatchObject({ carry: 'paste-after-ready', text: 'fix it' }) @@ -78,48 +105,58 @@ describe('where a launch prompt rides', () => { }) describe('a host that types the line raw', () => { + // Why: the user's own words stay in the agent's history wherever a Windows line carries them. it.each<['cmd' | 'powershell']>([['cmd'], ['powershell']])( - 'keeps a %s line to the typed budget and points Claude at a file past it', + 'keeps an exact %s line up to cmd’s cap and points Claude at a file past it', (shell) => { - const short = plan('claude', 'fix it', { platform: 'win32', shell }) - expect(short?.carry).toBe('on-line') - const long = plan('claude', 'y'.repeat(600), { platform: 'win32', shell }) - expect(long?.carry).toBe('launch-file') + expect(plan('claude', 'fix it', { platform: 'win32', shell })?.carry).toBe('on-line') + expect(plan('claude', 'y'.repeat(4_000), { platform: 'win32', shell })?.carry).toBe('on-line') + const past = plan('claude', 'y'.repeat(WINDOWS_TYPED_LINE_MAX_CHARS), { + platform: 'win32', + shell + }) + expect(past?.carry).toBe('launch-file') + } + ) + + // The measured matrix (#23962 W-1): PowerShell splits at `"`, turns a trailing `\` into `"`, and + // a .cmd shim expands `%NAME%`; everything else arrives byte for byte. + it.each([ + ['a double quote', 'say "hi"', 'launch-file'], + ['a trailing backslash', 'see C:\\dir\\', 'launch-file'], + ['a %NAME% pair', 'echo %PATH%', 'launch-file'], + ['a lone percent and an apostrophe', "it's 100% done", 'on-line'] + ] as const)( + 'carries a PowerShell prompt with %s by the measured matrix', + (_label, text, carry) => { + expect(plan('claude', text, { platform: 'win32', shell: 'powershell' })?.carry).toBe(carry) } ) - it('points Codex at a file for a Windows-damaged prompt, and pastes it for Gemini', () => { + it('points Codex at a file for a Windows-damaged prompt, and keeps it on Gemini’s line', () => { const prompt = 'fix the build\nthen run the tests' expect(plan('codex', prompt, { platform: 'win32', shell: 'cmd' })?.carry).toBe('launch-file') - expect(plan('gemini', prompt, { platform: 'win32', shell: 'cmd' })).toMatchObject({ - carry: 'paste-after-ready', - text: prompt - }) - }) - - it('points a PowerShell prompt with a double quote at a file', () => { - expect(plan('claude', 'say "hi"', { platform: 'win32', shell: 'powershell' })?.carry).toBe( - 'launch-file' - ) + // A Windows host cannot prove Gemini is in front to paste into, so it types it as main did. + expect(plan('gemini', prompt, { platform: 'win32', shell: 'cmd' })?.carry).toBe('on-line') }) // Why: a host that cannot prove the agent holds its terminal refuses a paste (#24257), so the // line carries what the paste would have, as main typed it; Claude and Codex still get the file. it('keeps a prompt on the line of a host that cannot paste, unless a file can carry it', () => { + // A local WSL pane: a Linux line, on a Windows host that cannot read what holds it. const extra = { - platform: 'win32' as const, - shell: 'cmd' as const, - hostProvesAgentInFront: false + platform: 'linux' as const, + host: { paired: false, provesAgentInFront: false } } const prompt = 'fix the build\nthen run the tests' expect(plan('gemini', prompt, extra)?.carry).toBe('on-line') - expect(plan('gemini', 'y'.repeat(600), extra)?.carry).toBe('on-line') - expect(plan('claude', prompt, extra)?.carry).toBe('launch-file') + expect(plan('gemini', 'y'.repeat(20_000), extra)?.carry).toBe('on-line') + expect(plan('claude', 'y'.repeat(20_000), extra)?.carry).toBe('launch-file') expect(plan('aider', prompt, extra)?.carry).toBe('paste-after-ready') }) it('pastes on a paired host what its line cannot carry typed, even for Claude', () => { - const extra = { platform: 'linux' as const, launchHostIsPaired: true } + const extra = { platform: 'linux' as const, host: { paired: true, provesAgentInFront: true } } expect(plan('claude', 'fix it', extra)?.carry).toBe('on-line') expect(plan('claude', 'first line\nsecond line', extra)?.carry).toBe('paste-after-ready') expect(plan('claude', 'z'.repeat(20_000), extra)?.carry).toBe('paste-after-ready') diff --git a/src/shared/launch-prompt-carry.ts b/src/shared/launch-prompt-carry.ts index cc1eccd7a6eb..1c98fb5e4f99 100644 --- a/src/shared/launch-prompt-carry.ts +++ b/src/shared/launch-prompt-carry.ts @@ -14,6 +14,7 @@ import { } from './tui-agent-startup-shell' import { hasControlByte, typedStartupLineFits } from './typed-startup-line' import type { TuiAgent } from './tui-agent' +import type { LaunchHost } from './launch-host' /** * Whether a Windows shell would damage `prompt` as one quoted argument. No quoting keeps a control @@ -75,26 +76,41 @@ export type CarriedPlanArgs = { shell?: AgentStartupShell /** The file `prompt` already points at, when the caller wrote its own. */ launchFile?: LaunchFile - /** Another Orca this client drives, possibly an older one: it is sent a command it may neither - * stage nor accompany with a launch file. Temporary, until paired hosts advertise both. */ - launchHostIsPaired?: boolean - /** False where the host cannot prove the launched agent holds its terminal (a Windows host), - * so it refuses a paste (`launched-agent-foreground`). */ - hostProvesAgentInFront?: boolean + /** The host the launch runs on (`describeLaunchHost`). */ + host: LaunchHost + /** Whether the caller pastes a prompt left for after the agent is ready. One that cannot still + * starts the agent with the prompt on its line, as main did. */ + canPasteAfterReady: boolean } export function agentReadsLaunchFile(agent: TuiAgent): boolean { return TUI_AGENT_CONFIG[agent].readsLaunchFile === true } +/** cmd.exe's documented line cap, the smallest of the Windows shells Orca types into. */ +export const WINDOWS_TYPED_LINE_MAX_CHARS = 8191 + +/** + * Whether a Windows shell carries `prompt` exactly on `line`. Nothing stages a Windows line, so it + * must hold no control byte, fit cmd's cap, and survive the shell's quoting (measured matrix: + * `windowsShellDamagesPrompt`). + */ +function windowsLineCarriesExactly(prompt: string, line: string, shell: AgentStartupShell) { + return ( + !hasControlByte(line) && + line.length <= WINDOWS_TYPED_LINE_MAX_CHARS && + !windowsShellDamagesPrompt(prompt, shell) + ) +} + /** - * The one carry rule. The prompt rides the agent's line, which a host that stages (POSIX, SSH, WSL) - * stages when it is long or multi-line. It rides a launch file when it is past the argv ceiling, - * when a Windows shell would damage it, or when the host types the line raw (a Windows host, a - * paired Orca) and the line is past the typed budget. A launch file goes only to an agent measured - * reading one, on a host that writes it; otherwise the agent starts clean and the prompt is pasted - * once it is ready, except where the host cannot prove the agent is in front to paste into: there - * the line carries it, as main typed it. + * The one carry rule. The prompt rides the agent's line: a host that stages (POSIX, SSH, WSL) + * stages it when it is long or multi-line, and a Windows host types it when its shell carries the + * text exactly. It rides a launch file when it is past the argv ceiling, when a Windows line would + * damage it, or past a paired host's typed budget. A launch file goes only to an agent measured + * reading one, on a host that writes it. Otherwise the prompt is pasted once the agent is ready, + * unless the host cannot prove the agent is in front to paste into or the caller has no paste: + * then the line carries it, as main typed it. */ export function carryLaunchPrompt( args: A, @@ -120,12 +136,13 @@ export function carryLaunchPrompt | null => { - const plan = args.hostProvesAgentInFront === false ? buildLine(args) : null + const pasteIsSafe = args.host.provesAgentInFront && args.canPasteAfterReady + const plan = pasteIsSafe ? null : buildLine(args) return plan ? { carry: 'on-line', plan } : pasteAfterReady() } const viaLaunchFile = (): LaunchPromptPlan

| null => { // Why: an agent not measured reading the file would stop on an approval or refuse the path. - if (args.launchHostIsPaired === true || !agentReadsLaunchFile(args.agent)) { + if (args.host.paired || !agentReadsLaunchFile(args.agent)) { return lineOrPaste() } const pointer = carryInLaunchFile(text, false) @@ -134,23 +151,25 @@ export function carryLaunchPrompt MAX_INLINE_LAUNCH_PROMPT_CHARS || - (!readsEnv && windowsShellDamagesPrompt(text, shell)) - ) { + if (text.length > MAX_INLINE_LAUNCH_PROMPT_CHARS) { return viaLaunchFile() } const plan = buildLine(args) + const readsEnv = mode === 'hermes-query' if (!plan) { // Hermes reads its prompt from the env and refuses one past that budget, counted in bytes; a // one-character query building proves the budget, not the command, refused it. return readsEnv && buildLine({ ...args, prompt: '.' }) ? viaLaunchFile() : null } - // Why the typed budget (#24257's): nothing stages a line the host types raw, and a long typed - // line is truncated or submitted early. Hermes's line never holds the text. - const hostTypesLineRaw = args.launchHostIsPaired === true || args.platform === 'win32' - if (!readsEnv && hostTypesLineRaw && !typedStartupLineFits(plan.launchCommand)) { + // Hermes's line never holds the text. + if (readsEnv) { + return { carry: 'on-line', plan } + } + if (args.platform === 'win32' && !windowsLineCarriesExactly(text, plan.launchCommand, shell)) { + return viaLaunchFile() + } + // Why #24257's typed budget: an older paired host may type the line raw, truncated past it. + if (args.host.paired && !typedStartupLineFits(plan.launchCommand)) { return viaLaunchFile() } return { carry: 'on-line', plan } diff --git a/src/shared/launch-prompt-file.ts b/src/shared/launch-prompt-file.ts index 0c99fc1251ee..62c9e2ebc621 100644 --- a/src/shared/launch-prompt-file.ts +++ b/src/shared/launch-prompt-file.ts @@ -87,7 +87,7 @@ export function parseLaunchFile(value: unknown): LaunchFile | undefined { return { placeholder, content, sensitive, ...(knownQuoting ? { quoting: knownQuoting } : {}) } } -const LAUNCH_FILE_UNAVAILABLE_CODE = 'launch_file_unavailable' +export const LAUNCH_FILE_UNAVAILABLE_CODE = 'launch_file_unavailable' /** The refusal a host sends when it could not write what carries the prompt (a launch file, or the * staged script holding a long line); it reaches the user as is. */ @@ -106,3 +106,8 @@ export function describeLaunchFileUnavailable( export function isLaunchFileUnavailableMessage(message: string): boolean { return message.includes(LAUNCH_FILE_UNAVAILABLE_CODE) } + +/** The refusal as it reaches main from any host: its code is its type across process boundaries. */ +export function isLaunchFileRefusal(error: unknown): boolean { + return error instanceof Error && isLaunchFileUnavailableMessage(error.message) +} diff --git a/src/shared/launch-prompt-plan.test-fixture.ts b/src/shared/launch-prompt-plan.test-fixture.ts index 782eba000863..2a6111527906 100644 --- a/src/shared/launch-prompt-plan.test-fixture.ts +++ b/src/shared/launch-prompt-plan.test-fixture.ts @@ -1,4 +1,5 @@ import type { LaunchFile } from './launch-prompt-file' +import { describeLaunchHost } from './launch-host' import type { LaunchPromptCarry } from './launch-prompt-carry' import { planLaunchPrompt, @@ -17,13 +18,28 @@ export type PlannedLaunchForTest = AgentStartupPlan & { /** `planLaunchPrompt`, flattened; null where it answers null, or for an empty prompt unless * `allowEmptyPromptLaunch`, as the builder did before the outcome was explicit. */ export function planLaunchForTest( - args: AgentLaunchPromptArgs & { allowEmptyPromptLaunch?: boolean } + args: Omit & + Partial> & { + allowEmptyPromptLaunch?: boolean + } ): PlannedLaunchForTest | null { const { allowEmptyPromptLaunch, ...launchArgs } = args if (allowEmptyPromptLaunch !== true && !args.prompt.trim()) { return null } - const planned = planLaunchPrompt(launchArgs) + const planned = planLaunchPrompt({ + ...launchArgs, + // A local launch on the platform under test, by a caller that pastes, unless the test says. + host: + args.host ?? + describeLaunchHost({ + launchPlatform: args.platform, + isRemote: args.isRemote === true, + hostPlatform: args.platform, + paired: false + }), + canPasteAfterReady: args.canPasteAfterReady ?? true + }) if (!planned) { return null } diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index ba7093e98840..23a47218339d 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -10,6 +10,7 @@ import { type AgentStartupShell } from './tui-agent-startup-shell' import type { LaunchFile } from './launch-prompt-file' +import type { LaunchHost } from './launch-host' import { carryLaunchPrompt, launchFileDirectoryGrant, @@ -72,10 +73,10 @@ export type AgentLaunchPromptArgs = AgentStartupPlanInputs & { prompt: string /** The file `prompt` already points at, when the caller wrote its own. */ launchFile?: LaunchFile - /** Another Orca this client drives; see `CarriedPlanArgs`. */ - launchHostIsPaired?: boolean /** See `CarriedPlanArgs`. */ - hostProvesAgentInFront?: boolean + host: LaunchHost + /** See `CarriedPlanArgs`. */ + canPasteAfterReady: boolean } /** @@ -89,7 +90,9 @@ export function planLaunchPrompt( } /** Builds the line with `prompt` on it; the carry rule decides whether it should. */ -function buildPlanWithPromptOnLine(args: AgentLaunchPromptArgs): AgentStartupPlan | null { +function buildPlanWithPromptOnLine( + args: AgentStartupPlanInputs & { prompt: string; launchFile?: LaunchFile } +): AgentStartupPlan | null { const { agent, prompt, cmdOverrides, platform } = args const shell = resolveStartupShell(platform, args.shell) const trimmedPrompt = prompt.trim() diff --git a/src/shared/windows-shell-prompt-damage.test.ts b/src/shared/windows-shell-prompt-damage.test.ts index 579f2639981d..1deb601ea659 100644 --- a/src/shared/windows-shell-prompt-damage.test.ts +++ b/src/shared/windows-shell-prompt-damage.test.ts @@ -76,18 +76,18 @@ describe('a prompt a Windows shell would damage on the launch line', () => { ) }) - it('launches clean and leaves the prompt for the paste when the host writes no launch file', () => { + it('keeps the prompt on a paired Windows host’s line, which writes no file and cannot paste', () => { const startup = planLaunchForTest({ agent: 'claude', prompt: 'fix the build\nthen run the tests', cmdOverrides: {}, platform: 'win32', shell: 'powershell', - launchHostIsPaired: true + host: { paired: true, provesAgentInFront: false } }) expect(startup?.launchFile).toBeUndefined() - expect(startup?.launchCommand).toBe('claude') - expect(startup?.pasteAfterReady).toBe('fix the build\nthen run the tests') + expect(startup?.launchCommand).toContain('fix the build') + expect(startup?.pasteAfterReady).toBeNull() }) it('tells its own pointer apart from a prompt that merely mentions one', () => { From 7c0269be54e671b7af25cf269bbc8a3a327c52be Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 02:52:48 -0700 Subject: [PATCH 19/42] test(launch-prompt): build the phone line-carry runtime from the default store, without a cast --- .../orca-runtime-tests/mobile-session-tabs-part-12.spec.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-12.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-12.spec.ts index 5545ce19b792..2251fe81b38d 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-12.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-12.spec.ts @@ -42,10 +42,7 @@ describe('OrcaRuntimeService', () => { // ready, so it keeps the line rather than refuse what main started. it('starts an agent with a prompt it cannot paste on its line, as main did', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'pty-agent-long-prompt' }) - const runtime = new OrcaRuntimeService({ - ...store, - getSettings: () => ({ ...store.getSettings(), disabledTuiAgents: [], agentCmdOverrides: {} }) - } as never) + const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ spawn, write: () => true, From ea4699eaea576d437bbebec44db4b8579997d0e1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 02:56:36 -0700 Subject: [PATCH 20/42] docs(launch-file): the sweep, not an age gate, is removeLaunchFile's fallback --- src/shared/launch-file-writing.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/launch-file-writing.ts b/src/shared/launch-file-writing.ts index 78b24dc2756e..684ad53e5a31 100644 --- a/src/shared/launch-file-writing.ts +++ b/src/shared/launch-file-writing.ts @@ -229,7 +229,7 @@ export function removeLaunchFile(written: WrittenLaunchFile | undefined): void { try { rmSync(written.directory, { recursive: true, force: true }) } catch { - // The age-gated sweep is the fallback. + // The sweep removes it once this process is gone. } } From 0599ae2f56a1552e004a628b8272117628f3db2e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 03:08:42 -0700 Subject: [PATCH 21/42] fix(launch-prompt): on Windows, paste what the line can't carry where main pasted it Main pastes an AI button's or notes send's prompt once the agent runs, so the agent's history, /resume, the chat view and AI Vault show the user's text. A launch file replaced nearly every such prompt on Windows with a pointer. The caller's paste is now one of 'never', 'when-host-proves-agent' (#24257's guarded paste) or 'once-agent-runs' (main's desktop paste for submit-after-ready prompts). On Windows a prompt the line cannot carry exactly goes to an 'once-agent-runs' paste first; a launch file is left for the lines main would have typed damaged or too long. Temporary, until the agent's own argv carries multi-line text past cmd without a shim re-parsing it. --- .../orca-runtime-create-agent-session.ts | 2 +- ...resolve-mobile-session-terminal-command.ts | 2 +- ...runtime-resolve-worktree-removal-target.ts | 2 +- .../runtime/runtime-worktree-agent-startup.ts | 4 +- .../folder-workspace-composer-submit.ts | 2 +- .../full-creation-startup.test.ts | 2 +- .../composer-state/full-submit-preparation.ts | 2 +- .../composer-state/quick-startup-plan.ts | 2 +- .../lib/launch-agent-background-session.ts | 2 +- ...h-agent-in-new-tab-windows-quoting.test.ts | 20 +++++++++ .../lib/launch-agent-startup-prompt-plan.ts | 8 +++- .../src/lib/launch-work-item-direct-agent.ts | 2 +- .../source-control-agent-action-plan.test.ts | 20 +++++++++ .../lib/source-control-agent-action-plan.ts | 3 +- src/shared/launch-host.ts | 2 +- src/shared/launch-prompt-carry.test.ts | 43 +++++++++++++++++-- src/shared/launch-prompt-carry.ts | 37 +++++++++++----- src/shared/launch-prompt-plan.test-fixture.ts | 8 ++-- src/shared/tui-agent-startup.ts | 3 +- 19 files changed, 134 insertions(+), 32 deletions(-) diff --git a/src/main/runtime/orca-runtime-create-agent-session.ts b/src/main/runtime/orca-runtime-create-agent-session.ts index 63370145083f..34f2a96e4ce7 100644 --- a/src/main/runtime/orca-runtime-create-agent-session.ts +++ b/src/main/runtime/orca-runtime-create-agent-session.ts @@ -189,7 +189,7 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe isRemote: Boolean(workspace.connectionId) }), // Why: this create returns before the agent is ready, so nothing pastes after it. - canPasteAfterReady: false + paste: 'never' }) if (!planned) { throw new Error('agent_session_identity_required') diff --git a/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts b/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts index a619bbb6ee95..50b05b07e03c 100644 --- a/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts +++ b/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts @@ -63,7 +63,7 @@ export class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends OrcaRunt prompt: opts.agentPrompt ?? '', host: thisOrcaLaunchHost({ launchPlatform, isRemote }), // Why: a quick command has no paste after ready. - canPasteAfterReady: false + paste: 'never' }) if (!planned) { throw new Error(`Could not build launch command for ${opts.agent}.`) 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 dc9ef38d8136..2617268d4c15 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -272,7 +272,7 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith prompt: opts.startupPrompt ?? '', ...(opts.launchFile ? { launchFile: opts.launchFile } : {}), host: thisOrcaLaunchHost({ launchPlatform: platform, isRemote }), - canPasteAfterReady: opts.onStartupPromptCarry !== undefined + paste: opts.onStartupPromptCarry ? 'when-host-proves-agent' : 'never' }) if (!planned) { // Why: an explicit agent that yields no plan would otherwise spawn a bare diff --git a/src/main/runtime/runtime-worktree-agent-startup.ts b/src/main/runtime/runtime-worktree-agent-startup.ts index 447daa30d540..33e65cd84896 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.ts @@ -162,8 +162,8 @@ export function buildWorktreeStartupForAgent( isRemote: repoIsRemote(repo), launchPlatform: environment.getLaunchPlatform() }), - // A caller that cannot paste takes a follow-up and sends it once the agent is ready. - canPasteAfterReady: true + // The caller, or else the host's follow-up, pastes what the line leaves once the agent runs. + paste: 'when-host-proves-agent' }) if (!planned) { throw new Error(`Could not build launch command for ${agent}.`) diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index 960291aa1e35..4149930dee46 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -134,7 +134,7 @@ export async function submitFolderWorkspaceCreate({ launchPlatform, isRemote: launchIsRemote }), - canPasteAfterReady: true + paste: 'when-host-proves-agent' }), note ) diff --git a/src/renderer/src/hooks/composer-state/full-creation-startup.test.ts b/src/renderer/src/hooks/composer-state/full-creation-startup.test.ts index 8bfefea7195f..33b00e8c2ac5 100644 --- a/src/renderer/src/hooks/composer-state/full-creation-startup.test.ts +++ b/src/renderer/src/hooks/composer-state/full-creation-startup.test.ts @@ -17,7 +17,7 @@ describe('the full composer’s renderer-spawned startup', () => { platform: 'linux', isRemote: true, host: { paired: false, provesAgentInFront: true }, - canPasteAfterReady: true + paste: 'when-host-proves-agent' }), prompt ) diff --git a/src/renderer/src/hooks/composer-state/full-submit-preparation.ts b/src/renderer/src/hooks/composer-state/full-submit-preparation.ts index 68a1fa6df82d..44f0c86e62e4 100644 --- a/src/renderer/src/hooks/composer-state/full-submit-preparation.ts +++ b/src/renderer/src/hooks/composer-state/full-submit-preparation.ts @@ -210,7 +210,7 @@ export function useFullSubmitPreparation(input: FullSubmitPreparationInput) { launchPlatform: selectedRepoAgentLaunchPlatform, isRemote: selectedRepoIsRemote }), - canPasteAfterReady: true + paste: 'when-host-proves-agent' }), submitStartupPrompt ) diff --git a/src/renderer/src/hooks/composer-state/quick-startup-plan.ts b/src/renderer/src/hooks/composer-state/quick-startup-plan.ts index 2a8feb0143ae..1517d2079ffa 100644 --- a/src/renderer/src/hooks/composer-state/quick-startup-plan.ts +++ b/src/renderer/src/hooks/composer-state/quick-startup-plan.ts @@ -98,7 +98,7 @@ export function buildQuickComposerStartup(input: QuickComposerStartupInput): Qui shell: input.shell ?? undefined, isRemote: input.isRemote, host: input.host, - canPasteAfterReady: true + paste: 'when-host-proves-agent' }), prompt ) diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index 34839d549054..9c6ba6f27140 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -89,7 +89,7 @@ export async function launchAgentBackgroundSession( launchPlatform, isRemote }), - canPasteAfterReady: true + paste: 'when-host-proves-agent' }) if (!planned) { return null diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-windows-quoting.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab-windows-quoting.test.ts index 63053e0e90a8..568229d01e48 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab-windows-quoting.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab-windows-quoting.test.ts @@ -181,6 +181,26 @@ describe('launchAgentInNewTab Windows shell quoting', () => { expect(mockCreateWebRuntimeAgentSessionTerminalWithLaunchDraft).not.toHaveBeenCalled() }) + it('pastes a multi-line AI-button prompt on Windows, as main did, instead of a launch file', async () => { + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + const prompt = 'Fix the failing checks.\nThen push.' + + launchAgentInNewTab({ + agent: 'codex', + worktreeId: 'wt-1', + prompt, + promptDelivery: 'submit-after-ready', + launchPlatform: 'win32' + }) + + const queued = mockQueueTabStartupCommand.mock.calls[0]?.[1] + expect(queued?.launchFile).toBeUndefined() + expect(queued?.command).not.toContain('Fix the failing checks') + expect(mockPasteDraftWhenAgentReady).toHaveBeenCalledWith( + expect.objectContaining({ tabId: 'tab-1', content: prompt, agent: 'codex', submit: true }) + ) + }) + it('uses the explicit startup shell platform when building draft launch commands', async () => { const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') diff --git a/src/renderer/src/lib/launch-agent-startup-prompt-plan.ts b/src/renderer/src/lib/launch-agent-startup-prompt-plan.ts index 7e41a5fdf161..c40d74f94da1 100644 --- a/src/renderer/src/lib/launch-agent-startup-prompt-plan.ts +++ b/src/renderer/src/lib/launch-agent-startup-prompt-plan.ts @@ -72,7 +72,13 @@ export function planLaunchAgentStartupPrompt(args: { if (prompt.length > 0 && args.host.paired && promptDelivery === 'submit-after-ready') { return pasteAfterReady(cleanPlan(), prompt, true) } - const planned = planLaunchPrompt({ ...base, prompt, host: args.host, canPasteAfterReady: true }) + const planned = planLaunchPrompt({ + ...base, + prompt, + host: args.host, + // Why: main pastes a submit-after-ready prompt once the agent runs and types the rest. + paste: promptDelivery === 'submit-after-ready' ? 'once-agent-runs' : 'when-host-proves-agent' + }) if (!planned) { return { startupPlan: null, pasteDraftAfterLaunch: null, submitPastedPrompt: false } } diff --git a/src/renderer/src/lib/launch-work-item-direct-agent.ts b/src/renderer/src/lib/launch-work-item-direct-agent.ts index 6a75ba2fc8ae..ab97738be23b 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent.ts @@ -83,7 +83,7 @@ export function buildDirectWorkItemAgentStartupPlan(args: { ...planInputs, prompt: args.draftContent, host: args.host, - canPasteAfterReady: true + paste: 'once-agent-runs' }) if (!planned) { return { startupPlan: null, promptOnLaunchCommand: false, startupPlanFailed: true } diff --git a/src/renderer/src/lib/source-control-agent-action-plan.test.ts b/src/renderer/src/lib/source-control-agent-action-plan.test.ts index 5441f2cde685..ebd6bd5aa7a3 100644 --- a/src/renderer/src/lib/source-control-agent-action-plan.test.ts +++ b/src/renderer/src/lib/source-control-agent-action-plan.test.ts @@ -55,6 +55,26 @@ describe('planSourceControlAgentActionLaunch', () => { expect(result.ok && result.summary).not.toContain('included in the launch command') }) + // Why: main pastes an AI button's prompt on Windows, so the agent's history shows the user's + // text; only a typed prompt the line would damage rides a launch file. + it('pastes a multi-line AI-button prompt on Windows, and files the same prompt typed', () => { + const plan = (promptDelivery: 'submit-after-ready' | 'auto-submit') => + planSourceControlAgentActionLaunch({ + agent: 'claude', + commandInput: 'Fix the failing checks.\nThen push.', + promptDelivery, + detectedAgents: ['claude'], + platform: 'win32', + terminalWindowsShell: 'cmd.exe' + }) + + const button = plan('submit-after-ready') + expect(button.ok && button.delivery).toBe('paste-submit') + expect(button.ok && button.plan.launchCommand).not.toContain('task-context') + const typed = plan('auto-submit') + expect(typed.ok && typed.summary).toContain('private file') + }) + it('includes per-action CLI arguments in submit-after-ready launch plans', () => { const result = planSourceControlAgentActionLaunch({ agent: 'codex', diff --git a/src/renderer/src/lib/source-control-agent-action-plan.ts b/src/renderer/src/lib/source-control-agent-action-plan.ts index 712d065abdeb..e28c01ea86d8 100644 --- a/src/renderer/src/lib/source-control-agent-action-plan.ts +++ b/src/renderer/src/lib/source-control-agent-action-plan.ts @@ -160,7 +160,8 @@ export function planSourceControlAgentActionLaunch(args: { launchPlatform: platform, isRemote }), - canPasteAfterReady: true + paste: + args.promptDelivery === 'submit-after-ready' ? 'once-agent-runs' : 'when-host-proves-agent' }) switch (planned?.carry) { case undefined: diff --git a/src/shared/launch-host.ts b/src/shared/launch-host.ts index 01dcdaa8c91e..2666fa39d940 100644 --- a/src/shared/launch-host.ts +++ b/src/shared/launch-host.ts @@ -4,7 +4,7 @@ export type LaunchHost = { * stage nor accompany with a launch file. Temporary, until paired hosts advertise both. */ paired: boolean /** Whether the host can prove the launched agent holds its terminal before a paste - * (`launched-agent-foreground`). A Windows host cannot, so it refuses a paste. */ + * (`launched-agent-foreground`). A Windows host cannot, so #24257's guarded paste is refused. */ provesAgentInFront: boolean } diff --git a/src/shared/launch-prompt-carry.test.ts b/src/shared/launch-prompt-carry.test.ts index d4a18be1e658..aad6793ede18 100644 --- a/src/shared/launch-prompt-carry.test.ts +++ b/src/shared/launch-prompt-carry.test.ts @@ -14,7 +14,7 @@ function plan( extra: Partial> = {} ) { const platform = extra.platform ?? 'darwin' - // A local launch on that platform, by a caller that pastes, unless the test says otherwise. + // A local launch on that platform, with #24257's guarded paste, unless the test says otherwise. const host = describeLaunchHost({ launchPlatform: platform, isRemote: false, @@ -27,7 +27,7 @@ function plan( cmdOverrides: {}, platform, host, - canPasteAfterReady: true, + paste: 'when-host-proves-agent', ...extra }) } @@ -84,7 +84,7 @@ describe('where a launch prompt rides', () => { // (agentSession.create, a phone quick command) must not refuse it now. it('keeps a file-sized prompt on the line for a caller that cannot paste', () => { const prompt = 'g'.repeat(MAX_INLINE_LAUNCH_PROMPT_CHARS + 1) - const planned = plan('gemini', prompt, { canPasteAfterReady: false }) + const planned = plan('gemini', prompt, { paste: 'never' }) expect(planned?.carry).toBe('on-line') expect(planned?.carry === 'on-line' && planned.plan.launchCommand).toContain('gggg') }) @@ -140,6 +140,40 @@ describe('a host that types the line raw', () => { expect(plan('gemini', prompt, { platform: 'win32', shell: 'cmd' })?.carry).toBe('on-line') }) + // Why: main pastes an AI button's or notes send's prompt once the agent runs, so the agent's + // history shows the user's text; a pointer replaces only a line main would type damaged. + it.each(['claude', 'codex'])( + 'pastes what a Windows line cannot carry for a caller whose paste main used, for %s', + (agent) => { + const paste = 'once-agent-runs' as const + const multiLine = 'fix the build\nthen run the tests' + const pasted = plan(agent, multiLine, { platform: 'win32', shell: 'cmd', paste }) + expect(pasted).toMatchObject({ carry: 'paste-after-ready', text: multiLine }) + const long = 'y'.repeat(20_000) + expect(plan(agent, long, { platform: 'win32', shell: 'cmd', paste })?.carry).toBe( + 'paste-after-ready' + ) + expect(plan(agent, 'fix it', { platform: 'win32', shell: 'cmd', paste })?.carry).toBe( + 'on-line' + ) + const typed = plan(agent, multiLine, { platform: 'win32', shell: 'cmd' }) + expect(typed?.carry).toBe('launch-file') + } + ) + + it('pastes a Windows-damaged prompt for any agent when the caller’s paste is main’s', () => { + const extra = { platform: 'win32' as const, shell: 'powershell' as const } + expect(plan('gemini', 'say "hi"', { ...extra, paste: 'once-agent-runs' })?.carry).toBe( + 'paste-after-ready' + ) + expect(plan('gemini', 'say "hi"', extra)?.carry).toBe('on-line') + }) + + it('still points Claude at a file past the argv ceiling on a POSIX host, whatever the paste', () => { + const planned = plan('claude', 'y'.repeat(20_000), { paste: 'once-agent-runs' }) + expect(planned?.carry).toBe('launch-file') + }) + // Why: a host that cannot prove the agent holds its terminal refuses a paste (#24257), so the // line carries what the paste would have, as main typed it; Claude and Codex still get the file. it('keeps a prompt on the line of a host that cannot paste, unless a file can carry it', () => { @@ -153,6 +187,9 @@ describe('a host that types the line raw', () => { expect(plan('gemini', 'y'.repeat(20_000), extra)?.carry).toBe('on-line') expect(plan('claude', 'y'.repeat(20_000), extra)?.carry).toBe('launch-file') expect(plan('aider', prompt, extra)?.carry).toBe('paste-after-ready') + // An AI button's paste is main's, which ran in WSL too. + const button = { ...extra, paste: 'once-agent-runs' as const } + expect(plan('gemini', 'y'.repeat(20_000), button)?.carry).toBe('paste-after-ready') }) it('pastes on a paired host what its line cannot carry typed, even for Claude', () => { diff --git a/src/shared/launch-prompt-carry.ts b/src/shared/launch-prompt-carry.ts index 1c98fb5e4f99..a54f386a051a 100644 --- a/src/shared/launch-prompt-carry.ts +++ b/src/shared/launch-prompt-carry.ts @@ -69,6 +69,16 @@ export function launchPromptNeedsPasteRefusal( 'paste the prompt once it opens.' } +/** + * The paste a caller has for a prompt left until the agent is ready: + * - `never`: none, so the line carries the prompt, as main did; + * - `when-host-proves-agent`: #24257's guarded paste, refused where the host cannot prove the agent + * is in front; + * - `once-agent-runs`: the desktop paste main uses for AI buttons and notes sends on every host, + * written once the agent's process owns the terminal. + */ +export type LaunchPromptPaste = 'never' | 'when-host-proves-agent' | 'once-agent-runs' + export type CarriedPlanArgs = { agent: TuiAgent prompt: string @@ -78,9 +88,8 @@ export type CarriedPlanArgs = { launchFile?: LaunchFile /** The host the launch runs on (`describeLaunchHost`). */ host: LaunchHost - /** Whether the caller pastes a prompt left for after the agent is ready. One that cannot still - * starts the agent with the prompt on its line, as main did. */ - canPasteAfterReady: boolean + /** The caller's paste for a prompt the line does not carry. */ + paste: LaunchPromptPaste } export function agentReadsLaunchFile(agent: TuiAgent): boolean { @@ -106,11 +115,11 @@ function windowsLineCarriesExactly(prompt: string, line: string, shell: AgentSta /** * The one carry rule. The prompt rides the agent's line: a host that stages (POSIX, SSH, WSL) * stages it when it is long or multi-line, and a Windows host types it when its shell carries the - * text exactly. It rides a launch file when it is past the argv ceiling, when a Windows line would - * damage it, or past a paired host's typed budget. A launch file goes only to an agent measured - * reading one, on a host that writes it. Otherwise the prompt is pasted once the agent is ready, - * unless the host cannot prove the agent is in front to paste into or the caller has no paste: - * then the line carries it, as main typed it. + * text exactly. When the line cannot (past the argv ceiling, damaged by a Windows shell, past a + * paired host's typed budget), it rides a launch file, which goes only to an agent measured reading + * one, on a host that writes it; on Windows, a caller whose paste main used gets that paste first. + * Failing a file it is pasted once the agent is ready, unless the caller's paste cannot reach the + * agent on this host: then the line carries it, as main typed it. */ export function carryLaunchPrompt( args: A, @@ -135,12 +144,20 @@ export function carryLaunchPrompt | null => { - const pasteIsSafe = args.host.provesAgentInFront && args.canPasteAfterReady - const plan = pasteIsSafe ? null : buildLine(args) + const plan = pasteReachesAgent ? null : buildLine(args) return plan ? { carry: 'on-line', plan } : pasteAfterReady() } const viaLaunchFile = (): LaunchPromptPlan

| null => { + // Why paste first on Windows: main pastes this caller's prompts, so the agent gets the user's + // text; there a pointer would replace nearly every generated (multi-line) prompt. Temporary, + // until the agent's own argv carries multi-line text past cmd without a shim re-parsing it. + if (args.paste === 'once-agent-runs' && args.platform === 'win32') { + return pasteAfterReady() + } // Why: an agent not measured reading the file would stop on an approval or refuse the path. if (args.host.paired || !agentReadsLaunchFile(args.agent)) { return lineOrPaste() diff --git a/src/shared/launch-prompt-plan.test-fixture.ts b/src/shared/launch-prompt-plan.test-fixture.ts index 2a6111527906..fec1f6cbfe10 100644 --- a/src/shared/launch-prompt-plan.test-fixture.ts +++ b/src/shared/launch-prompt-plan.test-fixture.ts @@ -18,8 +18,8 @@ export type PlannedLaunchForTest = AgentStartupPlan & { /** `planLaunchPrompt`, flattened; null where it answers null, or for an empty prompt unless * `allowEmptyPromptLaunch`, as the builder did before the outcome was explicit. */ export function planLaunchForTest( - args: Omit & - Partial> & { + args: Omit & + Partial> & { allowEmptyPromptLaunch?: boolean } ): PlannedLaunchForTest | null { @@ -29,7 +29,7 @@ export function planLaunchForTest( } const planned = planLaunchPrompt({ ...launchArgs, - // A local launch on the platform under test, by a caller that pastes, unless the test says. + // A local launch on the platform under test, with #24257's guarded paste, unless the test says. host: args.host ?? describeLaunchHost({ @@ -38,7 +38,7 @@ export function planLaunchForTest( hostPlatform: args.platform, paired: false }), - canPasteAfterReady: args.canPasteAfterReady ?? true + paste: args.paste ?? 'when-host-proves-agent' }) if (!planned) { return null diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index 23a47218339d..acbb9ec7f760 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -15,6 +15,7 @@ import { carryLaunchPrompt, launchFileDirectoryGrant, windowsShellDamagesPrompt, + type LaunchPromptPaste, type LaunchPromptPlan } from './launch-prompt-carry' import { TUI_AGENT_CONFIG } from './tui-agent-config' @@ -76,7 +77,7 @@ export type AgentLaunchPromptArgs = AgentStartupPlanInputs & { /** See `CarriedPlanArgs`. */ host: LaunchHost /** See `CarriedPlanArgs`. */ - canPasteAfterReady: boolean + paste: LaunchPromptPaste } /** From 3054018722b94529426ffef470591a1475cb04e7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 03:17:09 -0700 Subject: [PATCH 22/42] fix(agent-launch): find a launched agent behind a wrapper that leads its process group A tcsh or nu launch line runs the agent from /bin/sh '