@cruxy/cli 0.8.0 → 0.10.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 (83) 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/approval/prompt.js +4 -27
  8. package/dist/checkpoint/capture.d.ts +17 -0
  9. package/dist/checkpoint/capture.js +73 -0
  10. package/dist/checkpoint/git-store.d.ts +61 -0
  11. package/dist/checkpoint/git-store.js +171 -0
  12. package/dist/checkpoint/index.d.ts +6 -0
  13. package/dist/checkpoint/index.js +6 -0
  14. package/dist/checkpoint/restore.d.ts +23 -0
  15. package/dist/checkpoint/restore.js +195 -0
  16. package/dist/checkpoint/service.d.ts +80 -0
  17. package/dist/checkpoint/service.js +276 -0
  18. package/dist/checkpoint/shadow-store.d.ts +23 -0
  19. package/dist/checkpoint/shadow-store.js +93 -0
  20. package/dist/checkpoint/types.d.ts +117 -0
  21. package/dist/checkpoint/types.js +18 -0
  22. package/dist/cli/commands/checkpoint.d.ts +7 -0
  23. package/dist/cli/commands/checkpoint.js +31 -0
  24. package/dist/cli/commands/rollback.d.ts +10 -0
  25. package/dist/cli/commands/rollback.js +96 -0
  26. package/dist/cli/commands/run.js +10 -2
  27. package/dist/cli/program.js +4 -0
  28. package/dist/cli/repl.d.ts +7 -1
  29. package/dist/cli/repl.js +23 -3
  30. package/dist/cli/session-factory.d.ts +14 -1
  31. package/dist/cli/session-factory.js +87 -22
  32. package/dist/components/autocomplete.d.ts +32 -0
  33. package/dist/components/autocomplete.js +50 -0
  34. package/dist/components/frame.d.ts +25 -0
  35. package/dist/components/frame.js +49 -0
  36. package/dist/components/fuzzy.d.ts +61 -0
  37. package/dist/components/fuzzy.js +174 -0
  38. package/dist/components/index.d.ts +6 -0
  39. package/dist/components/index.js +6 -0
  40. package/dist/components/input.d.ts +78 -0
  41. package/dist/components/input.js +111 -0
  42. package/dist/components/keys.d.ts +48 -0
  43. package/dist/components/keys.js +105 -0
  44. package/dist/components/select.d.ts +28 -0
  45. package/dist/components/select.js +69 -0
  46. package/dist/config/schema.d.ts +133 -0
  47. package/dist/config/schema.js +40 -0
  48. package/dist/errors/constructors.d.ts +32 -0
  49. package/dist/errors/constructors.js +101 -0
  50. package/dist/errors/types.d.ts +8 -0
  51. package/dist/errors/types.js +18 -0
  52. package/dist/indexing/walker.d.ts +11 -0
  53. package/dist/indexing/walker.js +11 -6
  54. package/dist/onboarding/io.d.ts +3 -2
  55. package/dist/onboarding/io.js +35 -81
  56. package/dist/plan/execute.d.ts +8 -0
  57. package/dist/plan/execute.js +36 -22
  58. package/dist/plan/service.js +5 -1
  59. package/dist/plan/submit-plan.d.ts +4 -4
  60. package/dist/render/diff.js +27 -0
  61. package/dist/render/index.d.ts +2 -1
  62. package/dist/render/index.js +1 -0
  63. package/dist/render/plain-renderer.d.ts +7 -1
  64. package/dist/render/plain-renderer.js +26 -0
  65. package/dist/render/state.d.ts +31 -0
  66. package/dist/render/state.js +83 -0
  67. package/dist/render/tty-renderer.d.ts +41 -5
  68. package/dist/render/tty-renderer.js +150 -23
  69. package/dist/render/types.d.ts +85 -1
  70. package/dist/subagent/budget.d.ts +34 -0
  71. package/dist/subagent/budget.js +57 -0
  72. package/dist/subagent/index.d.ts +5 -0
  73. package/dist/subagent/index.js +5 -0
  74. package/dist/subagent/orchestrator.d.ts +67 -0
  75. package/dist/subagent/orchestrator.js +241 -0
  76. package/dist/subagent/registry-scope.d.ts +28 -0
  77. package/dist/subagent/registry-scope.js +63 -0
  78. package/dist/subagent/spawn-tool.d.ts +29 -0
  79. package/dist/subagent/spawn-tool.js +94 -0
  80. package/dist/subagent/types.d.ts +55 -0
  81. package/dist/subagent/types.js +1 -0
  82. package/dist/tools/types.d.ts +20 -2
  83. package/package.json +1 -1
@@ -1,6 +1,7 @@
1
1
  import pc from "picocolors";
2
2
  import { createStreamPrinter } from "../cli/stream-print.js";
3
3
  import { renderActionPreview } from "./diff.js";
4
+ import { ELAPSED_AFTER_MS, formatElapsed } from "./state.js";
4
5
  /**
5
6
  * The append-only renderer for pipes, CI, and cursor-less terminals. Emits no
6
7
  * cursor-control sequences ever, and no color unless the capabilities say so
@@ -20,6 +21,8 @@ export class PlainRenderer {
20
21
  /** Per-turn leading-newline trim; also tells endSegment whether to newline. */
21
22
  print;
22
23
  wroteInSegment = false;
24
+ /** In-flight tool call (serial by contract) for the end-note duration. */
25
+ toolStart = null;
23
26
  constructor(caps, out, err) {
24
27
  this.caps = caps;
25
28
  this.out = out;
@@ -56,6 +59,29 @@ export class PlainRenderer {
56
59
  status() {
57
60
  // Append-only medium: transient state is dropped by design.
58
61
  }
62
+ setPhase() {
63
+ // Phases are live-region state; there is no live region here (U.4).
64
+ }
65
+ progress() {
66
+ // The committed plan trail (C.31, via PromptIO) is the record in this
67
+ // medium; a live [i/n] prefix would just duplicate it line by line.
68
+ }
69
+ toolLifecycle(event) {
70
+ if (event.event === "start") {
71
+ // Silent: the end note is the single durable line per call — a start
72
+ // line too would double the chrome in CI logs for no information.
73
+ this.toolStart = { label: event.label, at: Date.now() };
74
+ return;
75
+ }
76
+ const started = this.toolStart?.label === event.label ? this.toolStart : null;
77
+ this.toolStart = null;
78
+ const elapsed = started === null ? 0 : Date.now() - started.at;
79
+ const suffix = elapsed >= ELAPSED_AFTER_MS ? ` (${formatElapsed(elapsed)})` : "";
80
+ this.note(`${event.ok ? "✓" : "✗"} ${event.label}${suffix}`);
81
+ }
82
+ promptResolved() {
83
+ // No live region to restore.
84
+ }
59
85
  endTurn() { }
60
86
  close() { }
61
87
  }
@@ -0,0 +1,31 @@
1
+ import type { ProgressState, RenderPhase } from "./types.js";
2
+ /**
3
+ * The U.4 state→text mapping: pure data → string, like plan/render.ts and
4
+ * diff.ts, so both renderers (and tests) share one composition with no
5
+ * terminal in sight. Color is deliberately absent — the live line is drawn
6
+ * dim as a whole by the TTY renderer; state text is content, not chrome.
7
+ */
8
+ /**
9
+ * Threshold before elapsed time appears on a live state or a committed tool
10
+ * note. Below this, a timer is noise; above it, it's the answer to "is this
11
+ * stuck?".
12
+ */
13
+ export declare const ELAPSED_AFTER_MS = 5000;
14
+ /** `342`, `1.2k`, `3.4M` — token counts at status-line width. */
15
+ export declare function formatTokens(n: number): string;
16
+ /** `37s`, `2m08s` — durations at status-line width. */
17
+ export declare function formatElapsed(ms: number): string;
18
+ /** The live-line text for a phase. `awaiting-approval` never renders (the line hides). */
19
+ export declare function describePhase(phase: RenderPhase): string;
20
+ /**
21
+ * Identity key for the elapsed clock: the clock resets when the phase becomes
22
+ * a *different activity*, not on every payload update — a thinking phase that
23
+ * gains token counts keeps its start time; a new tool label starts fresh.
24
+ */
25
+ export declare function phaseIdentity(phase: RenderPhase | null): string;
26
+ /**
27
+ * Compose the single live line: `[2/5] title · read_file src/x.ts… (12s)`.
28
+ * Elapsed appears only past {@link ELAPSED_AFTER_MS} — callers pass it only
29
+ * when they can keep it ticking honestly (no timer → no frozen number).
30
+ */
31
+ export declare function composeStatusLine(progress: ProgressState | null, phase: RenderPhase | null, elapsedMs?: number): string;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The U.4 state→text mapping: pure data → string, like plan/render.ts and
3
+ * diff.ts, so both renderers (and tests) share one composition with no
4
+ * terminal in sight. Color is deliberately absent — the live line is drawn
5
+ * dim as a whole by the TTY renderer; state text is content, not chrome.
6
+ */
7
+ /**
8
+ * Threshold before elapsed time appears on a live state or a committed tool
9
+ * note. Below this, a timer is noise; above it, it's the answer to "is this
10
+ * stuck?".
11
+ */
12
+ export const ELAPSED_AFTER_MS = 5_000;
13
+ /** `342`, `1.2k`, `3.4M` — token counts at status-line width. */
14
+ export function formatTokens(n) {
15
+ if (n < 1_000)
16
+ return String(n);
17
+ const scaled = n < 1_000_000 ? n / 1_000 : n / 1_000_000;
18
+ const unit = n < 1_000_000 ? "k" : "M";
19
+ const s = scaled.toFixed(1);
20
+ return (s.endsWith(".0") ? s.slice(0, -2) : s) + unit;
21
+ }
22
+ /** `37s`, `2m08s` — durations at status-line width. */
23
+ export function formatElapsed(ms) {
24
+ const seconds = Math.floor(ms / 1000);
25
+ if (seconds < 60)
26
+ return `${seconds}s`;
27
+ const minutes = Math.floor(seconds / 60);
28
+ return `${minutes}m${String(seconds % 60).padStart(2, "0")}s`;
29
+ }
30
+ /** The live-line text for a phase. `awaiting-approval` never renders (the line hides). */
31
+ export function describePhase(phase) {
32
+ switch (phase.kind) {
33
+ case "thinking": {
34
+ const t = phase.tokens;
35
+ // Honest numbers only: no usage yet → no figure at all.
36
+ return t && t.input + t.output > 0
37
+ ? `thinking… · tokens ↑${formatTokens(t.input)} ↓${formatTokens(t.output)}`
38
+ : "thinking…";
39
+ }
40
+ case "calling-tool":
41
+ return `${phase.label}…`;
42
+ case "awaiting-approval":
43
+ return "awaiting approval…";
44
+ case "executing-step":
45
+ return "working…";
46
+ case "subagent":
47
+ return `subagent: ${phase.label}…`;
48
+ }
49
+ }
50
+ /**
51
+ * Identity key for the elapsed clock: the clock resets when the phase becomes
52
+ * a *different activity*, not on every payload update — a thinking phase that
53
+ * gains token counts keeps its start time; a new tool label starts fresh.
54
+ */
55
+ export function phaseIdentity(phase) {
56
+ if (phase === null)
57
+ return "";
58
+ switch (phase.kind) {
59
+ case "calling-tool":
60
+ return `calling-tool:${phase.label}`;
61
+ case "subagent":
62
+ return `subagent:${phase.label}`;
63
+ default:
64
+ return phase.kind;
65
+ }
66
+ }
67
+ /**
68
+ * Compose the single live line: `[2/5] title · read_file src/x.ts… (12s)`.
69
+ * Elapsed appears only past {@link ELAPSED_AFTER_MS} — callers pass it only
70
+ * when they can keep it ticking honestly (no timer → no frozen number).
71
+ */
72
+ export function composeStatusLine(progress, phase, elapsedMs) {
73
+ const parts = [];
74
+ if (progress)
75
+ parts.push(`[${progress.step}/${progress.of}] ${progress.title}`);
76
+ if (phase)
77
+ parts.push(describePhase(phase));
78
+ const line = parts.join(" · ");
79
+ if (elapsedMs !== undefined && elapsedMs >= ELAPSED_AFTER_MS) {
80
+ return `${line} (${formatElapsed(elapsedMs)})`;
81
+ }
82
+ return line;
83
+ }
@@ -1,5 +1,5 @@
1
1
  import type { ActionPreview } from "../tools/types.js";
2
- import type { RenderCapabilities, RenderStream, StreamRenderer } from "./types.js";
2
+ import type { ProgressState, RenderCapabilities, RenderPhase, RenderStream, StreamRenderer, ToolLifecycleEvent } from "./types.js";
3
3
  /**
4
4
  * The interactive renderer: committed content is append-only; the one transient
5
5
  * thing on screen is a single managed status line, redrawn in place.
@@ -16,6 +16,14 @@ import type { RenderCapabilities, RenderStream, StreamRenderer } from "./types.j
16
16
  *
17
17
  * Fenced code blocks are highlighted incrementally (see highlight.ts): prose
18
18
  * deltas pass straight through, code is styled line-by-line on arrival.
19
+ *
20
+ * U.4 layers semantic state onto the SAME single live line — no new screen
21
+ * real estate, no extra timers. Two registers compose into it:
22
+ * - `phase` (loop-owned; cleared by endTurn) — thinking / calling-tool / …
23
+ * - `progressState` (plan-executor-owned; cleared only via progress(null))
24
+ * A committed write still only *hides* the drawn line (registers survive);
25
+ * the next state transition redraws. Nothing redraws per streamed delta, so
26
+ * the U.2 first-chunk-immediate guarantee is untouched.
19
27
  */
20
28
  export declare class TtyRenderer implements StreamRenderer {
21
29
  readonly caps: RenderCapabilities;
@@ -24,7 +32,18 @@ export declare class TtyRenderer implements StreamRenderer {
24
32
  private print;
25
33
  private highlighter;
26
34
  private wroteInSegment;
27
- private statusText;
35
+ /** Ad-hoc/legacy status text; wins over composed state when set. */
36
+ private rawStatus;
37
+ private phase;
38
+ private progressState;
39
+ /** When the current phase *identity* began — drives honest elapsed display. */
40
+ private phaseStartedAt;
41
+ /** In-flight tool call (serial by contract) for end-note duration. */
42
+ private toolStart;
43
+ /** Phase (+ its clock) displaced by an approval prompt, for promptResolved. */
44
+ private displaced;
45
+ /** Whether the live line is currently drawn on screen. */
46
+ private lineVisible;
28
47
  private timer;
29
48
  private frame;
30
49
  private closed;
@@ -32,16 +51,33 @@ export declare class TtyRenderer implements StreamRenderer {
32
51
  private newPrinter;
33
52
  /** Append committed content, erasing the status line first if one is live. */
34
53
  private commit;
35
- /** Erase the live status line (if any) and stop the spinner. */
36
- private dismissStatus;
54
+ /**
55
+ * Erase the drawn live line and stop the spinner — WITHOUT clearing the
56
+ * state registers. Committed content takes the screen; the next state
57
+ * transition redraws with full context. (This is what keeps streaming
58
+ * zero-cost: no redraw-under per delta.)
59
+ */
60
+ private hideLine;
37
61
  private stopTimer;
38
- private drawStatus;
62
+ /**
63
+ * The current live-line text, composed from the registers. `null` means the
64
+ * line must be hidden: nothing to say, or an interactive prompt owns the
65
+ * terminal (`awaiting-approval` — two things can't share the last row).
66
+ */
67
+ private currentLine;
68
+ /** Redraw the live line from current state, or hide it when there is none. */
69
+ private refresh;
70
+ private drawLine;
39
71
  beginTurn(): void;
40
72
  write(delta: string): void;
41
73
  endSegment(): void;
42
74
  note(text: string): void;
43
75
  preview(preview: ActionPreview): void;
44
76
  status(text: string | null): void;
77
+ setPhase(phase: RenderPhase | null): void;
78
+ progress(state: ProgressState | null): void;
79
+ toolLifecycle(event: ToolLifecycleEvent): void;
80
+ promptResolved(): void;
45
81
  endTurn(): void;
46
82
  close(): void;
47
83
  }
@@ -2,6 +2,7 @@ import pc from "picocolors";
2
2
  import { createStreamPrinter } from "../cli/stream-print.js";
3
3
  import { renderActionPreview } from "./diff.js";
4
4
  import { createStreamHighlighter, } from "./highlight.js";
5
+ import { composeStatusLine, ELAPSED_AFTER_MS, formatElapsed, phaseIdentity, } from "./state.js";
5
6
  /** Erase the current line and return the cursor to column 0. */
6
7
  const CLEAR_LINE = "\r\x1b[2K";
7
8
  /** Spinner frames (braille); a static glyph when animation is disabled. */
@@ -24,6 +25,14 @@ const SPINNER_INTERVAL_MS = 100;
24
25
  *
25
26
  * Fenced code blocks are highlighted incrementally (see highlight.ts): prose
26
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.
27
36
  */
28
37
  export class TtyRenderer {
29
38
  caps;
@@ -32,7 +41,18 @@ export class TtyRenderer {
32
41
  print;
33
42
  highlighter;
34
43
  wroteInSegment = false;
35
- statusText = null;
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;
36
56
  timer = null;
37
57
  frame = 0;
38
58
  closed = false;
@@ -52,15 +72,23 @@ export class TtyRenderer {
52
72
  commit(text) {
53
73
  if (text === "")
54
74
  return;
55
- this.dismissStatus();
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();
56
79
  this.wroteInSegment = true;
57
80
  this.out.write(text);
58
81
  }
59
- /** Erase the live status line (if any) and stop the spinner. */
60
- dismissStatus() {
61
- if (this.statusText === null)
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)
62
90
  return;
63
- this.statusText = null;
91
+ this.lineVisible = false;
64
92
  this.stopTimer();
65
93
  this.out.write(CLEAR_LINE);
66
94
  }
@@ -70,18 +98,53 @@ export class TtyRenderer {
70
98
  this.timer = null;
71
99
  }
72
100
  }
73
- drawStatus() {
74
- if (this.statusText === null)
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();
75
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;
76
141
  const glyph = this.caps.spinner
77
142
  ? FRAMES[this.frame % FRAMES.length]
78
143
  : STATIC_FRAME;
79
144
  // Reserve glyph + space; truncate so the live line can never soft-wrap.
80
145
  const room = Math.max(1, this.caps.width - 2);
81
- const text = this.statusText.length > room
82
- ? this.statusText.slice(0, Math.max(0, room - 1)) + "…"
83
- : this.statusText;
84
- this.out.write(`${CLEAR_LINE}${this.colors.cyan(glyph)} ${this.colors.dim(text)}`);
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)}`);
85
148
  }
86
149
  beginTurn() {
87
150
  this.highlighter = createStreamHighlighter(this.colors);
@@ -119,25 +182,89 @@ export class TtyRenderer {
119
182
  status(text) {
120
183
  if (this.closed)
121
184
  return;
185
+ this.rawStatus = text;
122
186
  if (text === null) {
123
- this.dismissStatus();
187
+ // Legacy clear semantics: hide now, redraw only on the next transition.
188
+ this.hideLine();
124
189
  return;
125
190
  }
126
- this.statusText = text;
127
- this.drawStatus();
128
- if (this.caps.spinner && this.timer === null) {
129
- this.timer = setInterval(() => {
130
- this.frame++;
131
- this.drawStatus();
132
- }, SPINNER_INTERVAL_MS);
133
- // Never hold the process open for a spinner.
134
- this.timer.unref?.();
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;
135
256
  }
257
+ this.refresh();
136
258
  }
137
259
  endTurn() {
138
260
  if (this.closed)
139
261
  return;
140
- this.dismissStatus();
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();
141
268
  this.commit(this.highlighter.flush());
142
269
  }
143
270
  close() {
@@ -24,6 +24,64 @@ export interface RenderCapabilities {
24
24
  /** Terminal columns; 80 when unknown (non-TTY). */
25
25
  width: number;
26
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
+ };
27
85
  /**
28
86
  * How the agent loop paints a turn. The contract that keeps output flicker-free:
29
87
  *
@@ -61,9 +119,35 @@ export interface StreamRenderer {
61
119
  * Replace the transient status line ("thinking…", "running bash…"); `null`
62
120
  * clears it. Where in-place updates are impossible this may drop the text —
63
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.
64
123
  */
65
124
  status(text: string | null): void;
66
- /** End the user turn: clear any status, flush everything held. */
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. */
67
151
  endTurn(): void;
68
152
  /** Release resources (spinner timer). Further calls are no-ops. */
69
153
  close(): void;
@@ -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";