diff --git a/src/main/runtime/terminal/launch/mobile-terminal-startup.ts b/src/main/runtime/terminal/launch/mobile-terminal-startup.ts index 123565e4689..98e78822d47 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-startup.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-startup.ts @@ -91,7 +91,8 @@ export abstract class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends ...resolveResumeLaunchRecipe(launchConfig), platform: launchRuntime.platform, shell: agentLaunchQuotingShell(launchRuntime), - isRemote: Boolean(workspace.connectionId) + isRemote: Boolean(workspace.connectionId), + launchCwd: workspace.path }) if (!startupPlan) { return null diff --git a/src/main/ssh/orcad-migration-path-fields.ts b/src/main/ssh/orcad-migration-path-fields.ts index 4c395619ca6..402fd14e206 100644 --- a/src/main/ssh/orcad-migration-path-fields.ts +++ b/src/main/ssh/orcad-migration-path-fields.ts @@ -123,6 +123,10 @@ export const ORCAD_MIGRATION_PATH_FIELDS: readonly { at: 'dormantState.workspaceSession.sleepingAgentSessionsByPaneKey{}.providerSession.transcriptPath', kind: 'absolute' }, + { + at: 'dormantState.workspaceSession.sleepingAgentSessionsByPaneKey{}.providerSession.cwd', + kind: 'absolute' + }, { at: 'dormantState.workspaceSession.sleepingAgentSessionsByPaneKey{}.launchConfig.ompResumeFilePath', kind: 'absolute' diff --git a/src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts b/src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts index 5b40a0d73c5..1ca88396138 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts @@ -93,7 +93,8 @@ export function bindBuildColdRestoreAgentResumeStartup(session: ConnectPanePtySe cmdOverrides: state.settings?.agentCmdOverrides ?? {}, ...resolveResumeLaunchRecipe(launchConfig), platform: resumeTarget.platform, - shell: resumeTarget.shell + shell: resumeTarget.shell, + launchCwd: session.worktree?.path }) if (!startupPlan) { return null diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 4ee52dc906c..0a1933a946e 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -1,4 +1,5 @@ import { readTerminalSpawnDeferral } from '../../../../shared/host-spawn-admission' +import { agentProviderSessionForEnsure } from '../../../../shared/agent-session-resume' import { remoteRestoreSpawnIntentParams } from '../../runtime/remote-restore-spawn-intent' import { createAgentSessionKeyboardOptions } from '@/runtime/agent-session-keyboard-capability' import { createRemoteRuntimeDisconnectedInputGrace } from './remote-runtime-disconnected-input-grace' @@ -3451,7 +3452,7 @@ export function createRemoteRuntimePtyTransport( ...keyboardOptions, worktree: toRuntimeTerminalWorktreeSelector(worktreeId), agent: launchAgentToSend!, - providerSession: resumeProviderSessionToSend, + providerSession: agentProviderSessionForEnsure(resumeProviderSessionToSend), ...(launchConfigToSend?.ompResumeFilePath ? { ompResumeFilePath: launchConfigToSend.ompResumeFilePath } : {}), diff --git a/src/renderer/src/lib/sleeping-agent-session-launch.ts b/src/renderer/src/lib/sleeping-agent-session-launch.ts index e5cfe10bfa3..5df77c13dc2 100644 --- a/src/renderer/src/lib/sleeping-agent-session-launch.ts +++ b/src/renderer/src/lib/sleeping-agent-session-launch.ts @@ -58,7 +58,8 @@ export function launchSleepingAgentSession( cmdOverrides: state.settings?.agentCmdOverrides ?? {}, ...resolveResumeLaunchRecipe(launchConfig), platform: resumeTarget.platform, - shell: resumeTarget.shell + shell: resumeTarget.shell, + launchCwd: state.getKnownWorktreeById(record.worktreeId)?.path }) if (!startupPlan) { toast.error( diff --git a/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts b/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts index 458618402d0..257c2a085cd 100644 --- a/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts +++ b/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts @@ -3,6 +3,7 @@ import { createAgentSessionKeyboardOptions } from './agent-session-keyboard-capa import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import type { RuntimeMobileSessionCreateTerminalResult } from '../../../shared/runtime-types' import { toRuntimeExecutionHostId } from '../../../shared/execution-host' +import { agentProviderSessionForEnsure } from '../../../shared/agent-session-resume' import { translate } from '../i18n/i18n' import { useAppStore } from '../store' import { agentResumeHostAuthorityCapability } from './agent-resume-host-authority-capability' @@ -109,7 +110,7 @@ export async function createWebRuntimeSessionTerminalResult( kind: 'explicit', worktree: toRuntimeWorktreeSelector(args.worktreeId), agent, - providerSession: args.providerSession!, + providerSession: agentProviderSessionForEnsure(args.providerSession!), ...(args.launchConfig?.ompResumeFilePath ? { ompResumeFilePath: args.launchConfig.ompResumeFilePath } : {}), diff --git a/src/shared/agent-hook-listener-claude-turn-state.test.ts b/src/shared/agent-hook-listener-claude-turn-state.test.ts index 62733d0f56e..07420b6c4c6 100644 --- a/src/shared/agent-hook-listener-claude-turn-state.test.ts +++ b/src/shared/agent-hook-listener-claude-turn-state.test.ts @@ -91,6 +91,46 @@ describe('shared agent-hook-listener', () => { }) }) + it('records the folder a Claude session started in, not the one its Bash tool moved to (#16960)', () => { + const sessionId = '55555555-5555-4555-8555-555555555555' + normalizeAndAccept(state, 'claude', { + hook_event_name: 'UserPromptSubmit', + session_id: sessionId, + prompt: 'go', + cwd: '/repo/pkg' + }) + // Claude's hook cwd follows `cd` inside its Bash tool. + const afterCd = normalizeAndAccept(state, 'claude', { + hook_event_name: 'PostToolUse', + session_id: sessionId, + tool_name: 'Bash', + cwd: '/repo/pkg/sub' + }) + const cleared = normalizeAndAccept(state, 'claude', { + hook_event_name: 'SessionStart', + source: 'clear', + session_id: '66666666-6666-4666-8666-666666666666', + cwd: '/repo/pkg' + }) + const clearedPrompt = normalizeAndAccept(state, 'claude', { + hook_event_name: 'UserPromptSubmit', + session_id: '66666666-6666-4666-8666-666666666666', + prompt: 'next', + cwd: '/repo/pkg/sub' + }) + const resumedElsewhere = normalizeAndAccept(state, 'claude', { + hook_event_name: 'SessionStart', + source: 'resume', + session_id: '77777777-7777-4777-8777-777777777777', + cwd: '/elsewhere' + }) + + expect(afterCd?.providerSession).toMatchObject({ id: sessionId, cwd: '/repo/pkg' }) + expect(cleared?.providerSession?.cwd).toBe('/repo/pkg') + expect(clearedPrompt?.providerSession?.cwd).toBe('/repo/pkg') + expect(resumedElsewhere?.providerSession?.cwd).toBe('/elsewhere') + }) + it('resets stale Claude turn state when SessionStart announces a new session on the pane', () => { normalizeAndAccept(state, 'claude', { hook_event_name: 'UserPromptSubmit', prompt: 'fix bug' }) normalizeAndAccept(state, 'claude', { diff --git a/src/shared/agent-hook-listener-hermes-codex-droid.test.ts b/src/shared/agent-hook-listener-hermes-codex-droid.test.ts index 8b5b7f6ba36..506dd687687 100644 --- a/src/shared/agent-hook-listener-hermes-codex-droid.test.ts +++ b/src/shared/agent-hook-listener-hermes-codex-droid.test.ts @@ -5,7 +5,7 @@ import { } from './agent-hook-listener/listener-state' import { normalizeHookPayload } from './agent-hook-listener' import { clearGrokSessionPathLookupCacheForTests } from './grok-session-paths' -import { PANE_KEY } from './agent-hook-listener-test-harness' +import { normalizeAndAccept, PANE_KEY } from './agent-hook-listener-test-harness' describe('shared agent-hook-listener', () => { let state: HookListenerState @@ -234,6 +234,23 @@ describe('shared agent-hook-listener', () => { expect(working?.payload.interactivePrompt).toBeUndefined() }) + it('records the new folder when Codex forks a thread elsewhere (/cd, /worktree)', () => { + normalizeAndAccept(state, 'codex', { + hook_event_name: 'UserPromptSubmit', + session_id: 'thread-a', + prompt: 'go', + cwd: '/repo/a' + }) + const forked = normalizeAndAccept(state, 'codex', { + hook_event_name: 'SessionStart', + session_id: 'thread-b', + source: 'startup', + cwd: '/repo/b' + }) + + expect(forked?.providerSession).toMatchObject({ id: 'thread-b', cwd: '/repo/b' }) + }) + it('clears stale Droid tool input when a same-tool update has explicit unpreviewable input', () => { normalizeHookPayload( state, diff --git a/src/shared/agent-hook-listener/pane-provider-session.ts b/src/shared/agent-hook-listener/pane-provider-session.ts index 2a30d20ee3a..33d24770f85 100644 --- a/src/shared/agent-hook-listener/pane-provider-session.ts +++ b/src/shared/agent-hook-listener/pane-provider-session.ts @@ -1,6 +1,7 @@ import type { AgentHookSource } from '../agent-hook-relay' import { extractAgentProviderSession, + readPathFromKeys, type AgentProviderSessionMetadata } from '../agent-session-resume' import type { HookListenerState } from './listener-state' @@ -17,8 +18,35 @@ export function readPaneProviderSession( if (source === 'codex' && readString(hookPayload, 'agent_id')) { return null } - return extractAgentProviderSession( + const providerSession = extractAgentProviderSession( source, source === 'hermes' ? withHermesPaneSessionId(state, paneKey, hookPayload) : hookPayload ) + if (!providerSession) { + return null + } + const cwd = readAgentCwd(state, source, paneKey, providerSession.id, hookPayload) + return cwd ? { ...providerSession, cwd } : providerSession +} + +/** The folder the pane's agent started in. Only Claude/Codex hooks are known to report it (Cursor's cwd is a shell command's). */ +function readAgentCwd( + state: HookListenerState, + source: AgentHookSource, + paneKey: string, + sessionId: string, + hookPayload: Record +): string | undefined { + if (source !== 'claude' && source !== 'codex') { + return undefined + } + const previous = state.lastStatusByPaneKey.get(paneKey) + const previousCwd = previous?.source === source ? previous.providerSession?.cwd : undefined + // Claude's hook cwd follows its Bash tool's `cd`; /clear and compact restart in the same process. + const keepsPreviousFolder = + previous?.providerSession?.id === sessionId || + (source === 'claude' && + hookPayload.hook_event_name === 'SessionStart' && + (hookPayload.source === 'clear' || hookPayload.source === 'compact')) + return previousCwd && keepsPreviousFolder ? previousCwd : readPathFromKeys(hookPayload, ['cwd']) } diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index 42cf5897dac..393cf55d971 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -41,6 +41,8 @@ export type AgentProviderSessionMetadata = { id: string /** Prefer hook-reported transcript paths: recent Claude filenames differ from session_id. Claude/Codex still resume by ID; Pi uses session_file as its --session locator. */ transcriptPath?: string + /** Folder the agent ran in; resume runs there, not the pane's start folder (#16960). */ + cwd?: string } export type SleepingAgentLaunchConfig = { @@ -131,8 +133,8 @@ function readSessionId(record: Record, keys: readonly string[]) return null } -/** Read the hook-reported transcript path; recent Claude filenames cannot be reconstructed from session ID. */ -function readTranscriptPathFromKeys( +/** Read a hook-reported path; recent Claude transcript filenames cannot be reconstructed from session ID. */ +export function readPathFromKeys( record: Record, keys: readonly string[] ): string | undefined { @@ -154,7 +156,7 @@ function withTranscriptPath( payload: Record, keys: readonly string[] = ['transcript_path', 'transcriptPath'] ): AgentProviderSessionMetadata { - const transcriptPath = readTranscriptPathFromKeys(payload, keys) + const transcriptPath = readPathFromKeys(payload, keys) return transcriptPath ? { ...metadata, transcriptPath } : metadata } @@ -176,8 +178,22 @@ export function normalizeAgentProviderSession(raw: unknown): AgentProviderSessio return null } // Persisted/relay paths cross trust boundaries too; reject controls as for hook paths. - const transcriptPath = readTranscriptPathFromKeys(record, ['transcriptPath']) - return transcriptPath ? { key, id, transcriptPath } : { key, id } + const transcriptPath = readPathFromKeys(record, ['transcriptPath']) + const cwd = readPathFromKeys(record, ['cwd']) + return { + key, + id, + ...(transcriptPath ? { transcriptPath } : {}), + ...(cwd ? { cwd } : {}) + } +} + +/** Older hosts' strict ensureAgentSession schema rejects the newer `cwd` field. */ +export function agentProviderSessionForEnsure( + providerSession: AgentProviderSessionMetadata +): AgentProviderSessionMetadata { + const { cwd: _cwd, ...identity } = providerSession + return identity } /** Compare the provider-owned values that identify the CLI resume target. diff --git a/src/shared/tui-agent-resume-startup.ts b/src/shared/tui-agent-resume-startup.ts index 1d41e21e3f9..8d278010467 100644 --- a/src/shared/tui-agent-resume-startup.ts +++ b/src/shared/tui-agent-resume-startup.ts @@ -12,6 +12,9 @@ import { resolveStartupShell, type AgentStartupShell } from './tui-agent-startup import { TUI_AGENT_CONFIG } from './tui-agent-config' import type { TuiAgent } from './tui-agent' import { buildAgentResumeLaunchCommand } from './agent-resume-launch-command' +import { buildAiVaultResumeShellCommand } from './ai-vault-resume-command' +import { normalizeRuntimePathForComparison } from './cross-platform-path' +import { parseWslUncPath } from './wsl-paths' export function buildAgentResumeStartupPlan(args: { agent: ResumableTuiAgent @@ -28,6 +31,8 @@ export function buildAgentResumeStartupPlan(args: { isRemote?: boolean /** The caller chose the terminal folder on purpose, so Codex must not ask for the recorded one. */ resumeInLaunchCwd?: boolean + /** Folder the resume shell starts in; an agent recorded elsewhere is resumed from its own folder (#16960). */ + launchCwd?: string | null /** Open a copy of the conversation instead of re-entering it; null for agents that cannot. */ fork?: boolean }): AgentStartupPlan | null { @@ -68,7 +73,17 @@ export function buildAgentResumeStartupPlan(args: { ...args, agentCommand: baseCommand.commandWithoutSessionOptions }) - const launchCommand = buildAgentResumeLaunchCommand(args.agent, baseCommand.command, argv, shell) + const resumeCommand = buildAgentResumeLaunchCommand(args.agent, baseCommand.command, argv, shell) + const agentCwd = args.providerSession.cwd + const launchCommand = + agentCwd && args.launchCwd && !args.resumeInLaunchCwd && !isSameFolder(agentCwd, args.launchCwd) + ? buildAiVaultResumeShellCommand({ + resumeCommand, + cwd: agentCwd, + platform: args.platform, + shell + }) + : resumeCommand const applied = baseCommand.appliedSessionOptions return { agent: args.agent, @@ -81,3 +96,11 @@ export function buildAgentResumeStartupPlan(args: { ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) } } + +/** WSL panes start at a UNC path while the agent reports its Linux path. */ +function isSameFolder(agentCwd: string, launchCwd: string): boolean { + const launchPath = parseWslUncPath(launchCwd)?.linuxPath ?? launchCwd + return ( + normalizeRuntimePathForComparison(agentCwd) === normalizeRuntimePathForComparison(launchPath) + ) +} diff --git a/src/shared/tui-agent-startup.spec.ts b/src/shared/tui-agent-startup.spec.ts index 2f19a542c17..cec14d4f8f6 100644 --- a/src/shared/tui-agent-startup.spec.ts +++ b/src/shared/tui-agent-startup.spec.ts @@ -455,6 +455,23 @@ describe('tui agent startup plans', () => { expect(plan?.startupCommandDelivery).toBe('shell-ready') }) + it('resumes an agent from the folder it ran in when the pane starts elsewhere (#16960)', () => { + const resume = (cwd: string, launchCwd: string) => + buildAgentResumeStartupPlan({ + agent: 'claude', + providerSession: { key: 'session_id', id: 's1', cwd }, + cmdOverrides: {}, + platform: 'darwin', + launchCwd + })?.launchCommand + + expect(resume('/repo/pkg', '/repo')).toBe("cd '/repo/pkg' && claude '--resume' 's1'") + expect(resume('/repo/', '/repo')).toBe("claude '--resume' 's1'") + expect(resume('/home/me/repo', '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo')).toBe( + "claude '--resume' 's1'" + ) + }) + it('quotes Windows resume argv for cmd.exe when shell is cmd', () => { const plan = buildAgentResumeStartupPlan({ agent: 'grok', diff --git a/src/shared/workspace-session-sleeping-agents.ts b/src/shared/workspace-session-sleeping-agents.ts index bdabab8abdb..e8fe4aaf735 100644 --- a/src/shared/workspace-session-sleeping-agents.ts +++ b/src/shared/workspace-session-sleeping-agents.ts @@ -19,7 +19,8 @@ const agentProviderSessionSchema = z.preprocess( key: z.enum(['session_id', 'conversation_id']), id: z.string().min(1).max(512), // Pi resume requires its persisted authoritative transcript path. - transcriptPath: z.string().min(1).optional() + transcriptPath: z.string().min(1).optional(), + cwd: z.string().min(1).optional() }) )