@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,7 +1,11 @@
1
1
  import { runAgent } from "../agent/loop.js";
2
- import { ApprovalService, classify, serializeGate, } from "../approval/index.js";
2
+ import { ApprovalService, classify, InteractivePolicy, serializeGate, SessionAllowlist, } from "../approval/index.js";
3
3
  import { CheckpointGate, withCheckpointGate } from "../checkpoint/index.js";
4
- import { CruxyError, ErrorCode, approvalRequired, jobLimitExceeded, jobNotFound, jobsDisabled, messageOf, } from "../errors/index.js";
4
+ import { compactTokens } from "../render/units.js";
5
+ import { CruxyError, ErrorCode, approvalRequired, jobLimitExceeded, jobNotFound, jobsDisabled, messageOf, poolDenial, sessionBudgetExhausted, } from "../errors/index.js";
6
+ import { resolveTaskModel } from "../routing/index.js";
7
+ import { UNRESOLVED_TIER } from "../budget/index.js";
8
+ import { UsageCollector } from "../usage/index.js";
5
9
  import { Budget, resolveBudget } from "../agent/budget.js";
6
10
  import { scopeRegistry } from "../subagent/index.js";
7
11
  import { Workspace } from "../workspace/index.js";
@@ -10,6 +14,33 @@ import { JobLogRenderer } from "./log-renderer.js";
10
14
  import { isTerminal } from "./types.js";
11
15
  /** Longest task excerpt kept as a job label (display, not record). */
12
16
  const LABEL_MAX = 60;
17
+ /**
18
+ * THE ADMISSION FLOOR, in whole requests' worth of fixed payload (cli#245).
19
+ *
20
+ * `Budget.exceeded` is checked BEFORE each model turn against the run's
21
+ * cumulative tokens, so a job's first request always goes out whatever its
22
+ * ceiling is: at iteration 0 the usage is zero and no cap can trip. A ceiling
23
+ * the pool narrowed down to a few hundred tokens therefore buys exactly one
24
+ * request — the system prompt and the whole tool catalogue re-sent, real
25
+ * weighted tokens drawn from a window that is already refusing — and then stops
26
+ * the run at the next boundary with `token cap reached`, which `statusFor` maps
27
+ * to `failed`. The job dispatches, the model is told it started, and it dies a
28
+ * second later having produced nothing and spent something.
29
+ *
30
+ * TWO, not one, and `context.reserveTokens` (4500 by default) is the unit
31
+ * because it is the figure this repo already keeps for exactly this quantity:
32
+ * "the built system prompt plus the default tool catalogue". One request's worth
33
+ * buys a job a single model turn it cannot act on — it can call a tool and never
34
+ * see the result. Two is the smallest ceiling under which a job can complete one
35
+ * act-and-report cycle, which is the least a background job can do and still be
36
+ * worth the tokens. It is a FLOOR and not a promise: input is re-sent every
37
+ * turn, so two turns cost more than twice the fixed payload.
38
+ *
39
+ * Derived from config rather than a literal, so it tracks the prompt: a build
40
+ * whose catalogue grows raises the floor with it. A config that sets
41
+ * `reserveTokens` to 0 states that there is no fixed payload, and floors nothing.
42
+ */
43
+ const MIN_JOB_REQUESTS = 2;
13
44
  /**
14
45
  * Session-scoped background jobs (C.28). The main agent dispatches a job with
15
46
  * `run_in_background`; it runs the SAME agent loop as a subagent but CONCURRENTLY
@@ -32,6 +63,8 @@ export class JobManager {
32
63
  idFactory;
33
64
  now;
34
65
  runAgentFn;
66
+ /** The last pool denial a job died on, until the foreground takes it. */
67
+ poolDenied;
35
68
  seq = 0;
36
69
  constructor(deps) {
37
70
  this.deps = deps;
@@ -43,9 +76,21 @@ export class JobManager {
43
76
  }
44
77
  /**
45
78
  * Dispatch a background job (the `run_in_background` seam). Fails loud when the
46
- * feature is disabled, when the live-job ceiling is reached, or when a named
47
- * root is unknown all BEFORE the job is registered, so a refused dispatch
48
- * leaves no ghost. Returns a view immediately; the job runs asynchronously.
79
+ * feature is disabled, when the live-job ceiling is reached, when a named
80
+ * root is unknown, or when the session's weighted budget will not cover the
81
+ * job all BEFORE the job is registered, so a refused dispatch leaves no
82
+ * ghost. Returns a view immediately; the job runs asynchronously.
83
+ *
84
+ * ADMISSION HAPPENS HERE, NOT IN `execute` (cli#245). A queued job can sit
85
+ * behind the shared semaphore for a long time, so "does it fit now" and "does
86
+ * it fit when it runs" are genuinely different questions when the pool is
87
+ * moving — and this is the only one of the two the model can be told the
88
+ * answer to. `dispatch` is synchronous and already throws three coded
89
+ * refusals that `dispatch-tool.ts` converts into a tool error; a budget
90
+ * refusal is that same shape, needing no new mechanism. `execute` has no
91
+ * error channel back to the model at all — only `job.status`, read by a human
92
+ * later — and by then the turn that dispatched has long returned, having
93
+ * already been told the job started.
49
94
  */
50
95
  dispatch(spec) {
51
96
  const { config } = this.deps;
@@ -58,7 +103,13 @@ export class JobManager {
58
103
  // Resolve (and validate) the job's scope up front — an unknown root name
59
104
  // throws CRUXY_E_ROOT_UNKNOWN here, which the dispatch tool surfaces to the
60
105
  // model, rather than failing silently inside the background run.
106
+ //
107
+ // Before the budget check, deliberately, and for the reason the fan-out
108
+ // seam orders its two checks the same way: a job naming a root that does
109
+ // not exist is MALFORMED, and answering it with "your budget is used up"
110
+ // would report a ceiling instead of the typo.
61
111
  const scope = this.jobScope(spec.root);
112
+ const limits = this.admit(spec);
62
113
  const id = this.idFactory();
63
114
  const checkpoints = config.checkpoint.enabled
64
115
  ? new CheckpointGate({
@@ -78,6 +129,7 @@ export class JobManager {
78
129
  controller: new AbortController(),
79
130
  checkpoints,
80
131
  scope,
132
+ limits,
81
133
  holdsSlot: false,
82
134
  };
83
135
  this.jobs.set(id, job);
@@ -177,6 +229,14 @@ export class JobManager {
177
229
  /** Drive one job to completion, mapping every outcome onto its status. */
178
230
  async execute(job) {
179
231
  const signal = job.controller.signal;
232
+ // Per-request usage for THIS job, collected exactly as the parent's turn and
233
+ // a subagent collect their own. Needed rather than `result.usage` for two
234
+ // reasons: the weighted arithmetic is per-tier, and a run that THREW has no
235
+ // result at all while still having spent whatever it spent before it died.
236
+ // Origin `"job"` on every entry (cli#244): this record is published to the
237
+ // store in its own right, so its entries have to say what they were.
238
+ const usage = new UsageCollector(undefined, "job");
239
+ const startedAt = new Date().toISOString();
180
240
  try {
181
241
  // Wait for an execution permit — a queued job holds none until a slot frees
182
242
  // (the shared cap, contended with subagents). This is the queued→running
@@ -192,7 +252,7 @@ export class JobManager {
192
252
  // NEVER again (a pause is not a checkpoint boundary), so pre- and post-pause
193
253
  // mutations coalesce and `cruxy rollback <id>` reverts the whole job.
194
254
  job.checkpoints?.beginRun(job.spec.task, job.id);
195
- const result = await this.runAgentFn(this.runArgs(job, signal));
255
+ const result = await this.runAgentFn(this.runArgs(job, signal, usage));
196
256
  job.iterations = result.iterations;
197
257
  job.usage = result.usage;
198
258
  job.summary = lastAssistantText(result.messages);
@@ -213,8 +273,31 @@ export class JobManager {
213
273
  job.error = `${err.code}: ${err.title}`;
214
274
  }
215
275
  else {
276
+ // THE WEIGHTED POOL REFUSED THIS JOB (429 `budget_exhausted`), and it is
277
+ // not a per-job outcome (cli#245). Two things used to be lost here, and
278
+ // they are the same two the fan-out seam recovered in cli#243.
279
+ //
280
+ // First the FACTS: `messageOf` flattened the typed error to a string, so
281
+ // `window`, `resetAt` and `miraAvailable` went with it — the last of
282
+ // which is the only one that unblocks someone now rather than telling
283
+ // them when to come back — and a job that died on a 429 read like any
284
+ // other failure in `/jobs`.
285
+ //
286
+ // Second the AUDIENCE. A pool denial is a statement about a denominator
287
+ // this job shares with the foreground turn, so the foreground is
288
+ // entitled to it: without the latch the job dies quietly off-screen and
289
+ // the user walks into their own refusal on the next turn, having been
290
+ // told nothing. It is the same "N denials for one fact" argument #243
291
+ // made across siblings, carried across the job/foreground boundary.
292
+ const denial = poolDenial(err);
216
293
  job.status = "failed";
217
- job.error = `${ErrorCode.SubagentFailed}: ${messageOf(err) ?? "unknown error"}`;
294
+ job.error = denial
295
+ ? denialLine(denial)
296
+ : `${ErrorCode.SubagentFailed}: ${messageOf(err) ?? "unknown error"}`;
297
+ if (denial) {
298
+ this.poolDenied = denial;
299
+ this.abortSiblings(job, denial);
300
+ }
218
301
  }
219
302
  }
220
303
  finally {
@@ -222,21 +305,90 @@ export class JobManager {
222
305
  this.deps.semaphore.release();
223
306
  job.holdsSlot = false;
224
307
  }
308
+ // JOB SPEND IS SESSION SPEND (cli#245), and in the `finally` because the
309
+ // tokens are spent on every path out of here — a job that failed, was
310
+ // cancelled at session exit, or died on a 429 still drew whatever it drew
311
+ // before it stopped. Without this the admission check above reads a
312
+ // numerator its own dispatches never move, and a job running across an
313
+ // idle period stays invisible to `/budget` for the rest of the session.
314
+ //
315
+ // AND NOW ALSO WRITTEN TO THE USAGE STORE (cli#244 answered the question
316
+ // this call site used to defer). One record, handed to both sinks, so
317
+ // `/budget` and `/usage` can never disagree about what this job drew.
318
+ //
319
+ // `job.id` is the run id — stable, already unique, and the thing the user
320
+ // saw in `/jobs`, so a record in the store can be traced back to the job
321
+ // that made it rather than to an anonymous UUID.
322
+ //
323
+ // The session id is read HERE rather than at construction: see the note on
324
+ // `sessionId`. Without it the record persists and then hides from every
325
+ // session-scoped read of the very store it is in.
326
+ const record = usage.toRecord(job.id, this.deps.sessionId?.(), startedAt);
327
+ this.deps.budget?.record(record);
328
+ // Empty records are not published. A job that was cancelled before it
329
+ // reached the model spent nothing, and a run of no requests is not a run —
330
+ // it would only pad `runCount` and shorten retention for nothing.
331
+ if (record.entries.length > 0)
332
+ this.deps.onRunUsage?.(record);
225
333
  this.log(job, "out", `job ${job.status}`);
226
334
  }
227
335
  }
336
+ /**
337
+ * The pool denial a job died on, handed over ONCE (cli#245).
338
+ *
339
+ * A latch rather than a push: the foreground drains it where it is idle —
340
+ * between turns, next to the pending-approval drain — so a background failure
341
+ * never writes into a live region a turn is painting, and never lands
342
+ * mid-stream in a shell that is reading a line.
343
+ *
344
+ * The newest denial wins. Two jobs refused by one exhausted window are one
345
+ * fact, and printing it twice is the flood #243 aborted a batch to avoid.
346
+ */
347
+ takePoolDenial() {
348
+ const denial = this.poolDenied;
349
+ this.poolDenied = undefined;
350
+ return denial;
351
+ }
352
+ /**
353
+ * ONE POOL DENIAL STOPS EVERY OTHER LIVE JOB (cli#245) — the batch abort #243
354
+ * made across siblings, carried to the job seam, because the argument is
355
+ * identical and the denominator is literally the same one.
356
+ *
357
+ * A 429 is not a fact about the job that received it. It is a fact about a
358
+ * window every live job draws on, so the other jobs are not in a different
359
+ * situation — they are in the same one, a few seconds behind. Left running,
360
+ * each spends its way to its own refusal: three concurrent jobs discovering
361
+ * one exhausted window produce three failed jobs, three wasted partial runs,
362
+ * and three denials for a single fact. Aborting makes the FIRST denial the
363
+ * session's answer, which is what {@link takePoolDenial} then reports once.
364
+ *
365
+ * `abort` and not a status write, so a job stops at its next TURN boundary
366
+ * exactly as `cruxy cancel` stops it: the in-flight request completes,
367
+ * whatever checkpoint it took survives for rollback, and its slot is released
368
+ * through the same `finally`. Aborted jobs land on the `signal.aborted` branch
369
+ * above and end `cancelled` — honest, and distinct from the one job that
370
+ * actually was refused, which ends `failed` carrying the denial. They do not
371
+ * re-latch: the one denial stays the one denial.
372
+ *
373
+ * Queued jobs are aborted too. A job still waiting for a slot has spent
374
+ * nothing yet, and dispatching it into a window that just refused its sibling
375
+ * is the clearest waste of the set.
376
+ */
377
+ abortSiblings(source, denial) {
378
+ for (const job of this.jobs.values()) {
379
+ if (job === source || isTerminal(job.status))
380
+ continue;
381
+ this.log(job, "out", `cancelling: ${denial.code} — job ${source.id} was refused by the weighted pool`);
382
+ this.abort(job);
383
+ }
384
+ }
228
385
  /** Build the {@link runAgent} args for a job: scoped registry, budget, ctx. */
229
- runArgs(job, signal) {
386
+ runArgs(job, signal, usage) {
230
387
  const { deps } = this;
231
388
  const registry = scopeRegistry(deps.parentRegistry, job.spec.tools);
232
- const budget = new Budget(resolveBudget(deps.config.subagent.defaultBudget, {
233
- ...(job.spec.budget?.maxIterations !== undefined
234
- ? { maxIterations: job.spec.budget.maxIterations }
235
- : {}),
236
- ...(job.spec.budget?.maxTokens !== undefined
237
- ? { maxTokens: job.spec.budget.maxTokens }
238
- : {}),
239
- }));
389
+ // The caps admission settled at dispatch — resolved there, not here, so a
390
+ // ceiling the budget cut is the ceiling the run actually executes under.
391
+ const budget = new Budget(job.limits);
240
392
  const ctx = {
241
393
  cwd: job.scope.cwd,
242
394
  workspace: job.scope.workspace,
@@ -262,6 +414,7 @@ export class JobManager {
262
414
  router: deps.router,
263
415
  taskClass: "subagent",
264
416
  signal,
417
+ onRequestUsage: (req) => usage.record(req),
265
418
  };
266
419
  }
267
420
  /**
@@ -277,10 +430,18 @@ export class JobManager {
277
430
  makeJobApproval(job) {
278
431
  const { approvalQueue, semaphore, approvalMutex, foregroundInteractive } = this.deps;
279
432
  const cwd = job.scope.cwd;
433
+ // THE session allowlist a job's grants actually accumulate in (cli#251).
434
+ // A job has no factory-built allowlist to share — a child gets the parent's
435
+ // MODE but not its grants (P5 track 3) — so "allow for this session" taken
436
+ // inside a job lands here and is spent by this policy alone, for this job's
437
+ // lifetime. The service used to build this line for us when no policy was
438
+ // passed, which meant the single call site where the allowlist is
439
+ // load-bearing looked identical to the ones where it is inert. Written out
440
+ // so the ownership is readable here rather than inferred from an absence.
280
441
  const interactive = new ApprovalService({
281
442
  cwd,
282
443
  interactive: foregroundInteractive,
283
- io: this.deps.promptIO,
444
+ policy: new InteractivePolicy(new SessionAllowlist(), this.deps.promptIO),
284
445
  });
285
446
  // The foreground-serviced decision: the interactive U.3 prompt, wrapped in
286
447
  // THIS job's checkpoint hook, serialized on the SHARED approval mutex — so a
@@ -321,6 +482,83 @@ export class JobManager {
321
482
  }
322
483
  };
323
484
  }
485
+ /**
486
+ * Ask the session budget whether this job fits, and return the caps it may run
487
+ * under (P10 track 3 / cli#245).
488
+ *
489
+ * THE VERDICT HAS ONLY TWO SHAPES HERE, because a job is dispatched ONE at a
490
+ * time. `spawnMany` can be told "three of your five fit" and hands the parent
491
+ * a positional `not-admitted` result for each of the two that did not; a
492
+ * single dispatch has no such slot and needs none — `admit` with `count: 1`
493
+ * either allows, or narrows to that one run with its token ceiling cut to
494
+ * what the allowance covers, or refuses outright — and a narrowed ceiling
495
+ * below the floor (see {@link MIN_JOB_REQUESTS}) collapses into that last
496
+ * one, because a job cannot use it.
497
+ *
498
+ * The refusal is a THROW, so it lands on `dispatch`'s existing coded-error
499
+ * path and reaches the model through the tool: the same treatment as
500
+ * `jobsDisabled` / `jobLimitExceeded` / an unknown root.
501
+ *
502
+ * Tier: a job routes on the `subagent` task class, exactly as a child does. An
503
+ * unresolved tier still draws on the pool, so it is weighed at the worst case
504
+ * rather than skipped; no router at all means a bring-your-own provider whose
505
+ * tokens never touch the weighted pool, and nothing here bounds them.
506
+ */
507
+ admit(spec) {
508
+ const { budget, config, logger } = this.deps;
509
+ const resolved = resolveBudget(config.subagent.defaultBudget, {
510
+ ...(spec.budget?.maxIterations !== undefined
511
+ ? { maxIterations: spec.budget.maxIterations }
512
+ : {}),
513
+ ...(spec.budget?.maxTokens !== undefined
514
+ ? { maxTokens: spec.budget.maxTokens }
515
+ : {}),
516
+ });
517
+ if (!budget)
518
+ return resolved;
519
+ const verdict = budget.admit({
520
+ count: 1,
521
+ perRunTokens: resolved.maxTokens,
522
+ tier: this.jobTier(),
523
+ });
524
+ if (verdict.kind === "refused")
525
+ throw sessionBudgetExhausted(verdict.reason);
526
+ if (verdict.kind === "allow" || verdict.maxTokens <= 0)
527
+ return resolved;
528
+ // A NARROWED CEILING BELOW THE FLOOR IS A REFUSAL (cli#245). `admit` narrows
529
+ // rather than refuses so that a user with real headroom is never left unable
530
+ // to ask anything at all, and for the turn that is exactly right: the user is
531
+ // present, sees the warning, and can shorten the question or raise
532
+ // `/budget`. A job has none of that. It runs after the turn returned, with
533
+ // nobody watching, on a task the model already wrote — so the same narrowing
534
+ // that keeps a turn useful hands a job a ceiling it cannot start under, and
535
+ // the honest answer is to say so at the seam where the model can still act
536
+ // on it rather than to dispatch a run that fails a second later.
537
+ //
538
+ // Refused HERE and not in the shared rule on purpose: `admit` is one object
539
+ // answering three callers, and only this one is unattended.
540
+ const floor = this.deps.config.context.reserveTokens * MIN_JOB_REQUESTS;
541
+ if (verdict.maxTokens < floor) {
542
+ throw sessionBudgetExhausted(`${verdict.reason}; a background job needs at least ` +
543
+ `${compactTokens(floor)} tokens to complete one act-and-report cycle, ` +
544
+ `so it was refused rather than dispatched to fail on its first turn`);
545
+ }
546
+ // Narrowed: the one run is admitted with a cut ceiling. `Math.min` because
547
+ // `admit` may only ever narrow — a granted figure above the resolved cap
548
+ // would raise a ceiling the config and the spec already set.
549
+ logger.warn(verdict.reason);
550
+ return {
551
+ ...resolved,
552
+ maxTokens: Math.min(resolved.maxTokens, verdict.maxTokens),
553
+ };
554
+ }
555
+ /** The tier a job would route to, or the unresolved/no-pool markers. */
556
+ jobTier() {
557
+ const router = this.deps.router;
558
+ if (!router)
559
+ return undefined; // not a cruxy request — no weighted pool
560
+ return resolveTaskModel(router, "subagent").tier ?? UNRESOLVED_TIER;
561
+ }
324
562
  /** Resolve a job's scope from an optional root name (fail-loud on unknown). */
325
563
  jobScope(rootName) {
326
564
  if (rootName === undefined) {
@@ -370,6 +608,20 @@ export class JobManager {
370
608
  };
371
609
  }
372
610
  }
611
+ /**
612
+ * A job's `error` line for a pool denial, with every fact the 429 carried.
613
+ *
614
+ * `job.error` is a string and a `JobView` is what `/jobs` and the Tasks view
615
+ * read, so the typed error cannot be handed over whole here — but its next
616
+ * steps are exactly where `miraAvailable` and `resetAt` were rendered into
617
+ * words, and dropping them is what made a denial indistinguishable from a
618
+ * crash. The typed object itself still survives, on the latch the foreground
619
+ * drains (see {@link JobManager.takePoolDenial}).
620
+ */
621
+ function denialLine(denial) {
622
+ const steps = denial.nextSteps.join("; ");
623
+ return `${denial.code}: ${denial.title}${steps ? ` — ${steps}` : ""}`;
624
+ }
373
625
  /** One-line task excerpt for a job label. */
374
626
  function taskLabel(task) {
375
627
  const flat = task.replace(/\s+/g, " ").trim();
@@ -10,6 +10,22 @@ import { APP_NAME, APP_VERSION } from "../constants.js";
10
10
  */
11
11
  /** The MCP protocol revision cruxy advertises. */
12
12
  const PROTOCOL_VERSION = "2025-06-18";
13
+ /**
14
+ * The three fields cruxy accepts from an advertised tool — and, by omission, the
15
+ * enforcement point for everything it refuses.
16
+ *
17
+ * `annotations` IS DELIBERATELY ABSENT. MCP lets a server describe its own tool
18
+ * with `readOnlyHint` / `destructiveHint` / `idempotentHint`, and zod strips
19
+ * unknown keys, so declaring only these three drops the block here at the wire
20
+ * boundary rather than carrying it inward for someone downstream to be tempted
21
+ * by. The spec's own rule is that clients "MUST consider tool annotations to be
22
+ * untrusted unless they come from trusted servers", and cruxy's MCP trust binds
23
+ * the INVOCATION rather than the code (`mcp/trust.ts`), so it cannot claim that
24
+ * exemption. Adding a field here would not merely widen a parse — it would put a
25
+ * server-controlled boolean within reach of the approval gate. The full argument
26
+ * is at `mcpRequest` in `approval/classify.ts`; `annotations-untrusted.test.ts`
27
+ * pins the property at both layers.
28
+ */
13
29
  const RawToolSchema = z.object({
14
30
  name: z.string().min(1),
15
31
  description: z.string().optional(),
@@ -0,0 +1,213 @@
1
+ import { compactTokens } from "./units.js";
2
+ import { buildLimitsSummary } from "./limits-view.js";
3
+ /**
4
+ * Rendering `cruxy limits` (cli#138) — the session-less half of the P9 reading.
5
+ *
6
+ * WHY A COMMAND AND NOT A COLUMN IN `cruxy usage`. The two answer questions with
7
+ * no common denominator, and joining them produces a number that reads as
8
+ * authoritative while being about two different things: `usage/store.ts` counts
9
+ * this machine's last FIFTY RUNS — a count, not a duration — while the pool is a
10
+ * calendar month and a trailing 12h window, already inclusive of every other
11
+ * surface on the account. The gap is not a rounding error. A credential minted
12
+ * seconds earlier, with zero local runs behind it, reported 63% of its month
13
+ * already drawn from web chat and desktop; any ratio over those two numerators
14
+ * would have been wrong by nearly its whole value on day one.
15
+ *
16
+ * So `cruxy usage` keeps its local-only guarantee and this command makes the
17
+ * call. THE COMMAND SPLIT IS THE BOUNDARY, and it is worth saying plainly that
18
+ * the no-phone-home guard is not: that guard scans a named list of files, this
19
+ * file is deliberately not on it, and a green run of it proves nothing whatever
20
+ * about a file it was never pointed at. What keeps `cruxy usage` local is that
21
+ * the network lives in a different command, not that a test noticed.
22
+ *
23
+ * IT NAMES THE WINDOW RATHER THAN THE ABSTRACTION. The rail picks one window
24
+ * because it has room for one bar, and `session-budget.ts` picks one because
25
+ * admission has to spend against one — both are right, and both are choices a
26
+ * SURFACE has to make. A command has room for neither excuse: it prints
27
+ * `monthly` and `12h burst` by name, in the vocabulary `/budget` already uses,
28
+ * because "the binding window" is a phrase that at Free tier (where
29
+ * `burst.cap === monthly.cap`) resolves to the same window essentially always —
30
+ * an abstraction that never varies, costing the reader the one fact they came
31
+ * for.
32
+ */
33
+ /** Bar width. Wider than the rail's twelve cells; a command has the columns. */
34
+ const BAR_CELLS = 24;
35
+ /** The label each window goes by here — see the module comment on naming. */
36
+ const WINDOW_LABEL = {
37
+ month: "monthly",
38
+ burst: "12h burst",
39
+ };
40
+ /** Widest label, so every row's bar starts in the same column. */
41
+ const LABEL_COLS = Math.max(...Object.values(WINDOW_LABEL).map((l) => l.length));
42
+ function bar(theme, fraction) {
43
+ const filled = Math.round(Math.min(1, Math.max(0, fraction)) * BAR_CELLS);
44
+ return (theme.glyph.barFilled.repeat(filled) +
45
+ theme.glyph.barEmpty.repeat(BAR_CELLS - filled));
46
+ }
47
+ /**
48
+ * A percentage, right-aligned to the width of "100%", so the figures beside it
49
+ * start in the same column on every row. A ragged column reads as two unrelated
50
+ * numbers rather than one comparison, which is the whole point of stacking the
51
+ * windows.
52
+ */
53
+ function pct(fraction) {
54
+ return `${Math.round(fraction * 100)}%`.padStart(4);
55
+ }
56
+ /** 27.77 → "$27.77", 29 → "$29". Same rule the panel uses. */
57
+ function usd(n) {
58
+ return Number.isInteger(n) ? `$${n}` : `$${n.toFixed(2)}`;
59
+ }
60
+ /** "in 18d" / "in 4h", or nothing at all when there is no reset to state. */
61
+ function resetLabel(iso, now) {
62
+ if (!iso)
63
+ return undefined;
64
+ const at = Date.parse(iso);
65
+ if (Number.isNaN(at))
66
+ return undefined;
67
+ const ms = at - now;
68
+ if (ms <= 0)
69
+ return "resets now";
70
+ const minutes = Math.floor(ms / 60_000);
71
+ if (minutes < 60)
72
+ return `resets in ${Math.max(1, minutes)}m`;
73
+ const hours = Math.floor(minutes / 60);
74
+ if (hours < 24)
75
+ return `resets in ${hours}h`;
76
+ return `resets in ${Math.floor(hours / 24)}d`;
77
+ }
78
+ /**
79
+ * How long ago a reading was taken, as a whole sentence.
80
+ *
81
+ * "just now" is a real case here and is not one for the rail: the panel only
82
+ * ever says this past a five-minute staleness threshold, while a command probes
83
+ * and prints in the same breath. Rounding that up to "1m ago" would age a
84
+ * reading by a minute it did not have.
85
+ */
86
+ function readAgeLine(ms) {
87
+ const minutes = Math.floor(ms / 60_000);
88
+ if (minutes < 1)
89
+ return "read just now";
90
+ if (minutes < 60)
91
+ return `read ${minutes}m ago`;
92
+ const hours = Math.floor(minutes / 60);
93
+ return hours < 24
94
+ ? `read ${hours}h ago`
95
+ : `read ${Math.floor(hours / 24)}d ago`;
96
+ }
97
+ /** One window: name, bar, percentage, the figures, and its reset if it has one. */
98
+ function windowLine(theme, w, now) {
99
+ const style = w.tone === "danger"
100
+ ? theme.danger
101
+ : w.tone === "warn"
102
+ ? theme.warning
103
+ : theme.strong;
104
+ const label = WINDOW_LABEL[w.key].padEnd(LABEL_COLS);
105
+ const figures = `${compactTokens(w.window.used)} / ${compactTokens(w.window.cap)}`;
106
+ const reset = resetLabel(w.window.resetsAt, now);
107
+ return (` ${theme.strong(label)} ${style(bar(theme, w.fraction))} ` +
108
+ `${style(pct(w.fraction))} ${theme.muted(figures)}` +
109
+ (reset ? theme.muted(`${theme.sep}${reset}`) : ""));
110
+ }
111
+ function spendCapLine(theme, label, cap) {
112
+ const name = label === "ws" ? "workspace spend cap" : "key spend cap";
113
+ return theme.muted(` ${name} ${usd(cap.remaining)} of ${usd(cap.cap)} left`);
114
+ }
115
+ /**
116
+ * The whole report, or the one honest sentence when there is no reading.
117
+ *
118
+ * The three not-a-reading states are said three ways, on the same discipline the
119
+ * panel keeps: "still asking" must not read as "no limits", and neither may read
120
+ * as "your key is bad". A command exits after saying it, so each one also gets
121
+ * the next step rather than leaving the user to infer it from four words.
122
+ */
123
+ export function limitsReportLines(theme, state, now = Date.now()) {
124
+ if (state.status === "pending") {
125
+ return [theme.muted("the limits reading has not arrived yet")];
126
+ }
127
+ if (state.status === "error") {
128
+ switch (state.reason) {
129
+ case "unauthenticated":
130
+ return [
131
+ theme.warning("not signed in"),
132
+ theme.muted("run `cruxy login` to sign in"),
133
+ ];
134
+ // A different FACT from "not signed in", and the fact is the point: this
135
+ // user's setup was right and simply aged out. Sending them to look for
136
+ // what they configured wrong would be sending them after nothing.
137
+ case "expired":
138
+ return [
139
+ theme.warning("your sign-in has expired"),
140
+ theme.muted("run `cruxy login` to sign in again"),
141
+ ];
142
+ // The gateway ANSWERED — it has no limits to report. Telling this user to
143
+ // check their network would be the wrong errand entirely.
144
+ case "unsupported":
145
+ return [
146
+ theme.muted("this gateway does not report limits"),
147
+ theme.muted("nothing here is wrong with your setup or your network"),
148
+ ];
149
+ case "unreachable":
150
+ return [
151
+ theme.warning("could not reach the gateway"),
152
+ theme.muted("check your connection and try again"),
153
+ ];
154
+ }
155
+ }
156
+ const s = buildLimitsSummary(state.reading, now);
157
+ const lines = [
158
+ `${theme.heading(s.tier)} ${theme.muted(`${theme.glyph.sep} ${s.bucket}`)}`,
159
+ ];
160
+ if (s.windows.length > 0) {
161
+ lines.push("");
162
+ for (const w of s.windows)
163
+ lines.push(windowLine(theme, w, now));
164
+ // The unit, once, under the windows it applies to. Named because a bare
165
+ // "942.8k" invites being read as requests or as raw tokens, and it is
166
+ // neither: it is `(billable_input + output) × tier multiplier`, the unit the
167
+ // meter counts in and the only one anything enforces.
168
+ lines.push(theme.muted(` ${" ".repeat(LABEL_COLS)} weighted tokens`));
169
+ }
170
+ // Why there is nothing to draw, for the shapes that have nothing. Stated, not
171
+ // omitted: an empty section reads as a figure that failed to load.
172
+ if (s.noAllowance) {
173
+ lines.push("");
174
+ lines.push(` ${theme.muted(s.noAllowance)}`);
175
+ }
176
+ // What this credential could have and does not (cli#263). An offer, not a
177
+ // correction — a deliberate `--paste` user is on an apikey on purpose and
178
+ // should be able to read this line and correctly ignore it.
179
+ for (const note of s.notes) {
180
+ lines.push(` ${theme.muted(note)}`);
181
+ }
182
+ const pool = s.budget;
183
+ if (pool.kind === "pool") {
184
+ // Both stated whenever they exist. The rail shows them only when the pool is
185
+ // already low, because it has five lines; this has a terminal, and "what can
186
+ // I still run" is a fair question before the answer becomes urgent.
187
+ if (pool.mira) {
188
+ lines.push(theme.muted(` mira ${compactTokens(pool.mira.remaining)} of ${compactTokens(pool.mira.cap)} requests left${theme.sep}${pool.mira.window}`));
189
+ }
190
+ if (pool.blockedModels.length > 0) {
191
+ lines.push(theme.danger(` refusing: ${pool.blockedModels.join(", ")}`));
192
+ }
193
+ }
194
+ if (pool.kind === "credits") {
195
+ lines.push("");
196
+ lines.push(` ${theme.strong(usd(pool.remaining))} ${theme.muted(`of ${usd(pool.granted)} left`)}`);
197
+ const reset = resetLabel(pool.resetsAt, now);
198
+ if (reset)
199
+ lines.push(theme.muted(` ${reset}`));
200
+ }
201
+ if (s.rpm) {
202
+ lines.push(theme.muted(` rate ${s.rpm.remaining} of ${s.rpm.limit} requests/min`));
203
+ }
204
+ for (const { label, cap } of s.spendCaps) {
205
+ lines.push(spendCapLine(theme, label, cap));
206
+ }
207
+ // Always, not only past a staleness threshold. The rail is repainting and can
208
+ // afford to stay quiet while a figure is fresh; a command prints once and is
209
+ // read later, so the reading's age is part of what it said.
210
+ lines.push("");
211
+ lines.push(theme.muted(readAgeLine(s.ageMs)));
212
+ return lines;
213
+ }