diff --git a/.gitignore b/.gitignore
index 6722fc5ae54..8d5ec2c3819 100644
--- a/.gitignore
+++ b/.gitignore
@@ -103,6 +103,7 @@ docs/**
!docs/agent-skill-sharing-implementation-checklist.md
!docs/mobile-terminal-shortcut-bar.md
!docs/reference/
+!docs/reference/agent-session-search.md
!docs/reference/git-compatibility.md
!docs/reference/headless-linux-server.md
!docs/reference/ime-regression-checklist.md
diff --git a/README.md b/README.md
index 7e3540c80f1..b98c42db58b 100644
--- a/README.md
+++ b/README.md
@@ -150,6 +150,8 @@ Agents drive Orca too — script every workflow with `orca worktree create`, `sn
[Docs →](https://www.onorca.dev/docs/cli/overview)
+[Agent-session search across local, paired, and SSH hosts](docs/reference/agent-session-search.md)
+
diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc
index 899914339c7..3b2a3a86977 100644
--- a/config/reliability-gates.jsonc
+++ b/config/reliability-gates.jsonc
@@ -10,6 +10,104 @@
}
},
"gates": [
+ {
+ "id": "agent-session.remote-search-ownership",
+ "title": "Remote search preserves host consent, ownership and bounded partial results",
+ "maturity": "experimental",
+ "protection": "partial",
+ "owner": "agent-session-history",
+ "layer": "cli-runtime-relay-unit-and-process",
+ "surfaces": ["agent-session search", "host index policy"],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["local", "ssh", "remote-runtime"],
+ "coveredPlatforms": ["macos"],
+ "coveredProviders": ["local", "ssh", "remote-runtime"],
+ "coverageNotes": "Synthetic CLI/RPC/provider contracts and real scanner processes run on macOS. Separate live checks exercised authenticated paired orcad and Linux Docker SSH; native Windows/WSL and rendered UI remain unverified.",
+ "motivatingLinks": ["docs/reference/agent-session-search.md"],
+ "invariant": "Targeted search and clear never fall back to an intermediate index; unknown policy and disconnected inventory never become complete empty results; only one relay process can own an index.",
+ "oracle": "Owner-separated five-plus-five results, sixteen-route/three-concurrent budgets, aborted socket closure, legacy targeted-method refusal, and a second scanner unable to clear until the first exits.",
+ "commands": [
+ "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/cli/session-search-all-hosts.test.ts src/cli/runtime/transport.test.ts src/cli/handlers/search.test.ts src/cli/search-interruption.test.ts src/cli/search-command-arguments.test.ts src/main/runtime/runtime-ai-vault-ssh-search.test.ts src/main/runtime/rpc/methods/ai-vault-search.test.ts src/relay/session-search-owner.test.ts src/relay/session-search-owner-progress.test.ts src/relay/session-search-owner-process.test.ts src/shared/ai-vault-search-projection.test.ts",
+ "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/runtime-ai-vault-search-durability.test.ts src/main/ai-vault-search/session-search-source-presence.test.ts src/main/ai-vault-search/session-search-source-refill.test.ts src/main/ai-vault-search/session-search-refresh-lane.test.ts src/main/ai-vault/session-scanner-search-recovery.test.ts src/main/ai-vault/session-scanner-service-env.test.ts"
+ ],
+ "testFiles": [
+ "src/cli/session-search-all-hosts.test.ts",
+ "src/cli/runtime/transport.test.ts",
+ "src/cli/handlers/search.test.ts",
+ "src/cli/search-interruption.test.ts",
+ "src/cli/search-command-arguments.test.ts",
+ "src/main/runtime/runtime-ai-vault-ssh-search.test.ts",
+ "src/main/runtime/rpc/methods/ai-vault-search.test.ts",
+ "src/relay/session-search-owner.test.ts",
+ "src/relay/session-search-owner-progress.test.ts",
+ "src/relay/session-search-owner-process.test.ts",
+ "src/shared/ai-vault-search-projection.test.ts",
+ "src/main/runtime/runtime-ai-vault-search-durability.test.ts",
+ "src/main/ai-vault-search/session-search-source-presence.test.ts",
+ "src/main/ai-vault-search/session-search-source-refill.test.ts",
+ "src/main/ai-vault-search/session-search-refresh-lane.test.ts",
+ "src/main/ai-vault/session-scanner-search-recovery.test.ts",
+ "src/main/ai-vault/session-scanner-service-env.test.ts"
+ ],
+ "assertionRefs": [
+ {
+ "file": "src/cli/session-search-all-hosts.test.ts",
+ "assertions": [
+ "returns five plus five in separate owner groups despite identical session IDs and paths",
+ "queries at most sixteen routes with no more than three simultaneous host legs"
+ ]
+ },
+ {
+ "file": "src/relay/session-search-owner-process.test.ts",
+ "assertions": [
+ "excludes a second scanner process, then releases ownership on owner crash without replaying clear"
+ ]
+ }
+ ],
+ "evidenceRuns": [
+ {
+ "date": "2026-09-07",
+ "runner": "local",
+ "platform": "macos",
+ "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/cli/session-search-all-hosts.test.ts src/cli/runtime/transport.test.ts src/cli/handlers/search.test.ts src/cli/search-interruption.test.ts src/cli/search-command-arguments.test.ts src/main/runtime/runtime-ai-vault-ssh-search.test.ts src/main/runtime/rpc/methods/ai-vault-search.test.ts src/relay/session-search-owner.test.ts src/relay/session-search-owner-progress.test.ts src/relay/session-search-owner-process.test.ts src/shared/ai-vault-search-projection.test.ts",
+ "result": "passed",
+ "durationSeconds": 2.51,
+ "summary": "74 tests passed across eleven files, including slow-pass handoff, queued pause/resume, interruption and process-crash exclusion."
+ },
+ {
+ "date": "2026-09-07",
+ "runner": "local",
+ "platform": "macos",
+ "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/runtime-ai-vault-search-durability.test.ts src/main/ai-vault-search/session-search-source-presence.test.ts src/main/ai-vault-search/session-search-source-refill.test.ts src/main/ai-vault-search/session-search-refresh-lane.test.ts src/main/ai-vault/session-scanner-search-recovery.test.ts src/main/ai-vault/session-scanner-service-env.test.ts",
+ "result": "passed",
+ "durationSeconds": 0.499,
+ "summary": "28 tests passed across six files, including durable policy acknowledgement, common source validation/refill, refresh draining and corrupt scanner recovery."
+ }
+ ],
+ "runtimeBudget": {
+ "p95Seconds": 15,
+ "scope": "synthetic fixtures and scanner-process crash recovery"
+ },
+ "flakeHistory": {
+ "status": "not-started",
+ "evidence": "Initial local verification; no soak claim."
+ },
+ "redGreenEvidence": {
+ "status": "partial",
+ "evidence": "Live paired-orcad immediate restart lost enabled policy before the durable flush fix and preserved it afterward. Repeated CLI filters failed before the parser change. The legacy registry is explicitly exercised for fail-closed routing. A full historical build comparison of the new surface is not claimed."
+ },
+ "performanceBudget": {
+ "required": true,
+ "evidence": "Tests bound queried routes to sixteen, simultaneous legs to three, abort listener/socket cleanup, response bytes, and per-host/inventory deadlines."
+ },
+ "promotionCriteria": ["Collect CI soak history and add native Windows/WSL live coverage."],
+ "knownGaps": [
+ "Live Docker and paired-orcad smoke checks are not part of this automatic unit gate.",
+ "Rendered desktop/web host selection is follow-on scope.",
+ "A different relay generation waits for the active pass or same-owner pause; ten-minute idle retirement can interrupt unfinished discovery."
+ ],
+ "demotionRule": "Keep experimental until reproducible cross-platform and soak evidence exists."
+ },
{
"id": "terminal-performance.padded-fullscreen-redraw",
"title": "Fullscreen redraw padding does not stall terminal delivery",
diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs
index 69c12f0ecc9..1785bdc4f92 100644
--- a/config/scripts/build-orcad.mjs
+++ b/config/scripts/build-orcad.mjs
@@ -106,8 +106,20 @@ function buildForkedChild(entryPoint, outfile) {
}
const childResults = await Promise.all([
+ buildForkedChild(
+ join(ROOT, 'src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts'),
+ join(OUT_DIR, 'session-scanner-opencode-sqlite-worker-entry.js')
+ ),
buildForkedChild(WATCHER_ENTRY, WATCHER_OUT_FILE),
- buildForkedChild(DAEMON_ENTRY, DAEMON_OUT_FILE)
+ buildForkedChild(DAEMON_ENTRY, DAEMON_OUT_FILE),
+ buildForkedChild(
+ join(ROOT, 'src/main/ai-vault/session-scanner-service-entry.ts'),
+ join(OUT_DIR, 'session-scanner-service-entry.js')
+ ),
+ buildForkedChild(
+ join(ROOT, 'src/main/native-chat/wsl-transcript-fs-process-entry.ts'),
+ join(OUT_DIR, 'wsl-transcript-fs-process-entry.js')
+ )
])
const result = await build({
diff --git a/config/scripts/build-relay.mjs b/config/scripts/build-relay.mjs
index 4d408712f97..7d08d93cd74 100644
--- a/config/scripts/build-relay.mjs
+++ b/config/scripts/build-relay.mjs
@@ -116,6 +116,16 @@ for (const platform of RELAY_BUILD_PLATFORMS) {
// manifest check and be hashed into .version, shipping mixed-generation bytes.
rmSync(outDir, { recursive: true, force: true })
mkdirSync(outDir, { recursive: true })
+ await build({
+ entryPoints: [join(ROOT, 'src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts')],
+ bundle: true,
+ platform: 'node',
+ target: 'node18',
+ format: 'cjs',
+ outfile: join(outDir, 'session-scanner-opencode-sqlite-worker-entry.js'),
+ minify: true,
+ logLevel: 'error'
+ })
await build({
entryPoints: [RELAY_ENTRY],
diff --git a/docs/reference/agent-session-search.md b/docs/reference/agent-session-search.md
new file mode 100644
index 00000000000..ee41bf8dd58
--- /dev/null
+++ b/docs/reference/agent-session-search.md
@@ -0,0 +1,118 @@
+# Agent-session search across hosts
+
+```sh
+orca search --agent-session "key phrase"
+orca search --agent-session "key phrase" --host runtime:server
+orca search --agent-session "key phrase" --host ssh:target
+orca search --agent-session "key phrase" --environment server --host ssh:target
+orca search --agent-session "key phrase" --host all --json
+```
+
+The default searches the addressed runtime. `all` includes that runtime, saved
+pairings on the CLI machine, and its already-connected direct SSH targets. When a
+remote environment or pairing is selected, `all` includes only that runtime and
+its connected direct SSH targets. It never recursively enumerates peers, connects
+SSH targets, deploys a relay, or enables indexing. Connection aliases are retained;
+identical paths or session IDs do not establish identical owners.
+
+Results are grouped by host, with each host's ranking, query repairs, and coverage.
+`--limit` is per host (20 by default, at most 100). Five matches on each of two
+hosts return ten hits when budgets permit. Aggregate JSON includes each host's
+outcome and `partial`; a failed or excluded host is distinct from a successful
+query with no matches. The exit status is successful if any host completes its
+query, including a zero-match query. Interrupting returns status 130.
+
+`--agent` and `--path` repeat. Paths are literal predicates evaluated on each
+execution host, with no implicit current-folder restriction. SSH/all require
+absolute host-native paths, including Windows drive/UNC paths. Remote/all do not
+expand `~`. A query's filters do not restrict indexing consent.
+
+## Consent and index lifecycle
+
+```sh
+orca search --enable --history-days 90 --host ssh:target
+orca search --index-status --host ssh:target
+orca search --pause --host ssh:target
+orca search --resume-indexing --host ssh:target
+orca search --disable --clear-index --host ssh:target
+```
+
+Management always selects one host. Status cannot be combined with a query or
+mutation; contradictory flags and invalid filters are rejected before mutation.
+Clear with indexing enabled rebuilds the index. Disable plus clear removes it.
+Pause retains searchable data while stopping new indexing.
+
+Paired runtimes use their existing settings, scanner child, and canonical
+`ai-vault-search/index.sqlite` under their data directory. Standalone `orcad`
+ships the same scanner and its sibling workers. Availability checks use an
+in-memory FTS5 probe, never transcript discovery. Node.js 22.13+ with `node:sqlite`
+and FTS5 is required; unsupported runtimes keep ordinary runtime operations and
+report search unavailable. Failed policy application is exposed as unapplied,
+and explicit configuration does not acknowledge success before application and
+the runtime's durable settings flush.
+
+Plain SSH runs the existing search service inside the relay's existing low-priority
+scanner child. Its account-local state lives in `~/.orca/session-search-relay`,
+outside versioned relay install directories, separately from runtime profiles.
+Consent defaults off and is bound to the authenticated account's default source
+home. The scanner also includes the existing remote managed Codex home. Controller
+environment variables and arbitrary client-supplied discovery roots are not used.
+
+A separate stable SQLite database holds an exclusive transaction while a relay
+owns the index. Other owners fail explicitly; SQLite releases the lock on process
+exit. Policy is atomically persisted under that lock. After five seconds the owner
+releases the index when its active requests and backfill pass finish. It does not
+abort discovery or parsing to hand off: doing so can permanently starve a slow
+source tree. Search, status and pause remain responsive through the same owner;
+a different relay generation receives an explicit busy error until the pass
+finishes or the current owner pauses. This deliberately favors a single complete
+pass over periodic teardown and rediscovery. Reacquisition rereads authoritative
+policy. Scanner retirement after ten idle minutes pauses work; a later search
+resumes it, skipping files already current in the durable index.
+
+Returned files are checked on the execution host for local, paired and SSH search. Confirmed missing files are
+invalidated; unverifiable files are omitted with an explicit count. Raw resume
+commands are labeled with the execution host and working directory. Search does
+not add a cross-host resume/delete command or infer workspace identity from a
+path. Provider coverage remains that of the existing indexer, including its
+unindexed providers.
+OpenCode rows are checked individually, so deleting or archiving one session in a
+shared database does not remove the other sessions or leave the deleted hit visible.
+
+## Routing, compatibility, and budgets
+
+The controlling runtime forwards `aiVault.sshSearchSessions`,
+`aiVault.sshSearchIndexStatus`, and `aiVault.sshSearchConfigure` through its
+registered SSH provider's existing `requestHostRpc` and relay multiplexer.
+Distinct targeted methods ensure an older runtime rejects the request instead
+of stripping a target field and searching or clearing its own index. Existing
+runtime `executionHostId` remains a label. Aggregate search requires affirmative
+policy evidence; absent legacy policy is unknown and is excluded.
+
+The CLI queries at most 16 routes with concurrency three, a 15-second host budget,
+a 30-second overall deadline, and a three-second SSH inventory budget. New hosts
+project replies to 512 KiB, snippets to 4 KiB, and the aggregate to 4 MiB, reporting
+omissions. Cancellation travels through the existing RPC, provider, relay and
+scanner cancellation paths. Already-sent mutations are not replayed after lost
+acknowledgement. Synchronous SQLite work cannot be interrupted mid-statement.
+
+## Verification
+
+```sh
+ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts \
+ src/cli/session-search-all-hosts.test.ts \
+ src/main/runtime/runtime-ai-vault-ssh-search.test.ts \
+ src/main/runtime/rpc/methods/ai-vault-search.test.ts \
+ src/relay/session-search-owner.test.ts \
+ src/relay/session-search-owner-process.test.ts \
+ src/shared/ai-vault-search-projection.test.ts
+```
+
+The fixtures cover owner-separated results with colliding IDs, unknown consent,
+stalled inventory, cancellation, mixed-version refusal, lease release, a real
+scanner-process crash, durable consent, clear, missing transcripts, and byte limits.
+Live validation also exercised the built CLI against an isolated authenticated
+paired `orcad`, and a registered provider through real authenticated Docker SSH,
+relay and scanner. The latter retained its relay PID and a test PTY through clear.
+Native Windows/WSL and rendered desktop/web selection UI require separate live
+coverage; this implementation adds the CLI surface.
diff --git a/src/cli/agent-session-search-format.ts b/src/cli/agent-session-search-format.ts
index 5d1f3a3c0b0..72caa0c8a55 100644
--- a/src/cli/agent-session-search-format.ts
+++ b/src/cli/agent-session-search-format.ts
@@ -2,7 +2,6 @@ import {
stripAnsiEscapeSequences,
TERMINAL_CONTROL_CHARACTER_PATTERN
} from '../shared/ansi-escape-sequences'
-import { basename } from 'node:path'
import type { AiVaultSearchIndexStatus } from '../shared/ai-vault-search-settings'
import { aiVaultAgentLabel } from '../shared/ai-vault-types'
import { aiVaultSearchUnindexedProviders } from '../shared/ai-vault-search-coverage'
@@ -41,43 +40,54 @@ function relativeAge(iso: string | null, now = Date.now()): string {
}
function projectLabel(hit: AiVaultSearchHit): string {
- const cwd = hit.cwd ? basename(hit.cwd) : '—'
+ const cwd = hit.cwd ? (hit.cwd.replaceAll('\\', '/').split('/').findLast(Boolean) ?? '—') : '—'
return hit.branch ? `${cwd} · ${hit.branch}` : cwd
}
// Why: transcript text reaches the terminal verbatim; an OSC 52 or cursor
// sequence inside a tool log would otherwise execute on the user's terminal.
-function terminalSafe(value: string): string {
+export function terminalSafe(value: string): string {
return stripAnsiEscapeSequences(value).replace(TERMINAL_CONTROL_CHARACTER_PATTERN, '')
}
-function formatHit(index: number, hit: AiVaultSearchHit): string {
+function formatHit(index: number, hit: AiVaultSearchHit, owner?: string): string {
const header = `${String(index + 1).padStart(2)}. ${terminalSafe(hit.title)}`
const meta = `${aiVaultAgentLabel(hit.agent)} · ${terminalSafe(projectLabel(hit))} · ${relativeAge(hit.updatedAt)}`
const evidence = hit.evidence.snippet
? ` ${ROLE_LABEL[hit.evidence.role]} ▸ ${terminalSafe(hit.evidence.snippet).replaceAll('\n', ' ')}`
: null
- const resume = ` resume: ${terminalSafe(hit.resumeCommand)}${hit.cwd ? ` (cwd ${terminalSafe(hit.cwd)})` : ''}`
+ const resume = ` ${owner ? `run on ${terminalSafe(owner)}` : 'resume'}: ${terminalSafe(hit.resumeCommand)}${hit.cwd ? ` (cwd ${terminalSafe(hit.cwd)})` : ''}`
return [`${header} ${meta}`, evidence, resume].filter(Boolean).join('\n')
}
export function formatAgentSessionSearch(
result: AiVaultSearchResult,
- context: { query: string; cwd: string }
+ context: { query: string; cwd: string; owner?: string }
): string {
const lines: string[] = []
+ if (result.sourceUnavailableFiles) {
+ lines.push(
+ `${result.sourceUnavailableFiles} source files could not be verified; their hits are omitted.`
+ )
+ }
+ if (result.omittedHits) {
+ lines.push(`${result.omittedHits} hits omitted by the response limit.`)
+ }
+ if (result.truncatedSnippets) {
+ lines.push(`${result.truncatedSnippets} snippets shortened.`)
+ }
if (result.hits.length === 0) {
- lines.push(`No sessions match "${context.query}".`)
+ lines.push(`No sessions match "${terminalSafe(context.query)}".`)
} else {
- lines.push(...result.hits.map((hit, index) => formatHit(index, hit)), '')
+ lines.push(...result.hits.map((hit, index) => formatHit(index, hit, context.owner)), '')
}
if (result.repairedTerms) {
- lines.push(`Searched for: ${result.repairedTerms.join(' ')}`)
+ lines.push(`Searched for: ${terminalSafe(result.repairedTerms.join(' '))}`)
}
const { coverage } = result
const scope = `${coverage.sessionsIndexed.toLocaleString()} sessions indexed`
const pending =
- coverage.backfill === 'running'
+ coverage.backfill !== 'complete'
? ', still indexing older sessions'
: coverage.filesPending > 0
? `, ${coverage.filesPending} changed files pending`
@@ -99,6 +109,18 @@ export function formatAgentSessionSearchEnabled(status: AiVaultSearchIndexStatus
: `the last ${status.historyDays.toLocaleString()} days`
return [
`Session search is on for ${scope}.`,
- 'Indexing runs in the background; searches answer from what is covered so far.'
+ status.paused
+ ? 'Indexing is paused; existing data remains searchable.'
+ : 'Indexing runs in the background; searches answer from what is covered so far.',
+ `Index size: ${status.indexSizeBytes === null ? 'no index file' : `${status.indexSizeBytes} bytes`}.`
].join('\n')
}
+
+export function formatAgentSessionSearchStatus(status: AiVaultSearchIndexStatus): string {
+ if (status.available === false || status.applied === false) {
+ return `Session search is unavailable: ${terminalSafe(status.reason ?? 'index policy is not applied')}. Saved policy: ${status.enabled ? 'on' : 'off'}.`
+ }
+ return status.enabled
+ ? formatAgentSessionSearchEnabled(status)
+ : `Session search is off. Retention: ${status.historyDays === null ? 'all history' : `${status.historyDays} days`}. Index size: ${status.indexSizeBytes ?? 0} bytes.`
+}
diff --git a/src/cli/args.ts b/src/cli/args.ts
index a934915655e..e81a8a47dd6 100644
--- a/src/cli/args.ts
+++ b/src/cli/args.ts
@@ -24,9 +24,17 @@ 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, name: string, value: string): void {
+function setFlagValue(
+ flags: Map,
+ name: string,
+ value: string,
+ search = false
+): void {
const existing = flags.get(name)
- if (typeof existing === 'string' && REPEATABLE_STRING_FLAGS.has(name)) {
+ if (
+ typeof existing === 'string' &&
+ (REPEATABLE_STRING_FLAGS.has(name) || (search && (name === 'agent' || name === 'path')))
+ ) {
flags.set(name, `${existing}${REPEATED_FLAG_SEPARATOR}${value}`)
return
}
@@ -51,12 +59,23 @@ 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))
+ setFlagValue(
+ flags,
+ assignment.slice(0, equalsIndex),
+ assignment.slice(equalsIndex + 1),
+ (argv[commandIndex] ?? commandPath[0]) === 'search'
+ )
continue
}
const flag = assignment
- if (BOOLEAN_FLAGS.has(flag)) {
+ if (
+ BOOLEAN_FLAGS.has(flag) ||
+ ((argv[commandIndex] ?? commandPath[0]) === 'search' &&
+ ['enable', 'disable', 'clear-index', 'index-status', 'pause', 'resume-indexing'].includes(
+ flag
+ ))
+ ) {
flags.set(flag, true)
continue
}
@@ -71,7 +90,7 @@ export function parseArgs(argv: string[], commandPaths?: readonly string[][]): P
flags.set(flag, true)
continue
}
- setFlagValue(flags, flag, next)
+ setFlagValue(flags, flag, next, (argv[commandIndex] ?? commandPath[0]) === 'search')
i += 1
}
diff --git a/src/cli/handlers/search.test.ts b/src/cli/handlers/search.test.ts
index 41321e6f38f..84e132b0f1d 100644
--- a/src/cli/handlers/search.test.ts
+++ b/src/cli/handlers/search.test.ts
@@ -72,7 +72,11 @@ describe('orca search --agent-session', () => {
it('sends the query with relevance sort by default', async () => {
await runSearch({ 'agent-session': 'strict mode violation' })
- expect(callMock).toHaveBeenCalledWith('aiVault.searchSessions', expect.any(Object))
+ expect(callMock).toHaveBeenCalledWith(
+ 'aiVault.searchSessions',
+ expect.any(Object),
+ expect.objectContaining({ signal: expect.any(AbortSignal), timeoutMs: expect.any(Number) })
+ )
expect(searchParams()).toMatchObject({ query: 'strict mode violation', sort: 'relevance' })
expect(searchParams().executionHostId).toBeUndefined()
expect(logSpy).toHaveBeenCalledTimes(1)
@@ -157,14 +161,17 @@ describe('orca search --agent-session', () => {
expect(searchParams().since).toBe('2026-07-31T22:00:00.000Z')
})
- it('rejects an ssh host because the index lives with the transcripts', async () => {
- const error = await runSearch({ 'agent-session': 'q', host: 'ssh:dev-box' }).catch(
- (caught: unknown) => caught
+ it('resolves SSH labels and invokes only the targeted host method', async () => {
+ callMock.mockResolvedValueOnce({
+ result: { targets: [{ id: 'ssh-1', label: 'dev-box', connected: true }] }
+ })
+ await runSearch({ 'agent-session': 'q', host: 'ssh:dev-box' })
+ expect(callMock).toHaveBeenLastCalledWith(
+ 'aiVault.sshSearchSessions',
+ expect.objectContaining({ targetId: 'ssh-1', query: 'q' }),
+ expect.objectContaining({ signal: expect.any(AbortSignal) })
)
- expect(error).toBeInstanceOf(RuntimeClientError)
- expect((error as RuntimeClientError).code).toBe('invalid_argument')
- expect((error as Error).message).toMatch(/runtime host/)
- expect(callMock).not.toHaveBeenCalled()
+ expect(callMock.mock.calls.map(([method]) => method)).not.toContain('aiVault.searchSessions')
})
it('forwards a runtime host id', async () => {
@@ -227,7 +234,11 @@ describe('orca search --agent-session with the index turned off', () => {
await runSearch({ enable: true })
- expect(callMock).toHaveBeenCalledWith('aiVault.configureSessionSearch', { enabled: true })
+ expect(callMock).toHaveBeenCalledWith(
+ 'aiVault.configureSessionSearch',
+ { enabled: true },
+ expect.objectContaining({ signal: expect.any(AbortSignal) })
+ )
expect(logSpy.mock.calls[0]?.[0] as string).toContain(
'Session search is on for the last 90 days.'
)
@@ -261,9 +272,13 @@ describe('orca search --agent-session with the index turned off', () => {
await runSearch({ enable: true, host: 'runtime:env-1' })
- expect(callMock).toHaveBeenCalledWith('aiVault.configureSessionSearch', {
- enabled: true,
- executionHostId: 'runtime:env-1'
- })
+ expect(callMock).toHaveBeenCalledWith(
+ 'aiVault.configureSessionSearch',
+ {
+ enabled: true,
+ executionHostId: 'runtime:env-1'
+ },
+ expect.objectContaining({ signal: expect.any(AbortSignal) })
+ )
})
})
diff --git a/src/cli/handlers/search.ts b/src/cli/handlers/search.ts
index 6bda030c7b4..1f71eea5991 100644
--- a/src/cli/handlers/search.ts
+++ b/src/cli/handlers/search.ts
@@ -1,114 +1,153 @@
import type { CommandHandler } from '../dispatch'
import { printResult } from '../format'
-import {
- getOptionalPositiveIntegerFlag,
- getOptionalStringFlag,
- getRepeatedStringFlag
-} from '../flags'
-import { parseHostFlag } from '../execution-host-flag'
import { RuntimeClientError } from '../runtime/types'
-import { AI_VAULT_AGENTS, type AiVaultAgent } from '../../shared/ai-vault-types'
-import type { AiVaultSearchHit, AiVaultSearchResult } from '../../shared/ai-vault-search-types'
+import { parseSearchCommand } from '../search-command-arguments'
import {
formatAgentSessionSearch,
- formatAgentSessionSearchEnabled
+ formatAgentSessionSearchStatus,
+ terminalSafe
} from '../agent-session-search-format'
-import { isAiVaultSearchDisabled } from '../../shared/ai-vault-search-coverage'
-import type { AiVaultSearchIndexStatus } from '../../shared/ai-vault-search-settings'
-
-function parseAgents(flags: Map): AiVaultAgent[] | undefined {
- const values = getRepeatedStringFlag(flags, 'agent')
- if (values.length === 0) {
- return undefined
- }
- const agents: AiVaultAgent[] = []
- for (const value of values) {
- const lowered = value.toLowerCase()
- if (!(AI_VAULT_AGENTS as readonly string[]).includes(lowered)) {
- throw new RuntimeClientError(
- 'invalid_argument',
- `Unknown --agent ${value}. Expected one of: ${AI_VAULT_AGENTS.join(', ')}.`
- )
- }
- agents.push(lowered as AiVaultAgent)
- }
- return agents
-}
-
-const ISO_8601 =
- /^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/
-
-/** `~` and `~/x` are the home directory; `~other/x` is left for the host to resolve. */
-function expandHomePath(value: string): string {
- const home = process.env.HOME
- if (!home || (value !== '~' && !value.startsWith('~/'))) {
- return value
- }
- return `${home}${value.slice(1)}`
-}
-
-function parseSince(flags: Map): string | undefined {
- const value = getOptionalStringFlag(flags, 'since')
- if (value === undefined) {
- return undefined
- }
- // Why: Date.parse also accepts `08/01/2026` and RFC 2822; the flag documents ISO 8601.
- const parsed = ISO_8601.test(value) ? Date.parse(value) : Number.NaN
- if (!Number.isFinite(parsed)) {
- throw new RuntimeClientError('invalid_argument', '--since must be an ISO 8601 timestamp.')
- }
- return new Date(parsed).toISOString()
-}
+import {
+ SessionSearchResultSchema,
+ SessionSearchStatusSchema
+} from '../../shared/ai-vault-search-contract'
+import { listSshTargets, findSshTargetByName } from '../host-selector-alternatives'
+import { searchAllHosts } from '../session-search-all-hosts'
+import {
+ searchHostMethod,
+ SEARCH_ALL_TIMEOUT_MS,
+ type SearchHost
+} from '../session-search-host-query'
+import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason'
export const SEARCH_DISABLED_MESSAGE =
'Session search is off. Enable it in Settings > Agent Session History, or run `orca search --agent-session --enable`.'
export const SEARCH_HANDLERS: Record = {
search: async ({ client, flags, json, cwd }) => {
- const query = getOptionalStringFlag(flags, 'agent-session')
- const enable = flags.get('enable') === true
- const host = parseHostFlag(flags)
- if (host?.kind === 'ssh') {
- throw new RuntimeClientError(
- 'invalid_argument',
- 'Agent session search runs on a runtime host. Use --host runtime: or omit --host.'
- )
+ const command = parseSearchCommand(flags, client.isRemote)
+ const controller = new AbortController()
+ const deadline = Date.now() + SEARCH_ALL_TIMEOUT_MS
+ const timer = setTimeout(
+ () => controller.abort(new Error('Search deadline exceeded.')),
+ SEARCH_ALL_TIMEOUT_MS
+ )
+ let interrupted = false
+ const interrupt = (): void => {
+ interrupted = true
+ process.exitCode = 130
+ controller.abort(new Error('Search interrupted.'))
}
- const hostParams = host?.kind === 'runtime' ? { executionHostId: host.id } : {}
- if (enable) {
- const status = await client.call('aiVault.configureSessionSearch', {
- enabled: true,
- ...hostParams
- })
- if (!query) {
- printResult(status, json, formatAgentSessionSearchEnabled)
+ process.once('SIGINT', interrupt)
+ const options = (): { signal: AbortSignal; timeoutMs: number } => ({
+ signal: controller.signal,
+ timeoutMs: Math.max(1, deadline - Date.now())
+ })
+ try {
+ if (command.host === 'all') {
+ const result = await searchAllHosts(client, command, controller.signal, deadline)
+ printResult(
+ { id: 'search-all', ok: true, result, _meta: { runtimeId: 'client' } },
+ json,
+ (value) =>
+ value.hosts
+ .map((entry) =>
+ [
+ `${terminalSafe(entry.host.name)} (${terminalSafe(entry.host.selector || 'current runtime')}) — ${entry.outcome}`,
+ entry.result
+ ? formatAgentSessionSearch(entry.result, {
+ query: command.query!.query,
+ cwd,
+ owner: entry.host.name
+ })
+ : terminalSafe(entry.message ?? '')
+ ].join('\n')
+ )
+ .join('\n\n') +
+ (value.omittedHosts ? `\n${value.omittedHosts} additional hosts omitted.` : '')
+ )
+ if (!result.hosts.some((host) => host.outcome === 'searched') && process.exitCode !== 130) {
+ process.exitCode = 1
+ }
return
}
- }
- if (!query) {
- throw new RuntimeClientError(
- 'invalid_argument',
- 'Missing --agent-session . Example: orca search --agent-session "strict mode violation"'
+ const host: SearchHost = {
+ id: command.host?.id ?? 'local',
+ name: command.host?.id ?? (client.isRemote ? 'selected runtime' : 'this runtime'),
+ selector: '',
+ client
+ }
+ if (command.host?.kind === 'ssh') {
+ const targets = await listSshTargets(client, {
+ strict: true,
+ signal: controller.signal,
+ deadline
+ })
+ const target = findSshTargetByName(targets, command.host.targetId)
+ if (!target) {
+ throw new RuntimeClientError('invalid_argument', 'Unknown or ambiguous SSH target.')
+ }
+ if (target.connected !== true) {
+ throw new RuntimeClientError(
+ 'runtime_unavailable',
+ 'SSH target is not known to be connected.'
+ )
+ }
+ host.targetId = target.id
+ host.name = target.label
+ }
+ const target = host.targetId
+ ? { targetId: host.targetId }
+ : command.host?.kind === 'runtime'
+ ? { executionHostId: command.host.id }
+ : {}
+ const call = (operation: 'query' | 'status' | 'configure', params: object) =>
+ waitForPromiseWithSignal(
+ client.call(searchHostMethod(host, operation), { ...params, ...target }, options()),
+ controller.signal
+ )
+ if (command.configure || command.status) {
+ const response = await call(
+ command.configure ? 'configure' : 'status',
+ command.configure ?? {}
+ )
+ const status = SessionSearchStatusSchema.parse(response.result)
+ if (command.configure && (status.available === false || status.applied === false)) {
+ throw new RuntimeClientError(
+ 'failed_precondition',
+ status.reason ?? 'Search policy is unavailable or not applied.'
+ )
+ }
+ if (!command.query) {
+ printResult({ ...response, result: status }, json, formatAgentSessionSearchStatus)
+ return
+ }
+ }
+ const response = await call('query', command.query!)
+ const result = SessionSearchResultSchema.parse(response.result)
+ if (result.coverage.enabled === false) {
+ throw new RuntimeClientError('failed_precondition', SEARCH_DISABLED_MESSAGE, {
+ disabled: true
+ })
+ }
+ printResult({ ...response, result }, json, (value) =>
+ formatAgentSessionSearch(value, {
+ query: command.query!.query,
+ cwd,
+ ...(host.targetId || client.isRemote || host.id.startsWith('runtime:')
+ ? { owner: host.name }
+ : {})
+ })
)
+ } catch (error) {
+ if (!interrupted) {
+ throw error
+ }
+ } finally {
+ clearTimeout(timer)
+ process.removeListener('SIGINT', interrupt)
}
- const scopePaths = getRepeatedStringFlag(flags, 'path').map(expandHomePath)
- const result = await client.call('aiVault.searchSessions', {
- query,
- limit: getOptionalPositiveIntegerFlag(flags, 'limit'),
- agents: parseAgents(flags),
- scopePaths: scopePaths.length > 0 ? scopePaths : undefined,
- since: parseSince(flags),
- sort: flags.get('newest') === true ? 'newest' : 'relevance',
- ...hostParams
- })
- if (isAiVaultSearchDisabled(result.result.coverage)) {
- throw new RuntimeClientError('failed_precondition', SEARCH_DISABLED_MESSAGE, {
- disabled: true,
- nextSteps: ['orca search --agent-session --enable']
- })
- }
- printResult(result, json, (value) => formatAgentSessionSearch(value, { query, cwd }))
}
}
-export type { AiVaultSearchHit }
+export type { AiVaultSearchHit } from '../../shared/ai-vault-search-types'
diff --git a/src/cli/host-selector-alternatives.ts b/src/cli/host-selector-alternatives.ts
index f42fec88aee..17cbf11972b 100644
--- a/src/cli/host-selector-alternatives.ts
+++ b/src/cli/host-selector-alternatives.ts
@@ -1,4 +1,6 @@
import type { RuntimeClient } from './runtime-client'
+import { mapWithConcurrency } from '../shared/map-with-concurrency'
+import { z } from 'zod'
export type SshTargetSummary = {
id: string
@@ -101,52 +103,111 @@ export function crossKindNextSteps(
// Why: only display identity crosses this boundary — the RPC deliberately withholds addresses
// and credentials — and an enumeration failure must never mask the error we are explaining.
-export async function listSshTargets(client: RuntimeClient): Promise {
+export type SshInventoryOptions = { strict?: boolean; signal?: AbortSignal; deadline?: number }
+
+function inventoryCallOptions(options?: SshInventoryOptions): {
+ signal?: AbortSignal
+ timeoutMs?: number
+} {
+ if (options?.signal?.aborted) {
+ throw options.signal.reason
+ }
+ const remaining = options?.deadline === undefined ? undefined : options.deadline - Date.now()
+ if (remaining !== undefined && remaining <= 0) {
+ throw new Error('SSH inventory deadline exceeded.')
+ }
+ return { signal: options?.signal, timeoutMs: remaining }
+}
+
+const SshTargetSummariesSchema = z
+ .array(
+ z.object({
+ id: z.string().min(1).max(512),
+ label: z.string().max(512),
+ connected: z.boolean().optional(),
+ connectionStatus: z.string().max(128).optional(),
+ remotePlatform: z.enum(['linux', 'darwin', 'win32']).optional()
+ })
+ )
+ .max(4096)
+
+export async function listSshTargets(
+ client: RuntimeClient,
+ options?: SshInventoryOptions
+): Promise {
try {
- const result = await client.call<{ targets: SshTargetSummary[] }>('ssh.listTargetSummaries')
- return result.result.targets
+ const result = await client.call<{ targets: SshTargetSummary[] }>(
+ 'ssh.listTargetSummaries',
+ ...(options ? ([undefined, inventoryCallOptions(options)] as const) : [])
+ )
+ return options?.strict
+ ? SshTargetSummariesSchema.parse(result.result.targets)
+ : result.result.targets
} catch (error) {
// Why: hosts predating listTargetSummaries still answer listTargets, and both are served by
// the same summariser. Without this an old host looks like one with no SSH targets at all,
// which would reject a target id that is actually valid there.
if (error instanceof Error && 'code' in error && error.code === 'method_not_found') {
try {
- const legacy = await client.call<{ targets: SshTargetSummary[] }>('ssh.listTargets')
- return await enrichLegacySshTargetStates(client, legacy.result.targets)
- } catch {
+ const legacy = await client.call<{ targets: SshTargetSummary[] }>(
+ 'ssh.listTargets',
+ ...(options ? ([undefined, inventoryCallOptions(options)] as const) : [])
+ )
+ return await enrichLegacySshTargetStates(
+ client,
+ options?.strict
+ ? SshTargetSummariesSchema.parse(legacy.result.targets)
+ : legacy.result.targets,
+ options
+ )
+ } catch (legacyError) {
+ if (options?.strict) {
+ throw legacyError
+ }
return []
}
}
+ if (options?.strict) {
+ throw error
+ }
return []
}
}
async function enrichLegacySshTargetStates(
client: RuntimeClient,
- targets: SshTargetSummary[]
+ targets: SshTargetSummary[],
+ options?: SshInventoryOptions
): Promise {
- return Promise.all(
- targets.map(async (target) => {
- try {
- const response = await client.call<{
- state: {
- status?: string
- remotePlatform?: 'linux' | 'darwin' | 'win32'
- } | null
- }>('ssh.getState', { targetId: target.id })
- const state = response.result.state
- return {
- ...target,
- ...(state?.status === undefined
- ? {}
- : { connected: state.status === 'connected', connectionStatus: state.status }),
- ...(state?.remotePlatform === undefined ? {} : { remotePlatform: state.remotePlatform })
- }
- } catch {
- return target
+ return mapWithConcurrency(targets, 3, async (target) => {
+ if (
+ options?.signal?.aborted ||
+ (options?.deadline !== undefined && Date.now() >= options.deadline)
+ ) {
+ return { ...target, connected: undefined, connectionStatus: 'unknown' }
+ }
+ try {
+ const response = await client.call<{
+ state: { status?: string; remotePlatform?: 'linux' | 'darwin' | 'win32' } | null
+ }>(
+ 'ssh.getState',
+ { targetId: target.id },
+ ...(options ? ([inventoryCallOptions(options)] as const) : [])
+ )
+ const state = response.result.state
+ return {
+ ...target,
+ ...(state?.status !== undefined
+ ? { connected: state.status === 'connected', connectionStatus: state.status }
+ : options?.strict
+ ? { connected: undefined, connectionStatus: 'unknown' }
+ : {}),
+ ...(state?.remotePlatform ? { remotePlatform: state.remotePlatform } : {})
}
- })
- )
+ } catch {
+ return { ...target, connected: undefined, connectionStatus: 'unknown' }
+ }
+ })
}
// Why: `--host ssh:` was never validated, so an unknown target answered ok:true with an
diff --git a/src/cli/index.ts b/src/cli/index.ts
index b5e182dd1f4..bcc1502226d 100644
--- a/src/cli/index.ts
+++ b/src/cli/index.ts
@@ -126,20 +126,22 @@ export async function main(
// retargeting a mutation to another server is the bug this flag already had.
// An ambient pairing code cannot be resolved to an id to compare, so the
// explicit flag simply wins there.
- const hostEnvironmentId = ignoreRemoteSelection
- ? null
- : await resolveHostFlagEnvironmentId(parsed.flags, {
- // Why: only consulted when the name missed, and against this machine's own runtime —
- // SSH targets are registered there, not in the paired server we failed to find.
- listSshTargets: listSshTargetsForSuggestion,
- pairingCode: typeof pairingCode === 'string' ? pairingCode : null,
- environmentSelector:
- typeof environmentSelector === 'string'
- ? { value: environmentSelector, label: '--environment' }
- : process.env.ORCA_ENVIRONMENT
- ? { value: process.env.ORCA_ENVIRONMENT, label: 'ORCA_ENVIRONMENT' }
- : null
- })
+ const hostEnvironmentId =
+ ignoreRemoteSelection ||
+ (parsed.commandPath[0] === 'search' && parsed.flags.get('host') === 'all')
+ ? null
+ : await resolveHostFlagEnvironmentId(parsed.flags, {
+ // Why: only consulted when the name missed, and against this machine's own runtime —
+ // SSH targets are registered there, not in the paired server we failed to find.
+ listSshTargets: listSshTargetsForSuggestion,
+ pairingCode: typeof pairingCode === 'string' ? pairingCode : null,
+ environmentSelector:
+ typeof environmentSelector === 'string'
+ ? { value: environmentSelector, label: '--environment' }
+ : process.env.ORCA_ENVIRONMENT
+ ? { value: process.env.ORCA_ENVIRONMENT, label: 'ORCA_ENVIRONMENT' }
+ : null
+ })
// Why: --host runtime: is canonicalized to the environment's id so downstream host-id
// comparisons against stored rows still match; rewrite the flag once, here, rather than
// resolving the name again at every consumer.
diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts
index 68a099ff2f2..18b2ec252fd 100644
--- a/src/cli/runtime/client.ts
+++ b/src/cli/runtime/client.ts
@@ -81,11 +81,16 @@ export class RuntimeClient {
return this.remotePairing !== null
}
+ get selectedEnvironment(): string | null {
+ return this.environmentSelector
+ }
+
async call(
method: string,
params?: unknown,
options?: {
timeoutMs?: number
+ signal?: AbortSignal
legacyTerminalPrompt?: true
terminalPromptPreflight?: { runtimeId: string | null }
} & RuntimeOrchestrationEnvelope
@@ -146,7 +151,8 @@ export class RuntimeClient {
method,
params,
timeoutMs: effectiveTimeoutMs,
- envelope
+ envelope,
+ ...(options?.signal ? { signal: options.signal } : {})
})
} catch (error) {
throw recover(error, null)
@@ -164,7 +170,14 @@ export class RuntimeClient {
const metadata = readMetadata(this.userDataPath)
let response
try {
- response = await sendRequest(metadata, method, params, effectiveTimeoutMs, envelope)
+ response = await sendRequest(
+ metadata,
+ method,
+ params,
+ effectiveTimeoutMs,
+ envelope,
+ ...(options?.signal ? [options.signal] : [])
+ )
} catch (error) {
throw recover(error, metadata.runtimeId ?? null)
}
diff --git a/src/cli/runtime/remote-runtime-compat-gate.ts b/src/cli/runtime/remote-runtime-compat-gate.ts
index a711a475e39..22c2cd962b3 100644
--- a/src/cli/runtime/remote-runtime-compat-gate.ts
+++ b/src/cli/runtime/remote-runtime-compat-gate.ts
@@ -33,6 +33,7 @@ export class RemoteRuntimeCompatGate {
params: unknown
timeoutMs: number
envelope?: RuntimeOrchestrationEnvelope
+ signal?: AbortSignal
}): Promise> {
if (this.checked || args.method === 'status.get') {
return args.transport.sendWebSocketRequest(
@@ -40,7 +41,8 @@ export class RemoteRuntimeCompatGate {
args.method,
args.params,
args.timeoutMs,
- args.envelope
+ args.envelope,
+ ...(args.signal ? [args.signal] : [])
)
}
return args.transport.sendWebSocketRequestWithStatusPreflight(
@@ -59,7 +61,8 @@ export class RemoteRuntimeCompatGate {
})
}
},
- args.envelope
+ args.envelope,
+ ...(args.signal ? [args.signal] : [])
)
}
diff --git a/src/cli/runtime/transport.test.ts b/src/cli/runtime/transport.test.ts
index 4d713c842a4..86015bf6a4a 100644
--- a/src/cli/runtime/transport.test.ts
+++ b/src/cli/runtime/transport.test.ts
@@ -1,4 +1,4 @@
-import { mkdtempSync } from 'node:fs'
+import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createServer, type Socket } from 'node:net'
@@ -50,6 +50,78 @@ describe('runtime transport timeout validation', () => {
// Why: these tests create Unix domain socket servers in temp directories.
// Windows does not support Unix domain sockets in the same way.
describe.skipIf(process.platform === 'win32')('runtime transport', () => {
+ it('cancellation closes a dispatched search socket instead of only discarding its response', async () => {
+ const directory = mkdtempSync(join(tmpdir(), 'orca-search-cancel-'))
+ const endpoint = join(directory, 'runtime.sock')
+ const controller = new AbortController()
+ let closed!: () => void
+ const socketClosed = new Promise((resolve) => {
+ closed = resolve
+ })
+ const server = createServer((socket) => {
+ sockets.add(socket)
+ socket.once('close', () => {
+ sockets.delete(socket)
+ closed()
+ })
+ socket.once('data', () => controller.abort(new Error('search cancelled')))
+ })
+ servers.add(server)
+ await new Promise((resolve) => server.listen(endpoint, resolve))
+ try {
+ await expect(
+ sendRequest(
+ {
+ runtimeId: 'test',
+ pid: 1,
+ transports: [{ kind: 'unix', endpoint }],
+ authToken: 'fixture',
+ startedAt: 1
+ },
+ 'aiVault.searchSessions',
+ { query: 'fixture' },
+ 30_000,
+ undefined,
+ controller.signal
+ )
+ ).rejects.toThrow('search cancelled')
+ await socketClosed
+ expect(sockets.size).toBe(0)
+ } finally {
+ rmSync(directory, { recursive: true, force: true })
+ }
+ })
+
+ it('rejects an oversized search frame before buffering the complete response', async () => {
+ const directory = mkdtempSync(join(tmpdir(), 'orca-search-size-'))
+ const endpoint = join(directory, 'runtime.sock')
+ const server = createServer((socket) => {
+ sockets.add(socket)
+ socket.on('error', () => undefined)
+ socket.once('close', () => sockets.delete(socket))
+ socket.once('data', () => socket.write(Buffer.alloc(4 * 1024 * 1024 + 1, 'a')))
+ })
+ servers.add(server)
+ await new Promise((resolve) => server.listen(endpoint, resolve))
+ try {
+ await expect(
+ sendRequest(
+ {
+ runtimeId: 'test',
+ pid: 1,
+ transports: [{ kind: 'unix', endpoint }],
+ authToken: 'fixture',
+ startedAt: 1
+ },
+ 'aiVault.searchSessions',
+ { query: 'fixture' },
+ 30_000
+ )
+ ).rejects.toMatchObject({ code: 'invalid_runtime_response' })
+ } finally {
+ rmSync(directory, { recursive: true, force: true })
+ }
+ })
it('refreshes the per-call timeout when the runtime sends keepalive frames', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-transport-'))
const endpoint = join(userDataPath, 'runtime.sock')
diff --git a/src/cli/runtime/transport.ts b/src/cli/runtime/transport.ts
index 4e0d0a15ec7..92b659196ea 100644
--- a/src/cli/runtime/transport.ts
+++ b/src/cli/runtime/transport.ts
@@ -1,3 +1,4 @@
+import { abortSignalReason, throwIfSignalAborted } from '../../shared/abort-signal-reason'
import { createConnection } from 'node:net'
import { randomUUID } from 'node:crypto'
import { findTransport, type RuntimeMetadata } from '../../shared/runtime-bootstrap'
@@ -11,8 +12,10 @@ export async function sendRequest(
method: string,
params: unknown,
timeoutMs: number,
- envelope?: RuntimeOrchestrationEnvelope
+ envelope?: RuntimeOrchestrationEnvelope,
+ signal?: AbortSignal
): Promise> {
+ throwIfSignalAborted(signal)
if (!isSafeTimerDelayMs(timeoutMs)) {
throw new RuntimeClientError(
'invalid_argument',
@@ -32,6 +35,9 @@ export async function sendRequest(
}
const socket = createConnection(transport.endpoint)
let lineSegments: string[] = []
+ let lineBytes = 0
+ const searchResponseLimit =
+ method.startsWith('aiVault.') && /search/i.test(method) ? 4 * 1024 * 1024 : Infinity
let settled = false
const requestId = randomUUID()
@@ -40,6 +46,7 @@ export async function sendRequest(
return
}
settled = true
+ signal?.removeEventListener('abort', onAbort)
lineSegments = []
socket.destroy()
reject(
@@ -57,6 +64,7 @@ export async function sendRequest(
return
}
settled = true
+ signal?.removeEventListener('abort', onAbort)
lineSegments = []
clearTimeout(timeout)
socket.end()
@@ -67,6 +75,15 @@ export async function sendRequest(
}
}
+ const onAbort = (): void => {
+ finish({ ok: false, error: abortSignalReason(signal!) })
+ socket.destroy()
+ }
+ signal?.addEventListener('abort', onAbort, { once: true })
+ if (signal?.aborted) {
+ onAbort()
+ return
+ }
socket.setEncoding('utf8')
socket.once('error', () => {
finish({
@@ -99,6 +116,20 @@ export async function sendRequest(
let cursor = 0
while (cursor < chunk.length && !settled) {
const newlineIndex = chunk.indexOf('\n', cursor)
+ lineBytes += Buffer.byteLength(
+ chunk.slice(cursor, newlineIndex === -1 ? undefined : newlineIndex)
+ )
+ if (lineBytes > searchResponseLimit) {
+ finish({
+ ok: false,
+ error: new RuntimeClientError(
+ 'invalid_runtime_response',
+ 'Search response exceeds the size limit.'
+ )
+ })
+ socket.destroy()
+ return
+ }
if (newlineIndex === -1) {
lineSegments.push(chunk.slice(cursor))
return
@@ -111,6 +142,7 @@ export async function sendRequest(
lineSegments = []
}
cursor = newlineIndex + 1
+ lineBytes = 0
if (line.trim().length === 0) {
continue
}
diff --git a/src/cli/runtime/websocket-transport.ts b/src/cli/runtime/websocket-transport.ts
index 8c7696d9f7d..8b66972dc30 100644
--- a/src/cli/runtime/websocket-transport.ts
+++ b/src/cli/runtime/websocket-transport.ts
@@ -13,10 +13,18 @@ export async function sendWebSocketRequest(
method: string,
params: unknown,
timeoutMs: number,
- envelope?: RuntimeOrchestrationEnvelope
+ envelope?: RuntimeOrchestrationEnvelope,
+ signal?: AbortSignal
): Promise> {
try {
- return await sendRemoteRuntimeRequest(pairing, method, params, timeoutMs, envelope)
+ return await sendRemoteRuntimeRequest(
+ pairing,
+ method,
+ params,
+ timeoutMs,
+ envelope,
+ signal
+ )
} catch (error) {
if (error instanceof RemoteRuntimeClientError) {
throw new RuntimeClientError(error.code, error.message, error.data)
@@ -31,7 +39,8 @@ export async function sendWebSocketRequestWithStatusPreflight(
params: unknown,
timeoutMs: number,
validateStatus: (response: RuntimeRpcResponse) => void,
- envelope?: RuntimeOrchestrationEnvelope
+ envelope?: RuntimeOrchestrationEnvelope,
+ signal?: AbortSignal
): Promise> {
try {
return await sendRemoteRuntimeRequestWithStatusPreflight(
@@ -40,7 +49,9 @@ export async function sendWebSocketRequestWithStatusPreflight(
params,
timeoutMs,
validateStatus,
- envelope
+ envelope,
+ [],
+ signal
)
} catch (error) {
if (error instanceof RemoteRuntimeClientError) {
diff --git a/src/cli/search-command-arguments.test.ts b/src/cli/search-command-arguments.test.ts
new file mode 100644
index 00000000000..73042cdacf9
--- /dev/null
+++ b/src/cli/search-command-arguments.test.ts
@@ -0,0 +1,77 @@
+import { expect, it } from 'vitest'
+import { parseArgs, REPEATED_FLAG_SEPARATOR } from './args'
+import { parseSearchCommand } from './search-command-arguments'
+
+it('preserves repeated filters through argv and validates before configuration', () => {
+ const parsed = parseArgs([
+ 'search',
+ '--agent-session',
+ 'needle',
+ '--agent',
+ 'codex',
+ '--agent',
+ 'claude',
+ '--path',
+ '/one',
+ '--path',
+ '/two'
+ ])
+ expect(parsed.flags.get('agent')).toBe(`codex${REPEATED_FLAG_SEPARATOR}claude`)
+ expect(parseSearchCommand(parsed.flags).query).toMatchObject({
+ agents: ['codex', 'claude'],
+ scopePaths: ['/one', '/two']
+ })
+ expect(() =>
+ parseSearchCommand(
+ new Map([
+ ['enable', true],
+ ['agent-session', 'needle'],
+ ['limit', '101']
+ ])
+ )
+ ).toThrow()
+})
+
+it('accepts queryless policy management and refuses aggregate mutations', () => {
+ expect(
+ parseSearchCommand(parseArgs(['search', '--agent-session', '--enable']).flags).configure
+ ).toEqual({ enabled: true })
+ expect(
+ parseSearchCommand(
+ parseArgs(['search', '--disable', '--clear-index', '--host', 'ssh:box']).flags
+ ).configure
+ ).toEqual({ enabled: false, clearIndex: true })
+ expect(() =>
+ parseSearchCommand(parseArgs(['search', '--enable', '--host', 'all']).flags)
+ ).toThrow()
+ expect(() => parseSearchCommand(parseArgs(['search', '--enable', '--disable']).flags)).toThrow()
+})
+
+it('handles command discovery, equals syntax, Windows paths and host-specific scope rules', () => {
+ const parsed = parseArgs(
+ [
+ '--json',
+ 'search',
+ '--agent-session=needle',
+ '--agent=codex',
+ '--agent=claude',
+ '--path=C:\\work',
+ '--path=\\\\server\\share',
+ '--host=all'
+ ],
+ [['search']]
+ )
+ expect(parseSearchCommand(parsed.flags).query).toMatchObject({
+ agents: ['codex', 'claude'],
+ scopePaths: ['C:\\work', '\\\\server\\share']
+ })
+ for (const args of [
+ ['--enable', '--path=/somewhere'],
+ ['--enable', '--agent-session=needle', '--newest=false'],
+ ['--agent-session=needle', '--host=ssh:box', '--path=relative'],
+ ['--agent-session=needle', '--host=all', '--path=~/private'],
+ ['--index-status', '--agent-session=needle']
+ ]) {
+ expect(() => parseSearchCommand(parseArgs(['search', ...args], [['search']]).flags)).toThrow()
+ }
+})
diff --git a/src/cli/search-command-arguments.ts b/src/cli/search-command-arguments.ts
new file mode 100644
index 00000000000..3032087e63b
--- /dev/null
+++ b/src/cli/search-command-arguments.ts
@@ -0,0 +1,142 @@
+import { homedir } from 'node:os'
+import { join } from 'node:path'
+import { AI_VAULT_AGENTS, type AiVaultAgent } from '../shared/ai-vault-types'
+import {
+ SessionSearchConfigureSchema,
+ SessionSearchQuerySchema,
+ type SessionSearchConfigure
+} from '../shared/ai-vault-search-contract'
+import type { AiVaultSearchArgs } from '../shared/ai-vault-search-types'
+import { isRuntimePathAbsolute } from '../shared/cross-platform-path'
+import { parseHostFlag } from './execution-host-flag'
+import {
+ getOptionalPositiveIntegerFlag,
+ getOptionalStringFlag,
+ getRepeatedStringFlag
+} from './flags'
+import { RuntimeClientError } from './runtime/types'
+
+export type SearchCommand = {
+ query?: AiVaultSearchArgs
+ configure?: SessionSearchConfigure
+ status: boolean
+ host: ReturnType | 'all'
+}
+
+function invalid(message: string): never {
+ throw new RuntimeClientError('invalid_argument', message)
+}
+
+export function parseSearchCommand(
+ flags: Map,
+ remote = false
+): SearchCommand {
+ const host = flags.get('host') === 'all' ? 'all' : parseHostFlag(flags)
+ const configure: SessionSearchConfigure = {}
+ for (const flag of [
+ 'enable',
+ 'disable',
+ 'clear-index',
+ 'index-status',
+ 'pause',
+ 'resume-indexing',
+ 'newest'
+ ]) {
+ if (flags.has(flag) && flags.get(flag) !== true) {
+ invalid(`--${flag} does not take a value.`)
+ }
+ }
+ if (flags.get('enable') && flags.get('disable')) {
+ invalid('Use either --enable or --disable.')
+ }
+ if (flags.get('pause') && flags.get('resume-indexing')) {
+ invalid('Use either --pause or --resume-indexing.')
+ }
+ if (flags.get('enable')) {
+ configure.enabled = true
+ }
+ if (flags.get('disable')) {
+ configure.enabled = false
+ }
+ if (flags.get('pause')) {
+ configure.paused = true
+ }
+ if (flags.get('resume-indexing')) {
+ configure.paused = false
+ }
+ if (flags.get('clear-index')) {
+ configure.clearIndex = true
+ }
+ const history = getOptionalStringFlag(flags, 'history-days')
+ if (history !== undefined) {
+ configure.historyDays = history === 'all' ? null : Number(history)
+ }
+ const config = SessionSearchConfigureSchema.safeParse(configure)
+ if (!config.success) {
+ invalid('Invalid search policy: history-days must be 1..3650 or all.')
+ }
+ const mutation = Object.keys(configure).length > 0
+ const status = flags.get('index-status') === true
+ if (host === 'all' && (mutation || status)) {
+ invalid('Select one host to manage its search index.')
+ }
+ const raw = flags.get('agent-session')
+ const query = raw === true && mutation ? undefined : getOptionalStringFlag(flags, 'agent-session')
+ if (status && (mutation || query)) {
+ invalid('Use --index-status without a query or policy change.')
+ }
+ if (!query && !mutation && !status) {
+ invalid('Missing --agent-session .')
+ }
+ if (!query && ['agent', 'path', 'since', 'limit', 'newest'].some((flag) => flags.has(flag))) {
+ invalid(
+ 'Search filters require --agent-session ; they do not restrict which sources are indexed.'
+ )
+ }
+ const agents = getRepeatedStringFlag(flags, 'agent').map((value) => {
+ const agent = value.toLowerCase() as AiVaultAgent
+ if (!AI_VAULT_AGENTS.includes(agent)) {
+ invalid(`Unknown --agent ${value}.`)
+ }
+ return agent
+ })
+ const paths = getRepeatedStringFlag(flags, 'path').map((path) => {
+ if (
+ path.startsWith('~') &&
+ (remote || host === 'all' || host?.kind === 'ssh' || host?.kind === 'runtime')
+ ) {
+ invalid('Use an absolute path on the execution host instead of ~.')
+ }
+ const expanded =
+ path === '~' ? homedir() : path.startsWith('~/') ? join(homedir(), path.slice(2)) : path
+ if ((host === 'all' || host?.kind === 'ssh') && !isRuntimePathAbsolute(expanded)) {
+ invalid('--path must be absolute on the execution host.')
+ }
+ return expanded
+ })
+ const since = getOptionalStringFlag(flags, 'since')
+ if (
+ since &&
+ (!/^\d{4}-\d{2}-\d{2}(?:[T ].*)?$/.test(since) || !Number.isFinite(Date.parse(since)))
+ ) {
+ invalid('--since must be an ISO 8601 timestamp.')
+ }
+ const args = {
+ query: query ?? 'policy validation',
+ limit: getOptionalPositiveIntegerFlag(flags, 'limit'),
+ agents: agents.length ? [...new Set(agents)] : undefined,
+ scopePaths: paths.length ? paths : undefined,
+ since: since ? new Date(since).toISOString() : undefined,
+ sort: flags.get('newest') === true ? 'newest' : 'relevance'
+ }
+ const validated = SessionSearchQuerySchema.safeParse(args)
+ if (!validated.success) {
+ invalid(validated.error.issues[0]?.message ?? 'Invalid search query.')
+ }
+ return {
+ host,
+ status,
+ configure: mutation ? configure : undefined,
+ query: query ? validated.data : undefined
+ }
+}
diff --git a/src/cli/search-interruption.test.ts b/src/cli/search-interruption.test.ts
new file mode 100644
index 00000000000..cd151192053
--- /dev/null
+++ b/src/cli/search-interruption.test.ts
@@ -0,0 +1,35 @@
+import { expect, it, vi } from 'vitest'
+
+const call = vi.hoisted(() => vi.fn())
+vi.mock('./runtime-client', async (importOriginal) => ({
+ ...(await importOriginal |