From e92f38d7225f250c888827dc683df421dcdb32eb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 06:21:57 -0700 Subject: [PATCH] fix(launch-prompt): the PowerShell a pane is spawned as is a host fact c729128c3af typed a PowerShell prompt holding `"` or a trailing `\` on the line because it treated 5.1 and 7 as indistinguishable, so 5.1 broke it as main does. But this Orca chooses the executable it spawns: the requested shell, the PowerShell implementation setting, then whether pwsh.exe is installed. LaunchHost gains windowsPowerShell, derived by spawnedWindowsPowerShell from those same inputs (main reads its pwsh probe cache, the renderer main's probe over the existing IPC). On powershell.exe those prompts are measured damaged and get a launch file (or main's paste on an AI button); on pwsh.exe they ride the line, which carried them exactly. The PowerShell stays unknown, and main's delivery decides, for an SSH or paired host and before the pwsh probe answers. --- src/main/pwsh.ts | 9 +++ .../orca-runtime-create-agent-session.ts | 3 +- ...resolve-mobile-session-terminal-command.ts | 2 +- ...runtime-resolve-worktree-removal-target.ts | 7 +- .../runtime/runtime-worktree-agent-startup.ts | 3 +- .../runtime/this-orca-launch-host.test.ts | 53 ++++++++++++++ src/main/runtime/this-orca-launch-host.ts | 26 ++++++- .../full-creation-startup.test.ts | 7 +- src/renderer/src/lib/launch-file-host.test.ts | 41 ++++++++++- src/renderer/src/lib/launch-file-host.ts | 19 ++++- .../lib/launch-work-item-direct-agent.test.ts | 30 ++++++-- .../src/lib/local-pwsh-availability.ts | 22 ++++++ src/shared/launch-host.test.ts | 72 ++++++++++++++++++- src/shared/launch-host.ts | 47 +++++++++++- src/shared/launch-prompt-carrier.test.ts | 14 +++- src/shared/launch-prompt-carry.test.ts | 14 +++- src/shared/launch-prompt-carry.ts | 4 +- src/shared/windows-launch-line.test.ts | 45 +++++++++--- src/shared/windows-launch-line.ts | 18 +++-- .../windows-shell-prompt-damage.test.ts | 7 +- 20 files changed, 404 insertions(+), 39 deletions(-) create mode 100644 src/main/runtime/this-orca-launch-host.test.ts create mode 100644 src/renderer/src/lib/local-pwsh-availability.ts diff --git a/src/main/pwsh.ts b/src/main/pwsh.ts index 14f77f4d826..04001c85b12 100644 --- a/src/main/pwsh.ts +++ b/src/main/pwsh.ts @@ -120,6 +120,15 @@ export function isPwshAvailableAsync(): Promise { return pwshProbeInFlight } +/** The probe's answer when it has one, without blocking; null starts a probe for a later caller. */ +export function cachedPwshAvailability(): boolean | null { + if (pwshAvailableCache && isCacheFresh(pwshAvailableCache)) { + return pwshAvailableCache.available + } + void isPwshAvailableAsync() + return null +} + export function warmPwshAvailabilityCache(): Promise { if (pwshAvailableCache?.available) { return Promise.resolve(true) diff --git a/src/main/runtime/orca-runtime-create-agent-session.ts b/src/main/runtime/orca-runtime-create-agent-session.ts index 34f2a96e4ce..403d649516c 100644 --- a/src/main/runtime/orca-runtime-create-agent-session.ts +++ b/src/main/runtime/orca-runtime-create-agent-session.ts @@ -186,7 +186,8 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe prompt: request.prompt ?? '', host: thisOrcaLaunchHost({ launchPlatform: startupArgs.platform, - isRemote: Boolean(workspace.connectionId) + isRemote: Boolean(workspace.connectionId), + settings }), // Why: this create returns before the agent is ready, so nothing pastes after it. paste: 'never' 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 50b05b07e03..358f9991b48 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 @@ -61,7 +61,7 @@ export class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends OrcaRunt isRemote }), prompt: opts.agentPrompt ?? '', - host: thisOrcaLaunchHost({ launchPlatform, isRemote }), + host: thisOrcaLaunchHost({ launchPlatform, isRemote, settings }), // Why: a quick command has no paste after ready. paste: 'never' }) 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 2617268d4c1..2ddc02021d0 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -271,7 +271,12 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith ...planInputs, prompt: opts.startupPrompt ?? '', ...(opts.launchFile ? { launchFile: opts.launchFile } : {}), - host: thisOrcaLaunchHost({ launchPlatform: platform, isRemote }), + host: thisOrcaLaunchHost({ + launchPlatform: platform, + isRemote, + settings, + windowsShellOverride: opts.shellOverride + }), paste: opts.onStartupPromptCarry ? 'when-host-proves-agent' : 'never' }) if (!planned) { diff --git a/src/main/runtime/runtime-worktree-agent-startup.ts b/src/main/runtime/runtime-worktree-agent-startup.ts index 33e65cd8489..d09734a518f 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.ts @@ -160,7 +160,8 @@ export function buildWorktreeStartupForAgent( prompt: environment.prompt ?? '', host: thisOrcaLaunchHost({ isRemote: repoIsRemote(repo), - launchPlatform: environment.getLaunchPlatform() + launchPlatform: environment.getLaunchPlatform(), + settings }), // The caller, or else the host's follow-up, pastes what the line leaves once the agent runs. paste: 'when-host-proves-agent' diff --git a/src/main/runtime/this-orca-launch-host.test.ts b/src/main/runtime/this-orca-launch-host.test.ts new file mode 100644 index 00000000000..dce8b2be36e --- /dev/null +++ b/src/main/runtime/this-orca-launch-host.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cachedPwshAvailability } from '../pwsh' +import { thisOrcaLaunchHost } from './this-orca-launch-host' + +vi.mock('../pwsh', () => ({ cachedPwshAvailability: vi.fn(() => null) })) + +const realPlatform = process.platform + +function onPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }) +} + +// Why: this Orca spawns its own Windows panes, so it knows the PowerShell that gets the line. +describe('the PowerShell a launch this Orca runs lands in', () => { + afterEach(() => { + onPlatform(realPlatform) + vi.mocked(cachedPwshAvailability).mockReturnValue(null) + }) + + it('follows the requested shell, then the settings and the pwsh probe, on this Windows machine', () => { + onPlatform('win32') + const settings = { + terminalWindowsShell: 'powershell.exe', + terminalWindowsPowerShellImplementation: 'auto' as const + } + const host = (windowsShellOverride?: string) => + thisOrcaLaunchHost({ + launchPlatform: 'win32', + isRemote: false, + settings, + windowsShellOverride + }) + expect(host().windowsPowerShell).toBeNull() + vi.mocked(cachedPwshAvailability).mockReturnValue(false) + expect(host().windowsPowerShell).toBe('powershell.exe') + expect(host('pwsh.exe').windowsPowerShell).toBe('pwsh.exe') + expect( + thisOrcaLaunchHost({ launchPlatform: 'win32', isRemote: true, settings }).windowsPowerShell + ).toBeNull() + }) + + it('knows none for a launch on another platform', () => { + onPlatform('darwin') + vi.mocked(cachedPwshAvailability).mockReturnValue(true) + expect( + thisOrcaLaunchHost({ + launchPlatform: 'win32', + isRemote: false, + settings: { terminalWindowsShell: 'pwsh.exe' } + }).windowsPowerShell + ).toBeNull() + }) +}) diff --git a/src/main/runtime/this-orca-launch-host.ts b/src/main/runtime/this-orca-launch-host.ts index 3a2d28f3f99..187b93e242e 100644 --- a/src/main/runtime/this-orca-launch-host.ts +++ b/src/main/runtime/this-orca-launch-host.ts @@ -1,9 +1,31 @@ -import { describeLaunchHost, type LaunchHost } from '../../shared/launch-host' +import { cachedPwshAvailability } from '../pwsh' +import { + describeLaunchHost, + spawnedWindowsPowerShell, + type LaunchHost, + type WindowsShellSettings +} from '../../shared/launch-host' /** The host facts for a launch this Orca runs itself, locally or over its own SSH connection. */ export function thisOrcaLaunchHost(args: { launchPlatform: NodeJS.Platform isRemote: boolean + settings: WindowsShellSettings + /** The shell this launch asked for (`--shell`), which outranks the setting. */ + windowsShellOverride?: string | null }): LaunchHost { - return describeLaunchHost({ ...args, hostPlatform: process.platform, paired: false }) + const local = !args.isRemote && args.launchPlatform === 'win32' && process.platform === 'win32' + return describeLaunchHost({ + launchPlatform: args.launchPlatform, + isRemote: args.isRemote, + hostPlatform: process.platform, + paired: false, + windowsPowerShell: local + ? spawnedWindowsPowerShell({ + settings: args.settings, + windowsShellOverride: args.windowsShellOverride, + pwshAvailable: cachedPwshAvailability() + }) + : null + }) } 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 b377610f81d..6d5db20674d 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 @@ -16,7 +16,12 @@ describe('the full composer’s renderer-spawned startup', () => { cmdOverrides: {}, platform: 'linux', isRemote: true, - host: { paired: false, provesAgentInFront: true, takesLaunchFile: true }, + host: { + paired: false, + provesAgentInFront: true, + takesLaunchFile: true, + windowsPowerShell: null + }, paste: 'when-host-proves-agent' }), prompt diff --git a/src/renderer/src/lib/launch-file-host.test.ts b/src/renderer/src/lib/launch-file-host.test.ts index 53256c7ca09..167a04742b1 100644 --- a/src/renderer/src/lib/launch-file-host.test.ts +++ b/src/renderer/src/lib/launch-file-host.test.ts @@ -1,5 +1,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { launchHostIsPaired } from './launch-file-host' +import { useAppStore } from '@/store' +import { clientLaunchHost, launchHostIsPaired } from './launch-file-host' +import { localPwshAvailability } from './local-pwsh-availability' + +vi.mock('./local-pwsh-availability', () => ({ localPwshAvailability: vi.fn(() => null) })) import { buildQuickComposerStartup } from '@/hooks/composer-state/quick-startup-plan' describe('whether a launch lands on a paired host', () => { @@ -26,7 +30,12 @@ describe('the quick composer on a paired host', () => { platform: 'linux', shell: null, isRemote: false, - host: { paired: true, provesAgentInFront: true, takesLaunchFile: false }, + host: { + paired: true, + provesAgentInFront: true, + takesLaunchFile: false, + windowsPowerShell: null + }, telemetrySource: 'sidebar' }) expect(startup.backendStartup).toBeUndefined() @@ -34,3 +43,31 @@ describe('the quick composer on a paired host', () => { expect(startup.startupPlan?.pastePromptAfterReady).toBe('fix the build\nthen run the tests') }) }) + +// Why: this client spawns its own Windows panes, so it knows the PowerShell that gets the line. +describe('the PowerShell a local Windows launch from this client lands in', () => { + afterEach(() => { + vi.mocked(localPwshAvailability).mockReturnValue(null) + }) + + const host = (isRemote = false) => + clientLaunchHost({ runtimeEnvironmentId: null, launchPlatform: 'win32', isRemote }) + + it('follows this client’s shell settings and the pwsh probe', () => { + const settings = useAppStore.getState().settings + useAppStore.setState({ + settings: { + ...settings!, + terminalWindowsShell: 'powershell.exe', + terminalWindowsPowerShellImplementation: 'auto' + } + }) + expect(host().windowsPowerShell).toBeNull() + vi.mocked(localPwshAvailability).mockReturnValue(false) + expect(host().windowsPowerShell).toBe('powershell.exe') + vi.mocked(localPwshAvailability).mockReturnValue(true) + expect(host().windowsPowerShell).toBe('pwsh.exe') + expect(host(true).windowsPowerShell).toBeNull() + useAppStore.setState({ settings }) + }) +}) diff --git a/src/renderer/src/lib/launch-file-host.ts b/src/renderer/src/lib/launch-file-host.ts index c1b7e5ed77f..59830ce5ac9 100644 --- a/src/renderer/src/lib/launch-file-host.ts +++ b/src/renderer/src/lib/launch-file-host.ts @@ -1,7 +1,13 @@ import { isWebRuntimeSessionActive } from '@/runtime/web-runtime-session-environment' import { CLIENT_PLATFORM } from '@/lib/new-workspace' -import { describeLaunchHost, type LaunchHost } from '../../../shared/launch-host' +import { useAppStore } from '@/store' +import { + describeLaunchHost, + spawnedWindowsPowerShell, + type LaunchHost +} from '../../../shared/launch-host' import { isWebClientLocation } from './web-client-location' +import { localPwshAvailability } from './local-pwsh-availability' /** * Whether a launch lands on a paired Orca: another Orca this client drives, which may be an older @@ -18,10 +24,19 @@ export function clientLaunchHost(args: { launchPlatform: NodeJS.Platform isRemote: boolean }): LaunchHost { + const paired = launchHostIsPaired(args.runtimeEnvironmentId) + const local = !paired && !args.isRemote && args.launchPlatform === 'win32' return describeLaunchHost({ launchPlatform: args.launchPlatform, isRemote: args.isRemote, hostPlatform: CLIENT_PLATFORM, - paired: launchHostIsPaired(args.runtimeEnvironmentId) + paired, + // Why this client's settings: a local pane is spawned from them, as its shell is. + windowsPowerShell: local + ? spawnedWindowsPowerShell({ + settings: useAppStore.getState().settings, + pwshAvailable: localPwshAvailability() + }) + : null }) } 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 a834b38c49d..ef9e68760ed 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 @@ -84,7 +84,12 @@ const settings = { describe('buildDirectWorkItemAgentStartupPlan', () => { it('omits native-chat preferences when the new workspace opens in terminal mode', () => { const result = buildDirectWorkItemAgentStartupPlan({ - host: { paired: false, provesAgentInFront: true, takesLaunchFile: true }, + host: { + paired: false, + provesAgentInFront: true, + takesLaunchFile: true, + windowsPowerShell: null + }, agent: 'codex', draftContent: 'Review issue 42', promptDelivery: 'draft', @@ -99,7 +104,12 @@ describe('buildDirectWorkItemAgentStartupPlan', () => { it('applies native-chat preferences when the new workspace opens in chat', () => { const result = buildDirectWorkItemAgentStartupPlan({ - host: { paired: false, provesAgentInFront: true, takesLaunchFile: true }, + host: { + paired: false, + provesAgentInFront: true, + takesLaunchFile: true, + windowsPowerShell: null + }, agent: 'codex', draftContent: 'Review issue 42', promptDelivery: 'draft', @@ -148,7 +158,12 @@ describe('buildDirectWorkItemAgentStartupPlan global arguments fallback', () => it('resolves the global Agents arguments when the launch names none', () => { const result = buildDirectWorkItemAgentStartupPlan({ - host: { paired: false, provesAgentInFront: true, takesLaunchFile: true }, + host: { + paired: false, + provesAgentInFront: true, + takesLaunchFile: true, + windowsPowerShell: null + }, agent: 'codex', draftContent: 'Fix the broken checks', promptDelivery: 'draft', @@ -162,7 +177,12 @@ describe('buildDirectWorkItemAgentStartupPlan global arguments fallback', () => it('lets an explicit per-action value win over the global one', () => { const result = buildDirectWorkItemAgentStartupPlan({ - host: { paired: false, provesAgentInFront: true, takesLaunchFile: true }, + host: { + paired: false, + provesAgentInFront: true, + takesLaunchFile: true, + windowsPowerShell: null + }, agent: 'codex', agentArgs: '--model gpt-5', draftContent: 'Fix the broken checks', @@ -180,7 +200,7 @@ describe('buildDirectWorkItemAgentStartupPlan global arguments fallback', () => describe('buildDirectWorkItemAgentStartupPlan submitted prompts', () => { const submit = (agent: 'claude' | 'gemini', draftContent: string, paired = false) => buildDirectWorkItemAgentStartupPlan({ - host: { paired, provesAgentInFront: true, takesLaunchFile: !paired }, + host: { paired, provesAgentInFront: true, takesLaunchFile: !paired, windowsPowerShell: null }, agent, draftContent, promptDelivery: 'submit-after-ready', diff --git a/src/renderer/src/lib/local-pwsh-availability.ts b/src/renderer/src/lib/local-pwsh-availability.ts new file mode 100644 index 00000000000..3761f38b080 --- /dev/null +++ b/src/renderer/src/lib/local-pwsh-availability.ts @@ -0,0 +1,22 @@ +let known: boolean | null = null +let reading = false + +/** + * Whether pwsh.exe is installed on this machine, from main's shared probe; null until it answers. + * The first call starts the read, so a launch before then is planned without it. + */ +export function localPwshAvailability(): boolean | null { + if (known === null && !reading && typeof window !== 'undefined' && window.api?.pwsh) { + reading = true + void window.api.pwsh + .isAvailable() + .then((available) => { + known = available + }) + .catch(() => {}) + .finally(() => { + reading = false + }) + } + return known +} diff --git a/src/shared/launch-host.test.ts b/src/shared/launch-host.test.ts index d7679cef75b..2c34b50232b 100644 --- a/src/shared/launch-host.test.ts +++ b/src/shared/launch-host.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { describeLaunchHost } from './launch-host' +import { describeLaunchHost, spawnedWindowsPowerShell } from './launch-host' describe('what a launch host can do with its prompt', () => { it.each([ @@ -19,8 +19,76 @@ describe('what a launch host can do with its prompt', () => { expect(describeLaunchHost({ isRemote, paired, launchPlatform, hostPlatform })).toEqual({ paired, provesAgentInFront: proves, - takesLaunchFile + takesLaunchFile, + windowsPowerShell: null }) } ) }) + +// Why: the PowerShell decides how `"` and a trailing `\` reach the agent, and this Orca picks it. +describe('the PowerShell a local Windows pane is spawned as', () => { + it.each([ + ['the pwsh shell', { terminalWindowsShell: 'pwsh.exe' }, undefined, null, 'pwsh.exe'], + [ + 'a requested pwsh over the setting', + { terminalWindowsShell: 'cmd.exe' }, + 'pwsh', + null, + 'pwsh.exe' + ], + [ + 'Windows PowerShell chosen explicitly', + { + terminalWindowsShell: 'powershell.exe', + terminalWindowsPowerShellImplementation: 'powershell.exe' + }, + undefined, + true, + 'powershell.exe' + ], + [ + 'auto with pwsh installed', + { terminalWindowsShell: '', terminalWindowsPowerShellImplementation: 'auto' }, + undefined, + true, + 'pwsh.exe' + ], + [ + 'auto without pwsh', + { terminalWindowsShell: 'powershell.exe', terminalWindowsPowerShellImplementation: 'auto' }, + undefined, + false, + 'powershell.exe' + ], + [ + 'auto before the probe answers', + { terminalWindowsShell: 'powershell.exe', terminalWindowsPowerShellImplementation: 'auto' }, + undefined, + null, + null + ], + ['cmd', { terminalWindowsShell: 'cmd.exe' }, undefined, true, null], + ['Git Bash', { terminalWindowsShell: 'git-bash' }, undefined, true, null] + ] as const)('%s', (_label, settings, windowsShellOverride, pwshAvailable, expected) => { + expect(spawnedWindowsPowerShell({ settings, windowsShellOverride, pwshAvailable })).toBe( + expected + ) + }) + + it('is not this machine’s to know for an SSH or paired host', () => { + for (const where of [ + { isRemote: true, paired: false }, + { isRemote: false, paired: true } + ]) { + expect( + describeLaunchHost({ + ...where, + launchPlatform: 'win32', + hostPlatform: 'win32', + windowsPowerShell: 'powershell.exe' + }).windowsPowerShell + ).toBeNull() + } + }) +}) diff --git a/src/shared/launch-host.ts b/src/shared/launch-host.ts index 20beb713b6e..7440e5e273e 100644 --- a/src/shared/launch-host.ts +++ b/src/shared/launch-host.ts @@ -1,3 +1,14 @@ +import type { GlobalSettings } from './global-settings-types' + +export type WindowsPowerShell = 'powershell.exe' | 'pwsh.exe' + +export type WindowsShellSettings = + | Partial< + Pick + > + | null + | undefined + /** What the host a launch runs on can do with its prompt, derived in one place from where it runs. */ export type LaunchHost = { /** Another Orca this client drives, possibly an older one: it is sent a command it may neither @@ -10,6 +21,36 @@ export type LaunchHost = { * SSH Windows host's relay may run its panes in WSL (its OpenSSH default shell, which this client * cannot see), where it writes none. Such a host gets the line or the paste instead. */ takesLaunchFile: boolean + /** The PowerShell a local Windows pane is spawned as, which decides how it hands `"` and a + * trailing `\` to the agent. Null where this Orca does not choose it (an SSH or paired host) or + * has not yet learned whether pwsh is installed. */ + windowsPowerShell: WindowsPowerShell | null +} + +/** + * The PowerShell this host spawns for a pane, resolved as the spawn resolves it: the requested + * shell, then the implementation setting, then whether pwsh.exe is installed. Null for a shell that + * is not PowerShell, or when that probe has not answered. + */ +export function spawnedWindowsPowerShell(args: { + settings: WindowsShellSettings + /** The shell this launch asked for, which outranks the setting. */ + windowsShellOverride?: string | null + pwshAvailable: boolean | null +}): WindowsPowerShell | null { + const shell = (args.windowsShellOverride ?? args.settings?.terminalWindowsShell ?? '').trim() + const name = shell.replaceAll('\\', '/').split('/').pop()?.toLowerCase() ?? '' + if (name === 'pwsh.exe' || name === 'pwsh') { + return 'pwsh.exe' + } + if (name !== '' && name !== 'powershell.exe' && name !== 'powershell') { + return null + } + const implementation = args.settings?.terminalWindowsPowerShellImplementation + if (implementation === 'powershell.exe' || implementation === 'pwsh.exe') { + return implementation + } + return args.pwshAvailable === null ? null : args.pwshAvailable ? 'pwsh.exe' : 'powershell.exe' } /** @@ -23,11 +64,15 @@ export function describeLaunchHost(args: { /** The platform of the machine this Orca runs on. */ hostPlatform: NodeJS.Platform paired: boolean + /** `spawnedWindowsPowerShell` on this machine, which a launch elsewhere does not use. */ + windowsPowerShell?: WindowsPowerShell | null }): LaunchHost { const runsElsewhere = args.isRemote || args.paired return { paired: args.paired, provesAgentInFront: (runsElsewhere ? args.launchPlatform : args.hostPlatform) !== 'win32', - takesLaunchFile: !args.paired && !(args.isRemote && args.launchPlatform === 'win32') + takesLaunchFile: !args.paired && !(args.isRemote && args.launchPlatform === 'win32'), + windowsPowerShell: + runsElsewhere || args.launchPlatform !== 'win32' ? null : (args.windowsPowerShell ?? null) } } diff --git a/src/shared/launch-prompt-carrier.test.ts b/src/shared/launch-prompt-carrier.test.ts index ff714410b18..02bbcc43d90 100644 --- a/src/shared/launch-prompt-carrier.test.ts +++ b/src/shared/launch-prompt-carrier.test.ts @@ -25,14 +25,24 @@ describe('the one launch-prompt decision every launch path builds through', () = ...base, prompt: 'fix it', platform: 'linux', - host: { paired: true, provesAgentInFront: true, takesLaunchFile: false } + host: { + paired: true, + provesAgentInFront: true, + takesLaunchFile: false, + windowsPowerShell: null + } }) expect(short?.carry).toBe('on-line') const long = planLaunchForTest({ ...base, prompt: 'fix it\nthen run the tests', platform: 'linux', - host: { paired: true, provesAgentInFront: true, takesLaunchFile: false } + host: { + paired: true, + provesAgentInFront: true, + takesLaunchFile: false, + windowsPowerShell: null + } }) expect(long?.carry).toBe('paste-after-ready') expect(long?.pasteAfterReady).toBe('fix it\nthen run the tests') diff --git a/src/shared/launch-prompt-carry.test.ts b/src/shared/launch-prompt-carry.test.ts index 1a0ba01551a..48dd1b18e65 100644 --- a/src/shared/launch-prompt-carry.test.ts +++ b/src/shared/launch-prompt-carry.test.ts @@ -181,7 +181,12 @@ describe('a host that types the line raw', () => { // A local WSL pane: a Linux line, on a Windows host that cannot read what holds it. const extra = { platform: 'linux' as const, - host: { paired: false, provesAgentInFront: false, takesLaunchFile: true } + host: { + paired: false, + provesAgentInFront: false, + takesLaunchFile: true, + windowsPowerShell: null + } } const prompt = 'fix the build\nthen run the tests' expect(plan('gemini', prompt, extra)?.carry).toBe('on-line') @@ -216,7 +221,12 @@ describe('a host that types the line raw', () => { it('pastes on a paired host what its line cannot carry typed, even for Claude', () => { const extra = { platform: 'linux' as const, - host: { paired: true, provesAgentInFront: true, takesLaunchFile: false } + host: { + paired: true, + provesAgentInFront: true, + takesLaunchFile: false, + windowsPowerShell: null + } } expect(plan('claude', 'fix it', extra)?.carry).toBe('on-line') expect(plan('claude', 'first line\nsecond line', extra)?.carry).toBe('paste-after-ready') diff --git a/src/shared/launch-prompt-carry.ts b/src/shared/launch-prompt-carry.ts index 1322acdc873..f5816cda0dc 100644 --- a/src/shared/launch-prompt-carry.ts +++ b/src/shared/launch-prompt-carry.ts @@ -170,7 +170,9 @@ export function carryLaunchPrompt { ['powershell', 'ml5', 'exact on 5.1 and 7', 'on-line'], ['powershell', 'ml9k', 'damaged on 5.1, lines run as commands on 7', 'launch-file'], ['powershell', 'e8191', 'exact on 5.1 and 7', 'on-line'], - ['powershell', 'pq', 'exact on 7, damaged on 5.1 (version unknown here)', 'on-line'], + ['powershell', 'pq', 'exact on 7, damaged on 5.1 (PowerShell unknown here)', 'on-line'], ['powershell', 'ppct', 'exact on 5.1 and 7', 'on-line'], - ['powershell', 'pbs', 'exact on 7, damaged on 5.1 (version unknown here)', 'on-line'], + ['powershell', 'pbs', 'exact on 7, damaged on 5.1 (PowerShell unknown here)', 'on-line'], ['posix', 'p2k', 'exact in Git Bash', 'on-line'], ['posix', 'p20k', 'never started in Git Bash', 'launch-file'], ['posix', 'ml5', 'exact in Git Bash', 'on-line'], @@ -88,6 +94,25 @@ describe('an agent.launch prompt on a Windows host, per measured shell', () => { expect(carry('codex', row, shell)).toBe(expected) }) + // Why: this Orca spawns the pane, so it knows which PowerShell gets the line. 5.1's measured + // damage gets a launch file, better than main; 7 keeps the line it carried exactly. + it.each<[Row, WindowsPowerShell, string, 'on-line' | 'launch-file']>([ + ['pq', 'powershell.exe', 'damaged on 5.1', 'launch-file'], + ['pbs', 'powershell.exe', 'damaged on 5.1', 'launch-file'], + ['ppct', 'powershell.exe', 'exact on 5.1', 'on-line'], + ['ml5', 'powershell.exe', 'exact on 5.1', 'on-line'], + ['p20k', 'powershell.exe', 'exact on 5.1', 'on-line'], + ['pq', 'pwsh.exe', 'exact on 7', 'on-line'], + ['pbs', 'pwsh.exe', 'exact on 7', 'on-line'], + ['ppct', 'pwsh.exe', 'exact on 7', 'on-line'], + ['ml9k', 'pwsh.exe', 'lines run as commands on 7', 'launch-file'] + ])('powershell %s spawned as %s (main: %s) rides %s', (row, ps, _main, expected) => { + const command = (agent: TuiAgent) => + `node C:/Users/neil/orca-qa/stack-final/win/bin/stub.js --qa-as=${agent}` + expect(carry('claude', row, 'powershell', command('claude'), ps)).toBe(expected) + expect(carry('codex', row, 'powershell', command('codex'), ps)).toBe(expected) + }) + // Why: a shim's cmd.exe re-reads the line, so cmd's cap, its `%NAME%` expansion and a cut at a // line break apply, and PowerShell hands a shim `"` and a trailing `\` the legacy way. it.each<[AgentStartupShell, Row, 'on-line' | 'launch-file']>([ diff --git a/src/shared/windows-launch-line.ts b/src/shared/windows-launch-line.ts index 787df4643a6..528a57c18ab 100644 --- a/src/shared/windows-launch-line.ts +++ b/src/shared/windows-launch-line.ts @@ -4,6 +4,7 @@ * `exact` arrived byte for byte, `damaged` was measured lost or changed, and `uncertain` was not * measured, so the caller does there what main does on its path. */ +import type { WindowsPowerShell } from './launch-host' import type { AgentStartupShell } from './tui-agent-startup-shell' import { TYPED_STARTUP_LINE_BUDGET_BYTES } from './typed-startup-line' @@ -72,7 +73,9 @@ function shellVerdict(line: string, shell: AgentStartupShell): WindowsLineVerdic export function windowsLaunchLineVerdict( prompt: string, line: string, - shell: AgentStartupShell + shell: AgentStartupShell, + /** The PowerShell the pane is spawned as (`LaunchHost.windowsPowerShell`), when known. */ + windowsPowerShell: WindowsPowerShell | null ): WindowsLineVerdict { if (hasKeyByte(line)) { return 'damaged' @@ -80,9 +83,16 @@ export function windowsLaunchLineVerdict( const verdict = shellVerdict(line, shell) const legacyArgsDamage = prompt.includes('"') || prompt.endsWith('\\') if (shell === 'cmd' || !launchesThroughCmdShim(line)) { - // Why: 5.1 hands every native command `"` and a trailing `\` the legacy way, 7 only a shim; - // this client cannot tell which PowerShell runs. - return shell === 'powershell' && verdict === 'exact' && legacyArgsDamage ? 'uncertain' : verdict + if (shell !== 'powershell' || verdict !== 'exact' || !legacyArgsDamage) { + return verdict + } + // Measured: 5.1 hands every native command `"` and a trailing `\` the legacy way and breaks + // them; 7 carried them exactly. Where the PowerShell is not known, main's delivery decides. + return windowsPowerShell === 'powershell.exe' + ? 'damaged' + : windowsPowerShell === 'pwsh.exe' + ? 'exact' + : 'uncertain' } // Measured (#23962 W-1): PowerShell hands a shim `"` and a trailing `\` the legacy way, and the // shim's cmd.exe expands `%NAME%`. diff --git a/src/shared/windows-shell-prompt-damage.test.ts b/src/shared/windows-shell-prompt-damage.test.ts index 5e96e657433..0ad862939a8 100644 --- a/src/shared/windows-shell-prompt-damage.test.ts +++ b/src/shared/windows-shell-prompt-damage.test.ts @@ -95,7 +95,12 @@ describe('a prompt a Windows shell would damage on the launch line', () => { cmdOverrides: {}, platform: 'win32', shell: 'powershell', - host: { paired: true, provesAgentInFront: false, takesLaunchFile: false } + host: { + paired: true, + provesAgentInFront: false, + takesLaunchFile: false, + windowsPowerShell: null + } }) expect(startup?.launchFile).toBeUndefined() expect(startup?.launchCommand).toContain('fix the build')