@cruxy/cli 0.22.1 → 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 (103) hide show
  1. package/dist/agent/loop.d.ts +21 -2
  2. package/dist/agent/loop.js +21 -5
  3. package/dist/approval/classify.js +18 -0
  4. package/dist/approval/index.d.ts +1 -0
  5. package/dist/approval/index.js +1 -0
  6. package/dist/approval/mutex.d.ts +45 -0
  7. package/dist/approval/mutex.js +57 -0
  8. package/dist/approval/prompt.js +11 -0
  9. package/dist/checkpoint/gate.d.ts +65 -0
  10. package/dist/checkpoint/gate.js +86 -0
  11. package/dist/checkpoint/index.d.ts +2 -0
  12. package/dist/checkpoint/index.js +2 -0
  13. package/dist/checkpoint/service.d.ts +9 -0
  14. package/dist/checkpoint/service.js +20 -0
  15. package/dist/checkpoint/set-rollback.d.ts +51 -0
  16. package/dist/checkpoint/set-rollback.js +74 -0
  17. package/dist/cli/commands/rollback.d.ts +11 -6
  18. package/dist/cli/commands/rollback.js +93 -33
  19. package/dist/cli/commands/run.js +104 -21
  20. package/dist/cli/onboard.js +4 -1
  21. package/dist/cli/repl.d.ts +2 -2
  22. package/dist/cli/repl.js +39 -0
  23. package/dist/cli/session-factory.d.ts +27 -4
  24. package/dist/cli/session-factory.js +234 -58
  25. package/dist/config/schema.d.ts +24 -0
  26. package/dist/config/schema.js +9 -0
  27. package/dist/errors/constructors.d.ts +51 -0
  28. package/dist/errors/constructors.js +97 -0
  29. package/dist/errors/types.d.ts +28 -0
  30. package/dist/errors/types.js +38 -0
  31. package/dist/hooks/index.d.ts +1 -0
  32. package/dist/hooks/index.js +1 -0
  33. package/dist/hooks/router.d.ts +58 -0
  34. package/dist/hooks/router.js +136 -0
  35. package/dist/hooks/runner.d.ts +12 -0
  36. package/dist/hooks/runner.js +23 -1
  37. package/dist/indexing/retriever.d.ts +29 -0
  38. package/dist/indexing/retriever.js +26 -0
  39. package/dist/indexing/service.js +3 -1
  40. package/dist/indexing/types.d.ts +7 -0
  41. package/dist/lsp/tools/common.d.ts +34 -7
  42. package/dist/lsp/tools/common.js +33 -11
  43. package/dist/lsp/tools/find-definition.js +2 -2
  44. package/dist/lsp/tools/find-references.js +10 -4
  45. package/dist/lsp/tools/get-diagnostics.js +6 -4
  46. package/dist/mcp/index.d.ts +1 -0
  47. package/dist/mcp/index.js +1 -0
  48. package/dist/mcp/sibling-banner.d.ts +25 -0
  49. package/dist/mcp/sibling-banner.js +34 -0
  50. package/dist/memory/recall.d.ts +24 -0
  51. package/dist/memory/recall.js +54 -0
  52. package/dist/memory/remember-tool.d.ts +3 -0
  53. package/dist/memory/remember-tool.js +11 -1
  54. package/dist/render/diff.js +42 -5
  55. package/dist/sandbox/policy.js +14 -5
  56. package/dist/sandbox/service.d.ts +8 -1
  57. package/dist/sandbox/service.js +4 -1
  58. package/dist/subagent/index.d.ts +1 -0
  59. package/dist/subagent/index.js +1 -0
  60. package/dist/subagent/orchestrator.d.ts +81 -1
  61. package/dist/subagent/orchestrator.js +204 -17
  62. package/dist/subagent/registry-scope.d.ts +13 -0
  63. package/dist/subagent/registry-scope.js +28 -2
  64. package/dist/subagent/semaphore.d.ts +27 -0
  65. package/dist/subagent/semaphore.js +56 -0
  66. package/dist/subagent/spawn-tool.d.ts +57 -0
  67. package/dist/subagent/spawn-tool.js +104 -9
  68. package/dist/subagent/types.d.ts +17 -2
  69. package/dist/testing/run-tests-tool.js +3 -0
  70. package/dist/tools/create-pull-request.d.ts +3 -0
  71. package/dist/tools/create-pull-request.js +50 -4
  72. package/dist/tools/file/apply-patch.js +2 -2
  73. package/dist/tools/file/edit-file.js +2 -2
  74. package/dist/tools/file/glob.d.ts +9 -2
  75. package/dist/tools/file/glob.js +73 -19
  76. package/dist/tools/file/grep-files.d.ts +12 -2
  77. package/dist/tools/file/grep-files.js +113 -38
  78. package/dist/tools/file/paths.d.ts +121 -9
  79. package/dist/tools/file/paths.js +164 -10
  80. package/dist/tools/file/read-file.js +2 -2
  81. package/dist/tools/file/write-file.js +2 -2
  82. package/dist/tools/git-status.d.ts +8 -1
  83. package/dist/tools/git-status.js +43 -11
  84. package/dist/tools/list-files.d.ts +9 -3
  85. package/dist/tools/list-files.js +48 -13
  86. package/dist/tools/search-codebase.d.ts +10 -0
  87. package/dist/tools/search-codebase.js +117 -14
  88. package/dist/tools/shell/exec.js +43 -4
  89. package/dist/tools/types.d.ts +74 -1
  90. package/dist/vcs/git.d.ts +8 -0
  91. package/dist/vcs/git.js +14 -0
  92. package/dist/vcs/github.d.ts +7 -1
  93. package/dist/vcs/github.js +10 -1
  94. package/dist/vcs/service.d.ts +8 -0
  95. package/dist/vcs/service.js +33 -1
  96. package/dist/vcs/types.d.ts +18 -2
  97. package/dist/workspace/add-root.d.ts +27 -0
  98. package/dist/workspace/add-root.js +16 -0
  99. package/dist/workspace/index.d.ts +2 -1
  100. package/dist/workspace/index.js +2 -1
  101. package/dist/workspace/workspace.d.ts +9 -4
  102. package/dist/workspace/workspace.js +9 -4
  103. package/package.json +1 -1
@@ -5,6 +5,7 @@ import type { StreamRenderer } from "../render/index.js";
5
5
  import type { Router } from "../routing/index.js";
6
6
  import type { ApproveAction, ToolContext, ToolRegistry } from "../tools/index.js";
7
7
  import type { SandboxService } from "../sandbox/index.js";
8
+ import { Workspace } from "../workspace/index.js";
8
9
  import type { SubagentResult, SubagentSpec } from "./types.js";
9
10
  /**
10
11
  * Everything a spawn needs from the surrounding session, injected by the
@@ -24,6 +25,13 @@ export interface SubagentOrchestratorDeps {
24
25
  /** The parent's registry — the ceiling every child scope derives from. */
25
26
  parentRegistry: ToolRegistry;
26
27
  cwd: string;
28
+ /**
29
+ * The session's declared workspace (C.26). Threaded onto the child ctx so a
30
+ * subagent sees the SAME roots as the main loop — without it the child would
31
+ * fall back to a single-root workspace over `cwd` and its fan tools would go
32
+ * silently primary-only (a split-brain WITHIN one session).
33
+ */
34
+ workspace: Workspace;
27
35
  logger: ToolContext["logger"];
28
36
  git?: {
29
37
  branch: string;
@@ -45,6 +53,13 @@ export interface SubagentOrchestratorDeps {
45
53
  * one checkpoint, C.32).
46
54
  */
47
55
  makeChildApproval(): (action: ApproveAction) => Promise<ApprovalDecision>;
56
+ /**
57
+ * Whether per-root checkpointing is active for the run (C.26 step 3). Threaded
58
+ * onto the child ctx so a subagent's non-primary writes lift the JC-1 refusal on
59
+ * the SAME condition as the parent — and, because `makeChildApproval` wraps the
60
+ * same shared checkpoint gate, those writes join the run's one set (⚖︎JC-δ).
61
+ */
62
+ checkpointsActive?: boolean;
48
63
  }
49
64
  /**
50
65
  * Spawns subagents (C.14): the existing agent loop re-driven over isolated
@@ -61,7 +76,19 @@ export interface SubagentOrchestratorDeps {
61
76
  */
62
77
  export declare class SubagentOrchestrator {
63
78
  private readonly deps;
79
+ /**
80
+ * The ONE shared bound on parallel fan-out (C.33, JC-D). Constructed once per
81
+ * session and reused by every {@link spawnMany} call (nested spawns reuse this
82
+ * same orchestrator instance), so `subagent.maxConcurrency` caps the number of
83
+ * concurrently-executing subagents across the WHOLE session — not per parent.
84
+ */
85
+ private readonly sem;
64
86
  constructor(deps: SubagentOrchestratorDeps);
87
+ /** Live/queued fan-out slots (inspection/tests): proves the global cap holds. */
88
+ get concurrency(): {
89
+ available: number;
90
+ waiting: number;
91
+ };
65
92
  /**
66
93
  * Run one subagent to completion. `parentDepth` is the spawner's depth (the
67
94
  * main agent is 0); spawning past `subagent.maxDepth` throws
@@ -74,7 +101,60 @@ export declare class SubagentOrchestrator {
74
101
  * (non-interactive default-deny must reach the boundary, U.3 — a subagent is
75
102
  * not a way to swallow it).
76
103
  */
77
- spawn(spec: SubagentSpec, parentDepth: number): Promise<SubagentResult>;
104
+ spawn(spec: SubagentSpec, parentDepth: number, opts?: SpawnOptions): Promise<SubagentResult>;
105
+ /**
106
+ * Parallel fan-out (C.33): run N children concurrently under the shared
107
+ * concurrency semaphore and fold their outcomes into a result array whose
108
+ * order MATCHES `specs` (position i is spec i's result — never completion
109
+ * order). A DEPTH-0 capability only (the plural tool is never granted to a
110
+ * child), so no permit holder ever nests a second fan-out — the semaphore
111
+ * stays deadlock-free.
112
+ *
113
+ * Safety before dispatch: overlapping write scope is REFUSED
114
+ * (`CRUXY_E_SUBAGENT_SCOPE_OVERLAP`) so two writers can never race on one root.
115
+ *
116
+ * Cancellation: children share one {@link AbortController}. A child returning a
117
+ * `failed`/`budget-exceeded` result is a normal PARTIAL outcome — siblings run
118
+ * on. But a *fatal* throw from any child (non-interactive default-deny) or an
119
+ * abort on `opts.signal` (Ctrl-C) aborts the controller: every sibling stops at
120
+ * its next turn boundary and its in-flight shell child is kill-tree'd, so the
121
+ * fan-out leaves no orphan. All children are awaited to settle before a fatal
122
+ * throw propagates — never a detached, still-running sibling.
123
+ */
124
+ spawnMany(specs: readonly SubagentSpec[], parentDepth: number, opts?: {
125
+ signal?: AbortSignal;
126
+ }): Promise<SubagentResult[]>;
127
+ /**
128
+ * Resolve a child's scope from an optional root name. With a name: a
129
+ * single-root workspace over that root (writes confined to it) + that root's
130
+ * cwd. Without: the full session workspace + primary cwd (C.14 behaviour).
131
+ */
132
+ private childScope;
133
+ /**
134
+ * Refuse a fan-out where two WRITING children (any mutating tool granted)
135
+ * target the same root — the disjoint-scope guarantee (C.33). A writer with no
136
+ * declared root defaults to the session PRIMARY, so in a single-root session at
137
+ * most one child may write per batch (the rest must be read-only). Read-only
138
+ * children never conflict.
139
+ *
140
+ * Collects EVERY colliding root (not just the first) so the refusal names all
141
+ * conflicting task pairs at once — the model can fix them in one correction.
142
+ * The check is on DECLARED scope (tools + root), an honest over-approximation
143
+ * the error message is explicit about.
144
+ */
145
+ private assertDisjointWriteScopes;
78
146
  /** Map the child's AgentResult to the structured, transcript-free shape. */
79
147
  private toResult;
80
148
  }
149
+ /** Per-spawn options: cancellation + a per-child render tag for a fan-out. */
150
+ interface SpawnOptions {
151
+ /** Cooperative cancellation; threaded into `runAgent` and `ctx.signal`. */
152
+ signal?: AbortSignal;
153
+ /**
154
+ * A short per-child label for a parallel fan-out (JC-F), e.g. `"2/3"`, so
155
+ * interleaved trail notes are attributable to the child that emitted them.
156
+ * Undefined for a single sequential spawn — chrome is byte-identical to C.14.
157
+ */
158
+ slot?: string;
159
+ }
160
+ export {};
@@ -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,31 +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,
81
+ cwd: scope.cwd,
82
+ workspace: scope.workspace,
60
83
  config: deps.config,
61
84
  logger: deps.logger,
62
85
  requestApproval: async (action) => {
63
86
  const decision = await approve(action);
64
87
  if (decision.allow)
65
- recordArtifacts(action, artifacts, deps.cwd);
88
+ recordArtifacts(action, artifacts, scope.cwd);
66
89
  return decision;
67
90
  },
91
+ checkpointsActive: deps.checkpointsActive,
68
92
  sandbox: deps.sandbox,
93
+ signal: opts.signal,
69
94
  };
70
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";
71
98
  if (deps.renderer) {
72
- deps.renderer.note(`${deps.renderer.theme.glyph.play} subagent: ${label}`);
99
+ deps.renderer.note(`${deps.renderer.theme.glyph.play} ${noun}: ${label}`);
73
100
  }
74
101
  deps.renderer?.setPhase({ kind: "subagent", label });
75
102
  // The isolation seam: a brand-new history seeded with ONLY the task. The
@@ -85,7 +112,7 @@ export class SubagentOrchestrator {
85
112
  config: deps.config,
86
113
  ctx,
87
114
  renderer: deps.renderer
88
- ? new SubagentRenderer(deps.renderer, label)
115
+ ? new SubagentRenderer(deps.renderer, label, tag)
89
116
  : undefined,
90
117
  git: deps.git,
91
118
  projectInstructions: deps.projectInstructions,
@@ -93,6 +120,7 @@ export class SubagentOrchestrator {
93
120
  budget,
94
121
  router: deps.router,
95
122
  taskClass: spec.taskClass ?? "subagent",
123
+ signal: opts.signal,
96
124
  });
97
125
  }
98
126
  catch (err) {
@@ -102,7 +130,7 @@ export class SubagentOrchestrator {
102
130
  throw err;
103
131
  }
104
132
  if (deps.renderer) {
105
- deps.renderer.note(`${deps.renderer.theme.glyph.failure} subagent failed: ${label}`);
133
+ deps.renderer.note(`${deps.renderer.theme.glyph.failure} ${noun} failed: ${label}`);
106
134
  }
107
135
  deps.renderer?.setPhase(null);
108
136
  return {
@@ -115,13 +143,140 @@ export class SubagentOrchestrator {
115
143
  };
116
144
  }
117
145
  deps.renderer?.setPhase(null);
118
- const result = this.toResult(run, artifacts, label);
119
- 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 ` +
120
148
  `${result.usage.input_tokens}/${result.usage.output_tokens} — ${label}`);
121
149
  return result;
122
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
+ }
123
278
  /** Map the child's AgentResult to the structured, transcript-free shape. */
124
- toResult(run, artifacts, label) {
279
+ toResult(run, artifacts, label, noun) {
125
280
  const summary = lastAssistantText(run.messages);
126
281
  const base = {
127
282
  summary,
@@ -132,9 +287,21 @@ export class SubagentOrchestrator {
132
287
  if (run.stop === "completed") {
133
288
  const r = this.deps.renderer;
134
289
  if (r)
135
- r.note(`${r.theme.glyph.success} subagent done: ${label}`);
290
+ r.note(`${r.theme.glyph.success} ${noun} done: ${label}`);
136
291
  return { status: "done", ...base };
137
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
+ }
138
305
  // Both cap paths are the same outcome for the parent: a truncated, partial
139
306
  // result with the reason — informational, never fatal (the parent decides
140
307
  // what to do with it).
@@ -143,7 +310,7 @@ export class SubagentOrchestrator {
143
310
  : `agent.maxIterations ceiling reached (${this.deps.config.agent.maxIterations})`;
144
311
  const r = this.deps.renderer;
145
312
  if (r)
146
- r.note(`${r.theme.glyph.failure} subagent stopped (budget): ${label}`);
313
+ r.note(`${r.theme.glyph.failure} ${noun} stopped (budget): ${label}`);
147
314
  return {
148
315
  status: "budget-exceeded",
149
316
  ...base,
@@ -156,6 +323,22 @@ function taskLabel(task) {
156
323
  const flat = task.replace(/\s+/g, " ").trim();
157
324
  return flat.length > LABEL_MAX ? flat.slice(0, LABEL_MAX - 1) + "…" : flat;
158
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
+ }
159
342
  /** `artifacts` only when non-empty — absent beats `[]` in the parent's context. */
160
343
  function artifactsField(artifacts) {
161
344
  return artifacts.size > 0 ? { artifacts: [...artifacts].sort() } : {};
@@ -210,12 +393,16 @@ class SubagentRenderer {
210
393
  inner;
211
394
  label;
212
395
  prefix;
213
- constructor(inner, label) {
396
+ constructor(inner, label, tag) {
214
397
  this.inner = inner;
215
398
  this.label = label;
216
399
  this.caps = inner.caps;
217
400
  this.theme = inner.theme;
218
- 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} `;
219
406
  }
220
407
  /** Turn framing belongs to the parent's turn — the child's is dropped. */
221
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 {};