@cruxy/cli 1.5.0 → 1.6.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 (42) hide show
  1. package/dist/agent/session.js +75 -2
  2. package/dist/budget/index.js +9 -0
  3. package/dist/budget/session-budget.js +223 -0
  4. package/dist/checkpoint/diff.js +130 -0
  5. package/dist/checkpoint/git-store.js +52 -0
  6. package/dist/checkpoint/index.js +2 -0
  7. package/dist/checkpoint/run-rollback.js +100 -0
  8. package/dist/cli/command-catalog.js +144 -0
  9. package/dist/cli/commands/hooks.js +1 -1
  10. package/dist/cli/commands/rollback.js +21 -57
  11. package/dist/cli/commands/run.js +9 -2
  12. package/dist/cli/commands/test.js +28 -16
  13. package/dist/cli/session-commands.js +315 -69
  14. package/dist/cli/session-factory.js +13 -0
  15. package/dist/errors/constructors.js +22 -0
  16. package/dist/errors/types.js +15 -0
  17. package/dist/hooks/config.js +18 -0
  18. package/dist/hooks/index.js +1 -1
  19. package/dist/hooks/router.js +1 -1
  20. package/dist/hooks/service.js +4 -4
  21. package/dist/hooks/slash.js +10 -26
  22. package/dist/memory/secrets.js +43 -0
  23. package/dist/render/context-view.js +2 -2
  24. package/dist/render/plan-view.js +1 -1
  25. package/dist/render/status-view.js +5 -5
  26. package/dist/render/units.js +22 -0
  27. package/dist/session/index.js +1 -0
  28. package/dist/session/log.js +19 -0
  29. package/dist/session/redact.js +74 -0
  30. package/dist/session/replay.js +16 -0
  31. package/dist/session/resume.js +8 -0
  32. package/dist/session/types.js +38 -0
  33. package/dist/subagent/orchestrator.js +82 -5
  34. package/dist/theme/resolve.js +1 -0
  35. package/dist/tui/app.js +7 -4
  36. package/dist/tui/approval-overlay.js +7 -1
  37. package/dist/tui/layout.js +7 -2
  38. package/dist/tui/limits-panel.js +6 -14
  39. package/dist/tui/palette.js +11 -19
  40. package/dist/usage/weighted.js +14 -0
  41. package/dist/utils/disk.js +11 -3
  42. package/package.json +2 -2
@@ -1,11 +1,15 @@
1
1
  import { MODE_LABELS, SESSION_MODES, modeDescription, modeFromFlags, parseMode, } from "../agent/index.js";
2
2
  import { existsSync, writeFileSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
+ import { COMMAND_CATALOG } from "./command-catalog.js";
4
5
  import { contextReport } from "../agent/context.js";
5
6
  import { buildSessionStatus } from "../agent/status.js";
7
+ import { resolveCheckpointDiff } from "../checkpoint/diff.js";
8
+ import { rollbackLatestRun } from "../checkpoint/run-rollback.js";
6
9
  import { scaffoldProjectInstructions } from "../config/index.js";
7
10
  import { resolveSlash } from "../hooks/index.js";
8
11
  import { contextReportLines } from "../render/context-view.js";
12
+ import { compactTokens } from "../render/units.js";
9
13
  import { renderUnifiedDiff } from "../render/index.js";
10
14
  import { sessionStatusLines } from "../render/status-view.js";
11
15
  import { defaultExportName, exportMarkdown } from "../session/index.js";
@@ -18,73 +22,11 @@ import { runGatedShell } from "../tools/shell/exec.js";
18
22
  import { addRootToWorkspace } from "../workspace/index.js";
19
23
  import { formatError, fromUnknown, isVerbose } from "../errors/index.js";
20
24
  /**
21
- * The commands shared by both shells, in the order `/help` lists them.
22
- *
23
- * ONE catalogue, three consumers: the help text, Tab completion, and the
24
- * command palette. They used to be three hand-maintained lists — which is how
25
- * the TUI ended up exporting a command list nothing consumed while advertising
26
- * a help text that named commands it could not run.
27
- *
28
- * Panel commands (`/close`, `/open`) are the TUI's alone and live in
29
- * `tui/app.ts`; they have no meaning in a shell with no panels.
25
+ * Re-exported so the many callers that already reach for the catalogue through
26
+ * this module keep working; the data itself lives in the leaf `command-catalog`
27
+ * so `hooks/` can read the reserved set without closing an import cycle.
30
28
  */
31
- export const COMMAND_CATALOG = [
32
- { name: "/help", summary: "show this help" },
33
- {
34
- name: "/clear",
35
- summary: "clear the conversation history (keep the session)",
36
- },
37
- {
38
- name: "/compact",
39
- summary: "summarize older history to free up context now",
40
- },
41
- { name: "/init", summary: "scaffold a project CRUXY.md and load it" },
42
- { name: "/reload", summary: "re-read project instructions (CRUXY.md)" },
43
- { name: "/status", summary: "show what this session is set up to do" },
44
- {
45
- name: "/diff",
46
- summary: "show uncommitted changes in the workspace",
47
- args: "[ref]",
48
- },
49
- {
50
- name: "/export",
51
- summary: "write this conversation to a markdown file",
52
- args: "[path]",
53
- },
54
- {
55
- name: "/plan",
56
- summary: "toggle plan mode (propose a plan before executing)",
57
- },
58
- {
59
- name: "/mode",
60
- summary: "show or set the session mode",
61
- args: "[manual | auto-approve | plan | full-auto]",
62
- },
63
- {
64
- name: "/model",
65
- summary: "show or set the model for this session",
66
- args: "[auto | kavi | vaani | mira]",
67
- },
68
- {
69
- name: "/context",
70
- summary: "show where the context budget is going, and what compaction would drop",
71
- },
72
- {
73
- name: "/usage",
74
- summary: "show token usage, weighted tokens and cost",
75
- args: "[all | last <n>]",
76
- },
77
- { name: "/jobs", summary: "list background jobs and their status" },
78
- { name: "/logs", summary: "show a background job's log", args: "<id>" },
79
- { name: "/cancel", summary: "cancel a background job", args: "<id>" },
80
- {
81
- name: "/add-root",
82
- summary: "declare another workspace root",
83
- args: "<name> <path>",
84
- },
85
- { name: "/exit", summary: "leave cruxy" },
86
- { name: "/quit", summary: "leave cruxy" },
87
- ];
29
+ export { COMMAND_CATALOG };
88
30
  /**
89
31
  * Line cap for `/diff`. Generous compared to a preview block — this is a command
90
32
  * whose entire purpose is the diff, not a block competing for space with a
@@ -163,7 +105,15 @@ export async function dispatchCommand(input, ctx) {
163
105
  return { kind: "handled" };
164
106
  }
165
107
  if (trimmed === "/diff" || trimmed.startsWith("/diff ")) {
166
- handleDiff(trimmed, ctx);
108
+ await handleDiff(trimmed, ctx);
109
+ return { kind: "handled" };
110
+ }
111
+ if (trimmed === "/undo-last") {
112
+ await handleUndoLast(ctx);
113
+ return { kind: "handled" };
114
+ }
115
+ if (trimmed === "/redact") {
116
+ handleRedact(ctx);
167
117
  return { kind: "handled" };
168
118
  }
169
119
  if (trimmed === "/export" || trimmed.startsWith("/export ")) {
@@ -178,6 +128,10 @@ export async function dispatchCommand(input, ctx) {
178
128
  handleUsage(trimmed, ctx);
179
129
  return { kind: "handled" };
180
130
  }
131
+ if (trimmed === "/budget" || trimmed.startsWith("/budget ")) {
132
+ handleBudget(trimmed, ctx);
133
+ return { kind: "handled" };
134
+ }
181
135
  if (trimmed === "/jobs") {
182
136
  handleJobsList(ctx);
183
137
  return { kind: "handled" };
@@ -391,14 +345,29 @@ function handleStatus(ctx) {
391
345
  * changes are that answer. `/diff <ref>` widens it to any commit-ish, which is
392
346
  * how you see a whole branch's worth.
393
347
  *
348
+ * `/diff --since [id]` (P10 track 2) narrows it the other way — to one RUN, which
349
+ * is the thing `HEAD` cannot express. `HEAD` answers "since my last commit", and
350
+ * mid-session that is usually several runs ago and includes work the user did by
351
+ * hand. A checkpoint is the only marker that means "before the agent started
352
+ * this". Resolution is `resolveCheckpointDiff`; from there this function is
353
+ * unchanged — the same `git diff` against the same renderer.
354
+ *
394
355
  * EVERY ROOT, not just the primary (C.26). Writes fan every declared root when
395
356
  * checkpoints are on, so a diff that showed only the primary would under-report
396
357
  * exactly the multi-root case that is hardest to keep track of by hand.
397
358
  */
398
- function handleDiff(input, ctx) {
359
+ async function handleDiff(input, ctx) {
360
+ const arg = input.slice("/diff".length).trim();
361
+ if (arg === "--since" || arg.startsWith("--since ")) {
362
+ await diffSinceCheckpoint(arg.slice("--since".length).trim(), ctx);
363
+ return;
364
+ }
365
+ diffAgainstRef(arg || "HEAD", ctx);
366
+ }
367
+ /** `/diff [ref]` — every root's working tree against one commit-ish. */
368
+ function diffAgainstRef(ref, ctx) {
399
369
  const { out, session } = ctx;
400
370
  const t = out.theme;
401
- const ref = input.slice("/diff".length).trim() || "HEAD";
402
371
  const roots = session.toolContext.workspace.roots();
403
372
  let any = false;
404
373
  for (const root of roots) {
@@ -428,6 +397,172 @@ function handleDiff(input, ctx) {
428
397
  out.print(t.muted(`(working tree vs ${ref} — commits, pushes and PRs are not shown)`));
429
398
  }
430
399
  }
400
+ /**
401
+ * `/diff --since [id]` — every root the checkpoint (or run) covers, against the
402
+ * tree that checkpoint captured. A bare `--since` means the most recent run,
403
+ * matching `/undo-last`'s default: the two commands answer "what would this undo"
404
+ * and "undo it", and they must not disagree about which run they mean.
405
+ *
406
+ * A root whose checkpoint has no tree-ish (a shadow-store snapshot, or objects
407
+ * git has since pruned) says so IN PLACE and the other roots still render. This
408
+ * command changes nothing, so there is no reason for one gap to withhold the
409
+ * rest — the opposite of the validate-all rule rollback is held to.
410
+ */
411
+ async function diffSinceCheckpoint(id, ctx) {
412
+ const { out, session } = ctx;
413
+ const t = out.theme;
414
+ const toolCtx = session.toolContext;
415
+ let scope;
416
+ try {
417
+ scope = await resolveCheckpointDiff(toolCtx.workspace.roots().map((r) => ({
418
+ name: r.name,
419
+ absPath: r.absPath,
420
+ primary: r.primary,
421
+ })), id === "" ? undefined : id, toolCtx.config);
422
+ }
423
+ catch (err) {
424
+ printCommandError(out, err);
425
+ return;
426
+ }
427
+ if (scope.kind === "none") {
428
+ out.print(t.muted(toolCtx.config.checkpoint.enabled
429
+ ? "no checkpoints yet — nothing has been recorded to diff against"
430
+ : "checkpoints are disabled (checkpoint.enabled = false), so there is nothing to diff against"));
431
+ return;
432
+ }
433
+ if (scope.kind === "not-found") {
434
+ // Never a nearest match: silently diffing against a checkpoint the user did
435
+ // not name is the same substitution `/model` refuses to make.
436
+ out.print(t.muted(`no run or checkpoint named "${scope.id}" — see \`cruxy rollback\` for the ids`));
437
+ return;
438
+ }
439
+ const label = scope.kind === "run"
440
+ ? `run ${scope.runId}`
441
+ : `checkpoint ${scope.targets[0].checkpointId}`;
442
+ const multi = scope.targets.length > 1;
443
+ let any = false;
444
+ for (const target of scope.targets) {
445
+ if (multi)
446
+ out.print(t.strong(target.rootName));
447
+ if (target.unavailable !== undefined) {
448
+ out.print(out.fit(t.muted(` ${target.unavailable}`)));
449
+ continue;
450
+ }
451
+ const diff = diffAgainst(target.rootPath, target.treeish);
452
+ if (diff.trim() === "") {
453
+ out.print(t.muted(` no changes since ${target.checkpointId}`));
454
+ continue;
455
+ }
456
+ any = true;
457
+ for (const line of renderUnifiedDiff(diff, t, {
458
+ maxLines: DIFF_MAX_LINES,
459
+ })) {
460
+ out.print(out.fit(line));
461
+ }
462
+ }
463
+ if (any) {
464
+ out.print(t.muted(`(working tree vs ${label} — commits, pushes and PRs are not shown)`));
465
+ }
466
+ }
467
+ /**
468
+ * `/undo-last` (P10 track 1) — roll back the most recent run's file changes
469
+ * without leaving the session.
470
+ *
471
+ * NOT a new capability, and deliberately not a new mechanism: `cruxy rollback`
472
+ * has done exactly this since C.32, and everything it needs — the set manifests,
473
+ * validate-all, the combined preview, the sequential apply — is on disk and
474
+ * root-relative rather than process-relative. What was missing was reachability.
475
+ * Undoing a run meant leaving the session that had just produced it, which is
476
+ * both the moment you most want to undo and the moment leaving costs most.
477
+ *
478
+ * So this is `rollbackLatestRun` with the newest set already chosen. THE GATE IS
479
+ * THE CONFIRMATION: the operation goes through the same U.3 destructive-tier
480
+ * approval as every other rollback, showing the same per-root preview, and there
481
+ * is no `--force`, no grant, and no picker. A user who typed `/undo-last`
482
+ * meaning something else sees exactly what it would do and says no.
483
+ *
484
+ * Two things it does not touch, both said out loud rather than assumed:
485
+ * the conversation (the transcript still describes changes that are no longer on
486
+ * disk, so the next turn is told), and anything that already left the working
487
+ * tree (commits, pushes, PRs — the shared caveat).
488
+ */
489
+ async function handleUndoLast(ctx) {
490
+ const { out, session } = ctx;
491
+ const t = out.theme;
492
+ const toolCtx = session.toolContext;
493
+ // Refused before anything is computed, for the same reason `cruxy rollback`
494
+ // refuses: this is a deliberate, interactive act and the gate cannot ask.
495
+ if (!ctx.tty) {
496
+ out.print(t.muted("/undo-last needs a terminal — it must ask before it restores"));
497
+ return;
498
+ }
499
+ if (!toolCtx.config.checkpoint.enabled) {
500
+ // Say which knob, because the answer is otherwise indistinguishable from
501
+ // "this run changed nothing".
502
+ out.print(t.muted("checkpoints are disabled — nothing was recorded to roll back to " +
503
+ "(enable with `cruxy config set checkpoint.enabled true`)"));
504
+ return;
505
+ }
506
+ try {
507
+ const outcome = await rollbackLatestRun(toolCtx.workspace.primary().absPath, {
508
+ config: toolCtx.config,
509
+ requestApproval: (action) => toolCtx.requestApproval(action),
510
+ interactive: ctx.tty,
511
+ report: { print: (line) => out.print(out.fit(line)), theme: t },
512
+ });
513
+ if (outcome === null) {
514
+ out.print(t.muted("nothing to undo — no run has changed a file yet"));
515
+ return;
516
+ }
517
+ if (outcome.kind === "applied") {
518
+ // The one thing the out-of-session command never has to say. History is
519
+ // not rewound with the files, so the model's next turn would otherwise
520
+ // reason from a working tree that no longer exists.
521
+ out.print(t.muted("the conversation still describes those changes — say so in your next " +
522
+ "message, or /clear if you are starting over"));
523
+ }
524
+ }
525
+ catch (err) {
526
+ // Includes the two coded set errors (INCOMPLETE before anything is applied,
527
+ // PARTIAL after a stop-on-failure): both name exactly what was restored and
528
+ // what was not, and neither may take the shell down.
529
+ printCommandError(out, err);
530
+ }
531
+ }
532
+ /**
533
+ * `/redact` (P10 track 5) — mask secrets in this conversation so the model stops
534
+ * seeing them.
535
+ *
536
+ * THE ONE THING IT PROMISES AND THE ONE THING IT DOES NOT, both said on screen
537
+ * every time. It removes the matched text from the live history and from every
538
+ * future replay of this session. It does NOT remove it from the `.jsonl` on
539
+ * disk: that file is append-only, which is what makes a crash mid-turn cost a
540
+ * torn last line rather than the whole conversation, and a redaction is
541
+ * therefore a new event that changes how earlier lines are read. A user who
542
+ * needs the bytes gone needs the session file deleted, and is told so rather
543
+ * than left with a command that sounds like it did more than it did.
544
+ *
545
+ * The detector is `memory/secrets.ts` — the SAME denylist that refuses to write
546
+ * a secret into memory. Two lists would mean two answers about what counts, and
547
+ * the weaker one would be the one someone discovered the hard way. It is a
548
+ * guardrail against accidental exposure, not a guarantee, and the message says
549
+ * that too: a value with no recognisable shape is not found by either.
550
+ */
551
+ function handleRedact(ctx) {
552
+ const { out, session } = ctx;
553
+ const t = out.theme;
554
+ const { kinds, count } = session.redact();
555
+ if (count === 0) {
556
+ out.print(t.muted("no recognisable secrets in this conversation"));
557
+ // Said on the empty result too — "nothing found" must not be heard as
558
+ // "nothing is there".
559
+ out.print(out.fit(t.muted(" (a denylist of known shapes, not a guarantee — a value with no telltale form is not matched)")));
560
+ return;
561
+ }
562
+ out.print(t.muted(`masked ${count} value${count === 1 ? "" : "s"} (${kinds.join(", ")}) — the model no longer sees ${count === 1 ? "it" : "them"}`));
563
+ out.print(out.fit(t.warning("the session file still holds the original text: this appends a redaction, it does not rewrite history")));
564
+ out.print(out.fit(t.muted(" rotate anything that leaked, and delete the session file if the bytes must be gone")));
565
+ }
431
566
  /**
432
567
  * `/export` (P6 track 4) — write this conversation to a Markdown file.
433
568
  *
@@ -563,6 +698,117 @@ function handleUsage(input, ctx) {
563
698
  out.print(out.fit(row));
564
699
  }
565
700
  }
701
+ /**
702
+ * `/budget` (P10 track 3) — read or set this session's cap, in weighted tokens.
703
+ *
704
+ * WEIGHTED, not raw, because that is the only unit anything enforces: the
705
+ * gateway meters `(billable_input + output) × multiplier`, so 100k tokens on
706
+ * mira and 100k on kavi differ by 3.8× in what is actually deducted. `/usage`
707
+ * already reports in this unit and the rail's limits panel already draws the
708
+ * server's windows in it; a third unit here would be a third answer.
709
+ *
710
+ * IT IS THE SAME OBJECT that bounds parallel fan-out (cli#212). This command is
711
+ * the readable face of an admission check that would otherwise be invisible —
712
+ * which matters, because the check can narrow a fan-out from five children to
713
+ * one, and a user who cannot see the ceiling cannot understand why.
714
+ *
715
+ * THREE NUMBERS, THREE SOURCES, deliberately not merged into one bar:
716
+ * • what this session has drawn — local, exact, and only this process;
717
+ * • the cap the user set, if any — local, and clearable;
718
+ * • the server's binding window — inclusive of every other surface on the
719
+ * account, and the only place headroom may honestly come from.
720
+ * A session that has spent 40k of its own 200k may still be blocked by a burst
721
+ * window three other devices drained, and a single merged figure could not say
722
+ * so.
723
+ *
724
+ * SESSION STATE, never written to config — the rule `/model` and `/mode` follow,
725
+ * for the strongest version of the reason: a cap silently re-applied from a file
726
+ * would eventually refuse a turn with nothing on screen explaining why.
727
+ */
728
+ function handleBudget(input, ctx) {
729
+ const { out, session } = ctx;
730
+ const t = out.theme;
731
+ const budget = session.budget;
732
+ const arg = input.slice("/budget".length).trim().toLowerCase();
733
+ if (!budget) {
734
+ // Not a failure to report later: a session with no budget wired has none to
735
+ // read either, and saying which is more useful than an empty report.
736
+ out.print(t.muted("this session has no budget to set"));
737
+ return;
738
+ }
739
+ if (arg !== "") {
740
+ if (arg === "off" || arg === "none" || arg === "0") {
741
+ budget.setLimit(null);
742
+ out.print(t.muted("budget cleared — this session has no local cap"));
743
+ // The server's window is untouched by clearing a local cap, and a user who
744
+ // has just removed one limit should not be left thinking they removed all.
745
+ printServerHeadroom(out, budget);
746
+ return;
747
+ }
748
+ const wanted = parseWeighted(arg);
749
+ if (wanted === null) {
750
+ out.print(t.muted("usage: /budget [<weighted tokens> | off] (e.g. `/budget 2M`, `/budget 500k`)"));
751
+ return;
752
+ }
753
+ budget.setLimit(wanted);
754
+ // Report against what is ALREADY spent, because a cap set mid-session can be
755
+ // below it — and a budget that is already used up the moment it is set must
756
+ // say so now rather than at the next turn.
757
+ const left = budget.remaining() ?? 0;
758
+ out.print(t.muted(`budget: ${compactTokens(wanted)} weighted tokens for this session — ` +
759
+ `${compactTokens(budget.spent)} already drawn, ${compactTokens(left)} left`));
760
+ if (left === 0) {
761
+ out.print(t.warning("that is at or below what this session has already spent"));
762
+ }
763
+ printServerHeadroom(out, budget);
764
+ return;
765
+ }
766
+ // No argument: report.
767
+ const limit = budget.limit;
768
+ out.print(t.muted(`this session has drawn ${compactTokens(budget.spent)} weighted tokens`));
769
+ if (budget.unweighable > 0) {
770
+ // Never folded into the total as zero — the same discipline `/usage` holds
771
+ // to. An unweighable request is one this build cannot price, not a free one.
772
+ out.print(t.muted(` (${budget.unweighable} request${budget.unweighable === 1 ? "" : "s"} could not be weighed and are not in that figure)`));
773
+ }
774
+ out.print(t.muted(limit === null
775
+ ? "no session cap set — `/budget <n>` sets one"
776
+ : `session cap ${compactTokens(limit)}, ${compactTokens(budget.remaining() ?? 0)} left`));
777
+ printServerHeadroom(out, budget);
778
+ }
779
+ /**
780
+ * The server's half of the denominator. Said in its own sentence rather than
781
+ * merged with the local figures, and said even when unreadable — "we could not
782
+ * read your ceiling" must never be silence, which reads as "you have none".
783
+ */
784
+ function printServerHeadroom(out, budget) {
785
+ const t = out.theme;
786
+ const headroom = budget.serverHeadroom();
787
+ if (headroom.kind === "window") {
788
+ out.print(out.fit(t.muted(`your ${headroom.name === "burst" ? "12h burst" : "monthly"} window: ` +
789
+ `${compactTokens(headroom.remaining)} of ${compactTokens(headroom.cap)} left ` +
790
+ `(shared with every other surface on your account)`)));
791
+ return;
792
+ }
793
+ if (headroom.kind === "uncapped") {
794
+ out.print(t.muted("your account has no weighted-pool ceiling"));
795
+ return;
796
+ }
797
+ out.print(out.fit(t.muted(`server headroom unknown — ${headroom.why}`)));
798
+ }
799
+ /**
800
+ * `500000`, `500k`, `2M` → weighted tokens. Rejects everything else rather than
801
+ * guessing: a mistyped budget that silently becomes a very small or very large
802
+ * number is a command that refuses turns, or fails to.
803
+ */
804
+ function parseWeighted(arg) {
805
+ const match = /^(\d+(?:\.\d+)?)([km])?$/.exec(arg);
806
+ if (!match)
807
+ return null;
808
+ const scale = match[2] === "m" ? 1_000_000 : match[2] === "k" ? 1_000 : 1;
809
+ const value = Number(match[1]) * scale;
810
+ return Number.isFinite(value) && value > 0 ? Math.round(value) : null;
811
+ }
566
812
  /** Render the background-job list (`/jobs`). */
567
813
  function handleJobsList(ctx) {
568
814
  const { out, session } = ctx;
@@ -3,6 +3,7 @@ import { loadProjectInstructions } from "../config/index.js";
3
3
  import { logger } from "../utils/logger.js";
4
4
  import { getGitInfo } from "../utils/git.js";
5
5
  import { ApprovalMutex, ApprovalService, InteractivePolicy, SessionAllowlist, defaultPromptIO, serializeGate, } from "../approval/index.js";
6
+ import { SessionBudget } from "../budget/index.js";
6
7
  import { withCheckpointGate } from "../checkpoint/index.js";
7
8
  // Re-exported for back-compat: the checkpoint hook moved to the checkpoint
8
9
  // package (so the subagent orchestrator and C.28 jobs can compose it without
@@ -362,6 +363,16 @@ opts = {}) {
362
363
  // each. The ONE pending-approval queue background jobs produce onto is created
363
364
  // here too, so foreground servicing and job production share it.
364
365
  const executionSemaphore = new Semaphore(config.subagent.maxConcurrency);
366
+ // The ONE weighted-token budget for the whole session (P10 track 3 / cli#212).
367
+ // `/budget` reads and sets it; `Session` narrows each turn's token guard by it;
368
+ // the orchestrator refuses to dispatch a fan-out it cannot cover. Three
369
+ // consumers, one object — a session cap and a fan-out bound that could disagree
370
+ // would be the failure this exists to prevent. Its server denominator is
371
+ // attached later (see `attachLimits`), because the limits cache does not exist
372
+ // yet at this point in the wiring.
373
+ const sessionBudget = new SessionBudget({
374
+ maxTokensPerTurn: config.agent.maxTokensPerTurn,
375
+ });
365
376
  const approvalQueue = new ApprovalQueue();
366
377
  const gate = (approval) => serializeGate(withCheckpointGate(
367
378
  // Outside `resumeLineAfterApproval`, so the live region is restored
@@ -385,6 +396,7 @@ opts = {}) {
385
396
  sandbox,
386
397
  checkpointsActive,
387
398
  executionSemaphore,
399
+ budget: sessionBudget,
388
400
  // A child gets the parent's MODE but not its grants (P5 track 3). The two
389
401
  // are different kinds of thing: a scoped session grant is consent to one
390
402
  // command in one root, and widening it to a child would be authority the
@@ -508,6 +520,7 @@ opts = {}) {
508
520
  // note on `SessionArgs.model`.
509
521
  ...(sessionModel ? { model: sessionModel } : {}),
510
522
  onRunUsage,
523
+ budget: sessionBudget,
511
524
  jobs: jobManager,
512
525
  recorder: opts.recorder,
513
526
  restore: opts.restore,
@@ -303,6 +303,28 @@ export function budgetExhausted(underlying) {
303
303
  : undefined,
304
304
  });
305
305
  }
306
+ /**
307
+ * The session's own `/budget` refused the turn (P10 track 3).
308
+ *
309
+ * Every next step is a lever the user has RIGHT HERE, which is the whole reason
310
+ * this is not {@link budgetExhausted}: nothing here involves waiting for a window
311
+ * to refill or changing a plan, because the ceiling is one the user set on this
312
+ * process a moment ago. `reason` comes from `SessionBudget` and already names
313
+ * which denominator bound — the session cap or the server window — so it is
314
+ * carried verbatim rather than re-phrased.
315
+ */
316
+ export function sessionBudgetExhausted(reason) {
317
+ return new CruxyError({
318
+ code: ErrorCode.SessionBudgetExhausted,
319
+ title: "this session's budget is used up",
320
+ cause: reason,
321
+ nextSteps: [
322
+ "raise it: `/budget <weighted tokens>`",
323
+ "or clear it: `/budget off`",
324
+ "`/usage` shows what this session has actually drawn",
325
+ ],
326
+ });
327
+ }
306
328
  /** Milliseconds until an ISO instant, or `undefined` if it is absent/unparseable. */
307
329
  function msUntil(iso) {
308
330
  const at = Date.parse(iso);
@@ -47,6 +47,18 @@ export const ErrorCode = {
47
47
  ApiRateLimit: "CRUXY_E_API_RATE_LIMIT",
48
48
  ApiOverloaded: "CRUXY_E_API_OVERLOADED",
49
49
  BudgetExhausted: "CRUXY_E_BUDGET_EXHAUSTED",
50
+ /**
51
+ * The SESSION's own `/budget` is used up (P10 track 3) — a cap this user set
52
+ * on this process, not the gateway refusing anything.
53
+ *
54
+ * A DISTINCT CODE from {@link BudgetExhausted} because the two need opposite
55
+ * advice and have opposite blast radius. The gateway's exhaustion is a shared
56
+ * sliding window that refills by trickle and cannot be raised from here; this
57
+ * one is a number the user typed thirty seconds ago and can change with
58
+ * `/budget`. Collapsing them would tell a user to wait twelve hours for a
59
+ * ceiling they could lift immediately.
60
+ */
61
+ SessionBudgetExhausted: "CRUXY_E_SESSION_BUDGET_EXHAUSTED",
50
62
  ForgeApi: "CRUXY_E_FORGE_API",
51
63
  // filesystem (exit 7)
52
64
  FileNotFound: "CRUXY_E_FILE_NOT_FOUND",
@@ -280,6 +292,9 @@ const EXIT_CODES = {
280
292
  [ErrorCode.ApiRateLimit]: 6,
281
293
  [ErrorCode.ApiOverloaded]: 6,
282
294
  [ErrorCode.BudgetExhausted]: 6,
295
+ // A cap this invocation set on itself is usage, not an API failure — the same
296
+ // exit class as any other "you asked for something out of bounds".
297
+ [ErrorCode.SessionBudgetExhausted]: 2,
283
298
  [ErrorCode.ForgeApi]: 6,
284
299
  [ErrorCode.FileNotFound]: 7,
285
300
  [ErrorCode.PermissionDenied]: 7,
@@ -1,5 +1,6 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
+ import { isReservedSlash } from "../cli/command-catalog.js";
3
4
  import { globalDir } from "../config/paths.js";
4
5
  import { COMMANDS_DIR_NAME, GLOBAL_DIR_NAME, HOOKS_FILE_NAME, } from "../constants.js";
5
6
  import { defaultBlocking, HOOK_SOURCE_PRECEDENCE, HookSpecSchema, SlashFrontmatterSchema, } from "./types.js";
@@ -143,6 +144,23 @@ async function scanCommandsDir(source, dir, out, errors) {
143
144
  });
144
145
  continue;
145
146
  }
147
+ // A reserved name is refused HERE, at load, and excluded from the catalog
148
+ // (P10 track 0). Both halves matter. Loading it and letting it lose the
149
+ // race later is what shipped before: `commands/status.md` parsed, appeared
150
+ // in `cruxy hooks list` and in the palette, and could not run, because the
151
+ // shells dispatch `/status` themselves several branches before the custom
152
+ // catalogue is consulted. Nothing said so at load and nothing said so at
153
+ // use. Keeping it in the catalog with a warning would fix half of that and
154
+ // leave the palette advertising a command that does nothing.
155
+ if (isReservedSlash(name)) {
156
+ errors.push({
157
+ source,
158
+ file,
159
+ name,
160
+ message: `"/${name}" is a built-in command and cannot be overridden — rename this file`,
161
+ });
162
+ continue;
163
+ }
146
164
  let text;
147
165
  try {
148
166
  text = await fs.readFile(file, "utf8");
@@ -2,6 +2,6 @@ export { HOOK_EVENTS, HOOK_SOURCE_PRECEDENCE, HookSpecSchema, HooksFileSchema, S
2
2
  export { defaultHookSources, loadHookCatalog, } from "./config.js";
3
3
  export { fileTrustStore, fingerprintHooks, isTrusted, memoryTrustStore, trustPath, } from "./trust.js";
4
4
  export { HookRunner, } from "./runner.js";
5
- export { BUILTIN_SLASH_COMMANDS, expandTemplate, isBuiltinSlash, resolveSlash, } from "./slash.js";
5
+ export { expandTemplate, resolveSlash } from "./slash.js";
6
6
  export { buildHooksService, } from "./service.js";
7
7
  export { buildHooksRouter, } from "./router.js";
@@ -79,7 +79,7 @@ export async function buildHooksRouter(opts) {
79
79
  for (const root of opts.workspace.roots()) {
80
80
  const catalog = await loadCatalog(root);
81
81
  for (const err of catalog.errors) {
82
- opts.logger.warn(`ignoring malformed ${err.source} hook/command "${err.name}" in ${root.name}: ${err.message}`);
82
+ opts.logger.warn(`ignoring ${err.source} hook/command "${err.name}" in ${root.name}: ${err.message}`);
83
83
  }
84
84
  // Each root's runner owns ONLY its project hooks. User hooks are global —
85
85
  // loaded once (from the primary, whose `sources.user` is identical for every
@@ -5,15 +5,15 @@ import { defaultHookSources, loadHookCatalog, } from "./config.js";
5
5
  import { HookRunner } from "./runner.js";
6
6
  import { fileTrustStore } from "./trust.js";
7
7
  /**
8
- * Load the catalog and assemble the {@link HooksService}. Malformed definitions
9
- * are surfaced (never eval'd, never silently dropped) through the logger; the
10
- * valid ones proceed.
8
+ * Load the catalog and assemble the {@link HooksService}. Rejected definitions
9
+ * malformed, or a command name the shells reserve — are surfaced (never
10
+ * eval'd, never silently dropped) through the logger; the valid ones proceed.
11
11
  */
12
12
  export async function buildHooksService(opts) {
13
13
  const sources = opts.sources ?? defaultHookSources(opts.cwd);
14
14
  const catalog = await loadHookCatalog(sources);
15
15
  for (const err of catalog.errors) {
16
- opts.logger.warn(`ignoring malformed ${err.source} hook/command "${err.name}": ${err.message}`);
16
+ opts.logger.warn(`ignoring ${err.source} hook/command "${err.name}": ${err.message}`);
17
17
  }
18
18
  const trust = opts.trust ?? fileTrustStore();
19
19
  const runner = new HookRunner({
@@ -1,29 +1,13 @@
1
+ import { isReservedSlash } from "../cli/command-catalog.js";
1
2
  /**
2
- * Custom slash-command resolution (C.19). Builtins are reserved and always win
3
- * a custom command can never shadow `/help`, `/exit`, etc. A resolved custom
4
- * command is either a `prompt` (expanded to safe text fed to the agent) or a
5
- * `shell` binding (executed through the SAME gate + sandbox as everything else,
6
- * by the caller). Nothing here executes anything; it only resolves + expands.
7
- */
8
- /** The builtin slash commands (C.13) reserved, not overridable. */
9
- export const BUILTIN_SLASH_COMMANDS = [
10
- "help",
11
- "clear",
12
- "compact",
13
- "reload",
14
- "plan",
15
- "exit",
16
- "quit",
17
- ];
18
- const BUILTINS = new Set(BUILTIN_SLASH_COMMANDS);
19
- /** Is `name` (no leading slash) a reserved builtin? */
20
- export function isBuiltinSlash(name) {
21
- return BUILTINS.has(name);
22
- }
23
- /**
24
- * Resolve one input line. Non-slash input and unknown names → `none`. Builtins
25
- * short-circuit to `builtin` BEFORE the custom catalog is consulted, so a custom
26
- * command named after a builtin is inert (surfaced separately at load time).
3
+ * Resolve one input line. Non-slash input and unknown names → `none`. Reserved
4
+ * names short-circuit to `builtin` BEFORE the custom catalog is consulted.
5
+ *
6
+ * This is now belt-and-braces rather than the only guard: the loader refuses a
7
+ * command file whose name is reserved, so the colliding spec should never reach
8
+ * this catalogue at all. It stays because a caller may assemble a spec list by
9
+ * some other route, and the failure mode this prevents — a project-authored
10
+ * `shell` command answering to `/clear` — is not one to leave to a single check.
27
11
  */
28
12
  export function resolveSlash(line, commands) {
29
13
  const trimmed = line.trim();
@@ -34,7 +18,7 @@ export function resolveSlash(line, commands) {
34
18
  const args = space === -1 ? "" : trimmed.slice(space + 1).trim();
35
19
  if (name === "")
36
20
  return { kind: "none" };
37
- if (isBuiltinSlash(name))
21
+ if (isReservedSlash(name))
38
22
  return { kind: "builtin", name };
39
23
  const spec = commands.find((c) => c.name === name);
40
24
  if (!spec)