@cruxy/cli 1.9.0 → 1.10.0

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 (38) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/loop.js +16 -1
  3. package/dist/agent/session.js +62 -9
  4. package/dist/approval/classify.js +170 -40
  5. package/dist/approval/prompt.js +52 -6
  6. package/dist/approval/service.js +1 -11
  7. package/dist/budget/session-budget.js +10 -1
  8. package/dist/checkpoint/coverage.js +147 -4
  9. package/dist/cli/commands/limits.js +76 -0
  10. package/dist/cli/commands/pr.js +10 -1
  11. package/dist/cli/commands/rollback.js +10 -2
  12. package/dist/cli/commands/run.js +19 -3
  13. package/dist/cli/commands/sessions.js +156 -0
  14. package/dist/cli/program.js +4 -0
  15. package/dist/cli/repl.js +25 -0
  16. package/dist/cli/session-factory.js +31 -10
  17. package/dist/config/schema.js +141 -9
  18. package/dist/constants.js +12 -2
  19. package/dist/errors/constructors.js +49 -44
  20. package/dist/jobs/manager.js +269 -17
  21. package/dist/mcp/client.js +16 -0
  22. package/dist/render/limits-report.js +213 -0
  23. package/dist/render/limits-view.js +125 -0
  24. package/dist/sandbox/service.js +9 -0
  25. package/dist/sandbox/types.js +15 -0
  26. package/dist/session/index.js +3 -1
  27. package/dist/session/list.js +20 -6
  28. package/dist/session/log.js +120 -21
  29. package/dist/session/prune.js +106 -0
  30. package/dist/session/resume.js +5 -0
  31. package/dist/subagent/orchestrator.js +71 -31
  32. package/dist/subagent/spawn-tool.js +11 -4
  33. package/dist/tools/schema-depth.js +18 -0
  34. package/dist/tui/limits-panel.js +53 -30
  35. package/dist/usage/collect.js +20 -1
  36. package/dist/usage/summary.js +48 -1
  37. package/dist/usage/types.js +27 -0
  38. package/package.json +2 -2
@@ -1,8 +1,8 @@
1
1
  import path from "node:path";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { runAgent } from "../agent/loop.js";
4
- import { classifyProviderError, CruxyError, ErrorCode, messageOf, sessionBudgetExhausted, subagentDepthExceeded, subagentScopeOverlap, } from "../errors/index.js";
5
- import { UsageCollector } from "../usage/index.js";
4
+ import { CruxyError, ErrorCode, messageOf, poolDenial, sessionBudgetExhausted, subagentDepthExceeded, subagentScopeOverlap, } from "../errors/index.js";
5
+ import { UsageCollector, } from "../usage/index.js";
6
6
  import { UNRESOLVED_TIER, } from "../budget/index.js";
7
7
  import { resolveTaskModel } from "../routing/index.js";
8
8
  import { Workspace } from "../workspace/index.js";
@@ -76,7 +76,30 @@ export class SubagentOrchestrator {
76
76
  if (childDepth < maxDepth) {
77
77
  registry.register(makeSpawnSubagentTool(this, childDepth));
78
78
  }
79
- const budget = new Budget(resolveBudget(defaultBudget, spec.budget));
79
+ // Admission control for the SEQUENTIAL seam (cli#245). `spawnMany` asks for
80
+ // its whole batch and passes the verdict down; everything else — the
81
+ // `spawn_subagent` tool, a plan step, a nested child — arrives here having
82
+ // asked nobody, which is the hole cli#243 left behind. A refusal throws the
83
+ // same coded error the fan-out throws, and the spawn tool hands it to the
84
+ // model as a tool error it can act on (see `PROPAGATE` in `spawn-tool.ts`).
85
+ const admission = opts.admission ?? this.admitRuns(1, [spec]);
86
+ if (admission.kind === "refused") {
87
+ throw sessionBudgetExhausted(admission.reason);
88
+ }
89
+ if (admission.kind === "narrowed" && !opts.admission) {
90
+ deps.logger.warn(admission.reason);
91
+ }
92
+ // `admit` only ever NARROWS, and `Math.min` is what makes that true here: a
93
+ // batch verdict carries the heaviest child's ceiling, so applying it raw to
94
+ // a cheaper sibling would RAISE that child's cap on the strength of an
95
+ // admission check. `0` means the budget bounded nothing.
96
+ const resolved = resolveBudget(defaultBudget, spec.budget);
97
+ const budget = new Budget(admission.maxTokens > 0
98
+ ? {
99
+ ...resolved,
100
+ maxTokens: Math.min(resolved.maxTokens, admission.maxTokens),
101
+ }
102
+ : resolved);
80
103
  // Root scoping (C.33): a `spec.root` narrows the child's cwd + confinement to
81
104
  // that ONE root (its writes land there and nowhere else). Omitted → the full
82
105
  // session workspace, unchanged from C.14. An unknown name fails loud here
@@ -150,12 +173,19 @@ export class SubagentOrchestrator {
150
173
  // sibling's pool denial cancelled, still drew whatever it drew before it
151
174
  // stopped, and the record is what it reported.
152
175
  //
153
- // NOT ALSO WRITTEN TO THE USAGE STORE. Child requests have never appeared
154
- // in `/usage` and this does not change that `/budget` now counts them
155
- // and `/usage` still does not, so the two figures can differ by a
156
- // fan-out's worth. That gap is real and pre-dates this; closing it means
157
- // persisting child runs, which is a change to what is on disk and belongs
158
- // in its own right rather than smuggled in behind an admission fix.
176
+ // STILL NOT WRITTEN TO THE USAGE STORE AS A RUN OF ITS OWN, and now for a
177
+ // different reason than when this was written (cli#244 closed the gap the
178
+ // old note described). The child's requests DO reach `/usage` forwarded
179
+ // above into the spawning turn's collector, so they land as entries of the
180
+ // turn's record. What this record is for is the budget, and only the
181
+ // budget: it exists so the fold can happen the MOMENT the child finishes
182
+ // rather than at end of turn, because the next admission check inside the
183
+ // same turn has to see the spend.
184
+ //
185
+ // Which is exactly why the parent must NOT hand these same entries to the
186
+ // budget again when its turn closes — see the `origin` filter at
187
+ // `agent/session.ts`. Two folds of one child's tokens would halve the
188
+ // effective session cap on any turn that fans out.
159
189
  this.deps.budget?.record(usage.toRecord(randomUUID(), undefined, startedAt));
160
190
  }
161
191
  }
@@ -183,7 +213,16 @@ export class SubagentOrchestrator {
183
213
  router: deps.router,
184
214
  taskClass: spec.taskClass ?? "subagent",
185
215
  signal: opts.signal,
186
- onRequestUsage: (req) => usage.record(req),
216
+ // Both, and in this order (cli#244). The child's OWN collector is what
217
+ // the budget fold below reads — it needs a per-child record — while the
218
+ // parent's sink is what puts this request in `/usage` at all. The
219
+ // explicit origin is load-bearing: the parent's collector defaults to
220
+ // `"turn"`, and a child's request inheriting that would be a lie about
221
+ // what the user did.
222
+ onRequestUsage: (req) => {
223
+ usage.record(req);
224
+ opts.onRequestUsage?.({ ...req, origin: "subagent" });
225
+ },
187
226
  });
188
227
  }
189
228
  catch (err) {
@@ -277,7 +316,7 @@ export class SubagentOrchestrator {
277
316
  // AFTER the scope check on purpose: an overlapping fan-out is malformed and
278
317
  // must be refused whatever the budget says, and narrowing a malformed batch
279
318
  // to two children would hide the overlap rather than report it.
280
- const admitted = this.admitFanOut(specs);
319
+ const admitted = this.admitRuns(specs.length, specs);
281
320
  if (admitted.kind === "refused") {
282
321
  throw sessionBudgetExhausted(admitted.reason);
283
322
  }
@@ -317,6 +356,17 @@ export class SubagentOrchestrator {
317
356
  results[i] = await this.spawn(spec, parentDepth, {
318
357
  signal: controller.signal,
319
358
  slot: `${i + 1}/${total}`,
359
+ // The batch verdict, so the child does not ask a second time —
360
+ // and so a cut token cap actually reaches the run it was cut
361
+ // for. `admit` returns one for a single run it can only
362
+ // partially afford, and until cli#245 `spawnMany` read the
363
+ // count off that verdict and dropped the ceiling with it.
364
+ admission: admitted,
365
+ // Every child of the batch reports into the one turn that
366
+ // spawned them, exactly as a sequential child does.
367
+ ...(opts.onRequestUsage
368
+ ? { onRequestUsage: opts.onRequestUsage }
369
+ : {}),
320
370
  });
321
371
  }
322
372
  catch (err) {
@@ -347,7 +397,13 @@ export class SubagentOrchestrator {
347
397
  }
348
398
  }
349
399
  /**
350
- * Ask the session budget whether this batch fits (P10 track 3 / cli#212).
400
+ * Ask the session budget whether these runs fit (P10 track 3 / cli#212).
401
+ *
402
+ * ONE method for both seams (cli#245): a batch of N from `spawnMany`, and the
403
+ * single run `spawn` asks for when nobody admitted it. They differ only in
404
+ * `count` — the arithmetic, the tier resolution and the per-run ceiling are
405
+ * the same question, and a second copy for the singular case is how the two
406
+ * would come to disagree.
351
407
  *
352
408
  * The estimate is the batch's CEILING, not a guess at its actual draw:
353
409
  * `count × perChildTokens × multiplier`, where `perChildTokens` is the local
@@ -361,17 +417,17 @@ export class SubagentOrchestrator {
361
417
  * the pool, so it is weighed at the worst case rather than skipped — see
362
418
  * `UNRESOLVED_TIER`.
363
419
  */
364
- admitFanOut(specs) {
420
+ admitRuns(count, specs) {
365
421
  const budget = this.deps.budget;
366
422
  if (!budget)
367
- return { kind: "allow", count: specs.length, maxTokens: 0 };
423
+ return { kind: "allow", count, maxTokens: 0 };
368
424
  const { defaultBudget } = this.deps.config.subagent;
369
425
  // The heaviest child in the batch sets the per-run figure. The bound has to
370
426
  // hold for the batch as dispatched, and averaging would let one 64k child
371
427
  // hide behind four 4k ones.
372
428
  const perRunTokens = specs.reduce((max, spec) => Math.max(max, resolveBudget(defaultBudget, spec.budget).maxTokens), 0);
373
429
  return budget.admit({
374
- count: specs.length,
430
+ count,
375
431
  perRunTokens,
376
432
  tier: this.fanOutTier(),
377
433
  });
@@ -489,22 +545,6 @@ function taskLabel(task) {
489
545
  function isWriter(spec) {
490
546
  return (spec.tools ?? []).some((t) => SUBAGENT_WRITE_TOOLS.has(t));
491
547
  }
492
- /**
493
- * The typed pool denial behind an error, or `null` if it is not one.
494
- *
495
- * Two shapes reach here and both are the same fact: the raw SDK
496
- * `BudgetExhaustedError` from this child's own request, and — when a child that
497
- * itself spawned re-throws — the `CruxyError` a nested `spawn` already
498
- * converted. Mapping goes through `classifyProviderError` so there is still ONE
499
- * place that knows which SDK class means what.
500
- */
501
- function poolDenial(err) {
502
- if (CruxyError.is(err)) {
503
- return err.code === ErrorCode.BudgetExhausted ? err : null;
504
- }
505
- const typed = classifyProviderError(err);
506
- return typed?.code === ErrorCode.BudgetExhausted ? typed : null;
507
- }
508
548
  /**
509
549
  * The tokens a child is KNOWN to have spent, or `undefined` when no request
510
550
  * reported any.
@@ -91,7 +91,7 @@ export function makeSpawnSubagentTool(orchestrator, depth) {
91
91
  "transcript is discarded. Use for independent subtasks whose details you don't " +
92
92
  'need in your own context (e.g. "find where X is configured and report the paths").',
93
93
  parameters,
94
- async execute(input) {
94
+ async execute(input, ctx) {
95
95
  const budget = {
96
96
  ...(input.maxIterations !== undefined
97
97
  ? { maxIterations: input.maxIterations }
@@ -102,7 +102,12 @@ export function makeSpawnSubagentTool(orchestrator, depth) {
102
102
  };
103
103
  let result;
104
104
  try {
105
- result = await orchestrator.spawn({ task: input.task, tools: input.tools, budget }, depth);
105
+ result = await orchestrator.spawn({ task: input.task, tools: input.tools, budget }, depth,
106
+ // The calling turn's usage collector (cli#244), the one thing on `ctx`
107
+ // that is per-turn rather than per-session. Absent outside a turn, in
108
+ // which case the child's spend still reaches the budget — it just has
109
+ // no record to join.
110
+ ctx.onRequestUsage ? { onRequestUsage: ctx.onRequestUsage } : {});
106
111
  }
107
112
  catch (err) {
108
113
  // Non-interactive default-deny propagates to the boundary (U.3) —
@@ -186,10 +191,12 @@ export function makeSpawnSubagentsTool(orchestrator, depth) {
186
191
  "For children that WRITE, give each a distinct `root`; overlapping write scope is " +
187
192
  "refused. For a single subtask, use spawn_subagent instead.",
188
193
  parameters: batchParameters,
189
- async execute(input) {
194
+ async execute(input, ctx) {
190
195
  let results;
191
196
  try {
192
- results = await orchestrator.spawnMany(input.tasks.map(toSpec), depth);
197
+ results = await orchestrator.spawnMany(input.tasks.map(toSpec), depth,
198
+ // Every child of the batch reports into the one turn that spawned it.
199
+ ctx.onRequestUsage ? { onRequestUsage: ctx.onRequestUsage } : {});
193
200
  }
194
201
  catch (err) {
195
202
  // Non-interactive default-deny propagates to the boundary (U.3).
@@ -59,6 +59,24 @@ export function schemaDepth(node) {
59
59
  * does not raise it there — it only moves the failure from a red CI run to every
60
60
  * user's terminal. If a schema cannot fit, the schema changes.
61
61
  *
62
+ * THE MIRROR ONLY PROTECTS ONE DIRECTION. Raising this literal is caught by the
63
+ * paragraph above; the gateway TIGHTENING it is caught by nothing here. If
64
+ * `maxSchemaDepth` drops to 7, `apply_patch` and `spawn_subagents` — both at
65
+ * rendered depth 7, a margin accepted deliberately in #233 — are dead in the
66
+ * field for every user on every turn, and this repo's CI stays green, because
67
+ * the test written to prevent exactly that outage is asserting against a stale
68
+ * copy of the number. `mcp/bounds.ts` reads this same constant, so a drift takes
69
+ * the MCP guard with it.
70
+ *
71
+ * Asked on cruxy-ai/api#183 whether the bound could be SERVED rather than
72
+ * mirrored. Answer, in cruxy-ai/api#187 (merged 2026-08-19): half of it. A
73
+ * rejection is now self-describing — code `invalid_tool_schema` with `tool`,
74
+ * `bound` (`bytes` | `depth` | `nodes`) and `limit` as structured fields — but
75
+ * nothing reports the bound before a request is sent. So this stays a hand-copy,
76
+ * CI cannot catch a tightening, and the runtime error is the ONLY detector we
77
+ * have: a rejection whose `limit` disagrees with this constant means the
78
+ * constant is stale, not that one tool is bad. Fix it here, not at the tool.
79
+ *
62
80
  * The bound is EXCLUSIVE: a schema is safe at `MAX_SCHEMA_DEPTH - 1` and dies at
63
81
  * `MAX_SCHEMA_DEPTH`. Both consumers must spell that the same way — the CI gate
64
82
  * asserts `depth < MAX_SCHEMA_DEPTH`, the MCP bound trips on
@@ -1,4 +1,5 @@
1
1
  import { compactTokens } from "../render/units.js";
2
+ import { buildLimitsSummary, } from "../render/limits-view.js";
2
3
  import { bindingWindow, usedFraction } from "../limits/index.js";
3
4
  /**
4
5
  * The limits panel (P9): what this credential may spend, and how much is left.
@@ -20,6 +21,13 @@ import { bindingWindow, usedFraction } from "../limits/index.js";
20
21
  * the fraction the bar fills IS the fraction the next request is judged against.
21
22
  * A bar is the right claim for a real measurement, and only for one.
22
23
  *
24
+ * WHAT IT NO LONGER DECIDES ALONE (cli#138). Which facts a bucket may state, and
25
+ * the words for the ones that are sentences, now come from
26
+ * `render/limits-view.ts` — because `cruxy limits` states the same facts outside
27
+ * a session and two copies of "no pool cap" are two places it can be softened.
28
+ * What stays here is everything that is a WIDTH decision: one bar rather than
29
+ * two, which window earns it, and the labels that fit 24 columns.
30
+ *
23
31
  * The corollary is the rule the rest of this file exists to keep: NO CAP, NO
24
32
  * FRACTION. An enterprise pool reports `{enforced: false}` and a developer key
25
33
  * reports `metered: true`; neither has an allowance, so neither gets a bar, a
@@ -39,14 +47,15 @@ export function bar(theme, fraction, cells = BAR_CELLS) {
39
47
  theme.glyph.barEmpty.repeat(cells - filled));
40
48
  }
41
49
  /**
42
- * The styler for a window's state. `low` and `blocked` are the two the user can
50
+ * The styler for a window's tone. `warn` and `danger` are the two the user can
43
51
  * act on, and they are different acts — one is "wrap up", the other is "you are
44
- * already being refused" — so they are not merged into one warning.
52
+ * already being refused" — so they are not merged into one warning. The tone
53
+ * itself is resolved once, in the shared builder.
45
54
  */
46
- function stateStyle(theme, state) {
47
- if (state === "blocked")
55
+ function toneStyle(theme, tone) {
56
+ if (tone === "danger")
48
57
  return theme.danger;
49
- if (state === "low")
58
+ if (tone === "warn")
50
59
  return theme.warning;
51
60
  return theme.strong;
52
61
  }
@@ -108,22 +117,31 @@ const STALE_AFTER_MS = 5 * 60_000;
108
117
  * tokens, so it must take the smaller REMAINDER, which is sometimes the other
109
118
  * window. `limits/reduce.ts` works the disagreement through.
110
119
  */
111
- function poolLines(theme, pool, now) {
120
+ function poolLines(theme, summary, now) {
121
+ const pool = summary.budget;
122
+ if (pool.kind !== "pool")
123
+ return [];
112
124
  const binding = bindingWindow(pool.monthly, pool.burst);
113
125
  // Unreachable via `reduceLimits` (a pool with no readable window reduces to
114
126
  // `unknown`), but the type permits it and inventing a bar is the one thing
115
- // this panel must never do on the way to being defensive.
127
+ // this panel must never do on the way to being defensive. The words are the
128
+ // builder's, so `cruxy limits` says the same thing about the same absence.
116
129
  if (!binding)
117
- return [theme.muted("figures not reported")];
130
+ return [theme.muted(summary.noAllowance ?? "figures not reported")];
118
131
  const { window: w, name } = binding;
119
132
  const fraction = usedFraction(w) ?? 0;
120
- const style = stateStyle(theme, w.state ?? pool.state);
133
+ // The tone the shared builder resolved for this window, found by key rather
134
+ // than recomputed — one place decides what `low` and `blocked` mean.
135
+ const tone = summary.windows.find((m) => m.key === name)?.tone ?? "normal";
136
+ const style = toneStyle(theme, tone);
121
137
  const lines = [
122
138
  `${style(bar(theme, fraction))} ${style(pct(fraction))}`,
123
139
  `${compactTokens(w.used)} / ${compactTokens(w.cap)} ${name}`,
124
140
  ];
125
141
  // The window that is NOT binding, so both dimensions are visible — a user
126
- // whose burst is tight still needs to know the month is nearly gone too.
142
+ // whose burst is tight still needs to know the month is nearly gone too. The
143
+ // rail shows it as a bare percentage because it has room for one bar; `cruxy
144
+ // limits` draws both in full, which is the whole reason layout stayed here.
127
145
  const other = name === "burst" ? pool.monthly : pool.burst;
128
146
  const otherName = name === "burst" ? "month" : "burst";
129
147
  const until = untilLabel(w.resetsAt, now);
@@ -139,6 +157,8 @@ function poolLines(theme, pool, now) {
139
157
  // Only when it is actionable. A blocked model list is the answer to "why did
140
158
  // that request fail"; mira's remaining requests are the answer to "what can I
141
159
  // still run" — and neither question is being asked while the pool is healthy.
160
+ // A WIDTH decision, not a shape one: `cruxy limits` has room to state both
161
+ // whenever they exist, and does.
142
162
  if (pool.blockedModels.length > 0) {
143
163
  lines.push(theme.danger(`no ${pool.blockedModels.join(" ")}`));
144
164
  }
@@ -161,27 +181,26 @@ function agoLabel(ms) {
161
181
  }
162
182
  /** The body for a ready reading, chosen by the budget shape. */
163
183
  function readingLines(theme, reading, now) {
184
+ const summary = buildLimitsSummary(reading, now);
164
185
  const lines = [];
165
- const budget = reading.budget;
166
- switch (budget.kind) {
186
+ switch (summary.budget.kind) {
167
187
  case "pool":
168
- lines.push(...poolLines(theme, budget, now));
188
+ lines.push(...poolLines(theme, summary, now));
169
189
  break;
170
190
  // Enterprise. NO fraction, and no consolation bar drawn at 0% either: a full
171
191
  // green bar and "no limit" are read the same way at a glance, and only one
172
192
  // of them is true.
173
193
  case "unenforced":
174
- lines.push(theme.strong(reading.tier));
175
- lines.push(theme.muted("pool not enforced"));
194
+ lines.push(theme.strong(summary.tier));
176
195
  break;
177
196
  // A developer key: billed per token, gated by nothing it draws down. Saying
178
197
  // "no pool" explicitly matters here — an empty budget section would read as
179
198
  // a figure that failed to load rather than as a figure that does not exist.
180
199
  case "metered":
181
- lines.push(`${theme.strong(reading.tier)}${theme.muted(" metered")}`);
182
- lines.push(theme.muted("no pool cap"));
200
+ lines.push(`${theme.strong(summary.tier)}${theme.muted(" metered")}`);
183
201
  break;
184
202
  case "credits": {
203
+ const budget = summary.budget;
185
204
  const fraction = budget.granted > 0
186
205
  ? Math.min(1, Math.max(0, budget.used / budget.granted))
187
206
  : undefined;
@@ -199,30 +218,34 @@ function readingLines(theme, reading, now) {
199
218
  // facts it DOES have are real and server-resolved, so both are shown; what
200
219
  // it does not have, it declines to imply.
201
220
  case "unknown":
202
- lines.push(`${theme.strong(reading.tier)}${theme.muted(` ${theme.glyph.sep} ${reading.bucket}`)}`);
203
- lines.push(theme.muted("budget not reported"));
221
+ lines.push(`${theme.strong(summary.tier)}${theme.muted(` ${theme.glyph.sep} ${summary.bucket}`)}`);
204
222
  break;
205
223
  }
224
+ // Why there is nothing to draw, in the builder's words. Skipped for `pool`,
225
+ // which either drew something or already said so itself.
226
+ if (summary.budget.kind !== "pool" && summary.noAllowance) {
227
+ lines.push(theme.muted(summary.noAllowance));
228
+ }
229
+ // What this credential could have and does not (cli#263) — an offer, so it
230
+ // reads as availability to the `--paste` user who is on an apikey on purpose
231
+ // and can correctly ignore it.
232
+ for (const note of summary.notes)
233
+ lines.push(theme.muted(note));
206
234
  // Rate and spend caps hang off every bucket, so they are appended once here
207
235
  // rather than repeated per branch. They are the ONLY ceilings a metered key
208
236
  // has — and they appear only when they exist, which is why they can never
209
237
  // stand in for the pool a metered key does not have.
210
- if (budget.kind === "metered" && reading.chat) {
211
- const { remaining, limit } = reading.chat.perKey;
212
- lines.push(theme.muted(`${remaining} / ${limit} rpm`));
213
- }
214
- if (reading.keySpendCap) {
215
- lines.push(spendCapLine(theme, "key", reading.keySpendCap));
238
+ if (summary.rpm) {
239
+ lines.push(theme.muted(`${summary.rpm.remaining} / ${summary.rpm.limit} rpm`));
216
240
  }
217
- if (reading.workspaceSpendCap) {
218
- lines.push(spendCapLine(theme, "ws", reading.workspaceSpendCap));
241
+ for (const { label, cap } of summary.spendCaps) {
242
+ lines.push(spendCapLine(theme, label, cap));
219
243
  }
220
244
  // The cache keeps the last good reading through a failed refresh, so the panel
221
245
  // owes the user the age of what it is showing rather than the impression that
222
246
  // it is live.
223
- const age = now - reading.readAt;
224
- if (age > STALE_AFTER_MS) {
225
- lines.push(theme.muted(`as of ${agoLabel(age)} ago`));
247
+ if (summary.ageMs > STALE_AFTER_MS) {
248
+ lines.push(theme.muted(`as of ${agoLabel(summary.ageMs)} ago`));
226
249
  }
227
250
  return lines;
228
251
  }
@@ -30,9 +30,23 @@ export function accumulateCacheTokens(acc, ev) {
30
30
  const systemClock = () => new Date().toISOString();
31
31
  export class UsageCollector {
32
32
  now;
33
+ origin;
33
34
  entries = [];
34
- constructor(now = systemClock) {
35
+ /**
36
+ * `origin` is the DEFAULT stamped on entries that don't carry one of their own
37
+ * (cli#244) — the run this collector belongs to, e.g. `"turn"` for a turn's
38
+ * collector. A per-request `origin` always wins, because one collector can
39
+ * legitimately hold requests of two kinds: a turn's collector receives its own
40
+ * loop's requests AND the requests of every subagent that turn spawned, and
41
+ * those must not read as the same thing.
42
+ *
43
+ * Left unset the entries carry no origin at all — which is what an older
44
+ * build's entries look like, and is the honest shape for a collector that
45
+ * genuinely doesn't know.
46
+ */
47
+ constructor(now = systemClock, origin) {
35
48
  this.now = now;
49
+ this.origin = origin;
36
50
  }
37
51
  /**
38
52
  * Record one completed request. When `req.usage` is absent the entry's token
@@ -41,6 +55,7 @@ export class UsageCollector {
41
55
  */
42
56
  record(req) {
43
57
  const u = req.usage;
58
+ const origin = req.origin ?? this.origin;
44
59
  this.entries.push({
45
60
  tier: req.tier,
46
61
  inputTokens: u?.input_tokens,
@@ -75,6 +90,10 @@ export class UsageCollector {
75
90
  ...(req.reasoningEffort !== undefined
76
91
  ? { reasoningEffort: req.reasoningEffort }
77
92
  : {}),
93
+ // Per-request first, collector default second, ABSENT third — absence is
94
+ // a real state (an older build's entries), so an unset default writes no
95
+ // key rather than a placeholder that groups would then count.
96
+ ...(origin !== undefined ? { origin } : {}),
78
97
  at: this.now(),
79
98
  });
80
99
  }
@@ -29,6 +29,10 @@ export function summarizeRuns(runs) {
29
29
  let requestsWithoutUsage = 0;
30
30
  let requestsWithoutWeight = 0;
31
31
  let requestsWithoutCost = 0;
32
+ // Keyed by the entry's own origin, and entries WITHOUT one are counted nowhere
33
+ // (cli#244). Defaulting them to `turn` would relabel every entry an older
34
+ // build wrote as something the file never said it was.
35
+ const requestsByOrigin = {};
32
36
  // Every distinct currency the gateway stated. Summing across two of them would
33
37
  // require an exchange rate we don't have and would never be told, so the set
34
38
  // is kept and a >1 outcome withholds the total rather than guessing.
@@ -37,6 +41,9 @@ export function summarizeRuns(runs) {
37
41
  for (const run of runs) {
38
42
  for (const e of run.entries) {
39
43
  requests++;
44
+ if (e.origin !== undefined) {
45
+ requestsByOrigin[e.origin] = (requestsByOrigin[e.origin] ?? 0) + 1;
46
+ }
40
47
  const known = e.inputTokens !== undefined || e.outputTokens !== undefined;
41
48
  if (!known)
42
49
  requestsWithoutUsage++;
@@ -114,6 +121,7 @@ export function summarizeRuns(runs) {
114
121
  requestsWithoutCost,
115
122
  requestsWithoutUsage,
116
123
  requests,
124
+ requestsByOrigin,
117
125
  runCount: runs.length,
118
126
  };
119
127
  }
@@ -210,10 +218,49 @@ export function renderSummary(summary, t) {
210
218
  if (summary.totalWeightedTokens !== undefined && weightShortfall > 0) {
211
219
  parts.push(t.muted(`${weightShortfall} not weighted`));
212
220
  }
221
+ //
222
+ // Cost has a THIRD case the other two don't: total absence. A partial
223
+ // shortfall explains itself (`2 without cost` beside a figure), but when NO
224
+ // request carries one there is no figure to hang the count on and the cost
225
+ // segment simply isn't emitted — so cost vanished with nothing said (cli#248).
226
+ //
227
+ // Gated on `requestsWithoutCost === requests`, NOT on `totalCost === undefined`.
228
+ // Mixed currencies ALSO withhold the total (see `summarizeRuns`) while costs
229
+ // genuinely exist, so keying on the total would print "cost not reported" next
230
+ // to "cost omitted: mixed currencies" — two contradictory claims about the same
231
+ // requests. The request count is the fact; the total is a rendering of it.
232
+ //
233
+ // The subtraction below is load-bearing in both branches: it is what stops this
234
+ // from restating `requestsWithoutUsage`. A request that reported nothing at all
235
+ // was already explained above, and is not separately news for having also
236
+ // carried no cost — so a store where every request reported nothing says
237
+ // nothing here, rather than saying it twice.
213
238
  const costShortfall = summary.requestsWithoutCost - summary.requestsWithoutUsage;
214
- if (summary.totalCost !== undefined && costShortfall > 0) {
239
+ if (summary.requestsWithoutCost === summary.requests) {
240
+ // No count: it is every request, and a number here would read as a partial.
241
+ if (costShortfall > 0)
242
+ parts.push(t.muted("cost not reported"));
243
+ }
244
+ else if (summary.totalCost !== undefined && costShortfall > 0) {
215
245
  parts.push(t.muted(`${costShortfall} without cost`));
216
246
  }
247
+ // Delegated + between-turns spend, broken out (cli#244). Folding a subagent's
248
+ // requests into its spawning turn's record is what makes `/usage` agree with
249
+ // `/budget`, but it also means `requests` stops matching "requests this
250
+ // conversation made" as the user counts them — three children at the ceiling
251
+ // can be most of the line. Naming the non-`turn` origins is what keeps the
252
+ // bigger number legible instead of merely bigger.
253
+ //
254
+ // `turn` is omitted deliberately: it is the unmarked case, and so are the
255
+ // entries carrying no origin at all (an older build's), which is why this
256
+ // reads `requestsByOrigin` rather than subtracting from `requests` — a
257
+ // subtraction would attribute every historical entry to whatever was left.
258
+ const delegated = Object.entries(summary.requestsByOrigin)
259
+ .filter(([origin]) => origin !== "turn")
260
+ .sort(([a], [b]) => a.localeCompare(b))
261
+ .map(([origin, n]) => `${origin} ${n}`);
262
+ if (delegated.length > 0)
263
+ parts.push(t.muted(delegated.join(", ")));
217
264
  // Two gateways quoting two currencies: refuse to add them, and say why rather
218
265
  // than just dropping the cost silently.
219
266
  if (summary.costMixedCurrency) {
@@ -110,6 +110,33 @@ export const UsageEntrySchema = z
110
110
  * gateway saying so writes the literal `"none"`.
111
111
  */
112
112
  reasoningEffort: z.string().optional(),
113
+ /**
114
+ * WHAT DROVE THIS REQUEST (cli#244). One of:
115
+ *
116
+ * - `"turn"` — the user's own turn: the main loop, its compaction, and (in
117
+ * plan mode) the propose + execution steps. The default for anything a
118
+ * turn's collector records without saying otherwise.
119
+ * - `"subagent"` — a delegated child. Folded into the SPAWNING TURN's
120
+ * record rather than getting one of its own, so `runCount` keeps meaning
121
+ * "turns" and a fan-out of three does not become four runs.
122
+ * - `"job"` — a background job. A job can start after the turn that
123
+ * launched it has returned, so it has no parent record to join and gets
124
+ * one of its own.
125
+ * - `"compact"` — a manually forced `/compact` (cli#254). Also between
126
+ * turns, also its own record. NOT `"turn"`: it is not one, and calling it
127
+ * one would make `runCount` count it as a turn the user never took.
128
+ *
129
+ * ABSENT MEANS "WRITTEN BY A BUILD BEFORE THIS", NOT "turn". Anything that
130
+ * groups by origin needs to leave unattributed entries out of every bucket
131
+ * rather than defaulting them into one — a historical entry relabelled as a
132
+ * turn is a claim the file never made. {@link UsageSummary.requestsByOrigin}
133
+ * does exactly that; see its note.
134
+ *
135
+ * A STRING, NOT A `z.enum`, for the reason {@link tier} and `routingMode`
136
+ * already are: an origin a future build introduces should render as itself
137
+ * here rather than fail the parse and cost the user the whole file.
138
+ */
139
+ origin: z.string().optional(),
113
140
  /** ISO-8601 timestamp the request completed. */
114
141
  at: z.string(),
115
142
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cruxy/cli",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "an agentic coding CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -36,7 +36,7 @@
36
36
  "undici": "^6.21.0",
37
37
  "zod": "^3.23.8",
38
38
  "zod-to-json-schema": "^3.23.5",
39
- "@cruxy/sdk": "0.7.0"
39
+ "@cruxy/sdk": "0.8.0"
40
40
  },
41
41
  "optionalDependencies": {
42
42
  "better-sqlite3": "^12.11.1"