@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/process.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { IClaudeOptions } from "./types/options.js";
2
2
  export interface IClaudeProcess {
3
3
  write: (message: string) => void;
4
- kill: () => void;
4
+ kill: (signal?: NodeJS.Signals | number) => void;
5
5
  exited: Promise<number>;
6
6
  stdout: ReadableStream<Uint8Array>;
7
7
  stderr: ReadableStream<Uint8Array>;
@@ -10,8 +10,20 @@ export interface IClaudeProcess {
10
10
  export interface ISpawnOptions extends IClaudeOptions {
11
11
  prompt?: string;
12
12
  }
13
+ export declare const safeKill: (proc: Pick<IClaudeProcess, "kill">, signal?: NodeJS.Signals | number) => void;
14
+ export declare const safeWrite: (proc: Pick<IClaudeProcess, "write">, line: string) => boolean;
13
15
  export declare const ALIAS_PATTERN: RegExp;
14
- export declare const resolveConfigDirFromAlias: () => string | undefined;
15
- export declare const resetBinaryCache: () => void;
16
+ /**
17
+ * Clears the cached resolved environment (binary path + alias-detected
18
+ * `CLAUDE_CONFIG_DIR`). Call this when either has changed mid-process -- for
19
+ * example after installing the Claude CLI during a test run, or when a long-
20
+ * running daemon updates the user's shell rc file. The next `spawnClaude()`
21
+ * will re-resolve from scratch.
22
+ *
23
+ * Normal applications should never need this; the cache is populated once at
24
+ * first use and kept for the process lifetime.
25
+ */
26
+ export declare const resetResolvedEnvCache: () => void;
16
27
  export declare const buildArgs: (options: ISpawnOptions, binaryPath: string) => string[];
28
+ export declare const buildSpawnEnv: (baseEnv: Record<string, string | undefined>, aliasConfigDir: string | undefined, options: Pick<ISpawnOptions, "configDir" | "env">) => Record<string, string | undefined> | undefined;
17
29
  export declare const spawnClaude: (options: ISpawnOptions) => IClaudeProcess;
package/dist/process.js CHANGED
@@ -2,16 +2,41 @@ import { readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { BINARY } from "./constants.js";
5
- import { assertPositiveNumber, errorMessage, KnownError, ProcessError } from "./errors.js";
6
- import { fileExists, spawnProcess, whichSync } from "./runtime.js";
5
+ import { errorMessage, KnownError, ProcessError } from "./errors.js";
6
+ import { isExecutableNonEmpty, spawnProcess, whichSync } from "./runtime.js";
7
+ import { assertPositiveNumber } from "./validation.js";
7
8
  import { writer } from "./writer.js";
9
+ // Swallow ESRCH/EPIPE-style throws from kill()/write() when the child is
10
+ // already gone. Every call site had the same try/catch -- keeping it in one
11
+ // place stops future adders from forgetting the guard.
12
+ export const safeKill = (proc, signal) => {
13
+ try {
14
+ proc.kill(signal);
15
+ }
16
+ catch {
17
+ // already dead
18
+ }
19
+ };
20
+ export const safeWrite = (proc, line) => {
21
+ try {
22
+ proc.write(line);
23
+ return true;
24
+ }
25
+ catch {
26
+ // stdin closed / process died -- caller surfaces the error via the read path
27
+ return false;
28
+ }
29
+ };
30
+ // Resolves the `claude` CLI binary path. POSIX-only today: uses `which` and
31
+ // `$HOME`-rooted common install paths. Windows users running under WSL get
32
+ // the Linux layout, which works; native Windows is not supported yet.
8
33
  const resolveBinaryPath = () => {
9
34
  const found = whichSync("claude");
10
35
  if (found) {
11
36
  return found;
12
37
  }
13
38
  for (const p of BINARY.commonPaths) {
14
- if (fileExists(p)) {
39
+ if (isExecutableNonEmpty(p)) {
15
40
  return p;
16
41
  }
17
42
  }
@@ -20,9 +45,12 @@ const resolveBinaryPath = () => {
20
45
  // Rejects lines whose first non-whitespace char is `#` so commented-out
21
46
  // aliases/exports don't silently apply. /m anchors to each line in rc files.
22
47
  export const ALIAS_PATTERN = /^(?!\s*#).*?(?:alias\s+claude\s*=|export\s+).*CLAUDE_CONFIG_DIR=["']?\$?(?:HOME|\{HOME\}|~)\/?([^\s"']+?)["']?(?:\s|$)/m;
23
- export const resolveConfigDirFromAlias = () => {
48
+ const resolveConfigDirFromAlias = () => {
24
49
  const home = homedir();
25
- const rcFiles = [".zshrc", ".bashrc", ".zprofile", ".bash_profile", ".aliases"];
50
+ // .zshenv is the one file zsh sources for NON-interactive shells, so
51
+ // users who export CLAUDE_CONFIG_DIR for cron/CI-like contexts often
52
+ // put it there. Include it alongside the interactive-shell rc files.
53
+ const rcFiles = [".zshenv", ".zshrc", ".bashrc", ".zprofile", ".bash_profile", ".aliases"];
26
54
  for (const rcFile of rcFiles) {
27
55
  try {
28
56
  const content = readFileSync(join(home, rcFile), "utf-8");
@@ -38,7 +66,17 @@ export const resolveConfigDirFromAlias = () => {
38
66
  return undefined;
39
67
  };
40
68
  let cached;
41
- export const resetBinaryCache = () => {
69
+ /**
70
+ * Clears the cached resolved environment (binary path + alias-detected
71
+ * `CLAUDE_CONFIG_DIR`). Call this when either has changed mid-process -- for
72
+ * example after installing the Claude CLI during a test run, or when a long-
73
+ * running daemon updates the user's shell rc file. The next `spawnClaude()`
74
+ * will re-resolve from scratch.
75
+ *
76
+ * Normal applications should never need this; the cache is populated once at
77
+ * first use and kept for the process lifetime.
78
+ */
79
+ export const resetResolvedEnvCache = () => {
42
80
  cached = undefined;
43
81
  };
44
82
  const resolve = () => {
@@ -62,6 +100,9 @@ export const buildArgs = (options, binaryPath) => {
62
100
  args.push(name, value);
63
101
  }
64
102
  };
103
+ // Default ON: the translator's block-dedup relies on --verbose emitting
104
+ // cumulative assistant content. Consumers must explicitly pass `false`
105
+ // to opt out (`undefined` still yields --verbose).
65
106
  flag(options.verbose !== false, "--verbose");
66
107
  kv(options.model, "--model");
67
108
  kv(options.systemPrompt, "--system-prompt");
@@ -103,36 +144,56 @@ export const buildArgs = (options, binaryPath) => {
103
144
  flag(options.disableSlashCommands, "--disable-slash-commands");
104
145
  return args;
105
146
  };
147
+ // Priority (lowest → highest): baseEnv < alias-detected config <
148
+ // user's explicit `options.env` < explicit `options.configDir`. User
149
+ // input always outranks the alias heuristic. Returns undefined when no
150
+ // override is needed, so spawnProcess can pass the parent env through.
151
+ export const buildSpawnEnv = (baseEnv, aliasConfigDir, options) => {
152
+ const needsEnv = aliasConfigDir || options.configDir || options.env;
153
+ if (!needsEnv) {
154
+ return undefined;
155
+ }
156
+ const spawnEnv = { ...baseEnv };
157
+ if (aliasConfigDir) {
158
+ spawnEnv.CLAUDE_CONFIG_DIR = aliasConfigDir;
159
+ }
160
+ if (options.env) {
161
+ Object.assign(spawnEnv, options.env);
162
+ }
163
+ if (options.configDir) {
164
+ spawnEnv.CLAUDE_CONFIG_DIR = options.configDir;
165
+ }
166
+ return spawnEnv;
167
+ };
106
168
  export const spawnClaude = (options) => {
107
169
  assertPositiveNumber(options.maxBudgetUsd, "maxBudgetUsd");
108
170
  const resolved = resolve();
109
171
  const args = buildArgs(options, resolved.binaryPath);
110
172
  try {
111
- const needsEnv = resolved.aliasConfigDir || options.configDir || options.env;
112
- let spawnEnv;
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.
117
- spawnEnv = { ...process.env };
118
- if (resolved.aliasConfigDir) {
119
- spawnEnv.CLAUDE_CONFIG_DIR = resolved.aliasConfigDir;
120
- }
121
- if (options.env) {
122
- Object.assign(spawnEnv, options.env);
123
- }
124
- if (options.configDir) {
125
- spawnEnv.CLAUDE_CONFIG_DIR = options.configDir;
126
- }
127
- }
173
+ const spawnEnv = buildSpawnEnv(process.env, resolved.aliasConfigDir, options);
128
174
  const rawProc = spawnProcess(args, { cwd: options.cwd, env: spawnEnv });
129
175
  rawProc.exited.catch(() => { });
176
+ // Tear the child down when the caller's signal aborts. Without this,
177
+ // a signal that fires BEFORE stdout emits anything leaves the reader
178
+ // loop to eventually notice -- the child keeps running in the meantime.
179
+ // Register FIRST, then re-check `aborted`: closes the gap where abort
180
+ // could fire between the check and listener attach. `once: true` lets
181
+ // the listener be GC'd after firing.
182
+ if (options.signal) {
183
+ const onAbort = () => {
184
+ safeKill(rawProc);
185
+ };
186
+ options.signal.addEventListener("abort", onAbort, { once: true });
187
+ if (options.signal.aborted) {
188
+ safeKill(rawProc);
189
+ }
190
+ }
130
191
  const claudeProc = {
131
192
  write: (msg) => {
132
193
  rawProc.stdin.write(msg);
133
194
  },
134
- kill: () => {
135
- rawProc.kill();
195
+ kill: (signal) => {
196
+ rawProc.kill(signal);
136
197
  },
137
198
  exited: rawProc.exited,
138
199
  stdout: rawProc.stdout,
package/dist/reader.d.ts CHANGED
@@ -2,16 +2,19 @@ import type { ITranslator } from "./parser/translator.js";
2
2
  import type { IClaudeProcess } from "./process.js";
3
3
  import type { IToolHandlerInstance } from "./tools/handler.js";
4
4
  import type { TRelayEvent } from "./types/events.js";
5
+ import type { TWarn } from "./warnings.js";
5
6
  export interface IReaderOptions {
6
7
  reader: ReadableStreamDefaultReader<Uint8Array>;
7
8
  translator: ITranslator;
8
9
  toolHandler?: IToolHandlerInstance;
9
10
  proc?: IClaudeProcess;
10
11
  signal?: AbortSignal;
12
+ onWarning?: TWarn;
11
13
  }
12
14
  export interface IStderrDrain {
13
15
  chunks: string[];
14
16
  done: Promise<void>;
17
+ text: () => string;
15
18
  }
16
19
  export declare const drainStderr: (proc: {
17
20
  stderr: ReadableStream<Uint8Array>;
package/dist/reader.js CHANGED
@@ -2,6 +2,7 @@ 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 { safeKill, safeWrite } from "./process.js";
5
6
  import { writer } from "./writer.js";
6
7
  export const drainStderr = (proc) => {
7
8
  const chunks = [];
@@ -29,7 +30,11 @@ export const drainStderr = (proc) => {
29
30
  stderrReader.releaseLock();
30
31
  }
31
32
  })().catch(() => { });
32
- return { chunks, done };
33
+ return {
34
+ chunks,
35
+ done,
36
+ text: () => chunks.join("").trim(),
37
+ };
33
38
  };
34
39
  export async function* readNdjsonEvents(opts) {
35
40
  const { reader, translator, signal } = opts;
@@ -43,13 +48,29 @@ export async function* readNdjsonEvents(opts) {
43
48
  });
44
49
  // Swallow unhandled rejection if nothing ever races against this promise.
45
50
  abortPromise.catch(() => { });
46
- // Single resettable timeout shared across all iterations avoids leaking
51
+ // Single resettable timeout shared across all iterations -- avoids leaking
47
52
  // a new Promise + setTimeout per read loop.
48
53
  let timeoutReject;
49
54
  const timeoutPromise = new Promise((_, reject) => {
50
55
  timeoutReject = reject;
51
56
  });
52
57
  timeoutPromise.catch(() => { });
58
+ // Shared per-raw-event dispatch. Used by both the main read loop and the
59
+ // trailing-buffer flush so the translate → tool-dispatch → yield sequence
60
+ // lives in one place. `!turnComplete` guards dispatch so we don't approve
61
+ // or deny a tool call the CLI emits after it already said it's done.
62
+ const processRaw = async function* (raw) {
63
+ const translated = translator.translate(raw);
64
+ for (const event of translated) {
65
+ if (event.type === "tool_use" && !turnComplete && opts.toolHandler && opts.proc) {
66
+ await dispatchToolDecision(opts.proc, opts.toolHandler, event, opts.onWarning);
67
+ }
68
+ yield event;
69
+ if (event.type === "turn_complete") {
70
+ turnComplete = true;
71
+ }
72
+ }
73
+ };
53
74
  const resetReadTimeout = () => {
54
75
  if (timeoutId) {
55
76
  clearTimeout(timeoutId);
@@ -62,13 +83,8 @@ export async function* readNdjsonEvents(opts) {
62
83
  ? () => {
63
84
  abortReject?.(new AbortError());
64
85
  if (opts.proc) {
65
- try {
66
- opts.proc.write(writer.abort());
67
- }
68
- catch {
69
- // stdin closed
70
- }
71
- opts.proc.kill();
86
+ safeWrite(opts.proc, writer.abort());
87
+ safeKill(opts.proc);
72
88
  }
73
89
  }
74
90
  : undefined;
@@ -90,8 +106,12 @@ export async function* readNdjsonEvents(opts) {
90
106
  break;
91
107
  }
92
108
  buffer += decoder.decode(value, { stream: true });
109
+ // The limit applies to the accumulated buffer (which contains at most
110
+ // one in-progress line plus any already-split lines being held), so
111
+ // a single oversize line trips the same guard. Name is legacy -- the
112
+ // check is effectively "no NDJSON message may grow past this size".
93
113
  if (buffer.length > LIMITS.ndjsonMaxLineChars) {
94
- throw new ClaudeError(`NDJSON buffer exceeded ${LIMITS.ndjsonMaxLineChars} chars`);
114
+ throw new ClaudeError(`NDJSON buffer exceeded ${LIMITS.ndjsonMaxLineChars} chars (single line or accumulated pending lines)`);
95
115
  }
96
116
  const lines = buffer.split("\n");
97
117
  buffer = lines.pop() ?? "";
@@ -100,16 +120,7 @@ export async function* readNdjsonEvents(opts) {
100
120
  if (!raw) {
101
121
  continue;
102
122
  }
103
- const events = translator.translate(raw);
104
- for (const event of events) {
105
- if (event.type === "tool_use" && opts.toolHandler && opts.proc) {
106
- await dispatchToolDecision(opts.proc, opts.toolHandler, event);
107
- }
108
- yield event;
109
- if (event.type === "turn_complete") {
110
- turnComplete = true;
111
- }
112
- }
123
+ yield* processRaw(raw);
113
124
  }
114
125
  if (turnComplete) {
115
126
  break;
@@ -118,16 +129,7 @@ export async function* readNdjsonEvents(opts) {
118
129
  if (buffer.trim()) {
119
130
  const raw = parseLine(buffer);
120
131
  if (raw) {
121
- const events = translator.translate(raw);
122
- for (const event of events) {
123
- if (event.type === "tool_use" && opts.toolHandler && opts.proc && !turnComplete) {
124
- await dispatchToolDecision(opts.proc, opts.toolHandler, event);
125
- }
126
- yield event;
127
- if (event.type === "turn_complete") {
128
- turnComplete = true;
129
- }
130
- }
132
+ yield* processRaw(raw);
131
133
  }
132
134
  }
133
135
  }
package/dist/runtime.d.ts CHANGED
@@ -1,18 +1,19 @@
1
- export interface IRawProcess {
1
+ interface IRawProcess {
2
2
  stdin: {
3
3
  write: (data: string) => void;
4
4
  end: () => void;
5
5
  };
6
6
  stdout: ReadableStream<Uint8Array>;
7
7
  stderr: ReadableStream<Uint8Array>;
8
- kill: () => void;
8
+ kill: (signal?: NodeJS.Signals | number) => void;
9
9
  exited: Promise<number>;
10
10
  pid: number;
11
11
  }
12
- export interface ISpawnOpts {
12
+ interface ISpawnOpts {
13
13
  cwd?: string;
14
14
  env?: Record<string, string | undefined>;
15
15
  }
16
16
  export declare const spawnProcess: (args: string[], opts: ISpawnOpts) => IRawProcess;
17
17
  export declare const whichSync: (name: string) => string | undefined;
18
- export declare const fileExists: (path: string) => boolean;
18
+ export declare const isExecutableNonEmpty: (path: string) => boolean;
19
+ export {};
package/dist/runtime.js CHANGED
@@ -30,7 +30,10 @@ export const whichSync = (name) => {
30
30
  return undefined;
31
31
  }
32
32
  };
33
- export const fileExists = (path) => {
33
+ // Used to vet candidate `claude` binary paths -- a zero-byte stub or a
34
+ // non-executable regular file both count as "not a usable binary" here.
35
+ // Name reflects behavior: this is NOT a generic fs.exists check.
36
+ export const isExecutableNonEmpty = (path) => {
34
37
  try {
35
38
  accessSync(path, constants.X_OK);
36
39
  return statSync(path).size > 0;
@@ -58,8 +61,8 @@ const spawnBun = (args, opts) => {
58
61
  },
59
62
  stdout: proc.stdout,
60
63
  stderr: proc.stderr,
61
- kill: () => {
62
- proc.kill();
64
+ kill: (signal) => {
65
+ proc.kill(signal);
63
66
  },
64
67
  exited: proc.exited,
65
68
  pid: proc.pid,
@@ -99,8 +102,8 @@ const spawnNode = (args, opts) => {
99
102
  },
100
103
  stdout: child.stdout ? toWeb(child.stdout) : new ReadableStream(),
101
104
  stderr: child.stderr ? toWeb(child.stderr) : new ReadableStream(),
102
- kill: () => {
103
- child.kill();
105
+ kill: (signal) => {
106
+ child.kill(signal);
104
107
  },
105
108
  exited,
106
109
  pid: child.pid,
package/dist/session.d.ts CHANGED
@@ -1,8 +1,37 @@
1
- import type { ISessionOptions } from "./types/options.js";
1
+ import type { IAskOptions, ISessionOptions } from "./types/options.js";
2
2
  import type { TAskResult } from "./types/results.js";
3
3
  export interface IClaudeSession extends AsyncDisposable {
4
- ask: (prompt: string) => Promise<TAskResult>;
4
+ ask: (prompt: string, options?: IAskOptions) => Promise<TAskResult>;
5
+ askJson: <T>(prompt: string, schema: import("./json.js").TSchemaInput<T>, options?: IAskOptions) => Promise<import("./json.js").IJsonResult<T>>;
5
6
  close: () => Promise<void>;
6
7
  sessionId: string | undefined;
7
8
  }
9
+ /**
10
+ * Creates a multi-turn Claude session backed by a single long-lived CLI
11
+ * process. Each `ask()` sends a user prompt and resolves with `TAskResult`
12
+ * for that turn. Calls are serialized -- a second `ask()` waits for the
13
+ * first to complete. Use `close()` (or `await using`) to free the process.
14
+ *
15
+ * ### Retry behavior
16
+ * Each `ask()` automatically retries transient failures -- process crashes
17
+ * matching SIGKILL/SIGTERM/SIGPIPE exit codes, `ECONNRESET`, `ECONNREFUSED`,
18
+ * `ETIMEDOUT`, `EHOSTUNREACH`, `ENETUNREACH`, `EAI_AGAIN`, Anthropic
19
+ * `overloaded_error` / 529s, broken-pipe / "socket hang up" messages, etc.
20
+ * (see `isTransientError`). Backoff is `500ms → 1s → 2s`; the budget is
21
+ * `LIMITS.maxRespawnAttempts` (currently 3) and is shared across a single
22
+ * `ask()`. When the budget is exhausted the session throws
23
+ * `KnownError("retry-exhausted")` and marks itself closed.
24
+ *
25
+ * Fatal errors -- `KnownError` and `BudgetExceededError` -- also close the
26
+ * session. Any subsequent `ask()` on a closed session rejects with
27
+ * `ClaudeError("Session is closed")`. All other errors (abort, timeout,
28
+ * non-transient `ProcessError`) propagate without closing, and the caller
29
+ * may decide whether to retry at a higher level.
30
+ *
31
+ * ### Observability
32
+ * - `onCostUpdate(snapshot)` -- fires after every `turn_complete`.
33
+ * - `onRetry(attempt, error)` -- fires each time a transient failure triggers
34
+ * a respawn inside one `ask()`. Attempt is 1-indexed.
35
+ * - `onWarning(message, cause)` -- routes all library-emitted warnings.
36
+ */
8
37
  export declare const createSession: (options?: ISessionOptions) => IClaudeSession;