@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
package/dist/hooks/runner.d.ts
CHANGED
|
@@ -36,6 +36,10 @@ export interface HookRunnerDeps {
|
|
|
36
36
|
interactive: boolean;
|
|
37
37
|
/** Project root — the trust key and fingerprint scope. */
|
|
38
38
|
cwd: string;
|
|
39
|
+
/** Root label for multi-root fan-out (C.26 step 5). When set, a blocking
|
|
40
|
+
* failure names its root (`<label> ▸ <hook>`) so an aborted run says which
|
|
41
|
+
* root refused. Undefined in the single-root path → messages unchanged. */
|
|
42
|
+
label?: string;
|
|
39
43
|
/** Interactive trust prompt (returns true to trust). Required only when a
|
|
40
44
|
* project defines hooks and `trustPrompt` + `interactive` are both on. */
|
|
41
45
|
promptTrust?: (info: TrustPromptInfo) => Promise<boolean>;
|
|
@@ -51,6 +55,14 @@ export declare class HookRunner {
|
|
|
51
55
|
constructor(deps: HookRunnerDeps);
|
|
52
56
|
/** The project hooks — the trust-gated subset. */
|
|
53
57
|
private get projectHooks();
|
|
58
|
+
/** Prefix a hook name with the root label (multi-root), else leave it. */
|
|
59
|
+
private qualify;
|
|
60
|
+
/** Whether this root's project hooks are trusted for their current
|
|
61
|
+
* fingerprint. Read-only (never prompts/records) — the router uses it to skip
|
|
62
|
+
* untrusted roots and to name them in the banner. */
|
|
63
|
+
get projectTrusted(): boolean;
|
|
64
|
+
/** Count of project (trust-gated) hooks — for the untrusted-root banner. */
|
|
65
|
+
get projectHookCount(): number;
|
|
54
66
|
/**
|
|
55
67
|
* Fire every hook registered for `event`, in catalog order. Resolves normally
|
|
56
68
|
* when all hooks pass (or advisory ones fail); THROWS `CRUXY_E_HOOK_FAILED`
|
package/dist/hooks/runner.js
CHANGED
|
@@ -10,12 +10,33 @@ export class HookRunner {
|
|
|
10
10
|
get projectHooks() {
|
|
11
11
|
return this.deps.hooks.filter((h) => h.source === "project");
|
|
12
12
|
}
|
|
13
|
+
/** Prefix a hook name with the root label (multi-root), else leave it. */
|
|
14
|
+
qualify(name) {
|
|
15
|
+
return this.deps.label ? `${this.deps.label} ▸ ${name}` : name;
|
|
16
|
+
}
|
|
17
|
+
/** Whether this root's project hooks are trusted for their current
|
|
18
|
+
* fingerprint. Read-only (never prompts/records) — the router uses it to skip
|
|
19
|
+
* untrusted roots and to name them in the banner. */
|
|
20
|
+
get projectTrusted() {
|
|
21
|
+
const projectHooks = this.projectHooks;
|
|
22
|
+
if (projectHooks.length === 0)
|
|
23
|
+
return true;
|
|
24
|
+
return isTrusted(this.deps.trust, this.deps.cwd, fingerprintHooks(projectHooks));
|
|
25
|
+
}
|
|
26
|
+
/** Count of project (trust-gated) hooks — for the untrusted-root banner. */
|
|
27
|
+
get projectHookCount() {
|
|
28
|
+
return this.projectHooks.length;
|
|
29
|
+
}
|
|
13
30
|
/**
|
|
14
31
|
* Fire every hook registered for `event`, in catalog order. Resolves normally
|
|
15
32
|
* when all hooks pass (or advisory ones fail); THROWS `CRUXY_E_HOOK_FAILED`
|
|
16
33
|
* when a blocking hook fails, or `CRUXY_E_HOOK_UNTRUSTED` when a project's
|
|
17
34
|
* hooks are not trusted. A no-op when hooks are disabled or none match.
|
|
18
35
|
*/
|
|
36
|
+
// The LifecycleHookRunner seam passes an optional acting-root hint as a 3rd
|
|
37
|
+
// arg; the single-root runner owns exactly one root and ignores it (a method
|
|
38
|
+
// with fewer params is still assignable to the interface), so single-root
|
|
39
|
+
// firing stays byte-identical.
|
|
19
40
|
async fire(event, ctx) {
|
|
20
41
|
if (!this.deps.enabled)
|
|
21
42
|
return;
|
|
@@ -33,7 +54,8 @@ export class HookRunner {
|
|
|
33
54
|
continue;
|
|
34
55
|
if (hook.blocking) {
|
|
35
56
|
// Fail-closed: stop, do not run the remaining hooks, abort the action.
|
|
36
|
-
|
|
57
|
+
// `label` (multi-root) names the refusing root in the aborted-run error.
|
|
58
|
+
throw hookFailed(this.qualify(hook.name), verdict.reason ?? "hook failed");
|
|
37
59
|
}
|
|
38
60
|
// Advisory: the action already happened (or proceeds) — report, continue.
|
|
39
61
|
this.deps.reportFailure?.(`hook "${hook.name}" failed (advisory): ${verdict.reason ?? "unknown"}`);
|
package/dist/mcp/index.d.ts
CHANGED
|
@@ -6,4 +6,5 @@ export { boundToolList, type McpBounds, type BoundedTool, type BoundedToolList,
|
|
|
6
6
|
export { McpStdioTransport, type McpSpawnSpec } from "./transport.js";
|
|
7
7
|
export { McpClient, type McpClientTimeouts } from "./client.js";
|
|
8
8
|
export { mcpToolsFrom, type McpToolSource } from "./adapter.js";
|
|
9
|
+
export { deferredSiblingServers, type DeferredSiblingServer, } from "./sibling-banner.js";
|
|
9
10
|
export { connectMcpTools, resetMcpServices, liveMcpConnectionCount, type ConnectMcpToolsParams, type ConnectMcpToolsResult, type McpServiceDeps, } from "./service.js";
|
package/dist/mcp/index.js
CHANGED
|
@@ -5,4 +5,5 @@ export { boundToolList, } from "./bounds.js";
|
|
|
5
5
|
export { McpStdioTransport } from "./transport.js";
|
|
6
6
|
export { McpClient } from "./client.js";
|
|
7
7
|
export { mcpToolsFrom } from "./adapter.js";
|
|
8
|
+
export { deferredSiblingServers, } from "./sibling-banner.js";
|
|
8
9
|
export { connectMcpTools, resetMcpServices, liveMcpConnectionCount, } from "./service.js";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Workspace } from "../workspace/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* MCP is PRIMARY-ROOT ONLY this release (JC-D). Two roots each declaring a
|
|
4
|
+
* `github` server would collide on the wire-name `mcp__github__<tool>` (the
|
|
5
|
+
* adapter derives it from the server id with no root component), so sibling-root
|
|
6
|
+
* servers are NOT registered. They are also NOT silently dropped: each one is
|
|
7
|
+
* named individually in the startup banner (server + which root declared it), so
|
|
8
|
+
* the deferral is explicit. Root-qualified wire-names are a separate follow-up.
|
|
9
|
+
*/
|
|
10
|
+
export interface DeferredSiblingServer {
|
|
11
|
+
/** The declared server id (the would-be `mcp__<server>__*` prefix). */
|
|
12
|
+
server: string;
|
|
13
|
+
/** The declaring non-primary root's name. */
|
|
14
|
+
root: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Enumerate the MCP servers declared by each NON-primary root's own project
|
|
18
|
+
* config — the set deferred this release. Pure over an injectable reader so the
|
|
19
|
+
* naming is unit-testable without touching disk. Reads each sibling's OWN
|
|
20
|
+
* declaration (not the merged/global set), so a sibling's `github` is named even
|
|
21
|
+
* when the primary also declares one (the exact collision we're deferring).
|
|
22
|
+
*/
|
|
23
|
+
export declare function deferredSiblingServers(workspace: Workspace, opts?: {
|
|
24
|
+
declaredServersFor?: (absPath: string) => string[];
|
|
25
|
+
}): DeferredSiblingServer[];
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { findProjectConfig } from "../config/paths.js";
|
|
3
|
+
/**
|
|
4
|
+
* Enumerate the MCP servers declared by each NON-primary root's own project
|
|
5
|
+
* config — the set deferred this release. Pure over an injectable reader so the
|
|
6
|
+
* naming is unit-testable without touching disk. Reads each sibling's OWN
|
|
7
|
+
* declaration (not the merged/global set), so a sibling's `github` is named even
|
|
8
|
+
* when the primary also declares one (the exact collision we're deferring).
|
|
9
|
+
*/
|
|
10
|
+
export function deferredSiblingServers(workspace, opts = {}) {
|
|
11
|
+
const declaredServersFor = opts.declaredServersFor ?? readProjectServers;
|
|
12
|
+
const out = [];
|
|
13
|
+
for (const root of workspace.roots()) {
|
|
14
|
+
if (root.primary)
|
|
15
|
+
continue;
|
|
16
|
+
for (const server of declaredServersFor(root.absPath)) {
|
|
17
|
+
out.push({ server, root: root.name });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
/** Read `mcp.servers` keys from a root's own project config file. */
|
|
23
|
+
function readProjectServers(absPath) {
|
|
24
|
+
const p = findProjectConfig(absPath);
|
|
25
|
+
if (!p || !existsSync(p))
|
|
26
|
+
return [];
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(readFileSync(p, "utf8"));
|
|
29
|
+
return Object.keys(parsed.mcp?.servers ?? {});
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
}
|
package/dist/memory/recall.d.ts
CHANGED
|
@@ -30,3 +30,27 @@ export interface RecallInput {
|
|
|
30
30
|
* truncated. Only entries actually passed in are rendered — nothing is invented.
|
|
31
31
|
*/
|
|
32
32
|
export declare function buildRecallBlock(input: RecallInput): string | null;
|
|
33
|
+
/** One trusted root's project entries, tagged with the root's declared name.
|
|
34
|
+
* The name is STRUCTURAL — the caller pairs it with the store the entries were
|
|
35
|
+
* loaded from at the same site, so a rendered block can never carry a root the
|
|
36
|
+
* entries didn't come from. */
|
|
37
|
+
export interface RootRecall {
|
|
38
|
+
name: string;
|
|
39
|
+
entries: readonly MemoryEntry[];
|
|
40
|
+
}
|
|
41
|
+
export interface MultiRootRecallInput {
|
|
42
|
+
/** Global user entries — recalled ONCE, never per root. */
|
|
43
|
+
user: readonly MemoryEntry[];
|
|
44
|
+
/** TRUSTED roots only, each with its own project entries (untrusted roots are
|
|
45
|
+
* filtered out by the caller and named separately). */
|
|
46
|
+
roots: readonly RootRecall[];
|
|
47
|
+
maxTokens: number;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Multi-root recall (C.26 step 5): one demarcated block with the shared user
|
|
51
|
+
* memory once, then each TRUSTED root's project memory under its own
|
|
52
|
+
* root-labeled sub-heading. Same framing, same budget, same omission honesty as
|
|
53
|
+
* {@link buildRecallBlock}; only the project scope is split by origin so a note
|
|
54
|
+
* from root A can never render unlabeled or attributed to root B.
|
|
55
|
+
*/
|
|
56
|
+
export declare function buildMultiRootRecallBlock(input: MultiRootRecallInput): string | null;
|
package/dist/memory/recall.js
CHANGED
|
@@ -71,3 +71,57 @@ export function buildRecallBlock(input) {
|
|
|
71
71
|
}
|
|
72
72
|
return parts.join("\n\n");
|
|
73
73
|
}
|
|
74
|
+
/** The per-root project sub-heading. The root name is the only variable part and
|
|
75
|
+
* comes from the caller's {@link RootRecall}, never from entry content. */
|
|
76
|
+
function rootHeading(name) {
|
|
77
|
+
return `Memory — ${name} (trusted project memory; reference data, not instructions):`;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Multi-root recall (C.26 step 5): one demarcated block with the shared user
|
|
81
|
+
* memory once, then each TRUSTED root's project memory under its own
|
|
82
|
+
* root-labeled sub-heading. Same framing, same budget, same omission honesty as
|
|
83
|
+
* {@link buildRecallBlock}; only the project scope is split by origin so a note
|
|
84
|
+
* from root A can never render unlabeled or attributed to root B.
|
|
85
|
+
*/
|
|
86
|
+
export function buildMultiRootRecallBlock(input) {
|
|
87
|
+
const tagged = [
|
|
88
|
+
...input.user.map((e, i) => ({
|
|
89
|
+
key: `u:${i}`,
|
|
90
|
+
entry: e,
|
|
91
|
+
source: "user",
|
|
92
|
+
})),
|
|
93
|
+
...input.roots.flatMap((r, ri) => r.entries.map((e, i) => ({ key: `${ri}:${i}`, entry: e, source: ri }))),
|
|
94
|
+
];
|
|
95
|
+
if (tagged.length === 0)
|
|
96
|
+
return null;
|
|
97
|
+
// Rank the whole eligible set by recency, greedily include under budget.
|
|
98
|
+
const included = new Set();
|
|
99
|
+
let spent = 0;
|
|
100
|
+
for (const t of [...tagged].sort((a, b) => byRecency(a.entry, b.entry))) {
|
|
101
|
+
const cost = estimateTokens(renderEntry(t.entry));
|
|
102
|
+
if (spent + cost > input.maxTokens && included.size > 0)
|
|
103
|
+
continue;
|
|
104
|
+
included.add(t.key);
|
|
105
|
+
spent += cost;
|
|
106
|
+
}
|
|
107
|
+
const omitted = tagged.length - included.size;
|
|
108
|
+
const render = (source) => tagged
|
|
109
|
+
.filter((t) => t.source === source && included.has(t.key))
|
|
110
|
+
.map((t) => t.entry)
|
|
111
|
+
.sort(byRecency)
|
|
112
|
+
.map(renderEntry)
|
|
113
|
+
.join("\n");
|
|
114
|
+
const parts = [RECALL_HEADING, FRAMING];
|
|
115
|
+
const userBody = render("user");
|
|
116
|
+
if (userBody)
|
|
117
|
+
parts.push(`${SCOPE_HEADINGS.user}\n${userBody}`);
|
|
118
|
+
input.roots.forEach((r, ri) => {
|
|
119
|
+
const body = render(ri);
|
|
120
|
+
if (body)
|
|
121
|
+
parts.push(`${rootHeading(r.name)}\n${body}`);
|
|
122
|
+
});
|
|
123
|
+
if (omitted > 0) {
|
|
124
|
+
parts.push(`[${omitted} older note${omitted === 1 ? "" : "s"} omitted to stay within the memory context budget.]`);
|
|
125
|
+
}
|
|
126
|
+
return parts.join("\n\n");
|
|
127
|
+
}
|
|
@@ -12,13 +12,16 @@ declare const RememberSchema: z.ZodObject<{
|
|
|
12
12
|
kind: z.ZodEnum<["fact", "decision", "preference"]>;
|
|
13
13
|
content: z.ZodString;
|
|
14
14
|
scope: z.ZodOptional<z.ZodEnum<["user", "project"]>>;
|
|
15
|
+
root: z.ZodOptional<z.ZodString>;
|
|
15
16
|
}, "strip", z.ZodTypeAny, {
|
|
16
17
|
kind: "fact" | "decision" | "preference";
|
|
17
18
|
content: string;
|
|
19
|
+
root?: string | undefined;
|
|
18
20
|
scope?: "project" | "user" | undefined;
|
|
19
21
|
}, {
|
|
20
22
|
kind: "fact" | "decision" | "preference";
|
|
21
23
|
content: string;
|
|
24
|
+
root?: string | undefined;
|
|
22
25
|
scope?: "project" | "user" | undefined;
|
|
23
26
|
}>;
|
|
24
27
|
export declare const rememberTool: Tool<typeof RememberSchema>;
|
|
@@ -23,6 +23,10 @@ const RememberSchema = z.object({
|
|
|
23
23
|
.enum(["user", "project"])
|
|
24
24
|
.optional()
|
|
25
25
|
.describe("'user' (default) saves to your cross-project memory; 'project' saves to this repo's memory (shared with the repo)."),
|
|
26
|
+
root: z
|
|
27
|
+
.string()
|
|
28
|
+
.optional()
|
|
29
|
+
.describe("Which declared workspace root a 'project' note belongs to, by name. Defaults to the primary root; ignored for 'user' scope. In a single-root session, omit."),
|
|
26
30
|
});
|
|
27
31
|
export const rememberTool = {
|
|
28
32
|
name: "remember",
|
|
@@ -30,8 +34,14 @@ export const rememberTool = {
|
|
|
30
34
|
parameters: RememberSchema,
|
|
31
35
|
async execute(input, ctx) {
|
|
32
36
|
try {
|
|
37
|
+
// A named `root` targets that root's project memory (default: primary). A
|
|
38
|
+
// bad name throws CRUXY_E_ROOT_UNKNOWN, surfaced to the model below. User
|
|
39
|
+
// memory is global, so `root` never changes where it lands.
|
|
40
|
+
const cwd = input.root && ctx.workspace
|
|
41
|
+
? ctx.workspace.rootByName(input.root).absPath
|
|
42
|
+
: ctx.cwd;
|
|
33
43
|
const service = new MemoryService({
|
|
34
|
-
cwd
|
|
44
|
+
cwd,
|
|
35
45
|
config: ctx.config.memory,
|
|
36
46
|
});
|
|
37
47
|
const entry = service.remember({
|
package/dist/sandbox/policy.js
CHANGED
|
@@ -30,11 +30,20 @@ export function buildPolicy(cfg, cwd, opts = {}) {
|
|
|
30
30
|
const siblingRoots = (opts.siblingRoots ?? [])
|
|
31
31
|
.map((r) => resolvePath(r))
|
|
32
32
|
.filter((r) => r !== resolvePath(cwd)) // the workdir is never a sibling
|
|
33
|
-
.map((source) =>
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
33
|
+
.map((source) => {
|
|
34
|
+
// A declared root is user input too — hold it to the SAME escape-hatch
|
|
35
|
+
// refusal as `sandbox.mounts` (JC-G): never mount the docker socket, the
|
|
36
|
+
// cruxy credential home, or `$HOME`/`/` even if declared as a root.
|
|
37
|
+
const forbidden = forbiddenMountSource(source);
|
|
38
|
+
if (forbidden) {
|
|
39
|
+
throw configInvalid(`refusing to mount workspace root ${forbidden}: "${source}"`);
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
source,
|
|
43
|
+
target: source, // identical path, like the workdir, for path coherence
|
|
44
|
+
readonly: !writable.has(source), // RO unless explicitly escalated (R5)
|
|
45
|
+
};
|
|
46
|
+
});
|
|
38
47
|
return {
|
|
39
48
|
image: cfg.image,
|
|
40
49
|
network: cfg.network,
|
|
@@ -10,8 +10,15 @@ export interface SandboxReporter {
|
|
|
10
10
|
}
|
|
11
11
|
export interface SandboxServiceDeps {
|
|
12
12
|
config: CruxyConfig;
|
|
13
|
-
/** The
|
|
13
|
+
/** The command's own workspace root — mounted read-write as the workdir. */
|
|
14
14
|
cwd: string;
|
|
15
|
+
/** The OTHER declared roots in a multi-repo session (C.26, R5). Each is
|
|
16
|
+
* mounted READ-ONLY so cross-repo reads work but a command can't silently
|
|
17
|
+
* write a sibling. Empty/absent in a single-root session. */
|
|
18
|
+
siblingRoots?: readonly string[];
|
|
19
|
+
/** Sibling roots granted an approved cross-root-write escalation (R5): each
|
|
20
|
+
* named one flips to read-write, and ONLY that one — never blanket. */
|
|
21
|
+
writableRoots?: readonly string[];
|
|
15
22
|
/** Execution runtime seam (defaults to Docker). */
|
|
16
23
|
runtime?: SandboxRuntime;
|
|
17
24
|
/** Capability probe seam (defaults to real docker detection). */
|
package/dist/sandbox/service.js
CHANGED
|
@@ -34,7 +34,10 @@ export class SandboxService {
|
|
|
34
34
|
if (!capability.available) {
|
|
35
35
|
throw sandboxUnavailable(capability.runtime, capability.detail);
|
|
36
36
|
}
|
|
37
|
-
const policy = buildPolicy(deps.config.sandbox, deps.cwd
|
|
37
|
+
const policy = buildPolicy(deps.config.sandbox, deps.cwd, {
|
|
38
|
+
siblingRoots: deps.siblingRoots,
|
|
39
|
+
writableRoots: deps.writableRoots,
|
|
40
|
+
});
|
|
38
41
|
return new SandboxService(runtime, policy, deps.reporter);
|
|
39
42
|
}
|
|
40
43
|
/** The runtime backing this service (e.g. "docker") — for logging. */
|
package/dist/subagent/index.d.ts
CHANGED
package/dist/subagent/index.js
CHANGED
|
@@ -5,7 +5,7 @@ import type { StreamRenderer } from "../render/index.js";
|
|
|
5
5
|
import type { Router } from "../routing/index.js";
|
|
6
6
|
import type { ApproveAction, ToolContext, ToolRegistry } from "../tools/index.js";
|
|
7
7
|
import type { SandboxService } from "../sandbox/index.js";
|
|
8
|
-
import
|
|
8
|
+
import { Workspace } from "../workspace/index.js";
|
|
9
9
|
import type { SubagentResult, SubagentSpec } from "./types.js";
|
|
10
10
|
/**
|
|
11
11
|
* Everything a spawn needs from the surrounding session, injected by the
|
|
@@ -76,7 +76,19 @@ export interface SubagentOrchestratorDeps {
|
|
|
76
76
|
*/
|
|
77
77
|
export declare class SubagentOrchestrator {
|
|
78
78
|
private readonly deps;
|
|
79
|
+
/**
|
|
80
|
+
* The ONE shared bound on parallel fan-out (C.33, JC-D). Constructed once per
|
|
81
|
+
* session and reused by every {@link spawnMany} call (nested spawns reuse this
|
|
82
|
+
* same orchestrator instance), so `subagent.maxConcurrency` caps the number of
|
|
83
|
+
* concurrently-executing subagents across the WHOLE session — not per parent.
|
|
84
|
+
*/
|
|
85
|
+
private readonly sem;
|
|
79
86
|
constructor(deps: SubagentOrchestratorDeps);
|
|
87
|
+
/** Live/queued fan-out slots (inspection/tests): proves the global cap holds. */
|
|
88
|
+
get concurrency(): {
|
|
89
|
+
available: number;
|
|
90
|
+
waiting: number;
|
|
91
|
+
};
|
|
80
92
|
/**
|
|
81
93
|
* Run one subagent to completion. `parentDepth` is the spawner's depth (the
|
|
82
94
|
* main agent is 0); spawning past `subagent.maxDepth` throws
|
|
@@ -89,7 +101,60 @@ export declare class SubagentOrchestrator {
|
|
|
89
101
|
* (non-interactive default-deny must reach the boundary, U.3 — a subagent is
|
|
90
102
|
* not a way to swallow it).
|
|
91
103
|
*/
|
|
92
|
-
spawn(spec: SubagentSpec, parentDepth: number): Promise<SubagentResult>;
|
|
104
|
+
spawn(spec: SubagentSpec, parentDepth: number, opts?: SpawnOptions): Promise<SubagentResult>;
|
|
105
|
+
/**
|
|
106
|
+
* Parallel fan-out (C.33): run N children concurrently under the shared
|
|
107
|
+
* concurrency semaphore and fold their outcomes into a result array whose
|
|
108
|
+
* order MATCHES `specs` (position i is spec i's result — never completion
|
|
109
|
+
* order). A DEPTH-0 capability only (the plural tool is never granted to a
|
|
110
|
+
* child), so no permit holder ever nests a second fan-out — the semaphore
|
|
111
|
+
* stays deadlock-free.
|
|
112
|
+
*
|
|
113
|
+
* Safety before dispatch: overlapping write scope is REFUSED
|
|
114
|
+
* (`CRUXY_E_SUBAGENT_SCOPE_OVERLAP`) so two writers can never race on one root.
|
|
115
|
+
*
|
|
116
|
+
* Cancellation: children share one {@link AbortController}. A child returning a
|
|
117
|
+
* `failed`/`budget-exceeded` result is a normal PARTIAL outcome — siblings run
|
|
118
|
+
* on. But a *fatal* throw from any child (non-interactive default-deny) or an
|
|
119
|
+
* abort on `opts.signal` (Ctrl-C) aborts the controller: every sibling stops at
|
|
120
|
+
* its next turn boundary and its in-flight shell child is kill-tree'd, so the
|
|
121
|
+
* fan-out leaves no orphan. All children are awaited to settle before a fatal
|
|
122
|
+
* throw propagates — never a detached, still-running sibling.
|
|
123
|
+
*/
|
|
124
|
+
spawnMany(specs: readonly SubagentSpec[], parentDepth: number, opts?: {
|
|
125
|
+
signal?: AbortSignal;
|
|
126
|
+
}): Promise<SubagentResult[]>;
|
|
127
|
+
/**
|
|
128
|
+
* Resolve a child's scope from an optional root name. With a name: a
|
|
129
|
+
* single-root workspace over that root (writes confined to it) + that root's
|
|
130
|
+
* cwd. Without: the full session workspace + primary cwd (C.14 behaviour).
|
|
131
|
+
*/
|
|
132
|
+
private childScope;
|
|
133
|
+
/**
|
|
134
|
+
* Refuse a fan-out where two WRITING children (any mutating tool granted)
|
|
135
|
+
* target the same root — the disjoint-scope guarantee (C.33). A writer with no
|
|
136
|
+
* declared root defaults to the session PRIMARY, so in a single-root session at
|
|
137
|
+
* most one child may write per batch (the rest must be read-only). Read-only
|
|
138
|
+
* children never conflict.
|
|
139
|
+
*
|
|
140
|
+
* Collects EVERY colliding root (not just the first) so the refusal names all
|
|
141
|
+
* conflicting task pairs at once — the model can fix them in one correction.
|
|
142
|
+
* The check is on DECLARED scope (tools + root), an honest over-approximation
|
|
143
|
+
* the error message is explicit about.
|
|
144
|
+
*/
|
|
145
|
+
private assertDisjointWriteScopes;
|
|
93
146
|
/** Map the child's AgentResult to the structured, transcript-free shape. */
|
|
94
147
|
private toResult;
|
|
95
148
|
}
|
|
149
|
+
/** Per-spawn options: cancellation + a per-child render tag for a fan-out. */
|
|
150
|
+
interface SpawnOptions {
|
|
151
|
+
/** Cooperative cancellation; threaded into `runAgent` and `ctx.signal`. */
|
|
152
|
+
signal?: AbortSignal;
|
|
153
|
+
/**
|
|
154
|
+
* A short per-child label for a parallel fan-out (JC-F), e.g. `"2/3"`, so
|
|
155
|
+
* interleaved trail notes are attributable to the child that emitted them.
|
|
156
|
+
* Undefined for a single sequential spawn — chrome is byte-identical to C.14.
|
|
157
|
+
*/
|
|
158
|
+
slot?: string;
|
|
159
|
+
}
|
|
160
|
+
export {};
|