@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
package/dist/render/plan-view.js
CHANGED
|
@@ -61,7 +61,7 @@ export function planChecklist(steps, t, maxRows = Infinity, width = Infinity) {
|
|
|
61
61
|
return [];
|
|
62
62
|
const done = steps.filter((s) => s.status === "done").length;
|
|
63
63
|
const failed = steps.filter((s) => s.status === "failed").length;
|
|
64
|
-
const header = t.strong(`plan ${done}/${steps.length}${failed > 0 ? t.danger(
|
|
64
|
+
const header = t.strong(`plan ${done}/${steps.length}${failed > 0 ? t.danger(`${t.sep}${failed} failed`) : ""}`);
|
|
65
65
|
const finish = (lines) => Number.isFinite(width)
|
|
66
66
|
? lines.map((l) => fit(l, width, t.glyph.ellipsis))
|
|
67
67
|
: lines;
|
|
@@ -1,13 +1,59 @@
|
|
|
1
|
+
import { capacityLevel, formatCapacity, } from "../utils/disk.js";
|
|
1
2
|
import { fit } from "./layout.js";
|
|
2
3
|
import { formatTokens } from "./state.js";
|
|
3
4
|
/** `key value`, aligned on a fixed gutter so the column is scannable. */
|
|
4
5
|
function row(key, value, t) {
|
|
5
6
|
return ` ${t.muted(key.padEnd(11))} ${value}`;
|
|
6
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* Collapse locations that report identical figures into one row, naming all of
|
|
10
|
+
* them.
|
|
11
|
+
*
|
|
12
|
+
* A project under `~` and `~/.cruxy` are usually the same filesystem, and two
|
|
13
|
+
* rows saying `54% free · 251 GiB of 465 GiB` twice is noise dressed as detail.
|
|
14
|
+
* Grouping is on the RENDERED numbers rather than on mount identity because
|
|
15
|
+
* `statfs` gives no filesystem id to compare — and grouping on the numbers is
|
|
16
|
+
* the honest test anyway: if two locations report the same total and the same
|
|
17
|
+
* free bytes, either they are one filesystem or they are two the reader has no
|
|
18
|
+
* way to tell apart, and in both cases one row is the whole of what's known.
|
|
19
|
+
*/
|
|
20
|
+
function groupLocations(locations) {
|
|
21
|
+
const groups = new Map();
|
|
22
|
+
for (const loc of locations) {
|
|
23
|
+
const key = `${loc.capacity.totalBytes}:${loc.capacity.freeBytes}`;
|
|
24
|
+
const existing = groups.get(key);
|
|
25
|
+
if (existing)
|
|
26
|
+
existing.labels.push(loc.label);
|
|
27
|
+
else
|
|
28
|
+
groups.set(key, { labels: [loc.label], capacity: loc.capacity });
|
|
29
|
+
}
|
|
30
|
+
return [...groups.values()];
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The disk rows: `disk cli, ~/.cruxy 54% free · 251 GiB of 465 GiB`.
|
|
34
|
+
*
|
|
35
|
+
* Coloured by {@link capacityLevel}, which judges absolute bytes — so a nearly
|
|
36
|
+
* full 4 TB disk with plenty left in it is not dressed up as an emergency, and
|
|
37
|
+
* a small VM disk with 800 MB left is, whatever percentage that happens to be.
|
|
38
|
+
* Only the first row carries the key; the rest align under it, because a `disk`
|
|
39
|
+
* label repeated down the column reads as several different facts.
|
|
40
|
+
*/
|
|
41
|
+
function diskLines(locations, t) {
|
|
42
|
+
return groupLocations(locations).map((group, i) => {
|
|
43
|
+
const text = formatCapacity(group.capacity, t.glyph.sep);
|
|
44
|
+
const level = capacityLevel(group.capacity);
|
|
45
|
+
const value = level === "critical"
|
|
46
|
+
? t.danger(text)
|
|
47
|
+
: level === "low"
|
|
48
|
+
? t.warning(text)
|
|
49
|
+
: t.muted(text);
|
|
50
|
+
return row(i === 0 ? "disk" : "", `${group.labels.join(", ")} ${value}`, t);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
7
53
|
/** The full `/status` block as lines to print. */
|
|
8
54
|
export function sessionStatusLines(status, t, width = Infinity) {
|
|
9
55
|
const lines = [t.heading("status")];
|
|
10
|
-
lines.push(row("session", `${status.sessionId.slice(0, 8)} ${t.muted(
|
|
56
|
+
lines.push(row("session", `${status.sessionId.slice(0, 8)} ${t.muted(`${t.glyph.sep} ${status.turns} turn${status.turns === 1 ? "" : "s"}`)}`, t));
|
|
11
57
|
// The mode leads the safety half, and carries its description rather than its
|
|
12
58
|
// name alone. The objection that removed the auto-approve config flag was that
|
|
13
59
|
// it disarmed the gate with nothing on screen saying so; a status screen
|
|
@@ -22,7 +68,7 @@ export function sessionStatusLines(status, t, width = Infinity) {
|
|
|
22
68
|
if (status.context) {
|
|
23
69
|
const { used, total, compactAt } = status.context;
|
|
24
70
|
lines.push(row("context", `${t.strong(`~${formatTokens(used)} / ${formatTokens(total)} budget`)} ` +
|
|
25
|
-
t.muted(
|
|
71
|
+
t.muted(`${t.glyph.sep} compacts above ~${formatTokens(compactAt)}`), t));
|
|
26
72
|
}
|
|
27
73
|
// A sandbox that is ON but whose runtime we cannot name is reported as on
|
|
28
74
|
// WITHOUT a name, rather than omitted — the safety-relevant half is that it
|
|
@@ -33,13 +79,19 @@ export function sessionStatusLines(status, t, width = Infinity) {
|
|
|
33
79
|
lines.push(row("checkpoints", status.checkpoints
|
|
34
80
|
? t.muted("on — `cruxy rollback` can undo a run's file changes")
|
|
35
81
|
: t.warning("off — file changes are not restorable"), t));
|
|
82
|
+
// Next to checkpoints, the feature that turns a turn into a shadow copy of
|
|
83
|
+
// the tree: the two rows answer one question between them — will this
|
|
84
|
+
// session's writes land.
|
|
85
|
+
if (status.disk && status.disk.length > 0) {
|
|
86
|
+
lines.push(...diskLines(status.disk, t));
|
|
87
|
+
}
|
|
36
88
|
if (status.jobs) {
|
|
37
89
|
const { total, running, needingApproval } = status.jobs;
|
|
38
90
|
const detail = total === 0
|
|
39
91
|
? t.muted("none this session")
|
|
40
|
-
: `${total}
|
|
92
|
+
: `${total}${t.sep}${running} running` +
|
|
41
93
|
(needingApproval > 0
|
|
42
|
-
? t.warning(
|
|
94
|
+
? t.warning(`${t.sep}${needingApproval} awaiting approval`)
|
|
43
95
|
: "");
|
|
44
96
|
lines.push(row("jobs", detail, t));
|
|
45
97
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display units shared by every surface that shows a token figure (P10 track 3).
|
|
3
|
+
*
|
|
4
|
+
* `compactTokens` lived in `tui/limits-panel.ts` while it was the rail's alone.
|
|
5
|
+
* `/budget` states the same quantity — weighted tokens — against the same server
|
|
6
|
+
* windows, and the two must not round differently: a panel reading `8.5M` beside
|
|
7
|
+
* a command reading `8500000` is one fact wearing two faces.
|
|
8
|
+
*/
|
|
9
|
+
/** 14_000_000 → "14M", 8_700_000 → "8.7M", 125_000 → "125k", 900 → "900". */
|
|
10
|
+
export function compactTokens(n) {
|
|
11
|
+
const abs = Math.abs(n);
|
|
12
|
+
if (abs >= 1_000_000)
|
|
13
|
+
return `${trimZero(n / 1_000_000)}M`;
|
|
14
|
+
if (abs >= 1_000)
|
|
15
|
+
return `${trimZero(n / 1_000)}k`;
|
|
16
|
+
return `${Math.round(n)}`;
|
|
17
|
+
}
|
|
18
|
+
/** One decimal, but only when it says something: 8.7 stays, 14.0 becomes 14. */
|
|
19
|
+
function trimZero(n) {
|
|
20
|
+
const one = n.toFixed(1);
|
|
21
|
+
return one.endsWith(".0") ? one.slice(0, -2) : one;
|
|
22
|
+
}
|
package/dist/session/index.js
CHANGED
|
@@ -15,6 +15,7 @@ export { PROJECTS_DIR_NAME, RESERVED_SUBDIRS, SESSION_FILE_EXT, projectDir, proj
|
|
|
15
15
|
export { SessionLog } from "./log.js";
|
|
16
16
|
export { defaultExportName, exportMarkdown, } from "./export.js";
|
|
17
17
|
export { foldEvents, readEvents, readMeta, replaySession } from "./replay.js";
|
|
18
|
+
export { redactMessages } from "./redact.js";
|
|
18
19
|
export { findSession, isAmbiguous, listSessions, summarizeSession, } from "./list.js";
|
|
19
20
|
export { cwdMismatchWarning, describeSession, loadResume, relativeAge, resumeById, resumePicker, shortId, PICKER_LIMIT, } from "./resume.js";
|
|
20
21
|
export { SESSION_FILE_VERSION, SessionEventSchema, SessionMetaSchema, } from "./types.js";
|
package/dist/session/log.js
CHANGED
|
@@ -92,6 +92,25 @@ export class SessionLog {
|
|
|
92
92
|
outputTokens,
|
|
93
93
|
});
|
|
94
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* `/redact` (P10 track 5): from here on, replay masks every recognised secret
|
|
97
|
+
* in the history recorded SO FAR.
|
|
98
|
+
*
|
|
99
|
+
* The one writer in this class that changes how EARLIER lines are read. It
|
|
100
|
+
* does not touch them — see the class header on why this file is only ever
|
|
101
|
+
* appended to — and it deliberately records no secret material: `kinds` and
|
|
102
|
+
* `count` say what was found, which is all a reader could already work out by
|
|
103
|
+
* running the same detector.
|
|
104
|
+
*/
|
|
105
|
+
redact(kinds, count) {
|
|
106
|
+
this.write({
|
|
107
|
+
kind: "redact",
|
|
108
|
+
at: new Date().toISOString(),
|
|
109
|
+
...this.runId(),
|
|
110
|
+
kinds,
|
|
111
|
+
count,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
95
114
|
runId() {
|
|
96
115
|
const id = this.currentRunId?.();
|
|
97
116
|
return id === undefined ? {} : { runId: id };
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { redactSecrets } from "../memory/secrets.js";
|
|
2
|
+
/**
|
|
3
|
+
* Mask every recognised secret across a whole message array.
|
|
4
|
+
*
|
|
5
|
+
* Returns a NEW array (and new blocks) rather than mutating: the caller's array
|
|
6
|
+
* is the one the recorder's watermark is measured against, and replacing it
|
|
7
|
+
* wholesale is what `Session` already does after every turn.
|
|
8
|
+
*/
|
|
9
|
+
export function redactMessages(messages) {
|
|
10
|
+
const kinds = [];
|
|
11
|
+
let count = 0;
|
|
12
|
+
const scrub = (text) => {
|
|
13
|
+
const r = redactSecrets(text);
|
|
14
|
+
count += r.count;
|
|
15
|
+
for (const k of r.kinds)
|
|
16
|
+
if (!kinds.includes(k))
|
|
17
|
+
kinds.push(k);
|
|
18
|
+
return r.text;
|
|
19
|
+
};
|
|
20
|
+
const out = messages.map((message) => {
|
|
21
|
+
if (typeof message.content === "string") {
|
|
22
|
+
return { ...message, content: scrub(message.content) };
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
...message,
|
|
26
|
+
content: message.content.map((block) => scrubBlock(block, scrub)),
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
return { messages: out, kinds, count };
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* One content block.
|
|
33
|
+
*
|
|
34
|
+
* `tool_use.input` is arbitrary JSON and is where the highest-risk material
|
|
35
|
+
* actually lands — a `run_command` whose argv carries a token, an HTTP tool with
|
|
36
|
+
* an auth header. It is redacted by round-tripping through its serialised form
|
|
37
|
+
* rather than by walking an unknown shape: the marker contains no character JSON
|
|
38
|
+
* escapes, so the string stays parseable, and a parse failure (which should be
|
|
39
|
+
* impossible) leaves the block untouched rather than corrupting a history the
|
|
40
|
+
* provider has to accept on the next turn.
|
|
41
|
+
*
|
|
42
|
+
* An unknown block kind is passed through unchanged. A newer cruxy's block must
|
|
43
|
+
* survive an older one reading the file (`ContentBlockSchema` says so), and
|
|
44
|
+
* inventing a redaction for a shape this build cannot interpret would be the
|
|
45
|
+
* lossy behaviour that rule exists to prevent.
|
|
46
|
+
*/
|
|
47
|
+
function scrubBlock(block, scrub) {
|
|
48
|
+
if (block.type === "text")
|
|
49
|
+
return { ...block, text: scrub(block.text) };
|
|
50
|
+
if (block.type === "tool_result") {
|
|
51
|
+
return { ...block, content: scrub(block.content) };
|
|
52
|
+
}
|
|
53
|
+
if (block.type === "tool_use") {
|
|
54
|
+
if (block.input === undefined)
|
|
55
|
+
return block;
|
|
56
|
+
let serialised;
|
|
57
|
+
try {
|
|
58
|
+
serialised = JSON.stringify(block.input);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return block; // not serialisable — leave it exactly as it was
|
|
62
|
+
}
|
|
63
|
+
const scrubbed = scrub(serialised);
|
|
64
|
+
if (scrubbed === serialised)
|
|
65
|
+
return block;
|
|
66
|
+
try {
|
|
67
|
+
return { ...block, input: JSON.parse(scrubbed) };
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return block;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return block;
|
|
74
|
+
}
|
package/dist/session/replay.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { modeFromFlags } from "../agent/mode.js";
|
|
3
|
+
import { redactMessages } from "./redact.js";
|
|
3
4
|
import { SessionEventSchema, SessionMetaSchema, } from "./types.js";
|
|
4
5
|
/**
|
|
5
6
|
* Replay: fold an append-only event log back into the state a session needs to
|
|
@@ -74,6 +75,7 @@ export function foldEvents(events, skipped = 0) {
|
|
|
74
75
|
// timestamp.
|
|
75
76
|
let planMode = false;
|
|
76
77
|
let mode = null;
|
|
78
|
+
let redactions = 0;
|
|
77
79
|
const usage = { input_tokens: 0, output_tokens: 0 };
|
|
78
80
|
for (const event of events) {
|
|
79
81
|
switch (event.kind) {
|
|
@@ -101,6 +103,19 @@ export function foldEvents(events, skipped = 0) {
|
|
|
101
103
|
usage.input_tokens += event.inputTokens;
|
|
102
104
|
usage.output_tokens += event.outputTokens;
|
|
103
105
|
break;
|
|
106
|
+
case "redact":
|
|
107
|
+
// Exactly what `Session.redact` did in memory: mask every recognised
|
|
108
|
+
// secret across the history AS IT STANDS. Position in the fold is the
|
|
109
|
+
// whole meaning of the event — messages appended afterwards are not
|
|
110
|
+
// covered, because at the moment the user asked, they did not exist.
|
|
111
|
+
//
|
|
112
|
+
// The event's own `kinds`/`count` are deliberately not consulted: they
|
|
113
|
+
// record what the writing build found, and this build's denylist may
|
|
114
|
+
// have grown since. Re-running the detector is what makes a redaction
|
|
115
|
+
// get stronger over time rather than being frozen at write time.
|
|
116
|
+
messages = redactMessages(messages).messages;
|
|
117
|
+
redactions++;
|
|
118
|
+
break;
|
|
104
119
|
case "meta":
|
|
105
120
|
break;
|
|
106
121
|
}
|
|
@@ -114,6 +129,7 @@ export function foldEvents(events, skipped = 0) {
|
|
|
114
129
|
// could be on, so nothing is being inferred.
|
|
115
130
|
mode: mode ?? modeFromFlags(planMode, false),
|
|
116
131
|
skipped,
|
|
132
|
+
redactions,
|
|
117
133
|
};
|
|
118
134
|
}
|
|
119
135
|
/** Read and parse a session file into its events, counting unusable lines. */
|
package/dist/session/resume.js
CHANGED
|
@@ -71,6 +71,14 @@ export function loadResume(session, cwd) {
|
|
|
71
71
|
warnings.push(`${state.skipped} unreadable line${state.skipped === 1 ? "" : "s"} in the session log were skipped — ` +
|
|
72
72
|
`the restored history may be incomplete`);
|
|
73
73
|
}
|
|
74
|
+
if (state.redactions > 0) {
|
|
75
|
+
// Said on resume because the alternative is a user finding `[redacted …]`
|
|
76
|
+
// in a transcript and not knowing whether cruxy did it or the model wrote
|
|
77
|
+
// it. The second clause is the part that is easy to leave out and matters
|
|
78
|
+
// most: `/redact` never rewrote the file it is being replayed from.
|
|
79
|
+
warnings.push(`this session was redacted ${state.redactions === 1 ? "once" : `${state.redactions} times`} — ` +
|
|
80
|
+
`secrets are masked in the restored history, but the original text is still in ${session.file}`);
|
|
81
|
+
}
|
|
74
82
|
return { session, state, warnings };
|
|
75
83
|
}
|
|
76
84
|
/**
|
package/dist/session/types.js
CHANGED
|
@@ -211,6 +211,43 @@ export const UsageEventSchema = z
|
|
|
211
211
|
outputTokens: z.number().int().nonnegative(),
|
|
212
212
|
})
|
|
213
213
|
.passthrough();
|
|
214
|
+
/**
|
|
215
|
+
* `/redact` (P10 track 5): every recognised secret in the history SO FAR is
|
|
216
|
+
* masked from this point in the fold onwards.
|
|
217
|
+
*
|
|
218
|
+
* A NEW EVENT RATHER THAN A REWRITE, and that is the entire design. Editing the
|
|
219
|
+
* earlier lines would mean re-serialising the file, which costs the two
|
|
220
|
+
* properties this format exists for — O(1) writes per turn, and a crash costing
|
|
221
|
+
* a torn last line instead of a conversation (see the module header, and
|
|
222
|
+
* `log.ts` on why temp-then-rename is wrong here). So a redaction changes how
|
|
223
|
+
* earlier lines are READ, and the earlier lines stay exactly as they were.
|
|
224
|
+
*
|
|
225
|
+
* THE EVENT CARRIES NO SECRET, deliberately: recording the strings to remove
|
|
226
|
+
* would write them into the log in order to say they should not be there. The
|
|
227
|
+
* fold re-derives the spans by re-running the detector, which is deterministic
|
|
228
|
+
* over the same messages and gives a reader nothing it could not already see.
|
|
229
|
+
*
|
|
230
|
+
* `kinds` and `count` are what the pass FOUND — a record for the user, not an
|
|
231
|
+
* instruction to the fold. The fold ignores them; a build whose denylist has
|
|
232
|
+
* since grown will legitimately mask more than the number written here, and
|
|
233
|
+
* treating the count as authoritative would cap it at what an older build knew.
|
|
234
|
+
*
|
|
235
|
+
* An older cruxy that has never heard of this kind SKIPS the line (`replay.ts`)
|
|
236
|
+
* and shows the unredacted history. That is the honest failure for a
|
|
237
|
+
* forward-compatibility rule that must not lose conversations, and it is why the
|
|
238
|
+
* command says the file itself still holds the raw text.
|
|
239
|
+
*/
|
|
240
|
+
export const RedactEventSchema = z
|
|
241
|
+
.object({
|
|
242
|
+
kind: z.literal("redact"),
|
|
243
|
+
at: z.string(),
|
|
244
|
+
runId: z.string().optional(),
|
|
245
|
+
/** Secret kinds the pass matched, for the record. Not read by the fold. */
|
|
246
|
+
kinds: z.array(z.string()).default([]),
|
|
247
|
+
/** Spans replaced at write time. Not read by the fold. */
|
|
248
|
+
count: z.number().int().nonnegative().default(0),
|
|
249
|
+
})
|
|
250
|
+
.passthrough();
|
|
214
251
|
/** Every event, discriminated on `kind`. */
|
|
215
252
|
export const SessionEventSchema = z.discriminatedUnion("kind", [
|
|
216
253
|
SessionMetaSchema,
|
|
@@ -220,4 +257,5 @@ export const SessionEventSchema = z.discriminatedUnion("kind", [
|
|
|
220
257
|
PlanModeEventSchema,
|
|
221
258
|
SessionModeEventSchema,
|
|
222
259
|
UsageEventSchema,
|
|
260
|
+
RedactEventSchema,
|
|
223
261
|
]);
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { runAgent } from "../agent/loop.js";
|
|
3
|
-
import { CruxyError, ErrorCode, messageOf, subagentDepthExceeded, subagentScopeOverlap, } from "../errors/index.js";
|
|
3
|
+
import { CruxyError, ErrorCode, messageOf, sessionBudgetExhausted, subagentDepthExceeded, subagentScopeOverlap, } from "../errors/index.js";
|
|
4
|
+
import { UNRESOLVED_TIER, } from "../budget/index.js";
|
|
5
|
+
import { resolveTaskModel } from "../routing/index.js";
|
|
4
6
|
import { Workspace } from "../workspace/index.js";
|
|
5
7
|
import { Budget, resolveBudget } from "../agent/budget.js";
|
|
6
8
|
import { scopeRegistry, SUBAGENT_WRITE_TOOLS } from "./registry-scope.js";
|
|
@@ -181,6 +183,28 @@ export class SubagentOrchestrator {
|
|
|
181
183
|
return [];
|
|
182
184
|
// Refuse overlapping write scope BEFORE any child is dispatched.
|
|
183
185
|
this.assertDisjointWriteScopes(specs);
|
|
186
|
+
// Admission control (P10 track 3 / cli#212), also before dispatch. Ordered
|
|
187
|
+
// AFTER the scope check on purpose: an overlapping fan-out is malformed and
|
|
188
|
+
// must be refused whatever the budget says, and narrowing a malformed batch
|
|
189
|
+
// to two children would hide the overlap rather than report it.
|
|
190
|
+
const admitted = this.admitFanOut(specs);
|
|
191
|
+
if (admitted.kind === "refused") {
|
|
192
|
+
throw sessionBudgetExhausted(admitted.reason);
|
|
193
|
+
}
|
|
194
|
+
// The children that do not run come back as an explicit NOT-ADMITTED result
|
|
195
|
+
// carrying the reason, never as a silently shorter array. The parent model is
|
|
196
|
+
// the one that has to decide what to do about a half-dispatched plan, and it
|
|
197
|
+
// can only do that if it is told — a fan-out of 5 that returns 2 results with
|
|
198
|
+
// no explanation reads as three crashes. Position is preserved for the same
|
|
199
|
+
// reason the dispatched half preserves it: result i is spec i, always.
|
|
200
|
+
const dispatch = specs.slice(0, admitted.count);
|
|
201
|
+
const deferred = [];
|
|
202
|
+
if (admitted.kind === "narrowed") {
|
|
203
|
+
this.deps.logger.warn(admitted.reason);
|
|
204
|
+
for (let i = admitted.count; i < specs.length; i++) {
|
|
205
|
+
deferred.push(notAdmittedResult(admitted.reason));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
184
208
|
const controller = new AbortController();
|
|
185
209
|
const onExternalAbort = () => controller.abort();
|
|
186
210
|
if (opts.signal) {
|
|
@@ -189,10 +213,10 @@ export class SubagentOrchestrator {
|
|
|
189
213
|
else
|
|
190
214
|
opts.signal.addEventListener("abort", onExternalAbort, { once: true });
|
|
191
215
|
}
|
|
192
|
-
const results = new Array(
|
|
193
|
-
const total =
|
|
216
|
+
const results = new Array(dispatch.length);
|
|
217
|
+
const total = dispatch.length;
|
|
194
218
|
try {
|
|
195
|
-
const settled = await Promise.allSettled(
|
|
219
|
+
const settled = await Promise.allSettled(dispatch.map((spec, i) => this.sem.run(async () => {
|
|
196
220
|
// Already cancelled (a fatal sibling or Ctrl-C fired first): record an
|
|
197
221
|
// honest cancelled result instead of starting a doomed run.
|
|
198
222
|
if (controller.signal.aborted) {
|
|
@@ -223,12 +247,51 @@ export class SubagentOrchestrator {
|
|
|
223
247
|
if (results[i] === undefined)
|
|
224
248
|
results[i] = cancelledResult();
|
|
225
249
|
}
|
|
226
|
-
return results;
|
|
250
|
+
return [...results, ...deferred];
|
|
227
251
|
}
|
|
228
252
|
finally {
|
|
229
253
|
opts.signal?.removeEventListener("abort", onExternalAbort);
|
|
230
254
|
}
|
|
231
255
|
}
|
|
256
|
+
/**
|
|
257
|
+
* Ask the session budget whether this batch fits (P10 track 3 / cli#212).
|
|
258
|
+
*
|
|
259
|
+
* The estimate is the batch's CEILING, not a guess at its actual draw:
|
|
260
|
+
* `count × perChildTokens × multiplier`, where `perChildTokens` is the local
|
|
261
|
+
* cap each child already runs under. A spec may narrow its own budget, so the
|
|
262
|
+
* per-child figure is the resolved one rather than the configured default —
|
|
263
|
+
* a fan-out of five deliberately-cheap children should not be refused on the
|
|
264
|
+
* arithmetic of five expensive ones.
|
|
265
|
+
*
|
|
266
|
+
* Tier: the router resolves per task class, and a child defaults to the
|
|
267
|
+
* `subagent` class. When routing cannot name a tier the request still draws on
|
|
268
|
+
* the pool, so it is weighed at the worst case rather than skipped — see
|
|
269
|
+
* `UNRESOLVED_TIER`.
|
|
270
|
+
*/
|
|
271
|
+
admitFanOut(specs) {
|
|
272
|
+
const budget = this.deps.budget;
|
|
273
|
+
if (!budget)
|
|
274
|
+
return { kind: "allow", count: specs.length, maxTokens: 0 };
|
|
275
|
+
const { defaultBudget } = this.deps.config.subagent;
|
|
276
|
+
// The heaviest child in the batch sets the per-run figure. The bound has to
|
|
277
|
+
// hold for the batch as dispatched, and averaging would let one 64k child
|
|
278
|
+
// hide behind four 4k ones.
|
|
279
|
+
const perRunTokens = specs.reduce((max, spec) => Math.max(max, resolveBudget(defaultBudget, spec.budget).maxTokens), 0);
|
|
280
|
+
return budget.admit({
|
|
281
|
+
count: specs.length,
|
|
282
|
+
perRunTokens,
|
|
283
|
+
tier: this.fanOutTier(),
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
/** The tier a child would route to, or the unresolved/no-pool markers. */
|
|
287
|
+
fanOutTier() {
|
|
288
|
+
const router = this.deps.router;
|
|
289
|
+
if (!router)
|
|
290
|
+
return undefined; // no cruxy routing — not a weighted-pool request
|
|
291
|
+
// No tier means the router chose `auto` and the gateway decides — a request
|
|
292
|
+
// that still draws on the pool, so it is weighed at the worst case.
|
|
293
|
+
return resolveTaskModel(router, "subagent").tier ?? UNRESOLVED_TIER;
|
|
294
|
+
}
|
|
232
295
|
/**
|
|
233
296
|
* Resolve a child's scope from an optional root name. With a name: a
|
|
234
297
|
* single-root workspace over that root (writes confined to it) + that root's
|
|
@@ -344,6 +407,20 @@ function cancelledResult() {
|
|
|
344
407
|
usage: { input_tokens: 0, output_tokens: 0 },
|
|
345
408
|
};
|
|
346
409
|
}
|
|
410
|
+
/**
|
|
411
|
+
* A child the budget would not admit. Zero usage and zero iterations, because
|
|
412
|
+
* that is literally what it consumed — a fabricated non-zero here would show up
|
|
413
|
+
* in the parent's accounting as spend that never happened.
|
|
414
|
+
*/
|
|
415
|
+
function notAdmittedResult(reason) {
|
|
416
|
+
return {
|
|
417
|
+
status: "not-admitted",
|
|
418
|
+
summary: "",
|
|
419
|
+
error: `${ErrorCode.SessionBudgetExhausted}: ${reason}`,
|
|
420
|
+
iterations: 0,
|
|
421
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
422
|
+
};
|
|
423
|
+
}
|
|
347
424
|
/** `artifacts` only when non-empty — absent beats `[]` in the parent's context. */
|
|
348
425
|
function artifactsField(artifacts) {
|
|
349
426
|
return artifacts.size > 0 ? { artifacts: [...artifacts].sort() } : {};
|
package/dist/theme/resolve.js
CHANGED
package/dist/theme/tokens.js
CHANGED
|
@@ -22,6 +22,8 @@ export const UNICODE_GLYPHS = {
|
|
|
22
22
|
caretDown: "↓",
|
|
23
23
|
cached: "↻",
|
|
24
24
|
cursorBar: "▏",
|
|
25
|
+
barFilled: "█",
|
|
26
|
+
barEmpty: "░",
|
|
25
27
|
bullet: "•",
|
|
26
28
|
sep: "·",
|
|
27
29
|
ellipsis: "…",
|
|
@@ -45,6 +47,8 @@ export const ASCII_GLYPHS = {
|
|
|
45
47
|
caretDown: "v",
|
|
46
48
|
cached: "",
|
|
47
49
|
cursorBar: "|",
|
|
50
|
+
barFilled: "#",
|
|
51
|
+
barEmpty: ".",
|
|
48
52
|
bullet: "*",
|
|
49
53
|
sep: "-",
|
|
50
54
|
ellipsis: "...",
|
|
@@ -72,6 +76,9 @@ export const SCREEN_READER_GLYPHS = {
|
|
|
72
76
|
caretDown: "down",
|
|
73
77
|
cached: "",
|
|
74
78
|
cursorBar: "",
|
|
79
|
+
// A bar announces as nothing; the percentage beside it says everything.
|
|
80
|
+
barFilled: "",
|
|
81
|
+
barEmpty: "",
|
|
75
82
|
bullet: "-",
|
|
76
83
|
sep: "-",
|
|
77
84
|
ellipsis: "...",
|
package/dist/tui/app.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { DEFAULT_MODE, MODE_LABELS, modeAutoApproves, } from "../agent/index.js";
|
|
2
2
|
import { completeLine } from "../components/autocomplete.js";
|
|
3
3
|
import { SHARED_COMMANDS, SHARED_HELP, announceMode, dispatchCommand, } from "../cli/session-commands.js";
|
|
4
|
+
import { TUI_ONLY_COMMANDS } from "../cli/command-catalog.js";
|
|
4
5
|
import { selectList } from "../components/select.js";
|
|
5
6
|
import { viewLabel, viewOrder } from "./views.js";
|
|
6
7
|
import { canOverlay, createKeyLease, createOverlayIO, } from "./overlay.js";
|
|
@@ -31,18 +32,20 @@ import { COLUMN_LABELS, PANEL_LABELS } from "./panels.js";
|
|
|
31
32
|
*/
|
|
32
33
|
/**
|
|
33
34
|
* Every command the TUI completes on Tab — the shared set plus this shell's own
|
|
34
|
-
* panel commands (P5 track 5).
|
|
35
|
+
* panel and view commands (P5 track 5).
|
|
35
36
|
*
|
|
36
37
|
* This constant existed before and was consumed by NOTHING: exported from
|
|
37
38
|
* `tui/index.ts`, imported nowhere, while the TUI had no completion at all.
|
|
38
39
|
* It is wired to Tab now rather than deleted, because the thing it was reaching
|
|
39
40
|
* for — the REPL's readline completer, which the TUI cannot use — is real.
|
|
41
|
+
*
|
|
42
|
+
* Both halves are derived (P10 track 0). The three names used to be written out
|
|
43
|
+
* here, in the palette, and in this file's `HELP`, and the reserved set knew
|
|
44
|
+
* about none of them.
|
|
40
45
|
*/
|
|
41
46
|
export const TUI_COMMANDS = [
|
|
42
47
|
...SHARED_COMMANDS,
|
|
43
|
-
|
|
44
|
-
"/open",
|
|
45
|
-
"/view",
|
|
48
|
+
...TUI_ONLY_COMMANDS.map((c) => c.name),
|
|
46
49
|
].sort();
|
|
47
50
|
const HELP = [
|
|
48
51
|
"commands:",
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { themeForColor } from "../theme/index.js";
|
|
1
2
|
/**
|
|
2
3
|
* The approval prompt as an in-viewport modal (P5 track 2).
|
|
3
4
|
*
|
|
@@ -28,6 +29,11 @@
|
|
|
28
29
|
* gives exactly that behaviour without the prompt knowing it is in a modal.
|
|
29
30
|
*/
|
|
30
31
|
export function createOverlayPromptIO(surface, lease, color) {
|
|
32
|
+
// The caret comes from the glyph table, not a literal (P11 track 1). It is a
|
|
33
|
+
// decorative mark whose meaning is carried by the text beside it, so the table
|
|
34
|
+
// degrades it to `|` on an ASCII terminal and to nothing at all for a screen
|
|
35
|
+
// reader — where a lone `▏` announces as noise on the end of every keystroke.
|
|
36
|
+
const t = themeForColor(color);
|
|
31
37
|
/** Everything the prompt has written this interaction, verbatim. */
|
|
32
38
|
let transcript = "";
|
|
33
39
|
/** The line being typed into a `readLine` follow-up, if one is open. */
|
|
@@ -46,7 +52,7 @@ export function createOverlayPromptIO(surface, lease, color) {
|
|
|
46
52
|
// always shows where the next character lands.
|
|
47
53
|
if (typing !== null) {
|
|
48
54
|
rows[Math.max(0, rows.length - 1)] =
|
|
49
|
-
`${rows[rows.length - 1] ?? ""}${typing}
|
|
55
|
+
`${rows[rows.length - 1] ?? ""}${typing}${t.glyph.cursorBar}`;
|
|
50
56
|
}
|
|
51
57
|
surface.setOverlay(rows);
|
|
52
58
|
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readDiskCapacity } from "../utils/disk.js";
|
|
2
|
+
export class WorkspaceDiskCache {
|
|
3
|
+
paths;
|
|
4
|
+
probe;
|
|
5
|
+
values = new Map();
|
|
6
|
+
/** The refresh in flight, so concurrent triggers share one pass. */
|
|
7
|
+
inFlight = null;
|
|
8
|
+
constructor(paths, probe = readDiskCapacity) {
|
|
9
|
+
// Deduplicated: the same path twice would be the same syscall twice for the
|
|
10
|
+
// same answer. Two DIFFERENT paths on one filesystem are NOT collapsed here
|
|
11
|
+
// — that is a question about the numbers, and it is answered where they are
|
|
12
|
+
// rendered (see `status-view.ts`), not by guessing at mount identity.
|
|
13
|
+
this.paths = [...new Set(paths)];
|
|
14
|
+
this.probe = probe;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The last known capacity for one path — a map read, safe from the paint
|
|
18
|
+
* path. `undefined` means not probed yet, probed and unreadable, or a path
|
|
19
|
+
* this cache was never given; every one of those renders as nothing, so they
|
|
20
|
+
* do not need telling apart the way the git tri-state does.
|
|
21
|
+
*/
|
|
22
|
+
current(path) {
|
|
23
|
+
return this.values.get(path);
|
|
24
|
+
}
|
|
25
|
+
/** Re-probe every path, concurrently, and resolve when all have settled. */
|
|
26
|
+
async refresh() {
|
|
27
|
+
if (this.inFlight)
|
|
28
|
+
return this.inFlight;
|
|
29
|
+
this.inFlight = this.run();
|
|
30
|
+
try {
|
|
31
|
+
await this.inFlight;
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
this.inFlight = null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
async run() {
|
|
38
|
+
await Promise.all(this.paths.map(async (path) => {
|
|
39
|
+
const value = await this.probe(path);
|
|
40
|
+
// A failed probe keeps the last good reading rather than blanking the
|
|
41
|
+
// row. A transient failure is not evidence that the disk changed, and a
|
|
42
|
+
// figure that flickers in and out is read as a bug in the tool.
|
|
43
|
+
if (value)
|
|
44
|
+
this.values.set(path, value);
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
}
|
package/dist/tui/index.js
CHANGED
|
@@ -3,6 +3,7 @@ export { COLUMN_LABELS, PANEL_LABELS, contextPanelLines, gitPanelLines, headerMo
|
|
|
3
3
|
export { ContextGauge, readContext, } from "./context-gauge.js";
|
|
4
4
|
export { ToolVersions, parseVersion, } from "./tool-versions.js";
|
|
5
5
|
export { GitStatusCache, WorkspaceGitCache, } from "./git-status.js";
|
|
6
|
+
export { WorkspaceDiskCache } from "./disk-status.js";
|
|
6
7
|
export { createOverviewView } from "./overview.js";
|
|
7
8
|
export { createGitView, gitViewLines, GIT_VIEW_MAX_FILES, } from "./git-view.js";
|
|
8
9
|
export { createTasksView, tasksViewLines, TASKS_VIEW_MAX_JOBS, TASKS_VIEW_TAIL, } from "./tasks-view.js";
|
package/dist/tui/layout.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { fit, visibleWidth } from "../render/layout.js";
|
|
2
2
|
export const RAIL_PANELS = [
|
|
3
3
|
"context",
|
|
4
|
+
"limits",
|
|
4
5
|
"model",
|
|
5
6
|
"git",
|
|
6
7
|
"tools",
|
|
@@ -163,10 +164,15 @@ export function scrollWindow(lines, rows, offset) {
|
|
|
163
164
|
*
|
|
164
165
|
* It names the key because a scrolled view is a mode, and a mode the user
|
|
165
166
|
* cannot see the exit from is a trap — there is no scrollbar here to drag.
|
|
167
|
+
*
|
|
168
|
+
* The marks come from the glyph table (P11 track 1). This used to branch on
|
|
169
|
+
* `theme.unicode ? "↓" : "v"`, which is the resolver reimplemented inline and
|
|
170
|
+
* one table short: screen-reader mode leaves `unicode` TRUE and swaps the table
|
|
171
|
+
* instead, so the branch printed a bare `↓` to the one reader who could not use
|
|
172
|
+
* it, where `theme.glyph.caretDown` says "down".
|
|
166
173
|
*/
|
|
167
174
|
export function scrollNotice(hiddenBelow, theme) {
|
|
168
|
-
|
|
169
|
-
return theme.warning(`${glyph} ${hiddenBelow} more line${hiddenBelow === 1 ? "" : "s"} below · PgDn / Esc to return`);
|
|
175
|
+
return theme.warning(`${theme.glyph.caretDown} ${hiddenBelow} more line${hiddenBelow === 1 ? "" : "s"} below${theme.sep}PgDn / Esc to return`);
|
|
170
176
|
}
|
|
171
177
|
/** Rows the overflow notice costs when at least one panel is dropped. */
|
|
172
178
|
const OVERFLOW_ROWS = 1;
|