From 0599ae2f56a1552e004a628b8272117628f3db2e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 03:08:42 -0700 Subject: [PATCH] fix(launch-prompt): on Windows, paste what the line can't carry where main pasted it Main pastes an AI button's or notes send's prompt once the agent runs, so the agent's history, /resume, the chat view and AI Vault show the user's text. A launch file replaced nearly every such prompt on Windows with a pointer. The caller's paste is now one of 'never', 'when-host-proves-agent' (#24257's guarded paste) or 'once-agent-runs' (main's desktop paste for submit-after-ready prompts). On Windows a prompt the line cannot carry exactly goes to an 'once-agent-runs' paste first; a launch file is left for the lines main would have typed damaged or too long. Temporary, until the agent's own argv carries multi-line text past cmd without a shim re-parsing it. --- .../orca-runtime-create-agent-session.ts | 2 +- ...resolve-mobile-session-terminal-command.ts | 2 +- ...runtime-resolve-worktree-removal-target.ts | 2 +- .../runtime/runtime-worktree-agent-startup.ts | 4 +- .../folder-workspace-composer-submit.ts | 2 +- .../full-creation-startup.test.ts | 2 +- .../composer-state/full-submit-preparation.ts | 2 +- .../composer-state/quick-startup-plan.ts | 2 +- .../lib/launch-agent-background-session.ts | 2 +- ...h-agent-in-new-tab-windows-quoting.test.ts | 20 +++++++++ .../lib/launch-agent-startup-prompt-plan.ts | 8 +++- .../src/lib/launch-work-item-direct-agent.ts | 2 +- .../source-control-agent-action-plan.test.ts | 20 +++++++++ .../lib/source-control-agent-action-plan.ts | 3 +- src/shared/launch-host.ts | 2 +- src/shared/launch-prompt-carry.test.ts | 43 +++++++++++++++++-- src/shared/launch-prompt-carry.ts | 37 +++++++++++----- src/shared/launch-prompt-plan.test-fixture.ts | 8 ++-- src/shared/tui-agent-startup.ts | 3 +- 19 files changed, 134 insertions(+), 32 deletions(-) diff --git a/src/main/runtime/orca-runtime-create-agent-session.ts b/src/main/runtime/orca-runtime-create-agent-session.ts index 63370145083..34f2a96e4ce 100644 --- a/src/main/runtime/orca-runtime-create-agent-session.ts +++ b/src/main/runtime/orca-runtime-create-agent-session.ts @@ -189,7 +189,7 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe isRemote: Boolean(workspace.connectionId) }), // Why: this create returns before the agent is ready, so nothing pastes after it. - canPasteAfterReady: false + paste: 'never' }) if (!planned) { throw new Error('agent_session_identity_required') diff --git a/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts b/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts index a619bbb6ee9..50b05b07e03 100644 --- a/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts +++ b/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts @@ -63,7 +63,7 @@ export class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends OrcaRunt prompt: opts.agentPrompt ?? '', host: thisOrcaLaunchHost({ launchPlatform, isRemote }), // Why: a quick command has no paste after ready. - canPasteAfterReady: false + paste: 'never' }) if (!planned) { throw new Error(`Could not build launch command for ${opts.agent}.`) diff --git a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts index dc9ef38d813..2617268d4c1 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -272,7 +272,7 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith prompt: opts.startupPrompt ?? '', ...(opts.launchFile ? { launchFile: opts.launchFile } : {}), host: thisOrcaLaunchHost({ launchPlatform: platform, isRemote }), - canPasteAfterReady: opts.onStartupPromptCarry !== undefined + paste: opts.onStartupPromptCarry ? 'when-host-proves-agent' : 'never' }) if (!planned) { // Why: an explicit agent that yields no plan would otherwise spawn a bare diff --git a/src/main/runtime/runtime-worktree-agent-startup.ts b/src/main/runtime/runtime-worktree-agent-startup.ts index 447daa30d54..33e65cd8489 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.ts @@ -162,8 +162,8 @@ export function buildWorktreeStartupForAgent( isRemote: repoIsRemote(repo), launchPlatform: environment.getLaunchPlatform() }), - // A caller that cannot paste takes a follow-up and sends it once the agent is ready. - canPasteAfterReady: true + // The caller, or else the host's follow-up, pastes what the line leaves once the agent runs. + paste: 'when-host-proves-agent' }) if (!planned) { throw new Error(`Could not build launch command for ${agent}.`) diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index 960291aa1e3..4149930dee4 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -134,7 +134,7 @@ export async function submitFolderWorkspaceCreate({ launchPlatform, isRemote: launchIsRemote }), - canPasteAfterReady: true + paste: 'when-host-proves-agent' }), note ) diff --git a/src/renderer/src/hooks/composer-state/full-creation-startup.test.ts b/src/renderer/src/hooks/composer-state/full-creation-startup.test.ts index 8bfefea7195..33b00e8c2ac 100644 --- a/src/renderer/src/hooks/composer-state/full-creation-startup.test.ts +++ b/src/renderer/src/hooks/composer-state/full-creation-startup.test.ts @@ -17,7 +17,7 @@ describe('the full composer’s renderer-spawned startup', () => { platform: 'linux', isRemote: true, host: { paired: false, provesAgentInFront: true }, - canPasteAfterReady: true + paste: 'when-host-proves-agent' }), prompt ) diff --git a/src/renderer/src/hooks/composer-state/full-submit-preparation.ts b/src/renderer/src/hooks/composer-state/full-submit-preparation.ts index 68a1fa6df82..44f0c86e62e 100644 --- a/src/renderer/src/hooks/composer-state/full-submit-preparation.ts +++ b/src/renderer/src/hooks/composer-state/full-submit-preparation.ts @@ -210,7 +210,7 @@ export function useFullSubmitPreparation(input: FullSubmitPreparationInput) { launchPlatform: selectedRepoAgentLaunchPlatform, isRemote: selectedRepoIsRemote }), - canPasteAfterReady: true + paste: 'when-host-proves-agent' }), submitStartupPrompt ) diff --git a/src/renderer/src/hooks/composer-state/quick-startup-plan.ts b/src/renderer/src/hooks/composer-state/quick-startup-plan.ts index 2a8feb0143a..1517d2079ff 100644 --- a/src/renderer/src/hooks/composer-state/quick-startup-plan.ts +++ b/src/renderer/src/hooks/composer-state/quick-startup-plan.ts @@ -98,7 +98,7 @@ export function buildQuickComposerStartup(input: QuickComposerStartupInput): Qui shell: input.shell ?? undefined, isRemote: input.isRemote, host: input.host, - canPasteAfterReady: true + paste: 'when-host-proves-agent' }), prompt ) diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index 34839d54905..9c6ba6f2714 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -89,7 +89,7 @@ export async function launchAgentBackgroundSession( launchPlatform, isRemote }), - canPasteAfterReady: true + paste: 'when-host-proves-agent' }) if (!planned) { return null diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-windows-quoting.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab-windows-quoting.test.ts index 63053e0e90a..568229d01e4 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab-windows-quoting.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab-windows-quoting.test.ts @@ -181,6 +181,26 @@ describe('launchAgentInNewTab Windows shell quoting', () => { expect(mockCreateWebRuntimeAgentSessionTerminalWithLaunchDraft).not.toHaveBeenCalled() }) + it('pastes a multi-line AI-button prompt on Windows, as main did, instead of a launch file', async () => { + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + const prompt = 'Fix the failing checks.\nThen push.' + + launchAgentInNewTab({ + agent: 'codex', + worktreeId: 'wt-1', + prompt, + promptDelivery: 'submit-after-ready', + launchPlatform: 'win32' + }) + + const queued = mockQueueTabStartupCommand.mock.calls[0]?.[1] + expect(queued?.launchFile).toBeUndefined() + expect(queued?.command).not.toContain('Fix the failing checks') + expect(mockPasteDraftWhenAgentReady).toHaveBeenCalledWith( + expect.objectContaining({ tabId: 'tab-1', content: prompt, agent: 'codex', submit: true }) + ) + }) + it('uses the explicit startup shell platform when building draft launch commands', async () => { const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') diff --git a/src/renderer/src/lib/launch-agent-startup-prompt-plan.ts b/src/renderer/src/lib/launch-agent-startup-prompt-plan.ts index 7e41a5fdf16..c40d74f94da 100644 --- a/src/renderer/src/lib/launch-agent-startup-prompt-plan.ts +++ b/src/renderer/src/lib/launch-agent-startup-prompt-plan.ts @@ -72,7 +72,13 @@ export function planLaunchAgentStartupPrompt(args: { if (prompt.length > 0 && args.host.paired && promptDelivery === 'submit-after-ready') { return pasteAfterReady(cleanPlan(), prompt, true) } - const planned = planLaunchPrompt({ ...base, prompt, host: args.host, canPasteAfterReady: true }) + const planned = planLaunchPrompt({ + ...base, + prompt, + host: args.host, + // Why: main pastes a submit-after-ready prompt once the agent runs and types the rest. + paste: promptDelivery === 'submit-after-ready' ? 'once-agent-runs' : 'when-host-proves-agent' + }) if (!planned) { return { startupPlan: null, pasteDraftAfterLaunch: null, submitPastedPrompt: false } } diff --git a/src/renderer/src/lib/launch-work-item-direct-agent.ts b/src/renderer/src/lib/launch-work-item-direct-agent.ts index 6a75ba2fc8a..ab97738be23 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent.ts @@ -83,7 +83,7 @@ export function buildDirectWorkItemAgentStartupPlan(args: { ...planInputs, prompt: args.draftContent, host: args.host, - canPasteAfterReady: true + paste: 'once-agent-runs' }) if (!planned) { return { startupPlan: null, promptOnLaunchCommand: false, startupPlanFailed: true } diff --git a/src/renderer/src/lib/source-control-agent-action-plan.test.ts b/src/renderer/src/lib/source-control-agent-action-plan.test.ts index 5441f2cde68..ebd6bd5aa7a 100644 --- a/src/renderer/src/lib/source-control-agent-action-plan.test.ts +++ b/src/renderer/src/lib/source-control-agent-action-plan.test.ts @@ -55,6 +55,26 @@ describe('planSourceControlAgentActionLaunch', () => { expect(result.ok && result.summary).not.toContain('included in the launch command') }) + // Why: main pastes an AI button's prompt on Windows, so the agent's history shows the user's + // text; only a typed prompt the line would damage rides a launch file. + it('pastes a multi-line AI-button prompt on Windows, and files the same prompt typed', () => { + const plan = (promptDelivery: 'submit-after-ready' | 'auto-submit') => + planSourceControlAgentActionLaunch({ + agent: 'claude', + commandInput: 'Fix the failing checks.\nThen push.', + promptDelivery, + detectedAgents: ['claude'], + platform: 'win32', + terminalWindowsShell: 'cmd.exe' + }) + + const button = plan('submit-after-ready') + expect(button.ok && button.delivery).toBe('paste-submit') + expect(button.ok && button.plan.launchCommand).not.toContain('task-context') + const typed = plan('auto-submit') + expect(typed.ok && typed.summary).toContain('private file') + }) + it('includes per-action CLI arguments in submit-after-ready launch plans', () => { const result = planSourceControlAgentActionLaunch({ agent: 'codex', diff --git a/src/renderer/src/lib/source-control-agent-action-plan.ts b/src/renderer/src/lib/source-control-agent-action-plan.ts index 712d065abde..e28c01ea86d 100644 --- a/src/renderer/src/lib/source-control-agent-action-plan.ts +++ b/src/renderer/src/lib/source-control-agent-action-plan.ts @@ -160,7 +160,8 @@ export function planSourceControlAgentActionLaunch(args: { launchPlatform: platform, isRemote }), - canPasteAfterReady: true + paste: + args.promptDelivery === 'submit-after-ready' ? 'once-agent-runs' : 'when-host-proves-agent' }) switch (planned?.carry) { case undefined: diff --git a/src/shared/launch-host.ts b/src/shared/launch-host.ts index 01dcdaa8c91..2666fa39d94 100644 --- a/src/shared/launch-host.ts +++ b/src/shared/launch-host.ts @@ -4,7 +4,7 @@ export type LaunchHost = { * stage nor accompany with a launch file. Temporary, until paired hosts advertise both. */ paired: boolean /** Whether the host can prove the launched agent holds its terminal before a paste - * (`launched-agent-foreground`). A Windows host cannot, so it refuses a paste. */ + * (`launched-agent-foreground`). A Windows host cannot, so #24257's guarded paste is refused. */ provesAgentInFront: boolean } diff --git a/src/shared/launch-prompt-carry.test.ts b/src/shared/launch-prompt-carry.test.ts index d4a18be1e65..aad6793ede1 100644 --- a/src/shared/launch-prompt-carry.test.ts +++ b/src/shared/launch-prompt-carry.test.ts @@ -14,7 +14,7 @@ function plan( extra: Partial> = {} ) { const platform = extra.platform ?? 'darwin' - // A local launch on that platform, by a caller that pastes, unless the test says otherwise. + // A local launch on that platform, with #24257's guarded paste, unless the test says otherwise. const host = describeLaunchHost({ launchPlatform: platform, isRemote: false, @@ -27,7 +27,7 @@ function plan( cmdOverrides: {}, platform, host, - canPasteAfterReady: true, + paste: 'when-host-proves-agent', ...extra }) } @@ -84,7 +84,7 @@ describe('where a launch prompt rides', () => { // (agentSession.create, a phone quick command) must not refuse it now. it('keeps a file-sized prompt on the line for a caller that cannot paste', () => { const prompt = 'g'.repeat(MAX_INLINE_LAUNCH_PROMPT_CHARS + 1) - const planned = plan('gemini', prompt, { canPasteAfterReady: false }) + const planned = plan('gemini', prompt, { paste: 'never' }) expect(planned?.carry).toBe('on-line') expect(planned?.carry === 'on-line' && planned.plan.launchCommand).toContain('gggg') }) @@ -140,6 +140,40 @@ describe('a host that types the line raw', () => { expect(plan('gemini', prompt, { platform: 'win32', shell: 'cmd' })?.carry).toBe('on-line') }) + // Why: main pastes an AI button's or notes send's prompt once the agent runs, so the agent's + // history shows the user's text; a pointer replaces only a line main would type damaged. + it.each(['claude', 'codex'])( + 'pastes what a Windows line cannot carry for a caller whose paste main used, for %s', + (agent) => { + const paste = 'once-agent-runs' as const + const multiLine = 'fix the build\nthen run the tests' + const pasted = plan(agent, multiLine, { platform: 'win32', shell: 'cmd', paste }) + expect(pasted).toMatchObject({ carry: 'paste-after-ready', text: multiLine }) + const long = 'y'.repeat(20_000) + expect(plan(agent, long, { platform: 'win32', shell: 'cmd', paste })?.carry).toBe( + 'paste-after-ready' + ) + expect(plan(agent, 'fix it', { platform: 'win32', shell: 'cmd', paste })?.carry).toBe( + 'on-line' + ) + const typed = plan(agent, multiLine, { platform: 'win32', shell: 'cmd' }) + expect(typed?.carry).toBe('launch-file') + } + ) + + it('pastes a Windows-damaged prompt for any agent when the caller’s paste is main’s', () => { + const extra = { platform: 'win32' as const, shell: 'powershell' as const } + expect(plan('gemini', 'say "hi"', { ...extra, paste: 'once-agent-runs' })?.carry).toBe( + 'paste-after-ready' + ) + expect(plan('gemini', 'say "hi"', extra)?.carry).toBe('on-line') + }) + + it('still points Claude at a file past the argv ceiling on a POSIX host, whatever the paste', () => { + const planned = plan('claude', 'y'.repeat(20_000), { paste: 'once-agent-runs' }) + expect(planned?.carry).toBe('launch-file') + }) + // Why: a host that cannot prove the agent holds its terminal refuses a paste (#24257), so the // line carries what the paste would have, as main typed it; Claude and Codex still get the file. it('keeps a prompt on the line of a host that cannot paste, unless a file can carry it', () => { @@ -153,6 +187,9 @@ describe('a host that types the line raw', () => { expect(plan('gemini', 'y'.repeat(20_000), extra)?.carry).toBe('on-line') expect(plan('claude', 'y'.repeat(20_000), extra)?.carry).toBe('launch-file') expect(plan('aider', prompt, extra)?.carry).toBe('paste-after-ready') + // An AI button's paste is main's, which ran in WSL too. + const button = { ...extra, paste: 'once-agent-runs' as const } + expect(plan('gemini', 'y'.repeat(20_000), button)?.carry).toBe('paste-after-ready') }) it('pastes on a paired host what its line cannot carry typed, even for Claude', () => { diff --git a/src/shared/launch-prompt-carry.ts b/src/shared/launch-prompt-carry.ts index 1c98fb5e4f9..a54f386a051 100644 --- a/src/shared/launch-prompt-carry.ts +++ b/src/shared/launch-prompt-carry.ts @@ -69,6 +69,16 @@ export function launchPromptNeedsPasteRefusal( 'paste the prompt once it opens.' } +/** + * The paste a caller has for a prompt left until the agent is ready: + * - `never`: none, so the line carries the prompt, as main did; + * - `when-host-proves-agent`: #24257's guarded paste, refused where the host cannot prove the agent + * is in front; + * - `once-agent-runs`: the desktop paste main uses for AI buttons and notes sends on every host, + * written once the agent's process owns the terminal. + */ +export type LaunchPromptPaste = 'never' | 'when-host-proves-agent' | 'once-agent-runs' + export type CarriedPlanArgs = { agent: TuiAgent prompt: string @@ -78,9 +88,8 @@ export type CarriedPlanArgs = { launchFile?: LaunchFile /** The host the launch runs on (`describeLaunchHost`). */ host: LaunchHost - /** Whether the caller pastes a prompt left for after the agent is ready. One that cannot still - * starts the agent with the prompt on its line, as main did. */ - canPasteAfterReady: boolean + /** The caller's paste for a prompt the line does not carry. */ + paste: LaunchPromptPaste } export function agentReadsLaunchFile(agent: TuiAgent): boolean { @@ -106,11 +115,11 @@ function windowsLineCarriesExactly(prompt: string, line: string, shell: AgentSta /** * The one carry rule. The prompt rides the agent's line: a host that stages (POSIX, SSH, WSL) * stages it when it is long or multi-line, and a Windows host types it when its shell carries the - * text exactly. It rides a launch file when it is past the argv ceiling, when a Windows line would - * damage it, or past a paired host's typed budget. A launch file goes only to an agent measured - * reading one, on a host that writes it. Otherwise the prompt is pasted once the agent is ready, - * unless the host cannot prove the agent is in front to paste into or the caller has no paste: - * then the line carries it, as main typed it. + * text exactly. When the line cannot (past the argv ceiling, damaged by a Windows shell, past a + * paired host's typed budget), it rides a launch file, which goes only to an agent measured reading + * one, on a host that writes it; on Windows, a caller whose paste main used gets that paste first. + * Failing a file it is pasted once the agent is ready, unless the caller's paste cannot reach the + * agent on this host: then the line carries it, as main typed it. */ export function carryLaunchPrompt( args: A, @@ -135,12 +144,20 @@ export function carryLaunchPrompt | null => { - const pasteIsSafe = args.host.provesAgentInFront && args.canPasteAfterReady - const plan = pasteIsSafe ? null : buildLine(args) + const plan = pasteReachesAgent ? null : buildLine(args) return plan ? { carry: 'on-line', plan } : pasteAfterReady() } const viaLaunchFile = (): LaunchPromptPlan

| null => { + // Why paste first on Windows: main pastes this caller's prompts, so the agent gets the user's + // text; there a pointer would replace nearly every generated (multi-line) prompt. Temporary, + // until the agent's own argv carries multi-line text past cmd without a shim re-parsing it. + if (args.paste === 'once-agent-runs' && args.platform === 'win32') { + return pasteAfterReady() + } // Why: an agent not measured reading the file would stop on an approval or refuse the path. if (args.host.paired || !agentReadsLaunchFile(args.agent)) { return lineOrPaste() diff --git a/src/shared/launch-prompt-plan.test-fixture.ts b/src/shared/launch-prompt-plan.test-fixture.ts index 2a611152790..fec1f6cbfe1 100644 --- a/src/shared/launch-prompt-plan.test-fixture.ts +++ b/src/shared/launch-prompt-plan.test-fixture.ts @@ -18,8 +18,8 @@ export type PlannedLaunchForTest = AgentStartupPlan & { /** `planLaunchPrompt`, flattened; null where it answers null, or for an empty prompt unless * `allowEmptyPromptLaunch`, as the builder did before the outcome was explicit. */ export function planLaunchForTest( - args: Omit & - Partial> & { + args: Omit & + Partial> & { allowEmptyPromptLaunch?: boolean } ): PlannedLaunchForTest | null { @@ -29,7 +29,7 @@ export function planLaunchForTest( } const planned = planLaunchPrompt({ ...launchArgs, - // A local launch on the platform under test, by a caller that pastes, unless the test says. + // A local launch on the platform under test, with #24257's guarded paste, unless the test says. host: args.host ?? describeLaunchHost({ @@ -38,7 +38,7 @@ export function planLaunchForTest( hostPlatform: args.platform, paired: false }), - canPasteAfterReady: args.canPasteAfterReady ?? true + paste: args.paste ?? 'when-host-proves-agent' }) if (!planned) { return null diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index 23a47218339..acbb9ec7f76 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -15,6 +15,7 @@ import { carryLaunchPrompt, launchFileDirectoryGrant, windowsShellDamagesPrompt, + type LaunchPromptPaste, type LaunchPromptPlan } from './launch-prompt-carry' import { TUI_AGENT_CONFIG } from './tui-agent-config' @@ -76,7 +77,7 @@ export type AgentLaunchPromptArgs = AgentStartupPlanInputs & { /** See `CarriedPlanArgs`. */ host: LaunchHost /** See `CarriedPlanArgs`. */ - canPasteAfterReady: boolean + paste: LaunchPromptPaste } /**