@basein/runner 0.2.11 → 0.3.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.
Files changed (144) hide show
  1. package/README.md +540 -350
  2. package/dist/auth/client.d.ts +65 -3
  3. package/dist/auth/client.js +305 -53
  4. package/dist/bin/bir-hooks.d.ts +72 -31
  5. package/dist/bin/bir-hooks.js +176 -284
  6. package/dist/bin/bir-proxy.d.ts +17 -2
  7. package/dist/bin/bir-proxy.js +222 -28
  8. package/dist/bin/bir-scenario.d.ts +61 -10
  9. package/dist/bin/bir-scenario.js +704 -135
  10. package/dist/bin/bir.d.ts +14 -8
  11. package/dist/bin/bir.js +2937 -488
  12. package/dist/bin/investigate.js +41 -16
  13. package/dist/bin/scenario-edit.d.ts +43 -10
  14. package/dist/bin/scenario-edit.js +281 -80
  15. package/dist/bin/scenario-liveness.d.ts +194 -0
  16. package/dist/bin/scenario-liveness.js +459 -0
  17. package/dist/bin/scenario-stderr.d.ts +43 -0
  18. package/dist/bin/scenario-stderr.js +76 -0
  19. package/dist/bin/service-read.d.ts +88 -0
  20. package/dist/bin/service-read.js +128 -0
  21. package/dist/bin/setup.d.ts +19 -0
  22. package/dist/bin/setup.js +146 -20
  23. package/dist/config/adapters/claude-code.d.ts +92 -14
  24. package/dist/config/adapters/claude-code.js +291 -101
  25. package/dist/config/adapters/generic.d.ts +4 -1
  26. package/dist/config/adapters/generic.js +13 -14
  27. package/dist/config/generate.d.ts +392 -14
  28. package/dist/config/generate.js +666 -19
  29. package/dist/config/mcp-approval.d.ts +76 -0
  30. package/dist/config/mcp-approval.js +210 -0
  31. package/dist/config/node-path.d.ts +42 -0
  32. package/dist/config/node-path.js +102 -0
  33. package/dist/config/overrides.d.ts +62 -0
  34. package/dist/config/overrides.js +136 -0
  35. package/dist/config/resolve.d.ts +70 -8
  36. package/dist/config/resolve.js +186 -15
  37. package/dist/control/client.d.ts +22 -0
  38. package/dist/control/client.js +26 -7
  39. package/dist/control/correlation.d.ts +23 -5
  40. package/dist/control/correlation.js +0 -0
  41. package/dist/control/daemon.d.ts +400 -18
  42. package/dist/control/daemon.js +1105 -102
  43. package/dist/control/discovery.d.ts +77 -5
  44. package/dist/control/discovery.js +186 -11
  45. package/dist/control/ensure-hook.d.ts +61 -0
  46. package/dist/control/ensure-hook.js +160 -8
  47. package/dist/control/paths.d.ts +26 -0
  48. package/dist/control/paths.js +34 -0
  49. package/dist/control/pin-guard.d.ts +83 -0
  50. package/dist/control/pin-guard.js +534 -0
  51. package/dist/control/recorder-keeper.d.ts +229 -0
  52. package/dist/control/recorder-keeper.js +721 -0
  53. package/dist/control/recorder-main.d.ts +73 -0
  54. package/dist/control/recorder-main.js +778 -0
  55. package/dist/control/server.d.ts +707 -31
  56. package/dist/control/server.js +2803 -306
  57. package/dist/control/transcript.d.ts +21 -1
  58. package/dist/control/transcript.js +378 -112
  59. package/dist/index.d.ts +5 -1
  60. package/dist/index.js +5 -1
  61. package/dist/jsonrpc/framing.d.ts +76 -12
  62. package/dist/jsonrpc/framing.js +216 -59
  63. package/dist/proxy/intercept.d.ts +49 -5
  64. package/dist/proxy/intercept.js +188 -59
  65. package/dist/proxy/relay.d.ts +16 -0
  66. package/dist/proxy/relay.js +77 -5
  67. package/dist/proxy/revive.d.ts +122 -0
  68. package/dist/proxy/revive.js +251 -0
  69. package/dist/proxy/session.d.ts +222 -14
  70. package/dist/proxy/session.js +708 -99
  71. package/dist/record/housekeeping.d.ts +71 -0
  72. package/dist/record/housekeeping.js +417 -0
  73. package/dist/record/recorder.d.ts +51 -3
  74. package/dist/record/recorder.js +3 -0
  75. package/dist/record/redact.d.ts +103 -9
  76. package/dist/record/redact.js +659 -30
  77. package/dist/record/remote-recorder.d.ts +327 -32
  78. package/dist/record/remote-recorder.js +1517 -187
  79. package/dist/record/switchable-recorder.d.ts +126 -0
  80. package/dist/record/switchable-recorder.js +351 -0
  81. package/dist/record/truncate.d.ts +40 -1
  82. package/dist/record/truncate.js +111 -4
  83. package/dist/replay/bash-channel.d.ts +35 -0
  84. package/dist/replay/bash-channel.js +133 -0
  85. package/dist/replay/bundle.d.ts +10 -0
  86. package/dist/replay/bundle.js +10 -0
  87. package/dist/replay/controller.d.ts +371 -20
  88. package/dist/replay/controller.js +1185 -111
  89. package/dist/replay/derive.d.ts +1 -0
  90. package/dist/replay/derive.js +81 -24
  91. package/dist/replay/executor.d.ts +51 -16
  92. package/dist/replay/executor.js +114 -41
  93. package/dist/replay/handover.d.ts +54 -1
  94. package/dist/replay/handover.js +55 -5
  95. package/dist/replay/logic.d.ts +62 -10
  96. package/dist/replay/logic.js +140 -22
  97. package/dist/replay/plan.d.ts +201 -27
  98. package/dist/replay/plan.js +581 -141
  99. package/dist/replay/safety.d.ts +72 -0
  100. package/dist/replay/safety.js +303 -0
  101. package/dist/replay/source-run.d.ts +10 -0
  102. package/dist/replay/source-run.js +80 -35
  103. package/dist/replay/targets.d.ts +15 -1
  104. package/dist/replay/targets.js +62 -4
  105. package/dist/replay/tool-error.d.ts +15 -1
  106. package/dist/replay/tool-error.js +33 -1
  107. package/dist/replay/types.d.ts +7 -4
  108. package/dist/upstream/client.d.ts +56 -3
  109. package/dist/upstream/client.js +61 -4
  110. package/dist/upstream/headers.d.ts +43 -0
  111. package/dist/upstream/headers.js +90 -0
  112. package/dist/upstream/http-client.d.ts +213 -22
  113. package/dist/upstream/http-client.js +1034 -168
  114. package/dist/upstream/http-transport.d.ts +143 -0
  115. package/dist/upstream/http-transport.js +391 -0
  116. package/dist/upstream/lazy-client.d.ts +8 -0
  117. package/dist/upstream/lazy-client.js +29 -1
  118. package/dist/upstream/sse.d.ts +78 -0
  119. package/dist/upstream/sse.js +189 -0
  120. package/dist/upstream/stdio-client.d.ts +61 -1
  121. package/dist/upstream/stdio-client.js +356 -33
  122. package/dist/util/atomic-write.d.ts +29 -0
  123. package/dist/util/atomic-write.js +96 -0
  124. package/dist/util/journal.d.ts +15 -0
  125. package/dist/util/journal.js +48 -3
  126. package/dist/util/log.d.ts +10 -0
  127. package/dist/util/log.js +28 -1
  128. package/dist/util/off-switch.d.ts +46 -0
  129. package/dist/util/off-switch.js +91 -0
  130. package/dist/util/service-health.d.ts +133 -0
  131. package/dist/util/service-health.js +217 -0
  132. package/docs/BaseInstRunner.md +694 -621
  133. package/docs/calculatedReplay.md +1625 -1244
  134. package/docs/calculatedReplayGuide.md +1212 -879
  135. package/docs/installRun.md +1013 -772
  136. package/docs/loginWeb.md +627 -607
  137. package/docs/mcpmark.md +49 -16
  138. package/docs/my-first-sample.md +569 -545
  139. package/docs/quickstart.md +384 -271
  140. package/docs/t-bench.md +406 -394
  141. package/package.json +58 -57
  142. package/production.md +1371 -0
  143. package/scripts/install.ps1 +97 -6
  144. package/scripts/install.sh +104 -7
@@ -14,17 +14,92 @@
14
14
  * mangles JSON), {@link resolveCommand} resolves the real file on PATH and,
15
15
  * for a batch file, invokes `cmd.exe /d /s /c` with `windowsVerbatimArguments`
16
16
  * and its own quoting.
17
+ *
18
+ * AND ONE THING THAT BREAKS THE USER'S MACHINE: a server the proxy started must
19
+ * not outlive it. On Windows a batch-file upstream is `cmd.exe` → `node` (or
20
+ * `python`, or Chrome), and killing `cmd.exe` leaves the rest running — holding
21
+ * a browser profile or a database lock the next session then cannot open. So
22
+ * {@link StdioUpstreamClient.shutdown} closes stdin first (the MCP-spec way to
23
+ * ask a server to exit), waits briefly, and only then kills the whole tree.
17
24
  */
18
25
  import { spawn } from "node:child_process";
19
26
  import { existsSync } from "node:fs";
20
27
  import { delimiter, isAbsolute, join } from "node:path";
21
- import { readFrames, writeFrame } from "../jsonrpc/framing.js";
28
+ import { DEFAULT_MAX_LINE_BYTES, classifyOversizedLine, readFrames, writeFrame } from "../jsonrpc/framing.js";
29
+ import { isRequest } from "../jsonrpc/types.js";
22
30
  import { logDetail, logLine, errText } from "../util/log.js";
23
- import { BaseUpstreamClient } from "./client.js";
31
+ import { timeoutSignal } from "../util/service-health.js";
32
+ import { BaseUpstreamClient, isBirId } from "./client.js";
24
33
  const BATCH_EXT = /\.(cmd|bat)$/i;
25
- /** Quote one argument for `cmd.exe /c`, which re-parses what it is handed. */
26
- function quoteForCmd(arg) {
27
- return '"' + arg.replace(/"/g, '""') + '"';
34
+ /** A forwarded stderr line longer than this is flushed without waiting for its newline. */
35
+ const MAX_STDERR_LINE = 8 * 1024;
36
+ /** Messages held while a `--restart` is in progress. Past this, the oldest are dropped. */
37
+ const MAX_RESTART_BUFFER = 1_000;
38
+ /** How long a restarted upstream gets to answer the replayed `initialize`. */
39
+ const REINIT_TIMEOUT_MS = 15_000;
40
+ /** Default time a closing upstream gets to exit on stdin EOF before it is killed. */
41
+ export const DEFAULT_SHUTDOWN_GRACE_MS = 1_500;
42
+ /**
43
+ * Quote one argument for `cmd.exe /c` and, behind it, the C runtime's argv
44
+ * parser in whatever the batch file launches.
45
+ *
46
+ * Three rules, each for a case that used to arrive mangled:
47
+ * - An embedded `"` becomes `""`, which cmd reads as leaving and re-entering
48
+ * a quoted run (so its metacharacters stay quoted) and the C runtime reads
49
+ * as one literal quote.
50
+ * - Backslashes before a quote — an embedded one or our closing one — are
51
+ * doubled, because the C runtime reads `\"` as an escaped quote.
52
+ * `C:\projects\` used to swallow the next argument whole.
53
+ * - `%` is taken *out* of the quotes and caret-escaped: cmd expands `%NAME%`
54
+ * even inside quotes, and a caret is literal there. Outside them, `^%`
55
+ * survives expansion (the text between the two percents is no longer a
56
+ * variable name, because it contains a quote) and the caret is removed
57
+ * afterwards, leaving `%`. The C runtime then joins the pieces:
58
+ * `"a"%"B"%"c"` is the single argument `a%B%c`.
59
+ */
60
+ export function quoteForCmd(arg) {
61
+ return arg
62
+ .split("%")
63
+ .map((piece) => '"' + piece.replace(/(\\*)"/g, '$1$1""').replace(/(\\+)$/, "$1$1") + '"')
64
+ .join("^%");
65
+ }
66
+ /**
67
+ * Read an environment variable the way Windows does — case-insensitively.
68
+ *
69
+ * `process.env` is case-insensitive on Windows, but a *copy* of it (`{ ...process.env }`,
70
+ * which is what spawning with extra variables needs) is a plain object, and a
71
+ * normal Windows process spells the key `Path`. Reading `.PATH` off the copy
72
+ * found nothing, `npx` stayed unresolved, and every npx-launched server failed
73
+ * with `spawn npx ENOENT` whenever a host passed its environment through.
74
+ */
75
+ export function envValue(env, key) {
76
+ if (env[key] !== undefined)
77
+ return env[key];
78
+ if (process.platform !== "win32")
79
+ return undefined;
80
+ const upper = key.toUpperCase();
81
+ const match = Object.keys(env).find((k) => k.toUpperCase() === upper);
82
+ return match === undefined ? undefined : env[match];
83
+ }
84
+ /**
85
+ * `base` overlaid with `extra`. On Windows a key in `extra` replaces *every*
86
+ * spelling of it in `base`: spawn keeps only one of `Path`/`PATH`, and it
87
+ * would not necessarily be the one the caller meant to set.
88
+ */
89
+ function mergeEnv(base, extra) {
90
+ const out = { ...base };
91
+ if (!extra)
92
+ return out;
93
+ for (const [key, value] of Object.entries(extra)) {
94
+ if (process.platform === "win32") {
95
+ const upper = key.toUpperCase();
96
+ for (const k of Object.keys(out))
97
+ if (k !== key && k.toUpperCase() === upper)
98
+ delete out[k];
99
+ }
100
+ out[key] = value;
101
+ }
102
+ return out;
28
103
  }
29
104
  /** Find `command` on PATH, trying PATHEXT extensions. Returns undefined if absent. */
30
105
  export function whichSync(command, env = process.env) {
@@ -32,9 +107,9 @@ export function whichSync(command, env = process.env) {
32
107
  return existsSync(command) ? command : undefined;
33
108
  }
34
109
  const exts = process.platform === "win32"
35
- ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)
110
+ ? (envValue(env, "PATHEXT") ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)
36
111
  : [""];
37
- const dirs = (env.PATH ?? "").split(delimiter).filter(Boolean);
112
+ const dirs = (envValue(env, "PATH") ?? "").split(delimiter).filter(Boolean);
38
113
  for (const dir of dirs) {
39
114
  for (const ext of exts) {
40
115
  const candidate = join(dir, command + ext);
@@ -54,7 +129,7 @@ export function resolveCommand(command, args, env = process.env) {
54
129
  const resolved = whichSync(command, env) ?? command;
55
130
  if (!BATCH_EXT.test(resolved))
56
131
  return { command: resolved, args };
57
- const comspec = env.ComSpec ?? env.COMSPEC ?? "cmd.exe";
132
+ const comspec = envValue(env, "ComSpec") ?? "cmd.exe";
58
133
  const line = [resolved, ...args].map(quoteForCmd).join(" ");
59
134
  return {
60
135
  command: comspec,
@@ -64,6 +139,88 @@ export function resolveCommand(command, args, env = process.env) {
64
139
  windowsVerbatimArguments: true,
65
140
  };
66
141
  }
142
+ /** Resolve when `child` has exited, or after `ms`. True when it exited. */
143
+ function waitForExit(child, ms) {
144
+ if (child.exitCode !== null || child.signalCode !== null)
145
+ return Promise.resolve(true);
146
+ return new Promise((resolve) => {
147
+ const onExit = () => {
148
+ clearTimeout(timer);
149
+ resolve(true);
150
+ };
151
+ const timer = setTimeout(() => {
152
+ child.off("exit", onExit);
153
+ resolve(false);
154
+ }, ms);
155
+ timer.unref?.();
156
+ child.once("exit", onExit);
157
+ });
158
+ }
159
+ /**
160
+ * Kill `child` and everything it started. On Windows that takes `taskkill /T`
161
+ * (a plain kill terminates one process: `cmd.exe`, not the server behind it);
162
+ * elsewhere SIGTERM, then SIGKILL. Bounded; never throws.
163
+ */
164
+ async function killTree(child) {
165
+ const pid = child.pid;
166
+ if (pid === undefined)
167
+ return;
168
+ if (process.platform === "win32") {
169
+ await new Promise((resolve) => {
170
+ let killer;
171
+ try {
172
+ killer = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], {
173
+ windowsHide: true,
174
+ stdio: "ignore",
175
+ });
176
+ }
177
+ catch {
178
+ resolve();
179
+ return;
180
+ }
181
+ const timer = setTimeout(() => {
182
+ try {
183
+ killer.kill();
184
+ }
185
+ catch {
186
+ /* already gone */
187
+ }
188
+ resolve();
189
+ }, 3_000);
190
+ timer.unref?.();
191
+ const done = () => {
192
+ clearTimeout(timer);
193
+ resolve();
194
+ };
195
+ killer.once("exit", done);
196
+ killer.once("error", done);
197
+ });
198
+ // taskkill failing (no such binary, access denied) must still end our child.
199
+ if (child.exitCode === null && child.signalCode === null) {
200
+ try {
201
+ child.kill();
202
+ }
203
+ catch {
204
+ /* already gone */
205
+ }
206
+ }
207
+ return;
208
+ }
209
+ try {
210
+ child.kill("SIGTERM");
211
+ }
212
+ catch {
213
+ return;
214
+ }
215
+ if (!(await waitForExit(child, 1_000))) {
216
+ try {
217
+ child.kill("SIGKILL");
218
+ }
219
+ catch {
220
+ /* already gone */
221
+ }
222
+ }
223
+ }
67
224
  export class StdioUpstreamClient extends BaseUpstreamClient {
68
225
  ready;
69
226
  child;
@@ -72,6 +229,17 @@ export class StdioUpstreamClient extends BaseUpstreamClient {
72
229
  opts;
73
230
  restartsLeft;
74
231
  closing = false;
232
+ /**
233
+ * Set while a `--restart` is in progress: host messages wait here for the
234
+ * new process to be spawned and re-initialised, instead of being written
235
+ * into a dead pipe and lost.
236
+ */
237
+ restartBuffer;
238
+ /** True while our replayed `initialize` is the one message allowed through. */
239
+ handshaking = false;
240
+ /** The host's `initialize` params, replayed to a restarted upstream. */
241
+ hostInitParams;
242
+ hostInitialized = false;
75
243
  constructor(opts) {
76
244
  super();
77
245
  this.opts = opts;
@@ -81,7 +249,7 @@ export class StdioUpstreamClient extends BaseUpstreamClient {
81
249
  }
82
250
  start() {
83
251
  const { command, args = [], env, cwd } = this.opts;
84
- const spawnEnv = { ...process.env, ...env };
252
+ const spawnEnv = mergeEnv(process.env, env);
85
253
  const resolved = resolveCommand(command, args, spawnEnv);
86
254
  return new Promise((resolve, reject) => {
87
255
  let child;
@@ -100,8 +268,16 @@ export class StdioUpstreamClient extends BaseUpstreamClient {
100
268
  return;
101
269
  }
102
270
  this.child = child;
271
+ // 'error' and 'exit' can both fire for one child; it is gone once.
272
+ let gone = false;
273
+ const goneOnce = (info) => {
274
+ if (gone)
275
+ return;
276
+ gone = true;
277
+ this.onChildGone(child, info);
278
+ };
103
279
  child.once("spawn", () => {
104
- logLine("upstream.spawned", {
280
+ logDetail("upstream.spawned", {
105
281
  server: this.label,
106
282
  pid: child.pid,
107
283
  command: resolved.command,
@@ -111,33 +287,82 @@ export class StdioUpstreamClient extends BaseUpstreamClient {
111
287
  child.once("error", (err) => {
112
288
  // A spawn failure (ENOENT) and a mid-life error arrive the same way.
113
289
  reject(err);
114
- this.onChildGone({ error: err });
290
+ goneOnce({ error: err });
115
291
  });
116
- child.once("exit", (code, signal) => this.onChildGone({ code, signal }));
292
+ child.once("exit", (code, signal) => goneOnce({ code, signal }));
293
+ // A child that dies with our write in flight raises EPIPE on its stdin;
294
+ // unhandled, that would be an uncaught exception in the proxy.
295
+ child.stdin.on("error", (err) => logDetail("upstream.stdin_error", { server: this.label, error: errText(err) }));
296
+ const maxLineBytes = this.opts.maxLineBytes ?? DEFAULT_MAX_LINE_BYTES;
117
297
  this.detach = readFrames(child.stdout, {
118
298
  onMessage: (msg) => this.accept(msg),
119
- onParseError: (d) => logLine("upstream.unparseable", {
120
- server: this.label,
121
- reason: d.reason,
122
- bytes: d.bytes,
123
- }),
124
- });
125
- // Upstream stderr → our stderr, never our stdout. Line-prefixed.
299
+ onParseError: (d) => {
300
+ logLine("upstream.unparseable", {
301
+ server: this.label,
302
+ reason: d.reason,
303
+ bytes: d.bytes,
304
+ });
305
+ if (!d.oversized)
306
+ return;
307
+ const shape = classifyOversizedLine(d.sample, d.tail);
308
+ if (shape.kind === "request") {
309
+ // A notification (a huge log or progress line) or a request from
310
+ // the server (sampling with an image). No host call is waiting on
311
+ // it, so nothing is answered toward the host — answering the one
312
+ // call in flight used to hand it a false "too large" error, and
313
+ // then its real answer as well. A request the server is waiting
314
+ // on gets a JSON-RPC error back, so it is not left waiting either.
315
+ if (shape.id !== undefined) {
316
+ this.write({
317
+ jsonrpc: "2.0",
318
+ id: shape.id,
319
+ error: {
320
+ code: -32600,
321
+ message: `request too large to deliver (${d.bytes} bytes; the limit is ${maxLineBytes} bytes)`,
322
+ },
323
+ });
324
+ }
325
+ return;
326
+ }
327
+ // An answer too big to parse is still an answer the host is waiting
328
+ // for. Without this, its request stayed pending forever and the tool
329
+ // call spun until the user pressed Esc.
330
+ this.undeliverable({
331
+ id: shape.id,
332
+ // Only a line known to be an answer may be matched to the one call
333
+ // in flight when it carries no id; and not while a request of our
334
+ // own is out too, since the answer could as well be that one's.
335
+ response: shape.kind === "response" && this.ownRequestsInFlight === 0,
336
+ bytes: d.bytes,
337
+ reason: `the result was too large to deliver (${d.bytes} bytes; ` +
338
+ `the limit is ${maxLineBytes} bytes)`,
339
+ });
340
+ },
341
+ }, { maxLineBytes });
342
+ // Upstream stderr → our stderr, never our stdout. Line-prefixed, split on
343
+ // any line ending (a `\r`-only progress spinner is still lines), and
344
+ // bounded: a writer that never ends a line is flushed in pieces rather
345
+ // than buffered for the life of the session.
126
346
  let errBuf = "";
127
347
  child.stderr.setEncoding("utf8");
128
348
  child.stderr.on("data", (chunk) => {
129
349
  errBuf += chunk;
130
- let nl;
131
- while ((nl = errBuf.indexOf("\n")) !== -1) {
132
- const line = errBuf.slice(0, nl).replace(/\r$/, "");
133
- errBuf = errBuf.slice(nl + 1);
350
+ const lines = errBuf.split(/\r\n|\n|\r/);
351
+ errBuf = lines.pop() ?? "";
352
+ for (const line of lines) {
134
353
  if (line)
135
354
  process.stderr.write("[" + this.label + "] " + line + "\n");
136
355
  }
356
+ while (errBuf.length > MAX_STDERR_LINE) {
357
+ process.stderr.write("[" + this.label + "] " + errBuf.slice(0, MAX_STDERR_LINE) + "\n");
358
+ errBuf = errBuf.slice(MAX_STDERR_LINE);
359
+ }
137
360
  });
138
361
  });
139
362
  }
140
- onChildGone(info) {
363
+ onChildGone(child, info) {
364
+ if (child !== this.child)
365
+ return; // an earlier process, already replaced
141
366
  this.detach?.();
142
367
  this.detach = undefined;
143
368
  if (this.closing) {
@@ -154,22 +379,88 @@ export class StdioUpstreamClient extends BaseUpstreamClient {
154
379
  this.restartsLeft -= 1;
155
380
  const delay = this.opts.restartDelayMs ?? 500;
156
381
  logLine("upstream.restarting", { server: this.label, delayMs: delay });
157
- const timer = setTimeout(() => {
158
- this.start().catch((err) => {
159
- logLine("upstream.restart_failed", { server: this.label, error: errText(err) });
160
- this.closed({ error: err instanceof Error ? err : new Error(String(err)) });
161
- });
162
- }, delay);
382
+ // Whatever was in flight died with the process: answer it now (the relay
383
+ // does, on this event) rather than leave the host waiting on a process
384
+ // that will never reply. What arrives from here on waits for the new one.
385
+ this.restartBuffer ??= [];
386
+ this.restarting(info);
387
+ const timer = setTimeout(() => void this.restart(), delay);
163
388
  timer.unref?.();
164
389
  return;
165
390
  }
166
- // Dead for good. The relay answers in-flight requests and stays up: the host
167
- // sees one broken server, not a broken session (§10).
391
+ // Dead for good. The relay answers in-flight requests; bir-proxy then exits,
392
+ // so the host sees the server disconnect exactly as it would unwrapped.
393
+ this.restartBuffer = undefined;
168
394
  this.closed(info);
169
395
  }
396
+ /** Start a new process, replay the host's handshake to it, then release held messages. */
397
+ async restart() {
398
+ if (this.closing || this.dead)
399
+ return;
400
+ try {
401
+ await this.start();
402
+ if (this.closing) {
403
+ // Shut down while the new process was starting: it must not outlive us.
404
+ const child = this.child;
405
+ if (child)
406
+ void killTree(child);
407
+ return;
408
+ }
409
+ if (this.hostInitParams !== undefined) {
410
+ this.handshaking = true;
411
+ try {
412
+ await this.request("initialize", this.hostInitParams, timeoutSignal(REINIT_TIMEOUT_MS));
413
+ }
414
+ finally {
415
+ this.handshaking = false;
416
+ }
417
+ if (this.hostInitialized)
418
+ this.write({ jsonrpc: "2.0", method: "notifications/initialized" });
419
+ }
420
+ }
421
+ catch (err) {
422
+ logLine("upstream.restart_failed", { server: this.label, error: errText(err) });
423
+ this.restartBuffer = undefined;
424
+ const child = this.child;
425
+ this.closing = true;
426
+ if (child && child.exitCode === null && child.signalCode === null)
427
+ void killTree(child);
428
+ this.closed({ error: err instanceof Error ? err : new Error(String(err)) });
429
+ return;
430
+ }
431
+ const held = this.restartBuffer ?? [];
432
+ this.restartBuffer = undefined;
433
+ logLine("upstream.restarted", { server: this.label, released: held.length || undefined });
434
+ for (const msg of held)
435
+ this.write(msg);
436
+ }
170
437
  send(msg) {
438
+ if (this.dead)
439
+ return;
440
+ const id = msg.id;
441
+ const method = msg.method;
442
+ if (!isBirId(id)) {
443
+ // Remember the host's half of the handshake, for a restart to replay.
444
+ if (method === "initialize" && isRequest(msg))
445
+ this.hostInitParams = msg.params ?? {};
446
+ if (method === "notifications/initialized")
447
+ this.hostInitialized = true;
448
+ }
449
+ if (this.restartBuffer) {
450
+ if (this.handshaking && method === "initialize" && isBirId(id)) {
451
+ this.write(msg);
452
+ return;
453
+ }
454
+ if (this.restartBuffer.length >= MAX_RESTART_BUFFER)
455
+ this.restartBuffer.shift();
456
+ this.restartBuffer.push(msg);
457
+ return;
458
+ }
459
+ this.write(msg);
460
+ }
461
+ write(msg) {
171
462
  const child = this.child;
172
- if (!child || this.dead || child.stdin.destroyed)
463
+ if (!child || child.stdin.destroyed)
173
464
  return;
174
465
  logDetail("upstream.send", {
175
466
  server: this.label,
@@ -179,6 +470,7 @@ export class StdioUpstreamClient extends BaseUpstreamClient {
179
470
  }
180
471
  close() {
181
472
  this.closing = true;
473
+ this.restartBuffer = undefined;
182
474
  this.detach?.();
183
475
  const child = this.child;
184
476
  if (!child) {
@@ -199,5 +491,36 @@ export class StdioUpstreamClient extends BaseUpstreamClient {
199
491
  }
200
492
  this.closed({});
201
493
  }
494
+ /**
495
+ * Close the way the MCP spec asks a client to: end stdin, give the server
496
+ * `graceMs` to exit on its own, then kill it — with its whole process tree on
497
+ * Windows. A server that exits on EOF (nearly all do) costs no wait at all.
498
+ */
499
+ async shutdown(graceMs = DEFAULT_SHUTDOWN_GRACE_MS) {
500
+ this.closing = true;
501
+ this.restartBuffer = undefined;
502
+ const child = this.child;
503
+ if (!child || child.exitCode !== null || child.signalCode !== null) {
504
+ this.detach?.();
505
+ this.closed({});
506
+ return;
507
+ }
508
+ // Stop parsing, but keep draining: a server blocked writing to a full
509
+ // stdout pipe could never exit on its own.
510
+ this.detach?.();
511
+ this.detach = undefined;
512
+ child.stdout.resume();
513
+ try {
514
+ child.stdin.end();
515
+ }
516
+ catch {
517
+ /* already gone */
518
+ }
519
+ if (!(await waitForExit(child, graceMs))) {
520
+ logDetail("upstream.kill", { server: this.label, pid: child.pid, why: `no exit within ${graceMs}ms of stdin closing` });
521
+ await killTree(child);
522
+ }
523
+ this.closed({});
524
+ }
202
525
  }
203
526
  //# sourceMappingURL=stdio-client.js.map
@@ -0,0 +1,29 @@
1
+ /**
2
+ * atomic-write — replace a file so a reader never sees half of it.
3
+ *
4
+ * Every file the runner writes is read by something else while it runs:
5
+ * ~/.claude.json by Claude Code (which also rewrites it constantly),
6
+ * settings.local.json and .mcp.json by Claude Code at start, credentials.json
7
+ * and installed.json by every sibling `bir-*` process. A plain `writeFileSync`
8
+ * truncates first and fills second, so a reader that lands in between gets an
9
+ * empty or torn JSON document — and for ~/.claude.json that means Claude Code
10
+ * starting with no MCP servers, or worse, rewriting it from the torn copy.
11
+ *
12
+ * Write a sibling temp file, then rename over the target: rename is atomic on
13
+ * one volume on every platform Node supports. Windows adds two wrinkles:
14
+ *
15
+ * - antivirus and indexers hold files briefly, so a rename can fail with
16
+ * EPERM/EBUSY/EACCES for a few milliseconds; it is retried, and if it keeps
17
+ * failing the write falls back to an in-place write rather than failing the
18
+ * caller's whole operation;
19
+ * - a symlinked target (dotfile managers do this to ~/.claude.json) must keep
20
+ * being a symlink, so the rename goes to the link's real target.
21
+ *
22
+ * The existing file's mode is kept; `mode` applies to a new file.
23
+ */
24
+ export interface AtomicWriteOptions {
25
+ /** Mode for a file that does not exist yet. An existing file keeps its own. */
26
+ mode?: number;
27
+ }
28
+ export declare function writeFileAtomic(path: string, data: string | Uint8Array, opts?: AtomicWriteOptions): void;
29
+ //# sourceMappingURL=atomic-write.d.ts.map
@@ -0,0 +1,96 @@
1
+ /**
2
+ * atomic-write — replace a file so a reader never sees half of it.
3
+ *
4
+ * Every file the runner writes is read by something else while it runs:
5
+ * ~/.claude.json by Claude Code (which also rewrites it constantly),
6
+ * settings.local.json and .mcp.json by Claude Code at start, credentials.json
7
+ * and installed.json by every sibling `bir-*` process. A plain `writeFileSync`
8
+ * truncates first and fills second, so a reader that lands in between gets an
9
+ * empty or torn JSON document — and for ~/.claude.json that means Claude Code
10
+ * starting with no MCP servers, or worse, rewriting it from the torn copy.
11
+ *
12
+ * Write a sibling temp file, then rename over the target: rename is atomic on
13
+ * one volume on every platform Node supports. Windows adds two wrinkles:
14
+ *
15
+ * - antivirus and indexers hold files briefly, so a rename can fail with
16
+ * EPERM/EBUSY/EACCES for a few milliseconds; it is retried, and if it keeps
17
+ * failing the write falls back to an in-place write rather than failing the
18
+ * caller's whole operation;
19
+ * - a symlinked target (dotfile managers do this to ~/.claude.json) must keep
20
+ * being a symlink, so the rename goes to the link's real target.
21
+ *
22
+ * The existing file's mode is kept; `mode` applies to a new file.
23
+ */
24
+ import { chmodSync, lstatSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
25
+ import { randomBytes } from "node:crypto";
26
+ import { basename, dirname, join } from "node:path";
27
+ const RETRYABLE = new Set(["EPERM", "EBUSY", "EACCES", "EEXIST", "ENOTEMPTY"]);
28
+ function sleepSync(ms) {
29
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
30
+ }
31
+ /** Where a write to `path` should really land: through a symlink, to its target. */
32
+ function resolveTarget(path) {
33
+ try {
34
+ if (lstatSync(path).isSymbolicLink())
35
+ return realpathSync(path);
36
+ }
37
+ catch {
38
+ /* missing: write it where asked */
39
+ }
40
+ return path;
41
+ }
42
+ export function writeFileAtomic(path, data, opts = {}) {
43
+ const target = resolveTarget(path);
44
+ let mode = opts.mode;
45
+ try {
46
+ mode = statSync(target).mode & 0o777;
47
+ }
48
+ catch {
49
+ /* new file */
50
+ }
51
+ const tmp = join(dirname(target), `.${basename(target)}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`);
52
+ try {
53
+ writeFileSync(tmp, data, mode === undefined ? undefined : { mode });
54
+ }
55
+ catch {
56
+ // The directory refuses temp files (odd permissions, a read-only sibling
57
+ // policy). Writing in place is still better than not writing.
58
+ writeFileSync(target, data, mode === undefined ? undefined : { mode });
59
+ return;
60
+ }
61
+ for (let attempt = 0;; attempt++) {
62
+ try {
63
+ renameSync(tmp, target);
64
+ if (mode !== undefined && process.platform !== "win32") {
65
+ try {
66
+ chmodSync(target, mode);
67
+ }
68
+ catch {
69
+ /* best effort */
70
+ }
71
+ }
72
+ return;
73
+ }
74
+ catch (err) {
75
+ const code = err.code ?? "";
76
+ if (RETRYABLE.has(code) && attempt < 6) {
77
+ sleepSync(10 * (attempt + 1));
78
+ continue;
79
+ }
80
+ try {
81
+ unlinkSync(tmp);
82
+ }
83
+ catch {
84
+ /* already gone */
85
+ }
86
+ if (RETRYABLE.has(code)) {
87
+ // Held open by something that will not let go. Fall back to the
88
+ // non-atomic write the caller used to do, rather than losing the change.
89
+ writeFileSync(target, data, mode === undefined ? undefined : { mode });
90
+ return;
91
+ }
92
+ throw err;
93
+ }
94
+ }
95
+ }
96
+ //# sourceMappingURL=atomic-write.js.map
@@ -23,9 +23,24 @@ export interface JournalEntry {
23
23
  }
24
24
  export declare function isJournaled(event: string): boolean;
25
25
  export declare const JOURNAL_MAX_BYTES: number;
26
+ /**
27
+ * The longest string one journal field keeps. The journal explains decisions;
28
+ * a field that carries a whole error page or a pasted log would spend the
29
+ * 4 MB budget on one line and rotate the useful ones out.
30
+ */
31
+ export declare const JOURNAL_FIELD_MAX_CHARS = 4000;
32
+ /** A journal nobody has written to for this long belongs to a project that is gone. */
33
+ export declare const JOURNAL_STALE_MS: number;
26
34
  export declare function journalPath(cwd: string): string;
27
35
  /** Start journaling this directory's audit lines. Returns the file, for the startup log. */
28
36
  export declare function openJournal(cwd: string): string;
37
+ /**
38
+ * Delete other projects' journals untouched for {@link JOURNAL_STALE_MS}.
39
+ * One file per directory ever recorded in, each up to 4 MB, never pruned, is
40
+ * how a year of throwaway checkouts becomes a gigabyte nobody asked for.
41
+ * Best-effort, and never the open file.
42
+ */
43
+ export declare function pruneStaleJournals(dir: string, keep?: string, now?: number): number;
29
44
  export declare function closeJournal(): void;
30
45
  /**
31
46
  * Append one entry, if a journal is open and the event is one we keep.