@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.
Files changed (142) hide show
  1. package/README.md +540 -369
  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 -21
  9. package/dist/bin/bir-scenario.js +593 -122
  10. package/dist/bin/bir.d.ts +12 -6
  11. package/dist/bin/bir.js +2935 -489
  12. package/dist/bin/investigate.js +40 -15
  13. package/dist/bin/scenario-edit.d.ts +7 -1
  14. package/dist/bin/scenario-edit.js +43 -21
  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 +2787 -302
  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/recorder.d.ts +51 -3
  72. package/dist/record/recorder.js +3 -0
  73. package/dist/record/redact.d.ts +103 -9
  74. package/dist/record/redact.js +659 -30
  75. package/dist/record/remote-recorder.d.ts +327 -32
  76. package/dist/record/remote-recorder.js +1517 -187
  77. package/dist/record/switchable-recorder.d.ts +126 -0
  78. package/dist/record/switchable-recorder.js +351 -0
  79. package/dist/record/truncate.d.ts +40 -1
  80. package/dist/record/truncate.js +111 -4
  81. package/dist/replay/bash-channel.d.ts +35 -0
  82. package/dist/replay/bash-channel.js +133 -0
  83. package/dist/replay/bundle.d.ts +10 -0
  84. package/dist/replay/bundle.js +10 -0
  85. package/dist/replay/controller.d.ts +369 -20
  86. package/dist/replay/controller.js +1176 -107
  87. package/dist/replay/derive.d.ts +1 -0
  88. package/dist/replay/derive.js +81 -24
  89. package/dist/replay/executor.d.ts +51 -16
  90. package/dist/replay/executor.js +114 -41
  91. package/dist/replay/handover.d.ts +54 -1
  92. package/dist/replay/handover.js +55 -5
  93. package/dist/replay/logic.d.ts +62 -10
  94. package/dist/replay/logic.js +140 -22
  95. package/dist/replay/plan.d.ts +201 -27
  96. package/dist/replay/plan.js +581 -141
  97. package/dist/replay/safety.d.ts +72 -0
  98. package/dist/replay/safety.js +303 -0
  99. package/dist/replay/source-run.d.ts +10 -0
  100. package/dist/replay/source-run.js +80 -35
  101. package/dist/replay/targets.d.ts +15 -1
  102. package/dist/replay/targets.js +62 -4
  103. package/dist/replay/tool-error.d.ts +15 -1
  104. package/dist/replay/tool-error.js +33 -1
  105. package/dist/replay/types.d.ts +7 -4
  106. package/dist/upstream/client.d.ts +56 -3
  107. package/dist/upstream/client.js +61 -4
  108. package/dist/upstream/headers.d.ts +43 -0
  109. package/dist/upstream/headers.js +90 -0
  110. package/dist/upstream/http-client.d.ts +213 -22
  111. package/dist/upstream/http-client.js +1034 -168
  112. package/dist/upstream/http-transport.d.ts +143 -0
  113. package/dist/upstream/http-transport.js +391 -0
  114. package/dist/upstream/lazy-client.d.ts +8 -0
  115. package/dist/upstream/lazy-client.js +29 -1
  116. package/dist/upstream/sse.d.ts +78 -0
  117. package/dist/upstream/sse.js +189 -0
  118. package/dist/upstream/stdio-client.d.ts +61 -1
  119. package/dist/upstream/stdio-client.js +356 -33
  120. package/dist/util/atomic-write.d.ts +29 -0
  121. package/dist/util/atomic-write.js +96 -0
  122. package/dist/util/journal.d.ts +15 -0
  123. package/dist/util/journal.js +48 -3
  124. package/dist/util/log.d.ts +10 -0
  125. package/dist/util/log.js +28 -1
  126. package/dist/util/off-switch.d.ts +46 -0
  127. package/dist/util/off-switch.js +91 -0
  128. package/dist/util/service-health.d.ts +133 -0
  129. package/dist/util/service-health.js +217 -0
  130. package/docs/BaseInstRunner.md +694 -621
  131. package/docs/calculatedReplay.md +1625 -1304
  132. package/docs/calculatedReplayGuide.md +1212 -1087
  133. package/docs/installRun.md +1013 -772
  134. package/docs/loginWeb.md +627 -607
  135. package/docs/mcpmark.md +49 -16
  136. package/docs/my-first-sample.md +569 -545
  137. package/docs/quickstart.md +384 -311
  138. package/docs/t-bench.md +406 -394
  139. package/package.json +58 -57
  140. package/production.md +1371 -0
  141. package/scripts/install.ps1 +97 -6
  142. package/scripts/install.sh +104 -7
@@ -15,25 +15,47 @@
15
15
  * --no-correlation never relax tool schemas; join on argument fingerprints
16
16
  * --lazy create the upstream on the first host message, not at
17
17
  * startup (see upstream/lazy-client.ts for the caveat)
18
- * --restart restart a dead stdio upstream once
19
- * --discovery-ms <n> how long to look for a control server (default 5000)
18
+ * --restart restart a dead stdio upstream once (in-flight calls
19
+ * are answered; the host's handshake is replayed to it)
20
+ * --discovery-ms <n> how long to look for a control server (default 10000)
20
21
  *
21
22
  * Environment:
22
23
  * BIR_CONTROL_URL / BIR_CONTROL_TOKEN control server, bypassing discovery
23
24
  * BIR_AUTH_URL BaseIn service, for Tier 2 recording
25
+ * BIR_UPSTREAM_HEADERS a remote upstream's headers, as a JSON
26
+ * object of name → value; `--header`
27
+ * wins on a clash. Read once, then
28
+ * removed from the environment: no
29
+ * process started from here sees it
30
+ * BIR_OFF=1 the kill switch (also `bir off`): run
31
+ * the server as if it were not wrapped
32
+ * BIR_PROXY_STAY_UP=1 keep answering after the upstream
33
+ * dies, instead of exiting with it
24
34
  * BIR_VERBOSE=1 per-message detail on stderr
25
35
  *
26
36
  * STDOUT IS SACRED. It is the host's JSON-RPC stream; every log line, every
27
37
  * upstream stderr line and every diagnostic goes to stderr instead. A single
28
38
  * stray byte here corrupts the session and surfaces somewhere else entirely.
39
+ *
40
+ * STDERR IS NOT FREE EITHER. Claude Code files every stderr line of an MCP
41
+ * server as a "Server stderr" error in its MCP logs, so routine lines (started,
42
+ * spawned, bound, stopped) are `BIR_VERBOSE`-only; what is left on stderr is a
43
+ * decision or a failure somebody may need to read.
29
44
  */
30
- import { StdioUpstreamClient } from "../upstream/stdio-client.js";
45
+ import { spawn } from "node:child_process";
46
+ import { constants as osConstants } from "node:os";
47
+ import { StdioUpstreamClient, resolveCommand } from "../upstream/stdio-client.js";
31
48
  import { HttpUpstreamClient } from "../upstream/http-client.js";
32
49
  import { LazyUpstreamClient } from "../upstream/lazy-client.js";
33
50
  import { Relay } from "../proxy/relay.js";
34
- import { RecordingInterceptor } from "../proxy/intercept.js";
51
+ import { RecordingInterceptor, StripCallIdInterceptor } from "../proxy/intercept.js";
35
52
  import { ProxySession } from "../proxy/session.js";
36
- import { logLine, errText } from "../util/log.js";
53
+ import { mergeHeaders, parseUpstreamHeaders, sendableHeaders, UPSTREAM_HEADERS_ENV } from "../upstream/headers.js";
54
+ import { exemptLoopbackFromProxy } from "../config/generate.js";
55
+ import { offSource } from "../util/off-switch.js";
56
+ import { logLine, logDetail, logOnce, errText } from "../util/log.js";
57
+ /** Hard ceiling on shutdown: the recorder's last chance, and the upstream's. */
58
+ const SHUTDOWN_BUDGET_MS = 3_000;
37
59
  function usage(message) {
38
60
  if (message)
39
61
  process.stderr.write(`[bir] ${message}\n`);
@@ -79,8 +101,10 @@ export function parseArgs(argv) {
79
101
  case "--header": {
80
102
  const raw = argv[++i] ?? "";
81
103
  const sep = raw.indexOf(":");
104
+ // Never echo `raw`: a header is usually a credential, and a malformed
105
+ // one (`Authorization Bearer …`, no colon) is still the whole token.
82
106
  if (sep <= 0)
83
- usage(`bad --header (expected 'Name: value'): ${raw}`);
107
+ usage("bad --header (expected 'Name: value')");
84
108
  out.headers[raw.slice(0, sep).trim()] = raw.slice(sep + 1).trim();
85
109
  break;
86
110
  }
@@ -113,7 +137,49 @@ export function parseArgs(argv) {
113
137
  usage("give either --url or -- <command>");
114
138
  return out;
115
139
  }
116
- function buildUpstream(args) {
140
+ /**
141
+ * `BIR_UPSTREAM_HEADERS` merged under the `--header` arguments (which win on a
142
+ * clash, so an older entry keeps meaning what it meant), and the variable
143
+ * removed from this process's environment either way: a stdio upstream — the
144
+ * user's own server — and anything else started from here must never inherit
145
+ * a remote server's credentials. A variable that is not usable is said once,
146
+ * by its shape; neither it nor any value is ever logged.
147
+ */
148
+ function takeUpstreamHeaders(args) {
149
+ const raw = process.env[UPSTREAM_HEADERS_ENV];
150
+ delete process.env[UPSTREAM_HEADERS_ENV];
151
+ if (!args.url)
152
+ return args.headers;
153
+ const fromEnv = parseUpstreamHeaders(raw);
154
+ if (fromEnv.problem) {
155
+ logOnce("upstream.headers_invalid:env", "upstream.headers_invalid", {
156
+ server: args.serverName,
157
+ source: UPSTREAM_HEADERS_ENV,
158
+ why: fromEnv.problem,
159
+ });
160
+ }
161
+ const fromArgs = sendableHeaders(args.headers);
162
+ if (fromArgs.problem) {
163
+ logOnce("upstream.headers_invalid:args", "upstream.headers_invalid", {
164
+ server: args.serverName,
165
+ source: "--header",
166
+ why: fromArgs.problem,
167
+ });
168
+ }
169
+ return mergeHeaders(fromEnv.headers, fromArgs.headers);
170
+ }
171
+ /**
172
+ * The proxy-variable changes {@link exemptLoopbackFromProxy} made to this
173
+ * process, undone for the user's own server: its networking is not ours to
174
+ * change. Empty when nothing was changed.
175
+ */
176
+ function environmentAsStarted(before, changed) {
177
+ if (!changed)
178
+ return {};
179
+ // On Windows the two spellings are one variable.
180
+ return process.platform === "win32" ? { NO_PROXY: before.NO_PROXY } : { NO_PROXY: before.NO_PROXY, no_proxy: before.no_proxy };
181
+ }
182
+ function buildUpstream(args, stdioEnv) {
117
183
  const create = () => {
118
184
  if (args.url) {
119
185
  return new HttpUpstreamClient({
@@ -126,6 +192,7 @@ function buildUpstream(args) {
126
192
  return new StdioUpstreamClient({
127
193
  command: args.command,
128
194
  args: args.commandArgs,
195
+ env: stdioEnv,
129
196
  label: args.serverName,
130
197
  restartDelayMs: args.restart ? 500 : 0,
131
198
  });
@@ -134,60 +201,187 @@ function buildUpstream(args) {
134
201
  // header comment in upstream/lazy-client.ts for why that is the honest limit.
135
202
  return args.lazy ? new LazyUpstreamClient(create) : create();
136
203
  }
204
+ /**
205
+ * The kill switch, for a stdio upstream: run the real server as the host would
206
+ * have run it — same stdin, same stdout, same stderr — and leave with its exit
207
+ * status. No relay, no parsing, no recording, no control server; the only thing
208
+ * left of the runner is one idle parent process.
209
+ */
210
+ function runAsPipe(args) {
211
+ const resolved = resolveCommand(args.command, args.commandArgs, process.env);
212
+ let child;
213
+ try {
214
+ child = spawn(resolved.command, resolved.args, {
215
+ stdio: "inherit",
216
+ shell: false,
217
+ windowsHide: true,
218
+ windowsVerbatimArguments: resolved.windowsVerbatimArguments,
219
+ });
220
+ }
221
+ catch (err) {
222
+ logLine("upstream.exited", { server: args.serverName, error: errText(err) });
223
+ process.exit(1);
224
+ }
225
+ child.once("error", (err) => {
226
+ logLine("upstream.exited", { server: args.serverName, error: errText(err) });
227
+ process.exit(1);
228
+ });
229
+ child.once("exit", (code, signal) => {
230
+ if (signal) {
231
+ const n = osConstants.signals[signal] ?? 0;
232
+ process.exit(128 + n);
233
+ }
234
+ process.exit(code ?? 1);
235
+ });
236
+ // A signal meant for the server reaches it, and we leave when it does.
237
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
238
+ try {
239
+ process.on(sig, () => {
240
+ try {
241
+ child.kill(sig);
242
+ }
243
+ catch {
244
+ /* already gone */
245
+ }
246
+ });
247
+ }
248
+ catch {
249
+ /* a signal this platform does not have */
250
+ }
251
+ }
252
+ }
253
+ /** Resolve once everything already written to stdout has been handed off, or after `ms`. */
254
+ function stdoutDrained(ms) {
255
+ return new Promise((resolve) => {
256
+ const timer = setTimeout(resolve, ms);
257
+ timer.unref?.();
258
+ try {
259
+ process.stdout.write("", () => {
260
+ clearTimeout(timer);
261
+ resolve();
262
+ });
263
+ }
264
+ catch {
265
+ clearTimeout(timer);
266
+ resolve();
267
+ }
268
+ });
269
+ }
137
270
  async function main() {
138
271
  const args = parseArgs(process.argv.slice(2));
272
+ const cwd = process.cwd();
273
+ // First, before anything can be spawned: the variable leaves the
274
+ // environment even on the kill-switch path below.
275
+ args.headers = takeUpstreamHeaders(args);
276
+ // THE KILL SWITCH (`bir off`, BIR_OFF=1). Decided once, at startup: a
277
+ // server already running keeps the path it started with, and the next
278
+ // session picks up the flip.
279
+ const off = offSource(cwd);
280
+ if (off && !args.url) {
281
+ logDetail("proxy.off", { server: args.serverName, source: off, why: "running the server unwrapped" });
282
+ runAsPipe(args);
283
+ return;
284
+ }
285
+ // Started with --use-env-proxy (entries carry it behind a proxy) in an
286
+ // environment whose NO_PROXY does not name 127.0.0.1: without this, every
287
+ // call to the control server — register, step reports, the replay work
288
+ // loop — went to the proxy, failed, and the proxy fell to Tier 2 or to
289
+ // nothing. Before any loopback fetch, as `bir` does. The pure pipe above
290
+ // runs no fetch and hands the user's server the environment untouched.
291
+ const noProxyBefore = { NO_PROXY: process.env.NO_PROXY, no_proxy: process.env.no_proxy };
292
+ const exempted = exemptLoopbackFromProxy();
139
293
  // The upstream is built first so the session can be handed it: on a matched
140
294
  // turn the control server runs the scenario's steps on this very connection
141
295
  // (docs/calculatedReplay.md §16.2), which is what makes replay cost nothing.
142
- const upstream = buildUpstream(args);
143
- const session = new ProxySession({
144
- serverName: args.serverName,
145
- cwd: process.cwd(),
146
- standalone: args.standalone,
147
- noCorrelation: args.noCorrelation,
148
- discoveryWindowMs: args.discoveryMs,
149
- upstream,
150
- });
296
+ const asStarted = environmentAsStarted(noProxyBefore, exempted);
297
+ const upstream = buildUpstream(args, asStarted);
298
+ // Off, with a remote upstream (there is no process to hand stdio to): relay
299
+ // only — no recording, no control server, no schema change. The call id is
300
+ // still stripped (StripCallIdInterceptor): `bir on` mid-session can make a
301
+ // control server inject one into a call routed here.
302
+ const session = off
303
+ ? undefined
304
+ : new ProxySession({
305
+ serverName: args.serverName,
306
+ cwd,
307
+ standalone: args.standalone,
308
+ noCorrelation: args.noCorrelation,
309
+ discoveryWindowMs: args.discoveryMs,
310
+ upstream,
311
+ // A recorder that dies under this proxy is started again with the
312
+ // environment the host gave us (proxy/revive.ts) — without the
313
+ // loopback exemption made above, and without BIR_UPSTREAM_HEADERS,
314
+ // which left this process's environment at the top of main().
315
+ revive: { env: { ...process.env, ...asStarted } },
316
+ });
317
+ if (off)
318
+ logDetail("proxy.off", { server: args.serverName, source: off, why: "relaying without recording" });
151
319
  // Negotiation runs in the background: relaying must start now, because the
152
320
  // host may already be writing `initialize` at us.
153
- session.start();
321
+ session?.start();
154
322
  upstream.ready.catch((err) => {
155
- // §10: the upstream failing to start is one broken server, not a broken
156
- // session. The relay stays up and answers requests with a tool-level error.
157
323
  logLine("upstream.unavailable", { server: args.serverName, error: errText(err) });
158
324
  });
325
+ // Declared before anything that can call `shutdown`.
326
+ let shuttingDown = false;
159
327
  const relay = new Relay({
160
328
  upstream,
161
329
  hostIn: process.stdin,
162
330
  hostOut: process.stdout,
163
- interceptor: new RecordingInterceptor({ serverName: args.serverName, session }),
331
+ interceptor: session
332
+ ? new RecordingInterceptor({ serverName: args.serverName, session })
333
+ : new StripCallIdInterceptor(),
164
334
  label: args.serverName,
165
335
  onHostDisconnect: () => void shutdown(0),
166
336
  });
167
337
  relay.start();
168
- logLine("proxy.started", {
338
+ // An upstream that is gone for good takes the proxy with it — after the
339
+ // relay (registered first) has answered whatever was in flight. The host
340
+ // then sees what it would have seen unwrapped: the server disconnected, shown
341
+ // as failed in /mcp with a reconnect offered, its tools gone from the model's
342
+ // list. Staying up instead left the tools listed and every call answering an
343
+ // error, turn after turn, with nothing to tell the user to reconnect.
344
+ upstream.onClose((info) => {
345
+ if (shuttingDown || process.env.BIR_PROXY_STAY_UP === "1")
346
+ return;
347
+ logLine("proxy.upstream_gone", {
348
+ server: args.serverName,
349
+ code: info.code,
350
+ signal: info.signal,
351
+ why: "exiting with it, as the server would have unwrapped",
352
+ });
353
+ // Its own exit status where it had one, as the host would have seen it.
354
+ void shutdown(typeof info.code === "number" ? info.code : 1);
355
+ });
356
+ logDetail("proxy.started", {
169
357
  server: args.serverName,
170
358
  upstream: args.url ?? args.command,
171
359
  transport: args.url ? args.transport : "stdio",
172
360
  pid: process.pid,
173
361
  });
174
- let shuttingDown = false;
175
362
  async function shutdown(code) {
176
363
  if (shuttingDown)
177
364
  return;
178
365
  shuttingDown = true;
179
366
  relay.stop();
180
- upstream.close();
181
- // Give the recorder its last chance; a run that loses its final steps is a
182
- // worse artefact than a proxy that takes an extra moment to exit.
367
+ // The upstream gets its polite close (stdin EOF, a moment to exit, then a
368
+ // tree kill), and the recorder its last chance — side by side, and never
369
+ // longer than the budget: a run that loses its final steps is a worse
370
+ // artefact than a proxy that takes an extra moment to exit, but a proxy
371
+ // that lingers is worse than both.
372
+ const closing = Promise.allSettled([
373
+ session?.close(),
374
+ upstream.shutdown ? upstream.shutdown() : Promise.resolve(upstream.close()),
375
+ ]);
183
376
  await Promise.race([
184
- session.close(),
377
+ closing,
185
378
  new Promise((resolve) => {
186
- const t = setTimeout(resolve, 3_000);
379
+ const t = setTimeout(resolve, SHUTDOWN_BUDGET_MS);
187
380
  t.unref?.();
188
381
  }),
189
382
  ]);
190
- logLine("proxy.stopped", { server: args.serverName, tier: session.tier });
383
+ logDetail("proxy.stopped", { server: args.serverName, tier: session?.tier ?? "off" });
384
+ await stdoutDrained(500);
191
385
  process.exit(code);
192
386
  }
193
387
  process.on("SIGINT", () => void shutdown(0));
@@ -18,31 +18,71 @@
18
18
  * control server exactly as a proxy does — `~/.baseinstrunner/control/<key>.json`,
19
19
  * mode 0600, bearer token — and forwards one call.
20
20
  *
21
+ * WHOSE CALL. A stdio MCP server is never told its session, and the control
22
+ * server runs a plan only for the session whose `PreToolUse` for
23
+ * `mcp__bir__run_scenario` it saw. Claude Code names the tool use in every
24
+ * MCP call's `params._meta["claudecode/toolUseId"]` — the same id that hook
25
+ * carried as `tool_use_id` — so the request forwards it as `toolUseId` and the
26
+ * control server matches it exactly instead of inferring the session. A host
27
+ * that names nothing sends an empty body, as before.
28
+ *
29
+ * IT RUNS IN EVERY SESSION, SO IT MUST NEVER BE WHY ONE GOES WRONG. Startup and
30
+ * `tools/list` are answered from local files alone (the hello to the recorder
31
+ * that follows them is sent after the answer, and never awaited). `run_scenario` never answers
32
+ * with an error or with the runner's internals ("fetch failed", "not_ready",
33
+ * "HTTP 404"): a turn with nothing to run is told so in one neutral sentence,
34
+ * and a replay that cannot be delivered becomes "do the task normally" — the
35
+ * real reason goes to the audit log, where `bir doctor` and a person look. A
36
+ * model called it on its own in the first session of every freshly installed
37
+ * project; a red error under a tool the user never asked about, five seconds
38
+ * late, was the result. `BIR_SCENARIO_DEBUG=1` brings the raw reasons back.
39
+ *
21
40
  * THE SCENARIO TOOLS RUN `bir` ITSELF. `scenario_show`, `scenario_check` and
22
41
  * the rest are the `bir scenario …` commands, spawned from this same package
23
42
  * with `--json` (D1: one path, whether a person types it or the model calls
24
43
  * it). That keeps this server credential-free — `bir` signs in the way it
25
- * always does — and means the model sees exactly the answer a person would.
26
- * Logic bodies travel through temporary files, never argv, and a non-zero exit
27
- * is an `isError` result that carries the output, refusal and report included.
28
- *
29
- * A CALL IN FLIGHT IS OWNED UNTIL IT ENDS. Each scenario call's `bir` child and
30
- * its temporary directory are tracked. When the host goes away (stdin closes,
31
- * SIGINT, SIGTERM) the children are stopped and the directories removed before
32
- * the process exits — `process.exit` never resumes a pending `finally`, so
33
- * cleanup left to one was cleanup that did not happen, and an orphaned `bir`
34
- * went on to save an edit for a session that had ended (C13). A
35
- * `notifications/cancelled` (the person pressed Esc) stops that call's child
36
- * the same way, and the call gets no answer, as MCP asks. A request the
37
- * service already received may still complete there; nothing here can call
38
- * it back, and `bir scenario edits` shows it.
39
- *
40
- * The tools that CHANGE a scenario are offered only where a person ran
41
- * `bir scenario editing on` (D2): this server runs in every session of every
42
- * installed project, a fleet included, and a plan must not change because some
43
- * agent there decided it should. The switch is read when the host lists the
44
- * tools, and again on every call, so a call to a tool that was never offered —
45
- * or was offered before the switch went off — is refused rather than run.
44
+ * always does — and means the model sees exactly the answer a person would,
45
+ * compact (`BIR_MCP=1`) and capped in size. Logic bodies travel through
46
+ * temporary files, never argv, and a non-zero exit is an `isError` result that
47
+ * carries the output, refusal and report included.
48
+ *
49
+ * A CALL IN FLIGHT IS OWNED UNTIL IT ENDS. Each call's `bir` child, temporary
50
+ * directory or control-server request is tracked. When the host goes away
51
+ * (stdin closes, SIGINT, SIGTERM) the children are stopped and the directories
52
+ * removed before the process exits — `process.exit` never resumes a pending
53
+ * `finally`, so cleanup left to one was cleanup that did not happen, and an
54
+ * orphaned `bir` went on to save an edit for a session that had ended (C13). A
55
+ * `notifications/cancelled` (the person pressed Esc) stops that call the same
56
+ * way — a `run_scenario` request is aborted, and the control server stops
57
+ * starting steps when its request closes — and the call gets no answer, as MCP
58
+ * asks. A request the service already received may still complete there;
59
+ * nothing here can call it back, and `bir scenario edits` shows it.
60
+ *
61
+ * WHICH TOOLS ARE OFFERED. `run_scenario`, always — unless the runner is
62
+ * switched off (`bir off`), when nothing is: an inert runner adds nothing to
63
+ * the model's context, and a call from a list taken before the switch starts
64
+ * nothing and asks nobody. The reading tools come with `bir scenario editing on`
65
+ * (or `BIR_SCENARIO_TOOLS=read` in this server's environment): their
66
+ * definitions ride along with every request of every session, and only
67
+ * somebody working on a scenario ever calls them. The tools that CHANGE a
68
+ * scenario are offered only where a person ran `bir scenario editing on` (D2):
69
+ * this server runs in every session of every installed project, a fleet
70
+ * included, and a plan must not change because some agent there decided it
71
+ * should. The switch is read when the host lists the tools, and again on every
72
+ * call, so a call to a tool that was never offered — or was offered before the
73
+ * switch went off — is refused rather than run. Turning the runner off or on
74
+ * mid-session tells the host its list changed (`notifications/tools/list_changed`).
75
+ *
76
+ * IT SAYS IT IS HERE, AND IT BRINGS THE RECORDER BACK (scenario-liveness.ts).
77
+ * A configured `bir` server is not a connected one, so the recorder announces
78
+ * a direct plan — "call run_scenario first" — only on this server's word: a
79
+ * `POST /scenario/hello` (pid, version, how many tools, off or not) on
80
+ * `initialize`, on `tools/list`, when the off switch flips, to each new
81
+ * recorder, and every 5 min. And this is the one long-lived process in every
82
+ * session of an installed project, so when the recorder's pid is dead and its
83
+ * address refuses, it starts `bir-hooks revive` — detached, rate-limited —
84
+ * rather than leave every later hook to fail until the next session start.
85
+ * Neither is ever awaited on the MCP path.
46
86
  *
47
87
  * STDOUT IS SACRED, as in every MCP server here: it is the host's JSON-RPC
48
88
  * stream, and every log line goes to stderr instead.