Merge branch 'main' into brennanb2025/unify-agent-session-launch

This commit is contained in:
Merge Sim
2026-09-09 01:42:02 -07:00
226 changed files with 12308 additions and 1742 deletions
@@ -104,6 +104,34 @@ describe('orchestration worker-start CLI contract', () => {
expect(process.exitCode).toBeUndefined()
})
it.each(['succeeded', 'failed'])(
'accepts a successful start whose task already %s',
async (workerOutcome) => {
const receipt = {
taskId: 'task_1',
dispatchId: 'ctx_1',
state: 'ready',
stage: 'settled',
workerOutcome,
effects: [],
residualResources: []
}
callMock.mockResolvedValue({ result: receipt })
await invokeWorkerStart(
new Map([
['task', 'task_1'],
['from', 'term_coord']
])
)
expect(process.exitCode).toBeUndefined()
expect(printResult).toHaveBeenCalledWith(
expect.objectContaining({ result: receipt }),
true,
expect.any(Function)
)
}
)
it('capability-gates and forwards per-invocation launch preferences', async () => {
callMock
.mockResolvedValueOnce({
@@ -18,6 +18,7 @@ import { launchOrcaApp } from './launch'
import { addEnvironmentFromPairingCode } from './environments'
import { RuntimeClientError } from './types'
import {
AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY,
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
@@ -70,6 +71,7 @@ describe('CLI remote WebSocket transport', () => {
expect(runtime.authFrames).toContainEqual(
expect.objectContaining({
clientCapabilities: [
AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY,
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY,
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
+12 -2
View File
@@ -13,7 +13,13 @@ export async function statRemoteSessionFile(
agent: AiVaultAgent,
executionHostId: ExecutionHostId,
issues: AiVaultScanIssue[],
options?: { missingIsExpected?: boolean; signal?: AbortSignal }
options?: {
missingIsExpected?: boolean
signal?: AbortSignal
// Lets a caller tell a missing path from a failed stat, which both report
// as null; the issue is recorded either way before this rethrows.
rethrowFailures?: boolean
}
): Promise<FileWithMtime | null> {
try {
throwIfAiVaultScanCancelled(options?.signal)
@@ -31,7 +37,8 @@ export async function statRemoteSessionFile(
}
} catch (error) {
throwIfAiVaultScanCancelled(options?.signal)
if (!options?.missingIsExpected || !isMissingRemoteSessionPathError(error)) {
const missing = isMissingRemoteSessionPathError(error)
if (!options?.missingIsExpected || !missing) {
recordSessionScanIssue(issues, {
executionHostId,
agent,
@@ -39,6 +46,9 @@ export async function statRemoteSessionFile(
message: errorMessage(error)
})
}
if (options?.rethrowFailures && !missing) {
throw error
}
return null
}
}
@@ -1,5 +1,6 @@
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { RemoteScannerContext, RemoteSessionCandidate } from './remote-session-scanner-types'
import { sidecarUnchanged, type SessionSidecarObservation } from './session-sidecar-stat'
// Matches the local scanner's cap. The relay sidecar is forked with
// --max-old-space-size=384, and a retained session row is a title, a preview
@@ -11,6 +12,7 @@ type RemoteSessionParseCacheEntry = {
mtimeMs: number
sizeBytes: number | null
hostKey: string
sidecar?: SessionSidecarObservation
session: AiVaultSession | null
}
@@ -55,13 +57,15 @@ function storeEntry(path: string, entry: RemoteSessionParseCacheEntry): void {
* (#13753). The local scanner has had `parseAgentSessionFileCached` for exactly
* this reason; this is its remote counterpart.
*
* `(mtimeMs, sizeBytes)` is a sound validity key here because discovery already
* folds a source's `contentDependencyPath` stat into both fields
* (remote-session-scanner-discovery.ts), so a metadata-only transcript whose
* companion file changed still looks changed. Sources whose parse reads a file
* discovery does not stat — Codex looks its title up in `session_index.jsonl` —
* are not covered by that key and pass `refreshReusedSession` to re-derive the
* uncovered part without touching the transcript.
* `(mtimeMs, sizeBytes)` covers the transcript, and the sidecar observation
* discovery records beside it (remote-session-scanner-discovery.ts) covers a
* source's companion file, so a metadata-only transcript whose companion
* changed still looks changed. Remote Cline is the only such source; remote
* Cursor streams transcript content with no sibling to read. Sources whose
* parse reads a file discovery does not stat — Codex looks its title up in
* `session_index.jsonl` — are not covered by either and pass
* `refreshReusedSession` to re-derive the uncovered part without touching the
* transcript.
*
* Only a completed parse is stored. A read that threw stays uncached so a
* transient filesystem failure cannot pin a wrong answer for the corpus's life.
@@ -80,7 +84,10 @@ export async function parseRemoteSessionFileCached(args: {
entry !== undefined &&
entry.hostKey === args.hostKey &&
entry.mtimeMs === file.mtimeMs &&
(entry.sizeBytes === null || file.sizeBytes === undefined || entry.sizeBytes === file.sizeBytes)
(entry.sizeBytes === null ||
file.sizeBytes === undefined ||
entry.sizeBytes === file.sizeBytes) &&
sidecarUnchanged(entry.sidecar, file.sidecar)
if (unchanged) {
if (args.stats) {
args.stats.reused++
@@ -101,6 +108,7 @@ export async function parseRemoteSessionFileCached(args: {
mtimeMs: file.mtimeMs,
sizeBytes: file.sizeBytes ?? null,
hostKey: args.hostKey,
sidecar: file.sidecar,
session
})
return session
@@ -4,6 +4,7 @@ import type { ExecutionHostId } from '../../shared/execution-host'
import { joinRemotePath } from '../ssh/ssh-remote-platform'
import { isMissingRemoteSessionPathError, statRemoteSessionFile } from './remote-session-file-stat'
import type { FileWithMtime } from './session-scanner-types'
import type { SessionSidecarObservation } from './session-sidecar-stat'
import { errorMessage } from './session-scanner-values'
import { mapRemoteScanBatches } from './remote-session-scan-batching'
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
@@ -61,23 +62,38 @@ async function statRemoteCandidateFile(
if (!file || !source.contentDependencyPath) {
return file
}
const dependency = await statRemoteSessionFile(
context.provider,
source.contentDependencyPath(path),
source.agent,
context.executionHostId,
issues,
{ missingIsExpected: true, signal: context.signal }
)
if (!dependency) {
return file
}
const mtimeMs = Math.max(file.mtimeMs, dependency.mtimeMs)
return {
...file,
mtimeMs,
modifiedAt: new Date(mtimeMs).toISOString(),
sizeBytes: (file.sizeBytes ?? 0) + (dependency.sizeBytes ?? 0)
const sidecarPath = source.contentDependencyPath(path)
// Recorded beside the transcript's own stat, never folded into it: one key
// cannot mean both "the transcript grew" and "the sibling changed".
return { ...file, sidecar: await observeRemoteSidecar(source, context, sidecarPath, issues) }
}
/**
* A stat that failed for any reason other than a missing path is `'unknown'`,
* not `'none'`: serving the cached session over an unreadable sibling would
* publish metadata nobody can currently see. `statRemoteSessionFile` already
* recorded the issue for the failure.
*/
async function observeRemoteSidecar(
source: RemoteSessionSource,
context: RemoteScannerContext,
sidecarPath: string,
issues: AiVaultScanIssue[]
): Promise<SessionSidecarObservation> {
try {
const sidecar = await statRemoteSessionFile(
context.provider,
sidecarPath,
source.agent,
context.executionHostId,
issues,
{ missingIsExpected: true, signal: context.signal, rethrowFailures: true }
)
return sidecar
? { path: sidecarPath, mtimeMs: sidecar.mtimeMs, sizeBytes: sidecar.sizeBytes ?? 0 }
: 'none'
} catch {
return 'unknown'
}
}
@@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest'
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
import { MemoryRemoteProvider } from './remote-session-scanner-test-fixtures'
import { resetRemoteSessionParseCacheForTests } from './remote-session-parse-cache'
describe('remote sidecar observations', () => {
const sessionId = '1786466194549_sidecar'
const sessionDir = `/home/ada/.cline/data/sessions/${sessionId}`
const messagesPath = `${sessionDir}/${sessionId}.messages.json`
function addCline(provider: MemoryRemoteProvider, firstPrompt: string): void {
provider.addFile(
`${sessionDir}/${sessionId}.json`,
JSON.stringify({
version: 1,
session_id: sessionId,
started_at: '2026-08-11T16:36:34.551Z',
cwd: '/home/ada/repo'
}),
10
)
provider.addFile(
messagesPath,
JSON.stringify({
version: 1,
updated_at: '2026-08-11T16:38:00.000Z',
sessionId,
messages: [{ role: 'user', content: [{ type: 'text', text: firstPrompt }] }]
}),
11
)
}
const scan = (provider: MemoryRemoteProvider): ReturnType<typeof scanRemoteAiVaultSessions> =>
scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:dev-box',
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64')
})
it('re-parses when a messages-file stat fails rather than serving the cached row', async () => {
resetRemoteSessionParseCacheForTests()
const provider = new MemoryRemoteProvider()
addCline(provider, 'first prompt')
expect((await scan(provider)).sessions[0]).toMatchObject({ title: 'first prompt' })
// The sidecar changed underneath, and its stat now fails for a reason that
// is not "missing": nothing about it may be assumed, so the row is re-read.
addCline(provider, 'second prompt')
provider.failStat(
messagesPath,
Object.assign(new Error('permission denied'), { code: 'EACCES' })
)
const refused = await scan(provider)
expect(refused.sessions[0]).toMatchObject({ title: 'second prompt' })
expect(refused.issues.map((issue) => issue.path)).toContain(messagesPath)
})
it('treats a genuinely missing messages file as no sidecar, not as unknown', async () => {
resetRemoteSessionParseCacheForTests()
const provider = new MemoryRemoteProvider()
provider.addFile(
`${sessionDir}/${sessionId}.json`,
JSON.stringify({
version: 1,
session_id: sessionId,
started_at: '2026-08-11T16:36:34.551Z',
cwd: '/home/ada/repo'
}),
10
)
const first = await scan(provider)
const second = await scan(provider)
expect(first.issues).toEqual([])
expect(second.issues).toEqual([])
expect(second.sessions[0]?.sessionId).toBe(sessionId)
})
})
@@ -0,0 +1,31 @@
import { expect, it } from 'vitest'
import { SessionNewestFiles } from './session-newest-files'
import type { FileWithMtime } from './session-scanner-types'
function file(i: number): FileWithMtime {
const mtimeMs = (i * 7919) % 997
return { path: String(i), mtimeMs, modifiedAt: new Date(mtimeMs).toISOString() }
}
it('retains at most 12 of 100,000 candidates with stable newest-first ties', () => {
const all = Array.from({ length: 100_000 }, (_, i) => file(i))
const retained = new SessionNewestFiles(12)
let peak = 0
for (const candidate of all) {
retained.add(candidate)
peak = Math.max(peak, retained.size)
}
expect(peak).toBe(12)
expect(retained.newest()).toEqual(all.sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, 12))
})
it('supports full backfill and empty requests', () => {
const all = new SessionNewestFiles(Infinity)
const none = new SessionNewestFiles(0)
for (let i = 0; i < 100; i++) {
all.add(file(i))
none.add(file(i))
}
expect(all.newest()).toHaveLength(100)
expect(none.newest()).toEqual([])
})
+50
View File
@@ -0,0 +1,50 @@
import type { FileWithMtime } from './session-scanner-types'
/** Retain only the requested newest files, preserving traversal order on ties. */
export class SessionNewestFiles {
private readonly files: FileWithMtime[] = []
private readonly limit: number
constructor(limit: number) {
this.limit = Math.max(0, Math.trunc(limit) || 0)
}
add(file: FileWithMtime): void {
// The backfill enumerates with no limit; skip the insert search entirely.
if (!Number.isFinite(this.limit)) {
this.files.push(file)
return
}
if (this.limit <= 0) {
return
}
const last = this.files.at(-1)
if (this.files.length >= this.limit && last && file.mtimeMs <= last.mtimeMs) {
return
}
let low = 0
let high = this.files.length
while (low < high) {
const middle = (low + high) >>> 1
if (this.files[middle].mtimeMs >= file.mtimeMs) {
low = middle + 1
} else {
high = middle
}
}
this.files.splice(low, 0, file)
if (this.files.length > this.limit) {
this.files.pop()
}
}
get size(): number {
return this.files.length
}
/** The unbounded path appends in traversal order, so the sort is not redundant. */
newest(): FileWithMtime[] {
return [...this.files].sort((a, b) => b.mtimeMs - a.mtimeMs)
}
}
@@ -29,9 +29,12 @@ import {
parseAgentSessionFileCached,
resetSessionParseCacheForTests,
seedSessionParseCache,
snapshotSessionParseCacheForPersistence,
type PersistedSessionParseCacheEntry,
type SessionParseStats
} from './session-scanner-parse-cache'
import { getSessionParseCacheEntry } from './session-parse-cache-store'
import type { SessionSidecarObservation } from './session-sidecar-stat'
import { isolatedScanRoots } from './session-scanner-test-fixtures'
import { parseClaudeSessionFile } from './session-scanner-primary-parsers'
import type { FileWithMtime, SessionFileCandidate } from './session-scanner-types'
@@ -526,3 +529,38 @@ describe('session parse cache persistence', () => {
debugSpy.mockRestore()
})
})
describe('sidecar observations survive the round trip', () => {
const OBSERVATIONS: [string, SessionSidecarObservation | undefined][] = [
['an object', { path: '/chats/a/meta.json', mtimeMs: 42, sizeBytes: 7 }],
['none', 'none'],
['unknown', 'unknown'],
['absent', undefined]
]
it.each(OBSERVATIONS)('restores %s exactly', async (_label, sidecar) => {
const root = await makeTempDir()
const cacheFile = join(root, 'session-parse-cache.json')
const path = await writeTranscript(root)
initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION })
await ensureSessionParseCacheLoaded()
const stats = createSessionParseStats()
await parseAgentSessionFileCached(await claudeCandidate(path), process.platform, stats)
const seeded = snapshotSessionParseCacheForPersistence().map(
([entryPath, entry]): [string, PersistedSessionParseCacheEntry] => [
entryPath,
sidecar === undefined ? entry : { ...entry, sidecar }
]
)
resetSessionParseCacheForTests()
seedSessionParseCache(seeded)
scheduleSessionParseCachePersist(stats)
await flushSessionParseCachePersistForTests()
simulateRestart(cacheFile)
await ensureSessionParseCacheLoaded()
expect(getSessionParseCacheEntry(path)?.sidecar).toEqual(sidecar)
})
})
@@ -11,6 +11,7 @@ import {
type PersistedSessionParseCacheEntry,
type SessionParseStats
} from './session-scanner-parse-cache'
import type { SessionSidecarObservation } from './session-sidecar-stat'
// Bump when the persisted entry layout or cached session semantics change; a
// mismatched file is discarded whole.
@@ -181,17 +182,37 @@ function parsePersistedEntry(item: unknown): [string, PersistedSessionParseCache
if (entry.session !== null && typeof entry.session !== 'object') {
return null
}
const sidecar = parsePersistedSidecar(entry.sidecar)
return [
path,
{
mtimeMs: entry.mtimeMs,
sizeBytes: entry.sizeBytes,
platform: entry.platform as NodeJS.Platform,
session: entry.session as PersistedSessionParseCacheEntry['session']
session: entry.session as PersistedSessionParseCacheEntry['session'],
...(sidecar === undefined ? {} : { sidecar })
}
]
}
// Why: added after SCHEMA_VERSION 2 shipped, so a file an older build wrote has
// no such field. Absent (or unreadable) means unknown, which costs one re-parse
// of the rows that have a sibling and nothing at all for the rest.
function parsePersistedSidecar(value: unknown): SessionSidecarObservation | undefined {
if (value === 'none' || value === 'unknown') {
return value
}
if (typeof value !== 'object' || value === null) {
return undefined
}
const record = value as Record<string, unknown>
return typeof record.path === 'string' &&
typeof record.mtimeMs === 'number' &&
typeof record.sizeBytes === 'number'
? { path: record.path, mtimeMs: record.mtimeMs, sizeBytes: record.sizeBytes }
: undefined
}
async function persistSnapshot(current: SessionParseCachePersistenceOptions): Promise<void> {
const directory = dirname(current.filePath)
const tempPath = join(directory, `session-parse-cache-${process.pid}-${Date.now()}.tmp`)
@@ -0,0 +1,111 @@
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { ResumableSessionParseState } from './session-scanner-types'
import type { SessionSidecarObservation } from './session-sidecar-stat'
import type { TranscriptMessageChannel } from './session-transcript-channel'
// Sized past the default recency cap (1000) plus the in-scope cap (2000) so a
// full steady-state result set stays resident between forced rescans.
const MAX_CACHE_ENTRIES = 4096
export type SessionParseResumePoint = {
state: ResumableSessionParseState
// Byte offset just past the last complete ('\n'-terminated) line consumed;
// a trailing unterminated line is deliberately left before this point.
byteOffset: number
// Bound to the cached state, which keeps the reference its parsers were built
// with; a resumed read re-points this channel instead of replacing it.
channel: TranscriptMessageChannel
}
export type SessionParseCacheEntry = {
mtimeMs: number
sizeBytes: number | null
platform: NodeJS.Platform
session: AiVaultSession | null
// What the sibling file looked like when `session` was built. Tracked apart
// from the transcript's key so each can go stale on its own.
sidecar?: SessionSidecarObservation
// The session the transcript alone produced, before any sibling was merged
// onto it. In-memory only: without it a sibling change costs one re-parse.
foldSession?: AiVaultSession | null
resume: SessionParseResumePoint | null
}
const cache = new Map<string, SessionParseCacheEntry>()
export function resetSessionParseCacheForTests(): void {
cache.clear()
}
// Drops one entry after its file is deleted. Cleanliness, not correctness:
// discovery walks disk first, so a trashed file is never rediscovered anyway.
export function invalidateSessionParseCacheEntry(path: string): void {
cache.delete(path)
}
// Persisted subset of a cache entry: the non-serializable `resume` parser
// state is dropped, and `foldSession` with it, so a restart pays one re-parse
// for a session whose sibling moved rather than storing every row twice
// (see session-parse-cache-persistence.ts).
export type PersistedSessionParseCacheEntry = Omit<SessionParseCacheEntry, 'resume' | 'foldSession'>
export function snapshotSessionParseCacheForPersistence(): [
string,
PersistedSessionParseCacheEntry
][] {
return [...cache].map(([path, entry]): [string, PersistedSessionParseCacheEntry] => [
path,
{
mtimeMs: entry.mtimeMs,
sizeBytes: entry.sizeBytes,
platform: entry.platform,
session: entry.session,
...(entry.sidecar === undefined ? {} : { sidecar: entry.sidecar })
}
])
}
// Seeded entries carry `resume: null`: after a restart an unchanged file is a
// cache hit; a file that changed while the app was closed pays one full
// (not incremental) re-parse.
export function seedSessionParseCache(
entries: Iterable<[string, PersistedSessionParseCacheEntry]>
): void {
const list = [...entries]
// Snapshot order is oldest→newest (LRU); an over-cap list keeps the newest
// tail rather than seeding the oldest entries and dropping the tail.
for (const [path, entry] of list.slice(Math.max(0, list.length - MAX_CACHE_ENTRIES))) {
if (cache.size >= MAX_CACHE_ENTRIES) {
return
}
// In-process entries are always fresher than persisted ones; never clobber.
if (cache.has(path)) {
continue
}
cache.set(path, {
mtimeMs: entry.mtimeMs,
sizeBytes: entry.sizeBytes,
platform: entry.platform,
session: entry.session,
// Absent in files an older build wrote; `sidecarUnchanged` reads that as
// unknown, so such a row re-enriches on its first scan.
sidecar: entry.sidecar,
resume: null
})
}
}
export function getSessionParseCacheEntry(path: string): SessionParseCacheEntry | undefined {
return cache.get(path)
}
export function storeSessionParseCacheEntry(path: string, entry: SessionParseCacheEntry): void {
cache.delete(path)
cache.set(path, entry)
if (cache.size > MAX_CACHE_ENTRIES) {
const oldest = cache.keys().next()
if (!oldest.done) {
cache.delete(oldest.value)
}
}
}
@@ -0,0 +1,28 @@
const pending = new Map<string, Promise<unknown>>()
/**
* Serializes parses of one transcript path.
*
* Two callers really do overlap on the same file: a forced refresh aborts the
* running scan while its in-flight parse keeps going as the replacement scan
* starts it again, and `session-title-file-reader.ts` parses on its own, with
* no scan involved. Overlapping reads share the cached resume point's message
* channel, so the second `beginRead` would drop the first read's consumers and
* the first `finishRead` would hand them the wrong outcome; the later store
* could also move the cursor backwards.
*/
export async function inSessionParseFileLane<T>(path: string, parse: () => Promise<T>): Promise<T> {
const previous = pending.get(path)
const run = (async () => {
await previous?.catch(() => undefined)
return parse()
})()
pending.set(path, run)
try {
return await run
} finally {
if (pending.get(path) === run) {
pending.delete(path)
}
}
}
@@ -23,6 +23,12 @@ import {
normalizePreviewText,
timestampMs
} from './session-scanner-values'
import { NO_TRANSCRIPT_MESSAGES, type TranscriptMessageSink } from './session-transcript-consumers'
import {
boundedText,
transcriptMessageRole,
transcriptMessagesFromContent
} from './session-transcript-message-content'
const SESSION_PREVIEW_MESSAGE_LIMIT = 5
@@ -30,9 +36,12 @@ export function createAccumulator(args: {
agent: AiVaultAgent
file: FileWithMtime
sessionId: string
// Where every decoded message goes; absent for one-shot parses with no reader.
messages?: TranscriptMessageSink
}): SessionAccumulator {
return {
agent: args.agent,
messages: args.messages ?? NO_TRANSCRIPT_MESSAGES,
sessionId: args.sessionId,
title: null,
fallbackTitle: null,
@@ -75,6 +84,9 @@ export function accumulatorFoldResumeState(
},
// Finalize a snapshot: the live accumulator (and its preview array) keeps
// accumulating appended lines after this session object is handed out.
// A sibling file's metadata is merged onto this result by the parse cache,
// never into the fold, so re-merging it later starts from what the
// transcript alone said (see session-scanner-sidecar-enrichment.ts).
finalize: (platform, options) =>
finalizeSession(cloneSessionAccumulator(accumulator), platform, options)
}
@@ -96,7 +108,7 @@ export function finalizeSession(
const title =
accumulator.title ||
accumulator.fallbackTitle ||
`${aiVaultAgentLabel(accumulator.agent)} ${sessionId.slice(0, 8)}`
generatedSessionTitle(accumulator.agent, sessionId)
const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID
@@ -137,6 +149,15 @@ export function finalizeSession(
}
}
/**
* The title a session gets when neither the transcript nor the agent named it.
* Exported so a later merge can tell "the fold found no title" from a real one
* without re-deriving the string (session-scanner-sidecar-enrichment.ts).
*/
export function generatedSessionTitle(agent: AiVaultAgent, sessionId: string): string {
return `${aiVaultAgentLabel(agent)} ${sessionId.slice(0, 8)}`
}
export function updateTimeline(accumulator: SessionAccumulator, timestamp: unknown): void {
const parsed = timestampMs(timestamp)
if (!Number.isFinite(parsed)) {
@@ -161,8 +182,13 @@ export function addPreviewMessage(
// Why: Claude meta/injected turns still preview, but must not seed the
// copyable first-prompt row.
seedFirstUserPrompt?: boolean
// Set false by callers that already published this record's messages.
publishMessage?: boolean
}
): void {
if (args.publishMessage !== false && accumulator.messages.active) {
publishTranscriptMessage(accumulator, args.role, args.text, args.timestamp)
}
// Seeded before the preview-empty return so the copy body never depends on
// preview-only normalization rules.
seedFullFirstUserPrompt(
@@ -199,15 +225,41 @@ export function addPreviewContent(
() => extractFullFirstUserPromptText(content),
options?.seedFirstUserPrompt
)
// Published from the content value, not the preview string: a consumer needs
// the whole turn, including the tool blocks the 220-char preview drops.
if (accumulator.messages.active) {
for (const message of transcriptMessagesFromContent(role, content, timestampIso(timestamp))) {
accumulator.messages.push(message)
}
}
addPreviewMessage(accumulator, {
role,
text: extractPreviewContentText(content),
timestamp,
// Content path already seeded above when capture is enabled.
seedFirstUserPrompt: false
seedFirstUserPrompt: false,
publishMessage: false
})
}
/** One already-flattened turn; the content path publishes per block instead. */
function publishTranscriptMessage(
accumulator: SessionAccumulator,
role: AiVaultSessionPreviewMessage['role'],
text: string | null,
timestamp: unknown
): void {
const messageRole = transcriptMessageRole(role)
const messageText = text === null ? null : boundedText(text)
if (messageRole && messageText) {
accumulator.messages.push({
role: messageRole,
text: messageText,
timestamp: timestampIso(timestamp)
})
}
}
/**
* Seed the copyable first prompt from the first real user turn. `fullText` is a
* thunk so list scans (capture mode `none`) never pay the extraction cost.
@@ -6,7 +6,10 @@ import { parseClineSessionFile } from './session-scanner-cline-parser'
import { parseGrokSessionFile } from './session-scanner-grok-parser'
import { parseMessageGraphSessionFile, parseRovoSessionFile } from './session-scanner-graph-parsers'
import { parseKimiSessionFile } from './session-scanner-kimi-parser'
import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths'
import {
looksLikeOpenCodeSqliteCandidate,
splitOpenCodeSqliteCandidate
} from './session-scanner-opencode-sqlite-paths'
import { parseOpenCodeSqliteSessionViaWorker } from './session-scanner-opencode-sqlite-worker-spawn'
import { parseClaudeSessionFile } from './session-scanner-primary-parsers'
import { parseGeminiSessionFile } from './session-scanner-gemini-parsers'
@@ -16,6 +19,16 @@ import { parseCursorSessionFile } from './session-scanner-cursor-parser'
import { parseHermesSessionFile } from './session-scanner-hermes-parser'
import { parseOpenCodeSessionFile } from './session-scanner-opencode-parser'
import type { SessionFileCandidate } from './session-scanner-types'
import type { TranscriptMessageSink } from './session-transcript-consumers'
/**
* False when a parser decodes its messages somewhere the channel cannot reach.
* OpenCode's SQLite sessions are read on a worker thread, so their messages
* never come back over the sink and the read must not be reported as complete.
*/
export function parserPublishesMessages(candidate: SessionFileCandidate): boolean {
return candidate.agent !== 'opencode' || !looksLikeOpenCodeSqliteCandidate(candidate.file.path)
}
/**
* Parse a single agent session file into an `AiVaultSession`. Routes to the
@@ -24,25 +37,33 @@ import type { SessionFileCandidate } from './session-scanner-types'
* `parseOpenCodeSqliteSession` instead of the legacy JSON parser.
* @param candidate - The session file candidate to parse.
* @param platform - The platform to use for resume command generation.
* @param messages - Where the parser publishes every decoded message.
* @returns The parsed `AiVaultSession`, or `null` if parsing fails.
*/
export async function parseAgentSessionFile(
candidate: SessionFileCandidate,
platform: NodeJS.Platform
platform: NodeJS.Platform,
messages?: TranscriptMessageSink
): Promise<AiVaultSession | null> {
switch (candidate.agent) {
case 'claude':
return parseClaudeSessionFile(candidate.file, platform)
return parseClaudeSessionFile(candidate.file, platform, messages)
case 'codex':
return parseCodexSessionFile(candidate.file, platform, candidate.codexHome)
return parseCodexSessionFile(
candidate.file,
platform,
candidate.codexHome,
undefined,
messages
)
case 'gemini':
return parseGeminiSessionFile(candidate.file, platform)
return parseGeminiSessionFile(candidate.file, platform, messages)
case 'antigravity':
return parseAntigravitySessionFile(candidate.file, platform)
return parseAntigravitySessionFile(candidate.file, platform, messages)
case 'copilot':
return parseCopilotSessionFile(candidate.file, platform)
return parseCopilotSessionFile(candidate.file, platform, messages)
case 'cursor':
return parseCursorSessionFile(candidate.file, platform)
return parseCursorSessionFile(candidate.file, platform, messages)
case 'opencode': {
// Why: OpenCode 1.17.x sessions are read from SQLite via a synthetic
// <dbPath>#<sessionId> candidate path. Legacy file-based sessions use
@@ -55,29 +76,29 @@ export async function parseAgentSessionFile(
platform
})
}
return parseOpenCodeSessionFile(candidate.file, platform)
return parseOpenCodeSessionFile(candidate.file, platform, messages)
}
case 'grok':
return parseGrokSessionFile(candidate.file, platform)
return parseGrokSessionFile(candidate.file, platform, messages)
case 'hermes':
return parseHermesSessionFile(candidate.file, platform)
return parseHermesSessionFile(candidate.file, platform, messages)
case 'rovo':
return parseRovoSessionFile(candidate.file, platform)
return parseRovoSessionFile(candidate.file, platform, messages)
case 'openclaw':
return parseMessageGraphSessionFile('openclaw', candidate.file, platform)
return parseMessageGraphSessionFile('openclaw', candidate.file, platform, messages)
case 'pi':
return parseMessageGraphSessionFile('pi', candidate.file, platform)
return parseMessageGraphSessionFile('pi', candidate.file, platform, messages)
case 'omp':
return parseMessageGraphSessionFile('omp', candidate.file, platform)
return parseMessageGraphSessionFile('omp', candidate.file, platform, messages)
case 'prime-agent':
return parseMessageGraphSessionFile('prime-agent', candidate.file, platform)
return parseMessageGraphSessionFile('prime-agent', candidate.file, platform, messages)
case 'droid':
return parseDroidSessionFile(candidate.file, platform)
return parseDroidSessionFile(candidate.file, platform, messages)
case 'cline':
return parseClineSessionFile(candidate.file, platform)
return parseClineSessionFile(candidate.file, platform, messages)
case 'devin':
return parseDevinSessionFile(candidate.file, platform)
return parseDevinSessionFile(candidate.file, platform, messages)
case 'kimi':
return parseKimiSessionFile(candidate.file, platform)
return parseKimiSessionFile(candidate.file, platform, messages)
}
}
@@ -8,6 +8,7 @@ import {
clineMessagesPathForMetadata,
isClineSessionMetadataPath
} from './session-scanner-cline-parser'
import { cursorChatMetaPath } from './session-scanner-cursor-chat-meta'
import { resolveKimiSessionsDir } from './session-scanner-kimi-paths'
import { OMP_SESSION_ARTIFACT_DIR_PATTERN } from './session-scanner-omp-subagent-transcripts'
import { claudeProjectsRootDirs, OMP_SESSIONS_DIR, sessionRootDirs } from './session-scanner-roots'
@@ -61,8 +62,9 @@ export type AiVaultAgentSource = {
rootDirs: (options: AiVaultScanOptions, wslHomeDirs: readonly string[]) => string[]
extensions: readonly string[]
filePredicate?: (filePath: string) => boolean
// A sibling whose stat participates in candidate freshness and recency.
contentDependencyPath?: (filePath: string) => string
// A sibling whose stat participates in candidate freshness and recency; async
// for agents that have to look the sibling up rather than derive its path.
contentDependencyPath?: (filePath: string) => string | undefined | Promise<string | undefined>
// Return false to skip a directory; depth 0 is a child of the root.
directoryPredicate?: (name: string, depth: number) => boolean
// Roots that are alternates for one install rather than distinct locations,
@@ -126,7 +128,8 @@ export const AI_VAULT_AGENT_SOURCES: AiVaultAgentSourceTable = {
'projects'
]),
extensions: ['.jsonl'],
filePredicate: (filePath) => pathSegments(filePath).includes('agent-transcripts')
filePredicate: (filePath) => pathSegments(filePath).includes('agent-transcripts'),
contentDependencyPath: cursorChatMetaPath
},
grok: {
rootDirs: (options, wslHomeDirs) =>
@@ -15,6 +15,7 @@ import type {
ResumableSessionParseState,
SessionAccumulator
} from './session-scanner-types'
import type { TranscriptMessageSink } from './session-transcript-consumers'
import { extractString, normalizeTitleText, parseJsonObject } from './session-scanner-values'
type ParserSessionOptions = {
@@ -24,12 +25,13 @@ type ParserSessionOptions = {
export async function parseAntigravitySessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
platform: NodeJS.Platform = process.platform,
messages?: TranscriptMessageSink
): Promise<AiVaultSession | null> {
const input = openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan')
const lines = createInterface({ input, crlfDelay: Infinity })
try {
return await parseAntigravitySessionLines({ file, lines, platform })
return await parseAntigravitySessionLines({ file, lines, platform, messages })
} finally {
// readline.close() leaves the underlying stream open; destroy it so a
// mid-parse throw cannot leak the gated transcript handle.
@@ -54,13 +56,14 @@ export async function parseAntigravitySessionContent(
}
export function createAntigravitySessionResumeState(
file: FileWithMtime
file: FileWithMtime,
messages?: TranscriptMessageSink
): ResumableSessionParseState {
const sessionId = antigravityConversationIdFromTranscriptPath(file.path) ?? ''
// Why: the transcript has no cwd/model fields. Workspace enrichment is a
// separate, conservative history join; protobuf/SQLite blobs are unstable.
return accumulatorFoldResumeState(
createAccumulator({ agent: 'antigravity', file, sessionId }),
createAccumulator({ agent: 'antigravity', file, sessionId, messages }),
consumeAntigravityRecordLine
)
}
@@ -70,8 +73,9 @@ async function parseAntigravitySessionLines(args: {
lines: AsyncIterable<string> | Iterable<string>
platform: NodeJS.Platform
options?: ParserSessionOptions
messages?: TranscriptMessageSink
}): Promise<AiVaultSession | null> {
const state = createAntigravitySessionResumeState(args.file)
const state = createAntigravitySessionResumeState(args.file, args.messages)
for await (const line of args.lines) {
state.consumeLine(line)
}
@@ -0,0 +1,46 @@
import { readCodexRolloutSessionMetaId } from '../codex/codex-rollout-session-meta'
import { codexRolloutHardlinkIdentity, dedupeCodexRolloutAliases } from './codex-session-root-dedup'
import { antigravityHistoryPathForBrainDir } from './session-scanner-antigravity-paths'
import { codexHomeForSessionsDir } from './session-scanner-codex-paths'
import { DEFAULT_CODEX_HOME_DIR } from './session-scanner-source-discovery'
import type {
AiVaultScanOptions,
SessionFileCandidate,
SessionFileDiscovery
} from './session-scanner-types'
/** Newest-first parse candidates for a discovery set, with Codex hardlink aliases collapsed. */
export async function sessionCandidatesFromDiscoveries(
discoveries: SessionFileDiscovery[],
options: AiVaultScanOptions
): Promise<SessionFileCandidate[]> {
return dedupeCodexRolloutAliases(
discoveries
.flatMap((discovery) =>
discovery.files.map((file): SessionFileCandidate => ({
agent: discovery.agent,
file,
codexHome:
discovery.agent === 'codex'
? codexHomeForSessionsDir(
discovery.rootDir,
options.defaultCodexHomeDir ?? DEFAULT_CODEX_HOME_DIR
)
: null,
antigravityHistoryPath:
discovery.agent === 'antigravity'
? antigravityHistoryPathForBrainDir(discovery.rootDir)
: undefined
}))
)
.sort((left, right) => right.file.mtimeMs - left.file.mtimeMs),
{
isCodex: (candidate) => candidate.agent === 'codex',
getFilePath: (candidate) => candidate.file.path,
getCodexHome: (candidate) => candidate.codexHome,
getHardlinkIdentity: (candidate) => codexRolloutHardlinkIdentity(candidate.file)
},
(filePath) => readCodexRolloutSessionMetaId(filePath, options.signal, 'scan'),
options.signal
)
}
@@ -9,6 +9,7 @@ import {
updateTimeline
} from './session-scanner-accumulator'
import type { FileWithMtime } from './session-scanner-types'
import type { TranscriptMessageSink } from './session-transcript-consumers'
import {
arrayValue,
asRecord,
@@ -35,7 +36,8 @@ export function clineMessagesPathForMetadata(filePath: string): string {
export async function parseClineSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
platform: NodeJS.Platform = process.platform,
messageSink?: TranscriptMessageSink
): Promise<AiVaultSession | null> {
const metadataContent = await wslGatedReadFile(file.path, 'utf-8', 'scan')
let messagesContent: string | null = null
@@ -52,7 +54,7 @@ export async function parseClineSessionFile(
throw error
}
}
return parseClineSessionContent(file, metadataContent, messagesContent, platform)
return parseClineSessionContent(file, metadataContent, messagesContent, platform, {}, messageSink)
}
function isMissingSessionPathError(error: unknown): boolean {
@@ -68,7 +70,8 @@ export function parseClineSessionContent(
metadataContent: string,
messagesContent: string | null,
platform: NodeJS.Platform = process.platform,
options: ParserSessionOptions = {}
options: ParserSessionOptions = {},
messageSink?: TranscriptMessageSink
): AiVaultSession | null {
const metadata = parseJsonRecord(metadataContent)
if (!metadata) {
@@ -76,7 +79,12 @@ export function parseClineSessionContent(
}
const pathSegments = file.path.replace(/\\/g, '/').split('/').filter(Boolean)
const sessionId = extractString(metadata.session_id) ?? pathSegments.at(-2) ?? ''
const accumulator = createAccumulator({ agent: 'cline', file, sessionId })
const accumulator = createAccumulator({
agent: 'cline',
file,
sessionId,
messages: messageSink
})
accumulator.cwd = extractString(metadata.cwd) ?? extractString(metadata.workspace_root)
accumulator.model = extractString(metadata.model)
updateTimeline(accumulator, metadata.started_at)
@@ -22,6 +22,7 @@ import type {
ResumableSessionParseState,
SessionAccumulator
} from './session-scanner-types'
import type { TranscriptMessageSink } from './session-transcript-consumers'
import {
addCodexUsage,
asRecord,
@@ -29,18 +30,22 @@ import {
extractModel,
extractString,
normalizeCodexUsage,
normalizeTitleText,
parseJsonObject,
subtractCodexUsage
} from './session-scanner-values'
import { remoteSessionContentLines } from './remote-session-content-lines'
import { readCodexTimelineOnlyRecord } from './session-scanner-codex-record-fast-path'
import {
extractCodexSessionMetadataTitle,
isCodexWorkerSession
} from './session-scanner-codex-session-meta'
export async function parseCodexSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform,
codexHome: string | null = null,
executionHostId?: ExecutionHostId
executionHostId?: ExecutionHostId,
messages?: TranscriptMessageSink
): Promise<AiVaultSession | null> {
const lines = createInterface({
input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'),
@@ -53,6 +58,7 @@ export async function parseCodexSessionFile(
platform,
codexHome,
executionHostId,
messages,
titleReader: (sessionId) => readCodexSessionIndexTitle(file.path, codexHome, sessionId)
})
}
@@ -89,12 +95,16 @@ type CodexSessionParseState = {
titleSource: 'meta' | 'user' | null
}
function createCodexParseState(file: FileWithMtime): CodexSessionParseState {
function createCodexParseState(
file: FileWithMtime,
messages?: TranscriptMessageSink
): CodexSessionParseState {
return {
accumulator: createAccumulator({
agent: 'codex',
file,
sessionId: sessionIdFromFileName(file.path)
sessionId: sessionIdFromFileName(file.path),
messages
}),
previousTotals: null,
rejectedWorkerSession: false,
@@ -257,10 +267,13 @@ async function finalizeCodexParseState(
export function createCodexSessionResumeState(
file: FileWithMtime,
codexHome: string | null
codexHome: string | null,
messages?: TranscriptMessageSink
): ResumableSessionParseState {
return codexResumeStateFromParseState(createCodexParseState(file), codexHome, (sessionId) =>
readCodexSessionIndexTitle(file.path, codexHome, sessionId)
return codexResumeStateFromParseState(
createCodexParseState(file, messages),
codexHome,
(sessionId) => readCodexSessionIndexTitle(file.path, codexHome, sessionId)
)
}
@@ -298,8 +311,9 @@ async function parseCodexSessionLines(args: {
executionHostId?: ExecutionHostId
executionHostPlatform?: NodeJS.Platform | null
titleReader?: (sessionId: string) => Promise<string | null>
messages?: TranscriptMessageSink
}): Promise<AiVaultSession | null> {
const state = createCodexParseState(args.file)
const state = createCodexParseState(args.file, args.messages)
for await (const line of args.lines) {
consumeCodexRecordLine(state, line)
if (state.rejectedWorkerSession) {
@@ -314,21 +328,3 @@ async function parseCodexSessionLines(args: {
executionHostPlatform: args.executionHostPlatform
})
}
function isCodexWorkerSession(payload: Record<string, unknown>): boolean {
const threadSource = extractString(payload.thread_source) ?? extractString(payload.threadSource)
if (threadSource) {
return threadSource.toLowerCase() !== 'user'
}
const source = asRecord(payload.source)
return Boolean(asRecord(source?.subagent))
}
function extractCodexSessionMetadataTitle(payload: Record<string, unknown>): string | null {
return (
normalizeTitleText(extractString(payload.title) ?? '') ??
normalizeTitleText(extractString(payload.thread_name) ?? '') ??
normalizeTitleText(extractString(payload.threadName) ?? '')
)
}
@@ -0,0 +1,22 @@
import { asRecord, extractString, normalizeTitleText } from './session-scanner-values'
// Field readers for Codex's `session_meta` record, whose key spelling has drifted
// across Codex releases (snake_case rollouts, camelCase app-server rollouts).
export function isCodexWorkerSession(payload: Record<string, unknown>): boolean {
const threadSource = extractString(payload.thread_source) ?? extractString(payload.threadSource)
if (threadSource) {
return threadSource.toLowerCase() !== 'user'
}
const source = asRecord(payload.source)
return Boolean(asRecord(source?.subagent))
}
export function extractCodexSessionMetadataTitle(payload: Record<string, unknown>): string | null {
return (
normalizeTitleText(extractString(payload.title) ?? '') ??
normalizeTitleText(extractString(payload.thread_name) ?? '') ??
normalizeTitleText(extractString(payload.threadName) ?? '')
)
}
@@ -8,6 +8,7 @@ import type {
ResumableSessionParseState,
SessionAccumulator
} from './session-scanner-types'
import type { TranscriptMessageSink } from './session-transcript-consumers'
import {
accumulatorFoldResumeState,
addPreviewMessage,
@@ -32,13 +33,14 @@ type ParserSessionOptions = {
export async function parseCopilotSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
platform: NodeJS.Platform = process.platform,
messages?: TranscriptMessageSink
): Promise<AiVaultSession | null> {
const lines = createInterface({
input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'),
crlfDelay: Infinity
})
return parseCopilotSessionLines({ file, lines, platform })
return parseCopilotSessionLines({ file, lines, platform, messages })
}
export async function parseCopilotSessionContent(
@@ -107,9 +109,17 @@ function consumeCopilotRecordLine(accumulator: SessionAccumulator, line: string)
}
}
export function createCopilotSessionResumeState(file: FileWithMtime): ResumableSessionParseState {
export function createCopilotSessionResumeState(
file: FileWithMtime,
messages?: TranscriptMessageSink
): ResumableSessionParseState {
return accumulatorFoldResumeState(
createAccumulator({ agent: 'copilot', file, sessionId: sessionIdFromFileName(file.path) }),
createAccumulator({
agent: 'copilot',
file,
sessionId: sessionIdFromFileName(file.path),
messages
}),
consumeCopilotRecordLine
)
}
@@ -119,8 +129,9 @@ async function parseCopilotSessionLines(args: {
lines: AsyncIterable<string> | Iterable<string>
platform: NodeJS.Platform
options?: ParserSessionOptions
messages?: TranscriptMessageSink
}): Promise<AiVaultSession | null> {
const state = createCopilotSessionResumeState(args.file)
const state = createCopilotSessionResumeState(args.file, args.messages)
for await (const line of args.lines) {
state.consumeLine(line)
}
@@ -0,0 +1,552 @@
import { appendFile, mkdir, mkdtemp, rm, stat, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-error'
// Why: a refused WSL read is the one build failure that must not be cached.
let failNextChatsReaddir = false
let failNextChatsRootReaddir = false
let failMetaJsonReads = false
let failMetaJsonStats: false | true | 'eacces' = false
let chatsRootReads = 0
vi.mock('../native-chat/wsl-transcript-fs-access', async (importOriginal) => {
const actual = await importOriginal<typeof WslTranscriptFsAccess>()
return {
...actual,
wslGatedReaddir: (
...args: Parameters<typeof actual.wslGatedReaddir>
): ReturnType<typeof actual.wslGatedReaddir> => {
if (args[0].endsWith('chats')) {
chatsRootReads += 1
if (failNextChatsRootReaddir) {
failNextChatsRootReaddir = false
return Promise.reject(new WslTranscriptFsError('timeout', 'wsl fs timed out'))
}
}
if (failNextChatsReaddir && args[0].includes('workspace-hash')) {
failNextChatsReaddir = false
return Promise.reject(new WslTranscriptFsError('timeout', 'wsl fs timed out'))
}
return actual.wslGatedReaddir(...args)
},
wslGatedReadFile: (
...args: Parameters<typeof actual.wslGatedReadFile>
): ReturnType<typeof actual.wslGatedReadFile> => {
if (failMetaJsonReads && String(args[0]).endsWith('meta.json')) {
return Promise.reject(new WslTranscriptFsError('timeout', 'wsl fs timed out'))
}
return actual.wslGatedReadFile(...args)
},
wslGatedStat: (
...args: Parameters<typeof actual.wslGatedStat>
): ReturnType<typeof actual.wslGatedStat> => {
if (failMetaJsonStats && String(args[0]).endsWith('meta.json')) {
return Promise.reject(
failMetaJsonStats === 'eacces'
? Object.assign(new Error('permission denied'), { code: 'EACCES' })
: new WslTranscriptFsError('timeout', 'wsl fs timed out')
)
}
return actual.wslGatedStat(...args)
}
}
})
import type * as WslTranscriptFsAccess from '../native-chat/wsl-transcript-fs-access'
import {
cursorChatMetaPath,
readCursorChatMeta,
resetCursorChatMetaIndexCacheForTests,
withCursorChatMetaScan
} from './session-scanner-cursor-chat-meta'
import { parseCursorSessionContent } from './session-scanner-cursor-parser'
import type { AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types'
import { AI_VAULT_AGENT_SOURCES } from './session-scanner-agent-sources'
import { discoverFiles } from './session-scanner-discovery'
import { scanAiVaultSessions } from './session-scanner'
import {
createSessionParseStats,
parseAgentSessionFileCached,
resetSessionParseCacheForTests,
seedSessionParseCache,
snapshotSessionParseCacheForPersistence,
type SessionParseStats
} from './session-scanner-parse-cache'
import {
getSessionParseCacheEntry,
type PersistedSessionParseCacheEntry
} from './session-parse-cache-store'
import { isolatedScanRoots } from './session-scanner-test-fixtures'
import type { FileWithMtime } from './session-scanner-types'
import type { SessionSidecarStat } from './session-sidecar-stat'
// Cursor's real meta.json keys (~/.cursor/chats/<md5 of cwd>/<uuid>/meta.json, 2026-09).
type CursorMetaFixture = {
schemaVersion: number
createdAtMs: number
updatedAtMs: number
cwd: string
hasConversation: boolean
title?: string
}
const CREATED_AT_MS = 1_787_039_612_017
const UPDATED_AT_MS = 1_787_039_640_532
let tempRoots: string[] = []
afterEach(async () => {
resetCursorChatMetaIndexCacheForTests()
resetSessionParseCacheForTests()
failNextChatsRootReaddir = false
failMetaJsonReads = false
failMetaJsonStats = false
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
tempRoots = []
})
async function createCursorHome(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'orca-cursor-chat-meta-'))
tempRoots.push(root)
const cursorHome = join(root, '.cursor')
await mkdir(cursorHome, { recursive: true })
return cursorHome
}
async function writeTranscript(
cursorHome: string,
projectSlug: string,
chatId: string,
lines: string[]
): Promise<string> {
const chatDir = join(cursorHome, 'projects', projectSlug, 'agent-transcripts', chatId)
await mkdir(chatDir, { recursive: true })
const transcriptPath = join(chatDir, `${chatId}.jsonl`)
await writeFile(transcriptPath, lines.map((line) => `${line}\n`).join(''))
return transcriptPath
}
async function writeChatMeta(
cursorHome: string,
workspaceHash: string,
chatId: string,
meta: Partial<CursorMetaFixture> = {}
): Promise<string> {
const chatDir = join(cursorHome, 'chats', workspaceHash, chatId)
await mkdir(chatDir, { recursive: true })
const metaPath = join(chatDir, 'meta.json')
await writeFile(
metaPath,
JSON.stringify({
schemaVersion: 1,
createdAtMs: CREATED_AT_MS,
updatedAtMs: UPDATED_AT_MS,
cwd: '/private/tmp/workspace',
hasConversation: true,
...meta
} satisfies CursorMetaFixture)
)
return metaPath
}
function fileWithMtime(path: string): FileWithMtime {
return { path, mtimeMs: 1, modifiedAt: new Date(1).toISOString() }
}
describe('cursor chat meta', () => {
it('resolves the meta.json under the workspace hash that holds the chat id', async () => {
const cursorHome = await createCursorHome()
await writeChatMeta(cursorHome, 'aa37220647fb7ce5eb044aa4bda60807', 'other-chat')
const metaPath = await writeChatMeta(cursorHome, '96fa26ac0f433670ebec73ecef20b47b', 'chat-1', {
title: 'Shell Command Hostname'
})
const transcriptPath = await writeTranscript(cursorHome, 'private-tmp-workspace', 'chat-1', [])
expect(await cursorChatMetaPath(transcriptPath)).toBe(metaPath)
expect(await readCursorChatMeta(transcriptPath)).toEqual({
title: 'Shell Command Hostname',
cwd: '/private/tmp/workspace',
createdAt: new Date(CREATED_AT_MS).toISOString(),
updatedAt: new Date(UPDATED_AT_MS).toISOString()
})
})
it('re-indexes after a chat appears under an already indexed workspace', async () => {
const cursorHome = await createCursorHome()
await writeChatMeta(cursorHome, 'workspace-hash', 'chat-first')
const firstTranscript = await writeTranscript(cursorHome, 'slug', 'chat-first', [])
expect(await cursorChatMetaPath(firstTranscript)).toBeDefined()
const laterMetaPath = await writeChatMeta(cursorHome, 'workspace-hash', 'chat-later')
const laterTranscript = await writeTranscript(cursorHome, 'slug', 'chat-later', [])
expect(await cursorChatMetaPath(laterTranscript)).toBe(laterMetaPath)
})
it('does not cache a metadata index whose build was refused by the WSL gate', async () => {
const cursorHome = await createCursorHome()
const metaPath = await writeChatMeta(cursorHome, 'workspace-hash', 'chat-refused')
const transcriptPath = await writeTranscript(cursorHome, 'slug', 'chat-refused', [])
failNextChatsReaddir = true
// A refusal degrades to "no metadata" rather than taking the session down.
await expect(cursorChatMetaPath(transcriptPath)).resolves.toBeUndefined()
// The next scan rebuilds instead of replaying the rejected promise.
await expect(cursorChatMetaPath(transcriptPath)).resolves.toBe(metaPath)
})
it('validates the index once per scan, not once per transcript', async () => {
const cursorHome = await createCursorHome()
const transcripts: string[] = []
for (const chatId of ['chat-a', 'chat-b', 'chat-c']) {
await writeChatMeta(cursorHome, 'workspace-hash', chatId)
transcripts.push(await writeTranscript(cursorHome, 'slug', chatId, []))
}
chatsRootReads = 0
const inScan = await withCursorChatMetaScan(() =>
Promise.all(transcripts.map((path) => cursorChatMetaPath(path)))
)
expect(inScan.every(Boolean)).toBe(true)
expect(chatsRootReads).toBe(1)
// Outside a scan every lookup re-validates, which is what the parse path needs.
chatsRootReads = 0
await Promise.all(transcripts.map((path) => cursorChatMetaPath(path)))
expect(chatsRootReads).toBe(3)
})
it('yields nothing and does not throw when there is no chats tree', async () => {
const cursorHome = await createCursorHome()
const transcriptPath = await writeTranscript(cursorHome, 'slug', 'chat-orphan', [])
await expect(cursorChatMetaPath(transcriptPath)).resolves.toBeUndefined()
await expect(readCursorChatMeta(transcriptPath)).resolves.toBeNull()
await expect(readCursorChatMeta('/nowhere/near/cursor/chat.jsonl')).resolves.toBeNull()
})
it('yields nothing and does not throw when meta.json is malformed', async () => {
const cursorHome = await createCursorHome()
const chatDir = join(cursorHome, 'chats', 'workspace-hash', 'chat-bad')
await mkdir(chatDir, { recursive: true })
await writeFile(join(chatDir, 'meta.json'), '{ not json')
const transcriptPath = await writeTranscript(cursorHome, 'slug', 'chat-bad', [])
await expect(readCursorChatMeta(transcriptPath)).resolves.toBeNull()
})
})
async function cursorCandidate(cursorHome: string): Promise<FileWithMtime> {
const issues: AiVaultScanIssue[] = []
const discovery = await discoverFiles({
rootDir: join(cursorHome, 'projects'),
limit: 10,
agent: 'cursor',
issues,
extensions: [...AI_VAULT_AGENT_SOURCES.cursor.extensions],
filePredicate: AI_VAULT_AGENT_SOURCES.cursor.filePredicate,
contentDependencyPath: AI_VAULT_AGENT_SOURCES.cursor.contentDependencyPath
})
return discovery.files[0]
}
/** The production path: the parse cache owns the sidecar merge, not the parser. */
function parseCursorCached(
file: FileWithMtime,
stats: SessionParseStats = createSessionParseStats()
): Promise<{ session: AiVaultSession | null; stats: SessionParseStats }> {
return withCursorChatMetaScan(async () => {
const session = await parseAgentSessionFileCached(
{ agent: 'cursor', file, codexHome: null },
'darwin',
stats
)
return { session, stats }
})
}
async function writeCursorScanFixture(chatIds: string[]): Promise<{
cursorHome: string
scanOptions: ReturnType<typeof isolatedScanRoots> & { cursorProjectsDir: string }
}> {
const cursorHome = await createCursorHome()
for (const chatId of chatIds) {
await writeChatMeta(cursorHome, 'workspace-hash', chatId, { cwd: `/tmp/ws-${chatId}` })
await writeTranscript(cursorHome, 'slug', chatId, [
JSON.stringify({ role: 'user', message: { content: [{ type: 'text', text: chatId }] } })
])
}
const root = join(cursorHome, '..')
return {
cursorHome,
scanOptions: {
...isolatedScanRoots(root),
cursorProjectsDir: join(cursorHome, 'projects')
}
}
}
describe('cursor discovery sidecar observation', () => {
it('records meta.json beside the transcript stat instead of folding it in', async () => {
const cursorHome = await createCursorHome()
const metaPath = await writeChatMeta(cursorHome, 'workspace-hash', 'chat-7')
const transcriptPath = await writeTranscript(cursorHome, 'slug', 'chat-7', [])
const before = await cursorCandidate(cursorHome)
const future = new Date(Date.now() + 10_000)
await utimes(metaPath, future, future)
const after = await cursorCandidate(cursorHome)
// The transcript's own key is untouched by a sibling rewrite.
const transcriptStat = await stat(transcriptPath)
expect(after.mtimeMs).toBe(transcriptStat.mtimeMs)
expect(after.sizeBytes).toBe(transcriptStat.size)
expect(after.mtimeMs).toBe(before.mtimeMs)
// The sibling is observed separately, and it did move.
expect(before.sidecar).toMatchObject({ path: metaPath })
expect(after.sidecar).toMatchObject({ path: metaPath })
expect((after.sidecar as SessionSidecarStat).mtimeMs).toBeGreaterThan(
(before.sidecar as SessionSidecarStat).mtimeMs
)
})
})
describe('cursor sidecar enrichment', () => {
it('fills cwd, timestamps and title from meta.json', async () => {
const cursorHome = await createCursorHome()
await writeChatMeta(cursorHome, 'workspace-hash', 'chat-2', { title: 'Named From Meta' })
await writeTranscript(cursorHome, 'slug', 'chat-2', [
JSON.stringify({ role: 'assistant', message: { content: 'hello' } })
])
resetSessionParseCacheForTests()
const { session } = await parseCursorCached(await cursorCandidate(cursorHome))
expect(session?.cwd).toBe('/private/tmp/workspace')
expect(session?.title).toBe('Named From Meta')
expect(session?.createdAt).toBe(new Date(CREATED_AT_MS).toISOString())
expect(session?.updatedAt).toBe(new Date(UPDATED_AT_MS).toISOString())
})
it('keeps a transcript title and timestamps over meta.json', async () => {
const cursorHome = await createCursorHome()
await writeChatMeta(cursorHome, 'workspace-hash', 'chat-3', { title: 'Meta Title' })
await writeTranscript(cursorHome, 'slug', 'chat-3', [
JSON.stringify({
role: 'user',
timestamp: '2026-01-01T00:00:00.000Z',
message: { content: 'transcript first prompt' }
})
])
resetSessionParseCacheForTests()
const { session } = await parseCursorCached(await cursorCandidate(cursorHome))
expect(session?.title).toBe('transcript first prompt')
expect(session?.createdAt).toBe('2026-01-01T00:00:00.000Z')
// cwd is never in the transcript, so it still comes from meta.json.
expect(session?.cwd).toBe('/private/tmp/workspace')
})
it('builds the resume command from the meta.json cwd', async () => {
const cursorHome = await createCursorHome()
await writeChatMeta(cursorHome, 'workspace-hash', 'chat-4', { cwd: '/repo/from-meta' })
await writeTranscript(cursorHome, 'slug', 'chat-4', [
JSON.stringify({ role: 'user', message: { content: 'hi' } })
])
resetSessionParseCacheForTests()
const { session } = await parseCursorCached(await cursorCandidate(cursorHome))
expect(session?.resumeCommand).toContain('/repo/from-meta')
})
it('leaves remote content parses to the transcript alone', async () => {
const cursorHome = await createCursorHome()
await writeChatMeta(cursorHome, 'workspace-hash', 'chat-5', { title: 'Meta Title' })
const transcriptPath = await writeTranscript(cursorHome, 'slug', 'chat-5', [])
const session = await parseCursorSessionContent(
fileWithMtime(transcriptPath),
`${JSON.stringify({ role: 'assistant', message: { content: 'remote' } })}\n`,
'linux'
)
expect(session?.cwd).toBeNull()
expect(session?.title).not.toBe('Meta Title')
})
it('re-enriches without a parse when only the sidecar is rewritten', async () => {
const cursorHome = await createCursorHome()
await writeChatMeta(cursorHome, 'workspace-hash', 'chat-8', { cwd: '/repo/first' })
await writeTranscript(cursorHome, 'slug', 'chat-8', [
JSON.stringify({ role: 'user', message: { content: 'hi' } })
])
resetSessionParseCacheForTests()
await parseCursorCached(await cursorCandidate(cursorHome))
const future = new Date(Date.now() + 10_000)
await writeChatMeta(cursorHome, 'workspace-hash', 'chat-8', { cwd: '/repo/second' })
await utimes(join(cursorHome, 'chats', 'workspace-hash', 'chat-8', 'meta.json'), future, future)
const { session, stats } = await parseCursorCached(await cursorCandidate(cursorHome))
// The transcript is not re-read: the merge runs over the stored fold result.
expect(stats.reused).toBe(1)
expect(stats.fullParses).toBe(0)
expect(stats.incremental).toBe(0)
// A rewritten cwd REPLACES the merged one; `??=` on the cached session could
// never do this, because the cached cwd is already non-null.
expect(session?.cwd).toBe('/repo/second')
expect(session?.resumeCommand).toContain('/repo/second')
})
it('treats a persisted entry with no sidecar as unknown and enriches once', async () => {
const cursorHome = await createCursorHome()
await writeChatMeta(cursorHome, 'workspace-hash', 'chat-9', { cwd: '/repo/persisted' })
await writeTranscript(cursorHome, 'slug', 'chat-9', [
JSON.stringify({ role: 'user', message: { content: 'hi' } })
])
resetSessionParseCacheForTests()
await parseCursorCached(await cursorCandidate(cursorHome))
// What a build older than the sidecar field wrote: no such key.
const persisted = snapshotSessionParseCacheForPersistence().map(
([path, entry]): [string, PersistedSessionParseCacheEntry] => {
const { sidecar: _sidecar, ...rest } = entry
return [path, rest]
}
)
resetSessionParseCacheForTests()
seedSessionParseCache(persisted)
const { session, stats } = await parseCursorCached(await cursorCandidate(cursorHome))
expect(stats.reused).toBe(0)
expect(session?.cwd).toBe('/repo/persisted')
})
})
describe('cursor chat meta scan failures', () => {
it('lists cursor sessions without metadata when the chats tree is refused, then heals', async () => {
const { cursorHome, scanOptions } = await writeCursorScanFixture(['chat-a', 'chat-b'])
resetSessionParseCacheForTests()
failNextChatsRootReaddir = true
const refused = await scanAiVaultSessions({ ...scanOptions, platform: 'darwin', limit: 20 })
const refusedCursor = refused.sessions.filter((session) => session.agent === 'cursor')
expect(refusedCursor).toHaveLength(2)
expect(refusedCursor.map((session) => session.cwd)).toEqual([null, null])
// One issue for the chats root, not one per transcript.
expect(refused.issues).toHaveLength(1)
expect(refused.issues[0].path).toBe(join(cursorHome, 'chats'))
expect(refused.issues[0].agent).toBe('cursor')
const healed = await scanAiVaultSessions({ ...scanOptions, platform: 'darwin', limit: 20 })
expect(healed.issues).toEqual([])
expect(
healed.sessions
.filter((session) => session.agent === 'cursor')
.map((session) => session.cwd)
.sort()
).toEqual(['/tmp/ws-chat-a', '/tmp/ws-chat-b'])
})
it('re-enriches after a refused meta.json read without losing the resume cursor', async () => {
const { scanOptions } = await writeCursorScanFixture(['chat-a'])
resetSessionParseCacheForTests()
failMetaJsonReads = true
const refused = await scanAiVaultSessions({ ...scanOptions, platform: 'darwin', limit: 20 })
const listed = refused.sessions.find((session) => session.agent === 'cursor')
expect(listed?.cwd).toBeNull()
expect(refused.issues).toHaveLength(1)
// The sibling alone is unknown; the transcript's work and its resume point
// are kept, so the next healthy scan merges without re-reading bytes.
const entry = getSessionParseCacheEntry(listed?.filePath ?? '')
expect(entry?.sidecar).toBe('unknown')
expect(entry?.resume).not.toBeNull()
failMetaJsonReads = false
const healed = await scanAiVaultSessions({ ...scanOptions, platform: 'darwin', limit: 20 })
expect(healed.issues).toEqual([])
expect(healed.sessions.find((session) => session.agent === 'cursor')?.cwd).toBe(
'/tmp/ws-chat-a'
)
})
it('treats a local EACCES on the sidecar stat as unknown, not as absent', async () => {
const { scanOptions } = await writeCursorScanFixture(['chat-a'])
resetSessionParseCacheForTests()
// On mac/Linux/Windows the gated stat is a bare fs stat, so a permissions
// failure is not a WslTranscriptFsError and must not read as "no sidecar".
failMetaJsonStats = 'eacces'
const refused = await scanAiVaultSessions({ ...scanOptions, platform: 'darwin', limit: 20 })
const listed = refused.sessions.find((session) => session.agent === 'cursor')
expect(listed?.sessionId).toBeTruthy()
expect(refused.issues).toHaveLength(1)
expect(refused.issues[0].agent).toBe('cursor')
expect(getSessionParseCacheEntry(listed?.filePath ?? '')?.sidecar).toBe('unknown')
failMetaJsonStats = false
const healed = await scanAiVaultSessions({ ...scanOptions, platform: 'darwin', limit: 20 })
expect(healed.issues).toEqual([])
expect(healed.sessions.find((session) => session.agent === 'cursor')?.cwd).toBe(
'/tmp/ws-chat-a'
)
})
it('lists a cursor session whose meta.json stat is refused instead of dropping it', async () => {
const { scanOptions } = await writeCursorScanFixture(['chat-a'])
resetSessionParseCacheForTests()
failMetaJsonStats = true
const refused = await scanAiVaultSessions({ ...scanOptions, platform: 'darwin', limit: 20 })
expect(refused.sessions.filter((session) => session.agent === 'cursor')).toHaveLength(1)
expect(refused.issues).toHaveLength(1)
expect(refused.issues[0].agent).toBe('cursor')
failMetaJsonStats = false
const healed = await scanAiVaultSessions({ ...scanOptions, platform: 'darwin', limit: 20 })
expect(healed.issues).toEqual([])
expect(healed.sessions.find((session) => session.agent === 'cursor')?.cwd).toBe(
'/tmp/ws-chat-a'
)
})
it('reads the chats root once per scan across discovery and parse', async () => {
const { scanOptions } = await writeCursorScanFixture(['chat-a', 'chat-b', 'chat-c'])
resetSessionParseCacheForTests()
chatsRootReads = 0
const result = await scanAiVaultSessions({ ...scanOptions, platform: 'darwin', limit: 20 })
expect(result.sessions.filter((session) => session.agent === 'cursor')).toHaveLength(3)
expect(chatsRootReads).toBe(1)
})
it('resumes an appended transcript after a refused sidecar scan', async () => {
const cursorHome = await createCursorHome()
await writeChatMeta(cursorHome, 'workspace-hash', 'chat-r', { cwd: '/repo/resume' })
const transcriptPath = await writeTranscript(cursorHome, 'slug', 'chat-r', [
JSON.stringify({ role: 'user', message: { content: 'one' } })
])
resetSessionParseCacheForTests()
await parseCursorCached(await cursorCandidate(cursorHome))
await appendFile(
transcriptPath,
`${JSON.stringify({ role: 'user', message: { content: 'two' } })}\n`
)
failMetaJsonReads = true
const { stats: refusedStats } = await parseCursorCached(await cursorCandidate(cursorHome))
expect(refusedStats.incremental).toBe(1)
failMetaJsonReads = false
const { session, stats } = await parseCursorCached(await cursorCandidate(cursorHome))
expect(stats.reused).toBe(1)
expect(stats.fullParses).toBe(0)
expect(session?.cwd).toBe('/repo/resume')
})
})
@@ -0,0 +1,294 @@
import { AsyncLocalStorage } from 'node:async_hooks'
import { basename, dirname, join } from 'node:path'
import { wslGatedReaddir, wslGatedStat } from '../native-chat/wsl-transcript-fs-access'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import { timestampIso } from './session-scanner-accumulator'
import { extractString, normalizeTitleText, readJsonObjectIfExists } from './session-scanner-values'
// Cursor keeps a chat's transcript and its metadata in two unrelated trees:
// <cursor>/projects/<slug>/agent-transcripts/<uuid>/<uuid>.jsonl holds the
// messages, while <cursor>/chats/<md5 of cwd>/<uuid>/meta.json holds the cwd,
// title and timestamps. The md5 hashes the very cwd we are looking for, so the
// only way across is an index of the chat directories.
const CURSOR_CHATS_DIR = 'chats'
const CURSOR_CHAT_META_FILE = 'meta.json'
const CURSOR_TRANSCRIPTS_DIR = 'agent-transcripts'
const CURSOR_PROJECTS_DIR = 'projects'
// Why: custom and WSL Cursor homes can vary over a long-lived main process.
const CURSOR_CHAT_META_INDEX_CACHE_MAX = 8
export type CursorChatMeta = {
title: string | null
cwd: string | null
createdAt: string | null
updatedAt: string | null
}
type CursorChatMetaIndexEntry = {
signature: string
metaPathByChatId: Map<string, string>
}
const cursorChatMetaIndexCache = new Map<string, Promise<CursorChatMetaIndexEntry>>()
type CursorChatMetaScan = {
index: Map<string, Promise<Map<string, string>>>
// Chats roots this scan could not read, reported once by the scan owner.
refusals: Map<string, string>
// Transcripts whose own meta.json read was refused, so the metadata merged
// onto them is not what the file on disk says.
refusedTranscripts: Set<string>
}
// Why: validating the module cache costs a readdir of the chats root plus a stat
// per workspace, and it cannot be skipped because the signature is built from
// those stats. Discovery asks once per transcript and finalize asks again, so
// the scope has to span both phases for one scan to see the tree once.
const scanScopedIndex = new AsyncLocalStorage<CursorChatMetaScan>()
export function resetCursorChatMetaIndexCacheForTests(): void {
cursorChatMetaIndexCache.clear()
}
/** Runs one whole scan, discovery and parse; every Cursor transcript in it shares one index read. */
export function withCursorChatMetaScan<T>(fn: () => Promise<T>): Promise<T> {
return scanScopedIndex.run(
{ index: new Map(), refusals: new Map(), refusedTranscripts: new Set() },
fn
)
}
/**
* True when this transcript's own meta.json read was refused, so the caller
* records the sidecar as unknown rather than as the observation discovery made.
* The transcript's own work and its resume point are kept either way.
*/
export function wasCursorChatMetaRefused(transcriptPath: string): boolean {
return scanScopedIndex.getStore()?.refusedTranscripts.has(transcriptPath) ?? false
}
/** Chats roots the current scan was refused, for the caller to report as scan issues. */
export function cursorChatMetaRefusals(): { chatsRoot: string; message: string }[] {
const scan = scanScopedIndex.getStore()
return scan ? [...scan.refusals].map(([chatsRoot, message]) => ({ chatsRoot, message })) : []
}
/** Path a discovery stat can watch so a rewritten meta.json invalidates the parse cache. */
export async function cursorChatMetaPath(transcriptPath: string): Promise<string | undefined> {
const chatsRoot = cursorChatsRootFromTranscriptPath(transcriptPath)
const chatId = cursorChatIdFromTranscriptPath(transcriptPath)
if (!chatsRoot || !chatId) {
return undefined
}
const index = await readCursorChatMetaIndexOncePerScan(chatsRoot)
return index.get(chatId)
}
function readCursorChatMetaIndexOncePerScan(chatsRoot: string): Promise<Map<string, string>> {
const scan = scanScopedIndex.getStore()
if (!scan) {
return readCursorChatMetaIndexOrNone(chatsRoot)
}
let pending = scan.index.get(chatsRoot)
if (!pending) {
pending = readCursorChatMetaIndexOrNone(chatsRoot)
scan.index.set(chatsRoot, pending)
}
return pending
}
/**
* A refused WSL read is not "no chats", but it must not take the transcript
* down with it: before this join a stalled distro could not hide a Cursor
* session at all. Degrade to no metadata for the scan and report the root once.
* The session still lists from its transcript alone, and the sidecar is
* recorded as unknown, so the next healthy scan merges the real metadata in
* without re-reading a byte of the transcript.
*/
async function readCursorChatMetaIndexOrNone(chatsRoot: string): Promise<Map<string, string>> {
try {
return await readCursorChatMetaIndex(chatsRoot)
} catch (error) {
if (!(error instanceof WslTranscriptFsError)) {
throw error
}
recordCursorChatMetaRefusal(chatsRoot, error.message)
return new Map()
}
}
function recordCursorChatMetaRefusal(chatsRoot: string, message: string): void {
const scan = scanScopedIndex.getStore()
if (scan && !scan.refusals.has(chatsRoot)) {
scan.refusals.set(chatsRoot, message)
}
}
export async function readCursorChatMeta(transcriptPath: string): Promise<CursorChatMeta | null> {
const metaPath = await cursorChatMetaPath(transcriptPath)
if (!metaPath) {
return null
}
let record: Record<string, unknown> | null
try {
record = await readJsonObjectIfExists(metaPath)
} catch (error) {
if (!(error instanceof WslTranscriptFsError)) {
throw error
}
// The session still lists, but unlike the index read this transcript's key
// already includes meta.json's stat, so the caller must not cache the
// un-enriched result. One issue per chats root, as for a refused index.
recordCursorChatMetaRefusal(
cursorChatsRootFromTranscriptPath(transcriptPath) ?? metaPath,
error.message
)
scanScopedIndex.getStore()?.refusedTranscripts.add(transcriptPath)
return null
}
if (!record) {
return null
}
return {
title: normalizeTitleText(extractString(record.title) ?? ''),
cwd: extractString(record.cwd),
createdAt: timestampIso(record.createdAtMs),
updatedAt: timestampIso(record.updatedAtMs)
}
}
function cursorChatIdFromTranscriptPath(transcriptPath: string): string | null {
const chatDir = dirname(transcriptPath)
return basename(dirname(chatDir)) === CURSOR_TRANSCRIPTS_DIR ? basename(chatDir) : null
}
function cursorChatsRootFromTranscriptPath(transcriptPath: string): string | null {
let currentDir = dirname(transcriptPath)
while (currentDir && dirname(currentDir) !== currentDir) {
// The chats tree is a sibling of the projects tree, custom Cursor homes included.
if (basename(currentDir) === CURSOR_PROJECTS_DIR) {
return join(dirname(currentDir), CURSOR_CHATS_DIR)
}
currentDir = dirname(currentDir)
}
return null
}
async function readCursorChatMetaIndex(chatsRoot: string): Promise<Map<string, string>> {
let workspaceDirs: string[]
try {
workspaceDirs = (await wslGatedReaddir(chatsRoot, 'scan'))
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort()
} catch (error) {
// Why: a refused WSL read is not "no chats"; letting it through keeps the
// session out of the parse cache instead of caching it without metadata.
if (error instanceof WslTranscriptFsError) {
throw error
}
return new Map()
}
const signature = await readCursorChatsSignature(chatsRoot, workspaceDirs)
const cached = await readCachedCursorChatMetaIndex(chatsRoot, signature)
if (cached) {
return cached
}
const pending = buildCursorChatMetaIndex(chatsRoot, workspaceDirs).then((metaPathByChatId) => ({
signature,
metaPathByChatId
}))
storeCursorChatMetaIndexEntry(chatsRoot, pending)
// Why: a rejected build (a refused WSL read) must not be served from the
// cache forever; the next scan rebuilds while this one still sees the error.
pending.catch(() => {
if (cursorChatMetaIndexCache.get(chatsRoot) === pending) {
cursorChatMetaIndexCache.delete(chatsRoot)
}
})
return (await pending).metaPathByChatId
}
// Why: a new chat only bumps its own workspace directory, so the chats root's
// own mtime would keep serving an index that is missing the newest sessions.
async function readCursorChatsSignature(
chatsRoot: string,
workspaceDirs: string[]
): Promise<string> {
const parts = await Promise.all(
workspaceDirs.map(async (name) => {
try {
const dirStat = await wslGatedStat(join(chatsRoot, name), 'scan')
return `${name}:${dirStat.mtimeMs}`
} catch {
return `${name}:?`
}
})
)
return parts.join('|')
}
async function buildCursorChatMetaIndex(
chatsRoot: string,
workspaceDirs: string[]
): Promise<Map<string, string>> {
const metaPathByChatId = new Map<string, string>()
for (const workspaceDir of workspaceDirs) {
let chatDirs
try {
chatDirs = await wslGatedReaddir(join(chatsRoot, workspaceDir), 'scan')
} catch (error) {
if (error instanceof WslTranscriptFsError) {
throw error
}
continue
}
for (const chatDir of chatDirs) {
// Why: the same chat id never appears under two workspace hashes, so the
// first hit wins and a duplicate would only cost a wasted read.
if (chatDir.isDirectory() && !metaPathByChatId.has(chatDir.name)) {
metaPathByChatId.set(
chatDir.name,
join(chatsRoot, workspaceDir, chatDir.name, CURSOR_CHAT_META_FILE)
)
}
}
}
return metaPathByChatId
}
async function readCachedCursorChatMetaIndex(
chatsRoot: string,
signature: string
): Promise<Map<string, string> | undefined> {
const cached = cursorChatMetaIndexCache.get(chatsRoot)
if (!cached) {
return undefined
}
const entry = await cached
if (entry.signature !== signature) {
return undefined
}
// Why: a concurrent scan can replace this Promise while it resolves; only the
// still-current entry may refresh recency without bypassing the cap.
if (cursorChatMetaIndexCache.get(chatsRoot) === cached) {
cursorChatMetaIndexCache.delete(chatsRoot)
cursorChatMetaIndexCache.set(chatsRoot, cached)
}
return entry.metaPathByChatId
}
function storeCursorChatMetaIndexEntry(
chatsRoot: string,
pending: Promise<CursorChatMetaIndexEntry>
): void {
cursorChatMetaIndexCache.delete(chatsRoot)
cursorChatMetaIndexCache.set(chatsRoot, pending)
if (cursorChatMetaIndexCache.size > CURSOR_CHAT_META_INDEX_CACHE_MAX) {
const oldest = cursorChatMetaIndexCache.keys().next()
if (!oldest.done) {
cursorChatMetaIndexCache.delete(oldest.value)
}
}
}
@@ -8,6 +8,7 @@ import type {
ResumableSessionParseState,
SessionAccumulator
} from './session-scanner-types'
import type { TranscriptMessageSink } from './session-transcript-consumers'
import {
accumulatorFoldResumeState,
addPreviewContent,
@@ -30,13 +31,14 @@ type ParserSessionOptions = {
export async function parseCursorSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
platform: NodeJS.Platform = process.platform,
messages?: TranscriptMessageSink
): Promise<AiVaultSession | null> {
const lines = createInterface({
input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'),
crlfDelay: Infinity
})
return parseCursorSessionLines({ file, lines, platform })
return parseCursorSessionLines({ file, lines, platform, messages })
}
export async function parseCursorSessionContent(
@@ -75,9 +77,17 @@ function consumeCursorRecordLine(accumulator: SessionAccumulator, line: string):
}
}
export function createCursorSessionResumeState(file: FileWithMtime): ResumableSessionParseState {
export function createCursorSessionResumeState(
file: FileWithMtime,
messages?: TranscriptMessageSink
): ResumableSessionParseState {
return accumulatorFoldResumeState(
createAccumulator({ agent: 'cursor', file, sessionId: sessionIdFromFileName(file.path) }),
createAccumulator({
agent: 'cursor',
file,
sessionId: sessionIdFromFileName(file.path),
messages
}),
consumeCursorRecordLine
)
}
@@ -87,8 +97,9 @@ async function parseCursorSessionLines(args: {
lines: AsyncIterable<string> | Iterable<string>
platform: NodeJS.Platform
options?: ParserSessionOptions
messages?: TranscriptMessageSink
}): Promise<AiVaultSession | null> {
const state = createCursorSessionResumeState(args.file)
const state = createCursorSessionResumeState(args.file, args.messages)
for await (const line of args.lines) {
state.consumeLine(line)
}
@@ -2,6 +2,7 @@ import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { FileWithMtime } from './session-scanner-types'
import type { TranscriptMessageSink } from './session-transcript-consumers'
import {
addPreviewContent,
createAccumulator,
@@ -25,20 +26,34 @@ type ParserSessionOptions = {
export async function parseDevinSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
platform: NodeJS.Platform = process.platform,
messages?: TranscriptMessageSink
): Promise<AiVaultSession | null> {
return parseDevinSessionContent(
return parseDevinSessionRecord(
file,
await wslGatedReadFile(file.path, 'utf-8', 'scan'),
platform
platform,
{},
messages
)
}
/** Remote transcript content, streamed from a host that has no reader attached. */
export function parseDevinSessionContent(
file: FileWithMtime,
content: string,
platform: NodeJS.Platform = process.platform,
options: ParserSessionOptions = {}
): AiVaultSession | null {
return parseDevinSessionRecord(file, content, platform, options)
}
function parseDevinSessionRecord(
file: FileWithMtime,
content: string,
platform: NodeJS.Platform,
options: ParserSessionOptions,
messages?: TranscriptMessageSink
): AiVaultSession | null {
const record = asRecord(JSON.parse(content) as unknown)
if (!record) {
@@ -48,7 +63,7 @@ export function parseDevinSessionContent(
extractString(record.session_id) ??
extractString(record.sessionId) ??
sessionIdFromFileName(file.path)
const accumulator = createAccumulator({ agent: 'devin', file, sessionId })
const accumulator = createAccumulator({ agent: 'devin', file, sessionId, messages })
const agentRecord = asRecord(record.agent)
accumulator.model =
extractString(agentRecord?.model_name) ??
+104 -59
View File
@@ -1,10 +1,12 @@
import type { Dirent } from 'node:fs'
import { extname, join } from 'node:path'
import { SessionNewestFiles } from './session-newest-files'
import type { SessionSidecarObservation } from './session-sidecar-stat'
import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types'
import { wslGatedReaddir, wslGatedStat } from '../native-chat/wsl-transcript-fs-access'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import { recordSessionScanIssue } from './session-scan-issues'
import type { FileWithMtime, SessionFileDiscovery } from './session-scanner-types'
import type { SessionFileDiscovery } from './session-scanner-types'
import { errorMessage } from './session-scanner-values'
export async function discoverFiles(args: {
@@ -14,16 +16,55 @@ export async function discoverFiles(args: {
issues: AiVaultScanIssue[]
extensions: string[]
filePredicate?: (path: string) => boolean
contentDependencyPath?: (path: string) => string
contentDependencyPath?: (path: string) => string | undefined | Promise<string | undefined>
directoryPredicate?: (name: string, depth: number) => boolean
}): Promise<SessionFileDiscovery> {
let paths: string[]
const files = new SessionNewestFiles(args.limit)
let refusedSidecar = false
try {
paths = await walkSessionFiles(args.rootDir, args.agent, args.issues, {
extensions: new Set(args.extensions),
filePredicate: args.filePredicate,
directoryPredicate: args.directoryPredicate
})
await forEachSessionFile(
args.rootDir,
args.agent,
args.issues,
{
extensions: new Set(args.extensions),
filePredicate: args.filePredicate,
directoryPredicate: args.directoryPredicate
},
async (path) => {
try {
const fileStat = await wslGatedStat(path, 'scan')
const sidecarPath = await args.contentDependencyPath?.(path)
const sidecar = await observeSessionSidecar(sidecarPath)
if (sidecar === 'unknown' && !refusedSidecar) {
// One issue per root: a refused sibling is a property of the tree,
// not of each transcript that happens to point at it.
refusedSidecar = true
recordSessionScanIssue(args.issues, {
agent: args.agent,
path: sidecarPath ?? args.rootDir,
message: 'Session metadata could not be read this scan.'
})
}
files.add({
path,
mtimeMs: fileStat.mtimeMs,
modifiedAt: new Date(fileStat.mtimeMs).toISOString(),
sizeBytes: fileStat.size,
sidecar,
dev: fileStat.dev,
ino: fileStat.ino,
nlink: fileStat.nlink
})
} catch (err) {
recordSessionScanIssue(args.issues, {
agent: args.agent,
path,
message: errorMessage(err)
})
}
}
)
} catch (err) {
// Why: discoverAiVaultSessionSources fans out with Promise.all, so one
// stalled distro would otherwise reject the whole vault scan — including
@@ -38,68 +79,74 @@ export async function discoverFiles(args: {
})
return { agent: args.agent, rootDir: args.rootDir, files: [] }
}
const files: FileWithMtime[] = []
for (const path of paths) {
try {
const fileStat = await wslGatedStat(path, 'scan')
const dependencyStat = await optionalContentDependencyStat(args.contentDependencyPath?.(path))
const mtimeMs = Math.max(fileStat.mtimeMs, dependencyStat?.mtimeMs ?? 0)
files.push({
path,
mtimeMs,
modifiedAt: new Date(mtimeMs).toISOString(),
sizeBytes: fileStat.size + (dependencyStat?.size ?? 0),
dev: fileStat.dev,
ino: fileStat.ino,
nlink: fileStat.nlink
})
} catch (err) {
recordSessionScanIssue(args.issues, {
agent: args.agent,
path,
message: errorMessage(err)
})
}
}
return {
agent: args.agent,
rootDir: args.rootDir,
files: files.sort((left, right) => right.mtimeMs - left.mtimeMs).slice(0, args.limit)
}
return { agent: args.agent, rootDir: args.rootDir, files: files.newest() }
}
async function optionalContentDependencyStat(
/**
* A sibling that cannot be statted is not "no sibling": it must not take the
* transcript down with it, and it must not read as absent either, or the parse
* cache would treat a session enriched from a file nobody can see as current
* forever. Only a genuinely missing path is `'none'`; every other failure —
* a stalled WSL distro, EACCES, EIO — is `'unknown'`.
*/
async function observeSessionSidecar(
filePath: string | undefined
): Promise<{ mtimeMs: number; size: number } | null> {
): Promise<SessionSidecarObservation> {
if (!filePath) {
return null
return 'none'
}
try {
const fileStat = await wslGatedStat(filePath, 'scan')
return { mtimeMs: fileStat.mtimeMs, size: fileStat.size }
return { path: filePath, mtimeMs: fileStat.mtimeMs, sizeBytes: fileStat.size }
} catch (error) {
if (error instanceof WslTranscriptFsError) {
throw error
}
return null
return isMissingSidecarError(error) ? 'none' : 'unknown'
}
}
function isMissingSidecarError(error: unknown): boolean {
if (error instanceof WslTranscriptFsError) {
return false
}
const code =
error && typeof error === 'object' && 'code' in error && typeof error.code === 'string'
? error.code
: null
return code === 'ENOENT' || code === 'ENOTDIR'
}
export type SessionFileWalkOptions = {
extensions: Set<string>
filePredicate?: (path: string) => boolean
// Return false to skip descending into a directory; depth 0 is a child of
// rootDir, so pruned subtrees are never stat'd or parsed.
directoryPredicate?: (name: string, depth: number) => boolean
readDirectory?: (dirPath: string) => Promise<Dirent[]>
signal?: AbortSignal
}
/** Collecting form for callers that want every match; bounded scans stream. */
export async function walkSessionFiles(
dirPath: string,
agent: AiVaultAgent,
issues: AiVaultScanIssue[],
options: {
extensions: Set<string>
filePredicate?: (path: string) => boolean
// Return false to skip descending into a directory; depth 0 is a child of
// rootDir, so pruned subtrees are never stat'd or parsed.
directoryPredicate?: (name: string, depth: number) => boolean
readDirectory?: (dirPath: string) => Promise<Dirent[]>
signal?: AbortSignal
},
depth = 0
options: SessionFileWalkOptions
): Promise<string[]> {
const files: string[] = []
await forEachSessionFile(dirPath, agent, issues, options, async (path) => {
files.push(path)
})
return files
}
/** Streams matches to `onFile` so a bounded consumer never retains the whole tree. */
export async function forEachSessionFile(
dirPath: string,
agent: AiVaultAgent,
issues: AiVaultScanIssue[],
options: SessionFileWalkOptions,
onFile: (path: string) => Promise<void>,
depth = 0
): Promise<void> {
options.signal?.throwIfAborted()
let entries
try {
@@ -113,10 +160,9 @@ export async function walkSessionFiles(
if (error instanceof WslTranscriptFsError) {
throw error
}
return []
return
}
const files: string[] = []
for (const entry of entries) {
options.signal?.throwIfAborted()
const fullPath = join(dirPath, entry.name)
@@ -124,7 +170,7 @@ export async function walkSessionFiles(
// Skip whole subtrees an agent never wants (e.g. subagent transcripts),
// avoiding the readdir cost of descending into them.
if (options.directoryPredicate?.(entry.name, depth) ?? true) {
files.push(...(await walkSessionFiles(fullPath, agent, issues, options, depth + 1)))
await forEachSessionFile(fullPath, agent, issues, options, onFile, depth + 1)
}
continue
}
@@ -133,8 +179,7 @@ export async function walkSessionFiles(
options.extensions.has(extname(entry.name).toLowerCase()) &&
(options.filePredicate?.(fullPath) ?? true)
) {
files.push(fullPath)
await onFile(fullPath)
}
}
return files
}
@@ -8,6 +8,7 @@ import type {
ResumableSessionParseState,
SessionAccumulator
} from './session-scanner-types'
import type { TranscriptMessageSink } from './session-transcript-consumers'
import {
accumulatorFoldResumeState,
addPreviewMessage,
@@ -32,12 +33,13 @@ type ParserSessionOptions = {
export async function parseDroidSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
platform: NodeJS.Platform = process.platform,
messages?: TranscriptMessageSink
): Promise<AiVaultSession | null> {
const input = openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan')
const lines = createInterface({ input, crlfDelay: Infinity })
try {
return await parseDroidSessionLines({ file, lines, platform })
return await parseDroidSessionLines({ file, lines, platform, messages })
} finally {
// readline.close() leaves the underlying stream open; destroy it so a
// mid-parse throw cannot leak the gated transcript handle.
@@ -94,9 +96,17 @@ function consumeDroidRecordLine(accumulator: SessionAccumulator, line: string):
}
}
export function createDroidSessionResumeState(file: FileWithMtime): ResumableSessionParseState {
export function createDroidSessionResumeState(
file: FileWithMtime,
messages?: TranscriptMessageSink
): ResumableSessionParseState {
return accumulatorFoldResumeState(
createAccumulator({ agent: 'droid', file, sessionId: sessionIdFromFileName(file.path) }),
createAccumulator({
agent: 'droid',
file,
sessionId: sessionIdFromFileName(file.path),
messages
}),
consumeDroidRecordLine
)
}
@@ -106,8 +116,9 @@ async function parseDroidSessionLines(args: {
lines: AsyncIterable<string> | Iterable<string>
platform: NodeJS.Platform
options?: ParserSessionOptions
messages?: TranscriptMessageSink
}): Promise<AiVaultSession | null> {
const state = createDroidSessionResumeState(args.file)
const state = createDroidSessionResumeState(args.file, args.messages)
for await (const line of args.lines) {
state.consumeLine(line)
}
@@ -8,6 +8,7 @@ import type {
ResumableSessionParseState,
SessionAccumulator
} from './session-scanner-types'
import type { TranscriptMessageSink } from './session-transcript-consumers'
import {
accumulatorFoldResumeState,
addPreviewContent,
@@ -27,16 +28,19 @@ import {
export async function parseGeminiSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
platform: NodeJS.Platform = process.platform,
messages?: TranscriptMessageSink
): Promise<AiVaultSession | null> {
if (file.path.endsWith('.jsonl')) {
return parseGeminiJsonlSessionFile(file, platform)
return parseGeminiJsonlSessionFile(file, platform, messages)
}
return parseGeminiJsonSessionContent(
file,
await wslGatedReadFile(file.path, 'utf-8', 'scan'),
platform
platform,
{},
messages
)
}
@@ -62,7 +66,8 @@ function parseGeminiJsonSessionContent(
file: FileWithMtime,
content: string,
platform: NodeJS.Platform,
options: ResumableParseFinalizeOptions = {}
options: ResumableParseFinalizeOptions = {},
messages?: TranscriptMessageSink
): AiVaultSession | null {
const record = asRecord(JSON.parse(content) as unknown)
if (!record) {
@@ -71,7 +76,8 @@ function parseGeminiJsonSessionContent(
const accumulator = createAccumulator({
agent: 'gemini',
file,
sessionId: extractString(record.sessionId) ?? sessionIdFromFileName(file.path)
sessionId: extractString(record.sessionId) ?? sessionIdFromFileName(file.path),
messages
})
updateTimeline(accumulator, extractString(record.startTime))
updateTimeline(accumulator, extractString(record.lastUpdated))
@@ -83,13 +89,14 @@ function parseGeminiJsonSessionContent(
export async function parseGeminiJsonlSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform
platform: NodeJS.Platform,
messages?: TranscriptMessageSink
): Promise<AiVaultSession | null> {
const lines = createInterface({
input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'),
crlfDelay: Infinity
})
return parseGeminiJsonlSessionLines({ file, lines, platform })
return parseGeminiJsonlSessionLines({ file, lines, platform, messages })
}
function consumeGeminiJsonlRecordLine(accumulator: SessionAccumulator, line: string): void {
@@ -114,10 +121,16 @@ function consumeGeminiJsonlRecordLine(accumulator: SessionAccumulator, line: str
// Resumable only for the JSONL log format; Gemini's legacy single-JSON
// session documents are rewritten in place and must be re-read whole.
export function createGeminiJsonlSessionResumeState(
file: FileWithMtime
file: FileWithMtime,
messages?: TranscriptMessageSink
): ResumableSessionParseState {
return accumulatorFoldResumeState(
createAccumulator({ agent: 'gemini', file, sessionId: sessionIdFromFileName(file.path) }),
createAccumulator({
agent: 'gemini',
file,
sessionId: sessionIdFromFileName(file.path),
messages
}),
consumeGeminiJsonlRecordLine
)
}
@@ -127,8 +140,9 @@ async function parseGeminiJsonlSessionLines(args: {
lines: AsyncIterable<string> | Iterable<string>
platform: NodeJS.Platform
options?: ResumableParseFinalizeOptions
messages?: TranscriptMessageSink
}): Promise<AiVaultSession | null> {
const state = createGeminiJsonlSessionResumeState(args.file)
const state = createGeminiJsonlSessionResumeState(args.file, args.messages)
for await (const line of args.lines) {
state.consumeLine(line)
}
@@ -10,6 +10,7 @@ import type {
ResumableSessionParseState,
SessionAccumulator
} from './session-scanner-types'
import type { TranscriptMessageSink } from './session-transcript-consumers'
import {
accumulatorFoldResumeState,
addPreviewContent,
@@ -38,7 +39,8 @@ type ParserSessionOptions = {
export async function parseRovoSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
platform: NodeJS.Platform = process.platform,
messages?: TranscriptMessageSink
): Promise<AiVaultSession | null> {
const metadata = asRecord(
JSON.parse(await wslGatedReadFile(file.path, 'utf-8', 'scan')) as unknown
@@ -49,7 +51,8 @@ export async function parseRovoSessionFile(
const accumulator = createAccumulator({
agent: 'rovo',
file,
sessionId: basename(dirname(file.path))
sessionId: basename(dirname(file.path)),
messages
})
accumulator.title = firstString(metadata, ['title', 'name', 'summary'])
accumulator.cwd = firstString(metadata, [
@@ -174,12 +177,13 @@ export type MessageGraphAgent = 'openclaw' | 'pi' | 'omp' | 'prime-agent'
export async function parseMessageGraphSessionFile(
agent: MessageGraphAgent,
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
platform: NodeJS.Platform = process.platform,
messages?: TranscriptMessageSink
): Promise<AiVaultSession | null> {
const input = openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan')
const lines = createInterface({ input, crlfDelay: Infinity })
try {
return await parseMessageGraphSessionLines({ agent, file, lines, platform })
return await parseMessageGraphSessionLines({ agent, file, lines, platform, messages })
} finally {
// readline.close() leaves the underlying stream open; destroy it so a
// mid-parse throw cannot leak the gated transcript handle.
@@ -245,10 +249,11 @@ function consumeMessageGraphRecordLine(accumulator: SessionAccumulator, line: st
export function createMessageGraphSessionResumeState(
agent: MessageGraphAgent,
file: FileWithMtime
file: FileWithMtime,
messages?: TranscriptMessageSink
): ResumableSessionParseState {
const state = accumulatorFoldResumeState(
createAccumulator({ agent, file, sessionId: sessionIdFromFileName(file.path) }),
createAccumulator({ agent, file, sessionId: sessionIdFromFileName(file.path), messages }),
consumeMessageGraphRecordLine
)
// Why: only OMP materializes task-subagent transcripts beside its sessions
@@ -263,8 +268,9 @@ async function parseMessageGraphSessionLines(args: {
lines: AsyncIterable<string> | Iterable<string>
platform: NodeJS.Platform
options?: ParserSessionOptions
messages?: TranscriptMessageSink
}): Promise<AiVaultSession | null> {
const state = createMessageGraphSessionResumeState(args.agent, args.file)
const state = createMessageGraphSessionResumeState(args.agent, args.file, args.messages)
for await (const line of args.lines) {
state.consumeLine(line)
}
@@ -4,6 +4,7 @@ import { dirname, join } from 'node:path'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
import type { TranscriptMessageSink } from './session-transcript-consumers'
import {
addPreviewMessage,
createAccumulator,
@@ -34,7 +35,8 @@ const GROK_USER_QUERY_PREVIEW_SCAN_LIMIT = 4096
export async function parseGrokSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
platform: NodeJS.Platform = process.platform,
messages?: TranscriptMessageSink
): Promise<AiVaultSession | null> {
const record = asRecord(JSON.parse(await wslGatedReadFile(file.path, 'utf-8', 'scan')) as unknown)
if (!record) {
@@ -42,7 +44,7 @@ export async function parseGrokSessionFile(
}
const info = asRecord(record.info)
const sessionId = extractString(info?.id) ?? sessionIdFromFileName(dirname(file.path))
const accumulator = createAccumulator({ agent: 'grok', file, sessionId })
const accumulator = createAccumulator({ agent: 'grok', file, sessionId, messages })
accumulator.cwd = extractString(info?.cwd)
accumulator.title =
normalizeTitleText(extractString(record.generated_title) ?? '') ??
@@ -2,6 +2,7 @@ import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { FileWithMtime } from './session-scanner-types'
import type { TranscriptMessageSink } from './session-transcript-consumers'
import {
addPreviewContent,
createAccumulator,
@@ -24,20 +25,34 @@ type ParserSessionOptions = {
export async function parseHermesSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
platform: NodeJS.Platform = process.platform,
messages?: TranscriptMessageSink
): Promise<AiVaultSession | null> {
return parseHermesSessionContent(
return parseHermesSessionRecord(
file,
await wslGatedReadFile(file.path, 'utf-8', 'scan'),
platform
platform,
{},
messages
)
}
/** Remote transcript content, streamed from a host that has no reader attached. */
export async function parseHermesSessionContent(
file: FileWithMtime,
content: string,
platform: NodeJS.Platform = process.platform,
options: ParserSessionOptions = {}
): Promise<AiVaultSession | null> {
return parseHermesSessionRecord(file, content, platform, options)
}
async function parseHermesSessionRecord(
file: FileWithMtime,
content: string,
platform: NodeJS.Platform,
options: ParserSessionOptions,
messages?: TranscriptMessageSink
): Promise<AiVaultSession | null> {
const record = asRecord(JSON.parse(content) as unknown)
if (!record) {
@@ -46,7 +61,8 @@ export async function parseHermesSessionContent(
const accumulator = createAccumulator({
agent: 'hermes',
file,
sessionId: extractString(record.session_id) ?? sessionIdFromFileName(file.path)
sessionId: extractString(record.session_id) ?? sessionIdFromFileName(file.path),
messages
})
accumulator.model = extractString(record.model)
accumulator.cwd = extractString(record.cwd)
@@ -16,6 +16,7 @@ import {
readKimiWorkDirBySessionId
} from './session-scanner-kimi-paths'
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
import type { TranscriptMessageSink } from './session-transcript-consumers'
import {
asRecord,
extractContentText,
@@ -32,7 +33,8 @@ import {
// session_index.jsonl; model/messages/tokens come from the wire transcript.
export async function parseKimiSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
platform: NodeJS.Platform = process.platform,
messages?: TranscriptMessageSink
): Promise<AiVaultSession | null> {
let stateRecord: Record<string, unknown> | null
try {
@@ -53,7 +55,7 @@ export async function parseKimiSessionFile(
}
const sessionId = kimiSessionIdFromStatePath(file.path)
const accumulator = createAccumulator({ agent: 'kimi', file, sessionId })
const accumulator = createAccumulator({ agent: 'kimi', file, sessionId, messages })
// Why: Kimi sessions are work-dir-scoped — the resume command must `cd` into
// the original directory or the CLI rejects it. That path lives only in the
@@ -3,6 +3,7 @@ import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import { join } from 'node:path'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
import type { TranscriptMessageSink } from './session-transcript-consumers'
import {
addPreviewMessage,
createAccumulator,
@@ -25,14 +26,15 @@ import {
export async function parseOpenCodeSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
platform: NodeJS.Platform = process.platform,
messages?: TranscriptMessageSink
): Promise<AiVaultSession | null> {
const record = asRecord(JSON.parse(await wslGatedReadFile(file.path, 'utf-8', 'scan')) as unknown)
if (!record) {
return null
}
const sessionId = extractString(record.id) ?? sessionIdFromFileName(file.path)
const accumulator = createAccumulator({ agent: 'opencode', file, sessionId })
const accumulator = createAccumulator({ agent: 'opencode', file, sessionId, messages })
accumulator.title = normalizeTitleText(extractString(record.title) ?? '')
accumulator.cwd = extractString(record.directory)
updateTimeline(accumulator, timeObjectValue(record.time, 'created'))
@@ -0,0 +1,29 @@
import type SyncDatabase from '../sqlite/sync-database'
import { columnExists, tableExists } from '../opencode-usage/schema-helpers'
// Why: OpenCode's schema has moved more than once, so every read probes for the
// columns it names. These are the two shapes the session parser depends on;
// keeping them here stops each reader from inventing its own partial gate.
/** Enough of `message` to count a session's turns. */
export function canCountOpenCodeMessages(db: SyncDatabase): boolean {
return (
tableExists(db, 'message') &&
columnExists(db, 'message', 'session_id') &&
columnExists(db, 'message', 'data')
)
}
/** Enough of `message`×`part` to read a session's parts in turn order. */
export function canReadOpenCodeMessageParts(db: SyncDatabase): boolean {
return (
canCountOpenCodeMessages(db) &&
columnExists(db, 'message', 'id') &&
// Every parts read orders by it; unprobed, a schema without it throws mid-read.
columnExists(db, 'message', 'time_created') &&
tableExists(db, 'part') &&
columnExists(db, 'part', 'message_id') &&
columnExists(db, 'part', 'time_created') &&
columnExists(db, 'part', 'data')
)
}
@@ -1,4 +1,4 @@
import type { Worker } from 'node:worker_threads'
import { LazyWorkerThreadHost, type WorkerThreadFactory } from '../lazy-worker-thread-host'
import type { AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types'
import type {
OpenCodeSqliteListRequest,
@@ -11,9 +11,10 @@ import type { SessionFileCandidate } from './session-scanner-types'
import { errorMessage } from './session-scanner-values'
// Why (#8864): a lazily-spawned, unref'd worker runs OpenCode SQLite reads off
// the main-process event loop. Lifecycle (idle teardown, FIFO one-at-a-time
// dispatch, per-call timeouts, respawn-on-fault) mirrors src/main/speech/
// stt-service.ts. The default spawn + shared singleton live in
// the main-process event loop. This module owns the request half (FIFO
// one-at-a-time dispatch, per-call timeouts, respawn-on-fault); the thread's
// own lifetime belongs to LazyWorkerThreadHost, shared with the port-scan probe
// client. The default spawn + shared singleton live in
// session-scanner-opencode-sqlite-worker-spawn.ts.
export const LIST_TIMEOUT_MS = 30_000
@@ -25,8 +26,6 @@ export const IDLE_TEARDOWN_MS = 30_000
// fresh scan burst starts from idle (so the cap is per-scan, not process-wide).
export const MAX_CONSECUTIVE_DEATHS = 3
export type WorkerFactory = () => Worker
// Omit<union, 'id'> collapses to the shared keys, so omit each member and let
// the client stamp the correlation id.
type OpenCodeSqliteRequestBody =
@@ -53,20 +52,27 @@ class OpenCodeSqliteWorkerUnavailableError extends Error {}
* no worker can be spawned rather than moving SQLite work onto the main thread.
*/
export class OpenCodeSqliteWorkerClient {
private worker: Worker | null = null
private active: PendingCall | null = null
private queue: PendingCall[] = []
private idleTimer: NodeJS.Timeout | null = null
private consecutiveDeaths = 0
private nextId = 1
private loggedWorkerUnavailable = false
private cleanupWorkerListeners: (() => void) | null = null
private readonly workerFactory: WorkerFactory
private readonly log: (message: string) => void
private readonly host: LazyWorkerThreadHost<OpenCodeSqliteWorkerResponse>
constructor(options: { workerFactory: WorkerFactory; log?: (message: string) => void }) {
this.workerFactory = options.workerFactory
this.log = options.log ?? ((message) => console.warn(message))
constructor(options: { workerFactory: WorkerThreadFactory; log?: (message: string) => void }) {
const log = options.log ?? ((message: string) => console.warn(message))
this.host = new LazyWorkerThreadHost<OpenCodeSqliteWorkerResponse>({
factory: options.workerFactory,
idleTeardownMs: IDLE_TEARDOWN_MS,
onMessage: (response) => this.onMessage(response),
onError: (error) => this.onWorkerFault(error),
onExit: (code) => this.onWorkerExit(code),
isIdle: () => !this.active && this.queue.length === 0,
// Why (#8864): never fall back to synchronous SQLite reads here; a missing
// bundle or resource-exhausted spawn must omit OpenCode history rather than
// reintroduce the main-process hang this worker boundary prevents.
onUnavailable: (err) =>
log(`OpenCode SQLite worker unavailable; skipping its history. ${errorMessage(err)}`)
})
}
/**
@@ -167,7 +173,7 @@ export class OpenCodeSqliteWorkerClient {
if (this.active || this.queue.length === 0) {
return
}
const worker = this.ensureWorker()
const worker = this.host.ensure()
if (!worker) {
this.failQueuedAsUnavailable()
return
@@ -177,7 +183,7 @@ export class OpenCodeSqliteWorkerClient {
return
}
this.active = call
this.clearIdleTimer()
this.host.clearIdleTimer()
// Timeout clock starts at dispatch (not enqueue): a batch may enqueue up to
// 8 parses at once, and a queue-inclusive timeout would fire falsely.
call.timer = setTimeout(() => this.onTimeout(call), call.timeoutMs)
@@ -185,39 +191,6 @@ export class OpenCodeSqliteWorkerClient {
worker.postMessage(call.request)
}
private ensureWorker(): Worker | null {
if (this.worker) {
return this.worker
}
try {
const worker = this.workerFactory()
const onMessage = (response: OpenCodeSqliteWorkerResponse): void => this.onMessage(response)
const onError = (error: Error): void => this.onWorkerFault(error)
const onExit = (code: number): void => this.onWorkerExit(code)
worker.on('message', onMessage)
worker.on('error', onError)
worker.on('exit', onExit)
this.cleanupWorkerListeners = () => {
worker.off('message', onMessage)
worker.off('error', onError)
worker.off('exit', onExit)
}
// Never keep the app alive for a scan worker.
worker.unref?.()
this.worker = worker
return worker
} catch (err) {
// Why (#8864): never fall back to synchronous SQLite reads here; a missing
// bundle or resource-exhausted spawn must omit OpenCode history rather than
// reintroduce the main-process hang this worker boundary prevents.
if (!this.loggedWorkerUnavailable) {
this.loggedWorkerUnavailable = true
this.log(`OpenCode SQLite worker unavailable; skipping its history. ${errorMessage(err)}`)
}
return null
}
}
private onMessage(response: OpenCodeSqliteWorkerResponse): void {
const call = this.active
if (!call || call.request.id !== response.id) {
@@ -243,7 +216,7 @@ export class OpenCodeSqliteWorkerClient {
// A clean self-exit is not a death, but the stale handle must be dropped
// or the next dispatch would post into the dead worker and stall to timeout.
if (code === 0 && !this.active && this.queue.length === 0) {
this.destroyWorker()
this.host.destroy()
return
}
this.onWorkerFault(new Error(`OpenCode SQLite worker exited with code ${code}`))
@@ -251,7 +224,7 @@ export class OpenCodeSqliteWorkerClient {
private onWorkerFault(error: Error): void {
const failed = this.active
this.destroyWorker()
this.host.destroy()
this.consecutiveDeaths++
if (failed) {
this.settle(failed, () => failed.reject(error))
@@ -302,46 +275,7 @@ export class OpenCodeSqliteWorkerClient {
if (this.queue.length > 0) {
this.pump()
} else {
this.scheduleIdleTeardown()
this.host.scheduleIdleTeardown()
}
}
private scheduleIdleTeardown(): void {
this.clearIdleTimer()
if (!this.worker) {
return
}
this.idleTimer = setTimeout(() => this.teardownIfIdle(), IDLE_TEARDOWN_MS)
this.idleTimer.unref?.()
}
private teardownIfIdle(): void {
this.idleTimer = null
// Only tear down with nothing active AND nothing queued: a request arriving
// as the timer fires must never be lost to a self-exiting worker.
if (this.active || this.queue.length > 0) {
return
}
this.destroyWorker()
}
private clearIdleTimer(): void {
if (this.idleTimer) {
clearTimeout(this.idleTimer)
this.idleTimer = null
}
}
private destroyWorker(): void {
this.clearIdleTimer()
const worker = this.worker
this.worker = null
if (!worker) {
return
}
this.cleanupWorkerListeners?.()
this.cleanupWorkerListeners = null
worker.removeAllListeners()
void worker.terminate().catch(() => undefined)
}
}
@@ -10,6 +10,10 @@ import {
shouldCaptureFullFirstUserPrompt
} from './session-scanner-first-user-prompt'
import { readOpenCodeDatabase } from './session-scanner-opencode-sqlite-open'
import {
canCountOpenCodeMessages,
canReadOpenCodeMessageParts
} from './session-scanner-opencode-sqlite-schema'
import { normalizeTitleText } from './session-scanner-values'
import type SyncDatabase from '../sqlite/sync-database'
import { columnExists, tableExists } from '../opencode-usage/schema-helpers'
@@ -70,14 +74,6 @@ function sessionNumberColumnSelect(db: SyncDatabase, columnName: string): string
return columnExists(db, 'session', columnName) ? `s.${columnName}` : '0'
}
function canCountOpenCodeMessages(db: SyncDatabase): boolean {
return (
tableExists(db, 'message') &&
columnExists(db, 'message', 'session_id') &&
columnExists(db, 'message', 'data')
)
}
function buildSessionQuery(db: SyncDatabase): string {
const messageCountSubquery = canCountOpenCodeMessages(db)
? `(SELECT COUNT(*) FROM message m
@@ -154,14 +150,7 @@ function extractPartText(partData: string): string | null {
}
function readFirstUserPromptFromOpenCodeDb(db: SyncDatabase, sessionId: string): string | null {
if (
!canCountOpenCodeMessages(db) ||
!tableExists(db, 'part') ||
!columnExists(db, 'message', 'id') ||
!columnExists(db, 'part', 'message_id') ||
!columnExists(db, 'part', 'time_created') ||
!columnExists(db, 'part', 'data')
) {
if (!canReadOpenCodeMessageParts(db)) {
return null
}
@@ -206,14 +195,7 @@ function readFirstUserPromptFromOpenCodeDb(db: SyncDatabase, sessionId: string):
}
function buildPreviewQuery(db: SyncDatabase): string | null {
if (
!canCountOpenCodeMessages(db) ||
!tableExists(db, 'part') ||
!columnExists(db, 'message', 'id') ||
!columnExists(db, 'part', 'message_id') ||
!columnExists(db, 'part', 'time_created') ||
!columnExists(db, 'part', 'data')
) {
if (!canReadOpenCodeMessageParts(db)) {
return null
}
return `SELECT json_extract(m.data, '$.role') AS role,
@@ -205,6 +205,61 @@ describe('codex-specific resume behavior', () => {
})
describe('non-resumable formats keep reuse-only caching', () => {
it('re-parses cline when only its messages sidecar changed', async () => {
const root = await makeTempDir()
const sessionDir = join(root, 'cline-1')
await mkdir(sessionDir, { recursive: true })
const metadataPath = join(sessionDir, 'cline-1.json')
const messagesPath = join(sessionDir, 'cline-1.messages.json')
await writeFile(
metadataPath,
JSON.stringify({
session_id: 'cline-1',
cwd: '/tmp/cline',
started_at: '2026-05-01T10:00:00Z'
})
)
const writeMessages = (text: string): Promise<void> =>
writeFile(
messagesPath,
JSON.stringify({
updated_at: '2026-05-01T10:00:01Z',
messages: [{ role: 'user', content: [{ type: 'text', text }] }]
})
)
await writeMessages('first ask')
// Cline reads the sidecar as part of its parse, so a change to it has to
// re-parse; there is no metadata-only merge to re-run.
const candidate = async (): Promise<SessionFileCandidate> => {
const base = await candidateFor('cline', metadataPath)
const sidecarStat = await stat(messagesPath)
return {
...base,
file: {
...base.file,
sidecar: {
path: messagesPath,
mtimeMs: sidecarStat.mtimeMs,
sizeBytes: sidecarStat.size
}
}
}
}
const stats = createSessionParseStats()
const seeded = await parseAgentSessionFileCached(await candidate(), process.platform, stats)
expect(seeded?.title).toBe('first ask')
await parseAgentSessionFileCached(await candidate(), process.platform, stats)
expect(stats).toMatchObject({ fullParses: 1, reused: 1 })
await writeMessages('second ask, rather longer than the first')
const rewritten = await parseAgentSessionFileCached(await candidate(), process.platform, stats)
expect(rewritten?.title).toBe('second ask, rather longer than the first')
expect(stats).toMatchObject({ fullParses: 2, reused: 1 })
})
it('re-parses a changed grok summary fully and reuses it when unchanged', async () => {
const root = await makeTempDir()
const sessionDir = join(root, 'session-1')
+124 -228
View File
@@ -1,7 +1,6 @@
import { readTranscriptSlice } from '../native-chat/wsl-transcript-fs-access'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import { inSessionParseFileLane } from './session-parse-file-lane'
import { createAntigravitySessionResumeState } from './session-scanner-antigravity-parser'
import { parseAgentSessionFile } from './session-scanner-agent-parser'
import { createCodexSessionResumeState } from './session-scanner-codex-parser'
import { createDroidSessionResumeState } from './session-scanner-droid-parser'
import { createMessageGraphSessionResumeState } from './session-scanner-graph-parsers'
@@ -13,27 +12,30 @@ import { countSubagentTranscripts } from './session-scanner-subagent-transcripts
import { countOmpSubagentTranscripts } from './session-scanner-omp-subagent-transcripts'
import type { ResumableSessionParseState, SessionFileCandidate } from './session-scanner-types'
import { refreshCachedCodexTitle } from './session-scanner-codex-cached-title'
import { consumeCompleteJsonlLines } from './session-scanner-jsonl-reader'
import {
getSessionParseCacheEntry,
storeSessionParseCacheEntry,
type SessionParseCacheEntry
} from './session-parse-cache-store'
import type { TranscriptMessageSink } from './session-transcript-consumers'
import { sidecarUnchanged } from './session-sidecar-stat'
import {
enrichSessionFromSidecar,
sidecarEnrichesWithoutReparse
} from './session-scanner-sidecar-enrichment'
import {
readResumableTranscript,
readWholeTranscript,
type TranscriptReadStats
} from './session-transcript-reader'
// Sized past the default recency cap (1000) plus the in-scope cap (2000) so a
// full steady-state result set stays resident between forced rescans.
const MAX_CACHE_ENTRIES = 4096
const NEWLINE_BYTE = 0x0a
type ResumePoint = {
state: ResumableSessionParseState
// Byte offset just past the last complete ('\n'-terminated) line consumed;
// a trailing unterminated line is deliberately left before this point.
byteOffset: number
}
type SessionParseCacheEntry = {
mtimeMs: number
sizeBytes: number | null
platform: NodeJS.Platform
session: AiVaultSession | null
resume: ResumePoint | null
}
export {
invalidateSessionParseCacheEntry,
resetSessionParseCacheForTests,
seedSessionParseCache,
snapshotSessionParseCacheForPersistence,
type PersistedSessionParseCacheEntry
} from './session-parse-cache-store'
// Incremental append-parsing applies only to transcripts that are append-only
// JSONL line-folds. Whole-JSON documents (grok/rovo/devin/hermes/gemini-json)
@@ -44,31 +46,32 @@ type SessionParseCacheEntry = {
// cached state instead, never pay for a throwaway accumulator.
function resumableStateFactoryFor(
candidate: SessionFileCandidate
): (() => ResumableSessionParseState) | null {
): ((messages: TranscriptMessageSink) => ResumableSessionParseState) | null {
switch (candidate.agent) {
case 'claude':
return () => createClaudeSessionResumeState(candidate.file)
return (messages) => createClaudeSessionResumeState(candidate.file, messages)
case 'codex':
return () => createCodexSessionResumeState(candidate.file, candidate.codexHome)
return (messages) =>
createCodexSessionResumeState(candidate.file, candidate.codexHome, messages)
case 'cursor':
return () => createCursorSessionResumeState(candidate.file)
return (messages) => createCursorSessionResumeState(candidate.file, messages)
case 'copilot':
return () => createCopilotSessionResumeState(candidate.file)
return (messages) => createCopilotSessionResumeState(candidate.file, messages)
case 'droid':
return () => createDroidSessionResumeState(candidate.file)
return (messages) => createDroidSessionResumeState(candidate.file, messages)
case 'openclaw':
case 'pi':
case 'omp':
case 'prime-agent': {
const agent = candidate.agent
return () => createMessageGraphSessionResumeState(agent, candidate.file)
return (messages) => createMessageGraphSessionResumeState(agent, candidate.file, messages)
}
case 'gemini':
return candidate.file.path.endsWith('.jsonl')
? () => createGeminiJsonlSessionResumeState(candidate.file)
? (messages) => createGeminiJsonlSessionResumeState(candidate.file, messages)
: null
case 'antigravity':
return () => createAntigravitySessionResumeState(candidate.file)
return (messages) => createAntigravitySessionResumeState(candidate.file, messages)
case 'devin':
case 'grok':
case 'hermes':
@@ -80,245 +83,138 @@ function resumableStateFactoryFor(
}
}
export type SessionParseStats = {
export type SessionParseStats = TranscriptReadStats & {
reused: number
incremental: number
fullParses: number
// Transcripts the parser already excluded (Codex workers), re-listed after a
// write and dismissed without reading. Counted apart from `incremental` so a
// scan span still shows how much work the early stop actually removed.
earlyStopped: number
bytesRead: number
}
export function createSessionParseStats(): SessionParseStats {
return { reused: 0, incremental: 0, fullParses: 0, earlyStopped: 0, bytesRead: 0 }
}
const cache = new Map<string, SessionParseCacheEntry>()
export function resetSessionParseCacheForTests(): void {
cache.clear()
}
// Drops one entry after its file is deleted. Cleanliness, not correctness:
// discovery walks disk first, so a trashed file is never rediscovered anyway.
export function invalidateSessionParseCacheEntry(path: string): void {
cache.delete(path)
}
// Persisted subset of a cache entry: the non-serializable `resume` parser
// state is dropped (see session-parse-cache-persistence.ts).
export type PersistedSessionParseCacheEntry = Omit<SessionParseCacheEntry, 'resume'>
export function snapshotSessionParseCacheForPersistence(): [
string,
PersistedSessionParseCacheEntry
][] {
return [...cache].map(([path, entry]): [string, PersistedSessionParseCacheEntry] => [
path,
{
mtimeMs: entry.mtimeMs,
sizeBytes: entry.sizeBytes,
platform: entry.platform,
session: entry.session
}
])
}
// Seeded entries carry `resume: null`: after a restart an unchanged file is a
// cache hit; a file that changed while the app was closed pays one full
// (not incremental) re-parse.
export function seedSessionParseCache(
entries: Iterable<[string, PersistedSessionParseCacheEntry]>
): void {
const list = [...entries]
// Snapshot order is oldest→newest (LRU); an over-cap list keeps the newest
// tail rather than seeding the oldest entries and dropping the tail.
for (const [path, entry] of list.slice(Math.max(0, list.length - MAX_CACHE_ENTRIES))) {
if (cache.size >= MAX_CACHE_ENTRIES) {
return
}
// In-process entries are always fresher than persisted ones; never clobber.
if (cache.has(path)) {
continue
}
cache.set(path, {
mtimeMs: entry.mtimeMs,
sizeBytes: entry.sizeBytes,
platform: entry.platform,
session: entry.session,
resume: null
})
}
}
function storeEntry(path: string, entry: SessionParseCacheEntry): void {
cache.delete(path)
cache.set(path, entry)
if (cache.size > MAX_CACHE_ENTRIES) {
const oldest = cache.keys().next()
if (!oldest.done) {
cache.delete(oldest.value)
}
}
}
/**
* Parse a session file, reusing prior work where the file is provably
* unchanged (mtime+size) and, for append-only JSONL transcripts (Claude,
* Codex, Cursor, Copilot, Droid, OpenClaw/Pi/OMP, Gemini-JSONL), resuming the
* parse from the last consumed byte when the file only grew. This is what
* keeps the renderer's ~5s forced rescans from re-reading gigabytes of
* transcripts (STA-1278/STA-1417: main process pegging one core during
* multi-agent workloads).
* The session list's cursor over the transcript reader: it remembers what each
* file looked like when it was last listed, reuses that work where the file is
* provably unchanged (mtime+size), and otherwise asks the reader to resume from
* the last consumed byte or re-read the file whole. This is what keeps the
* renderer's ~5s forced rescans from re-reading gigabytes of transcripts
* (STA-1278/STA-1417: main process pegging one core during multi-agent
* workloads). Other consumers of the reader keep their own equivalent cursor
* and never consult this one.
*/
export async function parseAgentSessionFileCached(
candidate: SessionFileCandidate,
platform: NodeJS.Platform,
stats?: SessionParseStats
): Promise<AiVaultSession | null> {
const { file } = candidate
const entry = cache.get(file.path)
// The whole lookup-read-store sequence runs in the lane: a concurrent parse of
// the same path shares this entry's resume point and its message channel.
return inSessionParseFileLane(candidate.file.path, () =>
parseCachedInLane(candidate, platform, stats)
)
}
const unchanged =
async function parseCachedInLane(
candidate: SessionFileCandidate,
platform: NodeJS.Platform,
stats?: SessionParseStats
): Promise<AiVaultSession | null> {
const { file } = candidate
const entry = getSessionParseCacheEntry(file.path)
const transcriptUnchanged =
entry !== undefined &&
entry.platform === platform &&
entry.mtimeMs === file.mtimeMs &&
(entry.sizeBytes === null || file.sizeBytes === undefined || entry.sizeBytes === file.sizeBytes)
if (unchanged) {
if (stats) {
stats.reused++
if (transcriptUnchanged) {
if (sidecarUnchanged(entry.sidecar, file.sidecar)) {
return reuseCachedSession(candidate, entry, stats)
}
// A zero-turn transcript usually never changes again, but its sibling
// subagent dir (Claude `<session>/subagents/`, OMP's same-named artifact
// dir) can gain files after the parent's last write (a still-running
// subagent finishing). The mtime+size key can't see that, so refresh the
// cheap directory count on reuse.
if (entry.session && entry.session.messageCount === 0) {
const subagentTranscriptCount =
candidate.agent === 'claude'
? await countSubagentTranscripts(file.path)
: candidate.agent === 'omp'
? await countOmpSubagentTranscripts(file.path)
: null
if (
subagentTranscriptCount !== null &&
subagentTranscriptCount !== entry.session.subagentTranscriptCount
) {
entry.session = { ...entry.session, subagentTranscriptCount }
// Only the sibling moved. For an agent whose sibling just adds metadata,
// re-merge it onto the stored fold result; the transcript is not re-read.
if (sidecarEnrichesWithoutReparse(candidate) && entry.foldSession !== undefined) {
const enriched = await enrichSessionFromSidecar(candidate, entry.foldSession, platform)
entry.session = enriched.session
entry.sidecar = enriched.refused ? 'unknown' : file.sidecar
storeSessionParseCacheEntry(file.path, entry)
if (stats) {
stats.reused++
}
return entry.session
}
// Codex titles come from session_index.jsonl, which mtime+size can't see.
// Remote counterpart: remote-session-scanner.ts's reusedCodexTitleRefresh.
if (entry.session && candidate.agent === 'codex') {
entry.session = await refreshCachedCodexTitle(candidate, entry.session)
}
storeEntry(file.path, entry)
return entry.session
}
const stateFactory = resumableStateFactoryFor(candidate)
if (stateFactory) {
const parsed = await parseResumableCandidate({
const read = await readResumableTranscript({
candidate,
platform,
entry,
stats,
stateFactory
resume: entry?.platform === platform ? entry.resume : null,
stateFactory,
stats
})
storeEntry(file.path, parsed)
return parsed.session
const enriched = await enrichSessionFromSidecar(candidate, read.session, platform)
storeSessionParseCacheEntry(file.path, {
mtimeMs: file.mtimeMs,
sizeBytes: file.sizeBytes ?? null,
platform,
session: enriched.session,
// A refused sibling leaves the transcript's own work cached and resumable;
// only the sibling is recorded as unknown, so the next healthy scan
// re-merges it without re-reading the transcript.
sidecar: enriched.refused ? 'unknown' : file.sidecar,
foldSession: read.session,
resume: read.resume
})
return enriched.session
}
if (stats) {
stats.fullParses++
stats.bytesRead += file.sizeBytes ?? 0
}
const session = await parseAgentSessionFile(candidate, platform)
storeEntry(file.path, {
const session = await readWholeTranscript({ candidate, platform, stats })
storeSessionParseCacheEntry(file.path, {
mtimeMs: file.mtimeMs,
sizeBytes: file.sizeBytes ?? null,
platform,
session,
// A whole-file parse reads the sibling itself, so a change to it re-parses.
sidecar: file.sidecar,
foldSession: session,
resume: null
})
return session
}
async function parseResumableCandidate(args: {
candidate: SessionFileCandidate
platform: NodeJS.Platform
entry: SessionParseCacheEntry | undefined
async function reuseCachedSession(
candidate: SessionFileCandidate,
entry: SessionParseCacheEntry,
stats?: SessionParseStats
stateFactory: () => ResumableSessionParseState
}): Promise<SessionParseCacheEntry> {
const { file } = args.candidate
const resume = args.entry?.platform === args.platform ? args.entry.resume : null
const canResume =
resume !== null &&
resume !== undefined &&
typeof file.sizeBytes === 'number' &&
file.sizeBytes >= resume.byteOffset &&
(resume.byteOffset === 0 || (await endsWithNewlineAt(file.path, resume.byteOffset)))
// Clone before consuming: a failed read must not corrupt the cached state,
// or the next resume would double-count the lines applied before the error.
const state = canResume ? resume.state.clone() : args.stateFactory()
const startOffset = canResume ? resume.byteOffset : 0
// Mirrors the reader's entry guard so a dismissed transcript is not reported
// as an incremental parse that read nothing.
const stoppedBeforeRead = state.shouldStop?.() === true
if (args.stats) {
if (stoppedBeforeRead) {
args.stats.earlyStopped++
} else if (canResume) {
args.stats.incremental++
} else {
args.stats.fullParses++
): Promise<AiVaultSession | null> {
if (stats) {
stats.reused++
}
// A zero-turn transcript usually never changes again, but its sibling
// subagent dir (Claude `<session>/subagents/`, OMP's same-named artifact
// dir) can gain files after the parent's last write (a still-running
// subagent finishing). The mtime+size key can't see that, so refresh the
// cheap directory count on reuse.
if (entry.session && entry.session.messageCount === 0) {
const subagentTranscriptCount =
candidate.agent === 'claude'
? await countSubagentTranscripts(candidate.file.path)
: candidate.agent === 'omp'
? await countOmpSubagentTranscripts(candidate.file.path)
: null
if (
subagentTranscriptCount !== null &&
subagentTranscriptCount !== entry.session.subagentTranscriptCount
) {
entry.session = { ...entry.session, subagentTranscriptCount }
}
}
const readResult = await consumeCompleteJsonlLines({
path: file.path,
start: startOffset,
onLine: (line) => state.consumeLine(line),
// Bound: the optional hooks are declared as methods, so a parser written
// with method syntax must not lose `this` on the way into the reader.
onLineBytes: state.consumeLineBytes?.bind(state),
shouldStop: state.shouldStop?.bind(state)
})
if (args.stats) {
args.stats.bytesRead += readResult.bytesRead
}
// The stat this scan displays is current even when nothing new was consumed.
state.touchFile(file)
// Keep parity with the one-shot parser: a final unterminated line is shown,
// but stays out of the resumable state so the (possibly still-growing) line
// is re-read once complete instead of being half-counted.
let displayState = state
if (readResult.trailingPartialLine !== null) {
displayState = state.clone()
displayState.consumeLine(readResult.trailingPartialLine)
}
return {
mtimeMs: file.mtimeMs,
sizeBytes: file.sizeBytes ?? null,
platform: args.platform,
session: await displayState.finalize(args.platform),
resume: { state, byteOffset: readResult.consumedThrough }
// Codex titles come from session_index.jsonl, which mtime+size can't see.
// Remote counterpart: remote-session-scanner.ts's reusedCodexTitleRefresh.
if (entry.session && candidate.agent === 'codex') {
entry.session = await refreshCachedCodexTitle(candidate, entry.session)
}
}
// A resume point is only valid if it still sits just past a line break;
// anything else means the file was rewritten, not appended. Heuristic: a
// grown rewrite keeping '\n' at exactly this byte would slip through, but
// agent transcripts are append-only so that trade is accepted (worst case is
// a stale vault row until the file is next truncated or the app restarts).
async function endsWithNewlineAt(path: string, offset: number): Promise<boolean> {
const slice = await readTranscriptSlice(path, offset - 1, 1, 'scan')
return slice.length === 1 && slice[0] === NEWLINE_BYTE
storeSessionParseCacheEntry(candidate.file.path, entry)
return entry.session
}
@@ -10,6 +10,7 @@ import type {
ResumableSessionParseState,
SessionAccumulator
} from './session-scanner-types'
import type { TranscriptMessageSink } from './session-transcript-consumers'
import {
addPreviewContent,
createAccumulator,
@@ -42,12 +43,16 @@ export type ClaudeSessionParseState = {
firstUserTitle: string | null
}
export function createClaudeSessionParseState(file: FileWithMtime): ClaudeSessionParseState {
export function createClaudeSessionParseState(
file: FileWithMtime,
messages?: TranscriptMessageSink
): ClaudeSessionParseState {
return {
accumulator: createAccumulator({
agent: 'claude',
file,
sessionId: sessionIdFromFileName(file.path)
sessionId: sessionIdFromFileName(file.path),
messages
}),
metaTitle: null,
generatedTitle: null,
@@ -188,8 +193,11 @@ export async function finalizeClaudeSessionParseState(
return finalizeSession(snapshot.accumulator, platform, options)
}
export function createClaudeSessionResumeState(file: FileWithMtime): ResumableSessionParseState {
return claudeResumeStateFromParseState(createClaudeSessionParseState(file))
export function createClaudeSessionResumeState(
file: FileWithMtime,
messages?: TranscriptMessageSink
): ResumableSessionParseState {
return claudeResumeStateFromParseState(createClaudeSessionParseState(file, messages))
}
function claudeResumeStateFromParseState(
@@ -207,13 +215,14 @@ function claudeResumeStateFromParseState(
export async function parseClaudeSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
platform: NodeJS.Platform = process.platform,
messages?: TranscriptMessageSink
): Promise<AiVaultSession | null> {
const lines = createInterface({
input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'),
crlfDelay: Infinity
})
return parseClaudeSessionLines({ file, lines, platform })
return parseClaudeSessionLines({ file, lines, platform, messages })
}
export async function parseClaudeSessionContent(
@@ -236,8 +245,9 @@ async function parseClaudeSessionLines(args: {
lines: AsyncIterable<string> | Iterable<string>
platform: NodeJS.Platform
options?: ParserSessionOptions
messages?: TranscriptMessageSink
}): Promise<AiVaultSession | null> {
const state = createClaudeSessionParseState(args.file)
const state = createClaudeSessionParseState(args.file, args.messages)
for await (const line of args.lines) {
consumeClaudeSessionLine(state, line)
}
@@ -0,0 +1,79 @@
import type { AiVaultSession } from '../../shared/ai-vault-types'
import { buildAiVaultResumeCommand } from '../../shared/ai-vault-resume-command'
import { generatedSessionTitle } from './session-scanner-accumulator'
import { readCursorChatMeta, wasCursorChatMetaRefused } from './session-scanner-cursor-chat-meta'
import type { SessionFileCandidate } from './session-scanner-types'
/**
* Merges an agent's sibling file onto the session its transcript alone
* produced. Kept out of the fold and applied here so it is a pure function of
* (fold result, sibling): re-running it starts from what the transcript said,
* never from a previous merge, so a rewritten sibling replaces the fields it
* supplied last time instead of losing to them.
*
* The reuse-time counterpart of session-scanner-codex-cached-title.ts, for
* agents whose sibling the transcript key cannot see.
*/
export type SidecarEnrichment = {
session: AiVaultSession | null
/** The sibling could not be read; the caller records the observation as unknown. */
refused: boolean
}
/** True when the sibling only adds metadata, so a change to it needs no re-parse. */
export function sidecarEnrichesWithoutReparse(candidate: SessionFileCandidate): boolean {
return candidate.agent === 'cursor'
}
export async function enrichSessionFromSidecar(
candidate: SessionFileCandidate,
foldSession: AiVaultSession | null,
platform: NodeJS.Platform
): Promise<SidecarEnrichment> {
if (candidate.agent !== 'cursor' || !foldSession) {
return { session: foldSession, refused: false }
}
const meta = await readCursorChatMeta(candidate.file.path)
if (!meta) {
return { session: foldSession, refused: wasCursorChatMetaRefused(candidate.file.path) }
}
return { session: mergeCursorChatMeta(foldSession, meta, platform), refused: false }
}
/** Fills only what the transcript never recorded; its own records always win. */
export function mergeCursorChatMeta(
session: AiVaultSession,
meta: {
title: string | null
cwd: string | null
createdAt: string | null
updatedAt: string | null
},
platform: NodeJS.Platform
): AiVaultSession {
// A generated title means the fold found none, so the sibling's may stand in.
const named = session.title !== generatedSessionTitle(session.agent, session.sessionId)
const cwd = session.cwd ?? meta.cwd
const merged: AiVaultSession = {
...session,
title: named ? session.title : (meta.title ?? session.title),
cwd,
createdAt: session.createdAt ?? meta.createdAt,
updatedAt: session.updatedAt ?? meta.updatedAt
}
if (cwd === session.cwd) {
return merged
}
// The resume command embeds the cwd, so it has to be rebuilt with it.
return {
...merged,
resumeCommand: buildAiVaultResumeCommand({
agent: merged.agent,
sessionId: merged.sessionId,
resumeFilePath: merged.filePath,
cwd,
platform
})
}
}
@@ -17,6 +17,8 @@ export async function discoverAiVaultSessionSources(args: {
const { options, limitPerAgent, issues } = args
const wslHomeDirs = normalizedWslHomeDirs(options.wslHomeDirs)
// The Cursor chat-meta scan scope is owned by scanAiVaultSessions: it has to
// span parse as well, and finalize runs after this returns.
return Promise.all([
// Why: OpenCode 1.17.x migrated sessions from per-session JSON files to a
// SQLite DB. discoverOpenCodeSessions runs both the file scanner (legacy)
+10 -1
View File
@@ -5,6 +5,8 @@ import type {
AiVaultSessionPreviewMessage
} from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { TranscriptMessageSink } from './session-transcript-consumers'
import type { SessionSidecarObservation } from './session-sidecar-stat'
export type AiVaultScanOptions = {
claudeProjectsDir?: string
@@ -54,8 +56,12 @@ export type FileWithMtime = {
modifiedAt: string
// Present when discovery statted the file; lets the parse cache detect
// unchanged/truncated files without a second stat. Synthetic candidates
// such as OpenCode SQLite rows omit it.
// such as OpenCode SQLite rows omit it. The transcript's own length: a byte
// offset into it may be compared against this directly.
sizeBytes?: number
// What discovery saw of the agent's sibling file, tracked apart from the
// transcript's own stat (see session-sidecar-stat.ts).
sidecar?: SessionSidecarObservation
// Present when discovery can prove filesystem identity. Codex dual-root
// scans use a multi-link inode to collapse only actual hardlink aliases.
dev?: number
@@ -108,6 +114,9 @@ export type ResumableSessionParseState = {
export type SessionAccumulator = {
agent: AiVaultAgent
// Every decoded message this fold sees also goes here, for the reader's
// consumers. Shared by clones on purpose: one read, one message stream.
messages: TranscriptMessageSink
sessionId: string
title: string | null
fallbackTitle: string | null
+82 -106
View File
@@ -6,19 +6,13 @@ import type {
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host'
import { withSpan } from '../observability/tracer'
import { sessionSortTime } from './session-scanner-accumulator'
import {
codexRolloutHardlinkIdentity,
dedupeCodexRolloutAliases,
dedupeCodexSessionsBySessionId
} from './codex-session-root-dedup'
import { readCodexRolloutSessionMetaId } from '../codex/codex-rollout-session-meta'
import { dedupeCodexSessionsBySessionId } from './codex-session-root-dedup'
import {
createAntigravityWorkspaceResolver,
readLocalAntigravityHistory,
type AntigravityWorkspaceResolver
} from './session-scanner-antigravity-history'
import { antigravityHistoryPathForBrainDir } from './session-scanner-antigravity-paths'
import { codexHomeForSessionsDir } from './session-scanner-codex-paths'
import { sessionCandidatesFromDiscoveries } from './session-scanner-candidates'
import {
ensureSessionParseCacheLoaded,
scheduleSessionParseCachePersist
@@ -30,10 +24,8 @@ import {
} from './session-scanner-parse-cache'
import { recordSessionScanIssue } from './session-scan-issues'
import { discoverInScopeClaudeFiles } from './session-scanner-scope-discovery'
import {
DEFAULT_CODEX_HOME_DIR,
discoverAiVaultSessionSources
} from './session-scanner-source-discovery'
import { discoverAiVaultSessionSources } from './session-scanner-source-discovery'
import { cursorChatMetaRefusals, withCursorChatMetaScan } from './session-scanner-cursor-chat-meta'
import type {
AiVaultScanOptions,
SessionFileCandidate,
@@ -62,103 +54,87 @@ export async function scanAiVaultSessions(
// The span makes scan cost visible in the local trace file: STA-1278-style
// "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 = 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[] = []
const parseStats = createSessionParseStats()
const antigravityWorkspaceResolver = createAntigravityWorkspaceResolver(
readLocalAntigravityHistory
)
// 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)
// The Cursor chat-meta scope spans discovery AND parse: its sibling meta.json
// is looked up in both phases, and one scan must read the chats tree once.
return withSpan('aiVault.scan', (span) =>
withCursorChatMetaScan(async () => {
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[] = []
const parseStats = createSessionParseStats()
const antigravityWorkspaceResolver = createAntigravityWorkspaceResolver(
readLocalAntigravityHistory
)
// 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 = await dedupeCodexRolloutAliases(
discoveries
.flatMap((discovery) =>
discovery.files.map((file): SessionFileCandidate => ({
agent: discovery.agent,
file,
codexHome:
discovery.agent === 'codex'
? codexHomeForSessionsDir(
discovery.rootDir,
options.defaultCodexHomeDir ?? DEFAULT_CODEX_HOME_DIR
)
: null,
antigravityHistoryPath:
discovery.agent === 'antigravity'
? antigravityHistoryPathForBrainDir(discovery.rootDir)
: undefined
}))
)
.sort((left, right) => right.file.mtimeMs - left.file.mtimeMs),
{
isCodex: (candidate) => candidate.agent === 'codex',
getFilePath: (candidate) => candidate.file.path,
getCodexHome: (candidate) => candidate.codexHome,
getHardlinkIdentity: (candidate) => codexRolloutHardlinkIdentity(candidate.file)
},
(filePath) => readCodexRolloutSessionMetaId(filePath, options.signal, 'scan'),
options.signal
)
const candidates = await sessionCandidatesFromDiscoveries(discoveries, options)
const parsedSessions = await parseSessionCandidates({
candidates: candidates.slice(0, limit * SESSION_PARSE_CANDIDATE_MULTIPLIER),
limit,
platform,
executionHostId,
issues,
parseStats,
signal: options.signal,
antigravityWorkspaceResolver
const parsedSessions = await parseSessionCandidates({
candidates: candidates.slice(0, limit * SESSION_PARSE_CANDIDATE_MULTIPLIER),
limit,
platform,
executionHostId,
issues,
parseStats,
signal: options.signal,
antigravityWorkspaceResolver
})
const cappedSessions = dedupeCodexSessionsBySessionId(parsedSessions)
.sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
.slice(0, limit)
const scopeSessions = await scanInScopeSessions({
discoveries,
scopePaths: options.scopePaths ?? [],
limit,
alreadyParsedFilePaths: new Set(cappedSessions.map((session) => session.filePath)),
platform,
executionHostId,
issues,
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)
for (const refusal of cursorChatMetaRefusals()) {
// One issue per refused chats root, not one per Cursor transcript.
recordSessionScanIssue(issues, {
agent: 'cursor',
path: refusal.chatsRoot,
message: refusal.message
})
}
span.setAttribute('candidates', candidates.length)
span.setAttribute('reused', parseStats.reused)
span.setAttribute('incremental', parseStats.incremental)
span.setAttribute('fullParses', parseStats.fullParses)
span.setAttribute('earlyStopped', parseStats.earlyStopped)
span.setAttribute('bytesRead', parseStats.bytesRead)
span.setAttribute('issues', issues.length)
scheduleSessionParseCachePersist(parseStats)
return {
sessions: mergeSessions(cappedSessions, scopeSessions),
issues: issues.map((issue) => ({ executionHostId, ...issue })),
scannedAt: new Date().toISOString()
}
})
const cappedSessions = dedupeCodexSessionsBySessionId(parsedSessions)
.sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
.slice(0, limit)
const scopeSessions = await scanInScopeSessions({
discoveries,
scopePaths: options.scopePaths ?? [],
limit,
alreadyParsedFilePaths: new Set(cappedSessions.map((session) => session.filePath)),
platform,
executionHostId,
issues,
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)
span.setAttribute('incremental', parseStats.incremental)
span.setAttribute('fullParses', parseStats.fullParses)
span.setAttribute('earlyStopped', parseStats.earlyStopped)
span.setAttribute('bytesRead', parseStats.bytesRead)
span.setAttribute('issues', issues.length)
scheduleSessionParseCachePersist(parseStats)
return {
sessions: mergeSessions(cappedSessions, scopeSessions),
issues: issues.map((issue) => ({ executionHostId, ...issue })),
scannedAt: new Date().toISOString()
}
})
)
}
// In-scope sessions are guaranteed regardless of the recency cap, so the global
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest'
import { sidecarUnchanged, type SessionSidecarObservation } from './session-sidecar-stat'
const META = { path: '/chats/a/meta.json', mtimeMs: 100, sizeBytes: 20 } as const
const OTHER = { path: '/chats/a/meta.json', mtimeMs: 101, sizeBytes: 20 } as const
type Named = [label: string, value: SessionSidecarObservation | undefined]
const ENTRIES: Named[] = [
['undefined', undefined],
["'none'", 'none'],
["'unknown'", 'unknown'],
['object', { ...META }]
]
const OBSERVED: Named[] = [
['undefined', undefined],
["'none'", 'none'],
["'unknown'", 'unknown'],
['same object', { ...META }],
['different object', { ...OTHER }]
]
// entry (row) x observed (column). A `true` cell is a cache hit.
const TRUTH_TABLE: Record<string, Record<string, boolean>> = {
undefined: {
undefined: true,
"'none'": true,
"'unknown'": false,
'same object': false,
'different object': false
},
"'none'": {
undefined: true,
"'none'": true,
"'unknown'": false,
'same object': false,
'different object': false
},
"'unknown'": {
undefined: false,
"'none'": false,
"'unknown'": false,
'same object': false,
'different object': false
},
object: {
undefined: false,
"'none'": false,
"'unknown'": false,
'same object': true,
'different object': false
}
}
describe.each(ENTRIES)('cached %s', (entryLabel, entry) => {
it.each(OBSERVED)(`vs observed %s`, (observedLabel, observed) => {
expect(sidecarUnchanged(entry, observed)).toBe(TRUTH_TABLE[entryLabel][observedLabel])
})
})
it('treats a vanished sidecar as a change, not as "never had one"', () => {
expect(sidecarUnchanged({ ...META }, 'none')).toBe(false)
})
it('never concludes anything from an unreadable sidecar, in either position', () => {
expect(sidecarUnchanged('unknown', 'none')).toBe(false)
expect(sidecarUnchanged('unknown', { ...META })).toBe(false)
expect(sidecarUnchanged({ ...META }, 'unknown')).toBe(false)
})
it('keeps an agent with no sidecar at all a cache hit', () => {
expect(sidecarUnchanged(undefined, undefined)).toBe(true)
expect(sidecarUnchanged(undefined, 'none')).toBe(true)
})
+49
View File
@@ -0,0 +1,49 @@
// Why: some agents keep part of a session beside its transcript — Cursor's
// chat meta.json, Cline's messages file. Folding that file's stat into the
// transcript's own mtime/size makes one key mean two things, so a byte offset
// into the transcript can no longer be compared against it and a refused read
// of the sibling takes the transcript down with it. The sidecar is observed
// separately and compared separately.
export type SessionSidecarStat = {
path: string
mtimeMs: number
sizeBytes: number
}
export type SessionSidecarObservation =
| SessionSidecarStat
/** This agent declares no sidecar, or it does not exist. */
| 'none'
/** It could not be read this scan; nothing may be concluded from its absence. */
| 'unknown'
/**
* Whether a cached observation still describes what discovery just saw.
*
* Asymmetric on purpose: `file` is observed now, so a missing value means the
* agent has no sidecar, while `entry` may predate the field (an entry seeded
* from a cache file an older build wrote), so a missing value means unknown.
*
* `'none'` is a claim, not an absence of one: a sidecar that was there and is
* gone changed, and one that was unreadable last time is still unknown now.
*/
export function sidecarUnchanged(
entry: SessionSidecarObservation | undefined,
file: SessionSidecarObservation | undefined
): boolean {
const observed = file ?? 'none'
if (observed === 'unknown' || entry === 'unknown') {
return false
}
if (observed === 'none') {
// Absent now: a hit only if it was absent before, or the agent never had one.
return entry === undefined || entry === 'none'
}
return (
typeof entry === 'object' &&
entry.path === observed.path &&
entry.mtimeMs === observed.mtimeMs &&
entry.sizeBytes === observed.sizeBytes
)
}
@@ -31,6 +31,9 @@ async function readOneTitle(
if (!stats.isFile() || signal?.aborted) {
return null
}
// Why: this key is a raw lstat with no content dependency, so it only
// matches the scanner's for providers that declare none — today claude and
// codex, which is all this request type carries.
const session = await parseAgentSessionFileCached(
{
agent: request.agent,
@@ -0,0 +1,92 @@
import {
hasTranscriptConsumers,
transcriptConsumers,
type TranscriptMessage,
type TranscriptMessageSink,
type TranscriptReadConsumer,
type TranscriptReadOutcome,
type TranscriptReadStart
} from './session-transcript-consumers'
/**
* The sink a parser pushes into, and the fan-out to every registered consumer.
*
* One channel belongs to one file for as long as its resumable parse state
* lives, because the cached state (and every clone of it) holds this reference.
* A read re-points the channel at that read's consumers instead of replacing it.
*/
export class TranscriptMessageChannel implements TranscriptMessageSink {
private readers: TranscriptReadConsumer[] = []
private muted = false
/** True while a read is open with at least one consumer attached. */
get active(): boolean {
return this.readers.length > 0
}
beginRead(start: TranscriptReadStart): void {
this.muted = false
this.readers = []
// Keeps a scan with no consumers allocation-free on its hottest path.
if (!hasTranscriptConsumers()) {
return
}
for (const consumer of transcriptConsumers()) {
try {
const reader = consumer.beginRead(start)
if (reader) {
this.readers.push(reader)
}
} catch {
// A consumer that cannot open this read simply does not see it.
}
}
}
push(message: TranscriptMessage): void {
if (this.muted || this.readers.length === 0) {
return
}
// A throwing consumer is dropped for the rest of the read rather than
// failing the parse; it then gets no `finish`, so it never records a cursor
// for a stream it did not see in full.
let index = 0
while (index < this.readers.length) {
try {
this.readers[index].message(message)
index++
} catch {
this.readers.splice(index, 1)
}
}
}
/**
* Suppresses emission for a display-only re-read: the trailing unterminated
* line is shown in the list but is re-read once complete, so emitting it here
* would hand every consumer the same line twice. `fn` must be synchronous.
*/
mute<T>(fn: () => T): T {
const previous = this.muted
this.muted = true
try {
return fn()
} finally {
this.muted = previous
}
}
finishRead(outcome: TranscriptReadOutcome): void {
const readers = this.readers
this.readers = []
this.muted = false
for (const reader of readers) {
try {
reader.finish(outcome)
} catch {
// A consumer failure must never fail the session list.
}
}
}
}
@@ -0,0 +1,330 @@
import { appendFile, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
import { scanAiVaultSessions } from './session-scanner'
import {
parseAgentSessionFileCached,
resetSessionParseCacheForTests
} from './session-scanner-parse-cache'
import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures'
import type { FileWithMtime, SessionFileCandidate } from './session-scanner-types'
import { readWholeTranscript } from './session-transcript-reader'
const OPENCODE_SQLITE_SESSION = {
id: 'local:opencode:sqlite-session:db',
agent: 'opencode' as const,
sessionId: 'sqlite-session'
}
// Stands in for the worker thread: the point is that its messages never come
// back over the channel, not what the SQLite read returns.
vi.mock('./session-scanner-opencode-sqlite-worker-spawn', async (importOriginal) => ({
...(await importOriginal<typeof OpenCodeSqliteWorkerSpawn>()),
parseOpenCodeSqliteSessionViaWorker: () => Promise.resolve(OPENCODE_SQLITE_SESSION)
}))
import type * as OpenCodeSqliteWorkerSpawn from './session-scanner-opencode-sqlite-worker-spawn'
import {
registerTranscriptConsumer,
resetTranscriptConsumersForTests,
type TranscriptMessage,
type TranscriptReadOutcome,
type TranscriptReadStart
} from './session-transcript-consumers'
type RecordedRead = {
start: TranscriptReadStart
messages: TranscriptMessage[]
outcome: TranscriptReadOutcome | null
}
function recordingConsumer(): { reads: RecordedRead[]; unregister: () => void } {
const reads: RecordedRead[] = []
const unregister = registerTranscriptConsumer({
beginRead: (start) => {
const read: RecordedRead = { start, messages: [], outcome: null }
reads.push(read)
return {
message: (message) => read.messages.push(message),
finish: (outcome) => {
read.outcome = outcome
}
}
}
})
return { reads, unregister }
}
function textsFor(reads: RecordedRead[], agent: string): string[] {
return reads
.filter((read) => read.start.candidate.agent === agent)
.flatMap((read) => read.messages.map((message) => `${message.role}:${message.text}`))
}
let tempRoots: string[] = []
afterEach(async () => {
resetTranscriptConsumersForTests()
resetSessionParseCacheForTests()
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
tempRoots = []
})
function claudeTurns(from: number, to: number): unknown[] {
const records: unknown[] = []
for (let index = from; index <= to; index++) {
records.push({
type: 'user',
sessionId: 'claude-session',
timestamp: `2026-05-01T10:0${index}:00.000Z`,
cwd: '/tmp/claude',
message: { role: 'user', content: `ask ${index}` }
})
records.push({
type: 'assistant',
sessionId: 'claude-session',
timestamp: `2026-05-01T10:0${index}:01.000Z`,
message: {
role: 'assistant',
content: [
{ type: 'text', text: `reply ${index}` },
{ type: 'tool_use', name: 'Bash', input: { command: `ls ${index}` } }
]
}
})
}
return records
}
async function writeClaudeFixture(): Promise<{
root: string
roots: ReturnType<typeof isolatedScanRoots>
transcript: string
}> {
const root = await mkdtemp(join(tmpdir(), 'orca-transcript-consumers-'))
tempRoots.push(root)
const roots = isolatedScanRoots(root)
const transcript = join(roots.claudeProjectsDir, 'project', 'claude-session.jsonl')
await mkdir(join(roots.claudeProjectsDir, 'project'), { recursive: true })
await writeFile(transcript, `${jsonLines(claudeTurns(1, 4))}\n`)
return { root, roots, transcript }
}
it('delivers one message stream to every registered consumer', async () => {
const { roots } = await writeClaudeFixture()
const first = recordingConsumer()
const second = recordingConsumer()
const result = await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 })
expect(result.issues).toEqual([])
const stream = textsFor(first.reads, 'claude')
expect(stream).toEqual(textsFor(second.reads, 'claude'))
expect(stream).toEqual([
'user:ask 1',
'assistant:reply 1',
'tool:Bash: ls 1',
'user:ask 2',
'assistant:reply 2',
'tool:Bash: ls 2',
'user:ask 3',
'assistant:reply 3',
'tool:Bash: ls 3',
'user:ask 4',
'assistant:reply 4',
'tool:Bash: ls 4'
])
// The list's own fold keeps only the newest five preview turns, so the stream
// is demonstrably the reader's, not a projection of the session row.
const session = result.sessions.find((entry) => entry.agent === 'claude')
expect(session?.previewMessages).toHaveLength(5)
expect(session?.messageCount).toBe(8)
})
it('leaves the session list identical whether or not a consumer is registered', async () => {
const withoutConsumer = await writeClaudeFixture()
const bare = await scanAiVaultSessions({
...withoutConsumer.roots,
platform: 'darwin',
limit: 20
})
resetSessionParseCacheForTests()
recordingConsumer()
const observed = await scanAiVaultSessions({
...withoutConsumer.roots,
platform: 'darwin',
limit: 20
})
expect(observed.sessions).toEqual(bare.sessions)
})
it('replays only the appended lines on a resumed read', async () => {
const { roots, transcript } = await writeClaudeFixture()
const consumer = recordingConsumer()
await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 })
const firstRead = consumer.reads.at(-1)
expect(firstRead?.start.mode).toBe('replace')
expect(firstRead?.start.previousByteOffset).toBe(0)
expect(firstRead?.outcome?.incomplete).toBe(false)
await appendFile(transcript, `${jsonLines(claudeTurns(5, 5))}\n`)
consumer.reads.length = 0
await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 })
const resumed = consumer.reads.find((read) => read.start.candidate.agent === 'claude')
expect(resumed?.start.mode).toBe('append')
expect(resumed?.start.previousByteOffset).toBe(firstRead?.outcome?.byteOffset)
expect(textsFor(consumer.reads, 'claude')).toEqual([
'user:ask 5',
'assistant:reply 5',
'tool:Bash: ls 5'
])
})
it('publishes a trailing unterminated line once, when it is complete', async () => {
const { roots, transcript } = await writeClaudeFixture()
const consumer = recordingConsumer()
await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 })
// A half-written record: the list shows it, the stream must not carry it yet.
const [partial] = claudeTurns(5, 5)
await appendFile(transcript, JSON.stringify(partial))
consumer.reads.length = 0
await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 })
expect(textsFor(consumer.reads, 'claude')).toEqual([])
await appendFile(transcript, '\n')
consumer.reads.length = 0
await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 })
expect(textsFor(consumer.reads, 'claude')).toEqual(['user:ask 5'])
})
it('keeps the session list working when a consumer throws', async () => {
const { roots } = await writeClaudeFixture()
registerTranscriptConsumer({
beginRead: () => ({
message: () => {
throw new Error('consumer exploded')
},
finish: () => undefined
})
})
const healthy = recordingConsumer()
const result = await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 })
expect(result.issues).toEqual([])
expect(result.sessions.find((entry) => entry.agent === 'claude')?.messageCount).toBe(8)
expect(textsFor(healthy.reads, 'claude')).toHaveLength(12)
})
it('skips a read a consumer declines without disturbing the others', async () => {
const { roots } = await writeClaudeFixture()
registerTranscriptConsumer({ beginRead: () => null })
const healthy = recordingConsumer()
await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 })
expect(textsFor(healthy.reads, 'claude')).toHaveLength(12)
})
async function claudeCandidate(transcript: string): Promise<SessionFileCandidate> {
const stats = await stat(transcript)
const file: FileWithMtime = {
path: transcript,
mtimeMs: stats.mtimeMs,
modifiedAt: stats.mtime.toISOString(),
sizeBytes: stats.size
}
return { agent: 'claude', file, codexHome: null }
}
it('serializes overlapping parses of one path so no consumer read is orphaned', async () => {
const { transcript } = await writeClaudeFixture()
// Seed a resume point: the channel it stores is what concurrent reads share.
await parseAgentSessionFileCached(await claudeCandidate(transcript), 'darwin')
await appendFile(transcript, `${jsonLines(claudeTurns(5, 5))}\n`)
const consumer = recordingConsumer()
const appended = await claudeCandidate(transcript)
const [first, second] = await Promise.all([
parseAgentSessionFileCached(appended, 'darwin'),
parseAgentSessionFileCached(appended, 'darwin')
])
// Every read that opened must also close, or its consumer keeps a half-read
// stream forever and never learns the outcome.
expect(consumer.reads.filter((read) => read.outcome === null)).toEqual([])
expect(consumer.reads).toHaveLength(1)
expect(consumer.reads[0].start.mode).toBe('append')
expect(textsFor(consumer.reads, 'claude')).toEqual([
'user:ask 5',
'assistant:reply 5',
'tool:Bash: ls 5'
])
// The later caller reuses the stored entry rather than moving the cursor back.
expect(first?.messageCount).toBe(10)
expect(second?.messageCount).toBe(10)
})
it('reports a read whose parser cannot publish its messages as not complete', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-transcript-opencode-'))
tempRoots.push(root)
const dbPath = join(root, 'opencode.db')
await writeFile(dbPath, '')
const consumer = recordingConsumer()
const session = await readWholeTranscript({
candidate: {
agent: 'opencode',
codexHome: null,
file: {
path: `${dbPath}#sqlite-session`,
mtimeMs: 1,
modifiedAt: new Date(1).toISOString(),
sizeBytes: 10
}
},
platform: 'darwin'
})
expect(session).toEqual(OPENCODE_SQLITE_SESSION)
expect(consumer.reads).toHaveLength(1)
expect(consumer.reads[0].messages).toEqual([])
expect(consumer.reads[0].outcome?.incomplete).toBe(true)
})
it('reports the transcript size, not the cache key, as a whole-file read offset', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-transcript-cline-'))
tempRoots.push(root)
const roots = isolatedScanRoots(root)
// Cline is whole-file and declares a sibling content dependency, so its cache
// key covers two files while the read covers one.
const sessionDir = join(roots.clineSessionsDir, 'cline-session')
await mkdir(sessionDir, { recursive: true })
const metadataPath = join(sessionDir, 'cline-session.json')
await writeFile(
metadataPath,
JSON.stringify({
session_id: 'cline-session',
started_at: '2026-05-01T10:00:00.000Z',
cwd: '/tmp/cline'
})
)
await writeFile(
join(sessionDir, 'cline-session.messages.json'),
JSON.stringify({
updated_at: '2026-05-01T10:00:01.000Z',
messages: [{ role: 'user', content: [{ type: 'text', text: 'x'.repeat(400) }] }]
})
)
const consumer = recordingConsumer()
await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 })
const read = consumer.reads.find((entry) => entry.start.candidate.agent === 'cline')
expect(read?.outcome?.byteOffset).toBe((await stat(metadataPath)).size)
})
@@ -0,0 +1,85 @@
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { SessionFileCandidate } from './session-scanner-types'
// Why: the transcript reader owns discovery, per-file cursors and decoding; a
// consumer only folds the message stream. Registering a second consumer (a
// search index, a digest) must not require touching the reader or the parse
// cache, so the reader publishes reads rather than knowing who reads them.
export type TranscriptMessageRole = 'user' | 'assistant' | 'tool'
export type TranscriptMessage = {
role: TranscriptMessageRole
/** Untruncated decoded text; caps and redaction are consumer policy. */
text: string
timestamp: string | null
}
/** Where a parser hands its decoded messages; the reader supplies the instance. */
export type TranscriptMessageSink = {
/** False when nobody is listening: parsers skip the extraction entirely. */
readonly active: boolean
push(message: TranscriptMessage): void
}
export const NO_TRANSCRIPT_MESSAGES: TranscriptMessageSink = {
active: false,
push: () => undefined
}
export type TranscriptReadStart = {
candidate: SessionFileCandidate
/** `replace`: the whole file is being re-read; `append`: a resumed read. */
mode: 'replace' | 'append'
/** Byte offset the messages of this read continue from. */
previousByteOffset: number
}
export type TranscriptReadOutcome = {
/** Null when the parser rejected the file (an excluded Codex worker transcript). */
session: AiVaultSession | null
/** Byte offset just past the last complete line this read consumed. */
byteOffset: number
/**
* The messages of this read are not the whole span: the read failed part way,
* or the parser decodes where the channel cannot reach it. A consumer must
* not record a cursor for an incomplete read.
*/
incomplete: boolean
}
/** One consumer's view of one file read. */
export type TranscriptReadConsumer = {
message(message: TranscriptMessage): void
finish(outcome: TranscriptReadOutcome): void
}
export type TranscriptConsumer = {
/**
* Open this read, or return null to ignore it. A consumer whose own cursor is
* behind `previousByteOffset` declines here and re-reads on its own schedule;
* it must never ask another consumer where it is.
*/
beginRead(start: TranscriptReadStart): TranscriptReadConsumer | null
}
const consumers = new Set<TranscriptConsumer>()
export function registerTranscriptConsumer(consumer: TranscriptConsumer): () => void {
consumers.add(consumer)
return () => {
consumers.delete(consumer)
}
}
export function transcriptConsumers(): readonly TranscriptConsumer[] {
return [...consumers]
}
export function hasTranscriptConsumers(): boolean {
return consumers.size > 0
}
export function resetTranscriptConsumersForTests(): void {
consumers.clear()
}
@@ -0,0 +1,61 @@
import { expect, it } from 'vitest'
import { transcriptMessagesFromContent } from './session-transcript-message-content'
const AT = '2026-05-01T10:00:00.000Z'
it('keeps a plain string turn under the record role', () => {
expect(transcriptMessagesFromContent('user', 'just words', AT)).toEqual([
{ role: 'user', text: 'just words', timestamp: AT }
])
})
it('drops turns whose role a consumer cannot use', () => {
expect(transcriptMessagesFromContent('system', 'boot', AT)).toEqual([])
expect(transcriptMessagesFromContent('unknown', 'noise', AT)).toEqual([])
})
it('joins text blocks and appends tool blocks as their own messages', () => {
expect(
transcriptMessagesFromContent(
'assistant',
[
{ type: 'text', text: 'first' },
{ type: 'tool_use', name: 'Bash', input: { command: 'ls -la', description: 'ignored' } },
{ type: 'thinking', text: 'second' },
{ type: 'image', source: {} }
],
AT
)
).toEqual([
{ role: 'assistant', text: 'first\nsecond', timestamp: AT },
{ role: 'tool', text: 'Bash: ls -la', timestamp: AT }
])
})
it('reads a tool result carried on a user record as a tool message', () => {
expect(
transcriptMessagesFromContent(
'user',
[{ type: 'tool_result', content: [{ type: 'text', text: 'exit 0' }] }],
AT
)
).toEqual([{ role: 'tool', text: 'exit 0', timestamp: AT }])
})
it('names a tool call even with no recognisable argument', () => {
expect(
transcriptMessagesFromContent('assistant', [{ type: 'tool_use', name: 'Read', input: {} }], AT)
).toEqual([{ role: 'tool', text: 'Read', timestamp: AT }])
})
it('emits nothing for blank or absent content', () => {
expect(transcriptMessagesFromContent('user', ' ', AT)).toEqual([])
expect(transcriptMessagesFromContent('user', null, AT)).toEqual([])
expect(transcriptMessagesFromContent('assistant', [{ type: 'tool_use' }], AT)).toEqual([])
})
it('does not apply the list preview cap', () => {
const long = 'x'.repeat(5000)
const [message] = transcriptMessagesFromContent('user', [{ type: 'text', text: long }], AT)
expect(message.text).toHaveLength(5000)
})
@@ -0,0 +1,138 @@
import { asRecord } from './session-scanner-record-value'
import { sliceAtCodeUnitLimit } from './session-scanner-text-normalization'
import type { AiVaultSessionPreviewMessage } from '../../shared/ai-vault-types'
import type { TranscriptMessage, TranscriptMessageRole } from './session-transcript-consumers'
// Safety bound only: a consumer applies its own caps. Matches the first-prompt
// copy path's ceiling so one pathological paste cannot dominate a scan.
const TRANSCRIPT_MESSAGE_TEXT_LIMIT = 256 * 1024
const TOOL_ARGUMENT_SCAN_LIMIT = 2000
const TEXT_BLOCK_TYPES = new Set(['text', 'input_text', 'output_text', 'thinking', 'reasoning'])
// The argument that identifies what a tool call actually did.
const TOOL_INPUT_KEYS = ['command', 'cmd', 'file_path', 'path', 'pattern', 'query', 'description']
type PreviewRole = AiVaultSessionPreviewMessage['role']
/** Only conversational roles reach consumers; system/unknown turns are noise. */
export function transcriptMessageRole(role: PreviewRole): TranscriptMessageRole | null {
return role === 'user' || role === 'assistant' || role === 'tool' ? role : null
}
export function toolCallText(name: unknown, input: unknown): string | null {
const toolName = typeof name === 'string' && name.trim() ? name.trim() : null
const inputRecord = asRecord(input)
let argument: string | null = null
if (inputRecord) {
for (const key of TOOL_INPUT_KEYS) {
const value = inputRecord[key]
if (typeof value === 'string' && value.trim()) {
argument = value
break
}
}
} else if (typeof input === 'string' && input.trim()) {
argument = input
}
if (!toolName && !argument) {
return null
}
const bounded = argument ? sliceAtCodeUnitLimit(argument, TOOL_ARGUMENT_SCAN_LIMIT) : null
return toolName && bounded ? `${toolName}: ${bounded}` : (toolName ?? bounded)
}
/** Flattens a tool_result body (a string, or an array of text blocks). */
function toolResultText(content: unknown): string | null {
if (typeof content === 'string') {
return content.trim() ? content : null
}
if (!Array.isArray(content)) {
return null
}
const parts: string[] = []
let length = 0
for (const item of content) {
const text = typeof item === 'string' ? item : asRecord(item)?.text
if (typeof text === 'string' && text) {
parts.push(text)
length += text.length
if (length >= TRANSCRIPT_MESSAGE_TEXT_LIMIT) {
break
}
}
}
const joined = parts.join('\n')
return joined.trim() ? joined : null
}
/**
* Splits one provider content value into the messages it decodes to. Text
* blocks keep the record's role; tool_use and tool_result blocks become `tool`
* messages whichever record carried them (Claude stores tool results on user
* records), so a consumer never has to know a provider's record shapes.
*/
export function transcriptMessagesFromContent(
role: PreviewRole,
content: unknown,
timestamp: string | null
): TranscriptMessage[] {
const messages: TranscriptMessage[] = []
const textRole = transcriptMessageRole(role)
if (typeof content === 'string') {
const text = boundedText(content)
return text && textRole ? [{ role: textRole, text, timestamp }] : []
}
const blocks = Array.isArray(content) ? content : content != null ? [content] : []
const textParts: string[] = []
for (const block of blocks) {
if (typeof block === 'string') {
textParts.push(block)
continue
}
const item = asRecord(block)
if (!item) {
continue
}
const type = typeof item.type === 'string' ? item.type : null
if (type === 'tool_use') {
pushMessage(messages, 'tool', toolCallText(item.name, item.input), timestamp)
continue
}
if (type === 'tool_result') {
pushMessage(messages, 'tool', toolResultText(item.content), timestamp)
continue
}
if (type !== null && !TEXT_BLOCK_TYPES.has(type)) {
continue
}
const text = typeof item.text === 'string' ? item.text : item.content
if (typeof text === 'string' && text) {
textParts.push(text)
}
}
if (textRole && textParts.length > 0) {
// The record's own words lead; its tool blocks follow in transcript order.
const text = boundedText(textParts.join('\n'))
if (text) {
messages.unshift({ role: textRole, text, timestamp })
}
}
return messages
}
function pushMessage(
messages: TranscriptMessage[],
role: TranscriptMessageRole,
text: string | null,
timestamp: string | null
): void {
const bounded = text === null ? null : boundedText(text)
if (bounded) {
messages.push({ role, text: bounded, timestamp })
}
}
export function boundedText(value: string): string | null {
const bounded = sliceAtCodeUnitLimit(value, TRANSCRIPT_MESSAGE_TEXT_LIMIT)
return bounded.trim() ? bounded : null
}
@@ -0,0 +1,150 @@
import { readTranscriptSlice } from '../native-chat/wsl-transcript-fs-access'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import { parseAgentSessionFile, parserPublishesMessages } from './session-scanner-agent-parser'
import { consumeCompleteJsonlLines } from './session-scanner-jsonl-reader'
import type { ResumableSessionParseState, SessionFileCandidate } from './session-scanner-types'
import type { SessionParseResumePoint } from './session-parse-cache-store'
import { TranscriptMessageChannel } from './session-transcript-channel'
const NEWLINE_BYTE = 0x0a
// Why: this layer owns reading a transcript and nothing else. It decides where
// a read starts, drives the parser, publishes the decoded messages to every
// registered consumer, and reports where the read ended. Which of those results
// are cached, listed or indexed belongs to the callers.
export type TranscriptReadStats = {
incremental: number
fullParses: number
// Transcripts the parser already excluded (Codex workers), re-listed after a
// write and dismissed without reading. Counted apart from `incremental` so a
// scan span still shows how much work the early stop actually removed.
earlyStopped: number
bytesRead: number
}
export type ResumableTranscriptRead = {
session: AiVaultSession | null
/** The fold to resume from next time, and the channel bound to it. */
resume: SessionParseResumePoint
}
/**
* Read an append-only transcript, resuming from `resume` when the file only
* grew and the recorded offset still sits on a line boundary. Anything else
* (a rewrite, a truncation, a platform change) re-reads the whole file.
*/
export async function readResumableTranscript(args: {
candidate: SessionFileCandidate
platform: NodeJS.Platform
resume: SessionParseResumePoint | null
stateFactory: (messages: TranscriptMessageChannel) => ResumableSessionParseState
stats?: TranscriptReadStats
}): Promise<ResumableTranscriptRead> {
const { file } = args.candidate
const resume = args.resume
const canResume =
resume !== null &&
typeof file.sizeBytes === 'number' &&
file.sizeBytes >= resume.byteOffset &&
(resume.byteOffset === 0 || (await endsWithNewlineAt(file.path, resume.byteOffset)))
// Clone before consuming: a failed read must not corrupt the cached state,
// or the next resume would double-count the lines applied before the error.
const channel = canResume ? resume.channel : new TranscriptMessageChannel()
const state = canResume ? resume.state.clone() : args.stateFactory(channel)
const startOffset = canResume ? resume.byteOffset : 0
// Mirrors the reader's entry guard so a dismissed transcript is not reported
// as an incremental parse that read nothing.
const stoppedBeforeRead = state.shouldStop?.() === true
if (args.stats) {
if (stoppedBeforeRead) {
args.stats.earlyStopped++
} else if (canResume) {
args.stats.incremental++
} else {
args.stats.fullParses++
}
}
channel.beginRead({
candidate: args.candidate,
mode: canResume ? 'append' : 'replace',
previousByteOffset: startOffset
})
try {
const readResult = await consumeCompleteJsonlLines({
path: file.path,
start: startOffset,
onLine: (line) => state.consumeLine(line),
// Bound: the optional hooks are declared as methods, so a parser written
// with method syntax must not lose `this` on the way into the reader.
onLineBytes: state.consumeLineBytes?.bind(state),
shouldStop: state.shouldStop?.bind(state)
})
if (args.stats) {
args.stats.bytesRead += readResult.bytesRead
}
// The stat this scan displays is current even when nothing new was consumed.
state.touchFile(file)
// Keep parity with the one-shot parser: a final unterminated line is shown,
// but stays out of the resumable state so the (possibly still-growing) line
// is re-read once complete instead of being half-counted.
let displayState = state
if (readResult.trailingPartialLine !== null) {
const partialLine = readResult.trailingPartialLine
displayState = state.clone()
channel.mute(() => displayState.consumeLine(partialLine))
}
const session = await displayState.finalize(args.platform)
channel.finishRead({ session, byteOffset: readResult.consumedThrough, incomplete: false })
return {
session,
resume: { state, byteOffset: readResult.consumedThrough, channel }
}
} catch (error) {
channel.finishRead({ session: null, byteOffset: startOffset, incomplete: true })
throw error
}
}
/**
* Read a transcript whose format is rewritten in place rather than appended
* (whole-JSON documents, Kimi's state doc, OpenCode). There is no cursor to
* keep, so every read is a whole-file `replace`.
*/
export async function readWholeTranscript(args: {
candidate: SessionFileCandidate
platform: NodeJS.Platform
stats?: TranscriptReadStats
}): Promise<AiVaultSession | null> {
const { file } = args.candidate
if (args.stats) {
args.stats.fullParses++
args.stats.bytesRead += file.sizeBytes ?? 0
}
const publishes = parserPublishesMessages(args.candidate)
const channel = new TranscriptMessageChannel()
channel.beginRead({ candidate: args.candidate, mode: 'replace', previousByteOffset: 0 })
try {
const session = await parseAgentSessionFile(args.candidate, args.platform, channel)
channel.finishRead({ session, byteOffset: file.sizeBytes ?? 0, incomplete: !publishes })
return session
} catch (error) {
channel.finishRead({ session: null, byteOffset: 0, incomplete: true })
throw error
}
}
// A resume point is only valid if it still sits just past a line break;
// anything else means the file was rewritten, not appended. Heuristic: a
// grown rewrite keeping '\n' at exactly this byte would slip through, but
// agent transcripts are append-only so that trade is accepted (worst case is
// a stale vault row until the file is next truncated or the app restarts).
async function endsWithNewlineAt(path: string, offset: number): Promise<boolean> {
const slice = await readTranscriptSlice(path, offset - 1, 1, 'scan')
return slice.length === 1 && slice[0] === NEWLINE_BYTE
}
@@ -20,14 +20,22 @@ function record(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null
}
function taskId(message: Record<string, unknown>): string | null {
const value = message.task_id
return typeof value === 'string' && value.length > 0 && value.length <= MAX_TASK_ID_LENGTH
? value
: null
/** The bound every task id shares, wherever it enters. An id the roster stores
* becomes a durable entry key, so a provisional one takes the same bound the
* announced path applies — an over-long id is rejected, never truncated. */
export function isBoundedClaudeTaskId(value: string): boolean {
return value.length > 0 && value.length <= MAX_TASK_ID_LENGTH
}
function taskDescription(value: unknown): string | undefined {
/** The task's canonical, resume-stable id. Shared with the subagent roster so
* both readers of this channel agree on what identifies a task. */
export function claudeTaskId(message: Record<string, unknown>): string | null {
const value = message.task_id
return typeof value === 'string' && isBoundedClaudeTaskId(value) ? value : null
}
/** A task's human label, collapsed and bounded. */
export function claudeTaskDescription(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined
}
@@ -107,7 +115,7 @@ export class ClaudeBackgroundTaskTracker {
this.replaceAggregateRoster(message.tasks)
return true
}
const id = taskId(message)
const id = claudeTaskId(message)
if (!id) {
return false
}
@@ -126,13 +134,13 @@ export class ClaudeBackgroundTaskTracker {
}
const existing = this.tasks.get(id)
if (
(patch.is_backgrounded === true || taskDescription(patch.description)) &&
(patch.is_backgrounded === true || claudeTaskDescription(patch.description)) &&
(!this.aggregateRosterObserved || existing)
) {
this.upsert(id, {
backgrounded: patch.is_backgrounded === true || existing?.backgrounded === true,
kind: existing?.kind ?? 'unknown',
description: taskDescription(patch.description) ?? existing?.description
description: claudeTaskDescription(patch.description) ?? existing?.description
})
return true
}
@@ -152,7 +160,7 @@ export class ClaudeBackgroundTaskTracker {
this.upsert(id, {
backgrounded: message.is_backgrounded === true || kind === 'workflow' || kind === 'monitor',
kind,
description: taskDescription(message.description)
description: claudeTaskDescription(message.description)
})
return true
}
@@ -172,14 +180,14 @@ export class ClaudeBackgroundTaskTracker {
if (!task || task.ambient === true) {
continue
}
const id = taskId(task)
const id = claudeTaskId(task)
if (!id) {
continue
}
this.tasks.set(id, {
backgrounded: true,
kind: classifyClaudeBackgroundTaskKind(task.task_type),
description: taskDescription(task.description)
description: claudeTaskDescription(task.description)
})
}
}
@@ -0,0 +1,165 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types'
import {
claudeDispatchInvokesSlashCommand,
claudeDispatchMessageContent
} from './claude-structured-dispatch-content'
const PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64'
)
function userMessage(blocks: AgentJournalMessageItem['blocks']): AgentJournalMessageItem {
return { kind: 'message', role: 'user', blocks }
}
const REMOTE_IMAGE = { type: 'image-ref' as const, url: 'https://example.test/a.png' }
describe('claudeDispatchMessageContent', () => {
it('puts the text block last so a slash command still expands with an attachment', async () => {
const content = await claudeDispatchMessageContent(
// The composer builds text-then-images; Claude only treats a leading `/` as a
// command when the LAST block is text.
userMessage([{ type: 'text', text: '/goal ship the parser' }, REMOTE_IMAGE])
)
expect(content).toEqual([
{ type: 'image', source: { type: 'url', url: 'https://example.test/a.png' } },
{ type: 'text', text: '/goal ship the parser' }
])
})
it('keeps every image ahead of the text and preserves each side’s order', async () => {
const second = { type: 'image-ref' as const, url: 'https://example.test/b.png' }
const content = await claudeDispatchMessageContent(
userMessage([{ type: 'text', text: 'look' }, REMOTE_IMAGE, second])
)
expect(content.map((part) => (part as { type: string }).type)).toEqual([
'image',
'image',
'text'
])
expect(content[0]).toEqual({
type: 'image',
source: { type: 'url', url: 'https://example.test/a.png' }
})
expect(content[1]).toEqual({
type: 'image',
source: { type: 'url', url: 'https://example.test/b.png' }
})
})
it('sends text alone unchanged', async () => {
const content = await claudeDispatchMessageContent(userMessage([{ type: 'text', text: 'hi' }]))
expect(content).toEqual([{ type: 'text', text: 'hi' }])
})
it('sends an image with no text', async () => {
const content = await claudeDispatchMessageContent(userMessage([REMOTE_IMAGE]))
expect(content).toEqual([
{ type: 'image', source: { type: 'url', url: 'https://example.test/a.png' } }
])
})
it('rejects a message with no renderable block', async () => {
await expect(
claudeDispatchMessageContent(userMessage([{ type: 'text', text: '' }]))
).rejects.toThrow('Claude dispatch requires text or an image')
})
it('rejects a non-user message', async () => {
await expect(
claudeDispatchMessageContent({
...userMessage([{ type: 'text', text: 'hi' }]),
role: 'assistant'
})
).rejects.toThrow('Claude dispatch accepts only user messages')
})
it('joins several text blocks so a command is not stranded ahead of trailing prose', async () => {
// Appending each block would leave `thanks` trailing, and Claude reads only that block.
const content = await claudeDispatchMessageContent(
userMessage([
{ type: 'text', text: '/goal ship' },
REMOTE_IMAGE,
{ type: 'text', text: 'thanks' }
])
)
expect(content).toEqual([
{ type: 'image', source: { type: 'url', url: 'https://example.test/a.png' } },
{ type: 'text', text: '/goal ship\nthanks' }
])
expect(claudeDispatchInvokesSlashCommand(content)).toBe(true)
})
it('puts a locally attached image ahead of the text, the shape the composer sends', async () => {
const dir = await mkdtemp(join(tmpdir(), 'claude-dispatch-content-'))
const path = join(dir, 'shot.png')
await writeFile(path, PNG)
try {
const content = await claudeDispatchMessageContent(
userMessage([
{ type: 'text', text: '/goal ship' },
{ type: 'image-ref', path }
])
)
expect(content).toEqual([
{
type: 'image',
source: { type: 'base64', media_type: 'image/png', data: PNG.toString('base64') }
},
{ type: 'text', text: '/goal ship' }
])
} finally {
await rm(dir, { recursive: true, force: true })
}
})
})
describe('claudeDispatchInvokesSlashCommand', () => {
it('reads the trailing prompt Claude recovers, not any text block', () => {
expect(
claudeDispatchInvokesSlashCommand([
{ type: 'image', source: { type: 'url', url: 'https://example.test/a.png' } },
{ type: 'text', text: '/goal ship' }
])
).toBe(true)
// The pre-fix order: Claude recovers no prompt at all, so no command runs.
expect(
claudeDispatchInvokesSlashCommand([
{ type: 'text', text: '/goal ship' },
{ type: 'image', source: { type: 'url', url: 'https://example.test/a.png' } }
])
).toBe(false)
})
it('reads the joined prompt, so a command behind leading prose is not one', async () => {
// Keeping the blocks separate would leave `/goal ship` trailing and falsely claim a command.
const content = await claudeDispatchMessageContent(
userMessage([
{ type: 'text', text: 'take a look' },
{ type: 'text', text: '/goal ship' }
])
)
expect(content).toEqual([{ type: 'text', text: 'take a look\n/goal ship' }])
expect(claudeDispatchInvokesSlashCommand(content)).toBe(false)
})
it('matches untrimmed, as Claude does, and ignores a promptless turn', () => {
expect(claudeDispatchInvokesSlashCommand([{ type: 'text', text: ' /goal ship' }])).toBe(false)
expect(claudeDispatchInvokesSlashCommand([{ type: 'text', text: 'ship it' }])).toBe(false)
expect(claudeDispatchInvokesSlashCommand([])).toBe(false)
})
})
@@ -3,6 +3,7 @@ import { open } from 'node:fs/promises'
import { extname } from 'node:path'
import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types'
import type { NativeChatBlock } from '../../shared/native-chat-types'
import { claudeRecord } from './claude-structured-item-translation'
const MAX_IMAGE_BYTES = 5 * 1024 * 1024
const MAX_IMAGE_COUNT = 20
@@ -88,27 +89,49 @@ async function imageContent(
}
}
/**
* Claude encodes a user turn as attachment blocks followed by the typed text, and recovers the
* typed prompt by reading only the trailing text block. Verified against the real CLI over
* stream-json: a body ending in an image has no recoverable prompt, so its `/command` reaches
* the model as prose instead of being expanded.
*/
export async function claudeDispatchMessageContent(
body: AgentJournalMessageItem
): Promise<unknown[]> {
if (body.role !== 'user') {
throw new Error('Claude dispatch accepts only user messages')
}
const content: unknown[] = []
const images: unknown[] = []
const texts: string[] = []
const imageBudget: ImageBudget = { count: 0, localBytes: 0 }
for (const block of body.blocks as NativeChatBlock[]) {
if (block.type === 'text' && block.text.length > 0) {
content.push({ type: 'text', text: block.text })
texts.push(block.text)
} else if (block.type === 'image-ref') {
content.push(await imageContent(block, imageBudget))
images.push(await imageContent(block, imageBudget))
}
}
// Join rather than append each block: only the trailing text is read as the prompt, so several
// text blocks would silently discard every one but the last.
const content = texts.length > 0 ? [...images, { type: 'text', text: texts.join('\n') }] : images
if (content.length === 0) {
throw new Error('Claude dispatch requires text or an image')
}
return content
}
/** The prompt Claude recovers from a dispatch, or null when the turn carries no prompt. */
function claudeDispatchPrompt(content: readonly unknown[]): string | null {
const last = claudeRecord(content.at(-1))
return last?.type === 'text' && typeof last.text === 'string' ? last.text : null
}
/** Mirrors how Claude decides a turn is a command. Untrimmed on purpose: Claude does not trim
* here either, so leading whitespace really does mean no command runs. */
export function claudeDispatchInvokesSlashCommand(content: readonly unknown[]): boolean {
return claudeDispatchPrompt(content)?.startsWith('/') === true
}
/**
* Keep waiter metadata bounded even when a dispatch contains large base64 images.
* The digest is only diagnostic: replay acknowledgement must use provider identity.
@@ -117,10 +140,7 @@ export function claudeDispatchContentKey(content: readonly unknown[]): string {
const digest = createHash('sha256')
const summary = content
.map((part) => {
const record =
typeof part === 'object' && part !== null && !Array.isArray(part)
? (part as Record<string, unknown>)
: null
const record = claudeRecord(part)
const type = typeof record?.type === 'string' ? record.type : 'unknown'
if (type === 'text') {
return `text:${typeof record?.text === 'string' ? record.text.length : 0}`
@@ -136,10 +156,7 @@ export function claudeDispatchContentKey(content: readonly unknown[]): string {
})
.join(',')
for (const [index, part] of content.entries()) {
const record =
typeof part === 'object' && part !== null && !Array.isArray(part)
? (part as Record<string, unknown>)
: null
const record = claudeRecord(part)
const type = typeof record?.type === 'string' ? record.type : 'unknown'
digest.update(`${index}:${type}:`)
if (type === 'text' && typeof record?.text === 'string') {
@@ -423,6 +423,73 @@ describe('Claude structured dispatch image limits', () => {
})
})
it('accepts a slash command sent with an attachment from its result receipt', async () => {
const session = sessionFor()
const dispatched = dispatchClaudeTurn(
session,
{
clientMessageId: 'client-1',
body: userMessage([
{ type: 'text', text: '/permissions' },
{ type: 'image-ref', url: 'https://example.test/a.png' }
])
},
100
)
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
// The mapper moves the image ahead of the prompt, so Claude runs the command and replies
// with a result receipt instead of a user replay.
expect(
resolveClaudeReplayWaiter(session, {
type: 'result',
subtype: 'success',
session_id: 'provider-session',
uuid: 'command-result-uuid'
})
).toBe(false)
await expect(dispatched).resolves.toMatchObject({
state: 'accepted',
providerIdentity: { uuid: 'command-result-uuid' }
})
// The sent order is the fix: the waiter's verdict alone was already what it is today.
expect(session.connection.send).toHaveBeenCalledWith(
expect.objectContaining({
message: {
role: 'user',
content: [
{ type: 'image', source: { type: 'url', url: 'https://example.test/a.png' } },
{ type: 'text', text: '/permissions' }
]
}
})
)
})
it('does not take a result receipt for leading whitespace Claude never reads as a command', async () => {
const session = sessionFor()
const dispatched = dispatchClaudeTurn(
session,
{
clientMessageId: 'client-1',
body: userMessage([{ type: 'text', text: ' /permissions' }])
},
100
)
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
expect(
resolveClaudeReplayWaiter(session, {
type: 'result',
subtype: 'success',
session_id: 'provider-session',
uuid: 'unrelated-result-uuid'
})
).toBe(false)
await expect(dispatched).resolves.toMatchObject({ state: 'unknown' })
})
it('correlates a later slash-command result by user_message_uuid despite a timed-out slash waiter', async () => {
const session = sessionFor()
const first = dispatchClaudeTurn(
@@ -12,6 +12,7 @@ import type { ClaudeDispatchWaiter, ClaudeSession } from './claude-structured-se
import { readClaudeFrameString } from './claude-structured-init-proof'
import {
claudeDispatchContentKey,
claudeDispatchInvokesSlashCommand,
claudeDispatchMessageContent
} from './claude-structured-dispatch-content'
@@ -231,9 +232,9 @@ export async function dispatchClaudeTurn(
return { state: 'rejected', reason: (error as Error).message }
}
const dispatchSequence = ++session.dispatchSequence
const acceptsResult = input.body.blocks.some(
(block) => block.type === 'text' && block.text.trimStart().startsWith('/')
)
// Read the sent content, not the journal blocks: only the mapped trailing prompt decides
// whether Claude runs a command, so the two cannot disagree about which frame settles this.
const acceptsResult = claudeDispatchInvokesSlashCommand(content)
const sentUuid = randomUUID()
const replay = waitForReplay(
session,
@@ -74,6 +74,18 @@ export function claudeMessageIdentity(
return { provider: 'claude', sessionId: envelope.sessionId, uuid: envelope.uuid }
}
/** User bubbles belong to the submitted message; SDK user frames carry echoes
* and tool results, so a user envelope keeps only its tool results. */
export function claudeOutputEnvelope(envelope: ClaudeMessageEnvelope): ClaudeMessageEnvelope {
if (envelope.role !== 'user') {
return envelope
}
return {
...envelope,
content: envelope.content.filter((part) => claudeRecord(part)?.type === 'tool_result')
}
}
function messageBlocks(envelope: ClaudeMessageEnvelope): NativeChatBlock[] {
const blocks: NativeChatBlock[] = []
for (const value of envelope.content) {
@@ -0,0 +1,259 @@
import { describe, expect, it, vi } from 'vitest'
import type {
AgentJournalItemBody,
AgentJournalItemIdentity
} from '../../shared/agent-session-journal-types'
import type {
NativeChatSubagentEntry,
NativeChatSubagentGroupBlock
} from '../../shared/native-chat-types'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { createClaudeJournalTranslator } from './claude-structured-journal-translation'
const GROUP_ITEM_ID = 'claude-subagents:claude-session:user-1'
/** The union's other arms carry no client message id, so reading one narrows. */
function orcaClientMessageId(identity: AgentJournalItemIdentity): string | null {
return identity.provider === 'orca' ? identity.clientMessageId : null
}
function harness() {
const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = []
const sink: StructuredAgentSessionEventSink = {
appendItem: (identity, body) => items.push({ identity, body }),
appendTombstone: vi.fn(),
publish: vi.fn()
}
const translator = createClaudeJournalTranslator({ sink, fallbackIdPrefix: 'test' })
const groupRows = () =>
items.filter((item) => orcaClientMessageId(item.identity) === GROUP_ITEM_ID)
const agentsOf = (body: AgentJournalItemBody | undefined): NativeChatSubagentEntry[] => {
if (!body || body.kind !== 'message') {
return []
}
const block = body.blocks.find(
(candidate): candidate is NativeChatSubagentGroupBlock => candidate.type === 'subagent-group'
)
return block ? block.agents : []
}
/** The last roster row written for one group, so a test can read a group that
* is no longer the live one. */
const rosterIn = (groupId: string): NativeChatSubagentEntry[] =>
agentsOf(
items.findLast((item) => orcaClientMessageId(item.identity) === `claude-subagents:${groupId}`)
?.body
)
const rosterOf = (turnUuid: string): NativeChatSubagentEntry[] =>
rosterIn(`claude-session:${turnUuid}`)
const roster = (): NativeChatSubagentEntry[] => agentsOf(groupRows().at(-1)?.body)
const fallbackRows = (): AgentJournalItemBody[] =>
items
.filter((item) => (orcaClientMessageId(item.identity) ?? '').startsWith('provider-frame:'))
.map((item) => item.body)
return { translator, groupRows, roster, rosterIn, rosterOf, fallbackRows }
}
function userTurn(uuid: string) {
return {
type: 'message' as const,
sessionId: 'orca-session',
startsTurn: true as const,
message: {
type: 'user',
uuid,
session_id: 'claude-session',
parent_tool_use_id: null,
message: { role: 'user', content: [{ type: 'text', text: 'go' }] }
}
}
}
function systemFrame(subtype: string, fields: Record<string, unknown>) {
return {
type: 'message' as const,
sessionId: 'orca-session',
message: { type: 'system', subtype, session_id: 'claude-session', ...fields }
}
}
function spawnResult(uuid: string, toolUseId: string) {
return {
type: 'message' as const,
sessionId: 'orca-session',
message: {
type: 'user',
uuid,
session_id: 'claude-session',
parent_tool_use_id: null,
message: {
role: 'user',
content: [{ type: 'tool_result', tool_use_id: toolUseId, content: 'done' }]
}
}
}
}
function resultFrame() {
return {
type: 'message' as const,
sessionId: 'orca-session',
message: {
type: 'result',
subtype: 'success',
session_id: 'claude-session',
uuid: 'result-1',
result: 'ok'
}
}
}
describe('claude journal translation — subagents', () => {
it('rosters a spawned subagent and settles it on the spawn call result', () => {
const { translator, roster, fallbackRows } = harness()
translator.handle(userTurn('user-1'))
translator.handle(
systemFrame('task_started', {
task_id: 'task-1',
tool_use_id: 'toolu_1',
task_type: 'local_agent',
subagent_type: 'explorer',
description: 'Map the lane'
})
)
expect(roster()).toEqual([
expect.objectContaining({ id: 'task-1', label: 'Map the lane', state: 'working' })
])
// The task frames stay status-chrome, so none of them prints an opcode row.
expect(fallbackRows()).toEqual([])
translator.handle(spawnResult('user-2', 'toolu_1'))
expect(roster()).toEqual([expect.objectContaining({ state: 'completed' })])
})
it('marks a child still working at turn end unverifiable', () => {
const { translator, roster } = harness()
translator.handle(userTurn('user-1'))
translator.handle(
systemFrame('task_started', {
task_id: 'task-1',
task_type: 'local_agent',
description: 'Map the lane'
})
)
translator.handle(resultFrame())
expect(roster()).toEqual([expect.objectContaining({ state: 'unverifiable' })])
})
it('leaves a backgrounded child running past the end of its turn', () => {
const { translator, roster } = harness()
translator.handle(userTurn('user-1'))
translator.handle(
systemFrame('task_started', {
task_id: 'task-1',
tool_use_id: 'toolu_1',
task_type: 'local_agent',
description: 'Watch the build',
is_backgrounded: true
})
)
// A backgrounded spawn returns its tool result immediately; the child runs on.
translator.handle(spawnResult('user-2', 'toolu_1'))
translator.handle(resultFrame())
expect(roster()).toEqual([expect.objectContaining({ state: 'working' })])
translator.handle({ type: 'ended', sessionId: 'orca-session', reason: 'closed' })
expect(roster()).toEqual([expect.objectContaining({ state: 'unverifiable' })])
})
it('keeps a backgrounded shell task out of the roster entirely', () => {
const { translator, groupRows } = harness()
translator.handle(userTurn('user-1'))
translator.handle(
systemFrame('task_started', {
task_id: 'task-bash',
tool_use_id: 'toolu_bash',
task_type: 'local_bash',
description: 'sleep 20',
is_backgrounded: true
})
)
translator.handle(resultFrame())
expect(groupRows()).toEqual([])
})
it('shows a subagent whose release announces no task frames, from its child traffic', () => {
const { translator, roster } = harness()
translator.handle(userTurn('user-1'))
translator.handle({
type: 'message' as const,
sessionId: 'orca-session',
message: {
type: 'assistant',
uuid: 'child-1',
session_id: 'claude-session',
parent_tool_use_id: 'toolu_1',
message: { role: 'assistant', content: [{ type: 'text', text: 'looking' }] }
}
})
expect(roster()).toEqual([
expect.objectContaining({ id: 'toolu_1', label: 'subagent', state: 'working' })
])
})
it('settles the turn a new turn superseded, and leaves the new one running', () => {
const { translator, rosterOf } = harness()
translator.handle(userTurn('user-1'))
translator.handle(
systemFrame('task_started', {
task_id: 'task-1',
task_type: 'local_agent',
description: 'First turn'
})
)
// A second turn starts with no result frame for the first: the first turn
// ends here, and nothing else will ever name its group again.
translator.handle(userTurn('user-2'))
translator.handle(
systemFrame('task_started', {
task_id: 'task-2',
task_type: 'local_agent',
description: 'Second turn'
})
)
expect(rosterOf('user-1')).toEqual([expect.objectContaining({ state: 'unverifiable' })])
expect(rosterOf('user-2')).toEqual([expect.objectContaining({ state: 'working' })])
})
it('does not let an unrelated turn end settle a child announced outside a turn', () => {
const { translator, rosterIn } = harness()
// No turn is live yet, so this child has no turn key to belong to.
translator.handle(
systemFrame('task_started', {
task_id: 'task-early',
task_type: 'local_agent',
description: 'Before the turn'
})
)
translator.handle(userTurn('user-1'))
translator.handle(resultFrame())
expect(rosterIn('outside-turn')).toEqual([expect.objectContaining({ state: 'working' })])
// The outcome still lands, which a latched `unverifiable` would have lost.
translator.handle(
systemFrame('task_updated', { task_id: 'task-early', patch: { status: 'completed' } })
)
expect(rosterIn('outside-turn')).toEqual([expect.objectContaining({ state: 'completed' })])
})
it('settles a child left outside every turn when the session ends', () => {
const { translator, rosterIn } = harness()
translator.handle(
systemFrame('task_started', {
task_id: 'task-early',
task_type: 'local_agent',
description: 'Before the turn'
})
)
translator.handle(userTurn('user-1'))
translator.handle(resultFrame())
translator.handle({ type: 'ended', sessionId: 'orca-session', reason: 'closed' })
expect(rosterIn('outside-turn')).toEqual([expect.objectContaining({ state: 'unverifiable' })])
})
})
@@ -11,9 +11,8 @@ import {
claudeMessageBody,
claudeMessageIdentity,
claudeHasReplayContent,
claudeRecord,
claudeOutputEnvelope,
claudeStreamingMessageBody,
claudeText,
claudeThinkingIdentity,
claudeThinkingText,
claudeToolBody,
@@ -29,16 +28,15 @@ import {
claudeQuestionItems
} from './claude-structured-prompt-items'
import type { ClaudePromptRegistry } from './claude-structured-prompt-replies'
import { readableProviderFrameText } from '../native-chat/agent-session-wire/unhandled-provider-frame'
import { claudeProviderFrameActivity } from '../native-chat/agent-session-wire/provider-frame-activity'
import {
CLAUDE_UNRENDERABLE_CONTENT_TEXT,
appendUnmodeledClaudeContent,
claudeProviderFrameKind,
claudeResultFailure,
createClaudeProviderFrameFallback,
isModeledClaudeContent,
isSettledClaudeResultKind
} from './claude-structured-provider-fallback'
import { ClaudeSubagentRoster } from './claude-subagent-roster'
import { createClaudeStreamedBlockRegistry } from './claude-streamed-block-identity'
import { createClaudeStreamedTextCheckpoints } from './claude-streamed-text-checkpoints'
@@ -89,10 +87,16 @@ export function createClaudeJournalTranslator(
const promptItems = new Map<string, AgentJournalItemIdentity[]>()
const streamedBlocks = createClaudeStreamedBlockRegistry()
let currentTurn: { sessionId: string; turnId: string } | null = null
const groupKeyOf = (turn: { sessionId: string; turnId: string } | null): string | null =>
turn ? `${turn.sessionId}:${turn.turnId}` : null
const providerFallback = createClaudeProviderFrameFallback(
deps.sink,
deps.fallbackIdPrefix ?? 'acquisition'
)
const subagents = new ClaudeSubagentRoster({
sink: deps.sink,
currentGroupKey: () => groupKeyOf(currentTurn)
})
const streamedText = createClaudeStreamedTextCheckpoints({
...(deps.coalesceMs === undefined ? {} : { coalesceMs: deps.coalesceMs }),
...(deps.schedule ? { schedule: deps.schedule } : {}),
@@ -144,14 +148,10 @@ export function createClaudeJournalTranslator(
return false
}
let changed = false
// User bubbles belong to the submitted message; SDK user frames carry echoes and tool results.
const outputEnvelope =
envelope.role === 'user'
? {
...envelope,
content: envelope.content.filter((part) => claudeRecord(part)?.type === 'tool_result')
}
: envelope
if (envelope.parentToolUseId) {
subagents.observeChildActivity(envelope.parentToolUseId)
}
const outputEnvelope = claudeOutputEnvelope(envelope)
const body = claudeMessageBody(outputEnvelope)
// The final frame of a streamed block lands on the block's identity, not its own uuid.
const identity =
@@ -180,6 +180,8 @@ export function createClaudeJournalTranslator(
claudeToolIdentity(envelope.sessionId, result.toolUseId),
claudeToolBody({ tool, result })
)
// A spawn call's result is the parent turn's evidence its child finished.
subagents.observeToolResult(result.toolUseId, result.failed)
// Tool inputs are only needed until their matching result arrives.
tools.delete(result.toolUseId)
changed = true
@@ -192,21 +194,7 @@ export function createClaudeJournalTranslator(
})
changed = true
}
const unhandledContent = outputEnvelope.content.filter((part) => !isModeledClaudeContent(part))
for (const part of unhandledContent) {
const partType = claudeText(claudeRecord(part)?.type) ?? 'unknown'
providerFallback.append(
`message:${envelope.role}:content:${partType}`,
part,
readableProviderFrameText(part) ?? CLAUDE_UNRENDERABLE_CONTENT_TEXT
)
changed = true
}
// An empty user frame is a replay with nothing to show, not an unknown kind.
if (envelope.content.length === 0 && envelope.role === 'assistant') {
providerFallback.append(`message:${envelope.role}:empty`, message)
changed = true
}
changed = appendUnmodeledClaudeContent(providerFallback, outputEnvelope, message) || changed
if (
envelope.role === 'user' &&
startsTurn &&
@@ -214,6 +202,9 @@ export function createClaudeJournalTranslator(
message.parent_tool_use_id === null
) {
if (currentTurn) {
// A new turn starting is the only end the previous one gets when its
// result never arrives; settling it later would sweep THIS turn.
subagents.settleTurn(groupKeyOf(currentTurn))
publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false)
}
currentTurn = { sessionId: envelope.sessionId, turnId: envelope.uuid }
@@ -254,6 +245,8 @@ export function createClaudeJournalTranslator(
handle: (event) => {
if (event.type === 'ended') {
streamedText.flush()
// No event will ever settle a child once the provider is gone.
subagents.settleSession()
if (currentTurn) {
publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false)
currentTurn = null
@@ -274,6 +267,9 @@ export function createClaudeJournalTranslator(
promptItems.delete(event.promptKey)
deps.sink.publish()
} else if (event.type === 'message' && event.message.type === 'result') {
// The turn is over however it ended, so a foreground child still
// reported as working will never be settled by an event.
subagents.settleTurn(groupKeyOf(currentTurn))
if (currentTurn) {
publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false)
currentTurn = null
@@ -291,6 +287,9 @@ export function createClaudeJournalTranslator(
providerFallback.append(kind, event.message, failure?.text)
}
} else if (event.type === 'message') {
// These frames stay `status-chrome`: the roster reads them here, and the
// fallback below still drops the raw frame instead of printing an opcode.
subagents.observeSystemFrame(event.message)
const kind = claudeProviderFrameKind(event.message)
if (!handleMessage(event.message, event.startsTurn === true)) {
providerFallback.append(kind, event.message)
@@ -310,6 +309,7 @@ export function createClaudeJournalTranslator(
tools.clear()
promptItems.clear()
streamedBlocks.clear()
subagents.dispose()
}
}
}
@@ -4,8 +4,15 @@ import {
DEFAULT_JOURNAL_PAYLOAD_LIMITS
} from '../native-chat/agent-session-journal/journal-payload-bounds'
import { CLAUDE_STREAM_JSON_FRAME_KINDS } from '../native-chat/agent-session-wire/claude-stream-json-frame-schema'
import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame'
import { claudeRecord, claudeText } from './claude-structured-item-translation'
import {
readableProviderFrameText,
unhandledProviderFrameJournalItem
} from '../native-chat/agent-session-wire/unhandled-provider-frame'
import {
claudeRecord,
claudeText,
type ClaudeMessageEnvelope
} from './claude-structured-item-translation'
export function claudeProviderFrameKind(message: Record<string, unknown>): string {
const type = claudeText(message.type) ?? 'unknown'
@@ -123,3 +130,30 @@ export function createClaudeProviderFrameFallback(
}
}
}
export type ClaudeProviderFrameFallback = ReturnType<typeof createClaudeProviderFrameFallback>
/** Journal each content part this build does not model, plus the empty assistant
* frame a replay leaves behind (an empty USER frame is a replay with nothing to
* show, not an unknown kind). Returns whether anything was appended. */
export function appendUnmodeledClaudeContent(
fallback: ClaudeProviderFrameFallback,
envelope: ClaudeMessageEnvelope,
message: Record<string, unknown>
): boolean {
let changed = false
for (const part of envelope.content.filter((part) => !isModeledClaudeContent(part))) {
const partType = claudeText(claudeRecord(part)?.type) ?? 'unknown'
fallback.append(
`message:${envelope.role}:content:${partType}`,
part,
readableProviderFrameText(part) ?? CLAUDE_UNRENDERABLE_CONTENT_TEXT
)
changed = true
}
if (envelope.content.length === 0 && envelope.role === 'assistant') {
fallback.append(`message:${envelope.role}:empty`, message)
changed = true
}
return changed
}
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatSubagentEntry } from '../../shared/native-chat-types'
import { claudeSubagentGroupBody } from './claude-subagent-group-row'
function entry(id: string, state: NativeChatSubagentEntry['state']): NativeChatSubagentEntry {
return { id, label: id, state, startedAt: 1 }
}
/** The fallback sentence is the WHOLE row on mobile and paired web, which have
* no roster renderer, so these assertions are the entire contract there. */
function sentence(agents: readonly NativeChatSubagentEntry[]): string {
const body = claudeSubagentGroupBody('turn-1', agents)
const block = body.kind === 'message' ? body.blocks[0] : undefined
return block && block.type === 'text' ? block.text : ''
}
describe('claudeSubagentGroupBody fallback sentence', () => {
it('reads as a plain completion when every child completed', () => {
expect(sentence([entry('a', 'completed'), entry('b', 'completed')])).toBe('Ran 2 subagents')
})
it('keeps the singular noun for a lone child', () => {
expect(sentence([entry('a', 'completed')])).toBe('Ran 1 subagent')
expect(sentence([entry('a', 'working')])).toBe('Kicked off 1 subagent')
})
it('names an unverifiable child instead of claiming the group ran', () => {
expect(sentence([entry('a', 'completed'), entry('b', 'unverifiable')])).toBe(
'Ran 2 subagents (1 unverifiable)'
)
})
it('ranks the adverse outcome worst-first', () => {
expect(
sentence([entry('a', 'failed'), entry('b', 'unverifiable'), entry('c', 'completed')])
).toBe('Ran 3 subagents (1 failed)')
expect(sentence([entry('a', 'stopped'), entry('b', 'unverifiable')])).toBe(
'Ran 2 subagents (1 stopped)'
)
})
it('shows the adverse outcome while a sibling still works', () => {
expect(
sentence([entry('a', 'working'), entry('b', 'working'), entry('c', 'unverifiable')])
).toBe('Kicked off 3 subagents (1 unverifiable)')
})
it('leaves a benign settled state out of the sentence', () => {
expect(sentence([entry('a', 'idle'), entry('b', 'completed')])).toBe('Ran 2 subagents')
})
it('counts every child holding the worst adverse state', () => {
expect(sentence([entry('a', 'failed'), entry('b', 'failed'), entry('c', 'stopped')])).toBe(
'Ran 3 subagents (2 failed)'
)
})
})
@@ -0,0 +1,32 @@
// The journal row one Claude spawn group writes: its durable identity and the
// body it revises in place.
import type {
AgentJournalItemBody,
AgentJournalItemIdentity
} from '../../shared/agent-session-journal-types'
import { subagentGroupFallbackText } from '../../shared/native-chat-subagent-summary'
import type { NativeChatSubagentEntry } from '../../shared/native-chat-types'
/** Durable journal identity for the group's row — stable across revisions and
* across a restart, so replay finds the same row instead of appending a new one. */
export function claudeSubagentGroupIdentity(groupId: string): AgentJournalItemIdentity {
return { provider: 'orca', clientMessageId: `claude-subagents:${groupId}` }
}
/** The roster row: the structured block plus the plain sentence an older client
* renders in its place. A message whose only block is the new variant would
* reach such a client with nothing it can draw. */
export function claudeSubagentGroupBody(
groupId: string,
agents: readonly NativeChatSubagentEntry[]
): AgentJournalItemBody {
return {
kind: 'message',
role: 'system',
blocks: [
{ type: 'text', text: subagentGroupFallbackText(agents) },
{ type: 'subagent-group', groupId, agents: [...agents] }
]
}
}
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import { ClaudeSubagentIds } from './claude-subagent-id-aliases'
describe('ClaudeSubagentIds', () => {
it('resolves an aliased tool id to its task, and an unaliased id to itself', () => {
const ids = new ClaudeSubagentIds()
ids.alias('toolu_1', 'task-1')
expect(ids.canonical('toolu_1')).toBe('task-1')
expect(ids.canonical('toolu_unknown')).toBe('toolu_unknown')
})
it('remembers an exclusion under either of the ids that named it', () => {
const ids = new ClaudeSubagentIds()
ids.exclude('task-bash')
expect(ids.isExcluded('toolu_bash', 'task-bash')).toBe(true)
expect(ids.isExcluded(null, null)).toBe(false)
expect(ids.isExcluded('task-agent')).toBe(false)
})
it('drops the oldest alias past the bound and keeps the newest', () => {
const ids = new ClaudeSubagentIds()
for (let index = 0; index <= 512; index += 1) {
ids.alias(`toolu_${index}`, `task-${index}`)
}
// Evicted: the id now stands only for itself.
expect(ids.canonical('toolu_0')).toBe('toolu_0')
expect(ids.canonical('toolu_512')).toBe('task-512')
expect(ids.canonical('toolu_1')).toBe('task-1')
})
it('drops the oldest exclusion past the bound and keeps the newest', () => {
const ids = new ClaudeSubagentIds()
for (let index = 0; index <= 512; index += 1) {
ids.exclude(`task-${index}`)
}
expect(ids.isExcluded('task-0')).toBe(false)
expect(ids.isExcluded('task-512')).toBe(true)
expect(ids.isExcluded('task-1')).toBe(true)
})
it('does not retain oversized aliases or exclusions', () => {
const ids = new ClaudeSubagentIds()
const oversized = 'x'.repeat(513)
ids.alias(oversized, 'task-1')
ids.alias('tool-1', oversized)
ids.exclude(oversized)
expect(ids.canonical(oversized)).toBe(oversized)
expect(ids.canonical('tool-1')).toBe('tool-1')
expect(ids.isExcluded(oversized)).toBe(false)
})
it('forgets everything on clear', () => {
const ids = new ClaudeSubagentIds()
ids.alias('toolu_1', 'task-1')
ids.exclude('task-1')
ids.clear()
expect(ids.canonical('toolu_1')).toBe('toolu_1')
expect(ids.isExcluded('task-1')).toBe(false)
})
})
@@ -0,0 +1,63 @@
// Which Claude ids name the same subagent, and which name no subagent at all.
//
// Claude re-announces a resumed task under a NEW `tool_use_id` while `task_id`
// stays put, so tool ids are aliases of a canonical task id — a store keyed on
// the tool id would show the child twice after every resume.
//
// The exclusions matter just as much: `task_updated` carries no `task_type` and
// child traffic carries no task metadata at all, so the one announcement that
// said "this is a backgrounded shell, not an agent" has to be remembered or a
// later frame re-admits it.
import { isBoundedClaudeTaskId } from './claude-background-task-tracker'
/** Both maps are event-accumulated and nothing prunes them, so both are bounded. */
const MAX_TOOL_USE_ALIASES = 512
const MAX_EXCLUDED_IDS = 512
export class ClaudeSubagentIds {
private readonly canonicalByToolUse = new Map<string, string>()
private readonly excluded = new Set<string>()
/** The task id a tool id stands for, or the id itself when nothing aliases it. */
canonical(id: string): string {
return this.canonicalByToolUse.get(id) ?? id
}
alias(toolUseId: string, taskId: string): void {
if (!isBoundedClaudeTaskId(toolUseId) || !isBoundedClaudeTaskId(taskId)) {
return
}
this.canonicalByToolUse.set(toolUseId, taskId)
while (this.canonicalByToolUse.size > MAX_TOOL_USE_ALIASES) {
const oldest = this.canonicalByToolUse.keys().next()
if (oldest.done || oldest.value === toolUseId) {
break
}
this.canonicalByToolUse.delete(oldest.value)
}
}
exclude(id: string): void {
if (!isBoundedClaudeTaskId(id)) {
return
}
this.excluded.add(id)
while (this.excluded.size > MAX_EXCLUDED_IDS) {
const oldest = this.excluded.values().next()
if (oldest.done || oldest.value === id) {
break
}
this.excluded.delete(oldest.value)
}
}
isExcluded(...ids: (string | null)[]): boolean {
return ids.some((id) => id !== null && this.excluded.has(id))
}
clear(): void {
this.canonicalByToolUse.clear()
this.excluded.clear()
}
}
@@ -0,0 +1,75 @@
import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types'
import type { NativeChatSubagentEntry } from '../../shared/native-chat-types'
import type { ClaudeSubagentTaskFrame } from './claude-subagent-task-frames'
const MAX_INVOCATIONS_PER_SUBAGENT = 16
export type TrackedEntry = {
entry: NativeChatSubagentEntry
/** The only signal separating a child that dies with its turn from one told to
* outlive it. A turn-end sweep must leave a backgrounded child alone. */
backgrounded: boolean
toolUseId: string | null
invocationIds: Set<string> | null
/** Label before its ordinal suffix, so a later announcement can tell a
* provisional row from one that already carries the provider's own name. */
labelBase: string
}
export type RosterGroup = {
groupId: string
identity: AgentJournalItemIdentity
/** Insertion order is the display order; the map holds the state. */
entries: Map<string, TrackedEntry>
/** Lifetime admissions bound retained labels even when entries are removed. */
admittedEntries: number
/** Labels remain reserved after removal or provisional-name replacement. */
claimedLabels: Set<string>
/** Last body written, so an idempotent replay writes no new revision. */
lastSerialized: string | null
}
// Invocation history stays with the entry, independent of the evicting alias cache.
export function applyClaudeSubagentInvocation(
tracked: TrackedEntry,
frame: ClaudeSubagentTaskFrame,
now: () => number
): boolean {
if (tracked.invocationIds === null) {
return false
}
const newInvocation =
frame.announcement && frame.toolUseId !== null && !tracked.invocationIds.has(frame.toolUseId)
if (newInvocation && frame.toolUseId) {
if (tracked.invocationIds.size >= MAX_INVOCATIONS_PER_SUBAGENT) {
tracked.invocationIds = null
tracked.entry = { ...tracked.entry, state: 'unverifiable', settledAt: now() }
return true
}
tracked.invocationIds.add(frame.toolUseId)
if (tracked.toolUseId !== null && tracked.toolUseId !== frame.toolUseId) {
tracked.backgrounded = frame.backgrounded ?? false
tracked.entry = { ...tracked.entry, state: frame.state ?? 'working', settledAt: undefined }
}
tracked.toolUseId = frame.toolUseId
} else if (tracked.toolUseId && frame.toolUseId && tracked.toolUseId !== frame.toolUseId) {
return false
}
if (tracked.toolUseId === null) {
tracked.toolUseId = frame.toolUseId
}
return true
}
/** Two children can share a description; the ordinal keeps their rows apart
* without inventing a name the provider never sent. The probe is over the
* labels actually rendered, not a per-base counter: a generated `Audit 2`
* must not collide with a provider that names its own child `Audit 2`. */
export function claimClaudeSubagentLabel(group: RosterGroup, base: string): string {
let candidate = base
for (let ordinal = 2; group.claimedLabels.has(candidate); ordinal++) {
candidate = `${base} ${ordinal}`
}
group.claimedLabels.add(candidate)
return candidate
}
@@ -0,0 +1,602 @@
import { describe, expect, it, vi } from 'vitest'
import type {
AgentJournalItemBody,
AgentJournalItemIdentity
} from '../../shared/agent-session-journal-types'
import type {
NativeChatSubagentEntry,
NativeChatSubagentGroupBlock
} from '../../shared/native-chat-types'
import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store'
import {
createDeferredStructuredAgentSessionEventSink,
type StructuredAgentSessionEventSink
} from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { ClaudeSubagentRoster } from './claude-subagent-roster'
const TURN_1 = 'claude-session:turn-1'
function agentsOf(body: AgentJournalItemBody | undefined): NativeChatSubagentEntry[] {
if (!body || body.kind !== 'message') {
return []
}
const block = body.blocks.find(
(candidate): candidate is NativeChatSubagentGroupBlock => candidate.type === 'subagent-group'
)
return block ? block.agents : []
}
function isGroupRow(identity: AgentJournalItemIdentity, groupId: string): boolean {
return identity.provider === 'orca' && identity.clientMessageId === `claude-subagents:${groupId}`
}
function harness(groupKey: string | null = TURN_1) {
const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = []
const tombstones: AgentJournalItemIdentity[] = []
const sink: StructuredAgentSessionEventSink = {
appendItem: (identity, body) => items.push({ identity, body }),
appendTombstone: (identity) => tombstones.push(identity),
publish: vi.fn()
}
let clock = 1_000
let key = groupKey
const roster = new ClaudeSubagentRoster({
sink,
currentGroupKey: () => key,
now: () => (clock += 1)
})
const roles = (): NativeChatSubagentEntry[] => agentsOf(items.at(-1)?.body)
/** The last row written for one group, so a test can read a row that is no
* longer the newest one. */
const rolesIn = (groupId: string): NativeChatSubagentEntry[] =>
agentsOf(items.findLast((item) => isGroupRow(item.identity, groupId))?.body)
return {
roster,
items,
tombstones,
roles,
rolesIn,
setGroupKey: (next: string | null) => {
key = next
}
}
}
function system(subtype: string, fields: Record<string, unknown>): Record<string, unknown> {
return { type: 'system', subtype, session_id: 'claude-session', ...fields }
}
function started(fields: Record<string, unknown>): Record<string, unknown> {
return system('task_started', { task_type: 'local_agent', ...fields })
}
describe('ClaudeSubagentRoster', () => {
it('builds the row from task_started, with the fallback sentence beside the block', () => {
const { roster, items, roles } = harness()
roster.observeSystemFrame(
started({ task_id: 'task-1', tool_use_id: 'toolu_1', description: 'Review the diff' })
)
expect(items).toHaveLength(1)
expect(items[0]?.identity).toEqual({
provider: 'orca',
clientMessageId: 'claude-subagents:claude-session:turn-1'
})
const body = items[0]?.body
expect(body?.kind === 'message' && body.blocks[0]).toEqual({
type: 'text',
text: 'Kicked off 1 subagent'
})
expect(roles()).toEqual([
expect.objectContaining({ id: 'task-1', label: 'Review the diff', state: 'working' })
])
})
it('keeps a backgrounded shell task out of the roster', () => {
const { roster, items } = harness()
roster.observeSystemFrame(
system('task_started', {
task_id: 'task-bash',
tool_use_id: 'toolu_bash',
task_type: 'local_bash',
description: 'sleep 20',
is_backgrounded: true
})
)
roster.observeSystemFrame(
system('task_updated', { task_id: 'task-bash', patch: { status: 'running' } })
)
// Its own frames carry a tool_use_id, so only the excluded-id memory stops it.
roster.observeChildActivity('toolu_bash')
expect(items).toHaveLength(0)
})
it('never renders a task marked skip_transcript', () => {
const { roster, items } = harness()
roster.observeSystemFrame(
started({ task_id: 'task-a', tool_use_id: 'toolu_a', skip_transcript: true })
)
roster.observeSystemFrame(
system('task_updated', { task_id: 'task-a', patch: { status: 'completed' } })
)
roster.observeChildActivity('toolu_a')
expect(items).toHaveLength(0)
})
it('drops a provisional row once an announcement says the task is not a subagent', () => {
const { roster, items, tombstones, roles } = harness()
roster.observeChildActivity('toolu_bash')
expect(roles()).toHaveLength(1)
roster.observeSystemFrame(
system('task_started', {
task_id: 'task-bash',
tool_use_id: 'toolu_bash',
task_type: 'local_bash'
})
)
expect(tombstones).toEqual([
{ provider: 'orca', clientMessageId: 'claude-subagents:claude-session:turn-1' }
])
expect(items).toHaveLength(1)
})
it('does not duplicate a resumed task re-announced under a new tool_use_id', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(
started({ task_id: 'task-1', tool_use_id: 'toolu_first', description: 'Audit' })
)
roster.observeChildActivity('toolu_first')
roster.observeSystemFrame(
started({ task_id: 'task-1', tool_use_id: 'toolu_second', description: 'Audit' })
)
roster.observeChildActivity('toolu_second')
expect(roles()).toEqual([
expect.objectContaining({ id: 'task-1', label: 'Audit', state: 'working' })
])
})
it('adopts a row built from child traffic when the announcement finally names it', () => {
const { roster, roles } = harness()
roster.observeChildActivity('toolu_1')
expect(roles()).toEqual([expect.objectContaining({ id: 'toolu_1', label: 'subagent' })])
roster.observeSystemFrame(
started({ task_id: 'task-1', tool_use_id: 'toolu_1', description: 'Explore' })
)
expect(roles()).toEqual([
expect.objectContaining({ id: 'task-1', label: 'Explore', state: 'working' })
])
})
it('is idempotent: a repeated frame writes no new revision', () => {
const { roster, items } = harness()
const frame = started({ task_id: 'task-1', tool_use_id: 'toolu_1', description: 'Audit' })
roster.observeSystemFrame(frame)
roster.observeSystemFrame(frame)
roster.observeSystemFrame(
system('task_updated', { task_id: 'task-1', patch: { status: 'running' } })
)
expect(items).toHaveLength(1)
})
it('latches a terminal state against a later live report', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' }))
roster.observeSystemFrame(
system('task_updated', { task_id: 'task-1', patch: { status: 'failed' } })
)
roster.observeSystemFrame(
system('task_updated', { task_id: 'task-1', patch: { status: 'running' } })
)
expect(roles()).toEqual([expect.objectContaining({ state: 'failed' })])
})
it('ignores an update for a task it never rostered', () => {
const { roster, items } = harness()
roster.observeSystemFrame(
system('task_updated', { task_id: 'task-unknown', patch: { status: 'running' } })
)
expect(items).toHaveLength(0)
})
it('disambiguates children that share a description', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Explore' }))
roster.observeSystemFrame(started({ task_id: 'task-2', description: 'Explore' }))
expect(roles().map((agent) => agent.label)).toEqual(['Explore', 'Explore 2'])
})
describe('turn end', () => {
it('leaves a backgrounded child working and marks a foreground one unverifiable', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(started({ task_id: 'task-fg', description: 'Foreground' }))
roster.observeSystemFrame(
started({ task_id: 'task-bg', description: 'Background', is_backgrounded: true })
)
roster.settleTurn(TURN_1)
expect(roles()).toEqual([
expect.objectContaining({ label: 'Foreground', state: 'unverifiable' }),
expect.objectContaining({ label: 'Background', state: 'working' })
])
})
it('never re-settles a child that already reported an outcome', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' }))
roster.observeSystemFrame(
system('task_updated', { task_id: 'task-1', patch: { status: 'completed' } })
)
roster.settleTurn(TURN_1)
expect(roles()).toEqual([expect.objectContaining({ state: 'completed' })])
})
it('sweeps backgrounded children only when the provider itself is gone', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(
started({ task_id: 'task-bg', description: 'Background', is_backgrounded: true })
)
roster.settleTurn(TURN_1)
roster.settleSession()
expect(roles()).toEqual([expect.objectContaining({ state: 'unverifiable' })])
})
})
describe('spawn tool result', () => {
it('settles a foreground child', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'toolu_1' }))
roster.observeToolResult('toolu_1', false)
expect(roles()).toEqual([expect.objectContaining({ state: 'completed' })])
})
it('reports a failed spawn as failed', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'toolu_1' }))
roster.observeToolResult('toolu_1', true)
expect(roles()).toEqual([expect.objectContaining({ state: 'failed' })])
})
it('ignores the immediate result a backgrounded spawn returns', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(
started({ task_id: 'task-1', tool_use_id: 'toolu_1', is_backgrounded: true })
)
roster.observeToolResult('toolu_1', false)
expect(roles()).toEqual([expect.objectContaining({ state: 'working' })])
})
it('ignores results for tools that are not spawn calls', () => {
const { roster, items } = harness()
roster.observeToolResult('toolu_read', false)
expect(items).toHaveLength(0)
})
})
describe('label ordinals', () => {
it('never re-issues an ordinal a removed row gave up', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' }))
roster.observeSystemFrame(started({ task_id: 'task-2', description: 'Audit' }))
// task-1 is re-announced as a shell task, so its row goes; reclaiming the
// ordinal it held would print a second 'Audit 2' beside the one still shown.
roster.observeSystemFrame(
system('task_started', { task_id: 'task-1', task_type: 'local_bash' })
)
roster.observeSystemFrame(started({ task_id: 'task-3', description: 'Audit' }))
expect(roles().map((agent) => agent.label)).toEqual(['Audit 2', 'Audit 3'])
})
it('never generates a label a provider-supplied one already took', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' }))
roster.observeSystemFrame(started({ task_id: 'task-2', description: 'Audit' }))
// The provider's own name for the third child is the label the ordinal just
// generated for the second; a per-base counter would print it twice.
roster.observeSystemFrame(started({ task_id: 'task-3', description: 'Audit 2' }))
const labels = roles().map((agent) => agent.label)
expect(labels).toEqual(['Audit', 'Audit 2', 'Audit 2 2'])
expect(new Set(labels).size).toBe(labels.length)
})
})
describe('child traffic for an id the CLI never declared', () => {
it('creates nothing once the CLI has announced any task at all', () => {
const { roster, items } = harness()
// A rejected announcement still proves this CLI declares what it spawns.
roster.observeSystemFrame(
system('task_started', { task_id: 'task-bash', task_type: 'local_bash' })
)
roster.observeChildActivity('toolu_never_announced')
expect(items).toHaveLength(0)
})
it('rejects an over-long provisional id instead of storing it as an entry id', () => {
const { roster, items } = harness()
// The announced path drops an id past `claudeTaskId`'s bound; the
// provisional one writes the same durable entry id, so it must too.
roster.observeChildActivity(`toolu_${'x'.repeat(512)}`)
expect(items).toHaveLength(0)
roster.observeChildActivity(`toolu_${'x'.repeat(500)}`)
expect(items).toHaveLength(1)
})
it('still rosters a subagent announced after a task the filter rejected', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(
system('task_started', { task_id: 'task-bash', task_type: 'local_bash' })
)
// The gate closes the child-traffic fallback, never the announcement path.
roster.observeSystemFrame(
started({ task_id: 'task-1', tool_use_id: 'toolu_1', description: 'Explore' })
)
roster.observeChildActivity('toolu_1')
expect(roles()).toEqual([
expect.objectContaining({ id: 'task-1', label: 'Explore', state: 'working' })
])
})
it('leaves a grandchild parented inside the sidechain out of the roster', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(
started({ task_id: 'task-1', tool_use_id: 'toolu_1', description: 'Explore' })
)
roster.observeChildActivity('toolu_1')
// A tool the subagent itself ran: never announced, so never excluded either.
roster.observeChildActivity('toolu_inner')
expect(roles()).toEqual([
expect.objectContaining({ id: 'task-1', label: 'Explore', state: 'working' })
])
})
it('still mints the provisional row for a release that announces no task', () => {
const { roster, roles } = harness()
roster.observeChildActivity('toolu_1')
// Not an announcement: the fallback path stays open for this release.
roster.observeSystemFrame(
system('task_updated', { task_id: 'task-x', patch: { status: 'running' } })
)
roster.observeChildActivity('toolu_2')
expect(roles().map((agent) => agent.label)).toEqual(['subagent', 'subagent 2'])
})
})
describe('groups that no later event can reach', () => {
it('loses contact with a group evicted past the bound', () => {
const { roster, rolesIn, setGroupKey } = harness('turn-0')
for (let index = 0; index < 33; index += 1) {
setGroupKey(`turn-${index}`)
roster.observeSystemFrame(started({ task_id: `task-${index}`, description: 'Audit' }))
}
expect(rolesIn('turn-0')).toEqual([expect.objectContaining({ state: 'unverifiable' })])
expect(rolesIn('turn-32')).toEqual([expect.objectContaining({ state: 'working' })])
})
it('loses contact with a live child when the translator is disposed without an end', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(
started({ task_id: 'task-bg', description: 'Background', is_backgrounded: true })
)
roster.dispose()
expect(roles()).toEqual([expect.objectContaining({ state: 'unverifiable' })])
})
it('writes nothing on dispose when the session already settled', () => {
const { roster, items } = harness()
roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' }))
roster.settleSession()
const written = items.length
roster.dispose()
expect(items).toHaveLength(written)
})
})
it('groups children outside any turn under their own row', () => {
const { roster, items } = harness(null)
roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' }))
expect(items[0]?.identity).toEqual({
provider: 'orca',
clientMessageId: 'claude-subagents:outside-turn'
})
})
})
describe('ClaudeSubagentRoster — the turn that is ending', () => {
it('leaves a child announced outside any turn alone when an unrelated turn ends', () => {
const { roster, rolesIn, setGroupKey } = harness(null)
roster.observeSystemFrame(started({ task_id: 'task-early', description: 'Early' }))
setGroupKey(TURN_1)
roster.observeSystemFrame(started({ task_id: 'task-turn', description: 'In turn' }))
roster.settleTurn(TURN_1)
expect(rolesIn('outside-turn')).toEqual([expect.objectContaining({ state: 'working' })])
expect(rolesIn(TURN_1)).toEqual([expect.objectContaining({ state: 'unverifiable' })])
// `unverifiable` latches, so sweeping it above would have swallowed this.
roster.observeSystemFrame(
system('task_updated', { task_id: 'task-early', patch: { status: 'completed' } })
)
expect(rolesIn('outside-turn')).toEqual([expect.objectContaining({ state: 'completed' })])
})
it('sweeps the outside-turn group when a turn with no key of its own ends', () => {
const { roster, rolesIn } = harness(null)
roster.observeSystemFrame(started({ task_id: 'task-early', description: 'Early' }))
roster.settleTurn(null)
expect(rolesIn('outside-turn')).toEqual([expect.objectContaining({ state: 'unverifiable' })])
})
it('still settles an outside-turn child once the session itself ends', () => {
const { roster, rolesIn, setGroupKey } = harness(null)
roster.observeSystemFrame(started({ task_id: 'task-early', description: 'Early' }))
setGroupKey(TURN_1)
roster.observeSystemFrame(started({ task_id: 'task-turn', description: 'In turn' }))
roster.settleTurn(TURN_1)
roster.settleSession()
expect(rolesIn('outside-turn')).toEqual([expect.objectContaining({ state: 'unverifiable' })])
})
it('sweeps the turn that ended, not whichever turn is live now', () => {
const { roster, rolesIn, setGroupKey } = harness()
roster.observeSystemFrame(started({ task_id: 'task-1', description: 'First turn' }))
setGroupKey('claude-session:turn-2')
roster.observeSystemFrame(started({ task_id: 'task-2', description: 'Second turn' }))
// Turn 1's result lands after turn 2 has already begun.
roster.settleTurn(TURN_1)
expect(rolesIn(TURN_1)).toEqual([expect.objectContaining({ state: 'unverifiable' })])
expect(rolesIn('claude-session:turn-2')).toEqual([
expect.objectContaining({ state: 'working' })
])
})
})
describe('ClaudeSubagentRoster — through the real sink queue', () => {
it('lands every revision, not just the one that was already in flight', async () => {
const appended: AgentJournalItemBody[] = []
let published = 0
const journal = {
appendItem: async (_identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => {
appended.push(body)
return { cursor: { epoch: 'e', sequence: appended.length } }
},
appendTombstone: async () => ({ epoch: 'e', sequence: 0 })
} as unknown as AgentSessionJournal
const deferred = createDeferredStructuredAgentSessionEventSink()
deferred.bind({
journal,
fence: 1,
publish: () => {
published += 1
}
})
const roster = new ClaudeSubagentRoster({ sink: deferred.sink, currentGroupKey: () => TURN_1 })
// The first append is in flight while the rest are submitted, so a publish
// sharing the row's coalescing key would evict them.
roster.observeSystemFrame(started({ task_id: 'task-1', description: 'One' }))
roster.observeSystemFrame(started({ task_id: 'task-2', description: 'Two' }))
roster.observeSystemFrame(
system('task_updated', { task_id: 'task-1', patch: { status: 'completed' } })
)
const drained = await deferred.drained()
expect(drained).toEqual({ ok: true })
expect(agentsOf(appended.at(-1))).toEqual([
expect.objectContaining({ id: 'task-1', label: 'One', state: 'completed' }),
expect.objectContaining({ id: 'task-2', label: 'Two', state: 'working' })
])
expect(published).toBeGreaterThan(0)
})
})
describe('ClaudeSubagentRoster — authoritative outcomes and retained budgets', () => {
it('accepts a notification after the foreground turn lost contact', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(started({ task_id: 'task-1' }))
roster.settleTurn(TURN_1)
roster.observeSystemFrame(
system('task_notification', { task_id: 'task-1', status: 'completed' })
)
expect(roles()).toEqual([expect.objectContaining({ state: 'completed' })])
})
it('settles a background child from its notification without a task_updated', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(started({ task_id: 'task-1', is_backgrounded: true }))
roster.settleTurn(TURN_1)
roster.observeSystemFrame(system('task_notification', { task_id: 'task-1', status: 'failed' }))
expect(roles()).toEqual([expect.objectContaining({ state: 'failed' })])
})
it('bounds lifetime admissions when reclassification repeatedly removes entries', () => {
const { roster, items } = harness()
for (let i = 0; i < 100; i++) {
roster.observeSystemFrame(started({ task_id: `task-${i}`, description: `Agent ${i}` }))
roster.observeSystemFrame(
system('task_started', { task_id: `task-${i}`, task_type: 'local_bash' })
)
}
expect(items).toHaveLength(64)
})
})
describe('ClaudeSubagentRoster — resumed invocation', () => {
it('reopens one canonical child on a new announcement without replaying old results', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'first' }))
roster.observeToolResult('first', false)
roster.observeSystemFrame(
started({ task_id: 'task-1', tool_use_id: 'resumed', is_backgrounded: true })
)
expect(roles()).toEqual([expect.objectContaining({ id: 'task-1', state: 'working' })])
expect(roles()[0].settledAt).toBeUndefined()
roster.observeSystemFrame(
system('task_notification', { task_id: 'task-1', tool_use_id: 'first', status: 'completed' })
)
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'first' }))
expect(roles()[0].state).toBe('working')
roster.observeSystemFrame(
system('task_notification', {
task_id: 'task-1',
tool_use_id: 'resumed',
status: 'completed'
})
)
roster.observeSystemFrame(
started({ task_id: 'task-1', tool_use_id: 'resumed', is_backgrounded: true })
)
expect(roles()[0].state).toBe('completed')
})
})
describe('ClaudeSubagentRoster — invocation fences', () => {
it('ignores a previous invocation tool result even without a background flag', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'first' }))
roster.observeToolResult('first', false)
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'next' }))
roster.observeToolResult('first', true)
expect(roles()[0].state).toBe('working')
roster.observeToolResult('next', false)
expect(roles()[0].state).toBe('completed')
})
it('does not treat an evicted alias as a new invocation', () => {
const { roster, rolesIn, setGroupKey } = harness()
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'first' }))
roster.observeToolResult('first', false)
setGroupKey('churn')
for (let i = 0; i < 513; i++) {
roster.observeSystemFrame(
system('task_updated', { task_id: `other-${i}`, tool_use_id: `tool-${i}` })
)
}
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'first' }))
expect(rolesIn(TURN_1)[0].state).toBe('completed')
})
it('bounds invocation history and refuses to reopen beyond the retained budget', () => {
const { roster, roles } = harness()
for (let i = 0; i < 20; i++) {
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: `tool-${i}` }))
if (i >= 16) {
expect(roles()[0].state).toBe('unverifiable')
}
roster.observeToolResult(`tool-${i}`, false)
}
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'tool-0' }))
expect(roles()[0].state).toBe('unverifiable')
})
})
it('merges an explicit foreground patch without clearing on absent metadata', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(
started({ task_id: 'task-1', tool_use_id: 'tool', is_backgrounded: true })
)
roster.observeSystemFrame(
system('task_updated', { task_id: 'task-1', patch: { description: 'Audit' } })
)
roster.observeToolResult('tool', false)
expect(roles()[0].state).toBe('working')
roster.observeSystemFrame(
system('task_updated', { task_id: 'task-1', patch: { is_backgrounded: false } })
)
roster.observeToolResult('tool', false)
expect(roles()[0].state).toBe('completed')
})
+388
View File
@@ -0,0 +1,388 @@
// The Claude subagent roster: one journal row per turn that spawned children.
//
// Entries are built from `task_started`, never from child traffic: a
// BACKGROUNDED subagent emits no child frames at all, so a roster fed by
// `parent_tool_use_id` alone would leave every one of them an unlabelled row
// forever. Child traffic only creates an entry for CLI releases that announce
// no task frames.
//
// Claude re-announces a resumed task under a NEW `tool_use_id`, so `task_id` is
// the key and tool ids are aliases; keying on the tool id would duplicate the
// child on every resume. Outcomes latch within an invocation; a new spawn
// alias can reopen it, and authoritative evidence can correct lost contact.
import {
canReplaceSubagentState,
isTerminalSubagentState
} from '../../shared/native-chat-subagent-summary'
import type { NativeChatSubagentEntry } from '../../shared/native-chat-types'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { isBoundedClaudeTaskId } from './claude-background-task-tracker'
import { claudeSubagentGroupBody, claudeSubagentGroupIdentity } from './claude-subagent-group-row'
import { ClaudeSubagentIds } from './claude-subagent-id-aliases'
import { readClaudeSubagentTaskFrame } from './claude-subagent-task-frames'
import {
applyClaudeSubagentInvocation,
claimClaudeSubagentLabel,
type RosterGroup,
type TrackedEntry
} from './claude-subagent-roster-state'
/** Spawn-group rows kept live per session, and children per row. Both bound an
* event-accumulated map that no provider snapshot ever prunes. */
const MAX_SUBAGENT_GROUPS = 32
const MAX_SUBAGENTS_PER_GROUP = 64
/** The turn a group belongs to when Claude reports a task outside any turn. */
const OUTSIDE_TURN = 'outside-turn'
const UNLABELLED_AGENT = 'subagent'
export type ClaudeSubagentRosterDeps = {
sink: StructuredAgentSessionEventSink
/** The turn that owns children spawned right now; null outside any turn. */
currentGroupKey: () => string | null
now?: () => number
}
export class ClaudeSubagentRoster {
private readonly groups = new Map<string, RosterGroup>()
/** Canonical id → the group holding its entry, so a late update for a child
* from an earlier turn revises that turn's row instead of the live one. */
private readonly groupIdByEntry = new Map<string, string>()
private readonly ids = new ClaudeSubagentIds()
/** Set by ANY `task_started`, including one the subagent filter rejects. Once
* this CLI has proven it declares its tasks, child traffic for an id it never
* announced is a nested tool or a grandchild, not a subagent. */
private announcesTasks = false
private readonly now: () => number
constructor(private readonly deps: ClaudeSubagentRosterDeps) {
this.now = deps.now ?? (() => Date.now())
}
/** Consume a `message:system:task_*` frame. Returns false when it is not one. */
observeSystemFrame(message: Record<string, unknown>): boolean {
const frame = readClaudeSubagentTaskFrame(message)
if (!frame) {
return false
}
this.announcesTasks ||= frame.announcement
if (frame.excluded) {
// Child traffic may already have built a provisional row under the tool id;
// the announcement is the first frame that says it is not a subagent.
for (const id of [frame.taskId, frame.toolUseId]) {
if (id !== null) {
this.ids.exclude(id)
this.remove(id)
}
}
return true
}
if (this.ids.isExcluded(frame.taskId, frame.toolUseId)) {
return true
}
if (frame.toolUseId) {
this.ids.alias(frame.toolUseId, frame.taskId)
}
const located =
this.locate(frame.taskId) ??
(frame.toolUseId ? this.adopt(frame.toolUseId, frame.taskId) : null)
if (!located) {
if (frame.announcesSubagent) {
this.create(
frame.taskId,
frame.label,
frame.state ?? 'working',
frame.backgrounded ?? false,
frame.toolUseId
)
}
return true
}
const tracked = located.group.entries.get(frame.taskId)
if (tracked && !applyClaudeSubagentInvocation(tracked, frame, this.now)) {
return true
}
this.revise(located.group, frame.taskId, {
label: frame.label,
state: frame.state,
backgrounded: frame.backgrounded
})
return true
}
/**
* A frame carrying `parent_tool_use_id` — the child's own traffic. It refreshes
* nothing on an announced child; it exists so a CLI release that sends no task
* frames still shows the subagent it is running.
*/
observeChildActivity(parentToolUseId: string): void {
const canonical = this.ids.canonical(parentToolUseId)
if (this.ids.isExcluded(parentToolUseId, canonical)) {
return
}
if (this.locate(canonical)) {
return
}
if (this.announcesTasks) {
// A nested Task, a workflow child, or a grandchild parented to a tool id
// inside the sidechain all reach here. This CLI announces what it spawns,
// so an id it never declared cannot be a subagent — and a row invented for
// one is unlabelled forever and can only ever end `unverifiable`. The
// bounded exclusion set cannot cover an id that was never announced.
return
}
if (!isBoundedClaudeTaskId(canonical)) {
// `claudeTaskId` rejects an over-long announced id rather than truncating
// it; a provisional id becomes the same durable entry key, so it cannot
// enter under a looser rule.
return
}
this.create(canonical, null, 'working', false, parentToolUseId)
}
/**
* The parent turn's tool result for a spawn call. It settles a foreground
* child, whose result IS the turn's evidence the child finished. A backgrounded
* child's spawn call returns immediately while the child keeps running, so its
* result proves nothing and is ignored.
*/
observeToolResult(toolUseId: string, failed: boolean): void {
const canonical = this.ids.canonical(toolUseId)
const located = this.locate(canonical)
if (
!located ||
located.tracked.invocationIds === null ||
located.tracked.backgrounded ||
(located.tracked.toolUseId !== null && located.tracked.toolUseId !== toolUseId)
) {
return
}
this.revise(located.group, canonical, {
label: null,
state: failed ? 'failed' : 'completed',
backgrounded: false
})
}
/**
* The parent turn ended. A foreground child still reported as working will
* never be settled by an event, so it becomes `unverifiable`: contact was
* lost, which is NOT evidence the child exited. A backgrounded child was
* explicitly told to outlive the turn and is left alone.
*/
settleTurn(groupKey: string | null): void {
// Only the group this key names. `OUTSIDE_TURN` belongs to no turn, so an
// unrelated turn ending is no evidence about a child announced outside it.
// `settleSession` reaches what no turn does.
this.sweep(this.groups.get(groupKey ?? OUTSIDE_TURN), false)
}
/** The provider is gone. Nothing more will arrive for any child, backgrounded
* or not, so every one of them loses contact at once. */
settleSession(): void {
for (const group of this.groups.values()) {
this.sweep(group, true)
}
}
dispose(): void {
// Teardown paths reach here without an `ended` event, so a row still
// reporting `working` would have nothing left to revise it. A session that
// did settle first leaves every child terminal, so this writes nothing.
this.settleSession()
this.groups.clear()
this.groupIdByEntry.clear()
this.ids.clear()
this.announcesTasks = false
}
private sweep(group: RosterGroup | undefined, includeBackgrounded: boolean): void {
if (!group) {
return
}
let changed = false
for (const [id, tracked] of group.entries) {
if (isTerminalSubagentState(tracked.entry.state)) {
continue
}
if (tracked.backgrounded && !includeBackgrounded) {
continue
}
group.entries.set(id, {
...tracked,
entry: { ...tracked.entry, state: 'unverifiable', settledAt: this.now() }
})
changed = true
}
if (changed) {
this.write(group)
}
}
private create(
id: string,
label: string | null,
state: NativeChatSubagentEntry['state'],
backgrounded: boolean,
toolUseId: string | null
): void {
const group = this.groupFor()
if (group.admittedEntries >= MAX_SUBAGENTS_PER_GROUP) {
return
}
group.admittedEntries += 1
const now = this.now()
const labelBase = label ?? UNLABELLED_AGENT
group.entries.set(id, {
backgrounded,
toolUseId,
invocationIds: new Set(toolUseId ? [toolUseId] : []),
labelBase,
entry: {
id,
label: claimClaudeSubagentLabel(group, labelBase),
state,
startedAt: now,
...(isTerminalSubagentState(state) ? { settledAt: now } : {})
}
})
this.groupIdByEntry.set(id, group.groupId)
this.write(group)
}
private revise(
group: RosterGroup,
id: string,
change: {
label: string | null
state: NativeChatSubagentEntry['state'] | null
backgrounded: boolean | null
}
): void {
const tracked = group.entries.get(id)
if (!tracked) {
return
}
const next: TrackedEntry = {
...tracked,
backgrounded: change.backgrounded ?? tracked.backgrounded,
entry: { ...tracked.entry }
}
// A provisional row built from child traffic takes the real name the first
// announcement carries; an announced row keeps the name it was given.
if (
change.label &&
tracked.labelBase === UNLABELLED_AGENT &&
change.label !== UNLABELLED_AGENT
) {
next.labelBase = change.label
next.entry.label = claimClaudeSubagentLabel(group, change.label)
}
// Proven outcomes latch; lost contact can still receive a later verdict.
if (change.state && canReplaceSubagentState(tracked.entry.state, change.state)) {
next.entry.state = change.state
if (isTerminalSubagentState(change.state)) {
next.entry.settledAt = this.now()
}
}
group.entries.set(id, next)
this.write(group)
}
/** Re-key a provisional entry from its tool id onto the canonical task id the
* announcement finally named, so the child does not appear twice. */
private adopt(toolUseId: string, taskId: string): { group: RosterGroup } | null {
if (toolUseId === taskId) {
return null
}
const located = this.locate(toolUseId)
if (!located) {
return null
}
located.group.entries.delete(toolUseId)
located.group.entries.set(taskId, {
...located.tracked,
entry: { ...located.tracked.entry, id: taskId }
})
this.groupIdByEntry.delete(toolUseId)
this.groupIdByEntry.set(taskId, located.group.groupId)
return { group: located.group }
}
private remove(id: string): void {
const located = this.locate(id)
if (!located) {
return
}
located.group.entries.delete(id)
this.groupIdByEntry.delete(id)
this.write(located.group)
}
private locate(id: string): { group: RosterGroup; tracked: TrackedEntry } | null {
const groupId = this.groupIdByEntry.get(id)
const group = groupId === undefined ? undefined : this.groups.get(groupId)
const tracked = group?.entries.get(id)
return group && tracked ? { group, tracked } : null
}
private groupFor(): RosterGroup {
const groupId = this.deps.currentGroupKey() ?? OUTSIDE_TURN
const existing = this.groups.get(groupId)
if (existing) {
return existing
}
const group: RosterGroup = {
groupId,
identity: claudeSubagentGroupIdentity(groupId),
entries: new Map(),
admittedEntries: 0,
claimedLabels: new Set(),
lastSerialized: null
}
this.groups.set(groupId, group)
while (this.groups.size > MAX_SUBAGENT_GROUPS) {
const oldest = this.groups.keys().next()
if (oldest.done || oldest.value === groupId) {
break
}
const evicted = this.groups.get(oldest.value)
// Once the group leaves the map nothing can reach its children again —
// not even a session sweep — so contact is lost here.
this.sweep(evicted, true)
for (const id of evicted?.entries.keys() ?? []) {
this.groupIdByEntry.delete(id)
}
this.groups.delete(oldest.value)
}
return group
}
private write(group: RosterGroup): void {
const agents = [...group.entries.values()].map((tracked) => tracked.entry)
const options = { coalescingKey: `claude-subagents:${group.groupId}` }
if (agents.length === 0) {
// The row's last child turned out not to be a subagent. An empty roster is
// not a roster of nothing, so the row goes rather than reading "Ran 0".
if (group.lastSerialized !== null) {
group.lastSerialized = null
this.deps.sink.appendTombstone(group.identity, options)
this.deps.sink.publish()
}
return
}
const body = claudeSubagentGroupBody(group.groupId, agents)
const serialized = JSON.stringify(body)
if (serialized === group.lastSerialized) {
// Nothing changed — a duplicate delivery must not burn a revision.
return
}
group.lastSerialized = serialized
this.deps.sink.appendItem(group.identity, body, options)
// Publish keeps the sink's own coalescing slot: sharing the row's key makes
// each queued publish evict the append it was meant to flush.
this.deps.sink.publish()
}
}
@@ -0,0 +1,201 @@
import { describe, expect, it } from 'vitest'
import { readClaudeSubagentTaskFrame } from './claude-subagent-task-frames'
function system(subtype: string, fields: Record<string, unknown>): Record<string, unknown> {
return { type: 'system', subtype, session_id: 'claude-session', ...fields }
}
describe('readClaudeSubagentTaskFrame', () => {
it('ignores frames that are not task frames', () => {
expect(readClaudeSubagentTaskFrame({ type: 'assistant', subtype: 'task_started' })).toBeNull()
expect(readClaudeSubagentTaskFrame(system('init', { task_id: 'task-1' }))).toBeNull()
expect(readClaudeSubagentTaskFrame(system('task_started', {}))).toBeNull()
expect(readClaudeSubagentTaskFrame(system('task_started', { task_id: '' }))).toBeNull()
})
describe('task_type triage', () => {
it('announces a local_agent task', () => {
const frame = readClaudeSubagentTaskFrame(
system('task_started', {
task_id: 'task-1',
tool_use_id: 'toolu_1',
task_type: 'local_agent',
subagent_type: 'code-reviewer',
description: 'Review the diff'
})
)
expect(frame).toMatchObject({
taskId: 'task-1',
toolUseId: 'toolu_1',
label: 'Review the diff',
announcesSubagent: true,
excluded: false
})
})
it('excludes a backgrounded shell command even though it carries a tool_use_id', () => {
const frame = readClaudeSubagentTaskFrame(
system('task_started', {
task_id: 'task-bash',
tool_use_id: 'toolu_bash',
task_type: 'local_bash',
description: 'sleep 20',
is_backgrounded: true
})
)
expect(frame).toMatchObject({
taskId: 'task-bash',
toolUseId: 'toolu_bash',
announcesSubagent: false,
excluded: true
})
})
it('excludes workflows and monitors', () => {
for (const taskType of ['local_workflow', 'monitor']) {
expect(
readClaudeSubagentTaskFrame(
system('task_started', { task_id: `task-${taskType}`, task_type: taskType })
)
).toMatchObject({ announcesSubagent: false, excluded: true })
}
})
it('caps a subagent_type label the way a description is capped', () => {
const frame = readClaudeSubagentTaskFrame(
system('task_started', { task_id: 'task-1', subagent_type: 'a'.repeat(900) })
)
// The roster stores this label verbatim, so nothing downstream bounds it.
expect(frame?.label).toHaveLength(512)
})
it('falls back to subagent_type only when the release sends no task_type', () => {
expect(
readClaudeSubagentTaskFrame(
system('task_started', { task_id: 'task-old', subagent_type: 'explorer' })
)
).toMatchObject({ announcesSubagent: true, label: 'explorer' })
expect(
readClaudeSubagentTaskFrame(system('task_started', { task_id: 'task-bare' }))
).toMatchObject({ announcesSubagent: false, excluded: true })
// A type this build does not recognise is not an agent on subagent_type's word.
expect(
readClaudeSubagentTaskFrame(
system('task_started', {
task_id: 'task-new',
task_type: 'local_something_new',
subagent_type: 'explorer'
})
)
).toMatchObject({ announcesSubagent: false, excluded: true })
})
it('excludes ambient housekeeping tasks', () => {
for (const suppression of [{ skip_transcript: true }, { ambient: true }]) {
expect(
readClaudeSubagentTaskFrame(
system('task_started', {
task_id: 'task-ambient',
task_type: 'local_agent',
subagent_type: 'watcher',
...suppression
})
)
).toMatchObject({ announcesSubagent: false, excluded: true })
}
})
})
describe('status', () => {
it('collapses every in-flight status to working', () => {
for (const status of ['pending', 'running', 'paused']) {
expect(
readClaudeSubagentTaskFrame(
system('task_updated', { task_id: 'task-1', patch: { status } })
)
).toMatchObject({ state: 'working' })
}
})
it('maps the settled statuses onto the carrier vocabulary', () => {
const mapped: [string, string][] = [
['completed', 'completed'],
['failed', 'failed'],
['killed', 'stopped'],
['stopped', 'stopped']
]
for (const [status, state] of mapped) {
expect(
readClaudeSubagentTaskFrame(
system('task_updated', { task_id: 'task-1', patch: { status } })
)
).toMatchObject({ state })
}
})
it('reports no state for a status it cannot map', () => {
for (const status of ['__proto__', 'toString', 'invented', 7, null]) {
expect(
readClaudeSubagentTaskFrame(
system('task_updated', { task_id: 'task-1', patch: { status } })
)
).toMatchObject({ state: null })
}
})
it('treats progress as no lifecycle verdict', () => {
for (const subtype of ['task_progress']) {
expect(
readClaudeSubagentTaskFrame(
system(subtype, { task_id: 'task-1', status: 'completed', patch: { status: 'failed' } })
)
).toMatchObject({ state: null })
}
})
})
it('reads the notification verdict from its top-level status', () => {
for (const state of ['completed', 'failed', 'stopped']) {
expect(
readClaudeSubagentTaskFrame(
system('task_notification', {
task_id: 'task-1',
status: state,
patch: { status: 'running' }
})
)
).toMatchObject({ state })
}
})
it('reads the backgrounded flag from the frame or its patch', () => {
expect(
readClaudeSubagentTaskFrame(
system('task_started', {
task_id: 'task-1',
task_type: 'local_agent',
is_backgrounded: true
})
)
).toMatchObject({ backgrounded: true })
expect(
readClaudeSubagentTaskFrame(
system('task_updated', { task_id: 'task-1', patch: { is_backgrounded: true } })
)
).toMatchObject({ backgrounded: true })
expect(
readClaudeSubagentTaskFrame(system('task_updated', { task_id: 'task-1', patch: {} }))
).toMatchObject({ backgrounded: null })
})
it('collapses a multi-line description into one bounded label', () => {
expect(
readClaudeSubagentTaskFrame(
system('task_updated', {
task_id: 'task-1',
patch: { description: ' audit\n the lockfile ' }
})
)
).toMatchObject({ label: 'audit the lockfile' })
})
})
@@ -0,0 +1,123 @@
// Claude's declarative task protocol, read as subagent roster events.
//
// `local_agent`, `local_workflow` and `local_bash` tasks all arrive on the same
// `message:system:task_*` channel and ALL carry a `tool_use_id`, so id presence
// discriminates nothing: filtering on it alone puts a backgrounded `sleep 20` in
// the subagent roster. `task_type` is the discriminator, with `subagent_type`
// covering CLI releases that predate it.
import type { NativeChatSubagentState } from '../../shared/native-chat-types'
import {
classifyClaudeBackgroundTaskKind,
claudeTaskDescription,
claudeTaskId,
isBoundedClaudeTaskId
} from './claude-background-task-tracker'
import { claudeRecord, claudeText } from './claude-structured-item-translation'
const TASK_SUBTYPES: ReadonlySet<string> = new Set([
'task_started',
'task_updated',
'task_progress',
'task_notification'
])
/** Provider status → the carrier's vocabulary. `killed` and `stopped` both mean
* the task was deliberately ended, which the carrier calls `stopped`; every
* in-flight status collapses to `working`. A Map, not an object, so a payload
* carrying `__proto__` as its status cannot resolve to an inherited value. */
const TASK_STATES: ReadonlyMap<string, NativeChatSubagentState> = new Map([
['pending', 'working'],
['running', 'working'],
['paused', 'working'],
['completed', 'completed'],
['failed', 'failed'],
['killed', 'stopped'],
['stopped', 'stopped']
] satisfies [string, NativeChatSubagentState][])
export type ClaudeSubagentTaskFrame = {
/** Canonical, resume-stable id — the roster key. */
taskId: string
/** Re-minted when Claude re-announces a resumed task, so it is only an alias. */
toolUseId: string | null
label: string | null
/** null when the frame reported no lifecycle status. */
state: NativeChatSubagentState | null
backgrounded: boolean | null
/** Any `task_started`, subagent or not. Proof this CLI declares its tasks. */
announcement: boolean
/** `task_started` for a task the roster should show. Only an announcement
* creates an entry: an update carries no `task_type`, so honouring one for an
* unknown id would roster whatever else shares this channel. */
announcesSubagent: boolean
/** Ambient housekeeping, or a task that is not a subagent at all. Its ids must
* never reach the roster, by this frame or by later child traffic. */
excluded: boolean
}
/** True when the task Claude announced is a subagent rather than a backgrounded
* shell command or a workflow. */
export function isClaudeSubagentTask(message: Record<string, unknown>): boolean {
if (classifyClaudeBackgroundTaskKind(message.task_type) === 'agent') {
return true
}
// Releases predating `task_type` still name the child in `subagent_type`. A
// task_type Orca does not recognise is NOT covered: it is a type this build
// has no reason to believe is an agent.
return (
(message.task_type === undefined || message.task_type === null) &&
claudeText(message.subagent_type) !== null
)
}
function taskState(value: unknown): NativeChatSubagentState | null {
return typeof value === 'string' ? (TASK_STATES.get(value) ?? null) : null
}
export function readClaudeSubagentTaskFrame(
message: Record<string, unknown>
): ClaudeSubagentTaskFrame | null {
if (message.type !== 'system') {
return null
}
const subtype = claudeText(message.subtype)
if (!subtype || !TASK_SUBTYPES.has(subtype)) {
return null
}
const taskId = claudeTaskId(message)
if (!taskId) {
return null
}
const patch = claudeRecord(message.patch)
const toolUseId = claudeText(message.tool_use_id) ?? claudeText(patch?.tool_use_id)
const announcement = subtype === 'task_started'
// Housekeeping Claude runs for itself; the user never asked for it.
const suppressed = message.ambient === true || message.skip_transcript === true
const subagent = announcement && !suppressed && isClaudeSubagentTask(message)
return {
taskId,
toolUseId: toolUseId && isBoundedClaudeTaskId(toolUseId) ? toolUseId : null,
label:
claudeTaskDescription(message.description) ??
claudeTaskDescription(patch?.description) ??
// Bounded like a description: the roster stores whatever this returns.
(announcement ? (claudeTaskDescription(message.subagent_type) ?? null) : null),
// Notifications carry terminal evidence; progress carries usage only.
state:
subtype === 'task_notification'
? taskState(message.status)
: announcement || subtype === 'task_updated'
? taskState(patch?.status ?? message.status)
: null,
backgrounded:
typeof patch?.is_backgrounded === 'boolean'
? patch.is_backgrounded
: typeof message.is_backgrounded === 'boolean'
? message.is_backgrounded
: null,
announcement,
announcesSubagent: subagent,
excluded: announcement && !subagent
}
}
@@ -0,0 +1,128 @@
import { describe, expect, it } from 'vitest'
import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types'
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
import { CodexBackgroundCommandTracker } from './codex-background-command-tracker'
import { createCodexJournalTranslator } from './codex-structured-journal-translation'
import type { CodexStructuredSessionEvent } from './codex-structured-session-state'
function notification(
method: string,
params: Record<string, unknown>
): Extract<CodexStructuredSessionEvent, { type: 'notification' }> {
return {
type: 'notification',
sessionId: 'session',
threadId: 'root',
method,
params: { threadId: 'root', turnId: 'turn', ...params }
}
}
function command(method: string, id = 'exec', threadId = 'root') {
return {
...notification(method, {
item: {
type: 'commandExecution',
id,
command: 'sleep 30',
source: 'unifiedExecStartup',
status: method === 'item/completed' ? 'completed' : 'inProgress',
exitCode: method === 'item/completed' ? 0 : null
}
}),
threadId
}
}
describe('persistent command ownership', () => {
it('preflights finite metadata capacity and admits work again after process completion', () => {
const tracker = new CodexBackgroundCommandTracker('root', 700)
const first = command('item/started', 'first')
const second = command('item/started', 'second')
expect(tracker.canObserve(first)).toBe(true)
tracker.observe(first)
expect(tracker.canObserve(second)).toBe(false)
expect(() => tracker.observe(second)).toThrow('not admitted')
expect(tracker.tasks()).toHaveLength(1)
expect(tracker.retainedMetadataBytes).toBeLessThanOrEqual(700)
tracker.observe(command('item/completed', 'first'))
expect(tracker.canObserve(second)).toBe(true)
tracker.observe(second)
expect(tracker.tasks()).toHaveLength(1)
expect(tracker.retainedMetadataBytes).toBeLessThanOrEqual(700)
tracker.clear()
expect(tracker.retainedMetadataBytes).toBe(0)
})
it('keeps the journal running across turn completion and accepts late output and exit', () => {
const rows: { key: string; body: AgentJournalItemBody }[] = []
const translator = createCodexJournalTranslator({
primaryThreadId: () => 'root',
sink: {
appendItem: (identity, body) => rows.push({ key: agentJournalItemKey(identity), body }),
appendTombstone: () => {},
publish: () => {}
}
})
const tracker = new CodexBackgroundCommandTracker('root')
const deliver = (event: Extract<CodexStructuredSessionEvent, { type: 'notification' }>) => {
expect(translator.handle(event)).toEqual({ accepted: true })
tracker.observe(event)
}
deliver(notification('turn/started', { turn: { id: 'turn' } }))
deliver(command('item/started'))
const originalKey = rows.find(({ body }) => body.kind === 'tool-call')?.key
deliver(notification('turn/completed', { turn: { id: 'turn' } }))
expect(rows.filter(({ body }) => body.kind === 'tool-call').map(({ body }) => body)).toEqual([
expect.objectContaining({ state: 'running' })
])
expect(tracker.tasks()).toHaveLength(1)
deliver(
notification('item/commandExecution/outputDelta', { itemId: 'exec', delta: 'late output' })
)
translator.flush()
expect(rows.at(-1)).toMatchObject({ key: originalKey, body: { state: 'running' } })
deliver(command('item/completed'))
expect(rows.at(-1)).toMatchObject({ key: originalKey, body: { state: 'completed' } })
expect(tracker.tasks()).toEqual([])
translator.dispose()
})
it('counts child shells only after the child stops covering them, without resurrecting exits', () => {
const tracker = new CodexBackgroundCommandTracker('root')
tracker.observe(command('item/started', 'child-exec', 'child'))
tracker.observe(
notification('item/started', {
item: {
type: 'commandExecution',
id: 'poll',
source: 'unifiedExecInteraction',
status: 'inProgress'
}
})
)
expect(tracker.tasks(new Set(['child']))).toEqual([])
expect(tracker.tasks()).toEqual([
{ id: 'codex-command:thread:child:child-exec', kind: 'command', description: 'sleep 30' }
])
tracker.observe(command('item/completed', 'child-exec', 'child'))
tracker.observe(command('item/started'))
tracker.observe(command('item/completed'))
tracker.observe(command('item/started'))
expect(tracker.tasks()).toEqual([])
})
it('retains live commands while recycling bounded settled history', () => {
const tracker = new CodexBackgroundCommandTracker('root')
tracker.observe(command('item/started', 'long-lived'))
for (let index = 0; index < 300; index += 1) {
tracker.observe(command('item/started', `short-${index}`))
tracker.observe(command('item/completed', `short-${index}`))
}
expect(tracker.tasks()).toEqual([
{ id: 'codex-command:primary:long-lived', kind: 'command', description: 'sleep 30' }
])
tracker.clear()
expect(tracker.tasks()).toEqual([])
})
})
@@ -0,0 +1,150 @@
import type { AgentSessionBackgroundTask } from '../../shared/agent-session-wire'
import type { CodexBackgroundTaskEvent } from './codex-background-task-frames'
import { codexCommandOutlivesTurn } from './codex-command-lifecycle'
import { readRecord, readString } from './codex-item-field-readers'
import { readCodexThreadItem } from './codex-structured-item-translation'
import { MAX_CODEX_ITEM_STREAM_METADATA_BYTES } from './codex-item-stream-retention'
const MAX_SETTLED_COMMANDS = 128
const MAX_DESCRIPTION_CHARS = 512
type Command = { threadId: string; task: AgentSessionBackgroundTask; bytes: number }
/** Stays within the retained bound, so read-time qualification cannot outgrow admission. */
function qualifiedDescription(label: string, description: string | undefined): string {
return (description ? `${label} — ${description}` : label).slice(0, MAX_DESCRIPTION_CHARS)
}
export class CodexBackgroundCommandTracker {
private readonly commands = new Map<string, Command>()
private readonly settled = new Map<string, number>()
private liveBytes = 0
private settledBytes = 0
constructor(
private readonly primaryThreadId: string,
private readonly maxMetadataBytes = MAX_CODEX_ITEM_STREAM_METADATA_BYTES
) {}
get retainedMetadataBytes(): number {
return this.liveBytes + this.settledBytes
}
canObserve(event: CodexBackgroundTaskEvent): boolean {
const parsed = this.parse(event)
return (
!parsed ||
parsed.completed ||
this.commands.has(parsed.key) ||
this.settled.has(parsed.key) ||
this.liveBytes + parsed.command.bytes <= this.maxMetadataBytes
)
}
observe(event: CodexBackgroundTaskEvent): void {
const parsed = this.parse(event)
if (!parsed || this.settled.has(parsed.key)) {
return
}
const { key, command, completed } = parsed
const existing = this.commands.get(key)
if (completed) {
if (existing) {
this.liveBytes -= existing.bytes
this.commands.delete(key)
}
const bytes = Buffer.byteLength(key, 'utf8') + 256
if (this.liveBytes + bytes <= this.maxMetadataBytes) {
this.settled.set(key, bytes)
this.settledBytes += bytes
}
this.trimSettled()
return
}
if (existing) {
return
}
if (this.liveBytes + command.bytes > this.maxMetadataBytes) {
throw new Error('Codex command metadata was not admitted before observation')
}
this.commands.set(key, command)
this.liveBytes += command.bytes
this.trimSettled()
}
tasks(
coveredThreads?: ReadonlySet<string>,
childLabel?: (threadId: string) => string | null
): AgentSessionBackgroundTask[] {
return [...this.commands.values()]
.filter((command) => !coveredThreads?.has(command.threadId))
.map(({ threadId, task }) => {
// The agent row carrying the child's name is gone by the time this row shows;
// unqualified it reads as a bare shell string with no owner. Resolved on read so
// a label registered after the command still lands.
const label = threadId === this.primaryThreadId ? null : childLabel?.(threadId)
return label
? { ...task, description: qualifiedDescription(label, task.description) }
: task
})
}
clear(): void {
this.commands.clear()
this.settled.clear()
this.liveBytes = 0
this.settledBytes = 0
}
private trimSettled(): void {
while (
this.settled.size > MAX_SETTLED_COMMANDS ||
this.retainedMetadataBytes > this.maxMetadataBytes
) {
const oldest = this.settled.entries().next().value
if (!oldest) {
break
}
this.settled.delete(oldest[0])
this.settledBytes -= oldest[1]
}
}
private parse(
event: CodexBackgroundTaskEvent
): { key: string; command: Command; completed: boolean } | null {
if (event.method !== 'item/started' && event.method !== 'item/completed') {
return null
}
const item = readCodexThreadItem(readRecord(event.params).item)
if (!item || !codexCommandOutlivesTurn(item)) {
return null
}
const key = JSON.stringify([event.threadId, item.id])
const completed = event.method === 'item/completed' || item.status !== 'inProgress'
const description = readString(item, 'command')
?.slice(0, MAX_DESCRIPTION_CHARS)
.replace(/\s+/g, ' ')
.trim()
const value = {
threadId: event.threadId,
task: {
id:
event.threadId === this.primaryThreadId
? `codex-command:primary:${encodeURIComponent(item.id)}`
: `codex-command:thread:${encodeURIComponent(event.threadId)}:${encodeURIComponent(item.id)}`,
kind: 'command' as const,
...(description ? { description } : {})
}
}
return {
key,
completed,
command: {
...value,
bytes:
Buffer.byteLength(key, 'utf8') + Buffer.byteLength(JSON.stringify(value), 'utf8') + 256
}
}
}
}
@@ -0,0 +1,72 @@
import type { NativeChatSubagentState } from '../../shared/native-chat-types'
import {
codexSubagentLabel,
isCodexRootAgentActivity,
readCodexSubagentActivity
} from './codex-subagent-activity'
import { codexChildTurnState } from './codex-subagent-executions'
import { readRecord } from './codex-item-field-readers'
import { readCodexThreadItem } from './codex-structured-item-translation'
import { readCodexTurnId } from './codex-structured-thread-facts'
export type CodexBackgroundTaskFrame =
| {
kind: 'subagent'
agentThreadId: string
label: string | null
parentTurnId: string | null | undefined
}
| {
kind: 'turn'
threadId: string
turnId: string
state: NativeChatSubagentState
}
export type CodexBackgroundTaskEvent = {
method: string
threadId: string
params: unknown
}
export function readCodexBackgroundTaskFrame(
event: CodexBackgroundTaskEvent,
primaryThreadId: string
): CodexBackgroundTaskFrame | null {
if (event.method === 'turn/started' || event.method === 'turn/completed') {
const turnId = readCodexTurnId(event.params)
if (turnId === null) {
return null
}
return {
kind: 'turn',
threadId: event.threadId,
turnId,
state:
event.method === 'turn/started'
? 'working'
: codexChildTurnState(readRecord(readRecord(event.params).turn).status)
}
}
if (event.method !== 'item/started' && event.method !== 'item/completed') {
return null
}
const item = readCodexThreadItem(readRecord(event.params).item)
const activity = item && readCodexSubagentActivity(item)
if (
!activity ||
activity.agentThreadId === primaryThreadId ||
isCodexRootAgentActivity(activity)
) {
return null
}
return {
kind: 'subagent',
agentThreadId: activity.agentThreadId,
label: codexSubagentLabel(activity),
parentTurnId:
activity.kind === 'started' || activity.kind === 'interacted'
? readCodexTurnId(event.params)
: undefined
}
}
@@ -0,0 +1,280 @@
import { describe, expect, it } from 'vitest'
import { CodexBackgroundTaskTracker } from './codex-background-task-tracker'
import {
readCodexBackgroundTaskFrame,
type CodexBackgroundTaskEvent
} from './codex-background-task-frames'
const PRIMARY = 'parent-thread'
const PARENT_TURN = 'parent-turn'
const CHILD = 'child-thread'
const CHILD_TURN = 'child-turn'
function turn(
method: 'turn/started' | 'turn/completed',
threadId: string,
turnId: string,
status = 'completed'
): CodexBackgroundTaskEvent {
return { method, threadId, params: { threadId, turn: { id: turnId, status } } }
}
function activity(
kind = 'started',
parentTurn = PARENT_TURN,
child = CHILD
): CodexBackgroundTaskEvent {
return {
method: 'item/started',
threadId: PRIMARY,
params: {
threadId: PRIMARY,
turnId: parentTurn,
item: {
type: 'subAgentActivity',
id: `activity-${kind}`,
kind,
agentThreadId: child,
agentPath: '/root/count_a'
}
}
}
}
function runningChild(): CodexBackgroundTaskTracker {
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
tracker.observe(turn('turn/started', PRIMARY, PARENT_TURN))
tracker.observe(turn('turn/started', CHILD, CHILD_TURN))
tracker.observe(activity())
return tracker
}
function command(threadId = PRIMARY, method = 'item/started'): CodexBackgroundTaskEvent {
return {
method,
threadId,
params: {
threadId,
turnId: PARENT_TURN,
item: {
type: 'commandExecution',
id: 'exec-1',
processId: '71831',
source: 'unifiedExecStartup',
command: 'sleep 90',
status: method === 'item/started' ? 'inProgress' : 'completed'
}
}
}
}
describe('readCodexBackgroundTaskFrame', () => {
it('reads activity as child metadata without inferring execution state', () => {
expect(readCodexBackgroundTaskFrame(activity('interacted'), PRIMARY)).toEqual({
kind: 'subagent',
agentThreadId: CHILD,
label: 'count_a',
parentTurnId: PARENT_TURN
})
})
it('reads a child turn with its own execution identity', () => {
expect(readCodexBackgroundTaskFrame(turn('turn/started', CHILD, CHILD_TURN), PRIMARY)).toEqual({
kind: 'turn',
threadId: CHILD,
turnId: CHILD_TURN,
state: 'working'
})
})
it('does not register the primary thread even when its activity path is missing', () => {
const event = activity('interacted', PARENT_TURN, PRIMARY)
;(event.params as { item: { agentPath?: string } }).item.agentPath = undefined
expect(readCodexBackgroundTaskFrame(event, PRIMARY)).toBeNull()
})
})
describe('CodexBackgroundTaskTracker child execution ownership', () => {
it('does not claim work from an activity item without a child turn', () => {
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
tracker.observe(activity())
tracker.observe(activity('interacted'))
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
expect(tracker.state).toBeNull()
})
it('reports an executing child only after the foreground turn ends', () => {
const tracker = runningChild()
expect(tracker.state).toBeNull()
expect(tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))).toBe(true)
expect(tracker.state).toEqual({
state: 'monitoring',
supportsStopAll: false,
tasks: [{ id: `codex-agent:${CHILD}`, kind: 'agent', description: 'count_a' }]
})
})
it('never settles a child when a primary turn ends', () => {
const tracker = runningChild()
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
for (let index = 0; index < 300; index++) {
expect(tracker.observe(turn('turn/completed', PRIMARY, `later-${index}`))).toBe(false)
}
expect(tracker.state?.tasks).toHaveLength(1)
})
it.each(['completed', 'interrupted', 'failed'])(
'settles on the matching child turn %s',
(status) => {
const tracker = runningChild()
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
expect(tracker.observe(turn('turn/completed', CHILD, CHILD_TURN, status))).toBe(true)
expect(tracker.state).toBeNull()
}
)
it('does not mistake late activity completion for the current child execution', () => {
const tracker = runningChild()
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
tracker.observe(activity('completed'))
expect(tracker.state?.tasks).toHaveLength(1)
})
it.each([PARENT_TURN, 'followup-parent'])(
'reports follow-up work in %s using the new child turn',
(parentTurn) => {
const tracker = runningChild()
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
tracker.observe(turn('turn/started', PRIMARY, parentTurn))
tracker.observe(activity('interacted', parentTurn))
expect(tracker.state).toBeNull()
tracker.observe(turn('turn/started', CHILD, 'followup-child-turn'))
tracker.observe(turn('turn/completed', PRIMARY, parentTurn))
expect(tracker.state?.tasks).toHaveLength(1)
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))
tracker.observe(turn('turn/started', CHILD, CHILD_TURN))
tracker.observe(activity('completed'))
expect(tracker.state?.tasks).toHaveLength(1)
tracker.observe(turn('turn/completed', CHILD, 'followup-child-turn'))
expect(tracker.state).toBeNull()
}
)
it('keeps idle send_message activity out of the strip', () => {
const tracker = runningChild()
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
tracker.observe(activity('interacted', 'message-parent'))
tracker.observe(turn('turn/completed', PRIMARY, 'message-parent'))
expect(tracker.state).toBeNull()
})
it('does not invent another execution for a message to a working child', () => {
const tracker = runningChild()
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
tracker.observe(activity('interacted', 'message-parent'))
tracker.observe(turn('turn/completed', PRIMARY, 'message-parent'))
expect(tracker.state?.tasks).toHaveLength(1)
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))
expect(tracker.state).toBeNull()
})
it('retains completion delivered before child registration', () => {
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
tracker.observe(turn('turn/started', CHILD, CHILD_TURN))
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))
tracker.observe(activity())
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
expect(tracker.state).toBeNull()
})
it('publishes no extra state for duplicate owner or metadata events', () => {
const tracker = runningChild()
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
expect(tracker.observe(turn('turn/started', CHILD, CHILD_TURN))).toBe(false)
expect(tracker.observe({ ...activity(), method: 'item/completed' })).toBe(false)
expect(tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))).toBe(true)
expect(tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))).toBe(false)
})
it('bounds retained child history while allowing repeated completed runs', () => {
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
tracker.observe(activity())
for (let index = 0; index < 300; index++) {
const id = `child-turn-${index}`
tracker.observe(turn('turn/started', CHILD, id))
expect(tracker.state?.tasks).toHaveLength(1)
tracker.observe(turn('turn/completed', CHILD, id))
expect(tracker.state).toBeNull()
}
})
it('clears the roster at session teardown', () => {
const tracker = runningChild()
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
expect(tracker.clear()).toBe(true)
expect(tracker.state).toBeNull()
expect(tracker.clear()).toBe(false)
})
})
describe('CodexBackgroundTaskTracker command integration', () => {
it('keeps a primary shell visible after the turn until its own completion', () => {
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
tracker.observe(turn('turn/started', PRIMARY, PARENT_TURN))
tracker.observe(command())
expect(tracker.state).toBeNull()
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
expect(tracker.state?.tasks).toEqual([
{ id: 'codex-command:primary:exec-1', kind: 'command', description: 'sleep 90' }
])
tracker.observe(command(PRIMARY, 'item/completed'))
expect(tracker.state).toBeNull()
})
it('reveals a child shell only after the child execution finishes', () => {
const tracker = runningChild()
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
tracker.observe(command(CHILD))
expect(tracker.state?.tasks).toHaveLength(1)
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN, 'interrupted'))
expect(tracker.state?.tasks).toEqual([
{
id: `codex-command:thread:${CHILD}:exec-1`,
kind: 'command',
description: 'count_a — sleep 90'
}
])
tracker.observe(command(CHILD, 'item/completed'))
expect(tracker.state).toBeNull()
})
it('leaves a primary shell unqualified', () => {
const tracker = runningChild()
tracker.observe(command(PRIMARY))
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
expect(tracker.state?.tasks).toContainEqual({
id: 'codex-command:primary:exec-1',
kind: 'command',
description: 'sleep 90'
})
})
it('names a child shell whose label only arrives after the command', () => {
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
tracker.observe(turn('turn/started', PRIMARY, PARENT_TURN))
tracker.observe(turn('turn/started', CHILD, CHILD_TURN))
tracker.observe(command(CHILD))
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
tracker.observe(activity())
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))
expect(tracker.state?.tasks).toEqual([
{
id: `codex-command:thread:${CHILD}:exec-1`,
kind: 'command',
description: 'count_a — sleep 90'
}
])
})
})
@@ -0,0 +1,96 @@
import type {
AgentSessionBackgroundTask,
AgentSessionBackgroundTaskState
} from '../../shared/agent-session-wire'
import {
readCodexBackgroundTaskFrame,
type CodexBackgroundTaskEvent
} from './codex-background-task-frames'
import { CodexSubagentExecutions } from './codex-subagent-executions'
import { CodexBackgroundCommandTracker } from './codex-background-command-tracker'
import { boundSubagentField } from './codex-subagent-group-body'
/** Projects the same child execution facts the durable roster consumes. */
export class CodexBackgroundTaskTracker {
private primaryTurnId: string | null = null
private publishedFingerprint = '[]'
private publishedState: AgentSessionBackgroundTaskState | null = null
private readonly commands: CodexBackgroundCommandTracker
constructor(
private readonly primaryThreadId: string,
private readonly executions = new CodexSubagentExecutions()
) {
this.commands = new CodexBackgroundCommandTracker(primaryThreadId)
}
get state(): AgentSessionBackgroundTaskState | null {
// Journal admission precedes observe; readers must not see its pending facts.
return this.publishedState
}
canObserve(event: CodexBackgroundTaskEvent): boolean {
return this.commands.canObserve(event)
}
observe(event: CodexBackgroundTaskEvent): boolean {
const itemEvent = event.method === 'item/started' || event.method === 'item/completed'
if (itemEvent) {
this.commands.observe(event)
}
const frame = readCodexBackgroundTaskFrame(event, this.primaryThreadId)
if (!frame) {
return itemEvent ? this.refresh() : false
}
if (frame.kind === 'subagent') {
this.executions.register(frame.agentThreadId, frame.label, frame.parentTurnId)
} else if (frame.threadId === this.primaryThreadId) {
if (frame.state === 'working') {
this.primaryTurnId = frame.turnId
} else if (frame.turnId === this.primaryTurnId) {
this.primaryTurnId = null
}
} else {
this.executions.observeTurn(frame.threadId, frame.turnId, frame.state)
}
return this.refresh()
}
clear(): boolean {
this.executions.clear()
this.commands.clear()
this.primaryTurnId = null
return this.refresh()
}
private tasks(): AgentSessionBackgroundTask[] {
if (this.primaryTurnId !== null) {
return []
}
const children = this.executions.workingChildren()
const agents: AgentSessionBackgroundTask[] = children.map((child, index) => ({
id: `codex-agent:${child.agentThreadId}`,
kind: 'agent',
...(child.label ? { description: boundSubagentField(child.label, index) } : {})
}))
return [
...agents,
...this.commands.tasks(new Set(children.map((child) => child.agentThreadId)), (threadId) =>
this.executions.label(threadId)
)
]
}
private refresh(): boolean {
const tasks = this.tasks()
const fingerprint = JSON.stringify(tasks)
if (fingerprint === this.publishedFingerprint) {
return false
}
this.publishedFingerprint = fingerprint
this.publishedState = tasks.length
? { state: 'monitoring', tasks, supportsStopAll: false }
: null
return true
}
}
@@ -0,0 +1,6 @@
import type { CodexThreadItem } from './codex-structured-item-translation'
/** Persistent exec has its own process-exit notification, independent of a turn. */
export function codexCommandOutlivesTurn(item: CodexThreadItem): boolean {
return item.type === 'commandExecution' && item.source === 'unifiedExecStartup'
}
@@ -0,0 +1,107 @@
import { codexCommandOutlivesTurn } from './codex-command-lifecycle'
import {
MAX_CODEX_ITEM_STREAM_ITEM_BYTES,
MAX_CODEX_ITEM_STREAM_STATES
} from './codex-structured-item-stream-bounds'
import type { CodexItemStreamState } from './codex-structured-item-stream-contracts'
// Preserve the previous metadata ceiling while letting small live commands share it.
export const MAX_CODEX_ITEM_STREAM_METADATA_BYTES =
MAX_CODEX_ITEM_STREAM_STATES * MAX_CODEX_ITEM_STREAM_ITEM_BYTES
type RetainedState = { state: CodexItemStreamState; bytes: number; persistent: boolean }
export class CodexItemStreamRetention {
private readonly states = new Map<string, RetainedState>()
private bytes = 0
private persistentBytes = 0
private persistentCount = 0
constructor(private readonly maxBytes = MAX_CODEX_ITEM_STREAM_METADATA_BYTES) {}
get retainedBytes(): number {
return this.bytes
}
get size(): number {
return this.states.size
}
get persistentSize(): number {
return this.persistentCount
}
get overCapacity(): boolean {
return (
this.bytes > this.maxBytes ||
this.states.size - this.persistentCount > MAX_CODEX_ITEM_STREAM_STATES
)
}
get(key: string): CodexItemStreamState | undefined {
return this.states.get(key)?.state
}
isPersistent(key: string): boolean {
return this.states.get(key)?.persistent === true
}
canRetain(key: string, state: CodexItemStreamState): boolean {
const previous = this.states.get(key)
return (
this.persistentBytes -
(previous?.persistent ? previous.bytes : 0) +
this.stateBytes(key, state) <=
this.maxBytes
)
}
retain(key: string, state: CodexItemStreamState): boolean {
if (!this.canRetain(key, state)) {
return false
}
this.forget(key)
const bytes = this.stateBytes(key, state)
const persistent = codexCommandOutlivesTurn(state.item)
this.states.set(key, { state, bytes, persistent })
this.bytes += bytes
if (persistent) {
this.persistentBytes += bytes
this.persistentCount += 1
}
return true
}
oldestEvictable(): string | undefined {
for (const [key, entry] of this.states) {
if (!entry.persistent) {
return key
}
}
return undefined
}
forget(key: string): void {
const entry = this.states.get(key)
if (!entry) {
return
}
this.bytes -= entry.bytes
if (entry.persistent) {
this.persistentBytes -= entry.bytes
this.persistentCount -= 1
}
this.states.delete(key)
}
clear(): void {
this.states.clear()
this.bytes = 0
this.persistentBytes = 0
this.persistentCount = 0
}
private stateBytes(key: string, state: CodexItemStreamState): number {
return Buffer.byteLength(key, 'utf8') + Buffer.byteLength(JSON.stringify(state), 'utf8') + 256
}
}
@@ -0,0 +1,219 @@
import { describe, expect, it, vi } from 'vitest'
import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types'
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
import { CodexBackgroundCommandTracker } from './codex-background-command-tracker'
import {
CodexItemStreamRetention,
MAX_CODEX_ITEM_STREAM_METADATA_BYTES
} from './codex-item-stream-retention'
import { CodexJournalItems } from './codex-structured-journal-items'
import { settleCodexJournalTurn } from './codex-structured-journal-settlement'
function command(threadId: string, id: string, method = 'item/started') {
return {
threadId,
method,
params: {
turnId: 'turn',
item: {
type: 'commandExecution',
id,
source: 'unifiedExecStartup',
command: `sleep 30 # ${threadId}/${id}`,
cwd: '/workspace',
status: method === 'item/completed' ? 'completed' : 'inProgress',
...(method === 'item/completed' ? { exitCode: 0, aggregatedOutput: 'BEFORE\nAFTER\n' } : {})
}
}
}
}
function fixture(maxMetadataBytes?: number) {
const rows = new Map<string, AgentJournalItemBody>()
const scheduled = new Set<() => void>()
const sink = {
appendItem: (
identity: Parameters<typeof agentJournalItemKey>[0],
body: AgentJournalItemBody
) => {
rows.set(agentJournalItemKey(identity), body)
},
appendTombstone() {},
publish() {}
}
const items = new CodexJournalItems(
{
sink,
maxMetadataBytes,
schedule: (run) => {
scheduled.add(run)
return () => {
scheduled.delete(run)
}
}
},
() => 'turn',
() => {}
)
return { items, sink, rows, scheduled }
}
describe('persistent command retention', () => {
it('does not rebuild unchanged persistent output on every later lifecycle flush', () => {
const { items } = fixture()
const event = command('root', 'quiet')
items.handle(event)
items.streams.handle('root', 'item/commandExecution/outputDelta', {
itemId: 'quiet',
delta: 'retained-prefix'
})
items.streams.flush()
const originalJoin = Array.prototype.join
let retainedJoins = 0
const spy = vi
.spyOn(Array.prototype, 'join')
.mockImplementation(function (this: unknown[], separator) {
if (this[0] === 'retained-prefix') {
retainedJoins += 1
}
return originalJoin.call(this, separator)
})
try {
for (let index = 0; index < 100; index += 1) {
items.streams.flush()
}
} finally {
spy.mockRestore()
items.dispose()
}
expect(retainedJoins).toBe(0)
})
it('retains 448 live commands through completed turns, late output, and process completion', () => {
const { items, sink, rows, scheduled } = fixture()
const tracker = new CodexBackgroundCommandTracker('thread-0')
const events = Array.from({ length: 7 }, (_, thread) =>
Array.from({ length: 64 }, (_, index) => command(`thread-${thread}`, `exec-${index}`))
).flat()
for (const event of events) {
expect(tracker.canObserve(event)).toBe(true)
expect(items.handle(event)).toMatchObject({ admission: { accepted: true } })
tracker.observe(event)
expect(
items.streams.handle(event.threadId, 'item/commandExecution/outputDelta', {
turnId: 'turn',
itemId: event.params.item.id,
delta: 'BEFORE\n'
}).admission
).toEqual({ accepted: true })
}
for (let thread = 0; thread < 7; thread += 1) {
expect(
settleCodexJournalTurn({
sessionId: 'session',
threadId: `thread-${thread}`,
turnId: 'turn',
sink,
streams: items.streams,
activeItems: items.activeItems
})
).toEqual({ accepted: true })
}
expect(items.activeItems.size).toBe(448)
expect(items.streams.persistentCount).toBe(448)
expect(tracker.tasks()).toHaveLength(448)
expect(tracker.retainedMetadataBytes).toBeLessThan(256 * 1024)
for (const event of events) {
items.streams.handle(event.threadId, 'item/commandExecution/outputDelta', {
turnId: 'turn',
itemId: event.params.item.id,
delta: 'AFTER\n'
})
}
expect(items.streams.flush()).toBe(true)
for (const event of events) {
const key = agentJournalItemKey({
provider: 'orca',
clientMessageId: `codex-item:${event.threadId}:${event.params.item.id}`
})
expect(rows.get(key)).toMatchObject({
state: 'running',
input: { command: event.params.item.command, cwd: '/workspace' },
output: { head: 'BEFORE\nAFTER\n' }
})
const completed = command(event.threadId, event.params.item.id, 'item/completed')
expect(items.handle(completed)).toMatchObject({ admission: { accepted: true } })
tracker.observe(completed)
expect(rows.get(key)).toMatchObject({
state: 'completed',
output: { head: 'BEFORE\nAFTER\n' }
})
expect(items.streams.snapshot(event.threadId, event.params.item.id)).toBeNull()
}
expect(items.activeItems.size).toBe(0)
expect(items.streams.persistentCount).toBe(0)
expect(tracker.tasks()).toEqual([])
expect(tracker.retainedMetadataBytes).toBeLessThan(64 * 1024)
items.dispose()
tracker.clear()
expect(tracker.retainedMetadataBytes).toBe(0)
expect(scheduled.size).toBe(0)
})
it('rejects command metadata exhaustion before appending or evicting live state and frees it on completion', () => {
const { items, rows } = fixture(800)
const first = command('root', 'first')
const second = command('root', 'second')
expect(items.handle(first)).toMatchObject({ admission: { accepted: true } })
const prior = [...rows]
expect(items.handle(second)).toMatchObject({ admission: { accepted: false, reason: 'failed' } })
expect([...rows]).toEqual(prior)
expect(items.activeItems.size).toBe(1)
expect(items.handle(command('root', 'first', 'item/completed'))).toMatchObject({
admission: { accepted: true }
})
expect(items.handle(second)).toMatchObject({ admission: { accepted: true } })
items.dispose()
})
it('accounts metadata bytes instead of interpreting the item count as liveness', () => {
const retention = new CodexItemStreamRetention()
for (let index = 0; index < 448; index += 1) {
const item = command('root', `exec-${index}`).params.item
expect(
retention.retain(item.id, {
item,
identity: { provider: 'orca', clientMessageId: item.id }
})
).toBe(true)
}
expect(retention.size).toBe(448)
expect(retention.retainedBytes).toBeLessThan(256 * 1024)
expect(retention.retainedBytes).toBeLessThan(MAX_CODEX_ITEM_STREAM_METADATA_BYTES)
expect(retention.overCapacity).toBe(false)
expect(retention.oldestEvictable()).toBeUndefined()
retention.clear()
expect(retention.retainedBytes).toBe(0)
expect(retention.persistentSize).toBe(0)
})
it('retains startup provenance when large command metadata is bounded', () => {
const { items, sink } = fixture()
const event = command('root', 'large')
event.params.item.command = 'x'.repeat(128 * 1024)
expect(items.handle(event)).toMatchObject({ admission: { accepted: true } })
expect(
settleCodexJournalTurn({
sessionId: 'session',
threadId: 'root',
turnId: 'turn',
sink,
streams: items.streams,
activeItems: items.activeItems
})
).toEqual({ accepted: true })
expect(items.activeItems.size).toBe(1)
expect(items.streams.persistentCount).toBe(1)
items.dispose()
})
})
@@ -30,6 +30,7 @@ export function boundStreamItem(item: Record<string, unknown>): Record<string, u
return {
type: item.type,
id: item.id,
...(typeof item.source === 'string' ? { source: item.source } : {}),
...(typeof item.command === 'string' ? { command: item.command.slice(0, 4096) } : {}),
...(typeof item.cwd === 'string' ? { cwd: item.cwd.slice(0, 4096) } : {}),
...(typeof item.status === 'string' ? { status: item.status } : {}),
@@ -13,6 +13,7 @@ export type CodexItemStreamDeps = {
coalesceMs?: number
maxRetainedBytes?: number
maxTotalRetainedBytes?: number
maxMetadataBytes?: number
schedule?: AgentSessionDeltaCoalescerDeps['schedule']
}
@@ -36,7 +37,9 @@ export type CodexStructuredItemStreamHandleResult = {
}
export type CodexStructuredItemStreams = {
track: (threadId: string, item: CodexThreadItem, identity: AgentJournalItemIdentity) => void
readonly persistentCount: number
canTrack: (threadId: string, item: CodexThreadItem, identity: AgentJournalItemIdentity) => boolean
track: (threadId: string, item: CodexThreadItem, identity: AgentJournalItemIdentity) => boolean
handle: (
threadId: string,
method: string,
+40 -28
View File
@@ -1,5 +1,6 @@
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
import { createAgentSessionDeltaCoalescer } from '../native-chat/agent-session-wire/agent-session-delta-coalescer'
import { CodexItemStreamRetention } from './codex-item-stream-retention'
import {
codexJournalItem,
codexStreamingJournalItem,
@@ -10,7 +11,6 @@ import {
MAX_CODEX_ITEM_STREAM_PENDING_PATCHES,
MAX_CODEX_ITEM_STREAM_PENDING_PATCH_BYTES,
MAX_CODEX_ITEM_STREAM_RETAINED_BYTES,
MAX_CODEX_ITEM_STREAM_STATES,
boundStreamItem,
pendingPatchBytes
} from './codex-structured-item-stream-bounds'
@@ -47,8 +47,9 @@ export {
export function createCodexStructuredItemStreams(
deps: CodexItemStreamDeps
): CodexStructuredItemStreams {
const states = new Map<string, CodexItemStreamState>()
const states = new CodexItemStreamRetention(deps.maxMetadataBytes)
const checkpointLengths = new Map<string, number>()
const pendingCheckpoints = new Set<string>()
// Patch updates are authoritative item snapshots. Keep the latest rejected
// snapshot until the journal admits it; unlike streamed deltas, there is no
// coalescer timer to retry these events for us.
@@ -57,8 +58,9 @@ export function createCodexStructuredItemStreams(
const forgetState = (key: string): void => {
coalescer.forget(key)
states.delete(key)
states.forget(key)
checkpointLengths.delete(key)
pendingCheckpoints.delete(key)
const pending = pendingPatches.get(key)
if (pending) {
retainedPatchBytes = Math.max(0, retainedPatchBytes - pendingPatchBytes(pending))
@@ -67,8 +69,8 @@ export function createCodexStructuredItemStreams(
}
const trimStates = (): void => {
while (states.size > MAX_CODEX_ITEM_STREAM_STATES) {
const oldest = states.keys().next().value
while (states.overCapacity) {
const oldest = states.oldestEvictable()
if (typeof oldest !== 'string') {
break
}
@@ -124,6 +126,7 @@ export function createCodexStructuredItemStreams(
const state = states.get(key)
if (state && append(state, text)) {
checkpointLengths.set(key, text.length)
pendingCheckpoints.delete(key)
return true
}
return false
@@ -133,6 +136,7 @@ export function createCodexStructuredItemStreams(
windowMs: deps.coalesceMs,
maxRetainedBytes: deps.maxRetainedBytes,
maxTotalRetainedBytes: deps.maxTotalRetainedBytes,
isProtected: (key) => states.isPersistent(key),
schedule: deps.schedule,
emit: (key, text) => {
return persist(key, text, false)
@@ -144,7 +148,7 @@ export function createCodexStructuredItemStreams(
itemId: string,
type: string,
params: unknown
): CodexItemStreamState => {
): CodexItemStreamState | null => {
const key = codexStructuredItemKey(threadId, itemId)
const existing = states.get(key)
if (existing) {
@@ -152,36 +156,25 @@ export function createCodexStructuredItemStreams(
}
const item = { type, id: itemId }
const state = { item, identity: deps.identityFor(threadId, params, item) }
states.set(key, state)
if (!states.retain(key, state)) {
return null
}
trimStates()
return state
}
const flush = (): boolean => {
let flushed = coalescer.flushAll()
for (const key of states.keys()) {
for (const key of pendingCheckpoints) {
const snapshot = coalescer.snapshot(key)
if (snapshot && checkpointLengths.get(key) !== snapshot.text.length) {
flushed = persist(key, snapshot.text, true) && flushed
} else {
pendingCheckpoints.delete(key)
}
}
for (const [key, pending] of pendingPatches) {
const admission = deps.sink.tryAppendItem
? deps.sink.tryAppendItem(pending.identity, pending.body)
: (deps.sink.appendItem(pending.identity, pending.body), { accepted: true as const })
if (!admission.accepted) {
flushed = false
continue
}
const published = deps.sink.tryPublish
? deps.sink.tryPublish()
: (deps.sink.publish(), { accepted: true as const })
if (!published.accepted) {
flushed = false
continue
}
retainedPatchBytes = Math.max(0, retainedPatchBytes - pendingPatchBytes(pending))
pendingPatches.delete(key)
for (const key of pendingPatches.keys()) {
flushed = flushPatch(key).accepted && flushed
}
return flushed
}
@@ -209,11 +202,21 @@ export function createCodexStructuredItemStreams(
}
return {
get persistentCount() {
return states.persistentSize
},
canTrack: (threadId, item, identity) =>
states.canRetain(codexStructuredItemKey(threadId, item.id), {
item: boundStreamItem(item) as CodexThreadItem,
identity
}),
track: (threadId, item, identity) => {
const key = codexStructuredItemKey(threadId, item.id)
states.delete(key)
states.set(key, { item: boundStreamItem(item) as CodexThreadItem, identity })
if (!states.retain(key, { item: boundStreamItem(item) as CodexThreadItem, identity })) {
return false
}
trimStates()
return true
},
handle: (threadId, method, params) => {
const paramsRecord = readCodexItemStreamRecord(params)
@@ -230,6 +233,9 @@ export function createCodexStructuredItemStreams(
const key = codexStructuredItemKey(threadId, itemId)
const streamFlushed = coalescer.flush(key)
const state = ensureState(threadId, itemId, 'fileChange', params)
if (!state) {
return { handled: true, admission: { accepted: false, reason: 'failed' } }
}
state.item = { ...state.item, changes: paramsRecord.changes }
const translated = codexJournalItem(state.item)
if (translated.body) {
@@ -269,9 +275,14 @@ export function createCodexStructuredItemStreams(
return { handled: true, admission: { accepted: true } }
}
const state = ensureState(threadId, itemId, type ?? 'reasoning', params)
if (!state) {
return { handled: true, admission: { accepted: false, reason: 'failed' } }
}
const delta = method === REASONING_PART_METHOD ? '\n' : paramsRecord.delta
if (typeof delta === 'string') {
const accepted = coalescer.append(codexStructuredItemKey(threadId, state.item.id), delta)
const key = codexStructuredItemKey(threadId, state.item.id)
pendingCheckpoints.add(key)
const accepted = coalescer.append(key, delta)
if (!accepted) {
return { handled: true, admission: { accepted: false, reason: 'backpressure' } }
}
@@ -287,6 +298,7 @@ export function createCodexStructuredItemStreams(
coalescer.dispose()
states.clear()
checkpointLengths.clear()
pendingCheckpoints.clear()
pendingPatches.clear()
retainedPatchBytes = 0
},
@@ -584,6 +584,16 @@ describe('codex item bodies', () => {
})
})
it('preserves plan prose documents byte-for-byte as status text', () => {
const text =
' # Implementation plan\r\n\r\n- [ ] Preserve prose\r\n- [x] Keep café → 日本語\r\n\r\n```ts\r\nconst task = "pending"\r\n```\r\n '
expect(codexJournalItem({ type: 'plan', id: 'plan-document', text })).toEqual({
body: { kind: 'status', text, presentation: 'plan-document' },
handled: true
})
})
it('renders reasoning as status and exposes an unknown item as a provider frame', () => {
expect(codexItemBody({ type: 'reasoning', id: 'r', text: 'thinking' })).toEqual({
kind: 'status',
@@ -1,11 +1,13 @@
import type { AgentSessionDeltaCoalescerDeps } from '../native-chat/agent-session-wire/agent-session-delta-coalescer'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter'
import type { CodexSubagentExecutions } from './codex-subagent-executions'
export type CodexJournalTranslatorDeps = {
sink: StructuredAgentSessionEventSink
bindPromptItemId?: (journalItemId: string, threadId: string, promptKey: string) => void
primaryThreadId?: () => string | null
subagentExecutions?: CodexSubagentExecutions
coalesceMs?: number
maxRetainedBytes?: number
schedule?: AgentSessionDeltaCoalescerDeps['schedule']
@@ -11,7 +11,8 @@ import {
type CodexThreadItem
} from './codex-structured-item-translation'
import { createCodexStructuredItemStreams } from './codex-structured-item-streams'
import { codexStructuredItemKey } from './codex-structured-item-stream-bounds'
import { boundStreamItem, codexStructuredItemKey } from './codex-structured-item-stream-bounds'
import { codexCommandOutlivesTurn } from './codex-command-lifecycle'
import type {
CodexItemTranslation,
CodexJournalTranslationAdmission,
@@ -40,7 +41,7 @@ export class CodexJournalItems {
private readonly deps: Pick<
CodexJournalTranslatorDeps,
'sink' | 'coalesceMs' | 'maxRetainedBytes' | 'schedule'
>,
> & { maxMetadataBytes?: number },
private readonly activeTurn: (threadId: string) => string | null,
private readonly suppress: (threadId: string, turnId: string) => void
) {
@@ -49,6 +50,7 @@ export class CodexJournalItems {
coalesceMs: deps.coalesceMs,
maxRetainedBytes: deps.maxRetainedBytes,
schedule: deps.schedule,
maxMetadataBytes: deps.maxMetadataBytes,
identityFor: (threadId, params, item) => {
const turnId = readCodexTurnId(params) ?? this.activeTurn(threadId)
return this.identityFor(threadId, turnId, item)
@@ -81,6 +83,12 @@ export class CodexJournalItems {
if (item.type === 'contextCompaction' && event.method === 'item/started') {
return { handled: true, admission: CODEX_JOURNAL_ADMITTED }
}
if (
event.method !== 'item/completed' &&
!this.streams.canTrack(event.threadId, item, identity)
) {
return { handled: true, admission: { accepted: false, reason: 'failed' } }
}
const translated = codexJournalItem(item)
const command = readCodexJournalString(item, 'command')
if (command) {
@@ -157,12 +165,15 @@ export class CodexJournalItems {
item: CodexThreadItem,
identity: AgentJournalItemIdentity
): void {
this.streams.track(threadId, item, identity)
const retainedItem = codexCommandOutlivesTurn(item)
? (boundStreamItem(item) as CodexThreadItem)
: item
this.streams.track(threadId, retainedItem, identity)
this.activeItems.set(codexStructuredItemKey(threadId, item.id), {
threadId,
turnId,
identity,
item
item: retainedItem
})
}
@@ -194,8 +205,10 @@ export class CodexJournalItems {
}
private trimActiveState(): CodexJournalTranslationAdmission {
while (this.activeItems.size > MAX_CODEX_ACTIVE_ITEMS) {
const oldest = this.activeItems.keys().next().value
while (this.activeItems.size - this.streams.persistentCount > MAX_CODEX_ACTIVE_ITEMS) {
const oldest = [...this.activeItems].find(
([, active]) => !codexCommandOutlivesTurn(active.item)
)?.[0]
if (typeof oldest !== 'string') {
break
}
@@ -20,6 +20,7 @@ import {
} from './codex-structured-item-translation'
import type { CodexStructuredItemStreams } from './codex-structured-item-streams'
import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter'
import { codexCommandOutlivesTurn } from './codex-command-lifecycle'
export type CodexActiveJournalItem = {
threadId: string
@@ -118,6 +119,9 @@ export function settleCodexJournalTurn(input: {
if (active.threadId !== input.threadId || active.turnId !== input.turnId) {
continue
}
if (codexCommandOutlivesTurn(active.item)) {
continue
}
const streamed = input.streams.snapshot(active.threadId, active.item.id)
const translated = streamed
? codexStreamingJournalItem(active.item, streamed.text)
@@ -6,6 +6,8 @@
* than as the shape checks each arm performs.
*/
import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter'
import type { CodexJournalItems } from './codex-structured-journal-items'
import type { CodexJournalTranslationAdmission } from './codex-structured-journal-contracts'
import { settleCodexOversizedNotification } from './codex-structured-journal-settlement'
import {
@@ -41,3 +43,23 @@ export function settleCodexOversizedNotificationFrame(input: {
})
: null
}
export function createCodexOversizedNotificationSettler(
deps: { sink: OversizedInput['sink'] },
items: Pick<CodexJournalItems, 'streams' | 'activeItems'>
) {
return settleOversizedNotification
/** Settles the item a notification the transport refused to carry left
* mid-flight; null when the frame is not one. */
function settleOversizedNotification(
event: Extract<CodexStructuredSessionEvent, { type: 'provider-frame' }>
): CodexJournalTranslationAdmission | null {
return settleCodexOversizedNotificationFrame({
...event,
sink: deps.sink,
streams: items.streams,
activeItems: items.activeItems
})
}
}
@@ -59,6 +59,22 @@ function deliverActivity(
translator: ReturnType<typeof createCodexJournalTranslator>,
params: unknown
): void {
const item = (params as { item: { kind: string; agentThreadId: string } }).item
if (item.kind === 'started' || item.kind === 'completed') {
translator.handle({
type: 'notification',
sessionId: SESSION_ID,
threadId: item.agentThreadId,
method: item.kind === 'started' ? 'turn/started' : 'turn/completed',
params: {
threadId: item.agentThreadId,
turn: {
id: `execution:${item.agentThreadId}`,
status: item.kind === 'started' ? 'inProgress' : 'completed'
}
}
})
}
translator.handle(notification('item/started', params))
translator.handle(notification('item/completed', params))
}
@@ -19,7 +19,7 @@ import {
settleCodexJournalSession,
settleCodexJournalTurn
} from './codex-structured-journal-settlement'
import { settleCodexOversizedNotificationFrame } from './codex-structured-journal-translation-frames'
import { createCodexOversizedNotificationSettler } from './codex-structured-journal-translation-frames'
import { restoreCodexJournalThread } from './codex-structured-journal-translation-restore'
import { CodexJournalActiveTurns } from './codex-structured-journal-translation-turn-state'
import { publishCodexTurnLifecycle } from './codex-structured-journal-translation-turns'
@@ -58,13 +58,15 @@ export function createCodexJournalTranslator(
(threadId) => activeTurns.current(threadId),
(threadId, turnId) => genericFrames.suppress(threadId, turnId)
)
const settleOversizedNotification = createCodexOversizedNotificationSettler(deps, items)
const prompts = new CodexJournalPrompts(deps, (threadId, itemId) =>
items.detailFor(threadId, itemId)
)
const subagents = new CodexSubagentRoster({
sink: deps.sink,
primaryThreadId: () => deps.primaryThreadId?.() ?? null,
activeTurn: (threadId) => activeTurns.current(threadId)
activeTurn: (threadId) => activeTurns.current(threadId),
...(deps.subagentExecutions ? { executions: deps.subagentExecutions } : {})
})
const flushStreams = (): CodexJournalTranslationAdmission =>
items.streams.flush() ? CODEX_JOURNAL_ADMITTED : { accepted: false, reason: 'backpressure' }
@@ -174,16 +176,17 @@ export function createCodexJournalTranslator(
}
return genericFrames.appendUnhandled(event.kind, event.payload, event.threadId)
}
if (event.method === 'turn/started') {
return startTurn(event)
if (event.method === 'turn/started' || event.method === 'turn/completed') {
const childAdmission = subagents.handleTurnEvent(event)
if (!childAdmission.accepted) {
return childAdmission
}
return event.method === 'turn/started' ? startTurn(event) : completeTurn(event)
}
const compaction = compactions.handle(event)
if (compaction) {
return publishActivity(event, compaction)
}
if (event.method === 'turn/completed') {
return completeTurn(event)
}
if (event.method === CODEX_TOKEN_USAGE_METHOD) {
// Classified `status-chrome`, so the generic-frame path swallows it
// before the journal. The roster consumes it as a typed notification.
@@ -240,19 +243,6 @@ export function createCodexJournalTranslator(
}
}
/** Settles the item a notification the transport refused to carry left
* mid-flight; null when the frame is not one. */
function settleOversizedNotification(
event: Extract<CodexStructuredSessionEvent, { type: 'provider-frame' }>
): CodexJournalTranslationAdmission | null {
return settleCodexOversizedNotificationFrame({
...event,
sink: deps.sink,
streams: items.streams,
activeItems: items.activeItems
})
}
function startTurn(
event: Extract<CodexStructuredSessionEvent, { type: 'notification' }>
): CodexJournalTranslationAdmission {
@@ -8,6 +8,8 @@ import {
closeFailedCodexAcquisition,
stopSupersededCodexAcquisition
} from './codex-structured-acquisition-lifecycle'
import { CodexBackgroundTaskTracker } from './codex-background-task-tracker'
import { CodexSubagentExecutions } from './codex-subagent-executions'
import { createCodexJournalTranslator } from './codex-structured-journal-translation'
import { openCodexAppServerConnection } from './codex-app-server-connection'
import { codexProcessIdentity, codexProviderHandleLink } from './codex-structured-owner-identity'
@@ -74,10 +76,12 @@ export async function acquireCodexStructuredSession(input: {
acquireInput.identity.providerHandle.kind === 'codex'
? acquireInput.identity.providerHandle.threadId
: null
const subagentExecutions = new CodexSubagentExecutions()
const translator = acquireInput.events
? createCodexJournalTranslator({
sink: acquireInput.events,
primaryThreadId: () => primaryThreadId,
subagentExecutions,
bindPromptItemId: (journalItemId, threadId, promptKey) =>
acquisition.prompts.bindJournalItemId(journalItemId, threadId, promptKey)
})
@@ -138,6 +142,7 @@ export async function acquireCodexStructuredSession(input: {
connection: acquisition.connection,
error,
prompts: acquisition.prompts,
onBackgroundTasksChanged: deps.onBackgroundTasksChanged,
...(deps.onEvent ? { onEvent: deps.onEvent } : {})
})
} finally {
@@ -199,6 +204,7 @@ export async function acquireCodexStructuredSession(input: {
reportedOptions: reportedCodexThreadOptions(opened),
turnIdWaiters: [],
translator,
backgroundTasks: new CodexBackgroundTaskTracker(opened.threadId, subagentExecutions),
forceCloseUnexpected: (reason) =>
input.forceCloseUnexpected(
sessionId,
@@ -16,11 +16,7 @@ import type { CodexJournalTranslationAdmission } from './codex-structured-journa
import { answerCodexPrompt } from './codex-structured-prompt-replies'
import { dispatchCodexTurn, isCodexTurnOptionKey } from './codex-structured-turn-start'
import { supportsCodexStructuredLocation } from './codex-structured-location-support'
import {
closeAllCodexSessions,
closeCodexPublishedSession,
closeCodexSession
} from './codex-structured-session-close'
import { CodexStructuredSessionTeardown } from './codex-structured-session-teardown'
import {
applyCodexStructuredSessionOption,
readLiveCodexSessionOptions
@@ -54,6 +50,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
private readonly acquisitions = new CodexAcquisitionRegistry()
private readonly turnCancellation: CodexStructuredTurnCancellation
private readonly notificationRetries: ReturnType<typeof createCodexStructuredNotificationRetry>
private readonly teardown: CodexStructuredSessionTeardown
constructor(private readonly deps: CodexStructuredSessionAdapterDeps) {
this.notificationRetries = createCodexStructuredNotificationRetry({
@@ -61,6 +58,15 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
translate: (sessionId, session, method, params) =>
this.translateNotification(sessionId, session, method, params)
})
this.teardown = new CodexStructuredSessionTeardown({
sessions: this.sessions,
acquisitions: this.acquisitions,
...(deps.onEvent ? { onEvent: deps.onEvent } : {}),
...(deps.onBackgroundTasksChanged
? { onBackgroundTasksChanged: deps.onBackgroundTasksChanged }
: {}),
forgetNotificationRetries: (sessionId) => this.notificationRetries.clear(sessionId, null)
})
this.turnCancellation = new CodexStructuredTurnCancellation({
captureTurnProcesses: deps.captureTurnProcesses,
terminateTurnProcesses: deps.terminateTurnProcesses,
@@ -92,7 +98,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
handleUnhandledFrame: (sessionId, kind, payload) =>
this.handleUnhandledFrame(sessionId, kind, payload),
forceCloseUnexpected: (sessionId, fence, acquisitionGeneration, reason) =>
this.forceCloseUnexpected(sessionId, fence, acquisitionGeneration, reason)
this.teardown.forceCloseUnexpected(sessionId, fence, acquisitionGeneration, reason)
})
/** Buffers pre-publication events and drops events from superseded children. */
@@ -134,12 +140,20 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
session: CodexSession,
event: CodexStructuredSessionEvent
): CodexJournalTranslationAdmission {
if (event.type === 'notification' && !session.backgroundTasks.canObserve(event)) {
return { accepted: false, reason: 'failed' }
}
const admission = session.translator?.handle(event) ?? { accepted: true }
if (!admission.accepted) {
return admission
}
if (event.type === 'notification') {
this.compactions.codex(event.sessionId, event.method, event.params)
// After the admission check, so a refused frame is observed by the strip
// only on the retry that also reaches the journal.
if (session.backgroundTasks.observe(event)) {
this.deps.onBackgroundTasksChanged?.(event.sessionId, session.backgroundTasks.state)
}
}
if (event.type === 'ended') {
this.compactions.ended(event.sessionId)
@@ -167,6 +181,10 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
)
}
backgroundTaskState: NonNullable<StructuredAgentSessionAdapter['backgroundTaskState']> = (
sessionId
) => this.sessions.get(sessionId)?.backgroundTasks.state
bindPromptItemId = (sessionId: string, journalItemId: string, promptKey: string): void =>
this.sessions
.get(sessionId)
@@ -267,59 +285,12 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
identity: AgentSessionJournalIdentity
}): Promise<string | null> => this.sessions.get(input.identity.sessionId)?.historyPath ?? null
closeSession = async (sessionId: string): Promise<boolean> => {
const closed = await closeCodexSession(
sessionId,
this.sessions,
this.acquisitions,
this.deps.onEvent
)
if (closed) {
this.notificationRetries.clear(sessionId, null)
}
return closed
}
forceCloseSession = async (sessionId: string): Promise<boolean> => {
const closed = await closeCodexPublishedSession(this.sessions, sessionId, this.deps.onEvent, {
allowFailedSettlement: true,
requestedClose: false
})
if (closed) {
this.notificationRetries.clear(sessionId, null)
}
return closed
}
private forceCloseUnexpected(
sessionId: string,
fence: number,
acquisitionGeneration: string,
reason: Error
): Promise<boolean> {
const session = this.sessions.get(sessionId)
if (
!session ||
session.ended ||
session.fence !== fence ||
session.acquisitionGeneration !== acquisitionGeneration
) {
return Promise.resolve(false)
}
return closeCodexPublishedSession(this.sessions, sessionId, this.deps.onEvent, {
allowFailedSettlement: true,
requestedClose: false,
expectedFence: fence,
expectedAcquisitionGeneration: acquisitionGeneration,
unexpectedReason: reason
})
}
disposeSession = (sessionId: string): Promise<boolean> => this.closeSession(sessionId)
closeAll = (): Promise<void> =>
closeAllCodexSessions(this.sessions, this.acquisitions, (sessionId) =>
this.disposeSession(sessionId)
)
closeSession = (sessionId: string): Promise<boolean> => this.teardown.close(sessionId)
forceCloseSession = (sessionId: string): Promise<boolean> => this.teardown.forceClose(sessionId)
disposeSession = (sessionId: string): Promise<boolean> => this.teardown.close(sessionId)
closeAll = (): Promise<void> => this.teardown.closeAll()
releaseAcquisition = (input: { sessionId: string }): Promise<boolean> =>
this.closeSession(input.sessionId)
this.teardown.close(input.sessionId)
private session(sessionId: string): CodexSession {
return requireLiveCodexSession(this.sessions, sessionId)
@@ -0,0 +1,258 @@
import { describe, expect, it, vi } from 'vitest'
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire'
import type {
CodexAppServerConnection,
CodexAppServerConnectionHandlers,
openCodexAppServerConnection
} from './codex-app-server-connection'
import { CodexStructuredSessionAdapter } from './codex-structured-session-adapter'
import { CodexBackgroundTaskTracker } from './codex-background-task-tracker'
import type { CodexStructuredSessionEvent } from './codex-structured-session-state'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
// Proves the strip is actually REACHED from provider traffic: the tracker is
// unit-tested separately, and a producer that is correct but unwired publishes
// nothing while every one of its own tests stays green.
const THREAD_ID = '01a07d54-3785-71d0-b065-82c8ebbc572a'
const PARENT_TURN = '01a07d54-37be-72e1-8206-8f0c23dd2cef'
const CHILD_ID = '01a07d54-5523-78a3-91f5-e0acb1dab065'
/** A three-route stand-in, deliberately smaller than the full adapter harness:
* this suite only needs a thread and a notification pipe. */
function fakeCodex(close: () => Promise<boolean> = async () => true): {
handlers: () => CodexAppServerConnectionHandlers
openConnection: typeof openCodexAppServerConnection
} {
let live: CodexAppServerConnectionHandlers = {}
const openConnection = (async (_launch, handlers = {}) => {
live = handlers
const connection: CodexAppServerConnection = {
pid: 4321,
closed: false,
request: async (method) =>
method === 'thread/start' ? { thread: { id: THREAD_ID, path: null } } : {},
notify: () => {},
respond: () => {},
respondWithError: () => {},
close
} as unknown as CodexAppServerConnection
return connection
}) as typeof openCodexAppServerConnection
return { handlers: () => live, openConnection }
}
function identity(sessionId: string): AgentSessionJournalIdentity {
return {
sessionId,
workspaceId: 'ws-1',
hostId: 'host-1',
agent: 'codex',
providerHandle: { kind: 'codex', threadId: THREAD_ID }
}
}
function subagentNotification(kind: string): { method: string; params: unknown } {
return {
method: 'item/started',
params: {
item: {
type: 'subAgentActivity',
id: 'call_1',
kind,
agentThreadId: CHILD_ID,
agentPath: '/root/count_a'
},
threadId: THREAD_ID,
turnId: PARENT_TURN
}
}
}
const TURN_COMPLETED = {
method: 'turn/completed',
params: { threadId: THREAD_ID, turn: { id: PARENT_TURN, status: 'completed' } }
}
async function adapterWithSession(
published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[],
events?: StructuredAgentSessionEventSink,
onEvent?: (event: CodexStructuredSessionEvent) => void,
close?: () => Promise<boolean>
): Promise<{ adapter: CodexStructuredSessionAdapter; codex: ReturnType<typeof fakeCodex> }> {
const codex = fakeCodex(close)
const adapter = new CodexStructuredSessionAdapter({
resolveLaunch: async () => ({
command: 'codex',
args: ['app-server'],
cwd: '/work/repo',
codexHome: null,
resumeThreadId: null
}),
openConnection: codex.openConnection,
readProcessStartTime: async () => 1_700_000_000_000,
onEvent,
onBackgroundTasksChanged: (sessionId, state) => published.push({ sessionId, state })
})
await adapter.acquire({
identity: identity('session-1'),
fence: 7,
spawnToken: 'spawn-9',
events
})
codex.handlers().onNotification?.('turn/started', {
threadId: THREAD_ID,
turn: { id: PARENT_TURN, status: 'inProgress' }
})
codex.handlers().onNotification?.('turn/started', {
threadId: CHILD_ID,
turn: { id: 'child-turn', status: 'inProgress' }
})
return { adapter, codex }
}
describe('codex background tasks reach the strip', () => {
it('clears natural-exit state before lifecycle observers can read it', async () => {
const published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[] = []
const onEvent = vi.fn()
const { adapter, codex } = await adapterWithSession(published, undefined, onEvent)
const spawn = subagentNotification('started')
codex.handlers().onNotification?.(spawn.method, spawn.params)
codex.handlers().onNotification?.(TURN_COMPLETED.method, TURN_COMPLETED.params)
expect(adapter.backgroundTaskState('session-1')?.tasks).toHaveLength(1)
published.length = 0
onEvent.mockImplementation((event: CodexStructuredSessionEvent) => {
if (event.type === 'ended') {
expect(adapter.backgroundTaskState('session-1')).toBeNull()
}
})
codex.handlers().onExit?.(new Error('provider exited'))
expect(adapter.backgroundTaskState('session-1')).toBeNull()
expect(published).toEqual([{ sessionId: 'session-1', state: null }])
await adapter.closeSession('session-1')
})
it('keeps live tasks when close is refused', async () => {
const published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[] = []
const close = vi.fn(async () => false)
const { adapter, codex } = await adapterWithSession(published, undefined, undefined, close)
const spawn = subagentNotification('started')
codex.handlers().onNotification?.(spawn.method, spawn.params)
codex.handlers().onNotification?.(TURN_COMPLETED.method, TURN_COMPLETED.params)
const before = adapter.backgroundTaskState('session-1')
published.length = 0
expect(await adapter.closeSession('session-1')).toBe(false)
expect(adapter.backgroundTaskState('session-1')).toEqual(before)
expect(published).toEqual([])
close.mockResolvedValue(true)
await adapter.closeSession('session-1')
})
it('does not let an old exit callback clear a replacement roster', async () => {
const published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[] = []
const { adapter, codex } = await adapterWithSession(published)
const oldExit = codex.handlers().onExit
await adapter.acquire({ identity: identity('session-1'), fence: 8, spawnToken: 'spawn-10' })
codex.handlers().onNotification?.('turn/started', {
threadId: CHILD_ID,
turn: { id: 'replacement-child-turn' }
})
const spawn = subagentNotification('started')
codex.handlers().onNotification?.(spawn.method, spawn.params)
const before = adapter.backgroundTaskState('session-1')
expect(before?.tasks).toHaveLength(1)
published.length = 0
oldExit?.(new Error('old provider exited late'))
expect(adapter.backgroundTaskState('session-1')).toEqual(before)
expect(published).toEqual([])
await adapter.closeSession('session-1')
})
it('recovers the exact provider generation when command metadata cannot be admitted', async () => {
const published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[] = []
const observed: CodexStructuredSessionEvent[] = []
const appendItem = vi.fn()
const { adapter, codex } = await adapterWithSession(
published,
{ appendItem, appendTombstone: () => {}, publish: () => {} },
(event) => observed.push(event)
)
appendItem.mockClear()
observed.length = 0
const admission = vi
.spyOn(CodexBackgroundTaskTracker.prototype, 'canObserve')
.mockReturnValue(false)
try {
codex.handlers().onNotification?.('item/started', {
threadId: THREAD_ID,
turnId: PARENT_TURN,
item: {
type: 'commandExecution',
id: 'over-budget',
command: 'sleep 1',
source: 'unifiedExecStartup',
status: 'inProgress'
}
})
await vi.waitFor(() => expect(adapter.backgroundTaskState('session-1')).toBeUndefined())
expect(appendItem.mock.calls.map((call) => call[1])).toEqual([
{ kind: 'status', text: 'Provider exited: notification admission failed (failed)' }
])
expect(observed).toEqual([
expect.objectContaining({
type: 'ended',
cause: 'unexpected-exit',
fence: 7,
acquisitionGeneration: expect.any(String),
reason: 'notification admission failed (failed)'
})
])
expect(published).toEqual([{ sessionId: 'session-1', state: null }])
} finally {
admission.mockRestore()
await adapter.closeSession('session-1')
}
})
it('publishes the orphaned fan-out once the spawning turn completes', async () => {
const published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[] = []
const { adapter, codex } = await adapterWithSession(published)
const spawn = subagentNotification('started')
codex.handlers().onNotification?.(spawn.method, spawn.params)
// The child is still inside the turn, so the strip stays silent.
expect(published).toEqual([])
expect(adapter.backgroundTaskState('session-1')).toBeNull()
codex.handlers().onNotification?.(TURN_COMPLETED.method, TURN_COMPLETED.params)
expect(published).toEqual([
{
sessionId: 'session-1',
state: {
state: 'monitoring',
supportsStopAll: false,
tasks: [{ id: `codex-agent:${CHILD_ID}`, kind: 'agent', description: 'count_a' }]
}
}
])
expect(adapter.backgroundTaskState('session-1')).toEqual(published[0].state)
})
it('clears the strip when the session closes', async () => {
const published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[] = []
const { adapter, codex } = await adapterWithSession(published)
const spawn = subagentNotification('started')
codex.handlers().onNotification?.(spawn.method, spawn.params)
codex.handlers().onNotification?.(TURN_COMPLETED.method, TURN_COMPLETED.params)
published.length = 0
expect(await adapter.closeSession('session-1')).toBe(true)
// Explicit null, not silence: the reader answers `undefined` once the
// session is gone, which every channel treats as "unchanged".
expect(published).toEqual([{ sessionId: 'session-1', state: null }])
expect(adapter.backgroundTaskState('session-1')).toBeUndefined()
})
})
@@ -10,6 +10,7 @@ import {
type CodexStructuredSessionEvent
} from './codex-structured-session-adapter'
import { handleCodexSessionExit } from './codex-structured-session-close'
import { CodexBackgroundTaskTracker } from './codex-background-task-tracker'
import type { CodexSession } from './codex-structured-session-state'
import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router'
@@ -90,6 +91,7 @@ describe('Codex structured session close lifecycle', () => {
} as unknown as NonNullable<CodexSession['translator']>
const session = {
connection,
backgroundTasks: new CodexBackgroundTaskTracker('thread-1'),
ended: false,
requestedClose: false,
fence: 7,
@@ -4,6 +4,7 @@ import {
cancelCodexAcquisitionAttempt,
type CodexAcquisitionRegistry,
type CodexSession,
type CodexStructuredSessionAdapterDeps,
type CodexStructuredSessionEvent
} from './codex-structured-session-state'
import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
@@ -16,6 +17,7 @@ export function handleCodexSessionExit(input: {
prompts?: CodexSession['prompts']
allowFailedSettlement?: boolean
onEvent?: (event: CodexStructuredSessionEvent) => void
onBackgroundTasksChanged?: CodexStructuredSessionAdapterDeps['onBackgroundTasksChanged']
}): boolean {
const session = input.sessions.get(input.sessionId)
if (!session || session.connection !== input.connection || session.ended) {
@@ -43,6 +45,8 @@ export function handleCodexSessionExit(input: {
event.settlementRetryRequired = true
}
session.ended = true
session.backgroundTasks.clear()
input.onBackgroundTasksChanged?.(input.sessionId, null)
session.unbindReadingControl?.()
input.onEvent?.(event)
session.prompts.clear()
@@ -7,6 +7,7 @@ import {
reportedCodexThreadOptions,
restoredCodexSessionOptions
} from './codex-structured-session-options'
import { CodexBackgroundTaskTracker } from './codex-background-task-tracker'
import type { CodexSession } from './codex-structured-session-state'
function optionSession(request: CodexAppServerConnection['request']): CodexSession {
@@ -20,6 +21,7 @@ function optionSession(request: CodexAppServerConnection['request']): CodexSessi
respondWithError: () => {},
close: async () => true
},
backgroundTasks: new CodexBackgroundTaskTracker('thread-1'),
ended: false,
requestedClose: false,
fence: 1,
@@ -6,6 +6,8 @@ import type {
openCodexAppServerConnection
} from './codex-app-server-connection'
import { CodexAcquisitionWindow } from './codex-structured-acquisition-window'
import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire'
import type { CodexBackgroundTaskTracker } from './codex-background-task-tracker'
import type { CodexJournalTranslator } from './codex-structured-journal-translation'
import type { CodexTurnProcessSnapshot } from './codex-structured-turn-processes'
import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
@@ -44,6 +46,10 @@ export type CodexStructuredSessionAdapterDeps = {
/** Host capability seam; production uses the native Windows process table. */
isWindowsProcessStartTimeAvailable?: () => boolean
onEvent?: (event: CodexStructuredSessionEvent) => void
onBackgroundTasksChanged?: (
sessionId: string,
state: AgentSessionBackgroundTaskState | null
) => void
openConnection?: typeof openCodexAppServerConnection
readProcessStartTime?: (pid: number) => Promise<number | null>
mintLinkId?: () => string
@@ -73,6 +79,8 @@ export type CodexSession = {
reportedOptions: { model?: string; effort?: string }
turnIdWaiters: ((turnId: string) => void)[]
translator: CodexJournalTranslator | null
/** Ephemeral roster behind the background-tasks strip; never durable state. */
backgroundTasks: CodexBackgroundTaskTracker
unbindReadingControl?: () => void
/** Terminates this exact child as an unexpected death and enters host recovery. */
forceCloseUnexpected?: (reason: Error) => Promise<boolean>
@@ -0,0 +1,94 @@
// Stopping one Codex app-server child, in the four ways the host asks for it.
//
// Every path funnels through `settled` so the ephemeral surfaces a closed
// session owns are cleared exactly once, and only when the child was actually
// proven stopped — a refused close leaves the session indexed for a retry.
import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire'
import {
closeAllCodexSessions,
closeCodexPublishedSession,
closeCodexSession
} from './codex-structured-session-close'
import type {
CodexAcquisitionRegistry,
CodexSession,
CodexStructuredSessionEvent
} from './codex-structured-session-state'
export type CodexStructuredSessionTeardownDeps = {
sessions: Map<string, CodexSession>
acquisitions: CodexAcquisitionRegistry
onEvent?: (event: CodexStructuredSessionEvent) => void
onBackgroundTasksChanged?: (
sessionId: string,
state: AgentSessionBackgroundTaskState | null
) => void
forgetNotificationRetries: (sessionId: string) => void
}
export class CodexStructuredSessionTeardown {
constructor(private readonly deps: CodexStructuredSessionTeardownDeps) {}
close = async (sessionId: string): Promise<boolean> => {
const closed = await closeCodexSession(
sessionId,
this.deps.sessions,
this.deps.acquisitions,
this.deps.onEvent
)
return this.settled(sessionId, closed)
}
forceClose = async (sessionId: string): Promise<boolean> => {
const closed = await closeCodexPublishedSession(
this.deps.sessions,
sessionId,
this.deps.onEvent,
{ allowFailedSettlement: true, requestedClose: false }
)
return this.settled(sessionId, closed)
}
/** Terminates this exact child as an unexpected death. Every ownership check
* stays here so a stale caller cannot close a replacement child. */
forceCloseUnexpected = (
sessionId: string,
fence: number,
acquisitionGeneration: string,
reason: Error
): Promise<boolean> => {
const session = this.deps.sessions.get(sessionId)
if (
!session ||
session.ended ||
session.fence !== fence ||
session.acquisitionGeneration !== acquisitionGeneration
) {
return Promise.resolve(false)
}
return closeCodexPublishedSession(this.deps.sessions, sessionId, this.deps.onEvent, {
allowFailedSettlement: true,
requestedClose: false,
expectedFence: fence,
expectedAcquisitionGeneration: acquisitionGeneration,
unexpectedReason: reason
}).then((closed) => this.settled(sessionId, closed))
}
closeAll = (): Promise<void> =>
closeAllCodexSessions(this.deps.sessions, this.deps.acquisitions, (sessionId) =>
this.close(sessionId)
)
private settled(sessionId: string, closed: boolean): boolean {
if (closed) {
this.deps.forgetNotificationRetries(sessionId)
// Explicit null, not silence: the state reader answers `undefined` once
// the session leaves the map, which every channel reads as "unchanged"
// and would leave the last roster on screen.
this.deps.onBackgroundTasksChanged?.(sessionId, null)
}
return closed
}
}
+1 -20
View File
@@ -7,11 +7,10 @@
// segment is a semantic task name and the only label available. There is no
// `thread/started` for a child, so nickname/role/depth do not exist.
// * `agentsStates` on `collabAgentToolCall` arrived empty (`{}`) throughout the
// probe, so nothing here reads it — state comes from `kind` alone.
// probe, so nothing here reads it; child turn events own execution state.
// * `thread/tokenUsage/updated` reports a per-thread RUNNING TOTAL, so the
// latest frame replaces the previous one — it is never accumulated.
import type { NativeChatSubagentState } from '../../shared/native-chat-types'
import type { CodexThreadItem } from './codex-structured-item-translation'
export const CODEX_SUBAGENT_ITEM_TYPE = 'subAgentActivity'
@@ -48,24 +47,6 @@ export function readCodexSubagentActivity(item: CodexThreadItem): CodexSubagentA
}
}
/**
* The state a `kind` implies for the child it names.
*
* An unrecognized kind means "this child exists and reported something we
* cannot classify" — `working`, which the session sweep will later settle to
* `unverifiable` if nothing better ever arrives. Claiming a terminal state from
* an unknown kind would assert an outcome the wire never gave us.
*/
export function codexSubagentStateForKind(kind: string): NativeChatSubagentState {
if (kind === 'completed') {
return 'completed'
}
if (kind === 'interrupted') {
return 'stopped'
}
return 'working'
}
/** Path segments, empty ones dropped: `/root/list_directory` → 2 segments. */
export function codexSubagentPathSegments(agentPath: string | null): string[] {
return agentPath === null ? [] : agentPath.split('/').filter((part) => part.length > 0)
@@ -0,0 +1,211 @@
import { describe, expect, it } from 'vitest'
import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types'
import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter'
import { createCodexJournalTranslator } from './codex-structured-journal-translation'
import { CodexBackgroundTaskTracker } from './codex-background-task-tracker'
import { CodexSubagentExecutions } from './codex-subagent-executions'
const PRIMARY = 'primary'
const CHILD = 'child'
function turn(
method: 'turn/started' | 'turn/completed',
threadId: string,
turnId: string,
status = 'completed'
): CodexStructuredSessionEvent {
return {
type: 'notification',
sessionId: 'session',
method,
threadId,
params: { threadId, turn: { id: turnId, status } }
}
}
function activity(parentTurn: string, kind = 'started'): CodexStructuredSessionEvent {
return {
type: 'notification',
sessionId: 'session',
threadId: PRIMARY,
method: 'item/started',
params: {
threadId: PRIMARY,
turnId: parentTurn,
item: {
type: 'subAgentActivity',
id: `activity-${parentTurn}-${kind}`,
kind,
agentThreadId: CHILD,
agentPath: '/root/task'
}
}
}
}
function harness() {
const executions = new CodexSubagentExecutions()
const tracker = new CodexBackgroundTaskTracker(PRIMARY, executions)
const rows = new Map<string, AgentJournalItemBody>()
let refused = false
const translator = createCodexJournalTranslator({
primaryThreadId: () => PRIMARY,
subagentExecutions: executions,
sink: {
appendItem: (identity, body) => rows.set(JSON.stringify(identity), body),
appendTombstone: () => {},
publish: () => {},
tryAppendItem: (identity, body) => {
if (refused) {
return { accepted: false, reason: 'backpressure' }
}
rows.set(JSON.stringify(identity), body)
return { accepted: true }
}
},
schedule: (run) => {
run()
return () => {}
}
})
function send(event: CodexStructuredSessionEvent) {
const admission = translator.handle(event)
if (admission.accepted && event.type === 'notification') {
tracker.observe(event)
}
return admission
}
function state(parentTurn: string): string | undefined {
for (const body of rows.values()) {
if (body.kind !== 'message') {
continue
}
for (const block of body.blocks) {
if (block.type === 'subagent-group' && block.groupId === `${PRIMARY}:${parentTurn}`) {
return block.agents[0]?.state
}
}
}
return undefined
}
return {
send,
tracker,
state,
refuse: (value: boolean) => {
refused = value
},
dispose: () => translator.dispose()
}
}
function firstRun(h: ReturnType<typeof harness>) {
h.send(turn('turn/started', PRIMARY, 'parent-1'))
h.send(turn('turn/started', CHILD, 'child-1'))
h.send(activity('parent-1'))
h.send(turn('turn/completed', CHILD, 'child-1'))
}
describe('shared child execution projection', () => {
it('creates no execution record from activity without an owner turn', () => {
const h = harness()
h.send(activity('parent-1'))
h.send(activity('parent-2', 'interacted'))
expect(h.state('parent-1')).toBeUndefined()
expect(h.state('parent-2')).toBeUndefined()
expect(h.tracker.state).toBeNull()
h.dispose()
})
it.each(['parent-1', 'parent-2'])(
'reopens a child for real follow-up in %s and fences old execution events',
(parent) => {
const h = harness()
firstRun(h)
if (parent === 'parent-2') {
h.send(turn('turn/completed', PRIMARY, 'parent-1'))
h.send(turn('turn/started', PRIMARY, parent))
}
h.send(activity(parent, 'interacted'))
expect(h.tracker.state).toBeNull()
h.send(turn('turn/started', CHILD, 'child-2'))
h.send(turn('turn/completed', PRIMARY, parent))
expect(h.state(parent)).toBe('working')
expect(h.tracker.state?.tasks).toHaveLength(1)
h.send(turn('turn/completed', CHILD, 'child-1'))
h.send(turn('turn/started', CHILD, 'child-1'))
h.send(activity('parent-1', 'completed'))
expect(h.state(parent)).toBe('working')
expect(h.tracker.state?.tasks).toHaveLength(1)
if (parent === 'parent-2') {
expect(h.state('parent-1')).toBe('completed')
}
h.send(turn('turn/completed', CHILD, 'child-2'))
expect(h.state(parent)).toBe('completed')
expect(h.tracker.state).toBeNull()
h.dispose()
}
)
it('keeps an idle message from creating execution on either surface', () => {
const h = harness()
firstRun(h)
h.send(turn('turn/completed', PRIMARY, 'parent-1'))
h.send(activity('parent-2', 'interacted'))
h.send(turn('turn/completed', PRIMARY, 'parent-2'))
expect(h.state('parent-1')).toBe('completed')
expect(h.state('parent-2')).toBeUndefined()
expect(h.tracker.state).toBeNull()
h.dispose()
})
it('keeps a late completion activity from retargeting a queued follow-up', () => {
const h = harness()
firstRun(h)
h.send(turn('turn/completed', PRIMARY, 'parent-1'))
h.send(activity('parent-2', 'interacted'))
h.send(activity('parent-1', 'completed'))
h.send(turn('turn/started', CHILD, 'child-2'))
expect(h.state('parent-1')).toBe('completed')
expect(h.state('parent-2')).toBe('working')
expect(h.tracker.state?.tasks).toHaveLength(1)
h.dispose()
})
it('keeps a message to a running child in the original execution group', () => {
const h = harness()
h.send(turn('turn/started', PRIMARY, 'parent-1'))
h.send(turn('turn/started', CHILD, 'child-1'))
h.send(activity('parent-1'))
h.send(turn('turn/completed', PRIMARY, 'parent-1'))
h.send(turn('turn/started', PRIMARY, 'parent-2'))
h.send(activity('parent-2', 'interacted'))
h.send(turn('turn/started', CHILD, 'child-1'))
h.send(turn('turn/completed', PRIMARY, 'parent-2'))
expect(h.tracker.state?.tasks).toHaveLength(1)
expect(h.state('parent-2')).toBeUndefined()
h.send(turn('turn/completed', CHILD, 'child-1'))
expect(h.state('parent-1')).toBe('completed')
expect(h.state('parent-2')).toBeUndefined()
expect(h.tracker.state).toBeNull()
h.dispose()
})
it('does not expose pending child settlement before journal admission and retries the same fact', () => {
const h = harness()
h.send(turn('turn/started', PRIMARY, 'parent-1'))
h.send(activity('parent-1'))
h.send(turn('turn/started', CHILD, 'child-1'))
h.send(turn('turn/completed', PRIMARY, 'parent-1'))
const complete = turn('turn/completed', CHILD, 'child-1')
h.refuse(true)
expect(h.send(complete)).toEqual({ accepted: false, reason: 'backpressure' })
expect(h.tracker.state?.tasks).toHaveLength(1)
expect(h.state('parent-1')).toBe('working')
h.refuse(false)
expect(h.send(complete)).toEqual({ accepted: true })
expect(h.state('parent-1')).toBe('completed')
expect(h.tracker.state).toBeNull()
h.dispose()
})
})
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import { CodexSubagentExecutions } from './codex-subagent-executions'
describe('CodexSubagentExecutions retention and identity', () => {
it('bounds settled history through repeated execution without evicting live children', () => {
const executions = new CodexSubagentExecutions()
executions.register('long-lived', 'long-lived', 'parent')
executions.observeTurn('long-lived', 'long-lived-turn', 'working')
for (let index = 0; index < 1_000; index++) {
const id = `child-${index}`
executions.observeTurn(id, id, 'working')
executions.register(id, id, 'parent')
executions.observeTurn(id, id, 'completed')
}
expect(executions.workingChildren().map((child) => child.agentThreadId)).toEqual(['long-lived'])
expect(Reflect.get(executions, 'children').size).toBeLessThanOrEqual(128)
expect(Reflect.get(executions, 'settledTurns').size).toBeLessThanOrEqual(256)
})
it('retains early live owner events at capacity and makes room only after settlement', () => {
const executions = new CodexSubagentExecutions()
for (let index = 0; index < 128; index++) {
executions.observeTurn(`child-${index}`, `turn-${index}`, 'working')
}
expect(executions.observeTurn('overflow', 'overflow', 'working')).toBeNull()
for (let index = 0; index < 128; index++) {
executions.register(`child-${index}`, `child-${index}`, 'parent')
}
expect(executions.workingChildren()).toHaveLength(128)
executions.observeTurn('child-0', 'turn-0', 'completed')
expect(executions.observeTurn('overflow', 'overflow', 'working')).not.toBeNull()
executions.register('overflow', 'overflow', 'parent')
expect(executions.workingChildren()).toHaveLength(128)
})
it('corrects an unverifiable execution with its own terminal event and ignores stale starts', () => {
const executions = new CodexSubagentExecutions()
executions.register('child', 'child', 'parent')
executions.observeTurn('child', 'turn', 'working')
executions.settleSession()
expect(executions.workingChildren()).toEqual([])
expect(executions.observeTurn('child', 'turn', 'working')).toBeNull()
expect(executions.observeTurn('child', 'turn', 'completed')?.execution.state).toBe('completed')
executions.observeTurn('child', 'new-turn', 'working')
executions.observeTurn('child', 'turn', 'failed')
expect(executions.workingChildren()[0]?.execution?.turnId).toBe('new-turn')
executions.clear()
expect(Reflect.get(executions, 'children').size).toBe(0)
expect(Reflect.get(executions, 'settledTurns').size).toBe(0)
})
})
+144
View File
@@ -0,0 +1,144 @@
import type { NativeChatSubagentState } from '../../shared/native-chat-types'
import { MAX_SUBAGENT_FIELD_CHARS } from '../../shared/native-chat-subagent-summary'
const MAX_CHILDREN = 128
const MAX_SETTLED_TURNS = 256
export type CodexChildExecution = {
turnId: string
state: NativeChatSubagentState
}
export type CodexExecutionChild = {
agentThreadId: string
registered: boolean
label: string | null
parentTurnId: string | null
execution: CodexChildExecution | null
}
/** Child turn events own execution; activity items only identify the child. */
export class CodexSubagentExecutions {
private readonly children = new Map<string, CodexExecutionChild>()
private readonly settledTurns = new Map<string, NativeChatSubagentState>()
register(
agentThreadId: string,
label: string | null,
parentTurnId: string | null | undefined
): CodexExecutionChild | undefined {
const child = this.child(agentThreadId)
if (!child) {
return undefined
}
if (!child.registered || parentTurnId !== undefined) {
child.parentTurnId = parentTurnId ?? null
}
child.registered = true
// Retain one overflow unit so the journal can append its per-row truncation marker.
child.label ??=
label
?.trim()
.replace(/\s+/g, ' ')
.slice(0, MAX_SUBAGENT_FIELD_CHARS + 1) || null
return child
}
observeTurn(
agentThreadId: string,
turnId: string,
state: NativeChatSubagentState
): { child: CodexExecutionChild; execution: CodexChildExecution } | null {
const key = JSON.stringify([agentThreadId, turnId])
const settled = this.settledTurns.get(key)
if (state === 'working' && settled !== undefined) {
return null
}
const child = this.child(agentThreadId)
if (!child) {
return null
}
if (
state === 'working' &&
child.execution?.turnId === turnId &&
child.execution.state !== 'working'
) {
return null
}
const execution = { turnId, state: settled ?? state }
if (state !== 'working') {
this.settledTurns.set(key, execution.state)
while (this.settledTurns.size > MAX_SETTLED_TURNS) {
const oldest = this.settledTurns.keys().next().value
if (oldest === undefined) {
break
}
this.settledTurns.delete(oldest)
}
}
if (state === 'working' || !child.execution || child.execution.turnId === turnId) {
child.execution = execution
}
return { child, execution }
}
/** Survives the child's turn, so a row outliving that turn can still name it. */
label(agentThreadId: string): string | null {
return this.children.get(agentThreadId)?.label ?? null
}
workingChildren(): CodexExecutionChild[] {
return [...this.children.values()].filter(
(child) => child.registered && child.execution?.state === 'working'
)
}
settleSession(): void {
for (const child of this.children.values()) {
if (child.execution?.state === 'working') {
child.execution = { ...child.execution, state: 'unverifiable' }
}
}
}
clear(): void {
this.children.clear()
this.settledTurns.clear()
}
private child(agentThreadId: string): CodexExecutionChild | undefined {
const existing = this.children.get(agentThreadId)
if (existing) {
return existing
}
if (this.children.size >= MAX_CHILDREN) {
const settled = [...this.children].find(([, child]) => child.execution?.state !== 'working')
if (!settled) {
return undefined
}
this.children.delete(settled[0])
}
const child: CodexExecutionChild = {
agentThreadId,
registered: false,
label: null,
parentTurnId: null,
execution: null
}
this.children.set(agentThreadId, child)
return child
}
}
export function codexChildTurnState(status: unknown): NativeChatSubagentState {
if (status === 'completed') {
return 'completed'
}
if (status === 'interrupted') {
return 'stopped'
}
if (status === 'failed') {
return 'failed'
}
return 'unverifiable'
}

Some files were not shown because too many files have changed in this diff Show More