@basein/runner 0.2.11 → 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 (144) hide show
  1. package/README.md +540 -350
  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 -10
  9. package/dist/bin/bir-scenario.js +704 -135
  10. package/dist/bin/bir.d.ts +14 -8
  11. package/dist/bin/bir.js +2937 -488
  12. package/dist/bin/investigate.js +41 -16
  13. package/dist/bin/scenario-edit.d.ts +43 -10
  14. package/dist/bin/scenario-edit.js +281 -80
  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 +2803 -306
  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/housekeeping.d.ts +71 -0
  72. package/dist/record/housekeeping.js +417 -0
  73. package/dist/record/recorder.d.ts +51 -3
  74. package/dist/record/recorder.js +3 -0
  75. package/dist/record/redact.d.ts +103 -9
  76. package/dist/record/redact.js +659 -30
  77. package/dist/record/remote-recorder.d.ts +327 -32
  78. package/dist/record/remote-recorder.js +1517 -187
  79. package/dist/record/switchable-recorder.d.ts +126 -0
  80. package/dist/record/switchable-recorder.js +351 -0
  81. package/dist/record/truncate.d.ts +40 -1
  82. package/dist/record/truncate.js +111 -4
  83. package/dist/replay/bash-channel.d.ts +35 -0
  84. package/dist/replay/bash-channel.js +133 -0
  85. package/dist/replay/bundle.d.ts +10 -0
  86. package/dist/replay/bundle.js +10 -0
  87. package/dist/replay/controller.d.ts +371 -20
  88. package/dist/replay/controller.js +1185 -111
  89. package/dist/replay/derive.d.ts +1 -0
  90. package/dist/replay/derive.js +81 -24
  91. package/dist/replay/executor.d.ts +51 -16
  92. package/dist/replay/executor.js +114 -41
  93. package/dist/replay/handover.d.ts +54 -1
  94. package/dist/replay/handover.js +55 -5
  95. package/dist/replay/logic.d.ts +62 -10
  96. package/dist/replay/logic.js +140 -22
  97. package/dist/replay/plan.d.ts +201 -27
  98. package/dist/replay/plan.js +581 -141
  99. package/dist/replay/safety.d.ts +72 -0
  100. package/dist/replay/safety.js +303 -0
  101. package/dist/replay/source-run.d.ts +10 -0
  102. package/dist/replay/source-run.js +80 -35
  103. package/dist/replay/targets.d.ts +15 -1
  104. package/dist/replay/targets.js +62 -4
  105. package/dist/replay/tool-error.d.ts +15 -1
  106. package/dist/replay/tool-error.js +33 -1
  107. package/dist/replay/types.d.ts +7 -4
  108. package/dist/upstream/client.d.ts +56 -3
  109. package/dist/upstream/client.js +61 -4
  110. package/dist/upstream/headers.d.ts +43 -0
  111. package/dist/upstream/headers.js +90 -0
  112. package/dist/upstream/http-client.d.ts +213 -22
  113. package/dist/upstream/http-client.js +1034 -168
  114. package/dist/upstream/http-transport.d.ts +143 -0
  115. package/dist/upstream/http-transport.js +391 -0
  116. package/dist/upstream/lazy-client.d.ts +8 -0
  117. package/dist/upstream/lazy-client.js +29 -1
  118. package/dist/upstream/sse.d.ts +78 -0
  119. package/dist/upstream/sse.js +189 -0
  120. package/dist/upstream/stdio-client.d.ts +61 -1
  121. package/dist/upstream/stdio-client.js +356 -33
  122. package/dist/util/atomic-write.d.ts +29 -0
  123. package/dist/util/atomic-write.js +96 -0
  124. package/dist/util/journal.d.ts +15 -0
  125. package/dist/util/journal.js +48 -3
  126. package/dist/util/log.d.ts +10 -0
  127. package/dist/util/log.js +28 -1
  128. package/dist/util/off-switch.d.ts +46 -0
  129. package/dist/util/off-switch.js +91 -0
  130. package/dist/util/service-health.d.ts +133 -0
  131. package/dist/util/service-health.js +217 -0
  132. package/docs/BaseInstRunner.md +694 -621
  133. package/docs/calculatedReplay.md +1625 -1244
  134. package/docs/calculatedReplayGuide.md +1212 -879
  135. package/docs/installRun.md +1013 -772
  136. package/docs/loginWeb.md +627 -607
  137. package/docs/mcpmark.md +49 -16
  138. package/docs/my-first-sample.md +569 -545
  139. package/docs/quickstart.md +384 -271
  140. package/docs/t-bench.md +406 -394
  141. package/package.json +58 -57
  142. package/production.md +1371 -0
  143. package/scripts/install.ps1 +97 -6
  144. package/scripts/install.sh +104 -7
@@ -18,53 +18,185 @@
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.
48
+ *
49
+ * A CALL IN FLIGHT IS OWNED UNTIL IT ENDS. Each call's `bir` child, temporary
50
+ * directory or control-server request is tracked. When the host goes away
51
+ * (stdin closes, SIGINT, SIGTERM) the children are stopped and the directories
52
+ * removed before the process exits — `process.exit` never resumes a pending
53
+ * `finally`, so cleanup left to one was cleanup that did not happen, and an
54
+ * orphaned `bir` went on to save an edit for a session that had ended (C13). A
55
+ * `notifications/cancelled` (the person pressed Esc) stops that call the same
56
+ * way — a `run_scenario` request is aborted, and the control server stops
57
+ * starting steps when its request closes — and the call gets no answer, as MCP
58
+ * asks. A request the service already received may still complete there;
59
+ * nothing here can call it back, and `bir scenario edits` shows it.
28
60
  *
29
- * The tools that CHANGE a scenario are offered only where a person ran
30
- * `bir scenario editing on` (D2): this server runs in every session of every
31
- * installed project, a fleet included, and a plan must not change because some
32
- * agent there decided it should. The switch is read when the host lists the
33
- * tools, and again on every call, so a call to a tool that was never offered —
34
- * 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.
35
86
  *
36
87
  * STDOUT IS SACRED, as in every MCP server here: it is the host's JSON-RPC
37
88
  * stream, and every log line goes to stderr instead.
38
89
  */
39
90
  import { readFrames, writeFrame } from "../jsonrpc/framing.js";
40
- import { JsonRpcErrorCode, errorResponse, isRequest, paramsObject, } from "../jsonrpc/types.js";
91
+ import { JsonRpcErrorCode, errorResponse, isNotification, isRequest, paramsObject, } from "../jsonrpc/types.js";
41
92
  import { spawn } from "node:child_process";
42
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
93
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
43
94
  import { tmpdir } from "node:os";
44
95
  import { dirname, join, resolve as resolvePath } from "node:path";
45
96
  import { fileURLToPath } from "node:url";
46
97
  import { resolveControl } from "../control/discovery.js";
47
- 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";
48
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";
49
106
  /** Must match `DIRECT_TOOL_NAME`'s suffix in the replay controller. */
50
107
  const TOOL_NAME = "run_scenario";
51
- /** How long to look for a control server before answering that there is none. */
52
- const DISCOVERY_MS = 5_000;
53
- /** The plan can be long; the whole-plan budget lives on the control server. */
54
- 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
+ }
55
164
  const TOOL = {
56
165
  name: TOOL_NAME,
57
- description: "Run the pre-calculated tool sequence BaseInstRunner has armed for the current request, " +
58
- "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.",
59
177
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
60
178
  };
61
- /** Ask the control server to run the armed plan. Never throws. */
62
- async function runArmedPlan(cwd) {
63
- const found = await resolveControl(cwd, DISCOVERY_MS);
64
- if (!found?.url)
65
- 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" };
66
193
  const controller = new AbortController();
67
- 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);
68
200
  timer.unref?.();
69
201
  try {
70
202
  const res = await fetch(`${found.url.replace(/\/+$/, "")}/scenario/run`, {
@@ -73,68 +205,184 @@ async function runArmedPlan(cwd) {
73
205
  "content-type": "application/json",
74
206
  authorization: `Bearer ${found.token}`,
75
207
  },
76
- body: "{}",
208
+ body: JSON.stringify(toolUseId ? { toolUseId } : {}),
77
209
  signal: controller.signal,
78
210
  });
211
+ const raw = await res.text();
79
212
  if (!res.ok)
80
213
  return { ok: false, why: `control server answered HTTP ${res.status}` };
81
- 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;
82
227
  }
83
228
  catch (err) {
84
- 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" };
85
235
  }
86
236
  finally {
87
237
  clearTimeout(timer);
238
+ flight.abort = undefined;
88
239
  }
89
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
+ }
245
+ /**
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 });
90
255
  /**
91
- * A `CallToolResult`. A turn with no armed plan is a **tool failure**, not a
92
- * protocol error: the model called a tool that had nothing to do — usually
93
- * because it remembered the tool from an earlier turn — and reacting to that is
94
- * something models do well and routinely.
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.
95
286
  */
96
287
  function toolResult(result) {
97
- if (!result.ok || !result.text) {
98
- const why = result.why === "no_plan"
99
- ? "no scenario is armed for this turn — answer the user's request normally"
100
- : (result.why ?? "the scenario could not be run");
101
- 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(" ")}` : ""}`);
102
313
  }
103
- const notes = [];
104
- if (result.partial)
105
- notes.push("(partial: the plan's time budget was reached)");
106
- const structured = result.responseModel && Object.keys(result.responseModel).length > 0
107
- ? `\n\nResponse model (derived by the scenario's own logic):\n${JSON.stringify(result.responseModel)}`
108
- : "";
109
- // NO `structuredContent`, AND THAT IS THE WHOLE POINT.
110
- //
111
- // A client that receives both MAY render `structuredContent` and drop
112
- // `content` — Claude Code does. The replay then reaches the model as the bare
113
- // response model: a few hundred bytes of derived keys, with no bundle header,
114
- // no per-step calls and no tool results. Observed in a real session as a
115
- // 744-character `{"services":[…],"worstService":…}` where a 30 KB, 8-step
116
- // bundle should have been.
117
- //
118
- // The model behaved correctly given what it saw — it could not answer from
119
- // that, so it re-ran all eight tools itself. The replay had executed
120
- // perfectly, cost nothing, and bought nothing, because its results never
121
- // arrived. The bundle is the payload; the response model rides inside it as
122
- // text rather than competing with it for the client's attention.
123
- return {
124
- content: [
125
- { type: "text", text: `${result.text}${structured}${notes.length ? `\n\n${notes.join(" ")}` : ""}` },
126
- ],
127
- isError: false,
128
- };
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);
129
326
  }
130
327
  // ── the scenario tools: `bir scenario …`, spawned ─────────────────────────────
131
- /** 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. */
132
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;
133
381
  class BadArguments extends Error {
134
382
  }
135
383
  const TARGET = {
136
384
  type: "string",
137
- description: "The scenario's scn_ id, or the run_ id of the recording it was calculated from — as investigate and scenario_show print them.",
385
+ description: "A scn_ id, or the run_ id of a whole-run scenario (a sub-task scenario has only its scn_ id) — as investigate and scenario_show print them.",
138
386
  };
139
387
  const STEP = {
140
388
  type: "integer",
@@ -185,13 +433,23 @@ function flag(args, key, name) {
185
433
  throw new BadArguments(`\`${key}\` must be true or false`);
186
434
  return [name];
187
435
  }
436
+ /** `--note <text>`. The text is one argv element, so no quoting can split it. */
437
+ function note(args) {
438
+ const value = args.note;
439
+ if (value === undefined || value === null)
440
+ return [];
441
+ if (typeof value !== "string")
442
+ throw new BadArguments("`note` must be a string");
443
+ return ["--note", value];
444
+ }
188
445
  const SCENARIO_TOOLS = [
189
446
  {
190
447
  name: "scenario_show",
191
448
  changes: false,
192
- description: "Read a calculated scenario of this account: the whole plan, or with `step` just that step — its input logic, " +
193
- "output logic, whether it is marked *needs a judgement* (nondeterministic) and the recorded input and output " +
194
- "it has to reproduce. 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.",
195
453
  inputSchema: {
196
454
  type: "object",
197
455
  properties: { target: TARGET, step: STEP },
@@ -203,8 +461,9 @@ const SCENARIO_TOOLS = [
203
461
  {
204
462
  name: "scenario_edits",
205
463
  changes: false,
206
- description: "List the hand edits of a calculated scenario, newest first: which step changed, the note, the revision, " +
207
- "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.",
208
467
  inputSchema: {
209
468
  type: "object",
210
469
  properties: { target: TARGET },
@@ -216,9 +475,10 @@ const SCENARIO_TOOLS = [
216
475
  {
217
476
  name: "investigate",
218
477
  changes: false,
219
- description: "Explain why a turn in this project did or did not replay its calculated scenario, what it cost and saved, " +
220
- "and what to fix. Without `id`, the newest turn here; with a run_, scn_ or sexec_ id, that one. A finding " +
221
- "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.",
222
482
  inputSchema: {
223
483
  type: "object",
224
484
  properties: {
@@ -238,6 +498,7 @@ const SCENARIO_TOOLS = [
238
498
  {
239
499
  name: "scenario_check",
240
500
  changes: true,
501
+ service: true,
241
502
  description: "Try a change to one step of a calculated scenario without saving anything: the service runs the new " +
242
503
  "`inputLogic` and/or `outputLogic` with the recording's values and reports whether this step and every later " +
243
504
  "step still reproduce the recorded calls, and what happens to the step's *needs a judgement* mark. The workflow " +
@@ -264,11 +525,14 @@ const SCENARIO_TOOLS = [
264
525
  {
265
526
  name: "scenario_edit",
266
527
  changes: true,
528
+ service: true,
267
529
  description: "Save a change to one step of a calculated scenario. It runs the same check as scenario_check and saves only " +
268
530
  "if the step reproduces the recording and is not a copy of the recorded call; always give a `note` saying why, " +
269
- "and `revision` (the chainRevision scenario_show printed) so a plan that changed meanwhile is not overwritten. " +
531
+ "and `revision` (the `chainRevision` at the top of a scenario_show answer with `step`) so a plan that changed " +
532
+ "meanwhile is not overwritten. " +
270
533
  "`force` saves what the check refused and is only for a recording that was itself wrong — never a way past a " +
271
- "failing check. Every save can be undone with scenario_undo.",
534
+ "failing check. A save can be taken back with scenario_undo, which the service checks too: it refuses an undo " +
535
+ "that would break a later step, and a recalculation with --discard-edits replaces edits for good.",
272
536
  inputSchema: {
273
537
  type: "object",
274
538
  properties: {
@@ -289,7 +553,7 @@ const SCENARIO_TOOLS = [
289
553
  revision: {
290
554
  type: "integer",
291
555
  minimum: 1,
292
- description: "The chainRevision the change was made against; the save is refused if the plan moved on.",
556
+ description: "The chainRevision the change was made against — at the top of a scenario_show answer with `step`; the save is refused if the plan moved on.",
293
557
  },
294
558
  },
295
559
  required: ["target", "step"],
@@ -307,11 +571,7 @@ const SCENARIO_TOOLS = [
307
571
  ...flag(a, "unfreeze", "--unfreeze"),
308
572
  ...flag(a, "force", "--force"),
309
573
  ];
310
- if (a.note !== undefined && a.note !== null) {
311
- if (typeof a.note !== "string")
312
- throw new BadArguments("`note` must be a string");
313
- argv.push("--note", a.note);
314
- }
574
+ argv.push(...note(a));
315
575
  if (a.revision !== undefined && a.revision !== null) {
316
576
  if (typeof a.revision !== "number" || !Number.isInteger(a.revision) || a.revision < 1) {
317
577
  throw new BadArguments("`revision` must be a whole number from 1");
@@ -324,13 +584,25 @@ const SCENARIO_TOOLS = [
324
584
  {
325
585
  name: "scenario_undo",
326
586
  changes: true,
327
- description: "Put one step of a calculated scenario back as it was before its newest hand edit — without `edit`, the newest " +
328
- "edit that can be undone. The undo is itself an edit, so undoing it again redoes the change.",
587
+ service: true,
588
+ description: "Take back a hand edit of a calculated scenario: its step goes back as it was before that edit. Without `edit`, " +
589
+ "the newest edit that can be undone (of `step`, when given); calling it again goes one further back. The service " +
590
+ "checks the undo like a save and refuses it when a later step or the final answer would break — `force` with a " +
591
+ "`note` undoes it anyway. To redo, undo the undo: pass its sedit_ id as `edit` (the answer names it).",
329
592
  inputSchema: {
330
593
  type: "object",
331
594
  properties: {
332
595
  target: TARGET,
333
- edit: { type: "string", description: "The sedit_ id to undo, as scenario_edits lists it." },
596
+ edit: { type: "string", description: "The sedit_ id to undo, as scenario_edits lists it. Not with `step`." },
597
+ step: {
598
+ ...STEP,
599
+ description: "Undo the newest edit of this step (its stepIndex, as scenario_show prints it). Not with `edit`.",
600
+ },
601
+ force: {
602
+ type: "boolean",
603
+ description: "Undo although the check refused it (a later step or the answer would break). Needs `note`.",
604
+ },
605
+ note: { type: "string", description: "Why; kept in the history. Required with `force`." },
334
606
  },
335
607
  required: ["target"],
336
608
  additionalProperties: false,
@@ -341,8 +613,12 @@ const SCENARIO_TOOLS = [
341
613
  if (typeof a.edit !== "string" || !/^sedit_[\w-]+$/.test(a.edit)) {
342
614
  throw new BadArguments("`edit` must be a sedit_… id");
343
615
  }
616
+ if (a.step !== undefined && a.step !== null) {
617
+ throw new BadArguments("`edit` names one entry and `step` picks one; give one of them");
618
+ }
344
619
  argv.push("--edit", a.edit);
345
620
  }
621
+ argv.push(...step(a, false), ...flag(a, "force", "--force"), ...note(a));
346
622
  return { argv };
347
623
  },
348
624
  },
@@ -356,65 +632,178 @@ function editingOn(cwd) {
356
632
  return false;
357
633
  }
358
634
  }
359
- /** 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. */
360
650
  function listTools(cwd) {
651
+ if (runnerOff(cwd))
652
+ return [];
361
653
  const editing = editingOn(cwd);
654
+ const reading = editing || readToolsOptedIn();
362
655
  return [
363
656
  TOOL,
364
- ...SCENARIO_TOOLS.filter((t) => !t.changes || editing).map(({ name, description, inputSchema }) => ({
657
+ ...SCENARIO_TOOLS.filter((t) => (t.changes ? editing : reading)).map(({ name, description, inputSchema }) => ({
365
658
  name,
366
659
  description,
367
660
  inputSchema,
368
661
  })),
369
662
  ];
370
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
+ }
371
695
  /** This package's own `bir`, beside this file — never whatever `bir` is first on PATH. */
372
696
  function birPath() {
373
697
  return resolvePath(dirname(fileURLToPath(import.meta.url)), "bir.js");
374
698
  }
375
- function runBir(argv, cwd) {
699
+ /** Calls in flight, by JSON-RPC request id. */
700
+ const inFlight = new Map();
701
+ /** Remove a call's logic directory, once. Never throws: this runs on the way out. */
702
+ function removeDir(flight) {
703
+ const dir = flight.dir;
704
+ if (!dir)
705
+ return;
706
+ flight.dir = undefined;
707
+ try {
708
+ // A child killed a moment ago may still hold a handle on Windows; retry briefly.
709
+ rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
710
+ }
711
+ catch (err) {
712
+ logLine("scenario.tmp_left", { dir, error: errText(err) });
713
+ }
714
+ }
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
+ */
720
+ function stopFlight(flight) {
721
+ const child = flight.child;
722
+ if (child && child.exitCode === null && child.signalCode === null) {
723
+ try {
724
+ child.kill();
725
+ }
726
+ catch {
727
+ // Already gone.
728
+ }
729
+ }
730
+ try {
731
+ flight.abort?.abort();
732
+ }
733
+ catch {
734
+ // Nothing left to abort.
735
+ }
736
+ removeDir(flight);
737
+ }
738
+ function runBir(argv, cwd, timeoutMs, flight) {
376
739
  return new Promise((resolve) => {
377
740
  const child = spawn(process.execPath, [...nodeFlagsToCarry(), birPath(), ...argv], {
378
741
  cwd,
379
- 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" },
380
744
  // Nothing is ever read from stdin: logic arrives as files.
381
745
  stdio: ["ignore", "pipe", "pipe"],
382
746
  windowsHide: true,
383
747
  });
748
+ if (flight)
749
+ flight.child = child;
384
750
  let stdout = "";
385
- 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
+ });
386
758
  let timedOut = false;
759
+ let truncated = false;
387
760
  child.stdout.setEncoding("utf8");
388
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.
389
764
  child.stdout.on("data", (c) => {
390
- stdout += c;
391
- });
392
- child.stderr.on("data", (c) => {
393
- 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;
394
770
  });
771
+ child.stderr.on("data", (c) => stderr.push(c));
395
772
  const timer = setTimeout(() => {
396
773
  timedOut = true;
397
774
  child.kill();
398
- }, BIR_TIMEOUT_MS);
775
+ }, timeoutMs);
399
776
  timer.unref?.();
400
777
  child.on("error", (err) => {
401
778
  clearTimeout(timer);
402
- resolve({ code: null, stdout, stderr: `${stderr}${errText(err)}`, timedOut });
779
+ resolve({ code: null, stdout, stderr: `${stderr.end()}${errText(err)}`, timedOut, truncated });
403
780
  });
404
781
  child.on("close", (code) => {
405
782
  clearTimeout(timer);
406
- resolve({ code, stdout, stderr, timedOut });
783
+ resolve({ code, stdout, stderr: stderr.end(), timedOut, truncated });
407
784
  });
408
785
  });
409
786
  }
410
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
+ }
411
798
  /**
412
799
  * Run one scenario tool. Never throws. The logic bodies are written to a
413
800
  * directory of their own under the OS temp directory, owner-only, and the
414
801
  * directory is removed whatever happens — the code may carry a table name or a
415
- * host the person would not want left lying around.
802
+ * host the person would not want left lying around. "Whatever happens"
803
+ * includes the host leaving mid-call: the directory and the child live in
804
+ * `flight`, which `main` stops on the way out ({@link stopFlight}).
416
805
  */
417
- async function callScenarioTool(tool, args, cwd) {
806
+ async function callScenarioTool(tool, args, cwd, flight = { tool: tool.name, cancelled: false }) {
418
807
  let plan;
419
808
  try {
420
809
  plan = tool.plan(args);
@@ -422,11 +811,14 @@ async function callScenarioTool(tool, args, cwd) {
422
811
  catch (err) {
423
812
  return failed(`invalid arguments for ${tool.name}: ${errText(err)}`);
424
813
  }
425
- let dir;
814
+ const problem = tool.service ? signInProblem() : undefined;
815
+ if (problem)
816
+ return failed(problem);
426
817
  try {
427
818
  const argv = [...plan.argv];
428
819
  if (plan.inputLogic !== undefined || plan.outputLogic !== undefined) {
429
- dir = mkdtempSync(join(tmpdir(), "bir-logic-"));
820
+ const dir = mkdtempSync(join(tmpdir(), "bir-logic-"));
821
+ flight.dir = dir;
430
822
  if (plan.inputLogic !== undefined) {
431
823
  const file = join(dir, "input.js");
432
824
  writeFileSync(file, plan.inputLogic, { encoding: "utf8", mode: 0o600 });
@@ -439,33 +831,171 @@ async function callScenarioTool(tool, args, cwd) {
439
831
  }
440
832
  }
441
833
  argv.push("--json");
442
- const run = await runBir(argv, cwd);
443
- if (run.timedOut)
444
- return failed(`bir did not finish in ${BIR_TIMEOUT_MS / 1000} s`);
445
- const stdout = run.stdout.trim();
834
+ if (flight.cancelled)
835
+ return failed("cancelled before bir started");
836
+ const timeoutMs = tool.changes ? BIR_TIMEOUT_MS : readToolTimeoutMs();
837
+ const run = await runBir(argv, cwd, timeoutMs, flight);
838
+ if (flight.cancelled)
839
+ return failed("cancelled");
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
+ }
446
854
  if (run.code === 0)
447
855
  return { content: [{ type: "text", text: stdout || "(no output)" }], isError: false };
448
856
  // The refusal and its report are on stdout; why bir itself failed, if it
449
857
  // did (not signed in, no service address), is on stderr. The model needs both.
450
- const text = [stdout, run.stderr.trim()].filter(Boolean).join("\n");
451
- 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 ?? "?"}`);
452
861
  }
453
862
  catch (err) {
454
863
  return failed(`could not run bir: ${errText(err)}`);
455
864
  }
456
865
  finally {
457
- if (dir)
458
- rmSync(dir, { recursive: true, force: true });
866
+ removeDir(flight);
459
867
  }
460
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;
461
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
+ }
462
883
  const cwd = process.cwd();
463
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;
464
887
  const reply = (msg) => writeFrame(process.stdout, msg);
888
+ /**
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
902
+ * logic files, then exit. Everything here is synchronous, because nothing
903
+ * after `process.exit` runs — not a pending call's `finally` either.
904
+ */
905
+ const shutdown = (why) => {
906
+ if (closing)
907
+ return;
908
+ closing = true;
909
+ liveness.stop();
910
+ if (inFlight.size > 0) {
911
+ logLine("scenario.shutdown", { why, stopped: [...inFlight.values()].map((f) => f.tool) });
912
+ }
913
+ for (const flight of inFlight.values())
914
+ stopFlight(flight);
915
+ inFlight.clear();
916
+ process.exit(0);
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
+ };
465
980
  readFrames(process.stdin, {
466
981
  onMessage: (msg) => {
467
- if (!isRequest(msg))
468
- return; // notifications need no answer
982
+ if (!isRequest(msg)) {
983
+ // Notifications need no answer; one says to stop a call (MCP
984
+ // `notifications/cancelled`), and a stopped call is answered by nobody.
985
+ if (isNotification(msg) && msg.method === "notifications/cancelled") {
986
+ const requestId = paramsObject(msg.params).requestId;
987
+ const flight = typeof requestId === "string" || typeof requestId === "number" ? inFlight.get(requestId) : undefined;
988
+ if (flight && !flight.cancelled) {
989
+ flight.cancelled = true;
990
+ logLine("scenario.tool_cancelled", {
991
+ tool: flight.tool,
992
+ running: flight.child !== undefined || flight.abort !== undefined,
993
+ });
994
+ stopFlight(flight);
995
+ }
996
+ }
997
+ return;
998
+ }
469
999
  const { id, method } = msg;
470
1000
  if (method === "initialize") {
471
1001
  const params = paramsObject(msg.params);
@@ -476,14 +1006,20 @@ function main() {
476
1006
  // Echo the host's protocol version when it names one; a server that
477
1007
  // insists on its own is a server the host may refuse to talk to.
478
1008
  protocolVersion: String(params.protocolVersion ?? "2025-06-18"),
479
- capabilities: { tools: {} },
480
- 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() },
481
1014
  },
482
1015
  });
1016
+ liveness.hello("initialize");
483
1017
  return;
484
1018
  }
485
1019
  if (method === "tools/list") {
1020
+ listed = true;
486
1021
  reply({ jsonrpc: "2.0", id, result: { tools: listTools(cwd) } });
1022
+ liveness.hello("tools/list");
487
1023
  return;
488
1024
  }
489
1025
  if (method === "ping") {
@@ -496,32 +1032,51 @@ function main() {
496
1032
  const scenarioTool = SCENARIO_TOOLS.find((t) => t.name === name);
497
1033
  if (scenarioTool) {
498
1034
  // Asked again, not remembered from tools/list: a host may call a tool
499
- // 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
+ }
500
1054
  if (scenarioTool.changes && !editingOn(cwd)) {
501
1055
  reply(errorResponse(id, JsonRpcErrorCode.MethodNotFound, `${name} is not offered in this project: scenario editing is off. ` +
502
1056
  "A person turns it on with `bir scenario editing on` in this directory, then restarts the session."));
503
1057
  return;
504
1058
  }
505
1059
  logDetail("scenario.tool", { cwd, tool: name });
506
- void callScenarioTool(scenarioTool, paramsObject(params.arguments), cwd).then((result) => {
507
- logLine("scenario.tool_answered", { tool: name, isError: result.isError === true });
508
- reply({ jsonrpc: "2.0", id, result });
509
- });
1060
+ const flight = { tool: name, cancelled: false };
1061
+ inFlight.set(id, flight);
1062
+ void answerTool(id, scenarioTool, paramsObject(params.arguments), flight);
510
1063
  return;
511
1064
  }
512
1065
  if (name !== TOOL_NAME) {
513
1066
  reply(errorResponse(id, JsonRpcErrorCode.MethodNotFound, `unknown tool: ${params.name}`));
514
1067
  return;
515
1068
  }
516
- logDetail("scenario.call", { cwd });
517
- void runArmedPlan(cwd).then((result) => {
518
- logLine("scenario.answered", {
519
- ok: result.ok ?? false,
520
- steps: result.steps,
521
- why: result.ok ? undefined : result.why,
522
- });
523
- reply({ jsonrpc: "2.0", id, result: toolResult(result) });
524
- });
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);
525
1080
  return;
526
1081
  }
527
1082
  // Anything else: this server advertises only tools, so an unknown method
@@ -529,17 +1084,31 @@ function main() {
529
1084
  reply(errorResponse(id, JsonRpcErrorCode.MethodNotFound, `unsupported method: ${method}`));
530
1085
  },
531
1086
  onParseError: (detail) => logLine("scenario.frame_error", { reason: detail.reason }),
532
- onEnd: () => {
533
- // The host closed our stdin: the session is over.
534
- if (closing)
535
- return;
536
- closing = true;
537
- process.exit(0);
538
- },
1087
+ // The host closed our stdin: the session is over.
1088
+ onEnd: () => shutdown("stdin closed"),
539
1089
  });
540
- logLine("scenario.started", { cwd, pid: process.pid });
541
- process.on("SIGINT", () => process.exit(0));
542
- process.on("SIGTERM", () => process.exit(0));
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 } : {}) });
1110
+ process.on("SIGINT", () => shutdown("SIGINT"));
1111
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
543
1112
  process.on("uncaughtException", (err) => logLine("scenario.uncaught", { error: errText(err) }));
544
1113
  process.on("unhandledRejection", (err) => logLine("scenario.unhandled_rejection", { error: errText(err) }));
545
1114
  }