Merge remote-tracking branch 'origin/main' into session-search-remote-enable-2

This commit is contained in:
Jinwoo-H
2026-09-16 12:41:51 -04:00
1155 changed files with 73225 additions and 35333 deletions
+15
View File
@@ -57,6 +57,21 @@ describe('buildAgentContext', () => {
])
})
it('omits hidden specs so agents do not discover an unadvertised command', () => {
const schema = buildAgentContext([
...specs,
{
path: ['terminal', 'stop'],
summary: 'Deprecated',
usage: 'orca terminal stop',
allowedFlags: [],
hidden: true
}
])
expect(schema.commandCount).toBe(2)
expect(schema.commands.map((command) => command.command)).not.toContain('terminal stop')
})
it('defaults optional fields to empty arrays', () => {
const schema = buildAgentContext(specs)
const agentContext = schema.commands.find((command) => command.command === 'agent-context')
+2
View File
@@ -27,6 +27,8 @@ export type AgentContextSchema = {
export function buildAgentContext(specs: CommandSpec[]): AgentContextSchema {
const commands = specs
// Why: hidden specs dispatch but stay off every discovery surface, including this one.
.filter((spec) => spec.hidden !== true)
.map((spec) => ({
command: spec.path.join(' '),
path: spec.path,
+296
View File
@@ -0,0 +1,296 @@
import { describe, expect, it } from 'vitest'
import {
formatSessionSearchResponse,
formatSessionSearchStatus,
terminalSafe
} from './agent-session-search-format'
import type {
AiVaultSearchHit,
AiVaultSearchResponse,
AiVaultSearchStatus
} from '../shared/ai-vault-search-types'
const localHit: AiVaultSearchHit = {
agent: 'claude',
executionHostId: 'ssh:build-01',
sessionId: 'session-1',
title: 'Terminal resize race on Windows',
cwd: 'C:\\src\\orca',
branch: 'main',
updatedAt: '2026-09-12T18:04:11.000Z',
messageCount: 214,
score: 12.5,
source: { presence: 'present', filePath: '/transcripts/session-1.jsonl' },
evidence: {
snippet: 'the [[resize]] handler drops the first event',
role: 'assistant',
timestamp: '2026-09-12T18:04:11.000Z'
},
resumeCommand: 'claude --resume session-1'
}
const evidencelessHit: AiVaultSearchHit = {
agent: 'codex',
sessionId: 'session-2',
title: 'Index sweep budget',
cwd: null,
branch: null,
updatedAt: null,
messageCount: 4,
score: 3,
source: { presence: 'unverifiable' },
evidence: null
}
function results(overrides: Partial<Extract<AiVaultSearchResponse, { kind: 'results' }>> = {}) {
return {
kind: 'results' as const,
hits: [localHit],
page: { cursor: null, hasMore: false },
generation: 42,
truncated: { candidates: false, snippets: 0, query: false, freshness: false },
durationMs: 18,
...overrides
}
}
describe('formatSessionSearchResponse: results', () => {
it('prints one line per hit with the snippet indented under it', () => {
expect(formatSessionSearchResponse(results())).toBe(
[
'Claude 2026-09-12T18:04:11.000Z Terminal resize race on Windows host=ssh:build-01',
' assistant: the [[resize]] handler drops the first event',
' resume: claude --resume session-1',
'',
'1 result on this page, 18 ms.'
].join('\n')
)
})
it('leaves the [[ ]] match marks exactly as the engine wrote them', () => {
expect(formatSessionSearchResponse(results())).toContain('[[resize]]')
})
it('omits the host and the resume line a paired host withheld', () => {
const { executionHostId: _host, resumeCommand: _resume, ...withheld } = localHit
expect(formatSessionSearchResponse(results({ hits: [withheld] }))).toBe(
[
'Claude 2026-09-12T18:04:11.000Z Terminal resize race on Windows',
' assistant: the [[resize]] handler drops the first event',
'',
'1 result on this page, 18 ms.'
].join('\n')
)
})
it('says so when a hit matched with no text evidence', () => {
expect(formatSessionSearchResponse(results({ hits: [evidencelessHit] }))).toContain(
'Codex unknown time Index sweep budget\n no text evidence for this match'
)
})
it('reports zero hits as an answer, not a failure', () => {
expect(formatSessionSearchResponse(results({ hits: [] }))).toBe(
['No sessions match this query.', '', '0 results on this page, 18 ms.'].join('\n')
)
})
it('prints the cursor a caller passes back for the next page', () => {
expect(
formatSessionSearchResponse(results({ page: { cursor: 'eyJ2IjoxfQ', hasMore: true } }))
).toContain('more pages: re-run with --cursor eyJ2IjoxfQ')
})
it('admits more pages exist when the host issued no cursor', () => {
expect(
formatSessionSearchResponse(results({ page: { cursor: null, hasMore: true } }))
).toContain('more pages exist, but this host issued no cursor for them')
})
it('renders every truncation flag in words', () => {
const text = formatSessionSearchResponse(
results({ truncated: { candidates: true, snippets: 3, query: true, freshness: true } })
)
expect(text).toContain(
'ranking saw only the first batch of candidate sessions, so a better match may be missing'
)
expect(text).toContain('query was cut')
expect(text).toContain(
'freshness wait timed out; these results come from the index as it stood'
)
expect(text).toContain('3 snippets were shortened')
})
it('prints nothing about truncation when nothing was truncated', () => {
expect(formatSessionSearchResponse(results())).not.toContain('truncat')
})
it('prints the planner route only when the host sent debug', () => {
expect(formatSessionSearchResponse(results())).not.toContain('debug:')
expect(
formatSessionSearchResponse(
results({
debug: {
route: 'typo+phrase',
repairedTerms: ['resize'],
plannerReport: { route: 'typo+phrase', repairedTerms: ['resize'], scope: 'all' }
}
})
)
).toContain(
['debug:', ' route: typo+phrase', ' repairedTerms: resize', ' plannerScope: all'].join(
'\n'
)
)
})
})
describe('formatSessionSearchResponse: non-result answers', () => {
it('reports a disabled index', () => {
expect(formatSessionSearchResponse({ kind: 'unavailable', reason: 'disabled' })).toBe(
'Session search is off on this host.'
)
})
it('names an index that has not started', () => {
expect(formatSessionSearchResponse({ kind: 'unavailable', reason: 'not-ready' })).toContain(
'not ready on this host yet'
)
})
it('tells an old host apart as a host with no service', () => {
expect(formatSessionSearchResponse({ kind: 'unavailable', reason: 'no-service' })).toBe(
[
'This host runs no session search service.',
'An Orca host older than session search answers the same way; update it and try again.'
].join('\n')
)
})
it('says the index moved and to drop the cursor', () => {
expect(formatSessionSearchResponse({ kind: 'stale-cursor', generation: 7 })).toBe(
[
'The index moved on since that page, so the cursor no longer names a place in it.',
'Re-run the same search without --cursor to start again from page 1.'
].join('\n')
)
})
})
describe('terminal safety', () => {
it('strips escape sequences a transcript could carry into the reader terminal', () => {
expect(terminalSafe('before\u001b]52;c;cGF5bG9hZA==\u0007after')).toBe('beforeafter')
expect(terminalSafe('red \u001b[31mtext\u001b[0m')).toBe('red text')
})
it.each([
['updatedAt', { updatedAt: '2026-09-12\u001b[31mT18:04:11.000Z' }, '\u001b'],
['executionHostId', { executionHostId: 'ssh:\u001b]0;pwned\u0007build-01' }, '\u001b'],
['title', { title: 'resize\u001b[2Jrace' }, '\u001b'],
['evidence snippet', { evidence: { ...localHit.evidence!, snippet: 'a\u001b[1mb' } }, '\u001b'],
['resumeCommand', { resumeCommand: 'claude \u001b[3Jresume' }, '\u001b']
])('strips an escape sequence a host put in %s', (_field, override, escape) => {
const text = formatSessionSearchResponse(results({ hits: [{ ...localHit, ...override }] }))
expect(text).not.toContain(escape)
})
it('strips an escape sequence a host put in the next-page cursor', () => {
const text = formatSessionSearchResponse(
results({ page: { cursor: 'eyJ2\u001b[31mIjoxfQ', hasMore: true } })
)
expect(text).toContain('more pages: re-run with --cursor eyJ2IjoxfQ')
expect(text).not.toContain('\u001b')
})
it('strips an escape sequence a host put in a repaired term', () => {
const text = formatSessionSearchResponse(
results({
debug: {
route: 'typo+phrase',
repairedTerms: ['resize\u001b[31m', '\u001b]0;t\u0007race'],
plannerReport: { route: 'typo+phrase', scope: 'all' }
}
})
)
expect(text).toContain(' repairedTerms: resize race')
expect(text).not.toContain('\u001b')
})
it.each([
['root', { root: '/Users/me/\u001b[31m.codex', reason: 'EACCES' }],
['reason', { root: '/r', reason: 'EACCES\u001b]0;x\u0007' }]
])('strips an escape sequence a host put in a degraded %s', (_field, degradedRoot) => {
const text = formatSessionSearchStatus({
enabled: true,
phase: 'degraded',
filesIndexed: 1,
filesDue: 0,
filesFailed: 1,
degradedRoots: [degradedRoot],
lastReconcileAt: null,
lastSweepCompletedAt: null,
generation: 1
})
expect(text).not.toContain('\u001b')
})
it('keeps a snippet on the one indented line it was given', () => {
const text = formatSessionSearchResponse(
results({
hits: [
{
...localHit,
title: 'wrapped\ntitle',
evidence: { ...localHit.evidence!, snippet: 'first\nsecond\u0007' }
}
]
})
)
expect(text).toContain('Claude 2026-09-12T18:04:11.000Z wrapped title host=ssh:build-01')
expect(text).toContain(' assistant: first second')
})
})
describe('formatSessionSearchStatus', () => {
const status: AiVaultSearchStatus = {
enabled: true,
phase: 'indexing',
filesIndexed: 5535,
filesDue: 12,
filesFailed: 1,
degradedRoots: [{ root: '/Users/me/.codex', reason: 'EACCES' }],
lastReconcileAt: 1789000000000,
lastSweepCompletedAt: null,
generation: 42
}
it('reports the indexer observations one per line', () => {
expect(formatSessionSearchStatus(status)).toBe(
[
'enabled: true',
'phase: indexing',
'filesIndexed: 5535',
'filesDue: 12',
'filesFailed: 1',
'lastReconcileAt: 2026-09-10T00:26:40.000Z',
'lastSweepCompletedAt: never',
'generation: 42',
'degradedRoots: 1',
' /Users/me/.codex: EACCES'
].join('\n')
)
})
it('keeps the count when a paired host withheld the root', () => {
expect(
formatSessionSearchStatus({
...status,
degradedRoots: [{ reason: 'Source root could not be verified.' }]
})
).toContain('degradedRoots: 1\n (withheld): Source root could not be verified.')
})
})
+141
View File
@@ -0,0 +1,141 @@
import {
stripAnsiEscapeSequences,
TERMINAL_CONTROL_CHARACTER_PATTERN
} from '../shared/ansi-escape-sequences'
import { aiVaultAgentLabel } from '../shared/ai-vault-types'
import type {
AiVaultSearchHit,
AiVaultSearchResponse,
AiVaultSearchStatus
} from '../shared/ai-vault-search-types'
type SessionSearchResults = Extract<AiVaultSearchResponse, { kind: 'results' }>
/** Malformed cursors are a caller mistake and leave through the CLI error channel. */
export type PrintableSessionSearchResponse = Exclude<
AiVaultSearchResponse,
{ kind: 'malformed-cursor' }
>
/**
* Transcript text reaches the terminal verbatim, so an OSC 52 or cursor sequence
* inside a tool log would otherwise run on the reader's terminal. The `[[` `]]`
* match marks are left as the engine wrote them: this CLI emits no ANSI anywhere,
* so a colour scheme invented here would be the only one in the surface.
*/
export function terminalSafe(value: string): string {
return stripAnsiEscapeSequences(value).replace(TERMINAL_CONTROL_CHARACTER_PATTERN, '')
}
function oneLine(value: string): string {
return terminalSafe(value)
.replaceAll(/[\r\n]+/g, ' ')
.trim()
}
function formatHit(hit: AiVaultSearchHit): string {
const host = oneLine(hit.executionHostId ?? '')
const header = [
aiVaultAgentLabel(hit.agent),
oneLine(hit.updatedAt ?? '') || 'unknown time',
oneLine(hit.title) || '(untitled)',
...(host ? [`host=${host}`] : [])
].join(' ')
const evidence = hit.evidence
? ` ${hit.evidence.role}: ${oneLine(hit.evidence.snippet)}`
: ' no text evidence for this match'
// Withheld for paired callers by the contract, so its absence is not a failure.
const resume = hit.resumeCommand ? [` resume: ${oneLine(hit.resumeCommand)}`] : []
return [header, evidence, ...resume].join('\n')
}
function formatTruncation(truncated: SessionSearchResults['truncated']): string[] {
return [
...(truncated.candidates
? ['ranking saw only the first batch of candidate sessions, so a better match may be missing']
: []),
...(truncated.query ? ['query was cut'] : []),
...(truncated.freshness
? ['freshness wait timed out; these results come from the index as it stood']
: []),
...(truncated.snippets > 0 ? [`${truncated.snippets} snippets were shortened`] : [])
]
}
function formatDebug(debug: SessionSearchResults['debug']): string[] {
if (!debug) {
return []
}
return [
'',
'debug:',
` route: ${debug.route}`,
...(debug.repairedTerms
? [` repairedTerms: ${debug.repairedTerms.map((term) => oneLine(term)).join(' ')}`]
: []),
` plannerScope: ${debug.plannerReport.scope}`
]
}
function formatUnavailable(reason: 'disabled' | 'not-ready' | 'no-service'): string {
if (reason === 'disabled') {
return 'Session search is off on this host.'
}
if (reason === 'not-ready') {
return 'Session search is not ready on this host yet. Try again once its index has started.'
}
return [
'This host runs no session search service.',
'An Orca host older than session search answers the same way; update it and try again.'
].join('\n')
}
function formatResults(response: SessionSearchResults): string {
const body =
response.hits.length === 0 ? ['No sessions match this query.'] : response.hits.map(formatHit)
const cursor = oneLine(response.page.cursor ?? '')
const footer = [
`${response.hits.length} ${response.hits.length === 1 ? 'result' : 'results'} on this page, ${Math.round(response.durationMs)} ms.`,
...(response.page.hasMore && cursor
? [`more pages: re-run with --cursor ${cursor}`]
: response.page.hasMore
? ['more pages exist, but this host issued no cursor for them']
: []),
...formatTruncation(response.truncated)
]
return [...body, '', ...footer, ...formatDebug(response.debug)].join('\n')
}
export function formatSessionSearchResponse(response: PrintableSessionSearchResponse): string {
if (response.kind === 'unavailable') {
return formatUnavailable(response.reason)
}
if (response.kind === 'stale-cursor') {
return [
'The index moved on since that page, so the cursor no longer names a place in it.',
'Re-run the same search without --cursor to start again from page 1.'
].join('\n')
}
return formatResults(response)
}
function formatEpochMs(value: number | null): string {
return value === null ? 'never' : new Date(value).toISOString()
}
export function formatSessionSearchStatus(status: AiVaultSearchStatus): string {
return [
`enabled: ${status.enabled}`,
`phase: ${status.phase}`,
`filesIndexed: ${status.filesIndexed}`,
`filesDue: ${status.filesDue}`,
`filesFailed: ${status.filesFailed}`,
`lastReconcileAt: ${formatEpochMs(status.lastReconcileAt)}`,
`lastSweepCompletedAt: ${formatEpochMs(status.lastSweepCompletedAt)}`,
`generation: ${status.generation}`,
`degradedRoots: ${status.degradedRoots.length}`,
// A paired host withholds the root itself and sends the count with a fixed reason.
...status.degradedRoots.map(
(root) => ` ${root.root ? oneLine(root.root) : '(withheld)'}: ${oneLine(root.reason)}`
)
].join('\n')
}
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import { parseArgs, REPEATED_FLAG_SEPARATOR, specPaths, type CommandSpec } from './args'
const leaf: CommandSpec = {
path: ['example', 'search'],
aliases: [['ex', 'find']],
summary: '',
usage: '',
allowedFlags: ['agent'],
repeatableFlags: ['agent']
}
const parent: CommandSpec = {
path: ['example'],
summary: '',
usage: '',
allowedFlags: ['path'],
repeatableFlags: ['path']
}
const specs = [parent, leaf]
const paths = specs.flatMap(specPaths)
function flags(argv: string[]) {
return parseArgs(argv, paths, specs).flags
}
describe('command-scoped repeatable flags', () => {
it.each(specPaths(leaf))('keeps values before and between %s %s command words', (...path) => {
const parsed = flags(['--agent', 'a', path[0], '--agent=b', path[1], '--agent', 'c'])
expect(parsed.get('agent')).toBe(['a', 'b', 'c'].join(REPEATED_FLAG_SEPARATOR))
})
it('uses the leaf rules for all flags even when the parent has different rules', () => {
const parsed = flags(['--path', '/a', 'example', '--path', '/b', 'search', '--path', '/c'])
expect(parsed.get('path')).toBe('/c')
})
it('still uses the parent rules when the parent itself is invoked', () => {
expect(flags(['--path', '/a', 'example', '--path=/b']).get('path')).toBe(
['/a', '/b'].join(REPEATED_FLAG_SEPARATOR)
)
})
it('preserves a trailing valueless flag for downstream validation', () => {
expect(flags(['example', 'search', '--agent=a', '--agent']).get('agent')).toBe(true)
})
it('preserves the existing reset behavior when a valueless flag precedes more values', () => {
expect(
flags(['example', 'search', '--agent=a', '--agent', '--agent=b', '--agent=c']).get('agent')
).toBe(['b', 'c'].join(REPEATED_FLAG_SEPARATOR))
})
it('preserves empty values and equals signs without reinterpreting flag-like values', () => {
expect(
flags(['example', 'search', '--agent=', '--agent=--help', '--agent=a=b']).get('agent')
).toBe(['', '--help', 'a=b'].join(REPEATED_FLAG_SEPARATOR))
})
})
+51 -9
View File
@@ -24,18 +24,47 @@ export const BOOLEAN_FLAGS = CLI_BOOLEAN_FLAGS
export const REPEATED_FLAG_SEPARATOR = '\u0000'
const REPEATABLE_STRING_FLAGS = new Set(['label', 'skill'])
function setFlagValue(flags: Map<string, string | boolean>, name: string, value: string): void {
function setFlagValue(
flags: Map<string, string | boolean>,
name: string,
value: string,
repeatable: ReadonlySet<string>
): void {
const existing = flags.get(name)
if (typeof existing === 'string' && REPEATABLE_STRING_FLAGS.has(name)) {
if (typeof existing === 'string' && repeatable.has(name)) {
flags.set(name, `${existing}${REPEATED_FLAG_SEPARATOR}${value}`)
return
}
flags.set(name, value)
}
export function parseArgs(argv: string[], commandPaths?: readonly string[][]): ParsedArgs {
/** The most specific spec whose path prefixes `path`, so a group never shadows a leaf. */
function specForPathPrefix(
specs: readonly CommandSpec[],
path: readonly string[]
): CommandSpec | undefined {
let best: { spec: CommandSpec; length: number } | undefined
for (const spec of specs) {
for (const candidate of specPaths(spec)) {
if (
candidate.length <= path.length &&
candidate.every((part, index) => part === path[index]) &&
(!best || candidate.length > best.length)
) {
best = { spec, length: candidate.length }
}
}
}
return best?.spec
}
export function parseArgs(
argv: string[],
commandPaths?: readonly string[][],
specs: readonly CommandSpec[] = []
): ParsedArgs {
const commandPath: string[] = []
const flags = new Map<string, string | boolean>()
const flagEntries: [string, string | boolean][] = []
const commandIndex = findCliCommandIndex(argv, commandPaths ?? [])
for (let i = 0; i < argv.length; i += 1) {
@@ -51,30 +80,42 @@ export function parseArgs(argv: string[], commandPaths?: readonly string[][]): P
// treats a `--`-leading next token as a new flag, so it can't express one.
const equalsIndex = assignment.indexOf('=')
if (equalsIndex !== -1) {
setFlagValue(flags, assignment.slice(0, equalsIndex), assignment.slice(equalsIndex + 1))
flagEntries.push([assignment.slice(0, equalsIndex), assignment.slice(equalsIndex + 1)])
continue
}
const flag = assignment
if (BOOLEAN_FLAGS.has(flag)) {
flags.set(flag, true)
flagEntries.push([flag, true])
continue
}
// Why: a pre-command flag must not consume a registry-resolvable command path.
if (commandPath.length === 0 && i + 1 === commandIndex) {
flags.set(flag, true)
flagEntries.push([flag, true])
continue
}
const hasNext = i + 1 < argv.length
const next = argv[i + 1]
if (!hasNext || next.startsWith('--')) {
flags.set(flag, true)
flagEntries.push([flag, true])
continue
}
setFlagValue(flags, flag, next)
flagEntries.push([flag, next])
i += 1
}
const declared = specForPathPrefix(specs, commandPath)?.repeatableFlags
const repeatable = declared
? new Set([...REPEATABLE_STRING_FLAGS, ...declared])
: REPEATABLE_STRING_FLAGS
const flags = new Map<string, string | boolean>()
for (const [name, value] of flagEntries) {
if (typeof value === 'string') {
setFlagValue(flags, name, value, repeatable)
} else {
flags.set(name, value)
}
}
return { commandPath, flags }
}
@@ -116,6 +157,7 @@ export function supportsBrowserPageFlag(commandPath: string[]): boolean {
'diagnostics',
'linear',
'skills',
'search',
'agent-context'
].includes(commandPath[0])
) {
File diff suppressed because one or more lines are too long
+28
View File
@@ -0,0 +1,28 @@
/** Per-command flag help, kept out of the shared help chain it would crowd. */
const COMMAND_SCOPED_FLAG_HELP: Record<string, Record<string, string>> = {
'skills get': {
full: '--full Print the full guide with bundled references',
reference: '--reference <name> Print one bundled reference by name',
references: '--references List the bundled reference names for a topic'
},
'skills install': {
agent: '--agent <names> Comma-separated install targets; default is detected agents'
},
search: {
query: '--query <text> Search text; also accepted as the positional argument',
scope: '--scope <corpus> conversation (user and assistant turns) or all (default)',
fresh: '--fresh Wait up to 5s for the host to reconcile its index first',
limit: '--limit <n> Hits per page (default 20, maximum 100)',
cursor: '--cursor <cursor> Opaque cursor printed by the previous page of this search',
agent: '--agent <id> Restrict to one agent; repeat for several',
path: '--path <path> Restrict to an execution-host path; repeat for several',
since: '--since <iso> Only sessions updated at or after this ISO 8601 timestamp',
sort: '--sort <order> relevance (default) or newest',
debug: '--debug Include the planner route the host used',
'index-status': '--index-status Report the index instead of searching'
}
}
export function formatCommandScopedFlagHelp(command: string, flag: string): string | undefined {
return COMMAND_SCOPED_FLAG_HELP[command]?.[flag]
}
+3
View File
@@ -9,6 +9,9 @@ export type CommandSpec = {
summary: string
usage: string
allowedFlags: string[]
// Why: repeatability is per-command vocabulary. `--agent` repeats for `search`
// and is single-valued for `worktree create`, which one global set cannot say.
repeatableFlags?: string[]
positionalArgs?: string[]
examples?: string[]
notes?: string[]
+5
View File
@@ -251,5 +251,10 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [
name: 'skills',
keys: ['skills list', 'skills get', 'skills install', 'skills update'],
load: async () => (await import('./handlers/skills.js')).SKILL_HANDLERS
},
{
name: 'search',
keys: ['search'],
load: async () => (await import('./handlers/search.js')).SEARCH_HANDLERS
}
]
+310
View File
@@ -0,0 +1,310 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MALFORMED_CURSOR_MESSAGE, SEARCH_HANDLERS } from './search'
import { AiVaultSearchResponseSchema } from '../../shared/ai-vault-search-contract'
import type { AiVaultSearchResponse, AiVaultSearchStatus } from '../../shared/ai-vault-search-types'
import { REPEATED_FLAG_SEPARATOR } from '../args'
import { RuntimeClientError } from '../runtime/types'
afterEach(() => vi.restoreAllMocks())
const hit = {
agent: 'claude' as const,
executionHostId: 'ssh:build-01',
sessionId: 'session-1',
title: 'Terminal resize race',
cwd: '/src/orca',
branch: 'main',
updatedAt: '2026-09-12T18:04:11.000Z',
messageCount: 214,
score: 12.5,
evidence: {
snippet: 'the [[resize]] handler drops the first event',
role: 'assistant' as const,
timestamp: '2026-09-12T18:04:11.000Z'
},
source: { presence: 'present' as const, filePath: '/transcripts/session-1.jsonl' },
resumeCommand: 'claude --resume session-1'
}
const resultsResponse: AiVaultSearchResponse = {
kind: 'results',
hits: [hit],
page: { cursor: 'eyJ2IjoxfQ', hasMore: true },
generation: 42,
truncated: { candidates: false, snippets: 0, query: false, freshness: false },
durationMs: 18
}
const statusResponse: AiVaultSearchStatus = {
enabled: true,
phase: 'current',
filesIndexed: 12,
filesDue: 0,
filesFailed: 0,
degradedRoots: [],
lastReconcileAt: 1789000000000,
lastSweepCompletedAt: 1789000000000,
generation: 42
}
function envelope(result: unknown) {
return { id: 'request-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } }
}
async function runSearch(
flags: [string, string | boolean][],
options: {
result?: unknown
error?: unknown
json?: boolean
isRemote?: boolean
} = {}
): Promise<{ call: ReturnType<typeof vi.fn>; output: string }> {
const call = options.error
? vi.fn().mockRejectedValue(options.error)
: vi.fn().mockResolvedValue(envelope(options.result ?? resultsResponse))
const lines: string[] = []
vi.spyOn(console, 'log').mockImplementation((value: unknown) => {
lines.push(String(value))
})
await SEARCH_HANDLERS.search!({
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only `call` and `isRemote`; a real RuntimeClient would resolve runtime metadata and open a socket.
client: { call, isRemote: options.isRemote ?? false } as never,
cwd: '/workspace',
flags: new Map(flags),
json: options.json ?? false
})
return { call, output: lines.join('\n') }
}
type CliFlags = [string, string | boolean][]
/** The printed envelope, narrowed by shape rather than asserted. */
function printedEnvelope(output: string): { keys: string[]; result: unknown } {
const parsed: unknown = JSON.parse(output)
if (typeof parsed !== 'object' || parsed === null || !('result' in parsed)) {
throw new Error(`Not an RPC envelope: ${output}`)
}
return { keys: Object.keys(parsed), result: parsed.result }
}
/** Re-reads the printed result through the contract, so the shape is checked, not claimed. */
function printedResults(output: string): Extract<AiVaultSearchResponse, { kind: 'results' }> {
const parsed = AiVaultSearchResponseSchema.parse(printedEnvelope(output).result)
if (parsed.kind !== 'results') {
throw new Error(`Expected results, got ${parsed.kind}`)
}
return parsed
}
describe('orca search over the runtime RPC', () => {
it('sends the query with the contract defaults the schema resolves', async () => {
const { call } = await runSearch([['query', 'resize race']])
expect(call).toHaveBeenCalledTimes(1)
expect(call).toHaveBeenCalledWith('aiVault.searchSessions', { query: 'resize race', limit: 20 })
})
const flagCases: [string, CliFlags, Record<string, unknown>][] = [
[
'scope and freshness',
[
['query', 'q'],
['scope', 'conversation'],
['fresh', true]
],
{ query: 'q', scope: 'conversation', freshness: 'wait-until-current', limit: 20 }
],
[
'paging',
[
['query', 'q'],
['limit', '50'],
['cursor', 'eyJ2IjoxfQ']
],
{ query: 'q', limit: 50, cursor: 'eyJ2IjoxfQ' }
],
[
'filters',
[
['query', 'q'],
['agent', `claude${REPEATED_FLAG_SEPARATOR}codex`],
['path', `/a${REPEATED_FLAG_SEPARATOR}/b`],
['since', '2026-08-01T00:00:00Z'],
['sort', 'newest']
],
{
query: 'q',
limit: 20,
filters: {
agents: ['claude', 'codex'],
scopePaths: ['/a', '/b'],
since: '2026-08-01T00:00:00Z',
sort: 'newest'
}
}
],
[
'debug',
[
['query', 'q'],
['debug', true]
],
{ query: 'q', limit: 20, debug: true }
]
]
it.each(flagCases)('sends %s', async (_name, flags, params) => {
const { call } = await runSearch(flags)
expect(call).toHaveBeenCalledWith('aiVault.searchSessions', params)
})
it('calls the status RPC for --index-status', async () => {
const { call, output } = await runSearch([['index-status', true]], { result: statusResponse })
expect(call).toHaveBeenCalledWith('aiVault.searchStatus', {})
expect(output).toContain('phase: current')
})
it('renders a result page as text', async () => {
const { output } = await runSearch([['query', 'q']])
expect(output).toBe(
[
'Claude 2026-09-12T18:04:11.000Z Terminal resize race host=ssh:build-01',
' assistant: the [[resize]] handler drops the first event',
' resume: claude --resume session-1',
'',
'1 result on this page, 18 ms.',
'more pages: re-run with --cursor eyJ2IjoxfQ'
].join('\n')
)
})
it('renders a disabled index as an answer', async () => {
const { output } = await runSearch([['query', 'q']], {
result: { kind: 'unavailable', reason: 'disabled' }
})
expect(output).toBe('Session search is off on this host.')
})
it('renders a stale cursor as guidance to re-run without one', async () => {
const { output } = await runSearch(
[
['query', 'q'],
['cursor', 'eyJ2IjoxfQ']
],
{ result: { kind: 'stale-cursor', generation: 7, expectedGeneration: 8 } }
)
expect(output).toContain('Re-run the same search without --cursor')
})
it('raises a malformed cursor through the CLI error channel', async () => {
await expect(
runSearch(
[
['query', 'q'],
['cursor', 'nope']
],
{ result: { kind: 'malformed-cursor' } }
)
).rejects.toThrow(MALFORMED_CURSOR_MESSAGE)
})
it('answers a host with no such method as unavailable rather than a raw error', async () => {
const { output } = await runSearch([['query', 'q']], {
error: new RuntimeClientError('method_not_found', 'Unknown method aiVault.searchSessions')
})
expect(output).toContain('This host runs no session search service.')
})
it('answers an old host asked for status with the absent-service sentinel', async () => {
const { output } = await runSearch([['index-status', true]], {
error: new RuntimeClientError('method_not_found', 'Unknown method aiVault.searchStatus')
})
expect(output).toContain('enabled: false')
expect(output).toContain('phase: idle')
})
it('propagates a transport failure instead of calling it unavailable', async () => {
await expect(
runSearch([['query', 'q']], {
error: new RuntimeClientError('runtime_unavailable', 'Orca is not running.')
})
).rejects.toThrow('Orca is not running.')
})
it('applies the paired-client exposure policy for a remote runtime', async () => {
const { output } = await runSearch([['query', 'q']], { isRemote: true, json: true })
expect(printedResults(output).hits[0]).not.toHaveProperty('resumeCommand')
expect(printedResults(output).hits[0]?.source).toEqual({ presence: 'present' })
})
it('keeps the local resume command and source path for a same-machine host', async () => {
const { output } = await runSearch([['query', 'q']], { json: true })
expect(printedResults(output).hits[0]?.resumeCommand).toBe('claude --resume session-1')
expect(printedResults(output).hits[0]?.source).toEqual({
presence: 'present',
filePath: '/transcripts/session-1.jsonl'
})
})
})
describe('orca search --json', () => {
it('hands back the contract response unchanged under the CLI envelope', async () => {
const { output } = await runSearch([['query', 'q']], { json: true })
const printed = printedEnvelope(output)
expect(printed.keys).toEqual(['id', 'ok', 'result', '_meta'])
expect(printed.result).toEqual(resultsResponse)
expect(JSON.stringify(printed.result)).toBe(JSON.stringify(resultsResponse))
})
it('drops the debug block the caller did not ask for', async () => {
const withDebug = {
...resultsResponse,
debug: {
route: 'phrase' as const,
plannerReport: { route: 'phrase' as const, scope: 'all' as const }
}
}
const { output } = await runSearch([['query', 'q']], { json: true, result: withDebug })
expect(printedEnvelope(output).result).not.toHaveProperty('debug')
})
it('keeps the debug block the caller asked for', async () => {
const withDebug = {
...resultsResponse,
debug: {
route: 'phrase' as const,
plannerReport: { route: 'phrase' as const, scope: 'all' as const }
}
}
const { output } = await runSearch(
[
['query', 'q'],
['debug', true]
],
{ json: true, result: withDebug }
)
expect(printedResults(output).debug).toEqual(withDebug.debug)
})
it('hands back the status response unchanged', async () => {
const { output } = await runSearch([['index-status', true]], {
json: true,
result: statusResponse
})
expect(printedEnvelope(output).result).toEqual(statusResponse)
})
})
+55
View File
@@ -0,0 +1,55 @@
import { createSessionSearchClient } from '../../shared/ai-vault-search-client'
import type { RuntimeRpcSuccess } from '../../shared/runtime-rpc-envelope'
import {
formatSessionSearchResponse,
formatSessionSearchStatus
} from '../agent-session-search-format'
import type { CommandHandler } from '../dispatch'
import { printResult } from '../format'
import type { RuntimeClient } from '../runtime-client'
import { RuntimeClientError } from '../runtime/types'
import { parseSearchCommand } from '../search-command-arguments'
export const MALFORMED_CURSOR_MESSAGE =
'The host did not recognise that --cursor value. Cursors belong to one query on one host; re-run the search without --cursor.'
/**
* The shared contract client over the CLI's runtime RPC. Reusing it is what makes
* an old host's unknown-method refusal an `unavailable/no-service` answer instead
* of a raw JSON-RPC error, and it applies the same exposure policy the host did:
* a paired runtime is a relay caller, a local one is not.
*/
function createCliSessionSearch(client: RuntimeClient) {
let lastEnvelope: RuntimeRpcSuccess<unknown> | undefined
const search = createSessionSearchClient(
async (method, params) => {
lastEnvelope = await client.call(method, params)
return lastEnvelope.result
},
client.isRemote ? 'relay' : 'runtime'
)
// Why `client`: an answer synthesised from a refusal had no successful call, so
// no runtime produced it and none of its identifiers may be claimed here.
const envelope = <TResult>(result: TResult): RuntimeRpcSuccess<TResult> =>
lastEnvelope
? { ...lastEnvelope, result }
: { id: 'local', ok: true, result, _meta: { runtimeId: 'client' } }
return { search, envelope }
}
/** `orca search` over `aiVault.searchSessions` / `aiVault.searchStatus` on one host. */
export const SEARCH_HANDLERS: Record<string, CommandHandler> = {
search: async ({ client, flags, json }) => {
const command = parseSearchCommand(flags)
const { search, envelope } = createCliSessionSearch(client)
if (command.kind === 'index-status') {
printResult(envelope(await search.searchStatus()), json, formatSessionSearchStatus)
return
}
const response = await search.searchSessions(command.request)
if (response.kind === 'malformed-cursor') {
throw new RuntimeClientError('invalid_argument', MALFORMED_CURSOR_MESSAGE)
}
printResult(envelope(response), json, formatSessionSearchResponse)
}
}
+4 -4
View File
@@ -1,8 +1,8 @@
import type { CommandSpec } from './args'
import { findCommandSpec, isCommandGroup, supportsBrowserPageFlag } from './args'
import { unknownCommandData } from './command-suggestion'
import { formatCommandScopedFlagHelp } from './command-scoped-flag-help'
import { FLAG_HELP_TEXT } from './flag-help-text'
import { formatSkillsCommandFlagHelp } from './skills-command-flag-help'
import { ROOT_HELP_TEXT_PRIMARY } from './root-help-text-primary'
import { ROOT_HELP_TEXT_SECONDARY } from './root-help-text-secondary'
@@ -74,9 +74,9 @@ export function formatGroupHelp(specs: CommandSpec[], group: string): string {
function formatCommandFlagHelp(flag: string, commandPath: string[]): string {
const command = commandPath.join(' ')
const skillsHelp = formatSkillsCommandFlagHelp(command, flag)
if (skillsHelp) {
return skillsHelp
const scopedHelp = formatCommandScopedFlagHelp(command, flag)
if (scopedHelp) {
return scopedHelp
}
if (command === 'terminal close' && flag === 'tab') {
return '--tab Close the whole tab and wait for durable persistence'
+4 -1
View File
@@ -83,7 +83,10 @@ export async function main(
await runClaudeTeams(argv.slice(1), cwd)
return
}
const parsed = normalizeCommandPositionals(COMMAND_SPECS, parseArgs(argv, COMMAND_PATHS))
const parsed = normalizeCommandPositionals(
COMMAND_SPECS,
parseArgs(argv, COMMAND_PATHS, COMMAND_SPECS)
)
const helpPath = resolveHelpPath(parsed)
if (helpPath !== null) {
printHelp(COMMAND_SPECS, helpPath)
+3
View File
@@ -14,6 +14,9 @@ export const ROOT_HELP_TEXT_PRIMARY = [
'Agent Discovery:',
' agent-context Print the machine-readable command schema for agents',
'',
'Agent Sessions:',
' search Search the full text of agent sessions on one Orca host',
'',
'Accounts:',
' account add Add a managed Claude or Codex account on this Orca host',
' account list List managed Claude and Codex accounts on this Orca host',
+2
View File
@@ -40,6 +40,8 @@ export const ROOT_HELP_TEXT_SECONDARY = [
' orca status [--json]',
' orca diagnostics memory [--json]',
' orca agent-context [--json]',
' orca search <query> [--scope conversation|all] [--fresh] [--limit <n>] [--cursor <c>] [--agent <id>] [--path <p>] [--since <iso>] [--sort relevance|newest] [--debug] [--json]',
' orca search --index-status [--json]',
' orca account add [--agent claude|codex] [--json]',
' orca account list [--json]',
' orca host list [--json]',
+244
View File
@@ -0,0 +1,244 @@
import { describe, expect, it } from 'vitest'
import {
normalizeCommandPositionals,
parseArgs,
specPaths,
validateCommandAndFlags,
type ParsedArgs
} from './args'
import { parseSearchCommand } from './search-command-arguments'
import { COMMAND_SPECS } from './specs'
const COMMAND_PATHS = COMMAND_SPECS.flatMap((spec) => specPaths(spec))
/** The real registry pipeline, so these assertions cover the shipped spec too. */
function parseCli(argv: string[]): ParsedArgs {
const parsed = normalizeCommandPositionals(
COMMAND_SPECS,
parseArgs(argv, COMMAND_PATHS, COMMAND_SPECS)
)
validateCommandAndFlags(COMMAND_SPECS, parsed)
return parsed
}
function parseSearch(argv: string[]): ReturnType<typeof parseSearchCommand> {
return parseSearchCommand(parseCli(argv).flags)
}
function request(argv: string[]) {
const command = parseSearch(argv)
if (command.kind !== 'search') {
throw new Error(`Expected a search, got ${command.kind}`)
}
return command.request
}
describe('orca search argument parsing', () => {
it('takes the query positionally', () => {
expect(parseCli(['search', 'resize race']).commandPath).toEqual(['search'])
expect(request(['search', 'resize race'])).toEqual({ query: 'resize race' })
})
it('takes the query as --query', () => {
expect(request(['search', '--query', 'resize race'])).toEqual({ query: 'resize race' })
})
it('refuses the query given both ways', () => {
expect(() => parseCli(['search', 'one', '--query', 'two'])).toThrow(
'Pass --query either positionally or as a flag, not both.'
)
})
it('refuses a search with no query', () => {
expect(() => parseSearch(['search'])).toThrow('Missing a search query')
})
it('maps every flag onto the contract request', () => {
expect(
request([
'search',
'kernel panic',
'--scope',
'conversation',
'--fresh',
'--limit',
'50',
'--cursor',
'eyJ2IjoxfQ',
'--agent',
'claude',
'--agent',
'codex',
'--path',
'/Users/me/orca',
'--path',
'C:\\src\\orca',
'--since',
'2026-08-01T00:00:00Z',
'--sort',
'newest',
'--debug'
])
).toEqual({
query: 'kernel panic',
scope: 'conversation',
freshness: 'wait-until-current',
limit: 50,
cursor: 'eyJ2IjoxfQ',
filters: {
agents: ['claude', 'codex'],
scopePaths: ['/Users/me/orca', 'C:\\src\\orca'],
since: '2026-08-01T00:00:00Z',
sort: 'newest'
},
debug: true
})
})
it('omits every optional field the caller did not name', () => {
expect(Object.keys(request(['search', 'q']))).toEqual(['query'])
})
it('reads --scope all and --sort relevance', () => {
expect(request(['search', 'q', '--scope', 'all', '--sort', 'relevance'])).toMatchObject({
scope: 'all',
filters: { sort: 'relevance' }
})
})
it('accepts --flag=value for a repeated flag', () => {
expect(request(['search', 'q', '--path=/a', '--path=/b'])).toMatchObject({
filters: { scopePaths: ['/a', '/b'] }
})
})
it('rejects an unsupported --scope', () => {
expect(() => parseSearch(['search', 'q', '--scope', 'files'])).toThrow(
'Unsupported --scope "files". Use conversation or all.'
)
})
it('rejects an unsupported --sort', () => {
expect(() => parseSearch(['search', 'q', '--sort', 'oldest'])).toThrow(
'Unsupported --sort "oldest". Use relevance or newest.'
)
})
it('rejects an unknown --agent and names the known ones', () => {
expect(() => parseSearch(['search', 'q', '--agent', 'claude', '--agent', 'bogus'])).toThrow(
/Unknown --agent "bogus"\. Known agents: claude, codex, /
)
})
it('rejects more --path values than the contract accepts', () => {
const paths = Array.from({ length: 65 }, (_, index) => ['--path', `/p${index}`]).flat()
expect(() => parseSearch(['search', 'q', ...paths])).toThrow('Too many --path values (65)')
})
it('accepts the maximum number of --path values', () => {
const paths = Array.from({ length: 64 }, (_, index) => ['--path', `/p${index}`]).flat()
expect(request(['search', 'q', ...paths]).filters?.scopePaths).toHaveLength(64)
})
it('rejects a --since without an offset', () => {
expect(() => parseSearch(['search', 'q', '--since', '2026-08-01'])).toThrow(
'Invalid --since "2026-08-01"'
)
})
it.each([
['--limit', '0'],
['--limit', '-1'],
['--limit', '1.5'],
['--limit', 'many']
])('rejects %s %s', (flag, value) => {
expect(() => parseSearch(['search', 'q', flag, value])).toThrow(/--limit/)
})
it('rejects a valueless --cursor', () => {
expect(() => parseSearch(['search', 'q', '--cursor', '--json'])).toThrow(
'--cursor requires a value; it was passed with none.'
)
})
it('rejects an unknown flag against the live registry', () => {
expect(() => parseCli(['search', 'q', '--tier', 'fast'])).toThrow(
'Unknown flag --tier for command: search'
)
})
it('does not accept the browser --page flag', () => {
expect(() => parseCli(['search', 'q', '--page', 'page_1'])).toThrow(
'Unknown flag --page for command: search'
)
})
})
describe('orca search --index-status', () => {
it('asks for the index report', () => {
expect(parseSearch(['search', '--index-status'])).toEqual({ kind: 'index-status' })
})
it.each([
[['search', 'q', '--index-status'], '--query'],
[['search', '--index-status', '--limit', '5'], '--limit'],
[['search', '--index-status', '--fresh'], '--fresh'],
[['search', '--index-status', '--agent', 'claude'], '--agent']
])('refuses %j because it also names %s', (argv, flag) => {
expect(() => parseSearch(argv)).toThrow(
`--index-status reports on the index and takes no query, so it cannot be combined with ${flag}.`
)
})
})
describe('repeatable flags are command-scoped', () => {
it('repeats --agent for search', () => {
expect(request(['search', 'q', '--agent', 'claude', '--agent', 'codex'])).toMatchObject({
filters: { agents: ['claude', 'codex'] }
})
})
it('repeats --agent placed before the command', () => {
expect(request(['--agent', 'claude', '--agent', 'codex', 'search', 'q'])).toMatchObject({
filters: { agents: ['claude', 'codex'] }
})
})
it('repeats --path across the command boundary', () => {
expect(request(['--path', '/a', 'search', 'q', '--path', '/b'])).toMatchObject({
filters: { scopePaths: ['/a', '/b'] }
})
})
it('leaves a pre-command --agent single-valued for worktree create', () => {
const parsed = parseCli([
'--agent',
'claude',
'--agent',
'codex',
'worktree',
'create',
'--name',
'w'
])
expect(parsed.flags.get('agent')).toBe('codex')
})
it('leaves --agent single-valued for worktree create', () => {
const parsed = parseCli([
'worktree',
'create',
'--name',
'w',
'--agent',
'claude',
'--agent',
'codex'
])
expect(parsed.flags.get('agent')).toBe('codex')
})
it('leaves --path single-valued for repo add', () => {
expect(parseCli(['repo', 'add', '--path', '/a', '--path', '/b']).flags.get('path')).toBe('/b')
})
})
+162
View File
@@ -0,0 +1,162 @@
import {
AI_VAULT_AGENTS,
AI_VAULT_SCOPE_PATHS_MAX_COUNT,
type AiVaultAgent
} from '../shared/ai-vault-types'
import { AiVaultSearchFiltersSchema } from '../shared/ai-vault-search-contract'
import type { AiVaultSearchRequest } from '../shared/ai-vault-search-types'
import {
getOptionalPositiveIntegerFlag,
getOptionalStringFlag,
getRepeatedStringFlag
} from './flags'
import { RuntimeClientError } from './runtime/types'
export type SearchCommand =
| { kind: 'index-status' }
| { kind: 'search'; request: AiVaultSearchRequest }
// Why enumerated: --index-status calls a different RPC that reads none of these,
// so ignoring one would answer a question the caller did not ask.
const QUERY_ONLY_FLAGS = [
'query',
'scope',
'fresh',
'limit',
'cursor',
'agent',
'path',
'since',
'sort',
'debug'
] as const
function readEnum<TValue extends string>(
flags: Map<string, string | boolean>,
name: string,
allowed: readonly TValue[]
): TValue | undefined {
const value = getOptionalStringFlag(flags, name)
if (value === undefined) {
return undefined
}
// Why find and not includes: the match carries the narrow type, so nothing is asserted.
const matched = allowed.find((candidate) => candidate === value)
if (matched === undefined) {
throw new RuntimeClientError(
'invalid_argument',
`Unsupported --${name} "${value}". Use ${allowed.join(' or ')}.`
)
}
return matched
}
const KNOWN_AGENTS = new Set<string>(AI_VAULT_AGENTS)
function isAiVaultAgent(value: string): value is AiVaultAgent {
return KNOWN_AGENTS.has(value)
}
function readAgents(flags: Map<string, string | boolean>): AiVaultAgent[] | undefined {
const agents = getRepeatedStringFlag(flags, 'agent')
if (agents.length === 0) {
return undefined
}
const unknown = agents.filter((agent) => !isAiVaultAgent(agent))
if (unknown.length > 0) {
throw new RuntimeClientError(
'invalid_argument',
`Unknown --agent ${unknown.map((agent) => `"${agent}"`).join(', ')}. Known agents: ${AI_VAULT_AGENTS.join(', ')}.`
)
}
return agents.filter(isAiVaultAgent)
}
function readScopePaths(flags: Map<string, string | boolean>): string[] | undefined {
const paths = getRepeatedStringFlag(flags, 'path')
if (paths.length === 0) {
return undefined
}
if (paths.length > AI_VAULT_SCOPE_PATHS_MAX_COUNT) {
throw new RuntimeClientError(
'invalid_argument',
`Too many --path values (${paths.length}); at most ${AI_VAULT_SCOPE_PATHS_MAX_COUNT} are accepted.`
)
}
return paths
}
// Why the contract's own schema: the offset requirement lives there, and a
// second copy here would drift from what the host accepts.
function readSince(flags: Map<string, string | boolean>): string | undefined {
const since = getOptionalStringFlag(flags, 'since')
if (since === undefined) {
return undefined
}
if (!AiVaultSearchFiltersSchema.shape.since.safeParse(since).success) {
throw new RuntimeClientError(
'invalid_argument',
`Invalid --since "${since}". Use an ISO 8601 timestamp with an offset, for example 2026-08-01T00:00:00Z.`
)
}
return since
}
function readQuery(flags: Map<string, string | boolean>): string {
const query = getOptionalStringFlag(flags, 'query')
if (query === undefined) {
throw new RuntimeClientError(
'invalid_argument',
'Missing a search query. Pass it as `orca search "<query>"` or --query "<query>", or ask for the index report with --index-status.'
)
}
return query
}
function readFilters(
flags: Map<string, string | boolean>
): AiVaultSearchRequest['filters'] | undefined {
const agents = readAgents(flags)
const scopePaths = readScopePaths(flags)
const since = readSince(flags)
const sort = readEnum(flags, 'sort', ['relevance', 'newest'] as const)
const filters = {
...(agents ? { agents } : {}),
...(scopePaths ? { scopePaths } : {}),
...(since ? { since } : {}),
...(sort ? { sort } : {})
}
return Object.keys(filters).length > 0 ? filters : undefined
}
/** Maps `orca search` flags onto the session-search contract; nothing it does not have. */
export function parseSearchCommand(flags: Map<string, string | boolean>): SearchCommand {
if (flags.has('index-status')) {
const conflicting = QUERY_ONLY_FLAGS.filter((flag) => flags.has(flag))
if (conflicting.length > 0) {
throw new RuntimeClientError(
'invalid_argument',
`--index-status reports on the index and takes no query, so it cannot be combined with ${conflicting.map((flag) => `--${flag}`).join(', ')}.`
)
}
return { kind: 'index-status' }
}
const query = readQuery(flags)
const scope = readEnum(flags, 'scope', ['conversation', 'all'] as const)
const limit = getOptionalPositiveIntegerFlag(flags, 'limit')
const cursor = getOptionalStringFlag(flags, 'cursor')
const filters = readFilters(flags)
return {
kind: 'search',
request: {
query,
...(scope ? { scope } : {}),
...(flags.has('fresh') ? { freshness: 'wait-until-current' as const } : {}),
...(limit === undefined ? {} : { limit }),
...(cursor ? { cursor } : {}),
...(filters ? { filters } : {}),
...(flags.has('debug') ? { debug: true } : {})
}
}
}
-15
View File
@@ -1,15 +0,0 @@
/** Per-flag help for the skills commands, kept out of the shared help chain it would crowd. */
const SKILLS_FLAG_HELP: Record<string, Record<string, string>> = {
'skills get': {
full: '--full Print the full guide with bundled references',
reference: '--reference <name> Print one bundled reference by name',
references: '--references List the bundled reference names for a topic'
},
'skills install': {
agent: '--agent <names> Comma-separated install targets; default is detected agents'
}
}
export function formatSkillsCommandFlagHelp(command: string, flag: string): string | undefined {
return SKILLS_FLAG_HELP[command]?.[flag]
}
+3 -1
View File
@@ -17,6 +17,7 @@ import { LINEAR_COMMAND_SPECS } from './linear'
import { VM_COMMAND_SPECS } from './vm'
import { SKILL_COMMAND_SPECS } from './skills'
import { ARTIFACT_COMMAND_SPECS } from './artifacts'
import { SEARCH_COMMAND_SPECS } from './search'
export const COMMAND_SPECS: CommandSpec[] = [
...CORE_COMMAND_SPECS,
@@ -36,5 +37,6 @@ export const COMMAND_SPECS: CommandSpec[] = [
...LINEAR_COMMAND_SPECS,
...VM_COMMAND_SPECS,
...EMULATOR_COMMAND_SPECS,
...SKILL_COMMAND_SPECS
...SKILL_COMMAND_SPECS,
...SEARCH_COMMAND_SPECS
]
+128
View File
@@ -0,0 +1,128 @@
import { describe, expect, it } from 'vitest'
import { SEARCH_COMMAND_SPECS } from './search'
import { effectiveAllowedFlags, findCommandSpec, GLOBAL_FLAGS } from '../args'
import { buildAgentContext } from '../agent-context'
import { suggestCommands } from '../command-suggestion'
import { HANDLER_COMMAND_KEYS } from '../dispatch'
import { formatCommandHelp, printHelp } from '../help'
import { ROOT_HELP_TEXT_PRIMARY } from '../root-help-text-primary'
import { ROOT_HELP_TEXT_SECONDARY } from '../root-help-text-secondary'
import { COMMAND_SPECS } from './index'
import { CLI_COMMAND_NAMES } from '../../main/startup/cli-command-names'
const searchSpec = SEARCH_COMMAND_SPECS[0]!
const help = formatCommandHelp(searchSpec)
describe('orca search command spec', () => {
it('is one command, not a group, because the query is a bare positional', () => {
expect(SEARCH_COMMAND_SPECS).toHaveLength(1)
expect(searchSpec.path).toEqual(['search'])
expect(searchSpec.positionalArgs).toEqual(['query'])
})
it('is registered in the live spec table, the dispatcher and the launch redirect', () => {
expect(COMMAND_SPECS).toContain(searchSpec)
expect(HANDLER_COMMAND_KEYS.has('search')).toBe(true)
expect(CLI_COMMAND_NAMES).toContain('search')
})
it('accepts exactly the flags that map onto the search contract', () => {
expect([...searchSpec.allowedFlags].sort()).toEqual([
'agent',
'cursor',
'debug',
'environment',
'fresh',
'help',
'index-status',
'json',
'limit',
'pairing-code',
'path',
'query',
'scope',
'since',
'sort'
])
})
it('declares --agent and --path repeatable for this command only', () => {
expect(searchSpec.repeatableFlags).toEqual(['agent', 'path'])
for (const spec of COMMAND_SPECS) {
if (spec !== searchSpec) {
expect(spec.repeatableFlags).toBeUndefined()
}
}
})
it('does not accept or advertise browser page targeting', () => {
expect(effectiveAllowedFlags(searchSpec)).not.toContain('page')
expect(help).not.toContain('--page')
})
it('describes every search flag rather than falling back to the bare name', () => {
const searchOnly = searchSpec.allowedFlags.filter((flag) => !GLOBAL_FLAGS.includes(flag))
expect(searchOnly).toHaveLength(11)
for (const flag of searchOnly) {
expect(help).toContain(`--${flag}`)
expect(help.split('\n')).not.toContain(` --${flag}`)
}
})
it('describes --agent as a search filter, not a terminal agent to launch', () => {
expect(help).toContain('Restrict to one agent; repeat for several')
expect(help).not.toContain('TUI agent')
})
it('tells the reader to quote a multi-word query', () => {
expect(searchSpec.notes?.join('\n')).toContain('Quote a multi-word query')
})
it('states that it searches one host and offers no all-computers search', () => {
expect(searchSpec.notes?.join('\n')).toContain('There is no all-computers search.')
})
it('shows both the query and the index report in its usage', () => {
expect(searchSpec.usage).toContain('orca search <query>')
expect(searchSpec.usage).toContain('orca search --index-status')
})
})
describe('orca search discovery surfaces', () => {
it('is listed in the root help', () => {
expect(ROOT_HELP_TEXT_PRIMARY).toContain('Agent Sessions:')
expect(ROOT_HELP_TEXT_PRIMARY).toContain(
' search Search the full text of agent sessions on one Orca host'
)
expect(ROOT_HELP_TEXT_SECONDARY).toContain(' orca search --index-status [--json]')
})
it('prints its own help for `orca search --help`', () => {
const lines: string[] = []
const restore = console.log
console.log = (value: unknown) => void lines.push(String(value))
try {
printHelp(COMMAND_SPECS, ['search'])
} finally {
console.log = restore
}
expect(lines.join('\n')).toContain('Usage: orca search <query>')
})
it('resolves for dispatch', () => {
expect(findCommandSpec(COMMAND_SPECS, ['search'])).toBe(searchSpec)
})
it('exposes the command to agent discovery with its positional and flags', () => {
const command = buildAgentContext(COMMAND_SPECS).commands.find(
(entry) => entry.command === 'search'
)
expect(command?.positionalArgs).toEqual(['query'])
expect(command?.flags).toContain('index-status')
expect(command?.flags).not.toContain('page')
})
it('is offered as a suggestion for a near-miss command', () => {
expect(suggestCommands(COMMAND_SPECS, ['serch'])).toContain('search')
})
})
+50
View File
@@ -0,0 +1,50 @@
import { GLOBAL_FLAGS, type CommandSpec } from '../args'
// Why one command and not a `search status` subcommand: the query is a bare
// positional, so `orca search status` would be indistinguishable from searching
// for the word "status". The index report is a flag on the same command instead.
export const SEARCH_COMMAND_SPECS: CommandSpec[] = [
{
path: ['search'],
summary: 'Search the full text of agent sessions indexed on the selected Orca host',
usage:
'orca search <query> [--scope conversation|all] [--fresh] [--limit <n>] [--cursor <c>] [--agent <id>] [--path <p>] [--since <iso>] [--sort relevance|newest] [--debug] [--json]\n orca search --index-status [--json]',
allowedFlags: [
...GLOBAL_FLAGS,
'query',
'scope',
'fresh',
'limit',
'cursor',
'agent',
'path',
'since',
'sort',
'debug',
'index-status'
],
repeatableFlags: ['agent', 'path'],
positionalArgs: ['query'],
notes: [
'Searches one host: this machine, or the paired Orca server named by --environment / --pairing-code. There is no all-computers search.',
'In an Orca SSH terminal, the forwarded CLI searches the controlling Orca runtime by default. Use --environment / --pairing-code to select a paired server; --path only filters results on the selected runtime.',
'Quote a multi-word query, or pass it as --query "<text>"; unquoted words are read as command names.',
'--scope conversation searches user and assistant turns only; --scope all (the default) also searches commands and tool output.',
'--fresh waits up to five seconds for the host to reconcile its index before searching, then searches anyway.',
'--agent and --path may be repeated. --path is a literal execution-host path and is not expanded or resolved against the current directory.',
'--since takes an ISO 8601 timestamp with an offset, for example 2026-08-01T00:00:00Z.',
'--limit is per page (default 20, maximum 100). Pass the printed cursor back with --cursor to read the next page.',
'A cursor belongs to one query on one host. Change the query, the filters, or the host and the cursor stops being valid.',
'Resume commands and source paths are printed only for a host on this machine; a paired server withholds them.',
'--json prints the runtime response envelope with the search contract answer under `result`.'
],
examples: [
'orca search "strict mode violation getByRole"',
'orca search resolveTerminalPath --agent claude --sort newest',
'orca search "kernel panic" --path /Users/me/orca --since 2026-08-01T00:00:00Z --json',
'orca search "kernel panic" --limit 50 --cursor eyJ2IjoxfQ',
'orca search --index-status',
'orca search "flaky test" --environment build-server'
]
}
]
@@ -118,7 +118,21 @@ describe('maybeAutoRenameBranchOnFirstWork', () => {
})
const feed = new StructuredAgentSessionStatusFeed({
sessions: new Map([
['session', { journal, params: { location: { workspaceId }, provider: agent } }]
[
'session',
{
journal,
params: {
location: {
executionHostId: 'local',
wslDistro: null,
workspaceId,
workspaceKind: 'git-worktree'
},
provider: agent
}
}
]
]),
getRecord: () => null,
now: () => 1,
@@ -193,7 +207,12 @@ describe('maybeAutoRenameBranchOnFirstWork', () => {
]
})
} as unknown as AgentSessionJournal
const location = { workspaceId, workspaceKind: 'git-worktree' as const }
const location = {
executionHostId: 'local' as const,
wslDistro: null,
workspaceId,
workspaceKind: 'git-worktree' as const
}
const pending: Promise<void>[] = []
const feed = new StructuredAgentSessionStatusFeed({
sessions: new Map([['session', { journal, params: { location, provider: 'codex' } }]]),
+2 -10
View File
@@ -707,10 +707,7 @@ describe('wrapWindowsHookCommand', () => {
describe('wrapWindowsCmdHookCommand', () => {
it('returns the bare, directly-spawnable path for a cmd-safe managed script', () => {
// Why: Codex/Antigravity/Devin launch the command as a program (argv[0]),
// not via cmd.exe, so the launcher must be a single spawnable token — a bare
// .cmd path. A cmd-builtin `if …` launcher has argv[0] = `if`, which is
// unspawnable and fails every hook with exit 1 (#8430 regression).
// Direct-spawn consumers need a launchable argv[0], not a cmd builtin such as `if`.
const scriptPath = 'C:\\Users\\alice\\.orca\\agent-hooks\\codex-hook.cmd'
const command = wrapWindowsCmdHookCommand(scriptPath)
expect(command).toBe(scriptPath)
@@ -721,12 +718,7 @@ describe('wrapWindowsCmdHookCommand', () => {
it.skipIf(process.platform !== 'win32')(
'resolves the launcher to a real executable file, not a shell fragment',
() => {
// Regression guard for #8430: Codex/Antigravity/Devin spawn the launcher as
// a program (argv[0]), so it must be an existing, launchable file. The broken
// `if exist … (call …)` form had argv[0] = `if` — a cmd builtin, not a file —
// which is unspawnable and failed every hook. The bare path is the file.
// win32-only: the real temp path is cmd-safe only with backslashes; a POSIX
// tmpDir has `/`, which routes to the encoded fallback by design.
// POSIX temp paths contain `/`, which selects the encoded fallback instead.
const scriptPath = join(tmpDir, 'codex-hook.cmd')
writeFileSync(scriptPath, '@echo off\r\nexit /b 0\r\n', 'utf-8')
const command = wrapWindowsCmdHookCommand(scriptPath)
+12 -3
View File
@@ -118,6 +118,16 @@ export {
} from './windows-powershell-hook-launcher'
export function wrapWindowsHookCommand(
scriptPath: string,
env: Record<string, string> = {},
options: { fallbackStdout?: string } = {}
): string {
return wrapWindowsPowerShellEncodedCommand(
buildWindowsHookPowerShellCommand(scriptPath, env, options)
)
}
export function buildWindowsHookPowerShellCommand(
scriptPath: string,
env: Record<string, string> = {},
// Why: POSIX wrap already answers missing-script with stdout; Windows must match so gate events cannot drift (#15462).
@@ -135,14 +145,13 @@ export function wrapWindowsHookCommand(
// Why the order: answer first (a gate event reads silence as deny), then the shared
// env guard, and only then own stdin — outside an Orca pane the caller may abandon the
// pipe, and ReadToEnd would strand the launcher there forever (#11549).
const command = `${envPrefix}if (Test-Path -LiteralPath ${quoted} -PathType Leaf) { & ${quoted}; exit $LASTEXITCODE }; ${fallback}${WINDOWS_POWERSHELL_HOOK_ENVIRONMENT_GUARD}; [Console]::In.ReadToEnd() | Out-Null; exit 0`
return wrapWindowsPowerShellEncodedCommand(command)
return `${envPrefix}if (Test-Path -LiteralPath ${quoted} -PathType Leaf) { & ${quoted}; exit $LASTEXITCODE }; ${fallback}${WINDOWS_POWERSHELL_HOOK_ENVIRONMENT_GUARD}; [Console]::In.ReadToEnd() | Out-Null; exit 0`
}
export const WINDOWS_CMD_SAFE_PATH = /^[A-Za-z0-9_.:\\~-]+$/
export function wrapWindowsCmdHookCommand(scriptPath: string): string {
// Why: Codex/Antigravity/Devin spawn the hook as argv[0], not via cmd.exe, so it must be one spawnable token; a cmd `if exist` launcher isn't (#8430).
// Direct-spawn consumers need one executable token; a cmd `if exist` fragment is not one (#8430).
return WINDOWS_CMD_SAFE_PATH.test(scriptPath) ? scriptPath : wrapWindowsHookCommand(scriptPath)
}
@@ -182,7 +182,12 @@ describe('managed hook command contract', () => {
expect(commands.length).toBeGreaterThan(0)
for (const command of commands) {
expect(command.length).toBeGreaterThan(0)
expect(findBareHookCommandVariables(command), command).toEqual([])
// Native Windows Codex evaluates PowerShell variables without Grok's dollar-byte scanner.
const scannedCommand =
agent === 'codex' && platform === 'win32' && command.startsWith('if (Test-Path')
? command.replaceAll('$LASTEXITCODE', '').replaceAll('$env:', '')
: command
expect(findBareHookCommandVariables(scannedCommand), command).toEqual([])
}
})
})
@@ -1,3 +1,4 @@
import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'
import { tmpdir } from 'node:os'
@@ -24,6 +25,15 @@ vi.mock('../telemetry/cohort-classifier', () => ({
}))
const SESSION = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d'
const SUBJECT = makeStructuredAgentStatusSubject(
{
executionHostId: 'local',
wslDistro: null,
workspaceId: 'repo-1::/workspace/app',
workspaceKind: 'git-worktree'
},
SESSION
)
const TAB = structuredAgentSessionTabId(SESSION)
const STRUCTURED_PANE = structuredAgentSessionPaneKey(TAB, SESSION)
const OBSERVED_AT = 1_757_030_400_000
@@ -59,7 +69,7 @@ afterEach(() => {
describe('AgentHookServer ingestStructuredStatus', () => {
it('stores the projection as a row under the pane key the renderer derives', () => {
const server = new AgentHookServer()
server.ingestStructuredStatus(summary())
server.ingestStructuredStatus(summary(), SUBJECT)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
@@ -86,22 +96,25 @@ describe('AgentHookServer ingestStructuredStatus', () => {
// The same mapping the sidebar applies, so the two surfaces cannot disagree about one session.
it('maps attention to blocked and idle to done', () => {
const server = new AgentHookServer()
server.ingestStructuredStatus(summary({ status: 'attention' }))
server.ingestStructuredStatus(summary({ status: 'attention' }), SUBJECT)
expect(server.getStatusSnapshot()[0]?.state).toBe('blocked')
server.ingestStructuredStatus(summary({ status: 'idle', updatedAt: OBSERVED_AT + 1 }))
server.ingestStructuredStatus(summary({ status: 'idle', updatedAt: OBSERVED_AT + 1 }), SUBJECT)
expect(server.getStatusSnapshot()[0]?.state).toBe('done')
})
it('marks a session whose provider child is gone as held, not owned', () => {
const server = new AgentHookServer()
server.ingestStructuredStatus(summary({ hostExecutionOwned: undefined }))
server.ingestStructuredStatus(summary({ hostExecutionOwned: undefined }), SUBJECT)
expect(server.getStatusSnapshot()[0]?.structuredHost).toBe('held')
})
it('keeps the state start while later evidence of the same state arrives', () => {
const server = new AgentHookServer()
server.ingestStructuredStatus(summary())
server.ingestStructuredStatus(summary({ toolName: 'read', updatedAt: OBSERVED_AT + 5_000 }))
server.ingestStructuredStatus(summary(), SUBJECT)
server.ingestStructuredStatus(
summary({ toolName: 'read', updatedAt: OBSERVED_AT + 5_000 }),
SUBJECT
)
expect(server.getStatusSnapshot()[0]).toMatchObject({
toolName: 'read',
@@ -113,18 +126,18 @@ describe('AgentHookServer ingestStructuredStatus', () => {
// Null status means no turn has been persisted; the chat shows nothing, so neither does this.
it('holds no row for a session without a persisted turn, and drops one that regresses to none', () => {
const server = new AgentHookServer()
server.ingestStructuredStatus(summary({ status: null }))
server.ingestStructuredStatus(summary({ status: null }), SUBJECT)
expect(server.getStatusSnapshot()).toEqual([])
server.ingestStructuredStatus(summary())
server.ingestStructuredStatus(summary({ status: null }))
server.ingestStructuredStatus(summary(), SUBJECT)
server.ingestStructuredStatus(summary({ status: null }), SUBJECT)
expect(server.getStatusSnapshot()).toEqual([])
})
it('drops the row when the host stops holding the session', () => {
const server = new AgentHookServer()
server.ingestStructuredStatus(summary())
server.dropStructuredStatus(SESSION)
server.ingestStructuredStatus(summary(), SUBJECT)
server.dropStructuredStatus(SUBJECT)
expect(server.getStatusSnapshot()).toEqual([])
})
@@ -136,13 +149,13 @@ describe('AgentHookServer ingestStructuredStatus', () => {
const withProviderSession = summary({
providerSession: { key: 'session_id', id: 'codex-thread-1' }
})
server.ingestStructuredStatus(withProviderSession)
server.ingestStructuredStatus(withProviderSession, SUBJECT)
expect(server.getStatusSnapshot()[0]?.providerSession).toEqual({
key: 'session_id',
id: 'codex-thread-1'
})
server.dropStructuredStatus(SESSION)
server.dropStructuredStatus(SUBJECT)
expect(server.getStatusSnapshot()).toEqual([])
})
@@ -157,8 +170,8 @@ describe('AgentHookServer ingestStructuredStatus', () => {
server.setPaneStatusClearListener((clear) => cleared.push(clear))
server.subscribeStatusDrop((paneKey) => dropped.push(paneKey))
server.ingestStructuredStatus(summary())
server.dropStructuredStatus(SESSION)
server.ingestStructuredStatus(summary(), SUBJECT)
server.dropStructuredStatus(SUBJECT)
expect(server.getStatusSnapshot()).toEqual([])
expect(cleared).toEqual([])
@@ -175,7 +188,7 @@ describe('AgentHookServer ingestStructuredStatus', () => {
original()
}
server.ingestStructuredStatus(summary())
server.ingestStructuredStatus(summary(), SUBJECT)
expect(persists).toHaveLength(0)
server.ingestTerminalStatus({
@@ -193,7 +206,7 @@ describe('AgentHookServer ingestStructuredStatus', () => {
connectionId: null,
payload: { state: 'working', prompt: 'watch the build', agentType: 'claude' }
})
server.ingestStructuredStatus(summary())
server.ingestStructuredStatus(summary(), SUBJECT)
const byPane = new Map(server.getStatusSnapshot().map((row) => [row.paneKey, row]))
expect(byPane.get(PANE)?.structuredHost).toBeUndefined()
@@ -227,7 +240,7 @@ describe('structured rows and last-status.json', () => {
connectionId: null,
payload: { state: 'working', prompt: 'watch the build', agentType: 'claude' }
})
server.ingestStructuredStatus(summary())
server.ingestStructuredStatus(summary(), SUBJECT)
server.flushStatusPersistSync()
} finally {
server.stop()
@@ -0,0 +1,221 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
makeStructuredAgentStatusSubject,
type AgentStatusExecutionScope,
type AgentStatusStructuredSessionSubject
} from '../../shared/agent-status-subject'
import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire'
import { makePaneKey } from '../../shared/stable-pane-id'
import {
structuredAgentSessionPaneKey,
structuredAgentSessionTabId
} from '../../shared/structured-agent-session-projection'
import { AgentHookServer } from './server'
import { GOOD_PANE, PANE } from './server.test-fixtures'
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn(() => ({})) }))
const SESSION = 'canonical-session-one'
const SCOPE: AgentStatusExecutionScope = {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-one',
workspaceKind: 'git-worktree'
}
const SUBJECT = makeStructuredAgentStatusSubject(SCOPE, SESSION)
const PANE_KEY = structuredAgentSessionPaneKey(structuredAgentSessionTabId(SESSION), SESSION)
function summary(
subject: AgentStatusStructuredSessionSubject = SUBJECT
): AgentSessionStatusSummary {
return {
sessionId: subject.sessionId,
workspaceId: subject.workspaceId,
agent: 'codex',
status: 'working',
hostExecutionOwned: true,
latestPrompt: 'trusted journal',
updatedAt: 100
}
}
function terminal(server: AgentHookServer, paneKey: string): void {
server.ingestTerminalStatus({
paneKey,
worktreeId: SCOPE.workspaceId,
connectionId: null,
payload: { state: 'working', prompt: 'legacy PTY', agentType: 'claude' }
})
}
afterEach(() => vi.restoreAllMocks())
describe('structured canonical production slice', () => {
it('stores once canonically and supplies every legacy reader from that row', () => {
const server = new AgentHookServer()
const changed = vi.fn()
const enriched = vi.fn()
server.subscribeStatusChanges(changed)
server.subscribeEnrichedStatus(enriched)
server.ingestStructuredStatus(summary(), SUBJECT)
expect(server._getStateForTests().lastStatusByPaneKey.size).toBe(0)
expect(server.getCanonicalStatusSnapshot().parents).toEqual([
expect.objectContaining({
subject: SUBJECT,
status: expect.objectContaining({ paneKey: PANE_KEY })
})
])
expect(server.getStatusSnapshotForPane(PANE_KEY)).toEqual(server.getStatusSnapshot())
expect(changed).toHaveBeenCalledExactlyOnceWith([
expect.objectContaining({
paneKey: PANE_KEY,
state: 'working',
observedInCurrentRuntime: true
})
])
expect(enriched).toHaveBeenCalledOnce()
const replay = vi.fn()
server.setListener(replay)
expect(replay).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ paneKey: PANE_KEY, isReplay: true })
)
})
it('keeps mixed legacy enumeration in original insertion order through updates and re-admission', () => {
vi.spyOn(Date, 'now').mockReturnValue(200)
const server = new AgentHookServer()
const second = makeStructuredAgentStatusSubject(SCOPE, 'canonical-session-two')
const secondPane = structuredAgentSessionPaneKey(
structuredAgentSessionTabId(second.sessionId),
second.sessionId
)
const baseline = new Map<string, string>()
terminal(server, PANE)
baseline.set(PANE, 'legacy PTY')
server.ingestStructuredStatus(summary(), SUBJECT)
baseline.set(PANE_KEY, 'trusted journal')
terminal(server, GOOD_PANE)
baseline.set(GOOD_PANE, 'legacy PTY')
server.ingestStructuredStatus(summary(second), second)
baseline.set(secondPane, 'trusted journal')
terminal(server, PANE)
server.ingestStructuredStatus({ ...summary(), latestPrompt: 'updated' }, SUBJECT)
baseline.set(PANE_KEY, 'updated')
const listing = () => server.getStatusSnapshot().map((row) => [row.paneKey, row.prompt])
expect(listing()).toEqual([...baseline])
expect(server.getStatusChangeSnapshot().map((row) => row.paneKey)).toEqual([...baseline.keys()])
const replay: string[] = []
server.setListener((entry) => replay.push(entry.paneKey))
expect(replay).toEqual([...baseline.keys()])
server.dropStructuredStatus(SUBJECT)
baseline.delete(PANE_KEY)
server.ingestStructuredStatus(summary(), SUBJECT)
baseline.set(PANE_KEY, 'trusted journal')
expect(listing()).toEqual([...baseline])
const relocated = makePaneKey('relocated-tab', '88888888-8888-4888-8888-888888888888')
server.transferPaneAuthority(PANE, relocated, undefined, 200, { authorityVerified: true })
baseline.delete(PANE)
baseline.set(relocated, 'legacy PTY')
expect(listing()).toEqual([...baseline])
expect(server._getStateForTests().lastStatusByPaneKey.size).toBe(2)
expect(server.getCanonicalStatusSnapshot().parents).toHaveLength(2)
})
it('isolates identical session identifiers across host, WSL and workspace kind scopes', () => {
const server = new AgentHookServer()
const scopes: AgentStatusExecutionScope[] = [
SCOPE,
{ ...SCOPE, wslDistro: 'Ubuntu' },
{ ...SCOPE, wslDistro: 'Debian' },
{ ...SCOPE, executionHostId: 'ssh:first' },
{ ...SCOPE, executionHostId: 'ssh:second' },
{ ...SCOPE, executionHostId: 'runtime:paired' },
{ ...SCOPE, workspaceKind: 'folder' }
]
const subjects = scopes.map((scope) => makeStructuredAgentStatusSubject(scope, SESSION))
for (const subject of subjects) {
server.ingestStructuredStatus(summary(subject), subject)
}
expect(server.getCanonicalStatusSnapshot().parents.map((row) => row.subject)).toEqual(subjects)
server.dropStructuredStatus(SUBJECT)
expect(server.getCanonicalStatusSnapshot().parents.map((row) => row.subject)).toEqual(
subjects.slice(1)
)
expect(server._getStateForTests().lastStatusByPaneKey.size).toBe(0)
})
it('rejects missing or mismatched structured scope without fabricating a parent', () => {
const server = new AgentHookServer()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: models a caller at an untyped boundary (e.g. IPC) invoking with fewer arguments than the method declares; no typed call expresses a missing required parameter.
const ingestMissingSubject = server.ingestStructuredStatus.bind(server) as unknown as (
summary: AgentSessionStatusSummary
) => void
expect(() => ingestMissingSubject(summary())).toThrow('trusted owner subject')
expect(() =>
server.ingestStructuredStatus({ ...summary(), workspaceId: 'other' }, SUBJECT)
).toThrow('trusted owner subject')
expect(server.getCanonicalStatusSnapshot().parents).toEqual([])
expect(server.getStatusSnapshot()).toEqual([])
})
it('refuses late PTY and relay evidence at a canonically owned address without fanout', () => {
const server = new AgentHookServer()
server.ingestStructuredStatus(summary(), SUBJECT)
const before = server.getCanonicalStatusSnapshot()
const changed = vi.fn()
const enriched = vi.fn()
server.subscribeStatusChanges(changed)
server.subscribeEnrichedStatus(enriched)
terminal(server, PANE_KEY)
server.ingestRemote(
{ paneKey: PANE_KEY, payload: { state: 'done', prompt: 'late', agentType: 'claude' } },
'ssh-route'
)
expect(server.getCanonicalStatusSnapshot()).toEqual(before)
expect(server._getStateForTests().lastStatusByPaneKey.size).toBe(0)
expect(server.getStatusSnapshot()).toHaveLength(1)
expect(changed).not.toHaveBeenCalled()
expect(enriched).not.toHaveBeenCalled()
})
it('refuses a canonical address already occupied by unbound legacy evidence', () => {
const server = new AgentHookServer()
terminal(server, PANE_KEY)
expect(() => server.ingestStructuredStatus(summary(), SUBJECT)).toThrow(
'conflicts with legacy evidence'
)
expect(server.getCanonicalStatusSnapshot().parents).toEqual([])
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ paneKey: PANE_KEY, prompt: 'legacy PTY' })
])
})
it('keeps incomplete remote evidence exclusively legacy and pane cleanup cannot remove a canonical row', () => {
const server = new AgentHookServer()
server.ingestRemote(
{ paneKey: PANE, payload: { state: 'working', prompt: 'remote', agentType: 'claude' } },
'ssh-route'
)
expect(server.getCanonicalStatusSnapshot().parents).toEqual([])
expect(server.getStatusSnapshot()[0]).toMatchObject({
connectionId: 'ssh-route',
paneKey: PANE
})
server.ingestStructuredStatus(summary(), SUBJECT)
server.dropStatusEntry(PANE_KEY)
server.retirePaneAuthority(PANE_KEY)
expect(server.getCanonicalStatusSnapshot().parents).toHaveLength(1)
expect(server.getStatusSnapshotForPane(PANE_KEY)).toHaveLength(1)
})
it('clears canonical state and renews the owner epoch when the server stops', () => {
const server = new AgentHookServer()
server.ingestStructuredStatus(summary(), SUBJECT)
const epoch = server.getCanonicalStatusSnapshot().epoch
server.stop()
expect(server.getCanonicalStatusSnapshot().parents).toEqual([])
expect(server.getCanonicalStatusSnapshot().epoch).not.toBe(epoch)
expect(server.getStatusSnapshot()).toEqual([])
})
})
+1
View File
@@ -42,6 +42,7 @@ export const _internals = {
parseFormEncodedBody,
resetCachesForTests: (): void => {
clearAllListenerCaches(agentHookServer._getStateForTests())
agentHookServer._resetCanonicalStatusForTests()
agentHookServer._resetRowOwnershipForTests()
agentHookServer._resetPromptSentDedupeForTests()
agentHookServer._resetConnectionTimestampWatermarksForTests()
@@ -1,30 +1,55 @@
import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire'
import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types'
import type { AgentStatusIpcPayload } from '../../../shared/agent-status-types'
import {
parseAgentStatusSubject,
serializeAgentStatusSubject,
type AgentStatusStructuredSessionSubject
} from '../../../shared/agent-status-subject'
import {
structuredAgentSessionPaneKey,
structuredAgentSessionStatusState,
structuredAgentSessionTabId
} from '../../../shared/structured-agent-session-projection'
import { structuredStatusLegacyEvent } from './server-structured-status-row'
import { AgentHookServerIngestTerminal } from './server-ingest-terminal'
/**
* Structured (native chat) sessions have no PTY and no hook script, so nothing else reaches this
* store for them. The host projects each session's journal into a summary; this is where that
* summary becomes the same row every other agent has, keyed by the pane key the renderer derives.
*/
export abstract class AgentHookServerIngestStructured extends AgentHookServerIngestTerminal {
ingestStructuredStatus(summary: AgentSessionStatusSummary): void {
const paneKey = structuredStatusPaneKey(summary.sessionId)
// No persisted turn yet: the chat shows nothing, so neither does any status reader.
ingestStructuredStatus(
summary: AgentSessionStatusSummary,
subject: AgentStatusStructuredSessionSubject
): void {
const parsed = parseAgentStatusSubject(subject)
if (
!parsed ||
parsed.kind !== 'structured-session' ||
parsed.sessionId !== summary.sessionId ||
parsed.workspaceId !== summary.workspaceId ||
!Number.isFinite(summary.updatedAt) ||
summary.updatedAt < 0
) {
throw new Error('Structured status does not match its trusted owner subject')
}
if (!summary.status) {
this.dropStructuredStatus(summary.sessionId)
this.dropStructuredStatus(parsed)
return
}
if (this.getAgentStatusDisposition(paneKey) !== 'accept') {
return
const previous = this.canonicalStatusStore.getParent(parsed)
const priorStatus = previous?.status
const state = structuredAgentSessionStatusState(summary.status)
const tabId = structuredAgentSessionTabId(parsed.sessionId)
const paneKey = structuredAgentSessionPaneKey(tabId, parsed.sessionId)
if (this.state.lastStatusByPaneKey.has(paneKey)) {
throw new Error('Structured status address conflicts with legacy evidence')
}
const payload: ParsedAgentStatusPayload = {
state: structuredAgentSessionStatusState(summary.status),
const snapshot = this.canonicalStatusStore.getSnapshot()
const status: AgentStatusIpcPayload = {
paneKey,
tabId,
worktreeId: parsed.workspaceId,
connectionId: null,
structuredHost: summary.hostExecutionOwned ? 'owned' : 'held',
...(summary.providerSession ? { providerSession: summary.providerSession } : {}),
state,
prompt: summary.latestPrompt,
agentType: summary.agent,
...(summary.model ? { model: summary.model } : {}),
@@ -32,35 +57,71 @@ export abstract class AgentHookServerIngestStructured extends AgentHookServerIng
...(summary.toolInput ? { toolInput: summary.toolInput } : {}),
...(summary.lastAssistantMessage
? { lastAssistantMessage: summary.lastAssistantMessage }
: {})
: {}),
receivedAt: Math.max(Date.now(), priorStatus?.receivedAt ?? 0),
evidenceObservedAt: summary.updatedAt,
stateStartedAt: priorStatus?.state === state ? priorStatus.stateStartedAt : summary.updatedAt,
observation: {
origin: 'structured',
kind: 'transition',
authorityId: snapshot.epoch,
incarnation: 0,
revision: snapshot.revision + 1,
observedAt: summary.updatedAt
}
}
// The journal clock stamps the evidence so a restart's republish does not read as fresh work.
this.applyNormalizedStatus(
{
paneKey,
tabId: structuredAgentSessionTabId(summary.sessionId),
worktreeId: summary.workspaceId,
connectionId: null,
structuredHost: summary.hostExecutionOwned ? 'owned' : 'held',
...(summary.providerSession ? { providerSession: summary.providerSession } : {}),
payload
},
undefined,
'structured',
summary.updatedAt
)
const publication = this.canonicalStatusStore.applyMutation({
parent: { subject: parsed, status, firstObservedAt: previous?.firstObservedAt ?? Date.now() }
})
if (!publication) {
return
}
const key = serializeAgentStatusSubject(parsed)
const subjects =
this.canonicalSubjectsByPane.get(paneKey) ??
new Map<string, AgentStatusStructuredSessionSubject>()
subjects.set(key, parsed)
this.canonicalSubjectsByPane.set(paneKey, subjects)
if (!this.canonicalListingOrder.has(key)) {
this.canonicalListingOrder.set(key, this.nextStatusListingOrder())
}
const committed = this.canonicalStatusStore.getParent(parsed)?.status
if (!committed) {
throw new Error('Committed structured status is missing')
}
const after = structuredStatusLegacyEvent(committed)
this.commitStatusRowMutation(priorStatus && structuredStatusLegacyEvent(priorStatus), after)
this.notifyStatusChangeListeners()
this.emitEnrichedStatus(after)
}
/** The host no longer holds the session; its last projection is history the journal keeps.
* `dropStatusEntry`, not `clearPaneState`: the renderer's own bridge still owns this pane key,
* so a pane-status-clear would make main a second writer for it. */
dropStructuredStatus(sessionId: string): void {
this.dropStatusEntry(structuredStatusPaneKey(sessionId), { preserveResumeIdentity: false })
/** Pane cleanup never resolves a canonical subject; only its owning feed can forget this row. */
dropStructuredStatus(subject: AgentStatusStructuredSessionSubject): void {
const parsed = parseAgentStatusSubject(subject)
if (!parsed || parsed.kind !== 'structured-session') {
throw new Error('Structured status removal requires its exact owner subject')
}
const previous = this.canonicalStatusStore.getParent(parsed)
if (!previous) {
return
}
const publication = this.canonicalStatusStore.applyMutation({
removeParent: parsed
})
if (!publication) {
return
}
const key = serializeAgentStatusSubject(parsed)
this.canonicalListingOrder.delete(key)
if (previous.status) {
const subjects = this.canonicalSubjectsByPane.get(previous.status.paneKey)
subjects?.delete(key)
if (subjects?.size === 0) {
this.canonicalSubjectsByPane.delete(previous.status.paneKey)
}
this.commitStatusRowMutation(structuredStatusLegacyEvent(previous.status), undefined)
this.notifyStatusChangeListeners()
this.emitStatusDropped(previous.status.paneKey)
}
}
}
// The DERIVED pane key the renderer publishes, never the orchestration bearer handle or the minted
// worker pane key: both of those are credentials.
function structuredStatusPaneKey(sessionId: string): string {
return structuredAgentSessionPaneKey(structuredAgentSessionTabId(sessionId), sessionId)
}
@@ -116,8 +116,10 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
}
this.recordCurrentAuthorityObservation(event)
const enriched = this.applyNormalizedStatus(event, normalized.onAccepted)
this.scheduleAssistantMessageRetry(source, aliasedBody, enriched)
this.scheduleCodexSubagentPoll(source, aliasedBody, enriched)
if (enriched) {
this.scheduleAssistantMessageRetry(source, aliasedBody, enriched)
this.scheduleCodexSubagentPoll(source, aliasedBody, enriched)
}
}
res.writeHead(204)
res.end()
@@ -212,6 +214,7 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
this.ownerStateInitialized = false
// Why: don't unlink the endpoint file — a stale file matches fail-open and avoids a TOCTOU race with a concurrent Orca.
clearAllListenerCaches(this.state)
this.resetCanonicalStatus()
this.notifyStatusChangeListeners()
this.paneStatusClearListeners.clear()
this.statusDropListeners.clear()
+70 -12
View File
@@ -4,7 +4,10 @@ import type {
} from '../../../shared/agent-status-types'
import type { ClaudeStatusLineRateLimits } from '../../../shared/claude-statusline-rate-limits'
import type { HookTransportInterferenceReport } from '../../../shared/agent-hook-transport-interference'
import type { HookListenerState } from '../../../shared/agent-hook-listener/listener-state'
import {
getLegacyStatusListingOrder,
type HookListenerState
} from '../../../shared/agent-hook-listener/listener-state'
import type {
AgentHookAuthorityEvidence,
AgentHookProviderSessionIdentity,
@@ -15,8 +18,54 @@ import type {
} from './server-types'
import { toAgentStatusIpcPayload } from './server-status-identity'
import { AgentHookServerState } from './server-state'
import { serializeAgentStatusSubject } from '../../../shared/agent-status-subject'
import { structuredStatusLegacyEvent } from './server-structured-status-row'
// Why: the listing counter starts at 1, so an unassigned row must sort last — never above every ordered row.
const UNORDERED_STATUS_ROW = Number.MAX_SAFE_INTEGER
export abstract class AgentHookServerListeners extends AgentHookServerState {
protected emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void {
this.onAgentStatus?.(enriched)
for (const listener of this.enrichedStatusListeners) {
try {
listener(enriched)
} catch (err) {
console.error('[agent-hooks] enriched status listener threw', err)
}
}
}
getCanonicalStatusSnapshot() {
return this.canonicalStatusStore.getSnapshot()
}
_resetCanonicalStatusForTests(): void {
this.resetCanonicalStatus()
}
private combinedStatusEntries(): EnrichedAgentHookEventPayload[] {
const rows: { entry: EnrichedAgentHookEventPayload; order: number }[] = []
for (const [paneKey, entry] of this.state.lastStatusByPaneKey) {
rows.push({
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Main admits enriched legacy rows; shared listeners expose only the base event type.
entry: entry as EnrichedAgentHookEventPayload,
order: getLegacyStatusListingOrder(this.state, paneKey) ?? UNORDERED_STATUS_ROW
})
}
for (const parent of this.canonicalStatusStore.getSnapshot().parents) {
if (!parent.status) {
continue
}
rows.push({
entry: structuredStatusLegacyEvent(parent.status),
order:
this.canonicalListingOrder.get(serializeAgentStatusSubject(parent.subject)) ??
UNORDERED_STATUS_ROW
})
}
return rows.sort((a, b) => a.order - b.order).map(({ entry }) => entry)
}
/**
* Notified once per process when repeated hook POSTs are cut off mid-body (#11217).
* Why: the listener fails open on every request error, so without this the only symptom is
@@ -34,10 +83,9 @@ export abstract class AgentHookServerListeners extends AgentHookServerState {
return
}
// Why: replay is best-effort per pane so one throwing listener can't starve the rest.
for (const payload of this.state.lastStatusByPaneKey.values()) {
for (const payload of this.combinedStatusEntries()) {
try {
// Why: cache always holds enriched payloads; the map's declared type is the bare shape only because the shared module never reads it.
listener({ ...(payload as EnrichedAgentHookEventPayload), isReplay: true })
listener({ ...payload, isReplay: true })
} catch (err) {
console.error('[agent-hooks] replay listener threw', err)
}
@@ -153,9 +201,7 @@ export abstract class AgentHookServerListeners extends AgentHookServerState {
/** Snapshot of cached statuses in IPC shape. Used by `agentStatus:getSnapshot` after tabs hydrate so the
* dashboard catches up on hook events that fired during startup. */
getStatusSnapshot(): AgentStatusIpcPayload[] {
return Array.from(this.state.lastStatusByPaneKey.values(), (entry) =>
toAgentStatusIpcPayload(entry as EnrichedAgentHookEventPayload)
)
return this.combinedStatusEntries().map(toAgentStatusIpcPayload)
}
/** Provider-session identities, including Pi's metadata-only rows. */
@@ -164,8 +210,19 @@ export abstract class AgentHookServerListeners extends AgentHookServerState {
}
getStatusSnapshotForPane(paneKey: string): AgentStatusIpcPayload[] {
const entry = this.state.lastStatusByPaneKey.get(paneKey)
return entry ? [toAgentStatusIpcPayload(entry as EnrichedAgentHookEventPayload)] : []
const legacy = this.state.lastStatusByPaneKey.get(paneKey)
if (legacy) {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Main admits enriched legacy rows; the shared view declares their base event type.
return [toAgentStatusIpcPayload(legacy as EnrichedAgentHookEventPayload)]
}
const rows: AgentStatusIpcPayload[] = []
for (const subject of this.canonicalSubjectsByPane.get(paneKey)?.values() ?? []) {
const status = this.canonicalStatusStore.getParent(subject)?.status
if (status) {
rows.push(status)
}
}
return rows
}
getHydratedAuthorityCommitments(): readonly AgentHookAuthorityEvidence[] {
@@ -184,8 +241,8 @@ export abstract class AgentHookServerListeners extends AgentHookServerState {
} {
const statuses: AgentHookStatusChangeEntry[] = []
const providerSessions: AgentHookProviderSessionIdentity[] = []
for (const [paneKey, entry] of this.state.lastStatusByPaneKey) {
const enriched = entry as EnrichedAgentHookEventPayload
for (const enriched of this.combinedStatusEntries()) {
const paneKey = enriched.paneKey
if (enriched.providerSession) {
providerSessions.push({
paneKey,
@@ -201,7 +258,8 @@ export abstract class AgentHookServerListeners extends AgentHookServerState {
paneKey,
state: enriched.payload.state,
receivedAt: enriched.receivedAt,
observedInCurrentRuntime: this.runtimeObservedStatusPaneKeys.has(paneKey)
observedInCurrentRuntime:
Boolean(enriched.structuredHost) || this.runtimeObservedStatusPaneKeys.has(paneKey)
})
}
}
+42 -3
View File
@@ -1,8 +1,9 @@
import type { createServer } from 'node:http'
import { randomBytes } from 'node:crypto'
import { randomBytes, randomUUID } from 'node:crypto'
import {
createHookListenerState,
canAdmitLegacyAgentStatusEntry,
type HookListenerState
} from '../../../shared/agent-hook-listener/listener-state'
import {
@@ -21,6 +22,9 @@ import type { AgentHookSource } from '../../../shared/agent-hook-relay'
import type { AgentStatusClearIpcPayload } from '../../../shared/agent-status-types'
import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-types'
import type { SpoolRecord } from '../../../shared/agent-hook-spool'
import { createAgentStatusStore, type AgentStatusStore } from '../../../shared/agent-status-store'
import { AGENT_STATUS_2A_CURRENT_PRODUCER_MODE } from '../../../shared/agent-status-legacy-adapter'
import type { AgentStatusStructuredSessionSubject } from '../../../shared/agent-status-subject'
import type {
AgentHookAuthorityEvidence,
AgentHookProviderSessionIdentity,
@@ -45,6 +49,38 @@ import type {
/** Shared mutable state for the layered hook-server implementation. */
export abstract class AgentHookServerState {
protected canWriteLegacyStatusRow(entry: AgentHookEventPayload): boolean {
return canAdmitLegacyAgentStatusEntry(
this.state,
'main-status-update',
entry,
AGENT_STATUS_2A_CURRENT_PRODUCER_MODE
)
}
// Why: the epoch is minted on first canonical use, so constructing the server — which happens at
// import time for the module singleton — owes nothing to a live crypto implementation.
private canonicalStatusStoreInstance: AgentStatusStore | null = null
protected get canonicalStatusStore(): AgentStatusStore {
this.canonicalStatusStoreInstance ??= createAgentStatusStore({
epoch: randomUUID(),
mode: 'authority'
})
return this.canonicalStatusStoreInstance
}
protected readonly canonicalListingOrder = new Map<string, number>()
protected readonly canonicalSubjectsByPane = new Map<
string,
Map<string, AgentStatusStructuredSessionSubject>
>()
private statusListingOrder = 0
protected nextStatusListingOrder = (): number => ++this.statusListingOrder
protected resetCanonicalStatus(): void {
this.canonicalStatusStoreInstance = null
this.canonicalListingOrder.clear()
this.canonicalSubjectsByPane.clear()
}
protected server: ReturnType<typeof createServer> | null = null
protected port = 0
protected token = ''
@@ -73,7 +109,10 @@ export abstract class AgentHookServerState {
protected endpointFilePathCache: string | null = null
protected endpointFileWritten = false
// Why: per-instance (not module-level) so tests can spin up multiple servers without state cross-contamination.
protected state: HookListenerState = createHookListenerState()
protected state: HookListenerState = createHookListenerState({
nextListingOrder: this.nextStatusListingOrder,
isCanonicalPaneKey: (paneKey) => this.canonicalSubjectsByPane.has(paneKey)
})
protected onTransportInterference: ((report: HookTransportInterferenceReport) => void) | null =
null
protected transportInterference = createHookTransportInterferenceTracker(
@@ -169,7 +208,7 @@ export abstract class AgentHookServerState {
origin?: AgentStatusObservationOrigin,
observedAt?: number,
mutationBefore?: EnrichedAgentHookEventPayload
): EnrichedAgentHookEventPayload
): EnrichedAgentHookEventPayload | undefined
protected abstract emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void
protected abstract clearAssistantMessageRetry(paneKey: string): void
protected abstract clearCodexSubagentPoll(paneKey: string): void
@@ -111,6 +111,9 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO
...(payload.subagents ? { subagents: payload.subagents } : {})
}
})
if (!inferred) {
return false
}
console.debug('[agent-hooks] inferred interrupted agent status', {
paneKey: inferred.paneKey,
agentType,
@@ -172,6 +175,9 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO
...(payload.subagents ? { subagents: payload.subagents } : {})
}
})
if (!inferred) {
return false
}
console.debug('[agent-hooks] inferred resolved question status', {
paneKey: inferred.paneKey,
state: inferred.payload.state
@@ -77,7 +77,9 @@ export abstract class AgentHookServerStatusRetries extends AgentHookServerStatus
const subagentsChanged =
JSON.stringify(normalized.payload.subagents) !== JSON.stringify(original.payload.subagents)
const next = subagentsChanged ? this.applyNormalizedStatus(normalized) : original
this.scheduleCodexSubagentPoll(source, body, next)
if (next) {
this.scheduleCodexSubagentPoll(source, body, next)
}
}
protected scheduleAssistantMessageRetry(
@@ -29,7 +29,10 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA
origin: AgentStatusObservationOrigin = 'hook',
observedAt?: number,
mutationBefore?: EnrichedAgentHookEventPayload
): EnrichedAgentHookEventPayload {
): EnrichedAgentHookEventPayload | undefined {
if (!this.canWriteLegacyStatusRow(payload)) {
return undefined
}
if (payload.hookEventName === 'UserPromptSubmit') {
// Why: the prompt boundary is authoritative even when text is unchanged; its next OSC working row must not inherit the prior cron/background turn stamp.
this.activeHookTurnCompletedAtByPaneKey.delete(payload.paneKey)
@@ -72,7 +75,9 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA
}
this.clearAssistantMessageRetry(enriched.paneKey)
this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey)
this.writeLegacyStatusRow(enriched)
if (!this.writeLegacyStatusRow(enriched)) {
return undefined
}
this.commitStatusRowMutation(rowBefore, enriched)
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
@@ -125,7 +130,9 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA
if (boundaryReconciledPrevious !== previous) {
previous = boundaryReconciledPrevious
if (previous) {
this.writeLegacyStatusRow(previous)
if (!this.writeLegacyStatusRow(previous)) {
return undefined
}
this.scheduleStatusPersist()
}
}
@@ -224,7 +231,9 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA
} else {
this.runtimeObservedStatusPaneKeys.add(enriched.paneKey)
}
this.writeLegacyStatusRow(enriched)
if (!this.writeLegacyStatusRow(enriched)) {
return undefined
}
this.commitStatusRowMutation(rowBefore, enriched)
// Why skipped for structured rows: the serializer drops them, so the whole walk and stringify
// can only ever reproduce the last file — once per debounce window for a streaming chat.
@@ -241,6 +250,9 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA
mutationBefore?: EnrichedAgentHookEventPayload,
emitEnrichedStatus = false
): void {
if (!this.canWriteLegacyStatusRow(previous)) {
return
}
const connectionClearWatermark = previous.connectionId
? this.connectionTimestampWatermarkById.get(previous.connectionId)
: undefined
@@ -266,7 +278,9 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA
}
const firstRuntimeObservation = !this.runtimeObservedStatusPaneKeys.has(refreshed.paneKey)
this.runtimeObservedStatusPaneKeys.add(refreshed.paneKey)
this.writeLegacyStatusRow(refreshed)
if (!this.writeLegacyStatusRow(refreshed)) {
return
}
this.commitStatusRowMutation(mutationBefore ?? previous, refreshed)
this.scheduleStatusPersist()
// A dismissed row may retain only provider resume identity. Its preserved payload can still
@@ -291,21 +305,8 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA
}
}
// Why: every status emit must reach plugins too, so a new early-return path
// upstream cannot silently leave the plugin tap behind the main-window fanout.
protected emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void {
this.onAgentStatus?.(enriched)
for (const listener of this.enrichedStatusListeners) {
try {
listener(enriched)
} catch (err) {
console.error('[agent-hooks] enriched status listener threw', err)
}
}
}
private writeLegacyStatusRow(entry: EnrichedAgentHookEventPayload): void {
admitLegacyAgentStatus(
private writeLegacyStatusRow(entry: EnrichedAgentHookEventPayload): boolean {
return admitLegacyAgentStatus(
this.state,
'main-status-update',
entry,
@@ -0,0 +1,24 @@
import {
pickParsedAgentStatusPayload,
type AgentStatusIpcPayload
} from '../../../shared/agent-status-types'
import type { EnrichedAgentHookEventPayload } from './server-types'
/** Canonical rows supply legacy fanout without retaining a writable pane copy. */
export function structuredStatusLegacyEvent(
row: AgentStatusIpcPayload
): EnrichedAgentHookEventPayload {
return {
paneKey: row.paneKey,
tabId: row.tabId,
worktreeId: row.worktreeId,
connectionId: row.connectionId,
receivedAt: row.receivedAt,
stateStartedAt: row.stateStartedAt,
evidenceObservedAt: row.evidenceObservedAt,
structuredHost: row.structuredHost,
...(row.providerSession ? { providerSession: row.providerSession } : {}),
...(row.observation ? { observation: row.observation } : {}),
payload: pickParsedAgentStatusPayload(row)
}
}
@@ -3,10 +3,9 @@
* `agent.launch` alone. Orchestration dispatch, mobile create, CLI create and the desktop agent
* tab each still start agents their own way; moving them here is later stack work.
*
* The mode decision is duplicated rather than shared: `agent-launch-mode` is a surface-neutral
* second copy of orchestration's `orchestration-worker-start-mode`, which is unchanged and still
* the one orchestration uses, with nothing enforcing agreement between them. That cutover is later
* stack work too. What this module adds is the *sequencing*, and the sequencing is where the bug
* The mode decision is shared, not copied: `agent-launch-mode` owns it, and
* `orchestration-worker-start-mode` is a thin adapter over it supplying orchestration's receipt
* vocabulary. What this module adds is the *sequencing*, and the sequencing is where the bug
* was:
*
* create the worktree agent-first -> its startup terminal IS the agent
+6 -8
View File
@@ -26,7 +26,7 @@ import {
type StructuredNativeChatBlocker
} from '../../shared/structured-native-chat-launch-route'
import type { TuiAgent } from '../../shared/tui-agent'
import { hasExplicitTuiLaunchCustomization } from '../../shared/tui-agent-launch-customization'
import { hasExplicitTuiLaunchCommand } from '../../shared/tui-agent-launch-command-override'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
export type AgentLaunchMode = 'structured' | 'terminal'
@@ -36,7 +36,7 @@ export type AgentLaunchModeReason =
| 'remote_execution_host'
| 'reused_terminal'
| 'agent_without_structured_session'
| 'tui_launch_customization'
| 'tui_launch_command'
| 'structured_sessions_unavailable'
| 'structured_support_unknown'
| 'wsl_execution_runtime'
@@ -71,8 +71,7 @@ export const DEFAULT_LAUNCH_VOCABULARY: AgentLaunchModeVocabulary = {
}
export type AgentLaunchModeSettings = Partial<
NativeChatDefaultSettings &
Pick<GlobalSettings, 'agentCmdOverrides' | 'agentDefaultArgs' | 'agentDefaultEnv'>
NativeChatDefaultSettings & Pick<GlobalSettings, 'agentCmdOverrides'>
>
/** The placement facts the decision reads. `worktree`, `model` and `effort` are deliberately not
@@ -89,8 +88,7 @@ const DOWNGRADE_DETAIL: Record<Exclude<AgentLaunchModeReason, 'user_default'>, s
remote_execution_host: 'this launch runs on a remote execution host',
reused_terminal: 'it reuses a running terminal agent',
agent_without_structured_session: 'this agent has no structured session',
tui_launch_customization:
'this agent has a custom launch command, arguments or environment that only a terminal applies',
tui_launch_command: 'this agent has a custom launch command that only a terminal runs',
structured_sessions_unavailable: 'this runtime does not support structured agent sessions',
structured_support_unknown: 'the execution host has not established structured session support',
wsl_execution_runtime: 'this workspace runs under WSL',
@@ -105,7 +103,7 @@ const BLOCKER_REASON: Record<
'reused-terminal': 'reused_terminal',
'agent-without-structured-session': 'agent_without_structured_session',
'floating-workspace': 'structured_unsupported_on_host',
'tui-launch-customization': 'tui_launch_customization',
'tui-launch-command': 'tui_launch_command',
'remote-execution-host': 'remote_execution_host',
'project-runtime': 'wsl_execution_runtime',
'runtime-capability': 'structured_sessions_unavailable',
@@ -151,7 +149,7 @@ export function decideAgentLaunchMode(args: {
// A resolved managed worktree or folder workspace is never a floating terminal. WSL is left to
// the executing host's own create-support probe, which reads the resolved workspace rather
// than guessing from a client-side project runtime.
requiresTuiLaunchCustomization: hasExplicitTuiLaunchCustomization(settings, agent)
requiresTuiLaunchCommand: hasExplicitTuiLaunchCommand(settings, agent)
})
if (!support.supported) {
return downgraded(BLOCKER_REASON[support.blocker], vocabulary)
@@ -1,7 +1,6 @@
import { chmod, mkdir, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it } from 'vitest'
import { parserPublishesMessages } from '../ai-vault/session-scanner-agent-parser'
import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers'
import { retireDeletedSessionSearchSources } from './session-search-deleted-sources'
import {
@@ -280,22 +279,6 @@ it('retires a synthetic row when the container it came from is gone', async () =
})
})
// Nothing in this PR can hold a synthetic row: the index pass refuses a source
// whose parser decodes its messages where the message channel cannot reach
// them, and OpenCode's SQLite sessions are read on a worker thread. The rule
// above is the guard for the day that changes -- without it the walk would read
// `<db>#<id>` as a filename and retire every such row the moment it appeared.
it('does not index a source whose messages the channel cannot reach', () => {
const db = join(harness.root, 'opencode.db')
expect(
parserPublishesMessages({
agent: 'opencode',
codexHome: null,
file: { path: `${db}#session-1`, mtimeMs: 1, modifiedAt: '', sizeBytes: 0 }
})
).toBe(false)
})
// Round 12, F1. The cap counts directories because that is what costs: rows
// sharing one are a single read and then map lookups.
it('caps the directories one pass reads, not the rows it answers', async () => {
@@ -66,6 +66,18 @@ describe('the route ladder tries phrase, then AND, then repair, then OR', () =>
expect(ids(result).sort()).toEqual(['1', '2'])
})
it('keeps the stop words a repaired prose phrase was typed with', async () => {
const { db, engine } = await open('ss-engine-typo-phrase')
// Two copies, so the repair only suggests a term the index really holds.
addSyntheticSession(db, { id: 1, text: 'relay is dropping frames' })
addSyntheticSession(db, { id: 2, text: 'dropping frames again here' })
// Repairing the body alone would re-plan `relay dropping frames`, which no
// phrase in the index can match, and the answer would fall to AND.
const result = engine.search({ query: 'relay is droppng frames' })
expect(result.planner.route).toBe('typo+phrase')
expect(ids(result)).toEqual(['1'])
})
it('keeps every term a repaired literal was typed with', async () => {
const { db, engine } = await open('ss-engine-typo-literal')
addSyntheticSession(db, { id: 1, text: 'parseJson the data' })
@@ -184,8 +196,8 @@ describe('the conversation scope is a column filter, and it binds the whole quer
(db.prepare('SELECT max(id) AS id FROM messages').get() as { id: number }).id
)
const plan = planSessionSearchQuery('harbor')
expect(sessionSearchSnippet(db, 'conversation', rowid, plan)).toEqual(EMPTY_SNIPPET)
expect(sessionSearchSnippet(db, 'all', rowid, plan).text).toContain('output')
expect(sessionSearchSnippet(db, 'conversation', rowid, plan, 'or')).toEqual(EMPTY_SNIPPET)
expect(sessionSearchSnippet(db, 'all', rowid, plan, 'or').text).toContain('output')
})
})
@@ -471,3 +483,59 @@ it.each(['repo:target', 'path:/work/target'])(
expect(result.truncated.candidates).toBe(false)
}
)
describe('a sentence pasted out of a transcript is found behind a full candidate set', () => {
// The words of an ordinary sentence are common, so over OR the candidate
// limit fills with whatever is recent and the old session holding the
// sentence never reaches ranking.
const sentence = 'The sol review says the PR is not quite merge-ready yet'
async function pasted(sessionCandidateLimit = 600): Promise<SessionSearchHarness> {
const opened = await open('ss-engine-pasted-sentence', { sessionCandidateLimit })
addSyntheticSession(opened.db, {
id: 1,
text: `${sentence}, but not because of the implementation.`,
updatedAt: '2026-08-01T00:00:00.000Z'
})
for (let id = 2; id <= sessionCandidateLimit + 50; id++) {
addSyntheticSession(opened.db, {
id,
text: 'the review says the implementation is not quite there yet',
updatedAt: '2026-09-09T00:00:00.000Z'
})
}
return opened
}
it('returns the exact sentence first, over the phrase route', async () => {
const { engine } = await pasted()
const result = engine.search({ query: sentence })
expect(result.planner.route).toBe('phrase')
expect(ids(result)).toEqual(['1'])
})
it('does not claim the results were limited when the phrase rung answered', async () => {
// The OR rung would have filled the candidate limit; the rung that answered
// did not, and it is the answering rung the notice describes.
const { engine } = await pasted()
expect(engine.search({ query: sentence }).truncated.candidates).toBe(false)
expect(engine.search({ query: 'the review says yet' }).truncated.candidates).toBe(true)
})
it('falls to AND for prose whose words are all present but not adjacent', async () => {
const { db, engine } = await open('ss-engine-prose-and')
addSyntheticSession(db, {
id: 1,
text: 'yet quite merge-ready the PR is not what sol says a review of it'
})
const result = engine.search({ query: sentence })
expect(result.planner.route).toBe('and')
expect(ids(result)).toEqual(['1'])
})
it('still sends a single prose word straight to OR', async () => {
const { db, engine } = await open('ss-engine-prose-one-word')
addSyntheticSession(db, { id: 1, text: 'relay' })
expect(engine.search({ query: 'relay' }).planner.route).toBe('or')
})
})
@@ -212,7 +212,7 @@ export class SessionSearchEngine {
const { session, message } = entry
const snippet =
message && retrieved
? sessionSearchSnippet(this.db, scope, message.rowid, retrieved.plan)
? sessionSearchSnippet(this.db, scope, message.rowid, retrieved.plan, retrieved.route)
: EMPTY_SNIPPET
return {
...sessionFields(session),
@@ -1,4 +1,3 @@
import { parserPublishesMessages } from '../ai-vault/session-scanner-agent-parser'
import {
registerTranscriptConsumer,
type TranscriptConsumer,
@@ -7,7 +6,6 @@ import {
type TranscriptReadOutcome,
type TranscriptReadStart
} from '../ai-vault/session-transcript-consumers'
import type { SessionFileCandidate } from '../ai-vault/session-scanner-types'
import { fileIdentity } from './session-search-file-cursor'
import type { SessionSearchFileWrite } from './session-search-index-writer'
import type { SessionSearchStore } from './session-search-store'
@@ -31,10 +29,6 @@ export class SessionSearchIndexConsumer implements TranscriptConsumer {
beginRead(start: TranscriptReadStart): TranscriptReadConsumer | null {
const { candidate } = start
if (!parserPublishesMessages(candidate)) {
this.noteUnreachableParser(candidate)
return null
}
if (start.mode === 'append') {
const cursor = this.store.indexedFile(candidate.file.path, fileIdentity(candidate.file))
if (!cursor || cursor.byteOffset !== start.previousByteOffset) {
@@ -60,35 +54,6 @@ export class SessionSearchIndexConsumer implements TranscriptConsumer {
}
return new SessionSearchReadConsumer(this.store, start, write)
}
/**
* A source no read can ever index, recorded as one this index has seen.
*
* A parser that decodes where the message channel cannot reach it -- OpenCode's
* SQLite sessions today -- publishes nothing, so no read of it will ever
* commit a row. Leaving the file table silent about it is not free: the next
* pass sees a path the index holds nothing for, asks for a read, and asking
* over a warm cache drops the session list's own resume point. The sidebar's
* fold is thrown away and the whole database is decoded again, on every pass,
* for ever.
*
* The row written is the shape the store already has for a read that went
* through and decoded no session: cursor at the file's size, no session row.
* The decide step then skips it until its stat moves, and the retirement walk
* retires it like any other row when it goes.
*/
private noteUnreachableParser(candidate: SessionFileCandidate): void {
const write = this.store.beginWrite(candidate, 'replace', 0)
const committed =
write?.commit({
session: null,
byteOffset: candidate.file.sizeBytes ?? 0,
incomplete: false
}) === true
if (committed) {
this.store.writeCommitted(candidate)
}
}
}
class SessionSearchReadConsumer implements TranscriptReadConsumer {
@@ -98,25 +98,42 @@ it('resumes into a grown transcript instead of re-reading it whole', async () =>
// Nothing is recorded about what a deadline cut off, because being owed is a
// fact about the row: the file is read on the next pass for the same reason it
// was owed on this one.
// was owed on this one. The one thing handed back is how many there were, since
// a candidate with no row yet is a backlog no query can see.
it('leaves what it ran out of time for owed, with nothing written down', async () => {
const all = await candidates()
const cut = await runSessionSearchIndexPass(store, all, { rows: rows(), overdue: () => true })
expect(cut.outOfTime).toBe(true)
expect(cut).toMatchObject({ outOfTime: true, left: 1 })
expect(store.files()).toHaveLength(1)
const second = await passOverAll()
expect(second.stats.fullParses).toBe(1)
expect(store.files()).toHaveLength(2)
})
// A deferred candidate whose row already says `due` is in `stateCounts().due`,
// which the status adds `left` to; counting it here would report it twice.
it('leaves a deferred candidate out of the count when its row already says due', async () => {
await passOverAll()
for (const row of store.files()) {
store.setFileState(row.path, 'due')
}
const cut = await runSessionSearchIndexPass(store, await candidates(), {
rows: rows(),
overdue: () => true
})
expect(cut).toMatchObject({ outOfTime: true, left: 0 })
})
// The deadline is never applied before the pass has read anything, so a single
// transcript larger than one deadline is read alone rather than starved.
it('reads one file even when the deadline has already expired', async () => {
const only = (await candidates()).slice(0, 1)
const alone = await runSessionSearchIndexPass(store, only, { rows: rows(), overdue: () => true })
expect(alone.outOfTime).toBe(false)
expect(alone).toMatchObject({ outOfTime: false, left: 0 })
expect(store.files()).toHaveLength(1)
})
@@ -26,21 +26,29 @@ export type SessionSearchIndexPassOptions = {
/**
* Reads whatever the decide step says is owed, until the deadline.
*
* Nothing is recorded about what it did not reach. A candidate the deadline cut
* off is still owed on the next pass for the same reason it was owed on this
* one — its row says so — so there is no queue to keep, nothing to bound, and
* nothing to drop. What the reads themselves leave behind is written by the
* index consumer onto the rows.
* Nothing is recorded about what it did not reach beyond `left`, a count the
* caller reports and nothing acts on. A candidate the deadline cut off is still
* owed on the next pass for the same reason it was owed on this one — its row
* says so — so there is no queue to keep, nothing to bound, and nothing to
* drop. What the reads themselves leave behind is written by the index consumer
* onto the rows.
*
* `left` is what makes the backlog sayable: a candidate with no row yet, or one
* whose row does not say it is owed, is counted by no `due` query, so without
* this the status has no way to tell an index that holds everything from one
* that has barely started. Candidates whose row is already `due` are left out,
* because the status adds `left` to that same count.
*/
export async function runSessionSearchIndexPass(
store: SessionSearchStore,
candidates: readonly SessionFileCandidate[],
options: SessionSearchIndexPassOptions
): Promise<{ stats: SessionParseStats; outOfTime: boolean }> {
): Promise<{ stats: SessionParseStats; outOfTime: boolean; left: number }> {
const stats = createSessionParseStats()
const cutoffMs = store.retentionCutoff
let read = 0
let outOfTime = false
let left = 0
for (const candidate of candidates) {
throwIfAiVaultScanCancelled(options.signal)
const path = candidate.file.path
@@ -61,6 +69,11 @@ export async function runSessionSearchIndexPass(
// count of what a pass left is worth more than the microseconds.
outOfTime ||= read > 0 && options.overdue?.() === true
if (outOfTime) {
// A `due` row is already in `stateCounts().due`, which the status adds
// this to; counting it here would report the same file twice.
if (row?.state !== 'due') {
left += 1
}
continue
}
// The clock the deadline reads is one the owner may close behind: the read
@@ -80,5 +93,5 @@ export async function runSessionSearchIndexPass(
)
}
}
return { stats, outOfTime }
return { stats, outOfTime, left }
}
@@ -329,7 +329,9 @@ it('reads what one pass has time for and finishes the rest on the next', async (
)
}
await indexer?.reconcile()
expect(indexer?.status()).toMatchObject({ filesIndexed: 3, filesDue: 0 })
// Two of the four went unread, and neither has a row, so the count it hands
// back is the only thing that can say the index is not done.
expect(indexer?.status()).toMatchObject({ filesIndexed: 3, filesDue: 2, phase: 'indexing' })
await indexer?.reconcile()
expect(sessionsMatching('deadlined')).toHaveLength(4)
@@ -926,14 +928,24 @@ it('stops the opening sweep at its deadline and drains the rest over the passes
await writeClaudeTranscript(transcriptPath(session), [`backlogged session ${index}`], session)
}
await newIndexer(readsPerPass(2)).start()
expect(indexer?.status().filesIndexed).toBe(2)
// A sweep that ran out of time did not sweep the machine: it says so rather
// than stamping itself complete and reporting the three it never opened as
// nothing at all.
expect(indexer?.status()).toMatchObject({
filesIndexed: 2,
filesDue: 3,
phase: 'indexing',
lastSweepCompletedAt: null
})
await nextCycle()
expect(indexer?.status().filesIndexed).toBe(4)
expect(indexer?.status()).toMatchObject({ filesIndexed: 4, filesDue: 1, phase: 'indexing' })
expect(indexer?.status().lastSweepCompletedAt).toBeNull()
await nextCycle()
expect(sessionsMatching('backlogged')).toHaveLength(5)
expect(indexer?.status()).toMatchObject({ filesIndexed: 5, filesDue: 0 })
expect(indexer?.status()).toMatchObject({ filesIndexed: 5, filesDue: 0, phase: 'current' })
expect(indexer?.status().lastSweepCompletedAt).not.toBeNull()
})
// The sweep cadence, with nobody asking for it: a file outside the recency
@@ -32,7 +32,11 @@ export type SessionSearchIndexStatus = {
phase: SessionSearchIndexPhase
/** Rows whose content matches the file at the stat the row records. */
filesIndexed: number
/** Rows owed a whole read: a declined append, or a window that widened. */
/**
* Files owed a read: rows the index holds and must re-read (a declined
* append, a window that widened), plus candidates the last pass ran out of
* time for, which have no row to be counted by.
*/
filesDue: number
/** Rows whose last read did not commit. */
filesFailed: number
@@ -40,6 +44,8 @@ export type SessionSearchIndexStatus = {
lastReconcileAt: number | null
/** When a whole-machine sweep last finished; null until one has. */
lastSweepCompletedAt: number | null
/** Indexed sessions per agent; an agent with files and none is unsearchable. */
sessionsByAgent: Record<string, number>
}
/**
@@ -64,8 +70,10 @@ export type SessionSearchIndexStatus = {
* `session-search-deleted-sources.ts`.
* - `cyclesSinceSweep` and `sweepNext`, which are about the timer rather than
* about any file, and mean nothing to a second process.
* - `degradedRoots`, `lastReconcileAt` and `lastSweepCompletedAt`: what the last
* pass observed, held so `status()` can answer between passes.
* - `degradedRoots`, `lastReconcileAt`, `lastSweepCompletedAt` and `left`: what
* the last pass observed, held so `status()` can answer between passes.
* `left` cannot be a row: a candidate the deadline never reached has no row
* yet, which is exactly why no query can see the backlog.
* - `lastCounts`, the one cached query result, read only after `close()` so that
* describing what happened does not reopen a handle the owner has finished
* with. While the indexer is open every call re-queries.
@@ -99,6 +107,8 @@ export class SessionSearchIndexer {
private degradedRoots: SessionSearchDegradedRoot[] = []
private lastReconcileAt: number | null = null
private lastSweepCompletedAt: number | null = null
/** Candidates the last completed pass was owed and did not read. */
private left = 0
private lastCounts: SessionSearchStateCounts | null = null
private cyclesSinceSweep = 0
private sweepNext = false
@@ -187,16 +197,18 @@ export class SessionSearchIndexer {
const settled = (this.closed ? this.lastCounts : this.readCounts()) ?? {
current: 0,
due: 0,
failed: 0
failed: 0,
sessionsByAgent: {}
}
return {
phase: this.phase(settled),
filesIndexed: settled.current,
filesDue: settled.due,
filesDue: settled.due + this.left,
filesFailed: settled.failed,
degradedRoots: this.degradedRoots.map((root) => ({ ...root })),
lastReconcileAt: this.lastReconcileAt,
lastSweepCompletedAt: this.lastSweepCompletedAt
lastSweepCompletedAt: this.lastSweepCompletedAt,
sessionsByAgent: { ...settled.sessionsByAgent }
}
}
@@ -234,11 +246,11 @@ export class SessionSearchIndexer {
}
/**
* `current` is a claim, so it takes all three: no row owed a read, no row
* whose last read failed, and a whole sweep that finished. `idle` is the
* other end of it — an indexer nobody started has not promised to index
* anything, and calling that `current` would claim an index nobody built is
* up to date.
* `current` is a claim, so it takes all of it: nothing owed a read by a row,
* nothing owed a read that has no row yet, no row whose last read failed,
* and a whole sweep that finished. `idle` is the other end of it
* — an indexer nobody started has not promised to index anything, and calling
* that `current` would claim an index nobody built is up to date.
*/
private phase(counts: SessionSearchStateCounts): SessionSearchIndexPhase {
if (this.closed) {
@@ -252,6 +264,10 @@ export class SessionSearchIndexer {
if (this.degradedRoots.length > 0 || counts.failed > 0) {
return 'degraded'
}
// Work the rows cannot show: a candidate the deadline cut off has no row.
if (this.left > 0) {
return 'indexing'
}
return counts.due === 0 && this.lastSweepCompletedAt !== null ? 'current' : 'indexing'
}
@@ -303,12 +319,20 @@ export class SessionSearchIndexer {
this.degradedRoots = result.degradedRoots
this.previousRootsWithFiles = result.rootsWithFiles
this.lastReconcileAt = this.clock.now()
// Replaced, not accumulated: it is this pass's measure of the backlog, and
// a pass that read everything it was owed measures zero.
this.left = result.left
// A backlog outside the recency window is only visible to a sweep, so a
// pass that ran out of time asks for one. It is self-limiting: the first
// pass that finishes its reads hands the interval back to cycles.
this.sweepNext ||= result.outOfTime
if (full) {
this.lastSweepCompletedAt = this.lastReconcileAt
// A sweep the deadline stopped with candidates still unread did not
// sweep the machine, and stamping it would let `current` be claimed
// over a backlog no row can account for.
if (!result.outOfTime) {
this.lastSweepCompletedAt = this.lastReconcileAt
}
this.cyclesSinceSweep = 0
return
}
@@ -1,177 +0,0 @@
import { mkdirSync } from 'node:fs'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
// Only the thread hop is replaced: both implementations below are the repo's
// own in-process readers, which the worker entry calls on the other side.
export const openCodeParseCalls: string[] = []
vi.mock('../ai-vault/session-scanner-opencode-sqlite-worker-spawn', async () => {
const list = await import('../ai-vault/session-scanner-opencode-sqlite-list')
const parse = await import('../ai-vault/session-scanner-opencode-sqlite')
const own = await import('./session-search-opencode-decline.test')
return {
resolveOpenCodeSqliteWorkerEntryPath: () => null,
listOpenCodeSqliteSessionsViaWorker: (
args: Parameters<typeof list.listOpenCodeSqliteSessions>[0]
) => list.listOpenCodeSqliteSessions(args),
parseOpenCodeSqliteSessionViaWorker: (
args: Parameters<typeof parse.parseOpenCodeSqliteSession>[0]
) => {
own.openCodeParseCalls.push(args.sessionId)
return parse.parseOpenCodeSqliteSession(args)
}
}
})
import Database from '../sqlite/sync-database'
import { getSessionParseCacheEntry } from '../ai-vault/session-parse-cache-store'
import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache'
import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers'
import { buildOpenCodeSqliteCandidatePath } from '../ai-vault/session-scanner-opencode-sqlite-paths'
import { SessionSearchIndexer } from './session-search-indexer'
import {
FakeSessionSearchClock,
openSessionSearchIndexerHarness,
writeClaudeTranscript,
type SessionSearchIndexerHarness
} from './session-search-indexer-test-fixture'
/*
* Round 12, F3. An OpenCode SQLite session decodes where the message channel
* cannot reach it, so no read of one will ever commit a row. The consumer
* declined it and wrote nothing, which left the file table silent about a
* source discovery returns on every pass: the decide step saw a path the index
* held nothing for, asked for a read, and asking for one over a warm cache
* drops the session list's own resume point. Every OpenCode session was fully
* decoded on every pass and the sidebar's fold was thrown away with it, which
* is the cache STA-1278 and STA-1417 added.
*/
const SESSION = 'ses_r12'
const CLAUDE_SESSION = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'
let harness: SessionSearchIndexerHarness
let clock: FakeSessionSearchClock
let indexer: SessionSearchIndexer | null = null
beforeEach(async () => {
resetSessionParseCacheForTests()
resetTranscriptConsumersForTests()
clock = new FakeSessionSearchClock()
harness = await openSessionSearchIndexerHarness('ss-opencode-decline')
indexer = null
openCodeParseCalls.length = 0
})
afterEach(async () => {
indexer?.close()
resetTranscriptConsumersForTests()
resetSessionParseCacheForTests()
await harness.cleanup()
})
function writeOpenCodeDb(path: string, sessionId: string): void {
const db = new Database(path)
db.exec(`
CREATE TABLE session (
id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT, slug TEXT NOT NULL,
directory TEXT NOT NULL, title TEXT NOT NULL, version TEXT NOT NULL, share_url TEXT,
summary_additions INTEGER, summary_deletions INTEGER, summary_files INTEGER,
summary_diffs TEXT, revert TEXT, permission TEXT,
time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, time_compacting INTEGER,
time_archived INTEGER, workspace_id TEXT, path TEXT, agent TEXT, model TEXT,
cost REAL DEFAULT 0 NOT NULL, tokens_input INTEGER DEFAULT 0 NOT NULL,
tokens_output INTEGER DEFAULT 0 NOT NULL, tokens_reasoning INTEGER DEFAULT 0 NOT NULL,
tokens_cache_read INTEGER DEFAULT 0 NOT NULL, tokens_cache_write INTEGER DEFAULT 0 NOT NULL,
metadata TEXT
);
CREATE TABLE message (
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL, data TEXT NOT NULL
);
CREATE TABLE project (
id TEXT PRIMARY KEY, worktree TEXT NOT NULL, vcs TEXT, name TEXT, icon_url TEXT,
icon_color TEXT, time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL,
time_initialized INTEGER, sandboxes TEXT NOT NULL, commands TEXT, icon_url_override TEXT
);
CREATE TABLE part (
id TEXT PRIMARY KEY, message_id TEXT NOT NULL, session_id TEXT NOT NULL,
time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, data TEXT NOT NULL
);
`)
db.prepare(
`INSERT INTO session (id, project_id, parent_id, slug, directory, title, version,
time_created, time_updated, agent, model, cost, tokens_input, tokens_output,
tokens_reasoning, tokens_cache_read, tokens_cache_write)
VALUES (?, 'proj-1', NULL, 'slug-1', '/tmp/opencode', 'OpenCode title', '1.0.0',
?, ?, 'build', '{"id":"glm"}', 0, 1, 1, 0, 0, 0)`
).run(sessionId, 1_740_000_000_000, 1_740_000_100_000)
db.prepare(
`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)`
).run(
'msg-1',
sessionId,
1_740_000_000_000,
1_740_000_000_000,
JSON.stringify({ role: 'user', time: { created: 1_740_000_000_000 } })
)
db.prepare(
`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)`
).run(
'part-1',
'msg-1',
sessionId,
1_740_000_000_000,
1_740_000_000_000,
JSON.stringify({ type: 'text', text: 'hello opencode' })
)
db.prepare(
`INSERT INTO project (id, worktree, name, time_created, time_updated, sandboxes)
VALUES ('proj-1', '/tmp/opencode', 'proj', ?, ?, '[]')`
).run(1_740_000_000_000, 1_740_000_000_000)
db.close()
}
it('reads an OpenCode session once, not on every pass', async () => {
const dbPath = join(harness.root, 'opencode-db', 'opencode.db')
mkdirSync(join(harness.root, 'opencode-db'), { recursive: true })
writeOpenCodeDb(dbPath, SESSION)
const claudePath = join(harness.claudeProjectDir, 'control.jsonl')
await writeClaudeTranscript(claudePath, ['control turn'], CLAUDE_SESSION)
indexer = new SessionSearchIndexer({
databasePath: harness.databasePath,
roots: { ...harness.roots, opencodeDbPaths: [dbPath] },
historyDays: null,
clock,
reconcileIntervalMs: 20_000,
onError: () => undefined
})
await indexer.start()
const syntheticPath = buildOpenCodeSqliteCandidatePath(dbPath, SESSION)
const openCodeAfterFirst = getSessionParseCacheEntry(syntheticPath)
const claudeAfterFirst = getSessionParseCacheEntry(claudePath)
await indexer.reconcile()
await indexer.reconcile()
// One decode across three passes, and the session list's cached fold for it
// is the same object it was after the first: nothing invalidated it.
expect(openCodeParseCalls).toHaveLength(1)
expect(getSessionParseCacheEntry(syntheticPath)).toBe(openCodeAfterFirst)
// The control, which the index really does hold, is untouched either way.
expect(getSessionParseCacheEntry(claudePath)).toBe(claudeAfterFirst)
// What makes it skippable: a row saying the index has seen this source and
// holds no session for it, which is the shape a read-through-with-no-session
// already leaves.
const rows = harness.read((db) =>
db.prepare('SELECT path, state, session_row_id FROM files ORDER BY path').all()
) as { path: string; state: string; session_row_id: number | null }[]
expect(rows).toHaveLength(2)
expect(rows.find((row) => row.path === syntheticPath)).toMatchObject({
state: 'current',
session_row_id: null
})
expect(indexer.status()).toMatchObject({ filesDue: 0, filesFailed: 0, phase: 'current' })
})
@@ -0,0 +1,261 @@
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
// Only the thread hop is replaced: all three implementations below are the
// repo's own in-process readers, which the worker entry calls on the other side.
export const openCodeReadCalls: string[] = []
vi.mock('../ai-vault/session-scanner-opencode-sqlite-worker-spawn', async () => {
const list = await import('../ai-vault/session-scanner-opencode-sqlite-list')
const parse = await import('../ai-vault/session-scanner-opencode-sqlite')
const capture = await import('../ai-vault/session-scanner-opencode-sqlite-capture')
const own = await import('./session-search-opencode-index.test')
return {
resolveOpenCodeSqliteWorkerEntryPath: () => null,
listOpenCodeSqliteSessionsViaWorker: (
args: Parameters<typeof list.listOpenCodeSqliteSessions>[0]
) => list.listOpenCodeSqliteSessions(args),
parseOpenCodeSqliteSessionViaWorker: (
args: Parameters<typeof parse.parseOpenCodeSqliteSession>[0]
) => {
own.openCodeReadCalls.push(`parse:${args.sessionId}`)
return parse.parseOpenCodeSqliteSession(args)
},
captureOpenCodeSqliteSessionViaWorker: (
args: Parameters<typeof capture.captureOpenCodeSqliteSession>[0]
) => {
own.openCodeReadCalls.push(`capture:${args.sessionId}`)
return capture.captureOpenCodeSqliteSession(args)
}
}
})
import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache'
import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers'
import { buildOpenCodeSqliteCandidatePath } from '../ai-vault/session-scanner-opencode-sqlite-paths'
import {
appendOpenCodeSqliteTurn,
writeOpenCodeSqliteDatabase
} from '../ai-vault/session-scanner-opencode-sqlite-fixture'
import type SyncDatabase from '../sqlite/sync-database'
import { SessionSearchEngine } from './session-search-engine'
import { SessionSearchIndexer } from './session-search-indexer'
import { openSessionSearchDatabase } from './session-search-schema'
import {
FakeSessionSearchClock,
openSessionSearchIndexerHarness,
writeClaudeTranscript,
type SessionSearchIndexerHarness
} from './session-search-indexer-test-fixture'
/*
* Nothing any OpenCode agent said used to be searchable. Its sessions live in
* one SQLite database read on a worker thread, and the worker only ever
* returned the newest few messages for the panel preview, so the index recorded
* a placeholder row and moved on. This is the end-to-end proof that a sentence
* an OpenCode assistant wrote comes back from a real search over a real index.
*/
// Literal-looking on purpose: the `phrase` route is the one a user quoting a
// remembered sentence takes, and only a literal query reaches it.
const ANSWER = 'the quokkaTelemetry harness reindexes every shard'
const OTHER = 'a completely unrelated conversation about typography'
// Appears only in a tool part's output, so it separates the two scopes.
const TOOL_ONLY = 'zarquonium'
const TOOL_FILE = '/repo/app/src/telemetry/shard-reindex.ts'
const SESSION = 'ses_capture'
const SECOND_SESSION = 'ses_second'
const CLAUDE_SESSION = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'
let harness: SessionSearchIndexerHarness
let clock: FakeSessionSearchClock
let indexer: SessionSearchIndexer | null = null
let engineDbs: SyncDatabase[] = []
beforeEach(async () => {
resetSessionParseCacheForTests()
resetTranscriptConsumersForTests()
clock = new FakeSessionSearchClock()
harness = await openSessionSearchIndexerHarness('ss-opencode-index')
indexer = null
engineDbs = []
openCodeReadCalls.length = 0
})
afterEach(async () => {
indexer?.close()
for (const db of engineDbs) {
db.close()
}
resetTranscriptConsumersForTests()
resetSessionParseCacheForTests()
await harness.cleanup()
})
function dbPath(): string {
return join(harness.root, 'opencode-db', 'opencode.db')
}
async function startIndexer(): Promise<SessionSearchIndexer> {
const started = new SessionSearchIndexer({
databasePath: harness.databasePath,
roots: { ...harness.roots, opencodeDbPaths: [dbPath()] },
historyDays: null,
clock,
reconcileIntervalMs: 20_000,
onError: (error) => {
throw error
}
})
indexer = started
await started.start()
return started
}
/** A second connection on the index file, the way the live instance pairs them. */
function openEngine(): SessionSearchEngine {
const db = openSessionSearchDatabase(harness.databasePath)
engineDbs.push(db)
return new SessionSearchEngine(db)
}
function writeVault(): void {
writeOpenCodeSqliteDatabase(dbPath(), [
{
id: SESSION,
title: 'Telemetry work',
directory: '/tmp/opencode',
turns: [
{ role: 'user', parts: ['how do I reindex the shards'] },
{
role: 'assistant',
parts: [
{ type: 'reasoning', text: 'Checking how the shard map is built.' },
'Here is the plan.',
ANSWER,
{
type: 'tool',
tool: 'read',
// The camelCase spelling OpenCode writes; the shared key list
// knows only `file_path`, so finding this proves the rename.
input: { filePath: TOOL_FILE },
output: `export const marker = '${TOOL_ONLY}'`
},
{
type: 'tool',
tool: 'bash',
input: { command: 'pnpm reindex --all' },
error: 'reindex exited with code 2'
}
]
}
]
},
{
id: SECOND_SESSION,
title: 'Typography',
directory: '/tmp/opencode-two',
turns: [{ role: 'assistant', parts: [OTHER] }]
}
])
}
it('finds a sentence an OpenCode assistant wrote, through the real indexer', async () => {
writeVault()
await startIndexer()
const response = openEngine().search({ query: ANSWER })
expect(response.planner.route).toBe('phrase')
expect(response.hits).toHaveLength(1)
const hit = response.hits[0]
expect(hit).toMatchObject({
agent: 'opencode',
sessionId: SESSION,
cwd: '/tmp/opencode'
})
expect(hit?.evidence?.role).toBe('assistant')
expect(hit?.evidence?.snippet).toContain('quokkaTelemetry')
// The whole-session read, not the preview window: the user turn is indexed too.
expect(openEngine().search({ query: 'reindex the shards' }).hits).toHaveLength(1)
// And the sibling session is a session of its own, not folded into this one.
expect(openEngine().search({ query: OTHER }).hits[0]?.sessionId).toBe(SECOND_SESSION)
})
it('searches tool output under the all scope and not under conversation', async () => {
writeVault()
await startIndexer()
const all = openEngine().search({ query: TOOL_ONLY, scope: 'all' })
expect(all.hits).toHaveLength(1)
expect(all.hits[0]).toMatchObject({ agent: 'opencode', sessionId: SESSION })
expect(all.hits[0]?.evidence?.role).toBe('tool')
// Conversation is user and assistant turns only, so a token that lives in a
// tool's output has nothing to match there.
expect(openEngine().search({ query: TOOL_ONLY, scope: 'conversation' }).hits).toEqual([])
})
it('indexes a tool call by its file argument and a failed one by its error', async () => {
writeVault()
await startIndexer()
// `filePath` renamed to the spelling the shared input-key list knows: without
// it the call line would be the bare tool name and this would find nothing.
expect(openEngine().search({ query: TOOL_FILE, scope: 'all' }).hits[0]?.sessionId).toBe(SESSION)
// A call that failed carries its error where a completed one carries output.
const failed = openEngine().search({ query: 'reindex exited with code', scope: 'all' })
expect(failed.hits[0]?.evidence?.role).toBe('tool')
})
it('folds a reasoning part into the assistant turn it belongs to', async () => {
writeVault()
await startIndexer()
const hit = openEngine().search({ query: 'checking how the shard map is built' }).hits[0]
expect(hit?.sessionId).toBe(SESSION)
expect(hit?.evidence?.role).toBe('assistant')
})
it('reads an OpenCode session once, not on every pass', async () => {
writeVault()
const claudePath = join(harness.claudeProjectDir, 'control.jsonl')
await writeClaudeTranscript(claudePath, ['control turn'], CLAUDE_SESSION)
const started = await startIndexer()
await started.reconcile()
await started.reconcile()
// One capture per session across three passes; nothing re-decodes a session
// whose `time_updated` has not moved.
expect(openCodeReadCalls).toEqual([`capture:${SESSION}`, `capture:${SECOND_SESSION}`])
const rows = harness.read((db) =>
db.prepare('SELECT path, state, session_row_id FROM files ORDER BY path').all()
) as { path: string; state: string; session_row_id: number | null }[]
expect(
rows.find((row) => row.path === buildOpenCodeSqliteCandidatePath(dbPath(), SESSION))
).toMatchObject({ state: 'current' })
expect(
rows.find((row) => row.path === buildOpenCodeSqliteCandidatePath(dbPath(), SESSION))
?.session_row_id
).not.toBeNull()
expect(started.status()).toMatchObject({ filesDue: 0, filesFailed: 0, phase: 'current' })
// The count that made this bug visible: two OpenCode files, two OpenCode
// sessions. Before the capture channel it read two files and zero sessions.
expect(started.status().sessionsByAgent).toMatchObject({ opencode: 2, claude: 1 })
})
it('re-reads a session that gained a message and replaces its rows', async () => {
writeVault()
const started = await startIndexer()
expect(openEngine().search({ query: 'orthogonal vestibule' }).hits).toHaveLength(0)
appendOpenCodeSqliteTurn(dbPath(), SESSION, {
role: 'assistant',
parts: ['an orthogonal vestibule appeared']
})
await started.reconcile()
const engine = openEngine()
expect(engine.search({ query: 'orthogonal vestibule' }).hits[0]?.sessionId).toBe(SESSION)
// Replaced whole, not appended twice: the original turn is still one hit.
expect(engine.search({ query: ANSWER }).hits).toHaveLength(1)
expect(openCodeReadCalls.filter((call) => call === `capture:${SESSION}`)).toHaveLength(2)
})
@@ -96,7 +96,7 @@ it('snippets nothing for an orphaned row, even asked for it by rowid', async ()
const { harness: open, rowids } = await withOrphans()
const plan = planSessionSearchQuery('marmoset')
for (const scope of ['all', 'conversation'] as const) {
expect(sessionSearchSnippet(open.db, scope, rowids[0]!, plan)).toEqual({
expect(sessionSearchSnippet(open.db, scope, rowids[0]!, plan, 'or')).toEqual({
text: '',
truncated: false
})
@@ -75,6 +75,14 @@ export type SessionSearchPassResult = {
* making progress only on the periodic sweep every five minutes.
*/
outOfTime: boolean
/**
* Candidates this pass decided were owed a read and did not read.
*
* Zero unless the deadline stopped the reads. Not a queue: it is the size of
* the backlog at the moment the pass gave up, reported so the caller can say
* so, and every one of them is owed again on the next pass by its row.
*/
left: number
}
/**
@@ -113,6 +121,7 @@ export async function runSessionSearchPass(
let completed = true
let outOfTime = false
let left = 0
const rows = new Map(store.files().map((row) => [row.path, row]))
try {
const read = await runSessionSearchIndexPass(store, swept.candidates, {
@@ -121,6 +130,7 @@ export async function runSessionSearchPass(
overdue: args.overdue
})
outOfTime = read.outOfTime
left = read.left
} catch (error) {
if (!signal?.aborted) {
throw error
@@ -183,7 +193,8 @@ export async function runSessionSearchPass(
unlistable
),
completed,
outOfTime
outOfTime,
left
}
})
}
@@ -82,3 +82,28 @@ describe('FTS5 expressions quote every term', () => {
expect(orExpression(['alpha', 'beta'])).toBe('"alpha" OR "beta"')
})
})
describe('the phrase candidate is the query as typed', () => {
const sentence = 'The sol review says the PR is not quite merge-ready yet'
it('is prose, so nothing about its shape reaches the phrase route', () => {
expect(isLiteralQuery(sentence)).toBe(false)
})
it('keeps the stop words the OR body drops, because the index holds them', () => {
const plan = planSessionSearchQuery('why is the relay dropping frames')
expect(plan.phrase).toEqual(['why', 'is', 'the', 'relay', 'dropping', 'frames'])
expect(plan.body).toEqual(['relay', 'dropping', 'frames'])
})
it('is the same list as the body for a literal, which keeps every token', () => {
const plan = planSessionSearchQuery('the foo.ts file')
expect(plan.phrase).toEqual(plan.body)
})
it('quotes into one phrase a pasted sentence can actually match', () => {
expect(phraseExpression(planSessionSearchQuery(sentence).phrase)).toBe(
'"The sol review says the PR is not quite merge-ready yet"'
)
})
})
@@ -31,8 +31,15 @@ export type SessionSearchQueryPlan = {
truncated: boolean
/** Deduplicated index-faithful terms for the OR fallback, incl. identifier pieces. */
terms: string[]
/** Query-order tokens minus stop words: the phrase / AND candidate. */
/** Query-order tokens minus stop words for prose, all of them for a literal. */
body: string[]
/**
* Query-order tokens exactly as typed, stop words kept: the phrase / AND
* candidate. A sentence pasted out of a transcript is only adjacent in the
* index with its stop words in place, and `unicode61` indexes them, so the
* phrase rung has to search the words the user actually typed.
*/
phrase: string[]
}
export function isLiteralQuery(query: string): boolean {
@@ -95,7 +102,8 @@ export function planSessionSearchQuery(
literal,
truncated,
terms: [...terms, ...extra].slice(0, MAX_TERMS),
body: body.slice(0, MAX_BODY_TERMS)
body: body.slice(0, MAX_BODY_TERMS),
phrase: raw
}
}
@@ -71,8 +71,7 @@ export class SessionSearchRetrieval {
}
/**
* The route ladder: phrase, then AND for a literal-looking query, then typo
* repair, then OR.
* The route ladder: phrase, then AND, then typo repair, then OR.
*
* Repair runs before the OR fallback rather than after it fails. A typo next
* to a common word would otherwise be masked: the common word alone retrieves
@@ -84,7 +83,10 @@ export class SessionSearchRetrieval {
let sessions: SessionRow[] = []
const match = (expression: string): MessageRow[] => {
const rows = this.match(expression, scope)
incomplete ||= rows.length >= scope.candidateLimit
// Assigned, not accumulated: only the rung whose rows are returned can
// say whether a cap hid anything. A phrase rung that filled the limit and
// was then discarded describes a row set the answering rung never used.
incomplete = rows.length >= scope.candidateLimit
sessions = this.loadSessions(
rows.map((row) => row.session_row_id),
scope
@@ -92,13 +94,13 @@ export class SessionSearchRetrieval {
const eligible = new Set(sessions.map((row) => row.id))
return rows.filter((row) => eligible.has(row.session_row_id))
}
const exact = this.literal(plan, match)
const exact = this.phraseThenAnd(plan, match)
if (exact) {
return { ...exact, plan, incomplete, sessions }
}
const repaired = this.repair(plan, scope.scope)
const effective = repaired ?? plan
const literal = repaired ? this.literal(repaired, match) : null
const literal = repaired ? this.phraseThenAnd(repaired, match) : null
const found = literal ?? {
rows: match(orExpression(effective.terms)),
route: 'or' as const
@@ -178,41 +180,59 @@ export class SessionSearchRetrieval {
): SessionSearchQueryPlan | null {
const typoRepair = this.typoRepair
let changed = false
const body = plan.body.map((term) => {
// Only the body is a candidate for a correction, but the re-plan is fed the
// tokens as typed: re-planning the body alone would hand the phrase rung a
// sentence with its stop words already gone, and `relay dropping frames`
// cannot match the `relay is dropping frames` that is in the transcript.
const repairable = new Set(plan.body.map((term) => term.toLowerCase()))
const phrase = plan.phrase.map((token) => {
if (!repairable.has(token.toLowerCase())) {
return token
}
// Repaired inside the scope the search will run in, so a spelling only
// tool output carries neither suppresses a repair nor becomes one.
const fix = typoRepair.correct(term, scope)
if (fix && fix !== term.toLowerCase()) {
const fix = typoRepair.correct(token, scope)
if (fix && fix !== token.toLowerCase()) {
changed = true
return fix
}
return term
return token
})
// The repair changes spellings, not the query's character: the re-plan is
// told what the original decided so a corrected literal keeps every term it
// was typed with.
return changed ? planSessionSearchQuery(body.join(' '), plan.literal) : null
return changed ? planSessionSearchQuery(phrase.join(' '), plan.literal) : null
}
/** Phrase, then AND, for literal-looking queries; null when neither matches. */
private literal(
/**
* Phrase, then AND, over the tokens as typed; null when neither matches.
*
* Prose runs it too, and not only a literal-looking query. A sentence pasted
* out of a transcript is ordinary words in order, and over OR its common
* words fill the candidate limit with recent sessions long before the old
* session that holds the sentence is reached, so the exact match a user can
* see in front of them comes back missing.
*/
private phraseThenAnd(
plan: SessionSearchQueryPlan,
match: (expression: string) => MessageRow[]
): { rows: MessageRow[]; route: 'phrase' | 'and' } | null {
if (!plan.literal || plan.body.length === 0) {
return null
}
const tokens = plan.phrase
// A one-token literal (`resolveTerminalPath`, `src/a/b.ts`) is its own
// phrase: the tokenizer keeps it whole, so the exact token is the cheap,
// precise first try before the identifier pieces fan out over OR.
const phrase = match(phraseExpression(plan.body))
// precise first try before the identifier pieces fan out over OR. One word
// of prose is not quoting anything, so it goes straight to OR as before.
if (tokens.length === 0 || (tokens.length < 2 && !plan.literal)) {
return null
}
const phrase = match(phraseExpression(tokens))
if (phrase.length > 0) {
return { rows: phrase, route: 'phrase' }
}
if (plan.body.length < 2) {
if (tokens.length < 2) {
return null
}
const and = match(andExpression(plan.body))
const and = match(andExpression(tokens))
return and.length > 0 ? { rows: and, route: 'and' } : null
}
@@ -11,7 +11,7 @@ import { removeTreeSync } from '../../shared/windows-transient-lock-removal'
// policy, decided where the wire is.
// Bump to drop and rebuild: the index is a cache over the transcripts, never a source.
export const SESSION_SEARCH_SCHEMA_VERSION = 5
export const SESSION_SEARCH_SCHEMA_VERSION = 6
// unicode61 keeps `_ . - /` inside tokens so paths and identifiers match exactly;
// the `identifiers` column carries the split form (see session-search-identifier-split).
@@ -24,7 +24,7 @@ async function fixture() {
// degradedRoots is re-stated because the contract type leaves `root` optional
// for relay redaction, while the indexer always names the root it degraded.
const indexer = {
status: () => ({ ...status, degradedRoots: [] }),
status: () => ({ ...status, degradedRoots: [], sessionsByAgent: {} }),
reconcile: vi.fn(async () => {})
}
const service = createSessionSearchService({ engine: harness.engine, indexer })
@@ -142,3 +142,31 @@ it('does not cut a snippet at a private-use code point the transcript wrote', as
)
expect(snippet).toContain('qqqqq')
})
it('marks a phrase hit as one run, stop words included', async () => {
harness = await openSessionSearchHarness('ss-snippet-phrase-run')
addSyntheticSession(harness.db, {
id: 1,
text: 'Agent: the code already has several fixes for blank restores, including replaying'
})
const result = harness.engine.search({ query: 'the code already has several fixes' })
expect(result.planner.route).toBe('phrase')
expect(result.hits[0]?.evidence?.snippet).toContain(
`${SESSION_SEARCH_SNIPPET_MARK_OPEN}the code already has several fixes${SESSION_SEARCH_SNIPPET_MARK_CLOSE}`
)
})
it('marks every typed word of an AND hit, stop words included', async () => {
harness = await openSessionSearchHarness('ss-snippet-and-words')
addSyntheticSession(harness.db, { id: 1, text: 'fixes for the restore path, several of them' })
const result = harness.engine.search({ query: 'several fixes for the restore' })
expect(result.planner.route).toBe('and')
const snippet = result.hits[0]?.evidence?.snippet ?? ''
for (const word of ['several', 'fixes', 'for', 'the', 'restore']) {
expect(snippet).toContain(
`${SESSION_SEARCH_SNIPPET_MARK_OPEN}${word}${SESSION_SEARCH_SNIPPET_MARK_CLOSE}`
)
}
})
@@ -4,11 +4,13 @@ import {
SESSION_SEARCH_SNIPPET_MARK_OPEN
} from './session-search-engine-types'
import {
andExpression,
orExpression,
phraseExpression,
scopedExpression,
type SessionSearchQueryPlan
} from './session-search-query-planner'
import type { SessionSearchScope } from './session-search-engine-types'
import type { SessionSearchRoute, SessionSearchScope } from './session-search-engine-types'
// What FTS5 wraps a match in before this module rewrites it to the public
// marks. Private-use code points, and not `[[`, because two different jobs here
@@ -35,15 +37,17 @@ export const EMPTY_SNIPPET: SessionSearchSnippet = { text: '', truncated: false
/**
* The window of one message that shows why it matched.
*
* The expression is the plan's OR form rather than the route's, so a hit found
* through typo repair is marked with the repaired terms it was actually
* retrieved by, and a phrase hit still marks each of its words.
* Marked with the expression the route retrieved by, so a phrase hit is one
* highlight over the words as typed, stop words included, and an OR hit marks
* each term it was found through. The plan is the effective one, so a hit
* found through typo repair is marked with the repaired terms.
*/
export function sessionSearchSnippet(
db: SyncDatabase,
scope: SessionSearchScope,
rowid: number,
plan: SessionSearchQueryPlan
plan: SessionSearchQueryPlan,
route: SessionSearchRoute
): SessionSearchSnippet {
// Why: the identifier shadow column is word soup; a hit that also matches in a
// prose column should be shown from there. Column -1 (any column) is the
@@ -83,10 +87,8 @@ export function sessionSearchSnippet(
JOIN sessions s ON s.id = m.session_row_id
WHERE messages_fts MATCH ? AND messages_fts.rowid IN (SELECT ?)`
)
.get(scopedExpression(scope, orExpression(plan.terms)), rowid) as
| Record<string, string>
| undefined
if (!row) {
.get(scopedExpression(scope, routeExpression(plan, route)), rowid)
if (!isSnippetRow(row)) {
return EMPTY_SNIPPET
}
// A snippet with nothing highlighted tells the user nothing; omit it.
@@ -103,6 +105,24 @@ export function sessionSearchSnippet(
}
}
function isSnippetRow(value: unknown): value is Record<string, string> {
return (
typeof value === 'object' &&
value !== null &&
Object.values(value).every((column) => typeof column === 'string')
)
}
function routeExpression(plan: SessionSearchQueryPlan, route: SessionSearchRoute): string {
if (route.endsWith('phrase')) {
return phraseExpression(plan.phrase)
}
if (route.endsWith('and')) {
return andExpression(plan.phrase)
}
return orExpression(plan.terms)
}
/** One run of the snippet's own text, or one mark FTS5 put between two runs. */
type SnippetPiece = { kind: 'text'; value: string } | { kind: 'mark'; value: string }
@@ -1,4 +1,5 @@
import type SyncDatabase from '../sqlite/sync-database'
import { asRecord } from '../ai-vault/session-scanner-record-value'
import type { SessionFileCandidate } from '../ai-vault/session-scanner-types'
import type { TranscriptSessionIdentity } from '../ai-vault/session-transcript-consumers'
import type {
@@ -42,7 +43,20 @@ export type SessionSearchFileRow = {
}
/** How many rows are in each state; the whole of the indexer's progress report. */
export type SessionSearchStateCounts = { current: number; due: number; failed: number }
export type SessionSearchStateCounts = {
current: number
due: number
failed: number
/**
* Indexed sessions per agent.
*
* The one number that distinguishes an agent the index has read from one it
* has only listed: OpenCode's 606 rows in `files` with nothing in `sessions`
* was the shape of a whole source being silently unsearchable, and no
* file-state count could show it.
*/
sessionsByAgent: Record<string, number>
}
/**
* Owns the index database. PR 2 scope: the write half only — the transcript
@@ -272,13 +286,34 @@ export class SessionSearchStore {
state: SessionSearchFileState
n: number
}[]
const counts: SessionSearchStateCounts = { current: 0, due: 0, failed: 0 }
const counts: SessionSearchStateCounts = {
current: 0,
due: 0,
failed: 0,
sessionsByAgent: this.sessionsByAgent()
}
for (const row of rows) {
counts[row.state] = Number(row.n)
}
return counts
}
// Grouped on `sessions_agent`, over one row per indexed session. Deliberately
// not the message count beside it: that would scan every indexed row on a call
// the panel polls, and it answers the same question one table later.
private sessionsByAgent(): Record<string, number> {
const rows = this.db.prepare('SELECT agent, count(*) AS n FROM sessions GROUP BY agent').all()
const counts: Record<string, number> = {}
for (const row of rows) {
const agent = asRecord(row)?.agent
const total = asRecord(row)?.n
if (typeof agent === 'string' && typeof total === 'number') {
counts[agent] = total
}
}
return counts
}
/**
* Drops a source's rows. Only a proven deletion may call this: an unreadable
* source is `unverifiable`, not `missing`, and keeps its rows
@@ -6,11 +6,11 @@ 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'
captureOpenCodeSqliteSessionViaWorker,
parseOpenCodeSqliteSessionViaWorker
} from './session-scanner-opencode-sqlite-worker-spawn'
import { parseClaudeSessionFile } from './session-scanner-primary-parsers'
import { parseGeminiSessionFile } from './session-scanner-gemini-parsers'
import { parseCodexSessionFile } from './session-scanner-codex-parser'
@@ -22,12 +22,27 @@ 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.
* Read an OpenCode SQLite session on the worker thread.
*
* Two request kinds rather than one, chosen by whether anyone is listening: a
* list scan wants the newest few messages for the panel preview, so asking for
* the whole transcript would read every part of every session on every refresh.
* A read with a sink is the search index's, and that one needs all of it.
*/
export function parserPublishesMessages(candidate: SessionFileCandidate): boolean {
return candidate.agent !== 'opencode' || !looksLikeOpenCodeSqliteCandidate(candidate.file.path)
async function readOpenCodeSqliteCandidate(
sqliteCandidate: { dbPath: string; sessionId: string },
platform: NodeJS.Platform,
messages?: TranscriptMessageSink
): Promise<AiVaultSession | null> {
const request = { ...sqliteCandidate, platform }
if (!messages?.active) {
return parseOpenCodeSqliteSessionViaWorker(request)
}
const capture = await captureOpenCodeSqliteSessionViaWorker(request)
for (const message of capture.messages) {
messages.push(message)
}
return capture.session
}
/**
@@ -70,11 +85,7 @@ export async function parseAgentSessionFile(
// real filesystem paths and fall through to the JSON parser.
const sqliteCandidate = splitOpenCodeSqliteCandidate(candidate.file.path)
if (sqliteCandidate) {
return parseOpenCodeSqliteSessionViaWorker({
dbPath: sqliteCandidate.dbPath,
sessionId: sqliteCandidate.sessionId,
platform
})
return readOpenCodeSqliteCandidate(sqliteCandidate, platform, messages)
}
return parseOpenCodeSessionFile(candidate.file, platform, messages)
}
@@ -2,7 +2,11 @@ import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { parseCodexSessionFile } from './session-scanner-codex-parser'
import {
createCodexSessionResumeState,
parseCodexSessionFile
} from './session-scanner-codex-parser'
import type { TranscriptMessage } from './session-transcript-consumers'
let tempRoots: string[] = []
@@ -16,6 +20,24 @@ function jsonLines(records: unknown[]): string {
}
describe('parseCodexSessionFile', () => {
it('publishes a paginated agent reply whose block is typed Text to transcript consumers', () => {
const timestamp = '2026-08-10T10:00:00.000Z'
const messages: TranscriptMessage[] = []
const state = createCodexSessionResumeState(
{ path: '/fixture/rollout.jsonl', mtimeMs: Date.parse(timestamp), modifiedAt: timestamp },
null,
{ active: true, push: (message) => messages.push(message) }
)
const consume = (type: string, payload: Record<string, unknown>) =>
state.consumeLineBytes!(Buffer.from(JSON.stringify({ timestamp, type, payload })))
consume('session_meta', { id: 'paginated-session', history_mode: 'paginated' })
consume('event_msg', {
type: 'item_completed',
item: { type: 'AgentMessage', content: [{ type: 'Text', text: 'the reply' }] }
})
expect(messages).toEqual([{ role: 'assistant', text: 'the reply', timestamp }])
})
it('uses completed user items for paginated session metadata', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codex-paginated-'))
tempRoots.push(root)
@@ -0,0 +1,222 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { writeAntigravityScannerFixture } from './session-scanner-test-fixtures'
import { jsonlBody, type AgentVaultRoots } from './session-scanner-vault-roots'
// The agents whose session is a JSON document, or a directory of them, rewritten
// in place rather than appended to. Antigravity rides along here because its
// fixture writer already owns the layout.
/**
* Write one session per document-shaped agent.
* @param root - The vault root, which Kimi's session index lives directly in.
* @param roots - The scan roots to write under.
* @param antigravitySessionId - The conversation id Antigravity resumes by.
*/
export async function writeDocumentAgentFixtures(
root: string,
roots: AgentVaultRoots,
antigravitySessionId: string
): Promise<void> {
await mkdir(roots.geminiSessionsDir, { recursive: true })
await writeFile(
join(roots.geminiSessionsDir, 'gemini-session.json'),
JSON.stringify({
sessionId: 'gemini-session',
startTime: '2026-05-01T10:02:00.000Z',
lastUpdated: '2026-05-01T10:02:01.000Z',
messages: [
{
type: 'user',
timestamp: '2026-05-01T10:02:00.000Z',
content: [{ text: 'Gemini title' }]
},
{
type: 'gemini',
timestamp: '2026-05-01T10:02:01.000Z',
model: 'gemini-2.5-pro',
tokens: { input: 10, output: 5 }
}
]
})
)
await writeAntigravityScannerFixture(roots.antigravityBrainDir, antigravitySessionId)
await mkdir(join(roots.opencodeStorageDir, 'session', 'project'), { recursive: true })
await mkdir(join(roots.opencodeStorageDir, 'message', 'opencode-session'), { recursive: true })
await writeFile(
join(roots.opencodeStorageDir, 'session', 'project', 'ses_opencode.json'),
JSON.stringify({
id: 'opencode-session',
directory: '/tmp/opencode',
title: 'OpenCode title',
time: { created: 1_777_634_000_000, updated: 1_777_634_001_000 }
})
)
await writeFile(
join(roots.opencodeStorageDir, 'message', 'opencode-session', 'msg_1.json'),
JSON.stringify({
role: 'user',
summary: { title: 'OpenCode title' },
time: { created: 1_777_634_000_000 },
tokens: { input: 7, output: 3 }
})
)
await mkdir(join(roots.grokSessionsDir, encodeURIComponent('/tmp/grok'), 'grok-session'), {
recursive: true
})
await writeFile(
join(roots.grokSessionsDir, encodeURIComponent('/tmp/grok'), 'grok-session', 'summary.json'),
JSON.stringify({
info: { id: 'grok-session', cwd: '/tmp/grok' },
session_summary: '',
created_at: '2026-05-01T10:04:00.000Z',
updated_at: '2026-05-01T10:04:01.000Z',
num_chat_messages: 2,
current_model_id: 'grok-build',
head_branch: 'feature/grok-vault'
})
)
await writeFile(
join(
roots.grokSessionsDir,
encodeURIComponent('/tmp/grok'),
'grok-session',
'chat_history.jsonl'
),
jsonlBody([
{
type: 'user',
content: [
{
type: 'text',
text: '<user_info>context</user_info><user_query>Grok title</user_query>'
}
]
},
{ type: 'assistant', content: 'Done' }
])
)
await mkdir(roots.hermesSessionsDir, { recursive: true })
await writeFile(
join(roots.hermesSessionsDir, 'session_hermes-session.json'),
JSON.stringify({
session_id: 'hermes-session',
model: 'hermes-1',
cwd: '/tmp/hermes',
session_start: '2026-05-01T10:05:00.000Z',
last_updated: '2026-05-01T10:05:01.000Z',
messages: [{ role: 'user', content: 'Hermes title' }]
})
)
await mkdir(join(roots.rovoSessionsDir, 'rovo-session'), { recursive: true })
await writeFile(
join(roots.rovoSessionsDir, 'rovo-session', 'metadata.json'),
JSON.stringify({ title: 'Rovo title', workspace_path: '/tmp/rovo' })
)
await writeFile(
join(roots.rovoSessionsDir, 'rovo-session', 'session_context.json'),
JSON.stringify({
message_history: [
{
kind: 'request',
timestamp: '2026-05-01T10:06:00.000Z',
parts: [{ part_kind: 'user-prompt', content: 'Rovo title' }]
}
]
})
)
await mkdir(roots.devinTranscriptsDir, { recursive: true })
await writeFile(
join(roots.devinTranscriptsDir, 'devin-session.json'),
JSON.stringify({
session_id: 'devin-session',
working_directory: '/tmp/devin',
agent: { model_name: 'swe-1-6-fast' },
steps: [
{
metadata: {
created_at: '2026-05-01T10:10:00.000Z',
is_user_input: true,
metrics: { input_tokens: 1, output_tokens: 2 }
},
text: 'Devin vault title'
}
]
})
)
const clineSessionId = 'cline-session'
const clineSessionDir = join(roots.clineSessionsDir, clineSessionId)
await mkdir(clineSessionDir, { recursive: true })
await writeFile(
join(clineSessionDir, `${clineSessionId}.json`),
JSON.stringify({
session_id: clineSessionId,
started_at: '2026-05-01T10:10:30.000Z',
model: 'cline-model',
cwd: '/tmp/cline'
})
)
await writeFile(
join(clineSessionDir, `${clineSessionId}.messages.json`),
JSON.stringify({
updated_at: '2026-05-01T10:10:31.000Z',
messages: [{ role: 'user', content: [{ type: 'text', text: 'Cline vault title' }] }]
})
)
// Kimi: <sessions>/wd_*/session_*/state.json + sibling agents/main/wire.jsonl,
// with the work dir resolved from the top-level session_index.jsonl.
const kimiSessionDir = join(roots.kimiSessionsDir, 'wd_app_abc', 'session_kimi-session')
await mkdir(join(kimiSessionDir, 'agents', 'main'), { recursive: true })
await writeFile(
join(kimiSessionDir, 'state.json'),
JSON.stringify({
createdAt: '2026-05-01T10:11:00.000Z',
updatedAt: '2026-05-01T10:11:05.000Z',
title: 'Kimi vault title',
lastPrompt: 'Kimi vault title',
agents: { main: { type: 'main', parentAgentId: null } }
})
)
await writeFile(
join(root, 'session_index.jsonl'),
jsonlBody([
{ sessionId: 'session_kimi-session', sessionDir: kimiSessionDir, workDir: '/tmp/kimi' }
])
)
await writeFile(
join(kimiSessionDir, 'agents', 'main', 'wire.jsonl'),
jsonlBody([
{ type: 'config.update', modelAlias: 'kimi-k2.6', time: 1781853559132 },
{
type: 'context.append_message',
message: {
role: 'user',
content: [{ type: 'text', text: 'Kimi vault title' }],
origin: { kind: 'user' }
},
time: 1781853559164
},
{
type: 'context.append_loop_event',
event: { type: 'content.part', part: { type: 'text', text: 'Kimi reply' } },
time: 1781853559177
},
{ type: 'context.append_loop_event', event: { type: 'step.end' }, time: 1781853559178 },
{
type: 'usage.record',
model: 'kimi-k2.6',
usage: { inputOther: 4, output: 6, inputCacheRead: 0, inputCacheCreation: 0 },
usageScope: 'turn',
time: 1781853559178
}
])
)
}
@@ -0,0 +1,32 @@
import { isolatedScanRoots } from './session-scanner-test-fixtures'
import { writeDocumentAgentFixtures } from './session-scanner-document-agent-fixtures'
import { writeLogAgentFixtures } from './session-scanner-log-agent-fixtures'
// Why this is shared rather than inline in one test: it is the only place that
// writes one transcript in every supported agent's own format. A scan test and
// the search index's capture guard both need exactly that, and a second copy
// would drift the moment an agent's layout changed.
export type EveryAgentVault = {
roots: ReturnType<typeof isolatedScanRoots>
/** Ids the caller asserts resume commands against. */
antigravitySessionId: string
/** OMP and Prime Agent resume by absolute transcript path, not by id. */
ompSessionFile: string
primeAgentSessionFile: string
}
/**
* Write one session per supported agent under `root`, each in that agent's own
* on-disk layout. OpenCode gets its legacy JSON layout here; its SQLite layout
* has its own builder, because it needs a database rather than a tree.
* @param root - An empty temporary directory to build the vault in.
* @returns The scan roots for `root`, and the ids a caller asserts against.
*/
export async function writeEveryAgentVault(root: string): Promise<EveryAgentVault> {
const roots = isolatedScanRoots(root)
const antigravitySessionId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'
const { ompSessionFile, primeAgentSessionFile } = await writeLogAgentFixtures(roots)
await writeDocumentAgentFixtures(root, roots, antigravitySessionId)
return { roots, antigravitySessionId, ompSessionFile, primeAgentSessionFile }
}
@@ -27,8 +27,12 @@ const ALLOWLIST = new Set([
// On-demand IPC readers, gated in the STA-4049 follow-up.
'session-scanner-claude-subagents.ts',
'session-scanner-omp-subagent-listing.ts',
// Test-only fixture builder.
'session-scanner-test-fixtures.ts'
// Test-only fixture builders: they create the vault a test reads, so the
// paths they touch are temp directories this process just made.
'session-scanner-test-fixtures.ts',
'session-scanner-document-agent-fixtures.ts',
'session-scanner-log-agent-fixtures.ts',
'session-scanner-opencode-sqlite-fixture.ts'
])
const FS_IMPORT = /import\s+([\s\S]*?)\s+from\s+['"]node:fs(?:\/promises)?['"]/g
@@ -0,0 +1,160 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import {
writeOmpScannerFixture,
writePrimeAgentScannerFixture
} from './session-scanner-test-fixtures'
import { jsonlBody, type AgentVaultRoots } from './session-scanner-vault-roots'
// The agents whose session is an append-only JSONL log the CLI writes a record
// at a time. Split from the document-shaped agents purely by file size; the two
// halves are called together and neither is meaningful alone.
/**
* Write one append-only transcript per log-shaped agent.
* @param roots - The scan roots to write under.
* @returns The transcript paths OMP and Prime Agent resume by.
*/
export async function writeLogAgentFixtures(
roots: AgentVaultRoots
): Promise<{ ompSessionFile: string; primeAgentSessionFile: string }> {
await mkdir(join(roots.claudeProjectsDir, 'project'), { recursive: true })
await writeFile(
join(roots.claudeProjectsDir, 'project', 'claude-session.jsonl'),
jsonlBody([
{
type: 'user',
sessionId: 'claude-session',
timestamp: '2026-05-01T10:00:00.000Z',
cwd: '/tmp/claude',
message: { role: 'user', content: 'Claude title' }
}
])
)
await mkdir(join(roots.codexSessionsDir, '2026', '05', '01'), { recursive: true })
await writeFile(
join(roots.codexSessionsDir, '2026', '05', '01', 'rollout-2026-codex-session.jsonl'),
jsonlBody([
{
timestamp: '2026-05-01T10:01:00.000Z',
type: 'session_meta',
payload: { id: 'codex-session', cwd: '/tmp/codex' }
},
{
timestamp: '2026-05-01T10:01:01.000Z',
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'text', text: 'Codex title' }]
}
}
])
)
await mkdir(roots.copilotSessionsDir, { recursive: true })
await writeFile(
join(roots.copilotSessionsDir, 'copilot-session.jsonl'),
jsonlBody([
{
type: 'session.start',
data: { sessionId: 'copilot-session', startTime: '2026-05-01T10:03:00.000Z' },
timestamp: '2026-05-01T10:03:00.000Z'
},
{
type: 'session.info',
data: {
infoType: 'folder_trust',
message: 'Folder /tmp/copilot has been added to trusted folders.'
},
timestamp: '2026-05-01T10:03:01.000Z'
},
{
type: 'user.message',
data: { transformedContent: 'Copilot title' },
timestamp: '2026-05-01T10:03:02.000Z'
}
])
)
await mkdir(join(roots.cursorProjectsDir, 'project', 'agent-transcripts'), { recursive: true })
await writeFile(
join(roots.cursorProjectsDir, 'project', 'agent-transcripts', 'cursor-session.jsonl'),
jsonlBody([
{
role: 'user',
message: { content: [{ type: 'text', text: 'Cursor title' }] }
},
{ role: 'assistant', message: { content: [{ type: 'text', text: 'Done' }] } }
])
)
await mkdir(join(roots.openclawStateDir, 'agents', 'default', 'sessions'), { recursive: true })
await writeFile(
join(roots.openclawStateDir, 'agents', 'default', 'sessions', 'openclaw-session.jsonl'),
jsonlBody([
{
type: 'session',
id: 'openclaw-session',
timestamp: '2026-05-01T10:07:00.000Z',
cwd: '/tmp/openclaw'
},
{
type: 'message',
timestamp: '2026-05-01T10:07:01.000Z',
message: { role: 'user', content: [{ type: 'text', text: 'OpenClaw title' }] }
}
])
)
await mkdir(roots.piSessionsDir, { recursive: true })
await writeFile(
join(roots.piSessionsDir, 'pi-session.jsonl'),
jsonlBody([
{
type: 'session',
id: 'pi-session',
timestamp: '2026-05-01T10:08:00.000Z',
cwd: '/tmp/pi'
},
{
type: 'message',
timestamp: '2026-05-01T10:08:01.000Z',
message: { role: 'user', content: [{ type: 'text', text: 'Pi title' }] }
}
])
)
const ompSessionFile = await writeOmpScannerFixture(roots.ompSessionsDir)
const primeAgentSessionFile = await writePrimeAgentScannerFixture(roots.primeAgentSessionsDir)
await mkdir(roots.droidSessionsDir, { recursive: true })
await writeFile(
join(roots.droidSessionsDir, 'droid-session.jsonl'),
jsonlBody([
{
type: 'system',
session_id: 'droid-session',
timestamp: '2026-05-01T10:09:00.000Z',
model: 'droid-model',
cwd: '/tmp/droid'
},
{
type: 'message',
session_id: 'droid-session',
timestamp: '2026-05-01T10:09:01.000Z',
role: 'user',
text: 'Droid title'
},
{
type: 'completion',
session_id: 'droid-session',
timestamp: '2026-05-01T10:09:02.000Z',
usage: { input_tokens: 2, output_tokens: 3 }
}
])
)
return { ompSessionFile, primeAgentSessionFile }
}
@@ -0,0 +1,270 @@
import type { AiVaultSession } from '../../shared/ai-vault-types'
import { timestampIso } from './session-scanner-accumulator'
import { asRecord } from './session-scanner-record-value'
import { extractPartText, readOpenCodeSqliteSession } from './session-scanner-opencode-sqlite'
import { readOpenCodeDatabase } from './session-scanner-opencode-sqlite-open'
import { canReadOpenCodeMessageParts } from './session-scanner-opencode-sqlite-schema'
import type { TranscriptMessage, TranscriptMessageRole } from './session-transcript-consumers'
import { boundedText, toolCallText } from './session-transcript-message-content'
import type SyncDatabase from '../sqlite/sync-database'
// Why: the session list needs the newest few messages, and the search index
// needs every one of them. That is the only difference between this read and
// `parseOpenCodeSqliteSession`, so the decoding is shared and only the query
// that selects the rows differs.
/** The part types that carry something a person would search for. */
const OPENCODE_CAPTURE_PART_TYPES = "('text','reasoning','tool')"
/**
* How many parts one session may hold before this read gives up.
*
* A safety valve on memory, not a policy: the rows are materialized and then
* posted across the worker boundary, so an unbounded session would be held
* twice. Exceeding it throws rather than returning a prefix, because a prefix
* committed under a complete-read cursor would leave the tail unsearchable with
* nothing on the row to say so. A failed read is retried and surfaces; a silent
* truncation does neither. Measured against a real 21 GB database: the busiest
* session there holds 1,427 of these parts.
*/
const OPENCODE_CAPTURE_PART_LIMIT = 20_000
/**
* How much decoded text one session may carry, for the same reason.
*
* Not a truncation policy and not a second cap on tool rows -- the index writer
* owns that, at 3 KB a row. This is the bound a non-streaming source needs and
* a streaming one does not: a JSONL provider publishes each message as it reads
* it, while this one holds the whole session before posting it. Measured on the
* same database, the largest session's parts total 9.5 MB, so this is ~7x the
* worst real one.
*/
const OPENCODE_CAPTURE_TEXT_LIMIT = 64 * 1024 * 1024
type CaptureRow = {
messageId: string
role: string | null
partType: string
partData: string
messageTimeMs: number
}
// A row this build cannot read is dropped rather than failing the session: the
// schema probe only proves the columns exist, not what any one row holds.
function toCaptureRow(value: unknown): CaptureRow | null {
const record = asRecord(value)
if (!record) {
return null
}
const {
message_id: messageId,
role,
part_type: partType,
part_data: partData,
message_time: messageTime
} = record
if (
typeof messageId !== 'string' ||
typeof partType !== 'string' ||
typeof partData !== 'string' ||
typeof messageTime !== 'number'
) {
return null
}
return {
messageId,
role: typeof role === 'string' ? role : null,
partType,
partData,
messageTimeMs: messageTime
}
}
/**
* One tool call as the text a consumer sees: what was run, then what came back.
*
* Both halves live on one `part` here, where a file provider writes a
* `tool_use` block and a matching `tool_result`, so this is one message where
* those are two. The wording of each half is the shared one on purpose: a
* search for a command should find it whichever agent ran it.
*/
function toolPartText(partData: string): string | null {
const part = asRecord(parseJson(partData))
if (!part) {
return null
}
const state = asRecord(part.state)
const lines = [toolCallText(part.tool, sharedToolInputSpelling(state?.input)), toolOutcome(state)]
const text = lines.filter((line) => line !== null).join('\n')
return text.trim() ? text : null
}
// `state.error` is set on a failed or cancelled call and `state.output` on a
// completed one; a call still running has neither, and its command line alone is
// worth indexing. Preferring the error matches what the session actually shows.
function toolOutcome(state: Record<string, unknown> | null): string | null {
const error = state?.error
if (typeof error === 'string' && error.trim()) {
return error
}
const output = state?.output
return typeof output === 'string' && output.trim() ? output : null
}
// OpenCode spells its file argument `filePath`; every other provider, and so the
// shared key list, spells it `file_path`. Renaming the one key here keeps a
// single list rather than teaching it one provider's casing.
function sharedToolInputSpelling(input: unknown): unknown {
const record = asRecord(input)
if (!record || typeof record.filePath !== 'string' || typeof record.file_path === 'string') {
return input
}
return { ...record, file_path: record.filePath }
}
function parseJson(value: string): unknown {
try {
return JSON.parse(value)
} catch {
return null
}
}
/** The session the panel renders, and every message the index folds. */
export type OpenCodeSqliteCapture = {
session: AiVaultSession | null
messages: TranscriptMessage[]
}
function captureRole(role: string | null): TranscriptMessageRole | null {
return role === 'user' || role === 'assistant' ? role : null
}
function buildCaptureQuery(): string {
// Message order, then part order within a message: the same key the preview
// read uses, run forwards and without the newest-N window.
return `SELECT m.id AS message_id,
json_extract(m.data, '$.role') AS role,
json_extract(p.data, '$.type') AS part_type,
p.data AS part_data,
m.time_created AS message_time
FROM message m
JOIN part p ON p.message_id = m.id
WHERE m.session_id = ?
AND json_extract(m.data, '$.role') IN ('user','assistant')
AND json_extract(p.data, '$.type') IN ${OPENCODE_CAPTURE_PART_TYPES}
ORDER BY m.time_created ASC, m.id ASC, p.time_created ASC, p.rowid ASC
LIMIT ?`
}
/**
* Decode one session's whole transcript.
*
* A turn's `text` and `reasoning` parts become one message, the way every other
* provider hands a consumer one message per turn, so a phrase that runs across
* two blocks of the same turn is still one indexable row. Reasoning folds into
* that text because the shared block list already treats a thinking block as the
* turn's own words. Each `tool` part is its own `tool` message, and they follow
* the turn's words in transcript order -- the ordering a content-block decode
* produces for every file provider.
*/
export function readOpenCodeSessionMessages(
db: SyncDatabase,
sessionId: string
): TranscriptMessage[] {
if (!canReadOpenCodeMessageParts(db)) {
// Thrown for the same reason the part limit below throws: an empty capture
// returned here is committed under a complete-read cursor, so the session
// stays out of search with nothing on its row to say why and no retry.
throw new Error(
`OpenCode session ${sessionId} uses an unreadable message-part schema; its transcript was not read.`
)
}
const rows = db.prepare(buildCaptureQuery()).all(sessionId, OPENCODE_CAPTURE_PART_LIMIT + 1)
if (rows.length > OPENCODE_CAPTURE_PART_LIMIT) {
throw new Error(
`OpenCode session ${sessionId} holds more than ${OPENCODE_CAPTURE_PART_LIMIT} text parts; its transcript was not read.`
)
}
const messages: TranscriptMessage[] = []
let captured = 0
let openMessageId: string | null = null
let openWords: string[] = []
let openTools: TranscriptMessage[] = []
let openRole: TranscriptMessageRole | null = null
let openTimestamp: string | null = null
const keep = (message: TranscriptMessage): void => {
captured += message.text.length
if (captured > OPENCODE_CAPTURE_TEXT_LIMIT) {
throw new Error(
`OpenCode session ${sessionId} decodes to more than ${OPENCODE_CAPTURE_TEXT_LIMIT} characters; its transcript was not read.`
)
}
messages.push(message)
}
// The turn's own words lead, its tool calls follow: the order
// `transcriptMessagesFromContent` produces for a file provider's blocks.
const flush = (): void => {
const text = openRole && openWords.length > 0 ? boundedText(openWords.join('\n')) : null
if (openRole && text) {
keep({ role: openRole, text, timestamp: openTimestamp })
}
for (const tool of openTools) {
keep(tool)
}
openWords = []
openTools = []
}
for (const value of rows) {
const row = toCaptureRow(value)
if (!row) {
continue
}
if (row.messageId !== openMessageId) {
flush()
openMessageId = row.messageId
openRole = captureRole(row.role)
openTimestamp = timestampIso(row.messageTimeMs)
}
if (row.partType === 'tool') {
const text = boundedText(toolPartText(row.partData) ?? '')
if (text) {
openTools.push({ role: 'tool', text, timestamp: openTimestamp })
}
continue
}
const text = extractPartText(row.partData)
if (text) {
openWords.push(text)
}
}
flush()
return messages
}
/**
* Read one OpenCode session and its whole transcript from a single open of the
* database, so the two can never describe different generations of the session.
* @param args.dbPath - Absolute path to the opencode.db file.
* @param args.sessionId - Primary key in the `session` table.
* @param args.platform - Platform used for resume-command generation.
* @returns The parsed session (null when it does not exist) and its messages.
*/
export async function captureOpenCodeSqliteSession(args: {
dbPath: string
sessionId: string
platform: NodeJS.Platform
}): Promise<OpenCodeSqliteCapture> {
return readOpenCodeDatabase({
dbPath: args.dbPath,
read: (db) => {
const session = readOpenCodeSqliteSession({ db, ...args })
// No session row is no transcript: the id names nothing in this database.
return { session, messages: session ? readOpenCodeSessionMessages(db, args.sessionId) : [] }
}
})
}
@@ -0,0 +1,226 @@
import { mkdirSync } from 'node:fs'
import { dirname } from 'node:path'
import SyncDatabase from '../sqlite/sync-database'
// The OpenCode 1.17.x schema, as the app itself creates it. Written out in full
// rather than trimmed to the columns a reader names, because every read probes
// for its columns and a trimmed fixture would pass a probe the real database
// fails (or the reverse) without the test being able to tell.
const OPENCODE_SCHEMA = `
CREATE TABLE session (
id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT, slug TEXT NOT NULL,
directory TEXT NOT NULL, title TEXT NOT NULL, version TEXT NOT NULL, share_url TEXT,
summary_additions INTEGER, summary_deletions INTEGER, summary_files INTEGER,
summary_diffs TEXT, revert TEXT, permission TEXT,
time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, time_compacting INTEGER,
time_archived INTEGER, workspace_id TEXT, path TEXT, agent TEXT, model TEXT,
cost REAL DEFAULT 0 NOT NULL, tokens_input INTEGER DEFAULT 0 NOT NULL,
tokens_output INTEGER DEFAULT 0 NOT NULL, tokens_reasoning INTEGER DEFAULT 0 NOT NULL,
tokens_cache_read INTEGER DEFAULT 0 NOT NULL, tokens_cache_write INTEGER DEFAULT 0 NOT NULL,
metadata TEXT
);
CREATE TABLE message (
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL, data TEXT NOT NULL
);
CREATE TABLE project (
id TEXT PRIMARY KEY, worktree TEXT NOT NULL, vcs TEXT, name TEXT, icon_url TEXT,
icon_color TEXT, time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL,
time_initialized INTEGER, sandboxes TEXT NOT NULL, commands TEXT, icon_url_override TEXT
);
CREATE TABLE part (
id TEXT PRIMARY KEY, message_id TEXT NOT NULL, session_id TEXT NOT NULL,
time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, data TEXT NOT NULL
);
`
export const OPENCODE_FIXTURE_EPOCH_MS = 1_740_000_000_000
/**
* One `part` row. A bare string is a text part, which is what most turns are.
*
* The tool shape mirrors what OpenCode actually writes: the call's name and id
* at the top level, and everything about the run nested under `state`.
*/
export type OpenCodeSqliteFixturePart =
| string
| { type: 'text' | 'reasoning'; text: string }
| {
type: 'tool'
tool: string
input?: Record<string, unknown>
output?: string
error?: string
}
export type OpenCodeSqliteFixtureTurn = {
role: 'user' | 'assistant'
/** One part row per entry, in the order the session recorded them. */
parts: readonly OpenCodeSqliteFixturePart[]
}
export type OpenCodeSqliteFixtureSession = {
id: string
title?: string
directory?: string
turns: readonly OpenCodeSqliteFixtureTurn[]
}
/**
* Create an OpenCode SQLite database holding `sessions`.
*
* Each turn's parts are written as separate `part` rows, which is the shape a
* reader has to reassemble; a fixture with one part per turn would never
* exercise it. A session's `time_updated` is its last turn's timestamp, the
* same stat the real database moves when a session gains a message.
* @param dbPath - Where to create the database; parent directories are created.
* @param sessions - The sessions to write, in the order they were created.
*/
export function writeOpenCodeSqliteDatabase(
dbPath: string,
sessions: readonly OpenCodeSqliteFixtureSession[]
): void {
mkdirSync(dirname(dbPath), { recursive: true })
const db = new SyncDatabase(dbPath)
try {
if (!tableAlreadyThere(db)) {
db.exec(OPENCODE_SCHEMA)
db.prepare(
`INSERT INTO project (id, worktree, name, time_created, time_updated, sandboxes)
VALUES ('proj-1', '/tmp/opencode', 'proj', ?, ?, '[]')`
).run(OPENCODE_FIXTURE_EPOCH_MS, OPENCODE_FIXTURE_EPOCH_MS)
}
for (const session of sessions) {
writeSession(db, session)
}
} finally {
db.close()
}
}
function tableAlreadyThere(db: SyncDatabase): boolean {
return (
db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='session'`).get() !==
undefined
)
}
function writeSession(db: SyncDatabase, session: OpenCodeSqliteFixtureSession): void {
const created = OPENCODE_FIXTURE_EPOCH_MS
const updated = created + Math.max(1, session.turns.length) * 60_000
db.prepare(
`INSERT INTO session (id, project_id, parent_id, slug, directory, title, version,
time_created, time_updated, agent, model, cost, tokens_input, tokens_output,
tokens_reasoning, tokens_cache_read, tokens_cache_write)
VALUES (?, 'proj-1', NULL, 'slug-1', ?, ?, '1.0.0', ?, ?, 'build', '{"id":"glm"}',
0, 1, 1, 0, 0, 0)
ON CONFLICT(id) DO UPDATE SET time_updated = excluded.time_updated`
).run(
session.id,
session.directory ?? '/tmp/opencode',
session.title ?? 'OpenCode title',
created,
updated
)
appendTurns(db, session, created)
}
/** Appends `turns` after whatever the session already holds. */
export function appendTurns(
db: SyncDatabase,
session: OpenCodeSqliteFixtureSession,
startMs: number
): void {
const insertMessage = db.prepare(
`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)`
)
const insertPart = db.prepare(
`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data)
VALUES (?, ?, ?, ?, ?, ?)`
)
session.turns.forEach((turn, turnIndex) => {
const at = startMs + (turnIndex + 1) * 60_000
const messageId = `${session.id}-msg-${turnIndex}-${at}`
insertMessage.run(
messageId,
session.id,
at,
at,
JSON.stringify({ role: turn.role, time: { created: at } })
)
turn.parts.forEach((part, partIndex) => {
insertPart.run(
`${messageId}-part-${partIndex}`,
messageId,
session.id,
at + partIndex,
at + partIndex,
JSON.stringify(partData(part, `${messageId}-call-${partIndex}`, at))
)
})
})
}
function partData(
part: OpenCodeSqliteFixturePart,
callId: string,
atMs: number
): Record<string, unknown> {
if (typeof part === 'string') {
return { type: 'text', text: part }
}
if (part.type !== 'tool') {
return { type: part.type, text: part.text }
}
const failed = typeof part.error === 'string'
return {
type: 'tool',
tool: part.tool,
callID: callId,
state: {
status: failed ? 'error' : 'completed',
input: part.input ?? {},
...(failed ? { error: part.error } : { output: part.output ?? '' }),
title: part.tool,
time: { start: atMs, end: atMs + 1 }
}
}
}
/**
* Add one turn to an existing session and move its `time_updated`, the way
* OpenCode does when a session continues.
* @param dbPath - The fixture database to append to.
* @param sessionId - The session to continue.
* @param turn - The turn to append.
*/
export function appendOpenCodeSqliteTurn(
dbPath: string,
sessionId: string,
turn: OpenCodeSqliteFixtureTurn
): void {
const db = new SyncDatabase(dbPath)
try {
const updated = currentUpdatedMs(db, sessionId)
appendTurns(db, { id: sessionId, turns: [turn] }, updated)
db.prepare('UPDATE session SET time_updated = ? WHERE id = ?').run(updated + 60_000, sessionId)
} finally {
db.close()
}
}
// Throws rather than falling back to the epoch: a mistyped id would otherwise
// append orphan rows and update nothing, leaving a test asserting over a
// transcript that no session owns.
function currentUpdatedMs(db: SyncDatabase, sessionId: string): number {
const row = db.prepare('SELECT time_updated FROM session WHERE id = ?').get(sessionId)
if (row === undefined) {
throw new Error(`OpenCode fixture has no session ${sessionId} to append to`)
}
const updated = Object.values(row)[0]
if (typeof updated !== 'number') {
throw new Error(`OpenCode fixture session ${sessionId} has no numeric time_updated`)
}
return updated
}
@@ -250,14 +250,13 @@ describe('openCodeBusyTimeoutMs', () => {
describe('openCodeDatabaseScanIssue', () => {
const cantOpen = Object.assign(new Error('unable to open database file'), { errcode: 14 })
it('names the wal-index over a WSL share rather than repeating the driver string', () => {
const issue = openCodeDatabaseScanIssue(
'\\\\wsl.localhost\\Ubuntu\\home\\ada\\.local\\share\\opencode\\opencode.db',
cantOpen
)
it('states the WSL share as a known limitation rather than an error to act on', () => {
const dbPath = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.local\\share\\opencode\\opencode.db'
const issue = openCodeDatabaseScanIssue(dbPath, cantOpen)
expect(issue.kind).toBe('scope')
expect(issue.message).toContain('\\\\wsl.localhost')
expect(issue.path).toBe(dbPath)
expect(issue.message).toBe("OpenCode sessions inside WSL can't be searched from Windows yet.")
// Checkpointing cannot fix a share that refuses SQLite's locks, so the copy
// must not send the user after the write-ahead log.
expect(issue.message).not.toContain('write-ahead log')
@@ -282,7 +281,7 @@ describe('openCodeDatabaseScanIssue', () => {
)
expect(issue.message).not.toContain('is writing to')
expect(issue.message).toContain('inside the distro')
expect(issue.message).toBe("OpenCode sessions inside WSL can't be searched from Windows yet.")
})
it('still blames a live writer for the same error on a local path', () => {
@@ -99,16 +99,15 @@ export function openCodeDatabaseScanIssue(dbPath: string, error: unknown): AiVau
? `OpenCode is writing to ${name} right now, so its history was skipped. It is read again on the next refresh.`
: kind === 'unreadable'
? `OpenCode history in ${name} could not be read: ${errorMessage(error)}`
: `OpenCode history in ${name} could not be read. ${unreadableShareAdvice(dbPath)}`
: unreadableShareDetail(dbPath, name)
return { agent: 'opencode', kind: 'scope', path: dbPath, message: detail }
}
function unreadableShareAdvice(dbPath: string): string {
// Named only when the evidence supports it; a generic share gets generic copy.
// Deliberately not "flush the write-ahead log": checkpointing changes nothing
// here, and telling the user to try it would send them after a fix that cannot
// work. The share itself is the blocker.
function unreadableShareDetail(dbPath: string, name: string): string {
// A known limitation, not a failure the user can act on: nothing they do on
// the Windows side makes the share hand out SQLite's locks. Deliberately not
// "flush the write-ahead log" either — checkpointing changes nothing here.
return isWslUncPath(dbPath)
? 'Windows cannot open SQLite databases over the \\\\wsl.localhost share, so this history has to be read from inside the distro.'
: 'Its write-ahead log cannot be opened read-only on this filesystem. Exit OpenCode cleanly to flush the log.'
? "OpenCode sessions inside WSL can't be searched from Windows yet."
: `OpenCode history in ${name} could not be read. Its write-ahead log cannot be opened read-only on this filesystem. Exit OpenCode cleanly to flush the log.`
}
@@ -1,12 +1,15 @@
import { LazyWorkerThreadHost, type WorkerThreadFactory } from '../lazy-worker-thread-host'
import type { AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types'
import type {
OpenCodeSqliteCaptureRequest,
OpenCodeSqliteCaptureValue,
OpenCodeSqliteListRequest,
OpenCodeSqliteListValue,
OpenCodeSqliteParseRequest,
OpenCodeSqliteWorkerRequest,
OpenCodeSqliteWorkerResponse
} from './session-scanner-opencode-sqlite-worker-protocol'
import { parseOpenCodeSqliteCaptureValue } from './session-scanner-opencode-sqlite-worker-response'
import type { SessionFileCandidate } from './session-scanner-types'
import { errorMessage } from './session-scanner-values'
@@ -19,6 +22,9 @@ import { errorMessage } from './session-scanner-values'
export const LIST_TIMEOUT_MS = 30_000
export const PARSE_TIMEOUT_MS = 15_000
// Longer than a parse because it reads every part of the session rather than
// the newest window, and shorter than nothing at all because the queue is FIFO.
export const CAPTURE_TIMEOUT_MS = 30_000
export const IDLE_TEARDOWN_MS = 30_000
// After this many consecutive worker deaths, fail the remaining queued calls to
// scan issues instead of respawning so a DB that reliably kills the worker can't
@@ -31,6 +37,7 @@ export const MAX_CONSECUTIVE_DEATHS = 3
type OpenCodeSqliteRequestBody =
| Omit<OpenCodeSqliteListRequest, 'id'>
| Omit<OpenCodeSqliteParseRequest, 'id'>
| Omit<OpenCodeSqliteCaptureRequest, 'id'>
type PendingCall = {
request: OpenCodeSqliteWorkerRequest
@@ -44,6 +51,15 @@ type PendingCall = {
// can surface a precise issue while keeping synchronous SQLite off the main thread.
class OpenCodeSqliteWorkerUnavailableError extends Error {}
// One session failed, not the whole source: the scanner turns this throw into a
// per-session scan issue and the search index records a failed read.
function sessionReadFailure(err: unknown): Error {
if (err instanceof OpenCodeSqliteWorkerUnavailableError) {
return new Error('OpenCode SQLite background scanner could not start.')
}
return err instanceof Error ? err : new Error(String(err))
}
/**
* Main-thread bridge that runs OpenCode SQLite reads on a persistent worker
* thread. Dispatches one request at a time (FIFO), times each request out from
@@ -140,13 +156,43 @@ export class OpenCodeSqliteWorkerClient {
{ kind: 'parse', dbPath: args.dbPath, sessionId: args.sessionId, platform: args.platform },
PARSE_TIMEOUT_MS
)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the worker's parse leg returns exactly this, built by the repo's own reader on the other side of a structured clone.
return value as AiVaultSession | null
} catch (err) {
if (err instanceof OpenCodeSqliteWorkerUnavailableError) {
throw new Error('OpenCode SQLite background scanner could not start.')
}
// Reject only this session; the scanner turns the throw into a scan issue.
throw err instanceof Error ? err : new Error(String(err))
throw sessionReadFailure(err)
}
}
/**
* Read one OpenCode session and its whole transcript on the worker.
*
* One request rather than a parse plus a second read: both halves then come
* from a single open of the database, so the messages the index folds cannot
* belong to a different generation of the session than the panel shows.
* @param args.dbPath - Absolute path to the opencode.db file.
* @param args.sessionId - Primary key in the `session` table.
* @param args.platform - Platform used for resume-command generation.
* @returns The session (null when it does not exist) and its messages;
* rejects on worker timeout/crash so the read is recorded as failed.
*/
async capture(args: {
dbPath: string
sessionId: string
platform: NodeJS.Platform
}): Promise<OpenCodeSqliteCaptureValue> {
try {
const value = await this.dispatch(
{
kind: 'capture',
dbPath: args.dbPath,
sessionId: args.sessionId,
platform: args.platform
},
CAPTURE_TIMEOUT_MS
)
return parseOpenCodeSqliteCaptureValue(value)
} catch (err) {
throw sessionReadFailure(err)
}
}
@@ -1,5 +1,6 @@
import { parentPort } from 'node:worker_threads'
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
import { captureOpenCodeSqliteSession } from './session-scanner-opencode-sqlite-capture'
import { listOpenCodeSqliteSessions } from './session-scanner-opencode-sqlite-list'
import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite'
import type {
@@ -30,6 +31,14 @@ async function handleRequest(
})
return { id: request.id, ok: true, value: { candidates, issues } }
}
if (request.kind === 'capture') {
const capture = await captureOpenCodeSqliteSession({
dbPath: request.dbPath,
sessionId: request.sessionId,
platform: request.platform
})
return { id: request.id, ok: true, value: capture }
}
const session = await parseOpenCodeSqliteSession({
dbPath: request.dbPath,
sessionId: request.sessionId,
@@ -1,5 +1,6 @@
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
import type { AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types'
import type { SessionFileCandidate } from './session-scanner-types'
import type { TranscriptMessage } from './session-transcript-consumers'
// Why: request/response shapes shared by the worker entry and the main-thread
// client. Kept type-only (and electron-free) so importing it into the worker
@@ -20,7 +21,21 @@ export type OpenCodeSqliteParseRequest = {
platform: NodeJS.Platform
}
export type OpenCodeSqliteWorkerRequest = OpenCodeSqliteListRequest | OpenCodeSqliteParseRequest
// Same arguments as `parse`, different answer: the session plus every message
// the session holds. Its own kind rather than a flag on `parse` so the two
// response shapes stay distinguishable at the type level on both sides.
export type OpenCodeSqliteCaptureRequest = {
id: number
kind: 'capture'
dbPath: string
sessionId: string
platform: NodeJS.Platform
}
export type OpenCodeSqliteWorkerRequest =
| OpenCodeSqliteListRequest
| OpenCodeSqliteParseRequest
| OpenCodeSqliteCaptureRequest
// The list leg returns candidates plus the issues it accumulated; the worker
// mutates a local array and hands it back so the caller can merge it into the
@@ -30,6 +45,14 @@ export type OpenCodeSqliteListValue = {
issues: AiVaultScanIssue[]
}
// The session the panel shows, and the transcript the search index folds. Both
// come from one open of the database, so the two can never disagree about which
// generation of the session they describe.
export type OpenCodeSqliteCaptureValue = {
session: AiVaultSession | null
messages: TranscriptMessage[]
}
export type OpenCodeSqliteWorkerResponse =
| { id: number; ok: true; value: unknown }
| { id: number; ok: false; error: string }
@@ -0,0 +1,41 @@
import type { AiVaultSession } from '../../shared/ai-vault-types'
import { asRecord } from './session-scanner-record-value'
import type { OpenCodeSqliteCaptureValue } from './session-scanner-opencode-sqlite-worker-protocol'
import type { TranscriptMessage } from './session-transcript-consumers'
// Why: a worker posts back a structured clone, which arrives as `unknown`. The
// messages are checked one by one because they are written into the index as
// rows keyed by role, and a value with no role at all would land under none.
function isTranscriptMessage(value: unknown): value is TranscriptMessage {
const record = asRecord(value)
return (
record !== null &&
(record.role === 'user' || record.role === 'assistant' || record.role === 'tool') &&
typeof record.text === 'string' &&
(record.timestamp === null || typeof record.timestamp === 'string')
)
}
/**
* Read a `capture` response from the OpenCode SQLite worker.
*
* A message that is not one is dropped rather than failing the read: the rest
* of the session is still worth indexing, and a row with a role the index has
* no column for would be written under an empty one.
* @param value - The worker's response value.
* @returns The session and the messages the response carried.
*/
export function parseOpenCodeSqliteCaptureValue(value: unknown): OpenCodeSqliteCaptureValue {
const record = asRecord(value)
if (!record) {
return { session: null, messages: [] }
}
const messages = Array.isArray(record.messages) ? record.messages.filter(isTranscriptMessage) : []
// Held to the same standard as the parse leg rather than validated harder: a
// session this build dropped here but kept there would be in the panel and
// absent from the index, which is worse than trusting our own worker.
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the worker builds this with the repo's own reader; only the structured clone sits between.
const session = (record.session ?? null) as AiVaultSession | null
return { session, messages }
}
@@ -3,6 +3,7 @@ import { join } from 'node:path'
import { Worker } from 'node:worker_threads'
import type { AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types'
import type { SessionFileCandidate } from './session-scanner-types'
import type { OpenCodeSqliteCaptureValue } from './session-scanner-opencode-sqlite-worker-protocol'
import { OpenCodeSqliteWorkerClient } from './session-scanner-opencode-sqlite-worker-client'
// Why: resolve the built worker entry + own the process-wide shared client so
@@ -70,3 +71,19 @@ export function parseOpenCodeSqliteSessionViaWorker(args: {
}): Promise<AiVaultSession | null> {
return getSharedClient().parse(args)
}
/**
* Read one OpenCode SQLite session and its whole transcript through the shared
* worker client.
* @param args.dbPath - Absolute path to the opencode.db file.
* @param args.sessionId - Primary key in the `session` table.
* @param args.platform - Platform used for resume-command generation.
* @returns The session and every message it holds.
*/
export function captureOpenCodeSqliteSessionViaWorker(args: {
dbPath: string
sessionId: string
platform: NodeJS.Platform
}): Promise<OpenCodeSqliteCaptureValue> {
return getSharedClient().capture(args)
}
@@ -6,6 +6,7 @@ import Database from '../sqlite/sync-database'
import { buildOpenCodeSqliteCandidatePath } from './session-scanner-opencode-sqlite-paths'
import { listOpenCodeSqliteSessions } from './session-scanner-opencode-sqlite-discovery'
import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite'
import { captureOpenCodeSqliteSession } from './session-scanner-opencode-sqlite-capture'
import { withFullFirstUserPromptCapture } from './session-scanner-first-user-prompt-capture'
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
@@ -479,6 +480,20 @@ describe('parseOpenCodeSqliteSession', () => {
expect(session!.previewMessages).toEqual([])
})
// The preview may degrade to nothing, but the search index may not: an empty
// capture is committed under a complete-read cursor, so the session would stay
// unsearchable with nothing on its row to say why and no retry.
it('refuses to capture a transcript it cannot read the message parts of', async () => {
const { db, path } = createTempDb()
applyMinimalOpenCodeSchema(db)
db.prepare(`INSERT INTO session VALUES ('ses_minimal', 1777634000000, 1777634001000)`).run()
db.close()
await expect(
captureOpenCodeSqliteSession({ dbPath: path, sessionId: 'ses_minimal', platform: 'darwin' })
).rejects.toThrow(/unreadable message-part schema/)
})
it('extracts model from older modelID schema', async () => {
const { db, path } = createTempDb()
applyOpenCodeSchema(db)
@@ -130,7 +130,8 @@ function mapPreviewRole(role: string | null): AiVaultSessionPreviewMessage['role
return 'unknown'
}
function extractPartText(partData: string): string | null {
/** The text a `type: 'text'` part carries; null for every other part shape. */
export function extractPartText(partData: string): string | null {
try {
const parsed = JSON.parse(partData) as unknown
const record =
@@ -233,12 +234,13 @@ export async function parseOpenCodeSqliteSession(args: {
}): Promise<AiVaultSession | null> {
return readOpenCodeDatabase({
dbPath: args.dbPath,
read: (db) => readSession({ db, ...args })
read: (db) => readOpenCodeSqliteSession({ db, ...args })
})
}
// Extracted so the open wrapper owns the handle's lifetime.
function readSession(args: {
// Exported so a capture read can take the session and its whole transcript from
// one open of the database rather than opening it twice.
export function readOpenCodeSqliteSession(args: {
db: SyncDatabase
dbPath: string
sessionId: string
@@ -33,9 +33,12 @@ export function jsonLines(records: unknown[]): string {
return records.map((record) => JSON.stringify(record)).join('\n')
}
// Newline-terminated, the way an agent writes each record: a file whose last
// line has no break is a transcript mid-write, and the reader deliberately
// withholds that line from consumers until it is complete.
export async function writeJsonlFile(filePath: string, records: unknown[]): Promise<void> {
await mkdir(dirname(filePath), { recursive: true })
await writeFile(filePath, jsonLines(records))
await writeFile(filePath, `${jsonLines(records)}\n`)
}
export async function writeAntigravityTranscript(
@@ -0,0 +1,11 @@
import { jsonLines, type isolatedScanRoots } from './session-scanner-test-fixtures'
// Shared by the two halves of the every-agent vault, which are split only
// because one file of every agent's layout is past the line ceiling.
export type AgentVaultRoots = ReturnType<typeof isolatedScanRoots>
/** Records as a file body: newline-terminated, the way an agent writes them. */
export function jsonlBody(records: unknown[]): string {
return `${jsonLines(records)}\n`
}
+4 -349
View File
@@ -4,13 +4,8 @@ import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types'
import { scanAiVaultSessions } from './session-scanner'
import {
isolatedScanRoots,
jsonLines,
writeAntigravityScannerFixture,
writeOmpScannerFixture,
writePrimeAgentScannerFixture
} from './session-scanner-test-fixtures'
import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures'
import { writeEveryAgentVault } from './session-scanner-every-agent-fixture'
let tempRoots: string[] = []
@@ -373,348 +368,8 @@ describe('scanAiVaultSessions', () => {
it('indexes every supported agent transcript format with native resume commands', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-all-agents-'))
tempRoots.push(root)
const roots = isolatedScanRoots(root)
await mkdir(join(roots.claudeProjectsDir, 'project'), { recursive: true })
await writeFile(
join(roots.claudeProjectsDir, 'project', 'claude-session.jsonl'),
jsonLines([
{
type: 'user',
sessionId: 'claude-session',
timestamp: '2026-05-01T10:00:00.000Z',
cwd: '/tmp/claude',
message: { role: 'user', content: 'Claude title' }
}
])
)
await mkdir(join(roots.codexSessionsDir, '2026', '05', '01'), { recursive: true })
await writeFile(
join(roots.codexSessionsDir, '2026', '05', '01', 'rollout-2026-codex-session.jsonl'),
jsonLines([
{
timestamp: '2026-05-01T10:01:00.000Z',
type: 'session_meta',
payload: { id: 'codex-session', cwd: '/tmp/codex' }
},
{
timestamp: '2026-05-01T10:01:01.000Z',
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'text', text: 'Codex title' }]
}
}
])
)
await mkdir(roots.geminiSessionsDir, { recursive: true })
await writeFile(
join(roots.geminiSessionsDir, 'gemini-session.json'),
JSON.stringify({
sessionId: 'gemini-session',
startTime: '2026-05-01T10:02:00.000Z',
lastUpdated: '2026-05-01T10:02:01.000Z',
messages: [
{
type: 'user',
timestamp: '2026-05-01T10:02:00.000Z',
content: [{ text: 'Gemini title' }]
},
{
type: 'gemini',
timestamp: '2026-05-01T10:02:01.000Z',
model: 'gemini-2.5-pro',
tokens: { input: 10, output: 5 }
}
]
})
)
const antigravitySessionId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'
await writeAntigravityScannerFixture(roots.antigravityBrainDir, antigravitySessionId)
await mkdir(roots.copilotSessionsDir, { recursive: true })
await writeFile(
join(roots.copilotSessionsDir, 'copilot-session.jsonl'),
jsonLines([
{
type: 'session.start',
data: { sessionId: 'copilot-session', startTime: '2026-05-01T10:03:00.000Z' },
timestamp: '2026-05-01T10:03:00.000Z'
},
{
type: 'session.info',
data: {
infoType: 'folder_trust',
message: 'Folder /tmp/copilot has been added to trusted folders.'
},
timestamp: '2026-05-01T10:03:01.000Z'
},
{
type: 'user.message',
data: { transformedContent: 'Copilot title' },
timestamp: '2026-05-01T10:03:02.000Z'
}
])
)
await mkdir(join(roots.cursorProjectsDir, 'project', 'agent-transcripts'), { recursive: true })
await writeFile(
join(roots.cursorProjectsDir, 'project', 'agent-transcripts', 'cursor-session.jsonl'),
jsonLines([
{
role: 'user',
message: { content: [{ type: 'text', text: 'Cursor title' }] }
},
{ role: 'assistant', message: { content: [{ type: 'text', text: 'Done' }] } }
])
)
await mkdir(join(roots.opencodeStorageDir, 'session', 'project'), { recursive: true })
await mkdir(join(roots.opencodeStorageDir, 'message', 'opencode-session'), { recursive: true })
await writeFile(
join(roots.opencodeStorageDir, 'session', 'project', 'ses_opencode.json'),
JSON.stringify({
id: 'opencode-session',
directory: '/tmp/opencode',
title: 'OpenCode title',
time: { created: 1_777_634_000_000, updated: 1_777_634_001_000 }
})
)
await writeFile(
join(roots.opencodeStorageDir, 'message', 'opencode-session', 'msg_1.json'),
JSON.stringify({
role: 'user',
summary: { title: 'OpenCode title' },
time: { created: 1_777_634_000_000 },
tokens: { input: 7, output: 3 }
})
)
await mkdir(join(roots.grokSessionsDir, encodeURIComponent('/tmp/grok'), 'grok-session'), {
recursive: true
})
await writeFile(
join(roots.grokSessionsDir, encodeURIComponent('/tmp/grok'), 'grok-session', 'summary.json'),
JSON.stringify({
info: { id: 'grok-session', cwd: '/tmp/grok' },
session_summary: '',
created_at: '2026-05-01T10:04:00.000Z',
updated_at: '2026-05-01T10:04:01.000Z',
num_chat_messages: 2,
current_model_id: 'grok-build',
head_branch: 'feature/grok-vault'
})
)
await writeFile(
join(
roots.grokSessionsDir,
encodeURIComponent('/tmp/grok'),
'grok-session',
'chat_history.jsonl'
),
jsonLines([
{
type: 'user',
content: [
{
type: 'text',
text: '<user_info>context</user_info><user_query>Grok title</user_query>'
}
]
},
{ type: 'assistant', content: 'Done' }
])
)
await mkdir(roots.hermesSessionsDir, { recursive: true })
await writeFile(
join(roots.hermesSessionsDir, 'session_hermes-session.json'),
JSON.stringify({
session_id: 'hermes-session',
model: 'hermes-1',
cwd: '/tmp/hermes',
session_start: '2026-05-01T10:05:00.000Z',
last_updated: '2026-05-01T10:05:01.000Z',
messages: [{ role: 'user', content: 'Hermes title' }]
})
)
await mkdir(join(roots.rovoSessionsDir, 'rovo-session'), { recursive: true })
await writeFile(
join(roots.rovoSessionsDir, 'rovo-session', 'metadata.json'),
JSON.stringify({ title: 'Rovo title', workspace_path: '/tmp/rovo' })
)
await writeFile(
join(roots.rovoSessionsDir, 'rovo-session', 'session_context.json'),
JSON.stringify({
message_history: [
{
kind: 'request',
timestamp: '2026-05-01T10:06:00.000Z',
parts: [{ part_kind: 'user-prompt', content: 'Rovo title' }]
}
]
})
)
await mkdir(join(roots.openclawStateDir, 'agents', 'default', 'sessions'), { recursive: true })
await writeFile(
join(roots.openclawStateDir, 'agents', 'default', 'sessions', 'openclaw-session.jsonl'),
jsonLines([
{
type: 'session',
id: 'openclaw-session',
timestamp: '2026-05-01T10:07:00.000Z',
cwd: '/tmp/openclaw'
},
{
type: 'message',
timestamp: '2026-05-01T10:07:01.000Z',
message: { role: 'user', content: [{ type: 'text', text: 'OpenClaw title' }] }
}
])
)
await mkdir(roots.piSessionsDir, { recursive: true })
await writeFile(
join(roots.piSessionsDir, 'pi-session.jsonl'),
jsonLines([
{
type: 'session',
id: 'pi-session',
timestamp: '2026-05-01T10:08:00.000Z',
cwd: '/tmp/pi'
},
{
type: 'message',
timestamp: '2026-05-01T10:08:01.000Z',
message: { role: 'user', content: [{ type: 'text', text: 'Pi title' }] }
}
])
)
const ompSessionFile = await writeOmpScannerFixture(roots.ompSessionsDir)
const primeAgentSessionFile = await writePrimeAgentScannerFixture(roots.primeAgentSessionsDir)
await mkdir(roots.devinTranscriptsDir, { recursive: true })
await writeFile(
join(roots.devinTranscriptsDir, 'devin-session.json'),
JSON.stringify({
session_id: 'devin-session',
working_directory: '/tmp/devin',
agent: { model_name: 'swe-1-6-fast' },
steps: [
{
metadata: {
created_at: '2026-05-01T10:10:00.000Z',
is_user_input: true,
metrics: { input_tokens: 1, output_tokens: 2 }
},
text: 'Devin vault title'
}
]
})
)
await mkdir(roots.droidSessionsDir, { recursive: true })
await writeFile(
join(roots.droidSessionsDir, 'droid-session.jsonl'),
jsonLines([
{
type: 'system',
session_id: 'droid-session',
timestamp: '2026-05-01T10:09:00.000Z',
model: 'droid-model',
cwd: '/tmp/droid'
},
{
type: 'message',
session_id: 'droid-session',
timestamp: '2026-05-01T10:09:01.000Z',
role: 'user',
text: 'Droid title'
},
{
type: 'completion',
session_id: 'droid-session',
timestamp: '2026-05-01T10:09:02.000Z',
usage: { input_tokens: 2, output_tokens: 3 }
}
])
)
const clineSessionId = 'cline-session'
const clineSessionDir = join(roots.clineSessionsDir, clineSessionId)
await mkdir(clineSessionDir, { recursive: true })
await writeFile(
join(clineSessionDir, `${clineSessionId}.json`),
JSON.stringify({
session_id: clineSessionId,
started_at: '2026-05-01T10:10:30.000Z',
model: 'cline-model',
cwd: '/tmp/cline'
})
)
await writeFile(
join(clineSessionDir, `${clineSessionId}.messages.json`),
JSON.stringify({
updated_at: '2026-05-01T10:10:31.000Z',
messages: [{ role: 'user', content: [{ type: 'text', text: 'Cline vault title' }] }]
})
)
// Kimi: <sessions>/wd_*/session_*/state.json + sibling agents/main/wire.jsonl,
// with the work dir resolved from the top-level session_index.jsonl.
const kimiSessionDir = join(roots.kimiSessionsDir, 'wd_app_abc', 'session_kimi-session')
await mkdir(join(kimiSessionDir, 'agents', 'main'), { recursive: true })
await writeFile(
join(kimiSessionDir, 'state.json'),
JSON.stringify({
createdAt: '2026-05-01T10:11:00.000Z',
updatedAt: '2026-05-01T10:11:05.000Z',
title: 'Kimi vault title',
lastPrompt: 'Kimi vault title',
agents: { main: { type: 'main', parentAgentId: null } }
})
)
await writeFile(
join(root, 'session_index.jsonl'),
jsonLines([
{ sessionId: 'session_kimi-session', sessionDir: kimiSessionDir, workDir: '/tmp/kimi' }
])
)
await writeFile(
join(kimiSessionDir, 'agents', 'main', 'wire.jsonl'),
jsonLines([
{ type: 'config.update', modelAlias: 'kimi-k2.6', time: 1781853559132 },
{
type: 'context.append_message',
message: {
role: 'user',
content: [{ type: 'text', text: 'Kimi vault title' }],
origin: { kind: 'user' }
},
time: 1781853559164
},
{
type: 'context.append_loop_event',
event: { type: 'content.part', part: { type: 'text', text: 'Kimi reply' } },
time: 1781853559177
},
{ type: 'context.append_loop_event', event: { type: 'step.end' }, time: 1781853559178 },
{
type: 'usage.record',
model: 'kimi-k2.6',
usage: { inputOther: 4, output: 6, inputCacheRead: 0, inputCacheCreation: 0 },
usageScope: 'turn',
time: 1781853559178
}
])
)
const { roots, antigravitySessionId, ompSessionFile, primeAgentSessionFile } =
await writeEveryAgentVault(root)
const result = await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 })
@@ -16,12 +16,18 @@ const OPENCODE_SQLITE_SESSION = {
agent: 'opencode' as const,
sessionId: 'sqlite-session'
}
const OPENCODE_SQLITE_MESSAGES = [
{ role: 'user' as const, text: 'ask sqlite', timestamp: null },
{ role: 'assistant' as const, text: 'reply sqlite', timestamp: null }
]
// Stands in for the worker thread: the point is that its messages never come
// back over the channel, not what the SQLite read returns.
// Stands in for the worker thread: the point is which leg the reader asks for
// and that what comes back reaches 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)
parseOpenCodeSqliteSessionViaWorker: () => Promise.resolve(OPENCODE_SQLITE_SESSION),
captureOpenCodeSqliteSessionViaWorker: () =>
Promise.resolve({ session: OPENCODE_SQLITE_SESSION, messages: OPENCODE_SQLITE_MESSAGES })
}))
import type * as OpenCodeSqliteWorkerSpawn from './session-scanner-opencode-sqlite-worker-spawn'
import {
@@ -304,7 +310,7 @@ it('serializes overlapping parses of one path so no consumer read is orphaned',
expect(second?.messageCount).toBe(10)
})
it('reports a read whose parser cannot publish its messages as not complete', async () => {
it('publishes an OpenCode SQLite session over the channel and reports it complete', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-transcript-opencode-'))
tempRoots.push(root)
const dbPath = join(root, 'opencode.db')
@@ -327,8 +333,9 @@ it('reports a read whose parser cannot publish its messages as not complete', as
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)
expect(consumer.reads[0].messages).toEqual(OPENCODE_SQLITE_MESSAGES)
expect(consumer.reads[0].outcome?.incomplete).toBe(false)
consumer.unregister()
})
it('reports the transcript size, not the cache key, as a whole-file read offset', async () => {
@@ -0,0 +1,165 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
// Only the thread hop is replaced: the implementations below are the repo's own
// in-process readers, which the worker entry calls on the other side.
vi.mock('./session-scanner-opencode-sqlite-worker-spawn', async () => {
const list = await import('./session-scanner-opencode-sqlite-list')
const parse = await import('./session-scanner-opencode-sqlite')
const capture = await import('./session-scanner-opencode-sqlite-capture')
return {
resolveOpenCodeSqliteWorkerEntryPath: () => null,
listOpenCodeSqliteSessionsViaWorker: (
args: Parameters<typeof list.listOpenCodeSqliteSessions>[0]
) => list.listOpenCodeSqliteSessions(args),
parseOpenCodeSqliteSessionViaWorker: (
args: Parameters<typeof parse.parseOpenCodeSqliteSession>[0]
) => parse.parseOpenCodeSqliteSession(args),
captureOpenCodeSqliteSessionViaWorker: (
args: Parameters<typeof capture.captureOpenCodeSqliteSession>[0]
) => capture.captureOpenCodeSqliteSession(args)
}
})
import { AI_VAULT_AGENTS, type AiVaultAgent } from '../../shared/ai-vault-types'
import { scanAiVaultSessions } from './session-scanner'
import { writeEveryAgentVault } from './session-scanner-every-agent-fixture'
import { resetSessionParseCacheForTests } from './session-scanner-parse-cache'
import { writeOpenCodeSqliteDatabase } from './session-scanner-opencode-sqlite-fixture'
import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths'
import {
registerTranscriptConsumer,
resetTranscriptConsumersForTests,
type TranscriptMessage
} from './session-transcript-consumers'
/*
* The guard the OpenCode capture gap needed.
*
* Every consumer of the transcript reader -- the search index today, a digest
* tomorrow -- sees an agent only through the messages its parser publishes. A
* parser can list a session, show a preview and resume it correctly while
* publishing nothing at all, which is exactly how 606 OpenCode sessions came to
* hold zero indexed messages. Nothing above this layer can tell the difference,
* so the assertion has to live here: one fixture per supported agent, read the
* way the app reads it, and every agent has to say something.
*/
const OPENCODE_SQLITE_SESSION = 'ses_capture_guard'
let tempRoots: string[] = []
afterEach(async () => {
resetTranscriptConsumersForTests()
resetSessionParseCacheForTests()
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
tempRoots = []
})
type CapturedRead = { agent: AiVaultAgent; path: string; messages: TranscriptMessage[] }
async function readEveryAgentVault(): Promise<CapturedRead[]> {
const root = await mkdtemp(join(tmpdir(), 'orca-transcript-every-agent-'))
tempRoots.push(root)
const { roots } = await writeEveryAgentVault(root)
const dbPath = join(root, 'opencode-db', 'opencode.db')
writeOpenCodeSqliteDatabase(dbPath, [
{
id: OPENCODE_SQLITE_SESSION,
turns: [
{ role: 'user', parts: ['what does the sqlite reader publish'] },
{
role: 'assistant',
parts: [
{ type: 'reasoning', text: 'Weighing which parts carry words.' },
'Every part of every turn.',
{
type: 'tool',
tool: 'bash',
input: { command: 'rg --count quokka' },
output: 'src/main/ai-vault: 3'
}
]
}
]
}
])
const reads: CapturedRead[] = []
registerTranscriptConsumer({
beginRead: (start) => {
const read: CapturedRead = {
agent: start.candidate.agent,
path: start.candidate.file.path,
messages: []
}
reads.push(read)
return { message: (message) => read.messages.push(message), finish: () => undefined }
}
})
const result = await scanAiVaultSessions({
...roots,
opencodeDbPaths: [dbPath],
platform: 'darwin',
limit: 40
})
expect(result.issues).toEqual([])
return reads
}
function spokeIn(read: CapturedRead): boolean {
return read.messages.some((message) => message.role === 'user' || message.role === 'assistant')
}
it('publishes at least one user or assistant message for every source it reads', async () => {
const reads = await readEveryAgentVault()
// Per source, not per agent: OpenCode has two storage shapes, and asking only
// that *some* OpenCode session spoke is exactly the question that read as
// healthy while every SQLite session in the vault was silent.
expect(reads.filter((read) => !spokeIn(read)).map((read) => read.path)).toEqual([])
// And the vault really does cover every agent, so a new one cannot be added
// without a fixture that proves it publishes.
expect(new Set(reads.map((read) => read.agent))).toEqual(new Set(AI_VAULT_AGENTS))
})
it('publishes an OpenCode SQLite session through the same channel as every file source', async () => {
const reads = await readEveryAgentVault()
const sqliteRead = reads.find(
(read) => splitOpenCodeSqliteCandidate(read.path)?.sessionId === OPENCODE_SQLITE_SESSION
)
expect(sqliteRead?.messages).toEqual([
{
role: 'user',
text: 'what does the sqlite reader publish',
timestamp: expect.any(String)
},
{
// Reasoning folds into the turn's own words, ahead of the text part it
// preceded, exactly as a thinking block does for a file provider.
role: 'assistant',
text: 'Weighing which parts carry words.\nEvery part of every turn.',
timestamp: expect.any(String)
},
{
// The call line and what came back, in one message: OpenCode writes both
// on one part where a file provider writes a call block and a result.
role: 'tool',
text: 'bash: rg --count quokka\nsrc/main/ai-vault: 3',
timestamp: expect.any(String)
}
])
})
it('gives an OpenCode session the same three roles a file provider publishes', async () => {
const reads = await readEveryAgentVault()
const sqliteRead = reads.find(
(read) => splitOpenCodeSqliteCandidate(read.path)?.sessionId === OPENCODE_SQLITE_SESSION
)
expect(new Set(sqliteRead?.messages.map((message) => message.role))).toEqual(
new Set(['user', 'assistant', 'tool'])
)
})
@@ -32,6 +32,12 @@ it('joins text blocks and appends tool blocks as their own messages', () => {
])
})
it('accepts the capitalised Text block Codex writes for a completed agent message', () => {
expect(
transcriptMessagesFromContent('assistant', [{ type: 'Text', text: 'the reply' }], AT)
).toEqual([{ role: 'assistant', text: 'the reply', timestamp: AT }])
})
it('reads a tool result carried on a user record as a tool message', () => {
expect(
transcriptMessagesFromContent(
@@ -93,7 +93,8 @@ export function transcriptMessagesFromContent(
if (!item) {
continue
}
const type = typeof item.type === 'string' ? item.type : null
// Codex 0.153+ item_completed blocks are typed `Text`; the set is lowercase.
const type = typeof item.type === 'string' ? item.type.toLowerCase() : null
if (type === 'tool_use') {
pushMessage(messages, 'tool', toolCallText(item.name, item.input), timestamp)
continue
@@ -1,6 +1,6 @@
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 { parseAgentSessionFile } from './session-scanner-agent-parser'
import { consumeCompleteJsonlLines } from './session-scanner-jsonl-reader'
import type { ResumableSessionParseState, SessionFileCandidate } from './session-scanner-types'
import {
@@ -159,12 +159,11 @@ export async function readWholeTranscript(args: {
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 })
channel.finishRead({ session, byteOffset: file.sizeBytes ?? 0, incomplete: false })
return session
} catch (error) {
channel.finishRead({ session: null, byteOffset: 0, incomplete: true })
@@ -6,6 +6,7 @@ import {
query,
type CanUseTool,
type Options,
type PermissionMode,
type SDKUserMessage,
type SpawnedProcess as SdkSpawnedProcess,
type SpawnOptions as SdkSpawnOptions
@@ -143,7 +144,7 @@ function recordingSpawner(spawns: SpawnSeen[]) {
}
}
function resolvedLaunch(launchArgs: string[]) {
function resolvedLaunch(permissionMode: PermissionMode, launchArgs: string[] = []) {
const record = {
sessionId: 'contract-pin-session',
provider: 'claude',
@@ -161,7 +162,8 @@ function resolvedLaunch(launchArgs: string[]) {
store: { getRecord: () => record } as unknown as AgentSessionRecordStore,
resolveWorkspacePath: async () => '/repos/workspace-1',
resolveCommand: () => FAKE_CLI,
resolveAuthPolicy: () => ({ stripAuthEnv: true })
resolveAuthPolicy: () => ({ stripAuthEnv: true }),
resolvePermissionMode: () => permissionMode
})({ identity: { sessionId: record.sessionId } as never })
}
@@ -338,9 +340,9 @@ describe('Claude Agent SDK contract pins', () => {
it('produces a matching CLI flag for every pre-SDK argv entry', async () => {
const scenario = scriptScenario([{ awaitUserMessage: true }, { emit: RESULT_FRAME }])
const spawns: SpawnSeen[] = []
// Driven by the real resolver, so the argv walk covers the durable-launchArgs
// translation and its merge order, not a hand-written options literal.
const launch = await resolvedLaunch(['--model', 'claude-sonnet-4-5', '--effort', 'high'])
// Driven by the real resolver, so the argv walk covers its option set and merge order,
// not a hand-written options literal.
const launch = await resolvedLaunch('bypassPermissions', ['--model', 'claude-sonnet-4-5'])
await drainQuery({
...launch.options,
pathToClaudeCodeExecutable: FAKE_CLI,
@@ -352,15 +354,20 @@ describe('Claude Agent SDK contract pins', () => {
expect(spawns).toHaveLength(1)
const argv = normalizeArgv(spawns[0]!.args)
// Typed-first translation must not also spell the flag through extraArgs.
for (const flag of ['--model', '--effort']) {
// Agent Permissions reaches the child as the SDK's own typed pair, spelled exactly once each.
// `--allow-dangerously-skip-permissions` is what the SDK emits for the allow flag; the CLI
// refuses `bypassPermissions` without it, so a rename upstream must fail here rather than
// silently return a Yolo user to permission prompts.
for (const flag of ['--permission-mode', '--allow-dangerously-skip-permissions']) {
expect(
argv.filter((arg) => arg === flag),
`${flag} occurrences`
).toHaveLength(1)
}
expect(argv[argv.indexOf('--model') + 1]).toBe('claude-sonnet-4-5')
expect(argv[argv.indexOf('--effort') + 1]).toBe('high')
expect(argv[argv.indexOf('--permission-mode') + 1]).toBe('bypassPermissions')
// Configured CLI arguments are a terminal concern; a record written before they stopped
// being read must not smuggle one back into the child's argv.
expect(argv).not.toContain('--model')
// Headless print mode is the SDK's only mode; `query()` never passes `-p`,
// and if the SDK ever started passing it this pin would notice.
const impliedByHeadlessQuery = new Set(['-p'])
@@ -11,10 +11,10 @@ import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-str
import {
CLAUDE_DEFAULT_SETTING_SOURCES,
CLAUDE_STRUCTURED_BASE_OPTIONS,
claudeSdkOptionsForLaunchArgs,
claudeSessionIdForOrcaSession,
createClaudeStructuredLaunchResolver
} from './claude-structured-launch-resolution'
import { claudeStructuredPermissionModeForSettings } from './claude-structured-permission-mode'
const SESSION_ID = 'orca-session-1'
const IDENTITY = { sessionId: SESSION_ID } as Parameters<
@@ -55,13 +55,16 @@ function makeExecutable(path: string): void {
function resolverFor(
value: AgentSessionRecord | null,
resolveEnv?: () => Record<string, string>,
stripAuthEnv = false
stripAuthEnv = false,
// Manual by default so a test that is not about permissions is not silently about them.
agentDefaultArgs: Record<string, string> = { claude: '' }
) {
return createClaudeStructuredLaunchResolver({
store: { getRecord: () => value } as unknown as AgentSessionRecordStore,
resolveWorkspacePath: async (id) => `/repos/${id}`,
resolveCommand: () => '/usr/local/bin/claude',
resolveAuthPolicy: () => ({ stripAuthEnv }),
resolvePermissionMode: () => claudeStructuredPermissionModeForSettings({ agentDefaultArgs }),
...(resolveEnv ? { resolveEnv } : {})
})
}
@@ -119,6 +122,7 @@ describe('claude structured launch resolution', () => {
supportedDialogKinds: [],
extraArgs: { 'replay-user-messages': null },
systemPrompt: { type: 'preset', preset: 'claude_code' },
permissionMode: 'default',
sessionId: first.providerSessionId
})
expect(first.options.resume).toBeUndefined()
@@ -190,42 +194,55 @@ describe('claude structured launch resolution', () => {
expect(launch.options.resumeSessionAt).toBeUndefined()
})
it('preserves durable Claude launch arguments as typed options and extraArgs', async () => {
// Agent Permissions is stored as the bypass flag inside the launch arguments, so presence of
// that flag — not the whole string — is what Yolo means, exactly as a terminal launch reads it.
it.each([
['--dangerously-skip-permissions'],
['--dangerously-skip-permissions --model Opus'],
['--model Opus --dangerously-skip-permissions']
])('starts a Yolo session in bypassPermissions for args %s', async (claude) => {
const launch = await resolverFor(record(), undefined, false, { claude })({ identity: IDENTITY })
expect(launch.options.permissionMode).toBe('bypassPermissions')
// The SDK refuses bypassPermissions unless the allow flag rides with it.
expect(launch.options.allowDangerouslySkipPermissions).toBe(true)
})
// The common profile: the toggle has never been used, so it has written nothing, and the
// default for the key it did not write is the bypass flag — the posture the terminal has
// always given these users.
it('starts a session that never opened Agent settings in bypassPermissions', async () => {
const launch = await resolverFor(record(), undefined, false, {})({ identity: IDENTITY })
expect(launch.options.permissionMode).toBe('bypassPermissions')
expect(launch.options.allowDangerouslySkipPermissions).toBe(true)
})
// Manual is stored as an empty string, which owns the key and so beats the shipped default.
it.each([[''], ['--model Opus']])(
'leaves a Manual session prompting for args %s',
async (claude) => {
const launch = await resolverFor(record(), undefined, false, { claude })({
identity: IDENTITY
})
expect(launch.options.permissionMode).toBe('default')
expect(launch.options.allowDangerouslySkipPermissions).toBeUndefined()
}
)
// The configured CLI arguments are a terminal concern: a durable record written before they
// stopped being read must not smuggle one back into the child.
it("ignores the record's durable launch arguments", async () => {
const launch = await resolverFor(
record({
launchArgs: [
'--model',
'claude-sonnet-4-5',
'--effort',
'high',
'--dangerously-skip-permissions'
]
launchArgs: ['--model', 'claude-sonnet-4-5', '--dangerously-skip-permissions']
})
)({ identity: IDENTITY })
expect(launch.options.model).toBe('claude-sonnet-4-5')
expect(launch.options.effort).toBe('high')
expect(launch.options.extraArgs).toEqual({
'dangerously-skip-permissions': null,
'replay-user-messages': null
})
})
it('routes durable launch arguments to a typed option first and refuses what neither can carry', () => {
// The catalog's own output: each flag lands in exactly one place, so the SDK
// cannot emit it twice with two different values.
expect(claudeSdkOptionsForLaunchArgs(['--model', 'opus', '--effort', 'xhigh'])).toEqual({
model: 'opus',
effort: 'xhigh'
})
// An effort the SDK's union does not name still reaches the CLI, unchanged.
expect(claudeSdkOptionsForLaunchArgs(['--effort', 'ultra'])).toEqual({
extraArgs: { effort: 'ultra' }
})
expect(claudeSdkOptionsForLaunchArgs(['--settings=/tmp/s.json'])).toEqual({
extraArgs: { settings: '/tmp/s.json' }
})
expect(() => claudeSdkOptionsForLaunchArgs(['-m', 'opus'])).toThrow(/no SDK option/)
expect(launch.options.model).toBeUndefined()
expect(launch.options.extraArgs).toEqual({ 'replay-user-messages': null })
expect(launch.options.permissionMode).toBe('default')
})
it('keeps the session launch environment pinned after account settings change', async () => {
@@ -1,5 +1,8 @@
import { createHash } from 'node:crypto'
import type { EffortLevel, Options as ClaudeAgentSdkOptions } from '@anthropic-ai/claude-agent-sdk'
import type {
Options as ClaudeAgentSdkOptions,
PermissionMode
} from '@anthropic-ai/claude-agent-sdk'
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
@@ -35,6 +38,8 @@ export type ClaudeStructuredSdkOptions = Pick<
| 'extraArgs'
| 'model'
| 'effort'
| 'permissionMode'
| 'allowDangerouslySkipPermissions'
| 'sessionId'
| 'resume'
| 'resumeSessionAt'
@@ -58,8 +63,6 @@ export const CLAUDE_STRUCTURED_BASE_OPTIONS: ClaudeStructuredSdkOptions = {
extraArgs: { 'replay-user-messages': null }
}
const EFFORT_LEVELS: readonly string[] = ['low', 'medium', 'high', 'xhigh', 'max']
function cloneDefinedEnv(env: NodeJS.ProcessEnv | Record<string, string>): Record<string, string> {
const next: Record<string, string> = {}
for (const [key, value] of Object.entries(env)) {
@@ -71,47 +74,18 @@ function cloneDefinedEnv(env: NodeJS.ProcessEnv | Record<string, string>): Recor
}
/**
* Translate the record's durable launch arguments into SDK options.
* Agent Permissions as query-start options.
*
* Typed option first so a flag is never emitted twice; `extraArgs` carries
* anything without one. A token expressible neither way is refused rather than
* dropped — a silent drop is how this lane loses launch flags.
* The SDK refuses `bypassPermissions` unless the allow flag rides with it, so the two are built
* here together and never emitted apart. The prompting mode is stated rather than left out: the
* SDK fills an absent mode with `default` anyway, and saying so keeps the launch readable.
*/
export function claudeSdkOptionsForLaunchArgs(
args: readonly string[]
): Pick<ClaudeStructuredSdkOptions, 'model' | 'effort' | 'extraArgs'> {
let model: string | undefined
let effort: EffortLevel | undefined
const extraArgs: Record<string, string | null> = {}
for (let index = 0; index < args.length; index += 1) {
const token = args[index] ?? ''
if (!token.startsWith('--') || token.length <= 2) {
throw new Error(
`claude launch argument ${token} has no SDK option; refusing rather than dropping it`
)
}
const equals = token.indexOf('=')
const flag = equals === -1 ? token : token.slice(0, equals)
let value = equals === -1 ? null : token.slice(equals + 1)
if (value === null) {
const next = args[index + 1]
if (next !== undefined && !next.startsWith('-')) {
value = next
index += 1
}
}
if (flag === '--model' && value !== null) {
model = value
} else if (flag === '--effort' && value !== null && EFFORT_LEVELS.includes(value)) {
effort = value as EffortLevel
} else {
extraArgs[flag.slice(2)] = value
}
}
export function claudeStructuredPermissionOptions(
mode: PermissionMode
): Pick<ClaudeStructuredSdkOptions, 'permissionMode' | 'allowDangerouslySkipPermissions'> {
return {
...(model === undefined ? {} : { model }),
...(effort === undefined ? {} : { effort }),
...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {})
permissionMode: mode,
...(mode === 'bypassPermissions' ? { allowDangerouslySkipPermissions: true } : {})
}
}
@@ -142,6 +116,8 @@ export type ClaudeStructuredLaunchResolverDeps = {
* inherit a guess. Build it with claudeStructuredAuthPolicyForSettings.
*/
resolveAuthPolicy: () => Promise<ClaudeStructuredAuthPolicy> | ClaudeStructuredAuthPolicy
/** The user's Agent Permissions setting, re-read per acquisition. Absent means prompting. */
resolvePermissionMode?: () => Promise<PermissionMode> | PermissionMode
/** How long an in-flight account switch may hold a launch before it is refused. */
authSwitchSettleTimeoutMs?: number
/** Account state for the managed-account gate; null when it cannot be read, which refuses. */
@@ -219,7 +195,11 @@ export function createClaudeStructuredLaunchResolver(
head?.handle.provider === 'claude'
? head.handle.sessionId
: claudeSessionIdForOrcaSession(identity.sessionId)
const durable = claudeSdkOptionsForLaunchArgs(record.launchArgs ?? [])
// `record.launchArgs` is deliberately not read: the configured CLI arguments are a terminal
// concern, and the permission mode they used to smuggle in is a typed option now.
const permission = claudeStructuredPermissionOptions(
(await deps.resolvePermissionMode?.()) ?? 'default'
)
const command = (deps.resolveCommand ?? resolveClaudeCommand)()
const auth = await deps.resolveAuthPolicy()
const overlay = await deps.resolveEnv?.()
@@ -256,9 +236,8 @@ export function createClaudeStructuredLaunchResolver(
return {
pathToClaudeCodeExecutable: command,
options: {
...durable,
...CLAUDE_STRUCTURED_BASE_OPTIONS,
extraArgs: { ...durable.extraArgs, ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs },
...permission,
...(head?.handle.provider === 'claude'
? {
resume: providerSessionId,
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { claudeStructuredPermissionModeForSettings } from './claude-structured-permission-mode'
describe('claudeStructuredPermissionModeForSettings', () => {
// The three states the Agent Permissions toggle can leave behind. The untouched case is the
// common one and the easiest to get wrong: the toggle writes nothing until it is used, and the
// default Orca ships for the key it did not write is the bypass flag — which is what a terminal
// launch has always applied to an untouched profile.
it('bypasses when the user has never opened Agent settings', () => {
expect(claudeStructuredPermissionModeForSettings({ agentDefaultArgs: {} })).toBe(
'bypassPermissions'
)
expect(claudeStructuredPermissionModeForSettings({})).toBe('bypassPermissions')
expect(claudeStructuredPermissionModeForSettings(null)).toBe('bypassPermissions')
expect(claudeStructuredPermissionModeForSettings({ agentDefaultArgs: { codex: '' } })).toBe(
'bypassPermissions'
)
})
it('bypasses when Yolo wrote the flag, alone or beside other tokens', () => {
for (const claude of [
'--dangerously-skip-permissions',
'--dangerously-skip-permissions --model Opus',
'--model Opus --dangerously-skip-permissions'
]) {
expect(
claudeStructuredPermissionModeForSettings({ agentDefaultArgs: { claude } }),
claude
).toBe('bypassPermissions')
}
})
// Manual is stored as an empty string, which owns the key and so beats the shipped default.
it('prompts when Manual cleared the flag', () => {
expect(claudeStructuredPermissionModeForSettings({ agentDefaultArgs: { claude: '' } })).toBe(
'default'
)
})
it('prompts when the user replaced the flag with something else', () => {
expect(
claudeStructuredPermissionModeForSettings({ agentDefaultArgs: { claude: '--model Opus' } })
).toBe('default')
})
})
@@ -0,0 +1,23 @@
import type { PermissionMode } from '@anthropic-ai/claude-agent-sdk'
import type { GlobalSettings } from '../../shared/global-settings-types'
import { resolvedTuiAgentArgsBypassPermissions } from '../../shared/tui-agent-launch-defaults'
/**
* The Agent Permissions setting as the SDK's own permission mode.
*
* Read per acquisition — like the environment overlay and the auth policy beside it — rather than
* latched into the session record: the setting is the one copy of this fact, so nothing can
* disagree with it and a failed restore cannot silently downgrade a session to prompting.
*
* Yolo still stores itself as the agent's bypass flag inside the launch arguments, which is also
* what a terminal launch acts on, so presence of that flag is the fact to read — resolved through
* the same default fallback the terminal uses, which is why an untouched profile bypasses. The
* rest of the arguments string is a terminal concern this path does not interpret.
*/
export function claudeStructuredPermissionModeForSettings(
settings: Partial<Pick<GlobalSettings, 'agentDefaultArgs'>> | null | undefined
): PermissionMode {
return resolvedTuiAgentArgsBypassPermissions('claude', settings?.agentDefaultArgs)
? 'bypassPermissions'
: 'default'
}
@@ -110,17 +110,14 @@ export async function cancelClaudeStructuredTurn(input: {
session.prompts.releaseClaim(claim)
return { cancelled: false }
}
// The translator owns turn identity. A session with no journal has published no
// turn row for a client to name, so it holds no identity this request can contradict.
// Judge against the published journal, because that is the only turn a client could have been
// shown — but only while it HAS an answer. The journal drains through a serialized async queue,
// so a null read means the row has not landed yet, not that nothing is running; falling back to
// the in-memory turn there keeps Stop from being gated on bookkeeping. No live turn either way
// means nothing has published an identity this request can contradict.
const ownsRequestedTurn = (): boolean => {
const translator = session.translator
if (!translator) {
return session.dispatchSequence === 0
}
const currentTurnId = translator.currentTurnId
return currentTurnId === null
? session.dispatchSequence === 0
: currentTurnId === request.turnId
const liveTurnId = request.resolveLiveTurnId?.() ?? session.translator?.currentTurnId ?? null
return liveTurnId === null ? session.dispatchSequence === 0 : liveTurnId === request.turnId
}
// The host supplies the durable latest submission; direct adapter callers fall back to
// the current in-memory waiter so an unknown dispatch remains fenced without a latch.
@@ -90,6 +90,33 @@ function providerOutput(connection: FakeConnection, uuid: string): void {
})
}
/** A session whose in-memory turn is `turnId`, standing in for the adapter's own read. */
function sessionHoldingTurn(turnId: string | null): ReturnType<typeof sessionFor> {
const session = sessionFor()
session.dispatchSequence = 1
session.translator = {
handle: vi.fn(),
journalPrompts: { cancel: vi.fn(), resolve: vi.fn() },
currentTurnId: turnId,
flush: vi.fn(),
pendingStreamedBlocks: 0,
dispose: vi.fn()
}
return session
}
function cancellationOf(
session: ReturnType<typeof sessionFor>,
request: Parameters<typeof cancelClaudeStructuredTurn>[0]['request']
): Promise<{ cancelled: boolean }> {
return cancelClaudeStructuredTurn({
request,
sessions: new Map([['session-1', session]]),
compactions: new StructuredSessionCompaction(),
admitPromptCancellation: () => true
})
}
describe('Claude turn ownership', () => {
it('stops a turn the provider opened after the session already dispatched once', async () => {
const claude = fakeClaude({ replayUuid: 'echo-turn' })
@@ -349,6 +376,87 @@ describe('Claude turn ownership', () => {
expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(true)
})
// The sink drains asynchronously, so the adapter's own turn can already name a row no client
// has been shown. The published journal is what a Stop is derived from, so it is what judges it.
it('admits a Stop for the published turn while the adapter already holds an undrained one', async () => {
const session = sessionHoldingTurn('turn-undrained')
const interrupt = vi.fn().mockResolvedValue(undefined)
session.connection.interrupt = interrupt
await expect(
cancellationOf(session, {
sessionId: 'session-1',
turnId: 'turn-shown',
fence: 1,
resolveLiveTurnId: () => 'turn-shown'
})
).resolves.toEqual({ cancelled: true })
expect(interrupt).toHaveBeenCalledOnce()
})
// The journal drains through a serialized async queue, so a live turn routinely has no published
// row yet. Refusing there would gate a user's Stop on bookkeeping, so the in-memory turn covers
// the lag — the journal is authoritative only while it has an answer.
it('admits a Stop for the live turn while the journal has not drained its row', async () => {
const session = sessionHoldingTurn('turn-live')
const interrupt = vi.fn().mockResolvedValue(undefined)
session.connection.interrupt = interrupt
await expect(
cancellationOf(session, {
sessionId: 'session-1',
turnId: 'turn-live',
fence: 1,
resolveLiveTurnId: () => null
})
).resolves.toEqual({ cancelled: true })
expect(interrupt).toHaveBeenCalledOnce()
})
it('refuses a Stop the adapter still holds once the journal published a newer turn', async () => {
const session = sessionHoldingTurn('turn-stale')
const interrupt = vi.fn().mockResolvedValue(undefined)
session.connection.interrupt = interrupt
await expect(
cancellationOf(session, {
sessionId: 'session-1',
turnId: 'turn-stale',
fence: 1,
resolveLiveTurnId: () => 'turn-newer'
})
).resolves.toEqual({ cancelled: false })
expect(interrupt).not.toHaveBeenCalled()
})
// The guard re-checks after the delivery fence may have waited seconds, so the journal read
// has to happen then — a value captured at request time would interrupt whatever ran next.
it('re-reads the published turn after the delivery fence waits', async () => {
vi.useFakeTimers()
try {
let publishedTurnId = 'turn-shown'
const session = sessionHoldingTurn('turn-shown')
const interrupt = vi.fn().mockResolvedValue(undefined)
session.connection.interrupt = interrupt
const cancellation = cancellationOf(session, {
sessionId: 'session-1',
turnId: 'turn-shown',
fence: 1,
dispatchStatus: { state: 'unknown', recovered: false },
resolveLiveTurnId: () => publishedTurnId
})
await vi.advanceTimersByTimeAsync(CLAUDE_DISPATCH_ADMISSION_TIMEOUT_MS - 1)
publishedTurnId = 'turn-next'
await vi.advanceTimersByTimeAsync(1)
await expect(cancellation).resolves.toEqual({ cancelled: false })
expect(interrupt).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('refuses a stale turn id once the provider opened a newer turn', async () => {
const claude = fakeClaude({ replayUuid: 'echo-turn' })
const { adapter, bodies, connection } = await acquiredWithJournal(claude)
+9 -4
View File
@@ -1,8 +1,9 @@
import { join } from 'node:path'
import {
getSharedManagedScriptPath,
buildWindowsHookPowerShellCommand,
wrapPosixHookCommand,
wrapWindowsCmdHookCommand,
WINDOWS_CMD_SAFE_PATH,
writeHooksJson,
type HookDefinition
} from '../agent-hooks/installer-utils'
@@ -70,9 +71,13 @@ export function getManagedScriptPath(): string {
}
export function getManagedCommand(scriptPath: string): string {
return process.platform === 'win32'
? wrapWindowsCmdHookCommand(scriptPath)
: wrapPosixHookCommand(scriptPath)
if (process.platform !== 'win32') {
return wrapPosixHookCommand(scriptPath)
}
// Codex's default native Windows hook host is PowerShell; reuse it to avoid a second interpreter.
return WINDOWS_CMD_SAFE_PATH.test(scriptPath)
? scriptPath
: buildWindowsHookPowerShellCommand(scriptPath)
}
export type CodexManagedHookInstallMaterial = {
@@ -1,32 +0,0 @@
import { describe, expect, it } from 'vitest'
import { resolveCodexStructuredAppServerArgs } from './codex-structured-app-server-args'
describe('structured Codex app-server arguments', () => {
it('keeps configuration flags and converts effort to the app-server config contract', () => {
expect(
resolveCodexStructuredAppServerArgs(
'--profile review -c approval_policy=never --model gpt-5.6 --effort high --search',
'posix'
)
).toEqual([
'--profile',
'review',
'-c',
'approval_policy=never',
'--model',
'gpt-5.6',
'-c',
'model_reasoning_effort=high',
'--search'
])
})
it.each(['--no-alt-screen', '--remote ws://host', '-C /tmp/elsewhere', 'resume thread-1'])(
'reports an incompatible configured argument instead of dropping %s',
(configured) => {
expect(() => resolveCodexStructuredAppServerArgs(configured, 'posix')).toThrow(
/cannot apply the configured CLI arguments.*Settings or use terminal view/
)
}
)
})
@@ -1,84 +0,0 @@
import {
tokenizeStartupCommand,
type AgentStartupShell
} from '../../shared/tui-agent-startup-shell'
const VALUE_FLAGS = new Set([
'-a',
'--add-dir',
'--ask-for-approval',
'-c',
'--config',
'--disable',
'--effort',
'--enable',
'--local-provider',
'-m',
'--model',
'-p',
'--profile',
'--reasoning-effort',
'-s',
'--sandbox'
])
const BOOLEAN_FLAGS = new Set([
'--approve-for-me',
'--dangerously-bypass-approvals-and-sandbox',
'--dangerously-bypass-hook-trust',
'--oss',
'--search',
'--strict-config'
])
const EFFORT_FLAGS = new Set(['--effort', '--reasoning-effort'])
function configuredArgsError(detail: string): Error {
return new Error(
`Structured Codex chat cannot apply the configured CLI arguments to app-server: ${detail}. Update Codex CLI arguments in Settings or use terminal view.`
)
}
function splitOption(token: string): { flag: string; inlineValue?: string } {
const separator = token.indexOf('=')
return separator > 0
? { flag: token.slice(0, separator), inlineValue: token.slice(separator + 1) }
: { flag: token }
}
/** Keeps config-affecting Codex flags and refuses every TUI-only or unknown token visibly. */
export function resolveCodexStructuredAppServerArgs(
configuredArgs: string,
shell: AgentStartupShell
): string[] {
const parsed = tokenizeStartupCommand(configuredArgs.trim(), shell)
if (!parsed.ok) {
throw configuredArgsError(parsed.error)
}
const divergent = parsed.spans.find((span) => span.divergesFromShell)
if (divergent) {
throw configuredArgsError(configuredArgs.slice(divergent.start, divergent.end))
}
const result: string[] = []
for (let index = 0; index < parsed.tokens.length; index += 1) {
const token = parsed.tokens[index]
const { flag, inlineValue } = splitOption(token)
if (BOOLEAN_FLAGS.has(flag) && inlineValue === undefined) {
result.push(flag)
continue
}
if (!VALUE_FLAGS.has(flag)) {
throw configuredArgsError(token || 'an empty positional argument')
}
const value = inlineValue ?? parsed.tokens[++index]
if (value === undefined || value.length === 0) {
throw configuredArgsError(`${flag} requires a value`)
}
if (EFFORT_FLAGS.has(flag)) {
result.push('-c', `model_reasoning_effort=${value}`)
} else {
result.push(flag, value)
}
}
return result
}
@@ -3,6 +3,7 @@ import type { AgentSessionRecord } from '../../shared/agent-session-record'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store'
import { createCodexStructuredLaunchResolver } from './codex-structured-launch-resolution'
import { codexStructuredPermissionArgsForSettings } from './codex-structured-permission-mode'
const SESSION_ID = 'session-1'
const IDENTITY = { sessionId: SESSION_ID } as Parameters<
@@ -38,14 +39,16 @@ function record(overrides: Partial<AgentSessionRecord> = {}): AgentSessionRecord
function resolverFor(
value: AgentSessionRecord | null,
resolveWorkspacePath: (workspaceId: string) => Promise<string> = async (id) => `/repos/${id}`,
resolveRollout: () => Promise<string | null> = async () => null
resolveRollout: () => Promise<string | null> = async () => null,
agentDefaultArgs: Record<string, string> = { codex: '' }
) {
return createCodexStructuredLaunchResolver({
store: { getRecord: () => value } as unknown as AgentSessionRecordStore,
resolveWorkspacePath,
resolveCommand: () => '/usr/local/bin/codex',
resolveRollout,
isWindowsProcessStartTimeAvailable: () => true
isWindowsProcessStartTimeAvailable: () => true,
resolvePermissionArgs: () => codexStructuredPermissionArgsForSettings({ agentDefaultArgs })
})
}
@@ -109,18 +112,36 @@ describe('codex structured launch resolution', () => {
expect(launch.resumeThreadId).toBe('thread-current')
})
it('places the durable user configuration before the app-server subcommand', async () => {
// Agent Permissions is the only thing from the arguments field that reaches app-server, and it
// keeps the position the durable arguments used to hold: before the subcommand.
it('places the permission flag before the app-server subcommand', async () => {
const launch = await resolverFor(record(), undefined, undefined, {
codex: '--dangerously-bypass-approvals-and-sandbox --model gpt-5.6-sol'
})({ identity: IDENTITY })
expect(launch.args).toEqual(['--dangerously-bypass-approvals-and-sandbox', 'app-server'])
})
it('bypasses approvals for a profile that never opened Agent settings', async () => {
const launch = await resolverFor(record(), undefined, undefined, {})({ identity: IDENTITY })
expect(launch.args).toEqual(['--dangerously-bypass-approvals-and-sandbox', 'app-server'])
})
it('leaves the approval prompts on under Manual', async () => {
const launch = await resolverFor(record())({ identity: IDENTITY })
expect(launch.args).toEqual(['app-server'])
})
// The configured CLI arguments are a terminal concern: a durable record written before they
// stopped being read must not smuggle one back into app-server's argv.
it("ignores the record's durable launch arguments", async () => {
const launch = await resolverFor(
record({ launchArgs: ['--profile', 'review', '-c', 'model_reasoning_effort=high'] })
)({ identity: IDENTITY })
expect(launch.args).toEqual([
'--profile',
'review',
'-c',
'model_reasoning_effort=high',
'app-server'
])
expect(launch.args).toEqual(['app-server'])
})
it('pins resume to the rollout file that proved the durable thread', async () => {
@@ -27,6 +27,9 @@ export type CodexStructuredLaunchResolverDeps = {
resolveRollout?: typeof resolvePinnedCodexRolloutProof
/** Test seam for the host capability; production uses the native process table. */
isWindowsProcessStartTimeAvailable?: () => boolean
/** The user's Agent Permissions setting as app-server argv, re-read per acquisition.
* Absent means the CLI's own approval prompts stay on. */
resolvePermissionArgs?: () => string[]
}
export function createCodexStructuredLaunchResolver(
@@ -66,7 +69,9 @@ export function createCodexStructuredLaunchResolver(
pathEnv,
...(homePath ? { homePath } : {})
})
const args = [...(record.launchArgs ?? []), 'app-server']
// `record.launchArgs` is deliberately not read: the configured CLI arguments are a terminal
// concern, and the permission posture they used to smuggle in is derived per acquisition.
const args = [...(deps.resolvePermissionArgs?.() ?? []), 'app-server']
const head = agentSessionProviderHandleChainHead(record.providerHandleChain)
const resumeThreadId = head?.handle.provider === 'codex' ? head.handle.threadId : null
return {
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { codexStructuredPermissionArgsForSettings } from './codex-structured-permission-mode'
const BYPASS = ['--dangerously-bypass-approvals-and-sandbox']
describe('codexStructuredPermissionArgsForSettings', () => {
it('bypasses when the user has never opened Agent settings', () => {
expect(codexStructuredPermissionArgsForSettings({ agentDefaultArgs: {} })).toEqual(BYPASS)
expect(codexStructuredPermissionArgsForSettings({})).toEqual(BYPASS)
expect(codexStructuredPermissionArgsForSettings(null)).toEqual(BYPASS)
expect(codexStructuredPermissionArgsForSettings({ agentDefaultArgs: { claude: '' } })).toEqual(
BYPASS
)
})
it('bypasses when Yolo wrote the flag, alone or beside other tokens', () => {
for (const codex of [
'--dangerously-bypass-approvals-and-sandbox',
'--dangerously-bypass-approvals-and-sandbox --model gpt-5.6-sol',
'--model gpt-5.6-sol --dangerously-bypass-approvals-and-sandbox'
]) {
expect(
codexStructuredPermissionArgsForSettings({ agentDefaultArgs: { codex } }),
codex
).toEqual(BYPASS)
}
})
it('leaves the approval prompts on when Manual cleared the flag', () => {
expect(codexStructuredPermissionArgsForSettings({ agentDefaultArgs: { codex: '' } })).toEqual(
[]
)
})
// The passthrough that used to carry these to app-server is gone on purpose; only the
// permission posture is derived, and nothing else from the field reaches argv.
it('carries nothing but the permission posture out of the arguments field', () => {
expect(
codexStructuredPermissionArgsForSettings({
agentDefaultArgs: {
codex: '--profile review --add-dir /repo -c model_reasoning_effort=high'
}
})
).toEqual([])
})
})
@@ -0,0 +1,21 @@
import type { GlobalSettings } from '../../shared/global-settings-types'
import { resolvedTuiAgentArgsBypassPermissions } from '../../shared/tui-agent-launch-defaults'
import { YOLO_TUI_AGENT_ARGS } from '../../shared/tui-agent-permissions'
/**
* The Agent Permissions setting as app-server argv.
*
* Derived per acquisition from the resolved launch arguments, never from the free-text Arguments
* field: app-server takes a narrower option set than the interactive CLI and the two are versioned
* apart, so the only thing read out of that field is the posture the toggle stores in it. An
* untouched profile resolves to the default Orca ships, which is the bypass flag.
*/
export function codexStructuredPermissionArgsForSettings(
settings: Partial<Pick<GlobalSettings, 'agentDefaultArgs'>> | null | undefined
): string[] {
const bypassArg = YOLO_TUI_AGENT_ARGS.codex
return bypassArg !== undefined &&
resolvedTuiAgentArgsBypassPermissions('codex', settings?.agentDefaultArgs)
? [bypassArg]
: []
}
@@ -28,11 +28,9 @@ vi.mock('os', async (importOriginal) => {
})
import { CodexHookService } from './hook-service'
import { buildWindowsHookPowerShellCommand } from '../agent-hooks/installer-utils'
import { runExclusivelyForCodexTrustConfig } from './codex-trust-config-mutation-queue'
const WINDOWS_POWERSHELL_LAUNCHER =
/^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -EncodedCommand \S+$/
const homes = setupCodexHookHomes(homedirMock, getPathMock)
function localManagedCodexEvents(): string[] {
@@ -184,10 +182,7 @@ describe('CodexHookService', () => {
expect(Object.keys(hooksConfig)).toEqual(['hooks'])
})
// Why: #6078 — a Windows user profile path like `C:\Users\Jane Doe` used to
// be written verbatim as the hook command, so Codex split it at the space and
// the hook exited with code 1. Keep spaced paths on the encoded launcher so
// `cmd.exe /C` never sees the raw script path.
// #6078: the existing PowerShell host must still quote spaced profile paths.
it.skipIf(process.platform !== 'win32')(
'wraps the managed hook command when the profile path contains a space (#6078)',
async () => {
@@ -208,7 +203,11 @@ describe('CodexHookService', () => {
for (const eventName of localManagedCodexEvents()) {
const command = hooksConfig.hooks[eventName]?.[0]?.hooks?.[0]?.command
expect(command).toMatch(WINDOWS_POWERSHELL_LAUNCHER)
expect(command).toBe(
buildWindowsHookPowerShellCommand(
join(homedir(), '.orca', 'agent-hooks', 'codex-hook.cmd')
)
)
}
} finally {
rmSync(spaceHome, { recursive: true, force: true })
@@ -216,10 +215,9 @@ describe('CodexHookService', () => {
}
)
// Why: cmd.exe expands `%` and treats `^` as an escape even inside otherwise
// plausible paths. Keep those rare cases on the encoded launcher from #6078.
// Preserve literal-path quoting when constructing commands for shell metacharacters.
it.skipIf(process.platform !== 'win32')(
'keeps the encoded launcher when the profile path contains cmd metacharacters',
'quotes the script path when the profile contains cmd metacharacters',
async () => {
const metacharHome = join(tmpdir(), 'orca %ORCA_TEST% ^ home')
mkdirSync(metacharHome, { recursive: true })
@@ -238,7 +236,11 @@ describe('CodexHookService', () => {
for (const eventName of localManagedCodexEvents()) {
const command = hooksConfig.hooks[eventName]?.[0]?.hooks?.[0]?.command
expect(command).toMatch(WINDOWS_POWERSHELL_LAUNCHER)
expect(command).toBe(
buildWindowsHookPowerShellCommand(
join(homedir(), '.orca', 'agent-hooks', 'codex-hook.cmd')
)
)
}
} finally {
rmSync(metacharHome, { recursive: true, force: true })
@@ -268,7 +270,11 @@ describe('CodexHookService', () => {
expect(command).not.toMatch(/powershell/i)
expect(command).toMatch(/\\agent-hooks\\codex-hook\.cmd$/)
} else {
expect(command).toMatch(WINDOWS_POWERSHELL_LAUNCHER)
expect(command).toBe(
buildWindowsHookPowerShellCommand(
join(homedir(), '.orca', 'agent-hooks', 'codex-hook.cmd')
)
)
}
}
)
+146
View File
@@ -0,0 +1,146 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { delimiter, join } from 'node:path'
import { createServer } from 'node:http'
import { runProcess } from '../../shared/child-process/run-process'
import { removeTree } from '../../shared/windows-transient-lock-removal'
import { getManagedCommand, CODEX_EVENTS } from './codex-hook-definition'
import { getManagedScript } from './codex-hook-script'
import {
createManagedCommandMatcher,
wrapWindowsCmdHookCommand
} from '../agent-hooks/installer-utils'
vi.mock('electron', () => ({ app: { getPath: () => process.cwd() } }))
afterEach(() => vi.restoreAllMocks())
describe('Codex Windows hook command', () => {
it.each(['测试用户', '홍길동', '日本語', 'rené', '测试 用户', "测试 O'Brien"])(
'uses the existing PowerShell host for %s without a second interpreter',
(profile) => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
const path = `C:\\Users\\${profile}\\.orca\\agent-hooks\\codex-hook.cmd`
const command = getManagedCommand(path)
expect(command).not.toMatch(/powershell\.exe|EncodedCommand|Set-ExecutionPolicy/)
expect(command).toContain(`-LiteralPath '${path.replaceAll("'", "''")}' -PathType Leaf`)
expect(command).toContain(`[Console]::In.ReadToEnd()`)
expect(createManagedCommandMatcher('codex-hook.cmd')(command)).toBe(true)
expect(wrapWindowsCmdHookCommand(path)).toContain('-EncodedCommand')
}
)
it('preserves the existing ASCII command and POSIX launcher', () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
const path = 'C:\\Users\\alice\\.orca\\agent-hooks\\codex-hook.cmd'
expect(getManagedCommand(path)).toBe(path)
vi.spyOn(process, 'platform', 'get').mockReturnValue('linux')
expect(getManagedCommand('/home/测试/.orca/agent-hooks/codex-hook.sh')).toContain(
"[ -x '/home/测试/.orca/agent-hooks/codex-hook.sh' ]"
)
})
})
const windowsPowerShell = join(
process.env.SystemRoot ?? 'C:\\Windows',
'System32',
'WindowsPowerShell',
'v1.0',
'powershell.exe'
)
const windowsPwsh = (process.env.PATH ?? '')
.split(delimiter)
.map((directory) => join(directory, 'pwsh.exe'))
.find((file) => existsSync(file))
describe.skipIf(process.platform !== 'win32')('Codex hook delivery through PowerShell', () => {
it.each([windowsPowerShell, ...(windowsPwsh ? [windowsPwsh] : [])])(
'delivers all eight events exactly once from a Unicode profile through %s',
async (shell) => {
const root = mkdtempSync(join(tmpdir(), 'orca-codex-cjk-'))
const home = join(root, "测试 사용자 O'Brien")
mkdirSync(home)
const scriptPath = join(home, 'codex-hook.cmd')
writeFileSync(scriptPath, getManagedScript())
const posts: URLSearchParams[] = []
const tokens: unknown[] = []
const server = createServer((req, res) => {
const chunks: Buffer[] = []
req.on('data', (chunk) => chunks.push(chunk))
req.on('end', () => {
tokens.push(req.headers['x-orca-agent-hook-token'])
posts.push(new URLSearchParams(Buffer.concat(chunks).toString('utf8')))
res.writeHead(204).end()
})
})
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (!address || typeof address === 'string') {
throw new Error('Missing listener port')
}
const env = {
...Object.fromEntries(
Object.entries(process.env).filter(([key]) => !key.startsWith('ORCA_'))
),
ORCA_BACKGROUND_LAUNCH: '1',
ORCA_AGENT_HOOK_PORT: String(address.port),
ORCA_AGENT_HOOK_TOKEN: 'unicode-test-token',
ORCA_PANE_KEY: 'unicode-tab:unicode-leaf',
ORCA_WORKTREE_ID: 'C:\\folder workspace\\测试 & repo'
}
const payloads = CODEX_EVENTS.map((hook_event_name) =>
JSON.stringify({
hook_event_name,
prompt: '测试 한국어 😀 " \\ \n & %PATH% ! $HOME '.repeat(7000)
})
)
const invoke = (command: string, input: string) =>
runProcess({
program: shell,
args: ['-NoProfile', '-Command', command],
input,
env,
timeoutMs: 10_000,
terminationBarrier: true
})
try {
for (let offset = 0; offset < payloads.length; offset += 4) {
const results = await Promise.all(
payloads
.slice(offset, offset + 4)
.map((payload) => invoke(getManagedCommand(scriptPath), payload))
)
for (const result of results) {
expect(result).toMatchObject({ code: 0, stdout: '', stderr: '', timedOut: false })
}
}
expect(posts).toHaveLength(CODEX_EVENTS.length)
expect(tokens).toEqual(CODEX_EVENTS.map(() => 'unicode-test-token'))
expect(posts.map((post) => post.get('payload')).sort()).toEqual([...payloads].sort())
for (const post of posts) {
expect(post.get('paneKey')).toBe(env.ORCA_PANE_KEY)
expect(post.get('worktreeId')).toBe(env.ORCA_WORKTREE_ID)
}
await new Promise<void>((resolve) => server.close(() => resolve()))
expect(await invoke(getManagedCommand(scriptPath), payloads[0])).toMatchObject({
code: 0,
stdout: '',
stderr: '',
timedOut: false
})
rmSync(scriptPath)
expect(await invoke(getManagedCommand(scriptPath), payloads[0])).toMatchObject({
code: 0,
stdout: '',
stderr: '',
timedOut: false
})
expect(posts).toHaveLength(CODEX_EVENTS.length)
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()))
await removeTree(root)
}
},
30_000
)
})

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