@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
@@ -0,0 +1,459 @@
1
+ /**
2
+ * scenario-liveness — the two things `bir-scenario` does besides answering MCP:
3
+ * it tells its project's recorder that a delivery tool is live
4
+ * (`POST /scenario/hello`), and it brings back a recorder that died
5
+ * mid-session (`bir-hooks revive`).
6
+ *
7
+ * WHY HELLO. A direct plan tells the model to call `mcp__bir__run_scenario`
8
+ * before any other tool. The control server used to announce one whenever the
9
+ * MCP config listed the `bir` server — but a configured server is not a
10
+ * connected one: a stale node path, a crash, the server disabled in `/mcp`, or
11
+ * a session run with `BIR_OFF=1` (which lists no tools) all leave the model
12
+ * told to call a tool it does not have. So this server says, on `initialize`,
13
+ * on `tools/list`, when the off switch flips, whenever its recorder changes,
14
+ * and every {@link HELLO_EVERY_MS} while it runs: this pid, this version, this
15
+ * many tools, off or not. The control server announces a direct plan only on
16
+ * the word of a live pid that lists a tool.
17
+ *
18
+ * WHY REVIVE. Every hook but SessionStart is an HTTP post to the recorder.
19
+ * With the recorder gone — killed by an antivirus, out of memory — Claude Code
20
+ * shows a hook error on every prompt and tool call until the next session
21
+ * start, `/clear` or compaction, because nothing else starts one mid-session.
22
+ * This server is the one long-lived process in every session of an installed
23
+ * project, wrapped servers or none, so it starts `bir-hooks revive --cwd <dir>`
24
+ * (which runs the ordinary ensure, under the ordinary start lock).
25
+ *
26
+ * NEITHER MAY COST THE SESSION ANYTHING.
27
+ * - A hello is fire-and-forget: nothing on the MCP path awaits it, it is
28
+ * bounded by {@link HELLO_TIMEOUT_MS}, one is in flight at a time, and a
29
+ * recorder that cannot take it (not up yet, busy, an older version with no
30
+ * such route) costs a retry later, never an error.
31
+ * - A revive is a detached child that is never waited for, and it is started
32
+ * only when the discovery file names a pid that is DEAD and the recorder's
33
+ * address REFUSES the connection. A recorder that is alive but slow is never
34
+ * replaced (that is how duplicates that cannot bind were made). A missing
35
+ * discovery file is never revived either: `bir down` removes it, and a
36
+ * recorder somebody stopped on purpose stays stopped. Only an installed
37
+ * project is revived, at most once per {@link REVIVE_EVERY_MS} per process
38
+ * (after a random wait of up to {@link REVIVE_JITTER_MS}, so the several
39
+ * processes of a session that see the same death do not all start one),
40
+ * less often each time a revive did not bring a recorder back, and not in
41
+ * the server's first {@link REVIVE_GRACE_MS}, while the SessionStart hook
42
+ * is starting the recorder itself.
43
+ * - Neither writes a byte to stdout (the host's JSON-RPC stream), and
44
+ * neither throws.
45
+ */
46
+ import { spawn } from "node:child_process";
47
+ import { readFileSync, statSync } from "node:fs";
48
+ import { pidAlive } from "../control/discovery.js";
49
+ import { configDir, discoveryPath } from "../control/paths.js";
50
+ import { nodeFlagsToCarry, projectRecord, readSidecar } from "../config/generate.js";
51
+ import { errText, logDetail, logLine } from "../util/log.js";
52
+ import { packageVersion } from "../util/version.js";
53
+ /** A hello waits no longer than this for the recorder's answer. */
54
+ export const HELLO_TIMEOUT_MS = 1_000;
55
+ /** A hello is repeated this often while the server runs (the recorder trusts one for 30 min). */
56
+ export const HELLO_EVERY_MS = 5 * 60_000;
57
+ /**
58
+ * How often the discovery file is looked at: whether the recorder changed (a
59
+ * hello is owed to the new one) or died (a revive may be). One `stat`, and a
60
+ * parse only when the file changed.
61
+ */
62
+ export const WATCH_EVERY_MS = 2_000;
63
+ /** At most one revive per this long per process; doubled each time one did not help. */
64
+ export const REVIVE_EVERY_MS = 30_000;
65
+ /** The longest a revive waits, at random, before it looks again and starts. */
66
+ export const REVIVE_JITTER_MS = 5_000;
67
+ /** The longest wait between revives that did not bring a recorder back. */
68
+ export const REVIVE_MAX_EVERY_MS = 5 * 60_000;
69
+ /**
70
+ * No revive in a server's first this-long: it starts with the session, and so
71
+ * does the SessionStart hook, whose own ensure (up to 11 s, ensure-hook.ts) is
72
+ * what starts the recorder then — typically over a dead record left from
73
+ * before a reboot. Reviving beside it would only race it.
74
+ */
75
+ export const REVIVE_GRACE_MS = 12_000;
76
+ /** How long the "does anything listen there" probe waits before calling the recorder busy. */
77
+ export const REFUSED_PROBE_MS = 2_000;
78
+ /** The longest wait between hellos a recorder could not take. */
79
+ const HELLO_RETRY_MAX_MS = 60_000;
80
+ let recordCache;
81
+ /**
82
+ * This project's discovery record, whether or not its pid is alive — the
83
+ * shared reader drops a dead pid's record, and a dead pid is exactly what a
84
+ * revive needs to see. Parsed again only when the file changed. Never throws.
85
+ */
86
+ export function readRecorderRecord(cwd) {
87
+ try {
88
+ const path = discoveryPath(cwd);
89
+ let stamp;
90
+ try {
91
+ const st = statSync(path);
92
+ // The inode too: the file is replaced by a rename, and a coarse clock can
93
+ // give the replacement the same time and, by chance, the same size.
94
+ stamp = `${st.ino}:${st.mtimeMs}:${st.size}`;
95
+ }
96
+ catch {
97
+ recordCache = { path, stamp: "-", record: undefined };
98
+ return undefined;
99
+ }
100
+ if (recordCache && recordCache.path === path && recordCache.stamp === stamp)
101
+ return recordCache.record;
102
+ let record;
103
+ try {
104
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
105
+ record =
106
+ typeof parsed.url === "string" && typeof parsed.token === "string" && typeof parsed.pid === "number"
107
+ ? { url: parsed.url, token: parsed.token, pid: parsed.pid }
108
+ : undefined;
109
+ }
110
+ catch {
111
+ record = undefined; // being replaced this instant, or damaged: nothing to go on
112
+ }
113
+ recordCache = { path, stamp, record };
114
+ return record;
115
+ }
116
+ catch {
117
+ return undefined;
118
+ }
119
+ }
120
+ /** Whether `cwd` is a project `bir setup`/`bir install` put the runner in. Never throws. */
121
+ export function projectInstalled(cwd) {
122
+ try {
123
+ return projectRecord(readSidecar(), cwd) !== undefined;
124
+ }
125
+ catch {
126
+ return false;
127
+ }
128
+ }
129
+ /**
130
+ * Start `bir-hooks revive --cwd <cwd>` from this package, detached, and forget
131
+ * it: it reads nothing, writes nothing we could see, and exits by itself.
132
+ */
133
+ export async function launchRevive(cwd) {
134
+ // Loaded here, not at the top: a revive is rare, and the module graph behind
135
+ // the daemon is not needed to answer MCP.
136
+ const { hooksScriptPath } = await import("../control/daemon.js");
137
+ const child = spawn(process.execPath, [...nodeFlagsToCarry(), hooksScriptPath(), "revive", "--cwd", cwd], {
138
+ // Not the project: on Windows a process holds its current directory, and
139
+ // the project folder must stay free to rename or delete (daemon.ts).
140
+ cwd: configDir(),
141
+ detached: true,
142
+ stdio: "ignore",
143
+ // A detached child on Windows would get a console window of its own.
144
+ windowsHide: true,
145
+ env: process.env,
146
+ });
147
+ child.on("error", (err) => logLine("scenario.revive_failed", { error: errText(err) }));
148
+ child.unref();
149
+ }
150
+ /** How a fetch to the recorder failed: busy (timed out), refused (nothing listens), or otherwise. */
151
+ function reachOf(err) {
152
+ const e = err;
153
+ if (e?.name === "TimeoutError" || e?.name === "AbortError")
154
+ return "timeout";
155
+ if (e?.cause?.name === "TimeoutError")
156
+ return "timeout";
157
+ if (e?.cause?.code === "ECONNREFUSED")
158
+ return "refused";
159
+ // A host name with two addresses fails as one AggregateError of both.
160
+ const all = e?.cause?.errors;
161
+ if (Array.isArray(all) && all.length > 0 && all.every((x) => x?.code === "ECONNREFUSED"))
162
+ return "refused";
163
+ return "error";
164
+ }
165
+ function trimUrl(url) {
166
+ return url.replace(/\/+$/, "");
167
+ }
168
+ const unrefSleep = (ms) => new Promise((resolve) => {
169
+ const t = setTimeout(resolve, ms);
170
+ t.unref?.();
171
+ });
172
+ /** See the module comment. One per `bir-scenario` process. */
173
+ export class ScenarioLiveness {
174
+ opts;
175
+ cwd;
176
+ now;
177
+ random;
178
+ fetchImpl;
179
+ record;
180
+ alive;
181
+ installed;
182
+ launch;
183
+ sleep;
184
+ env;
185
+ timer;
186
+ stopped = false;
187
+ /** No revive before this (see {@link REVIVE_GRACE_MS}). */
188
+ reviveFrom;
189
+ // ── hello ──
190
+ helloBusy = false;
191
+ /** A hello asked for while one was in flight: sent when it ends, with the state as it is then. */
192
+ helloOwed;
193
+ /** The recorder the last hello was answered by (`url#pid`). */
194
+ reached;
195
+ /** The recorder the last hello was sent to, answered or not. */
196
+ tried;
197
+ lastHelloAt = 0;
198
+ helloFailures = 0;
199
+ helloNotBefore = 0;
200
+ // ── revive ──
201
+ reviveBusy = false;
202
+ reviveNotBefore = 0;
203
+ /** Revives started since a recorder was last seen alive. */
204
+ revivesInARow = 0;
205
+ /** When this process last started one (0: never). */
206
+ lastReviveAt = 0;
207
+ /** Revives started by this process. */
208
+ revives = 0;
209
+ /** Hellos a recorder answered. */
210
+ hellos = 0;
211
+ constructor(opts) {
212
+ this.opts = opts;
213
+ this.cwd = opts.cwd;
214
+ this.now = opts.now ?? Date.now;
215
+ this.random = opts.random ?? Math.random;
216
+ this.fetchImpl = opts.fetch ?? fetch;
217
+ this.record = opts.record ?? readRecorderRecord;
218
+ this.alive = opts.alive ?? pidAlive;
219
+ this.installed = opts.installed ?? projectInstalled;
220
+ this.launch = opts.launch ?? launchRevive;
221
+ this.sleep = opts.sleep ?? unrefSleep;
222
+ this.env = opts.env ?? process.env;
223
+ this.reviveFrom = this.now() + Math.max(0, opts.reviveGraceMs ?? REVIVE_GRACE_MS);
224
+ }
225
+ /** Watch the discovery file every {@link WATCH_EVERY_MS}, on a timer that never keeps the process alive. */
226
+ start(everyMs = WATCH_EVERY_MS) {
227
+ if (this.timer || this.stopped)
228
+ return;
229
+ this.timer = setInterval(() => void this.tick(), everyMs);
230
+ this.timer.unref();
231
+ }
232
+ /** The host is gone: nothing more is sent or started. */
233
+ stop() {
234
+ this.stopped = true;
235
+ if (this.timer)
236
+ clearInterval(this.timer);
237
+ this.timer = undefined;
238
+ }
239
+ /**
240
+ * Tell the recorder what this server lists, now — or, while a hello is in
241
+ * flight, as soon as it ends. Returns at once; never throws.
242
+ */
243
+ hello(why) {
244
+ if (this.stopped)
245
+ return;
246
+ if (this.helloBusy) {
247
+ this.helloOwed = why;
248
+ return;
249
+ }
250
+ this.helloBusy = true;
251
+ // On the next turn of the loop: the answer this follows goes out first,
252
+ // and not even reading the discovery file happens before it.
253
+ setImmediate(() => void this.sendHello(why));
254
+ }
255
+ /**
256
+ * Something on the MCP path found no recorder (`run_scenario` found no live
257
+ * discovery file, or its request was refused): look now rather than at the
258
+ * next tick. Returns at once; never throws.
259
+ */
260
+ recorderGone(why) {
261
+ if (this.stopped)
262
+ return;
263
+ setImmediate(() => void this.checkRevive(why));
264
+ }
265
+ /** One look at the discovery file. Exposed for tests; never throws. */
266
+ async tick() {
267
+ if (this.stopped)
268
+ return;
269
+ try {
270
+ const rec = this.record(this.cwd);
271
+ if (rec && !this.alive(rec.pid)) {
272
+ await this.checkRevive("the recorder's pid is gone");
273
+ return;
274
+ }
275
+ if (rec)
276
+ this.recorderSeenAlive();
277
+ const target = this.target();
278
+ if (!target)
279
+ return;
280
+ const now = this.now();
281
+ if (target.identity !== this.tried) {
282
+ this.hello("recorder");
283
+ return;
284
+ }
285
+ if (now < this.helloNotBefore)
286
+ return;
287
+ if (target.identity !== this.reached || now - this.lastHelloAt >= HELLO_EVERY_MS) {
288
+ this.hello(target.identity !== this.reached ? "retry" : "periodic");
289
+ }
290
+ }
291
+ catch (err) {
292
+ logDetail("scenario.liveness_failed", { error: errText(err) });
293
+ }
294
+ }
295
+ /**
296
+ * Where a hello goes, the way `run_scenario`'s request does: `BIR_CONTROL_URL`
297
+ * (with `BIR_CONTROL_TOKEN`, or the discovery file's token), else the
298
+ * discovery file — whose pid must be alive.
299
+ */
300
+ target() {
301
+ const rec = this.record(this.cwd);
302
+ const live = rec && this.alive(rec.pid) ? rec : undefined;
303
+ const envUrl = this.env.BIR_CONTROL_URL?.trim();
304
+ if (envUrl) {
305
+ const token = this.env.BIR_CONTROL_TOKEN || live?.token;
306
+ if (!token)
307
+ return undefined;
308
+ return { url: trimUrl(envUrl), token, identity: `${trimUrl(envUrl)}#${live?.pid ?? 0}` };
309
+ }
310
+ if (!live)
311
+ return undefined;
312
+ return { url: trimUrl(live.url), token: live.token, identity: `${trimUrl(live.url)}#${live.pid}` };
313
+ }
314
+ state() {
315
+ try {
316
+ const s = this.opts.state();
317
+ return { tools: Number.isInteger(s.tools) && s.tools >= 0 ? s.tools : 0, off: s.off === true };
318
+ }
319
+ catch {
320
+ return { tools: 0, off: false };
321
+ }
322
+ }
323
+ async sendHello(why) {
324
+ this.helloBusy = true;
325
+ try {
326
+ if (this.stopped)
327
+ return;
328
+ // No recorder yet (the host often starts this server before SessionStart
329
+ // starts the recorder): the watch says hello when one appears.
330
+ const target = this.target();
331
+ if (!target)
332
+ return;
333
+ const { tools, off } = this.state();
334
+ const body = { pid: process.pid, version: packageVersion(), tools, off };
335
+ this.tried = target.identity;
336
+ this.lastHelloAt = this.now();
337
+ let reach;
338
+ let status;
339
+ try {
340
+ const res = await this.fetchImpl(`${target.url}/scenario/hello`, {
341
+ method: "POST",
342
+ headers: { "content-type": "application/json", authorization: `Bearer ${target.token}` },
343
+ body: JSON.stringify(body),
344
+ signal: AbortSignal.timeout(HELLO_TIMEOUT_MS),
345
+ });
346
+ status = res.status;
347
+ // The answer is `{ok:true}` and nothing here needs it; read it so the
348
+ // connection can be reused, within the same bound.
349
+ await res.arrayBuffer().catch(() => undefined);
350
+ reach = "answered";
351
+ }
352
+ catch (err) {
353
+ reach = reachOf(err);
354
+ }
355
+ if (reach === "answered") {
356
+ this.reached = target.identity;
357
+ this.hellos += 1;
358
+ this.helloFailures = 0;
359
+ this.helloNotBefore = 0;
360
+ // A recorder from before this route answers 404: it does not ask for
361
+ // hellos, so there is nothing to retry until it is replaced.
362
+ logDetail("scenario.hello", { why, tools, off, status });
363
+ return;
364
+ }
365
+ this.helloFailures += 1;
366
+ this.helloNotBefore =
367
+ this.now() + Math.min(WATCH_EVERY_MS * 2 ** Math.min(this.helloFailures - 1, 10), HELLO_RETRY_MAX_MS);
368
+ logDetail("scenario.hello_failed", { why, reach });
369
+ if (reach === "refused")
370
+ this.recorderGone("the recorder refused a hello");
371
+ }
372
+ catch (err) {
373
+ logDetail("scenario.hello_failed", { why, error: errText(err) });
374
+ }
375
+ finally {
376
+ this.helloBusy = false;
377
+ const owed = this.helloOwed;
378
+ this.helloOwed = undefined;
379
+ if (owed !== undefined && !this.stopped)
380
+ this.hello(owed);
381
+ }
382
+ }
383
+ /** Whether anything listens at `url`: only a refused connection counts as nothing. */
384
+ async probe(url) {
385
+ try {
386
+ const res = await this.fetchImpl(`${trimUrl(url)}/health`, { signal: AbortSignal.timeout(REFUSED_PROBE_MS) });
387
+ await res.arrayBuffer().catch(() => undefined);
388
+ return "answered";
389
+ }
390
+ catch (err) {
391
+ return reachOf(err);
392
+ }
393
+ }
394
+ /**
395
+ * A recorder is up: the revives before it helped (or were not needed), so the
396
+ * next death waits the plain {@link REVIVE_EVERY_MS} since the last one, not
397
+ * a doubled wait.
398
+ */
399
+ recorderSeenAlive() {
400
+ if (this.revivesInARow === 0)
401
+ return;
402
+ this.revivesInARow = 0;
403
+ this.reviveNotBefore = Math.min(this.reviveNotBefore, this.lastReviveAt + REVIVE_EVERY_MS);
404
+ }
405
+ /** Start a revive if — and only if — the recorder is dead, refused and this project's. */
406
+ async checkRevive(why) {
407
+ if (this.stopped || this.reviveBusy)
408
+ return;
409
+ const now = this.now();
410
+ if (now < this.reviveNotBefore || now < this.reviveFrom)
411
+ return;
412
+ this.reviveBusy = true;
413
+ try {
414
+ // Every process of the session sees the same death at about the same
415
+ // moment. Each waits a moment of its own and looks again: whoever went
416
+ // first has usually brought a recorder back by then.
417
+ const wait = Math.floor(Math.max(0, Math.min(1, this.random())) * REVIVE_JITTER_MS);
418
+ if (wait > 0)
419
+ await this.sleep(wait);
420
+ if (this.stopped)
421
+ return;
422
+ const rec = this.record(this.cwd);
423
+ // No record: `bir down` removed it (stopped on purpose, so it stays
424
+ // stopped), or no recorder ever ran here. A live pid: busy, or back.
425
+ if (!rec)
426
+ return;
427
+ if (this.alive(rec.pid)) {
428
+ this.recorderSeenAlive();
429
+ return;
430
+ }
431
+ const every = Math.min(REVIVE_EVERY_MS * 2 ** Math.min(this.revivesInARow, 10), REVIVE_MAX_EVERY_MS);
432
+ this.reviveNotBefore = this.now() + every;
433
+ if (!this.installed(this.cwd))
434
+ return;
435
+ const reach = await this.probe(rec.url);
436
+ // Something answers there (a recorder that has not rewritten the file
437
+ // yet), or it is slow to: not ours to replace.
438
+ if (reach !== "refused" || this.stopped)
439
+ return;
440
+ logLine("scenario.recorder_revive", {
441
+ pid: rec.pid,
442
+ url: rec.url,
443
+ why,
444
+ detail: "the project's recorder is gone mid-session; starting it again so its hooks answer",
445
+ });
446
+ this.revives += 1;
447
+ this.revivesInARow += 1;
448
+ this.lastReviveAt = this.now();
449
+ await this.launch(this.cwd);
450
+ }
451
+ catch (err) {
452
+ logLine("scenario.revive_failed", { error: errText(err) });
453
+ }
454
+ finally {
455
+ this.reviveBusy = false;
456
+ }
457
+ }
458
+ }
459
+ //# sourceMappingURL=scenario-liveness.js.map
@@ -0,0 +1,43 @@
1
+ /**
2
+ * scenario-stderr — how the `bir` MCP server (bir-scenario) reads the stderr
3
+ * of a `bir` it spawned for a scenario tool.
4
+ *
5
+ * Two kinds of line arrive there, mixed: `bir`'s audit lines
6
+ * (`[bir] <iso time> <event> …`), which belong in the log where `bir doctor`
7
+ * and a person look, and the sentences `bir` wrote for a person
8
+ * ("[bir] not signed in — run `bir login`"), which the model needs when the
9
+ * command failed. They are sorted line by line AS THEY ARRIVE: audit lines go
10
+ * straight on, and of the rest only the LAST so many characters are kept.
11
+ *
12
+ * WHY AS THEY ARRIVE, AND WHY THE LAST. Sorting after the exit, with the first
13
+ * 8 000 characters kept, lost exactly the line that mattered: with
14
+ * `BIR_VERBOSE=1` the audit lines filled the cap, and `bir`'s closing reason —
15
+ * the last thing it prints — never reached the model, which got "exited with
16
+ * code 1" and nothing to act on.
17
+ *
18
+ * Its own module so it can be tested: bir-scenario starts serving on import.
19
+ */
20
+ /** `bir`'s own audit lines — for the log, not for the model. */
21
+ export declare const AUDIT_LINE: RegExp;
22
+ export interface StderrSorterOptions {
23
+ /** How much of the non-audit text is kept: the last this many characters. */
24
+ keepChars: number;
25
+ /** How much audit text is passed on, at most; the rest is dropped. */
26
+ forwardChars: number;
27
+ /** Where an audit line goes (one line, without its newline). Must not throw. */
28
+ forward: (line: string) => void;
29
+ }
30
+ export declare class StderrSorter {
31
+ private readonly opts;
32
+ private partial;
33
+ /** Where the rest of a line cut for length goes, once its start was sorted. */
34
+ private continuing;
35
+ private kept;
36
+ private forwarded;
37
+ constructor(opts: StderrSorterOptions);
38
+ push(chunk: string): void;
39
+ /** Everything that was not an audit line (the last of it), once the stream ended. */
40
+ end(): string;
41
+ private line;
42
+ }
43
+ //# sourceMappingURL=scenario-stderr.d.ts.map
@@ -0,0 +1,76 @@
1
+ /**
2
+ * scenario-stderr — how the `bir` MCP server (bir-scenario) reads the stderr
3
+ * of a `bir` it spawned for a scenario tool.
4
+ *
5
+ * Two kinds of line arrive there, mixed: `bir`'s audit lines
6
+ * (`[bir] <iso time> <event> …`), which belong in the log where `bir doctor`
7
+ * and a person look, and the sentences `bir` wrote for a person
8
+ * ("[bir] not signed in — run `bir login`"), which the model needs when the
9
+ * command failed. They are sorted line by line AS THEY ARRIVE: audit lines go
10
+ * straight on, and of the rest only the LAST so many characters are kept.
11
+ *
12
+ * WHY AS THEY ARRIVE, AND WHY THE LAST. Sorting after the exit, with the first
13
+ * 8 000 characters kept, lost exactly the line that mattered: with
14
+ * `BIR_VERBOSE=1` the audit lines filled the cap, and `bir`'s closing reason —
15
+ * the last thing it prints — never reached the model, which got "exited with
16
+ * code 1" and nothing to act on.
17
+ *
18
+ * Its own module so it can be tested: bir-scenario starts serving on import.
19
+ */
20
+ /** `bir`'s own audit lines — for the log, not for the model. */
21
+ export const AUDIT_LINE = /^\[bir\] \d{4}-\d\d-\d\dT/;
22
+ export class StderrSorter {
23
+ opts;
24
+ partial = "";
25
+ /** Where the rest of a line cut for length goes, once its start was sorted. */
26
+ continuing;
27
+ kept = "";
28
+ forwarded = 0;
29
+ constructor(opts) {
30
+ this.opts = opts;
31
+ }
32
+ push(chunk) {
33
+ const lines = `${this.partial}${chunk}`.split("\n");
34
+ this.partial = lines.pop() ?? "";
35
+ for (const line of lines) {
36
+ this.line(line);
37
+ this.continuing = undefined;
38
+ }
39
+ // A "line" with no end in sight is sorted in pieces rather than held whole.
40
+ if (this.partial.length > this.opts.keepChars) {
41
+ this.line(this.partial);
42
+ this.partial = "";
43
+ }
44
+ }
45
+ /** Everything that was not an audit line (the last of it), once the stream ended. */
46
+ end() {
47
+ if (this.partial !== "")
48
+ this.line(this.partial);
49
+ this.partial = "";
50
+ this.continuing = undefined;
51
+ return this.kept;
52
+ }
53
+ line(raw) {
54
+ const line = raw.replace(/\r$/, "");
55
+ const kind = this.continuing ?? (AUDIT_LINE.test(line) ? "audit" : "kept");
56
+ this.continuing = kind;
57
+ if (line.trim() === "")
58
+ return;
59
+ if (kind === "audit") {
60
+ if (this.forwarded >= this.opts.forwardChars)
61
+ return;
62
+ this.forwarded += line.length + 1;
63
+ try {
64
+ this.opts.forward(line);
65
+ }
66
+ catch {
67
+ // The log is gone; nothing to do about that here.
68
+ }
69
+ return;
70
+ }
71
+ this.kept += `${line}\n`;
72
+ if (this.kept.length > this.opts.keepChars)
73
+ this.kept = this.kept.slice(-this.opts.keepChars);
74
+ }
75
+ }
76
+ //# sourceMappingURL=scenario-stderr.js.map
@@ -0,0 +1,88 @@
1
+ /**
2
+ * service-read — what `bir scenario show|edits` and `bir investigate` share
3
+ * about asking the service and printing its answer, so a slow service, a
4
+ * signed-out machine or a large scenario never turns into a stall or a flood.
5
+ *
6
+ * WHY A BOUND OF THEIR OWN. The `service()` these commands are handed settles
7
+ * the service address with a `/health` probe, may refresh the session, and
8
+ * then makes its request; a service (or a corporate proxy in front of it) that
9
+ * accepts the connection and never answers held every one of those for as long
10
+ * as Node's fetch waits — measured at 120 s through the `bir` MCP server, which
11
+ * is 120 s of a turn stalled on a debugging tool. A read is safe to abandon:
12
+ * nothing was asked to change. So each one gets {@link readTimeoutMs} — a
13
+ * command that reads twice shares it between them ({@link ReadClock}) — and
14
+ * then an answer in words. Writes (`check`, `edit`, `undo`, `calc`) are not
15
+ * bounded here — abandoning one would leave "was it saved?" unanswerable — and
16
+ * the MCP server bounds the whole command instead.
17
+ *
18
+ * WHY COMPACT JSON UNDER THE MCP SERVER. The `bir` MCP server runs these
19
+ * commands with `BIR_MCP=1` and hands their `--json` output to the model.
20
+ * Indentation is for a person at a terminal; in a model's context it is a
21
+ * quarter of the tokens and none of the meaning.
22
+ */
23
+ /** Default bound on one command's reads of the service. `BIR_READ_TIMEOUT_MS` overrides it. */
24
+ export declare const DEFAULT_READ_TIMEOUT_MS = 15000;
25
+ /**
26
+ * The most any timer here is set for from the environment. Node turns a delay
27
+ * past 2^31 - 1 ms into 1 ms, so a mistyped `BIR_READ_TIMEOUT_MS=3000000000`
28
+ * used to make every read give up at once; ten minutes is already far longer
29
+ * than anybody waits for a debugging tool.
30
+ */
31
+ export declare const MAX_ENV_TIMEOUT_MS = 600000;
32
+ /**
33
+ * A number of milliseconds from the environment: positive and at most
34
+ * {@link MAX_ENV_TIMEOUT_MS}, or `fallback`. Out of range is a mistake, and
35
+ * the default is the value known to work.
36
+ */
37
+ export declare function envTimeoutMs(name: string, fallback: number): number;
38
+ export declare function readTimeoutMs(): number;
39
+ /** True when the `bir` MCP server ran this command for a model (it sets `BIR_MCP=1`). */
40
+ export declare function underMcp(): boolean;
41
+ /** `--json` output: indented for a person, compact for a model. */
42
+ export declare function jsonText(value: unknown): string;
43
+ /**
44
+ * `pending`, or `{ error }` when it takes longer than `ms`. The request itself
45
+ * is not cancelled — `bir` exits as soon as the command returns, which is what
46
+ * ends it — and a late rejection is observed here, so it can never surface as
47
+ * an unhandled one. `statedMs` is the bound the answer names, when `ms` is
48
+ * only what was left of it ({@link ReadClock}).
49
+ */
50
+ export declare function readWithin<T>(pending: Promise<T | {
51
+ error: string;
52
+ }>, ms?: number, statedMs?: number): Promise<T | {
53
+ error: string;
54
+ }>;
55
+ /**
56
+ * One command's reads of the service, sharing one bound. A command that reads
57
+ * twice — `scenario edits` on a `run_` id finds the scenario, then its
58
+ * history; `investigate` tries the matched run after a "no" — used to give
59
+ * each read the whole bound, so a service that answered the first slowly and
60
+ * held the second took twice as long as the `bir` MCP server allows a
61
+ * reading tool, and was killed there with none of the answer the command
62
+ * already had (the journal half of `investigate`, say). Sharing the bound
63
+ * keeps the whole command inside it, and the timeout still names the bound,
64
+ * not the scrap of it the last read had left.
65
+ */
66
+ export declare class ReadClock {
67
+ readonly totalMs: number;
68
+ private readonly deadline;
69
+ constructor(totalMs?: number);
70
+ /** What is left of the bound, in ms (0 once it is spent). */
71
+ remaining(): number;
72
+ /** {@link readWithin}, with what is left of the command's bound. */
73
+ within<T>(pending: Promise<T | {
74
+ error: string;
75
+ }>): Promise<T | {
76
+ error: string;
77
+ }>;
78
+ }
79
+ /** Whether a `{ error }` means the service could not be reached at all, as opposed to answering. */
80
+ export declare function unreachable(error: string): boolean;
81
+ /**
82
+ * A failure to reach the service, in words a person (or a model) can act on.
83
+ * The transport's own words — "fetch failed", "This operation was aborted" —
84
+ * say nothing about what to do, so they are replaced; the sentences `bir`
85
+ * itself wrote ("not signed in — run `bir login`") are already the answer.
86
+ */
87
+ export declare function serviceProblem(error: string): string;
88
+ //# sourceMappingURL=service-read.d.ts.map