@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
@@ -1,19 +1,25 @@
1
- import path from "node:path";
2
1
  import { createProvider } from "@cruxy/sdk";
3
2
  import { loadProjectInstructions } from "../config/index.js";
4
3
  import { logger } from "../utils/logger.js";
5
4
  import { getGitInfo } from "../utils/git.js";
6
- import { ApprovalService, InteractivePolicy, SessionAllowlist, classify, defaultPromptIO, } from "../approval/index.js";
5
+ import { ApprovalMutex, ApprovalService, InteractivePolicy, SessionAllowlist, defaultPromptIO, serializeGate, } from "../approval/index.js";
6
+ import { withCheckpointGate } from "../checkpoint/index.js";
7
+ // Re-exported for back-compat: the checkpoint hook moved to the checkpoint
8
+ // package (so the subagent orchestrator and C.28 jobs can compose it without
9
+ // importing the CLI layer). Existing imports of `withCheckpointGate` from the
10
+ // session factory keep working.
11
+ export { withCheckpointGate };
7
12
  import { shouldUseColor } from "../errors/index.js";
8
13
  import { buildDefaultRegistry, } from "../tools/index.js";
9
14
  import { Session, } from "../agent/index.js";
10
15
  import { PlanExecutionPolicy, runPlanSession } from "../plan/index.js";
11
16
  import { routerForConfig } from "../routing/index.js";
12
- import { MemoryService, rememberTool } from "../memory/index.js";
17
+ import { MemoryService, buildMultiRootRecallBlock, rememberTool, } from "../memory/index.js";
13
18
  import { findDefinitionTool, findReferencesTool, getDiagnosticsTool, hoverTool, } from "../lsp/index.js";
14
19
  import { createWebSearchTool, createWebFetchTool } from "../web/index.js";
15
20
  import { appendRun } from "../usage/index.js";
16
- import { SubagentOrchestrator, makeSpawnSubagentTool, } from "../subagent/index.js";
21
+ import { Semaphore, SubagentOrchestrator, makeSpawnSubagentTool, makeSpawnSubagentsTool, } from "../subagent/index.js";
22
+ import { ApprovalQueue, JobManager, makeRunInBackgroundTool, } from "../jobs/index.js";
17
23
  /**
18
24
  * Wrap a PromptIO so the live region yields before any prompt text lands
19
25
  * (U.2/U.4): the prompt writes to stderr while the status line owns the last
@@ -54,97 +60,62 @@ function resumeLineAfterApproval(requestApproval, renderer) {
54
60
  };
55
61
  }
56
62
  /**
57
- * Wrap the approval gate with the C.32 auto-checkpoint hook. Ordering is the
58
- * whole point: a tool mutates only *after* `requestApproval` resolves, so
59
- * snapshotting after an `allow` decision but before returning it means the
60
- * checkpoint always lands before the run's first mutation and a denied
61
- * action never creates one. The same seam records which paths the run touched
62
- * (file actions) or that attribution is lost (shell), for rollback's
63
- * external-change detection.
64
- */
65
- export function withCheckpointGate(requestApproval, gate, ws) {
66
- if (!gate)
67
- return requestApproval;
68
- return async (action) => {
69
- const decision = await requestApproval(action);
70
- if (!decision.allow)
71
- return decision;
72
- const request = classify(action, ws.primary().absPath);
73
- if (request.tier === "read")
74
- return decision;
75
- if (action.kind === "shell" || action.kind === "test") {
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;
88
- }
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
- }
115
- }
116
- return decision;
117
- };
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.
63
+ * Register every CONDITIONALLY-enabled runtime tool onto `registry`, in the fixed
64
+ * order the model sees them: `remember` (memory), the four LSP tools, the two web
65
+ * tools, the trusted MCP tools, then `spawn_subagent`. Factored out of
66
+ * {@link buildAgentSession} for ONE reason beyond tidiness: it makes the *complete
67
+ * runtime* tool set enumerable by a test (JC-B). The default registry only covers
68
+ * the 14 always-on tools; the allowlist test that pins "no tool can add a workspace
69
+ * root" is only sound if it runs against the SAME set the session ships — so the
70
+ * session factory and that test both build the surface through this one seam. A new
71
+ * conditionally-registered tool added here fails the allowlist test until it is
72
+ * consciously listed and audited.
73
+ *
74
+ * Each family is opt-in and inert when its feature flag is off, exactly as before —
75
+ * this function is a pure move of the inline registration, same guards, same order.
76
+ * `spawnTool` is passed in (already depth-bound) because it needs the orchestrator;
77
+ * it registers only when nesting is allowed (`subagent.maxDepth > 0`).
125
78
  */
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);
79
+ export function registerRuntimeTools(registry, config, opts = {}) {
80
+ // Persistent memory write tool (C.29) — inert unless enabled.
81
+ if (config.memory.enabled)
82
+ registry.register(rememberTool);
83
+ // Per-language LSP introspection (C.12): four read-only tools that spawn
84
+ // EXTERNAL language servers, so opt-in; none registered when off.
85
+ if (config.lsp.enabled) {
86
+ registry.register(findDefinitionTool);
87
+ registry.register(findReferencesTool);
88
+ registry.register(getDiagnosticsTool);
89
+ registry.register(hoverTool);
131
90
  }
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
- }
91
+ // Web search + fetch (C.20): read-only, reach the PUBLIC internet and inject
92
+ // untrusted data, so opt-in; neither registered (nor a provider constructed)
93
+ // when off.
94
+ if (config.web.enabled) {
95
+ registry.register(createWebSearchTool());
96
+ registry.register(createWebFetchTool());
136
97
  }
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);
98
+ // MCP servers (C.27): already connected, trusted, and adapted to bounded,
99
+ // destructive-gated, `mcp__`-namespaced tools before we got here — a plain
100
+ // hand-off. Empty unless mcp is enabled with a trusted server.
101
+ for (const tool of opts.mcpTools ?? [])
102
+ registry.register(tool);
103
+ // Subagent spawn (C.14) + parallel fan-out (C.33): only when nesting depth
104
+ // allows (maxDepth 0 disables the feature structurally). Both are read-only to
105
+ // propose; the plan propose phase filters them out. The plural `spawn_subagents`
106
+ // is registered ONLY here (the main registry) — never on a child's scoped
107
+ // registry — so parallel fan-out stays a depth-0 capability.
108
+ if (config.subagent.maxDepth > 0) {
109
+ if (opts.spawnTool)
110
+ registry.register(opts.spawnTool);
111
+ if (opts.spawnManyTool)
112
+ registry.register(opts.spawnManyTool);
146
113
  }
147
- return byRoot;
114
+ // Background jobs (C.28): the non-blocking `run_in_background` dispatch tool,
115
+ // registered only when the feature is enabled (off by default). Bound to the
116
+ // session's job manager by the caller.
117
+ if (config.jobs.enabled && opts.jobTool)
118
+ registry.register(opts.jobTool);
148
119
  }
149
120
  /**
150
121
  * Build a ready-to-run agent {@link Session} from a resolved key — the wiring
@@ -191,51 +162,71 @@ export function buildAgentSession(config, apiKey, workspace, ttyInteractive, pla
191
162
  const execRegistry = buildDefaultRegistry();
192
163
  const git = getGitInfo(cwd);
193
164
  const projectInstructions = loadProjectInstructions(cwd);
194
- // Persistent memory (C.29): register the write tool and build the recall block
195
- // ONCE at session start. User memory is always eligible; project memory is
196
- // recalled only when trusted (a cloned repo's notes never inject silently).
197
- // Fully inert when disabled. Recall is best-effort — a load problem degrades to
198
- // "no memory", never a hard failure at session start.
165
+ // Persistent memory (C.29): build the recall block ONCE at session start (the
166
+ // `remember` write tool is registered via registerRuntimeTools below). User
167
+ // memory is always eligible; project memory is recalled only when trusted (a
168
+ // cloned repo's notes never inject silently). Fully inert when disabled. Recall
169
+ // is best-effort — a load problem degrades to "no memory", never a hard failure.
199
170
  let recalledMemory = null;
200
171
  if (config.memory.enabled) {
201
- execRegistry.register(rememberTool);
202
- const recall = new MemoryService({ cwd, config: config.memory }).recall();
203
- recalledMemory = recall.block;
204
- if (recall.projectPresentButUntrusted) {
205
- logger.info("project memory found but not trusted run `cruxy memory trust .` to recall it");
172
+ if (workspace.isMultiRoot) {
173
+ // Recall project memory from EVERY trusted root, each under its own
174
+ // root-labeled block; user memory (global) once. Untrusted roots are not
175
+ // injected and are NAMED — a cloned repo's notes never inject silently,
176
+ // and a root whose notes you've never trusted is called out, not dropped.
177
+ const roots = [];
178
+ let userEntries = [];
179
+ for (const r of workspace.roots()) {
180
+ const svc = new MemoryService({
181
+ cwd: r.absPath,
182
+ config: config.memory,
183
+ });
184
+ const st = svc.status();
185
+ // The label is paired with the store HERE — structurally, a block can
186
+ // never carry a root the entries didn't come from.
187
+ if (r.primary) {
188
+ userEntries = st.user;
189
+ for (const e of st.errors) {
190
+ logger.warn(`memory: excluded ${e.scope} entry ${e.id} — ${e.message}`);
191
+ }
192
+ }
193
+ else {
194
+ // Siblings share the same global user file — surface only their own
195
+ // project-scope load errors (user errors already reported by primary).
196
+ for (const e of st.errors.filter((e) => e.scope === "project")) {
197
+ logger.warn(`memory: excluded ${e.scope} entry ${e.id} in ${r.name} — ${e.message}`);
198
+ }
199
+ }
200
+ if (st.projectTrusted) {
201
+ if (st.project.length > 0)
202
+ roots.push({ name: r.name, entries: st.project });
203
+ }
204
+ else if (st.project.length > 0) {
205
+ logger.info(`memory: root "${r.name}" project memory not trusted — run \`cruxy memory trust ${r.absPath}\` to recall it`);
206
+ }
207
+ }
208
+ recalledMemory = buildMultiRootRecallBlock({
209
+ user: userEntries,
210
+ roots,
211
+ maxTokens: config.memory.maxRecallTokens,
212
+ });
206
213
  }
207
- for (const e of recall.errors) {
208
- logger.warn(`memory: excluded ${e.scope} entry ${e.id} ${e.message}`);
214
+ else {
215
+ const recall = new MemoryService({ cwd, config: config.memory }).recall();
216
+ recalledMemory = recall.block;
217
+ if (recall.projectPresentButUntrusted) {
218
+ logger.info("project memory found but not trusted — run `cruxy memory trust .` to recall it");
219
+ }
220
+ for (const e of recall.errors) {
221
+ logger.warn(`memory: excluded ${e.scope} entry ${e.id} — ${e.message}`);
222
+ }
209
223
  }
210
224
  }
211
- // Per-language LSP (C.12): register the four read-only introspection tools
212
- // only when enabled. They spawn and manage EXTERNAL language-server processes,
213
- // so like the sandbox — the feature is opt-in; when off, none is registered
214
- // and no server ever spawns. Read-only (no approval), so they bypass the U.3
215
- // gate like search_codebase and are available to subagents and plan proposals.
216
- if (config.lsp.enabled) {
217
- execRegistry.register(findDefinitionTool);
218
- execRegistry.register(findReferencesTool);
219
- execRegistry.register(getDiagnosticsTool);
220
- execRegistry.register(hoverTool);
221
- }
222
- // Web search + fetch (C.20): register the two read-only web tools only when
223
- // enabled. They reach the PUBLIC internet and inject attacker-controllable data
224
- // (results/pages are demarcated as untrusted and never persisted), so — like
225
- // LSP and MCP — the feature is opt-in; when off, neither tool is registered and
226
- // no search provider is ever constructed. Read-only (no approval), so they
227
- // bypass the U.3 gate like search_codebase.
228
- if (config.web.enabled) {
229
- execRegistry.register(createWebSearchTool());
230
- execRegistry.register(createWebFetchTool());
231
- }
232
- // MCP servers (C.27): the caller connected + trusted the servers and produced
233
- // these tools through the single adapter seam BEFORE building the session, so
234
- // registration here is a plain hand-off — every one is destructive-gated,
235
- // demarcated, and bounded by construction. Empty unless `mcp.enabled` and at
236
- // least one trusted server produced tools, so the default path is unchanged.
237
- for (const tool of mcpTools)
238
- execRegistry.register(tool);
225
+ // The conditionally-registered runtime tools (remember / LSP / web / MCP) are
226
+ // added below via registerRuntimeTools the single seam the JC-B allowlist test
227
+ // also builds through, so the audited surface can never drift from the shipped
228
+ // one. spawn_subagent joins them there too (it needs the orchestrator built
229
+ // first). See registerRuntimeTools.
239
230
  // One io shared by every prompt in the session (plan approval, the U.3 gate,
240
231
  // and any gate inside a subagent), so they all coordinate with the same live
241
232
  // region. The full wrapper stack around an ApprovalService is factored here
@@ -247,7 +238,20 @@ export function buildAgentSession(config, apiKey, workspace, ttyInteractive, pla
247
238
  // wired, and it is set on the SAME ctx whose `requestApproval` IS that gate — so
248
239
  // lifting the non-primary-write refusal and capturing the write are one decision.
249
240
  const checkpointsActive = Boolean(checkpoints);
250
- const gate = (approval) => withCheckpointGate(resumeLineAfterApproval((action) => approval.requestApproval(action), renderer), checkpoints, workspace);
241
+ // The C.33 spine: ONE approval mutex shared by the main loop and every subagent
242
+ // gate, so across concurrent siblings only one non-read action is in its gate at
243
+ // a time — one prompt on screen, one checkpoint snapshot/set-write in flight.
244
+ // Read-tier actions bypass it (see serializeGate), so a parallel read fan-out is
245
+ // never stalled behind an unrelated pending prompt.
246
+ const approvalMutex = new ApprovalMutex();
247
+ // The ONE execution semaphore for the whole session (C.28 + C.33): subagent
248
+ // fan-out AND background jobs draw permits from THIS instance, so
249
+ // `subagent.maxConcurrency` bounds their COMBINED concurrency — not one cap
250
+ // each. The ONE pending-approval queue background jobs produce onto is created
251
+ // here too, so foreground servicing and job production share it.
252
+ const executionSemaphore = new Semaphore(config.subagent.maxConcurrency);
253
+ const approvalQueue = new ApprovalQueue();
254
+ const gate = (approval) => serializeGate(withCheckpointGate(resumeLineAfterApproval((action) => approval.requestApproval(action), renderer), checkpoints, workspace), approvalMutex, cwd);
251
255
  // Subagent orchestration (C.14): spawn_subagent goes on the main registry
252
256
  // only when depth allows (maxDepth 0 disables the feature structurally).
253
257
  // Registered before the plan wiring so plan-mode execution steps can
@@ -265,11 +269,49 @@ export function buildAgentSession(config, apiKey, workspace, ttyInteractive, pla
265
269
  renderer,
266
270
  sandbox,
267
271
  checkpointsActive,
272
+ executionSemaphore,
268
273
  makeChildApproval: () => gate(new ApprovalService({ cwd, interactive: ttyInteractive, io })),
269
274
  });
270
- if (config.subagent.maxDepth > 0) {
271
- execRegistry.register(makeSpawnSubagentTool(orchestrator, 0));
272
- }
275
+ // Background jobs (C.28): the session-scoped manager the `run_in_background`
276
+ // tool dispatches onto. Built only when enabled. It shares the SAME execution
277
+ // semaphore, approval queue, and approval mutex as the foreground/subagents —
278
+ // one system, one cap, one queue — and each job gets its OWN checkpoint gate
279
+ // (keyed by job id) so `cruxy rollback <id>` isolates a job.
280
+ const jobManager = config.jobs.enabled
281
+ ? new JobManager({
282
+ config,
283
+ provider,
284
+ router,
285
+ parentRegistry: execRegistry,
286
+ cwd,
287
+ workspace,
288
+ logger,
289
+ git,
290
+ projectInstructions,
291
+ sandbox,
292
+ semaphore: executionSemaphore,
293
+ approvalQueue,
294
+ approvalMutex,
295
+ foregroundInteractive: ttyInteractive,
296
+ promptIO: io,
297
+ })
298
+ : undefined;
299
+ const jobTool = jobManager ? makeRunInBackgroundTool(jobManager) : undefined;
300
+ // Now that the orchestrator exists, register every conditionally-enabled tool
301
+ // (remember / LSP / web / MCP / spawn_subagent) through the one seam the JC-B
302
+ // allowlist test also uses — order preserved, behaviour byte-identical.
303
+ const spawnTool = config.subagent.maxDepth > 0
304
+ ? makeSpawnSubagentTool(orchestrator, 0)
305
+ : undefined;
306
+ const spawnManyTool = config.subagent.maxDepth > 0
307
+ ? makeSpawnSubagentsTool(orchestrator, 0)
308
+ : undefined;
309
+ registerRuntimeTools(execRegistry, config, {
310
+ mcpTools,
311
+ spawnTool,
312
+ spawnManyTool,
313
+ jobTool,
314
+ });
273
315
  if (planMode) {
274
316
  // One allowlist shared by the plan-approval prompt and the per-action
275
317
  // gate, so a grant recorded during execution is honored by U.3's own check.
@@ -319,6 +361,7 @@ export function buildAgentSession(config, apiKey, workspace, ttyInteractive, pla
319
361
  hooks,
320
362
  router,
321
363
  onRunUsage,
364
+ jobs: jobManager,
322
365
  });
323
366
  }
324
367
  const approval = new ApprovalService({
@@ -346,5 +389,6 @@ export function buildAgentSession(config, apiKey, workspace, ttyInteractive, pla
346
389
  hooks,
347
390
  router,
348
391
  onRunUsage,
392
+ jobs: jobManager,
349
393
  });
350
394
  }
@@ -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,12 +284,60 @@ 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;
280
291
  maxIterations?: number | undefined;
281
292
  } | undefined;
282
293
  }>;
294
+ /**
295
+ * Session-scoped background jobs (C.28): non-interactive orchestration the main
296
+ * agent dispatches with `run_in_background`, running CONCURRENTLY with the
297
+ * foreground session but bound to it — nothing survives session exit (NOT a
298
+ * daemon). A job hitting a gated action enqueues an approval request into the one
299
+ * foreground queue and pauses until a human services it; a paused job releases
300
+ * its execution slot.
301
+ *
302
+ * Two distinct ceilings, stated explicitly because they bound different things:
303
+ * • {@link maxJobs} — how many background JOBS may exist at once (queued +
304
+ * running + paused). A dispatch past it is refused (`CRUXY_E_JOB_LIMIT`).
305
+ * • the shared execution cap is `subagent.maxConcurrency` (default 3) — how many
306
+ * runs (subagents AND jobs, combined) may EXECUTE at once. It is NOT
307
+ * duplicated here: jobs and subagents draw from the one semaphore. So up to
308
+ * `maxJobs` jobs can be alive while only `maxConcurrency` execute; the rest
309
+ * wait for a slot (or are paused on a human).
310
+ */
311
+ export declare const JobsConfigSchema: z.ZodObject<{
312
+ /**
313
+ * Master switch. When true, the `run_in_background` tool is registered so the
314
+ * agent can dispatch background jobs. OFF by default — background work is an
315
+ * opt-in capability, and a session that never enables it behaves exactly as
316
+ * before (no tool, no manager, no queue).
317
+ */
318
+ enabled: z.ZodDefault<z.ZodBoolean>;
319
+ /**
320
+ * Ceiling on live background jobs (queued + running + paused). Distinct from
321
+ * the shared execution cap (`subagent.maxConcurrency`): this bounds how many
322
+ * jobs can be OUTSTANDING, not how many run at once. Default 5.
323
+ */
324
+ maxJobs: z.ZodDefault<z.ZodNumber>;
325
+ /**
326
+ * How many of a job's most-recent log lines are retained in its ring buffer
327
+ * for `cruxy logs <id>`. Bounded so a chatty job can't grow memory without
328
+ * limit; older lines roll off oldest-first. Default 1000.
329
+ */
330
+ logBufferLines: z.ZodDefault<z.ZodNumber>;
331
+ }, "strict", z.ZodTypeAny, {
332
+ maxJobs: number;
333
+ enabled: boolean;
334
+ logBufferLines: number;
335
+ }, {
336
+ maxJobs?: number | undefined;
337
+ enabled?: boolean | undefined;
338
+ logBufferLines?: number | undefined;
339
+ }>;
340
+ export type JobsConfig = z.infer<typeof JobsConfigSchema>;
283
341
  /**
284
342
  * Sandbox / container execution (C.16): defense-in-depth beneath the U.3 gate.
285
343
  * When enabled, `run_command` and `run_tests` execute inside an isolated,
@@ -1027,6 +1085,15 @@ export declare const CruxyConfigSchema: z.ZodObject<{
1027
1085
  * 1 lets it spawn subagents that cannot themselves spawn (no fork bombs).
1028
1086
  */
1029
1087
  maxDepth: z.ZodDefault<z.ZodNumber>;
1088
+ /**
1089
+ * Parallel fan-out breadth (C.33): the maximum number of subagents that run
1090
+ * concurrently across the whole session, enforced by one shared semaphore.
1091
+ * Parallel fan-out happens at depth 0 only and everything beneath it is
1092
+ * sequential, so this composes with {@link maxDepth} into a LINEAR global
1093
+ * ceiling (≤ maxConcurrency × maxDepth live agents) — never an exponential
1094
+ * depth×breadth fork bomb. Default 3.
1095
+ */
1096
+ maxConcurrency: z.ZodDefault<z.ZodNumber>;
1030
1097
  /** Per-subagent budget ceilings; spawn-time overrides are clamped to these. */
1031
1098
  defaultBudget: z.ZodDefault<z.ZodObject<{
1032
1099
  /** Hard cap on the subagent's model turns. */
@@ -1046,6 +1113,7 @@ export declare const CruxyConfigSchema: z.ZodObject<{
1046
1113
  }>>;
1047
1114
  }, "strict", z.ZodTypeAny, {
1048
1115
  maxDepth: number;
1116
+ maxConcurrency: number;
1049
1117
  defaultBudget: {
1050
1118
  maxTokens: number;
1051
1119
  maxIterations: number;
@@ -1053,12 +1121,42 @@ export declare const CruxyConfigSchema: z.ZodObject<{
1053
1121
  };
1054
1122
  }, {
1055
1123
  maxDepth?: number | undefined;
1124
+ maxConcurrency?: number | undefined;
1056
1125
  defaultBudget?: {
1057
1126
  timeoutMs?: number | undefined;
1058
1127
  maxTokens?: number | undefined;
1059
1128
  maxIterations?: number | undefined;
1060
1129
  } | undefined;
1061
1130
  }>>;
1131
+ jobs: z.ZodDefault<z.ZodObject<{
1132
+ /**
1133
+ * Master switch. When true, the `run_in_background` tool is registered so the
1134
+ * agent can dispatch background jobs. OFF by default — background work is an
1135
+ * opt-in capability, and a session that never enables it behaves exactly as
1136
+ * before (no tool, no manager, no queue).
1137
+ */
1138
+ enabled: z.ZodDefault<z.ZodBoolean>;
1139
+ /**
1140
+ * Ceiling on live background jobs (queued + running + paused). Distinct from
1141
+ * the shared execution cap (`subagent.maxConcurrency`): this bounds how many
1142
+ * jobs can be OUTSTANDING, not how many run at once. Default 5.
1143
+ */
1144
+ maxJobs: z.ZodDefault<z.ZodNumber>;
1145
+ /**
1146
+ * How many of a job's most-recent log lines are retained in its ring buffer
1147
+ * for `cruxy logs <id>`. Bounded so a chatty job can't grow memory without
1148
+ * limit; older lines roll off oldest-first. Default 1000.
1149
+ */
1150
+ logBufferLines: z.ZodDefault<z.ZodNumber>;
1151
+ }, "strict", z.ZodTypeAny, {
1152
+ maxJobs: number;
1153
+ enabled: boolean;
1154
+ logBufferLines: number;
1155
+ }, {
1156
+ maxJobs?: number | undefined;
1157
+ enabled?: boolean | undefined;
1158
+ logBufferLines?: number | undefined;
1159
+ }>>;
1062
1160
  test: z.ZodDefault<z.ZodObject<{
1063
1161
  /** Explicit test command (overrides package.json detection). */
1064
1162
  command: z.ZodOptional<z.ZodString>;
@@ -1450,6 +1548,7 @@ export declare const CruxyConfigSchema: z.ZodObject<{
1450
1548
  };
1451
1549
  subagent: {
1452
1550
  maxDepth: number;
1551
+ maxConcurrency: number;
1453
1552
  defaultBudget: {
1454
1553
  maxTokens: number;
1455
1554
  maxIterations: number;
@@ -1509,6 +1608,11 @@ export declare const CruxyConfigSchema: z.ZodObject<{
1509
1608
  idleTimeout: number;
1510
1609
  maxResults: number;
1511
1610
  };
1611
+ jobs: {
1612
+ maxJobs: number;
1613
+ enabled: boolean;
1614
+ logBufferLines: number;
1615
+ };
1512
1616
  test: {
1513
1617
  maxIterations: number;
1514
1618
  captureBytes: number;
@@ -1594,6 +1698,7 @@ export declare const CruxyConfigSchema: z.ZodObject<{
1594
1698
  } | undefined;
1595
1699
  subagent?: {
1596
1700
  maxDepth?: number | undefined;
1701
+ maxConcurrency?: number | undefined;
1597
1702
  defaultBudget?: {
1598
1703
  timeoutMs?: number | undefined;
1599
1704
  maxTokens?: number | undefined;
@@ -1653,6 +1758,11 @@ export declare const CruxyConfigSchema: z.ZodObject<{
1653
1758
  idleTimeout?: number | undefined;
1654
1759
  maxResults?: number | undefined;
1655
1760
  } | undefined;
1761
+ jobs?: {
1762
+ maxJobs?: number | undefined;
1763
+ enabled?: boolean | undefined;
1764
+ logBufferLines?: number | undefined;
1765
+ } | undefined;
1656
1766
  test?: {
1657
1767
  command?: string | undefined;
1658
1768
  maxIterations?: 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({
@@ -195,6 +204,46 @@ export const SubagentConfigSchema = z
195
204
  .default({}),
196
205
  })
197
206
  .strict();
207
+ /**
208
+ * Session-scoped background jobs (C.28): non-interactive orchestration the main
209
+ * agent dispatches with `run_in_background`, running CONCURRENTLY with the
210
+ * foreground session but bound to it — nothing survives session exit (NOT a
211
+ * daemon). A job hitting a gated action enqueues an approval request into the one
212
+ * foreground queue and pauses until a human services it; a paused job releases
213
+ * its execution slot.
214
+ *
215
+ * Two distinct ceilings, stated explicitly because they bound different things:
216
+ * • {@link maxJobs} — how many background JOBS may exist at once (queued +
217
+ * running + paused). A dispatch past it is refused (`CRUXY_E_JOB_LIMIT`).
218
+ * • the shared execution cap is `subagent.maxConcurrency` (default 3) — how many
219
+ * runs (subagents AND jobs, combined) may EXECUTE at once. It is NOT
220
+ * duplicated here: jobs and subagents draw from the one semaphore. So up to
221
+ * `maxJobs` jobs can be alive while only `maxConcurrency` execute; the rest
222
+ * wait for a slot (or are paused on a human).
223
+ */
224
+ export const JobsConfigSchema = z
225
+ .object({
226
+ /**
227
+ * Master switch. When true, the `run_in_background` tool is registered so the
228
+ * agent can dispatch background jobs. OFF by default — background work is an
229
+ * opt-in capability, and a session that never enables it behaves exactly as
230
+ * before (no tool, no manager, no queue).
231
+ */
232
+ enabled: z.boolean().default(false),
233
+ /**
234
+ * Ceiling on live background jobs (queued + running + paused). Distinct from
235
+ * the shared execution cap (`subagent.maxConcurrency`): this bounds how many
236
+ * jobs can be OUTSTANDING, not how many run at once. Default 5.
237
+ */
238
+ maxJobs: z.number().int().positive().default(5),
239
+ /**
240
+ * How many of a job's most-recent log lines are retained in its ring buffer
241
+ * for `cruxy logs <id>`. Bounded so a chatty job can't grow memory without
242
+ * limit; older lines roll off oldest-first. Default 1000.
243
+ */
244
+ logBufferLines: z.number().int().positive().default(1000),
245
+ })
246
+ .strict();
198
247
  /**
199
248
  * Sandbox / container execution (C.16): defense-in-depth beneath the U.3 gate.
200
249
  * When enabled, `run_command` and `run_tests` execute inside an isolated,
@@ -481,6 +530,7 @@ export const CruxyConfigSchema = z
481
530
  lsp: LspConfigSchema.default({}),
482
531
  checkpoint: CheckpointConfigSchema.default({}),
483
532
  subagent: SubagentConfigSchema.default({}),
533
+ jobs: JobsConfigSchema.default({}),
484
534
  test: TestConfigSchema.default({}),
485
535
  sandbox: SandboxConfigSchema.default({}),
486
536
  hooks: HooksConfigSchema.default({}),