fix(ai-vault): support session scanning in SSH worktrees (#11004)

* fix(ai-vault): support session scanning in SSH worktrees

Add relay-native aiVault.listSessions scanning that discovers agent
sessions on SSH hosts. Includes fallback to filesystem crawl for
legacy relays, full cancellation support, result validation, and
scan coalescing to reduce redundant work.

* fix(ai-vault): scan sessions in SSH worktrees with coordinated cancellat

- Extract batching logic to `mapRemoteScanBatches` for reuse and proper cancellation checkpoints
- Move `AiVaultScanCoordinator` from relay to main to handle concurrent same-key requests with individual cancellation signals
- Report scope path truncation consistently across relay and SSH fallback paths
- Gracefully degrade relay handler on unsupported platforms instead of aborting startup
- Refactor issue display to separate blocking errors, scope notices, and skipped transcript counts

* fix(ai-vault): stabilize SSH session scan CI

Swallow async WSL relay stdin EPIPE so the live hook-relay shard no longer
fails after all tests pass. Merge main, resolve scan/relay conflicts, and
align cancellation/host-issue reporting with IPC expectations.

* fix(ai-vault): harden session scan cancellation, relay timeouts, and preemption

Thread the abort signal through every scan and parse path so superseded or
cancelled scans stop promptly instead of parsing every remaining transcript
for a caller that already left.  Replace the fragile message-text relay
timeout check with a typed error code so unrelated errors carrying the
phrase "timed out after" no longer suppress the filesystem fallback.  Fix
scan coordinator preemption so a forced Refresh in one window no longer
re-enters as a spurious cancellation in another.  Add a host-leg cache for
the all-hosts view and cap filesystem concurrency so a single slow remote
home cannot stall the whole merge.

Co-authored-by: Orca <help@stably.ai>

* fix(ai-vault): use stable React keys for scan issue banners

Drop array-index keys so react-doctor/no-array-index-as-key passes.
Uniqueness comes from host, kind, agent, path, and message.

* fix(ai-vault): SSH session scanning with configurable depth limits

Implement depth-aware caching and proper scan boundaries to make SSH session
scanning reliable in worktrees. Users can now select between faster (250
sessions) and comprehensive (unlimited) history scans. The scanner:
- Deduplicates scans across relay, host leg, runtime, and renderer layers
- Reuses larger scans to serve smaller depth requests
- Properly bounds in-scope discovery per-limit
- Fixes timeout enforcement when SSH providers ignore abort signals

* Move sessionLimit ref update to useLayoutEffect

Keep render pure for React Doctor by deferring ref updates to
a layout effect, which still executes before render-dependent
effects that consume the ref.

* fix(adhoc): stamp version prefix from main, not the feature branch

Adhoc builds check out arbitrary refs whose package.json often lags
version bumps (e.g. 1.4.165-rc.0 while main is 1.4.168-rc.1). Hourly
always builds main so it already tracks the product line; adhoc now
resolves the base version from origin/main (or ORCA_ADHOC_BASE_VERSION)
so branch builds share that prefix.

* Revert "fix(adhoc): stamp version prefix from main, not the feature branch"

This reverts commit a26a18eb3fd83f7e7d2db9a6a7c3e02e0f79089a.

* fix(ai-vault): fix scoped backfill and coordinator race conditions

Resolve race where the last waiter leaving could abort an already-settled scan (add `settled` flag). Redesign scoped session backfill to keep searching through newer files until the scope reaches its requested session quota instead of stopping at the candidate limit; out-of-scope files no longer consume the scope budget. Centralize scan limit normalization and fix error classification for cancelled scans using the proper helper instead of checking Error.name. Disambiguate cache keys using JSON and add cancellation check after scope discovery phase.

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinjing 2026-08-03 16:17:00 -07:00 committed by GitHub
parent a7282fed40
commit d7fe9d6bcc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
86 changed files with 4389 additions and 584 deletions

View File

@ -21,7 +21,11 @@ function fakeChild(): FakeChild {
resume: ReturnType<typeof vi.fn>
}
stderr: EventEmitter
stdin: EventEmitter & { write: ReturnType<typeof vi.fn> }
stdin: EventEmitter & {
write: ReturnType<typeof vi.fn>
destroyed: boolean
writable: boolean
}
kill: ReturnType<typeof vi.fn>
}
child.stdout = Object.assign(new EventEmitter(), {
@ -29,7 +33,18 @@ function fakeChild(): FakeChild {
resume: vi.fn()
})
child.stderr = new EventEmitter()
child.stdin = Object.assign(new EventEmitter(), { write: vi.fn(() => true) })
const stdin = new EventEmitter() as EventEmitter & {
write: ReturnType<typeof vi.fn>
destroyed: boolean
writable: boolean
}
stdin.write = vi.fn((_data, onWritten?: (error?: Error | null) => void) => {
onWritten?.(null)
return true
})
stdin.destroyed = false
stdin.writable = true
child.stdin = stdin
child.kill = vi.fn()
return child as unknown as FakeChild
}
@ -166,7 +181,7 @@ describe('waitForWslRelaySentinel', () => {
const drain = vi.fn()
const writeMock = child.stdin.write as ReturnType<typeof vi.fn>
writeMock.mockImplementation((_data, onWritten) => {
onWritten(null)
onWritten?.(null)
return false
})
const promise = waitForWslRelaySentinel(child)
@ -180,4 +195,16 @@ describe('waitForWslRelaySentinel', () => {
expect(drain).toHaveBeenCalledOnce()
expect(transport.supportsWriteSettlement).toBe(true)
})
it('swallows async stdin EPIPE after the guest dies so it is not uncaught', async () => {
const child = fakeChild()
const promise = waitForWslRelaySentinel(child)
emitStdout(child, RELAY_SENTINEL)
await promise
// Real pipes emit EPIPE on the stream after a failed write; without a
// listener Node surfaces that as an unhandled exception (CI flake).
expect(() => {
child.stdin.emit('error', Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }))
}).not.toThrow()
})
})

View File

@ -88,6 +88,12 @@ export function waitForWslRelaySentinel(
child.on('error', (err) =>
fail({ kind: 'exit', code: null, stderr: `${stderrOutput}\n${err.message}` })
)
// Why: stdin.write() surfaces EPIPE asynchronously when the guest dies
// mid-flight (try/catch only covers the sync throw). Without a listener
// Node treats that as an uncaught exception and fails the whole process.
child.stdin.on('error', () => {
// Channel already closing — mux close handling takes over.
})
child.on('exit', (code) => {
exitCode = code
})

View File

@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { abandonRemoteSessionScanOnCancel } from './ai-vault-scan-cancellation'
describe('abandonRemoteSessionScanOnCancel', () => {
it('stops waiting on a scan that has no transport-level abort', async () => {
const controller = new AbortController()
const pending = abandonRemoteSessionScanOnCancel(
new Promise<string>(() => {}),
controller.signal
)
controller.abort()
await expect(pending).rejects.toMatchObject({ name: 'AbortError' })
})
it('rejects immediately when the caller already cancelled', async () => {
const controller = new AbortController()
controller.abort()
await expect(
abandonRemoteSessionScanOnCancel(Promise.resolve('scanned'), controller.signal)
).rejects.toMatchObject({ name: 'AbortError' })
})
it('passes the scan result through without a signal', async () => {
await expect(abandonRemoteSessionScanOnCancel(Promise.resolve('scanned'))).resolves.toBe(
'scanned'
)
})
})

View File

@ -0,0 +1,47 @@
import { AI_VAULT_SCAN_CANCELLED_MESSAGE } from '../../shared/ai-vault-types'
export function throwIfAiVaultScanCancelled(signal?: AbortSignal): void {
if (!signal?.aborted) {
return
}
throw createAiVaultScanCancelledError()
}
// Why: the runtime RPC transport has no abort hook, so cancellation can only
// stop the caller waiting on it — the in-flight request settles on its own
// timeout instead of holding an 'all'-scope merge open after every waiter left.
export function abandonRemoteSessionScanOnCancel<T>(
promise: Promise<T>,
signal?: AbortSignal
): Promise<T> {
if (!signal) {
return promise
}
return new Promise<T>((resolve, reject) => {
if (signal.aborted) {
// The scanner promise already exists; observe it so its later failure is
// not an unhandled rejection after this caller walked away.
void promise.catch(() => undefined)
reject(createAiVaultScanCancelledError())
return
}
const onAbort = (): void => reject(createAiVaultScanCancelledError())
signal.addEventListener('abort', onAbort, { once: true })
void promise.then(
(value) => {
signal.removeEventListener('abort', onAbort)
resolve(value)
},
(error: unknown) => {
signal.removeEventListener('abort', onAbort)
reject(error)
}
)
})
}
export function createAiVaultScanCancelledError(): Error {
const error = new Error(AI_VAULT_SCAN_CANCELLED_MESSAGE)
error.name = 'AbortError'
return error
}

View File

@ -0,0 +1,148 @@
import { describe, expect, it, vi } from 'vitest'
import { AiVaultScanCoordinator } from './ai-vault-scan-coordinator'
const EMPTY_RESULT = { sessions: [], issues: [], scannedAt: '2026-07-27T00:00:00.000Z' }
describe('AiVaultScanCoordinator', () => {
it('keeps a shared scan alive when only one caller cancels', async () => {
const coordinator = new AiVaultScanCoordinator()
let resolveScan: ((result: typeof EMPTY_RESULT) => void) | undefined
let sharedSignal: AbortSignal | undefined
const start = vi.fn(
(signal: AbortSignal) =>
new Promise<typeof EMPTY_RESULT>((resolve) => {
sharedSignal = signal
resolveScan = resolve
})
)
const controller = new AbortController()
const first = coordinator.run({ key: 'scope', signal: controller.signal, start })
const second = coordinator.run({ key: 'scope', start })
await Promise.resolve()
controller.abort()
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
expect(sharedSignal?.aborted).toBe(false)
resolveScan?.(EMPTY_RESULT)
await expect(second).resolves.toEqual(EMPTY_RESULT)
expect(start).toHaveBeenCalledTimes(1)
})
it('preempts a non-forced scan and re-joins its caller onto the forced scan', async () => {
const coordinator = new AiVaultScanCoordinator()
const signals: AbortSignal[] = []
let resolveForced: ((result: typeof EMPTY_RESULT) => void) | undefined
const start = vi.fn((signal: AbortSignal) => {
signals.push(signal)
return new Promise<typeof EMPTY_RESULT>((resolve) => {
if (signals.length === 1) {
signal.addEventListener('abort', () => resolve(EMPTY_RESULT), { once: true })
} else {
resolveForced = resolve
}
})
})
const first = coordinator.run({ key: 'scope', start })
await Promise.resolve()
const forced = coordinator.run({ key: 'scope', force: true, start })
const joined = coordinator.run({ key: 'scope', force: true, start })
await Promise.resolve()
expect(signals[0]?.aborted).toBe(true)
expect(start).toHaveBeenCalledTimes(2)
resolveForced?.(EMPTY_RESULT)
// The first caller never asked to cancel, so someone else's Refresh must
// hand it the replacement's result instead of a spurious cancellation.
await expect(Promise.all([first, forced, joined])).resolves.toEqual([
EMPTY_RESULT,
EMPTY_RESULT,
EMPTY_RESULT
])
})
it('keeps coalescing forced callers onto a forced scan that is still fresh', async () => {
vi.useFakeTimers()
try {
const coordinator = new AiVaultScanCoordinator()
let resolveScan: ((result: typeof EMPTY_RESULT) => void) | undefined
const start = vi.fn(
() =>
new Promise<typeof EMPTY_RESULT>((resolve) => {
resolveScan = resolve
})
)
const first = coordinator.run({ key: 'scope', force: true, start })
await Promise.resolve()
vi.advanceTimersByTime(4_999)
const second = coordinator.run({ key: 'scope', force: true, start })
await Promise.resolve()
expect(start).toHaveBeenCalledTimes(1)
resolveScan?.(EMPTY_RESULT)
// Both callers settle off the single shared scan instead of preempting it.
await expect(Promise.all([first, second])).resolves.toEqual([EMPTY_RESULT, EMPTY_RESULT])
} finally {
vi.useRealTimers()
}
})
it('lets a forced refresh preempt a forced scan that hung past the coalescing window', async () => {
vi.useFakeTimers()
try {
const coordinator = new AiVaultScanCoordinator()
const signals: AbortSignal[] = []
let resolveSecond: ((result: typeof EMPTY_RESULT) => void) | undefined
const start = vi.fn((signal: AbortSignal) => {
signals.push(signal)
return new Promise<typeof EMPTY_RESULT>((resolve) => {
if (signals.length > 1) {
resolveSecond = resolve
}
})
})
const stuck = coordinator.run({ key: 'scope', force: true, start })
await Promise.resolve()
vi.advanceTimersByTime(5_000)
const retry = coordinator.run({ key: 'scope', force: true, start })
await Promise.resolve()
expect(signals[0]?.aborted).toBe(true)
expect(start).toHaveBeenCalledTimes(2)
resolveSecond?.(EMPTY_RESULT)
// The caller stranded on the hung scan rides the replacement out rather
// than being told its own refresh was cancelled.
await expect(Promise.all([stuck, retry])).resolves.toEqual([EMPTY_RESULT, EMPTY_RESULT])
} finally {
vi.useRealTimers()
}
})
it('starts a fresh scan after every waiter cancels', async () => {
const coordinator = new AiVaultScanCoordinator()
const signals: AbortSignal[] = []
const start = vi.fn((signal: AbortSignal) => {
signals.push(signal)
if (signals.length > 1) {
return Promise.resolve(EMPTY_RESULT)
}
return new Promise<typeof EMPTY_RESULT>((resolve) => {
signal.addEventListener('abort', () => resolve(EMPTY_RESULT), { once: true })
})
})
const controller = new AbortController()
const first = coordinator.run({ key: 'scope', signal: controller.signal, start })
await Promise.resolve()
controller.abort()
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
const second = coordinator.run({ key: 'scope', start })
await expect(second).resolves.toEqual(EMPTY_RESULT)
expect(start).toHaveBeenCalledTimes(2)
expect(signals[0]?.aborted).toBe(true)
})
})

View File

@ -0,0 +1,155 @@
import {
AI_VAULT_SCAN_CANCELLED_MESSAGE,
type AiVaultListResult
} from '../../shared/ai-vault-types'
// Matches the renderer's forced-rescan throttle: forced callers that arrive
// inside this window share one scan, later ones may preempt a scan that hung.
const FORCED_SCAN_PREEMPT_AFTER_MS = 5_000
type ScanEntry = {
controller: AbortController
force: boolean
startedAt: number
promise: Promise<AiVaultListResult>
waiterCount: number
// The last waiter leaving must not abort a scan that already finished.
settled: boolean
// Set when a forced caller replaced this entry, so its waiters re-join the
// replacement instead of surfacing someone else's refresh as their own cancel.
preemptedBy: ScanEntry | null
}
export class AiVaultScanCoordinator {
private readonly entries = new Map<string, ScanEntry>()
run(args: {
key: string
force?: boolean
signal?: AbortSignal
start: (signal: AbortSignal) => Promise<AiVaultListResult>
}): Promise<AiVaultListResult> {
if (args.signal?.aborted) {
return Promise.reject(scanCancellationError())
}
let entry = this.entries.get(args.key)
const preempted = entry && args.force === true && canPreemptForForcedScan(entry) ? entry : null
if (preempted) {
this.removeEntry(args.key, preempted)
entry = undefined
}
if (!entry) {
entry = this.createEntry(args.key, args.force === true, args.start)
this.entries.set(args.key, entry)
}
if (preempted) {
// The replacement must be registered before the abort lands: waiters of
// the old scan re-join it synchronously from their abort listener.
preempted.preemptedBy = entry
preempted.controller.abort()
}
return this.attach(args.key, entry, args.signal)
}
private createEntry(
key: string,
force: boolean,
start: (signal: AbortSignal) => Promise<AiVaultListResult>
): ScanEntry {
const controller = new AbortController()
const entry: ScanEntry = {
controller,
force,
startedAt: Date.now(),
promise: Promise.resolve().then(() => {
if (controller.signal.aborted) {
throw scanCancellationError()
}
return start(controller.signal)
}),
waiterCount: 0,
settled: false,
preemptedBy: null
}
const onSettled = (): void => {
entry.settled = true
this.removeEntry(key, entry)
}
void entry.promise.then(onSettled, onSettled)
return entry
}
private attach(key: string, entry: ScanEntry, signal?: AbortSignal): Promise<AiVaultListResult> {
entry.waiterCount++
return new Promise((resolve, reject) => {
let attached = true
const detach = (): void => {
if (!attached) {
return
}
attached = false
signal?.removeEventListener('abort', onAbort)
entry.controller.signal.removeEventListener('abort', onAbort)
entry.waiterCount--
if (entry.waiterCount === 0 && !entry.settled && !entry.controller.signal.aborted) {
this.removeEntry(key, entry)
entry.controller.abort()
}
}
const onAbort = (): void => {
if (!attached) {
return
}
// Why: a forced refresh aborts the shared entry, but the other waiters
// never asked to cancel — rejecting them turns one window's Refresh into
// a cancelled multi-host merge somewhere else. Re-join the replacement.
const replacement = signal?.aborted ? null : entry.preemptedBy
detach()
if (!replacement) {
reject(scanCancellationError())
return
}
void this.attach(key, replacement, signal).then(resolve, reject)
}
signal?.addEventListener('abort', onAbort, { once: true })
entry.controller.signal.addEventListener('abort', onAbort, { once: true })
if (signal?.aborted || entry.controller.signal.aborted) {
onAbort()
return
}
void entry.promise.then(
(result) => {
if (attached) {
detach()
resolve(result)
}
},
(error) => {
if (attached) {
detach()
reject(error)
}
}
)
})
}
private removeEntry(key: string, entry: ScanEntry): void {
if (this.entries.get(key) === entry) {
this.entries.delete(key)
}
}
}
// Why: without this a forced scan that never settles (no inactivity timer on the
// legacy SSH file-stream reader, see #11364) makes every later Refresh join the
// dead promise, so the panel stays empty until the app restarts.
function canPreemptForForcedScan(entry: ScanEntry): boolean {
return !entry.force || Date.now() - entry.startedAt >= FORCED_SCAN_PREEMPT_AFTER_MS
}
function scanCancellationError(): Error {
const error = new Error(AI_VAULT_SCAN_CANCELLED_MESSAGE)
error.name = 'AbortError'
return error
}

View File

@ -3,6 +3,13 @@ import { scanAiVaultSessions } from './session-scanner'
import { getWslHomeAsync, listWslDistrosAsync } from '../wsl'
import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import { AiVaultScanCoordinator } from './ai-vault-scan-coordinator'
import {
aiVaultSessionDepthCovers,
requestedAiVaultSessionDepth,
truncateAiVaultListResult,
type AiVaultSessionDepth
} from '../../shared/ai-vault-session-depth'
// Why: ONE module owns the scan cache so the desktop IPC handler AND the runtime
// RPC method share a single cache instance — opening the desktop panel and the
@ -19,65 +26,77 @@ export type AiVaultSessionSources = {
type CachedAiVaultList = {
key: string
depth: AiVaultSessionDepth
result: AiVaultListResult
expiresAt: number
}
let cachedList: CachedAiVaultList | null = null
let inflightList: Promise<AiVaultListResult> | null = null
let inflightKey: string | null = null
let scanCoordinator = new AiVaultScanCoordinator()
let sources: AiVaultSessionSources = {}
export function configureAiVaultSessionSources(next: AiVaultSessionSources): void {
sources = next
}
export async function listAiVaultSessions(args?: AiVaultListArgs): Promise<AiVaultListResult> {
export async function listAiVaultSessions(
args?: AiVaultListArgs,
options: { signal?: AbortSignal } = {}
): Promise<AiVaultListResult> {
// 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 ?? []
})
const key = JSON.stringify({ scopePaths: [...new Set(args?.scopePaths ?? [])].sort() })
const depth = requestedAiVaultSessionDepth(args)
const scanKey = JSON.stringify({ key, depth })
const now = Date.now()
// Why: opening this panel repeatedly should not re-parse hundreds of JSONL
// transcripts; explicit refreshes bypass the cache but not an active scan.
if (args?.force !== true && cachedList?.key === key && cachedList.expiresAt > now) {
return cachedList.result
// transcripts; explicit refreshes bypass the cache and preempt stale scans.
if (
args?.force !== true &&
cachedList?.key === key &&
cachedList.expiresAt > now &&
aiVaultSessionDepthCovers(cachedList.depth, depth)
) {
return truncateAiVaultListResult(cachedList.result, depth, args?.scopePaths)
}
if (inflightList && inflightKey === key) {
return inflightList
}
inflightKey = key
const additionalCodexSessionsDirs =
sources.getAdditionalCodexHomePaths?.().map((homePath) => join(homePath, 'sessions')) ?? []
inflightList = (async () =>
scanAiVaultSessions({
limit: args?.limit,
scopePaths: args?.scopePaths,
additionalCodexSessionsDirs,
wslHomeDirs: await getAiVaultWslHomeDirs(),
// Why: this scan is always host-local; callers addressing this host by a
// runtime id get the result restamped at the RPC edge, never rescanned.
executionHostId: LOCAL_EXECUTION_HOST_ID
}))()
.then((result) => {
cachedList = {
key,
result,
expiresAt: Date.now() + AI_VAULT_CACHE_TTL_MS
return scanCoordinator.run({
key: scanKey,
force: args?.force,
signal: options.signal,
start: async (scanSignal) => {
const additionalCodexSessionsDirs =
sources.getAdditionalCodexHomePaths?.().map((homePath) => join(homePath, 'sessions')) ?? []
const result = await scanAiVaultSessions({
limit: args?.limit,
unlimited: args?.unlimited,
scopePaths: args?.scopePaths,
additionalCodexSessionsDirs,
wslHomeDirs: await getAiVaultWslHomeDirs(),
// Cancelled/superseded callers must stop the parse, not just stop
// waiting for it — the scan owns hundreds of transcript reads.
signal: scanSignal,
// Why: this scan is always host-local; callers addressing this host by a
// runtime id get the result restamped at the RPC edge, never rescanned.
executionHostId: LOCAL_EXECUTION_HOST_ID
})
if (!scanSignal.aborted) {
const current = cachedList
if (
args?.force === true ||
current?.key !== key ||
current.expiresAt <= Date.now() ||
!aiVaultSessionDepthCovers(current.depth, depth)
) {
cachedList = {
key,
depth,
result,
expiresAt: Date.now() + AI_VAULT_CACHE_TTL_MS
}
}
}
return result
})
.finally(() => {
// Only clear tracking if it still refers to this request: a concurrent
// different-key scan may have replaced it and must stay dedupable.
if (inflightKey === key) {
inflightKey = null
inflightList = null
}
})
return inflightList
}
})
}
// Exported for the subagent-transcript IPC path, which validates
@ -95,7 +114,6 @@ export async function getAiVaultWslHomeDirs(): Promise<string[]> {
// Why: tests reset module-level cache/source state between cases.
export function resetAiVaultSessionListCacheForTests(): void {
cachedList = null
inflightList = null
inflightKey = null
scanCoordinator = new AiVaultScanCoordinator()
sources = {}
}

View File

@ -0,0 +1,47 @@
import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
const REMOTE_CONTENT_YIELD_LINE_COUNT = 200
const REMOTE_CONTENT_YIELD_CHAR_COUNT = 256 * 1024
/** Splits transcript content into lines. Without a signal there is nothing to
* observe, so it splits in one pass; with one it yields to the event loop so a
* cancelled scan stops mid-transcript instead of parsing megabytes for a caller
* that already left. */
export function remoteSessionContentLines(
content: string,
signal?: AbortSignal
): Iterable<string> | AsyncIterable<string> {
return signal ? cancellableContentLines(content, signal) : content.split(/\r?\n/)
}
async function* cancellableContentLines(
content: string,
signal: AbortSignal
): AsyncGenerator<string> {
// Content below one yield window would otherwise never observe cancellation.
throwIfAiVaultScanCancelled(signal)
let lineStart = 0
let yieldStart = 0
let linesSinceYield = 0
for (let index = 0; index <= content.length; index++) {
if (index < content.length && content.charCodeAt(index) !== 10) {
continue
}
const lineEnd = index > lineStart && content.charCodeAt(index - 1) === 13 ? index - 1 : index
yield content.slice(lineStart, lineEnd)
lineStart = index + 1
linesSinceYield++
if (
linesSinceYield >= REMOTE_CONTENT_YIELD_LINE_COUNT ||
index - yieldStart >= REMOTE_CONTENT_YIELD_CHAR_COUNT
) {
throwIfAiVaultScanCancelled(signal)
await yieldToEventLoop()
throwIfAiVaultScanCancelled(signal)
linesSinceYield = 0
yieldStart = index
}
}
}

View File

@ -1,19 +1,24 @@
import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { FileStat, IFilesystemProvider } from '../providers/types'
import type { FileStat } from '../providers/types'
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
import { recordRemoteSessionScanIssue } from './remote-session-scan-issues'
import type { RemoteSessionFilesystemProvider } from './remote-session-scanner-types'
import type { FileWithMtime } from './session-scanner-types'
import { errorMessage } from './session-scanner-values'
export async function statRemoteSessionFile(
provider: IFilesystemProvider,
provider: RemoteSessionFilesystemProvider,
path: string,
agent: AiVaultAgent,
executionHostId: ExecutionHostId,
issues: AiVaultScanIssue[],
options?: { missingIsExpected?: boolean }
options?: { missingIsExpected?: boolean; signal?: AbortSignal }
): Promise<FileWithMtime | null> {
try {
throwIfAiVaultScanCancelled(options?.signal)
const stat = await provider.stat(path)
throwIfAiVaultScanCancelled(options?.signal)
const mtimeMs = remoteSessionMtimeMs(stat)
return {
path,
@ -25,8 +30,14 @@ export async function statRemoteSessionFile(
...(typeof stat.nlink === 'number' ? { nlink: stat.nlink } : {})
}
} catch (error) {
throwIfAiVaultScanCancelled(options?.signal)
if (!options?.missingIsExpected || !isMissingRemoteSessionPathError(error)) {
issues.push({ executionHostId, agent, path, message: errorMessage(error) })
recordRemoteSessionScanIssue(issues, {
executionHostId,
agent,
path,
message: errorMessage(error)
})
}
return null
}

View File

@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { mapRemoteScanBatches } from './remote-session-scan-batching'
describe('mapRemoteScanBatches', () => {
it('observes an abort that lands while the final batch yields', async () => {
const controller = new AbortController()
await expect(
mapRemoteScanBatches(
['a', 'b'],
2,
async (item) => {
controller.abort()
return item
},
controller.signal
)
).rejects.toMatchObject({ name: 'AbortError' })
})
it('observes an abort for empty inputs that never enter the loop', async () => {
const controller = new AbortController()
controller.abort()
await expect(
mapRemoteScanBatches([], 8, async (item) => item, controller.signal)
).rejects.toMatchObject({ name: 'AbortError' })
})
it('returns every batch result when nothing cancels', async () => {
expect(await mapRemoteScanBatches([1, 2, 3], 2, async (item) => item * 2)).toEqual([2, 4, 6])
})
})

View File

@ -0,0 +1,22 @@
import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
// Batching bounds concurrent remote round trips; the yield between batches keeps
// the process responsive, and the batch boundary is where cancellation lands.
export async function mapRemoteScanBatches<T, U>(
items: readonly T[],
concurrency: number,
mapper: (item: T) => Promise<U>,
signal?: AbortSignal
): Promise<U[]> {
const results: U[] = []
for (let index = 0; index < items.length; index += concurrency) {
throwIfAiVaultScanCancelled(signal)
results.push(...(await Promise.all(items.slice(index, index + concurrency).map(mapper))))
await yieldToEventLoop()
}
// An abort can land while the last batch yields, and empty inputs never enter
// the loop at all — observe it here so neither path returns as a success.
throwIfAiVaultScanCancelled(signal)
return results
}

View File

@ -0,0 +1,47 @@
import type { RemoteSessionFilesystemProvider } from './remote-session-scanner-types'
// Why: discovery batches (8 sources) each stat in batches of 8, and parse
// batches read whole transcripts — nested fan-out put ~64 filesystem round
// trips on one SSH mux or relay event loop at once, blowing the scan budget and
// starving pty/fs/hook traffic. Every scan shares one in-flight ceiling.
const REMOTE_SCAN_FILESYSTEM_CONCURRENCY = 8
/** Wraps a provider so all of one scan's filesystem calls share a single
* in-flight ceiling, regardless of how the callers nest their batches. */
export function limitRemoteScanFilesystemConcurrency(
provider: RemoteSessionFilesystemProvider,
maxInFlight: number = REMOTE_SCAN_FILESYSTEM_CONCURRENCY
): RemoteSessionFilesystemProvider {
const gate = createConcurrencyGate(maxInFlight)
return {
readDir: (dirPath) => gate(() => provider.readDir(dirPath)),
readFile: (filePath) => gate(() => provider.readFile(filePath)),
stat: (filePath) => gate(() => provider.stat(filePath))
}
}
function createConcurrencyGate(maxInFlight: number): <T>(run: () => Promise<T>) => Promise<T> {
const limit = Math.max(1, Math.floor(maxInFlight))
const waiting: (() => void)[] = []
let inFlight = 0
return async <T>(run: () => Promise<T>): Promise<T> => {
if (inFlight < limit) {
inFlight++
} else {
// The releasing call hands its slot over directly, so a queued caller can
// never race a fresh one into an over-limit slot.
await new Promise<void>((resolve) => waiting.push(resolve))
}
try {
return await run()
} finally {
const next = waiting.shift()
if (next) {
next()
} else {
inFlight--
}
}
}
}

View File

@ -0,0 +1,24 @@
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
const REMOTE_SCAN_ISSUE_LIMIT = 500
export function recordRemoteSessionScanIssue(
issues: AiVaultScanIssue[],
issue: AiVaultScanIssue
): void {
if (issues.length < REMOTE_SCAN_ISSUE_LIMIT - 1) {
issues.push(issue)
return
}
if (issues.length === REMOTE_SCAN_ISSUE_LIMIT - 1) {
// Kinded: this row is a scan notice, not a skipped transcript — the panel
// counts unkinded issues as skipped transcript files.
issues.push({
executionHostId: issue.executionHostId,
agent: issue.agent,
kind: 'notice',
path: 'Agent Session History scan',
message: 'Additional scan issues were omitted.'
})
}
}

View File

@ -1,39 +1,50 @@
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'
import { remoteSessionContentLines } from './remote-session-content-lines'
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
import type { RemoteSessionFilesystemProvider } from './remote-session-scanner-types'
const CODEX_SESSION_INDEX_FILE = 'session_index.jsonl'
export async function remoteCodexIndexTitles(args: {
provider: IFilesystemProvider
provider: RemoteSessionFilesystemProvider
codexHome: string
hostPlatform: RemoteHostPlatform
titleCaches: Map<string, Promise<Map<string, string>>>
signal?: AbortSignal
}): Promise<Map<string, string>> {
const cached = args.titleCaches.get(args.codexHome)
if (cached) {
return cached
}
const pending = readRemoteCodexIndexTitles(args.provider, args.codexHome, args.hostPlatform)
const pending = readRemoteCodexIndexTitles(
args.provider,
args.codexHome,
args.hostPlatform,
args.signal
)
args.titleCaches.set(args.codexHome, pending)
return pending
}
async function readRemoteCodexIndexTitles(
provider: IFilesystemProvider,
provider: RemoteSessionFilesystemProvider,
codexHome: string,
hostPlatform: RemoteHostPlatform
hostPlatform: RemoteHostPlatform,
signal?: AbortSignal
): Promise<Map<string, string>> {
const titleBySessionId = new Map<string, string>()
try {
throwIfAiVaultScanCancelled(signal)
const { content, isBinary } = await provider.readFile(
joinRemotePath(hostPlatform, codexHome, CODEX_SESSION_INDEX_FILE)
)
throwIfAiVaultScanCancelled(signal)
if (isBinary) {
return titleBySessionId
}
for (const line of content.split(/\r?\n/)) {
for await (const line of remoteSessionContentLines(content, signal)) {
const record = parseJsonObject(line)
if (!record) {
continue
@ -45,6 +56,7 @@ async function readRemoteCodexIndexTitles(
}
}
} catch {
throwIfAiVaultScanCancelled(signal)
// Codex indexes are opportunistic; raw transcripts remain sufficient.
}
return titleBySessionId

View File

@ -6,6 +6,9 @@ import { isMissingRemoteSessionPathError, statRemoteSessionFile } from './remote
import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts'
import type { FileWithMtime } from './session-scanner-types'
import { errorMessage } from './session-scanner-values'
import { mapRemoteScanBatches } from './remote-session-scan-batching'
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
import { recordRemoteSessionScanIssue } from './remote-session-scan-issues'
import type {
RemoteScannerContext,
RemoteSessionCandidate,
@ -26,15 +29,22 @@ export async function discoverRemoteSourceCandidates(args: {
? partitionSubagentTranscriptPaths(walked)
: null
const paths = partition ? partition.sessionFilePaths : walked
const files = await mapDiscoveryConcurrently(paths, (path) =>
statRemoteSessionFile(
args.context.provider,
path,
args.source.agent,
args.context.executionHostId,
args.issues,
{ missingIsExpected: Boolean(args.source.fixedChildFileSegments) }
)
const files = await mapRemoteScanBatches(
paths,
REMOTE_DISCOVERY_CONCURRENCY,
(path) =>
statRemoteSessionFile(
args.context.provider,
path,
args.source.agent,
args.context.executionHostId,
args.issues,
{
missingIsExpected: Boolean(args.source.fixedChildFileSegments),
signal: args.context.signal
}
),
args.context.signal
)
return files
.filter((file): file is FileWithMtime => Boolean(file))
@ -50,10 +60,12 @@ async function listRemoteFixedChildFiles(
context: RemoteScannerContext,
issues: AiVaultScanIssue[]
): Promise<string[]> {
throwIfAiVaultScanCancelled(context.signal)
let entries
try {
entries = await context.provider.readDir(source.rootDir)
} catch (err) {
throwIfAiVaultScanCancelled(context.signal)
recordRemoteDirectoryIssue(source, context.executionHostId, issues, source.rootDir, err)
return []
}
@ -73,10 +85,12 @@ async function walkRemoteSessionFiles(
dirPath = source.rootDir,
depth = 0
): Promise<string[]> {
throwIfAiVaultScanCancelled(context.signal)
let entries
try {
entries = await context.provider.readDir(dirPath)
} catch (err) {
throwIfAiVaultScanCancelled(context.signal)
recordRemoteDirectoryIssue(source, context.executionHostId, issues, dirPath, err)
return []
}
@ -84,6 +98,7 @@ async function walkRemoteSessionFiles(
const extensions = new Set(source.extensions)
const files: string[] = []
for (const entry of entries) {
throwIfAiVaultScanCancelled(context.signal)
const fullPath = joinRemotePath(context.hostPlatform, dirPath, entry.name)
if (
entry.isDirectory &&
@ -112,18 +127,12 @@ function recordRemoteDirectoryIssue(
err: unknown
): void {
if (!isMissingRemoteSessionPathError(err)) {
issues.push({ executionHostId, agent: source.agent, path, message: errorMessage(err) })
recordRemoteSessionScanIssue(issues, {
executionHostId,
agent: source.agent,
kind: 'host',
path,
message: errorMessage(err)
})
}
}
async function mapDiscoveryConcurrently<T, U>(
items: readonly T[],
mapper: (item: T) => Promise<U>
): Promise<U[]> {
const results: U[] = []
for (let index = 0; index < items.length; index += REMOTE_DISCOVERY_CONCURRENCY) {
const batch = items.slice(index, index + REMOTE_DISCOVERY_CONCURRENCY)
results.push(...(await Promise.all(batch.map(mapper))))
}
return results
}

View File

@ -25,7 +25,9 @@ type RemoteContentParser = (
file: FileWithMtime,
content: string,
platform: NodeJS.Platform,
options: RemoteParserOptions
options: RemoteParserOptions,
// Line-based parsers iterate cancellably; whole-document parsers ignore it.
signal?: AbortSignal
) => Promise<AiVaultSession | null> | AiVaultSession | null
export function remoteSessionSources(
@ -126,7 +128,8 @@ function remoteAntigravitySource(
file,
content,
context.hostPlatform.os,
parserOptions(context)
parserOptions(context),
context.signal
)
return session ? context.antigravityWorkspaceResolver.enrich(session, historyPath) : null
}
@ -150,7 +153,9 @@ function source(
filePredicate,
directoryPredicate,
parse: (file, content, context) =>
Promise.resolve(parseContent(file, content, context.hostPlatform.os, parserOptions(context)))
Promise.resolve(
parseContent(file, content, context.hostPlatform.os, parserOptions(context), context.signal)
)
}
}
@ -193,13 +198,15 @@ function remoteCodexSources(
codexHome,
executionHostId: context.executionHostId,
executionHostPlatform: context.hostPlatform.os,
signal: context.signal,
readIndexedTitle: async (sessionId) =>
(
await remoteCodexIndexTitles({
provider: context.provider,
codexHome,
hostPlatform,
titleCaches: context.titleCaches
titleCaches: context.titleCaches,
signal: context.signal
})
).get(sessionId) ?? null
})
@ -233,27 +240,30 @@ function piParser(
file: FileWithMtime,
content: string,
platform: NodeJS.Platform,
options: RemoteParserOptions
options: RemoteParserOptions,
signal?: AbortSignal
): Promise<AiVaultSession | null> {
return parseMessageGraphSessionContent('pi', file, content, platform, options)
return parseMessageGraphSessionContent('pi', file, content, platform, options, signal)
}
function ompParser(
file: FileWithMtime,
content: string,
platform: NodeJS.Platform,
options: RemoteParserOptions
options: RemoteParserOptions,
signal?: AbortSignal
): Promise<AiVaultSession | null> {
return parseMessageGraphSessionContent('omp', file, content, platform, options)
return parseMessageGraphSessionContent('omp', file, content, platform, options, signal)
}
function openClawParser(
file: FileWithMtime,
content: string,
platform: NodeJS.Platform,
options: RemoteParserOptions
options: RemoteParserOptions,
signal?: AbortSignal
): Promise<AiVaultSession | null> {
return parseMessageGraphSessionContent('openclaw', file, content, platform, options)
return parseMessageGraphSessionContent('openclaw', file, content, platform, options, signal)
}
function remotePathSegments(path: string): string[] {

View File

@ -6,13 +6,19 @@ import type { FileWithMtime } from './session-scanner-types'
import type { AntigravityWorkspaceResolver } from './session-scanner-antigravity-history'
export type RemoteScannerContext = {
provider: IFilesystemProvider
provider: RemoteSessionFilesystemProvider
executionHostId: ExecutionHostId
hostPlatform: RemoteHostPlatform
signal?: AbortSignal
titleCaches: Map<string, Promise<Map<string, string>>>
antigravityWorkspaceResolver: AntigravityWorkspaceResolver
}
export type RemoteSessionFilesystemProvider = Pick<
IFilesystemProvider,
'readDir' | 'readFile' | 'stat'
>
export type RemoteParserOptions = {
executionHostId: ExecutionHostId
executionHostPlatform: NodeJS.Platform

View File

@ -153,7 +153,9 @@ describe('scanRemoteAiVaultSessions', () => {
provider,
executionHostId: 'ssh:dev-box',
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64')
hostPlatform: getRemoteHostPlatform('linux-x64'),
limit: 1,
unlimited: true
})
expect(result.issues).toEqual([])
@ -404,11 +406,13 @@ describe('scanRemoteAiVaultSessions', () => {
expect.arrayContaining([
expect.objectContaining({
agent: 'antigravity',
kind: 'host',
path: brainDir,
message: expect.stringContaining('EACCES')
}),
expect.objectContaining({
agent: 'claude',
kind: 'host',
path: claudeProjectDir,
message: expect.stringContaining('ECONNRESET')
})
@ -706,6 +710,93 @@ describe('scanRemoteAiVaultSessions', () => {
'scoped-session'
])
})
it('keeps looking past newer out-of-scope candidates during scoped backfill', async () => {
const provider = new MemoryRemoteProvider()
for (const [sessionId, mtimeMs, hour] of [
['other-newest', 50, '05'],
['other-newer', 40, '04']
] as const) {
provider.addFile(
`/home/ada/.codex/sessions/${sessionId}.jsonl`,
codexTranscript({
sessionId,
title: sessionId,
cwd: '/home/ada/other',
timestamp: `2026-07-04T${hour}:00:00.000Z`
}),
mtimeMs
)
}
provider.addFile(
'/home/ada/.codex/sessions/scoped.jsonl',
codexTranscript({
sessionId: 'scoped-session',
title: 'Scoped workspace',
cwd: '/home/ada/repo',
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-newest',
'scoped-session'
])
})
it('caps scoped backfill at the requested limit', 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
)
for (const [sessionId, mtimeMs] of [
['newer-scoped', 30],
['older-scoped', 20]
] as const) {
provider.addFile(
`/home/ada/.codex/sessions/${sessionId}.jsonl`,
codexTranscript({
sessionId,
title: sessionId,
cwd: '/home/ada/repo',
timestamp: `2026-07-04T0${mtimeMs / 10}:00:00.000Z`
}),
mtimeMs
)
}
const result = await scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:dev-box',
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64'),
limit: 1,
scopePaths: ['/home/ada/repo']
})
expect(result.sessions.map((session) => session.sessionId)).toEqual([
'other-session',
'newer-scoped'
])
})
})
function codexTranscript(args: {

View File

@ -5,7 +5,7 @@ import type {
} from '../../shared/ai-vault-types'
import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { IFilesystemProvider } from '../providers/types'
import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
import {
codexRolloutHardlinkIdentity,
@ -14,44 +14,71 @@ import {
} from './codex-session-root-dedup'
import { discoverRemoteSourceCandidates } from './remote-session-scanner-discovery'
import { remoteSessionSources } from './remote-session-scanner-sources'
import type { RemoteScannerContext, RemoteSessionCandidate } from './remote-session-scanner-types'
import type {
RemoteScannerContext,
RemoteSessionCandidate,
RemoteSessionFilesystemProvider
} from './remote-session-scanner-types'
import { sessionSortTime } from './session-scanner-accumulator'
import { createAntigravityWorkspaceResolver } from './session-scanner-antigravity-history'
import { errorMessage } from './session-scanner-values'
import { mapRemoteScanBatches } from './remote-session-scan-batching'
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
import { recordRemoteSessionScanIssue } from './remote-session-scan-issues'
import { limitRemoteScanFilesystemConcurrency } from './remote-session-scan-concurrency'
import { aiVaultScanLimit } from '../../shared/ai-vault-session-depth'
const DEFAULT_REMOTE_SCAN_LIMIT = 1000
const REMOTE_SCAN_CONCURRENCY = 8
const REMOTE_SCOPE_PARSE_LIMIT = 2000
const REMOTE_PARSE_CANDIDATE_MULTIPLIER = 2
// Remote scope membership is only known after a transcript is read, so the scope
// backfill carries its own ceiling on remote reads instead of borrowing the
// recency cap — under the cap, newer out-of-scope files ate the scope budget and
// silently dropped older in-scope sessions the scope contract guarantees.
const REMOTE_SCOPE_PARSE_CANDIDATE_LIMIT = 1000
export async function scanRemoteAiVaultSessions(args: {
provider: IFilesystemProvider
provider: RemoteSessionFilesystemProvider
executionHostId: ExecutionHostId
remoteHome: string
hostPlatform: RemoteHostPlatform
limit?: number
unlimited?: boolean
scopePaths?: readonly string[]
signal?: AbortSignal
}): Promise<AiVaultListResult> {
const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DEFAULT_REMOTE_SCAN_LIMIT
throwIfAiVaultScanCancelled(args.signal)
const limit = aiVaultScanLimit(args)
const issues: AiVaultScanIssue[] = []
// One ceiling for the whole scan: discovery walks, stats and transcript reads
// all queue behind it instead of multiplying into a nested fan-out.
const provider = limitRemoteScanFilesystemConcurrency(args.provider)
const context: RemoteScannerContext = {
provider: args.provider,
provider,
executionHostId: args.executionHostId,
hostPlatform: args.hostPlatform,
signal: args.signal,
titleCaches: new Map(),
antigravityWorkspaceResolver: createAntigravityWorkspaceResolver(async (historyPath) => {
try {
const read = await args.provider.readFile(historyPath)
throwIfAiVaultScanCancelled(args.signal)
const read = await provider.readFile(historyPath)
throwIfAiVaultScanCancelled(args.signal)
return read.isBinary ? null : read.content
} catch {
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw error
}
return null
}
})
}
const candidates = dedupeCodexRolloutFileAliases(
(
await mapRemoteScanConcurrently(
await mapRemoteScanBatches(
remoteSessionSources(args.remoteHome, args.hostPlatform),
(source) => discoverRemoteSourceCandidates({ source, context, issues })
REMOTE_SCAN_CONCURRENCY,
(source) => discoverRemoteSourceCandidates({ source, context, issues }),
args.signal
)
)
.flat()
@ -64,7 +91,12 @@ export async function scanRemoteAiVaultSessions(args: {
}
)
const parsed = await parseRemoteSessionCandidates({ candidates, context, issues, limit })
const parsed = await parseRemoteSessionCandidates({
candidates: candidates.slice(0, limit * REMOTE_PARSE_CANDIDATE_MULTIPLIER),
context,
issues,
limit
})
const parsedSessions = dedupeCodexSessionsBySessionId(parsed.sessions)
const cappedSessions = parsedSessions
.sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
@ -78,12 +110,15 @@ export async function scanRemoteAiVaultSessions(args: {
context,
issues,
scopePaths,
limit,
alreadyParsedFilePaths: parsed.parsedFilePaths
})
const scopeSessions = dedupeCodexSessionsBySessionId([
...parsedScopeSessions,
...extraScopeSessions
])
.sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
.slice(0, limit)
return {
sessions: mergeRemoteSessions(cappedSessions, scopeSessions),
@ -107,19 +142,27 @@ async function parseRemoteSessionCandidates(args: {
break
}
const batch = args.candidates.slice(index, index + REMOTE_SCAN_CONCURRENCY)
const remaining = args.candidates.length - index
const needed = Math.max(args.limit - sessions.length, 1)
const batchSize = Math.min(REMOTE_SCAN_CONCURRENCY, needed, remaining)
const batch = args.candidates.slice(index, index + batchSize)
for (const candidate of batch) {
parsedFilePaths.add(candidate.file.path)
}
throwIfAiVaultScanCancelled(args.context.signal)
const results = await Promise.all(
batch.map((candidate) => parseRemoteSessionCandidate(candidate, args.context, args.issues))
)
sessions.push(...results.filter(isAiVaultSession))
const uniqueSessions = dedupeCodexSessionsBySessionId(sessions)
sessions.splice(0, sessions.length, ...uniqueSessions)
index += batch.length
index += batchSize
await yieldToEventLoop()
}
// The loop can terminate on the yield after its final batch, so re-check
// rather than letting a cancelled scan return a partial parse as a success.
throwIfAiVaultScanCancelled(args.context.signal)
return { sessions, parsedFilePaths }
}
@ -128,21 +171,29 @@ async function scanRemoteInScopeSessions(args: {
context: RemoteScannerContext
issues: AiVaultScanIssue[]
scopePaths: readonly string[]
limit: number
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 candidates = args.candidates.filter(
(candidate) => !args.alreadyParsedFilePaths.has(candidate.file.path)
)
const bound = Math.min(candidates.length, REMOTE_SCOPE_PARSE_CANDIDATE_LIMIT)
const sessions: AiVaultSession[] = []
let index = 0
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))
// Keep reading newest-first until the scope has its requested number of
// sessions; out-of-scope candidates no longer end the search.
while (index < bound && sessions.length < args.limit) {
const batchEnd = Math.min(index + REMOTE_SCAN_CONCURRENCY, bound)
const results = await mapRemoteScanBatches(
candidates.slice(index, batchEnd),
REMOTE_SCAN_CONCURRENCY,
(candidate) => parseRemoteSessionCandidate(candidate, args.context, args.issues),
args.context.signal
)
sessions.push(
...results.filter(
@ -150,6 +201,17 @@ async function scanRemoteInScopeSessions(args: {
isAiVaultSession(session) && isRemoteSessionInScope(session, args.scopePaths)
)
)
index = batchEnd
}
if (index < candidates.length && sessions.length < args.limit) {
recordRemoteSessionScanIssue(args.issues, {
executionHostId: args.context.executionHostId,
agent: 'codex',
kind: 'scope',
path: 'Agent Session History scan',
message: `Only the ${REMOTE_SCOPE_PARSE_CANDIDATE_LIMIT} most recent remote transcripts were checked for this workspace; older sessions may be missing.`
})
}
return sessions
@ -161,11 +223,14 @@ async function parseRemoteSessionCandidate(
issues: AiVaultScanIssue[]
): Promise<AiVaultSession | null> {
try {
throwIfAiVaultScanCancelled(context.signal)
const read = await context.provider.readFile(candidate.file.path)
throwIfAiVaultScanCancelled(context.signal)
if (read.isBinary) {
return null
}
const session = await candidate.source.parse(candidate.file, read.content, context)
throwIfAiVaultScanCancelled(context.signal)
// Mirror the local rule: every session carries its sibling subagent
// transcript count (row badge; recoverable signal at zero turns). The
// walk listing supplies it — the parser can't readdir a remote disk.
@ -175,7 +240,8 @@ async function parseRemoteSessionCandidate(
}
return session
} catch (err) {
issues.push({
throwIfAiVaultScanCancelled(context.signal)
recordRemoteSessionScanIssue(issues, {
executionHostId: context.executionHostId,
agent: candidate.source.agent,
path: candidate.file.path,
@ -232,15 +298,3 @@ function canStopParsingRemoteSessions(
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
}

View File

@ -68,6 +68,21 @@ describe('runtime AI Vault session scanner', () => {
)
})
it('surfaces project scope truncation from the runtime transport bound', async () => {
const scopePaths = Array.from({ length: 80 }, (_, index) => `/srv/repo-${index}`)
const scanResult = await scanRuntimeAiVaultSessions('/user-data', 'env-1', {
scopePaths
})
expect(scanResult.issues).toContainEqual(
expect.objectContaining({
kind: 'scope',
message: expect.stringContaining('first 64 project paths')
})
)
})
it('stamps sessions and issues returned for a different execution host', async () => {
mocks.callRuntimeEnvironment.mockResolvedValueOnce({
ok: true,

View File

@ -1,18 +1,18 @@
import { z } from 'zod'
import {
AI_VAULT_AGENTS,
AI_VAULT_SCOPE_PATHS_MAX_COUNT,
type AiVaultListArgs,
type AiVaultListResult,
type AiVaultSession
} from '../../shared/ai-vault-types'
import { normalizeExecutionHostId, toRuntimeExecutionHostId } from '../../shared/execution-host'
import { toRuntimeExecutionHostId } from '../../shared/execution-host'
import { listEnvironments } from '../../shared/runtime-environment-store'
import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing'
import type {
AiVaultPrepareSessionResumeArgs,
AiVaultPrepareSessionResumeResult
} from '../../shared/ai-vault-resume-preparation'
import { parseAiVaultListResult } from './session-list-result-validation'
export type RuntimeAiVaultHostInfo = {
environmentId: string
@ -23,90 +23,6 @@ export type RuntimeAiVaultScanOptions = {
timeoutMs?: number
}
const nodePlatformSchema = z.enum([
'aix',
'android',
'darwin',
'freebsd',
'haiku',
'linux',
'openbsd',
'sunos',
'win32',
'cygwin',
'netbsd'
] satisfies NodeJS.Platform[])
const aiVaultSessionPreviewMessageSchema = z.object({
role: z.enum(['user', 'assistant', 'system', 'tool', 'unknown']),
text: z.string(),
timestamp: z.string().nullable()
})
const executionHostIdSchema = z.string().transform((value, ctx) => {
const normalized = normalizeExecutionHostId(value)
if (normalized) {
return normalized
}
ctx.addIssue({
code: 'custom',
message: 'Invalid execution host id'
})
return z.NEVER
})
const aiVaultListResultSchema = z.object({
sessions: z.array(
z.object({
id: z.string(),
executionHostId: executionHostIdSchema,
executionHostPlatform: nodePlatformSchema.nullable().optional(),
agent: z.enum(AI_VAULT_AGENTS),
sessionId: z.string(),
title: z.string(),
cwd: z.string().nullable(),
branch: z.string().nullable(),
model: z.string().nullable(),
filePath: z.string(),
codexHome: z.string().nullable(),
createdAt: z.string().nullable(),
updatedAt: z.string().nullable(),
modifiedAt: z.string(),
messageCount: z.number(),
totalTokens: z.number(),
previewMessages: z.array(aiVaultSessionPreviewMessageSchema),
// Optional keeps paired hosts on older builds compatible.
previewMessagesTruncated: z.boolean().optional(),
firstUserPrompt: z.string().nullable().optional(),
lastUserPrompt: z.string().nullable().optional(),
// Default keeps remote hosts running an older build (no recoverable-signal
// fields) parseable; they simply report no recoverable-empty sessions.
queuedMessageCount: z.number().default(0),
subagentTranscriptCount: z.number().default(0),
resumeCommand: z.string(),
// The default keeps remote hosts running an older build (no subagent
// field) parseable; scanned top-level sessions carry null anyway.
subagent: z
.object({
parentSessionId: z.string(),
agentType: z.string().nullable(),
status: z.enum(['running', 'completed', 'failed', 'stopped']).nullable()
})
.nullable()
.default(null)
})
),
issues: z.array(
z.object({
executionHostId: executionHostIdSchema.optional(),
agent: z.enum(AI_VAULT_AGENTS),
path: z.string(),
message: z.string()
})
),
scannedAt: z.string()
})
// Why: zod strips unknown keys, so the repin home must be declared or the
// parent would silently drop it and resume under the wrong account's home.
const aiVaultPrepareSessionResumeResultSchema = z.object({
@ -136,6 +52,7 @@ export async function scanRuntimeAiVaultSessions(
'aiVault.listSessions',
{
limit: args.limit,
unlimited: args.unlimited,
force: args.force,
// Why: cap here so the set of scanned paths is explicit on this side —
// the RPC schema CLAMPS to the same bound anyway (older hosts had no
@ -147,17 +64,36 @@ export async function scanRuntimeAiVaultSessions(
options.timeoutMs
)
if (response.ok === true) {
const parsed = aiVaultListResultSchema.safeParse(response.result)
if (parsed.success) {
return withRuntimeExecutionHost(parsed.data, executionHostId)
try {
const result = withRuntimeExecutionHost(
parseAiVaultListResult(response.result),
executionHostId
)
if (!args.scopePaths || args.scopePaths.length <= AI_VAULT_SCOPE_PATHS_MAX_COUNT) {
return result
}
return {
...result,
issues: [
...result.issues,
{
executionHostId,
agent: 'codex',
kind: 'scope',
path: environmentId,
message: `Only the first ${AI_VAULT_SCOPE_PATHS_MAX_COUNT} project paths were scanned.`
}
]
}
} catch (error) {
return runtimeScanIssueResult({
executionHostId,
environmentId,
message: `Invalid aiVault.listSessions response: ${
error instanceof Error ? error.message : 'unexpected result shape'
}`
})
}
return runtimeScanIssueResult({
executionHostId,
environmentId,
message: `Invalid aiVault.listSessions response: ${
parsed.error.issues[0]?.message ?? 'unexpected result shape'
}`
})
}
return runtimeScanIssueResult({
executionHostId,
@ -224,6 +160,7 @@ function runtimeScanIssueResult(args: {
{
executionHostId: args.executionHostId,
agent: 'codex',
kind: 'host',
path: args.environmentId,
message: args.message
}

View File

@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest'
import { parseAiVaultListResult } from './session-list-result-validation'
describe('parseAiVaultListResult', () => {
it('keeps valid sessions when another wire entry is malformed', () => {
const parsed = parseAiVaultListResult({
sessions: [validSession(), { id: 42 }],
issues: [],
scannedAt: '2026-07-27T00:00:00.000Z'
})
expect(parsed.sessions).toHaveLength(1)
expect(parsed.sessions[0]?.sessionId).toBe('session-1')
expect(parsed.issues).toContainEqual(
expect.objectContaining({
path: 'aiVault.listSessions',
message: expect.stringContaining('Skipped 1 invalid')
})
)
})
it('preserves the optional session fields the panel renders', () => {
const parsed = parseAiVaultListResult({
sessions: [
{
...validSession(),
previewMessagesTruncated: true,
firstUserPrompt: 'first',
lastUserPrompt: 'last'
}
],
issues: [],
scannedAt: '2026-07-27T00:00:00.000Z'
})
expect(parsed.sessions[0]).toMatchObject({
previewMessagesTruncated: true,
firstUserPrompt: 'first',
lastUserPrompt: 'last'
})
})
it('rejects a malformed result envelope', () => {
expect(() => parseAiVaultListResult({ sessions: [] })).toThrow()
})
it('rejects a nonempty sessions array when every row is invalid', () => {
expect(() =>
parseAiVaultListResult({
sessions: [{ id: 42 }, null],
issues: [],
scannedAt: '2026-07-27T00:00:00.000Z'
})
).toThrow('all supplied Agent Session History sessions were invalid')
})
})
function validSession(): Record<string, unknown> {
return {
id: 'local:codex:session-1:/tmp/session-1.jsonl',
executionHostId: 'local',
executionHostPlatform: 'linux',
agent: 'codex',
sessionId: 'session-1',
title: 'Session one',
cwd: '/repo',
branch: null,
model: null,
filePath: '/tmp/session-1.jsonl',
codexHome: null,
createdAt: null,
updatedAt: null,
modifiedAt: '2026-07-27T00:00:00.000Z',
messageCount: 1,
totalTokens: 0,
previewMessages: [],
queuedMessageCount: 0,
subagentTranscriptCount: 0,
resumeCommand: 'codex resume session-1',
subagent: null
}
}

View File

@ -0,0 +1,108 @@
import { z } from 'zod'
import { AI_VAULT_AGENTS, type AiVaultListResult } from '../../shared/ai-vault-types'
import { normalizeExecutionHostId } from '../../shared/execution-host'
const nodePlatformSchema = z.enum([
'aix',
'android',
'darwin',
'freebsd',
'haiku',
'linux',
'openbsd',
'sunos',
'win32',
'cygwin',
'netbsd'
] satisfies NodeJS.Platform[])
const executionHostIdSchema = z.string().transform((value, ctx) => {
const normalized = normalizeExecutionHostId(value)
if (normalized) {
return normalized
}
ctx.addIssue({ code: 'custom', message: 'Invalid execution host id' })
return z.NEVER
})
const sessionPreviewMessageSchema = z.object({
role: z.enum(['user', 'assistant', 'system', 'tool', 'unknown']),
text: z.string(),
timestamp: z.string().nullable()
})
const aiVaultSessionSchema = z.object({
id: z.string(),
executionHostId: executionHostIdSchema,
executionHostPlatform: nodePlatformSchema.nullable().optional(),
agent: z.enum(AI_VAULT_AGENTS),
sessionId: z.string(),
title: z.string(),
cwd: z.string().nullable(),
branch: z.string().nullable(),
model: z.string().nullable(),
filePath: z.string(),
codexHome: z.string().nullable(),
createdAt: z.string().nullable(),
updatedAt: z.string().nullable(),
modifiedAt: z.string(),
messageCount: z.number(),
totalTokens: z.number(),
previewMessages: z.array(sessionPreviewMessageSchema),
previewMessagesTruncated: z.boolean().optional(),
firstUserPrompt: z.string().nullable().optional(),
lastUserPrompt: z.string().nullable().optional(),
queuedMessageCount: z.number().default(0),
subagentTranscriptCount: z.number().default(0),
resumeCommand: z.string(),
subagent: z
.object({
parentSessionId: z.string(),
agentType: z.string().nullable(),
status: z.enum(['running', 'completed', 'failed', 'stopped']).nullable()
})
.nullable()
.default(null)
})
const aiVaultScanIssueSchema = z.object({
executionHostId: executionHostIdSchema.optional(),
agent: z.enum(AI_VAULT_AGENTS),
kind: z.enum(['host', 'scope', 'notice']).optional(),
path: z.string(),
message: z.string()
})
const aiVaultListResultEnvelopeSchema = z.object({
sessions: z.array(z.unknown()),
issues: z.array(z.unknown()),
scannedAt: z.string()
})
export function parseAiVaultListResult(value: unknown): AiVaultListResult {
const envelope = aiVaultListResultEnvelopeSchema.safeParse(value)
if (!envelope.success) {
throw new Error(envelope.error.issues[0]?.message ?? 'unexpected result shape')
}
const sessions = envelope.data.sessions.flatMap((session) => {
const parsed = aiVaultSessionSchema.safeParse(session)
return parsed.success ? [parsed.data] : []
})
if (envelope.data.sessions.length > 0 && sessions.length === 0) {
throw new Error('all supplied Agent Session History sessions were invalid')
}
const issues = envelope.data.issues.flatMap((issue) => {
const parsed = aiVaultScanIssueSchema.safeParse(issue)
return parsed.success ? [parsed.data] : []
})
const invalidCount =
envelope.data.sessions.length - sessions.length + (envelope.data.issues.length - issues.length)
if (invalidCount > 0) {
issues.push({
agent: 'codex',
path: 'aiVault.listSessions',
message: `Skipped ${invalidCount} invalid Agent Session History result ${invalidCount === 1 ? 'entry' : 'entries'}.`
})
}
return { sessions, issues, scannedAt: envelope.data.scannedAt }
}

View File

@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest'
import type {
AiVaultListResult,
AiVaultScanIssue,
AiVaultSession
} from '../../shared/ai-vault-types'
import { mergeAiVaultListResults } from './session-list-results'
function listResult(issues: AiVaultScanIssue[]): AiVaultListResult {
return { sessions: [], issues, scannedAt: '2026-08-02T00:00:00.000Z' }
}
function session(index: number): AiVaultSession {
const id = `session-${index}`
const timestamp = new Date(Date.UTC(2026, 7, 2, 0, 0, index)).toISOString()
return {
id,
executionHostId: 'local',
agent: 'codex',
sessionId: id,
title: id,
cwd: '/repo',
branch: null,
model: null,
filePath: `/sessions/${id}.jsonl`,
codexHome: null,
createdAt: timestamp,
updatedAt: timestamp,
modifiedAt: timestamp,
messageCount: 1,
totalTokens: 0,
previewMessages: [],
queuedMessageCount: 0,
subagentTranscriptCount: 0,
resumeCommand: id,
subagent: null
}
}
const SCOPE_TRUNCATION: AiVaultScanIssue = {
executionHostId: 'ssh:dev-box',
agent: 'codex',
kind: 'scope',
path: '/home/ada',
message: 'Only the first 64 project paths were scanned.'
}
describe('mergeAiVaultListResults', () => {
it('does not cap an Unlimited all-host merge', () => {
const sessions = Array.from({ length: 1001 }, (_, index) => session(index))
const merged = mergeAiVaultListResults([{ ...listResult([]), sessions }], undefined, true)
expect(merged.sessions).toHaveLength(1001)
})
it('keeps a per-host scope truncation notice when merging all-host results', () => {
const merged = mergeAiVaultListResults(
[listResult([]), listResult([SCOPE_TRUNCATION])],
undefined
)
expect(merged.issues).toEqual([SCOPE_TRUNCATION])
})
it('keeps one scope notice per host rather than collapsing them', () => {
const otherHost: AiVaultScanIssue = { ...SCOPE_TRUNCATION, executionHostId: 'ssh:build-box' }
const merged = mergeAiVaultListResults(
[listResult([SCOPE_TRUNCATION]), listResult([otherHost])],
undefined
)
expect(merged.issues.map((issue) => issue.executionHostId)).toEqual([
'ssh:dev-box',
'ssh:build-box'
])
})
it('keeps a scope notice alongside a failing host so one bad host is not the whole story', () => {
const hostDown: AiVaultScanIssue = {
executionHostId: 'ssh:build-box',
agent: 'codex',
kind: 'host',
path: 'build-box',
message: 'Remote connection dropped.'
}
const merged = mergeAiVaultListResults(
[listResult([SCOPE_TRUNCATION]), listResult([hostDown])],
undefined
)
expect(merged.issues).toEqual([SCOPE_TRUNCATION, hostDown])
// Kinded issues render as their own banner rows, never as skipped transcripts.
expect(merged.issues.filter((issue) => !issue.kind)).toEqual([])
})
})

View File

@ -5,6 +5,7 @@ import type {
} from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import { sessionSortTime } from './session-scanner-accumulator'
import { aiVaultScanLimit } from '../../shared/ai-vault-session-depth'
export function aiVaultScanIssueResult(args: {
executionHostId?: ExecutionHostId
@ -17,6 +18,7 @@ export function aiVaultScanIssueResult(args: {
{
...(args.executionHostId ? { executionHostId: args.executionHostId } : {}),
agent: 'codex',
kind: 'host',
path: args.path,
message: args.message
}
@ -25,6 +27,12 @@ export function aiVaultScanIssueResult(args: {
}
}
// A superseded scan has no findings to report; the flag tells the renderer to
// keep the list it already has rather than paint this empty body.
export function cancelledAiVaultListResult(): AiVaultListResult {
return { sessions: [], issues: [], scannedAt: new Date().toISOString(), cancelled: true }
}
// Why: the serving-side scan is host-local and cached once for every caller
// (desktop parent, web, mobile), so callers that address this host by a runtime
// id get the cached result restamped on the way out instead of a per-host scan.
@ -50,9 +58,10 @@ export function restampAiVaultListResult(
export function mergeAiVaultListResults(
results: readonly AiVaultListResult[],
rawLimit: number | undefined
rawLimit: number | undefined,
unlimited = false
): AiVaultListResult {
const limit = rawLimit && rawLimit > 0 ? Math.floor(rawLimit) : 1000
const limit = aiVaultScanLimit({ limit: rawLimit, unlimited })
const byId = new Map<string, AiVaultSession>()
const issues: AiVaultScanIssue[] = []
for (const result of results) {

View File

@ -1,3 +1,4 @@
import { remoteSessionContentLines } from './remote-session-content-lines'
import { createReadStream } from 'node:fs'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
@ -36,11 +37,12 @@ export async function parseAntigravitySessionContent(
file: FileWithMtime,
content: string,
platform: NodeJS.Platform = process.platform,
options: ParserSessionOptions = {}
options: ParserSessionOptions = {},
signal?: AbortSignal
): Promise<AiVaultSession | null> {
return parseAntigravitySessionLines({
file,
lines: content.split(/\r?\n/),
lines: remoteSessionContentLines(content, signal),
platform,
options
})

View File

@ -31,6 +31,7 @@ import {
parseJsonObject,
subtractCodexUsage
} from './session-scanner-values'
import { remoteSessionContentLines } from './remote-session-content-lines'
export async function parseCodexSessionFile(
file: FileWithMtime,
@ -61,10 +62,11 @@ export async function parseCodexSessionContent(args: {
executionHostId?: ExecutionHostId
executionHostPlatform?: NodeJS.Platform | null
readIndexedTitle?: (sessionId: string) => Promise<string | null>
signal?: AbortSignal
}): Promise<AiVaultSession | null> {
return parseCodexSessionLines({
file: args.file,
lines: args.content.split(/\r?\n/),
lines: remoteSessionContentLines(args.content, args.signal),
platform: args.platform ?? process.platform,
codexHome: args.codexHome ?? null,
executionHostId: args.executionHostId,

View File

@ -1,3 +1,4 @@
import { remoteSessionContentLines } from './remote-session-content-lines'
import { createReadStream } from 'node:fs'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
@ -44,11 +45,12 @@ export async function parseCopilotSessionContent(
file: FileWithMtime,
content: string,
platform: NodeJS.Platform = process.platform,
options: ParserSessionOptions = {}
options: ParserSessionOptions = {},
signal?: AbortSignal
): Promise<AiVaultSession | null> {
return parseCopilotSessionLines({
file,
lines: content.split(/\r?\n/),
lines: remoteSessionContentLines(content, signal),
platform,
options
})

View File

@ -1,3 +1,4 @@
import { remoteSessionContentLines } from './remote-session-content-lines'
import { createReadStream } from 'node:fs'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
@ -42,11 +43,12 @@ export async function parseCursorSessionContent(
file: FileWithMtime,
content: string,
platform: NodeJS.Platform = process.platform,
options: ParserSessionOptions = {}
options: ParserSessionOptions = {},
signal?: AbortSignal
): Promise<AiVaultSession | null> {
return parseCursorSessionLines({
file,
lines: content.split(/\r?\n/),
lines: remoteSessionContentLines(content, signal),
platform,
options
})

View File

@ -1,3 +1,4 @@
import { remoteSessionContentLines } from './remote-session-content-lines'
import { createReadStream } from 'node:fs'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
@ -44,11 +45,12 @@ export async function parseDroidSessionContent(
file: FileWithMtime,
content: string,
platform: NodeJS.Platform = process.platform,
options: ParserSessionOptions = {}
options: ParserSessionOptions = {},
signal?: AbortSignal
): Promise<AiVaultSession | null> {
return parseDroidSessionLines({
file,
lines: content.split(/\r?\n/),
lines: remoteSessionContentLines(content, signal),
platform,
options
})

View File

@ -1,3 +1,4 @@
import { remoteSessionContentLines } from './remote-session-content-lines'
import { createReadStream } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { createInterface } from 'node:readline'
@ -40,12 +41,13 @@ export async function parseGeminiSessionContent(
file: FileWithMtime,
content: string,
platform: NodeJS.Platform = process.platform,
options: ResumableParseFinalizeOptions = {}
options: ResumableParseFinalizeOptions = {},
signal?: AbortSignal
): Promise<AiVaultSession | null> {
if (file.path.endsWith('.jsonl')) {
return parseGeminiJsonlSessionLines({
file,
lines: content.split(/\r?\n/),
lines: remoteSessionContentLines(content, signal),
platform,
options
})

View File

@ -1,3 +1,4 @@
import { remoteSessionContentLines } from './remote-session-content-lines'
import { createReadStream } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
@ -184,12 +185,13 @@ export async function parseMessageGraphSessionContent(
file: FileWithMtime,
content: string,
platform: NodeJS.Platform = process.platform,
options: ParserSessionOptions = {}
options: ParserSessionOptions = {},
signal?: AbortSignal
): Promise<AiVaultSession | null> {
return parseMessageGraphSessionLines({
agent,
file,
lines: content.split(/\r?\n/),
lines: remoteSessionContentLines(content, signal),
platform,
options
})

View File

@ -135,6 +135,22 @@ describe('listOpenCodeSqliteSessions — LIMIT-first discovery', () => {
])
})
it('omits the SQL limit for an Unlimited scan', async () => {
const { db, path } = createTempDb()
applySchema(db)
for (let i = 0; i < 5; i++) {
insertSession(db, `ses_${i}`, 1_777_634_000_000 + i * 1000)
}
db.close()
const candidates = await listOpenCodeSqliteSessions({
dbPaths: [path],
limit: Number.POSITIVE_INFINITY,
issues: []
})
expect(candidates).toHaveLength(5)
})
it('uses time_created recency when time_updated is not positive', async () => {
const { db, path } = createTempDb()
applySchema(db)

View File

@ -32,7 +32,7 @@ function canReadOpenCodeSessions(db: SyncDatabase): boolean {
)
}
function buildSessionListQuery(db: SyncDatabase): string {
function buildSessionListQuery(db: SyncDatabase, limited: boolean): string {
const parentIdPredicate = columnExists(db, 'session', 'parent_id') ? 'AND parent_id IS NULL' : ''
const archivedPredicate = columnExists(db, 'session', 'time_archived')
? 'AND time_archived IS NULL'
@ -45,7 +45,7 @@ function buildSessionListQuery(db: SyncDatabase): string {
FROM session
WHERE 1=1 ${parentIdPredicate} ${archivedPredicate}
ORDER BY CASE WHEN time_updated > 0 THEN time_updated ELSE time_created END DESC
LIMIT ?`
${limited ? 'LIMIT ?' : ''}`
}
function rowToCandidate(row: SessionRow, dbPath: string): SessionFileCandidate {
@ -105,7 +105,9 @@ export async function listOpenCodeSqliteSessions(args: {
if (!canReadOpenCodeSessions(db)) {
continue
}
const rows = db.prepare(buildSessionListQuery(db)).all(args.limit) as SessionRow[]
const limited = Number.isFinite(args.limit)
const statement = db.prepare(buildSessionListQuery(db, limited))
const rows = (limited ? statement.all(args.limit) : statement.all()) as SessionRow[]
for (const row of rows) {
candidates.push(rowToCandidate(row, dbPath))
}

View File

@ -1,3 +1,4 @@
import { remoteSessionContentLines } from './remote-session-content-lines'
import { createReadStream } from 'node:fs'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
@ -219,11 +220,12 @@ export async function parseClaudeSessionContent(
file: FileWithMtime,
content: string,
platform: NodeJS.Platform = process.platform,
options: ParserSessionOptions = {}
options: ParserSessionOptions = {},
signal?: AbortSignal
): Promise<AiVaultSession | null> {
return parseClaudeSessionLines({
file,
lines: content.split(/\r?\n/),
lines: remoteSessionContentLines(content, signal),
platform,
options
})

View File

@ -34,12 +34,16 @@ export type AiVaultScanOptions = {
droidProjectsDir?: string
kimiSessionsDir?: string
limit?: number
unlimited?: boolean
limitPerAgent?: number
// Active workspace/project paths whose sessions must be included regardless of
// the recency cap (see discoverInScopeClaudeFiles).
scopePaths?: readonly string[]
platform?: NodeJS.Platform
executionHostId?: ExecutionHostId
// Superseded/cancelled scans stop between parse batches instead of parsing
// every remaining transcript for a caller that already left.
signal?: AbortSignal
}
export type FileWithMtime = {

View File

@ -154,7 +154,9 @@ describe('scanAiVaultSessions', () => {
const result = await scanAiVaultSessions({
...roots,
platform: 'darwin'
platform: 'darwin',
limit: 1,
unlimited: true
})
expect(result.issues).toEqual([])

View File

@ -39,13 +39,11 @@ import type {
SessionParseResult
} from './session-scanner-types'
import { clampPositiveInteger, errorMessage } from './session-scanner-values'
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
import { DEFAULT_AI_VAULT_SCAN_LIMIT } from '../../shared/ai-vault-session-depth'
const DEFAULT_LIMIT = 1000
const DEFAULT_SCAN_LIMIT_PER_AGENT = 1000
const SESSION_PARSE_CONCURRENCY = 8
// Upper bound on extra in-scope transcripts discovered and parsed past the
// recency cap; guards against a pathological scoped history directory.
const SCOPE_PARSE_LIMIT = 2000
const SESSION_PARSE_CANDIDATE_MULTIPLIER = 2
/**
* Scan all supported AI agent session stores and return a unified, sorted,
@ -63,8 +61,12 @@ export async function scanAiVaultSessions(
// "one core pegged" reports need to show whether transcript scanning is the
// subsystem burning CPU, and how much of each scan the cache absorbed.
return withSpan('aiVault.scan', async (span) => {
const limit = clampPositiveInteger(options.limit, DEFAULT_LIMIT)
const limitPerAgent = clampPositiveInteger(options.limitPerAgent, DEFAULT_SCAN_LIMIT_PER_AGENT)
const limit = options.unlimited
? Number.POSITIVE_INFINITY
: clampPositiveInteger(options.limit, DEFAULT_AI_VAULT_SCAN_LIMIT)
const limitPerAgent = options.unlimited
? Number.POSITIVE_INFINITY
: clampPositiveInteger(options.limitPerAgent, limit * SESSION_PARSE_CANDIDATE_MULTIPLIER)
const platform = options.platform ?? process.platform
const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID
const issues: AiVaultScanIssue[] = []
@ -72,8 +74,10 @@ export async function scanAiVaultSessions(
const antigravityWorkspaceResolver = createAntigravityWorkspaceResolver(readOptionalTextFile)
// Why: persisted entries must be seeded before any candidate is parsed, or
// the cold scan gains nothing from the cache file (#9210).
throwIfAiVaultScanCancelled(options.signal)
await ensureSessionParseCacheLoaded()
const discoveries = await discoverAiVaultSessionSources({ options, limitPerAgent, issues })
throwIfAiVaultScanCancelled(options.signal)
const candidates = dedupeCodexRolloutFileAliases(
discoveries
@ -106,12 +110,13 @@ export async function scanAiVaultSessions(
)
const parsedSessions = await parseSessionCandidates({
candidates,
candidates: candidates.slice(0, limit * SESSION_PARSE_CANDIDATE_MULTIPLIER),
limit,
platform,
executionHostId,
issues,
parseStats,
signal: options.signal,
antigravityWorkspaceResolver
})
@ -122,12 +127,17 @@ export async function scanAiVaultSessions(
const scopeSessions = await scanInScopeSessions({
discoveries,
scopePaths: options.scopePaths ?? [],
limit,
alreadyParsedFilePaths: new Set(cappedSessions.map((session) => session.filePath)),
platform,
executionHostId,
issues,
parseStats
parseStats,
signal: options.signal
})
// Scope discovery can return without parsing anything, so an abort landing
// here would otherwise persist and return a cancelled scan as complete.
throwIfAiVaultScanCancelled(options.signal)
span.setAttribute('candidates', candidates.length)
span.setAttribute('reused', parseStats.reused)
@ -169,11 +179,13 @@ function mergeSessions(
async function scanInScopeSessions(args: {
discoveries: SessionFileDiscovery[]
scopePaths: readonly string[]
limit: number
alreadyParsedFilePaths: ReadonlySet<string>
platform: NodeJS.Platform
executionHostId: ExecutionHostId
issues: AiVaultScanIssue[]
parseStats: SessionParseStats
signal?: AbortSignal
}): Promise<AiVaultSession[]> {
if (args.scopePaths.length === 0) {
return []
@ -184,7 +196,7 @@ async function scanInScopeSessions(args: {
const files = await discoverInScopeClaudeFiles({
rootDirs: claudeRootDirs,
scopePaths: args.scopePaths,
limit: SCOPE_PARSE_LIMIT,
limit: args.limit,
excludedFilePaths: args.alreadyParsedFilePaths,
issues: args.issues
})
@ -201,7 +213,8 @@ async function scanInScopeSessions(args: {
platform: args.platform,
executionHostId: args.executionHostId,
issues: args.issues,
parseStats: args.parseStats
parseStats: args.parseStats,
signal: args.signal
})
}
@ -212,12 +225,14 @@ async function parseSessionCandidates(args: {
executionHostId: ExecutionHostId
issues: AiVaultScanIssue[]
parseStats: SessionParseStats
signal?: AbortSignal
antigravityWorkspaceResolver?: AntigravityWorkspaceResolver
}): Promise<AiVaultSession[]> {
const sessions: AiVaultSession[] = []
let index = 0
while (index < args.candidates.length) {
throwIfAiVaultScanCancelled(args.signal)
if (canStopParsingSessions(sessions, args.limit, args.candidates[index]?.file.mtimeMs)) {
break
}
@ -255,6 +270,9 @@ async function parseSessionCandidates(args: {
index += batchSize
}
// An abort can land while the final batch settles; observe it here so a
// partial parse is never cached or returned as a complete scan.
throwIfAiVaultScanCancelled(args.signal)
return sessions
}

View File

@ -0,0 +1,249 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types'
import { SSH_MUX_REQUEST_TIMEOUT_CODE } from '../ssh/ssh-channel-multiplexer'
const requestActiveSshAiVaultSessionList = vi.fn()
const getActiveSshAiVaultHostInfo = vi.fn()
const getSshFilesystemProvider = vi.fn()
const scanRemoteAiVaultSessions = vi.fn()
vi.mock('../ipc/ssh', () => ({
requestActiveSshAiVaultSessionList: (...args: unknown[]) =>
requestActiveSshAiVaultSessionList(...args),
getActiveSshAiVaultHostInfo: (...args: unknown[]) => getActiveSshAiVaultHostInfo(...args)
}))
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
getSshFilesystemProvider: (...args: unknown[]) => getSshFilesystemProvider(...args),
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE: 'SSH filesystem is unavailable.'
}))
vi.mock('./remote-session-scanner', () => ({
scanRemoteAiVaultSessions: (...args: unknown[]) => scanRemoteAiVaultSessions(...args)
}))
const { scanSshAiVaultSessions } = await import('./ssh-session-list')
describe('scanSshAiVaultSessions', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useRealTimers()
getActiveSshAiVaultHostInfo.mockReturnValue({ remoteHome: '/home/dev', hostPlatform: 'linux' })
getSshFilesystemProvider.mockReturnValue({})
})
it('returns at the deadline when the legacy crawl ignores abort', async () => {
// Relay without the method resolves null, so the leg falls through to the
// desktop crawl — which used to run unbounded even under an all-host budget.
vi.useFakeTimers()
requestActiveSshAiVaultSessionList.mockResolvedValue(null)
let fallbackSignal: AbortSignal | undefined
scanRemoteAiVaultSessions.mockImplementation(({ signal }: { signal?: AbortSignal }) => {
fallbackSignal = signal
return new Promise(() => {})
})
const resultPromise = scanSshAiVaultSessions('dev-box', undefined, { timeoutMs: 20 })
await vi.advanceTimersByTimeAsync(20)
const result = await Promise.race([resultPromise, Promise.resolve('still-pending' as const)])
expect(result).not.toBe('still-pending')
expect(fallbackSignal?.aborted).toBe(true)
if (result === 'still-pending') {
return
}
expect(result.sessions).toEqual([])
expect(result.issues).toEqual([
expect.objectContaining({
executionHostId: 'ssh:dev-box',
kind: 'host',
message: 'Agent Session History scan timed out after 20ms on this SSH host.'
})
])
})
it('leaves the crawl unbounded when no budget was requested', async () => {
requestActiveSshAiVaultSessionList.mockResolvedValue(null)
scanRemoteAiVaultSessions.mockResolvedValue(emptyResult())
await scanSshAiVaultSessions('dev-box')
expect(scanRemoteAiVaultSessions).toHaveBeenCalledWith(
expect.objectContaining({ signal: undefined })
)
})
it('reports an unexpected crawl failure as a host issue instead of rejecting', async () => {
// Why: `all` scope awaits every host leg together, so a throw here would
// discard the local sessions alongside this host's.
requestActiveSshAiVaultSessionList.mockResolvedValue(null)
scanRemoteAiVaultSessions.mockRejectedValue(new TypeError('provider blew up'))
const result = await scanSshAiVaultSessions('dev-box')
expect(result.sessions).toEqual([])
expect(result.issues).toEqual([
expect.objectContaining({ executionHostId: 'ssh:dev-box', message: 'provider blew up' })
])
})
it('bounds the relay round trip separately from the whole leg', async () => {
// The relay needs seconds to walk a real remote home; only the leg total
// has to stay short enough that one host cannot hold the merge open.
requestActiveSshAiVaultSessionList.mockResolvedValue(null)
scanRemoteAiVaultSessions.mockResolvedValue(emptyResult())
await scanSshAiVaultSessions('dev-box', undefined, {
timeoutMs: 20_000,
relayTimeoutMs: 15_000
})
expect(requestActiveSshAiVaultSessionList).toHaveBeenCalledWith(
'dev-box',
expect.any(Object),
expect.objectContaining({ timeoutMs: 15_000 })
)
})
it('keeps a relay scan that outran the old three-second bound', async () => {
// Regression: the shared 3s budget emptied healthy hosts in the all-hosts
// view. A relay answering inside the scan budget must keep its sessions.
requestActiveSshAiVaultSessionList.mockImplementation(
(_targetId: string, _params: unknown, options: { timeoutMs?: number }) =>
(options.timeoutMs ?? 0) > 3_000
? Promise.resolve({
sessions: [remoteSession()],
issues: [],
scannedAt: '2026-08-02T00:00:00.000Z'
})
: Promise.reject(relayTimeoutError())
)
const result = await scanSshAiVaultSessions('dev-box', undefined, {
timeoutMs: 20_000,
relayTimeoutMs: 15_000
})
expect(result.sessions).toEqual([expect.objectContaining({ sessionId: 'remote-session' })])
expect(scanRemoteAiVaultSessions).not.toHaveBeenCalled()
})
it('reports a host issue when the relay timed out on a real scan budget', async () => {
// A relay that had a fair scan window will not answer faster over the far
// slower filesystem crawl, so retrying it only stalls the merge.
requestActiveSshAiVaultSessionList.mockRejectedValue(relayTimeoutError())
const result = await scanSshAiVaultSessions('dev-box', undefined, {
timeoutMs: 20_000,
relayTimeoutMs: 15_000
})
expect(scanRemoteAiVaultSessions).not.toHaveBeenCalled()
expect(result.issues).toEqual([
expect.objectContaining({ executionHostId: 'ssh:dev-box', kind: 'host' })
])
})
it('still falls back when the relay budget was too short for a fair attempt', async () => {
requestActiveSshAiVaultSessionList.mockRejectedValue(relayTimeoutError())
scanRemoteAiVaultSessions.mockResolvedValue({
sessions: [remoteSession()],
issues: [],
scannedAt: '2026-08-02T00:00:00.000Z'
})
const result = await scanSshAiVaultSessions('dev-box', undefined, {
timeoutMs: 3_000,
relayTimeoutMs: 2_000
})
expect(scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1)
expect(result.sessions).toEqual([expect.objectContaining({ sessionId: 'remote-session' })])
})
it('does not treat an unrelated error mentioning a timeout as a relay timeout', async () => {
requestActiveSshAiVaultSessionList.mockRejectedValue(
new Error('the remote agent timed out after loading its index')
)
scanRemoteAiVaultSessions.mockResolvedValue({
sessions: [remoteSession()],
issues: [],
scannedAt: '2026-08-02T00:00:00.000Z'
})
const result = await scanSshAiVaultSessions('dev-box', undefined, {
timeoutMs: 20_000,
relayTimeoutMs: 15_000
})
expect(scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1)
expect(result.sessions).toEqual([expect.objectContaining({ sessionId: 'remote-session' })])
})
it('reports the relay error when the fallback crawl recovered nothing', async () => {
// Otherwise a broken relay over an empty crawl reads as a healthy but empty
// host, and the panel shows no reason for the missing sessions.
requestActiveSshAiVaultSessionList.mockRejectedValue(new Error('relay method exploded'))
scanRemoteAiVaultSessions.mockResolvedValue(emptyResult())
const result = await scanSshAiVaultSessions('dev-box')
expect(result.issues).toEqual([
expect.objectContaining({ executionHostId: 'ssh:dev-box', message: 'relay method exploded' })
])
})
it('still propagates a caller cancellation', async () => {
const controller = new AbortController()
requestActiveSshAiVaultSessionList.mockResolvedValue(null)
scanRemoteAiVaultSessions.mockImplementation(() => new Promise(() => {}))
const result = scanSshAiVaultSessions('dev-box', undefined, {
signal: controller.signal,
timeoutMs: 5_000
})
await vi.waitFor(() => expect(scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1))
controller.abort()
await expect(result).rejects.toMatchObject({ name: 'AbortError' })
})
})
function emptyResult(): AiVaultListResult {
return { sessions: [], issues: [], scannedAt: '2026-08-02T00:00:00.000Z' }
}
/** Mirrors the multiplexer's typed timeout: the leg branches on the code, not
* on the message text, so a look-alike message must not take that branch. */
function relayTimeoutError(): Error {
return Object.assign(new Error('Request "aiVault.listSessions" timed out after 15000ms'), {
code: SSH_MUX_REQUEST_TIMEOUT_CODE
})
}
// Relay rows are re-validated before they are trusted, so this has to satisfy
// the full session schema rather than a partial stub.
function remoteSession(): AiVaultSession {
return {
id: 'ssh:dev-box:codex:remote-session:/home/dev/remote-session.jsonl',
executionHostId: 'ssh:dev-box',
agent: 'codex',
sessionId: 'remote-session',
title: 'remote-session',
cwd: '/home/dev/repo',
branch: null,
model: null,
filePath: '/home/dev/remote-session.jsonl',
codexHome: null,
createdAt: null,
updatedAt: '2026-08-02T00:00:00.000Z',
modifiedAt: '2026-08-02T00:00:00.000Z',
messageCount: 1,
totalTokens: 0,
previewMessages: [],
queuedMessageCount: 0,
subagentTranscriptCount: 0,
resumeCommand: 'codex resume remote-session',
subagent: null
}
}

View File

@ -0,0 +1,241 @@
import {
AI_VAULT_SCOPE_PATHS_MAX_COUNT,
type AiVaultListArgs,
type AiVaultListResult,
type AiVaultScanIssue
} from '../../shared/ai-vault-types'
import { toSshExecutionHostId } from '../../shared/execution-host'
import {
getSshFilesystemProvider,
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE
} from '../providers/ssh-filesystem-dispatch'
import { getActiveSshAiVaultHostInfo, requestActiveSshAiVaultSessionList } from '../ipc/ssh'
import { isSshMuxRequestTimeoutError } from '../ssh/ssh-channel-multiplexer'
import { createAiVaultScanCancelledError } from './ai-vault-scan-cancellation'
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
import { parseAiVaultListResult } from './session-list-result-validation'
import { aiVaultScanIssueResult, restampAiVaultListResult } from './session-list-results'
/** Bounds one SSH host leg. `relayTimeoutMs` caps the relay round trip alone;
* `timeoutMs` caps the whole leg, including the legacy filesystem crawl. */
export type SshAiVaultScanBudget = {
signal?: AbortSignal
timeoutMs?: number
relayTimeoutMs?: number
}
// Why: a relay that was given a real scan budget and still timed out will not
// answer faster over the slower filesystem crawl, so that leg reports a host
// issue. A relay cut short below this never got a fair attempt — those fall
// back rather than emptying a healthy host's sessions (#12178 follow-up).
const MEANINGFUL_RELAY_SCAN_ATTEMPT_MS = 10_000
export async function scanSshAiVaultSessions(
targetId: string,
args?: AiVaultListArgs,
options: SshAiVaultScanBudget = {}
): Promise<AiVaultListResult> {
const executionHostId = toSshExecutionHostId(targetId)
// Why: in `all` scope every host leg is awaited together, so an unexpected
// throw here (not a caller cancellation) would discard the local results too.
try {
return await scanOneSshHost(targetId, executionHostId, args, options)
} catch (error) {
if (isAbortError(error)) {
throw error
}
return sshScanIssueResult(executionHostId, targetId, errorMessage(error))
}
}
async function scanOneSshHost(
targetId: string,
executionHostId: `ssh:${string}`,
args: AiVaultListArgs | undefined,
options: SshAiVaultScanBudget
): Promise<AiVaultListResult> {
const startedAt = Date.now()
// Both legs scan the same capped set, so the fallback can't quietly scan more
// paths — or skip the truncation notice — than the relay leg would.
const scopePaths = args?.scopePaths?.slice(0, AI_VAULT_SCOPE_PATHS_MAX_COUNT)
const scopePathsTruncated = (args?.scopePaths?.length ?? 0) > AI_VAULT_SCOPE_PATHS_MAX_COUNT
let relayError: unknown
const relayTimeoutMs = options.relayTimeoutMs ?? options.timeoutMs
try {
const params = {
limit: args?.limit,
...(args?.unlimited === true ? { unlimited: true } : {}),
...(args?.force === true ? { force: true } : {}),
scopePaths,
...(scopePathsTruncated ? { scopePathsTruncated: true } : {})
}
const relayResult =
options.signal || relayTimeoutMs !== undefined
? await requestActiveSshAiVaultSessionList(targetId, params, {
signal: options.signal,
timeoutMs: relayTimeoutMs
})
: await requestActiveSshAiVaultSessionList(targetId, params)
if (relayResult !== null) {
return restampAiVaultListResult(parseAiVaultListResult(relayResult), executionHostId)
}
} catch (error) {
if (isAbortError(error)) {
throw error
}
if (
isSshMuxRequestTimeoutError(error) &&
(relayTimeoutMs === undefined || relayTimeoutMs >= MEANINGFUL_RELAY_SCAN_ATTEMPT_MS)
) {
return sshScanIssueResult(executionHostId, targetId, errorMessage(error))
}
relayError = error
}
const hostInfo = getActiveSshAiVaultHostInfo(targetId)
const provider = getSshFilesystemProvider(targetId)
if (!hostInfo || !provider) {
return sshScanIssueResult(
executionHostId,
targetId,
relayError ? errorMessage(relayError) : SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE
)
}
// Why: `timeoutMs` bounded only the relay round trip, so a host on a relay
// without the list method fell through to the unbounded desktop crawl and one
// stalled SSH file stream could hold every other host's results hostage.
const fallbackResult = await scanRemoteSessionsWithinBudget({
scan: (signal) =>
scanRemoteAiVaultSessions({
provider,
executionHostId,
remoteHome: hostInfo.remoteHome,
hostPlatform: hostInfo.hostPlatform,
limit: args?.limit,
unlimited: args?.unlimited,
scopePaths,
signal
}),
signal: options.signal,
remainingMs: remainingScanBudgetMs(options.timeoutMs, startedAt)
})
if (!fallbackResult) {
return sshScanIssueResult(
executionHostId,
targetId,
`Agent Session History scan timed out after ${options.timeoutMs}ms on this SSH host.`
)
}
const scopeIssues = scopePathsTruncated
? [scopeTruncationIssue(executionHostId, hostInfo.remoteHome)]
: []
// An empty remote home and "the relay method failed and the crawl found
// nothing" look identical, so a fallback that recovered nothing still reports
// the relay error instead of presenting a broken relay as an empty host.
if (!relayError || fallbackResult.sessions.length > 0) {
return { ...fallbackResult, issues: [...fallbackResult.issues, ...scopeIssues] }
}
return {
...fallbackResult,
issues: [
...sshScanIssueResult(executionHostId, targetId, errorMessage(relayError)).issues,
...fallbackResult.issues,
...scopeIssues
]
}
}
/** Budget left for the legacy crawl after the relay attempt spent part of it. */
function remainingScanBudgetMs(timeoutMs: number | undefined, startedAt: number): number | null {
if (timeoutMs === undefined) {
return null
}
return Math.max(0, timeoutMs - (Date.now() - startedAt))
}
/** Runs the legacy crawl under `remainingMs`; resolves null once the budget is
* spent. Caller cancellation still propagates as an AbortError. */
async function scanRemoteSessionsWithinBudget(args: {
scan: (signal?: AbortSignal) => Promise<AiVaultListResult>
signal?: AbortSignal
remainingMs: number | null
}): Promise<AiVaultListResult | null> {
const remainingMs = args.remainingMs
if (remainingMs === null) {
return args.scan(args.signal)
}
if (args.signal?.aborted) {
throw createAiVaultScanCancelledError()
}
if (remainingMs === 0) {
return null
}
const controller = new AbortController()
return new Promise<AiVaultListResult | null>((resolve, reject) => {
let settled = false
const finish = (settle: () => void): void => {
if (settled) {
return
}
settled = true
clearTimeout(timer)
args.signal?.removeEventListener('abort', onCallerAbort)
settle()
}
const onCallerAbort = (): void => {
controller.abort()
finish(() => reject(createAiVaultScanCancelledError()))
}
const timer = setTimeout(() => {
controller.abort()
finish(() => resolve(null))
}, remainingMs)
args.signal?.addEventListener('abort', onCallerAbort, { once: true })
if (args.signal?.aborted) {
onCallerAbort()
return
}
try {
const scan = args.scan(controller.signal)
// The SSH provider may ignore abort; observe any eventual rejection after
// this caller has already returned its timeout or cancellation result.
void scan.then(
(result) => finish(() => resolve(result)),
(error: unknown) => finish(() => reject(error))
)
} catch (error) {
finish(() => reject(error))
}
})
}
// Mirrors the notice the relay leg appends so both legs report the same cap.
function scopeTruncationIssue(
executionHostId: `ssh:${string}`,
remoteHome: string
): AiVaultScanIssue {
return {
executionHostId,
agent: 'codex',
kind: 'scope',
path: remoteHome,
message: `Only the first ${AI_VAULT_SCOPE_PATHS_MAX_COUNT} project paths were scanned.`
}
}
function sshScanIssueResult(
executionHostId: `ssh:${string}`,
targetId: string,
message: string
): AiVaultListResult {
return aiVaultScanIssueResult({ executionHostId, path: targetId, message })
}
function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === 'AbortError'
}
function errorMessage(error: unknown): string {
return error instanceof Error
? error.message
: 'Agent Session History scan failed on the SSH target.'
}

View File

@ -0,0 +1,29 @@
import type { AiVaultListResult } from '../../shared/ai-vault-types'
import { aiVaultScanIssueResult } from '../ai-vault/session-list-results'
export type AiVaultHostDiscoveryResult<T> = {
hostInfos: readonly T[]
issue?: AiVaultListResult
}
/**
* Why: enumerating runtime/SSH hosts reads live session state and can throw. In
* 'all' scope that rejection would take down the whole Promise.all and drop the
* local sessions too, so a broken enumerator degrades to one issue row instead.
*/
export function discoverAiVaultHosts<T>(
enumerate: () => readonly T[],
args: { path: string; fallbackMessage: string }
): AiVaultHostDiscoveryResult<T> {
try {
return { hostInfos: enumerate() }
} catch (error) {
return {
hostInfos: [],
issue: aiVaultScanIssueResult({
path: args.path,
message: error instanceof Error ? error.message : args.fallbackMessage
})
}
}
}

View File

@ -0,0 +1,78 @@
import type { AiVaultListResult } from '../../shared/ai-vault-types'
import {
aiVaultSessionDepthCovers,
truncateAiVaultListResult,
type AiVaultSessionDepth
} from '../../shared/ai-vault-session-depth'
export const AI_VAULT_CACHE_TTL_MS = 15_000
type CachedHostLeg = {
depth: AiVaultSessionDepth
result: AiVaultListResult
expiresAt: number
}
// Why: the merged result is only cacheable when every host answered, so one
// flaky host used to force a full rescan of every healthy host on each panel
// open. Legs are cached individually and the failing host is the only one
// rescanned.
const cachedHostLegs = new Map<string, CachedHostLeg>()
/** Serves one host's leg from the per-host TTL cache, and stores it only when
* that host answered without a host issue a failing host is retried on the
* next open while its healthy neighbours stay cached. */
export async function scanHostLegWithCache(args: {
cacheKey: string
depth: AiVaultSessionDepth
scopePaths?: readonly string[]
force: boolean
scan: () => Promise<AiVaultListResult>
}): Promise<AiVaultListResult> {
const now = Date.now()
const cached = cachedHostLegs.get(args.cacheKey)
if (
!args.force &&
cached &&
cached.expiresAt > now &&
aiVaultSessionDepthCovers(cached.depth, args.depth)
) {
return truncateAiVaultListResult(cached.result, args.depth, args.scopePaths)
}
const result = await args.scan()
if (result.issues.some((issue) => issue.kind === 'host')) {
const current = cachedHostLegs.get(args.cacheKey)
if (!current || current.expiresAt <= Date.now()) {
cachedHostLegs.delete(args.cacheKey)
}
return result
}
pruneExpiredHostLegs(now)
const current = cachedHostLegs.get(args.cacheKey)
if (
!args.force &&
current &&
current.expiresAt > Date.now() &&
aiVaultSessionDepthCovers(current.depth, args.depth)
) {
return result
}
cachedHostLegs.set(args.cacheKey, {
depth: args.depth,
result,
expiresAt: Date.now() + AI_VAULT_CACHE_TTL_MS
})
return result
}
function pruneExpiredHostLegs(now: number): void {
for (const [cacheKey, entry] of cachedHostLegs) {
if (entry.expiresAt <= now) {
cachedHostLegs.delete(cacheKey)
}
}
}
export function resetAiVaultHostLegCacheForTests(): void {
cachedHostLegs.clear()
}

View File

@ -0,0 +1,95 @@
import {
isAiVaultScanCancelledError,
type AiVaultListArgs,
type AiVaultListResult
} from '../../shared/ai-vault-types'
import {
abandonRemoteSessionScanOnCancel,
throwIfAiVaultScanCancelled
} from '../ai-vault/ai-vault-scan-cancellation'
import { aiVaultScanIssueResult } from '../ai-vault/session-list-results'
export type RuntimeAiVaultHostInfo = {
environmentId: string
executionHostId: `runtime:${string}`
}
export type RuntimeAiVaultScanOptions = {
timeoutMs?: number
}
export type RuntimeAiVaultScanner = (
environmentId: string,
args: AiVaultListArgs,
options?: RuntimeAiVaultScanOptions
) => Promise<AiVaultListResult>
/**
* Why: an unreachable Orca server must cost this host's row, not the whole
* multi-host list, so every failure except cancellation degrades to an issue.
*/
export async function scanRuntimeAiVaultSessions(args: {
hostInfo: RuntimeAiVaultHostInfo
scanner: RuntimeAiVaultScanner | undefined
listArgs?: AiVaultListArgs
options?: RuntimeAiVaultScanOptions & { signal?: AbortSignal }
}): Promise<AiVaultListResult> {
const { signal, ...scannerOptions } = args.options ?? {}
throwIfAiVaultScanCancelled(signal)
if (!args.scanner) {
return runtimeScanIssueResult(
args.hostInfo,
'Agent Session History is not available for this execution host.'
)
}
try {
return await abandonRemoteSessionScanOnCancel(
args.scanner(args.hostInfo.environmentId, runtimeScanArgs(args.hostInfo, args.listArgs), {
...scannerOptions
}),
signal
)
} catch (error) {
// RPC rejections keep the message but lose Error.name, so classify on both.
if (isAiVaultScanCancelledError(error)) {
throw error
}
return runtimeScanIssueResult(
args.hostInfo,
error instanceof Error ? error.message : 'Remote Orca server is unavailable.'
)
}
}
// Optional keys are copied only when present so the RPC schema never sees an
// explicit undefined for limit/force/scopePaths.
function runtimeScanArgs(
hostInfo: RuntimeAiVaultHostInfo,
listArgs: AiVaultListArgs | undefined
): AiVaultListArgs {
const scanArgs: AiVaultListArgs = { executionHostScope: hostInfo.executionHostId }
if (listArgs?.limit !== undefined) {
scanArgs.limit = listArgs.limit
}
if (listArgs?.unlimited !== undefined) {
scanArgs.unlimited = listArgs.unlimited
}
if (listArgs?.force !== undefined) {
scanArgs.force = listArgs.force
}
if (listArgs?.scopePaths !== undefined) {
scanArgs.scopePaths = listArgs.scopePaths
}
return scanArgs
}
function runtimeScanIssueResult(
hostInfo: RuntimeAiVaultHostInfo,
message: string
): AiVaultListResult {
return aiVaultScanIssueResult({
executionHostId: hostInfo.executionHostId,
path: hostInfo.environmentId,
message
})
}

View File

@ -0,0 +1,230 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AiVaultListResult } 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(),
scanRuntimeAiVaultSessions: vi.fn(),
getSshFilesystemProvider: vi.fn(),
getActiveSshAiVaultHostInfo: vi.fn(),
getActiveSshAiVaultHostInfos: vi.fn(),
requestActiveSshAiVaultSessionList: vi.fn(),
ipcHandle: vi.fn()
}))
vi.mock('electron', () => ({ app: { on: vi.fn() }, ipcMain: { handle: mocks.ipcHandle } }))
vi.mock('../ai-vault/session-scanner', () => ({
scanAiVaultSessions: mocks.scanAiVaultSessions
}))
vi.mock('../ai-vault/remote-session-scanner', () => ({
scanRemoteAiVaultSessions: mocks.scanRemoteAiVaultSessions
}))
vi.mock('../wsl', () => ({
getWslHomeAsync: vi.fn(),
listWslDistrosAsync: vi.fn().mockResolvedValue([])
}))
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE: 'SSH unavailable',
getSshFilesystemProvider: mocks.getSshFilesystemProvider
}))
vi.mock('./ssh', () => ({
getActiveSshAiVaultHostInfo: mocks.getActiveSshAiVaultHostInfo,
getActiveSshAiVaultHostInfos: mocks.getActiveSshAiVaultHostInfos,
requestActiveSshAiVaultSessionList: mocks.requestActiveSshAiVaultSessionList
}))
const { _internals, registerAiVaultHandlers } = await import('./ai-vault')
const EMPTY_RESULT: AiVaultListResult = {
sessions: [],
issues: [],
scannedAt: '2026-07-27T00:00:00.000Z'
}
beforeEach(() => {
vi.clearAllMocks()
_internals.resetAiVaultCacheForTests()
mocks.scanAiVaultSessions.mockResolvedValue(EMPTY_RESULT)
mocks.scanRemoteAiVaultSessions.mockResolvedValue(EMPTY_RESULT)
mocks.scanRuntimeAiVaultSessions.mockResolvedValue(EMPTY_RESULT)
mocks.getSshFilesystemProvider.mockReturnValue({} as IFilesystemProvider)
mocks.getActiveSshAiVaultHostInfo.mockReturnValue(hostInfo())
mocks.getActiveSshAiVaultHostInfos.mockReturnValue([hostInfo()])
mocks.requestActiveSshAiVaultSessionList.mockResolvedValue(null)
})
describe('Agent Session History scan coalescing', () => {
it.each([
['local', mocks.scanAiVaultSessions],
['runtime:remote-server', mocks.scanRuntimeAiVaultSessions]
] as const)('coalesces %s scans while isolating caller cancellation', async (scope, scan) => {
let resolveScan: ((result: AiVaultListResult) => void) | undefined
scan.mockImplementation(
() =>
new Promise((resolve) => {
resolveScan = resolve
})
)
registerRuntimeHost()
const firstController = new AbortController()
const first = _internals.listAiVaultSessions(
{ executionHostScope: scope },
{ signal: firstController.signal }
)
const second = _internals.listAiVaultSessions({ executionHostScope: scope })
await vi.waitFor(() => expect(resolveScan).toBeDefined())
firstController.abort()
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
expect(scan).toHaveBeenCalledTimes(1)
resolveScan?.(EMPTY_RESULT)
await expect(second).resolves.toEqual(EMPTY_RESULT)
})
it('coalesces every all-host leg while isolating caller cancellation', async () => {
let resolveRuntime: ((result: AiVaultListResult) => void) | undefined
mocks.scanRuntimeAiVaultSessions.mockImplementation(
() =>
new Promise((resolve) => {
resolveRuntime = resolve
})
)
registerRuntimeHost()
const controller = new AbortController()
const first = _internals.listAiVaultSessions(
{ executionHostScope: 'all' },
{ signal: controller.signal }
)
const firstRejection = expect(first).rejects.toMatchObject({ name: 'AbortError' })
const second = _internals.listAiVaultSessions({ executionHostScope: 'all' })
await vi.waitFor(() => expect(resolveRuntime).toBeDefined())
expect(mocks.scanAiVaultSessions).toHaveBeenCalledTimes(1)
expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1)
expect(mocks.scanRuntimeAiVaultSessions).toHaveBeenCalledTimes(1)
controller.abort()
await firstRejection
resolveRuntime?.(EMPTY_RESULT)
await expect(second).resolves.toMatchObject({ sessions: [], issues: [] })
})
it('keeps a shared multi-window scan alive when one window cancels', async () => {
let resolveRelay: ((result: AiVaultListResult) => void) | undefined
mocks.requestActiveSshAiVaultSessionList.mockImplementation(
() =>
new Promise((resolve) => {
resolveRelay = resolve
})
)
registerAiVaultHandlers()
const list = ipcHandler('aiVault:listSessions')
const cancel = ipcHandler('aiVault:cancelListSessions')
const firstEvent = { sender: { id: 1 } }
const secondEvent = { sender: { id: 2 } }
const first = list(firstEvent, {
executionHostScope: 'ssh:dev-box',
requestToken: 'scan'
}) as Promise<AiVaultListResult>
const second = list(secondEvent, {
executionHostScope: 'ssh:dev-box',
requestToken: 'scan'
}) as Promise<AiVaultListResult>
await vi.waitFor(() => expect(resolveRelay).toBeDefined())
cancel(firstEvent, { requestToken: 'scan' })
// Electron logs every rejected handler, so a cancelled scan resolves instead.
await expect(first).resolves.toMatchObject({ cancelled: true, sessions: [], issues: [] })
expect(mocks.requestActiveSshAiVaultSessionList).toHaveBeenCalledTimes(1)
resolveRelay?.(EMPTY_RESULT)
await expect(second).resolves.toEqual(EMPTY_RESULT)
})
it('reports a real scan failure as a host issue rather than cancellation', async () => {
mocks.requestActiveSshAiVaultSessionList.mockRejectedValue(new Error('relay socket closed'))
mocks.scanRemoteAiVaultSessions.mockRejectedValue(new Error('relay socket closed'))
registerAiVaultHandlers()
const list = ipcHandler('aiVault:listSessions')
// SSH host legs convert unexpected throws into scan issues so an `all`
// multi-host list still returns the other hosts' sessions.
const result = await list(
{ sender: { id: 1 } },
{ executionHostScope: 'ssh:dev-box', requestToken: 'scan' }
)
expect(result).toMatchObject({
sessions: [],
issues: [expect.objectContaining({ message: 'relay socket closed', kind: 'host' })]
})
expect(result).not.toHaveProperty('cancelled')
})
it('still rejects the handler when a scan fails for a non-cancellation reason', async () => {
mocks.scanAiVaultSessions.mockRejectedValue(new Error('transcript root is unreadable'))
registerAiVaultHandlers()
const list = ipcHandler('aiVault:listSessions')
await expect(
list({ sender: { id: 1 } }, { executionHostScope: 'local', requestToken: 'scan' })
).rejects.toThrow('transcript root is unreadable')
})
it('re-joins a preempted same-scope caller onto the forced refresh', async () => {
const signals: AbortSignal[] = []
let resolveForced: ((result: AiVaultListResult) => void) | undefined
mocks.requestActiveSshAiVaultSessionList.mockImplementation(
(_targetId, _params, options: { signal: AbortSignal }) => {
signals.push(options.signal)
return new Promise((resolve) => {
if (signals.length === 1) {
options.signal.addEventListener('abort', () => resolve(EMPTY_RESULT), { once: true })
} else {
resolveForced = resolve
}
})
}
)
const first = _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' })
await vi.waitFor(() => expect(signals).toHaveLength(1))
const forced = _internals.listAiVaultSessions({
executionHostScope: 'ssh:dev-box',
force: true
})
await vi.waitFor(() => expect(signals).toHaveLength(2))
expect(signals[0]?.aborted).toBe(true)
resolveForced?.(EMPTY_RESULT)
// Another window's Refresh must not surface as this caller's cancellation.
await expect(Promise.all([first, forced])).resolves.toEqual([EMPTY_RESULT, EMPTY_RESULT])
})
})
function registerRuntimeHost(): void {
registerAiVaultHandlers({
getActiveRuntimeAiVaultHostInfos: () => [
{ environmentId: 'remote-server', executionHostId: 'runtime:remote-server' }
],
scanRuntimeAiVaultSessions: mocks.scanRuntimeAiVaultSessions
})
}
function hostInfo() {
return {
targetId: 'dev-box',
executionHostId: 'ssh:dev-box' as const,
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64')
}
}
function ipcHandler(channel: string): (...args: unknown[]) => unknown {
const registration = mocks.ipcHandle.mock.calls.find(([registered]) => registered === channel)
if (!registration) {
throw new Error(`${channel} was not registered`)
}
return registration[1]
}

View File

@ -4,6 +4,7 @@ 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'
import { SSH_MUX_REQUEST_TIMEOUT_CODE } from '../ssh/ssh-channel-multiplexer'
const mocks = vi.hoisted(() => ({
scanAiVaultSessions: vi.fn(),
@ -14,6 +15,7 @@ const mocks = vi.hoisted(() => ({
getSshFilesystemProvider: vi.fn(),
getActiveSshAiVaultHostInfo: vi.fn(),
getActiveSshAiVaultHostInfos: vi.fn(),
requestActiveSshAiVaultSessionList: vi.fn(),
ipcHandle: vi.fn()
}))
@ -47,7 +49,8 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({
vi.mock('./ssh', () => ({
getActiveSshAiVaultHostInfo: mocks.getActiveSshAiVaultHostInfo,
getActiveSshAiVaultHostInfos: mocks.getActiveSshAiVaultHostInfos
getActiveSshAiVaultHostInfos: mocks.getActiveSshAiVaultHostInfos,
requestActiveSshAiVaultSessionList: mocks.requestActiveSshAiVaultSessionList
}))
const { _internals, registerAiVaultHandlers } = await import('./ai-vault')
@ -66,6 +69,7 @@ beforeEach(() => {
result([session('runtime:remote-server', 'runtime-session')])
)
mocks.getSshFilesystemProvider.mockReturnValue(provider)
mocks.requestActiveSshAiVaultSessionList.mockResolvedValue(null)
mocks.getActiveSshAiVaultHostInfo.mockReturnValue(hostInfo('dev-box'))
mocks.getActiveSshAiVaultHostInfos.mockReturnValue([hostInfo('dev-box')])
})
@ -101,11 +105,160 @@ describe('listAiVaultSessions host routing', () => {
)
})
it('uses one target-side relay scan when the SSH relay supports it', async () => {
mocks.requestActiveSshAiVaultSessionList.mockResolvedValue(
result([session('local', 'remote-session')])
)
const scanned = await _internals.listAiVaultSessions({
executionHostScope: 'ssh:dev-box',
scopePaths: ['/home/ada/repo']
})
expect(mocks.requestActiveSshAiVaultSessionList).toHaveBeenCalledWith(
'dev-box',
{
limit: undefined,
scopePaths: ['/home/ada/repo']
},
expect.objectContaining({ signal: expect.any(AbortSignal) })
)
expect(mocks.scanRemoteAiVaultSessions).not.toHaveBeenCalled()
expect(scanned.sessions[0]).toMatchObject({
executionHostId: 'ssh:dev-box',
id: expect.stringContaining('ssh:dev-box:')
})
})
it('marks oversized project scopes when sending their bounded relay form', async () => {
const scopePaths = Array.from({ length: 80 }, (_, index) => `/repo/${index}`)
mocks.requestActiveSshAiVaultSessionList.mockResolvedValue(result([]))
await _internals.listAiVaultSessions({
executionHostScope: 'ssh:dev-box',
scopePaths
})
expect(mocks.requestActiveSshAiVaultSessionList).toHaveBeenCalledWith(
'dev-box',
{
limit: undefined,
scopePaths: scopePaths.slice(0, 64),
scopePathsTruncated: true
},
expect.objectContaining({ signal: expect.any(AbortSignal) })
)
})
it('caps and reports oversized project scopes on the SSH filesystem fallback', async () => {
const scopePaths = Array.from({ length: 80 }, (_, index) => `/repo/${index}`)
const scanned = await _internals.listAiVaultSessions({
executionHostScope: 'ssh:dev-box',
scopePaths
})
expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledWith(
expect.objectContaining({ scopePaths: scopePaths.slice(0, 64) })
)
expect(scanned.issues).toContainEqual(
expect.objectContaining({
kind: 'scope',
message: expect.stringContaining('first 64 project paths')
})
)
})
it('coalesces concurrent cancellable requests into one scan', async () => {
let resolveRelay: (() => void) | undefined
mocks.requestActiveSshAiVaultSessionList.mockImplementation(
() =>
new Promise((resolve) => {
resolveRelay = () => resolve(result([]))
})
)
const args = { executionHostScope: 'ssh:dev-box' as const }
const first = _internals.listAiVaultSessions(args, { signal: new AbortController().signal })
const second = _internals.listAiVaultSessions(args, { signal: new AbortController().signal })
await vi.waitFor(() => expect(resolveRelay).toBeDefined())
expect(mocks.requestActiveSshAiVaultSessionList).toHaveBeenCalledTimes(1)
resolveRelay?.()
await expect(Promise.all([first, second])).resolves.toHaveLength(2)
})
it('does not start a second remote crawl after the relay scan budget expires', async () => {
mocks.requestActiveSshAiVaultSessionList.mockRejectedValue(relayTimeoutError())
const scanned = await _internals.listAiVaultSessions({
executionHostScope: 'ssh:dev-box'
})
expect(mocks.scanRemoteAiVaultSessions).not.toHaveBeenCalled()
expect(scanned.sessions).toEqual([])
expect(scanned.issues[0]?.message).toContain('timed out')
})
it('does not cache a host-level relay failure', async () => {
mocks.requestActiveSshAiVaultSessionList.mockRejectedValue(relayTimeoutError())
await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' })
await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' })
expect(mocks.requestActiveSshAiVaultSessionList).toHaveBeenCalledTimes(2)
})
it('falls back to the filesystem crawl after a non-timeout relay failure', async () => {
mocks.requestActiveSshAiVaultSessionList.mockRejectedValue(
new Error('Invalid aiVault.listSessions response')
)
const scanned = await _internals.listAiVaultSessions({
executionHostScope: 'ssh:dev-box'
})
expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1)
expect(scanned.sessions).toEqual([expect.objectContaining({ sessionId: 'remote-session' })])
})
it('falls back when a nonempty relay sessions array contains no valid rows', async () => {
mocks.requestActiveSshAiVaultSessionList.mockResolvedValue({
sessions: [{ id: 42 }],
issues: [],
scannedAt: '2026-07-27T00:00:00.000Z'
})
const scanned = await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' })
expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1)
expect(scanned.sessions).toEqual([expect.objectContaining({ sessionId: 'remote-session' })])
})
it('uses the relay scan without requiring the fallback filesystem provider', async () => {
mocks.getSshFilesystemProvider.mockReturnValue(undefined)
mocks.requestActiveSshAiVaultSessionList.mockResolvedValue(
result([session('local', 'remote-session')])
)
const scanned = await _internals.listAiVaultSessions({
executionHostScope: 'ssh:dev-box'
})
expect(scanned.sessions).toHaveLength(1)
expect(mocks.scanRemoteAiVaultSessions).not.toHaveBeenCalled()
})
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(mocks.requestActiveSshAiVaultSessionList).toHaveBeenCalledWith(
'dev-box',
expect.any(Object),
expect.objectContaining({ timeoutMs: 15_000 })
)
expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['ssh:dev-box', 'local'])
})
@ -159,6 +312,50 @@ describe('listAiVaultSessions host routing', () => {
])
})
it('keeps SSH results when the local scan itself throws', async () => {
// Why: `all` awaits every leg together, so an unguarded local throw (parse
// cache load, WSL home resolution) would discard every host's sessions.
mocks.scanAiVaultSessions.mockRejectedValue(new Error('session parse cache is corrupt'))
registerAiVaultHandlers({
getActiveRuntimeAiVaultHostInfos: () => [],
scanRuntimeAiVaultSessions: mocks.scanRuntimeAiVaultSessions
})
const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' })
expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['ssh:dev-box'])
expect(result.issues).toEqual([
expect.objectContaining({
executionHostId: 'local',
kind: 'host',
path: 'this computer',
message: 'session parse cache is corrupt'
})
])
})
it('keeps local results when SSH host discovery fails', async () => {
mocks.getActiveSshAiVaultHostInfos.mockImplementation(() => {
throw new Error('relay session map is unavailable')
})
registerAiVaultHandlers({
getActiveRuntimeAiVaultHostInfos: () => [],
scanRuntimeAiVaultSessions: mocks.scanRuntimeAiVaultSessions
})
const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' })
expect(mocks.scanAiVaultSessions).toHaveBeenCalledTimes(1)
expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['local'])
expect(result.issues).toEqual([
expect.objectContaining({
agent: 'codex',
path: 'SSH hosts',
message: 'relay session map is unavailable'
})
])
})
it('keeps direct runtime host scans on the normal runtime timeout', async () => {
registerAiVaultHandlers({
getActiveRuntimeAiVaultHostInfos: () => [],
@ -205,6 +402,63 @@ describe('listAiVaultSessions host routing', () => {
expect(mocks.scanAiVaultSessions).toHaveBeenCalledTimes(1)
expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1)
})
it('caches completed SSH scans by host and workspace scope', async () => {
await _internals.listAiVaultSessions({
executionHostScope: 'ssh:dev-box',
scopePaths: ['/home/ada/repo-a', '/home/ada/repo-b']
})
await _internals.listAiVaultSessions({
executionHostScope: 'ssh:dev-box',
scopePaths: ['/home/ada/repo-b', '/home/ada/repo-a']
})
expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1)
})
it('serves lower SSH depths from a larger completed scan', async () => {
const base = { executionHostScope: 'ssh:dev-box' as const, scopePaths: ['/home/ada/repo'] }
await _internals.listAiVaultSessions({ ...base, limit: 1000 })
await _internals.listAiVaultSessions({ ...base, limit: 250 })
await _internals.listAiVaultSessions({ ...base, limit: 500 })
expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1)
})
it('threads renderer cancellation into the SSH relay request', async () => {
let relaySignal: AbortSignal | undefined
mocks.requestActiveSshAiVaultSessionList.mockImplementation(
(_targetId, _params, options: { signal?: AbortSignal }) =>
new Promise((_resolve, reject) => {
relaySignal = options.signal
options.signal?.addEventListener(
'abort',
() => {
const error = new Error('cancelled')
error.name = 'AbortError'
reject(error)
},
{ once: true }
)
})
)
registerAiVaultHandlers()
const event = { sender: { id: 7 } }
const pending = getIpcHandler('aiVault:listSessions')(event, {
executionHostScope: 'ssh:dev-box',
requestToken: 'scan-1'
})
await vi.waitFor(() => expect(relaySignal).toBeDefined())
await getIpcHandler('aiVault:cancelListSessions')(event, {
requestToken: 'scan-1'
})
expect(relaySignal?.aborted).toBe(true)
// Resolved, not rejected: Electron logs every rejected handler, and a
// superseded scan is normal control flow rather than a failure.
await expect(pending).resolves.toMatchObject({ cancelled: true, sessions: [] })
})
})
describe('prepareSessionResume IPC', () => {
@ -280,6 +534,14 @@ function getPrepareSessionResumeHandler(): (
return registration[1]
}
function getIpcHandler(channel: string): (...args: unknown[]) => unknown {
const registration = mocks.ipcHandle.mock.calls.find(([registered]) => registered === channel)
if (!registration) {
throw new Error(`${channel} was not registered`)
}
return registration[1]
}
describe('listAiVaultSubagentSessions gating', () => {
const claudeRoot = join(homedir(), '.claude', 'projects')
@ -365,6 +627,14 @@ function hostInfo(targetId: string) {
}
}
/** Mirrors the multiplexer's typed timeout: callers branch on the code, not on
* the message text. */
function relayTimeoutError(): Error {
return Object.assign(new Error('Request "aiVault.listSessions" timed out after 130000ms'), {
code: SSH_MUX_REQUEST_TIMEOUT_CODE
})
}
function result(sessions: AiVaultSession[]): AiVaultListResult {
return { sessions, issues: [], scannedAt: new Date().toISOString() }
}

View File

@ -7,17 +7,24 @@ import {
resetAiVaultSessionListCacheForTests,
type AiVaultSessionSources
} from '../ai-vault/cached-session-list'
import { scanRemoteAiVaultSessions } from '../ai-vault/remote-session-scanner'
import { listClaudeSubagentSessions } from '../ai-vault/session-scanner-claude-subagents'
import { claudeProjectsRootDirs } from '../ai-vault/session-scanner-source-discovery'
import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
import { aiVaultScanIssueResult, mergeAiVaultListResults } from '../ai-vault/session-list-results'
import type {
AiVaultFirstUserPromptArgs,
AiVaultListArgs,
AiVaultListResult,
AiVaultSubagentListArgs,
AiVaultSubagentListResult
import {
aiVaultScanIssueResult,
cancelledAiVaultListResult,
mergeAiVaultListResults
} from '../ai-vault/session-list-results'
import { scanSshAiVaultSessions } from '../ai-vault/ssh-session-list'
import { AiVaultScanCoordinator } from '../ai-vault/ai-vault-scan-coordinator'
import {
AI_VAULT_SCOPE_PATHS_MAX_COUNT,
isAiVaultScanCancelledError,
type AiVaultFirstUserPromptArgs,
type AiVaultListArgs,
type AiVaultListResult,
type AiVaultSubagentListArgs,
type AiVaultSubagentListResult
} from '../../shared/ai-vault-types'
import { handleAiVaultGetFirstUserPrompt } from '../ai-vault/session-first-user-prompt-read'
import { registerAiVaultResumeHandler, type AiVaultResumeHandlerOptions } from './ai-vault-resume'
@ -29,126 +36,150 @@ import {
toSshExecutionHostId,
type ExecutionHostScope
} from '../../shared/execution-host'
import { getActiveSshAiVaultHostInfos } from './ssh'
import { createSenderScopedRequestCancellations } from './sender-scoped-request-cancellation'
import { discoverAiVaultHosts, type AiVaultHostDiscoveryResult } from './ai-vault-host-discovery'
import {
getSshFilesystemProvider,
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE
} from '../providers/ssh-filesystem-dispatch'
import { getActiveSshAiVaultHostInfo, getActiveSshAiVaultHostInfos } from './ssh'
scanRuntimeAiVaultSessions,
type RuntimeAiVaultHostInfo,
type RuntimeAiVaultScanner
} from './ai-vault-runtime-scan'
import { resetAiVaultHostLegCacheForTests, scanHostLegWithCache } from './ai-vault-host-leg-cache'
import { requestedAiVaultSessionDepth } from '../../shared/ai-vault-session-depth'
const AI_VAULT_CACHE_TTL_MS = 15_000
const AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS = 3_000
// Why: a remote home with many agent roots routinely needs seconds to walk,
// stat and parse. The old shared 3s bound emptied healthy SSH hosts in the
// all-hosts view; the relay gets a real scan budget and the whole leg (relay
// attempt plus any legacy crawl) stays bounded so one host can't hold the
// merge open.
const AI_VAULT_ALL_HOST_SSH_RELAY_TIMEOUT_MS = 15_000
const AI_VAULT_ALL_HOST_SSH_TIMEOUT_MS = 20_000
type AiVaultHandlerOptions = AiVaultSessionSources &
AiVaultResumeHandlerOptions & {
getActiveRuntimeAiVaultHostInfos?: () => readonly RuntimeAiVaultHostInfo[]
scanRuntimeAiVaultSessions?: (
environmentId: string,
args: AiVaultListArgs,
options?: RuntimeAiVaultScanOptions
) => Promise<AiVaultListResult>
scanRuntimeAiVaultSessions?: RuntimeAiVaultScanner
}
type RuntimeAiVaultScanOptions = {
timeoutMs?: number
}
type CachedAiVaultList = {
key: string
result: AiVaultListResult
expiresAt: number
}
type RuntimeAiVaultHostInfo = {
environmentId: string
executionHostId: `runtime:${string}`
}
let cachedList: CachedAiVaultList | null = null
let inflightList: Promise<AiVaultListResult> | null = null
let inflightKey: string | null = null
let scanCoordinator = new AiVaultScanCoordinator()
let handlerOptions: AiVaultHandlerOptions = {}
const listCancellations = createSenderScopedRequestCancellations()
async function listAiVaultSessions(args?: AiVaultListArgs): Promise<AiVaultListResult> {
async function listAiVaultSessions(
args?: AiVaultListArgs,
options: { signal?: AbortSignal } = {}
): Promise<AiVaultListResult> {
const executionHostScope = normalizeExecutionHostScope(
args?.executionHostScope ?? LOCAL_EXECUTION_HOST_ID
)
// Why: local-scope scans go straight to the shared cache module (also used by
// the runtime RPC method), so the desktop panel and a paired mobile client
// never double-scan the same transcripts; the cache below only has to dedupe
// the multi-host (ssh/runtime/all) merges that exist on the desktop side.
if (executionHostScope === LOCAL_EXECUTION_HOST_ID) {
return scanLocalAiVaultSessions(args)
}
// Scope paths change the result set, so they must be part of the cache key.
// A scanner consumes at most 64 paths, so smaller equivalent workspace sets
// can share a snapshot regardless of which worktree was selected first.
const scopePaths = args?.scopePaths ?? []
const key = JSON.stringify({
limit: args?.limit ?? 'default',
scopePaths: args?.scopePaths ?? [],
scopePaths:
scopePaths.length <= AI_VAULT_SCOPE_PATHS_MAX_COUNT
? [...new Set(scopePaths)].sort()
: scopePaths,
executionHostScope
})
const now = Date.now()
// Why: opening this panel repeatedly should not re-parse hundreds of JSONL
// transcripts; explicit refreshes bypass the cache but not an active scan.
if (args?.force !== true && cachedList?.key === key && cachedList.expiresAt > now) {
return cachedList.result
}
if (inflightList && inflightKey === key) {
return inflightList
}
inflightKey = key
inflightList = scanAiVaultSessionsByHostScope(args, executionHostScope)
.then((result) => {
cachedList = {
key,
result,
expiresAt: Date.now() + AI_VAULT_CACHE_TTL_MS
const depth = requestedAiVaultSessionDepth(args)
const scanKey = JSON.stringify({ key, depth })
// Why: every renderer request carries its own cancellation signal, so
// coalescing has to survive them — the coordinator hands all same-key callers
// one scan and only aborts it once every one of them has cancelled.
return scanCoordinator.run({
key: scanKey,
force: args?.force,
signal: options.signal,
start: (scanSignal) => {
const scan = () => scanAiVaultSessionsByHostScope(args, executionHostScope, scanSignal, key)
if (executionHostScope === LOCAL_EXECUTION_HOST_ID) {
return scan()
}
return result
})
.finally(() => {
// 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
return scanHostLegWithCache({
cacheKey: key,
depth,
scopePaths,
force: args?.force === true,
scan
})
}
})
}
async function scanAiVaultSessionsByHostScope(
args: AiVaultListArgs | undefined,
executionHostScope: ExecutionHostScope
executionHostScope: ExecutionHostScope,
signal?: AbortSignal,
cacheKey = ''
): Promise<AiVaultListResult> {
const depth = requestedAiVaultSessionDepth(args)
const scopePaths = args?.scopePaths ?? []
if (executionHostScope === LOCAL_EXECUTION_HOST_ID) {
return scanLocalAiVaultSessions(args, signal)
}
if (executionHostScope === 'all') {
const runtimeHosts = getActiveRuntimeAiVaultHostInfosResult()
const runtimeResults = runtimeHosts.issue ? [runtimeHosts.issue] : []
const sshHosts = getActiveSshAiVaultHostInfosResult()
const runtimeResults = [
...(runtimeHosts.issue ? [runtimeHosts.issue] : []),
...(sshHosts.issue ? [sshHosts.issue] : [])
]
const scannedResults = await Promise.all([
scanLocalAiVaultSessions(args),
...getActiveSshAiVaultHostInfos().map((hostInfo) =>
scanSshAiVaultSessions(hostInfo.targetId, args)
scanLocalAiVaultSessionsForAllScope(args, signal),
...sshHosts.hostInfos.map((hostInfo) =>
scanHostLegWithCache({
cacheKey: `${cacheKey}|${toSshExecutionHostId(hostInfo.targetId)}`,
depth,
scopePaths,
force: args?.force === true,
scan: () =>
scanSshAiVaultSessions(hostInfo.targetId, args, {
signal,
timeoutMs: AI_VAULT_ALL_HOST_SSH_TIMEOUT_MS,
relayTimeoutMs: AI_VAULT_ALL_HOST_SSH_RELAY_TIMEOUT_MS
})
})
),
...runtimeHosts.hostInfos.map((hostInfo) =>
scanRuntimeAiVaultSessions(hostInfo, args, {
timeoutMs: AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS
scanHostLegWithCache({
cacheKey: `${cacheKey}|${hostInfo.executionHostId}`,
depth,
scopePaths,
force: args?.force === true,
scan: () =>
scanRuntimeAiVaultSessions({
hostInfo,
scanner: handlerOptions.scanRuntimeAiVaultSessions,
listArgs: args,
options: { signal, timeoutMs: AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS }
})
})
)
])
return mergeAiVaultListResults([...scannedResults, ...runtimeResults], args?.limit)
return mergeAiVaultListResults(
[...scannedResults, ...runtimeResults],
args?.limit,
args?.unlimited
)
}
const parsed = parseExecutionHostId(executionHostScope)
if (parsed?.kind === 'ssh') {
return scanSshAiVaultSessions(parsed.targetId, args)
return scanSshAiVaultSessions(parsed.targetId, args, { signal })
}
if (parsed?.kind === 'runtime') {
return scanRuntimeAiVaultSessions(
{
return scanRuntimeAiVaultSessions({
hostInfo: {
environmentId: parsed.environmentId,
executionHostId: toRuntimeExecutionHostId(parsed.environmentId)
},
args
)
scanner: handlerOptions.scanRuntimeAiVaultSessions,
listArgs: args,
options: { signal }
})
}
return aiVaultScanIssueResult({
@ -158,118 +189,57 @@ async function scanAiVaultSessionsByHostScope(
})
}
function getActiveRuntimeAiVaultHostInfos(): readonly RuntimeAiVaultHostInfo[] {
return handlerOptions.getActiveRuntimeAiVaultHostInfos?.() ?? []
}
function getActiveRuntimeAiVaultHostInfosResult(): {
hostInfos: readonly RuntimeAiVaultHostInfo[]
issue?: AiVaultListResult
} {
try {
return { hostInfos: getActiveRuntimeAiVaultHostInfos() }
} catch (error) {
return {
hostInfos: [],
issue: runtimeHostDiscoveryIssueResult(
error instanceof Error ? error.message : 'Runtime hosts are unavailable.'
)
}
}
}
async function scanRuntimeAiVaultSessions(
hostInfo: RuntimeAiVaultHostInfo,
args?: AiVaultListArgs,
options: RuntimeAiVaultScanOptions = {}
): Promise<AiVaultListResult> {
const scanner = handlerOptions.scanRuntimeAiVaultSessions
if (!scanner) {
return runtimeScanIssueResult(
hostInfo,
'Agent Session History is not available for this execution host.'
)
}
const scanArgs: AiVaultListArgs = { executionHostScope: hostInfo.executionHostId }
if (args?.limit !== undefined) {
scanArgs.limit = args.limit
}
if (args?.force !== undefined) {
scanArgs.force = args.force
}
if (args?.scopePaths !== undefined) {
scanArgs.scopePaths = args.scopePaths
}
try {
return await scanner(hostInfo.environmentId, scanArgs, options)
} catch (error) {
return runtimeScanIssueResult(
hostInfo,
error instanceof Error ? error.message : 'Remote Orca server is unavailable.'
)
}
}
function runtimeScanIssueResult(
hostInfo: RuntimeAiVaultHostInfo,
message: string
): AiVaultListResult {
return aiVaultScanIssueResult({
executionHostId: hostInfo.executionHostId,
path: hostInfo.environmentId,
message
function getActiveRuntimeAiVaultHostInfosResult(): AiVaultHostDiscoveryResult<RuntimeAiVaultHostInfo> {
return discoverAiVaultHosts(() => handlerOptions.getActiveRuntimeAiVaultHostInfos?.() ?? [], {
path: 'runtime environments',
fallbackMessage: 'Runtime hosts are unavailable.'
})
}
function runtimeHostDiscoveryIssueResult(message: string): AiVaultListResult {
return aiVaultScanIssueResult({ path: 'runtime environments', message })
function getActiveSshAiVaultHostInfosResult(): AiVaultHostDiscoveryResult<{ targetId: string }> {
return discoverAiVaultHosts(getActiveSshAiVaultHostInfos, {
path: 'SSH hosts',
fallbackMessage: 'SSH hosts are unavailable.'
})
}
async function scanLocalAiVaultSessions(args?: AiVaultListArgs): Promise<AiVaultListResult> {
// Why: the SSH legs already degrade to an issue row so one bad host can't take
// the shared Promise.all down; the local leg can throw too (parse-cache load,
// WSL home resolution) and would otherwise discard every host's sessions.
async function scanLocalAiVaultSessionsForAllScope(
args: AiVaultListArgs | undefined,
signal: AbortSignal | undefined
): Promise<AiVaultListResult> {
try {
return await scanLocalAiVaultSessions(args, signal)
} catch (error) {
if (isAiVaultScanCancelledError(error)) {
throw error
}
return aiVaultScanIssueResult({
executionHostId: LOCAL_EXECUTION_HOST_ID,
path: 'this computer',
message: error instanceof Error ? error.message : 'Local session scan failed.'
})
}
}
async function scanLocalAiVaultSessions(
args?: AiVaultListArgs,
signal?: AbortSignal
): Promise<AiVaultListResult> {
// Why: the shared cache module owns codex-home/WSL sourcing and the local
// scan cache, so the desktop IPC path and the runtime RPC method (mobile)
// share one cache instance and one source of managed-Codex homes.
return listCachedLocalAiVaultSessions({
limit: args?.limit,
force: args?.force,
scopePaths: args?.scopePaths
})
}
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 aiVaultScanIssueResult({
executionHostId: args.executionHostId,
path: args.targetId,
message: args.message
})
return listCachedLocalAiVaultSessions(
{
limit: args?.limit,
unlimited: args?.unlimited,
force: args?.force,
scopePaths: args?.scopePaths
},
{ signal }
)
}
export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): void {
@ -279,8 +249,32 @@ export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): vo
// WSL injection. The runtime also configures these sources from its deps
// (serve-mode reachable); this desktop path supplies the same source.
configureAiVaultSessionSources(options)
ipcMain.handle('aiVault:listSessions', (_event, args?: AiVaultListArgs) =>
listAiVaultSessions(args)
ipcMain.handle('aiVault:listSessions', async (event, args?: AiVaultListArgs) => {
const requestToken =
typeof args?.requestToken === 'string' && args.requestToken.length <= 128
? args.requestToken
: undefined
const controller = listCancellations.begin(event, requestToken)
try {
return await listAiVaultSessions(args, { signal: controller?.signal })
} catch (error) {
// Why: superseding a scan is normal control flow, but Electron logs every
// rejected handler — report it as a result so the log stays truthful.
if (!isAiVaultScanCancelledError(error)) {
throw error
}
return cancelledAiVaultListResult()
} finally {
listCancellations.finish(event, requestToken, controller)
}
})
ipcMain.handle(
'aiVault:cancelListSessions',
(event, args: { requestToken?: string } | undefined): void => {
if (typeof args?.requestToken === 'string' && args.requestToken.length <= 128) {
listCancellations.cancel(event, args.requestToken)
}
}
)
registerAiVaultResumeHandler(options)
ipcMain.handle(
@ -335,9 +329,8 @@ async function listAiVaultSubagentSessions(
}
function resetAiVaultCacheForTests(): void {
cachedList = null
inflightList = null
inflightKey = null
resetAiVaultHostLegCacheForTests()
scanCoordinator = new AiVaultScanCoordinator()
handlerOptions = {}
// The local leg delegates to the shared cache module; reset it too so tests
// never see a scan cached by an earlier case.

View File

@ -7,6 +7,7 @@ import type { SshConnectionCallbacks } from '../ssh/ssh-connection'
import { SshConnectionManager } from '../ssh/ssh-connection-manager'
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import { SshRelaySession, type SshRelayAiVaultHostInfo } from '../ssh/ssh-relay-session'
import type { SshAiVaultRelayListParams } from '../../shared/ssh-ai-vault-relay'
import { SshPortForwardManager } from '../ssh/ssh-port-forward'
import type {
DetectedPort,
@ -161,6 +162,21 @@ export function getActiveSshAiVaultHostInfos(): SshRelayAiVaultHostInfo[] {
})
}
export async function requestActiveSshAiVaultSessionList(
targetId: string,
params: SshAiVaultRelayListParams,
options: { signal?: AbortSignal; timeoutMs?: number } = {}
): Promise<unknown | null> {
if (isRuntimeOwnedSshTargetId(targetId)) {
return null
}
const session = activeSessions.get(targetId)
if (!session) {
throw new Error('SSH relay is not ready')
}
return session.requestAiVaultSessionList(params, options)
}
function runTargetLifecycle(targetId: string, operation: () => Promise<void>): Promise<void> {
const prior = targetLifecycleInFlight.get(targetId)
const operationPromise = (async () => {

View File

@ -85,6 +85,11 @@ describe('aiVault.listSessions params schema', () => {
expect(parsed.success).toBe(false)
})
it('accepts Unlimited without applying the numeric cap', () => {
const parsed = AiVaultListSessionsParams.safeParse({ limit: 5000, unlimited: true })
expect(parsed.success).toBe(true)
})
it('clamps scopePaths past the cap instead of rejecting', () => {
// Why: uncapped producers (web client, pre-cap desktop parents) may exceed
// the bound; scope paths only widen discovery, so truncation is safe.
@ -181,6 +186,33 @@ describe('aiVault.listSessions handler + shared cache', () => {
expect(scanAiVaultSessions).toHaveBeenCalledTimes(1)
})
it('serves lower depths from a larger completed scan', async () => {
await listAiVaultSessions({ limit: 1000 })
await listAiVaultSessions({ limit: 250 })
await listAiVaultSessions({ limit: 500 })
expect(scanAiVaultSessions).toHaveBeenCalledTimes(1)
})
it('shares a cache entry across equivalent scope path ordering', async () => {
await listAiVaultSessions({ limit: 500, scopePaths: ['/repo/a', '/repo/b'] })
await listAiVaultSessions({ limit: 500, scopePaths: ['/repo/b', '/repo/a'] })
expect(scanAiVaultSessions).toHaveBeenCalledTimes(1)
})
it('forwards Unlimited without a numeric limit', async () => {
const dispatcher = makeDispatcher()
const response = await dispatcher.dispatch(
makeRequest('aiVault.listSessions', { limit: 5000, unlimited: true })
)
expect(response).toMatchObject({ ok: true })
expect(scanAiVaultSessions).toHaveBeenCalledWith(
expect.objectContaining({ limit: undefined, unlimited: true })
)
})
it('keeps a newer different-key scan dedupable after an older scan resolves', async () => {
// Why: the resolving scan's cleanup must not clear tracking a concurrent
// different-key scan replaced, or re-requests start a duplicate rescan.

View File

@ -23,27 +23,34 @@ const executionHostIdSchema = z.string().transform((value, ctx): `runtime:${stri
return z.NEVER
})
export const AiVaultListSessionsParams = z.object({
limit: z
.unknown()
.transform((value) =>
typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined
)
.pipe(z.union([z.number().int().max(AI_VAULT_LIMIT_MAX), z.undefined()]))
.optional(),
force: OptionalBoolean,
scopePaths: z
.array(z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH))
// Why: clamp instead of reject — scope paths only ever widen discovery, and
// rejecting would hard-break older/uncapped producers (web client, pre-cap
// desktop parents) that send more than the bound.
.transform((paths) => paths.slice(0, AI_VAULT_SCOPE_PATHS_MAX_COUNT))
.optional(),
// Why: desktop/web callers name the runtime host they are addressing; mobile
// omits it. The scan itself is host-local either way, so the id must never
// change what is scanned — it only restamps the shared cached result.
executionHostId: executionHostIdSchema.optional()
})
export const AiVaultListSessionsParams = z
.object({
limit: z
.unknown()
.transform((value) =>
typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined
)
.pipe(z.union([z.number().int(), z.undefined()]))
.optional(),
unlimited: OptionalBoolean,
force: OptionalBoolean,
scopePaths: z
.array(z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH))
// Why: clamp instead of reject — scope paths only ever widen discovery, and
// rejecting would hard-break older/uncapped producers (web client, pre-cap
// desktop parents) that send more than the bound.
.transform((paths) => paths.slice(0, AI_VAULT_SCOPE_PATHS_MAX_COUNT))
.optional(),
// Why: desktop/web callers name the runtime host they are addressing; mobile
// omits it. The scan itself is host-local either way, so the id must never
// change what is scanned — it only restamps the shared cached result.
executionHostId: executionHostIdSchema.optional()
})
.superRefine((params, ctx) => {
if (params.unlimited !== true && params.limit && params.limit > AI_VAULT_LIMIT_MAX) {
ctx.addIssue({ code: 'custom', path: ['limit'], message: 'Limit exceeds maximum' })
}
})
export const AiVaultPrepareSessionResumeParams = z.object({
agent: z.enum(AI_VAULT_AGENTS),
@ -58,7 +65,8 @@ export const AI_VAULT_METHODS: RpcMethod[] = [
params: AiVaultListSessionsParams,
handler: async (params, { runtime }) => {
const result = await runtime.listAiVaultSessions({
limit: params.limit,
limit: params.unlimited ? undefined : params.limit,
unlimited: params.unlimited,
force: params.force,
scopePaths: params.scopePaths
})

View File

@ -48,6 +48,24 @@ const MAX_UNACKED_TIMESTAMPS = MAX_ORDINARY_UNACKED_TIMESTAMPS + 1
// (system sleep, App Nap timer throttling) — not that the link is dead (#7773).
const WAKE_GAP_MS = KEEPALIVE_SEND_MS * 3
// Why: callers branch on "the request timed out" (fall back to a slower path,
// report a host issue). Matching the message text made every unrelated error
// carrying the same phrase take the timeout branch.
export const SSH_MUX_REQUEST_TIMEOUT_CODE = 'SSH_MUX_REQUEST_TIMEOUT'
function sshMuxRequestTimeoutError(method: string, timeoutMs: number): Error {
return Object.assign(new Error(`Request "${method}" timed out after ${timeoutMs}ms`), {
code: SSH_MUX_REQUEST_TIMEOUT_CODE
})
}
export function isSshMuxRequestTimeoutError(error: unknown): boolean {
return (
error instanceof Error &&
(error as Error & { code?: unknown }).code === SSH_MUX_REQUEST_TIMEOUT_CODE
)
}
export class SshChannelMultiplexer {
private decoder: FrameDecoder
private transport: MultiplexerTransport
@ -231,7 +249,7 @@ export class SshChannelMultiplexer {
this.notify('rpc.cancel', { id })
}
this.pendingRequests.delete(id)
reject(new Error(`Request "${method}" timed out after ${timeoutMs}ms`))
reject(sshMuxRequestTimeoutError(method, timeoutMs))
}, timeoutMs)
if (options?.signal) {

View File

@ -15,6 +15,7 @@ import type { SshPtyRecoveryActivationLease } from '../providers/ssh-pty-notific
import { isSshPtyIdentityMismatchError, isSshPtyNotFoundError } from '../providers/ssh-pty-errors'
import { toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id'
import { SshFilesystemProvider } from '../providers/ssh-filesystem-provider'
import { isMethodNotFoundError } from './ssh-filesystem-stream-reader'
import { SshGitProvider } from '../providers/ssh-git-provider'
import { agentHookServer } from '../agent-hooks/server'
import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls'
@ -86,6 +87,11 @@ import {
parseRemoteOrcaCliPostOutput
} from './ssh-remote-orchestration-post-output'
import { toSshExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
import {
SSH_AI_VAULT_LIST_SESSIONS_METHOD,
SSH_AI_VAULT_LIST_SESSIONS_TIMEOUT_MS,
type SshAiVaultRelayListParams
} from '../../shared/ssh-ai-vault-relay'
import { isTerminalLeafId, makePaneKey } from '../../shared/stable-pane-id'
import { isValidTerminalTabId } from '../../shared/terminal-tab-id'
import {
@ -272,6 +278,7 @@ export class SshRelaySession {
private currentConnection: SshConnection | null = null
private hostPlatform: RemoteHostPlatform | null = null
private remoteCliBridgeEnv: RemoteCliBridgeEnv | null = null
private aiVaultListMethodSupported: boolean | null = null
private pendingPtyReattaches = new Map<string, PendingPtyReattach>()
private readonly ptyRecoveryRetention = new SshPtyRecoveryRetentionBudget()
private activePtyProviderGeneration: number | null = null
@ -377,6 +384,33 @@ export class SshRelaySession {
}
}
async requestAiVaultSessionList(
params: SshAiVaultRelayListParams,
options: { signal?: AbortSignal; timeoutMs?: number } = {}
): Promise<unknown | null> {
if (this.aiVaultListMethodSupported === false) {
return null
}
const mux = this.mux
if (!mux || mux.isDisposed() || this._state !== 'ready') {
throw new Error('SSH relay is not ready')
}
try {
const result = await mux.request(SSH_AI_VAULT_LIST_SESSIONS_METHOD, params, {
signal: options.signal,
timeoutMs: options.timeoutMs ?? SSH_AI_VAULT_LIST_SESSIONS_TIMEOUT_MS
})
this.aiVaultListMethodSupported = true
return result
} catch (error) {
if (isMethodNotFoundError(error)) {
this.aiVaultListMethodSupported = false
return null
}
throw error
}
}
getPortScanner(): PortScanner | null {
return this.portScanner
}
@ -395,6 +429,7 @@ export class SshRelaySession {
throw new Error(`Cannot establish relay session in state: ${this._state}`)
}
this._state = 'deploying'
this.aiVaultListMethodSupported = null
this.currentConnection = conn
try {
@ -528,6 +563,7 @@ export class SshRelaySession {
this.abortController = abortController
this._state = 'reconnecting'
this.aiVaultListMethodSupported = null
this.currentConnection = conn
// Why: stop scanning before teardownProviders so the poll timer can't fire against a disposed multiplexer.

View File

@ -890,6 +890,7 @@ export type OpenCodeUsageApi = {
export type AiVaultApi = {
listSessions: (args?: AiVaultListArgs) => Promise<AiVaultListResult>
cancelListSessions: (args: { requestToken: string }) => Promise<void>
prepareSessionResume: (
args: AiVaultPrepareSessionResumeArgs
) => Promise<AiVaultPrepareSessionResumeResult>

View File

@ -4164,6 +4164,8 @@ const api = {
aiVault: {
listSessions: (args?: AiVaultListArgs): Promise<unknown> =>
ipcRenderer.invoke('aiVault:listSessions', args),
cancelListSessions: (args: { requestToken: string }): Promise<void> =>
ipcRenderer.invoke('aiVault:cancelListSessions', args),
prepareSessionResume: (args: AiVaultPrepareSessionResumeArgs): Promise<unknown> =>
ipcRenderer.invoke('aiVault:prepareSessionResume', args),
listSubagentSessions: (args: AiVaultSubagentListArgs): Promise<unknown> =>

View File

@ -0,0 +1,260 @@
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AiVaultListResult } from '../shared/ai-vault-types'
import { SSH_AI_VAULT_LIST_SESSIONS_METHOD } from '../shared/ssh-ai-vault-relay'
import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform'
import type { RelayDispatcher, RequestContext } from './dispatcher'
import { AiVaultHandler } from './ai-vault-handler'
type RequestHandler = (params: Record<string, unknown>, context: RequestContext) => Promise<unknown>
const temporaryHomes: string[] = []
afterEach(async () => {
await Promise.all(
temporaryHomes.splice(0).map((path) => rm(path, { recursive: true, force: true }))
)
})
describe('AiVaultHandler', () => {
it('discovers and parses sessions entirely on the relay host', async () => {
const remoteHome = await makeTemporaryHome()
const transcriptPath = join(
remoteHome,
'.codex',
'sessions',
'2026',
'07',
'26',
'rollout-test.jsonl'
)
await mkdir(dirname(transcriptPath), { recursive: true })
await writeFile(
transcriptPath,
[
JSON.stringify({
timestamp: '2026-07-26T01:00:00.000Z',
type: 'session_meta',
payload: { id: 'ssh-session', cwd: join(remoteHome, 'repo') }
}),
JSON.stringify({
timestamp: '2026-07-26T01:00:01.000Z',
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'text', text: 'Scan on the SSH target' }]
}
})
].join('\n')
)
const dispatcher = createMockDispatcher()
new AiVaultHandler(dispatcher.value, {
remoteHome,
hostPlatform: getRemoteHostPlatform('linux-x64')
})
const result = (await dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {
limit: 20
})) as AiVaultListResult
expect(result.issues).toEqual([])
expect(result.sessions).toHaveLength(1)
expect(result.sessions[0]).toMatchObject({
executionHostId: 'local',
executionHostPlatform: 'linux',
sessionId: 'ssh-session',
title: 'Scan on the SSH target',
filePath: transcriptPath
})
})
it('bounds relay scan parameters before touching the target filesystem', async () => {
const scanRemoteSessions = vi.fn().mockResolvedValue(emptyResult())
const dispatcher = createMockDispatcher()
new AiVaultHandler(dispatcher.value, {
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64'),
scanRemoteSessions
})
const scopePaths = Array.from({ length: 80 }, (_, index) => `/repo/${index}`)
await dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {
limit: 50_000,
scopePaths
})
expect(scanRemoteSessions).toHaveBeenCalledWith(
expect.objectContaining({
executionHostId: 'local',
limit: 1000,
scopePaths: scopePaths.slice(0, 64)
})
)
const result = (await dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {
scopePaths
})) as AiVaultListResult
expect(result.issues).toContainEqual(
expect.objectContaining({
kind: 'scope',
message: expect.stringContaining('first 64 project paths')
})
)
})
it('forwards Unlimited without the relay numeric cap', async () => {
const scanRemoteSessions = vi.fn().mockResolvedValue(emptyResult())
const dispatcher = createMockDispatcher()
new AiVaultHandler(dispatcher.value, {
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64'),
scanRemoteSessions
})
await dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {
limit: 50_000,
unlimited: true
})
expect(scanRemoteSessions).toHaveBeenCalledWith(
expect.objectContaining({ limit: undefined, unlimited: true })
)
})
it('coalesces identical in-flight scans without coupling caller cancellation', async () => {
let resolveScan: ((result: AiVaultListResult) => void) | undefined
let sharedSignal: AbortSignal | undefined
const scanRemoteSessions = vi.fn(
(args: { signal?: AbortSignal }) =>
new Promise<AiVaultListResult>((resolve) => {
sharedSignal = args.signal
resolveScan = resolve
})
)
const dispatcher = createMockDispatcher()
new AiVaultHandler(dispatcher.value, {
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64'),
scanRemoteSessions: scanRemoteSessions as never
})
const firstController = new AbortController()
const first = dispatcher.call(
SSH_AI_VAULT_LIST_SESSIONS_METHOD,
{ limit: 20 },
firstController.signal
)
const second = dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, { limit: 20 })
await vi.waitFor(() => expect(scanRemoteSessions).toHaveBeenCalledTimes(1))
firstController.abort()
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
expect(sharedSignal?.aborted).toBe(false)
resolveScan?.(emptyResult())
await expect(second).resolves.toEqual(emptyResult())
})
it('re-joins a preempted relay caller onto the forced refresh', async () => {
const signals: AbortSignal[] = []
let resolveForced: ((result: AiVaultListResult) => void) | undefined
const scanRemoteSessions = vi.fn((args: { signal: AbortSignal }) => {
signals.push(args.signal)
return new Promise<AiVaultListResult>((resolve) => {
if (signals.length === 1) {
args.signal.addEventListener('abort', () => resolve(emptyResult()), { once: true })
} else {
resolveForced = resolve
}
})
})
const dispatcher = createMockDispatcher()
new AiVaultHandler(dispatcher.value, {
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64'),
scanRemoteSessions: scanRemoteSessions as never
})
const first = dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, { limit: 20 })
await vi.waitFor(() => expect(signals).toHaveLength(1))
const forced = dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {
limit: 20,
force: true
})
await vi.waitFor(() => expect(signals).toHaveLength(2))
expect(signals[0]?.aborted).toBe(true)
resolveForced?.(emptyResult())
// The desktop caller that did not ask for a refresh still gets sessions.
await expect(Promise.all([first, forced])).resolves.toEqual([emptyResult(), emptyResult()])
})
it('soft-disables the method instead of aborting relay startup on an unsupported platform', () => {
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'freebsd', configurable: true })
const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
try {
const dispatcher = createMockDispatcher()
expect(() => new AiVaultHandler(dispatcher.value)).not.toThrow()
expect(() => dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {})).toThrow(/No handler/)
} finally {
stderr.mockRestore()
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
})
it('stops a relay-local scan when the owning request is cancelled', async () => {
const dispatcher = createMockDispatcher()
new AiVaultHandler(dispatcher.value, {
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64')
})
const controller = new AbortController()
controller.abort()
await expect(
dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {}, controller.signal)
).rejects.toMatchObject({ name: 'AbortError' })
})
})
async function makeTemporaryHome(): Promise<string> {
const path = await mkdtemp(join(tmpdir(), 'orca-relay-ai-vault-'))
temporaryHomes.push(path)
return path
}
function emptyResult(): AiVaultListResult {
return { sessions: [], issues: [], scannedAt: '2026-07-26T00:00:00.000Z' }
}
function createMockDispatcher(): {
value: RelayDispatcher
call: (method: string, params: Record<string, unknown>, signal?: AbortSignal) => Promise<unknown>
} {
const handlers = new Map<string, RequestHandler>()
const value = {
onRequest(method: string, handler: RequestHandler) {
handlers.set(method, handler)
}
} as RelayDispatcher
return {
value,
call(method, params, signal) {
const handler = handlers.get(method)
if (!handler) {
throw new Error(`No handler for ${method}`)
}
return handler(params, {
clientId: 1,
isStale: () => signal?.aborted ?? false,
signal
})
}
}
}

View File

@ -0,0 +1,159 @@
import { lstat, readdir } from 'node:fs/promises'
import { homedir } from 'node:os'
import { AI_VAULT_SCOPE_PATHS_MAX_COUNT, type AiVaultListResult } from '../shared/ai-vault-types'
import { LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host'
import {
SSH_AI_VAULT_LIST_LIMIT_MAX,
SSH_AI_VAULT_LIST_SESSIONS_METHOD,
SSH_AI_VAULT_SCOPE_PATH_MAX_LENGTH,
type SshAiVaultRelayListParams
} from '../shared/ssh-ai-vault-relay'
import { scanRemoteAiVaultSessions } from '../main/ai-vault/remote-session-scanner'
import type { RemoteSessionFilesystemProvider } from '../main/ai-vault/remote-session-scanner-types'
import { getRemoteHostPlatform, type RemoteHostPlatform } from '../main/ssh/ssh-remote-platform'
import { parseUnameToRelayPlatform } from '../main/ssh/relay-protocol'
import { readRelayFileContent } from './fs-handler-file-read'
import { relayLogLine } from './relay-diagnostic-log'
import type { RelayDispatcher } from './dispatcher'
import { AiVaultScanCoordinator } from '../main/ai-vault/ai-vault-scan-coordinator'
type ScanRemoteSessions = typeof scanRemoteAiVaultSessions
type AiVaultHandlerOptions = {
remoteHome?: string
hostPlatform?: RemoteHostPlatform
scanRemoteSessions?: ScanRemoteSessions
}
export class AiVaultHandler {
private readonly remoteHome: string
private readonly scanRemoteSessions: ScanRemoteSessions
private readonly provider: RemoteSessionFilesystemProvider
private readonly scanCoordinator = new AiVaultScanCoordinator()
constructor(dispatcher: RelayDispatcher, options: AiVaultHandlerOptions = {}) {
this.remoteHome = options.remoteHome ?? homedir()
this.scanRemoteSessions = options.scanRemoteSessions ?? scanRemoteAiVaultSessions
this.provider = createRelayAiVaultFilesystemProvider()
const hostPlatform = options.hostPlatform ?? currentRelayHostPlatform()
// Why: an OS/arch this build has no path flavor for must not abort relay
// startup — leaving the method unregistered soft-disables the feature and
// the host falls back to its own SSH filesystem scan.
if (!hostPlatform) {
relayLogLine(
`[relay] Agent Session History disabled: unsupported platform ${process.platform}-${process.arch}`
)
return
}
dispatcher.onRequest(SSH_AI_VAULT_LIST_SESSIONS_METHOD, (params, context) =>
this.listSessions(hostPlatform, params, context.signal)
)
}
private async listSessions(
hostPlatform: RemoteHostPlatform,
rawParams: Record<string, unknown>,
signal?: AbortSignal
): Promise<AiVaultListResult> {
const params = normalizeSshAiVaultRelayListParams(rawParams)
const result = await this.scanCoordinator.run({
key: JSON.stringify({
limit: params.limit,
unlimited: params.unlimited,
scopePaths: params.scopePaths,
scopePathsTruncated: params.scopePathsTruncated
}),
force: params.force,
signal,
start: (scanSignal) =>
this.scanRemoteSessions({
provider: this.provider,
executionHostId: LOCAL_EXECUTION_HOST_ID,
remoteHome: this.remoteHome,
hostPlatform,
limit: params.limit,
unlimited: params.unlimited,
scopePaths: params.scopePaths,
signal: scanSignal
})
})
if (!params.scopePathsTruncated) {
return result
}
return {
...result,
issues: [
...result.issues,
{
executionHostId: LOCAL_EXECUTION_HOST_ID,
agent: 'codex',
kind: 'scope',
path: this.remoteHome,
message: `Only the first ${AI_VAULT_SCOPE_PATHS_MAX_COUNT} project paths were scanned.`
}
]
}
}
}
export function normalizeSshAiVaultRelayListParams(
params: Record<string, unknown>
): SshAiVaultRelayListParams {
const rawLimit = params.limit
const unlimited = params.unlimited === true
const limit =
!unlimited && typeof rawLimit === 'number' && Number.isFinite(rawLimit) && rawLimit > 0
? Math.min(Math.floor(rawLimit), SSH_AI_VAULT_LIST_LIMIT_MAX)
: undefined
const scopePaths = Array.isArray(params.scopePaths)
? params.scopePaths
.slice(0, AI_VAULT_SCOPE_PATHS_MAX_COUNT)
.filter(
(path): path is string =>
typeof path === 'string' &&
path.trim().length > 0 &&
path.length <= SSH_AI_VAULT_SCOPE_PATH_MAX_LENGTH
)
: undefined
const scopePathsTruncated =
params.scopePathsTruncated === true ||
(Array.isArray(params.scopePaths) && params.scopePaths.length > AI_VAULT_SCOPE_PATHS_MAX_COUNT)
return {
...(unlimited ? { unlimited: true } : {}),
...(limit === undefined ? {} : { limit }),
...(params.force === true ? { force: true } : {}),
...(scopePaths === undefined ? {} : { scopePaths }),
...(scopePathsTruncated ? { scopePathsTruncated: true } : {})
}
}
function currentRelayHostPlatform(): RemoteHostPlatform | null {
const relayPlatform = parseUnameToRelayPlatform(process.platform, process.arch)
return relayPlatform ? getRemoteHostPlatform(relayPlatform) : null
}
function createRelayAiVaultFilesystemProvider(): RemoteSessionFilesystemProvider {
return {
async readDir(dirPath) {
const entries = await readdir(dirPath, { withFileTypes: true })
return entries.map((entry) => ({
name: entry.name,
isDirectory: entry.isDirectory(),
isSymlink: entry.isSymbolicLink()
}))
},
readFile: readRelayFileContent,
async stat(filePath) {
const stats = await lstat(filePath)
return {
size: stats.size,
type: stats.isDirectory() ? 'directory' : stats.isSymbolicLink() ? 'symlink' : 'file',
mtime: stats.mtimeMs,
mtimeMs: stats.mtimeMs,
dev: stats.dev,
ino: stats.ino,
nlink: stats.nlink
}
}
}
}

View File

@ -32,6 +32,7 @@ import { ExternalAutomationsHandler } from './external-automations-handler'
import { PortScanHandler } from './port-scan-handler'
import { AgentExecHandler } from './agent-exec-handler'
import { WorkspaceSessionHandler } from './workspace-session-handler'
import { AiVaultHandler } from './ai-vault-handler'
import { endpointDirForRelaySocket, RelayAgentHookServer } from './agent-hook-server'
import { PluginOverlayManager } from './plugin-overlay'
import {
@ -663,6 +664,9 @@ async function main(): Promise<void> {
const _workspaceSessionHandler = new WorkspaceSessionHandler(dispatcher)
void _workspaceSessionHandler
const _aiVaultHandler = new AiVaultHandler(dispatcher)
void _aiVaultHandler
// Why: relay-hosted plugin provisioning is a later phase. Register the
// enforcement boundary now with no consented identities or runtime services.
registerRelayPluginHostCallHandlers(

View File

@ -48,6 +48,7 @@ import {
} from './ai-vault-host-scope'
import { usePersistedAiVaultViewOptions } from './use-persisted-ai-vault-view-options'
import { AgentSessionContinuationDialog } from '@/components/agent-session-continuation/AgentSessionContinuationDialog'
import { AiVaultScanIssueBanners } from './AiVaultScanIssueBanners'
export default function AiVaultPanel(): React.JSX.Element {
const activeWorktreeId = useActiveWorktreeId()
@ -77,9 +78,11 @@ export default function AiVaultPanel(): React.JSX.Element {
sort,
group,
hideEmptySessions,
sessionLimit,
setSort,
setGroup,
setHideEmptySessions,
setSessionLimit,
setAgentEnabled,
setAllAgentsEnabled,
resetViewOptions
@ -141,9 +144,10 @@ export default function AiVaultPanel(): React.JSX.Element {
)
const { error, loading, refresh, scanResult, sessions } = useAiVaultSessionRefresh(
scopePaths,
executionHostScope
executionHostScope,
sessionLimit
)
// Deliberately blind to the active repo/worktree: rebuilding these ~500-entry
// Deliberately blind to the active repo/worktree: rebuilding these session
// maps on every worktree switch is what made switching visibly slow (#10841 era).
const sessionProjectById = useMemo(
() =>
@ -180,7 +184,8 @@ export default function AiVaultPanel(): React.JSX.Element {
agents,
sort,
group,
hideEmptySessions
hideEmptySessions,
sessionLimit
})
// Workspace is the preferred default, but unavailable context still falls back to All.
@ -316,6 +321,7 @@ export default function AiVaultPanel(): React.JSX.Element {
sort={sort}
group={group}
hideEmptySessions={hideEmptySessions}
sessionLimit={sessionLimit}
adjustmentCount={viewAdjustmentCount}
onQueryChange={setQuery}
onScopeChange={handleScopeChange}
@ -325,6 +331,7 @@ export default function AiVaultPanel(): React.JSX.Element {
onSortChange={setSort}
onGroupChange={setGroup}
onHideEmptySessionsChange={setHideEmptySessions}
onSessionLimitChange={setSessionLimit}
onReset={resetViewOptions}
onRefresh={() => void refresh({ force: true })}
/>
@ -335,15 +342,7 @@ export default function AiVaultPanel(): React.JSX.Element {
</div>
) : null}
{scanResult && scanResult.issues.length > 0 ? (
<div className="border-b border-sidebar-border px-3 py-1.5 text-[11px] text-muted-foreground">
{translate(
'auto.components.right.sidebar.AiVaultPanel.transcriptsSkipped',
'{{count}} transcript skipped',
{ count: scanResult.issues.length }
)}
</div>
) : null}
<AiVaultScanIssueBanners scanResult={scanResult} />
<AiVaultSessionVirtualList
groups={groups}

View File

@ -6,7 +6,6 @@ import {
Clock3,
FolderOpen,
ListFilter,
LoaderCircle,
PanelsTopLeft,
Server
} from 'lucide-react'
@ -36,6 +35,8 @@ import { getExecutionHostLabel, type ExecutionHostScope } from '../../../../shar
import { agentLabel, type AiVaultSessionGroup } from './ai-vault-session-filters'
import { translate } from '@/i18n/i18n'
import type { AiVaultHostScopeOption } from './ai-vault-host-scope'
import { AiVaultSessionLimitMenu } from './AiVaultSessionLimitMenu'
import type { AiVaultSessionLimit } from './ai-vault-session-limit'
const VAULT_HEADER_CONTROL_CLASS = 'size-6 shrink-0'
@ -78,34 +79,6 @@ export function VaultGroupHeader({
)
}
export function SessionLoadingState(): React.JSX.Element {
return (
<div className="px-3 py-3" aria-busy="true">
<div className="mb-3 flex items-center gap-2 text-[11px] text-muted-foreground">
<LoaderCircle className="size-3.5 shrink-0 animate-spin" />
<span>
{translate(
'auto.components.right.sidebar.AiVaultPanelControls.scanningSessions',
'Scanning sessions'
)}
</span>
</div>
<div className="space-y-3">
{Array.from({ length: 6 }, (_, index) => (
<div key={index} className="flex items-start gap-2">
<div className="mt-1 size-4 rounded-full bg-sidebar-accent" />
<div className="min-w-0 flex-1 space-y-1.5">
<div className="h-3 w-4/5 rounded-sm bg-sidebar-accent" />
<div className="h-2.5 w-3/5 rounded-sm bg-sidebar-accent/75" />
<div className="h-2.5 w-2/5 rounded-sm bg-sidebar-accent/60" />
</div>
</div>
))}
</div>
</div>
)
}
export function VaultScopeSwitch({
scope,
workspaceAvailable,
@ -235,24 +208,28 @@ export function VaultViewMenu({
sort,
group,
hideEmptySessions,
sessionLimit,
adjustmentCount,
onAgentEnabledChange,
onAllAgentsEnabledChange,
onSortChange,
onGroupChange,
onHideEmptySessionsChange,
onSessionLimitChange,
onReset
}: {
agents: readonly AiVaultAgent[]
sort: AiVaultSort
group: AiVaultGroup
hideEmptySessions: boolean
sessionLimit: AiVaultSessionLimit
adjustmentCount: number
onAgentEnabledChange: (agent: AiVaultAgent, enabled: boolean) => void
onAllAgentsEnabledChange: (enabled: boolean) => void
onSortChange: (sort: AiVaultSort) => void
onGroupChange: (group: AiVaultGroup) => void
onHideEmptySessionsChange: (hideEmptySessions: boolean) => void
onSessionLimitChange: (limit: AiVaultSessionLimit) => void
onReset: () => void
}): React.JSX.Element {
const allAgentsSelected = agents.length === AI_VAULT_AGENTS.length
@ -388,6 +365,10 @@ export function VaultViewMenu({
'Hide empty sessions'
)}
</DropdownMenuCheckboxItem>
<AiVaultSessionLimitMenu
sessionLimit={sessionLimit}
onSessionLimitChange={onSessionLimitChange}
/>
{adjustmentCount > 0 ? (
<>
<DropdownMenuSeparator />
@ -403,12 +384,3 @@ export function VaultViewMenu({
</DropdownMenu>
)
}
export function EmptyState({ title }: { title: string }): React.JSX.Element {
return (
<div className="flex h-full flex-col items-center justify-center px-4 text-center text-muted-foreground">
<ArchiveRestore className="mb-3 size-7 opacity-50" />
<p className="text-sm font-medium">{title}</p>
</div>
)
}

View File

@ -10,6 +10,7 @@ import type {
import type { ExecutionHostScope } from '../../../../shared/execution-host'
import { VaultHostScopeMenu, VaultScopeSwitch, VaultViewMenu } from './AiVaultPanelControls'
import type { AiVaultHostScopeOption } from './ai-vault-host-scope'
import type { AiVaultSessionLimit } from './ai-vault-session-limit'
type AiVaultPanelHeaderProps = {
query: string
@ -26,6 +27,7 @@ type AiVaultPanelHeaderProps = {
sort: AiVaultSort
group: AiVaultGroup
hideEmptySessions: boolean
sessionLimit: AiVaultSessionLimit
adjustmentCount: number
onQueryChange: (query: string) => void
onScopeChange: (scope: AiVaultScope) => void
@ -35,6 +37,7 @@ type AiVaultPanelHeaderProps = {
onSortChange: (sort: AiVaultSort) => void
onGroupChange: (group: AiVaultGroup) => void
onHideEmptySessionsChange: (hideEmptySessions: boolean) => void
onSessionLimitChange: (limit: AiVaultSessionLimit) => void
onReset: () => void
onRefresh: () => void
}
@ -54,6 +57,7 @@ export function AiVaultPanelHeader({
sort,
group,
hideEmptySessions,
sessionLimit,
adjustmentCount,
onQueryChange,
onScopeChange,
@ -63,6 +67,7 @@ export function AiVaultPanelHeader({
onSortChange,
onGroupChange,
onHideEmptySessionsChange,
onSessionLimitChange,
onReset,
onRefresh
}: AiVaultPanelHeaderProps): React.JSX.Element {
@ -119,12 +124,14 @@ export function AiVaultPanelHeader({
sort={sort}
group={group}
hideEmptySessions={hideEmptySessions}
sessionLimit={sessionLimit}
adjustmentCount={adjustmentCount}
onAgentEnabledChange={onAgentEnabledChange}
onAllAgentsEnabledChange={onAllAgentsEnabledChange}
onSortChange={onSortChange}
onGroupChange={onGroupChange}
onHideEmptySessionsChange={onHideEmptySessionsChange}
onSessionLimitChange={onSessionLimitChange}
onReset={onReset}
/>
<Button

View File

@ -0,0 +1,59 @@
import type React from 'react'
import type { AiVaultListResult } from '../../../../shared/ai-vault-types'
import {
aiVaultScanNoticeIssues,
blockingAiVaultScanIssue,
skippedAiVaultTranscriptCount,
skippedAiVaultTranscriptReasons
} from './ai-vault-scan-issue-state'
import { translate } from '@/i18n/i18n'
// Messages are scanner-authored (host name, remote path, cap), so they render raw
// rather than through a catalog key.
export function AiVaultScanIssueBanners({
scanResult
}: {
scanResult: AiVaultListResult | null
}): React.JSX.Element {
const blocking = blockingAiVaultScanIssue(scanResult)
const skippedTranscriptCount = skippedAiVaultTranscriptCount(scanResult)
return (
<>
{blocking ? (
<div className="border-b border-sidebar-border px-3 py-2 text-xs text-destructive">
{blocking.message}
</div>
) : null}
{aiVaultScanNoticeIssues(scanResult).map((issue) => (
<div
// Message is part of the key: one host can report several distinct
// messages for the same path, and a colliding key drops those rows.
key={`${issue.executionHostId ?? 'local'}:${issue.kind}:${issue.agent}:${issue.path}:${issue.message}`}
className={`border-b border-sidebar-border px-3 py-1.5 text-[11px] ${
issue.kind === 'host' ? 'text-destructive' : 'text-muted-foreground'
}`}
>
{issue.message}
</div>
))}
{skippedTranscriptCount > 0 ? (
<div className="border-b border-sidebar-border px-3 py-1.5 text-[11px] text-muted-foreground">
{translate(
'auto.components.right.sidebar.AiVaultPanel.transcriptsSkipped',
'{{count}} transcript skipped',
{ count: skippedTranscriptCount }
)}
</div>
) : null}
{skippedAiVaultTranscriptReasons(scanResult).map((reason) => (
<div
key={reason}
className="border-b border-sidebar-border px-3 py-1.5 text-[11px] text-muted-foreground"
>
{reason}
</div>
))}
</>
)
}

View File

@ -0,0 +1,85 @@
import {
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger
} from '@/components/ui/dropdown-menu'
import { translate } from '@/i18n/i18n'
import {
AI_VAULT_SESSION_LIMITS,
DEFAULT_AI_VAULT_SESSION_LIMIT,
type AiVaultSessionLimit
} from './ai-vault-session-limit'
export function AiVaultSessionLimitMenu({
sessionLimit,
onSessionLimitChange
}: {
sessionLimit: AiVaultSessionLimit
onSessionLimitChange: (limit: AiVaultSessionLimit) => void
}): React.JSX.Element {
const sessionLimitLabel =
sessionLimit === 'unlimited'
? translate('auto.components.right.sidebar.AiVaultSessionLimitMenu.unlimited', 'Unlimited')
: sessionLimit.toLocaleString()
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
{translate(
'auto.components.right.sidebar.AiVaultSessionLimitMenu.historyDepth',
'History depth: {{value0}}',
{ value0: sessionLimitLabel }
)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-60">
<DropdownMenuLabel className="whitespace-normal font-normal leading-4">
{translate(
'auto.components.right.sidebar.AiVaultSessionLimitMenu.performanceWarning',
'Larger histories can slow the entire app, especially on remote hosts. Unlimited scans all available history.'
)}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={String(sessionLimit)}
onValueChange={(value) =>
onSessionLimitChange(
value === 'unlimited' ? 'unlimited' : (Number(value) as AiVaultSessionLimit)
)
}
>
{AI_VAULT_SESSION_LIMITS.map((limit) => (
<DropdownMenuRadioItem key={limit} value={String(limit)}>
<span>
{limit === 'unlimited'
? translate(
'auto.components.right.sidebar.AiVaultSessionLimitMenu.unlimited',
'Unlimited'
)
: limit.toLocaleString()}
</span>
<span className="ml-auto text-[11px] font-normal text-muted-foreground">
{limit === DEFAULT_AI_VAULT_SESSION_LIMIT
? translate(
'auto.components.right.sidebar.AiVaultSessionLimitMenu.recommended',
'Recommended'
)
: limit === 500
? translate(
'auto.components.right.sidebar.AiVaultSessionLimitMenu.mayBeSlower',
'May be slower'
)
: translate(
'auto.components.right.sidebar.AiVaultSessionLimitMenu.slowest',
'Slowest'
)}
</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
)
}

View File

@ -0,0 +1,39 @@
import { ArchiveRestore, LoaderCircle } from 'lucide-react'
import { translate } from '@/i18n/i18n'
export function SessionLoadingState(): React.JSX.Element {
return (
<div className="px-3 py-3" aria-busy="true">
<div className="mb-3 flex items-center gap-2 text-[11px] text-muted-foreground">
<LoaderCircle className="size-3.5 shrink-0 animate-spin" />
<span>
{translate(
'auto.components.right.sidebar.AiVaultPanelControls.scanningSessions',
'Scanning sessions'
)}
</span>
</div>
<div className="space-y-3">
{Array.from({ length: 6 }, (_, index) => (
<div key={index} className="flex items-start gap-2">
<div className="mt-1 size-4 rounded-full bg-sidebar-accent" />
<div className="min-w-0 flex-1 space-y-1.5">
<div className="h-3 w-4/5 rounded-sm bg-sidebar-accent" />
<div className="h-2.5 w-3/5 rounded-sm bg-sidebar-accent/75" />
<div className="h-2.5 w-2/5 rounded-sm bg-sidebar-accent/60" />
</div>
</div>
))}
</div>
</div>
)
}
export function EmptyState({ title }: { title: string }): React.JSX.Element {
return (
<div className="flex h-full flex-col items-center justify-center px-4 text-center text-muted-foreground">
<ArchiveRestore className="mb-3 size-7 opacity-50" />
<p className="text-sm font-medium">{title}</p>
</div>
)
}

View File

@ -6,7 +6,8 @@ import type { AiVaultResumeStartup } from '@/lib/ai-vault-resume-command'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { getActiveStickyHeaderIndexForScroll } from '../sidebar/worktree-list-virtual-rows'
import { EmptyState, SessionLoadingState, VaultGroupHeader } from './AiVaultPanelControls'
import { VaultGroupHeader } from './AiVaultPanelControls'
import { EmptyState, SessionLoadingState } from './AiVaultSessionListStates'
import { VaultSessionRow } from './AiVaultSessionRow'
import type { AiVaultSessionGroup } from './ai-vault-session-filters'
import type { AiVaultOriginalPaneTarget } from './ai-vault-original-pane'

View File

@ -0,0 +1,164 @@
import { describe, expect, it } from 'vitest'
import type { AiVaultListResult } from '../../../../shared/ai-vault-types'
import {
aiVaultScanNoticeIssues,
blockingAiVaultScanIssue,
skippedAiVaultTranscriptCount,
skippedAiVaultTranscriptReasons
} from './ai-vault-scan-issue-state'
describe('blockingAiVaultScanIssue', () => {
it('surfaces the cause when a scan returns no sessions', () => {
const issue = {
executionHostId: 'ssh:dev-box' as const,
agent: 'codex' as const,
kind: 'host' as const,
path: 'dev-box',
message: 'Remote connection dropped. Reconnect the SSH target.'
}
expect(blockingAiVaultScanIssue(result([], [issue]))).toEqual(issue)
})
it('leaves partial-result issues as a skipped transcript count', () => {
expect(
blockingAiVaultScanIssue(
result(
[{ id: 'session' }],
[{ agent: 'codex', path: '/bad.jsonl', message: 'Malformed transcript' }]
)
)
).toBeNull()
})
it('does not block an empty scan for a skipped transcript', () => {
expect(
blockingAiVaultScanIssue(
result([], [{ agent: 'codex', path: '/bad.jsonl', message: 'Malformed transcript' }])
)
).toBeNull()
})
})
describe('aiVaultScanNoticeIssues', () => {
it('surfaces scope truncation without counting it as a skipped transcript', () => {
const scopeIssue = {
agent: 'codex' as const,
kind: 'scope' as const,
path: '/home/ada',
message: 'Only the first 64 project paths were scanned.'
}
const truncated = result([], [scopeIssue])
expect(blockingAiVaultScanIssue(truncated)).toBeNull()
expect(aiVaultScanNoticeIssues(truncated)).toEqual([scopeIssue])
expect(skippedAiVaultTranscriptCount(truncated)).toBe(0)
})
it('keeps kinded issues as notices and counts only transcripts as skipped', () => {
const hostIssue = {
executionHostId: 'ssh:dev-box' as const,
agent: 'codex' as const,
kind: 'host' as const,
path: 'dev-box',
message: 'Remote connection dropped.'
}
const scopeIssue = {
agent: 'codex' as const,
kind: 'scope' as const,
path: '/home/ada',
message: 'Only the first 64 project paths were scanned.'
}
const partial = result(
[{ id: 'session' }],
[hostIssue, scopeIssue, { agent: 'codex', path: '/bad.jsonl', message: 'Malformed' }]
)
expect(aiVaultScanNoticeIssues(partial)).toEqual([hostIssue, scopeIssue])
expect(skippedAiVaultTranscriptCount(partial)).toBe(1)
})
it('does not repeat the blocking issue as a notice row', () => {
const hostIssue = {
executionHostId: 'ssh:dev-box' as const,
agent: 'codex' as const,
kind: 'host' as const,
path: 'dev-box',
message: 'Remote connection dropped.'
}
expect(aiVaultScanNoticeIssues(result([], [hostIssue]))).toEqual([])
})
it('reports nothing before the first scan', () => {
expect(aiVaultScanNoticeIssues(null)).toEqual([])
expect(skippedAiVaultTranscriptCount(null)).toBe(0)
})
})
describe('skippedAiVaultTranscriptReasons', () => {
it('surfaces the file-too-large reason behind a skipped transcript', () => {
expect(
skippedAiVaultTranscriptReasons(
result(
[{ id: 'session' }],
[
{
agent: 'claude',
path: '/home/dev/.claude/projects/a/huge.jsonl',
message: 'File too large: 12.4MB exceeds 10MB limit'
}
]
)
)
).toEqual(['File too large: 12.4MB exceeds 10MB limit'])
})
it('leaves host and scope notices to their own rows', () => {
expect(
skippedAiVaultTranscriptReasons(
result(
[],
[
{
agent: 'codex',
kind: 'scope',
path: '/home/dev',
message: 'Only the first 64 project paths were scanned.'
},
{ agent: 'codex', kind: 'host', path: 'dev-box', message: 'Reconnect the SSH target.' }
]
)
)
).toEqual([])
})
it('dedupes repeats and caps the list so a 500-issue scan stays readable', () => {
const issues = Array.from({ length: 40 }, (_unused, index) => ({
agent: 'codex' as const,
path: `/transcripts/${index}.jsonl`,
message: `Unreadable transcript ${index % 5}`
}))
expect(skippedAiVaultTranscriptReasons(result([], issues))).toEqual([
'Unreadable transcript 0',
'Unreadable transcript 1',
'Unreadable transcript 2'
])
})
it('reports nothing before the first scan', () => {
expect(skippedAiVaultTranscriptReasons(null)).toEqual([])
})
})
function result(
sessions: { id: string }[],
issues: AiVaultListResult['issues']
): AiVaultListResult {
return {
sessions: sessions as AiVaultListResult['sessions'],
issues,
scannedAt: '2026-07-26T00:00:00.000Z'
}
}

View File

@ -0,0 +1,48 @@
import type { AiVaultListResult, AiVaultScanIssue } from '../../../../shared/ai-vault-types'
export function blockingAiVaultScanIssue(
result: AiVaultListResult | null
): AiVaultScanIssue | null {
if (!result || result.sessions.length > 0) {
return null
}
return result.issues.find((issue) => issue.kind === 'host') ?? null
}
// Host and scope issues carry their own scanner-authored copy, so they get their
// own rows instead of being counted as skipped transcripts — a partial scan
// (one SSH host down, rest fine) must not report a connectivity failure as a
// skipped transcript file.
export function aiVaultScanNoticeIssues(result: AiVaultListResult | null): AiVaultScanIssue[] {
if (!result) {
return []
}
const blocking = blockingAiVaultScanIssue(result)
return result.issues.filter((issue) => Boolean(issue.kind) && issue !== blocking)
}
export function skippedAiVaultTranscriptCount(result: AiVaultListResult | null): number {
return result ? result.issues.filter((issue) => !issue.kind).length : 0
}
const SKIPPED_TRANSCRIPT_REASON_LIMIT = 3
// Why: a bare "3 transcripts skipped" hides the actionable part (a 10 MiB cap
// hit, an unreadable transcript). Surface the distinct scanner-authored reasons,
// capped so a 500-issue scan can't turn the panel into a wall of text.
export function skippedAiVaultTranscriptReasons(result: AiVaultListResult | null): string[] {
const reasons = new Set<string>()
for (const issue of result?.issues ?? []) {
if (issue.kind) {
continue
}
const message = issue.message.trim()
if (message) {
reasons.add(message)
}
if (reasons.size === SKIPPED_TRANSCRIPT_REASON_LIMIT) {
break
}
}
return [...reasons]
}

View File

@ -0,0 +1,11 @@
export const AI_VAULT_SESSION_LIMITS = [250, 500, 1000, 'unlimited'] as const
export type AiVaultSessionLimit = (typeof AI_VAULT_SESSION_LIMITS)[number]
export const DEFAULT_AI_VAULT_SESSION_LIMIT: AiVaultSessionLimit = 250
export function normalizeAiVaultSessionLimit(value: unknown): AiVaultSessionLimit {
return AI_VAULT_SESSION_LIMITS.includes(value as AiVaultSessionLimit)
? (value as AiVaultSessionLimit)
: DEFAULT_AI_VAULT_SESSION_LIMIT
}

View File

@ -4,12 +4,15 @@ import { act, createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import type { AiVaultListResult } from '../../../../shared/ai-vault-types'
import type { AiVaultListResult, AiVaultSession } from '../../../../shared/ai-vault-types'
import type { ExecutionHostScope } from '../../../../shared/execution-host'
import { useAppStore } from '@/store'
import {
isAiVaultScanCancellation,
resetAiVaultForcedRescanThrottleForTest,
useAiVaultSessionRefresh
} from './ai-vault-session-refresh'
import { DEFAULT_AI_VAULT_SESSION_LIMIT, type AiVaultSessionLimit } from './ai-vault-session-limit'
const EMPTY_RESULT: AiVaultListResult = {
sessions: [],
@ -20,6 +23,7 @@ const EMPTY_RESULT: AiVaultListResult = {
const THROTTLE_MS = 5_000
const listSessionsMock = vi.fn<(args: unknown) => Promise<AiVaultListResult>>()
const cancelListSessionsMock = vi.fn<() => Promise<void>>()
// Captures the hook's subscription to the main-process window-focus push.
let windowFocusCallback: (() => void) | null = null
@ -39,40 +43,70 @@ async function fireWindowFocused(): Promise<void> {
const initialAppState = useAppStore.getInitialState()
describe('isAiVaultScanCancellation', () => {
it('recognises a cancellation through the IPC error wrapper', () => {
expect(
isAiVaultScanCancellation(
new Error(
"Error invoking remote method 'aiVault:listSessions': Error: Agent Session History scan was cancelled"
)
)
).toBe(true)
})
it('recognises an in-process AbortError', () => {
const error = new Error('aborted')
error.name = 'AbortError'
expect(isAiVaultScanCancellation(error)).toBe(true)
})
it('leaves a real scan failure reportable', () => {
expect(isAiVaultScanCancellation(new Error('SSH relay is not ready'))).toBe(false)
expect(isAiVaultScanCancellation('nope')).toBe(false)
})
})
const roots: Root[] = []
let latest: ReturnType<typeof useAiVaultSessionRefresh> | null = null
function HookProbe(props: {
scopePaths: readonly string[]
executionHostScope?: 'local' | 'all' | `ssh:${string}`
executionHostScope?: ExecutionHostScope
sessionLimit?: AiVaultSessionLimit
}): null {
latest = useAiVaultSessionRefresh(props.scopePaths, props.executionHostScope ?? 'local')
latest = useAiVaultSessionRefresh(
props.scopePaths,
props.executionHostScope ?? 'local',
props.sessionLimit ?? DEFAULT_AI_VAULT_SESSION_LIMIT
)
return null
}
async function renderHook(
scopePaths: readonly string[] = [],
executionHostScope: 'local' | 'all' | `ssh:${string}` = 'local'
executionHostScope: ExecutionHostScope = 'local',
sessionLimit: AiVaultSessionLimit = DEFAULT_AI_VAULT_SESSION_LIMIT
): 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, executionHostScope }))
root.render(createElement(HookProbe, { scopePaths, executionHostScope, sessionLimit }))
})
}
async function rerenderHook(
scopePaths: readonly string[] = [],
executionHostScope: 'local' | 'all' | `ssh:${string}` = 'local'
executionHostScope: ExecutionHostScope = 'local',
sessionLimit: AiVaultSessionLimit = DEFAULT_AI_VAULT_SESSION_LIMIT
): 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 }))
root.render(createElement(HookProbe, { scopePaths, executionHostScope, sessionLimit }))
})
}
@ -109,6 +143,33 @@ function makeAgentEntry(sessionId: string, state = 'working'): AgentStatusEntry
} as AgentStatusEntry
}
function makeVaultSession(index: number): AiVaultSession {
const id = `session-${index}`
const timestamp = new Date(Date.UTC(2026, 6, 1, 0, 0, index)).toISOString()
return {
id,
executionHostId: 'ssh:dev-box',
agent: 'codex',
sessionId: id,
title: id,
cwd: '/repo',
branch: null,
model: null,
filePath: `/sessions/${id}.jsonl`,
codexHome: null,
createdAt: timestamp,
updatedAt: timestamp,
modifiedAt: timestamp,
messageCount: 1,
totalTokens: 0,
previewMessages: [],
queuedMessageCount: 0,
subagentTranscriptCount: 0,
resumeCommand: id,
subagent: null
}
}
async function setAgentStatuses(entries: Record<string, AgentStatusEntry>): Promise<void> {
await act(async () => {
useAppStore.setState({ agentStatusByPaneKey: entries })
@ -123,9 +184,14 @@ function lastCallArgs(): unknown {
beforeEach(() => {
vi.useFakeTimers()
listSessionsMock.mockReset().mockResolvedValue(EMPTY_RESULT)
cancelListSessionsMock.mockReset().mockResolvedValue()
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test-only window.api shim
;(window as any).api = {
aiVault: { listSessions: listSessionsMock, onWindowFocused: onWindowFocusedMock }
aiVault: {
listSessions: listSessionsMock,
cancelListSessions: cancelListSessionsMock,
onWindowFocused: onWindowFocusedMock
}
}
resetAiVaultForcedRescanThrottleForTest()
useAppStore.setState(initialAppState, true)
@ -151,6 +217,24 @@ describe('useAiVaultSessionRefresh refocus behavior', () => {
})
})
it.each(['ssh:dev-box', 'runtime:remote-server'] as const)(
'uses the cache on %s panel entry',
async (executionHostScope) => {
await renderHook(['/repo'], executionHostScope)
await flushMicrotasks()
expect(listSessionsMock).toHaveBeenCalledTimes(1)
expect(lastCallArgs()).toMatchObject({
executionHostScope,
scopePaths: ['/repo'],
force: false
})
await advance(THROTTLE_MS + 1)
expect(listSessionsMock).toHaveBeenCalledTimes(1)
}
)
it('passes the requested execution host scope to the scanner', async () => {
await renderHook(['/repo'], 'ssh:dev-box')
await flushMicrotasks()
@ -162,6 +246,73 @@ describe('useAiVaultSessionRefresh refocus behavior', () => {
})
})
it('re-scans with the selected history depth', async () => {
await renderHook(['/repo'], 'ssh:dev-box')
await flushMicrotasks()
await rerenderHook(['/repo'], 'ssh:dev-box', 1000)
await flushMicrotasks()
expect(listSessionsMock).toHaveBeenCalledTimes(2)
expect(lastCallArgs()).toMatchObject({ limit: 1000, force: false })
})
it('reuses a loaded larger depth when lowering and raising within its coverage', async () => {
const loaded = {
...EMPTY_RESULT,
sessions: Array.from({ length: 600 }, (_, index) => makeVaultSession(index))
}
listSessionsMock.mockResolvedValueOnce(loaded)
await renderHook([], 'ssh:dev-box', 1000)
await flushMicrotasks()
await rerenderHook([], 'ssh:dev-box', 250)
await flushMicrotasks()
expect(latest?.sessions).toHaveLength(250)
await rerenderHook([], 'ssh:dev-box', 500)
await flushMicrotasks()
expect(latest?.sessions).toHaveLength(500)
expect(listSessionsMock).toHaveBeenCalledTimes(1)
})
it('reuses the rendered result after the panel remounts on a tab switch', async () => {
const loaded = { ...EMPTY_RESULT, sessions: [makeVaultSession(1)] }
listSessionsMock.mockResolvedValueOnce(loaded)
await renderHook(['/repo'], 'ssh:dev-box', 250)
await flushMicrotasks()
roots.splice(0).forEach((root) => act(() => root.unmount()))
await renderHook(['/repo'], 'ssh:dev-box', 250)
await flushMicrotasks()
expect(listSessionsMock).toHaveBeenCalledTimes(1)
expect(latest?.sessions).toEqual(loaded.sessions)
})
it('reuses each workspace result when switching back across tabs', async () => {
listSessionsMock
.mockResolvedValueOnce({ ...EMPTY_RESULT, sessions: [makeVaultSession(1)] })
.mockResolvedValueOnce({ ...EMPTY_RESULT, sessions: [makeVaultSession(2)] })
await renderHook(['/repo-a'], 'ssh:dev-box', 250)
await flushMicrotasks()
await rerenderHook(['/repo-b'], 'ssh:dev-box', 250)
await flushMicrotasks()
await rerenderHook(['/repo-a'], 'ssh:dev-box', 250)
await flushMicrotasks()
expect(listSessionsMock).toHaveBeenCalledTimes(2)
expect(latest?.sessions[0]?.id).toBe('session-1')
})
it('requests an uncapped scan for Unlimited', async () => {
await renderHook([], 'ssh:dev-box', 'unlimited')
await flushMicrotasks()
expect(lastCallArgs()).toMatchObject({ limit: undefined, unlimited: true, force: false })
})
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
@ -178,6 +329,7 @@ describe('useAiVaultSessionRefresh refocus behavior', () => {
await rerenderHook(['/remote/repo'], 'ssh:dev-box')
expect(listSessionsMock).toHaveBeenCalledTimes(1)
expect(cancelListSessionsMock).toHaveBeenCalledTimes(1)
await act(async () => {
resolveLocal?.({ ...EMPTY_RESULT, scannedAt: '2026-07-01T00:00:01.000Z' })
@ -254,6 +406,7 @@ describe('useAiVaultSessionRefresh refocus behavior', () => {
await dispatch(document, 'visibilitychange')
expect(listSessionsMock).toHaveBeenCalledTimes(1)
expect(cancelListSessionsMock).toHaveBeenCalledTimes(1)
})
it('does not raise the loading flag for refocus refreshes', async () => {
@ -297,6 +450,24 @@ describe('useAiVaultSessionRefresh refocus behavior', () => {
expect(latest?.scanResult).not.toBe(firstResult)
})
it('keeps the current list when a superseded scan resolves cancelled', async () => {
await renderHook()
await flushMicrotasks()
const applied = latest?.scanResult
listSessionsMock.mockResolvedValueOnce({
sessions: [],
issues: [],
scannedAt: '2026-07-01T00:00:09.000Z',
cancelled: true
})
await advance(THROTTLE_MS + 1)
await fireWindowFocused()
expect(latest?.scanResult).toBe(applied)
expect(latest?.error).toBeNull()
})
it('keeps the manual refresh button forcing a cache bypass', async () => {
await renderHook()
await flushMicrotasks()

View File

@ -1,9 +1,18 @@
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 { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import {
isAiVaultScanCancelledError,
type AiVaultListResult,
type AiVaultSession
} from '../../../../shared/ai-vault-types'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostScope } from '../../../../shared/execution-host'
import { useAppStore } from '@/store'
const SESSION_LIMIT = 500
import type { AiVaultSessionLimit } from './ai-vault-session-limit'
import {
aiVaultSessionResultCacheKey,
cacheAiVaultSessionResult,
readCachedAiVaultSessionResult,
resetAiVaultSessionResultCacheForTest
} from './ai-vault-session-result-cache'
// Panel entry and window refocus must show sessions started since the last
// scan, so they bypass the main process's 15s cache — but a full scan parses
@ -23,13 +32,19 @@ function consumeForcedRescanBudget(): boolean {
export function resetAiVaultForcedRescanThrottleForTest(): void {
lastForcedRescanAt = 0
resetAiVaultSessionResultCacheForTest()
}
type AiVaultRefreshArgs = { force?: boolean; background?: boolean }
// Desktop IPC reports cancellation as a result, but the web/runtime RPC path
// still rejects, so both shapes have to be recognised.
export const isAiVaultScanCancellation = isAiVaultScanCancelledError
type AiVaultRefreshArgs = { force?: boolean; background?: boolean; reuseLoadedDepth?: boolean }
export function useAiVaultSessionRefresh(
scopePaths: readonly string[],
executionHostScope: ExecutionHostScope
executionHostScope: ExecutionHostScope,
sessionLimit: AiVaultSessionLimit
): {
error: string | null
loading: boolean
@ -41,6 +56,7 @@ export function useAiVaultSessionRefresh(
const [scanResult, setScanResult] = useState<AiVaultListResult | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const requestTokenRef = useRef(crypto.randomUUID())
const refreshIdRef = useRef(0)
const refreshInFlightRef = useRef(false)
const pendingRefreshRef = useRef(false)
@ -48,19 +64,47 @@ export function useAiVaultSessionRefresh(
const pendingBackgroundRef = useRef(true)
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 scanScopeKey = `${aiVaultSessionResultCacheKey(executionHostScope, scopePaths)}\n${sessionLimit}`
const scopePathsRef = useRef<readonly string[]>(scopePaths)
scopePathsRef.current = scopePaths
const executionHostScopeRef = useRef<ExecutionHostScope>(executionHostScope)
executionHostScopeRef.current = executionHostScope
const sessionLimitRef = useRef(sessionLimit)
// Keep render pure for React Doctor; layout effect still lands before refresh effects.
useLayoutEffect(() => {
sessionLimitRef.current = sessionLimit
}, [sessionLimit])
const currentScanScopeKey = useCallback(
() => `${executionHostScopeRef.current}\n${scopePathsRef.current.join('\n')}`,
() =>
`${aiVaultSessionResultCacheKey(
executionHostScopeRef.current,
scopePathsRef.current
)}\n${sessionLimitRef.current}`,
[]
)
const refresh = useCallback(
async (args: AiVaultRefreshArgs = {}): Promise<void> => {
const hostScope = executionHostScopeRef.current
const selectedLimit = sessionLimitRef.current
const baseKey = aiVaultSessionResultCacheKey(hostScope, scopePathsRef.current)
const cachedResult =
args.reuseLoadedDepth === true
? readCachedAiVaultSessionResult({
key: baseKey,
limit: selectedLimit,
scopePaths: scopePathsRef.current
})
: null
if (cachedResult) {
const scanKey = `${baseKey}\n${selectedLimit}`
lastAppliedScanRef.current = { scopeKey: scanKey, scannedAt: cachedResult.scannedAt }
setError(null)
setScanResult(cachedResult)
setSessions(cachedResult.sessions)
setLoading(false)
return
}
// 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) {
@ -85,17 +129,20 @@ export function useAiVaultSessionRefresh(
setLoading(true)
}
setError(null)
const scopeKey = scopePathsRef.current.join('\n')
const hostScope = executionHostScopeRef.current
const scanKey = `${hostScope}\n${scopeKey}`
const limit = selectedLimit === 'unlimited' ? undefined : selectedLimit
const scanKey = `${baseKey}\n${selectedLimit}`
try {
const result = await window.api.aiVault.listSessions({
limit: SESSION_LIMIT,
limit,
unlimited: selectedLimit === 'unlimited',
scopePaths: scopePathsRef.current,
executionHostScope: hostScope,
force: args.force
force: args.force,
requestToken: requestTokenRef.current
})
if (!mountedRef.current || refreshIdRef.current !== refreshId) {
// A superseded scan resolves cancelled rather than rejecting, so the
// main-process log stays clean; its empty body must not be painted.
if (result.cancelled || !mountedRef.current || refreshIdRef.current !== refreshId) {
return
}
// Why: host/scope changes queue a follow-up scan, but the older result
@ -112,10 +159,21 @@ export function useAiVaultSessionRefresh(
return
}
lastAppliedScanRef.current = { scopeKey: scanKey, scannedAt: result.scannedAt }
cacheAiVaultSessionResult({
key: baseKey,
executionHostScope: hostScope,
limit: selectedLimit,
result,
replaceHostEntries: args.force === true
})
setScanResult(result)
setSessions(result.sessions)
} catch (err) {
// A cancelled scan is not a failure: another caller's forced refresh
// preempts the shared scan, and painting its abort would replace the
// list with an error the incoming scan is about to make obsolete.
if (
!isAiVaultScanCancellation(err) &&
mountedRef.current &&
refreshIdRef.current === refreshId &&
scanKey === currentScanScopeKey()
@ -167,10 +225,14 @@ export function useAiVaultSessionRefresh(
useEffect(() => {
mountedRef.current = true
const requestToken = requestTokenRef.current
return () => {
mountedRef.current = false
refreshIdRef.current += 1
refreshInFlightRef.current = false
void window.api.aiVault.cancelListSessions({
requestToken
})
if (forcedRescanTimerRef.current !== null) {
clearTimeout(forcedRescanTimerRef.current)
forcedRescanTimerRef.current = null
@ -178,17 +240,22 @@ export function useAiVaultSessionRefresh(
}
}, [])
// Re-scan on mount and whenever the active scope changes, since the scanner
// tailors its in-scope results to scopePaths. Force (throttled) so
// re-entering the panel shows sessions newer than the 15s cache; when the
// throttle denies it, paint from cache now and catch up once it frees.
// Remote scans can take long enough for normal tab navigation to feel stuck,
// so re-entering a remote panel uses its host/scope cache. Explicit refresh,
// app refocus and new in-app agent sessions still force a fresh scan.
useEffect(() => {
const force = consumeForcedRescanBudget()
void refresh({ force })
if (!force) {
if (refreshInFlightRef.current) {
void window.api.aiVault.cancelListSessions({
requestToken: requestTokenRef.current
})
}
const refreshOnEntry = executionHostScope === LOCAL_EXECUTION_HOST_ID
const force = refreshOnEntry && consumeForcedRescanBudget()
void refresh({ force, reuseLoadedDepth: true })
if (refreshOnEntry && !force) {
requestForcedRescan()
}
}, [refresh, requestForcedRescan, scanScopeKey])
}, [executionHostScope, 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

View File

@ -0,0 +1,77 @@
import type { AiVaultListResult } from '../../../../shared/ai-vault-types'
import type { ExecutionHostScope } from '../../../../shared/execution-host'
import {
aiVaultSessionDepthCovers,
truncateAiVaultListResult
} from '../../../../shared/ai-vault-session-depth'
import type { AiVaultSessionLimit } from './ai-vault-session-limit'
const MAX_CACHED_SESSION_SCOPES = 8
type CachedSessionResult = {
executionHostScope: ExecutionHostScope
limit: AiVaultSessionLimit
result: AiVaultListResult
}
const cachedSessionResults = new Map<string, CachedSessionResult>()
export function aiVaultSessionResultCacheKey(
executionHostScope: ExecutionHostScope,
scopePaths: readonly string[]
): string {
// JSON keeps the parts unambiguous: a path may legally contain any separator.
return JSON.stringify([executionHostScope, ...[...new Set(scopePaths)].sort()])
}
export function readCachedAiVaultSessionResult(args: {
key: string
limit: AiVaultSessionLimit
scopePaths: readonly string[]
}): AiVaultListResult | null {
const cached = cachedSessionResults.get(args.key)
if (!cached || !aiVaultSessionDepthCovers(cached.limit, args.limit)) {
return null
}
cachedSessionResults.delete(args.key)
cachedSessionResults.set(args.key, cached)
return truncateAiVaultListResult(cached.result, args.limit, args.scopePaths)
}
export function cacheAiVaultSessionResult(args: {
key: string
executionHostScope: ExecutionHostScope
limit: AiVaultSessionLimit
result: AiVaultListResult
replaceHostEntries: boolean
}): void {
if (args.replaceHostEntries) {
for (const [key, cached] of cachedSessionResults) {
if (cached.executionHostScope === args.executionHostScope) {
cachedSessionResults.delete(key)
}
}
} else {
const cached = cachedSessionResults.get(args.key)
if (cached && aiVaultSessionDepthCovers(cached.limit, args.limit)) {
return
}
}
cachedSessionResults.delete(args.key)
cachedSessionResults.set(args.key, {
executionHostScope: args.executionHostScope,
limit: args.limit,
result: args.result
})
while (cachedSessionResults.size > MAX_CACHED_SESSION_SCOPES) {
const oldestKey = cachedSessionResults.keys().next().value
if (oldestKey === undefined) {
break
}
cachedSessionResults.delete(oldestKey)
}
}
export function resetAiVaultSessionResultCacheForTest(): void {
cachedSessionResults.clear()
}

View File

@ -6,6 +6,7 @@ import {
DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS,
DEFAULT_AI_VAULT_SORT
} from './ai-vault-view-defaults'
import { DEFAULT_AI_VAULT_SESSION_LIMIT } from './ai-vault-session-limit'
describe('ai-vault-view-defaults', () => {
it('shows empty sessions by default', () => {
@ -18,7 +19,8 @@ describe('ai-vault-view-defaults', () => {
agents: [...AI_VAULT_AGENTS],
sort: DEFAULT_AI_VAULT_SORT,
group: DEFAULT_AI_VAULT_GROUP,
hideEmptySessions: DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS
hideEmptySessions: DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS,
sessionLimit: DEFAULT_AI_VAULT_SESSION_LIMIT
})
).toBe(0)
})
@ -29,7 +31,8 @@ describe('ai-vault-view-defaults', () => {
agents: [...AI_VAULT_AGENTS],
sort: DEFAULT_AI_VAULT_SORT,
group: DEFAULT_AI_VAULT_GROUP,
hideEmptySessions: true
hideEmptySessions: true,
sessionLimit: DEFAULT_AI_VAULT_SESSION_LIMIT
})
).toBe(1)
})
@ -40,7 +43,8 @@ describe('ai-vault-view-defaults', () => {
agents: [...AI_VAULT_AGENTS],
sort: DEFAULT_AI_VAULT_SORT,
group: DEFAULT_AI_VAULT_GROUP,
hideEmptySessions: false
hideEmptySessions: false,
sessionLimit: DEFAULT_AI_VAULT_SESSION_LIMIT
})
).toBe(0)
})
@ -54,7 +58,8 @@ describe('ai-vault-view-defaults', () => {
agents: swapped,
sort: DEFAULT_AI_VAULT_SORT,
group: DEFAULT_AI_VAULT_GROUP,
hideEmptySessions: DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS
hideEmptySessions: DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS,
sessionLimit: DEFAULT_AI_VAULT_SESSION_LIMIT
})
).toBe(1)
})
@ -65,8 +70,9 @@ describe('ai-vault-view-defaults', () => {
agents: ['claude'],
sort: 'created',
group: 'agent',
hideEmptySessions: true
hideEmptySessions: true,
sessionLimit: 1000
})
).toBe(4)
).toBe(5)
})
})

View File

@ -4,6 +4,7 @@ import {
type AiVaultGroup,
type AiVaultSort
} from '../../../../shared/ai-vault-types'
import { DEFAULT_AI_VAULT_SESSION_LIMIT, type AiVaultSessionLimit } from './ai-vault-session-limit'
// Why: hide-empty used to default true; keep initial state, badge count, and Reset view
// on one constant so a default flip cannot leave Reset pointing at the old value.
@ -16,6 +17,7 @@ export function countAiVaultViewAdjustments(options: {
sort: AiVaultSort
group: AiVaultGroup
hideEmptySessions: boolean
sessionLimit: AiVaultSessionLimit
}): number {
// Why: count by membership, not length — an agent swap keeps the array length but
// still deviates from the default of every agent enabled.
@ -24,6 +26,7 @@ export function countAiVaultViewAdjustments(options: {
(allAgentsEnabled ? 0 : 1) +
(options.sort === DEFAULT_AI_VAULT_SORT ? 0 : 1) +
(options.group === DEFAULT_AI_VAULT_GROUP ? 0 : 1) +
(options.hideEmptySessions === DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS ? 0 : 1)
(options.hideEmptySessions === DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS ? 0 : 1) +
(options.sessionLimit === DEFAULT_AI_VAULT_SESSION_LIMIT ? 0 : 1)
)
}

View File

@ -26,13 +26,15 @@ describe('AI Vault view option persistence', () => {
disabledAgents: ['codex', 'unknown', 'codex', 7],
sort: 'invalid',
group: 'agent',
hideEmptySessions: 'yes'
hideEmptySessions: 'yes',
sessionLimit: 999
})
).toEqual({
disabledAgents: ['codex'],
sort: 'updated',
group: 'agent',
hideEmptySessions: false
hideEmptySessions: false,
sessionLimit: 250
})
})
@ -42,28 +44,35 @@ describe('AI Vault view option persistence', () => {
disabledAgents: [],
sort: 'updated',
group: 'project',
hideEmptySessions: false
hideEmptySessions: false,
sessionLimit: 250
})
).toEqual({
disabledAgents: [],
sort: 'updated',
group: 'project',
hideEmptySessions: false
hideEmptySessions: false,
sessionLimit: 250
})
expect(
normalizeAiVaultViewOptions({
disabledAgents: [],
sort: 'created',
group: 'folder',
hideEmptySessions: true
hideEmptySessions: true,
sessionLimit: 1000
})
).toEqual({
disabledAgents: [],
sort: 'created',
group: 'folder',
hideEmptySessions: true
hideEmptySessions: true,
sessionLimit: 1000
})
expect(normalizeAiVaultViewOptions({ group: 'agent' }).group).toBe('agent')
expect(normalizeAiVaultViewOptions({ sessionLimit: 'unlimited' }).sessionLimit).toBe(
'unlimited'
)
})
it('preserves a fully cleared agent selection', () => {
@ -114,7 +123,8 @@ describe('AI Vault view option persistence', () => {
disabledAgents: ['codex'],
sort: 'created',
group: 'folder',
hideEmptySessions: true
hideEmptySessions: true,
sessionLimit: 500
},
storage
)
@ -125,7 +135,8 @@ describe('AI Vault view option persistence', () => {
disabledAgents: ['codex'],
sort: 'created',
group: 'folder',
hideEmptySessions: true
hideEmptySessions: true,
sessionLimit: 500
})
)
})

View File

@ -9,6 +9,11 @@ import {
DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS,
DEFAULT_AI_VAULT_SORT
} from './ai-vault-view-defaults'
import {
DEFAULT_AI_VAULT_SESSION_LIMIT,
normalizeAiVaultSessionLimit,
type AiVaultSessionLimit
} from './ai-vault-session-limit'
export const AI_VAULT_VIEW_OPTIONS_STORAGE_KEY = 'orca.aiVault.viewOptions.v1'
@ -17,6 +22,7 @@ export type AiVaultViewOptions = {
sort: AiVaultSort
group: AiVaultGroup
hideEmptySessions: boolean
sessionLimit: AiVaultSessionLimit
}
type AiVaultViewOptionsStorage = {
@ -29,7 +35,8 @@ export function createDefaultAiVaultViewOptions(): AiVaultViewOptions {
disabledAgents: [],
sort: DEFAULT_AI_VAULT_SORT,
group: DEFAULT_AI_VAULT_GROUP,
hideEmptySessions: DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS
hideEmptySessions: DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS,
sessionLimit: DEFAULT_AI_VAULT_SESSION_LIMIT
}
}
@ -63,7 +70,8 @@ export function normalizeAiVaultViewOptions(value: unknown): AiVaultViewOptions
hideEmptySessions:
typeof record.hideEmptySessions === 'boolean'
? record.hideEmptySessions
: DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS
: DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS,
sessionLimit: normalizeAiVaultSessionLimit(record.sessionLimit)
}
}

View File

@ -24,6 +24,7 @@ describe('usePersistedAiVaultViewOptions', () => {
first.result.current.setSort('created')
first.result.current.setGroup('folder')
first.result.current.setHideEmptySessions(true)
first.result.current.setSessionLimit('unlimited')
})
first.unmount()
@ -32,6 +33,7 @@ describe('usePersistedAiVaultViewOptions', () => {
expect(restored.result.current.sort).toBe('created')
expect(restored.result.current.group).toBe('folder')
expect(restored.result.current.hideEmptySessions).toBe(true)
expect(restored.result.current.sessionLimit).toBe('unlimited')
})
it('allows clearing every agent so a single agent can be re-enabled', () => {
@ -81,6 +83,7 @@ describe('usePersistedAiVaultViewOptions', () => {
hook.result.current.setSort('created')
hook.result.current.setGroup('agent')
hook.result.current.setHideEmptySessions(true)
hook.result.current.setSessionLimit(1000)
hook.result.current.resetViewOptions()
})
@ -88,6 +91,7 @@ describe('usePersistedAiVaultViewOptions', () => {
expect(hook.result.current.sort).toBe('updated')
expect(hook.result.current.group).toBe('project')
expect(hook.result.current.hideEmptySessions).toBe(false)
expect(hook.result.current.sessionLimit).toBe(250)
hook.unmount()
const restored = renderHook(() => usePersistedAiVaultViewOptions())
@ -95,5 +99,6 @@ describe('usePersistedAiVaultViewOptions', () => {
expect(restored.result.current.sort).toBe('updated')
expect(restored.result.current.group).toBe('project')
expect(restored.result.current.hideEmptySessions).toBe(false)
expect(restored.result.current.sessionLimit).toBe(250)
})
})

View File

@ -12,6 +12,7 @@ import {
writeAiVaultViewOptions,
type AiVaultViewOptions
} from './ai-vault-view-options-persistence'
import type { AiVaultSessionLimit } from './ai-vault-session-limit'
type AiVaultViewOptionsUpdate = (current: AiVaultViewOptions) => AiVaultViewOptions
@ -20,9 +21,11 @@ export function usePersistedAiVaultViewOptions(): {
sort: AiVaultSort
group: AiVaultGroup
hideEmptySessions: boolean
sessionLimit: AiVaultSessionLimit
setSort: (sort: AiVaultSort) => void
setGroup: (group: AiVaultGroup) => void
setHideEmptySessions: (hide: boolean) => void
setSessionLimit: (limit: AiVaultSessionLimit) => void
setAgentEnabled: (agent: AiVaultAgent, enabled: boolean) => void
setAllAgentsEnabled: (enabled: boolean) => void
resetViewOptions: () => void
@ -65,6 +68,13 @@ export function usePersistedAiVaultViewOptions(): {
),
[updateOptions]
)
const setSessionLimit = useCallback(
(sessionLimit: AiVaultSessionLimit) =>
updateOptions((current) =>
current.sessionLimit === sessionLimit ? current : { ...current, sessionLimit }
),
[updateOptions]
)
const setAgentEnabled = useCallback(
(agent: AiVaultAgent, enabled: boolean) => {
updateOptions((current) => {
@ -110,9 +120,11 @@ export function usePersistedAiVaultViewOptions(): {
sort: options.sort,
group: options.group,
hideEmptySessions: options.hideEmptySessions,
sessionLimit: options.sessionLimit,
setSort,
setGroup,
setHideEmptySessions,
setSessionLimit,
setAgentEnabled,
setAllAgentsEnabled,
resetViewOptions

View File

@ -11475,6 +11475,14 @@
},
"activityBar": {
"error": "Error"
},
"AiVaultSessionLimitMenu": {
"historyDepth": "History depth: {{value0}}",
"performanceWarning": "Larger histories can slow the entire app, especially on remote hosts. Unlimited scans all available history.",
"recommended": "Recommended",
"mayBeSlower": "May be slower",
"slowest": "Slowest",
"unlimited": "Unlimited"
}
}
},

View File

@ -11335,6 +11335,14 @@
},
"activityBar": {
"error": "Error"
},
"AiVaultSessionLimitMenu": {
"historyDepth": "Profundidad del historial: {{value0}}",
"performanceWarning": "Los historiales más extensos pueden ralentizar toda la aplicación, especialmente en hosts remotos. Sin límite escanea todo el historial disponible.",
"recommended": "Recomendado",
"mayBeSlower": "Puede ser más lento",
"slowest": "Más lento",
"unlimited": "Sin límite"
}
}
},

View File

@ -11335,6 +11335,14 @@
},
"activityBar": {
"error": "エラー"
},
"AiVaultSessionLimitMenu": {
"historyDepth": "履歴の件数: {{value0}}",
"performanceWarning": "履歴を増やすと、特にリモートホストではアプリ全体が遅くなる場合があります。無制限では利用可能な履歴をすべてスキャンします。",
"recommended": "推奨",
"mayBeSlower": "遅くなる可能性あり",
"slowest": "最も低速",
"unlimited": "無制限"
}
}
},

View File

@ -11335,6 +11335,14 @@
},
"activityBar": {
"error": "오류"
},
"AiVaultSessionLimitMenu": {
"historyDepth": "기록 개수: {{value0}}",
"performanceWarning": "더 많은 기록을 불러오면 특히 원격 호스트에서 앱 전체가 느려질 수 있습니다. 무제한은 사용 가능한 모든 기록을 스캔합니다.",
"recommended": "권장",
"mayBeSlower": "느려질 수 있음",
"slowest": "가장 느림",
"unlimited": "무제한"
}
}
},

View File

@ -11347,6 +11347,14 @@
},
"activityBar": {
"error": "错误"
},
"AiVaultSessionLimitMenu": {
"historyDepth": "历史记录数量:{{value0}}",
"performanceWarning": "加载更多历史记录可能会拖慢整个应用,尤其是在远程主机上。无限制模式会扫描所有可用历史记录。",
"recommended": "推荐",
"mayBeSlower": "可能较慢",
"slowest": "最慢",
"unlimited": "无限制"
}
}
},

View File

@ -1530,6 +1530,10 @@ function createAiVaultApi(): NonNullable<Partial<PreloadApi>['aiVault']> {
executionHostId
})
},
// Why: the runtime RPC transport has no cancel verb, so the in-flight scan
// settles on its own timeout. The renderer's refreshId guard already drops
// the late result; this only means web pays for a scan nobody reads.
cancelListSessions: () => Promise.resolve(),
prepareSessionResume: (args: AiVaultPrepareSessionResumeArgs) =>
callRuntimeResult<AiVaultPrepareSessionResumeResult>('aiVault.prepareSessionResume', args),
// Why: no server-side RPC for subagent transcript listing yet, so report an empty (not erroring) result.

View File

@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest'
import type { AiVaultListResult, AiVaultSession } from './ai-vault-types'
import {
aiVaultScanLimit,
aiVaultSessionDepthCovers,
requestedAiVaultSessionDepth,
truncateAiVaultListResult
} from './ai-vault-session-depth'
function session(id: string, cwd: string, index: number): AiVaultSession {
const timestamp = new Date(Date.UTC(2026, 7, 2, 0, 0, index)).toISOString()
return {
id,
executionHostId: 'local',
agent: 'codex',
sessionId: id,
title: id,
cwd,
branch: null,
model: null,
filePath: `/sessions/${id}.jsonl`,
codexHome: null,
createdAt: timestamp,
updatedAt: timestamp,
modifiedAt: timestamp,
messageCount: 1,
totalTokens: 0,
previewMessages: [],
queuedMessageCount: 0,
subagentTranscriptCount: 0,
resumeCommand: id,
subagent: null
}
}
function result(sessions: AiVaultSession[]): AiVaultListResult {
return { sessions, issues: [], scannedAt: '2026-08-02T00:00:00.000Z' }
}
describe('Agent Session History depth', () => {
it('recognizes which loaded depths cover a request', () => {
expect(aiVaultSessionDepthCovers(250, 250)).toBe(true)
expect(aiVaultSessionDepthCovers(1000, 250)).toBe(true)
expect(aiVaultSessionDepthCovers(250, 500)).toBe(false)
expect(aiVaultSessionDepthCovers(1000, 'unlimited')).toBe(false)
expect(aiVaultSessionDepthCovers('unlimited', 1000)).toBe(true)
expect(aiVaultSessionDepthCovers('unlimited', 'unlimited')).toBe(true)
})
it('normalizes default, finite, and unlimited requests', () => {
expect(requestedAiVaultSessionDepth()).toBe(1000)
expect(requestedAiVaultSessionDepth({ limit: 500 })).toBe(500)
expect(requestedAiVaultSessionDepth({ limit: 500.75 })).toBe(500)
expect(requestedAiVaultSessionDepth({ limit: 0 })).toBe(1000)
expect(requestedAiVaultSessionDepth({ limit: -1 })).toBe(1000)
expect(requestedAiVaultSessionDepth({ limit: Number.NaN })).toBe(1000)
expect(requestedAiVaultSessionDepth({ limit: Number.POSITIVE_INFINITY })).toBe(1000)
expect(requestedAiVaultSessionDepth({ limit: 500, unlimited: true })).toBe('unlimited')
})
it('resolves scan limits to a numeric bound', () => {
expect(aiVaultScanLimit({ limit: 500 })).toBe(500)
expect(aiVaultScanLimit({ limit: Number.POSITIVE_INFINITY })).toBe(1000)
expect(aiVaultScanLimit({ unlimited: true })).toBe(Number.POSITIVE_INFINITY)
})
it('keeps the newest global and scoped sessions when truncating', () => {
const loaded = result([
session('global-1', '/other', 6),
session('global-2', '/other', 5),
session('global-3', '/other', 4),
session('scoped-1', '/repo/app', 3),
session('scoped-2', '/repo/lib', 2),
session('scoped-3', '/repo/old', 1)
])
expect(truncateAiVaultListResult(loaded, 2, ['/repo']).sessions.map(({ id }) => id)).toEqual([
'global-1',
'global-2',
'scoped-1',
'scoped-2'
])
expect(truncateAiVaultListResult(loaded, 'unlimited')).toBe(loaded)
})
})

View File

@ -0,0 +1,55 @@
import { isPathInsideOrEqual } from './cross-platform-path'
import type { AiVaultListArgs, AiVaultListResult } from './ai-vault-types'
export const DEFAULT_AI_VAULT_SCAN_LIMIT = 1000
export type AiVaultSessionDepth = number | 'unlimited'
export function requestedAiVaultSessionDepth(
args?: Pick<AiVaultListArgs, 'limit' | 'unlimited'>
): AiVaultSessionDepth {
if (args?.unlimited === true) {
return 'unlimited'
}
return args?.limit && Number.isFinite(args.limit) && args.limit > 0
? Math.floor(args.limit)
: DEFAULT_AI_VAULT_SCAN_LIMIT
}
// Scanners bound with slice/comparisons, so 'unlimited' resolves to no bound.
// Single owner of the normalization every scan limit goes through.
export function aiVaultScanLimit(args?: Pick<AiVaultListArgs, 'limit' | 'unlimited'>): number {
const depth = requestedAiVaultSessionDepth(args)
return depth === 'unlimited' ? Number.POSITIVE_INFINITY : depth
}
export function aiVaultSessionDepthCovers(
cached: AiVaultSessionDepth,
requested: AiVaultSessionDepth
): boolean {
return cached === 'unlimited' || (requested !== 'unlimited' && cached >= requested)
}
export function truncateAiVaultListResult(
result: AiVaultListResult,
depth: AiVaultSessionDepth,
scopePaths: readonly string[] = []
): AiVaultListResult {
if (depth === 'unlimited') {
return result
}
const selectedIds = new Set(result.sessions.slice(0, depth).map((session) => session.id))
if (scopePaths.length > 0) {
let scopedCount = 0
for (const session of result.sessions) {
const cwd = session.cwd
if (cwd && scopePaths.some((scopePath) => isPathInsideOrEqual(scopePath, cwd))) {
selectedIds.add(session.id)
if (++scopedCount >= depth) {
break
}
}
}
}
return { ...result, sessions: result.sessions.filter((session) => selectedIds.has(session.id)) }
}

View File

@ -25,6 +25,17 @@ export const AI_VAULT_AGENTS = [
// value are optional belt-and-braces, not required for the request to succeed.
export const AI_VAULT_SCOPE_PATHS_MAX_COUNT = 64
// Why: IPC rejection drops `Error.name`, so the renderer can only recognise a
// cancelled scan by its message. Both sides share this literal.
export const AI_VAULT_SCAN_CANCELLED_MESSAGE = 'Agent Session History scan was cancelled'
export function isAiVaultScanCancelledError(error: unknown): boolean {
return (
error instanceof Error &&
(error.name === 'AbortError' || error.message.includes(AI_VAULT_SCAN_CANCELLED_MESSAGE))
)
}
export type AiVaultAgent = (typeof AI_VAULT_AGENTS)[number]
export type AiVaultScope = 'workspace' | 'project' | 'all'
export type AiVaultSort = 'updated' | 'created'
@ -172,23 +183,29 @@ export function isAiVaultSessionRecoverableEmpty(
export type AiVaultScanIssue = {
executionHostId?: ExecutionHostId
agent: AiVaultAgent
// 'notice' rows are scanner commentary (issue-list overflow), never a failure.
kind?: 'host' | 'scope' | 'notice'
path: string
message: string
}
export type AiVaultListArgs = {
limit?: number
unlimited?: boolean
force?: boolean
// 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
requestToken?: string
}
export type AiVaultListResult = {
sessions: AiVaultSession[]
issues: AiVaultScanIssue[]
scannedAt: string
/** Set only by the desktop IPC boundary: this scan was superseded, so its empty body means "nothing to apply". */
cancelled?: true
}
export function aiVaultAgentLabel(agent: AiVaultAgent): string {

View File

@ -0,0 +1,12 @@
export const SSH_AI_VAULT_LIST_SESSIONS_METHOD = 'aiVault.listSessions' as const
export const SSH_AI_VAULT_LIST_SESSIONS_TIMEOUT_MS = 130_000
export const SSH_AI_VAULT_LIST_LIMIT_MAX = 1000
export const SSH_AI_VAULT_SCOPE_PATH_MAX_LENGTH = 4096
export type SshAiVaultRelayListParams = {
limit?: number
unlimited?: boolean
force?: boolean
scopePaths?: string[]
scopePathsTruncated?: boolean
}

View File

@ -29,7 +29,7 @@ export async function connectDockerRemote(
page: Page,
target: DockerSshRelayTarget
): Promise<ConnectedDockerRemote> {
return await page.evaluate(
const remote = await page.evaluate(
async ({ target, remotePath }) => {
const store = window.__store
if (!store) {
@ -70,22 +70,44 @@ export async function connectDockerRemote(
}
await store.getState().fetchRepos()
await store.getState().fetchWorktrees(result.repo.id)
const worktree = (store.getState().worktreesByRepo[result.repo.id] ?? [])[0]
if (!worktree) {
throw new Error(`No remote worktree found for ${result.repo.path}`)
}
store.getState().setActiveWorktree(worktree.id)
if ((store.getState().tabsByWorktree[worktree.id] ?? []).length === 0) {
store.getState().createTab(worktree.id)
}
store.getState().setActiveTabType('terminal')
return { targetId: createdTarget.id, worktreeId: worktree.id }
return { targetId: createdTarget.id, repoId: result.repo.id, repoPath: result.repo.path }
} finally {
credentialUnsub()
}
},
{ target, remotePath: DOCKER_SSH_RELAY_REMOTE_REPO_PATH }
)
await expect
.poll(
() =>
page.evaluate(async (repoId) => {
const store = window.__store
if (!store) {
return 0
}
await store.getState().fetchWorktrees(repoId)
return store.getState().worktreesByRepo[repoId]?.length ?? 0
}, remote.repoId),
{ timeout: 30_000, message: `No remote worktree found for ${remote.repoPath}` }
)
.toBeGreaterThan(0)
const worktreeId = await page.evaluate((repoId) => {
const store = window.__store
const worktree = store?.getState().worktreesByRepo[repoId]?.[0]
if (!store || !worktree) {
throw new Error(`Remote worktree disappeared for repo ${repoId}`)
}
store.getState().setActiveWorktree(worktree.id)
if ((store.getState().tabsByWorktree[worktree.id] ?? []).length === 0) {
store.getState().createTab(worktree.id)
}
store.getState().setActiveTabType('terminal')
return worktree.id
}, remote.repoId)
return { targetId: remote.targetId, worktreeId }
}
export async function switchToNonRemoteWorktree(