@cruxy/cli 0.8.0 → 0.9.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 (66) hide show
  1. package/README.md +40 -13
  2. package/dist/agent/loop.d.ts +28 -1
  3. package/dist/agent/loop.js +36 -4
  4. package/dist/agent/prompts.d.ts +2 -0
  5. package/dist/agent/prompts.js +8 -0
  6. package/dist/approval/classify.js +26 -0
  7. package/dist/checkpoint/capture.d.ts +17 -0
  8. package/dist/checkpoint/capture.js +73 -0
  9. package/dist/checkpoint/git-store.d.ts +61 -0
  10. package/dist/checkpoint/git-store.js +171 -0
  11. package/dist/checkpoint/index.d.ts +6 -0
  12. package/dist/checkpoint/index.js +6 -0
  13. package/dist/checkpoint/restore.d.ts +23 -0
  14. package/dist/checkpoint/restore.js +195 -0
  15. package/dist/checkpoint/service.d.ts +80 -0
  16. package/dist/checkpoint/service.js +276 -0
  17. package/dist/checkpoint/shadow-store.d.ts +23 -0
  18. package/dist/checkpoint/shadow-store.js +93 -0
  19. package/dist/checkpoint/types.d.ts +117 -0
  20. package/dist/checkpoint/types.js +18 -0
  21. package/dist/cli/commands/checkpoint.d.ts +7 -0
  22. package/dist/cli/commands/checkpoint.js +31 -0
  23. package/dist/cli/commands/rollback.d.ts +10 -0
  24. package/dist/cli/commands/rollback.js +51 -0
  25. package/dist/cli/commands/run.js +10 -2
  26. package/dist/cli/program.js +4 -0
  27. package/dist/cli/repl.d.ts +2 -1
  28. package/dist/cli/repl.js +6 -3
  29. package/dist/cli/session-factory.d.ts +14 -1
  30. package/dist/cli/session-factory.js +87 -22
  31. package/dist/config/schema.d.ts +133 -0
  32. package/dist/config/schema.js +40 -0
  33. package/dist/errors/constructors.d.ts +25 -0
  34. package/dist/errors/constructors.js +86 -0
  35. package/dist/errors/types.d.ts +7 -0
  36. package/dist/errors/types.js +16 -0
  37. package/dist/indexing/walker.d.ts +11 -0
  38. package/dist/indexing/walker.js +11 -6
  39. package/dist/plan/execute.d.ts +8 -0
  40. package/dist/plan/execute.js +36 -22
  41. package/dist/plan/service.js +5 -1
  42. package/dist/plan/submit-plan.d.ts +4 -4
  43. package/dist/render/diff.js +27 -0
  44. package/dist/render/index.d.ts +2 -1
  45. package/dist/render/index.js +1 -0
  46. package/dist/render/plain-renderer.d.ts +7 -1
  47. package/dist/render/plain-renderer.js +26 -0
  48. package/dist/render/state.d.ts +31 -0
  49. package/dist/render/state.js +83 -0
  50. package/dist/render/tty-renderer.d.ts +41 -5
  51. package/dist/render/tty-renderer.js +150 -23
  52. package/dist/render/types.d.ts +85 -1
  53. package/dist/subagent/budget.d.ts +34 -0
  54. package/dist/subagent/budget.js +57 -0
  55. package/dist/subagent/index.d.ts +5 -0
  56. package/dist/subagent/index.js +5 -0
  57. package/dist/subagent/orchestrator.d.ts +67 -0
  58. package/dist/subagent/orchestrator.js +241 -0
  59. package/dist/subagent/registry-scope.d.ts +28 -0
  60. package/dist/subagent/registry-scope.js +63 -0
  61. package/dist/subagent/spawn-tool.d.ts +29 -0
  62. package/dist/subagent/spawn-tool.js +94 -0
  63. package/dist/subagent/types.d.ts +55 -0
  64. package/dist/subagent/types.js +1 -0
  65. package/dist/tools/types.d.ts +20 -2
  66. package/package.json +1 -1
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The subagent budget (C.14): iteration + token + optional wall-clock caps,
3
+ * checked by the agent loop before every model turn (see `LoopBudget`). A
4
+ * tripped cap stops the run with a human-readable reason — the subagent
5
+ * returns a partial result, it never runs unbounded.
6
+ */
7
+ /**
8
+ * Resolve the effective limits for one spawn: start from the configured
9
+ * ceilings and let overrides only *narrow* them. A request above a ceiling is
10
+ * clamped down, not honored — "budget overrides within limits" by construction.
11
+ */
12
+ export function resolveBudget(defaults, overrides) {
13
+ const clamp = (ceiling, requested) => requested !== undefined && requested > 0
14
+ ? Math.min(ceiling, requested)
15
+ : ceiling;
16
+ const timeoutMs = defaults.timeoutMs !== undefined
17
+ ? clamp(defaults.timeoutMs, overrides?.timeoutMs)
18
+ : overrides?.timeoutMs;
19
+ return {
20
+ maxIterations: clamp(defaults.maxIterations, overrides?.maxIterations),
21
+ maxTokens: clamp(defaults.maxTokens, overrides?.maxTokens),
22
+ ...(timeoutMs !== undefined && timeoutMs > 0 ? { timeoutMs } : {}),
23
+ };
24
+ }
25
+ /**
26
+ * A live budget for one subagent run. The wall clock starts at construction
27
+ * (spawn time); the clock source is injectable so tests never sleep.
28
+ */
29
+ export class Budget {
30
+ limits;
31
+ now;
32
+ startedAt;
33
+ constructor(limits, now = Date.now) {
34
+ this.limits = limits;
35
+ this.now = now;
36
+ this.startedAt = now();
37
+ }
38
+ /**
39
+ * The reason to stop before the next model turn, or `null` to continue.
40
+ * Checked at iteration boundaries — the in-flight turn always completes, so
41
+ * overshoot is bounded by one turn.
42
+ */
43
+ exceeded(state) {
44
+ const { maxIterations, maxTokens, timeoutMs } = this.limits;
45
+ if (state.iterations >= maxIterations) {
46
+ return `iteration cap reached (${maxIterations})`;
47
+ }
48
+ const tokens = state.usage.input_tokens + state.usage.output_tokens;
49
+ if (tokens >= maxTokens) {
50
+ return `token cap reached (${tokens} of ${maxTokens})`;
51
+ }
52
+ if (timeoutMs !== undefined && this.now() - this.startedAt >= timeoutMs) {
53
+ return `time cap reached (${timeoutMs}ms)`;
54
+ }
55
+ return null;
56
+ }
57
+ }
@@ -0,0 +1,5 @@
1
+ export * from "./types.js";
2
+ export * from "./budget.js";
3
+ export * from "./registry-scope.js";
4
+ export * from "./orchestrator.js";
5
+ export * from "./spawn-tool.js";
@@ -0,0 +1,5 @@
1
+ export * from "./types.js";
2
+ export * from "./budget.js";
3
+ export * from "./registry-scope.js";
4
+ export * from "./orchestrator.js";
5
+ export * from "./spawn-tool.js";
@@ -0,0 +1,67 @@
1
+ import type { Provider } from "@cruxy/sdk";
2
+ import type { ApprovalDecision } from "../approval/types.js";
3
+ import type { CruxyConfig } from "../config/index.js";
4
+ import type { StreamRenderer } from "../render/index.js";
5
+ import type { ApproveAction, ToolContext, ToolRegistry } from "../tools/index.js";
6
+ import type { SubagentResult, SubagentSpec } from "./types.js";
7
+ /**
8
+ * Everything a spawn needs from the surrounding session, injected by the
9
+ * session factory so this package stays free of wiring detail. Shared members
10
+ * are stateless (provider, config, logger) or deliberately run-scoped (the
11
+ * renderer, and — via `makeChildApproval` — the checkpoint gate); everything
12
+ * stateful (history, registry, allowlist, budget) is built fresh per spawn.
13
+ */
14
+ export interface SubagentOrchestratorDeps {
15
+ provider: Provider;
16
+ config: CruxyConfig;
17
+ /** The parent's registry — the ceiling every child scope derives from. */
18
+ parentRegistry: ToolRegistry;
19
+ cwd: string;
20
+ logger: ToolContext["logger"];
21
+ git?: {
22
+ branch: string;
23
+ dirty: boolean;
24
+ } | null;
25
+ projectInstructions?: string | null;
26
+ renderer?: StreamRenderer;
27
+ /**
28
+ * Build a fresh, fully-wrapped approval gate for one child run: a NEW
29
+ * `ApprovalService` (so the child gets its own empty session allowlist —
30
+ * parent grants never silently widen child authority) behind the same
31
+ * checkpoint hook as the parent (so child mutations land under the run's
32
+ * one checkpoint, C.32).
33
+ */
34
+ makeChildApproval(): (action: ApproveAction) => Promise<ApprovalDecision>;
35
+ }
36
+ /**
37
+ * Spawns subagents (C.14): the existing agent loop re-driven over isolated
38
+ * state. Each spawn builds a fresh history seeded with only the task, a
39
+ * scoped-down registry, a fresh approval gate, and a hard budget, runs
40
+ * `runAgent` to completion (or cap), and folds the outcome into a compact
41
+ * {@link SubagentResult}. The child transcript is dropped here — the parent
42
+ * never sees it.
43
+ *
44
+ * Spawns are sequential by construction: the loop awaits each tool call, so
45
+ * two spawn requests in one turn run one after the other in deterministic
46
+ * order. Parallel fan-out is C.33's job — `spawn` is a self-contained async
47
+ * unit with no cross-spawn state, so it slots in without reshaping this class.
48
+ */
49
+ export declare class SubagentOrchestrator {
50
+ private readonly deps;
51
+ constructor(deps: SubagentOrchestratorDeps);
52
+ /**
53
+ * Run one subagent to completion. `parentDepth` is the spawner's depth (the
54
+ * main agent is 0); spawning past `subagent.maxDepth` throws
55
+ * `CRUXY_E_SUBAGENT_DEPTH_EXCEEDED` — the spawn tool is also structurally
56
+ * withheld at the cap, so this throw is the fail-loud backstop.
57
+ *
58
+ * Never rejects on a *child* failure — provider or tool crashes come back as
59
+ * `status: "failed"` for the parent to reason over. The two exceptions that
60
+ * do propagate: the depth cap (above) and `CRUXY_E_APPROVAL_REQUIRED`
61
+ * (non-interactive default-deny must reach the boundary, U.3 — a subagent is
62
+ * not a way to swallow it).
63
+ */
64
+ spawn(spec: SubagentSpec, parentDepth: number): Promise<SubagentResult>;
65
+ /** Map the child's AgentResult to the structured, transcript-free shape. */
66
+ private toResult;
67
+ }
@@ -0,0 +1,241 @@
1
+ import path from "node:path";
2
+ import { runAgent } from "../agent/loop.js";
3
+ import { CruxyError, ErrorCode, messageOf, subagentDepthExceeded, } from "../errors/index.js";
4
+ import { Budget, resolveBudget } from "./budget.js";
5
+ import { scopeRegistry } from "./registry-scope.js";
6
+ import { makeSpawnSubagentTool } from "./spawn-tool.js";
7
+ /** Longest task excerpt shown in render chrome — display, not record. */
8
+ const LABEL_MAX = 60;
9
+ /**
10
+ * Spawns subagents (C.14): the existing agent loop re-driven over isolated
11
+ * state. Each spawn builds a fresh history seeded with only the task, a
12
+ * scoped-down registry, a fresh approval gate, and a hard budget, runs
13
+ * `runAgent` to completion (or cap), and folds the outcome into a compact
14
+ * {@link SubagentResult}. The child transcript is dropped here — the parent
15
+ * never sees it.
16
+ *
17
+ * Spawns are sequential by construction: the loop awaits each tool call, so
18
+ * two spawn requests in one turn run one after the other in deterministic
19
+ * order. Parallel fan-out is C.33's job — `spawn` is a self-contained async
20
+ * unit with no cross-spawn state, so it slots in without reshaping this class.
21
+ */
22
+ export class SubagentOrchestrator {
23
+ deps;
24
+ constructor(deps) {
25
+ this.deps = deps;
26
+ }
27
+ /**
28
+ * Run one subagent to completion. `parentDepth` is the spawner's depth (the
29
+ * main agent is 0); spawning past `subagent.maxDepth` throws
30
+ * `CRUXY_E_SUBAGENT_DEPTH_EXCEEDED` — the spawn tool is also structurally
31
+ * withheld at the cap, so this throw is the fail-loud backstop.
32
+ *
33
+ * Never rejects on a *child* failure — provider or tool crashes come back as
34
+ * `status: "failed"` for the parent to reason over. The two exceptions that
35
+ * do propagate: the depth cap (above) and `CRUXY_E_APPROVAL_REQUIRED`
36
+ * (non-interactive default-deny must reach the boundary, U.3 — a subagent is
37
+ * not a way to swallow it).
38
+ */
39
+ async spawn(spec, parentDepth) {
40
+ const { deps } = this;
41
+ const { maxDepth, defaultBudget } = deps.config.subagent;
42
+ if (parentDepth >= maxDepth) {
43
+ throw subagentDepthExceeded(parentDepth, maxDepth);
44
+ }
45
+ const childDepth = parentDepth + 1;
46
+ // Scoped-down registry (throws on a tool the parent lacks — the spawn tool
47
+ // surfaces that to the model), plus a depth-bound spawn tool only while
48
+ // nesting is still allowed.
49
+ const registry = scopeRegistry(deps.parentRegistry, spec.tools);
50
+ if (childDepth < maxDepth) {
51
+ registry.register(makeSpawnSubagentTool(this, childDepth));
52
+ }
53
+ const budget = new Budget(resolveBudget(defaultBudget, spec.budget));
54
+ // Fresh gate per child (own allowlist), recording what it approves so the
55
+ // result can name the artifacts without ever shipping the transcript.
56
+ const approve = deps.makeChildApproval();
57
+ const artifacts = new Set();
58
+ const ctx = {
59
+ cwd: deps.cwd,
60
+ config: deps.config,
61
+ logger: deps.logger,
62
+ requestApproval: async (action) => {
63
+ const decision = await approve(action);
64
+ if (decision.allow)
65
+ recordArtifacts(action, artifacts, deps.cwd);
66
+ return decision;
67
+ },
68
+ };
69
+ const label = taskLabel(spec.task);
70
+ deps.renderer?.note(`⏵ subagent: ${label}`);
71
+ deps.renderer?.setPhase({ kind: "subagent", label });
72
+ // The isolation seam: a brand-new history seeded with ONLY the task. The
73
+ // parent's messages are never in scope here, and this array dies with the
74
+ // spawn — only the structured result below leaves this function.
75
+ const messages = [{ role: "user", content: spec.task }];
76
+ let run;
77
+ try {
78
+ run = await runAgent({
79
+ messages,
80
+ provider: deps.provider,
81
+ registry,
82
+ config: deps.config,
83
+ ctx,
84
+ renderer: deps.renderer
85
+ ? new SubagentRenderer(deps.renderer, label)
86
+ : undefined,
87
+ git: deps.git,
88
+ projectInstructions: deps.projectInstructions,
89
+ subagent: true,
90
+ budget,
91
+ });
92
+ }
93
+ catch (err) {
94
+ // Non-interactive default-deny is the boundary's to report, not ours.
95
+ if (CruxyError.is(err) && err.code === ErrorCode.ApprovalRequired) {
96
+ deps.renderer?.setPhase(null);
97
+ throw err;
98
+ }
99
+ deps.renderer?.note(`✗ subagent failed: ${label}`);
100
+ deps.renderer?.setPhase(null);
101
+ return {
102
+ status: "failed",
103
+ summary: "",
104
+ ...artifactsField(artifacts),
105
+ error: `${ErrorCode.SubagentFailed}: ${messageOf(err) ?? "unknown error"}`,
106
+ iterations: 0,
107
+ usage: { input_tokens: 0, output_tokens: 0 },
108
+ };
109
+ }
110
+ deps.renderer?.setPhase(null);
111
+ const result = this.toResult(run, artifacts, label);
112
+ deps.logger.debug(`subagent ${result.status}: ${result.iterations} turn(s), tokens in/out ` +
113
+ `${result.usage.input_tokens}/${result.usage.output_tokens} — ${label}`);
114
+ return result;
115
+ }
116
+ /** Map the child's AgentResult to the structured, transcript-free shape. */
117
+ toResult(run, artifacts, label) {
118
+ const summary = lastAssistantText(run.messages);
119
+ const base = {
120
+ summary,
121
+ ...artifactsField(artifacts),
122
+ iterations: run.iterations,
123
+ usage: run.usage,
124
+ };
125
+ if (run.stop === "completed") {
126
+ this.deps.renderer?.note(`✓ subagent done: ${label}`);
127
+ return { status: "done", ...base };
128
+ }
129
+ // Both cap paths are the same outcome for the parent: a truncated, partial
130
+ // result with the reason — informational, never fatal (the parent decides
131
+ // what to do with it).
132
+ const reason = run.stop === "budget"
133
+ ? (run.stopReason ?? "budget cap reached")
134
+ : `agent.maxIterations ceiling reached (${this.deps.config.agent.maxIterations})`;
135
+ this.deps.renderer?.note(`✗ subagent stopped (budget): ${label}`);
136
+ return {
137
+ status: "budget-exceeded",
138
+ ...base,
139
+ error: `${ErrorCode.SubagentBudget}: ${reason}`,
140
+ };
141
+ }
142
+ }
143
+ /** One-line task excerpt for chrome (live phase, trail notes). */
144
+ function taskLabel(task) {
145
+ const flat = task.replace(/\s+/g, " ").trim();
146
+ return flat.length > LABEL_MAX ? flat.slice(0, LABEL_MAX - 1) + "…" : flat;
147
+ }
148
+ /** `artifacts` only when non-empty — absent beats `[]` in the parent's context. */
149
+ function artifactsField(artifacts) {
150
+ return artifacts.size > 0 ? { artifacts: [...artifacts].sort() } : {};
151
+ }
152
+ /**
153
+ * Record the paths an *approved* mutating action targets, project-relative.
154
+ * Shell commands carry no per-file attribution (same limitation C.32 records);
155
+ * they contribute no artifact entries.
156
+ */
157
+ function recordArtifacts(action, artifacts, cwd) {
158
+ if (action.path) {
159
+ artifacts.add(path.relative(cwd, action.path) || action.path);
160
+ }
161
+ if (action.preview?.type === "patch") {
162
+ for (const file of action.preview.files)
163
+ artifacts.add(file.path);
164
+ }
165
+ }
166
+ /** The child's final assistant text — the summary the parent receives. */
167
+ function lastAssistantText(messages) {
168
+ for (let i = messages.length - 1; i >= 0; i--) {
169
+ const msg = messages[i];
170
+ if (msg.role !== "assistant")
171
+ continue;
172
+ if (typeof msg.content === "string") {
173
+ if (msg.content.trim())
174
+ return msg.content.trim();
175
+ continue;
176
+ }
177
+ const text = msg.content
178
+ .filter((block) => block.type === "text")
179
+ .map((block) => block.text)
180
+ .join("\n")
181
+ .trim();
182
+ if (text)
183
+ return text;
184
+ }
185
+ return "";
186
+ }
187
+ /**
188
+ * The child's view of the ONE render path (U.4): a decorator over the parent's
189
+ * renderer, not a second implementation. Child assistant text is dropped — it
190
+ * is not user transcript; it returns to the parent as the structured summary.
191
+ * Child activity folds into the live region and trail with a `subagent ·`
192
+ * prefix, and the child's thinking phase keeps the task label on screen.
193
+ * Approval plumbing (`awaiting-approval`, previews, `promptResolved`) passes
194
+ * through untouched — the U.3 prompt renders identically inside a subagent.
195
+ */
196
+ class SubagentRenderer {
197
+ caps;
198
+ inner;
199
+ label;
200
+ constructor(inner, label) {
201
+ this.inner = inner;
202
+ this.label = label;
203
+ this.caps = inner.caps;
204
+ }
205
+ /** Turn framing belongs to the parent's turn — the child's is dropped. */
206
+ beginTurn() { }
207
+ endTurn() { }
208
+ /** Child assistant text is result data, not transcript — dropped. */
209
+ write() { }
210
+ endSegment() { }
211
+ note(text) {
212
+ this.inner.note(`subagent · ${text}`);
213
+ }
214
+ preview(preview) {
215
+ this.inner.preview(preview);
216
+ }
217
+ status(text) {
218
+ this.inner.status(text);
219
+ }
220
+ setPhase(phase) {
221
+ // Keep the task on the live line while the child thinks; everything else
222
+ // (tool phases via toolLifecycle, approval yields) passes through.
223
+ if (phase?.kind === "thinking") {
224
+ this.inner.setPhase({ kind: "subagent", label: this.label });
225
+ return;
226
+ }
227
+ if (phase === null)
228
+ return; // the orchestrator owns clearing
229
+ this.inner.setPhase(phase);
230
+ }
231
+ /** The plan executor owns the progress register (C.31) — never the child. */
232
+ progress() { }
233
+ toolLifecycle(event) {
234
+ this.inner.toolLifecycle({ ...event, label: `subagent · ${event.label}` });
235
+ }
236
+ promptResolved() {
237
+ this.inner.promptResolved();
238
+ }
239
+ /** The parent owns the renderer's lifetime. */
240
+ close() { }
241
+ }
@@ -0,0 +1,28 @@
1
+ import { ToolRegistry } from "../tools/index.js";
2
+ /**
3
+ * Child tool-registry derivation (C.14). The one rule: a subagent's toolset is
4
+ * scoped DOWN from its parent's, never up. Enforcement is structural — the
5
+ * child registry is built exclusively from `parent.get(name)` lookups, so a
6
+ * tool the parent lacks is unobtainable, not merely denied.
7
+ */
8
+ /** The spawn tool's registered name (excluded from every scoped child set —
9
+ * the orchestrator re-adds a depth-bound instance only while depth allows). */
10
+ export declare const SPAWN_SUBAGENT_TOOL_NAME = "spawn_subagent";
11
+ /**
12
+ * The default child toolset: read-only investigation plus skills. Mirrors the
13
+ * C.31 propose-phase set — no writes, no shell, no VCS unless the parent
14
+ * explicitly grants them at spawn (and holds them itself).
15
+ */
16
+ export declare const DEFAULT_SUBAGENT_TOOLS: readonly string[];
17
+ /**
18
+ * Build a child registry as a strict subset of `parent`.
19
+ *
20
+ * - No `requested` → the intersection of {@link DEFAULT_SUBAGENT_TOOLS} with
21
+ * what the parent actually has (a scoped parent scopes its children further).
22
+ * - With `requested` → every name must exist in the parent; an unavailable
23
+ * name **throws** (fail loud — the spawn tool surfaces it to the model as a
24
+ * tool error so it can self-correct, exactly like invalid arguments).
25
+ * - `spawn_subagent` is always stripped: nesting is the orchestrator's
26
+ * decision (depth-capped), never a scope grant.
27
+ */
28
+ export declare function scopeRegistry(parent: ToolRegistry, requested?: readonly string[]): ToolRegistry;
@@ -0,0 +1,63 @@
1
+ import { ToolRegistry } from "../tools/index.js";
2
+ /**
3
+ * Child tool-registry derivation (C.14). The one rule: a subagent's toolset is
4
+ * scoped DOWN from its parent's, never up. Enforcement is structural — the
5
+ * child registry is built exclusively from `parent.get(name)` lookups, so a
6
+ * tool the parent lacks is unobtainable, not merely denied.
7
+ */
8
+ /** The spawn tool's registered name (excluded from every scoped child set —
9
+ * the orchestrator re-adds a depth-bound instance only while depth allows). */
10
+ export const SPAWN_SUBAGENT_TOOL_NAME = "spawn_subagent";
11
+ /**
12
+ * The default child toolset: read-only investigation plus skills. Mirrors the
13
+ * C.31 propose-phase set — no writes, no shell, no VCS unless the parent
14
+ * explicitly grants them at spawn (and holds them itself).
15
+ */
16
+ export const DEFAULT_SUBAGENT_TOOLS = [
17
+ "list_files",
18
+ "read_file",
19
+ "glob",
20
+ "grep_files",
21
+ "git_status",
22
+ "search_codebase",
23
+ "list_skills",
24
+ "load_skill",
25
+ ];
26
+ /**
27
+ * Build a child registry as a strict subset of `parent`.
28
+ *
29
+ * - No `requested` → the intersection of {@link DEFAULT_SUBAGENT_TOOLS} with
30
+ * what the parent actually has (a scoped parent scopes its children further).
31
+ * - With `requested` → every name must exist in the parent; an unavailable
32
+ * name **throws** (fail loud — the spawn tool surfaces it to the model as a
33
+ * tool error so it can self-correct, exactly like invalid arguments).
34
+ * - `spawn_subagent` is always stripped: nesting is the orchestrator's
35
+ * decision (depth-capped), never a scope grant.
36
+ */
37
+ export function scopeRegistry(parent, requested) {
38
+ const child = new ToolRegistry();
39
+ if (requested === undefined) {
40
+ for (const name of DEFAULT_SUBAGENT_TOOLS) {
41
+ const tool = parent.get(name);
42
+ if (tool)
43
+ child.register(tool);
44
+ }
45
+ return child;
46
+ }
47
+ for (const name of new Set(requested)) {
48
+ if (name === SPAWN_SUBAGENT_TOOL_NAME)
49
+ continue;
50
+ const tool = parent.get(name);
51
+ if (!tool) {
52
+ const available = parent
53
+ .list()
54
+ .map((t) => t.name)
55
+ .filter((n) => n !== SPAWN_SUBAGENT_TOOL_NAME)
56
+ .join(", ");
57
+ throw new Error(`tool "${name}" is not available to grant a subagent (a subagent's tools ` +
58
+ `must be a subset of yours). Available: ${available}`);
59
+ }
60
+ child.register(tool);
61
+ }
62
+ return child;
63
+ }
@@ -0,0 +1,29 @@
1
+ import { z } from "zod";
2
+ import type { Tool } from "../tools/index.js";
3
+ import type { SubagentOrchestrator } from "./orchestrator.js";
4
+ /**
5
+ * The `spawn_subagent` tool (C.14): the parent-facing seam for delegation. A
6
+ * normal tool on the same loop as everything else — no hidden control flow.
7
+ * Bound to the spawner's depth at construction, so the registry a tool lives
8
+ * on structurally encodes how much deeper nesting may go: at the configured
9
+ * cap the tool simply isn't registered.
10
+ */
11
+ declare const parameters: z.ZodObject<{
12
+ task: z.ZodString;
13
+ tools: z.ZodOptional<z.ZodArray<z.ZodString, "atleastone">>;
14
+ maxIterations: z.ZodOptional<z.ZodNumber>;
15
+ maxTokens: z.ZodOptional<z.ZodNumber>;
16
+ }, "strip", z.ZodTypeAny, {
17
+ task: string;
18
+ maxTokens?: number | undefined;
19
+ maxIterations?: number | undefined;
20
+ tools?: [string, ...string[]] | undefined;
21
+ }, {
22
+ task: string;
23
+ maxTokens?: number | undefined;
24
+ maxIterations?: number | undefined;
25
+ tools?: [string, ...string[]] | undefined;
26
+ }>;
27
+ /** Build a `spawn_subagent` tool bound to `orchestrator` at `depth`. */
28
+ export declare function makeSpawnSubagentTool(orchestrator: SubagentOrchestrator, depth: number): Tool<typeof parameters>;
29
+ export {};
@@ -0,0 +1,94 @@
1
+ import { z } from "zod";
2
+ import { CruxyError, ErrorCode } from "../errors/index.js";
3
+ import { SPAWN_SUBAGENT_TOOL_NAME } from "./registry-scope.js";
4
+ /**
5
+ * The `spawn_subagent` tool (C.14): the parent-facing seam for delegation. A
6
+ * normal tool on the same loop as everything else — no hidden control flow.
7
+ * Bound to the spawner's depth at construction, so the registry a tool lives
8
+ * on structurally encodes how much deeper nesting may go: at the configured
9
+ * cap the tool simply isn't registered.
10
+ */
11
+ const parameters = z.object({
12
+ task: z
13
+ .string()
14
+ .min(1)
15
+ .describe("The complete, self-contained subtask. The subagent starts with NO context " +
16
+ "beyond this text — include every path, constraint, and expected output."),
17
+ tools: z
18
+ .array(z.string().min(1))
19
+ .nonempty()
20
+ .optional()
21
+ .describe("Tool names to grant the subagent — must be a subset of your own tools. " +
22
+ "Omit for the default read-only set. Grant write/shell tools only when " +
23
+ "the subtask needs them; every side effect still requires user approval."),
24
+ maxIterations: z
25
+ .number()
26
+ .int()
27
+ .positive()
28
+ .optional()
29
+ .describe("Cap on the subagent's model turns (clamped to the configured ceiling)."),
30
+ maxTokens: z
31
+ .number()
32
+ .int()
33
+ .positive()
34
+ .optional()
35
+ .describe("Cap on the subagent's total tokens (clamped to the configured ceiling)."),
36
+ });
37
+ /** The compact wire shape fed back to the parent model. */
38
+ function renderResult(result) {
39
+ return JSON.stringify({
40
+ status: result.status,
41
+ summary: result.summary,
42
+ ...(result.artifacts ? { artifacts: result.artifacts } : {}),
43
+ ...(result.error ? { error: result.error } : {}),
44
+ iterations: result.iterations,
45
+ tokens: {
46
+ input: result.usage.input_tokens,
47
+ output: result.usage.output_tokens,
48
+ },
49
+ });
50
+ }
51
+ /** Build a `spawn_subagent` tool bound to `orchestrator` at `depth`. */
52
+ export function makeSpawnSubagentTool(orchestrator, depth) {
53
+ return {
54
+ name: SPAWN_SUBAGENT_TOOL_NAME,
55
+ description: "Delegate a bounded, self-contained subtask to a scoped subagent with its own " +
56
+ "fresh context, a restricted toolset, and a hard budget. It runs to completion " +
57
+ "and returns only a structured result (status, summary, artifacts) — its " +
58
+ "transcript is discarded. Use for independent subtasks whose details you don't " +
59
+ 'need in your own context (e.g. "find where X is configured and report the paths").',
60
+ parameters,
61
+ async execute(input) {
62
+ const budget = {
63
+ ...(input.maxIterations !== undefined
64
+ ? { maxIterations: input.maxIterations }
65
+ : {}),
66
+ ...(input.maxTokens !== undefined
67
+ ? { maxTokens: input.maxTokens }
68
+ : {}),
69
+ };
70
+ let result;
71
+ try {
72
+ result = await orchestrator.spawn({ task: input.task, tools: input.tools, budget }, depth);
73
+ }
74
+ catch (err) {
75
+ // Non-interactive default-deny propagates to the boundary (U.3) —
76
+ // same behavior as every other gated tool.
77
+ if (CruxyError.is(err) && err.code === ErrorCode.ApprovalRequired) {
78
+ throw err;
79
+ }
80
+ // Depth-exceed and scope violations are the model's to correct: feed
81
+ // the coded, actionable message back as a tool error.
82
+ const message = CruxyError.is(err)
83
+ ? `${err.code}: ${err.title}${err.cause ? ` — ${err.cause}` : ""}`
84
+ : err.message;
85
+ return { ok: false, error: message };
86
+ }
87
+ // A failed child is an is_error result (strong signal), still structured;
88
+ // budget-exceeded is informational — a partial result, not an error.
89
+ return result.status === "failed"
90
+ ? { ok: false, error: renderResult(result) }
91
+ : { ok: true, output: renderResult(result) };
92
+ },
93
+ };
94
+ }
@@ -0,0 +1,55 @@
1
+ import type { Usage } from "@cruxy/sdk";
2
+ /**
3
+ * Types for subagent orchestration (C.14): the main agent delegates a bounded
4
+ * subtask to a child agent that runs the SAME loop with its own fresh history,
5
+ * a scoped-down tool registry, and a hard budget — then returns a structured
6
+ * result and discards its transcript. The parent reasons over the result only;
7
+ * context isolation is the whole point.
8
+ */
9
+ /** Why a subagent run ended. Every path returns a result — never a hang. */
10
+ export type SubagentStatus = "done" | "budget-exceeded" | "failed";
11
+ /**
12
+ * Hard caps a subagent runs under. `maxIterations` and `maxTokens` are always
13
+ * finite — a subagent is bounded by construction; `timeoutMs` is an optional
14
+ * wall-clock backstop on top.
15
+ */
16
+ export interface BudgetLimits {
17
+ /** Cap on the subagent's model turns. */
18
+ maxIterations: number;
19
+ /** Cap on the subagent's combined input+output tokens. */
20
+ maxTokens: number;
21
+ /** Optional wall-clock cap in milliseconds. */
22
+ timeoutMs?: number;
23
+ }
24
+ /** A spawn request: the bounded task plus optional scope/budget narrowing. */
25
+ export interface SubagentSpec {
26
+ /** The complete, self-contained subtask the subagent should perform. */
27
+ task: string;
28
+ /**
29
+ * Tool names to grant, resolved against the *parent's* registry — a subagent
30
+ * can only ever scope DOWN. Omitted → the default read-only set.
31
+ */
32
+ tools?: readonly string[];
33
+ /**
34
+ * Budget overrides. Clamped to `subagent.defaultBudget` from config — a
35
+ * spawn can narrow its budget, never raise it past the configured ceilings.
36
+ */
37
+ budget?: Partial<BudgetLimits>;
38
+ }
39
+ /**
40
+ * What the parent gets back — compact structured data, never the transcript.
41
+ * `summary` is the subagent's final assistant message (partial when a budget
42
+ * tripped); `artifacts` are the project-relative paths the subagent was
43
+ * approved to mutate.
44
+ */
45
+ export interface SubagentResult {
46
+ status: SubagentStatus;
47
+ summary: string;
48
+ artifacts?: string[];
49
+ /** Present on `budget-exceeded` / `failed`: the coded, actionable reason. */
50
+ error?: string;
51
+ /** Model turns the subagent consumed. */
52
+ iterations: number;
53
+ /** Token usage the subagent consumed (folded into the parent's accounting). */
54
+ usage: Usage;
55
+ }
@@ -0,0 +1 @@
1
+ export {};