@basein/runner 0.2.10 → 0.2.12

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.
package/README.md CHANGED
@@ -147,7 +147,22 @@ between the two is not guaranteed.
147
147
  section.
148
148
  - **Change what the model does.** Tool descriptions are never edited. The
149
149
  model-visible changes are schema relaxation, below, and — only with
150
- `--replay` — one first-party tool.
150
+ `--replay` — the first-party `bir` server's tools: `run_scenario`, and three
151
+ that only read a scenario. The three for changing one (`scenario_check`,
152
+ `scenario_edit`, `scenario_undo`) appear only after `bir scenario editing on`
153
+ in that project (not yet released, see below).
154
+ - **Record its own `bir` calls** (next version, not yet released). A session
155
+ that fixes a plan is not a task to replay. The `bir` server's tools other
156
+ than `run_scenario`, and a `Bash` or `PowerShell` command made only of `bir`
157
+ commands, are never recorded as steps and never count as leaving a plan.
158
+ `npm test && bir investigate` is still recorded: `npm test` is real work.
159
+ - **Sandbox scenario code on this machine.** A calculated or hand-edited step
160
+ runs here with `new Function`, next to your sign-in and your wrapped servers.
161
+ The service will sandbox its own runs of the same code (with its next deploy);
162
+ the runner does not. The service's check of a hand edit only judges what the
163
+ code returns for the recording's values, so it cannot prove what the code
164
+ does here: read a hand edit before it is saved. See
165
+ [the design's §13.1](docs/calculatedReplay.md).
151
166
 
152
167
  ---
153
168
 
@@ -192,6 +207,31 @@ sequence runs through connections the proxies already hold — **zero model toke
192
207
  scenarios steer the live session instead, pinning each step's arguments. Either
193
208
  way the turn reports what it cost, so the savings ledger measures something real.
194
209
 
210
+ **Fixing a step.** Built 2026-09-25, not yet released: it needs the next
211
+ `@basein/runner` version and a service deploy. A calculated step is code, and
212
+ code can be wrong. `bir investigate` names such a step; `bir scenario edit`
213
+ fixes it, and the service checks the change against the recording before it
214
+ saves it. **A change is saved only if it reproduces the recording and is not a
215
+ copy — or on purpose, with `--force --note "why"`.** Only the owner can change
216
+ a scenario. `bir scenario undo` takes changes back one at a time: run it again
217
+ to go one further back. An undo is checked too, and is refused if it would
218
+ break a later step or the final answer, unless you add `--force --note "why"`.
219
+ Claude can do all of this: in its terminal, or with the `bir` server's tools
220
+ once `bir scenario editing on` was run in the project. Step by step, with real
221
+ output:
222
+ [the guide's §9.2](docs/calculatedReplayGuide.md).
223
+
224
+ ```bash
225
+ bir scenario show scn_… --step 3 # read one step (scenarioId and chainRevision first)
226
+ bir scenario check scn_… --step 3 --input-logic step3.js # try a change; nothing is saved
227
+ bir scenario edit scn_… --step 3 --input-logic step3.js --note "why" # the same check, then save
228
+ bir scenario edits scn_… # the history, newest first
229
+ bir scenario undo scn_… # take back the newest edit; again to go further back
230
+ bir scenario undo scn_… --step 3 # take back step 3's newest edit only
231
+ bir scenario calc scn_… --force --discard-edits # recalculate this plan, dropping its hand edits
232
+ bir scenario editing on # let Claude's `bir` tools change scenarios here
233
+ ```
234
+
195
235
  ---
196
236
 
197
237
  ## Two things to know before you turn it on
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * bir-scenario — a first-party MCP server with exactly one tool
4
- * (docs/calculatedReplay.md §6.3).
3
+ * bir-scenario — the first-party `bir` MCP server (docs/calculatedReplay.md §6.3):
4
+ * `run_scenario`, plus the tools that read and fix a calculated scenario
5
+ * (editSteps.md in the BaseIn repository; docs/calculatedReplayGuide.md §9.2).
5
6
  *
6
7
  * WHY THIS EXISTS. On a fully-wrapped match the whole scenario runs server-side,
7
8
  * through proxies that are already connected, and the model's only job is to read
@@ -17,6 +18,32 @@
17
18
  * control server exactly as a proxy does — `~/.baseinstrunner/control/<key>.json`,
18
19
  * mode 0600, bearer token — and forwards one call.
19
20
  *
21
+ * THE SCENARIO TOOLS RUN `bir` ITSELF. `scenario_show`, `scenario_check` and
22
+ * the rest are the `bir scenario …` commands, spawned from this same package
23
+ * with `--json` (D1: one path, whether a person types it or the model calls
24
+ * it). That keeps this server credential-free — `bir` signs in the way it
25
+ * always does — and means the model sees exactly the answer a person would.
26
+ * Logic bodies travel through temporary files, never argv, and a non-zero exit
27
+ * is an `isError` result that carries the output, refusal and report included.
28
+ *
29
+ * A CALL IN FLIGHT IS OWNED UNTIL IT ENDS. Each scenario call's `bir` child and
30
+ * its temporary directory are tracked. When the host goes away (stdin closes,
31
+ * SIGINT, SIGTERM) the children are stopped and the directories removed before
32
+ * the process exits — `process.exit` never resumes a pending `finally`, so
33
+ * cleanup left to one was cleanup that did not happen, and an orphaned `bir`
34
+ * went on to save an edit for a session that had ended (C13). A
35
+ * `notifications/cancelled` (the person pressed Esc) stops that call's child
36
+ * the same way, and the call gets no answer, as MCP asks. A request the
37
+ * service already received may still complete there; nothing here can call
38
+ * it back, and `bir scenario edits` shows it.
39
+ *
40
+ * The tools that CHANGE a scenario are offered only where a person ran
41
+ * `bir scenario editing on` (D2): this server runs in every session of every
42
+ * installed project, a fleet included, and a plan must not change because some
43
+ * agent there decided it should. The switch is read when the host lists the
44
+ * tools, and again on every call, so a call to a tool that was never offered —
45
+ * or was offered before the switch went off — is refused rather than run.
46
+ *
20
47
  * STDOUT IS SACRED, as in every MCP server here: it is the host's JSON-RPC
21
48
  * stream, and every log line goes to stderr instead.
22
49
  */
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * bir-scenario — a first-party MCP server with exactly one tool
4
- * (docs/calculatedReplay.md §6.3).
3
+ * bir-scenario — the first-party `bir` MCP server (docs/calculatedReplay.md §6.3):
4
+ * `run_scenario`, plus the tools that read and fix a calculated scenario
5
+ * (editSteps.md in the BaseIn repository; docs/calculatedReplayGuide.md §9.2).
5
6
  *
6
7
  * WHY THIS EXISTS. On a fully-wrapped match the whole scenario runs server-side,
7
8
  * through proxies that are already connected, and the model's only job is to read
@@ -17,12 +18,44 @@
17
18
  * control server exactly as a proxy does — `~/.baseinstrunner/control/<key>.json`,
18
19
  * mode 0600, bearer token — and forwards one call.
19
20
  *
21
+ * THE SCENARIO TOOLS RUN `bir` ITSELF. `scenario_show`, `scenario_check` and
22
+ * the rest are the `bir scenario …` commands, spawned from this same package
23
+ * with `--json` (D1: one path, whether a person types it or the model calls
24
+ * it). That keeps this server credential-free — `bir` signs in the way it
25
+ * always does — and means the model sees exactly the answer a person would.
26
+ * Logic bodies travel through temporary files, never argv, and a non-zero exit
27
+ * is an `isError` result that carries the output, refusal and report included.
28
+ *
29
+ * A CALL IN FLIGHT IS OWNED UNTIL IT ENDS. Each scenario call's `bir` child and
30
+ * its temporary directory are tracked. When the host goes away (stdin closes,
31
+ * SIGINT, SIGTERM) the children are stopped and the directories removed before
32
+ * the process exits — `process.exit` never resumes a pending `finally`, so
33
+ * cleanup left to one was cleanup that did not happen, and an orphaned `bir`
34
+ * went on to save an edit for a session that had ended (C13). A
35
+ * `notifications/cancelled` (the person pressed Esc) stops that call's child
36
+ * the same way, and the call gets no answer, as MCP asks. A request the
37
+ * service already received may still complete there; nothing here can call
38
+ * it back, and `bir scenario edits` shows it.
39
+ *
40
+ * The tools that CHANGE a scenario are offered only where a person ran
41
+ * `bir scenario editing on` (D2): this server runs in every session of every
42
+ * installed project, a fleet included, and a plan must not change because some
43
+ * agent there decided it should. The switch is read when the host lists the
44
+ * tools, and again on every call, so a call to a tool that was never offered —
45
+ * or was offered before the switch went off — is refused rather than run.
46
+ *
20
47
  * STDOUT IS SACRED, as in every MCP server here: it is the host's JSON-RPC
21
48
  * stream, and every log line goes to stderr instead.
22
49
  */
23
50
  import { readFrames, writeFrame } from "../jsonrpc/framing.js";
24
- import { JsonRpcErrorCode, errorResponse, isRequest, paramsObject, } from "../jsonrpc/types.js";
51
+ import { JsonRpcErrorCode, errorResponse, isNotification, isRequest, paramsObject, } from "../jsonrpc/types.js";
52
+ import { spawn } from "node:child_process";
53
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
54
+ import { tmpdir } from "node:os";
55
+ import { dirname, join, resolve as resolvePath } from "node:path";
56
+ import { fileURLToPath } from "node:url";
25
57
  import { resolveControl } from "../control/discovery.js";
58
+ import { editingEnabled, nodeFlagsToCarry, readSidecar } from "../config/generate.js";
26
59
  import { logLine, logDetail, errText } from "../util/log.js";
27
60
  /** Must match `DIRECT_TOOL_NAME`'s suffix in the replay controller. */
28
61
  const TOOL_NAME = "run_scenario";
@@ -105,14 +138,432 @@ function toolResult(result) {
105
138
  isError: false,
106
139
  };
107
140
  }
141
+ // ── the scenario tools: `bir scenario …`, spawned ─────────────────────────────
142
+ /** How long one `bir` command may take. A check replays the chain on the service. */
143
+ const BIR_TIMEOUT_MS = 120_000;
144
+ class BadArguments extends Error {
145
+ }
146
+ const TARGET = {
147
+ type: "string",
148
+ 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.",
149
+ };
150
+ const STEP = {
151
+ type: "integer",
152
+ minimum: 0,
153
+ description: "The step's stepIndex, as scenario_show prints it (the first step is 0).",
154
+ };
155
+ const INPUT_LOGIC = {
156
+ type: "string",
157
+ description: "The new input logic: the BODY of a JavaScript function of (parameters, intent, respParams) that ends in `return { … }` — the tool's input. Only JavaScript's built-ins exist when the service runs it.",
158
+ };
159
+ const OUTPUT_LOGIC = {
160
+ type: "string",
161
+ description: "The new output logic: the BODY of a JavaScript function of (toolOutput, parameters, intent, respParams) — toolOutput is the tool's answer as text — returning the values later steps read.",
162
+ };
163
+ /** A `run_`/`scn_` id: letters, digits, `_` and `-`, so it can never read as a flag. */
164
+ function target(args) {
165
+ const value = args.target;
166
+ if (typeof value !== "string" || !/^(run|scn)_[\w-]+$/.test(value)) {
167
+ throw new BadArguments("`target` must be a scn_… or run_… id");
168
+ }
169
+ return value;
170
+ }
171
+ function step(args, required) {
172
+ const value = typeof args.step === "string" && /^\d+$/.test(args.step) ? Number(args.step) : args.step;
173
+ if (value === undefined || value === null) {
174
+ if (required)
175
+ throw new BadArguments("`step` is required: the stepIndex scenario_show prints");
176
+ return [];
177
+ }
178
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
179
+ throw new BadArguments("`step` must be a whole number from 0");
180
+ }
181
+ return ["--step", String(value)];
182
+ }
183
+ function logic(args, key) {
184
+ const value = args[key];
185
+ if (value === undefined || value === null)
186
+ return undefined;
187
+ if (typeof value !== "string")
188
+ throw new BadArguments(`\`${key}\` must be a string: a JavaScript function body`);
189
+ return value;
190
+ }
191
+ function flag(args, key, name) {
192
+ const value = args[key];
193
+ if (value === undefined || value === null || value === false)
194
+ return [];
195
+ if (value !== true)
196
+ throw new BadArguments(`\`${key}\` must be true or false`);
197
+ return [name];
198
+ }
199
+ /** `--note <text>`. The text is one argv element, so no quoting can split it. */
200
+ function note(args) {
201
+ const value = args.note;
202
+ if (value === undefined || value === null)
203
+ return [];
204
+ if (typeof value !== "string")
205
+ throw new BadArguments("`note` must be a string");
206
+ return ["--note", value];
207
+ }
208
+ const SCENARIO_TOOLS = [
209
+ {
210
+ name: "scenario_show",
211
+ 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.",
215
+ inputSchema: {
216
+ type: "object",
217
+ properties: { target: TARGET, step: STEP },
218
+ required: ["target"],
219
+ additionalProperties: false,
220
+ },
221
+ plan: (a) => ({ argv: ["scenario", "show", target(a), ...step(a, false)] }),
222
+ },
223
+ {
224
+ name: "scenario_edits",
225
+ 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.",
228
+ inputSchema: {
229
+ type: "object",
230
+ properties: { target: TARGET },
231
+ required: ["target"],
232
+ additionalProperties: false,
233
+ },
234
+ plan: (a) => ({ argv: ["scenario", "edits", target(a)] }),
235
+ },
236
+ {
237
+ name: "investigate",
238
+ 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.",
242
+ inputSchema: {
243
+ type: "object",
244
+ properties: {
245
+ id: { type: "string", description: "A run_, scn_ or sexec_ id. Omit it for the newest turn in this project." },
246
+ },
247
+ additionalProperties: false,
248
+ },
249
+ plan: (a) => {
250
+ if (a.id === undefined || a.id === null || a.id === "")
251
+ return { argv: ["investigate"] };
252
+ if (typeof a.id !== "string" || !/^(run|scn|sexec)_[\w-]+$/.test(a.id)) {
253
+ throw new BadArguments("`id` must be a run_…, scn_… or sexec_… id");
254
+ }
255
+ return { argv: ["investigate", a.id] };
256
+ },
257
+ },
258
+ {
259
+ name: "scenario_check",
260
+ changes: true,
261
+ description: "Try a change to one step of a calculated scenario without saving anything: the service runs the new " +
262
+ "`inputLogic` and/or `outputLogic` with the recording's values and reports whether this step and every later " +
263
+ "step still reproduce the recorded calls, and what happens to the step's *needs a judgement* mark. The workflow " +
264
+ "is scenario_show → scenario_check until it passes → scenario_edit with the same change and a `note` saying why. " +
265
+ "A failing check means the code is wrong, not that the check is in the way: fix the code.",
266
+ inputSchema: {
267
+ type: "object",
268
+ properties: {
269
+ target: TARGET,
270
+ step: STEP,
271
+ inputLogic: INPUT_LOGIC,
272
+ outputLogic: OUTPUT_LOGIC,
273
+ unfreeze: { type: "boolean", description: "Ask whether the *needs a judgement* mark could be removed." },
274
+ },
275
+ required: ["target", "step"],
276
+ additionalProperties: false,
277
+ },
278
+ plan: (a) => ({
279
+ argv: ["scenario", "check", target(a), ...step(a, true), ...flag(a, "unfreeze", "--unfreeze")],
280
+ inputLogic: logic(a, "inputLogic"),
281
+ outputLogic: logic(a, "outputLogic"),
282
+ }),
283
+ },
284
+ {
285
+ name: "scenario_edit",
286
+ changes: true,
287
+ description: "Save a change to one step of a calculated scenario. It runs the same check as scenario_check and saves only " +
288
+ "if the step reproduces the recording and is not a copy of the recorded call; always give a `note` saying why, " +
289
+ "and `revision` (the `chainRevision` at the top of a scenario_show answer with `step`) so a plan that changed " +
290
+ "meanwhile is not overwritten. " +
291
+ "`force` saves what the check refused and is only for a recording that was itself wrong — never a way past a " +
292
+ "failing check. A save can be taken back with scenario_undo, which the service checks too: it refuses an undo " +
293
+ "that would break a later step, and a recalculation with --discard-edits replaces edits for good.",
294
+ inputSchema: {
295
+ type: "object",
296
+ properties: {
297
+ target: TARGET,
298
+ step: STEP,
299
+ inputLogic: INPUT_LOGIC,
300
+ outputLogic: OUTPUT_LOGIC,
301
+ freeze: {
302
+ type: "boolean",
303
+ description: "Mark the step *needs a judgement*: runs then hand over to the agent in front of it.",
304
+ },
305
+ unfreeze: { type: "boolean", description: "Remove that mark, if the check says the step reproduces." },
306
+ force: {
307
+ type: "boolean",
308
+ description: "Save although the check refused it. Needs `note`. Only when the recording itself was wrong.",
309
+ },
310
+ note: { type: "string", description: "Why the step is changed; kept in the history. Required with `force`." },
311
+ revision: {
312
+ type: "integer",
313
+ minimum: 1,
314
+ 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.",
315
+ },
316
+ },
317
+ required: ["target", "step"],
318
+ additionalProperties: false,
319
+ },
320
+ plan: (a) => {
321
+ if (a.freeze === true && a.unfreeze === true)
322
+ throw new BadArguments("`freeze` and `unfreeze` ask for opposite things");
323
+ const argv = [
324
+ "scenario",
325
+ "edit",
326
+ target(a),
327
+ ...step(a, true),
328
+ ...flag(a, "freeze", "--freeze"),
329
+ ...flag(a, "unfreeze", "--unfreeze"),
330
+ ...flag(a, "force", "--force"),
331
+ ];
332
+ argv.push(...note(a));
333
+ if (a.revision !== undefined && a.revision !== null) {
334
+ if (typeof a.revision !== "number" || !Number.isInteger(a.revision) || a.revision < 1) {
335
+ throw new BadArguments("`revision` must be a whole number from 1");
336
+ }
337
+ argv.push("--revision", String(a.revision));
338
+ }
339
+ return { argv, inputLogic: logic(a, "inputLogic"), outputLogic: logic(a, "outputLogic") };
340
+ },
341
+ },
342
+ {
343
+ name: "scenario_undo",
344
+ changes: true,
345
+ description: "Take back a hand edit of a calculated scenario: its step goes back as it was before that edit. Without `edit`, " +
346
+ "the newest edit that can be undone (of `step`, when given); calling it again goes one further back. The service " +
347
+ "checks the undo like a save and refuses it when a later step or the final answer would break — `force` with a " +
348
+ "`note` undoes it anyway. To redo, undo the undo: pass its sedit_ id as `edit` (the answer names it).",
349
+ inputSchema: {
350
+ type: "object",
351
+ properties: {
352
+ target: TARGET,
353
+ edit: { type: "string", description: "The sedit_ id to undo, as scenario_edits lists it. Not with `step`." },
354
+ step: {
355
+ ...STEP,
356
+ description: "Undo the newest edit of this step (its stepIndex, as scenario_show prints it). Not with `edit`.",
357
+ },
358
+ force: {
359
+ type: "boolean",
360
+ description: "Undo although the check refused it (a later step or the answer would break). Needs `note`.",
361
+ },
362
+ note: { type: "string", description: "Why; kept in the history. Required with `force`." },
363
+ },
364
+ required: ["target"],
365
+ additionalProperties: false,
366
+ },
367
+ plan: (a) => {
368
+ const argv = ["scenario", "undo", target(a)];
369
+ if (a.edit !== undefined && a.edit !== null) {
370
+ if (typeof a.edit !== "string" || !/^sedit_[\w-]+$/.test(a.edit)) {
371
+ throw new BadArguments("`edit` must be a sedit_… id");
372
+ }
373
+ if (a.step !== undefined && a.step !== null) {
374
+ throw new BadArguments("`edit` names one entry and `step` picks one; give one of them");
375
+ }
376
+ argv.push("--edit", a.edit);
377
+ }
378
+ argv.push(...step(a, false), ...flag(a, "force", "--force"), ...note(a));
379
+ return { argv };
380
+ },
381
+ },
382
+ ];
383
+ /** Whether this project may be offered the tools that change a scenario. Never throws. */
384
+ function editingOn(cwd) {
385
+ try {
386
+ return editingEnabled(readSidecar(), cwd);
387
+ }
388
+ catch {
389
+ return false;
390
+ }
391
+ }
392
+ /** The tools to list for `cwd`, read now: the switch may have moved since the last list. */
393
+ function listTools(cwd) {
394
+ const editing = editingOn(cwd);
395
+ return [
396
+ TOOL,
397
+ ...SCENARIO_TOOLS.filter((t) => !t.changes || editing).map(({ name, description, inputSchema }) => ({
398
+ name,
399
+ description,
400
+ inputSchema,
401
+ })),
402
+ ];
403
+ }
404
+ /** This package's own `bir`, beside this file — never whatever `bir` is first on PATH. */
405
+ function birPath() {
406
+ return resolvePath(dirname(fileURLToPath(import.meta.url)), "bir.js");
407
+ }
408
+ /** Scenario tool calls in flight, by JSON-RPC request id. */
409
+ const inFlight = new Map();
410
+ /** Remove a call's logic directory, once. Never throws: this runs on the way out. */
411
+ function removeDir(flight) {
412
+ const dir = flight.dir;
413
+ if (!dir)
414
+ return;
415
+ flight.dir = undefined;
416
+ try {
417
+ // A child killed a moment ago may still hold a handle on Windows; retry briefly.
418
+ rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
419
+ }
420
+ catch (err) {
421
+ logLine("scenario.tmp_left", { dir, error: errText(err) });
422
+ }
423
+ }
424
+ /** Stop a call's `bir` (if it still runs) and remove its logic files. Synchronous: it runs before `process.exit`. */
425
+ function stopFlight(flight) {
426
+ const child = flight.child;
427
+ if (child && child.exitCode === null && child.signalCode === null) {
428
+ try {
429
+ child.kill();
430
+ }
431
+ catch {
432
+ // Already gone.
433
+ }
434
+ }
435
+ removeDir(flight);
436
+ }
437
+ function runBir(argv, cwd, flight) {
438
+ return new Promise((resolve) => {
439
+ const child = spawn(process.execPath, [...nodeFlagsToCarry(), birPath(), ...argv], {
440
+ cwd,
441
+ env: process.env,
442
+ // Nothing is ever read from stdin: logic arrives as files.
443
+ stdio: ["ignore", "pipe", "pipe"],
444
+ windowsHide: true,
445
+ });
446
+ if (flight)
447
+ flight.child = child;
448
+ let stdout = "";
449
+ let stderr = "";
450
+ let timedOut = false;
451
+ child.stdout.setEncoding("utf8");
452
+ child.stderr.setEncoding("utf8");
453
+ child.stdout.on("data", (c) => {
454
+ stdout += c;
455
+ });
456
+ child.stderr.on("data", (c) => {
457
+ stderr += c;
458
+ });
459
+ const timer = setTimeout(() => {
460
+ timedOut = true;
461
+ child.kill();
462
+ }, BIR_TIMEOUT_MS);
463
+ timer.unref?.();
464
+ child.on("error", (err) => {
465
+ clearTimeout(timer);
466
+ resolve({ code: null, stdout, stderr: `${stderr}${errText(err)}`, timedOut });
467
+ });
468
+ child.on("close", (code) => {
469
+ clearTimeout(timer);
470
+ resolve({ code, stdout, stderr, timedOut });
471
+ });
472
+ });
473
+ }
474
+ const failed = (text) => ({ content: [{ type: "text", text }], isError: true });
475
+ /**
476
+ * Run one scenario tool. Never throws. The logic bodies are written to a
477
+ * directory of their own under the OS temp directory, owner-only, and the
478
+ * directory is removed whatever happens — the code may carry a table name or a
479
+ * host the person would not want left lying around. "Whatever happens"
480
+ * includes the host leaving mid-call: the directory and the child live in
481
+ * `flight`, which `main` stops on the way out ({@link stopFlight}).
482
+ */
483
+ async function callScenarioTool(tool, args, cwd, flight = { tool: tool.name, cancelled: false }) {
484
+ let plan;
485
+ try {
486
+ plan = tool.plan(args);
487
+ }
488
+ catch (err) {
489
+ return failed(`invalid arguments for ${tool.name}: ${errText(err)}`);
490
+ }
491
+ try {
492
+ const argv = [...plan.argv];
493
+ if (plan.inputLogic !== undefined || plan.outputLogic !== undefined) {
494
+ const dir = mkdtempSync(join(tmpdir(), "bir-logic-"));
495
+ flight.dir = dir;
496
+ if (plan.inputLogic !== undefined) {
497
+ const file = join(dir, "input.js");
498
+ writeFileSync(file, plan.inputLogic, { encoding: "utf8", mode: 0o600 });
499
+ argv.push("--input-logic", file);
500
+ }
501
+ if (plan.outputLogic !== undefined) {
502
+ const file = join(dir, "output.js");
503
+ writeFileSync(file, plan.outputLogic, { encoding: "utf8", mode: 0o600 });
504
+ argv.push("--output-logic", file);
505
+ }
506
+ }
507
+ argv.push("--json");
508
+ if (flight.cancelled)
509
+ return failed("cancelled before bir started");
510
+ const run = await runBir(argv, cwd, flight);
511
+ if (flight.cancelled)
512
+ 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();
516
+ if (run.code === 0)
517
+ return { content: [{ type: "text", text: stdout || "(no output)" }], isError: false };
518
+ // The refusal and its report are on stdout; why bir itself failed, if it
519
+ // 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 ?? "?"}`);
522
+ }
523
+ catch (err) {
524
+ return failed(`could not run bir: ${errText(err)}`);
525
+ }
526
+ finally {
527
+ removeDir(flight);
528
+ }
529
+ }
108
530
  function main() {
109
531
  const cwd = process.cwd();
110
532
  let closing = false;
111
533
  const reply = (msg) => writeFrame(process.stdout, msg);
534
+ /**
535
+ * The host is gone: stop every `bir` still running for it and remove its
536
+ * logic files, then exit. Everything here is synchronous, because nothing
537
+ * after `process.exit` runs — not a pending call's `finally` either.
538
+ */
539
+ const shutdown = (why) => {
540
+ if (closing)
541
+ return;
542
+ closing = true;
543
+ if (inFlight.size > 0) {
544
+ logLine("scenario.shutdown", { why, stopped: [...inFlight.values()].map((f) => f.tool) });
545
+ }
546
+ for (const flight of inFlight.values())
547
+ stopFlight(flight);
548
+ inFlight.clear();
549
+ process.exit(0);
550
+ };
112
551
  readFrames(process.stdin, {
113
552
  onMessage: (msg) => {
114
- if (!isRequest(msg))
115
- return; // notifications need no answer
553
+ if (!isRequest(msg)) {
554
+ // Notifications need no answer; one says to stop a call (MCP
555
+ // `notifications/cancelled`), and a stopped call is answered by nobody.
556
+ if (isNotification(msg) && msg.method === "notifications/cancelled") {
557
+ const requestId = paramsObject(msg.params).requestId;
558
+ const flight = typeof requestId === "string" || typeof requestId === "number" ? inFlight.get(requestId) : undefined;
559
+ if (flight && !flight.cancelled) {
560
+ flight.cancelled = true;
561
+ logLine("scenario.tool_cancelled", { tool: flight.tool, running: flight.child !== undefined });
562
+ stopFlight(flight);
563
+ }
564
+ }
565
+ return;
566
+ }
116
567
  const { id, method } = msg;
117
568
  if (method === "initialize") {
118
569
  const params = paramsObject(msg.params);
@@ -130,7 +581,7 @@ function main() {
130
581
  return;
131
582
  }
132
583
  if (method === "tools/list") {
133
- reply({ jsonrpc: "2.0", id, result: { tools: [TOOL] } });
584
+ reply({ jsonrpc: "2.0", id, result: { tools: listTools(cwd) } });
134
585
  return;
135
586
  }
136
587
  if (method === "ping") {
@@ -139,7 +590,29 @@ function main() {
139
590
  }
140
591
  if (method === "tools/call") {
141
592
  const params = paramsObject(msg.params);
142
- if (String(params.name ?? "") !== TOOL_NAME) {
593
+ const name = String(params.name ?? "");
594
+ const scenarioTool = SCENARIO_TOOLS.find((t) => t.name === name);
595
+ if (scenarioTool) {
596
+ // 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.
598
+ if (scenarioTool.changes && !editingOn(cwd)) {
599
+ reply(errorResponse(id, JsonRpcErrorCode.MethodNotFound, `${name} is not offered in this project: scenario editing is off. ` +
600
+ "A person turns it on with `bir scenario editing on` in this directory, then restarts the session."));
601
+ return;
602
+ }
603
+ logDetail("scenario.tool", { cwd, tool: name });
604
+ const flight = { tool: name, cancelled: false };
605
+ 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
+ });
613
+ return;
614
+ }
615
+ if (name !== TOOL_NAME) {
143
616
  reply(errorResponse(id, JsonRpcErrorCode.MethodNotFound, `unknown tool: ${params.name}`));
144
617
  return;
145
618
  }
@@ -159,17 +632,12 @@ function main() {
159
632
  reply(errorResponse(id, JsonRpcErrorCode.MethodNotFound, `unsupported method: ${method}`));
160
633
  },
161
634
  onParseError: (detail) => logLine("scenario.frame_error", { reason: detail.reason }),
162
- onEnd: () => {
163
- // The host closed our stdin: the session is over.
164
- if (closing)
165
- return;
166
- closing = true;
167
- process.exit(0);
168
- },
635
+ // The host closed our stdin: the session is over.
636
+ onEnd: () => shutdown("stdin closed"),
169
637
  });
170
638
  logLine("scenario.started", { cwd, pid: process.pid });
171
- process.on("SIGINT", () => process.exit(0));
172
- process.on("SIGTERM", () => process.exit(0));
639
+ process.on("SIGINT", () => shutdown("SIGINT"));
640
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
173
641
  process.on("uncaughtException", (err) => logLine("scenario.uncaught", { error: errText(err) }));
174
642
  process.on("unhandledRejection", (err) => logLine("scenario.unhandled_rejection", { error: errText(err) }));
175
643
  }
package/dist/bin/bir.d.ts CHANGED
@@ -12,6 +12,16 @@
12
12
  * bir login | logout
13
13
  * bir --version
14
14
  *
15
+ * bir scenario list | show <runId|scnId> [--step <n>] | calc <runId|scnId> [--force [--discard-edits]]
16
+ * bir scenario check|edit <runId|scnId> --step <n> --input-logic <file|-> … ← fix one step
17
+ * bir scenario edits | undo <runId|scnId> [--edit <sedit_id> | --step <n>] [--force --note "why"]
18
+ * bir scenario editing on|off|status ← may this project's `bir` MCP server change a scenario?
19
+ * bir scenario replay <scnId> --prompt "…" [--dry] | bir replay status|on|off|allow …
20
+ * bir investigate [<id>] | list | executions
21
+ *
22
+ * The `scenario` words that read or change one step live in scenario-edit.ts
23
+ * (editSteps.md in the BaseIn repository); `investigate` in investigate.ts.
24
+ *
15
25
  * `bir install` is reversible by construction: every file it edits is stashed
16
26
  * verbatim first, so `bir uninstall` restores it byte-for-byte unless somebody
17
27
  * else edited it in the meantime (in which case it repairs the entries and says