diff --git a/src/main/agent-launch/agent-launch-prompt-delivery.ts b/src/main/agent-launch/agent-launch-prompt-delivery.ts index 7a730848394..5822d340f21 100644 --- a/src/main/agent-launch/agent-launch-prompt-delivery.ts +++ b/src/main/agent-launch/agent-launch-prompt-delivery.ts @@ -13,8 +13,9 @@ * argv has no readiness race, so it carries the prompt wherever the agent's CLI takes a prompt * argument (`agentPromptRidesLaunchCommand`). Where it rides is decided once, where the line is built * (`carryLaunchPrompt`): on the line, which the host stages when it is too long or multi-line to - * type, or in a launch file the line points at. Only a prompt that needs a file an agent is not - * known to read is left for the paste, and the surface reports that back. + * 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 { diff --git a/src/main/agent-launch/agent-launch-surface-factories.ts b/src/main/agent-launch/agent-launch-surface-factories.ts index 3bb6554bea2..fb3593c6517 100644 --- a/src/main/agent-launch/agent-launch-surface-factories.ts +++ b/src/main/agent-launch/agent-launch-surface-factories.ts @@ -26,8 +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 - * unless it needs a launch file the agent is not known to read; `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 @@ -60,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 needing a launch file - * the agent is not known to read; and a reused terminal, whose process was already running before this launch existed. `false` for every failure, on the same rule the structured + * `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: { diff --git a/src/main/daemon/terminal-host-startup-staging.test.ts b/src/main/daemon/terminal-host-startup-staging.test.ts index cfb89c87717..109c8e75acd 100644 --- a/src/main/daemon/terminal-host-startup-staging.test.ts +++ b/src/main/daemon/terminal-host-startup-staging.test.ts @@ -127,8 +127,8 @@ describePosix('daemon startup command staging', () => { ) }) - // Why (stack QA 2a): the plan saw the same unusable folder and chose main's delivery, the line - // typed as is; refusing it made a 5-line prompt worse than main. + // 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'` diff --git a/src/main/ipc/pty/ipc/launched-agent-foreground.ts b/src/main/ipc/pty/ipc/launched-agent-foreground.ts index 0d8246cef57..f5512ad66ba 100644 --- a/src/main/ipc/pty/ipc/launched-agent-foreground.ts +++ b/src/main/ipc/pty/ipc/launched-agent-foreground.ts @@ -1,19 +1,20 @@ import { getPtyIpc } from '../../pty-host-bindings' import { isTuiAgent } from '../../../../shared/tui-agent-config' import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' -import type { LaunchedAgentForeground } from '../../../runtime/launched-agent-foreground' +import type { TerminalForegroundVerdict } from '../../../runtime/terminal-foreground-group' -/** The renderer's launch-prompt receipt asks the question #24257's crash guard asks before a paste. */ +/** 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 => { + async (_event, args: { id: string; agent: string }): Promise => { if (!runtime || typeof args?.id !== 'string' || !isTuiAgent(args.agent)) { return 'unknown' } - return runtime.readLaunchedAgentForeground(args.id, args.agent) + return runtime.readTerminalForegroundVerdict(args.id, args.agent) } ) } diff --git a/src/main/ipc/pty/ipc/spawn-options-launch-file.test.ts b/src/main/ipc/pty/ipc/spawn-options-launch-file.test.ts index 25cba1619cb..e2cdb6a52f6 100644 --- a/src/main/ipc/pty/ipc/spawn-options-launch-file.test.ts +++ b/src/main/ipc/pty/ipc/spawn-options-launch-file.test.ts @@ -33,7 +33,7 @@ async function spawnOptionsFor( } describe('renderer pty spawn: launch file', () => { - const command = `claude 'The full task is in the file "${LAUNCH_FILE.placeholder}".'` + 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( diff --git a/src/main/runtime/launched-agent-foreground.test.ts b/src/main/runtime/launched-agent-foreground.test.ts index f398a02aa0c..347fa82162d 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 b7e300b797a..3b301444b83 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 80e4a727fb7..eeebc75c398 100644 --- a/src/main/runtime/orca-runtime-activate-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-activate-managed-worktree.ts @@ -36,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( @@ -251,10 +253,20 @@ 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 { + async readLaunchedAgentForeground( + ptyId: string, + agent: TuiAgent + ): Promise { + return asLaunchedAgentForeground(await this.readTerminalForegroundVerdict(ptyId, agent)) + } + + readTerminalForegroundVerdict( + ptyId: string, + agent: TuiAgent + ): Promise { const pty = this.ptysById.get(ptyId) const remote = !!pty?.connectionId - return readLaunchedAgentForeground( + return readTerminalForegroundVerdict( this.ptyController, // A local WSL pane still runs on a Windows host, whose process reads cannot see into it. { diff --git a/src/main/runtime/orca-runtime-create-agent-session.ts b/src/main/runtime/orca-runtime-create-agent-session.ts index 25c92b96132..d8c006bb4bb 100644 --- a/src/main/runtime/orca-runtime-create-agent-session.ts +++ b/src/main/runtime/orca-runtime-create-agent-session.ts @@ -26,7 +26,6 @@ import { launchPromptNeedsPasteRefusal, windowsDraftRefusal } from '../../shared/launch-prompt-carry' -import { resolveStartupShell } from '../../shared/tui-agent-startup-shell' import { probedThisOrcaLaunchHost } from './this-orca-launch-host' import type { RuntimeTerminalCreate } from '../../shared/runtime-types' import type { @@ -174,10 +173,7 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe if (request.promptDelivery === 'draft') { startup = buildAgentDraftLaunchPlan({ ...startupArgs, draft: request.prompt ?? '' }) if (!startup) { - const refusal = windowsDraftRefusal( - request.prompt ?? '', - resolveStartupShell(startupArgs.platform, startupArgs.shell) - ) + const refusal = windowsDraftRefusal(request.agent, startupArgs.platform) throw new Error(refusal ?? 'agent_session_identity_required') } } else { diff --git a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts index 79eb2e6af11..9fdcd66d999 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 unless it needs a launch file the agent - // is not known to read, and the runtime reports which so that prompt is pasted once ready. + // 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 0fefbf11896..ae9c8514083 100644 --- a/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.ts +++ b/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.ts @@ -9,8 +9,9 @@ * * This is the half the launch command cannot serve. An argv agent's prompt rides that command, on * the line or in a launch file (`carryLaunchPrompt`), so no readiness race exists for it. What - * reaches here is a `stdin-after-start` agent, whose CLI accepts no such argument; a prompt that - * needs a launch file its agent is not known to read; and a reused terminal, whose process was + * 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. * 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 0c923cfa130..734f7be401c 100644 --- a/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts +++ b/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts @@ -67,7 +67,7 @@ export function agentLaunchWorkspaceFactory( ...(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 - // unless it needs a launch file the agent is not known to read. + // unless `carryLaunchPrompt` leaves it for the paste. ...(startupPrompt ? { startupPrompt } : {}) }, { diff --git a/src/main/runtime/runtime-managed-worktree-create-types.ts b/src/main/runtime/runtime-managed-worktree-create-types.ts index bd0e1d1c91e..0a87b7ce7bf 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 (one needing a - * launch file the agent is not known to read); reports whether the prompt rode the command. */ + /** 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/terminal-foreground-group.test.ts b/src/main/runtime/terminal-foreground-group.test.ts index df1c3348378..3e026e36958 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 35b237d2362..192f6815afb 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/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index c24ec208b06..5482f4390c9 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -129,8 +129,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). */ - readLaunchedAgentForeground: (id: string, agent: string) => Promise<'agent' | 'shell' | 'unknown'> + /** 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; false for any other pane. */ isCodexOnSharedServer: (id: string) => Promise /** Runs the fix with the pane's own Codex; true only once verified. Local panes only. */ diff --git a/src/preload/api/pty-bridge-stream-and-serialization.ts b/src/preload/api/pty-bridge-stream-and-serialization.ts index 22c8cf8bbf9..ec1b2eddcb3 100644 --- a/src/preload/api/pty-bridge-stream-and-serialization.ts +++ b/src/preload/api/pty-bridge-stream-and-serialization.ts @@ -19,7 +19,7 @@ export const ptyStreamAndSerializationApi = { readLaunchedAgentForeground: ( id: string, agent: string - ): Promise<'agent' | 'shell' | 'unknown'> => + ): Promise<'launched-agent' | 'other' | 'shell' | 'unknown'> => ipcRenderer.invoke('pty:readLaunchedAgentForeground', { id, agent }), isCodexOnSharedServer: (id: string): Promise => ipcRenderer.invoke('pty:isCodexOnSharedServer', { id }), diff --git a/src/renderer/src/lib/agent-launch-prompt-receipt.test.ts b/src/renderer/src/lib/agent-launch-prompt-receipt.test.ts index c4865f96c43..cac2ab74c2b 100644 --- a/src/renderer/src/lib/agent-launch-prompt-receipt.test.ts +++ b/src/renderer/src/lib/agent-launch-prompt-receipt.test.ts @@ -33,8 +33,7 @@ const mocks = vi.hoisted(() => { state = empty() }, readiness: vi.fn(), - readForeground: vi.fn(), - foregroundName: vi.fn() + readForeground: vi.fn() } }) @@ -61,18 +60,12 @@ describe('whether a prompt that rode the launch command reached the agent', () = mocks.reset() mocks.setState({ tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] } }) mocks.readiness.mockReset().mockResolvedValue(true) - mocks.readForeground.mockReset().mockResolvedValue('agent') - mocks.foregroundName.mockReset().mockResolvedValue('claude') + 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, - getForegroundProcess: mocks.foregroundName - } - } + api: { pty: { readLaunchedAgentForeground: mocks.readForeground } } }) }) @@ -104,7 +97,7 @@ describe('whether a prompt that rode the launch command reached the agent', () = vi.useFakeTimers() let ready: () => void = () => {} mocks.readiness.mockReturnValue(new Promise((resolve) => (ready = resolve))) - mocks.readForeground.mockResolvedValueOnce('agent').mockResolvedValue('shell') + mocks.readForeground.mockResolvedValueOnce('launched-agent').mockResolvedValue('shell') const pending = receipt() mocks.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1'] } }) await vi.advanceTimersByTimeAsync(250) @@ -122,23 +115,17 @@ describe('whether a prompt that rode the launch command reached the agent', () = // 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('agent') + .mockResolvedValueOnce('other') .mockResolvedValueOnce('shell') .mockResolvedValueOnce('shell') .mockResolvedValueOnce('shell') - .mockResolvedValue('agent') - mocks.foregroundName - .mockResolvedValueOnce('sleep') - .mockResolvedValueOnce('zsh') - .mockResolvedValueOnce('zsh') - .mockResolvedValueOnce('zsh') - .mockResolvedValue('claude') + .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(2_000) + await vi.advanceTimersByTimeAsync(10_000) await expect(pending).resolves.toBe('delivered') expect(mocks.readForeground.mock.calls.length).toBeGreaterThanOrEqual(5) }) @@ -146,15 +133,40 @@ describe('whether a prompt that rode the launch command reached the agent', () = it('is unconfirmed, never exited, when the shell stays in front with no sign the agent ran', async () => { vi.useFakeTimers() mocks.readForeground.mockResolvedValue('shell') - mocks.foregroundName.mockResolvedValue('zsh') 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(1_500) + 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 () => { @@ -208,4 +220,22 @@ describe('whether a prompt that rode the launch command reached the agent', () = 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 index d0a0bb0d7bd..8710fac4fc6 100644 --- a/src/renderer/src/lib/agent-launch-prompt-receipt.ts +++ b/src/renderer/src/lib/agent-launch-prompt-receipt.ts @@ -1,8 +1,4 @@ import type { TuiAgent } from '../../../shared/tui-agent' -import { - isExpectedAgentProcess, - recognizeAgentProcess -} from '../../../shared/agent-process-recognition' import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' import { resolveDraftPasteReadyTimeoutMs } from '../../../shared/draft-paste-ready-timeout' import { parsePaneKey } from '../../../shared/stable-pane-id' @@ -17,8 +13,9 @@ export type LaunchPromptReceipt = 'delivered' | 'not-delivered' | 'agent-exited' /** How long a hook turn may still arrive after a read that could not tell. */ const HOOK_GRACE_AFTER_UNKNOWN_READ_MS = 2000 -/** How often what holds the terminal is read until the receipt settles. */ -const FOREGROUND_POLL_MS = 250 +/** 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 @@ -33,10 +30,9 @@ function tabExists(state: AppState, tabId: string): boolean { return Object.values(state.tabsByWorktree).some((tabs) => tabs?.some((tab) => tab.id === tabId)) } -async function readLaunchedAgentForeground( - ptyId: string, - agent: TuiAgent -): Promise<'agent' | 'shell' | 'unknown'> { +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' @@ -44,30 +40,18 @@ async function readLaunchedAgentForeground( return window.api.pty.readLaunchedAgentForeground(ptyId, agent).catch(() => 'unknown' as const) } -/** Whether the host names the launched agent itself in front: proof it started, which a read of - * "not the shell" is not (a slow shell startup runs its own commands in front first). */ -async function launchedAgentNamedInFront(ptyId: string, agent: TuiAgent): Promise { - if (isRemoteRuntimePtyId(ptyId)) { - return false - } - const name = await window.api.pty.getForegroundProcess(ptyId).catch(() => null) - return ( - name !== null && - (recognizeAgentProcess(name)?.agent === agent || - isExpectedAgentProcess(name, TUI_AGENT_CONFIG[agent].expectedProcess)) - ) -} - /** - * Whether a prompt that rode an agent's launch command reached the agent, proven the way #24257's - * crash guard proves an agent before a paste. Delivered on the agent's own hook turn on the tab, or - * a fresh read on the execution host that finds the launched agent, not its shell, in front of the - * terminal (`readLaunchedAgentForeground`). A ready signal never counts on its own: it only times - * that read, and bracketed paste turned off (`2004l`) revokes it. Not delivered when the PTY never - * spawns (a refused launch file included) or the tab closes. Agent exited only on proof it ran and - * ended: the agent itself was named 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. - * Unconfirmed when nothing proves either, as on a Windows host without hooks. + * 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 @@ -92,8 +76,36 @@ export function waitForLaunchPromptReceipt(args: { let agentSeen = false const watchForeground = (ptyId: string): void => { const config = TUI_AGENT_CONFIG[agent] - let readyAt: number | null = null + 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), @@ -102,42 +114,31 @@ export function waitForLaunchPromptReceipt(args: { { revokeOnBracketedPasteOff: true } ) .then(() => { - readyAt = Date.now() + 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')) - const read = async (): Promise => { - const [foreground, named] = await Promise.all([ - readLaunchedAgentForeground(ptyId, agent), - agentSeen ? Promise.resolve(true) : launchedAgentNamedInFront(ptyId, agent) - ]) - if (settled) { - return - } - agentSeen ||= named && foreground === 'agent' - if (foreground === 'agent' && readyAt !== null) { - finish('delivered') - } else if (foreground === 'shell' && agentSeen) { - finish('agent-exited') - } else if (readyAt !== null && Date.now() - readyAt >= LAUNCH_LINE_WAIT_AFTER_READY_MS) { - finish('unconfirmed') - } else { - if (foreground === 'unknown' && readyAt !== null && !graceArmed) { - graceArmed = true - timers.push( - window.setTimeout(() => finish('unconfirmed'), HOOK_GRACE_AFTER_UNKNOWN_READ_MS) - ) - } - timers.push(window.setTimeout(() => void read(), FOREGROUND_POLL_MS)) - } - } 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 (hookReportedTurnOnTab(state, tabId, launchedAt)) { + if (statusRowProvesTurn && hookReportedTurnOnTab(state, tabId, launchedAt)) { finish('delivered') return } 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 00ecfe3ec1c..c7a9a8b133c 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -154,7 +154,7 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent } const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(store, worktreeId) const hostPublishesTab = isWebRuntimeSessionActive(runtimeEnvironmentId) - const { startupPlan, launchFile, pasteDraftAfterLaunch, submitPastedPrompt } = + const { startupPlan, launchFile, unstageableLine, pasteDraftAfterLaunch, submitPastedPrompt } = planLaunchAgentStartupPrompt({ base: startupPlanBase, prompt: trimmedPrompt, @@ -274,11 +274,7 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent ...(hasPrompt && pasteDraftAfterLaunch === null && promptDelivery !== 'draft' ? { launchPrompt: trimmedPrompt } : {}), - // Why: main pasted a submit-after-ready prompt, so a line the host cannot stage is refused - // with the prompt to copy rather than typed raw. Temporary until these move to agent.launch. - ...(hasPrompt && pasteDraftAfterLaunch === null && promptDelivery === 'submit-after-ready' - ? { unstageableLine: 'refuse' as const } - : {}), + ...(unstageableLine ? { unstageableLine } : {}), // Why: Command Code has no prompt-submit hook, so a prompt its launch line submits seeds working. ...(agent === 'command-code' && hasPrompt && 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 index 42a7b56722d..89a4362cd21 100644 --- 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 @@ -27,13 +27,13 @@ vi.mock('@/lib/telemetry', () => ({ track: vi.fn(), tuiAgentToAgentKind: vi.fn() import { deliverNewTabLaunchPrompt } from './launch-agent-new-tab-prompt-delivery' -function deliver() { +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: 'submit-after-ready', + promptDelivery, pasteDraftAfterLaunch: null, submitPastedPrompt: false, promptInLaunchFile: false, @@ -64,4 +64,19 @@ describe('the notice for a prompt the launch line carried', () => { 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 index 6b66424eec4..9d33f66b672 100644 --- a/src/renderer/src/lib/launch-agent-new-tab-prompt-delivery.ts +++ b/src/renderer/src/lib/launch-agent-new-tab-prompt-delivery.ts @@ -77,6 +77,11 @@ export function deliverNewTabLaunchPrompt(args: { 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 } 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 c40d74f94da..af981dca96c 100644 --- a/src/renderer/src/lib/launch-agent-startup-prompt-plan.ts +++ b/src/renderer/src/lib/launch-agent-startup-prompt-plan.ts @@ -5,13 +5,15 @@ import { type AgentStartupPlan, type AgentStartupPlanInputs } from '@/lib/tui-agent-startup' -import type { LaunchFile } from '../../../shared/launch-prompt-file' +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 @@ -84,8 +86,14 @@ export function planLaunchAgentStartupPrompt(args: { } switch (planned.carry) { case 'none': - case 'on-line': 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, 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 b1f5c3c364b..e4680720dd4 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 @@ -126,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) 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 ab97738be23..2644a2d3ae2 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent.ts @@ -18,7 +18,7 @@ import { import { translate } from '@/i18n/i18n' import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat/native-chat-launch-session-options' import type { PersistedNativeChatSessionOptions } from '../../../shared/native-chat-session-options' -import type { LaunchFile } from '../../../shared/launch-prompt-file' +import type { LaunchFile, UnstageableLine } from '../../../shared/launch-prompt-file' import type { LaunchHost } from '../../../shared/launch-host' export function buildDirectWorkItemAgentStartupPlan(args: { @@ -49,6 +49,8 @@ export function buildDirectWorkItemAgentStartupPlan(args: { 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 @@ -95,6 +97,7 @@ export function buildDirectWorkItemAgentStartupPlan(args: { return { startupPlan: planned.plan, launchPrompt: args.draftContent, + ...(planned.unstageableLine ? { unstageableLine: planned.unstageableLine } : {}), promptOnLaunchCommand: true, startupPlanFailed: false } @@ -175,7 +178,11 @@ export function buildDirectWorkItemStartupOpts( launchDraftText?: string, /** What the launch command carries: its launch file, and the submitted prompt (see * `PtyPaneStartup.launchPrompt`). */ - carried: { launchFile?: LaunchFile; launchPrompt?: string } = {} + carried: { + launchFile?: LaunchFile + launchPrompt?: string + unstageableLine?: UnstageableLine + } = {} ): { startup?: { command: string @@ -188,6 +195,7 @@ export function buildDirectWorkItemStartupOpts( startupCommandDelivery?: StartupCommandDelivery launchFile?: LaunchFile launchPrompt?: string + unstageableLine?: UnstageableLine telemetry?: AgentStartedTelemetry } } { @@ -212,6 +220,7 @@ export function buildDirectWorkItemStartupOpts( : {}), ...(carried.launchFile ? { launchFile: carried.launchFile } : {}), ...(carried.launchPrompt ? { launchPrompt: carried.launchPrompt } : {}), + ...(carried.unstageableLine ? { unstageableLine: carried.unstageableLine } : {}), ...(telemetry ? { telemetry } : {}) } } diff --git a/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts b/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts index b968b74fb0c..05e213eeb89 100644 --- a/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts +++ b/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts @@ -4,7 +4,7 @@ import { getConnectionId } from '@/lib/connection-context' import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' -import type { LaunchFile } from '../../../shared/launch-prompt-file' +import type { LaunchFile, UnstageableLine } from '../../../shared/launch-prompt-file' import type { AgentSessionLaunchPlan, planAgentSessionLaunch @@ -21,6 +21,7 @@ export type DirectWorkItemAgentLaunchPreparation = { startupPlan: ReturnType['startupPlan'] launchFile?: LaunchFile launchPrompt?: string + unstageableLine?: UnstageableLine promptOnLaunchCommand: boolean startupPlanFailed: boolean structuredLaunch: boolean @@ -74,26 +75,32 @@ export async function prepareDirectWorkItemAgentLaunch(args: { // Non-critical: activation still has the explicit startup below. }) } - const { startupPlan, launchFile, launchPrompt, promptOnLaunchCommand, startupPlanFailed } = - buildDirectWorkItemStartup({ - agent: effectiveAgent, - agentArgs: args.agentArgs, - draftContent: args.draftContent, - promptDelivery: args.promptDelivery, - settings: args.settings, - launchPlatform: args.launchPlatform, - launchConnectionId, - worktreePath: args.worktreePath, - repoProjectRuntime: - launchConnectionId === null - ? (getLocalProjectExecutionRuntimeContext( - args.latestStore, - args.worktreeId, - CLIENT_PLATFORM - ) ?? args.repoProjectRuntime) - : undefined, - runtimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(args.latestStore, args.worktreeId) - }) + const { + startupPlan, + launchFile, + launchPrompt, + unstageableLine, + promptOnLaunchCommand, + startupPlanFailed + } = buildDirectWorkItemStartup({ + agent: effectiveAgent, + agentArgs: args.agentArgs, + draftContent: args.draftContent, + promptDelivery: args.promptDelivery, + settings: args.settings, + launchPlatform: args.launchPlatform, + launchConnectionId, + worktreePath: args.worktreePath, + repoProjectRuntime: + launchConnectionId === null + ? (getLocalProjectExecutionRuntimeContext( + args.latestStore, + args.worktreeId, + CLIENT_PLATFORM + ) ?? args.repoProjectRuntime) + : undefined, + runtimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(args.latestStore, args.worktreeId) + }) const plan = effectiveAgent === null @@ -114,6 +121,7 @@ export async function prepareDirectWorkItemAgentLaunch(args: { startupPlan, ...(launchFile ? { launchFile } : {}), ...(launchPrompt ? { launchPrompt } : {}), + ...(unstageableLine ? { unstageableLine } : {}), promptOnLaunchCommand, startupPlanFailed, structuredLaunch, diff --git a/src/renderer/src/lib/launch-work-item-direct.test.ts b/src/renderer/src/lib/launch-work-item-direct.test.ts index 8c4c2cf3448..afc5a3e7d25 100644 --- a/src/renderer/src/lib/launch-work-item-direct.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct.test.ts @@ -419,14 +419,13 @@ describe('launchWorkItemDirect', () => { platform: 'win32', isRemote: false }) - // Windows shells have no bracketed paste, so a multi-line draft is never typed onto the launch - // line; it is pasted into the agent's own composer instead. + // 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).not.toContain('Linked Linear issue') - const pasted: string = startup?.draftPrompt ?? '' - expect(pasted).toContain(expectedDraft) - expect(pasted).not.toContain('The distinctive Linear body text is here.') - expect(pasted).not.toContain('--- BEGIN LINKED WORK ITEM CONTEXT ---') + 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 () => { @@ -523,10 +522,19 @@ describe('launchWorkItemDirect', () => { ).resolves.toBe(true) expect(buildAgentDraftLaunchPlan).not.toHaveBeenCalled() - // The explicit prompt is submitted by the launch line, never pasted into the running agent. - expect(pasteDraftWhenAgentReady).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 + }) + ) const startup = mocks.activateAndRevealWorktree.mock.calls.at(-1)?.[1]?.startup - expect(startup?.command).toContain('Use this explicit user prompt.') + 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', diff --git a/src/renderer/src/lib/new-workspace.ts b/src/renderer/src/lib/new-workspace.ts index e932c32811b..d063cb78ec3 100644 --- a/src/renderer/src/lib/new-workspace.ts +++ b/src/renderer/src/lib/new-workspace.ts @@ -289,7 +289,7 @@ async function deliverAgentStartupToTerminal( // live session and submitted. Wait until the agent owns the PTY before writing. if (startup.pastePromptAfterReady) { const prompt = startup.pastePromptAfterReady - // Why: an argv agent's prompt left for paste (a WSL line too long to type, a paired host) waits + // 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({ diff --git a/src/renderer/src/lib/worktree-startup-payload.ts b/src/renderer/src/lib/worktree-startup-payload.ts index 46244464315..552debdf225 100644 --- a/src/renderer/src/lib/worktree-startup-payload.ts +++ b/src/renderer/src/lib/worktree-startup-payload.ts @@ -6,7 +6,7 @@ import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-resume' import type { SessionOptionValue } from '../../../shared/native-chat-session-options' -import type { LaunchFile } from '../../../shared/launch-prompt-file' +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. */ @@ -35,6 +35,7 @@ export type WorktreeStartupPayload = { 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/shared/cmd-startup-arg-quoting.win32.test.ts b/src/shared/cmd-startup-arg-quoting.win32.test.ts index 132dbed105f..b67601550d6 100644 --- a/src/shared/cmd-startup-arg-quoting.win32.test.ts +++ b/src/shared/cmd-startup-arg-quoting.win32.test.ts @@ -54,12 +54,16 @@ describeOnWindows('cmd launch-line prompt quoting', () => { expect(await typeIntoCmd(`"${shim}" ${quoteStartupArg(prompt, 'cmd')}`)).toEqual([prompt]) }) - it.each(WINDOWS_ARGUMENT_CORPUS.filter(({ value }) => value !== ''))( - 'delivers $name unchanged', - async ({ value }) => { - expect(await typeIntoCmd(`"${shim}" ${quoteStartupArg(value, 'cmd')}`)).toEqual([value]) - } - ) + // 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') @@ -84,9 +88,11 @@ describeOnWindows('cmd launch-line prompt quoting', () => { try { const typed = written.command ?? '' expect(typed).not.toMatch(/[\r\n]/) - const args = await typeIntoCmd(typed) - expect(args).toHaveLength(1) - expect(args[0]).toContain(written.path) + // 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 { @@ -115,9 +121,10 @@ describeOnWindows('cmd launch-line prompt quoting', () => { baseDirectory: home }) try { - const args = await typeIntoCmd(written.command ?? '') - expect(args).toHaveLength(1) - expect(args[0]).toContain(written.path) + expect(await typeIntoCmd(written.command ?? '')).toEqual([ + `--add-dir=${written.directory}`, + expect.stringContaining(written.path) + ]) } finally { removeLaunchFile(written) } diff --git a/src/shared/launch-file-writing.ts b/src/shared/launch-file-writing.ts index 4eceede9792..36cda0bba90 100644 --- a/src/shared/launch-file-writing.ts +++ b/src/shared/launch-file-writing.ts @@ -191,8 +191,8 @@ export function spawnNeedsWslLaunchDirectory(args: { /** * 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, which is the delivery the - * plan chose, from the same probe, for a distro whose folder is unusable. + * 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 diff --git a/src/shared/launch-host.ts b/src/shared/launch-host.ts index 29741287e5c..08f0de5f809 100644 --- a/src/shared/launch-host.ts +++ b/src/shared/launch-host.ts @@ -24,7 +24,8 @@ export type LaunchHost = { /** 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, or the paste, instead. */ + * 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 diff --git a/src/shared/launch-prompt-carrier.test.ts b/src/shared/launch-prompt-carrier.test.ts index bcaf986c2ab..1cb1ea5af4a 100644 --- a/src/shared/launch-prompt-carrier.test.ts +++ b/src/shared/launch-prompt-carrier.test.ts @@ -6,7 +6,7 @@ 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 Windows prompt past the typed budget at a launch file', () => { + 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) diff --git a/src/shared/launch-prompt-carry.test.ts b/src/shared/launch-prompt-carry.test.ts index 2b3328116d8..06e3dcbf012 100644 --- a/src/shared/launch-prompt-carry.test.ts +++ b/src/shared/launch-prompt-carry.test.ts @@ -131,10 +131,11 @@ describe('a host that types the line raw', () => { expect(plan('gemini', prompt, { platform: 'win32', shell: 'cmd' })?.carry).toBe('on-line') }) - // Why: main pastes an AI button's or notes send's prompt once the agent runs, so the agent's - // history shows the user's text; a pointer replaces only a line main would type damaged. + // 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 what a Windows line cannot carry for a caller whose paste main used, for %s', + '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' @@ -145,8 +146,10 @@ describe('a host that types the line raw', () => { 'paste-after-ready' ) expect(plan(agent, 'fix it', { platform: 'win32', shell: 'cmd', paste })?.carry).toBe( - 'on-line' + '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') } diff --git a/src/shared/launch-prompt-carry.ts b/src/shared/launch-prompt-carry.ts index 28502beb028..141c01c669e 100644 --- a/src/shared/launch-prompt-carry.ts +++ b/src/shared/launch-prompt-carry.ts @@ -3,45 +3,27 @@ import { MAX_LINE_PROMPT_BYTES, carryInLaunchFile, launchFileDirectoryPlaceholder, - type LaunchFile + type LaunchFile, + type UnstageableLine } from './launch-prompt-file' import { TUI_AGENT_CONFIG } from './tui-agent-config' import { - isPosixStartupShell, quoteStartupArg, resolveStartupShell, type AgentStartupShell } from './tui-agent-startup-shell' -import { hasControlByte, typedStartupLineFits } from './typed-startup-line' +import { typedStartupLineFits } from './typed-startup-line' import type { TuiAgent } from './tui-agent' import type { LaunchHost } from './launch-host' import { windowsLaunchLineVerdict } from './windows-launch-line' -/** - * Whether a Windows shell would damage `prompt` as one quoted argument. No quoting keeps a control - * byte literal: cmd types the line and a line break submits it early. PowerShell runs a short line - * from its argv, so its damage is the hand-off to the agent (measured): legacy argument passing (5.1 - * always, 7.x into a `.cmd` shim) splits at an inner `"` and turns a trailing `\` into `"`, and a - * shim's cmd.exe expands `%NAME%`; a lone `%` stays literal. - */ -export function windowsShellDamagesPrompt(prompt: string, shell: AgentStartupShell): boolean { - if (isPosixStartupShell(shell)) { - return false - } - return ( - hasControlByte(prompt) || - (shell === 'powershell' && - (prompt.includes('"') || /%[^%]+%/.test(prompt) || prompt.endsWith('\\'))) - ) -} - -/** Why a prefill draft could not be launched, in the user's words, when the Windows shell is why. */ -export function windowsDraftRefusal(draft: string, shell: AgentStartupShell): string | null { - return windowsShellDamagesPrompt(draft.trim(), shell) - ? "The host's Windows shell would break this draft on the agent's command line (it has a line " + - 'break or other control character, or on PowerShell a double quote, a %NAME% pair or a ' + - 'trailing backslash), so the agent was not started. Start it without the draft and paste ' + - 'the draft once it opens.' +/** 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 } @@ -51,7 +33,12 @@ export function windowsDraftRefusal(draft: string, shell: AgentStartupShell): st */ export type LaunchPromptPlan

= | { carry: 'none'; plan: P } - | { carry: 'on-line'; 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 } @@ -65,9 +52,8 @@ export function launchPromptNeedsPasteRefusal( return TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start' ? `${agent} takes its prompt only after it starts, so no ${created} was created. Start the ` + 'agent and paste the prompt once it opens.' - : `${agent} cannot take this prompt on its command line here (too long, or broken by the ` + - `host's Windows shell), so no ${created} was created. Start the agent without it and ` + - 'paste the prompt once it opens.' + : `${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.` } /** @@ -93,14 +79,16 @@ export type CarriedPlanArgs = { paste: LaunchPromptPaste } -export function agentReadsLaunchFile(agent: TuiAgent): boolean { +function agentReadsLaunchFile(agent: TuiAgent): boolean { return TUI_AGENT_CONFIG[agent].readsLaunchFile === true } const utf8 = new TextEncoder() /** - * The one carry rule. The prompt rides the agent's line: a host that stages (POSIX, SSH, WSL) + * 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 @@ -133,12 +121,24 @@ export function carryLaunchPrompt => ({ + 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 ? { carry: 'on-line', plan } : pasteAfterReady() + 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 @@ -168,7 +168,7 @@ export function carryLaunchPrompt` form earlier builds sent. */ +/** 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) } diff --git a/src/shared/startup-command-staging.ts b/src/shared/startup-command-staging.ts index 76e2d65fa49..c2d6da6e858 100644 --- a/src/shared/startup-command-staging.ts +++ b/src/shared/startup-command-staging.ts @@ -39,9 +39,11 @@ export type StartupCommandStaging = { scriptPath?: string /** Why staging failed, for the host's log. */ failure?: string - /** The failed write's folder was unusable before it (this host's temp folder, unwritable), which - * the launch's plan also saw, so it chose main's typed line; otherwise the folder looked usable - * (a WSL distro's, found by its probe) and the write still failed. */ + /** 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 } diff --git a/src/shared/tui-agent-config-types.ts b/src/shared/tui-agent-config-types.ts index ca70601d5ca..fcac3e84350 100644 --- a/src/shared/tui-agent-config-types.ts +++ b/src/shared/tui-agent-config-types.ts @@ -36,7 +36,7 @@ export type TuiAgentConfig = { * following positional prompt as a second directory. */ launchFileDirectoryFlag?: '--add-dir' /** Measured reading its launch file (through `launchFileDirectoryFlag` when set) without asking. - * Any other agent keeps the paste after ready for a prompt that would need one. */ + * 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 diff --git a/src/shared/tui-agent-startup.test.ts b/src/shared/tui-agent-startup.test.ts index 945565f3921..122452842f1 100644 --- a/src/shared/tui-agent-startup.test.ts +++ b/src/shared/tui-agent-startup.test.ts @@ -149,7 +149,6 @@ describe('tui agent startup plans', () => { expect(plan?.launchCommand).toContain('line one\nline two') }) - // Why: a Windows host stages nothing, so Git Bash would get the raw line break typed. // 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({ diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index acbb9ec7f76..bc6798b917a 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -14,10 +14,10 @@ import type { LaunchHost } from './launch-host' import { carryLaunchPrompt, launchFileDirectoryGrant, - windowsShellDamagesPrompt, 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' @@ -220,11 +220,13 @@ function buildPlanWithPromptOnLine( /** * Whether this agent's prompt rides the launch command rather than the live PTY. * - * The same question `planLaunchPrompt` answers with any outcome but `paste-after-ready`, asked - * before a command exists — a caller deciding how to deliver a prompt has to know which half it is - * getting while it is still choosing what to create. Derived from the one injection table rather - * than restating it, and pinned against the builder for every agent by - * `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. @@ -284,16 +286,20 @@ export function buildAgentDraftLaunchPlan(args: { agentCommand: baseCommand.commandWithoutSessionOptions }) let plan: AgentDraftLaunchPlan | null = null - // Why: the shell would damage the draft (see windowsShellDamagesPrompt); callers paste it into - // the agent instead, and a pointer sentence is no draft to edit. - if (config.draftPromptFlag && windowsShellDamagesPrompt(trimmed, shell)) { - return null - } if (config.draftPromptFlag) { - const quoted = quoteStartupArg(trimmed, shell) + 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/windows-launch-line.test.ts b/src/shared/windows-launch-line.test.ts index ecea6a950a4..5c5dfb5de6e 100644 --- a/src/shared/windows-launch-line.test.ts +++ b/src/shared/windows-launch-line.test.ts @@ -1,6 +1,7 @@ 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 type { AgentStartupShell } from './tui-agent-startup-shell' import type { TuiAgent } from './tui-agent' @@ -160,6 +161,29 @@ describe('an agent.launch prompt on a Windows host, per measured shell', () => { } }) + // 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({ @@ -175,16 +199,17 @@ describe('an agent.launch prompt on a Windows host, per measured shell', () => { expect(planned('when-host-proves-agent')).toBe('on-line') }) - // Why: an AI button's prompt was pasted on main, so only a line measured exact replaces that - // paste; measured damage and the unmeasured both keep main's paste. + // 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', 'on-line'], - ['powershell', 'ml5', 'on-line'], + ['cmd', 'pq', 'paste-after-ready'], + ['powershell', 'ml5', 'paste-after-ready'], ['powershell', 'ml9k', 'paste-after-ready'], ['powershell', 'pq', 'paste-after-ready'], - ['powershell', 'p20k', 'on-line'], - ['posix', 'ml9k', 'on-line'], + ['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({ diff --git a/src/shared/windows-launch-line.ts b/src/shared/windows-launch-line.ts index b9a37239c30..1621f619ab0 100644 --- a/src/shared/windows-launch-line.ts +++ b/src/shared/windows-launch-line.ts @@ -67,13 +67,22 @@ function shellVerdict(line: string, shell: AgentStartupShell): WindowsLineVerdic } return bytes >= WINDOWS_POWERSHELL_MULTI_LINE_DAMAGED_BYTES ? 'damaged' : 'uncertain' } - // Git Bash (and a WSL shell): POSIX quoting arrived whole. + // 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 } @@ -82,7 +91,7 @@ export function windowsLaunchLineVerdict( prompt: string, line: string, shell: AgentStartupShell, - /** The PowerShell the pane is spawned as (`LaunchHost.windowsPowerShell`), when known. */ + /** The PowerShell the pane is spawned as (from `LaunchHost.windowsPaneShell`), when known. */ windowsPowerShell: WindowsPowerShell | null ): WindowsLineVerdict { const keys = keyBytes(line) @@ -91,7 +100,10 @@ export function windowsLaunchLineVerdict( if (keys.tabOrReturn && shell !== 'powershell') { return 'damaged' } - const verdict = withUnmeasuredKeys(shellVerdict(line, shell), keys.unmeasured) + // 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)) + const verdict = withUnmeasuredKeys(shellVerdict(line, shell), unmeasured) const legacyArgsDamage = prompt.includes('"') || prompt.endsWith('\\') if (shell === 'cmd' || !launchesThroughCmdShim(line)) { if (shell !== 'powershell' || verdict !== 'exact' || !legacyArgsDamage) { diff --git a/src/shared/windows-shell-prompt-damage.test.ts b/src/shared/windows-shell-prompt-damage.test.ts index f7cb9f1e050..d313e8ce8a9 100644 --- a/src/shared/windows-shell-prompt-damage.test.ts +++ b/src/shared/windows-shell-prompt-damage.test.ts @@ -67,18 +67,23 @@ describe('a prompt a Windows shell would damage on the launch line', () => { expect(plan('fix the "foo bar" bug', 'posix')?.launchFile).toBeUndefined() }) - it('pastes a PowerShell draft holding `"` or ending in `\\` instead of prefilling it', () => { - const draft = (text: string) => + // 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: 'powershell' + shell }) - expect(draft('say "hi"')).toBeNull() - expect(draft('see C:\\dir\\')).toBeNull() - expect(draft('say hi')?.launchCommand).toBe("claude --prefill 'say hi'") + 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', () => { @@ -114,12 +119,9 @@ describe('a prompt a Windows shell would damage on the launch line', () => { }) it('says in plain words why a Windows shell draft was not launched', () => { - expect(windowsDraftRefusal('line one\nline two', 'powershell')).toMatch( - /Windows shell would break this draft on the agent's command line \(it has a line break or other control character, or on PowerShell a double quote, a %NAME% pair or a trailing backslash\), so the agent was not started/ + expect(windowsDraftRefusal('claude', 'win32')).toMatch( + /Windows shell would break this draft on the agent's command line, so the agent was not started/ ) - expect(windowsDraftRefusal('see C:\\dir\\', 'powershell')).not.toBeNull() - expect(windowsDraftRefusal('say "hi"', 'powershell')).not.toBeNull() - expect(windowsDraftRefusal('say "hi"', 'cmd')).toBeNull() - expect(windowsDraftRefusal('line one\nline two', 'posix')).toBeNull() + expect(windowsDraftRefusal('claude', 'darwin')).toBeNull() }) })