@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.
- package/README.md +540 -350
- package/dist/auth/client.d.ts +65 -3
- package/dist/auth/client.js +305 -53
- package/dist/bin/bir-hooks.d.ts +72 -31
- package/dist/bin/bir-hooks.js +176 -284
- package/dist/bin/bir-proxy.d.ts +17 -2
- package/dist/bin/bir-proxy.js +222 -28
- package/dist/bin/bir-scenario.d.ts +61 -10
- package/dist/bin/bir-scenario.js +704 -135
- package/dist/bin/bir.d.ts +14 -8
- package/dist/bin/bir.js +2937 -488
- package/dist/bin/investigate.js +41 -16
- package/dist/bin/scenario-edit.d.ts +43 -10
- package/dist/bin/scenario-edit.js +281 -80
- package/dist/bin/scenario-liveness.d.ts +194 -0
- package/dist/bin/scenario-liveness.js +459 -0
- package/dist/bin/scenario-stderr.d.ts +43 -0
- package/dist/bin/scenario-stderr.js +76 -0
- package/dist/bin/service-read.d.ts +88 -0
- package/dist/bin/service-read.js +128 -0
- package/dist/bin/setup.d.ts +19 -0
- package/dist/bin/setup.js +146 -20
- package/dist/config/adapters/claude-code.d.ts +92 -14
- package/dist/config/adapters/claude-code.js +291 -101
- package/dist/config/adapters/generic.d.ts +4 -1
- package/dist/config/adapters/generic.js +13 -14
- package/dist/config/generate.d.ts +392 -14
- package/dist/config/generate.js +666 -19
- package/dist/config/mcp-approval.d.ts +76 -0
- package/dist/config/mcp-approval.js +210 -0
- package/dist/config/node-path.d.ts +42 -0
- package/dist/config/node-path.js +102 -0
- package/dist/config/overrides.d.ts +62 -0
- package/dist/config/overrides.js +136 -0
- package/dist/config/resolve.d.ts +70 -8
- package/dist/config/resolve.js +186 -15
- package/dist/control/client.d.ts +22 -0
- package/dist/control/client.js +26 -7
- package/dist/control/correlation.d.ts +23 -5
- package/dist/control/correlation.js +0 -0
- package/dist/control/daemon.d.ts +400 -18
- package/dist/control/daemon.js +1105 -102
- package/dist/control/discovery.d.ts +77 -5
- package/dist/control/discovery.js +186 -11
- package/dist/control/ensure-hook.d.ts +61 -0
- package/dist/control/ensure-hook.js +160 -8
- package/dist/control/paths.d.ts +26 -0
- package/dist/control/paths.js +34 -0
- package/dist/control/pin-guard.d.ts +83 -0
- package/dist/control/pin-guard.js +534 -0
- package/dist/control/recorder-keeper.d.ts +229 -0
- package/dist/control/recorder-keeper.js +721 -0
- package/dist/control/recorder-main.d.ts +73 -0
- package/dist/control/recorder-main.js +778 -0
- package/dist/control/server.d.ts +707 -31
- package/dist/control/server.js +2803 -306
- package/dist/control/transcript.d.ts +21 -1
- package/dist/control/transcript.js +378 -112
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/jsonrpc/framing.d.ts +76 -12
- package/dist/jsonrpc/framing.js +216 -59
- package/dist/proxy/intercept.d.ts +49 -5
- package/dist/proxy/intercept.js +188 -59
- package/dist/proxy/relay.d.ts +16 -0
- package/dist/proxy/relay.js +77 -5
- package/dist/proxy/revive.d.ts +122 -0
- package/dist/proxy/revive.js +251 -0
- package/dist/proxy/session.d.ts +222 -14
- package/dist/proxy/session.js +708 -99
- package/dist/record/housekeeping.d.ts +71 -0
- package/dist/record/housekeeping.js +417 -0
- package/dist/record/recorder.d.ts +51 -3
- package/dist/record/recorder.js +3 -0
- package/dist/record/redact.d.ts +103 -9
- package/dist/record/redact.js +659 -30
- package/dist/record/remote-recorder.d.ts +327 -32
- package/dist/record/remote-recorder.js +1517 -187
- package/dist/record/switchable-recorder.d.ts +126 -0
- package/dist/record/switchable-recorder.js +351 -0
- package/dist/record/truncate.d.ts +40 -1
- package/dist/record/truncate.js +111 -4
- package/dist/replay/bash-channel.d.ts +35 -0
- package/dist/replay/bash-channel.js +133 -0
- package/dist/replay/bundle.d.ts +10 -0
- package/dist/replay/bundle.js +10 -0
- package/dist/replay/controller.d.ts +371 -20
- package/dist/replay/controller.js +1185 -111
- package/dist/replay/derive.d.ts +1 -0
- package/dist/replay/derive.js +81 -24
- package/dist/replay/executor.d.ts +51 -16
- package/dist/replay/executor.js +114 -41
- package/dist/replay/handover.d.ts +54 -1
- package/dist/replay/handover.js +55 -5
- package/dist/replay/logic.d.ts +62 -10
- package/dist/replay/logic.js +140 -22
- package/dist/replay/plan.d.ts +201 -27
- package/dist/replay/plan.js +581 -141
- package/dist/replay/safety.d.ts +72 -0
- package/dist/replay/safety.js +303 -0
- package/dist/replay/source-run.d.ts +10 -0
- package/dist/replay/source-run.js +80 -35
- package/dist/replay/targets.d.ts +15 -1
- package/dist/replay/targets.js +62 -4
- package/dist/replay/tool-error.d.ts +15 -1
- package/dist/replay/tool-error.js +33 -1
- package/dist/replay/types.d.ts +7 -4
- package/dist/upstream/client.d.ts +56 -3
- package/dist/upstream/client.js +61 -4
- package/dist/upstream/headers.d.ts +43 -0
- package/dist/upstream/headers.js +90 -0
- package/dist/upstream/http-client.d.ts +213 -22
- package/dist/upstream/http-client.js +1034 -168
- package/dist/upstream/http-transport.d.ts +143 -0
- package/dist/upstream/http-transport.js +391 -0
- package/dist/upstream/lazy-client.d.ts +8 -0
- package/dist/upstream/lazy-client.js +29 -1
- package/dist/upstream/sse.d.ts +78 -0
- package/dist/upstream/sse.js +189 -0
- package/dist/upstream/stdio-client.d.ts +61 -1
- package/dist/upstream/stdio-client.js +356 -33
- package/dist/util/atomic-write.d.ts +29 -0
- package/dist/util/atomic-write.js +96 -0
- package/dist/util/journal.d.ts +15 -0
- package/dist/util/journal.js +48 -3
- package/dist/util/log.d.ts +10 -0
- package/dist/util/log.js +28 -1
- package/dist/util/off-switch.d.ts +46 -0
- package/dist/util/off-switch.js +91 -0
- package/dist/util/service-health.d.ts +133 -0
- package/dist/util/service-health.js +217 -0
- package/docs/BaseInstRunner.md +694 -621
- package/docs/calculatedReplay.md +1625 -1244
- package/docs/calculatedReplayGuide.md +1212 -879
- package/docs/installRun.md +1013 -772
- package/docs/loginWeb.md +627 -607
- package/docs/mcpmark.md +49 -16
- package/docs/my-first-sample.md +569 -545
- package/docs/quickstart.md +384 -271
- package/docs/t-bench.md +406 -394
- package/package.json +58 -57
- package/production.md +1371 -0
- package/scripts/install.ps1 +97 -6
- package/scripts/install.sh +104 -7
package/dist/bin/bir-scenario.js
CHANGED
|
@@ -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
|
-
*
|
|
27
|
-
* is an `isError` result that
|
|
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
|
-
*
|
|
30
|
-
* `bir
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
-
/**
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
/**
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
92
|
-
*
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
104
|
-
if (
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
//
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
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: "
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
"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
|
-
|
|
207
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
328
|
-
|
|
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
|
-
/**
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
},
|
|
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
|
-
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
const
|
|
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
|
|
451
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
480
|
-
|
|
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
|
|
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
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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
|
-
|
|
517
|
-
|
|
518
|
-
logLine("scenario.answered", {
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
533
|
-
|
|
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
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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
|
}
|