@pivanov/claude-wire 0.0.2 → 0.0.3

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/README.md CHANGED
@@ -3,8 +3,7 @@
3
3
  Run [Claude Code](https://claude.ai/download) programmatically from TypeScript.
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/@pivanov/claude-wire)](https://www.npmjs.com/package/@pivanov/claude-wire)
6
- [![npm bundle size](https://img.shields.io/bundlephobia/minzip/@pivanov/claude-wire)](https://bundlephobia.com/package/@pivanov/claude-wire)
7
- [![license](https://img.shields.io/npm/l/claude-wire)](./LICENSE)
6
+ [![license](https://img.shields.io/npm/l/@pivanov/claude-wire)](./LICENSE)
8
7
 
9
8
  ```ts
10
9
  import { claude } from "@pivanov/claude-wire";
@@ -26,7 +25,7 @@ console.log(result.costUsd); // 0.0084
26
25
  - **Cost tracking** - per-request budgets with auto-abort
27
26
  - **Fully typed** - discriminated union events, full IntelliSense
28
27
  - **Resilient** - auto-respawn, transient error detection, AbortSignal
29
- - **Zero dependencies** - 13 kB gzipped
28
+ - **Zero dependencies** - 16.2 kB gzipped
30
29
 
31
30
  ## Install
32
31
 
package/dist/client.js CHANGED
@@ -1,11 +1,15 @@
1
1
  import { createSession } from "./session.js";
2
2
  import { createStream } from "./stream.js";
3
- const mergeOptions = (defaults, overrides) => ({
4
- ...defaults,
5
- ...overrides,
6
- tools: overrides && "tools" in overrides ? (overrides.tools ? { ...defaults.tools, ...overrides.tools } : overrides.tools) : defaults.tools,
7
- env: overrides && "env" in overrides ? (overrides.env ? { ...defaults.env, ...overrides.env } : overrides.env) : defaults.env,
8
- });
3
+ const mergeOptions = (defaults, overrides) => {
4
+ const merged = { ...defaults, ...overrides };
5
+ if (overrides && "tools" in overrides) {
6
+ merged.tools = overrides.tools ? { ...defaults.tools, ...overrides.tools } : overrides.tools;
7
+ }
8
+ if (overrides && "env" in overrides) {
9
+ merged.env = overrides.env ? { ...defaults.env, ...overrides.env } : overrides.env;
10
+ }
11
+ return merged;
12
+ };
9
13
  export const createClient = (defaults = {}) => {
10
14
  const ask = async (prompt, options) => {
11
15
  const merged = mergeOptions(defaults, options);
@@ -6,7 +6,9 @@ export declare const LIMITS: {
6
6
  readonly maxRespawnAttempts: 3;
7
7
  readonly sessionMaxTurnsBeforeRecycle: 100;
8
8
  readonly ndjsonMaxLineChars: number;
9
+ readonly fingerprintTextLen: 64;
9
10
  };
11
+ export declare const RESPAWN_BACKOFF_MS: readonly [500, 1000, 2000];
10
12
  export declare const BINARY: {
11
13
  readonly name: "claude";
12
14
  readonly commonPaths: readonly [`${string}/.local/bin/claude`, `${string}/.claude/bin/claude`, "/usr/local/bin/claude", "/opt/homebrew/bin/claude"];
package/dist/constants.js CHANGED
@@ -7,7 +7,10 @@ export const LIMITS = {
7
7
  maxRespawnAttempts: 3,
8
8
  sessionMaxTurnsBeforeRecycle: 100,
9
9
  ndjsonMaxLineChars: 10 * 1024 * 1024,
10
+ fingerprintTextLen: 64,
10
11
  };
12
+ // Respawn backoff in ms, indexed by consecutiveCrashes (1st=500ms, 2nd=1s, 3rd=2s).
13
+ export const RESPAWN_BACKOFF_MS = [500, 1000, 2000];
11
14
  const home = homedir();
12
15
  export const BINARY = {
13
16
  name: "claude",
package/dist/cost.js CHANGED
@@ -17,8 +17,8 @@ export const createCostTracker = (options = {}) => {
17
17
  try {
18
18
  options.onCostUpdate(snapshot());
19
19
  }
20
- catch {
21
- // user callback error - don't crash the stream/session
20
+ catch (error) {
21
+ console.warn("[claude-wire] onCostUpdate callback threw:", error instanceof Error ? error.message : error);
22
22
  }
23
23
  }
24
24
  };
package/dist/errors.d.ts CHANGED
@@ -16,8 +16,8 @@ export declare class ProcessError extends ClaudeError {
16
16
  readonly exitCode?: number | undefined;
17
17
  constructor(message: string, exitCode?: number | undefined);
18
18
  }
19
- declare const KNOWN_ERROR_CODES: readonly ["not-authenticated", "binary-not-found", "session-expired", "permission-denied", "invalid-model"];
20
- type TKnownErrorCode = (typeof KNOWN_ERROR_CODES)[number];
19
+ export declare const KNOWN_ERROR_CODES: readonly ["not-authenticated", "binary-not-found", "session-expired", "permission-denied", "invalid-model"];
20
+ export type TKnownErrorCode = (typeof KNOWN_ERROR_CODES)[number];
21
21
  export declare class KnownError extends ClaudeError {
22
22
  readonly code: TKnownErrorCode;
23
23
  constructor(code: TKnownErrorCode, message?: string);
@@ -26,4 +26,3 @@ export declare const isKnownError: (error: unknown) => error is KnownError;
26
26
  export declare const isTransientError: (error: unknown) => boolean;
27
27
  export declare const errorMessage: (error: unknown) => string;
28
28
  export declare const assertPositiveNumber: (value: number | undefined, name: string) => void;
29
- export {};
package/dist/errors.js CHANGED
@@ -34,7 +34,7 @@ export class ProcessError extends ClaudeError {
34
34
  this.name = "ProcessError";
35
35
  }
36
36
  }
37
- const KNOWN_ERROR_CODES = ["not-authenticated", "binary-not-found", "session-expired", "permission-denied", "invalid-model"];
37
+ export const KNOWN_ERROR_CODES = ["not-authenticated", "binary-not-found", "session-expired", "permission-denied", "invalid-model"];
38
38
  export class KnownError extends ClaudeError {
39
39
  code;
40
40
  constructor(code, message) {
@@ -46,14 +46,16 @@ export class KnownError extends ClaudeError {
46
46
  export const isKnownError = (error) => {
47
47
  return error instanceof KnownError;
48
48
  };
49
- const TRANSIENT_PATTERN = /fetch failed|ECONNREFUSED|ETIMEDOUT|ECONNRESET|EAI_AGAIN|network error|network timeout|EPIPE|SIGPIPE|broken pipe/i;
49
+ const TRANSIENT_PATTERN = /fetch failed|ECONNREFUSED|ETIMEDOUT|ECONNRESET|ECONNABORTED|ENETUNREACH|EAI_AGAIN|network error|network timeout|EPIPE|SIGPIPE|broken pipe/i;
50
+ // Exit codes we treat as transient: 137 = SIGKILL (OOM), 141 = SIGPIPE,
51
+ // 143 = SIGTERM. Non-zero normal exits (e.g. 1) stay non-transient.
52
+ const TRANSIENT_EXIT_CODES = new Set([137, 141, 143]);
50
53
  export const isTransientError = (error) => {
51
54
  if (error instanceof AbortError || error instanceof BudgetExceededError) {
52
55
  return false;
53
56
  }
54
57
  if (error instanceof ProcessError) {
55
- const transientCodes = [137, 143];
56
- return error.exitCode !== undefined && transientCodes.includes(error.exitCode);
58
+ return error.exitCode !== undefined && TRANSIENT_EXIT_CODES.has(error.exitCode);
57
59
  }
58
60
  const message = errorMessage(error);
59
61
  return TRANSIENT_PATTERN.test(message);
@@ -62,7 +64,8 @@ export const errorMessage = (error) => {
62
64
  return error instanceof Error ? error.message : String(error);
63
65
  };
64
66
  export const assertPositiveNumber = (value, name) => {
65
- if (value !== undefined && (!Number.isFinite(value) || value <= 0)) {
66
- throw new ClaudeError(`${name} must be a finite positive number`);
67
+ // Allow 0 so callers can express "no spend permitted" (useful in tests).
68
+ if (value !== undefined && (!Number.isFinite(value) || value < 0)) {
69
+ throw new ClaudeError(`${name} must be a finite non-negative number`);
67
70
  }
68
71
  };
package/dist/index.d.ts CHANGED
@@ -3,7 +3,8 @@ export { createClient } from "./client.js";
3
3
  export { BINARY, LIMITS, TIMEOUTS } from "./constants.js";
4
4
  export type { ICostTracker, ICostTrackerOptions } from "./cost.js";
5
5
  export { createCostTracker } from "./cost.js";
6
- export { AbortError, assertPositiveNumber, BudgetExceededError, ClaudeError, errorMessage, isKnownError, isTransientError, KnownError, ProcessError, TimeoutError, } from "./errors.js";
6
+ export type { TKnownErrorCode } from "./errors.js";
7
+ export { AbortError, assertPositiveNumber, BudgetExceededError, ClaudeError, errorMessage, isKnownError, isTransientError, KNOWN_ERROR_CODES, KnownError, ProcessError, TimeoutError, } from "./errors.js";
7
8
  export { blockFingerprint, extractContent, parseDoubleEncoded } from "./parser/content.js";
8
9
  export { parseLine } from "./parser/ndjson.js";
9
10
  export type { ITranslator } from "./parser/translator.js";
package/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
+ import { createClient } from "./client.js";
1
2
  export { createClient } from "./client.js";
2
3
  export { BINARY, LIMITS, TIMEOUTS } from "./constants.js";
3
4
  export { createCostTracker } from "./cost.js";
4
- export { AbortError, assertPositiveNumber, BudgetExceededError, ClaudeError, errorMessage, isKnownError, isTransientError, KnownError, ProcessError, TimeoutError, } from "./errors.js";
5
+ export { AbortError, assertPositiveNumber, BudgetExceededError, ClaudeError, errorMessage, isKnownError, isTransientError, KNOWN_ERROR_CODES, KnownError, ProcessError, TimeoutError, } from "./errors.js";
5
6
  export { blockFingerprint, extractContent, parseDoubleEncoded } from "./parser/content.js";
6
7
  export { parseLine } from "./parser/ndjson.js";
7
8
  export { createTranslator } from "./parser/translator.js";
@@ -12,5 +13,4 @@ export { createStream } from "./stream.js";
12
13
  export { createToolHandler } from "./tools/handler.js";
13
14
  export { BUILT_IN_TOOLS, isBuiltInTool } from "./tools/registry.js";
14
15
  export { writer } from "./writer.js";
15
- import { createClient } from "./client.js";
16
16
  export const claude = createClient();
@@ -1,10 +1,11 @@
1
+ import { LIMITS } from "../constants.js";
1
2
  export const blockFingerprint = (block) => {
2
3
  if (block.type === "tool_use" && block.id) {
3
4
  return `tool_use:${block.id}`;
4
5
  }
5
6
  const text = block.type === "thinking" ? (block.thinking ?? block.text ?? "") : (block.text ?? "");
6
7
  if (text) {
7
- return `${block.type}:${text.slice(0, 64)}`;
8
+ return `${block.type}:${text.slice(0, LIMITS.fingerprintTextLen)}`;
8
9
  }
9
10
  return `${block.type}:${block.tool_use_id ?? "unknown"}`;
10
11
  };
@@ -17,7 +18,7 @@ export const extractContent = (content) => {
17
18
  }
18
19
  if (Array.isArray(content)) {
19
20
  return content
20
- .filter((block) => typeof block === "object" && block !== null && "text" in block)
21
+ .filter((block) => typeof block === "object" && block !== null && "text" in block && typeof block.text === "string")
21
22
  .map((block) => block.text)
22
23
  .join("\n");
23
24
  }
@@ -41,13 +41,15 @@ const translateContentBlock = (block) => {
41
41
  return undefined;
42
42
  }
43
43
  case "tool_use": {
44
- if (!block.id) {
44
+ // Drop malformed tool_use events entirely. An empty toolName would
45
+ // otherwise bypass allow/block lists by matching nothing.
46
+ if (!block.id || !block.name) {
45
47
  return undefined;
46
48
  }
47
49
  return {
48
50
  type: "tool_use",
49
51
  toolUseId: block.id,
50
- toolName: block.name ?? "",
52
+ toolName: block.name,
51
53
  input: typeof block.input === "string" ? block.input : JSON.stringify(block.input ?? {}),
52
54
  };
53
55
  }
package/dist/process.d.ts CHANGED
@@ -10,6 +10,8 @@ export interface IClaudeProcess {
10
10
  export interface ISpawnOptions extends IClaudeOptions {
11
11
  prompt?: string;
12
12
  }
13
+ export declare const ALIAS_PATTERN: RegExp;
14
+ export declare const resolveConfigDirFromAlias: () => string | undefined;
13
15
  export declare const resetBinaryCache: () => void;
14
16
  export declare const buildArgs: (options: ISpawnOptions, binaryPath: string) => string[];
15
17
  export declare const spawnClaude: (options: ISpawnOptions) => IClaudeProcess;
package/dist/process.js CHANGED
@@ -17,8 +17,10 @@ const resolveBinaryPath = () => {
17
17
  }
18
18
  return BINARY.name;
19
19
  };
20
- const ALIAS_PATTERN = /(?:alias\s+claude\s*=|export\s+).*CLAUDE_CONFIG_DIR=["']?\$?(?:HOME|\{HOME\}|~)\/?([^\s"']+?)["']?(?:\s|\/|$)/;
21
- const resolveConfigDirFromAlias = () => {
20
+ // Rejects lines whose first non-whitespace char is `#` so commented-out
21
+ // aliases/exports don't silently apply. /m anchors to each line in rc files.
22
+ export const ALIAS_PATTERN = /^(?!\s*#).*?(?:alias\s+claude\s*=|export\s+).*CLAUDE_CONFIG_DIR=["']?\$?(?:HOME|\{HOME\}|~)\/?([^\s"']+?)["']?(?:\s|$)/m;
23
+ export const resolveConfigDirFromAlias = () => {
22
24
  const home = homedir();
23
25
  const rcFiles = [".zshrc", ".bashrc", ".zprofile", ".bash_profile", ".aliases"];
24
26
  for (const rcFile of rcFiles) {
@@ -50,18 +52,20 @@ const resolve = () => {
50
52
  };
51
53
  export const buildArgs = (options, binaryPath) => {
52
54
  const args = [binaryPath, "-p", "--output-format", "stream-json", "--input-format", "stream-json"];
53
- if (options.verbose !== false) {
54
- args.push("--verbose");
55
- }
56
- if (options.model) {
57
- args.push("--model", options.model);
58
- }
59
- if (options.systemPrompt) {
60
- args.push("--system-prompt", options.systemPrompt);
61
- }
62
- if (options.appendSystemPrompt) {
63
- args.push("--append-system-prompt", options.appendSystemPrompt);
64
- }
55
+ const flag = (cond, name) => {
56
+ if (cond) {
57
+ args.push(name);
58
+ }
59
+ };
60
+ const kv = (value, name) => {
61
+ if (value) {
62
+ args.push(name, value);
63
+ }
64
+ };
65
+ flag(options.verbose !== false, "--verbose");
66
+ kv(options.model, "--model");
67
+ kv(options.systemPrompt, "--system-prompt");
68
+ kv(options.appendSystemPrompt, "--append-system-prompt");
65
69
  if (options.allowedTools) {
66
70
  if (options.allowedTools.length === 0) {
67
71
  args.push("--tools", "");
@@ -76,53 +80,27 @@ export const buildArgs = (options, binaryPath) => {
76
80
  if (options.maxBudgetUsd !== undefined) {
77
81
  args.push("--max-budget-usd", String(options.maxBudgetUsd));
78
82
  }
79
- if (options.resume) {
80
- args.push("--resume", options.resume);
81
- }
82
- if (options.mcpConfig) {
83
- args.push("--mcp-config", options.mcpConfig);
84
- }
85
- if (options.continueSession) {
86
- args.push("--continue");
87
- }
88
- if (options.permissionMode) {
89
- args.push("--permission-mode", options.permissionMode);
90
- }
83
+ kv(options.resume, "--resume");
84
+ kv(options.mcpConfig, "--mcp-config");
85
+ flag(options.continueSession, "--continue");
86
+ kv(options.permissionMode, "--permission-mode");
91
87
  if (options.addDirs && options.addDirs.length > 0) {
92
88
  for (const dir of options.addDirs) {
93
89
  args.push("--add-dir", dir);
94
90
  }
95
91
  }
96
- if (options.effort) {
97
- args.push("--effort", options.effort);
98
- }
99
- if (options.includeHookEvents) {
100
- args.push("--include-hook-events");
101
- }
102
- if (options.includePartialMessages) {
103
- args.push("--include-partial-messages");
104
- }
105
- if (options.bare) {
106
- args.push("--bare");
107
- }
108
- if (options.jsonSchema) {
109
- args.push("--json-schema", options.jsonSchema);
110
- }
111
- if (options.forkSession) {
112
- args.push("--fork-session");
113
- }
114
- if (options.noSessionPersistence) {
115
- args.push("--no-session-persistence");
116
- }
117
- if (options.sessionId) {
118
- args.push("--session-id", options.sessionId);
119
- }
92
+ kv(options.effort, "--effort");
93
+ flag(options.includeHookEvents, "--include-hook-events");
94
+ flag(options.includePartialMessages, "--include-partial-messages");
95
+ flag(options.bare, "--bare");
96
+ kv(options.jsonSchema, "--json-schema");
97
+ flag(options.forkSession, "--fork-session");
98
+ flag(options.noSessionPersistence, "--no-session-persistence");
99
+ kv(options.sessionId, "--session-id");
120
100
  if (options.settingSources !== undefined) {
121
101
  args.push("--setting-sources", options.settingSources);
122
102
  }
123
- if (options.disableSlashCommands) {
124
- args.push("--disable-slash-commands");
125
- }
103
+ flag(options.disableSlashCommands, "--disable-slash-commands");
126
104
  return args;
127
105
  };
128
106
  export const spawnClaude = (options) => {
@@ -133,13 +111,16 @@ export const spawnClaude = (options) => {
133
111
  const needsEnv = resolved.aliasConfigDir || options.configDir || options.env;
134
112
  let spawnEnv;
135
113
  if (needsEnv) {
114
+ // Priority (lowest → highest): process.env < alias-detected config <
115
+ // user's explicit `options.env` < explicit `options.configDir`. User
116
+ // input always outranks the alias heuristic.
136
117
  spawnEnv = { ...process.env };
137
- if (options.env) {
138
- Object.assign(spawnEnv, options.env);
139
- }
140
118
  if (resolved.aliasConfigDir) {
141
119
  spawnEnv.CLAUDE_CONFIG_DIR = resolved.aliasConfigDir;
142
120
  }
121
+ if (options.env) {
122
+ Object.assign(spawnEnv, options.env);
123
+ }
143
124
  if (options.configDir) {
144
125
  spawnEnv.CLAUDE_CONFIG_DIR = options.configDir;
145
126
  }
package/dist/reader.d.ts CHANGED
@@ -9,5 +9,11 @@ export interface IReaderOptions {
9
9
  proc?: IClaudeProcess;
10
10
  signal?: AbortSignal;
11
11
  }
12
+ export interface IStderrDrain {
13
+ chunks: string[];
14
+ done: Promise<void>;
15
+ }
16
+ export declare const drainStderr: (proc: {
17
+ stderr: ReadableStream<Uint8Array>;
18
+ }) => IStderrDrain;
12
19
  export declare function readNdjsonEvents(opts: IReaderOptions): AsyncGenerator<TRelayEvent>;
13
- export type { TRelayEvent };
package/dist/reader.js CHANGED
@@ -2,17 +2,68 @@ import { LIMITS, TIMEOUTS } from "./constants.js";
2
2
  import { AbortError, ClaudeError, TimeoutError } from "./errors.js";
3
3
  import { parseLine } from "./parser/ndjson.js";
4
4
  import { dispatchToolDecision } from "./pipeline.js";
5
+ import { writer } from "./writer.js";
6
+ export const drainStderr = (proc) => {
7
+ const chunks = [];
8
+ const stderrReader = proc.stderr.getReader();
9
+ const decoder = new TextDecoder();
10
+ const done = (async () => {
11
+ try {
12
+ while (true) {
13
+ const { done: isDone, value } = await stderrReader.read();
14
+ if (isDone) {
15
+ break;
16
+ }
17
+ chunks.push(decoder.decode(value, { stream: true }));
18
+ }
19
+ }
20
+ catch {
21
+ // process exited
22
+ }
23
+ finally {
24
+ // Flush any trailing partial multibyte sequence.
25
+ const tail = decoder.decode();
26
+ if (tail) {
27
+ chunks.push(tail);
28
+ }
29
+ stderrReader.releaseLock();
30
+ }
31
+ })().catch(() => { });
32
+ return { chunks, done };
33
+ };
5
34
  export async function* readNdjsonEvents(opts) {
6
35
  const { reader, translator, signal } = opts;
7
36
  const decoder = new TextDecoder();
8
37
  let buffer = "";
9
38
  let timeoutId;
10
39
  let turnComplete = false;
40
+ let abortReject;
41
+ const abortPromise = new Promise((_, reject) => {
42
+ abortReject = reject;
43
+ });
44
+ // Swallow unhandled rejection if nothing ever races against this promise.
45
+ abortPromise.catch(() => { });
46
+ // Single resettable timeout shared across all iterations — avoids leaking
47
+ // a new Promise + setTimeout per read loop.
48
+ let timeoutReject;
49
+ const timeoutPromise = new Promise((_, reject) => {
50
+ timeoutReject = reject;
51
+ });
52
+ timeoutPromise.catch(() => { });
53
+ const resetReadTimeout = () => {
54
+ if (timeoutId) {
55
+ clearTimeout(timeoutId);
56
+ }
57
+ timeoutId = setTimeout(() => {
58
+ timeoutReject?.(new TimeoutError(`No data received within ${TIMEOUTS.defaultAbortMs}ms`));
59
+ }, TIMEOUTS.defaultAbortMs);
60
+ };
11
61
  const abortHandler = signal
12
62
  ? () => {
63
+ abortReject?.(new AbortError());
13
64
  if (opts.proc) {
14
65
  try {
15
- opts.proc.write('{"type":"abort"}\n');
66
+ opts.proc.write(writer.abort());
16
67
  }
17
68
  catch {
18
69
  // stdin closed
@@ -32,13 +83,8 @@ export async function* readNdjsonEvents(opts) {
32
83
  if (signal?.aborted) {
33
84
  throw new AbortError();
34
85
  }
35
- const timeoutPromise = new Promise((_, reject) => {
36
- timeoutId = setTimeout(() => {
37
- reject(new TimeoutError(`No data received within ${TIMEOUTS.defaultAbortMs}ms`));
38
- }, TIMEOUTS.defaultAbortMs);
39
- });
40
- const readResult = await Promise.race([reader.read(), timeoutPromise]);
41
- clearTimeout(timeoutId);
86
+ resetReadTimeout();
87
+ const readResult = await Promise.race([reader.read(), timeoutPromise, abortPromise]);
42
88
  const { done, value } = readResult;
43
89
  if (done) {
44
90
  break;
package/dist/runtime.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { execFileSync, spawn as nodeSpawn } from "node:child_process";
2
2
  import { accessSync, constants, statSync } from "node:fs";
3
3
  import { Readable } from "node:stream";
4
+ import { ProcessError } from "./errors.js";
4
5
  const isBun = typeof globalThis.Bun !== "undefined";
5
6
  export const spawnProcess = (args, opts) => {
6
7
  if (isBun) {
@@ -30,15 +31,6 @@ export const whichSync = (name) => {
30
31
  }
31
32
  };
32
33
  export const fileExists = (path) => {
33
- if (isBun) {
34
- try {
35
- accessSync(path, constants.X_OK);
36
- return statSync(path).size > 0;
37
- }
38
- catch {
39
- return false;
40
- }
41
- }
42
34
  try {
43
35
  accessSync(path, constants.X_OK);
44
36
  return statSync(path).size > 0;
@@ -73,43 +65,20 @@ const spawnBun = (args, opts) => {
73
65
  pid: proc.pid,
74
66
  };
75
67
  };
76
- const nodeReadableToWeb = (readable) => {
77
- return new ReadableStream({
78
- start(controller) {
79
- let closed = false;
80
- readable.on("data", (chunk) => {
81
- if (!closed) {
82
- controller.enqueue(new Uint8Array(chunk));
83
- }
84
- });
85
- readable.on("end", () => {
86
- if (!closed) {
87
- closed = true;
88
- controller.close();
89
- }
90
- });
91
- readable.on("error", (err) => {
92
- if (!closed) {
93
- closed = true;
94
- controller.error(err);
95
- }
96
- });
97
- },
98
- cancel() {
99
- readable.destroy();
100
- },
101
- });
102
- };
68
+ const toWeb = (readable) => Readable.toWeb(readable);
103
69
  const spawnNode = (args, opts) => {
104
70
  const [cmd, ...rest] = args;
105
71
  if (!cmd) {
106
- throw new Error("No command specified");
72
+ throw new ProcessError("No command specified");
107
73
  }
108
74
  const child = nodeSpawn(cmd, rest, {
109
75
  cwd: opts.cwd,
110
76
  stdio: ["pipe", "pipe", "pipe"],
111
77
  env: opts.env,
112
78
  });
79
+ if (child.pid === undefined) {
80
+ throw new ProcessError(`Failed to spawn ${cmd}: no PID assigned`);
81
+ }
113
82
  const exited = new Promise((resolve, reject) => {
114
83
  child.on("exit", (code) => {
115
84
  resolve(code ?? 1);
@@ -119,18 +88,21 @@ const spawnNode = (args, opts) => {
119
88
  return {
120
89
  stdin: {
121
90
  write: (data) => {
122
- child.stdin?.write(data);
91
+ if (!child.stdin || child.stdin.destroyed) {
92
+ throw new ProcessError("Cannot write: stdin is not writable");
93
+ }
94
+ child.stdin.write(data);
123
95
  },
124
96
  end: () => {
125
97
  child.stdin?.end();
126
98
  },
127
99
  },
128
- stdout: child.stdout ? nodeReadableToWeb(child.stdout) : new ReadableStream(),
129
- stderr: child.stderr ? nodeReadableToWeb(child.stderr) : new ReadableStream(),
100
+ stdout: child.stdout ? toWeb(child.stdout) : new ReadableStream(),
101
+ stderr: child.stderr ? toWeb(child.stderr) : new ReadableStream(),
130
102
  kill: () => {
131
103
  child.kill();
132
104
  },
133
105
  exited,
134
- pid: child.pid ?? 0,
106
+ pid: child.pid,
135
107
  };
136
108
  };
package/dist/session.js CHANGED
@@ -1,10 +1,10 @@
1
- import { LIMITS, TIMEOUTS } from "./constants.js";
1
+ import { LIMITS, RESPAWN_BACKOFF_MS, TIMEOUTS } from "./constants.js";
2
2
  import { createCostTracker } from "./cost.js";
3
3
  import { AbortError, BudgetExceededError, ClaudeError, isTransientError, KnownError, ProcessError, TimeoutError } from "./errors.js";
4
4
  import { createTranslator } from "./parser/translator.js";
5
5
  import { buildResult } from "./pipeline.js";
6
6
  import { spawnClaude } from "./process.js";
7
- import { readNdjsonEvents } from "./reader.js";
7
+ import { drainStderr, readNdjsonEvents } from "./reader.js";
8
8
  import { createToolHandler } from "./tools/handler.js";
9
9
  import { writer } from "./writer.js";
10
10
  const gracefulKill = async (p) => {
@@ -62,29 +62,7 @@ export const createSession = (options = {}) => {
62
62
  reader = undefined;
63
63
  };
64
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
- };
65
+ let lastDrainDone;
88
66
  const getStderrText = () => lastStderrChunks.join("").trim();
89
67
  const killProc = () => {
90
68
  if (proc) {
@@ -105,7 +83,16 @@ export const createSession = (options = {}) => {
105
83
  const spawnOpts = resumeId ? { prompt, ...options, resume: resumeId } : { prompt, ...options };
106
84
  proc = spawnClaude(spawnOpts);
107
85
  reader = proc.stdout.getReader();
108
- drainStderr(proc);
86
+ const drain = drainStderr(proc);
87
+ lastStderrChunks = drain.chunks;
88
+ lastDrainDone = drain.done;
89
+ };
90
+ const respawnBackoff = async () => {
91
+ const idx = Math.min(consecutiveCrashes, RESPAWN_BACKOFF_MS.length) - 1;
92
+ const delay = idx >= 0 ? RESPAWN_BACKOFF_MS[idx] : 0;
93
+ if (delay) {
94
+ await new Promise((r) => setTimeout(r, delay));
95
+ }
109
96
  };
110
97
  const readUntilTurnComplete = async (signal) => {
111
98
  if (!proc || !reader) {
@@ -135,8 +122,25 @@ export const createSession = (options = {}) => {
135
122
  if (signal?.aborted) {
136
123
  throw new AbortError();
137
124
  }
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).
128
+ let exitCode;
129
+ if (proc) {
130
+ const live = proc;
131
+ exitCode = await Promise.race([
132
+ live.exited,
133
+ new Promise((r) => setTimeout(() => r(undefined), TIMEOUTS.gracefulExitMs)),
134
+ ]);
135
+ if (exitCode === undefined) {
136
+ live.kill();
137
+ }
138
+ }
139
+ if (lastDrainDone) {
140
+ await Promise.race([lastDrainDone, new Promise((r) => setTimeout(r, 500))]);
141
+ }
138
142
  const stderrMsg = getStderrText();
139
- throw new ProcessError(stderrMsg || "Process exited without completing the turn");
143
+ throw new ProcessError(stderrMsg || "Process exited without completing the turn", exitCode);
140
144
  }
141
145
  return events;
142
146
  };
@@ -155,30 +159,25 @@ export const createSession = (options = {}) => {
155
159
  }
156
160
  }
157
161
  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;
162
+ while (true) {
163
+ try {
164
+ events = await readUntilTurnComplete(options.signal);
165
+ break;
165
166
  }
166
- if (isTransientError(error) && consecutiveCrashes < LIMITS.maxRespawnAttempts) {
167
- consecutiveCrashes++;
168
- spawnFresh(prompt, currentSessionId);
169
- try {
170
- events = await readUntilTurnComplete(options.signal);
167
+ catch (error) {
168
+ if (error instanceof AbortError || error instanceof TimeoutError) {
169
+ killProc();
170
+ throw error;
171
171
  }
172
- catch (retryError) {
172
+ if (!isTransientError(error) || consecutiveCrashes >= LIMITS.maxRespawnAttempts) {
173
173
  killProc();
174
174
  translator.reset();
175
- throw retryError;
175
+ throw error;
176
176
  }
177
- }
178
- else {
179
- killProc();
180
- translator.reset();
181
- throw error;
177
+ consecutiveCrashes++;
178
+ await respawnBackoff();
179
+ spawnFresh(prompt, currentSessionId);
180
+ // Loop to retry; stops when budget exhausted or turn completes.
182
181
  }
183
182
  }
184
183
  consecutiveCrashes = 0;
@@ -201,19 +200,32 @@ export const createSession = (options = {}) => {
201
200
  }
202
201
  const prev = inFlight ?? Promise.resolve();
203
202
  const run = prev
204
- .catch((prevError) => {
205
- if (prevError instanceof KnownError || prevError instanceof BudgetExceededError) {
206
- throw prevError;
203
+ .catch(() => {
204
+ // Prior ask failure shouldn't prevent this one from running. Fatal
205
+ // errors (KnownError/BudgetExceededError) set `closed` in the .catch
206
+ // below, which the sync check above picks up on the NEXT ask.
207
+ })
208
+ .then(() => {
209
+ if (closed) {
210
+ throw new ClaudeError("Session is closed");
207
211
  }
212
+ return doAsk(prompt);
208
213
  })
209
- .then(() => doAsk(prompt));
214
+ .catch((error) => {
215
+ if (error instanceof KnownError || error instanceof BudgetExceededError) {
216
+ closed = true;
217
+ }
218
+ throw error;
219
+ });
210
220
  inFlight = run;
211
221
  return run;
212
222
  };
213
223
  const close = async () => {
214
224
  closed = true;
215
225
  if (inFlight) {
216
- await inFlight.catch(() => { });
226
+ // Cap the wait: a stuck reader.read() inside the queued ask would
227
+ // otherwise hang close() forever before gracefulKill gets a chance.
228
+ await Promise.race([inFlight.catch(() => { }), new Promise((r) => setTimeout(r, TIMEOUTS.gracefulExitMs))]);
217
229
  inFlight = undefined;
218
230
  }
219
231
  if (proc) {
package/dist/stream.js CHANGED
@@ -3,53 +3,41 @@ import { AbortError, ClaudeError, ProcessError } from "./errors.js";
3
3
  import { createTranslator } from "./parser/translator.js";
4
4
  import { buildResult, extractText } from "./pipeline.js";
5
5
  import { spawnClaude } from "./process.js";
6
- import { readNdjsonEvents } from "./reader.js";
6
+ import { drainStderr, readNdjsonEvents } from "./reader.js";
7
7
  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
- };
31
8
  export const createStream = (prompt, options = {}) => {
32
9
  if (options.signal?.aborted) {
33
10
  throw new AbortError();
34
11
  }
35
- const proc = spawnClaude({ prompt, ...options });
36
- const stderr = drainStderr(proc);
37
12
  const translator = createTranslator();
38
13
  const toolHandler = options.tools ? createToolHandler(options.tools) : undefined;
39
14
  const costTracker = createCostTracker({
40
15
  maxCostUsd: options.maxCostUsd,
41
16
  onCostUpdate: options.onCostUpdate,
42
17
  });
18
+ let proc;
19
+ let stderr;
43
20
  let cachedGenerator;
21
+ const ensureSpawned = () => {
22
+ if (!proc) {
23
+ if (options.signal?.aborted) {
24
+ throw new AbortError();
25
+ }
26
+ proc = spawnClaude({ prompt, ...options });
27
+ stderr = drainStderr(proc);
28
+ }
29
+ return proc;
30
+ };
44
31
  const generate = async function* () {
45
- const stdoutReader = proc.stdout.getReader();
32
+ const p = ensureSpawned();
33
+ const stdoutReader = p.stdout.getReader();
46
34
  let turnComplete = false;
47
35
  try {
48
36
  for await (const event of readNdjsonEvents({
49
37
  reader: stdoutReader,
50
38
  translator,
51
39
  toolHandler,
52
- proc,
40
+ proc: p,
53
41
  signal: options.signal,
54
42
  })) {
55
43
  if (event.type === "turn_complete") {
@@ -60,22 +48,21 @@ export const createStream = (prompt, options = {}) => {
60
48
  yield event;
61
49
  }
62
50
  if (!turnComplete) {
63
- const exitCode = await proc.exited;
51
+ const exitCode = await p.exited;
64
52
  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;
53
+ if (stderr) {
54
+ await stderr.done;
70
55
  }
71
- throw new ProcessError(errorMessage, exitCode);
56
+ const stderrText = stderr ? stderr.chunks.join("").trim() : "";
57
+ const exitMsg = stderrText || `Claude process exited with code ${exitCode}`;
58
+ throw new ProcessError(exitMsg, exitCode);
72
59
  }
73
60
  throw new ProcessError("Process exited without completing the turn");
74
61
  }
75
62
  }
76
63
  finally {
77
64
  stdoutReader.releaseLock();
78
- proc.kill();
65
+ p.kill();
79
66
  }
80
67
  };
81
68
  const bufferedEvents = [];
@@ -85,9 +72,10 @@ export const createStream = (prompt, options = {}) => {
85
72
  if (cachedGenerator) {
86
73
  throw new ClaudeError("Cannot call text()/cost()/result() after iterating with for-await. Use one or the other.");
87
74
  }
75
+ const gen = generate();
76
+ cachedGenerator = gen;
88
77
  consumePromise = (async () => {
89
- cachedGenerator = generate();
90
- for await (const event of { [Symbol.asyncIterator]: () => cachedGenerator }) {
78
+ for await (const event of gen) {
91
79
  bufferedEvents.push(event);
92
80
  }
93
81
  })();
@@ -108,7 +96,10 @@ export const createStream = (prompt, options = {}) => {
108
96
  return buildResult(bufferedEvents, costTracker, sessionId);
109
97
  };
110
98
  const cleanup = () => {
111
- if (!cachedGenerator) {
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.
102
+ if (proc) {
112
103
  proc.kill();
113
104
  }
114
105
  };
@@ -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
  };
@@ -5,6 +5,7 @@ export interface IToolHandler {
5
5
  allowed?: string[];
6
6
  blocked?: string[];
7
7
  onToolUse?: (tool: TToolUseEvent) => Promise<TToolDecision>;
8
+ onError?: (error: unknown, tool: TToolUseEvent) => TToolDecision | Promise<TToolDecision>;
8
9
  }
9
10
  export interface IClaudeOptions {
10
11
  cwd?: string;
@@ -14,7 +15,17 @@ export interface IClaudeOptions {
14
15
  allowedTools?: string[];
15
16
  disallowedTools?: string[];
16
17
  tools?: IToolHandler;
18
+ /**
19
+ * SDK-side budget limit, evaluated after each turn. Throws `BudgetExceededError`
20
+ * and kills the process when `total_cost_usd` exceeds this value. `0` means
21
+ * "disallow any spend" (useful for tests).
22
+ */
17
23
  maxCostUsd?: number;
24
+ /**
25
+ * CLI-level budget forwarded as `--max-budget-usd`. Enforced by the Claude
26
+ * binary itself, independent of {@link IClaudeOptions.maxCostUsd}. Either
27
+ * can fire first; set both for belt-and-suspenders.
28
+ */
18
29
  maxBudgetUsd?: number;
19
30
  onCostUpdate?: (cost: TCostSnapshot) => void;
20
31
  signal?: AbortSignal;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pivanov/claude-wire",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "description": "Run Claude Code programmatically. Typed SDK for spawning, streaming, and controlling the CLI.",
5
5
  "type": "module",
6
6
  "engines": {