@phnx-labs/agents-cli 1.22.53 → 1.22.54

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 (134) hide show
  1. package/CHANGELOG.md +190 -0
  2. package/README.md +41 -8
  3. package/dist/bootstrap.js +55 -154
  4. package/dist/cli/command-registry.d.ts +5 -0
  5. package/dist/cli/command-registry.js +8 -1
  6. package/dist/commands/accounts.js +219 -173
  7. package/dist/commands/apply.js +6 -3
  8. package/dist/commands/auth-mint.d.ts +8 -0
  9. package/dist/commands/auth-mint.js +96 -0
  10. package/dist/commands/auth.js +5 -1
  11. package/dist/commands/browser.js +1 -1
  12. package/dist/commands/cost.js +8 -2
  13. package/dist/commands/daemon.js +2 -2
  14. package/dist/commands/doctor.js +6 -1
  15. package/dist/commands/exec.js +10 -8
  16. package/dist/commands/focus.d.ts +1 -0
  17. package/dist/commands/focus.js +2 -2
  18. package/dist/commands/go.d.ts +5 -4
  19. package/dist/commands/go.js +7 -7
  20. package/dist/commands/insights.js +9 -0
  21. package/dist/commands/monitors.js +85 -30
  22. package/dist/commands/output.js +8 -2
  23. package/dist/commands/repo.js +18 -0
  24. package/dist/commands/secrets.js +33 -14
  25. package/dist/commands/sessions.d.ts +20 -12
  26. package/dist/commands/sessions.js +64 -20
  27. package/dist/commands/setup-accounts.d.ts +8 -0
  28. package/dist/commands/setup-accounts.js +47 -0
  29. package/dist/commands/setup.d.ts +1 -1
  30. package/dist/commands/setup.js +11 -2
  31. package/dist/commands/share.d.ts +52 -3
  32. package/dist/commands/share.js +262 -18
  33. package/dist/commands/ssh.d.ts +7 -0
  34. package/dist/commands/ssh.js +18 -2
  35. package/dist/commands/status.js +14 -0
  36. package/dist/commands/view.d.ts +3 -1
  37. package/dist/commands/view.js +5 -4
  38. package/dist/lib/account-registry.js +15 -3
  39. package/dist/lib/accounting/rotate.d.ts +20 -6
  40. package/dist/lib/accounting/rotate.js +38 -7
  41. package/dist/lib/accounting/usage.d.ts +37 -1
  42. package/dist/lib/accounting/usage.js +71 -6
  43. package/dist/lib/agent-spec/agents.d.ts +5 -2
  44. package/dist/lib/agent-spec/agents.js +25 -7
  45. package/dist/lib/analytics/mix-commands.js +12 -6
  46. package/dist/lib/auth-mint.d.ts +150 -0
  47. package/dist/lib/auth-mint.js +434 -0
  48. package/dist/lib/browser/remote-control.d.ts +9 -7
  49. package/dist/lib/browser/remote-control.js +9 -7
  50. package/dist/lib/claude-account-token.d.ts +10 -0
  51. package/dist/lib/claude-account-token.js +14 -4
  52. package/dist/lib/config-drift.d.ts +37 -0
  53. package/dist/lib/config-drift.js +72 -0
  54. package/dist/lib/daemon/auth-sync-service.d.ts +19 -0
  55. package/dist/lib/daemon/auth-sync-service.js +34 -0
  56. package/dist/lib/daemon/daemon.js +30 -4
  57. package/dist/lib/daemon-services.d.ts +1 -1
  58. package/dist/lib/daemon-services.js +5 -0
  59. package/dist/lib/device-config.d.ts +3 -3
  60. package/dist/lib/device-config.js +5 -5
  61. package/dist/lib/devices/connect.d.ts +26 -0
  62. package/dist/lib/devices/connect.js +48 -1
  63. package/dist/lib/devices/doctor-findings.d.ts +5 -1
  64. package/dist/lib/devices/doctor-findings.js +19 -1
  65. package/dist/lib/exec.d.ts +28 -0
  66. package/dist/lib/exec.js +73 -7
  67. package/dist/lib/feed/feed.d.ts +1 -1
  68. package/dist/lib/feed/feed.js +23 -1
  69. package/dist/lib/feed-broadcast.js +1 -1
  70. package/dist/lib/fleet/apply.d.ts +11 -0
  71. package/dist/lib/fleet/apply.js +23 -3
  72. package/dist/lib/fleet/auth-sync.js +5 -3
  73. package/dist/lib/help.d.ts +9 -0
  74. package/dist/lib/help.js +29 -1
  75. package/dist/lib/hosts/passthrough.d.ts +1 -10
  76. package/dist/lib/hosts/passthrough.js +1 -13
  77. package/dist/lib/installations/versions.js +9 -1
  78. package/dist/lib/linux-userns.d.ts +58 -0
  79. package/dist/lib/linux-userns.js +116 -0
  80. package/dist/lib/memory.d.ts +26 -0
  81. package/dist/lib/memory.js +80 -1
  82. package/dist/lib/monitors/config.d.ts +11 -0
  83. package/dist/lib/monitors/config.js +8 -0
  84. package/dist/lib/monitors/engine.js +8 -1
  85. package/dist/lib/monitors/state.d.ts +37 -1
  86. package/dist/lib/monitors/state.js +79 -4
  87. package/dist/lib/permissions-registry.d.ts +2 -0
  88. package/dist/lib/permissions-registry.js +116 -14
  89. package/dist/lib/permissions.d.ts +5 -3
  90. package/dist/lib/permissions.js +25 -27
  91. package/dist/lib/profiles.d.ts +8 -7
  92. package/dist/lib/profiles.js +12 -0
  93. package/dist/lib/project-key.d.ts +9 -0
  94. package/dist/lib/project-key.js +11 -0
  95. package/dist/lib/secrets/bundles.d.ts +35 -0
  96. package/dist/lib/secrets/bundles.js +78 -1
  97. package/dist/lib/secrets/push.d.ts +3 -8
  98. package/dist/lib/secrets/push.js +18 -14
  99. package/dist/lib/secrets/remote.d.ts +9 -18
  100. package/dist/lib/secrets/remote.js +11 -26
  101. package/dist/lib/secrets/reserved-sync.d.ts +65 -0
  102. package/dist/lib/secrets/reserved-sync.js +129 -0
  103. package/dist/lib/self-heal/checks/hook-manifest.d.ts +2 -0
  104. package/dist/lib/self-heal/checks/hook-manifest.js +56 -0
  105. package/dist/lib/self-heal/registry.js +4 -0
  106. package/dist/lib/self-heal/types.d.ts +1 -1
  107. package/dist/lib/session/active.js +1 -4
  108. package/dist/lib/session/db.d.ts +39 -4
  109. package/dist/lib/session/db.js +130 -29
  110. package/dist/lib/session/discover.d.ts +32 -4
  111. package/dist/lib/session/discover.js +119 -25
  112. package/dist/lib/session/insights.d.ts +14 -0
  113. package/dist/lib/session/insights.js +25 -2
  114. package/dist/lib/session/linear.js +1 -1
  115. package/dist/lib/session/shell-programs.d.ts +17 -0
  116. package/dist/lib/session/shell-programs.js +21 -0
  117. package/dist/lib/session/state.js +2 -1
  118. package/dist/lib/session/stream-render.js +2 -1
  119. package/dist/lib/session/tool-calls.js +2 -5
  120. package/dist/lib/session/trajectory-html.js +2 -1
  121. package/dist/lib/session/trajectory.js +3 -12
  122. package/dist/lib/session/types.d.ts +8 -0
  123. package/dist/lib/share/publish.d.ts +53 -5
  124. package/dist/lib/share/publish.js +99 -17
  125. package/dist/lib/share/worker-template.js +582 -57
  126. package/dist/lib/startup/root-command.js +2 -1
  127. package/dist/lib/state.d.ts +16 -0
  128. package/dist/lib/state.js +178 -46
  129. package/dist/lib/sync-status.d.ts +4 -0
  130. package/dist/lib/sync-status.js +3 -0
  131. package/dist/lib/traces/classify.js +24 -19
  132. package/dist/lib/usage-refresh.js +2 -1
  133. package/dist/lib/view-types.d.ts +2 -0
  134. package/package.json +2 -1
@@ -45,6 +45,7 @@ import { classifyHostLink, HOST_HEARTBEAT_STALE_MS } from './host-link.js';
45
45
  import { mapBounded } from '../concurrency.js';
46
46
  import { linearIssueUrl } from './linear.js';
47
47
  import { viewingInLabel } from './viewing-in.js';
48
+ import { claudeProjectDirName } from '../project-key.js';
48
49
  const execFileAsync = promisify(execFile);
49
50
  /**
50
51
  * The owner (actor id) to show for a session in `--active`. Prefers the actor
@@ -530,10 +531,6 @@ function readLiveTerminals() {
530
531
  }
531
532
  return Array.from(merged.values());
532
533
  }
533
- /** Convert an absolute cwd to the Claude-project folder name (slashes and dots → dashes). */
534
- function claudeProjectDirName(cwd) {
535
- return cwd.replace(/[/.]/g, '-');
536
- }
537
534
  /**
538
535
  * Process-local memo for Claude transcript path resolution. Each active-session
539
536
  * poll re-walks every Claude version-home `projects/` tree for every live pid
@@ -12,21 +12,41 @@ import { type IndexedToolCall } from './tool-calls.js';
12
12
  /** Current schema version; bumped when migrations are added. Exported so tests
13
13
  * assert against the constant instead of hardcoding a number that every bump
14
14
  * then has to chase (docs/sessions.md calls the constant the source of truth). */
15
- export declare const SCHEMA_VERSION = 41;
15
+ export declare const SCHEMA_VERSION = 42;
16
+ /**
17
+ * Bump to force the content extractor (assistant-answer text, alongside the
18
+ * user-prompt text every harness already accumulates) to re-derive on every
19
+ * session's next scan. Unlike RESOURCE_INDEX_VERSION this is read by the
20
+ * change-detector itself (`filterChangedEntries`, discover.ts) via
21
+ * `scan_ledger.extractor_version` — a stored version below this one is treated
22
+ * as "changed" even when the file's (mtime, size) are unchanged, so bumping it
23
+ * here backfills every existing session's assistant text on its next scan
24
+ * without a `DELETE FROM scan_ledger` (which would also throw away the
25
+ * resumable parser_state/content_text for Claude/Codex).
26
+ */
27
+ export declare const CONTENT_INDEX_VERSION = 1;
16
28
  /**
17
29
  * Bumping this invalidates every cached facet row without touching the schema
18
30
  * version, so a change to the extraction logic (a new metric, a corrected bucket)
19
31
  * re-derives on the next `agents insights` instead of silently reporting stale
20
32
  * numbers alongside fresh ones. Same role as RESOURCE_INDEX_VERSION.
21
33
  */
22
- /** Bump when facet extraction changes so cached rows recompute (stalls-by-model v6). */
23
- export declare const INSIGHTS_EXTRACTOR_VERSION = 6;
34
+ /** Bump when facet extraction changes so cached rows recompute (shell-command-by-binary v7). */
35
+ export declare const INSIGHTS_EXTRACTOR_VERSION = 7;
24
36
  export declare const SESSION_TOPIC_EXTRACTOR_VERSION = 1;
25
37
  /** File stat snapshot used to detect changes between scan runs. */
26
38
  export interface ScanStamp {
27
39
  fileMtimeMs: number;
28
40
  fileSize: number;
29
41
  scannedAt?: number;
42
+ /**
43
+ * `scan_ledger.extractor_version` as of the last scan, when read from the
44
+ * ledger (undefined for a freshly-computed stamp that hasn't been persisted
45
+ * yet). Compared against {@link CONTENT_INDEX_VERSION} by
46
+ * `filterChangedEntries` (discover.ts) to force a re-extract independent of
47
+ * (mtime, size).
48
+ */
49
+ extractorVersion?: number | null;
30
50
  }
31
51
  /** Filter and pagination options for querying the sessions table. */
32
52
  export interface QueryOptions {
@@ -163,6 +183,10 @@ interface ParserStateRow {
163
183
  fileMtimeMs: number;
164
184
  fileSize: number;
165
185
  scannedAt: number;
186
+ /** See {@link ScanStamp.extractorVersion}. A mismatch vs CONTENT_INDEX_VERSION
187
+ * means this continuation predates the current content extractor and MUST
188
+ * be treated as absent (forcing a full re-parse) rather than resumed from. */
189
+ extractorVersion: number | null;
166
190
  }
167
191
  /**
168
192
  * Bulk-load the resumable-parse continuation (parser_state + content_text) plus
@@ -208,11 +232,15 @@ export declare function recordDirScans(entries: Array<{
208
232
  * Upsert a session row and replace its FTS5 content in a single transaction.
209
233
  * `content` is the tokenizable user-prompt text; pass '' to leave the row unsearchable.
210
234
  */
211
- export declare function upsertSession(meta: SessionMeta, content: string, scan?: ScanStamp): void;
235
+ export declare function upsertSession(meta: SessionMeta, content: string, scan?: ScanStamp, assistantContent?: string): void;
212
236
  /** Batch-upsert sessions with their FTS5 content and scan stamps in a single transaction. */
213
237
  export declare function upsertSessionsBatch(entries: Array<{
214
238
  meta: SessionMeta;
215
239
  content: string;
240
+ /** Assistant-answer text, accumulated the same way as `content` (the
241
+ * user-prompt text) but stored in session_text's own `assistant` column
242
+ * with a lower BM25 weight — see BM25_WEIGHTS. */
243
+ assistantContent?: string;
216
244
  scan?: ScanStamp;
217
245
  parserState?: string;
218
246
  contentText?: string;
@@ -584,6 +612,13 @@ interface FtsHit {
584
612
  sessionId: string;
585
613
  score: number;
586
614
  matchedTerms: string[];
615
+ /**
616
+ * A short bm25 `snippet()` excerpt around the best-matching column (label,
617
+ * topic, project, user content, or assistant answer), with the matched
618
+ * term(s) wrapped in `**…**`. Absent for a handle/label-tier hit (tiers 1-3
619
+ * below), which has no excerpt to show — the label itself IS the match.
620
+ */
621
+ snippet?: string;
587
622
  }
588
623
  /**
589
624
  * Escape a raw user query into a safe FTS5 MATCH expression.
@@ -27,7 +27,19 @@ const DB_PATH = getSessionsDbPath();
27
27
  /** Current schema version; bumped when migrations are added. Exported so tests
28
28
  * assert against the constant instead of hardcoding a number that every bump
29
29
  * then has to chase (docs/sessions.md calls the constant the source of truth). */
30
- export const SCHEMA_VERSION = 41;
30
+ export const SCHEMA_VERSION = 42;
31
+ /**
32
+ * Bump to force the content extractor (assistant-answer text, alongside the
33
+ * user-prompt text every harness already accumulates) to re-derive on every
34
+ * session's next scan. Unlike RESOURCE_INDEX_VERSION this is read by the
35
+ * change-detector itself (`filterChangedEntries`, discover.ts) via
36
+ * `scan_ledger.extractor_version` — a stored version below this one is treated
37
+ * as "changed" even when the file's (mtime, size) are unchanged, so bumping it
38
+ * here backfills every existing session's assistant text on its next scan
39
+ * without a `DELETE FROM scan_ledger` (which would also throw away the
40
+ * resumable parser_state/content_text for Claude/Codex).
41
+ */
42
+ export const CONTENT_INDEX_VERSION = 1;
31
43
  /**
32
44
  * Bump to force `agents sessions backfill resources` to re-derive every
33
45
  * session's skill/slash-command tallies on its next run (resource_scan_ledger
@@ -54,10 +66,15 @@ function canonicalLedgerKey(filePath) {
54
66
  return filePath;
55
67
  }
56
68
  }
57
- // BM25 column weights for session_text: label > topic > project > content.
58
- // Higher weights make matches in that column rank higher.
59
- /** BM25 column weights for FTS5: label > topic > project > content. */
60
- const BM25_WEIGHTS = [5.0, 2.0, 1.5, 1.0];
69
+ // BM25 column weights for session_text: label > topic > project > content >
70
+ // assistant. Higher weights make matches in that column rank higher.
71
+ // `assistant` (the agent's own answers) is weighted BELOW `content` (the
72
+ // user's prompts): a user's own words are a stronger signal of "this is the
73
+ // session I meant" than the agent echoing/paraphrasing them back, so an
74
+ // assistant-only match still surfaces but ranks behind an equivalent
75
+ // user-prompt match.
76
+ /** BM25 column weights for FTS5: label > topic > project > content > assistant. */
77
+ const BM25_WEIGHTS = [5.0, 2.0, 1.5, 1.0, 0.5];
61
78
  /** DDL for the sessions database (tables, indexes, FTS5 virtual table). */
62
79
  const SCHEMA = `
63
80
  CREATE TABLE IF NOT EXISTS sessions (
@@ -135,6 +152,7 @@ CREATE VIRTUAL TABLE IF NOT EXISTS session_text USING fts5(
135
152
  topic,
136
153
  project,
137
154
  content,
155
+ assistant,
138
156
  tokenize = 'unicode61 remove_diacritics 2'
139
157
  );
140
158
 
@@ -158,7 +176,13 @@ CREATE TABLE IF NOT EXISTS scan_ledger (
158
176
  -- doc so detectTicket + FTS can rebuild on append without re-reading the file.
159
177
  -- Written by B-2; B-1 only defines + round-trips them.
160
178
  parser_state TEXT,
161
- content_text TEXT
179
+ content_text TEXT,
180
+ -- CONTENT_INDEX_VERSION this row's session_text content was last extracted
181
+ -- at. NULL (a pre-v42 row) never equals the current constant, so the
182
+ -- change-detector (filterChangedEntries) treats it as changed even when
183
+ -- (mtime, size) match — the lever that backfills assistant text into
184
+ -- existing sessions without wiping scan_ledger outright.
185
+ extractor_version INTEGER
162
186
  );
163
187
 
164
188
  -- Tracks the mtime + entry-count of every LEAF directory that directly holds
@@ -409,8 +433,8 @@ CREATE INDEX IF NOT EXISTS idx_computer_sessions_started ON computer_sessions(st
409
433
  * re-derives on the next `agents insights` instead of silently reporting stale
410
434
  * numbers alongside fresh ones. Same role as RESOURCE_INDEX_VERSION.
411
435
  */
412
- /** Bump when facet extraction changes so cached rows recompute (stalls-by-model v6). */
413
- export const INSIGHTS_EXTRACTOR_VERSION = 6;
436
+ /** Bump when facet extraction changes so cached rows recompute (shell-command-by-binary v7). */
437
+ export const INSIGHTS_EXTRACTOR_VERSION = 7;
414
438
  export const SESSION_TOPIC_EXTRACTOR_VERSION = 1;
415
439
  const PREVIEW_EXTRACTOR_VERSION = 1;
416
440
  let dbInstance = null;
@@ -1085,6 +1109,48 @@ function migrateSchema(db, fromVersion) {
1085
1109
  if (!cols.has('harness'))
1086
1110
  db.exec(`ALTER TABLE sessions ADD COLUMN harness TEXT`);
1087
1111
  }
1112
+ if (fromVersion < 42) {
1113
+ // v41 -> v42: index the agent's ANSWERS, not just the user's prompts.
1114
+ // session_text gains an `assistant` column (own FTS5 column, own lower
1115
+ // BM25 weight — see BM25_WEIGHTS) and scan_ledger gains `extractor_version`
1116
+ // so the change-detector can force a re-extract independent of
1117
+ // (mtime, size). FTS5 can't ALTER a virtual table's column set, but unlike
1118
+ // v1->v2 (which dropped the table and forced a blind full rescan of
1119
+ // everything), the existing label/topic/project/content in every row is
1120
+ // still exactly right — only `assistant` is missing. Rename the old table
1121
+ // out of the way, create the new 6-column one, and copy the old rows back
1122
+ // in (assistant defaults to '' until the extractor_version lever backfills
1123
+ // it on that row's next scan) — search over label/topic/project/content
1124
+ // never blacks out for the transient window it takes existing sessions to
1125
+ // get rescanned, which can be a long time for a session whose transcript
1126
+ // is otherwise cold.
1127
+ db.exec(`ALTER TABLE session_text RENAME TO session_text_v41`);
1128
+ db.exec(`
1129
+ CREATE VIRTUAL TABLE session_text USING fts5(
1130
+ session_id UNINDEXED,
1131
+ label,
1132
+ topic,
1133
+ project,
1134
+ content,
1135
+ assistant,
1136
+ tokenize = 'unicode61 remove_diacritics 2'
1137
+ );
1138
+ `);
1139
+ db.exec(`
1140
+ INSERT INTO session_text (session_id, label, topic, project, content, assistant)
1141
+ SELECT session_id, label, topic, project, content, '' FROM session_text_v41
1142
+ `);
1143
+ db.exec(`DROP TABLE session_text_v41`);
1144
+ const ledgerCols = db.prepare(`PRAGMA table_info(scan_ledger)`).all();
1145
+ if (!ledgerCols.some(c => c.name === 'extractor_version')) {
1146
+ db.exec(`ALTER TABLE scan_ledger ADD COLUMN extractor_version INTEGER`);
1147
+ }
1148
+ // No `DELETE FROM scan_ledger` — the new column is NULL on every existing
1149
+ // row, which already never equals CONTENT_INDEX_VERSION, so every session
1150
+ // re-extracts on its next scan while parser_state/content_text (the
1151
+ // Claude/Codex resumable continuation) stay intact for rows that don't
1152
+ // need a full reparse for any OTHER reason.
1153
+ }
1088
1154
  }
1089
1155
  /**
1090
1156
  * Stamp `account_key` / `account_org` / `account` on every Claude row from its
@@ -1455,13 +1521,18 @@ export function getScanStampsForPaths(filePaths) {
1455
1521
  const placeholders = chunk.map(() => '?').join(',');
1456
1522
  const rows = db
1457
1523
  .prepare(`
1458
- SELECT file_path, file_mtime_ms, file_size, scanned_at
1524
+ SELECT file_path, file_mtime_ms, file_size, scanned_at, extractor_version
1459
1525
  FROM scan_ledger
1460
1526
  WHERE file_path IN (${placeholders})
1461
1527
  `)
1462
1528
  .all(...chunk);
1463
1529
  for (const row of rows) {
1464
- const stamp = { fileMtimeMs: row.file_mtime_ms, fileSize: row.file_size, scannedAt: row.scanned_at };
1530
+ const stamp = {
1531
+ fileMtimeMs: row.file_mtime_ms,
1532
+ fileSize: row.file_size,
1533
+ scannedAt: row.scanned_at,
1534
+ extractorVersion: row.extractor_version,
1535
+ };
1465
1536
  for (const original of canonicalToOriginals.get(row.file_path) || []) {
1466
1537
  result.set(original, stamp);
1467
1538
  }
@@ -1498,7 +1569,7 @@ export function getParserStatesForPaths(filePaths) {
1498
1569
  const placeholders = chunk.map(() => '?').join(',');
1499
1570
  const rows = db
1500
1571
  .prepare(`
1501
- SELECT file_path, file_mtime_ms, file_size, scanned_at, parser_state, content_text
1572
+ SELECT file_path, file_mtime_ms, file_size, scanned_at, parser_state, content_text, extractor_version
1502
1573
  FROM scan_ledger
1503
1574
  WHERE file_path IN (${placeholders})
1504
1575
  `)
@@ -1510,6 +1581,7 @@ export function getParserStatesForPaths(filePaths) {
1510
1581
  fileMtimeMs: row.file_mtime_ms,
1511
1582
  fileSize: row.file_size,
1512
1583
  scannedAt: row.scanned_at,
1584
+ extractorVersion: row.extractor_version,
1513
1585
  };
1514
1586
  for (const original of canonicalToOriginals.get(row.file_path) || []) {
1515
1587
  result.set(original, state);
@@ -1527,17 +1599,23 @@ export function recordScans(entries) {
1527
1599
  return;
1528
1600
  const db = getDB();
1529
1601
  const stmt = db.prepare(`
1530
- INSERT INTO scan_ledger (file_path, file_mtime_ms, file_size, scanned_at)
1531
- VALUES (?, ?, ?, ?)
1602
+ INSERT INTO scan_ledger (file_path, file_mtime_ms, file_size, scanned_at, extractor_version)
1603
+ VALUES (?, ?, ?, ?, ?)
1532
1604
  ON CONFLICT(file_path) DO UPDATE SET
1533
1605
  file_mtime_ms = excluded.file_mtime_ms,
1534
1606
  file_size = excluded.file_size,
1535
- scanned_at = excluded.scanned_at
1607
+ scanned_at = excluded.scanned_at,
1608
+ extractor_version = excluded.extractor_version
1536
1609
  `);
1537
1610
  const now = Date.now();
1538
1611
  const txn = db.transaction((items) => {
1539
1612
  for (const { filePath, scan } of items) {
1540
- stmt.run(canonicalLedgerKey(filePath), scan.fileMtimeMs, scan.fileSize, now);
1613
+ // Stamped at the CURRENT content extractor version even for a file that
1614
+ // yielded no session (malformed / no id): we ran today's extractor over
1615
+ // it and it produced nothing, so it is current, not stale — otherwise a
1616
+ // permanently-unparseable file would re-trigger "changed" on every scan
1617
+ // forever once CONTENT_INDEX_VERSION is bumped.
1618
+ stmt.run(canonicalLedgerKey(filePath), scan.fileMtimeMs, scan.fileSize, now, CONTENT_INDEX_VERSION);
1541
1619
  }
1542
1620
  });
1543
1621
  txn(entries);
@@ -1867,7 +1945,7 @@ function enrichCachedSessionMeta(meta) {
1867
1945
  }
1868
1946
  }
1869
1947
  const deleteTextStmt = (db) => db.prepare(`DELETE FROM session_text WHERE session_id = ?`);
1870
- const insertTextStmt = (db) => db.prepare(`INSERT INTO session_text (session_id, label, topic, project, content) VALUES (?, ?, ?, ?, ?)`);
1948
+ const insertTextStmt = (db) => db.prepare(`INSERT INTO session_text (session_id, label, topic, project, content, assistant) VALUES (?, ?, ?, ?, ?, ?)`);
1871
1949
  // Read back the label the upsert actually stored (which may be the preserved
1872
1950
  // one, not the incoming blank) so the FTS label column stays consistent with
1873
1951
  // sessions.label after a bare rescan.
@@ -1904,7 +1982,7 @@ function resolveMachine(meta) {
1904
1982
  * Upsert a session row and replace its FTS5 content in a single transaction.
1905
1983
  * `content` is the tokenizable user-prompt text; pass '' to leave the row unsearchable.
1906
1984
  */
1907
- export function upsertSession(meta, content, scan) {
1985
+ export function upsertSession(meta, content, scan, assistantContent = '') {
1908
1986
  meta = enrichCachedSessionMeta(meta);
1909
1987
  // Join the durable sessionId -> actor sidecar (RUSH-2019) when the caller
1910
1988
  // didn't already carry an actor, so a scanned transcript still attributes to a
@@ -1975,7 +2053,7 @@ export function upsertSession(meta, content, scan) {
1975
2053
  insText.run(meta.id,
1976
2054
  // Use the label the upsert actually stored (preserve-non-empty rule),
1977
2055
  // not the raw incoming one, so FTS label ranking survives a bare rescan.
1978
- storedFtsLabel(readLabel, meta.id), meta.topic ?? '', meta.project ?? '', content ?? '');
2056
+ storedFtsLabel(readLabel, meta.id), meta.topic ?? '', meta.project ?? '', content ?? '', assistantContent ?? '');
1979
2057
  });
1980
2058
  txn();
1981
2059
  }
@@ -1993,19 +2071,23 @@ export function upsertSessionsBatch(entries) {
1993
2071
  // stored owner on rescan.
1994
2072
  const actorIndex = loadSessionActorIndex();
1995
2073
  // Persist the Claude resumable-parse continuation (parser_state + content_text)
1996
- // alongside the stamp. On a full/incremental Claude parse the caller passes the
2074
+ // alongside the stamp, plus the CURRENT content extractor version — a caller
2075
+ // that reached this batch write ran today's extractor over the file, so the
2076
+ // ledger row is current regardless of which branch (full/incremental)
2077
+ // produced it. On a full/incremental Claude parse the caller passes the
1997
2078
  // serialized newState + accumulated user doc so the NEXT scan can resume from
1998
2079
  // the persisted offset (B-2). Other scanners pass neither, leaving both columns
1999
2080
  // NULL exactly as before — their ledger rows are unaffected.
2000
2081
  const ledger = db.prepare(`
2001
- INSERT INTO scan_ledger (file_path, file_mtime_ms, file_size, scanned_at, parser_state, content_text)
2002
- VALUES (?, ?, ?, ?, ?, ?)
2082
+ INSERT INTO scan_ledger (file_path, file_mtime_ms, file_size, scanned_at, parser_state, content_text, extractor_version)
2083
+ VALUES (?, ?, ?, ?, ?, ?, ?)
2003
2084
  ON CONFLICT(file_path) DO UPDATE SET
2004
2085
  file_mtime_ms = excluded.file_mtime_ms,
2005
2086
  file_size = excluded.file_size,
2006
2087
  scanned_at = excluded.scanned_at,
2007
2088
  parser_state = excluded.parser_state,
2008
- content_text = excluded.content_text
2089
+ content_text = excluded.content_text,
2090
+ extractor_version = excluded.extractor_version
2009
2091
  `);
2010
2092
  // Build a lookup from canonical file path → entry, used inside the write
2011
2093
  // transaction to re-check the ledger AFTER acquiring the lock. When a
@@ -2076,17 +2158,26 @@ export function upsertSessionsBatch(entries) {
2076
2158
  const chunk = paths.slice(i, i + CHUNK);
2077
2159
  const phs = chunk.map(() => '?').join(',');
2078
2160
  const rows = db
2079
- .prepare(`SELECT file_path, file_mtime_ms, file_size FROM scan_ledger WHERE file_path IN (${phs})`)
2161
+ .prepare(`SELECT file_path, file_mtime_ms, file_size, extractor_version FROM scan_ledger WHERE file_path IN (${phs})`)
2080
2162
  .all(...chunk);
2081
2163
  for (const row of rows) {
2082
2164
  const entry = byPath.get(row.file_path);
2083
- if (entry && row.file_mtime_ms === entry.scan.fileMtimeMs && row.file_size === entry.scan.fileSize) {
2165
+ // A concurrent writer's row only makes THIS entry redundant when it is
2166
+ // current at CONTENT_INDEX_VERSION too — otherwise a (mtime, size) match
2167
+ // alone would make the version lever a no-op: the very reason this batch
2168
+ // was scheduled (a stale extractor_version) would be silently skipped as
2169
+ // "someone else already indexed it", when what they indexed predates the
2170
+ // current extractor.
2171
+ if (entry &&
2172
+ row.file_mtime_ms === entry.scan.fileMtimeMs &&
2173
+ row.file_size === entry.scan.fileSize &&
2174
+ row.extractor_version === CONTENT_INDEX_VERSION) {
2084
2175
  alreadyIndexed.add(entry.meta.id);
2085
2176
  }
2086
2177
  }
2087
2178
  }
2088
2179
  for (const entry of items) {
2089
- const { meta, content, scan, parserState, contentText } = entry;
2180
+ const { meta, content, assistantContent, scan, parserState, contentText } = entry;
2090
2181
  if (alreadyIndexed.has(meta.id))
2091
2182
  continue;
2092
2183
  // Per-row guard: one malformed session (e.g. a required field that resolves to
@@ -2170,9 +2261,9 @@ export function upsertSessionsBatch(entries) {
2170
2261
  insText.run(meta.id,
2171
2262
  // Mirror upsertSession: index the label the upsert actually stored
2172
2263
  // (preserve-non-empty rule), not the raw incoming one.
2173
- storedFtsLabel(readLabel, meta.id), meta.topic ?? '', meta.project ?? '', content ?? '');
2264
+ storedFtsLabel(readLabel, meta.id), meta.topic ?? '', meta.project ?? '', content ?? '', assistantContent ?? '');
2174
2265
  if (scan && meta.filePath) {
2175
- ledger.run(canonicalLedgerKey(meta.filePath), scan.fileMtimeMs, scan.fileSize, now, parserState ?? null, contentText ?? null);
2266
+ ledger.run(canonicalLedgerKey(meta.filePath), scan.fileMtimeMs, scan.fileSize, now, parserState ?? null, contentText ?? null, CONTENT_INDEX_VERSION);
2176
2267
  }
2177
2268
  writtenEntries.push(entry);
2178
2269
  }
@@ -3510,11 +3601,16 @@ export function ftsSearch(input, limit = 200) {
3510
3601
  return hits.slice(0, limit);
3511
3602
  }
3512
3603
  // Tier 4: FTS5 content match, skipping anything already surfaced via label.
3604
+ // `snippet(session_text, -1, ...)` lets FTS5 pick the best-matching column
3605
+ // itself (label/topic/project/content/assistant) rather than us guessing —
3606
+ // a query that only matched in `assistant` (an agent-only answer) still gets
3607
+ // an excerpt from the right column instead of an empty content snippet.
3513
3608
  if (expr) {
3514
3609
  try {
3515
3610
  const rows = db
3516
3611
  .prepare(`
3517
- SELECT session_id, bm25(session_text, ${BM25_WEIGHTS.join(', ')}) AS rank
3612
+ SELECT session_id, bm25(session_text, ${BM25_WEIGHTS.join(', ')}) AS rank,
3613
+ snippet(session_text, -1, '**', '**', '…', 12) AS snip
3518
3614
  FROM session_text
3519
3615
  WHERE session_text MATCH ?
3520
3616
  ORDER BY rank ASC
@@ -3524,7 +3620,12 @@ export function ftsSearch(input, limit = 200) {
3524
3620
  for (const r of rows) {
3525
3621
  if (seen.has(r.session_id))
3526
3622
  continue;
3527
- hits.push({ sessionId: r.session_id, score: -r.rank, matchedTerms: terms });
3623
+ hits.push({
3624
+ sessionId: r.session_id,
3625
+ score: -r.rank,
3626
+ matchedTerms: terms,
3627
+ snippet: r.snip?.trim() || undefined,
3628
+ });
3528
3629
  seen.add(r.session_id);
3529
3630
  }
3530
3631
  }
@@ -104,6 +104,8 @@ interface ClaudeSessionScan {
104
104
  entrypoint?: string;
105
105
  /** Concatenated user message text, ready to hand to FTS5. */
106
106
  contentText?: string;
107
+ /** Concatenated assistant-answer text, ready to hand to FTS5's `assistant` column. */
108
+ assistantText?: string;
107
109
  /** Durable state signals persisted to the index by the session-state engine. */
108
110
  prUrl?: string;
109
111
  prNumber?: number;
@@ -154,6 +156,7 @@ interface CodexSessionScan {
154
156
  durationMs?: number;
155
157
  lastActivity?: string;
156
158
  contentText?: string;
159
+ assistantText?: string;
157
160
  prUrl?: string;
158
161
  prNumber?: number;
159
162
  worktreeSlug?: string;
@@ -347,8 +350,15 @@ export declare function looksLikeSessionId(query: string): boolean;
347
350
  */
348
351
  export declare function resolveSessionById(sessions: SessionMeta[], idQuery: string): SessionMeta[];
349
352
  /**
350
- * Run an FTS5 search over the DB and intersect with the given session list,
351
- * preserving the existing SessionMeta[] contract so sessions.ts is unchanged.
353
+ * Run an FTS5 search over the DB and union hits with the given session list.
354
+ *
355
+ * The listing pool is a minority of the index — cwd-scoped, default-capped at
356
+ * 50, and skipping whole classes of indexed transcript — so intersecting FTS
357
+ * hits with it dropped grep-visible sessions that the index already found
358
+ * (PHNX-2767: `agents sessions "tmux pane"` returned 0 while the project
359
+ * transcripts matched). Hits already in the pool keep the caller's SessionMeta;
360
+ * hits the pool missed are hydrated from the index so a content query returns
361
+ * the transcript FTS matched.
352
362
  */
353
363
  export declare function searchContentIndex(sessions: SessionMeta[], query: string): Map<string, SessionMeta>;
354
364
  /**
@@ -375,6 +385,13 @@ interface PreStatEntry {
375
385
  * stat. Same debounce and change-detection as filterChangedFiles; the raw
376
386
  * mtime is floored here so warm files match the ledger exactly as the stat path
377
387
  * does (Math.floor(stat.mtimeMs)).
388
+ *
389
+ * A file is also treated as "changed" — independent of (mtime, size) — when
390
+ * its ledger row's `extractor_version` is behind {@link CONTENT_INDEX_VERSION}.
391
+ * This is the lever that backfills a content-extractor improvement (e.g.
392
+ * indexing assistant answers, not just user prompts) into every already-scanned
393
+ * session on its next pass, without a `DELETE FROM scan_ledger` that would also
394
+ * discard the Claude/Codex resumable parser_state.
378
395
  */
379
396
  export declare function filterChangedEntries(entries: PreStatEntry[]): Array<{
380
397
  filePath: string;
@@ -429,9 +446,11 @@ export declare function parseCodexThreadNameIndex(raw: string): Map<string, stri
429
446
  export declare function readCodexMeta(filePath: string, resolveAccount?: () => string | undefined, currentVersion?: string, scanStamp?: ScanStamp, priorRow?: {
430
447
  parserState: string | null;
431
448
  fileMtimeMs: number;
449
+ extractorVersion?: number | null;
432
450
  }): Promise<{
433
451
  meta: SessionMeta;
434
452
  content: string;
453
+ assistantContent: string;
435
454
  parserState?: string;
436
455
  contentText?: string;
437
456
  toolCalls?: IndexedToolCall[];
@@ -469,6 +488,10 @@ interface ClaudeParseState {
469
488
  lastTsMs?: number;
470
489
  seenAssistantIds: Set<string>;
471
490
  userTexts: string[];
491
+ /** Assistant-answer text (#PHNX content-search), accumulated the same way as
492
+ * `userTexts` but indexed into session_text's own lower-weighted `assistant`
493
+ * column instead of `content` — see BM25_WEIGHTS. */
494
+ assistantTexts: string[];
472
495
  sawPrCreate: boolean;
473
496
  prUrl?: string;
474
497
  prNumber?: number;
@@ -521,7 +544,7 @@ export declare function scanClaudeSession(filePath: string): Promise<ClaudeSessi
521
544
  * exact even when the recent window is smaller than the true count.
522
545
  */
523
546
  export interface ClaudeParserState {
524
- v: 4;
547
+ v: 5;
525
548
  /**
526
549
  * Fan-out tallies carried across a RESUMED parse (RUSH-3091/3095). They must
527
550
  * live in the durable blob: the resumable parser reads only new bytes, so
@@ -566,6 +589,7 @@ export interface ClaudeParserState {
566
589
  spawnedTeam?: string;
567
590
  ticketId?: string;
568
591
  contentText?: string;
592
+ assistantContentText?: string;
569
593
  checklistEvents: SessionEvent[];
570
594
  recentDirectoriesTouched: string[];
571
595
  toolCalls: ToolCallCollectorSnapshot;
@@ -663,6 +687,8 @@ interface CodexParseState {
663
687
  firstTsMs?: number;
664
688
  lastTsMs?: number;
665
689
  userTexts: string[];
690
+ /** See ClaudeParseState.assistantTexts — same accumulate-and-lower-weight FTS treatment. */
691
+ assistantTexts: string[];
666
692
  sawPrCreate: boolean;
667
693
  prUrl?: string;
668
694
  prNumber?: number;
@@ -693,7 +719,7 @@ export declare function initCodexParseState(): CodexParseState;
693
719
  * finalize is identical after a resume.
694
720
  */
695
721
  export interface CodexParserState {
696
- v: 2;
722
+ v: 3;
697
723
  offset: number;
698
724
  jsonlDroppingOversizedLine?: boolean;
699
725
  sessionId?: string;
@@ -716,6 +742,7 @@ export interface CodexParserState {
716
742
  spawnedTeam?: string;
717
743
  ticketId?: string;
718
744
  contentText?: string;
745
+ assistantContentText?: string;
719
746
  checklistEvents: SessionEvent[];
720
747
  recentDirectoriesTouched: string[];
721
748
  toolCalls: ToolCallCollectorSnapshot;
@@ -772,6 +799,7 @@ export declare function __resetCodexScanBranchCountsForTest(): void;
772
799
  export declare function readCursorMeta(filePath: string, currentVersion?: string): {
773
800
  meta: SessionMeta;
774
801
  content: string;
802
+ assistantContent: string;
775
803
  events: SessionEvent[];
776
804
  } | null;
777
805
  /** Parse a single Kimi session state.json file to extract session metadata. */