@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
@@ -17,6 +17,7 @@
17
17
  * declined and what mode armed; the service knows the baseline, the ledger and
18
18
  * whether a run ever became a recording. Both lists are printed, worst first.
19
19
  */
20
+ import { ReadClock, jsonText, readWithin, serviceProblem, underMcp, unreachable } from "./service-read.js";
20
21
  const num = (v) => {
21
22
  if (v === undefined || v === null || v === "")
22
23
  return undefined;
@@ -170,8 +171,8 @@ export const DECLINES = {
170
171
  fix: "wait for the calculation, or start one with `bir scenario calc <runId>`, then run the prompt again.",
171
172
  },
172
173
  similarity: {
173
- cause: "the prompt was a hit on the recording but below the steering threshold (BIR_REPLAY_MIN_SIMILARITY, default 0.92).",
174
- fix: "phrase the prompt closer to the recorded one, or lower BIR_REPLAY_MIN_SIMILARITY.",
174
+ cause: "the prompt was a hit on the recording but below the steering threshold (BIR_MIN_STEER_SIMILARITY, default 0.92).",
175
+ fix: "phrase the prompt closer to the recorded one, or lower BIR_MIN_STEER_SIMILARITY.",
175
176
  },
176
177
  coverage: {
177
178
  cause: "no step of the scenario could run anywhere: its tools are neither wrapped MCP servers registered in this session nor built-ins.",
@@ -435,16 +436,22 @@ export function renderService(out, s, viewerIsOwner) {
435
436
  renderFindings(out, s.findings ?? [], "Service findings");
436
437
  }
437
438
  // ── the command ───────────────────────────────────────────────────────────────
438
- async function fetchInvestigation(deps, id, limit) {
439
- const reply = await deps.service(`/investigate/${id}${limit ? `?limit=${limit}` : ""}`);
440
- if ("error" in reply)
441
- return { note: `service not consulted: ${reply.error}` };
439
+ async function fetchInvestigation(deps, id, limit, clock) {
440
+ const reply = await clock.within(deps.service(`/investigate/${id}${limit ? `?limit=${limit}` : ""}`));
441
+ if ("error" in reply) {
442
+ return { note: `service not consulted: ${serviceProblem(reply.error)}`, unreachable: unreachable(reply.error) };
443
+ }
442
444
  if (reply.status === 404)
443
445
  return { note: `the service has nothing for ${id} (not yours, or never reached it)` };
444
446
  if (reply.status !== 200)
445
447
  return { note: `service answered HTTP ${reply.status}` };
446
448
  return { data: reply.body };
447
449
  }
450
+ /** A turn without its raw journal lines, which only a person reading the whole record wants. */
451
+ function withoutEvents(turn) {
452
+ const { events, ...rest } = turn;
453
+ return { ...rest, eventCount: events.length };
454
+ }
448
455
  /** Which id the service should be asked about for a turn. */
449
456
  function serviceIdFor(turn) {
450
457
  return (turn.reported?.ticket ??
@@ -463,9 +470,10 @@ export async function investigateCommand(args, deps) {
463
470
  params.set("limit", String(args.limit));
464
471
  if (args.user)
465
472
  params.set("user", args.user);
466
- const reply = await deps.service(`/investigate/executions${params.size ? `?${params}` : ""}`);
473
+ const reply = await readWithin(deps.service(`/investigate/executions${params.size ? `?${params}` : ""}`));
467
474
  if ("error" in reply) {
468
- out(`Could not reach the service: ${reply.error}`);
475
+ const words = serviceProblem(reply.error);
476
+ out(`Could not reach the service: ${words}${words === reply.error ? "" : ` (${reply.error})`}`);
469
477
  return 1;
470
478
  }
471
479
  if (reply.status === 403) {
@@ -482,7 +490,7 @@ export async function investigateCommand(args, deps) {
482
490
  }
483
491
  const body = reply.body;
484
492
  if (args.json) {
485
- out(JSON.stringify(body, null, 2));
493
+ out(jsonText(body));
486
494
  return 0;
487
495
  }
488
496
  const rows = body.executions ?? [];
@@ -503,7 +511,7 @@ export async function investigateCommand(args, deps) {
503
511
  const limit = args.limit ?? 20;
504
512
  const recent = turns.slice(-limit).reverse();
505
513
  if (args.json) {
506
- out(JSON.stringify(recent, null, 2));
514
+ out(jsonText(underMcp() ? recent.map(withoutEvents) : recent));
507
515
  return 0;
508
516
  }
509
517
  if (recent.length === 0) {
@@ -550,14 +558,31 @@ export async function investigateCommand(args, deps) {
550
558
  }
551
559
  const findings = turn ? localFindings(turn) : [];
552
560
  const serviceId = turn ? serviceIdFor(turn) : id;
553
- const service = await fetchInvestigation(deps, serviceId, args.limit);
561
+ // Both reads below share one bound, so a slow "no" followed by a held
562
+ // second try still ends inside the time the `bir` MCP server allows a
563
+ // reading tool — with the journal half, which needed no service at all.
564
+ const clock = new ReadClock();
565
+ const service = await fetchInvestigation(deps, serviceId, args.limit, clock);
554
566
  // A matched turn's own run id is unknown to the service; when the scenario
555
- // lookup fails, the matched run is still worth a try.
556
- const fallbackId = turn && service.note && turn.matched?.runId && serviceId !== turn.matched.runId ? turn.matched.runId : undefined;
557
- const second = fallbackId ? await fetchInvestigation(deps, fallbackId, args.limit) : undefined;
567
+ // lookup fails, the matched run is still worth a try — unless the service
568
+ // could not be reached at all, when a second try only doubles the wait, or
569
+ // the bound is spent.
570
+ const fallbackId = turn &&
571
+ service.note &&
572
+ !service.unreachable &&
573
+ turn.matched?.runId &&
574
+ serviceId !== turn.matched.runId &&
575
+ clock.remaining() > 0
576
+ ? turn.matched.runId
577
+ : undefined;
578
+ const second = fallbackId ? await fetchInvestigation(deps, fallbackId, args.limit, clock) : undefined;
558
579
  const chosen = service.data ? service : second?.data ? second : service;
559
580
  if (args.json) {
560
- out(JSON.stringify({ turn: turn ?? null, findings, service: chosen.data ?? null, note: chosen.note ?? null }, null, 2));
581
+ // For a model (the `bir` MCP server), the turn goes without its raw
582
+ // journal lines: every field they carry is already parsed into the turn,
583
+ // and a long turn's lines are most of the output.
584
+ const shown = turn ? (underMcp() ? withoutEvents(turn) : turn) : null;
585
+ out(jsonText({ turn: shown, findings, service: chosen.data ?? null, note: chosen.note ?? null }));
561
586
  return 0;
562
587
  }
563
588
  if (turn) {
@@ -143,7 +143,13 @@ export interface ScenarioEditArgs {
143
143
  }
144
144
  /** The `bir scenario` words this module answers; `list` and `replay` stay in bir.ts. */
145
145
  export declare const SCENARIO_EDIT_SUBCOMMANDS: ReadonlySet<string>;
146
- /** The `bir` MCP server's tools that only read: always offered (D2). */
146
+ /**
147
+ * The `bir` MCP server's tools that only read. Offered with scenario editing
148
+ * on, or where the server's environment has `BIR_SCENARIO_TOOLS=read` — no
149
+ * longer in every session: their definitions ride along with every request a
150
+ * session makes, and only somebody working on a scenario ever calls them (the
151
+ * same answers are `bir scenario show` and `bir investigate` in a terminal).
152
+ */
147
153
  export declare const READ_TOOLS: readonly ["scenario_show", "scenario_edits", "investigate"];
148
154
  /** The ones that change a scenario: offered only after `bir scenario editing on` (D2). */
149
155
  export declare const EDIT_TOOLS: readonly ["scenario_check", "scenario_edit", "scenario_undo"];
@@ -31,6 +31,7 @@
31
31
  import { normalizePath } from "../control/paths.js";
32
32
  import { SCENARIO_SERVER_KEY, editingEnabled, projectRecord, readSidecar, setEditing, writeSidecar, } from "../config/generate.js";
33
33
  import { errText } from "../util/log.js";
34
+ import { ReadClock, jsonText, readWithin, serviceProblem } from "./service-read.js";
34
35
  /** The `bir scenario` words this module answers; `list` and `replay` stay in bir.ts. */
35
36
  export const SCENARIO_EDIT_SUBCOMMANDS = new Set([
36
37
  "show",
@@ -41,7 +42,13 @@ export const SCENARIO_EDIT_SUBCOMMANDS = new Set([
41
42
  "calc",
42
43
  "editing",
43
44
  ]);
44
- /** The `bir` MCP server's tools that only read: always offered (D2). */
45
+ /**
46
+ * The `bir` MCP server's tools that only read. Offered with scenario editing
47
+ * on, or where the server's environment has `BIR_SCENARIO_TOOLS=read` — no
48
+ * longer in every session: their definitions ride along with every request a
49
+ * session makes, and only somebody working on a scenario ever calls them (the
50
+ * same answers are `bir scenario show` and `bir investigate` in a terminal).
51
+ */
45
52
  export const READ_TOOLS = ["scenario_show", "scenario_edits", "investigate"];
46
53
  /** The ones that change a scenario: offered only after `bir scenario editing on` (D2). */
47
54
  export const EDIT_TOOLS = ["scenario_check", "scenario_edit", "scenario_undo"];
@@ -91,7 +98,7 @@ async function show(args, deps) {
91
98
  if (!target)
92
99
  return usageError(deps, "show");
93
100
  const byScenario = target.startsWith("scn_");
94
- const reply = await deps.service("GET", byScenario ? `/scenarios/${seg(target)}` : `/recordings/runs/${seg(target)}/scenario`);
101
+ const reply = await readWithin(deps.service("GET", byScenario ? `/scenarios/${seg(target)}` : `/recordings/runs/${seg(target)}/scenario`));
95
102
  if ("error" in reply || reply.status !== 200) {
96
103
  return explain(reply, args, deps, {
97
104
  verb: "Could not read the scenario",
@@ -102,7 +109,7 @@ async function show(args, deps) {
102
109
  });
103
110
  }
104
111
  if (args.step === undefined) {
105
- out(JSON.stringify(reply.body, null, 2));
112
+ out(jsonText(reply.body));
106
113
  return 0;
107
114
  }
108
115
  const served = reply.body;
@@ -119,7 +126,7 @@ async function show(args, deps) {
119
126
  out(`${scenario.id ?? target} has no step ${args.step}${indexes.length ? ` (its steps are ${Math.min(...indexes)}–${Math.max(...indexes)})` : " (it has no steps)"}.`);
120
127
  return 1;
121
128
  }
122
- out(JSON.stringify({ scenarioId: scenario.id ?? null, chainRevision: scenario.chainRevision ?? null, ...step }, null, 2));
129
+ out(jsonText({ scenarioId: scenario.id ?? null, chainRevision: scenario.chainRevision ?? null, ...step }));
123
130
  return 0;
124
131
  }
125
132
  /**
@@ -184,7 +191,7 @@ async function checkOrEdit(args, deps, save) {
184
191
  return explain(reply, args, deps, { verb: "Not checked", ...explained });
185
192
  const report = reply.body?.report;
186
193
  if (args.json) {
187
- out(JSON.stringify(reply.body, null, 2));
194
+ out(jsonText(reply.body));
188
195
  return report?.ok || report?.noChange ? 0 : 1;
189
196
  }
190
197
  if (!report) {
@@ -225,7 +232,7 @@ async function checkOrEdit(args, deps, save) {
225
232
  return explain(reply, args, deps, { verb: "Not saved", ...explained });
226
233
  }
227
234
  if (args.json) {
228
- out(JSON.stringify(reply.body, null, 2));
235
+ out(jsonText(reply.body));
229
236
  return refused ? 1 : 0;
230
237
  }
231
238
  if (answer?.report)
@@ -489,7 +496,7 @@ function refusal(out, report, opening, hint) {
489
496
  }
490
497
  // ── edits / undo ──────────────────────────────────────────────────────────────
491
498
  async function fetchEdits(scenarioId, deps) {
492
- const reply = await deps.service("GET", `/scenarios/${seg(scenarioId)}/edits`);
499
+ const reply = await readWithin(deps.service("GET", `/scenarios/${seg(scenarioId)}/edits`));
493
500
  if ("error" in reply || reply.status !== 200)
494
501
  return { reply };
495
502
  const edits = reply.body?.edits;
@@ -500,15 +507,18 @@ async function listEdits(args, deps) {
500
507
  const target = args.positionals[1];
501
508
  if (!target)
502
509
  return usageError(deps, "edits");
503
- const resolved = await resolveTarget(target, args, deps);
510
+ // A `run_` id is two reads — which scenario, then its history — inside one
511
+ // bound: the `bir` MCP server gives a reading tool one read's worth of time.
512
+ const clock = new ReadClock();
513
+ const resolved = await resolveTarget(target, args, deps, clock);
504
514
  if ("code" in resolved)
505
515
  return resolved.code;
506
- const reply = await deps.service("GET", `/scenarios/${seg(resolved.id)}/edits`);
516
+ const reply = await clock.within(deps.service("GET", `/scenarios/${seg(resolved.id)}/edits`));
507
517
  if ("error" in reply || reply.status !== 200) {
508
518
  return explain(reply, args, deps, { verb: "Could not read the history", target });
509
519
  }
510
520
  if (args.json) {
511
- out(JSON.stringify(reply.body, null, 2));
521
+ out(jsonText(reply.body));
512
522
  return 0;
513
523
  }
514
524
  const edits = reply.body?.edits ?? [];
@@ -626,7 +636,7 @@ async function undo(args, deps) {
626
636
  });
627
637
  }
628
638
  if (args.json) {
629
- out(JSON.stringify(reply.body, null, 2));
639
+ out(jsonText(reply.body));
630
640
  return refused ? 1 : 0;
631
641
  }
632
642
  if (answer?.report)
@@ -687,7 +697,7 @@ async function calc(args, deps) {
687
697
  if ("error" in reply)
688
698
  return explain(reply, args, deps, { verb: "Could not start calculation", target });
689
699
  if (args.json) {
690
- out(JSON.stringify(reply.body ?? null, null, 2));
700
+ out(jsonText(reply.body ?? null));
691
701
  return reply.status === 202 ? 0 : 1;
692
702
  }
693
703
  const { status, body } = reply;
@@ -771,11 +781,19 @@ function describeEditing(out, sidecar, cwd) {
771
781
  if (!hasScenarioServer(sidecar, cwd)) {
772
782
  out(" (no `bir` MCP server is installed here, so no tool is offered at all — `bir install --replay` adds it)");
773
783
  }
774
- out(` the bir MCP server offers ${READ_TOOLS.join(", ")} (they read)`);
775
- if (on)
784
+ // The reading tools come with the switch too: they are for whoever is
785
+ // working on a scenario, and every other session would pay for their
786
+ // definitions on every request without ever calling them.
787
+ if (on) {
788
+ out(` the bir MCP server offers run_scenario, ${READ_TOOLS.join(", ")} (they read)`);
776
789
  out(` and ${EDIT_TOOLS.join(", ")} (they change a scenario)`);
777
- else
790
+ }
791
+ else {
792
+ out(" the bir MCP server offers run_scenario");
778
793
  out(` \`bir scenario editing on\` adds ${EDIT_TOOLS.join(", ")} (they change a scenario)`);
794
+ out(` and ${READ_TOOLS.join(", ")} (they read)`);
795
+ out(" (BIR_SCENARIO_TOOLS=read in the server's environment offers the reading ones alone)");
796
+ }
779
797
  out(" The `bir scenario` commands work either way: this switch decides only the MCP tools.");
780
798
  }
781
799
  function hasScenarioServer(sidecar, cwd) {
@@ -796,7 +814,7 @@ function usageError(deps, sub, why) {
796
814
  * `{error, details}` shape (with `extra` beside them when there is more to say).
797
815
  */
798
816
  function localFailure(args, deps, code, text, extra = {}) {
799
- deps.out(args.json ? JSON.stringify({ error: code, details: text, ...extra }, null, 2) : text);
817
+ deps.out(args.json ? jsonText({ error: code, details: text, ...extra }) : text);
800
818
  return 1;
801
819
  }
802
820
  /**
@@ -822,10 +840,10 @@ function noWholeRunScenario(target, segments, args, deps) {
822
840
  * `scn_` id (D7). A `run_` id reaches only the run's **whole-run** scenario; a
823
841
  * `scn_` id is used as it is — sub-task scenarios have nothing else.
824
842
  */
825
- async function resolveTarget(target, args, deps) {
843
+ async function resolveTarget(target, args, deps, clock = new ReadClock()) {
826
844
  if (target.startsWith("scn_"))
827
845
  return { id: target };
828
- const reply = await deps.service("GET", `/recordings/runs/${seg(target)}/scenario`);
846
+ const reply = await clock.within(deps.service("GET", `/recordings/runs/${seg(target)}/scenario`));
829
847
  if ("error" in reply || reply.status !== 200) {
830
848
  return {
831
849
  code: explain(reply, args, deps, {
@@ -849,14 +867,18 @@ async function resolveTarget(target, args, deps) {
849
867
  function explain(reply, args, deps, e) {
850
868
  const { out } = deps;
851
869
  if ("error" in reply) {
870
+ // In words, not the transport's ("fetch failed"): this is what reaches a
871
+ // model through the `bir` MCP server. A person at a terminal also gets the
872
+ // raw reason in brackets, for a bug report.
873
+ const words = serviceProblem(reply.error);
852
874
  if (args.json)
853
- out(JSON.stringify({ error: "service_unreachable", details: reply.error }, null, 2));
875
+ out(jsonText({ error: "service_unreachable", details: words }));
854
876
  else
855
- out(`Could not reach the service: ${reply.error}`);
877
+ out(`Could not reach the service: ${words}${words === reply.error ? "" : ` (${reply.error})`}`);
856
878
  return 1;
857
879
  }
858
880
  if (args.json) {
859
- out(JSON.stringify(reply.body ?? null, null, 2));
881
+ out(jsonText(reply.body ?? null));
860
882
  return 1;
861
883
  }
862
884
  const body = (reply.body && typeof reply.body === "object" ? reply.body : {});
@@ -0,0 +1,194 @@
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
+ /** A hello waits no longer than this for the recorder's answer. */
47
+ export declare const HELLO_TIMEOUT_MS = 1000;
48
+ /** A hello is repeated this often while the server runs (the recorder trusts one for 30 min). */
49
+ export declare const HELLO_EVERY_MS: number;
50
+ /**
51
+ * How often the discovery file is looked at: whether the recorder changed (a
52
+ * hello is owed to the new one) or died (a revive may be). One `stat`, and a
53
+ * parse only when the file changed.
54
+ */
55
+ export declare const WATCH_EVERY_MS = 2000;
56
+ /** At most one revive per this long per process; doubled each time one did not help. */
57
+ export declare const REVIVE_EVERY_MS = 30000;
58
+ /** The longest a revive waits, at random, before it looks again and starts. */
59
+ export declare const REVIVE_JITTER_MS = 5000;
60
+ /** The longest wait between revives that did not bring a recorder back. */
61
+ export declare const REVIVE_MAX_EVERY_MS: number;
62
+ /**
63
+ * No revive in a server's first this-long: it starts with the session, and so
64
+ * does the SessionStart hook, whose own ensure (up to 11 s, ensure-hook.ts) is
65
+ * what starts the recorder then — typically over a dead record left from
66
+ * before a reboot. Reviving beside it would only race it.
67
+ */
68
+ export declare const REVIVE_GRACE_MS = 12000;
69
+ /** How long the "does anything listen there" probe waits before calling the recorder busy. */
70
+ export declare const REFUSED_PROBE_MS = 2000;
71
+ /** What a hello says. */
72
+ export interface HelloBody {
73
+ pid: number;
74
+ version: string;
75
+ /** How many tools this server lists now (0 when off). */
76
+ tools: number;
77
+ off: boolean;
78
+ }
79
+ /** A discovery record as written, its pid not yet checked. */
80
+ export interface RecorderRecord {
81
+ url: string;
82
+ token: string;
83
+ pid: number;
84
+ }
85
+ /** How a request to the recorder's address ended. */
86
+ export type Reach = "answered" | "refused" | "timeout" | "error";
87
+ /**
88
+ * This project's discovery record, whether or not its pid is alive — the
89
+ * shared reader drops a dead pid's record, and a dead pid is exactly what a
90
+ * revive needs to see. Parsed again only when the file changed. Never throws.
91
+ */
92
+ export declare function readRecorderRecord(cwd: string): RecorderRecord | undefined;
93
+ /** Whether `cwd` is a project `bir setup`/`bir install` put the runner in. Never throws. */
94
+ export declare function projectInstalled(cwd: string): boolean;
95
+ /**
96
+ * Start `bir-hooks revive --cwd <cwd>` from this package, detached, and forget
97
+ * it: it reads nothing, writes nothing we could see, and exits by itself.
98
+ */
99
+ export declare function launchRevive(cwd: string): Promise<void>;
100
+ export interface LivenessOptions {
101
+ /** The project this server runs for. */
102
+ cwd: string;
103
+ /** What this server lists now. Called for every hello. */
104
+ state: () => {
105
+ tools: number;
106
+ off: boolean;
107
+ };
108
+ now?: () => number;
109
+ random?: () => number;
110
+ fetch?: typeof fetch;
111
+ record?: (cwd: string) => RecorderRecord | undefined;
112
+ alive?: (pid: number) => boolean;
113
+ installed?: (cwd: string) => boolean;
114
+ launch?: (cwd: string) => Promise<void> | void;
115
+ sleep?: (ms: number) => Promise<void>;
116
+ env?: NodeJS.ProcessEnv;
117
+ /** Default {@link REVIVE_GRACE_MS}. */
118
+ reviveGraceMs?: number;
119
+ }
120
+ /** See the module comment. One per `bir-scenario` process. */
121
+ export declare class ScenarioLiveness {
122
+ private readonly opts;
123
+ private readonly cwd;
124
+ private readonly now;
125
+ private readonly random;
126
+ private readonly fetchImpl;
127
+ private readonly record;
128
+ private readonly alive;
129
+ private readonly installed;
130
+ private readonly launch;
131
+ private readonly sleep;
132
+ private readonly env;
133
+ private timer;
134
+ private stopped;
135
+ /** No revive before this (see {@link REVIVE_GRACE_MS}). */
136
+ private readonly reviveFrom;
137
+ private helloBusy;
138
+ /** A hello asked for while one was in flight: sent when it ends, with the state as it is then. */
139
+ private helloOwed;
140
+ /** The recorder the last hello was answered by (`url#pid`). */
141
+ private reached;
142
+ /** The recorder the last hello was sent to, answered or not. */
143
+ private tried;
144
+ private lastHelloAt;
145
+ private helloFailures;
146
+ private helloNotBefore;
147
+ private reviveBusy;
148
+ private reviveNotBefore;
149
+ /** Revives started since a recorder was last seen alive. */
150
+ private revivesInARow;
151
+ /** When this process last started one (0: never). */
152
+ private lastReviveAt;
153
+ /** Revives started by this process. */
154
+ revives: number;
155
+ /** Hellos a recorder answered. */
156
+ hellos: number;
157
+ constructor(opts: LivenessOptions);
158
+ /** Watch the discovery file every {@link WATCH_EVERY_MS}, on a timer that never keeps the process alive. */
159
+ start(everyMs?: number): void;
160
+ /** The host is gone: nothing more is sent or started. */
161
+ stop(): void;
162
+ /**
163
+ * Tell the recorder what this server lists, now — or, while a hello is in
164
+ * flight, as soon as it ends. Returns at once; never throws.
165
+ */
166
+ hello(why: string): void;
167
+ /**
168
+ * Something on the MCP path found no recorder (`run_scenario` found no live
169
+ * discovery file, or its request was refused): look now rather than at the
170
+ * next tick. Returns at once; never throws.
171
+ */
172
+ recorderGone(why: string): void;
173
+ /** One look at the discovery file. Exposed for tests; never throws. */
174
+ tick(): Promise<void>;
175
+ /**
176
+ * Where a hello goes, the way `run_scenario`'s request does: `BIR_CONTROL_URL`
177
+ * (with `BIR_CONTROL_TOKEN`, or the discovery file's token), else the
178
+ * discovery file — whose pid must be alive.
179
+ */
180
+ private target;
181
+ private state;
182
+ private sendHello;
183
+ /** Whether anything listens at `url`: only a refused connection counts as nothing. */
184
+ private probe;
185
+ /**
186
+ * A recorder is up: the revives before it helped (or were not needed), so the
187
+ * next death waits the plain {@link REVIVE_EVERY_MS} since the last one, not
188
+ * a doubled wait.
189
+ */
190
+ private recorderSeenAlive;
191
+ /** Start a revive if — and only if — the recorder is dead, refused and this project's. */
192
+ private checkRevive;
193
+ }
194
+ //# sourceMappingURL=scenario-liveness.d.ts.map