@moxxy/sdk 0.15.0 → 0.15.2

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/dist/compactor.d.ts +1 -1
  2. package/dist/compactor.d.ts.map +1 -1
  3. package/dist/elision-state.js +23 -23
  4. package/dist/elision-state.js.map +1 -1
  5. package/dist/errors.js +17 -2
  6. package/dist/errors.js.map +1 -1
  7. package/dist/index.d.ts +2 -2
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +1 -1
  10. package/dist/index.js.map +1 -1
  11. package/dist/json-file-store.d.ts +6 -1
  12. package/dist/json-file-store.d.ts.map +1 -1
  13. package/dist/json-file-store.js +20 -4
  14. package/dist/json-file-store.js.map +1 -1
  15. package/dist/mode/abort-backoff.d.ts +23 -0
  16. package/dist/mode/abort-backoff.d.ts.map +1 -0
  17. package/dist/mode/abort-backoff.js +53 -0
  18. package/dist/mode/abort-backoff.js.map +1 -0
  19. package/dist/mode/collect-stream.d.ts.map +1 -1
  20. package/dist/mode/collect-stream.js +34 -7
  21. package/dist/mode/collect-stream.js.map +1 -1
  22. package/dist/mode/project-messages.d.ts.map +1 -1
  23. package/dist/mode/project-messages.js +63 -5
  24. package/dist/mode/project-messages.js.map +1 -1
  25. package/dist/mode/stable-hash.d.ts +8 -0
  26. package/dist/mode/stable-hash.d.ts.map +1 -1
  27. package/dist/mode/stable-hash.js +67 -7
  28. package/dist/mode/stable-hash.js.map +1 -1
  29. package/dist/mode-helpers.d.ts +1 -0
  30. package/dist/mode-helpers.d.ts.map +1 -1
  31. package/dist/mode-helpers.js +1 -0
  32. package/dist/mode-helpers.js.map +1 -1
  33. package/dist/mode.d.ts.map +1 -1
  34. package/dist/mode.js +7 -1
  35. package/dist/mode.js.map +1 -1
  36. package/dist/provider-utils.d.ts +1 -10
  37. package/dist/provider-utils.d.ts.map +1 -1
  38. package/dist/provider-utils.js +20 -11
  39. package/dist/provider-utils.js.map +1 -1
  40. package/dist/requirements.d.ts +18 -3
  41. package/dist/requirements.d.ts.map +1 -1
  42. package/dist/tool-dispatch.d.ts.map +1 -1
  43. package/dist/tool-dispatch.js +13 -3
  44. package/dist/tool-dispatch.js.map +1 -1
  45. package/dist/tool-display.d.ts.map +1 -1
  46. package/dist/tool-display.js +23 -4
  47. package/dist/tool-display.js.map +1 -1
  48. package/dist/tunnel.d.ts.map +1 -1
  49. package/dist/tunnel.js +12 -2
  50. package/dist/tunnel.js.map +1 -1
  51. package/dist/view-renderer.d.ts +6 -0
  52. package/dist/view-renderer.d.ts.map +1 -1
  53. package/dist/view-renderer.js +17 -3
  54. package/dist/view-renderer.js.map +1 -1
  55. package/package.json +1 -1
  56. package/src/compactor.ts +6 -1
  57. package/src/elision-state.ts +22 -22
  58. package/src/errors.test.ts +19 -0
  59. package/src/errors.ts +17 -2
  60. package/src/index.ts +3 -0
  61. package/src/json-file-store.test.ts +40 -0
  62. package/src/json-file-store.ts +25 -4
  63. package/src/loop-helpers.test.ts +72 -0
  64. package/src/mode/abort-backoff.test.ts +64 -0
  65. package/src/mode/abort-backoff.ts +53 -0
  66. package/src/mode/collect-stream.ts +37 -7
  67. package/src/mode/project-messages.test.ts +64 -2
  68. package/src/mode/project-messages.ts +58 -5
  69. package/src/mode/stable-hash.ts +69 -9
  70. package/src/mode-helpers.ts +1 -0
  71. package/src/mode.test.ts +18 -0
  72. package/src/mode.ts +7 -1
  73. package/src/provider-utils.test.ts +10 -0
  74. package/src/provider-utils.ts +20 -11
  75. package/src/requirements.ts +15 -3
  76. package/src/stuck-loop.test.ts +77 -1
  77. package/src/tool-dispatch.test.ts +215 -0
  78. package/src/tool-dispatch.ts +13 -3
  79. package/src/tool-display.test.ts +24 -0
  80. package/src/tool-display.ts +22 -6
  81. package/src/tunnel.ts +12 -2
  82. package/src/view-renderer.test.ts +12 -0
  83. package/src/view-renderer.ts +16 -2
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { createStuckLoopDetector } from './mode-helpers.js';
2
+ import { createStuckLoopDetector, stableHash } from './mode-helpers.js';
3
3
 
4
4
  describe('createStuckLoopDetector', () => {
5
5
  it('trips on exact-input repeats at repeatThreshold', () => {
@@ -39,4 +39,80 @@ describe('createStuckLoopDetector', () => {
39
39
  expect(sig.stuck).toBe(false);
40
40
  }
41
41
  });
42
+
43
+ // record() hashes the (model-supplied, `unknown`) tool input in the hot
44
+ // dispatch path; a throw there crashes the whole turn. These assert it stays
45
+ // total on hostile/partial input — the worst case the provider can hand us.
46
+ it('does not throw recording a tool input with a circular reference', () => {
47
+ const d = createStuckLoopDetector();
48
+ const input: Record<string, unknown> = { a: 1 };
49
+ input.self = input;
50
+ expect(() => d.record('weird', input)).not.toThrow();
51
+ // And a repeated circular input still trips exact detection (stable key).
52
+ d.record('weird', input);
53
+ expect(d.record('weird', input).stuck).toBe(true);
54
+ });
55
+
56
+ it('does not throw recording a tool input that carries a BigInt', () => {
57
+ const d = createStuckLoopDetector();
58
+ expect(() => d.record('weird', { n: 10n })).not.toThrow();
59
+ });
60
+
61
+ it('does not throw on a pathologically deep tool input', () => {
62
+ const d = createStuckLoopDetector();
63
+ let deep: Record<string, unknown> = {};
64
+ const root = deep;
65
+ for (let i = 0; i < 5000; i++) {
66
+ const next: Record<string, unknown> = {};
67
+ deep.child = next;
68
+ deep = next;
69
+ }
70
+ expect(() => d.record('weird', root)).not.toThrow();
71
+ });
72
+ });
73
+
74
+ describe('stableHash', () => {
75
+ it('is key-order canonical', () => {
76
+ expect(stableHash({ a: 1, b: 2 })).toBe(stableHash({ b: 2, a: 1 }));
77
+ });
78
+
79
+ it('returns a string (never throws) on circular, BigInt, and non-finite input', () => {
80
+ const circular: Record<string, unknown> = {};
81
+ circular.self = circular;
82
+ expect(typeof stableHash(circular)).toBe('string');
83
+ expect(typeof stableHash({ n: 9007199254740993n })).toBe('string');
84
+ expect(typeof stableHash({ x: Number.NaN, y: Infinity })).toBe('string');
85
+ });
86
+
87
+ it('distinguishes a BigInt from the equal-valued number', () => {
88
+ expect(stableHash({ n: 1n })).not.toBe(stableHash({ n: 1 }));
89
+ });
90
+
91
+ it('does not flag a shared (non-cyclic) sub-object as circular', () => {
92
+ const shared = { k: 'v' };
93
+ // Same object in two sibling positions is a DAG, not a cycle.
94
+ expect(stableHash({ a: shared, b: shared })).toBe(
95
+ stableHash({ a: { k: 'v' }, b: { k: 'v' } }),
96
+ );
97
+ });
98
+
99
+ it('never throws even when a value trap / getter throws', () => {
100
+ const hostileProxy = new Proxy(
101
+ {},
102
+ {
103
+ ownKeys() {
104
+ throw new Error('trap throws');
105
+ },
106
+ },
107
+ );
108
+ expect(() => stableHash({ x: hostileProxy })).not.toThrow();
109
+ const throwingGetter = Object.defineProperty({}, 'boom', {
110
+ enumerable: true,
111
+ get() {
112
+ throw new Error('getter throws');
113
+ },
114
+ });
115
+ expect(() => stableHash(throwingGetter)).not.toThrow();
116
+ expect(typeof stableHash(throwingGetter)).toBe('string');
117
+ });
42
118
  });
@@ -0,0 +1,215 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { asSessionId, asTurnId } from './ids.js';
3
+ import type { EmittedEvent, MoxxyEvent } from './events.js';
4
+ import type { ModeContext } from './mode.js';
5
+ import type { CollectedToolUse, StuckLoopDetector, StuckSignal } from './mode-helpers.js';
6
+ import type { ToolCallVerdict } from './hooks.js';
7
+ import type { PermissionDecision } from './permission.js';
8
+ import {
9
+ dispatchToolCall,
10
+ executeToolUses,
11
+ emitRequestsAndDetectStuck,
12
+ type StuckLoopReport,
13
+ } from './tool-dispatch.js';
14
+
15
+ /**
16
+ * The orphan-prevention guarantees in tool-dispatch are load-bearing: the event
17
+ * log must never end on a `tool_call_requested` without a paired `tool_result`,
18
+ * across hook throws, resolver throws, emit throws, mid-batch abort, and a
19
+ * stuck-loop trip. These tests drive each defensive path directly (the mode
20
+ * tests only exercise the happy path).
21
+ */
22
+
23
+ interface StubOpts {
24
+ readonly verdict?: ToolCallVerdict | (() => ToolCallVerdict | Promise<ToolCallVerdict>);
25
+ readonly decision?: PermissionDecision;
26
+ readonly execute?: (name: string, input: unknown) => unknown;
27
+ /** Throw from `emit` for these 0-based emit indices. */
28
+ readonly throwOnEmitIndices?: ReadonlyArray<number>;
29
+ readonly aborted?: boolean;
30
+ }
31
+
32
+ function makeCtx(opts: StubOpts = {}): { ctx: ModeContext; events: MoxxyEvent[] } {
33
+ const events: MoxxyEvent[] = [];
34
+ let emitCount = 0;
35
+ const controller = new AbortController();
36
+ if (opts.aborted) controller.abort();
37
+
38
+ const emit = async (event: EmittedEvent): Promise<MoxxyEvent> => {
39
+ const idx = emitCount++;
40
+ if (opts.throwOnEmitIndices?.includes(idx)) {
41
+ throw new Error('emit boom');
42
+ }
43
+ const full = { ...event, id: `e${idx}`, ts: idx } as unknown as MoxxyEvent;
44
+ events.push(full);
45
+ return full;
46
+ };
47
+
48
+ const ctx = {
49
+ sessionId: asSessionId('s1'),
50
+ turnId: asTurnId('t1'),
51
+ cwd: '/tmp',
52
+ env: {},
53
+ log: { events: () => [], subscribe: () => () => {} } as unknown,
54
+ signal: controller.signal,
55
+ hooks: {
56
+ dispatchToolCall: async (): Promise<ToolCallVerdict> => {
57
+ const v = opts.verdict ?? { action: 'allow' };
58
+ return typeof v === 'function' ? v() : v;
59
+ },
60
+ } as unknown,
61
+ permissions: {
62
+ check: async (): Promise<PermissionDecision> => opts.decision ?? { mode: 'allow' },
63
+ } as unknown,
64
+ tools: {
65
+ get: () => undefined,
66
+ execute: async (name: string, input: unknown) =>
67
+ opts.execute ? opts.execute(name, input) : 'ok',
68
+ } as unknown,
69
+ emit,
70
+ } as unknown as ModeContext;
71
+
72
+ return { ctx, events };
73
+ }
74
+
75
+ async function drain(
76
+ gen: AsyncGenerator<MoxxyEvent, unknown, unknown>,
77
+ ): Promise<unknown> {
78
+ let res = await gen.next();
79
+ while (!res.done) res = await gen.next();
80
+ return res.value;
81
+ }
82
+
83
+ const tool: CollectedToolUse = { id: 'c1', name: 'Read', input: { x: 1 } };
84
+
85
+ describe('dispatchToolCall — orphan prevention', () => {
86
+ it('happy path ends with a successful tool_result', async () => {
87
+ const { ctx, events } = makeCtx({ execute: () => 'output' });
88
+ await drain(dispatchToolCall(ctx, tool, 0));
89
+ const result = events.find((e) => e.type === 'tool_result') as { ok: boolean } | undefined;
90
+ expect(result?.ok).toBe(true);
91
+ });
92
+
93
+ it('synthesizes a failed result when the hook throws (no orphan)', async () => {
94
+ const { ctx, events } = makeCtx({
95
+ verdict: () => {
96
+ throw new Error('hook exploded');
97
+ },
98
+ });
99
+ await drain(dispatchToolCall(ctx, tool, 0));
100
+ const result = events.find((e) => e.type === 'tool_result') as
101
+ | { ok: boolean; error: { message: string } }
102
+ | undefined;
103
+ expect(result?.ok).toBe(false);
104
+ expect(result?.error.message).toContain('pre-execute failure');
105
+ expect(result?.error.message).toContain('hook exploded');
106
+ });
107
+
108
+ it('synthesizes a failed result when the resolver throws', async () => {
109
+ const { ctx, events } = makeCtx({
110
+ decision: undefined,
111
+ });
112
+ // Make permissions.check itself throw via a hostile override.
113
+ (ctx.permissions as unknown as { check: () => Promise<never> }).check = async () => {
114
+ throw new Error('resolver down');
115
+ };
116
+ await drain(dispatchToolCall(ctx, tool, 0));
117
+ const result = events.find((e) => e.type === 'tool_result') as
118
+ | { ok: boolean; error: { message: string } }
119
+ | undefined;
120
+ expect(result?.ok).toBe(false);
121
+ expect(result?.error.message).toContain('pre-execute failure');
122
+ });
123
+
124
+ it('labels a post-run emit failure as "tool ran but result emit failed"', async () => {
125
+ // Emit order: #0 = tool_call_approved, #1 = success tool_result (throws →
126
+ // inner catch), #2 = inner-catch failed tool_result (throws → outer catch).
127
+ // The outer catch must NOT mislabel a tool that already ran as a
128
+ // pre-execute failure.
129
+ const { ctx, events } = makeCtx({
130
+ execute: () => 'output',
131
+ throwOnEmitIndices: [1, 2],
132
+ });
133
+ await drain(dispatchToolCall(ctx, tool, 0));
134
+ const result = events.find((e) => e.type === 'tool_result') as
135
+ | { ok: boolean; error: { message: string } }
136
+ | undefined;
137
+ expect(result?.ok).toBe(false);
138
+ expect(result?.error.message).toContain('tool ran but result emit failed');
139
+ expect(result?.error.message).not.toContain('pre-execute failure');
140
+ });
141
+
142
+ it('emits a denied result + tool_result when the hook denies', async () => {
143
+ const { ctx, events } = makeCtx({ verdict: { action: 'deny', reason: 'nope' } });
144
+ await drain(dispatchToolCall(ctx, tool, 0));
145
+ expect(events.some((e) => e.type === 'tool_call_denied')).toBe(true);
146
+ const result = events.find((e) => e.type === 'tool_result') as { ok: boolean } | undefined;
147
+ expect(result?.ok).toBe(false);
148
+ });
149
+ });
150
+
151
+ describe('executeToolUses — mid-batch abort', () => {
152
+ it('synthesizes a tool_result for every un-run call and an abort when aborted up front', async () => {
153
+ const { ctx, events } = makeCtx({ aborted: true });
154
+ const calls: CollectedToolUse[] = [
155
+ { id: 'a', name: 'Read', input: {} },
156
+ { id: 'b', name: 'Read', input: {} },
157
+ ];
158
+ const stop = await drain(executeToolUses(ctx, calls, 0));
159
+ expect(stop).toBe(true);
160
+ const results = events.filter((e) => e.type === 'tool_result') as Array<{
161
+ callId: string;
162
+ error: { kind: string };
163
+ }>;
164
+ expect(new Set(results.map((r) => r.callId))).toEqual(new Set(['a', 'b']));
165
+ expect(results.every((r) => r.error.kind === 'aborted')).toBe(true);
166
+ expect(events.some((e) => e.type === 'abort')).toBe(true);
167
+ });
168
+ });
169
+
170
+ describe('emitRequestsAndDetectStuck — stuck trip', () => {
171
+ const report: StuckLoopReport = {
172
+ abortedResultMessage: 'stuck — not run',
173
+ nearHint: 'with nearly identical input',
174
+ fatalMessage: ({ toolName }) => `stuck on ${toolName}`,
175
+ };
176
+
177
+ function detector(trip: StuckSignal): StuckLoopDetector {
178
+ return { record: () => trip } as unknown as StuckLoopDetector;
179
+ }
180
+
181
+ it('synthesizes a failed tool_result for the emitted request before the fatal error', async () => {
182
+ const { ctx, events } = makeCtx();
183
+ const stop = await drain(
184
+ emitRequestsAndDetectStuck(
185
+ ctx,
186
+ [tool],
187
+ detector({ stuck: true, kind: 'exact', count: 3 }),
188
+ report,
189
+ ),
190
+ );
191
+ expect(stop).toBe(true);
192
+ // The request must be paired with a synthesized aborted result — no orphan.
193
+ expect(events.some((e) => e.type === 'tool_call_requested')).toBe(true);
194
+ const result = events.find((e) => e.type === 'tool_result') as
195
+ | { ok: boolean; error: { kind: string } }
196
+ | undefined;
197
+ expect(result?.ok).toBe(false);
198
+ expect(result?.error.kind).toBe('aborted');
199
+ expect(events.some((e) => e.type === 'error')).toBe(true);
200
+ });
201
+
202
+ it('returns false (no synthesis) when the detector does not trip', async () => {
203
+ const { ctx, events } = makeCtx();
204
+ const stop = await drain(
205
+ emitRequestsAndDetectStuck(
206
+ ctx,
207
+ [tool],
208
+ detector({ stuck: false, kind: 'exact', count: 0 }),
209
+ report,
210
+ ),
211
+ );
212
+ expect(stop).toBe(false);
213
+ expect(events.filter((e) => e.type === 'tool_result')).toHaveLength(0);
214
+ });
215
+ });
@@ -23,6 +23,11 @@ export async function* dispatchToolCall(
23
23
  t: CollectedToolUse,
24
24
  iteration: number,
25
25
  ): AsyncGenerator<MoxxyEvent, void, unknown> {
26
+ // Set once the tool body resolves, so the outer catch can tell a genuine
27
+ // pre-execute failure (hook/permission/emit-before-run threw) apart from a
28
+ // post-run failure (the tool ran — possibly with side effects — but emitting
29
+ // its success result threw). The two warrant different log wording.
30
+ let executed = false;
26
31
  try {
27
32
  const verdict = await ctx.hooks.dispatchToolCall({
28
33
  sessionId: ctx.sessionId,
@@ -70,6 +75,7 @@ export async function* dispatchToolCall(
70
75
  log: ctx.log,
71
76
  ...(ctx.subagents ? { subagents: ctx.subagents } : {}),
72
77
  });
78
+ executed = true;
73
79
  yield await ctx.emit({
74
80
  type: 'tool_result',
75
81
  sessionId: ctx.sessionId,
@@ -94,9 +100,13 @@ export async function* dispatchToolCall(
94
100
  }
95
101
  } catch (err) {
96
102
  // Defensive: a hook handler, permission resolver, or the emit itself threw
97
- // before we could produce a tool_result. Synthesize a failed result so the
98
- // event log stays well-formed (no orphan tool_call_requested).
103
+ // before (or after) we could produce a tool_result. Synthesize a failed
104
+ // result so the event log stays well-formed (no orphan tool_call_requested).
105
+ // When `executed` is set the tool actually ran (possibly with side effects)
106
+ // and only the success-result emit failed — don't mislabel that as a
107
+ // pre-execute failure.
99
108
  const message = err instanceof Error ? err.message : String(err);
109
+ const prefix = executed ? 'tool ran but result emit failed' : 'pre-execute failure';
100
110
  yield await ctx.emit({
101
111
  type: 'tool_result',
102
112
  sessionId: ctx.sessionId,
@@ -104,7 +114,7 @@ export async function* dispatchToolCall(
104
114
  source: 'tool',
105
115
  callId: asToolCallId(t.id),
106
116
  ok: false,
107
- error: { kind: 'threw', message: `pre-execute failure: ${message}` },
117
+ error: { kind: 'threw', message: `${prefix}: ${message}` },
108
118
  });
109
119
  }
110
120
  }
@@ -68,4 +68,28 @@ describe('guards', () => {
68
68
  expect(isToolDisplayResult({ forModel: 'x' })).toBe(false);
69
69
  expect(isToolDisplayResult('wrote 12 chars')).toBe(false);
70
70
  });
71
+
72
+ it('rejects malformed file-diff objects (not just the right `kind`)', () => {
73
+ // Right kind, but the rest of the shape is bogus — must not be trusted as a
74
+ // structured diff (channels would otherwise render garbage / the model loses
75
+ // the real output).
76
+ expect(isFileDiffDisplay({ kind: 'file-diff' })).toBe(false); // no path/counts/hunks
77
+ expect(isFileDiffDisplay({ kind: 'file-diff', path: 1, added: 1, removed: 0, hunks: [] })).toBe(false); // path not a string
78
+ expect(isFileDiffDisplay({ kind: 'file-diff', path: 'a', added: '1', removed: 0, hunks: [] })).toBe(false); // added not numeric
79
+ expect(isFileDiffDisplay({ kind: 'file-diff', path: 'a', added: 1, removed: 0, hunks: 'nope' })).toBe(false); // hunks not an array
80
+ expect(isFileDiffDisplay({ kind: 'file-diff', path: 'a', added: 1, removed: 0, hunks: [{ lines: 'x' }] })).toBe(false); // hunk.lines not an array
81
+ expect(isFileDiffDisplay({ kind: 'file-diff', path: 'a', added: 1, removed: 0, hunks: [null] })).toBe(false); // bad hunk entry
82
+ // A malformed display must also fail the wrapping ToolDisplayResult guard.
83
+ expect(isToolDisplayResult({ forModel: 'x', display: { kind: 'file-diff' } })).toBe(false);
84
+ // A well-formed hunk passes.
85
+ expect(
86
+ isFileDiffDisplay({
87
+ kind: 'file-diff',
88
+ path: 'a',
89
+ added: 1,
90
+ removed: 0,
91
+ hunks: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: [] }],
92
+ }),
93
+ ).toBe(true);
94
+ });
71
95
  });
@@ -72,12 +72,28 @@ export function isToolDisplay(x: unknown): x is ToolDisplay {
72
72
  }
73
73
 
74
74
  export function isFileDiffDisplay(x: unknown): x is FileDiffDisplay {
75
- return (
76
- typeof x === 'object' &&
77
- x !== null &&
78
- (x as { kind?: unknown }).kind === 'file-diff' &&
79
- Array.isArray((x as { hunks?: unknown }).hunks)
80
- );
75
+ if (typeof x !== 'object' || x === null) return false;
76
+ const d = x as {
77
+ kind?: unknown;
78
+ path?: unknown;
79
+ added?: unknown;
80
+ removed?: unknown;
81
+ hunks?: unknown;
82
+ };
83
+ if (d.kind !== 'file-diff') return false;
84
+ // Validate the load-bearing shape, not just `kind` + a `hunks` array: this
85
+ // guard decides whether a tool result is TRUSTED as a structured diff (the
86
+ // model is then shown only `forModel`, and channels render the payload). A
87
+ // malformed object with a bogus path / non-numeric counts / a non-array hunk
88
+ // must be rejected, not silently rendered.
89
+ if (typeof d.path !== 'string') return false;
90
+ if (typeof d.added !== 'number' || typeof d.removed !== 'number') return false;
91
+ if (!Array.isArray(d.hunks)) return false;
92
+ for (const hunk of d.hunks) {
93
+ if (typeof hunk !== 'object' || hunk === null) return false;
94
+ if (!Array.isArray((hunk as { lines?: unknown }).lines)) return false;
95
+ }
96
+ return true;
81
97
  }
82
98
 
83
99
  /** Human summary, e.g. "Added 10 lines, removed 1 line". */
package/src/tunnel.ts CHANGED
@@ -82,8 +82,18 @@ function ensureExitHook(): void {
82
82
  liveChildren.clear();
83
83
  };
84
84
  process.once('exit', killAll);
85
- process.once('SIGINT', killAll);
86
- process.once('SIGTERM', killAll);
85
+ // Registering a SIGINT/SIGTERM listener suppresses Node's default
86
+ // terminate-on-signal behavior, so an entrypoint that spawns a tunnel but
87
+ // installs no exit handler of its own would swallow the first Ctrl-C and hang
88
+ // with the process still alive. Re-raise the signal after cleanup so the
89
+ // default termination still fires. `process.once` removes the listener before
90
+ // it runs, so re-raising hits Node's default disposition (terminate).
91
+ const onSignal = (sig: 'SIGINT' | 'SIGTERM') => (): void => {
92
+ killAll();
93
+ process.kill(process.pid, sig);
94
+ };
95
+ process.once('SIGINT', onSignal('SIGINT'));
96
+ process.once('SIGTERM', onSignal('SIGTERM'));
87
97
  }
88
98
 
89
99
  /** Track a spawned child; returns an `untrack()` that also kills it cleanly. */
@@ -33,6 +33,18 @@ describe('countNodes', () => {
33
33
  it('counts a leaf element as 1', () => {
34
34
  expect(countNodes(el('hr'))).toBe(1);
35
35
  });
36
+
37
+ it('does not throw (RangeError) on a deeply nested AST', () => {
38
+ // A hand-built/corrupt AST can be arbitrarily deep; the count must be
39
+ // iterative so a deep chain never blows the call stack. 100k deep would
40
+ // overflow naive recursion.
41
+ const depth = 100_000;
42
+ let node: ViewNode = text('leaf');
43
+ for (let i = 0; i < depth; i++) node = el('wrap', [node]);
44
+ // depth wrapping elements + the leaf text node.
45
+ expect(() => countNodes(node)).not.toThrow();
46
+ expect(countNodes(node)).toBe(depth + 1);
47
+ });
36
48
  });
37
49
 
38
50
  describe('view vocabulary integrity', () => {
@@ -188,8 +188,22 @@ export const DEFAULT_VIEW_TAGS: ReadonlyArray<ViewTagSpec> = [...VIEW_PRIMITIVES
188
188
  * Count element + text nodes in a view tree (the `nodeCount` a `present_view`
189
189
  * tool reports). Single source of truth: core's parser and the plugin-view tool
190
190
  * both import this rather than re-implementing the recursion.
191
+ *
192
+ * Iterative (explicit stack) rather than recursive: a deeply nested
193
+ * hand-built/corrupt AST would blow the call stack with a `RangeError`, and this
194
+ * runs on untrusted agent-authored input. Each node counts as 1; an element's
195
+ * children are pushed for later counting. Order-independent (we only sum), so
196
+ * the count is byte-identical to the old recursion for any normal tree.
191
197
  */
192
198
  export function countNodes(node: ViewNode): number {
193
- if (node.kind === 'text') return 1;
194
- return 1 + node.children.reduce((sum, child) => sum + countNodes(child), 0);
199
+ let count = 0;
200
+ const stack: ViewNode[] = [node];
201
+ while (stack.length > 0) {
202
+ const current = stack.pop()!;
203
+ count += 1;
204
+ if (current.kind === 'element') {
205
+ for (const child of current.children) stack.push(child);
206
+ }
207
+ }
208
+ return count;
195
209
  }