@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
@@ -1,23 +1,26 @@
1
1
  /**
2
- * Insights command — how you actually work, split by the account that did the work.
2
+ * Insights command — one observe verb for "how work looks".
3
3
  *
4
- * The behavioural sibling of the existing rollups, and deliberately not a duplicate of
5
- * any of them:
4
+ * Two data paths under one name (do not re-split into peer top-level commands):
6
5
  *
7
- * agents cost what you spent ($ and duration)
8
- * agents output what shipped (burn vs PRs and commits)
9
- * agents usage live quota headroom (rate-limit windows, right now)
10
- * agents trends aggregate distributions (harness mix, tools-per-session, token ratios)
11
- * agents sessions browse individual work (search, resume, render)
12
- * agents insights HOW you work (tools, friction, rhythm, per account)
6
+ * agents insights HOW you work (transcript content: tools, friction,
7
+ * rhythm, edits) split by Claude account by default
8
+ * agents insights mix COUNTERS (sessions index + usage.db recipes:
9
+ * harness/model mix, token ratios, secrets, browser)
10
+ * agents insights <recipe> One baked mix recipe (harness-mix, tools-per-session, )
11
+ * agents insights query Raw usage.db rows
13
12
  *
14
- * The closest neighbour is `agents trends`, and the boundary is the data path: trends
15
- * reads counters — `tool_scan_ledger` call counts and the analytics warehouse — to
16
- * produce distributions ("how many tool calls per session, by harness"). This reads
17
- * transcript CONTENT through `parseSession` to produce behaviour ("which tools, which
18
- * languages, where it went wrong, when you were working"), and splits all of it by
19
- * account, a dimension trends does not have. They overlap in spirit on tool and model
20
- * mix; they do not read the same store or answer the same question.
13
+ * Sibling observe verbs (stay separate different questions):
14
+ *
15
+ * agents cost what you spent ($ and duration)
16
+ * agents output what shipped (burn vs PRs and commits)
17
+ * agents usage live quota headroom
18
+ * agents perf latency (hooks, CLI commands, agent.run) not popularity
19
+ * agents sessions stats which skills/slash-commands were explicitly invoked
20
+ *
21
+ * Why mix lives here (not a second top-level `trends`): two abstract "analytics"
22
+ * nouns taught agents and humans to guess. One verb, two engines — cheap SQL mix
23
+ * vs transcript facets. Latency stays on `perf` so it is never confused with mix.
21
24
  *
22
25
  * Modelled on Claude Code's `/insights`, with the difference that motivated it: that
23
26
  * command reads one account's directory, while `balanced` rotation sprays sessions
@@ -27,6 +30,8 @@
27
30
  * The deterministic report makes zero network calls. `--narrative` is opt-in and adds
28
31
  * the coaching prose by piping the AGGREGATE (never raw transcripts) through a headless
29
32
  * `claude -p`.
33
+ *
34
+ * `agents trends` is a thin deprecated alias of the mix tree only (see commands/trends.ts).
30
35
  */
31
36
  import * as fs from 'fs';
32
37
  import chalk from 'chalk';
@@ -37,11 +42,25 @@ import { setHelpSections } from '../lib/help.js';
37
42
  import { discoverSessions, parseTimeFilter } from '../lib/session/discover.js';
38
43
  import { querySessions, readSessionInsights, writeSessionInsights, clearSessionInsights, } from '../lib/session/db.js';
39
44
  import { parseSession } from '../lib/session/parse.js';
40
- import { computeInsightFacets, mergeFacets, newFacetAccumulator, detectOverlap, percentile, bucketGaps, topEntries, } from '../lib/session/insights.js';
45
+ import { computeInsightFacets, mergeFacets, newFacetAccumulator, detectOverlap, percentile, bucketGaps, topEntries, buildInsightActions, } from '../lib/session/insights.js';
41
46
  import { formatUsd } from '../lib/pricing/index.js';
42
47
  import { formatDuration } from '../lib/session/render.js';
43
48
  import { terminalWidth, truncateToWidth, stringWidth, padToWidth } from '../lib/session/width.js';
49
+ import { registerMixCommands } from '../lib/analytics/mix-commands.js';
44
50
  const execFileAsync = promisify(execFile);
51
+ function collectAgent(value, previous) {
52
+ return [...previous, value];
53
+ }
54
+ function agentsFromArgv(argv) {
55
+ const values = [];
56
+ for (let i = 0; i < argv.length; i++) {
57
+ if (argv[i] === '--agent' && argv[i + 1])
58
+ values.push(argv[++i]);
59
+ else if (argv[i].startsWith('--agent='))
60
+ values.push(argv[i].slice('--agent='.length));
61
+ }
62
+ return values;
63
+ }
45
64
  function resolveGroup(by) {
46
65
  if (by === undefined)
47
66
  return 'account';
@@ -187,7 +206,7 @@ function renderHours(hours, out) {
187
206
  out.push(` ${chalk.cyan(spark)}`);
188
207
  out.push(` ${chalk.gray('0h'.padEnd(6))}${chalk.gray('6h'.padEnd(6))}${chalk.gray('12h'.padEnd(6))}${chalk.gray('18h'.padEnd(5))}${chalk.gray('23h')}`);
189
208
  }
190
- function renderReport(groups, dim, meta) {
209
+ function renderReport(groups, dim, meta, actions, harnesses) {
191
210
  const out = [];
192
211
  const scope = meta.since ? `last ${meta.since}` : 'all time';
193
212
  out.push(chalk.bold('Insights') + chalk.gray(` ${scope} · ${meta.analyzed} of ${meta.scanned} sessions`));
@@ -198,16 +217,27 @@ function renderReport(groups, dim, meta) {
198
217
  return;
199
218
  }
200
219
  // Per-group table — the headline, and the thing no sibling command produces.
220
+ // Includes silent-stall counts so harness/account laziness is visible without --json.
201
221
  out.push('');
202
222
  out.push(chalk.bold(`By ${dim}`));
203
- const labelW = Math.min(Math.max(...groups.map((g) => stringWidth(g.label)), 5), Math.max(20, terminalWidth() - 46));
223
+ const labelW = Math.min(Math.max(...groups.map((g) => stringWidth(g.label)), 5), Math.max(16, terminalWidth() - 58));
204
224
  const sessW = Math.max(...groups.map((g) => String(g.sessions).length), 3);
225
+ const stallOf = (g) => Object.entries(g.facets.frictionSignals)
226
+ .filter(([k]) => k.startsWith('silent stall:'))
227
+ .reduce((n, [, c]) => n + c, 0);
228
+ const resumeOf = (g) => g.facets.correctionSignals['resume after silent stall'] ?? 0;
229
+ const stallW = Math.max(...groups.map((g) => String(stallOf(g)).length), 5);
230
+ out.push(chalk.gray(` ${padToWidth('', labelW)} ${''.padStart(sessW)} ` +
231
+ `${''.padStart(9)} ${''.padStart(8)} ${'stalls'.padStart(stallW)} resume`));
205
232
  for (const g of groups) {
206
233
  const cost = g.costUsd > 0 ? formatUsd(g.costUsd) : '—';
207
234
  const dur = g.durationMs > 0 ? formatDuration(g.durationMs) : '—';
235
+ const stalls = stallOf(g);
236
+ const resumes = resumeOf(g);
208
237
  out.push(` ${padToWidth(truncateToWidth(g.label, labelW), labelW)} ` +
209
238
  `${chalk.gray(String(g.sessions).padStart(sessW))} ${chalk.gray('sess')} ` +
210
- `${chalk.green(padToWidth(cost, 9))} ${chalk.gray(dur)}`);
239
+ `${chalk.green(padToWidth(cost, 9))} ${chalk.gray(padToWidth(dur, 8))} ` +
240
+ `${chalk.cyan(String(stalls).padStart(stallW))} ${chalk.cyan(String(resumes))}`);
211
241
  }
212
242
  // Everything below is the whole scope folded together; per-group detail is in --json.
213
243
  const all = newFacetAccumulator();
@@ -216,22 +246,54 @@ function renderReport(groups, dim, meta) {
216
246
  renderCounts('Top tools', topEntries(all.toolCounts, 8), out);
217
247
  renderCounts('Languages', topEntries(all.languages, 6), out);
218
248
  renderCounts('Models', topEntries(all.models, 6), out);
249
+ renderCounts('Silent stalls by model', topEntries(all.silentStallsByModel ?? {}, 8), out);
219
250
  // Friction — the section that earns the command.
220
251
  const gaps = all.responseGaps;
252
+ const silentStalls = Object.entries(all.frictionSignals)
253
+ .filter(([k]) => k.startsWith('silent stall:'))
254
+ .reduce((n, [, c]) => n + c, 0);
255
+ const resumeNudges = all.correctionSignals['resume after silent stall'] ?? 0;
221
256
  out.push('');
222
257
  out.push(chalk.bold('Friction'));
223
258
  out.push(` ${padToWidth('interruptions', 18)} ${chalk.cyan(String(all.interruptions))}` +
224
259
  chalk.gray(' turns you cut short'));
225
260
  out.push(` ${padToWidth('tool errors', 18)} ${chalk.cyan(String(all.errorCount))}`);
226
261
  if (gaps.length > 0) {
227
- out.push(` ${padToWidth('your reply time', 18)} ` +
228
- chalk.cyan(`p50 ${Math.round(percentile(gaps, 50))}s`) + chalk.gray(` · p90 ${Math.round(percentile(gaps, 90))}s`));
262
+ // Same timestamps as silent stalls; this line is the distribution. Silent
263
+ // stalls (below) are the agent-attributed long gaps after the model stopped.
264
+ out.push(` ${padToWidth('gap until next msg', 18)} ` +
265
+ chalk.cyan(`p50 ${Math.round(percentile(gaps, 50))}s`) + chalk.gray(` · p90 ${Math.round(percentile(gaps, 90))}s`) +
266
+ chalk.gray(' after assistant last spoke'));
267
+ }
268
+ if (silentStalls > 0) {
269
+ out.push(` ${padToWidth('silent stalls', 18)} ${chalk.cyan(String(silentStalls))}` +
270
+ chalk.gray(' agent idle ≥5m until you resumed (also in By ' + dim + ' table)'));
271
+ }
272
+ if (resumeNudges > 0) {
273
+ out.push(` ${padToWidth('resume nudges', 18)} ${chalk.cyan(String(resumeNudges))}` +
274
+ chalk.gray(' "continue"/"keep going" after a silent stall'));
229
275
  }
230
276
  const errs = topEntries(all.errorCategories, 6);
231
277
  if (errs.length > 0) {
232
278
  for (const e of errs)
233
279
  out.push(` ${chalk.gray('·')} ${padToWidth(e.name, 16)} ${chalk.gray(String(e.count))}`);
234
280
  }
281
+ renderCounts('Friction / thrash', topEntries(all.frictionSignals, 10), out);
282
+ renderCounts('Dissatisfaction / corrections', topEntries(all.correctionSignals, 10), out);
283
+ renderCounts('Automatable repeats', topEntries(all.automationSignals, 10), out);
284
+ renderCounts('Harness split', harnesses, out);
285
+ out.push('');
286
+ out.push(chalk.bold('Actions'));
287
+ if (actions.length === 0) {
288
+ out.push(chalk.gray(' No repeated action pattern met the evidence threshold in this window.'));
289
+ }
290
+ else {
291
+ out.push(chalk.gray(' pri category evidence sample sessions action'));
292
+ for (const action of actions.slice(0, 12)) {
293
+ out.push(` ${padToWidth(action.priority, 7)} ${padToWidth(action.category, 11)} ` +
294
+ `${String(action.evidenceCount).padStart(8)} ${padToWidth(action.sampleSessionIds.join(', '), 25)} ${action.action}`);
295
+ }
296
+ }
235
297
  // Output
236
298
  out.push('');
237
299
  out.push(chalk.bold('What you changed'));
@@ -286,7 +348,7 @@ function renderReport(groups, dim, meta) {
286
348
  out.push(chalk.yellow(` ${meta.unreadable} transcripts could not be read; their behaviour is missing from these totals.`));
287
349
  }
288
350
  if (all.gapsOverCeiling > 0) {
289
- out.push(chalk.gray(` ${all.gapsOverCeiling} reply gaps over an hour excluded from the percentiles.`));
351
+ out.push(chalk.gray(` ${all.gapsOverCeiling} gaps over an hour excluded from p50/p90 (still counted as silent stall: 1h+ when the assistant last spoke).`));
290
352
  }
291
353
  out.push('');
292
354
  out.push(chalk.gray(' `agents insights --by project` to see it per repo'));
@@ -306,6 +368,13 @@ async function renderNarrative(payload) {
306
368
  '2. What is costing you — split into the assistant\'s fault vs your own workflow.',
307
369
  '3. Quick wins — concrete, tied to a number in the data.',
308
370
  '4. Worth trying — one more ambitious workflow change.',
371
+ '',
372
+ 'Silent stalls (required): if frictionSignals contain "silent stall: …" or',
373
+ 'correctionSignals contain "resume after silent stall" / "continue / keep going",',
374
+ 'call that out explicitly in section 2 or 3 with the counts. Those mean the model',
375
+ 'stopped mid-session and sat idle until the human pinged it (timestamps: last',
376
+ 'assistant event → next user message ≥ 5 minutes). Do not reframe them as the',
377
+ 'user being slow unless the data only shows short reply gaps.',
309
378
  'Be specific and cite the numbers. No preamble, no flattery, no bullet padding.',
310
379
  '',
311
380
  JSON.stringify(payload),
@@ -351,11 +420,11 @@ async function insightsAction(options) {
351
420
  if (options.refresh)
352
421
  clearSessionInsights();
353
422
  const filter = { sinceMs };
354
- if (options.agent)
355
- filter.agent = options.agent;
356
423
  const scanned = querySessions(filter);
357
424
  const wanted = options.account?.toLowerCase();
358
425
  const inScope = scanned.filter((m) => {
426
+ if (options.agent?.length && !options.agent.includes(m.agent))
427
+ return false;
359
428
  if (!wanted)
360
429
  return true;
361
430
  return [m.accountKey, m.account, m.accountOrg]
@@ -381,6 +450,15 @@ async function insightsAction(options) {
381
450
  });
382
451
  const overlap = detectOverlap(spans);
383
452
  const groups = buildGroups(substantive, facetsById, dim);
453
+ const evidence = substantive.flatMap((m) => {
454
+ const facets = facetsById.get(m.id);
455
+ return facets ? [{ id: m.id, facets }] : [];
456
+ });
457
+ const actions = buildInsightActions(evidence);
458
+ const harnesses = topEntries(substantive.reduce((counts, row) => {
459
+ counts[row.agent] = (counts[row.agent] ?? 0) + 1;
460
+ return counts;
461
+ }, {}), 20);
384
462
  if (options.json) {
385
463
  const payload = {
386
464
  generatedAt: new Date().toISOString(),
@@ -392,6 +470,8 @@ async function insightsAction(options) {
392
470
  minMessages,
393
471
  by: dim,
394
472
  overlap,
473
+ actions,
474
+ harnesses,
395
475
  groups: groups.map((g) => ({
396
476
  key: g.key,
397
477
  label: g.label,
@@ -420,7 +500,7 @@ async function insightsAction(options) {
420
500
  unreadable,
421
501
  minMessages,
422
502
  overlap,
423
- });
503
+ }, actions, harnesses);
424
504
  if (options.narrative) {
425
505
  await renderNarrative(groups.map((g) => ({
426
506
  account: g.label, sessions: g.sessions, costUsd: g.costUsd,
@@ -434,49 +514,77 @@ async function insightsAction(options) {
434
514
  })));
435
515
  }
436
516
  }
437
- export function registerInsightsCommand(program) {
438
- const cmd = addHostOption(program.command('insights'))
439
- .description('How you work — tools, friction, and rhythm, split by the account that did the work')
517
+ function configureInsightsCommand(cmd) {
518
+ addHostOption(cmd)
519
+ .description('How work looks behavioural report (default) or counter mix (`mix`, recipes)')
440
520
  .option('--json', 'Output the full report as JSON')
441
521
  .option('--since <time>', 'Window: 7d, 4w, 3mo, an ISO date, or "all" (default 30d)')
442
522
  .option('--all', 'Every session ever indexed. Alias for --since all')
443
523
  .option('--by <dimension>', 'Group by: account (default), agent, project, or day')
444
524
  .option('--account <match>', 'Only sessions whose account key, email, or org contains this')
445
- .option('--agent <id>', 'Only one harness (claude, codex, droid, )')
525
+ .option('--agent <id>', 'Only these harnesses; repeat for more than one', collectAgent, [])
446
526
  .option('--min-messages <n>', 'Skip sessions under this many messages, both roles counted (default 2)')
447
527
  .option('--refresh', 'Discard cached facets and re-read every transcript')
448
528
  .option('--narrative', 'Add a written read on the numbers via a headless `claude -p`')
449
529
  .action(async (options) => {
450
- await insightsAction(options);
530
+ const inherited = cmd.parent?.name() === 'sessions'
531
+ ? cmd.parent.opts()
532
+ : {};
533
+ const inheritedAgent = typeof inherited.agent === 'string' ? [inherited.agent] : [];
534
+ const rawAgents = agentsFromArgv(process.argv.slice(2));
535
+ await insightsAction({
536
+ ...inherited,
537
+ ...options,
538
+ agent: rawAgents.length > 0 ? rawAgents : [...inheritedAgent, ...(options.agent ?? [])],
539
+ json: options.json ?? inherited.json,
540
+ since: options.since ?? inherited.since,
541
+ });
451
542
  });
543
+ // Cheap counter recipes (former top-level `agents trends`) — same parent, no peer verb.
544
+ registerMixCommands(cmd);
452
545
  setHelpSections(cmd, {
453
546
  examples: `
454
- # Last 30 days, split by Claude account — the default
547
+ # Behavioural report — last 30 days, split by Claude account (default)
455
548
  agents insights
456
549
 
457
550
  # Which repo is eating the time
458
551
  agents insights --by project --since 90d
459
552
 
553
+ # Counter mix board (harness/model/token/secrets recipes) — former agents trends
554
+ agents insights mix
555
+ agents insights mix --days 30
556
+ agents insights harness-mix --json
557
+ agents insights query --kind secret --days 7
558
+
460
559
  # One account only, all of its history
461
560
  agents insights --account "Turing Labs" --all
462
561
 
463
562
  # Machine-readable, for a dashboard or a slash command
464
- agents insights --json
563
+ agents sessions insights --agent claude --agent codex --json
465
564
 
466
565
  # Add a written read on what to change
467
566
  agents insights --narrative
468
567
  `,
469
568
  notes: `
470
- Answers "how do you work". For "what did it cost" use \`agents cost\`, for "what
471
- shipped" use \`agents output\`, for live quota use \`agents usage\`.
569
+ Two paths under one verb:
570
+ bare \`agents insights\` — transcript behaviour (tools, friction, rhythm, by account)
571
+ \`agents insights mix\` — cheap counters from sessions.db + usage.db
572
+ Latency is \`agents perf\` (not mix). Quota is \`agents usage\`. Skill/slash popularity
573
+ is \`agents sessions stats\`. \`agents trends\` is a deprecated alias of the mix tree.
472
574
 
473
- The first run parses every in-scope transcript and caches the result; later runs
575
+ The behavioural report parses in-scope transcripts once and caches facets; later runs
474
576
  re-read only files that changed. \`--refresh\` forces a full re-read.
475
577
 
476
- Account attribution is Claude-only today. Sessions from other harnesses group
477
- under \`unattributed:<agent>\`.
578
+ \`agents insights\` is the top-level alias of \`agents sessions insights\`.
579
+ Repeat \`--agent\` to compare several harnesses in one report.
478
580
 
479
581
  Everything except \`--narrative\` is local and makes no network calls.
480
582
  `,
481
583
  });
482
584
  }
585
+ export function registerInsightsCommand(program) {
586
+ configureInsightsCommand(program.command('insights'));
587
+ }
588
+ export function registerSessionsInsightsCommand(sessions) {
589
+ configureInsightsCommand(sessions.command('insights'));
590
+ }
@@ -12,6 +12,7 @@ import { terminalWidth, truncateToWidth, padToWidth } from '../lib/session/width
12
12
  import { collectGitOutput } from '../lib/output/git-output.js';
13
13
  import { loadDevices, isControlDevice } from '../lib/devices/registry.js';
14
14
  import { machineId } from '../lib/session/sync/config.js';
15
+ import { stripClixml } from '../lib/hosts/remote-cmd.js';
15
16
  const execFileAsync = promisify(execFile);
16
17
  export function registerOutputCommand(program) {
17
18
  addHostOption(program.command('output'))
@@ -24,16 +25,21 @@ export function registerOutputCommand(program) {
24
25
  .option('--login <login...>', 'Count PRs for these GitHub logins (default: current gh user)')
25
26
  .option('--no-prs', 'Skip the GitHub PR lookup (commits only)')
26
27
  .option('--all-hosts', 'Aggregate across every online device (ag devices) over SSH')
28
+ .option('--pricing <scenario>', 'Cost scenario: actual (default, cache-discounted) or no-cache (cache read/write billed at the full input rate)')
27
29
  .addHelpText('after', `
28
30
  Examples:
29
31
  agents output Last 7 days: burn, output tokens, PRs, commits, ratios
30
32
  agents output --since 24h Last 24 hours
31
33
  agents output --since 1mo Last month (units: 1h 24h 7d 4w 1mo 1y, or ISO date)
34
+ agents output --pricing no-cache Model the burn as if prompt caching were off
32
35
  agents output --all-hosts Fleet-wide, folding in every online machine
33
36
  agents output --by day --json Machine-readable daily burn/output rollup
34
37
 
35
38
  Burn (cost) is computed offline from a versioned per-model price table (${PRICING_VERSION}).
36
39
  Output tokens are the real generated tokens — NOT the cache-inflated total token count.
40
+ The burn is split into input / cache-read / cache-write where the harness records it
41
+ (Claude/Codex/Gemini/Droid). --pricing no-cache reprices cached tokens at the input rate,
42
+ so you can see what caching is saving. --json always carries both actual and no-cache costs.
37
43
  `)
38
44
  .action(async (options) => {
39
45
  await outputAction(options);
@@ -47,6 +53,14 @@ function resolveGroup(by) {
47
53
  console.error(chalk.red('error: --by must be one of: agent, project, day, account'));
48
54
  process.exit(1);
49
55
  }
56
+ function resolvePricing(pricing) {
57
+ if (pricing === undefined || pricing === 'actual')
58
+ return 'actual';
59
+ if (pricing === 'no-cache')
60
+ return 'no-cache';
61
+ console.error(chalk.red('error: --pricing must be one of: actual, no-cache'));
62
+ process.exit(1);
63
+ }
50
64
  /** Compact token formatter: 38.6M, 4.1K, 10.6B. */
51
65
  function formatCompact(n) {
52
66
  const abs = Math.abs(n);
@@ -59,13 +73,27 @@ function formatCompact(n) {
59
73
  return String(n);
60
74
  }
61
75
  function emptyBurn() {
62
- return { costUsd: 0, outputTokens: 0, tokenCount: 0, sessionCount: 0, durationMs: 0 };
76
+ return {
77
+ costUsd: 0,
78
+ costUsdNoCache: 0,
79
+ outputTokens: 0,
80
+ tokenCount: 0,
81
+ inputTokens: 0,
82
+ cacheReadTokens: 0,
83
+ cacheWriteTokens: 0,
84
+ sessionCount: 0,
85
+ durationMs: 0,
86
+ };
63
87
  }
64
88
  function sumBurn(rows) {
65
89
  return rows.reduce((acc, r) => {
66
90
  acc.costUsd += r.costUsd;
91
+ acc.costUsdNoCache += r.costUsdNoCache;
67
92
  acc.outputTokens += r.outputTokens;
68
93
  acc.tokenCount += r.tokenCount;
94
+ acc.inputTokens += r.inputTokens;
95
+ acc.cacheReadTokens += r.cacheReadTokens;
96
+ acc.cacheWriteTokens += r.cacheWriteTokens;
69
97
  acc.sessionCount += r.sessionCount;
70
98
  acc.durationMs += r.durationMs;
71
99
  return acc;
@@ -122,7 +150,9 @@ async function fetchRemotePayload(device, options) {
122
150
  timeout: 120_000,
123
151
  maxBuffer: 64 * 1024 * 1024,
124
152
  });
125
- const parsed = JSON.parse(stdout);
153
+ // A Windows device relays its payload through PowerShell, which can prefix a
154
+ // CLIXML banner ahead of the JSON — strip it before parsing (RUSH-2286).
155
+ const parsed = JSON.parse(stripClixml(stdout));
126
156
  parsed.machine = parsed.machine || device;
127
157
  return parsed;
128
158
  }
@@ -141,13 +171,17 @@ async function fetchRemotePayload(device, options) {
141
171
  }
142
172
  async function outputAction(options) {
143
173
  const includePrs = options.prs !== false;
174
+ // Validate --pricing up front so a bad value errors even under --json. The JSON
175
+ // payload always carries BOTH costs regardless of the scenario — the flag only
176
+ // chooses which one the text renderer leads with.
177
+ const scenario = resolvePricing(options.pricing);
144
178
  if (!options.allHosts) {
145
179
  const payload = await computeLocalPayload(options, includePrs);
146
180
  if (options.json) {
147
181
  process.stdout.write(JSON.stringify(withRatios(payload, [payload]), null, 2) + '\n');
148
182
  return;
149
183
  }
150
- renderSingle(payload);
184
+ renderSingle(payload, scenario);
151
185
  return;
152
186
  }
153
187
  // Fleet: local payload + every online device, folded in over SSH.
@@ -166,7 +200,7 @@ async function outputAction(options) {
166
200
  process.stdout.write(JSON.stringify(withRatios(mergeMachines(machines, options), machines), null, 2) + '\n');
167
201
  return;
168
202
  }
169
- renderFleet(machines, options);
203
+ renderFleet(machines, options, scenario);
170
204
  }
171
205
  /** Merge per-machine payloads into a combined one (burn + commits summed; PRs local-only). */
172
206
  function mergeMachines(machines, options) {
@@ -179,8 +213,12 @@ function mergeMachines(machines, options) {
179
213
  const uncosted = new Set();
180
214
  for (const m of machines) {
181
215
  burn.costUsd += m.burn.costUsd;
216
+ burn.costUsdNoCache += m.burn.costUsdNoCache;
182
217
  burn.outputTokens += m.burn.outputTokens;
183
218
  burn.tokenCount += m.burn.tokenCount;
219
+ burn.inputTokens += m.burn.inputTokens;
220
+ burn.cacheReadTokens += m.burn.cacheReadTokens;
221
+ burn.cacheWriteTokens += m.burn.cacheWriteTokens;
184
222
  burn.sessionCount += m.burn.sessionCount;
185
223
  burn.durationMs += m.burn.durationMs;
186
224
  for (const s of m.output.commitShas)
@@ -188,15 +226,23 @@ function mergeMachines(machines, options) {
188
226
  for (const a of m.uncostedAgents)
189
227
  uncosted.add(a);
190
228
  for (const r of m.breakdown.rows) {
191
- const cur = byKey.get(r.key) ?? { key: r.key, label: r.label, costUsd: 0, durationMs: 0, sessionCount: 0, tokenCount: 0, outputTokens: 0 };
229
+ const cur = byKey.get(r.key) ?? {
230
+ key: r.key, label: r.label, costUsd: 0, costUsdNoCache: 0, durationMs: 0,
231
+ sessionCount: 0, tokenCount: 0, outputTokens: 0,
232
+ inputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
233
+ };
192
234
  // Peers resolve their own labels; keep the first non-empty one so a machine that
193
235
  // has not indexed an account yet does not blank a label another machine supplied.
194
236
  cur.label ??= r.label;
195
237
  cur.costUsd += r.costUsd;
238
+ cur.costUsdNoCache += r.costUsdNoCache;
196
239
  cur.durationMs += r.durationMs;
197
240
  cur.sessionCount += r.sessionCount;
198
241
  cur.tokenCount += r.tokenCount;
199
242
  cur.outputTokens += r.outputTokens;
243
+ cur.inputTokens += r.inputTokens;
244
+ cur.cacheReadTokens += r.cacheReadTokens;
245
+ cur.cacheWriteTokens += r.cacheWriteTokens;
200
246
  byKey.set(r.key, cur);
201
247
  }
202
248
  }
@@ -226,47 +272,76 @@ function withRatios(payload, machines) {
226
272
  machines: machines.length > 1 ? machines.map(m => ({ machine: m.machine, burn: m.burn, commits: m.output.commits, error: m.error })) : undefined,
227
273
  };
228
274
  }
229
- /** Shared header line: burned · output tokens · PRs · commits, plus ratios. */
230
- function headerLines(payload) {
275
+ /** The cost the text renderer leads with for the chosen scenario. */
276
+ function scenarioCost(x, scenario) {
277
+ return scenario === 'no-cache' ? x.costUsdNoCache : x.costUsd;
278
+ }
279
+ /** Shared header line: burned · output tokens · PRs · commits, plus ratios + burn split. */
280
+ function headerLines(payload, scenario) {
231
281
  const prsTotal = payload.output.prsOpened + payload.output.prsMerged;
282
+ const burn = payload.burn;
283
+ const cost = scenarioCost(burn, scenario);
232
284
  const out = [];
233
285
  out.push(' ' +
234
- `${chalk.green(formatUsd(payload.burn.costUsd))} burned` +
286
+ `${chalk.green(formatUsd(cost))} burned${scenario === 'no-cache' ? chalk.gray(' (no-cache)') : ''}` +
235
287
  chalk.gray(' · ') +
236
- `${chalk.cyan(formatCompact(payload.burn.outputTokens))} output tokens` +
288
+ `${chalk.cyan(formatCompact(burn.outputTokens))} output tokens` +
237
289
  chalk.gray(' · ') +
238
290
  `${chalk.yellow(String(prsTotal))} PRs ${chalk.gray(`(${payload.output.prsMerged} merged)`)}` +
239
291
  chalk.gray(' · ') +
240
292
  `${chalk.yellow(String(payload.output.commits))} commits`);
241
293
  const ratios = [];
242
294
  if (prsTotal > 0)
243
- ratios.push(`${formatUsd(payload.burn.costUsd / prsTotal)}/PR`);
295
+ ratios.push(`${formatUsd(cost / prsTotal)}/PR`);
244
296
  if (payload.output.commits > 0)
245
- ratios.push(`${formatUsd(payload.burn.costUsd / payload.output.commits)}/commit`);
246
- if (payload.burn.costUsd > 0)
247
- ratios.push(`${formatCompact(Math.round(payload.burn.outputTokens / payload.burn.costUsd))} out-tok/$`);
297
+ ratios.push(`${formatUsd(cost / payload.output.commits)}/commit`);
298
+ if (cost > 0)
299
+ ratios.push(`${formatCompact(Math.round(burn.outputTokens / cost))} out-tok/$`);
248
300
  if (ratios.length > 0)
249
301
  out.push(chalk.gray(' ' + ratios.join(' · ')));
302
+ // Burn split — only for harnesses that recorded one (input/cache totals > 0).
303
+ const splitTotal = burn.inputTokens + burn.cacheReadTokens + burn.cacheWriteTokens;
304
+ if (splitTotal > 0) {
305
+ out.push(chalk.gray(' burn split: ') +
306
+ `${chalk.cyan(formatCompact(burn.inputTokens))} input` +
307
+ chalk.gray(' · ') +
308
+ `${chalk.cyan(formatCompact(burn.cacheReadTokens))} cache-read` +
309
+ chalk.gray(' · ') +
310
+ `${chalk.cyan(formatCompact(burn.cacheWriteTokens))} cache-write`);
311
+ }
312
+ // No-cache comparison — shown whenever caching actually moved the number, so an
313
+ // operator sees the saving in `actual` mode too (RUSH-2287: "both if useful").
314
+ if (burn.costUsdNoCache > burn.costUsd && burn.costUsd > 0) {
315
+ const saved = burn.costUsdNoCache - burn.costUsd;
316
+ const pct = Math.round((saved / burn.costUsdNoCache) * 100);
317
+ out.push(chalk.gray(' caching: ') +
318
+ `actual ${chalk.green(formatUsd(burn.costUsd))}` +
319
+ chalk.gray(' vs ') +
320
+ `no-cache ${chalk.yellow(formatUsd(burn.costUsdNoCache))}` +
321
+ chalk.gray(` (saved ${formatUsd(saved)}, ${pct}%)`));
322
+ }
250
323
  return out;
251
324
  }
252
325
  /** Render the per-group burn/output table. */
253
- function renderBreakdown(rows, groupBy) {
326
+ function renderBreakdown(rows, groupBy, scenario) {
254
327
  const out = [chalk.bold(`By ${groupBy}`)];
255
328
  if (rows.length === 0)
256
329
  return out;
330
+ const rowCost = (r) => scenarioCost(r, scenario);
257
331
  const cols = terminalWidth();
258
- const burnW = Math.max(...rows.map(r => formatUsd(r.costUsd).length), 4);
332
+ const burnHeader = scenario === 'no-cache' ? 'burn(nc)' : 'burn';
333
+ const burnW = Math.max(...rows.map(r => formatUsd(rowCost(r)).length), burnHeader.length);
259
334
  const outW = Math.max(...rows.map(r => formatCompact(r.outputTokens).length), 6);
260
335
  const sessW = Math.max(...rows.map(r => String(r.sessionCount).length), 3);
261
336
  const fixedW = 2 + 2 + burnW + 2 + outW + 2 + sessW + 8;
262
337
  const display = (r) => r.label ?? r.key;
263
338
  const keyW = Math.max(8, Math.min(Math.max(...rows.map(r => display(r).length), groupBy.length), cols - fixedW));
264
- out.push(' ' + chalk.gray(padToWidth('', keyW)) + ' ' + chalk.gray(padToWidth('burn', burnW)) + ' ' + chalk.gray(padToWidth('output', outW)) + ' ' + chalk.gray('sessions'));
339
+ out.push(' ' + chalk.gray(padToWidth('', keyW)) + ' ' + chalk.gray(padToWidth(burnHeader, burnW)) + ' ' + chalk.gray(padToWidth('output', outW)) + ' ' + chalk.gray('sessions'));
265
340
  for (const r of rows) {
266
341
  out.push(' ' +
267
342
  padToWidth(truncateToWidth(display(r), keyW), keyW) +
268
343
  ' ' +
269
- chalk.green(padToWidth(formatUsd(r.costUsd), burnW)) +
344
+ chalk.green(padToWidth(formatUsd(rowCost(r)), burnW)) +
270
345
  ' ' +
271
346
  chalk.cyan(padToWidth(formatCompact(r.outputTokens), outW)) +
272
347
  ' ' +
@@ -274,17 +349,17 @@ function renderBreakdown(rows, groupBy) {
274
349
  }
275
350
  return out;
276
351
  }
277
- function renderSingle(payload) {
352
+ function renderSingle(payload, scenario) {
278
353
  const out = [];
279
354
  out.push(chalk.bold('Output') + chalk.gray(` · pricing ${payload.pricingVersion} · since ${payload.since}`));
280
- out.push(...headerLines(payload));
355
+ out.push(...headerLines(payload, scenario));
281
356
  out.push('');
282
357
  if (payload.burn.sessionCount === 0) {
283
358
  out.push(chalk.gray('No sessions with cost data found. Run `agents sessions --all` to index, then retry.'));
284
359
  console.log(out.join('\n'));
285
360
  return;
286
361
  }
287
- out.push(...renderBreakdown(payload.breakdown.rows, payload.breakdown.by));
362
+ out.push(...renderBreakdown(payload.breakdown.rows, payload.breakdown.by, scenario));
288
363
  out.push('');
289
364
  out.push(chalk.bold('Shipped'));
290
365
  out.push(` ${chalk.yellow(String(payload.output.commits))} commits across ${payload.output.reposScanned} repos` + chalk.gray(` (authors: ${payload.output.authors.length > 0 ? payload.output.authors.join(', ') : 'none detected'})`));
@@ -300,22 +375,22 @@ function renderSingle(payload) {
300
375
  out.push(chalk.gray(`agent wall-clock: ${formatDuration(payload.burn.durationMs)}`));
301
376
  console.log(out.join('\n'));
302
377
  }
303
- function renderFleet(machines, options) {
378
+ function renderFleet(machines, options, scenario) {
304
379
  const merged = mergeMachines(machines, options);
305
380
  const out = [];
306
381
  out.push(chalk.bold('Output') + chalk.gray(` · fleet (${machines.length} machines) · pricing ${merged.pricingVersion} · since ${merged.since}`));
307
- out.push(...headerLines(merged));
382
+ out.push(...headerLines(merged, scenario));
308
383
  out.push('');
309
384
  // By machine.
310
385
  out.push(chalk.bold('By machine'));
311
386
  const nameW = Math.max(...machines.map(m => m.machine.length), 7);
312
- const burnW = Math.max(...machines.map(m => formatUsd(m.burn.costUsd).length), 4);
387
+ const burnW = Math.max(...machines.map(m => formatUsd(scenarioCost(m.burn, scenario)).length), 4);
313
388
  for (const m of machines) {
314
389
  const note = m.error ? chalk.red(` (${m.error})`) : '';
315
390
  out.push(' ' +
316
391
  padToWidth(m.machine, nameW) +
317
392
  ' ' +
318
- chalk.green(padToWidth(formatUsd(m.burn.costUsd), burnW)) +
393
+ chalk.green(padToWidth(formatUsd(scenarioCost(m.burn, scenario)), burnW)) +
319
394
  ' ' +
320
395
  chalk.cyan(padToWidth(formatCompact(m.burn.outputTokens), 7)) +
321
396
  ' ' +
@@ -323,7 +398,7 @@ function renderFleet(machines, options) {
323
398
  note);
324
399
  }
325
400
  out.push('');
326
- out.push(...renderBreakdown(merged.breakdown.rows, merged.breakdown.by));
401
+ out.push(...renderBreakdown(merged.breakdown.rows, merged.breakdown.by, scenario));
327
402
  out.push('');
328
403
  out.push(chalk.gray(notCountedLine(merged.uncostedAgents)));
329
404
  console.log(out.join('\n'));