@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 +41 -1
- package/dist/bin/bir-scenario.d.ts +29 -2
- package/dist/bin/bir-scenario.js +484 -16
- package/dist/bin/bir.d.ts +10 -0
- package/dist/bin/bir.js +143 -43
- package/dist/bin/investigate.js +2 -2
- package/dist/bin/scenario-edit.d.ts +200 -0
- package/dist/bin/scenario-edit.js +950 -0
- package/dist/config/generate.d.ts +21 -0
- package/dist/config/generate.js +16 -0
- package/dist/control/server.js +18 -5
- package/dist/record/housekeeping.d.ts +71 -0
- package/dist/record/housekeeping.js +417 -0
- package/dist/replay/controller.d.ts +3 -1
- package/dist/replay/controller.js +17 -12
- package/docs/calculatedReplay.md +115 -4
- package/docs/calculatedReplayGuide.md +528 -10
- package/docs/quickstart.md +44 -2
- package/package.json +1 -1
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` —
|
|
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 —
|
|
4
|
-
*
|
|
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
|
*/
|
package/dist/bin/bir-scenario.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* bir-scenario —
|
|
4
|
-
*
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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", () =>
|
|
172
|
-
process.on("SIGTERM", () =>
|
|
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
|