@pivanov/claude-wire 0.0.3 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/dist/async.d.ts +10 -0
- package/dist/async.js +27 -0
- package/dist/constants.d.ts +2 -0
- package/dist/constants.js +7 -0
- package/dist/cost.d.ts +2 -0
- package/dist/cost.js +5 -2
- package/dist/errors.d.ts +2 -2
- package/dist/errors.js +14 -8
- package/dist/index.d.ts +5 -4
- package/dist/index.js +3 -3
- package/dist/parser/translator.js +6 -1
- package/dist/pipeline.d.ts +14 -4
- package/dist/pipeline.js +38 -18
- 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 +30 -2
- package/dist/session.js +129 -69
- package/dist/stream.js +60 -25
- 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/options.d.ts +42 -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/reader.js
CHANGED
|
@@ -2,6 +2,7 @@ import { LIMITS, TIMEOUTS } from "./constants.js";
|
|
|
2
2
|
import { AbortError, ClaudeError, TimeoutError } from "./errors.js";
|
|
3
3
|
import { parseLine } from "./parser/ndjson.js";
|
|
4
4
|
import { dispatchToolDecision } from "./pipeline.js";
|
|
5
|
+
import { safeKill, safeWrite } from "./process.js";
|
|
5
6
|
import { writer } from "./writer.js";
|
|
6
7
|
export const drainStderr = (proc) => {
|
|
7
8
|
const chunks = [];
|
|
@@ -29,7 +30,11 @@ export const drainStderr = (proc) => {
|
|
|
29
30
|
stderrReader.releaseLock();
|
|
30
31
|
}
|
|
31
32
|
})().catch(() => { });
|
|
32
|
-
return {
|
|
33
|
+
return {
|
|
34
|
+
chunks,
|
|
35
|
+
done,
|
|
36
|
+
text: () => chunks.join("").trim(),
|
|
37
|
+
};
|
|
33
38
|
};
|
|
34
39
|
export async function* readNdjsonEvents(opts) {
|
|
35
40
|
const { reader, translator, signal } = opts;
|
|
@@ -43,13 +48,29 @@ export async function* readNdjsonEvents(opts) {
|
|
|
43
48
|
});
|
|
44
49
|
// Swallow unhandled rejection if nothing ever races against this promise.
|
|
45
50
|
abortPromise.catch(() => { });
|
|
46
|
-
// Single resettable timeout shared across all iterations
|
|
51
|
+
// Single resettable timeout shared across all iterations -- avoids leaking
|
|
47
52
|
// a new Promise + setTimeout per read loop.
|
|
48
53
|
let timeoutReject;
|
|
49
54
|
const timeoutPromise = new Promise((_, reject) => {
|
|
50
55
|
timeoutReject = reject;
|
|
51
56
|
});
|
|
52
57
|
timeoutPromise.catch(() => { });
|
|
58
|
+
// Shared per-raw-event dispatch. Used by both the main read loop and the
|
|
59
|
+
// trailing-buffer flush so the translate → tool-dispatch → yield sequence
|
|
60
|
+
// lives in one place. `!turnComplete` guards dispatch so we don't approve
|
|
61
|
+
// or deny a tool call the CLI emits after it already said it's done.
|
|
62
|
+
const processRaw = async function* (raw) {
|
|
63
|
+
const translated = translator.translate(raw);
|
|
64
|
+
for (const event of translated) {
|
|
65
|
+
if (event.type === "tool_use" && !turnComplete && opts.toolHandler && opts.proc) {
|
|
66
|
+
await dispatchToolDecision(opts.proc, opts.toolHandler, event, opts.onWarning);
|
|
67
|
+
}
|
|
68
|
+
yield event;
|
|
69
|
+
if (event.type === "turn_complete") {
|
|
70
|
+
turnComplete = true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
};
|
|
53
74
|
const resetReadTimeout = () => {
|
|
54
75
|
if (timeoutId) {
|
|
55
76
|
clearTimeout(timeoutId);
|
|
@@ -62,13 +83,8 @@ export async function* readNdjsonEvents(opts) {
|
|
|
62
83
|
? () => {
|
|
63
84
|
abortReject?.(new AbortError());
|
|
64
85
|
if (opts.proc) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
catch {
|
|
69
|
-
// stdin closed
|
|
70
|
-
}
|
|
71
|
-
opts.proc.kill();
|
|
86
|
+
safeWrite(opts.proc, writer.abort());
|
|
87
|
+
safeKill(opts.proc);
|
|
72
88
|
}
|
|
73
89
|
}
|
|
74
90
|
: undefined;
|
|
@@ -90,8 +106,12 @@ export async function* readNdjsonEvents(opts) {
|
|
|
90
106
|
break;
|
|
91
107
|
}
|
|
92
108
|
buffer += decoder.decode(value, { stream: true });
|
|
109
|
+
// The limit applies to the accumulated buffer (which contains at most
|
|
110
|
+
// one in-progress line plus any already-split lines being held), so
|
|
111
|
+
// a single oversize line trips the same guard. Name is legacy -- the
|
|
112
|
+
// check is effectively "no NDJSON message may grow past this size".
|
|
93
113
|
if (buffer.length > LIMITS.ndjsonMaxLineChars) {
|
|
94
|
-
throw new ClaudeError(`NDJSON buffer exceeded ${LIMITS.ndjsonMaxLineChars} chars`);
|
|
114
|
+
throw new ClaudeError(`NDJSON buffer exceeded ${LIMITS.ndjsonMaxLineChars} chars (single line or accumulated pending lines)`);
|
|
95
115
|
}
|
|
96
116
|
const lines = buffer.split("\n");
|
|
97
117
|
buffer = lines.pop() ?? "";
|
|
@@ -100,16 +120,7 @@ export async function* readNdjsonEvents(opts) {
|
|
|
100
120
|
if (!raw) {
|
|
101
121
|
continue;
|
|
102
122
|
}
|
|
103
|
-
|
|
104
|
-
for (const event of events) {
|
|
105
|
-
if (event.type === "tool_use" && opts.toolHandler && opts.proc) {
|
|
106
|
-
await dispatchToolDecision(opts.proc, opts.toolHandler, event);
|
|
107
|
-
}
|
|
108
|
-
yield event;
|
|
109
|
-
if (event.type === "turn_complete") {
|
|
110
|
-
turnComplete = true;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
123
|
+
yield* processRaw(raw);
|
|
113
124
|
}
|
|
114
125
|
if (turnComplete) {
|
|
115
126
|
break;
|
|
@@ -118,16 +129,7 @@ export async function* readNdjsonEvents(opts) {
|
|
|
118
129
|
if (buffer.trim()) {
|
|
119
130
|
const raw = parseLine(buffer);
|
|
120
131
|
if (raw) {
|
|
121
|
-
|
|
122
|
-
for (const event of events) {
|
|
123
|
-
if (event.type === "tool_use" && opts.toolHandler && opts.proc && !turnComplete) {
|
|
124
|
-
await dispatchToolDecision(opts.proc, opts.toolHandler, event);
|
|
125
|
-
}
|
|
126
|
-
yield event;
|
|
127
|
-
if (event.type === "turn_complete") {
|
|
128
|
-
turnComplete = true;
|
|
129
|
-
}
|
|
130
|
-
}
|
|
132
|
+
yield* processRaw(raw);
|
|
131
133
|
}
|
|
132
134
|
}
|
|
133
135
|
}
|
package/dist/runtime.d.ts
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
|
-
|
|
1
|
+
interface IRawProcess {
|
|
2
2
|
stdin: {
|
|
3
3
|
write: (data: string) => void;
|
|
4
4
|
end: () => void;
|
|
5
5
|
};
|
|
6
6
|
stdout: ReadableStream<Uint8Array>;
|
|
7
7
|
stderr: ReadableStream<Uint8Array>;
|
|
8
|
-
kill: () => void;
|
|
8
|
+
kill: (signal?: NodeJS.Signals | number) => void;
|
|
9
9
|
exited: Promise<number>;
|
|
10
10
|
pid: number;
|
|
11
11
|
}
|
|
12
|
-
|
|
12
|
+
interface ISpawnOpts {
|
|
13
13
|
cwd?: string;
|
|
14
14
|
env?: Record<string, string | undefined>;
|
|
15
15
|
}
|
|
16
16
|
export declare const spawnProcess: (args: string[], opts: ISpawnOpts) => IRawProcess;
|
|
17
17
|
export declare const whichSync: (name: string) => string | undefined;
|
|
18
|
-
export declare const
|
|
18
|
+
export declare const isExecutableNonEmpty: (path: string) => boolean;
|
|
19
|
+
export {};
|
package/dist/runtime.js
CHANGED
|
@@ -30,7 +30,10 @@ export const whichSync = (name) => {
|
|
|
30
30
|
return undefined;
|
|
31
31
|
}
|
|
32
32
|
};
|
|
33
|
-
|
|
33
|
+
// Used to vet candidate `claude` binary paths -- a zero-byte stub or a
|
|
34
|
+
// non-executable regular file both count as "not a usable binary" here.
|
|
35
|
+
// Name reflects behavior: this is NOT a generic fs.exists check.
|
|
36
|
+
export const isExecutableNonEmpty = (path) => {
|
|
34
37
|
try {
|
|
35
38
|
accessSync(path, constants.X_OK);
|
|
36
39
|
return statSync(path).size > 0;
|
|
@@ -58,8 +61,8 @@ const spawnBun = (args, opts) => {
|
|
|
58
61
|
},
|
|
59
62
|
stdout: proc.stdout,
|
|
60
63
|
stderr: proc.stderr,
|
|
61
|
-
kill: () => {
|
|
62
|
-
proc.kill();
|
|
64
|
+
kill: (signal) => {
|
|
65
|
+
proc.kill(signal);
|
|
63
66
|
},
|
|
64
67
|
exited: proc.exited,
|
|
65
68
|
pid: proc.pid,
|
|
@@ -99,8 +102,8 @@ const spawnNode = (args, opts) => {
|
|
|
99
102
|
},
|
|
100
103
|
stdout: child.stdout ? toWeb(child.stdout) : new ReadableStream(),
|
|
101
104
|
stderr: child.stderr ? toWeb(child.stderr) : new ReadableStream(),
|
|
102
|
-
kill: () => {
|
|
103
|
-
child.kill();
|
|
105
|
+
kill: (signal) => {
|
|
106
|
+
child.kill(signal);
|
|
104
107
|
},
|
|
105
108
|
exited,
|
|
106
109
|
pid: child.pid,
|
package/dist/session.d.ts
CHANGED
|
@@ -1,8 +1,36 @@
|
|
|
1
|
-
import type { ISessionOptions } from "./types/options.js";
|
|
1
|
+
import type { IAskOptions, ISessionOptions } from "./types/options.js";
|
|
2
2
|
import type { TAskResult } from "./types/results.js";
|
|
3
3
|
export interface IClaudeSession extends AsyncDisposable {
|
|
4
|
-
ask: (prompt: string) => Promise<TAskResult>;
|
|
4
|
+
ask: (prompt: string, options?: IAskOptions) => Promise<TAskResult>;
|
|
5
5
|
close: () => Promise<void>;
|
|
6
6
|
sessionId: string | undefined;
|
|
7
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* Creates a multi-turn Claude session backed by a single long-lived CLI
|
|
10
|
+
* process. Each `ask()` sends a user prompt and resolves with `TAskResult`
|
|
11
|
+
* for that turn. Calls are serialized -- a second `ask()` waits for the
|
|
12
|
+
* first to complete. Use `close()` (or `await using`) to free the process.
|
|
13
|
+
*
|
|
14
|
+
* ### Retry behavior
|
|
15
|
+
* Each `ask()` automatically retries transient failures -- process crashes
|
|
16
|
+
* matching SIGKILL/SIGTERM/SIGPIPE exit codes, `ECONNRESET`, `ECONNREFUSED`,
|
|
17
|
+
* `ETIMEDOUT`, `EHOSTUNREACH`, `ENETUNREACH`, `EAI_AGAIN`, Anthropic
|
|
18
|
+
* `overloaded_error` / 529s, broken-pipe / "socket hang up" messages, etc.
|
|
19
|
+
* (see `isTransientError`). Backoff is `500ms → 1s → 2s`; the budget is
|
|
20
|
+
* `LIMITS.maxRespawnAttempts` (currently 3) and is shared across a single
|
|
21
|
+
* `ask()`. When the budget is exhausted the session throws
|
|
22
|
+
* `KnownError("retry-exhausted")` and marks itself closed.
|
|
23
|
+
*
|
|
24
|
+
* Fatal errors -- `KnownError` and `BudgetExceededError` -- also close the
|
|
25
|
+
* session. Any subsequent `ask()` on a closed session rejects with
|
|
26
|
+
* `ClaudeError("Session is closed")`. All other errors (abort, timeout,
|
|
27
|
+
* non-transient `ProcessError`) propagate without closing, and the caller
|
|
28
|
+
* may decide whether to retry at a higher level.
|
|
29
|
+
*
|
|
30
|
+
* ### Observability
|
|
31
|
+
* - `onCostUpdate(snapshot)` -- fires after every `turn_complete`.
|
|
32
|
+
* - `onRetry(attempt, error)` -- fires each time a transient failure triggers
|
|
33
|
+
* a respawn inside one `ask()`. Attempt is 1-indexed.
|
|
34
|
+
* - `onWarning(message, cause)` -- routes all library-emitted warnings.
|
|
35
|
+
*/
|
|
8
36
|
export declare const createSession: (options?: ISessionOptions) => IClaudeSession;
|
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 {
|
|
7
|
-
import {
|
|
6
|
+
import { applyTurnComplete, buildResult, startPipeline } from "./pipeline.js";
|
|
7
|
+
import { safeKill, safeWrite } from "./process.js";
|
|
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,9 +111,11 @@ export const createSession = (options = {}) => {
|
|
|
61
111
|
}
|
|
62
112
|
reader = undefined;
|
|
63
113
|
};
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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() ?? "";
|
|
67
119
|
const killProc = () => {
|
|
68
120
|
if (proc) {
|
|
69
121
|
proc.kill();
|
|
@@ -75,20 +127,27 @@ export const createSession = (options = {}) => {
|
|
|
75
127
|
const spawnFresh = (prompt, resumeId) => {
|
|
76
128
|
if (consecutiveCrashes >= LIMITS.maxRespawnAttempts) {
|
|
77
129
|
killProc();
|
|
78
|
-
|
|
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`);
|
|
79
133
|
}
|
|
80
134
|
costOffsets = costTracker.snapshot();
|
|
81
135
|
killProc();
|
|
82
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.
|
|
83
140
|
const spawnOpts = resumeId ? { prompt, ...options, resume: resumeId } : { prompt, ...options };
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
lastDrainDone = drain.done;
|
|
141
|
+
const pipeline = startPipeline(spawnOpts);
|
|
142
|
+
proc = pipeline.proc;
|
|
143
|
+
reader = pipeline.reader;
|
|
144
|
+
lastStderrDrain = pipeline.stderr;
|
|
89
145
|
};
|
|
90
146
|
const respawnBackoff = async () => {
|
|
91
|
-
|
|
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;
|
|
92
151
|
const delay = idx >= 0 ? RESPAWN_BACKOFF_MS[idx] : 0;
|
|
93
152
|
if (delay) {
|
|
94
153
|
await new Promise((r) => setTimeout(r, delay));
|
|
@@ -106,14 +165,14 @@ export const createSession = (options = {}) => {
|
|
|
106
165
|
toolHandler,
|
|
107
166
|
proc,
|
|
108
167
|
signal,
|
|
168
|
+
onWarning: options.onWarning,
|
|
109
169
|
})) {
|
|
110
170
|
if (event.type === "session_meta") {
|
|
111
171
|
currentSessionId = event.sessionId;
|
|
112
172
|
}
|
|
113
173
|
events.push(event);
|
|
114
174
|
if (event.type === "turn_complete") {
|
|
115
|
-
|
|
116
|
-
costTracker.checkBudget();
|
|
175
|
+
applyTurnComplete(event, costTracker, costOffsets);
|
|
117
176
|
gotTurnComplete = true;
|
|
118
177
|
break;
|
|
119
178
|
}
|
|
@@ -122,46 +181,49 @@ export const createSession = (options = {}) => {
|
|
|
122
181
|
if (signal?.aborted) {
|
|
123
182
|
throw new AbortError();
|
|
124
183
|
}
|
|
125
|
-
// stdout closed → process is dying.
|
|
126
|
-
//
|
|
127
|
-
//
|
|
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).
|
|
128
187
|
let exitCode;
|
|
129
188
|
if (proc) {
|
|
130
189
|
const live = proc;
|
|
131
|
-
exitCode = await
|
|
132
|
-
live.exited,
|
|
133
|
-
new Promise((r) => setTimeout(() => r(undefined), TIMEOUTS.gracefulExitMs)),
|
|
134
|
-
]);
|
|
190
|
+
exitCode = await withTimeout(live.exited, TIMEOUTS.gracefulExitMs);
|
|
135
191
|
if (exitCode === undefined) {
|
|
136
192
|
live.kill();
|
|
137
193
|
}
|
|
138
194
|
}
|
|
139
|
-
if (
|
|
140
|
-
await
|
|
195
|
+
if (lastStderrDrain) {
|
|
196
|
+
await withTimeout(lastStderrDrain.done, TIMEOUTS.stderrDrainGraceMs);
|
|
141
197
|
}
|
|
142
|
-
|
|
143
|
-
throw new ProcessError(stderrMsg || "Process exited without completing the turn", exitCode);
|
|
198
|
+
throw processExitedEarly(getStderrText(), exitCode);
|
|
144
199
|
}
|
|
145
200
|
return events;
|
|
146
201
|
};
|
|
147
|
-
const doAsk = async (prompt) => {
|
|
202
|
+
const doAsk = async (prompt, askOpts) => {
|
|
148
203
|
if (!proc) {
|
|
149
204
|
spawnFresh(prompt, currentSessionId);
|
|
150
205
|
}
|
|
151
|
-
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();
|
|
152
213
|
try {
|
|
153
|
-
proc.write(writer.user(prompt));
|
|
154
|
-
}
|
|
155
|
-
catch {
|
|
156
|
-
consecutiveCrashes++;
|
|
157
|
-
translator.reset();
|
|
158
214
|
spawnFresh(prompt, currentSessionId);
|
|
159
215
|
}
|
|
216
|
+
catch (respawnError) {
|
|
217
|
+
killProc();
|
|
218
|
+
throw respawnError;
|
|
219
|
+
}
|
|
160
220
|
}
|
|
221
|
+
// Compose per-ask signal with session-level signal: either firing aborts.
|
|
222
|
+
const effectiveSignal = composeSignals(options.signal, askOpts?.signal);
|
|
161
223
|
let events;
|
|
162
224
|
while (true) {
|
|
163
225
|
try {
|
|
164
|
-
events = await readUntilTurnComplete(
|
|
226
|
+
events = await readUntilTurnComplete(effectiveSignal);
|
|
165
227
|
break;
|
|
166
228
|
}
|
|
167
229
|
catch (error) {
|
|
@@ -175,6 +237,9 @@ export const createSession = (options = {}) => {
|
|
|
175
237
|
throw error;
|
|
176
238
|
}
|
|
177
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);
|
|
178
243
|
await respawnBackoff();
|
|
179
244
|
spawnFresh(prompt, currentSessionId);
|
|
180
245
|
// Loop to retry; stops when budget exhausted or turn completes.
|
|
@@ -194,7 +259,7 @@ export const createSession = (options = {}) => {
|
|
|
194
259
|
return buildResult(events, costTracker, currentSessionId);
|
|
195
260
|
};
|
|
196
261
|
let closed = false;
|
|
197
|
-
const ask = (prompt) => {
|
|
262
|
+
const ask = (prompt, askOpts) => {
|
|
198
263
|
if (closed) {
|
|
199
264
|
return Promise.reject(new ClaudeError("Session is closed"));
|
|
200
265
|
}
|
|
@@ -209,7 +274,7 @@ export const createSession = (options = {}) => {
|
|
|
209
274
|
if (closed) {
|
|
210
275
|
throw new ClaudeError("Session is closed");
|
|
211
276
|
}
|
|
212
|
-
return doAsk(prompt);
|
|
277
|
+
return doAsk(prompt, askOpts);
|
|
213
278
|
})
|
|
214
279
|
.catch((error) => {
|
|
215
280
|
if (error instanceof KnownError || error instanceof BudgetExceededError) {
|
|
@@ -225,16 +290,11 @@ export const createSession = (options = {}) => {
|
|
|
225
290
|
if (inFlight) {
|
|
226
291
|
// Cap the wait: a stuck reader.read() inside the queued ask would
|
|
227
292
|
// otherwise hang close() forever before gracefulKill gets a chance.
|
|
228
|
-
await
|
|
293
|
+
await withTimeout(inFlight.catch(() => { }), TIMEOUTS.gracefulExitMs);
|
|
229
294
|
inFlight = undefined;
|
|
230
295
|
}
|
|
231
296
|
if (proc) {
|
|
232
|
-
|
|
233
|
-
proc.write(writer.abort());
|
|
234
|
-
}
|
|
235
|
-
catch {
|
|
236
|
-
// stdin may already be closed
|
|
237
|
-
}
|
|
297
|
+
safeWrite(proc, writer.abort());
|
|
238
298
|
await gracefulKill(proc);
|
|
239
299
|
proc = undefined;
|
|
240
300
|
}
|