@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.
- package/dist/agent/loop.d.ts +21 -2
- package/dist/agent/loop.js +21 -5
- package/dist/approval/classify.js +18 -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/approval/prompt.js +11 -0
- package/dist/checkpoint/gate.d.ts +65 -0
- package/dist/checkpoint/gate.js +86 -0
- package/dist/checkpoint/index.d.ts +2 -0
- package/dist/checkpoint/index.js +2 -0
- package/dist/checkpoint/service.d.ts +9 -0
- package/dist/checkpoint/service.js +20 -0
- package/dist/checkpoint/set-rollback.d.ts +51 -0
- package/dist/checkpoint/set-rollback.js +74 -0
- package/dist/cli/commands/rollback.d.ts +11 -6
- package/dist/cli/commands/rollback.js +93 -33
- package/dist/cli/commands/run.js +104 -21
- package/dist/cli/onboard.js +4 -1
- package/dist/cli/repl.d.ts +2 -2
- package/dist/cli/repl.js +39 -0
- package/dist/cli/session-factory.d.ts +27 -4
- package/dist/cli/session-factory.js +234 -58
- package/dist/config/schema.d.ts +24 -0
- package/dist/config/schema.js +9 -0
- package/dist/errors/constructors.d.ts +51 -0
- package/dist/errors/constructors.js +97 -0
- package/dist/errors/types.d.ts +28 -0
- package/dist/errors/types.js +38 -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/indexing/retriever.d.ts +29 -0
- package/dist/indexing/retriever.js +26 -0
- package/dist/indexing/service.js +3 -1
- package/dist/indexing/types.d.ts +7 -0
- package/dist/lsp/tools/common.d.ts +34 -7
- package/dist/lsp/tools/common.js +33 -11
- package/dist/lsp/tools/find-definition.js +2 -2
- package/dist/lsp/tools/find-references.js +10 -4
- package/dist/lsp/tools/get-diagnostics.js +6 -4
- 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/render/diff.js +42 -5
- 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 +81 -1
- package/dist/subagent/orchestrator.js +204 -17
- package/dist/subagent/registry-scope.d.ts +13 -0
- package/dist/subagent/registry-scope.js +28 -2
- package/dist/subagent/semaphore.d.ts +27 -0
- package/dist/subagent/semaphore.js +56 -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 +3 -0
- package/dist/tools/create-pull-request.d.ts +3 -0
- package/dist/tools/create-pull-request.js +50 -4
- package/dist/tools/file/apply-patch.js +2 -2
- package/dist/tools/file/edit-file.js +2 -2
- package/dist/tools/file/glob.d.ts +9 -2
- package/dist/tools/file/glob.js +73 -19
- package/dist/tools/file/grep-files.d.ts +12 -2
- package/dist/tools/file/grep-files.js +113 -38
- package/dist/tools/file/paths.d.ts +121 -9
- package/dist/tools/file/paths.js +164 -10
- package/dist/tools/file/read-file.js +2 -2
- package/dist/tools/file/write-file.js +2 -2
- package/dist/tools/git-status.d.ts +8 -1
- package/dist/tools/git-status.js +43 -11
- package/dist/tools/list-files.d.ts +9 -3
- package/dist/tools/list-files.js +48 -13
- package/dist/tools/search-codebase.d.ts +10 -0
- package/dist/tools/search-codebase.js +117 -14
- package/dist/tools/shell/exec.js +43 -4
- package/dist/tools/types.d.ts +74 -1
- package/dist/vcs/git.d.ts +8 -0
- package/dist/vcs/git.js +14 -0
- package/dist/vcs/github.d.ts +7 -1
- package/dist/vcs/github.js +10 -1
- package/dist/vcs/service.d.ts +8 -0
- package/dist/vcs/service.js +33 -1
- package/dist/vcs/types.d.ts +18 -2
- 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,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,
|
|
65
|
-
if (!
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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,
|
|
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):
|
|
125
|
-
//
|
|
126
|
-
// recalled only when trusted (a
|
|
127
|
-
// Fully inert when disabled. Recall
|
|
128
|
-
// "no memory", never a hard failure
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
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 = {
|
|
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,
|
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,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;
|
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({
|
|
@@ -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
|