@cruxy/cli 0.23.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/loop.d.ts +21 -2
- package/dist/agent/loop.js +21 -5
- package/dist/agent/session.d.ts +13 -0
- package/dist/agent/session.js +6 -0
- package/dist/approval/index.d.ts +1 -0
- package/dist/approval/index.js +1 -0
- package/dist/approval/mutex.d.ts +45 -0
- package/dist/approval/mutex.js +57 -0
- package/dist/checkpoint/gate-hook.d.ts +28 -0
- package/dist/checkpoint/gate-hook.js +98 -0
- package/dist/checkpoint/gate.d.ts +7 -1
- package/dist/checkpoint/gate.js +8 -2
- package/dist/checkpoint/index.d.ts +1 -0
- package/dist/checkpoint/index.js +1 -0
- package/dist/checkpoint/service.d.ts +9 -0
- package/dist/checkpoint/service.js +20 -0
- package/dist/cli/commands/rollback.d.ts +4 -1
- package/dist/cli/commands/rollback.js +16 -9
- package/dist/cli/commands/run.js +62 -16
- package/dist/cli/onboard.js +2 -2
- package/dist/cli/repl.d.ts +1 -1
- package/dist/cli/repl.js +145 -0
- package/dist/cli/session-factory.d.ts +24 -10
- package/dist/cli/session-factory.js +179 -135
- package/dist/config/schema.d.ts +110 -0
- package/dist/config/schema.js +50 -0
- package/dist/errors/constructors.d.ts +41 -0
- package/dist/errors/constructors.js +87 -0
- package/dist/errors/types.d.ts +21 -0
- package/dist/errors/types.js +33 -0
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/router.d.ts +58 -0
- package/dist/hooks/router.js +136 -0
- package/dist/hooks/runner.d.ts +12 -0
- package/dist/hooks/runner.js +23 -1
- package/dist/jobs/approval-queue.d.ts +85 -0
- package/dist/jobs/approval-queue.js +96 -0
- package/dist/jobs/dispatch-tool.d.ts +34 -0
- package/dist/jobs/dispatch-tool.js +96 -0
- package/dist/jobs/index.d.ts +6 -0
- package/dist/jobs/index.js +6 -0
- package/dist/jobs/log-buffer.d.ts +31 -0
- package/dist/jobs/log-buffer.js +30 -0
- package/dist/jobs/log-renderer.d.ts +32 -0
- package/dist/jobs/log-renderer.js +70 -0
- package/dist/jobs/manager.d.ts +139 -0
- package/dist/jobs/manager.js +397 -0
- package/dist/jobs/types.d.ts +81 -0
- package/dist/jobs/types.js +10 -0
- package/dist/mcp/index.d.ts +1 -0
- package/dist/mcp/index.js +1 -0
- package/dist/mcp/sibling-banner.d.ts +25 -0
- package/dist/mcp/sibling-banner.js +34 -0
- package/dist/memory/recall.d.ts +24 -0
- package/dist/memory/recall.js +54 -0
- package/dist/memory/remember-tool.d.ts +3 -0
- package/dist/memory/remember-tool.js +11 -1
- package/dist/sandbox/policy.js +14 -5
- package/dist/sandbox/service.d.ts +8 -1
- package/dist/sandbox/service.js +4 -1
- package/dist/subagent/index.d.ts +1 -0
- package/dist/subagent/index.js +1 -0
- package/dist/subagent/orchestrator.d.ts +76 -2
- package/dist/subagent/orchestrator.js +208 -18
- package/dist/subagent/registry-scope.d.ts +13 -0
- package/dist/subagent/registry-scope.js +28 -2
- package/dist/subagent/semaphore.d.ts +56 -0
- package/dist/subagent/semaphore.js +53 -0
- package/dist/subagent/spawn-tool.d.ts +57 -0
- package/dist/subagent/spawn-tool.js +104 -9
- package/dist/subagent/types.d.ts +17 -2
- package/dist/testing/run-tests-tool.js +1 -1
- package/dist/tools/file/paths.d.ts +5 -6
- package/dist/tools/file/paths.js +7 -8
- package/dist/tools/shell/exec.js +36 -4
- package/dist/tools/types.d.ts +16 -5
- package/dist/workspace/add-root.d.ts +27 -0
- package/dist/workspace/add-root.js +16 -0
- package/dist/workspace/index.d.ts +2 -1
- package/dist/workspace/index.js +2 -1
- package/dist/workspace/workspace.d.ts +9 -4
- package/dist/workspace/workspace.js +9 -4
- package/package.json +1 -1
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A counting semaphore (C.33): bounds how many agent runs execute at once.
|
|
3
|
+
* FIFO — waiters are served in arrival order, so a fan-out's results stay
|
|
4
|
+
* dispatch-order-fair — and the permit is handed directly from a releaser to
|
|
5
|
+
* the next waiter, so the live count never transiently exceeds the cap.
|
|
6
|
+
*
|
|
7
|
+
* Used as the ONE shared bound on all concurrent execution: parallel subagent
|
|
8
|
+
* fan-out (C.33) AND background jobs (C.28) draw permits from the SAME instance,
|
|
9
|
+
* so `subagent.maxConcurrency` caps their COMBINED live count — never one cap
|
|
10
|
+
* per subsystem. Parallel dispatch happens at depth 0 only (the `spawn_subagents`
|
|
11
|
+
* tool is never granted to a child), and a permit is held for a child's whole
|
|
12
|
+
* lifetime — including any *sequential* nested spawn beneath it, which is
|
|
13
|
+
* deliberately un-permitted. Because no permit holder ever blocks trying to
|
|
14
|
+
* acquire a second permit, the semaphore cannot be part of a wait cycle: it is
|
|
15
|
+
* deadlock-free by construction (see the C.33 design doc's deadlock argument).
|
|
16
|
+
*
|
|
17
|
+
* C.28 adds a PRIORITY lane to {@link acquire}. A background job that pauses to
|
|
18
|
+
* await a human approval RELEASES its permit (JC-D: a job blocked on a human is
|
|
19
|
+
* not using compute, and holding the slot would deadlock the cap if N jobs all
|
|
20
|
+
* paused). When the human approves, the job re-acquires with `priority: true` so
|
|
21
|
+
* it jumps ahead of newly-dispatched work — a resumed job never starves behind a
|
|
22
|
+
* fresh fan-out that filled the queue while it waited. Normal (non-priority)
|
|
23
|
+
* waiters keep strict FIFO among themselves, so C.33's dispatch-order fairness is
|
|
24
|
+
* unchanged for everything that does not pause.
|
|
25
|
+
*/
|
|
26
|
+
export interface AcquireOptions {
|
|
27
|
+
/**
|
|
28
|
+
* Serve this waiter ahead of all non-priority waiters (still FIFO among
|
|
29
|
+
* priority waiters). Used by a resumed background job re-acquiring its slot
|
|
30
|
+
* after an approval, so it is not starved by work dispatched while it paused.
|
|
31
|
+
*/
|
|
32
|
+
priority?: boolean;
|
|
33
|
+
}
|
|
34
|
+
export declare class Semaphore {
|
|
35
|
+
private permits;
|
|
36
|
+
/** Waiters that take the next permit ahead of {@link normal} (FIFO within). */
|
|
37
|
+
private readonly priority;
|
|
38
|
+
/** Ordinary waiters, served strictly FIFO after any priority waiters. */
|
|
39
|
+
private readonly normal;
|
|
40
|
+
constructor(permits: number);
|
|
41
|
+
/** Run `fn` while holding one permit; the permit is released even if it throws. */
|
|
42
|
+
run<T>(fn: () => Promise<T>): Promise<T>;
|
|
43
|
+
/**
|
|
44
|
+
* Take one permit, waiting if none is free. Resolves immediately when a permit
|
|
45
|
+
* is available; otherwise enqueues onto the priority or normal lane and resolves
|
|
46
|
+
* when {@link release} hands it a permit. Callers that acquire directly (a
|
|
47
|
+
* paused/resumed job) MUST call {@link release} exactly once per acquire.
|
|
48
|
+
*/
|
|
49
|
+
acquire(opts?: AcquireOptions): Promise<void>;
|
|
50
|
+
/** Return one permit — handed straight to the next waiter (priority first). */
|
|
51
|
+
release(): void;
|
|
52
|
+
/** Permits currently available (inspection/tests). */
|
|
53
|
+
get available(): number;
|
|
54
|
+
/** Callers currently blocked waiting for a permit (inspection/tests). */
|
|
55
|
+
get waiting(): number;
|
|
56
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export class Semaphore {
|
|
2
|
+
permits;
|
|
3
|
+
/** Waiters that take the next permit ahead of {@link normal} (FIFO within). */
|
|
4
|
+
priority = [];
|
|
5
|
+
/** Ordinary waiters, served strictly FIFO after any priority waiters. */
|
|
6
|
+
normal = [];
|
|
7
|
+
constructor(permits) {
|
|
8
|
+
// A non-positive cap would wedge every run; clamp to at least 1.
|
|
9
|
+
this.permits = Math.max(1, Math.floor(permits));
|
|
10
|
+
}
|
|
11
|
+
/** Run `fn` while holding one permit; the permit is released even if it throws. */
|
|
12
|
+
async run(fn) {
|
|
13
|
+
await this.acquire();
|
|
14
|
+
try {
|
|
15
|
+
return await fn();
|
|
16
|
+
}
|
|
17
|
+
finally {
|
|
18
|
+
this.release();
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Take one permit, waiting if none is free. Resolves immediately when a permit
|
|
23
|
+
* is available; otherwise enqueues onto the priority or normal lane and resolves
|
|
24
|
+
* when {@link release} hands it a permit. Callers that acquire directly (a
|
|
25
|
+
* paused/resumed job) MUST call {@link release} exactly once per acquire.
|
|
26
|
+
*/
|
|
27
|
+
acquire(opts = {}) {
|
|
28
|
+
if (this.permits > 0) {
|
|
29
|
+
this.permits--;
|
|
30
|
+
return Promise.resolve();
|
|
31
|
+
}
|
|
32
|
+
const lane = opts.priority ? this.priority : this.normal;
|
|
33
|
+
return new Promise((resolve) => lane.push(resolve));
|
|
34
|
+
}
|
|
35
|
+
/** Return one permit — handed straight to the next waiter (priority first). */
|
|
36
|
+
release() {
|
|
37
|
+
const next = this.priority.shift() ?? this.normal.shift();
|
|
38
|
+
// Hand the permit straight to the next waiter (never bump the count above
|
|
39
|
+
// the cap); only when nobody waits does the count grow back.
|
|
40
|
+
if (next)
|
|
41
|
+
next();
|
|
42
|
+
else
|
|
43
|
+
this.permits++;
|
|
44
|
+
}
|
|
45
|
+
/** Permits currently available (inspection/tests). */
|
|
46
|
+
get available() {
|
|
47
|
+
return this.permits;
|
|
48
|
+
}
|
|
49
|
+
/** Callers currently blocked waiting for a permit (inspection/tests). */
|
|
50
|
+
get waiting() {
|
|
51
|
+
return this.priority.length + this.normal.length;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -26,4 +26,61 @@ declare const parameters: z.ZodObject<{
|
|
|
26
26
|
}>;
|
|
27
27
|
/** Build a `spawn_subagent` tool bound to `orchestrator` at `depth`. */
|
|
28
28
|
export declare function makeSpawnSubagentTool(orchestrator: SubagentOrchestrator, depth: number): Tool<typeof parameters>;
|
|
29
|
+
declare const batchParameters: z.ZodObject<{
|
|
30
|
+
tasks: z.ZodArray<z.ZodObject<{
|
|
31
|
+
task: z.ZodString;
|
|
32
|
+
tools: z.ZodOptional<z.ZodArray<z.ZodString, "atleastone">>;
|
|
33
|
+
root: z.ZodOptional<z.ZodString>;
|
|
34
|
+
maxIterations: z.ZodOptional<z.ZodNumber>;
|
|
35
|
+
maxTokens: z.ZodOptional<z.ZodNumber>;
|
|
36
|
+
}, "strip", z.ZodTypeAny, {
|
|
37
|
+
task: string;
|
|
38
|
+
root?: string | undefined;
|
|
39
|
+
maxTokens?: number | undefined;
|
|
40
|
+
maxIterations?: number | undefined;
|
|
41
|
+
tools?: [string, ...string[]] | undefined;
|
|
42
|
+
}, {
|
|
43
|
+
task: string;
|
|
44
|
+
root?: string | undefined;
|
|
45
|
+
maxTokens?: number | undefined;
|
|
46
|
+
maxIterations?: number | undefined;
|
|
47
|
+
tools?: [string, ...string[]] | undefined;
|
|
48
|
+
}>, "atleastone">;
|
|
49
|
+
}, "strip", z.ZodTypeAny, {
|
|
50
|
+
tasks: [{
|
|
51
|
+
task: string;
|
|
52
|
+
root?: string | undefined;
|
|
53
|
+
maxTokens?: number | undefined;
|
|
54
|
+
maxIterations?: number | undefined;
|
|
55
|
+
tools?: [string, ...string[]] | undefined;
|
|
56
|
+
}, ...{
|
|
57
|
+
task: string;
|
|
58
|
+
root?: string | undefined;
|
|
59
|
+
maxTokens?: number | undefined;
|
|
60
|
+
maxIterations?: number | undefined;
|
|
61
|
+
tools?: [string, ...string[]] | undefined;
|
|
62
|
+
}[]];
|
|
63
|
+
}, {
|
|
64
|
+
tasks: [{
|
|
65
|
+
task: string;
|
|
66
|
+
root?: string | undefined;
|
|
67
|
+
maxTokens?: number | undefined;
|
|
68
|
+
maxIterations?: number | undefined;
|
|
69
|
+
tools?: [string, ...string[]] | undefined;
|
|
70
|
+
}, ...{
|
|
71
|
+
task: string;
|
|
72
|
+
root?: string | undefined;
|
|
73
|
+
maxTokens?: number | undefined;
|
|
74
|
+
maxIterations?: number | undefined;
|
|
75
|
+
tools?: [string, ...string[]] | undefined;
|
|
76
|
+
}[]];
|
|
77
|
+
}>;
|
|
78
|
+
/**
|
|
79
|
+
* Build the `spawn_subagents` tool (C.33) — the PARALLEL fan-out seam, bound to
|
|
80
|
+
* `depth`. One tool call dispatches N independent, internally-sequential children
|
|
81
|
+
* concurrently (JC-A) under the shared concurrency semaphore, and returns their
|
|
82
|
+
* results IN REQUEST ORDER. A DEPTH-0 capability: it is never granted to a child,
|
|
83
|
+
* so fan-out never nests (which keeps the semaphore deadlock-free).
|
|
84
|
+
*/
|
|
85
|
+
export declare function makeSpawnSubagentsTool(orchestrator: SubagentOrchestrator, depth: number): Tool<typeof batchParameters>;
|
|
29
86
|
export {};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { CruxyError, ErrorCode } from "../errors/index.js";
|
|
3
|
-
import { SPAWN_SUBAGENT_TOOL_NAME } from "./registry-scope.js";
|
|
3
|
+
import { SPAWN_SUBAGENT_TOOL_NAME, SPAWN_SUBAGENTS_TOOL_NAME, } from "./registry-scope.js";
|
|
4
4
|
/**
|
|
5
5
|
* The `spawn_subagent` tool (C.14): the parent-facing seam for delegation. A
|
|
6
6
|
* normal tool on the same loop as everything else — no hidden control flow.
|
|
@@ -34,9 +34,9 @@ const parameters = z.object({
|
|
|
34
34
|
.optional()
|
|
35
35
|
.describe("Cap on the subagent's total tokens (clamped to the configured ceiling)."),
|
|
36
36
|
});
|
|
37
|
-
/** The compact
|
|
38
|
-
function
|
|
39
|
-
return
|
|
37
|
+
/** The compact, transcript-free object fed back to the parent model. */
|
|
38
|
+
function resultPayload(result) {
|
|
39
|
+
return {
|
|
40
40
|
status: result.status,
|
|
41
41
|
summary: result.summary,
|
|
42
42
|
...(result.artifacts ? { artifacts: result.artifacts } : {}),
|
|
@@ -46,7 +46,18 @@ function renderResult(result) {
|
|
|
46
46
|
input: result.usage.input_tokens,
|
|
47
47
|
output: result.usage.output_tokens,
|
|
48
48
|
},
|
|
49
|
-
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/** The compact wire shape fed back to the parent model. */
|
|
52
|
+
function renderResult(result) {
|
|
53
|
+
return JSON.stringify(resultPayload(result));
|
|
54
|
+
}
|
|
55
|
+
/** A coded, actionable tool-error string for an error the MODEL should correct
|
|
56
|
+
* (scope overlap, depth exceeded, tool scoping) — never the raw stack. */
|
|
57
|
+
function toolErrorMessage(err) {
|
|
58
|
+
return CruxyError.is(err)
|
|
59
|
+
? `${err.code}: ${err.title}${err.cause ? ` — ${err.cause}` : ""}`
|
|
60
|
+
: err.message;
|
|
50
61
|
}
|
|
51
62
|
/** Build a `spawn_subagent` tool bound to `orchestrator` at `depth`. */
|
|
52
63
|
export function makeSpawnSubagentTool(orchestrator, depth) {
|
|
@@ -79,10 +90,7 @@ export function makeSpawnSubagentTool(orchestrator, depth) {
|
|
|
79
90
|
}
|
|
80
91
|
// Depth-exceed and scope violations are the model's to correct: feed
|
|
81
92
|
// the coded, actionable message back as a tool error.
|
|
82
|
-
|
|
83
|
-
? `${err.code}: ${err.title}${err.cause ? ` — ${err.cause}` : ""}`
|
|
84
|
-
: err.message;
|
|
85
|
-
return { ok: false, error: message };
|
|
93
|
+
return { ok: false, error: toolErrorMessage(err) };
|
|
86
94
|
}
|
|
87
95
|
// A failed child is an is_error result (strong signal), still structured;
|
|
88
96
|
// budget-exceeded is informational — a partial result, not an error.
|
|
@@ -92,3 +100,90 @@ export function makeSpawnSubagentTool(orchestrator, depth) {
|
|
|
92
100
|
},
|
|
93
101
|
};
|
|
94
102
|
}
|
|
103
|
+
// ── parallel fan-out (C.33) ───────────────────────────────────────────────────
|
|
104
|
+
/** One child's spec inside a `spawn_subagents` batch. Mirrors the singular
|
|
105
|
+
* tool's shape plus `root` — the C.33 disjoint-scope unit. */
|
|
106
|
+
const batchChild = z.object({
|
|
107
|
+
task: z
|
|
108
|
+
.string()
|
|
109
|
+
.min(1)
|
|
110
|
+
.describe("The complete, self-contained subtask. The subagent starts with NO context " +
|
|
111
|
+
"beyond this text — include every path, constraint, and expected output."),
|
|
112
|
+
tools: z
|
|
113
|
+
.array(z.string().min(1))
|
|
114
|
+
.nonempty()
|
|
115
|
+
.optional()
|
|
116
|
+
.describe("Tool names to grant this child — a subset of your own. Omit for read-only. " +
|
|
117
|
+
"Two children that both hold write/shell tools MUST target different `root`s."),
|
|
118
|
+
root: z
|
|
119
|
+
.string()
|
|
120
|
+
.min(1)
|
|
121
|
+
.optional()
|
|
122
|
+
.describe("Workspace root name (from your Environment) to scope this child to: its " +
|
|
123
|
+
"writes are confined there. Required to make two writing children disjoint; " +
|
|
124
|
+
"omit for read-only children or a single-root workspace."),
|
|
125
|
+
maxIterations: z.number().int().positive().optional(),
|
|
126
|
+
maxTokens: z.number().int().positive().optional(),
|
|
127
|
+
});
|
|
128
|
+
const batchParameters = z.object({
|
|
129
|
+
tasks: z
|
|
130
|
+
.array(batchChild)
|
|
131
|
+
.nonempty()
|
|
132
|
+
.describe("The independent subtasks to run CONCURRENTLY. Results come back in this " +
|
|
133
|
+
"same order. Bounded by subagent.maxConcurrency; excess children queue."),
|
|
134
|
+
});
|
|
135
|
+
/** Map a validated batch child to a {@link SubagentSpec}. */
|
|
136
|
+
function toSpec(child) {
|
|
137
|
+
const budget = {
|
|
138
|
+
...(child.maxIterations !== undefined
|
|
139
|
+
? { maxIterations: child.maxIterations }
|
|
140
|
+
: {}),
|
|
141
|
+
...(child.maxTokens !== undefined ? { maxTokens: child.maxTokens } : {}),
|
|
142
|
+
};
|
|
143
|
+
return {
|
|
144
|
+
task: child.task,
|
|
145
|
+
...(child.tools ? { tools: child.tools } : {}),
|
|
146
|
+
...(child.root !== undefined ? { root: child.root } : {}),
|
|
147
|
+
budget,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Build the `spawn_subagents` tool (C.33) — the PARALLEL fan-out seam, bound to
|
|
152
|
+
* `depth`. One tool call dispatches N independent, internally-sequential children
|
|
153
|
+
* concurrently (JC-A) under the shared concurrency semaphore, and returns their
|
|
154
|
+
* results IN REQUEST ORDER. A DEPTH-0 capability: it is never granted to a child,
|
|
155
|
+
* so fan-out never nests (which keeps the semaphore deadlock-free).
|
|
156
|
+
*/
|
|
157
|
+
export function makeSpawnSubagentsTool(orchestrator, depth) {
|
|
158
|
+
return {
|
|
159
|
+
name: SPAWN_SUBAGENTS_TOOL_NAME,
|
|
160
|
+
description: "Delegate SEVERAL independent subtasks to scoped subagents that run in PARALLEL, " +
|
|
161
|
+
"each with its own fresh context, restricted toolset, and hard budget. Returns a " +
|
|
162
|
+
"structured result per child, in the same order as `tasks` — transcripts discarded. " +
|
|
163
|
+
"Use when subtasks don't depend on each other (e.g. investigate N areas at once). " +
|
|
164
|
+
"For children that WRITE, give each a distinct `root`; overlapping write scope is " +
|
|
165
|
+
"refused. For a single subtask, use spawn_subagent instead.",
|
|
166
|
+
parameters: batchParameters,
|
|
167
|
+
async execute(input) {
|
|
168
|
+
let results;
|
|
169
|
+
try {
|
|
170
|
+
results = await orchestrator.spawnMany(input.tasks.map(toSpec), depth);
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
// Non-interactive default-deny propagates to the boundary (U.3).
|
|
174
|
+
if (CruxyError.is(err) && err.code === ErrorCode.ApprovalRequired) {
|
|
175
|
+
throw err;
|
|
176
|
+
}
|
|
177
|
+
// Scope overlap / depth-exceed are the model's to correct — coded error.
|
|
178
|
+
return { ok: false, error: toolErrorMessage(err) };
|
|
179
|
+
}
|
|
180
|
+
// Partial results are honest, not an error: the array carries each child's
|
|
181
|
+
// own status (done / failed / budget-exceeded / cancelled). The batch call
|
|
182
|
+
// succeeds as long as it was dispatched — the parent reasons over the mix.
|
|
183
|
+
return {
|
|
184
|
+
ok: true,
|
|
185
|
+
output: JSON.stringify(results.map(resultPayload)),
|
|
186
|
+
};
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
package/dist/subagent/types.d.ts
CHANGED
|
@@ -7,8 +7,13 @@ import type { TaskClass } from "../routing/index.js";
|
|
|
7
7
|
* result and discards its transcript. The parent reasons over the result only;
|
|
8
8
|
* context isolation is the whole point.
|
|
9
9
|
*/
|
|
10
|
-
/**
|
|
11
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Why a subagent run ended. Every path returns a result — never a hang.
|
|
12
|
+
* `cancelled` (C.33) is a parallel-fan-out outcome: a fatal sibling failure or
|
|
13
|
+
* Ctrl-C aborted this child before it finished — recorded honestly, never
|
|
14
|
+
* dressed up as `done`.
|
|
15
|
+
*/
|
|
16
|
+
export type SubagentStatus = "done" | "budget-exceeded" | "failed" | "cancelled";
|
|
12
17
|
/**
|
|
13
18
|
* Hard caps a subagent runs under. `maxIterations` and `maxTokens` are always
|
|
14
19
|
* finite — a subagent is bounded by construction; `timeoutMs` is an optional
|
|
@@ -41,6 +46,16 @@ export interface SubagentSpec {
|
|
|
41
46
|
* declaration at the spawn call site — not something the router guesses.
|
|
42
47
|
*/
|
|
43
48
|
taskClass?: TaskClass;
|
|
49
|
+
/**
|
|
50
|
+
* The workspace root (by exact name, C.26) this child is scoped to (C.33).
|
|
51
|
+
* When set, the child's cwd and confinement narrow to that ONE root, so its
|
|
52
|
+
* writes land there and nowhere else — the unit of the parallel fan-out's
|
|
53
|
+
* disjoint-scope guarantee: two writing children must name distinct roots or
|
|
54
|
+
* the batch is refused pre-dispatch. Omitted → the child inherits the full
|
|
55
|
+
* session workspace (read-only fan-out, or a single-root session), unchanged
|
|
56
|
+
* from C.14.
|
|
57
|
+
*/
|
|
58
|
+
root?: string;
|
|
44
59
|
}
|
|
45
60
|
/**
|
|
46
61
|
* What the parent gets back — compact structured data, never the transcript.
|
|
@@ -99,7 +99,7 @@ export function makeRunTestsTool(deps = {}) {
|
|
|
99
99
|
command: resolved.command,
|
|
100
100
|
// C.26 attribution seam (JC-β): primary root name for Steps 4/5. Tests are
|
|
101
101
|
// primary-only this release; the gate hard-attributes to the primary.
|
|
102
|
-
root: ctx.workspace
|
|
102
|
+
root: ctx.workspace.primary().name,
|
|
103
103
|
});
|
|
104
104
|
if (!decision.allow) {
|
|
105
105
|
return {
|
|
@@ -9,12 +9,11 @@ import type { ToolContext } from "../types.js";
|
|
|
9
9
|
*/
|
|
10
10
|
export { PathEscapeError };
|
|
11
11
|
/**
|
|
12
|
-
* The workspace this context acts in.
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* `resolveInRoot` confined to.
|
|
12
|
+
* The workspace this context acts in. `ctx.workspace` is a required field (C.26
|
|
13
|
+
* Step 6), so this is a plain accessor — there is no single-root fallback to
|
|
14
|
+
* synthesize, because no code path can reach a path-taking tool without a
|
|
15
|
+
* workspace (the type forbids the omission; the `no-split-brain` guard forbids a
|
|
16
|
+
* mid-subsystem synthesis). Kept as the one named seam every funnel calls.
|
|
18
17
|
*/
|
|
19
18
|
export declare function contextWorkspace(ctx: ToolContext): Workspace;
|
|
20
19
|
/**
|
package/dist/tools/file/paths.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { multirootWriteDeferred, rootAmbiguous } from "../../errors/index.js";
|
|
3
|
-
import { confineToRoot, PathEscapeError, selectRoot,
|
|
3
|
+
import { confineToRoot, PathEscapeError, selectRoot, } from "../../workspace/index.js";
|
|
4
4
|
/**
|
|
5
5
|
* Path confinement for file tools (C.26). The confinement kernel itself lives in
|
|
6
6
|
* `src/workspace` ({@link confineToRoot}); this module is the tool-facing funnel
|
|
@@ -10,15 +10,14 @@ import { confineToRoot, PathEscapeError, selectRoot, singleRootWorkspace, } from
|
|
|
10
10
|
*/
|
|
11
11
|
export { PathEscapeError };
|
|
12
12
|
/**
|
|
13
|
-
* The workspace this context acts in.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* `resolveInRoot` confined to.
|
|
13
|
+
* The workspace this context acts in. `ctx.workspace` is a required field (C.26
|
|
14
|
+
* Step 6), so this is a plain accessor — there is no single-root fallback to
|
|
15
|
+
* synthesize, because no code path can reach a path-taking tool without a
|
|
16
|
+
* workspace (the type forbids the omission; the `no-split-brain` guard forbids a
|
|
17
|
+
* mid-subsystem synthesis). Kept as the one named seam every funnel calls.
|
|
19
18
|
*/
|
|
20
19
|
export function contextWorkspace(ctx) {
|
|
21
|
-
return ctx.workspace
|
|
20
|
+
return ctx.workspace;
|
|
22
21
|
}
|
|
23
22
|
/**
|
|
24
23
|
* Resolve a tool-supplied path against the workspace and prove it stays inside the
|
package/dist/tools/shell/exec.js
CHANGED
|
@@ -13,7 +13,7 @@ export async function runGatedShell(command, ctx) {
|
|
|
13
13
|
const decision = await ctx.requestApproval({
|
|
14
14
|
kind: "shell",
|
|
15
15
|
command,
|
|
16
|
-
root: ctx.workspace
|
|
16
|
+
root: ctx.workspace.primary().name,
|
|
17
17
|
});
|
|
18
18
|
if (!decision.allow) {
|
|
19
19
|
return { approved: false, rejection: decision.feedback };
|
|
@@ -74,12 +74,17 @@ function runBounded(command, ctx) {
|
|
|
74
74
|
};
|
|
75
75
|
child.stdout?.on("data", capture);
|
|
76
76
|
child.stderr?.on("data", capture);
|
|
77
|
-
// A single guard so the timeout-kill and the natural
|
|
77
|
+
// A single guard so the timeout-kill, an external abort, and the natural
|
|
78
|
+
// close can't all fire. `cleanup` deregisters the abort listener on every
|
|
79
|
+
// settle path so a resolved command never holds a dangling subscription.
|
|
78
80
|
let settled = false;
|
|
81
|
+
const abortSignal = ctx.signal;
|
|
82
|
+
const cleanup = () => abortSignal?.removeEventListener("abort", onAbort);
|
|
79
83
|
const timer = setTimeout(() => {
|
|
80
84
|
if (settled)
|
|
81
85
|
return;
|
|
82
86
|
settled = true;
|
|
87
|
+
cleanup();
|
|
83
88
|
killTree(child.pid);
|
|
84
89
|
resolve({
|
|
85
90
|
timedOut: true,
|
|
@@ -89,11 +94,37 @@ function runBounded(command, ctx) {
|
|
|
89
94
|
truncated,
|
|
90
95
|
});
|
|
91
96
|
}, timeoutMs);
|
|
97
|
+
// External cancellation (C.33): a cancelled sibling's in-flight command is
|
|
98
|
+
// kill-tree'd here instead of orphaned. Reports as a `SIGKILL` termination
|
|
99
|
+
// (not a timeout) so the caller can tell cancel apart from a real timeout.
|
|
100
|
+
function onAbort() {
|
|
101
|
+
if (settled)
|
|
102
|
+
return;
|
|
103
|
+
settled = true;
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
cleanup();
|
|
106
|
+
killTree(child.pid);
|
|
107
|
+
resolve({
|
|
108
|
+
timedOut: false,
|
|
109
|
+
exitCode: null,
|
|
110
|
+
signal: "SIGKILL",
|
|
111
|
+
output: Buffer.concat(chunks).toString("utf8"),
|
|
112
|
+
truncated,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
if (abortSignal?.aborted) {
|
|
116
|
+
// Already cancelled before the child got going — reap it immediately.
|
|
117
|
+
queueMicrotask(onAbort);
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
abortSignal?.addEventListener("abort", onAbort, { once: true });
|
|
121
|
+
}
|
|
92
122
|
child.on("error", (err) => {
|
|
93
123
|
if (settled)
|
|
94
124
|
return;
|
|
95
125
|
settled = true;
|
|
96
126
|
clearTimeout(timer);
|
|
127
|
+
cleanup();
|
|
97
128
|
resolve({
|
|
98
129
|
timedOut: false,
|
|
99
130
|
exitCode: null,
|
|
@@ -103,15 +134,16 @@ function runBounded(command, ctx) {
|
|
|
103
134
|
spawnError: err.message,
|
|
104
135
|
});
|
|
105
136
|
});
|
|
106
|
-
child.on("close", (code,
|
|
137
|
+
child.on("close", (code, closeSignal) => {
|
|
107
138
|
if (settled)
|
|
108
139
|
return;
|
|
109
140
|
settled = true;
|
|
110
141
|
clearTimeout(timer);
|
|
142
|
+
cleanup();
|
|
111
143
|
resolve({
|
|
112
144
|
timedOut: false,
|
|
113
145
|
exitCode: code,
|
|
114
|
-
signal:
|
|
146
|
+
signal: closeSignal ?? null,
|
|
115
147
|
output: Buffer.concat(chunks).toString("utf8"),
|
|
116
148
|
truncated,
|
|
117
149
|
});
|
package/dist/tools/types.d.ts
CHANGED
|
@@ -166,13 +166,15 @@ export interface ToolContext {
|
|
|
166
166
|
/**
|
|
167
167
|
* The declared workspace root set for this session (C.26). Path-taking tools
|
|
168
168
|
* resolve *through* this rather than through `cwd`, so a multi-root session
|
|
169
|
-
* confines each call to the one root it selects.
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
169
|
+
* confines each call to the one root it selects. **Required** (C.26 Step 6): a
|
|
170
|
+
* construction site that fails to thread it is a *compile error*, not a runtime
|
|
171
|
+
* primary-default — closing the omission hole the old optional field left. The
|
|
172
|
+
* runtime always sets it; a single-root caller builds one with `sessionWorkspace`.
|
|
173
|
+
* (The type catches *omission*; it cannot catch a *fabricated* workspace threaded
|
|
174
|
+
* through — the `no-split-brain` grep guard covers that. See its header.) `cwd`
|
|
173
175
|
* remains equal to `workspace.primary().absPath`.
|
|
174
176
|
*/
|
|
175
|
-
workspace
|
|
177
|
+
workspace: Workspace;
|
|
176
178
|
/** Fully-resolved CLI configuration. */
|
|
177
179
|
config: CruxyConfig;
|
|
178
180
|
/** Shared leveled logger (diagnostics to stderr, `print` to stdout). */
|
|
@@ -208,6 +210,15 @@ export interface ToolContext {
|
|
|
208
210
|
* populated). Absent → host execution, unchanged.
|
|
209
211
|
*/
|
|
210
212
|
sandbox?: SandboxService;
|
|
213
|
+
/**
|
|
214
|
+
* Cooperative cancellation for in-flight tool work (C.33). Set on a subagent's
|
|
215
|
+
* ctx when it runs inside a parallel fan-out: when a fatal sibling failure or
|
|
216
|
+
* Ctrl-C aborts the batch, this signal fires, and a long-running `run_command`
|
|
217
|
+
* kills its whole process tree instead of leaving it orphaned — the C.12/C.16
|
|
218
|
+
* kill-tree discipline extended to N concurrent children. Absent → no external
|
|
219
|
+
* cancellation (the tool's own timeout still applies), unchanged behaviour.
|
|
220
|
+
*/
|
|
221
|
+
signal?: AbortSignal;
|
|
211
222
|
}
|
|
212
223
|
/**
|
|
213
224
|
* The one interface every tool implements. `parameters` is a zod schema; it both
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { RootSpec } from "./types.js";
|
|
2
|
+
import { Workspace } from "./workspace.js";
|
|
3
|
+
/**
|
|
4
|
+
* The interactive add-root path (C.26 step 5). Adding a workspace root is an
|
|
5
|
+
* EXPLICIT HUMAN act — it grows the trust surface (a new root can carry its own
|
|
6
|
+
* hooks / MCP / project memory), so it is:
|
|
7
|
+
*
|
|
8
|
+
* - reachable ONLY from the REPL command and the CLI, never as a model tool.
|
|
9
|
+
* The model can act only through the tool registry (`registry.get(name)`),
|
|
10
|
+
* and no tool is registered for this — the allowlist argument is unchanged.
|
|
11
|
+
* - TTY-only: refused (coded) when there is no interactive human to vouch.
|
|
12
|
+
* - held to the SAME validation as `--root`: the new root must exist, be a
|
|
13
|
+
* directory, have a unique name, and NOT nest/overlap an existing root
|
|
14
|
+
* (CRUXY_E_ROOT_OVERLAP) — reusing {@link buildWorkspace}, the single
|
|
15
|
+
* Workspace constructor from user input.
|
|
16
|
+
*
|
|
17
|
+
* Returns the NEW immutable Workspace (the root set only ever grows by
|
|
18
|
+
* constructing a new one). A freshly added root starts UNTRUSTED; its hooks and
|
|
19
|
+
* project memory stay inert until explicitly trusted.
|
|
20
|
+
*/
|
|
21
|
+
export interface AddRootOptions {
|
|
22
|
+
/** Base dir for resolving a relative `path` (the session's primary root). */
|
|
23
|
+
cwd: string;
|
|
24
|
+
/** Whether stdin is a TTY — add-root is refused without an interactive human. */
|
|
25
|
+
tty: boolean;
|
|
26
|
+
}
|
|
27
|
+
export declare function addRootToWorkspace(current: Workspace, spec: RootSpec, opts: AddRootOptions): Promise<Workspace>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { usageError } from "../errors/index.js";
|
|
2
|
+
import { buildWorkspace } from "./workspace.js";
|
|
3
|
+
export async function addRootToWorkspace(current, spec, opts) {
|
|
4
|
+
if (!opts.tty) {
|
|
5
|
+
throw usageError("adding a workspace root is an interactive action and needs a TTY", ["declare it up front with `--root name=path` instead"]);
|
|
6
|
+
}
|
|
7
|
+
// Rebuild from the existing roots (as specs) + the new one, so EVERY
|
|
8
|
+
// buildWorkspace guard — existence, directory, name uniqueness, and the
|
|
9
|
+
// nested/overlap refusal — applies to the addition. Existing roots keep their
|
|
10
|
+
// names, absolute paths, and order (the primary stays first).
|
|
11
|
+
const specs = [
|
|
12
|
+
...current.roots().map((r) => ({ name: r.name, path: r.absPath })),
|
|
13
|
+
spec,
|
|
14
|
+
];
|
|
15
|
+
return buildWorkspace(specs, { cwd: opts.cwd });
|
|
16
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export type { DeclaredRoot, RootSpec } from "./types.js";
|
|
2
|
-
export { Workspace, buildWorkspace,
|
|
2
|
+
export { Workspace, buildWorkspace, sessionWorkspace } from "./workspace.js";
|
|
3
3
|
export { PathEscapeError, confineToRoot, isInside, resolveInWorkspace, } from "./resolve.js";
|
|
4
4
|
export { selectRoot } from "./select.js";
|
|
5
5
|
export type { RootRef, SelectedRoot } from "./select.js";
|
|
6
|
+
export { addRootToWorkspace, type AddRootOptions } from "./add-root.js";
|
package/dist/workspace/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
-
export { Workspace, buildWorkspace,
|
|
1
|
+
export { Workspace, buildWorkspace, sessionWorkspace } from "./workspace.js";
|
|
2
2
|
export { PathEscapeError, confineToRoot, isInside, resolveInWorkspace, } from "./resolve.js";
|
|
3
3
|
export { selectRoot } from "./select.js";
|
|
4
|
+
export { addRootToWorkspace } from "./add-root.js";
|
|
@@ -49,8 +49,13 @@ export declare function buildWorkspace(specs: readonly RootSpec[], opts?: {
|
|
|
49
49
|
cwd?: string;
|
|
50
50
|
}): Promise<Workspace>;
|
|
51
51
|
/**
|
|
52
|
-
* Build
|
|
53
|
-
*
|
|
54
|
-
*
|
|
52
|
+
* Build THE session's workspace from a single absolute path — the trivial
|
|
53
|
+
* single-root case. This is a *session construction* primitive, not a per-call
|
|
54
|
+
* fallback: after C.26 Step 6 it is called ONLY at the two CLI entry points that
|
|
55
|
+
* turn argv into a session (`cli/commands/run.ts`, `cli/onboard.ts`), never in the
|
|
56
|
+
* middle of a subsystem. Synthesizing one elsewhere would silently scope that
|
|
57
|
+
* subsystem to its own cwd while the fan tools see N roots (a split-brain) — the
|
|
58
|
+
* `no-split-brain` guard fails on any `sessionWorkspace(` outside those two files.
|
|
59
|
+
* The one root is primary and named by its basename.
|
|
55
60
|
*/
|
|
56
|
-
export declare function
|
|
61
|
+
export declare function sessionWorkspace(absPath: string): Workspace;
|
|
@@ -168,11 +168,16 @@ export async function buildWorkspace(specs, opts = {}) {
|
|
|
168
168
|
return new Workspace(declared);
|
|
169
169
|
}
|
|
170
170
|
/**
|
|
171
|
-
* Build
|
|
172
|
-
*
|
|
173
|
-
*
|
|
171
|
+
* Build THE session's workspace from a single absolute path — the trivial
|
|
172
|
+
* single-root case. This is a *session construction* primitive, not a per-call
|
|
173
|
+
* fallback: after C.26 Step 6 it is called ONLY at the two CLI entry points that
|
|
174
|
+
* turn argv into a session (`cli/commands/run.ts`, `cli/onboard.ts`), never in the
|
|
175
|
+
* middle of a subsystem. Synthesizing one elsewhere would silently scope that
|
|
176
|
+
* subsystem to its own cwd while the fan tools see N roots (a split-brain) — the
|
|
177
|
+
* `no-split-brain` guard fails on any `sessionWorkspace(` outside those two files.
|
|
178
|
+
* The one root is primary and named by its basename.
|
|
174
179
|
*/
|
|
175
|
-
export function
|
|
180
|
+
export function sessionWorkspace(absPath) {
|
|
176
181
|
const abs = path.resolve(absPath);
|
|
177
182
|
return new Workspace([
|
|
178
183
|
{ name: path.basename(abs) || "root", absPath: abs, primary: true },
|