fix: queue AI Vault resume command in the configured Windows shell (#6571)

Resume-in-tab/drag-resume queued a cmd.exe-syntax command into a freshly
spawned tab whose live shell is the configured Windows shell (default
PowerShell). PowerShell mis-parsed the cmd ""-doubled wrapper and reported
"is not recognized as an internal or external command, operable program or
batch file", so the session never resumed.

Resolve terminalWindowsShell to a startup-shell family and quote the queued
command per shell (PowerShell Set-Location/$env, POSIX cd for git-bash),
only emitting the cmd /d /s /c wrapper when the live shell is cmd. The
copy-to-clipboard command is unchanged and stays cmd-wrapped.

Fixes #6152

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil 2026-06-28 23:20:26 -07:00 committed by GitHub
parent 396f9c342a
commit bcce8e37e2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 183 additions and 6 deletions

View File

@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import type { AppState } from '@/store/types'
import { buildAiVaultResumeCommand } from '../../../shared/ai-vault-types'
import {
buildAiVaultResumeCommandForWorktree,
buildAiVaultResumeStartupForWorktree,
@ -27,6 +28,7 @@ type AiVaultResumeCommandState = Pick<
function makeState(args: {
worktreePath: string
localWindowsRuntimePreference?: RuntimePreference
terminalWindowsShell?: string
}): AiVaultResumeCommandState {
return {
activeRepoId: 'repo-1',
@ -45,6 +47,7 @@ function makeState(args: {
],
settings: {
localWindowsRuntimeDefault: { kind: 'windows-host' },
...(args.terminalWindowsShell ? { terminalWindowsShell: args.terminalWindowsShell } : {}),
agentDefaultArgs: { claude: '', codex: '' },
agentDefaultEnv: { claude: {}, codex: {} }
},
@ -61,9 +64,31 @@ function makeState(args: {
}
describe('ai vault resume command runtime', () => {
it('uses Windows command wrapping for Windows-host projects', () => {
it('queues a PowerShell-valid command for the default Windows shell', () => {
// Why: the queued command is typed into the live tab shell (default
// PowerShell), which mis-parses the cmd `""`-doubled wrapper (#6152).
const state = makeState({ worktreePath: 'C:\\Users\\alice\\repo' })
expect(
buildAiVaultResumeCommandForWorktree({
state,
worktreeId: 'repo-1::worktree-1',
session: {
agent: 'claude',
sessionId: 'session one',
cwd: 'C:\\Users\\alice\\repo',
codexHome: null
}
})
).toBe("Set-Location -LiteralPath 'C:\\Users\\alice\\repo'; claude '--resume' 'session one'")
})
it('keeps the cmd wrapper when the configured Windows shell is cmd.exe', () => {
const state = makeState({
worktreePath: 'C:\\Users\\alice\\repo',
terminalWindowsShell: 'cmd.exe'
})
expect(
buildAiVaultResumeCommandForWorktree({
state,
@ -78,6 +103,40 @@ describe('ai vault resume command runtime', () => {
).toBe('cmd /d /s /c "cd /d ""C:\\Users\\alice\\repo"" && claude ""--resume"" ""session one"""')
})
it('queues a POSIX command for the Git Bash Windows shell', () => {
const state = makeState({
worktreePath: 'C:\\Users\\alice\\repo',
terminalWindowsShell: 'git-bash'
})
expect(
buildAiVaultResumeCommandForWorktree({
state,
worktreeId: 'repo-1::worktree-1',
session: {
agent: 'claude',
sessionId: 'session one',
cwd: 'C:\\Users\\alice\\repo',
codexHome: null
}
})
).toBe("cd 'C:\\Users\\alice\\repo' && claude '--resume' 'session one'")
})
it('keeps the cmd wrapper for the copy-to-clipboard command on Windows', () => {
// Regression guard: the copy path is self-contained for pasting into cmd.exe
// and must stay cmd-wrapped even though the queued path now follows the shell.
expect(
buildAiVaultResumeCommand({
agent: 'claude',
sessionId: 'session one',
cwd: 'C:\\Users\\alice\\repo',
platform: 'win32',
codexHome: null
})
).toBe('cmd /d /s /c "cd /d ""C:\\Users\\alice\\repo"" && claude --resume ""session one"""')
})
it('uses configured agent defaults for resumable session history entries', () => {
const state = makeState({
worktreePath: 'C:\\Users\\alice\\repo',

View File

@ -12,6 +12,8 @@ import {
resolveTuiAgentLaunchEnv
} from '../../../shared/tui-agent-launch-defaults'
import { parseWslUncPath } from '../../../shared/wsl-paths'
import { resolveWindowsShellStartupFamily } from '../../../shared/windows-terminal-shell'
import type { AgentStartupShell } from '../../../shared/tui-agent-startup-shell'
import type { AppState } from '@/store/types'
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
@ -65,6 +67,14 @@ export function buildAiVaultResumeStartupForWorktree(args: {
}): AiVaultResumeStartup {
const platform = getAiVaultResumePlatform(args.state, args.worktreeId)
const codexHome = getAiVaultResumeCodexHome(args.session.codexHome, platform)
// Why: the queued command is typed verbatim into the freshly spawned tab whose
// live shell is the configured Windows shell (default PowerShell). Hardcoding
// cmd quoting made PowerShell mis-parse the `""`-doubled wrapper (#6152), so
// resolve the actual shell to quote per-shell instead.
const queuedShell: AgentStartupShell | undefined =
platform === 'win32'
? resolveWindowsShellStartupFamily(args.state.settings?.terminalWindowsShell)
: undefined
if (isResumableTuiAgent(args.session.agent)) {
const startupPlan = buildAgentResumeStartupPlan({
agent: args.session.agent,
@ -74,9 +84,7 @@ export function buildAiVaultResumeStartupForWorktree(args: {
...(args.commandOverride?.trim() ? { [args.session.agent]: args.commandOverride } : {})
},
platform,
// Why: copied AI Vault commands are shell-wrapped for portability; the
// same inner command must be queued so drag/click resume match copy.
shell: platform === 'win32' ? 'cmd' : undefined,
shell: queuedShell,
agentArgs: resolveTuiAgentLaunchArgs(
args.session.agent,
args.state.settings?.agentDefaultArgs
@ -89,7 +97,8 @@ export function buildAiVaultResumeStartupForWorktree(args: {
resumeCommand: startupPlan.launchCommand,
cwd: args.session.cwd,
platform,
codexHome
codexHome,
shell: queuedShell
}),
...(startupPlan.env ? { env: startupPlan.env } : {}),
launchConfig: startupPlan.launchConfig

View File

@ -1,4 +1,9 @@
import { TUI_AGENT_CONFIG } from './tui-agent-config'
import {
commandSeparator,
quoteStartupArg,
type AgentStartupShell
} from './tui-agent-startup-shell'
import type { TuiAgent } from './types'
export const AI_VAULT_AGENTS = [
@ -106,8 +111,26 @@ export function buildAiVaultResumeShellCommand(args: {
cwd: string | null
platform: NodeJS.Platform
codexHome?: string | null
// Why: the QUEUED resume command is typed into the live tab shell, so its
// cd/env prefix must match that shell. The copy-to-clipboard string omits this
// and keeps the self-contained `cmd /d /s /c` wrapper (its documented purpose).
shell?: AgentStartupShell
}): string {
const { cwd, platform, codexHome } = args
const { cwd, platform, codexHome, shell } = args
// Why: on Windows the queued command must target the configured live shell
// (default PowerShell). PowerShell mis-parses the cmd `""`-doubled wrapper and
// reports "operable program or batch file", so only re-wrap with cmd when the
// live shell actually is cmd (or when no shell is given, i.e. the copy path).
if (platform === 'win32' && shell && shell !== 'cmd') {
return buildResumeShellCommandForShell({
resumeCommand: args.resumeCommand,
cwd,
codexHome: codexHome?.trim() || null,
shell
})
}
const resumeCommand = `${codexHomeEnvPrefix(codexHome?.trim() || null, platform)}${
args.resumeCommand
}`
@ -123,6 +146,33 @@ export function buildAiVaultResumeShellCommand(args: {
return `cd ${quoteShellArg(cwd, platform)} && ${resumeCommand}`
}
function buildResumeShellCommandForShell(args: {
resumeCommand: string
cwd: string | null
codexHome: string | null
shell: Exclude<AgentStartupShell, 'cmd'>
}): string {
const { cwd, codexHome, shell } = args
if (shell === 'posix') {
// Why: git-bash on a Windows host runs a POSIX shell, so reuse the same
// inline-env + `cd '<cwd>'` prefix as the non-Windows path.
const envPrefix = codexHome ? `CODEX_HOME=${quoteStartupArg(codexHome, shell)} ` : ''
const command = `${envPrefix}${args.resumeCommand}`
return cwd ? `cd ${quoteStartupArg(cwd, shell)} && ${command}` : command
}
const separator = commandSeparator(shell)
const segments: string[] = []
if (cwd) {
segments.push(`Set-Location -LiteralPath ${quoteStartupArg(cwd, shell)}`)
}
if (codexHome) {
segments.push(`$env:CODEX_HOME=${quoteStartupArg(codexHome, shell)}`)
}
segments.push(args.resumeCommand)
return segments.join(separator)
}
export function aiVaultAgentLabel(agent: AiVaultAgent): string {
return AI_VAULT_AGENT_LABELS[agent]
}

View File

@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import { resolveWindowsShellStartupFamily } from './windows-terminal-shell'
describe('resolveWindowsShellStartupFamily', () => {
it('defaults to PowerShell when unset', () => {
expect(resolveWindowsShellStartupFamily(undefined)).toBe('powershell')
expect(resolveWindowsShellStartupFamily(null)).toBe('powershell')
expect(resolveWindowsShellStartupFamily(' ')).toBe('powershell')
})
it('treats PowerShell and pwsh as PowerShell', () => {
expect(resolveWindowsShellStartupFamily('powershell.exe')).toBe('powershell')
expect(resolveWindowsShellStartupFamily('pwsh.exe')).toBe('powershell')
expect(resolveWindowsShellStartupFamily('C:\\Program Files\\PowerShell\\7\\pwsh.exe')).toBe(
'powershell'
)
})
it('maps cmd.exe to cmd quoting', () => {
expect(resolveWindowsShellStartupFamily('cmd.exe')).toBe('cmd')
expect(resolveWindowsShellStartupFamily('C:\\Windows\\System32\\cmd.exe')).toBe('cmd')
})
it('maps Git Bash and WSL shells to POSIX quoting', () => {
expect(resolveWindowsShellStartupFamily('git-bash')).toBe('posix')
expect(resolveWindowsShellStartupFamily('wsl.exe')).toBe('posix')
expect(resolveWindowsShellStartupFamily('C:\\Program Files\\Git\\bin\\bash.exe')).toBe('posix')
})
})

View File

@ -1,3 +1,5 @@
import type { AgentStartupShell } from './tui-agent-startup-shell'
export const WINDOWS_GIT_BASH_SHELL = 'git-bash'
export type BuiltInWindowsTerminalShell =
@ -5,3 +7,31 @@ export type BuiltInWindowsTerminalShell =
| 'cmd.exe'
| 'wsl.exe'
| typeof WINDOWS_GIT_BASH_SHELL
/**
* Classifies a configured `terminalWindowsShell` value into the startup-shell
* family used to quote queued commands. Git Bash / wsl.exe run a POSIX shell;
* cmd.exe needs cmd quoting; everything else (PowerShell, pwsh, unknown) is
* treated as PowerShell, matching the Windows default.
*/
export function resolveWindowsShellStartupFamily(
shell: string | null | undefined
): AgentStartupShell {
const trimmed = shell?.trim()
if (!trimmed) {
return 'powershell'
}
if (trimmed === WINDOWS_GIT_BASH_SHELL) {
return 'posix'
}
const basename = trimmed.replaceAll('\\', '/').split('/').pop()?.toLowerCase() ?? ''
if (basename === 'cmd.exe') {
return 'cmd'
}
// Why: wsl.exe and bash.exe (Git for Windows) launch POSIX shells, so queued
// commands must use POSIX quoting and `cd '<cwd>'` rather than cmd/PowerShell.
if (basename === 'wsl.exe' || basename === 'wsl' || basename === 'bash.exe') {
return 'posix'
}
return 'powershell'
}