@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.
Files changed (59) hide show
  1. package/dist/agent/loop.d.ts +21 -2
  2. package/dist/agent/loop.js +21 -5
  3. package/dist/approval/index.d.ts +1 -0
  4. package/dist/approval/index.js +1 -0
  5. package/dist/approval/mutex.d.ts +45 -0
  6. package/dist/approval/mutex.js +57 -0
  7. package/dist/checkpoint/service.d.ts +9 -0
  8. package/dist/checkpoint/service.js +20 -0
  9. package/dist/cli/commands/run.js +50 -16
  10. package/dist/cli/onboard.js +2 -2
  11. package/dist/cli/repl.js +39 -0
  12. package/dist/cli/session-factory.d.ts +23 -1
  13. package/dist/cli/session-factory.js +137 -47
  14. package/dist/config/schema.d.ts +24 -0
  15. package/dist/config/schema.js +9 -0
  16. package/dist/errors/constructors.d.ts +23 -0
  17. package/dist/errors/constructors.js +38 -0
  18. package/dist/errors/types.d.ts +8 -0
  19. package/dist/errors/types.js +12 -0
  20. package/dist/hooks/index.d.ts +1 -0
  21. package/dist/hooks/index.js +1 -0
  22. package/dist/hooks/router.d.ts +58 -0
  23. package/dist/hooks/router.js +136 -0
  24. package/dist/hooks/runner.d.ts +12 -0
  25. package/dist/hooks/runner.js +23 -1
  26. package/dist/mcp/index.d.ts +1 -0
  27. package/dist/mcp/index.js +1 -0
  28. package/dist/mcp/sibling-banner.d.ts +25 -0
  29. package/dist/mcp/sibling-banner.js +34 -0
  30. package/dist/memory/recall.d.ts +24 -0
  31. package/dist/memory/recall.js +54 -0
  32. package/dist/memory/remember-tool.d.ts +3 -0
  33. package/dist/memory/remember-tool.js +11 -1
  34. package/dist/sandbox/policy.js +14 -5
  35. package/dist/sandbox/service.d.ts +8 -1
  36. package/dist/sandbox/service.js +4 -1
  37. package/dist/subagent/index.d.ts +1 -0
  38. package/dist/subagent/index.js +1 -0
  39. package/dist/subagent/orchestrator.d.ts +67 -2
  40. package/dist/subagent/orchestrator.js +203 -18
  41. package/dist/subagent/registry-scope.d.ts +13 -0
  42. package/dist/subagent/registry-scope.js +28 -2
  43. package/dist/subagent/semaphore.d.ts +27 -0
  44. package/dist/subagent/semaphore.js +56 -0
  45. package/dist/subagent/spawn-tool.d.ts +57 -0
  46. package/dist/subagent/spawn-tool.js +104 -9
  47. package/dist/subagent/types.d.ts +17 -2
  48. package/dist/testing/run-tests-tool.js +1 -1
  49. package/dist/tools/file/paths.d.ts +5 -6
  50. package/dist/tools/file/paths.js +7 -8
  51. package/dist/tools/shell/exec.js +36 -4
  52. package/dist/tools/types.d.ts +16 -5
  53. package/dist/workspace/add-root.d.ts +27 -0
  54. package/dist/workspace/add-root.js +16 -0
  55. package/dist/workspace/index.d.ts +2 -1
  56. package/dist/workspace/index.js +2 -1
  57. package/dist/workspace/workspace.d.ts +9 -4
  58. package/dist/workspace/workspace.js +9 -4
  59. package/package.json +1 -1
@@ -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). */
@@ -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
- const blocked = await fireBeforeTool(args.hooks, ctx, call.id);
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) {
@@ -3,3 +3,4 @@ export * from "./classify.js";
3
3
  export * from "./policy.js";
4
4
  export * from "./prompt.js";
5
5
  export * from "./service.js";
6
+ export * from "./mutex.js";
@@ -3,3 +3,4 @@ export * from "./classify.js";
3
3
  export * from "./policy.js";
4
4
  export * from "./prompt.js";
5
5
  export * from "./service.js";
6
+ export * from "./mutex.js";
@@ -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);
@@ -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, singleRootWorkspace, } from "../../workspace/index.js";
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
- : singleRootWorkspace(invocationCwd);
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/MCP scope to primary this release`));
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). Built once per run: loads the
137
- // layered catalog and yields the lifecycle runner (threaded into the
138
- // session) + the resolved custom slash commands (given to the REPL).
139
- const hooksService = await buildHooksService({
140
- cwd: primaryRoot,
141
- config,
142
- interactive: Boolean(process.stdin.isTTY),
143
- logger,
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
- const session = buildAgentSession(config, apiKey, workspace, Boolean(process.stdin.isTTY), planMode, renderer, checkpoints, sandbox, hooksService.runner, mcp.tools);
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, hooksService.commands);
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
@@ -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 { singleRootWorkspace } from "../workspace/index.js";
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, singleRootWorkspace(cwd), true, false, renderer);
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).