@cruxy/cli 1.4.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.
- package/dist/agent/session.js +75 -2
- package/dist/agent/status.js +41 -1
- 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 +34 -3
- package/dist/cli/commands/test.js +28 -16
- package/dist/cli/session-commands.js +321 -70
- package/dist/cli/session-factory.js +13 -0
- package/dist/errors/constructors.js +111 -9
- 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/limits/cache.js +100 -0
- package/dist/limits/index.js +11 -0
- package/dist/limits/reduce.js +172 -0
- package/dist/limits/types.js +25 -0
- package/dist/memory/secrets.js +43 -0
- package/dist/onboarding/detect.js +95 -9
- package/dist/onboarding/types.js +29 -1
- package/dist/render/context-view.js +2 -2
- package/dist/render/plan-view.js +1 -1
- package/dist/render/status-view.js +56 -4
- 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/theme/tokens.js +7 -0
- package/dist/tui/app.js +7 -4
- package/dist/tui/approval-overlay.js +7 -1
- package/dist/tui/disk-status.js +47 -0
- package/dist/tui/index.js +1 -0
- package/dist/tui/layout.js +8 -2
- package/dist/tui/limits-panel.js +247 -0
- package/dist/tui/overview.js +16 -4
- package/dist/tui/palette.js +11 -19
- package/dist/tui/panels.js +2 -0
- package/dist/tui/renderer.js +66 -0
- package/dist/usage/weighted.js +14 -0
- package/dist/utils/disk.js +103 -0
- package/package.json +3 -3
|
@@ -1,14 +1,19 @@
|
|
|
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";
|
|
16
|
+
import { readDiskCapacitySync } from "../utils/disk.js";
|
|
12
17
|
import { getGitInfo } from "../utils/git.js";
|
|
13
18
|
import { currentBranch, diffAgainst, hasChanges } from "../vcs/git.js";
|
|
14
19
|
import { MODEL_CHOICES, describeModelChoice, parseModelChoice, } from "../routing/index.js";
|
|
@@ -17,73 +22,11 @@ import { runGatedShell } from "../tools/shell/exec.js";
|
|
|
17
22
|
import { addRootToWorkspace } from "../workspace/index.js";
|
|
18
23
|
import { formatError, fromUnknown, isVerbose } from "../errors/index.js";
|
|
19
24
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* command palette. They used to be three hand-maintained lists — which is how
|
|
24
|
-
* the TUI ended up exporting a command list nothing consumed while advertising
|
|
25
|
-
* a help text that named commands it could not run.
|
|
26
|
-
*
|
|
27
|
-
* Panel commands (`/close`, `/open`) are the TUI's alone and live in
|
|
28
|
-
* `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.
|
|
29
28
|
*/
|
|
30
|
-
export
|
|
31
|
-
{ name: "/help", summary: "show this help" },
|
|
32
|
-
{
|
|
33
|
-
name: "/clear",
|
|
34
|
-
summary: "clear the conversation history (keep the session)",
|
|
35
|
-
},
|
|
36
|
-
{
|
|
37
|
-
name: "/compact",
|
|
38
|
-
summary: "summarize older history to free up context now",
|
|
39
|
-
},
|
|
40
|
-
{ name: "/init", summary: "scaffold a project CRUXY.md and load it" },
|
|
41
|
-
{ name: "/reload", summary: "re-read project instructions (CRUXY.md)" },
|
|
42
|
-
{ name: "/status", summary: "show what this session is set up to do" },
|
|
43
|
-
{
|
|
44
|
-
name: "/diff",
|
|
45
|
-
summary: "show uncommitted changes in the workspace",
|
|
46
|
-
args: "[ref]",
|
|
47
|
-
},
|
|
48
|
-
{
|
|
49
|
-
name: "/export",
|
|
50
|
-
summary: "write this conversation to a markdown file",
|
|
51
|
-
args: "[path]",
|
|
52
|
-
},
|
|
53
|
-
{
|
|
54
|
-
name: "/plan",
|
|
55
|
-
summary: "toggle plan mode (propose a plan before executing)",
|
|
56
|
-
},
|
|
57
|
-
{
|
|
58
|
-
name: "/mode",
|
|
59
|
-
summary: "show or set the session mode",
|
|
60
|
-
args: "[manual | auto-approve | plan | full-auto]",
|
|
61
|
-
},
|
|
62
|
-
{
|
|
63
|
-
name: "/model",
|
|
64
|
-
summary: "show or set the model for this session",
|
|
65
|
-
args: "[auto | kavi | vaani | mira]",
|
|
66
|
-
},
|
|
67
|
-
{
|
|
68
|
-
name: "/context",
|
|
69
|
-
summary: "show where the context budget is going, and what compaction would drop",
|
|
70
|
-
},
|
|
71
|
-
{
|
|
72
|
-
name: "/usage",
|
|
73
|
-
summary: "show token usage, weighted tokens and cost",
|
|
74
|
-
args: "[all | last <n>]",
|
|
75
|
-
},
|
|
76
|
-
{ name: "/jobs", summary: "list background jobs and their status" },
|
|
77
|
-
{ name: "/logs", summary: "show a background job's log", args: "<id>" },
|
|
78
|
-
{ name: "/cancel", summary: "cancel a background job", args: "<id>" },
|
|
79
|
-
{
|
|
80
|
-
name: "/add-root",
|
|
81
|
-
summary: "declare another workspace root",
|
|
82
|
-
args: "<name> <path>",
|
|
83
|
-
},
|
|
84
|
-
{ name: "/exit", summary: "leave cruxy" },
|
|
85
|
-
{ name: "/quit", summary: "leave cruxy" },
|
|
86
|
-
];
|
|
29
|
+
export { COMMAND_CATALOG };
|
|
87
30
|
/**
|
|
88
31
|
* Line cap for `/diff`. Generous compared to a preview block — this is a command
|
|
89
32
|
* whose entire purpose is the diff, not a block competing for space with a
|
|
@@ -162,7 +105,15 @@ export async function dispatchCommand(input, ctx) {
|
|
|
162
105
|
return { kind: "handled" };
|
|
163
106
|
}
|
|
164
107
|
if (trimmed === "/diff" || trimmed.startsWith("/diff ")) {
|
|
165
|
-
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);
|
|
166
117
|
return { kind: "handled" };
|
|
167
118
|
}
|
|
168
119
|
if (trimmed === "/export" || trimmed.startsWith("/export ")) {
|
|
@@ -177,6 +128,10 @@ export async function dispatchCommand(input, ctx) {
|
|
|
177
128
|
handleUsage(trimmed, ctx);
|
|
178
129
|
return { kind: "handled" };
|
|
179
130
|
}
|
|
131
|
+
if (trimmed === "/budget" || trimmed.startsWith("/budget ")) {
|
|
132
|
+
handleBudget(trimmed, ctx);
|
|
133
|
+
return { kind: "handled" };
|
|
134
|
+
}
|
|
180
135
|
if (trimmed === "/jobs") {
|
|
181
136
|
handleJobsList(ctx);
|
|
182
137
|
return { kind: "handled" };
|
|
@@ -373,7 +328,11 @@ function handleStatus(ctx) {
|
|
|
373
328
|
//
|
|
374
329
|
// Everything else comes from the shared builder, so this and the Overview
|
|
375
330
|
// view cannot drift: they differ in exactly this argument and nowhere else.
|
|
376
|
-
const status = buildSessionStatus(session, (absPath) => getGitInfo(absPath)
|
|
331
|
+
const status = buildSessionStatus(session, (absPath) => getGitInfo(absPath), undefined,
|
|
332
|
+
// Synchronous here for the same reason git is: the user asked. This one is
|
|
333
|
+
// a single syscall rather than two subprocesses, so it costs microseconds
|
|
334
|
+
// on any disk that is answering at all.
|
|
335
|
+
(path) => readDiskCapacitySync(path));
|
|
377
336
|
for (const line of sessionStatusLines(status, out.theme)) {
|
|
378
337
|
out.print(out.fit(line));
|
|
379
338
|
}
|
|
@@ -386,14 +345,29 @@ function handleStatus(ctx) {
|
|
|
386
345
|
* changes are that answer. `/diff <ref>` widens it to any commit-ish, which is
|
|
387
346
|
* how you see a whole branch's worth.
|
|
388
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
|
+
*
|
|
389
355
|
* EVERY ROOT, not just the primary (C.26). Writes fan every declared root when
|
|
390
356
|
* checkpoints are on, so a diff that showed only the primary would under-report
|
|
391
357
|
* exactly the multi-root case that is hardest to keep track of by hand.
|
|
392
358
|
*/
|
|
393
|
-
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) {
|
|
394
369
|
const { out, session } = ctx;
|
|
395
370
|
const t = out.theme;
|
|
396
|
-
const ref = input.slice("/diff".length).trim() || "HEAD";
|
|
397
371
|
const roots = session.toolContext.workspace.roots();
|
|
398
372
|
let any = false;
|
|
399
373
|
for (const root of roots) {
|
|
@@ -423,6 +397,172 @@ function handleDiff(input, ctx) {
|
|
|
423
397
|
out.print(t.muted(`(working tree vs ${ref} — commits, pushes and PRs are not shown)`));
|
|
424
398
|
}
|
|
425
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
|
+
}
|
|
426
566
|
/**
|
|
427
567
|
* `/export` (P6 track 4) — write this conversation to a Markdown file.
|
|
428
568
|
*
|
|
@@ -558,6 +698,117 @@ function handleUsage(input, ctx) {
|
|
|
558
698
|
out.print(out.fit(row));
|
|
559
699
|
}
|
|
560
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
|
+
}
|
|
561
812
|
/** Render the background-job list (`/jobs`). */
|
|
562
813
|
function handleJobsList(ctx) {
|
|
563
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,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ApiError, AuthError, NetworkError, OverloadedError, RateLimitError, } from "@cruxy/sdk";
|
|
1
|
+
import { ApiError, AuthError, BudgetExhaustedError, NetworkError, OverloadedError, RateLimitError, } from "@cruxy/sdk";
|
|
2
2
|
import { scrubModelNames } from "../brand/index.js";
|
|
3
3
|
import { CruxyError, ErrorCode } from "./types.js";
|
|
4
4
|
/**
|
|
@@ -223,15 +223,115 @@ export function apiOverloaded(underlying) {
|
|
|
223
223
|
underlying,
|
|
224
224
|
});
|
|
225
225
|
}
|
|
226
|
+
/**
|
|
227
|
+
* A wait, in the largest unit that stays honest: "4h", "12m", "18d".
|
|
228
|
+
*
|
|
229
|
+
* `apiRateLimit` renders seconds because a rate-limit wait IS seconds. A budget
|
|
230
|
+
* window is hours or days, and "~14400s" is a number a human has to do
|
|
231
|
+
* arithmetic on at the moment they are least inclined to.
|
|
232
|
+
*/
|
|
233
|
+
function humanWait(ms) {
|
|
234
|
+
const minutes = Math.round(ms / 60_000);
|
|
235
|
+
if (minutes < 60)
|
|
236
|
+
return `${Math.max(1, minutes)}m`;
|
|
237
|
+
const hours = Math.round(minutes / 60);
|
|
238
|
+
return hours < 48 ? `${hours}h` : `${Math.round(hours / 24)}d`;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* The weighted-pool gate refused the request (429 `budget_exhausted`).
|
|
242
|
+
*
|
|
243
|
+
* THIS IS THE POOL, AND ONLY THE POOL. The gateway spends separate codes on the
|
|
244
|
+
* things a user actually can pay their way out of — `credits_exhausted`,
|
|
245
|
+
* `key_spend_cap_exceeded`, `workspace_spend_cap_exceeded` — and none of them
|
|
246
|
+
* arrive here. So the advice this used to give ("top up or raise your budget,
|
|
247
|
+
* then retry") was wrong in both halves: there is nothing to top up on a
|
|
248
|
+
* subscription pool, and "retry" against a window that refills on a clock is an
|
|
249
|
+
* instruction to hammer a gate that will refuse again.
|
|
250
|
+
*
|
|
251
|
+
* That wording was harmless while it was unreachable from this CLI. cruxy-ai/api#174
|
|
252
|
+
* makes a CLI login mint a subscription credential, so it is now the message a
|
|
253
|
+
* blocked user actually reads — see cruxy-ai/cli#212.
|
|
254
|
+
*
|
|
255
|
+
* The error already carries everything needed to say something true, and all of
|
|
256
|
+
* it used to be discarded:
|
|
257
|
+
*
|
|
258
|
+
* - `miraAvailable` — mira is exempt from the pool kill, so on most denials
|
|
259
|
+
* there IS a way to keep working. It goes first: it is the only step that
|
|
260
|
+
* unblocks someone now rather than telling them when to come back.
|
|
261
|
+
* - `window` — "monthly" and "burst" are different waits (a calendar rollover
|
|
262
|
+
* versus a trailing sum sliding back under its cap), so the wait is named.
|
|
263
|
+
* - `resetAt` / `retryAfterMs` — WHEN, so the wait is a time rather than a
|
|
264
|
+
* vague "later". The window length is deliberately not asserted here; the
|
|
265
|
+
* server sends the recovery instant and this reports that.
|
|
266
|
+
*/
|
|
226
267
|
export function budgetExhausted(underlying) {
|
|
268
|
+
const err = underlying instanceof BudgetExhaustedError ? underlying : undefined;
|
|
269
|
+
const waitMs = err?.retryAfterMs ??
|
|
270
|
+
(err?.resetAt !== undefined ? msUntil(err.resetAt) : undefined);
|
|
271
|
+
const nextSteps = [];
|
|
272
|
+
// Mira stays available on an exhausted pool (it is the always-available
|
|
273
|
+
// floor), so when the gateway says so, the first step is the one that gets
|
|
274
|
+
// the user working again instead of waiting.
|
|
275
|
+
if (err?.miraAvailable) {
|
|
276
|
+
nextSteps.push("switch to the mira tier, which stays available: `/model mira`");
|
|
277
|
+
}
|
|
278
|
+
const window = err?.window === "burst" ? "burst" : err?.window;
|
|
279
|
+
const windowPhrase = window ? `your ${window} budget window` : "your budget";
|
|
280
|
+
nextSteps.push(waitMs !== undefined && waitMs > 0
|
|
281
|
+
? `wait ~${humanWait(waitMs)} — ${windowPhrase} recovers then`
|
|
282
|
+
: `wait for ${windowPhrase} to recover`);
|
|
283
|
+
// Only where it is true. A subscription pool is not something a user can add
|
|
284
|
+
// to; a plan change is the only lever, and it is a different action from
|
|
285
|
+
// "top up" with a different place to do it.
|
|
286
|
+
nextSteps.push("or move to a higher plan for a larger allowance");
|
|
227
287
|
return new CruxyError({
|
|
228
288
|
code: ErrorCode.BudgetExhausted,
|
|
229
|
-
title:
|
|
289
|
+
title: window
|
|
290
|
+
? `your ${window} Cruxy budget is exhausted`
|
|
291
|
+
: "your Cruxy budget is exhausted",
|
|
230
292
|
cause: scrubbedMessageOf(underlying),
|
|
231
|
-
nextSteps
|
|
293
|
+
nextSteps,
|
|
232
294
|
underlying,
|
|
295
|
+
meta: err
|
|
296
|
+
? {
|
|
297
|
+
...(err.window !== undefined ? { window: err.window } : {}),
|
|
298
|
+
...(err.resetAt !== undefined ? { resetAt: err.resetAt } : {}),
|
|
299
|
+
...(err.miraAvailable !== undefined
|
|
300
|
+
? { miraAvailable: err.miraAvailable }
|
|
301
|
+
: {}),
|
|
302
|
+
}
|
|
303
|
+
: undefined,
|
|
233
304
|
});
|
|
234
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
|
+
}
|
|
328
|
+
/** Milliseconds until an ISO instant, or `undefined` if it is absent/unparseable. */
|
|
329
|
+
function msUntil(iso) {
|
|
330
|
+
const at = Date.parse(iso);
|
|
331
|
+
if (Number.isNaN(at))
|
|
332
|
+
return undefined;
|
|
333
|
+
return Math.max(0, at - Date.now());
|
|
334
|
+
}
|
|
235
335
|
// ── filesystem (exit 7) ───────────────────────────────────────────────────────
|
|
236
336
|
export function fileNotFound(path, underlying) {
|
|
237
337
|
return new CruxyError({
|
|
@@ -1309,12 +1409,14 @@ export function classifyProviderError(underlying) {
|
|
|
1309
1409
|
return apiOverloaded(underlying);
|
|
1310
1410
|
if (underlying instanceof NetworkError)
|
|
1311
1411
|
return gatewayUnreachable(underlying);
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
return
|
|
1412
|
+
// Before the `ApiError` base it extends, like every other subclass here. The
|
|
1413
|
+
// name check this replaces was a workaround for an export that exists — the
|
|
1414
|
+
// SDK's barrel has always re-exported the class — and it silently stopped
|
|
1415
|
+
// matching anything the moment a bundler minified the constructor name.
|
|
1416
|
+
if (underlying instanceof BudgetExhaustedError) {
|
|
1417
|
+
return budgetExhausted(underlying);
|
|
1318
1418
|
}
|
|
1419
|
+
if (underlying instanceof ApiError)
|
|
1420
|
+
return apiError(underlying);
|
|
1319
1421
|
return null;
|
|
1320
1422
|
}
|
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,
|