@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/README.md CHANGED
@@ -20,12 +20,14 @@ console.log(result.costUsd); // 0.0084
20
20
  ## Features
21
21
 
22
22
  - **Simple API** - `claude.ask()` returns a typed result, `claude.stream()` yields events
23
+ - **Structured JSON** - `claude.askJson(prompt, schema)` with Standard Schema (Zod/Valibot/ArkType) validation
23
24
  - **Tool control** - allow, block, or intercept any tool at runtime
24
25
  - **Multi-turn sessions** - persistent process across multiple prompts
25
- - **Cost tracking** - per-request budgets with auto-abort
26
+ - **Cost tracking** - per-request budgets with auto-abort and projection primitives
27
+ - **Typed errors** - rate-limit, overload, context-length, retry-exhausted as `KnownError` codes
26
28
  - **Fully typed** - discriminated union events, full IntelliSense
27
- - **Resilient** - auto-respawn, transient error detection, AbortSignal
28
- - **Zero dependencies** - 16.2 kB gzipped
29
+ - **Resilient** - auto-respawn with backoff, transient error detection, AbortSignal
30
+ - **Zero dependencies** - ~29 kB gzipped
29
31
 
30
32
  ## Install
31
33
 
@@ -37,6 +39,8 @@ npm install @pivanov/claude-wire
37
39
 
38
40
  Requires [Claude Code CLI](https://claude.ai/download) installed and authenticated. Runs on [Bun](https://bun.sh) >= 1.0 or Node.js >= 22.
39
41
 
42
+ > **Platform:** POSIX only (macOS, Linux, WSL). Native Windows isn't supported yet -- binary resolution relies on `which` and POSIX path conventions.
43
+
40
44
  > This SDK wraps Claude Code's `--output-format stream-json` protocol, which is not officially documented by Anthropic and may change between releases.
41
45
 
42
46
  ## Documentation
@@ -65,7 +69,7 @@ apps/examples/ interactive example runner
65
69
 
66
70
  ```bash
67
71
  bun install
68
- bun run test # 147 tests
72
+ bun run test # 217 tests
69
73
  bun run typecheck
70
74
  bun run lint
71
75
  bun run docs:dev # local docs server
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Races `promise` against a timeout. When the timer fires first, resolves
3
+ * with `onTimeout()` (or `undefined` if omitted). The caller decides
4
+ * whether that's an acceptable fallback or a signal to kill/retry.
5
+ *
6
+ * Intentionally does NOT reject on timeout -- callers usually have a
7
+ * specific action to take (kill the process, bail early with undefined)
8
+ * that a thrown error would force into a catch branch for no reason.
9
+ */
10
+ export declare const withTimeout: <T, F = undefined>(promise: Promise<T>, ms: number, onTimeout?: () => F) => Promise<T | F>;
package/dist/async.js ADDED
@@ -0,0 +1,27 @@
1
+ // Small async helpers shared between session.ts and stream.ts. Kept in
2
+ // their own file because both consumers care about the *shape* of the
3
+ // timeout pattern, not its underlying mechanics -- drop a single
4
+ // withTimeout() next to whatever else needs it rather than carrying the
5
+ // Promise.race idiom inline at every site.
6
+ /**
7
+ * Races `promise` against a timeout. When the timer fires first, resolves
8
+ * with `onTimeout()` (or `undefined` if omitted). The caller decides
9
+ * whether that's an acceptable fallback or a signal to kill/retry.
10
+ *
11
+ * Intentionally does NOT reject on timeout -- callers usually have a
12
+ * specific action to take (kill the process, bail early with undefined)
13
+ * that a thrown error would force into a catch branch for no reason.
14
+ */
15
+ export const withTimeout = (promise, ms, onTimeout) => {
16
+ let timer;
17
+ const timeout = new Promise((resolve) => {
18
+ timer = setTimeout(() => {
19
+ resolve(onTimeout ? onTimeout() : undefined);
20
+ }, ms);
21
+ });
22
+ return Promise.race([promise, timeout]).finally(() => {
23
+ if (timer) {
24
+ clearTimeout(timer);
25
+ }
26
+ });
27
+ };
package/dist/client.d.ts CHANGED
@@ -1,9 +1,11 @@
1
+ import { type IJsonResult, type TSchemaInput } from "./json.js";
1
2
  import type { IClaudeSession } from "./session.js";
2
3
  import type { IClaudeStream } from "./stream.js";
3
4
  import type { IClaudeOptions, ISessionOptions } from "./types/options.js";
4
5
  import type { TAskResult } from "./types/results.js";
5
6
  export interface IClaudeClient {
6
7
  ask: (prompt: string, options?: IClaudeOptions) => Promise<TAskResult>;
8
+ askJson: <T>(prompt: string, schema: TSchemaInput<T>, options?: IClaudeOptions) => Promise<IJsonResult<T>>;
7
9
  stream: (prompt: string, options?: IClaudeOptions) => IClaudeStream;
8
10
  session: (options?: ISessionOptions) => IClaudeSession;
9
11
  create: (defaults: IClaudeOptions) => IClaudeClient;
package/dist/client.js CHANGED
@@ -1,9 +1,10 @@
1
+ import { isStandardSchema, parseAndValidate } from "./json.js";
1
2
  import { createSession } from "./session.js";
2
3
  import { createStream } from "./stream.js";
3
4
  const mergeOptions = (defaults, overrides) => {
4
5
  const merged = { ...defaults, ...overrides };
5
- if (overrides && "tools" in overrides) {
6
- merged.tools = overrides.tools ? { ...defaults.tools, ...overrides.tools } : overrides.tools;
6
+ if (overrides && "toolHandler" in overrides) {
7
+ merged.toolHandler = overrides.toolHandler ? { ...defaults.toolHandler, ...overrides.toolHandler } : overrides.toolHandler;
7
8
  }
8
9
  if (overrides && "env" in overrides) {
9
10
  merged.env = overrides.env ? { ...defaults.env, ...overrides.env } : overrides.env;
@@ -16,6 +17,23 @@ export const createClient = (defaults = {}) => {
16
17
  const stream = createStream(prompt, merged);
17
18
  return stream.result();
18
19
  };
20
+ const askJson = async (prompt, schema, options) => {
21
+ const merged = mergeOptions(defaults, options);
22
+ // Forward the raw JSON Schema string to the CLI via --json-schema when
23
+ // the caller passes a string. Standard Schema objects are validated
24
+ // SDK-side after the response arrives.
25
+ if (typeof schema === "string") {
26
+ merged.jsonSchema = schema;
27
+ }
28
+ else if (isStandardSchema(schema)) {
29
+ // Extract JSON Schema representation if available for CLI-side
30
+ // constraint. Many Standard Schema libs expose this via toJsonSchema()
31
+ // but it's not part of the protocol. We validate SDK-side regardless.
32
+ }
33
+ const raw = await ask(prompt, merged);
34
+ const data = parseAndValidate(raw.text, schema);
35
+ return { data, raw };
36
+ };
19
37
  const stream = (prompt, options) => {
20
38
  const merged = mergeOptions(defaults, options);
21
39
  return createStream(prompt, merged);
@@ -28,5 +46,5 @@ export const createClient = (defaults = {}) => {
28
46
  const merged = mergeOptions(defaults, newDefaults);
29
47
  return createClient(merged);
30
48
  };
31
- return { ask, stream, session, create };
49
+ return { ask, askJson, stream, session, create };
32
50
  };
@@ -1,6 +1,7 @@
1
1
  export declare const TIMEOUTS: {
2
2
  readonly defaultAbortMs: 300000;
3
3
  readonly gracefulExitMs: 5000;
4
+ readonly stderrDrainGraceMs: 500;
4
5
  };
5
6
  export declare const LIMITS: {
6
7
  readonly maxRespawnAttempts: 3;
@@ -9,6 +10,7 @@ export declare const LIMITS: {
9
10
  readonly fingerprintTextLen: 64;
10
11
  };
11
12
  export declare const RESPAWN_BACKOFF_MS: readonly [500, 1000, 2000];
13
+ export declare const MAX_BACKOFF_INDEX: 3;
12
14
  export declare const BINARY: {
13
15
  readonly name: "claude";
14
16
  readonly commonPaths: readonly [`${string}/.local/bin/claude`, `${string}/.claude/bin/claude`, "/usr/local/bin/claude", "/opt/homebrew/bin/claude"];
package/dist/constants.js CHANGED
@@ -2,6 +2,9 @@ import { homedir } from "node:os";
2
2
  export const TIMEOUTS = {
3
3
  defaultAbortMs: 300_000,
4
4
  gracefulExitMs: 5_000,
5
+ // Grace period for stderr drain to catch up before an error is thrown so
6
+ // the error message carries the CLI's actual complaint instead of "".
7
+ stderrDrainGraceMs: 500,
5
8
  };
6
9
  export const LIMITS = {
7
10
  maxRespawnAttempts: 3,
@@ -11,6 +14,10 @@ export const LIMITS = {
11
14
  };
12
15
  // Respawn backoff in ms, indexed by consecutiveCrashes (1st=500ms, 2nd=1s, 3rd=2s).
13
16
  export const RESPAWN_BACKOFF_MS = [500, 1000, 2000];
17
+ // Highest index into RESPAWN_BACKOFF_MS[]. Used by respawnBackoff() to
18
+ // clamp the delay lookup to the last defined backoff when crashes exceed
19
+ // the table length -- keeps the table and its bound co-located.
20
+ export const MAX_BACKOFF_INDEX = RESPAWN_BACKOFF_MS.length;
14
21
  const home = homedir();
15
22
  export const BINARY = {
16
23
  name: "claude",
package/dist/cost.d.ts CHANGED
@@ -1,12 +1,20 @@
1
1
  import type { TCostSnapshot } from "./types/results.js";
2
+ import type { TWarn } from "./warnings.js";
3
+ export interface ICostProjection {
4
+ projectedUsd: number;
5
+ }
2
6
  export interface ICostTracker {
3
7
  update: (totalCostUsd: number, totalInputTokens: number, totalOutputTokens: number) => void;
4
8
  snapshot: () => TCostSnapshot;
5
9
  checkBudget: () => void;
6
10
  reset: () => void;
11
+ turnCount: number;
12
+ averagePerTurn: number;
13
+ project: (remainingTurns: number) => ICostProjection;
7
14
  }
8
15
  export interface ICostTrackerOptions {
9
16
  maxCostUsd?: number;
10
17
  onCostUpdate?: (cost: TCostSnapshot) => void;
18
+ onWarning?: TWarn;
11
19
  }
12
20
  export declare const createCostTracker: (options?: ICostTrackerOptions) => ICostTracker;
package/dist/cost.js CHANGED
@@ -1,24 +1,28 @@
1
- import { assertPositiveNumber, BudgetExceededError } from "./errors.js";
1
+ import { BudgetExceededError } from "./errors.js";
2
+ import { assertPositiveNumber } from "./validation.js";
3
+ import { createWarn } from "./warnings.js";
2
4
  export const createCostTracker = (options = {}) => {
3
5
  assertPositiveNumber(options.maxCostUsd, "maxCostUsd");
6
+ const warn = createWarn(options.onWarning);
4
7
  let totalUsd = 0;
5
- let inputTokens = 0;
6
- let outputTokens = 0;
8
+ let input = 0;
9
+ let output = 0;
10
+ let turns = 0;
7
11
  const snapshot = () => ({
8
12
  totalUsd,
9
- inputTokens,
10
- outputTokens,
13
+ tokens: { input, output },
11
14
  });
12
15
  const update = (totalCostUsd, totalInputToks, totalOutputToks) => {
13
16
  totalUsd = totalCostUsd;
14
- inputTokens = totalInputToks;
15
- outputTokens = totalOutputToks;
17
+ input = totalInputToks;
18
+ output = totalOutputToks;
19
+ turns++;
16
20
  if (options.onCostUpdate) {
17
21
  try {
18
22
  options.onCostUpdate(snapshot());
19
23
  }
20
24
  catch (error) {
21
- console.warn("[claude-wire] onCostUpdate callback threw:", error instanceof Error ? error.message : error);
25
+ warn("onCostUpdate callback threw", error);
22
26
  }
23
27
  }
24
28
  };
@@ -29,8 +33,25 @@ export const createCostTracker = (options = {}) => {
29
33
  };
30
34
  const reset = () => {
31
35
  totalUsd = 0;
32
- inputTokens = 0;
33
- outputTokens = 0;
36
+ input = 0;
37
+ output = 0;
38
+ turns = 0;
39
+ };
40
+ const project = (remainingTurns) => {
41
+ const avg = turns > 0 ? totalUsd / turns : 0;
42
+ return { projectedUsd: totalUsd + avg * remainingTurns };
43
+ };
44
+ return {
45
+ update,
46
+ snapshot,
47
+ checkBudget,
48
+ reset,
49
+ get turnCount() {
50
+ return turns;
51
+ },
52
+ get averagePerTurn() {
53
+ return turns > 0 ? totalUsd / turns : 0;
54
+ },
55
+ project,
34
56
  };
35
- return { update, snapshot, checkBudget, reset };
36
57
  };
package/dist/errors.d.ts CHANGED
@@ -16,7 +16,7 @@ export declare class ProcessError extends ClaudeError {
16
16
  readonly exitCode?: number | undefined;
17
17
  constructor(message: string, exitCode?: number | undefined);
18
18
  }
19
- export declare const KNOWN_ERROR_CODES: readonly ["not-authenticated", "binary-not-found", "session-expired", "permission-denied", "invalid-model"];
19
+ export declare const KNOWN_ERROR_CODES: readonly ["not-authenticated", "binary-not-found", "permission-denied", "retry-exhausted", "rate-limit", "overloaded", "context-length-exceeded", "invalid-json-schema", "mcp-error"];
20
20
  export type TKnownErrorCode = (typeof KNOWN_ERROR_CODES)[number];
21
21
  export declare class KnownError extends ClaudeError {
22
22
  readonly code: TKnownErrorCode;
@@ -25,4 +25,7 @@ export declare class KnownError extends ClaudeError {
25
25
  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
- export declare const assertPositiveNumber: (value: number | undefined, name: string) => void;
28
+ declare let stderrClassifier: ((stderr: string, exitCode?: number) => TKnownErrorCode | undefined) | undefined;
29
+ export declare const setStderrClassifier: (fn: typeof stderrClassifier) => void;
30
+ export declare const processExitedEarly: (stderr: string, exitCode?: number) => ProcessError | KnownError;
31
+ export {};
package/dist/errors.js CHANGED
@@ -34,7 +34,21 @@ export class ProcessError extends ClaudeError {
34
34
  this.name = "ProcessError";
35
35
  }
36
36
  }
37
- export const KNOWN_ERROR_CODES = ["not-authenticated", "binary-not-found", "session-expired", "permission-denied", "invalid-model"];
37
+ // Only codes the SDK actually constructs are listed. Add a new code here
38
+ // alongside the throw site that needs it -- aspirational entries give
39
+ // consumers false confidence that they can pattern-match on them.
40
+ export const KNOWN_ERROR_CODES = [
41
+ "not-authenticated",
42
+ "binary-not-found",
43
+ "permission-denied",
44
+ "retry-exhausted",
45
+ // Classified from stderr by classifyStderr (src/stderr.ts):
46
+ "rate-limit",
47
+ "overloaded",
48
+ "context-length-exceeded",
49
+ "invalid-json-schema",
50
+ "mcp-error",
51
+ ];
38
52
  export class KnownError extends ClaudeError {
39
53
  code;
40
54
  constructor(code, message) {
@@ -46,7 +60,11 @@ export class KnownError extends ClaudeError {
46
60
  export const isKnownError = (error) => {
47
61
  return error instanceof KnownError;
48
62
  };
49
- const TRANSIENT_PATTERN = /fetch failed|ECONNREFUSED|ETIMEDOUT|ECONNRESET|ECONNABORTED|ENETUNREACH|EAI_AGAIN|network error|network timeout|EPIPE|SIGPIPE|broken pipe/i;
63
+ // Network-level transients (ECONNRESET/REFUSED/ABORTED, ENETUNREACH, EHOSTUNREACH),
64
+ // DNS transients (EAI_AGAIN), pipe resets (EPIPE/SIGPIPE, broken pipe), fetch
65
+ // errors, ad-hoc "socket hang up" messages from node, and Anthropic
66
+ // overloaded_error which the CLI bubbles up verbatim for 529 responses.
67
+ const TRANSIENT_PATTERN = /fetch failed|ECONNREFUSED|ETIMEDOUT|ECONNRESET|ECONNABORTED|ENETUNREACH|EHOSTUNREACH|EAI_AGAIN|network error|network timeout|EPIPE|SIGPIPE|broken pipe|socket hang up|overloaded_error/i;
50
68
  // Exit codes we treat as transient: 137 = SIGKILL (OOM), 141 = SIGPIPE,
51
69
  // 143 = SIGTERM. Non-zero normal exits (e.g. 1) stay non-transient.
52
70
  const TRANSIENT_EXIT_CODES = new Set([137, 141, 143]);
@@ -63,9 +81,23 @@ export const isTransientError = (error) => {
63
81
  export const errorMessage = (error) => {
64
82
  return error instanceof Error ? error.message : String(error);
65
83
  };
66
- export const assertPositiveNumber = (value, name) => {
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`);
84
+ // Shared error factory for the "process died before emitting turn_complete"
85
+ // case. session.ts + stream.ts both need this; the string used to be
86
+ // duplicated verbatim, which drifted at least once. Prefix stderr when
87
+ // available because CLI error output is the most actionable signal.
88
+ // Auto-promotes to KnownError when stderr matches a classifiable pattern.
89
+ // The classifier is injected to avoid a circular import (stderr.ts imports
90
+ // types from this file). Wire it at boot via `setStderrClassifier`.
91
+ let stderrClassifier;
92
+ export const setStderrClassifier = (fn) => {
93
+ stderrClassifier = fn;
94
+ };
95
+ export const processExitedEarly = (stderr, exitCode) => {
96
+ if (stderr && stderrClassifier) {
97
+ const code = stderrClassifier(stderr, exitCode);
98
+ if (code) {
99
+ return new KnownError(code, stderr);
100
+ }
70
101
  }
102
+ return new ProcessError(stderr || "Process exited without completing the turn", exitCode);
71
103
  };
package/dist/index.d.ts CHANGED
@@ -1,28 +1,32 @@
1
1
  export type { IClaudeClient } from "./client.js";
2
2
  export { createClient } from "./client.js";
3
3
  export { BINARY, LIMITS, TIMEOUTS } from "./constants.js";
4
- export type { ICostTracker, ICostTrackerOptions } from "./cost.js";
4
+ export type { ICostProjection, ICostTracker, ICostTrackerOptions } from "./cost.js";
5
5
  export { createCostTracker } from "./cost.js";
6
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
+ export { AbortError, BudgetExceededError, ClaudeError, errorMessage, isKnownError, isTransientError, KNOWN_ERROR_CODES, KnownError, ProcessError, TimeoutError, } from "./errors.js";
8
+ export type { IJsonResult, IStandardSchema, TSchemaInput } from "./json.js";
9
+ export { JsonValidationError, parseAndValidate, stripFences } from "./json.js";
8
10
  export { blockFingerprint, extractContent, parseDoubleEncoded } from "./parser/content.js";
9
11
  export { parseLine } from "./parser/ndjson.js";
10
12
  export type { ITranslator } from "./parser/translator.js";
11
13
  export { createTranslator } from "./parser/translator.js";
12
14
  export type { IClaudeProcess, ISpawnOptions } from "./process.js";
13
- export { buildArgs, resetBinaryCache, spawnClaude } from "./process.js";
15
+ export { buildArgs, resetResolvedEnvCache, spawnClaude } from "./process.js";
14
16
  export type { IReaderOptions } from "./reader.js";
15
17
  export { readNdjsonEvents } from "./reader.js";
16
18
  export type { IClaudeSession } from "./session.js";
17
19
  export { createSession } from "./session.js";
20
+ export { classifyStderr } from "./stderr.js";
18
21
  export type { IClaudeStream } from "./stream.js";
19
22
  export { createStream } from "./stream.js";
20
23
  export type { IToolHandlerInstance, TToolDecision } from "./tools/handler.js";
21
24
  export { createToolHandler } from "./tools/handler.js";
22
- export { BUILT_IN_TOOLS, isBuiltInTool } from "./tools/registry.js";
25
+ export type { TBuiltInToolName } from "./tools/registry.js";
26
+ export { BUILT_IN_TOOL_NAMES, BUILT_IN_TOOLS, isBuiltInTool } from "./tools/registry.js";
23
27
  export type { TErrorEvent, TRelayEvent, TSessionMetaEvent, TTextEvent, TThinkingEvent, TToolResultEvent, TToolUseEvent, TTurnCompleteEvent, } from "./types/events.js";
24
- export type { IClaudeOptions, ISessionOptions, IToolHandler } from "./types/options.js";
28
+ export type { IAskOptions, IClaudeOptions, ISessionOptions, IToolHandler } from "./types/options.js";
25
29
  export type { TClaudeContent, TClaudeContentType, TClaudeEvent, TClaudeEventType, TClaudeMessage, TModelUsageEntry } from "./types/protocol.js";
26
- export type { TAskResult, TCostSnapshot } from "./types/results.js";
30
+ export type { TAskResult, TCostSnapshot, TTokens } from "./types/results.js";
27
31
  export { writer } from "./writer.js";
28
32
  export declare const claude: import("./client.js").IClaudeClient;
package/dist/index.js CHANGED
@@ -2,15 +2,22 @@ import { createClient } from "./client.js";
2
2
  export { createClient } from "./client.js";
3
3
  export { BINARY, LIMITS, TIMEOUTS } from "./constants.js";
4
4
  export { createCostTracker } from "./cost.js";
5
- export { AbortError, assertPositiveNumber, BudgetExceededError, ClaudeError, errorMessage, isKnownError, isTransientError, KNOWN_ERROR_CODES, KnownError, ProcessError, TimeoutError, } from "./errors.js";
5
+ export { AbortError, BudgetExceededError, ClaudeError, errorMessage, isKnownError, isTransientError, KNOWN_ERROR_CODES, KnownError, ProcessError, TimeoutError, } from "./errors.js";
6
+ export { JsonValidationError, parseAndValidate, stripFences } from "./json.js";
6
7
  export { blockFingerprint, extractContent, parseDoubleEncoded } from "./parser/content.js";
7
8
  export { parseLine } from "./parser/ndjson.js";
8
9
  export { createTranslator } from "./parser/translator.js";
9
- export { buildArgs, resetBinaryCache, spawnClaude } from "./process.js";
10
+ export { buildArgs, resetResolvedEnvCache, spawnClaude } from "./process.js";
10
11
  export { readNdjsonEvents } from "./reader.js";
11
12
  export { createSession } from "./session.js";
13
+ export { classifyStderr } from "./stderr.js";
12
14
  export { createStream } from "./stream.js";
13
15
  export { createToolHandler } from "./tools/handler.js";
14
- export { BUILT_IN_TOOLS, isBuiltInTool } from "./tools/registry.js";
16
+ export { BUILT_IN_TOOL_NAMES, BUILT_IN_TOOLS, isBuiltInTool } from "./tools/registry.js";
15
17
  export { writer } from "./writer.js";
18
+ // Wire the stderr classifier into the error factory at module load so
19
+ // processExitedEarly can auto-promote ProcessError -> KnownError.
20
+ import { setStderrClassifier } from "./errors.js";
21
+ import { classifyStderr as _classifyStderr } from "./stderr.js";
22
+ setStderrClassifier(_classifyStderr);
16
23
  export const claude = createClient();
package/dist/json.d.ts ADDED
@@ -0,0 +1,35 @@
1
+ import { ClaudeError } from "./errors.js";
2
+ export interface IStandardSchema<T = unknown> {
3
+ "~standard": {
4
+ version: 1;
5
+ vendor: string;
6
+ validate: (value: unknown) => IStandardResult<T>;
7
+ };
8
+ }
9
+ interface IStandardResult<T> {
10
+ value?: T;
11
+ issues?: ReadonlyArray<{
12
+ message?: string;
13
+ path?: ReadonlyArray<string | number>;
14
+ }>;
15
+ }
16
+ export declare class JsonValidationError extends ClaudeError {
17
+ readonly rawText: string;
18
+ readonly issues: ReadonlyArray<{
19
+ message?: string;
20
+ path?: ReadonlyArray<string | number>;
21
+ }>;
22
+ constructor(message: string, rawText: string, issues: ReadonlyArray<{
23
+ message?: string;
24
+ path?: ReadonlyArray<string | number>;
25
+ }>);
26
+ }
27
+ export interface IJsonResult<T> {
28
+ data: T;
29
+ raw: import("./types/results.js").TAskResult;
30
+ }
31
+ export declare const stripFences: (text: string) => string;
32
+ export type TSchemaInput<T> = IStandardSchema<T> | string;
33
+ export declare const isStandardSchema: <T>(schema: TSchemaInput<T>) => schema is IStandardSchema<T>;
34
+ export declare const parseAndValidate: <T>(text: string, schema: TSchemaInput<T>) => T;
35
+ export {};
package/dist/json.js ADDED
@@ -0,0 +1,43 @@
1
+ import { ClaudeError } from "./errors.js";
2
+ export class JsonValidationError extends ClaudeError {
3
+ rawText;
4
+ issues;
5
+ constructor(message, rawText, issues) {
6
+ super(message);
7
+ this.rawText = rawText;
8
+ this.issues = issues;
9
+ this.name = "JsonValidationError";
10
+ }
11
+ }
12
+ // Strip common fences: ```json ... ```, ``` ... ```, or bare JSON.
13
+ const FENCE_RE = /^\s*```(?:json)?\s*\n?([\s\S]*?)\n?\s*```\s*$/;
14
+ export const stripFences = (text) => {
15
+ const match = text.match(FENCE_RE);
16
+ return match?.[1] ?? text.trim();
17
+ };
18
+ export const isStandardSchema = (schema) => {
19
+ return typeof schema === "object" && schema !== null && "~standard" in schema;
20
+ };
21
+ export const parseAndValidate = (text, schema) => {
22
+ const stripped = stripFences(text);
23
+ let parsed;
24
+ try {
25
+ parsed = JSON.parse(stripped);
26
+ }
27
+ catch (err) {
28
+ throw new JsonValidationError(`Failed to parse JSON from Claude response: ${err instanceof Error ? err.message : String(err)}`, text, [
29
+ { message: "Invalid JSON" },
30
+ ]);
31
+ }
32
+ if (isStandardSchema(schema)) {
33
+ const result = schema["~standard"].validate(parsed);
34
+ if (result.issues && result.issues.length > 0) {
35
+ const summary = result.issues.map((i) => i.message ?? "validation error").join("; ");
36
+ throw new JsonValidationError(`Schema validation failed: ${summary}`, text, result.issues);
37
+ }
38
+ return result.value;
39
+ }
40
+ // Raw JSON Schema string -- no runtime validation, just parse. The CLI's
41
+ // --json-schema constrains the model output, so we trust it here.
42
+ return parsed;
43
+ };
@@ -7,7 +7,12 @@ const extractTokens = (modelUsage) => {
7
7
  for (const entry of Object.values(modelUsage)) {
8
8
  inputTokens = (inputTokens ?? 0) + entry.inputTokens + (entry.cacheReadInputTokens ?? 0) + (entry.cacheCreationInputTokens ?? 0);
9
9
  outputTokens = (outputTokens ?? 0) + entry.outputTokens;
10
- contextWindow = entry.contextWindow;
10
+ // Multi-model turns (e.g. sub-agent fan-out) report distinct windows
11
+ // per model. Take max so consumers see the widest context available,
12
+ // not whichever model happened to iterate last.
13
+ if (entry.contextWindow !== undefined && (contextWindow === undefined || entry.contextWindow > contextWindow)) {
14
+ contextWindow = entry.contextWindow;
15
+ }
11
16
  }
12
17
  }
13
18
  return { inputTokens, outputTokens, contextWindow };
@@ -50,7 +55,7 @@ const translateContentBlock = (block) => {
50
55
  type: "tool_use",
51
56
  toolUseId: block.id,
52
57
  toolName: block.name,
53
- input: typeof block.input === "string" ? block.input : JSON.stringify(block.input ?? {}),
58
+ input: block.input ?? {},
54
59
  };
55
60
  }
56
61
  case "tool_result": {
@@ -1,8 +1,18 @@
1
1
  import type { ICostTracker } from "./cost.js";
2
- import type { IClaudeProcess } from "./process.js";
2
+ import type { IClaudeProcess, ISpawnOptions } from "./process.js";
3
+ import { type IStderrDrain } from "./reader.js";
3
4
  import type { IToolHandlerInstance } from "./tools/handler.js";
4
- import type { TRelayEvent, TToolUseEvent } from "./types/events.js";
5
- import type { TAskResult } from "./types/results.js";
6
- export declare const dispatchToolDecision: (proc: IClaudeProcess, toolHandler: IToolHandlerInstance, event: TToolUseEvent) => Promise<void>;
5
+ import type { TRelayEvent, TToolUseEvent, TTurnCompleteEvent } from "./types/events.js";
6
+ import type { TAskResult, TCostSnapshot } from "./types/results.js";
7
+ import type { TWarn } from "./warnings.js";
8
+ interface IPipeline {
9
+ proc: IClaudeProcess;
10
+ reader: ReadableStreamDefaultReader<Uint8Array>;
11
+ stderr: IStderrDrain;
12
+ }
13
+ export declare const startPipeline: (options: ISpawnOptions) => IPipeline;
14
+ export declare const dispatchToolDecision: (proc: IClaudeProcess, toolHandler: IToolHandlerInstance, event: TToolUseEvent, onWarning?: TWarn) => Promise<void>;
15
+ export declare const applyTurnComplete: (event: TTurnCompleteEvent, costTracker: ICostTracker, offsets?: TCostSnapshot) => void;
7
16
  export declare const extractText: (events: TRelayEvent[]) => string;
8
17
  export declare const buildResult: (events: TRelayEvent[], costTracker: ICostTracker, sessionId: string | undefined) => TAskResult;
18
+ export {};
package/dist/pipeline.js CHANGED
@@ -1,31 +1,51 @@
1
+ import { safeWrite, spawnClaude } from "./process.js";
2
+ import { drainStderr } from "./reader.js";
3
+ import { createWarn } from "./warnings.js";
1
4
  import { writer } from "./writer.js";
2
- export const dispatchToolDecision = async (proc, toolHandler, event) => {
5
+ // Shared process-boot: spawn the CLI, lock the stdout reader, drain
6
+ // stderr. session.ts and stream.ts both need this exact trio; keeping
7
+ // the order in one place prevents the "one forgot to drain stderr and
8
+ // the other swallows exits silently" class of bug.
9
+ export const startPipeline = (options) => {
10
+ const proc = spawnClaude(options);
11
+ const reader = proc.stdout.getReader();
12
+ const stderr = drainStderr(proc);
13
+ return { proc, reader, stderr };
14
+ };
15
+ export const dispatchToolDecision = async (proc, toolHandler, event, onWarning) => {
16
+ const warn = createWarn(onWarning);
3
17
  let decision;
4
18
  try {
5
19
  decision = await toolHandler.decide(event);
6
20
  }
7
21
  catch (error) {
8
- console.warn(`[claude-wire] Tool handler threw, defaulting to deny: ${error instanceof Error ? error.message : String(error)}`);
22
+ warn("Tool handler threw, defaulting to deny", error);
9
23
  decision = "deny";
10
24
  }
11
- try {
12
- if (decision === "approve") {
13
- proc.write(writer.approve(event.toolUseId));
14
- }
15
- else if (decision === "deny") {
16
- proc.write(writer.deny(event.toolUseId));
17
- }
18
- else if (typeof decision === "object" && decision !== null && typeof decision.result === "string") {
19
- proc.write(writer.toolResult(event.toolUseId, decision.result));
20
- }
21
- else {
22
- console.warn("[claude-wire] Invalid tool decision, defaulting to deny");
23
- proc.write(writer.deny(event.toolUseId));
24
- }
25
+ if (decision === "approve") {
26
+ safeWrite(proc, writer.approve(event.toolUseId));
27
+ }
28
+ else if (decision === "deny") {
29
+ safeWrite(proc, writer.deny(event.toolUseId));
25
30
  }
26
- catch {
27
- // stdin closed - process died, error will surface through read path
31
+ else if (typeof decision === "object" && decision !== null && typeof decision.result === "string") {
32
+ const isError = "isError" in decision ? decision.isError : undefined;
33
+ safeWrite(proc, writer.toolResult(event.toolUseId, decision.result, isError ? { isError: true } : undefined));
28
34
  }
35
+ else {
36
+ warn("Invalid tool decision, defaulting to deny");
37
+ safeWrite(proc, writer.deny(event.toolUseId));
38
+ }
39
+ };
40
+ // Applies a turn_complete event's cumulative totals to the cost tracker
41
+ // and enforces the budget. `offsets` covers session's respawn case where
42
+ // the new process starts its cumulative count from zero but the session
43
+ // wants to carry forward what previous processes already spent -- stream
44
+ // has no such concept and passes it undefined.
45
+ export const applyTurnComplete = (event, costTracker, offsets) => {
46
+ const base = offsets ?? { totalUsd: 0, tokens: { input: 0, output: 0 } };
47
+ costTracker.update(base.totalUsd + (event.costUsd ?? 0), base.tokens.input + (event.inputTokens ?? 0), base.tokens.output + (event.outputTokens ?? 0));
48
+ costTracker.checkBudget();
29
49
  };
30
50
  export const extractText = (events) => {
31
51
  return events
@@ -39,7 +59,7 @@ export const buildResult = (events, costTracker, sessionId) => {
39
59
  return {
40
60
  text: extractText(events),
41
61
  costUsd: snap.totalUsd,
42
- tokens: { input: snap.inputTokens, output: snap.outputTokens },
62
+ tokens: snap.tokens,
43
63
  duration: tc?.durationMs ?? 0,
44
64
  sessionId,
45
65
  events,