@cruxy/cli 0.7.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 (78) hide show
  1. package/README.md +46 -13
  2. package/dist/agent/loop.d.ts +35 -6
  3. package/dist/agent/loop.js +84 -10
  4. package/dist/agent/prompts.d.ts +2 -0
  5. package/dist/agent/prompts.js +8 -0
  6. package/dist/agent/session.d.ts +6 -4
  7. package/dist/agent/session.js +6 -5
  8. package/dist/approval/classify.js +26 -0
  9. package/dist/approval/prompt.d.ts +9 -0
  10. package/dist/approval/prompt.js +2 -77
  11. package/dist/checkpoint/capture.d.ts +17 -0
  12. package/dist/checkpoint/capture.js +73 -0
  13. package/dist/checkpoint/git-store.d.ts +61 -0
  14. package/dist/checkpoint/git-store.js +171 -0
  15. package/dist/checkpoint/index.d.ts +6 -0
  16. package/dist/checkpoint/index.js +6 -0
  17. package/dist/checkpoint/restore.d.ts +23 -0
  18. package/dist/checkpoint/restore.js +195 -0
  19. package/dist/checkpoint/service.d.ts +80 -0
  20. package/dist/checkpoint/service.js +276 -0
  21. package/dist/checkpoint/shadow-store.d.ts +23 -0
  22. package/dist/checkpoint/shadow-store.js +93 -0
  23. package/dist/checkpoint/types.d.ts +117 -0
  24. package/dist/checkpoint/types.js +18 -0
  25. package/dist/cli/commands/checkpoint.d.ts +7 -0
  26. package/dist/cli/commands/checkpoint.js +31 -0
  27. package/dist/cli/commands/rollback.d.ts +10 -0
  28. package/dist/cli/commands/rollback.js +51 -0
  29. package/dist/cli/commands/run.js +24 -10
  30. package/dist/cli/onboard.js +9 -4
  31. package/dist/cli/program.js +4 -0
  32. package/dist/cli/repl.d.ts +10 -4
  33. package/dist/cli/repl.js +26 -12
  34. package/dist/cli/session-factory.d.ts +15 -1
  35. package/dist/cli/session-factory.js +104 -18
  36. package/dist/config/schema.d.ts +133 -0
  37. package/dist/config/schema.js +40 -0
  38. package/dist/errors/constructors.d.ts +25 -0
  39. package/dist/errors/constructors.js +86 -0
  40. package/dist/errors/types.d.ts +7 -0
  41. package/dist/errors/types.js +16 -0
  42. package/dist/indexing/walker.d.ts +11 -0
  43. package/dist/indexing/walker.js +11 -6
  44. package/dist/plan/execute.d.ts +8 -0
  45. package/dist/plan/execute.js +36 -22
  46. package/dist/plan/service.d.ts +2 -1
  47. package/dist/plan/service.js +7 -3
  48. package/dist/plan/submit-plan.d.ts +4 -4
  49. package/dist/render/capabilities.d.ts +12 -0
  50. package/dist/render/capabilities.js +27 -0
  51. package/dist/render/diff.d.ts +19 -0
  52. package/dist/render/diff.js +107 -0
  53. package/dist/render/highlight.d.ts +47 -0
  54. package/dist/render/highlight.js +265 -0
  55. package/dist/render/index.d.ts +15 -0
  56. package/dist/render/index.js +21 -0
  57. package/dist/render/plain-renderer.d.ts +38 -0
  58. package/dist/render/plain-renderer.js +87 -0
  59. package/dist/render/state.d.ts +31 -0
  60. package/dist/render/state.js +83 -0
  61. package/dist/render/tty-renderer.d.ts +83 -0
  62. package/dist/render/tty-renderer.js +276 -0
  63. package/dist/render/types.d.ts +160 -0
  64. package/dist/render/types.js +1 -0
  65. package/dist/subagent/budget.d.ts +34 -0
  66. package/dist/subagent/budget.js +57 -0
  67. package/dist/subagent/index.d.ts +5 -0
  68. package/dist/subagent/index.js +5 -0
  69. package/dist/subagent/orchestrator.d.ts +67 -0
  70. package/dist/subagent/orchestrator.js +241 -0
  71. package/dist/subagent/registry-scope.d.ts +28 -0
  72. package/dist/subagent/registry-scope.js +63 -0
  73. package/dist/subagent/spawn-tool.d.ts +29 -0
  74. package/dist/subagent/spawn-tool.js +94 -0
  75. package/dist/subagent/types.d.ts +55 -0
  76. package/dist/subagent/types.js +1 -0
  77. package/dist/tools/types.d.ts +20 -2
  78. package/package.json +1 -1
@@ -0,0 +1,276 @@
1
+ import pc from "picocolors";
2
+ import { createStreamPrinter } from "../cli/stream-print.js";
3
+ import { renderActionPreview } from "./diff.js";
4
+ import { createStreamHighlighter, } from "./highlight.js";
5
+ import { composeStatusLine, ELAPSED_AFTER_MS, formatElapsed, phaseIdentity, } from "./state.js";
6
+ /** Erase the current line and return the cursor to column 0. */
7
+ const CLEAR_LINE = "\r\x1b[2K";
8
+ /** Spinner frames (braille); a static glyph when animation is disabled. */
9
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
10
+ const STATIC_FRAME = "◐";
11
+ const SPINNER_INTERVAL_MS = 100;
12
+ /**
13
+ * The interactive renderer: committed content is append-only; the one transient
14
+ * thing on screen is a single managed status line, redrawn in place.
15
+ *
16
+ * The no-flicker discipline, concretely:
17
+ * - Only the status line is ever rewritten, via `\r` + erase-line — never a
18
+ * screen clear, never a repaint of committed rows.
19
+ * - Every committed write first erases the status line, so the live region is
20
+ * always the last row and committed text can never interleave with it. A
21
+ * committed write *dismisses* the status (it does not redraw underneath), so
22
+ * nothing re-renders per delta while text streams.
23
+ * - The status text is hard-truncated to the terminal width: a soft-wrapped
24
+ * status would span two rows and erase-line could no longer clean it up.
25
+ *
26
+ * Fenced code blocks are highlighted incrementally (see highlight.ts): prose
27
+ * deltas pass straight through, code is styled line-by-line on arrival.
28
+ *
29
+ * U.4 layers semantic state onto the SAME single live line — no new screen
30
+ * real estate, no extra timers. Two registers compose into it:
31
+ * - `phase` (loop-owned; cleared by endTurn) — thinking / calling-tool / …
32
+ * - `progressState` (plan-executor-owned; cleared only via progress(null))
33
+ * A committed write still only *hides* the drawn line (registers survive);
34
+ * the next state transition redraws. Nothing redraws per streamed delta, so
35
+ * the U.2 first-chunk-immediate guarantee is untouched.
36
+ */
37
+ export class TtyRenderer {
38
+ caps;
39
+ out;
40
+ colors;
41
+ print;
42
+ highlighter;
43
+ wroteInSegment = false;
44
+ /** Ad-hoc/legacy status text; wins over composed state when set. */
45
+ rawStatus = null;
46
+ phase = null;
47
+ progressState = null;
48
+ /** When the current phase *identity* began — drives honest elapsed display. */
49
+ phaseStartedAt = 0;
50
+ /** In-flight tool call (serial by contract) for end-note duration. */
51
+ toolStart = null;
52
+ /** Phase (+ its clock) displaced by an approval prompt, for promptResolved. */
53
+ displaced = null;
54
+ /** Whether the live line is currently drawn on screen. */
55
+ lineVisible = false;
56
+ timer = null;
57
+ frame = 0;
58
+ closed = false;
59
+ constructor(caps, out) {
60
+ this.caps = caps;
61
+ this.out = out;
62
+ this.colors = pc.createColors(caps.color);
63
+ this.highlighter = createStreamHighlighter(this.colors);
64
+ this.print = this.newPrinter();
65
+ }
66
+ newPrinter() {
67
+ return createStreamPrinter((text) => {
68
+ this.commit(this.highlighter.push(text));
69
+ });
70
+ }
71
+ /** Append committed content, erasing the status line first if one is live. */
72
+ commit(text) {
73
+ if (text === "")
74
+ return;
75
+ // Legacy ad-hoc status is decor and dies with the dismissal (U.2
76
+ // semantics); the typed U.4 registers survive for the next transition.
77
+ this.rawStatus = null;
78
+ this.hideLine();
79
+ this.wroteInSegment = true;
80
+ this.out.write(text);
81
+ }
82
+ /**
83
+ * Erase the drawn live line and stop the spinner — WITHOUT clearing the
84
+ * state registers. Committed content takes the screen; the next state
85
+ * transition redraws with full context. (This is what keeps streaming
86
+ * zero-cost: no redraw-under per delta.)
87
+ */
88
+ hideLine() {
89
+ if (!this.lineVisible)
90
+ return;
91
+ this.lineVisible = false;
92
+ this.stopTimer();
93
+ this.out.write(CLEAR_LINE);
94
+ }
95
+ stopTimer() {
96
+ if (this.timer !== null) {
97
+ clearInterval(this.timer);
98
+ this.timer = null;
99
+ }
100
+ }
101
+ /**
102
+ * The current live-line text, composed from the registers. `null` means the
103
+ * line must be hidden: nothing to say, or an interactive prompt owns the
104
+ * terminal (`awaiting-approval` — two things can't share the last row).
105
+ */
106
+ currentLine() {
107
+ if (this.rawStatus !== null)
108
+ return this.rawStatus;
109
+ if (this.phase?.kind === "awaiting-approval")
110
+ return null;
111
+ if (this.phase === null && this.progressState === null)
112
+ return null;
113
+ // Elapsed only when it can keep ticking honestly: the spinner timer is the
114
+ // only thing that redraws between transitions, so no spinner → no number.
115
+ const elapsed = this.phase !== null && this.caps.spinner
116
+ ? Date.now() - this.phaseStartedAt
117
+ : undefined;
118
+ return composeStatusLine(this.progressState, this.phase, elapsed);
119
+ }
120
+ /** Redraw the live line from current state, or hide it when there is none. */
121
+ refresh() {
122
+ const line = this.currentLine();
123
+ if (line === null) {
124
+ this.hideLine();
125
+ return;
126
+ }
127
+ this.drawLine(line);
128
+ if (this.caps.spinner && this.timer === null) {
129
+ this.timer = setInterval(() => {
130
+ this.frame++;
131
+ const current = this.currentLine();
132
+ if (current !== null)
133
+ this.drawLine(current);
134
+ }, SPINNER_INTERVAL_MS);
135
+ // Never hold the process open for a spinner.
136
+ this.timer.unref?.();
137
+ }
138
+ }
139
+ drawLine(text) {
140
+ this.lineVisible = true;
141
+ const glyph = this.caps.spinner
142
+ ? FRAMES[this.frame % FRAMES.length]
143
+ : STATIC_FRAME;
144
+ // Reserve glyph + space; truncate so the live line can never soft-wrap.
145
+ const room = Math.max(1, this.caps.width - 2);
146
+ const line = text.length > room ? text.slice(0, Math.max(0, room - 1)) + "…" : text;
147
+ this.out.write(`${CLEAR_LINE}${this.colors.cyan(glyph)} ${this.colors.dim(line)}`);
148
+ }
149
+ beginTurn() {
150
+ this.highlighter = createStreamHighlighter(this.colors);
151
+ this.print = this.newPrinter();
152
+ this.wroteInSegment = false;
153
+ }
154
+ write(delta) {
155
+ if (this.closed)
156
+ return;
157
+ this.print(delta);
158
+ }
159
+ endSegment() {
160
+ if (this.closed)
161
+ return;
162
+ this.commit(this.highlighter.flush());
163
+ if (this.wroteInSegment) {
164
+ this.commit("\n");
165
+ this.wroteInSegment = false;
166
+ }
167
+ }
168
+ note(text) {
169
+ if (this.closed)
170
+ return;
171
+ const room = Math.max(1, this.caps.width);
172
+ const line = text.length > room ? text.slice(0, room - 1) + "…" : text;
173
+ this.commit(this.colors.dim(line) + "\n");
174
+ }
175
+ preview(preview) {
176
+ if (this.closed)
177
+ return;
178
+ const block = renderActionPreview(preview, this.colors);
179
+ if (block)
180
+ this.commit(block + "\n");
181
+ }
182
+ status(text) {
183
+ if (this.closed)
184
+ return;
185
+ this.rawStatus = text;
186
+ if (text === null) {
187
+ // Legacy clear semantics: hide now, redraw only on the next transition.
188
+ this.hideLine();
189
+ return;
190
+ }
191
+ this.refresh();
192
+ }
193
+ setPhase(phase) {
194
+ if (this.closed)
195
+ return;
196
+ // Entering awaiting-approval stashes what it displaces (phase + clock) so
197
+ // promptResolved can put the world back exactly as the prompt found it.
198
+ if (phase?.kind === "awaiting-approval") {
199
+ if (this.phase?.kind !== "awaiting-approval") {
200
+ this.displaced = { phase: this.phase, startedAt: this.phaseStartedAt };
201
+ }
202
+ }
203
+ else {
204
+ this.displaced = null;
205
+ }
206
+ const before = phaseIdentity(this.phase);
207
+ this.phase = phase;
208
+ if (phaseIdentity(phase) !== before)
209
+ this.phaseStartedAt = Date.now();
210
+ if (phase === null) {
211
+ // A cleared phase means "nothing is happening" — hide rather than
212
+ // redraw a bare progress prefix between turns.
213
+ this.hideLine();
214
+ return;
215
+ }
216
+ this.refresh();
217
+ }
218
+ progress(state) {
219
+ if (this.closed)
220
+ return;
221
+ this.progressState = state;
222
+ this.refresh();
223
+ }
224
+ toolLifecycle(event) {
225
+ if (this.closed)
226
+ return;
227
+ if (event.event === "start") {
228
+ this.toolStart = { label: event.label, at: Date.now() };
229
+ this.setPhase({ kind: "calling-tool", label: event.label });
230
+ return;
231
+ }
232
+ const started = this.toolStart?.label === event.label ? this.toolStart : null;
233
+ this.toolStart = null;
234
+ if (this.phase?.kind === "calling-tool")
235
+ this.phase = null;
236
+ // Duration is measured (start→end timestamps), never animated — so it is
237
+ // honest even with CRUXY_NO_SPINNER; shown only once it means something.
238
+ const elapsed = started === null ? 0 : Date.now() - started.at;
239
+ const suffix = elapsed >= ELAPSED_AFTER_MS ? ` (${formatElapsed(elapsed)})` : "";
240
+ this.note(`${event.ok ? "✓" : "✗"} ${event.label}${suffix}`);
241
+ }
242
+ promptResolved() {
243
+ if (this.closed)
244
+ return;
245
+ if (this.phase?.kind !== "awaiting-approval")
246
+ return;
247
+ const displaced = this.displaced;
248
+ this.displaced = null;
249
+ this.phase = displaced?.phase ?? null;
250
+ // Restore the ORIGINAL clock: a long tool call approved late reports
251
+ // wall-time since it started, matching the committed end note.
252
+ this.phaseStartedAt = displaced?.startedAt ?? Date.now();
253
+ if (this.phase === null) {
254
+ this.hideLine();
255
+ return;
256
+ }
257
+ this.refresh();
258
+ }
259
+ endTurn() {
260
+ if (this.closed)
261
+ return;
262
+ // The turn's phase is over; step progress belongs to the plan executor
263
+ // and survives until it says otherwise.
264
+ this.phase = null;
265
+ this.rawStatus = null;
266
+ this.displaced = null;
267
+ this.hideLine();
268
+ this.commit(this.highlighter.flush());
269
+ }
270
+ close() {
271
+ if (this.closed)
272
+ return;
273
+ this.endTurn();
274
+ this.closed = true;
275
+ }
276
+ }
@@ -0,0 +1,160 @@
1
+ import type { ActionPreview } from "../tools/types.js";
2
+ /**
3
+ * The streaming render seam (U.2): the agent loop talks to a
4
+ * {@link StreamRenderer}, never to raw stdout. Two implementations exist —
5
+ * `TtyRenderer` (managed one-line live region, append-only committed content)
6
+ * and `PlainRenderer` (append-only, zero ANSI) — picked by the factory from
7
+ * detected {@link RenderCapabilities}. Same swappable discipline as
8
+ * `Formatter`/`VectorStore`.
9
+ */
10
+ /**
11
+ * What the output medium supports, detected once at construction (see
12
+ * `detectCapabilities`). Renderers and the factory key off these flags; nothing
13
+ * downstream re-probes the environment.
14
+ */
15
+ export interface RenderCapabilities {
16
+ /** The primary output stream is a terminal. */
17
+ tty: boolean;
18
+ /** Emit ANSI color (NO_COLOR / FORCE_COLOR / TTY resolved; `TERM=dumb` → false). */
19
+ color: boolean;
20
+ /** Cursor-control sequences are safe (`tty` and not `TERM=dumb`). */
21
+ cursor: boolean;
22
+ /** Animation is welcome (`cursor` and CRUXY_NO_SPINNER unset). */
23
+ spinner: boolean;
24
+ /** Terminal columns; 80 when unknown (non-TTY). */
25
+ width: number;
26
+ }
27
+ /** Accumulated token usage the loop already tracks (U.4) — never fabricated. */
28
+ export interface TokenUsage {
29
+ input: number;
30
+ output: number;
31
+ }
32
+ /**
33
+ * Semantic live-state phases (U.4). The loop and the approval seam emit these
34
+ * instead of format strings; renderers decide presentation per capability.
35
+ * One phase is live at a time — it is a register, not a queue.
36
+ */
37
+ export type RenderPhase =
38
+ /** Waiting on the model. `tokens` = usage accumulated so far, omitted at 0. */
39
+ {
40
+ kind: "thinking";
41
+ tokens?: TokenUsage;
42
+ }
43
+ /** A tool call is executing; `label` is the human form ("read_file src/x.ts"). */
44
+ | {
45
+ kind: "calling-tool";
46
+ label: string;
47
+ }
48
+ /** An interactive prompt owns the terminal — the live line must yield to it. */
49
+ | {
50
+ kind: "awaiting-approval";
51
+ }
52
+ /** A plan step is active but the model is not yet engaged (C.31). */
53
+ | {
54
+ kind: "executing-step";
55
+ }
56
+ /** A subagent is running its task (C.14); `label` is the (truncated) task. */
57
+ | {
58
+ kind: "subagent";
59
+ label: string;
60
+ };
61
+ /**
62
+ * Plan-mode step progress (U.4/C.31): rendered as a persistent `[i/n] title`
63
+ * prefix on the live line. A separate register from {@link RenderPhase} with a
64
+ * separate owner (the plan executor), so `endTurn` clearing the loop's phase
65
+ * can never wipe step context mid-step.
66
+ */
67
+ export interface ProgressState {
68
+ step: number;
69
+ of: number;
70
+ title: string;
71
+ }
72
+ /**
73
+ * Tool-call lifecycle (U.4): `start` paints live state and starts the honest
74
+ * elapsed clock; `end` commits the `✓/✗` trail note (with a duration suffix
75
+ * when the measured start→end gap crossed the threshold — never estimated).
76
+ */
77
+ export type ToolLifecycleEvent = {
78
+ event: "start";
79
+ label: string;
80
+ } | {
81
+ event: "end";
82
+ label: string;
83
+ ok: boolean;
84
+ };
85
+ /**
86
+ * How the agent loop paints a turn. The contract that keeps output flicker-free:
87
+ *
88
+ * - **Committed content is append-only.** `write`/`note`/`preview` output is
89
+ * never repainted, moved, or cleared.
90
+ * - **Transient state is one line.** `status` owns a single live line that is
91
+ * redrawn in place and erased before any committed write lands.
92
+ *
93
+ * Call shape per user turn: `beginTurn` → per model turn: `write`* +
94
+ * `endSegment`, with `status`/`note`/`preview` between segments → `endTurn`.
95
+ * All methods must be safe to call in any environment — degradation is the
96
+ * implementation's job, not the caller's.
97
+ */
98
+ export interface StreamRenderer {
99
+ readonly caps: RenderCapabilities;
100
+ /** Start a user turn: reset leading-newline trim and code-fence state. */
101
+ beginTurn(): void;
102
+ /**
103
+ * Append streamed assistant text. Must forward promptly — implementations may
104
+ * hold back at most the current line (for fence detection), never the block
105
+ * or the response. Dismisses any live status first.
106
+ */
107
+ write(delta: string): void;
108
+ /**
109
+ * Close one model-turn text segment: flush any held partial line / open
110
+ * fence and terminate with a single newline (only if the segment produced
111
+ * visible text), so whatever follows starts on its own line.
112
+ */
113
+ endSegment(): void;
114
+ /** A committed one-line chrome note (e.g. `✓ read_file src/x.ts`), dim when colored. */
115
+ note(text: string): void;
116
+ /** A committed diff/action preview, rendered by the shared diff renderer. */
117
+ preview(preview: ActionPreview): void;
118
+ /**
119
+ * Replace the transient status line ("thinking…", "running bash…"); `null`
120
+ * clears it. Where in-place updates are impossible this may drop the text —
121
+ * status is progress decor, never information of record (use `note` for that).
122
+ * Ad-hoc/legacy; production callers use the typed U.4 methods below.
123
+ */
124
+ status(text: string | null): void;
125
+ /**
126
+ * Set (or clear) the semantic live phase (U.4). Rendered into the same
127
+ * managed status line; `awaiting-approval` yields the line to the prompt.
128
+ * PlainRenderer drops phases — transient state has no meaning append-only.
129
+ */
130
+ setPhase(phase: RenderPhase | null): void;
131
+ /**
132
+ * Set (or clear) plan-step progress (U.4). Persists across phase changes and
133
+ * `endTurn` — only the plan executor clears it. Rendered as an `[i/n] title`
134
+ * prefix on the live line; dropped by PlainRenderer.
135
+ */
136
+ progress(state: ProgressState | null): void;
137
+ /**
138
+ * Tool-call lifecycle (U.4): `start` → live "label…" state (+ elapsed clock),
139
+ * `end` → the committed `✓/✗ label` note, with an honest duration suffix for
140
+ * long calls. Replaces the loop's ad-hoc status/note pair.
141
+ */
142
+ toolLifecycle(event: ToolLifecycleEvent): void;
143
+ /**
144
+ * The interactive prompt released the terminal (its key/line read resolved):
145
+ * the pair-closer for `awaiting-approval`. Restores the phase the prompt
146
+ * displaced — with its original clock, so a long tool call approved late
147
+ * still reports honest wall-time. No-op where there is no live region.
148
+ */
149
+ promptResolved(): void;
150
+ /** End the user turn: clear any status and the live phase (never progress), flush everything held. */
151
+ endTurn(): void;
152
+ /** Release resources (spinner timer). Further calls are no-ops. */
153
+ close(): void;
154
+ }
155
+ /** The minimal stream surface a renderer writes to. Injectable for tests. */
156
+ export interface RenderStream {
157
+ isTTY?: boolean;
158
+ columns?: number;
159
+ write(text: string): unknown;
160
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,34 @@
1
+ import type { Usage } from "@cruxy/sdk";
2
+ import type { LoopBudget } from "../agent/loop.js";
3
+ import type { BudgetLimits } from "./types.js";
4
+ /**
5
+ * The subagent budget (C.14): iteration + token + optional wall-clock caps,
6
+ * checked by the agent loop before every model turn (see `LoopBudget`). A
7
+ * tripped cap stops the run with a human-readable reason — the subagent
8
+ * returns a partial result, it never runs unbounded.
9
+ */
10
+ /**
11
+ * Resolve the effective limits for one spawn: start from the configured
12
+ * ceilings and let overrides only *narrow* them. A request above a ceiling is
13
+ * clamped down, not honored — "budget overrides within limits" by construction.
14
+ */
15
+ export declare function resolveBudget(defaults: BudgetLimits, overrides?: Partial<BudgetLimits>): BudgetLimits;
16
+ /**
17
+ * A live budget for one subagent run. The wall clock starts at construction
18
+ * (spawn time); the clock source is injectable so tests never sleep.
19
+ */
20
+ export declare class Budget implements LoopBudget {
21
+ private readonly limits;
22
+ private readonly now;
23
+ private readonly startedAt;
24
+ constructor(limits: BudgetLimits, now?: () => number);
25
+ /**
26
+ * The reason to stop before the next model turn, or `null` to continue.
27
+ * Checked at iteration boundaries — the in-flight turn always completes, so
28
+ * overshoot is bounded by one turn.
29
+ */
30
+ exceeded(state: {
31
+ iterations: number;
32
+ usage: Usage;
33
+ }): string | null;
34
+ }
@@ -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
+ }