@pivanov/claude-wire 0.0.3 → 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/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
18
  const toolHandler = options.tools ? createToolHandler(options.tools) : 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
  };
@@ -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,8 +14,8 @@ 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
+ allowedTools?: TToolName[];
18
+ disallowedTools?: TToolName[];
17
19
  tools?: IToolHandler;
18
20
  /**
19
21
  * SDK-side budget limit, evaluated after each turn. Throws `BudgetExceededError`
@@ -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
  }
@@ -3,7 +3,7 @@ export type TModelUsageEntry = {
3
3
  outputTokens: number;
4
4
  cacheReadInputTokens?: number;
5
5
  cacheCreationInputTokens?: number;
6
- contextWindow: number;
6
+ contextWindow?: number;
7
7
  };
8
8
  export type TClaudeContentType = "text" | "thinking" | "tool_use" | "tool_result" | (string & {});
9
9
  export type TClaudeContent = {
@@ -32,11 +32,7 @@ export type TClaudeEvent = {
32
32
  model?: string;
33
33
  tools?: string[];
34
34
  duration_ms?: number;
35
- duration_api_ms?: number;
36
- cost_usd?: number;
37
35
  total_cost_usd?: number;
38
36
  is_error?: boolean;
39
- num_turns?: number;
40
37
  modelUsage?: Record<string, TModelUsageEntry>;
41
- usage?: unknown;
42
38
  };
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Rejects non-finite values and negatives. Zero is intentionally allowed
3
+ * so callers can express "disallow any spend" (useful in tests).
4
+ */
5
+ export declare const assertPositiveNumber: (value: number | undefined, name: string) => void;
6
+ /**
7
+ * Rejects empty strings. Used on writer payloads where an empty value
8
+ * would produce a malformed JSON line on the CLI's stdin.
9
+ */
10
+ export declare const requireNonEmpty: (value: string, name: string) => void;
@@ -0,0 +1,23 @@
1
+ import { ClaudeError } from "./errors.js";
2
+ // Boundary validators -- throw ClaudeError with a stable message shape so
3
+ // callers (SDK tests, consumer apps) can pattern-match on field name.
4
+ // Both helpers are cheap; performance-sensitive paths should still prefer
5
+ // type-level guards, but runtime checks catch undeclared-undefined drift.
6
+ /**
7
+ * Rejects non-finite values and negatives. Zero is intentionally allowed
8
+ * so callers can express "disallow any spend" (useful in tests).
9
+ */
10
+ export const assertPositiveNumber = (value, name) => {
11
+ if (value !== undefined && (!Number.isFinite(value) || value < 0)) {
12
+ throw new ClaudeError(`${name} must be a finite non-negative number`);
13
+ }
14
+ };
15
+ /**
16
+ * Rejects empty strings. Used on writer payloads where an empty value
17
+ * would produce a malformed JSON line on the CLI's stdin.
18
+ */
19
+ export const requireNonEmpty = (value, name) => {
20
+ if (!value) {
21
+ throw new ClaudeError(`${name} must be a non-empty string`);
22
+ }
23
+ };
@@ -0,0 +1,2 @@
1
+ export type TWarn = (message: string, cause?: unknown) => void;
2
+ export declare const createWarn: (onWarning?: TWarn) => TWarn;
@@ -0,0 +1,24 @@
1
+ // One-line library-warning emitter. Consumers set `onWarning` on
2
+ // IClaudeOptions to route warnings anywhere; when unset we fall back to
3
+ // `console.warn` so behavior is unchanged for casual users.
4
+ const DEFAULT = (message, cause) => {
5
+ if (cause === undefined) {
6
+ console.warn(`[claude-wire] ${message}`);
7
+ }
8
+ else {
9
+ console.warn(`[claude-wire] ${message}`, cause);
10
+ }
11
+ };
12
+ export const createWarn = (onWarning) => {
13
+ return onWarning
14
+ ? (message, cause) => {
15
+ try {
16
+ onWarning(message, cause);
17
+ }
18
+ catch {
19
+ // A user hook that itself throws shouldn't take down the stream.
20
+ DEFAULT(message, cause);
21
+ }
22
+ }
23
+ : DEFAULT;
24
+ };
package/dist/writer.d.ts CHANGED
@@ -2,6 +2,15 @@ export declare const writer: {
2
2
  user: (content: string) => string;
3
3
  approve: (toolUseId: string) => string;
4
4
  deny: (toolUseId: string) => string;
5
- toolResult: (toolUseId: string, content: string) => string;
5
+ /**
6
+ * Send a tool result in response to a `tool_use` event. Pass
7
+ * `{ isError: true }` to mark the result as a tool-side error -- the model
8
+ * will see it as an error and can react (retry, apologize, pick another
9
+ * tool) rather than treating it as success. The protocol supports the
10
+ * flag natively; without it, results are assumed successful.
11
+ */
12
+ toolResult: (toolUseId: string, content: string, options?: {
13
+ isError?: boolean;
14
+ }) => string;
6
15
  abort: () => string;
7
16
  };
package/dist/writer.js CHANGED
@@ -1,10 +1,5 @@
1
- import { ClaudeError } from "./errors.js";
1
+ import { requireNonEmpty } from "./validation.js";
2
2
  const ABORT_LINE = `${JSON.stringify({ type: "abort" })}\n`;
3
- const requireNonEmpty = (value, name) => {
4
- if (!value) {
5
- throw new ClaudeError(`${name} must be a non-empty string`);
6
- }
7
- };
8
3
  export const writer = {
9
4
  user: (content) => {
10
5
  requireNonEmpty(content, "content");
@@ -18,9 +13,20 @@ export const writer = {
18
13
  requireNonEmpty(toolUseId, "toolUseId");
19
14
  return `${JSON.stringify({ type: "deny", tool_use_id: toolUseId })}\n`;
20
15
  },
21
- toolResult: (toolUseId, content) => {
16
+ /**
17
+ * Send a tool result in response to a `tool_use` event. Pass
18
+ * `{ isError: true }` to mark the result as a tool-side error -- the model
19
+ * will see it as an error and can react (retry, apologize, pick another
20
+ * tool) rather than treating it as success. The protocol supports the
21
+ * flag natively; without it, results are assumed successful.
22
+ */
23
+ toolResult: (toolUseId, content, options) => {
22
24
  requireNonEmpty(toolUseId, "toolUseId");
23
- return `${JSON.stringify({ type: "tool_result", tool_use_id: toolUseId, content })}\n`;
25
+ const payload = { type: "tool_result", tool_use_id: toolUseId, content };
26
+ if (options?.isError) {
27
+ payload.is_error = true;
28
+ }
29
+ return `${JSON.stringify(payload)}\n`;
24
30
  },
25
31
  abort: () => ABORT_LINE,
26
32
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pivanov/claude-wire",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "description": "Run Claude Code programmatically. Typed SDK for spawning, streaming, and controlling the CLI.",
5
5
  "type": "module",
6
6
  "engines": {