diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index a9e993ee2900..d036a529a5bc 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1206,6 +1206,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 2ae50ded85e4..a85f4412eaf2 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -345,6 +345,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 101297a6cbbb..1508793d153d 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' @@ -258,6 +259,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({ showLaunchPrompt: () => '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..04083b7b8c34 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 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). @@ -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 (no longer kept, as after a restart) a later prompt of the user's own can. + 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 ebc9484071bc..82229db59a28 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 { launchPromptShownForPane } 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 { + 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 new file mode 100644 index 000000000000..2ccfd82ce8bb --- /dev/null +++ b/src/main/agent-hooks/launch-file-prompt-by-pane.ts @@ -0,0 +1,34 @@ +import { opensLikeLaunchFilePointer, 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: the agent's hook reports only the pointer + * to the file, and Orca shows the prompt instead (`launchPromptShownForPane`). + */ +export function rememberLaunchFilePrompt( + paneKey: string, + launchFile: LaunchFile | undefined +): void { + if (!launchFile) { + 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) + } +} + +/** The one place Orca reads a pane's reported prompt or session title: a launch file's pointer, + * whole or cut short, reads as the prompt it points at, when this host kept it. */ +export function launchPromptShownForPane(paneKey: string, prompt: string): string { + return opensLikeLaunchFilePointer(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..0f698cce6973 --- /dev/null +++ b/src/main/agent-hooks/server-launch-file-prompt.test.ts @@ -0,0 +1,26 @@ +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').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' }) + ]) + }) +}) 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/agent-launch-executor.test.ts b/src/main/agent-launch/agent-launch-executor.test.ts index b2c50e820379..974fa0340048 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 755bd55e2767..af38a79ec1f2 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -220,7 +220,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 @@ -257,7 +257,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..5822d340f212 100644 --- a/src/main/agent-launch/agent-launch-prompt-delivery.ts +++ b/src/main/agent-launch/agent-launch-prompt-delivery.ts @@ -5,15 +5,17 @@ * 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. A prompt the rule leaves for the paste (an agent + * not known to read a launch file, or a host that cannot write its staging folder) is pasted, and + * the surface reports that back. */ import type { @@ -38,7 +40,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 +104,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..fb3593c65174 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 `carryLaunchPrompt` leaves it for the paste; `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 `carryLaunchPrompt` + * leaves for the paste; 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/ai-vault/session-title-launch-prompt.ts b/src/main/ai-vault/session-title-launch-prompt.ts new file mode 100644 index 000000000000..1eb550e5a167 --- /dev/null +++ b/src/main/ai-vault/session-title-launch-prompt.ts @@ -0,0 +1,39 @@ +import type { + AiVaultSessionTitleRequest, + AiVaultSessionTitlesResult +} from '../../shared/ai-vault-session-title' +import { launchPromptShownForPane } from '../agent-hooks/launch-file-prompt-by-pane' +import { normalizeTitleText } from './session-scanner-text-normalization' + +/** + * Titles a launch file's session with the prompt it carried. Its transcript opens with Orca's + * pointer, so the tab label and agent row would otherwise show "The full task is in the file …". + */ +export function showLaunchPromptsInSessionTitles( + requests: readonly AiVaultSessionTitleRequest[], + result: AiVaultSessionTitlesResult +): AiVaultSessionTitlesResult { + const paneBySession = new Map( + requests.flatMap((request) => + request.paneKey ? [[`${request.agent}\0${request.sessionId}`, request.paneKey] as const] : [] + ) + ) + if (paneBySession.size === 0) { + return result + } + return { + titles: result.titles.map((title) => { + const paneKey = paneBySession.get(`${title.agent}\0${title.sessionId}`) + const shown = paneKey ? launchPromptShownForPane(paneKey, title.title) : title.title + return shown === title.title + ? title + : { ...title, title: normalizeTitleText(shown) ?? title.title } + }) + } +} + +/** A request as another host receives it: the pane is this host's to read. */ +export function withoutPaneKey(request: AiVaultSessionTitleRequest): AiVaultSessionTitleRequest { + const { paneKey: _paneKey, ...forwarded } = request + return forwarded +} 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..96e73f2b0cc1 --- /dev/null +++ b/src/main/daemon/daemon-create-or-attach-request.ts @@ -0,0 +1,56 @@ +import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' +import type { TuiAgent } from '../../shared/tui-agent' +import type { LaunchFile, UnstageableLine } 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 + /** v42+ (`LAUNCH_FILE_DAEMON_PROTOCOL_VERSION`): written by the daemon before it types `command`. */ + launchFile?: LaunchFile + unstageableLine?: UnstageableLine + /** 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-foreground-confirmation-protocol.test.ts b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts index c712d0e8c9dd..59ebe7e48ce5 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(41) + expect(PROTOCOL_VERSION).toBe(42) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23) @@ -23,5 +23,7 @@ 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) + expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(41) }) }) 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..925dc0193cf4 --- /dev/null +++ b/src/main/daemon/daemon-launch-file-protocol.test.ts @@ -0,0 +1,80 @@ +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: only a session an older daemon still runs routes to it, so the spawn attaches; it must never + // type a line naming a file that daemon would not write, and it must not refuse the attach. + it('is withheld, with its command, from a daemon older than launch files', async () => { + vi.spyOn(DaemonClient.prototype, 'ensureConnected').mockResolvedValue() + const request = vi + .spyOn(DaemonClient.prototype, 'request') + .mockResolvedValue({ isNew: false, snapshot: null, pid: 1 }) + const legacy = new DaemonPtyAdapter({ + socketPath, + tokenPath, + protocolVersion: LAUNCH_FILE_DAEMON_PROTOCOL_VERSION - 1 + }) + const { prompt, launchFile } = carryInLaunchFile('the whole task') + try { + await legacy + .spawn({ + sessionId: 'legacy-launch', + cols: 80, + rows: 24, + command: `claude '${prompt}'`, + launchAgent: 'claude', + launchFile + }) + .catch(() => undefined) + const createCall = request.mock.calls.find(([method]) => method === 'createOrAttach') + expect(createCall?.[1]).toMatchObject({ sessionId: 'legacy-launch' }) + expect(createCall?.[1]).not.toHaveProperty('launchFile') + expect(createCall?.[1]).toMatchObject({ command: undefined }) + } 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') + 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.ts b/src/main/daemon/daemon-protocol-version.ts index 68eae86f58b2..d1604534d48d 100644 --- a/src/main/daemon/daemon-protocol-version.ts +++ b/src/main/daemon/daemon-protocol-version.ts @@ -1,7 +1,9 @@ // Why: daemons survive app updates, so wire behavior must be version-gated. -// v41 stages long startup commands as sourced scripts; v40 rolled the #25130/#24636 shell-wrapper -// changes and the wider agent list into a fresh daemon; older owners stay attachable. -export const PROTOCOL_VERSION = 41 +// v42 writes an agent's launch file before it types the line naming it; v41 stages long startup +// commands as sourced scripts; older owners stay attachable. +export const PROTOCOL_VERSION = 42 +// Why: an older daemon drops `launchFile` and would type a line naming a file nobody wrote. +export const LAUNCH_FILE_DAEMON_PROTOCOL_VERSION = 42 // v39 gives plain fish panes Orca's codex function through XDG_DATA_DIRS. export const CODEX_FISH_SHELL_FUNCTION_DAEMON_PROTOCOL_VERSION = 39 // Why: older daemons reject `setColorQueryReplyColors` as an unknown request type. @@ -36,7 +38,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, 40 + 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41 ] 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 388919dd2e30..7f3ba9503053 100644 --- a/src/main/daemon/daemon-pty-session-spawn.ts +++ b/src/main/daemon/daemon-pty-session-spawn.ts @@ -18,6 +18,8 @@ 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 { noteLaunchArtifactRefusal } from '../providers/local-launch-artifact-directory' +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' @@ -184,6 +186,12 @@ export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult { } await this.ensureConnected() + 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 } + } // 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. @@ -251,7 +259,13 @@ export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult { detectColdRestore } activeSpawnContext = context - const result = await this.createOrAttachSpawn(context, context.historySeedSegments) + let result + try { + result = await this.createOrAttachSpawn(context, context.historySeedSegments) + } catch (error) { + noteLaunchArtifactRefusal({ wslDistro, wslLaunchDirectory }, error) + throw error + } if (result.isNew && !attachOnly) { // Not awaited: the app-side read behind it can sit on an unanswered macOS folder prompt. void reportDaemonPtyCwdVerdict({ diff --git a/src/main/daemon/daemon-pty-spawn-request.ts b/src/main/daemon/daemon-pty-spawn-request.ts index 1da17c63da95..4189ea457c7f 100644 --- a/src/main/daemon/daemon-pty-spawn-request.ts +++ b/src/main/daemon/daemon-pty-spawn-request.ts @@ -7,7 +7,10 @@ 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 type { ColdRestoreInfo } from './history-reader' import { NdjsonLineTooLongError } from './ndjson' import { @@ -106,6 +109,11 @@ export abstract class DaemonPtySpawnRequest extends DaemonPtyRuntimeState { if (opts.signal?.aborted) { throw new Error('client_disconnected') } + // Why no command: only a session an older daemon still runs routes here, so this attaches and + // types nothing; were it gone, a line naming a file that daemon never writes must not run. + const launches = + !context.attachOnly && + !(opts.launchFile && this.protocolVersion < LAUNCH_FILE_DAEMON_PROTOCOL_VERSION) const payload = { sessionId: context.sessionId, cols: context.effectiveCols, @@ -113,8 +121,14 @@ export abstract class DaemonPtySpawnRequest extends DaemonPtyRuntimeState { cwd: context.attachOnly ? undefined : context.effectiveCwd, env: context.attachOnly ? undefined : opts.env, envToDelete: context.attachOnly ? undefined : opts.envToDelete, - command: context.attachOnly ? undefined : opts.command, - startupCommandDelivery: context.attachOnly ? undefined : opts.startupCommandDelivery, + command: launches ? opts.command : undefined, + startupCommandDelivery: launches ? opts.startupCommandDelivery : undefined, + ...(launches && opts.launchFile ? { launchFile: opts.launchFile } : {}), + // Optional: a daemon that predates it ignores it and types the line, as before. + ...(launches && opts.unstageableLine ? { unstageableLine: opts.unstageableLine } : {}), + ...(!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 6691ca0a5143..1662728d7be7 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, carryInLaunchFile } 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 } = carryInLaunchFile('secret brief') + 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)}'\r` + ) + 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 f02cbdb9205c..4f678066b83d 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, parseUnstageableLine } from '../../shared/launch-prompt-file' +import { parseWslLaunchDirectory } from '../../shared/wsl-launch-directory' import { isAgentSessionExecutionClaim, isAgentSessionSurfaceBinding @@ -102,6 +104,9 @@ export class DaemonTerminalAdmission { envToDelete: payload.envToDelete, command: payload.command, startupCommandDelivery: payload.startupCommandDelivery, + launchFile: parseLaunchFile(payload.launchFile), + unstageableLine: parseUnstageableLine(payload.unstageableLine), + wslLaunchDirectory: parseWslLaunchDirectory(payload.wslLaunchDirectory), ...(attachOnly ? { attachOnly: true } : {}), ...(isTuiAgent(payload.launchAgent) ? { launchAgent: payload.launchAgent } : {}), shellOverride: payload.shellOverride, diff --git a/src/main/daemon/daemon-unstageable-line.test.ts b/src/main/daemon/daemon-unstageable-line.test.ts new file mode 100644 index 000000000000..30a335c343c6 --- /dev/null +++ b/src/main/daemon/daemon-unstageable-line.test.ts @@ -0,0 +1,54 @@ +import './mock-descendant-sweep' +import { join } from 'node:path' +import { rmSync } from 'node:fs' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { DaemonPtyAdapter } from './daemon-pty-adapter' +import type { DaemonServer } from './daemon-server' +import { createMockSubprocess, startDaemonAdapterHarness } from './daemon-pty-adapter-test-harness' + +const describePosix = process.platform === 'win32' ? describe.skip : describe + +// Why (stack QA 2a follow-up): with this machine's temp folder unusable, an AI button's or a note's +// long prompt was typed raw where main pasted it, and could be lost; those callers ask to refuse. +describePosix('an agent line the daemon cannot stage, sent over the daemon wire', () => { + let dir: string + let server: DaemonServer + let adapter: DaemonPtyAdapter + let sub: ReturnType + const command = `claude 'one\ntwo'` + + beforeEach(async () => { + sub = { ...createMockSubprocess(), shellPath: '/bin/zsh' } + ;({ dir, server, adapter } = await startDaemonAdapterHarness(() => sub)) + // After the harness, so only staging meets the missing folder. + vi.stubEnv('TMPDIR', join(dir, 'missing')) + }) + + afterEach(async () => { + vi.unstubAllEnvs() + adapter?.dispose() + await server?.shutdown() + rmSync(dir, { recursive: true, force: true }) + }) + + it('is refused with the prompt’s reason when the caller asks to refuse it', async () => { + await expect( + adapter.spawn({ + cols: 80, + rows: 24, + command, + launchAgent: 'claude', + unstageableLine: 'refuse' + }) + ).rejects.toThrow(/launch_file_unavailable/) + expect(sub.write).not.toHaveBeenCalled() + }) + + // The same as a daemon that predates the optional field, which never reads it. The line waits + // for the shell's ready marker, which this mock never prints, so the session is the evidence. + it('is created to type the line as is without the field', async () => { + await expect( + adapter.spawn({ cols: 80, rows: 24, command, launchAgent: 'claude' }) + ).resolves.toMatchObject({ id: expect.any(String) }) + }) +}) diff --git a/src/main/daemon/terminal-host-create-contract.ts b/src/main/daemon/terminal-host-create-contract.ts index 9f6e6fbec1b1..ffb5c3c5c5ff 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, UnstageableLine } from '../../shared/launch-prompt-file' +import type { WslLaunchDirectory } from '../../shared/wsl-launch-directory' export type CreateOrAttachOptions = { sessionId: string @@ -19,6 +21,9 @@ export type CreateOrAttachOptions = { envToDelete?: string[] command?: string startupCommandDelivery?: StartupCommandDelivery + launchFile?: LaunchFile + unstageableLine?: UnstageableLine + 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 9b10ec036021..4e8a4654c98e 100644 --- a/src/main/daemon/terminal-host-session-create.ts +++ b/src/main/daemon/terminal-host-session-create.ts @@ -6,7 +6,13 @@ import { type StartupCommandStaging } from '../../shared/startup-command-staging' import { resolvePtyOwnerBackend } from '../../shared/pty-owner-backend' +import { + LaunchFileUnavailableError, + removeLaunchFile, + writeSpawnLaunchFile +} from '../../shared/launch-file-writing' import { getDaemonSessionResultMetadata } from './daemon-create-or-attach-result' +import { unstagedAgentLineRefusal } from './unstaged-agent-line-refusal' import { enumerateDirectoryOnce } from './directory-enumeration-probe' import { normalizePtySize } from './daemon-pty-size' import { Session } from './session' @@ -119,23 +125,39 @@ 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, + 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 +186,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 +201,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) @@ -184,32 +210,55 @@ async function spawnAndPublishSession( throw new TerminalAttachCanceledError(opts.sessionId) } + const startupCommandWritten = Boolean(command) && !subprocess.startupCommandDeliveredInShellArgs + if (startupCommandWritten && command) { + staging = stageStartupCommand({ + command, + shellPath: subprocess.shellPath, + orcaBuiltLine: opts.launchAgent !== undefined, + wslDirectory + }) + // Why refuse: typed in full, a long agent line can leave the shell at a quote prompt with the + // prompt lost while the launch reports success; the refusal hands the user the prompt instead. + const refusal = unstagedAgentLineRefusal({ + command, + staging, + agentLaunch: opts.launchAgent !== undefined, + unstageableLine: opts.unstageableLine, + wslWithoutFolder: wslDistro !== undefined && wslDirectory === undefined + }) + if (refusal !== null) { + 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) + deps.onDeadSessionRemoved(opts.sessionId) + } + throw new LaunchFileUnavailableError(refusal, 'staged-line') + } + } + deps.sessions.set(opts.sessionId, session) deps.onSessionCreated(opts.sessionId, opts.agentSessionGeneration, session.isAlive) const token = session.attachClient(opts.streamClient) - const startupCommandWritten = - Boolean(opts.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) { - staging = stageStartupCommand({ - command: opts.command, - shellPath: subprocess.shellPath, - orcaBuiltLine: opts.launchAgent !== undefined - }) + if (staging) { const notice = startupStagingFailureNotice(staging) if (notice) { session.startupIngress.accept(notice) diff --git a/src/main/daemon/terminal-host-startup-staging.test.ts b/src/main/daemon/terminal-host-startup-staging.test.ts index 3b7f596388e2..f3c45379140e 100644 --- a/src/main/daemon/terminal-host-startup-staging.test.ts +++ b/src/main/daemon/terminal-host-startup-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, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -99,6 +99,54 @@ describePosix('daemon startup command staging', () => { expect(sub.write).not.toHaveBeenCalled() }) + // Why (stack QA P8-2): typed in full, a long agent line left the shell at a quote prompt, the + // prompt lost while agent.launch reported success; refusing hands the user the prompt instead. + // A temp folder that looks writable and still fails the write is that race. + it('refuses an agent launch whose staging write failed in a usable-looking folder', async () => { + const notAFolder = join(tempDir, 'file') + writeFileSync(notAFolder, '') + vi.stubEnv('TMPDIR', notAFolder) + const sub = mockSubprocess('/bin/zsh') + vi.mocked(sub.forceKill).mockImplementation(() => exitSubprocess?.(1)) + vi.mocked(sub.kill).mockImplementation(() => exitSubprocess?.(1)) + const host = new TerminalHost({ spawnSubprocess: () => sub }) + await expect( + host.createOrAttach({ + sessionId: 's-agent-stage-failed', + cols: 80, + rows: 24, + command: `claude '${'x'.repeat(600)}'`, + launchAgent: 'claude', + shellReadySupported: false, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + ).rejects.toThrow(/launch_file_unavailable/) + expect(sub.write).not.toHaveBeenCalled() + expect(host.listSessions().map((session) => session.sessionId)).not.toContain( + 's-agent-stage-failed' + ) + }) + + // Why (stack QA 2a): main's delivery for an unusable folder is the line typed as is; refusing it + // made a 5-line prompt worse than main. + it('types an agent line as is when the temp folder is unusable', async () => { + vi.stubEnv('TMPDIR', join(tempDir, 'missing')) + const command = `claude 'one\ntwo'` + const sub = mockSubprocess('/bin/zsh') + const host = new TerminalHost({ spawnSubprocess: () => sub }) + const result = await host.createOrAttach({ + sessionId: 's-agent-folder-unusable', + cols: 80, + rows: 24, + command, + launchAgent: 'claude', + shellReadySupported: false, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + expect(result.isNew).toBe(true) + expect(sub.write).toHaveBeenCalledWith(`claude 'one\rtwo'\r`) + }) + 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)}'` 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..5df111eb93ff --- /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, carryInLaunchFile } 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 } = carryInLaunchFile('secret brief') + 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-\d+-\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/daemon/unstaged-agent-line-refusal.ts b/src/main/daemon/unstaged-agent-line-refusal.ts new file mode 100644 index 000000000000..47f92adf2640 --- /dev/null +++ b/src/main/daemon/unstaged-agent-line-refusal.ts @@ -0,0 +1,36 @@ +import type { UnstageableLine } from '../../shared/launch-prompt-file' +import { + startupLineNeedsStaging, + type StartupCommandStaging +} from '../../shared/startup-command-staging' + +/** + * Why the daemon refuses an agent line it did not stage, or null to type it as is. A write that + * failed in a folder that looked usable is a race, refused for every caller. A folder already + * unusable gets the line its plan chose, typed, unless the caller asked to refuse it + * (`UnstageableLine`); a WSL spawn with no distro folder never tried to stage. + */ +export function unstagedAgentLineRefusal(args: { + command: string + staging: StartupCommandStaging + agentLaunch: boolean + unstageableLine: UnstageableLine | undefined + wslWithoutFolder: boolean +}): string | null { + const { staging } = args + if (!args.agentLaunch) { + return null + } + if (staging.failure !== undefined && staging.folderUnusable !== true) { + return staging.failure + } + if (args.unstageableLine !== 'refuse') { + return null + } + if (staging.failure !== undefined) { + return staging.failure + } + return args.wslWithoutFolder && startupLineNeedsStaging(args.command) + ? "the WSL distro's home directory could not be reached" + : null +} diff --git a/src/main/ipc/ai-vault-session-title-routing.test.ts b/src/main/ipc/ai-vault-session-title-routing.test.ts new file mode 100644 index 000000000000..3747d052545a --- /dev/null +++ b/src/main/ipc/ai-vault-session-title-routing.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resolveLocalAiVaultSessionTitles } from '../ai-vault/session-title-resolver' +import { rememberLaunchFilePrompt } from '../agent-hooks/launch-file-prompt-by-pane' +import { carryInLaunchFile, buildLaunchFilePointer } from '../../shared/launch-prompt-file' +import { resolveAiVaultSessionTitlesByHost } from './ai-vault-session-title-routing' +import { requestActiveSshAiVaultSessionTitles } from './ssh' + +vi.mock('../ai-vault/session-title-resolver', () => ({ + resolveLocalAiVaultSessionTitles: vi.fn() +})) +vi.mock('./ssh', () => ({ requestActiveSshAiVaultSessionTitles: vi.fn() })) + +const PANE = 'tab-title:11111111-1111-4111-8111-111111111111' +// The transcript's first message is the pointer; its title is cut to 96 characters. +const POINTER_TITLE = buildLaunchFilePointer( + '/Users/ada/Library/Caches/orca-launch-file-1-a/task-context.md' +).slice(0, 96) + +// Why: a launch file's agent opens its transcript with Orca's pointer, and the tab label and the +// sidebar agent row read the session title from it (stack QA 1.2). +describe('a session title for a launch file’s agent', () => { + beforeEach(() => { + rememberLaunchFilePrompt(PANE, carryInLaunchFile('QA-STACK fix the flaky test').launchFile) + }) + + it('shows the prompt the launch file carried, not the pointer', async () => { + vi.mocked(resolveLocalAiVaultSessionTitles).mockResolvedValue({ + titles: [{ agent: 'claude', sessionId: 's-1', title: POINTER_TITLE }] + }) + const result = await resolveAiVaultSessionTitlesByHost({ + requests: [{ agent: 'claude', sessionId: 's-1', paneKey: PANE }] + }) + expect(result.titles).toEqual([ + { agent: 'claude', sessionId: 's-1', title: 'QA-STACK fix the flaky test' } + ]) + }) + + it('leaves another pane’s title, and a request without a pane, as the transcript has it', async () => { + vi.mocked(resolveLocalAiVaultSessionTitles).mockResolvedValue({ + titles: [ + { agent: 'claude', sessionId: 's-2', title: POINTER_TITLE }, + { agent: 'codex', sessionId: 's-3', title: 'fix the build' } + ] + }) + const result = await resolveAiVaultSessionTitlesByHost({ + requests: [ + { agent: 'claude', sessionId: 's-2' }, + { agent: 'codex', sessionId: 's-3', paneKey: PANE } + ] + }) + expect(result.titles.map((title) => title.title)).toEqual([POINTER_TITLE, 'fix the build']) + }) + + it('maps an SSH session’s title here, never sending the relay this host’s pane', async () => { + vi.mocked(requestActiveSshAiVaultSessionTitles).mockResolvedValue({ + titles: [{ agent: 'claude', sessionId: 's-4', title: POINTER_TITLE }] + }) + const result = await resolveAiVaultSessionTitlesByHost({ + executionHostScope: 'ssh:dev-box', + requests: [{ agent: 'claude', sessionId: 's-4', paneKey: PANE }] + }) + expect(requestActiveSshAiVaultSessionTitles).toHaveBeenCalledWith('dev-box', { + requests: [{ agent: 'claude', sessionId: 's-4' }] + }) + expect(result.titles[0]?.title).toBe('QA-STACK fix the flaky test') + }) +}) diff --git a/src/main/ipc/ai-vault-session-title-routing.ts b/src/main/ipc/ai-vault-session-title-routing.ts index cca3bafd8936..698dbb949201 100644 --- a/src/main/ipc/ai-vault-session-title-routing.ts +++ b/src/main/ipc/ai-vault-session-title-routing.ts @@ -10,6 +10,10 @@ import { import { resolveLocalAiVaultSessionTitles } from '../ai-vault/session-title-resolver' import { parseAiVaultSessionTitlesResult } from '../ai-vault/session-title-result-validation' import { requestActiveSshAiVaultSessionTitles } from './ssh' +import { + showLaunchPromptsInSessionTitles, + withoutPaneKey +} from '../ai-vault/session-title-launch-prompt' export type RuntimeAiVaultSessionTitleResolver = ( environmentId: string, @@ -19,6 +23,17 @@ export type RuntimeAiVaultSessionTitleResolver = ( export async function resolveAiVaultSessionTitlesByHost( args: AiVaultSessionTitlesArgs, resolveRuntime?: RuntimeAiVaultSessionTitleResolver +): Promise { + // Why here: this host spawned the panes it shows, local and SSH alike, so it kept their prompts. + return showLaunchPromptsInSessionTitles( + args.requests, + await resolveTitlesOnHost(args, resolveRuntime) + ) +} + +async function resolveTitlesOnHost( + args: AiVaultSessionTitlesArgs, + resolveRuntime?: RuntimeAiVaultSessionTitleResolver ): Promise { const executionHostScope = requestedExecutionHostScope(args.executionHostScope) if (executionHostScope === LOCAL_EXECUTION_HOST_ID) { @@ -28,7 +43,7 @@ export async function resolveAiVaultSessionTitlesByHost( if (parsed?.kind === 'ssh') { try { const result = await requestActiveSshAiVaultSessionTitles(parsed.targetId, { - requests: args.requests + requests: args.requests.map(withoutPaneKey) }) return result === null ? { titles: [] } : parseAiVaultSessionTitlesResult(result) } catch { 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..f5512ad66bae --- /dev/null +++ b/src/main/ipc/pty/ipc/launched-agent-foreground.ts @@ -0,0 +1,20 @@ +import { getPtyIpc } from '../../pty-host-bindings' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import type { TerminalForegroundVerdict } from '../../../runtime/terminal-foreground-group' + +/** The renderer's launch-prompt receipt asks #24257's crash-guard question, keeping the launched + * agent named on its command line apart from any other process that is not the shell. */ +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.readTerminalForegroundVerdict(args.id, args.agent) + } + ) +} 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 new file mode 100644 index 000000000000..e2cdb6a52f6d --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-options-launch-file.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../../shared/constants' +import { buildPtyIpcSpawnOptions } from './spawn-options' +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' + +const LAUNCH_FILE = { + placeholder: `orca-launch-file-${'a'.repeat(32)}`, + content: 'the whole task' +} + +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; Orca shows the prompt it points at instead. + it('shows the prompt a launch file carries for its pane', async () => { + await spawnOptionsFor( + { cols: 80, rows: 24, command, launchFile: LAUNCH_FILE }, + command, + 'tab-a:leaf-a' + ) + 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') + }) +}) + +describe('renderer pty spawn: a line the host cannot stage', () => { + const command = "claude 'a long prompt'" + + it('keeps the caller’s wish with its own command, and drops it for another line or value', async () => { + const refuse = { cols: 80, rows: 24, command, unstageableLine: 'refuse' } + expect((await spawnOptionsFor(refuse, command)).unstageableLine).toBe('refuse') + expect((await spawnOptionsFor(refuse, 'codex resume abc')).unstageableLine).toBeUndefined() + expect( + (await spawnOptionsFor({ ...refuse, unstageableLine: 'paste' }, command)).unstageableLine + ).toBeUndefined() + }) +}) diff --git a/src/main/ipc/pty/ipc/spawn-options.ts b/src/main/ipc/pty/ipc/spawn-options.ts index 8adec688a678..face70b9b34f 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, parseUnstageableLine } from '../../../../shared/launch-prompt-file' import { applyAgentWorkspaceTrustToSpawn } from '../../../agent-workspace-trust-spawn' import { prepareOpenCodePtyLaunch } from '../../../opencode/opencode-pty-launch' @@ -115,6 +117,24 @@ 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 + const unstageableLine = + ctx.launchCommand !== undefined && ctx.launchCommand === args.command + ? parseUnstageableLine(args.unstageableLine) + : undefined + if (unstageableLine) { + ctx.spawnOptions.unstageableLine = unstageableLine + } + 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 8e91799c4296..5e4d9225c06e 100644 --- a/src/main/ipc/pty/ipc/spawn-types.ts +++ b/src/main/ipc/pty/ipc/spawn-types.ts @@ -36,6 +36,9 @@ export type PtySpawnIpcArgs = { launchToken?: unknown launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery + /** Unvalidated here; spawn options keep it only when it parses. */ + launchFile?: unknown + unstageableLine?: unknown connectionId?: string | null worktreeId?: string sessionId?: string diff --git a/src/main/ipc/pty/ipc/spawn.ts b/src/main/ipc/pty/ipc/spawn.ts index 6fd5183a36d8..dcd8a89db733 100644 --- a/src/main/ipc/pty/ipc/spawn.ts +++ b/src/main/ipc/pty/ipc/spawn.ts @@ -1,6 +1,11 @@ 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 { + noteTerminalPaneSpawn, + recordTerminalLaunchRefusal +} from '../../../runtime/terminal-launch-refusals' export function installPtySpawnIpcHandler(deps: PtySpawnIpcDeps): void { const ipcMain = getPtyIpc() @@ -11,6 +16,19 @@ export function installPtySpawnIpcHandler(deps: PtySpawnIpcDeps): void { if (startupPromise) { await startupPromise } - return runPtyIpcSpawn(deps, args) + // 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) { + 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 + } 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/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index 26c3856b45ee..08244af71188 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -138,6 +138,7 @@ export async function buildRuntimePtySpawnOptions( if (args.startupCommandDelivery !== undefined) { ctx.spawnOptions.startupCommandDelivery = args.startupCommandDelivery } + Object.assign(ctx.spawnOptions, args.launchFile ? { 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/qoder-independent-renderer-spawn-review.test.ts b/src/main/ipc/qoder-independent-renderer-spawn-review.test.ts index a9c20cc9ad3e..55953965804c 100644 --- a/src/main/ipc/qoder-independent-renderer-spawn-review.test.ts +++ b/src/main/ipc/qoder-independent-renderer-spawn-review.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it, vi } from 'vitest' +import { planLaunchForTest } from '../../shared/launch-prompt-plan.test-fixture' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { createDaemonActiveProviderFixtures } from './pty-ipc-daemon-provider-fixtures' import { registerPtyHandlers } from './pty' import { detectAgentCommandsOnHost } from '../preflight/agent-detection' -import { buildAgentStartupPlan, buildAgentResumeStartupPlan } from '../../shared/tui-agent-startup' +import { buildAgentResumeStartupPlan } from '../../shared/tui-agent-startup' import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry' import { trackMock } from './pty-ipc-mock-registry' @@ -62,7 +63,7 @@ describe('independent renderer Qoder provider spawn boundary', () => { async (mode) => { const plan = mode === 'start' - ? buildAgentStartupPlan({ + ? planLaunchForTest({ agent: 'qoder', prompt: 'review prompt', agentArgs: null, diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 902f9203b55b..202b88a952aa 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -174,6 +174,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-removal-table' +import { parseLaunchFile } from '../../shared/launch-prompt-file' const SSH_WORKTREE_CREATE_FETCH_FRESHNESS_MS = 30_000 const SSH_WORKTREE_CREATE_FETCH_CACHE_MAX = 512 @@ -414,6 +415,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}`, { @@ -424,6 +427,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/opencode/opencode-model-startup-plan.test.ts b/src/main/opencode/opencode-model-startup-plan.test.ts index 46771e630bc8..d0ef0aa1d755 100644 --- a/src/main/opencode/opencode-model-startup-plan.test.ts +++ b/src/main/opencode/opencode-model-startup-plan.test.ts @@ -7,7 +7,8 @@ import { resolveOpenCodeDirectModelExecutable } from './opencode-model-availability' import { probeOpenCodeLaunchModelContext } from './opencode-launch-model-context' -import { buildExecutionHostAgentStartupPlan } from './opencode-model-startup-plan' +import { planExecutionHostLaunchPrompt } from './opencode-model-startup-plan' +import { describeLaunchHost } from '../../shared/launch-host' vi.mock('../managed-data-accounts/launch-environment', () => ({ applyManagedDataAccountEnvironment: vi.fn() @@ -43,6 +44,23 @@ function scope() { } } +/** The plan a local launch runs, with the prompt on its line. */ +async function buildExecutionHostAgentStartupPlan( + options: ReturnType & { isWsl?: boolean } +) { + const planned = await planExecutionHostLaunchPrompt({ + ...options, + host: describeLaunchHost({ + launchPlatform: process.platform, + isRemote: false, + hostPlatform: process.platform, + paired: false + }), + paste: 'never' + }) + return planned && (planned.carry === 'paste-after-ready' ? planned.cleanPlan : planned.plan) +} + describe('execution-host OpenCode model startup', () => { beforeEach(() => { vi.resetAllMocks() diff --git a/src/main/opencode/opencode-model-startup-plan.ts b/src/main/opencode/opencode-model-startup-plan.ts index 84b0599ce882..8fab026c164b 100644 --- a/src/main/opencode/opencode-model-startup-plan.ts +++ b/src/main/opencode/opencode-model-startup-plan.ts @@ -1,10 +1,12 @@ import type { AgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' import { buildAgentDraftLaunchPlan, - buildAgentStartupPlan, + planLaunchPrompt, type AgentStartupPlan } from '../../shared/tui-agent-startup' -import { planStartupWithPromptCandidate } from '../../shared/startup-line-prompt-carry' +import type { LaunchPromptPaste, LaunchPromptPlan } from '../../shared/launch-prompt-carry' +import type { LaunchFile } from '../../shared/launch-prompt-file' +import type { LaunchHost } from '../../shared/launch-host' import { buildSleepingAgentLaunchConfig } from '../../shared/sleeping-agent-launch-config' import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' import type { TuiAgent } from '../../shared/tui-agent' @@ -142,45 +144,38 @@ export async function prepareOpenCodeModelStartupInputs( } } -export async function buildExecutionHostAgentStartupPlan( - options: StartupScope & { - prompt: string - promptDelivery?: 'auto-submit' | 'draft' - } -) { +/** A draft launch on the execution host; a one-time model preference cannot ride a draft. */ +export async function buildExecutionHostDraftLaunchPlan(options: StartupScope & { draft: string }) { const prepared = await prepareOpenCodeModelStartupInputs(options) - if (prepared.launchConfig && options.promptDelivery === 'draft') { + if (prepared.launchConfig) { refuseModel() } - const plan = - options.promptDelivery === 'draft' - ? buildAgentDraftLaunchPlan({ ...prepared.inputs, draft: options.prompt }) - : buildAgentStartupPlan({ - ...prepared.inputs, - prompt: options.prompt, - allowEmptyPromptLaunch: true - }) - if (plan && prepared.launchConfig) { - plan.launchConfig = prepared.launchConfig - plan.sessionOptions = { ...options.inputs.sessionOptions } - } - return plan + return buildAgentDraftLaunchPlan({ ...prepared.inputs, draft: options.draft }) } -/** `buildExecutionHostAgentStartupPlan` for a caller that delivers an uncarried prompt itself. */ -export async function planExecutionHostStartupWithPromptCandidate( +/** `planLaunchPrompt` on the execution host, after OpenCode's one-time model preparation. */ +export async function planExecutionHostLaunchPrompt( options: StartupScope & { prompt: string - host: { shellName?: string; provesAgentInFront: boolean } + launchFile?: LaunchFile + host: LaunchHost + paste: LaunchPromptPaste } -): Promise<{ plan: AgentStartupPlan | null; promptCarried: boolean }> { +): Promise | null> { const prepared = await prepareOpenCodeModelStartupInputs(options) - const offered = planStartupWithPromptCandidate(prepared.inputs, options.prompt, options.host) - if (offered.plan && prepared.launchConfig) { - offered.plan.launchConfig = prepared.launchConfig - offered.plan.sessionOptions = { ...options.inputs.sessionOptions } + const planned = planLaunchPrompt({ + ...prepared.inputs, + prompt: options.prompt, + ...(options.launchFile ? { launchFile: options.launchFile } : {}), + host: options.host, + paste: options.paste + }) + if (planned && prepared.launchConfig) { + const plan = planned.carry === 'paste-after-ready' ? planned.cleanPlan : planned.plan + plan.launchConfig = prepared.launchConfig + plan.sessionOptions = { ...options.inputs.sessionOptions } } - return offered + return planned } export function assertOpenCodeModelLaunchPreferencesAbsent( diff --git a/src/main/providers/local-launch-artifact-directory.test.ts b/src/main/providers/local-launch-artifact-directory.test.ts new file mode 100644 index 000000000000..b65465fa1b4a --- /dev/null +++ b/src/main/providers/local-launch-artifact-directory.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + localLaunchArtifactsWritable, + noteLaunchArtifactRefusal +} from './local-launch-artifact-directory' +import { WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS } from './wsl-launch-directory-resolution' + +vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: vi.fn() })) + +const REFUSAL = new Error( + 'Orca could not write it (ENOSPC), so the agent was not started. [launch_file_unavailable]' +) + +// Why: a host that cannot write its temp folder neither stages a line nor writes a launch file, so +// the next launch is planned without either rather than refused again. +describe('whether this machine can write its staging folder', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('leaves this machine’s folder alone when the refused write was into a WSL distro', () => { + noteLaunchArtifactRefusal({ wslDistro: 'Ubuntu', wslLaunchDirectory: undefined }, REFUSAL) + expect(localLaunchArtifactsWritable()).toBe(true) + }) + + it('is yes until a write there is refused, then again after the failure TTL', () => { + vi.useFakeTimers() + expect(localLaunchArtifactsWritable()).toBe(true) + noteLaunchArtifactRefusal({ wslDistro: null, wslLaunchDirectory: undefined }, new Error('EIO')) + expect(localLaunchArtifactsWritable()).toBe(true) + noteLaunchArtifactRefusal({ wslDistro: null, wslLaunchDirectory: undefined }, REFUSAL) + expect(localLaunchArtifactsWritable()).toBe(false) + vi.advanceTimersByTime(WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS) + expect(localLaunchArtifactsWritable()).toBe(true) + }) +}) diff --git a/src/main/providers/local-launch-artifact-directory.ts b/src/main/providers/local-launch-artifact-directory.ts new file mode 100644 index 000000000000..e4d54a04fabf --- /dev/null +++ b/src/main/providers/local-launch-artifact-directory.ts @@ -0,0 +1,40 @@ +import { accessSync, constants } from 'node:fs' +import { tmpdir } from 'node:os' +import { isLaunchFileRefusal } from '../../shared/launch-prompt-file' +import type { WslLaunchDirectory } from '../../shared/wsl-launch-directory' +import { + noteWslLaunchDirectoryRefusal, + WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS +} from './wsl-launch-directory-resolution' + +let refusedAt: number | null = null + +/** Whether this machine can write its temp folder, where staged lines and launch files go. A write + * refused there counts for the failure TTL, then the folder is checked again. */ +export function localLaunchArtifactsWritable(now = Date.now()): boolean { + if (refusedAt !== null && now - refusedAt < WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS) { + return false + } + try { + accessSync(tmpdir(), constants.W_OK) + return true + } catch { + return false + } +} + +/** A spawn refused for its staged line or launch file: the folder it wrote to counts as broken, + * so the next launch is planned without writing there. */ +export function noteLaunchArtifactRefusal( + spawn: { + wslDistro: string | null | undefined + wslLaunchDirectory: WslLaunchDirectory | undefined + }, + error: unknown +): void { + if (spawn.wslDistro) { + noteWslLaunchDirectoryRefusal(spawn.wslLaunchDirectory, error) + } else if (isLaunchFileRefusal(error)) { + refusedAt = Date.now() + } +} 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 13851cc40209..8fe115ee5549 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, carryInLaunchFile } 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 } = carryInLaunchFile('secret brief') + + 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]))}'\r` + ) + } 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..528d6d6ab2f2 --- /dev/null +++ b/src/main/providers/local-pty-spawn-wsl-shutdown-order.test.ts @@ -0,0 +1,66 @@ +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 { resolveSpawnWslLaunchDirectory, reattachLocalPty } = vi.hoisted(() => ({ + resolveSpawnWslLaunchDirectory: vi.fn(), + reattachLocalPty: vi.fn() +})) +vi.mock('./wsl-launch-directory-resolution', () => ({ resolveSpawnWslLaunchDirectory })) +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 + resolveSpawnWslLaunchDirectory.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(resolveSpawnWslLaunchDirectory).toHaveBeenCalledWith('Ubuntu', expect.anything()) + ) + + 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 9ae7966eca76..312da9b407b6 100644 --- a/src/main/providers/local-pty-spawn.ts +++ b/src/main/providers/local-pty-spawn.ts @@ -7,7 +7,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' @@ -24,12 +28,24 @@ 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 { resolveSpawnWslLaunchDirectory } from './wsl-launch-directory-resolution' +import { noteLaunchArtifactRefusal } from './local-launch-artifact-directory' 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 wslProbe = resolveSpawnWslLaunchDirectory(wslDistro, args) + const wslLaunchDirectory = wslProbe ? await wslProbe : undefined if (reattachId) { const pendingShutdown = ptyShutdownOperations.get(reattachId) if (pendingShutdown) { @@ -58,6 +74,42 @@ export async function spawnLocalPty( delete args.env?.[JCODE_RUNTIME_DIR_ENV_KEY] } } + let launchFile: WrittenLaunchFile | undefined + try { + launchFile = writeSpawnLaunchFile({ + launchFile: args.launchFile, + command: args.command, + env: args.env, + wslDistro, + wslDirectory: wslLaunchDirectory + }) + } catch (error) { + noteLaunchArtifactRefusal({ wslDistro, wslLaunchDirectory }, error) + throw error + } + 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) + noteLaunchArtifactRefusal({ wslDistro, wslLaunchDirectory }, error) + 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() @@ -169,7 +221,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..d88385bbc322 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, UnstageableLine } from '../../shared/launch-prompt-file' +import type { WslLaunchDirectory } from '../../shared/wsl-launch-directory' export type { PtyBackgroundStreamEvent, @@ -58,6 +60,13 @@ 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 + /** For an agent line the host cannot stage; read by the terminal daemon only. */ + unstageableLine?: UnstageableLine + /** 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-plain-shell-pty-provider.test.ts b/src/main/providers/ssh-plain-shell-pty-provider.test.ts index 064f37a5cef1..a6173adfbfbf 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') + 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/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..0c68c110dfc7 --- /dev/null +++ b/src/main/providers/wsl-launch-directory-resolution.test.ts @@ -0,0 +1,176 @@ +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 { + noteWslLaunchDirectoryRefusal, + wslLaunchDirectoryKnownBroken, + 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() })) +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 launch file's prompt 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 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() + } + }) + + // Why (stack QA P8-2): a folder found once and broken later must not be written into forever, + // and the next launch is planned without it until the distro is asked again. + it('counts a folder broken after a refused write, then asks the distro again', async () => { + vi.useFakeTimers() + try { + runWslProcess.mockResolvedValue(answers('/home/ada\n/bin/bash\n')) + const found = await resolveWslLaunchDirectory('Void') + noteWslLaunchDirectoryRefusal(found, new Error('EACCES')) + expect(wslLaunchDirectoryKnownBroken('Void')).toBe(false) + noteWslLaunchDirectoryRefusal( + found, + new Error( + 'Orca could not write it (EEXIST), so the agent was not started. [launch_file_unavailable]' + ) + ) + expect(wslLaunchDirectoryKnownBroken('Void')).toBe(true) + await expect(resolveWslLaunchDirectory('Void')).resolves.toBeUndefined() + expect(runWslProcess).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS) + expect(wslLaunchDirectoryKnownBroken('Void')).toBe(false) + await expect(resolveWslLaunchDirectory('Void')).resolves.toMatchObject({ distro: 'Void' }) + expect(runWslProcess).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + + it('counts a distro whose probe failed broken, for planning', async () => { + runWslProcess.mockResolvedValue(answers('', 1)) + await resolveWslLaunchDirectory('Mint') + await Promise.resolve() + expect(wslLaunchDirectoryKnownBroken('Mint')).toBe(true) + }) + + it('probes only for a spawn that needs the directory', async () => { + runWslProcess.mockResolvedValue(answers('/home/ada\n/bin/bash\n')) + 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 () => { + 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..e44dfc7baa8c --- /dev/null +++ b/src/main/providers/wsl-launch-directory-resolution.ts @@ -0,0 +1,130 @@ +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 { isLaunchFileRefusal } from '../../shared/launch-prompt-file' +import type { TuiAgent } from '../../shared/tui-agent' +import { toWindowsWslUncPath } from '../../shared/wsl-paths' +import { runWslProcess } from '../wsl/wsl-runner' + +const ORCA_CACHE_RELATIVE = '.cache/orca' + +const probes = new Map>() +/** Distros whose folder a probe or a write found broken, until the failure TTL lets them retry. */ +const failedDistros = new Set() + +/** 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; a + * success is kept until a write into it fails (`noteWslLaunchDirectoryRefusal`), a failure for + * `WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS`. + */ +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) + const pending = probe + void pending.then((found) => { + if (found) { + failedDistros.delete(distro) + } else { + rememberFailure(distro, pending) + } + }) + } + return await probe +} + +function rememberFailure(distro: string, pending: Promise): void { + failedDistros.add(distro) + setTimeout(() => { + if (probes.get(distro) === pending) { + probes.delete(distro) + failedDistros.delete(distro) + } + }, WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS).unref?.() +} + +/** Whether the distro's folder is known broken, so a launch is planned without writing there. */ +export function wslLaunchDirectoryKnownBroken(distro: string): boolean { + return failedDistros.has(distro) +} + +/** A spawn into this directory was refused: count it broken until the failure TTL, so the next + * launch is planned without it and a later one asks the distro again. */ +export function noteWslLaunchDirectoryRefusal( + directory: WslLaunchDirectory | undefined, + error: unknown +): void { + if (directory && isLaunchFileRefusal(error)) { + const pending = Promise.resolve(undefined) + probes.set(directory.distro, pending) + rememberFailure(directory.distro, pending) + } +} + +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 launch file's prompt 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/pwsh.ts b/src/main/pwsh.ts index 14f77f4d826b..04001c85b120 100644 --- a/src/main/pwsh.ts +++ b/src/main/pwsh.ts @@ -120,6 +120,15 @@ export function isPwshAvailableAsync(): Promise { return pwshProbeInFlight } +/** The probe's answer when it has one, without blocking; null starts a probe for a later caller. */ +export function cachedPwshAvailability(): boolean | null { + if (pwshAvailableCache && isCacheFresh(pwshAvailableCache)) { + return pwshAvailableCache.available + } + void isPwshAvailableAsync() + return null +} + export function warmPwshAvailabilityCache(): Promise { if (pwshAvailableCache?.available) { return Promise.resolve(true) 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 9d17d07a49bc..000000000000 --- a/src/main/runtime/agent-launch-typed-line-shell.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, it } from 'vitest' -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', () => { - 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() - }) -}) - -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 deleted file mode 100644 index f733ade33859..000000000000 --- a/src/main/runtime/agent-launch-typed-line-shell.ts +++ /dev/null @@ -1,42 +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() -} - -/** - * 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/agent-session-launch-startup.ts b/src/main/runtime/agent-session-launch-startup.ts new file mode 100644 index 000000000000..860f42742a94 --- /dev/null +++ b/src/main/runtime/agent-session-launch-startup.ts @@ -0,0 +1,80 @@ +import type { AgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' +import type { AgentPromptDelivery } from '../../shared/agent-session-host-authority' +import type { WindowsShellSettings } from '../../shared/launch-host' +import type { LaunchFile } from '../../shared/launch-prompt-file' +import type { TuiAgent } from '../../shared/tui-agent' +import { + agentPromptRidesLaunchCommand, + type AgentDraftLaunchPlan, + type AgentStartupPlan +} from '../../shared/tui-agent-startup' +import { + launchPromptNeedsPasteRefusal, + windowsDraftRefusal +} from '../../shared/launch-prompt-carry' +import { + buildExecutionHostDraftLaunchPlan, + planExecutionHostLaunchPrompt +} from '../opencode/opencode-model-startup-plan' +import { probedThisOrcaLaunchHost } from './this-orca-launch-host' + +/** The launch line for an agent session's terminal, and the launch file it names, if any. */ +export async function planAgentSessionLaunchStartup(args: { + agent: TuiAgent + prompt: string | undefined + promptDelivery: AgentPromptDelivery | undefined + startupArgs: AgentStartupPlanInputs + cwd: string + hostIdentity: string + signal: AbortSignal | undefined + isRemote: boolean + settings: WindowsShellSettings + workspacePath: string +}): Promise<{ startup: AgentStartupPlan | AgentDraftLaunchPlan; launchFile?: LaunchFile }> { + const hostScope = { + inputs: args.startupArgs, + cwd: args.cwd, + hostIdentity: args.hostIdentity, + signal: args.signal + } + if (args.promptDelivery === 'draft') { + const startup = await buildExecutionHostDraftLaunchPlan({ + ...hostScope, + draft: args.prompt ?? '' + }) + if (!startup) { + const refusal = windowsDraftRefusal(args.agent, args.startupArgs.platform) + throw new Error(refusal ?? 'agent_session_identity_required') + } + return { startup } + } + const planned = await planExecutionHostLaunchPrompt({ + ...hostScope, + prompt: args.prompt ?? '', + host: await probedThisOrcaLaunchHost({ + launchPlatform: args.startupArgs.platform, + isRemote: args.isRemote, + settings: args.settings, + workspacePath: args.workspacePath, + prompt: args.prompt + }), + // Why: this create returns before the agent is ready, so nothing pastes after it. + paste: 'never' + }) + if (!planned) { + throw new Error('agent_session_identity_required') + } + switch (planned.carry) { + case 'none': + case 'on-line': + return { startup: planned.plan } + case 'launch-file': + return { startup: planned.plan, launchFile: planned.launchFile } + 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(args.agent)) { + throw new Error(launchPromptNeedsPasteRefusal(args.agent, 'session')) + } + return { startup: planned.cleanPlan } + } +} diff --git a/src/main/runtime/agent-terminal-startup-prompt.test.ts b/src/main/runtime/agent-terminal-startup-prompt.test.ts index cdf6cd16dbb5..bc3d53593cf5 100644 --- a/src/main/runtime/agent-terminal-startup-prompt.test.ts +++ b/src/main/runtime/agent-terminal-startup-prompt.test.ts @@ -2,13 +2,14 @@ * 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' import { OrcaRuntimeService } from './orca-runtime' +import { MAX_LINE_PROMPT_BYTES, 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(MAX_LINE_PROMPT_BYTES + 1) + + await runtime.createTerminal('id:wt-1', { startupAgent: 'claude', startupPrompt: prompt }) + + const launchFile = spawn.mock.calls[0]?.[0]?.launchFile + expect(launchFile).toMatchObject({ content: prompt, 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') 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(MAX_LINE_PROMPT_BYTES + 1), 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 () => { @@ -152,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/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/launched-agent-foreground.test.ts b/src/main/runtime/launched-agent-foreground.test.ts index f398a02aa0c7..347fa82162d0 100644 --- a/src/main/runtime/launched-agent-foreground.test.ts +++ b/src/main/runtime/launched-agent-foreground.test.ts @@ -2,7 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RemoteForegroundEvidence } from '../../shared/foreground-process-evidence' import type { ProcessTableRow } from '../../shared/process-table-snapshot' import type * as TerminalForegroundGroup from './terminal-foreground-group' -import { readLaunchedAgentForeground } from './launched-agent-foreground' +import { + readLaunchedAgentForeground, + readTerminalForegroundVerdict +} from './launched-agent-foreground' const paneTerminal = vi.hoisted(() => { const state: { rows: ProcessTableRow[] | null } = { rows: null } @@ -70,6 +73,23 @@ describe('the relay’s foreground group as proof of a launched agent', () => { ).resolves.toBe('agent') }) + // Why: the receipt counts only the agent named as having run; the paste guard keeps any process. + it.each<[string, 'launched-agent' | 'other']>([ + ['claude', 'launched-agent'], + ['node', 'other'] + ])('tells the launched agent from another process (%s) by name', async (name, verdict) => { + const controller = { + ...controllerAnswering(async () => claudeInGroup(1_500)), + getForegroundProcess: async () => name + } + await expect( + readTerminalForegroundVerdict(controller, POSIX_SSH, 'pty-1', 'claude') + ).resolves.toBe(verdict) + await expect( + readLaunchedAgentForeground(controller, POSIX_SSH, 'pty-1', 'claude') + ).resolves.toBe('agent') + }) + it('takes the relay’s name over a capture reused from before the read was asked for', async () => { const controller = controllerAnswering(async () => claudeInGroup(1_500)) diff --git a/src/main/runtime/launched-agent-foreground.ts b/src/main/runtime/launched-agent-foreground.ts index b7e300b797ac..3b301444b83b 100644 --- a/src/main/runtime/launched-agent-foreground.ts +++ b/src/main/runtime/launched-agent-foreground.ts @@ -7,7 +7,11 @@ 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' -import { judgeTerminalForeground, readTerminalProcessRows } from './terminal-foreground-group' +import { + judgeTerminalForeground, + readTerminalProcessRows, + type TerminalForegroundVerdict +} from './terminal-foreground-group' /** * What holds a launched agent's terminal: the agent (anything but the pane's shell), the shell @@ -44,6 +48,21 @@ function isLaunchedAgent(processName: string, agent: TuiAgent): boolean { * there proves the agent. A Windows host can still prove its shell alone (`confirmShellForeground`). */ export async function readLaunchedAgentForeground( + ...args: Parameters +): Promise { + return asLaunchedAgentForeground(await readTerminalForegroundVerdict(...args)) +} + +/** The paste guard's answer: any process but the shell counts as the agent. */ +export function asLaunchedAgentForeground( + verdict: TerminalForegroundVerdict +): LaunchedAgentForeground { + return verdict === 'launched-agent' || verdict === 'other' ? 'agent' : verdict +} + +/** The same read, telling the launched agent named on its command line from any other process + * that is not the shell: only the first proves the agent itself ran. */ +export async function readTerminalForegroundVerdict( controller: Pick< RuntimePtyController, 'getForegroundProcess' | 'confirmShellForeground' | 'inspectProcess' | 'listProcesses' @@ -51,7 +70,7 @@ export async function readLaunchedAgentForeground( host: { remote: boolean; windows: boolean }, ptyId: string, agent: TuiAgent -): Promise { +): Promise { if (!controller) { return 'unknown' } @@ -76,16 +95,19 @@ export async function readLaunchedAgentForeground( evidence.processName && isLaunchedAgent(evidence.processName, agent) ) { - return 'agent' + return 'launched-agent' } const foreground = await controller.getForegroundProcess(ptyId) if (!foreground) { return 'unknown' } - return isLaunchShell(foreground) && + if ( + isLaunchShell(foreground) && !isExpectedAgentProcess(foreground, TUI_AGENT_CONFIG[agent].expectedProcess) - ? 'shell' - : 'agent' + ) { + return 'shell' + } + return isLaunchedAgent(foreground, agent) ? 'launched-agent' : 'other' } catch { return 'unknown' } diff --git a/src/main/runtime/orca-runtime-activate-managed-worktree.ts b/src/main/runtime/orca-runtime-activate-managed-worktree.ts index 36e05550fd54..c443c19aae22 100644 --- a/src/main/runtime/orca-runtime-activate-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-activate-managed-worktree.ts @@ -4,6 +4,8 @@ import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' import { navigationTargetsClients, navigationTargetsHost } from '../../shared/runtime-navigation' import { getRepoExecutionHostId } from '../../shared/execution-host' import type { Repo } from '../../shared/repo-types' +import { repoIsRemote } from '../../shared/agent-launch-remote' +import { probeWslLaunchFolderBeforePlanning } from './this-orca-launch-host' import type { TuiAgent } from '../../shared/tui-agent' import type { RuntimeTerminalWait } from '../../shared/runtime-terminal-contracts' import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' @@ -34,9 +36,11 @@ import { provisionWorktreeTerminals } from './runtime-worktree-terminal-provisio import { readFreshComposerHold } from './launched-agent-composer-readiness' import { buildTerminalWaitText } from './terminal-wait-tail-state' import { - readLaunchedAgentForeground, + asLaunchedAgentForeground, + readTerminalForegroundVerdict, type LaunchedAgentForeground } from './launched-agent-foreground' +import type { TerminalForegroundVerdict } from './terminal-foreground-group' export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListManagedWorktrees { async activateManagedWorktree( @@ -143,7 +147,7 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM }) } - protected buildStartupForAgent( + protected async buildStartupForAgent( repo: Repo, agent: TuiAgent, prompt: string | undefined, @@ -153,10 +157,20 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM launchSource?: string onPromptCarry?: (carried: boolean) => void } - ): { agent: TuiAgent; startup: WorktreeStartupLaunch; followup?: WorktreeStartupFollowup } { + ): Promise<{ + agent: TuiAgent + startup: WorktreeStartupLaunch + followup?: WorktreeStartupFollowup + }> { if (!this.store) { throw new Error('runtime_unavailable') } + await probeWslLaunchFolderBeforePlanning({ + launchPlatform: this.getAgentLaunchPlatformForRepo(repo), + isRemote: repoIsRemote(repo), + workspacePath: repo.path, + prompt + }) return buildWorktreeStartupForAgent({ repo, agent, @@ -251,8 +265,18 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM } /** What holds the terminal a launch started its agent in, read fresh from the execution host. */ - readLaunchedAgentForeground(ptyId: string, agent: TuiAgent): Promise { - return readLaunchedAgentForeground( + async readLaunchedAgentForeground( + ptyId: string, + agent: TuiAgent + ): Promise { + return asLaunchedAgentForeground(await this.readTerminalForegroundVerdict(ptyId, agent)) + } + + readTerminalForegroundVerdict( + ptyId: string, + agent: TuiAgent + ): Promise { + return readTerminalForegroundVerdict( this.ptyController, this.launchedAgentHost(ptyId), ptyId, 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..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,6 +213,24 @@ describe('agent-session create operation ledger', () => { ) }) + // 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 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 () => { 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 b2ba25b7d739..fd654ca17690 100644 --- a/src/main/runtime/orca-runtime-create-agent-session.ts +++ b/src/main/runtime/orca-runtime-create-agent-session.ts @@ -17,7 +17,7 @@ import { } from './orca-runtime-core' import { isTuiAgentEnabled } from '../../shared/tui-agent-selection' import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' -import { buildExecutionHostAgentStartupPlan } from '../opencode/opencode-model-startup-plan' +import { planAgentSessionLaunchStartup } from './agent-session-launch-startup' import type { RuntimeTerminalCreate } from '../../shared/runtime-types' import type { AgentSessionCreateOperation, @@ -159,17 +159,18 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe ...(request.agentArgs !== undefined ? { agentArgs: request.agentArgs } : {}), sessionOptions: this.toAgentSessionOptions(request.launchPreferences) }) - const startup = await buildExecutionHostAgentStartupPlan({ - inputs: startupArgs, - cwd: startupCwd ?? workspace.path, - prompt: request.prompt ?? '', + const { startup, launchFile } = await planAgentSessionLaunchStartup({ + agent: request.agent, + prompt: request.prompt, promptDelivery: request.promptDelivery, + startupArgs, + cwd: startupCwd ?? workspace.path, hostIdentity: this.runtimeId, - signal: caller.signal + signal: caller.signal, + isRemote: Boolean(workspace.connectionId), + settings, + workspacePath: workspace.path }) - if (!startup) { - throw new Error('agent_session_identity_required') - } if (caller.signal?.aborted) { throw new Error('client_disconnected') } @@ -201,6 +202,7 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe launchAgent: request.agent, terminalKittyKeyboardProtocol: request.terminalKittyKeyboardProtocol, startupCommandDelivery: startup.startupCommandDelivery, + ...(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-managed-worktree.ts b/src/main/runtime/orca-runtime-create-managed-worktree.ts index 5e8cf5e9d8a9..769c2ff448c3 100644 --- a/src/main/runtime/orca-runtime-create-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-create-managed-worktree.ts @@ -63,7 +63,7 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork ) { throw new Error('Selected agent is disabled. Choose an enabled agent before creating.') } - const agentStartup = resolveWorktreeCreateAgentStartup(args, (...inputs) => + const agentStartup = await resolveWorktreeCreateAgentStartup(args, (...inputs) => this.buildStartupForAgent(repo, ...inputs) ) const draftStartup = 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 0799fd1b17a3..91d6e58bc8d2 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 @@ -51,6 +51,7 @@ export class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal extends Orca env?: Record envToDelete?: string[] startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] + launchFile?: WorktreeStartupLaunch['launchFile'] identity?: { tabId: string; leafId: string; sessionId?: string } createMutation?: { clientIdentity: string; id: string } launchAgent?: TuiAgent @@ -111,6 +112,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.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 6a56bff56b0c..713b2d0ea6d8 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' import type { Worktree } from '../../shared/worktree/types' export async function createDesktopTerminal( @@ -72,6 +73,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', @@ -79,7 +81,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-create-terminal.ts b/src/main/runtime/orca-runtime-create-terminal.ts index eba6d03a29a7..4288ea8b841d 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( @@ -82,9 +83,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, @@ -123,6 +122,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate try { const { launchAgent } = launchOpts launchOpts.onPtySpawnDispatched?.({ launchConfig: effectiveLaunchConfig, launchAgent }) + rememberLaunchFilePrompt(dependencies.makePaneKey(tabId, leafId), launchOpts.launchFile) result = await this.ptyController.spawn({ cols: 120, rows: 40, @@ -133,6 +133,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..74b692e8ee63 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,9 @@ 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 { probedThisOrcaLaunchHost } from './this-orca-launch-host' +import { launchPromptNeedsPasteRefusal } from '../../shared/launch-prompt-carry' import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' export class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends OrcaRuntimeWithRunCreateMobileSessionTerminal { @@ -28,6 +30,7 @@ export class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends OrcaRunt startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent + launchFile?: WorktreeStartupLaunch['launchFile'] }> { if (opts.command || !opts.agent) { return { @@ -46,23 +49,45 @@ 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({ + // 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 ?? '', - allowEmptyPromptLaunch: true + host: await probedThisOrcaLaunchHost({ + launchPlatform, + isRemote, + settings, + workspacePath: workspace.path, + prompt: opts.agentPrompt + }), + // Why: a quick command has no paste after ready. + paste: 'never' }) - 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, @@ -72,7 +97,8 @@ export class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends OrcaRunt envToDelete: opts.envToDelete, launchConfig: startupPlan.launchConfig, launchAgent: opts.agent, - startupCommandDelivery: startupPlan.startupCommandDelivery + startupCommandDelivery: startupPlan.startupCommandDelivery, + ...(launchFile ? { launchFile } : {}) } } } 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..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,9 +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, + 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, @@ -137,26 +145,54 @@ 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 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() + 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-run-create-mobile-session-terminal.ts b/src/main/runtime/orca-runtime-run-create-mobile-session-terminal.ts index 5e3c64582526..f9793ddea270 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 @@ -102,6 +102,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, @@ -164,6 +165,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 }) @@ -227,6 +229,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/orca-runtime-terminal-create-deduplication.ts b/src/main/runtime/orca-runtime-terminal-create-deduplication.ts index 64041d6d8da3..1fef90bfcb83 100644 --- a/src/main/runtime/orca-runtime-terminal-create-deduplication.ts +++ b/src/main/runtime/orca-runtime-terminal-create-deduplication.ts @@ -160,7 +160,7 @@ export class OrcaRuntimeWithTerminalCreateDeduplication extends OrcaRuntimeWithC if (!repo) { throw new Error('Repository for the selected workspace is no longer available.') } - const startup = this.buildStartupForAgent(repo, opts.agent, opts.prompt) + const startup = await this.buildStartupForAgent(repo, opts.agent, opts.prompt) return await this.createTerminal(`id:${worktree.id}`, { command: startup.startup.command, env: startup.startup.env, 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..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 @@ -34,10 +34,33 @@ 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() }) + // 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) + 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/qoder-terminal-command-resolution.test.ts b/src/main/runtime/qoder-terminal-command-resolution.test.ts index 30c2e189bd0c..fdc4d95ba4a1 100644 --- a/src/main/runtime/qoder-terminal-command-resolution.test.ts +++ b/src/main/runtime/qoder-terminal-command-resolution.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import { buildAgentStartupPlan, buildAgentResumeStartupPlan } from '../../shared/tui-agent-startup' +import { planLaunchForTest } from '../../shared/launch-prompt-plan.test-fixture' +import { buildAgentResumeStartupPlan } from '../../shared/tui-agent-startup' import { buildAiVaultResumeShellCommand } from '../../shared/ai-vault-resume-command' import { resolveQoderTerminalCommand } from './qoder-terminal-command-resolution' @@ -9,7 +10,7 @@ describe('execution-host Qoder command selection', () => { it.each(['darwin', 'linux', 'win32'] as const)( 'starts and resumes modern-only and legacy-only installs on %s', async (platform) => { - const startup = buildAgentStartupPlan({ + const startup = planLaunchForTest({ agent: 'qoder', prompt: 'qodercli is a prompt word', cmdOverrides: {}, 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-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 bf3b4a2ceaaf..8ad1359a8d82 100644 --- a/src/main/runtime/rpc/methods/agent-launch-failure-code.ts +++ b/src/main/runtime/rpc/methods/agent-launch-failure-code.ts @@ -13,6 +13,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 @@ -55,5 +59,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/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..9fdcd66d9998 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 `carryLaunchPrompt` leaves it for + // the paste, 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 8cffda5cf012..ae9c85140837 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,13 @@ * 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 the rule + * leaves for the paste (an agent not known to read a launch file, or a host that cannot write its + * staging folder); 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..734f7be401c8 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 `carryLaunchPrompt` leaves it for the paste. ...(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-agent-launch-resolution.ts b/src/main/runtime/runtime-agent-launch-resolution.ts index 9f5ddd4bd0a6..2ace8f5c4641 100644 --- a/src/main/runtime/runtime-agent-launch-resolution.ts +++ b/src/main/runtime/runtime-agent-launch-resolution.ts @@ -5,6 +5,7 @@ import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resum import type { ClaudeAgentTeamsMode } from '../../shared/claude-agent-teams-tmux-compat' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' +import { isWslUncPath } from '../../shared/wsl-paths' import { getTuiAgentLaunchCommand, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' import { isTuiAgentEnabled } from '../../shared/tui-agent-selection' @@ -110,7 +111,12 @@ export function getAgentLaunchPlatformForRepo( if (projectRuntime?.status === 'repair-required') { return projectRuntime.repair.preferredRuntime.kind === 'wsl' ? 'linux' : process.platform } - if (projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl') { + // Why a WSL path: the pane spawns in the distro's POSIX shell, whatever the Windows shell + // setting, so its line is judged and quoted for that shell. + if ( + (projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl') || + isWslUncPath(repo.path) + ) { return 'linux' } return process.platform diff --git a/src/main/runtime/runtime-agent-terminal-startup.ts b/src/main/runtime/runtime-agent-terminal-startup.ts index 68932fc00587..fba84e9bacce 100644 --- a/src/main/runtime/runtime-agent-terminal-startup.ts +++ b/src/main/runtime/runtime-agent-terminal-startup.ts @@ -4,12 +4,9 @@ import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-termi import type { TerminalCreateOptions } from './runtime-terminal-contracts' import { isTuiAgentEnabled } from '../../shared/tui-agent-selection' import { resolveBareAgentLaunchCommand } from './runtime-agent-launch-resolution' -import { planExecutionHostStartupWithPromptCandidate } from '../opencode/opencode-model-startup-plan' -import { agentPromptRidesLaunchCommand } from '../../shared/tui-agent-startup' -import { - launchHostProvesAgentInFront, - nameLocalTypedLineShell -} from './agent-launch-typed-line-shell' +import { planExecutionHostLaunchPrompt } from '../opencode/opencode-model-startup-plan' +import { launchPromptNeedsPasteRefusal } from '../../shared/launch-prompt-carry' +import { probedThisOrcaLaunchHost } from './this-orca-launch-host' import { resolveTerminalStartupCwd } from '../../shared/terminal-startup-cwd' import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry' @@ -40,12 +37,9 @@ export async function buildRuntimeAgentTerminalStartupOptions( 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 { plan: startupPlan, promptCarried } = await planExecutionHostStartupWithPromptCandidate({ + const startupCwd = resolveTerminalStartupCwd(workspace.path, opts.cwd) ?? workspace.path + // A caller that wrote its own launch file already passes the pointer to it as the prompt. + const planned = await planExecutionHostLaunchPrompt({ inputs: resolveAgentStartupPlanInputs({ agent, settings, @@ -56,21 +50,22 @@ export async function buildRuntimeAgentTerminalStartupOptions( windowsShellOverride: opts.shellOverride, sessionOptions: sessionOptions }), - prompt: opts.startupPrompt ?? '', - cwd: resolveTerminalStartupCwd(workspace.path, opts.cwd) ?? workspace.path, + cwd: startupCwd, hostIdentity, - host: { - shellName: nameLocalTypedLineShell({ - isRemote, - ...(opts.shellOverride ? { shellOverride: opts.shellOverride } : {}), - ...(settings.terminalDefaultShell - ? { defaultShellSetting: settings.terminalDefaultShell } - : {}) - }), - provesAgentInFront: launchHostProvesAgentInFront({ isRemote, launchPlatform: platform }) - } + prompt: opts.startupPrompt ?? '', + ...(opts.launchFile ? { launchFile: opts.launchFile } : {}), + host: await probedThisOrcaLaunchHost({ + launchPlatform: platform, + isRemote, + settings, + windowsShellOverride: opts.shellOverride, + workspacePath: workspace.path, + // A caller's own launch file already carries the prompt, so no line needs staging. + ...(opts.launchFile ? {} : { prompt: opts.startupPrompt }) + }), + paste: opts.onStartupPromptCarry ? 'when-host-proves-agent' : 'never' }) - if (!startupPlan) { + if (!planned) { // Why: an explicit agent that yields no plan would otherwise spawn a bare // shell that never reaches agent readiness. if (opts.startupAgent) { @@ -78,8 +73,29 @@ export async function buildRuntimeAgentTerminalStartupOptions( } return opts } - if (opts.startupPrompt) { - opts.onStartupPromptCarry?.(promptCarried) + 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 { @@ -89,6 +105,7 @@ export async function buildRuntimeAgentTerminalStartupOptions( 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/runtime-ai-vault-commands.test.ts b/src/main/runtime/runtime-ai-vault-commands.test.ts new file mode 100644 index 000000000000..8e514bc46b22 --- /dev/null +++ b/src/main/runtime/runtime-ai-vault-commands.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from 'vitest' +import { resolveLocalAiVaultSessionTitles } from '../ai-vault/session-title-resolver' +import { rememberLaunchFilePrompt } from '../agent-hooks/launch-file-prompt-by-pane' +import { buildLaunchFilePointer, carryInLaunchFile } from '../../shared/launch-prompt-file' +import { RuntimeAiVaultCommands } from './runtime-ai-vault-commands' + +vi.mock('../ai-vault/cached-session-list', () => ({ listAiVaultSessions: vi.fn() })) +vi.mock('../ai-vault/session-title-resolver', () => ({ + resolveLocalAiVaultSessionTitles: vi.fn() +})) + +// Why: a paired web client and the phone ask this host for titles over RPC; the host spawned the +// pane, so it shows the launch file's prompt there too. +describe('session titles a paired client asks this host for', () => { + it('show a launch file’s prompt, not the pointer its transcript opens with', async () => { + const pane = 'tab-rpc:22222222-2222-4222-8222-222222222222' + rememberLaunchFilePrompt(pane, carryInLaunchFile('QA-STACK review the diff').launchFile) + vi.mocked(resolveLocalAiVaultSessionTitles).mockResolvedValue({ + titles: [ + { + agent: 'codex', + sessionId: 's-rpc', + title: buildLaunchFilePointer('/tmp/orca-launch-file-1-b/task-context.md').slice(0, 96) + } + ] + }) + const result = await new RuntimeAiVaultCommands(() => null).resolveTitles([ + { agent: 'codex', sessionId: 's-rpc', paneKey: pane } + ]) + expect(result.titles[0]?.title).toBe('QA-STACK review the diff') + }) +}) diff --git a/src/main/runtime/runtime-ai-vault-commands.ts b/src/main/runtime/runtime-ai-vault-commands.ts index 2701d782b61c..801cffe044e2 100644 --- a/src/main/runtime/runtime-ai-vault-commands.ts +++ b/src/main/runtime/runtime-ai-vault-commands.ts @@ -9,6 +9,7 @@ import type { import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types' import { listAiVaultSessions } from '../ai-vault/cached-session-list' import { resolveLocalAiVaultSessionTitles } from '../ai-vault/session-title-resolver' +import { showLaunchPromptsInSessionTitles } from '../ai-vault/session-title-launch-prompt' export class RuntimeAiVaultCommands { constructor( @@ -21,11 +22,14 @@ export class RuntimeAiVaultCommands { return listAiVaultSessions(args) } - resolveTitles( + async resolveTitles( requests: AiVaultSessionTitleRequest[], signal?: AbortSignal ): Promise { - return resolveLocalAiVaultSessionTitles(requests, signal) + return showLaunchPromptsInSessionTitles( + requests, + await resolveLocalAiVaultSessionTitles(requests, signal) + ) } prepare(args: AiVaultPrepareSessionResumeArgs): Promise { 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..0a87b7ce7bf6 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 + * `carryLaunchPrompt` leaves for the paste); 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 306fe4f053c9..28d38955e4b1 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 PtyInventoryRefreshOptions = { @@ -54,6 +55,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 ef4e016b3925..a7f9af2a9069 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 43b487db3c94..2134256a8494 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -16,6 +16,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' @@ -45,16 +46,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, + * 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. */ 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..c297d7d5b68a 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.test.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import type { Repo } from '../../shared/repo-types' import { tuiAgentToAgentKind } from '../../shared/agent-kind' +import { MAX_LINE_PROMPT_BYTES } from '../../shared/launch-prompt-file' const mocks = vi.hoisted(() => ({ detectRemoteAgents: vi.fn(), @@ -109,37 +110,70 @@ 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(MAX_LINE_PROMPT_BYTES + 1) + 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) + // Why: measured on PowerShell, a short multi-line line arrives and a 9 KB one does not. PowerShell + // quotes it onto one physical line (#23672). + it('keeps a short multi-line prompt on a Windows line and points a long one at a file', () => { + expect(build('summarize the diff\nthen list the risks', 'win32').startup.command).toContain( + '"summarize the diff`nthen list the risks"' + ) + const long = Array.from({ length: 20 }, (_, i) => `step ${i} `.padEnd(500, 'x')).join('\n') + const result = build(long, 'win32') + expect(result.startup.launchFile?.content).toBe(long) + expect(result.startup.command).not.toContain('step 1 ') }) - 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('leaves a prompt needing a file the agent is not known to read as the follow-up paste', () => { + const prompt = 'x'.repeat(MAX_LINE_PROMPT_BYTES + 1) + 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('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(MAX_LINE_PROMPT_BYTES + 1), + 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 989f5ec06a93..cee7c3fa64fa 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.ts @@ -8,12 +8,14 @@ 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 { planStartupWithPromptCandidate } from '../../shared/startup-line-prompt-carry' import { - launchHostProvesAgentInFront, - nameLocalTypedLineShell -} from './agent-launch-typed-line-shell' + buildAgentDraftLaunchPlan, + buildAgentStartupPlan, + planLaunchPrompt, + type AgentStartupPlan +} from '../../shared/tui-agent-startup' +import type { LaunchFile } from '../../shared/launch-prompt-file' +import { thisOrcaLaunchHost } from './this-orca-launch-host' import { detectInstalledAgentsWithShellPathHydration, detectRemoteAgents @@ -133,8 +135,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 } ): { @@ -154,31 +156,43 @@ 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, { - 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) - } - } else { - startupPlan = buildAgentStartupPlan({ ...planInputs, prompt, allowEmptyPromptLaunch: true }) - } - if (!startupPlan) { + const planned = planLaunchPrompt({ + ...planInputs, + prompt: environment.prompt ?? '', + host: thisOrcaLaunchHost({ + isRemote: repoIsRemote(repo), + launchPlatform: environment.getLaunchPlatform(), + settings, + workspacePath: repo.path + }), + // 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}.`) } + 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, startup: { @@ -188,16 +202,10 @@ export function buildWorktreeStartupForAgent( ? { startupCommandDelivery: startupPlan.startupCommandDelivery } : {}), ...(startupPlan.env ? { env: startupPlan.env } : {}), + ...(launchFile ? { launchFile } : {}), telemetry: agentStartedTelemetry(agent, environment.launchSource) }, - ...(startupPlan.followupPrompt - ? { - followup: { - expectedProcess: startupPlan.expectedProcess, - prompt: startupPlan.followupPrompt - } - } - : {}) + ...(followup ? { followup } : {}) } } @@ -212,7 +220,11 @@ export function resolveWorktreeCreateAgentStartup( launchSource?: string onPromptCarry?: (carried: boolean) => void } - ) => { agent: TuiAgent; startup: WorktreeStartupLaunch; followup?: WorktreeStartupFollowup } + ) => Promise<{ + agent: TuiAgent + startup: WorktreeStartupLaunch + followup?: WorktreeStartupFollowup + }> ) { if (args.startup || !args.startupAgent) { return null diff --git a/src/main/runtime/terminal-foreground-group.test.ts b/src/main/runtime/terminal-foreground-group.test.ts index df1c33483781..3e026e369588 100644 --- a/src/main/runtime/terminal-foreground-group.test.ts +++ b/src/main/runtime/terminal-foreground-group.test.ts @@ -27,7 +27,7 @@ describe('who holds a pane’s terminal, by its foreground process group', () => [ 'the agent the launch line ran', pane(200, [{ pid: 200, ppid: 101, pgid: 200, stat: 'S+', command: '/opt/bin/claude' }]), - 'agent' + 'launched-agent' ], // A tcsh or nu launch line, or a wrapper script that does not `exec` its agent. [ @@ -36,7 +36,7 @@ describe('who holds a pane’s terminal, by its foreground process group', () => { pid: 200, ppid: 101, pgid: 200, stat: 'S+', command: '/bin/sh /tmp/orca-launch/run.sh' }, { pid: 201, ppid: 200, pgid: 200, stat: 'S+', command: '/opt/bin/claude' } ]), - 'agent' + 'launched-agent' ], [ 'an agent it cannot recognize behind a bash wrapper', @@ -44,7 +44,9 @@ describe('who holds a pane’s terminal, by its foreground process group', () => { pid: 200, ppid: 101, pgid: 200, stat: 'S+', command: 'bash ./start-agent.sh' }, { pid: 201, ppid: 200, pgid: 200, stat: 'S+', command: 'node /opt/agent/cli.js' } ]), - 'agent' + // Not named as the launched agent: the paste guard still counts it, the receipt's exit + // verdict does not. + 'other' ], // Between its commands a wrapper is only shells, and typing into it could run the text. [ diff --git a/src/main/runtime/terminal-foreground-group.ts b/src/main/runtime/terminal-foreground-group.ts index 35b237d23626..192f6815afb3 100644 --- a/src/main/runtime/terminal-foreground-group.ts +++ b/src/main/runtime/terminal-foreground-group.ts @@ -72,6 +72,13 @@ function isShellCommand(command: string): boolean { return isShellProcess(executable.split('/').pop() ?? executable) } +/** + * What holds a terminal: `launched-agent` when a member names the launched agent on its command + * line, `other` when a member is not a shell (an agent behind a wrapper that did not `exec` it, an + * unrecognized command override, or a startup file's own command), `shell` when every member is. + */ +export type TerminalForegroundVerdict = 'launched-agent' | 'other' | 'shell' | 'unknown' + /** * The terminal's foreground process group decides. The launched agent among its members, or any * member that is not a shell, is the agent: that finds it behind a wrapper that did not `exec` it, @@ -83,7 +90,7 @@ export function judgeTerminalForeground( rows: readonly ProcessTableRow[], rootPid: number, agent: TuiAgent -): 'agent' | 'shell' | 'unknown' { +): TerminalForegroundVerdict { const root = rows.find((row) => row.pid === rootPid) const foregroundGroup = root?.tpgid if (foregroundGroup === undefined || foregroundGroup <= 0) { @@ -93,11 +100,8 @@ export function judgeTerminalForeground( if (front.length === 0) { return 'unknown' } - return front.some( - (row) => - recognizeAgentProcessFromCommandLine(row.command)?.agent === agent || - !isShellCommand(row.command) - ) - ? 'agent' - : 'shell' + if (front.some((row) => recognizeAgentProcessFromCommandLine(row.command)?.agent === agent)) { + return 'launched-agent' + } + return front.some((row) => !isShellCommand(row.command)) ? 'other' : 'shell' } 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..5ad04e356d2c --- /dev/null +++ b/src/main/runtime/terminal-launch-refusals.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { + noteTerminalPaneSpawn, + 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, + 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, timeoutMs) +} + +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) + }) + + // 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 new file mode 100644 index 000000000000..c4419fef8658 --- /dev/null +++ b/src/main/runtime/terminal-launch-refusals.ts @@ -0,0 +1,62 @@ +/** + * 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>() +/** 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() + 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/main/runtime/this-orca-launch-host.test.ts b/src/main/runtime/this-orca-launch-host.test.ts new file mode 100644 index 000000000000..fe34375b6188 --- /dev/null +++ b/src/main/runtime/this-orca-launch-host.test.ts @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cachedPwshAvailability } from '../pwsh' +import { localLaunchArtifactsWritable } from '../providers/local-launch-artifact-directory' +import { wslLaunchDirectoryKnownBroken } from '../providers/wsl-launch-directory-resolution' +import { thisOrcaLaunchHost } from './this-orca-launch-host' + +vi.mock('../pwsh', () => ({ cachedPwshAvailability: vi.fn(() => null) })) +vi.mock('../providers/local-launch-artifact-directory', () => ({ + localLaunchArtifactsWritable: vi.fn(() => true) +})) +vi.mock('../providers/wsl-launch-directory-resolution', () => ({ + wslLaunchDirectoryKnownBroken: vi.fn(() => false) +})) + +const realPlatform = process.platform + +function onPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }) +} + +// Why: this Orca spawns its own Windows panes, so it knows the PowerShell that gets the line. +describe('the PowerShell a launch this Orca runs lands in', () => { + afterEach(() => { + onPlatform(realPlatform) + vi.mocked(cachedPwshAvailability).mockReturnValue(null) + }) + + it('follows the requested shell, then the settings and the pwsh probe, on this Windows machine', () => { + onPlatform('win32') + const settings = { + terminalWindowsShell: 'powershell.exe', + terminalWindowsPowerShellImplementation: 'auto' as const + } + const host = (windowsShellOverride?: string) => + thisOrcaLaunchHost({ + launchPlatform: 'win32', + isRemote: false, + settings, + windowsShellOverride + }) + expect(host().windowsPaneShell).toBeNull() + vi.mocked(cachedPwshAvailability).mockReturnValue(false) + expect(host().windowsPaneShell).toBe('powershell.exe') + expect(host('pwsh.exe').windowsPaneShell).toBe('pwsh.exe') + expect( + thisOrcaLaunchHost({ launchPlatform: 'win32', isRemote: true, settings }).windowsPaneShell + ).toBeNull() + }) + + it('knows none for a launch on another platform', () => { + onPlatform('darwin') + vi.mocked(cachedPwshAvailability).mockReturnValue(true) + expect( + thisOrcaLaunchHost({ + launchPlatform: 'win32', + isRemote: false, + settings: { terminalWindowsShell: 'pwsh.exe' } + }).windowsPaneShell + ).toBeNull() + }) +}) + +// Why: a host that cannot write its staging folder can neither stage a line nor write a launch +// file there, so the launch is planned for main's delivery up front instead of refused. +describe('whether a launch this Orca runs can write its staging folder', () => { + afterEach(() => { + onPlatform(realPlatform) + vi.mocked(localLaunchArtifactsWritable).mockReturnValue(true) + vi.mocked(wslLaunchDirectoryKnownBroken).mockReturnValue(false) + }) + + it('follows this machine’s temp folder for a local launch', () => { + const host = () => + thisOrcaLaunchHost({ launchPlatform: 'darwin', isRemote: false, settings: null }) + onPlatform('darwin') + expect(host().takesLaunchFile).toBe(true) + vi.mocked(localLaunchArtifactsWritable).mockReturnValue(false) + expect(host().takesLaunchFile).toBe(false) + }) + + it('follows the distro’s folder for a WSL workspace', () => { + onPlatform('win32') + vi.mocked(wslLaunchDirectoryKnownBroken).mockImplementation((distro) => distro === 'qasfwin') + const host = (workspacePath: string) => + thisOrcaLaunchHost({ + launchPlatform: 'linux', + isRemote: false, + settings: null, + workspacePath + }) + expect(host('\\\\wsl.localhost\\qasfwin\\root\\fixture').takesLaunchFile).toBe(false) + expect(host('\\\\wsl.localhost\\Ubuntu\\home\\ada').takesLaunchFile).toBe(true) + }) + + it('cannot check an SSH host’s folder from here', () => { + vi.mocked(localLaunchArtifactsWritable).mockReturnValue(false) + expect( + thisOrcaLaunchHost({ launchPlatform: 'linux', isRemote: true, settings: null }) + .takesLaunchFile + ).toBe(true) + }) +}) 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..92f4e7a1f8ec --- /dev/null +++ b/src/main/runtime/this-orca-launch-host.ts @@ -0,0 +1,96 @@ +import { cachedPwshAvailability } from '../pwsh' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { localLaunchArtifactsWritable } from '../providers/local-launch-artifact-directory' +import { + resolveWslLaunchDirectory, + wslLaunchDirectoryKnownBroken +} from '../providers/wsl-launch-directory-resolution' +import { typedStartupLineFits } from '../../shared/typed-startup-line' +import { + describeLaunchHost, + spawnedWindowsShell, + type LaunchHost, + type WindowsShellSettings +} from '../../shared/launch-host' + +type ThisOrcaLaunchHostArgs = { + launchPlatform: NodeJS.Platform + isRemote: boolean + settings: WindowsShellSettings + /** The shell this launch asked for (`--shell`), which outranks the setting. */ + windowsShellOverride?: string | null + /** Where the pane opens; a WSL path names the distro its line and file are written into. */ + workspacePath?: string +} + +/** The host facts for a launch this Orca runs itself, locally or over its own SSH connection. */ +export function thisOrcaLaunchHost(args: ThisOrcaLaunchHostArgs): LaunchHost { + const local = !args.isRemote && args.launchPlatform === 'win32' && process.platform === 'win32' + return describeLaunchHost({ + launchPlatform: args.launchPlatform, + isRemote: args.isRemote, + hostPlatform: process.platform, + paired: false, + windowsPaneShell: local + ? spawnedWindowsShell({ + settings: args.settings, + windowsShellOverride: args.windowsShellOverride, + pwshAvailable: cachedPwshAvailability() + }) + : null, + writesLaunchArtifacts: writesLaunchArtifacts(args) + }) +} + +/** + * Probes the WSL distro a prompt too long to type would be staged into, before the launch is + * planned, so a first launch into an unusable folder is planned for main's delivery as later ones + * are. The spawn would await the same probe for such a line; a prompt short enough to type skips it. + */ +export async function probeWslLaunchFolderBeforePlanning(args: { + launchPlatform: NodeJS.Platform + isRemote: boolean + workspacePath?: string + prompt?: string +}): Promise { + const distro = wslLaunchDistro(args) + if (distro && args.prompt && !typedStartupLineFits(args.prompt.trim())) { + await resolveWslLaunchDirectory(distro) + } +} + +/** `thisOrcaLaunchHost` for a launch of `prompt`, once its WSL folder is probed when it may be. */ +export async function probedThisOrcaLaunchHost( + args: ThisOrcaLaunchHostArgs & { prompt?: string } +): Promise { + await probeWslLaunchFolderBeforePlanning(args) + return thisOrcaLaunchHost(args) +} + +function wslLaunchDistro(args: { + launchPlatform: NodeJS.Platform + isRemote: boolean + workspacePath?: string +}): string | undefined { + if (args.isRemote || process.platform !== 'win32' || args.launchPlatform === 'win32') { + return undefined + } + return args.workspacePath ? parseWslUncPath(args.workspacePath)?.distro : undefined +} + +/** Whether this host can write the folder a staged line and a launch file go in. An SSH host's is + * the relay's, which this client cannot check. */ +function writesLaunchArtifacts(args: { + launchPlatform: NodeJS.Platform + isRemote: boolean + workspacePath?: string +}): boolean { + if (args.isRemote) { + return true + } + if (process.platform === 'win32' && args.launchPlatform !== 'win32') { + const distro = wslLaunchDistro(args) + return !distro || !wslLaunchDirectoryKnownBroken(distro) + } + return localLaunchArtifactsWritable() +} diff --git a/src/main/runtime/wsl-broken-launch-folder.test.ts b/src/main/runtime/wsl-broken-launch-folder.test.ts new file mode 100644 index 000000000000..bbb35e24dbf4 --- /dev/null +++ b/src/main/runtime/wsl-broken-launch-folder.test.ts @@ -0,0 +1,222 @@ +import '../daemon/mock-descendant-sweep' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' +import { buildStartupCommandSubmission } from '../../shared/startup-command-submission' +import { planLaunchPrompt } from '../../shared/tui-agent-startup' +import type { TuiAgent } from '../../shared/tui-agent' +import type { WslLaunchDirectory } from '../../shared/wsl-launch-directory' +import type * as WslPaths from '../../shared/wsl-paths' +import { TerminalHost } from '../daemon/terminal-host' +import type { SubprocessHandle } from '../daemon/session-subprocess-handle' +import { noteLaunchArtifactRefusal } from '../providers/local-launch-artifact-directory' +import { resolveSpawnWslLaunchDirectory } from '../providers/wsl-launch-directory-resolution' +import { getAgentLaunchPlatformForRepo } from './runtime-agent-launch-resolution' +import { probedThisOrcaLaunchHost } from './this-orca-launch-host' + +const { runWslProcess, uncSide } = vi.hoisted(() => { + const side: { path: string | null } = { path: null } + return { runWslProcess: vi.fn(), uncSide: side } +}) +vi.mock('../wsl/wsl-runner', () => ({ runWslProcess })) +// Stands in for `\\wsl.localhost\\root\.cache\orca`, which only Windows can open. +vi.mock('../../shared/wsl-paths', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + toWindowsWslUncPath: (linuxPath: string, distro: string) => + uncSide.path ?? actual.toWindowsWslUncPath(linuxPath, distro) + } +}) + +// The stack QA's prompts (2a): one long line and five short ones. +const P25K = 'QA-STACK P25K one line, inert. '.padEnd(25_600, 'x') +const ML5 = [1, 2, 3, 4, 5].map((i) => `QA-STACK ML5 line ${i}: inert text.`).join('\n') + +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')! +let scratch: string +let exitSubprocess: ((code: number) => void) | undefined + +beforeEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + scratch = mkdtempSync(join(tmpdir(), 'orca-wsl-broken-folder-')) + exitSubprocess = undefined +}) + +afterEach(() => { + Object.defineProperty(process, 'platform', originalPlatform) + rmSync(scratch, { recursive: true, force: true }) + uncSide.path = null + vi.clearAllMocks() +}) + +function probeAnswers(home: string | null) { + return { + environmentResolved: true, + code: home ? 0 : 1, + stdout: home ? `${home}\n/bin/bash\n` : '', + stderr: home ? '' : 'mkdir: cannot create directory: File exists', + timedOut: false + } +} + +function wslRepoPath(distro: string): string { + return `\\\\wsl.localhost\\${distro}\\root\\fixture` +} + +/** What `agent.launch` plans, with its paste for an uncarried prompt. */ +async function planAgentLaunch(distro: string, agent: TuiAgent, prompt: string) { + const repo = { connectionId: null, path: wslRepoPath(distro) } + const platform = getAgentLaunchPlatformForRepo(repo) + const settings = { terminalWindowsShell: 'powershell.exe' } + return planLaunchPrompt({ + ...resolveAgentStartupPlanInputs({ agent, settings, platform, isRemote: false }), + prompt, + host: await probedThisOrcaLaunchHost({ + launchPlatform: platform, + isRemote: false, + settings, + workspacePath: repo.path, + prompt + }), + paste: 'when-host-proves-agent' + }) +} + +function mockSubprocess(): SubprocessHandle { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handle stubs only what session creation calls. + return { + pid: 1, + shellPath: 'C:\\Windows\\System32\\wsl.exe', + getForegroundProcess: vi.fn(() => null), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(() => exitSubprocess?.(1)), + terminateOwnedTree: () => 'unavailable' as const, + forceKill: vi.fn(() => exitSubprocess?.(1)), + signal: vi.fn(), + onData: () => {}, + onExit: (callback: (code: number) => void) => { + exitSubprocess = callback + }, + dispose: vi.fn() + } as SubprocessHandle +} + +/** The spawn as main runs it: its probe, then the daemon's session create, noting a refusal. */ +async function spawnAgentLine( + distro: string, + agent: TuiAgent, + command: string, + unstageableLine?: 'refuse' +) { + const wslDistro = distro + const probe = resolveSpawnWslLaunchDirectory(wslDistro, { command, launchAgent: agent }) + const wslLaunchDirectory: WslLaunchDirectory | undefined = probe ? await probe : undefined + const sub = mockSubprocess() + const host = new TerminalHost({ spawnSubprocess: () => sub }) + try { + await host.createOrAttach({ + sessionId: `s-${distro}-${agent}-${command.length}`, + cols: 80, + rows: 24, + cwd: wslRepoPath(distro), + command, + launchAgent: agent, + ...(wslLaunchDirectory ? { wslLaunchDirectory } : {}), + ...(unstageableLine ? { unstageableLine } : {}), + shellReadySupported: false, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + } catch (error) { + noteLaunchArtifactRefusal({ wslDistro, wslLaunchDirectory }, error) + return { refused: error, typed: vi.mocked(sub.write).mock.calls.map(([data]) => data) } + } + return { refused: null, typed: vi.mocked(sub.write).mock.calls.map(([data]) => data) } +} + +/** What main types for `command` into a shell without bracketed paste. */ +function typedAsMain(command: string): string { + return buildStartupCommandSubmission(command, { bracketedPasteSafe: false }) +} + +async function launch(distro: string, agent: TuiAgent, prompt: string) { + const planned = await planAgentLaunch(distro, agent, prompt) + if (planned?.carry !== 'on-line') { + throw new Error(`expected the line, got ${planned?.carry}`) + } + return { + command: planned.plan.launchCommand, + ...(await spawnAgentLine(distro, agent, planned.plan.launchCommand)) + } +} + +// Why (stack QA 2a): with the distro's ~/.cache/orca unusable, main types the agent line as is (a +// 5-line prompt arrives exact); refusing it made that launch worse than main. +describe('agent.launch into a WSL workspace whose launch folder is unusable', () => { + it.each<[TuiAgent, string, string]>([ + ['claude', 'P25K', P25K], + ['claude', 'ML5', ML5], + ['grok', 'P25K', P25K] + ])( + 'types %s %s as main does when the folder was broken before first use', + async (agent, tag, prompt) => { + const distro = `qa-before-${agent}-${tag}` + runWslProcess.mockResolvedValue(probeAnswers(null)) + const { command, refused, typed } = await launch(distro, agent, prompt) + expect(command).toContain(prompt.split('\n')[0]) + expect(refused).toBeNull() + expect(typed).toEqual([typedAsMain(command)]) + } + ) + + // Why the probe before planning: past the line's ceiling the plan would otherwise pick a launch + // file, which a distro with no folder must refuse. + it("plans a prompt past the line ceiling for main's line on the first launch", async () => { + runWslProcess.mockResolvedValue(probeAnswers(null)) + const prompt = 'QA-STACK P120K one line, inert. '.padEnd(120_000, 'x') + const { command, refused, typed } = await launch('qa-before-p120k', 'claude', prompt) + expect(command).toContain(prompt) + expect(refused).toBeNull() + expect(typed).toEqual([typedAsMain(command)]) + }) + + // Why: main pasted an AI button's or a note's prompt, so that caller asks to be refused rather + // than have a line typed raw that can leave bash waiting. + it('refuses a caller that asked to refuse, and still types a short line', async () => { + runWslProcess.mockResolvedValue(probeAnswers(null)) + const distro = 'qa-before-refuse' + await planAgentLaunch(distro, 'claude', P25K) + const long = await spawnAgentLine(distro, 'claude', `claude '${P25K}'`, 'refuse') + expect(String(long.refused)).toMatch(/launch_file_unavailable/) + expect(long.typed).toEqual([]) + const short = await spawnAgentLine(distro, 'claude', `claude 'fix it'`, 'refuse') + expect(short.refused).toBeNull() + }) + + it('refuses only the launch whose write failed after the probe found the folder, then types as main does', async () => { + const distro = 'qa-after-first-use' + const windowsSide = join(scratch, 'cache') + runWslProcess.mockResolvedValue(probeAnswers('/root')) + uncSide.path = windowsSide + const first = await launch(distro, 'claude', P25K) + expect(first.refused).toBeNull() + expect(first.typed).toEqual([expect.stringMatching(/^\. '\/root\/\.cache\/orca\/orca-launch-/)]) + + // The folder breaks after the probe found it: the probe said yes, so this write's failure is + // the race, refused with nothing typed. + rmSync(windowsSide, { recursive: true, force: true }) + writeFileSync(windowsSide, '') + const raced = await launch(distro, 'claude', P25K) + expect(String(raced.refused)).toMatch(/launch_file_unavailable/) + expect(raced.typed).toEqual([]) + + for (const prompt of [P25K, ML5]) { + const later = await launch(distro, 'claude', prompt) + expect(later.refused).toBeNull() + expect(later.typed).toEqual([typedAsMain(later.command)]) + } + }) +}) diff --git a/src/main/runtime/wsl-pane-launch-carry.test.ts b/src/main/runtime/wsl-pane-launch-carry.test.ts new file mode 100644 index 000000000000..0c1c00017fba --- /dev/null +++ b/src/main/runtime/wsl-pane-launch-carry.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' +import { describeLaunchHost } from '../../shared/launch-host' +import { planLaunchPrompt } from '../../shared/tui-agent-startup' +import { getAgentLaunchPlatformForRepo } from './runtime-agent-launch-resolution' + +// The stack QA's WSL workspace: a folder repo added by its distro path (P8-6). +const WSL_REPO = { connectionId: null, path: '\\\\wsl.localhost\\qasfwin\\root\\fixture' } +const P25K = 'QA-STACK P25K one line, inert. '.padEnd(25_600, 'x') + +function plan(prompt: string, terminalWindowsShell: string) { + const platform = getAgentLaunchPlatformForRepo(WSL_REPO) + return planLaunchPrompt({ + ...resolveAgentStartupPlanInputs({ + agent: 'claude', + settings: { terminalWindowsShell }, + platform, + isRemote: false + }), + prompt, + host: describeLaunchHost({ + launchPlatform: platform, + isRemote: false, + hostPlatform: 'win32', + paired: false + }), + paste: 'when-host-proves-agent' + }) +} + +// Why: the pane runs the distro's POSIX shell, so the Windows shell setting must not decide how the +// line is judged or quoted; quoted for cmd, bash ran the pointer's backticked path and lost it. +describe('a launch into a WSL workspace, whatever the Windows shell setting', () => { + it('is planned for the distro', () => { + expect(getAgentLaunchPlatformForRepo(WSL_REPO)).toBe('linux') + }) + + it.each(['powershell.exe', 'git-bash', 'cmd.exe'])( + 'stages a 25 KB prompt on a POSIX line under %s', + (terminalWindowsShell) => { + const planned = plan(P25K, terminalWindowsShell) + expect(planned?.carry).toBe('on-line') + expect(planned?.carry === 'on-line' && planned.plan.launchCommand).toMatch( + new RegExp(`^claude .* '${P25K}'$`) + ) + } + ) + + it.each(['powershell.exe', 'cmd.exe'])( + 'keeps $ and backticks literal on the line under %s', + (terminalWindowsShell) => { + const prompt = 'print $HOME and `id` as text' + const planned = plan(prompt, terminalWindowsShell) + // POSIX single quotes: bash expands neither `$HOME` nor the backticks inside them. + expect(planned?.carry === 'on-line' && planned.plan.launchCommand).toMatch( + /^claude .* 'print \$HOME and `id` as text'$/ + ) + } + ) +}) + +// Why: a pane whose Windows shell is WSL runs the same POSIX shell, staged by the host. +describe('a WSL pane on a Windows workspace', () => { + it('judges its line by the distro, not by Git Bash', () => { + const planned = planLaunchPrompt({ + ...resolveAgentStartupPlanInputs({ + agent: 'claude', + settings: { terminalWindowsShell: 'wsl.exe' }, + platform: 'win32', + isRemote: false + }), + prompt: P25K, + host: describeLaunchHost({ + launchPlatform: 'win32', + isRemote: false, + hostPlatform: 'win32', + paired: false, + windowsPaneShell: 'wsl.exe' + }), + paste: 'when-host-proves-agent' + }) + expect(planned?.carry).toBe('on-line') + }) +}) diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index 4cfb5572776c..5cfa1c30cabe 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, UnstageableLine } 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' @@ -35,6 +36,8 @@ export type PtyApi = { launchToken?: string launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery + launchFile?: LaunchFile + unstageableLine?: UnstageableLine connectionId?: string | null worktreeId?: string sessionId?: string @@ -127,6 +130,12 @@ export type PtyApi = { } ) => Promise confirmForegroundProcess: (id: string) => Promise + /** What holds a launched agent's terminal, read fresh on its execution host (#24257's guard), + * with the agent named on its command line kept apart from any other process. */ + readLaunchedAgentForeground: ( + id: string, + agent: string + ) => Promise<'launched-agent' | 'other' | 'shell' | 'unknown'> /** Local panes only; never joined 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-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/preload/api/pty-bridge-stream-and-serialization.ts b/src/preload/api/pty-bridge-stream-and-serialization.ts index e0b2e7f2bf67..d6496aadefc8 100644 --- a/src/preload/api/pty-bridge-stream-and-serialization.ts +++ b/src/preload/api/pty-bridge-stream-and-serialization.ts @@ -17,6 +17,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<'launched-agent' | 'other' | '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/relay/pty-handler-startup-command-staging.test.ts b/src/relay/pty-handler-startup-command-staging.test.ts index 672aaf4d9e56..964994cca3be 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, carryInLaunchFile } 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 } = carryInLaunchFile('secret brief') + 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)}'\r` + ) + 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 d107d249d1aa..f052e02356f6 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -55,6 +55,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, @@ -278,6 +285,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). */ @@ -333,6 +342,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') { @@ -359,6 +385,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) @@ -2046,7 +2073,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 @@ -2083,6 +2110,11 @@ export class PtyHandler { openCodeCapabilities, isRelayWslShell(shell) ) + 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) }) @@ -2134,6 +2166,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') } @@ -2162,6 +2195,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() @@ -2207,6 +2241,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/right-sidebar/buildSourceControlAgentDeliveryPlan.ts b/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts index d8320dd10750..c906836cfa24 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 + runtimeEnvironmentId?: string | null } export function buildSourceControlAgentDeliveryPlan({ @@ -26,7 +27,8 @@ export function buildSourceControlAgentDeliveryPlan({ detectedAgents, connectionUnavailable, launchPlatform, - isRemote + isRemote, + runtimeEnvironmentId }: BuildSourceControlAgentDeliveryPlanArgs): SourceControlAgentActionDeliveryPlanState { if (connectionUnavailable) { return buildSourceControlAgentConnectionErrorPlan() @@ -50,7 +52,8 @@ export function buildSourceControlAgentDeliveryPlan({ cmdOverrides: settings?.agentCmdOverrides, terminalWindowsShell: settings?.terminalWindowsShell, platform: launchPlatform, - isRemote + isRemote, + 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 2209c1621fef..08fadee4c257 100644 --- a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts +++ b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts @@ -9,6 +9,8 @@ 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 { 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 +118,8 @@ export function useSourceControlAgentActionStart({ detectedAgents: currentDetectedAgents, connectionUnavailable, launchPlatform, - isRemote + isRemote, + runtimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), worktreeId) }) }, [ @@ -128,7 +131,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 40d65b759e22..544802664df3 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' @@ -22,6 +22,11 @@ import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id' import { beginStructuredAgentSessionProvisionalLaunch } from '@/lib/structured-agent-session-provisional-tab' import { getNewWorkspaceProjectGroupHostId } from '@/lib/new-workspace-project-options' import { useAppStore } from '@/store' +import { clientLaunchHost } from '@/lib/launch-file-host' +import { + composerAgentStartupPlan, + type ComposerAgentStartupPlan +} from '@/lib/composer-agent-startup-plan' import { buildFolderWorkspaceLinkedStartupPlan, getFolderWorkspaceAgentLaunchPlatform, @@ -99,7 +104,7 @@ export async function submitFolderWorkspaceCreate({ isRemote: launchIsRemote, terminalWindowsShell }) - const startupPlan = + const startupPlan: ComposerAgentStartupPlan | null = quickAgent && linkedWorkItem ? buildFolderWorkspaceLinkedStartupPlan({ agent: quickAgent, @@ -114,18 +119,26 @@ export async function submitFolderWorkspaceCreate({ isRemote: launchIsRemote }) : quickAgent - ? buildAgentStartupPlan({ - agent: quickAgent, - prompt: note, - cmdOverrides: agentCmdOverrides ?? {}, - agentArgs, - agentEnv, - sessionOptions, - platform: launchPlatform, - shell: launchShell, - isRemote: launchIsRemote, - allowEmptyPromptLaunch: true - }) + ? composerAgentStartupPlan( + planLaunchPrompt({ + agent: quickAgent, + prompt: note, + cmdOverrides: agentCmdOverrides ?? {}, + agentArgs, + agentEnv, + sessionOptions, + platform: launchPlatform, + shell: launchShell, + isRemote: launchIsRemote, + host: clientLaunchHost({ + runtimeEnvironmentId, + launchPlatform, + isRemote: launchIsRemote + }), + paste: 'when-host-proves-agent' + }), + note + ) : null // Why: the argv-prefill plan carries the draft inside `launchCommand`, so // `startupPlan.draftPrompt` alone can't tell whether this launch has one. @@ -191,6 +204,8 @@ export async function submitFolderWorkspaceCreate({ ...(startupPlan.startupCommandDelivery ? { startupCommandDelivery: startupPlan.startupCommandDelivery } : {}), + ...(startupPlan.launchFile ? { launchFile: startupPlan.launchFile } : {}), + ...(startupPlan.launchPrompt ? { launchPrompt: startupPlan.launchPrompt } : {}), telemetry: { agent_kind: tuiAgentToAgentKind(quickAgent), launch_source: launchSource, @@ -243,7 +258,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 e8c0c2caf4f2..32d40ae4522f 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 { settleRefusedAgentLaunch } from './refused-agent-launch-tab' import type { IpcPtyTransportOptions, PtyConnectResult, PtyTransport } from './pty-transport-types' const SSH_PTY_CONNECTION_MISMATCH_MARKER = 'belongs to SSH connection' @@ -190,6 +192,15 @@ function handleConnectError( if (message.includes('was explicitly killed')) { return undefined } + // 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, tabId } = 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, and is the only error. + settleRefusedAgentLaunch({ tabId, prompt: refusedPrompt }) + return undefined + } 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 667865deb7e1..913c2c00d016 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 @@ -81,6 +81,8 @@ export async function spawnIpcPty( launchToken, launchAgent, startupCommandDelivery, + launchFile, + unstageableLine, connectionId, worktreeId, tabId, @@ -125,6 +127,9 @@ export async function spawnIpcPty( startupCommandDelivery: connectOptions.startupCommandDelivery ?? startupCommandDelivery } : {}), + // Only alongside the queued command that names it by placeholder. + ...(launchFile && !connectOptions.command ? { launchFile } : {}), + ...(unstageableLine && !connectOptions.command ? { unstageableLine } : {}), ...(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 6e8d340b3a37..92e700177885 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -8,6 +8,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, UnstageableLine } from '../../../../shared/launch-prompt-file' import type { TuiAgent } from '../../../../shared/tui-agent' import type { SetupSplitDirection } from '../../../../shared/worktree/launch-types' import type { @@ -26,6 +27,11 @@ export type PtyPaneStartup = { * semantics before the submit Enter. */ delivery?: 'terminal-paste' startupCommandDelivery?: StartupCommandDelivery + launchFile?: LaunchFile + /** The submitted prompt `command` carries; handed back to copy if the host refuses the spawn. */ + launchPrompt?: string + /** See `UnstageableLine`; sent with the spawn. */ + unstageableLine?: UnstageableLine 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..5727b521730a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-startup-command-options.ts @@ -0,0 +1,21 @@ +import type { ConnectPanePtySession } from './connect-pane-pty-session' +import type { UnstageableLine } from '../../../../../shared/launch-prompt-file' + +/** 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 + unstageableLine?: UnstageableLine +} { + if (session.shouldDeliverStartupViaTerminalPaste) { + return {} + } + return { + command: session.paneStartup?.command, + launchFile: session.paneStartup?.launchFile, + launchPrompt: session.paneStartup?.launchPrompt, + unstageableLine: session.paneStartup?.unstageableLine + } +} 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 aa226a3a78ee..2d95cd12b438 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 } : {}), - command: session.shouldDeliverStartupViaTerminalPaste - ? undefined - : session.paneStartup?.command, + ...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..be4c597cacb1 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-transport-launch-refusal.test.ts @@ -0,0 +1,137 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installIpcPtyWindow, restorePtySpecWindow } from './pty-transport-test-harness' +import { describeLaunchFileUnavailable } from '../../../../shared/launch-prompt-file' +import type { PtyPaneStartup } from './pty-connection-types' + +const mocks = vi.hoisted(() => { + const ptyIdsByTabId: Record = {} + return { showNotStarted: vi.fn(), closeTerminalTab: vi.fn(), ptyIdsByTabId } +}) +vi.mock('@/lib/agent-launch-prompt-not-delivered-notice', () => ({ + showAgentLaunchNotStartedNotice: mocks.showNotStarted +})) +vi.mock('@/components/terminal/terminal-tab-actions', () => ({ + closeTerminalTab: mocks.closeTerminalTab +})) +vi.mock('@/store', () => ({ + useAppStore: { getState: () => ({ ptyIdsByTabId: mocks.ptyIdsByTabId }) } +})) + +describe('a pane spawn the host refused for what carries its prompt', () => { + const originalWindow = globalThis.window + + beforeEach(() => { + vi.resetModules() + mocks.showNotStarted.mockReset() + mocks.closeTerminalTab.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' }) + }) + + // Why (stack QA, new agent tab with the temp folder read-only): the empty pane's recovery spawned + // it again, so the notice showed twice, under a red error panel, in a tab left open with nothing. + it('tells the user once, with no error panel, and closes the tab it left empty', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const refusal = new Error(describeLaunchFileUnavailable('EACCES', 'staged-line')) + vi.mocked(window.api.pty.spawn).mockRejectedValue(refusal) + const onError = vi.fn() + const connect = () => + createIpcPtyTransport({ + command: "claude 'a long prompt'", + launchPrompt: 'a long prompt', + tabId: 'tab-refused', + worktreeId: 'wt-1' + }).connect({ url: '', callbacks: { onError } }) + + await connect() + await connect() + + expect(mocks.showNotStarted).toHaveBeenCalledTimes(1) + expect(onError).not.toHaveBeenCalled() + await vi.waitFor(() => + expect(mocks.closeTerminalTab).toHaveBeenCalledWith('tab-refused', { + force: true, + skipRunningProcessConfirm: true, + captureRecentlyClosed: false + }) + ) + }) + + it('leaves a tab that still holds a live terminal', async () => { + mocks.ptyIdsByTabId['tab-with-shell'] = ['pty-live'] + const { createIpcPtyTransport } = await import('./pty-transport') + vi.mocked(window.api.pty.spawn).mockRejectedValueOnce( + new Error(describeLaunchFileUnavailable('EACCES', 'staged-line')) + ) + + await createIpcPtyTransport({ + command: "claude 'a long prompt'", + launchPrompt: 'a long prompt', + tabId: 'tab-with-shell', + worktreeId: 'wt-1' + }).connect({ url: '', callbacks: { onError: vi.fn() } }) + + expect(mocks.showNotStarted).toHaveBeenCalledTimes(1) + // The close reads the store after a lazy import; let it settle before asserting it did not run. + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(mocks.closeTerminalTab).not.toHaveBeenCalled() + }) + + it('sends the caller’s wish for a line the host cannot stage with the spawn', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + vi.mocked(window.api.pty.spawn).mockRejectedValueOnce(new Error('spawn ENOENT')) + + await createIpcPtyTransport({ + command: "claude 'a long prompt'", + launchPrompt: 'a long prompt', + unstageableLine: 'refuse' + }).connect({ url: '', callbacks: { onError: vi.fn() } }) + + expect(window.api.pty.spawn).toHaveBeenCalledWith( + expect.objectContaining({ unstageableLine: 'refuse' }) + ) + }) + + it('carries that wish from the pane’s queued startup to the transport', async () => { + const { paneStartupCommandOptions } = + await import('./pty-connection/pane-startup-command-options') + const paneStartup: PtyPaneStartup = { command: 'claude x', unstageableLine: 'refuse' } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the helper reads only these two members. + const session = { + shouldDeliverStartupViaTerminalPaste: false, + paneStartup + } as unknown as Parameters[0] + + expect(paneStartupCommandOptions(session)).toMatchObject({ unstageableLine: 'refuse' }) + }) + + 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 39f1c7962ef8..bec3dda3c314 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, UnstageableLine } 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' @@ -273,6 +274,11 @@ export type IpcPtyTransportOptions = { launchToken?: string launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery + /** Local spawns only: a remote runtime writes no file the renderer names. */ + launchFile?: LaunchFile + /** See `PtyPaneStartup.launchPrompt`. */ + launchPrompt?: string + unstageableLine?: UnstageableLine connectionId?: string | null executionHostId?: ExecutionHostId | null worktreeId?: string diff --git a/src/renderer/src/components/terminal-pane/refused-agent-launch-tab.ts b/src/renderer/src/components/terminal-pane/refused-agent-launch-tab.ts new file mode 100644 index 000000000000..875e6d49f99a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/refused-agent-launch-tab.ts @@ -0,0 +1,44 @@ +import { showAgentLaunchNotStartedNotice } from '@/lib/agent-launch-prompt-not-delivered-notice' + +/** Tabs whose refused agent launch the user was already told about. */ +const noticedTabIds = new Set() + +/** + * The host refused to start a pane's agent (`launch_file_unavailable`), so nothing ran in it. The + * notice holds the prompt, once per tab: the empty pane's recovery spawns it again, and is refused + * again. A tab left with no live terminal is closed, as main's desktop route closes a refused tab; + * one that still holds a live terminal is the user's, and stays. + */ +export function settleRefusedAgentLaunch(args: { + tabId: string | undefined + prompt: string +}): void { + const { tabId } = args + if (tabId !== undefined && noticedTabIds.has(tabId)) { + return + } + if (tabId !== undefined) { + noticedTabIds.add(tabId) + } + showAgentLaunchNotStartedNotice({ prompt: args.prompt }) + if (tabId !== undefined) { + void closeTabLeftEmpty(tabId).catch((error: unknown) => + console.error('Could not close a refused tab', error) + ) + } +} + +async function closeTabLeftEmpty(tabId: string): Promise { + // Why lazy: the store's terminal slice imports this transport, so a static import is a cycle. + const [{ useAppStore }, { closeTerminalTab }] = await Promise.all([ + import('@/store'), + import('@/components/terminal/terminal-tab-actions') + ]) + if ((useAppStore.getState().ptyIdsByTabId[tabId] ?? []).length === 0) { + closeTerminalTab(tabId, { + force: true, + skipRunningProcessConfirm: true, + captureRecentlyClosed: false + }) + } +} 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 13cf2c225f29..19600a67bdd5 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..a7ed8f7bae68 --- /dev/null +++ b/src/renderer/src/hooks/composer-state/full-creation-startup.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +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' + +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 prompt = 'x'.repeat(200_000) + const startupPlan = composerAgentStartupPlan( + planLaunchPrompt({ + agent: 'claude', + prompt, + cmdOverrides: {}, + platform: 'linux', + isRemote: true, + host: { + paired: false, + provesAgentInFront: true, + takesLaunchFile: true, + windowsPaneShell: null + }, + paste: 'when-host-proves-agent' + }), + prompt + ) + 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) + // 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 43c21661a763..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 @@ -23,6 +23,9 @@ 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.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 a1120fccf4e0..44f0c86e62e4 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' @@ -29,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 @@ -38,6 +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 { clientLaunchHost } 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) { @@ -56,6 +59,7 @@ export function useFullSubmitPreparation(input: FullSubmitPreparationInput) { selectedRepoExecutionHostId, selectedRepoIsGit, selectedRepoIsRemote, + selectedRepoSettings, selectedRepoStartupShell, settings, smartNameMode, @@ -178,29 +182,38 @@ 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 - }) + 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, + host: clientLaunchHost({ + runtimeEnvironmentId: selectedRepoSettings?.activeRuntimeEnvironmentId, + launchPlatform: selectedRepoAgentLaunchPlatform, + isRemote: selectedRepoIsRemote + }), + paste: 'when-host-proves-agent' + }), + submitStartupPrompt + ) const shouldSeedInitialAgentStatus = tuiAgent === 'command-code' && submitStartupPrompt.trim().length > 0 @@ -213,7 +226,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 } : {}), @@ -222,6 +235,7 @@ export function useFullSubmitPreparation(input: FullSubmitPreparationInput) { ...(startupPlan.startupCommandDelivery ? { startupCommandDelivery: startupPlan.startupCommandDelivery } : {}), + ...(startupPlan.launchFile ? { launchFile: startupPlan.launchFile } : {}), telemetry: composerTelemetry } : undefined @@ -256,6 +270,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 554571dc6314..d1c0c66a6cc9 100644 --- a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts +++ b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts @@ -39,6 +39,7 @@ import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' import { useAppStore } from '@/store' import { settleComposerSubmit } from '@/lib/composer-submit-cancellation' import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +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' @@ -138,6 +139,11 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) { platform: selectedRepoAgentLaunchPlatform, shell: selectedRepoStartupShell, isRemote: selectedRepoIsRemote, + 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 486550197814..1517d2079ffa 100644 --- a/src/renderer/src/hooks/composer-state/quick-startup-plan.ts +++ b/src/renderer/src/hooks/composer-state/quick-startup-plan.ts @@ -1,10 +1,14 @@ 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 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,11 +26,13 @@ export type QuickComposerStartupInput = { platform: NodeJS.Platform shell: AgentStartupShell | null | undefined isRemote: boolean + /** The host the launch runs on (`clientLaunchHost`). */ + host: LaunchHost telemetrySource: WorktreeCreationRequest['telemetrySource'] } export type QuickComposerStartup = { - startupPlan: AgentStartupPlan | null + startupPlan: ComposerAgentStartupPlan | null backendStartup: WorktreeCreationRequest['startup'] telemetry: AgentStartedTelemetry | null } @@ -66,13 +72,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 @@ -81,18 +86,22 @@ 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, - 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, + host: input.host, + paste: 'when-host-proves-agent' + }), + prompt + ) if (startupPlan && draftPrompt) { startupPlan.draftPrompt = draftPrompt } @@ -107,7 +116,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 } : {}), @@ -116,6 +125,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 47fc41cca96b..d3f71a4a875a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -722,6 +722,33 @@ "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." + } + } + }, + "exited": { + "notice": { + "message": "The agent exited at startup, before it received your prompt. Copy the prompt and start the agent again." + } + }, + "unconfirmed": { + "notice": { + "message": "Orca couldn't confirm the agent received your prompt. Check the agent before sending it again." + } + } + } } }, "remotePairingCopy": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ee8ad0eb4f7e..21530ce31186 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -551,6 +551,33 @@ "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." + } + } + }, + "exited": { + "notice": { + "message": "El agente se cerró al iniciarse, antes de recibir tu prompt. Copia el prompt e inicia el agente de nuevo." + } + }, + "unconfirmed": { + "notice": { + "message": "Orca no pudo confirmar que el agente recibió tu prompt. Revisa el agente antes de enviarlo de nuevo." + } + } + } } }, "remotePairingCopy": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index c1d2b60f7199..55c52f346a33 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -650,6 +650,33 @@ "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." + } + } + }, + "exited": { + "notice": { + "message": "L’agent s’est arrêté au démarrage, avant de recevoir votre prompt. Copiez le prompt et relancez l’agent." + } + }, + "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." + } + } + } } }, "remotePairingCopy": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index c49555516f1c..8acc75e1ac5b 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -551,6 +551,33 @@ "refreshCliRegistration": "CLI の登録状態を更新してから、もう一度お試しください。" } } + }, + "launch": { + "prompt": { + "not": { + "delivered": { + "notice": { + "message": "Agent は起動しましたが、プロンプトは送信されませんでした。Agent の準備ができたらコピーして貼り付けてください。", + "copy": "プロンプトをコピー" + } + }, + "started": { + "notice": { + "message": "Agent は起動しませんでした。プロンプトを渡すファイルを Orca が書き込めませんでした。プロンプトをコピーして、もう一度起動してください。" + } + } + }, + "exited": { + "notice": { + "message": "Agent は起動時に終了したため、プロンプトを受け取っていません。プロンプトをコピーして、Agent をもう一度起動してください。" + } + }, + "unconfirmed": { + "notice": { + "message": "Agent がプロンプトを受け取ったことを Orca で確認できませんでした。再送する前に Agent を確認してください。" + } + } + } } }, "remotePairingCopy": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 0fdd13b366c5..034084e9f610 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -551,6 +551,33 @@ "refreshCliRegistration": "CLI 등록 상태를 새로 고친 후 다시 시도하세요." } } + }, + "launch": { + "prompt": { + "not": { + "delivered": { + "notice": { + "message": "에이전트가 시작되었지만 프롬프트는 전송되지 않았습니다. 에이전트가 준비되면 복사해서 붙여 넣으세요.", + "copy": "프롬프트 복사" + } + }, + "started": { + "notice": { + "message": "에이전트가 시작되지 않았습니다. Orca가 프롬프트를 담을 파일을 쓰지 못했습니다. 프롬프트를 복사한 뒤 다시 시작하세요." + } + } + }, + "exited": { + "notice": { + "message": "에이전트가 시작 중에 종료되어 프롬프트를 받지 못했습니다. 프롬프트를 복사한 뒤 에이전트를 다시 시작하세요." + } + }, + "unconfirmed": { + "notice": { + "message": "에이전트가 프롬프트를 받았는지 Orca가 확인하지 못했습니다. 다시 보내기 전에 에이전트를 확인하세요." + } + } + } } }, "remotePairingCopy": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index a6e49bbbad4c..02f0e36b227a 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -551,6 +551,33 @@ "refreshCliRegistration": "请刷新 CLI 注册状态后重试。" } } + }, + "launch": { + "prompt": { + "not": { + "delivered": { + "notice": { + "message": "代理已启动,但你的提示词未发送。请复制提示词,并在代理就绪后粘贴。", + "copy": "复制提示词" + } + }, + "started": { + "notice": { + "message": "代理未启动:Orca 无法写入承载你的提示词的文件。请复制提示词并重新启动。" + } + } + }, + "exited": { + "notice": { + "message": "代理在启动时退出,未收到你的提示词。请复制提示词并重新启动代理。" + } + }, + "unconfirmed": { + "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-draft-readiness.test.ts b/src/renderer/src/lib/agent-draft-readiness.test.ts index bfc11476ca60..b279d6fe0928 100644 --- a/src/renderer/src/lib/agent-draft-readiness.test.ts +++ b/src/renderer/src/lib/agent-draft-readiness.test.ts @@ -5,7 +5,7 @@ import { drainPreHandlerPtyData } from '@/components/terminal-pane/pty-pre-handler-buffer' import { OPENCODE_AGENT_ROW_GRACE_MS } from '../../../shared/opencode-agent-row-scanner' -import { waitForAgentDraftInputReady } from './agent-draft-readiness' +import { bracketedPasteStateAfter, waitForAgentDraftInputReady } from './agent-draft-readiness' const testState = vi.hoisted(() => ({ observer: null as ((data: string) => void) | null, @@ -57,6 +57,62 @@ 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) + }) + }) + it('falls back to the box after the grace when OpenCode never paints its agent row', async () => { vi.useFakeTimers() vi.stubGlobal('window', globalThis) diff --git a/src/renderer/src/lib/agent-draft-readiness.ts b/src/renderer/src/lib/agent-draft-readiness.ts index 22e9c2e85356..0b1ff7e9d120 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 @@ -57,19 +69,30 @@ 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, readyAfterMs } = scanner.observe(data) if (ready) { finish(true) return } + 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 (readyAfterMs === null && graceTimer !== null) { window.clearTimeout(graceTimer) graceTimer = null } else if (typeof readyAfterMs === 'number' && graceTimer === null) { graceTimer = window.setTimeout(() => finish(true), readyAfterMs) } - if (shouldArm) { + 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 0c1b3066f171..d0825069da0c 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 () => 'delivered') +})) vi.mock('@/lib/agent-ready-wait', () => ({ waitForAgentReady: vi.fn(async () => ({ ready: true, reason: 'foreground-match' })) })) @@ -79,9 +82,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-placement.test.ts b/src/renderer/src/lib/agent-launch-caller-placement.test.ts index 9cc4fedab4c1..9b91f497c4db 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 () => '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 ed8d689c90df..66116099f145 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 @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { TuiAgent } from '../../../shared/tui-agent' +import { MAX_LINE_PROMPT_BYTES } from '../../../shared/launch-prompt-file' import { callerProfileCases, type AgentLaunchCallerProfile @@ -48,6 +49,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 () => 'delivered') +vi.mock('@/lib/agent-launch-prompt-receipt', () => ({ + waitForLaunchPromptReceipt: mockWaitForLaunchPromptReceipt +})) vi.mock('@/runtime/local-runtime-capabilities', () => ({ readLocalRuntimeCapabilitiesOrUnknown: () => [] })) @@ -69,11 +74,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 +147,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 +156,24 @@ 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 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. - const expectsPromise = + // 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(expectsPromise) - if (expectsPromise) { - await expect(result?.promptDeliveryResult).resolves.toEqual({ - delivered: true, - failureNotified: false - }) - } + expect(result?.promptDeliveryResult !== undefined).toBe(waitsForSubmit) } ) @@ -227,6 +227,83 @@ 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. Why Claude and Codex too: main + // pastes an AI button's prompt, so the agent gets the user's text, never a launch-file pointer. + it.each([ + ['past the argv ceiling', 'gemini', 'x'.repeat(MAX_LINE_PROMPT_BYTES + 1), 'darwin'], + ['a Windows-damaged prompt', 'gemini', 'say "hi"', 'win32'], + ['past the argv ceiling', 'claude', 'x'.repeat(MAX_LINE_PROMPT_BYTES + 1), 'darwin'], + ['a 9 KB multi-line Windows prompt', 'codex', `Fix the checks.\n${'x'.repeat(9_100)}`, 'win32'] + ] as const)( + 'pastes %s for %s, and waits for that paste', + async (_label, agent, prompt, launchPlatform) => { + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + const result = launchAgentInNewTab({ + requestId: 'prompt-carry-243', + agent, + 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('stages a 20 KB AI-button prompt on the agent’s own line on a POSIX host', async () => { + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + const prompt = `Session context:\n${'w'.repeat(20_000)}` + + launchAgentInNewTab({ + requestId: 'prompt-carry-269', + agent: 'codex', + worktreeId: 'wt-1', + prompt, + promptDelivery: 'submit-after-ready', + launchPlatform: 'darwin' + }) + + expect(queuedStartupCommand(store)).toContain('w'.repeat(100)) + expect(queuedStartupPayload(store)?.launchFile).toBeUndefined() + expect(mockPasteDraftWhenAgentReady).not.toHaveBeenCalled() + }) + + // Why (stack QA 2a follow-up): main pasted these prompts, so a host that cannot stage the line + // refuses it with the prompt to copy instead of typing it raw; other callers keep main's line. + it.each<['submit-after-ready' | 'auto-submit', 'refuse' | undefined]>([ + ['submit-after-ready', 'refuse'], + ['auto-submit', undefined] + ])( + 'asks the host to refuse an unstageable line only for %s', + async (promptDelivery, expected) => { + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ + requestId: 'prompt-carry-292', + agent: 'codex', + worktreeId: 'wt-1', + prompt: `Session context:\n${'w'.repeat(20_000)}`, + promptDelivery, + launchPlatform: 'darwin' + }) + + expect(queuedStartupPayload(store)?.unstageableLine).toBe(expected) + } + ) + it('mirrors an argv-carried draft into the chat composer', async () => { const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') @@ -263,9 +340,10 @@ 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({ requestId: 'request-6', - agent: 'codex', + agent: 'amp', worktreeId: 'wt-1', prompt: PROMPT, promptDelivery: 'submit-after-ready', diff --git a/src/renderer/src/lib/agent-launch-platform.test.ts b/src/renderer/src/lib/agent-launch-platform.test.ts new file mode 100644 index 000000000000..e2f5fd54ddd8 --- /dev/null +++ b/src/renderer/src/lib/agent-launch-platform.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from 'vitest' +import { getAgentLaunchPlatformForRepo } from './agent-launch-platform' + +vi.mock('@/lib/new-workspace', () => ({ CLIENT_PLATFORM: 'win32' })) + +// Why (stack QA P8-6): a pane in a WSL path runs the distro's POSIX shell, so the desktop must plan +// its line for that shell, not for the Windows shell setting. +describe('the platform a desktop launch is planned for', () => { + it('is the distro for a workspace in a WSL path', () => { + expect( + getAgentLaunchPlatformForRepo({ + connectionId: null, + path: '\\\\wsl.localhost\\qasfwin\\root\\fixture' + }) + ).toBe('linux') + }) + + it('stays this machine for a native path', () => { + expect(getAgentLaunchPlatformForRepo({ connectionId: null, path: 'C:\\src\\repo' })).toBe( + 'win32' + ) + }) +}) diff --git a/src/renderer/src/lib/agent-launch-platform.ts b/src/renderer/src/lib/agent-launch-platform.ts index 0788610892bf..1d63585c998e 100644 --- a/src/renderer/src/lib/agent-launch-platform.ts +++ b/src/renderer/src/lib/agent-launch-platform.ts @@ -1,4 +1,5 @@ import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path' +import { isWslUncPath } from '../../../shared/wsl-paths' import { CLIENT_PLATFORM } from '@/lib/new-workspace' import type { AppState } from '@/store' import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime' @@ -11,7 +12,12 @@ export function getAgentLaunchPlatformForRepo( if (projectRuntime?.status === 'repair-required') { return projectRuntime.repair.preferredRuntime.kind === 'wsl' ? 'linux' : CLIENT_PLATFORM } - if (projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl') { + // Why a WSL path: the pane spawns in the distro's POSIX shell, whatever the Windows shell + // setting, so its line is judged and quoted for that shell. + if ( + (projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl') || + isWslUncPath(repo.path) + ) { return 'linux' } return CLIENT_PLATFORM 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..0f212a715198 --- /dev/null +++ b/src/renderer/src/lib/agent-launch-prompt-not-delivered-notice.ts @@ -0,0 +1,92 @@ +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 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 agent had the prompt on its command line but exited at startup, before it read it. It did + * not really start, so the user is handed the text to start it again with. + */ +export function showAgentLaunchExitedNotice(args: { agent: TuiAgent; prompt: string }): void { + showPromptCopyNotice( + translate( + 'auto.lib.agent.launch.prompt.exited.notice.message', + 'The agent exited at startup, before it received your prompt. Copy the prompt and start the agent again.' + ), + args.prompt + ) + track('agent_error', { error_class: 'unknown', 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-launch-prompt-receipt.test.ts b/src/renderer/src/lib/agent-launch-prompt-receipt.test.ts new file mode 100644 index 000000000000..cac2ab74c2bc --- /dev/null +++ b/src/renderer/src/lib/agent-launch-prompt-receipt.test.ts @@ -0,0 +1,241 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +type ReceiptState = { + tabsByWorktree: Record + ptyIdsByTabId: Record + agentStatusByPaneKey: Record + settings: Record +} + +const mocks = vi.hoisted(() => { + const empty = (): ReceiptState => ({ + tabsByWorktree: {}, + ptyIdsByTabId: {}, + agentStatusByPaneKey: {}, + settings: {} + }) + let state = empty() + 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 = empty() + }, + readiness: 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: () => ({}) +})) +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ isRemoteRuntimePtyId: () => false })) + +import { waitForLaunchPromptReceipt } from './agent-launch-prompt-receipt' + +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().mockResolvedValue(true) + mocks.readForeground.mockReset().mockResolvedValue('launched-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 () => { + mocks.readiness.mockReturnValue(new Promise(() => {})) + const pending = receipt() + mocks.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } }) + mocks.setState({ agentStatusByPaneKey: { [PANE]: { updatedAt: 150 } } }) + await expect(pending).resolves.toBe('delivered') + }) + + // 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 }) + }) + + // Why: the agent had the prompt on its line and quit at startup, before reading it. Only the + // agent itself seen in front, then the shell, proves that. + it('reports the agent exited when the agent was seen in front and then the shell', async () => { + vi.useFakeTimers() + let ready: () => void = () => {} + mocks.readiness.mockReturnValue(new Promise((resolve) => (ready = resolve))) + mocks.readForeground.mockResolvedValueOnce('launched-agent').mockResolvedValue('shell') + const pending = receipt() + mocks.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } }) + await vi.advanceTimersByTimeAsync(250) + ready() + await vi.advanceTimersByTimeAsync(250) + await expect(pending).resolves.toBe('agent-exited') + }) + + // Why (stack QA, slow shell startup): the shell drew its prompt and looked ready before it ran + // the launch line, and one read finding it in front was taken for an agent that had exited. + it('keeps reading through a slow shell startup until the agent is in front', async () => { + vi.useFakeTimers() + let ready: () => void = () => {} + mocks.readiness.mockReturnValue(new Promise((resolve) => (ready = resolve))) + // The shell's startup file runs `sleep` in front, then the shell draws its prompt (and looks + // ready) before it runs the launch line, then the agent starts. + mocks.readForeground + .mockResolvedValueOnce('other') + .mockResolvedValueOnce('shell') + .mockResolvedValueOnce('shell') + .mockResolvedValueOnce('shell') + .mockResolvedValue('launched-agent') + mocks.setState({ agentStatusByPaneKey: { [PANE]: { updatedAt: 50 } } }) + const pending = receipt() + mocks.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } }) + await vi.advanceTimersByTimeAsync(100) + ready() + await vi.advanceTimersByTimeAsync(10_000) + await expect(pending).resolves.toBe('delivered') + expect(mocks.readForeground.mock.calls.length).toBeGreaterThanOrEqual(5) + }) + + it('is unconfirmed, never exited, when the shell stays in front with no sign the agent ran', async () => { + vi.useFakeTimers() + mocks.readForeground.mockResolvedValue('shell') + const pending = receipt() + mocks.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } }) + let settled: string | null = null + void pending.then((value) => (settled = value)) + await vi.advanceTimersByTimeAsync(29_000) + expect(settled).toBeNull() + await vi.advanceTimersByTimeAsync(2_500) + expect(settled).toBe('unconfirmed') + // Why: each read forks a `ps`; backed off, the whole wait costs a few dozen, not 120. + expect(mocks.readForeground.mock.calls.length).toBeLessThanOrEqual(20) + }) + + // Why: #24257's predicate finds an agent behind a wrapper or a command override as another + // process; once the agent looks ready that counts as delivered, as before. + it('is delivered on another process in front once the agent looks ready', async () => { + mocks.readForeground.mockResolvedValue('other') + const pending = receipt() + mocks.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } }) + await expect(pending).resolves.toBe('delivered') + }) + + it('does not count another process as the agent having run', async () => { + vi.useFakeTimers() + let ready: () => void = () => {} + mocks.readiness.mockReturnValue(new Promise((resolve) => (ready = resolve))) + mocks.readForeground.mockResolvedValueOnce('other').mockResolvedValue('shell') + const pending = receipt() + mocks.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } }) + await vi.advanceTimersByTimeAsync(300) + ready() + let settled: string | null = null + void pending.then((value) => (settled = value)) + await vi.advanceTimersByTimeAsync(5_000) + expect(settled).toBeNull() + }) + + 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('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 the agent exited when the PTY exits after the agent was seen', async () => { + vi.useFakeTimers() + mocks.readiness.mockReturnValue(new Promise(() => {})) + const pending = receipt() + mocks.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } }) + await vi.advanceTimersByTimeAsync(0) + mocks.setState({ ptyIdsByTabId: { 'tab-1': [] } }) + await expect(pending).resolves.toBe('agent-exited') + }) + + it('is unconfirmed when the PTY exits before the agent was ever seen', async () => { + mocks.readiness.mockReturnValue(new Promise(() => {})) + mocks.readForeground.mockReturnValue(new Promise(() => {})) + const pending = receipt() + mocks.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } }) + mocks.setState({ ptyIdsByTabId: { 'tab-1': [] } }) + await expect(pending).resolves.toBe('unconfirmed') + }) + + 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 pending = receipt() + mocks.setState({ tabsByWorktree: { 'wt-1': [] } }) + await expect(pending).resolves.toBe('not-delivered') + }) + + // Why (final review P3-3): the launch seeds Command Code's working row from its own prompt. + it('does not take Command Code’s seeded status row as its turn', async () => { + vi.useFakeTimers() + mocks.readiness.mockReturnValue(new Promise(() => {})) + mocks.readForeground.mockResolvedValue('shell') + const pending = waitForLaunchPromptReceipt({ + tabId: 'tab-1', + agent: 'command-code', + launchedAt: 100 + }) + mocks.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } }) + mocks.setState({ agentStatusByPaneKey: { [PANE]: { updatedAt: 150 } } }) + let settled: string | null = null + void pending.then((value) => (settled = value)) + await vi.advanceTimersByTimeAsync(1_000) + expect(settled).toBeNull() + }) +}) 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..8710fac4fc67 --- /dev/null +++ b/src/renderer/src/lib/agent-launch-prompt-receipt.ts @@ -0,0 +1,166 @@ +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 { 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' | 'agent-exited' | 'unconfirmed' + +/** How long a hook turn may still arrive after a read that could not tell. */ +const HOOK_GRACE_AFTER_UNKNOWN_READ_MS = 2000 +/** Reads start this often and back off: each forks one `ps` on the execution host. */ +const FIRST_READ_INTERVAL_MS = 250 +const MAX_READ_INTERVAL_MS = 2000 +/** How long after the ready signal the shell may stay in front before the launch line runs: a + * slow shell startup can draw its prompt, and look ready, before it types the line. */ +const LAUNCH_LINE_WAIT_AFTER_READY_MS = 30_000 + +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)) +} + +type TerminalForeground = 'launched-agent' | 'other' | 'shell' | 'unknown' + +async function readTerminalForeground(ptyId: string, agent: TuiAgent): Promise { + // 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, read with #24257's + * crash-guard predicate (`readLaunchedAgentForeground`), which tells the launched agent named on its + * command line from any other process that is not the shell. Delivered on the agent's own hook turn + * on the tab, or on a process other than the shell in front once the agent looks ready (one behind + * a wrapper or a command override counts); the ready signal never counts on its own, and bracketed + * paste turned off (`2004l`) revokes it. Not delivered when the PTY never spawns (a + * refused launch file included) or the tab closes. Agent exited only on proof it ran and ended: + * the launched agent was in front, then the shell came back or the PTY exited. A shell in front + * before then is a launch line not yet run (a slow shell startup), so the reads go on until the + * agent is in front, or for 30 s after the ready signal. Unconfirmed when nothing proves either, + * as on a Windows host without hooks. + */ +export function waitForLaunchPromptReceipt(args: { + tabId: string + agent: TuiAgent + launchedAt: number +}): Promise { + const { tabId, agent, launchedAt } = args + return new Promise((resolve) => { + let settled = false + let boundPtyId: string | null = null + let unsubscribe: (() => void) | null = null + const timers: number[] = [] + const finish = (receipt: LaunchPromptReceipt): void => { + if (settled) { + return + } + settled = true + unsubscribe?.() + timers.forEach((timer) => window.clearTimeout(timer)) + resolve(receipt) + } + let agentSeen = false + const watchForeground = (ptyId: string): void => { + const config = TUI_AGENT_CONFIG[agent] + let ready = false + let graceArmed = false + let interval = FIRST_READ_INTERVAL_MS + let reading = false + let nextRead: number | null = null + const read = async (): Promise => { + nextRead = null + reading = true + const foreground = await readTerminalForeground(ptyId, agent) + reading = false + if (settled) { + return + } + agentSeen ||= foreground === 'launched-agent' + if ((foreground === 'launched-agent' || foreground === 'other') && ready) { + finish('delivered') + } else if (foreground === 'shell' && agentSeen) { + finish('agent-exited') + } else { + if (foreground === 'unknown' && ready && !graceArmed) { + graceArmed = true + timers.push( + window.setTimeout(() => finish('unconfirmed'), HOOK_GRACE_AFTER_UNKNOWN_READ_MS) + ) + } + nextRead = window.setTimeout(() => void read(), interval) + timers.push(nextRead) + interval = Math.min(interval * 2, MAX_READ_INTERVAL_MS) + } + } + void waitForAgentDraftInputReady( + ptyId, + resolveDraftPasteReadyTimeoutMs(agent), + config.draftPasteReadySignal ?? 'render-quiet-after-bracketed-paste', + getSettingsForAgentTabRuntimeOwner(tabId), + { revokeOnBracketedPasteOff: true } + ) + .then(() => { + ready = true + timers.push( + window.setTimeout(() => finish('unconfirmed'), LAUNCH_LINE_WAIT_AFTER_READY_MS) + ) + // Why: the read the ready signal times runs now, not at the backed-off interval. + interval = FIRST_READ_INTERVAL_MS + if (!reading && !settled) { + if (nextRead !== null) { + window.clearTimeout(nextRead) + } + void read().catch(() => finish('unconfirmed')) + } + }) + .catch(() => finish('unconfirmed')) + void read().catch(() => finish('unconfirmed')) + } + let scannedStatus: AppState['agentStatusByPaneKey'] | null = null + // Why: Command Code has no hook; its launch seeds a working row from the prompt itself (the new + // agent tab and workspace creation), which proves nothing, so only the foreground read does. + const statusRowProvesTurn = agent !== 'command-code' + 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 (statusRowProvesTurn && hookReportedTurnOnTab(state, tabId, launchedAt)) { + finish('delivered') + return + } + } + const ptyId = state.ptyIdsByTabId[tabId]?.[0] + if (ptyId && !boundPtyId) { + boundPtyId = ptyId + watchForeground(ptyId) + } else if (!tabExists(state, tabId)) { + finish('not-delivered') + } else if (boundPtyId && !ptyId) { + finish(agentSeen ? 'agent-exited' : 'unconfirmed') + } + } + unsubscribe = useAppStore.subscribe(observe) + timers.push( + window.setTimeout(() => { + if (!boundPtyId) { + finish('not-delivered') + } + }, 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 f0fc547e3585..db07445b7bf7 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -43,7 +43,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 @@ -134,15 +134,23 @@ export async function pasteDraftWhenAgentReady(args: { onUnconfirmedDelivery?.() } - return await sendBracketedPasteToAgent({ - settings, - ptyId, - content, - submit: submit === true, - agent, - // Why launch: this delivers the prompt or draft an agent is started with. - inputKind: 'launch' - }) + return await sendLaunchPaste( + { settings, ptyId, content, submit: submit === true, agent }, + onTimeout + ) +} + +/** Why launch: this delivers the prompt or draft an agent is started with. A write that never + * landed drops the text as surely as a readiness timeout. */ +async function sendLaunchPaste( + args: Omit[0], 'inputKind'>, + onTimeout: (() => void) | undefined +): Promise { + const sent = await sendBracketedPasteToAgent({ ...args, inputKind: 'launch' }) + if (!sent) { + onTimeout?.() + } + return sent } export async function pasteDraftToAgentPtyWhenReady(args: { @@ -188,15 +196,10 @@ export async function pasteDraftToAgentPtyWhenReady(args: { onUnconfirmedDelivery?.() } - return await sendBracketedPasteToAgent({ - settings, - ptyId, - content, - submit: submit === true, - agent, - // Why launch: this delivers the prompt or draft an agent is started with. - inputKind: 'launch' - }) + return await sendLaunchPaste( + { settings, ptyId, content, submit: submit === true, agent }, + onTimeout + ) } export async function submitPromptToAgentPty(args: { 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/ai-vault-tab-title-requests.ts b/src/renderer/src/lib/ai-vault-tab-title-requests.ts index 1efeb48e0014..92ab347a08c3 100644 --- a/src/renderer/src/lib/ai-vault-tab-title-requests.ts +++ b/src/renderer/src/lib/ai-vault-tab-title-requests.ts @@ -14,6 +14,8 @@ export type AiVaultTitleRequest = { providerSession: AgentProviderSessionMetadata refresh: boolean tabId: string + /** The pane the session runs in, so its host can title a launch file's session by its prompt. */ + paneKey: string worktreeId: string } @@ -62,6 +64,7 @@ function registerCandidate( providerSession: args.providerSession, refresh: args.refresh, tabId, + paneKey: args.paneKey, worktreeId, priority }) diff --git a/src/renderer/src/lib/ai-vault-tab-title-sync.test.ts b/src/renderer/src/lib/ai-vault-tab-title-sync.test.ts index a062b9df9062..217d12d46721 100644 --- a/src/renderer/src/lib/ai-vault-tab-title-sync.test.ts +++ b/src/renderer/src/lib/ai-vault-tab-title-sync.test.ts @@ -192,6 +192,8 @@ describe('AI Vault tab title sync', () => { { agent, sessionId: `${agent}-session`, + // Why: the host titles a launch file's session by the prompt it kept for this pane. + paneKey: 'tab-1:leaf-1', transcriptPath: `/sessions/${agent}.jsonl` } ] @@ -373,6 +375,7 @@ describe('AI Vault tab title sync', () => { providerSession: { key: 'session_id', id: `session-${index}` }, refresh: true, tabId: `tab-${index}`, + paneKey: `tab-${index}:leaf-${index}`, worktreeId: `worktree-${index}` }) const groups = batchAiVaultTitleRequests( @@ -391,6 +394,7 @@ describe('AI Vault tab title sync', () => { providerSession: { key: 'session_id' as const, id: `session-${index}` }, refresh: true, tabId: `tab-${index}`, + paneKey: `tab-${index}:leaf-${index}`, worktreeId: `worktree-${index}` }) const requests = [ diff --git a/src/renderer/src/lib/ai-vault-tab-title-sync.ts b/src/renderer/src/lib/ai-vault-tab-title-sync.ts index bac035ff2652..bf0457f2a6a4 100644 --- a/src/renderer/src/lib/ai-vault-tab-title-sync.ts +++ b/src/renderer/src/lib/ai-vault-tab-title-sync.ts @@ -94,6 +94,7 @@ export function startAiVaultTabTitleSync(dependencies: SyncDependencies): () => requests: requests.map((request) => ({ agent: request.agent, sessionId: request.providerSession.id, + paneKey: request.paneKey, ...(request.providerSession.transcriptPath ? { transcriptPath: request.providerSession.transcriptPath } : {}) 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/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-remote.test.ts b/src/renderer/src/lib/launch-agent-background-session-remote.test.ts index e6480d197f7a..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 @@ -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,108 @@ 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 long prompt to an old paired Linux host, which may neither stage it nor read a file', async () => { + useRemoteAgentBackgroundRuntime(state) + mockGetAgentLaunchPlatformForRepo.mockReturnValue('linux') + 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 }) + ) + }) + + // 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({ + 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..9c6ba6f27140 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -1,5 +1,7 @@ import { useAppStore } from '@/store' -import { buildAgentStartupPlan } from '@/lib/tui-agent-startup' +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 { LaunchAgentBackgroundSessionArgs, LaunchAgentBackgroundSessionResult @@ -68,24 +70,49 @@ 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' - const pasteDraftAfterLaunch = hasPrompt && isFollowupPath ? trimmedPrompt : null - const startupPlan = buildAgentStartupPlan({ + // Route by the worktree's owner host, not the focused runtime. + const ownerSettings = getSettingsForWorktreeRuntimeOwner(store, worktreeId) + const runtimeTarget = getActiveRuntimeTarget(ownerSettings) + const planned = planLaunchPrompt({ agent, - prompt: hasPrompt && !isFollowupPath ? trimmedPrompt : '', + prompt: trimmedPrompt, cmdOverrides, agentArgs, agentEnv, platform: launchPlatform, shell: startupShell, isRemote, - allowEmptyPromptLaunch: !hasPrompt || isFollowupPath + host: clientLaunchHost({ + runtimeEnvironmentId: ownerSettings?.activeRuntimeEnvironmentId, + launchPlatform, + isRemote + }), + paste: 'when-host-proves-agent' }) - if (!startupPlan) { + if (!planned) { return 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 = trimmedPrompt && isFollowupPath ? trimmedPrompt : promptLeftForPaste // A hidden run tab must never be store-visible without its PTY (#2989). const { reservedTabId, leafId, launchToken, launchRegistration, paneEnv } = @@ -98,10 +125,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 @@ -155,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, @@ -165,9 +188,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 +212,7 @@ export async function launchAgentBackgroundSession( ? { startupCommandDelivery: startupPlan.startupCommandDelivery } : {}), env: paneEnv, + ...(launchFile ? { launchFile } : {}), launchConfig: startupPlan.launchConfig, launchToken, launchAgent: agent, @@ -222,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-windows-quoting.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab-windows-quoting.test.ts index 8704b28ad157..284023dcf192 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 @@ -182,6 +182,28 @@ describe('launchAgentInNewTab Windows shell quoting', () => { expect(mockCreateWebRuntimeAgentSessionTerminalWithLaunchDraft).not.toHaveBeenCalled() }) + // Why: a 9 KB multi-line PowerShell line was measured losing its line breaks; main pasted it. + it('pastes a long 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.\n${'Then push. '.repeat(900)}Done.` + + launchAgentInNewTab({ + requestId: 'prompt-carry-190', + 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-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index adf90ead68bf..111d52e3cce5 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,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { toAppSshPtyId } from '../../../shared/ssh-pty-id' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import { MAX_LINE_PROMPT_BYTES } from '../../../shared/launch-prompt-file' const mockCreateTab = vi.fn() const mockQueueTabStartupCommand = vi.fn() @@ -15,8 +15,7 @@ const mockMarkNativeChatLaunchPromptFailed = vi.fn() const mockTrack = vi.fn() const mockToastMessage = vi.fn() const mockWaitForAgentReady = vi.fn() - -const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const mockWaitForLaunchPromptReceipt = vi.fn() const store = { activeRepoId: 'repo-1', @@ -120,6 +119,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 @@ -144,9 +147,15 @@ const COMMAND_CODE_CLICK = { worktreeId: 'wt-1' } as const +/** One click that launches Amp in wt-1, which takes its prompt only after it starts. */ +const AMP_CLICK = { requestId: 'amp-click', agent: 'amp', worktreeId: 'wt-1' } as const +const CODEX_CLICK = { requestId: 'codex-click', agent: 'codex', worktreeId: 'wt-1' } as const +const CLAUDE_CLICK = { requestId: 'claude-click', agent: 'claude', worktreeId: 'wt-1' } as const + describe('launchAgentInNewTab', () => { beforeEach(() => { vi.clearAllMocks() + mockWaitForLaunchPromptReceipt.mockResolvedValue('delivered') mockIsWebRuntimeSessionActive.mockReturnValue(false) mockCreateWebRuntimeSessionTerminal.mockResolvedValue({ status: 'created' }) mockCreateWebRuntimeAgentSessionTerminalWithLaunchDraft.mockResolvedValue({ status: 'created' }) @@ -249,12 +258,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', @@ -297,6 +308,28 @@ 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({ + ...CODEX_CLICK, + prompt: 'y'.repeat(MAX_LINE_PROMPT_BYTES + 1), + promptDelivery: 'auto-submit' + }) + + 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: {}, @@ -679,8 +712,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({ @@ -688,100 +720,48 @@ 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')] } + + // 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 }) - await Promise.resolve() - await Promise.resolve() - + 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( 'tab-1', expect.objectContaining({ - command: "command-code --trust '--yolo'" - }) - ) - expect(mockPasteDraftWhenAgentReady).toHaveBeenCalledWith( - expect.objectContaining({ - tabId: 'tab-1', - content: 'large generated prompt', - agent: 'command-code', - submit: true, - forcePaste: true + command: "command-code --trust '--yolo' 'large generated prompt'", + initialAgentStatus: { agent: 'command-code', prompt: 'large generated prompt' } }) ) - 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({ - ...COMMAND_CODE_CLICK, - 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({ - ...COMMAND_CODE_CLICK, + ...AMP_CLICK, 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 () => { @@ -793,7 +773,7 @@ describe('launchAgentInNewTab', () => { const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') const result = launchAgentInNewTab({ - ...COMMAND_CODE_CLICK, + ...AMP_CLICK, prompt: 'large generated prompt', promptDelivery: 'submit-after-ready' }) @@ -803,7 +783,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' }) }) ) }) @@ -817,7 +798,7 @@ describe('launchAgentInNewTab', () => { const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') const result = launchAgentInNewTab({ - ...COMMAND_CODE_CLICK, + ...AMP_CLICK, prompt: 'large generated prompt', promptDelivery: 'submit-after-ready' }) @@ -838,7 +819,7 @@ describe('launchAgentInNewTab', () => { const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') const result = launchAgentInNewTab({ - ...COMMAND_CODE_CLICK, + ...AMP_CLICK, prompt: 'large generated prompt', promptDelivery: 'submit-after-ready' }) @@ -859,7 +840,7 @@ describe('launchAgentInNewTab', () => { const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') const result = launchAgentInNewTab({ - ...COMMAND_CODE_CLICK, + ...AMP_CLICK, prompt: 'large generated prompt', promptDelivery: 'submit-after-ready' }) @@ -871,7 +852,42 @@ describe('launchAgentInNewTab', () => { expect(mockToastMessage).not.toHaveBeenCalled() }) - it('queues per-launch CLI arguments without putting generated prompts in argv', async () => { + it('hands a typed 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(MAX_LINE_PROMPT_BYTES)}` + + launchAgentInNewTab({ + ...CODEX_CLICK, + prompt, + promptDelivery: 'auto-submit' + }) + + 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 }) + 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('not-delivered') + const onPromptDelivered = vi.fn() + + const result = launchAgentInNewTab({ + ...CLAUDE_CLICK, + 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') launchAgentInNewTab({ @@ -886,7 +902,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 87ddca84bd2b..1a9b83bb9f98 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -3,11 +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 -} from '@/lib/agent-launch-prompt-delivery' +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' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' @@ -18,7 +15,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' @@ -164,12 +160,20 @@ 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 hostPublishesTab = isWebRuntimeSessionActive(runtimeEnvironmentId) + const { startupPlan, launchFile, unstageableLine, pasteDraftAfterLaunch, submitPastedPrompt } = + planLaunchAgentStartupPrompt({ + base: startupPlanBase, + prompt: trimmedPrompt, + promptDelivery, + isFollowupPath, + host: clientLaunchHost({ + runtimeEnvironmentId, + launchPlatform: resolvedLaunchPlatform, + isRemote + }) + }) let promptDeliveryResult: Promise<{ delivered: boolean; failureNotified: boolean }> | undefined if (!startupPlan) { @@ -209,8 +213,7 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent return structured && { ...structured, startupPlan } } - const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(store, worktreeId) - if (isWebRuntimeSessionActive(runtimeEnvironmentId)) { + if (hostPublishesTab) { if (beforeSurfaceOpen?.({ kind: 'host-published' }) === false) { return null } @@ -244,6 +247,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, { @@ -267,7 +271,16 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent ...(startupPlan.startupCommandDelivery ? { startupCommandDelivery: startupPlan.startupCommandDelivery } : {}), - ...(agent === 'command-code' && hasPrompt && promptDelivery === 'auto-submit' + ...(launchFile ? { launchFile } : {}), + ...(hasPrompt && pasteDraftAfterLaunch === null && promptDelivery !== 'draft' + ? { launchPrompt: trimmedPrompt } + : {}), + ...(unstageableLine ? { unstageableLine } : {}), + // 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: { @@ -276,49 +289,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. - 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 }) - } - if (pasteDraftAfterLaunch !== null) { - const timeoutNotice = createPasteReadinessTimeoutNotice({ - worktreeId, - tabId: tab.id, - agent, - submitted: submitPastedPrompt - }) - const deliveryPromise = deliverLaunchPromptToAgentTab({ - tabId: tab.id, - content: pasteDraftAfterLaunch, - agent, - submit: submitPastedPrompt, - 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() } - }) - 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.test.ts b/src/renderer/src/lib/launch-agent-new-tab-prompt-delivery.test.ts new file mode 100644 index 000000000000..89a4362cd21e --- /dev/null +++ b/src/renderer/src/lib/launch-agent-new-tab-prompt-delivery.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + receipt: vi.fn(), + exited: vi.fn(), + unconfirmed: vi.fn(), + notDelivered: vi.fn() +})) + +vi.mock('@/lib/agent-launch-prompt-receipt', () => ({ waitForLaunchPromptReceipt: mocks.receipt })) +vi.mock('@/lib/agent-launch-prompt-not-delivered-notice', () => ({ + showAgentLaunchExitedNotice: mocks.exited, + showAgentLaunchPromptUnconfirmedNotice: mocks.unconfirmed, + showAgentLaunchPromptNotDeliveredNotice: mocks.notDelivered +})) +vi.mock('@/lib/agent-launch-prompt-delivery', () => ({ + deliverLaunchPromptToAgentTab: vi.fn(), + seedNativeChatLaunchDraftForAgentTab: vi.fn(), + seedNativeChatLaunchPromptForAgentTab: vi.fn() +})) +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'pty-1' }] } }) + } +})) +vi.mock('@/lib/telemetry', () => ({ track: vi.fn(), tuiAgentToAgentKind: vi.fn() })) + +import { deliverNewTabLaunchPrompt } from './launch-agent-new-tab-prompt-delivery' + +function deliver(promptDelivery: 'submit-after-ready' | 'auto-submit' = 'submit-after-ready') { + return deliverNewTabLaunchPrompt({ + worktreeId: 'wt-1', + tabId: 'tab-1', + agent: 'claude', + prompt: 'fix the failing checks', + promptDelivery, + pasteDraftAfterLaunch: null, + submitPastedPrompt: false, + promptInLaunchFile: false, + launchedAt: 0 + }) +} + +// Why: the notice must say what happened (stack QA 1.3); "the agent started, but your prompt +// wasn't sent" was shown for an agent that crashed with the prompt already on its command line. +describe('the notice for a prompt the launch line carried', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('says the agent exited at startup when it quit before reading the prompt', async () => { + mocks.receipt.mockResolvedValue('agent-exited') + await expect(deliver()).resolves.toEqual({ delivered: false, failureNotified: true }) + expect(mocks.exited).toHaveBeenCalledWith({ + agent: 'claude', + prompt: 'fix the failing checks' + }) + expect(mocks.notDelivered).not.toHaveBeenCalled() + }) + + it('keeps "wasn’t sent" for a prompt that never reached a running agent', async () => { + mocks.receipt.mockResolvedValue('not-delivered') + await expect(deliver()).resolves.toMatchObject({ delivered: false }) + expect(mocks.notDelivered).toHaveBeenCalled() + expect(mocks.exited).not.toHaveBeenCalled() + }) + + // Why (final review P1-1): main reported nothing for a launch nothing waits on, and a Windows host + // without hooks can never confirm one, so that notice would follow every such launch. + it('stays silent for an unconfirmed prompt nothing waits on, and says so when an action waits', async () => { + mocks.receipt.mockResolvedValue('unconfirmed') + deliver('auto-submit') + await vi.waitFor(() => expect(mocks.receipt).toHaveBeenCalled()) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(mocks.unconfirmed).not.toHaveBeenCalled() + await expect(deliver('submit-after-ready')).resolves.toEqual({ + delivered: false, + failureNotified: true + }) + expect(mocks.unconfirmed).toHaveBeenCalledTimes(1) + }) +}) 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..9d33f66b6729 --- /dev/null +++ b/src/renderer/src/lib/launch-agent-new-tab-prompt-delivery.ts @@ -0,0 +1,105 @@ +import { createPasteReadinessTimeoutNotice } from '@/lib/launch-agent-paste-timeout-notice' +import { waitForLaunchPromptReceipt } from '@/lib/agent-launch-prompt-receipt' +import { + showAgentLaunchExitedNotice, + showAgentLaunchPromptUnconfirmedNotice +} from '@/lib/agent-launch-prompt-not-delivered-notice' +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 result = + pasteDraftAfterLaunch === null + ? waitForLaunchPromptReceipt({ tabId, agent, launchedAt: args.launchedAt }).then( + (receipt) => { + switch (receipt) { + case 'delivered': + case 'not-delivered': + return timeoutNotice.settle(receipt === 'delivered', args.onPromptDelivered) + case 'agent-exited': + // Why its own words: the agent did not really start, so "paste it once ready" is wrong. + showAgentLaunchExitedNotice({ agent, prompt }) + return { delivered: false, failureNotified: true } + case 'unconfirmed': + // Why silent when nothing waits on it: main reported nothing for such a launch, and + // a host that cannot prove the agent (Windows without hooks) would raise it every time. + if (promptDelivery !== 'submit-after-ready') { + return { delivered: false, failureNotified: false } + } + // 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, + agent, + submit: submitted, + forcePaste: true, + onTimeout: timeoutNotice.onTimeout, + ...unconfirmed + }).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-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..af981dca96ca 100644 --- a/src/renderer/src/lib/launch-agent-startup-prompt-plan.ts +++ b/src/renderer/src/lib/launch-agent-startup-prompt-plan.ts @@ -1,58 +1,60 @@ import { buildAgentDraftLaunchPlan, buildAgentStartupPlan, - type AgentStartupPlan + planLaunchPrompt, + type AgentStartupPlan, + type AgentStartupPlanInputs } from '@/lib/tui-agent-startup' - -type StartupPlanBase = Omit< - Parameters[0], - 'prompt' | 'allowEmptyPromptLaunch' -> +import type { LaunchFile, UnstageableLine } from '../../../shared/launch-prompt-file' +import type { LaunchHost } from '../../../shared/launch-host' export type LaunchAgentStartupPromptPlan = { startupPlan: AgentStartupPlan | null + /** Written by the host before it types the launch line naming it. */ + launchFile?: LaunchFile + /** The rule's wish for a line the host cannot stage, sent with the spawn. */ + unstageableLine?: UnstageableLine /** 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: 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 + /** The host the launch runs on (`clientLaunchHost`). */ + host: LaunchHost }): 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(), - 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 === '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') { + 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 } @@ -66,16 +68,45 @@ export function planLaunchAgentStartupPrompt(args: { submitPastedPrompt: false } } - if (hasPrompt && isFollowupPath) { - return pasteAfterReady(false) + // 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, + 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 } } - return { - startupPlan: buildAgentStartupPlan({ - ...base, - prompt: hasPrompt ? prompt : '', - allowEmptyPromptLaunch: !hasPrompt - }), - pasteDraftAfterLaunch: null, - submitPastedPrompt: false + switch (planned.carry) { + case 'none': + return { startupPlan: planned.plan, pasteDraftAfterLaunch: null, submitPastedPrompt: false } + case 'on-line': + return { + startupPlan: planned.plan, + ...(planned.unstageableLine ? { unstageableLine: planned.unstageableLine } : {}), + pasteDraftAfterLaunch: null, + submitPastedPrompt: false + } + case 'launch-file': + return { + startupPlan: planned.plan, + launchFile: planned.launchFile, + pasteDraftAfterLaunch: null, + submitPastedPrompt: false + } + case 'paste-after-ready': + // 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 new file mode 100644 index 000000000000..95717c94f376 --- /dev/null +++ b/src/renderer/src/lib/launch-file-host.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { clientLaunchHost, launchHostIsPaired } from './launch-file-host' +import { localPwshAvailability } from './local-pwsh-availability' + +vi.mock('./local-pwsh-availability', () => ({ localPwshAvailability: vi.fn(() => null) })) +import { buildQuickComposerStartup } from '@/hooks/composer-state/quick-startup-plan' + +describe('whether a launch lands on a paired host', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + 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(launchHostIsPaired(null)).toBe(true) + }) +}) + +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: 'linux', + shell: null, + isRemote: false, + host: { + paired: true, + provesAgentInFront: true, + takesLaunchFile: false, + windowsPaneShell: null + }, + telemetrySource: 'sidebar' + }) + expect(startup.backendStartup).toBeUndefined() + expect(startup.startupPlan?.launchFile).toBeUndefined() + expect(startup.startupPlan?.pastePromptAfterReady).toBe('fix the build\nthen run the tests') + }) +}) + +// Why: this client spawns its own Windows panes, so it knows the PowerShell that gets the line. +describe('the PowerShell a local Windows launch from this client lands in', () => { + afterEach(() => { + vi.mocked(localPwshAvailability).mockReturnValue(null) + }) + + const host = (isRemote = false) => + clientLaunchHost({ runtimeEnvironmentId: null, launchPlatform: 'win32', isRemote }) + + it('follows this client’s shell settings and the pwsh probe', () => { + const settings = useAppStore.getState().settings + useAppStore.setState({ + settings: { + ...settings!, + terminalWindowsShell: 'powershell.exe', + terminalWindowsPowerShellImplementation: 'auto' + } + }) + expect(host().windowsPaneShell).toBeNull() + vi.mocked(localPwshAvailability).mockReturnValue(false) + expect(host().windowsPaneShell).toBe('powershell.exe') + vi.mocked(localPwshAvailability).mockReturnValue(true) + expect(host().windowsPaneShell).toBe('pwsh.exe') + expect(host(true).windowsPaneShell).toBeNull() + useAppStore.setState({ settings }) + }) +}) 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..9badd87cd04e --- /dev/null +++ b/src/renderer/src/lib/launch-file-host.ts @@ -0,0 +1,42 @@ +import { isWebRuntimeSessionActive } from '@/runtime/web-runtime-session-environment' +import { CLIENT_PLATFORM } from '@/lib/new-workspace' +import { useAppStore } from '@/store' +import { + describeLaunchHost, + spawnedWindowsShell, + type LaunchHost +} from '../../../shared/launch-host' +import { isWebClientLocation } from './web-client-location' +import { localPwshAvailability } from './local-pwsh-availability' + +/** + * 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. 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 { + const paired = launchHostIsPaired(args.runtimeEnvironmentId) + const local = !paired && !args.isRemote && args.launchPlatform === 'win32' + return describeLaunchHost({ + launchPlatform: args.launchPlatform, + isRemote: args.isRemote, + hostPlatform: CLIENT_PLATFORM, + paired, + // Why this client's settings: a local pane is spawned from them, as its shell is. + windowsPaneShell: local + ? spawnedWindowsShell({ + settings: useAppStore.getState().settings, + pwshAvailable: localPwshAvailability() + }) + : null + }) +} 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 20b02593adbe..2c7a40390218 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' @@ -19,6 +20,7 @@ export function buildDirectWorkItemStartup(args: { launchConnectionId: string | null worktreePath: string repoProjectRuntime?: Parameters[0]['projectRuntime'] + runtimeEnvironmentId: string | null }): ReturnType { const launchPlatform = args.launchPlatform ?? @@ -38,7 +40,12 @@ 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', + 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 b5358ec6b3a5..e4680720dd44 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 @@ -19,6 +19,7 @@ import { notifyDirectWorkItemAgentStartTimeout } from './launch-work-item-direct-agent' import type { AgentStartupPlan } from './tui-agent-startup' +import { MAX_LINE_PROMPT_BYTES } from '../../../shared/launch-prompt-file' describe('buildDirectWorkItemStartupOpts', () => { it('preserves Codex startup command delivery for linked work-item launches', () => { @@ -26,7 +27,6 @@ describe('buildDirectWorkItemStartupOpts', () => { agent: 'codex', launchCommand: "codex 'review linked issue'", expectedProcess: 'codex', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} }, startupCommandDelivery: 'shell-ready' } @@ -53,7 +53,6 @@ describe('buildDirectWorkItemStartupOpts', () => { agent: 'claude', launchCommand: "claude --prefill 'https://github.com/o/r/issues/12'", expectedProcess: 'claude', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} } } @@ -85,6 +84,12 @@ const settings = { describe('buildDirectWorkItemAgentStartupPlan', () => { it('omits native-chat preferences when the new workspace opens in terminal mode', () => { const result = buildDirectWorkItemAgentStartupPlan({ + host: { + paired: false, + provesAgentInFront: true, + takesLaunchFile: true, + windowsPaneShell: null + }, agent: 'codex', draftContent: 'Review issue 42', promptDelivery: 'draft', @@ -99,6 +104,12 @@ describe('buildDirectWorkItemAgentStartupPlan', () => { it('applies native-chat preferences when the new workspace opens in chat', () => { const result = buildDirectWorkItemAgentStartupPlan({ + host: { + paired: false, + provesAgentInFront: true, + takesLaunchFile: true, + windowsPaneShell: null + }, agent: 'codex', draftContent: 'Review issue 42', promptDelivery: 'draft', @@ -115,6 +126,35 @@ describe('buildDirectWorkItemAgentStartupPlan', () => { }) }) +// Why (final review P2-2): main pasted a work item's submitted prompt, so like an AI button its +// line asks the host to refuse, not type raw, when the host cannot stage it. +describe('a work item whose prompt rides the launch line', () => { + it('asks the host to refuse a line it cannot stage, through to the queued startup', () => { + const preparation = buildDirectWorkItemAgentStartupPlan({ + host: { + paired: false, + provesAgentInFront: true, + takesLaunchFile: true, + windowsPaneShell: null + }, + agent: 'codex', + draftContent: `Review issue 42\n${'x'.repeat(2_000)}`, + promptDelivery: 'submit-after-ready', + settings, + launchPlatform: 'darwin' + }) + expect(preparation.unstageableLine).toBe('refuse') + const opts = buildDirectWorkItemStartupOpts( + 'codex', + preparation.startupPlan, + 'task_page', + undefined, + preparation + ) + expect(opts.startup?.unstageableLine).toBe('refuse') + }) +}) + describe('notifyDirectWorkItemAgentStartTimeout', () => { it('toasts the paste hint and records the startup timeout', () => { notifyDirectWorkItemAgentStartTimeout('codex', true) @@ -147,6 +187,12 @@ describe('buildDirectWorkItemAgentStartupPlan global arguments fallback', () => it('resolves the global Agents arguments when the launch names none', () => { const result = buildDirectWorkItemAgentStartupPlan({ + host: { + paired: false, + provesAgentInFront: true, + takesLaunchFile: true, + windowsPaneShell: null + }, agent: 'codex', draftContent: 'Fix the broken checks', promptDelivery: 'draft', @@ -160,6 +206,12 @@ describe('buildDirectWorkItemAgentStartupPlan global arguments fallback', () => it('lets an explicit per-action value win over the global one', () => { const result = buildDirectWorkItemAgentStartupPlan({ + host: { + paired: false, + provesAgentInFront: true, + takesLaunchFile: true, + windowsPaneShell: null + }, agent: 'codex', agentArgs: '--model gpt-5', draftContent: 'Fix the broken checks', @@ -173,3 +225,41 @@ 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, paired = false) => + buildDirectWorkItemAgentStartupPlan({ + host: { paired, provesAgentInFront: true, takesLaunchFile: !paired, windowsPaneShell: null }, + 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(MAX_LINE_PROMPT_BYTES + 1)) + expect(result.promptOnLaunchCommand).toBe(false) + expect(result.startupPlan?.launchCommand).not.toContain('gggg') + expect(result.launchFile).toBeUndefined() + }) + + // Why: main pastes a work item's prompt, so past the argv ceiling Claude gets the user's text. + it('stages a long prompt on Claude’s line and pastes one past the argv ceiling', () => { + const long = submit('claude', 'c'.repeat(20_000)) + expect(long.promptOnLaunchCommand).toBe(true) + expect(long.launchFile).toBeUndefined() + const huge = submit('claude', 'c'.repeat(MAX_LINE_PROMPT_BYTES + 1)) + expect(huge.promptOnLaunchCommand).toBe(false) + expect(huge.launchFile).toBeUndefined() + expect(huge.startupPlan?.launchCommand).not.toContain('cccc') + }) + + 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 e5595c0674be..20bf43ff3fb4 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent.ts @@ -1,8 +1,15 @@ import { toast } from 'sonner' +import { showAgentLaunchPromptNotDeliveredNotice } from '@/lib/agent-launch-prompt-not-delivered-notice' +import { + deliverLaunchPromptToAgentTab, + seedNativeChatLaunchDraftForAgentTab, + seedNativeChatLaunchPromptForAgentTab +} from '@/lib/agent-launch-prompt-delivery' 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' @@ -17,6 +24,8 @@ 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, UnstageableLine } from '../../../shared/launch-prompt-file' +import type { LaunchHost } from '../../../shared/launch-host' export function buildDirectWorkItemAgentStartupPlan(args: { agent: TuiAgent | null @@ -39,13 +48,21 @@ 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 + /** The host the launch runs on (`clientLaunchHost`). */ + host: LaunchHost }): { startupPlan: AgentStartupPlan | null - draftLaunchedNatively: boolean + launchFile?: LaunchFile + /** The submitted prompt the launch command carries (on its line or in `launchFile`). */ + launchPrompt?: string + /** The rule's wish for a line the host cannot stage, sent with the spawn. */ + unstageableLine?: UnstageableLine + /** 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,19 +77,62 @@ export function buildDirectWorkItemAgentStartupPlan(args: { : {}), nativeChatTranscriptIsLocalReadable: args.nativeChatTranscriptIsLocalReadable }) - 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 planInputs = { + agent: args.agent, + cmdOverrides: args.settings?.agentCmdOverrides ?? {}, + platform: args.launchPlatform, + isRemote: args.isRemote, + agentArgs: effectiveAgentArgs, + agentEnv: effectiveAgentEnv, + sessionOptions + } + if (args.promptDelivery === 'submit-after-ready') { + const planned = planLaunchPrompt({ + ...planInputs, + prompt: args.draftContent, + host: args.host, + paste: 'once-agent-runs' + }) + 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, + ...(planned.unstageableLine ? { unstageableLine: planned.unstageableLine } : {}), + 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 = 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 { @@ -80,7 +140,6 @@ export function buildDirectWorkItemAgentStartupPlan(args: { agent: draftLaunchPlan.agent, launchCommand: draftLaunchPlan.launchCommand, expectedProcess: draftLaunchPlan.expectedProcess, - followupPrompt: null, launchConfig: draftLaunchPlan.launchConfig, ...(draftLaunchPlan.sessionOptions ? { sessionOptions: draftLaunchPlan.sessionOptions } @@ -90,7 +149,7 @@ export function buildDirectWorkItemAgentStartupPlan(args: { : {}), ...(draftLaunchPlan.env ? { env: draftLaunchPlan.env } : {}) }, - draftLaunchedNatively: true, + promptOnLaunchCommand: true, startupPlanFailed: false } } @@ -111,7 +170,7 @@ export function buildDirectWorkItemAgentStartupPlan(args: { } return { startupPlan, - draftLaunchedNatively: false, + promptOnLaunchCommand: false, startupPlanFailed: startupPlan === null } } @@ -122,7 +181,14 @@ 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, + /** What the launch command carries: its launch file, and the submitted prompt (see + * `PtyPaneStartup.launchPrompt`). */ + carried: { + launchFile?: LaunchFile + launchPrompt?: string + unstageableLine?: UnstageableLine + } = {} ): { startup?: { command: string @@ -133,6 +199,9 @@ export function buildDirectWorkItemStartupOpts( launchDraftText?: string sessionOptions?: AgentStartupPlan['sessionOptions'] startupCommandDelivery?: StartupCommandDelivery + launchFile?: LaunchFile + launchPrompt?: string + unstageableLine?: UnstageableLine telemetry?: AgentStartedTelemetry } } { @@ -155,6 +224,9 @@ export function buildDirectWorkItemStartupOpts( ...(plan.startupCommandDelivery ? { startupCommandDelivery: plan.startupCommandDelivery } : {}), + ...(carried.launchFile ? { launchFile: carried.launchFile } : {}), + ...(carried.launchPrompt ? { launchPrompt: carried.launchPrompt } : {}), + ...(carried.unstageableLine ? { unstageableLine: carried.unstageableLine } : {}), ...(telemetry ? { telemetry } : {}) } } @@ -173,3 +245,51 @@ export function notifyDirectWorkItemAgentStartTimeout(agent: TuiAgent, submit: b // on the dashboard is the trigger to add one. track('agent_error', { error_class: 'unknown', agent_kind: tuiAgentToAgentKind(agent) }) } + +/** The work item's text once its agent tab exists: mirrored into chat where the launch carried it, + * else pasted once the agent is ready. */ +export function deliverDirectWorkItemPrompt(args: { + tabId: string + agent: TuiAgent | null + startupPlan: Pick | null + promptDelivery: 'draft' | 'submit-after-ready' + content: string + promptOnLaunchCommand: boolean + promptInLaunchFile: boolean +}): void { + const { tabId, agent, startupPlan, promptDelivery, content } = args + if (agent && promptDelivery === 'draft') { + // Why: the draft rides in on argv or the startup payload, so no paste runs + // below; mirror it into chat the way the new-tab launcher does. + seedNativeChatLaunchDraftForAgentTab({ tabId, agent, text: content }) + } + if ( + agent && + promptDelivery === 'submit-after-ready' && + args.promptOnLaunchCommand && + // Why: the transcript then shows the pointer sentence, which would never prune this copy. + !args.promptInLaunchFile + ) { + // Why: the launch line submits it, so no paste seeds the chat's copy of the prompt. + seedNativeChatLaunchPromptForAgentTab({ tabId, agent, text: content }) + } + if ( + startupPlan && + !args.promptOnLaunchCommand && + !(promptDelivery === 'draft' && startupPlan.draftPrompt) + ) { + const submit = promptDelivery === 'submit-after-ready' + const launched = startupPlan.agent + void deliverLaunchPromptToAgentTab({ + tabId, + agent: launched, + content, + submit, + forcePaste: submit, + onTimeout: () => + submit + ? showAgentLaunchPromptNotDeliveredNotice({ agent: launched, prompt: content }) + : notifyDirectWorkItemAgentStartTimeout(launched, submit) + }) + } +} 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 3dddd4276b45..5a0517efc6e2 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,8 @@ 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 type { LaunchFile, UnstageableLine } from '../../../shared/launch-prompt-file' import type { AgentSessionLaunchPlan, planAgentSessionLaunch @@ -18,7 +20,10 @@ export type DirectWorkItemAgentLaunchPreparation = { unavailable: boolean effectiveAgent: TuiAgent | null startupPlan: ReturnType['startupPlan'] - draftLaunchedNatively: boolean + launchFile?: LaunchFile + launchPrompt?: string + unstageableLine?: UnstageableLine + promptOnLaunchCommand: boolean startupPlanFailed: boolean structuredLaunch: boolean /** Null when no agent was selected. */ @@ -57,7 +62,7 @@ export async function prepareDirectWorkItemAgentLaunch(args: { unavailable: true, effectiveAgent: null, startupPlan: null, - draftLaunchedNatively: false, + promptOnLaunchCommand: false, startupPlanFailed: false, structuredLaunch: false, plan: null @@ -73,7 +78,14 @@ export async function prepareDirectWorkItemAgentLaunch(args: { // Non-critical: activation still has the explicit startup below. }) } - const { startupPlan, draftLaunchedNatively, startupPlanFailed } = buildDirectWorkItemStartup({ + const { + startupPlan, + launchFile, + launchPrompt, + unstageableLine, + promptOnLaunchCommand, + startupPlanFailed + } = buildDirectWorkItemStartup({ agent: effectiveAgent, agentArgs: args.agentArgs, draftContent: args.draftContent, @@ -89,7 +101,8 @@ export async function prepareDirectWorkItemAgentLaunch(args: { args.worktreeId, CLIENT_PLATFORM ) ?? args.repoProjectRuntime) - : undefined + : undefined, + runtimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(args.latestStore, args.worktreeId) }) const plan = @@ -110,7 +123,10 @@ export async function prepareDirectWorkItemAgentLaunch(args: { unavailable: false, effectiveAgent, startupPlan, - draftLaunchedNatively, + ...(launchFile ? { launchFile } : {}), + ...(launchPrompt ? { launchPrompt } : {}), + ...(unstageableLine ? { unstageableLine } : {}), + 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 92e625db5290..74cda2c723a2 100644 --- a/src/renderer/src/lib/launch-work-item-direct.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct.test.ts @@ -103,7 +103,8 @@ vi.mock('@/lib/tui-agent-startup', async () => { return { ...actual, buildAgentDraftLaunchPlan: vi.fn(actual.buildAgentDraftLaunchPlan), - buildAgentStartupPlan: vi.fn(actual.buildAgentStartupPlan) + buildAgentStartupPlan: vi.fn(actual.buildAgentStartupPlan), + planLaunchPrompt: vi.fn(actual.planLaunchPrompt) } }) @@ -130,7 +131,11 @@ 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' const mockApi = { @@ -414,26 +419,13 @@ 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() + // Why (final review P3-2): a short multi-line draft was measured exact on PowerShell's line, so + // it is prefilled there, as main typed it; only a measured damage pastes it. + const startup = mocks.activateAndRevealWorktree.mock.calls[0]?.[1]?.startup + expect(startup?.command).toContain('Linked Linear issue: ENG-42') + expect(startup?.command).not.toContain('The distinctive Linear body text is here.') + expect(startup?.command).not.toContain('--- BEGIN LINKED WORK ITEM CONTEXT ---') + expect(startup?.draftPrompt).toBeUndefined() }) it('seeds the chat-composer launch draft for a GitHub issue draft launch', async () => { @@ -530,16 +522,20 @@ describe('launchWorkItemDirect', () => { ).resolves.toBe(true) expect(buildAgentDraftLaunchPlan).not.toHaveBeenCalled() + // Why (final review P1-1): on a Windows host nothing can confirm a prompt the line carried, so + // a work item's submit-after-ready prompt is pasted once the agent runs, as main did. expect(pasteDraftWhenAgentReady).toHaveBeenCalledWith( expect.objectContaining({ tabId: 'tab-1', content: 'Use this explicit user prompt.', agent: 'claude', submit: true, - forcePaste: true, - onTimeout: expect.any(Function) + forcePaste: true }) ) + const startup = mocks.activateAndRevealWorktree.mock.calls.at(-1)?.[1]?.startup + expect(startup?.command).not.toContain('Use this explicit user prompt.') + expect(startup?.command).not.toContain('generated Linear source') expect(mocks.seedNativeChatLaunchPrompt).toHaveBeenCalledWith({ tabId: 'tab-1', agent: 'claude', @@ -644,7 +640,6 @@ describe('launchWorkItemDirect', () => { agent: 'cursor', launchCommand: 'cursor-agent', expectedProcess: 'cursor-agent', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} } }) mocks.store.createWorktree.mockResolvedValue({ @@ -857,10 +852,11 @@ describe('launchWorkItemDirect', () => { }) ).resolves.toBe(true) - expect(buildAgentStartupPlan).toHaveBeenCalledWith( + expect(planLaunchPrompt).toHaveBeenCalledWith( expect.objectContaining({ agent: 'codex', - platform: 'linux' + 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 dba04c50c1f8..add2e272c587 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -1,9 +1,5 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' -import { - deliverLaunchPromptToAgentTab, - seedNativeChatLaunchDraftForAgentTab -} from '@/lib/agent-launch-prompt-delivery' import { planAgentCliArgsSuffix } from '@/lib/tui-agent-startup' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { CLIENT_PLATFORM, getWorkspaceIntentName, getWorkspaceSeedName } from '@/lib/new-workspace' @@ -23,7 +19,7 @@ import { resolveGitHubWorkItemIdentity } from '@/lib/github-work-item-identity' import type { buildDirectWorkItemAgentStartupPlan } from '@/lib/launch-work-item-direct-agent' import { buildDirectWorkItemStartupOpts, - notifyDirectWorkItemAgentStartTimeout + deliverDirectWorkItemPrompt } from '@/lib/launch-work-item-direct-agent' import { getDirectWorkItemDraftContent } from '@/lib/launch-work-item-direct-draft' import { @@ -164,7 +160,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) @@ -228,7 +225,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 @@ -246,7 +244,8 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom effectiveAgent, startupPlan, launchSource, - promptDelivery === 'draft' ? draftContent : undefined + promptDelivery === 'draft' ? draftContent : undefined, + launchPreparation )) }) return activationHolder.value !== false @@ -292,30 +291,15 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom return false } - if (primaryTabId && effectiveAgent && promptDelivery === 'draft') { - // Why: the draft rides in on argv or the startup payload, so no paste runs - // below; mirror it into chat the way the new-tab launcher does. - seedNativeChatLaunchDraftForAgentTab({ + if (primaryTabId) { + deliverDirectWorkItemPrompt({ tabId: primaryTabId, agent: effectiveAgent, - text: draftContent - }) - } - if ( - primaryTabId && - startupPlan && - !draftLaunchedNatively && - !(promptDelivery === 'draft' && startupPlan.draftPrompt) - ) { - const submit = promptDelivery === 'submit-after-ready' - const agent = startupPlan.agent - void deliverLaunchPromptToAgentTab({ - tabId: primaryTabId, - agent, + startupPlan, + promptDelivery, content: draftContent, - submit, - forcePaste: submit, - onTimeout: () => notifyDirectWorkItemAgentStartTimeout(agent, submit) + promptOnLaunchCommand, + promptInLaunchFile }) } return true diff --git a/src/renderer/src/lib/local-pwsh-availability.ts b/src/renderer/src/lib/local-pwsh-availability.ts new file mode 100644 index 000000000000..3761f38b0802 --- /dev/null +++ b/src/renderer/src/lib/local-pwsh-availability.ts @@ -0,0 +1,22 @@ +let known: boolean | null = null +let reading = false + +/** + * Whether pwsh.exe is installed on this machine, from main's shared probe; null until it answers. + * The first call starts the read, so a launch before then is planned without it. + */ +export function localPwshAvailability(): boolean | null { + if (known === null && !reading && typeof window !== 'undefined' && window.api?.pwsh) { + reading = true + void window.api.pwsh + .isAvailable() + .then((available) => { + known = available + }) + .catch(() => {}) + .finally(() => { + reading = false + }) + } + return known +} diff --git a/src/renderer/src/lib/new-workspace.test.ts b/src/renderer/src/lib/new-workspace.test.ts index d3ca4fa01e63..b6d130b6656a 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 { @@ -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,13 +333,38 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { agent: 'aider', launchCommand: 'aider', expectedProcess: 'aider', - followupPrompt: 'fix the spinner', + pastePromptAfterReady: 'fix the spinner', launchConfig: { agentArgs: '', agentEnv: {} } } }) 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', + pastePromptAfterReady: '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 () => { @@ -349,7 +374,7 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { agent: 'aider', launchCommand: 'aider', expectedProcess: 'aider', - followupPrompt: 'fix the spinner', + pastePromptAfterReady: 'fix the spinner', launchConfig: { agentArgs: '', agentEnv: {} } } }) @@ -364,7 +389,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { agent: 'claude', launchCommand: 'claude', expectedProcess: 'claude', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} }, draftPrompt: 'review this before sending' } @@ -375,7 +399,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 () => { @@ -388,7 +415,7 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { agent: 'aider', launchCommand: 'aider', expectedProcess: 'aider', - followupPrompt: 'fix the spinner', + pastePromptAfterReady: 'fix the spinner', launchConfig: { agentArgs: '', agentEnv: {} } } }) @@ -404,7 +431,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { agent: 'claude', launchCommand: 'claude', expectedProcess: 'claude', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} }, draftPrompt: 'review this before sending' } @@ -448,7 +474,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { agent: 'codex', launchCommand: 'codex', expectedProcess: 'codex', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} }, draftPrompt: 'Linear context draft' } @@ -478,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' } @@ -535,7 +559,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { agent: 'codex', launchCommand: 'codex', expectedProcess: 'codex', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} }, draftPrompt: 'linked draft' } @@ -617,7 +640,6 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { agent: 'codex', launchCommand: 'codex', expectedProcess: 'codex', - followupPrompt: null, launchConfig: { agentArgs: '', agentEnv: {} }, draftPrompt: 'linked draft' } @@ -656,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' @@ -686,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' @@ -747,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' @@ -799,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 d19da5e5de42..d063cb78ec32 100644 --- a/src/renderer/src/lib/new-workspace.ts +++ b/src/renderer/src/lib/new-workspace.ts @@ -1,11 +1,13 @@ +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 { ComposerAgentStartupPlan } from '@/lib/composer-agent-startup-plan' import type { LinkedWorkItemContext } from '@/lib/linked-work-item-context' import { beginAgentStartupDeliveryAttempt, @@ -227,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) @@ -279,24 +281,34 @@ 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 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. + // 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 paired host's line past its typed budget) 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/pending-worktree-creation.ts b/src/renderer/src/lib/pending-worktree-creation.ts index 8460d917f05c..4ec8d5ea13d6 100644 --- a/src/renderer/src/lib/pending-worktree-creation.ts +++ b/src/renderer/src/lib/pending-worktree-creation.ts @@ -12,7 +12,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' @@ -112,7 +112,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/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..f71ae310a18a 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,61 @@ 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 a typed input past the argv ceiling goes in a private file, and an AI button pastes it', () => { + const plan = (promptDelivery: 'submit-after-ready' | 'auto-submit') => + planSourceControlAgentActionLaunch({ + agent: 'codex', + commandInput: 'z'.repeat(200_000), + promptDelivery, + detectedAgents: ['codex'], + platform: 'linux' + }) + + const typed = plan('auto-submit') + expect(typed.ok && typed.summary).toContain('private file') + expect(typed.ok && typed.summary).not.toContain('included in the launch command') + const button = plan('submit-after-ready') + expect(button.ok && button.delivery).toBe('paste-submit') + }) + + it('carries a 20 KB AI-button input on the command line of a POSIX host', () => { + const result = planSourceControlAgentActionLaunch({ + agent: 'codex', + commandInput: 'z'.repeat(20_000), + promptDelivery: 'submit-after-ready', + detectedAgents: ['codex'], + platform: 'darwin' + }) + + expect(result.ok && result.delivery).toBe('argv') + }) + + // 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', @@ -52,7 +101,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..e28c01ea86d8 100644 --- a/src/renderer/src/lib/source-control-agent-action-plan.ts +++ b/src/renderer/src/lib/source-control-agent-action-plan.ts @@ -2,9 +2,11 @@ import { buildAgentDraftLaunchPlan, buildAgentStartupPlan, planAgentCliArgsSuffix, + planLaunchPrompt, 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' @@ -14,6 +16,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 +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 + /** The runtime environment the launch targets, for its host facts (`clientLaunchHost`). */ + runtimeEnvironmentId?: string | null }): SourceControlLaunchPlanResult { const agent = args.agent if (!agent) { @@ -100,20 +105,7 @@ export function planSourceControlAgentActionLaunch(args: { let startupPlan: AgentStartupPlan | null = null let delivery: SourceControlLaunchPlanDelivery - if (args.promptDelivery === 'submit-after-ready') { - 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, @@ -129,7 +121,6 @@ export function planSourceControlAgentActionLaunch(args: { agent: draftLaunchPlan.agent, launchCommand: draftLaunchPlan.launchCommand, expectedProcess: draftLaunchPlan.expectedProcess, - followupPrompt: null, launchConfig: draftLaunchPlan.launchConfig, ...(draftLaunchPlan.sessionOptions ? { sessionOptions: draftLaunchPlan.sessionOptions } @@ -154,21 +145,8 @@ 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 { - startupPlan = buildAgentStartupPlan({ + const planned = planLaunchPrompt({ agent, prompt: trimmedInput, cmdOverrides, @@ -177,9 +155,37 @@ export function planSourceControlAgentActionLaunch(args: { isRemote, agentArgs: args.agentArgs, sessionOptions: args.sessionOptions, - allowEmptyPromptLaunch: false + host: clientLaunchHost({ + runtimeEnvironmentId: args.runtimeEnvironmentId, + launchPlatform: platform, + isRemote + }), + paste: + args.promptDelivery === 'submit-after-ready' ? 'once-agent-runs' : 'when-host-proves-agent' }) - 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 + // 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 + } } if (!startupPlan) { @@ -199,7 +205,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.' + : 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.' return { ok: true, diff --git a/src/renderer/src/lib/tui-agent-startup.test.ts b/src/renderer/src/lib/tui-agent-startup.test.ts index 01dec6c9e850..17eba4f583c1 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,7 +22,8 @@ describe('buildAgentStartupPlan', () => { agent: 'claude', launchCommand: "claude 'Fix the bug'", expectedProcess: 'claude', - followupPrompt: null, + carry: 'on-line', + pasteAfterReady: null, launchConfig: emptyLaunchConfig('claude') }) }) @@ -33,7 +31,7 @@ describe('buildAgentStartupPlan', () => { // Native chat ignores a custom launch command; a terminal launch must still run it. it('starts a terminal Claude with its custom launch command', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'claude', prompt: 'Fix the bug', cmdOverrides: { claude: 'claude-wrapper' }, @@ -43,14 +41,15 @@ describe('buildAgentStartupPlan', () => { agent: 'claude', launchCommand: "claude-wrapper 'Fix the bug'", expectedProcess: 'claude', - followupPrompt: null, + carry: 'on-line', + pasteAfterReady: null, launchConfig: emptyLaunchConfig('claude-wrapper') }) }) it('uses Gemini interactive prompt mode instead of dropping the prompt', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'gemini', prompt: 'Investigate this regression', cmdOverrides: {}, @@ -60,14 +59,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: {}, @@ -77,14 +77,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: {}, @@ -94,14 +95,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: {}, @@ -111,14 +113,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: {}, @@ -128,14 +131,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: {}, @@ -145,7 +149,8 @@ describe('buildAgentStartupPlan', () => { agent: 'trae', launchCommand: "traecli -- 'Summarize the failing tests'", expectedProcess: 'traecli', - followupPrompt: null, + carry: 'on-line', + pasteAfterReady: null, launchConfig: emptyLaunchConfig('traecli') }) }) @@ -153,7 +158,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: {}, @@ -164,7 +169,7 @@ describe('buildAgentStartupPlan', () => { it('delivers the Muse prompt after its composer is ready', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'muse', prompt: 'Summarize the failing tests', cmdOverrides: {}, @@ -174,7 +179,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' @@ -184,7 +190,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: {}, @@ -194,7 +200,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') }) }) @@ -202,7 +209,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: {}, @@ -213,7 +220,7 @@ describe('buildAgentStartupPlan', () => { it('uses cursor-agent as the actual launch binary', () => { expect( - buildAgentStartupPlan({ + planLaunchForTest({ agent: 'cursor', prompt: 'Review this file', cmdOverrides: {}, @@ -223,14 +230,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' }, @@ -240,14 +248,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: {}, @@ -257,14 +266,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: {}, @@ -274,14 +284,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: {}, @@ -292,7 +303,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', @@ -303,7 +315,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: {}, @@ -313,14 +325,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 4418cb21ed3a..66e5afb16da3 100644 --- a/src/renderer/src/lib/worktree-creation-flow-startup.ts +++ b/src/renderer/src/lib/worktree-creation-flow-startup.ts @@ -27,6 +27,8 @@ 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 } : {}), + ...(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 afb29b980dde..552debdf2258 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, UnstageableLine } 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,11 @@ export type WorktreeStartupPayload = { */ launchDraftText?: string 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 + unstageableLine?: UnstageableLine 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 601b3a009d3c..57da30ce3795 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, UnstageableLine } 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' @@ -204,6 +205,9 @@ export type TerminalActions = { command: string delivery?: 'terminal-paste' startupCommandDelivery?: StartupCommandDelivery + launchFile?: LaunchFile + launchPrompt?: string + unstageableLine?: UnstageableLine env?: Record envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig @@ -230,6 +234,9 @@ export type TerminalActions = { command: string delivery?: 'terminal-paste' startupCommandDelivery?: StartupCommandDelivery + launchFile?: LaunchFile + launchPrompt?: string + unstageableLine?: UnstageableLine 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..dab5c6815fb9 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, UnstageableLine } 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,9 @@ export type TerminalState = { command: string delivery?: 'terminal-paste' startupCommandDelivery?: StartupCommandDelivery + launchFile?: LaunchFile + launchPrompt?: string + unstageableLine?: UnstageableLine env?: Record envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig 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 55c0ec364eb1..9b05da07e2d6 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({ joined: false }), disableCodexSharedServerAutoStart: () => Promise.resolve(false), 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/ai-vault-session-title.ts b/src/shared/ai-vault-session-title.ts index d72d5b6192b8..99cd4c756168 100644 --- a/src/shared/ai-vault-session-title.ts +++ b/src/shared/ai-vault-session-title.ts @@ -13,6 +13,9 @@ export type AiVaultSessionTitleRequest = { agent: AiVaultSessionTitle['agent'] sessionId: string transcriptPath?: string + /** The pane showing this session, so the host that spawned it titles a launch file's session + * with the prompt, not the pointer the transcript holds. */ + paneKey?: string } export type AiVaultSessionTitlesArgs = { 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..42612770bcde --- /dev/null +++ b/src/shared/cmd-startup-arg-quoting.win32.test.ts @@ -0,0 +1,134 @@ +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 { planLaunchForTest } from './launch-prompt-plan.test-fixture' +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 { 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]) + }) + + // Why ASCII only: this harness pipes the line in, which cmd decodes in its startup code page + // whatever `chcp` says, while a pane's typed input arrives as UTF-16. The carry rule leaves a + // non-ASCII prompt on cmd to main's delivery (`windowsLaunchLineVerdict`), as it is unmeasured. + it.each( + WINDOWS_ARGUMENT_CORPUS.filter( + ({ value }) => value !== '' && [...value].every((char) => char.charCodeAt(0) <= 0x7f) + ) + )('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 = planLaunchForTest({ + 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]/) + // Claude's read grant for the file's folder, then the pointer naming the file, whole. + expect(await typeIntoCmd(typed)).toEqual([ + `--add-dir=${written.directory}`, + expect.stringContaining(written.path) + ]) + expect(readFileSync(written.path, 'utf8')).toBe(prompt) + expect(existsSync(marker)).toBe(false) + } finally { + removeLaunchFile(written) + } + }) + + // Why ASCII only: this harness decodes piped input in cmd's startup code page (see the corpus + // case above), so accents and CJK in the path arrive garbled here though cmd split it right. + it('names a launch file under a home with an apostrophe, a space and `%`', async () => { + const home = join(dir, "O'Brien Smith 100%") + mkdirSync(home) + const plan = planLaunchForTest({ + 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 { + expect(await typeIntoCmd(written.command ?? '')).toEqual([ + `--add-dir=${written.directory}`, + expect.stringContaining(written.path) + ]) + } finally { + removeLaunchFile(written) + } + }) +}) diff --git a/src/shared/dsh-startup.test.ts b/src/shared/dsh-startup.test.ts index 0a99d2cc67b3..539c703e2700 100644 --- a/src/shared/dsh-startup.test.ts +++ b/src/shared/dsh-startup.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from 'vitest' -import { buildAgentStartupPlan } from './tui-agent-startup' +import { planLaunchForTest } from './launch-prompt-plan.test-fixture' import { TUI_AGENT_CONFIG } from './tui-agent-config' describe('DSH first launch', () => { it.each(['darwin', 'linux', 'win32'] as const)( 'selects the current workspace before pasting the task on %s', (platform) => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'dsh', prompt: 'Review this folder', cmdOverrides: {}, diff --git a/src/shared/flag-prompt-startup.ts b/src/shared/flag-prompt-startup.ts index 50e1be306faf..3c36b8aa88d9 100644 --- a/src/shared/flag-prompt-startup.ts +++ b/src/shared/flag-prompt-startup.ts @@ -36,7 +36,6 @@ export function buildFlagPromptStartupPlan(args: { agent: args.agent, launchCommand: `${args.launchCommand}${promptSuffix}`, expectedProcess: TUI_AGENT_CONFIG[args.agent].expectedProcess, - followupPrompt: null, launchConfig: args.launchConfig, ...appliedSessionOptionProps(args.sessionOptions), ...openCodeStartupPromptEnv( 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 new file mode 100644 index 000000000000..3ff7b2032552 --- /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 { planLaunchForTest } from './launch-prompt-plan.test-fixture' +import { writeLaunchFile } from './launch-file-writing' +import { + buildLaunchFilePointer, + carryInLaunchFile, + launchFileDirectoryPlaceholder +} from './launch-prompt-file' +import type { TuiAgent } from './tui-agent' + +function planWithLaunchFile(agent: TuiAgent, shell: 'posix' | 'cmd' | 'powershell' = 'posix') { + const { prompt, launchFile } = carryInLaunchFile('the brief') + const plan = planLaunchForTest({ + 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( + planLaunchForTest({ + 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..9b2fab215684 --- /dev/null +++ b/src/shared/launch-file-writing.test.ts @@ -0,0 +1,244 @@ +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, vi } from 'vitest' +import { runProcess } from './child-process/run-process' +import { + LAUNCH_FILE_STALE_MS, + LaunchFileUnavailableError, + launchFilePathInQuotedRun, + removeLaunchFile, + sweepStaleLaunchFiles, + writeLaunchFile +} from './launch-file-writing' +import { buildLaunchFilePointer, carryInLaunchFile, parseLaunchFile } 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('carryInLaunchFile', () => { + it('moves a prompt into a launch file and names it by a pointer', () => { + const prompt = 'b'.repeat(20_000) + const carried = carryInLaunchFile(prompt) + expect(carried.launchFile).toMatchObject({ content: prompt }) + expect(carried.prompt).toBe(buildLaunchFilePointer(carried.launchFile.placeholder)) + }) + + it('keeps the prompt off the pointer', () => { + const carried = carryInLaunchFile('fix the flaky test') + expect(carried.prompt).not.toContain('flaky') + }) + + it('mints a fresh placeholder each time', () => { + const first = carryInLaunchFile('x').launchFile.placeholder + const second = carryInLaunchFile('x').launchFile.placeholder + expect(first).not.toBe(second) + }) +}) + +describe('parseLaunchFile', () => { + it('accepts what carryInLaunchFile mints and rejects anything else', () => { + const { launchFile } = carryInLaunchFile('x') + expect(parseLaunchFile(launchFile)).toEqual(launchFile) + expect(parseLaunchFile({ ...launchFile, placeholder: 'HOME' })).toBeUndefined() + expect(parseLaunchFile({ ...launchFile, content: 7 })).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 } = carryInLaunchFile('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 } = carryInLaunchFile('x') + expect(() => writeLaunchFile({ launchFile: launchFile!, baseDirectory: quoted })).toThrow( + LaunchFileUnavailableError + ) + expect(readdirSync(quoted)).toEqual([]) + }) + + it('refuses when the temp directory cannot be written', () => { + const { launchFile } = carryInLaunchFile('x') + 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 } = carryInLaunchFile('x') + 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 } = carryInLaunchFile('brief') + 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', () => { + 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') + const unrelated = join(baseDirectory, 'unrelated') + for (const directory of [stale, fresh, unrelated]) { + mkdirSync(directory) + } + 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') + 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 new file mode 100644 index 000000000000..36cda0bba902 --- /dev/null +++ b/src/shared/launch-file-writing.ts @@ -0,0 +1,260 @@ +/** + * 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 { startupLineNeedsStaging } from './startup-command-staging' +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+)-/ + +/** 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]/ + +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, carrier: 'file' | 'staged-line' = 'file') { + super(describeLaunchFileUnavailable(reason, carrier)) + } +} + +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. + // 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) + 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)) + } +} + +/** 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: a Windows path means nothing to an agent in the distro. A + * line is not refused here: with no directory the host types it as is, main's delivery, unless the + * launch asked to refuse it (`UnstageableLine`); a plan made in main read the same probe. + */ +export function writeSpawnLaunchFile(args: { + launchFile?: LaunchFile + command?: string + env?: Record + wslDistro: string | null | undefined + wslDirectory: WslLaunchDirectory | undefined +}): WrittenLaunchFile | undefined { + const wslDirectory = args.wslDistro ? args.wslDirectory : undefined + if (args.wslDistro && args.launchFile && 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 sweep removes it once this process is gone. + } +} + +/** + * 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() + 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) + const owner = OWNED_LAUNCH_FILE_DIR.exec(name)?.[1] + try { + const orphaned = owner + ? !processIsAlive(Number(owner)) + : now - statSync(directory).mtimeMs > LAUNCH_FILE_STALE_MS + if (orphaned) { + rmSync(directory, { recursive: true, force: true }) + } + } catch { + // Raced another instance's sweep. + } + } +} diff --git a/src/shared/launch-host.test.ts b/src/shared/launch-host.test.ts new file mode 100644 index 000000000000..1e69289ceaa9 --- /dev/null +++ b/src/shared/launch-host.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest' +import { describeLaunchHost, spawnedWindowsShell } from './launch-host' + +describe('what a launch host can do with its prompt', () => { + it.each([ + ['a local macOS host', false, false, 'darwin', 'darwin', true, true], + ['a local Linux host', false, false, 'linux', 'linux', true, true], + ['a local Windows host', false, false, 'win32', 'win32', false, true], + // The pane runs in the distro, but the reads run on the Windows host; Orca writes into the distro. + ['a local WSL pane', false, false, 'linux', 'win32', false, true], + ['an SSH Linux host from Windows', true, false, 'linux', 'win32', true, true], + // Its relay may run panes in WSL, where it writes no launch file; this client cannot tell. + ['an SSH Windows host', true, false, 'win32', 'darwin', false, false], + ['a paired Linux Orca from Windows', false, true, 'linux', 'win32', true, false], + ['a paired Windows Orca from macOS', false, true, 'win32', 'darwin', false, false] + ] as const)( + '%s', + (_label, isRemote, paired, launchPlatform, hostPlatform, proves, takesLaunchFile) => { + expect(describeLaunchHost({ isRemote, paired, launchPlatform, hostPlatform })).toEqual({ + paired, + provesAgentInFront: proves, + takesLaunchFile, + windowsPaneShell: null + }) + } + ) +}) + +// Why: the PowerShell decides how `"` and a trailing `\` reach the agent, and this Orca picks it. +describe('the shell a local Windows pane is spawned as', () => { + it.each([ + ['the pwsh shell', { terminalWindowsShell: 'pwsh.exe' }, undefined, null, 'pwsh.exe'], + [ + 'a requested pwsh over the setting', + { terminalWindowsShell: 'cmd.exe' }, + 'pwsh', + null, + 'pwsh.exe' + ], + [ + 'Windows PowerShell chosen explicitly', + { + terminalWindowsShell: 'powershell.exe', + terminalWindowsPowerShellImplementation: 'powershell.exe' + }, + undefined, + true, + 'powershell.exe' + ], + [ + 'auto with pwsh installed', + { terminalWindowsShell: '', terminalWindowsPowerShellImplementation: 'auto' }, + undefined, + true, + 'pwsh.exe' + ], + [ + 'auto without pwsh', + { terminalWindowsShell: 'powershell.exe', terminalWindowsPowerShellImplementation: 'auto' }, + undefined, + false, + 'powershell.exe' + ], + [ + 'auto before the probe answers', + { terminalWindowsShell: 'powershell.exe', terminalWindowsPowerShellImplementation: 'auto' }, + undefined, + null, + null + ], + ['cmd', { terminalWindowsShell: 'cmd.exe' }, undefined, true, 'cmd.exe'], + ['Git Bash', { terminalWindowsShell: 'git-bash' }, undefined, true, 'git-bash'], + // The distro's POSIX shell, whatever PowerShell would have been. + ['WSL', { terminalWindowsShell: 'wsl.exe' }, undefined, true, 'wsl.exe'], + [ + 'a requested WSL over a cmd setting', + { terminalWindowsShell: 'cmd.exe' }, + 'wsl', + null, + 'wsl.exe' + ], + // Why unknown: System32's bash.exe is WSL, Git's is Git Bash. + ['a bare bash.exe', { terminalWindowsShell: 'bash.exe' }, undefined, true, null] + ] as const)('%s', (_label, settings, windowsShellOverride, pwshAvailable, expected) => { + expect(spawnedWindowsShell({ settings, windowsShellOverride, pwshAvailable })).toBe(expected) + }) + + it('is not this machine’s to know for an SSH or paired host', () => { + for (const where of [ + { isRemote: true, paired: false }, + { isRemote: false, paired: true } + ]) { + expect( + describeLaunchHost({ + ...where, + launchPlatform: 'win32', + hostPlatform: 'win32', + windowsPaneShell: 'powershell.exe' + }).windowsPaneShell + ).toBeNull() + } + }) +}) diff --git a/src/shared/launch-host.ts b/src/shared/launch-host.ts new file mode 100644 index 000000000000..08f0de5f8096 --- /dev/null +++ b/src/shared/launch-host.ts @@ -0,0 +1,99 @@ +import type { GlobalSettings } from './global-settings-types' +import { WINDOWS_GIT_BASH_SHELL } from './windows-terminal-shell' + +export type WindowsPowerShell = 'powershell.exe' | 'pwsh.exe' + +/** The shell a local Windows pane runs; `wsl.exe` is the distro's POSIX login shell. */ +export type WindowsPaneShell = WindowsPowerShell | 'cmd.exe' | 'git-bash' | 'wsl.exe' + +export type WindowsShellSettings = + | Partial< + Pick + > + | null + | undefined + +/** 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 #24257's guarded paste is refused. */ + provesAgentInFront: boolean + /** Whether the host writes a launch file the agent can read, and stages a long line, both in one + * folder. A paired Orca may be older, an SSH Windows host's relay may run its panes in WSL (its + * OpenSSH default shell, which this client cannot see), and a host may find that folder + * unwritable. Such a host gets the line (within its typed budget where it types raw, or by its + * Windows shell's verdict), or the paste, instead. */ + takesLaunchFile: boolean + /** The shell a local Windows pane is spawned as, which the line is judged and quoted by: a WSL + * pane runs the distro's POSIX shell whatever the Windows setting, and the PowerShell decides + * how `"` and a trailing `\` reach the agent. Null where this Orca does not choose it (an SSH or + * paired host), or has not yet learned whether pwsh is installed. */ + windowsPaneShell: WindowsPaneShell | null +} + +/** + * The shell this host spawns for a local Windows pane, resolved as the spawn resolves it: the + * requested shell, then the setting; for PowerShell, the implementation setting, then whether + * pwsh.exe is installed. Null for a shell it cannot name (a custom path, or `bash.exe`, which may be + * Git Bash or WSL), or a PowerShell before that probe answers. + */ +export function spawnedWindowsShell(args: { + settings: WindowsShellSettings + /** The shell this launch asked for, which outranks the setting. */ + windowsShellOverride?: string | null + pwshAvailable: boolean | null +}): WindowsPaneShell | null { + const shell = (args.windowsShellOverride ?? args.settings?.terminalWindowsShell ?? '').trim() + if (shell === WINDOWS_GIT_BASH_SHELL) { + return 'git-bash' + } + const name = shell + .replaceAll('\\', '/') + .split('/') + .pop() + ?.toLowerCase() + .replace(/\.exe$/, '') + if (name === 'pwsh' || name === 'cmd' || name === 'wsl') { + return `${name}.exe` + } + if (name !== '' && name !== 'powershell') { + return null + } + const implementation = args.settings?.terminalWindowsPowerShellImplementation + if (implementation === 'powershell.exe' || implementation === 'pwsh.exe') { + return implementation + } + return args.pwshAvailable === null ? null : args.pwshAvailable ? 'pwsh.exe' : 'powershell.exe' +} + +/** + * 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 + /** `spawnedWindowsShell` on this machine, which a launch elsewhere does not use. */ + windowsPaneShell?: WindowsPaneShell | null + /** Whether the host can write the folder its staged lines and launch files go in. */ + writesLaunchArtifacts?: boolean +}): LaunchHost { + const runsElsewhere = args.isRemote || args.paired + return { + paired: args.paired, + provesAgentInFront: (runsElsewhere ? args.launchPlatform : args.hostPlatform) !== 'win32', + takesLaunchFile: + !args.paired && + !(args.isRemote && args.launchPlatform === 'win32') && + args.writesLaunchArtifacts !== false, + windowsPaneShell: + runsElsewhere || args.launchPlatform !== 'win32' ? null : (args.windowsPaneShell ?? null) + } +} diff --git a/src/shared/launch-prompt-carrier.test.ts b/src/shared/launch-prompt-carrier.test.ts new file mode 100644 index 000000000000..1cb1ea5af4a9 --- /dev/null +++ b/src/shared/launch-prompt-carrier.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { planLaunchForTest } from './launch-prompt-plan.test-fixture' +import { MAX_LINE_PROMPT_BYTES } from './launch-prompt-file' + +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 cmd prompt past cmd’s 8,191-character line at a launch file', () => { + const prompt = 'y'.repeat(9_000) + 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(MAX_LINE_PROMPT_BYTES + 1) + expect(planLaunchForTest({ ...base, prompt, platform: 'linux' })?.launchFile?.content).toBe( + prompt + ) + }) + + it('keeps a paired host to the typed budget, pasting what it may neither stage nor read', () => { + const short = planLaunchForTest({ + ...base, + prompt: 'fix it', + platform: 'linux', + host: { + paired: true, + provesAgentInFront: true, + takesLaunchFile: false, + windowsPaneShell: null + } + }) + expect(short?.carry).toBe('on-line') + const long = planLaunchForTest({ + ...base, + prompt: 'fix it\nthen run the tests', + platform: 'linux', + host: { + paired: true, + provesAgentInFront: true, + takesLaunchFile: false, + windowsPaneShell: null + } + }) + 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 = planLaunchForTest({ ...base, platform: 'linux', prompt: 'one\ntwo' }) + expect(typed?.launchCommand).toContain('two') + expect(typed?.pasteAfterReady).toBeNull() + const huge = 'c'.repeat(MAX_LINE_PROMPT_BYTES + 1) + const pointed = planLaunchForTest({ ...base, platform: 'linux', prompt: huge }) + expect(pointed?.launchFile?.content).toBe(huge) + 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..06e3dcbf0126 --- /dev/null +++ b/src/shared/launch-prompt-carry.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, it } from 'vitest' +import { MAX_LINE_PROMPT_BYTES, 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' +import { describeLaunchHost } from './launch-host' +import { + WINDOWS_CMD_LINE_MAX_CHARS, + WINDOWS_POWERSHELL_LINE_MAX_CHARS +} from './windows-launch-line' + +function plan( + agent: TuiAgent, + prompt: string, + extra: Partial> = {} +) { + const platform = extra.platform ?? 'darwin' + // A local launch on that platform, with #24257's guarded paste, unless the test says otherwise. + const host = describeLaunchHost({ + launchPlatform: platform, + isRemote: false, + hostPlatform: platform, + paired: false + }) + return planLaunchPrompt({ + agent, + prompt, + cmdOverrides: {}, + platform, + host, + paste: 'when-host-proves-agent', + ...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_LINE_PROMPT_BYTES)}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 }) + 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_LINE_PROMPT_BYTES + 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') + }) + + // 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_LINE_PROMPT_BYTES + 1) + const planned = plan('gemini', prompt, { paste: 'never' }) + 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' }) + }) + + it('names a launch file its caller wrote, quoted for the line', () => { + const { prompt, launchFile } = carryInLaunchFile('worker brief') + const planned = plan('claude', prompt, { launchFile }) + expect(planned).toMatchObject({ + carry: 'launch-file', + launchFile: { ...launchFile, quoting: 'posix' } + }) + }) +}) + +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; + // the per-shell measurements are in windows-launch-line.test.ts. + it('keeps an exact cmd line up to cmd’s cap and points Claude at a file past it', () => { + const extra = { platform: 'win32' as const, shell: 'cmd' as const } + expect(plan('claude', 'y'.repeat(4_000), extra)?.carry).toBe('on-line') + expect(plan('claude', 'y'.repeat(WINDOWS_CMD_LINE_MAX_CHARS), extra)?.carry).toBe('launch-file') + }) + + it('keeps a PowerShell line to CreateProcess’s cap and points Claude at a file past it', () => { + const extra = { platform: 'win32' as const, shell: 'powershell' as const } + expect(plan('claude', 'y'.repeat(20_000), extra)?.carry).toBe('on-line') + expect(plan('claude', 'y'.repeat(WINDOWS_POWERSHELL_LINE_MAX_CHARS), extra)?.carry).toBe( + 'launch-file' + ) + }) + + 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') + // 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: main pastes an AI button's or notes send's prompt once the agent runs, and runs the action + // on that paste; a Windows host cannot prove the agent in front to confirm a carried prompt, so + // even a short exact line is pasted there (final review P1-1). + it.each(['claude', 'codex'])( + 'pastes every prompt on a Windows host 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( + 'paste-after-ready' + ) + // A host that proves the agent keeps the exact line for the same caller. + expect(plan(agent, 'fix it', { platform: 'darwin', 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') + }) + + // Why: a 20 KB Fix-checks prompt on macOS arrives as the user's text, never a pointer. + it('stages a long POSIX prompt on the line, and past the ceiling pastes where main pasted', () => { + expect(plan('claude', 'y'.repeat(20_000), { paste: 'once-agent-runs' })?.carry).toBe('on-line') + const huge = 'y'.repeat(MAX_LINE_PROMPT_BYTES + 1) + expect(plan('claude', huge, { paste: 'once-agent-runs' })?.carry).toBe('paste-after-ready') + expect(plan('claude', huge)?.carry).toBe('launch-file') + }) + + it('measures the argv ceiling in UTF-8 bytes, not characters', () => { + // 99,999 bytes in 33,333 three-byte characters. + const underCeiling = '日'.repeat(Math.floor(MAX_LINE_PROMPT_BYTES / 3)) + expect(plan('claude', underCeiling)?.carry).toBe('on-line') + expect(plan('claude', '日'.repeat(MAX_LINE_PROMPT_BYTES / 3 + 1))?.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', () => { + // A local WSL pane: a Linux line, on a Windows host that cannot read what holds it. + const extra = { + platform: 'linux' as const, + host: { + paired: false, + provesAgentInFront: false, + takesLaunchFile: true, + windowsPaneShell: null + } + } + const prompt = 'fix the build\nthen run the tests' + expect(plan('gemini', prompt, extra)?.carry).toBe('on-line') + const huge = 'y'.repeat(MAX_LINE_PROMPT_BYTES + 1) + expect(plan('gemini', huge, extra)?.carry).toBe('on-line') + expect(plan('claude', huge, 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', huge, button)?.carry).toBe('paste-after-ready') + }) + + // Why: its relay may run the pane in WSL, where no launch file is written; main typed the line or + // pasted, so that is the fallback, never a refusal. + it('gives an SSH Windows host no launch file: the line, or the paste main used', () => { + const host = describeLaunchHost({ + launchPlatform: 'win32', + isRemote: true, + hostPlatform: 'darwin', + paired: false + }) + const prompt = 'fix the build\nthen run the tests' + const extra = { platform: 'win32' as const, shell: 'cmd' as const, host } + expect(plan('claude', prompt, extra)?.carry).toBe('on-line') + expect(plan('codex', 'y'.repeat(MAX_LINE_PROMPT_BYTES + 1), extra)?.carry).toBe('on-line') + expect(plan('claude', prompt, { ...extra, paste: 'once-agent-runs' })?.carry).toBe( + 'paste-after-ready' + ) + expect(plan('claude', 'fix it', extra)?.carry).toBe('on-line') + }) + + // Why: a host that cannot write its staging folder types a long line raw, so the plan picks + // main's delivery up front: the paste where main pasted, the line where main typed it. + it('plans a host that cannot stage for main’s delivery, not a staged line or a file', () => { + const extra = { + platform: 'linux' as const, + host: { + paired: false, + provesAgentInFront: true, + takesLaunchFile: false, + windowsPaneShell: null + } + } + const long = 'y'.repeat(20_000) + expect(plan('claude', 'fix it', extra)?.carry).toBe('on-line') + expect(plan('claude', long, { ...extra, paste: 'once-agent-runs' })?.carry).toBe( + 'paste-after-ready' + ) + expect(plan('claude', long, extra)?.carry).toBe('paste-after-ready') + expect(plan('codex', 'first line\nsecond line', extra)?.carry).toBe('paste-after-ready') + expect(plan('claude', long, { ...extra, paste: 'never' })?.carry).toBe('on-line') + }) + + it('pastes on a paired host what its line cannot carry typed, even for Claude', () => { + const extra = { + platform: 'linux' as const, + host: { + paired: true, + provesAgentInFront: true, + takesLaunchFile: false, + windowsPaneShell: null + } + } + 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..141c01c669ed --- /dev/null +++ b/src/shared/launch-prompt-carry.ts @@ -0,0 +1,214 @@ +/** Where a launch prompt rides: decided once here, for every launch path, and switched on by each. */ +import { + MAX_LINE_PROMPT_BYTES, + carryInLaunchFile, + launchFileDirectoryPlaceholder, + type LaunchFile, + type UnstageableLine +} from './launch-prompt-file' +import { TUI_AGENT_CONFIG } from './tui-agent-config' +import { + quoteStartupArg, + resolveStartupShell, + type AgentStartupShell +} from './tui-agent-startup-shell' +import { typedStartupLineFits } from './typed-startup-line' +import type { TuiAgent } from './tui-agent' +import type { LaunchHost } from './launch-host' +import { windowsLaunchLineVerdict } from './windows-launch-line' + +/** Why a prefill draft could not be launched, in the user's words: on a Windows host the only + * reason a flag-prefilled draft builds no line is the shell's measured damage + * (`windowsLaunchLineVerdict`) or the line's length. */ +export function windowsDraftRefusal(agent: TuiAgent, platform: NodeJS.Platform): string | null { + return platform === 'win32' && TUI_AGENT_CONFIG[agent].draftPromptFlag + ? "The host's Windows shell would break this draft on the agent's command line, 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 + /** Sent with the spawn: the line is refused, not typed raw, if the host cannot stage it. */ + unstageableLine?: UnstageableLine + } + | { 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 (it is too long for it), so no ` + + `${created} was created. Start the agent without it and 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 + platform: NodeJS.Platform + shell?: AgentStartupShell + /** The file `prompt` already points at, when the caller wrote its own. */ + launchFile?: LaunchFile + /** The host the launch runs on (`describeLaunchHost`). */ + host: LaunchHost + /** The caller's paste for a prompt the line does not carry. */ + paste: LaunchPromptPaste +} + +function agentReadsLaunchFile(agent: TuiAgent): boolean { + return TUI_AGENT_CONFIG[agent].readsLaunchFile === true +} + +const utf8 = new TextEncoder() + +/** + * The one carry rule. A caller that acts once its prompt is sent, on a host that cannot prove the + * agent is in front (Windows), gets main's paste: nothing there could confirm a carried prompt. + * Otherwise 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 was measured + * carrying such a line exactly (`windowsLaunchLineVerdict`); an unmeasured one does what main does + * on the caller's path. When the line cannot (past the argv ceiling, measured damaged by a Windows + * shell, past the typed budget of a paired host or one that cannot write its staging folder), a + * caller whose paste main used gets that paste, so the agent receives the user's text. Otherwise it + * rides a launch file, which goes only to an agent measured reading one, on a host that writes it. + * 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, + 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() + } + // Why: a host that cannot prove the agent is in front cannot confirm a carried prompt either, so + // a caller that acts once its prompt is sent (AI buttons, notes) gets main's paste and its verdict. + if (args.paste === 'once-agent-runs' && !args.host.provesAgentInFront) { + return pasteAfterReady() + } + // Why from the caller's paste: main pasted this caller's prompts, so a line the host cannot stage + // is refused with the prompt to copy rather than typed raw. + const onLine = (plan: P): LaunchPromptPlan

=> ({ + carry: 'on-line', + plan, + ...(args.paste === 'once-agent-runs' ? { unstageableLine: 'refuse' as const } : {}) + }) + const pasteReachesAgent = + args.paste === 'once-agent-runs' || + (args.paste === 'when-host-proves-agent' && args.host.provesAgentInFront) + const lineOrPaste = (): LaunchPromptPlan

| null => { + const plan = pasteReachesAgent ? null : buildLine(args) + return plan ? onLine(plan) : pasteAfterReady() + } + const viaLaunchFile = (): LaunchPromptPlan

| null => { + // Why paste first: main pastes this caller's prompts, so the agent gets the user's text; a + // pointer replaces only a line main would have typed damaged or cut. + if (args.paste === 'once-agent-runs') { + return pasteAfterReady() + } + // Why: an agent not measured reading the file would stop on an approval or refuse the path. + if (!args.host.takesLaunchFile || !agentReadsLaunchFile(args.agent)) { + return lineOrPaste() + } + const pointer = carryInLaunchFile(text) + const plan = buildLine({ ...args, prompt: pointer.prompt, launchFile: pointer.launchFile }) + return ( + plan && { carry: 'launch-file', plan, launchFile: withQuoting(pointer.launchFile, shell) } + ) + } + if (utf8.encode(text).byteLength > MAX_LINE_PROMPT_BYTES) { + 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 + } + // Hermes's line never holds the text. + if (readsEnv) { + return onLine(plan) + } + const paneShell = args.host.windowsPaneShell + // Why not for a WSL pane: it runs the distro's POSIX shell, which the host stages into. + const windowsLine = + args.platform === 'win32' && paneShell !== 'wsl.exe' + ? windowsLaunchLineVerdict( + text, + plan.launchCommand, + shell, + paneShell === 'powershell.exe' || paneShell === 'pwsh.exe' ? paneShell : null + ) + : 'exact' + // Why #24257's typed budget: an older paired host, or a host that cannot write its staging + // folder, types the line raw, truncated past it. + const stagesOnHost = args.platform !== 'win32' || paneShell === 'wsl.exe' + const typesRaw = args.host.paired || (!args.host.takesLaunchFile && stagesOnHost) + if (windowsLine === 'damaged' || (typesRaw && !typedStartupLineFits(plan.launchCommand))) { + return viaLaunchFile() + } + // Why: unmeasured, so this path does what main does: its paste, or the line it typed. + if (windowsLine === 'uncertain' && args.paste === 'once-agent-runs') { + return pasteAfterReady() + } + return onLine(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 new file mode 100644 index 000000000000..36b4194341b9 --- /dev/null +++ b/src/shared/launch-prompt-file.ts @@ -0,0 +1,124 @@ +/** + * A launch prompt its command line cannot carry 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' + +/** UTF-8 bytes a launch line carries to the agent's argv exactly (measured through staging in real + * shells); under Linux's 131,072-byte cap on one argument. */ +export const MAX_LINE_PROMPT_BYTES = 100_000 + +/** 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 + /** 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 + ) +} + +/** Whether text opens like Orca's pointer, as a session title cut from one does. */ +export function opensLikeLaunchFilePointer(text: string): boolean { + return text.trim().startsWith(POINTER_LEAD) +} + +export function carryInLaunchFile(content: string): { 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 } + } +} + +/** 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) + ) { + return undefined + } + const { placeholder, content } = 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' || + (quoting !== undefined && knownQuoting === undefined) + ) { + return undefined + } + return { placeholder, content, ...(knownQuoting ? { quoting: knownQuoting } : {}) } +} + +/** + * What a launch wants for an agent line too long or multi-line to type when the host cannot stage + * it (its staging folder is unusable): `refuse` it, for a caller whose prompt main pasted, so the + * user is handed the prompt instead of a raw line that can leave the shell waiting. Absent, the + * host types the line as is, main's delivery where main typed it, as a host that predates the + * field does. Set by the carry rule (`carryLaunchPrompt`), never by a caller. + */ +export type UnstageableLine = 'refuse' + +export function parseUnstageableLine(value: unknown): UnstageableLine | undefined { + return value === 'refuse' ? value : undefined +} + +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. */ +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}]` +} + +/** Matched by its code, wherever in the message a host or the IPC layer put it. */ +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 new file mode 100644 index 000000000000..fec1f6cbfe10 --- /dev/null +++ b/src/shared/launch-prompt-plan.test-fixture.ts @@ -0,0 +1,60 @@ +import type { LaunchFile } from './launch-prompt-file' +import { describeLaunchHost } from './launch-host' +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: Omit & + Partial> & { + allowEmptyPromptLaunch?: boolean + } +): PlannedLaunchForTest | null { + const { allowEmptyPromptLaunch, ...launchArgs } = args + if (allowEmptyPromptLaunch !== true && !args.prompt.trim()) { + return null + } + const planned = planLaunchPrompt({ + ...launchArgs, + // A local launch on the platform under test, with #24257's guarded paste, unless the test says. + host: + args.host ?? + describeLaunchHost({ + launchPlatform: args.platform, + isRemote: args.isRemote === true, + hostPlatform: args.platform, + paired: false + }), + paste: args.paste ?? 'when-host-proves-agent' + }) + 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 bfebe8c975c2..626bd5c2b986 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": 41, + "daemonProtocolVersion": 42, "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, 41 + 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42 ] } diff --git a/src/shared/local-build-compatibility-contract.ts b/src/shared/local-build-compatibility-contract.ts index afda2d8f2275..e7e6c827ea37 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: 41, + daemonProtocolVersion: 42, 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, 41 + 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42 ] } 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/opencode-startup-prompt.test.ts b/src/shared/opencode-startup-prompt.test.ts index 286f7e3ad2f5..2ef3d5137075 100644 --- a/src/shared/opencode-startup-prompt.test.ts +++ b/src/shared/opencode-startup-prompt.test.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto' import { describe, expect, it } from 'vitest' +import { planLaunchForTest } from './launch-prompt-plan.test-fixture' import { buildAgentStartupPlan, buildAgentDraftLaunchPlan } from './tui-agent-startup' import { OPENCODE_STARTUP_PROMPT_SHA256_ENV, @@ -10,7 +11,7 @@ import { tokenizeStartupCommand } from './tui-agent-startup-shell' describe('native OpenCode startup submission intent', () => { it('binds the exact trimmed native prompt to host-selectable transport', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'opencode', prompt: ' task\nwith unicode é ', cmdOverrides: {}, @@ -27,7 +28,7 @@ describe('native OpenCode startup submission intent', () => { }) it('preserves explicit run commands without a submission intent', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'opencode', prompt: 'task', cmdOverrides: { opencode: 'opencode --log-level debug run' }, @@ -42,7 +43,7 @@ describe('native OpenCode startup submission intent', () => { (shell) => { for (const agent of ['opencode', 'opencode2'] as const) { const prompt = '--literal task with unicode é' - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent, prompt, cmdOverrides: { [agent]: 'opencode --log-level debug run --standalone' }, @@ -66,7 +67,7 @@ describe('native OpenCode startup submission intent', () => { prompt ]) expect(plan?.env).toEqual({ CUSTOM_CONFIG: 'kept' }) - expect(plan?.followupPrompt).toBeNull() + expect(plan?.pasteAfterReady).toBeNull() } } ) @@ -74,7 +75,7 @@ describe('native OpenCode startup submission intent', () => { it.each(['posix', 'powershell', 'cmd'] as const)( 'reuses an existing run message separator in %s', (shell) => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'opencode', prompt: '--literal task', cmdOverrides: { opencode: 'opencode run --standalone --' }, @@ -117,7 +118,7 @@ describe('wrapped OpenCode run startup', () => { 'env -- CUSTOM_CONFIG=private opencode run --standalone --', 'env CUSTOM_CONFIG=private opencode run --title "--"' ]) { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent, prompt: '--literal task', cmdOverrides: { [agent]: command }, @@ -130,7 +131,7 @@ describe('wrapped OpenCode run startup', () => { command.endsWith(' --') ? `${command} '--literal task'` : `${command} -- '--literal task'` ) expect(plan?.env).toEqual({ CUSTOM_CONFIG: 'kept' }) - expect(plan?.followupPrompt).toBeNull() + expect(plan?.pasteAfterReady).toBeNull() } } ) @@ -140,7 +141,7 @@ describe('wrapped OpenCode run startup', () => { (agent) => { for (const separator of ['', ' --']) { const command = `& "C:\\Program Files\\opencode\\opencode.exe" --log-level debug run --standalone${separator}` - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent, prompt: "--task's é", cmdOverrides: { [agent]: command }, @@ -150,7 +151,7 @@ describe('wrapped OpenCode run startup', () => { }) expect(plan?.launchCommand).toBe(`${command}${separator ? ' ' : ' -- '}'--task''s é'`) expect(plan?.env).toEqual({ CUSTOM_CONFIG: 'kept' }) - expect(plan?.followupPrompt).toBeNull() + expect(plan?.pasteAfterReady).toBeNull() } } ) diff --git a/src/shared/qoder-agent.test.ts b/src/shared/qoder-agent.test.ts index 5078ee686ef3..f94b0fdf938f 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,7 @@ import { normalizeTerminalTitle, detectAgentStatusFromTitle } from './agent-detection' -import { buildAgentStartupPlan, buildAgentResumeStartupPlan } from './tui-agent-startup' +import { buildAgentResumeStartupPlan } 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' @@ -159,14 +160,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'" @@ -185,7 +186,7 @@ it('does not rewrite DSH titles containing a pipe as Qoder', () => { it.each(['darwin', 'linux', 'win32'] as const)( 'starts and resumes a configured modern Qoder entry on %s', (platform) => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'qoder', prompt: 'proof', cmdOverrides: { qoder: 'qoder' }, diff --git a/src/shared/qoder-cn-launch-recipe.test.ts b/src/shared/qoder-cn-launch-recipe.test.ts index 510d2d940778..a1872d939121 100644 --- a/src/shared/qoder-cn-launch-recipe.test.ts +++ b/src/shared/qoder-cn-launch-recipe.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it } from 'vitest' -import { buildAgentStartupPlan, buildAgentResumeStartupPlan } from './tui-agent-startup' +import { planLaunchForTest } from './launch-prompt-plan.test-fixture' +import { buildAgentResumeStartupPlan } from './tui-agent-startup' describe('Qoder China launch recipe identity', () => { it.each(['darwin', 'linux', 'win32'] as const)( 'preserves the official China executable on %s', (platform) => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'qoder-cn', prompt: 'fixture', cmdOverrides: {}, @@ -28,7 +29,7 @@ describe('Qoder China launch recipe identity', () => { 'preserves an explicitly configured shared-binary --cn recipe on %s', (platform) => { const cmdOverrides = { 'qoder-cn': 'qodercli --cn' } - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'qoder-cn', prompt: 'fixture', cmdOverrides, diff --git a/src/shared/rpc-contract/ai-vault-params.ts b/src/shared/rpc-contract/ai-vault-params.ts index f2f86da6a22f..30162efc90c0 100644 --- a/src/shared/rpc-contract/ai-vault-params.ts +++ b/src/shared/rpc-contract/ai-vault-params.ts @@ -67,7 +67,8 @@ export const AiVaultSessionTitlesParams = z.object({ z.object({ agent: z.enum(['claude', 'codex']), sessionId: z.string().min(1).max(512), - transcriptPath: z.string().min(1).max(32_768).optional() + transcriptPath: z.string().min(1).max(32_768).optional(), + paneKey: z.string().min(1).max(512).optional() }) ) .max(AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT) diff --git a/src/shared/runtime-terminal-contracts.ts b/src/shared/runtime-terminal-contracts.ts index 4fc5b09fda99..8b2e902f29e6 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' @@ -10,7 +5,7 @@ import type { RuntimeMobileSessionTabsResult } from './runtime-session-contracts import type { TabGroupLayoutNode } from './tab-types' import type { TerminalExitCause } from './terminal-exit-cause' import type { TerminalPaneLayoutNode } from './terminal-tab-types' -import type { TerminalAgent, TuiAgent } from './terminal-agent' +import type { TerminalAgent } from './terminal-agent' export type RuntimeTerminalSummary = { handle: string @@ -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 eeee1b307c42..b324e3256af4 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -148,7 +148,6 @@ export type { RuntimeTerminalAgentStatusState, RuntimeTerminalClose, RuntimeTerminalCreate, - RuntimeTerminalCreateRequestPayload, RuntimeTerminalFocus, RuntimeTerminalInteractiveWait, RuntimeTerminalInteractiveWaitSource, @@ -160,7 +159,6 @@ export type { RuntimeTerminalOrphanTopology, RuntimeTerminalOrphanTopologyGroup, RuntimeTerminalOrphanTopologyTab, - RuntimeTerminalPresentation, RuntimeTerminalPromptDelivery, RuntimeTerminalPromptStage, RuntimeTerminalRead, @@ -183,6 +181,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 888c8fd9930b..7935a9be1b7f 100644 --- a/src/shared/startup-command-staging.node-pty.test.ts +++ b/src/shared/startup-command-staging.node-pty.test.ts @@ -22,6 +22,12 @@ 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 { + MAX_LINE_PROMPT_BYTES, + 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' @@ -61,7 +67,8 @@ const SANDBOX = mkdtempSync(join(tmpdir(), 'orca-staging-pty-')) const AGENT = join(SANDBOX, 'agent') writeFileSync( AGENT, - `#!/bin/sh\nps -o pgid=,tpgid= -p $$ > "$ORCA_TEST_CAPTURE.job"\nfor arg in "$@"; do printf '%s\\0' "$arg"; done > "$ORCA_TEST_CAPTURE"\n` + // Why the rename: the capture appears only once whole, however long a large argv takes to write. + `#!/bin/sh\nps -o pgid=,tpgid= -p $$ > "$ORCA_TEST_CAPTURE.job"\nfor arg in "$@"; do printf '%s\\0' "$arg"; done > "$ORCA_TEST_CAPTURE.part"\nmv "$ORCA_TEST_CAPTURE.part" "$ORCA_TEST_CAPTURE"\n` ) chmodSync(AGENT, 0o755) @@ -69,6 +76,18 @@ afterAll(() => { rmSync(SANDBOX, { recursive: true, force: true }) }) +/** Exactly `bytes` of UTF-8: the hostile characters, line breaks, and two-, three- and four-byte + * characters. */ +function utf8PromptOfBytes(bytes: number): string { + const encoder = new TextEncoder() + const unit = `${HOSTILE}\nčé 日本語 🙂\n` + let prompt = '' + while (encoder.encode(prompt + unit).byteLength <= bytes) { + prompt += unit + } + return prompt + 'x'.repeat(bytes - encoder.encode(prompt).byteLength) +} + const HOSTILE = `it's "quoted" $HOME \`id\` $(id) \\\\server\\share %PATH% !! #` const PROMPTS: [string, string][] = [ @@ -77,15 +96,21 @@ const PROMPTS: [string, string][] = [ ['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'] + ['a prompt with a tab', 'before\tafter'], + // Why: the most a launch line carries before its prompt moves to a launch file or the paste. + ['a 100,000-byte multi-line UTF-8 prompt', utf8PromptOfBytes(MAX_LINE_PROMPT_BYTES)] ] 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, @@ -129,8 +154,6 @@ async function launchInRealShell(shell: LiveShell, prompt: string): Promise setTimeout(resolve, 100)) const [pgid, foregroundPgid] = readFileSync(`${capture}.job`, 'utf8').trim().split(/\s+/) return { argv: readFileSync(capture, 'utf8'), @@ -168,3 +191,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 = carryInLaunchFile(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 15e9d98db680..c2d6da6e858f 100644 --- a/src/shared/startup-command-staging.ts +++ b/src/shared/startup-command-staging.ts @@ -8,11 +8,20 @@ * 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 { + accessSync, + constants, + 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-' @@ -30,6 +39,12 @@ export type StartupCommandStaging = { scriptPath?: string /** Why staging failed, for the host's log. */ failure?: string + /** The failed write's folder fails its writable check now (this host's temp folder), so the host + * types the line as main typed it; a plan made in main checked the same folder and planned for + * that. Otherwise the folder looked usable (a WSL distro's found by its probe, or a temp folder + * that passes the check, as when the disk is full) and the write still failed: a race, refused. + * A temp folder that turns unwritable between the plan and the spawn reads as unusable here. */ + folderUnusable?: true } // Why only these type any short line as is: Orca's portable quoting is verified literal in these. Any @@ -41,8 +56,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 @@ -63,17 +76,23 @@ export function shouldStageStartupCommand(args: { return false } const shellName = stagingShellName(args.shellPath) - const body = stripSubmitTerminator(args.command) - const needsStaging = - hasControlByte(body) || encoder.encode(body).byteLength > TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES + const needsStaging = startupLineNeedsStaging(args.command) if (shellName === null) { // Why these characters: tcsh doubles a quoted backslash and expands `!!`, and nu cannot read // `'\''`. A plain line is typed so the agent is the shell's own job, where the paste guard finds it. - return args.orcaBuiltLine === true && (needsStaging || /[!\\"`$]/.test(body)) + return ( + args.orcaBuiltLine === true && + (needsStaging || /[!\\"`$]/.test(stripSubmitTerminator(args.command))) + ) } return needsStaging && (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. @@ -84,7 +103,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 @@ -92,24 +111,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( @@ -121,7 +151,8 @@ export function stageStartupCommand(args: { return { command: args.command, delivery: 'typed-after-stage-failed', - failure: error instanceof Error ? error.message : String(error) + failure: error instanceof Error ? error.message : String(error), + ...(!wsl && !folderWritable(directory) ? { folderUnusable: true } : {}) } } return { @@ -131,6 +162,15 @@ export function stageStartupCommand(args: { } } +function folderWritable(directory: string): boolean { + try { + accessSync(directory, constants.W_OK) + return true + } catch { + return false + } +} + /** * 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. 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 6fca87caff00..000000000000 --- a/src/shared/startup-line-prompt-carry.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -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 type { TuiAgent } from './tui-agent' -import { RUNTIME_CAPABILITIES } from './protocol-version' -import { AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY } from './agent-launch-runtime-capability' - -function offer( - agent: TuiAgent, - prompt: string, - extra: { - cmdOverride?: string - shellName?: string | undefined - platform?: NodeJS.Platform - provesAgentInFront?: boolean - } = {} -) { - return planStartupWithPromptCandidate( - { - agent, - cmdOverrides: extra.cmdOverride ? { [agent]: extra.cmdOverride } : {}, - platform: extra.platform ?? 'darwin' - }, - prompt, - { - ...(extra.shellName ? { shellName: extra.shellName } : {}), - provesAgentInFront: extra.provesAgentInFront ?? true - } - ) -} - -/** `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' - ) -} - -/** 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 short Windows multi-line prompt as one encoded physical launch line', () => { - const { plan, promptCarried } = offer('claude', linesOf(5, 40), { platform: 'win32' }) - expect(promptCarried).toBe(true) - expect(plan?.launchCommand).not.toContain('\n') - expect(plan?.launchCommand).toContain('`n') - }) - - 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) - }) -}) - -// 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) - }) -}) - -// 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)], - // Over the typed budget even as PowerShell's one-line form, so the control still pastes it. - ['a long multi-line prompt', linesOf(20, 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: 'darwin' }).promptCarried).toBe(false) - }) -}) - -// Why: PowerShell's multi-line form is one physical line with backtick escapes (#23672), so a short -// multi-line prompt has no control byte there and rides the line within the typed budget. -describe('a short multi-line prompt on a Windows PowerShell host', () => { - it('rides the launch line as one physical line', () => { - const prompt = linesOf(5, 40) - const { plan, promptCarried } = offer('claude', prompt, { platform: 'win32' }) - expect(promptCarried).toBe(true) - expect(plan?.launchCommand).not.toMatch(/[\r\n]/) - expect(plan?.launchCommand).toContain(prompt.replaceAll('\n', '`n')) - }) -}) - -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 deleted file mode 100644 index e808cb9c74d9..000000000000 --- a/src/shared/startup-line-prompt-carry.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * 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. 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 - * 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. - */ - -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. 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 - /** 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') { - 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( - inputs: StartupPlanInputs, - prompt: string, - host: { shellName?: string; provesAgentInFront: boolean } -): { plan: AgentStartupPlan | null; promptCarried: boolean } { - if (prompt.trim()) { - const withPrompt = buildAgentStartupPlan({ ...inputs, prompt, allowEmptyPromptLaunch: true }) - if ( - startupLineCarriesPrompt({ - agent: inputs.agent, - withPrompt, - ...(host.shellName ? { shellName: host.shellName } : {}), - hostProvesAgentInFront: host.provesAgentInFront - }) - ) { - return { plan: withPrompt, promptCarried: true } - } - } - return { - plan: buildAgentStartupPlan({ ...inputs, prompt: '', allowEmptyPromptLaunch: true }), - promptCarried: false - } -} diff --git a/src/shared/tui-agent-config-types.ts b/src/shared/tui-agent-config-types.ts index 587d4df84d75..c2c0492fef57 100644 --- a/src/shared/tui-agent-config-types.ts +++ b/src/shared/tui-agent-config-types.ts @@ -35,6 +35,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 gets the caller's paste for a prompt that would need one, or else the line. */ + 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 8e94ce870fef..13959b5ec10b 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -28,6 +28,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', @@ -64,6 +67,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', @@ -342,11 +347,9 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { draftPasteReadySignal: 'zcode-composer-prompt', composerReadyCaptures: ['zcode-composer-ready'] }, - devin: { - detectCmd: 'devin', - // Why: `devin -- ` auto-submits immediately (docs.devin.ai/cli), so start the REPL with no argv prompt. - promptInjectionMode: 'stdin-after-start' - }, + // Why: `devin -- ` auto-submits immediately (docs.devin.ai/cli), so start the REPL with no argv prompt. + // prettier-ignore + devin: { detectCmd: 'devin', promptInjectionMode: 'stdin-after-start' }, // prettier-ignore jcode: { detectCmd: 'jcode', launchCmd: 'jcode', expectedProcess: 'jcode', promptInjectionMode: 'stdin-after-start' } } 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 173aabf1aa7a..381423aeef1b 100644 --- a/src/shared/tui-agent-resume-startup.ts +++ b/src/shared/tui-agent-resume-startup.ts @@ -70,7 +70,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 bcf5f43750c1..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' }, @@ -163,21 +163,22 @@ describe('hermes startup plans', () => { ).toBeNull() }) - it('rejects Hermes queries that exceed the safe Windows environment limit', () => { - const plan = buildAgentStartupPlan({ + it('leaves a Hermes query past the safe Windows environment limit for the paste', () => { + const plan = planLaunchForTest({ agent: 'hermes', prompt: 'x'.repeat(24_000), cmdOverrides: {}, platform: 'win32' }) - expect(plan).toBeNull() + expect(plan?.launchFile).toBeUndefined() + 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: {}, @@ -191,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: {}, @@ -204,19 +205,20 @@ describe('hermes startup plans', () => { ) }) - it('measures POSIX Hermes query limits in UTF-8 bytes', () => { - const plan = buildAgentStartupPlan({ + it('measures POSIX Hermes query limits in UTF-8 bytes, pasting past them', () => { + const plan = planLaunchForTest({ agent: 'hermes', - prompt: '界'.repeat(50_000), + prompt: '界'.repeat(9_000), cmdOverrides: {}, platform: 'linux' }) - expect(plan).toBeNull() + expect(plan?.launchFile).toBeUndefined() + 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: {}, @@ -225,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-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 c9a62b747b6d..6de03714e9b2 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,19 +75,20 @@ 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 "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'") }) // Why: a typed line break submits early, and PowerShell 5.1 without PSReadLine then hangs at `>>`. it('keeps a multi-line PowerShell launch on one physical line', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'claude', prompt: 'first line\nsecond $line', cmdOverrides: {}, @@ -105,7 +106,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: {}, @@ -113,11 +114,82 @@ describe('tui agent startup plans', () => { shell: 'cmd' }) - expect(plan?.launchCommand).toBe('claude "fix ^"quoted^" ^& ^%PATH^%"') + expect(plan?.launchCommand).toBe('claude "fix ""quoted"" & "^%"PATH"^%""') + }) + + it('never types a line break into cmd: a multi-line prompt rides a launch file', () => { + const prompt = 'fix the build\r\n& echo PWNED\nthen run tests' + const plan = planLaunchForTest({ + agent: 'claude', + prompt, + cmdOverrides: {}, + platform: 'win32', + shell: 'cmd' + }) + + expect(plan?.launchCommand).not.toMatch(/[\r\n]/) + expect(plan?.launchCommand).not.toContain('PWNED') + expect(plan?.launchFile).toMatchObject({ content: prompt }) + expect(plan?.launchCommand).toContain(plan?.launchFile?.placeholder) + }) + + // Why: PowerShell keeps a line break inside a single-quoted argument (measured), so `&` there is + // text, never a command; a CR before a line feed was not measured, so it is typed as main typed it. + it('keeps a PowerShell prompt with line breaks inside its one quoted argument', () => { + const prompt = 'fix the build\r\n& echo PWNED\nthen run tests' + const plan = planLaunchForTest({ + agent: 'claude', + prompt, + cmdOverrides: {}, + platform: 'win32', + shell: 'powershell' + }) + + expect(plan?.launchFile).toBeUndefined() + expect(plan?.launchCommand).toBe('claude "fix the build`r`n& echo PWNED`nthen run tests"') + }) + + it('keeps a multi-line prompt inline for a POSIX shell on a POSIX host, which stages it', () => { + const plan = planLaunchForTest({ + 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: measured, a multi-line line arrives whole in Git Bash, as main typed it. + it('keeps a multi-line prompt on a Git Bash line on a Windows host', () => { + const plan = planLaunchForTest({ + agent: 'claude', + prompt: 'line one\nline two', + cmdOverrides: {}, + platform: 'win32', + shell: 'posix' + }) + + expect(plan?.launchFile).toBeUndefined() + expect(plan?.launchCommand).toContain('line one\nline two') + }) + + 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', () => { - const plan = buildAgentStartupPlan({ + const plan = planLaunchForTest({ agent: 'grok', prompt: '--version', cmdOverrides: {}, @@ -128,7 +200,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: {}, @@ -139,7 +211,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: {}, @@ -151,7 +223,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: {}, @@ -163,7 +235,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: {}, @@ -179,7 +251,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: {}, @@ -189,7 +261,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 = @@ -213,7 +285,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: {}, @@ -230,7 +302,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: {}, @@ -242,7 +314,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: {}, @@ -254,13 +326,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: {}, @@ -272,7 +345,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: {}, @@ -288,7 +361,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: {}, @@ -303,7 +376,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: {}, @@ -319,7 +392,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: {}, @@ -332,7 +405,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: {}, @@ -343,13 +416,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: {}, @@ -360,13 +434,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: {}, @@ -377,7 +452,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: {} } }) }) @@ -394,7 +470,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: {}, @@ -407,7 +483,7 @@ describe('tui agent startup plans', () => { agent: 'muse', launchCommand: command, expectedProcess: 'muse', - followupPrompt: 'fix it' + pasteAfterReady: 'fix it' }) } ) @@ -415,19 +491,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' }, @@ -438,7 +514,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' }, @@ -541,7 +617,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: {}, @@ -555,7 +631,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: {}, @@ -567,7 +643,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: {}, @@ -586,7 +662,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: {}, @@ -603,7 +679,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: {}, @@ -663,7 +739,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: {}, @@ -675,7 +751,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: {}, @@ -749,7 +825,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: {}, @@ -760,7 +836,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 8c56c1d9d95f..db9c6d19b627 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -9,6 +9,15 @@ import { resolveStartupShell, type AgentStartupShell } from './tui-agent-startup-shell' +import type { LaunchFile } from './launch-prompt-file' +import type { LaunchHost } from './launch-host' +import { + carryLaunchPrompt, + launchFileDirectoryGrant, + type LaunchPromptPaste, + type LaunchPromptPlan +} from './launch-prompt-carry' +import { windowsLaunchLineVerdict } from './windows-launch-line' import { TUI_AGENT_CONFIG } from './tui-agent-config' import type { StartupCommandDelivery } from './codex-startup-delivery' import { buildSleepingAgentLaunchConfig } from './sleeping-agent-launch-config' @@ -25,7 +34,6 @@ export type AgentStartupPlan = { agent: TuiAgent launchCommand: string expectedProcess: string - followupPrompt: string | null launchConfig: SleepingAgentLaunchConfig launchToken?: string draftPrompt?: string | null @@ -36,13 +44,11 @@ export type AgentStartupPlan = { sessionOptions?: Record } -export function buildAgentStartupPlan(args: { +export type AgentStartupPlanInputs = { agent: TuiAgent - prompt: string cmdOverrides: Partial> platform: NodeJS.Platform shell?: AgentStartupShell - allowEmptyPromptLaunch?: boolean agentArgs?: string | null agentEnv?: Record | null sessionOptions?: Record @@ -50,8 +56,42 @@ 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 { - const { agent, prompt, cmdOverrides, platform, allowEmptyPromptLaunch = false } = args +} + +/** 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 + /** See `CarriedPlanArgs`. */ + host: LaunchHost + /** See `CarriedPlanArgs`. */ + paste: LaunchPromptPaste +} + +/** + * 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 planLaunchPrompt( + args: AgentLaunchPromptArgs +): LaunchPromptPlan | null { + return carryLaunchPrompt(args, buildPlanWithPromptOnLine) +} + +/** Builds the line with `prompt` on it; the carry rule decides whether it should. */ +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() const config = TUI_AGENT_CONFIG[agent] @@ -79,14 +119,10 @@ export function buildAgentStartupPlan(args: { }) if (!trimmedPrompt) { - if (!allowEmptyPromptLaunch) { - return null - } return { agent, launchCommand, expectedProcess: config.expectedProcess, - followupPrompt: null, launchConfig, ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) @@ -94,6 +130,7 @@ export function buildAgentStartupPlan(args: { } const quotedPrompt = quoteStartupArg(trimmedPrompt, shell) + const grant = launchFileDirectoryGrant(agent, args.launchFile, shell) if (config.promptInjectionMode === 'argv') { const promptSeparator = config.argvPromptSeparator ? ` ${config.argvPromptSeparator}` : '' @@ -101,10 +138,13 @@ 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 } : {}), @@ -115,7 +155,7 @@ export function buildAgentStartupPlan(args: { if (config.promptInjectionMode === 'flag-prompt') { return buildFlagPromptStartupPlan({ agent, - launchCommand, + launchCommand: `${launchCommand}${grant}`, quotedPrompt, prompt: trimmedPrompt, shell, @@ -144,7 +184,6 @@ export function buildAgentStartupPlan(args: { // 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 } : {}) @@ -154,9 +193,8 @@ export function buildAgentStartupPlan(args: { 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 } } : {}) @@ -166,34 +204,28 @@ export function buildAgentStartupPlan(args: { 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 } } : {}) } } - 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 - * 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 - * `tui-agent-prompt-transport.test.ts`, so the two cannot answer differently. + * Whether the agent's CLI can take its prompt on argv at all, asked before a command exists: a + * caller deciding how to deliver a prompt has to know which half it may get 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 `tui-agent-prompt-transport.test.ts`. `planLaunchPrompt` + * can still leave such a prompt for the paste (a file the agent is not known to read, a host that + * cannot write its staging folder, a caller whose paste main used on a Windows host); a `false` + * here is always the paste. * * Every mode but `stdin-after-start` folds the prompt into argv — that is what argv is FOR, so * multi-line and special-character text reaches the CLI as one argument instead of keystrokes. @@ -254,10 +286,19 @@ export function buildAgentDraftLaunchPlan(args: { }) let plan: AgentDraftLaunchPlan | null = null if (config.draftPromptFlag) { - const quoted = quoteStartupArg(trimmed, shell) + const draftLine = `${launchCommand} ${config.draftPromptFlag} ${quoteStartupArg(trimmed, shell)}` + // Why: a line the Windows shell was measured to damage is no draft to edit, and a pointer + // sentence is none either, so callers paste it into the agent; an unmeasured line is typed as + // main typed it. The pane's PowerShell is not known here. + if ( + platform === 'win32' && + windowsLaunchLineVerdict(trimmed, draftLine, shell, null) === 'damaged' + ) { + return null + } plan = { agent, - launchCommand: `${launchCommand} ${config.draftPromptFlag} ${quoted}`, + launchCommand: draftLine, expectedProcess: config.expectedProcess, launchConfig, ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), diff --git a/src/shared/typed-startup-line.ts b/src/shared/typed-startup-line.ts new file mode 100644 index 000000000000..4829179e3033 --- /dev/null +++ b/src/shared/typed-startup-line.ts @@ -0,0 +1,22 @@ +/** 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 + +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 +} diff --git a/src/shared/windows-launch-line.test.ts b/src/shared/windows-launch-line.test.ts new file mode 100644 index 000000000000..1f65df29eaf6 --- /dev/null +++ b/src/shared/windows-launch-line.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, it } from 'vitest' +import { describeLaunchHost, type WindowsPowerShell } from './launch-host' +import { planLaunchPrompt } from './tui-agent-startup' +import { windowsLaunchLineVerdict } from './windows-launch-line' +import { quoteStartupArg, type AgentStartupShell } from './tui-agent-startup-shell' +import type { TuiAgent } from './tui-agent' + +// The stack QA's prompts (Windows lane, 2026-10-02), built the way its rig built them. +function qaLine(tag: string, length: number): string { + let text = `QA-STACK ${tag} start.` + for (let i = 1; text.length < length - 14; i += 1) { + text += ` w${i}` + } + text += ` ${tag} end.` + return text.padEnd(length, '.').slice(0, length) +} + +const PROMPTS = { + p2k: qaLine('P2K', 2048), + p20k: qaLine('P20K', 20480), + p100k: qaLine('P100K', 99000), + ml5: [1, 2, 3, 4, 5] + .map((i) => `QA-STACK ML5 line ${i}: inert text, do not run any command.`) + .join('\n'), + ml9k: Array.from({ length: 18 }, (_, i) => qaLine(`ML9K-L${i + 1}`, 500)).join('\n'), + e8100: qaLine('E8100', 8100), + e8191: qaLine('E8191', 8191), + pq: 'QA-STACK PQ one line with a "double quote" inside, inert, do not run any command.', + ppct: 'QA-STACK PPCT one line with a %QAVAR% pair inside, inert, do not run any command.', + pbs: 'QA-STACK PBS one line ending in a backslash, inert, do not run any command \\' +} as const + +type Row = keyof typeof PROMPTS + +function windowsHost(windowsPowerShell: WindowsPowerShell | null) { + return describeLaunchHost({ + launchPlatform: 'win32', + isRemote: false, + hostPlatform: 'win32', + paired: false, + windowsPaneShell: windowsPowerShell + }) +} + +function carry( + agent: TuiAgent, + row: Row, + shell: AgentStartupShell, + command = `node C:/Users/neil/orca-qa/stack-final/win/bin/stub.js --qa-as=${agent}`, + windowsPowerShell: WindowsPowerShell | null = null +) { + return planLaunchPrompt({ + agent, + prompt: PROMPTS[row], + cmdOverrides: { [agent]: command }, + platform: 'win32', + shell, + host: windowsHost(windowsPowerShell), + paste: 'when-host-proves-agent' + })?.carry +} + +// Each row: what main's typed line did, then the carry it gets. A launch file only where main's +// line was measured to damage or lose the prompt; everywhere else the line, as main typed it. +describe('an agent.launch prompt on a Windows host, per measured shell', () => { + it.each<[AgentStartupShell, Row, string, 'on-line' | 'launch-file']>([ + ['cmd', 'p2k', 'exact', 'on-line'], + ['cmd', 'p20k', 'input line too long', 'launch-file'], + ['cmd', 'ml5', 'damaged', 'launch-file'], + ['cmd', 'ml9k', 'input line too long', 'launch-file'], + ['cmd', 'e8100', 'exact', 'on-line'], + ['cmd', 'e8191', 'input line too long', 'launch-file'], + ['cmd', 'pq', 'damaged by main’s quoting, exact on this line', 'on-line'], + ['cmd', 'ppct', 'damaged by main’s quoting, exact on this line', 'on-line'], + ['cmd', 'pbs', 'damaged by main’s quoting, exact on this line', 'on-line'], + ['powershell', 'p2k', 'exact on 5.1 and 7', 'on-line'], + ['powershell', 'p20k', 'exact on 5.1 and 7', 'on-line'], + ['powershell', 'p100k', 'never started on 5.1 and 7', 'launch-file'], + ['powershell', 'ml5', 'exact on 5.1 and 7', 'on-line'], + ['powershell', 'ml9k', 'damaged on 5.1, lines run as commands on 7', 'launch-file'], + ['powershell', 'e8191', 'exact on 5.1 and 7', 'on-line'], + ['powershell', 'pq', 'exact on 7, damaged on 5.1 (PowerShell unknown here)', 'on-line'], + ['powershell', 'ppct', 'exact on 5.1 and 7', 'on-line'], + ['powershell', 'pbs', 'exact on 7, damaged on 5.1 (PowerShell unknown here)', 'on-line'], + ['posix', 'p2k', 'exact in Git Bash', 'on-line'], + ['posix', 'p20k', 'never started in Git Bash', 'launch-file'], + ['posix', 'ml5', 'exact in Git Bash', 'on-line'], + ['posix', 'ml9k', 'exact in Git Bash', 'on-line'], + ['posix', 'e8191', 'exact in Git Bash', 'on-line'], + ['posix', 'pq', 'exact in Git Bash', 'on-line'], + ['posix', 'ppct', 'exact in Git Bash', 'on-line'], + ['posix', 'pbs', 'exact in Git Bash', 'on-line'] + ])('%s %s (main: %s) rides %s', (shell, row, _main, expected) => { + expect(carry('claude', row, shell)).toBe(expected) + expect(carry('codex', row, shell)).toBe(expected) + }) + + // Why: this Orca spawns the pane, so it knows which PowerShell gets the line. 5.1's measured + // damage gets a launch file, better than main; 7 keeps the line it carried exactly. + it.each<[Row, WindowsPowerShell, string, 'on-line' | 'launch-file']>([ + ['pq', 'powershell.exe', 'damaged on 5.1', 'launch-file'], + ['pbs', 'powershell.exe', 'damaged on 5.1', 'launch-file'], + ['ppct', 'powershell.exe', 'exact on 5.1', 'on-line'], + ['ml5', 'powershell.exe', 'exact on 5.1', 'on-line'], + ['p20k', 'powershell.exe', 'exact on 5.1', 'on-line'], + ['pq', 'pwsh.exe', 'exact on 7', 'on-line'], + ['pbs', 'pwsh.exe', 'exact on 7', 'on-line'], + ['ppct', 'pwsh.exe', 'exact on 7', 'on-line'], + ['ml9k', 'pwsh.exe', 'lines run as commands on 7', 'launch-file'] + ])('powershell %s spawned as %s (main: %s) rides %s', (row, ps, _main, expected) => { + const command = (agent: TuiAgent) => + `node C:/Users/neil/orca-qa/stack-final/win/bin/stub.js --qa-as=${agent}` + expect(carry('claude', row, 'powershell', command('claude'), ps)).toBe(expected) + expect(carry('codex', row, 'powershell', command('codex'), ps)).toBe(expected) + }) + + // Why: a shim's cmd.exe re-reads the line, so cmd's cap, its `%NAME%` expansion and a cut at a + // line break apply, and PowerShell hands a shim `"` and a trailing `\` the legacy way. + it.each<[AgentStartupShell, Row, 'on-line' | 'launch-file']>([ + ['powershell', 'p2k', 'on-line'], + ['powershell', 'pq', 'launch-file'], + ['powershell', 'ppct', 'launch-file'], + ['powershell', 'pbs', 'launch-file'], + ['posix', 'ppct', 'launch-file'], + ['posix', 'pq', 'on-line'], + // Unmeasured through a shim (its cmd.exe's cap and line-break cut), so typed as main typed it. + ['powershell', 'ml5', 'on-line'], + ['powershell', 'e8191', 'on-line'], + ['posix', 'ml5', 'on-line'] + ])('through a spelled-out .cmd shim, %s %s rides %s', (shell, row, expected) => { + expect(carry('claude', row, shell, 'C:/Users/ada/AppData/Roaming/npm/claude.cmd')).toBe( + expected + ) + }) + + // Why per shell (stack QA ptab/pcr): cmd and Git Bash read a Tab or carriage return as a key + // (completion, or Enter, which in cmd runs the rest as commands); both PowerShells carried them. + it.each<[AgentStartupShell, 'ptab' | 'pcr', 'on-line' | 'launch-file']>([ + ['cmd', 'ptab', 'launch-file'], + ['cmd', 'pcr', 'launch-file'], + ['posix', 'ptab', 'launch-file'], + ['posix', 'pcr', 'launch-file'], + ['powershell', 'ptab', 'on-line'], + ['powershell', 'pcr', 'on-line'] + ])('types %s %s only where the shell carried it: %s', (shell, row, expected) => { + const prompt = + row === 'ptab' + ? 'QA-STACK PTAB one line with a\ttab inside, inert, do not run any command.' + : 'QA-STACK PCR one line with a bare\rCR inside, inert, do not run any command.' + for (const windowsPowerShell of ['powershell.exe', 'pwsh.exe'] as const) { + const planned = planLaunchPrompt({ + agent: 'claude', + prompt, + cmdOverrides: {}, + platform: 'win32', + shell, + host: windowsHost(windowsPowerShell), + paste: 'when-host-proves-agent' + }) + expect(planned?.carry).toBe(expected) + } + }) + + // Why (Windows CI): no QA row carried a non-ASCII prompt into cmd, and the piped-stdin harness + // garbles it, so cmd leaves one to main's delivery; PowerShell and Git Bash rows are unchanged. + it('leaves a non-ASCII prompt on cmd to main’s delivery', () => { + const planned = (shell: AgentStartupShell, paste: 'once-agent-runs' | 'never') => + planLaunchPrompt({ + agent: 'claude', + prompt: '日本語 café', + cmdOverrides: {}, + platform: 'win32', + shell, + host: windowsHost('pwsh.exe'), + paste + })?.carry + expect(planned('cmd', 'never')).toBe('on-line') + expect(planned('cmd', 'once-agent-runs')).toBe('paste-after-ready') + expect(windowsLaunchLineVerdict('日本語 café', "claude '日本語 café'", 'cmd', null)).toBe( + 'uncertain' + ) + expect(windowsLaunchLineVerdict('日本語 café', "claude '日本語 café'", 'posix', null)).toBe( + 'exact' + ) + }) + + it('leaves another control byte, never measured, to main’s delivery', () => { + const planned = (paste: 'once-agent-runs' | 'when-host-proves-agent') => + planLaunchPrompt({ + agent: 'claude', + prompt: 'before\x1bafter', + cmdOverrides: {}, + platform: 'win32', + shell: 'powershell', + host: windowsHost('pwsh.exe'), + paste + })?.carry + expect(planned('once-agent-runs')).toBe('paste-after-ready') + expect(planned('when-host-proves-agent')).toBe('on-line') + }) + + // Why: an AI button's prompt was pasted on main, which ran the action on that paste. A Windows + // host cannot prove the agent in front to confirm a carried prompt, so every row keeps the paste, + // even one the shell carries exactly (final review P1-1). + it.each<[AgentStartupShell, Row, 'on-line' | 'paste-after-ready']>([ + ['cmd', 'ml5', 'paste-after-ready'], + ['cmd', 'pq', 'paste-after-ready'], + ['powershell', 'ml5', 'paste-after-ready'], + ['powershell', 'ml9k', 'paste-after-ready'], + ['powershell', 'pq', 'paste-after-ready'], + ['powershell', 'p20k', 'paste-after-ready'], + ['posix', 'ml9k', 'paste-after-ready'], + ['posix', 'p20k', 'paste-after-ready'] + ])('an AI button on %s with %s gets %s', (shell, row, expected) => { + const planned = planLaunchPrompt({ + agent: 'claude', + prompt: PROMPTS[row], + cmdOverrides: { + claude: 'node C:/Users/neil/orca-qa/stack-final/win/bin/stub.js --qa-as=claude' + }, + platform: 'win32', + shell, + host: describeLaunchHost({ + launchPlatform: 'win32', + isRemote: false, + hostPlatform: 'win32', + paired: false + }), + paste: 'once-agent-runs' + }) + expect(planned?.carry).toBe(expected) + }) +}) + +// Why: PowerShell quotes a multi-line prompt onto one physical line (#23672), so its line breaks +// are in the prompt, not the line, and the measured multi-line rows must still apply. +describe('a multi-line prompt PowerShell quotes onto one line', () => { + const line = (command: string, row: Row) => + `${command} ${quoteStartupArg(PROMPTS[row], 'powershell')}` + + const stub = 'node C:/Users/neil/orca-qa/stack-final/win/bin/stub.js --qa-as=claude' + + it.each<[Row, 'exact' | 'damaged']>([ + ['ml5', 'exact'], + ['ml9k', 'damaged'] + ])('keeps %s at its measured verdict', (row, expected) => { + expect(line(stub, row)).not.toMatch(/[\r\n]/) + expect(windowsLaunchLineVerdict(PROMPTS[row], line(stub, row), 'powershell', null)).toBe( + expected + ) + }) + + it('leaves it unmeasured through a .cmd shim, whose cmd.exe cuts at a line break', () => { + const shimLine = line('C:/Users/ada/AppData/Roaming/npm/claude.cmd', 'ml5') + expect(windowsLaunchLineVerdict(PROMPTS.ml5, shimLine, 'powershell', null)).toBe('uncertain') + }) +}) diff --git a/src/shared/windows-launch-line.ts b/src/shared/windows-launch-line.ts new file mode 100644 index 000000000000..543f755d67c4 --- /dev/null +++ b/src/shared/windows-launch-line.ts @@ -0,0 +1,135 @@ +/** + * What a typed Windows launch line does to its prompt. Nothing stages a Windows line, so this is per + * shell, from lines measured through each one (stack QA, native agent executables, 2026-10-02): + * `exact` arrived byte for byte, `damaged` was measured lost or changed, and `uncertain` was not + * measured, so the caller does there what main does on its path. + */ +import type { WindowsPowerShell } from './launch-host' +import type { AgentStartupShell } from './tui-agent-startup-shell' +import { TYPED_STARTUP_LINE_BUDGET_BYTES } from './typed-startup-line' + +export type WindowsLineVerdict = 'exact' | 'damaged' | 'uncertain' + +/** cmd.exe's line cap: an 8,172-character line ran, an 8,263-character one did not start. */ +export const WINDOWS_CMD_LINE_MAX_CHARS = 8191 + +/** CreateProcess's 32,767-character command line, less room for the resolved executable path. + * Measured: 20,552 characters arrived, 99,072 never started. */ +export const WINDOWS_POWERSHELL_LINE_MAX_CHARS = 32_000 + +/** A multi-line PowerShell line: 356 bytes arrived; 9,088 lost its line breaks (5.1) or ran its + * lines as commands (7). Exact is held to the typed budget, damaged from the measured failure. */ +const WINDOWS_POWERSHELL_MULTI_LINE_DAMAGED_BYTES = 9_088 + +/** Git Bash: 9,089 characters arrived whole, line breaks included (typed in 17 s); 20,551 never + * started. */ +export const WINDOWS_POSIX_LINE_EXACT_MAX_CHARS = 9_089 +const WINDOWS_POSIX_LINE_DAMAGED_CHARS = 20_551 + +const encoder = new TextEncoder() + +/** Control bytes other than a line feed: Tab and carriage return were measured, the rest not. */ +function keyBytes(line: string): { tabOrReturn: boolean; unmeasured: boolean } { + let tabOrReturn = false + let unmeasured = false + for (let i = 0; i < line.length; i += 1) { + const code = line.charCodeAt(i) + // Measured: a Tab and a bare carriage return; a CR before a line feed was not. + if (code === 0x09 || (code === 0x0d && line.charCodeAt(i + 1) !== 0x0a)) { + tabOrReturn = true + } else if ((code < 0x20 && code !== 0x0a) || code === 0x7f) { + unmeasured = true + } + } + return { tabOrReturn, unmeasured } +} + +/** An agent spelled as a `.cmd`/`.bat` shim, whose cmd.exe re-reads its arguments. A bare name + * may be either, so only the spelled-out shim counts. */ +function launchesThroughCmdShim(line: string): boolean { + const target = /^(?:&\s*)?(?:'([^']*)'|"([^"]*)"|(\S+))/.exec(line.trim()) + return /\.(?:cmd|bat)$/i.test(target?.[1] ?? target?.[2] ?? target?.[3] ?? '') +} + +function shellVerdict( + line: string, + shell: AgentStartupShell, + multiLine: boolean +): WindowsLineVerdict { + if (shell === 'cmd') { + // A line feed submits early; `"`, `%NAME%` and a trailing `\` arrive. + return multiLine || line.length > WINDOWS_CMD_LINE_MAX_CHARS ? 'damaged' : 'exact' + } + if (shell === 'powershell') { + if (line.length > WINDOWS_POWERSHELL_LINE_MAX_CHARS) { + return 'damaged' + } + const bytes = encoder.encode(line).byteLength + if (!multiLine || bytes <= TYPED_STARTUP_LINE_BUDGET_BYTES) { + return 'exact' + } + return bytes >= WINDOWS_POWERSHELL_MULTI_LINE_DAMAGED_BYTES ? 'damaged' : 'uncertain' + } + // Git Bash: POSIX quoting arrived whole. A WSL pane never reaches this check. + if (line.length <= WINDOWS_POSIX_LINE_EXACT_MAX_CHARS) { + return 'exact' + } + return line.length >= WINDOWS_POSIX_LINE_DAMAGED_CHARS ? 'damaged' : 'uncertain' +} + +function hasNonAscii(line: string): boolean { + for (let i = 0; i < line.length; i += 1) { + if (line.charCodeAt(i) > 0x7f) { + return true + } + } + return false +} + +function withUnmeasuredKeys(verdict: WindowsLineVerdict, unmeasured: boolean): WindowsLineVerdict { + return unmeasured && verdict === 'exact' ? 'uncertain' : verdict +} + +export function windowsLaunchLineVerdict( + prompt: string, + line: string, + shell: AgentStartupShell, + /** The PowerShell the pane is spawned as (from `LaunchHost.windowsPaneShell`), when known. */ + windowsPowerShell: WindowsPowerShell | null +): WindowsLineVerdict { + const keys = keyBytes(line) + // Measured: cmd and Git Bash read a Tab or carriage return as a key (completion, or Enter, which + // in cmd runs the rest as commands); both PowerShells carried them exactly. + if (keys.tabOrReturn && shell !== 'powershell') { + return 'damaged' + } + // Not measured: a non-ASCII prompt typed into cmd (Orca's cmd panes run `chcp 65001`, as main's + // do, but no QA row carried one), so main's delivery decides. + const unmeasured = keys.unmeasured || (shell === 'cmd' && hasNonAscii(line)) + // Why the prompt too: PowerShell quotes a multi-line prompt onto one physical line (`n escapes, + // #23672), and the multi-line rows were measured by the prompt's line breaks, not the line's. + const multiLine = line.includes('\n') || prompt.includes('\n') + const verdict = withUnmeasuredKeys(shellVerdict(line, shell, multiLine), unmeasured) + const legacyArgsDamage = prompt.includes('"') || prompt.endsWith('\\') + if (shell === 'cmd' || !launchesThroughCmdShim(line)) { + if (shell !== 'powershell' || verdict !== 'exact' || !legacyArgsDamage) { + return verdict + } + // Measured: 5.1 hands every native command `"` and a trailing `\` the legacy way and breaks + // them; 7 carried them exactly. Where the PowerShell is not known, main's delivery decides. + return windowsPowerShell === 'powershell.exe' + ? 'damaged' + : windowsPowerShell === 'pwsh.exe' + ? 'exact' + : 'uncertain' + } + // Measured (#23962 W-1): PowerShell hands a shim `"` and a trailing `\` the legacy way, and the + // shim's cmd.exe expands `%NAME%`. + if (/%[^%\s]+%/.test(prompt) || (shell === 'powershell' && legacyArgsDamage)) { + return 'damaged' + } + // The shim's cmd.exe also caps the line and cuts at a line break; not measured through a shim. + return verdict === 'exact' && (multiLine || line.length > WINDOWS_CMD_LINE_MAX_CHARS) + ? 'uncertain' + : verdict +} 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..d313e8ce8a9d --- /dev/null +++ b/src/shared/windows-shell-prompt-damage.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest' +import { planLaunchForTest } from './launch-prompt-plan.test-fixture' +import { buildLaunchFilePointer, isLaunchFilePointer } from './launch-prompt-file' +import { buildAgentDraftLaunchPlan } from './tui-agent-startup' +import type { AgentStartupShell } from './tui-agent-startup-shell' +import { windowsDraftRefusal } from './launch-prompt-carry' + +const SHIM = 'C:/Users/ada/AppData/Roaming/npm/claude.cmd' + +function plan(prompt: string, shell: AgentStartupShell, command?: string) { + return planLaunchForTest({ + agent: 'claude', + prompt, + cmdOverrides: command ? { claude: command } : {}, + 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. A spelled-out shim gets a + // launch file. A bare name may be a native executable, where 7 carried them exactly (stack QA), + // so its line is typed as main typed it. + 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 through a PowerShell shim, and types it for a bare name', + (_, prompt) => { + const startup = plan(prompt, 'powershell', SHIM) + expect(startup?.launchFile?.content).toBe(prompt) + expect(startup?.launchCommand).not.toContain('"') + expect(startup?.launchCommand).not.toContain('Legacy') + expect(plan(prompt, 'powershell')?.launchFile).toBeUndefined() + } + ) + + // 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; + // to a native executable it arrived (stack QA). + it('moves a %NAME% pair into a launch file through a shim and keeps a lone percent typed', () => { + expect(plan('echo %PATH% for me', 'powershell', SHIM)?.launchFile?.content).toBe( + 'echo %PATH% for me' + ) + expect(plan('echo %PATH% for me', 'powershell')?.launchFile).toBeUndefined() + 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() + }) + + // Why (final review P3-2): one measured predicate judges a draft's line as it judges a prompt's. + // A measured damage pastes the draft; an unmeasured line (PowerShell `"`, which the pane's + // PowerShell decides) is typed as main typed it. + it('pastes a Windows draft only where its line was measured to damage it', () => { + const draft = (text: string, shell: AgentStartupShell) => + buildAgentDraftLaunchPlan({ + agent: 'claude', + draft: text, + cmdOverrides: {}, + platform: 'win32', + shell + }) + expect(draft('line one\nline two', 'cmd')).toBeNull() + expect(draft('say\thi', 'cmd')).toBeNull() + expect(draft('say "hi"', 'powershell')?.launchCommand).toBe(`claude --prefill 'say "hi"'`) + expect(draft('line one\nline two', 'powershell')?.launchCommand).toContain('line two') + expect(draft('say hi', 'powershell')?.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('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', + host: { + paired: true, + provesAgentInFront: false, + takesLaunchFile: false, + windowsPaneShell: null + } + }) + expect(startup?.launchFile).toBeUndefined() + expect(startup?.launchCommand).toContain('fix the build') + expect(startup?.pasteAfterReady).toBeNull() + }) + + 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('claude', 'win32')).toMatch( + /Windows shell would break this draft on the agent's command line, so the agent was not started/ + ) + expect(windowsDraftRefusal('claude', 'darwin')).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..3ab443697c0e --- /dev/null +++ b/src/shared/wsl-launch-directory.test.ts @@ -0,0 +1,145 @@ +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, carryInLaunchFile, 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 = carryInLaunchFile(content) + 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)}'`, + 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', + wslDistro: 'Ubuntu', + wslDirectory + }) + expect(() => write(undefined)).toThrow(LaunchFileUnavailableError) + expect(() => write({ ...directory, distro: 'Debian' })).toThrow(LaunchFileUnavailableError) + expect(readdirSync(join(windowsSide, '..'))).toEqual([]) + }) + + // Why: the plan read the same probe, so with no folder it chose main's delivery, typed as is. + it('leaves a line of any length to be typed when the distro has no folder', () => { + for (const command of [`claude '${'x'.repeat(600)}'`, `claude 'one\ntwo'`, `claude 'fix it'`]) { + expect( + writeSpawnLaunchFile({ command, wslDistro: 'Ubuntu', wslDirectory: undefined }) + ).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 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: {},