@phnx-labs/agents-cli 1.22.26 → 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 (131) hide show
  1. package/CHANGELOG.md +244 -0
  2. package/README.md +18 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/bench.d.ts +2 -0
  5. package/dist/commands/bench.js +101 -0
  6. package/dist/commands/doctor.js +24 -10
  7. package/dist/commands/exec.js +17 -16
  8. package/dist/commands/insights.js +39 -5
  9. package/dist/commands/output.js +100 -25
  10. package/dist/commands/resume.d.ts +11 -0
  11. package/dist/commands/resume.js +51 -0
  12. package/dist/commands/sessions-picker.js +12 -7
  13. package/dist/commands/sessions.d.ts +47 -1
  14. package/dist/commands/sessions.js +345 -37
  15. package/dist/commands/ssh.js +184 -11
  16. package/dist/commands/teams.js +3 -3
  17. package/dist/index.js +16 -20
  18. package/dist/lib/auth-health.d.ts +47 -3
  19. package/dist/lib/auth-health.js +74 -15
  20. package/dist/lib/bench/index.d.ts +4 -0
  21. package/dist/lib/bench/index.js +4 -0
  22. package/dist/lib/bench/runner.d.ts +16 -0
  23. package/dist/lib/bench/runner.js +111 -0
  24. package/dist/lib/bench/schema.d.ts +5 -0
  25. package/dist/lib/bench/schema.js +91 -0
  26. package/dist/lib/bench/storage.d.ts +5 -0
  27. package/dist/lib/bench/storage.js +32 -0
  28. package/dist/lib/bench/types.d.ts +40 -0
  29. package/dist/lib/bench/types.js +1 -0
  30. package/dist/lib/claude-account-token.d.ts +2 -0
  31. package/dist/lib/claude-account-token.js +27 -1
  32. package/dist/lib/crabbox/cli.d.ts +2 -0
  33. package/dist/lib/crabbox/cli.js +2 -0
  34. package/dist/lib/crabbox/lease.js +7 -1
  35. package/dist/lib/daemon.js +20 -0
  36. package/dist/lib/devices/connect.d.ts +2 -0
  37. package/dist/lib/devices/connect.js +7 -0
  38. package/dist/lib/devices/doctor-findings.d.ts +4 -1
  39. package/dist/lib/devices/doctor-findings.js +15 -1
  40. package/dist/lib/devices/harness-inventory.d.ts +97 -0
  41. package/dist/lib/devices/harness-inventory.js +0 -0
  42. package/dist/lib/devices/registry.d.ts +2 -0
  43. package/dist/lib/devices/ssh-config.js +3 -0
  44. package/dist/lib/devices/windows-ssh-enrollment.d.ts +20 -0
  45. package/dist/lib/devices/windows-ssh-enrollment.js +98 -0
  46. package/dist/lib/exec.bench.d.ts +1 -0
  47. package/dist/lib/exec.bench.js +186 -0
  48. package/dist/lib/exec.js +18 -8
  49. package/dist/lib/fleet/apply.js +9 -7
  50. package/dist/lib/fleet/remote-login.d.ts +4 -3
  51. package/dist/lib/fleet/remote-login.js +11 -9
  52. package/dist/lib/gemini-settings.d.ts +0 -1
  53. package/dist/lib/gemini-settings.js +12 -7
  54. package/dist/lib/hooks.d.ts +8 -5
  55. package/dist/lib/hooks.js +10 -6
  56. package/dist/lib/hosts/dispatch.js +7 -3
  57. package/dist/lib/hosts/passthrough.d.ts +22 -0
  58. package/dist/lib/hosts/passthrough.js +14 -8
  59. package/dist/lib/hosts/progress.d.ts +2 -1
  60. package/dist/lib/hosts/progress.js +4 -3
  61. package/dist/lib/hosts/providers/devices.js +1 -0
  62. package/dist/lib/hosts/ready.d.ts +4 -2
  63. package/dist/lib/hosts/ready.js +24 -8
  64. package/dist/lib/hosts/reconcile.d.ts +1 -1
  65. package/dist/lib/hosts/reconcile.js +16 -7
  66. package/dist/lib/hosts/reconnect.d.ts +45 -12
  67. package/dist/lib/hosts/reconnect.js +90 -34
  68. package/dist/lib/hosts/registry.d.ts +2 -2
  69. package/dist/lib/hosts/registry.js +3 -5
  70. package/dist/lib/hosts/remote-cmd.d.ts +17 -0
  71. package/dist/lib/hosts/remote-cmd.js +29 -0
  72. package/dist/lib/hosts/tasks.d.ts +2 -0
  73. package/dist/lib/hosts/types.d.ts +1 -0
  74. package/dist/lib/hosts/types.js +3 -0
  75. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  76. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  77. package/dist/lib/menubar/install-menubar.d.ts +9 -6
  78. package/dist/lib/menubar/install-menubar.js +20 -9
  79. package/dist/lib/models.d.ts +30 -1
  80. package/dist/lib/models.js +42 -144
  81. package/dist/lib/pricing/cost.d.ts +9 -0
  82. package/dist/lib/pricing/cost.js +24 -0
  83. package/dist/lib/pricing/index.d.ts +1 -1
  84. package/dist/lib/pricing/index.js +1 -1
  85. package/dist/lib/redact.js +8 -3
  86. package/dist/lib/remote-agents-json.d.ts +32 -0
  87. package/dist/lib/remote-agents-json.js +47 -16
  88. package/dist/lib/resource-profiles.js +1 -2
  89. package/dist/lib/routine-notify-owner.d.ts +102 -0
  90. package/dist/lib/routine-notify-owner.js +232 -0
  91. package/dist/lib/routines.d.ts +10 -0
  92. package/dist/lib/runner.d.ts +9 -1
  93. package/dist/lib/runner.js +182 -17
  94. package/dist/lib/sandbox.d.ts +0 -2
  95. package/dist/lib/sandbox.js +2 -19
  96. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  97. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  98. package/dist/lib/secrets/agent.js +3 -2
  99. package/dist/lib/secrets/lease.d.ts +25 -0
  100. package/dist/lib/secrets/lease.js +44 -0
  101. package/dist/lib/self-update.d.ts +20 -5
  102. package/dist/lib/self-update.js +93 -16
  103. package/dist/lib/session/db.d.ts +19 -2
  104. package/dist/lib/session/db.js +54 -5
  105. package/dist/lib/session/discover.d.ts +21 -1
  106. package/dist/lib/session/discover.js +124 -32
  107. package/dist/lib/session/insights.d.ts +19 -0
  108. package/dist/lib/session/insights.js +78 -10
  109. package/dist/lib/session/recovery.d.ts +17 -1
  110. package/dist/lib/session/recovery.js +111 -4
  111. package/dist/lib/session/remote-list.d.ts +10 -9
  112. package/dist/lib/session/remote-list.js +25 -23
  113. package/dist/lib/session/resume-owner.d.ts +55 -0
  114. package/dist/lib/session/resume-owner.js +69 -0
  115. package/dist/lib/session/team-filter.d.ts +65 -0
  116. package/dist/lib/session/team-filter.js +98 -3
  117. package/dist/lib/session/types.d.ts +33 -0
  118. package/dist/lib/smart-launch.d.ts +31 -5
  119. package/dist/lib/smart-launch.js +43 -7
  120. package/dist/lib/ssh-exec.d.ts +27 -0
  121. package/dist/lib/ssh-exec.js +34 -1
  122. package/dist/lib/ssh-tunnel.d.ts +3 -2
  123. package/dist/lib/ssh-tunnel.js +25 -16
  124. package/dist/lib/startup/command-registry.d.ts +1 -0
  125. package/dist/lib/startup/command-registry.js +2 -0
  126. package/dist/lib/teams/placement-probe.d.ts +1 -1
  127. package/dist/lib/teams/placement-probe.js +24 -24
  128. package/dist/lib/tmux/session.d.ts +8 -0
  129. package/dist/lib/tmux/session.js +22 -0
  130. package/dist/lib/types.d.ts +2 -0
  131. package/package.json +1 -1
@@ -217,16 +217,27 @@ function renderReport(groups, dim, meta, actions, harnesses) {
217
217
  return;
218
218
  }
219
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.
220
221
  out.push('');
221
222
  out.push(chalk.bold(`By ${dim}`));
222
- 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));
223
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`));
224
232
  for (const g of groups) {
225
233
  const cost = g.costUsd > 0 ? formatUsd(g.costUsd) : '—';
226
234
  const dur = g.durationMs > 0 ? formatDuration(g.durationMs) : '—';
235
+ const stalls = stallOf(g);
236
+ const resumes = resumeOf(g);
227
237
  out.push(` ${padToWidth(truncateToWidth(g.label, labelW), labelW)} ` +
228
238
  `${chalk.gray(String(g.sessions).padStart(sessW))} ${chalk.gray('sess')} ` +
229
- `${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))}`);
230
241
  }
231
242
  // Everything below is the whole scope folded together; per-group detail is in --json.
232
243
  const all = newFacetAccumulator();
@@ -235,16 +246,32 @@ function renderReport(groups, dim, meta, actions, harnesses) {
235
246
  renderCounts('Top tools', topEntries(all.toolCounts, 8), out);
236
247
  renderCounts('Languages', topEntries(all.languages, 6), out);
237
248
  renderCounts('Models', topEntries(all.models, 6), out);
249
+ renderCounts('Silent stalls by model', topEntries(all.silentStallsByModel ?? {}, 8), out);
238
250
  // Friction — the section that earns the command.
239
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;
240
256
  out.push('');
241
257
  out.push(chalk.bold('Friction'));
242
258
  out.push(` ${padToWidth('interruptions', 18)} ${chalk.cyan(String(all.interruptions))}` +
243
259
  chalk.gray(' turns you cut short'));
244
260
  out.push(` ${padToWidth('tool errors', 18)} ${chalk.cyan(String(all.errorCount))}`);
245
261
  if (gaps.length > 0) {
246
- out.push(` ${padToWidth('your reply time', 18)} ` +
247
- 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'));
248
275
  }
249
276
  const errs = topEntries(all.errorCategories, 6);
250
277
  if (errs.length > 0) {
@@ -321,7 +348,7 @@ function renderReport(groups, dim, meta, actions, harnesses) {
321
348
  out.push(chalk.yellow(` ${meta.unreadable} transcripts could not be read; their behaviour is missing from these totals.`));
322
349
  }
323
350
  if (all.gapsOverCeiling > 0) {
324
- 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).`));
325
352
  }
326
353
  out.push('');
327
354
  out.push(chalk.gray(' `agents insights --by project` to see it per repo'));
@@ -341,6 +368,13 @@ async function renderNarrative(payload) {
341
368
  '2. What is costing you — split into the assistant\'s fault vs your own workflow.',
342
369
  '3. Quick wins — concrete, tied to a number in the data.',
343
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.',
344
378
  'Be specific and cite the numbers. No preamble, no flattery, no bullet padding.',
345
379
  '',
346
380
  JSON.stringify(payload),
@@ -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'));
@@ -5,7 +5,18 @@ interface ResumeOptions {
5
5
  headless?: boolean;
6
6
  cwd?: string;
7
7
  quiet?: boolean;
8
+ /** Run on THIS machine even when the session belongs to a peer (escape hatch). */
9
+ here?: boolean;
8
10
  }
11
+ /**
12
+ * The argv to re-run this resume on the machine that owns the session.
13
+ *
14
+ * Deliberately carries no "don't route again" FLAG — that rides the
15
+ * {@link RESUME_PINNED_ENV} export instead, so the hop also works against a peer
16
+ * on an older CLI (see the constant's docs). Every argv token here exists in the
17
+ * released surface.
18
+ */
19
+ export declare function buildResumeRemoteArgs(sessionId: string, prompt: string | undefined, options: ResumeOptions): string[];
9
20
  /** Translate `agents resume <id>` to the canonical `agents run` surface after
10
21
  * resolving the source identity. The run command remains the only executor. */
11
22
  export declare function buildResumeRunArgs(session: {
@@ -3,6 +3,29 @@ import { spawn } from 'child_process';
3
3
  import chalk from 'chalk';
4
4
  import { setHelpSections } from '../lib/help.js';
5
5
  import { resolveSessionMetadataValue } from './sessions.js';
6
+ import { sessionOwnerDevice, consumeResumePinned, RESUME_PINNED_ENV } from '../lib/session/resume-owner.js';
7
+ /**
8
+ * The argv to re-run this resume on the machine that owns the session.
9
+ *
10
+ * Deliberately carries no "don't route again" FLAG — that rides the
11
+ * {@link RESUME_PINNED_ENV} export instead, so the hop also works against a peer
12
+ * on an older CLI (see the constant's docs). Every argv token here exists in the
13
+ * released surface.
14
+ */
15
+ export function buildResumeRemoteArgs(sessionId, prompt, options) {
16
+ const args = ['resume', sessionId, ...(prompt === undefined ? [] : [prompt])];
17
+ if (options.mode)
18
+ args.push('--mode', options.mode);
19
+ if (options.interactive)
20
+ args.push('--interactive');
21
+ if (options.headless)
22
+ args.push('--headless');
23
+ if (options.cwd)
24
+ args.push('--cwd', options.cwd);
25
+ if (options.quiet)
26
+ args.push('--quiet');
27
+ return args;
28
+ }
6
29
  /** Translate `agents resume <id>` to the canonical `agents run` surface after
7
30
  * resolving the source identity. The run command remains the only executor. */
8
31
  export function buildResumeRunArgs(session, prompt, options) {
@@ -29,7 +52,11 @@ export function registerResumeCommand(program) {
29
52
  .option('--headless', 'Resume headlessly (a prompt is required)')
30
53
  .option('--cwd <path>', 'Override the recorded working directory')
31
54
  .option('-q, --quiet', 'Suppress routing banners')
55
+ .option('--here', 'Run on this machine even if the session belongs to another device')
32
56
  .action(async (sessionId, prompt, options) => {
57
+ // Read (and clear) the routing pin before anything else, so it can never
58
+ // reach the agent's own children.
59
+ const pinnedHere = consumeResumePinned() || !!options.here;
33
60
  const outcome = await resolveSessionMetadataValue(sessionId.trim());
34
61
  if (outcome.kind === 'partial') {
35
62
  console.error(chalk.red(`Could not resolve session while these devices were unavailable: ${outcome.failedPeers.join(', ')}`));
@@ -46,6 +73,29 @@ export function registerResumeCommand(program) {
46
73
  process.exitCode = 1;
47
74
  return;
48
75
  }
76
+ // The harness keeps its conversation state on the machine that produced
77
+ // the session, so a peer-owned session MUST resume there. Running it here
78
+ // starts the agent against state this box does not have — silently, since
79
+ // a synced mirror makes the transcript look local (RUSH-2022).
80
+ const owner = pinnedHere ? undefined : sessionOwnerDevice(outcome.session);
81
+ if (owner) {
82
+ if (!options.quiet) {
83
+ process.stderr.write(chalk.gray(`[agents] session ${outcome.session.shortId} belongs to ${owner} → resuming there\n`));
84
+ }
85
+ // `runOnPeer` is the existing transport for "this session's transcript
86
+ // and agent binary are on that box" (lib/session/remote-list.ts) — the
87
+ // same one the picker already uses. Not the `--host` passthrough: that
88
+ // one re-discovers locally and marks the run AGENTS_FLEET_REMOTE, which
89
+ // a long-lived resumed session must not inherit.
90
+ const { runOnPeer } = await import('../lib/session/remote-list.js');
91
+ const rc = await runOnPeer(buildResumeRemoteArgs(outcome.session.id, prompt, options), owner, { tty: !!process.stdout.isTTY, env: { [RESUME_PINNED_ENV]: '1' } });
92
+ if (rc === 'no-target') {
93
+ console.error(chalk.red(`Session ${outcome.session.shortId} lives on ${owner}, which isn't a reachable device right now.`));
94
+ console.error(chalk.gray(`Register/wake it (agents devices), or run there: agents ssh ${owner}`));
95
+ process.exitCode = 1;
96
+ }
97
+ return;
98
+ }
49
99
  const args = buildResumeRunArgs(outcome.session, prompt, options);
50
100
  const child = spawn(process.execPath, [process.argv[1], ...args], {
51
101
  stdio: 'inherit',
@@ -82,6 +132,7 @@ export function registerResumeCommand(program) {
82
132
  agents resume 019fd0c8-b3e9-77a2-a1a4-444698c4d897 --mode edit`,
83
133
  notes: `
84
134
  A full ID resolves from the local session database first (zero SSH) and, on a local miss, fans out with the first peer holding it cancelling the rest. An exact label always consults the fleet (labels are not globally unique) and auto-resumes the one match; a cross-machine label collision surfaces as an ambiguity.
135
+ A session that ran on another device resumes ON that device over SSH — the harness's conversation state lives there, so running it here would start against state this machine does not have. --here overrides that and runs locally.
85
136
  Use agents run auto --resume <id> when the original account is unavailable and another harness may continue.`,
86
137
  });
87
138
  }
@@ -19,12 +19,17 @@ import { renderMarkdown } from '../lib/markdown.js';
19
19
  import { itemPicker } from '../lib/picker.js';
20
20
  import { classifyFileChanges, changeCounts, toolHistogram, detectTestResult } from '../lib/session/digest.js';
21
21
  import { extractArtifacts, extractHooks, extractLinks, extractRepos, extractSkills } from '../lib/session/highlights.js';
22
- /** A session whose transcript lives on another machine (folded in over the live
23
- * cross-machine fan-out): its `filePath` is on that peer's disk, so the preview
24
- * can't parse it locally — it shows metadata + a "resume there" note instead.
25
- * Keys off `_remote`, not the machine tag, so locally-readable synced mirrors
26
- * still parse their file normally. */
27
- function remoteMachineOf(session) {
22
+ /** A session whose transcript FILE is on another machine (folded in over the
23
+ * live cross-machine fan-out): its `filePath` is on that peer's disk, so the
24
+ * preview can't parse it locally — it shows metadata + a "resume there" note
25
+ * instead. Keys off `_remote`, not the machine tag, so locally-readable synced
26
+ * mirrors still parse their file normally.
27
+ *
28
+ * This is the READ rule and only the read rule. Whether a session may be
29
+ * RESUMED here is a different question with a different answer — a mirror is
30
+ * readable but not resumable — and `sessionOwnerDevice`
31
+ * (lib/session/resume-owner.ts) is the single place that answers it (RUSH-2022). */
32
+ function transcriptOnPeerOf(session) {
28
33
  return session._remote ? session.machine : undefined;
29
34
  }
30
35
  /**
@@ -98,7 +103,7 @@ function sanitizeMeta(s) {
98
103
  const previewCache = new Map();
99
104
  /** Build a cached multi-line preview string for display in the session picker. */
100
105
  export function buildPreview(session) {
101
- const remote = remoteMachineOf(session);
106
+ const remote = transcriptOnPeerOf(session);
102
107
  const cacheKey = remote ? `${remote}:${session.id}` : session.id;
103
108
  const cached = previewCache.get(cacheKey);
104
109
  if (cached)
@@ -11,7 +11,7 @@ interface SessionFilterOptions {
11
11
  all?: boolean;
12
12
  teams?: boolean;
13
13
  inTeam?: string;
14
- routine?: boolean;
14
+ routine?: boolean | string;
15
15
  since?: string;
16
16
  until?: string;
17
17
  }
@@ -389,6 +389,22 @@ export declare function isBareBrowserListing(options: SessionsOptions, query: st
389
389
  * `runSessionBrowser` picker cannot represent.
390
390
  */
391
391
  export declare function hasNoBrowserDisqualifyingFlags(options: SessionsOptions, query: string | undefined): boolean;
392
+ /** Resolve a typed routine selector by exact name, substring, or one unambiguous typo. */
393
+ export declare function resolveRoutineName(query: string, names: readonly string[]): string | null;
394
+ export interface RoutineRunGroup {
395
+ runId: string;
396
+ timestamp: string;
397
+ sessions: SessionMeta[];
398
+ }
399
+ export interface RoutineChoice {
400
+ name: string;
401
+ lastRunAt: string;
402
+ runCount: number;
403
+ latestRunSessionCount: number;
404
+ }
405
+ export declare function buildRoutineRunGroups(sessions: SessionMeta[]): RoutineRunGroup[];
406
+ export declare function buildRoutineChoices(sessions: SessionMeta[]): RoutineChoice[];
407
+ export declare function filterSessionsByRoutine(sessions: SessionMeta[], routine: boolean | string, interactive: boolean): Promise<SessionMeta[] | null>;
392
408
  /** Resolve a session by id/query globally and print its compact preview (no pager).
393
409
  * Backs `--preview` — the fast path for the "peek before resume" hot loop. */
394
410
  export declare function renderSessionPreview(query: string, scope: {
@@ -478,6 +494,10 @@ export declare function buildOverviewGroups(pool: SessionMeta[], perProjectCap:
478
494
  groups: OverviewGroup[];
479
495
  projectCount: number;
480
496
  };
497
+ export declare function printRoutineRunOverview(sessions: SessionMeta[], liveIndex: Map<string, ActiveSession> | undefined, opts?: {
498
+ hiddenCount?: number;
499
+ hiddenUnmanaged?: number;
500
+ }): void;
481
501
  /**
482
502
  * Render a resolved session to stdout — the non-follow view behind
483
503
  * `agents logs <sessionId>`. Defaults to the concise `summary` digest (same as
@@ -577,6 +597,16 @@ export declare function pickSessionInteractive(sessions: SessionMeta[], message?
577
597
  export declare const LIVE_ROW_PREFIX = "live:";
578
598
  export declare function isIdlessLiveRow(s: SessionMeta): boolean;
579
599
  export declare function handlePickedSession(picked: PickedSession): Promise<void>;
600
+ /**
601
+ * Resume `session` on the machine that owns it, when that is not this one.
602
+ * Returns true when it handled the resume (hopped, or reported an unreachable
603
+ * owner), false when the session is local and the caller should take over here.
604
+ *
605
+ * The one hop for a foreground, one-session-at-a-time resume — shared by the
606
+ * `agents sessions` picker and by `sessions resume`'s no-tab-backend path, which
607
+ * would otherwise reach `resumeSessionInPlace` and be refused (RUSH-2022).
608
+ */
609
+ export declare function resumeOnOwnerIfRemote(session: SessionMeta): Promise<boolean>;
580
610
  /**
581
611
  * Resume a session in the current terminal — a foreground takeover of this
582
612
  * process. Used by the single-select picker and by `sessions resume` when the
@@ -647,6 +677,22 @@ export declare function resolveSessionQuery(pool: SessionMeta[], query: string,
647
677
  export declare function fleetNotFoundMessage(query: string, deviceCount: number, unreachable: string[]): string[];
648
678
  /** Filter and rank sessions by a multi-term search query across metadata and content. */
649
679
  export declare function filterSessionsByQuery(sessions: SessionMeta[], query: string | undefined): SessionMeta[];
680
+ /**
681
+ * Narrow a session list by --project and --agent before search resolution.
682
+ * Without this, a query like "scoped search" could match sessions in BOTH
683
+ * the project you specified AND elsewhere, producing an ambiguity error
684
+ * even though the user already pointed at the correct scope.
685
+ */
686
+ export declare function applyScopeFilters(sessions: SessionMeta[], scope: {
687
+ agent?: string;
688
+ project?: string;
689
+ routine?: boolean | string;
690
+ }): SessionMeta[];
691
+ export declare function artifactLookupScope(agent?: string, project?: string, routine?: boolean | string): {
692
+ agent?: string;
693
+ project?: string;
694
+ routine?: boolean | string;
695
+ };
650
696
  /**
651
697
  * Whether a missed local id lookup should widen to a cross-machine sweep.
652
698
  *