@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.
- package/dist/agent/loop.d.ts +21 -2
- package/dist/agent/loop.js +21 -5
- package/dist/agent/session.d.ts +13 -0
- package/dist/agent/session.js +6 -0
- package/dist/approval/index.d.ts +1 -0
- package/dist/approval/index.js +1 -0
- package/dist/approval/mutex.d.ts +45 -0
- package/dist/approval/mutex.js +57 -0
- package/dist/checkpoint/gate-hook.d.ts +28 -0
- package/dist/checkpoint/gate-hook.js +98 -0
- package/dist/checkpoint/gate.d.ts +7 -1
- package/dist/checkpoint/gate.js +8 -2
- package/dist/checkpoint/index.d.ts +1 -0
- package/dist/checkpoint/index.js +1 -0
- package/dist/checkpoint/service.d.ts +9 -0
- package/dist/checkpoint/service.js +20 -0
- package/dist/cli/commands/rollback.d.ts +4 -1
- package/dist/cli/commands/rollback.js +16 -9
- package/dist/cli/commands/run.js +62 -16
- package/dist/cli/onboard.js +2 -2
- package/dist/cli/repl.d.ts +1 -1
- package/dist/cli/repl.js +145 -0
- package/dist/cli/session-factory.d.ts +24 -10
- package/dist/cli/session-factory.js +179 -135
- package/dist/config/schema.d.ts +110 -0
- package/dist/config/schema.js +50 -0
- package/dist/errors/constructors.d.ts +41 -0
- package/dist/errors/constructors.js +87 -0
- package/dist/errors/types.d.ts +21 -0
- package/dist/errors/types.js +33 -0
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/router.d.ts +58 -0
- package/dist/hooks/router.js +136 -0
- package/dist/hooks/runner.d.ts +12 -0
- package/dist/hooks/runner.js +23 -1
- package/dist/jobs/approval-queue.d.ts +85 -0
- package/dist/jobs/approval-queue.js +96 -0
- package/dist/jobs/dispatch-tool.d.ts +34 -0
- package/dist/jobs/dispatch-tool.js +96 -0
- package/dist/jobs/index.d.ts +6 -0
- package/dist/jobs/index.js +6 -0
- package/dist/jobs/log-buffer.d.ts +31 -0
- package/dist/jobs/log-buffer.js +30 -0
- package/dist/jobs/log-renderer.d.ts +32 -0
- package/dist/jobs/log-renderer.js +70 -0
- package/dist/jobs/manager.d.ts +139 -0
- package/dist/jobs/manager.js +397 -0
- package/dist/jobs/types.d.ts +81 -0
- package/dist/jobs/types.js +10 -0
- package/dist/mcp/index.d.ts +1 -0
- package/dist/mcp/index.js +1 -0
- package/dist/mcp/sibling-banner.d.ts +25 -0
- package/dist/mcp/sibling-banner.js +34 -0
- package/dist/memory/recall.d.ts +24 -0
- package/dist/memory/recall.js +54 -0
- package/dist/memory/remember-tool.d.ts +3 -0
- package/dist/memory/remember-tool.js +11 -1
- package/dist/sandbox/policy.js +14 -5
- package/dist/sandbox/service.d.ts +8 -1
- package/dist/sandbox/service.js +4 -1
- package/dist/subagent/index.d.ts +1 -0
- package/dist/subagent/index.js +1 -0
- package/dist/subagent/orchestrator.d.ts +76 -2
- package/dist/subagent/orchestrator.js +208 -18
- package/dist/subagent/registry-scope.d.ts +13 -0
- package/dist/subagent/registry-scope.js +28 -2
- package/dist/subagent/semaphore.d.ts +56 -0
- package/dist/subagent/semaphore.js +53 -0
- package/dist/subagent/spawn-tool.d.ts +57 -0
- package/dist/subagent/spawn-tool.js +104 -9
- package/dist/subagent/types.d.ts +17 -2
- package/dist/testing/run-tests-tool.js +1 -1
- package/dist/tools/file/paths.d.ts +5 -6
- package/dist/tools/file/paths.js +7 -8
- package/dist/tools/shell/exec.js +36 -4
- package/dist/tools/types.d.ts +16 -5
- package/dist/workspace/add-root.d.ts +27 -0
- package/dist/workspace/add-root.js +16 -0
- package/dist/workspace/index.d.ts +2 -1
- package/dist/workspace/index.js +2 -1
- package/dist/workspace/workspace.d.ts +9 -4
- package/dist/workspace/workspace.js +9 -4
- 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,
|
|
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
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
127
|
-
|
|
128
|
-
if (
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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):
|
|
195
|
-
//
|
|
196
|
-
// recalled only when trusted (a
|
|
197
|
-
// Fully inert when disabled. Recall
|
|
198
|
-
// "no memory", never a hard failure
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
208
|
-
|
|
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
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
271
|
-
|
|
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
|
}
|
package/dist/config/schema.d.ts
CHANGED
|
@@ -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;
|
package/dist/config/schema.js
CHANGED
|
@@ -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({}),
|