@cruxy/cli 0.23.0 → 0.25.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 (84) hide show
  1. package/dist/agent/loop.d.ts +21 -2
  2. package/dist/agent/loop.js +21 -5
  3. package/dist/agent/session.d.ts +13 -0
  4. package/dist/agent/session.js +6 -0
  5. package/dist/approval/index.d.ts +1 -0
  6. package/dist/approval/index.js +1 -0
  7. package/dist/approval/mutex.d.ts +45 -0
  8. package/dist/approval/mutex.js +57 -0
  9. package/dist/checkpoint/gate-hook.d.ts +28 -0
  10. package/dist/checkpoint/gate-hook.js +98 -0
  11. package/dist/checkpoint/gate.d.ts +7 -1
  12. package/dist/checkpoint/gate.js +8 -2
  13. package/dist/checkpoint/index.d.ts +1 -0
  14. package/dist/checkpoint/index.js +1 -0
  15. package/dist/checkpoint/service.d.ts +9 -0
  16. package/dist/checkpoint/service.js +20 -0
  17. package/dist/cli/commands/rollback.d.ts +4 -1
  18. package/dist/cli/commands/rollback.js +16 -9
  19. package/dist/cli/commands/run.js +62 -16
  20. package/dist/cli/onboard.js +2 -2
  21. package/dist/cli/repl.d.ts +1 -1
  22. package/dist/cli/repl.js +145 -0
  23. package/dist/cli/session-factory.d.ts +24 -10
  24. package/dist/cli/session-factory.js +179 -135
  25. package/dist/config/schema.d.ts +110 -0
  26. package/dist/config/schema.js +50 -0
  27. package/dist/errors/constructors.d.ts +41 -0
  28. package/dist/errors/constructors.js +87 -0
  29. package/dist/errors/types.d.ts +21 -0
  30. package/dist/errors/types.js +33 -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/jobs/approval-queue.d.ts +85 -0
  38. package/dist/jobs/approval-queue.js +96 -0
  39. package/dist/jobs/dispatch-tool.d.ts +34 -0
  40. package/dist/jobs/dispatch-tool.js +96 -0
  41. package/dist/jobs/index.d.ts +6 -0
  42. package/dist/jobs/index.js +6 -0
  43. package/dist/jobs/log-buffer.d.ts +31 -0
  44. package/dist/jobs/log-buffer.js +30 -0
  45. package/dist/jobs/log-renderer.d.ts +32 -0
  46. package/dist/jobs/log-renderer.js +70 -0
  47. package/dist/jobs/manager.d.ts +139 -0
  48. package/dist/jobs/manager.js +397 -0
  49. package/dist/jobs/types.d.ts +81 -0
  50. package/dist/jobs/types.js +10 -0
  51. package/dist/mcp/index.d.ts +1 -0
  52. package/dist/mcp/index.js +1 -0
  53. package/dist/mcp/sibling-banner.d.ts +25 -0
  54. package/dist/mcp/sibling-banner.js +34 -0
  55. package/dist/memory/recall.d.ts +24 -0
  56. package/dist/memory/recall.js +54 -0
  57. package/dist/memory/remember-tool.d.ts +3 -0
  58. package/dist/memory/remember-tool.js +11 -1
  59. package/dist/sandbox/policy.js +14 -5
  60. package/dist/sandbox/service.d.ts +8 -1
  61. package/dist/sandbox/service.js +4 -1
  62. package/dist/subagent/index.d.ts +1 -0
  63. package/dist/subagent/index.js +1 -0
  64. package/dist/subagent/orchestrator.d.ts +76 -2
  65. package/dist/subagent/orchestrator.js +208 -18
  66. package/dist/subagent/registry-scope.d.ts +13 -0
  67. package/dist/subagent/registry-scope.js +28 -2
  68. package/dist/subagent/semaphore.d.ts +56 -0
  69. package/dist/subagent/semaphore.js +53 -0
  70. package/dist/subagent/spawn-tool.d.ts +57 -0
  71. package/dist/subagent/spawn-tool.js +104 -9
  72. package/dist/subagent/types.d.ts +17 -2
  73. package/dist/testing/run-tests-tool.js +1 -1
  74. package/dist/tools/file/paths.d.ts +5 -6
  75. package/dist/tools/file/paths.js +7 -8
  76. package/dist/tools/shell/exec.js +36 -4
  77. package/dist/tools/types.d.ts +16 -5
  78. package/dist/workspace/add-root.d.ts +27 -0
  79. package/dist/workspace/add-root.js +16 -0
  80. package/dist/workspace/index.d.ts +2 -1
  81. package/dist/workspace/index.js +2 -1
  82. package/dist/workspace/workspace.d.ts +9 -4
  83. package/dist/workspace/workspace.js +9 -4
  84. package/package.json +1 -1
@@ -23,6 +23,10 @@ const RememberSchema = z.object({
23
23
  .enum(["user", "project"])
24
24
  .optional()
25
25
  .describe("'user' (default) saves to your cross-project memory; 'project' saves to this repo's memory (shared with the repo)."),
26
+ root: z
27
+ .string()
28
+ .optional()
29
+ .describe("Which declared workspace root a 'project' note belongs to, by name. Defaults to the primary root; ignored for 'user' scope. In a single-root session, omit."),
26
30
  });
27
31
  export const rememberTool = {
28
32
  name: "remember",
@@ -30,8 +34,14 @@ export const rememberTool = {
30
34
  parameters: RememberSchema,
31
35
  async execute(input, ctx) {
32
36
  try {
37
+ // A named `root` targets that root's project memory (default: primary). A
38
+ // bad name throws CRUXY_E_ROOT_UNKNOWN, surfaced to the model below. User
39
+ // memory is global, so `root` never changes where it lands.
40
+ const cwd = input.root && ctx.workspace
41
+ ? ctx.workspace.rootByName(input.root).absPath
42
+ : ctx.cwd;
33
43
  const service = new MemoryService({
34
- cwd: ctx.cwd,
44
+ cwd,
35
45
  config: ctx.config.memory,
36
46
  });
37
47
  const entry = service.remember({
@@ -30,11 +30,20 @@ export function buildPolicy(cfg, cwd, opts = {}) {
30
30
  const siblingRoots = (opts.siblingRoots ?? [])
31
31
  .map((r) => resolvePath(r))
32
32
  .filter((r) => r !== resolvePath(cwd)) // the workdir is never a sibling
33
- .map((source) => ({
34
- source,
35
- target: source, // identical path, like the workdir, for path coherence
36
- readonly: !writable.has(source), // RO unless explicitly escalated (R5)
37
- }));
33
+ .map((source) => {
34
+ // A declared root is user input too — hold it to the SAME escape-hatch
35
+ // refusal as `sandbox.mounts` (JC-G): never mount the docker socket, the
36
+ // cruxy credential home, or `$HOME`/`/` even if declared as a root.
37
+ const forbidden = forbiddenMountSource(source);
38
+ if (forbidden) {
39
+ throw configInvalid(`refusing to mount workspace root ${forbidden}: "${source}"`);
40
+ }
41
+ return {
42
+ source,
43
+ target: source, // identical path, like the workdir, for path coherence
44
+ readonly: !writable.has(source), // RO unless explicitly escalated (R5)
45
+ };
46
+ });
38
47
  return {
39
48
  image: cfg.image,
40
49
  network: cfg.network,
@@ -10,8 +10,15 @@ export interface SandboxReporter {
10
10
  }
11
11
  export interface SandboxServiceDeps {
12
12
  config: CruxyConfig;
13
- /** The run's working directory — mounted read-write as the workdir. */
13
+ /** The command's own workspace root — mounted read-write as the workdir. */
14
14
  cwd: string;
15
+ /** The OTHER declared roots in a multi-repo session (C.26, R5). Each is
16
+ * mounted READ-ONLY so cross-repo reads work but a command can't silently
17
+ * write a sibling. Empty/absent in a single-root session. */
18
+ siblingRoots?: readonly string[];
19
+ /** Sibling roots granted an approved cross-root-write escalation (R5): each
20
+ * named one flips to read-write, and ONLY that one — never blanket. */
21
+ writableRoots?: readonly string[];
15
22
  /** Execution runtime seam (defaults to Docker). */
16
23
  runtime?: SandboxRuntime;
17
24
  /** Capability probe seam (defaults to real docker detection). */
@@ -34,7 +34,10 @@ export class SandboxService {
34
34
  if (!capability.available) {
35
35
  throw sandboxUnavailable(capability.runtime, capability.detail);
36
36
  }
37
- const policy = buildPolicy(deps.config.sandbox, deps.cwd);
37
+ const policy = buildPolicy(deps.config.sandbox, deps.cwd, {
38
+ siblingRoots: deps.siblingRoots,
39
+ writableRoots: deps.writableRoots,
40
+ });
38
41
  return new SandboxService(runtime, policy, deps.reporter);
39
42
  }
40
43
  /** The runtime backing this service (e.g. "docker") — for logging. */
@@ -1,5 +1,6 @@
1
1
  export * from "./types.js";
2
2
  export * from "./budget.js";
3
+ export * from "./semaphore.js";
3
4
  export * from "./registry-scope.js";
4
5
  export * from "./orchestrator.js";
5
6
  export * from "./spawn-tool.js";
@@ -1,5 +1,6 @@
1
1
  export * from "./types.js";
2
2
  export * from "./budget.js";
3
+ export * from "./semaphore.js";
3
4
  export * from "./registry-scope.js";
4
5
  export * from "./orchestrator.js";
5
6
  export * from "./spawn-tool.js";
@@ -5,7 +5,8 @@ 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 type { Workspace } from "../workspace/index.js";
8
+ import { Workspace } from "../workspace/index.js";
9
+ import { Semaphore } from "./semaphore.js";
9
10
  import type { SubagentResult, SubagentSpec } from "./types.js";
10
11
  /**
11
12
  * Everything a spawn needs from the surrounding session, injected by the
@@ -60,6 +61,14 @@ export interface SubagentOrchestratorDeps {
60
61
  * same shared checkpoint gate, those writes join the run's one set (⚖︎JC-δ).
61
62
  */
62
63
  checkpointsActive?: boolean;
64
+ /**
65
+ * The ONE shared execution semaphore (C.28). When set, parallel fan-out draws
66
+ * permits from THIS instance — the same one background jobs use — so the
67
+ * `subagent.maxConcurrency` cap bounds subagents AND jobs COMBINED, never one
68
+ * cap each. Omitted → the orchestrator constructs its own (C.33 behaviour,
69
+ * used by tests that exercise the orchestrator in isolation).
70
+ */
71
+ executionSemaphore?: Semaphore;
63
72
  }
64
73
  /**
65
74
  * Spawns subagents (C.14): the existing agent loop re-driven over isolated
@@ -76,7 +85,19 @@ export interface SubagentOrchestratorDeps {
76
85
  */
77
86
  export declare class SubagentOrchestrator {
78
87
  private readonly deps;
88
+ /**
89
+ * The ONE shared bound on parallel fan-out (C.33, JC-D). Constructed once per
90
+ * session and reused by every {@link spawnMany} call (nested spawns reuse this
91
+ * same orchestrator instance), so `subagent.maxConcurrency` caps the number of
92
+ * concurrently-executing subagents across the WHOLE session — not per parent.
93
+ */
94
+ private readonly sem;
79
95
  constructor(deps: SubagentOrchestratorDeps);
96
+ /** Live/queued fan-out slots (inspection/tests): proves the global cap holds. */
97
+ get concurrency(): {
98
+ available: number;
99
+ waiting: number;
100
+ };
80
101
  /**
81
102
  * Run one subagent to completion. `parentDepth` is the spawner's depth (the
82
103
  * main agent is 0); spawning past `subagent.maxDepth` throws
@@ -89,7 +110,60 @@ export declare class SubagentOrchestrator {
89
110
  * (non-interactive default-deny must reach the boundary, U.3 — a subagent is
90
111
  * not a way to swallow it).
91
112
  */
92
- spawn(spec: SubagentSpec, parentDepth: number): Promise<SubagentResult>;
113
+ spawn(spec: SubagentSpec, parentDepth: number, opts?: SpawnOptions): Promise<SubagentResult>;
114
+ /**
115
+ * Parallel fan-out (C.33): run N children concurrently under the shared
116
+ * concurrency semaphore and fold their outcomes into a result array whose
117
+ * order MATCHES `specs` (position i is spec i's result — never completion
118
+ * order). A DEPTH-0 capability only (the plural tool is never granted to a
119
+ * child), so no permit holder ever nests a second fan-out — the semaphore
120
+ * stays deadlock-free.
121
+ *
122
+ * Safety before dispatch: overlapping write scope is REFUSED
123
+ * (`CRUXY_E_SUBAGENT_SCOPE_OVERLAP`) so two writers can never race on one root.
124
+ *
125
+ * Cancellation: children share one {@link AbortController}. A child returning a
126
+ * `failed`/`budget-exceeded` result is a normal PARTIAL outcome — siblings run
127
+ * on. But a *fatal* throw from any child (non-interactive default-deny) or an
128
+ * abort on `opts.signal` (Ctrl-C) aborts the controller: every sibling stops at
129
+ * its next turn boundary and its in-flight shell child is kill-tree'd, so the
130
+ * fan-out leaves no orphan. All children are awaited to settle before a fatal
131
+ * throw propagates — never a detached, still-running sibling.
132
+ */
133
+ spawnMany(specs: readonly SubagentSpec[], parentDepth: number, opts?: {
134
+ signal?: AbortSignal;
135
+ }): Promise<SubagentResult[]>;
136
+ /**
137
+ * Resolve a child's scope from an optional root name. With a name: a
138
+ * single-root workspace over that root (writes confined to it) + that root's
139
+ * cwd. Without: the full session workspace + primary cwd (C.14 behaviour).
140
+ */
141
+ private childScope;
142
+ /**
143
+ * Refuse a fan-out where two WRITING children (any mutating tool granted)
144
+ * target the same root — the disjoint-scope guarantee (C.33). A writer with no
145
+ * declared root defaults to the session PRIMARY, so in a single-root session at
146
+ * most one child may write per batch (the rest must be read-only). Read-only
147
+ * children never conflict.
148
+ *
149
+ * Collects EVERY colliding root (not just the first) so the refusal names all
150
+ * conflicting task pairs at once — the model can fix them in one correction.
151
+ * The check is on DECLARED scope (tools + root), an honest over-approximation
152
+ * the error message is explicit about.
153
+ */
154
+ private assertDisjointWriteScopes;
93
155
  /** Map the child's AgentResult to the structured, transcript-free shape. */
94
156
  private toResult;
95
157
  }
158
+ /** Per-spawn options: cancellation + a per-child render tag for a fan-out. */
159
+ interface SpawnOptions {
160
+ /** Cooperative cancellation; threaded into `runAgent` and `ctx.signal`. */
161
+ signal?: AbortSignal;
162
+ /**
163
+ * A short per-child label for a parallel fan-out (JC-F), e.g. `"2/3"`, so
164
+ * interleaved trail notes are attributable to the child that emitted them.
165
+ * Undefined for a single sequential spawn — chrome is byte-identical to C.14.
166
+ */
167
+ slot?: string;
168
+ }
169
+ 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,25 @@ 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
+ // Share the session's ONE execution semaphore when injected (C.28) so
36
+ // subagents and background jobs contend for the same permits; fall back to a
37
+ // private one at the same cap when running the orchestrator in isolation.
38
+ this.sem =
39
+ deps.executionSemaphore ??
40
+ new Semaphore(deps.config.subagent.maxConcurrency);
41
+ }
42
+ /** Live/queued fan-out slots (inspection/tests): proves the global cap holds. */
43
+ get concurrency() {
44
+ return { available: this.sem.available, waiting: this.sem.waiting };
26
45
  }
27
46
  /**
28
47
  * Run one subagent to completion. `parentDepth` is the spawner's depth (the
@@ -36,7 +55,7 @@ export class SubagentOrchestrator {
36
55
  * (non-interactive default-deny must reach the boundary, U.3 — a subagent is
37
56
  * not a way to swallow it).
38
57
  */
39
- async spawn(spec, parentDepth) {
58
+ async spawn(spec, parentDepth, opts = {}) {
40
59
  const { deps } = this;
41
60
  const { maxDepth, defaultBudget } = deps.config.subagent;
42
61
  if (parentDepth >= maxDepth) {
@@ -45,33 +64,44 @@ export class SubagentOrchestrator {
45
64
  const childDepth = parentDepth + 1;
46
65
  // Scoped-down registry (throws on a tool the parent lacks — the spawn tool
47
66
  // surfaces that to the model), plus a depth-bound spawn tool only while
48
- // nesting is still allowed.
67
+ // nesting is still allowed. (Only the SEQUENTIAL spawn is re-added; parallel
68
+ // fan-out stays a depth-0 capability, so a child cannot nest a fan-out.)
49
69
  const registry = scopeRegistry(deps.parentRegistry, spec.tools);
50
70
  if (childDepth < maxDepth) {
51
71
  registry.register(makeSpawnSubagentTool(this, childDepth));
52
72
  }
53
73
  const budget = new Budget(resolveBudget(defaultBudget, spec.budget));
74
+ // Root scoping (C.33): a `spec.root` narrows the child's cwd + confinement to
75
+ // that ONE root (its writes land there and nowhere else). Omitted → the full
76
+ // session workspace, unchanged from C.14. An unknown name fails loud here
77
+ // (CRUXY_E_ROOT_UNKNOWN), which the spawn tool surfaces to the model.
78
+ const scope = this.childScope(spec.root);
54
79
  // Fresh gate per child (own allowlist), recording what it approves so the
55
- // result can name the artifacts without ever shipping the transcript.
80
+ // result can name the artifacts without ever shipping the transcript. The
81
+ // gate itself is serialized by the shared approval mutex (wired at the
82
+ // session factory), so concurrent siblings never prompt or checkpoint at once.
56
83
  const approve = deps.makeChildApproval();
57
84
  const artifacts = new Set();
58
85
  const ctx = {
59
- cwd: deps.cwd,
60
- workspace: deps.workspace,
86
+ cwd: scope.cwd,
87
+ workspace: scope.workspace,
61
88
  config: deps.config,
62
89
  logger: deps.logger,
63
90
  requestApproval: async (action) => {
64
91
  const decision = await approve(action);
65
92
  if (decision.allow)
66
- recordArtifacts(action, artifacts, deps.cwd);
93
+ recordArtifacts(action, artifacts, scope.cwd);
67
94
  return decision;
68
95
  },
69
96
  checkpointsActive: deps.checkpointsActive,
70
97
  sandbox: deps.sandbox,
98
+ signal: opts.signal,
71
99
  };
72
100
  const label = taskLabel(spec.task);
101
+ const tag = opts.slot; // per-child render label for a fan-out (JC-F)
102
+ const noun = tag ? `subagent[${tag}]` : "subagent";
73
103
  if (deps.renderer) {
74
- deps.renderer.note(`${deps.renderer.theme.glyph.play} subagent: ${label}`);
104
+ deps.renderer.note(`${deps.renderer.theme.glyph.play} ${noun}: ${label}`);
75
105
  }
76
106
  deps.renderer?.setPhase({ kind: "subagent", label });
77
107
  // The isolation seam: a brand-new history seeded with ONLY the task. The
@@ -87,7 +117,7 @@ export class SubagentOrchestrator {
87
117
  config: deps.config,
88
118
  ctx,
89
119
  renderer: deps.renderer
90
- ? new SubagentRenderer(deps.renderer, label)
120
+ ? new SubagentRenderer(deps.renderer, label, tag)
91
121
  : undefined,
92
122
  git: deps.git,
93
123
  projectInstructions: deps.projectInstructions,
@@ -95,6 +125,7 @@ export class SubagentOrchestrator {
95
125
  budget,
96
126
  router: deps.router,
97
127
  taskClass: spec.taskClass ?? "subagent",
128
+ signal: opts.signal,
98
129
  });
99
130
  }
100
131
  catch (err) {
@@ -104,7 +135,7 @@ export class SubagentOrchestrator {
104
135
  throw err;
105
136
  }
106
137
  if (deps.renderer) {
107
- deps.renderer.note(`${deps.renderer.theme.glyph.failure} subagent failed: ${label}`);
138
+ deps.renderer.note(`${deps.renderer.theme.glyph.failure} ${noun} failed: ${label}`);
108
139
  }
109
140
  deps.renderer?.setPhase(null);
110
141
  return {
@@ -117,13 +148,140 @@ export class SubagentOrchestrator {
117
148
  };
118
149
  }
119
150
  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 ` +
151
+ const result = this.toResult(run, artifacts, label, noun);
152
+ deps.logger.debug(`${noun} ${result.status}: ${result.iterations} turn(s), tokens in/out ` +
122
153
  `${result.usage.input_tokens}/${result.usage.output_tokens} — ${label}`);
123
154
  return result;
124
155
  }
156
+ /**
157
+ * Parallel fan-out (C.33): run N children concurrently under the shared
158
+ * concurrency semaphore and fold their outcomes into a result array whose
159
+ * order MATCHES `specs` (position i is spec i's result — never completion
160
+ * order). A DEPTH-0 capability only (the plural tool is never granted to a
161
+ * child), so no permit holder ever nests a second fan-out — the semaphore
162
+ * stays deadlock-free.
163
+ *
164
+ * Safety before dispatch: overlapping write scope is REFUSED
165
+ * (`CRUXY_E_SUBAGENT_SCOPE_OVERLAP`) so two writers can never race on one root.
166
+ *
167
+ * Cancellation: children share one {@link AbortController}. A child returning a
168
+ * `failed`/`budget-exceeded` result is a normal PARTIAL outcome — siblings run
169
+ * on. But a *fatal* throw from any child (non-interactive default-deny) or an
170
+ * abort on `opts.signal` (Ctrl-C) aborts the controller: every sibling stops at
171
+ * its next turn boundary and its in-flight shell child is kill-tree'd, so the
172
+ * fan-out leaves no orphan. All children are awaited to settle before a fatal
173
+ * throw propagates — never a detached, still-running sibling.
174
+ */
175
+ async spawnMany(specs, parentDepth, opts = {}) {
176
+ const { maxDepth } = this.deps.config.subagent;
177
+ if (parentDepth >= maxDepth) {
178
+ throw subagentDepthExceeded(parentDepth, maxDepth);
179
+ }
180
+ if (specs.length === 0)
181
+ return [];
182
+ // Refuse overlapping write scope BEFORE any child is dispatched.
183
+ this.assertDisjointWriteScopes(specs);
184
+ const controller = new AbortController();
185
+ const onExternalAbort = () => controller.abort();
186
+ if (opts.signal) {
187
+ if (opts.signal.aborted)
188
+ controller.abort();
189
+ else
190
+ opts.signal.addEventListener("abort", onExternalAbort, { once: true });
191
+ }
192
+ const results = new Array(specs.length);
193
+ const total = specs.length;
194
+ try {
195
+ const settled = await Promise.allSettled(specs.map((spec, i) => this.sem.run(async () => {
196
+ // Already cancelled (a fatal sibling or Ctrl-C fired first): record an
197
+ // honest cancelled result instead of starting a doomed run.
198
+ if (controller.signal.aborted) {
199
+ results[i] = cancelledResult();
200
+ return;
201
+ }
202
+ try {
203
+ results[i] = await this.spawn(spec, parentDepth, {
204
+ signal: controller.signal,
205
+ slot: `${i + 1}/${total}`,
206
+ });
207
+ }
208
+ catch (err) {
209
+ // A fatal throw (non-interactive default-deny) cancels the whole
210
+ // fan-out — no sibling is left running — then propagates.
211
+ controller.abort();
212
+ throw err;
213
+ }
214
+ })));
215
+ // Any child that was aborted mid-flight (returned stop:"aborted") is folded
216
+ // as cancelled by toResult; a fatal throw surfaces here after all settled.
217
+ const fatal = settled.find((s) => s.status === "rejected");
218
+ if (fatal && fatal.status === "rejected")
219
+ throw fatal.reason;
220
+ // Backfill any slot a cancelled-before-dispatch child left (defensive: the
221
+ // sem callback always assigns, but never ship a hole as success).
222
+ for (let i = 0; i < results.length; i++) {
223
+ if (results[i] === undefined)
224
+ results[i] = cancelledResult();
225
+ }
226
+ return results;
227
+ }
228
+ finally {
229
+ opts.signal?.removeEventListener("abort", onExternalAbort);
230
+ }
231
+ }
232
+ /**
233
+ * Resolve a child's scope from an optional root name. With a name: a
234
+ * single-root workspace over that root (writes confined to it) + that root's
235
+ * cwd. Without: the full session workspace + primary cwd (C.14 behaviour).
236
+ */
237
+ childScope(rootName) {
238
+ if (rootName === undefined) {
239
+ return { cwd: this.deps.cwd, workspace: this.deps.workspace };
240
+ }
241
+ const root = this.deps.workspace.rootByName(rootName); // fail-loud on unknown
242
+ return {
243
+ cwd: root.absPath,
244
+ workspace: new Workspace([
245
+ { name: root.name, absPath: root.absPath, primary: true },
246
+ ]),
247
+ };
248
+ }
249
+ /**
250
+ * Refuse a fan-out where two WRITING children (any mutating tool granted)
251
+ * target the same root — the disjoint-scope guarantee (C.33). A writer with no
252
+ * declared root defaults to the session PRIMARY, so in a single-root session at
253
+ * most one child may write per batch (the rest must be read-only). Read-only
254
+ * children never conflict.
255
+ *
256
+ * Collects EVERY colliding root (not just the first) so the refusal names all
257
+ * conflicting task pairs at once — the model can fix them in one correction.
258
+ * The check is on DECLARED scope (tools + root), an honest over-approximation
259
+ * the error message is explicit about.
260
+ */
261
+ assertDisjointWriteScopes(specs) {
262
+ const primaryName = this.deps.workspace.primary().name;
263
+ const byRoot = new Map(); // root → writing-child labels
264
+ for (const spec of specs) {
265
+ if (!isWriter(spec))
266
+ continue;
267
+ // Validate the named root exists (fail-loud, same as spawn) before claiming.
268
+ const rootName = spec.root === undefined
269
+ ? primaryName
270
+ : this.deps.workspace.rootByName(spec.root).name;
271
+ const claimants = byRoot.get(rootName);
272
+ if (claimants)
273
+ claimants.push(taskLabel(spec.task));
274
+ else
275
+ byRoot.set(rootName, [taskLabel(spec.task)]);
276
+ }
277
+ const conflicts = [...byRoot.entries()]
278
+ .filter(([, tasks]) => tasks.length > 1)
279
+ .map(([root, tasks]) => ({ root, tasks }));
280
+ if (conflicts.length > 0)
281
+ throw subagentScopeOverlap(conflicts);
282
+ }
125
283
  /** Map the child's AgentResult to the structured, transcript-free shape. */
126
- toResult(run, artifacts, label) {
284
+ toResult(run, artifacts, label, noun) {
127
285
  const summary = lastAssistantText(run.messages);
128
286
  const base = {
129
287
  summary,
@@ -134,9 +292,21 @@ export class SubagentOrchestrator {
134
292
  if (run.stop === "completed") {
135
293
  const r = this.deps.renderer;
136
294
  if (r)
137
- r.note(`${r.theme.glyph.success} subagent done: ${label}`);
295
+ r.note(`${r.theme.glyph.success} ${noun} done: ${label}`);
138
296
  return { status: "done", ...base };
139
297
  }
298
+ // Cancellation (C.33): a fatal sibling failure or Ctrl-C stopped this child
299
+ // at a turn boundary. An honest partial result — never a fabricated success.
300
+ if (run.stop === "aborted") {
301
+ const r = this.deps.renderer;
302
+ if (r)
303
+ r.note(`${r.theme.glyph.failure} ${noun} cancelled: ${label}`);
304
+ return {
305
+ status: "cancelled",
306
+ ...base,
307
+ error: `${ErrorCode.SubagentCancelled}: cancelled before completion`,
308
+ };
309
+ }
140
310
  // Both cap paths are the same outcome for the parent: a truncated, partial
141
311
  // result with the reason — informational, never fatal (the parent decides
142
312
  // what to do with it).
@@ -145,7 +315,7 @@ export class SubagentOrchestrator {
145
315
  : `agent.maxIterations ceiling reached (${this.deps.config.agent.maxIterations})`;
146
316
  const r = this.deps.renderer;
147
317
  if (r)
148
- r.note(`${r.theme.glyph.failure} subagent stopped (budget): ${label}`);
318
+ r.note(`${r.theme.glyph.failure} ${noun} stopped (budget): ${label}`);
149
319
  return {
150
320
  status: "budget-exceeded",
151
321
  ...base,
@@ -158,6 +328,22 @@ function taskLabel(task) {
158
328
  const flat = task.replace(/\s+/g, " ").trim();
159
329
  return flat.length > LABEL_MAX ? flat.slice(0, LABEL_MAX - 1) + "…" : flat;
160
330
  }
331
+ /** A child that holds any mutating tool — the disjoint-scope check's unit. A
332
+ * spec with no `tools` gets the default READ-ONLY set, so it is never a writer. */
333
+ function isWriter(spec) {
334
+ return (spec.tools ?? []).some((t) => SUBAGENT_WRITE_TOOLS.has(t));
335
+ }
336
+ /** The honest result for a child cancelled before it could produce anything —
337
+ * used when a fatal sibling / Ctrl-C fired before this slot even dispatched. */
338
+ function cancelledResult() {
339
+ return {
340
+ status: "cancelled",
341
+ summary: "",
342
+ error: `${ErrorCode.SubagentCancelled}: cancelled before dispatch`,
343
+ iterations: 0,
344
+ usage: { input_tokens: 0, output_tokens: 0 },
345
+ };
346
+ }
161
347
  /** `artifacts` only when non-empty — absent beats `[]` in the parent's context. */
162
348
  function artifactsField(artifacts) {
163
349
  return artifacts.size > 0 ? { artifacts: [...artifacts].sort() } : {};
@@ -212,12 +398,16 @@ class SubagentRenderer {
212
398
  inner;
213
399
  label;
214
400
  prefix;
215
- constructor(inner, label) {
401
+ constructor(inner, label, tag) {
216
402
  this.inner = inner;
217
403
  this.label = label;
218
404
  this.caps = inner.caps;
219
405
  this.theme = inner.theme;
220
- this.prefix = `subagent ${inner.theme.glyph.sep} `;
406
+ // A fan-out child carries its slot in the prefix (`subagent[2/3] · …`) so
407
+ // interleaved trail notes stay attributable per-subagent (JC-F); a lone
408
+ // sequential spawn keeps the byte-identical C.14 `subagent · …` prefix.
409
+ const noun = tag ? `subagent[${tag}]` : "subagent";
410
+ this.prefix = `${noun} ${inner.theme.glyph.sep} `;
221
411
  }
222
412
  /** Turn framing belongs to the parent's turn — the child's is dropped. */
223
413
  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}`);