@pivanov/claude-wire 0.0.2 → 0.0.4

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.
package/dist/session.js CHANGED
@@ -1,41 +1,90 @@
1
- import { LIMITS, TIMEOUTS } from "./constants.js";
1
+ import { withTimeout } from "./async.js";
2
+ import { LIMITS, MAX_BACKOFF_INDEX, RESPAWN_BACKOFF_MS, TIMEOUTS } from "./constants.js";
2
3
  import { createCostTracker } from "./cost.js";
3
- import { AbortError, BudgetExceededError, ClaudeError, isTransientError, KnownError, ProcessError, TimeoutError } from "./errors.js";
4
+ import { AbortError, BudgetExceededError, ClaudeError, isTransientError, KnownError, processExitedEarly, TimeoutError } from "./errors.js";
4
5
  import { createTranslator } from "./parser/translator.js";
5
- import { buildResult } from "./pipeline.js";
6
- import { spawnClaude } from "./process.js";
6
+ import { applyTurnComplete, buildResult, startPipeline } from "./pipeline.js";
7
+ import { safeKill, safeWrite } from "./process.js";
7
8
  import { readNdjsonEvents } from "./reader.js";
8
9
  import { createToolHandler } from "./tools/handler.js";
9
10
  import { writer } from "./writer.js";
10
- const gracefulKill = async (p) => {
11
- p.kill();
12
- let timer;
13
- let timedOut = false;
11
+ // Two-stage termination: SIGTERM first, escalate to SIGKILL after the
12
+ // graceful-exit timeout. A stuck child (e.g. blocked on a syscall that
13
+ // ignores SIGTERM) would otherwise survive the "graceful" path and leak.
14
+ // The sentinel value distinguishes "exited on time" from "timeout fired"
15
+ // without a side-channel boolean.
16
+ // Compose two optional AbortSignals into one. If either fires, the
17
+ // returned signal aborts. Returns undefined when both inputs are undefined.
18
+ const composeSignals = (a, b) => {
19
+ if (!a && !b) {
20
+ return undefined;
21
+ }
22
+ if (!a) {
23
+ return b;
24
+ }
25
+ if (!b) {
26
+ return a;
27
+ }
28
+ const ctrl = new AbortController();
29
+ const abort = () => ctrl.abort();
30
+ a.addEventListener("abort", abort, { once: true });
31
+ b.addEventListener("abort", abort, { once: true });
32
+ if (a.aborted || b.aborted) {
33
+ ctrl.abort();
34
+ }
35
+ return ctrl.signal;
36
+ };
37
+ // Fire both session-level and per-ask onRetry, swallowing throws from either.
38
+ const fireRetry = (attempt, error, sessionLevel, askLevel) => {
14
39
  try {
15
- await Promise.race([
16
- p.exited,
17
- new Promise((r) => {
18
- timer = setTimeout(() => {
19
- timedOut = true;
20
- r();
21
- }, TIMEOUTS.gracefulExitMs);
22
- }),
23
- ]);
40
+ sessionLevel?.(attempt, error);
24
41
  }
25
- finally {
26
- if (timer) {
27
- clearTimeout(timer);
28
- }
42
+ catch {
43
+ // observer threw -- retry still happens
29
44
  }
30
- if (timedOut) {
31
- try {
32
- p.kill();
33
- }
34
- catch {
35
- // already dead
36
- }
45
+ try {
46
+ askLevel?.(attempt, error);
47
+ }
48
+ catch {
49
+ // observer threw -- retry still happens
37
50
  }
38
51
  };
52
+ const KILL_TIMED_OUT = Symbol("kill-timed-out");
53
+ const gracefulKill = async (p) => {
54
+ safeKill(p, "SIGTERM");
55
+ const outcome = await withTimeout(p.exited, TIMEOUTS.gracefulExitMs, () => KILL_TIMED_OUT);
56
+ if (outcome === KILL_TIMED_OUT) {
57
+ safeKill(p, "SIGKILL");
58
+ }
59
+ };
60
+ /**
61
+ * Creates a multi-turn Claude session backed by a single long-lived CLI
62
+ * process. Each `ask()` sends a user prompt and resolves with `TAskResult`
63
+ * for that turn. Calls are serialized -- a second `ask()` waits for the
64
+ * first to complete. Use `close()` (or `await using`) to free the process.
65
+ *
66
+ * ### Retry behavior
67
+ * Each `ask()` automatically retries transient failures -- process crashes
68
+ * matching SIGKILL/SIGTERM/SIGPIPE exit codes, `ECONNRESET`, `ECONNREFUSED`,
69
+ * `ETIMEDOUT`, `EHOSTUNREACH`, `ENETUNREACH`, `EAI_AGAIN`, Anthropic
70
+ * `overloaded_error` / 529s, broken-pipe / "socket hang up" messages, etc.
71
+ * (see `isTransientError`). Backoff is `500ms → 1s → 2s`; the budget is
72
+ * `LIMITS.maxRespawnAttempts` (currently 3) and is shared across a single
73
+ * `ask()`. When the budget is exhausted the session throws
74
+ * `KnownError("retry-exhausted")` and marks itself closed.
75
+ *
76
+ * Fatal errors -- `KnownError` and `BudgetExceededError` -- also close the
77
+ * session. Any subsequent `ask()` on a closed session rejects with
78
+ * `ClaudeError("Session is closed")`. All other errors (abort, timeout,
79
+ * non-transient `ProcessError`) propagate without closing, and the caller
80
+ * may decide whether to retry at a higher level.
81
+ *
82
+ * ### Observability
83
+ * - `onCostUpdate(snapshot)` -- fires after every `turn_complete`.
84
+ * - `onRetry(attempt, error)` -- fires each time a transient failure triggers
85
+ * a respawn inside one `ask()`. Attempt is 1-indexed.
86
+ * - `onWarning(message, cause)` -- routes all library-emitted warnings.
87
+ */
39
88
  export const createSession = (options = {}) => {
40
89
  let proc;
41
90
  let currentSessionId;
@@ -46,6 +95,7 @@ export const createSession = (options = {}) => {
46
95
  const costTracker = createCostTracker({
47
96
  maxCostUsd: options.maxCostUsd,
48
97
  onCostUpdate: options.onCostUpdate,
98
+ onWarning: options.onWarning,
49
99
  });
50
100
  const toolHandler = options.tools ? createToolHandler(options.tools) : undefined;
51
101
  let inFlight;
@@ -61,31 +111,11 @@ export const createSession = (options = {}) => {
61
111
  }
62
112
  reader = undefined;
63
113
  };
64
- let lastStderrChunks = [];
65
- const drainStderr = (p) => {
66
- const chunks = [];
67
- lastStderrChunks = chunks;
68
- const stderrReader = p.stderr.getReader();
69
- const decoder = new TextDecoder();
70
- (async () => {
71
- try {
72
- while (true) {
73
- const { done, value } = await stderrReader.read();
74
- if (done) {
75
- break;
76
- }
77
- chunks.push(decoder.decode(value, { stream: true }));
78
- }
79
- }
80
- catch {
81
- // process exited
82
- }
83
- finally {
84
- stderrReader.releaseLock();
85
- }
86
- })().catch(() => { });
87
- };
88
- const getStderrText = () => lastStderrChunks.join("").trim();
114
+ // Drain handle from the most recent spawn. Stored as a whole so we
115
+ // can call `.text()` (shared helper on IStderrDrain) instead of
116
+ // reimplementing chunks.join/trim at every use site.
117
+ let lastStderrDrain;
118
+ const getStderrText = () => lastStderrDrain?.text() ?? "";
89
119
  const killProc = () => {
90
120
  if (proc) {
91
121
  proc.kill();
@@ -97,15 +127,31 @@ export const createSession = (options = {}) => {
97
127
  const spawnFresh = (prompt, resumeId) => {
98
128
  if (consecutiveCrashes >= LIMITS.maxRespawnAttempts) {
99
129
  killProc();
100
- throw new ProcessError(`Process crashed ${consecutiveCrashes} times, giving up`);
130
+ // Typed code so consumers can pattern-match on
131
+ // `KnownError && err.code === "retry-exhausted"` without parsing strings.
132
+ throw new KnownError("retry-exhausted", `Process crashed ${consecutiveCrashes} times, giving up`);
101
133
  }
102
134
  costOffsets = costTracker.snapshot();
103
135
  killProc();
104
136
  translator.reset();
137
+ // Respawn always overrides caller-supplied options.resume with the live
138
+ // session id when one is available: mid-session recovery must resume the
139
+ // same conversation, not whatever static id was passed at construction.
105
140
  const spawnOpts = resumeId ? { prompt, ...options, resume: resumeId } : { prompt, ...options };
106
- proc = spawnClaude(spawnOpts);
107
- reader = proc.stdout.getReader();
108
- drainStderr(proc);
141
+ const pipeline = startPipeline(spawnOpts);
142
+ proc = pipeline.proc;
143
+ reader = pipeline.reader;
144
+ lastStderrDrain = pipeline.stderr;
145
+ };
146
+ const respawnBackoff = async () => {
147
+ // consecutiveCrashes starts at 1 for the first retry; idx points
148
+ // into RESPAWN_BACKOFF_MS and clamps to the last defined entry for
149
+ // any crash count beyond the table length.
150
+ const idx = Math.min(consecutiveCrashes, MAX_BACKOFF_INDEX) - 1;
151
+ const delay = idx >= 0 ? RESPAWN_BACKOFF_MS[idx] : 0;
152
+ if (delay) {
153
+ await new Promise((r) => setTimeout(r, delay));
154
+ }
109
155
  };
110
156
  const readUntilTurnComplete = async (signal) => {
111
157
  if (!proc || !reader) {
@@ -119,14 +165,14 @@ export const createSession = (options = {}) => {
119
165
  toolHandler,
120
166
  proc,
121
167
  signal,
168
+ onWarning: options.onWarning,
122
169
  })) {
123
170
  if (event.type === "session_meta") {
124
171
  currentSessionId = event.sessionId;
125
172
  }
126
173
  events.push(event);
127
174
  if (event.type === "turn_complete") {
128
- costTracker.update(costOffsets.totalUsd + (event.costUsd ?? 0), costOffsets.inputTokens + (event.inputTokens ?? 0), costOffsets.outputTokens + (event.outputTokens ?? 0));
129
- costTracker.checkBudget();
175
+ applyTurnComplete(event, costTracker, costOffsets);
130
176
  gotTurnComplete = true;
131
177
  break;
132
178
  }
@@ -135,50 +181,68 @@ export const createSession = (options = {}) => {
135
181
  if (signal?.aborted) {
136
182
  throw new AbortError();
137
183
  }
138
- const stderrMsg = getStderrText();
139
- throw new ProcessError(stderrMsg || "Process exited without completing the turn");
184
+ // stdout closed → process is dying. Wait briefly for exited so we
185
+ // can attach an exit code to the error; if it doesn't resolve in
186
+ // time, force-kill and leave exitCode undefined (→ non-transient).
187
+ let exitCode;
188
+ if (proc) {
189
+ const live = proc;
190
+ exitCode = await withTimeout(live.exited, TIMEOUTS.gracefulExitMs);
191
+ if (exitCode === undefined) {
192
+ live.kill();
193
+ }
194
+ }
195
+ if (lastStderrDrain) {
196
+ await withTimeout(lastStderrDrain.done, TIMEOUTS.stderrDrainGraceMs);
197
+ }
198
+ throw processExitedEarly(getStderrText(), exitCode);
140
199
  }
141
200
  return events;
142
201
  };
143
- const doAsk = async (prompt) => {
202
+ const doAsk = async (prompt, askOpts) => {
144
203
  if (!proc) {
145
204
  spawnFresh(prompt, currentSessionId);
146
205
  }
147
- else {
206
+ else if (!safeWrite(proc, writer.user(prompt))) {
207
+ // stdin write failed -- process probably died. Try to respawn.
208
+ // spawnFresh can itself throw ProcessError synchronously when the
209
+ // respawn cap is already hit; surface as an Error like the retry
210
+ // loop below would, instead of a raw synchronous throw.
211
+ consecutiveCrashes++;
212
+ translator.reset();
148
213
  try {
149
- proc.write(writer.user(prompt));
150
- }
151
- catch {
152
- consecutiveCrashes++;
153
- translator.reset();
154
214
  spawnFresh(prompt, currentSessionId);
155
215
  }
216
+ catch (respawnError) {
217
+ killProc();
218
+ throw respawnError;
219
+ }
156
220
  }
221
+ // Compose per-ask signal with session-level signal: either firing aborts.
222
+ const effectiveSignal = composeSignals(options.signal, askOpts?.signal);
157
223
  let events;
158
- try {
159
- events = await readUntilTurnComplete(options.signal);
160
- }
161
- catch (error) {
162
- if (error instanceof AbortError || error instanceof TimeoutError) {
163
- killProc();
164
- throw error;
224
+ while (true) {
225
+ try {
226
+ events = await readUntilTurnComplete(effectiveSignal);
227
+ break;
165
228
  }
166
- if (isTransientError(error) && consecutiveCrashes < LIMITS.maxRespawnAttempts) {
167
- consecutiveCrashes++;
168
- spawnFresh(prompt, currentSessionId);
169
- try {
170
- events = await readUntilTurnComplete(options.signal);
229
+ catch (error) {
230
+ if (error instanceof AbortError || error instanceof TimeoutError) {
231
+ killProc();
232
+ throw error;
171
233
  }
172
- catch (retryError) {
234
+ if (!isTransientError(error) || consecutiveCrashes >= LIMITS.maxRespawnAttempts) {
173
235
  killProc();
174
236
  translator.reset();
175
- throw retryError;
237
+ throw error;
176
238
  }
177
- }
178
- else {
179
- killProc();
180
- translator.reset();
181
- throw error;
239
+ consecutiveCrashes++;
240
+ // Fire both session-level and per-ask onRetry. Both are safe-invoked
241
+ // so a throwing observer doesn't prevent the retry.
242
+ fireRetry(consecutiveCrashes, error, options.onRetry, askOpts?.onRetry);
243
+ await respawnBackoff();
244
+ spawnFresh(prompt, currentSessionId);
245
+ // Loop to retry; stops when budget exhausted or turn completes.
182
246
  }
183
247
  }
184
248
  consecutiveCrashes = 0;
@@ -195,34 +259,42 @@ export const createSession = (options = {}) => {
195
259
  return buildResult(events, costTracker, currentSessionId);
196
260
  };
197
261
  let closed = false;
198
- const ask = (prompt) => {
262
+ const ask = (prompt, askOpts) => {
199
263
  if (closed) {
200
264
  return Promise.reject(new ClaudeError("Session is closed"));
201
265
  }
202
266
  const prev = inFlight ?? Promise.resolve();
203
267
  const run = prev
204
- .catch((prevError) => {
205
- if (prevError instanceof KnownError || prevError instanceof BudgetExceededError) {
206
- throw prevError;
268
+ .catch(() => {
269
+ // Prior ask failure shouldn't prevent this one from running. Fatal
270
+ // errors (KnownError/BudgetExceededError) set `closed` in the .catch
271
+ // below, which the sync check above picks up on the NEXT ask.
272
+ })
273
+ .then(() => {
274
+ if (closed) {
275
+ throw new ClaudeError("Session is closed");
207
276
  }
277
+ return doAsk(prompt, askOpts);
208
278
  })
209
- .then(() => doAsk(prompt));
279
+ .catch((error) => {
280
+ if (error instanceof KnownError || error instanceof BudgetExceededError) {
281
+ closed = true;
282
+ }
283
+ throw error;
284
+ });
210
285
  inFlight = run;
211
286
  return run;
212
287
  };
213
288
  const close = async () => {
214
289
  closed = true;
215
290
  if (inFlight) {
216
- await inFlight.catch(() => { });
291
+ // Cap the wait: a stuck reader.read() inside the queued ask would
292
+ // otherwise hang close() forever before gracefulKill gets a chance.
293
+ await withTimeout(inFlight.catch(() => { }), TIMEOUTS.gracefulExitMs);
217
294
  inFlight = undefined;
218
295
  }
219
296
  if (proc) {
220
- try {
221
- proc.write(writer.abort());
222
- }
223
- catch {
224
- // stdin may already be closed
225
- }
297
+ safeWrite(proc, writer.abort());
226
298
  await gracefulKill(proc);
227
299
  proc = undefined;
228
300
  }
package/dist/stream.js CHANGED
@@ -1,81 +1,99 @@
1
+ import { withTimeout } from "./async.js";
2
+ import { TIMEOUTS } from "./constants.js";
1
3
  import { createCostTracker } from "./cost.js";
2
- import { AbortError, ClaudeError, ProcessError } from "./errors.js";
4
+ import { AbortError, ClaudeError, ProcessError, processExitedEarly } from "./errors.js";
3
5
  import { createTranslator } from "./parser/translator.js";
4
- import { buildResult, extractText } from "./pipeline.js";
5
- import { spawnClaude } from "./process.js";
6
+ import { applyTurnComplete, buildResult, extractText, startPipeline } from "./pipeline.js";
6
7
  import { readNdjsonEvents } from "./reader.js";
7
8
  import { createToolHandler } from "./tools/handler.js";
8
- const drainStderr = (proc) => {
9
- const chunks = [];
10
- const stderrReader = proc.stderr.getReader();
11
- const decoder = new TextDecoder();
12
- const done = (async () => {
13
- try {
14
- while (true) {
15
- const { done: isDone, value } = await stderrReader.read();
16
- if (isDone) {
17
- break;
18
- }
19
- chunks.push(decoder.decode(value, { stream: true }));
20
- }
21
- }
22
- catch {
23
- // process exited
24
- }
25
- finally {
26
- stderrReader.releaseLock();
27
- }
28
- })().catch(() => { });
29
- return { chunks, done };
30
- };
9
+ // Enforced exclusivity between iterating the stream and consuming via
10
+ // text()/cost()/result(). Sharing the base message keeps the two throw
11
+ // sites from drifting apart over time.
12
+ const MIX_ITER_CONSUME = "Cannot mix for-await iteration with text()/cost()/result() on the same stream -- use one or the other.";
31
13
  export const createStream = (prompt, options = {}) => {
32
- if (options.signal?.aborted) {
33
- throw new AbortError();
34
- }
35
- const proc = spawnClaude({ prompt, ...options });
36
- const stderr = drainStderr(proc);
14
+ // Abort check happens inside `ensureSpawned` -- at factory time we only
15
+ // capture config. A pre-aborted signal surfaces on the first access
16
+ // (iterate / text / cost / result), which is when spawn would happen.
37
17
  const translator = createTranslator();
38
18
  const toolHandler = options.tools ? createToolHandler(options.tools) : undefined;
39
19
  const costTracker = createCostTracker({
40
20
  maxCostUsd: options.maxCostUsd,
41
21
  onCostUpdate: options.onCostUpdate,
22
+ onWarning: options.onWarning,
42
23
  });
24
+ let proc;
25
+ let stderr;
26
+ let stdoutReader;
43
27
  let cachedGenerator;
28
+ const ensureSpawned = () => {
29
+ if (!proc) {
30
+ if (options.signal?.aborted) {
31
+ throw new AbortError();
32
+ }
33
+ // Shared boot: spawnClaude → getReader → drainStderr in one call.
34
+ // Matches session.ts so future refactors can't let the two drift.
35
+ const pipeline = startPipeline({ prompt, ...options });
36
+ proc = pipeline.proc;
37
+ stdoutReader = pipeline.reader;
38
+ stderr = pipeline.stderr;
39
+ }
40
+ return proc;
41
+ };
44
42
  const generate = async function* () {
45
- const stdoutReader = proc.stdout.getReader();
43
+ const p = ensureSpawned();
44
+ // ensureSpawned always populates stdoutReader alongside proc. Typed
45
+ // assertion so consumers can treat it as non-null below.
46
+ const currentReader = stdoutReader;
46
47
  let turnComplete = false;
47
48
  try {
48
49
  for await (const event of readNdjsonEvents({
49
- reader: stdoutReader,
50
+ reader: currentReader,
50
51
  translator,
51
52
  toolHandler,
52
- proc,
53
+ proc: p,
53
54
  signal: options.signal,
55
+ onWarning: options.onWarning,
54
56
  })) {
55
57
  if (event.type === "turn_complete") {
56
- costTracker.update(event.costUsd ?? 0, event.inputTokens ?? 0, event.outputTokens ?? 0);
57
- costTracker.checkBudget();
58
+ applyTurnComplete(event, costTracker);
58
59
  turnComplete = true;
59
60
  }
60
61
  yield event;
61
62
  }
62
63
  if (!turnComplete) {
63
- const exitCode = await proc.exited;
64
+ // Don't wait forever on p.exited -- a stuck child that never closes
65
+ // stdout would hang the generator. Cap at gracefulExitMs, then
66
+ // force-kill so cleanup() isn't left waiting too.
67
+ const exitCode = await withTimeout(p.exited, TIMEOUTS.gracefulExitMs);
68
+ if (exitCode === undefined) {
69
+ p.kill();
70
+ }
71
+ // Give stderr a brief chance to drain so the thrown error carries
72
+ // the CLI's actual complaint instead of an empty string. Uniform
73
+ // across all three branches below so users never get "no context".
74
+ if (stderr) {
75
+ await withTimeout(stderr.done, TIMEOUTS.stderrDrainGraceMs);
76
+ }
77
+ const stderrText = stderr ? stderr.text() : "";
78
+ if (exitCode === undefined) {
79
+ throw processExitedEarly(stderrText);
80
+ }
64
81
  if (exitCode !== 0) {
65
- let errorMessage = `Claude process exited with code ${exitCode}`;
66
- await stderr.done;
67
- const stderrText = stderr.chunks.join("").trim();
68
- if (stderrText) {
69
- errorMessage = stderrText;
70
- }
71
- throw new ProcessError(errorMessage, exitCode);
82
+ const exitMsg = stderrText || `Claude process exited with code ${exitCode}`;
83
+ throw new ProcessError(exitMsg, exitCode);
72
84
  }
73
- throw new ProcessError("Process exited without completing the turn");
85
+ throw processExitedEarly(stderrText);
74
86
  }
75
87
  }
76
88
  finally {
77
- stdoutReader.releaseLock();
78
- proc.kill();
89
+ currentReader.releaseLock();
90
+ p.kill();
91
+ // Let stderr catch up so any trailing lines aren't silently dropped --
92
+ // session's error path does the same via withTimeout. Capped so a
93
+ // stuck drain can't hold up consumer cleanup.
94
+ if (stderr) {
95
+ await withTimeout(stderr.done, TIMEOUTS.stderrDrainGraceMs);
96
+ }
79
97
  }
80
98
  };
81
99
  const bufferedEvents = [];
@@ -83,11 +101,12 @@ export const createStream = (prompt, options = {}) => {
83
101
  const ensureConsumed = () => {
84
102
  if (!consumePromise) {
85
103
  if (cachedGenerator) {
86
- throw new ClaudeError("Cannot call text()/cost()/result() after iterating with for-await. Use one or the other.");
104
+ throw new ClaudeError(MIX_ITER_CONSUME);
87
105
  }
106
+ const gen = generate();
107
+ cachedGenerator = gen;
88
108
  consumePromise = (async () => {
89
- cachedGenerator = generate();
90
- for await (const event of { [Symbol.asyncIterator]: () => cachedGenerator }) {
109
+ for await (const event of gen) {
91
110
  bufferedEvents.push(event);
92
111
  }
93
112
  })();
@@ -108,14 +127,21 @@ export const createStream = (prompt, options = {}) => {
108
127
  return buildResult(bufferedEvents, costTracker, sessionId);
109
128
  };
110
129
  const cleanup = () => {
111
- if (!cachedGenerator) {
130
+ // One-shot kill: streams are single-turn, so unlike session.gracefulKill
131
+ // there's no second ask() to worry about leaving the child stranded for.
132
+ // SIGTERM is sufficient -- a stuck child would be the CLI's bug, and we
133
+ // wouldn't gain anything by blocking cleanup() on a SIGKILL escalation.
134
+ // Always kill if a proc was ever spawned -- the generator's finally may
135
+ // not have run yet (e.g., iterator created but never ticked). Redundant
136
+ // kill on an already-exited process is a harmless ESRCH.
137
+ if (proc) {
112
138
  proc.kill();
113
139
  }
114
140
  };
115
141
  return {
116
142
  [Symbol.asyncIterator]: () => {
117
143
  if (consumePromise) {
118
- throw new ClaudeError("Cannot iterate after calling text()/cost()/result(). Use one or the other.");
144
+ throw new ClaudeError(MIX_ITER_CONSUME);
119
145
  }
120
146
  cachedGenerator ??= generate();
121
147
  return cachedGenerator;
@@ -2,6 +2,7 @@ import type { TToolUseEvent } from "../types/events.js";
2
2
  import type { IToolHandler } from "../types/options.js";
3
3
  export type TToolDecision = "approve" | "deny" | {
4
4
  result: string;
5
+ isError?: boolean;
5
6
  };
6
7
  export interface IToolHandlerInstance {
7
8
  decide: (tool: TToolUseEvent) => Promise<TToolDecision>;
@@ -1,5 +1,5 @@
1
1
  export const createToolHandler = (options = {}) => {
2
- const { allowed, blocked, onToolUse } = options;
2
+ const { allowed, blocked, onToolUse, onError } = options;
3
3
  const allowedSet = allowed ? new Set(allowed) : undefined;
4
4
  const blockedSet = blocked ? new Set(blocked) : undefined;
5
5
  const decide = async (tool) => {
@@ -9,10 +9,18 @@ export const createToolHandler = (options = {}) => {
9
9
  if (allowedSet && !allowedSet.has(tool.toolName)) {
10
10
  return "deny";
11
11
  }
12
- if (onToolUse) {
13
- return onToolUse(tool);
12
+ if (!onToolUse) {
13
+ return "approve";
14
+ }
15
+ try {
16
+ return await onToolUse(tool);
17
+ }
18
+ catch (error) {
19
+ if (!onError) {
20
+ throw error;
21
+ }
22
+ return onError(error, tool);
14
23
  }
15
- return "approve";
16
24
  };
17
25
  return { decide };
18
26
  };
@@ -1,2 +1,4 @@
1
- export declare const BUILT_IN_TOOLS: Set<string>;
2
- export declare const isBuiltInTool: (name: string) => boolean;
1
+ export declare const BUILT_IN_TOOL_NAMES: readonly ["Read", "Write", "Edit", "Bash", "Glob", "Grep", "Agent", "NotebookEdit", "WebFetch", "WebSearch", "TaskCreate", "TaskUpdate", "TaskGet", "TaskList", "TaskStop", "TaskOutput", "ToolSearch", "Monitor", "EnterPlanMode", "ExitPlanMode", "SendMessage", "LSP", "AskUserQuestion", "Skill", "CronCreate", "CronDelete", "CronList", "RemoteTrigger", "TeamCreate", "TeamDelete", "EnterWorktree", "ExitWorktree", "ScheduleWakeup"];
2
+ export type TBuiltInToolName = (typeof BUILT_IN_TOOL_NAMES)[number];
3
+ export declare const BUILT_IN_TOOLS: ReadonlySet<TBuiltInToolName>;
4
+ export declare const isBuiltInTool: (name: string) => name is TBuiltInToolName;
@@ -1,6 +1,10 @@
1
1
  // Best-effort snapshot of known Claude Code tools. May not be exhaustive.
2
2
  // For the authoritative list, check session_meta.tools from a live session.
3
- export const BUILT_IN_TOOLS = new Set([
3
+ //
4
+ // Declared as a literal tuple so `TBuiltInToolName` is the exact union of
5
+ // known names -- lets callers narrow `allowedTools` / `disallowedTools`
6
+ // arrays at compile time instead of accepting any string[].
7
+ export const BUILT_IN_TOOL_NAMES = [
4
8
  "Read",
5
9
  "Write",
6
10
  "Edit",
@@ -11,8 +15,6 @@ export const BUILT_IN_TOOLS = new Set([
11
15
  "NotebookEdit",
12
16
  "WebFetch",
13
17
  "WebSearch",
14
- "TodoRead",
15
- "TodoWrite",
16
18
  "TaskCreate",
17
19
  "TaskUpdate",
18
20
  "TaskGet",
@@ -36,7 +38,8 @@ export const BUILT_IN_TOOLS = new Set([
36
38
  "EnterWorktree",
37
39
  "ExitWorktree",
38
40
  "ScheduleWakeup",
39
- ]);
41
+ ];
42
+ export const BUILT_IN_TOOLS = new Set(BUILT_IN_TOOL_NAMES);
40
43
  export const isBuiltInTool = (name) => {
41
44
  return BUILT_IN_TOOLS.has(name);
42
45
  };