diff --git a/src/main/ai-vault/isolated-scan-roots.ts b/src/main/ai-vault/isolated-scan-roots.ts new file mode 100644 index 00000000000..0fe3057f0a5 --- /dev/null +++ b/src/main/ai-vault/isolated-scan-roots.ts @@ -0,0 +1,32 @@ +import { join } from 'path' + +// Per-test scan roots that point every agent source at a throwaway temp dir, so +// the scanner never reads the developer's real ~/.claude, ~/.codex, or the +// shared OpenCode SQLite database while tests run. +export function isolatedScanRoots(root: string) { + return { + claudeProjectsDir: join(root, 'claude-projects'), + codexSessionsDir: join(root, 'codex-sessions'), + geminiSessionsDir: join(root, 'gemini-sessions'), + copilotSessionsDir: join(root, 'copilot-sessions'), + cursorProjectsDir: join(root, 'cursor-projects'), + opencodeStorageDir: join(root, 'opencode-storage'), + // Why: prevent the SQLite scanner from picking up the real + // ~/.local/share/opencode/opencode.db during tests. + opencodeDbPaths: [] as readonly string[], + grokSessionsDir: join(root, 'grok-sessions'), + devinTranscriptsDir: join(root, 'devin-transcripts'), + hermesSessionsDir: join(root, 'hermes-sessions'), + rovoSessionsDir: join(root, 'rovo-sessions'), + openclawStateDir: join(root, 'openclaw-state'), + openclawLegacyStateDir: join(root, 'openclaw-legacy-state'), + piSessionsDir: join(root, 'pi-sessions'), + droidSessionsDir: join(root, 'droid-sessions'), + droidProjectsDir: join(root, 'droid-projects'), + kimiSessionsDir: join(root, 'kimi-sessions') + } +} + +export function jsonLines(records: unknown[]): string { + return records.map((record) => JSON.stringify(record)).join('\n') +} diff --git a/src/main/ai-vault/session-scanner-claude-path-only.test.ts b/src/main/ai-vault/session-scanner-claude-path-only.test.ts new file mode 100644 index 00000000000..4953af36700 --- /dev/null +++ b/src/main/ai-vault/session-scanner-claude-path-only.test.ts @@ -0,0 +1,60 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import { isolatedScanRoots, jsonLines } from './isolated-scan-roots' +import { scanAiVaultSessions } from './session-scanner' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +describe('scanAiVaultSessions path-only Claude transcripts', () => { + it('keeps cwd null because the scanner does not decode the project folder', async () => { + // The scanner intentionally does NOT decode the Claude project folder name + // back into a cwd (decoding is lossy for names with punctuation). It keeps + // cwd null; the renderer's workspace filter and resume builder match the file + // path against known worktree paths instead (see ai-vault-session-filters and + // ai-vault-resume-command tests, which prove the user-visible bug is fixed). + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-claude-project-path-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const claudeProjectDir = join( + roots.claudeProjectsDir, + '-Users-ada-orca-workspaces-orca-path-only-session' + ) + await mkdir(claudeProjectDir, { recursive: true }) + + const filePath = join(claudeProjectDir, '77777777-1111-4222-8333-444444444444.jsonl') + await writeFile( + filePath, + jsonLines([ + { + type: 'user', + sessionId: '77777777-1111-4222-8333-444444444444', + timestamp: '2026-06-12T10:00:00.000Z', + isMeta: false, + message: { role: 'user', content: 'Find the path-only Claude session' } + } + ]) + ) + + const result = await scanAiVaultSessions({ + ...roots, + platform: 'darwin' + }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + agent: 'claude', + sessionId: '77777777-1111-4222-8333-444444444444', + cwd: null, + filePath, + resumeCommand: "claude --resume '77777777-1111-4222-8333-444444444444'" + }) + }) +}) diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts index a4c5cf6540f..2e56d4bd188 100644 --- a/src/main/ai-vault/session-scanner.test.ts +++ b/src/main/ai-vault/session-scanner.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'os' import { join } from 'path' import { afterEach, describe, expect, it, vi } from 'vitest' import { AI_VAULT_AGENTS, buildAiVaultResumeCommand } from '../../shared/ai-vault-types' +import { isolatedScanRoots, jsonLines } from './isolated-scan-roots' import { scanAiVaultSessions } from './session-scanner' let tempRoots: string[] = [] @@ -13,34 +14,6 @@ afterEach(async () => { tempRoots = [] }) -function isolatedScanRoots(root: string) { - return { - claudeProjectsDir: join(root, 'claude-projects'), - codexSessionsDir: join(root, 'codex-sessions'), - geminiSessionsDir: join(root, 'gemini-sessions'), - copilotSessionsDir: join(root, 'copilot-sessions'), - cursorProjectsDir: join(root, 'cursor-projects'), - opencodeStorageDir: join(root, 'opencode-storage'), - // Why: prevent the SQLite scanner from picking up the real - // ~/.local/share/opencode/opencode.db during tests. - opencodeDbPaths: [] as readonly string[], - grokSessionsDir: join(root, 'grok-sessions'), - devinTranscriptsDir: join(root, 'devin-transcripts'), - hermesSessionsDir: join(root, 'hermes-sessions'), - rovoSessionsDir: join(root, 'rovo-sessions'), - openclawStateDir: join(root, 'openclaw-state'), - openclawLegacyStateDir: join(root, 'openclaw-legacy-state'), - piSessionsDir: join(root, 'pi-sessions'), - droidSessionsDir: join(root, 'droid-sessions'), - droidProjectsDir: join(root, 'droid-projects'), - kimiSessionsDir: join(root, 'kimi-sessions') - } -} - -function jsonLines(records: unknown[]): string { - return records.map((record) => JSON.stringify(record)).join('\n') -} - describe('scanAiVaultSessions', () => { it('indexes Claude and Codex transcripts with resume commands', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-')) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts index 5acfd125b09..8209c2054c5 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts @@ -86,6 +86,70 @@ describe('filterAiVaultSessions', () => { ).toEqual(['claude:old-path']) }) + it('includes a path-only Claude session whose file matches the active worktree path', () => { + const pathOnlySession: AiVaultSession = { + ...baseSession, + id: 'claude:path-only', + cwd: null, + filePath: + '/Users/ada/.claude/projects/-Users-ada-orca-workspaces-orca-path-only-session/sess.jsonl' + } + + expect( + filterAiVaultSessions([pathOnlySession], { + query: '', + agents: ['claude'], + scope: 'workspace', + sort: 'updated', + activeWorktreePaths: ['/Users/ada/orca/workspaces/orca/path-only-session'], + hideEmptySessions: true + }).map((session) => session.id) + ).toEqual(['claude:path-only']) + }) + + it('excludes a path-only Claude session for a non-matching worktree path', () => { + const pathOnlySession: AiVaultSession = { + ...baseSession, + id: 'claude:path-only', + cwd: null, + filePath: + '/Users/ada/.claude/projects/-Users-ada-orca-workspaces-orca-path-only-session/sess.jsonl' + } + + expect( + filterAiVaultSessions([pathOnlySession], { + query: '', + agents: ['claude'], + scope: 'workspace', + sort: 'updated', + activeWorktreePaths: ['/Users/ada/orca/workspaces/orca/other-session'], + hideEmptySessions: true + }) + ).toEqual([]) + }) + + it('does not apply the path-only fallback to non-Claude null-cwd sessions', () => { + const codexPathOnly: AiVaultSession = { + ...baseSession, + id: 'codex:path-only', + agent: 'codex', + cwd: null, + filePath: + '/Users/ada/.claude/projects/-Users-ada-orca-workspaces-orca-path-only-session/sess.jsonl' + } + + expect( + filterAiVaultSessions([codexPathOnly], { + query: '', + agents: ['codex'], + scope: 'workspace', + sort: 'updated', + activeWorktreePaths: ['/Users/ada/orca/workspaces/orca/path-only-session'], + hideEmptySessions: true + }) + ).toEqual([]) + }) + it('returns no workspace matches when active workspace paths are empty', () => { expect( filterAiVaultSessions([baseSession], { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts index f0422547761..20d25bbcfac 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts @@ -5,6 +5,7 @@ import { normalizeRuntimePathSeparators } from '../../../../shared/cross-platform-path' import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' +import { isClaudeSessionFileForWorkspace } from '../../../../shared/claude-project-path' import { parseWslUncPath } from '../../../../shared/wsl-paths' import type { AiVaultAgent, @@ -71,16 +72,8 @@ export function filterAiVaultSessions( if (filters.hideEmptySessions && session.messageCount === 0) { return false } - if (filters.scope === 'workspace') { - const cwd = session.cwd - if ( - !cwd || - !filters.activeWorktreePaths.some((pathValue) => - isAiVaultSessionInWorkspacePath(pathValue, cwd) - ) - ) { - return false - } + if (filters.scope === 'workspace' && !isSessionInWorkspaceScope(session, filters)) { + return false } if (filters.scope === 'project') { if (!filters.activeProjectKey) { @@ -278,6 +271,26 @@ function addAiVaultWorkspaceScopePath(paths: string[], pathValue: string): void paths.push(trimmedPath) } +function isSessionInWorkspaceScope( + session: AiVaultSession, + filters: Pick +): boolean { + const cwd = session.cwd + if (cwd) { + return filters.activeWorktreePaths.some((pathValue) => + isAiVaultSessionInWorkspacePath(pathValue, cwd) + ) + } + // Path-only Claude transcripts encode their cwd only in the project folder + // name; match the file path against known worktree paths instead. + return ( + session.agent === 'claude' && + filters.activeWorktreePaths.some((pathValue) => + isClaudeSessionFileForWorkspace(session.filePath, pathValue) + ) + ) +} + function isAiVaultSessionInWorkspacePath(workspacePath: string, sessionCwd: string): boolean { if (isPathInsideOrEqual(workspacePath, sessionCwd)) { return true 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 1d44f82a78f..b32fb679e25 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.test.ts @@ -59,12 +59,79 @@ describe('ai vault resume command runtime', () => { agent: 'claude', sessionId: 'session one', cwd: 'C:\\Users\\alice\\repo', - codexHome: null + codexHome: null, + filePath: 'C:\\Users\\alice\\.claude\\projects\\repo\\s.jsonl' } }) ).toBe('cmd /d /s /c "cd /d ""C:\\Users\\alice\\repo"" && claude --resume ""session one"""') }) + it('resumes a path-only Claude session into the matching worktree cwd', () => { + const state = makeState({ + worktreePath: '/Users/ada/orca/workspaces/orca/path-only-session', + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + }) + + expect( + buildAiVaultResumeCommandForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + session: { + agent: 'claude', + sessionId: 'session one', + cwd: null, + codexHome: null, + filePath: + '/Users/ada/.claude/projects/-Users-ada-orca-workspaces-orca-path-only-session/s.jsonl' + } + }) + ).toBe( + "cd '/Users/ada/orca/workspaces/orca/path-only-session' && claude --resume 'session one'" + ) + }) + + it('keeps a no-cwd command when a path-only Claude session does not match the worktree', () => { + const state = makeState({ + worktreePath: '/Users/ada/orca/workspaces/orca/other-session', + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + }) + + expect( + buildAiVaultResumeCommandForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + session: { + agent: 'claude', + sessionId: 'session one', + cwd: null, + codexHome: null, + filePath: + '/Users/ada/.claude/projects/-Users-ada-orca-workspaces-orca-path-only-session/s.jsonl' + } + }) + ).toBe("claude --resume 'session one'") + }) + + it('resumes a WSL path-only Claude session into the linux cwd', () => { + const state = makeState({ + worktreePath: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\repo' + }) + + expect( + buildAiVaultResumeCommandForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + session: { + agent: 'claude', + sessionId: 'session one', + cwd: null, + codexHome: null, + filePath: '/Users/ada/.claude/projects/-home-ada-repo/s.jsonl' + } + }) + ).toBe("cd '/home/ada/repo' && claude --resume 'session one'") + }) + it('uses POSIX command wrapping for Windows-path projects forced to WSL', () => { const state = makeState({ worktreePath: 'C:\\Users\\alice\\repo', @@ -80,7 +147,8 @@ describe('ai vault resume command runtime', () => { agent: 'claude', sessionId: 'session one', cwd: '/home/alice/repo', - codexHome: null + codexHome: null, + filePath: '/home/alice/.claude/projects/repo/s.jsonl' } }) ).toBe("cd '/home/alice/repo' && claude --resume 'session one'") @@ -107,7 +175,8 @@ describe('ai vault resume command runtime', () => { agent: 'codex', sessionId: 'session one', cwd: '/home/alice/repo', - codexHome: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex' + codexHome: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex', + filePath: '/home/alice/.codex/sessions/s.jsonl' } }) ).toBe("cd '/home/alice/repo' && CODEX_HOME='/home/alice/.codex' 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 eb628023552..804f0011305 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.ts @@ -1,32 +1,76 @@ import { buildAiVaultResumeCommand, type AiVaultSession } from '../../../shared/ai-vault-types' +import { resolveClaudeSessionWorkspaceCwd } from '../../../shared/claude-project-path' import { parseWslUncPath } from '../../../shared/wsl-paths' import type { AppState } from '@/store/types' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { CLIENT_PLATFORM } from '@/lib/new-workspace' -type AiVaultResumeCommandSession = Pick +type AiVaultResumeWorktree = { id: string; path?: string } + +type AiVaultResumeState = Pick< + AppState, + 'activeRepoId' | 'activeWorktreeId' | 'projects' | 'repos' | 'settings' | 'worktreesByRepo' +> + +type AiVaultResumeCommandSession = Pick< + AiVaultSession, + 'agent' | 'sessionId' | 'cwd' | 'codexHome' | 'filePath' +> export function buildAiVaultResumeCommandForWorktree(args: { - state: Pick< - AppState, - 'activeRepoId' | 'activeWorktreeId' | 'projects' | 'repos' | 'settings' | 'worktreesByRepo' - > + state: AiVaultResumeState worktreeId?: string | null session: AiVaultResumeCommandSession commandOverride?: string | null }): string { const platform = getAiVaultResumePlatform(args.state, args.worktreeId) const codexHome = getAiVaultResumeCodexHome(args.session.codexHome, platform) + const cwd = resolveAiVaultResumeCwd(args.state, args.worktreeId, args.session) return buildAiVaultResumeCommand({ agent: args.session.agent, sessionId: args.session.sessionId, - cwd: args.session.cwd, + cwd, platform, commandOverride: args.commandOverride, codexHome }) } +function resolveAiVaultResumeCwd( + state: AiVaultResumeState, + worktreeId: string | null | undefined, + session: AiVaultResumeCommandSession +): string | null { + if (session.cwd) { + return session.cwd + } + // Path-only Claude transcripts carry no cwd; resume into the target worktree + // only when its path encodes to the transcript's Claude project folder. + if (session.agent !== 'claude') { + return null + } + const worktreePath = findAiVaultResumeWorktree(state, worktreeId)?.path + if (!worktreePath) { + return null + } + return resolveClaudeSessionWorkspaceCwd(session.filePath, worktreePath) +} + +function findAiVaultResumeWorktree( + state: AiVaultResumeState, + worktreeId: string | null | undefined +): AiVaultResumeWorktree | null { + const targetWorktreeId = worktreeId ?? state.activeWorktreeId + if (!targetWorktreeId) { + return null + } + return ( + Object.values(state.worktreesByRepo ?? {}) + .flat() + .find((candidate) => candidate.id === targetWorktreeId) ?? null + ) +} + function getAiVaultResumeCodexHome( codexHome: string | null, platform: NodeJS.Platform @@ -40,10 +84,7 @@ function getAiVaultResumeCodexHome( } export function getAiVaultResumePlatform( - state: Pick< - AppState, - 'activeRepoId' | 'activeWorktreeId' | 'projects' | 'repos' | 'settings' | 'worktreesByRepo' - >, + state: AiVaultResumeState, worktreeId?: string | null ): NodeJS.Platform { const projectRuntime = getLocalProjectExecutionRuntimeContext(state, worktreeId, CLIENT_PLATFORM) @@ -54,11 +95,6 @@ export function getAiVaultResumePlatform( return 'linux' } - const targetWorktreeId = worktreeId ?? state.activeWorktreeId - const worktree = targetWorktreeId - ? Object.values(state.worktreesByRepo ?? {}) - .flat() - .find((candidate) => candidate.id === targetWorktreeId) - : null + const worktree = findAiVaultResumeWorktree(state, worktreeId) return worktree?.path && parseWslUncPath(worktree.path) ? 'linux' : CLIENT_PLATFORM } diff --git a/src/shared/claude-project-path.test.ts b/src/shared/claude-project-path.test.ts new file mode 100644 index 00000000000..d0bc01887ce --- /dev/null +++ b/src/shared/claude-project-path.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import { + encodeClaudeProjectPath, + isClaudeSessionFileForWorkspace, + resolveClaudeSessionWorkspaceCwd +} from './claude-project-path' + +describe('encodeClaudeProjectPath', () => { + it('replaces every non-alphanumeric char with a dash without collapsing runs', () => { + expect(encodeClaudeProjectPath('/Users/ada/orca/workspaces/orca/path-only-session')).toBe( + '-Users-ada-orca-workspaces-orca-path-only-session' + ) + // `/.` produces `--`, not a single dash (no run collapsing). + expect(encodeClaudeProjectPath('/Users/ada/.superset/worktrees/foo')).toBe( + '-Users-ada--superset-worktrees-foo' + ) + expect(encodeClaudeProjectPath('/Users/ada/source/orca/.claude/worktrees/foo')).toBe( + '-Users-ada-source-orca--claude-worktrees-foo' + ) + }) + + it('preserves POSIX case but lowercases Windows-flavored paths', () => { + expect(encodeClaudeProjectPath('/Users/Ada/Repo')).toBe('-Users-Ada-Repo') + expect(encodeClaudeProjectPath('C:\\Users\\Ada\\repo')).toBe('c--users-ada-repo') + }) + + it('trims a trailing separator before encoding', () => { + expect(encodeClaudeProjectPath('/Users/ada/repo/')).toBe('-Users-ada-repo') + }) +}) + +describe('resolveClaudeSessionWorkspaceCwd', () => { + const fileFor = (folder: string): string => + `/Users/ada/.claude/projects/${folder}/77777777-1111-4222-8333-444444444444.jsonl` + + it('matches an all-slash workspace path to its Claude folder', () => { + const workspace = '/Users/ada/orca/workspaces/orca/path-only-session' + expect( + resolveClaudeSessionWorkspaceCwd( + fileFor('-Users-ada-orca-workspaces-orca-path-only-session'), + workspace + ) + ).toBe(workspace) + }) + + it('matches a dotted/underscored worktree path a slash-only encoder would miss', () => { + const workspace = '/Users/ada/source/orca/.claude/worktrees/my_project' + expect( + resolveClaudeSessionWorkspaceCwd( + fileFor('-Users-ada-source-orca--claude-worktrees-my-project'), + workspace + ) + ).toBe(workspace) + }) + + it('does not match when a single encoded character differs', () => { + expect( + resolveClaudeSessionWorkspaceCwd( + fileFor('-Users-ada-orca-workspaces-orca-path-only-sessions'), + '/Users/ada/orca/workspaces/orca/path-only-session' + ) + ).toBeNull() + }) + + it('documents punctuation-variant paths collide under Claude project encoding', () => { + const folder = fileFor('-Users-ada-orca-workspaces-foo-bar') + + expect(resolveClaudeSessionWorkspaceCwd(folder, '/Users/ada/orca/workspaces/foo-bar')).toBe( + '/Users/ada/orca/workspaces/foo-bar' + ) + expect(resolveClaudeSessionWorkspaceCwd(folder, '/Users/ada/orca/workspaces/foo_bar')).toBe( + '/Users/ada/orca/workspaces/foo_bar' + ) + }) + + it('matches a WSL UNC worktree against its linux-encoded folder and returns the linux cwd', () => { + expect( + resolveClaudeSessionWorkspaceCwd( + fileFor('-home-ada-repo'), + '\\\\wsl.localhost\\Ubuntu\\home\\ada\\repo' + ) + ).toBe('/home/ada/repo') + }) + + it('tolerates Windows case drift between recorded cwd and stored worktree path', () => { + expect( + resolveClaudeSessionWorkspaceCwd(fileFor('c--users-ada-repo'), 'C:\\Users\\Ada\\repo') + ).toBe('C:\\Users\\Ada\\repo') + }) + + it('exposes a boolean predicate', () => { + expect(isClaudeSessionFileForWorkspace(fileFor('-Users-ada-repo'), '/Users/ada/repo')).toBe( + true + ) + expect(isClaudeSessionFileForWorkspace(fileFor('-Users-ada-repo'), '/Users/ada/other')).toBe( + false + ) + }) +}) diff --git a/src/shared/claude-project-path.ts b/src/shared/claude-project-path.ts new file mode 100644 index 00000000000..a7996917838 --- /dev/null +++ b/src/shared/claude-project-path.ts @@ -0,0 +1,60 @@ +import { + isWindowsAbsolutePathLike, + normalizeRuntimePathForComparison, + normalizeRuntimePathSeparators +} from './cross-platform-path' +import { parseWslUncPath } from './wsl-paths' + +// Claude Code names each project directory after the session's absolute cwd by +// replacing EVERY non-alphanumeric character with '-' — it does not collapse +// runs of '-' and preserves case (verified against real on-disk names, e.g. +// `/Users/ada/.claude/worktrees/foo` -> `-Users-ada--claude-worktrees-foo`). +// We never decode these names (lossy and non-injective); instead we encode a +// KNOWN workspace path and compare, which keeps the fallback scoped to the +// worktree the user is currently viewing/resuming from. +export function encodeClaudeProjectPath(workspacePath: string): string { + return normalizeRuntimePathForComparison(workspacePath).replace(/[^a-zA-Z0-9]/g, '-') +} + +// Returns the effective cwd to use for a path-only Claude transcript when its +// project folder encodes exactly to one of the given workspace candidates, or +// null when none match. For WSL UNC worktrees the returned cwd is the Linux +// path so POSIX resume commands cd into a real directory, not a UNC path. +export function resolveClaudeSessionWorkspaceCwd( + filePath: string, + workspacePath: string +): string | null { + const folder = claudeProjectFolderName(filePath) + if (!folder) { + return null + } + + for (const candidate of claudeWorkspaceMatchCandidates(workspacePath)) { + // Claude preserves the original cwd casing in the folder name, so lowercase + // it only on the Windows branch to mirror normalizeRuntimePathForComparison. + const folderKey = isWindowsAbsolutePathLike(candidate) ? folder.toLowerCase() : folder + if (encodeClaudeProjectPath(candidate) === folderKey) { + return candidate + } + } + return null +} + +export function isClaudeSessionFileForWorkspace(filePath: string, workspacePath: string): boolean { + return resolveClaudeSessionWorkspaceCwd(filePath, workspacePath) !== null +} + +function claudeProjectFolderName(filePath: string): string { + // The transcript's immediate parent directory is Claude's encoded project + // folder name. String-only (no fs): normalize separators then take the + // second-to-last segment. + const segments = normalizeRuntimePathSeparators(filePath).split('/').filter(Boolean) + return segments.at(-2) ?? '' +} + +function claudeWorkspaceMatchCandidates(workspacePath: string): string[] { + // Claude under WSL encodes the Linux cwd, but Orca may store the worktree as a + // Windows UNC path; try the Linux path too so the encoded folder can match. + const wsl = parseWslUncPath(workspacePath) + return wsl ? [workspacePath, wsl.linuxPath] : [workspacePath] +}