Skip to content

Commit cb07e9f

Browse files
committed
feat(0.2.0): subpath exports, inactivity watchdog, error _tag, public testing module
Add four new public surfaces and one observability primitive without breaking back-compat. Default behavior is unchanged for existing callers. - Subpath exports: ./errors, ./parser, ./testing, ./package.json. Main entry unchanged. Locks the public boundary so internal refactors stay safe and keeps the testing module out of production bundles. - inactivityTimeoutMs option on IClaudeOptions, plumbed through session and stream readers. Resets on every stdout chunk. Defaults to the existing TIMEOUTS.defaultAbortMs (5 min). Pass Infinity to disable. New AgentInactivityError extends TimeoutError so existing catches still fire. - _tag literal on every error class plus TClaudeErrorTag union, enabling exhaustive switch without instanceof and survival across structured- clone boundaries. instanceof still works. - @pivanov/claude-wire/testing exposes createMockProcess and createMultiTurnMockProcess (with new emitEvent and closeStdout helpers) as IMockProcess / IMultiTurnMockProcess. Migrated four internal test files off the private tests/helpers shim. - Parser fuzz harness (mulberry32, seeded) over 2000 random TClaudeEvent shapes plus 2000 byte-fuzzed NDJSON lines. Caught a real bug: parseLine returned non-object JSON values (numbers, booleans, arrays, scalars) cast as TClaudeEvent. Now returns undefined and warns. Docs: new pages api/subpaths.md, api/testing.md; updated api/errors.md, api/session.md, api/stream.md; sidebar entries; README bundle figure corrected to ~8 kB minified+gzipped (~37 kB tarball). Tests: 296 -> 301 passing.
1 parent 448ad75 commit cb07e9f

27 files changed

Lines changed: 855 additions & 184 deletions

.changeset/correctness-fixes-0-1-7.md

Lines changed: 0 additions & 21 deletions
This file was deleted.

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ console.log(result.costUsd); // 0.0084
2727
- **Typed errors** - rate-limit, overload, context-length, retry-exhausted as `KnownError` codes
2828
- **Fully typed** - discriminated union events, full IntelliSense
2929
- **Resilient** - auto-respawn with backoff, transient error detection, AbortSignal
30-
- **Zero dependencies** - ~23 kB gzipped (bundle), 35 kB npm tarball
30+
- **Zero dependencies** - ~8 kB minified+gzipped (bundle), ~37 kB npm tarball
31+
- **Subpath exports** - `/errors`, `/parser`, `/testing` for narrower imports and bundle-isolated test helpers
3132

3233
## Install
3334

@@ -89,7 +90,7 @@ apps/examples/ interactive example runner
8990

9091
```bash
9192
bun install
92-
bun run test # 279 tests
93+
bun run test # 301 tests including parser fuzz
9394
bun run typecheck
9495
bun run lint
9596
bun run docs:dev # local docs server

apps/docs/.vitepress/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ export default defineConfig({
4040
{ text: "Events", link: "/api/events" },
4141
{ text: "JSON (askJson)", link: "/api/json" },
4242
{ text: "Errors", link: "/api/errors" },
43+
{ text: "Subpath Exports", link: "/api/subpaths" },
44+
{ text: "Testing", link: "/api/testing" },
4345
],
4446
},
4547
{

apps/docs/api/errors.md

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,32 @@
22

33
All errors extend `ClaudeError`, which extends the native `Error`. You can catch them specifically or broadly.
44

5+
## `_tag` Discriminants
6+
7+
Every error class carries a `_tag` literal so consumers can pattern-match without `instanceof`:
8+
9+
```ts
10+
import type { TClaudeErrorTag } from "@pivanov/claude-wire";
11+
12+
try {
13+
await claude.ask("...");
14+
} catch (error) {
15+
if (!(error instanceof Error)) throw error;
16+
switch ((error as { _tag?: TClaudeErrorTag })._tag) {
17+
case "AgentInactivityError": /* handle hung process */ break;
18+
case "BudgetExceededError": /* handle budget */ break;
19+
case "AbortError": /* handle cancel */ break;
20+
case "ProcessError": /* handle exit */ break;
21+
case "KnownError": /* handle classified */ break;
22+
default: /* fall through */
23+
}
24+
}
25+
```
26+
27+
The full union is `"ClaudeError" | "BudgetExceededError" | "AbortError" | "TimeoutError" | "AgentInactivityError" | "ProcessError" | "KnownError"`.
28+
29+
`instanceof` checks still work and remain the recommended pattern for most code; `_tag` is for places where you want exhaustive `switch` coverage or are comparing across realms (e.g. structured-clone boundaries) where `instanceof` is unreliable.
30+
531
## `ClaudeError`
632

733
Base error class for all claude-wire errors.
@@ -56,7 +82,28 @@ try {
5682

5783
## `TimeoutError`
5884

59-
Thrown when an operation times out. Distinct from `AbortError` for cases where the SDK itself enforces a timeout.
85+
Thrown when an operation times out. Distinct from `AbortError` for cases where the SDK itself enforces a timeout. Parent class of `AgentInactivityError`, so `instanceof TimeoutError` catches both.
86+
87+
## `AgentInactivityError`
88+
89+
Thrown by the SDK's inactivity watchdog when the CLI goes silent past `inactivityTimeoutMs` (default `TIMEOUTS.defaultAbortMs`, 5 minutes). The watchdog timer resets on every stdout chunk, so a chatty stream stays alive indefinitely.
90+
91+
```ts
92+
import { AgentInactivityError } from "@pivanov/claude-wire";
93+
94+
try {
95+
await claude.ask("...", { inactivityTimeoutMs: 30_000 });
96+
} catch (error) {
97+
if (error instanceof AgentInactivityError) {
98+
console.error(`Agent silent for ${error.inactivityMs}ms, killed`);
99+
}
100+
}
101+
```
102+
103+
**Properties:**
104+
- `inactivityMs: number` -- the configured timeout that fired.
105+
106+
Extends `TimeoutError`, so legacy `instanceof TimeoutError` checks keep working. Pass `Infinity` to disable the watchdog entirely.
60107

61108
## `ProcessError`
62109

apps/docs/api/session.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,18 @@ Sessions respect the `signal` option from `IClaudeOptions`:
174174
const session = claude.session({ signal: AbortSignal.timeout(60_000) });
175175
```
176176

177-
## Timeouts
177+
## Timeouts and Inactivity Watchdog
178178

179-
Each read operation has a 5-minute inactivity timeout (`TIMEOUTS.defaultAbortMs`). If no data is received within this window, a `TimeoutError` is thrown. The timeout resets on every chunk, so a turn that keeps streaming data can run indefinitely.
179+
Each read operation has a configurable inactivity timeout, defaulting to `TIMEOUTS.defaultAbortMs` (5 minutes). If no data arrives within this window the SDK throws `AgentInactivityError`, kills the process, and surfaces the error to the caller. The timer resets on every stdout chunk, so a turn that keeps streaming data can run indefinitely.
180+
181+
```ts
182+
const session = claude.session({
183+
model: "sonnet",
184+
inactivityTimeoutMs: 30_000, // fail fast in production paths
185+
});
186+
187+
// Disable the watchdog for batch jobs that may legitimately stall:
188+
const longRunning = claude.session({ inactivityTimeoutMs: Infinity });
189+
```
190+
191+
`AgentInactivityError` extends `TimeoutError`, so existing `instanceof TimeoutError` catches still fire. See [Errors](./errors.md#agentinactivityerror) for the full type signature.

apps/docs/api/stream.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,19 @@ for await (const event of stream) { /* silently yields nothing */ }
8888
If `.text()`, `.cost()`, or `.result()` was called between the two iterations (or before the second one), the second iteration throws a `ClaudeError` instead of silently yielding nothing. Calling a convenience method marks the stream as consumed, making any subsequent iteration an error rather than a no-op.
8989
:::
9090

91-
## Timeouts
91+
## Timeouts and Inactivity Watchdog
9292

93-
Streams have a 5-minute timeout per read operation. If Claude doesn't respond within this window, a `TimeoutError` is thrown and the process is killed.
93+
Streams have a configurable inactivity timeout, defaulting to 5 minutes (`TIMEOUTS.defaultAbortMs`). The watchdog resets on every stdout chunk, so an actively streaming response can run indefinitely. If Claude goes silent past the window, the SDK throws `AgentInactivityError` and kills the process.
94+
95+
```ts
96+
// Fail fast in interactive UIs:
97+
const stream = claude.stream("Explain generics", { inactivityTimeoutMs: 15_000 });
98+
99+
// Disable the watchdog for batch jobs:
100+
const batch = claude.stream("Long task", { inactivityTimeoutMs: Infinity });
101+
```
102+
103+
`AgentInactivityError` extends `TimeoutError`, so `instanceof TimeoutError` catches both. See [Errors](./errors.md#agentinactivityerror) for details.
94104

95105
## Buffer Limits
96106

apps/docs/api/subpaths.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Subpath Exports
2+
3+
The package exposes four entry points. The main entry re-exports the full public API; the subpaths give narrower surfaces for tooling, lazy-loaded modules, and tests that should not bundle into production builds.
4+
5+
| Subpath | Purpose |
6+
|---------|---------|
7+
| `@pivanov/claude-wire` | Main API: `claude`, `createSession`, `createStream`, `askJson`, errors, types, cost tracking. Use this for app code. |
8+
| `@pivanov/claude-wire/errors` | Error classes only. Useful for catch handlers in parent apps that don't want to pull the client. |
9+
| `@pivanov/claude-wire/parser` | Low-level NDJSON + translator helpers (`parseLine`, `createTranslator`, `extractContent`, `blockFingerprint`, `parseDoubleEncoded`). For protocol-level integrations. |
10+
| `@pivanov/claude-wire/testing` | In-process `IClaudeProcess` mocks (`createMockProcess`, `createMultiTurnMockProcess`). See [Testing](./testing.md). |
11+
| `@pivanov/claude-wire/package.json` | Direct access to the manifest (versions, repository metadata) for tooling. |
12+
13+
## Why Subpaths?
14+
15+
The package has zero runtime dependencies and ships with `"sideEffects": false`, so a tree-shaking bundler already drops unused code from the main entry. The subpaths add two extra guarantees:
16+
17+
1. **API hygiene.** The `exports` map is a whitelist. Code reaching into deep paths like `dist/parser/translator.js` is rejected by Node's resolver, so internal refactors stay safe.
18+
2. **Bundle isolation for testing helpers.** Production code that imports only the main entry never reaches `dist/testing/`, so any future growth of the testing module (mock builders, scripted sequences) stays out of production bundles even if a bundler's tree-shaking is conservative.
19+
20+
## Examples
21+
22+
```ts
23+
// App code: full API.
24+
import { claude, createSession } from "@pivanov/claude-wire";
25+
26+
// Catch handler in a worker that just needs error types.
27+
import { isKnownError, AgentInactivityError } from "@pivanov/claude-wire/errors";
28+
29+
// Custom protocol pipeline reusing the translator.
30+
import { parseLine, createTranslator } from "@pivanov/claude-wire/parser";
31+
32+
// Test file: in-process mock, no real CLI spawn.
33+
import { createMockProcess } from "@pivanov/claude-wire/testing";
34+
```

apps/docs/api/testing.md

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# Testing
2+
3+
The `@pivanov/claude-wire/testing` subpath ships in-process `IClaudeProcess` fakes you can swap in for the real `spawnClaude()` during unit tests. Living behind a subpath means production installs that never import from `/testing` skip the module entirely.
4+
5+
## When to Use
6+
7+
- Tests that exercise SDK behavior (parsing, sessions, retries, tool dispatch) without spawning the real `claude` binary.
8+
- CI environments where the binary is unavailable or authentication isn't set up.
9+
- Deterministic regression tests that pin a specific NDJSON transcript.
10+
11+
For end-to-end coverage against the real CLI, spawn `claude` normally and consume `claude.ask()` as usual.
12+
13+
## `createMockProcess(options)`
14+
15+
One-shot mock. Pre-supply the NDJSON lines the mock should emit; the stream emits them in order, closes stdout, and resolves `exited` with the configured exit code.
16+
17+
```ts
18+
import { createMockProcess } from "@pivanov/claude-wire/testing";
19+
20+
const proc = createMockProcess({
21+
lines: [
22+
'{"type":"system","subtype":"init","session_id":"s1","model":"haiku","tools":[]}',
23+
'{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}',
24+
'{"type":"result","subtype":"success","session_id":"s1","result":"hi","is_error":false,"total_cost_usd":0.001,"duration_ms":100,"modelUsage":{}}',
25+
],
26+
exitCode: 0,
27+
});
28+
29+
// Inspect what the SDK wrote to stdin:
30+
console.log(proc.writes);
31+
console.log(proc.killed);
32+
```
33+
34+
A positional overload is also accepted for ergonomics: `createMockProcess(lines, exitCode?)`.
35+
36+
### `IMockProcess`
37+
38+
Extends `IClaudeProcess` with two read-only inspection fields:
39+
40+
| Field | Type | Description |
41+
|-------|------|-------------|
42+
| `writes` | `readonly string[]` | Every line written to stdin via `write()`, in order. |
43+
| `killed` | `boolean` | True after `kill()` has been called at least once. |
44+
45+
## `createMultiTurnMockProcess()`
46+
47+
Long-lived mock for tests that need to react to SDK input. The stdout stream stays open until `closeStdout()` or `kill()`. Push events with `emitLines()` (raw NDJSON) or `emitEvent()` (typed `TClaudeEvent`).
48+
49+
```ts
50+
import { createMultiTurnMockProcess } from "@pivanov/claude-wire/testing";
51+
52+
const proc = createMultiTurnMockProcess();
53+
54+
// First turn:
55+
proc.emitEvent({ type: "system", subtype: "init", session_id: "s1", model: "haiku", tools: [] });
56+
proc.emitEvent({ type: "assistant", message: { role: "assistant", content: [{ type: "text", text: "first" }] } });
57+
proc.emitEvent({ type: "result", subtype: "success", session_id: "s1", result: "first", is_error: false, total_cost_usd: 0.001 });
58+
59+
// React to a stdin write before emitting the next turn:
60+
await waitFor(() => proc.writes.some((w) => w.includes("follow-up")));
61+
proc.emitEvent({ type: "result", subtype: "success", session_id: "s1", result: "second", is_error: false, total_cost_usd: 0.002 });
62+
63+
proc.kill();
64+
```
65+
66+
### `IMultiTurnMockProcess`
67+
68+
Extends `IMockProcess` with three control methods:
69+
70+
| Method | Description |
71+
|--------|-------------|
72+
| `emitLines(lines: string[])` | Push raw NDJSON lines into stdout. Each gets a trailing `\n`. |
73+
| `emitEvent(event: TClaudeEvent)` | JSON-stringify a typed event and emit it as one line. |
74+
| `closeStdout()` | Close the stdout stream so the reader sees EOF. Idempotent. |
75+
76+
## Wiring Into Bun Tests
77+
78+
Use `mock.module` to redirect `spawnClaude` at the module boundary:
79+
80+
```ts
81+
import { beforeEach, mock, test, expect } from "bun:test";
82+
import { createMockProcess, type IMockProcess } from "@pivanov/claude-wire/testing";
83+
84+
let mockProc: IMockProcess;
85+
86+
beforeEach(() => {
87+
mockProc = createMockProcess({
88+
lines: [/* fixture lines */],
89+
exitCode: 0,
90+
});
91+
mock.module("@pivanov/claude-wire", async () => {
92+
const real = await import("@pivanov/claude-wire");
93+
return { ...real, spawnClaude: () => mockProc };
94+
});
95+
});
96+
97+
test("session reads a turn from the mock", async () => {
98+
const { createSession } = await import("@pivanov/claude-wire");
99+
const session = createSession();
100+
const result = await session.ask("hi");
101+
expect(result.text).toBe("hi");
102+
});
103+
```
104+
105+
For Vitest, Jest, or other runners, use the equivalent module-mock primitive (`vi.mock`, `jest.mock`).
106+
107+
## Fuzz Testing the Parser
108+
109+
The `@pivanov/claude-wire/parser` subpath exposes `parseLine` and `createTranslator`, both deterministic given the same input. The internal test suite ships a seeded fuzz harness over these; if you build adapters or alternative pipelines, the same harness pattern works for your translator. See `tests/parser/translator.fuzz.test.ts` in the repo for a reference implementation.

0 commit comments

Comments
 (0)