@cruxy/cli 0.23.0 → 0.24.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 (59) hide show
  1. package/dist/agent/loop.d.ts +21 -2
  2. package/dist/agent/loop.js +21 -5
  3. package/dist/approval/index.d.ts +1 -0
  4. package/dist/approval/index.js +1 -0
  5. package/dist/approval/mutex.d.ts +45 -0
  6. package/dist/approval/mutex.js +57 -0
  7. package/dist/checkpoint/service.d.ts +9 -0
  8. package/dist/checkpoint/service.js +20 -0
  9. package/dist/cli/commands/run.js +50 -16
  10. package/dist/cli/onboard.js +2 -2
  11. package/dist/cli/repl.js +39 -0
  12. package/dist/cli/session-factory.d.ts +23 -1
  13. package/dist/cli/session-factory.js +137 -47
  14. package/dist/config/schema.d.ts +24 -0
  15. package/dist/config/schema.js +9 -0
  16. package/dist/errors/constructors.d.ts +23 -0
  17. package/dist/errors/constructors.js +38 -0
  18. package/dist/errors/types.d.ts +8 -0
  19. package/dist/errors/types.js +12 -0
  20. package/dist/hooks/index.d.ts +1 -0
  21. package/dist/hooks/index.js +1 -0
  22. package/dist/hooks/router.d.ts +58 -0
  23. package/dist/hooks/router.js +136 -0
  24. package/dist/hooks/runner.d.ts +12 -0
  25. package/dist/hooks/runner.js +23 -1
  26. package/dist/mcp/index.d.ts +1 -0
  27. package/dist/mcp/index.js +1 -0
  28. package/dist/mcp/sibling-banner.d.ts +25 -0
  29. package/dist/mcp/sibling-banner.js +34 -0
  30. package/dist/memory/recall.d.ts +24 -0
  31. package/dist/memory/recall.js +54 -0
  32. package/dist/memory/remember-tool.d.ts +3 -0
  33. package/dist/memory/remember-tool.js +11 -1
  34. package/dist/sandbox/policy.js +14 -5
  35. package/dist/sandbox/service.d.ts +8 -1
  36. package/dist/sandbox/service.js +4 -1
  37. package/dist/subagent/index.d.ts +1 -0
  38. package/dist/subagent/index.js +1 -0
  39. package/dist/subagent/orchestrator.d.ts +67 -2
  40. package/dist/subagent/orchestrator.js +203 -18
  41. package/dist/subagent/registry-scope.d.ts +13 -0
  42. package/dist/subagent/registry-scope.js +28 -2
  43. package/dist/subagent/semaphore.d.ts +27 -0
  44. package/dist/subagent/semaphore.js +56 -0
  45. package/dist/subagent/spawn-tool.d.ts +57 -0
  46. package/dist/subagent/spawn-tool.js +104 -9
  47. package/dist/subagent/types.d.ts +17 -2
  48. package/dist/testing/run-tests-tool.js +1 -1
  49. package/dist/tools/file/paths.d.ts +5 -6
  50. package/dist/tools/file/paths.js +7 -8
  51. package/dist/tools/shell/exec.js +36 -4
  52. package/dist/tools/types.d.ts +16 -5
  53. package/dist/workspace/add-root.d.ts +27 -0
  54. package/dist/workspace/add-root.js +16 -0
  55. package/dist/workspace/index.d.ts +2 -1
  56. package/dist/workspace/index.js +2 -1
  57. package/dist/workspace/workspace.d.ts +9 -4
  58. package/dist/workspace/workspace.js +9 -4
  59. package/package.json +1 -1
@@ -1,8 +1,10 @@
1
1
  import path from "node:path";
2
2
  import { runAgent } from "../agent/loop.js";
3
- import { CruxyError, ErrorCode, messageOf, subagentDepthExceeded, } from "../errors/index.js";
3
+ import { CruxyError, ErrorCode, messageOf, subagentDepthExceeded, subagentScopeOverlap, } from "../errors/index.js";
4
+ import { Workspace } from "../workspace/index.js";
4
5
  import { Budget, resolveBudget } from "./budget.js";
5
- import { scopeRegistry } from "./registry-scope.js";
6
+ import { scopeRegistry, SUBAGENT_WRITE_TOOLS } from "./registry-scope.js";
7
+ import { Semaphore } from "./semaphore.js";
6
8
  import { makeSpawnSubagentTool } from "./spawn-tool.js";
7
9
  /** Longest task excerpt shown in render chrome — display, not record. */
8
10
  const LABEL_MAX = 60;
@@ -21,8 +23,20 @@ const LABEL_MAX = 60;
21
23
  */
22
24
  export class SubagentOrchestrator {
23
25
  deps;
26
+ /**
27
+ * The ONE shared bound on parallel fan-out (C.33, JC-D). Constructed once per
28
+ * session and reused by every {@link spawnMany} call (nested spawns reuse this
29
+ * same orchestrator instance), so `subagent.maxConcurrency` caps the number of
30
+ * concurrently-executing subagents across the WHOLE session — not per parent.
31
+ */
32
+ sem;
24
33
  constructor(deps) {
25
34
  this.deps = deps;
35
+ this.sem = new Semaphore(deps.config.subagent.maxConcurrency);
36
+ }
37
+ /** Live/queued fan-out slots (inspection/tests): proves the global cap holds. */
38
+ get concurrency() {
39
+ return { available: this.sem.available, waiting: this.sem.waiting };
26
40
  }
27
41
  /**
28
42
  * Run one subagent to completion. `parentDepth` is the spawner's depth (the
@@ -36,7 +50,7 @@ export class SubagentOrchestrator {
36
50
  * (non-interactive default-deny must reach the boundary, U.3 — a subagent is
37
51
  * not a way to swallow it).
38
52
  */
39
- async spawn(spec, parentDepth) {
53
+ async spawn(spec, parentDepth, opts = {}) {
40
54
  const { deps } = this;
41
55
  const { maxDepth, defaultBudget } = deps.config.subagent;
42
56
  if (parentDepth >= maxDepth) {
@@ -45,33 +59,44 @@ export class SubagentOrchestrator {
45
59
  const childDepth = parentDepth + 1;
46
60
  // Scoped-down registry (throws on a tool the parent lacks — the spawn tool
47
61
  // surfaces that to the model), plus a depth-bound spawn tool only while
48
- // nesting is still allowed.
62
+ // nesting is still allowed. (Only the SEQUENTIAL spawn is re-added; parallel
63
+ // fan-out stays a depth-0 capability, so a child cannot nest a fan-out.)
49
64
  const registry = scopeRegistry(deps.parentRegistry, spec.tools);
50
65
  if (childDepth < maxDepth) {
51
66
  registry.register(makeSpawnSubagentTool(this, childDepth));
52
67
  }
53
68
  const budget = new Budget(resolveBudget(defaultBudget, spec.budget));
69
+ // Root scoping (C.33): a `spec.root` narrows the child's cwd + confinement to
70
+ // that ONE root (its writes land there and nowhere else). Omitted → the full
71
+ // session workspace, unchanged from C.14. An unknown name fails loud here
72
+ // (CRUXY_E_ROOT_UNKNOWN), which the spawn tool surfaces to the model.
73
+ const scope = this.childScope(spec.root);
54
74
  // Fresh gate per child (own allowlist), recording what it approves so the
55
- // result can name the artifacts without ever shipping the transcript.
75
+ // result can name the artifacts without ever shipping the transcript. The
76
+ // gate itself is serialized by the shared approval mutex (wired at the
77
+ // session factory), so concurrent siblings never prompt or checkpoint at once.
56
78
  const approve = deps.makeChildApproval();
57
79
  const artifacts = new Set();
58
80
  const ctx = {
59
- cwd: deps.cwd,
60
- workspace: deps.workspace,
81
+ cwd: scope.cwd,
82
+ workspace: scope.workspace,
61
83
  config: deps.config,
62
84
  logger: deps.logger,
63
85
  requestApproval: async (action) => {
64
86
  const decision = await approve(action);
65
87
  if (decision.allow)
66
- recordArtifacts(action, artifacts, deps.cwd);
88
+ recordArtifacts(action, artifacts, scope.cwd);
67
89
  return decision;
68
90
  },
69
91
  checkpointsActive: deps.checkpointsActive,
70
92
  sandbox: deps.sandbox,
93
+ signal: opts.signal,
71
94
  };
72
95
  const label = taskLabel(spec.task);
96
+ const tag = opts.slot; // per-child render label for a fan-out (JC-F)
97
+ const noun = tag ? `subagent[${tag}]` : "subagent";
73
98
  if (deps.renderer) {
74
- deps.renderer.note(`${deps.renderer.theme.glyph.play} subagent: ${label}`);
99
+ deps.renderer.note(`${deps.renderer.theme.glyph.play} ${noun}: ${label}`);
75
100
  }
76
101
  deps.renderer?.setPhase({ kind: "subagent", label });
77
102
  // The isolation seam: a brand-new history seeded with ONLY the task. The
@@ -87,7 +112,7 @@ export class SubagentOrchestrator {
87
112
  config: deps.config,
88
113
  ctx,
89
114
  renderer: deps.renderer
90
- ? new SubagentRenderer(deps.renderer, label)
115
+ ? new SubagentRenderer(deps.renderer, label, tag)
91
116
  : undefined,
92
117
  git: deps.git,
93
118
  projectInstructions: deps.projectInstructions,
@@ -95,6 +120,7 @@ export class SubagentOrchestrator {
95
120
  budget,
96
121
  router: deps.router,
97
122
  taskClass: spec.taskClass ?? "subagent",
123
+ signal: opts.signal,
98
124
  });
99
125
  }
100
126
  catch (err) {
@@ -104,7 +130,7 @@ export class SubagentOrchestrator {
104
130
  throw err;
105
131
  }
106
132
  if (deps.renderer) {
107
- deps.renderer.note(`${deps.renderer.theme.glyph.failure} subagent failed: ${label}`);
133
+ deps.renderer.note(`${deps.renderer.theme.glyph.failure} ${noun} failed: ${label}`);
108
134
  }
109
135
  deps.renderer?.setPhase(null);
110
136
  return {
@@ -117,13 +143,140 @@ export class SubagentOrchestrator {
117
143
  };
118
144
  }
119
145
  deps.renderer?.setPhase(null);
120
- const result = this.toResult(run, artifacts, label);
121
- deps.logger.debug(`subagent ${result.status}: ${result.iterations} turn(s), tokens in/out ` +
146
+ const result = this.toResult(run, artifacts, label, noun);
147
+ deps.logger.debug(`${noun} ${result.status}: ${result.iterations} turn(s), tokens in/out ` +
122
148
  `${result.usage.input_tokens}/${result.usage.output_tokens} — ${label}`);
123
149
  return result;
124
150
  }
151
+ /**
152
+ * Parallel fan-out (C.33): run N children concurrently under the shared
153
+ * concurrency semaphore and fold their outcomes into a result array whose
154
+ * order MATCHES `specs` (position i is spec i's result — never completion
155
+ * order). A DEPTH-0 capability only (the plural tool is never granted to a
156
+ * child), so no permit holder ever nests a second fan-out — the semaphore
157
+ * stays deadlock-free.
158
+ *
159
+ * Safety before dispatch: overlapping write scope is REFUSED
160
+ * (`CRUXY_E_SUBAGENT_SCOPE_OVERLAP`) so two writers can never race on one root.
161
+ *
162
+ * Cancellation: children share one {@link AbortController}. A child returning a
163
+ * `failed`/`budget-exceeded` result is a normal PARTIAL outcome — siblings run
164
+ * on. But a *fatal* throw from any child (non-interactive default-deny) or an
165
+ * abort on `opts.signal` (Ctrl-C) aborts the controller: every sibling stops at
166
+ * its next turn boundary and its in-flight shell child is kill-tree'd, so the
167
+ * fan-out leaves no orphan. All children are awaited to settle before a fatal
168
+ * throw propagates — never a detached, still-running sibling.
169
+ */
170
+ async spawnMany(specs, parentDepth, opts = {}) {
171
+ const { maxDepth } = this.deps.config.subagent;
172
+ if (parentDepth >= maxDepth) {
173
+ throw subagentDepthExceeded(parentDepth, maxDepth);
174
+ }
175
+ if (specs.length === 0)
176
+ return [];
177
+ // Refuse overlapping write scope BEFORE any child is dispatched.
178
+ this.assertDisjointWriteScopes(specs);
179
+ const controller = new AbortController();
180
+ const onExternalAbort = () => controller.abort();
181
+ if (opts.signal) {
182
+ if (opts.signal.aborted)
183
+ controller.abort();
184
+ else
185
+ opts.signal.addEventListener("abort", onExternalAbort, { once: true });
186
+ }
187
+ const results = new Array(specs.length);
188
+ const total = specs.length;
189
+ try {
190
+ const settled = await Promise.allSettled(specs.map((spec, i) => this.sem.run(async () => {
191
+ // Already cancelled (a fatal sibling or Ctrl-C fired first): record an
192
+ // honest cancelled result instead of starting a doomed run.
193
+ if (controller.signal.aborted) {
194
+ results[i] = cancelledResult();
195
+ return;
196
+ }
197
+ try {
198
+ results[i] = await this.spawn(spec, parentDepth, {
199
+ signal: controller.signal,
200
+ slot: `${i + 1}/${total}`,
201
+ });
202
+ }
203
+ catch (err) {
204
+ // A fatal throw (non-interactive default-deny) cancels the whole
205
+ // fan-out — no sibling is left running — then propagates.
206
+ controller.abort();
207
+ throw err;
208
+ }
209
+ })));
210
+ // Any child that was aborted mid-flight (returned stop:"aborted") is folded
211
+ // as cancelled by toResult; a fatal throw surfaces here after all settled.
212
+ const fatal = settled.find((s) => s.status === "rejected");
213
+ if (fatal && fatal.status === "rejected")
214
+ throw fatal.reason;
215
+ // Backfill any slot a cancelled-before-dispatch child left (defensive: the
216
+ // sem callback always assigns, but never ship a hole as success).
217
+ for (let i = 0; i < results.length; i++) {
218
+ if (results[i] === undefined)
219
+ results[i] = cancelledResult();
220
+ }
221
+ return results;
222
+ }
223
+ finally {
224
+ opts.signal?.removeEventListener("abort", onExternalAbort);
225
+ }
226
+ }
227
+ /**
228
+ * Resolve a child's scope from an optional root name. With a name: a
229
+ * single-root workspace over that root (writes confined to it) + that root's
230
+ * cwd. Without: the full session workspace + primary cwd (C.14 behaviour).
231
+ */
232
+ childScope(rootName) {
233
+ if (rootName === undefined) {
234
+ return { cwd: this.deps.cwd, workspace: this.deps.workspace };
235
+ }
236
+ const root = this.deps.workspace.rootByName(rootName); // fail-loud on unknown
237
+ return {
238
+ cwd: root.absPath,
239
+ workspace: new Workspace([
240
+ { name: root.name, absPath: root.absPath, primary: true },
241
+ ]),
242
+ };
243
+ }
244
+ /**
245
+ * Refuse a fan-out where two WRITING children (any mutating tool granted)
246
+ * target the same root — the disjoint-scope guarantee (C.33). A writer with no
247
+ * declared root defaults to the session PRIMARY, so in a single-root session at
248
+ * most one child may write per batch (the rest must be read-only). Read-only
249
+ * children never conflict.
250
+ *
251
+ * Collects EVERY colliding root (not just the first) so the refusal names all
252
+ * conflicting task pairs at once — the model can fix them in one correction.
253
+ * The check is on DECLARED scope (tools + root), an honest over-approximation
254
+ * the error message is explicit about.
255
+ */
256
+ assertDisjointWriteScopes(specs) {
257
+ const primaryName = this.deps.workspace.primary().name;
258
+ const byRoot = new Map(); // root → writing-child labels
259
+ for (const spec of specs) {
260
+ if (!isWriter(spec))
261
+ continue;
262
+ // Validate the named root exists (fail-loud, same as spawn) before claiming.
263
+ const rootName = spec.root === undefined
264
+ ? primaryName
265
+ : this.deps.workspace.rootByName(spec.root).name;
266
+ const claimants = byRoot.get(rootName);
267
+ if (claimants)
268
+ claimants.push(taskLabel(spec.task));
269
+ else
270
+ byRoot.set(rootName, [taskLabel(spec.task)]);
271
+ }
272
+ const conflicts = [...byRoot.entries()]
273
+ .filter(([, tasks]) => tasks.length > 1)
274
+ .map(([root, tasks]) => ({ root, tasks }));
275
+ if (conflicts.length > 0)
276
+ throw subagentScopeOverlap(conflicts);
277
+ }
125
278
  /** Map the child's AgentResult to the structured, transcript-free shape. */
126
- toResult(run, artifacts, label) {
279
+ toResult(run, artifacts, label, noun) {
127
280
  const summary = lastAssistantText(run.messages);
128
281
  const base = {
129
282
  summary,
@@ -134,9 +287,21 @@ export class SubagentOrchestrator {
134
287
  if (run.stop === "completed") {
135
288
  const r = this.deps.renderer;
136
289
  if (r)
137
- r.note(`${r.theme.glyph.success} subagent done: ${label}`);
290
+ r.note(`${r.theme.glyph.success} ${noun} done: ${label}`);
138
291
  return { status: "done", ...base };
139
292
  }
293
+ // Cancellation (C.33): a fatal sibling failure or Ctrl-C stopped this child
294
+ // at a turn boundary. An honest partial result — never a fabricated success.
295
+ if (run.stop === "aborted") {
296
+ const r = this.deps.renderer;
297
+ if (r)
298
+ r.note(`${r.theme.glyph.failure} ${noun} cancelled: ${label}`);
299
+ return {
300
+ status: "cancelled",
301
+ ...base,
302
+ error: `${ErrorCode.SubagentCancelled}: cancelled before completion`,
303
+ };
304
+ }
140
305
  // Both cap paths are the same outcome for the parent: a truncated, partial
141
306
  // result with the reason — informational, never fatal (the parent decides
142
307
  // what to do with it).
@@ -145,7 +310,7 @@ export class SubagentOrchestrator {
145
310
  : `agent.maxIterations ceiling reached (${this.deps.config.agent.maxIterations})`;
146
311
  const r = this.deps.renderer;
147
312
  if (r)
148
- r.note(`${r.theme.glyph.failure} subagent stopped (budget): ${label}`);
313
+ r.note(`${r.theme.glyph.failure} ${noun} stopped (budget): ${label}`);
149
314
  return {
150
315
  status: "budget-exceeded",
151
316
  ...base,
@@ -158,6 +323,22 @@ function taskLabel(task) {
158
323
  const flat = task.replace(/\s+/g, " ").trim();
159
324
  return flat.length > LABEL_MAX ? flat.slice(0, LABEL_MAX - 1) + "…" : flat;
160
325
  }
326
+ /** A child that holds any mutating tool — the disjoint-scope check's unit. A
327
+ * spec with no `tools` gets the default READ-ONLY set, so it is never a writer. */
328
+ function isWriter(spec) {
329
+ return (spec.tools ?? []).some((t) => SUBAGENT_WRITE_TOOLS.has(t));
330
+ }
331
+ /** The honest result for a child cancelled before it could produce anything —
332
+ * used when a fatal sibling / Ctrl-C fired before this slot even dispatched. */
333
+ function cancelledResult() {
334
+ return {
335
+ status: "cancelled",
336
+ summary: "",
337
+ error: `${ErrorCode.SubagentCancelled}: cancelled before dispatch`,
338
+ iterations: 0,
339
+ usage: { input_tokens: 0, output_tokens: 0 },
340
+ };
341
+ }
161
342
  /** `artifacts` only when non-empty — absent beats `[]` in the parent's context. */
162
343
  function artifactsField(artifacts) {
163
344
  return artifacts.size > 0 ? { artifacts: [...artifacts].sort() } : {};
@@ -212,12 +393,16 @@ class SubagentRenderer {
212
393
  inner;
213
394
  label;
214
395
  prefix;
215
- constructor(inner, label) {
396
+ constructor(inner, label, tag) {
216
397
  this.inner = inner;
217
398
  this.label = label;
218
399
  this.caps = inner.caps;
219
400
  this.theme = inner.theme;
220
- this.prefix = `subagent ${inner.theme.glyph.sep} `;
401
+ // A fan-out child carries its slot in the prefix (`subagent[2/3] · …`) so
402
+ // interleaved trail notes stay attributable per-subagent (JC-F); a lone
403
+ // sequential spawn keeps the byte-identical C.14 `subagent · …` prefix.
404
+ const noun = tag ? `subagent[${tag}]` : "subagent";
405
+ this.prefix = `${noun} ${inner.theme.glyph.sep} `;
221
406
  }
222
407
  /** Turn framing belongs to the parent's turn — the child's is dropped. */
223
408
  beginTurn() { }
@@ -8,6 +8,19 @@ import { ToolRegistry } from "../tools/index.js";
8
8
  /** The spawn tool's registered name (excluded from every scoped child set —
9
9
  * the orchestrator re-adds a depth-bound instance only while depth allows). */
10
10
  export declare const SPAWN_SUBAGENT_TOOL_NAME = "spawn_subagent";
11
+ /** The parallel fan-out tool's name (C.33). Stripped from every child scope:
12
+ * parallel fan-out is a depth-0 capability only, so a child can never obtain it
13
+ * (which is also what keeps the concurrency semaphore deadlock-free — no permit
14
+ * holder ever nests a second fan-out). A child may still spawn ONE sequential
15
+ * subagent via {@link SPAWN_SUBAGENT_TOOL_NAME} when depth allows. */
16
+ export declare const SPAWN_SUBAGENTS_TOOL_NAME = "spawn_subagents";
17
+ /**
18
+ * Mutating tools (C.33): a child holding ANY of these is a "writer" for the
19
+ * disjoint-scope check. Two writers in one parallel batch must target distinct
20
+ * roots, or the batch is refused pre-dispatch. Kept in sync with the gated,
21
+ * side-effecting tool set (file writes, shell/test, VCS).
22
+ */
23
+ export declare const SUBAGENT_WRITE_TOOLS: ReadonlySet<string>;
11
24
  /**
12
25
  * The default child toolset: read-only investigation plus skills. Mirrors the
13
26
  * C.31 propose-phase set — no writes, no shell, no VCS unless the parent
@@ -8,6 +8,28 @@ import { ToolRegistry } from "../tools/index.js";
8
8
  /** The spawn tool's registered name (excluded from every scoped child set —
9
9
  * the orchestrator re-adds a depth-bound instance only while depth allows). */
10
10
  export const SPAWN_SUBAGENT_TOOL_NAME = "spawn_subagent";
11
+ /** The parallel fan-out tool's name (C.33). Stripped from every child scope:
12
+ * parallel fan-out is a depth-0 capability only, so a child can never obtain it
13
+ * (which is also what keeps the concurrency semaphore deadlock-free — no permit
14
+ * holder ever nests a second fan-out). A child may still spawn ONE sequential
15
+ * subagent via {@link SPAWN_SUBAGENT_TOOL_NAME} when depth allows. */
16
+ export const SPAWN_SUBAGENTS_TOOL_NAME = "spawn_subagents";
17
+ /**
18
+ * Mutating tools (C.33): a child holding ANY of these is a "writer" for the
19
+ * disjoint-scope check. Two writers in one parallel batch must target distinct
20
+ * roots, or the batch is refused pre-dispatch. Kept in sync with the gated,
21
+ * side-effecting tool set (file writes, shell/test, VCS).
22
+ */
23
+ export const SUBAGENT_WRITE_TOOLS = new Set([
24
+ "write_file",
25
+ "edit_file",
26
+ "apply_patch",
27
+ "run_command",
28
+ "run_tests",
29
+ "git_commit",
30
+ "git_branch",
31
+ "open_pr",
32
+ ]);
11
33
  /**
12
34
  * The default child toolset: read-only investigation plus skills. Mirrors the
13
35
  * C.31 propose-phase set — no writes, no shell, no VCS unless the parent
@@ -45,14 +67,18 @@ export function scopeRegistry(parent, requested) {
45
67
  return child;
46
68
  }
47
69
  for (const name of new Set(requested)) {
48
- if (name === SPAWN_SUBAGENT_TOOL_NAME)
70
+ // Neither spawn tool is grantable to a child: nesting (sequential) is the
71
+ // orchestrator's depth-capped decision, and parallel fan-out is depth-0 only.
72
+ if (name === SPAWN_SUBAGENT_TOOL_NAME ||
73
+ name === SPAWN_SUBAGENTS_TOOL_NAME) {
49
74
  continue;
75
+ }
50
76
  const tool = parent.get(name);
51
77
  if (!tool) {
52
78
  const available = parent
53
79
  .list()
54
80
  .map((t) => t.name)
55
- .filter((n) => n !== SPAWN_SUBAGENT_TOOL_NAME)
81
+ .filter((n) => n !== SPAWN_SUBAGENT_TOOL_NAME && n !== SPAWN_SUBAGENTS_TOOL_NAME)
56
82
  .join(", ");
57
83
  throw new Error(`tool "${name}" is not available to grant a subagent (a subagent's tools ` +
58
84
  `must be a subset of yours). Available: ${available}`);
@@ -0,0 +1,27 @@
1
+ /**
2
+ * A counting semaphore (C.33): bounds how many subagent runs execute at once.
3
+ * FIFO — waiters are served in arrival order, so a fan-out's results stay
4
+ * dispatch-order-fair — and the permit is handed directly from a releaser to
5
+ * the next waiter, so the live count never transiently exceeds the cap.
6
+ *
7
+ * Used as the ONE shared bound on parallel fan-out. Parallel dispatch happens at
8
+ * depth 0 only (the `spawn_subagents` tool is never granted to a child), and a
9
+ * permit is held for a child's whole lifetime — including any *sequential*
10
+ * nested spawn beneath it, which is deliberately un-permitted. Because no permit
11
+ * holder ever blocks trying to acquire a second permit, the semaphore cannot be
12
+ * part of a wait cycle: it is deadlock-free by construction (see the C.33 design
13
+ * doc's deadlock argument).
14
+ */
15
+ export declare class Semaphore {
16
+ private permits;
17
+ private readonly queue;
18
+ constructor(permits: number);
19
+ /** Run `fn` while holding one permit; the permit is released even if it throws. */
20
+ run<T>(fn: () => Promise<T>): Promise<T>;
21
+ /** Permits currently available (inspection/tests). */
22
+ get available(): number;
23
+ /** Callers currently blocked waiting for a permit (inspection/tests). */
24
+ get waiting(): number;
25
+ private acquire;
26
+ private release;
27
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * A counting semaphore (C.33): bounds how many subagent runs execute at once.
3
+ * FIFO — waiters are served in arrival order, so a fan-out's results stay
4
+ * dispatch-order-fair — and the permit is handed directly from a releaser to
5
+ * the next waiter, so the live count never transiently exceeds the cap.
6
+ *
7
+ * Used as the ONE shared bound on parallel fan-out. Parallel dispatch happens at
8
+ * depth 0 only (the `spawn_subagents` tool is never granted to a child), and a
9
+ * permit is held for a child's whole lifetime — including any *sequential*
10
+ * nested spawn beneath it, which is deliberately un-permitted. Because no permit
11
+ * holder ever blocks trying to acquire a second permit, the semaphore cannot be
12
+ * part of a wait cycle: it is deadlock-free by construction (see the C.33 design
13
+ * doc's deadlock argument).
14
+ */
15
+ export class Semaphore {
16
+ permits;
17
+ queue = [];
18
+ constructor(permits) {
19
+ // A non-positive cap would wedge every run; clamp to at least 1.
20
+ this.permits = Math.max(1, Math.floor(permits));
21
+ }
22
+ /** Run `fn` while holding one permit; the permit is released even if it throws. */
23
+ async run(fn) {
24
+ await this.acquire();
25
+ try {
26
+ return await fn();
27
+ }
28
+ finally {
29
+ this.release();
30
+ }
31
+ }
32
+ /** Permits currently available (inspection/tests). */
33
+ get available() {
34
+ return this.permits;
35
+ }
36
+ /** Callers currently blocked waiting for a permit (inspection/tests). */
37
+ get waiting() {
38
+ return this.queue.length;
39
+ }
40
+ acquire() {
41
+ if (this.permits > 0) {
42
+ this.permits--;
43
+ return Promise.resolve();
44
+ }
45
+ return new Promise((resolve) => this.queue.push(resolve));
46
+ }
47
+ release() {
48
+ const next = this.queue.shift();
49
+ // Hand the permit straight to the next waiter (never bump the count above
50
+ // the cap); only when nobody waits does the count grow back.
51
+ if (next)
52
+ next();
53
+ else
54
+ this.permits++;
55
+ }
56
+ }
@@ -26,4 +26,61 @@ declare const parameters: z.ZodObject<{
26
26
  }>;
27
27
  /** Build a `spawn_subagent` tool bound to `orchestrator` at `depth`. */
28
28
  export declare function makeSpawnSubagentTool(orchestrator: SubagentOrchestrator, depth: number): Tool<typeof parameters>;
29
+ declare const batchParameters: z.ZodObject<{
30
+ tasks: z.ZodArray<z.ZodObject<{
31
+ task: z.ZodString;
32
+ tools: z.ZodOptional<z.ZodArray<z.ZodString, "atleastone">>;
33
+ root: z.ZodOptional<z.ZodString>;
34
+ maxIterations: z.ZodOptional<z.ZodNumber>;
35
+ maxTokens: z.ZodOptional<z.ZodNumber>;
36
+ }, "strip", z.ZodTypeAny, {
37
+ task: string;
38
+ root?: string | undefined;
39
+ maxTokens?: number | undefined;
40
+ maxIterations?: number | undefined;
41
+ tools?: [string, ...string[]] | undefined;
42
+ }, {
43
+ task: string;
44
+ root?: string | undefined;
45
+ maxTokens?: number | undefined;
46
+ maxIterations?: number | undefined;
47
+ tools?: [string, ...string[]] | undefined;
48
+ }>, "atleastone">;
49
+ }, "strip", z.ZodTypeAny, {
50
+ tasks: [{
51
+ task: string;
52
+ root?: string | undefined;
53
+ maxTokens?: number | undefined;
54
+ maxIterations?: number | undefined;
55
+ tools?: [string, ...string[]] | undefined;
56
+ }, ...{
57
+ task: string;
58
+ root?: string | undefined;
59
+ maxTokens?: number | undefined;
60
+ maxIterations?: number | undefined;
61
+ tools?: [string, ...string[]] | undefined;
62
+ }[]];
63
+ }, {
64
+ tasks: [{
65
+ task: string;
66
+ root?: string | undefined;
67
+ maxTokens?: number | undefined;
68
+ maxIterations?: number | undefined;
69
+ tools?: [string, ...string[]] | undefined;
70
+ }, ...{
71
+ task: string;
72
+ root?: string | undefined;
73
+ maxTokens?: number | undefined;
74
+ maxIterations?: number | undefined;
75
+ tools?: [string, ...string[]] | undefined;
76
+ }[]];
77
+ }>;
78
+ /**
79
+ * Build the `spawn_subagents` tool (C.33) — the PARALLEL fan-out seam, bound to
80
+ * `depth`. One tool call dispatches N independent, internally-sequential children
81
+ * concurrently (JC-A) under the shared concurrency semaphore, and returns their
82
+ * results IN REQUEST ORDER. A DEPTH-0 capability: it is never granted to a child,
83
+ * so fan-out never nests (which keeps the semaphore deadlock-free).
84
+ */
85
+ export declare function makeSpawnSubagentsTool(orchestrator: SubagentOrchestrator, depth: number): Tool<typeof batchParameters>;
29
86
  export {};