@phnx-labs/agents-cli 1.22.24 → 1.22.26

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 (143) hide show
  1. package/CHANGELOG.md +350 -1
  2. package/README.md +31 -6
  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/browser.js +35 -0
  7. package/dist/commands/cloud.js +9 -5
  8. package/dist/commands/doctor.d.ts +24 -0
  9. package/dist/commands/doctor.js +108 -9
  10. package/dist/commands/exec.js +19 -16
  11. package/dist/commands/feed.d.ts +5 -0
  12. package/dist/commands/feed.js +21 -1
  13. package/dist/commands/focus.js +2 -2
  14. package/dist/commands/insights.d.ts +25 -19
  15. package/dist/commands/insights.js +107 -33
  16. package/dist/commands/menubar.js +8 -0
  17. package/dist/commands/reconnect.d.ts +46 -0
  18. package/dist/commands/reconnect.js +109 -0
  19. package/dist/commands/routines.d.ts +3 -0
  20. package/dist/commands/routines.js +72 -49
  21. package/dist/commands/run-cloud.js +1 -1
  22. package/dist/commands/secrets.d.ts +2 -8
  23. package/dist/commands/secrets.js +29 -105
  24. package/dist/commands/sessions-browser.d.ts +1 -1
  25. package/dist/commands/sessions-browser.js +27 -7
  26. package/dist/commands/sessions-resume.js +3 -2
  27. package/dist/commands/sessions.d.ts +13 -1
  28. package/dist/commands/sessions.js +28 -2
  29. package/dist/commands/setup-secrets.d.ts +1 -0
  30. package/dist/commands/setup-secrets.js +1 -1
  31. package/dist/commands/setup-watchdog.js +5 -10
  32. package/dist/commands/setup.d.ts +26 -3
  33. package/dist/commands/setup.js +106 -47
  34. package/dist/commands/teams.d.ts +6 -0
  35. package/dist/commands/teams.js +46 -2
  36. package/dist/commands/trends.d.ts +8 -0
  37. package/dist/commands/trends.js +10 -156
  38. package/dist/commands/watchdog.d.ts +3 -4
  39. package/dist/commands/watchdog.js +26 -66
  40. package/dist/index.js +37 -2
  41. package/dist/lib/agents.d.ts +11 -0
  42. package/dist/lib/agents.js +155 -23
  43. package/dist/lib/analytics/dashboard.d.ts +10 -6
  44. package/dist/lib/analytics/dashboard.js +6 -4
  45. package/dist/lib/analytics/mix-commands.d.ts +53 -0
  46. package/dist/lib/analytics/mix-commands.js +229 -0
  47. package/dist/lib/analytics/recipes.d.ts +19 -14
  48. package/dist/lib/analytics/recipes.js +4 -2
  49. package/dist/lib/browser/ipc.d.ts +26 -0
  50. package/dist/lib/browser/ipc.js +139 -24
  51. package/dist/lib/browser/profiles.d.ts +11 -0
  52. package/dist/lib/browser/profiles.js +1 -1
  53. package/dist/lib/browser/stream.d.ts +14 -0
  54. package/dist/lib/browser/stream.js +71 -0
  55. package/dist/lib/channels/owner-sink.d.ts +27 -0
  56. package/dist/lib/channels/owner-sink.js +93 -0
  57. package/dist/lib/cloud/cursor.d.ts +79 -0
  58. package/dist/lib/cloud/cursor.js +228 -0
  59. package/dist/lib/cloud/registry.js +2 -0
  60. package/dist/lib/cloud/types.d.ts +7 -2
  61. package/dist/lib/cloud/types.js +14 -0
  62. package/dist/lib/crabbox/cli.d.ts +2 -2
  63. package/dist/lib/crabbox/config.d.ts +7 -8
  64. package/dist/lib/crabbox/config.js +14 -14
  65. package/dist/lib/crabbox/lease.d.ts +11 -4
  66. package/dist/lib/crabbox/lease.js +40 -8
  67. package/dist/lib/crabbox/setup-copy.d.ts +5 -0
  68. package/dist/lib/crabbox/setup-copy.js +17 -1
  69. package/dist/lib/daemon.js +27 -1
  70. package/dist/lib/device-config.js +7 -0
  71. package/dist/lib/devices/doctor-findings.d.ts +13 -1
  72. package/dist/lib/devices/doctor-findings.js +73 -2
  73. package/dist/lib/events.d.ts +9 -0
  74. package/dist/lib/events.js +58 -0
  75. package/dist/lib/exec.d.ts +3 -3
  76. package/dist/lib/exec.js +24 -10
  77. package/dist/lib/feed-outcome.d.ts +3 -0
  78. package/dist/lib/feed-outcome.js +18 -10
  79. package/dist/lib/feed.d.ts +4 -0
  80. package/dist/lib/fleet/apply.d.ts +59 -3
  81. package/dist/lib/fleet/apply.js +183 -6
  82. package/dist/lib/fleet/types.d.ts +21 -2
  83. package/dist/lib/hooks/cache.js +15 -0
  84. package/dist/lib/hosts/passthrough.d.ts +44 -0
  85. package/dist/lib/hosts/passthrough.js +84 -12
  86. package/dist/lib/hosts/ready.d.ts +2 -0
  87. package/dist/lib/hosts/ready.js +10 -1
  88. package/dist/lib/hosts/reconnect.d.ts +14 -12
  89. package/dist/lib/hosts/reconnect.js +41 -40
  90. package/dist/lib/mcp.js +5 -1
  91. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  92. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  93. package/dist/lib/menubar/snapshot.d.ts +15 -0
  94. package/dist/lib/menubar/snapshot.js +40 -0
  95. package/dist/lib/plugins.js +13 -1
  96. package/dist/lib/resources/mcp.js +3 -0
  97. package/dist/lib/routine-process-cleanup.d.ts +9 -0
  98. package/dist/lib/routine-process-cleanup.js +73 -0
  99. package/dist/lib/routines.js +14 -2
  100. package/dist/lib/runner.d.ts +0 -3
  101. package/dist/lib/runner.js +6 -19
  102. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  103. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  104. package/dist/lib/secrets/bundles.js +1 -20
  105. package/dist/lib/secrets/filestore.d.ts +2 -0
  106. package/dist/lib/secrets/filestore.js +13 -0
  107. package/dist/lib/secrets/push.d.ts +94 -0
  108. package/dist/lib/secrets/push.js +145 -0
  109. package/dist/lib/secrets/rc-hygiene.d.ts +14 -0
  110. package/dist/lib/secrets/rc-hygiene.js +14 -1
  111. package/dist/lib/secrets/reaper.d.ts +15 -1
  112. package/dist/lib/secrets/reaper.js +30 -3
  113. package/dist/lib/session/active.d.ts +4 -0
  114. package/dist/lib/session/db.d.ts +21 -3
  115. package/dist/lib/session/db.js +229 -15
  116. package/dist/lib/session/discover.d.ts +1 -0
  117. package/dist/lib/session/discover.js +115 -19
  118. package/dist/lib/session/insights.d.ts +18 -0
  119. package/dist/lib/session/insights.js +143 -1
  120. package/dist/lib/session/remote-list.d.ts +2 -0
  121. package/dist/lib/session/remote-list.js +1 -0
  122. package/dist/lib/session/session-cache.d.ts +4 -4
  123. package/dist/lib/session/session-cache.js +4 -4
  124. package/dist/lib/session/tool-index.js +133 -22
  125. package/dist/lib/session/tool-store.d.ts +26 -2
  126. package/dist/lib/session/tool-store.js +36 -17
  127. package/dist/lib/shims.js +21 -1
  128. package/dist/lib/signin-badge.js +2 -0
  129. package/dist/lib/ssh-exec.js +8 -2
  130. package/dist/lib/startup/command-registry.d.ts +16 -0
  131. package/dist/lib/startup/command-registry.js +46 -0
  132. package/dist/lib/teams/agents.d.ts +13 -0
  133. package/dist/lib/teams/agents.js +76 -8
  134. package/dist/lib/teams/parsers.d.ts +1 -1
  135. package/dist/lib/teams/placement-probe.d.ts +21 -0
  136. package/dist/lib/teams/placement-probe.js +135 -0
  137. package/dist/lib/teams/scheduler.d.ts +74 -1
  138. package/dist/lib/teams/scheduler.js +187 -10
  139. package/dist/lib/types.d.ts +1 -1
  140. package/dist/lib/versions.js +16 -1
  141. package/dist/lib/watchdog/service.d.ts +17 -0
  142. package/dist/lib/watchdog/service.js +39 -0
  143. package/package.json +1 -1
@@ -65,6 +65,7 @@ function emptyFacets() {
65
65
  shellCommandsSeen: 0,
66
66
  messageHours: new Array(24).fill(0), userTurns: 0, assistantTurns: 0,
67
67
  toolCount: 0, errorCount: 0,
68
+ frictionSignals: {}, correctionSignals: {}, automationSignals: {},
68
69
  };
69
70
  }
70
71
  function bump(map, key, by = 1) {
@@ -145,6 +146,7 @@ export function computeInsightFacets(events, timezoneOffsetMinutes = new Date().
145
146
  // a reply latency. It censors 5.5% of gaps, and `gapsOverCeiling` reports how many so
146
147
  // the number is never quietly truncated.
147
148
  let lastAssistantTs = null;
149
+ let lastFailedTool = null;
148
150
  for (const e of events) {
149
151
  const ts = new Date(e.timestamp).getTime();
150
152
  const hasTs = !Number.isNaN(ts);
@@ -153,13 +155,21 @@ export function computeInsightFacets(events, timezoneOffsetMinutes = new Date().
153
155
  f.interruptions++;
154
156
  break;
155
157
  case 'usage':
156
- // shortenModel so the label matches `agents sessions <id>` and `trends`
158
+ // shortenModel so the label matches `agents sessions <id>` and `insights mix`
157
159
  // rather than printing the raw id beside their shortened one.
158
160
  if (e.model)
159
161
  bump(f.models, shortenModel(e.model));
160
162
  break;
161
163
  case 'error':
162
164
  bump(f.errorCategories, categorizeError(e.content ?? e.output ?? ''));
165
+ if (e.tool && e.tool === lastFailedTool)
166
+ bump(f.frictionSignals, `failed tool loop: ${e.tool}`);
167
+ lastFailedTool = e.tool ?? null;
168
+ classifyFriction(e.content ?? e.output ?? '', f.frictionSignals);
169
+ break;
170
+ case 'tool_result':
171
+ if (e.success !== false && e.outcome !== 'error')
172
+ lastFailedTool = null;
163
173
  break;
164
174
  case 'message':
165
175
  if (e.role === 'assistant') {
@@ -169,6 +179,8 @@ export function computeInsightFacets(events, timezoneOffsetMinutes = new Date().
169
179
  }
170
180
  if (e.role !== 'user')
171
181
  break;
182
+ if (!e._synthetic)
183
+ classifyCorrection(e.content ?? '', f.correctionSignals);
172
184
  if (hasTs) {
173
185
  // Local-time hour. parse.ts falls back to `new Date()` for a record with no
174
186
  // timestamp; those are indistinguishable here, but they are rare and would
@@ -197,6 +209,8 @@ export function computeInsightFacets(events, timezoneOffsetMinutes = new Date().
197
209
  lastAssistantTs = ts;
198
210
  const args = e.args ?? {};
199
211
  const toolName = e.tool ?? '';
212
+ if (/askuserquestion/i.test(toolName))
213
+ classifyAskStall(args, f.correctionSignals);
200
214
  // Keyed on the SHARED cross-harness vocabulary, not Claude's literals. Keying
201
215
  // on 'Edit'|'MultiEdit'|'Write' meant codex (whose vocabulary is exec /
202
216
  // exec_command / write_stdin) reported 5,197 tool calls and exactly zero lines
@@ -218,6 +232,7 @@ export function computeInsightFacets(events, timezoneOffsetMinutes = new Date().
218
232
  f.shellCommandsSeen++;
219
233
  f.gitCommits += countGitOp(e.command, 'commit');
220
234
  f.gitPushes += countGitOp(e.command, 'push');
235
+ classifyAutomation(e.command, f.automationSignals);
221
236
  }
222
237
  break;
223
238
  }
@@ -228,6 +243,59 @@ export function computeInsightFacets(events, timezoneOffsetMinutes = new Date().
228
243
  }
229
244
  return f;
230
245
  }
246
+ const FRICTION_PATTERNS = [
247
+ [/blocked by|guard(?:rail)? (?:blocked|denied)|permission denied/i, 'blocked guard'],
248
+ [/\b(?:ci|check|workflow)\b.*\b(?:red|fail(?:ed|ure)?)\b|\b(?:red|failed)\b.*\bci\b/i, 'CI red loop'],
249
+ [/merge conflict|conflict in |CONFLICT \(/i, 'merge conflict'],
250
+ ];
251
+ const CORRECTION_PATTERNS = [
252
+ [/\b(?:continue|keep going|don'?t stop)\b/i, 'continue / keep going'],
253
+ [/\b(?:yes|go ahead|do it|merge it)\b/i, 'approval repeated'],
254
+ [/\b(?:are we done|done end.to.end|what(?:'s| is) left)\b/i, 'done end-to-end?'],
255
+ [/\b(?:did you merge|merged\??)\b/i, 'did you merge?'],
256
+ [/\bwhat(?:'s| is) next\??\b/i, "what's next?"],
257
+ [/\b(?:check now|check again|try now|did it work)\b/i, 'check now'],
258
+ [/\b(?:don'?t ask|just do it|run what)\b/i, "don't ask / just do it"],
259
+ ];
260
+ const AUTOMATION_PATTERNS = [
261
+ [/\bgh pr (?:checks|view|merge)\b/i, 'PR babysitting'],
262
+ [/\bagents secrets (?:list|exec|unlock|export)\b/i, 'secrets unlock dance'],
263
+ [/\bgit (?:fetch|rebase|merge|push)\b/i, 'git reconcile recipe'],
264
+ [/\b(?:scp|rsync|agents ssh)\b/i, 'fleet file transfer'],
265
+ [/\b(?:release\.sh|deploy\.sh|npm publish)\b/i, 'release / deploy recipe'],
266
+ ];
267
+ function classifyFriction(text, counts) {
268
+ for (const [pattern, label] of FRICTION_PATTERNS)
269
+ if (pattern.test(text))
270
+ bump(counts, label);
271
+ }
272
+ function classifyCorrection(text, counts) {
273
+ const normalized = text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
274
+ for (const [pattern, label] of CORRECTION_PATTERNS)
275
+ if (pattern.test(normalized))
276
+ bump(counts, label);
277
+ }
278
+ function classifyAskStall(args, counts) {
279
+ const text = JSON.stringify(args).toLowerCase();
280
+ const categories = [
281
+ [/release|ship|deploy|publish/, 'Ask stall: release / ship / deploy'],
282
+ [/what'?s next|next step|next move/, "Ask stall: what's next?"],
283
+ [/merge|reconcile|rebase/, 'Ask stall: merge / reconcile'],
284
+ [/direction|approach|implementation/, 'Ask stall: direction / approach'],
285
+ ];
286
+ for (const [pattern, label] of categories) {
287
+ if (pattern.test(text)) {
288
+ bump(counts, label);
289
+ return;
290
+ }
291
+ }
292
+ bump(counts, 'AskUserQuestion');
293
+ }
294
+ function classifyAutomation(command, counts) {
295
+ for (const [pattern, label] of AUTOMATION_PATTERNS)
296
+ if (pattern.test(command))
297
+ bump(counts, label);
298
+ }
231
299
  /** Percentile of a numeric sample, nearest-rank. Returns 0 for an empty sample. */
232
300
  export function percentile(values, p) {
233
301
  if (values.length === 0)
@@ -298,6 +366,12 @@ export function mergeFacets(into, add) {
298
366
  bump(into.slashCommands, k, v);
299
367
  for (const [k, v] of Object.entries(add.errorCategories))
300
368
  bump(into.errorCategories, k, v);
369
+ for (const [k, v] of Object.entries(add.frictionSignals ?? {}))
370
+ bump(into.frictionSignals, k, v);
371
+ for (const [k, v] of Object.entries(add.correctionSignals ?? {}))
372
+ bump(into.correctionSignals, k, v);
373
+ for (const [k, v] of Object.entries(add.automationSignals ?? {}))
374
+ bump(into.automationSignals, k, v);
301
375
  into.interruptions += add.interruptions;
302
376
  into.responseGaps.push(...add.responseGaps);
303
377
  into.gapsOverCeiling += add.gapsOverCeiling;
@@ -328,3 +402,71 @@ export function topEntries(counts, limit) {
328
402
  .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name))
329
403
  .slice(0, limit);
330
404
  }
405
+ /** Build a stable, evidence-backed action list without exposing transcript text. */
406
+ export function buildInsightActions(sessions) {
407
+ const specs = [
408
+ { source: 'correctionSignals', label: 'continue / keep going', category: 'rule', action: 'Keep working through the delivery chain without waiting for another “continue”.' },
409
+ { source: 'correctionSignals', label: 'approval repeated', category: 'rule', action: 'Treat the original build or ship request as authorization for routine follow-through.' },
410
+ { source: 'correctionSignals', label: 'done end-to-end?', category: 'rule', action: 'Verify the user-visible outcome before declaring the task complete.' },
411
+ { source: 'correctionSignals', label: 'did you merge?', category: 'automation', action: 'Automate PR review, CI watching, and merge-on-green as one durable workflow.' },
412
+ { source: 'correctionSignals', label: "what's next?", category: 'rule', action: 'State and execute the next in-scope step instead of asking the owner to steer implementation.' },
413
+ { source: 'correctionSignals', label: 'check now', category: 'automation', action: 'Add bounded status polling with a terminal success or failure signal.' },
414
+ { source: 'correctionSignals', label: "don't ask / just do it", category: 'rule', action: 'Reserve questions for genuine product or scope choices.' },
415
+ { source: 'correctionSignals', label: 'Ask stall: release / ship / deploy', category: 'skill', action: 'Teach release workflows to carry publish, tag, rollout, and live verification as one chain.' },
416
+ { source: 'correctionSignals', label: "Ask stall: what's next?", category: 'rule', action: 'Remove workflow-stall “what next?” prompts from agent guidance.' },
417
+ { source: 'correctionSignals', label: 'Ask stall: merge / reconcile', category: 'skill', action: 'Encode the safe merge and reconcile path in the git workflow skill.' },
418
+ { source: 'correctionSignals', label: 'Ask stall: direction / approach', category: 'rule', action: 'Let agents choose implementation details after scope is clear.' },
419
+ { source: 'frictionSignals', label: 'blocked guard', category: 'product', action: 'Make guard failures return the safe next command and exact blocked operation.' },
420
+ { source: 'frictionSignals', label: 'CI red loop', category: 'automation', action: 'Deduplicate CI watchers and turn repeated red checks into one stateful wait.' },
421
+ { source: 'frictionSignals', label: 'merge conflict', category: 'skill', action: 'Standardize conflict diagnosis and fix-forward reconciliation.' },
422
+ { source: 'automationSignals', label: 'PR babysitting', category: 'automation', action: 'Bundle PR checks, review collection, comment handling, and merge-on-green.' },
423
+ { source: 'automationSignals', label: 'secrets unlock dance', category: 'product', action: 'Provide one headless secrets-backed command path for repeated credential operations.' },
424
+ { source: 'automationSignals', label: 'git reconcile recipe', category: 'skill', action: 'Promote repeated git reconciliation commands into the canonical workflow.' },
425
+ { source: 'automationSignals', label: 'fleet file transfer', category: 'product', action: 'Add a first-class fleet file transfer command with host/path validation.' },
426
+ { source: 'automationSignals', label: 'release / deploy recipe', category: 'automation', action: 'Turn repeated release shell recipes into a checked-in release script.' },
427
+ ];
428
+ const actions = [];
429
+ for (const spec of specs) {
430
+ let evidenceCount = 0;
431
+ const ids = [];
432
+ for (const session of sessions) {
433
+ const count = session.facets[spec.source]?.[spec.label] ?? 0;
434
+ if (count <= 0)
435
+ continue;
436
+ evidenceCount += count;
437
+ if (ids.length < 3)
438
+ ids.push(session.id.slice(0, 8));
439
+ }
440
+ if (evidenceCount === 0)
441
+ continue;
442
+ actions.push({
443
+ priority: evidenceCount >= 10 ? 'high' : evidenceCount >= 3 ? 'medium' : 'low',
444
+ category: spec.category,
445
+ action: spec.action,
446
+ evidenceCount,
447
+ sampleSessionIds: ids,
448
+ });
449
+ }
450
+ let failedLoopCount = 0;
451
+ const failedLoopIds = [];
452
+ for (const session of sessions) {
453
+ const count = Object.entries(session.facets.frictionSignals ?? {})
454
+ .filter(([label]) => label.startsWith('failed tool loop:'))
455
+ .reduce((sum, [, value]) => sum + value, 0);
456
+ if (count <= 0)
457
+ continue;
458
+ failedLoopCount += count;
459
+ if (failedLoopIds.length < 3)
460
+ failedLoopIds.push(session.id.slice(0, 8));
461
+ }
462
+ if (failedLoopCount > 0) {
463
+ actions.push({
464
+ priority: failedLoopCount >= 10 ? 'high' : failedLoopCount >= 3 ? 'medium' : 'low',
465
+ category: 'automation',
466
+ action: 'Detect repeated failures of the same tool and stop the retry loop with a different recovery path.',
467
+ evidenceCount: failedLoopCount,
468
+ sampleSessionIds: failedLoopIds,
469
+ });
470
+ }
471
+ return actions.sort((a, b) => b.evidenceCount - a.evidenceCount || a.action.localeCompare(b.action));
472
+ }
@@ -81,6 +81,8 @@ export interface GatherRemoteListOptions {
81
81
  * know whether the match is unique or conflicting.
82
82
  */
83
83
  isDefinitive?: (session: SessionMeta, machine: string) => boolean;
84
+ /** Per-peer deadline for slower indexed browse queries. */
85
+ timeoutMs?: number;
84
86
  }
85
87
  export declare function gatherRemoteList(forwardedArgs: string[], hosts?: string[], opts?: GatherRemoteListOptions): Promise<RemoteListResult>;
86
88
  export interface RemoteToolSearchResult {
@@ -197,6 +197,7 @@ export async function gatherRemoteList(forwardedArgs, hosts, opts) {
197
197
  args: forwardedArgs,
198
198
  noFanoutEnv: NO_FANOUT_ENV,
199
199
  hosts,
200
+ timeoutMs: opts?.timeoutMs,
200
201
  earlyExit: opts?.isDefinitive ? { isDefinitive: opts.isDefinitive } : undefined,
201
202
  parse: (stdout, machine) => parseRemoteListPayload(stdout, machine, safeResolver),
202
203
  });
@@ -4,11 +4,11 @@ import type { ActiveSession } from './active.js';
4
4
  * Short on purpose: live status (running/idle/waiting) must not go stale.
5
5
  * The daemon warm tick uses the same cadence (see {@link SESSION_CACHE_WARM_INTERVAL_MS}).
6
6
  */
7
- export declare const DEFAULT_ACTIVE_CACHE_MAX_AGE_MS = 15000;
7
+ export declare const DEFAULT_ACTIVE_CACHE_MAX_AGE_MS: number;
8
8
  /** Daemon warm interval — keep in sync with the setInterval in `lib/daemon.ts`. */
9
- export declare const SESSION_CACHE_WARM_INTERVAL_MS = 15000;
10
- /** Kick off the first warm ~25s after daemon start (staggered off other ticks). */
11
- export declare const SESSION_CACHE_WARM_KICKOFF_MS = 25000;
9
+ export declare const SESSION_CACHE_WARM_INTERVAL_MS: number;
10
+ /** Kick off the first warm 30s after daemon start (staggered off other ticks). */
11
+ export declare const SESSION_CACHE_WARM_KICKOFF_MS = 30000;
12
12
  /** Snapshot scope: this host only, or a fleet-wide merge written by a reader. */
13
13
  export type ActiveCacheScope = 'local' | 'fleet';
14
14
  export interface ActiveSessionsSnapshot {
@@ -34,11 +34,11 @@ const IMMUTABLE_FILE = '.active-session-immutable.json';
34
34
  * Short on purpose: live status (running/idle/waiting) must not go stale.
35
35
  * The daemon warm tick uses the same cadence (see {@link SESSION_CACHE_WARM_INTERVAL_MS}).
36
36
  */
37
- export const DEFAULT_ACTIVE_CACHE_MAX_AGE_MS = 15_000;
37
+ export const DEFAULT_ACTIVE_CACHE_MAX_AGE_MS = 4 * 60_000;
38
38
  /** Daemon warm interval — keep in sync with the setInterval in `lib/daemon.ts`. */
39
- export const SESSION_CACHE_WARM_INTERVAL_MS = 15_000;
40
- /** Kick off the first warm ~25s after daemon start (staggered off other ticks). */
41
- export const SESSION_CACHE_WARM_KICKOFF_MS = 25_000;
39
+ export const SESSION_CACHE_WARM_INTERVAL_MS = 3 * 60_000;
40
+ /** Kick off the first warm 30s after daemon start (staggered off other ticks). */
41
+ export const SESSION_CACHE_WARM_KICKOFF_MS = 30_000;
42
42
  /** Keys stored in the immutable memo (transcript-stable). */
43
43
  export const IMMUTABLE_FIELD_KEYS = [
44
44
  'topic',
@@ -1,9 +1,9 @@
1
1
  import * as fs from 'fs';
2
2
  import { StringDecoder } from 'string_decoder';
3
- import { getDB } from './db.js';
3
+ import { getDB, maintainSessionSearchIndex } from './db.js';
4
4
  import { parseSession } from './parse.js';
5
5
  import { TOOL_INDEX_VERSION, TOOL_INDEX_LIMIT_ORDINAL, ToolCallCollector, collectClaudeToolCalls, collectCodexToolCalls, toolCallEvidenceBytes, toolCallsFromEvents, } from './tool-calls.js';
6
- import { persistToolCalls, purgeToolCalls, toolEvidenceSourcePath, } from './tool-store.js';
6
+ import { canonicalToolLedgerPath, persistToolCalls, purgeToolCalls, toolEvidenceSourcePath, } from './tool-store.js';
7
7
  const BACKFILL_MAX_FILES = 25;
8
8
  const BACKFILL_MAX_BYTES = 16 * 1024 * 1024;
9
9
  const BACKFILL_MAX_IN_MEMORY_SOURCE_BYTES = 16 * 1024 * 1024;
@@ -39,16 +39,71 @@ export function toolSearchRemoteReceiveBudget(envelope) {
39
39
  const localBytes = serializedToolSearchEnvelopeBytes(envelope);
40
40
  return Math.max(0, TOOL_QUERY_MAX_SERIALIZED_BYTES - TOOL_QUERY_MERGE_OVERHEAD_BYTES - localBytes);
41
41
  }
42
- function needsIndex(db, sessionId, stamp) {
43
- const row = db.prepare(`
44
- SELECT file_mtime_ms, file_size, extractor_version
42
+ /**
43
+ * The ledger columns every candidate session is judged on. Deliberately NOT
44
+ * `parser_state`: this runs once per session in the scan's warm path, and that
45
+ * column holds a serialized collector snapshot that can reach a megabyte. It is
46
+ * read separately, only for the sessions that turn out to need indexing.
47
+ */
48
+ function readToolLedger(db, sessionId) {
49
+ return db.prepare(`
50
+ SELECT file_path, file_mtime_ms, file_size, extractor_version, parsed_offset
45
51
  FROM tool_scan_ledger WHERE session_id = ?
46
52
  `).get(sessionId);
53
+ }
54
+ function readToolParserState(db, sessionId) {
55
+ const row = db.prepare(`SELECT parser_state FROM tool_scan_ledger WHERE session_id = ?`)
56
+ .get(sessionId);
57
+ return row?.parser_state ?? null;
58
+ }
59
+ function needsIndex(row, stamp) {
47
60
  return !row
48
61
  || row.file_mtime_ms !== stamp.fileMtimeMs
49
62
  || row.file_size !== stamp.fileSize
50
63
  || row.extractor_version !== TOOL_INDEX_VERSION;
51
64
  }
65
+ /**
66
+ * Where to start reading a session whose transcript changed.
67
+ *
68
+ * A live session's transcript is append-only, so re-reading it from byte 0 on
69
+ * every scan re-parses the entire history to discover the handful of records
70
+ * that are new — the cost that makes a large session's tool index quadratic in
71
+ * the number of scans. When the ledger carries a resume point that the current
72
+ * file still agrees with, the scan reads only the appended bytes and merges the
73
+ * result (`append`); anything else re-reads the whole file (`replace`).
74
+ *
75
+ * Each check below rejects a case where the stored prefix may no longer describe
76
+ * the file: a harness the streaming parser cannot resume, a different extractor,
77
+ * no recorded resume point, a source path the ledger row does not describe, a
78
+ * file that shrank below what was already parsed (a rewrite or truncation, not
79
+ * an append), or a snapshot that does not read back.
80
+ */
81
+ function planToolScan(db, sessionId, row, sourcePath, stamp, resumable) {
82
+ const full = { mode: 'replace', startOffset: 0 };
83
+ if (!resumable || !row)
84
+ return full;
85
+ if (row.extractor_version !== TOOL_INDEX_VERSION)
86
+ return full;
87
+ if (row.parsed_offset === null)
88
+ return full;
89
+ if (row.file_path !== canonicalToolLedgerPath(sourcePath))
90
+ return full;
91
+ if (stamp.fileSize < row.file_size || stamp.fileSize < row.parsed_offset)
92
+ return full;
93
+ const parserState = readToolParserState(db, sessionId);
94
+ if (parserState === null)
95
+ return full;
96
+ let snapshot;
97
+ try {
98
+ snapshot = JSON.parse(parserState);
99
+ }
100
+ catch {
101
+ return full;
102
+ }
103
+ if (snapshot?.v !== 1 || !Number.isSafeInteger(snapshot.nextOrdinal))
104
+ return full;
105
+ return { mode: 'append', startOffset: row.parsed_offset, snapshot };
106
+ }
52
107
  /** Read index completeness from SQLite only; never stat or parse transcripts. */
53
108
  export function readToolIndexCoverage(sessions) {
54
109
  const db = getDB();
@@ -90,14 +145,23 @@ function backfillLimitCall(session, reason) {
90
145
  };
91
146
  }
92
147
  /** Stream Claude/Codex JSONL without ever retaining an oversized record. */
93
- async function streamJsonlToolCalls(session) {
94
- const collector = new ToolCallCollector();
95
- const stream = fs.createReadStream(session.filePath, { highWaterMark: 64 * 1024 });
148
+ async function streamJsonlToolCalls(session, from = { startOffset: 0 }) {
149
+ const collector = new ToolCallCollector(from.snapshot);
150
+ const stream = fs.createReadStream(session.filePath, {
151
+ highWaterMark: 64 * 1024,
152
+ start: from.startOffset,
153
+ });
96
154
  const decoder = new StringDecoder('utf8');
97
155
  let pending = '';
98
156
  let pendingBytes = 0;
99
157
  let droppingOversizedLine = false;
100
158
  let skippedOversizedLine = false;
159
+ // Byte offset just past the last complete record applied. Only a complete,
160
+ // newline-terminated record advances it, so resuming here can never re-apply a
161
+ // record (which would mint a second ordinal for it) nor skip a partial tail.
162
+ let parsedOffset = from.startOffset;
163
+ /** Bytes of the record currently being assembled, across chunk boundaries. */
164
+ let lineBytes = 0;
101
165
  const applyLine = (line) => {
102
166
  if (!line.trim())
103
167
  return;
@@ -119,8 +183,14 @@ async function streamJsonlToolCalls(session) {
119
183
  const newline = text.indexOf('\n', start);
120
184
  const end = newline >= 0 ? newline : text.length;
121
185
  const segment = text.slice(start, end);
186
+ const segmentBytes = Buffer.byteLength(segment);
187
+ // Counted outside the drop guard and across chunk boundaries: this is the
188
+ // record's true size on disk, which is what the resume offset is measured
189
+ // in. `pendingBytes` cannot stand in for it — that one resets when an
190
+ // oversized record is dropped, and a record split over two 64 KiB reads
191
+ // would lose the part carried in from the previous chunk.
192
+ lineBytes += segmentBytes;
122
193
  if (!droppingOversizedLine) {
123
- const segmentBytes = Buffer.byteLength(segment);
124
194
  if (pendingBytes + segmentBytes <= BACKFILL_MAX_JSONL_RECORD_BYTES) {
125
195
  pending += segment;
126
196
  pendingBytes += segmentBytes;
@@ -136,6 +206,8 @@ async function streamJsonlToolCalls(session) {
136
206
  break;
137
207
  if (!droppingOversizedLine)
138
208
  applyLine(pending);
209
+ parsedOffset += lineBytes + 1; // + the newline itself
210
+ lineBytes = 0;
139
211
  pending = '';
140
212
  pendingBytes = 0;
141
213
  droppingOversizedLine = false;
@@ -145,25 +217,48 @@ async function streamJsonlToolCalls(session) {
145
217
  for await (const chunk of stream)
146
218
  consume(decoder.write(chunk));
147
219
  consume(decoder.end());
220
+ // Snapshot BEFORE the unterminated trailing record, and pair it with an offset
221
+ // that stops short of that record. The writer may be mid-append, so the record
222
+ // is indexed now (its evidence is real) but is re-read by the next scan — which
223
+ // resumes with the same next-ordinal and so re-derives the same ordinals,
224
+ // making the re-read an idempotent upsert rather than a duplicate.
225
+ const resume = skippedOversizedLine
226
+ // A dropped oversized record left the ordinals and the pending map out of
227
+ // step with the file; nothing here can be resumed from.
228
+ ? null
229
+ : { parserState: JSON.stringify(collector.snapshot()), parsedOffset };
148
230
  if (!droppingOversizedLine && pending.length > 0)
149
231
  applyLine(pending);
150
232
  const calls = collector.drainChanged();
151
233
  if (skippedOversizedLine && !calls.some((call) => call.ordinal === TOOL_INDEX_LIMIT_ORDINAL)) {
152
234
  calls.push(backfillLimitCall(session, 'At least one JSONL record exceeded the 1 MiB tool-backfill parser limit.'));
153
235
  }
154
- return calls;
236
+ return { calls, resume };
237
+ }
238
+ /** True for the harnesses whose transcript the streaming parser can resume. */
239
+ function isResumableToolSource(agent) {
240
+ return agent === 'claude' || agent === 'codex';
155
241
  }
156
- async function toolCallsForBackfill(session, sourceBytes) {
157
- if (session.agent === 'claude' || session.agent === 'codex') {
242
+ async function toolCallsForBackfill(session, sourceBytes, from = { startOffset: 0 }) {
243
+ if (isResumableToolSource(session.agent)) {
158
244
  if (sourceBytes > BACKFILL_MAX_STREAM_SOURCE_BYTES) {
159
- return [backfillLimitCall(session, 'Transcript exceeds the 64 MiB safe streaming tool-backfill limit.')];
245
+ return {
246
+ calls: [backfillLimitCall(session, 'Transcript exceeds the 64 MiB safe streaming tool-backfill limit.')],
247
+ resume: null,
248
+ };
160
249
  }
161
- return streamJsonlToolCalls(session);
250
+ return streamJsonlToolCalls(session, from);
162
251
  }
163
252
  if (sourceBytes > BACKFILL_MAX_IN_MEMORY_SOURCE_BYTES) {
164
- return [backfillLimitCall(session, 'Transcript exceeds the 16 MiB safe in-memory tool-backfill parser limit.')];
253
+ return {
254
+ calls: [backfillLimitCall(session, 'Transcript exceeds the 16 MiB safe in-memory tool-backfill parser limit.')],
255
+ resume: null,
256
+ };
165
257
  }
166
- return toolCallsFromEvents(parseSession(session.filePath, session.agent));
258
+ // Every other harness is parsed whole into memory by parseSession, which
259
+ // exposes no byte offset to resume from — so these stay full replaces and
260
+ // record no resume point, rather than storing one this path cannot honour.
261
+ return { calls: toolCallsFromEvents(parseSession(session.filePath, session.agent)), resume: null };
167
262
  }
168
263
  /**
169
264
  * Fill one bounded chunk of the independent tool index. A warm call performs
@@ -179,6 +274,7 @@ export async function ensureToolIndex(sessions, limits = {}) {
179
274
  if (!session.filePath)
180
275
  continue;
181
276
  const sourcePath = toolEvidenceSourcePath(session.filePath, session.agent);
277
+ const ledger = readToolLedger(db, session.id);
182
278
  const mustStatSource = limits.verifySourceStamps || sourcePath !== session.filePath;
183
279
  const indexed = !mustStatSource
184
280
  ? db.prepare(`
@@ -199,8 +295,15 @@ export async function ensureToolIndex(sessions, limits = {}) {
199
295
  continue;
200
296
  }
201
297
  }
202
- if (needsIndex(db, session.id, stamp))
203
- pending.push({ session, stamp });
298
+ if (!needsIndex(ledger, stamp))
299
+ continue;
300
+ const plan = planToolScan(db, session.id, ledger, sourcePath, stamp, isResumableToolSource(session.agent));
301
+ pending.push({
302
+ session,
303
+ stamp,
304
+ plan,
305
+ readBytes: Math.max(0, stamp.fileSize - plan.startOffset),
306
+ });
204
307
  }
205
308
  let indexedFiles = 0;
206
309
  let indexedCalls = 0;
@@ -212,13 +315,16 @@ export async function ensureToolIndex(sessions, limits = {}) {
212
315
  // The byte budget is a batch boundary, not a correctness boundary. Admit
213
316
  // one oversized transcript by itself so it can never wedge the ledger or
214
317
  // silently disappear from results; the next invocation resumes afterward.
215
- if (attemptedFiles > 0 && consumedBytes + item.stamp.fileSize > maxBytes)
318
+ // Budgeted on the bytes this scan reads, not the file's size: a resumed
319
+ // session costs only its appended tail, so a batch can cover far more
320
+ // growing sessions than it could when every one was re-read whole.
321
+ if (attemptedFiles > 0 && consumedBytes + item.readBytes > maxBytes)
216
322
  break;
217
323
  attemptedFiles++;
218
- consumedBytes += item.stamp.fileSize;
324
+ consumedBytes += item.readBytes;
219
325
  try {
220
- const calls = await toolCallsForBackfill(item.session, item.stamp.fileSize);
221
- persistToolCalls(db, item.session, calls, item.stamp);
326
+ const { calls, resume } = await toolCallsForBackfill(item.session, item.stamp.fileSize, item.plan);
327
+ persistToolCalls(db, item.session, calls, item.stamp, { mode: item.plan.mode, resume });
222
328
  indexedFiles++;
223
329
  indexedCalls += calls.length;
224
330
  }
@@ -226,6 +332,11 @@ export async function ensureToolIndex(sessions, limits = {}) {
226
332
  skippedFiles++;
227
333
  }
228
334
  }
335
+ // The scan just wrote a batch of FTS segments; pay a bounded slice of the
336
+ // merge they need so the index converges here instead of degrading until
337
+ // someone runs `agents sessions optimize` by hand (RUSH-2208).
338
+ if (indexedFiles > 0)
339
+ maintainSessionSearchIndex(db);
229
340
  const remainingFiles = Math.max(0, pending.length - attemptedFiles);
230
341
  const limitedSessionIds = new Set();
231
342
  const sessionIds = sessions.map((session) => session.id);
@@ -8,8 +8,32 @@ export declare function toolEvidenceSourcePath(filePath: string, agent: string):
8
8
  export declare function purgeToolCalls(db: Database.Database, sessionId: string): void;
9
9
  /** Purge deleted direct children when a transcript directory's stamp changes. */
10
10
  export declare function purgeMissingToolCallsInDirectory(db: Database.Database, dirPath: string, currentFilePaths: string[]): number;
11
- /** Persist one parser batch and its file stamp atomically. */
11
+ /** The resume point a later incremental scan starts from. */
12
+ export interface ToolScanResumePoint {
13
+ /** Serialized ToolCallCollector snapshot at `parsedOffset`. */
14
+ parserState: string;
15
+ /** Byte offset just past the last complete record consumed. */
16
+ parsedOffset: number;
17
+ }
18
+ export interface PersistToolCallsOptions {
19
+ /**
20
+ * `replace` drops the session's stored evidence first — correct for a parse
21
+ * that started at byte 0. `append` merges the batch into what is already
22
+ * stored and requires an existing ledger row; use it only for a parse that
23
+ * resumed from that row's `parsedOffset`.
24
+ */
25
+ mode?: 'replace' | 'append';
26
+ /**
27
+ * Where a later scan may resume. Omitted (or null) clears any stored resume
28
+ * point, which forces the next scan of this session to re-read from byte 0 —
29
+ * the correct outcome whenever the parse could not cover the whole prefix
30
+ * (an oversized record, a size-capped transcript, a non-streaming harness).
31
+ */
32
+ resume?: ToolScanResumePoint | null;
33
+ maxSessionBytes?: number;
34
+ }
35
+ /** Persist one parser batch, its file stamp, and its resume point atomically. */
12
36
  export declare function persistToolCalls(db: Database.Database, session: SessionMeta, calls: IndexedToolCall[], sourceStamp: {
13
37
  fileMtimeMs: number;
14
38
  fileSize: number;
15
- }, mode?: 'replace' | 'append', maxSessionBytes?: number): void;
39
+ }, options?: PersistToolCallsOptions): void;