From bdd763188fc79a94a3325e8e72c81e957a331bfc Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 9 Aug 2026 19:52:16 -0700 Subject: [PATCH] Fix Windows AI Vault resumes using the wrong shell (#13420) * fix: use project shell for AI Vault resumes * fix: preserve WSL cwd fallback semantics --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- src/main/ipc/pty.test.ts | 276 ++++++++++++++++++ src/main/ipc/pty.ts | 66 ++++- src/main/providers/local-pty-provider.test.ts | 25 ++ src/main/providers/local-pty-provider.ts | 4 +- src/main/wsl.test.ts | 96 +++++- src/main/wsl.ts | 62 +++- .../tab-group/AiVaultSessionDropLayer.tsx | 1 + .../src/lib/ai-vault-omp-cold-resume.test.ts | 4 +- ...ai-vault-resume-command.drop-repin.test.ts | 2 +- .../src/lib/ai-vault-resume-command.test.ts | 76 +++-- .../src/lib/ai-vault-resume-command.ts | 68 ++--- src/renderer/src/lib/ai-vault-resume-shell.ts | 72 +++++ .../src/lib/launch-ai-vault-session.test.ts | 8 +- .../src/lib/launch-ai-vault-session.ts | 7 +- 14 files changed, 658 insertions(+), 109 deletions(-) create mode 100644 src/renderer/src/lib/ai-vault-resume-shell.ts diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 05cafc817..010f35f43 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -16,6 +16,7 @@ import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-a import { AGENT_SESSION_CLAIM_DIGEST_VERSION } from '../../shared/agent-session-host-authority' import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error' import { TerminalSessionOwnerUnverifiedError } from '../daemon/daemon-errors' +import type * as Wsl from '../wsl' const isWindowsHost = process.platform === 'win32' const posixOnlyIt = isWindowsHost ? it.skip : it @@ -57,6 +58,7 @@ const { piBuildPtyEnvMock, piClearPtyMock, isPwshAvailableMock, + wslUncDirectoryExistsAsyncMock, trackMock, classifyErrorMock, registerPtyMock, @@ -86,6 +88,7 @@ const { openCodeBuildPtyEnvMock: vi.fn(), mimoCodeBuildPtyEnvMock: vi.fn(), isPwshAvailableMock: vi.fn(), + wslUncDirectoryExistsAsyncMock: vi.fn(), openCodeClearPtyMock: vi.fn(), buildAgentHookEnvMock: vi.fn(), clearAgentHookPaneStateMock: vi.fn(), @@ -183,6 +186,11 @@ vi.mock('../pwsh', () => ({ isPwshAvailableAsync: isPwshAvailableMock })) +vi.mock('../wsl', async (importOriginal) => ({ + ...(await importOriginal()), + wslUncDirectoryExistsAsync: (...args: unknown[]) => wslUncDirectoryExistsAsyncMock(...args) +})) + vi.mock('../telemetry/client', () => ({ track: trackMock })) @@ -384,6 +392,8 @@ describe('registerPtyHandlers', () => { piBuildPtyEnvMock.mockReset() piClearPtyMock.mockReset() isPwshAvailableMock.mockReset() + wslUncDirectoryExistsAsyncMock.mockReset() + wslUncDirectoryExistsAsyncMock.mockResolvedValue(null) trackMock.mockReset() classifyErrorMock.mockReset() registerPtyMock.mockReset() @@ -12125,6 +12135,272 @@ describe('registerPtyHandlers', () => { expect(result.startupCwdFallback).toEqual({ kind: 'worktree', cwd: worktreePath }) }) + it('keeps an existing POSIX startup cwd for the selected WSL runtime', async () => { + const originalPlatform = process.platform + const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-cwd' }) + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + wslUncDirectoryExistsAsyncMock.mockResolvedValue(true) + statSyncMock.mockImplementation((target: string) => { + if (target === '/home/alice/repo') { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + } + return { isDirectory: () => true, mode: 0o755 } + }) + + try { + installDaemonTestProvider({ spawn: providerSpawn }) + registerPtyHandlers(mainWindow as never) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/home/alice/repo', + cwdFallback: 'worktree', + worktreeId: 'repo-1::C:\\Users\\alice\\repo', + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'repo-1', + distro: 'Ubuntu-24.04', + reason: 'project-override', + cacheKey: 'repo-1:wsl' + } + } + }) + + expect(statSyncMock).not.toHaveBeenCalledWith('/home/alice/repo') + expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledWith( + '\\\\wsl.localhost\\Ubuntu-24.04\\home\\alice\\repo' + ) + expect(providerSpawn).toHaveBeenCalledWith( + expect.objectContaining({ cwd: '/home/alice/repo', shellOverride: 'wsl.exe' }) + ) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('falls back when the selected WSL runtime reports a POSIX cwd missing', async () => { + const originalPlatform = process.platform + const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-missing-cwd' }) + const worktreePath = 'C:/Users/alice/repo' + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + wslUncDirectoryExistsAsyncMock.mockResolvedValue(false) + + try { + installDaemonTestProvider({ spawn: providerSpawn }) + registerPtyHandlers(mainWindow as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/home/alice/deleted', + cwdFallback: 'worktree', + worktreeId: `repo-1::${worktreePath}`, + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'repo-1', + distro: 'Ubuntu-24.04', + reason: 'project-override', + cacheKey: 'repo-1:wsl' + } + } + })) as { startupCwdFallback?: { kind: string; cwd: string } } + + expect(providerSpawn).toHaveBeenCalledWith(expect.objectContaining({ cwd: worktreePath })) + expect(result.startupCwdFallback).toEqual({ kind: 'worktree', cwd: worktreePath }) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('preserves a POSIX WSL cwd when the distro probe is inconclusive', async () => { + const originalPlatform = process.platform + const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-inconclusive-cwd' }) + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + wslUncDirectoryExistsAsyncMock.mockResolvedValue(null) + + try { + installDaemonTestProvider({ spawn: providerSpawn }) + registerPtyHandlers(mainWindow as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/home/alice/repo', + cwdFallback: 'worktree', + worktreeId: 'repo-1::C:/Users/alice/repo', + shellOverride: 'wsl.exe' + })) as { startupCwdFallback?: unknown } + + expect(providerSpawn).toHaveBeenCalledWith( + expect.objectContaining({ cwd: '/home/alice/repo' }) + ) + expect(result.startupCwdFallback).toBeUndefined() + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('preserves a POSIX cwd when WSL owns it but no distro can be resolved', async () => { + const originalPlatform = process.platform + const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-no-distro-cwd' }) + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + _setWslCachesForTests({ available: true, distros: [] }) + + try { + installDaemonTestProvider({ spawn: providerSpawn }) + registerPtyHandlers(mainWindow as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/home/alice/repo', + cwdFallback: 'worktree', + worktreeId: 'repo-1::C:/Users/alice/repo', + shellOverride: 'wsl.exe' + })) as { startupCwdFallback?: unknown } + + expect(statSyncMock).not.toHaveBeenCalledWith('/home/alice/repo') + expect(wslUncDirectoryExistsAsyncMock).not.toHaveBeenCalled() + expect(providerSpawn).toHaveBeenCalledWith( + expect.objectContaining({ cwd: '/home/alice/repo' }) + ) + expect(result.startupCwdFallback).toBeUndefined() + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it.each([ + { exists: true, expectedCwd: '/home/alice/repo', expectedFallback: undefined }, + { + exists: false, + expectedCwd: '\\\\wsl.localhost\\Ubuntu\\home\\alice', + expectedFallback: { + kind: 'worktree', + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\alice' + } + } + ])('resolves POSIX cwd existence for a WSL UNC workspace ($exists)', async (testCase) => { + const originalPlatform = process.platform + const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-unc-cwd' }) + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + wslUncDirectoryExistsAsyncMock.mockResolvedValueOnce(testCase.exists) + if (!testCase.exists) { + wslUncDirectoryExistsAsyncMock.mockResolvedValueOnce(true) + } + + try { + installDaemonTestProvider({ spawn: providerSpawn }) + registerPtyHandlers(mainWindow as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/home/alice/repo', + cwdFallback: 'worktree', + worktreeId: 'repo-1::\\\\wsl.localhost\\Ubuntu\\home\\alice' + })) as { startupCwdFallback?: { kind: string; cwd: string } } + + expect(providerSpawn).toHaveBeenCalledWith( + expect.objectContaining({ cwd: testCase.expectedCwd }) + ) + expect(result.startupCwdFallback).toEqual(testCase.expectedFallback) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('preserves a missing POSIX cwd when its WSL workspace root is also missing', async () => { + const originalPlatform = process.platform + const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-missing-root' }) + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + wslUncDirectoryExistsAsyncMock.mockResolvedValue(false) + + try { + installDaemonTestProvider({ spawn: providerSpawn }) + registerPtyHandlers(mainWindow as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/home/alice/deleted', + cwdFallback: 'worktree', + worktreeId: 'repo-1::\\\\wsl.localhost\\Ubuntu\\home\\alice' + })) as { startupCwdFallback?: unknown } + + expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledTimes(2) + expect(providerSpawn).toHaveBeenCalledWith( + expect.objectContaining({ cwd: '/home/alice/deleted' }) + ) + expect(result.startupCwdFallback).toBeUndefined() + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('validates a /mnt drive cwd through its native Windows path', async () => { + const originalPlatform = process.platform + const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-mnt-cwd' }) + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + _setWslCachesForTests({ available: true, distros: ['Ubuntu'] }) + + try { + installDaemonTestProvider({ spawn: providerSpawn }) + registerPtyHandlers(mainWindow as never) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/mnt/c/Users/alice/repo', + cwdFallback: 'worktree', + worktreeId: 'repo-1::C:/Users/alice/repo', + shellOverride: 'wsl.exe' + }) + + expect(statSyncMock).toHaveBeenCalledWith('C:\\Users\\alice\\repo') + expect(wslUncDirectoryExistsAsyncMock).not.toHaveBeenCalled() + expect(providerSpawn).toHaveBeenCalledWith( + expect.objectContaining({ cwd: '/mnt/c/Users/alice/repo' }) + ) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('still falls back a missing Windows cwd when the selected runtime is WSL', async () => { + const originalPlatform = process.platform + const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-windows-cwd' }) + const worktreePath = 'C:/Users/alice/repo' + const missingCwd = `${worktreePath}/deleted-folder` + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + statSyncMock.mockImplementation((target: string) => { + if (target === missingCwd) { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + } + return { isDirectory: () => true, mode: 0o755 } + }) + + try { + installDaemonTestProvider({ spawn: providerSpawn }) + registerPtyHandlers(mainWindow as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: missingCwd, + cwdFallback: 'worktree', + worktreeId: `repo-1::${worktreePath}`, + shellOverride: 'wsl.exe' + })) as { startupCwdFallback?: { kind: string; cwd: string } } + + expect(providerSpawn).toHaveBeenCalledWith( + expect.objectContaining({ cwd: worktreePath, shellOverride: 'wsl.exe' }) + ) + expect(result.startupCwdFallback).toEqual({ kind: 'worktree', cwd: worktreePath }) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + it('keeps a missing cwd unchanged without the fallback flag', async () => { registerPtyHandlers(mainWindow as never) existsSyncMock.mockImplementation((target: string) => target !== '/repo/app/deleted-folder') diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 219829c61..3ff4e36fe 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -139,7 +139,7 @@ import { resolveTerminalStartupCwdForWorkspace, type TerminalStartupCwdMissingDirFallback } from '../../shared/terminal-startup-cwd' -import { isWslUncPath } from '../../shared/wsl-paths' +import { isWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths' import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id' import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' import { @@ -150,7 +150,7 @@ import { clearMigrationUnsupportedPty, clearMigrationUnsupportedPtysForPaneKey } from '../agent-hooks/migration-unsupported-pty-state' -import { parseWslPath } from '../wsl' +import { parseWslPath, wslUncDirectoryExistsAsync } from '../wsl' import { mergePersistedWindowsPath, resolvePathEnvKey } from '../pty/windows-environment-path' import { addOrcaWslInteropEnv, stampWslOrchestrationCompatibilityHost } from '../pty/wsl-orca-env' import { PtyProducerFlowController } from './pty-producer-flow-control' @@ -5775,15 +5775,73 @@ export function registerPtyHandlers( await startupPromise } // Why: honor the fallback only for fresh local spawns — reattach needs exact cwd and SSH can't probe the local filesystem. - const allowMissingCwdFallback = + const requestedMissingCwdFallback = !args.connectionId && !args.sessionId && args.cwdFallback === 'worktree' + const isWslOwnedPosixCwd = + args.cwd?.startsWith('/') === true && !/^\/[A-Za-z](?:\/|$)/.test(args.cwd) + const startupWorkspaceCwd = + requestedMissingCwdFallback && isWslOwnedPosixCwd + ? resolvePtySpawnStartupCwd(args.worktreeId, '.') + : undefined + const initiallyResolvedStartupCwd = + requestedMissingCwdFallback && isWslOwnedPosixCwd + ? resolvePtySpawnStartupCwd(args.worktreeId, args.cwd) + : undefined + const startupTerminalRuntimeOptions = + requestedMissingCwdFallback && process.platform === 'win32' + ? resolveLocalWindowsTerminalRuntimeOptions({ + requestedShellOverride: args.shellOverride, + settings: getSettings?.(), + projectRuntime: args.projectRuntime, + fallbackHostShell: process.env.COMSPEC || 'powershell.exe' + }) + : undefined + const wslRuntimeOwnsStartupCwd = + requestedMissingCwdFallback && + isWslOwnedPosixCwd && + (isWslShellName(startupTerminalRuntimeOptions?.shellOverride) || + isWslUncPath(startupWorkspaceCwd ?? '')) + const startupWslContext = wslRuntimeOwnsStartupCwd + ? resolveWslSessionContext({ + cwd: startupWorkspaceCwd, + shellOverride: startupTerminalRuntimeOptions?.shellOverride, + terminalWindowsWslDistro: startupTerminalRuntimeOptions?.terminalWindowsWslDistro + }) + : undefined + let wslStartupCwdExists: boolean | null = null + let wslWorkspaceCwdExists: boolean | null = null + if (startupWslContext && initiallyResolvedStartupCwd) { + const validationCwd = toWindowsWslPath( + initiallyResolvedStartupCwd, + startupWslContext.distro + ) + wslStartupCwdExists = isWslUncPath(validationCwd) + ? await wslUncDirectoryExistsAsync(validationCwd) + : localStartupCwdDirectoryExists(validationCwd) + if (wslStartupCwdExists === false && startupWorkspaceCwd) { + wslWorkspaceCwdExists = isWslUncPath(startupWorkspaceCwd) + ? await wslUncDirectoryExistsAsync(startupWorkspaceCwd) + : localStartupCwdDirectoryExists(startupWorkspaceCwd) + } + } + const allowMissingCwdFallback = + requestedMissingCwdFallback && + (!wslRuntimeOwnsStartupCwd || + (wslStartupCwdExists === false && wslWorkspaceCwdExists === true)) let didFallbackToWorkspaceRootCwd = false const cwd = resolvePtySpawnStartupCwd( args.worktreeId, args.cwd, allowMissingCwdFallback ? { - directoryExists: localStartupCwdDirectoryExists, + directoryExists: (path) => + startupWslContext && + wslStartupCwdExists === false && + path === initiallyResolvedStartupCwd + ? false + : startupWslContext && path === startupWorkspaceCwd + ? wslWorkspaceCwdExists === true + : localStartupCwdDirectoryExists(path), onFallbackToWorkspaceRoot: () => { didFallbackToWorkspaceRootCwd = true } diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index 5567c11c9..a7b675e78 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -978,6 +978,31 @@ describe('LocalPtyProvider', () => { expect(spawnCall[2].env.HISTFILE).toContain('terminal-history-wsl/Debian') }) + it('translates a POSIX cwd through the preferred WSL distro', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + await provider.spawn({ + cols: 80, + rows: 24, + worktreeId: 'repo-1::C:\\Users\\jin\\repo', + cwd: '/home/jin/repo', + shellOverride: 'wsl.exe', + terminalWindowsWslDistro: 'Debian' + }) + + const spawnCall = spawnMock.mock.calls.at(-1)! + expect(spawnCall[0]).toBe('wsl.exe') + expect(spawnCall[1]).toEqual([ + '-d', + 'Debian', + '--', + 'sh', + '-c', + expect.stringContaining("cd '/home/jin/repo'") + ]) + expect(spawnCall[2].cwd).not.toBe('/home/jin/repo') + }) + it('resolves and persists the default distro for Windows cwd WSL terminals', async () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const buildSpawnEnv = vi.fn( diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 6ed3c9f0f..d1ba44e5f 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -239,9 +239,9 @@ function getWslContextFromWorktreeId( */ function getWslContextFromPreferredDistro( distro: string | null | undefined -): { distro: string } | undefined { +): { distro: string; treatPosixCwdAsWsl: true } | undefined { const trimmed = distro?.trim() - return trimmed ? { distro: trimmed } : undefined + return trimmed ? { distro: trimmed, treatPosixCwdAsWsl: true } : undefined } /** diff --git a/src/main/wsl.test.ts b/src/main/wsl.test.ts index cd77d761c..719368e3a 100644 --- a/src/main/wsl.test.ts +++ b/src/main/wsl.test.ts @@ -29,7 +29,8 @@ import { parseWslPath, toLinuxPath, toWindowsWslPath, - wslUncDirectoryExists + wslUncDirectoryExists, + wslUncDirectoryExistsAsync } from './wsl' function withPlatform(value: NodeJS.Platform, fn: () => T): T { @@ -805,36 +806,39 @@ describe('wslUncDirectoryExists', () => { }) it('returns true when the distro reports the directory exists', () => { - execFileSyncMock.mockReturnValue('') + execFileSyncMock.mockReturnValue('__ORCA_DIRECTORY_EXISTS__') const result = withPlatform('win32', () => wslUncDirectoryExists('\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo') ) expect(result).toBe(true) expect(execFileSyncMock).toHaveBeenCalledWith( 'wsl.exe', - ['-d', 'Ubuntu', '--', 'test', '-d', '/home/jin/repo'], + [ + '-d', + 'Ubuntu', + '--', + 'sh', + '-c', + expect.stringContaining('__ORCA_DIRECTORY_EXISTS__'), + 'sh', + '/home/jin/repo' + ], expect.objectContaining({ timeout: 5000 }) ) }) - it('returns false when test -d exits non-zero (directory missing)', () => { - execFileSyncMock.mockImplementation(() => { - // Why: child_process surfaces a non-zero exit as an Error with `status`. - const error = new Error('Command failed') as Error & { status: number } - error.status = 1 - throw error - }) + it('returns false when the guest reports the directory missing', () => { + execFileSyncMock.mockReturnValue('__ORCA_DIRECTORY_MISSING__') const result = withPlatform('win32', () => wslUncDirectoryExists('\\\\wsl.localhost\\Ubuntu\\home\\jin\\missing') ) expect(result).toBe(false) }) - it('returns null when wsl.exe is unavailable (inconclusive)', () => { + it('returns null when wsl.exe or the distro is unavailable', () => { execFileSyncMock.mockImplementation(() => { - // No numeric `status` -> spawn failure (ENOENT), not a missing directory. - const error = new Error('spawn wsl.exe ENOENT') as Error & { code: string } - error.code = 'ENOENT' + const error = new Error('distro unavailable') as Error & { status: number } + error.status = 4294967295 throw error }) const result = withPlatform('win32', () => @@ -851,3 +855,67 @@ describe('wslUncDirectoryExists', () => { expect(execFileSyncMock).not.toHaveBeenCalled() }) }) + +describe('wslUncDirectoryExistsAsync', () => { + afterEach(() => { + execFileMock.mockReset() + }) + + it('returns true when the distro reports the directory exists', async () => { + execFileMock.mockImplementation((_command, _args, _options, callback) => + callback(null, '__ORCA_DIRECTORY_EXISTS__') + ) + + await expect( + withPlatformAsync('win32', () => + wslUncDirectoryExistsAsync('\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo') + ) + ).resolves.toBe(true) + expect(execFileMock).toHaveBeenCalledWith( + 'wsl.exe', + [ + '-d', + 'Ubuntu', + '--', + 'sh', + '-c', + expect.stringContaining('__ORCA_DIRECTORY_EXISTS__'), + 'sh', + '/home/jin/repo' + ], + expect.objectContaining({ timeout: 5000 }), + expect.any(Function) + ) + }) + + it('distinguishes a missing directory from an inconclusive probe', async () => { + execFileMock + .mockImplementationOnce((_command, _args, _options, callback) => + callback(null, '__ORCA_DIRECTORY_MISSING__') + ) + .mockImplementationOnce((_command, _args, _options, callback) => + callback(Object.assign(new Error('distro unavailable'), { code: 4294967295 }), '') + ) + + await withPlatformAsync('win32', async () => { + await expect( + wslUncDirectoryExistsAsync('\\\\wsl.localhost\\Ubuntu\\home\\jin\\missing') + ).resolves.toBe(false) + await expect( + wslUncDirectoryExistsAsync('\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo') + ).resolves.toBeNull() + }) + }) + + it('returns null without spawning for paths outside WSL or off Windows', async () => { + await expect( + withPlatformAsync('win32', () => wslUncDirectoryExistsAsync('C:\\Users\\jin\\repo')) + ).resolves.toBeNull() + await expect( + withPlatformAsync('linux', () => + wslUncDirectoryExistsAsync('\\\\wsl.localhost\\Ubuntu\\home\\jin') + ) + ).resolves.toBeNull() + expect(execFileMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/wsl.ts b/src/main/wsl.ts index e415a3c93..8311170fa 100644 --- a/src/main/wsl.ts +++ b/src/main/wsl.ts @@ -21,6 +21,33 @@ export type WslPathInfo = { linuxPath: string } +const WSL_DIRECTORY_EXISTS_MARKER = '__ORCA_DIRECTORY_EXISTS__' +const WSL_DIRECTORY_MISSING_MARKER = '__ORCA_DIRECTORY_MISSING__' + +function getWslDirectoryProbeArgs(info: WslPathInfo): string[] { + return [ + '-d', + info.distro, + '--', + 'sh', + '-c', + `if [ -d "$1" ]; then printf ${WSL_DIRECTORY_EXISTS_MARKER}; else printf ${WSL_DIRECTORY_MISSING_MARKER}; fi`, + 'sh', + info.linuxPath + ] +} + +function parseWslDirectoryProbeOutput(stdout: unknown): boolean | null { + const output = String(stdout) + if (output.includes(WSL_DIRECTORY_EXISTS_MARKER)) { + return true + } + if (output.includes(WSL_DIRECTORY_MISSING_MARKER)) { + return false + } + return null +} + /** * Detect if a Windows path is a WSL UNC path and extract the distro name * and equivalent Linux path. @@ -49,8 +76,8 @@ export function isWslPath(path: string): boolean { * Why: Win32 fs.statSync against the WSL 9P filesystem (\\wsl.localhost\...) * is unreliable for repos that live on the WSL side — it can report ENOENT for * directories that exist, which made opening a WSL worktree fail with - * "Working directory ... does not exist". `wsl.exe -d test -d` asks - * the distro directly, which is the authoritative answer. Returns null (rather + * "Working directory ... does not exist". The guest marker probe asks the + * distro directly, which is the authoritative answer. Returns null (rather * than false) when wsl.exe is unavailable or errors so callers can fall back to * the fs check instead of falsely rejecting a valid directory. */ @@ -63,22 +90,33 @@ export function wslUncDirectoryExists(uncPath: string): boolean | null { return null } try { - execFileSync('wsl.exe', ['-d', info.distro, '--', 'test', '-d', info.linuxPath], { + const stdout = execFileSync('wsl.exe', getWslDirectoryProbeArgs(info), { stdio: ['pipe', 'pipe', 'pipe'], - timeout: 5000 + timeout: 5000, + encoding: 'utf8' }) - return true - } catch (error) { - // A non-zero exit (directory missing) surfaces as an error with a numeric - // `status`; treat that as a definitive "does not exist". Any other failure - // (wsl.exe missing, distro not running, timeout) is inconclusive -> null. - if (typeof (error as { status?: unknown })?.status === 'number') { - return false - } + return parseWslDirectoryProbeOutput(stdout) + } catch { return null } } +export function wslUncDirectoryExistsAsync(uncPath: string): Promise { + if (process.platform !== 'win32') { + return Promise.resolve(null) + } + const info = parseWslUncPath(uncPath) + if (!info) { + return Promise.resolve(null) + } + return new Promise((resolve) => { + execFile('wsl.exe', getWslDirectoryProbeArgs(info), { timeout: 5000 }, (_error, stdout) => { + // Why: wsl.exe uses numeric exits for both guest results and host failures; only the guest marker is authoritative. + resolve(parseWslDirectoryProbeOutput(stdout)) + }) + }) +} + /** * Convert a Windows path to a Linux path for commands that will execute inside WSL. * Returns the path unchanged if it is already POSIX-style. diff --git a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx index 3c9ddd263..a55aed7e8 100644 --- a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx +++ b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx @@ -260,6 +260,7 @@ export default function AiVaultSessionDropLayer({ agent: payload.agent, worktreeId, command: startup.command, + ...(payload.sessionCwd ? { cwd: payload.sessionCwd } : {}), ...(startup.env ? { env: startup.env } : {}), ...(startup.envToDelete ? { envToDelete: startup.envToDelete } : {}), ...(startup.launchConfig ? { launchConfig: startup.launchConfig } : {}), diff --git a/src/renderer/src/lib/ai-vault-omp-cold-resume.test.ts b/src/renderer/src/lib/ai-vault-omp-cold-resume.test.ts index f6e17312a..256858d3a 100644 --- a/src/renderer/src/lib/ai-vault-omp-cold-resume.test.ts +++ b/src/renderer/src/lib/ai-vault-omp-cold-resume.test.ts @@ -39,8 +39,8 @@ describe('AI Vault OMP cold resume', () => { }) expect(startup).toMatchObject({ - command: - "cd '/repo' && omp '--model' 'custom' --resume '/custom/omp-sessions/project/session.jsonl'", + command: "omp '--model' 'custom' --resume '/custom/omp-sessions/project/session.jsonl'", + cwd: '/repo', env: { OMP_PROFILE: 'custom' }, launchConfig: { agentCommand: "omp '--model' 'custom'", diff --git a/src/renderer/src/lib/ai-vault-resume-command.drop-repin.test.ts b/src/renderer/src/lib/ai-vault-resume-command.drop-repin.test.ts index 6a0e64d2b..139b4e3fd 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.drop-repin.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.drop-repin.test.ts @@ -64,7 +64,7 @@ describe('buildAiVaultDropRepinStartup', () => { expect(startup).not.toBeNull() expect(startup?.command).toContain(`CODEX_HOME='${SELECTED_HOME}'`) expect(startup?.command).not.toContain(RECORDED_HOME) - expect(startup?.command).toContain("cd '/Users/ada/repo' && ") + expect(startup).toMatchObject({ cwd: '/Users/ada/repo' }) }) it('repins a payload whose session has no cwd instead of keeping the wrong-account command', () => { diff --git a/src/renderer/src/lib/ai-vault-resume-command.test.ts b/src/renderer/src/lib/ai-vault-resume-command.test.ts index 9ad997974..f89f4054b 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.test.ts @@ -69,6 +69,30 @@ function buildQueuedAiVaultResumeCommand( } describe('ai vault resume command runtime', () => { + it('repro: queues a host-runtime resume without configured-WSL shell syntax', () => { + const state = makeState({ + worktreePath: 'C:\\Users\\alice\\repo', + localWindowsRuntimePreference: { kind: 'windows-host' }, + terminalWindowsShell: 'wsl.exe' + }) + + expect( + buildAiVaultResumeStartupForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + session: { + agent: 'claude', + sessionId: 'session one', + cwd: 'C:\\Users\\alice\\repo', + codexHome: null + } + }) + ).toMatchObject({ + command: "claude '--resume' 'session one'", + cwd: 'C:\\Users\\alice\\repo' + }) + }) + 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). @@ -85,7 +109,7 @@ describe('ai vault resume command runtime', () => { codexHome: null } }) - ).toBe("Set-Location -LiteralPath 'C:\\Users\\alice\\repo'; claude '--resume' 'session one'") + ).toBe("claude '--resume' 'session one'") }) it('queues direct cmd syntax when the configured Windows shell is cmd.exe', () => { @@ -105,7 +129,7 @@ describe('ai vault resume command runtime', () => { codexHome: null } }) - ).toBe('cd /d "C:\\Users\\alice\\repo" && claude "--resume" "session one"') + ).toBe('claude "--resume" "session one"') }) it('queues a POSIX command for the Git Bash Windows shell', () => { @@ -125,7 +149,7 @@ describe('ai vault resume command runtime', () => { codexHome: null } }) - ).toBe("cd 'C:\\Users\\alice\\repo' && claude '--resume' 'session one'") + ).toBe("claude '--resume' 'session one'") }) it('follows the live Windows shell for non-resumable agents in the fallback path', () => { @@ -144,9 +168,7 @@ describe('ai vault resume command runtime', () => { codexHome: null } }) - ).toBe( - "Set-Location -LiteralPath 'C:\\Users\\alice\\repo'; cursor-agent --resume 'session one'" - ) + ).toBe("cursor-agent --resume 'session one'") }) it('queues a PowerShell-valid local OMP resume by absolute transcript path', () => { @@ -166,9 +188,7 @@ describe('ai vault resume command runtime', () => { } }) - expect(command).toBe( - "Set-Location -LiteralPath 'C:\\Users\\alice\\repo'; omp --resume 'C:\\Users\\alice\\.omp\\agent\\sessions\\repo\\sess.jsonl'" - ) + expect(command).toBe("omp --resume 'C:\\Users\\alice\\.omp\\agent\\sessions\\repo\\sess.jsonl'") expect(command).not.toContain('019f27cd-4268-7000-96e7-62f42a55c144') }) @@ -190,9 +210,7 @@ describe('ai vault resume command runtime', () => { codexHome: null } }) - ).toBe( - 'cd /d "C:\\Users\\alice\\repo" && omp --resume "C:\\Users\\alice\\.omp\\agent\\sessions\\repo\\sess.jsonl"' - ) + ).toBe('omp --resume "C:\\Users\\alice\\.omp\\agent\\sessions\\repo\\sess.jsonl"') }) it('copies syntax that matches the configured cmd shell', () => { @@ -337,8 +355,8 @@ describe('ai vault resume command runtime', () => { } }) ).toEqual({ - command: - "cd '/home/alice/repo' && claude '--dangerously-skip-permissions' '--effort' 'max' '--resume' 'session-1'", + command: "claude '--dangerously-skip-permissions' '--effort' 'max' '--resume' 'session-1'", + cwd: '/home/alice/repo', env: { ANTHROPIC_BASE_URL: 'https://claude.example.test' }, launchConfig: { agentCommand: "claude '--dangerously-skip-permissions' '--effort' 'max'", @@ -367,7 +385,7 @@ describe('ai vault resume command runtime', () => { codexHome: null } }) - ).toBe("cd '/home/alice/repo' && claude '--resume' 'session one'") + ).toBe("claude '--resume' 'session one'") }) it('uses POSIX command wrapping for SSH-owned worktrees on Windows clients', () => { @@ -386,7 +404,7 @@ describe('ai vault resume command runtime', () => { codexHome: null } }) - ).toBe("cd '/home/alice/repo' && claude '--resume' 'session one'") + ).toBe("claude '--resume' 'session one'") }) it('uses POSIX command wrapping for folder workspaces with their own SSH target', () => { @@ -415,7 +433,7 @@ describe('ai vault resume command runtime', () => { codexHome: null } }) - ).toBe("cd '/home/alice/platform' && claude '--resume' 'session one'") + ).toBe("claude '--resume' 'session one'") }) it('uses POSIX command wrapping for WSL UNC folder workspaces on Windows clients', () => { @@ -443,7 +461,7 @@ describe('ai vault resume command runtime', () => { codexHome: null } }) - ).toBe("cd '/home/alice/platform' && claude '--resume' 'session one'") + ).toBe("claude '--resume' 'session one'") }) it('keeps WSL UNC worktrees on POSIX command wrapping without an explicit override', () => { @@ -470,7 +488,7 @@ describe('ai vault resume command runtime', () => { codexHome: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex' } }) - ).toBe("cd '/home/alice/repo' && CODEX_HOME='/home/alice/.codex' codex 'resume' 'session one'") + ).toBe("CODEX_HOME='/home/alice/.codex' codex 'resume' 'session one'") }) it('converts WSL UNC OMP transcript paths before building Linux resume commands', () => { @@ -491,9 +509,7 @@ describe('ai vault resume command runtime', () => { codexHome: null } }) - ).toBe( - "cd '/home/alice/repo' && omp --resume '/home/alice/.omp/agent/sessions/repo/sess.jsonl'" - ) + ).toBe("omp --resume '/home/alice/.omp/agent/sessions/repo/sess.jsonl'") }) it('deletes inherited Codex homes when resuming a real-home session', () => { @@ -511,7 +527,8 @@ describe('ai vault resume command runtime', () => { } }) ).toMatchObject({ - command: "Set-Location -LiteralPath '/home/alice/repo'; codex 'resume' 'session one'", + command: "codex 'resume' 'session one'", + cwd: '/home/alice/repo', envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'] }) }) @@ -534,7 +551,8 @@ describe('ai vault resume command runtime', () => { } }) ).toMatchObject({ - command: "cd '/home/alice/repo' && codex 'resume' 'session one'", + command: "codex 'resume' 'session one'", + cwd: '/home/alice/repo', envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'], providerSession: { key: 'session_id', id: 'session one' } }) @@ -558,7 +576,7 @@ describe('ai vault resume command runtime', () => { resumeCommand: "CODEX_HOME='/root/.codex' codex resume 'session one'" } }) - ).toBe("cd '/home/alice/repo' && codex 'resume' 'session one'") + ).toBe("codex 'resume' 'session one'") }) it('copies remote real-home Codex commands with explicit environment cleanup', () => { @@ -603,7 +621,7 @@ describe('ai vault resume command runtime', () => { resumeCommand: "CODEX_HOME='/root/.codex' codex resume 'session one'" } }) - ).toBe("cd '/home/alice/repo' && my-codex 'resume' 'session one'") + ).toBe("my-codex 'resume' 'session one'") }) it('rebuilds overridden remote commands with the recorded remote host platform', () => { @@ -629,9 +647,7 @@ describe('ai vault resume command runtime', () => { 'cmd /d /s /c "cd /d ""C:/Users/alice/repo"" && set ""CODEX_HOME=C:/Users/alice/.codex"" && codex resume ""session one"""' } }) - ).toBe( - "Set-Location -LiteralPath 'C:/Users/alice/repo'; $env:CODEX_HOME='C:/Users/alice/.codex'; my-codex 'resume' 'session one'" - ) + ).toBe("$env:CODEX_HOME='C:/Users/alice/.codex'; my-codex 'resume' 'session one'") }) it('ignores a stored resume command for local-host sessions', () => { @@ -651,6 +667,6 @@ describe('ai vault resume command runtime', () => { resumeCommand: "CODEX_HOME='/root/.codex' codex resume 'session one'" } }) - ).toBe("cd '/home/alice/repo' && codex 'resume' 'session one'") + ).toBe("codex 'resume' 'session one'") }) }) diff --git a/src/renderer/src/lib/ai-vault-resume-command.ts b/src/renderer/src/lib/ai-vault-resume-command.ts index ed13d16e8..e8d05f611 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.ts @@ -15,13 +15,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 { - clearEnvCommand, - commandSeparator, - resolveStartupShell -} from '../../../shared/tui-agent-startup-shell' +import { clearEnvCommand, commandSeparator } from '../../../shared/tui-agent-startup-shell' import type { AppState } from '@/store/types' import type { AiVaultSessionDragPayload } from '@/lib/ai-vault-session-drag' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' @@ -29,7 +24,10 @@ import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../../shared/execution-host' -import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { + getAiVaultResumeWorkspacePath, + resolveAiVaultResumeStartupShell +} from '@/lib/ai-vault-resume-shell' type AiVaultResumeCommandSession = Pick< AiVaultSession, @@ -41,6 +39,7 @@ type AiVaultResumeCommandSession = Pick< export type AiVaultResumeStartup = { command: string + cwd?: string env?: Record envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig @@ -65,7 +64,7 @@ type AiVaultResumeWorktreeArgs = { } export function buildAiVaultResumeCopyCommandForWorktree(args: AiVaultResumeWorktreeArgs): string { - const command = buildAiVaultResumeForWorktree(args).command + const command = buildAiVaultResumeForWorktree(args, true).command if (args.session.agent !== 'codex' || args.session.codexHome !== null) { return command } @@ -80,7 +79,7 @@ export function buildAiVaultResumeCopyCommandForWorktree(args: AiVaultResumeWork export function buildAiVaultResumeStartupForWorktree( args: AiVaultResumeWorktreeArgs ): AiVaultResumeStartup { - return buildAiVaultResumeForWorktree(args) + return buildAiVaultResumeForWorktree(args, false) } /** @@ -120,7 +119,10 @@ export function buildAiVaultDropRepinStartup(args: { }) } -function buildAiVaultResumeForWorktree(args: AiVaultResumeWorktreeArgs): AiVaultResumeStartup { +function buildAiVaultResumeForWorktree( + args: AiVaultResumeWorktreeArgs, + embedCwd: boolean +): AiVaultResumeStartup { const providerSession = getAiVaultAgentProviderSession(args.session) if ( args.session.executionHostId && @@ -151,9 +153,11 @@ function buildAiVaultResumeForWorktree(args: AiVaultResumeWorktreeArgs): AiVault const liveShell: AgentStartupShell | undefined = platform === 'win32' ? isLocalSession - ? resolveWindowsShellStartupFamily(args.state.settings?.terminalWindowsShell) + ? resolveAiVaultResumeShell(args) : 'powershell' : undefined + const cwd = embedCwd ? args.session.cwd : null + const startupCwd = !embedCwd && args.session.cwd ? { cwd: args.session.cwd } : {} if (providerSession && isResumableTuiAgent(args.session.agent)) { const startupPlan = buildAgentResumeStartupPlan({ agent: args.session.agent, @@ -181,7 +185,7 @@ function buildAiVaultResumeForWorktree(args: AiVaultResumeWorktreeArgs): AiVault agent: args.session.agent, sessionId: args.session.sessionId, resumeFilePath, - cwd: args.session.cwd, + cwd, platform, commandOverride: startupPlan.launchConfig.agentCommand, codexHome, @@ -189,13 +193,14 @@ function buildAiVaultResumeForWorktree(args: AiVaultResumeWorktreeArgs): AiVault }) : buildAiVaultResumeShellCommand({ resumeCommand: startupPlan.launchCommand, - cwd: args.session.cwd, + cwd, platform, codexHome, shell: liveShell }), ...(startupPlan.env ? { env: startupPlan.env } : {}), ...realHomeCodexResumeEnvDeletion(args.session), + ...startupCwd, launchConfig: startupPlan.launchConfig, providerSession } @@ -210,7 +215,7 @@ function buildAiVaultResumeForWorktree(args: AiVaultResumeWorktreeArgs): AiVault // forward it too — otherwise a custom OMP_CODING_AGENT_DIR / WSL-store // session would resume by id against the default store and miss. resumeFilePath, - cwd: args.session.cwd, + cwd, platform, commandOverride: args.commandOverride, codexHome, @@ -218,6 +223,7 @@ function buildAiVaultResumeForWorktree(args: AiVaultResumeWorktreeArgs): AiVault // quote for the live Windows shell like the startup-plan branch above. shell: liveShell }), + ...startupCwd, ...realHomeCodexResumeEnvDeletion(args.session) } } @@ -231,11 +237,12 @@ function resolveAiVaultResumeShell(args: AiVaultResumeWorktreeArgs): AgentStartu : getAiVaultResumePlatform(args.state, args.worktreeId) const isLocalSession = !args.session.executionHostId || args.session.executionHostId === LOCAL_EXECUTION_HOST_ID - const shell = - platform === 'win32' && isLocalSession - ? resolveWindowsShellStartupFamily(args.state.settings?.terminalWindowsShell) - : undefined - return resolveStartupShell(platform, shell) + return resolveAiVaultResumeStartupShell({ + state: args.state, + worktreeId: args.worktreeId, + platform, + isLocalSession + }) } export function getAiVaultAgentProviderSession( @@ -298,26 +305,3 @@ export function getAiVaultResumePlatform( const workspacePath = getAiVaultResumeWorkspacePath(state, targetWorktreeId) return workspacePath && parseWslUncPath(workspacePath) ? 'linux' : CLIENT_PLATFORM } - -function getAiVaultResumeWorkspacePath( - state: Pick, - worktreeId: string | null | undefined -): string | null { - if (!worktreeId) { - return null - } - const workspaceScope = parseWorkspaceKey(worktreeId) - if (workspaceScope?.type === 'folder') { - return ( - state.folderWorkspaces.find((workspace) => workspace.id === workspaceScope.folderWorkspaceId) - ?.folderPath ?? null - ) - } - const targetWorktreeId = - workspaceScope?.type === 'worktree' ? workspaceScope.worktreeId : worktreeId - return ( - Object.values(state.worktreesByRepo ?? {}) - .flat() - .find((candidate) => candidate.id === targetWorktreeId)?.path ?? null - ) -} diff --git a/src/renderer/src/lib/ai-vault-resume-shell.ts b/src/renderer/src/lib/ai-vault-resume-shell.ts new file mode 100644 index 000000000..d1ab72648 --- /dev/null +++ b/src/renderer/src/lib/ai-vault-resume-shell.ts @@ -0,0 +1,72 @@ +import type { AppState } from '@/store/types' +import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' +import { CLIENT_PLATFORM } from '@/lib/new-workspace' +import { resolveLocalWindowsTerminalShellOverrideForTab } from '../../../shared/local-windows-terminal-runtime' +import { resolveWindowsShellStartupFamily } from '../../../shared/windows-terminal-shell' +import { + resolveStartupShell, + type AgentStartupShell +} from '../../../shared/tui-agent-startup-shell' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { parseWslUncPath } from '../../../shared/wsl-paths' + +type AiVaultResumeShellState = Pick< + AppState, + | 'activeRepoId' + | 'activeWorktreeId' + | 'folderWorkspaces' + | 'projects' + | 'repos' + | 'settings' + | 'worktreesByRepo' +> + +export function resolveAiVaultResumeStartupShell(args: { + state: AiVaultResumeShellState + worktreeId?: string | null + platform: NodeJS.Platform + isLocalSession: boolean +}): AgentStartupShell { + const projectRuntime = + args.platform === 'win32' && args.isLocalSession + ? getLocalProjectExecutionRuntimeContext(args.state, args.worktreeId, CLIENT_PLATFORM) + : undefined + const workspacePath = getAiVaultResumeWorkspacePath( + args.state, + args.worktreeId ?? args.state.activeWorktreeId + ) + const shellOverride = + args.platform === 'win32' && args.isLocalSession + ? resolveLocalWindowsTerminalShellOverrideForTab({ + explicitShellOverride: undefined, + defaultWindowsShell: args.state.settings?.terminalWindowsShell, + isWslWorktree: Boolean(workspacePath && parseWslUncPath(workspacePath)), + projectRuntime + }) + : undefined + const shell = shellOverride ? resolveWindowsShellStartupFamily(shellOverride) : undefined + return resolveStartupShell(args.platform, shell) +} + +export function getAiVaultResumeWorkspacePath( + state: Pick, + worktreeId: string | null | undefined +): string | null { + if (!worktreeId) { + return null + } + const workspaceScope = parseWorkspaceKey(worktreeId) + if (workspaceScope?.type === 'folder') { + return ( + state.folderWorkspaces.find((workspace) => workspace.id === workspaceScope.folderWorkspaceId) + ?.folderPath ?? null + ) + } + const targetWorktreeId = + workspaceScope?.type === 'worktree' ? workspaceScope.worktreeId : worktreeId + return ( + Object.values(state.worktreesByRepo ?? {}) + .flat() + .find((candidate) => candidate.id === targetWorktreeId)?.path ?? null + ) +} diff --git a/src/renderer/src/lib/launch-ai-vault-session.test.ts b/src/renderer/src/lib/launch-ai-vault-session.test.ts index 9eaee5b64..901bdd192 100644 --- a/src/renderer/src/lib/launch-ai-vault-session.test.ts +++ b/src/renderer/src/lib/launch-ai-vault-session.test.ts @@ -98,15 +98,20 @@ describe('launchAiVaultSessionInNewTab', () => { agent: 'claude', worktreeId: 'wt-1', command: "claude '--dangerously-skip-permissions' '--effort' 'max' '--resume' 'session-1'", + cwd: 'C:\\Users\\alice\\repo', env: { ANTHROPIC_BASE_URL: 'https://claude.example.test' }, envToDelete: ['CODEX_HOME'], launchConfig: { agentCommand: "claude '--dangerously-skip-permissions' '--effort' 'max'", agentArgs: '--dangerously-skip-permissions --effort max', agentEnv: { ANTHROPIC_BASE_URL: 'https://claude.example.test' } - } + }, + providerSession: { key: 'session_id', id: 'session-1' } }) + expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { + startupCwd: 'C:\\Users\\alice\\repo' + }) expect(mockQueueTabStartupCommand).toHaveBeenCalledWith('tab-1', { command: "claude '--dangerously-skip-permissions' '--effort' 'max' '--resume' 'session-1'", env: { ANTHROPIC_BASE_URL: 'https://claude.example.test' }, @@ -117,6 +122,7 @@ describe('launchAiVaultSessionInNewTab', () => { agentEnv: { ANTHROPIC_BASE_URL: 'https://claude.example.test' } }, launchAgent: 'claude', + resumeProviderSession: { key: 'session_id', id: 'session-1' }, telemetry: { agent_kind: 'claude', launch_source: 'sidebar', diff --git a/src/renderer/src/lib/launch-ai-vault-session.ts b/src/renderer/src/lib/launch-ai-vault-session.ts index 6e0b9a897..799b13ed5 100644 --- a/src/renderer/src/lib/launch-ai-vault-session.ts +++ b/src/renderer/src/lib/launch-ai-vault-session.ts @@ -22,6 +22,7 @@ export function launchAiVaultSessionInNewTab(args: { agent: AiVaultAgent worktreeId: string command: string + cwd?: string env?: Record envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig @@ -40,6 +41,7 @@ export function launchAiVaultSessionInNewTab(args: { agentSessionKind: 'resume', launchAgent: args.agent, command: args.command, + ...(args.cwd ? { cwd: args.cwd } : {}), ...(args.env ? { env: args.env } : {}), ...(args.envToDelete ? { envToDelete: args.envToDelete } : {}), ...(args.launchConfig ? { launchConfig: args.launchConfig } : {}), @@ -66,12 +68,15 @@ export function launchAiVaultSessionInNewTab(args: { targetGroupId } - const tab = store.createTab(args.worktreeId, targetGroupId) + const tab = args.cwd + ? store.createTab(args.worktreeId, targetGroupId, undefined, { startupCwd: args.cwd }) + : store.createTab(args.worktreeId, targetGroupId) store.queueTabStartupCommand(tab.id, { command: args.command, ...(args.env ? { env: args.env } : {}), ...(args.envToDelete ? { envToDelete: args.envToDelete } : {}), ...(args.launchConfig ? { launchConfig: args.launchConfig, launchAgent: args.agent } : {}), + ...(args.providerSession ? { resumeProviderSession: args.providerSession } : {}), telemetry: { agent_kind: tuiAgentToAgentKind(args.agent), launch_source: 'sidebar',