@moxxy/sdk 0.14.5 → 0.15.1

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 (59) hide show
  1. package/dist/channel.d.ts +32 -0
  2. package/dist/channel.d.ts.map +1 -1
  3. package/dist/channel.js +28 -1
  4. package/dist/channel.js.map +1 -1
  5. package/dist/elision-state.js +23 -23
  6. package/dist/elision-state.js.map +1 -1
  7. package/dist/errors.js +17 -2
  8. package/dist/errors.js.map +1 -1
  9. package/dist/index.d.ts +3 -2
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/json-file-store.d.ts +6 -1
  14. package/dist/json-file-store.d.ts.map +1 -1
  15. package/dist/json-file-store.js +20 -4
  16. package/dist/json-file-store.js.map +1 -1
  17. package/dist/mode/collect-stream.d.ts.map +1 -1
  18. package/dist/mode/collect-stream.js +34 -7
  19. package/dist/mode/collect-stream.js.map +1 -1
  20. package/dist/mode/project-messages.d.ts.map +1 -1
  21. package/dist/mode/project-messages.js +25 -1
  22. package/dist/mode/project-messages.js.map +1 -1
  23. package/dist/mode/stable-hash.d.ts +8 -0
  24. package/dist/mode/stable-hash.d.ts.map +1 -1
  25. package/dist/mode/stable-hash.js +67 -7
  26. package/dist/mode/stable-hash.js.map +1 -1
  27. package/dist/mode.d.ts.map +1 -1
  28. package/dist/mode.js +7 -1
  29. package/dist/mode.js.map +1 -1
  30. package/dist/provider-utils.d.ts +1 -10
  31. package/dist/provider-utils.d.ts.map +1 -1
  32. package/dist/provider-utils.js +20 -11
  33. package/dist/provider-utils.js.map +1 -1
  34. package/dist/tool-dispatch.d.ts.map +1 -1
  35. package/dist/tool-dispatch.js +13 -3
  36. package/dist/tool-dispatch.js.map +1 -1
  37. package/dist/tunnel.d.ts.map +1 -1
  38. package/dist/tunnel.js +12 -2
  39. package/dist/tunnel.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/channel.ts +37 -0
  42. package/src/elision-state.ts +22 -22
  43. package/src/errors.test.ts +19 -0
  44. package/src/errors.ts +17 -2
  45. package/src/index.ts +3 -0
  46. package/src/json-file-store.test.ts +40 -0
  47. package/src/json-file-store.ts +25 -4
  48. package/src/mode/collect-stream.ts +37 -7
  49. package/src/mode/project-messages.test.ts +64 -2
  50. package/src/mode/project-messages.ts +23 -1
  51. package/src/mode/stable-hash.ts +69 -9
  52. package/src/mode.test.ts +18 -0
  53. package/src/mode.ts +7 -1
  54. package/src/provider-utils.test.ts +10 -0
  55. package/src/provider-utils.ts +20 -11
  56. package/src/stuck-loop.test.ts +77 -1
  57. package/src/tool-dispatch.test.ts +215 -0
  58. package/src/tool-dispatch.ts +13 -3
  59. package/src/tunnel.ts +12 -2
@@ -72,7 +72,16 @@ export function toFriendlyError(
72
72
  * (`shape`, `type`) which aren't part of zod's public typed surface but are
73
73
  * stable enough across versions to rely on for this best-effort path.
74
74
  */
75
- export function zodToJsonSchema(schema: unknown): unknown {
75
+ /**
76
+ * Max recursion depth for {@link zodToJsonSchema}. A legitimately deep finite
77
+ * schema (or a pathological/adversarial tool definition) recurses one frame per
78
+ * level; past this cap we degrade to the permissive `{}` schema instead of
79
+ * blowing the call stack and taking down provider request construction.
80
+ */
81
+ const MAX_SCHEMA_DEPTH = 32;
82
+
83
+ export function zodToJsonSchema(schema: unknown, depth = 0): unknown {
84
+ if (depth > MAX_SCHEMA_DEPTH) return {};
76
85
  const s = schema as { _def?: { typeName?: string }; toJSON?: () => unknown };
77
86
  // Only honor a pre-serialized schema's `toJSON` for NON-zod objects (a plain
78
87
  // JSON-schema object passed through). A real zod schema (has `_def`) MUST go
@@ -89,7 +98,7 @@ export function zodToJsonSchema(schema: unknown): unknown {
89
98
  // Codex's /responses validator reject ("object schema missing properties").
90
99
  if (typeName === 'ZodEffects') {
91
100
  const inner = (def as unknown as { schema: unknown }).schema;
92
- return zodToJsonSchema(inner);
101
+ return zodToJsonSchema(inner, depth + 1);
93
102
  }
94
103
  // ZodOptional / ZodNullable / ZodDefault / ZodBranded / ZodReadonly all
95
104
  // wrap an inner schema we want to unwrap for JSON-schema purposes.
@@ -101,7 +110,7 @@ export function zodToJsonSchema(schema: unknown): unknown {
101
110
  typeName === 'ZodReadonly'
102
111
  ) {
103
112
  const inner = (def as unknown as { innerType: unknown }).innerType;
104
- return zodToJsonSchema(inner);
113
+ return zodToJsonSchema(inner, depth + 1);
105
114
  }
106
115
  // `.and(other)` produces a ZodIntersection. The standard JSON-schema
107
116
  // representation is `allOf`, but strict validators (Codex again) want
@@ -110,8 +119,8 @@ export function zodToJsonSchema(schema: unknown): unknown {
110
119
  // and required lists into one object. Fall back to `allOf` otherwise.
111
120
  if (typeName === 'ZodIntersection') {
112
121
  const intersection = def as unknown as { left: unknown; right: unknown };
113
- const left = zodToJsonSchema(intersection.left) as Record<string, unknown>;
114
- const right = zodToJsonSchema(intersection.right) as Record<string, unknown>;
122
+ const left = zodToJsonSchema(intersection.left, depth + 1) as Record<string, unknown>;
123
+ const right = zodToJsonSchema(intersection.right, depth + 1) as Record<string, unknown>;
115
124
  if (
116
125
  left &&
117
126
  right &&
@@ -139,7 +148,7 @@ export function zodToJsonSchema(schema: unknown): unknown {
139
148
  const properties: Record<string, unknown> = {};
140
149
  const required: string[] = [];
141
150
  for (const [key, value] of Object.entries(shape)) {
142
- properties[key] = zodToJsonSchema(value);
151
+ properties[key] = zodToJsonSchema(value, depth + 1);
143
152
  const isOptional = (value as { isOptional?: () => boolean }).isOptional?.();
144
153
  if (!isOptional) required.push(key);
145
154
  }
@@ -166,11 +175,11 @@ export function zodToJsonSchema(schema: unknown): unknown {
166
175
  }
167
176
  if (typeName === 'ZodUnion' || typeName === 'ZodDiscriminatedUnion') {
168
177
  const options = (def as unknown as { options: ReadonlyArray<unknown> }).options;
169
- return { anyOf: options.map((o) => zodToJsonSchema(o)) };
178
+ return { anyOf: options.map((o) => zodToJsonSchema(o, depth + 1)) };
170
179
  }
171
180
  if (typeName === 'ZodArray') {
172
181
  // ZodArray._def.type is the element schema.
173
- const items = zodToJsonSchema((def as unknown as { type: unknown }).type);
182
+ const items = zodToJsonSchema((def as unknown as { type: unknown }).type, depth + 1);
174
183
  return { type: 'array', items };
175
184
  }
176
185
  if (typeName === 'ZodRecord') {
@@ -178,18 +187,18 @@ export function zodToJsonSchema(schema: unknown): unknown {
178
187
  // one schema. Carry the value schema through `additionalProperties` rather
179
188
  // than discarding it to the permissive fallback below.
180
189
  const valueType = (def as unknown as { valueType: unknown }).valueType;
181
- return { type: 'object', additionalProperties: zodToJsonSchema(valueType) };
190
+ return { type: 'object', additionalProperties: zodToJsonSchema(valueType, depth + 1) };
182
191
  }
183
192
  if (typeName === 'ZodTuple') {
184
193
  // ZodTuple._def.items is the ordered element schemas; `rest` (if set) is the
185
194
  // variadic tail. Emit a fixed-position `items` array + an `additionalItems`
186
195
  // schema for the rest, mirroring JSON-schema's positional-tuple form.
187
196
  const tupleDef = def as unknown as { items: ReadonlyArray<unknown>; rest?: unknown };
188
- const items = tupleDef.items.map((i) => zodToJsonSchema(i));
197
+ const items = tupleDef.items.map((i) => zodToJsonSchema(i, depth + 1));
189
198
  return {
190
199
  type: 'array',
191
200
  items,
192
- ...(tupleDef.rest != null ? { additionalItems: zodToJsonSchema(tupleDef.rest) } : {}),
201
+ ...(tupleDef.rest != null ? { additionalItems: zodToJsonSchema(tupleDef.rest, depth + 1) } : {}),
193
202
  };
194
203
  }
195
204
  if (typeName === 'ZodAny' || typeName === 'ZodUnknown') {
@@ -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
  }
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. */