@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.
- package/README.md +1 -1
- package/dist/agent/loop.js +16 -1
- package/dist/agent/session.js +62 -9
- package/dist/approval/classify.js +170 -40
- package/dist/approval/prompt.js +52 -6
- package/dist/approval/service.js +1 -11
- package/dist/budget/session-budget.js +10 -1
- package/dist/checkpoint/coverage.js +147 -4
- package/dist/cli/commands/limits.js +76 -0
- package/dist/cli/commands/pr.js +10 -1
- package/dist/cli/commands/rollback.js +10 -2
- package/dist/cli/commands/run.js +19 -3
- package/dist/cli/commands/sessions.js +156 -0
- package/dist/cli/program.js +4 -0
- package/dist/cli/repl.js +25 -0
- package/dist/cli/session-factory.js +31 -10
- package/dist/config/schema.js +141 -9
- package/dist/constants.js +12 -2
- package/dist/errors/constructors.js +49 -44
- package/dist/jobs/manager.js +269 -17
- package/dist/mcp/client.js +16 -0
- package/dist/render/limits-report.js +213 -0
- package/dist/render/limits-view.js +125 -0
- package/dist/sandbox/service.js +9 -0
- package/dist/sandbox/types.js +15 -0
- package/dist/session/index.js +3 -1
- package/dist/session/list.js +20 -6
- package/dist/session/log.js +120 -21
- package/dist/session/prune.js +106 -0
- package/dist/session/resume.js +5 -0
- package/dist/subagent/orchestrator.js +71 -31
- package/dist/subagent/spawn-tool.js +11 -4
- package/dist/tools/schema-depth.js +18 -0
- package/dist/tui/limits-panel.js +53 -30
- package/dist/usage/collect.js +20 -1
- package/dist/usage/summary.js +48 -1
- package/dist/usage/types.js +27 -0
- 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 {
|
|
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
|
-
|
|
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
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
420
|
+
admitRuns(count, specs) {
|
|
365
421
|
const budget = this.deps.budget;
|
|
366
422
|
if (!budget)
|
|
367
|
-
return { kind: "allow", count
|
|
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
|
|
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
|
package/dist/tui/limits-panel.js
CHANGED
|
@@ -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
|
|
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
|
|
47
|
-
if (
|
|
55
|
+
function toneStyle(theme, tone) {
|
|
56
|
+
if (tone === "danger")
|
|
48
57
|
return theme.danger;
|
|
49
|
-
if (
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
switch (budget.kind) {
|
|
186
|
+
switch (summary.budget.kind) {
|
|
167
187
|
case "pool":
|
|
168
|
-
lines.push(...poolLines(theme,
|
|
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(
|
|
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(
|
|
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(
|
|
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 (
|
|
211
|
-
|
|
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
|
-
|
|
218
|
-
lines.push(spendCapLine(theme,
|
|
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
|
-
|
|
224
|
-
|
|
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
|
}
|
package/dist/usage/collect.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/usage/summary.js
CHANGED
|
@@ -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.
|
|
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) {
|
package/dist/usage/types.js
CHANGED
|
@@ -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.
|
|
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.
|
|
39
|
+
"@cruxy/sdk": "0.8.0"
|
|
40
40
|
},
|
|
41
41
|
"optionalDependencies": {
|
|
42
42
|
"better-sqlite3": "^12.11.1"
|