From e94c83d164bc448b415e5cc165f9efdd723c28e7 Mon Sep 17 00:00:00 2001 From: Avichal Dwivedi <150160561+AvichalDwivedi2205@users.noreply.github.com> Date: Sun, 5 Jul 2026 11:26:55 +0530 Subject: [PATCH] fix(ai-vault): make SSH session history host-aware (#7367) * fix(ai-vault): scan sessions by execution host * fix(ai-vault): route history resume by host * test(e2e): cover SSH AI Vault history * Generalize remote session scanning for all AI Vault agents Replace the Codex-only remote SSH session history scanner with a unified scanner supporting all registered agents. This ensures remote transcripts for Claude, Gemini, Devin, Droid, and others are scanned and listed alongside local history. - Propagate host metadata (host ID and platform) to scanned sessions - Scope remote actions by host, disabling local OS path actions on remote session logs - Resolve ambiguous project/worktree matching for overlapping paths by verifying matching host setup IDs - Update tests and E2E specs to validate multi-agent remote scanning --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- .../remote-session-scanner-codex-index.ts | 51 +++ .../remote-session-scanner-sources.ts | 221 +++++++++++ .../ai-vault/remote-session-scanner-types.ts | 34 ++ .../ai-vault/remote-session-scanner.test.ts | 344 ++++++++++++++++++ src/main/ai-vault/remote-session-scanner.ts | 296 +++++++++++++++ .../ai-vault/session-scanner-accumulator.ts | 15 +- .../ai-vault/session-scanner-agent-parser.ts | 7 +- .../ai-vault/session-scanner-codex-parser.ts | 67 +++- .../ai-vault/session-scanner-devin-parser.ts | 19 +- .../ai-vault/session-scanner-droid-parser.ts | 120 ++++++ .../ai-vault/session-scanner-graph-parsers.ts | 112 ++---- .../session-scanner-primary-parsers.ts | 101 ++++- .../session-scanner-secondary-parsers.ts | 93 ++++- src/main/ai-vault/session-scanner-types.ts | 2 + src/main/ai-vault/session-scanner.ts | 36 +- src/main/ipc/ai-vault.test.ts | 163 +++++++++ src/main/ipc/ai-vault.ts | 174 ++++++++- src/main/ipc/ssh.ts | 19 +- src/main/ssh/ssh-relay-session.ts | 24 ++ .../components/right-sidebar/AiVaultPanel.tsx | 17 +- .../right-sidebar/AiVaultPanelControls.tsx | 62 +++- .../right-sidebar/AiVaultPanelHeader.tsx | 14 +- .../AiVaultSessionActionMenuItems.tsx | 108 ++++++ .../right-sidebar/AiVaultSessionDetails.tsx | 32 +- .../right-sidebar/AiVaultSessionRow.tsx | 111 +----- .../AiVaultSessionVirtualList.tsx | 11 +- .../SessionRowTrailingActions.tsx | 6 +- .../right-sidebar/ai-vault-host-scope.test.ts | 120 ++++++ .../right-sidebar/ai-vault-host-scope.ts | 64 ++++ .../ai-vault-original-pane.test.ts | 1 + .../ai-vault-session-display.test.ts | 1 + .../ai-vault-session-filters.test.ts | 1 + .../ai-vault-session-launch-actions.ts | 26 +- .../ai-vault-session-path-actions.test.ts | 14 + .../ai-vault-session-path-actions.ts | 13 + .../ai-vault-session-projects.test.ts | 30 +- .../ai-vault-session-projects.ts | 57 ++- .../ai-vault-session-refresh.test.ts | 80 +++- .../right-sidebar/ai-vault-session-refresh.ts | 156 ++++---- .../right-sidebar/ai-vault-session-resume.ts | 21 +- .../ai-vault-session-worktree.test.ts | 65 +++- .../ai-vault-session-worktree.ts | 33 +- .../tab-group/AiVaultSessionDropLayer.tsx | 13 +- src/renderer/src/i18n/locales/en.json | 6 +- src/renderer/src/i18n/locales/es.json | 10 +- src/renderer/src/i18n/locales/ja.json | 10 +- src/renderer/src/i18n/locales/ko.json | 10 +- src/renderer/src/i18n/locales/zh.json | 10 +- .../src/lib/ai-vault-resume-command.test.ts | 107 ++++++ .../src/lib/ai-vault-resume-command.ts | 23 +- .../src/lib/ai-vault-resume-target.test.ts | 74 ++++ .../src/lib/ai-vault-resume-target.ts | 82 +++++ src/renderer/src/lib/ai-vault-session-drag.ts | 16 +- src/shared/ai-vault-types.ts | 5 + .../e2e/ssh-ai-vault-session-history.spec.ts | 293 +++++++++++++++ 55 files changed, 3197 insertions(+), 403 deletions(-) create mode 100644 src/main/ai-vault/remote-session-scanner-codex-index.ts create mode 100644 src/main/ai-vault/remote-session-scanner-sources.ts create mode 100644 src/main/ai-vault/remote-session-scanner-types.ts create mode 100644 src/main/ai-vault/remote-session-scanner.test.ts create mode 100644 src/main/ai-vault/remote-session-scanner.ts create mode 100644 src/main/ai-vault/session-scanner-droid-parser.ts create mode 100644 src/main/ipc/ai-vault.test.ts create mode 100644 src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.tsx create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-host-scope.test.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-host-scope.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.test.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.ts create mode 100644 tests/e2e/ssh-ai-vault-session-history.spec.ts diff --git a/src/main/ai-vault/remote-session-scanner-codex-index.ts b/src/main/ai-vault/remote-session-scanner-codex-index.ts new file mode 100644 index 00000000000..d6a305ba4cf --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-codex-index.ts @@ -0,0 +1,51 @@ +import type { IFilesystemProvider } from '../providers/types' +import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { joinRemotePath } from '../ssh/ssh-remote-platform' +import { extractString, normalizeTitleText, parseJsonObject } from './session-scanner-values' + +const CODEX_SESSION_INDEX_FILE = 'session_index.jsonl' + +export async function remoteCodexIndexTitles(args: { + provider: IFilesystemProvider + codexHome: string + hostPlatform: RemoteHostPlatform + titleCaches: Map>> +}): Promise> { + const cached = args.titleCaches.get(args.codexHome) + if (cached) { + return cached + } + const pending = readRemoteCodexIndexTitles(args.provider, args.codexHome, args.hostPlatform) + args.titleCaches.set(args.codexHome, pending) + return pending +} + +async function readRemoteCodexIndexTitles( + provider: IFilesystemProvider, + codexHome: string, + hostPlatform: RemoteHostPlatform +): Promise> { + const titleBySessionId = new Map() + try { + const { content, isBinary } = await provider.readFile( + joinRemotePath(hostPlatform, codexHome, CODEX_SESSION_INDEX_FILE) + ) + if (isBinary) { + return titleBySessionId + } + for (const line of content.split(/\r?\n/)) { + const record = parseJsonObject(line) + if (!record) { + continue + } + const sessionId = extractString(record.id) + const title = normalizeTitleText(extractString(record.thread_name) ?? '') + if (sessionId && title) { + titleBySessionId.set(sessionId, title) + } + } + } catch { + // Codex indexes are opportunistic; raw transcripts remain sufficient. + } + return titleBySessionId +} diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts new file mode 100644 index 00000000000..f232b0adf81 --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-sources.ts @@ -0,0 +1,221 @@ +import type { AiVaultAgent, AiVaultSession } from '../../shared/ai-vault-types' +import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { joinRemotePath } from '../ssh/ssh-remote-platform' +import { parseCodexSessionContent } from './session-scanner-codex-parser' +import { parseDevinSessionContent } from './session-scanner-devin-parser' +import { parseDroidSessionContent } from './session-scanner-droid-parser' +import { parseMessageGraphSessionContent } from './session-scanner-graph-parsers' +import { + parseClaudeSessionContent, + parseGeminiSessionContent +} from './session-scanner-primary-parsers' +import { + parseCopilotSessionContent, + parseCursorSessionContent, + parseHermesSessionContent +} from './session-scanner-secondary-parsers' +import type { FileWithMtime } from './session-scanner-types' +import { normalizePiSessionsDir } from './session-scanner-values' +import { remoteCodexIndexTitles } from './remote-session-scanner-codex-index' +import type { + RemoteParserOptions, + RemoteScannerContext, + RemoteSessionSource +} from './remote-session-scanner-types' + +type RemoteContentParser = ( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform, + options: RemoteParserOptions +) => Promise | AiVaultSession | null + +export function remoteSessionSources( + remoteHome: string, + hostPlatform: RemoteHostPlatform +): RemoteSessionSource[] { + return [ + ...remoteCodexSources(remoteHome, hostPlatform), + jsonlSource( + 'claude', + remoteHome, + hostPlatform, + ['.claude', 'projects'], + parseClaudeSessionContent + ), + source( + 'gemini', + remoteHome, + hostPlatform, + ['.gemini', 'tmp'], + ['.json', '.jsonl'], + parseGeminiSessionContent + ), + jsonlSource( + 'copilot', + remoteHome, + hostPlatform, + ['.copilot', 'session-state'], + parseCopilotSessionContent + ), + jsonlSource( + 'cursor', + remoteHome, + hostPlatform, + ['.cursor', 'projects'], + parseCursorSessionContent, + (path) => remotePathSegments(path).includes('agent-transcripts') + ), + source( + 'hermes', + remoteHome, + hostPlatform, + ['.hermes', 'sessions'], + ['.json'], + parseHermesSessionContent + ), + source( + 'devin', + remoteHome, + hostPlatform, + ['.local', 'share', 'devin', 'cli', 'transcripts'], + ['.json'], + parseDevinSessionContent + ), + jsonlSource('pi', remoteHome, hostPlatform, remotePiSessionsSegments(), piParser), + jsonlSource( + 'droid', + remoteHome, + hostPlatform, + ['.factory', 'sessions'], + parseDroidSessionContent + ), + jsonlSource( + 'droid', + remoteHome, + hostPlatform, + ['.factory', 'projects'], + parseDroidSessionContent + ), + ...remoteOpenClawSources(remoteHome, hostPlatform) + ] +} + +function source( + agent: AiVaultAgent, + remoteHome: string, + hostPlatform: RemoteHostPlatform, + segments: readonly string[], + extensions: readonly string[], + parseContent: RemoteContentParser, + filePredicate?: (path: string) => boolean +): RemoteSessionSource { + return { + agent, + rootDir: joinRemotePath(hostPlatform, remoteHome, ...segments), + extensions, + filePredicate, + parse: (file, content, context) => + Promise.resolve(parseContent(file, content, context.hostPlatform.os, parserOptions(context))) + } +} + +function jsonlSource( + agent: AiVaultAgent, + remoteHome: string, + hostPlatform: RemoteHostPlatform, + segments: readonly string[], + parseContent: RemoteContentParser, + filePredicate?: (path: string) => boolean +): RemoteSessionSource { + return source(agent, remoteHome, hostPlatform, segments, ['.jsonl'], parseContent, filePredicate) +} + +function remoteCodexSources( + remoteHome: string, + hostPlatform: RemoteHostPlatform +): RemoteSessionSource[] { + return [ + joinRemotePath(hostPlatform, remoteHome, '.codex'), + joinRemotePath( + hostPlatform, + remoteHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + ].map((codexHome) => ({ + agent: 'codex', + rootDir: joinRemotePath(hostPlatform, codexHome, 'sessions'), + extensions: ['.jsonl'], + parse: (file, content, context) => + parseCodexSessionContent({ + file, + content, + platform: context.hostPlatform.os, + codexHome, + executionHostId: context.executionHostId, + executionHostPlatform: context.hostPlatform.os, + readIndexedTitle: async (sessionId) => + ( + await remoteCodexIndexTitles({ + provider: context.provider, + codexHome, + hostPlatform, + titleCaches: context.titleCaches + }) + ).get(sessionId) ?? null + }) + })) +} + +function remoteOpenClawSources( + remoteHome: string, + hostPlatform: RemoteHostPlatform +): RemoteSessionSource[] { + return ['.openclaw', '.clawdbot'].map((rootName) => + jsonlSource( + 'openclaw', + remoteHome, + hostPlatform, + [rootName, 'agents'], + openClawParser, + (path) => remotePathSegments(path).includes('sessions') + ) + ) +} + +function parserOptions(context: RemoteScannerContext): RemoteParserOptions { + return { + executionHostId: context.executionHostId, + executionHostPlatform: context.hostPlatform.os + } +} + +function piParser( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform, + options: RemoteParserOptions +): Promise { + return parseMessageGraphSessionContent('pi', file, content, platform, options) +} + +function openClawParser( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform, + options: RemoteParserOptions +): Promise { + return parseMessageGraphSessionContent('openclaw', file, content, platform, options) +} + +function remotePathSegments(path: string): string[] { + return path.replace(/\\/g, '/').split('/').filter(Boolean) +} + +function remotePiSessionsSegments(): string[] { + return normalizePiSessionsDir('/.pi/agent/sessions').split('/').filter(Boolean) +} diff --git a/src/main/ai-vault/remote-session-scanner-types.ts b/src/main/ai-vault/remote-session-scanner-types.ts new file mode 100644 index 00000000000..d5a4b2f1acf --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-types.ts @@ -0,0 +1,34 @@ +import type { AiVaultAgent, AiVaultSession } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { IFilesystemProvider } from '../providers/types' +import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' +import type { FileWithMtime } from './session-scanner-types' + +export type RemoteScannerContext = { + provider: IFilesystemProvider + executionHostId: ExecutionHostId + hostPlatform: RemoteHostPlatform + titleCaches: Map>> +} + +export type RemoteParserOptions = { + executionHostId: ExecutionHostId + executionHostPlatform: NodeJS.Platform +} + +export type RemoteSessionSource = { + agent: AiVaultAgent + rootDir: string + extensions: readonly string[] + filePredicate?: (path: string) => boolean + parse: ( + file: FileWithMtime, + content: string, + context: RemoteScannerContext + ) => Promise +} + +export type RemoteSessionCandidate = { + source: RemoteSessionSource + file: FileWithMtime +} diff --git a/src/main/ai-vault/remote-session-scanner.test.ts b/src/main/ai-vault/remote-session-scanner.test.ts new file mode 100644 index 00000000000..16173f7b736 --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner.test.ts @@ -0,0 +1,344 @@ +import { describe, expect, it } from 'vitest' +import type { DirEntry } from '../../shared/types' +import type { FileReadResult, FileStat, IFilesystemProvider } from '../providers/types' +import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { scanRemoteAiVaultSessions } from './remote-session-scanner' + +class MemoryRemoteProvider implements IFilesystemProvider { + private readonly files = new Map() + + addFile(path: string, content: string, mtimeMs: number): void { + this.files.set(normalize(path), { content, mtimeMs }) + } + + async readDir(dirPath: string): Promise { + const dir = normalize(dirPath) + const prefix = dir.endsWith('/') ? dir : `${dir}/` + const entries = new Map() + for (const path of this.files.keys()) { + if (!path.startsWith(prefix)) { + continue + } + const relative = path.slice(prefix.length) + if (!relative) { + continue + } + const [name, ...rest] = relative.split('/') + if (!name) { + continue + } + entries.set(name, { + name, + isDirectory: rest.length > 0, + isSymlink: false + }) + } + return [...entries.values()].sort((left, right) => left.name.localeCompare(right.name)) + } + + async readFile(filePath: string): Promise { + const file = this.files.get(normalize(filePath)) + if (!file) { + throw new Error(`ENOENT: ${filePath}`) + } + return { content: file.content, isBinary: false } + } + + async stat(filePath: string): Promise { + const file = this.files.get(normalize(filePath)) + if (!file) { + throw new Error(`ENOENT: ${filePath}`) + } + return { size: file.content.length, type: 'file', mtime: file.mtimeMs, mtimeMs: file.mtimeMs } + } + + writeFile = unsupported + writeFileBase64 = unsupported + writeFileBase64Chunk = unsupported + deletePath = unsupported + createFile = unsupported + createDir = unsupported + createDirNoClobber = unsupported + rename = unsupported + renameNoClobber = unsupported + copy = unsupported + realpath = async (path: string): Promise => path + search = unsupported + listFiles = unsupported + watch = unsupported +} + +async function unsupported(): Promise { + throw new Error('unsupported') +} + +function normalize(path: string): string { + return path.replace(/\\/g, '/').replace(/\/+$/, '') +} + +function jsonLines(records: unknown[]): string { + return records.map((record) => JSON.stringify(record)).join('\n') +} + +describe('scanRemoteAiVaultSessions', () => { + it('parses remote default and Orca-managed Codex homes with SSH host ids', async () => { + const provider = new MemoryRemoteProvider() + provider.addFile( + '/home/ada/.codex/session_index.jsonl', + jsonLines([{ id: 'default-session', thread_name: 'Indexed remote title' }]), + 1 + ) + provider.addFile( + '/home/ada/.codex/sessions/2026/07/04/default.jsonl', + jsonLines([ + { + timestamp: '2026-07-04T01:00:00.000Z', + type: 'session_meta', + payload: { id: 'default-session', cwd: '/home/ada/repo' } + }, + { + timestamp: '2026-07-04T01:00:01.000Z', + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: 'Fallback default title' }] + } + } + ]), + 10 + ) + provider.addFile( + '/home/ada/.local/share/orca/codex-runtime-home/home/sessions/runtime.jsonl', + jsonLines([ + { + timestamp: '2026-07-04T02:00:00.000Z', + type: 'session_meta', + payload: { id: 'runtime-session', cwd: '/home/ada/runtime-repo' } + }, + { + timestamp: '2026-07-04T02:00:01.000Z', + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: 'Managed remote title' }] + } + } + ]), + 20 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(result.issues).toEqual([]) + expect(result.sessions.map((session) => session.title)).toEqual([ + 'Managed remote title', + 'Indexed remote title' + ]) + expect(new Set(result.sessions.map((session) => session.id)).size).toBe(2) + expect(result.sessions.every((session) => session.executionHostId === 'ssh:dev-box')).toBe(true) + expect(result.sessions.every((session) => session.executionHostPlatform === 'linux')).toBe(true) + expect( + result.sessions.find((session) => session.sessionId === 'default-session') + ).toMatchObject({ + codexHome: '/home/ada/.codex', + resumeCommand: + "cd '/home/ada/repo' && CODEX_HOME='/home/ada/.codex' codex resume 'default-session'" + }) + expect( + result.sessions.find((session) => session.sessionId === 'runtime-session') + ).toMatchObject({ + codexHome: '/home/ada/.local/share/orca/codex-runtime-home/home', + resumeCommand: + "cd '/home/ada/runtime-repo' && CODEX_HOME='/home/ada/.local/share/orca/codex-runtime-home/home' codex resume 'runtime-session'" + }) + }) + + it('parses non-Codex transcripts through the same remote scanner', async () => { + const provider = new MemoryRemoteProvider() + provider.addFile( + '/home/ada/.claude/projects/repo/claude-session.jsonl', + jsonLines([ + { + sessionId: 'claude-session', + timestamp: '2026-07-04T04:00:00.000Z', + type: 'user', + message: { content: [{ type: 'text', text: 'Summarize the remote branch' }] } + }, + { + sessionId: 'claude-session', + timestamp: '2026-07-04T04:00:01.000Z', + type: 'assistant', + message: { model: 'claude-opus-4', content: 'Sure.' } + } + ]), + 40 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + executionHostId: 'ssh:dev-box', + executionHostPlatform: 'linux', + agent: 'claude', + sessionId: 'claude-session', + title: 'Summarize the remote branch', + model: 'claude-opus-4', + filePath: '/home/ada/.claude/projects/repo/claude-session.jsonl' + }) + }) + + it('builds resume commands with the remote host platform', async () => { + const provider = new MemoryRemoteProvider() + provider.addFile( + 'C:/Users/Ada/.codex/sessions/win.jsonl', + jsonLines([ + { + timestamp: '2026-07-04T03:00:00.000Z', + type: 'session_meta', + payload: { id: 'win-session', cwd: 'C:/repo/app' } + }, + { + timestamp: '2026-07-04T03:00:01.000Z', + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: 'Windows remote title' }] + } + } + ]), + 30 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:win-box', + remoteHome: 'C:/Users/Ada', + hostPlatform: getRemoteHostPlatform('win32-x64') + }) + + expect(result.issues).toEqual([]) + expect(result.sessions[0]?.executionHostPlatform).toBe('win32') + expect(result.sessions[0]?.resumeCommand).toBe( + 'cmd /d /s /c "cd /d ""C:/repo/app"" && set ""CODEX_HOME=C:/Users/Ada/.codex"" && codex resume ""win-session"""' + ) + }) + + it('continues past skipped candidates to fill the remote scan limit', async () => { + const provider = new MemoryRemoteProvider() + provider.addFile( + '/home/ada/.codex/sessions/worker.jsonl', + codexTranscript({ + sessionId: 'worker-session', + title: 'Internal worker', + cwd: '/home/ada/repo', + timestamp: '2026-07-04T04:00:00.000Z', + threadSource: 'agent' + }), + 40 + ) + provider.addFile( + '/home/ada/.codex/sessions/user.jsonl', + codexTranscript({ + sessionId: 'user-session', + title: 'Visible user session', + cwd: '/home/ada/repo', + timestamp: '2026-07-04T03:00:00.000Z' + }), + 30 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64'), + limit: 1 + }) + + expect(result.issues).toEqual([]) + expect(result.sessions.map((session) => session.sessionId)).toEqual(['user-session']) + }) + + it('keeps scoped remote sessions even when they are older than the recency cap', async () => { + const provider = new MemoryRemoteProvider() + provider.addFile( + '/home/ada/.codex/sessions/other.jsonl', + codexTranscript({ + sessionId: 'other-session', + title: 'Other workspace', + cwd: '/home/ada/other', + timestamp: '2026-07-04T05:00:00.000Z' + }), + 50 + ) + provider.addFile( + '/home/ada/.codex/sessions/scoped.jsonl', + codexTranscript({ + sessionId: 'scoped-session', + title: 'Scoped workspace', + cwd: '/home/ada/repo/app', + timestamp: '2026-07-04T01:00:00.000Z' + }), + 10 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64'), + limit: 1, + scopePaths: ['/home/ada/repo'] + }) + + expect(result.issues).toEqual([]) + expect(result.sessions.map((session) => session.sessionId)).toEqual([ + 'other-session', + 'scoped-session' + ]) + }) +}) + +function codexTranscript(args: { + sessionId: string + title: string + cwd: string + timestamp: string + threadSource?: string +}): string { + return jsonLines([ + { + timestamp: args.timestamp, + type: 'session_meta', + payload: { + id: args.sessionId, + cwd: args.cwd, + ...(args.threadSource ? { thread_source: args.threadSource } : {}) + } + }, + { + timestamp: args.timestamp.replace(':00.000Z', ':01.000Z'), + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: args.title }] + } + } + ]) +} diff --git a/src/main/ai-vault/remote-session-scanner.ts b/src/main/ai-vault/remote-session-scanner.ts new file mode 100644 index 00000000000..1b7d66cb7b0 --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner.ts @@ -0,0 +1,296 @@ +import { extname } from 'node:path' +import type { + AiVaultAgent, + AiVaultListResult, + AiVaultScanIssue, + AiVaultSession +} from '../../shared/ai-vault-types' +import { isPathInsideOrEqual } from '../../shared/cross-platform-path' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { FileStat, IFilesystemProvider } from '../providers/types' +import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { joinRemotePath } from '../ssh/ssh-remote-platform' +import { sessionSortTime } from './session-scanner-accumulator' +import type { FileWithMtime } from './session-scanner-types' +import { errorMessage } from './session-scanner-values' +import { remoteSessionSources } from './remote-session-scanner-sources' +import type { + RemoteScannerContext, + RemoteSessionCandidate, + RemoteSessionSource +} from './remote-session-scanner-types' + +const DEFAULT_REMOTE_SCAN_LIMIT = 1000 +const REMOTE_SCAN_CONCURRENCY = 8 +const REMOTE_SCOPE_PARSE_LIMIT = 2000 + +export async function scanRemoteAiVaultSessions(args: { + provider: IFilesystemProvider + executionHostId: ExecutionHostId + remoteHome: string + hostPlatform: RemoteHostPlatform + limit?: number + scopePaths?: readonly string[] +}): Promise { + const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DEFAULT_REMOTE_SCAN_LIMIT + const issues: AiVaultScanIssue[] = [] + const context: RemoteScannerContext = { + provider: args.provider, + executionHostId: args.executionHostId, + hostPlatform: args.hostPlatform, + titleCaches: new Map() + } + const candidates = ( + await mapRemoteScanConcurrently( + remoteSessionSources(args.remoteHome, args.hostPlatform), + (source) => discoverRemoteSourceCandidates({ source, context, issues }) + ) + ) + .flat() + .sort((left, right) => right.file.mtimeMs - left.file.mtimeMs) + + const parsed = await parseRemoteSessionCandidates({ candidates, context, issues, limit }) + const cappedSessions = parsed.sessions + .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) + .slice(0, limit) + const scopePaths = normalizeRemoteScopePaths(args.scopePaths ?? []) + const parsedScopeSessions = parsed.sessions.filter((session) => + isRemoteSessionInScope(session, scopePaths) + ) + const extraScopeSessions = await scanRemoteInScopeSessions({ + candidates, + context, + issues, + scopePaths, + alreadyParsedFilePaths: parsed.parsedFilePaths + }) + + return { + sessions: mergeRemoteSessions(cappedSessions, [...parsedScopeSessions, ...extraScopeSessions]), + issues, + scannedAt: new Date().toISOString() + } +} + +async function discoverRemoteSourceCandidates(args: { + source: RemoteSessionSource + context: RemoteScannerContext + issues: AiVaultScanIssue[] +}): Promise { + const paths = await walkRemoteSessionFiles( + args.source, + args.context.provider, + args.context.hostPlatform + ) + const files = await mapRemoteScanConcurrently(paths, (path) => + statRemoteFile( + args.context.provider, + path, + args.source.agent, + args.context.executionHostId, + args.issues + ) + ) + return files + .filter((file): file is FileWithMtime => Boolean(file)) + .map((file) => ({ source: args.source, file })) +} + +async function walkRemoteSessionFiles( + source: RemoteSessionSource, + provider: IFilesystemProvider, + hostPlatform: RemoteHostPlatform, + dirPath = source.rootDir +): Promise { + let entries + try { + entries = await provider.readDir(dirPath) + } catch { + return [] + } + + const extensions = new Set(source.extensions) + const files: string[] = [] + for (const entry of entries) { + const fullPath = joinRemotePath(hostPlatform, dirPath, entry.name) + if (entry.isDirectory && !entry.isSymlink) { + files.push(...(await walkRemoteSessionFiles(source, provider, hostPlatform, fullPath))) + continue + } + if ( + !entry.isSymlink && + extensions.has(extname(entry.name).toLowerCase()) && + (source.filePredicate?.(fullPath) ?? true) + ) { + files.push(fullPath) + } + } + return files +} + +async function parseRemoteSessionCandidates(args: { + candidates: readonly RemoteSessionCandidate[] + context: RemoteScannerContext + issues: AiVaultScanIssue[] + limit: number +}): Promise<{ sessions: AiVaultSession[]; parsedFilePaths: Set }> { + const sessions: AiVaultSession[] = [] + const parsedFilePaths = new Set() + let index = 0 + + while (index < args.candidates.length) { + if (canStopParsingRemoteSessions(sessions, args.limit, args.candidates[index]?.file.mtimeMs)) { + break + } + + const batch = args.candidates.slice(index, index + REMOTE_SCAN_CONCURRENCY) + for (const candidate of batch) { + parsedFilePaths.add(candidate.file.path) + } + const results = await Promise.all( + batch.map((candidate) => parseRemoteSessionCandidate(candidate, args.context, args.issues)) + ) + sessions.push(...results.filter(isAiVaultSession)) + index += batch.length + } + + return { sessions, parsedFilePaths } +} + +async function scanRemoteInScopeSessions(args: { + candidates: readonly RemoteSessionCandidate[] + context: RemoteScannerContext + issues: AiVaultScanIssue[] + scopePaths: readonly string[] + alreadyParsedFilePaths: ReadonlySet +}): Promise { + if (args.scopePaths.length === 0) { + return [] + } + + const candidates = args.candidates + .filter((candidate) => !args.alreadyParsedFilePaths.has(candidate.file.path)) + .slice(0, REMOTE_SCOPE_PARSE_LIMIT) + const sessions: AiVaultSession[] = [] + + for (let index = 0; index < candidates.length; index += REMOTE_SCAN_CONCURRENCY) { + const batch = candidates.slice(index, index + REMOTE_SCAN_CONCURRENCY) + const results = await Promise.all( + batch.map((candidate) => parseRemoteSessionCandidate(candidate, args.context, args.issues)) + ) + sessions.push( + ...results.filter( + (session): session is AiVaultSession => + isAiVaultSession(session) && isRemoteSessionInScope(session, args.scopePaths) + ) + ) + } + + return sessions +} + +async function parseRemoteSessionCandidate( + candidate: RemoteSessionCandidate, + context: RemoteScannerContext, + issues: AiVaultScanIssue[] +): Promise { + try { + const read = await context.provider.readFile(candidate.file.path) + if (read.isBinary) { + return null + } + return candidate.source.parse(candidate.file, read.content, context) + } catch (err) { + issues.push({ + executionHostId: context.executionHostId, + agent: candidate.source.agent, + path: candidate.file.path, + message: errorMessage(err) + }) + return null + } +} + +function mergeRemoteSessions( + cappedSessions: AiVaultSession[], + scopeSessions: AiVaultSession[] +): AiVaultSession[] { + if (scopeSessions.length === 0) { + return cappedSessions + } + const byId = new Map() + for (const session of cappedSessions) { + byId.set(session.id, session) + } + for (const session of scopeSessions) { + byId.set(session.id, session) + } + return [...byId.values()].sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) +} + +function isRemoteSessionInScope(session: AiVaultSession, scopePaths: readonly string[]): boolean { + const cwd = session.cwd + return Boolean(cwd && scopePaths.some((scopePath) => isPathInsideOrEqual(scopePath, cwd))) +} + +function normalizeRemoteScopePaths(scopePaths: readonly string[]): string[] { + return scopePaths.map((scopePath) => scopePath.trim()).filter(Boolean) +} + +async function statRemoteFile( + provider: IFilesystemProvider, + path: string, + agent: AiVaultAgent, + executionHostId: ExecutionHostId, + issues: AiVaultScanIssue[] +): Promise { + try { + const stat = await provider.stat(path) + const mtimeMs = remoteStatMtimeMs(stat) + return { path, mtimeMs, modifiedAt: new Date(mtimeMs).toISOString() } + } catch (err) { + issues.push({ executionHostId, agent, path, message: errorMessage(err) }) + return null + } +} + +function remoteStatMtimeMs(stat: FileStat): number { + if (typeof stat.mtimeMs === 'number' && Number.isFinite(stat.mtimeMs)) { + return stat.mtimeMs + } + return stat.mtime > 10_000_000_000 ? stat.mtime : stat.mtime * 1000 +} + +function canStopParsingRemoteSessions( + sessions: AiVaultSession[], + limit: number, + nextCandidateMtimeMs: number | undefined +): boolean { + if (sessions.length < limit || typeof nextCandidateMtimeMs !== 'number') { + return false + } + const visibleCutoff = sessions + .map(sessionSortTime) + .sort((left, right) => right - left) + .at(limit - 1) + + // Transcript mtimes bound the remaining candidate order; once the visible + // cutoff is newer, older files cannot enter the unscoped top-N result. + return typeof visibleCutoff === 'number' && nextCandidateMtimeMs < visibleCutoff +} + +function isAiVaultSession(session: AiVaultSession | null): session is AiVaultSession { + return Boolean(session) +} + +async function mapRemoteScanConcurrently( + items: readonly T[], + mapper: (item: T) => Promise +): Promise { + const results: U[] = [] + for (let index = 0; index < items.length; index += REMOTE_SCAN_CONCURRENCY) { + const batch = items.slice(index, index + REMOTE_SCAN_CONCURRENCY) + results.push(...(await Promise.all(batch.map(mapper)))) + } + return results +} diff --git a/src/main/ai-vault/session-scanner-accumulator.ts b/src/main/ai-vault/session-scanner-accumulator.ts index a6e8cc293dd..85b33a962d6 100644 --- a/src/main/ai-vault/session-scanner-accumulator.ts +++ b/src/main/ai-vault/session-scanner-accumulator.ts @@ -6,6 +6,7 @@ import { type AiVaultSession, type AiVaultSessionPreviewMessage } from '../../shared/ai-vault-types' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' import { extractPreviewContentText, @@ -43,7 +44,11 @@ export function createAccumulator(args: { export function finalizeSession( accumulator: SessionAccumulator, platform: NodeJS.Platform, - options: { codexHome?: string | null } = {} + options: { + codexHome?: string | null + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null + } = {} ): AiVaultSession | null { const sessionId = accumulator.sessionId.trim() if (!sessionId) { @@ -54,8 +59,14 @@ export function finalizeSession( accumulator.fallbackTitle || `${aiVaultAgentLabel(accumulator.agent)} ${sessionId.slice(0, 8)}` + const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID + return { - id: `${accumulator.agent}:${sessionId}:${accumulator.filePath}`, + id: `${executionHostId}:${accumulator.agent}:${sessionId}:${accumulator.filePath}`, + executionHostId, + ...(options.executionHostPlatform + ? { executionHostPlatform: options.executionHostPlatform } + : {}), agent: accumulator.agent, sessionId, title, diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts index adfb2aa86db..1114c1b8bb2 100644 --- a/src/main/ai-vault/session-scanner-agent-parser.ts +++ b/src/main/ai-vault/session-scanner-agent-parser.ts @@ -1,11 +1,8 @@ import type { AiVaultSession } from '../../shared/ai-vault-types' import { parseDevinSessionFile } from './session-scanner-devin-parser' +import { parseDroidSessionFile } from './session-scanner-droid-parser' import { parseGrokSessionFile } from './session-scanner-grok-parser' -import { - parseDroidSessionFile, - parseMessageGraphSessionFile, - parseRovoSessionFile -} from './session-scanner-graph-parsers' +import { parseMessageGraphSessionFile, parseRovoSessionFile } from './session-scanner-graph-parsers' import { parseKimiSessionFile } from './session-scanner-kimi-parser' import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths' import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite' diff --git a/src/main/ai-vault/session-scanner-codex-parser.ts b/src/main/ai-vault/session-scanner-codex-parser.ts index a42e0af7962..b18721a8296 100644 --- a/src/main/ai-vault/session-scanner-codex-parser.ts +++ b/src/main/ai-vault/session-scanner-codex-parser.ts @@ -3,6 +3,7 @@ import { stat } from 'node:fs/promises' import { basename, dirname, join } from 'node:path' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' import { addPreviewContent, createAccumulator, @@ -35,21 +36,61 @@ const codexSessionIndexTitleCache = new Map { - const accumulator = createAccumulator({ - agent: 'codex', - file, - sessionId: sessionIdFromFileName(file.path) - }) - let previousTotals: CodexUsageSnapshot | null = null - const lines = createInterface({ input: createReadStream(file.path, { encoding: 'utf-8' }), crlfDelay: Infinity }) - for await (const line of lines) { + return parseCodexSessionLines({ + file, + lines, + platform, + codexHome, + executionHostId, + titleReader: (sessionId) => readCodexSessionIndexTitle(file.path, codexHome, sessionId) + }) +} + +export async function parseCodexSessionContent(args: { + file: FileWithMtime + content: string + platform?: NodeJS.Platform + codexHome?: string | null + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null + readIndexedTitle?: (sessionId: string) => Promise +}): Promise { + return parseCodexSessionLines({ + file: args.file, + lines: args.content.split(/\r?\n/), + platform: args.platform ?? process.platform, + codexHome: args.codexHome ?? null, + executionHostId: args.executionHostId, + executionHostPlatform: args.executionHostPlatform, + titleReader: args.readIndexedTitle + }) +} + +async function parseCodexSessionLines(args: { + file: FileWithMtime + lines: AsyncIterable | Iterable + platform: NodeJS.Platform + codexHome: string | null + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null + titleReader?: (sessionId: string) => Promise +}): Promise { + const accumulator = createAccumulator({ + agent: 'codex', + file: args.file, + sessionId: sessionIdFromFileName(args.file.path) + }) + let previousTotals: CodexUsageSnapshot | null = null + + for await (const line of args.lines) { const record = parseJsonObject(line) if (!record) { continue @@ -70,7 +111,7 @@ export async function parseCodexSessionFile( } const indexedTitle = extractCodexSessionMetadataTitle(payload) ?? - (await readCodexSessionIndexTitle(file.path, codexHome, accumulator.sessionId)) + (await args.titleReader?.(accumulator.sessionId)) if (indexedTitle) { accumulator.title = indexedTitle } @@ -158,7 +199,11 @@ export async function parseCodexSessionFile( } } - return finalizeSession(accumulator, platform, { codexHome }) + return finalizeSession(accumulator, args.platform, { + codexHome: args.codexHome, + executionHostId: args.executionHostId, + executionHostPlatform: args.executionHostPlatform + }) } function addCodexUsage( diff --git a/src/main/ai-vault/session-scanner-devin-parser.ts b/src/main/ai-vault/session-scanner-devin-parser.ts index 4750dd788ef..4ad6fa45f78 100644 --- a/src/main/ai-vault/session-scanner-devin-parser.ts +++ b/src/main/ai-vault/session-scanner-devin-parser.ts @@ -1,5 +1,6 @@ import { readFile } from 'node:fs/promises' import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' import type { FileWithMtime } from './session-scanner-types' import { addPreviewContent, @@ -17,11 +18,25 @@ import { numberValue } from './session-scanner-values' +type ParserSessionOptions = { + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null +} + export async function parseDevinSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown) + return parseDevinSessionContent(file, await readFile(file.path, 'utf-8'), platform) +} + +export function parseDevinSessionContent( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform = process.platform, + options: ParserSessionOptions = {} +): AiVaultSession | null { + const record = asRecord(JSON.parse(content) as unknown) if (!record) { return null } @@ -69,7 +84,7 @@ export async function parseDevinSessionFile( ) } } - return finalizeSession(accumulator, platform) + return finalizeSession(accumulator, platform, options) } function extractDevinStepText(step: Record): string | null { diff --git a/src/main/ai-vault/session-scanner-droid-parser.ts b/src/main/ai-vault/session-scanner-droid-parser.ts new file mode 100644 index 00000000000..d99795bbbd3 --- /dev/null +++ b/src/main/ai-vault/session-scanner-droid-parser.ts @@ -0,0 +1,120 @@ +import { createReadStream } from 'node:fs' +import { createInterface } from 'node:readline' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' +import { + addPreviewMessage, + createAccumulator, + finalizeSession, + sessionIdFromFileName, + updateTimeline +} from './session-scanner-accumulator' +import { + asRecord, + extractMessageText, + extractPreviewContentText, + extractString, + normalizeTitleText, + parseJsonObject, + tokenTotal +} from './session-scanner-values' + +type ParserSessionOptions = { + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null +} + +export async function parseDroidSessionFile( + file: FileWithMtime, + platform: NodeJS.Platform = process.platform +): Promise { + const lines = createInterface({ + input: createReadStream(file.path, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) + return parseDroidSessionLines({ file, lines, platform }) +} + +export async function parseDroidSessionContent( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform = process.platform, + options: ParserSessionOptions = {} +): Promise { + return parseDroidSessionLines({ + file, + lines: content.split(/\r?\n/), + platform, + options + }) +} + +async function parseDroidSessionLines(args: { + file: FileWithMtime + lines: AsyncIterable | Iterable + platform: NodeJS.Platform + options?: ParserSessionOptions +}): Promise { + const accumulator = createAccumulator({ + agent: 'droid', + file: args.file, + sessionId: sessionIdFromFileName(args.file.path) + }) + + for await (const line of args.lines) { + const record = parseJsonObject(line) + if (!record) { + continue + } + updateTimeline(accumulator, record.timestamp) + if (record.type === 'session_start') { + accumulator.sessionId = extractString(record.id) ?? accumulator.sessionId + accumulator.title = normalizeTitleText(extractString(record.title) ?? '') + accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd + continue + } + if (record.type === 'system') { + accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd + accumulator.model = extractString(record.model) ?? accumulator.model + } + const streamSessionId = extractString(record.session_id) ?? extractString(record.sessionId) + if (streamSessionId) { + accumulator.sessionId = streamSessionId + } + if (record.type === 'message') { + consumeDroidMessage(accumulator, record) + } else if (record.type === 'completion') { + accumulator.messageCount++ + accumulator.totalTokens += tokenTotal(record.usage) + addPreviewMessage(accumulator, { + role: 'assistant', + text: extractString(record.finalText), + timestamp: record.timestamp + }) + } + } + return finalizeSession(accumulator, args.platform, args.options) +} + +function consumeDroidMessage( + accumulator: SessionAccumulator, + record: Record +): void { + const role = extractString(record.role) ?? extractString(asRecord(record.message)?.role) + if (role !== 'user' && role !== 'assistant') { + return + } + accumulator.messageCount++ + if (role === 'user') { + accumulator.title ??= + normalizeTitleText(extractString(record.text) ?? '') || + extractMessageText(asRecord(record.message)) + } + addPreviewMessage(accumulator, { + role, + text: + extractString(record.text) ?? extractPreviewContentText(asRecord(record.message)?.content), + timestamp: record.timestamp + }) +} diff --git a/src/main/ai-vault/session-scanner-graph-parsers.ts b/src/main/ai-vault/session-scanner-graph-parsers.ts index 3013925a548..a52c83b3213 100644 --- a/src/main/ai-vault/session-scanner-graph-parsers.ts +++ b/src/main/ai-vault/session-scanner-graph-parsers.ts @@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises' import { basename, dirname, join } from 'node:path' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' import { addPreviewContent, @@ -17,15 +18,18 @@ import { asRecord, extractContentText, extractMessageText, - extractPreviewContentText, extractString, firstString, - normalizeTitleText, parseJsonObject, readJsonObjectIfExists, tokenTotal } from './session-scanner-values' +type ParserSessionOptions = { + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null +} + export async function parseRovoSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform @@ -159,17 +163,43 @@ export async function parseMessageGraphSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const accumulator = createAccumulator({ - agent, - file, - sessionId: sessionIdFromFileName(file.path) - }) const lines = createInterface({ input: createReadStream(file.path, { encoding: 'utf-8' }), crlfDelay: Infinity }) + return parseMessageGraphSessionLines({ agent, file, lines, platform }) +} - for await (const line of lines) { +export async function parseMessageGraphSessionContent( + agent: 'openclaw' | 'pi', + file: FileWithMtime, + content: string, + platform: NodeJS.Platform = process.platform, + options: ParserSessionOptions = {} +): Promise { + return parseMessageGraphSessionLines({ + agent, + file, + lines: content.split(/\r?\n/), + platform, + options + }) +} + +async function parseMessageGraphSessionLines(args: { + agent: 'openclaw' | 'pi' + file: FileWithMtime + lines: AsyncIterable | Iterable + platform: NodeJS.Platform + options?: ParserSessionOptions +}): Promise { + const accumulator = createAccumulator({ + agent: args.agent, + file: args.file, + sessionId: sessionIdFromFileName(args.file.path) + }) + + for await (const line of args.lines) { const record = parseJsonObject(line) if (!record) { continue @@ -204,69 +234,5 @@ export async function parseMessageGraphSessionFile( } } - return finalizeSession(accumulator, platform) -} - -export async function parseDroidSessionFile( - file: FileWithMtime, - platform: NodeJS.Platform = process.platform -): Promise { - const accumulator = createAccumulator({ - agent: 'droid', - file, - sessionId: sessionIdFromFileName(file.path) - }) - const lines = createInterface({ - input: createReadStream(file.path, { encoding: 'utf-8' }), - crlfDelay: Infinity - }) - - for await (const line of lines) { - const record = parseJsonObject(line) - if (!record) { - continue - } - updateTimeline(accumulator, record.timestamp) - if (record.type === 'session_start') { - accumulator.sessionId = extractString(record.id) ?? accumulator.sessionId - accumulator.title = normalizeTitleText(extractString(record.title) ?? '') - accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd - continue - } - if (record.type === 'system') { - accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd - accumulator.model = extractString(record.model) ?? accumulator.model - } - const streamSessionId = extractString(record.session_id) ?? extractString(record.sessionId) - if (streamSessionId) { - accumulator.sessionId = streamSessionId - } - if (record.type === 'message') { - const role = extractString(record.role) ?? extractString(asRecord(record.message)?.role) - if (role === 'user' || role === 'assistant') { - accumulator.messageCount++ - if (role === 'user') { - accumulator.title ??= - normalizeTitleText(extractString(record.text) ?? '') || - extractMessageText(asRecord(record.message)) - } - addPreviewMessage(accumulator, { - role, - text: - extractString(record.text) ?? - extractPreviewContentText(asRecord(record.message)?.content), - timestamp: record.timestamp - }) - } - } else if (record.type === 'completion') { - accumulator.messageCount++ - accumulator.totalTokens += tokenTotal(record.usage) - addPreviewMessage(accumulator, { - role: 'assistant', - text: extractString(record.finalText), - timestamp: record.timestamp - }) - } - } - return finalizeSession(accumulator, platform) + return finalizeSession(accumulator, args.platform, args.options) } diff --git a/src/main/ai-vault/session-scanner-primary-parsers.ts b/src/main/ai-vault/session-scanner-primary-parsers.ts index 363bfe636a8..28a37602a6c 100644 --- a/src/main/ai-vault/session-scanner-primary-parsers.ts +++ b/src/main/ai-vault/session-scanner-primary-parsers.ts @@ -2,6 +2,7 @@ import { createReadStream } from 'node:fs' import { readFile } from 'node:fs/promises' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' import { addPreviewContent, @@ -23,24 +24,51 @@ import { tokenTotal } from './session-scanner-values' +type ParserSessionOptions = { + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null +} + export async function parseClaudeSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const accumulator = createAccumulator({ - agent: 'claude', - file, - sessionId: sessionIdFromFileName(file.path) - }) - let metaTitle: string | null = null - let generatedTitle: string | null = null - const lines = createInterface({ input: createReadStream(file.path, { encoding: 'utf-8' }), crlfDelay: Infinity }) + return parseClaudeSessionLines({ file, lines, platform }) +} - for await (const line of lines) { +export async function parseClaudeSessionContent( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform = process.platform, + options: ParserSessionOptions = {} +): Promise { + return parseClaudeSessionLines({ + file, + lines: content.split(/\r?\n/), + platform, + options + }) +} + +async function parseClaudeSessionLines(args: { + file: FileWithMtime + lines: AsyncIterable | Iterable + platform: NodeJS.Platform + options?: ParserSessionOptions +}): Promise { + const accumulator = createAccumulator({ + agent: 'claude', + file: args.file, + sessionId: sessionIdFromFileName(args.file.path) + }) + let metaTitle: string | null = null + let generatedTitle: string | null = null + + for await (const line of args.lines) { const record = parseJsonObject(line) if (!record) { continue @@ -92,7 +120,7 @@ export async function parseClaudeSessionFile( } accumulator.fallbackTitle = generatedTitle ?? metaTitle - return finalizeSession(accumulator, platform) + return finalizeSession(accumulator, args.platform, args.options) } export async function parseGeminiSessionFile( @@ -103,7 +131,33 @@ export async function parseGeminiSessionFile( return parseGeminiJsonlSessionFile(file, platform) } - const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown) + return parseGeminiJsonSessionContent(file, await readFile(file.path, 'utf-8'), platform) +} + +export async function parseGeminiSessionContent( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform = process.platform, + options: ParserSessionOptions = {} +): Promise { + if (file.path.endsWith('.jsonl')) { + return parseGeminiJsonlSessionLines({ + file, + lines: content.split(/\r?\n/), + platform, + options + }) + } + return parseGeminiJsonSessionContent(file, content, platform, options) +} + +function parseGeminiJsonSessionContent( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform, + options: ParserSessionOptions = {} +): AiVaultSession | null { + const record = asRecord(JSON.parse(content) as unknown) if (!record) { return null } @@ -117,24 +171,33 @@ export async function parseGeminiSessionFile( for (const message of arrayValue(record.messages)) { consumeGeminiMessage(accumulator, asRecord(message)) } - return finalizeSession(accumulator, platform) + return finalizeSession(accumulator, platform, options) } export async function parseGeminiJsonlSessionFile( file: FileWithMtime, platform: NodeJS.Platform ): Promise { - const accumulator = createAccumulator({ - agent: 'gemini', - file, - sessionId: sessionIdFromFileName(file.path) - }) const lines = createInterface({ input: createReadStream(file.path, { encoding: 'utf-8' }), crlfDelay: Infinity }) + return parseGeminiJsonlSessionLines({ file, lines, platform }) +} - for await (const line of lines) { +async function parseGeminiJsonlSessionLines(args: { + file: FileWithMtime + lines: AsyncIterable | Iterable + platform: NodeJS.Platform + options?: ParserSessionOptions +}): Promise { + const accumulator = createAccumulator({ + agent: 'gemini', + file: args.file, + sessionId: sessionIdFromFileName(args.file.path) + }) + + for await (const line of args.lines) { const record = parseJsonObject(line) if (!record) { continue @@ -153,7 +216,7 @@ export async function parseGeminiJsonlSessionFile( consumeGeminiMessage(accumulator, record) } - return finalizeSession(accumulator, platform) + return finalizeSession(accumulator, args.platform, args.options) } export function consumeGeminiMessage( diff --git a/src/main/ai-vault/session-scanner-secondary-parsers.ts b/src/main/ai-vault/session-scanner-secondary-parsers.ts index edc03cf5815..1edcf6292f0 100644 --- a/src/main/ai-vault/session-scanner-secondary-parsers.ts +++ b/src/main/ai-vault/session-scanner-secondary-parsers.ts @@ -3,6 +3,7 @@ import { readFile, readdir } from 'node:fs/promises' import { join } from 'node:path' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' import { addPreviewContent, @@ -29,21 +30,49 @@ import { tokenTotal } from './session-scanner-values' +type ParserSessionOptions = { + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null +} + export async function parseCopilotSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const accumulator = createAccumulator({ - agent: 'copilot', - file, - sessionId: sessionIdFromFileName(file.path) - }) const lines = createInterface({ input: createReadStream(file.path, { encoding: 'utf-8' }), crlfDelay: Infinity }) + return parseCopilotSessionLines({ file, lines, platform }) +} - for await (const line of lines) { +export async function parseCopilotSessionContent( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform = process.platform, + options: ParserSessionOptions = {} +): Promise { + return parseCopilotSessionLines({ + file, + lines: content.split(/\r?\n/), + platform, + options + }) +} + +async function parseCopilotSessionLines(args: { + file: FileWithMtime + lines: AsyncIterable | Iterable + platform: NodeJS.Platform + options?: ParserSessionOptions +}): Promise { + const accumulator = createAccumulator({ + agent: 'copilot', + file: args.file, + sessionId: sessionIdFromFileName(args.file.path) + }) + + for await (const line of args.lines) { const record = parseJsonObject(line) if (!record) { continue @@ -94,24 +123,47 @@ export async function parseCopilotSessionFile( } } - return finalizeSession(accumulator, platform) + return finalizeSession(accumulator, args.platform, args.options) } export async function parseCursorSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const accumulator = createAccumulator({ - agent: 'cursor', - file, - sessionId: sessionIdFromFileName(file.path) - }) const lines = createInterface({ input: createReadStream(file.path, { encoding: 'utf-8' }), crlfDelay: Infinity }) + return parseCursorSessionLines({ file, lines, platform }) +} - for await (const line of lines) { +export async function parseCursorSessionContent( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform = process.platform, + options: ParserSessionOptions = {} +): Promise { + return parseCursorSessionLines({ + file, + lines: content.split(/\r?\n/), + platform, + options + }) +} + +async function parseCursorSessionLines(args: { + file: FileWithMtime + lines: AsyncIterable | Iterable + platform: NodeJS.Platform + options?: ParserSessionOptions +}): Promise { + const accumulator = createAccumulator({ + agent: 'cursor', + file: args.file, + sessionId: sessionIdFromFileName(args.file.path) + }) + + for await (const line of args.lines) { const record = parseJsonObject(line) if (!record) { continue @@ -132,7 +184,7 @@ export async function parseCursorSessionFile( ) } } - return finalizeSession(accumulator, platform) + return finalizeSession(accumulator, args.platform, args.options) } export async function parseOpenCodeSessionFile( @@ -207,7 +259,16 @@ export async function parseHermesSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown) + return parseHermesSessionContent(file, await readFile(file.path, 'utf-8'), platform) +} + +export async function parseHermesSessionContent( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform = process.platform, + options: ParserSessionOptions = {} +): Promise { + const record = asRecord(JSON.parse(content) as unknown) if (!record) { return null } @@ -234,5 +295,5 @@ export async function parseHermesSessionFile( if (accumulator.messageCount === 0) { accumulator.messageCount = numberValue(record.message_count) } - return finalizeSession(accumulator, platform) + return finalizeSession(accumulator, platform, options) } diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index b6c6ae96412..5d2208b7b60 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -4,6 +4,7 @@ import type { AiVaultSession, AiVaultSessionPreviewMessage } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' export type AiVaultScanOptions = { claudeProjectsDir?: string @@ -33,6 +34,7 @@ export type AiVaultScanOptions = { // the recency cap (see discoverInScopeClaudeFiles). scopePaths?: readonly string[] platform?: NodeJS.Platform + executionHostId?: ExecutionHostId } export type FileWithMtime = { diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index 181bf1466ea..59f92dafc7a 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -3,6 +3,7 @@ import type { AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' import { sessionSortTime } from './session-scanner-accumulator' import { parseAgentSessionFile } from './session-scanner-agent-parser' import { codexHomeForSessionsDir } from './session-scanner-codex-paths' @@ -41,6 +42,7 @@ export async function scanAiVaultSessions( const limit = clampPositiveInteger(options.limit, DEFAULT_LIMIT) const limitPerAgent = clampPositiveInteger(options.limitPerAgent, DEFAULT_SCAN_LIMIT_PER_AGENT) const platform = options.platform ?? process.platform + const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID const issues: AiVaultScanIssue[] = [] const discoveries = await discoverAiVaultSessionSources({ options, limitPerAgent, issues }) @@ -63,6 +65,7 @@ export async function scanAiVaultSessions( candidates, limit, platform, + executionHostId, issues }) @@ -75,12 +78,13 @@ export async function scanAiVaultSessions( scopePaths: options.scopePaths ?? [], alreadyParsedFilePaths: new Set(cappedSessions.map((session) => session.filePath)), platform, + executionHostId, issues }) return { sessions: mergeSessions(cappedSessions, scopeSessions), - issues, + issues: issues.map((issue) => ({ executionHostId, ...issue })), scannedAt: new Date().toISOString() } } @@ -110,6 +114,7 @@ async function scanInScopeSessions(args: { scopePaths: readonly string[] alreadyParsedFilePaths: ReadonlySet platform: NodeJS.Platform + executionHostId: ExecutionHostId issues: AiVaultScanIssue[] }): Promise { if (args.scopePaths.length === 0) { @@ -136,6 +141,7 @@ async function scanInScopeSessions(args: { candidates, limit: candidates.length, platform: args.platform, + executionHostId: args.executionHostId, issues: args.issues }) } @@ -144,6 +150,7 @@ async function parseSessionCandidates(args: { candidates: SessionFileCandidate[] limit: number platform: NodeJS.Platform + executionHostId: ExecutionHostId issues: AiVaultScanIssue[] }): Promise { const sessions: AiVaultSession[] = [] @@ -159,7 +166,9 @@ async function parseSessionCandidates(args: { const batchSize = Math.min(SESSION_PARSE_CONCURRENCY, needed, remaining) const batch = args.candidates.slice(index, index + batchSize) const results = await Promise.all( - batch.map((candidate) => parseSessionCandidate(candidate, args.platform)) + batch.map((candidate) => + parseSessionCandidate(candidate, args.platform, args.executionHostId) + ) ) for (const result of results) { @@ -179,15 +188,20 @@ async function parseSessionCandidates(args: { async function parseSessionCandidate( candidate: SessionFileCandidate, - platform: NodeJS.Platform + platform: NodeJS.Platform, + executionHostId: ExecutionHostId ): Promise { try { const session = await parseAgentSessionFile(candidate, platform) - return { session, issue: null } + return { + session: session ? withSessionExecutionHost(session, executionHostId) : null, + issue: null + } } catch (err) { return { session: null, issue: { + executionHostId, agent: candidate.agent, path: candidate.file.path, message: errorMessage(err) @@ -196,6 +210,20 @@ async function parseSessionCandidate( } } +function withSessionExecutionHost( + session: AiVaultSession, + executionHostId: ExecutionHostId +): AiVaultSession { + if (session.executionHostId === executionHostId) { + return session + } + return { + ...session, + executionHostId, + id: `${executionHostId}:${session.agent}:${session.sessionId}:${session.filePath}` + } +} + function canStopParsingSessions( sessions: AiVaultSession[], limit: number, diff --git a/src/main/ipc/ai-vault.test.ts b/src/main/ipc/ai-vault.test.ts new file mode 100644 index 00000000000..9cc8060eee2 --- /dev/null +++ b/src/main/ipc/ai-vault.test.ts @@ -0,0 +1,163 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types' +import type { IFilesystemProvider } from '../providers/types' +import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' + +const mocks = vi.hoisted(() => ({ + scanAiVaultSessions: vi.fn(), + scanRemoteAiVaultSessions: vi.fn(), + getAiVaultWslHomeDirs: vi.fn(), + getSshFilesystemProvider: vi.fn(), + getActiveSshAiVaultHostInfo: vi.fn(), + getActiveSshAiVaultHostInfos: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { on: vi.fn() }, + ipcMain: { handle: vi.fn() } +})) + +vi.mock('../ai-vault/session-scanner', () => ({ + scanAiVaultSessions: mocks.scanAiVaultSessions +})) + +vi.mock('../ai-vault/remote-session-scanner', () => ({ + scanRemoteAiVaultSessions: mocks.scanRemoteAiVaultSessions +})) + +vi.mock('../wsl', () => ({ + getWslHomeAsync: mocks.getAiVaultWslHomeDirs, + listWslDistrosAsync: vi.fn().mockResolvedValue([]) +})) + +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE: + 'Remote connection dropped. Click Reconnect on the SSH target before retrying.', + getSshFilesystemProvider: mocks.getSshFilesystemProvider +})) + +vi.mock('./ssh', () => ({ + getActiveSshAiVaultHostInfo: mocks.getActiveSshAiVaultHostInfo, + getActiveSshAiVaultHostInfos: mocks.getActiveSshAiVaultHostInfos +})) + +const { _internals } = await import('./ai-vault') + +const provider = {} as IFilesystemProvider + +beforeEach(() => { + vi.clearAllMocks() + _internals.resetAiVaultCacheForTests() + mocks.scanAiVaultSessions.mockResolvedValue(result([session('local', 'local-session')])) + mocks.scanRemoteAiVaultSessions.mockResolvedValue( + result([session('ssh:dev-box', 'remote-session')]) + ) + mocks.getSshFilesystemProvider.mockReturnValue(provider) + mocks.getActiveSshAiVaultHostInfo.mockReturnValue(hostInfo('dev-box')) + mocks.getActiveSshAiVaultHostInfos.mockReturnValue([hostInfo('dev-box')]) +}) + +describe('listAiVaultSessions host routing', () => { + it('routes local scope to the local scanner', async () => { + await _internals.listAiVaultSessions({ executionHostScope: 'local', scopePaths: ['/repo'] }) + + expect(mocks.scanAiVaultSessions).toHaveBeenCalledWith( + expect.objectContaining({ + scopePaths: ['/repo'], + executionHostId: 'local' + }) + ) + expect(mocks.scanRemoteAiVaultSessions).not.toHaveBeenCalled() + }) + + it('routes SSH scope to only that SSH target', async () => { + await _internals.listAiVaultSessions({ + executionHostScope: 'ssh:dev-box', + scopePaths: ['/home/ada/repo'] + }) + + expect(mocks.scanAiVaultSessions).not.toHaveBeenCalled() + expect(mocks.getActiveSshAiVaultHostInfo).toHaveBeenCalledWith('dev-box') + expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledWith( + expect.objectContaining({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + scopePaths: ['/home/ada/repo'] + }) + ) + }) + + it('merges local plus connected SSH targets for all hosts', async () => { + const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' }) + + expect(mocks.scanAiVaultSessions).toHaveBeenCalledTimes(1) + expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) + expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['ssh:dev-box', 'local']) + }) + + it('returns a scan issue for a disconnected SSH target', async () => { + mocks.getActiveSshAiVaultHostInfo.mockReturnValue(null) + mocks.getSshFilesystemProvider.mockReturnValue(undefined) + + const result = await _internals.listAiVaultSessions({ + executionHostScope: 'ssh:disconnected' + }) + + expect(result.sessions).toEqual([]) + expect(result.issues).toMatchObject([ + { + executionHostId: 'ssh:disconnected', + agent: 'codex', + path: 'disconnected' + } + ]) + }) + + it('keeps host scope in the cache key', async () => { + await _internals.listAiVaultSessions({ executionHostScope: 'local' }) + await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) + + expect(mocks.scanAiVaultSessions).toHaveBeenCalledTimes(1) + expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) + }) +}) + +function hostInfo(targetId: string) { + return { + targetId, + executionHostId: `ssh:${targetId}` as const, + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + } +} + +function result(sessions: AiVaultSession[]): AiVaultListResult { + return { sessions, issues: [], scannedAt: new Date().toISOString() } +} + +function session( + executionHostId: AiVaultSession['executionHostId'], + sessionId: string +): AiVaultSession { + return { + id: `${executionHostId}:codex:${sessionId}:/tmp/${sessionId}.jsonl`, + executionHostId, + agent: 'codex', + sessionId, + title: sessionId, + cwd: '/repo', + branch: null, + model: null, + filePath: `/tmp/${sessionId}.jsonl`, + codexHome: null, + createdAt: null, + updatedAt: + sessionId === 'remote-session' ? '2026-07-04T02:00:00.000Z' : '2026-07-04T01:00:00.000Z', + modifiedAt: '2026-07-04T00:00:00.000Z', + messageCount: 1, + totalTokens: 0, + previewMessages: [], + resumeCommand: `codex resume ${sessionId}` + } +} diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index f96aa0b6d61..4551379afde 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -1,8 +1,26 @@ import { app, ipcMain } from 'electron' import { join } from 'node:path' +import { scanRemoteAiVaultSessions } from '../ai-vault/remote-session-scanner' import { scanAiVaultSessions } from '../ai-vault/session-scanner' +import { sessionSortTime } from '../ai-vault/session-scanner-accumulator' import { getWslHomeAsync, listWslDistrosAsync } from '../wsl' -import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types' +import type { + AiVaultListArgs, + AiVaultListResult, + AiVaultScanIssue +} from '../../shared/ai-vault-types' +import { + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostScope, + parseExecutionHostId, + toSshExecutionHostId, + type ExecutionHostScope +} from '../../shared/execution-host' +import { + getSshFilesystemProvider, + SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE +} from '../providers/ssh-filesystem-dispatch' +import { getActiveSshAiVaultHostInfo, getActiveSshAiVaultHostInfos } from './ssh' const AI_VAULT_CACHE_TTL_MS = 15_000 @@ -22,10 +40,14 @@ let inflightKey: string | null = null let handlerOptions: AiVaultHandlerOptions = {} async function listAiVaultSessions(args?: AiVaultListArgs): Promise { + const executionHostScope = normalizeExecutionHostScope( + args?.executionHostScope ?? LOCAL_EXECUTION_HOST_ID + ) // Scope paths change the result set, so they must be part of the cache key. const key = JSON.stringify({ limit: args?.limit ?? 'default', - scopePaths: args?.scopePaths ?? [] + scopePaths: args?.scopePaths ?? [], + executionHostScope }) const now = Date.now() // Why: opening this panel repeatedly should not re-parse hundreds of JSONL @@ -38,16 +60,7 @@ async function listAiVaultSessions(args?: AiVaultListArgs): Promise join(homePath, 'sessions')) ?? - [] - inflightList = (async () => - scanAiVaultSessions({ - limit: args?.limit, - scopePaths: args?.scopePaths, - additionalCodexSessionsDirs, - wslHomeDirs: await getAiVaultWslHomeDirs() - }))() + inflightList = scanAiVaultSessionsByHostScope(args, executionHostScope) .then((result) => { cachedList = { key, @@ -57,12 +70,133 @@ async function listAiVaultSessions(args?: AiVaultListArgs): Promise { - inflightKey = null - inflightList = null + // Only clear tracking if it still refers to this request: a concurrent + // different-scope scan may have replaced it and must stay dedupable. + if (inflightKey === key) { + inflightKey = null + inflightList = null + } }) return inflightList } +async function scanAiVaultSessionsByHostScope( + args: AiVaultListArgs | undefined, + executionHostScope: ExecutionHostScope +): Promise { + if (executionHostScope === LOCAL_EXECUTION_HOST_ID) { + return scanLocalAiVaultSessions(args) + } + + if (executionHostScope === 'all') { + return mergeAiVaultListResults( + await Promise.all([ + scanLocalAiVaultSessions(args), + ...getActiveSshAiVaultHostInfos().map((hostInfo) => + scanSshAiVaultSessions(hostInfo.targetId, args) + ) + ]), + args?.limit + ) + } + + const parsed = parseExecutionHostId(executionHostScope) + if (parsed?.kind === 'ssh') { + return scanSshAiVaultSessions(parsed.targetId, args) + } + + return { + sessions: [], + issues: [ + { + executionHostId: executionHostScope, + agent: 'codex', + path: executionHostScope, + message: 'Agent Session History is not available for this execution host.' + } + ], + scannedAt: new Date().toISOString() + } +} + +async function scanLocalAiVaultSessions(args?: AiVaultListArgs): Promise { + const additionalCodexSessionsDirs = + handlerOptions.getAdditionalCodexHomePaths?.().map((homePath) => join(homePath, 'sessions')) ?? + [] + return scanAiVaultSessions({ + limit: args?.limit, + scopePaths: args?.scopePaths, + additionalCodexSessionsDirs, + wslHomeDirs: await getAiVaultWslHomeDirs(), + executionHostId: LOCAL_EXECUTION_HOST_ID + }) +} + +async function scanSshAiVaultSessions( + targetId: string, + args?: AiVaultListArgs +): Promise { + const executionHostId = toSshExecutionHostId(targetId) + const hostInfo = getActiveSshAiVaultHostInfo(targetId) + const provider = getSshFilesystemProvider(targetId) + if (!hostInfo || !provider) { + return sshScanIssueResult({ + executionHostId, + targetId, + message: SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE + }) + } + return scanRemoteAiVaultSessions({ + provider, + executionHostId: hostInfo.executionHostId, + remoteHome: hostInfo.remoteHome, + hostPlatform: hostInfo.hostPlatform, + limit: args?.limit, + scopePaths: args?.scopePaths + }) +} + +function sshScanIssueResult(args: { + executionHostId: `ssh:${string}` + targetId: string + message: string +}): AiVaultListResult { + return { + sessions: [], + issues: [ + { + executionHostId: args.executionHostId, + agent: 'codex', + path: args.targetId, + message: args.message + } + ], + scannedAt: new Date().toISOString() + } +} + +function mergeAiVaultListResults( + results: readonly AiVaultListResult[], + rawLimit: number | undefined +): AiVaultListResult { + const limit = rawLimit && rawLimit > 0 ? Math.floor(rawLimit) : 1000 + const byId = new Map() + const issues: AiVaultScanIssue[] = [] + for (const result of results) { + for (const session of result.sessions) { + byId.set(session.id, session) + } + issues.push(...result.issues) + } + return { + sessions: [...byId.values()] + .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) + .slice(0, limit), + issues, + scannedAt: new Date().toISOString() + } +} + export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): void { handlerOptions = options ipcMain.handle('aiVault:listSessions', (_event, args?: AiVaultListArgs) => @@ -77,6 +211,18 @@ export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): vo }) } +function resetAiVaultCacheForTests(): void { + cachedList = null + inflightList = null + inflightKey = null + handlerOptions = {} +} + +export const _internals = { + listAiVaultSessions, + resetAiVaultCacheForTests +} + async function getAiVaultWslHomeDirs(): Promise { if (process.platform !== 'win32') { return [] diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 15f95306c78..ff676112218 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -5,7 +5,7 @@ import type { Store } from '../persistence' import { SshConnectionStore } from '../ssh/ssh-connection-store' import { SshConnectionManager, type SshConnectionCallbacks } from '../ssh/ssh-connection' import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import { SshRelaySession } from '../ssh/ssh-relay-session' +import { SshRelaySession, type SshRelayAiVaultHostInfo } from '../ssh/ssh-relay-session' import { SshPortForwardManager } from '../ssh/ssh-port-forward' import type { DetectedPort, @@ -123,6 +123,23 @@ export async function removeRegisteredSshTarget(targetId: string): Promise // scattered Maps/Sets that previously tracked this state independently. const activeSessions = new Map() +export function getActiveSshAiVaultHostInfo(targetId: string): SshRelayAiVaultHostInfo | null { + if (isRuntimeOwnedSshTargetId(targetId)) { + return null + } + return activeSessions.get(targetId)?.getAiVaultHostInfo() ?? null +} + +export function getActiveSshAiVaultHostInfos(): SshRelayAiVaultHostInfo[] { + return [...activeSessions.values()].flatMap((session) => { + if (isRuntimeOwnedSshTargetId(session.targetId)) { + return [] + } + const info = session.getAiVaultHostInfo() + return info ? [info] : [] + }) +} + async function detachActiveSshSession(targetId: string): Promise { await teardownActiveSshSession(targetId, (session) => session.detach()) } diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 58bf3fbf798..85761621d44 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -62,10 +62,12 @@ import { import type { Store } from '../persistence' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { runRemoteOrcaCli } from './ssh-remote-orca-cli' +import { toSshExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' export type RelaySessionState = 'idle' | 'deploying' | 'ready' | 'reconnecting' | 'disposed' type RemoteCliBridgeEnv = { + remoteHome: string binDir: string relayDir: string nodePath: string @@ -79,6 +81,13 @@ type ForwardedReplayFingerprint = { deliveredAt: number } +export type SshRelayAiVaultHostInfo = { + targetId: string + executionHostId: ExecutionHostId + remoteHome: string + hostPlatform: RemoteHostPlatform +} + const RECONNECT_REPLAY_DUPLICATE_WINDOW_MS = 1000 const REPLAY_FINGERPRINT_EDGE_CHARS = 128 @@ -189,6 +198,19 @@ export class SshRelaySession { return this.remoteCliBridgeEnv?.hostPlatform ?? this.hostPlatform } + getAiVaultHostInfo(): SshRelayAiVaultHostInfo | null { + const env = this.remoteCliBridgeEnv + if (!env) { + return null + } + return { + targetId: this.targetId, + executionHostId: toSshExecutionHostId(this.targetId), + remoteHome: env.remoteHome, + hostPlatform: env.hostPlatform + } + } + getPortScanner(): PortScanner | null { return this.portScanner } @@ -218,6 +240,7 @@ export class SshRelaySession { this.remoteCliBridgeEnv = remoteHome && remoteRelayDir && nodePath && sockPath && hostPlatform ? { + remoteHome, binDir: joinRemotePath(hostPlatform, remoteHome, '.orca-relay', 'bin'), relayDir: remoteRelayDir, nodePath, @@ -345,6 +368,7 @@ export class SshRelaySession { this.remoteCliBridgeEnv = remoteHome && remoteRelayDir && nodePath && sockPath && hostPlatform ? { + remoteHome, binDir: joinRemotePath(hostPlatform, remoteHome, '.orca-relay', 'bin'), relayDir: remoteRelayDir, nodePath, diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index f67c7d82a3a..3fd7ae88809 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -41,6 +41,7 @@ import { translate } from '@/i18n/i18n' import { AiVaultPanelHeader } from './AiVaultPanelHeader' import { AiVaultSessionVirtualList } from './AiVaultSessionVirtualList' import { useAiVaultSessionRefresh } from './ai-vault-session-refresh' +import { useAiVaultExecutionHostScope } from './ai-vault-host-scope' export default function AiVaultPanel(): React.JSX.Element { const activeWorktreeId = useActiveWorktreeId() @@ -77,6 +78,11 @@ export default function AiVaultPanel(): React.JSX.Element { 'runtime', [activeWorktreeId, resumeTargetState] ) + const { executionHostScope, activeSshExecutionHostScope, onExecutionHostScopeChange } = + useAiVaultExecutionHostScope({ + activeWorktreeId: activeWorktreeId ?? null, + resumeTargetState + }) const activeWorktreePath = activeWorktree?.path ?? null // Why: AI Vault ownership is cwd-based, so we must consider live worktrees across all repos. const activeWorktreePaths = useMemo( @@ -106,7 +112,10 @@ export default function AiVaultPanel(): React.JSX.Element { }), [activeProjectKey, activeWorktree, allWorktrees, projectHostSetupProjection] ) - const { error, loading, refresh, scanResult, sessions } = useAiVaultSessionRefresh(scopePaths) + const { error, loading, refresh, scanResult, sessions } = useAiVaultSessionRefresh( + scopePaths, + executionHostScope + ) const sessionProjectById = useMemo( () => buildAiVaultProjectContext({ @@ -121,6 +130,7 @@ export default function AiVaultPanel(): React.JSX.Element { ) const sessionWorktreeById = useAiVaultSessionWorktreeMap({ sessions, + repos, worktrees: allWorktrees, activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null }) @@ -211,6 +221,7 @@ export default function AiVaultPanel(): React.JSX.Element { (session: AiVaultSession) => resolveAiVaultSessionResumeState({ sessionFilePath: session.filePath, + sessionExecutionHostId: session.executionHostId, worktreeInfo: sessionWorktreeById.get(session.id) ?? null, activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null, worktrees: allWorktrees, @@ -231,6 +242,7 @@ export default function AiVaultPanel(): React.JSX.Element { (session: AiVaultSession) => resolveAiVaultSessionResumeActions({ sessionFilePath: session.filePath, + sessionExecutionHostId: session.executionHostId, worktreeInfo: sessionWorktreeById.get(session.id) ?? null, activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null, worktrees: allWorktrees, @@ -293,6 +305,8 @@ export default function AiVaultPanel(): React.JSX.Element { activeWorktreePath={activeWorktreePath} activeProjectKey={activeProjectKey} scope={scope} + executionHostScope={executionHostScope} + activeSshExecutionHostScope={activeSshExecutionHostScope} agents={agents} sort={sort} group={group} @@ -300,6 +314,7 @@ export default function AiVaultPanel(): React.JSX.Element { adjustmentCount={viewAdjustmentCount} onQueryChange={setQuery} onScopeChange={handleScopeChange} + onExecutionHostScopeChange={onExecutionHostScopeChange} onAgentEnabledChange={setAgentEnabled} onSortChange={setSort} onGroupChange={setGroup} diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx index 227e96ccca9..2b3cc2802b0 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx @@ -7,7 +7,8 @@ import { FolderOpen, ListFilter, LoaderCircle, - PanelsTopLeft + PanelsTopLeft, + Server } from 'lucide-react' import { Button } from '@/components/ui/button' import { @@ -31,6 +32,12 @@ import { type AiVaultScope, type AiVaultSort } from '../../../../shared/ai-vault-types' +import { + ALL_EXECUTION_HOSTS_SCOPE, + getExecutionHostLabel, + LOCAL_EXECUTION_HOST_ID, + type ExecutionHostScope +} from '../../../../shared/execution-host' import { agentLabel, type AiVaultSessionGroup } from './ai-vault-session-filters' import { translate } from '@/i18n/i18n' @@ -175,6 +182,59 @@ export function VaultScopeSwitch({ ) } +export function VaultHostScopeMenu({ + executionHostScope, + activeSshExecutionHostScope, + onExecutionHostScopeChange +}: { + executionHostScope: ExecutionHostScope + activeSshExecutionHostScope: ExecutionHostScope | null + onExecutionHostScopeChange: (scope: ExecutionHostScope) => void +}): React.JSX.Element { + const hostOptions = [ + LOCAL_EXECUTION_HOST_ID, + ...(activeSshExecutionHostScope ? [activeSshExecutionHostScope] : []), + ALL_EXECUTION_HOSTS_SCOPE + ] as const + const label = getExecutionHostLabel(executionHostScope) + + return ( + + + + + + + {translate('auto.components.right.sidebar.AiVaultPanelControls.host', 'Host')} + + onExecutionHostScopeChange(value as ExecutionHostScope)} + > + {hostOptions.map((scope) => ( + + {getExecutionHostLabel(scope)} + + ))} + + + + ) +} + export function VaultViewMenu({ agents, sort, diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx index 6f40190617e..b463e188a7e 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx @@ -7,7 +7,8 @@ import type { AiVaultScope, AiVaultSort } from '../../../../shared/ai-vault-types' -import { VaultScopeSwitch, VaultViewMenu } from './AiVaultPanelControls' +import type { ExecutionHostScope } from '../../../../shared/execution-host' +import { VaultHostScopeMenu, VaultScopeSwitch, VaultViewMenu } from './AiVaultPanelControls' type AiVaultPanelHeaderProps = { query: string @@ -18,6 +19,8 @@ type AiVaultPanelHeaderProps = { activeWorktreePath: string | null activeProjectKey: string | null scope: AiVaultScope + executionHostScope: ExecutionHostScope + activeSshExecutionHostScope: ExecutionHostScope | null agents: readonly AiVaultAgent[] sort: AiVaultSort group: AiVaultGroup @@ -25,6 +28,7 @@ type AiVaultPanelHeaderProps = { adjustmentCount: number onQueryChange: (query: string) => void onScopeChange: (scope: AiVaultScope) => void + onExecutionHostScopeChange: (scope: ExecutionHostScope) => void onAgentEnabledChange: (agent: AiVaultAgent, enabled: boolean) => void onSortChange: (sort: AiVaultSort) => void onGroupChange: (group: AiVaultGroup) => void @@ -42,6 +46,8 @@ export function AiVaultPanelHeader({ activeWorktreePath, activeProjectKey, scope, + executionHostScope, + activeSshExecutionHostScope, agents, sort, group, @@ -49,6 +55,7 @@ export function AiVaultPanelHeader({ adjustmentCount, onQueryChange, onScopeChange, + onExecutionHostScopeChange, onAgentEnabledChange, onSortChange, onGroupChange, @@ -99,6 +106,11 @@ export function AiVaultPanelHeader({
+ void + onJumpToOriginalPane?: () => void + showJumpToWorktree: boolean + onJumpToWorktree?: () => void + onCopyResume: () => void + onCopyId: () => void + onCopyPath: () => void + onOpenLog?: () => void + onRevealLog?: () => void + onOpenCwd?: () => void +}) { + const Item = menuKind === 'context' ? ContextMenuItem : DropdownMenuItem + const Separator = menuKind === 'context' ? ContextMenuSeparator : DropdownMenuSeparator + const hasLocalPathActions = Boolean(onOpenLog || onRevealLog || onOpenCwd) + + return ( + <> + {onJumpToOriginalPane ? ( + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.jumpToOriginalPane', + 'Jump to Original Pane' + )} + + ) : null} + {showJumpToWorktree ? ( + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.jumpToWorktree', + 'Jump to Worktree' + )} + + ) : null} + + + {resumeLabel} + + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.copyResumeCommand', + 'Copy Resume Command' + )} + + {hasLocalPathActions ? ( + <> + + {onOpenLog ? ( + + + {translate('auto.components.right.sidebar.AiVaultSessionRow.openLog', 'Open Log')} + + ) : null} + {onRevealLog ? ( + + + {translate('auto.components.right.sidebar.AiVaultSessionRow.revealLog', 'Reveal Log')} + + ) : null} + {onOpenCwd ? ( + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.openWorkingDirectory', + 'Open Working Directory' + )} + + ) : null} + + ) : null} + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.copySessionId', + 'Copy Session ID' + )} + + + {translate('auto.components.right.sidebar.AiVaultSessionRow.copyLogPath', 'Copy Log Path')} + + + ) +} diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx index 58923a53479..d5a538b44fd 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx @@ -31,7 +31,7 @@ export function SessionInlineDetails({ } onResumeInWorktree: () => void onResumeInNewTab: () => void - onOpenLog: () => void + onOpenLog?: () => void }): React.JSX.Element { const showResumeInWorktree = Boolean(resumeActions.worktree.worktreeId) const showResumeInNewTab = !showResumeInWorktree || Boolean(resumeActions.newTab.worktreeId) @@ -132,20 +132,22 @@ export function SessionInlineDetails({ )} ) : null} - + {onOpenLog ? ( + + ) : null}
) diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx index 5ea6bd9d307..0826eed9509 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx @@ -1,14 +1,6 @@ import { useCallback } from 'react' import type React from 'react' -import { Copy, FileJson, FolderOpen, LocateFixed, PanelTopOpen, Play } from 'lucide-react' -import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu' -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger -} from '@/components/ui/context-menu' +import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from '@/components/ui/context-menu' import { Badge } from '@/components/ui/badge' import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' import { AgentIcon } from '@/lib/agent-catalog' @@ -27,6 +19,7 @@ import { agentLabel } from './ai-vault-session-filters' import { translate } from '@/i18n/i18n' import { SessionInlineDetails, SessionTime } from './AiVaultSessionDetails' import { latestSessionConversationTurn } from './ai-vault-session-display' +import { SessionActionMenuItems } from './AiVaultSessionActionMenuItems' import { SessionRowTrailingActions } from './SessionRowTrailingActions' import type { AiVaultSessionResumeActions } from './ai-vault-session-resume' import { @@ -74,8 +67,8 @@ export function VaultSessionRow({ onCopyResume: () => void onCopyId: () => void onCopyPath: () => void - onOpenLog: () => void - onRevealLog: () => void + onOpenLog?: () => void + onRevealLog?: () => void onOpenCwd?: () => void }) { const updatedAt = session.updatedAt ?? session.modifiedAt @@ -102,6 +95,7 @@ export function VaultSessionRow({ title: session.title, command: resumeStartup.command, sessionFilePath: session.filePath, + sessionExecutionHostId: session.executionHostId, ...(resumeStartup.env ? { env: resumeStartup.env } : {}), ...(resumeStartup.launchConfig ? { launchConfig: resumeStartup.launchConfig } : {}) }) @@ -223,101 +217,6 @@ export function VaultSessionRow({ ) } -export function SessionActionMenuItems({ - menuKind = 'dropdown', - resumeDisabled, - resumeLabel, - onResume, - onJumpToOriginalPane, - showJumpToWorktree, - onJumpToWorktree, - onCopyResume, - onCopyId, - onCopyPath, - onOpenLog, - onRevealLog, - onOpenCwd -}: { - menuKind?: 'dropdown' | 'context' - resumeDisabled: boolean - resumeLabel: string - onResume: () => void - onJumpToOriginalPane?: () => void - showJumpToWorktree: boolean - onJumpToWorktree?: () => void - onCopyResume: () => void - onCopyId: () => void - onCopyPath: () => void - onOpenLog: () => void - onRevealLog: () => void - onOpenCwd?: () => void -}) { - const Item = menuKind === 'context' ? ContextMenuItem : DropdownMenuItem - const Separator = menuKind === 'context' ? ContextMenuSeparator : DropdownMenuSeparator - - return ( - <> - {onJumpToOriginalPane ? ( - - - {translate( - 'auto.components.right.sidebar.AiVaultSessionRow.jumpToOriginalPane', - 'Jump to Original Pane' - )} - - ) : null} - {showJumpToWorktree ? ( - - - {translate( - 'auto.components.right.sidebar.AiVaultSessionRow.jumpToWorktree', - 'Jump to Worktree' - )} - - ) : null} - - - {resumeLabel} - - - - {translate( - 'auto.components.right.sidebar.AiVaultSessionRow.copyResumeCommand', - 'Copy Resume Command' - )} - - - - - {translate('auto.components.right.sidebar.AiVaultSessionRow.openLog', 'Open Log')} - - - - {translate('auto.components.right.sidebar.AiVaultSessionRow.revealLog', 'Reveal Log')} - - {onOpenCwd ? ( - - - {translate( - 'auto.components.right.sidebar.AiVaultSessionRow.openWorkingDirectory', - 'Open Working Directory' - )} - - ) : null} - - - {translate( - 'auto.components.right.sidebar.AiVaultSessionRow.copySessionId', - 'Copy Session ID' - )} - - - {translate('auto.components.right.sidebar.AiVaultSessionRow.copyLogPath', 'Copy Log Path')} - - - ) -} - function getSessionDetailsId(sessionId: string): string { return `ai-vault-session-details-${sessionId.replace(/[^A-Za-z0-9_-]/g, '-')}` } diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx index e7293f15de7..3d2bdde0384 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx @@ -19,6 +19,7 @@ import { isAiVaultSessionInCurrentWorktree, type AiVaultSessionWorktreeInfo } from './ai-vault-session-worktree' +import { canUseLocalAiVaultSessionPathActions } from './ai-vault-session-path-actions' import { extractVaultVirtualRowIndexes, getVaultStickyHeaderIndexes, @@ -275,6 +276,8 @@ function AiVaultVirtualRow({ const resumeState = row.type === 'session' ? getSessionResumeState(row.session) : null const resumeActions = row.type === 'session' ? getSessionResumeActions(row.session) : null const resumeLabel = resumeState ? aiVaultSessionResumeLabel(resumeState) : '' + const canOpenLocalSessionPaths = + row.type === 'session' && canUseLocalAiVaultSessionPathActions(row.session.executionHostId) return (
onCopyResume(row.session, resumeState?.worktreeId)} onCopyId={() => onCopyId(row.session)} onCopyPath={() => onCopyPath(row.session)} - onOpenLog={() => onOpenLog(row.session)} - onRevealLog={() => onRevealLog(row.session)} - onOpenCwd={row.session.cwd ? () => onOpenCwd(row.session) : undefined} + onOpenLog={canOpenLocalSessionPaths ? () => onOpenLog(row.session) : undefined} + onRevealLog={canOpenLocalSessionPaths ? () => onRevealLog(row.session) : undefined} + onOpenCwd={ + canOpenLocalSessionPaths && row.session.cwd ? () => onOpenCwd(row.session) : undefined + } /> )}
diff --git a/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx b/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx index 27a29bbf032..10167da346b 100644 --- a/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx +++ b/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx @@ -11,7 +11,7 @@ import { cn } from '@/lib/utils' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import { agentLabel } from './ai-vault-session-filters' import { translate } from '@/i18n/i18n' -import { SessionActionMenuItems } from './AiVaultSessionRow' +import { SessionActionMenuItems } from './AiVaultSessionActionMenuItems' import { aiVaultWorktreeJumpTooltip, type AiVaultSessionWorktreeInfo @@ -67,8 +67,8 @@ export function SessionRowTrailingActions({ onCopyResume: () => void onCopyId: () => void onCopyPath: () => void - onOpenLog: () => void - onRevealLog: () => void + onOpenLog?: () => void + onRevealLog?: () => void onOpenCwd?: () => void }) { const jumpToWorktreeTooltip = aiVaultWorktreeJumpTooltip(worktreeInfo) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-host-scope.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-host-scope.test.ts new file mode 100644 index 00000000000..73af6ab915e --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-host-scope.test.ts @@ -0,0 +1,120 @@ +// @vitest-environment happy-dom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it } from 'vitest' +import type { AppState } from '@/store/types' +import type { AiVaultSessionResumeTargetState } from './ai-vault-session-resume' +import { useAiVaultExecutionHostScope } from './ai-vault-host-scope' + +type HostScopeResult = ReturnType + +let root: Root | null = null +let latest: HostScopeResult | null = null + +function HookProbe(props: { + activeWorktreeId: string | null + resumeTargetState: AiVaultSessionResumeTargetState +}): null { + latest = useAiVaultExecutionHostScope(props) + return null +} + +async function renderHook(props: { + activeWorktreeId: string | null + resumeTargetState: AiVaultSessionResumeTargetState +}): Promise { + if (!root) { + const container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + } + await act(async () => { + root?.render(createElement(HookProbe, props)) + }) +} + +function stateForWorktree(args: { + worktreeId: string + repoId: string + executionHostId?: string | null + hostId?: string | null +}): AiVaultSessionResumeTargetState { + return { + folderWorkspaces: [], + projectGroups: [], + repos: [ + { + id: args.repoId, + connectionId: null, + executionHostId: args.executionHostId ?? 'local' + } + ], + worktreesByRepo: { + [args.repoId]: [ + { + id: args.worktreeId, + repoId: args.repoId, + hostId: args.hostId ?? null + } + ] + } + } as unknown as Pick +} + +afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + root = null + latest = null + document.body.replaceChildren() +}) + +describe('useAiVaultExecutionHostScope', () => { + it('defaults SSH worktrees to their SSH execution host', async () => { + await renderHook({ + activeWorktreeId: 'repo-1::/remote/repo', + resumeTargetState: stateForWorktree({ + worktreeId: 'repo-1::/remote/repo', + repoId: 'repo-1', + hostId: 'ssh:dev-box' + }) + }) + + expect(latest?.executionHostScope).toBe('ssh:dev-box') + expect(latest?.activeSshExecutionHostScope).toBe('ssh:dev-box') + }) + + it('defaults local worktrees to local history', async () => { + await renderHook({ + activeWorktreeId: 'repo-1::/local/repo', + resumeTargetState: stateForWorktree({ + worktreeId: 'repo-1::/local/repo', + repoId: 'repo-1' + }) + }) + + expect(latest?.executionHostScope).toBe('local') + expect(latest?.activeSshExecutionHostScope).toBeNull() + }) + + it('preserves manual host scope changes across unrelated rerenders', async () => { + const props = { + activeWorktreeId: 'repo-1::/remote/repo', + resumeTargetState: stateForWorktree({ + worktreeId: 'repo-1::/remote/repo', + repoId: 'repo-1', + hostId: 'ssh:dev-box' + }) + } + await renderHook(props) + + await act(async () => { + latest?.onExecutionHostScopeChange('all') + }) + await renderHook({ ...props, resumeTargetState: { ...props.resumeTargetState } }) + + expect(latest?.executionHostScope).toBe('all') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-host-scope.ts b/src/renderer/src/components/right-sidebar/ai-vault-host-scope.ts new file mode 100644 index 00000000000..8993e8a00e6 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-host-scope.ts @@ -0,0 +1,64 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { getAiVaultResumeWorkspaceExecutionHostId } from '@/lib/ai-vault-resume-target' +import { + ALL_EXECUTION_HOSTS_SCOPE, + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + type ExecutionHostScope +} from '../../../../shared/execution-host' +import type { AiVaultSessionResumeTargetState } from './ai-vault-session-resume' + +export function useAiVaultExecutionHostScope(args: { + activeWorktreeId: string | null + resumeTargetState: AiVaultSessionResumeTargetState +}): { + executionHostScope: ExecutionHostScope + activeSshExecutionHostScope: ExecutionHostScope | null + onExecutionHostScopeChange: (scope: ExecutionHostScope) => void +} { + const userChangedHostScopeRef = useRef(false) + const activeExecutionHostId = useMemo( + () => getAiVaultResumeWorkspaceExecutionHostId(args.resumeTargetState, args.activeWorktreeId), + [args.activeWorktreeId, args.resumeTargetState] + ) + const activeExecutionHost = parseExecutionHostId(activeExecutionHostId) + const defaultExecutionHostScope: ExecutionHostScope = + activeExecutionHost?.kind === 'ssh' ? activeExecutionHost.id : LOCAL_EXECUTION_HOST_ID + const activeSshExecutionHostScope: ExecutionHostScope | null = + activeExecutionHost?.kind === 'ssh' ? activeExecutionHost.id : null + const [executionHostScope, setExecutionHostScope] = + useState(defaultExecutionHostScope) + + useEffect(() => { + // Why: preserve an explicit user choice (e.g. "All") across incidental + // rerenders, but reset to the new default once that choice no longer + // applies to the active worktree's host. + const allowedScopes = new Set([ + LOCAL_EXECUTION_HOST_ID, + ALL_EXECUTION_HOSTS_SCOPE, + ...(activeSshExecutionHostScope ? [activeSshExecutionHostScope] : []) + ]) + if (!allowedScopes.has(executionHostScope)) { + setExecutionHostScope(defaultExecutionHostScope) + userChangedHostScopeRef.current = false + return + } + if (!userChangedHostScopeRef.current && executionHostScope !== defaultExecutionHostScope) { + setExecutionHostScope(defaultExecutionHostScope) + } + }, [activeSshExecutionHostScope, defaultExecutionHostScope, executionHostScope]) + + const handleExecutionHostScopeChange = useCallback( + (nextScope: ExecutionHostScope) => { + userChangedHostScopeRef.current = nextScope !== defaultExecutionHostScope + setExecutionHostScope(nextScope) + }, + [defaultExecutionHostScope] + ) + + return { + executionHostScope, + activeSshExecutionHostScope, + onExecutionHostScopeChange: handleExecutionHostScopeChange + } +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-original-pane.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-original-pane.test.ts index cec8a30a270..c47f64b9720 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-original-pane.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-original-pane.test.ts @@ -10,6 +10,7 @@ const OTHER_LEAF_ID = '22222222-2222-4222-8222-222222222222' const baseSession: AiVaultSession = { id: 'codex:session-1', + executionHostId: 'local', agent: 'codex', sessionId: 'session-1', title: 'Fix the pane focus', diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts index b341558c628..3da906781d9 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts @@ -9,6 +9,7 @@ import { const baseSession: AiVaultSession = { id: 'codex:1', + executionHostId: 'local', agent: 'codex', sessionId: 'session-1', title: 'Fix the flaky golden tests', 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 3190221f389..acbe0b051a1 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 @@ -15,6 +15,7 @@ import { const baseSession: AiVaultSession = { id: 'claude:1', + executionHostId: 'local', agent: 'claude', sessionId: 'session-1', title: 'Implement vault filters', diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts index dc2ed601f83..c55acb1e939 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts @@ -13,6 +13,7 @@ import { import { useAppStore } from '@/store' import { canResumeAiVaultSessionOnTarget, + getAiVaultResumeWorkspaceExecutionHostId, getAiVaultResumeWorkspaceTargetStatus } from '@/lib/ai-vault-resume-target' import type { AiVaultAgent, AiVaultSession } from '../../../../shared/ai-vault-types' @@ -79,6 +80,7 @@ export function useAiVaultSessionLaunchActions({ (session: AiVaultSession, targetWorktreeId?: string): void => { const targetId = resolveAiVaultSessionLaunchTarget({ sessionFilePath: session.filePath, + sessionExecutionHostId: session.executionHostId, activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null, targetWorktreeId, targetState @@ -130,6 +132,7 @@ export type AiVaultSessionLaunchTarget = export function resolveAiVaultSessionLaunchTarget(args: { sessionFilePath: string | null + sessionExecutionHostId?: AiVaultSession['executionHostId'] | null activeWorktreeId: string | null targetWorktreeId?: string targetState: AiVaultSessionResumeTargetState @@ -143,7 +146,18 @@ export function resolveAiVaultSessionLaunchTarget(args: { } const targetStatus = getAiVaultResumeWorkspaceTargetStatus(args.targetState, targetWorktreeId) - if (!canResumeAiVaultSessionOnTarget({ sessionFilePath: args.sessionFilePath, targetStatus })) { + const targetExecutionHostId = getAiVaultResumeWorkspaceExecutionHostId( + args.targetState, + targetWorktreeId + ) + if ( + !canResumeAiVaultSessionOnTarget({ + sessionFilePath: args.sessionFilePath, + sessionExecutionHostId: args.sessionExecutionHostId, + targetStatus, + targetExecutionHostId + }) + ) { return { status: 'unsupported', targetStatus } } @@ -159,12 +173,12 @@ function aiVaultResumeUnsupportedMessage( 'Resume from history is not available in runtime-hosted workspaces.' ) } - // Why: 'ssh' only reaches the unsupported branch when the session file lives - // on this machine, so the message explains the host mismatch, not SSH itself. - if (targetStatus === 'ssh') { + // Why: local and SSH targets can both be valid generally; this branch means + // the session's recorded host does not match the selected workspace. + if (targetStatus === 'ssh' || targetStatus === 'local') { return translate( - 'auto.components.right.sidebar.AiVaultPanel.localSessionSshWorkspaceUnsupported', - "This session's history is stored on this machine, so it can't resume in an SSH workspace. Open a local workspace instead." + 'auto.components.right.sidebar.AiVaultPanel.sessionHostMismatchUnsupported', + 'This session belongs to a different host. Open a workspace on the same host to resume it.' ) } return translate( diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.test.ts new file mode 100644 index 00000000000..93fee6c94e9 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { canUseLocalAiVaultSessionPathActions } from './ai-vault-session-path-actions' + +describe('canUseLocalAiVaultSessionPathActions', () => { + it('allows OS path actions for local session history', () => { + expect(canUseLocalAiVaultSessionPathActions('local')).toBe(true) + }) + + it('blocks OS path actions for non-local or unknown session history', () => { + expect(canUseLocalAiVaultSessionPathActions('ssh:dev-box')).toBe(false) + expect(canUseLocalAiVaultSessionPathActions('runtime:gpu-box')).toBe(false) + expect(canUseLocalAiVaultSessionPathActions(undefined)).toBe(false) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.ts new file mode 100644 index 00000000000..f723bbb1959 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.ts @@ -0,0 +1,13 @@ +import { + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' + +export function canUseLocalAiVaultSessionPathActions( + executionHostId: ExecutionHostId | null | undefined +): boolean { + // Why: Electron shell open/reveal APIs only validate paths on this computer; + // SSH session history exposes paths that exist on the remote host instead. + return normalizeExecutionHostId(executionHostId) === LOCAL_EXECUTION_HOST_ID +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts index fa53c685da8..d0e4c1a1c6e 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts @@ -6,6 +6,7 @@ import { buildAiVaultProjectContext, toAiVaultProjectKey } from './ai-vault-sess const baseSession: AiVaultSession = { id: 'claude:1', + executionHostId: 'local', agent: 'claude', sessionId: 'session-1', title: 'Implement project history', @@ -182,7 +183,11 @@ describe('buildAiVaultProjectContext', () => { repoId: repo.id, path: '/runtime/orca' }) - const session = makeSession({ id: 'claude:runtime-worktree', cwd: '/runtime/orca/src' }) + const session = makeSession({ + id: 'claude:runtime-worktree', + cwd: '/runtime/orca/src', + executionHostId: 'runtime:preview' + }) const context = buildAiVaultProjectContext({ repos: [repo], @@ -292,7 +297,7 @@ describe('buildAiVaultProjectContext', () => { expect(context.sessionProjectById.get(session.id)?.key).toBe('repo:repo-win') }) - it('falls back to folder when a hostless session matches multiple host buckets', () => { + it('uses the session host when matching overlapping local and SSH project paths', () => { const localRepo = makeRepo({ id: 'local', displayName: 'Local', path: '/srv/orca' }) const sshRepo = makeRepo({ id: 'ssh', @@ -300,7 +305,11 @@ describe('buildAiVaultProjectContext', () => { path: '/srv/orca', connectionId: 'target-1' }) - const session = makeSession({ id: 'claude:ambiguous', cwd: '/srv/orca/src' }) + const session = makeSession({ + id: 'claude:ssh-session', + cwd: '/srv/orca/src', + executionHostId: 'ssh:target-1' + }) const context = buildAiVaultProjectContext({ repos: [localRepo, sshRepo], @@ -324,16 +333,21 @@ describe('buildAiVaultProjectContext', () => { }) expect(context.sessionProjectById.get(session.id)).toMatchObject({ - kind: 'folder', - key: 'folder:/srv/orca/src', - label: 'orca/src' + kind: 'repo', + key: 'repo:ssh', + label: 'SSH', + hostKey: 'ssh:target-1' }) }) - it('uses ProjectHostSetup host ids when detecting ambiguous host buckets', () => { + it('falls back to folder when a legacy hostless session matches multiple host buckets', () => { const localRepo = makeRepo({ id: 'local', displayName: 'Local', path: '/srv/orca' }) const runtimeRepo = makeRepo({ id: 'runtime', displayName: 'Runtime', path: '/srv/orca' }) - const session = makeSession({ id: 'claude:runtime-ambiguous', cwd: '/srv/orca/src' }) + const session = makeSession({ + id: 'claude:runtime-ambiguous', + cwd: '/srv/orca/src', + executionHostId: undefined as unknown as AiVaultSession['executionHostId'] + }) const context = buildAiVaultProjectContext({ repos: [localRepo, runtimeRepo], diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts index fdb78325025..c0734a11881 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts @@ -1,4 +1,9 @@ -import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import { + getRepoExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' import type { ProjectHostSetupProjection } from '../../../../shared/project-host-setup-projection' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import type { ProjectHostSetup, Repo, Worktree } from '../../../../shared/types' @@ -14,7 +19,7 @@ export type AiVaultSessionProject = { label: string projectId?: string repoId?: string - hostKey?: string + hostKey?: ExecutionHostId } export type AiVaultProjectContext = { @@ -27,7 +32,7 @@ export type AiVaultProjectContext = { type SessionProjectCandidate = { source: 'worktree' | 'setup' normalizedPath: string - hostKey: string + hostKey: ExecutionHostId projectId: string | null repoId: string | null } @@ -63,7 +68,7 @@ export function buildAiVaultProjectContext({ for (const session of sessions) { sessionProjectById.set( session.id, - resolveSessionProject(session.cwd, candidates, projectLabelByKey) + resolveSessionProject(session, candidates, projectLabelByKey) ) } @@ -147,10 +152,11 @@ function buildProjectCandidates( candidates.push({ source: 'worktree', normalizedPath: normalizeRuntimePathForComparison(worktree.path), - hostKey: - worktree.hostId ?? - setup?.hostId ?? - (repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID), + hostKey: resolveCandidateHostId( + worktree.hostId, + setup?.hostId, + repo ? getRepoExecutionHostId(repo) : null + ), projectId: worktree.projectId ?? setup?.projectId ?? null, repoId: worktree.repoId }) @@ -168,7 +174,7 @@ function buildProjectCandidates( candidates.push({ source: 'setup', normalizedPath: normalizeRuntimePathForComparison(setup.path), - hostKey: setup.hostId || getRepoExecutionHostId(setup), + hostKey: resolveCandidateHostId(setup.hostId, getRepoExecutionHostId(setup)), projectId: setup.projectId, repoId: setup.repoId || null }) @@ -193,15 +199,28 @@ function buildProjectCandidates( return candidates } +function resolveCandidateHostId( + ...values: readonly (string | null | undefined)[] +): ExecutionHostId { + for (const value of values) { + const hostId = normalizeExecutionHostId(value) + if (hostId) { + return hostId + } + } + return LOCAL_EXECUTION_HOST_ID +} + function hasCandidatePath(pathValue: string): boolean { return pathValue.trim().length > 0 } function resolveSessionProject( - cwd: string | null, + session: AiVaultSession, candidates: readonly SessionProjectCandidate[], projectLabelByKey: ReadonlyMap ): AiVaultSessionProject { + const cwd = session.cwd if (!cwd) { return { kind: 'unknown', key: 'unknown', label: '' } } @@ -209,14 +228,22 @@ function resolveSessionProject( const matches = candidates.filter((candidate) => isPathInsideOrEqual(candidate.normalizedPath, cwd) ) - const hostBuckets = new Set(matches.map((candidate) => candidate.hostKey)) - if (hostBuckets.size > 1) { - // Why: session rows do not carry host ids yet, so overlapping local/SSH - // paths must stay visible without being attributed to the wrong project. + const sessionHostId = normalizeExecutionHostId(session.executionHostId) + const hostMatches = sessionHostId + ? matches.filter((candidate) => candidate.hostKey === sessionHostId) + : matches + if (sessionHostId && matches.length > 0 && hostMatches.length === 0) { + // Why: same-path projects can exist on several hosts; a tagged transcript + // should never be attributed to a project on a different machine. return folderProject(cwd) } - const bestCandidate = matches.sort(compareCandidates)[0] + const hostBuckets = new Set(hostMatches.map((candidate) => candidate.hostKey)) + if (!sessionHostId && hostBuckets.size > 1) { + return folderProject(cwd) + } + + const bestCandidate = hostMatches.sort(compareCandidates)[0] if (!bestCandidate) { return folderProject(cwd) } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts index cdff27604d9..2ea75f40f48 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts @@ -42,18 +42,37 @@ const initialAppState = useAppStore.getInitialState() const roots: Root[] = [] let latest: ReturnType | null = null -function HookProbe(props: { scopePaths: readonly string[] }): null { - latest = useAiVaultSessionRefresh(props.scopePaths) +function HookProbe(props: { + scopePaths: readonly string[] + executionHostScope?: 'local' | 'all' | `ssh:${string}` +}): null { + latest = useAiVaultSessionRefresh(props.scopePaths, props.executionHostScope ?? 'local') return null } -async function renderHook(scopePaths: readonly string[] = []): Promise { +async function renderHook( + scopePaths: readonly string[] = [], + executionHostScope: 'local' | 'all' | `ssh:${string}` = 'local' +): Promise { const container = document.createElement('div') document.body.appendChild(container) const root = createRoot(container) roots.push(root) await act(async () => { - root.render(createElement(HookProbe, { scopePaths })) + root.render(createElement(HookProbe, { scopePaths, executionHostScope })) + }) +} + +async function rerenderHook( + scopePaths: readonly string[] = [], + executionHostScope: 'local' | 'all' | `ssh:${string}` = 'local' +): Promise { + const root = roots.at(-1) + if (!root) { + throw new Error('renderHook must be called before rerenderHook') + } + await act(async () => { + root.render(createElement(HookProbe, { scopePaths, executionHostScope })) }) } @@ -126,7 +145,58 @@ describe('useAiVaultSessionRefresh refocus behavior', () => { await flushMicrotasks() expect(listSessionsMock).toHaveBeenCalledTimes(1) - expect(listSessionsMock.mock.calls[0]?.[0]).toMatchObject({ force: true }) + expect(listSessionsMock.mock.calls[0]?.[0]).toMatchObject({ + executionHostScope: 'local', + force: true + }) + }) + + it('passes the requested execution host scope to the scanner', async () => { + await renderHook(['/repo'], 'ssh:dev-box') + await flushMicrotasks() + + expect(listSessionsMock).toHaveBeenCalledTimes(1) + expect(lastCallArgs()).toMatchObject({ + executionHostScope: 'ssh:dev-box', + scopePaths: ['/repo'] + }) + }) + + it('does not apply stale results after the host scope changes mid-scan', async () => { + let resolveLocal: ((result: AiVaultListResult) => void) | null = null + let resolveSsh: ((result: AiVaultListResult) => void) | null = null + listSessionsMock + .mockImplementationOnce( + () => new Promise((resolve) => (resolveLocal = resolve)) + ) + .mockImplementationOnce( + () => new Promise((resolve) => (resolveSsh = resolve)) + ) + + await renderHook([], 'local') + expect(listSessionsMock).toHaveBeenCalledTimes(1) + + await rerenderHook(['/remote/repo'], 'ssh:dev-box') + expect(listSessionsMock).toHaveBeenCalledTimes(1) + + await act(async () => { + resolveLocal?.({ ...EMPTY_RESULT, scannedAt: '2026-07-01T00:00:01.000Z' }) + }) + await flushMicrotasks() + + expect(latest?.scanResult).toBeNull() + expect(listSessionsMock).toHaveBeenCalledTimes(2) + expect(lastCallArgs()).toMatchObject({ + executionHostScope: 'ssh:dev-box', + scopePaths: ['/remote/repo'] + }) + + await act(async () => { + resolveSsh?.({ ...EMPTY_RESULT, scannedAt: '2026-07-01T00:00:02.000Z' }) + }) + await flushMicrotasks() + + expect(latest?.scanResult?.scannedAt).toBe('2026-07-01T00:00:02.000Z') }) it('force re-scans on refocus once the throttle allows it', async () => { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts index 98539f858ce..88757297603 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { AiVaultListResult, AiVaultSession } from '../../../../shared/ai-vault-types' +import type { ExecutionHostScope } from '../../../../shared/execution-host' import { useAppStore } from '@/store' const SESSION_LIMIT = 500 @@ -26,7 +27,10 @@ export function resetAiVaultForcedRescanThrottleForTest(): void { type AiVaultRefreshArgs = { force?: boolean; background?: boolean } -export function useAiVaultSessionRefresh(scopePaths: readonly string[]): { +export function useAiVaultSessionRefresh( + scopePaths: readonly string[], + executionHostScope: ExecutionHostScope +): { error: string | null loading: boolean refresh: (args?: AiVaultRefreshArgs) => Promise @@ -45,77 +49,99 @@ export function useAiVaultSessionRefresh(scopePaths: readonly string[]): { const lastAppliedScanRef = useRef<{ scopeKey: string; scannedAt: string } | null>(null) const mountedRef = useRef(true) const scopePathsKey = useMemo(() => scopePaths.join('\n'), [scopePaths]) + const scanScopeKey = `${executionHostScope}\n${scopePathsKey}` const scopePathsRef = useRef(scopePaths) scopePathsRef.current = scopePaths + const executionHostScopeRef = useRef(executionHostScope) + executionHostScopeRef.current = executionHostScope + const currentScanScopeKey = useCallback( + () => `${executionHostScopeRef.current}\n${scopePathsRef.current.join('\n')}`, + [] + ) - const refresh = useCallback(async (args: AiVaultRefreshArgs = {}): Promise => { - // A scope change during an in-flight scan must not be dropped; queue one more - // scan so the current scoped view is refreshed after the older scan settles. - if (refreshInFlightRef.current) { - pendingRefreshRef.current = true - pendingForceRef.current ||= args.force === true - pendingBackgroundRef.current &&= args.background === true - return - } - - refreshInFlightRef.current = true - const refreshId = refreshIdRef.current + 1 - refreshIdRef.current = refreshId - // A manual force scan counts against the throttle so an auto rescan right - // after the button press doesn't trigger a second full scan. - if (args.force === true) { - lastForcedRescanAt = Date.now() - } - // Background (refocus) refreshes usually resolve from the main-process - // cache; suppressing the loading flag avoids a spinner flash on every - // return to the app. - if (args.background !== true) { - setLoading(true) - } - setError(null) - const scopeKey = scopePathsRef.current.join('\n') - try { - const result = await window.api.aiVault.listSessions({ - limit: SESSION_LIMIT, - scopePaths: scopePathsRef.current, - force: args.force - }) - if (!mountedRef.current || refreshIdRef.current !== refreshId) { + const refresh = useCallback( + async (args: AiVaultRefreshArgs = {}): Promise => { + // A scope change during an in-flight scan must not be dropped; queue one more + // scan so the current scoped view is refreshed after the older scan settles. + if (refreshInFlightRef.current) { + pendingRefreshRef.current = true + pendingForceRef.current ||= args.force === true + pendingBackgroundRef.current &&= args.background === true return } - // A cache hit returns the snapshot already on screen; skip the state - // updates so refocus flips don't force pointless re-renders. - if ( - lastAppliedScanRef.current?.scopeKey === scopeKey && - lastAppliedScanRef.current.scannedAt === result.scannedAt - ) { - return + + refreshInFlightRef.current = true + const refreshId = refreshIdRef.current + 1 + refreshIdRef.current = refreshId + // A manual force scan counts against the throttle so an auto rescan right + // after the button press doesn't trigger a second full scan. + if (args.force === true) { + lastForcedRescanAt = Date.now() } - lastAppliedScanRef.current = { scopeKey, scannedAt: result.scannedAt } - setScanResult(result) - setSessions(result.sessions) - } catch (err) { - if (mountedRef.current && refreshIdRef.current === refreshId) { - setError(err instanceof Error ? err.message : String(err)) + // Background (refocus) refreshes usually resolve from the main-process + // cache; suppressing the loading flag avoids a spinner flash on every + // return to the app. + if (args.background !== true) { + setLoading(true) } - } finally { - refreshInFlightRef.current = false - if (mountedRef.current && refreshIdRef.current === refreshId) { - setLoading(false) + setError(null) + const scopeKey = scopePathsRef.current.join('\n') + const hostScope = executionHostScopeRef.current + const scanKey = `${hostScope}\n${scopeKey}` + try { + const result = await window.api.aiVault.listSessions({ + limit: SESSION_LIMIT, + scopePaths: scopePathsRef.current, + executionHostScope: hostScope, + force: args.force + }) + if (!mountedRef.current || refreshIdRef.current !== refreshId) { + return + } + // Why: host/scope changes queue a follow-up scan, but the older result + // may resolve first and must not briefly paint the wrong history list. + if (scanKey !== currentScanScopeKey()) { + return + } + // A cache hit returns the snapshot already on screen; skip the state + // updates so refocus flips don't force pointless re-renders. + if ( + lastAppliedScanRef.current?.scopeKey === scanKey && + lastAppliedScanRef.current.scannedAt === result.scannedAt + ) { + return + } + lastAppliedScanRef.current = { scopeKey: scanKey, scannedAt: result.scannedAt } + setScanResult(result) + setSessions(result.sessions) + } catch (err) { + if ( + mountedRef.current && + refreshIdRef.current === refreshId && + scanKey === currentScanScopeKey() + ) { + setError(err instanceof Error ? err.message : String(err)) + } + } finally { + refreshInFlightRef.current = false + if (mountedRef.current && refreshIdRef.current === refreshId) { + setLoading(false) + } + if (pendingRefreshRef.current && mountedRef.current) { + pendingRefreshRef.current = false + const force = pendingForceRef.current + // The queued refresh is background-only if every queued caller was. + const background = pendingBackgroundRef.current + pendingForceRef.current = false + pendingBackgroundRef.current = true + void refresh({ force, background }) + } } - if (pendingRefreshRef.current && mountedRef.current) { - pendingRefreshRef.current = false - const force = pendingForceRef.current - // The queued refresh is background-only if every queued caller was. - const background = pendingBackgroundRef.current - pendingForceRef.current = false - pendingBackgroundRef.current = true - void refresh({ force, background }) - } - } - // Deps are intentionally empty: refresh reads changing values through refs - // and recurses on itself, so its identity must stay stable. - }, []) + // Deps intentionally avoid changing scope values: refresh reads them + // through refs and recurses on itself, so its identity must stay stable. + }, + [currentScanScopeKey] + ) // Forced rescans triggered by events (refocus, agent-session starts) run // immediately when the throttle allows, otherwise once as soon as it frees @@ -162,7 +188,7 @@ export function useAiVaultSessionRefresh(scopePaths: readonly string[]): { if (!force) { requestForcedRescan() } - }, [refresh, requestForcedRescan, scopePathsKey]) + }, [refresh, requestForcedRescan, scanScopeKey]) // Sessions started while the app was backgrounded should appear when the // user returns, so refocus also bypasses the scan cache (throttled). OS diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts index b8a428cd0c0..eb82d910cb9 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts @@ -1,8 +1,10 @@ import type { Repo, Worktree } from '../../../../shared/types' import { canResumeAiVaultSessionOnTarget, + getAiVaultResumeWorkspaceExecutionHostId, getAiVaultResumeWorkspaceTargetStatus } from '@/lib/ai-vault-resume-target' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' import type { AppState } from '@/store/types' import { translate } from '@/i18n/i18n' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' @@ -34,6 +36,7 @@ export type AiVaultSessionResumeActions = { export function resolveAiVaultSessionResumeState(args: { sessionFilePath: string | null + sessionExecutionHostId?: AiVaultSession['executionHostId'] | null worktreeInfo: AiVaultSessionWorktreeInfo | null activeWorktreeId: string | null worktrees: readonly Worktree[] @@ -56,6 +59,7 @@ export function resolveAiVaultSessionResumeState(args: { for (const worktreeId of candidateWorktreeIds) { const targetId = resolveSupportedResumeWorktreeId({ sessionFilePath: args.sessionFilePath, + sessionExecutionHostId: args.sessionExecutionHostId, worktreeId, targetState }) @@ -78,6 +82,7 @@ export function resolveAiVaultSessionResumeState(args: { export function resolveAiVaultSessionResumeActions(args: { sessionFilePath: string | null + sessionExecutionHostId?: AiVaultSession['executionHostId'] | null worktreeInfo: AiVaultSessionWorktreeInfo | null activeWorktreeId: string | null worktrees: readonly Worktree[] @@ -92,11 +97,13 @@ export function resolveAiVaultSessionResumeActions(args: { const sessionTargetId = resolveSupportedResumeWorktreeId({ sessionFilePath: args.sessionFilePath, + sessionExecutionHostId: args.sessionExecutionHostId, worktreeId: sessionWorktreeId, targetState }) const activeTargetId = resolveSupportedResumeWorktreeId({ sessionFilePath: args.sessionFilePath, + sessionExecutionHostId: args.sessionExecutionHostId, worktreeId: args.activeWorktreeId && args.activeWorktreeId !== sessionWorktreeId ? args.activeWorktreeId @@ -142,6 +149,7 @@ export function isKnownAiVaultResumeWorkspaceTarget( function resolveSupportedResumeWorktreeId(args: { sessionFilePath: string | null + sessionExecutionHostId?: AiVaultSession['executionHostId'] | null worktreeId: string | null targetState: AiVaultSessionResumeTargetState }): string | null { @@ -154,7 +162,18 @@ function resolveSupportedResumeWorktreeId(args: { } const targetStatus = getAiVaultResumeWorkspaceTargetStatus(args.targetState, args.worktreeId) - if (!canResumeAiVaultSessionOnTarget({ sessionFilePath: args.sessionFilePath, targetStatus })) { + const targetExecutionHostId = getAiVaultResumeWorkspaceExecutionHostId( + args.targetState, + args.worktreeId + ) + if ( + !canResumeAiVaultSessionOnTarget({ + sessionFilePath: args.sessionFilePath, + sessionExecutionHostId: args.sessionExecutionHostId, + targetStatus, + targetExecutionHostId + }) + ) { return null } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts index b378afe3d42..58687977a33 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { AiVaultSession } from '../../../../shared/ai-vault-types' -import type { Worktree } from '../../../../shared/types' +import type { Repo, Worktree } from '../../../../shared/types' import { aiVaultWorktreeCompactPath, aiVaultWorktreeJumpTooltip, @@ -15,6 +15,7 @@ import { const baseSession: AiVaultSession = { id: 'codex:session-1', + executionHostId: 'local', agent: 'codex', sessionId: 'session-1', title: 'Find the pane', @@ -55,6 +56,19 @@ function makeWorktree(overrides: Partial = {}): Worktree { return { ...worktree, ...overrides } } +function makeRepo(overrides: Partial = {}): Repo { + return { + id: 'repo-1', + path: '/repo/orca', + displayName: 'orca', + badgeColor: '#737373', + addedAt: 1, + connectionId: null, + executionHostId: 'local', + ...overrides + } +} + describe('resolveAiVaultSessionWorktreeInfo', () => { it('marks the selected owning worktree as current', () => { const worktree = makeWorktree() @@ -135,6 +149,55 @@ describe('resolveAiVaultSessionWorktreeInfo', () => { path: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\orca' }) }) + + it('uses the session host when multiple worktrees share the same path', () => { + const localWorktree = makeWorktree({ + id: 'repo-local::/srv/orca', + repoId: 'repo-local', + displayName: 'local', + path: '/srv/orca', + hostId: 'local' + }) + const sshWorktree = makeWorktree({ + id: 'repo-ssh::/srv/orca', + repoId: 'repo-ssh', + displayName: 'ssh', + path: '/srv/orca', + hostId: 'ssh:target-1' + }) + + expect( + resolveAiVaultSessionWorktreeInfo({ + session: { ...baseSession, cwd: '/srv/orca/src', executionHostId: 'ssh:target-1' }, + worktrees: [localWorktree, sshWorktree], + activeWorktreeId: null + }) + ).toMatchObject({ + label: 'ssh', + worktreeId: sshWorktree.id + }) + }) + + it('uses repo host ownership when a legacy worktree lacks host metadata', () => { + const worktree = makeWorktree({ + id: 'repo-ssh::/srv/orca', + repoId: 'repo-ssh', + displayName: 'ssh', + path: '/srv/orca' + }) + + expect( + resolveAiVaultSessionWorktreeInfo({ + session: { ...baseSession, cwd: '/srv/orca/src', executionHostId: 'ssh:target-1' }, + repos: [makeRepo({ id: 'repo-ssh', connectionId: 'target-1', executionHostId: null })], + worktrees: [worktree], + activeWorktreeId: null + }) + ).toMatchObject({ + label: 'ssh', + worktreeId: worktree.id + }) + }) }) describe('canJumpToAiVaultSessionWorktree', () => { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts index bd7aeef1bea..31222bec1b5 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts @@ -1,6 +1,12 @@ import { useMemo } from 'react' import { parseWslUncPath } from '../../../../shared/wsl-paths' import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id' +import { + getRepoExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' import { isPathInsideOrEqual, isRuntimePathAbsolute, @@ -8,7 +14,7 @@ import { normalizeRuntimePathSeparators } from '../../../../shared/cross-platform-path' import type { AiVaultSession } from '../../../../shared/ai-vault-types' -import type { Worktree } from '../../../../shared/types' +import type { Repo, Worktree } from '../../../../shared/types' import { translate } from '@/i18n/i18n' export type AiVaultSessionWorktreeStatus = 'current' | 'active' | 'archived' | 'unavailable' @@ -23,16 +29,19 @@ export type AiVaultSessionWorktreeInfo = { type WorktreeCandidate = { worktree: Worktree path: string + hostId: ExecutionHostId status: Exclude source: 'current-path' | 'prior-path' } export function resolveAiVaultSessionWorktreeInfo({ session, + repos = [], worktrees, activeWorktreeId }: { session: AiVaultSession + repos?: readonly Pick[] worktrees: readonly Worktree[] activeWorktreeId: string | null }): AiVaultSessionWorktreeInfo | null { @@ -40,8 +49,10 @@ export function resolveAiVaultSessionWorktreeInfo({ return null } - const candidates = buildWorktreeCandidates(worktrees) + const sessionHostId = normalizeExecutionHostId(session.executionHostId) + const candidates = buildWorktreeCandidates(worktrees, repos) .filter((candidate) => isSessionInWorktreePath(candidate.path, session.cwd!)) + .filter((candidate) => !sessionHostId || candidate.hostId === sessionHostId) .sort(compareWorktreeCandidates) const best = candidates[0] @@ -85,6 +96,7 @@ export function extractWorktreePathFromSessionTitle(title: string): string | nul export function resolveAiVaultSessionWorktreeDisplay(args: { session: AiVaultSession + repos?: readonly Pick[] worktrees: readonly Worktree[] activeWorktreeId: string | null }): AiVaultSessionWorktreeInfo | null { @@ -117,10 +129,12 @@ export function resolveAiVaultSessionWorktreeDisplay(args: { export function useAiVaultSessionWorktreeMap({ sessions, + repos = [], worktrees, activeWorktreeId }: { sessions: readonly AiVaultSession[] + repos?: readonly Pick[] worktrees: readonly Worktree[] activeWorktreeId: string | null }): ReadonlyMap { @@ -130,13 +144,14 @@ export function useAiVaultSessionWorktreeMap({ sessions.flatMap((session) => { const worktreeInfo = resolveAiVaultSessionWorktreeDisplay({ session, + repos, worktrees, activeWorktreeId }) return worktreeInfo ? [[session.id, worktreeInfo] as const] : [] }) ), - [activeWorktreeId, sessions, worktrees] + [activeWorktreeId, repos, sessions, worktrees] ) } @@ -192,13 +207,22 @@ export function aiVaultWorktreeJumpTooltip( ) } -function buildWorktreeCandidates(worktrees: readonly Worktree[]): WorktreeCandidate[] { +function buildWorktreeCandidates( + worktrees: readonly Worktree[], + repos: readonly Pick[] +): WorktreeCandidate[] { const candidates: WorktreeCandidate[] = [] + const repoById = new Map(repos.map((repo) => [repo.id, repo])) for (const worktree of worktrees) { + const repo = repoById.get(worktree.repoId) + const hostId = + normalizeExecutionHostId(worktree.hostId) ?? + (repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID) if (hasUsablePath(worktree.path)) { candidates.push({ worktree, path: worktree.path, + hostId, status: worktree.isArchived ? 'archived' : 'active', source: 'current-path' }) @@ -211,6 +235,7 @@ function buildWorktreeCandidates(worktrees: readonly Worktree[]): WorktreeCandid candidates.push({ worktree, path: parsed.worktreePath, + hostId, status: worktree.isArchived ? 'archived' : 'active', source: 'prior-path' }) diff --git a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx index ee41d5c6e81..af09c852cab 100644 --- a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx +++ b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState, type CSSProperties } from 're import { toast } from 'sonner' import { canResumeAiVaultSessionOnTarget, + getAiVaultResumeWorkspaceExecutionHostId, getAiVaultResumeWorkspaceTargetStatus } from '@/lib/ai-vault-resume-target' import { @@ -169,7 +170,9 @@ export default function AiVaultSessionDropLayer({ return true } - const targetStatus = getAiVaultResumeWorkspaceTargetStatus(useAppStore.getState(), worktreeId) + const state = useAppStore.getState() + const targetStatus = getAiVaultResumeWorkspaceTargetStatus(state, worktreeId) + const targetExecutionHostId = getAiVaultResumeWorkspaceExecutionHostId(state, worktreeId) if (targetStatus === 'runtime') { toast.error( translate( @@ -191,13 +194,15 @@ export default function AiVaultSessionDropLayer({ if ( !canResumeAiVaultSessionOnTarget({ sessionFilePath: payload.sessionFilePath ?? null, - targetStatus + sessionExecutionHostId: payload.sessionExecutionHostId ?? null, + targetStatus, + targetExecutionHostId }) ) { toast.error( translate( - 'auto.components.tab.group.AiVaultSessionDropLayer.localSessionSshWorkspaceUnsupported', - "This session's history is stored on this machine, so it can't resume in an SSH workspace. Drop it onto a local workspace instead." + 'auto.components.tab.group.AiVaultSessionDropLayer.sessionHostMismatchUnsupported', + 'This session belongs to a different host. Drop it onto a workspace on the same host.' ) ) return true diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index ea81c2c094a..1080958f7c2 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2589,6 +2589,7 @@ "sessionQueued": "Session queued", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "sessionHostMismatchUnsupported": "This session belongs to a different host. Drop it onto a workspace on the same host.", "localSessionSshWorkspaceUnsupported": "This session's history is stored on this machine, so it can't resume in an SSH workspace. Drop it onto a local workspace instead." }, "TabGroupDropOverlay": { @@ -9590,6 +9591,7 @@ "runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "sessionHostMismatchUnsupported": "This session belongs to a different host. Open a workspace on the same host to resume it.", "localSessionSshWorkspaceUnsupported": "This session's history is stored on this machine, so it can't resume in an SSH workspace. Open a local workspace instead." }, "AiVaultPanelControls": { @@ -9619,7 +9621,9 @@ "globalScope": "Global", "projectScope": "Project", "currentProjectLower": "current project", - "project": "Project" + "project": "Project", + "hostScopeAriaLabel": "Session History host: {{value0}}", + "host": "Host" }, "AiVaultSessionDetails": { "originalAsk": "Original ask", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 97cbdff6f63..b7a29585352 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2589,7 +2589,8 @@ "sessionQueued": "Sesión en cola", "runtimeWorkspacesUnsupported": "Reanudar desde el historial no está disponible en espacios de trabajo alojados en un host.", "openSupportedWorkspace": "Abre un espacio de trabajo local o SSH antes de reanudar una sesión.", - "localSessionSshWorkspaceUnsupported": "El historial de esta sesión solo existe en este equipo. Para reanudarla, arrástrala a un workspace local. Los workspaces SSH no tienen acceso a ese historial." + "localSessionSshWorkspaceUnsupported": "El historial de esta sesión solo existe en este equipo. Para reanudarla, arrástrala a un workspace local. Los workspaces SSH no tienen acceso a ese historial.", + "sessionHostMismatchUnsupported": "Esta sesión pertenece a un host diferente. Suéltala en un espacio de trabajo del mismo host." }, "TabGroupDropOverlay": { "paneColumnLabel": "Nueva división" @@ -9590,7 +9591,8 @@ "runtimeBrowseLocalHistory": "Los workspaces alojados en runtime pueden explorar el historial local. Las acciones de reanudación están disponibles en workspaces locales y SSH.", "runtimeWorkspacesUnsupported": "Reanudar desde el historial no está disponible en workspaces alojados en runtime.", "openSupportedWorkspace": "Abre un workspace local o SSH antes de reanudar una sesión.", - "localSessionSshWorkspaceUnsupported": "El historial de esta sesión solo existe en este equipo. Para reanudarla, abre un workspace local. Los workspaces SSH no tienen acceso a ese historial." + "localSessionSshWorkspaceUnsupported": "El historial de esta sesión solo existe en este equipo. Para reanudarla, abre un workspace local. Los workspaces SSH no tienen acceso a ese historial.", + "sessionHostMismatchUnsupported": "Esta sesión pertenece a un host diferente. Abre un espacio de trabajo en el mismo host para reanudarla." }, "AiVaultPanelControls": { "scanningSessions": "Buscando sesiones", @@ -9619,7 +9621,9 @@ "globalScope": "Global", "projectScope": "Proyecto", "currentProjectLower": "proyecto actual", - "project": "Proyecto" + "project": "Proyecto", + "hostScopeAriaLabel": "Session History host: {{value0}}", + "host": "Host" }, "AiVaultSessionDetails": { "updated": "Actualizado", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 590f6d505cf..e6ff4fc77f5 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -2589,7 +2589,8 @@ "sessionQueued": "セッションをキューに追加しました", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", - "localSessionSshWorkspaceUnsupported": "このセッションの履歴はこのマシンに保存されているため、SSH ワークスペースでは再開できません。代わりにローカルワークスペースにドロップしてください。" + "localSessionSshWorkspaceUnsupported": "このセッションの履歴はこのマシンに保存されているため、SSH ワークスペースでは再開できません。代わりにローカルワークスペースにドロップしてください。", + "sessionHostMismatchUnsupported": "このセッションは別のホストに属しています。同じホスト上のワークスペースにドロップしてください。" }, "TabGroupDropOverlay": { "paneColumnLabel": "新しい分割" @@ -9590,7 +9591,8 @@ "runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", - "localSessionSshWorkspaceUnsupported": "このセッションの履歴はこのマシンに保存されているため、SSH ワークスペースでは再開できません。代わりにローカルワークスペースを開いてください。" + "localSessionSshWorkspaceUnsupported": "このセッションの履歴はこのマシンに保存されているため、SSH ワークスペースでは再開できません。代わりにローカルワークスペースを開いてください。", + "sessionHostMismatchUnsupported": "このセッションは別のホストに属しています。再開するには同じホスト上のワークスペースを開いてください。" }, "AiVaultPanelControls": { "scanningSessions": "Scanning sessions", @@ -9619,7 +9621,9 @@ "globalScope": "グローバル", "projectScope": "プロジェクト", "currentProjectLower": "現在のプロジェクト", - "project": "プロジェクト" + "project": "プロジェクト", + "hostScopeAriaLabel": "Session History host: {{value0}}", + "host": "Host" }, "AiVaultSessionDetails": { "updated": "更新日時", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 8ae5f66134f..705815c7739 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -2589,7 +2589,8 @@ "sessionQueued": "세션이 대기열에 추가됨", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", - "localSessionSshWorkspaceUnsupported": "이 세션의 기록은 이 컴퓨터에 저장되어 있어 SSH 워크스페이스에서는 재개할 수 없습니다. 대신 로컬 워크스페이스에 놓으세요." + "localSessionSshWorkspaceUnsupported": "이 세션의 기록은 이 컴퓨터에 저장되어 있어 SSH 워크스페이스에서는 재개할 수 없습니다. 대신 로컬 워크스페이스에 놓으세요.", + "sessionHostMismatchUnsupported": "이 세션은 다른 호스트에 속합니다. 같은 호스트의 워크스페이스에 놓으세요." }, "TabGroupDropOverlay": { "paneColumnLabel": "새 분할" @@ -9590,7 +9591,8 @@ "runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", - "localSessionSshWorkspaceUnsupported": "이 세션의 기록은 이 컴퓨터에 저장되어 있어 SSH 워크스페이스에서는 재개할 수 없습니다. 대신 로컬 워크스페이스를 여세요." + "localSessionSshWorkspaceUnsupported": "이 세션의 기록은 이 컴퓨터에 저장되어 있어 SSH 워크스페이스에서는 재개할 수 없습니다. 대신 로컬 워크스페이스를 여세요.", + "sessionHostMismatchUnsupported": "이 세션은 다른 호스트에 속합니다. 재개하려면 같은 호스트의 워크스페이스를 여세요." }, "AiVaultPanelControls": { "scanningSessions": "세션 스캔 중", @@ -9619,7 +9621,9 @@ "globalScope": "전역", "projectScope": "프로젝트", "currentProjectLower": "현재 프로젝트", - "project": "프로젝트" + "project": "프로젝트", + "hostScopeAriaLabel": "Session History host: {{value0}}", + "host": "Host" }, "AiVaultSessionDetails": { "updated": "업데이트됨", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 4166c860372..db284162150 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -2589,7 +2589,8 @@ "sessionQueued": "会话已排队", "runtimeWorkspacesUnsupported": "在远程运行时托管的工作区中无法从历史记录恢复会话。", "openSupportedWorkspace": "在恢复会话之前,请先打开一个本地或 SSH 工作区。", - "localSessionSshWorkspaceUnsupported": "此会话的历史记录存储在本机上,因此无法在 SSH 工作区中恢复。请改为将其拖放到本地工作区。" + "localSessionSshWorkspaceUnsupported": "此会话的历史记录存储在本机上,因此无法在 SSH 工作区中恢复。请改为将其拖放到本地工作区。", + "sessionHostMismatchUnsupported": "此会话属于其他主机。请将其拖放到同一主机上的工作区。" }, "TabGroupDropOverlay": { "paneColumnLabel": "新建拆分" @@ -9590,7 +9591,8 @@ "runtimeBrowseLocalHistory": "远程运行时托管的工作区可以浏览本地历史。恢复操作在本地和 SSH 工作区中可用。", "runtimeWorkspacesUnsupported": "在远程运行时托管的工作区中无法从历史记录恢复会话。", "openSupportedWorkspace": "在恢复会话之前,请先打开一个本地或 SSH 工作区。", - "localSessionSshWorkspaceUnsupported": "此会话的历史记录存储在本机上,因此无法在 SSH 工作区中恢复。请改为打开一个本地工作区。" + "localSessionSshWorkspaceUnsupported": "此会话的历史记录存储在本机上,因此无法在 SSH 工作区中恢复。请改为打开一个本地工作区。", + "sessionHostMismatchUnsupported": "此会话属于其他主机。请打开同一主机上的工作区以恢复它。" }, "AiVaultPanelControls": { "scanningSessions": "正在扫描会话", @@ -9619,7 +9621,9 @@ "globalScope": "全局", "projectScope": "项目", "currentProjectLower": "当前项目", - "project": "项目" + "project": "项目", + "hostScopeAriaLabel": "Session History host: {{value0}}", + "host": "Host" }, "AiVaultSessionDetails": { "updated": "更新时间", 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 1fa2559074d..dc0345491bf 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.test.ts @@ -294,4 +294,111 @@ describe('ai vault resume command runtime', () => { }) ).toBe("cd '/home/alice/repo' && CODEX_HOME='/home/alice/.codex' codex 'resume' 'session one'") }) + + it('returns the remote resume command verbatim for non-local host sessions', () => { + const state = makeState({ worktreePath: '/home/alice/repo' }) + state.repos = [{ id: 'repo-1', path: '/home/alice/repo', connectionId: 'ssh-1' }] as never + + expect( + buildAiVaultResumeStartupForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + session: { + agent: 'codex', + sessionId: 'session one', + cwd: '/home/alice/repo', + codexHome: null, + executionHostId: 'ssh:dev-box', + resumeCommand: "CODEX_HOME='/root/.codex' codex resume 'session one'" + } + }) + ).toEqual({ command: "CODEX_HOME='/root/.codex' codex resume 'session one'" }) + }) + + it('bypasses the resume pipeline even when the command override is blank', () => { + const state = makeState({ worktreePath: '/home/alice/repo' }) + state.repos = [{ id: 'repo-1', path: '/home/alice/repo', connectionId: 'ssh-1' }] as never + + expect( + buildAiVaultResumeCommandForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + commandOverride: ' ', + session: { + agent: 'codex', + sessionId: 'session one', + cwd: '/home/alice/repo', + codexHome: null, + executionHostId: 'ssh:dev-box', + resumeCommand: "CODEX_HOME='/root/.codex' codex resume 'session one'" + } + }) + ).toBe("CODEX_HOME='/root/.codex' codex resume 'session one'") + }) + + it('rebuilds the command when a non-blank override is supplied for a remote session', () => { + const state = makeState({ worktreePath: '/home/alice/repo' }) + state.repos = [{ id: 'repo-1', path: '/home/alice/repo', connectionId: 'ssh-1' }] as never + + expect( + buildAiVaultResumeCommandForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + commandOverride: 'my-codex', + session: { + agent: 'codex', + sessionId: 'session one', + cwd: '/home/alice/repo', + codexHome: null, + executionHostId: 'ssh:dev-box', + resumeCommand: "CODEX_HOME='/root/.codex' codex resume 'session one'" + } + }) + ).toBe("cd '/home/alice/repo' && my-codex 'resume' 'session one'") + }) + + it('rebuilds overridden remote commands with the recorded remote host platform', () => { + const state = makeState({ worktreePath: '/home/alice/repo' }) + state.repos = [{ id: 'repo-1', path: '/home/alice/repo', connectionId: 'ssh-1' }] as never + + expect( + buildAiVaultResumeCommandForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + commandOverride: 'my-codex', + session: { + agent: 'codex', + sessionId: 'session one', + cwd: 'C:/Users/alice/repo', + codexHome: 'C:/Users/alice/.codex', + executionHostId: 'ssh:win-box', + executionHostPlatform: 'win32', + resumeCommand: + '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'" + ) + }) + + it('ignores a stored resume command for local-host sessions', () => { + const state = makeState({ worktreePath: '/home/alice/repo' }) + state.repos = [{ id: 'repo-1', path: '/home/alice/repo', connectionId: 'ssh-1' }] as never + + expect( + buildAiVaultResumeCommandForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + session: { + agent: 'codex', + sessionId: 'session one', + cwd: '/home/alice/repo', + codexHome: null, + executionHostId: 'local', + resumeCommand: "CODEX_HOME='/root/.codex' codex resume 'session one'" + } + }) + ).toBe("cd '/home/alice/repo' && 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 95da5ee49de..691d69efc09 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.ts @@ -19,10 +19,14 @@ import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-co import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' -import { parseExecutionHostId } from '../../../shared/execution-host' +import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../../shared/execution-host' import { parseWorkspaceKey } from '../../../shared/workspace-scope' -type AiVaultResumeCommandSession = Pick +type AiVaultResumeCommandSession = Pick< + AiVaultSession, + 'agent' | 'sessionId' | 'cwd' | 'codexHome' +> & + Partial> export type AiVaultResumeStartup = { command: string @@ -65,7 +69,20 @@ export function buildAiVaultResumeStartupForWorktree(args: { session: AiVaultResumeCommandSession commandOverride?: string | null }): AiVaultResumeStartup { - const platform = getAiVaultResumePlatform(args.state, args.worktreeId) + if ( + args.session.executionHostId && + args.session.executionHostId !== LOCAL_EXECUTION_HOST_ID && + args.session.resumeCommand && + !args.commandOverride?.trim() + ) { + return { command: args.session.resumeCommand } + } + const platform = + args.session.executionHostId && + args.session.executionHostId !== LOCAL_EXECUTION_HOST_ID && + args.session.executionHostPlatform + ? args.session.executionHostPlatform + : 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 diff --git a/src/renderer/src/lib/ai-vault-resume-target.test.ts b/src/renderer/src/lib/ai-vault-resume-target.test.ts index a2c827cf99b..207bc525a59 100644 --- a/src/renderer/src/lib/ai-vault-resume-target.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-target.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import type { AppState } from '@/store/types' import { canResumeAiVaultSessionOnTarget, + getAiVaultResumeWorkspaceExecutionHostId, getAiVaultResumeRepoTargetStatus, getAiVaultResumeWorktreeTargetStatus, getAiVaultResumeWorkspaceTargetStatus, @@ -68,6 +69,51 @@ describe('ai vault session storage compatibility', () => { ).toBe(true) }) + it('allows host-tagged SSH sessions only on the matching SSH target', () => { + expect( + canResumeAiVaultSessionOnTarget({ + sessionFilePath: '/home/ada/.codex/sessions/remote.jsonl', + sessionExecutionHostId: 'ssh:dev-box', + targetStatus: 'ssh', + targetExecutionHostId: 'ssh:dev-box' + }) + ).toBe(true) + expect( + canResumeAiVaultSessionOnTarget({ + sessionFilePath: '/home/ada/.codex/sessions/remote.jsonl', + sessionExecutionHostId: 'ssh:dev-box', + targetStatus: 'ssh', + targetExecutionHostId: 'ssh:other-box' + }) + ).toBe(false) + expect( + canResumeAiVaultSessionOnTarget({ + sessionFilePath: '/home/ada/.codex/sessions/remote.jsonl', + sessionExecutionHostId: 'ssh:dev-box', + targetStatus: 'local', + targetExecutionHostId: 'local' + }) + ).toBe(false) + expect( + canResumeAiVaultSessionOnTarget({ + sessionFilePath: '/home/ada/.codex/sessions/remote.jsonl', + sessionExecutionHostId: 'ssh:dev-box', + targetStatus: 'local' + }) + ).toBe(false) + }) + + it('allows WSL-stored local sessions on any SSH target even with explicit host ids', () => { + expect( + canResumeAiVaultSessionOnTarget({ + sessionFilePath: wslSessionFile, + sessionExecutionHostId: 'local', + targetStatus: 'ssh', + targetExecutionHostId: 'ssh:dev-box' + }) + ).toBe(true) + }) + it('never allows runtime or unknown targets', () => { expect( canResumeAiVaultSessionOnTarget({ sessionFilePath: wslSessionFile, targetStatus: 'runtime' }) @@ -166,6 +212,34 @@ describe('ai vault resume target ownership', () => { ).toBe('runtime') }) + it('resolves exact execution host ids for active workspaces', () => { + const state = makeState({ + worktreesByRepo: { + 'repo-1': [{ id: 'repo-1::/repo/orca', repoId: 'repo-1', hostId: 'ssh:ssh-1' }] + }, + repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'local' }] + }) + + expect(getAiVaultResumeWorkspaceExecutionHostId(state, 'repo-1::/repo/orca')).toBe('ssh:ssh-1') + expect(getAiVaultResumeWorkspaceExecutionHostId(state, 'worktree:repo-1::/repo/orca')).toBe( + 'ssh:ssh-1' + ) + }) + + it('resolves local execution host ids for local workspaces', () => { + expect( + getAiVaultResumeWorkspaceExecutionHostId( + makeState({ + worktreesByRepo: { + 'repo-1': [{ id: 'repo-1::/repo/orca', repoId: 'repo-1' }] + }, + repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'local' }] + }), + 'repo-1::/repo/orca' + ) + ).toBe('local') + }) + it('supports folder workspaces owned by SSH project groups', () => { expect( getAiVaultResumeWorkspaceTargetStatus( diff --git a/src/renderer/src/lib/ai-vault-resume-target.ts b/src/renderer/src/lib/ai-vault-resume-target.ts index 74a1c8c48f2..823840ec85b 100644 --- a/src/renderer/src/lib/ai-vault-resume-target.ts +++ b/src/renderer/src/lib/ai-vault-resume-target.ts @@ -1,5 +1,6 @@ import { getRepoExecutionHostId, + LOCAL_EXECUTION_HOST_ID, normalizeExecutionHostId, parseExecutionHostId, toSshExecutionHostId, @@ -43,11 +44,33 @@ export function isWslStoredAiVaultSessionFile(sessionFilePath: string | null | u export function canResumeAiVaultSessionOnTarget(args: { sessionFilePath: string | null | undefined + sessionExecutionHostId?: ExecutionHostId | null targetStatus: AiVaultResumeTargetStatus + targetExecutionHostId?: ExecutionHostId | null }): boolean { if (!isSupportedAiVaultResumeTargetStatus(args.targetStatus)) { return false } + const sessionExecutionHostId = normalizeExecutionHostId(args.sessionExecutionHostId) + const targetExecutionHostId = normalizeExecutionHostId(args.targetExecutionHostId) + if (sessionExecutionHostId) { + if (targetExecutionHostId) { + if (sessionExecutionHostId === targetExecutionHostId) { + return true + } + // Why: SSH-to-local-WSL setups (#6270) tag the session 'local' but the + // file lives under a WSL UNC path reachable from any SSH shell into this + // machine, so we bypass the exact host-id match for that case. + return ( + sessionExecutionHostId === LOCAL_EXECUTION_HOST_ID && + args.targetStatus === 'ssh' && + isWslStoredAiVaultSessionFile(args.sessionFilePath) + ) + } + if (sessionExecutionHostId !== LOCAL_EXECUTION_HOST_ID) { + return false + } + } // Why: vault sessions are scanned from this machine's disk (host home dirs // plus local WSL homes). An SSH shell can only reach the WSL-stored ones // (SSH-to-local-WSL setups, #6270); host-stored session files do not exist @@ -85,6 +108,32 @@ export function getAiVaultResumeWorktreeTargetStatus(args: { ) } +export function getAiVaultResumeWorkspaceExecutionHostId( + state: Pick, + workspaceId: string | null +): ExecutionHostId | null { + if (!workspaceId) { + return null + } + + const workspaceKey = parseWorkspaceKey(workspaceId) + if (workspaceKey?.type === 'folder') { + return getAiVaultResumeFolderExecutionHostId(state, workspaceKey.folderWorkspaceId) + } + + const worktreeId = workspaceKey?.type === 'worktree' ? workspaceKey.worktreeId : workspaceId + const worktree = Object.values(state.worktreesByRepo ?? {}) + .flat() + .find((candidate) => candidate.id === worktreeId) + const worktreeHostId = normalizeExecutionHostId(worktree?.hostId) + if (worktreeHostId) { + return worktreeHostId + } + const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId) + const repo = state.repos.find((candidate) => candidate.id === repoId) + return repo ? getRepoExecutionHostId(repo) : null +} + export function getAiVaultResumeWorkspaceTargetStatus( state: Pick, workspaceId: string | null @@ -136,6 +185,29 @@ function getAiVaultResumeFolderTargetStatus( ) } +function getAiVaultResumeFolderExecutionHostId( + state: Pick, + folderWorkspaceId: string +): ExecutionHostId | null { + const workspace = state.folderWorkspaces.find((entry) => entry.id === folderWorkspaceId) + if (!workspace) { + return null + } + + const group = state.projectGroups.find((entry) => entry.id === workspace.projectGroupId) + const groupHostId = normalizeExecutionHostId(group?.executionHostId) + if (groupHostId) { + return groupHostId + } + const explicitConnectionId = (workspace.connectionId ?? group?.connectionId ?? '').trim() + if (explicitConnectionId) { + return toSshExecutionHostId(explicitConnectionId) + } + return mergeAiVaultResumeExecutionHostIds( + getFolderWorkspaceCandidateRepos(state, folderWorkspaceId).map(getRepoExecutionHostId) + ) +} + function getAiVaultResumeExecutionHostTargetStatus( hostId: ExecutionHostId | null | undefined ): AiVaultResumeTargetStatus { @@ -162,3 +234,13 @@ function mergeAiVaultResumeExecutionHostTargetStatuses( } return new Set(hostIds).size === 1 ? (statuses[0] ?? 'unknown') : 'unknown' } + +function mergeAiVaultResumeExecutionHostIds( + hostIds: readonly ExecutionHostId[] +): ExecutionHostId | null { + if (hostIds.length === 0) { + return LOCAL_EXECUTION_HOST_ID + } + const uniqueHostIds = new Set(hostIds) + return uniqueHostIds.size === 1 ? (hostIds[0] ?? null) : null +} diff --git a/src/renderer/src/lib/ai-vault-session-drag.ts b/src/renderer/src/lib/ai-vault-session-drag.ts index 2b52359e534..7b5ec941dd7 100644 --- a/src/renderer/src/lib/ai-vault-session-drag.ts +++ b/src/renderer/src/lib/ai-vault-session-drag.ts @@ -1,6 +1,7 @@ import { AI_VAULT_AGENTS, type AiVaultAgent } from '../../../shared/ai-vault-types' import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-resume' import { measureClipboardTextByteLength } from '../../../shared/clipboard-text' +import { normalizeExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' export const AI_VAULT_SESSION_DRAG_TYPE = 'application/x-orca-ai-vault-session' export const AI_VAULT_SESSION_DRAG_START_EVENT = 'orca-ai-vault-session-drag-start' @@ -15,6 +16,7 @@ export type AiVaultSessionDragPayload = { // Why: drop targets must know where the session file lives (host vs local // WSL) to reject SSH panes that cannot reach it. sessionFilePath?: string + sessionExecutionHostId?: ExecutionHostId // Why: drag/drop resume must preserve planned env/default args, not just the shell command. env?: Record launchConfig?: SleepingAgentLaunchConfig @@ -67,6 +69,8 @@ function isSerializedPayload(value: unknown): value is SerializedAiVaultSessionD isNonEmptyString(payload.title) && isNonEmptyString(payload.command) && (payload.sessionFilePath === undefined || isNonEmptyString(payload.sessionFilePath)) && + (payload.sessionExecutionHostId === undefined || + Boolean(normalizeExecutionHostId(payload.sessionExecutionHostId))) && (payload.env === undefined || isStringRecord(payload.env)) && (payload.launchConfig === undefined || isLaunchConfig(payload.launchConfig)) ) @@ -114,13 +118,23 @@ export function readAiVaultSessionDragData( if (!isSerializedPayload(parsed)) { return null } - const { agent, sessionId, title, command, sessionFilePath, env, launchConfig } = parsed + const { + agent, + sessionId, + title, + command, + sessionFilePath, + sessionExecutionHostId, + env, + launchConfig + } = parsed return { agent, sessionId, title, command, ...(sessionFilePath ? { sessionFilePath } : {}), + ...(sessionExecutionHostId ? { sessionExecutionHostId } : {}), ...(env ? { env } : {}), ...(launchConfig ? { launchConfig } : {}) } diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index 70d2054d623..58bf82597ab 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -5,6 +5,7 @@ import { type AgentStartupShell } from './tui-agent-startup-shell' import type { TuiAgent } from './types' +import type { ExecutionHostId, ExecutionHostScope } from './execution-host' export const AI_VAULT_AGENTS = [ 'claude', @@ -53,6 +54,8 @@ export type AiVaultSessionPreviewMessage = { export type AiVaultSession = { id: string + executionHostId: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null agent: AiVaultAgent sessionId: string title: string @@ -71,6 +74,7 @@ export type AiVaultSession = { } export type AiVaultScanIssue = { + executionHostId?: ExecutionHostId agent: AiVaultAgent path: string message: string @@ -82,6 +86,7 @@ export type AiVaultListArgs = { // Active workspace/project paths. The global result is recency-capped, so these // guarantee a scoped view still surfaces its own (possibly older) sessions. scopePaths?: readonly string[] + executionHostScope?: ExecutionHostScope } export type AiVaultListResult = { diff --git a/tests/e2e/ssh-ai-vault-session-history.spec.ts b/tests/e2e/ssh-ai-vault-session-history.spec.ts new file mode 100644 index 00000000000..0cb6a485ca4 --- /dev/null +++ b/tests/e2e/ssh-ai-vault-session-history.spec.ts @@ -0,0 +1,293 @@ +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { + cleanupDockerSshRelayTarget, + DOCKER_SSH_RELAY_REMOTE_REPO_PATH, + startDockerSshRelayTarget, + type DockerSshRelayTarget +} from './helpers/docker-ssh-relay-target' +import { connectDockerRemote } from './ssh-codex-reconnect-replay-driver' +import { dockerExec, dockerWriteFile } from './ssh-codex-repro-remote-fixtures' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' + +const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1' + +test.describe('SSH Agent Session History', () => { + test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run Docker-backed SSH tests.') + test.skip(process.platform === 'win32', 'Docker SSH tests use POSIX ssh tooling.') + + test('shows remote session history only for the SSH host and resumes Codex on that worktree', async ({ + orcaPage + }, testInfo: TestInfo) => { + test.slow() + let target: DockerSshRelayTarget | null = null + const stamp = Date.now() + const defaultSessionId = `remote-ai-vault-${stamp}` + const runtimeSessionId = `remote-ai-vault-runtime-${stamp}` + const claudeSessionId = `remote-ai-vault-claude-${stamp}` + const defaultTitle = `Remote AI Vault ${stamp}` + const runtimeTitle = `Remote Runtime AI Vault ${stamp}` + const claudeTitle = `Remote Claude AI Vault ${stamp}` + + try { + target = startDockerSshRelayTarget(testInfo) + seedRemoteAiVaultHistory(target, { + defaultSessionId, + runtimeSessionId, + claudeSessionId, + defaultTitle, + runtimeTitle, + claudeTitle + }) + + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + const remote = await connectDockerRemote(orcaPage, target) + const sshScope = `ssh:${encodeURIComponent(remote.targetId)}` + + const scan = await orcaPage.evaluate( + async ({ sshScope, defaultTitle, runtimeTitle, claudeTitle }) => { + const local = await window.api.aiVault.listSessions({ + executionHostScope: 'local', + force: true + }) + const ssh = await window.api.aiVault.listSessions({ + executionHostScope: sshScope, + force: true + }) + const all = await window.api.aiVault.listSessions({ + executionHostScope: 'all', + force: true + }) + return { + localHasRemote: local.sessions.some((session) => session.title === defaultTitle), + sshTitles: ssh.sessions.map((session) => session.title), + allHasRuntime: all.sessions.some((session) => session.title === runtimeTitle), + allHasClaude: all.sessions.some((session) => session.title === claudeTitle), + remoteHostIds: ssh.sessions + .filter((session) => + [defaultTitle, runtimeTitle, claudeTitle].includes(session.title) + ) + .map((session) => session.executionHostId), + remoteCommands: ssh.sessions + .filter((session) => session.title === defaultTitle || session.title === runtimeTitle) + .map((session) => session.resumeCommand) + } + }, + { sshScope, defaultTitle, runtimeTitle, claudeTitle } + ) + expect(scan.localHasRemote).toBe(false) + expect(scan.sshTitles).toEqual( + expect.arrayContaining([defaultTitle, runtimeTitle, claudeTitle]) + ) + expect(scan.allHasRuntime).toBe(true) + expect(scan.allHasClaude).toBe(true) + expect(new Set(scan.remoteHostIds)).toEqual(new Set([sshScope])) + expect(scan.remoteCommands.join('\n')).toContain("CODEX_HOME='/root/.codex'") + expect(scan.remoteCommands.join('\n')).toContain( + "CODEX_HOME='/root/.local/share/orca/codex-runtime-home/home'" + ) + + const defaultSessionTitle = orcaPage.getByText(defaultTitle, { exact: true }) + const runtimeSessionTitle = orcaPage.getByText(runtimeTitle, { exact: true }) + + await openAiVaultSidebar(orcaPage) + await expect(defaultSessionTitle.first()).toBeVisible({ timeout: 30_000 }) + + const hostButton = orcaPage.getByRole('button', { name: /Session History host:/ }) + await hostButton.click() + await orcaPage.getByRole('menuitemradio', { name: /Local/ }).click() + await expect(defaultSessionTitle).toHaveCount(0, { timeout: 30_000 }) + + await hostButton.click() + await orcaPage.getByRole('menuitemradio', { name: 'All hosts' }).click() + await expect(runtimeSessionTitle.first()).toBeVisible({ timeout: 30_000 }) + + await hostButton.click() + await orcaPage + .getByRole('menuitemradio') + .filter({ hasNotText: /Local|All hosts/ }) + .click() + await expect(defaultSessionTitle.first()).toBeVisible({ timeout: 30_000 }) + + await installStartupQueueProbe(orcaPage) + await defaultSessionTitle.first().click() + await orcaPage.getByText('Resume in Worktree', { exact: true }).click() + + await expect + .poll(() => readLastQueuedStartupCommand(orcaPage), { timeout: 30_000 }) + .toContain(`CODEX_HOME='/root/.codex' codex resume '${defaultSessionId}'`) + const queuedWorktreeId = await readLastQueuedStartupWorktreeId(orcaPage) + expect(queuedWorktreeId).toBe(remote.worktreeId) + } finally { + cleanupDockerSshRelayTarget(target) + } + }) +}) + +function seedRemoteAiVaultHistory( + target: DockerSshRelayTarget, + args: { + defaultSessionId: string + runtimeSessionId: string + claudeSessionId: string + defaultTitle: string + runtimeTitle: string + claudeTitle: string + } +): void { + dockerExec( + target, + [ + 'mkdir -p /root/.codex/sessions/2026/07/04', + 'mkdir -p /root/.local/share/orca/codex-runtime-home/home/sessions/2026/07/04', + 'mkdir -p /root/.claude/projects/orca' + ].join(' && ') + ) + dockerWriteFile( + target, + '/root/.codex/session_index.jsonl', + jsonLines([{ id: args.defaultSessionId, thread_name: args.defaultTitle }]), + '600' + ) + dockerWriteFile( + target, + `/root/.codex/sessions/2026/07/04/${args.defaultSessionId}.jsonl`, + codexTranscript({ + sessionId: args.defaultSessionId, + title: args.defaultTitle, + cwd: DOCKER_SSH_RELAY_REMOTE_REPO_PATH, + timestamp: '2026-07-04T01:00:00.000Z' + }), + '600' + ) + dockerWriteFile( + target, + `/root/.local/share/orca/codex-runtime-home/home/sessions/2026/07/04/${args.runtimeSessionId}.jsonl`, + codexTranscript({ + sessionId: args.runtimeSessionId, + title: args.runtimeTitle, + cwd: DOCKER_SSH_RELAY_REMOTE_REPO_PATH, + timestamp: '2026-07-04T02:00:00.000Z' + }), + '600' + ) + dockerWriteFile( + target, + `/root/.claude/projects/orca/${args.claudeSessionId}.jsonl`, + claudeTranscript({ + sessionId: args.claudeSessionId, + title: args.claudeTitle, + timestamp: '2026-07-04T03:00:00.000Z' + }), + '600' + ) +} + +function codexTranscript(args: { + sessionId: string + title: string + cwd: string + timestamp: string +}): string { + return jsonLines([ + { + timestamp: args.timestamp, + type: 'session_meta', + payload: { id: args.sessionId, cwd: args.cwd } + }, + { + timestamp: args.timestamp.replace(':00.000Z', ':01.000Z'), + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: args.title }] + } + } + ]) +} + +function jsonLines(records: unknown[]): string { + return records.map((record) => JSON.stringify(record)).join('\n') +} + +function claudeTranscript(args: { sessionId: string; title: string; timestamp: string }): string { + return jsonLines([ + { + sessionId: args.sessionId, + timestamp: args.timestamp, + type: 'user', + message: { content: [{ type: 'text', text: args.title }] } + }, + { + sessionId: args.sessionId, + timestamp: args.timestamp.replace(':00.000Z', ':01.000Z'), + type: 'assistant', + message: { model: 'claude-opus-4', content: 'Remote session acknowledged.' } + } + ]) +} + +async function openAiVaultSidebar(page: Page): Promise { + await page.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + store.getState().setRightSidebarOpen(true) + store.getState().setRightSidebarTab('vault') + }) +} + +async function installStartupQueueProbe(page: Page): Promise { + await page.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const holder = window as unknown as { + __aiVaultQueuedStartups?: { tabId: string; startup: { command: string } }[] + } + holder.__aiVaultQueuedStartups = [] + const current = store.getState() + const original = current.queueTabStartupCommand + store.setState({ + queueTabStartupCommand: (tabId, startup) => { + holder.__aiVaultQueuedStartups?.push({ tabId, startup: { command: startup.command } }) + original(tabId, startup) + } + }) + }) +} + +async function readLastQueuedStartupCommand(page: Page): Promise { + return page.evaluate(() => { + const holder = window as unknown as { + __aiVaultQueuedStartups?: { startup: { command: string } }[] + } + return holder.__aiVaultQueuedStartups?.at(-1)?.startup.command ?? null + }) +} + +async function readLastQueuedStartupWorktreeId(page: Page): Promise { + return page.evaluate(() => { + const holder = window as unknown as { + __aiVaultQueuedStartups?: { tabId: string }[] + } + const tabId = holder.__aiVaultQueuedStartups?.at(-1)?.tabId + if (!tabId) { + return null + } + const state = window.__store?.getState() + if (!state) { + return null + } + for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) { + if (tabs.some((tab) => tab.id === tabId)) { + return worktreeId + } + } + return null + }) +}