@cruxy/cli 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/agent/session.js +75 -2
  2. package/dist/budget/index.js +9 -0
  3. package/dist/budget/session-budget.js +223 -0
  4. package/dist/checkpoint/diff.js +130 -0
  5. package/dist/checkpoint/git-store.js +52 -0
  6. package/dist/checkpoint/index.js +2 -0
  7. package/dist/checkpoint/run-rollback.js +100 -0
  8. package/dist/cli/command-catalog.js +144 -0
  9. package/dist/cli/commands/hooks.js +1 -1
  10. package/dist/cli/commands/rollback.js +21 -57
  11. package/dist/cli/commands/run.js +9 -2
  12. package/dist/cli/commands/test.js +28 -16
  13. package/dist/cli/session-commands.js +315 -69
  14. package/dist/cli/session-factory.js +13 -0
  15. package/dist/errors/constructors.js +22 -0
  16. package/dist/errors/types.js +15 -0
  17. package/dist/hooks/config.js +18 -0
  18. package/dist/hooks/index.js +1 -1
  19. package/dist/hooks/router.js +1 -1
  20. package/dist/hooks/service.js +4 -4
  21. package/dist/hooks/slash.js +10 -26
  22. package/dist/memory/secrets.js +43 -0
  23. package/dist/render/context-view.js +2 -2
  24. package/dist/render/plan-view.js +1 -1
  25. package/dist/render/status-view.js +5 -5
  26. package/dist/render/units.js +22 -0
  27. package/dist/session/index.js +1 -0
  28. package/dist/session/log.js +19 -0
  29. package/dist/session/redact.js +74 -0
  30. package/dist/session/replay.js +16 -0
  31. package/dist/session/resume.js +8 -0
  32. package/dist/session/types.js +38 -0
  33. package/dist/subagent/orchestrator.js +82 -5
  34. package/dist/theme/resolve.js +1 -0
  35. package/dist/tui/app.js +7 -4
  36. package/dist/tui/approval-overlay.js +7 -1
  37. package/dist/tui/layout.js +7 -2
  38. package/dist/tui/limits-panel.js +6 -14
  39. package/dist/tui/palette.js +11 -19
  40. package/dist/usage/weighted.js +14 -0
  41. package/dist/utils/disk.js +11 -3
  42. package/package.json +2 -2
@@ -59,3 +59,46 @@ export function containsSecret(text) {
59
59
  }
60
60
  return { secret: false };
61
61
  }
62
+ /**
63
+ * The global twins of {@link SECRET_PATTERNS}, built once.
64
+ *
65
+ * Detection asks "is there one?" and can stop at the first hit; redaction has to
66
+ * replace EVERY occurrence, which needs the `g` flag — and a `g` regex carries
67
+ * `lastIndex` state that would make `containsSecret` return alternating answers
68
+ * for the same input. So the two uses get their own objects rather than sharing
69
+ * one and remembering to reset it.
70
+ */
71
+ const GLOBAL_PATTERNS = SECRET_PATTERNS.map(({ kind, re }) => ({ kind, re: new RegExp(re.source, `${re.flags}g`) }));
72
+ /**
73
+ * Replace every recognised secret in `text` with an opaque marker (P10 track 5).
74
+ *
75
+ * THE SAME DENYLIST as detection, deliberately — a `/redact` that used a
76
+ * different pattern set from the one that refuses a memory write would give two
77
+ * different answers about what counts as a secret, and the weaker of the two
78
+ * would be the one a user found out about.
79
+ *
80
+ * The marker is fixed text with no quotes, backslashes or control characters, so
81
+ * a redacted string survives being embedded in JSON unchanged — which is what
82
+ * lets a `tool_use` input be redacted by round-tripping through its serialised
83
+ * form rather than by walking an arbitrary shape.
84
+ *
85
+ * Everything the pattern matched goes, including a `api_key =` prefix on the
86
+ * generic assignment rule. Keeping the field name would read better and would
87
+ * leak the shape of the thing next to a marker announcing that something was
88
+ * there; the marker's `kind` already says as much as is safe to say.
89
+ */
90
+ export function redactSecrets(text) {
91
+ const kinds = [];
92
+ let count = 0;
93
+ let out = text;
94
+ for (const { kind, re } of GLOBAL_PATTERNS) {
95
+ re.lastIndex = 0;
96
+ out = out.replace(re, () => {
97
+ count++;
98
+ if (!kinds.includes(kind))
99
+ kinds.push(kind);
100
+ return `[redacted ${kind}]`;
101
+ });
102
+ }
103
+ return { text: out, kinds, count };
104
+ }
@@ -51,8 +51,8 @@ export function contextReportLines(report, t, width = Infinity) {
51
51
  // The headline, worded exactly as the panel words it — same estimate, same
52
52
  // caveats, so the detail view can never read as the more authoritative one.
53
53
  lines.push(`${t.strong(`${approx(reading.used)} / ${formatTokens(reading.total)} budget`)} ` +
54
- t.muted(`(estimated · budget is a local setting, not the model's window)`));
55
- lines.push(t.muted(`${report.messages} message${report.messages === 1 ? "" : "s"} · compacts above ${approx(reading.compactAt)}`));
54
+ t.muted(`(estimated${t.sep}budget is a local setting, not the model's window)`));
55
+ lines.push(t.muted(`${report.messages} message${report.messages === 1 ? "" : "s"}${t.sep}compacts above ${approx(reading.compactAt)}`));
56
56
  // ── where the tokens are ──────────────────────────────────────────────────
57
57
  const historyTokens = report.parts.reduce((sum, p) => sum + p.tokens, 0);
58
58
  lines.push("");
@@ -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(` · ${failed} failed`) : ""}`);
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;
@@ -40,7 +40,7 @@ function groupLocations(locations) {
40
40
  */
41
41
  function diskLines(locations, t) {
42
42
  return groupLocations(locations).map((group, i) => {
43
- const text = formatCapacity(group.capacity);
43
+ const text = formatCapacity(group.capacity, t.glyph.sep);
44
44
  const level = capacityLevel(group.capacity);
45
45
  const value = level === "critical"
46
46
  ? t.danger(text)
@@ -53,7 +53,7 @@ function diskLines(locations, t) {
53
53
  /** The full `/status` block as lines to print. */
54
54
  export function sessionStatusLines(status, t, width = Infinity) {
55
55
  const lines = [t.heading("status")];
56
- lines.push(row("session", `${status.sessionId.slice(0, 8)} ${t.muted( ${status.turns} turn${status.turns === 1 ? "" : "s"}`)}`, t));
56
+ lines.push(row("session", `${status.sessionId.slice(0, 8)} ${t.muted(`${t.glyph.sep} ${status.turns} turn${status.turns === 1 ? "" : "s"}`)}`, t));
57
57
  // The mode leads the safety half, and carries its description rather than its
58
58
  // name alone. The objection that removed the auto-approve config flag was that
59
59
  // it disarmed the gate with nothing on screen saying so; a status screen
@@ -68,7 +68,7 @@ export function sessionStatusLines(status, t, width = Infinity) {
68
68
  if (status.context) {
69
69
  const { used, total, compactAt } = status.context;
70
70
  lines.push(row("context", `${t.strong(`~${formatTokens(used)} / ${formatTokens(total)} budget`)} ` +
71
- t.muted( compacts above ~${formatTokens(compactAt)}`), t));
71
+ t.muted(`${t.glyph.sep} compacts above ~${formatTokens(compactAt)}`), t));
72
72
  }
73
73
  // A sandbox that is ON but whose runtime we cannot name is reported as on
74
74
  // WITHOUT a name, rather than omitted — the safety-relevant half is that it
@@ -89,9 +89,9 @@ export function sessionStatusLines(status, t, width = Infinity) {
89
89
  const { total, running, needingApproval } = status.jobs;
90
90
  const detail = total === 0
91
91
  ? t.muted("none this session")
92
- : `${total} · ${running} running` +
92
+ : `${total}${t.sep}${running} running` +
93
93
  (needingApproval > 0
94
- ? t.warning(` · ${needingApproval} awaiting approval`)
94
+ ? t.warning(`${t.sep}${needingApproval} awaiting approval`)
95
95
  : "");
96
96
  lines.push(row("jobs", detail, t));
97
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
+ }
@@ -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";
@@ -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
+ }
@@ -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. */
@@ -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
  /**
@@ -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(specs.length);
193
- const total = specs.length;
216
+ const results = new Array(dispatch.length);
217
+ const total = dispatch.length;
194
218
  try {
195
- const settled = await Promise.allSettled(specs.map((spec, i) => this.sem.run(async () => {
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() } : {};
@@ -47,6 +47,7 @@ export function resolveTheme(caps) {
47
47
  sep: ` ${glyph.sep} `,
48
48
  color: caps.color,
49
49
  unicode: caps.unicode,
50
+ screenReader: caps.screenReader ?? false,
50
51
  };
51
52
  }
52
53
  /**
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
- "/close",
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
  };
@@ -164,10 +164,15 @@ export function scrollWindow(lines, rows, offset) {
164
164
  *
165
165
  * It names the key because a scrolled view is a mode, and a mode the user
166
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".
167
173
  */
168
174
  export function scrollNotice(hiddenBelow, theme) {
169
- const glyph = theme.unicode ? "" : "v";
170
- 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`);
171
176
  }
172
177
  /** Rows the overflow notice costs when at least one panel is dropped. */
173
178
  const OVERFLOW_ROWS = 1;
@@ -1,3 +1,4 @@
1
+ import { compactTokens } from "../render/units.js";
1
2
  import { bindingWindow, usedFraction } from "../limits/index.js";
2
3
  /**
3
4
  * The limits panel (P9): what this credential may spend, and how much is left.
@@ -49,20 +50,11 @@ function stateStyle(theme, state) {
49
50
  return theme.warning;
50
51
  return theme.strong;
51
52
  }
52
- /** 14_000_000 → "14M", 8_700_000 → "8.7M", 125_000 → "125k", 900 → "900". */
53
- export function compactTokens(n) {
54
- const abs = Math.abs(n);
55
- if (abs >= 1_000_000)
56
- return `${trimZero(n / 1_000_000)}M`;
57
- if (abs >= 1_000)
58
- return `${trimZero(n / 1_000)}k`;
59
- return `${Math.round(n)}`;
60
- }
61
- /** One decimal, but only when it says something: 8.7 stays, 14.0 becomes 14. */
62
- function trimZero(n) {
63
- const one = n.toFixed(1);
64
- return one.endsWith(".0") ? one.slice(0, -2) : one;
65
- }
53
+ /**
54
+ * Re-exported, not defined here any more (P10 track 3): `/budget` states the
55
+ * same windows in the same unit, so the two share one formatter.
56
+ */
57
+ export { compactTokens };
66
58
  /** 27.77 → "$27.77", 29 → "$29", 0.5 → "$0.50". */
67
59
  export function usd(n) {
68
60
  return Number.isInteger(n) ? `$${n}` : `$${n.toFixed(2)}`;
@@ -1,29 +1,21 @@
1
1
  import { fuzzyFind } from "../components/fuzzy.js";
2
- import { COMMAND_CATALOG } from "../cli/session-commands.js";
2
+ import { COMMAND_CATALOG, TUI_ONLY_COMMANDS } from "../cli/command-catalog.js";
3
3
  import { canOverlay, createOverlayIO } from "./overlay.js";
4
- /** The TUI's own commands, which the shared catalogue deliberately excludes. */
5
- const PANEL_COMMANDS = [
6
- {
7
- name: "/close",
8
- summary: "hide a panel or the whole rail",
9
- args: "<sidebar | context | model | git | tools | rail>",
10
- },
11
- {
12
- name: "/open",
13
- summary: "show a hidden panel",
14
- args: "<sidebar | context | model | git | tools | rail>",
15
- },
16
- ];
17
4
  /**
18
- * Everything the palette offers: the shared catalogue, this shell's panel
5
+ * Everything the palette offers: the shared catalogue, this shell's own
19
6
  * commands, and the project's own slash commands.
20
7
  *
21
- * Custom commands come LAST and are labelled. `resolveSlash` consults builtins
22
- * first, so a custom command named `clear` can never shadow `/clear` listing
23
- * it above the builtin would show an order the dispatcher does not honour.
8
+ * The TUI's own three are {@link TUI_ONLY_COMMANDS} rather than a copy kept
9
+ * here. The copy had drifted it listed `/close` and `/open` and had never
10
+ * gained `/view`, so the one shell with a palette was also the one place `/view`
11
+ * could not be discovered.
12
+ *
13
+ * Custom commands come LAST and are labelled. A custom command can no longer be
14
+ * named after a reserved one at all (the loader refuses the file), so this list
15
+ * cannot contain two rows for one name.
24
16
  */
25
17
  export function paletteItems(slashCommands = []) {
26
- const builtins = [...COMMAND_CATALOG, ...PANEL_COMMANDS].map((c) => ({
18
+ const builtins = [...COMMAND_CATALOG, ...TUI_ONLY_COMMANDS].map((c) => ({
27
19
  name: c.name,
28
20
  summary: c.summary,
29
21
  ...(c.args === undefined ? {} : { args: c.args }),