@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/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 { chunks, done };
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 avoids leaking
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
- try {
66
- opts.proc.write(writer.abort());
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
- const events = translator.translate(raw);
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
- const events = translator.translate(raw);
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
- export interface IRawProcess {
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
- export interface ISpawnOpts {
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 fileExists: (path: string) => boolean;
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
- export const fileExists = (path) => {
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 { LIMITS, RESPAWN_BACKOFF_MS, TIMEOUTS } from "./constants.js";
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, ProcessError, TimeoutError } from "./errors.js";
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 { spawnClaude } from "./process.js";
7
- import { drainStderr, readNdjsonEvents } from "./reader.js";
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
- const gracefulKill = async (p) => {
11
- p.kill();
12
- let timer;
13
- let timedOut = false;
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
- await Promise.race([
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
- finally {
26
- if (timer) {
27
- clearTimeout(timer);
28
- }
42
+ catch {
43
+ // observer threw -- retry still happens
29
44
  }
30
- if (timedOut) {
31
- try {
32
- p.kill();
33
- }
34
- catch {
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
- let lastStderrChunks = [];
65
- let lastDrainDone;
66
- 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() ?? "";
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
- throw new ProcessError(`Process crashed ${consecutiveCrashes} times, giving up`);
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
- proc = spawnClaude(spawnOpts);
85
- reader = proc.stdout.getReader();
86
- const drain = drainStderr(proc);
87
- lastStderrChunks = drain.chunks;
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
- const idx = Math.min(consecutiveCrashes, RESPAWN_BACKOFF_MS.length) - 1;
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
- costTracker.update(costOffsets.totalUsd + (event.costUsd ?? 0), costOffsets.inputTokens + (event.inputTokens ?? 0), costOffsets.outputTokens + (event.outputTokens ?? 0));
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. Race `exited` against a short
126
- // timeout so a zombie/unreaped child doesn't hang us. If exited doesn't
127
- // resolve in time we leave exitCode undefined (→ non-transient).
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 Promise.race([
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 (lastDrainDone) {
140
- await Promise.race([lastDrainDone, new Promise((r) => setTimeout(r, 500))]);
195
+ if (lastStderrDrain) {
196
+ await withTimeout(lastStderrDrain.done, TIMEOUTS.stderrDrainGraceMs);
141
197
  }
142
- const stderrMsg = getStderrText();
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(options.signal);
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 Promise.race([inFlight.catch(() => { }), new Promise((r) => setTimeout(r, TIMEOUTS.gracefulExitMs))]);
293
+ await withTimeout(inFlight.catch(() => { }), TIMEOUTS.gracefulExitMs);
229
294
  inFlight = undefined;
230
295
  }
231
296
  if (proc) {
232
- try {
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
  }