@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 +8 -4
- package/dist/async.d.ts +10 -0
- package/dist/async.js +27 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.js +21 -3
- package/dist/constants.d.ts +2 -0
- package/dist/constants.js +7 -0
- package/dist/cost.d.ts +8 -0
- package/dist/cost.js +32 -11
- package/dist/errors.d.ts +5 -2
- package/dist/errors.js +38 -6
- package/dist/index.d.ts +10 -6
- package/dist/index.js +10 -3
- package/dist/json.d.ts +35 -0
- package/dist/json.js +43 -0
- package/dist/parser/translator.js +7 -2
- package/dist/pipeline.d.ts +14 -4
- package/dist/pipeline.js +39 -19
- package/dist/process.d.ts +15 -3
- package/dist/process.js +86 -25
- package/dist/reader.d.ts +3 -0
- package/dist/reader.js +32 -30
- package/dist/runtime.d.ts +5 -4
- package/dist/runtime.js +8 -5
- package/dist/session.d.ts +31 -2
- package/dist/session.js +138 -71
- package/dist/stderr.d.ts +10 -0
- package/dist/stderr.js +31 -0
- package/dist/stream.js +61 -26
- package/dist/tools/handler.d.ts +1 -0
- package/dist/tools/registry.d.ts +4 -2
- package/dist/tools/registry.js +7 -4
- package/dist/types/events.d.ts +1 -1
- package/dist/types/options.d.ts +43 -6
- package/dist/types/protocol.d.ts +1 -5
- package/dist/types/results.d.ts +6 -6
- package/dist/validation.d.ts +10 -0
- package/dist/validation.js +23 -0
- package/dist/warnings.d.ts +2 -0
- package/dist/warnings.js +24 -0
- package/dist/writer.d.ts +10 -1
- package/dist/writer.js +14 -8
- package/package.json +1 -1
package/dist/session.js
CHANGED
|
@@ -1,53 +1,104 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { withTimeout } from "./async.js";
|
|
2
|
+
import { LIMITS, MAX_BACKOFF_INDEX, RESPAWN_BACKOFF_MS, TIMEOUTS } from "./constants.js";
|
|
2
3
|
import { createCostTracker } from "./cost.js";
|
|
3
|
-
import { AbortError, BudgetExceededError, ClaudeError, isTransientError, KnownError,
|
|
4
|
+
import { AbortError, BudgetExceededError, ClaudeError, isTransientError, KnownError, processExitedEarly, TimeoutError } from "./errors.js";
|
|
5
|
+
import { parseAndValidate } from "./json.js";
|
|
4
6
|
import { createTranslator } from "./parser/translator.js";
|
|
5
|
-
import { buildResult } from "./pipeline.js";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
7
|
+
import { applyTurnComplete, buildResult, startPipeline } from "./pipeline.js";
|
|
8
|
+
import { safeKill, safeWrite } from "./process.js";
|
|
9
|
+
import { readNdjsonEvents } from "./reader.js";
|
|
8
10
|
import { createToolHandler } from "./tools/handler.js";
|
|
9
11
|
import { writer } from "./writer.js";
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
// Two-stage termination: SIGTERM first, escalate to SIGKILL after the
|
|
13
|
+
// graceful-exit timeout. A stuck child (e.g. blocked on a syscall that
|
|
14
|
+
// ignores SIGTERM) would otherwise survive the "graceful" path and leak.
|
|
15
|
+
// The sentinel value distinguishes "exited on time" from "timeout fired"
|
|
16
|
+
// without a side-channel boolean.
|
|
17
|
+
// Compose two optional AbortSignals into one. If either fires, the
|
|
18
|
+
// returned signal aborts. Returns undefined when both inputs are undefined.
|
|
19
|
+
const composeSignals = (a, b) => {
|
|
20
|
+
if (!a && !b) {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
if (!a) {
|
|
24
|
+
return b;
|
|
25
|
+
}
|
|
26
|
+
if (!b) {
|
|
27
|
+
return a;
|
|
28
|
+
}
|
|
29
|
+
const ctrl = new AbortController();
|
|
30
|
+
const abort = () => ctrl.abort();
|
|
31
|
+
a.addEventListener("abort", abort, { once: true });
|
|
32
|
+
b.addEventListener("abort", abort, { once: true });
|
|
33
|
+
if (a.aborted || b.aborted) {
|
|
34
|
+
ctrl.abort();
|
|
35
|
+
}
|
|
36
|
+
return ctrl.signal;
|
|
37
|
+
};
|
|
38
|
+
// Fire both session-level and per-ask onRetry, swallowing throws from either.
|
|
39
|
+
const fireRetry = (attempt, error, sessionLevel, askLevel) => {
|
|
14
40
|
try {
|
|
15
|
-
|
|
16
|
-
p.exited,
|
|
17
|
-
new Promise((r) => {
|
|
18
|
-
timer = setTimeout(() => {
|
|
19
|
-
timedOut = true;
|
|
20
|
-
r();
|
|
21
|
-
}, TIMEOUTS.gracefulExitMs);
|
|
22
|
-
}),
|
|
23
|
-
]);
|
|
41
|
+
sessionLevel?.(attempt, error);
|
|
24
42
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
clearTimeout(timer);
|
|
28
|
-
}
|
|
43
|
+
catch {
|
|
44
|
+
// observer threw -- retry still happens
|
|
29
45
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
// already dead
|
|
36
|
-
}
|
|
46
|
+
try {
|
|
47
|
+
askLevel?.(attempt, error);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// observer threw -- retry still happens
|
|
37
51
|
}
|
|
38
52
|
};
|
|
53
|
+
const KILL_TIMED_OUT = Symbol("kill-timed-out");
|
|
54
|
+
const gracefulKill = async (p) => {
|
|
55
|
+
safeKill(p, "SIGTERM");
|
|
56
|
+
const outcome = await withTimeout(p.exited, TIMEOUTS.gracefulExitMs, () => KILL_TIMED_OUT);
|
|
57
|
+
if (outcome === KILL_TIMED_OUT) {
|
|
58
|
+
safeKill(p, "SIGKILL");
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Creates a multi-turn Claude session backed by a single long-lived CLI
|
|
63
|
+
* process. Each `ask()` sends a user prompt and resolves with `TAskResult`
|
|
64
|
+
* for that turn. Calls are serialized -- a second `ask()` waits for the
|
|
65
|
+
* first to complete. Use `close()` (or `await using`) to free the process.
|
|
66
|
+
*
|
|
67
|
+
* ### Retry behavior
|
|
68
|
+
* Each `ask()` automatically retries transient failures -- process crashes
|
|
69
|
+
* matching SIGKILL/SIGTERM/SIGPIPE exit codes, `ECONNRESET`, `ECONNREFUSED`,
|
|
70
|
+
* `ETIMEDOUT`, `EHOSTUNREACH`, `ENETUNREACH`, `EAI_AGAIN`, Anthropic
|
|
71
|
+
* `overloaded_error` / 529s, broken-pipe / "socket hang up" messages, etc.
|
|
72
|
+
* (see `isTransientError`). Backoff is `500ms → 1s → 2s`; the budget is
|
|
73
|
+
* `LIMITS.maxRespawnAttempts` (currently 3) and is shared across a single
|
|
74
|
+
* `ask()`. When the budget is exhausted the session throws
|
|
75
|
+
* `KnownError("retry-exhausted")` and marks itself closed.
|
|
76
|
+
*
|
|
77
|
+
* Fatal errors -- `KnownError` and `BudgetExceededError` -- also close the
|
|
78
|
+
* session. Any subsequent `ask()` on a closed session rejects with
|
|
79
|
+
* `ClaudeError("Session is closed")`. All other errors (abort, timeout,
|
|
80
|
+
* non-transient `ProcessError`) propagate without closing, and the caller
|
|
81
|
+
* may decide whether to retry at a higher level.
|
|
82
|
+
*
|
|
83
|
+
* ### Observability
|
|
84
|
+
* - `onCostUpdate(snapshot)` -- fires after every `turn_complete`.
|
|
85
|
+
* - `onRetry(attempt, error)` -- fires each time a transient failure triggers
|
|
86
|
+
* a respawn inside one `ask()`. Attempt is 1-indexed.
|
|
87
|
+
* - `onWarning(message, cause)` -- routes all library-emitted warnings.
|
|
88
|
+
*/
|
|
39
89
|
export const createSession = (options = {}) => {
|
|
40
90
|
let proc;
|
|
41
91
|
let currentSessionId;
|
|
42
92
|
let consecutiveCrashes = 0;
|
|
43
93
|
let turnCount = 0;
|
|
44
|
-
let costOffsets = { totalUsd: 0,
|
|
94
|
+
let costOffsets = { totalUsd: 0, tokens: { input: 0, output: 0 } };
|
|
45
95
|
const translator = createTranslator();
|
|
46
96
|
const costTracker = createCostTracker({
|
|
47
97
|
maxCostUsd: options.maxCostUsd,
|
|
48
98
|
onCostUpdate: options.onCostUpdate,
|
|
99
|
+
onWarning: options.onWarning,
|
|
49
100
|
});
|
|
50
|
-
const toolHandler = options.
|
|
101
|
+
const toolHandler = options.toolHandler ? createToolHandler(options.toolHandler) : undefined;
|
|
51
102
|
let inFlight;
|
|
52
103
|
let reader;
|
|
53
104
|
const cleanupProcess = () => {
|
|
@@ -61,9 +112,11 @@ export const createSession = (options = {}) => {
|
|
|
61
112
|
}
|
|
62
113
|
reader = undefined;
|
|
63
114
|
};
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
115
|
+
// Drain handle from the most recent spawn. Stored as a whole so we
|
|
116
|
+
// can call `.text()` (shared helper on IStderrDrain) instead of
|
|
117
|
+
// reimplementing chunks.join/trim at every use site.
|
|
118
|
+
let lastStderrDrain;
|
|
119
|
+
const getStderrText = () => lastStderrDrain?.text() ?? "";
|
|
67
120
|
const killProc = () => {
|
|
68
121
|
if (proc) {
|
|
69
122
|
proc.kill();
|
|
@@ -75,20 +128,27 @@ export const createSession = (options = {}) => {
|
|
|
75
128
|
const spawnFresh = (prompt, resumeId) => {
|
|
76
129
|
if (consecutiveCrashes >= LIMITS.maxRespawnAttempts) {
|
|
77
130
|
killProc();
|
|
78
|
-
|
|
131
|
+
// Typed code so consumers can pattern-match on
|
|
132
|
+
// `KnownError && err.code === "retry-exhausted"` without parsing strings.
|
|
133
|
+
throw new KnownError("retry-exhausted", `Process crashed ${consecutiveCrashes} times, giving up`);
|
|
79
134
|
}
|
|
80
135
|
costOffsets = costTracker.snapshot();
|
|
81
136
|
killProc();
|
|
82
137
|
translator.reset();
|
|
138
|
+
// Respawn always overrides caller-supplied options.resume with the live
|
|
139
|
+
// session id when one is available: mid-session recovery must resume the
|
|
140
|
+
// same conversation, not whatever static id was passed at construction.
|
|
83
141
|
const spawnOpts = resumeId ? { prompt, ...options, resume: resumeId } : { prompt, ...options };
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
lastDrainDone = drain.done;
|
|
142
|
+
const pipeline = startPipeline(spawnOpts);
|
|
143
|
+
proc = pipeline.proc;
|
|
144
|
+
reader = pipeline.reader;
|
|
145
|
+
lastStderrDrain = pipeline.stderr;
|
|
89
146
|
};
|
|
90
147
|
const respawnBackoff = async () => {
|
|
91
|
-
|
|
148
|
+
// consecutiveCrashes starts at 1 for the first retry; idx points
|
|
149
|
+
// into RESPAWN_BACKOFF_MS and clamps to the last defined entry for
|
|
150
|
+
// any crash count beyond the table length.
|
|
151
|
+
const idx = Math.min(consecutiveCrashes, MAX_BACKOFF_INDEX) - 1;
|
|
92
152
|
const delay = idx >= 0 ? RESPAWN_BACKOFF_MS[idx] : 0;
|
|
93
153
|
if (delay) {
|
|
94
154
|
await new Promise((r) => setTimeout(r, delay));
|
|
@@ -106,14 +166,14 @@ export const createSession = (options = {}) => {
|
|
|
106
166
|
toolHandler,
|
|
107
167
|
proc,
|
|
108
168
|
signal,
|
|
169
|
+
onWarning: options.onWarning,
|
|
109
170
|
})) {
|
|
110
171
|
if (event.type === "session_meta") {
|
|
111
172
|
currentSessionId = event.sessionId;
|
|
112
173
|
}
|
|
113
174
|
events.push(event);
|
|
114
175
|
if (event.type === "turn_complete") {
|
|
115
|
-
|
|
116
|
-
costTracker.checkBudget();
|
|
176
|
+
applyTurnComplete(event, costTracker, costOffsets);
|
|
117
177
|
gotTurnComplete = true;
|
|
118
178
|
break;
|
|
119
179
|
}
|
|
@@ -122,46 +182,49 @@ export const createSession = (options = {}) => {
|
|
|
122
182
|
if (signal?.aborted) {
|
|
123
183
|
throw new AbortError();
|
|
124
184
|
}
|
|
125
|
-
// stdout closed → process is dying.
|
|
126
|
-
//
|
|
127
|
-
//
|
|
185
|
+
// stdout closed → process is dying. Wait briefly for exited so we
|
|
186
|
+
// can attach an exit code to the error; if it doesn't resolve in
|
|
187
|
+
// time, force-kill and leave exitCode undefined (→ non-transient).
|
|
128
188
|
let exitCode;
|
|
129
189
|
if (proc) {
|
|
130
190
|
const live = proc;
|
|
131
|
-
exitCode = await
|
|
132
|
-
live.exited,
|
|
133
|
-
new Promise((r) => setTimeout(() => r(undefined), TIMEOUTS.gracefulExitMs)),
|
|
134
|
-
]);
|
|
191
|
+
exitCode = await withTimeout(live.exited, TIMEOUTS.gracefulExitMs);
|
|
135
192
|
if (exitCode === undefined) {
|
|
136
193
|
live.kill();
|
|
137
194
|
}
|
|
138
195
|
}
|
|
139
|
-
if (
|
|
140
|
-
await
|
|
196
|
+
if (lastStderrDrain) {
|
|
197
|
+
await withTimeout(lastStderrDrain.done, TIMEOUTS.stderrDrainGraceMs);
|
|
141
198
|
}
|
|
142
|
-
|
|
143
|
-
throw new ProcessError(stderrMsg || "Process exited without completing the turn", exitCode);
|
|
199
|
+
throw processExitedEarly(getStderrText(), exitCode);
|
|
144
200
|
}
|
|
145
201
|
return events;
|
|
146
202
|
};
|
|
147
|
-
const doAsk = async (prompt) => {
|
|
203
|
+
const doAsk = async (prompt, askOpts) => {
|
|
148
204
|
if (!proc) {
|
|
149
205
|
spawnFresh(prompt, currentSessionId);
|
|
150
206
|
}
|
|
151
|
-
else {
|
|
207
|
+
else if (!safeWrite(proc, writer.user(prompt))) {
|
|
208
|
+
// stdin write failed -- process probably died. Try to respawn.
|
|
209
|
+
// spawnFresh can itself throw ProcessError synchronously when the
|
|
210
|
+
// respawn cap is already hit; surface as an Error like the retry
|
|
211
|
+
// loop below would, instead of a raw synchronous throw.
|
|
212
|
+
consecutiveCrashes++;
|
|
213
|
+
translator.reset();
|
|
152
214
|
try {
|
|
153
|
-
proc.write(writer.user(prompt));
|
|
154
|
-
}
|
|
155
|
-
catch {
|
|
156
|
-
consecutiveCrashes++;
|
|
157
|
-
translator.reset();
|
|
158
215
|
spawnFresh(prompt, currentSessionId);
|
|
159
216
|
}
|
|
217
|
+
catch (respawnError) {
|
|
218
|
+
killProc();
|
|
219
|
+
throw respawnError;
|
|
220
|
+
}
|
|
160
221
|
}
|
|
222
|
+
// Compose per-ask signal with session-level signal: either firing aborts.
|
|
223
|
+
const effectiveSignal = composeSignals(options.signal, askOpts?.signal);
|
|
161
224
|
let events;
|
|
162
225
|
while (true) {
|
|
163
226
|
try {
|
|
164
|
-
events = await readUntilTurnComplete(
|
|
227
|
+
events = await readUntilTurnComplete(effectiveSignal);
|
|
165
228
|
break;
|
|
166
229
|
}
|
|
167
230
|
catch (error) {
|
|
@@ -175,6 +238,9 @@ export const createSession = (options = {}) => {
|
|
|
175
238
|
throw error;
|
|
176
239
|
}
|
|
177
240
|
consecutiveCrashes++;
|
|
241
|
+
// Fire both session-level and per-ask onRetry. Both are safe-invoked
|
|
242
|
+
// so a throwing observer doesn't prevent the retry.
|
|
243
|
+
fireRetry(consecutiveCrashes, error, options.onRetry, askOpts?.onRetry);
|
|
178
244
|
await respawnBackoff();
|
|
179
245
|
spawnFresh(prompt, currentSessionId);
|
|
180
246
|
// Loop to retry; stops when budget exhausted or turn completes.
|
|
@@ -194,7 +260,7 @@ export const createSession = (options = {}) => {
|
|
|
194
260
|
return buildResult(events, costTracker, currentSessionId);
|
|
195
261
|
};
|
|
196
262
|
let closed = false;
|
|
197
|
-
const ask = (prompt) => {
|
|
263
|
+
const ask = (prompt, askOpts) => {
|
|
198
264
|
if (closed) {
|
|
199
265
|
return Promise.reject(new ClaudeError("Session is closed"));
|
|
200
266
|
}
|
|
@@ -209,7 +275,7 @@ export const createSession = (options = {}) => {
|
|
|
209
275
|
if (closed) {
|
|
210
276
|
throw new ClaudeError("Session is closed");
|
|
211
277
|
}
|
|
212
|
-
return doAsk(prompt);
|
|
278
|
+
return doAsk(prompt, askOpts);
|
|
213
279
|
})
|
|
214
280
|
.catch((error) => {
|
|
215
281
|
if (error instanceof KnownError || error instanceof BudgetExceededError) {
|
|
@@ -225,23 +291,24 @@ export const createSession = (options = {}) => {
|
|
|
225
291
|
if (inFlight) {
|
|
226
292
|
// Cap the wait: a stuck reader.read() inside the queued ask would
|
|
227
293
|
// otherwise hang close() forever before gracefulKill gets a chance.
|
|
228
|
-
await
|
|
294
|
+
await withTimeout(inFlight.catch(() => { }), TIMEOUTS.gracefulExitMs);
|
|
229
295
|
inFlight = undefined;
|
|
230
296
|
}
|
|
231
297
|
if (proc) {
|
|
232
|
-
|
|
233
|
-
proc.write(writer.abort());
|
|
234
|
-
}
|
|
235
|
-
catch {
|
|
236
|
-
// stdin may already be closed
|
|
237
|
-
}
|
|
298
|
+
safeWrite(proc, writer.abort());
|
|
238
299
|
await gracefulKill(proc);
|
|
239
300
|
proc = undefined;
|
|
240
301
|
}
|
|
241
302
|
cleanupProcess();
|
|
242
303
|
};
|
|
304
|
+
const askJson = async (prompt, schema, askOpts) => {
|
|
305
|
+
const raw = await ask(prompt, askOpts);
|
|
306
|
+
const data = parseAndValidate(raw.text, schema);
|
|
307
|
+
return { data, raw };
|
|
308
|
+
};
|
|
243
309
|
return {
|
|
244
310
|
ask,
|
|
311
|
+
askJson,
|
|
245
312
|
close,
|
|
246
313
|
get sessionId() {
|
|
247
314
|
return currentSessionId;
|
package/dist/stderr.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { TKnownErrorCode } from "./errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* Attempts to classify an opaque stderr string into a typed `TKnownErrorCode`.
|
|
4
|
+
* Returns `undefined` when no pattern matches -- the caller should keep the
|
|
5
|
+
* original `ProcessError` as-is in that case.
|
|
6
|
+
*
|
|
7
|
+
* The exit code is accepted but not currently used (all classification is
|
|
8
|
+
* text-based). Reserved for future exit-code-specific rules.
|
|
9
|
+
*/
|
|
10
|
+
export declare const classifyStderr: (stderr: string, _exitCode?: number) => TKnownErrorCode | undefined;
|
package/dist/stderr.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Conservative pattern table for classifying CLI stderr into typed error
|
|
2
|
+
// codes. Intentionally small -- false classification is worse than no
|
|
3
|
+
// classification. Documented as "may drift across CLI versions."
|
|
4
|
+
//
|
|
5
|
+
// Each entry: [pattern to test against stderr text, resulting code].
|
|
6
|
+
const STDERR_PATTERNS = [
|
|
7
|
+
[/rate[_ -]?limit|429|too many requests/i, "rate-limit"],
|
|
8
|
+
[/overloaded|529|temporarily unavailable/i, "overloaded"],
|
|
9
|
+
[/context[_ -]?length|context[_ -]?window|too long|maximum.*tokens/i, "context-length-exceeded"],
|
|
10
|
+
[/invalid.*json[_ -]?schema|schema.*invalid|json.*schema.*error/i, "invalid-json-schema"],
|
|
11
|
+
[/mcp.*error|mcp.*fail|mcp.*server/i, "mcp-error"],
|
|
12
|
+
[/not authenticated|authentication|unauthorized|401/i, "not-authenticated"],
|
|
13
|
+
[/permission denied|forbidden|403/i, "permission-denied"],
|
|
14
|
+
[/binary.*not found|command not found|ENOENT.*claude/i, "binary-not-found"],
|
|
15
|
+
];
|
|
16
|
+
/**
|
|
17
|
+
* Attempts to classify an opaque stderr string into a typed `TKnownErrorCode`.
|
|
18
|
+
* Returns `undefined` when no pattern matches -- the caller should keep the
|
|
19
|
+
* original `ProcessError` as-is in that case.
|
|
20
|
+
*
|
|
21
|
+
* The exit code is accepted but not currently used (all classification is
|
|
22
|
+
* text-based). Reserved for future exit-code-specific rules.
|
|
23
|
+
*/
|
|
24
|
+
export const classifyStderr = (stderr, _exitCode) => {
|
|
25
|
+
for (const [pattern, code] of STDERR_PATTERNS) {
|
|
26
|
+
if (pattern.test(stderr)) {
|
|
27
|
+
return code;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return undefined;
|
|
31
|
+
};
|
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 {
|
|
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
|
-
|
|
10
|
-
|
|
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
|
-
const toolHandler = options.
|
|
18
|
+
const toolHandler = options.toolHandler ? createToolHandler(options.toolHandler) : 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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
45
|
-
costTracker.checkBudget();
|
|
58
|
+
applyTurnComplete(event, costTracker);
|
|
46
59
|
turnComplete = true;
|
|
47
60
|
}
|
|
48
61
|
yield event;
|
|
49
62
|
}
|
|
50
63
|
if (!turnComplete) {
|
|
51
|
-
|
|
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
|
|
85
|
+
throw processExitedEarly(stderrText);
|
|
61
86
|
}
|
|
62
87
|
}
|
|
63
88
|
finally {
|
|
64
|
-
|
|
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(
|
|
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
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
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(
|
|
144
|
+
throw new ClaudeError(MIX_ITER_CONSUME);
|
|
110
145
|
}
|
|
111
146
|
cachedGenerator ??= generate();
|
|
112
147
|
return cachedGenerator;
|
package/dist/tools/handler.d.ts
CHANGED
|
@@ -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>;
|
package/dist/tools/registry.d.ts
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
|
-
export declare const
|
|
2
|
-
export
|
|
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;
|
package/dist/tools/registry.js
CHANGED
|
@@ -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
|
-
|
|
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
|
};
|
package/dist/types/events.d.ts
CHANGED
package/dist/types/options.d.ts
CHANGED
|
@@ -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?:
|
|
6
|
-
blocked?:
|
|
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,9 +14,9 @@ export interface IClaudeOptions {
|
|
|
12
14
|
model?: "opus" | "sonnet" | "haiku" | (string & {});
|
|
13
15
|
systemPrompt?: string;
|
|
14
16
|
appendSystemPrompt?: string;
|
|
15
|
-
allowedTools?:
|
|
16
|
-
disallowedTools?:
|
|
17
|
-
|
|
17
|
+
allowedTools?: TToolName[];
|
|
18
|
+
disallowedTools?: TToolName[];
|
|
19
|
+
toolHandler?: IToolHandler;
|
|
18
20
|
/**
|
|
19
21
|
* SDK-side budget limit, evaluated after each turn. Throws `BudgetExceededError`
|
|
20
22
|
* and kills the process when `total_cost_usd` exceeds this value. `0` means
|
|
@@ -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
|
}
|