@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
@@ -1,18 +1,19 @@
1
+ import path from "node:path";
1
2
  import { createProvider } from "@cruxy/sdk";
2
3
  import { loadProjectInstructions } from "../config/index.js";
3
4
  import { logger } from "../utils/logger.js";
4
5
  import { getGitInfo } from "../utils/git.js";
5
- import { ApprovalService, InteractivePolicy, SessionAllowlist, classify, defaultPromptIO, } from "../approval/index.js";
6
+ import { ApprovalMutex, ApprovalService, InteractivePolicy, SessionAllowlist, classify, defaultPromptIO, serializeGate, } from "../approval/index.js";
6
7
  import { shouldUseColor } from "../errors/index.js";
7
8
  import { buildDefaultRegistry, } from "../tools/index.js";
8
9
  import { Session, } from "../agent/index.js";
9
10
  import { PlanExecutionPolicy, runPlanSession } from "../plan/index.js";
10
11
  import { routerForConfig } from "../routing/index.js";
11
- import { MemoryService, rememberTool } from "../memory/index.js";
12
+ import { MemoryService, buildMultiRootRecallBlock, rememberTool, } from "../memory/index.js";
12
13
  import { findDefinitionTool, findReferencesTool, getDiagnosticsTool, hoverTool, } from "../lsp/index.js";
13
14
  import { createWebSearchTool, createWebFetchTool } from "../web/index.js";
14
15
  import { appendRun } from "../usage/index.js";
15
- import { SubagentOrchestrator, makeSpawnSubagentTool, } from "../subagent/index.js";
16
+ import { SubagentOrchestrator, makeSpawnSubagentTool, makeSpawnSubagentsTool, } from "../subagent/index.js";
16
17
  /**
17
18
  * Wrap a PromptIO so the live region yields before any prompt text lands
18
19
  * (U.2/U.4): the prompt writes to stderr while the status line owns the last
@@ -61,28 +62,143 @@ function resumeLineAfterApproval(requestApproval, renderer) {
61
62
  * (file actions) or that attribution is lost (shell), for rollback's
62
63
  * external-change detection.
63
64
  */
64
- export function withCheckpointGate(requestApproval, checkpoints, cwd) {
65
- if (!checkpoints)
65
+ export function withCheckpointGate(requestApproval, gate, ws) {
66
+ if (!gate)
66
67
  return requestApproval;
67
68
  return async (action) => {
68
69
  const decision = await requestApproval(action);
69
70
  if (!decision.allow)
70
71
  return decision;
71
- const request = classify(action, cwd);
72
+ const request = classify(action, ws.primary().absPath);
72
73
  if (request.tier === "read")
73
74
  return decision;
74
- await checkpoints.ensureCheckpoint();
75
- // Shell AND test executions (C.13) can mutate files we can't attribute
76
- // (scripts, snapshot writers) — record the lost attribution the same way.
77
75
  if (action.kind === "shell" || action.kind === "test") {
78
- await checkpoints.recordShellMutation();
76
+ // JC-β residual: non-primary shell/test are Step 5, so they are still
77
+ // hard-attributed to the primary root regardless of `action.root` (which
78
+ // those tools populate as the seam). They can mutate files we cannot
79
+ // attribute (scripts, snapshot writers) — record the lost attribution.
80
+ const root = ws.primary();
81
+ const svc = gate.serviceFor(root.name, root.absPath);
82
+ const checkpoint = await svc.ensureCheckpoint();
83
+ await svc.recordShellMutation();
84
+ if (checkpoint) {
85
+ await gate.recordMember(root.name, root.absPath, checkpoint.id);
86
+ }
87
+ return decision;
79
88
  }
80
- else {
81
- await checkpoints.recordTouched([...request.targets]);
89
+ if (action.kind === "vcs") {
90
+ // C.26 Step 4: a PR now names its root (⚖︎#11), so the checkpoint is
91
+ // attributed to THAT selected root — its git commit stages/lands in that
92
+ // root's working tree, never the primary's. `recordShellMutation` because a
93
+ // `git add -A` + commit mutates the tree opaquely (no per-file attribution).
94
+ // Fall back to the primary only if a root name is somehow absent (defensive).
95
+ const root = (action.root ? ws.tryRootByName(action.root) : undefined) ??
96
+ ws.primary();
97
+ const svc = gate.serviceFor(root.name, root.absPath);
98
+ const checkpoint = await svc.ensureCheckpoint();
99
+ await svc.recordShellMutation();
100
+ if (checkpoint) {
101
+ await gate.recordMember(root.name, root.absPath, checkpoint.id);
102
+ }
103
+ return decision;
104
+ }
105
+ // File actions (write/edit/patch): attribute each RESOLVED target to its root
106
+ // (JC-G — post-confinement truth) and checkpoint every touched root. A patch
107
+ // may span roots; each root gets its own checkpoint + set member.
108
+ for (const [rootName, group] of attributeFileTargets(action, ws)) {
109
+ const svc = gate.serviceFor(rootName, group.rootAbsPath);
110
+ const checkpoint = await svc.ensureCheckpoint();
111
+ await svc.recordTouched(group.paths);
112
+ if (checkpoint) {
113
+ await gate.recordMember(rootName, group.rootAbsPath, checkpoint.id);
114
+ }
82
115
  }
83
116
  return decision;
84
117
  };
85
118
  }
119
+ /**
120
+ * Group a file action's resolved absolute targets by the root that contains each
121
+ * (JC-G). write/edit carry an already-absolute `path`; patch preview paths are
122
+ * relative to the PRIMARY cwd (`path.relative(ctx.cwd, abs)` in apply_patch), so
123
+ * we reconstruct the absolute path from the primary root rather than trusting
124
+ * classify's `targets` — which also correctly handles a patch spanning roots.
125
+ */
126
+ function attributeFileTargets(action, ws) {
127
+ const abs = [];
128
+ if (action.kind === "write" || action.kind === "edit") {
129
+ if (action.path)
130
+ abs.push(action.path);
131
+ }
132
+ else if (action.kind === "patch" && action.preview?.type === "patch") {
133
+ for (const file of action.preview.files) {
134
+ abs.push(path.resolve(ws.primary().absPath, file.path));
135
+ }
136
+ }
137
+ const byRoot = new Map();
138
+ for (const target of abs) {
139
+ const root = ws.rootContaining(target);
140
+ const group = byRoot.get(root.name) ?? {
141
+ rootAbsPath: root.absPath,
142
+ paths: [],
143
+ };
144
+ group.paths.push(target);
145
+ byRoot.set(root.name, group);
146
+ }
147
+ return byRoot;
148
+ }
149
+ /**
150
+ * Register every CONDITIONALLY-enabled runtime tool onto `registry`, in the fixed
151
+ * order the model sees them: `remember` (memory), the four LSP tools, the two web
152
+ * tools, the trusted MCP tools, then `spawn_subagent`. Factored out of
153
+ * {@link buildAgentSession} for ONE reason beyond tidiness: it makes the *complete
154
+ * runtime* tool set enumerable by a test (JC-B). The default registry only covers
155
+ * the 14 always-on tools; the allowlist test that pins "no tool can add a workspace
156
+ * root" is only sound if it runs against the SAME set the session ships — so the
157
+ * session factory and that test both build the surface through this one seam. A new
158
+ * conditionally-registered tool added here fails the allowlist test until it is
159
+ * consciously listed and audited.
160
+ *
161
+ * Each family is opt-in and inert when its feature flag is off, exactly as before —
162
+ * this function is a pure move of the inline registration, same guards, same order.
163
+ * `spawnTool` is passed in (already depth-bound) because it needs the orchestrator;
164
+ * it registers only when nesting is allowed (`subagent.maxDepth > 0`).
165
+ */
166
+ export function registerRuntimeTools(registry, config, opts = {}) {
167
+ // Persistent memory write tool (C.29) — inert unless enabled.
168
+ if (config.memory.enabled)
169
+ registry.register(rememberTool);
170
+ // Per-language LSP introspection (C.12): four read-only tools that spawn
171
+ // EXTERNAL language servers, so opt-in; none registered when off.
172
+ if (config.lsp.enabled) {
173
+ registry.register(findDefinitionTool);
174
+ registry.register(findReferencesTool);
175
+ registry.register(getDiagnosticsTool);
176
+ registry.register(hoverTool);
177
+ }
178
+ // Web search + fetch (C.20): read-only, reach the PUBLIC internet and inject
179
+ // untrusted data, so opt-in; neither registered (nor a provider constructed)
180
+ // when off.
181
+ if (config.web.enabled) {
182
+ registry.register(createWebSearchTool());
183
+ registry.register(createWebFetchTool());
184
+ }
185
+ // MCP servers (C.27): already connected, trusted, and adapted to bounded,
186
+ // destructive-gated, `mcp__`-namespaced tools before we got here — a plain
187
+ // hand-off. Empty unless mcp is enabled with a trusted server.
188
+ for (const tool of opts.mcpTools ?? [])
189
+ registry.register(tool);
190
+ // Subagent spawn (C.14) + parallel fan-out (C.33): only when nesting depth
191
+ // allows (maxDepth 0 disables the feature structurally). Both are read-only to
192
+ // propose; the plan propose phase filters them out. The plural `spawn_subagents`
193
+ // is registered ONLY here (the main registry) — never on a child's scoped
194
+ // registry — so parallel fan-out stays a depth-0 capability.
195
+ if (config.subagent.maxDepth > 0) {
196
+ if (opts.spawnTool)
197
+ registry.register(opts.spawnTool);
198
+ if (opts.spawnManyTool)
199
+ registry.register(opts.spawnManyTool);
200
+ }
201
+ }
86
202
  /**
87
203
  * Build a ready-to-run agent {@link Session} from a resolved key — the wiring
88
204
  * shared by `cruxy run` and the onboarding first-win task (so they can't drift).
@@ -92,7 +208,14 @@ export function withCheckpointGate(requestApproval, checkpoints, cwd) {
92
208
  * over the shared U.3 allowlist and a `planRunner` so `session.send` proposes →
93
209
  * approves → executes. Plan mode is fully opt-in; the default path is unchanged.
94
210
  */
95
- export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode = false, renderer, checkpoints, sandbox, hooks, mcpTools = []) {
211
+ export function buildAgentSession(config, apiKey, workspace, ttyInteractive, planMode = false, renderer, checkpoints, sandbox, hooks, mcpTools = []) {
212
+ // The workspace is the single source of truth for "which roots" (C.26). Every
213
+ // primary-scoped subsystem below (git context, project instructions, memory,
214
+ // checkpoints, subagents, the approval gate) derives its cwd from the primary
215
+ // root, so cwd can never disagree with `workspace.primary()`. The full workspace
216
+ // is threaded onto the ToolContext so the fan tools see every root; the subagent
217
+ // orchestrator receives it too, so a child sees the SAME roots as the main loop.
218
+ const cwd = workspace.primary().absPath;
96
219
  const provider = createProvider({
97
220
  provider: config.model.provider,
98
221
  apiKey,
@@ -121,51 +244,71 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
121
244
  const execRegistry = buildDefaultRegistry();
122
245
  const git = getGitInfo(cwd);
123
246
  const projectInstructions = loadProjectInstructions(cwd);
124
- // Persistent memory (C.29): register the write tool and build the recall block
125
- // ONCE at session start. User memory is always eligible; project memory is
126
- // recalled only when trusted (a cloned repo's notes never inject silently).
127
- // Fully inert when disabled. Recall is best-effort — a load problem degrades to
128
- // "no memory", never a hard failure at session start.
247
+ // Persistent memory (C.29): build the recall block ONCE at session start (the
248
+ // `remember` write tool is registered via registerRuntimeTools below). User
249
+ // memory is always eligible; project memory is recalled only when trusted (a
250
+ // cloned repo's notes never inject silently). Fully inert when disabled. Recall
251
+ // is best-effort — a load problem degrades to "no memory", never a hard failure.
129
252
  let recalledMemory = null;
130
253
  if (config.memory.enabled) {
131
- execRegistry.register(rememberTool);
132
- const recall = new MemoryService({ cwd, config: config.memory }).recall();
133
- recalledMemory = recall.block;
134
- if (recall.projectPresentButUntrusted) {
135
- logger.info("project memory found but not trusted run `cruxy memory trust .` to recall it");
254
+ if (workspace.isMultiRoot) {
255
+ // Recall project memory from EVERY trusted root, each under its own
256
+ // root-labeled block; user memory (global) once. Untrusted roots are not
257
+ // injected and are NAMED — a cloned repo's notes never inject silently,
258
+ // and a root whose notes you've never trusted is called out, not dropped.
259
+ const roots = [];
260
+ let userEntries = [];
261
+ for (const r of workspace.roots()) {
262
+ const svc = new MemoryService({
263
+ cwd: r.absPath,
264
+ config: config.memory,
265
+ });
266
+ const st = svc.status();
267
+ // The label is paired with the store HERE — structurally, a block can
268
+ // never carry a root the entries didn't come from.
269
+ if (r.primary) {
270
+ userEntries = st.user;
271
+ for (const e of st.errors) {
272
+ logger.warn(`memory: excluded ${e.scope} entry ${e.id} — ${e.message}`);
273
+ }
274
+ }
275
+ else {
276
+ // Siblings share the same global user file — surface only their own
277
+ // project-scope load errors (user errors already reported by primary).
278
+ for (const e of st.errors.filter((e) => e.scope === "project")) {
279
+ logger.warn(`memory: excluded ${e.scope} entry ${e.id} in ${r.name} — ${e.message}`);
280
+ }
281
+ }
282
+ if (st.projectTrusted) {
283
+ if (st.project.length > 0)
284
+ roots.push({ name: r.name, entries: st.project });
285
+ }
286
+ else if (st.project.length > 0) {
287
+ logger.info(`memory: root "${r.name}" project memory not trusted — run \`cruxy memory trust ${r.absPath}\` to recall it`);
288
+ }
289
+ }
290
+ recalledMemory = buildMultiRootRecallBlock({
291
+ user: userEntries,
292
+ roots,
293
+ maxTokens: config.memory.maxRecallTokens,
294
+ });
136
295
  }
137
- for (const e of recall.errors) {
138
- logger.warn(`memory: excluded ${e.scope} entry ${e.id} ${e.message}`);
296
+ else {
297
+ const recall = new MemoryService({ cwd, config: config.memory }).recall();
298
+ recalledMemory = recall.block;
299
+ if (recall.projectPresentButUntrusted) {
300
+ logger.info("project memory found but not trusted — run `cruxy memory trust .` to recall it");
301
+ }
302
+ for (const e of recall.errors) {
303
+ logger.warn(`memory: excluded ${e.scope} entry ${e.id} — ${e.message}`);
304
+ }
139
305
  }
140
306
  }
141
- // Per-language LSP (C.12): register the four read-only introspection tools
142
- // only when enabled. They spawn and manage EXTERNAL language-server processes,
143
- // so like the sandbox — the feature is opt-in; when off, none is registered
144
- // and no server ever spawns. Read-only (no approval), so they bypass the U.3
145
- // gate like search_codebase and are available to subagents and plan proposals.
146
- if (config.lsp.enabled) {
147
- execRegistry.register(findDefinitionTool);
148
- execRegistry.register(findReferencesTool);
149
- execRegistry.register(getDiagnosticsTool);
150
- execRegistry.register(hoverTool);
151
- }
152
- // Web search + fetch (C.20): register the two read-only web tools only when
153
- // enabled. They reach the PUBLIC internet and inject attacker-controllable data
154
- // (results/pages are demarcated as untrusted and never persisted), so — like
155
- // LSP and MCP — the feature is opt-in; when off, neither tool is registered and
156
- // no search provider is ever constructed. Read-only (no approval), so they
157
- // bypass the U.3 gate like search_codebase.
158
- if (config.web.enabled) {
159
- execRegistry.register(createWebSearchTool());
160
- execRegistry.register(createWebFetchTool());
161
- }
162
- // MCP servers (C.27): the caller connected + trusted the servers and produced
163
- // these tools through the single adapter seam BEFORE building the session, so
164
- // registration here is a plain hand-off — every one is destructive-gated,
165
- // demarcated, and bounded by construction. Empty unless `mcp.enabled` and at
166
- // least one trusted server produced tools, so the default path is unchanged.
167
- for (const tool of mcpTools)
168
- execRegistry.register(tool);
307
+ // The conditionally-registered runtime tools (remember / LSP / web / MCP) are
308
+ // added below via registerRuntimeTools the single seam the JC-B allowlist test
309
+ // also builds through, so the audited surface can never drift from the shipped
310
+ // one. spawn_subagent joins them there too (it needs the orchestrator built
311
+ // first). See registerRuntimeTools.
169
312
  // One io shared by every prompt in the session (plan approval, the U.3 gate,
170
313
  // and any gate inside a subagent), so they all coordinate with the same live
171
314
  // region. The full wrapper stack around an ApprovalService is factored here
@@ -173,7 +316,17 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
173
316
  // prompt + same checkpoint hook, but a new (empty) session allowlist — a
174
317
  // grant in the parent never silently widens a child's authority.
175
318
  const io = suspendStatusOnPrompt(defaultPromptIO(shouldUseColor()), renderer);
176
- const gate = (approval) => withCheckpointGate(resumeLineAfterApproval((action) => approval.requestApproval(action), renderer), checkpoints, cwd);
319
+ // The C.26 coupling: `checkpointsActive` is true exactly when a per-root gate is
320
+ // wired, and it is set on the SAME ctx whose `requestApproval` IS that gate — so
321
+ // lifting the non-primary-write refusal and capturing the write are one decision.
322
+ const checkpointsActive = Boolean(checkpoints);
323
+ // The C.33 spine: ONE approval mutex shared by the main loop and every subagent
324
+ // gate, so across concurrent siblings only one non-read action is in its gate at
325
+ // a time — one prompt on screen, one checkpoint snapshot/set-write in flight.
326
+ // Read-tier actions bypass it (see serializeGate), so a parallel read fan-out is
327
+ // never stalled behind an unrelated pending prompt.
328
+ const approvalMutex = new ApprovalMutex();
329
+ const gate = (approval) => serializeGate(withCheckpointGate(resumeLineAfterApproval((action) => approval.requestApproval(action), renderer), checkpoints, workspace), approvalMutex, cwd);
177
330
  // Subagent orchestration (C.14): spawn_subagent goes on the main registry
178
331
  // only when depth allows (maxDepth 0 disables the feature structurally).
179
332
  // Registered before the plan wiring so plan-mode execution steps can
@@ -184,16 +337,29 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
184
337
  router,
185
338
  parentRegistry: execRegistry,
186
339
  cwd,
340
+ workspace,
187
341
  logger,
188
342
  git,
189
343
  projectInstructions,
190
344
  renderer,
191
345
  sandbox,
346
+ checkpointsActive,
192
347
  makeChildApproval: () => gate(new ApprovalService({ cwd, interactive: ttyInteractive, io })),
193
348
  });
194
- if (config.subagent.maxDepth > 0) {
195
- execRegistry.register(makeSpawnSubagentTool(orchestrator, 0));
196
- }
349
+ // Now that the orchestrator exists, register every conditionally-enabled tool
350
+ // (remember / LSP / web / MCP / spawn_subagent) through the one seam the JC-B
351
+ // allowlist test also uses — order preserved, behaviour byte-identical.
352
+ const spawnTool = config.subagent.maxDepth > 0
353
+ ? makeSpawnSubagentTool(orchestrator, 0)
354
+ : undefined;
355
+ const spawnManyTool = config.subagent.maxDepth > 0
356
+ ? makeSpawnSubagentsTool(orchestrator, 0)
357
+ : undefined;
358
+ registerRuntimeTools(execRegistry, config, {
359
+ mcpTools,
360
+ spawnTool,
361
+ spawnManyTool,
362
+ });
197
363
  if (planMode) {
198
364
  // One allowlist shared by the plan-approval prompt and the per-action
199
365
  // gate, so a grant recorded during execution is honored by U.3's own check.
@@ -207,9 +373,11 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
207
373
  });
208
374
  const ctx = {
209
375
  cwd,
376
+ workspace,
210
377
  config,
211
378
  logger,
212
379
  requestApproval: gate(approval),
380
+ checkpointsActive,
213
381
  sandbox,
214
382
  };
215
383
  const planRunner = ({ messages, projectInstructions, recalledMemory: turnMemory, renderer: turnRenderer, onRequestUsage, }) => runPlanSession({
@@ -248,7 +416,15 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
248
416
  interactive: ttyInteractive,
249
417
  io,
250
418
  });
251
- const ctx = { cwd, config, logger, requestApproval: gate(approval), sandbox };
419
+ const ctx = {
420
+ cwd,
421
+ workspace,
422
+ config,
423
+ logger,
424
+ requestApproval: gate(approval),
425
+ checkpointsActive,
426
+ sandbox,
427
+ };
252
428
  return new Session({
253
429
  provider,
254
430
  registry: execRegistry,
@@ -248,6 +248,15 @@ export declare const SubagentConfigSchema: z.ZodObject<{
248
248
  * 1 lets it spawn subagents that cannot themselves spawn (no fork bombs).
249
249
  */
250
250
  maxDepth: z.ZodDefault<z.ZodNumber>;
251
+ /**
252
+ * Parallel fan-out breadth (C.33): the maximum number of subagents that run
253
+ * concurrently across the whole session, enforced by one shared semaphore.
254
+ * Parallel fan-out happens at depth 0 only and everything beneath it is
255
+ * sequential, so this composes with {@link maxDepth} into a LINEAR global
256
+ * ceiling (≤ maxConcurrency × maxDepth live agents) — never an exponential
257
+ * depth×breadth fork bomb. Default 3.
258
+ */
259
+ maxConcurrency: z.ZodDefault<z.ZodNumber>;
251
260
  /** Per-subagent budget ceilings; spawn-time overrides are clamped to these. */
252
261
  defaultBudget: z.ZodDefault<z.ZodObject<{
253
262
  /** Hard cap on the subagent's model turns. */
@@ -267,6 +276,7 @@ export declare const SubagentConfigSchema: z.ZodObject<{
267
276
  }>>;
268
277
  }, "strict", z.ZodTypeAny, {
269
278
  maxDepth: number;
279
+ maxConcurrency: number;
270
280
  defaultBudget: {
271
281
  maxTokens: number;
272
282
  maxIterations: number;
@@ -274,6 +284,7 @@ export declare const SubagentConfigSchema: z.ZodObject<{
274
284
  };
275
285
  }, {
276
286
  maxDepth?: number | undefined;
287
+ maxConcurrency?: number | undefined;
277
288
  defaultBudget?: {
278
289
  timeoutMs?: number | undefined;
279
290
  maxTokens?: number | undefined;
@@ -1027,6 +1038,15 @@ export declare const CruxyConfigSchema: z.ZodObject<{
1027
1038
  * 1 lets it spawn subagents that cannot themselves spawn (no fork bombs).
1028
1039
  */
1029
1040
  maxDepth: z.ZodDefault<z.ZodNumber>;
1041
+ /**
1042
+ * Parallel fan-out breadth (C.33): the maximum number of subagents that run
1043
+ * concurrently across the whole session, enforced by one shared semaphore.
1044
+ * Parallel fan-out happens at depth 0 only and everything beneath it is
1045
+ * sequential, so this composes with {@link maxDepth} into a LINEAR global
1046
+ * ceiling (≤ maxConcurrency × maxDepth live agents) — never an exponential
1047
+ * depth×breadth fork bomb. Default 3.
1048
+ */
1049
+ maxConcurrency: z.ZodDefault<z.ZodNumber>;
1030
1050
  /** Per-subagent budget ceilings; spawn-time overrides are clamped to these. */
1031
1051
  defaultBudget: z.ZodDefault<z.ZodObject<{
1032
1052
  /** Hard cap on the subagent's model turns. */
@@ -1046,6 +1066,7 @@ export declare const CruxyConfigSchema: z.ZodObject<{
1046
1066
  }>>;
1047
1067
  }, "strict", z.ZodTypeAny, {
1048
1068
  maxDepth: number;
1069
+ maxConcurrency: number;
1049
1070
  defaultBudget: {
1050
1071
  maxTokens: number;
1051
1072
  maxIterations: number;
@@ -1053,6 +1074,7 @@ export declare const CruxyConfigSchema: z.ZodObject<{
1053
1074
  };
1054
1075
  }, {
1055
1076
  maxDepth?: number | undefined;
1077
+ maxConcurrency?: number | undefined;
1056
1078
  defaultBudget?: {
1057
1079
  timeoutMs?: number | undefined;
1058
1080
  maxTokens?: number | undefined;
@@ -1450,6 +1472,7 @@ export declare const CruxyConfigSchema: z.ZodObject<{
1450
1472
  };
1451
1473
  subagent: {
1452
1474
  maxDepth: number;
1475
+ maxConcurrency: number;
1453
1476
  defaultBudget: {
1454
1477
  maxTokens: number;
1455
1478
  maxIterations: number;
@@ -1594,6 +1617,7 @@ export declare const CruxyConfigSchema: z.ZodObject<{
1594
1617
  } | undefined;
1595
1618
  subagent?: {
1596
1619
  maxDepth?: number | undefined;
1620
+ maxConcurrency?: number | undefined;
1597
1621
  defaultBudget?: {
1598
1622
  timeoutMs?: number | undefined;
1599
1623
  maxTokens?: number | undefined;
@@ -181,6 +181,15 @@ export const SubagentConfigSchema = z
181
181
  * 1 lets it spawn subagents that cannot themselves spawn (no fork bombs).
182
182
  */
183
183
  maxDepth: z.number().int().nonnegative().default(1),
184
+ /**
185
+ * Parallel fan-out breadth (C.33): the maximum number of subagents that run
186
+ * concurrently across the whole session, enforced by one shared semaphore.
187
+ * Parallel fan-out happens at depth 0 only and everything beneath it is
188
+ * sequential, so this composes with {@link maxDepth} into a LINEAR global
189
+ * ceiling (≤ maxConcurrency × maxDepth live agents) — never an exponential
190
+ * depth×breadth fork bomb. Default 3.
191
+ */
192
+ maxConcurrency: z.number().int().positive().default(3),
184
193
  /** Per-subagent budget ceilings; spawn-time overrides are clamped to these. */
185
194
  defaultBudget: z
186
195
  .object({
@@ -90,6 +90,33 @@ export declare function forgeAuth(host?: string): CruxyError;
90
90
  export declare function gitProtectedBranch(branch: string): CruxyError;
91
91
  /** The forge REST API returned an error (non-auth) while opening a PR. */
92
92
  export declare function forgeApi(title: string, underlying?: unknown, meta?: Record<string, unknown>): CruxyError;
93
+ /**
94
+ * The `origin` remote of the acting root resolved to a DIFFERENT `owner/repo`
95
+ * between the U.3 preview (what the human approved) and the moment the pull-request
96
+ * API call is made — a concurrent `git remote set-url` on a mutable `.git/config`
97
+ * (C.26 Step 4). The PR is NOT opened: we refuse rather than warn-and-proceed,
98
+ * because opening it would target a repo the human never saw. Both targets are
99
+ * named so the discrepancy is legible. `owner/repo` are already-parsed identifiers
100
+ * (never an upstream model id), so the message is gag-safe by construction (U.8).
101
+ */
102
+ export declare function vcsRemoteChanged(approved: {
103
+ host: string;
104
+ owner: string;
105
+ repo: string;
106
+ }, current: {
107
+ host: string;
108
+ owner: string;
109
+ repo: string;
110
+ }, rootName: string): CruxyError;
111
+ /**
112
+ * A pull request was requested for one declared root while a SIBLING declared root
113
+ * shares the same git working tree — two non-overlapping roots inside one repo
114
+ * (e.g. `packages/a` + `packages/b` under one `.git`), which filesystem-overlap
115
+ * refusal does not catch (C.26 Step 4). A commit there `git add -A`s the sibling's
116
+ * changes too, so the PR would span both roots. A PR is a single-repo artifact, so
117
+ * we refuse (naming both) rather than silently PR one half.
118
+ */
119
+ export declare function vcsCrossRoot(root: string, sibling: string, repoPath: string): CruxyError;
93
120
  /**
94
121
  * `git push` failed — most often the husky `pre-push` verify hook (build ·
95
122
  * typecheck · lint · test) or a rejected non-fast-forward. We never `--force` or
@@ -137,6 +164,7 @@ export declare function rootAmbiguous(ref: string, candidates: string[]): CruxyE
137
164
  * session start — overlap makes "which root owns this path" ambiguous and lets two
138
165
  * checkpoints/grants fight over the same bytes.
139
166
  */
167
+ export declare function multirootWriteDeferred(root: string, primary: string): CruxyError;
140
168
  export declare function rootOverlap(a: string, b: string): CruxyError;
141
169
  /**
142
170
  * An interactive add-root was refused: no TTY to confirm, or the user declined the
@@ -162,6 +190,29 @@ export declare function checkpointSetPartial(runId: string, restored: string[],
162
190
  * orchestrator seam was driven directly — fail loud, never spawn.
163
191
  */
164
192
  export declare function subagentDepthExceeded(depth: number, maxDepth: number): CruxyError;
193
+ /** One workspace root claimed by two-or-more WRITING children of a single
194
+ * `spawn_subagents` call — the unit of a disjoint-scope violation (C.33). */
195
+ export interface ScopeConflict {
196
+ /** The root name both children declared write scope on. */
197
+ root: string;
198
+ /** The task labels of every writing child that claimed `root` (always ≥2). */
199
+ tasks: string[];
200
+ }
201
+ /**
202
+ * A parallel fan-out (C.33) declared children with overlapping write scope — two
203
+ * or more target the SAME workspace root while both hold mutating tools. The
204
+ * ENTIRE batch is refused BEFORE any child is dispatched, so concurrent siblings
205
+ * can never race on one root's files or checkpoint. Every colliding group is
206
+ * named (the conflicting task pairs, per root) so the model can fix all of them
207
+ * at once. Surfaced as a tool error to correct (assign distinct roots, or drop
208
+ * write tools on all but one child per root).
209
+ *
210
+ * THE HONEST LIMIT, stated in the message: the check is on DECLARED scope — the
211
+ * tools + root each child was granted — NOT the files actually written. Two
212
+ * children could still touch disjoint paths under one root; cruxy refuses anyway
213
+ * rather than let two potential writers race on that root's single checkpoint.
214
+ */
215
+ export declare function subagentScopeOverlap(conflicts: readonly ScopeConflict[]): CruxyError;
165
216
  /**
166
217
  * A subagent run failed outright (provider error, tool crash) before producing
167
218
  * a result. Normally folded into the structured `SubagentResult` the parent