@cruxy/cli 0.26.0 → 0.28.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 (40) hide show
  1. package/dist/cli/commands/mcp.js +106 -7
  2. package/dist/config/credentials.d.ts +9 -0
  3. package/dist/config/credentials.js +29 -1
  4. package/dist/config/manager.js +30 -3
  5. package/dist/config/schema.d.ts +182 -8
  6. package/dist/config/schema.js +43 -5
  7. package/dist/errors/constructors.d.ts +29 -0
  8. package/dist/errors/constructors.js +69 -0
  9. package/dist/errors/types.d.ts +15 -0
  10. package/dist/errors/types.js +18 -0
  11. package/dist/mcp/http-transport.d.ts +89 -0
  12. package/dist/mcp/http-transport.js +299 -0
  13. package/dist/mcp/index.d.ts +4 -2
  14. package/dist/mcp/index.js +3 -1
  15. package/dist/mcp/service.d.ts +19 -2
  16. package/dist/mcp/service.js +92 -20
  17. package/dist/mcp/trust-gate.d.ts +35 -11
  18. package/dist/mcp/trust-gate.js +87 -22
  19. package/dist/mcp/trust.d.ts +12 -2
  20. package/dist/mcp/trust.js +26 -2
  21. package/dist/mcp/types.d.ts +10 -0
  22. package/dist/mcp/url-guard.d.ts +48 -0
  23. package/dist/mcp/url-guard.js +62 -0
  24. package/dist/net/ip-guard.d.ts +55 -0
  25. package/dist/net/ip-guard.js +229 -0
  26. package/dist/render/index.d.ts +1 -0
  27. package/dist/render/index.js +1 -0
  28. package/dist/render/motion.d.ts +76 -0
  29. package/dist/render/motion.js +94 -0
  30. package/dist/render/tty-renderer.d.ts +17 -3
  31. package/dist/render/tty-renderer.js +58 -21
  32. package/dist/tools/file/apply-patch.js +12 -8
  33. package/dist/tools/file/edit-file.d.ts +0 -2
  34. package/dist/tools/file/edit-file.js +10 -19
  35. package/dist/tools/file/match.d.ts +43 -0
  36. package/dist/tools/file/match.js +127 -0
  37. package/dist/web/ssrf.d.ts +8 -22
  38. package/dist/web/ssrf.js +11 -183
  39. package/dist/web/types.d.ts +4 -2
  40. package/package.json +1 -1
@@ -0,0 +1,229 @@
1
+ import { lookup } from "node:dns";
2
+ /** Default resolver: node's `dns.lookup` returning ALL addresses. */
3
+ export const defaultResolveHost = (host) => new Promise((resolve, reject) => {
4
+ lookup(host, { all: true }, (err, addresses) => {
5
+ if (err)
6
+ reject(err);
7
+ else
8
+ resolve(addresses.map((a) => a.address));
9
+ });
10
+ });
11
+ /** Thrown when a URL/host is refused pre-dispatch; carries a human reason. */
12
+ export class BlockedHostError extends Error {
13
+ }
14
+ /** Thrown when the host could not be resolved (a network failure, not a block). */
15
+ export class HostUnresolvedError extends Error {
16
+ }
17
+ /** Parse a dotted-quad IPv4 string into its four octets, or null. */
18
+ function parseIpv4(ip) {
19
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip);
20
+ if (!m)
21
+ return null;
22
+ const octets = m.slice(1, 5).map((s) => Number(s));
23
+ if (octets.some((o) => o > 255))
24
+ return null;
25
+ return octets;
26
+ }
27
+ /** True if an IPv4 address falls in a private/loopback/link-local/reserved range. */
28
+ function isBlockedIpv4(ip) {
29
+ const octets = parseIpv4(ip);
30
+ if (!octets)
31
+ return false;
32
+ const [a, b] = octets;
33
+ if (a === 0)
34
+ return true; // 0.0.0.0/8 "this network" / unspecified
35
+ if (a === 10)
36
+ return true; // 10.0.0.0/8 private
37
+ if (a === 127)
38
+ return true; // 127.0.0.0/8 loopback
39
+ if (a === 169 && b === 254)
40
+ return true; // 169.254.0.0/16 link-local (incl. 169.254.169.254 metadata)
41
+ if (a === 172 && b >= 16 && b <= 31)
42
+ return true; // 172.16.0.0/12 private
43
+ if (a === 192 && b === 168)
44
+ return true; // 192.168.0.0/16 private
45
+ if (a === 100 && b >= 64 && b <= 127)
46
+ return true; // 100.64.0.0/10 CGNAT
47
+ if (a === 198 && (b === 18 || b === 19))
48
+ return true; // 198.18.0.0/15 benchmarking
49
+ if (a === 255 && b === 255)
50
+ return true; // broadcast-ish
51
+ return false;
52
+ }
53
+ /**
54
+ * Expand an IPv6 literal into its 8 sixteen-bit groups, or null if unparseable.
55
+ * Handles `::` compression and an embedded IPv4 tail (`::ffff:127.0.0.1`).
56
+ */
57
+ function parseIpv6(input) {
58
+ let s = input;
59
+ const tail = [];
60
+ // Peel off a trailing dotted-quad (IPv4-mapped/-compatible forms).
61
+ const v4 = /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(s);
62
+ if (v4) {
63
+ const o = parseIpv4(v4[1]);
64
+ if (!o)
65
+ return null;
66
+ tail.push((o[0] << 8) | o[1], (o[2] << 8) | o[3]);
67
+ s = s.slice(0, v4.index); // leaves a trailing ':' before the compression split
68
+ }
69
+ const halves = s.split("::");
70
+ if (halves.length > 2)
71
+ return null; // more than one "::" is illegal
72
+ const head = halves[0] ? halves[0].split(":").filter(Boolean) : [];
73
+ const rest = halves[1] ? halves[1].split(":").filter(Boolean) : [];
74
+ const toNums = (groups) => {
75
+ const out = [];
76
+ for (const g of groups) {
77
+ if (!/^[0-9a-f]{1,4}$/.test(g))
78
+ return null;
79
+ out.push(parseInt(g, 16));
80
+ }
81
+ return out;
82
+ };
83
+ const headNums = toNums(head);
84
+ const restNums = toNums(rest);
85
+ if (!headNums || !restNums)
86
+ return null;
87
+ let groups;
88
+ if (halves.length === 2) {
89
+ const fill = 8 - (headNums.length + restNums.length + tail.length);
90
+ if (fill < 0)
91
+ return null;
92
+ groups = [
93
+ ...headNums,
94
+ ...Array(fill).fill(0),
95
+ ...restNums,
96
+ ...tail,
97
+ ];
98
+ }
99
+ else {
100
+ groups = [...headNums, ...tail];
101
+ }
102
+ return groups.length === 8 ? groups : null;
103
+ }
104
+ /** The dotted-quad embedded in an IPv6 tail's last two groups. */
105
+ function embeddedV4(g) {
106
+ return `${g[6] >> 8}.${g[6] & 0xff}.${g[7] >> 8}.${g[7] & 0xff}`;
107
+ }
108
+ /** True if an expanded IPv6 address is in a range that must never be reached. */
109
+ function isBlockedIpv6(g) {
110
+ // IPv4-mapped (::ffff:a.b.c.d) and IPv4-compatible (::a.b.c.d): check the v4 part.
111
+ const firstFiveZero = g.slice(0, 5).every((x) => x === 0);
112
+ const firstSixZero = firstFiveZero && g[5] === 0;
113
+ const embedded = embeddedV4(g);
114
+ if (firstFiveZero && g[5] === 0xffff)
115
+ return isBlockedIpv4(embedded); // ::ffff:x
116
+ if (firstSixZero &&
117
+ !(g[6] === 0 && g[7] === 0) &&
118
+ !(g[6] === 0 && g[7] === 1))
119
+ return isBlockedIpv4(embedded); // ::x.y.z.w (IPv4-compatible, deprecated)
120
+ if (g.every((x) => x === 0))
121
+ return true; // :: unspecified
122
+ if (firstSixZero && g[6] === 0 && g[7] === 1)
123
+ return true; // ::1 loopback
124
+ if ((g[0] & 0xffc0) === 0xfe80)
125
+ return true; // fe80::/10 link-local (fe80–febf)
126
+ if ((g[0] & 0xfe00) === 0xfc00)
127
+ return true; // fc00::/7 unique-local (fc00–fdff)
128
+ if ((g[0] & 0xff00) === 0xff00)
129
+ return true; // ff00::/8 multicast
130
+ return false;
131
+ }
132
+ /** Strip brackets, lowercase, and drop an IPv6 scope/zone id from an address. */
133
+ function normalizeAddress(addr) {
134
+ return addr
135
+ .trim()
136
+ .toLowerCase()
137
+ .replace(/^\[|\]$/g, "")
138
+ .split("%")[0];
139
+ }
140
+ /** True if an address (v4 or v6) is in a range that must never be reached. */
141
+ export function isBlockedAddress(addr) {
142
+ const ip = normalizeAddress(addr);
143
+ if (ip.includes(":")) {
144
+ const groups = parseIpv6(ip);
145
+ if (!groups)
146
+ return true; // fail closed: an unparseable colon-address is refused
147
+ return isBlockedIpv6(groups);
148
+ }
149
+ return isBlockedIpv4(ip);
150
+ }
151
+ /**
152
+ * True if an address is a LOOPBACK address (127.0.0.0/8, ::1, or an IPv4-mapped
153
+ * loopback). This is the ONLY range the network MCP transport permits over plain
154
+ * `http` (loopback dev servers); everything else must be `https` to a public IP.
155
+ * An unparseable address is not loopback (fail closed — it won't get the http pass).
156
+ */
157
+ export function isLoopbackAddress(addr) {
158
+ const ip = normalizeAddress(addr);
159
+ if (ip.includes(":")) {
160
+ const g = parseIpv6(ip);
161
+ if (!g)
162
+ return false;
163
+ const firstSixZero = g.slice(0, 6).every((x) => x === 0);
164
+ if (firstSixZero && g[6] === 0 && g[7] === 1)
165
+ return true; // ::1
166
+ // ::ffff:127.x and deprecated ::127.x embed a v4 loopback.
167
+ const firstFiveZero = g.slice(0, 5).every((x) => x === 0);
168
+ if (firstFiveZero && (g[5] === 0xffff || g[5] === 0)) {
169
+ const embedded = `${g[6] >> 8}.${g[6] & 0xff}.${g[7] >> 8}.${g[7] & 0xff}`;
170
+ const o = parseIpv4(embedded);
171
+ return o !== null && o[0] === 127;
172
+ }
173
+ return false;
174
+ }
175
+ const o = parseIpv4(ip);
176
+ return o !== null && o[0] === 127;
177
+ }
178
+ /**
179
+ * Resolve `host` and require that EVERY resolved address is public — the SSRF
180
+ * gate for any URL a caller did not fully control. Throws {@link BlockedHostError}
181
+ * if any address is private/loopback/link-local/reserved (a security refusal), or
182
+ * {@link HostUnresolvedError} if the host cannot be resolved (a network failure).
183
+ * Returns the validated addresses so the caller can PIN the connection to them.
184
+ */
185
+ export async function assertAllPublic(host, resolve) {
186
+ const cleaned = host.replace(/^\[|\]$/g, "");
187
+ let addresses;
188
+ try {
189
+ addresses = await resolve(cleaned);
190
+ }
191
+ catch (err) {
192
+ throw new HostUnresolvedError(`could not resolve host "${cleaned}": ${err.message}`);
193
+ }
194
+ if (addresses.length === 0) {
195
+ throw new HostUnresolvedError(`host "${cleaned}" resolved to no addresses`);
196
+ }
197
+ for (const addr of addresses) {
198
+ if (isBlockedAddress(addr)) {
199
+ throw new BlockedHostError(`host "${cleaned}" resolves to ${addr}, a private/loopback/link-local address`);
200
+ }
201
+ }
202
+ return addresses;
203
+ }
204
+ /**
205
+ * A `dns.lookup`-compatible function that ignores the hostname and always hands
206
+ * back one of the pre-validated `addresses`. This is what pins a connection to the
207
+ * address the SSRF check already approved, defeating DNS rebinding: the socket can
208
+ * only reach a validated IP, never a value re-resolved at connect time. The Host
209
+ * header / TLS SNI still carry the original hostname (only the dialed IP is pinned).
210
+ */
211
+ export function pinnedLookup(addresses) {
212
+ const resolved = addresses.map((address) => ({
213
+ address,
214
+ family: address.includes(":") ? 6 : 4,
215
+ }));
216
+ return (_hostname, options, callback) => {
217
+ const all = typeof options === "object" && options !== null && "all" in options
218
+ ? options.all
219
+ : false;
220
+ if (all)
221
+ callback(null, resolved);
222
+ else
223
+ callback(null, resolved[0].address, resolved[0].family);
224
+ };
225
+ }
226
+ /** Sort + dedupe an address set so equality checks are order-insensitive. */
227
+ export function normalizeAddressSet(addresses) {
228
+ return [...new Set(addresses.map(normalizeAddress))].sort();
229
+ }
@@ -4,6 +4,7 @@ export { detectCapabilities, detectReducedMotion, resolveColumns, DEFAULT_COLUMN
4
4
  export { attachResize, processResizeSignal, type ResizeSignal, } from "./resize.js";
5
5
  export { fit, fitMiddle, reflow, stripAnsi, visibleWidth, kvStack, MIN_VALUE_COLS, type KvRow, } from "./layout.js";
6
6
  export { composeStatusLine, describePhase, ELAPSED_AFTER_MS, fitStatusLine, formatElapsed, formatTokens, phaseIdentity, } from "./state.js";
7
+ export { createFrameClock, inTransition, intervalFrameTimer, spinnerGlyph, FRAME_INTERVAL_MS, TRANSITION_TICKS, type FrameClock, type FrameTimer, } from "./motion.js";
7
8
  export { renderActionPreview, PREVIEW_MAX_LINES } from "./diff.js";
8
9
  export { createStreamHighlighter, defaultLineHighlighter, type HighlightCarry, type LineHighlighter, type StreamHighlighter, } from "./highlight.js";
9
10
  export { PlainRenderer } from "./plain-renderer.js";
@@ -7,6 +7,7 @@ export { detectCapabilities, detectReducedMotion, resolveColumns, DEFAULT_COLUMN
7
7
  export { attachResize, processResizeSignal, } from "./resize.js";
8
8
  export { fit, fitMiddle, reflow, stripAnsi, visibleWidth, kvStack, MIN_VALUE_COLS, } from "./layout.js";
9
9
  export { composeStatusLine, describePhase, ELAPSED_AFTER_MS, fitStatusLine, formatElapsed, formatTokens, phaseIdentity, } from "./state.js";
10
+ export { createFrameClock, inTransition, intervalFrameTimer, spinnerGlyph, FRAME_INTERVAL_MS, TRANSITION_TICKS, } from "./motion.js";
10
11
  export { renderActionPreview, PREVIEW_MAX_LINES } from "./diff.js";
11
12
  export { createStreamHighlighter, defaultLineHighlighter, } from "./highlight.js";
12
13
  export { PlainRenderer } from "./plain-renderer.js";
@@ -0,0 +1,76 @@
1
+ import type { ThemeGlyphs } from "../theme/index.js";
2
+ /**
3
+ * Motion (U.10) — one frame clock drives every live animation on the render
4
+ * seam, and a tiny set of pure frame→glyph/style primitives express it. The
5
+ * design is the same honest-signal discipline as U.4 state: motion encodes real
6
+ * state (work is progressing, the activity changed, a result landed) and never
7
+ * decorates. It is a cosmetic overlay on the transient live region (U.2) — never
8
+ * in the content path, never touching committed bytes.
9
+ *
10
+ * The single gate is `caps.spinner` (`cursor && !reducedMotion`, from U.11):
11
+ * when motion is reduced the clock is *disabled* — it schedules nothing and
12
+ * never ticks, so every motion collapses to its static end-state, drawn once.
13
+ * There is exactly one clock per renderer (refcounted like the U.12 resize
14
+ * signal), not a timer per surface.
15
+ */
16
+ /** The one frame cadence: 10 fps, the pre-U.10 spinner interval. Bounded by construction. */
17
+ export declare const FRAME_INTERVAL_MS = 100;
18
+ /**
19
+ * How many frames a phase-transition emphasis lasts before it settles — ~300ms
20
+ * at {@link FRAME_INTERVAL_MS}. Long enough for the eye to catch "the activity
21
+ * changed", short enough to never read as decoration.
22
+ */
23
+ export declare const TRANSITION_TICKS = 3;
24
+ /** Schedule/cancel a repeating tick; injectable so tests drive frames deterministically. */
25
+ export interface FrameTimer {
26
+ schedule(callback: () => void, intervalMs: number): {
27
+ unref?: () => void;
28
+ };
29
+ cancel(handle: {
30
+ unref?: () => void;
31
+ }): void;
32
+ }
33
+ /** The real timer: a single unref'd interval that never holds the process open. */
34
+ export declare const intervalFrameTimer: FrameTimer;
35
+ /**
36
+ * The one clock that drives all live animation. Subscribers get a monotonic
37
+ * frame number on each tick; the underlying timer runs ONLY while at least one
38
+ * subscriber is attached (refcounted), and is torn down on the last unsubscribe
39
+ * — the same discipline that keeps the U.12 resize listener from leaking.
40
+ */
41
+ export interface FrameClock {
42
+ /**
43
+ * Attach a per-frame callback. The first subscriber starts the single timer;
44
+ * the returned function detaches and, when it was the last subscriber, stops
45
+ * the timer. A no-op subscription (returns a no-op unsubscribe) when the clock
46
+ * is disabled — reduced motion never schedules anything.
47
+ */
48
+ subscribe(onFrame: (frame: number) => void): () => void;
49
+ /** The current monotonic frame counter. Stays 0 while disabled (no ticks). */
50
+ readonly frame: number;
51
+ /** Whether motion runs at all — false under reduced motion (`caps.spinner`). */
52
+ readonly enabled: boolean;
53
+ }
54
+ /**
55
+ * Build the frame clock. `enabled` is `caps.spinner`: when false the clock is
56
+ * inert — {@link FrameClock.subscribe} schedules nothing, never ticks, and
57
+ * `frame` never advances, so callers draw their static end-state once and stop.
58
+ */
59
+ export declare function createFrameClock(enabled: boolean, timer?: FrameTimer, intervalMs?: number): FrameClock;
60
+ /**
61
+ * The spinner glyph for a frame (U.10 progress motion): the animated braille
62
+ * (or single-column ASCII) cycle when the clock runs, the static marker
63
+ * otherwise. `animated=false` (reduced motion / no cursor) yields the static
64
+ * glyph with no reference to `frame` — the honest still form of "working".
65
+ */
66
+ export declare function spinnerGlyph(frame: number, glyph: ThemeGlyphs, animated: boolean): string;
67
+ /**
68
+ * Whether a phase-transition emphasis is still playing (U.10): true for the
69
+ * first {@link TRANSITION_TICKS} frames after the phase *identity* changed. The
70
+ * caller records the frame the identity began on and passes it here; when the
71
+ * clock is not advancing (reduced motion), `startFrame === frame` forever, so
72
+ * this is briefly true only if drawn on the very first frame — the renderer
73
+ * gates the whole emphasis behind `clock.enabled`, so a static draw always uses
74
+ * the settled end-state.
75
+ */
76
+ export declare function inTransition(frame: number, startFrame: number): boolean;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Motion (U.10) — one frame clock drives every live animation on the render
3
+ * seam, and a tiny set of pure frame→glyph/style primitives express it. The
4
+ * design is the same honest-signal discipline as U.4 state: motion encodes real
5
+ * state (work is progressing, the activity changed, a result landed) and never
6
+ * decorates. It is a cosmetic overlay on the transient live region (U.2) — never
7
+ * in the content path, never touching committed bytes.
8
+ *
9
+ * The single gate is `caps.spinner` (`cursor && !reducedMotion`, from U.11):
10
+ * when motion is reduced the clock is *disabled* — it schedules nothing and
11
+ * never ticks, so every motion collapses to its static end-state, drawn once.
12
+ * There is exactly one clock per renderer (refcounted like the U.12 resize
13
+ * signal), not a timer per surface.
14
+ */
15
+ /** The one frame cadence: 10 fps, the pre-U.10 spinner interval. Bounded by construction. */
16
+ export const FRAME_INTERVAL_MS = 100;
17
+ /**
18
+ * How many frames a phase-transition emphasis lasts before it settles — ~300ms
19
+ * at {@link FRAME_INTERVAL_MS}. Long enough for the eye to catch "the activity
20
+ * changed", short enough to never read as decoration.
21
+ */
22
+ export const TRANSITION_TICKS = 3;
23
+ /** The real timer: a single unref'd interval that never holds the process open. */
24
+ export const intervalFrameTimer = {
25
+ schedule(callback, intervalMs) {
26
+ const handle = setInterval(callback, intervalMs);
27
+ handle.unref?.();
28
+ return handle;
29
+ },
30
+ cancel(handle) {
31
+ clearInterval(handle);
32
+ },
33
+ };
34
+ /**
35
+ * Build the frame clock. `enabled` is `caps.spinner`: when false the clock is
36
+ * inert — {@link FrameClock.subscribe} schedules nothing, never ticks, and
37
+ * `frame` never advances, so callers draw their static end-state once and stop.
38
+ */
39
+ export function createFrameClock(enabled, timer = intervalFrameTimer, intervalMs = FRAME_INTERVAL_MS) {
40
+ const subscribers = new Set();
41
+ let handle = null;
42
+ let frame = 0;
43
+ const tick = () => {
44
+ frame++;
45
+ for (const fn of [...subscribers])
46
+ fn(frame);
47
+ };
48
+ return {
49
+ get frame() {
50
+ return frame;
51
+ },
52
+ enabled,
53
+ subscribe(onFrame) {
54
+ // Reduced motion: register nothing, schedule nothing. The single absolute
55
+ // gate — no timer can ever start, so no frame can ever be emitted.
56
+ if (!enabled)
57
+ return () => { };
58
+ subscribers.add(onFrame);
59
+ if (handle === null)
60
+ handle = timer.schedule(tick, intervalMs);
61
+ return () => {
62
+ subscribers.delete(onFrame);
63
+ if (subscribers.size === 0 && handle !== null) {
64
+ timer.cancel(handle);
65
+ handle = null;
66
+ }
67
+ };
68
+ },
69
+ };
70
+ }
71
+ /**
72
+ * The spinner glyph for a frame (U.10 progress motion): the animated braille
73
+ * (or single-column ASCII) cycle when the clock runs, the static marker
74
+ * otherwise. `animated=false` (reduced motion / no cursor) yields the static
75
+ * glyph with no reference to `frame` — the honest still form of "working".
76
+ */
77
+ export function spinnerGlyph(frame, glyph, animated) {
78
+ if (!animated)
79
+ return glyph.spinnerStatic;
80
+ const frames = glyph.spinnerFrames;
81
+ return frames[frame % frames.length];
82
+ }
83
+ /**
84
+ * Whether a phase-transition emphasis is still playing (U.10): true for the
85
+ * first {@link TRANSITION_TICKS} frames after the phase *identity* changed. The
86
+ * caller records the frame the identity began on and passes it here; when the
87
+ * clock is not advancing (reduced motion), `startFrame === frame` forever, so
88
+ * this is briefly true only if drawn on the very first frame — the renderer
89
+ * gates the whole emphasis behind `clock.enabled`, so a static draw always uses
90
+ * the settled end-state.
91
+ */
92
+ export function inTransition(frame, startFrame) {
93
+ return frame - startFrame < TRANSITION_TICKS;
94
+ }
@@ -25,6 +25,15 @@ import type { ProgressState, RenderCapabilities, RenderPhase, RenderStream, Stre
25
25
  * A committed write still only *hides* the drawn line (registers survive);
26
26
  * the next state transition redraws. Nothing redraws per streamed delta, so
27
27
  * the U.2 first-chunk-immediate guarantee is untouched.
28
+ *
29
+ * U.10 puts every live animation on ONE frame clock (see motion.ts) instead of
30
+ * a private interval: the spinner glyph and the phase-transition emphasis draw
31
+ * off `clock.frame`, subscribed only while the line is visible and torn down on
32
+ * hide/close (no leaked timer). Reduced motion disables the clock — the line is
33
+ * drawn once in its static end-state. The result reveal is a one-time entrance
34
+ * emphasis on a committed tool note (never a redraw of committed bytes), so it
35
+ * needs no clock. Motion is a cosmetic overlay on the live region only; it never
36
+ * gates a committed write, so the latency guarantee holds with motion active.
28
37
  */
29
38
  export declare class TtyRenderer implements StreamRenderer {
30
39
  readonly caps: RenderCapabilities;
@@ -45,8 +54,12 @@ export declare class TtyRenderer implements StreamRenderer {
45
54
  private displaced;
46
55
  /** Whether the live line is currently drawn on screen. */
47
56
  private lineVisible;
48
- private timer;
49
- private frame;
57
+ /** The one frame clock driving live animation (U.10); disabled under reduced motion. */
58
+ private readonly clock;
59
+ /** Detach from the clock; null when the live line is hidden (no ticks wanted). */
60
+ private unsubscribeFrame;
61
+ /** Clock frame the current phase *identity* began on — drives the transition emphasis (U.10). */
62
+ private phaseStartFrame;
50
63
  private closed;
51
64
  /** Unsubscribe from the resize signal (U.12); null when the stream can't resize. */
52
65
  private unsubscribeResize;
@@ -67,7 +80,8 @@ export declare class TtyRenderer implements StreamRenderer {
67
80
  * zero-cost: no redraw-under per delta.)
68
81
  */
69
82
  private hideLine;
70
- private stopTimer;
83
+ /** Detach from the frame clock — the timer stops once this is the last subscriber. */
84
+ private stopFrames;
71
85
  /**
72
86
  * The current live-line text, composed from the registers. `null` means the
73
87
  * line must be hidden: nothing to say, or an interactive prompt owns the
@@ -3,10 +3,10 @@ import { createStreamPrinter } from "../cli/stream-print.js";
3
3
  import { renderActionPreview } from "./diff.js";
4
4
  import { createStreamHighlighter, } from "./highlight.js";
5
5
  import { fit } from "./layout.js";
6
+ import { createFrameClock, inTransition, spinnerGlyph, } from "./motion.js";
6
7
  import { ELAPSED_AFTER_MS, fitStatusLine, formatElapsed, phaseIdentity, } from "./state.js";
7
8
  /** Erase the current line and return the cursor to column 0. */
8
9
  const CLEAR_LINE = "\r\x1b[2K";
9
- const SPINNER_INTERVAL_MS = 100;
10
10
  /**
11
11
  * The interactive renderer: committed content is append-only; the one transient
12
12
  * thing on screen is a single managed status line, redrawn in place.
@@ -31,6 +31,15 @@ const SPINNER_INTERVAL_MS = 100;
31
31
  * A committed write still only *hides* the drawn line (registers survive);
32
32
  * the next state transition redraws. Nothing redraws per streamed delta, so
33
33
  * the U.2 first-chunk-immediate guarantee is untouched.
34
+ *
35
+ * U.10 puts every live animation on ONE frame clock (see motion.ts) instead of
36
+ * a private interval: the spinner glyph and the phase-transition emphasis draw
37
+ * off `clock.frame`, subscribed only while the line is visible and torn down on
38
+ * hide/close (no leaked timer). Reduced motion disables the clock — the line is
39
+ * drawn once in its static end-state. The result reveal is a one-time entrance
40
+ * emphasis on a committed tool note (never a redraw of committed bytes), so it
41
+ * needs no clock. Motion is a cosmetic overlay on the live region only; it never
42
+ * gates a committed write, so the latency guarantee holds with motion active.
34
43
  */
35
44
  export class TtyRenderer {
36
45
  caps;
@@ -51,8 +60,12 @@ export class TtyRenderer {
51
60
  displaced = null;
52
61
  /** Whether the live line is currently drawn on screen. */
53
62
  lineVisible = false;
54
- timer = null;
55
- frame = 0;
63
+ /** The one frame clock driving live animation (U.10); disabled under reduced motion. */
64
+ clock;
65
+ /** Detach from the clock; null when the live line is hidden (no ticks wanted). */
66
+ unsubscribeFrame = null;
67
+ /** Clock frame the current phase *identity* began on — drives the transition emphasis (U.10). */
68
+ phaseStartFrame = 0;
56
69
  closed = false;
57
70
  /** Unsubscribe from the resize signal (U.12); null when the stream can't resize. */
58
71
  unsubscribeResize = null;
@@ -62,6 +75,10 @@ export class TtyRenderer {
62
75
  this.theme = resolveTheme(caps);
63
76
  this.highlighter = createStreamHighlighter(this.theme);
64
77
  this.print = this.newPrinter();
78
+ // One clock for all live motion (U.10). Gated on `caps.spinner`
79
+ // (cursor && !reducedMotion), so reduced motion yields an inert clock that
80
+ // never schedules or ticks — the line is drawn once, statically.
81
+ this.clock = createFrameClock(caps.spinner);
65
82
  // Resize reactivity (U.12): only the live line reflows at the new width —
66
83
  // committed rows above are immutable and never rewritten.
67
84
  this.unsubscribeResize = caps.onResize?.(() => this.reflowLive()) ?? null;
@@ -107,13 +124,14 @@ export class TtyRenderer {
107
124
  if (!this.lineVisible)
108
125
  return;
109
126
  this.lineVisible = false;
110
- this.stopTimer();
127
+ this.stopFrames();
111
128
  this.out.write(CLEAR_LINE);
112
129
  }
113
- stopTimer() {
114
- if (this.timer !== null) {
115
- clearInterval(this.timer);
116
- this.timer = null;
130
+ /** Detach from the frame clock — the timer stops once this is the last subscriber. */
131
+ stopFrames() {
132
+ if (this.unsubscribeFrame !== null) {
133
+ this.unsubscribeFrame();
134
+ this.unsubscribeFrame = null;
117
135
  }
118
136
  }
119
137
  /**
@@ -146,28 +164,36 @@ export class TtyRenderer {
146
164
  return;
147
165
  }
148
166
  this.drawLine(line);
149
- if (this.caps.spinner && this.timer === null) {
150
- this.timer = setInterval(() => {
151
- this.frame++;
167
+ // Subscribe to the one clock while the line is live; the clock owns the
168
+ // single (unref'd) timer and only runs while something is subscribed. An
169
+ // inert clock (reduced motion) subscribes to nothing — the static draw
170
+ // above is the whole story, no frames follow.
171
+ if (this.clock.enabled && this.unsubscribeFrame === null) {
172
+ this.unsubscribeFrame = this.clock.subscribe(() => {
152
173
  const current = this.currentLine();
153
174
  if (current !== null)
154
175
  this.drawLine(current);
155
- }, SPINNER_INTERVAL_MS);
156
- // Never hold the process open for a spinner.
157
- this.timer.unref?.();
176
+ });
158
177
  }
159
178
  }
160
179
  drawLine(text) {
161
180
  this.lineVisible = true;
162
- const frames = this.theme.glyph.spinnerFrames;
163
- const glyph = this.caps.spinner
164
- ? frames[this.frame % frames.length]
165
- : this.theme.glyph.spinnerStatic;
181
+ const animated = this.caps.spinner;
182
+ const glyph = spinnerGlyph(this.clock.frame, this.theme.glyph, animated);
166
183
  // Reserve glyph + space; ANSI-aware fit so the live line can never
167
184
  // soft-wrap (currentLine already fits — this is defense in depth) (U.12).
168
185
  const room = Math.max(1, this.caps.width - 2);
169
186
  const line = fit(text, room, this.theme.glyph.ellipsis);
170
- this.out.write(`${CLEAR_LINE}${this.theme.accent(glyph)} ${this.theme.muted(line)}`);
187
+ // Phase-transition emphasis (U.10): a newly-begun activity brightens for a
188
+ // few frames, then settles to muted. Purely a color role, so NO_COLOR and
189
+ // the static (reduced-motion) draw both collapse to the settled end-state —
190
+ // and it only ever plays on a live phase whose identity just changed.
191
+ const emphasized = animated &&
192
+ this.rawStatus === null &&
193
+ this.phase !== null &&
194
+ inTransition(this.clock.frame, this.phaseStartFrame);
195
+ const body = emphasized ? this.theme.accent(line) : this.theme.muted(line);
196
+ this.out.write(`${CLEAR_LINE}${this.theme.accent(glyph)} ${body}`);
171
197
  }
172
198
  beginTurn() {
173
199
  this.highlighter = createStreamHighlighter(this.theme);
@@ -230,8 +256,13 @@ export class TtyRenderer {
230
256
  }
231
257
  const before = phaseIdentity(this.phase);
232
258
  this.phase = phase;
233
- if (phaseIdentity(phase) !== before)
259
+ if (phaseIdentity(phase) !== before) {
234
260
  this.phaseStartedAt = Date.now();
261
+ // A new activity: restart the transition emphasis from the current frame
262
+ // (U.10). Only a genuine identity change resets it — a thinking phase that
263
+ // merely gains token counts keeps drawing settled, no re-flourish.
264
+ this.phaseStartFrame = this.clock.frame;
265
+ }
235
266
  if (phase === null) {
236
267
  // A cleared phase means "nothing is happening" — hide rather than
237
268
  // redraw a bare progress prefix between turns.
@@ -263,7 +294,13 @@ export class TtyRenderer {
263
294
  const elapsed = started === null ? 0 : Date.now() - started.at;
264
295
  const suffix = elapsed >= ELAPSED_AFTER_MS ? ` (${formatElapsed(elapsed)})` : "";
265
296
  const mark = event.ok ? this.theme.glyph.success : this.theme.glyph.failure;
266
- this.note(`${mark} ${event.label}${suffix}`);
297
+ // Result reveal (U.10): a freshly-landed result gets a one-time entrance
298
+ // emphasis on its mark when motion is active — written once into committed
299
+ // history and never redrawn (immutable). It gates on the same motion axis,
300
+ // so reduced motion shows the plain mark; under NO_COLOR `strong` is the
301
+ // identity, so the committed bytes are byte-for-byte unchanged either way.
302
+ const shownMark = this.caps.spinner ? this.theme.strong(mark) : mark;
303
+ this.note(`${shownMark} ${event.label}${suffix}`);
267
304
  }
268
305
  promptResolved() {
269
306
  if (this.closed)
@@ -2,7 +2,7 @@ import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { z } from "zod";
4
4
  import { resolveToolPath } from "./paths.js";
5
- import { countOccurrences } from "./edit-file.js";
5
+ import { applyEol, detectEol, findMatch, tierLabel } from "./match.js";
6
6
  /** How many leading lines of a created file the approval preview shows. */
7
7
  const PREVIEW_LINES = 20;
8
8
  const HunkSchema = z.object({
@@ -143,25 +143,29 @@ async function planOp(i, op, abs, ctx) {
143
143
  }
144
144
  return { ok: false, error: opError(i, op, err.message) };
145
145
  }
146
+ // Detect the file's line ending once, from the original bytes, so every hunk
147
+ // re-encodes newStr to the same convention as content mutates across hunks.
148
+ const fileEol = detectEol(content);
146
149
  for (let h = 0; h < op.hunks.length; h++) {
147
150
  const { oldStr, newStr } = op.hunks[h];
148
- const matches = countOccurrences(content, oldStr);
149
- if (matches === 0) {
151
+ const match = findMatch(content, oldStr);
152
+ if (match.kind === "none") {
150
153
  return {
151
154
  ok: false,
152
155
  error: opError(i, op, `hunk ${h + 1}: oldStr not found`),
153
156
  };
154
157
  }
155
- if (matches > 1) {
158
+ if (match.kind === "ambiguous") {
156
159
  return {
157
160
  ok: false,
158
- error: opError(i, op, `hunk ${h + 1}: oldStr not unique (${matches} matches)`),
161
+ error: opError(i, op, `hunk ${h + 1}: oldStr not unique (${match.count} matches${tierLabel(match.tier)})`),
159
162
  };
160
163
  }
161
- // Replace by index so `$` patterns in newStr aren't interpreted.
162
- const idx = content.indexOf(oldStr);
164
+ // Splice by offset so `$` patterns in newStr aren't interpreted.
163
165
  content =
164
- content.slice(0, idx) + newStr + content.slice(idx + oldStr.length);
166
+ content.slice(0, match.start) +
167
+ applyEol(newStr, fileEol) +
168
+ content.slice(match.end);
165
169
  }
166
170
  return {
167
171
  ok: true,
@@ -1,7 +1,5 @@
1
1
  import { z } from "zod";
2
2
  import type { Tool } from "../types.js";
3
- /** Count non-overlapping exact occurrences of `needle` in `haystack`. */
4
- export declare function countOccurrences(haystack: string, needle: string): number;
5
3
  /**
6
4
  * Replace one exact, unique occurrence of `old_str` with `new_str` in a file.
7
5
  * The uniqueness requirement is checked before approval so the model can fix an