@basein/runner 0.2.12 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +2786 -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
@@ -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.
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.
28
48
  *
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.
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.
39
60
  *
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.
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.
@@ -50,32 +90,113 @@
50
90
  import { readFrames, writeFrame } from "../jsonrpc/framing.js";
51
91
  import { JsonRpcErrorCode, errorResponse, isNotification, isRequest, paramsObject, } from "../jsonrpc/types.js";
52
92
  import { spawn } from "node:child_process";
53
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
93
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
54
94
  import { tmpdir } from "node:os";
55
95
  import { dirname, join, resolve as resolvePath } from "node:path";
56
96
  import { fileURLToPath } from "node:url";
57
97
  import { resolveControl } from "../control/discovery.js";
58
- import { editingEnabled, nodeFlagsToCarry, readSidecar } from "../config/generate.js";
98
+ import { editingEnabled, exemptLoopbackFromProxy, nodeFlagsToCarry, readSidecar } from "../config/generate.js";
99
+ import { credentialsPath, resolveAuthUrl } from "../auth/client.js";
100
+ import { isRunnerOff } from "../util/off-switch.js";
59
101
  import { logLine, logDetail, errText } from "../util/log.js";
102
+ import { packageVersion } from "../util/version.js";
103
+ import { envTimeoutMs, readTimeoutMs } from "./service-read.js";
104
+ import { StderrSorter } from "./scenario-stderr.js";
105
+ import { ScenarioLiveness } from "./scenario-liveness.js";
60
106
  /** Must match `DIRECT_TOOL_NAME`'s suffix in the replay controller. */
61
107
  const TOOL_NAME = "run_scenario";
62
- /** How long to look for a control server before answering that there is none. */
63
- const DISCOVERY_MS = 5_000;
64
- /** The plan can be long; the whole-plan budget lives on the control server. */
65
- const RUN_TIMEOUT_MS = 180_000;
108
+ /**
109
+ * How long to look for the control server before answering that there is
110
+ * nothing to run. A plan is armed only by a live control server, and that
111
+ * server wrote its discovery file before it could arm anything — so a missing
112
+ * file is a certain "no plan", and waiting for one (this used to be 5 s) only
113
+ * delayed that answer. The grace covers a file being replaced at that instant.
114
+ */
115
+ const DISCOVERY_GRACE_MS = 250;
116
+ /**
117
+ * The whole `run_scenario` request. The run's own budgets live on the control
118
+ * server (derivation, then the plan, each step clipped to it — about 200 s at
119
+ * the very worst), and this sits well above them, so a replay that finished is
120
+ * never thrown away as a client timeout. It also sits BELOW the host's own
121
+ * limit: Claude Code gives up on an MCP call that has been quiet for 5 min
122
+ * (`CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT`), and this server sends no progress, so
123
+ * at exactly 5 min its red timeout raced our neutral answer. Ours goes first.
124
+ * `BIR_SCENARIO_RUN_TIMEOUT_MS` overrides it (up to 10 min; past that, or
125
+ * nonsense, is the default — a delay past 2^31 ms is 1 ms to Node, which
126
+ * silently turned every replay off).
127
+ */
128
+ const RUN_TIMEOUT_MS = envTimeoutMs("BIR_SCENARIO_RUN_TIMEOUT_MS", 290_000);
129
+ /**
130
+ * `BIR_SCENARIO_DEBUG=1`: `run_scenario` answers with the raw reason, as an
131
+ * error, as it used to — the old sentences included, so "no recorder here" and
132
+ * "the recorder has no plan" can be told apart.
133
+ */
134
+ const DEBUG = process.env.BIR_SCENARIO_DEBUG === "1";
135
+ /** What the model reads when no plan is armed — the common case, and never an error. */
136
+ const NO_PLAN_TEXT = "Nothing to run for this request; continue normally.";
137
+ /** What the model reads when an armed plan could not be delivered, whatever the reason. */
138
+ const UNAVAILABLE_TEXT = "The prepared results are not available for this request. Do the task normally with your tools.";
139
+ /**
140
+ * What the model reads when a second `run_scenario` arrives while the plan is
141
+ * still running for the first (the control server refuses a second execution:
142
+ * it would repeat every step). The results come back on the first call, so
143
+ * this one must never read "do the task normally" — that is the model
144
+ * repeating, by hand, the very steps the run is making.
145
+ */
146
+ const ALREADY_RUNNING_TEXT = "The prepared results for this request are already being produced for an earlier run_scenario call, and they come back there. Do not call run_scenario again, and do not repeat its steps.";
147
+ /** Appended to a delivered replay that stopped before its last step: out of time, or its caller left. */
148
+ const PARTIAL_TEXT = "(partial: the scenario stopped before all of its steps ran)";
149
+ /** Every model-directed note the replay controller writes opens with this. */
150
+ const NOTE_PREFIX = "[BaseInstRunner calculated replay]";
151
+ /**
152
+ * The key Claude Code names the tool use under in a `tools/call`'s `_meta`:
153
+ * `{ ...mcpRequestMeta, "claudecode/toolUseId": <id> }` (seen in Claude Code
154
+ * 2.1.280) — the id its `PreToolUse` hook carried as `tool_use_id`.
155
+ */
156
+ const TOOL_USE_ID_META = "claudecode/toolUseId";
157
+ /** Ids longer than this are not a host's tool-use id, and are not forwarded. */
158
+ const MAX_TOOL_USE_ID_CHARS = 256;
159
+ /** The host's tool-use id for this `tools/call`, when it named one. Never throws. */
160
+ function toolUseIdOf(params) {
161
+ const value = paramsObject(params._meta)[TOOL_USE_ID_META];
162
+ return typeof value === "string" && value.trim() !== "" && value.length <= MAX_TOOL_USE_ID_CHARS ? value : undefined;
163
+ }
66
164
  const TOOL = {
67
165
  name: TOOL_NAME,
68
- description: "Run the pre-calculated tool sequence BaseInstRunner has armed for the current request, " +
69
- "and return its results. Call once, with no arguments, when instructed to.",
166
+ // Narrow on purpose. "Call once, when instructed to" read to models as an
167
+ // invitation: they called it on their own, to see what it did.
168
+ //
169
+ // "A note", not "a message": the directive never arrives as a message of its
170
+ // own. At prompt time it is hook context the host wraps in its own words; for
171
+ // a sub-task plan it is the reason a tool call was refused, or that call's
172
+ // result. A model reading "a message starting with" literally found none and
173
+ // did the work by hand.
174
+ description: "Returns the results BaseInstRunner prepared for the current request. Call it only when a BaseInstRunner " +
175
+ `note in this turn (text beginning "${NOTE_PREFIX}", in a system note or a tool result) tells you to; ` +
176
+ "never otherwise.",
70
177
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
71
178
  };
72
- /** Ask the control server to run the armed plan. Never throws. */
73
- async function runArmedPlan(cwd) {
74
- const found = await resolveControl(cwd, DISCOVERY_MS);
75
- if (!found?.url)
76
- return { ok: false, why: "no control server for this directory" };
179
+ /**
180
+ * Ask the control server to run the armed plan. Never throws. The reasons it
181
+ * returns are for the audit log; {@link toolResult} decides what the model reads.
182
+ * `toolUseId` is the host's id for this call, which the control server matches
183
+ * to the `PreToolUse` it saw (see WHOSE CALL above).
184
+ */
185
+ async function runArmedPlan(cwd, flight, toolUseId) {
186
+ const found = await resolveControl(cwd, DISCOVERY_GRACE_MS, 50);
187
+ // No file, or `BIR_CONTROL_URL` with no token to go with it: no control
188
+ // server that could have armed anything is running for this directory.
189
+ if (!found?.url || !found.token)
190
+ return { ok: false, why: "no_plan", kind: "no_control" };
191
+ if (flight.cancelled)
192
+ return { ok: false, why: "cancelled" };
77
193
  const controller = new AbortController();
78
- const timer = setTimeout(() => controller.abort(), RUN_TIMEOUT_MS);
194
+ flight.abort = controller;
195
+ let timedOut = false;
196
+ const timer = setTimeout(() => {
197
+ timedOut = true;
198
+ controller.abort();
199
+ }, RUN_TIMEOUT_MS);
79
200
  timer.unref?.();
80
201
  try {
81
202
  const res = await fetch(`${found.url.replace(/\/+$/, "")}/scenario/run`, {
@@ -84,63 +205,179 @@ async function runArmedPlan(cwd) {
84
205
  "content-type": "application/json",
85
206
  authorization: `Bearer ${found.token}`,
86
207
  },
87
- body: "{}",
208
+ body: JSON.stringify(toolUseId ? { toolUseId } : {}),
88
209
  signal: controller.signal,
89
210
  });
211
+ const raw = await res.text();
90
212
  if (!res.ok)
91
213
  return { ok: false, why: `control server answered HTTP ${res.status}` };
92
- return (await res.json());
214
+ let body;
215
+ try {
216
+ body = JSON.parse(raw);
217
+ }
218
+ catch {
219
+ return { ok: false, why: "control server answered something that is not JSON" };
220
+ }
221
+ // Something else on a reused port can answer `null`, a number or a list;
222
+ // reading `.ok` off that used to throw where nothing answered the call.
223
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
224
+ return { ok: false, why: "control server answered no result object" };
225
+ }
226
+ return body;
93
227
  }
94
228
  catch (err) {
95
- return { ok: false, why: errText(err) };
229
+ if (flight.cancelled)
230
+ return { ok: false, why: "cancelled" };
231
+ if (timedOut)
232
+ return { ok: false, why: `no answer within ${RUN_TIMEOUT_MS / 1000} s` };
233
+ // `unreachable`: the recorder may be gone, so the caller has it looked at.
234
+ return { ok: false, why: errText(err), kind: "unreachable" };
96
235
  }
97
236
  finally {
98
237
  clearTimeout(timer);
238
+ flight.abort = undefined;
99
239
  }
100
240
  }
241
+ /** A model-directed hand-over note: the plan stopped, and the note says what is left. */
242
+ function isHandover(result, why) {
243
+ return why !== "" && (result.note === true || result.kind === "handover" || why.startsWith(NOTE_PREFIX));
244
+ }
101
245
  /**
102
- * A `CallToolResult`. A turn with no armed plan is a **tool failure**, not a
103
- * protocol error: the model called a tool that had nothing to do — usually
104
- * because it remembered the tool from an earlier turn — and reacting to that is
105
- * something models do well and routinely.
246
+ * The control server's "this scenario is already running for this turn"
247
+ * (server.ts, `ALREADY_RUNNING`): matched on its words, which are all it
248
+ * sends. Checked after {@link isHandover}, so a note that happens to use the
249
+ * phrase still reaches the model whole.
250
+ */
251
+ function isAlreadyRunning(why) {
252
+ return /\balready running\b/i.test(why);
253
+ }
254
+ const said = (text) => ({ content: [{ type: "text", text }], isError: false });
255
+ /**
256
+ * The results a run delivered, or "" when it delivered none. A hand-over whose
257
+ * note was empty leaves the bundle behind a blank line or two; they go.
258
+ */
259
+ function delivered(result) {
260
+ return result.ok === true && typeof result.text === "string" ? result.text.replace(/^\s+/, "") : "";
261
+ }
262
+ /**
263
+ * The raw reason `BIR_SCENARIO_DEBUG=1` answers with. The three ways of having
264
+ * nothing to run share the code `no_plan` for the model's sake; whoever turned
265
+ * debugging on is asking which one it was.
266
+ */
267
+ function debugReason(result, why) {
268
+ if (result.kind === "no_control")
269
+ return "no control server for this directory";
270
+ if (result.kind === "off")
271
+ return "BaseInstRunner is switched off here (`bir on` turns it back on)";
272
+ if (why === "no_plan")
273
+ return "no scenario is armed for this turn — answer the user's request normally";
274
+ return why || "the scenario could not be run";
275
+ }
276
+ /**
277
+ * A `CallToolResult`, and never an error. Nothing to run is the common case —
278
+ * a model that remembered the tool, or tried it unasked — and a replay that
279
+ * could not be delivered leaves the task exactly where it was: the model does
280
+ * it itself. Neither is something the user should see a red error for, and
281
+ * neither is helped by the runner's own words for what went wrong. The one
282
+ * reason that passes through is the controller's hand-over note, which is
283
+ * written for the model and says what is left to do. A second call while the
284
+ * plan still runs is told, in words of its own, that the results come back on
285
+ * the first — not to do the task, which would repeat the steps being run.
106
286
  */
107
287
  function toolResult(result) {
108
- if (!result.ok || !result.text) {
109
- const why = result.why === "no_plan"
110
- ? "no scenario is armed for this turn — answer the user's request normally"
111
- : (result.why ?? "the scenario could not be run");
112
- return { content: [{ type: "text", text: why }], isError: true };
288
+ const bundle = delivered(result);
289
+ if (bundle !== "") {
290
+ const notes = [];
291
+ // A budget stop and a run whose caller left are both `partial`; the
292
+ // hand-over note in front of the bundle already says which steps ran.
293
+ if (result.partial === true)
294
+ notes.push(PARTIAL_TEXT);
295
+ const structured = result.responseModel && typeof result.responseModel === "object" && Object.keys(result.responseModel).length > 0
296
+ ? `\n\nResponse model (derived by the scenario's own logic):\n${JSON.stringify(result.responseModel)}`
297
+ : "";
298
+ // NO `structuredContent`, AND THAT IS THE WHOLE POINT.
299
+ //
300
+ // A client that receives both MAY render `structuredContent` and drop
301
+ // `content` — Claude Code does. The replay then reaches the model as the bare
302
+ // response model: a few hundred bytes of derived keys, with no bundle header,
303
+ // no per-step calls and no tool results. Observed in a real session as a
304
+ // 744-character `{"services":[…],"worstService":…}` where a 30 KB, 8-step
305
+ // bundle should have been.
306
+ //
307
+ // The model behaved correctly given what it saw — it could not answer from
308
+ // that, so it re-ran all eight tools itself. The replay had executed
309
+ // perfectly, cost nothing, and bought nothing, because its results never
310
+ // arrived. The bundle is the payload; the response model rides inside it as
311
+ // text rather than competing with it for the client's attention.
312
+ return said(`${bundle}${structured}${notes.length ? `\n\n${notes.join(" ")}` : ""}`);
113
313
  }
114
- const notes = [];
115
- if (result.partial)
116
- notes.push("(partial: the plan's time budget was reached)");
117
- const structured = result.responseModel && Object.keys(result.responseModel).length > 0
118
- ? `\n\nResponse model (derived by the scenario's own logic):\n${JSON.stringify(result.responseModel)}`
119
- : "";
120
- // NO `structuredContent`, AND THAT IS THE WHOLE POINT.
121
- //
122
- // A client that receives both MAY render `structuredContent` and drop
123
- // `content` — Claude Code does. The replay then reaches the model as the bare
124
- // response model: a few hundred bytes of derived keys, with no bundle header,
125
- // no per-step calls and no tool results. Observed in a real session as a
126
- // 744-character `{"services":[…],"worstService":…}` where a 30 KB, 8-step
127
- // bundle should have been.
128
- //
129
- // The model behaved correctly given what it saw — it could not answer from
130
- // that, so it re-ran all eight tools itself. The replay had executed
131
- // perfectly, cost nothing, and bought nothing, because its results never
132
- // arrived. The bundle is the payload; the response model rides inside it as
133
- // text rather than competing with it for the client's attention.
134
- return {
135
- content: [
136
- { type: "text", text: `${result.text}${structured}${notes.length ? `\n\n${notes.join(" ")}` : ""}` },
137
- ],
138
- isError: false,
139
- };
314
+ const why = typeof result.why === "string" ? result.why : "";
315
+ if (DEBUG)
316
+ return { content: [{ type: "text", text: debugReason(result, why) }], isError: true };
317
+ if (why === "no_plan")
318
+ return said(NO_PLAN_TEXT);
319
+ // Written by the controller and capped there (4 000 characters); capped
320
+ // again here, because a port can be answered by something else.
321
+ if (isHandover(result, why))
322
+ return said(why.length > 8_000 ? `${why.slice(0, 8_000)}…` : why);
323
+ if (isAlreadyRunning(why))
324
+ return said(ALREADY_RUNNING_TEXT);
325
+ return said(UNAVAILABLE_TEXT);
140
326
  }
141
327
  // ── the scenario tools: `bir scenario …`, spawned ─────────────────────────────
142
- /** How long one `bir` command may take. A check replays the chain on the service. */
328
+ /** How long a `bir` that may change a scenario may take. A check replays the chain on the service. */
143
329
  const BIR_TIMEOUT_MS = 120_000;
330
+ /**
331
+ * How long a reading tool's `bir` may take: the command's bounded reads of the
332
+ * service (`BIR_READ_TIMEOUT_MS`, 15 s by default — one bound for the whole
333
+ * command, even where it reads twice: `ReadClock` in service-read.ts) plus
334
+ * Node's start and some margin. A read used to share the 120 s above, which a
335
+ * service that accepted the connection and never answered used in full.
336
+ */
337
+ function readToolTimeoutMs() {
338
+ return readTimeoutMs() + 10_000;
339
+ }
340
+ /**
341
+ * The most of a `bir` answer that reaches the model, by default. A whole
342
+ * scenario with its recorded inputs and outputs runs to hundreds of kilobytes;
343
+ * past the cap it is dropped as it arrives (so it is not held here either) and
344
+ * the answer says how to ask for less.
345
+ *
346
+ * The HOST has a limit too, and ours must land first: Claude Code refuses or
347
+ * cuts an MCP answer past 25 000 tokens (`MAX_MCP_OUTPUT_TOKENS`) with an error
348
+ * of its own, and warns the person past 10 000. Compact scenario JSON — ids,
349
+ * escaped recorded outputs — runs at 2.5–3 characters a token, so 80 000
350
+ * characters (the first cap) was 27–32 thousand tokens: the host's error, not
351
+ * our hint. 24 000 characters stays under the warning.
352
+ */
353
+ const DEFAULT_MAX_OUTPUT_CHARS = 24_000;
354
+ /** Whatever the settings say, no more than this is held in memory for one answer. */
355
+ const HARD_MAX_OUTPUT_CHARS = 400_000;
356
+ /**
357
+ * The cap for this server: `BIR_SCENARIO_MAX_OUTPUT_CHARS` when set; else, when
358
+ * the person raised (or lowered) the host's own limit with
359
+ * `MAX_MCP_OUTPUT_TOKENS`, 1.5 characters a token of it — under the limit even
360
+ * for the densest JSON; else {@link DEFAULT_MAX_OUTPUT_CHARS}.
361
+ */
362
+ function maxOutputChars() {
363
+ const explicit = Number(process.env.BIR_SCENARIO_MAX_OUTPUT_CHARS);
364
+ if (Number.isFinite(explicit) && explicit >= 1_000)
365
+ return Math.min(Math.floor(explicit), HARD_MAX_OUTPUT_CHARS);
366
+ const tokens = Number(process.env.MAX_MCP_OUTPUT_TOKENS);
367
+ if (Number.isFinite(tokens) && tokens > 0) {
368
+ return Math.max(2_000, Math.min(Math.floor(tokens * 1.5), HARD_MAX_OUTPUT_CHARS));
369
+ }
370
+ return DEFAULT_MAX_OUTPUT_CHARS;
371
+ }
372
+ const MAX_OUTPUT_CHARS = maxOutputChars();
373
+ /**
374
+ * Of `bir`'s stderr that is not an audit line — the sentences it wrote for a
375
+ * person — the LAST this many characters are kept: `bir`'s reason for failing
376
+ * ("not signed in — run `bir login`") is the last thing it prints.
377
+ */
378
+ const MAX_STDERR_CHARS = 8_000;
379
+ /** Of `bir`'s audit lines, passed on to this server's log as they arrive. */
380
+ const MAX_AUDIT_FORWARD_CHARS = 64_000;
144
381
  class BadArguments extends Error {
145
382
  }
146
383
  const TARGET = {
@@ -209,9 +446,10 @@ const SCENARIO_TOOLS = [
209
446
  {
210
447
  name: "scenario_show",
211
448
  changes: false,
212
- description: "Read a calculated scenario of this account: the whole plan, or with `step` just that step — its input logic, " +
213
- "output logic, whether it is marked *needs a judgement* (nondeterministic) and the recorded input and output " +
214
- "it has to reproduce, with the scenario's `scenarioId` and `chainRevision` at the top. Read a step here before changing it.",
449
+ service: true,
450
+ description: "Read a calculated scenario: the whole plan, or with `step` one step — its input and output logic, its " +
451
+ "*needs a judgement* mark and the recorded call it must reproduce, under the scenario's `scenarioId` and " +
452
+ "`chainRevision`. Read a step here before changing it.",
215
453
  inputSchema: {
216
454
  type: "object",
217
455
  properties: { target: TARGET, step: STEP },
@@ -223,8 +461,9 @@ const SCENARIO_TOOLS = [
223
461
  {
224
462
  name: "scenario_edits",
225
463
  changes: false,
226
- description: "List the hand edits of a calculated scenario, newest first: which step changed, the note, the revision, " +
227
- "and whether it was saved on purpose (forced), undone, or replaced by a recalculation.",
464
+ service: true,
465
+ description: "List a calculated scenario's hand edits, newest first: step, note, revision, and whether each was forced, " +
466
+ "undone or replaced by a recalculation.",
228
467
  inputSchema: {
229
468
  type: "object",
230
469
  properties: { target: TARGET },
@@ -236,9 +475,10 @@ const SCENARIO_TOOLS = [
236
475
  {
237
476
  name: "investigate",
238
477
  changes: false,
239
- description: "Explain why a turn in this project did or did not replay its calculated scenario, what it cost and saved, " +
240
- "and what to fix. Without `id`, the newest turn here; with a run_, scn_ or sexec_ id, that one. A finding " +
241
- "that names a step can be read with scenario_show and, where editing is on, fixed with scenario_check and scenario_edit.",
478
+ // The journal half needs no service; the service half says why it is missing.
479
+ service: false,
480
+ description: "Explain why a turn here did or did not replay its calculated scenario, what it cost and saved, and what " +
481
+ "to fix. Without `id`, the newest turn; with a run_, scn_ or sexec_ id, that one.",
242
482
  inputSchema: {
243
483
  type: "object",
244
484
  properties: {
@@ -258,6 +498,7 @@ const SCENARIO_TOOLS = [
258
498
  {
259
499
  name: "scenario_check",
260
500
  changes: true,
501
+ service: true,
261
502
  description: "Try a change to one step of a calculated scenario without saving anything: the service runs the new " +
262
503
  "`inputLogic` and/or `outputLogic` with the recording's values and reports whether this step and every later " +
263
504
  "step still reproduce the recorded calls, and what happens to the step's *needs a judgement* mark. The workflow " +
@@ -284,6 +525,7 @@ const SCENARIO_TOOLS = [
284
525
  {
285
526
  name: "scenario_edit",
286
527
  changes: true,
528
+ service: true,
287
529
  description: "Save a change to one step of a calculated scenario. It runs the same check as scenario_check and saves only " +
288
530
  "if the step reproduces the recording and is not a copy of the recorded call; always give a `note` saying why, " +
289
531
  "and `revision` (the `chainRevision` at the top of a scenario_show answer with `step`) so a plan that changed " +
@@ -342,6 +584,7 @@ const SCENARIO_TOOLS = [
342
584
  {
343
585
  name: "scenario_undo",
344
586
  changes: true,
587
+ service: true,
345
588
  description: "Take back a hand edit of a calculated scenario: its step goes back as it was before that edit. Without `edit`, " +
346
589
  "the newest edit that can be undone (of `step`, when given); calling it again goes one further back. The service " +
347
590
  "checks the undo like a save and refuses it when a later step or the final answer would break — `force` with a " +
@@ -389,23 +632,71 @@ function editingOn(cwd) {
389
632
  return false;
390
633
  }
391
634
  }
392
- /** The tools to list for `cwd`, read now: the switch may have moved since the last list. */
635
+ /** `bir off` (everywhere, or for this project): the runner stays out of the way. Never throws. */
636
+ function runnerOff(cwd) {
637
+ try {
638
+ return isRunnerOff(cwd);
639
+ }
640
+ catch {
641
+ return false;
642
+ }
643
+ }
644
+ /** `BIR_SCENARIO_TOOLS=read`: offer the reading tools without turning editing on. */
645
+ function readToolsOptedIn() {
646
+ const value = (process.env.BIR_SCENARIO_TOOLS ?? "").trim().toLowerCase();
647
+ return value === "read" || value === "1" || value === "on" || value === "true";
648
+ }
649
+ /** The tools to list for `cwd`, read now: the switches may have moved since the last list. */
393
650
  function listTools(cwd) {
651
+ if (runnerOff(cwd))
652
+ return [];
394
653
  const editing = editingOn(cwd);
654
+ const reading = editing || readToolsOptedIn();
395
655
  return [
396
656
  TOOL,
397
- ...SCENARIO_TOOLS.filter((t) => !t.changes || editing).map(({ name, description, inputSchema }) => ({
657
+ ...SCENARIO_TOOLS.filter((t) => (t.changes ? editing : reading)).map(({ name, description, inputSchema }) => ({
398
658
  name,
399
659
  description,
400
660
  inputSchema,
401
661
  })),
402
662
  ];
403
663
  }
664
+ /**
665
+ * Why a tool that needs the service cannot have it on this machine, found
666
+ * without the network — or undefined. Checked before `bir` starts, because
667
+ * `bir` settles the service address with a request *before* it looks for a
668
+ * session, and a signed-out machine with an unreachable service then waited
669
+ * out that request for nothing. Only whether the files exist is looked at:
670
+ * this server holds no credentials.
671
+ */
672
+ function signInProblem() {
673
+ if (process.env.BIR_AUTH_DISABLE === "1")
674
+ return undefined;
675
+ let url = "";
676
+ try {
677
+ url = resolveAuthUrl();
678
+ }
679
+ catch {
680
+ url = "";
681
+ }
682
+ if (!url) {
683
+ return "No BaseIn service is configured on this machine, so there is nothing to read or change. A person runs `bir setup` (or `bir login`) in a terminal.";
684
+ }
685
+ try {
686
+ if (!existsSync(credentialsPath())) {
687
+ return "Not signed in to BaseIn on this machine. A person runs `bir login` in a terminal; then this works.";
688
+ }
689
+ }
690
+ catch {
691
+ // Cannot tell: let `bir` find out.
692
+ }
693
+ return undefined;
694
+ }
404
695
  /** This package's own `bir`, beside this file — never whatever `bir` is first on PATH. */
405
696
  function birPath() {
406
697
  return resolvePath(dirname(fileURLToPath(import.meta.url)), "bir.js");
407
698
  }
408
- /** Scenario tool calls in flight, by JSON-RPC request id. */
699
+ /** Calls in flight, by JSON-RPC request id. */
409
700
  const inFlight = new Map();
410
701
  /** Remove a call's logic directory, once. Never throws: this runs on the way out. */
411
702
  function removeDir(flight) {
@@ -421,7 +712,11 @@ function removeDir(flight) {
421
712
  logLine("scenario.tmp_left", { dir, error: errText(err) });
422
713
  }
423
714
  }
424
- /** Stop a call's `bir` (if it still runs) and remove its logic files. Synchronous: it runs before `process.exit`. */
715
+ /**
716
+ * Stop a call: its `bir` (if it still runs), its logic files, its request to
717
+ * the control server — whose closing is what tells the control server to stop
718
+ * starting steps. Synchronous: it runs before `process.exit`.
719
+ */
425
720
  function stopFlight(flight) {
426
721
  const child = flight.child;
427
722
  if (child && child.exitCode === null && child.signalCode === null) {
@@ -432,13 +727,20 @@ function stopFlight(flight) {
432
727
  // Already gone.
433
728
  }
434
729
  }
730
+ try {
731
+ flight.abort?.abort();
732
+ }
733
+ catch {
734
+ // Nothing left to abort.
735
+ }
435
736
  removeDir(flight);
436
737
  }
437
- function runBir(argv, cwd, flight) {
738
+ function runBir(argv, cwd, timeoutMs, flight) {
438
739
  return new Promise((resolve) => {
439
740
  const child = spawn(process.execPath, [...nodeFlagsToCarry(), birPath(), ...argv], {
440
741
  cwd,
441
- env: process.env,
742
+ // BIR_MCP: compact JSON, and no raw journal lines — the answer is for a model.
743
+ env: { ...process.env, BIR_MCP: "1" },
442
744
  // Nothing is ever read from stdin: logic arrives as files.
443
745
  stdio: ["ignore", "pipe", "pipe"],
444
746
  windowsHide: true,
@@ -446,32 +748,53 @@ function runBir(argv, cwd, flight) {
446
748
  if (flight)
447
749
  flight.child = child;
448
750
  let stdout = "";
449
- let stderr = "";
751
+ // Audit lines go on to this server's log as they arrive, as its own would;
752
+ // only the sentences `bir` wrote for a person are kept for the model.
753
+ const stderr = new StderrSorter({
754
+ keepChars: MAX_STDERR_CHARS,
755
+ forwardChars: MAX_AUDIT_FORWARD_CHARS,
756
+ forward: (line) => process.stderr.write(`${line}\n`),
757
+ });
450
758
  let timedOut = false;
759
+ let truncated = false;
451
760
  child.stdout.setEncoding("utf8");
452
761
  child.stderr.setEncoding("utf8");
762
+ // Past the cap the pipe is still drained — a child blocked on a full pipe
763
+ // never exits — but nothing more is kept.
453
764
  child.stdout.on("data", (c) => {
454
- stdout += c;
455
- });
456
- child.stderr.on("data", (c) => {
457
- stderr += c;
765
+ const room = MAX_OUTPUT_CHARS - stdout.length;
766
+ if (c.length > room)
767
+ truncated = true;
768
+ if (room > 0)
769
+ stdout += c.length > room ? c.slice(0, room) : c;
458
770
  });
771
+ child.stderr.on("data", (c) => stderr.push(c));
459
772
  const timer = setTimeout(() => {
460
773
  timedOut = true;
461
774
  child.kill();
462
- }, BIR_TIMEOUT_MS);
775
+ }, timeoutMs);
463
776
  timer.unref?.();
464
777
  child.on("error", (err) => {
465
778
  clearTimeout(timer);
466
- resolve({ code: null, stdout, stderr: `${stderr}${errText(err)}`, timedOut });
779
+ resolve({ code: null, stdout, stderr: `${stderr.end()}${errText(err)}`, timedOut, truncated });
467
780
  });
468
781
  child.on("close", (code) => {
469
782
  clearTimeout(timer);
470
- resolve({ code, stdout, stderr, timedOut });
783
+ resolve({ code, stdout, stderr: stderr.end(), timedOut, truncated });
471
784
  });
472
785
  });
473
786
  }
474
787
  const failed = (text) => ({ content: [{ type: "text", text }], isError: true });
788
+ /** What a model reads when `bir` ran out of time: what it means, and what to do. */
789
+ function timeoutText(tool, argv, ms) {
790
+ const opening = `${tool.name} did not finish within ${Math.round(ms / 1000)} s — the BaseIn service may be slow or unreachable.`;
791
+ if (!tool.changes) {
792
+ return `${opening} Try again later; a person can also run \`bir ${argv.join(" ")}\` in a terminal.`;
793
+ }
794
+ return tool.name === "scenario_check"
795
+ ? `${opening} Nothing was saved.`
796
+ : `${opening} It may still have been saved: scenario_edits lists what was.`;
797
+ }
475
798
  /**
476
799
  * Run one scenario tool. Never throws. The logic bodies are written to a
477
800
  * directory of their own under the OS temp directory, owner-only, and the
@@ -488,6 +811,9 @@ async function callScenarioTool(tool, args, cwd, flight = { tool: tool.name, can
488
811
  catch (err) {
489
812
  return failed(`invalid arguments for ${tool.name}: ${errText(err)}`);
490
813
  }
814
+ const problem = tool.service ? signInProblem() : undefined;
815
+ if (problem)
816
+ return failed(problem);
491
817
  try {
492
818
  const argv = [...plan.argv];
493
819
  if (plan.inputLogic !== undefined || plan.outputLogic !== undefined) {
@@ -507,18 +833,31 @@ async function callScenarioTool(tool, args, cwd, flight = { tool: tool.name, can
507
833
  argv.push("--json");
508
834
  if (flight.cancelled)
509
835
  return failed("cancelled before bir started");
510
- const run = await runBir(argv, cwd, flight);
836
+ const timeoutMs = tool.changes ? BIR_TIMEOUT_MS : readToolTimeoutMs();
837
+ const run = await runBir(argv, cwd, timeoutMs, flight);
511
838
  if (flight.cancelled)
512
839
  return failed("cancelled");
513
- if (run.timedOut)
514
- return failed(`bir did not finish in ${BIR_TIMEOUT_MS / 1000} s`);
515
- const stdout = run.stdout.trim();
840
+ // `bir`'s audit lines went to this server's log as they arrived, as its
841
+ // own would; `run.stderr` is only the sentences it wrote for a person.
842
+ if (run.timedOut) {
843
+ logLine("scenario.tool_timeout", { tool: tool.name, ms: timeoutMs });
844
+ return failed(timeoutText(tool, plan.argv, timeoutMs));
845
+ }
846
+ let stdout = run.stdout.trim();
847
+ if (run.truncated) {
848
+ logLine("scenario.tool_truncated", { tool: tool.name, kept: MAX_OUTPUT_CHARS });
849
+ const less = tool.name === "scenario_show" && !plan.argv.includes("--step")
850
+ ? "Ask for one step at a time with `step`."
851
+ : `A person can run \`bir ${plan.argv.join(" ")}\` in a terminal for all of it.`;
852
+ stdout += `\n…(truncated: the answer passed ${MAX_OUTPUT_CHARS} characters. ${less})`;
853
+ }
516
854
  if (run.code === 0)
517
855
  return { content: [{ type: "text", text: stdout || "(no output)" }], isError: false };
518
856
  // The refusal and its report are on stdout; why bir itself failed, if it
519
857
  // did (not signed in, no service address), is on stderr. The model needs both.
520
- const text = [stdout, run.stderr.trim()].filter(Boolean).join("\n");
521
- return failed(text || `bir exited with code ${run.code ?? "?"}`);
858
+ const reason = run.stderr.trim();
859
+ const text = [stdout, reason].filter(Boolean).join("\n");
860
+ return failed(text || `${tool.name} failed: bir exited with code ${run.code ?? "?"}`);
522
861
  }
523
862
  catch (err) {
524
863
  return failed(`could not run bir: ${errText(err)}`);
@@ -527,12 +866,39 @@ async function callScenarioTool(tool, args, cwd, flight = { tool: tool.name, can
527
866
  removeDir(flight);
528
867
  }
529
868
  }
869
+ /** How often the off switch is looked at, so the host can be told its tool list changed. */
870
+ const SWITCH_POLL_MS = 2_000;
530
871
  function main() {
872
+ // Started with --use-env-proxy in a shell whose NO_PROXY does not name
873
+ // 127.0.0.1: without this, `run_scenario`'s request to the control server
874
+ // goes to the proxy, which refuses or swallows it, and every replay became
875
+ // "not available". Before anything here fetches; the `bir` it spawns does
876
+ // the same for itself.
877
+ try {
878
+ exemptLoopbackFromProxy();
879
+ }
880
+ catch (err) {
881
+ logLine("scenario.no_proxy_failed", { error: errText(err) });
882
+ }
531
883
  const cwd = process.cwd();
532
884
  let closing = false;
885
+ /** The host has asked for the tools, so it has a list a change could make stale. */
886
+ let listed = false;
533
887
  const reply = (msg) => writeFrame(process.stdout, msg);
534
888
  /**
535
- * The host is gone: stop every `bir` still running for it and remove its
889
+ * The hello and the revive (scenario-liveness.ts). Every call to it returns
890
+ * at once and is made after the answer it follows was written: nothing a
891
+ * host waits for waits on the recorder.
892
+ */
893
+ const liveness = new ScenarioLiveness({
894
+ cwd,
895
+ state: () => {
896
+ const off = runnerOff(cwd);
897
+ return { tools: off ? 0 : listTools(cwd).length, off };
898
+ },
899
+ });
900
+ /**
901
+ * The host is gone: stop every call still running for it and remove its
536
902
  * logic files, then exit. Everything here is synchronous, because nothing
537
903
  * after `process.exit` runs — not a pending call's `finally` either.
538
904
  */
@@ -540,6 +906,7 @@ function main() {
540
906
  if (closing)
541
907
  return;
542
908
  closing = true;
909
+ liveness.stop();
543
910
  if (inFlight.size > 0) {
544
911
  logLine("scenario.shutdown", { why, stopped: [...inFlight.values()].map((f) => f.tool) });
545
912
  }
@@ -548,6 +915,68 @@ function main() {
548
915
  inFlight.clear();
549
916
  process.exit(0);
550
917
  };
918
+ /** `run_scenario`: ask the control server, and answer exactly once — unless cancelled. */
919
+ const answerRun = async (id, flight, toolUseId) => {
920
+ let result;
921
+ try {
922
+ result = await runArmedPlan(cwd, flight, toolUseId);
923
+ }
924
+ catch (err) {
925
+ result = { ok: false, why: errText(err) };
926
+ }
927
+ if (inFlight.get(id) === flight)
928
+ inFlight.delete(id);
929
+ // No live recorder, or none that took the request: have it looked at —
930
+ // after the answer below is on its way, never before.
931
+ if (result.kind === "no_control" || result.kind === "unreachable") {
932
+ liveness.recorderGone("run_scenario found no recorder");
933
+ }
934
+ if (flight.cancelled || closing)
935
+ return;
936
+ // `ok` is what the model got: an "ok" with no results to show is not one.
937
+ const ok = delivered(result) !== "";
938
+ const why = ok
939
+ ? undefined
940
+ : typeof result.why === "string"
941
+ ? result.why
942
+ : result.ok === true
943
+ ? "the control server answered ok with no results"
944
+ : "no reason given";
945
+ logLine("scenario.answered", {
946
+ ok,
947
+ steps: typeof result.steps === "number" ? result.steps : undefined,
948
+ partial: (ok && result.partial === true) || undefined,
949
+ why: why && why.length > 200 ? `${why.slice(0, 200)}…` : why,
950
+ // Whether the host named its tool use, so the control server matched the call exactly.
951
+ bound: toolUseId !== undefined || undefined,
952
+ detail: result.kind === "no_control" ? "no control server for this directory" : undefined,
953
+ });
954
+ let answer;
955
+ try {
956
+ answer = toolResult(result);
957
+ }
958
+ catch (err) {
959
+ logLine("scenario.answer_failed", { error: errText(err) });
960
+ answer = said(UNAVAILABLE_TEXT);
961
+ }
962
+ reply({ jsonrpc: "2.0", id, result: answer });
963
+ };
964
+ /** A scenario tool: run `bir`, and answer exactly once — unless cancelled. */
965
+ const answerTool = async (id, tool, args, flight) => {
966
+ let result;
967
+ try {
968
+ result = await callScenarioTool(tool, args, cwd, flight);
969
+ }
970
+ catch (err) {
971
+ result = failed(`could not run bir: ${errText(err)}`);
972
+ }
973
+ if (inFlight.get(id) === flight)
974
+ inFlight.delete(id);
975
+ if (flight.cancelled || closing)
976
+ return;
977
+ logLine("scenario.tool_answered", { tool: tool.name, isError: result.isError === true });
978
+ reply({ jsonrpc: "2.0", id, result });
979
+ };
551
980
  readFrames(process.stdin, {
552
981
  onMessage: (msg) => {
553
982
  if (!isRequest(msg)) {
@@ -558,7 +987,10 @@ function main() {
558
987
  const flight = typeof requestId === "string" || typeof requestId === "number" ? inFlight.get(requestId) : undefined;
559
988
  if (flight && !flight.cancelled) {
560
989
  flight.cancelled = true;
561
- logLine("scenario.tool_cancelled", { tool: flight.tool, running: flight.child !== undefined });
990
+ logLine("scenario.tool_cancelled", {
991
+ tool: flight.tool,
992
+ running: flight.child !== undefined || flight.abort !== undefined,
993
+ });
562
994
  stopFlight(flight);
563
995
  }
564
996
  }
@@ -574,14 +1006,20 @@ function main() {
574
1006
  // Echo the host's protocol version when it names one; a server that
575
1007
  // insists on its own is a server the host may refuse to talk to.
576
1008
  protocolVersion: String(params.protocolVersion ?? "2025-06-18"),
577
- capabilities: { tools: {} },
578
- serverInfo: { name: "bir-scenario", version: "1" },
1009
+ // listChanged: `bir off` / `bir on` mid-session changes the list.
1010
+ capabilities: { tools: { listChanged: true } },
1011
+ // The package's version, so a session still talking to a server
1012
+ // from before an upgrade shows which one it is.
1013
+ serverInfo: { name: "bir-scenario", version: packageVersion() },
579
1014
  },
580
1015
  });
1016
+ liveness.hello("initialize");
581
1017
  return;
582
1018
  }
583
1019
  if (method === "tools/list") {
1020
+ listed = true;
584
1021
  reply({ jsonrpc: "2.0", id, result: { tools: listTools(cwd) } });
1022
+ liveness.hello("tools/list");
585
1023
  return;
586
1024
  }
587
1025
  if (method === "ping") {
@@ -594,7 +1032,25 @@ function main() {
594
1032
  const scenarioTool = SCENARIO_TOOLS.find((t) => t.name === name);
595
1033
  if (scenarioTool) {
596
1034
  // Asked again, not remembered from tools/list: a host may call a tool
597
- // it listed before the switch went off, or one it was never offered.
1035
+ // it listed before a switch went off, or one it was never offered.
1036
+ //
1037
+ // Off is off for the reading tools too: a host still holding the list
1038
+ // from before `bir off` must not start a `bir` that asks the service —
1039
+ // the service misbehaving is a reason people switch the runner off.
1040
+ // Nothing was asked to change, so it is said plainly, not as an error.
1041
+ if (!scenarioTool.changes && runnerOff(cwd)) {
1042
+ logLine("scenario.tool_answered", { tool: name, isError: false, detail: "runner is off" });
1043
+ reply({
1044
+ jsonrpc: "2.0",
1045
+ id,
1046
+ result: said(`BaseInstRunner is switched off here, so ${name} has nothing to read. A person turns it back on with \`bir on\`.`),
1047
+ });
1048
+ return;
1049
+ }
1050
+ if (scenarioTool.changes && runnerOff(cwd)) {
1051
+ reply(errorResponse(id, JsonRpcErrorCode.MethodNotFound, `${name} is not offered: BaseInstRunner is switched off here (\`bir on\` turns it back on).`));
1052
+ return;
1053
+ }
598
1054
  if (scenarioTool.changes && !editingOn(cwd)) {
599
1055
  reply(errorResponse(id, JsonRpcErrorCode.MethodNotFound, `${name} is not offered in this project: scenario editing is off. ` +
600
1056
  "A person turns it on with `bir scenario editing on` in this directory, then restarts the session."));
@@ -603,28 +1059,24 @@ function main() {
603
1059
  logDetail("scenario.tool", { cwd, tool: name });
604
1060
  const flight = { tool: name, cancelled: false };
605
1061
  inFlight.set(id, flight);
606
- void callScenarioTool(scenarioTool, paramsObject(params.arguments), cwd, flight).then((result) => {
607
- inFlight.delete(id);
608
- if (flight.cancelled || closing)
609
- return;
610
- logLine("scenario.tool_answered", { tool: name, isError: result.isError === true });
611
- reply({ jsonrpc: "2.0", id, result });
612
- });
1062
+ void answerTool(id, scenarioTool, paramsObject(params.arguments), flight);
613
1063
  return;
614
1064
  }
615
1065
  if (name !== TOOL_NAME) {
616
1066
  reply(errorResponse(id, JsonRpcErrorCode.MethodNotFound, `unknown tool: ${params.name}`));
617
1067
  return;
618
1068
  }
619
- logDetail("scenario.call", { cwd });
620
- void runArmedPlan(cwd).then((result) => {
621
- logLine("scenario.answered", {
622
- ok: result.ok ?? false,
623
- steps: result.steps,
624
- why: result.ok ? undefined : result.why,
625
- });
626
- reply({ jsonrpc: "2.0", id, result: toolResult(result) });
627
- });
1069
+ // Off: the recorder arms nothing, so there is nothing to ask it for.
1070
+ if (runnerOff(cwd)) {
1071
+ logLine("scenario.answered", { ok: false, why: "no_plan", detail: "runner is off" });
1072
+ reply({ jsonrpc: "2.0", id, result: toolResult({ ok: false, why: "no_plan", kind: "off" }) });
1073
+ return;
1074
+ }
1075
+ const toolUseId = toolUseIdOf(params);
1076
+ logDetail("scenario.call", { cwd, toolUseId });
1077
+ const flight = { tool: TOOL_NAME, cancelled: false };
1078
+ inFlight.set(id, flight);
1079
+ void answerRun(id, flight, toolUseId);
628
1080
  return;
629
1081
  }
630
1082
  // Anything else: this server advertises only tools, so an unknown method
@@ -635,7 +1087,26 @@ function main() {
635
1087
  // The host closed our stdin: the session is over.
636
1088
  onEnd: () => shutdown("stdin closed"),
637
1089
  });
638
- logLine("scenario.started", { cwd, pid: process.pid });
1090
+ // `bir off` / `bir on` while the session runs: the host lists the tools once,
1091
+ // so it is told when the answer changes rather than keeping `run_scenario`
1092
+ // on an inert runner (or missing it on a live one) until a restart. One
1093
+ // `stat` every couple of seconds, on a timer that never keeps us alive.
1094
+ let wasOff = runnerOff(cwd);
1095
+ const watch = setInterval(() => {
1096
+ const off = runnerOff(cwd);
1097
+ if (off === wasOff)
1098
+ return;
1099
+ wasOff = off;
1100
+ logLine("scenario.switched", { off });
1101
+ if (listed && !closing) {
1102
+ writeFrame(process.stdout, { jsonrpc: "2.0", method: "notifications/tools/list_changed" });
1103
+ }
1104
+ // The recorder must stop announcing direct plans on our word while we list nothing.
1105
+ liveness.hello("switched");
1106
+ }, SWITCH_POLL_MS);
1107
+ watch.unref();
1108
+ liveness.start();
1109
+ logLine("scenario.started", { cwd, pid: process.pid, ...(wasOff ? { off: true } : {}) });
639
1110
  process.on("SIGINT", () => shutdown("SIGINT"));
640
1111
  process.on("SIGTERM", () => shutdown("SIGTERM"));
641
1112
  process.on("uncaughtException", (err) => logLine("scenario.uncaught", { error: errText(err) }));