From 940f2ff1e4f117e449c9d107e8264f4b4cb937ce Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:57:15 -0700 Subject: [PATCH] fix(terminal): quote agent resume for the tab's real Windows shell (cmd.exe) (#12476) * fix(terminal): quote agent resume commands for the tab's real Windows shell Cold restore and sleeping-agent resume built their launch line without the host shell family, so win32 fell back to PowerShell argv quoting. On cmd.exe tabs those quotes arrived literally and agent CLIs rejected the resume argv and permission flags after a reboot ("unexpected argument '''' found"). Both call sites now share resolveAgentResumeLaunchTarget, which resolves the launch platform and the live shell family together via resolveLocalWindowsAgentStartupShell, honoring a per-tab shell override for cold restore and leaving SSH / remote-runtime / WSL workspaces on their own default quoting. Fixes #12320 Co-authored-by: Orca * test(shared): cover cmd.exe resume quoting at the plan layer Adopted from #12321 by @CountClaw. Co-authored-by: Orca --------- Co-authored-by: Orca --- .../terminal-pane/pty-connection.test.ts | 118 +++++++++++++ .../terminal-pane/pty-connection.ts | 28 ++-- .../lib/agent-resume-launch-target.test.ts | 149 +++++++++++++++++ .../src/lib/agent-resume-launch-target.ts | 62 +++++++ ...ent-session-launch-windows-quoting.test.ts | 157 ++++++++++++++++++ .../src/lib/sleeping-agent-session-launch.ts | 32 ++-- src/shared/tui-agent-startup.test.ts | 34 ++++ 7 files changed, 550 insertions(+), 30 deletions(-) create mode 100644 src/renderer/src/lib/agent-resume-launch-target.test.ts create mode 100644 src/renderer/src/lib/agent-resume-launch-target.ts create mode 100644 src/renderer/src/lib/sleeping-agent-session-launch-windows-quoting.test.ts diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 58408b8ba..c6df6cfda 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -8361,6 +8361,124 @@ describe('connectPanePty', () => { } }) + // Regression (#12320): a cold restore after reboot typed PowerShell single quotes into + // cmd.exe tabs, so the agent CLI rejected the resume argv ("unexpected argument"). + async function runWindowsColdRestoreResume(args: { + terminalWindowsShell: string + tabShellOverride?: string + }): Promise { + const restoreNavigator = temporarilySetNavigatorUserAgent( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + ) + const pendingTimeouts: (() => void)[] = [] + const originalSetTimeout = globalThis.setTimeout + globalThis.setTimeout = vi.fn((fn: () => void) => { + pendingTimeouts.push(fn) + return 999 as unknown as ReturnType + }) as unknown as typeof setTimeout + + try { + const { connectPanePty } = await import('./pty-connection') + const paneKey = makePaneKey('tab-1', LEAF_2) + let activePtyId: string | null = 'restored-session' + const transport = createMockTransport('restored-session') + transport.getPtyId.mockImplementation(() => activePtyId) + transport.disconnect.mockImplementation(() => { + activePtyId = null + }) + transport.connect.mockImplementation(async (opts: { sessionId?: string }) => { + if (opts.sessionId) { + activePtyId = opts.sessionId + return { + id: opts.sessionId, + isReattach: true, + snapshot: undefined, + replay: undefined, + coldRestore: undefined + } + } + activePtyId = 'fresh-resume-pty' + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + onPtySpawn?.('fresh-resume-pty') + return 'fresh-resume-pty' + }) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [ + { + id: 'tab-1', + ptyId: 'restored-session', + ...(args.tabShellOverride ? { shellOverride: args.tabShellOverride } : {}) + } + ] + }, + settings: { + ...mockStoreState.settings, + agentCmdOverrides: {}, + terminalWindowsShell: args.terminalWindowsShell + }, + sleepingAgentSessionsByPaneKey: { + [paneKey]: { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session-1' }, + prompt: 'finish the task', + state: 'done', + origin: 'worktree-sleep', + capturedAt: 1, + updatedAt: 1 + } + } + } as StoreState + const deps = createDeps({ + restoredLeafId: LEAF_2, + restoredPtyIdByLeafId: { [LEAF_2]: 'restored-session' } + }) + vi.mocked(window.api.pty.declarePendingPaneSerializer) + .mockResolvedValueOnce(1) + .mockResolvedValueOnce(2) + + connectPanePty(createPane(2) as never, createManager(2) as never, deps as never) + await flushAsyncTicks(20) + for (const fn of pendingTimeouts) { + fn() + } + await flushAsyncTicks(10) + + return (transport.connect.mock.calls.at(-1)?.[0] as { command?: string } | undefined)?.command + } finally { + globalThis.setTimeout = originalSetTimeout + restoreNavigator() + } + } + + it('quotes a cold-restore resume command for a cmd.exe Windows tab', async () => { + await expect(runWindowsColdRestoreResume({ terminalWindowsShell: 'cmd.exe' })).resolves.toBe( + 'codex "--dangerously-bypass-approvals-and-sandbox" "resume" "codex-session-1"' + ) + }) + + it('prefers the tab shell override over the global Windows shell on cold restore', async () => { + await expect( + runWindowsColdRestoreResume({ + terminalWindowsShell: 'powershell.exe', + tabShellOverride: 'cmd.exe' + }) + ).resolves.toBe('codex "--dangerously-bypass-approvals-and-sandbox" "resume" "codex-session-1"') + }) + + it('keeps PowerShell quoting for a cold-restore resume on a PowerShell Windows tab', async () => { + await expect( + runWindowsColdRestoreResume({ terminalWindowsShell: 'powershell.exe' }) + ).resolves.toBe("codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'") + }) + it('keeps a contentless reattach when the sleeping record represents a live session', async () => { const { connectPanePty } = await import('./pty-connection') const paneKey = makePaneKey('tab-1', LEAF_2) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 9d14131a4..1e12e3106 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -284,6 +284,7 @@ import { } from '@/lib/worktree-runtime-owner' import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup' +import { resolveAgentResumeLaunchTarget } from '@/lib/agent-resume-launch-target' import { resolveAgentStatusTerminalTitle } from '@/lib/agent-status-terminal-title' import { resolveTuiAgentLaunchArgs, @@ -308,7 +309,6 @@ import { recognizeAgentProcessFromCommandLine } from '../../../../shared/agent-process-recognition' import type { SetupSplitDirection, TuiAgent } from '../../../../shared/types' -import { isWslUncPath } from '../../../../shared/wsl-paths' import { isTuiAgent, TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config' import { createDraftPasteReadyScanner } from '../../../../shared/draft-paste-ready-scanner' import { sendAgentDraftPasteContent } from '@/lib/agent-draft-paste-content' @@ -4931,18 +4931,6 @@ export function connectPanePty( sessionRestoredBannerShown = reason deps.onShowSessionRestoredBanner(pane.id, reason) } - const getColdRestoreAgentResumePlatform = (): NodeJS.Platform => { - if (projectRuntime?.status === 'repair-required') { - return projectRuntime.repair.preferredRuntime.kind === 'wsl' ? 'linux' : CLIENT_PLATFORM - } - if (projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl') { - return 'linux' - } - if (connectionId || (worktree?.path && isWslUncPath(worktree.path))) { - return 'linux' - } - return CLIENT_PLATFORM - } const buildColdRestoreAgentResumeStartup = (): ColdRestoreAgentResumeStartup | null => { if (pendingStartupCommand) { return null @@ -4975,7 +4963,16 @@ export function connectPanePty( const launchConfig = (useLiveEntry && entry ? state.getAgentLaunchConfigForStatusEntry(entry) : undefined) ?? matchingSleepingLaunchConfig - const resumePlatform = getColdRestoreAgentResumePlatform() + // Why: the resume line is typed into this pane's live shell, so its quoting must + // follow the tab's effective Windows shell, not the win32 PowerShell default. + const resumeTarget = resolveAgentResumeLaunchTarget({ + projectRuntime, + connectionId, + executionHostId, + worktreePath: worktree?.path, + terminalWindowsShell: state.settings?.terminalWindowsShell, + tabShellOverride: shellOverride + }) const startupPlan = buildAgentResumeStartupPlan({ agent, providerSession, @@ -4992,7 +4989,8 @@ export function connectPanePty( ...(launchConfig?.ompResumeFilePath ? { ompResumeFilePath: launchConfig.ompResumeFilePath } : {}), - platform: resumePlatform + platform: resumeTarget.platform, + shell: resumeTarget.shell }) if (!startupPlan) { return null diff --git a/src/renderer/src/lib/agent-resume-launch-target.test.ts b/src/renderer/src/lib/agent-resume-launch-target.test.ts new file mode 100644 index 000000000..c26541331 --- /dev/null +++ b/src/renderer/src/lib/agent-resume-launch-target.test.ts @@ -0,0 +1,149 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentResumeLaunchTargetArgs } from './agent-resume-launch-target' + +function setNavigatorUserAgent(userAgent: string): () => void { + const original = Object.getOwnPropertyDescriptor(globalThis, 'navigator') + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { + platform: userAgent.includes('Windows') ? 'Win32' : 'MacIntel', + userAgent + } + }) + return () => { + if (original) { + Object.defineProperty(globalThis, 'navigator', original) + } else { + delete (globalThis as { navigator?: Navigator }).navigator + } + } +} + +const LOCAL_WINDOWS_ARGS: AgentResumeLaunchTargetArgs = { + projectRuntime: undefined, + connectionId: null, + executionHostId: 'local', + worktreePath: 'C:\\Users\\neil\\orca\\workspaces\\orca\\feature', + terminalWindowsShell: null +} + +async function resolveWith( + overrides: Partial +): Promise<{ platform: NodeJS.Platform; shell: string | undefined }> { + const { resolveAgentResumeLaunchTarget } = await import('./agent-resume-launch-target') + return resolveAgentResumeLaunchTarget({ + ...LOCAL_WINDOWS_ARGS, + ...overrides + }) +} + +describe('resolveAgentResumeLaunchTarget on a Windows client', () => { + let restoreNavigator = (): void => {} + + beforeEach(() => { + vi.resetModules() + restoreNavigator = setNavigatorUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64)') + }) + + afterEach(() => { + restoreNavigator() + }) + + it('quotes for cmd.exe when the global Windows shell is cmd.exe', async () => { + await expect(resolveWith({ terminalWindowsShell: 'cmd.exe' })).resolves.toEqual({ + platform: 'win32', + shell: 'cmd' + }) + }) + + it('prefers the per-tab shell override over the global Windows shell', async () => { + await expect( + resolveWith({ + terminalWindowsShell: 'powershell.exe', + tabShellOverride: 'C:\\WINDOWS\\system32\\cmd.exe' + }) + ).resolves.toEqual({ platform: 'win32', shell: 'cmd' }) + }) + + it('quotes for POSIX on a Git Bash tab', async () => { + await expect(resolveWith({ terminalWindowsShell: 'git-bash' })).resolves.toEqual({ + platform: 'win32', + shell: 'posix' + }) + }) + + it('keeps PowerShell quoting when no Windows shell is configured', async () => { + await expect(resolveWith({})).resolves.toEqual({ + platform: 'win32', + shell: 'powershell' + }) + }) + + it('leaves an SSH workspace on its own default quoting', async () => { + await expect( + resolveWith({ + connectionId: 'ssh-1', + executionHostId: 'ssh:ssh-1', + terminalWindowsShell: 'cmd.exe' + }) + ).resolves.toEqual({ platform: 'linux', shell: undefined }) + }) + + it('does not describe a remote runtime host with the local Windows shell setting', async () => { + await expect( + resolveWith({ + executionHostId: 'runtime:prod-box', + terminalWindowsShell: 'cmd.exe' + }) + ).resolves.toEqual({ platform: 'win32', shell: undefined }) + }) + + it('keeps POSIX quoting for a WSL UNC worktree', async () => { + await expect( + resolveWith({ + worktreePath: '\\\\wsl.localhost\\Ubuntu\\home\\neil\\repo', + terminalWindowsShell: 'cmd.exe' + }) + ).resolves.toEqual({ platform: 'linux', shell: undefined }) + }) + + it('keeps POSIX quoting for a project pinned to a WSL runtime', async () => { + await expect( + resolveWith({ + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'wsl', + distro: 'Ubuntu', + projectId: 'repo-1', + reason: 'project-override', + cacheKey: 'repo-1:wsl:Ubuntu' + } + } as AgentResumeLaunchTargetArgs['projectRuntime'], + terminalWindowsShell: 'cmd.exe' + }) + ).resolves.toEqual({ platform: 'linux', shell: undefined }) + }) +}) + +describe('resolveAgentResumeLaunchTarget off Windows', () => { + let restoreNavigator = (): void => {} + + beforeEach(() => { + vi.resetModules() + restoreNavigator = setNavigatorUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)') + }) + + afterEach(() => { + restoreNavigator() + }) + + it('ignores a stale Windows shell setting on a mac client', async () => { + await expect( + resolveWith({ + worktreePath: '/Users/neil/repo', + terminalWindowsShell: 'cmd.exe' + }) + ).resolves.toEqual({ platform: 'darwin', shell: undefined }) + }) +}) diff --git a/src/renderer/src/lib/agent-resume-launch-target.ts b/src/renderer/src/lib/agent-resume-launch-target.ts new file mode 100644 index 000000000..90a6a243a --- /dev/null +++ b/src/renderer/src/lib/agent-resume-launch-target.ts @@ -0,0 +1,62 @@ +import { CLIENT_PLATFORM } from '@/lib/new-workspace' +import { resolveWindowsShellOverride } from '@/lib/pane-manager/windows-pty-compatibility' +import { parseExecutionHostId } from '../../../shared/execution-host' +import { isWslUncPath } from '../../../shared/wsl-paths' +import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell' +import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime' +import type { AgentStartupShell } from '../../../shared/tui-agent-startup-shell' + +export type AgentResumeLaunchTarget = { + platform: NodeJS.Platform + /** undefined keeps the platform default: PowerShell on win32, POSIX elsewhere. */ + shell: AgentStartupShell | undefined +} + +export type AgentResumeLaunchTargetArgs = { + projectRuntime: ProjectExecutionRuntimeResolution | undefined + /** SSH connection owning the workspace, if any. */ + connectionId: string | null | undefined + /** Pane/workspace execution owner; only a 'local' host is the one `terminalWindowsShell` describes. */ + executionHostId: string | null + worktreePath: string | null | undefined + terminalWindowsShell: string | null | undefined + /** Per-tab Windows shell override, which beats the global setting at spawn time. */ + tabShellOverride?: string | null +} + +function resolveResumeLaunchPlatform(args: AgentResumeLaunchTargetArgs): NodeJS.Platform { + if (args.projectRuntime?.status === 'repair-required') { + return args.projectRuntime.repair.preferredRuntime.kind === 'wsl' ? 'linux' : CLIENT_PLATFORM + } + if (args.projectRuntime?.status === 'resolved' && args.projectRuntime.runtime.kind === 'wsl') { + return 'linux' + } + if (args.connectionId || (args.worktreePath && isWslUncPath(args.worktreePath))) { + return 'linux' + } + return CLIENT_PLATFORM +} + +/** + * Platform *and* live shell family a queued agent-resume command must be quoted for. + * Why the shell half matters: resume lines are typed into a real terminal, so on a + * cmd.exe tab the win32 PowerShell default sends literal quotes and the agent CLI + * rejects the resume argv ("unexpected argument '''' found", #12320). + */ +export function resolveAgentResumeLaunchTarget( + args: AgentResumeLaunchTargetArgs +): AgentResumeLaunchTarget { + const platform = resolveResumeLaunchPlatform(args) + return { + platform, + shell: resolveLocalWindowsAgentStartupShell({ + platform, + isRemote: + Boolean(args.connectionId) || parseExecutionHostId(args.executionHostId)?.kind !== 'local', + terminalWindowsShell: resolveWindowsShellOverride( + args.tabShellOverride, + args.terminalWindowsShell + ) + }) + } +} diff --git a/src/renderer/src/lib/sleeping-agent-session-launch-windows-quoting.test.ts b/src/renderer/src/lib/sleeping-agent-session-launch-windows-quoting.test.ts new file mode 100644 index 000000000..9fe908050 --- /dev/null +++ b/src/renderer/src/lib/sleeping-agent-session-launch-windows-quoting.test.ts @@ -0,0 +1,157 @@ +// Windows shell-quoting coverage for the sleeping-agent resume launch (#12320): +// the queued resume line is typed into the new tab's shell, so cmd.exe tabs must +// not receive PowerShell single quotes. + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' + +const mockCreateTab = vi.fn() +const mockQueueTabStartupCommand = vi.fn() + +const store = { + settings: { + agentCmdOverrides: {}, + agentDefaultArgs: {} as Record, + agentDefaultEnv: {} as Record>, + activeRuntimeEnvironmentId: null as string | null + } as { + agentCmdOverrides: Record + agentDefaultArgs: Record + agentDefaultEnv: Record> + activeRuntimeEnvironmentId: string | null + terminalWindowsShell?: string + }, + repos: [ + { + id: 'repo-1', + connectionId: null as string | null, + path: 'C:\\Users\\neil\\repo' + } + ], + worktreesByRepo: { + 'repo-1': [ + { + id: 'wt-1', + repoId: 'repo-1', + path: 'C:\\Users\\neil\\repo\\feature', + displayName: 'feature' + } + ] + } as Record, + getKnownWorktreeById: (id: string) => + Object.values(store.worktreesByRepo) + .flat() + .find((worktree) => worktree.id === id), + tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] }, + openFiles: [] as { id: string; worktreeId: string }[], + browserTabsByWorktree: {} as Record, + tabBarOrderByWorktree: {} as Record, + createTab: mockCreateTab, + queueTabStartupCommand: mockQueueTabStartupCommand, + claimAutomaticAgentResume: vi.fn(), + clearSleepingAgentSession: vi.fn(), + setActiveTabType: vi.fn(), + setTabBarOrder: vi.fn() +} + +vi.mock('@/store', () => ({ useAppStore: { getState: () => store } })) +vi.mock('@/lib/new-workspace', () => ({ CLIENT_PLATFORM: 'win32' })) +vi.mock('sonner', () => ({ toast: { message: vi.fn(), error: vi.fn() } })) +vi.mock('@/lib/telemetry', () => ({ + track: vi.fn(), + tuiAgentToAgentKind: (agent: string) => agent +})) +vi.mock('@/components/tab-bar/reconcile-order', () => ({ + reconcileTabOrder: vi.fn((_stored, termIds: string[]) => [...termIds]) +})) + +const SESSION_ID = '0199f7a1-0000-7000-8000-000000000001' + +const record: SleepingAgentSessionRecord = { + paneKey: 'tab-1::leaf-1', + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'codex', + providerSession: { key: 'session_id', id: SESSION_ID }, + prompt: 'finish the task', + state: 'done', + origin: 'worktree-sleep', + capturedAt: 1, + updatedAt: 1 +} + +async function launch(): Promise { + const { launchSleepingAgentSession } = await import('./sleeping-agent-session-launch') + launchSleepingAgentSession(record) + const queued = mockQueueTabStartupCommand.mock.calls.at(-1)?.[1] as + | { command: string } + | undefined + return queued?.command +} + +describe('launchSleepingAgentSession Windows shell quoting', () => { + beforeEach(() => { + vi.clearAllMocks() + store.settings = { + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {}, + activeRuntimeEnvironmentId: null + } + store.repos = [{ id: 'repo-1', connectionId: null, path: 'C:\\Users\\neil\\repo' }] + store.worktreesByRepo = { + 'repo-1': [ + { + id: 'wt-1', + repoId: 'repo-1', + path: 'C:\\Users\\neil\\repo\\feature', + displayName: 'feature' + } + ] + } + mockCreateTab.mockReturnValue({ id: 'tab-1' }) + }) + + it('quotes the resume argv for a cmd.exe tab', async () => { + store.settings.terminalWindowsShell = 'cmd.exe' + + await expect(launch()).resolves.toBe( + `codex "--dangerously-bypass-approvals-and-sandbox" "resume" "${SESSION_ID}"` + ) + }) + + it('keeps PowerShell quoting for a powershell tab', async () => { + store.settings.terminalWindowsShell = 'powershell.exe' + + await expect(launch()).resolves.toBe( + `codex '--dangerously-bypass-approvals-and-sandbox' 'resume' '${SESSION_ID}'` + ) + }) + + it('quotes the resume argv for a Git Bash tab', async () => { + store.settings.terminalWindowsShell = 'git-bash' + + await expect(launch()).resolves.toBe( + `codex '--dangerously-bypass-approvals-and-sandbox' 'resume' '${SESSION_ID}'` + ) + }) + + it('ignores the local Windows shell setting for an SSH workspace', async () => { + store.settings.terminalWindowsShell = 'cmd.exe' + store.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/home/neil/repo' }] + store.worktreesByRepo = { + 'repo-1': [ + { + id: 'wt-1', + repoId: 'repo-1', + path: '/home/neil/repo/feature', + displayName: 'feature' + } + ] + } + + await expect(launch()).resolves.toBe( + `codex '--dangerously-bypass-approvals-and-sandbox' 'resume' '${SESSION_ID}'` + ) + }) +}) diff --git a/src/renderer/src/lib/sleeping-agent-session-launch.ts b/src/renderer/src/lib/sleeping-agent-session-launch.ts index 7aedee345..6d5f0d5de 100644 --- a/src/renderer/src/lib/sleeping-agent-session-launch.ts +++ b/src/renderer/src/lib/sleeping-agent-session-launch.ts @@ -1,10 +1,13 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' -import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup' import { tuiAgentToAgentKind } from '@/lib/telemetry' import { reconcileTabOrder } from '@/components/tab-bar/reconcile-order' -import { isWslUncPath } from '../../../shared/wsl-paths' +import { + resolveAgentResumeLaunchTarget, + type AgentResumeLaunchTarget +} from '@/lib/agent-resume-launch-target' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { resolveTuiAgentLaunchArgs, @@ -25,21 +28,18 @@ export type ResumeSleepingAgentSessionsOptions = { onSessionLaunched?: (tabId: string) => void } -function getResumeLaunchPlatform(worktreeId: string): NodeJS.Platform { +function getResumeLaunchTarget(worktreeId: string): AgentResumeLaunchTarget { const state = useAppStore.getState() const worktree = state.getKnownWorktreeById(worktreeId) const repo = worktree ? state.repos.find((entry) => entry.id === worktree.repoId) : null - const projectRuntime = getLocalProjectExecutionRuntimeContext(state, worktreeId) - if (projectRuntime?.status === 'repair-required') { - return projectRuntime.repair.preferredRuntime.kind === 'wsl' ? 'linux' : CLIENT_PLATFORM - } - if (projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl') { - return 'linux' - } - if (repo?.connectionId || (worktree?.path && isWslUncPath(worktree.path))) { - return 'linux' - } - return CLIENT_PLATFORM + // The resume tab is created without a shell override, so the global Windows shell wins. + return resolveAgentResumeLaunchTarget({ + projectRuntime: getLocalProjectExecutionRuntimeContext(state, worktreeId), + connectionId: repo?.connectionId, + executionHostId: getExecutionHostIdForWorktree(state, worktreeId), + worktreePath: worktree?.path, + terminalWindowsShell: state.settings?.terminalWindowsShell + }) } function appendTabToWorktreeOrder(worktreeId: string, tabId: string): void { @@ -68,6 +68,7 @@ export function launchSleepingAgentSession( ): boolean { const state = useAppStore.getState() const launchConfig = record.launchConfig + const resumeTarget = getResumeLaunchTarget(record.worktreeId) const startupPlan = buildAgentResumeStartupPlan({ agent: record.agent, providerSession: record.providerSession, @@ -84,7 +85,8 @@ export function launchSleepingAgentSession( ...(launchConfig?.ompResumeFilePath ? { ompResumeFilePath: launchConfig.ompResumeFilePath } : {}), - platform: getResumeLaunchPlatform(record.worktreeId) + platform: resumeTarget.platform, + shell: resumeTarget.shell }) if (!startupPlan) { toast.error( diff --git a/src/shared/tui-agent-startup.test.ts b/src/shared/tui-agent-startup.test.ts index f8fdcdb50..f43608813 100644 --- a/src/shared/tui-agent-startup.test.ts +++ b/src/shared/tui-agent-startup.test.ts @@ -593,6 +593,40 @@ describe('tui agent startup plans', () => { expect(plan?.launchCommand).toBe("codex 'resume' 's1'") }) + it('quotes Windows resume argv for cmd.exe when shell is cmd', () => { + const plan = buildAgentResumeStartupPlan({ + agent: 'grok', + providerSession: { key: 'session_id', id: '019fc272-80fa-7a91-80a2-9c461ef1a9da' }, + cmdOverrides: {}, + agentArgs: '--permission-mode bypassPermissions', + platform: 'win32', + shell: 'cmd' + }) + + // Why: cmd.exe treats single quotes as literal characters. Resume must use + // double quotes (or unquoted tokens) so the CLI receives clean argv. + expect(plan?.launchCommand).toBe( + 'grok "--permission-mode" "bypassPermissions" "--resume" "019fc272-80fa-7a91-80a2-9c461ef1a9da"' + ) + }) + + it('keeps cmd-quoted agentCommand aligned with cmd resume suffix', () => { + const plan = buildAgentResumeStartupPlan({ + agent: 'grok', + providerSession: { key: 'session_id', id: '019fc272-80fa-7a91-80a2-9c461ef1a9da' }, + cmdOverrides: {}, + agentCommand: 'grok "--permission-mode" "bypassPermissions"', + platform: 'win32', + shell: 'cmd' + }) + + // Regression: agentCommand from a prior cmd launch + PowerShell-default resume + // suffix produced mixed quoting and broke reboot restore on cmd.exe tabs. + expect(plan?.launchCommand).toBe( + 'grok "--permission-mode" "bypassPermissions" "--resume" "019fc272-80fa-7a91-80a2-9c461ef1a9da"' + ) + }) + it('honors command overrides when building POSIX resume plans', () => { const plan = buildAgentResumeStartupPlan({ agent: 'codex',