ai-hist 0.18.0 → 0.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,11 +1,37 @@
1
1
  #!/usr/bin/env node
2
+ import { realpathSync } from 'node:fs';
2
3
  import { readFile } from 'node:fs/promises';
4
+ import { pathToFileURL } from 'node:url';
3
5
  import { discoverSessions, ensureLocalStore, formatSessionRow, getSession, getSessionEventsPage, getSessionFileEditsPage, getSessionRelationships, getSessionToolCallsPage, getSessionTree, hydrateSession, listSessionCatalogPage, recent, resumeCommand, search, stats, sync, } from './index.js';
4
6
  import { runDeliveryCommand, runHistoryExportCommand, loadHistoryApplicationConfig } from './delivery-cli.js';
5
- const BOOLEAN_FLAGS = new Set(['all', 'fts', 'help', 'json', 'local', 'no-bootstrap', 'no-related', 'no-source-connectors', 'no-warning', 'once', 'pretty', 'remote', 'version']);
6
- const VALUE_FLAGS = new Set([
7
+ /**
8
+ * A run that is over: usage errors and `--help`, which used to call
9
+ * `process.exit`.
10
+ *
11
+ * `usage()` is reached from argument parsing several frames down and is typed
12
+ * `never`, so returning a code from it is not available. Carrying the text on
13
+ * the throw keeps those call sites unchanged and leaves `runCli` the only place
14
+ * that decides what reaches `io`.
15
+ */
16
+ class CliExit extends Error {
17
+ exitCode;
18
+ stdout;
19
+ stderr;
20
+ constructor(exitCode, stdout, stderr) {
21
+ super(`ai-hist exited with ${exitCode}`);
22
+ this.exitCode = exitCode;
23
+ this.stdout = stdout;
24
+ this.stderr = stderr;
25
+ this.name = 'CliExit';
26
+ }
27
+ }
28
+ export const BOOLEAN_FLAGS = new Set(['all', 'fts', 'help', 'json', 'local', 'no-bootstrap', 'no-related', 'no-source-connectors', 'no-warning', 'once', 'pretty', 'remote', 'version']);
29
+ export const VALUE_FLAGS = new Set([
7
30
  'config', 'job', 'selection', 'poll-ms', 'timeout-ms', 'base-url', 'interval', 'label', 'max-content', 'out', 'after', 'after-ms', 'after-session-id', 'after-source', 'before-ms', 'db', 'limit',
8
31
  'max-depth', 'max-nodes', 'config', 'source-connector', 'project', 'source', 'tag', 'token', 'tokens',
32
+ // Documented in the usage text and read by `sessions discover`, `sessions
33
+ // hydrate` and `sync`, but absent here, so `parse` rejected it as unknown.
34
+ 'acquisition-timeout-ms',
9
35
  ]);
10
36
  const KNOWN_FLAGS = new Set([...BOOLEAN_FLAGS, ...VALUE_FLAGS]);
11
37
  function versionTriple(value) {
@@ -23,11 +49,28 @@ function newerVersion(current, latest) {
23
49
  }
24
50
  return false;
25
51
  }
52
+ /**
53
+ * True when this module was started as the `ai-hist` program.
54
+ *
55
+ * `relay-cli.ts` imports this file for `runCli`; without this guard that import
56
+ * would run `main()` against the host's own `process.argv`.
57
+ */
58
+ function isBinEntrypoint() {
59
+ const entry = process.argv[1];
60
+ if (entry === undefined)
61
+ return false;
62
+ try {
63
+ return pathToFileURL(realpathSync(entry)).href === import.meta.url;
64
+ }
65
+ catch {
66
+ return false;
67
+ }
68
+ }
26
69
  async function packageVersion() {
27
70
  const contents = await readFile(new URL('../package.json', import.meta.url), 'utf8');
28
71
  return JSON.parse(contents).version ?? 'unknown';
29
72
  }
30
- async function maybePrintUpdateNotice(current, args) {
73
+ async function maybePrintUpdateNotice(io, current, args) {
31
74
  const optOut = process.env.RELAYHISTORY_NO_UPDATE_CHECK;
32
75
  if (!process.stderr.isTTY || args.includes('--no-warning') || (optOut && optOut !== '0'))
33
76
  return;
@@ -41,7 +84,7 @@ async function maybePrintUpdateNotice(current, args) {
41
84
  const latest = (await response.json());
42
85
  if (!latest.version || !newerVersion(current, latest.version))
43
86
  return;
44
- process.stderr.write(`\nA new version of ai-hist is available: ${current} -> ${latest.version}\n` +
87
+ io.stderr(`\nA new version of ai-hist is available: ${current} -> ${latest.version}\n` +
45
88
  'Update with:\n npm install --global ai-hist@latest\n' +
46
89
  '(pass --no-warning or set RELAYHISTORY_NO_UPDATE_CHECK=1 to hide this notice)\n');
47
90
  }
@@ -166,7 +209,7 @@ function wireValue(value) {
166
209
  return Object.fromEntries(Object.entries(value)
167
210
  .map(([key, item]) => [snakeCase(key), OPAQUE_JSON_KEYS.has(key) ? item : wireValue(item)]));
168
211
  }
169
- function humanLine(value) {
212
+ export function humanLine(value) {
170
213
  if (!value || typeof value !== 'object')
171
214
  return String(value);
172
215
  const row = value;
@@ -175,30 +218,30 @@ function humanLine(value) {
175
218
  .filter((item) => item !== '' && item != null)
176
219
  .join(' ');
177
220
  }
178
- function output(value, json) {
221
+ export function output(io, value, json) {
179
222
  if (json) {
180
- process.stdout.write(`${JSON.stringify(wireValue(value))}\n`);
223
+ io.stdout(`${JSON.stringify(wireValue(value))}\n`);
181
224
  }
182
225
  else if (Array.isArray(value)) {
183
- process.stdout.write(value.length ? `${value.map(humanLine).join('\n')}\n` : 'No results.\n');
226
+ io.stdout(value.length ? `${value.map(humanLine).join('\n')}\n` : 'No results.\n');
184
227
  }
185
228
  else if (typeof value === 'object' && value !== null) {
186
229
  const record = value;
187
230
  if (Array.isArray(record.sessions)) {
188
- process.stdout.write(record.sessions.length ? `${record.sessions.map(humanLine).join('\n')}\n` : 'No sessions in the catalog.\n');
231
+ io.stdout(record.sessions.length ? `${record.sessions.map(humanLine).join('\n')}\n` : 'No sessions in the catalog.\n');
189
232
  if (record.nextCursor)
190
- process.stdout.write(`more available: --after '${JSON.stringify(record.nextCursor)}'\n`);
233
+ io.stdout(`more available: --after '${JSON.stringify(record.nextCursor)}'\n`);
191
234
  }
192
235
  else {
193
- process.stdout.write(`${Object.entries(record).map(([key, item]) => `${key}: ${typeof item === 'object' ? JSON.stringify(item) : String(item)}`).join('\n')}\n`);
236
+ io.stdout(`${Object.entries(record).map(([key, item]) => `${key}: ${typeof item === 'object' ? JSON.stringify(item) : String(item)}`).join('\n')}\n`);
194
237
  }
195
238
  }
196
239
  else {
197
- process.stdout.write(`${String(value)}\n`);
240
+ io.stdout(`${String(value)}\n`);
198
241
  }
199
242
  }
200
- function showHelp() {
201
- process.stdout.write(`Usage:
243
+ /** The one usage text. It was duplicated verbatim in `showHelp` and `usage`. */
244
+ const USAGE_TEXT = `Usage:
202
245
  ai-hist [--no-bootstrap] [--db PATH] [--json] [--help]
203
246
  ai-hist sessions list [--pretty] [--local | --remote | --all] [--source SOURCE]... [--limit N] [--before-ms MS] [--after JSON | --after-source SOURCE --after-session-id ID [--after-ms MS]] [--json]
204
247
  ai-hist sessions discover [--local | --remote | --all] [--source-connector ID | --no-source-connectors] [--acquisition-timeout-ms N] [--source SOURCE] [--limit N] [--json]
@@ -222,38 +265,12 @@ function showHelp() {
222
265
 
223
266
  Every command that reads local history indexes it on first use; pass
224
267
  --no-bootstrap to answer from the store exactly as it stands.
225
- `);
226
- process.exit(0);
268
+ `;
269
+ function showHelp() {
270
+ throw new CliExit(0, USAGE_TEXT, '');
227
271
  }
228
272
  function usage(message) {
229
- if (message)
230
- process.stderr.write(`ai-hist: ${message}\n\n`);
231
- process.stderr.write(`Usage:
232
- ai-hist [--no-bootstrap] [--db PATH] [--json] [--help]
233
- ai-hist sessions list [--pretty] [--local | --remote | --all] [--source SOURCE]... [--limit N] [--before-ms MS] [--after JSON | --after-source SOURCE --after-session-id ID [--after-ms MS]] [--json]
234
- ai-hist sessions discover [--local | --remote | --all] [--source-connector ID | --no-source-connectors] [--acquisition-timeout-ms N] [--source SOURCE] [--limit N] [--json]
235
- ai-hist sessions hydrate SOURCE SESSION_ID [--local | --remote | --all] [--source-connector ID | --no-source-connectors] [--acquisition-timeout-ms N] [--no-related] [--db PATH] [--json]
236
- ai-hist sessions relationships SOURCE SESSION_ID [--db PATH] [--json]
237
- ai-hist sessions tree SOURCE SESSION_ID [--max-depth N] [--max-nodes N] [--db PATH] [--json]
238
- ai-hist sessions tools SOURCE SESSION_ID [--limit N] [--after JSON] [--db PATH] [--json]
239
- ai-hist sessions edits SOURCE SESSION_ID [--limit N] [--after JSON] [--db PATH] [--json]
240
- ai-hist search QUERY... [--local | --remote | --all] [--source SOURCE] [--project PATH] [--limit N] [--json]
241
- ai-hist recent [N] [--local | --remote | --all] [--source SOURCE] [--project PATH] [--json]
242
- ai-hist session SESSION_ID [--source SOURCE] [--json]
243
- ai-hist events SESSION_ID [--source SOURCE] [--limit N] [--after JSON] [--json]
244
- ai-hist resume QUERY... [--local | --remote | --all] [--db PATH] [--fts] [--json]
245
- ai-hist pack QUERY... [--local | --remote | --all] [--source SOURCE] [--project PATH] [--tag TAG] [--limit N] [--tokens N] [--db PATH] [--fts] [--json]
246
- ai-hist stats [--local | --remote | --all] [--json]
247
- ai-hist export --selection FILE [--out FILE] [--db PATH]
248
- ai-hist delivery enable|drain|run --config FILE [--job ID] [--db PATH]
249
- ai-hist delivery status|pause|resume|retry|cancel [--job ID] [--db PATH]
250
- ai-hist plugin COMMAND --config FILE -- [ARGS...]
251
- ai-hist sync [--local | --remote | --all] [--source-connector ID | --no-source-connectors] [--acquisition-timeout-ms N] [--db PATH] [--json]
252
-
253
- Every command that reads local history indexes it on first use; pass
254
- --no-bootstrap to answer from the store exactly as it stands.
255
- `);
256
- process.exit(2);
273
+ throw new CliExit(2, '', `${message ? `ai-hist: ${message}\n\n` : ''}${USAGE_TEXT}`);
257
274
  }
258
275
  function cursorFlag(args) {
259
276
  const raw = textFlag(args, 'after');
@@ -291,77 +308,77 @@ function evidenceCursorFlag(args) {
291
308
  }
292
309
  return { tsMs: tsMs, id: raw.id };
293
310
  }
294
- function outputDiscovery(value, json) {
311
+ function outputDiscovery(io, value, json) {
295
312
  if (!json) {
296
313
  for (const session of value.sessions)
297
- process.stdout.write(`${humanLine(session)}\n`);
298
- process.stdout.write(`${value.sessions.length} session(s): ${value.discovered} discovered, ${value.skippedUnchanged} unchanged ` +
314
+ io.stdout(`${humanLine(session)}\n`);
315
+ io.stdout(`${value.sessions.length} session(s): ${value.discovered} discovered, ${value.skippedUnchanged} unchanged ` +
299
316
  `(${value.counters.filesOpened} file(s) opened, ${value.counters.shallowReads} shallow read(s)); ` +
300
317
  `requested scope: ${value.scope}, connector locations run: ${value.locationsRun.length > 0 ? value.locationsRun.join(', ') : 'none'}\n`);
301
318
  return;
302
319
  }
303
320
  for (const session of value.sessions)
304
- output({ type: 'session', ...session }, true);
321
+ output(io, { type: 'session', ...session }, true);
305
322
  for (const diagnostic of value.diagnostics)
306
- output({ type: 'diagnostic', ...diagnostic }, true);
323
+ output(io, { type: 'diagnostic', ...diagnostic }, true);
307
324
  const { sessions: _sessions, diagnostics: _diagnostics, ...summary } = value;
308
325
  const providers = Object.fromEntries(summary.providers.map(({ source, ...provider }) => [source, provider]));
309
- output({ type: 'summary', ...summary, providers }, true);
326
+ output(io, { type: 'summary', ...summary, providers }, true);
310
327
  }
311
- function continuationNotice(cursor) {
328
+ function continuationNotice(io, cursor) {
312
329
  if (cursor)
313
- process.stdout.write(`more available: --after '${JSON.stringify(cursor)}'\n`);
330
+ io.stdout(`more available: --after '${JSON.stringify(cursor)}'\n`);
314
331
  }
315
332
  // Human rows are positional, so every column is always printed: an absent
316
333
  // value is `-` rather than a dropped field that would shift the columns after
317
334
  // it, and an uncounted line delta is `?` rather than a fabricated 0.
318
- function outputToolCalls(page, json) {
335
+ function outputToolCalls(io, page, json) {
319
336
  if (json) {
320
- output(page, true);
337
+ output(io, page, true);
321
338
  return;
322
339
  }
323
340
  if (page.toolCalls.length === 0) {
324
- process.stdout.write('No tool calls.\n');
341
+ io.stdout('No tool calls.\n');
325
342
  return;
326
343
  }
327
344
  for (const call of page.toolCalls) {
328
- process.stdout.write([
345
+ io.stdout([
329
346
  call.tsMs ?? '-', call.source, call.toolUseId, call.name,
330
347
  call.target ?? '-', call.isError === true ? '(error)' : '-',
331
348
  ].join(' ').concat('\n'));
332
349
  }
333
- continuationNotice(page.nextCursor);
350
+ continuationNotice(io, page.nextCursor);
334
351
  }
335
- function outputFileEdits(page, json) {
352
+ function outputFileEdits(io, page, json) {
336
353
  if (json) {
337
- output(page, true);
354
+ output(io, page, true);
338
355
  return;
339
356
  }
340
357
  if (page.fileEdits.length === 0) {
341
- process.stdout.write('No file edits.\n');
358
+ io.stdout('No file edits.\n');
342
359
  return;
343
360
  }
344
361
  for (const edit of page.fileEdits) {
345
- process.stdout.write([
362
+ io.stdout([
346
363
  edit.tsMs ?? '-', edit.source, edit.toolUseId, edit.toolName ?? '-', edit.filePath,
347
364
  `+${edit.linesAdded ?? '?'}/-${edit.linesRemoved ?? '?'}`,
348
365
  ].join(' ').concat('\n'));
349
366
  }
350
- continuationNotice(page.nextCursor);
367
+ continuationNotice(io, page.nextCursor);
351
368
  }
352
- function outputHydration(value, json) {
369
+ function outputHydration(io, value, json) {
353
370
  if (json) {
354
- output(value, true);
371
+ output(io, value, true);
355
372
  return;
356
373
  }
357
- process.stdout.write(`${value.source}/${value.sessionId}: ${value.status}\n`);
358
- process.stdout.write(`evidence: ${value.evidence.prompts} prompt(s), ${value.evidence.events} event(s), ` +
374
+ io.stdout(`${value.source}/${value.sessionId}: ${value.status}\n`);
375
+ io.stdout(`evidence: ${value.evidence.prompts} prompt(s), ${value.evidence.events} event(s), ` +
359
376
  `${value.evidence.toolCalls} tool call(s), ${value.evidence.fileEdits} file edit(s)\n`);
360
377
  if (value.relatedSessionIds.length) {
361
- process.stdout.write(`related sessions: ${value.relatedSessionIds.join(', ')}\n`);
378
+ io.stdout(`related sessions: ${value.relatedSessionIds.join(', ')}\n`);
362
379
  }
363
380
  for (const diagnostic of value.diagnostics) {
364
- process.stdout.write(`${diagnostic.code}: ${diagnostic.message}\n`);
381
+ io.stdout(`${diagnostic.code}: ${diagnostic.message}\n`);
365
382
  }
366
383
  }
367
384
  function relationshipLine(direction, row) {
@@ -372,23 +389,23 @@ function relationshipLine(direction, row) {
372
389
  row.evidenceLocator ?? row.evidenceKind,
373
390
  ].join(' ');
374
391
  }
375
- function outputRelationships(value, json) {
392
+ function outputRelationships(io, value, json) {
376
393
  if (json) {
377
- output(value, true);
394
+ output(io, value, true);
378
395
  return;
379
396
  }
380
397
  const total = value.asParent.length + value.asChild.length;
381
- process.stdout.write(total === 0
398
+ io.stdout(total === 0
382
399
  ? `${value.source}/${value.sessionId}: no delegation relationships.\n`
383
400
  : `${value.source}/${value.sessionId}: ${value.asParent.length} child relationship(s), ` +
384
401
  `${value.asChild.length} parent relationship(s)\n`);
385
402
  for (const row of value.asParent)
386
- process.stdout.write(`${relationshipLine('child', row)}\n`);
403
+ io.stdout(`${relationshipLine('child', row)}\n`);
387
404
  for (const row of value.asChild)
388
- process.stdout.write(`${relationshipLine('parent', row)}\n`);
389
- process.stdout.write(`capability: stable child identity = ${value.capabilities.stableChildIdentity}\n`);
405
+ io.stdout(`${relationshipLine('parent', row)}\n`);
406
+ io.stdout(`capability: stable child identity = ${value.capabilities.stableChildIdentity}\n`);
390
407
  for (const diagnostic of value.diagnostics) {
391
- process.stdout.write(`${diagnostic.code}: ${diagnostic.message}\n`);
408
+ io.stdout(`${diagnostic.code}: ${diagnostic.message}\n`);
392
409
  }
393
410
  }
394
411
  // Mirrors the Rust CLI's `Local.timestamp_millis_opt(ms).format("%Y-%m-%d %H:%M")`:
@@ -404,7 +421,7 @@ function queryPositionals(subcommand, rest, command) {
404
421
  usage(`${command} requires a query`);
405
422
  return query;
406
423
  }
407
- async function runResume(args, subcommand, rest, json) {
424
+ async function runResume(io, args, subcommand, rest, json) {
408
425
  const query = queryPositionals(subcommand, rest, 'resume');
409
426
  // Matches the Rust CLI: search is capped to the single best match, then that
410
427
  // one row is checked for a usable session id rather than scanning further.
@@ -420,7 +437,7 @@ async function runResume(args, subcommand, rest, json) {
420
437
  // field rather than failing, matching that contract here.
421
438
  const locallyAvailable = entry.locations.length === 0 || entry.locations.includes('local');
422
439
  if (json) {
423
- output({
440
+ output(io, {
424
441
  ...entry,
425
442
  resumeCmd: cmd,
426
443
  scope: scopeFlag(args),
@@ -428,11 +445,11 @@ async function runResume(args, subcommand, rest, json) {
428
445
  resumeUnavailableReason: 'session is remote-only; materialize it locally before resuming',
429
446
  }),
430
447
  }, true);
431
- return;
448
+ return 0;
432
449
  }
433
450
  if (cmd) {
434
- process.stdout.write(`${cmd}\n`);
435
- return;
451
+ io.stdout(`${cmd}\n`);
452
+ return 0;
436
453
  }
437
454
  if (!locallyAvailable) {
438
455
  throw new Error(`Session ${entry.sessionId} is remote-only and cannot be resumed locally; materialize it locally first.`);
@@ -457,7 +474,7 @@ function takeCodePoints(text, limit) {
457
474
  return { truncated: false, text };
458
475
  return { truncated: true, text: points.slice(0, limit).join('') };
459
476
  }
460
- async function runPack(args, subcommand, rest, json) {
477
+ async function runPack(io, args, subcommand, rest, json) {
461
478
  const query = queryPositionals(subcommand, rest, 'pack');
462
479
  const queryStr = query.join(' ');
463
480
  const tokens = nonNegativeIntFlag(args, 'tokens') ?? 0;
@@ -468,13 +485,12 @@ async function runPack(args, subcommand, rest, json) {
468
485
  });
469
486
  if (rows.length === 0) {
470
487
  if (json) {
471
- output({ query: queryStr, entries: [] }, true);
488
+ output(io, { query: queryStr, entries: [] }, true);
472
489
  }
473
490
  else {
474
- process.stdout.write('No results.\n');
491
+ io.stdout('No results.\n');
475
492
  }
476
- process.exitCode = 1;
477
- return;
493
+ return 1;
478
494
  }
479
495
  const charsBudget = tokens > 0 ? tokens * 4 : undefined;
480
496
  const generatedMs = Date.now();
@@ -483,10 +499,10 @@ async function runPack(args, subcommand, rest, json) {
483
499
  const prompt = charsBudget ? takeCodePoints(entry.prompt, charsBudget).text : entry.prompt;
484
500
  return { ...entry, prompt, resumeCmd: resumeCommand(entry) };
485
501
  });
486
- output({ query: queryStr, generatedMs, tokenBudget: tokens, entries }, true);
487
- return;
502
+ output(io, { query: queryStr, generatedMs, tokenBudget: tokens, entries }, true);
503
+ return 0;
488
504
  }
489
- process.stdout.write(`=== ai-hist pack: "${queryStr}" | ${formatLocalMinute(generatedMs)} | ${rows.length} entries ===\n\n`);
505
+ io.stdout(`=== ai-hist pack: "${queryStr}" | ${formatLocalMinute(generatedMs)} | ${rows.length} entries ===\n\n`);
490
506
  rows.forEach((entry, index) => {
491
507
  const project = entry.project ? ` ${entry.project}` : '';
492
508
  let text = entry.prompt.replace(/\n/g, ' ');
@@ -495,24 +511,25 @@ async function runPack(args, subcommand, rest, json) {
495
511
  if (capped.truncated)
496
512
  text = `${capped.text}...`;
497
513
  }
498
- process.stdout.write(`[${index + 1}/${rows.length}] #${entry.id} ${formatLocalMinute(entry.timestampMs)} ${entry.source}${project}\n`);
499
- process.stdout.write(` ${text}\n`);
514
+ io.stdout(`[${index + 1}/${rows.length}] #${entry.id} ${formatLocalMinute(entry.timestampMs)} ${entry.source}${project}\n`);
515
+ io.stdout(` ${text}\n`);
500
516
  if (entry.sessionId) {
501
517
  const cmd = resumeCommand(entry);
502
518
  if (cmd) {
503
- process.stdout.write(` Resume: ${cmd}\n`);
519
+ io.stdout(` Resume: ${cmd}\n`);
504
520
  }
505
521
  else {
506
522
  const short = entry.sessionId.length > 16 ? `${entry.sessionId.slice(0, 16)}...` : entry.sessionId;
507
- process.stdout.write(` Session: ${short}\n`);
523
+ io.stdout(` Session: ${short}\n`);
508
524
  }
509
525
  }
510
- process.stdout.write('\n');
526
+ io.stdout('\n');
511
527
  });
528
+ return 0;
512
529
  }
513
- function outputTree(value, json) {
530
+ function outputTree(io, value, json) {
514
531
  if (json) {
515
- output(value, true);
532
+ output(io, value, true);
516
533
  return;
517
534
  }
518
535
  for (const node of value.nodes) {
@@ -520,17 +537,17 @@ function outputTree(value, json) {
520
537
  ? ` [${[node.relationship.relationship, node.relationship.childAgentType].filter(Boolean).join(' ')}]` +
521
538
  ` events=${node.hasEvents ? 'yes' : 'no'}`
522
539
  : '';
523
- process.stdout.write(`${' '.repeat(node.depth)}${node.sessionId}${edge}${node.truncated ? ' …' : ''}\n`);
540
+ io.stdout(`${' '.repeat(node.depth)}${node.sessionId}${edge}${node.truncated ? ' …' : ''}\n`);
524
541
  }
525
- process.stdout.write(`${Math.max(value.nodes.length - 1, 0)} descendant(s), max depth ${value.maxDepthReached}\n`);
542
+ io.stdout(`${Math.max(value.nodes.length - 1, 0)} descendant(s), max depth ${value.maxDepthReached}\n`);
526
543
  for (const row of value.unlinked) {
527
- process.stdout.write(`unlinked evidence: ${row.evidenceKind} ${row.evidenceLocator ?? ''}`.trimEnd() + '\n');
544
+ io.stdout(`unlinked evidence: ${row.evidenceKind} ${row.evidenceLocator ?? ''}`.trimEnd() + '\n');
528
545
  }
529
546
  for (const diagnostic of value.diagnostics) {
530
- process.stdout.write(`${diagnostic.code}: ${diagnostic.message}\n`);
547
+ io.stdout(`${diagnostic.code}: ${diagnostic.message}\n`);
531
548
  }
532
549
  if (value.truncated)
533
- process.stdout.write('truncated: node/depth budget reached\n');
550
+ io.stdout('truncated: node/depth budget reached\n');
534
551
  }
535
552
  function validateInterval(args) {
536
553
  // --interval is seconds; validate before converting so the error names the
@@ -540,17 +557,78 @@ function validateInterval(args) {
540
557
  usage('--interval must be a whole number of seconds between 1 and 2147483');
541
558
  }
542
559
  }
560
+ /**
561
+ * Every option a command may list in `allowed`, spelled the way help shows it.
562
+ *
563
+ * This is the only place a flag's user-facing text lives. The drift test
564
+ * asserts it covers exactly the flags the command table allows, and that each
565
+ * one's arity matches `BOOLEAN_FLAGS`/`VALUE_FLAGS` — so a flag that `parse`
566
+ * cannot accept can never reach the mounted help.
567
+ */
568
+ export const FLAG_SPECS = {
569
+ 'acquisition-timeout-ms': { flags: '--acquisition-timeout-ms <ms>', description: 'Budget for remote acquisition, in milliseconds.' },
570
+ after: { flags: '--after <json>', description: 'Continue from a printed cursor.' },
571
+ 'after-ms': { flags: '--after-ms <ms>', description: 'Cursor timestamp, with --after-source and --after-session-id.' },
572
+ 'after-session-id': { flags: '--after-session-id <id>', description: 'Cursor session id, with --after-source.' },
573
+ 'after-source': { flags: '--after-source <source>', description: 'Cursor source, with --after-session-id.' },
574
+ all: { flags: '--all', description: 'Read local and remote history.' },
575
+ 'before-ms': { flags: '--before-ms <ms>', description: 'Only entries older than this epoch-millisecond timestamp.' },
576
+ config: { flags: '--config <file>', description: 'History application config file.' },
577
+ db: { flags: '--db <path>', description: 'History database to read or write.' },
578
+ fts: { flags: '--fts', description: 'Treat the query as raw SQLite full-text syntax.' },
579
+ job: { flags: '--job <id>', description: 'Act on one delivery job.' },
580
+ json: { flags: '--json', description: 'Emit JSON instead of human-readable text.' },
581
+ limit: { flags: '--limit <n>', description: 'Maximum rows to return.' },
582
+ local: { flags: '--local', description: 'Read only local history (the default).' },
583
+ 'max-depth': { flags: '--max-depth <n>', description: 'Stop walking below this depth.' },
584
+ 'max-nodes': { flags: '--max-nodes <n>', description: 'Stop after this many nodes.' },
585
+ 'no-bootstrap': { flags: '--no-bootstrap', description: 'Answer from the store as it stands, without first-use indexing.' },
586
+ 'no-related': { flags: '--no-related', description: 'Do not hydrate related sessions.' },
587
+ 'no-source-connectors': { flags: '--no-source-connectors', description: 'Disable remote acquisition entirely.' },
588
+ out: { flags: '--out <file>', description: 'Write to this file instead of standard output.' },
589
+ 'poll-ms': { flags: '--poll-ms <ms>', description: 'Delivery poll interval, in milliseconds.' },
590
+ pretty: { flags: '--pretty', description: 'Render aligned, colourized rows.' },
591
+ project: { flags: '--project <path>', description: 'Only sessions from this project directory.' },
592
+ remote: { flags: '--remote', description: 'Read only remote history.' },
593
+ selection: { flags: '--selection <file>', description: 'Export selection file.' },
594
+ source: { flags: '--source <source>', description: 'Restrict to one coding-agent source.' },
595
+ 'source-connector': { flags: '--source-connector <id>', description: 'Run this remote connector; repeatable.' },
596
+ tag: { flags: '--tag <tag>', description: 'Restrict to entries carrying this tag.' },
597
+ 'timeout-ms': { flags: '--timeout-ms <ms>', description: 'Per-request delivery timeout, in milliseconds.' },
598
+ tokens: { flags: '--tokens <n>', description: 'Approximate token budget for the packed output.' },
599
+ };
600
+ /** Options the host owns, so they never reach a mounted command's help. */
601
+ export const HOST_OWNED_FLAGS = new Set(['help', 'no-warning']);
543
602
  // The whole dispatch surface in one table. `readsLocalStore` is the decision
544
603
  // that used to live only in the bare-invocation branch: keeping it here is what
545
604
  // stops `search` and `ai-hist` disagreeing about whether a store exists.
546
- const COMMANDS = new Map([
547
- ['', { name: 'ai-hist', positionals: [0, 0], allowed: ['db', 'json', 'help'], readsLocalStore: true }],
548
- ['export', { name: 'export', positionals: [0, 0], allowed: ['db', 'selection', 'out'] }],
549
- ['plugin', { name: 'plugin', positionals: [1, null], allowed: ['config'], requires: 'plugin requires a command name' }],
550
- ...['enable', 'status', 'drain', 'run', 'pause', 'resume', 'retry', 'cancel'].map((action) => [`delivery ${action}`, {
551
- name: `delivery ${action}`, positionals: [0, 0], allowed: ['db', 'config', 'job', 'poll-ms', 'timeout-ms'],
605
+ export const COMMANDS = new Map([
606
+ // The bare invocation reports the store's condition; a mounted group shows
607
+ // help instead, so `list` is its explicit equivalent there.
608
+ ['', { name: 'ai-hist', description: 'Report local history readiness and list the catalogue.', surface: null,
609
+ positionals: [0, 0], allowed: ['db', 'json', 'help'], readsLocalStore: true }],
610
+ ['export', { name: 'export', description: 'Export selected history as NDJSON.', surface: ['export'],
611
+ positionals: [0, 0], allowed: ['db', 'selection', 'out'] }],
612
+ // A config-driven extension hook that needs `-- ARGS` passthrough, not a
613
+ // user-facing verb: it stays on the bin and off the mounted tree.
614
+ ['plugin', { name: 'plugin', description: 'Run a configured history plugin command.', surface: null,
615
+ positionals: [1, null], allowed: ['config'], requires: 'plugin requires a command name' }],
616
+ ...Object.entries({
617
+ enable: 'Create the delivery job declared in the config file.',
618
+ status: 'Report delivery job status and retention.',
619
+ drain: 'Deliver everything currently queued, then stop.',
620
+ run: 'Run the delivery loop until it is cancelled.',
621
+ pause: 'Stop a delivery job from making progress.',
622
+ resume: 'Let a paused delivery job make progress again.',
623
+ retry: 'Clear a delivery job\'s failure and try it again.',
624
+ cancel: 'Abandon a delivery job.',
625
+ }).map(([action, description]) => [`delivery ${action}`, {
626
+ name: `delivery ${action}`, description, surface: ['delivery', action],
627
+ positionals: [0, 0], allowed: ['db', 'config', 'job', 'poll-ms', 'timeout-ms'],
628
+ cancellable: true,
552
629
  }]),
553
- ['sessions list', { name: 'sessions list', positionals: [0, 0], readsLocalStore: true,
630
+ ['sessions list', { name: 'sessions list', description: 'List indexed sessions from the catalogue.',
631
+ surface: ['list'], positionals: [0, 0], readsLocalStore: true,
554
632
  validate: (args) => {
555
633
  if (args.flags.has('json') && args.flags.has('pretty'))
556
634
  usage('--pretty and --json are mutually exclusive');
@@ -559,26 +637,36 @@ const COMMANDS = new Map([
559
637
  'after', 'after-ms', 'after-session-id', 'after-source', 'all', 'before-ms', 'db',
560
638
  'json', 'limit', 'local', 'pretty', 'remote', 'source',
561
639
  ] }],
562
- ['sessions discover', { name: 'sessions discover', positionals: [0, 0],
640
+ ['sessions discover', { name: 'sessions discover', description: 'Find coding-agent sessions and index the new ones.',
641
+ surface: ['discover'], positionals: [0, 0],
563
642
  validate: (args) => { sourceConnectorFlags(args); },
564
643
  allowed: ['all', 'db', 'json', 'limit', 'local', 'remote', 'source', 'config', 'source-connector', 'no-source-connectors', 'acquisition-timeout-ms'] }],
565
- ['sessions hydrate', { name: 'sessions hydrate', positionals: [2, 2],
644
+ ['sessions hydrate', { name: 'sessions hydrate', description: 'Index one session\'s full evidence.',
645
+ surface: ['hydrate'], positionals: [2, 2], args: [{ name: 'source', description: 'Coding-agent source, e.g. claude or codex.', required: true }, { name: 'session-id', description: 'Session identifier.', required: true }],
566
646
  requires: 'sessions hydrate requires SOURCE and SESSION_ID',
567
647
  validate: (args) => { sourceConnectorFlags(args); },
568
648
  allowed: ['all', 'db', 'json', 'local', 'no-bootstrap', 'no-related', 'remote', 'config', 'source-connector', 'no-source-connectors', 'acquisition-timeout-ms'] }],
569
- ['sessions relationships', { name: 'sessions relationships', positionals: [2, 2], readsLocalStore: true,
649
+ ['sessions relationships', { name: 'sessions relationships', description: 'Show a session\'s parent and child delegations.',
650
+ surface: ['relationships'], positionals: [2, 2], args: [{ name: 'source', description: 'Coding-agent source, e.g. claude or codex.', required: true }, { name: 'session-id', description: 'Session identifier.', required: true }], readsLocalStore: true,
570
651
  rejectsScope: true, requires: 'sessions relationships requires SOURCE and SESSION_ID',
571
652
  allowed: ['all', 'db', 'json', 'local', 'remote'] }],
572
- ['sessions tree', { name: 'sessions tree', positionals: [2, 2], readsLocalStore: true, rejectsScope: true,
653
+ ['sessions tree', { name: 'sessions tree', description: 'Print a session\'s delegation tree.',
654
+ surface: ['tree'], positionals: [2, 2], args: [{ name: 'source', description: 'Coding-agent source, e.g. claude or codex.', required: true }, { name: 'session-id', description: 'Session identifier.', required: true }], readsLocalStore: true, rejectsScope: true,
573
655
  requires: 'sessions tree requires SOURCE and SESSION_ID',
574
656
  allowed: ['all', 'db', 'json', 'local', 'max-depth', 'max-nodes', 'remote'] }],
575
- ['sessions tools', { name: 'sessions tools', positionals: [2, 2], readsLocalStore: true,
657
+ ['sessions tools', { name: 'sessions tools', description: 'Page through a session\'s tool calls.',
658
+ surface: ['tools'], positionals: [2, 2], args: [{ name: 'source', description: 'Coding-agent source, e.g. claude or codex.', required: true }, { name: 'session-id', description: 'Session identifier.', required: true }], readsLocalStore: true,
576
659
  requires: 'sessions tools requires SOURCE and SESSION_ID', allowed: ['after', 'db', 'json', 'limit'] }],
577
- ['sessions edits', { name: 'sessions edits', positionals: [2, 2], readsLocalStore: true,
660
+ ['sessions edits', { name: 'sessions edits', description: 'Page through a session\'s file edits.',
661
+ surface: ['edits'], positionals: [2, 2], args: [{ name: 'source', description: 'Coding-agent source, e.g. claude or codex.', required: true }, { name: 'session-id', description: 'Session identifier.', required: true }], readsLocalStore: true,
578
662
  requires: 'sessions edits requires SOURCE and SESSION_ID', allowed: ['after', 'db', 'json', 'limit'] }],
579
- ['search', { name: 'search', positionals: [1, null], requires: 'search requires a query', readsLocalStore: true,
663
+ ['search', { name: 'search', description: 'Search indexed prompts.', surface: ['search'],
664
+ positionals: [1, null], args: [{ name: 'query', description: 'Search terms.', required: true, variadic: true }],
665
+ requires: 'search requires a query', readsLocalStore: true,
580
666
  allowed: ['all', 'before-ms', 'db', 'fts', 'json', 'limit', 'local', 'project', 'remote', 'source', 'tag'] }],
581
- ['recent', { name: 'recent', positionals: [0, 1], readsLocalStore: true,
667
+ ['recent', { name: 'recent', description: 'Show the most recent prompts.', surface: ['recent'],
668
+ positionals: [0, 1], args: [{ name: 'count', description: 'How many to show.', required: false }],
669
+ readsLocalStore: true,
582
670
  validate: (args) => {
583
671
  const count = args.positional[1];
584
672
  if (count !== undefined && !Number.isFinite(Number(count))) {
@@ -586,22 +674,33 @@ const COMMANDS = new Map([
586
674
  }
587
675
  },
588
676
  allowed: ['all', 'before-ms', 'db', 'json', 'limit', 'local', 'project', 'remote', 'source', 'tag'] }],
589
- ['session', { name: 'session', positionals: [1, 1], requires: 'session requires SESSION_ID',
677
+ // `agent-relay sessions session ID` reads badly, so the mounted spelling is
678
+ // `show`; the original name stays as an alias for existing muscle memory.
679
+ ['session', { name: 'session', description: 'Show one session.', surface: ['show'], surfaceAliases: ['session'],
680
+ positionals: [1, 1], args: [{ name: 'session-id', description: 'Session identifier.', required: true }], requires: 'session requires SESSION_ID',
590
681
  readsLocalStore: true, rejectsScope: true,
591
682
  allowed: ['all', 'db', 'json', 'local', 'remote', 'source', 'tag'] }],
592
- ['events', { name: 'events', positionals: [1, 1], requires: 'events requires SESSION_ID',
683
+ ['events', { name: 'events', description: 'Page through one session\'s events.', surface: ['events'],
684
+ positionals: [1, 1], args: [{ name: 'session-id', description: 'Session identifier.', required: true }], requires: 'events requires SESSION_ID',
593
685
  readsLocalStore: true, rejectsScope: true,
594
686
  allowed: ['after', 'all', 'db', 'json', 'limit', 'local', 'remote', 'source'] }],
595
- ['resume', { name: 'resume', positionals: [1, null], requires: 'resume requires a query', readsLocalStore: true,
687
+ ['resume', { name: 'resume', description: 'Print the command that resumes the best-matching session.',
688
+ surface: ['resume'], positionals: [1, null],
689
+ args: [{ name: 'query', description: 'Search terms identifying the session.', required: true, variadic: true }],
690
+ requires: 'resume requires a query', readsLocalStore: true,
596
691
  allowed: ['all', 'db', 'fts', 'json', 'local', 'remote'] }],
597
- ['pack', { name: 'pack', positionals: [1, null], requires: 'pack requires a query', readsLocalStore: true,
692
+ ['pack', { name: 'pack', description: 'Pack matching history into a context block.', surface: ['pack'],
693
+ positionals: [1, null], args: [{ name: 'query', description: 'Search terms.', required: true, variadic: true }],
694
+ requires: 'pack requires a query', readsLocalStore: true,
598
695
  validate: (args) => { nonNegativeIntFlag(args, 'tokens'); },
599
696
  allowed: ['all', 'db', 'fts', 'json', 'limit', 'local', 'project', 'remote', 'source', 'tag', 'tokens'] }],
600
- ['stats', { name: 'stats', positionals: [0, 0], readsLocalStore: true,
697
+ ['stats', { name: 'stats', description: 'Summarize what the history store holds.', surface: ['stats'],
698
+ positionals: [0, 0], readsLocalStore: true,
601
699
  allowed: ['all', 'db', 'json', 'local', 'remote', 'tag'] }],
602
700
  // sync and `sessions discover` build the store rather than read it, so they
603
701
  // do not bootstrap first; running them is itself the remedy for an empty one.
604
- ['sync', { name: 'sync', positionals: [0, 0], validate: (args) => { sourceConnectorFlags(args); },
702
+ ['sync', { name: 'sync', description: 'Index new sessions from every configured source.', surface: ['sync'],
703
+ positionals: [0, 0], validate: (args) => { sourceConnectorFlags(args); },
605
704
  allowed: ['all', 'db', 'json', 'local', 'remote', 'config', 'source-connector', 'no-source-connectors', 'acquisition-timeout-ms'] }],
606
705
  ]);
607
706
  /** Command words consumed before the positional arguments start. */
@@ -617,6 +716,32 @@ function commandSpec(command, subcommand) {
617
716
  return subcommand ? COMMANDS.get(`${command} ${subcommand}`) : undefined;
618
717
  return COMMANDS.get(command);
619
718
  }
719
+ /**
720
+ * Whether this command line routes to a command that reads the cancellation
721
+ * signal.
722
+ *
723
+ * The bin asks before installing a `SIGINT`/`SIGTERM` handler. A listener
724
+ * replaces Node's default termination, so a handler on an invocation that
725
+ * never reads the signal swallows the first shutdown request: the user presses
726
+ * Ctrl-C, nothing happens, and they press it again. Resolved from the same
727
+ * `COMMANDS` table `dispatch` resolves against, so the two cannot disagree
728
+ * about which commands those are.
729
+ */
730
+ export function usesCancellation(argv) {
731
+ // `plugin -- ARGS` passes its tail to a plugin verbatim, and `plugin` is not
732
+ // cancellable, so stopping at the separator can only ever read less.
733
+ const boundary = argv.indexOf('--');
734
+ const core = [...(boundary < 0 ? argv : argv.slice(0, boundary))];
735
+ try {
736
+ const { positional } = parse(core.map((arg) => arg === '-h' ? '--help' : arg));
737
+ return commandSpec(positional[0], positional[1])?.cancellable === true;
738
+ }
739
+ catch {
740
+ // An argv `parse` refuses is a usage error `dispatch` is about to report.
741
+ // It runs no command, so it reads no signal.
742
+ return false;
743
+ }
744
+ }
620
745
  function unknownCommandMessage(command, subcommand) {
621
746
  if (command === undefined)
622
747
  return 'invalid usage';
@@ -639,27 +764,36 @@ function skipUnusableStoreGate(spec, scope) {
639
764
  || spec.name === 'search'
640
765
  || spec.name === 'recent';
641
766
  }
642
- function reportUnusableStore(readiness, json) {
767
+ function reportUnusableStore(io, readiness, json) {
643
768
  if (readiness.status === 'ready' || readiness.status === 'skipped')
644
769
  return false;
645
770
  const message = readiness.status === 'unbuilt'
646
771
  ? 'No local index yet: run ai-hist (or ai-hist sync) to build one.'
647
772
  : 'No searchable local sessions found. Start a coding-agent session, then run ai-hist again.';
648
773
  if (json)
649
- output({ status: readiness.status, indexedPrompts: readiness.indexedPrompts, message }, true);
774
+ output(io, { status: readiness.status, indexedPrompts: readiness.indexedPrompts, message }, true);
650
775
  else
651
- process.stdout.write(`${message}\n`);
652
- process.exitCode = 1;
776
+ io.stdout(`${message}\n`);
653
777
  return true;
654
778
  }
655
- async function main() {
656
- const rawArgs = process.argv.slice(2);
779
+ /**
780
+ * One invocation, start to finish, with no process state touched.
781
+ *
782
+ * This is the whole of what `main()` used to be. Every exit that was a
783
+ * `process.exit`/`process.exitCode` is now a returned code, and every write is
784
+ * routed through `io`, so a host can mount the same dispatch.
785
+ */
786
+ async function dispatch(argv, io, options) {
787
+ const rawArgs = [...argv];
657
788
  const versionArgs = rawArgs.filter((arg) => arg !== '--no-warning');
658
789
  if (versionArgs.length === 1 && (versionArgs[0] === '--version' || versionArgs[0] === '-V')) {
659
790
  const version = await packageVersion();
660
- process.stdout.write(`ai-hist ${version}\n`);
661
- await maybePrintUpdateNotice(version, rawArgs);
662
- return;
791
+ io.stdout(`ai-hist ${version}\n`);
792
+ // A mounted host prints its own version and must not make a network call
793
+ // on the user's behalf, so the registry check is the bin's alone.
794
+ if (options.updateNotice)
795
+ await maybePrintUpdateNotice(io, version, rawArgs);
796
+ return 0;
663
797
  }
664
798
  const boundary = rawArgs.indexOf('--');
665
799
  const beforeBoundary = boundary < 0 ? rawArgs : rawArgs.slice(0, boundary);
@@ -703,16 +837,16 @@ async function main() {
703
837
  const recentFallback = command === 'recent' && tail.length > 0 ? Number(tail[0]) : undefined;
704
838
  const scope = scopeFlag(args);
705
839
  if (command === 'delivery') {
706
- await runDeliveryCommand(subcommand, { dbPath: textFlag(args, 'db'), configPath: textFlag(args, 'config'),
707
- jobId: textFlag(args, 'job'), pollIntervalMs: numberFlag(args, 'poll-ms'), requestTimeoutMs: numberFlag(args, 'timeout-ms') });
708
- return;
840
+ return runDeliveryCommand(subcommand, io, { dbPath: textFlag(args, 'db'), configPath: textFlag(args, 'config'),
841
+ jobId: textFlag(args, 'job'), pollIntervalMs: numberFlag(args, 'poll-ms'), requestTimeoutMs: numberFlag(args, 'timeout-ms'),
842
+ signal: options.signal });
709
843
  }
710
844
  if (command === 'export') {
711
845
  const selectionPath = textFlag(args, 'selection');
712
846
  if (!selectionPath)
713
847
  usage('export requires --selection FILE');
714
- await runHistoryExportCommand({ dbPath: textFlag(args, 'db'), selectionPath, outputPath: textFlag(args, 'out') });
715
- return;
848
+ await runHistoryExportCommand({ dbPath: textFlag(args, 'db'), selectionPath, outputPath: textFlag(args, 'out') }, options.stdoutStream);
849
+ return 0;
716
850
  }
717
851
  if (command === 'plugin') {
718
852
  const configPath = textFlag(args, 'config');
@@ -722,8 +856,8 @@ async function main() {
722
856
  const operation = registry.command(tail[0]);
723
857
  if (!operation)
724
858
  usage('configured plugin command not found');
725
- output(await operation.run([...tail.slice(1), ...pluginArgs]), true);
726
- return;
859
+ output(io, await operation.run([...tail.slice(1), ...pluginArgs]), true);
860
+ return 0;
727
861
  }
728
862
  const acquisitionPlugins = ['sync', 'sessions'].includes(command ?? '') && textFlag(args, 'config') ? (await loadHistoryApplicationConfig(textFlag(args, 'config'))).registry : undefined;
729
863
  let readiness = null;
@@ -732,29 +866,29 @@ async function main() {
732
866
  dbPath: textFlag(args, 'db'), scope, bootstrap: !args.flags.has('no-bootstrap'),
733
867
  });
734
868
  if (readiness.bootstrap?.status === 'partial') {
735
- process.stderr.write('Some local sessions could not be fully indexed; run ai-hist --json for diagnostics.\n');
869
+ io.stderr('Some local sessions could not be fully indexed; run ai-hist --json for diagnostics.\n');
736
870
  }
737
871
  // The bare invocation reports the store's condition as its result and
738
872
  // succeeds either way. Every command that asks the store a question refuses
739
873
  // to answer out of one that cannot hold an answer.
740
- if (command !== undefined && !skipUnusableStoreGate(spec, scope) && reportUnusableStore(readiness, json))
741
- return;
874
+ if (command !== undefined && !skipUnusableStoreGate(spec, scope) && reportUnusableStore(io, readiness, json))
875
+ return 1;
742
876
  }
743
877
  if (command === undefined) {
744
878
  // --no-bootstrap answers from the catalog as it stands; the bootstrap path
745
879
  // reports what it just indexed.
746
880
  if (!readiness?.bootstrap) {
747
- output(await listSessionCatalogPage({ dbPath: textFlag(args, 'db') }), json);
748
- return;
881
+ output(io, await listSessionCatalogPage({ dbPath: textFlag(args, 'db') }), json);
882
+ return 0;
749
883
  }
750
884
  if (json)
751
- output(readiness.bootstrap, true);
885
+ output(io, readiness.bootstrap, true);
752
886
  else {
753
- process.stdout.write(readiness.indexedPrompts > 0
887
+ io.stdout(readiness.indexedPrompts > 0
754
888
  ? `Ready: ${readiness.indexedPrompts} indexed prompt(s). Search with: ai-hist search "your query"\n`
755
889
  : 'No searchable local sessions found. Start a coding-agent session, then run ai-hist again.\n');
756
890
  }
757
- return;
891
+ return 0;
758
892
  }
759
893
  if (command === 'sessions' && subcommand === 'list') {
760
894
  const sources = textFlags(args, 'source');
@@ -764,28 +898,28 @@ async function main() {
764
898
  after: catalogCursorFlag(args),
765
899
  });
766
900
  if (args.flags.has('pretty')) {
767
- const color = Boolean(process.stdout.isTTY) && process.env.NO_COLOR === undefined;
768
- process.stdout.write(page.sessions.length
901
+ const color = options.color && process.env.NO_COLOR === undefined;
902
+ io.stdout(page.sessions.length
769
903
  ? `${page.sessions.map((session) => formatSessionRow(session, { color })).join('\n')}\n`
770
904
  : 'No sessions in the catalog.\n');
771
905
  if (page.nextCursor)
772
- process.stdout.write(`more available: --after '${JSON.stringify(page.nextCursor)}'\n`);
906
+ io.stdout(`more available: --after '${JSON.stringify(page.nextCursor)}'\n`);
773
907
  }
774
908
  else
775
- output(page, json);
776
- return;
909
+ output(io, page, json);
910
+ return 0;
777
911
  }
778
912
  if (command === 'sessions' && subcommand === 'discover') {
779
913
  const sources = textFlags(args, 'source');
780
- outputDiscovery(await discoverSessions({
914
+ outputDiscovery(io, await discoverSessions({
781
915
  sourceConnectors: sourceConnectorFlags(args), acquisitionTimeoutMs: numberFlag(args, 'acquisition-timeout-ms'), plugins: acquisitionPlugins,
782
916
  dbPath: textFlag(args, 'db'), scope: scopeFlag(args), sources: sources.length ? sources : undefined,
783
917
  limit: numberFlag(args, 'limit'),
784
918
  }), json);
785
- return;
919
+ return 0;
786
920
  }
787
921
  if (command === 'sessions' && subcommand === 'hydrate') {
788
- outputHydration(await hydrateSession({
922
+ outputHydration(io, await hydrateSession({
789
923
  sourceConnectors: sourceConnectorFlags(args), acquisitionTimeoutMs: numberFlag(args, 'acquisition-timeout-ms'), plugins: acquisitionPlugins,
790
924
  source: sessionSource,
791
925
  sessionId: sessionId,
@@ -793,20 +927,20 @@ async function main() {
793
927
  dbPath: textFlag(args, 'db'),
794
928
  includeRelated: !args.flags.has('no-related'),
795
929
  }), json);
796
- return;
930
+ return 0;
797
931
  }
798
932
  if (command === 'sessions' && subcommand === 'relationships') {
799
- outputRelationships(await getSessionRelationships({
933
+ outputRelationships(io, await getSessionRelationships({
800
934
  source: sessionSource, sessionId: sessionId, dbPath: textFlag(args, 'db'),
801
935
  }), json);
802
- return;
936
+ return 0;
803
937
  }
804
938
  if (command === 'sessions' && subcommand === 'tree') {
805
- outputTree(await getSessionTree({
939
+ outputTree(io, await getSessionTree({
806
940
  source: sessionSource, sessionId: sessionId, dbPath: textFlag(args, 'db'),
807
941
  maxDepth: numberFlag(args, 'max-depth'), maxNodes: numberFlag(args, 'max-nodes'),
808
942
  }), json);
809
- return;
943
+ return 0;
810
944
  }
811
945
  if (command === 'sessions' && (subcommand === 'tools' || subcommand === 'edits')) {
812
946
  const name = `sessions ${subcommand}`;
@@ -816,53 +950,109 @@ async function main() {
816
950
  after: evidenceCursorFlag(args),
817
951
  };
818
952
  if (subcommand === 'tools') {
819
- outputToolCalls(await getSessionToolCallsPage(sessionSource, sessionId, options), json);
953
+ outputToolCalls(io, await getSessionToolCallsPage(sessionSource, sessionId, options), json);
820
954
  }
821
955
  else {
822
- outputFileEdits(await getSessionFileEditsPage(sessionSource, sessionId, options), json);
956
+ outputFileEdits(io, await getSessionFileEditsPage(sessionSource, sessionId, options), json);
823
957
  }
824
- return;
958
+ return 0;
825
959
  }
826
960
  if (command === 'search') {
827
- output(await search([subcommand, ...rest].join(' '), { ...common(args), rawFts: args.flags.has('fts') }), json);
828
- return;
961
+ output(io, await search([subcommand, ...rest].join(' '), { ...common(args), rawFts: args.flags.has('fts') }), json);
962
+ return 0;
829
963
  }
830
964
  if (command === 'recent') {
831
- output(await recent({ ...common(args), limit: numberFlag(args, 'limit') ?? recentFallback }), json);
832
- return;
965
+ output(io, await recent({ ...common(args), limit: numberFlag(args, 'limit') ?? recentFallback }), json);
966
+ return 0;
833
967
  }
834
968
  if (command === 'session') {
835
- output(await getSession(subcommand, { dbPath: textFlag(args, 'db'), source: textFlag(args, 'source'), tag: textFlag(args, 'tag') }), json);
836
- return;
969
+ output(io, await getSession(subcommand, { dbPath: textFlag(args, 'db'), source: textFlag(args, 'source'), tag: textFlag(args, 'tag') }), json);
970
+ return 0;
837
971
  }
838
972
  if (command === 'events') {
839
- output(await getSessionEventsPage(subcommand, {
973
+ output(io, await getSessionEventsPage(subcommand, {
840
974
  dbPath: textFlag(args, 'db'), source: textFlag(args, 'source'),
841
975
  limit: numberFlag(args, 'limit'), after: cursorFlag(args),
842
976
  }), json);
843
- return;
977
+ return 0;
844
978
  }
845
979
  if (command === 'resume') {
846
- await runResume(args, subcommand, rest, json);
847
- return;
980
+ return runResume(io, args, subcommand, rest, json);
848
981
  }
849
982
  if (command === 'pack') {
850
- await runPack(args, subcommand, rest, json);
851
- return;
983
+ return runPack(io, args, subcommand, rest, json);
852
984
  }
853
985
  if (command === 'stats') {
854
- output(await stats({ dbPath: textFlag(args, 'db'), scope: scopeFlag(args), tag: textFlag(args, 'tag') }), json);
855
- return;
986
+ output(io, await stats({ dbPath: textFlag(args, 'db'), scope: scopeFlag(args), tag: textFlag(args, 'tag') }), json);
987
+ return 0;
856
988
  }
857
989
  if (command === 'sync') {
858
- output(await sync({ dbPath: textFlag(args, 'db'), scope: scopeFlag(args), sourceConnectors: sourceConnectorFlags(args), acquisitionTimeoutMs: numberFlag(args, 'acquisition-timeout-ms'), plugins: acquisitionPlugins }), json);
859
- return;
990
+ output(io, await sync({ dbPath: textFlag(args, 'db'), scope: scopeFlag(args), sourceConnectors: sourceConnectorFlags(args), acquisitionTimeoutMs: numberFlag(args, 'acquisition-timeout-ms'), plugins: acquisitionPlugins }), json);
991
+ return 0;
860
992
  }
861
993
  usage();
862
994
  }
863
- main().catch((error) => {
864
- const value = error;
865
- process.stderr.write(`ai-hist: ${value.code ? `${value.code}: ` : ''}${value.message ?? String(error)}\n`);
866
- process.exitCode = 1;
867
- });
995
+ /**
996
+ * Run one `ai-hist` command line and resolve to its exit code.
997
+ *
998
+ * Never calls `process.exit`, never writes to `process.stdout`/`process.stderr`
999
+ * and never installs a signal handler: the caller owns all three. `argv` is the
1000
+ * arguments after the program name.
1001
+ */
1002
+ export async function runCli(argv, io, options = {}) {
1003
+ try {
1004
+ return await dispatch(argv, io, options);
1005
+ }
1006
+ catch (error) {
1007
+ if (error instanceof CliExit) {
1008
+ if (error.stdout)
1009
+ io.stdout(error.stdout);
1010
+ if (error.stderr)
1011
+ io.stderr(error.stderr);
1012
+ return error.exitCode;
1013
+ }
1014
+ const value = error;
1015
+ io.stderr(`ai-hist: ${value.code ? `${value.code}: ` : ''}${value.message ?? String(error)}\n`);
1016
+ return 1;
1017
+ }
1018
+ }
1019
+ /**
1020
+ * The `ai-hist` binary: the only place that owns process state.
1021
+ *
1022
+ * Signal handling lives here rather than in the delivery command so that
1023
+ * `runCli` stays free of global handlers for hosts that mount it. It is also
1024
+ * claimed only for the commands that read it: every other invocation keeps
1025
+ * Node's default `SIGINT`/`SIGTERM` behaviour, so Ctrl-C ends it the first
1026
+ * time it is pressed.
1027
+ */
1028
+ async function main() {
1029
+ const io = {
1030
+ stdout: (chunk) => void process.stdout.write(chunk),
1031
+ stderr: (chunk) => void process.stderr.write(chunk),
1032
+ };
1033
+ const argv = process.argv.slice(2);
1034
+ const cancellable = usesCancellation(argv);
1035
+ const abort = new AbortController();
1036
+ const stop = () => abort.abort();
1037
+ if (cancellable) {
1038
+ process.once('SIGINT', stop);
1039
+ process.once('SIGTERM', stop);
1040
+ }
1041
+ try {
1042
+ process.exitCode = await runCli(argv, io, {
1043
+ signal: cancellable ? abort.signal : undefined,
1044
+ updateNotice: true,
1045
+ color: Boolean(process.stdout.isTTY),
1046
+ stdoutStream: process.stdout,
1047
+ });
1048
+ }
1049
+ finally {
1050
+ if (cancellable) {
1051
+ process.removeListener('SIGINT', stop);
1052
+ process.removeListener('SIGTERM', stop);
1053
+ }
1054
+ }
1055
+ }
1056
+ if (isBinEntrypoint())
1057
+ void main();
868
1058
  //# sourceMappingURL=cli.js.map