@basein/runner 0.2.12 → 0.3.1
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 +540 -369
- package/dist/auth/client.d.ts +65 -3
- package/dist/auth/client.js +305 -53
- package/dist/bin/bir-hooks.d.ts +72 -31
- package/dist/bin/bir-hooks.js +176 -284
- package/dist/bin/bir-proxy.d.ts +17 -2
- package/dist/bin/bir-proxy.js +222 -28
- package/dist/bin/bir-scenario.d.ts +61 -21
- package/dist/bin/bir-scenario.js +593 -122
- package/dist/bin/bir.d.ts +12 -6
- package/dist/bin/bir.js +2935 -489
- package/dist/bin/investigate.js +40 -15
- package/dist/bin/scenario-edit.d.ts +7 -1
- package/dist/bin/scenario-edit.js +43 -21
- package/dist/bin/scenario-liveness.d.ts +194 -0
- package/dist/bin/scenario-liveness.js +459 -0
- package/dist/bin/scenario-stderr.d.ts +43 -0
- package/dist/bin/scenario-stderr.js +76 -0
- package/dist/bin/service-read.d.ts +88 -0
- package/dist/bin/service-read.js +128 -0
- package/dist/bin/setup.d.ts +19 -0
- package/dist/bin/setup.js +146 -20
- package/dist/config/adapters/claude-code.d.ts +92 -14
- package/dist/config/adapters/claude-code.js +291 -101
- package/dist/config/adapters/generic.d.ts +4 -1
- package/dist/config/adapters/generic.js +13 -14
- package/dist/config/generate.d.ts +392 -14
- package/dist/config/generate.js +666 -19
- package/dist/config/mcp-approval.d.ts +76 -0
- package/dist/config/mcp-approval.js +210 -0
- package/dist/config/node-path.d.ts +42 -0
- package/dist/config/node-path.js +102 -0
- package/dist/config/overrides.d.ts +62 -0
- package/dist/config/overrides.js +136 -0
- package/dist/config/resolve.d.ts +70 -8
- package/dist/config/resolve.js +186 -15
- package/dist/control/client.d.ts +22 -0
- package/dist/control/client.js +26 -7
- package/dist/control/correlation.d.ts +23 -5
- package/dist/control/correlation.js +0 -0
- package/dist/control/daemon.d.ts +400 -18
- package/dist/control/daemon.js +1105 -102
- package/dist/control/discovery.d.ts +77 -5
- package/dist/control/discovery.js +186 -11
- package/dist/control/ensure-hook.d.ts +61 -0
- package/dist/control/ensure-hook.js +160 -8
- package/dist/control/paths.d.ts +26 -0
- package/dist/control/paths.js +34 -0
- package/dist/control/pin-guard.d.ts +83 -0
- package/dist/control/pin-guard.js +534 -0
- package/dist/control/recorder-keeper.d.ts +229 -0
- package/dist/control/recorder-keeper.js +721 -0
- package/dist/control/recorder-main.d.ts +73 -0
- package/dist/control/recorder-main.js +778 -0
- package/dist/control/server.d.ts +707 -31
- package/dist/control/server.js +2787 -302
- package/dist/control/transcript.d.ts +21 -1
- package/dist/control/transcript.js +378 -112
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/jsonrpc/framing.d.ts +76 -12
- package/dist/jsonrpc/framing.js +216 -59
- package/dist/proxy/intercept.d.ts +49 -5
- package/dist/proxy/intercept.js +188 -59
- package/dist/proxy/relay.d.ts +16 -0
- package/dist/proxy/relay.js +77 -5
- package/dist/proxy/revive.d.ts +122 -0
- package/dist/proxy/revive.js +251 -0
- package/dist/proxy/session.d.ts +222 -14
- package/dist/proxy/session.js +708 -99
- package/dist/record/recorder.d.ts +51 -3
- package/dist/record/recorder.js +3 -0
- package/dist/record/redact.d.ts +103 -9
- package/dist/record/redact.js +659 -30
- package/dist/record/remote-recorder.d.ts +327 -32
- package/dist/record/remote-recorder.js +1517 -187
- package/dist/record/switchable-recorder.d.ts +126 -0
- package/dist/record/switchable-recorder.js +351 -0
- package/dist/record/truncate.d.ts +40 -1
- package/dist/record/truncate.js +111 -4
- package/dist/replay/bash-channel.d.ts +35 -0
- package/dist/replay/bash-channel.js +133 -0
- package/dist/replay/bundle.d.ts +10 -0
- package/dist/replay/bundle.js +10 -0
- package/dist/replay/controller.d.ts +369 -20
- package/dist/replay/controller.js +1176 -107
- package/dist/replay/derive.d.ts +1 -0
- package/dist/replay/derive.js +81 -24
- package/dist/replay/executor.d.ts +51 -16
- package/dist/replay/executor.js +114 -41
- package/dist/replay/handover.d.ts +54 -1
- package/dist/replay/handover.js +55 -5
- package/dist/replay/logic.d.ts +62 -10
- package/dist/replay/logic.js +140 -22
- package/dist/replay/plan.d.ts +201 -27
- package/dist/replay/plan.js +581 -141
- package/dist/replay/safety.d.ts +72 -0
- package/dist/replay/safety.js +303 -0
- package/dist/replay/source-run.d.ts +10 -0
- package/dist/replay/source-run.js +80 -35
- package/dist/replay/targets.d.ts +15 -1
- package/dist/replay/targets.js +62 -4
- package/dist/replay/tool-error.d.ts +15 -1
- package/dist/replay/tool-error.js +33 -1
- package/dist/replay/types.d.ts +7 -4
- package/dist/upstream/client.d.ts +56 -3
- package/dist/upstream/client.js +61 -4
- package/dist/upstream/headers.d.ts +43 -0
- package/dist/upstream/headers.js +90 -0
- package/dist/upstream/http-client.d.ts +213 -22
- package/dist/upstream/http-client.js +1034 -168
- package/dist/upstream/http-transport.d.ts +143 -0
- package/dist/upstream/http-transport.js +391 -0
- package/dist/upstream/lazy-client.d.ts +8 -0
- package/dist/upstream/lazy-client.js +29 -1
- package/dist/upstream/sse.d.ts +78 -0
- package/dist/upstream/sse.js +189 -0
- package/dist/upstream/stdio-client.d.ts +61 -1
- package/dist/upstream/stdio-client.js +356 -33
- package/dist/util/atomic-write.d.ts +29 -0
- package/dist/util/atomic-write.js +96 -0
- package/dist/util/journal.d.ts +15 -0
- package/dist/util/journal.js +48 -3
- package/dist/util/log.d.ts +10 -0
- package/dist/util/log.js +28 -1
- package/dist/util/off-switch.d.ts +46 -0
- package/dist/util/off-switch.js +91 -0
- package/dist/util/service-health.d.ts +133 -0
- package/dist/util/service-health.js +217 -0
- package/docs/BaseInstRunner.md +694 -621
- package/docs/calculatedReplay.md +1625 -1304
- package/docs/calculatedReplayGuide.md +1212 -1087
- package/docs/installRun.md +1013 -772
- package/docs/loginWeb.md +627 -607
- package/docs/mcpmark.md +49 -16
- package/docs/my-first-sample.md +569 -545
- package/docs/quickstart.md +384 -311
- package/docs/t-bench.md +406 -394
- package/package.json +58 -57
- package/production.md +1371 -0
- package/scripts/install.ps1 +97 -6
- 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 {
|
|
31
|
+
import { timeoutSignal } from "../util/service-health.js";
|
|
32
|
+
import { BaseUpstreamClient, isBirId } from "./client.js";
|
|
24
33
|
const BATCH_EXT = /\.(cmd|bat)$/i;
|
|
25
|
-
/**
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
110
|
+
? (envValue(env, "PATHEXT") ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)
|
|
36
111
|
: [""];
|
|
37
|
-
const dirs = (env
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
290
|
+
goneOnce({ error: err });
|
|
115
291
|
});
|
|
116
|
-
child.once("exit", (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) =>
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
167
|
-
// sees
|
|
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 ||
|
|
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
|
package/dist/util/journal.d.ts
CHANGED
|
@@ -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.
|