@cruxy/cli 1.8.1 → 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 (47) 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/login.js +18 -5
  11. package/dist/cli/commands/pr.js +10 -1
  12. package/dist/cli/commands/rollback.js +10 -2
  13. package/dist/cli/commands/run.js +55 -5
  14. package/dist/cli/commands/sessions.js +156 -0
  15. package/dist/cli/program.js +4 -0
  16. package/dist/cli/repl.js +25 -0
  17. package/dist/cli/session-factory.js +31 -10
  18. package/dist/config/credential-lifetime.js +42 -0
  19. package/dist/config/credentials.js +66 -0
  20. package/dist/config/schema.js +141 -9
  21. package/dist/constants.js +12 -2
  22. package/dist/errors/boundary.js +4 -4
  23. package/dist/errors/constructors.js +136 -57
  24. package/dist/errors/types.js +18 -0
  25. package/dist/index.js +27 -1
  26. package/dist/jobs/manager.js +269 -17
  27. package/dist/limits/cache.js +21 -5
  28. package/dist/mcp/client.js +16 -0
  29. package/dist/onboarding/flow.js +121 -6
  30. package/dist/onboarding/steps.js +112 -0
  31. package/dist/render/limits-report.js +213 -0
  32. package/dist/render/limits-view.js +125 -0
  33. package/dist/sandbox/service.js +9 -0
  34. package/dist/sandbox/types.js +15 -0
  35. package/dist/session/index.js +3 -1
  36. package/dist/session/list.js +20 -6
  37. package/dist/session/log.js +120 -21
  38. package/dist/session/prune.js +106 -0
  39. package/dist/session/resume.js +5 -0
  40. package/dist/subagent/orchestrator.js +71 -31
  41. package/dist/subagent/spawn-tool.js +11 -4
  42. package/dist/tools/schema-depth.js +18 -0
  43. package/dist/tui/limits-panel.js +62 -30
  44. package/dist/usage/collect.js +20 -1
  45. package/dist/usage/summary.js +48 -1
  46. package/dist/usage/types.js +27 -0
  47. package/package.json +2 -2
@@ -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
  }
@@ -241,6 +264,15 @@ export function limitsPanelLines(theme, state, now = Date.now()) {
241
264
  switch (state.reason) {
242
265
  case "unauthenticated":
243
266
  return [theme.muted("not signed in"), theme.muted("run cruxy login")];
267
+ case "expired":
268
+ // The same remedy as "not signed in", but a different fact — and the
269
+ // fact is the point. "Sign-in expired" tells someone their setup was
270
+ // right and simply aged out; "not signed in" invites them to go looking
271
+ // for what they configured wrong.
272
+ return [
273
+ theme.warning("sign-in expired"),
274
+ theme.muted("run cruxy login"),
275
+ ];
244
276
  case "unsupported":
245
277
  // The gateway answered — it simply has no limits to report. Sending this
246
278
  // user to debug their network would be the wrong errand entirely.
@@ -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.8.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.6.0"
39
+ "@cruxy/sdk": "0.8.0"
40
40
  },
41
41
  "optionalDependencies": {
42
42
  "better-sqlite3": "^12.11.1"