@phnx-labs/agents-cli 1.21.3 → 1.22.1

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.
Files changed (90) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/README.md +32 -3
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/computer-actions.js +1 -0
  5. package/dist/commands/doctor.js +34 -2
  6. package/dist/commands/exec.d.ts +27 -0
  7. package/dist/commands/exec.js +123 -6
  8. package/dist/commands/models.js +36 -1
  9. package/dist/commands/projects.js +120 -62
  10. package/dist/commands/sessions-backfill.d.ts +32 -0
  11. package/dist/commands/sessions-backfill.js +186 -0
  12. package/dist/commands/sessions.d.ts +17 -1
  13. package/dist/commands/sessions.js +317 -18
  14. package/dist/commands/teams.js +1 -1
  15. package/dist/commands/worktree.d.ts +3 -3
  16. package/dist/commands/worktree.js +35 -4
  17. package/dist/lib/app-bundle-install.d.ts +17 -0
  18. package/dist/lib/app-bundle-install.js +94 -0
  19. package/dist/lib/daemon.d.ts +5 -1
  20. package/dist/lib/daemon.js +63 -14
  21. package/dist/lib/devices/doctor-findings.js +12 -4
  22. package/dist/lib/devices/doctor-overview-cache.d.ts +45 -0
  23. package/dist/lib/devices/doctor-overview-cache.js +168 -0
  24. package/dist/lib/devices/fleet.js +7 -2
  25. package/dist/lib/devices/resolve-target.d.ts +6 -0
  26. package/dist/lib/devices/resolve-target.js +9 -3
  27. package/dist/lib/devices/self-host.d.ts +9 -0
  28. package/dist/lib/devices/self-host.js +61 -0
  29. package/dist/lib/exec.js +39 -8
  30. package/dist/lib/hosts/dispatch.d.ts +12 -0
  31. package/dist/lib/hosts/dispatch.js +23 -6
  32. package/dist/lib/hosts/passthrough.js +8 -6
  33. package/dist/lib/hosts/reconnect.d.ts +38 -0
  34. package/dist/lib/hosts/reconnect.js +85 -4
  35. package/dist/lib/hosts/run-target.js +14 -2
  36. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  37. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  38. package/dist/lib/menubar/install-menubar.js +27 -23
  39. package/dist/lib/model-tiers.d.ts +54 -0
  40. package/dist/lib/model-tiers.js +229 -0
  41. package/dist/lib/models.d.ts +3 -0
  42. package/dist/lib/models.js +44 -7
  43. package/dist/lib/pricing/prices.json +16 -1
  44. package/dist/lib/project-focus.d.ts +42 -0
  45. package/dist/lib/project-focus.js +80 -0
  46. package/dist/lib/project-schedule.d.ts +75 -0
  47. package/dist/lib/project-schedule.js +110 -0
  48. package/dist/lib/project-status.d.ts +7 -0
  49. package/dist/lib/project-status.js +9 -0
  50. package/dist/lib/projects.d.ts +11 -2
  51. package/dist/lib/projects.js +57 -9
  52. package/dist/lib/redact.d.ts +2 -0
  53. package/dist/lib/redact.js +22 -0
  54. package/dist/lib/remote-agents-json.d.ts +2 -0
  55. package/dist/lib/remote-agents-json.js +3 -3
  56. package/dist/lib/rotate.d.ts +84 -1
  57. package/dist/lib/rotate.js +155 -5
  58. package/dist/lib/runner.d.ts +4 -2
  59. package/dist/lib/runner.js +13 -4
  60. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  61. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  62. package/dist/lib/secrets/install-helper.js +28 -31
  63. package/dist/lib/session/bash-command.js +60 -9
  64. package/dist/lib/session/db.d.ts +7 -1
  65. package/dist/lib/session/db.js +301 -32
  66. package/dist/lib/session/discover.d.ts +40 -7
  67. package/dist/lib/session/discover.js +144 -83
  68. package/dist/lib/session/parse.d.ts +8 -1
  69. package/dist/lib/session/parse.js +83 -32
  70. package/dist/lib/session/remote-list.d.ts +71 -0
  71. package/dist/lib/session/remote-list.js +410 -2
  72. package/dist/lib/session/shell-programs.d.ts +15 -0
  73. package/dist/lib/session/shell-programs.js +359 -0
  74. package/dist/lib/session/tool-calls.d.ts +88 -0
  75. package/dist/lib/session/tool-calls.js +612 -0
  76. package/dist/lib/session/tool-index.d.ts +100 -0
  77. package/dist/lib/session/tool-index.js +773 -0
  78. package/dist/lib/session/tool-store.d.ts +15 -0
  79. package/dist/lib/session/tool-store.js +198 -0
  80. package/dist/lib/session/types.d.ts +7 -0
  81. package/dist/lib/state.d.ts +10 -1
  82. package/dist/lib/state.js +11 -2
  83. package/dist/lib/teams/remoteWorktree.d.ts +3 -4
  84. package/dist/lib/teams/remoteWorktree.js +3 -4
  85. package/dist/lib/teams/worktree.d.ts +11 -1
  86. package/dist/lib/teams/worktree.js +42 -4
  87. package/dist/lib/types.d.ts +17 -0
  88. package/dist/lib/types.js +17 -0
  89. package/dist/lib/versions.js +69 -22
  90. package/package.json +3 -1
@@ -15,6 +15,8 @@ import { getSessionsDir, getSessionsDbPath } from '../state.js';
15
15
  import { query as queryEvents } from '../events.js';
16
16
  import { machineForSessionFile } from './origin-machine.js';
17
17
  import { loadSessionActorIndex, readSessionActorRecord } from './actor-sidecar.js';
18
+ import { toolCallsFromEvents } from './tool-calls.js';
19
+ import { persistToolCalls, purgeToolCalls, toolEvidenceSourcePath } from './tool-store.js';
18
20
  import { extractSkills, extractSlashCommands } from './highlights.js';
19
21
  import { resolveResource } from '../resources.js';
20
22
  import { discoverPlugins } from '../plugins.js';
@@ -23,7 +25,7 @@ const DB_PATH = getSessionsDbPath();
23
25
  /** Current schema version; bumped when migrations are added. Exported so tests
24
26
  * assert against the constant instead of hardcoding a number that every bump
25
27
  * then has to chase (docs/05-sessions.md calls the constant the source of truth). */
26
- export const SCHEMA_VERSION = 24;
28
+ export const SCHEMA_VERSION = 30;
27
29
  /**
28
30
  * Canonicalize a file path for use as a scan_ledger key. The same physical
29
31
  * session file is reachable via multiple aliases — `~/.claude/projects/x.jsonl`
@@ -147,6 +149,73 @@ CREATE TABLE IF NOT EXISTS dir_ledger (
147
149
  scanned_at INTEGER NOT NULL
148
150
  );
149
151
 
152
+ -- One redacted evidence row per tool call. The ordinal is assigned in
153
+ -- transcript order and is stable across incremental appends.
154
+ CREATE TABLE IF NOT EXISTS tool_calls (
155
+ call_key TEXT PRIMARY KEY,
156
+ session_id TEXT NOT NULL,
157
+ ordinal INTEGER NOT NULL,
158
+ source_call_id TEXT,
159
+ timestamp TEXT NOT NULL,
160
+ tool TEXT NOT NULL,
161
+ input TEXT NOT NULL,
162
+ outcome TEXT NOT NULL,
163
+ exit_code INTEGER,
164
+ status_code INTEGER,
165
+ error_code TEXT,
166
+ output TEXT,
167
+ error TEXT,
168
+ parse_error TEXT,
169
+ evidence_bytes INTEGER NOT NULL,
170
+ UNIQUE(session_id, ordinal)
171
+ );
172
+ CREATE INDEX IF NOT EXISTS idx_tool_calls_session ON tool_calls(session_id, ordinal);
173
+ CREATE INDEX IF NOT EXISTS idx_tool_calls_tool ON tool_calls(tool COLLATE NOCASE);
174
+ CREATE INDEX IF NOT EXISTS idx_tool_calls_outcome ON tool_calls(outcome);
175
+
176
+ CREATE TABLE IF NOT EXISTS tool_call_programs (
177
+ call_key TEXT NOT NULL,
178
+ program TEXT NOT NULL COLLATE NOCASE,
179
+ PRIMARY KEY(call_key, program)
180
+ );
181
+ CREATE INDEX IF NOT EXISTS idx_tool_call_programs_program ON tool_call_programs(program, call_key);
182
+
183
+ -- Ordered static program sites retain repeated commands within one Bash call.
184
+ -- The complete redacted command stays on tool_calls.input; these rows contain
185
+ -- only the normalized program and whether it is a wrapper or effective target.
186
+ CREATE TABLE IF NOT EXISTS tool_program_occurrences (
187
+ call_key TEXT NOT NULL,
188
+ occurrence_ordinal INTEGER NOT NULL,
189
+ program TEXT NOT NULL COLLATE NOCASE,
190
+ role TEXT NOT NULL CHECK(role IN ('wrapper', 'effective')),
191
+ PRIMARY KEY(call_key, occurrence_ordinal)
192
+ );
193
+ CREATE INDEX IF NOT EXISTS idx_tool_program_occurrences_program
194
+ ON tool_program_occurrences(program, call_key);
195
+
196
+ CREATE VIRTUAL TABLE IF NOT EXISTS tool_call_text USING fts5(
197
+ call_key UNINDEXED,
198
+ tool,
199
+ input,
200
+ output,
201
+ error,
202
+ tokenize = 'trigram'
203
+ );
204
+
205
+ -- Independent of scan_ledger: schema migration never forces the normal
206
+ -- session index to reread history. Existing transcripts are backfilled only
207
+ -- by the explicit, bounded agents sessions backfill tools command.
208
+ CREATE TABLE IF NOT EXISTS tool_scan_ledger (
209
+ session_id TEXT PRIMARY KEY,
210
+ file_path TEXT NOT NULL UNIQUE,
211
+ file_mtime_ms INTEGER NOT NULL,
212
+ file_size INTEGER NOT NULL,
213
+ extractor_version INTEGER NOT NULL,
214
+ indexed_at INTEGER NOT NULL,
215
+ call_count INTEGER NOT NULL,
216
+ evidence_bytes INTEGER NOT NULL
217
+ );
218
+
150
219
  -- Skill/slash-command usage per session (#12), computed from a session's
151
220
  -- parsed transcript (extractSkills / extractSlashCommands, session/highlights.ts)
152
221
  -- and joined at write time against the currently-installed resource/plugin
@@ -390,12 +459,11 @@ function migrateSchema(db, fromVersion) {
390
459
  .prepare(`SELECT id, agent, file_path FROM sessions WHERE machine IS NULL OR machine = ''`)
391
460
  .all();
392
461
  const upd = db.prepare(`UPDATE sessions SET machine = ? WHERE id = ?`);
393
- const txn = db.transaction((items) => {
394
- for (const r of items) {
395
- upd.run(machineForSessionFile(r.file_path, r.agent), r.id);
396
- }
397
- });
398
- txn(rows);
462
+ // migrateSchema runs inside getDB's schema transaction, so these writes
463
+ // deliberately share that transaction instead of opening a nested one.
464
+ for (const row of rows) {
465
+ upd.run(machineForSessionFile(row.file_path, row.agent), row.id);
466
+ }
399
467
  }
400
468
  if (fromVersion < 19) {
401
469
  // v18 → v19: actor provenance (RUSH-2018) — who initiated the session and
@@ -430,7 +498,7 @@ function migrateSchema(db, fromVersion) {
430
498
  db.exec(`DELETE FROM scan_ledger; DELETE FROM dir_ledger;`);
431
499
  }
432
500
  if (fromVersion < 22) {
433
- // v21 → v22 (main): persist the transcript's tool-call count.
501
+ // v21 → v22: persist the transcript's aggregate tool-call count.
434
502
  const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
435
503
  if (!cols.some(c => c.name === 'tool_call_count'))
436
504
  db.exec(`ALTER TABLE sessions ADD COLUMN tool_call_count INTEGER`);
@@ -445,10 +513,7 @@ function migrateSchema(db, fromVersion) {
445
513
  //
446
514
  // Deliberately NO DEFAULT (NULL on ALTER, for every pre-existing row):
447
515
  // NULL means "not yet computed by this scanner" (a legacy row), distinct
448
- // from a real, computed 0/false. Collapsing that into a DEFAULT 0 would
449
- // make every un-rescanned row look like a definite "never used
450
- // browser/computer" and permanently blind the picker's fallback path for
451
- // any row that isn't rescanned before it's next viewed.
516
+ // from a real, computed 0/false.
452
517
  const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
453
518
  if (!cols.some(c => c.name === 'used_browser'))
454
519
  db.exec(`ALTER TABLE sessions ADD COLUMN used_browser INTEGER`);
@@ -458,12 +523,7 @@ function migrateSchema(db, fromVersion) {
458
523
  if (fromVersion < 24) {
459
524
  // v23 → v24: session_resource_usage (#12) — skill/slash-command usage per
460
525
  // session, joined against the currently-installed resource/plugin for
461
- // provenance (repo_root/snapshot_sha/plugin/source). A brand-new table
462
- // needs only CREATE TABLE IF NOT EXISTS, already present in SCHEMA for a
463
- // fresh DB — this repeats it for an existing DB whose SCHEMA exec ran
464
- // before the table definition was added. No ledger wipe: this table is
465
- // populated by writeResourceUsage() at upsert time, independent of the
466
- // scan ledgers.
526
+ // provenance. No ledger wipe: writeResourceUsage() owns this table.
467
527
  db.exec(`
468
528
  CREATE TABLE IF NOT EXISTS session_resource_usage (
469
529
  session_id TEXT NOT NULL,
@@ -480,6 +540,144 @@ function migrateSchema(db, fromVersion) {
480
540
  CREATE INDEX IF NOT EXISTS idx_session_resource_usage_plugin ON session_resource_usage(plugin);
481
541
  `);
482
542
  }
543
+ if (fromVersion < 25) {
544
+ // v24 → v25: tool-call evidence uses an independent ledger. Do not clear
545
+ // scan_ledger or dir_ledger: normal session listing stays warm, while tool
546
+ // history is filled once on demand.
547
+ db.exec(`
548
+ CREATE TABLE IF NOT EXISTS tool_calls (
549
+ call_key TEXT PRIMARY KEY,
550
+ session_id TEXT NOT NULL,
551
+ ordinal INTEGER NOT NULL,
552
+ source_call_id TEXT,
553
+ timestamp TEXT NOT NULL,
554
+ tool TEXT NOT NULL,
555
+ input TEXT NOT NULL,
556
+ outcome TEXT NOT NULL,
557
+ exit_code INTEGER,
558
+ status_code INTEGER,
559
+ error_code TEXT,
560
+ output TEXT,
561
+ error TEXT,
562
+ parse_error TEXT,
563
+ evidence_bytes INTEGER NOT NULL,
564
+ UNIQUE(session_id, ordinal)
565
+ );
566
+ CREATE INDEX IF NOT EXISTS idx_tool_calls_session ON tool_calls(session_id, ordinal);
567
+ CREATE INDEX IF NOT EXISTS idx_tool_calls_tool ON tool_calls(tool COLLATE NOCASE);
568
+ CREATE INDEX IF NOT EXISTS idx_tool_calls_outcome ON tool_calls(outcome);
569
+ CREATE TABLE IF NOT EXISTS tool_call_programs (
570
+ call_key TEXT NOT NULL,
571
+ program TEXT NOT NULL COLLATE NOCASE,
572
+ PRIMARY KEY(call_key, program)
573
+ );
574
+ CREATE INDEX IF NOT EXISTS idx_tool_call_programs_program ON tool_call_programs(program, call_key);
575
+ CREATE VIRTUAL TABLE IF NOT EXISTS tool_call_text USING fts5(
576
+ call_key UNINDEXED,
577
+ tool,
578
+ input,
579
+ output,
580
+ error,
581
+ tokenize = 'unicode61 remove_diacritics 2'
582
+ );
583
+ CREATE TABLE IF NOT EXISTS tool_scan_ledger (
584
+ file_path TEXT PRIMARY KEY,
585
+ file_mtime_ms INTEGER NOT NULL,
586
+ file_size INTEGER NOT NULL,
587
+ extractor_version INTEGER NOT NULL,
588
+ indexed_at INTEGER NOT NULL,
589
+ call_count INTEGER NOT NULL,
590
+ evidence_bytes INTEGER NOT NULL
591
+ );
592
+ `);
593
+ }
594
+ if (fromVersion < 26) {
595
+ // v25 → v26: make append accounting O(changed calls), including empty
596
+ // deltas, instead of reading every historical evidence row per append.
597
+ const callCols = db.prepare(`PRAGMA table_info(tool_calls)`).all();
598
+ if (!callCols.some((column) => column.name === 'evidence_bytes')) {
599
+ db.exec(`ALTER TABLE tool_calls ADD COLUMN evidence_bytes INTEGER NOT NULL DEFAULT 0`);
600
+ }
601
+ const ledgerCols = db.prepare(`PRAGMA table_info(tool_scan_ledger)`).all();
602
+ if (!ledgerCols.some((column) => column.name === 'evidence_bytes')) {
603
+ db.exec(`ALTER TABLE tool_scan_ledger ADD COLUMN evidence_bytes INTEGER NOT NULL DEFAULT 0`);
604
+ }
605
+ // The first tool schema existed only in prerelease development builds. Force its tool
606
+ // evidence through one bounded rebuild rather than trusting zeroed totals.
607
+ db.exec(`DELETE FROM tool_scan_ledger`);
608
+ }
609
+ if (fromVersion < 27) {
610
+ // v26 → v27: the original unicode word tokenizer could not prefilter the
611
+ // substring semantics promised by input/output/error queries. Rebuild only
612
+ // the derived FTS table from already-redacted call rows; transcripts and
613
+ // both scan ledgers remain warm.
614
+ db.exec(`
615
+ DROP TABLE IF EXISTS tool_call_text;
616
+ CREATE VIRTUAL TABLE tool_call_text USING fts5(
617
+ call_key UNINDEXED,
618
+ tool,
619
+ input,
620
+ output,
621
+ error,
622
+ tokenize = 'trigram'
623
+ );
624
+ INSERT INTO tool_call_text (call_key, tool, input, output, error)
625
+ SELECT call_key, tool, input, coalesce(output, ''), coalesce(error, '')
626
+ FROM tool_calls;
627
+ `);
628
+ }
629
+ if (fromVersion < 28) {
630
+ // v27 → v28: retain every static program occurrence instead of only the
631
+ // distinct program set. The source transcript is rebuilt only by the
632
+ // explicit tools backfill; normal session and directory ledgers stay warm.
633
+ db.exec(`
634
+ CREATE TABLE IF NOT EXISTS tool_program_occurrences (
635
+ call_key TEXT NOT NULL,
636
+ occurrence_ordinal INTEGER NOT NULL,
637
+ program TEXT NOT NULL COLLATE NOCASE,
638
+ role TEXT NOT NULL CHECK(role IN ('wrapper', 'effective')),
639
+ PRIMARY KEY(call_key, occurrence_ordinal)
640
+ );
641
+ CREATE INDEX IF NOT EXISTS idx_tool_program_occurrences_program
642
+ ON tool_program_occurrences(program, call_key);
643
+ DELETE FROM tool_scan_ledger;
644
+ `);
645
+ }
646
+ if (fromVersion < 29) {
647
+ // v28 → v29: coverage and query planning address the independent tool
648
+ // ledger by session id. Rebuild only this derived ledger so a tool query
649
+ // never has to resolve or stat transcript paths.
650
+ db.exec(`
651
+ DROP TABLE tool_scan_ledger;
652
+ CREATE TABLE tool_scan_ledger (
653
+ session_id TEXT PRIMARY KEY,
654
+ file_path TEXT NOT NULL UNIQUE,
655
+ file_mtime_ms INTEGER NOT NULL,
656
+ file_size INTEGER NOT NULL,
657
+ extractor_version INTEGER NOT NULL,
658
+ indexed_at INTEGER NOT NULL,
659
+ call_count INTEGER NOT NULL,
660
+ evidence_bytes INTEGER NOT NULL
661
+ );
662
+ `);
663
+ }
664
+ if (fromVersion < 30) {
665
+ // v29 → v30: prerelease tool-index builds temporarily used schema versions
666
+ // later owned by independent main migrations. Repair from the physical
667
+ // schema because a v29 marker alone cannot prove these columns are present.
668
+ const cols = new Set(db.prepare(`PRAGMA table_info(sessions)`).all().map((column) => column.name));
669
+ if (!cols.has('tool_call_count')) {
670
+ db.exec(`
671
+ ALTER TABLE sessions ADD COLUMN tool_call_count INTEGER;
672
+ DELETE FROM scan_ledger;
673
+ DELETE FROM dir_ledger;
674
+ `);
675
+ }
676
+ if (!cols.has('used_browser'))
677
+ db.exec(`ALTER TABLE sessions ADD COLUMN used_browser INTEGER`);
678
+ if (!cols.has('used_computer'))
679
+ db.exec(`ALTER TABLE sessions ADD COLUMN used_computer INTEGER`);
680
+ }
483
681
  }
484
682
  /** Open (or return the cached) sessions database, applying migrations as needed. */
485
683
  export function getDB() {
@@ -487,24 +685,37 @@ export function getDB() {
487
685
  return dbInstance;
488
686
  fs.mkdirSync(SESSIONS_DIR, { recursive: true });
489
687
  const db = new Database(DB_PATH);
490
- db.pragma('journal_mode = WAL');
491
- db.pragma('synchronous = NORMAL');
492
- db.pragma('temp_store = MEMORY');
493
- // Wait up to 30s instead of failing immediately on SQLITE_BUSY. Multiple
688
+ // Wait up to 30s instead of failing immediately on SQLITE_BUSY. Install the
689
+ // handler before journal_mode: concurrent first opens can race for its schema
690
+ // lock, before a later busy_timeout would have a chance to wait. Multiple
494
691
  // agents (CLIs, skills, hooks) open this DB concurrently. The first scan of
495
692
  // a new version home can take longer than 10s; concurrent callers need enough
496
693
  // headroom to wait. The ledger-recheck in upsertSessionsBatch makes
497
694
  // subsequent writers near-instant, so 30s is a rarely-reached safety net.
498
695
  db.pragma('busy_timeout = 30000');
696
+ db.pragma('journal_mode = WAL');
697
+ db.pragma('synchronous = NORMAL');
698
+ db.pragma('temp_store = MEMORY');
499
699
  db.exec(SCHEMA);
500
- const current = db.prepare(`SELECT value FROM meta WHERE key = 'schema_version'`).get();
501
- const currentVersion = current ? parseInt(current.value, 10) : 0;
502
- if (!current) {
700
+ const readSchemaVersion = () => {
701
+ const row = db.prepare(`SELECT value FROM meta WHERE key = 'schema_version'`).get();
702
+ return row ? parseInt(row.value, 10) : undefined;
703
+ };
704
+ const currentVersion = readSchemaVersion();
705
+ if (currentVersion === undefined) {
503
706
  db.prepare(`INSERT OR IGNORE INTO meta(key, value) VALUES ('schema_version', ?)`).run(String(SCHEMA_VERSION));
504
707
  }
505
708
  else if (currentVersion < SCHEMA_VERSION) {
506
- migrateSchema(db, currentVersion);
507
- db.prepare(`INSERT OR REPLACE INTO meta(key, value) VALUES ('schema_version', ?)`).run(String(SCHEMA_VERSION));
709
+ // Re-read after BEGIN IMMEDIATE acquires the writer lock. A second process
710
+ // may have completed the migration while this connection was waiting.
711
+ const migrate = db.transaction(() => {
712
+ const lockedVersion = readSchemaVersion();
713
+ if (lockedVersion === undefined || lockedVersion >= SCHEMA_VERSION)
714
+ return;
715
+ migrateSchema(db, lockedVersion);
716
+ db.prepare(`INSERT OR REPLACE INTO meta(key, value) VALUES ('schema_version', ?)`).run(String(SCHEMA_VERSION));
717
+ });
718
+ migrate();
508
719
  }
509
720
  // Index last_activity only after the column is guaranteed to exist — fresh DBs
510
721
  // get it from CREATE TABLE above, existing pre-v8 DBs from the migration just
@@ -1186,9 +1397,38 @@ export function upsertSessionsBatch(entries) {
1186
1397
  const byPath = new Map(entries
1187
1398
  .filter(e => e.scan && e.meta.filePath)
1188
1399
  .map(e => [canonicalLedgerKey(e.meta.filePath), e]));
1189
- const enrichedEntries = entries.map(entry => entry.meta.agent === 'claude' || entry.meta.agent === 'codex'
1190
- ? entry
1191
- : { ...entry, meta: enrichCachedSessionMeta(entry.meta) });
1400
+ const enrichedEntries = entries.map(entry => {
1401
+ if (entry.meta.agent === 'claude' || entry.meta.agent === 'codex' || !entry.meta.filePath)
1402
+ return entry;
1403
+ try {
1404
+ const toolSourcePath = toolEvidenceSourcePath(entry.meta.filePath, entry.meta.agent);
1405
+ const toolScan = toolSourcePath === entry.meta.filePath
1406
+ ? entry.scan
1407
+ : (() => {
1408
+ const stat = fs.statSync(toolSourcePath);
1409
+ return { fileMtimeMs: stat.mtimeMs, fileSize: stat.size };
1410
+ })();
1411
+ // Non-resumable harnesses already parse here for todos/recent dirs. Derive
1412
+ // the tool index from those same in-memory events: no second file read.
1413
+ const events = parseSession(entry.meta.filePath, entry.meta.agent);
1414
+ writeResourceUsage(entry.meta.id, events, entry.meta.cwd);
1415
+ return {
1416
+ ...entry,
1417
+ meta: {
1418
+ ...entry.meta,
1419
+ todos: extractTodoProgressFromEvents(events),
1420
+ recentDirectoriesTouched: extractRecentDirectoriesTouched(events, entry.meta.cwd),
1421
+ },
1422
+ toolCalls: toolCallsFromEvents(events),
1423
+ toolScan,
1424
+ toolIndexMode: 'replace',
1425
+ };
1426
+ }
1427
+ catch {
1428
+ return entry;
1429
+ }
1430
+ });
1431
+ const writtenEntries = [];
1192
1432
  const txn = db.transaction((items) => {
1193
1433
  // Re-read the ledger now that we hold the write lock. Any file committed
1194
1434
  // by a concurrent process since our pre-scan is visible here.
@@ -1208,7 +1448,8 @@ export function upsertSessionsBatch(entries) {
1208
1448
  }
1209
1449
  }
1210
1450
  }
1211
- for (const { meta, content, scan, parserState, contentText } of items) {
1451
+ for (const entry of items) {
1452
+ const { meta, content, scan, parserState, contentText } = entry;
1212
1453
  if (alreadyIndexed.has(meta.id))
1213
1454
  continue;
1214
1455
  // Per-row guard: one malformed session (e.g. a required field that resolves to
@@ -1280,6 +1521,7 @@ export function upsertSessionsBatch(entries) {
1280
1521
  if (scan && meta.filePath) {
1281
1522
  ledger.run(canonicalLedgerKey(meta.filePath), scan.fileMtimeMs, scan.fileSize, now, parserState ?? null, contentText ?? null);
1282
1523
  }
1524
+ writtenEntries.push(entry);
1283
1525
  }
1284
1526
  catch (err) {
1285
1527
  if (process.stderr.isTTY) {
@@ -1289,6 +1531,20 @@ export function upsertSessionsBatch(entries) {
1289
1531
  }
1290
1532
  });
1291
1533
  txn(enrichedEntries);
1534
+ // Tool evidence shares the transcript parse above but owns an independent
1535
+ // transaction/ledger. If this write fails, the normal session row remains
1536
+ // valid and ensureToolIndex retries from the missing tool ledger later.
1537
+ for (const entry of writtenEntries) {
1538
+ const toolScan = entry.toolScan ?? entry.scan;
1539
+ if (!toolScan || !entry.toolCalls)
1540
+ continue;
1541
+ try {
1542
+ persistToolCalls(db, entry.meta, entry.toolCalls, toolScan, entry.toolIndexMode ?? 'replace');
1543
+ }
1544
+ catch {
1545
+ // Boundary is intentionally retryable via tool_scan_ledger.
1546
+ }
1547
+ }
1292
1548
  }
1293
1549
  /**
1294
1550
  * Sync labels for a set of sessions. For each id in the map, if the stored
@@ -1637,13 +1893,26 @@ export function querySessions(options = {}) {
1637
1893
  : 'ORDER BY IFNULL(last_activity, timestamp) DESC, timestamp DESC';
1638
1894
  const sql = `SELECT * FROM sessions ${clause} ${orderClause} ${limitClause}`;
1639
1895
  const rows = db.prepare(sql).all(...params);
1896
+ if (options.skipExistenceCheck) {
1897
+ const trimmed = options.limit ? rows.slice(0, options.limit) : rows;
1898
+ return trimmed.map(rowToMeta);
1899
+ }
1640
1900
  // Belt-and-suspenders: drop rows whose JSONL no longer exists on disk. The
1641
1901
  // authoritative fix is to keep file_path in sync (see updateSessionFilePaths
1642
1902
  // callers), but skipping vanished rows here prevents phantom sessions from
1643
1903
  // surfacing in the Factory UI if any code path forgets to rewrite (#136).
1644
1904
  // Synthetic rows (OpenClaw channels/cron — see scanOpenClawIncremental) carry
1645
1905
  // an empty file_path and are exempt; they're keyed by CLI output, not files.
1646
- const live = rows.filter(r => !r.file_path || fs.existsSync(r.file_path));
1906
+ const missing = rows.filter(r => r.file_path && !fs.existsSync(r.file_path));
1907
+ if (missing.length > 0) {
1908
+ const purge = db.transaction(() => {
1909
+ for (const row of missing)
1910
+ purgeToolCalls(db, row.id);
1911
+ });
1912
+ purge();
1913
+ }
1914
+ const missingIds = new Set(missing.map((row) => row.id));
1915
+ const live = rows.filter(r => !r.file_path || !missingIds.has(r.id));
1647
1916
  const trimmed = options.limit ? live.slice(0, options.limit) : live;
1648
1917
  return trimmed.map(rowToMeta);
1649
1918
  }
@@ -9,6 +9,9 @@
9
9
  import type { SessionAgentId, SessionEvent, SessionMeta, TodoProgress } from './types.js';
10
10
  export { machineForSessionFile } from './origin-machine.js';
11
11
  import { type ScanStamp } from './db.js';
12
+ import { ToolCallCollector, type IndexedToolCall, type ToolCallCollectorSnapshot } from './tool-calls.js';
13
+ /** One JSONL record may not force an unbounded string allocation. */
14
+ export declare const SESSION_JSONL_LINE_MAX_BYTES: number;
12
15
  /** Options controlling which sessions to discover and how to report progress. */
13
16
  export interface DiscoverOptions {
14
17
  agent?: SessionAgentId;
@@ -27,6 +30,8 @@ export interface DiscoverOptions {
27
30
  /** Match any session whose cwd equals this or is a descendant. Overrides `cwd`. */
28
31
  cwdPrefix?: string;
29
32
  limit?: number;
33
+ /** Internal indexed-query path: return every row in scope instead of the default page. */
34
+ unbounded?: boolean;
30
35
  /** Filter sessions newer than this (ISO timestamp or "7d", "30d", "90d") */
31
36
  since?: string;
32
37
  /** Filter sessions older than this (ISO timestamp) */
@@ -39,6 +44,8 @@ export interface DiscoverOptions {
39
44
  origin?: 'cli' | 'routine';
40
45
  /** Column to order results by (all descending): 'timestamp' (default), 'cost', or 'duration'. */
41
46
  sortBy?: 'timestamp' | 'cost' | 'duration';
47
+ /** Trust scan-ledger rows without stat'ing every returned transcript. */
48
+ skipExistenceCheck?: boolean;
42
49
  /** Called as each agent makes parsing progress. Totals count only files that need re-parsing (cache misses). */
43
50
  onProgress?: (progress: ScanProgress) => void;
44
51
  /** Only sessions that invoked this skill (#12) — see QueryOptions.skill in session/db.ts. */
@@ -139,6 +146,22 @@ interface CodexSessionScan {
139
146
  * TTL, no external lock files needed.
140
147
  */
141
148
  export declare function discoverSessions(options?: DiscoverOptions): Promise<SessionMeta[]>;
149
+ /** Read the current SQLite snapshot without scanning or parsing transcript files. */
150
+ export declare function queryIndexedSessions(options?: DiscoverOptions, indexedOptions?: {
151
+ resolveLinear?: boolean;
152
+ skipExistenceCheck?: boolean;
153
+ }): Promise<SessionMeta[]>;
154
+ /**
155
+ * Drop unmanaged rows for agents that HAVE managed versions.
156
+ *
157
+ * Scoping happens here, at query time, rather than by narrowing the scan: the index
158
+ * stays complete, so `--unmanaged` needs no re-scan and every other consumer of the
159
+ * DB (watchdog, the Factory watcher, `--roots`) is unaffected.
160
+ *
161
+ * An agent with no managed versions is left alone entirely — someone who has never
162
+ * run `agents add` sees exactly what they saw before.
163
+ */
164
+ export declare function scopeToManaged(sessions: SessionMeta[], agents: readonly SessionAgentId[], options?: DiscoverOptions): SessionMeta[];
142
165
  /**
143
166
  * How many agents' dotfile dirs we scan at once, and the minimum spacing between
144
167
  * successive scan starts. A small bound + stagger turns a simultaneous bulk
@@ -370,6 +393,8 @@ export declare function readCodexMeta(filePath: string, resolveAccount?: () => s
370
393
  content: string;
371
394
  parserState?: string;
372
395
  contentText?: string;
396
+ toolCalls?: IndexedToolCall[];
397
+ toolIndexMode: 'replace' | 'append';
373
398
  } | null>;
374
399
  /**
375
400
  * Mutable accumulator for the Claude transcript reducer. One field per local
@@ -408,6 +433,7 @@ export interface ClaudeParseState {
408
433
  plan?: string;
409
434
  checklistEvents: SessionEvent[];
410
435
  recentDirectoriesTouched: string[];
436
+ toolCollector: ToolCallCollector;
411
437
  /** Skill-invocation tool_use events, held for extractSkills() at finalize (#12). */
412
438
  skillEvents: SessionEvent[];
413
439
  /** Slash-command events (user-typed <command-name> wrapper OR a SlashCommand
@@ -442,8 +468,9 @@ export declare function scanClaudeSession(filePath: string): Promise<ClaudeSessi
442
468
  * exact even when the recent window is smaller than the true count.
443
469
  */
444
470
  export interface ClaudeParserState {
445
- v: 1;
471
+ v: 2;
446
472
  offset: number;
473
+ jsonlDroppingOversizedLine?: boolean;
447
474
  timestamp?: string;
448
475
  cwd?: string;
449
476
  gitBranch?: string;
@@ -475,6 +502,7 @@ export interface ClaudeParserState {
475
502
  contentText?: string;
476
503
  checklistEvents: SessionEvent[];
477
504
  recentDirectoriesTouched: string[];
505
+ toolCalls: ToolCallCollectorSnapshot;
478
506
  /** #12: see ClaudeParseState.skillEvents/slashCommandEvents. */
479
507
  skillEvents: SessionEvent[];
480
508
  slashCommandEvents: SessionEvent[];
@@ -484,7 +512,7 @@ export interface ClaudeParserState {
484
512
  * `offset` bytes consumed. Round-trips through {@link hydrateClaudeParseState}
485
513
  * so incremental replay equals a full parse.
486
514
  */
487
- export declare function serializeClaudeParserState(state: ClaudeParseState, offset: number): ClaudeParserState;
515
+ export declare function serializeClaudeParserState(state: ClaudeParseState, offset: number, jsonlDroppingOversizedLine?: boolean): ClaudeParserState;
488
516
  /**
489
517
  * Rebuild a live {@link ClaudeParseState} from a persisted continuation so that
490
518
  * applying the appended lines yields the same accumulator a full parse would.
@@ -517,6 +545,7 @@ export declare function scanClaudeSessionIncremental(filePath: string, fromOffse
517
545
  scan: ClaudeSessionScan;
518
546
  newState: ClaudeParserState;
519
547
  newOffset: number;
548
+ toolCalls: IndexedToolCall[];
520
549
  }>;
521
550
  /** Test seam: read the (incremental, full) Claude parse counters. */
522
551
  export declare function __claudeScanBranchCountsForTest(): {
@@ -553,6 +582,7 @@ export interface CodexParseState {
553
582
  spawnedTeam?: string;
554
583
  checklistEvents: SessionEvent[];
555
584
  recentDirectoriesTouched: string[];
585
+ toolCollector: ToolCallCollector;
556
586
  /** #12: see ClaudeParseState.skillEvents. Empty in practice today — no
557
587
  * verified Codex skill-invocation tool name — but wired for parity so a
558
588
  * future confirmed tool name (or a literal 'SlashCommand' function_call)
@@ -586,8 +616,9 @@ export declare function finalizeCodexScan(state: CodexParseState): CodexSessionS
586
616
  * finalize is identical after a resume.
587
617
  */
588
618
  export interface CodexParserState {
589
- v: 1;
619
+ v: 2;
590
620
  offset: number;
621
+ jsonlDroppingOversizedLine?: boolean;
591
622
  sessionId?: string;
592
623
  timestamp?: string;
593
624
  cwd?: string;
@@ -610,13 +641,14 @@ export interface CodexParserState {
610
641
  contentText?: string;
611
642
  checklistEvents: SessionEvent[];
612
643
  recentDirectoriesTouched: string[];
644
+ toolCalls: ToolCallCollectorSnapshot;
613
645
  }
614
646
  /**
615
647
  * Snapshot a live {@link CodexParseState} into its serializable form at `offset`
616
648
  * bytes consumed. Round-trips through {@link hydrateCodexParseState} so
617
649
  * incremental replay equals a full parse.
618
650
  */
619
- export declare function serializeCodexParserState(state: CodexParseState, offset: number): CodexParserState;
651
+ export declare function serializeCodexParserState(state: CodexParseState, offset: number, jsonlDroppingOversizedLine?: boolean): CodexParserState;
620
652
  /**
621
653
  * Rebuild a live {@link CodexParseState} from a persisted continuation so that
622
654
  * applying the appended lines yields the same accumulator a full parse would.
@@ -635,9 +667,9 @@ export declare function hydrateCodexParseState(prior: CodexParserState): CodexPa
635
667
  *
636
668
  * Same trailing-line discipline as {@link scanClaudeSessionIncremental}: apply
637
669
  * ONLY the run of newline-terminated lines (slice at the last `'\n'`), and set
638
- * `newOffset = fromOffset + consumedBytes`. Any tail after the last `'\n'` —
639
- * syntactically broken OR a complete-but-not-yet-terminated record — is DEFERRED
640
- * to the next pass once its `'\n'` lands. Codex `messageCount` is additive with
670
+ * `newOffset = fromOffset + consumedBytes`. An ordinary tail after the last
671
+ * `'\n'` is deferred; an oversized one advances under a persisted discard bit.
672
+ * Codex `messageCount` is additive with
641
673
  * NO dedup, so re-reading a still-unterminated complete line would double-count
642
674
  * it; deferring prevents that (the bug class prix-cloud caught for Claude).
643
675
  */
@@ -645,6 +677,7 @@ export declare function scanCodexSessionIncremental(filePath: string, fromOffset
645
677
  scan: CodexSessionScan;
646
678
  newState: CodexParserState;
647
679
  newOffset: number;
680
+ toolCalls: IndexedToolCall[];
648
681
  }>;
649
682
  /** Test seam: read the (incremental, full) Codex parse counters. */
650
683
  export declare function __codexScanBranchCountsForTest(): {