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>
This commit is contained in:
Avichal Dwivedi
2026-07-04 22:56:55 -07:00
committed by GitHub
co-authored by Jinjing
parent 331a30b454
commit e94c83d164
55 changed files with 3197 additions and 403 deletions
@@ -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<string, Promise<Map<string, string>>>
}): Promise<Map<string, string>> {
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<Map<string, string>> {
const titleBySessionId = new Map<string, string>()
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
}
@@ -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> | 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<AiVaultSession | null> {
return parseMessageGraphSessionContent('pi', file, content, platform, options)
}
function openClawParser(
file: FileWithMtime,
content: string,
platform: NodeJS.Platform,
options: RemoteParserOptions
): Promise<AiVaultSession | null> {
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)
}
@@ -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<string, Promise<Map<string, string>>>
}
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<AiVaultSession | null>
}
export type RemoteSessionCandidate = {
source: RemoteSessionSource
file: FileWithMtime
}
@@ -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<string, { content: string; mtimeMs: number }>()
addFile(path: string, content: string, mtimeMs: number): void {
this.files.set(normalize(path), { content, mtimeMs })
}
async readDir(dirPath: string): Promise<DirEntry[]> {
const dir = normalize(dirPath)
const prefix = dir.endsWith('/') ? dir : `${dir}/`
const entries = new Map<string, DirEntry>()
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<FileReadResult> {
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<FileStat> {
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<string> => path
search = unsupported
listFiles = unsupported
watch = unsupported
}
async function unsupported(): Promise<never> {
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 }]
}
}
])
}
+296
View File
@@ -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<AiVaultListResult> {
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<RemoteSessionCandidate[]> {
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<string[]> {
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<string> }> {
const sessions: AiVaultSession[] = []
const parsedFilePaths = new Set<string>()
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<string>
}): Promise<AiVaultSession[]> {
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<AiVaultSession | null> {
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<string, AiVaultSession>()
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<FileWithMtime | null> {
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<T, U>(
items: readonly T[],
mapper: (item: T) => Promise<U>
): Promise<U[]> {
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
}
@@ -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,
@@ -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'
@@ -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<string, Promise<CodexSessionIndexTit
export async function parseCodexSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform,
codexHome: string | null = null
codexHome: string | null = null,
executionHostId?: ExecutionHostId
): Promise<AiVaultSession | null> {
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<string | null>
}): Promise<AiVaultSession | null> {
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<string> | Iterable<string>
platform: NodeJS.Platform
codexHome: string | null
executionHostId?: ExecutionHostId
executionHostPlatform?: NodeJS.Platform | null
titleReader?: (sessionId: string) => Promise<string | null>
}): Promise<AiVaultSession | null> {
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(
@@ -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<AiVaultSession | null> {
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, unknown>): string | null {
@@ -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<AiVaultSession | null> {
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<AiVaultSession | null> {
return parseDroidSessionLines({
file,
lines: content.split(/\r?\n/),
platform,
options
})
}
async function parseDroidSessionLines(args: {
file: FileWithMtime
lines: AsyncIterable<string> | Iterable<string>
platform: NodeJS.Platform
options?: ParserSessionOptions
}): Promise<AiVaultSession | null> {
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<string, unknown>
): 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
})
}
@@ -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<AiVaultSession | null> {
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<AiVaultSession | null> {
return parseMessageGraphSessionLines({
agent,
file,
lines: content.split(/\r?\n/),
platform,
options
})
}
async function parseMessageGraphSessionLines(args: {
agent: 'openclaw' | 'pi'
file: FileWithMtime
lines: AsyncIterable<string> | Iterable<string>
platform: NodeJS.Platform
options?: ParserSessionOptions
}): Promise<AiVaultSession | null> {
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<AiVaultSession | null> {
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)
}
@@ -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<AiVaultSession | null> {
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<AiVaultSession | null> {
return parseClaudeSessionLines({
file,
lines: content.split(/\r?\n/),
platform,
options
})
}
async function parseClaudeSessionLines(args: {
file: FileWithMtime
lines: AsyncIterable<string> | Iterable<string>
platform: NodeJS.Platform
options?: ParserSessionOptions
}): Promise<AiVaultSession | null> {
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<AiVaultSession | null> {
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<AiVaultSession | null> {
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<string> | Iterable<string>
platform: NodeJS.Platform
options?: ParserSessionOptions
}): Promise<AiVaultSession | null> {
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(
@@ -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<AiVaultSession | null> {
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<AiVaultSession | null> {
return parseCopilotSessionLines({
file,
lines: content.split(/\r?\n/),
platform,
options
})
}
async function parseCopilotSessionLines(args: {
file: FileWithMtime
lines: AsyncIterable<string> | Iterable<string>
platform: NodeJS.Platform
options?: ParserSessionOptions
}): Promise<AiVaultSession | null> {
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<AiVaultSession | null> {
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<AiVaultSession | null> {
return parseCursorSessionLines({
file,
lines: content.split(/\r?\n/),
platform,
options
})
}
async function parseCursorSessionLines(args: {
file: FileWithMtime
lines: AsyncIterable<string> | Iterable<string>
platform: NodeJS.Platform
options?: ParserSessionOptions
}): Promise<AiVaultSession | null> {
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<AiVaultSession | null> {
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<AiVaultSession | null> {
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)
}
@@ -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 = {
+32 -4
View File
@@ -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<string>
platform: NodeJS.Platform
executionHostId: ExecutionHostId
issues: AiVaultScanIssue[]
}): Promise<AiVaultSession[]> {
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<AiVaultSession[]> {
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<SessionParseResult> {
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,
+163
View File
@@ -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}`
}
}
+160 -14
View File
@@ -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<AiVaultListResult> {
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<AiVaultListR
}
inflightKey = key
const additionalCodexSessionsDirs =
handlerOptions.getAdditionalCodexHomePaths?.().map((homePath) => 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<AiVaultListR
return result
})
.finally(() => {
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<AiVaultListResult> {
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<AiVaultListResult> {
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<AiVaultListResult> {
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<string, AiVaultListResult['sessions'][number]>()
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<string[]> {
if (process.platform !== 'win32') {
return []
+18 -1
View File
@@ -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<void>
// scattered Maps/Sets that previously tracked this state independently.
const activeSessions = new Map<string, SshRelaySession>()
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<void> {
await teardownActiveSshSession(targetId, (session) => session.detach())
}
+24
View File
@@ -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,
@@ -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}
@@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 max-w-24 shrink-0 gap-1 px-1.5 text-[11px] font-medium text-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground @max-[340px]/ai-vault:w-6 @max-[340px]/ai-vault:px-0"
aria-label={translate(
'auto.components.right.sidebar.AiVaultPanelControls.hostScopeAriaLabel',
'Session History host: {{value0}}',
{ value0: label }
)}
>
<Server className="size-3 shrink-0" />
<span className="min-w-0 truncate @max-[340px]/ai-vault:hidden">{label}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={6} className="w-44">
<DropdownMenuLabel>
{translate('auto.components.right.sidebar.AiVaultPanelControls.host', 'Host')}
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={executionHostScope}
onValueChange={(value) => onExecutionHostScopeChange(value as ExecutionHostScope)}
>
{hostOptions.map((scope) => (
<DropdownMenuRadioItem key={scope} value={scope}>
{getExecutionHostLabel(scope)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
export function VaultViewMenu({
agents,
sort,
@@ -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({
</div>
</div>
<div className="flex shrink-0 items-center gap-1 @max-[300px]/ai-vault:gap-0.5">
<VaultHostScopeMenu
executionHostScope={executionHostScope}
activeSshExecutionHostScope={activeSshExecutionHostScope}
onExecutionHostScopeChange={onExecutionHostScopeChange}
/>
<VaultViewMenu
agents={agents}
sort={sort}
@@ -0,0 +1,108 @@
import { Copy, FileJson, FolderOpen, LocateFixed, PanelTopOpen, Play } from 'lucide-react'
import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu'
import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu'
import { translate } from '@/i18n/i18n'
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
const hasLocalPathActions = Boolean(onOpenLog || onRevealLog || onOpenCwd)
return (
<>
{onJumpToOriginalPane ? (
<Item onSelect={onJumpToOriginalPane}>
<LocateFixed className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.jumpToOriginalPane',
'Jump to Original Pane'
)}
</Item>
) : null}
{showJumpToWorktree ? (
<Item disabled={!onJumpToWorktree} onSelect={onJumpToWorktree}>
<PanelTopOpen className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.jumpToWorktree',
'Jump to Worktree'
)}
</Item>
) : null}
<Item disabled={resumeDisabled} onSelect={onResume}>
<Play className="size-3.5" />
{resumeLabel}
</Item>
<Item onSelect={onCopyResume}>
<Copy className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.copyResumeCommand',
'Copy Resume Command'
)}
</Item>
{hasLocalPathActions ? (
<>
<Separator />
{onOpenLog ? (
<Item onSelect={onOpenLog}>
<FileJson className="size-3.5" />
{translate('auto.components.right.sidebar.AiVaultSessionRow.openLog', 'Open Log')}
</Item>
) : null}
{onRevealLog ? (
<Item onSelect={onRevealLog}>
<FolderOpen className="size-3.5" />
{translate('auto.components.right.sidebar.AiVaultSessionRow.revealLog', 'Reveal Log')}
</Item>
) : null}
{onOpenCwd ? (
<Item onSelect={onOpenCwd}>
<FolderOpen className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.openWorkingDirectory',
'Open Working Directory'
)}
</Item>
) : null}
</>
) : null}
<Separator />
<Item onSelect={onCopyId}>
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.copySessionId',
'Copy Session ID'
)}
</Item>
<Item onSelect={onCopyPath}>
{translate('auto.components.right.sidebar.AiVaultSessionRow.copyLogPath', 'Copy Log Path')}
</Item>
</>
)
}
@@ -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({
)}
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="xs"
draggable={false}
onClick={(event) => {
event.stopPropagation()
onOpenLog()
}}
className="h-7 shrink-0 px-2.5 text-[11px] text-muted-foreground"
>
<FileJson className="size-3.5" />
{translate('auto.components.right.sidebar.AiVaultSessionDetails.viewLog', 'View Log')}
</Button>
{onOpenLog ? (
<Button
type="button"
variant="ghost"
size="xs"
draggable={false}
onClick={(event) => {
event.stopPropagation()
onOpenLog()
}}
className="h-7 shrink-0 px-2.5 text-[11px] text-muted-foreground"
>
<FileJson className="size-3.5" />
{translate('auto.components.right.sidebar.AiVaultSessionDetails.viewLog', 'View Log')}
</Button>
) : null}
</div>
</div>
)
@@ -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 ? (
<Item onSelect={onJumpToOriginalPane}>
<LocateFixed className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.jumpToOriginalPane',
'Jump to Original Pane'
)}
</Item>
) : null}
{showJumpToWorktree ? (
<Item disabled={!onJumpToWorktree} onSelect={onJumpToWorktree}>
<PanelTopOpen className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.jumpToWorktree',
'Jump to Worktree'
)}
</Item>
) : null}
<Item disabled={resumeDisabled} onSelect={onResume}>
<Play className="size-3.5" />
{resumeLabel}
</Item>
<Item onSelect={onCopyResume}>
<Copy className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.copyResumeCommand',
'Copy Resume Command'
)}
</Item>
<Separator />
<Item onSelect={onOpenLog}>
<FileJson className="size-3.5" />
{translate('auto.components.right.sidebar.AiVaultSessionRow.openLog', 'Open Log')}
</Item>
<Item onSelect={onRevealLog}>
<FolderOpen className="size-3.5" />
{translate('auto.components.right.sidebar.AiVaultSessionRow.revealLog', 'Reveal Log')}
</Item>
{onOpenCwd ? (
<Item onSelect={onOpenCwd}>
<FolderOpen className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.openWorkingDirectory',
'Open Working Directory'
)}
</Item>
) : null}
<Separator />
<Item onSelect={onCopyId}>
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.copySessionId',
'Copy Session ID'
)}
</Item>
<Item onSelect={onCopyPath}>
{translate('auto.components.right.sidebar.AiVaultSessionRow.copyLogPath', 'Copy Log Path')}
</Item>
</>
)
}
function getSessionDetailsId(sessionId: string): string {
return `ai-vault-session-details-${sessionId.replace(/[^A-Za-z0-9_-]/g, '-')}`
}
@@ -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 (
<div
@@ -330,9 +333,11 @@ function AiVaultVirtualRow({
onCopyResume={() => 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
}
/>
)}
</div>
@@ -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)
@@ -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<typeof useAiVaultExecutionHostScope>
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<void> {
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<AppState, 'folderWorkspaces' | 'projectGroups' | 'repos' | 'worktreesByRepo'>
}
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')
})
})
@@ -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<ExecutionHostScope>(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<ExecutionHostScope>([
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
}
}
@@ -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',
@@ -9,6 +9,7 @@ import {
const baseSession: AiVaultSession = {
id: 'codex:1',
executionHostId: 'local',
agent: 'codex',
sessionId: 'session-1',
title: 'Fix the flaky golden tests',
@@ -15,6 +15,7 @@ import {
const baseSession: AiVaultSession = {
id: 'claude:1',
executionHostId: 'local',
agent: 'claude',
sessionId: 'session-1',
title: 'Implement vault filters',
@@ -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(
@@ -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)
})
})
@@ -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
}
@@ -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],
@@ -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<string, string>
): 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)
}
@@ -42,18 +42,37 @@ const initialAppState = useAppStore.getInitialState()
const roots: Root[] = []
let latest: ReturnType<typeof useAiVaultSessionRefresh> | 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<void> {
async function renderHook(
scopePaths: readonly string[] = [],
executionHostScope: 'local' | 'all' | `ssh:${string}` = 'local'
): Promise<void> {
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<void> {
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<AiVaultListResult>((resolve) => (resolveLocal = resolve))
)
.mockImplementationOnce(
() => new Promise<AiVaultListResult>((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 () => {
@@ -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<void>
@@ -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<readonly string[]>(scopePaths)
scopePathsRef.current = scopePaths
const executionHostScopeRef = useRef<ExecutionHostScope>(executionHostScope)
executionHostScopeRef.current = executionHostScope
const currentScanScopeKey = useCallback(
() => `${executionHostScopeRef.current}\n${scopePathsRef.current.join('\n')}`,
[]
)
const refresh = useCallback(async (args: AiVaultRefreshArgs = {}): Promise<void> => {
// 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<void> => {
// 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
@@ -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
}
@@ -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> = {}): Worktree {
return { ...worktree, ...overrides }
}
function makeRepo(overrides: Partial<Repo> = {}): 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', () => {
@@ -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<AiVaultSessionWorktreeStatus, 'current'>
source: 'current-path' | 'prior-path'
}
export function resolveAiVaultSessionWorktreeInfo({
session,
repos = [],
worktrees,
activeWorktreeId
}: {
session: AiVaultSession
repos?: readonly Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>[]
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<Repo, 'id' | 'connectionId' | 'executionHostId'>[]
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<Repo, 'id' | 'connectionId' | 'executionHostId'>[]
worktrees: readonly Worktree[]
activeWorktreeId: string | null
}): ReadonlyMap<string, AiVaultSessionWorktreeInfo> {
@@ -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<Repo, 'id' | 'connectionId' | 'executionHostId'>[]
): 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'
})
@@ -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
+5 -1
View File
@@ -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",
+7 -3
View File
@@ -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",
+7 -3
View File
@@ -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": "更新日時",
+7 -3
View File
@@ -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": "업데이트됨",
+7 -3
View File
@@ -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": "更新时间",
@@ -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'")
})
})
@@ -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<AiVaultSession, 'agent' | 'sessionId' | 'cwd' | 'codexHome'>
type AiVaultResumeCommandSession = Pick<
AiVaultSession,
'agent' | 'sessionId' | 'cwd' | 'codexHome'
> &
Partial<Pick<AiVaultSession, 'executionHostId' | 'executionHostPlatform' | 'resumeCommand'>>
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
@@ -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(
@@ -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<AppState, 'folderWorkspaces' | 'projectGroups' | 'repos' | 'worktreesByRepo'>,
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<AppState, 'folderWorkspaces' | 'projectGroups' | 'repos' | 'worktreesByRepo'>,
workspaceId: string | null
@@ -136,6 +185,29 @@ function getAiVaultResumeFolderTargetStatus(
)
}
function getAiVaultResumeFolderExecutionHostId(
state: Pick<AppState, 'folderWorkspaces' | 'projectGroups' | 'repos'>,
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
}
+15 -1
View File
@@ -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<string, string>
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 } : {})
}
+5
View File
@@ -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 = {
@@ -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<void> {
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<void> {
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<string | null> {
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<string | null> {
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
})
}