@phnx-labs/agents-cli 1.22.25 → 1.22.27

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 (178) hide show
  1. package/CHANGELOG.md +427 -0
  2. package/README.md +35 -3
  3. package/dist/bin/agents +0 -0
  4. package/dist/browser.js +14 -4
  5. package/dist/commands/apply.js +52 -8
  6. package/dist/commands/bench.d.ts +2 -0
  7. package/dist/commands/bench.js +101 -0
  8. package/dist/commands/browser.js +35 -0
  9. package/dist/commands/doctor.js +32 -10
  10. package/dist/commands/exec.js +17 -16
  11. package/dist/commands/insights.d.ts +25 -19
  12. package/dist/commands/insights.js +146 -38
  13. package/dist/commands/output.js +100 -25
  14. package/dist/commands/reconnect.d.ts +46 -0
  15. package/dist/commands/reconnect.js +109 -0
  16. package/dist/commands/resume.d.ts +11 -0
  17. package/dist/commands/resume.js +51 -0
  18. package/dist/commands/routines.js +2 -2
  19. package/dist/commands/secrets.d.ts +2 -8
  20. package/dist/commands/secrets.js +29 -105
  21. package/dist/commands/sessions-picker.js +12 -7
  22. package/dist/commands/sessions.d.ts +47 -1
  23. package/dist/commands/sessions.js +349 -37
  24. package/dist/commands/setup-secrets.d.ts +1 -0
  25. package/dist/commands/setup-secrets.js +1 -1
  26. package/dist/commands/setup.d.ts +26 -3
  27. package/dist/commands/setup.js +105 -46
  28. package/dist/commands/ssh.js +184 -11
  29. package/dist/commands/teams.d.ts +6 -0
  30. package/dist/commands/teams.js +46 -3
  31. package/dist/commands/trends.d.ts +8 -0
  32. package/dist/commands/trends.js +10 -156
  33. package/dist/index.js +17 -21
  34. package/dist/lib/agents.d.ts +11 -0
  35. package/dist/lib/agents.js +29 -2
  36. package/dist/lib/analytics/dashboard.d.ts +10 -6
  37. package/dist/lib/analytics/dashboard.js +6 -4
  38. package/dist/lib/analytics/mix-commands.d.ts +53 -0
  39. package/dist/lib/analytics/mix-commands.js +229 -0
  40. package/dist/lib/analytics/recipes.d.ts +19 -14
  41. package/dist/lib/analytics/recipes.js +4 -2
  42. package/dist/lib/auth-health.d.ts +47 -3
  43. package/dist/lib/auth-health.js +74 -15
  44. package/dist/lib/bench/index.d.ts +4 -0
  45. package/dist/lib/bench/index.js +4 -0
  46. package/dist/lib/bench/runner.d.ts +16 -0
  47. package/dist/lib/bench/runner.js +111 -0
  48. package/dist/lib/bench/schema.d.ts +5 -0
  49. package/dist/lib/bench/schema.js +91 -0
  50. package/dist/lib/bench/storage.d.ts +5 -0
  51. package/dist/lib/bench/storage.js +32 -0
  52. package/dist/lib/bench/types.d.ts +40 -0
  53. package/dist/lib/bench/types.js +1 -0
  54. package/dist/lib/browser/ipc.d.ts +26 -0
  55. package/dist/lib/browser/ipc.js +139 -24
  56. package/dist/lib/browser/profiles.d.ts +11 -0
  57. package/dist/lib/browser/profiles.js +1 -1
  58. package/dist/lib/browser/stream.d.ts +14 -0
  59. package/dist/lib/browser/stream.js +71 -0
  60. package/dist/lib/channels/owner-sink.d.ts +27 -0
  61. package/dist/lib/channels/owner-sink.js +93 -0
  62. package/dist/lib/claude-account-token.d.ts +2 -0
  63. package/dist/lib/claude-account-token.js +27 -1
  64. package/dist/lib/crabbox/cli.d.ts +2 -0
  65. package/dist/lib/crabbox/cli.js +2 -0
  66. package/dist/lib/crabbox/lease.js +7 -1
  67. package/dist/lib/daemon.js +20 -0
  68. package/dist/lib/devices/connect.d.ts +2 -0
  69. package/dist/lib/devices/connect.js +7 -0
  70. package/dist/lib/devices/doctor-findings.d.ts +10 -1
  71. package/dist/lib/devices/doctor-findings.js +47 -1
  72. package/dist/lib/devices/harness-inventory.d.ts +97 -0
  73. package/dist/lib/devices/harness-inventory.js +0 -0
  74. package/dist/lib/devices/registry.d.ts +2 -0
  75. package/dist/lib/devices/ssh-config.js +3 -0
  76. package/dist/lib/devices/windows-ssh-enrollment.d.ts +20 -0
  77. package/dist/lib/devices/windows-ssh-enrollment.js +98 -0
  78. package/dist/lib/exec.bench.d.ts +1 -0
  79. package/dist/lib/exec.bench.js +186 -0
  80. package/dist/lib/exec.js +18 -8
  81. package/dist/lib/fleet/apply.d.ts +59 -3
  82. package/dist/lib/fleet/apply.js +191 -12
  83. package/dist/lib/fleet/remote-login.d.ts +4 -3
  84. package/dist/lib/fleet/remote-login.js +11 -9
  85. package/dist/lib/fleet/types.d.ts +21 -2
  86. package/dist/lib/gemini-settings.d.ts +0 -1
  87. package/dist/lib/gemini-settings.js +12 -7
  88. package/dist/lib/hooks/cache.js +15 -0
  89. package/dist/lib/hooks.d.ts +8 -5
  90. package/dist/lib/hooks.js +10 -6
  91. package/dist/lib/hosts/dispatch.js +7 -3
  92. package/dist/lib/hosts/passthrough.d.ts +45 -0
  93. package/dist/lib/hosts/passthrough.js +59 -8
  94. package/dist/lib/hosts/progress.d.ts +2 -1
  95. package/dist/lib/hosts/progress.js +4 -3
  96. package/dist/lib/hosts/providers/devices.js +1 -0
  97. package/dist/lib/hosts/ready.d.ts +6 -2
  98. package/dist/lib/hosts/ready.js +33 -8
  99. package/dist/lib/hosts/reconcile.d.ts +1 -1
  100. package/dist/lib/hosts/reconcile.js +16 -7
  101. package/dist/lib/hosts/reconnect.d.ts +56 -21
  102. package/dist/lib/hosts/reconnect.js +128 -71
  103. package/dist/lib/hosts/registry.d.ts +2 -2
  104. package/dist/lib/hosts/registry.js +3 -5
  105. package/dist/lib/hosts/remote-cmd.d.ts +17 -0
  106. package/dist/lib/hosts/remote-cmd.js +29 -0
  107. package/dist/lib/hosts/tasks.d.ts +2 -0
  108. package/dist/lib/hosts/types.d.ts +1 -0
  109. package/dist/lib/hosts/types.js +3 -0
  110. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  111. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  112. package/dist/lib/menubar/install-menubar.d.ts +9 -6
  113. package/dist/lib/menubar/install-menubar.js +20 -9
  114. package/dist/lib/models.d.ts +30 -1
  115. package/dist/lib/models.js +42 -144
  116. package/dist/lib/pricing/cost.d.ts +9 -0
  117. package/dist/lib/pricing/cost.js +24 -0
  118. package/dist/lib/pricing/index.d.ts +1 -1
  119. package/dist/lib/pricing/index.js +1 -1
  120. package/dist/lib/redact.js +8 -3
  121. package/dist/lib/remote-agents-json.d.ts +32 -0
  122. package/dist/lib/remote-agents-json.js +47 -16
  123. package/dist/lib/resource-profiles.js +1 -2
  124. package/dist/lib/routine-notify-owner.d.ts +102 -0
  125. package/dist/lib/routine-notify-owner.js +232 -0
  126. package/dist/lib/routines.d.ts +10 -0
  127. package/dist/lib/routines.js +14 -2
  128. package/dist/lib/runner.d.ts +9 -4
  129. package/dist/lib/runner.js +182 -30
  130. package/dist/lib/sandbox.d.ts +0 -2
  131. package/dist/lib/sandbox.js +2 -19
  132. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  133. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  134. package/dist/lib/secrets/agent.js +3 -2
  135. package/dist/lib/secrets/lease.d.ts +25 -0
  136. package/dist/lib/secrets/lease.js +44 -0
  137. package/dist/lib/secrets/push.d.ts +94 -0
  138. package/dist/lib/secrets/push.js +145 -0
  139. package/dist/lib/secrets/reaper.d.ts +15 -1
  140. package/dist/lib/secrets/reaper.js +30 -3
  141. package/dist/lib/self-update.d.ts +20 -5
  142. package/dist/lib/self-update.js +93 -16
  143. package/dist/lib/session/db.d.ts +38 -3
  144. package/dist/lib/session/db.js +273 -16
  145. package/dist/lib/session/discover.d.ts +22 -1
  146. package/dist/lib/session/discover.js +239 -51
  147. package/dist/lib/session/insights.d.ts +37 -0
  148. package/dist/lib/session/insights.js +219 -9
  149. package/dist/lib/session/recovery.d.ts +17 -1
  150. package/dist/lib/session/recovery.js +111 -4
  151. package/dist/lib/session/remote-list.d.ts +10 -9
  152. package/dist/lib/session/remote-list.js +25 -23
  153. package/dist/lib/session/resume-owner.d.ts +55 -0
  154. package/dist/lib/session/resume-owner.js +69 -0
  155. package/dist/lib/session/team-filter.d.ts +65 -0
  156. package/dist/lib/session/team-filter.js +98 -3
  157. package/dist/lib/session/tool-index.js +133 -22
  158. package/dist/lib/session/tool-store.d.ts +26 -2
  159. package/dist/lib/session/tool-store.js +36 -17
  160. package/dist/lib/session/types.d.ts +33 -0
  161. package/dist/lib/smart-launch.d.ts +31 -5
  162. package/dist/lib/smart-launch.js +43 -7
  163. package/dist/lib/ssh-exec.d.ts +27 -0
  164. package/dist/lib/ssh-exec.js +42 -3
  165. package/dist/lib/ssh-tunnel.d.ts +3 -2
  166. package/dist/lib/ssh-tunnel.js +25 -16
  167. package/dist/lib/startup/command-registry.d.ts +2 -0
  168. package/dist/lib/startup/command-registry.js +6 -0
  169. package/dist/lib/teams/agents.d.ts +13 -0
  170. package/dist/lib/teams/agents.js +75 -7
  171. package/dist/lib/teams/placement-probe.d.ts +21 -0
  172. package/dist/lib/teams/placement-probe.js +135 -0
  173. package/dist/lib/teams/scheduler.d.ts +74 -1
  174. package/dist/lib/teams/scheduler.js +187 -10
  175. package/dist/lib/tmux/session.d.ts +8 -0
  176. package/dist/lib/tmux/session.js +22 -0
  177. package/dist/lib/types.d.ts +2 -0
  178. package/package.json +1 -1
@@ -7,12 +7,12 @@
7
7
  * BM25 ranking, and label-first search for /rename'd sessions.
8
8
  */
9
9
  import Database from '../sqlite.js';
10
- import type { SessionAgentId, SessionMeta } from './types.js';
10
+ import type { SessionAgentId, SessionEvent, SessionMeta } from './types.js';
11
11
  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/05-sessions.md calls the constant the source of truth). */
15
- export declare const SCHEMA_VERSION = 34;
15
+ export declare const SCHEMA_VERSION = 37;
16
16
  /**
17
17
  * Bump to force `agents sessions backfill resources` to re-derive every
18
18
  * session's skill/slash-command tallies on its next run (resource_scan_ledger
@@ -26,7 +26,8 @@ export declare const RESOURCE_INDEX_VERSION = 1;
26
26
  * re-derives on the next `agents insights` instead of silently reporting stale
27
27
  * numbers alongside fresh ones. Same role as RESOURCE_INDEX_VERSION.
28
28
  */
29
- export declare const INSIGHTS_EXTRACTOR_VERSION = 3;
29
+ /** Bump when facet extraction changes so cached rows recompute (stalls-by-model v6). */
30
+ export declare const INSIGHTS_EXTRACTOR_VERSION = 6;
30
31
  /** Raw row shape returned from the sessions table. */
31
32
  export interface SessionRow {
32
33
  id: string;
@@ -50,7 +51,11 @@ export interface SessionRow {
50
51
  message_count: number | null;
51
52
  token_count: number | null;
52
53
  output_tokens: number | null;
54
+ input_tokens: number | null;
55
+ cache_read_tokens: number | null;
56
+ cache_write_tokens: number | null;
53
57
  cost_usd: number | null;
58
+ cost_usd_nocache: number | null;
54
59
  duration_ms: number | null;
55
60
  model: string | null;
56
61
  tool_call_count: number | null;
@@ -149,6 +154,23 @@ export interface FtsOptimizeResult {
149
154
  * the DB file; run VACUUM (with the daemon stopped) to return it to the OS.
150
155
  */
151
156
  export declare function optimizeSessionSearchIndex(): FtsOptimizeResult[];
157
+ /**
158
+ * Keep the FTS indexes from degrading on the normal scan path.
159
+ *
160
+ * `optimizeSessionSearchIndex` is the full, unbounded compaction behind
161
+ * `agents sessions optimize`. Leaving it as the ONLY compaction meant the index
162
+ * degraded until a human happened to run that command, which is how
163
+ * `tool_call_text_data` reached gigabytes for tens of MB of content. This is the
164
+ * automatic counterpart: bounded, threshold-gated, and safe to call after every
165
+ * batch of writes. Non-destructive — merging never changes what is searchable.
166
+ *
167
+ * Returns one result per table it actually merged (empty when every table is
168
+ * under the threshold, which is the common case on a warm index).
169
+ */
170
+ export declare function maintainSessionSearchIndex(db?: Database.Database, options?: {
171
+ segmentThreshold?: number;
172
+ mergePages?: number;
173
+ }): FtsOptimizeResult[];
152
174
  /**
153
175
  * Try to claim the right to run the incremental scan. Returns true if this
154
176
  * process should proceed with scanning, false if another live process is
@@ -245,6 +267,7 @@ export declare function upsertSessionsBatch(entries: Array<{
245
267
  scan?: ScanStamp;
246
268
  parserState?: string;
247
269
  contentText?: string;
270
+ events?: SessionEvent[];
248
271
  toolCalls?: IndexedToolCall[];
249
272
  toolScan?: ScanStamp;
250
273
  toolIndexMode?: 'replace' | 'append';
@@ -317,11 +340,23 @@ export interface UsageRollupRow {
317
340
  */
318
341
  label?: string;
319
342
  costUsd: number;
343
+ /**
344
+ * USD cost priced as if caching were off (cache read/write at the input rate),
345
+ * summed from `cost_usd_nocache`. Backs `agents output --pricing no-cache`.
346
+ * Equals `costUsd` for rows whose sessions record no cache split (RUSH-2287).
347
+ */
348
+ costUsdNoCache: number;
320
349
  durationMs: number;
321
350
  sessionCount: number;
322
351
  tokenCount: number;
323
352
  /** Real generated (output) tokens — excludes cache-read/-write context. */
324
353
  outputTokens: number;
354
+ /** Uncached input tokens summed across the group (0 where no harness recorded a split). */
355
+ inputTokens: number;
356
+ /** Cache-read tokens summed across the group. */
357
+ cacheReadTokens: number;
358
+ /** Cache-write (cache-creation) tokens summed across the group. */
359
+ cacheWriteTokens: number;
325
360
  }
326
361
  /** What to group a usage rollup by. */
327
362
  /**
@@ -26,7 +26,7 @@ const DB_PATH = getSessionsDbPath();
26
26
  /** Current schema version; bumped when migrations are added. Exported so tests
27
27
  * assert against the constant instead of hardcoding a number that every bump
28
28
  * then has to chase (docs/05-sessions.md calls the constant the source of truth). */
29
- export const SCHEMA_VERSION = 34;
29
+ export const SCHEMA_VERSION = 37;
30
30
  /**
31
31
  * Bump to force `agents sessions backfill resources` to re-derive every
32
32
  * session's skill/slash-command tallies on its next run (resource_scan_ledger
@@ -81,7 +81,11 @@ CREATE TABLE IF NOT EXISTS sessions (
81
81
  message_count INTEGER,
82
82
  token_count INTEGER,
83
83
  output_tokens INTEGER,
84
+ input_tokens INTEGER,
85
+ cache_read_tokens INTEGER,
86
+ cache_write_tokens INTEGER,
84
87
  cost_usd REAL,
88
+ cost_usd_nocache REAL,
85
89
  duration_ms INTEGER,
86
90
  model TEXT,
87
91
  tool_call_count INTEGER,
@@ -204,6 +208,12 @@ CREATE TABLE IF NOT EXISTS tool_program_occurrences (
204
208
  CREATE INDEX IF NOT EXISTS idx_tool_program_occurrences_program
205
209
  ON tool_program_occurrences(program, call_key);
206
210
 
211
+ -- Derived search index over tool_calls. call_key is UNINDEXED -- it is carried
212
+ -- for display, NOT for lookup: an FTS5 table has no index on an ordinary column,
213
+ -- so DELETE ... WHERE call_key = ? scans the whole index once per call, which is
214
+ -- quadratic in a session's call count. Every write here therefore addresses a
215
+ -- row by rowid, mirroring the tool_calls.rowid of the call it describes, so a
216
+ -- delete is a single rowid seek (tool-store.ts persistToolCalls/deleteSessionCalls).
207
217
  CREATE VIRTUAL TABLE IF NOT EXISTS tool_call_text USING fts5(
208
218
  call_key UNINDEXED,
209
219
  tool,
@@ -224,7 +234,15 @@ CREATE TABLE IF NOT EXISTS tool_scan_ledger (
224
234
  extractor_version INTEGER NOT NULL,
225
235
  indexed_at INTEGER NOT NULL,
226
236
  call_count INTEGER NOT NULL,
227
- evidence_bytes INTEGER NOT NULL
237
+ evidence_bytes INTEGER NOT NULL,
238
+ -- Resume point for the incremental tool scan. parsed_offset is the byte
239
+ -- offset just past the last COMPLETE newline-terminated record consumed, and
240
+ -- parser_state is the serialized ToolCallCollector snapshot at that offset
241
+ -- (next ordinal + still-unresolved calls). Together they let the next scan of
242
+ -- a session that only grew read the appended bytes instead of the whole file.
243
+ -- NULL means "no resume point" — the next scan re-reads from byte 0.
244
+ parser_state TEXT,
245
+ parsed_offset INTEGER
228
246
  );
229
247
 
230
248
  -- Skill/slash-command usage per session (#12), computed from a session's
@@ -294,7 +312,8 @@ CREATE TABLE IF NOT EXISTS session_insights (
294
312
  * re-derives on the next `agents insights` instead of silently reporting stale
295
313
  * numbers alongside fresh ones. Same role as RESOURCE_INDEX_VERSION.
296
314
  */
297
- export const INSIGHTS_EXTRACTOR_VERSION = 3;
315
+ /** Bump when facet extraction changes so cached rows recompute (stalls-by-model v6). */
316
+ export const INSIGHTS_EXTRACTOR_VERSION = 6;
298
317
  let dbInstance = null;
299
318
  /**
300
319
  * Apply schema migrations from `fromVersion` → SCHEMA_VERSION. The new
@@ -810,6 +829,76 @@ function migrateSchema(db, fromVersion) {
810
829
  );
811
830
  `);
812
831
  }
832
+ if (fromVersion < 35) {
833
+ // v34 -> v35: the default listing sort was `ORDER BY IFNULL(last_activity,
834
+ // timestamp) DESC` — wrapping the column in IFNULL() makes SQLite unable to
835
+ // satisfy it from idx_sessions_last_activity, so every list/resume query did
836
+ // a full table sort instead of an index walk (RUSH-2211). Every upsert path
837
+ // already writes a non-NULL last_activity (resolveLastActivity falls back to
838
+ // `timestamp`, itself NOT NULL) — the only rows that can still be NULL here
839
+ // are ones written before the v8 migration that somehow slipped the backfill,
840
+ // or seeded directly by a test. Backfill them so the column is unconditionally
841
+ // NOT NULL, then querySessions can sort on the bare column and use the index.
842
+ db.exec(`UPDATE sessions SET last_activity = timestamp WHERE last_activity IS NULL`);
843
+ }
844
+ if (fromVersion < 36) {
845
+ // v35 -> v36: make the tool index incremental, and stop paying a full FTS
846
+ // scan per deleted call.
847
+ //
848
+ // (a) tool_scan_ledger gains a resume point (parser_state + parsed_offset).
849
+ // Existing rows get NULLs, which read as "no resume point": the next
850
+ // scan of each session re-reads it once from byte 0 and records a resume
851
+ // point, so every scan after that is incremental. No ledger is wiped.
852
+ //
853
+ // (b) tool_call_text is rebuilt so its rowid mirrors tool_calls.rowid. The
854
+ // old rows were inserted with FTS5-assigned rowids and are only
855
+ // addressable by the UNINDEXED call_key, i.e. a full index scan per
856
+ // delete. There is no ALTER for that, and the rowids cannot be repaired
857
+ // in place, so the table is dropped and repopulated from tool_calls --
858
+ // the same non-destructive derived-table rebuild v27 did (the source of
859
+ // truth is tool_calls, which is untouched). The rebuild also lands the
860
+ // content as one merged segment, which is the compaction
861
+ // optimizeSessionSearchIndex would otherwise have to do afterwards.
862
+ const ledgerCols = new Set(db.prepare(`PRAGMA table_info(tool_scan_ledger)`).all()
863
+ .map((column) => column.name));
864
+ if (!ledgerCols.has('parser_state'))
865
+ db.exec(`ALTER TABLE tool_scan_ledger ADD COLUMN parser_state TEXT`);
866
+ if (!ledgerCols.has('parsed_offset'))
867
+ db.exec(`ALTER TABLE tool_scan_ledger ADD COLUMN parsed_offset INTEGER`);
868
+ db.exec(`
869
+ DROP TABLE IF EXISTS tool_call_text;
870
+ CREATE VIRTUAL TABLE tool_call_text USING fts5(
871
+ call_key UNINDEXED,
872
+ tool,
873
+ input,
874
+ output,
875
+ error,
876
+ tokenize = 'trigram'
877
+ );
878
+ INSERT INTO tool_call_text (rowid, call_key, tool, input, output, error)
879
+ SELECT rowid, call_key, tool, input, coalesce(output, ''), coalesce(error, '')
880
+ FROM tool_calls;
881
+ `);
882
+ }
883
+ if (fromVersion < 37) {
884
+ // v36 -> v37: persist the burn SPLIT (uncached input / cache-read /
885
+ // cache-write) and a second "no-cache" cost per session, so `agents output`
886
+ // can report the token split and a --pricing no-cache scenario (RUSH-2287).
887
+ // These are new nullable columns — do NOT flush scan_ledger (adding a column
888
+ // must keep warm session ledgers warm, the contract the v33->v34 note above
889
+ // states). Pre-upgrade rows stay NULL for the split until their transcript is
890
+ // re-scanned; `agents output` reports the split only where it is present, so
891
+ // an absent split reads as "not available for this session", never as zero.
892
+ const cols = new Set(db.prepare(`PRAGMA table_info(sessions)`).all().map((c) => c.name));
893
+ if (!cols.has('input_tokens'))
894
+ db.exec(`ALTER TABLE sessions ADD COLUMN input_tokens INTEGER`);
895
+ if (!cols.has('cache_read_tokens'))
896
+ db.exec(`ALTER TABLE sessions ADD COLUMN cache_read_tokens INTEGER`);
897
+ if (!cols.has('cache_write_tokens'))
898
+ db.exec(`ALTER TABLE sessions ADD COLUMN cache_write_tokens INTEGER`);
899
+ if (!cols.has('cost_usd_nocache'))
900
+ db.exec(`ALTER TABLE sessions ADD COLUMN cost_usd_nocache REAL`);
901
+ }
813
902
  }
814
903
  /**
815
904
  * Stamp `account_key` / `account_org` / `account` on every Claude row from its
@@ -988,6 +1077,49 @@ export function optimizeSessionSearchIndex() {
988
1077
  return { table, segmentsBefore, segmentsAfter: segments(table) };
989
1078
  });
990
1079
  }
1080
+ /**
1081
+ * Segment count above which a scan pays for a slice of merge work. Below it the
1082
+ * index is small enough that querying it is not the bottleneck and merging is
1083
+ * pure overhead on every scan.
1084
+ */
1085
+ const FTS_MAINTENANCE_SEGMENT_THRESHOLD = 512;
1086
+ /**
1087
+ * Page budget for one incremental merge. FTS5's `'merge'` command does at most
1088
+ * this much work and returns — it is not `'optimize'`, which merges the whole
1089
+ * index in one unbounded pass. That bound is why this can run on the scan path:
1090
+ * the cost per scan is fixed, and repeated scans converge the index instead of
1091
+ * one scan stalling on a multi-gigabyte compaction.
1092
+ */
1093
+ const FTS_MAINTENANCE_MERGE_PAGES = 64;
1094
+ /**
1095
+ * Keep the FTS indexes from degrading on the normal scan path.
1096
+ *
1097
+ * `optimizeSessionSearchIndex` is the full, unbounded compaction behind
1098
+ * `agents sessions optimize`. Leaving it as the ONLY compaction meant the index
1099
+ * degraded until a human happened to run that command, which is how
1100
+ * `tool_call_text_data` reached gigabytes for tens of MB of content. This is the
1101
+ * automatic counterpart: bounded, threshold-gated, and safe to call after every
1102
+ * batch of writes. Non-destructive — merging never changes what is searchable.
1103
+ *
1104
+ * Returns one result per table it actually merged (empty when every table is
1105
+ * under the threshold, which is the common case on a warm index).
1106
+ */
1107
+ export function maintainSessionSearchIndex(db = getDB(), options = {}) {
1108
+ const threshold = options.segmentThreshold ?? FTS_MAINTENANCE_SEGMENT_THRESHOLD;
1109
+ const pages = options.mergePages ?? FTS_MAINTENANCE_MERGE_PAGES;
1110
+ // Hardcoded literals — never interpolate caller input into an identifier.
1111
+ const tables = ['tool_call_text', 'session_text'];
1112
+ const segments = (table) => db.prepare(`SELECT count(*) AS n FROM ${table}_data`).get().n;
1113
+ const results = [];
1114
+ for (const table of tables) {
1115
+ const segmentsBefore = segments(table);
1116
+ if (segmentsBefore < threshold)
1117
+ continue;
1118
+ db.prepare(`INSERT INTO ${table}(${table}, rank) VALUES('merge', ?)`).run(pages);
1119
+ results.push({ table, segmentsBefore, segmentsAfter: segments(table) });
1120
+ }
1121
+ return results;
1122
+ }
991
1123
  // ---------------------------------------------------------------------------
992
1124
  // Scan coordinator — prevents concurrent full scans across processes
993
1125
  // ---------------------------------------------------------------------------
@@ -1261,7 +1393,8 @@ const upsertSessionStmt = (db) => db.prepare(`
1261
1393
  id, short_id, agent, origin, routine_name, routine_run_id,
1262
1394
  version, account, account_key, account_org, mode, timestamp, last_activity,
1263
1395
  project, cwd, git_branch, topic, label, message_count, token_count,
1264
- output_tokens, cost_usd, duration_ms, model, tool_call_count,
1396
+ output_tokens, input_tokens, cache_read_tokens, cache_write_tokens,
1397
+ cost_usd, cost_usd_nocache, duration_ms, model, tool_call_count,
1265
1398
  file_path, file_mtime_ms, file_size, scanned_at, is_team_origin,
1266
1399
  pr_url, pr_number, worktree_slug, ticket_id, spawned_team, plan, todos,
1267
1400
  recent_directories_touched, linear_project, linear_project_url, machine,
@@ -1270,7 +1403,8 @@ const upsertSessionStmt = (db) => db.prepare(`
1270
1403
  @id, @short_id, @agent, @origin, @routine_name, @routine_run_id,
1271
1404
  @version, @account, @account_key, @account_org, @mode, @timestamp, @last_activity,
1272
1405
  @project, @cwd, @git_branch, @topic, @label, @message_count, @token_count,
1273
- @output_tokens, @cost_usd, @duration_ms, @model, @tool_call_count,
1406
+ @output_tokens, @input_tokens, @cache_read_tokens, @cache_write_tokens,
1407
+ @cost_usd, @cost_usd_nocache, @duration_ms, @model, @tool_call_count,
1274
1408
  @file_path, @file_mtime_ms, @file_size, @scanned_at, @is_team_origin,
1275
1409
  @pr_url, @pr_number, @worktree_slug, @ticket_id, @spawned_team, @plan, @todos,
1276
1410
  @recent_directories_touched, @linear_project, @linear_project_url, @machine,
@@ -1305,7 +1439,11 @@ const upsertSessionStmt = (db) => db.prepare(`
1305
1439
  message_count = excluded.message_count,
1306
1440
  token_count = excluded.token_count,
1307
1441
  output_tokens = excluded.output_tokens,
1442
+ input_tokens = excluded.input_tokens,
1443
+ cache_read_tokens = excluded.cache_read_tokens,
1444
+ cache_write_tokens = excluded.cache_write_tokens,
1308
1445
  cost_usd = excluded.cost_usd,
1446
+ cost_usd_nocache = excluded.cost_usd_nocache,
1309
1447
  duration_ms = excluded.duration_ms,
1310
1448
  model = excluded.model,
1311
1449
  tool_call_count = excluded.tool_call_count,
@@ -1544,7 +1682,11 @@ export function upsertSession(meta, content, scan) {
1544
1682
  message_count: meta.messageCount ?? null,
1545
1683
  token_count: meta.tokenCount ?? null,
1546
1684
  output_tokens: meta.outputTokens ?? null,
1685
+ input_tokens: meta.inputTokens ?? null,
1686
+ cache_read_tokens: meta.cacheReadTokens ?? null,
1687
+ cache_write_tokens: meta.cacheWriteTokens ?? null,
1547
1688
  cost_usd: meta.costUsd ?? null,
1689
+ cost_usd_nocache: meta.costUsdNoCache ?? null,
1548
1690
  duration_ms: meta.durationMs ?? null,
1549
1691
  model: meta.model ?? null,
1550
1692
  tool_call_count: meta.toolCallCount ?? null,
@@ -1627,9 +1769,10 @@ export function upsertSessionsBatch(entries) {
1627
1769
  const stat = fs.statSync(toolSourcePath);
1628
1770
  return { fileMtimeMs: stat.mtimeMs, fileSize: stat.size };
1629
1771
  })();
1630
- // Non-resumable harnesses already parse here for todos/recent dirs. Derive
1631
- // the tool index from those same in-memory events: no second file read.
1632
- const events = parseSession(entry.meta.filePath, entry.meta.agent);
1772
+ // Some non-resumable scanners already normalized the transcript while
1773
+ // deriving metadata. Reuse those events; scanners that only read summary
1774
+ // metadata fall back to exactly one normalized parse here.
1775
+ const events = entry.events ?? parseSession(entry.meta.filePath, entry.meta.agent);
1633
1776
  writeResourceUsage(entry.meta.id, events, entry.meta.cwd);
1634
1777
  return {
1635
1778
  ...entry,
@@ -1640,6 +1783,8 @@ export function upsertSessionsBatch(entries) {
1640
1783
  },
1641
1784
  toolCalls: toolCallsFromEvents(events),
1642
1785
  toolScan,
1786
+ // These are complete event arrays, not an appended tail. Append would
1787
+ // duplicate existing evidence even when persistToolCalls supports it.
1643
1788
  toolIndexMode: 'replace',
1644
1789
  };
1645
1790
  }
@@ -1721,7 +1866,11 @@ export function upsertSessionsBatch(entries) {
1721
1866
  message_count: meta.messageCount ?? null,
1722
1867
  token_count: meta.tokenCount ?? null,
1723
1868
  output_tokens: meta.outputTokens ?? null,
1869
+ input_tokens: meta.inputTokens ?? null,
1870
+ cache_read_tokens: meta.cacheReadTokens ?? null,
1871
+ cache_write_tokens: meta.cacheWriteTokens ?? null,
1724
1872
  cost_usd: meta.costUsd ?? null,
1873
+ cost_usd_nocache: meta.costUsdNoCache ?? null,
1725
1874
  duration_ms: meta.durationMs ?? null,
1726
1875
  model: meta.model ?? null,
1727
1876
  tool_call_count: meta.toolCallCount ?? null,
@@ -1773,12 +1922,18 @@ export function upsertSessionsBatch(entries) {
1773
1922
  if (!toolScan || !entry.toolCalls)
1774
1923
  continue;
1775
1924
  try {
1776
- persistToolCalls(db, entry.meta, entry.toolCalls, toolScan, entry.toolIndexMode ?? 'replace');
1925
+ persistToolCalls(db, entry.meta, entry.toolCalls, toolScan, { mode: entry.toolIndexMode ?? 'replace' });
1777
1926
  }
1778
1927
  catch {
1779
1928
  // Boundary is intentionally retryable via tool_scan_ledger.
1780
1929
  }
1781
1930
  }
1931
+ // Every batch appends FTS segments (session_text always, tool_call_text for the
1932
+ // harnesses indexed above). Pay a bounded slice of the merge here so the
1933
+ // scan path keeps its own index healthy instead of leaving all compaction to
1934
+ // the manual `agents sessions optimize` (RUSH-2208). Threshold-gated, so a
1935
+ // small index costs two counts and nothing else.
1936
+ maintainSessionSearchIndex(db);
1782
1937
  }
1783
1938
  /**
1784
1939
  * Sync labels for a set of sessions. For each id in the map, if the stored
@@ -1929,7 +2084,11 @@ function rowToMeta(row) {
1929
2084
  messageCount: row.message_count ?? undefined,
1930
2085
  tokenCount: row.token_count ?? undefined,
1931
2086
  outputTokens: row.output_tokens ?? undefined,
2087
+ inputTokens: row.input_tokens ?? undefined,
2088
+ cacheReadTokens: row.cache_read_tokens ?? undefined,
2089
+ cacheWriteTokens: row.cache_write_tokens ?? undefined,
1932
2090
  costUsd: row.cost_usd ?? undefined,
2091
+ costUsdNoCache: row.cost_usd_nocache ?? undefined,
1933
2092
  durationMs: row.duration_ms ?? undefined,
1934
2093
  model: row.model ?? undefined,
1935
2094
  toolCallCount: row.tool_call_count ?? undefined,
@@ -2118,6 +2277,51 @@ function buildSessionWhere(options) {
2118
2277
  const clause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
2119
2278
  return { clause, params };
2120
2279
  }
2280
+ /**
2281
+ * Resolve which of the given file paths no longer exist, batching the check
2282
+ * per directory instead of one `fs.existsSync` stat syscall per file.
2283
+ * Transcript trees put many sessions in the same directory (one Claude
2284
+ * `~/.claude/projects/<slug>/` holds every session for that project), so
2285
+ * `readdirSync` once per directory and a Set membership test collapses what
2286
+ * used to be N stat syscalls into (number of distinct directories) readdir
2287
+ * syscalls — the same existence answer, far fewer syscalls on a large index
2288
+ * (RUSH-2211). Falls back to per-file existsSync only when the directory
2289
+ * itself can't be listed (permissions, race with a concurrent delete).
2290
+ */
2291
+ function findMissingFilePaths(filePaths) {
2292
+ const byDir = new Map();
2293
+ for (const p of filePaths) {
2294
+ const dir = path.dirname(p);
2295
+ let basenames = byDir.get(dir);
2296
+ if (!basenames) {
2297
+ basenames = new Set();
2298
+ byDir.set(dir, basenames);
2299
+ }
2300
+ basenames.add(path.basename(p));
2301
+ }
2302
+ const missing = new Set();
2303
+ for (const [dir, basenames] of byDir) {
2304
+ let entries;
2305
+ try {
2306
+ entries = new Set(fs.readdirSync(dir));
2307
+ }
2308
+ catch {
2309
+ // Directory itself is gone (or unreadable) — every file in it is missing.
2310
+ // Also covers the race where readdir loses to a concurrent delete: fall
2311
+ // back to a direct stat rather than assuming existence.
2312
+ for (const base of basenames) {
2313
+ if (!fs.existsSync(path.join(dir, base)))
2314
+ missing.add(path.join(dir, base));
2315
+ }
2316
+ continue;
2317
+ }
2318
+ for (const base of basenames) {
2319
+ if (!entries.has(base))
2320
+ missing.add(path.join(dir, base));
2321
+ }
2322
+ }
2323
+ return missing;
2324
+ }
2121
2325
  /** Query sessions from the database, applying filters and ordering by last-activity descending (default). */
2122
2326
  export function querySessions(options = {}) {
2123
2327
  const db = getDB();
@@ -2130,11 +2334,18 @@ export function querySessions(options = {}) {
2130
2334
  : '';
2131
2335
  // NULLs last so unpriced / duration-less rows never crowd out real data when
2132
2336
  // sorting by cost or duration. timestamp is never null (NOT NULL column).
2337
+ // Default sort is the bare `last_activity` column, not `IFNULL(last_activity,
2338
+ // timestamp)` — the v35 migration backfills every row so last_activity is
2339
+ // never NULL, and every upsert path (resolveLastActivity) keeps it that way
2340
+ // going forward. Wrapping the column in IFNULL() defeats
2341
+ // idx_sessions_last_activity (SQLite can't use an index on an expression that
2342
+ // isn't the bare column); the bare column lets the planner walk the index
2343
+ // instead of sorting the whole result set (RUSH-2211).
2133
2344
  const orderClause = options.sortBy === 'cost'
2134
2345
  ? 'ORDER BY cost_usd IS NULL, cost_usd DESC, timestamp DESC'
2135
2346
  : options.sortBy === 'duration'
2136
2347
  ? 'ORDER BY duration_ms IS NULL, duration_ms DESC, timestamp DESC'
2137
- : 'ORDER BY IFNULL(last_activity, timestamp) DESC, timestamp DESC';
2348
+ : 'ORDER BY last_activity DESC, timestamp DESC';
2138
2349
  const sql = `SELECT * FROM sessions ${clause} ${orderClause} ${limitClause}`;
2139
2350
  const rows = db.prepare(sql).all(...params);
2140
2351
  if (options.skipExistenceCheck) {
@@ -2147,7 +2358,8 @@ export function querySessions(options = {}) {
2147
2358
  // surfacing in the Factory UI if any code path forgets to rewrite (#136).
2148
2359
  // Synthetic rows (OpenClaw channels/cron — see scanOpenClawIncremental) carry
2149
2360
  // an empty file_path and are exempt; they're keyed by CLI output, not files.
2150
- const missing = rows.filter(r => r.file_path && !fs.existsSync(r.file_path));
2361
+ const missingPaths = findMissingFilePaths(rows.map(r => r.file_path).filter((p) => !!p));
2362
+ const missing = rows.filter(r => r.file_path && missingPaths.has(r.file_path));
2151
2363
  if (missing.length > 0) {
2152
2364
  const purge = db.transaction(() => {
2153
2365
  for (const row of missing)
@@ -2324,10 +2536,17 @@ export function queryUsageRollup(options) {
2324
2536
  THEN account_org || ' <' || account || '>' END) AS label,`
2325
2537
  : ''}
2326
2538
  IFNULL(SUM(cost_usd), 0) AS costUsd,
2539
+ -- A session with a cost but no persisted no-cache figure records no cache
2540
+ -- split, so its no-cache cost equals its actual cost — fall back to cost_usd
2541
+ -- so it still contributes to the scenario total rather than dropping to 0.
2542
+ IFNULL(SUM(COALESCE(cost_usd_nocache, cost_usd)), 0) AS costUsdNoCache,
2327
2543
  IFNULL(SUM(duration_ms), 0) AS durationMs,
2328
2544
  COUNT(*) AS sessionCount,
2329
2545
  IFNULL(SUM(token_count), 0) AS tokenCount,
2330
- IFNULL(SUM(output_tokens), 0) AS outputTokens
2546
+ IFNULL(SUM(output_tokens), 0) AS outputTokens,
2547
+ IFNULL(SUM(input_tokens), 0) AS inputTokens,
2548
+ IFNULL(SUM(cache_read_tokens), 0) AS cacheReadTokens,
2549
+ IFNULL(SUM(cache_write_tokens), 0) AS cacheWriteTokens
2331
2550
  FROM sessions
2332
2551
  ${clause}
2333
2552
  GROUP BY key
@@ -2628,6 +2847,20 @@ export function buildFtsQuery(input) {
2628
2847
  const expr = terms.map(t => `${t}*`).join(' OR ');
2629
2848
  return { expr, terms };
2630
2849
  }
2850
+ /**
2851
+ * Build a `label:(...)` FTS5 column-filter MATCH expression for the label
2852
+ * tier. Unlike `buildFtsQuery` (2-char floor, tuned for full-content search),
2853
+ * this allows 1-char terms: label search is the interactive type-ahead path —
2854
+ * the query grows one keystroke at a time, so a single character has to be
2855
+ * indexable too. Terms are filtered to `[a-z0-9]` before being embedded in the
2856
+ * expression string, so there's no FTS5 syntax injection from user input.
2857
+ */
2858
+ function buildLabelFtsQuery(input) {
2859
+ const terms = input.toLowerCase().split(/[^a-z0-9]+/).filter(t => t.length >= 1);
2860
+ if (terms.length === 0)
2861
+ return '';
2862
+ return `label:(${terms.map(t => `${t}*`).join(' OR ')})`;
2863
+ }
2631
2864
  /**
2632
2865
  * Label-first search. Sessions whose custom label substring-matches the query
2633
2866
  * always rank ahead of FTS5 hits — this gives predictable behavior when a user
@@ -2655,10 +2888,34 @@ export function ftsSearch(input, limit = 200) {
2655
2888
  // its `label` — set by an agent title / `/rename`, or seeded at launch from
2656
2889
  // `agents run --name`. Typing it resolves the session ahead of any FTS content
2657
2890
  // hit.
2658
- const labelRows = db.prepare(`
2659
- SELECT id, label FROM sessions
2660
- WHERE label IS NOT NULL AND LOWER(label) LIKE ?
2661
- `).all(`%${lower}%`);
2891
+ //
2892
+ // Candidates come from the FTS5 `label` column, not a raw `LOWER(label) LIKE
2893
+ // '%q%'` scan of `sessions`: a leading wildcard can't use any index, so on a
2894
+ // large session table that was a full-table scan on every keystroke of
2895
+ // interactive search (RUSH-2211). `session_text.label` is kept 1:1 with
2896
+ // `sessions.label` by every upsert path (storedFtsLabel), so this is the
2897
+ // same data, indexed. Token-prefix matching seeks the FTS index instead of
2898
+ // scanning every row, at the cost of only matching at token boundaries — a
2899
+ // substring inside a single token (e.g. "ckf" inside "quickfix") no longer
2900
+ // matches, since FTS5 only indexes prefixes of whole tokens, not arbitrary
2901
+ // interior slices. (A slice spanning a token boundary, like "ix-b" inside
2902
+ // "fix-bug", still matches: "ix-b" tokenizes to "ix" + "b", and "b" is a
2903
+ // valid prefix of the "bug" token.) That's the accepted trade-off for an
2904
+ // indexable interactive path; the exact/prefix/contains scoring below still
2905
+ // runs in JS over the FTS candidate set, so ranking among real matches is
2906
+ // unchanged. Only a query with no indexable token (rare — e.g.
2907
+ // punctuation-only input) falls back to the direct scan rather than
2908
+ // silently dropping the tier.
2909
+ const labelMatchExpr = buildLabelFtsQuery(input);
2910
+ const labelRows = labelMatchExpr
2911
+ ? db.prepare(`
2912
+ SELECT session_id AS id, label FROM session_text
2913
+ WHERE session_text MATCH ?
2914
+ `).all(labelMatchExpr)
2915
+ : db.prepare(`
2916
+ SELECT id, label FROM sessions
2917
+ WHERE label IS NOT NULL AND LOWER(label) LIKE ?
2918
+ `).all(`%${lower}%`);
2662
2919
  let hasExactLabelMatch = false;
2663
2920
  for (const row of labelRows) {
2664
2921
  // Score the label by match quality (exact > prefix > contains).
@@ -71,8 +71,14 @@ interface ClaudeSessionScan {
71
71
  tokenCount?: number;
72
72
  /** Real generated (output) tokens, excluding cache-read/-write context. */
73
73
  outputTokens?: number;
74
+ /** Burn split — uncached input / cache-read / cache-write tokens (RUSH-2287). */
75
+ inputTokens?: number;
76
+ cacheReadTokens?: number;
77
+ cacheWriteTokens?: number;
74
78
  /** Total USD cost accumulated from per-(model, direction) token usage. */
75
79
  costUsd?: number;
80
+ /** USD cost with cache read/write repriced at the input rate (RUSH-2287). */
81
+ costUsdNoCache?: number;
76
82
  /** Wall-clock duration in ms between the first and last timestamped event. */
77
83
  durationMs?: number;
78
84
  /** ISO time of the last timestamped event — the session's last activity. */
@@ -122,7 +128,13 @@ interface CodexSessionScan {
122
128
  tokenCount?: number;
123
129
  /** Real generated (output) tokens, excluding cache-read/-write context. */
124
130
  outputTokens?: number;
131
+ /** Burn split — uncached input / cache-read tokens (Codex has no cache-write) (RUSH-2287). */
132
+ inputTokens?: number;
133
+ cacheReadTokens?: number;
134
+ cacheWriteTokens?: number;
125
135
  costUsd?: number;
136
+ /** USD cost with cache read repriced at the input rate (RUSH-2287). */
137
+ costUsdNoCache?: number;
126
138
  durationMs?: number;
127
139
  lastActivity?: string;
128
140
  contentText?: string;
@@ -417,8 +429,12 @@ export interface ClaudeParseState {
417
429
  toolCallCount: number;
418
430
  tokenCount: number;
419
431
  outputTokens: number;
432
+ inputTokens: number;
433
+ cacheReadTokens: number;
434
+ cacheWriteTokens: number;
420
435
  sawTokenCount: boolean;
421
436
  costUsd: number;
437
+ costUsdNoCache: number;
422
438
  sawCost: boolean;
423
439
  firstTsMs?: number;
424
440
  lastTsMs?: number;
@@ -468,7 +484,7 @@ export declare function scanClaudeSession(filePath: string): Promise<ClaudeSessi
468
484
  * exact even when the recent window is smaller than the true count.
469
485
  */
470
486
  export interface ClaudeParserState {
471
- v: 2;
487
+ v: 3;
472
488
  offset: number;
473
489
  jsonlDroppingOversizedLine?: boolean;
474
490
  timestamp?: string;
@@ -487,9 +503,13 @@ export interface ClaudeParserState {
487
503
  toolCallCount: number;
488
504
  tokenCount: number;
489
505
  outputTokens: number;
506
+ inputTokens: number;
507
+ cacheReadTokens: number;
508
+ cacheWriteTokens: number;
490
509
  sawTokenCount: boolean;
491
510
  sawCost: boolean;
492
511
  costUsd: number;
512
+ costUsdNoCache: number;
493
513
  seenIdsSize: number;
494
514
  seenIdsRecent: string[];
495
515
  sawPrCreate: boolean;
@@ -690,6 +710,7 @@ export declare function __resetCodexScanBranchCountsForTest(): void;
690
710
  export declare function readCursorMeta(filePath: string, currentVersion?: string): {
691
711
  meta: SessionMeta;
692
712
  content: string;
713
+ events: SessionEvent[];
693
714
  } | null;
694
715
  /** Parse a single Kimi session state.json file to extract session metadata. */
695
716
  export declare function readKimiMeta(filePath: string, priorRow?: {