@pivanov/claude-wire 0.0.2 → 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/README.md +5 -4
- package/dist/async.d.ts +10 -0
- package/dist/async.js +27 -0
- package/dist/client.js +10 -6
- package/dist/constants.d.ts +4 -0
- package/dist/constants.js +10 -0
- package/dist/cost.d.ts +2 -0
- package/dist/cost.js +6 -3
- package/dist/errors.d.ts +3 -4
- package/dist/errors.js +18 -9
- package/dist/index.d.ts +6 -4
- package/dist/index.js +4 -4
- package/dist/parser/content.js +3 -2
- package/dist/parser/translator.js +10 -3
- package/dist/pipeline.d.ts +14 -4
- package/dist/pipeline.js +38 -18
- package/dist/process.d.ts +16 -2
- package/dist/process.js +115 -73
- package/dist/reader.d.ts +10 -1
- package/dist/reader.js +83 -35
- package/dist/runtime.d.ts +5 -4
- package/dist/runtime.js +21 -46
- package/dist/session.d.ts +30 -2
- package/dist/session.js +171 -99
- package/dist/stream.js +78 -52
- package/dist/tools/handler.d.ts +1 -0
- package/dist/tools/handler.js +12 -4
- package/dist/tools/registry.d.ts +4 -2
- package/dist/tools/registry.js +7 -4
- package/dist/types/options.d.ts +53 -5
- package/dist/types/protocol.d.ts +1 -5
- 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,41 +1,90 @@
|
|
|
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";
|
|
4
5
|
import { createTranslator } from "./parser/translator.js";
|
|
5
|
-
import { buildResult } from "./pipeline.js";
|
|
6
|
-
import {
|
|
6
|
+
import { applyTurnComplete, buildResult, startPipeline } from "./pipeline.js";
|
|
7
|
+
import { safeKill, safeWrite } from "./process.js";
|
|
7
8
|
import { readNdjsonEvents } from "./reader.js";
|
|
8
9
|
import { createToolHandler } from "./tools/handler.js";
|
|
9
10
|
import { writer } from "./writer.js";
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
// Two-stage termination: SIGTERM first, escalate to SIGKILL after the
|
|
12
|
+
// graceful-exit timeout. A stuck child (e.g. blocked on a syscall that
|
|
13
|
+
// ignores SIGTERM) would otherwise survive the "graceful" path and leak.
|
|
14
|
+
// The sentinel value distinguishes "exited on time" from "timeout fired"
|
|
15
|
+
// without a side-channel boolean.
|
|
16
|
+
// Compose two optional AbortSignals into one. If either fires, the
|
|
17
|
+
// returned signal aborts. Returns undefined when both inputs are undefined.
|
|
18
|
+
const composeSignals = (a, b) => {
|
|
19
|
+
if (!a && !b) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
if (!a) {
|
|
23
|
+
return b;
|
|
24
|
+
}
|
|
25
|
+
if (!b) {
|
|
26
|
+
return a;
|
|
27
|
+
}
|
|
28
|
+
const ctrl = new AbortController();
|
|
29
|
+
const abort = () => ctrl.abort();
|
|
30
|
+
a.addEventListener("abort", abort, { once: true });
|
|
31
|
+
b.addEventListener("abort", abort, { once: true });
|
|
32
|
+
if (a.aborted || b.aborted) {
|
|
33
|
+
ctrl.abort();
|
|
34
|
+
}
|
|
35
|
+
return ctrl.signal;
|
|
36
|
+
};
|
|
37
|
+
// Fire both session-level and per-ask onRetry, swallowing throws from either.
|
|
38
|
+
const fireRetry = (attempt, error, sessionLevel, askLevel) => {
|
|
14
39
|
try {
|
|
15
|
-
|
|
16
|
-
p.exited,
|
|
17
|
-
new Promise((r) => {
|
|
18
|
-
timer = setTimeout(() => {
|
|
19
|
-
timedOut = true;
|
|
20
|
-
r();
|
|
21
|
-
}, TIMEOUTS.gracefulExitMs);
|
|
22
|
-
}),
|
|
23
|
-
]);
|
|
40
|
+
sessionLevel?.(attempt, error);
|
|
24
41
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
clearTimeout(timer);
|
|
28
|
-
}
|
|
42
|
+
catch {
|
|
43
|
+
// observer threw -- retry still happens
|
|
29
44
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
// already dead
|
|
36
|
-
}
|
|
45
|
+
try {
|
|
46
|
+
askLevel?.(attempt, error);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// observer threw -- retry still happens
|
|
37
50
|
}
|
|
38
51
|
};
|
|
52
|
+
const KILL_TIMED_OUT = Symbol("kill-timed-out");
|
|
53
|
+
const gracefulKill = async (p) => {
|
|
54
|
+
safeKill(p, "SIGTERM");
|
|
55
|
+
const outcome = await withTimeout(p.exited, TIMEOUTS.gracefulExitMs, () => KILL_TIMED_OUT);
|
|
56
|
+
if (outcome === KILL_TIMED_OUT) {
|
|
57
|
+
safeKill(p, "SIGKILL");
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Creates a multi-turn Claude session backed by a single long-lived CLI
|
|
62
|
+
* process. Each `ask()` sends a user prompt and resolves with `TAskResult`
|
|
63
|
+
* for that turn. Calls are serialized -- a second `ask()` waits for the
|
|
64
|
+
* first to complete. Use `close()` (or `await using`) to free the process.
|
|
65
|
+
*
|
|
66
|
+
* ### Retry behavior
|
|
67
|
+
* Each `ask()` automatically retries transient failures -- process crashes
|
|
68
|
+
* matching SIGKILL/SIGTERM/SIGPIPE exit codes, `ECONNRESET`, `ECONNREFUSED`,
|
|
69
|
+
* `ETIMEDOUT`, `EHOSTUNREACH`, `ENETUNREACH`, `EAI_AGAIN`, Anthropic
|
|
70
|
+
* `overloaded_error` / 529s, broken-pipe / "socket hang up" messages, etc.
|
|
71
|
+
* (see `isTransientError`). Backoff is `500ms → 1s → 2s`; the budget is
|
|
72
|
+
* `LIMITS.maxRespawnAttempts` (currently 3) and is shared across a single
|
|
73
|
+
* `ask()`. When the budget is exhausted the session throws
|
|
74
|
+
* `KnownError("retry-exhausted")` and marks itself closed.
|
|
75
|
+
*
|
|
76
|
+
* Fatal errors -- `KnownError` and `BudgetExceededError` -- also close the
|
|
77
|
+
* session. Any subsequent `ask()` on a closed session rejects with
|
|
78
|
+
* `ClaudeError("Session is closed")`. All other errors (abort, timeout,
|
|
79
|
+
* non-transient `ProcessError`) propagate without closing, and the caller
|
|
80
|
+
* may decide whether to retry at a higher level.
|
|
81
|
+
*
|
|
82
|
+
* ### Observability
|
|
83
|
+
* - `onCostUpdate(snapshot)` -- fires after every `turn_complete`.
|
|
84
|
+
* - `onRetry(attempt, error)` -- fires each time a transient failure triggers
|
|
85
|
+
* a respawn inside one `ask()`. Attempt is 1-indexed.
|
|
86
|
+
* - `onWarning(message, cause)` -- routes all library-emitted warnings.
|
|
87
|
+
*/
|
|
39
88
|
export const createSession = (options = {}) => {
|
|
40
89
|
let proc;
|
|
41
90
|
let currentSessionId;
|
|
@@ -46,6 +95,7 @@ export const createSession = (options = {}) => {
|
|
|
46
95
|
const costTracker = createCostTracker({
|
|
47
96
|
maxCostUsd: options.maxCostUsd,
|
|
48
97
|
onCostUpdate: options.onCostUpdate,
|
|
98
|
+
onWarning: options.onWarning,
|
|
49
99
|
});
|
|
50
100
|
const toolHandler = options.tools ? createToolHandler(options.tools) : undefined;
|
|
51
101
|
let inFlight;
|
|
@@ -61,31 +111,11 @@ export const createSession = (options = {}) => {
|
|
|
61
111
|
}
|
|
62
112
|
reader = undefined;
|
|
63
113
|
};
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const decoder = new TextDecoder();
|
|
70
|
-
(async () => {
|
|
71
|
-
try {
|
|
72
|
-
while (true) {
|
|
73
|
-
const { done, value } = await stderrReader.read();
|
|
74
|
-
if (done) {
|
|
75
|
-
break;
|
|
76
|
-
}
|
|
77
|
-
chunks.push(decoder.decode(value, { stream: true }));
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
catch {
|
|
81
|
-
// process exited
|
|
82
|
-
}
|
|
83
|
-
finally {
|
|
84
|
-
stderrReader.releaseLock();
|
|
85
|
-
}
|
|
86
|
-
})().catch(() => { });
|
|
87
|
-
};
|
|
88
|
-
const getStderrText = () => lastStderrChunks.join("").trim();
|
|
114
|
+
// Drain handle from the most recent spawn. Stored as a whole so we
|
|
115
|
+
// can call `.text()` (shared helper on IStderrDrain) instead of
|
|
116
|
+
// reimplementing chunks.join/trim at every use site.
|
|
117
|
+
let lastStderrDrain;
|
|
118
|
+
const getStderrText = () => lastStderrDrain?.text() ?? "";
|
|
89
119
|
const killProc = () => {
|
|
90
120
|
if (proc) {
|
|
91
121
|
proc.kill();
|
|
@@ -97,15 +127,31 @@ export const createSession = (options = {}) => {
|
|
|
97
127
|
const spawnFresh = (prompt, resumeId) => {
|
|
98
128
|
if (consecutiveCrashes >= LIMITS.maxRespawnAttempts) {
|
|
99
129
|
killProc();
|
|
100
|
-
|
|
130
|
+
// Typed code so consumers can pattern-match on
|
|
131
|
+
// `KnownError && err.code === "retry-exhausted"` without parsing strings.
|
|
132
|
+
throw new KnownError("retry-exhausted", `Process crashed ${consecutiveCrashes} times, giving up`);
|
|
101
133
|
}
|
|
102
134
|
costOffsets = costTracker.snapshot();
|
|
103
135
|
killProc();
|
|
104
136
|
translator.reset();
|
|
137
|
+
// Respawn always overrides caller-supplied options.resume with the live
|
|
138
|
+
// session id when one is available: mid-session recovery must resume the
|
|
139
|
+
// same conversation, not whatever static id was passed at construction.
|
|
105
140
|
const spawnOpts = resumeId ? { prompt, ...options, resume: resumeId } : { prompt, ...options };
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
141
|
+
const pipeline = startPipeline(spawnOpts);
|
|
142
|
+
proc = pipeline.proc;
|
|
143
|
+
reader = pipeline.reader;
|
|
144
|
+
lastStderrDrain = pipeline.stderr;
|
|
145
|
+
};
|
|
146
|
+
const respawnBackoff = async () => {
|
|
147
|
+
// consecutiveCrashes starts at 1 for the first retry; idx points
|
|
148
|
+
// into RESPAWN_BACKOFF_MS and clamps to the last defined entry for
|
|
149
|
+
// any crash count beyond the table length.
|
|
150
|
+
const idx = Math.min(consecutiveCrashes, MAX_BACKOFF_INDEX) - 1;
|
|
151
|
+
const delay = idx >= 0 ? RESPAWN_BACKOFF_MS[idx] : 0;
|
|
152
|
+
if (delay) {
|
|
153
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
154
|
+
}
|
|
109
155
|
};
|
|
110
156
|
const readUntilTurnComplete = async (signal) => {
|
|
111
157
|
if (!proc || !reader) {
|
|
@@ -119,14 +165,14 @@ export const createSession = (options = {}) => {
|
|
|
119
165
|
toolHandler,
|
|
120
166
|
proc,
|
|
121
167
|
signal,
|
|
168
|
+
onWarning: options.onWarning,
|
|
122
169
|
})) {
|
|
123
170
|
if (event.type === "session_meta") {
|
|
124
171
|
currentSessionId = event.sessionId;
|
|
125
172
|
}
|
|
126
173
|
events.push(event);
|
|
127
174
|
if (event.type === "turn_complete") {
|
|
128
|
-
|
|
129
|
-
costTracker.checkBudget();
|
|
175
|
+
applyTurnComplete(event, costTracker, costOffsets);
|
|
130
176
|
gotTurnComplete = true;
|
|
131
177
|
break;
|
|
132
178
|
}
|
|
@@ -135,50 +181,68 @@ export const createSession = (options = {}) => {
|
|
|
135
181
|
if (signal?.aborted) {
|
|
136
182
|
throw new AbortError();
|
|
137
183
|
}
|
|
138
|
-
|
|
139
|
-
|
|
184
|
+
// stdout closed → process is dying. Wait briefly for exited so we
|
|
185
|
+
// can attach an exit code to the error; if it doesn't resolve in
|
|
186
|
+
// time, force-kill and leave exitCode undefined (→ non-transient).
|
|
187
|
+
let exitCode;
|
|
188
|
+
if (proc) {
|
|
189
|
+
const live = proc;
|
|
190
|
+
exitCode = await withTimeout(live.exited, TIMEOUTS.gracefulExitMs);
|
|
191
|
+
if (exitCode === undefined) {
|
|
192
|
+
live.kill();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (lastStderrDrain) {
|
|
196
|
+
await withTimeout(lastStderrDrain.done, TIMEOUTS.stderrDrainGraceMs);
|
|
197
|
+
}
|
|
198
|
+
throw processExitedEarly(getStderrText(), exitCode);
|
|
140
199
|
}
|
|
141
200
|
return events;
|
|
142
201
|
};
|
|
143
|
-
const doAsk = async (prompt) => {
|
|
202
|
+
const doAsk = async (prompt, askOpts) => {
|
|
144
203
|
if (!proc) {
|
|
145
204
|
spawnFresh(prompt, currentSessionId);
|
|
146
205
|
}
|
|
147
|
-
else {
|
|
206
|
+
else if (!safeWrite(proc, writer.user(prompt))) {
|
|
207
|
+
// stdin write failed -- process probably died. Try to respawn.
|
|
208
|
+
// spawnFresh can itself throw ProcessError synchronously when the
|
|
209
|
+
// respawn cap is already hit; surface as an Error like the retry
|
|
210
|
+
// loop below would, instead of a raw synchronous throw.
|
|
211
|
+
consecutiveCrashes++;
|
|
212
|
+
translator.reset();
|
|
148
213
|
try {
|
|
149
|
-
proc.write(writer.user(prompt));
|
|
150
|
-
}
|
|
151
|
-
catch {
|
|
152
|
-
consecutiveCrashes++;
|
|
153
|
-
translator.reset();
|
|
154
214
|
spawnFresh(prompt, currentSessionId);
|
|
155
215
|
}
|
|
216
|
+
catch (respawnError) {
|
|
217
|
+
killProc();
|
|
218
|
+
throw respawnError;
|
|
219
|
+
}
|
|
156
220
|
}
|
|
221
|
+
// Compose per-ask signal with session-level signal: either firing aborts.
|
|
222
|
+
const effectiveSignal = composeSignals(options.signal, askOpts?.signal);
|
|
157
223
|
let events;
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if (error instanceof AbortError || error instanceof TimeoutError) {
|
|
163
|
-
killProc();
|
|
164
|
-
throw error;
|
|
224
|
+
while (true) {
|
|
225
|
+
try {
|
|
226
|
+
events = await readUntilTurnComplete(effectiveSignal);
|
|
227
|
+
break;
|
|
165
228
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
events = await readUntilTurnComplete(options.signal);
|
|
229
|
+
catch (error) {
|
|
230
|
+
if (error instanceof AbortError || error instanceof TimeoutError) {
|
|
231
|
+
killProc();
|
|
232
|
+
throw error;
|
|
171
233
|
}
|
|
172
|
-
|
|
234
|
+
if (!isTransientError(error) || consecutiveCrashes >= LIMITS.maxRespawnAttempts) {
|
|
173
235
|
killProc();
|
|
174
236
|
translator.reset();
|
|
175
|
-
throw
|
|
237
|
+
throw error;
|
|
176
238
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
239
|
+
consecutiveCrashes++;
|
|
240
|
+
// Fire both session-level and per-ask onRetry. Both are safe-invoked
|
|
241
|
+
// so a throwing observer doesn't prevent the retry.
|
|
242
|
+
fireRetry(consecutiveCrashes, error, options.onRetry, askOpts?.onRetry);
|
|
243
|
+
await respawnBackoff();
|
|
244
|
+
spawnFresh(prompt, currentSessionId);
|
|
245
|
+
// Loop to retry; stops when budget exhausted or turn completes.
|
|
182
246
|
}
|
|
183
247
|
}
|
|
184
248
|
consecutiveCrashes = 0;
|
|
@@ -195,34 +259,42 @@ export const createSession = (options = {}) => {
|
|
|
195
259
|
return buildResult(events, costTracker, currentSessionId);
|
|
196
260
|
};
|
|
197
261
|
let closed = false;
|
|
198
|
-
const ask = (prompt) => {
|
|
262
|
+
const ask = (prompt, askOpts) => {
|
|
199
263
|
if (closed) {
|
|
200
264
|
return Promise.reject(new ClaudeError("Session is closed"));
|
|
201
265
|
}
|
|
202
266
|
const prev = inFlight ?? Promise.resolve();
|
|
203
267
|
const run = prev
|
|
204
|
-
.catch((
|
|
205
|
-
|
|
206
|
-
|
|
268
|
+
.catch(() => {
|
|
269
|
+
// Prior ask failure shouldn't prevent this one from running. Fatal
|
|
270
|
+
// errors (KnownError/BudgetExceededError) set `closed` in the .catch
|
|
271
|
+
// below, which the sync check above picks up on the NEXT ask.
|
|
272
|
+
})
|
|
273
|
+
.then(() => {
|
|
274
|
+
if (closed) {
|
|
275
|
+
throw new ClaudeError("Session is closed");
|
|
207
276
|
}
|
|
277
|
+
return doAsk(prompt, askOpts);
|
|
208
278
|
})
|
|
209
|
-
.
|
|
279
|
+
.catch((error) => {
|
|
280
|
+
if (error instanceof KnownError || error instanceof BudgetExceededError) {
|
|
281
|
+
closed = true;
|
|
282
|
+
}
|
|
283
|
+
throw error;
|
|
284
|
+
});
|
|
210
285
|
inFlight = run;
|
|
211
286
|
return run;
|
|
212
287
|
};
|
|
213
288
|
const close = async () => {
|
|
214
289
|
closed = true;
|
|
215
290
|
if (inFlight) {
|
|
216
|
-
|
|
291
|
+
// Cap the wait: a stuck reader.read() inside the queued ask would
|
|
292
|
+
// otherwise hang close() forever before gracefulKill gets a chance.
|
|
293
|
+
await withTimeout(inFlight.catch(() => { }), TIMEOUTS.gracefulExitMs);
|
|
217
294
|
inFlight = undefined;
|
|
218
295
|
}
|
|
219
296
|
if (proc) {
|
|
220
|
-
|
|
221
|
-
proc.write(writer.abort());
|
|
222
|
-
}
|
|
223
|
-
catch {
|
|
224
|
-
// stdin may already be closed
|
|
225
|
-
}
|
|
297
|
+
safeWrite(proc, writer.abort());
|
|
226
298
|
await gracefulKill(proc);
|
|
227
299
|
proc = undefined;
|
|
228
300
|
}
|
package/dist/stream.js
CHANGED
|
@@ -1,81 +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 { applyTurnComplete, buildResult, extractText, startPipeline } from "./pipeline.js";
|
|
6
7
|
import { readNdjsonEvents } from "./reader.js";
|
|
7
8
|
import { createToolHandler } from "./tools/handler.js";
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const done = (async () => {
|
|
13
|
-
try {
|
|
14
|
-
while (true) {
|
|
15
|
-
const { done: isDone, value } = await stderrReader.read();
|
|
16
|
-
if (isDone) {
|
|
17
|
-
break;
|
|
18
|
-
}
|
|
19
|
-
chunks.push(decoder.decode(value, { stream: true }));
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
catch {
|
|
23
|
-
// process exited
|
|
24
|
-
}
|
|
25
|
-
finally {
|
|
26
|
-
stderrReader.releaseLock();
|
|
27
|
-
}
|
|
28
|
-
})().catch(() => { });
|
|
29
|
-
return { chunks, done };
|
|
30
|
-
};
|
|
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.";
|
|
31
13
|
export const createStream = (prompt, options = {}) => {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const proc = spawnClaude({ prompt, ...options });
|
|
36
|
-
const stderr = drainStderr(proc);
|
|
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.
|
|
37
17
|
const translator = createTranslator();
|
|
38
18
|
const toolHandler = options.tools ? createToolHandler(options.tools) : undefined;
|
|
39
19
|
const costTracker = createCostTracker({
|
|
40
20
|
maxCostUsd: options.maxCostUsd,
|
|
41
21
|
onCostUpdate: options.onCostUpdate,
|
|
22
|
+
onWarning: options.onWarning,
|
|
42
23
|
});
|
|
24
|
+
let proc;
|
|
25
|
+
let stderr;
|
|
26
|
+
let stdoutReader;
|
|
43
27
|
let cachedGenerator;
|
|
28
|
+
const ensureSpawned = () => {
|
|
29
|
+
if (!proc) {
|
|
30
|
+
if (options.signal?.aborted) {
|
|
31
|
+
throw new AbortError();
|
|
32
|
+
}
|
|
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;
|
|
39
|
+
}
|
|
40
|
+
return proc;
|
|
41
|
+
};
|
|
44
42
|
const generate = async function* () {
|
|
45
|
-
const
|
|
43
|
+
const p = ensureSpawned();
|
|
44
|
+
// ensureSpawned always populates stdoutReader alongside proc. Typed
|
|
45
|
+
// assertion so consumers can treat it as non-null below.
|
|
46
|
+
const currentReader = stdoutReader;
|
|
46
47
|
let turnComplete = false;
|
|
47
48
|
try {
|
|
48
49
|
for await (const event of readNdjsonEvents({
|
|
49
|
-
reader:
|
|
50
|
+
reader: currentReader,
|
|
50
51
|
translator,
|
|
51
52
|
toolHandler,
|
|
52
|
-
proc,
|
|
53
|
+
proc: p,
|
|
53
54
|
signal: options.signal,
|
|
55
|
+
onWarning: options.onWarning,
|
|
54
56
|
})) {
|
|
55
57
|
if (event.type === "turn_complete") {
|
|
56
|
-
|
|
57
|
-
costTracker.checkBudget();
|
|
58
|
+
applyTurnComplete(event, costTracker);
|
|
58
59
|
turnComplete = true;
|
|
59
60
|
}
|
|
60
61
|
yield event;
|
|
61
62
|
}
|
|
62
63
|
if (!turnComplete) {
|
|
63
|
-
|
|
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
|
+
}
|
|
64
81
|
if (exitCode !== 0) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const stderrText = stderr.chunks.join("").trim();
|
|
68
|
-
if (stderrText) {
|
|
69
|
-
errorMessage = stderrText;
|
|
70
|
-
}
|
|
71
|
-
throw new ProcessError(errorMessage, exitCode);
|
|
82
|
+
const exitMsg = stderrText || `Claude process exited with code ${exitCode}`;
|
|
83
|
+
throw new ProcessError(exitMsg, exitCode);
|
|
72
84
|
}
|
|
73
|
-
throw
|
|
85
|
+
throw processExitedEarly(stderrText);
|
|
74
86
|
}
|
|
75
87
|
}
|
|
76
88
|
finally {
|
|
77
|
-
|
|
78
|
-
|
|
89
|
+
currentReader.releaseLock();
|
|
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
|
+
}
|
|
79
97
|
}
|
|
80
98
|
};
|
|
81
99
|
const bufferedEvents = [];
|
|
@@ -83,11 +101,12 @@ export const createStream = (prompt, options = {}) => {
|
|
|
83
101
|
const ensureConsumed = () => {
|
|
84
102
|
if (!consumePromise) {
|
|
85
103
|
if (cachedGenerator) {
|
|
86
|
-
throw new ClaudeError(
|
|
104
|
+
throw new ClaudeError(MIX_ITER_CONSUME);
|
|
87
105
|
}
|
|
106
|
+
const gen = generate();
|
|
107
|
+
cachedGenerator = gen;
|
|
88
108
|
consumePromise = (async () => {
|
|
89
|
-
|
|
90
|
-
for await (const event of { [Symbol.asyncIterator]: () => cachedGenerator }) {
|
|
109
|
+
for await (const event of gen) {
|
|
91
110
|
bufferedEvents.push(event);
|
|
92
111
|
}
|
|
93
112
|
})();
|
|
@@ -108,14 +127,21 @@ export const createStream = (prompt, options = {}) => {
|
|
|
108
127
|
return buildResult(bufferedEvents, costTracker, sessionId);
|
|
109
128
|
};
|
|
110
129
|
const cleanup = () => {
|
|
111
|
-
|
|
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.
|
|
137
|
+
if (proc) {
|
|
112
138
|
proc.kill();
|
|
113
139
|
}
|
|
114
140
|
};
|
|
115
141
|
return {
|
|
116
142
|
[Symbol.asyncIterator]: () => {
|
|
117
143
|
if (consumePromise) {
|
|
118
|
-
throw new ClaudeError(
|
|
144
|
+
throw new ClaudeError(MIX_ITER_CONSUME);
|
|
119
145
|
}
|
|
120
146
|
cachedGenerator ??= generate();
|
|
121
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/handler.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export const createToolHandler = (options = {}) => {
|
|
2
|
-
const { allowed, blocked, onToolUse } = options;
|
|
2
|
+
const { allowed, blocked, onToolUse, onError } = options;
|
|
3
3
|
const allowedSet = allowed ? new Set(allowed) : undefined;
|
|
4
4
|
const blockedSet = blocked ? new Set(blocked) : undefined;
|
|
5
5
|
const decide = async (tool) => {
|
|
@@ -9,10 +9,18 @@ export const createToolHandler = (options = {}) => {
|
|
|
9
9
|
if (allowedSet && !allowedSet.has(tool.toolName)) {
|
|
10
10
|
return "deny";
|
|
11
11
|
}
|
|
12
|
-
if (onToolUse) {
|
|
13
|
-
return
|
|
12
|
+
if (!onToolUse) {
|
|
13
|
+
return "approve";
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
return await onToolUse(tool);
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (!onError) {
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
return onError(error, tool);
|
|
14
23
|
}
|
|
15
|
-
return "approve";
|
|
16
24
|
};
|
|
17
25
|
return { decide };
|
|
18
26
|
};
|
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
|
};
|