@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.
- package/README.md +116 -0
- package/dist/agent/session.js +76 -7
- package/dist/budget/index.js +9 -0
- package/dist/budget/session-budget.js +223 -0
- package/dist/checkpoint/diff.js +130 -0
- package/dist/checkpoint/git-store.js +52 -0
- package/dist/checkpoint/index.js +2 -0
- package/dist/checkpoint/run-rollback.js +100 -0
- package/dist/cli/command-catalog.js +144 -0
- package/dist/cli/commands/hooks.js +1 -1
- package/dist/cli/commands/rollback.js +21 -57
- package/dist/cli/commands/run.js +9 -2
- package/dist/cli/commands/test.js +28 -16
- package/dist/cli/session-commands.js +315 -69
- package/dist/cli/session-factory.js +13 -0
- package/dist/components/frame.js +39 -1
- package/dist/errors/constructors.js +43 -4
- package/dist/errors/types.js +15 -0
- package/dist/hooks/config.js +18 -0
- package/dist/hooks/index.js +1 -1
- package/dist/hooks/router.js +1 -1
- package/dist/hooks/service.js +4 -4
- package/dist/hooks/slash.js +10 -26
- package/dist/lsp/index.js +1 -1
- package/dist/lsp/registry.js +28 -10
- package/dist/memory/secrets.js +43 -0
- package/dist/plan/service.js +26 -1
- package/dist/plan/submit-plan.js +11 -0
- package/dist/render/capabilities.js +9 -2
- package/dist/render/context-view.js +2 -2
- package/dist/render/index.js +6 -1
- package/dist/render/plan-view.js +1 -1
- package/dist/render/status-view.js +5 -5
- package/dist/render/units.js +22 -0
- package/dist/session/index.js +1 -0
- package/dist/session/log.js +19 -0
- package/dist/session/redact.js +74 -0
- package/dist/session/replay.js +16 -0
- package/dist/session/resume.js +8 -0
- package/dist/session/types.js +38 -0
- package/dist/subagent/orchestrator.js +82 -5
- package/dist/theme/resolve.js +1 -0
- package/dist/tui/app.js +25 -7
- package/dist/tui/approval-overlay.js +7 -1
- package/dist/tui/index.js +3 -2
- package/dist/tui/layout.js +7 -2
- package/dist/tui/limits-panel.js +6 -14
- package/dist/tui/mode-ring.js +84 -0
- package/dist/tui/palette.js +11 -19
- package/dist/tui/renderer.js +145 -4
- package/dist/tui/restore.js +137 -0
- package/dist/tui/supports.js +22 -0
- package/dist/tui/tool-versions.js +119 -18
- package/dist/usage/weighted.js +14 -0
- package/dist/utils/disk.js +11 -3
- 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
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
|
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,
|
package/dist/components/frame.js
CHANGED
|
@@ -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
|
-
/**
|
|
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 —
|
|
646
|
-
"or
|
|
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
|
-
|
|
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
|
});
|
package/dist/errors/types.js
CHANGED
|
@@ -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,
|