@cruxy/cli 0.23.0 → 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/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/service.d.ts +9 -0
- package/dist/checkpoint/service.js +20 -0
- package/dist/cli/commands/run.js +50 -16
- package/dist/cli/onboard.js +2 -2
- package/dist/cli/repl.js +39 -0
- package/dist/cli/session-factory.d.ts +23 -1
- package/dist/cli/session-factory.js +137 -47
- package/dist/config/schema.d.ts +24 -0
- package/dist/config/schema.js +9 -0
- package/dist/errors/constructors.d.ts +23 -0
- package/dist/errors/constructors.js +38 -0
- package/dist/errors/types.d.ts +8 -0
- package/dist/errors/types.js +12 -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/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 +67 -2
- package/dist/subagent/orchestrator.js +203 -18
- 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 +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
|
@@ -3,17 +3,17 @@ import { createProvider } from "@cruxy/sdk";
|
|
|
3
3
|
import { loadProjectInstructions } from "../config/index.js";
|
|
4
4
|
import { logger } from "../utils/logger.js";
|
|
5
5
|
import { getGitInfo } from "../utils/git.js";
|
|
6
|
-
import { ApprovalService, InteractivePolicy, SessionAllowlist, classify, defaultPromptIO, } from "../approval/index.js";
|
|
6
|
+
import { ApprovalMutex, ApprovalService, InteractivePolicy, SessionAllowlist, classify, defaultPromptIO, serializeGate, } from "../approval/index.js";
|
|
7
7
|
import { shouldUseColor } from "../errors/index.js";
|
|
8
8
|
import { buildDefaultRegistry, } from "../tools/index.js";
|
|
9
9
|
import { Session, } from "../agent/index.js";
|
|
10
10
|
import { PlanExecutionPolicy, runPlanSession } from "../plan/index.js";
|
|
11
11
|
import { routerForConfig } from "../routing/index.js";
|
|
12
|
-
import { MemoryService, rememberTool } from "../memory/index.js";
|
|
12
|
+
import { MemoryService, buildMultiRootRecallBlock, rememberTool, } from "../memory/index.js";
|
|
13
13
|
import { findDefinitionTool, findReferencesTool, getDiagnosticsTool, hoverTool, } from "../lsp/index.js";
|
|
14
14
|
import { createWebSearchTool, createWebFetchTool } from "../web/index.js";
|
|
15
15
|
import { appendRun } from "../usage/index.js";
|
|
16
|
-
import { SubagentOrchestrator, makeSpawnSubagentTool, } from "../subagent/index.js";
|
|
16
|
+
import { SubagentOrchestrator, makeSpawnSubagentTool, makeSpawnSubagentsTool, } from "../subagent/index.js";
|
|
17
17
|
/**
|
|
18
18
|
* Wrap a PromptIO so the live region yields before any prompt text lands
|
|
19
19
|
* (U.2/U.4): the prompt writes to stderr while the status line owns the last
|
|
@@ -146,6 +146,59 @@ function attributeFileTargets(action, ws) {
|
|
|
146
146
|
}
|
|
147
147
|
return byRoot;
|
|
148
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
|
+
}
|
|
149
202
|
/**
|
|
150
203
|
* Build a ready-to-run agent {@link Session} from a resolved key — the wiring
|
|
151
204
|
* shared by `cruxy run` and the onboarding first-win task (so they can't drift).
|
|
@@ -191,51 +244,71 @@ export function buildAgentSession(config, apiKey, workspace, ttyInteractive, pla
|
|
|
191
244
|
const execRegistry = buildDefaultRegistry();
|
|
192
245
|
const git = getGitInfo(cwd);
|
|
193
246
|
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
|
|
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.
|
|
199
252
|
let recalledMemory = null;
|
|
200
253
|
if (config.memory.enabled) {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
+
});
|
|
206
295
|
}
|
|
207
|
-
|
|
208
|
-
|
|
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
|
+
}
|
|
209
305
|
}
|
|
210
306
|
}
|
|
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);
|
|
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.
|
|
239
312
|
// One io shared by every prompt in the session (plan approval, the U.3 gate,
|
|
240
313
|
// and any gate inside a subagent), so they all coordinate with the same live
|
|
241
314
|
// region. The full wrapper stack around an ApprovalService is factored here
|
|
@@ -247,7 +320,13 @@ export function buildAgentSession(config, apiKey, workspace, ttyInteractive, pla
|
|
|
247
320
|
// wired, and it is set on the SAME ctx whose `requestApproval` IS that gate — so
|
|
248
321
|
// lifting the non-primary-write refusal and capturing the write are one decision.
|
|
249
322
|
const checkpointsActive = Boolean(checkpoints);
|
|
250
|
-
|
|
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);
|
|
251
330
|
// Subagent orchestration (C.14): spawn_subagent goes on the main registry
|
|
252
331
|
// only when depth allows (maxDepth 0 disables the feature structurally).
|
|
253
332
|
// Registered before the plan wiring so plan-mode execution steps can
|
|
@@ -267,9 +346,20 @@ export function buildAgentSession(config, apiKey, workspace, ttyInteractive, pla
|
|
|
267
346
|
checkpointsActive,
|
|
268
347
|
makeChildApproval: () => gate(new ApprovalService({ cwd, interactive: ttyInteractive, io })),
|
|
269
348
|
});
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
+
});
|
|
273
363
|
if (planMode) {
|
|
274
364
|
// One allowlist shared by the plan-approval prompt and the per-action
|
|
275
365
|
// gate, so a grant recorded during execution is honored by U.3's own check.
|
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({
|
|
@@ -190,6 +190,29 @@ export declare function checkpointSetPartial(runId: string, restored: string[],
|
|
|
190
190
|
* orchestrator seam was driven directly — fail loud, never spawn.
|
|
191
191
|
*/
|
|
192
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;
|
|
193
216
|
/**
|
|
194
217
|
* A subagent run failed outright (provider error, tool crash) before producing
|
|
195
218
|
* a result. Normally folded into the structured `SubagentResult` the parent
|
|
@@ -737,6 +737,44 @@ export function subagentDepthExceeded(depth, maxDepth) {
|
|
|
737
737
|
meta: { depth, maxDepth },
|
|
738
738
|
});
|
|
739
739
|
}
|
|
740
|
+
/**
|
|
741
|
+
* A parallel fan-out (C.33) declared children with overlapping write scope — two
|
|
742
|
+
* or more target the SAME workspace root while both hold mutating tools. The
|
|
743
|
+
* ENTIRE batch is refused BEFORE any child is dispatched, so concurrent siblings
|
|
744
|
+
* can never race on one root's files or checkpoint. Every colliding group is
|
|
745
|
+
* named (the conflicting task pairs, per root) so the model can fix all of them
|
|
746
|
+
* at once. Surfaced as a tool error to correct (assign distinct roots, or drop
|
|
747
|
+
* write tools on all but one child per root).
|
|
748
|
+
*
|
|
749
|
+
* THE HONEST LIMIT, stated in the message: the check is on DECLARED scope — the
|
|
750
|
+
* tools + root each child was granted — NOT the files actually written. Two
|
|
751
|
+
* children could still touch disjoint paths under one root; cruxy refuses anyway
|
|
752
|
+
* rather than let two potential writers race on that root's single checkpoint.
|
|
753
|
+
*/
|
|
754
|
+
export function subagentScopeOverlap(conflicts) {
|
|
755
|
+
const list = conflicts
|
|
756
|
+
.map((c) => `root "${c.root}" ← ${c.tasks.map((t) => `“${t}”`).join(", ")}`)
|
|
757
|
+
.join("; ");
|
|
758
|
+
return new CruxyError({
|
|
759
|
+
code: ErrorCode.SubagentScopeOverlap,
|
|
760
|
+
title: conflicts.length === 1
|
|
761
|
+
? `parallel subagents overlap on root "${conflicts[0].root}"`
|
|
762
|
+
: `parallel subagents overlap on ${conflicts.length} roots`,
|
|
763
|
+
cause: `these children of one spawn_subagents call declared write scope on the ` +
|
|
764
|
+
`same root — ${list}. The check is on DECLARED scope (the tools and root ` +
|
|
765
|
+
`each child was granted), NOT the files actually written — two children ` +
|
|
766
|
+
`could still touch disjoint paths — but cruxy refuses the whole batch ` +
|
|
767
|
+
`rather than let two writers race on one root's checkpoint.`,
|
|
768
|
+
nextSteps: [
|
|
769
|
+
"give each writing child a distinct `root` (parallel writes require disjoint roots)",
|
|
770
|
+
"or grant write/shell tools to only one child per root and keep the rest read-only",
|
|
771
|
+
"or run the conflicting subtasks sequentially with spawn_subagent",
|
|
772
|
+
],
|
|
773
|
+
meta: {
|
|
774
|
+
conflicts: conflicts.map((c) => ({ root: c.root, tasks: [...c.tasks] })),
|
|
775
|
+
},
|
|
776
|
+
});
|
|
777
|
+
}
|
|
740
778
|
/**
|
|
741
779
|
* A subagent run failed outright (provider error, tool crash) before producing
|
|
742
780
|
* a result. Normally folded into the structured `SubagentResult` the parent
|
package/dist/errors/types.d.ts
CHANGED
|
@@ -52,6 +52,14 @@ export declare const ErrorCode: {
|
|
|
52
52
|
/** Carried inside a SubagentResult (informational) — never fatal by itself. */
|
|
53
53
|
readonly SubagentBudget: "CRUXY_E_SUBAGENT_BUDGET";
|
|
54
54
|
readonly SubagentFailed: "CRUXY_E_SUBAGENT_FAILED";
|
|
55
|
+
/** Two children of one parallel fan-out (C.33) declare overlapping write scope
|
|
56
|
+
* (same root). Refused pre-dispatch so concurrent siblings can never race on a
|
|
57
|
+
* root — the model corrects it (distinct roots, or read-only). */
|
|
58
|
+
readonly SubagentScopeOverlap: "CRUXY_E_SUBAGENT_SCOPE_OVERLAP";
|
|
59
|
+
/** Carried inside a SubagentResult (informational): the run was cancelled — a
|
|
60
|
+
* fatal sibling failure or Ctrl-C aborted the whole fan-out (C.33). Never a
|
|
61
|
+
* fabricated success; the parent sees exactly which children did not finish. */
|
|
62
|
+
readonly SubagentCancelled: "CRUXY_E_SUBAGENT_CANCELLED";
|
|
55
63
|
readonly TestCommandNotFound: "CRUXY_E_TEST_COMMAND_NOT_FOUND";
|
|
56
64
|
/** Carried inside a run_tests result (informational) — never fatal by itself. */
|
|
57
65
|
readonly TestIterationLimit: "CRUXY_E_TEST_ITERATION_LIMIT";
|
package/dist/errors/types.js
CHANGED
|
@@ -63,6 +63,14 @@ export const ErrorCode = {
|
|
|
63
63
|
/** Carried inside a SubagentResult (informational) — never fatal by itself. */
|
|
64
64
|
SubagentBudget: "CRUXY_E_SUBAGENT_BUDGET",
|
|
65
65
|
SubagentFailed: "CRUXY_E_SUBAGENT_FAILED",
|
|
66
|
+
/** Two children of one parallel fan-out (C.33) declare overlapping write scope
|
|
67
|
+
* (same root). Refused pre-dispatch so concurrent siblings can never race on a
|
|
68
|
+
* root — the model corrects it (distinct roots, or read-only). */
|
|
69
|
+
SubagentScopeOverlap: "CRUXY_E_SUBAGENT_SCOPE_OVERLAP",
|
|
70
|
+
/** Carried inside a SubagentResult (informational): the run was cancelled — a
|
|
71
|
+
* fatal sibling failure or Ctrl-C aborted the whole fan-out (C.33). Never a
|
|
72
|
+
* fabricated success; the parent sees exactly which children did not finish. */
|
|
73
|
+
SubagentCancelled: "CRUXY_E_SUBAGENT_CANCELLED",
|
|
66
74
|
// testing (exit 2 / 11)
|
|
67
75
|
TestCommandNotFound: "CRUXY_E_TEST_COMMAND_NOT_FOUND",
|
|
68
76
|
/** Carried inside a run_tests result (informational) — never fatal by itself. */
|
|
@@ -230,6 +238,10 @@ const EXIT_CODES = {
|
|
|
230
238
|
[ErrorCode.SubagentDepthExceeded]: 2,
|
|
231
239
|
[ErrorCode.SubagentBudget]: 11,
|
|
232
240
|
[ErrorCode.SubagentFailed]: 11,
|
|
241
|
+
// Overlapping fan-out scope is a spawn misuse (usage, model-correctable);
|
|
242
|
+
// cancellation surfaces inside a SubagentResult and is never fatal by itself.
|
|
243
|
+
[ErrorCode.SubagentScopeOverlap]: 2,
|
|
244
|
+
[ErrorCode.SubagentCancelled]: 11,
|
|
233
245
|
// No test command is a configuration gap (usage); the iteration limit
|
|
234
246
|
// surfaces inside a run_tests result and is never fatal by itself.
|
|
235
247
|
[ErrorCode.TestCommandNotFound]: 2,
|
package/dist/hooks/index.d.ts
CHANGED
|
@@ -4,3 +4,4 @@ export { fileTrustStore, fingerprintHooks, isTrusted, memoryTrustStore, trustPat
|
|
|
4
4
|
export { HookRunner, type HookRunnerDeps, type TrustPromptInfo, } from "./runner.js";
|
|
5
5
|
export { BUILTIN_SLASH_COMMANDS, expandTemplate, isBuiltinSlash, resolveSlash, type SlashResolution, } from "./slash.js";
|
|
6
6
|
export { buildHooksService, type BuildHooksServiceOptions, type HooksService, } from "./service.js";
|
|
7
|
+
export { buildHooksRouter, type BuildHooksRouterOptions, type HooksRouterResult, type UntrustedHookRoot, } from "./router.js";
|
package/dist/hooks/index.js
CHANGED
|
@@ -4,3 +4,4 @@ export { fileTrustStore, fingerprintHooks, isTrusted, memoryTrustStore, trustPat
|
|
|
4
4
|
export { HookRunner, } from "./runner.js";
|
|
5
5
|
export { BUILTIN_SLASH_COMMANDS, expandTemplate, isBuiltinSlash, resolveSlash, } from "./slash.js";
|
|
6
6
|
export { buildHooksService, } from "./service.js";
|
|
7
|
+
export { buildHooksRouter, } from "./router.js";
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { CruxyConfig } from "../config/index.js";
|
|
2
|
+
import type { LifecycleHookRunner } from "../agent/loop.js";
|
|
3
|
+
import type { logger as Logger } from "../utils/logger.js";
|
|
4
|
+
import type { DeclaredRoot, Workspace } from "../workspace/index.js";
|
|
5
|
+
import { type TrustStore } from "./trust.js";
|
|
6
|
+
import type { HookCatalog, SlashCommandSpec } from "./types.js";
|
|
7
|
+
/**
|
|
8
|
+
* Per-root hook dispatch for a multi-root session (C.26 step 5).
|
|
9
|
+
*
|
|
10
|
+
* The isolation here is a PROPERTY OF CONSTRUCTION, not a guard: each root gets
|
|
11
|
+
* its OWN {@link HookRunner} (own project-hook catalog, own trust key), so there
|
|
12
|
+
* is no code path from root A's runner to a root-B action. The router only
|
|
13
|
+
* decides WHICH runner to invoke:
|
|
14
|
+
*
|
|
15
|
+
* - Tool-scoped events (`before-tool` / `after-tool` / `on-file-change`) fire
|
|
16
|
+
* exactly the ONE acting root's runner — the same root the tool resolved via
|
|
17
|
+
* {@link selectRoot}. A's project hooks structurally cannot fire for a
|
|
18
|
+
* B-scoped call (⚖︎10, pinned by test).
|
|
19
|
+
* - Session lifecycle events (`before-run` / `after-run`) have NO acting root,
|
|
20
|
+
* so they fan EVERY trusted root sequentially, in declaration order,
|
|
21
|
+
* root-labeled; a blocking failure in any root aborts the run naming that
|
|
22
|
+
* root (JC-1). Untrusted roots are skipped and named in the startup banner —
|
|
23
|
+
* never silent (a user's pre-run guard in an untrusted root must not vanish
|
|
24
|
+
* without a word).
|
|
25
|
+
*
|
|
26
|
+
* User-scope hooks (`~/.cruxy`) are session-global — authored by you, shared by
|
|
27
|
+
* every root — so they fire EXACTLY ONCE per event through a single shared
|
|
28
|
+
* runner, never once per root.
|
|
29
|
+
*/
|
|
30
|
+
/** A root whose project hooks exist but are not trusted → named in the banner. */
|
|
31
|
+
export interface UntrustedHookRoot {
|
|
32
|
+
name: string;
|
|
33
|
+
absPath: string;
|
|
34
|
+
count: number;
|
|
35
|
+
}
|
|
36
|
+
export interface HooksRouterResult {
|
|
37
|
+
/** The lifecycle firing seam (a {@link LifecycleHookRunner}). */
|
|
38
|
+
runner: LifecycleHookRunner;
|
|
39
|
+
/** Custom slash commands (primary root only this release). */
|
|
40
|
+
commands: SlashCommandSpec[];
|
|
41
|
+
/** Roots with untrusted project hooks — named in the banner, never silent. */
|
|
42
|
+
untrustedHookRoots: UntrustedHookRoot[];
|
|
43
|
+
}
|
|
44
|
+
export interface BuildHooksRouterOptions {
|
|
45
|
+
workspace: Workspace;
|
|
46
|
+
config: CruxyConfig;
|
|
47
|
+
logger: typeof Logger;
|
|
48
|
+
trust?: TrustStore;
|
|
49
|
+
/** Per-root catalog loader; defaults to the layered on-disk loader. */
|
|
50
|
+
loadCatalog?: (root: DeclaredRoot) => Promise<HookCatalog>;
|
|
51
|
+
now?: () => string;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Build the multi-root hook router. One project-hook runner per declared root
|
|
55
|
+
* (trust-gated against that root's key) plus one shared user-hook runner. Load
|
|
56
|
+
* errors are surfaced through the logger exactly as the single-root path does.
|
|
57
|
+
*/
|
|
58
|
+
export declare function buildHooksRouter(opts: BuildHooksRouterOptions): Promise<HooksRouterResult>;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { selectRoot } from "../workspace/index.js";
|
|
2
|
+
import { defaultHookSources, loadHookCatalog } from "./config.js";
|
|
3
|
+
import { HookRunner } from "./runner.js";
|
|
4
|
+
import { fileTrustStore } from "./trust.js";
|
|
5
|
+
const LIFECYCLE_EVENTS = new Set(["before-run", "after-run"]);
|
|
6
|
+
/**
|
|
7
|
+
* Resolve the ONE acting root for a tool-scoped event from the raw tool-call
|
|
8
|
+
* input, reusing the exact {@link selectRoot} precedence the tool itself uses so
|
|
9
|
+
* the hook's root always matches the tool's root. Bare/ambiguous/unknown → the
|
|
10
|
+
* primary (a hook never crosses into a root the call didn't address).
|
|
11
|
+
*/
|
|
12
|
+
function resolveActingRoot(ws, input) {
|
|
13
|
+
if (input && typeof input === "object") {
|
|
14
|
+
const rec = input;
|
|
15
|
+
const root = typeof rec.root === "string" ? rec.root : undefined;
|
|
16
|
+
const p = typeof rec.path === "string" ? rec.path : undefined;
|
|
17
|
+
if (root !== undefined || p !== undefined) {
|
|
18
|
+
try {
|
|
19
|
+
return selectRoot(ws, { root, path: p ?? "." }).root;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// Unknown root name / not-contained absolute path → fall to primary.
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return ws.primary();
|
|
27
|
+
}
|
|
28
|
+
class HookRouter {
|
|
29
|
+
deps;
|
|
30
|
+
constructor(deps) {
|
|
31
|
+
this.deps = deps;
|
|
32
|
+
}
|
|
33
|
+
async fire(event, ctx, hint) {
|
|
34
|
+
if (!this.deps.enabled)
|
|
35
|
+
return;
|
|
36
|
+
if (LIFECYCLE_EVENTS.has(event)) {
|
|
37
|
+
// Session lifecycle: user hooks once, then every TRUSTED root in order.
|
|
38
|
+
await this.deps.userRunner.fire(event, ctx);
|
|
39
|
+
for (const root of this.deps.workspace.roots()) {
|
|
40
|
+
const runner = this.deps.perRoot.get(root.absPath);
|
|
41
|
+
if (!runner || !runner.projectTrusted)
|
|
42
|
+
continue; // skipped; banner-named
|
|
43
|
+
// A blocking failure throws `hookFailed("<root> ▸ <hook>", …)` — the run
|
|
44
|
+
// aborts naming which root refused (the runner carries `label`).
|
|
45
|
+
await runner.fire(event, rootCtx(ctx, root));
|
|
46
|
+
}
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
// Tool-scoped: exactly the ONE acting root (+ the shared user hooks).
|
|
50
|
+
const acting = resolveActingRoot(this.deps.workspace, hint?.input);
|
|
51
|
+
const actingCtx = rootCtx(ctx, acting);
|
|
52
|
+
await this.deps.userRunner.fire(event, actingCtx);
|
|
53
|
+
const runner = this.deps.perRoot.get(acting.absPath);
|
|
54
|
+
if (runner && runner.projectTrusted) {
|
|
55
|
+
await runner.fire(event, actingCtx);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** A per-root view of the tool context: the hook command runs in THAT root's
|
|
60
|
+
* directory (`runGatedShell` uses `ctx.cwd`), so a root's hook always executes
|
|
61
|
+
* against its own tree — never the primary's. */
|
|
62
|
+
function rootCtx(ctx, root) {
|
|
63
|
+
return { ...ctx, cwd: root.absPath };
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Build the multi-root hook router. One project-hook runner per declared root
|
|
67
|
+
* (trust-gated against that root's key) plus one shared user-hook runner. Load
|
|
68
|
+
* errors are surfaced through the logger exactly as the single-root path does.
|
|
69
|
+
*/
|
|
70
|
+
export async function buildHooksRouter(opts) {
|
|
71
|
+
const trust = opts.trust ?? fileTrustStore();
|
|
72
|
+
const enabled = opts.config.hooks.enabled;
|
|
73
|
+
const perRoot = new Map();
|
|
74
|
+
const untrustedHookRoots = [];
|
|
75
|
+
let userHooks = [];
|
|
76
|
+
let primaryCommands = [];
|
|
77
|
+
const loadCatalog = opts.loadCatalog ??
|
|
78
|
+
((root) => loadHookCatalog(defaultHookSources(root.absPath)));
|
|
79
|
+
for (const root of opts.workspace.roots()) {
|
|
80
|
+
const catalog = await loadCatalog(root);
|
|
81
|
+
for (const err of catalog.errors) {
|
|
82
|
+
opts.logger.warn(`ignoring malformed ${err.source} hook/command "${err.name}" in ${root.name}: ${err.message}`);
|
|
83
|
+
}
|
|
84
|
+
// Each root's runner owns ONLY its project hooks. User hooks are global —
|
|
85
|
+
// loaded once (from the primary, whose `sources.user` is identical for every
|
|
86
|
+
// root) and fired through the shared runner, never per-root.
|
|
87
|
+
const projectHooks = catalog.hooks.filter((h) => h.source === "project");
|
|
88
|
+
if (root.primary) {
|
|
89
|
+
userHooks = catalog.hooks.filter((h) => h.source === "user");
|
|
90
|
+
primaryCommands = catalog.commands;
|
|
91
|
+
}
|
|
92
|
+
const runner = new HookRunner({
|
|
93
|
+
hooks: projectHooks,
|
|
94
|
+
trust,
|
|
95
|
+
enabled,
|
|
96
|
+
// Multi-root never lazy-prompts (a barrage of per-sibling prompts mid-run
|
|
97
|
+
// is worse than a named skip). Untrusted roots are skipped + banner-named;
|
|
98
|
+
// `cruxy hooks trust <path>` is the deliberate opt-in.
|
|
99
|
+
trustPrompt: false,
|
|
100
|
+
interactive: false,
|
|
101
|
+
cwd: root.absPath,
|
|
102
|
+
label: root.name,
|
|
103
|
+
announce: (m) => opts.logger.info(`[${root.name}] ${m}`),
|
|
104
|
+
reportFailure: (m) => opts.logger.warn(`[${root.name}] ${m}`),
|
|
105
|
+
now: opts.now,
|
|
106
|
+
});
|
|
107
|
+
perRoot.set(root.absPath, runner);
|
|
108
|
+
if (!runner.projectTrusted) {
|
|
109
|
+
untrustedHookRoots.push({
|
|
110
|
+
name: root.name,
|
|
111
|
+
absPath: root.absPath,
|
|
112
|
+
count: runner.projectHookCount,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// Shared user-hook runner: user hooks carry no project source, so its trust
|
|
117
|
+
// gate never triggers (you authored them) — it just fires them once.
|
|
118
|
+
const userRunner = new HookRunner({
|
|
119
|
+
hooks: userHooks,
|
|
120
|
+
trust,
|
|
121
|
+
enabled,
|
|
122
|
+
trustPrompt: false,
|
|
123
|
+
interactive: false,
|
|
124
|
+
cwd: opts.workspace.primary().absPath,
|
|
125
|
+
announce: (m) => opts.logger.info(m),
|
|
126
|
+
reportFailure: (m) => opts.logger.warn(m),
|
|
127
|
+
now: opts.now,
|
|
128
|
+
});
|
|
129
|
+
const runner = new HookRouter({
|
|
130
|
+
workspace: opts.workspace,
|
|
131
|
+
perRoot,
|
|
132
|
+
userRunner,
|
|
133
|
+
enabled,
|
|
134
|
+
});
|
|
135
|
+
return { runner, commands: primaryCommands, untrustedHookRoots };
|
|
136
|
+
}
|