@cruxy/cli 1.8.1 → 1.10.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 (47) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/loop.js +16 -1
  3. package/dist/agent/session.js +62 -9
  4. package/dist/approval/classify.js +170 -40
  5. package/dist/approval/prompt.js +52 -6
  6. package/dist/approval/service.js +1 -11
  7. package/dist/budget/session-budget.js +10 -1
  8. package/dist/checkpoint/coverage.js +147 -4
  9. package/dist/cli/commands/limits.js +76 -0
  10. package/dist/cli/commands/login.js +18 -5
  11. package/dist/cli/commands/pr.js +10 -1
  12. package/dist/cli/commands/rollback.js +10 -2
  13. package/dist/cli/commands/run.js +55 -5
  14. package/dist/cli/commands/sessions.js +156 -0
  15. package/dist/cli/program.js +4 -0
  16. package/dist/cli/repl.js +25 -0
  17. package/dist/cli/session-factory.js +31 -10
  18. package/dist/config/credential-lifetime.js +42 -0
  19. package/dist/config/credentials.js +66 -0
  20. package/dist/config/schema.js +141 -9
  21. package/dist/constants.js +12 -2
  22. package/dist/errors/boundary.js +4 -4
  23. package/dist/errors/constructors.js +136 -57
  24. package/dist/errors/types.js +18 -0
  25. package/dist/index.js +27 -1
  26. package/dist/jobs/manager.js +269 -17
  27. package/dist/limits/cache.js +21 -5
  28. package/dist/mcp/client.js +16 -0
  29. package/dist/onboarding/flow.js +121 -6
  30. package/dist/onboarding/steps.js +112 -0
  31. package/dist/render/limits-report.js +213 -0
  32. package/dist/render/limits-view.js +125 -0
  33. package/dist/sandbox/service.js +9 -0
  34. package/dist/sandbox/types.js +15 -0
  35. package/dist/session/index.js +3 -1
  36. package/dist/session/list.js +20 -6
  37. package/dist/session/log.js +120 -21
  38. package/dist/session/prune.js +106 -0
  39. package/dist/session/resume.js +5 -0
  40. package/dist/subagent/orchestrator.js +71 -31
  41. package/dist/subagent/spawn-tool.js +11 -4
  42. package/dist/tools/schema-depth.js +18 -0
  43. package/dist/tui/limits-panel.js +62 -30
  44. package/dist/usage/collect.js +20 -1
  45. package/dist/usage/summary.js +48 -1
  46. package/dist/usage/types.js +27 -0
  47. package/package.json +2 -2
@@ -1,28 +1,46 @@
1
1
  import { appendFileSync, mkdirSync, statSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { APP_VERSION } from "../constants.js";
4
+ import { formatBytes } from "../utils/disk.js";
4
5
  import { sessionFile } from "./paths.js";
6
+ import { pruneSessions } from "./prune.js";
5
7
  import { SESSION_FILE_VERSION, } from "./types.js";
8
+ /**
9
+ * How many pruned sessions is worth telling the user about unprompted.
10
+ *
11
+ * Retention on an existing install is a one-off cliff: every build so far had
12
+ * none, so the first prune after upgrading can remove months at once. That
13
+ * should not happen silently. Steady-state prunes remove one or two files and
14
+ * stay at `debug`, where they belong.
15
+ */
16
+ const PRUNE_NOTICE_THRESHOLD = 10;
6
17
  export class SessionLog {
7
18
  file;
8
19
  logger;
9
20
  currentRunId;
10
21
  /** Set once a write fails: the log goes inert rather than warning per turn. */
11
22
  broken = false;
23
+ /**
24
+ * A NEW session's `meta` line, held until the session records something.
25
+ * Null on a reopen (the file already has its meta) and null again the moment
26
+ * it is flushed — see {@link write}.
27
+ */
28
+ pendingMeta = null;
12
29
  constructor(file, opts) {
13
30
  this.file = file;
14
31
  this.logger = opts.logger;
15
32
  this.currentRunId = opts.currentRunId;
16
33
  }
17
34
  /**
18
- * Open (or create) a session log.
35
+ * Open a session log. WRITES NOTHING for a new session — see the note on the
36
+ * class about why the file no longer exists before the conversation does.
19
37
  *
20
- * A NEW file gets its `meta` line. Reopening an existing one (a `--resume`)
21
- * still does NOT get a second `meta` — that ruling is unchanged and is the
22
- * reason this method has always branched: `meta` describes where and when the
23
- * conversation began, and a second copy written from wherever it was resumed
24
- * would make "the session's directory" ambiguous. Replay takes the first
25
- * `meta`, so a duplicate could only ever mislead a later reader.
38
+ * A NEW session gets its `meta` line BUFFERED. Reopening an existing one (a
39
+ * `--resume`) still does NOT get a second `meta` — that ruling is unchanged
40
+ * and is the reason this method has always branched: `meta` describes where
41
+ * and when the conversation began, and a second copy written from wherever it
42
+ * was resumed would make "the session's directory" ambiguous. Replay takes
43
+ * the first `meta`, so a duplicate could only ever mislead a later reader.
26
44
  *
27
45
  * What a reopen gets instead is a `resumed` event (#172 item 2). It carries
28
46
  * the directory this run is in, which is the fact that used to vanish: a
@@ -31,16 +49,33 @@ export class SessionLog {
31
49
  * file forgot immediately. `meta` stays singular and authoritative; the
32
50
  * reopen is a separate kind, and no reader can confuse the two.
33
51
  *
34
- * Returns `null` when a new log cannot be created at all the caller then
35
- * runs without persistence rather than failing, exactly as a corrupt usage
36
- * store is survivable. A reopen still never returns null: the `resumed` write
37
- * is now the first append, and a write problem there warns and goes inert
38
- * exactly as any other failed append does. A session that cannot record its
39
- * own reopen is not a session worth refusing to continue.
52
+ * The reopen write stays EAGER, and deliberately: a reopen is a fact about a
53
+ * conversation that already exists on disk, and `tree.test.ts` pins that it
54
+ * lifts a resumed session to the top of the picker with nothing said to it.
55
+ * Deferring it would quietly undo that.
56
+ *
57
+ * WHAT A FRESH OPEN NO LONGER TELLS YOU. It used to return `null` when the
58
+ * meta write failed, which is how the caller learned up front that
59
+ * persistence was dead. Nothing is written now, so there is nothing to fail:
60
+ * an unwritable home surfaces on the FIRST append instead, as one warning,
61
+ * and the session continues in memory. That is not a new tolerance — it is
62
+ * exactly how the reopen path has always behaved ("a session that cannot
63
+ * record its own reopen is not a session worth refusing to continue"), now
64
+ * applied to both branches rather than one.
65
+ *
66
+ * The `| null` in the signature is kept for the next thing to land here — a
67
+ * `sessions.enabled: false` that declines to record at all (#257) — rather
68
+ * than churning every call site twice.
40
69
  */
41
70
  static open(opts) {
71
+ // `sessions.enabled: false` — record nothing. The session runs in memory,
72
+ // which is the same degraded mode an unwritable home produces, reached on
73
+ // purpose. Every consumer already treats a missing recorder as normal.
74
+ if (opts.sessions?.enabled === false)
75
+ return null;
42
76
  const file = opts.file ?? sessionFile(opts.cwd, opts.sessionId);
43
77
  const log = new SessionLog(file, opts);
78
+ log.pruneOnce(opts);
44
79
  if (hasContent(file)) {
45
80
  log.write({
46
81
  kind: "resumed",
@@ -51,7 +86,10 @@ export class SessionLog {
51
86
  });
52
87
  return log;
53
88
  }
54
- const ok = log.write({
89
+ // Buffered, not written. `startedAt` is stamped HERE rather than at flush:
90
+ // it means "when this conversation began", and the session began when it
91
+ // was opened, not when the user got around to saying something.
92
+ log.pendingMeta = {
55
93
  kind: "meta",
56
94
  version: SESSION_FILE_VERSION,
57
95
  sessionId: opts.sessionId,
@@ -61,8 +99,8 @@ export class SessionLog {
61
99
  cliVersion: APP_VERSION,
62
100
  ...(opts.provider !== undefined ? { provider: opts.provider } : {}),
63
101
  ...(opts.model !== undefined ? { model: opts.model } : {}),
64
- });
65
- return ok ? log : null;
102
+ };
103
+ return log;
66
104
  }
67
105
  /** Messages appended to the history since the last event. */
68
106
  append(messages) {
@@ -99,8 +137,11 @@ export class SessionLog {
99
137
  }
100
138
  /**
101
139
  * One turn's token usage. Copied here rather than referenced, because the
102
- * usage store keeps only its newest 50 runs while sessions are kept
103
- * indefinitely — see the note on `UsageEventSchema`.
140
+ * usage store keeps only its newest 50 runs while a session keeps its own
141
+ * for as long as the session survives `sessions.retention` — see the note on
142
+ * `UsageEventSchema`. (That used to say "indefinitely", which was true until
143
+ * #257 gave sessions a bound; the two stores still expire independently, and
144
+ * the copy is still what makes a restored session's numbers whole.)
104
145
  */
105
146
  usage(inputTokens, outputTokens) {
106
147
  this.write({
@@ -130,18 +171,76 @@ export class SessionLog {
130
171
  count,
131
172
  });
132
173
  }
174
+ /**
175
+ * Enforce retention for this project, once, as this session opens.
176
+ *
177
+ * ORDERING: before the `resumed` write and before any meta flush, so the
178
+ * count cap is applied to the sessions that were there BEFORE this one and a
179
+ * fresh session never has to compete with itself for a slot. On a resume the
180
+ * file already exists and is explicitly excluded by id.
181
+ *
182
+ * Skipped entirely when the `file` seam is in use — see the note on
183
+ * {@link SessionLogOptions.sessions}.
184
+ */
185
+ pruneOnce(opts) {
186
+ if (!opts.sessions || opts.file !== undefined)
187
+ return;
188
+ let result;
189
+ try {
190
+ result = pruneSessions(opts.cwd, {
191
+ sessions: opts.sessions,
192
+ activeSessionId: opts.sessionId,
193
+ });
194
+ }
195
+ catch (err) {
196
+ // Retention is housekeeping. It does not get to fail a session.
197
+ this.logger?.debug(`session retention skipped: ${err.message}`);
198
+ return;
199
+ }
200
+ if (result.removed.length === 0)
201
+ return;
202
+ const line = `pruned ${result.removed.length} session${result.removed.length === 1 ? "" : "s"} ` +
203
+ `(${formatBytes(result.bytesFreed)}) past sessions.retention/maxAgeDays`;
204
+ if (result.removed.length >= PRUNE_NOTICE_THRESHOLD) {
205
+ this.logger?.info(`${line} — see \`cruxy sessions\``);
206
+ }
207
+ else {
208
+ this.logger?.debug(line);
209
+ }
210
+ }
133
211
  runId() {
134
212
  const id = this.currentRunId?.();
135
213
  return id === undefined ? {} : { runId: id };
136
214
  }
137
215
  /**
138
- * Append one event as a single line. Returns whether it landed. The first
139
- * failure warns and latches `broken`, so a persistent problem (a full disk)
140
- * produces one diagnostic rather than one per turn.
216
+ * Record one event, flushing a buffered `meta` ahead of it.
217
+ *
218
+ * This is where a new session's file comes into existence: the first event to
219
+ * reach here brings the `meta` line with it, so the on-disk order is exactly
220
+ * what it always was and no reader can observe the difference.
221
+ *
222
+ * `pendingMeta` is cleared BEFORE the write is attempted. If the meta write
223
+ * fails, `broken` latches and every later event is refused — leaving the meta
224
+ * queued for a retry would let it land AFTER events it is supposed to
225
+ * precede, which is the one ordering the format depends on.
141
226
  */
142
227
  write(event) {
143
228
  if (this.broken)
144
229
  return false;
230
+ const meta = this.pendingMeta;
231
+ if (meta !== null) {
232
+ this.pendingMeta = null;
233
+ if (!this.writeLine(meta))
234
+ return false;
235
+ }
236
+ return this.writeLine(event);
237
+ }
238
+ /**
239
+ * Append one event as a single line. Returns whether it landed. The first
240
+ * failure warns and latches `broken`, so a persistent problem (a full disk)
241
+ * produces one diagnostic rather than one per turn.
242
+ */
243
+ writeLine(event) {
145
244
  try {
146
245
  mkdirSync(path.dirname(this.file), { recursive: true });
147
246
  // `mode` applies only when the file is created — 0600 from the first
@@ -0,0 +1,106 @@
1
+ import { unlinkSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { SESSION_RETENTION_FLOOR, } from "../config/index.js";
4
+ import { sessionFilesByRecency } from "./list.js";
5
+ import { SESSION_FILE_EXT } from "./paths.js";
6
+ /** `<sessionId>.jsonl` → `<sessionId>`. */
7
+ function idOf(file) {
8
+ return path.basename(file, SESSION_FILE_EXT);
9
+ }
10
+ /**
11
+ * Enforce `sessions.retention` and `sessions.maxAgeDays` on one project.
12
+ *
13
+ * Age is the primary bound and is measured on MTIME, never on
14
+ * `meta.startedAt`: a conversation begun forty days ago and resumed this
15
+ * morning is live, and an age check on when it BEGAN would delete it out from
16
+ * under the user. mtime is also the field the scan already carries, so the
17
+ * correct rule is the cheap one.
18
+ *
19
+ * The count cap is the backstop, applied by position in the same
20
+ * newest-first ordering. It exists because age alone does not protect a
21
+ * directory where someone runs forty sessions a day.
22
+ *
23
+ * Never throws. A file that cannot be unlinked is counted in `failed` and the
24
+ * sweep continues — retention failing is not a reason to take a session down,
25
+ * exactly as a failed session write is not.
26
+ */
27
+ export function pruneSessions(cwd, opts) {
28
+ const result = {
29
+ removed: [],
30
+ kept: 0,
31
+ bytesFreed: 0,
32
+ failed: 0,
33
+ };
34
+ if (!opts.sessions.enabled)
35
+ return result;
36
+ // Defensive, mirroring `usage/store.ts`: the schema rejects anything below
37
+ // the floor, but this is also reachable with a hand-built config.
38
+ const retention = Math.max(SESSION_RETENTION_FLOOR, Math.floor(opts.sessions.retention));
39
+ const now = opts.now ?? Date.now();
40
+ const cutoff = now - opts.sessions.maxAgeDays * 24 * 60 * 60 * 1000;
41
+ // Newest first. Every file here ends in `.jsonl` and is a regular file — see
42
+ // the note on the scan about why `subagents/` depends on that.
43
+ const files = sessionFilesByRecency(cwd);
44
+ for (const [index, ref] of files.entries()) {
45
+ const sessionId = idOf(ref.file);
46
+ // WHY THE FILENAME IS TRUSTED HERE, when `listSessionRefs` refuses to.
47
+ // That refusal is about what may be OFFERED: a session resolvable by
48
+ // filename but unloadable for want of a meta line is a `--resume` that
49
+ // fails after the user picked it. This is the opposite question. Being
50
+ // conservative about what to DELETE and permissive about what to offer are
51
+ // opposite failure modes, and the cheap answer is the safe one on this
52
+ // side — at worst a filename that does not match the meta spares a file
53
+ // that could have gone. Do not "fix" this to `readMeta`; that puts a full
54
+ // read of every session back on the startup path.
55
+ if (sessionId === opts.activeSessionId) {
56
+ result.kept++;
57
+ continue;
58
+ }
59
+ const tooOld = ref.mtimeMs < cutoff;
60
+ const beyondCap = index >= retention;
61
+ if (!tooOld && !beyondCap) {
62
+ result.kept++;
63
+ continue;
64
+ }
65
+ try {
66
+ unlinkSync(ref.file);
67
+ result.removed.push({ ...ref, sessionId });
68
+ result.bytesFreed += ref.size;
69
+ }
70
+ catch {
71
+ result.failed++;
72
+ result.kept++;
73
+ }
74
+ }
75
+ sweepOrphanedJobLogs();
76
+ return result;
77
+ }
78
+ /**
79
+ * Drop retained job logs whose owning session has just been pruned.
80
+ *
81
+ * DELIBERATELY EMPTY, and reserved rather than forgotten. #172 item 1 will
82
+ * write background-job output to the `subagents/` subtree `paths.ts` reserves
83
+ * under this same project directory, and #257 exists because that issue asked
84
+ * "whether the retained-on-disk log is pruned with the session or independently"
85
+ * and found no answer. The answer is HERE, and it is: with the session, keyed on
86
+ * the session.
87
+ *
88
+ * That makes a job log content owned by its session in exactly the way a
89
+ * checkpoint's shadow objects are content owned by its manifest — so this is
90
+ * `CheckpointService.prune`'s second phase, the mark-and-sweep that runs after
91
+ * the doomed manifests are gone. Filling it in needs one thing the writer does
92
+ * not have yet: a job log has to record which session owns it, in its path or
93
+ * its first line.
94
+ *
95
+ * The hook is here so item 1 fills it rather than inventing a parallel
96
+ * retention policy alongside this one. Whatever lands must not `rm -r` this
97
+ * directory and must not assume every entry is a session's — `subagents/` is a
98
+ * sibling of the session files, not one of them.
99
+ *
100
+ * It takes nothing today because it does nothing; item 1 will want the `cwd`
101
+ * and the {@link PruneResult} whose `removed` names the sessions whose logs are
102
+ * now orphaned.
103
+ */
104
+ function sweepOrphanedJobLogs() {
105
+ // #172 item 1.
106
+ }
@@ -152,6 +152,11 @@ export function resolveSessionId(cwd, id) {
152
152
  throw usageError(`no session \`${id}\` in this project`, [
153
153
  "run `cruxy --resume` to pick from recent sessions",
154
154
  "sessions are per-directory; check you are in the right one",
155
+ // #257: retention can now delete a session the user still remembers the
156
+ // id of, and a pruned session must not read as a bug. The checkpoint
157
+ // equivalent (`errors/constructors.ts`, CRUXY_E_CHECKPOINT_NOT_FOUND)
158
+ // says the same thing for the same reason.
159
+ "old sessions are pruned by retention — adjust `sessions.retention` / `sessions.maxAgeDays` in config to keep more",
155
160
  ]);
156
161
  }
157
162
  return summary;
@@ -1,8 +1,8 @@
1
1
  import path from "node:path";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { runAgent } from "../agent/loop.js";
4
- import { classifyProviderError, CruxyError, ErrorCode, messageOf, sessionBudgetExhausted, subagentDepthExceeded, subagentScopeOverlap, } from "../errors/index.js";
5
- import { UsageCollector } from "../usage/index.js";
4
+ import { CruxyError, ErrorCode, messageOf, poolDenial, sessionBudgetExhausted, subagentDepthExceeded, subagentScopeOverlap, } from "../errors/index.js";
5
+ import { UsageCollector, } from "../usage/index.js";
6
6
  import { UNRESOLVED_TIER, } from "../budget/index.js";
7
7
  import { resolveTaskModel } from "../routing/index.js";
8
8
  import { Workspace } from "../workspace/index.js";
@@ -76,7 +76,30 @@ export class SubagentOrchestrator {
76
76
  if (childDepth < maxDepth) {
77
77
  registry.register(makeSpawnSubagentTool(this, childDepth));
78
78
  }
79
- const budget = new Budget(resolveBudget(defaultBudget, spec.budget));
79
+ // Admission control for the SEQUENTIAL seam (cli#245). `spawnMany` asks for
80
+ // its whole batch and passes the verdict down; everything else — the
81
+ // `spawn_subagent` tool, a plan step, a nested child — arrives here having
82
+ // asked nobody, which is the hole cli#243 left behind. A refusal throws the
83
+ // same coded error the fan-out throws, and the spawn tool hands it to the
84
+ // model as a tool error it can act on (see `PROPAGATE` in `spawn-tool.ts`).
85
+ const admission = opts.admission ?? this.admitRuns(1, [spec]);
86
+ if (admission.kind === "refused") {
87
+ throw sessionBudgetExhausted(admission.reason);
88
+ }
89
+ if (admission.kind === "narrowed" && !opts.admission) {
90
+ deps.logger.warn(admission.reason);
91
+ }
92
+ // `admit` only ever NARROWS, and `Math.min` is what makes that true here: a
93
+ // batch verdict carries the heaviest child's ceiling, so applying it raw to
94
+ // a cheaper sibling would RAISE that child's cap on the strength of an
95
+ // admission check. `0` means the budget bounded nothing.
96
+ const resolved = resolveBudget(defaultBudget, spec.budget);
97
+ const budget = new Budget(admission.maxTokens > 0
98
+ ? {
99
+ ...resolved,
100
+ maxTokens: Math.min(resolved.maxTokens, admission.maxTokens),
101
+ }
102
+ : resolved);
80
103
  // Root scoping (C.33): a `spec.root` narrows the child's cwd + confinement to
81
104
  // that ONE root (its writes land there and nowhere else). Omitted → the full
82
105
  // session workspace, unchanged from C.14. An unknown name fails loud here
@@ -150,12 +173,19 @@ export class SubagentOrchestrator {
150
173
  // sibling's pool denial cancelled, still drew whatever it drew before it
151
174
  // stopped, and the record is what it reported.
152
175
  //
153
- // NOT ALSO WRITTEN TO THE USAGE STORE. Child requests have never appeared
154
- // in `/usage` and this does not change that `/budget` now counts them
155
- // and `/usage` still does not, so the two figures can differ by a
156
- // fan-out's worth. That gap is real and pre-dates this; closing it means
157
- // persisting child runs, which is a change to what is on disk and belongs
158
- // in its own right rather than smuggled in behind an admission fix.
176
+ // STILL NOT WRITTEN TO THE USAGE STORE AS A RUN OF ITS OWN, and now for a
177
+ // different reason than when this was written (cli#244 closed the gap the
178
+ // old note described). The child's requests DO reach `/usage` forwarded
179
+ // above into the spawning turn's collector, so they land as entries of the
180
+ // turn's record. What this record is for is the budget, and only the
181
+ // budget: it exists so the fold can happen the MOMENT the child finishes
182
+ // rather than at end of turn, because the next admission check inside the
183
+ // same turn has to see the spend.
184
+ //
185
+ // Which is exactly why the parent must NOT hand these same entries to the
186
+ // budget again when its turn closes — see the `origin` filter at
187
+ // `agent/session.ts`. Two folds of one child's tokens would halve the
188
+ // effective session cap on any turn that fans out.
159
189
  this.deps.budget?.record(usage.toRecord(randomUUID(), undefined, startedAt));
160
190
  }
161
191
  }
@@ -183,7 +213,16 @@ export class SubagentOrchestrator {
183
213
  router: deps.router,
184
214
  taskClass: spec.taskClass ?? "subagent",
185
215
  signal: opts.signal,
186
- onRequestUsage: (req) => usage.record(req),
216
+ // Both, and in this order (cli#244). The child's OWN collector is what
217
+ // the budget fold below reads — it needs a per-child record — while the
218
+ // parent's sink is what puts this request in `/usage` at all. The
219
+ // explicit origin is load-bearing: the parent's collector defaults to
220
+ // `"turn"`, and a child's request inheriting that would be a lie about
221
+ // what the user did.
222
+ onRequestUsage: (req) => {
223
+ usage.record(req);
224
+ opts.onRequestUsage?.({ ...req, origin: "subagent" });
225
+ },
187
226
  });
188
227
  }
189
228
  catch (err) {
@@ -277,7 +316,7 @@ export class SubagentOrchestrator {
277
316
  // AFTER the scope check on purpose: an overlapping fan-out is malformed and
278
317
  // must be refused whatever the budget says, and narrowing a malformed batch
279
318
  // to two children would hide the overlap rather than report it.
280
- const admitted = this.admitFanOut(specs);
319
+ const admitted = this.admitRuns(specs.length, specs);
281
320
  if (admitted.kind === "refused") {
282
321
  throw sessionBudgetExhausted(admitted.reason);
283
322
  }
@@ -317,6 +356,17 @@ export class SubagentOrchestrator {
317
356
  results[i] = await this.spawn(spec, parentDepth, {
318
357
  signal: controller.signal,
319
358
  slot: `${i + 1}/${total}`,
359
+ // The batch verdict, so the child does not ask a second time —
360
+ // and so a cut token cap actually reaches the run it was cut
361
+ // for. `admit` returns one for a single run it can only
362
+ // partially afford, and until cli#245 `spawnMany` read the
363
+ // count off that verdict and dropped the ceiling with it.
364
+ admission: admitted,
365
+ // Every child of the batch reports into the one turn that
366
+ // spawned them, exactly as a sequential child does.
367
+ ...(opts.onRequestUsage
368
+ ? { onRequestUsage: opts.onRequestUsage }
369
+ : {}),
320
370
  });
321
371
  }
322
372
  catch (err) {
@@ -347,7 +397,13 @@ export class SubagentOrchestrator {
347
397
  }
348
398
  }
349
399
  /**
350
- * Ask the session budget whether this batch fits (P10 track 3 / cli#212).
400
+ * Ask the session budget whether these runs fit (P10 track 3 / cli#212).
401
+ *
402
+ * ONE method for both seams (cli#245): a batch of N from `spawnMany`, and the
403
+ * single run `spawn` asks for when nobody admitted it. They differ only in
404
+ * `count` — the arithmetic, the tier resolution and the per-run ceiling are
405
+ * the same question, and a second copy for the singular case is how the two
406
+ * would come to disagree.
351
407
  *
352
408
  * The estimate is the batch's CEILING, not a guess at its actual draw:
353
409
  * `count × perChildTokens × multiplier`, where `perChildTokens` is the local
@@ -361,17 +417,17 @@ export class SubagentOrchestrator {
361
417
  * the pool, so it is weighed at the worst case rather than skipped — see
362
418
  * `UNRESOLVED_TIER`.
363
419
  */
364
- admitFanOut(specs) {
420
+ admitRuns(count, specs) {
365
421
  const budget = this.deps.budget;
366
422
  if (!budget)
367
- return { kind: "allow", count: specs.length, maxTokens: 0 };
423
+ return { kind: "allow", count, maxTokens: 0 };
368
424
  const { defaultBudget } = this.deps.config.subagent;
369
425
  // The heaviest child in the batch sets the per-run figure. The bound has to
370
426
  // hold for the batch as dispatched, and averaging would let one 64k child
371
427
  // hide behind four 4k ones.
372
428
  const perRunTokens = specs.reduce((max, spec) => Math.max(max, resolveBudget(defaultBudget, spec.budget).maxTokens), 0);
373
429
  return budget.admit({
374
- count: specs.length,
430
+ count,
375
431
  perRunTokens,
376
432
  tier: this.fanOutTier(),
377
433
  });
@@ -489,22 +545,6 @@ function taskLabel(task) {
489
545
  function isWriter(spec) {
490
546
  return (spec.tools ?? []).some((t) => SUBAGENT_WRITE_TOOLS.has(t));
491
547
  }
492
- /**
493
- * The typed pool denial behind an error, or `null` if it is not one.
494
- *
495
- * Two shapes reach here and both are the same fact: the raw SDK
496
- * `BudgetExhaustedError` from this child's own request, and — when a child that
497
- * itself spawned re-throws — the `CruxyError` a nested `spawn` already
498
- * converted. Mapping goes through `classifyProviderError` so there is still ONE
499
- * place that knows which SDK class means what.
500
- */
501
- function poolDenial(err) {
502
- if (CruxyError.is(err)) {
503
- return err.code === ErrorCode.BudgetExhausted ? err : null;
504
- }
505
- const typed = classifyProviderError(err);
506
- return typed?.code === ErrorCode.BudgetExhausted ? typed : null;
507
- }
508
548
  /**
509
549
  * The tokens a child is KNOWN to have spent, or `undefined` when no request
510
550
  * reported any.
@@ -91,7 +91,7 @@ export function makeSpawnSubagentTool(orchestrator, depth) {
91
91
  "transcript is discarded. Use for independent subtasks whose details you don't " +
92
92
  'need in your own context (e.g. "find where X is configured and report the paths").',
93
93
  parameters,
94
- async execute(input) {
94
+ async execute(input, ctx) {
95
95
  const budget = {
96
96
  ...(input.maxIterations !== undefined
97
97
  ? { maxIterations: input.maxIterations }
@@ -102,7 +102,12 @@ export function makeSpawnSubagentTool(orchestrator, depth) {
102
102
  };
103
103
  let result;
104
104
  try {
105
- result = await orchestrator.spawn({ task: input.task, tools: input.tools, budget }, depth);
105
+ result = await orchestrator.spawn({ task: input.task, tools: input.tools, budget }, depth,
106
+ // The calling turn's usage collector (cli#244), the one thing on `ctx`
107
+ // that is per-turn rather than per-session. Absent outside a turn, in
108
+ // which case the child's spend still reaches the budget — it just has
109
+ // no record to join.
110
+ ctx.onRequestUsage ? { onRequestUsage: ctx.onRequestUsage } : {});
106
111
  }
107
112
  catch (err) {
108
113
  // Non-interactive default-deny propagates to the boundary (U.3) —
@@ -186,10 +191,12 @@ export function makeSpawnSubagentsTool(orchestrator, depth) {
186
191
  "For children that WRITE, give each a distinct `root`; overlapping write scope is " +
187
192
  "refused. For a single subtask, use spawn_subagent instead.",
188
193
  parameters: batchParameters,
189
- async execute(input) {
194
+ async execute(input, ctx) {
190
195
  let results;
191
196
  try {
192
- results = await orchestrator.spawnMany(input.tasks.map(toSpec), depth);
197
+ results = await orchestrator.spawnMany(input.tasks.map(toSpec), depth,
198
+ // Every child of the batch reports into the one turn that spawned it.
199
+ ctx.onRequestUsage ? { onRequestUsage: ctx.onRequestUsage } : {});
193
200
  }
194
201
  catch (err) {
195
202
  // Non-interactive default-deny propagates to the boundary (U.3).
@@ -59,6 +59,24 @@ export function schemaDepth(node) {
59
59
  * does not raise it there — it only moves the failure from a red CI run to every
60
60
  * user's terminal. If a schema cannot fit, the schema changes.
61
61
  *
62
+ * THE MIRROR ONLY PROTECTS ONE DIRECTION. Raising this literal is caught by the
63
+ * paragraph above; the gateway TIGHTENING it is caught by nothing here. If
64
+ * `maxSchemaDepth` drops to 7, `apply_patch` and `spawn_subagents` — both at
65
+ * rendered depth 7, a margin accepted deliberately in #233 — are dead in the
66
+ * field for every user on every turn, and this repo's CI stays green, because
67
+ * the test written to prevent exactly that outage is asserting against a stale
68
+ * copy of the number. `mcp/bounds.ts` reads this same constant, so a drift takes
69
+ * the MCP guard with it.
70
+ *
71
+ * Asked on cruxy-ai/api#183 whether the bound could be SERVED rather than
72
+ * mirrored. Answer, in cruxy-ai/api#187 (merged 2026-08-19): half of it. A
73
+ * rejection is now self-describing — code `invalid_tool_schema` with `tool`,
74
+ * `bound` (`bytes` | `depth` | `nodes`) and `limit` as structured fields — but
75
+ * nothing reports the bound before a request is sent. So this stays a hand-copy,
76
+ * CI cannot catch a tightening, and the runtime error is the ONLY detector we
77
+ * have: a rejection whose `limit` disagrees with this constant means the
78
+ * constant is stale, not that one tool is bad. Fix it here, not at the tool.
79
+ *
62
80
  * The bound is EXCLUSIVE: a schema is safe at `MAX_SCHEMA_DEPTH - 1` and dies at
63
81
  * `MAX_SCHEMA_DEPTH`. Both consumers must spell that the same way — the CI gate
64
82
  * asserts `depth < MAX_SCHEMA_DEPTH`, the MCP bound trips on