@cruxy/cli 1.5.0 → 1.7.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 (56) hide show
  1. package/README.md +116 -0
  2. package/dist/agent/session.js +76 -7
  3. package/dist/budget/index.js +9 -0
  4. package/dist/budget/session-budget.js +223 -0
  5. package/dist/checkpoint/diff.js +130 -0
  6. package/dist/checkpoint/git-store.js +52 -0
  7. package/dist/checkpoint/index.js +2 -0
  8. package/dist/checkpoint/run-rollback.js +100 -0
  9. package/dist/cli/command-catalog.js +144 -0
  10. package/dist/cli/commands/hooks.js +1 -1
  11. package/dist/cli/commands/rollback.js +21 -57
  12. package/dist/cli/commands/run.js +9 -2
  13. package/dist/cli/commands/test.js +28 -16
  14. package/dist/cli/session-commands.js +315 -69
  15. package/dist/cli/session-factory.js +13 -0
  16. package/dist/components/frame.js +39 -1
  17. package/dist/errors/constructors.js +43 -4
  18. package/dist/errors/types.js +15 -0
  19. package/dist/hooks/config.js +18 -0
  20. package/dist/hooks/index.js +1 -1
  21. package/dist/hooks/router.js +1 -1
  22. package/dist/hooks/service.js +4 -4
  23. package/dist/hooks/slash.js +10 -26
  24. package/dist/lsp/index.js +1 -1
  25. package/dist/lsp/registry.js +28 -10
  26. package/dist/memory/secrets.js +43 -0
  27. package/dist/plan/service.js +26 -1
  28. package/dist/plan/submit-plan.js +11 -0
  29. package/dist/render/capabilities.js +9 -2
  30. package/dist/render/context-view.js +2 -2
  31. package/dist/render/index.js +6 -1
  32. package/dist/render/plan-view.js +1 -1
  33. package/dist/render/status-view.js +5 -5
  34. package/dist/render/units.js +22 -0
  35. package/dist/session/index.js +1 -0
  36. package/dist/session/log.js +19 -0
  37. package/dist/session/redact.js +74 -0
  38. package/dist/session/replay.js +16 -0
  39. package/dist/session/resume.js +8 -0
  40. package/dist/session/types.js +38 -0
  41. package/dist/subagent/orchestrator.js +82 -5
  42. package/dist/theme/resolve.js +1 -0
  43. package/dist/tui/app.js +25 -7
  44. package/dist/tui/approval-overlay.js +7 -1
  45. package/dist/tui/index.js +3 -2
  46. package/dist/tui/layout.js +7 -2
  47. package/dist/tui/limits-panel.js +6 -14
  48. package/dist/tui/mode-ring.js +84 -0
  49. package/dist/tui/palette.js +11 -19
  50. package/dist/tui/renderer.js +145 -4
  51. package/dist/tui/restore.js +137 -0
  52. package/dist/tui/supports.js +22 -0
  53. package/dist/tui/tool-versions.js +119 -18
  54. package/dist/usage/weighted.js +14 -0
  55. package/dist/utils/disk.js +11 -3
  56. 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,
@@ -4,9 +4,15 @@ import { resolveTheme } from "../theme/index.js";
4
4
  const CLEAR_LINE = "\r\x1b[2K";
5
5
  /** Move the cursor up one row. */
6
6
  const CURSOR_UP = "\x1b[1A";
7
+ /** Erase the whole row the cursor is on, wherever the cursor is in it. */
8
+ const ERASE_ROW = "\x1b[2K";
9
+ /** Park the cursor at row 1, column 1. */
10
+ const CURSOR_HOME = "\x1b[H";
11
+ /** Park the cursor at the start of an absolute row (1-based). */
12
+ const cursorToRow = (row) => `\x1b[${row};1H`;
7
13
  /** The visible text of a possibly-styled row (re-exported from the U.12 home). */
8
14
  export { stripAnsi };
9
- export function createFrame(write, caps) {
15
+ export function createFrame(write, caps, opts = {}) {
10
16
  let drawn = 0;
11
17
  let lastLines = [];
12
18
  const ellipsis = resolveTheme(caps).glyph.ellipsis;
@@ -20,6 +26,16 @@ export function createFrame(write, caps) {
20
26
  const erase = () => {
21
27
  if (drawn === 0)
22
28
  return;
29
+ if (opts.absolute) {
30
+ // Every row named, then home — nothing is inferred from where the cursor
31
+ // happens to be, which is the whole point of the mode.
32
+ let out = "";
33
+ for (let row = 1; row <= drawn; row++)
34
+ out += cursorToRow(row) + ERASE_ROW;
35
+ write(out + CURSOR_HOME);
36
+ drawn = 0;
37
+ return;
38
+ }
23
39
  // Cursor sits at the end of the last drawn row: clear it, then walk up
24
40
  // clearing each prior row, ending at column 0 of the first frame row.
25
41
  let out = CLEAR_LINE;
@@ -28,7 +44,29 @@ export function createFrame(write, caps) {
28
44
  write(out);
29
45
  drawn = 0;
30
46
  };
47
+ const paintAbsolute = (lines) => {
48
+ // NO ERASE PASS. Each row is cleared as part of being rewritten, so the
49
+ // screen never passes through a blank intermediate state — which is both
50
+ // one fewer write and one fewer chance to flicker.
51
+ let out = "";
52
+ for (const [index, line] of lines.entries()) {
53
+ out += cursorToRow(index + 1) + ERASE_ROW + fitRow(line);
54
+ }
55
+ // Rows the previous paint used and this one does not. A shorter frame must
56
+ // not leave its own tail on screen.
57
+ for (let row = lines.length + 1; row <= drawn; row++) {
58
+ out += cursorToRow(row) + ERASE_ROW;
59
+ }
60
+ if (out !== "")
61
+ write(out);
62
+ drawn = lines.length;
63
+ };
31
64
  const paint = (lines) => {
65
+ if (opts.absolute) {
66
+ lastLines = lines;
67
+ paintAbsolute(lines);
68
+ return;
69
+ }
32
70
  erase();
33
71
  lastLines = lines;
34
72
  if (lines.length === 0)
@@ -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);
@@ -635,15 +657,29 @@ export function gitPushFailed(branch, stderr) {
635
657
  });
636
658
  }
637
659
  // ── plan mode (exit 2 / 10) ───────────────────────────────────────────────────
638
- /** The agent's proposed plan was missing or malformed (plan mode, C.31). */
660
+ /**
661
+ * The agent's proposed plan was MALFORMED (plan mode, C.31).
662
+ *
663
+ * Malformed, and only malformed. A turn that ends with no plan at all is not
664
+ * this: the propose phase is read-only plus `submit_plan`, so the model cannot
665
+ * have acted, and a plain answer to a plain question is an ordinary completed
666
+ * turn — `runPlanSession` returns it rather than raising here.
667
+ *
668
+ * The next steps say `/mode manual` because that is the lever a user in a
669
+ * planning mode actually has. Shift+Tab is how most people arrive at `plan` and
670
+ * `full-auto` in the first place, and there is no flag on the running session to
671
+ * take back; `--plan` only chooses what mode a fresh `cruxy run` STARTS in, so it
672
+ * is named last and only for that case.
673
+ */
639
674
  export function planInvalid(reason) {
640
675
  return new CruxyError({
641
676
  code: ErrorCode.PlanInvalid,
642
677
  title: "the agent did not produce a valid plan",
643
678
  cause: reason,
644
679
  nextSteps: [
645
- "retry the task — the model must call `submit_plan` with at least one step",
646
- "or run without `--plan` to execute directly",
680
+ "retry the task — a plan needs at least one step, each with a title and a rationale",
681
+ "or leave planning for this session: `/mode manual` (Shift+Tab cycles the same ring)",
682
+ "for a one-shot `cruxy run`, drop `--plan` / `agent.planMode` to execute directly",
647
683
  ],
648
684
  meta: { reason },
649
685
  });
@@ -656,7 +692,10 @@ export function planRevisionLimit(limit) {
656
692
  cause: "the revision limit was reached without an approved plan",
657
693
  nextSteps: [
658
694
  "restate the task more concretely, or split it into smaller tasks",
659
- "run without `--plan` to execute directly",
695
+ // Same lever as planInvalid's, for the same reason: the session was most
696
+ // likely cycled into a planning mode with Shift+Tab, and there is no flag
697
+ // on it to drop.
698
+ "or leave planning for this session: `/mode manual` (Shift+Tab cycles the same ring)",
660
699
  ],
661
700
  meta: { limit },
662
701
  });
@@ -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,