@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/agent/loop.d.ts
CHANGED
|
@@ -5,11 +5,19 @@ import type { StreamRenderer } from "../render/index.js";
|
|
|
5
5
|
import { type Router, type TaskClass } from "../routing/index.js";
|
|
6
6
|
import type { ToolContext } from "../tools/index.js";
|
|
7
7
|
import { ToolRegistry } from "../tools/index.js";
|
|
8
|
+
/** Optional per-fire context (C.26 step 5). For tool-scoped events the loop
|
|
9
|
+
* passes the raw tool-call `input` so a multi-root {@link HookRouter} can resolve
|
|
10
|
+
* the ONE acting root and fire only that root's hooks. Session lifecycle events
|
|
11
|
+
* (`before-run`/`after-run`) carry no hint — they have no acting root and fan
|
|
12
|
+
* every trusted root. Ignored by the single-root runner. */
|
|
13
|
+
export interface HookFireHint {
|
|
14
|
+
input?: unknown;
|
|
15
|
+
}
|
|
8
16
|
/** The lifecycle-hook firing seam (C.19). Structural so the loop stays
|
|
9
17
|
* decoupled from the concrete `HookRunner`. `fire` resolves when hooks pass (or
|
|
10
18
|
* advisory ones fail) and throws `CRUXY_E_HOOK_FAILED` on a blocking failure. */
|
|
11
19
|
export interface LifecycleHookRunner {
|
|
12
|
-
fire(event: HookEvent, ctx: ToolContext): Promise<void>;
|
|
20
|
+
fire(event: HookEvent, ctx: ToolContext, hint?: HookFireHint): Promise<void>;
|
|
13
21
|
}
|
|
14
22
|
export interface RunAgentArgs {
|
|
15
23
|
/**
|
|
@@ -84,6 +92,17 @@ export interface RunAgentArgs {
|
|
|
84
92
|
tier?: string;
|
|
85
93
|
usage?: Usage;
|
|
86
94
|
}) => void;
|
|
95
|
+
/**
|
|
96
|
+
* Cooperative cancellation (C.33). When the signal aborts, the loop stops at
|
|
97
|
+
* the NEXT turn boundary and returns `stop: "aborted"` with the coherent
|
|
98
|
+
* partial history — the in-flight turn (model call + its tool executions)
|
|
99
|
+
* always completes first, exactly like a tripped {@link budget}, so overshoot
|
|
100
|
+
* is bounded by one turn. Used by the parallel orchestrator to cancel sibling
|
|
101
|
+
* subagents on a fatal failure or Ctrl-C; omitted → no cancellation (unchanged).
|
|
102
|
+
* The same signal reaches tools via `ctx.signal`, so an in-flight shell child
|
|
103
|
+
* is kill-tree'd rather than orphaned.
|
|
104
|
+
*/
|
|
105
|
+
signal?: AbortSignal;
|
|
87
106
|
}
|
|
88
107
|
/**
|
|
89
108
|
* The budget seam for {@link runAgent}: implementations track their own caps
|
|
@@ -107,7 +126,7 @@ export interface AgentResult {
|
|
|
107
126
|
/** Number of model turns consumed. */
|
|
108
127
|
iterations: number;
|
|
109
128
|
/** Why the loop ended. */
|
|
110
|
-
stop: "completed" | "max_iterations" | "budget";
|
|
129
|
+
stop: "completed" | "max_iterations" | "budget" | "aborted";
|
|
111
130
|
/** Which cap tripped, when `stop === "budget"` (from {@link LoopBudget}). */
|
|
112
131
|
stopReason?: string;
|
|
113
132
|
/** Accumulated token usage (stashed for cost tracking in C.22). */
|
package/dist/agent/loop.js
CHANGED
|
@@ -62,6 +62,19 @@ async function driveLoop(args, renderer, routed) {
|
|
|
62
62
|
});
|
|
63
63
|
let iterations = 0;
|
|
64
64
|
for (let i = 0; i < maxIterations; i++) {
|
|
65
|
+
// Cancellation check (C.33), before committing to another model turn: a
|
|
66
|
+
// signalled abort returns the history as it stands, at a clean turn boundary
|
|
67
|
+
// (the prior iteration fully resolved its tool calls). Checked ahead of the
|
|
68
|
+
// budget so a cancelled fan-out never spends one more turn's tokens.
|
|
69
|
+
if (args.signal?.aborted) {
|
|
70
|
+
return {
|
|
71
|
+
messages,
|
|
72
|
+
iterations,
|
|
73
|
+
stop: "aborted",
|
|
74
|
+
stopReason: "cancelled",
|
|
75
|
+
usage,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
65
78
|
// Budget check before committing to another model turn (C.14): a tripped
|
|
66
79
|
// cap returns the history as it stands — always at a clean turn boundary,
|
|
67
80
|
// because the previous iteration fully resolved its tool calls.
|
|
@@ -184,7 +197,10 @@ async function driveLoop(args, renderer, routed) {
|
|
|
184
197
|
// fail-closed — the tool never runs; the model is told via an error
|
|
185
198
|
// result. The hook command itself went through the U.3 gate + C.16 sandbox
|
|
186
199
|
// (same path as run_command), so a hook is never an approval bypass.
|
|
187
|
-
|
|
200
|
+
// The raw input lets a multi-root HookRouter resolve the ONE acting root
|
|
201
|
+
// (same precedence the tool uses) and fire only that root's hooks.
|
|
202
|
+
const hint = { input: call.input };
|
|
203
|
+
const blocked = await fireBeforeTool(args.hooks, ctx, call.id, hint);
|
|
188
204
|
if (blocked) {
|
|
189
205
|
renderer?.toolLifecycle({ event: "end", label, ok: false });
|
|
190
206
|
toolResults.push(blocked);
|
|
@@ -196,9 +212,9 @@ async function driveLoop(args, renderer, routed) {
|
|
|
196
212
|
// after-tool + on-file-change (C.19): fire once the action is done.
|
|
197
213
|
// Advisory by default (report, don't rewrite history); a hook explicitly
|
|
198
214
|
// marked blocking here throws and aborts the run.
|
|
199
|
-
await args.hooks?.fire("after-tool", ctx);
|
|
215
|
+
await args.hooks?.fire("after-tool", ctx, hint);
|
|
200
216
|
if (!result.is_error && FILE_MUTATING_TOOLS.has(call.name)) {
|
|
201
|
-
await args.hooks?.fire("on-file-change", ctx);
|
|
217
|
+
await args.hooks?.fire("on-file-change", ctx, hint);
|
|
202
218
|
}
|
|
203
219
|
}
|
|
204
220
|
messages.push({ role: "user", content: toolResults });
|
|
@@ -250,11 +266,11 @@ function describeToolCall(call) {
|
|
|
250
266
|
* the failure is greppable. A non-blocking (advisory) hook failure never reaches
|
|
251
267
|
* here — the runner reports it and resolves normally.
|
|
252
268
|
*/
|
|
253
|
-
async function fireBeforeTool(hooks, ctx, toolUseId) {
|
|
269
|
+
async function fireBeforeTool(hooks, ctx, toolUseId, hint) {
|
|
254
270
|
if (!hooks)
|
|
255
271
|
return null;
|
|
256
272
|
try {
|
|
257
|
-
await hooks.fire("before-tool", ctx);
|
|
273
|
+
await hooks.fire("before-tool", ctx, hint);
|
|
258
274
|
return null;
|
|
259
275
|
}
|
|
260
276
|
catch (err) {
|
package/dist/approval/index.d.ts
CHANGED
package/dist/approval/index.js
CHANGED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ApprovalDecision } from "./types.js";
|
|
2
|
+
import type { ApproveAction } from "../tools/types.js";
|
|
3
|
+
/**
|
|
4
|
+
* The approval mutex (C.33, JC-C) — the spine of the concurrent-subagent safety
|
|
5
|
+
* model. Node is single-threaded, so the only hazard between parallel subagents
|
|
6
|
+
* is interleaving at `await` boundaries; the one resource they genuinely contend
|
|
7
|
+
* for is the interactive terminal (one prompt at a time) and the run's shared
|
|
8
|
+
* checkpoint state (one snapshot/set-write at a time). Serializing every
|
|
9
|
+
* *gated write* through this one lock resolves BOTH with a single mechanism:
|
|
10
|
+
*
|
|
11
|
+
* • **one prompt at a time** — a child blocked awaiting the user's keypress
|
|
12
|
+
* holds the lock, so no sibling can paint a second prompt over it (the
|
|
13
|
+
* keypress is always attributable to the one displayed prompt); and
|
|
14
|
+
* • **serialized gated writes** — the checkpoint hook (snapshot + per-root set
|
|
15
|
+
* member write) runs inside the same critical section, so two concurrent
|
|
16
|
+
* writes to disjoint roots can never race on `ensureCheckpoint`'s latch or
|
|
17
|
+
* the set manifest.
|
|
18
|
+
*
|
|
19
|
+
* It is a plain promise-chain serializer: `runExclusive(fn)` runs `fn` only
|
|
20
|
+
* after every previously-enqueued `fn` has settled. It is a LEAF lock — nothing
|
|
21
|
+
* is acquired while holding it except the terminal and the filesystem, neither
|
|
22
|
+
* of which waits on a subagent resource — so it cannot take part in a cycle
|
|
23
|
+
* (see the deadlock argument in the C.33 design doc).
|
|
24
|
+
*/
|
|
25
|
+
export declare class ApprovalMutex {
|
|
26
|
+
/** The settled-marker chain: always resolves (never rejects), so a rejecting
|
|
27
|
+
* critical section never wedges the queue for the next waiter. */
|
|
28
|
+
private tail;
|
|
29
|
+
/** Run `fn` in mutual exclusion with every other `runExclusive` on this mutex. */
|
|
30
|
+
runExclusive<T>(fn: () => Promise<T>): Promise<T>;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Wrap a fully-built gate (`ApprovalService.requestApproval` behind the C.32
|
|
34
|
+
* checkpoint hook) so that every gated action serializes through `mutex`.
|
|
35
|
+
*
|
|
36
|
+
* Every action that actually reaches the gate is a *mutation* — read-only tools
|
|
37
|
+
* never call `requestApproval` at all (see the ToolContext contract), so a
|
|
38
|
+
* parallel READ fan-out is already free of the lock and never stalls behind a
|
|
39
|
+
* sibling's pending prompt. The `read`-tier short-circuit below is therefore
|
|
40
|
+
* defensive belt-and-suspenders (mirroring `ApprovalService`'s own read check):
|
|
41
|
+
* if a read-classified action ever did flow here, it would bypass the spine
|
|
42
|
+
* rather than needlessly hold it. What the mutex serializes in practice is the
|
|
43
|
+
* mutating set — exactly the prompt + checkpoint work that must be one-at-a-time.
|
|
44
|
+
*/
|
|
45
|
+
export declare function serializeGate(gate: (action: ApproveAction) => Promise<ApprovalDecision>, mutex: ApprovalMutex, cwd: string): (action: ApproveAction) => Promise<ApprovalDecision>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { classify } from "./classify.js";
|
|
2
|
+
/**
|
|
3
|
+
* The approval mutex (C.33, JC-C) — the spine of the concurrent-subagent safety
|
|
4
|
+
* model. Node is single-threaded, so the only hazard between parallel subagents
|
|
5
|
+
* is interleaving at `await` boundaries; the one resource they genuinely contend
|
|
6
|
+
* for is the interactive terminal (one prompt at a time) and the run's shared
|
|
7
|
+
* checkpoint state (one snapshot/set-write at a time). Serializing every
|
|
8
|
+
* *gated write* through this one lock resolves BOTH with a single mechanism:
|
|
9
|
+
*
|
|
10
|
+
* • **one prompt at a time** — a child blocked awaiting the user's keypress
|
|
11
|
+
* holds the lock, so no sibling can paint a second prompt over it (the
|
|
12
|
+
* keypress is always attributable to the one displayed prompt); and
|
|
13
|
+
* • **serialized gated writes** — the checkpoint hook (snapshot + per-root set
|
|
14
|
+
* member write) runs inside the same critical section, so two concurrent
|
|
15
|
+
* writes to disjoint roots can never race on `ensureCheckpoint`'s latch or
|
|
16
|
+
* the set manifest.
|
|
17
|
+
*
|
|
18
|
+
* It is a plain promise-chain serializer: `runExclusive(fn)` runs `fn` only
|
|
19
|
+
* after every previously-enqueued `fn` has settled. It is a LEAF lock — nothing
|
|
20
|
+
* is acquired while holding it except the terminal and the filesystem, neither
|
|
21
|
+
* of which waits on a subagent resource — so it cannot take part in a cycle
|
|
22
|
+
* (see the deadlock argument in the C.33 design doc).
|
|
23
|
+
*/
|
|
24
|
+
export class ApprovalMutex {
|
|
25
|
+
/** The settled-marker chain: always resolves (never rejects), so a rejecting
|
|
26
|
+
* critical section never wedges the queue for the next waiter. */
|
|
27
|
+
tail = Promise.resolve();
|
|
28
|
+
/** Run `fn` in mutual exclusion with every other `runExclusive` on this mutex. */
|
|
29
|
+
runExclusive(fn) {
|
|
30
|
+
const result = this.tail.then(fn);
|
|
31
|
+
// Advance the chain on a branch that swallows the outcome, so the caller
|
|
32
|
+
// still observes `fn`'s rejection while the next waiter is not poisoned.
|
|
33
|
+
this.tail = result.then(() => undefined, () => undefined);
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Wrap a fully-built gate (`ApprovalService.requestApproval` behind the C.32
|
|
39
|
+
* checkpoint hook) so that every gated action serializes through `mutex`.
|
|
40
|
+
*
|
|
41
|
+
* Every action that actually reaches the gate is a *mutation* — read-only tools
|
|
42
|
+
* never call `requestApproval` at all (see the ToolContext contract), so a
|
|
43
|
+
* parallel READ fan-out is already free of the lock and never stalls behind a
|
|
44
|
+
* sibling's pending prompt. The `read`-tier short-circuit below is therefore
|
|
45
|
+
* defensive belt-and-suspenders (mirroring `ApprovalService`'s own read check):
|
|
46
|
+
* if a read-classified action ever did flow here, it would bypass the spine
|
|
47
|
+
* rather than needlessly hold it. What the mutex serializes in practice is the
|
|
48
|
+
* mutating set — exactly the prompt + checkpoint work that must be one-at-a-time.
|
|
49
|
+
*/
|
|
50
|
+
export function serializeGate(gate, mutex, cwd) {
|
|
51
|
+
return (action) => {
|
|
52
|
+
// Same classifier the gate uses; read tier contends for nothing → no lock.
|
|
53
|
+
if (classify(action, cwd).tier === "read")
|
|
54
|
+
return gate(action);
|
|
55
|
+
return mutex.runExclusive(() => gate(action));
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -48,6 +48,14 @@ export declare class CheckpointService {
|
|
|
48
48
|
private readonly pinnedStore?;
|
|
49
49
|
private runSummary;
|
|
50
50
|
private active;
|
|
51
|
+
/** In-flight `ensureCheckpoint` construction (C.33). PROMISE-latched, not
|
|
52
|
+
* value-latched: two concurrent gated writes to this root await the SAME
|
|
53
|
+
* snapshot instead of each taking one (the once-per-run latch `this.active`
|
|
54
|
+
* is only set AFTER several awaits, so a boolean/value latch would let a
|
|
55
|
+
* second caller slip through and double-snapshot). The approval mutex already
|
|
56
|
+
* serializes gated writes, so this is defense-in-depth — but it makes the
|
|
57
|
+
* service correct on its own, independent of the caller's discipline. */
|
|
58
|
+
private pending;
|
|
51
59
|
constructor(opts: CheckpointServiceOptions);
|
|
52
60
|
/** Start a new undo unit: reset the once-per-run latch and name the run. */
|
|
53
61
|
beginRun(summary: string): void;
|
|
@@ -59,6 +67,7 @@ export declare class CheckpointService {
|
|
|
59
67
|
* either substrate, the run must not mutate without its undo protection.
|
|
60
68
|
*/
|
|
61
69
|
ensureCheckpoint(): Promise<Checkpoint | null>;
|
|
70
|
+
private buildCheckpoint;
|
|
62
71
|
/** Attribute mutated paths to the current run (persisted for later rollback). */
|
|
63
72
|
recordTouched(absPaths: string[]): Promise<void>;
|
|
64
73
|
/** The run ran a shell command: per-path attribution is no longer possible. */
|
|
@@ -36,6 +36,14 @@ export class CheckpointService {
|
|
|
36
36
|
pinnedStore;
|
|
37
37
|
runSummary = "agent run";
|
|
38
38
|
active = null;
|
|
39
|
+
/** In-flight `ensureCheckpoint` construction (C.33). PROMISE-latched, not
|
|
40
|
+
* value-latched: two concurrent gated writes to this root await the SAME
|
|
41
|
+
* snapshot instead of each taking one (the once-per-run latch `this.active`
|
|
42
|
+
* is only set AFTER several awaits, so a boolean/value latch would let a
|
|
43
|
+
* second caller slip through and double-snapshot). The approval mutex already
|
|
44
|
+
* serializes gated writes, so this is defense-in-depth — but it makes the
|
|
45
|
+
* service correct on its own, independent of the caller's discipline. */
|
|
46
|
+
pending = null;
|
|
39
47
|
constructor(opts) {
|
|
40
48
|
this.root = path.resolve(opts.root);
|
|
41
49
|
this.config = opts.config;
|
|
@@ -44,6 +52,7 @@ export class CheckpointService {
|
|
|
44
52
|
/** Start a new undo unit: reset the once-per-run latch and name the run. */
|
|
45
53
|
beginRun(summary) {
|
|
46
54
|
this.active = null;
|
|
55
|
+
this.pending = null;
|
|
47
56
|
const firstLine = summary.split("\n", 1)[0].trim();
|
|
48
57
|
this.runSummary =
|
|
49
58
|
firstLine.length > 80
|
|
@@ -62,6 +71,17 @@ export class CheckpointService {
|
|
|
62
71
|
return null;
|
|
63
72
|
if (this.active)
|
|
64
73
|
return this.active;
|
|
74
|
+
// Coalesce concurrent first-use: a second caller awaits the first's snapshot
|
|
75
|
+
// rather than starting a second one. Cleared on settle so a FAILED attempt
|
|
76
|
+
// (which leaves `this.active` null) lets the next call retry.
|
|
77
|
+
if (this.pending)
|
|
78
|
+
return this.pending;
|
|
79
|
+
this.pending = this.buildCheckpoint().finally(() => {
|
|
80
|
+
this.pending = null;
|
|
81
|
+
});
|
|
82
|
+
return this.pending;
|
|
83
|
+
}
|
|
84
|
+
async buildCheckpoint() {
|
|
65
85
|
const gitWorkTree = this.pinnedStore
|
|
66
86
|
? this.pinnedStore.kind === "git"
|
|
67
87
|
: isGitWorkTree(this.root);
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -7,14 +7,14 @@ import { themeForColor } from "../../theme/index.js";
|
|
|
7
7
|
import { summarizeRuns, renderSummary, } from "../../usage/index.js";
|
|
8
8
|
import { CheckpointGate } from "../../checkpoint/index.js";
|
|
9
9
|
import { SandboxService } from "../../sandbox/index.js";
|
|
10
|
-
import { buildHooksService } from "../../hooks/index.js";
|
|
10
|
+
import { buildHooksService, buildHooksRouter } from "../../hooks/index.js";
|
|
11
11
|
import { runInteractive } from "../repl.js";
|
|
12
12
|
import { buildAgentSession } from "../session-factory.js";
|
|
13
13
|
import { apiKeyEnvVar, maybeRunOnboarding } from "../onboard.js";
|
|
14
14
|
import { resetLspServices } from "../../lsp/index.js";
|
|
15
|
-
import { connectMcpTools, resetMcpServices } from "../../mcp/index.js";
|
|
15
|
+
import { connectMcpTools, deferredSiblingServers, resetMcpServices, } from "../../mcp/index.js";
|
|
16
16
|
import { defaultPromptIO } from "../../approval/index.js";
|
|
17
|
-
import { buildWorkspace,
|
|
17
|
+
import { buildWorkspace, sessionWorkspace, } from "../../workspace/index.js";
|
|
18
18
|
/**
|
|
19
19
|
* Parse one repeatable `--root` value into a {@link RootSpec} and append it. Form
|
|
20
20
|
* `name=path` (explicit name) or a bare `path` (basename-named by buildWorkspace).
|
|
@@ -59,7 +59,7 @@ export function runCommand() {
|
|
|
59
59
|
// onboarding or the session starts, never a half-built session.
|
|
60
60
|
const workspace = opts.root.length
|
|
61
61
|
? await buildWorkspace(opts.root, { cwd: invocationCwd })
|
|
62
|
-
:
|
|
62
|
+
: sessionWorkspace(invocationCwd);
|
|
63
63
|
const primaryRoot = workspace.primary().absPath;
|
|
64
64
|
logger.info(t.muted(`model: ${config.model.provider}/${config.model.model}`));
|
|
65
65
|
logger.info(t.muted(`config: ${sources.project ?? sources.global ?? "defaults"}`));
|
|
@@ -78,7 +78,7 @@ export function runCommand() {
|
|
|
78
78
|
? "writes fan all roots (each checkpointed)"
|
|
79
79
|
: "writes scope to primary (checkpoints disabled)";
|
|
80
80
|
logger.info(t.muted(`roots: ${workspace.roots().length} (${names}); primary ${workspace.primary().name} — ` +
|
|
81
|
-
`reads fan all roots; ${writes}; hooks
|
|
81
|
+
`reads fan all roots; ${writes}; hooks fan per trusted root; MCP loads from primary only this release`));
|
|
82
82
|
}
|
|
83
83
|
// First-run with no key (and a TTY) → guided onboarding instead of the
|
|
84
84
|
// dead-end auth error. The first-win demo is offered only in the no-prompt
|
|
@@ -127,21 +127,46 @@ export function runCommand() {
|
|
|
127
127
|
? await SandboxService.create({
|
|
128
128
|
config,
|
|
129
129
|
cwd: primaryRoot,
|
|
130
|
+
// R5 (C.26): the command's root (primary — shell/test attribute to
|
|
131
|
+
// it this release) mounts read-write; every OTHER declared root
|
|
132
|
+
// mounts read-only. Cross-root write is only ever granted per-root
|
|
133
|
+
// via a named escalation, never blanket.
|
|
134
|
+
siblingRoots: workspace
|
|
135
|
+
.roots()
|
|
136
|
+
.filter((r) => !r.primary)
|
|
137
|
+
.map((r) => r.absPath),
|
|
130
138
|
reporter: renderer,
|
|
131
139
|
})
|
|
132
140
|
: undefined;
|
|
133
141
|
if (sandbox) {
|
|
134
142
|
logger.info(t.muted(`sandbox: ${sandbox.runtimeName} (network ${config.sandbox.network})`));
|
|
135
143
|
}
|
|
136
|
-
// Hooks + custom slash commands (C.19).
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
144
|
+
// Hooks + custom slash commands (C.19). Single-root uses the unchanged
|
|
145
|
+
// service (byte-identical: one runner, lazy trust prompt). Multi-root
|
|
146
|
+
// (C.26 step 5) builds ONE runner per root behind a HookRouter — tool
|
|
147
|
+
// events fire the acting root's hooks, lifecycle events fan every trusted
|
|
148
|
+
// root. Untrusted roots are skipped and NAMED here (never a silent loss).
|
|
149
|
+
let hooksRunner;
|
|
150
|
+
let hookCommands;
|
|
151
|
+
if (workspace.isMultiRoot) {
|
|
152
|
+
const router = await buildHooksRouter({ workspace, config, logger });
|
|
153
|
+
hooksRunner = router.runner;
|
|
154
|
+
hookCommands = router.commands;
|
|
155
|
+
for (const u of router.untrustedHookRoots) {
|
|
156
|
+
logger.info(t.muted(`hooks: root "${u.name}" has ${u.count} untrusted hook${u.count === 1 ? "" : "s"} — ` +
|
|
157
|
+
`not firing (run \`cruxy hooks trust ${u.absPath}\`)`));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
const hooksService = await buildHooksService({
|
|
162
|
+
cwd: primaryRoot,
|
|
163
|
+
config,
|
|
164
|
+
interactive: Boolean(process.stdin.isTTY),
|
|
165
|
+
logger,
|
|
166
|
+
});
|
|
167
|
+
hooksRunner = hooksService.runner;
|
|
168
|
+
hookCommands = hooksService.commands;
|
|
169
|
+
}
|
|
145
170
|
// MCP servers (C.27): connect + trust-gate BEFORE building the session so
|
|
146
171
|
// the tool catalogue is complete when the model first runs. Off by
|
|
147
172
|
// default (no servers connect). A non-interactive run with an untrusted
|
|
@@ -153,10 +178,19 @@ export function runCommand() {
|
|
|
153
178
|
interactive: Boolean(process.stdin.isTTY),
|
|
154
179
|
io: defaultPromptIO(shouldUseColor()),
|
|
155
180
|
});
|
|
156
|
-
|
|
181
|
+
// MCP is primary-root only this release (JC-D). Name each sibling-root
|
|
182
|
+
// server individually — never one generic line, never a silent drop —
|
|
183
|
+
// so a user who declared `github` in a sibling knows exactly why its
|
|
184
|
+
// tools aren't present (and that root-qualified names are a follow-up).
|
|
185
|
+
if (workspace.isMultiRoot) {
|
|
186
|
+
for (const d of deferredSiblingServers(workspace)) {
|
|
187
|
+
logger.info(t.muted(`mcp: server "${d.server}" declared in ${d.root} not loaded (primary-root MCP only this release)`));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const session = buildAgentSession(config, apiKey, workspace, Boolean(process.stdin.isTTY), planMode, renderer, checkpoints, sandbox, hooksRunner, mcp.tools);
|
|
157
191
|
if (interactive) {
|
|
158
192
|
try {
|
|
159
|
-
await runInteractive(session, undefined, renderer, checkpoints,
|
|
193
|
+
await runInteractive(session, undefined, renderer, checkpoints, hookCommands);
|
|
160
194
|
}
|
|
161
195
|
finally {
|
|
162
196
|
// LSP (C.12) + MCP (C.27): gracefully shut down any external server
|
package/dist/cli/onboard.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { resolveApiKey } from "../config/index.js";
|
|
2
2
|
import { createDefaultDeps, defaultOnboardingIO, isFirstRun, runOnboarding, } from "../onboarding/index.js";
|
|
3
3
|
import { createRenderer } from "../render/index.js";
|
|
4
|
-
import {
|
|
4
|
+
import { sessionWorkspace } from "../workspace/index.js";
|
|
5
5
|
import { buildAgentSession } from "./session-factory.js";
|
|
6
6
|
/**
|
|
7
7
|
* CLI-layer glue between the entry points and the onboarding module (U.6). Keeps
|
|
@@ -27,7 +27,7 @@ export async function runFirstWinTask(config, cwd, prompt) {
|
|
|
27
27
|
const renderer = createRenderer();
|
|
28
28
|
// The onboarding first-win is inherently single-root (it runs before any
|
|
29
29
|
// `--root` is parsed), so it acts over a trivial workspace on its cwd.
|
|
30
|
-
const session = buildAgentSession(config, apiKey,
|
|
30
|
+
const session = buildAgentSession(config, apiKey, sessionWorkspace(cwd), true, false, renderer);
|
|
31
31
|
try {
|
|
32
32
|
await session.send(prompt, renderer);
|
|
33
33
|
}
|
package/dist/cli/repl.js
CHANGED
|
@@ -2,6 +2,7 @@ import readline from "node:readline";
|
|
|
2
2
|
import { makeReplCompleter } from "../components/index.js";
|
|
3
3
|
import { resolveSlash } from "../hooks/index.js";
|
|
4
4
|
import { runGatedShell } from "../tools/shell/exec.js";
|
|
5
|
+
import { addRootToWorkspace } from "../workspace/index.js";
|
|
5
6
|
import { themeForColor } from "../theme/index.js";
|
|
6
7
|
import { formatError, fromUnknown, isVerbose, shouldUseColor, } from "../errors/index.js";
|
|
7
8
|
import { createRenderer } from "../render/index.js";
|
|
@@ -113,6 +114,40 @@ function printReplError(err) {
|
|
|
113
114
|
color: shouldUseColor(process.stdout),
|
|
114
115
|
}));
|
|
115
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* `/add-root <name> <path>` (C.26 step 5) — an explicit, human-only way to
|
|
119
|
+
* declare another workspace root. This is a REPL command, NOT a model tool: the
|
|
120
|
+
* model can only reach the tool registry, and nothing named `add_root` is
|
|
121
|
+
* registered there, so the allowlist argument is unchanged. It validates the
|
|
122
|
+
* addition (TTY-only; same existence/name/overlap refusal as `--root`) and, on
|
|
123
|
+
* success, tells the user to relaunch with `--root` to activate it — a
|
|
124
|
+
* mid-session hot-swap would leave the checkpoint gate + hook router (both wired
|
|
125
|
+
* from the session-start workspace) half-attributed, so activation is deferred
|
|
126
|
+
* to a clean relaunch. A newly declared root always starts untrusted.
|
|
127
|
+
*/
|
|
128
|
+
async function handleAddRoot(input, session) {
|
|
129
|
+
const parts = input
|
|
130
|
+
.slice("/add-root".length)
|
|
131
|
+
.trim()
|
|
132
|
+
.split(/\s+/)
|
|
133
|
+
.filter(Boolean);
|
|
134
|
+
if (parts.length !== 2) {
|
|
135
|
+
logger.print(theme.muted("usage: /add-root <name> <path>"));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const [name, rootPath] = parts;
|
|
139
|
+
const ctx = session.toolContext;
|
|
140
|
+
const current = ctx.workspace;
|
|
141
|
+
try {
|
|
142
|
+
const next = await addRootToWorkspace(current, { name, path: rootPath }, { cwd: ctx.cwd, tty: Boolean(process.stdin.isTTY) });
|
|
143
|
+
const abs = next.rootByName(name).absPath;
|
|
144
|
+
logger.print(theme.muted(`validated root "${name}" (${abs}). relaunch with \`--root ${name}=${rootPath}\` to activate it — ` +
|
|
145
|
+
`it starts untrusted (its hooks and project memory stay inert until you run \`cruxy hooks/memory trust\`).`));
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
printReplError(err);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
116
151
|
/**
|
|
117
152
|
* Drive an interactive multi-turn session: prompt, read a line, dispatch slash
|
|
118
153
|
* commands or run a turn, repeat. Assistant text and tool-call progress stream
|
|
@@ -182,6 +217,10 @@ async function replLoop(session, io, renderer, checkpoints, slashCommands = [])
|
|
|
182
217
|
logger.print(HELP);
|
|
183
218
|
continue;
|
|
184
219
|
}
|
|
220
|
+
if (trimmed === "/add-root" || trimmed.startsWith("/add-root ")) {
|
|
221
|
+
await handleAddRoot(trimmed, session);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
185
224
|
// Custom slash commands (C.19) — consulted AFTER builtins, so a custom
|
|
186
225
|
// command can never shadow /help, /exit, etc. A `prompt` command expands to
|
|
187
226
|
// text fed to the agent (safe); a `shell` command runs through the SAME
|
|
@@ -3,7 +3,7 @@ import type { ApprovalDecision } from "../approval/index.js";
|
|
|
3
3
|
import type { CheckpointGate } from "../checkpoint/index.js";
|
|
4
4
|
import type { SandboxService } from "../sandbox/index.js";
|
|
5
5
|
import type { StreamRenderer } from "../render/index.js";
|
|
6
|
-
import { type ApproveAction, type Tool } from "../tools/index.js";
|
|
6
|
+
import { ToolRegistry, type ApproveAction, type Tool } from "../tools/index.js";
|
|
7
7
|
import { Session, type LifecycleHookRunner } from "../agent/index.js";
|
|
8
8
|
import type { Workspace } from "../workspace/index.js";
|
|
9
9
|
/**
|
|
@@ -16,6 +16,28 @@ import type { Workspace } from "../workspace/index.js";
|
|
|
16
16
|
* external-change detection.
|
|
17
17
|
*/
|
|
18
18
|
export declare function withCheckpointGate(requestApproval: (action: ApproveAction) => Promise<ApprovalDecision>, gate: CheckpointGate | undefined, ws: Workspace): (action: ApproveAction) => Promise<ApprovalDecision>;
|
|
19
|
+
/**
|
|
20
|
+
* Register every CONDITIONALLY-enabled runtime tool onto `registry`, in the fixed
|
|
21
|
+
* order the model sees them: `remember` (memory), the four LSP tools, the two web
|
|
22
|
+
* tools, the trusted MCP tools, then `spawn_subagent`. Factored out of
|
|
23
|
+
* {@link buildAgentSession} for ONE reason beyond tidiness: it makes the *complete
|
|
24
|
+
* runtime* tool set enumerable by a test (JC-B). The default registry only covers
|
|
25
|
+
* the 14 always-on tools; the allowlist test that pins "no tool can add a workspace
|
|
26
|
+
* root" is only sound if it runs against the SAME set the session ships — so the
|
|
27
|
+
* session factory and that test both build the surface through this one seam. A new
|
|
28
|
+
* conditionally-registered tool added here fails the allowlist test until it is
|
|
29
|
+
* consciously listed and audited.
|
|
30
|
+
*
|
|
31
|
+
* Each family is opt-in and inert when its feature flag is off, exactly as before —
|
|
32
|
+
* this function is a pure move of the inline registration, same guards, same order.
|
|
33
|
+
* `spawnTool` is passed in (already depth-bound) because it needs the orchestrator;
|
|
34
|
+
* it registers only when nesting is allowed (`subagent.maxDepth > 0`).
|
|
35
|
+
*/
|
|
36
|
+
export declare function registerRuntimeTools(registry: ToolRegistry, config: CruxyConfig, opts?: {
|
|
37
|
+
mcpTools?: Tool[];
|
|
38
|
+
spawnTool?: Tool;
|
|
39
|
+
spawnManyTool?: Tool;
|
|
40
|
+
}): void;
|
|
19
41
|
/**
|
|
20
42
|
* Build a ready-to-run agent {@link Session} from a resolved key — the wiring
|
|
21
43
|
* shared by `cruxy run` and the onboarding first-win task (so they can't drift).
|