@pivanov/claude-wire 0.0.3 → 0.1.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.
package/dist/session.js CHANGED
@@ -1,53 +1,104 @@
1
- import { LIMITS, RESPAWN_BACKOFF_MS, 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";
5
+ import { parseAndValidate } from "./json.js";
4
6
  import { createTranslator } from "./parser/translator.js";
5
- import { buildResult } from "./pipeline.js";
6
- import { spawnClaude } from "./process.js";
7
- import { drainStderr, readNdjsonEvents } from "./reader.js";
7
+ import { applyTurnComplete, buildResult, startPipeline } from "./pipeline.js";
8
+ import { safeKill, safeWrite } from "./process.js";
9
+ import { readNdjsonEvents } from "./reader.js";
8
10
  import { createToolHandler } from "./tools/handler.js";
9
11
  import { writer } from "./writer.js";
10
- const gracefulKill = async (p) => {
11
- p.kill();
12
- let timer;
13
- let timedOut = false;
12
+ // Two-stage termination: SIGTERM first, escalate to SIGKILL after the
13
+ // graceful-exit timeout. A stuck child (e.g. blocked on a syscall that
14
+ // ignores SIGTERM) would otherwise survive the "graceful" path and leak.
15
+ // The sentinel value distinguishes "exited on time" from "timeout fired"
16
+ // without a side-channel boolean.
17
+ // Compose two optional AbortSignals into one. If either fires, the
18
+ // returned signal aborts. Returns undefined when both inputs are undefined.
19
+ const composeSignals = (a, b) => {
20
+ if (!a && !b) {
21
+ return undefined;
22
+ }
23
+ if (!a) {
24
+ return b;
25
+ }
26
+ if (!b) {
27
+ return a;
28
+ }
29
+ const ctrl = new AbortController();
30
+ const abort = () => ctrl.abort();
31
+ a.addEventListener("abort", abort, { once: true });
32
+ b.addEventListener("abort", abort, { once: true });
33
+ if (a.aborted || b.aborted) {
34
+ ctrl.abort();
35
+ }
36
+ return ctrl.signal;
37
+ };
38
+ // Fire both session-level and per-ask onRetry, swallowing throws from either.
39
+ const fireRetry = (attempt, error, sessionLevel, askLevel) => {
14
40
  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
- ]);
41
+ sessionLevel?.(attempt, error);
24
42
  }
25
- finally {
26
- if (timer) {
27
- clearTimeout(timer);
28
- }
43
+ catch {
44
+ // observer threw -- retry still happens
29
45
  }
30
- if (timedOut) {
31
- try {
32
- p.kill();
33
- }
34
- catch {
35
- // already dead
36
- }
46
+ try {
47
+ askLevel?.(attempt, error);
48
+ }
49
+ catch {
50
+ // observer threw -- retry still happens
37
51
  }
38
52
  };
53
+ const KILL_TIMED_OUT = Symbol("kill-timed-out");
54
+ const gracefulKill = async (p) => {
55
+ safeKill(p, "SIGTERM");
56
+ const outcome = await withTimeout(p.exited, TIMEOUTS.gracefulExitMs, () => KILL_TIMED_OUT);
57
+ if (outcome === KILL_TIMED_OUT) {
58
+ safeKill(p, "SIGKILL");
59
+ }
60
+ };
61
+ /**
62
+ * Creates a multi-turn Claude session backed by a single long-lived CLI
63
+ * process. Each `ask()` sends a user prompt and resolves with `TAskResult`
64
+ * for that turn. Calls are serialized -- a second `ask()` waits for the
65
+ * first to complete. Use `close()` (or `await using`) to free the process.
66
+ *
67
+ * ### Retry behavior
68
+ * Each `ask()` automatically retries transient failures -- process crashes
69
+ * matching SIGKILL/SIGTERM/SIGPIPE exit codes, `ECONNRESET`, `ECONNREFUSED`,
70
+ * `ETIMEDOUT`, `EHOSTUNREACH`, `ENETUNREACH`, `EAI_AGAIN`, Anthropic
71
+ * `overloaded_error` / 529s, broken-pipe / "socket hang up" messages, etc.
72
+ * (see `isTransientError`). Backoff is `500ms → 1s → 2s`; the budget is
73
+ * `LIMITS.maxRespawnAttempts` (currently 3) and is shared across a single
74
+ * `ask()`. When the budget is exhausted the session throws
75
+ * `KnownError("retry-exhausted")` and marks itself closed.
76
+ *
77
+ * Fatal errors -- `KnownError` and `BudgetExceededError` -- also close the
78
+ * session. Any subsequent `ask()` on a closed session rejects with
79
+ * `ClaudeError("Session is closed")`. All other errors (abort, timeout,
80
+ * non-transient `ProcessError`) propagate without closing, and the caller
81
+ * may decide whether to retry at a higher level.
82
+ *
83
+ * ### Observability
84
+ * - `onCostUpdate(snapshot)` -- fires after every `turn_complete`.
85
+ * - `onRetry(attempt, error)` -- fires each time a transient failure triggers
86
+ * a respawn inside one `ask()`. Attempt is 1-indexed.
87
+ * - `onWarning(message, cause)` -- routes all library-emitted warnings.
88
+ */
39
89
  export const createSession = (options = {}) => {
40
90
  let proc;
41
91
  let currentSessionId;
42
92
  let consecutiveCrashes = 0;
43
93
  let turnCount = 0;
44
- let costOffsets = { totalUsd: 0, inputTokens: 0, outputTokens: 0 };
94
+ let costOffsets = { totalUsd: 0, tokens: { input: 0, output: 0 } };
45
95
  const translator = createTranslator();
46
96
  const costTracker = createCostTracker({
47
97
  maxCostUsd: options.maxCostUsd,
48
98
  onCostUpdate: options.onCostUpdate,
99
+ onWarning: options.onWarning,
49
100
  });
50
- const toolHandler = options.tools ? createToolHandler(options.tools) : undefined;
101
+ const toolHandler = options.toolHandler ? createToolHandler(options.toolHandler) : undefined;
51
102
  let inFlight;
52
103
  let reader;
53
104
  const cleanupProcess = () => {
@@ -61,9 +112,11 @@ export const createSession = (options = {}) => {
61
112
  }
62
113
  reader = undefined;
63
114
  };
64
- let lastStderrChunks = [];
65
- let lastDrainDone;
66
- const getStderrText = () => lastStderrChunks.join("").trim();
115
+ // Drain handle from the most recent spawn. Stored as a whole so we
116
+ // can call `.text()` (shared helper on IStderrDrain) instead of
117
+ // reimplementing chunks.join/trim at every use site.
118
+ let lastStderrDrain;
119
+ const getStderrText = () => lastStderrDrain?.text() ?? "";
67
120
  const killProc = () => {
68
121
  if (proc) {
69
122
  proc.kill();
@@ -75,20 +128,27 @@ export const createSession = (options = {}) => {
75
128
  const spawnFresh = (prompt, resumeId) => {
76
129
  if (consecutiveCrashes >= LIMITS.maxRespawnAttempts) {
77
130
  killProc();
78
- throw new ProcessError(`Process crashed ${consecutiveCrashes} times, giving up`);
131
+ // Typed code so consumers can pattern-match on
132
+ // `KnownError && err.code === "retry-exhausted"` without parsing strings.
133
+ throw new KnownError("retry-exhausted", `Process crashed ${consecutiveCrashes} times, giving up`);
79
134
  }
80
135
  costOffsets = costTracker.snapshot();
81
136
  killProc();
82
137
  translator.reset();
138
+ // Respawn always overrides caller-supplied options.resume with the live
139
+ // session id when one is available: mid-session recovery must resume the
140
+ // same conversation, not whatever static id was passed at construction.
83
141
  const spawnOpts = resumeId ? { prompt, ...options, resume: resumeId } : { prompt, ...options };
84
- proc = spawnClaude(spawnOpts);
85
- reader = proc.stdout.getReader();
86
- const drain = drainStderr(proc);
87
- lastStderrChunks = drain.chunks;
88
- lastDrainDone = drain.done;
142
+ const pipeline = startPipeline(spawnOpts);
143
+ proc = pipeline.proc;
144
+ reader = pipeline.reader;
145
+ lastStderrDrain = pipeline.stderr;
89
146
  };
90
147
  const respawnBackoff = async () => {
91
- const idx = Math.min(consecutiveCrashes, RESPAWN_BACKOFF_MS.length) - 1;
148
+ // consecutiveCrashes starts at 1 for the first retry; idx points
149
+ // into RESPAWN_BACKOFF_MS and clamps to the last defined entry for
150
+ // any crash count beyond the table length.
151
+ const idx = Math.min(consecutiveCrashes, MAX_BACKOFF_INDEX) - 1;
92
152
  const delay = idx >= 0 ? RESPAWN_BACKOFF_MS[idx] : 0;
93
153
  if (delay) {
94
154
  await new Promise((r) => setTimeout(r, delay));
@@ -106,14 +166,14 @@ export const createSession = (options = {}) => {
106
166
  toolHandler,
107
167
  proc,
108
168
  signal,
169
+ onWarning: options.onWarning,
109
170
  })) {
110
171
  if (event.type === "session_meta") {
111
172
  currentSessionId = event.sessionId;
112
173
  }
113
174
  events.push(event);
114
175
  if (event.type === "turn_complete") {
115
- costTracker.update(costOffsets.totalUsd + (event.costUsd ?? 0), costOffsets.inputTokens + (event.inputTokens ?? 0), costOffsets.outputTokens + (event.outputTokens ?? 0));
116
- costTracker.checkBudget();
176
+ applyTurnComplete(event, costTracker, costOffsets);
117
177
  gotTurnComplete = true;
118
178
  break;
119
179
  }
@@ -122,46 +182,49 @@ export const createSession = (options = {}) => {
122
182
  if (signal?.aborted) {
123
183
  throw new AbortError();
124
184
  }
125
- // stdout closed → process is dying. Race `exited` against a short
126
- // timeout so a zombie/unreaped child doesn't hang us. If exited doesn't
127
- // resolve in time we leave exitCode undefined (→ non-transient).
185
+ // stdout closed → process is dying. Wait briefly for exited so we
186
+ // can attach an exit code to the error; if it doesn't resolve in
187
+ // time, force-kill and leave exitCode undefined (→ non-transient).
128
188
  let exitCode;
129
189
  if (proc) {
130
190
  const live = proc;
131
- exitCode = await Promise.race([
132
- live.exited,
133
- new Promise((r) => setTimeout(() => r(undefined), TIMEOUTS.gracefulExitMs)),
134
- ]);
191
+ exitCode = await withTimeout(live.exited, TIMEOUTS.gracefulExitMs);
135
192
  if (exitCode === undefined) {
136
193
  live.kill();
137
194
  }
138
195
  }
139
- if (lastDrainDone) {
140
- await Promise.race([lastDrainDone, new Promise((r) => setTimeout(r, 500))]);
196
+ if (lastStderrDrain) {
197
+ await withTimeout(lastStderrDrain.done, TIMEOUTS.stderrDrainGraceMs);
141
198
  }
142
- const stderrMsg = getStderrText();
143
- throw new ProcessError(stderrMsg || "Process exited without completing the turn", exitCode);
199
+ throw processExitedEarly(getStderrText(), exitCode);
144
200
  }
145
201
  return events;
146
202
  };
147
- const doAsk = async (prompt) => {
203
+ const doAsk = async (prompt, askOpts) => {
148
204
  if (!proc) {
149
205
  spawnFresh(prompt, currentSessionId);
150
206
  }
151
- else {
207
+ else if (!safeWrite(proc, writer.user(prompt))) {
208
+ // stdin write failed -- process probably died. Try to respawn.
209
+ // spawnFresh can itself throw ProcessError synchronously when the
210
+ // respawn cap is already hit; surface as an Error like the retry
211
+ // loop below would, instead of a raw synchronous throw.
212
+ consecutiveCrashes++;
213
+ translator.reset();
152
214
  try {
153
- proc.write(writer.user(prompt));
154
- }
155
- catch {
156
- consecutiveCrashes++;
157
- translator.reset();
158
215
  spawnFresh(prompt, currentSessionId);
159
216
  }
217
+ catch (respawnError) {
218
+ killProc();
219
+ throw respawnError;
220
+ }
160
221
  }
222
+ // Compose per-ask signal with session-level signal: either firing aborts.
223
+ const effectiveSignal = composeSignals(options.signal, askOpts?.signal);
161
224
  let events;
162
225
  while (true) {
163
226
  try {
164
- events = await readUntilTurnComplete(options.signal);
227
+ events = await readUntilTurnComplete(effectiveSignal);
165
228
  break;
166
229
  }
167
230
  catch (error) {
@@ -175,6 +238,9 @@ export const createSession = (options = {}) => {
175
238
  throw error;
176
239
  }
177
240
  consecutiveCrashes++;
241
+ // Fire both session-level and per-ask onRetry. Both are safe-invoked
242
+ // so a throwing observer doesn't prevent the retry.
243
+ fireRetry(consecutiveCrashes, error, options.onRetry, askOpts?.onRetry);
178
244
  await respawnBackoff();
179
245
  spawnFresh(prompt, currentSessionId);
180
246
  // Loop to retry; stops when budget exhausted or turn completes.
@@ -194,7 +260,7 @@ export const createSession = (options = {}) => {
194
260
  return buildResult(events, costTracker, currentSessionId);
195
261
  };
196
262
  let closed = false;
197
- const ask = (prompt) => {
263
+ const ask = (prompt, askOpts) => {
198
264
  if (closed) {
199
265
  return Promise.reject(new ClaudeError("Session is closed"));
200
266
  }
@@ -209,7 +275,7 @@ export const createSession = (options = {}) => {
209
275
  if (closed) {
210
276
  throw new ClaudeError("Session is closed");
211
277
  }
212
- return doAsk(prompt);
278
+ return doAsk(prompt, askOpts);
213
279
  })
214
280
  .catch((error) => {
215
281
  if (error instanceof KnownError || error instanceof BudgetExceededError) {
@@ -225,23 +291,24 @@ export const createSession = (options = {}) => {
225
291
  if (inFlight) {
226
292
  // Cap the wait: a stuck reader.read() inside the queued ask would
227
293
  // otherwise hang close() forever before gracefulKill gets a chance.
228
- await Promise.race([inFlight.catch(() => { }), new Promise((r) => setTimeout(r, TIMEOUTS.gracefulExitMs))]);
294
+ await withTimeout(inFlight.catch(() => { }), TIMEOUTS.gracefulExitMs);
229
295
  inFlight = undefined;
230
296
  }
231
297
  if (proc) {
232
- try {
233
- proc.write(writer.abort());
234
- }
235
- catch {
236
- // stdin may already be closed
237
- }
298
+ safeWrite(proc, writer.abort());
238
299
  await gracefulKill(proc);
239
300
  proc = undefined;
240
301
  }
241
302
  cleanupProcess();
242
303
  };
304
+ const askJson = async (prompt, schema, askOpts) => {
305
+ const raw = await ask(prompt, askOpts);
306
+ const data = parseAndValidate(raw.text, schema);
307
+ return { data, raw };
308
+ };
243
309
  return {
244
310
  ask,
311
+ askJson,
245
312
  close,
246
313
  get sessionId() {
247
314
  return currentSessionId;
@@ -0,0 +1,10 @@
1
+ import type { TKnownErrorCode } from "./errors.js";
2
+ /**
3
+ * Attempts to classify an opaque stderr string into a typed `TKnownErrorCode`.
4
+ * Returns `undefined` when no pattern matches -- the caller should keep the
5
+ * original `ProcessError` as-is in that case.
6
+ *
7
+ * The exit code is accepted but not currently used (all classification is
8
+ * text-based). Reserved for future exit-code-specific rules.
9
+ */
10
+ export declare const classifyStderr: (stderr: string, _exitCode?: number) => TKnownErrorCode | undefined;
package/dist/stderr.js ADDED
@@ -0,0 +1,31 @@
1
+ // Conservative pattern table for classifying CLI stderr into typed error
2
+ // codes. Intentionally small -- false classification is worse than no
3
+ // classification. Documented as "may drift across CLI versions."
4
+ //
5
+ // Each entry: [pattern to test against stderr text, resulting code].
6
+ const STDERR_PATTERNS = [
7
+ [/rate[_ -]?limit|429|too many requests/i, "rate-limit"],
8
+ [/overloaded|529|temporarily unavailable/i, "overloaded"],
9
+ [/context[_ -]?length|context[_ -]?window|too long|maximum.*tokens/i, "context-length-exceeded"],
10
+ [/invalid.*json[_ -]?schema|schema.*invalid|json.*schema.*error/i, "invalid-json-schema"],
11
+ [/mcp.*error|mcp.*fail|mcp.*server/i, "mcp-error"],
12
+ [/not authenticated|authentication|unauthorized|401/i, "not-authenticated"],
13
+ [/permission denied|forbidden|403/i, "permission-denied"],
14
+ [/binary.*not found|command not found|ENOENT.*claude/i, "binary-not-found"],
15
+ ];
16
+ /**
17
+ * Attempts to classify an opaque stderr string into a typed `TKnownErrorCode`.
18
+ * Returns `undefined` when no pattern matches -- the caller should keep the
19
+ * original `ProcessError` as-is in that case.
20
+ *
21
+ * The exit code is accepted but not currently used (all classification is
22
+ * text-based). Reserved for future exit-code-specific rules.
23
+ */
24
+ export const classifyStderr = (stderr, _exitCode) => {
25
+ for (const [pattern, code] of STDERR_PATTERNS) {
26
+ if (pattern.test(stderr)) {
27
+ return code;
28
+ }
29
+ }
30
+ return undefined;
31
+ };
package/dist/stream.js CHANGED
@@ -1,68 +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 { drainStderr, readNdjsonEvents } from "./reader.js";
6
+ import { applyTurnComplete, buildResult, extractText, startPipeline } from "./pipeline.js";
7
+ import { readNdjsonEvents } from "./reader.js";
7
8
  import { createToolHandler } from "./tools/handler.js";
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.";
8
13
  export const createStream = (prompt, options = {}) => {
9
- if (options.signal?.aborted) {
10
- throw new AbortError();
11
- }
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.
12
17
  const translator = createTranslator();
13
- const toolHandler = options.tools ? createToolHandler(options.tools) : undefined;
18
+ const toolHandler = options.toolHandler ? createToolHandler(options.toolHandler) : undefined;
14
19
  const costTracker = createCostTracker({
15
20
  maxCostUsd: options.maxCostUsd,
16
21
  onCostUpdate: options.onCostUpdate,
22
+ onWarning: options.onWarning,
17
23
  });
18
24
  let proc;
19
25
  let stderr;
26
+ let stdoutReader;
20
27
  let cachedGenerator;
21
28
  const ensureSpawned = () => {
22
29
  if (!proc) {
23
30
  if (options.signal?.aborted) {
24
31
  throw new AbortError();
25
32
  }
26
- proc = spawnClaude({ prompt, ...options });
27
- stderr = drainStderr(proc);
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;
28
39
  }
29
40
  return proc;
30
41
  };
31
42
  const generate = async function* () {
32
43
  const p = ensureSpawned();
33
- const stdoutReader = p.stdout.getReader();
44
+ // ensureSpawned always populates stdoutReader alongside proc. Typed
45
+ // assertion so consumers can treat it as non-null below.
46
+ const currentReader = stdoutReader;
34
47
  let turnComplete = false;
35
48
  try {
36
49
  for await (const event of readNdjsonEvents({
37
- reader: stdoutReader,
50
+ reader: currentReader,
38
51
  translator,
39
52
  toolHandler,
40
53
  proc: p,
41
54
  signal: options.signal,
55
+ onWarning: options.onWarning,
42
56
  })) {
43
57
  if (event.type === "turn_complete") {
44
- costTracker.update(event.costUsd ?? 0, event.inputTokens ?? 0, event.outputTokens ?? 0);
45
- costTracker.checkBudget();
58
+ applyTurnComplete(event, costTracker);
46
59
  turnComplete = true;
47
60
  }
48
61
  yield event;
49
62
  }
50
63
  if (!turnComplete) {
51
- const exitCode = await p.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
+ }
52
81
  if (exitCode !== 0) {
53
- if (stderr) {
54
- await stderr.done;
55
- }
56
- const stderrText = stderr ? stderr.chunks.join("").trim() : "";
57
82
  const exitMsg = stderrText || `Claude process exited with code ${exitCode}`;
58
83
  throw new ProcessError(exitMsg, exitCode);
59
84
  }
60
- throw new ProcessError("Process exited without completing the turn");
85
+ throw processExitedEarly(stderrText);
61
86
  }
62
87
  }
63
88
  finally {
64
- stdoutReader.releaseLock();
89
+ currentReader.releaseLock();
65
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
+ }
66
97
  }
67
98
  };
68
99
  const bufferedEvents = [];
@@ -70,7 +101,7 @@ export const createStream = (prompt, options = {}) => {
70
101
  const ensureConsumed = () => {
71
102
  if (!consumePromise) {
72
103
  if (cachedGenerator) {
73
- 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);
74
105
  }
75
106
  const gen = generate();
76
107
  cachedGenerator = gen;
@@ -96,9 +127,13 @@ export const createStream = (prompt, options = {}) => {
96
127
  return buildResult(bufferedEvents, costTracker, sessionId);
97
128
  };
98
129
  const cleanup = () => {
99
- // Always kill if a proc was ever spawned — the generator's finally may not
100
- // have run yet (e.g., iterator created but never ticked). Redundant kill
101
- // on an already-exited process is a harmless ESRCH.
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.
102
137
  if (proc) {
103
138
  proc.kill();
104
139
  }
@@ -106,7 +141,7 @@ export const createStream = (prompt, options = {}) => {
106
141
  return {
107
142
  [Symbol.asyncIterator]: () => {
108
143
  if (consumePromise) {
109
- throw new ClaudeError("Cannot iterate after calling text()/cost()/result(). Use one or the other.");
144
+ throw new ClaudeError(MIX_ITER_CONSUME);
110
145
  }
111
146
  cachedGenerator ??= generate();
112
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,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
  };
@@ -10,7 +10,7 @@ export type TToolUseEvent = {
10
10
  type: "tool_use";
11
11
  toolUseId: string;
12
12
  toolName: string;
13
- input: string;
13
+ input: unknown;
14
14
  };
15
15
  export type TToolResultEvent = {
16
16
  type: "tool_result";
@@ -1,9 +1,11 @@
1
1
  import type { TToolDecision } from "../tools/handler.js";
2
+ import type { TBuiltInToolName } from "../tools/registry.js";
2
3
  import type { TToolUseEvent } from "./events.js";
3
4
  import type { TCostSnapshot } from "./results.js";
5
+ export type TToolName = TBuiltInToolName | (string & {});
4
6
  export interface IToolHandler {
5
- allowed?: string[];
6
- blocked?: string[];
7
+ allowed?: TToolName[];
8
+ blocked?: TToolName[];
7
9
  onToolUse?: (tool: TToolUseEvent) => Promise<TToolDecision>;
8
10
  onError?: (error: unknown, tool: TToolUseEvent) => TToolDecision | Promise<TToolDecision>;
9
11
  }
@@ -12,9 +14,9 @@ export interface IClaudeOptions {
12
14
  model?: "opus" | "sonnet" | "haiku" | (string & {});
13
15
  systemPrompt?: string;
14
16
  appendSystemPrompt?: string;
15
- allowedTools?: string[];
16
- disallowedTools?: string[];
17
- tools?: IToolHandler;
17
+ allowedTools?: TToolName[];
18
+ disallowedTools?: TToolName[];
19
+ toolHandler?: IToolHandler;
18
20
  /**
19
21
  * SDK-side budget limit, evaluated after each turn. Throws `BudgetExceededError`
20
22
  * and kills the process when `total_cost_usd` exceeds this value. `0` means
@@ -45,8 +47,43 @@ export interface IClaudeOptions {
45
47
  forkSession?: boolean;
46
48
  noSessionPersistence?: boolean;
47
49
  sessionId?: string;
48
- settingSources?: string;
50
+ settingSources?: "project" | "user" | "local" | "all" | "" | (string & {});
49
51
  disableSlashCommands?: boolean;
52
+ /**
53
+ * Called for every library-emitted warning (user-callback threw, malformed
54
+ * tool decision, etc.). Set this to route warnings to your telemetry or
55
+ * silence them with `() => {}`. When omitted, warnings go to `console.warn`
56
+ * prefixed with `[claude-wire]`.
57
+ */
58
+ onWarning?: (message: string, cause?: unknown) => void;
50
59
  }
51
60
  export interface ISessionOptions extends IClaudeOptions {
61
+ /**
62
+ * Fires each time a transient failure triggers a respawn inside a single
63
+ * `ask()`. `attempt` is 1-indexed. The error is the one that caused the
64
+ * retry (e.g. `ProcessError` with a SIGKILL exit code). Use this to
65
+ * surface retry activity in UI/telemetry; the SDK still handles the retry.
66
+ *
67
+ * Can also be passed per-ask via `session.ask(prompt, { onRetry })` for
68
+ * request-scoped correlation. Both fire if both are set.
69
+ */
70
+ onRetry?: (attempt: number, error: unknown) => void;
71
+ }
72
+ /**
73
+ * Per-ask options passed to `session.ask(prompt, options?)`. Override or
74
+ * supplement session-level callbacks for a single call -- useful for
75
+ * request-scoped logging/correlation in daemon-style consumers.
76
+ */
77
+ export interface IAskOptions {
78
+ /**
79
+ * Per-ask retry observer. Fires alongside the session-level `onRetry` when
80
+ * both are set, so callers can attach request-scoped context (request id,
81
+ * trace span, user id) without reaching outside the callback.
82
+ */
83
+ onRetry?: (attempt: number, error: unknown) => void;
84
+ /**
85
+ * Per-ask abort signal. Aborts this ask only (the session stays alive).
86
+ * Composes with the session-level `signal` -- either firing aborts the ask.
87
+ */
88
+ signal?: AbortSignal;
52
89
  }