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