@basein/runner 0.2.12 → 0.3.1
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 +2787 -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/control/server.d.ts
CHANGED
|
@@ -26,6 +26,38 @@
|
|
|
26
26
|
import { type IntentSource } from "./transcript.js";
|
|
27
27
|
import { type Recorder } from "../record/recorder.js";
|
|
28
28
|
import { type ReplayOptions } from "../replay/controller.js";
|
|
29
|
+
import { type ServiceHealth } from "../util/service-health.js";
|
|
30
|
+
/**
|
|
31
|
+
* The prompt match gate (see {@link ControlServer.armFromMatch}). A match that
|
|
32
|
+
* answered slower than `slowMs` (`BIR_MATCH_SLOW_MS`), or not inside its
|
|
33
|
+
* budget, is not waited for again for `holdMs`; the hold doubles while the
|
|
34
|
+
* service stays slow, up to `maxHoldMs`, and a fast answer ends it.
|
|
35
|
+
*/
|
|
36
|
+
export interface MatchGateTimings {
|
|
37
|
+
slowMs: number;
|
|
38
|
+
holdMs: number;
|
|
39
|
+
maxHoldMs: number;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Session bookkeeping (see {@link ControlServer.reap}). A long-lived recorder
|
|
43
|
+
* sees every session its project ever has — every `/clear`, every `claude -p`
|
|
44
|
+
* — and must forget them, and a turn that ended without `Stop` (Esc, then the
|
|
45
|
+
* window closed) must stop counting as one in progress.
|
|
46
|
+
*/
|
|
47
|
+
export interface SessionTimings {
|
|
48
|
+
/** A session unheard from for longer is not "mid-turn" on `/health`. */
|
|
49
|
+
activeMs: number;
|
|
50
|
+
/** An open run unheard from for longer is sealed: the turn was abandoned. */
|
|
51
|
+
idleSealMs: number;
|
|
52
|
+
/** A session with no open run, unheard from for longer, is forgotten. */
|
|
53
|
+
forgetMs: number;
|
|
54
|
+
/** A session this long after its `SessionEnd`, and silent since, is forgotten. */
|
|
55
|
+
endedForgetMs: number;
|
|
56
|
+
/** Past this many, the least recently heard sessions without an open run go first. */
|
|
57
|
+
maxSessions: number;
|
|
58
|
+
/** How often the reaper runs. */
|
|
59
|
+
reapEveryMs: number;
|
|
60
|
+
}
|
|
29
61
|
/** The subset of the Claude Code hook payload we consume (all optional, all defensive). */
|
|
30
62
|
export interface HookPayload {
|
|
31
63
|
session_id?: string;
|
|
@@ -39,6 +71,18 @@ export interface HookPayload {
|
|
|
39
71
|
tool_use_id?: string;
|
|
40
72
|
error?: unknown;
|
|
41
73
|
agent_id?: string;
|
|
74
|
+
/**
|
|
75
|
+
* The host's permission mode for this event (`default`, `plan`,
|
|
76
|
+
* `acceptEdits`, `bypassPermissions`, …). Decides whether answering `allow`
|
|
77
|
+
* would change anything the user is asked — see `injectionDecision`.
|
|
78
|
+
*/
|
|
79
|
+
permission_mode?: string;
|
|
80
|
+
/**
|
|
81
|
+
* Added by the SessionStart ensure hook (never by the host) when the
|
|
82
|
+
* session was started with `BIR_OFF=1`: the runner is off for this session
|
|
83
|
+
* only. See {@link ControlServer.offSessions}.
|
|
84
|
+
*/
|
|
85
|
+
bir_off?: unknown;
|
|
42
86
|
}
|
|
43
87
|
export interface ControlServerOptions {
|
|
44
88
|
recorder: Recorder;
|
|
@@ -71,15 +115,56 @@ export interface ControlServerOptions {
|
|
|
71
115
|
version?: string;
|
|
72
116
|
};
|
|
73
117
|
/**
|
|
74
|
-
*
|
|
118
|
+
* Legacy twin of {@link correlation}, as `bir-hooks` up to 0.2.12 passes it.
|
|
75
119
|
*
|
|
76
|
-
* `"allow"`
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* the fingerprint path). Set with `BIR_CORRELATION_DECISION`.
|
|
120
|
+
* Those versions passed `"allow"` whenever `BIR_CORRELATION_DECISION` was
|
|
121
|
+
* unset — the old default, not a choice anybody made — so only `"ask"` is
|
|
122
|
+
* read from here. `BIR_CORRELATION_DECISION=allow` in the recorder's
|
|
123
|
+
* environment is the explicit opt-in, and is read directly.
|
|
81
124
|
*/
|
|
82
125
|
correlationDecision?: "allow" | "ask";
|
|
126
|
+
/**
|
|
127
|
+
* How `PreToolUse` joins an ordinary (not replay-pinned) wrapped MCP call to
|
|
128
|
+
* its proxy's report. Overrides `BIR_CORRELATION_DECISION`.
|
|
129
|
+
*
|
|
130
|
+
* - `"auto"` (default): carry a correlation id only where answering `allow`
|
|
131
|
+
* changes nothing the user is asked — the session is in
|
|
132
|
+
* `bypassPermissions` mode. Everywhere else the hook answers with no
|
|
133
|
+
* decision at all and the report joins by fingerprint, so the user's
|
|
134
|
+
* permission prompts for their MCP tools are exactly what they were
|
|
135
|
+
* without the runner.
|
|
136
|
+
* - `"allow"`: always inject and answer `allow` — the behaviour of 0.2.12 and earlier,
|
|
137
|
+
* which **auto-approves every wrapped call**. Opt-in only.
|
|
138
|
+
* - `"ask"`: always inject and answer `ask`, which prompts even for tools
|
|
139
|
+
* the user allow-listed, and shows the injected id in the prompt.
|
|
140
|
+
*
|
|
141
|
+
* Never `allow` in `plan` mode, and never an id into a server with no live
|
|
142
|
+
* proxy to strip it (see `hasLiveProxy`).
|
|
143
|
+
*/
|
|
144
|
+
correlation?: "auto" | "allow" | "ask";
|
|
145
|
+
/**
|
|
146
|
+
* Whether this project has the `bir` MCP server, through which a `direct`
|
|
147
|
+
* plan is delivered (`mcp__bir__run_scenario`). A plan that names a tool
|
|
148
|
+
* the model does not have is never armed: the model would look for it, fail,
|
|
149
|
+
* reach for its own tools — and a divergence would then run the scenario
|
|
150
|
+
* unattended in a project that never installed replay's delivery tool.
|
|
151
|
+
*
|
|
152
|
+
* A boolean is the caller vouching either way (tests, an embedder that
|
|
153
|
+
* delivers direct plans itself). Omitted — every recorder `bir` starts — a
|
|
154
|
+
* direct plan needs **live** evidence: a `/scenario/hello` from a
|
|
155
|
+
* `bir-scenario` that still runs, lists at least one tool and is not off,
|
|
156
|
+
* within {@link DELIVERY_HELLO_TTL_MS}. Configured is not connected: the
|
|
157
|
+
* server may have failed to start, been disabled in `/mcp`, or run under
|
|
158
|
+
* `BIR_OFF=1` with no tools. The background recorder (`BIR_DAEMON=1`) also
|
|
159
|
+
* reads the project's MCP configuration, off the hook path, and a project
|
|
160
|
+
* with no `bir` entry there has none. A function answers the configuration
|
|
161
|
+
* half; live evidence is still required.
|
|
162
|
+
*/
|
|
163
|
+
deliveryTool?: boolean | (() => boolean | undefined);
|
|
164
|
+
/** The prompt match gate's thresholds (tests). See {@link MatchGateTimings}. */
|
|
165
|
+
matchGate?: Partial<MatchGateTimings>;
|
|
166
|
+
/** Session bookkeeping thresholds (tests). See {@link SessionTimings}. */
|
|
167
|
+
sessionTimings?: Partial<SessionTimings>;
|
|
83
168
|
/** Disable id injection; use `(server, tool, hash(args))` matching instead. */
|
|
84
169
|
noCorrelation?: boolean;
|
|
85
170
|
/**
|
|
@@ -118,6 +203,30 @@ export interface ControlServerOptions {
|
|
|
118
203
|
* killed, which on Windows is the only other option.
|
|
119
204
|
*/
|
|
120
205
|
onStopRequested?: () => void;
|
|
206
|
+
/**
|
|
207
|
+
* Whether an intent probe may arm a plan inside the `PreToolUse` that sent it
|
|
208
|
+
* (fallbk.md §Runner 4).
|
|
209
|
+
*
|
|
210
|
+
* Arming there means *waiting* there, on every unsteered tool call, for a
|
|
211
|
+
* service round trip — so by default the probe is sent without waiting, for
|
|
212
|
+
* hit counting only, and nothing arms mid-turn. It waits (at most
|
|
213
|
+
* `BIR_INTENT_ARM_BUDGET_MS`, 500 ms, and never while the service is failing)
|
|
214
|
+
* when segment arming is on (`BIR_SEGMENT_ARM=1`), or when `BIR_INTENT_ARM=1`
|
|
215
|
+
* asks for whole-scenario intent arming as before. `BIR_INTENT_ARM=0` never
|
|
216
|
+
* waits.
|
|
217
|
+
*/
|
|
218
|
+
intentArm?: boolean;
|
|
219
|
+
/**
|
|
220
|
+
* The verdict hot paths consult before they wait on the service. Defaults to
|
|
221
|
+
* the process-wide one every service call reports to; tests pass their own.
|
|
222
|
+
*/
|
|
223
|
+
serviceHealth?: ServiceHealth;
|
|
224
|
+
/**
|
|
225
|
+
* Shorter per-route hook deadlines, in ms, keyed by route (tests). A value
|
|
226
|
+
* above the built-in deadline is ignored: those are what keep every hook
|
|
227
|
+
* inside the timeout `bir install` writes.
|
|
228
|
+
*/
|
|
229
|
+
hookDeadlinesMs?: Readonly<Record<string, number>>;
|
|
121
230
|
}
|
|
122
231
|
export interface ControlServerAddress {
|
|
123
232
|
url: string;
|
|
@@ -136,11 +245,102 @@ export declare class ControlServer {
|
|
|
136
245
|
private readonly wrapped;
|
|
137
246
|
/** serverName → registration facts, for `bir doctor`. */
|
|
138
247
|
private readonly registrations;
|
|
248
|
+
/**
|
|
249
|
+
* serverName → every proxy pid that registered for it. A call id is carried
|
|
250
|
+
* only into a server one of these still serves: the proxy is what strips it,
|
|
251
|
+
* and a server unwrapped while this recorder ran must never receive it.
|
|
252
|
+
*/
|
|
253
|
+
private readonly proxyPids;
|
|
254
|
+
/** serverName → the last answer of {@link hasLiveProxy}, briefly. */
|
|
255
|
+
private readonly liveProxyCache;
|
|
256
|
+
/**
|
|
257
|
+
* proxyId → the session its calls were joined to. Where a report no hook
|
|
258
|
+
* minted an id for is recorded — never "whichever session is first".
|
|
259
|
+
*/
|
|
260
|
+
private readonly proxySessions;
|
|
261
|
+
/** `run_scenario` pres not yet consumed by a `/scenario/run`, oldest first. */
|
|
262
|
+
private directCalls;
|
|
263
|
+
/** Plans a `/scenario/run` is executing right now: one run per plan. */
|
|
264
|
+
private readonly runningPlans;
|
|
265
|
+
/**
|
|
266
|
+
* Steer plans whose last pin the guard declined (control/pin-guard.ts): the
|
|
267
|
+
* model made the step's call its own way, so the plan is a step behind the
|
|
268
|
+
* model. See {@link abandonAfterDeclinedPin}.
|
|
269
|
+
*/
|
|
270
|
+
private readonly declinedPins;
|
|
271
|
+
/** Whether this project's `bir` server exists, from its MCP config (see {@link ControlServerOptions.deliveryTool}). */
|
|
272
|
+
private deliveryFromConfig?;
|
|
273
|
+
private deliveryConfigStamp?;
|
|
274
|
+
private deliveryTimer?;
|
|
275
|
+
/**
|
|
276
|
+
* A hook named an `mcp__bir__*` tool: the delivery tool is configured,
|
|
277
|
+
* whatever the config file said. Configured only — connected is what
|
|
278
|
+
* {@link scenarioHellos} proves.
|
|
279
|
+
*/
|
|
280
|
+
private birToolSeen;
|
|
281
|
+
/**
|
|
282
|
+
* `bir-scenario` pid → its last `/scenario/hello` (K3). The only evidence a
|
|
283
|
+
* direct plan's delivery tool is connected right now, rather than merely
|
|
284
|
+
* listed in a config file (see {@link deliveryToolAvailable}).
|
|
285
|
+
*/
|
|
286
|
+
private readonly scenarioHellos;
|
|
287
|
+
/**
|
|
288
|
+
* Sessions started with `BIR_OFF=1` (K2), as the SessionStart ensure hook
|
|
289
|
+
* relays it, → when last heard, and when its `SessionEnd` came. Every hook
|
|
290
|
+
* of such a session is answered `{}` unprocessed, and its proxies' reports
|
|
291
|
+
* are dropped: the switch is the session's, and the long-lived recorder
|
|
292
|
+
* never inherits it. Kept for {@link OFF_SESSION_TTL_MS} of silence (not the
|
|
293
|
+
* ordinary `forgetMs`), or until a little after its `SessionEnd` — a resume
|
|
294
|
+
* fires SessionStart, which relays the switch again. Memory only: a
|
|
295
|
+
* recorder that restarts no longer knows the session is off until its next
|
|
296
|
+
* SessionStart.
|
|
297
|
+
*/
|
|
298
|
+
private readonly offSessions;
|
|
299
|
+
/**
|
|
300
|
+
* Proxy ids that registered with *this* recorder (UPG-7). A poll from any
|
|
301
|
+
* other id is a proxy from before this recorder started — a 0.2.12 proxy
|
|
302
|
+
* never registers again — and is held rather than answered at once.
|
|
303
|
+
*/
|
|
304
|
+
private readonly registeredProxies;
|
|
305
|
+
/** Releases for polls held with replay off (see {@link onProxyPoll}), for `close()`. */
|
|
306
|
+
private readonly heldPolls;
|
|
307
|
+
/**
|
|
308
|
+
* Direct plans whose `/scenario/run` caller went away mid-run (Esc). The
|
|
309
|
+
* turn was cut short by the user, so what it cost says nothing about the
|
|
310
|
+
* scenario: its report is sent unmeasured.
|
|
311
|
+
*/
|
|
312
|
+
private readonly cancelledPlans;
|
|
313
|
+
/** See {@link MatchGateTimings}. */
|
|
314
|
+
private readonly matchGateTimings;
|
|
315
|
+
/**
|
|
316
|
+
* The prompt match gate's state: until when prompts do not wait on the
|
|
317
|
+
* match, the hold that was last applied (doubles while slow), and when it
|
|
318
|
+
* was applied (a slow match that started before then says nothing new).
|
|
319
|
+
*/
|
|
320
|
+
private matchGate;
|
|
321
|
+
private readonly timings;
|
|
322
|
+
private reaper?;
|
|
139
323
|
private server?;
|
|
140
324
|
private address?;
|
|
141
325
|
private lossy;
|
|
142
|
-
/**
|
|
143
|
-
|
|
326
|
+
/**
|
|
327
|
+
* The project's current token as the install sidecar last said, and the
|
|
328
|
+
* file stamp it was read at (see {@link currentProjectToken}).
|
|
329
|
+
*/
|
|
330
|
+
private sidecarToken?;
|
|
331
|
+
/**
|
|
332
|
+
* Runs being drained to the service in the background, after a hook sealed
|
|
333
|
+
* them. Only `close()` waits for these, and only for so long.
|
|
334
|
+
*/
|
|
335
|
+
private readonly drains;
|
|
336
|
+
/** Whether the service may be waited on right now (service-health.ts). */
|
|
337
|
+
private readonly service;
|
|
338
|
+
/**
|
|
339
|
+
* Hook requests answered `{}` without being processed, and why — surfaced on
|
|
340
|
+
* `/health`, because each is invisible to the user by design and `bir
|
|
341
|
+
* doctor` is where it has to be explained.
|
|
342
|
+
*/
|
|
343
|
+
private readonly hookStats;
|
|
144
344
|
/** Calculated replay. Inert unless `opts.replay.enabled`. */
|
|
145
345
|
private readonly replay;
|
|
146
346
|
/**
|
|
@@ -160,16 +360,147 @@ export declare class ControlServer {
|
|
|
160
360
|
close(): Promise<void>;
|
|
161
361
|
/** Facts for `bir doctor` / `GET /health`. */
|
|
162
362
|
health(): Record<string, unknown>;
|
|
363
|
+
/**
|
|
364
|
+
* What this recorder is doing right now, for the daemon deciding whether a
|
|
365
|
+
* superseded recorder may exit (K4). Cheap: no I/O beyond pid checks.
|
|
366
|
+
*
|
|
367
|
+
* `knownSessions` counts the sessions started with `BIR_OFF=1` that have not
|
|
368
|
+
* ended, too: only this recorder knows they are off, and a recorder that
|
|
369
|
+
* replaced it would record them.
|
|
370
|
+
*/
|
|
371
|
+
activity(): {
|
|
372
|
+
openRuns: number;
|
|
373
|
+
knownSessions: number;
|
|
374
|
+
liveProxyPids: number;
|
|
375
|
+
};
|
|
376
|
+
/**
|
|
377
|
+
* The recorder's own sending state (record/recorder.ts `RecorderHealth`),
|
|
378
|
+
* for `/health`, or null when it has none to give. A recorder that swaps
|
|
379
|
+
* backends may answer nothing for one that has no service; and `/health`
|
|
380
|
+
* must answer whatever this throws.
|
|
381
|
+
*/
|
|
382
|
+
private recorderBacklog;
|
|
383
|
+
/**
|
|
384
|
+
* Whether `header` carries a token this recorder accepts: the one it was
|
|
385
|
+
* started with, or — after a reinstall on the same port rewrote the hooks
|
|
386
|
+
* with a new one — the project's *current* token from the install sidecar.
|
|
387
|
+
* Without the second, every hook of every session answered `{}` unprocessed
|
|
388
|
+
* and nothing was recorded until the recorder happened to restart.
|
|
389
|
+
*/
|
|
390
|
+
private authorized;
|
|
391
|
+
/**
|
|
392
|
+
* This project's token as the install sidecar names it now. The sidecar is
|
|
393
|
+
* stat'd at most once per {@link SIDECAR_TOKEN_RECHECK_MS} and parsed only
|
|
394
|
+
* when its mtime or size changed, so a stale token on every hook costs a
|
|
395
|
+
* cached comparison. Never throws: an unreadable sidecar is "no token".
|
|
396
|
+
*/
|
|
397
|
+
private currentProjectToken;
|
|
163
398
|
private handle;
|
|
399
|
+
/**
|
|
400
|
+
* One Claude Code hook event. Always `200`, always inside the route's
|
|
401
|
+
* deadline, and never an error the user would see.
|
|
402
|
+
*
|
|
403
|
+
* Everything that is not a hook this recorder can process is answered `{}`
|
|
404
|
+
* without reading the payload: a token it did not issue and that is not the
|
|
405
|
+
* project's current one either (a reinstall's new token is accepted — see
|
|
406
|
+
* {@link authorized}), an event it does not know
|
|
407
|
+
* (settings from a newer `bir install`), a method that is not POST, a body
|
|
408
|
+
* past the size cap, and every hook while the runner is switched off. Each
|
|
409
|
+
* would otherwise be a "hook error" on every prompt and tool call until the
|
|
410
|
+
* recorder restarts, which the user can neither silence nor explain. They
|
|
411
|
+
* are counted on `/health` instead, where `bir doctor` reads them.
|
|
412
|
+
*/
|
|
413
|
+
private handleHook;
|
|
414
|
+
/**
|
|
415
|
+
* `body` as the event that fired may carry it.
|
|
416
|
+
*
|
|
417
|
+
* CLAUDE CODE CHECKS THE NAME. An answer whose `hookSpecificOutput.hookEventName`
|
|
418
|
+
* is not the event that fired is rejected: the user is shown a hook error
|
|
419
|
+
* with this answer's JSON in it, and the context never reaches the model.
|
|
420
|
+
* `/tool/post` serves both `PostToolUse` and `PostToolUseFailure`, which take
|
|
421
|
+
* the same output, so that name is echoed. Any other mismatch would be a
|
|
422
|
+
* bug here, and the output is dropped rather than shown to the user as one.
|
|
423
|
+
* A payload that names no event (an older host, a test) is answered as is.
|
|
424
|
+
*/
|
|
425
|
+
private forEvent;
|
|
426
|
+
/**
|
|
427
|
+
* Whether this hook belongs to a session started with `BIR_OFF=1` (K2), and
|
|
428
|
+
* so is answered `{}` unprocessed. The SessionStart ensure hook relays that
|
|
429
|
+
* as `bir_off: true` — the only way the long-lived recorder can learn it,
|
|
430
|
+
* since HTTP hooks carry no environment. A SessionStart for the same id
|
|
431
|
+
* without it (the session resumed from a shell without BIR_OFF) turns the
|
|
432
|
+
* session back on.
|
|
433
|
+
*/
|
|
434
|
+
private sessionOff;
|
|
435
|
+
/** Mark `sessionId` off (see {@link sessionOff}), sealing whatever it had open. */
|
|
436
|
+
private markSessionOff;
|
|
437
|
+
/** Whether `sessionId` is a session started with `BIR_OFF=1`. */
|
|
438
|
+
private isSessionOff;
|
|
439
|
+
/** The route's hook deadline, or undefined when `route` is not a hook this recorder serves. */
|
|
440
|
+
private deadlineFor;
|
|
441
|
+
/** What the prompt hook may spend on the match and arming: a second inside its deadline. */
|
|
442
|
+
private promptWaitMs;
|
|
443
|
+
private dispatchHook;
|
|
444
|
+
/**
|
|
445
|
+
* `body` with `text` put in front of whatever `additionalContext` it
|
|
446
|
+
* already carries, under `event` — the event that fired.
|
|
447
|
+
*/
|
|
448
|
+
private withContext;
|
|
449
|
+
/**
|
|
450
|
+
* The note a cancelled direct run left `sessionId` (see
|
|
451
|
+
* `SessionState.stashedNote`), claimed for delivery — or undefined when
|
|
452
|
+
* there is none, or the run's own hooks already delivered it.
|
|
453
|
+
*
|
|
454
|
+
* With `forTool`, only when that tool is one of the cancelled plan's own:
|
|
455
|
+
* a call that could repeat a step that already ran (see {@link onToolPre}).
|
|
456
|
+
*/
|
|
457
|
+
private claimStashedNote;
|
|
164
458
|
private send;
|
|
459
|
+
/**
|
|
460
|
+
* The request body as JSON. Past {@link MAX_BODY_BYTES} the rest is read and
|
|
461
|
+
* thrown away, then {@link BodyTooLarge} is rejected — the socket is never
|
|
462
|
+
* destroyed, because a reset is what a hook's caller would show as an error.
|
|
463
|
+
*/
|
|
165
464
|
private readJson;
|
|
166
465
|
private ensureSession;
|
|
466
|
+
/**
|
|
467
|
+
* Whether `session` is mid-turn: an open run, heard from recently enough to
|
|
468
|
+
* be working rather than abandoned (see {@link SessionTimings.activeMs}).
|
|
469
|
+
*/
|
|
470
|
+
private isActive;
|
|
471
|
+
/** Start the reaper and, for the background recorder, the delivery-tool refresh. Both unref'd. */
|
|
472
|
+
private startHousekeeping;
|
|
473
|
+
/**
|
|
474
|
+
* Forget what a long-lived recorder no longer needs, and stop an abandoned
|
|
475
|
+
* turn from looking like a live one.
|
|
476
|
+
*
|
|
477
|
+
* A turn interrupted with Esc gets no `Stop`, and a window closed after it
|
|
478
|
+
* often gets no `SessionEnd` either: its run stayed open for the recorder's
|
|
479
|
+
* whole life — counted `active`, so an upgrade never restarted the
|
|
480
|
+
* recorder; first in line for another session's proxy steps; and holding
|
|
481
|
+
* its plan and outputs in memory. And every `/clear` and every `claude -p`
|
|
482
|
+
* added a session that was never removed. So:
|
|
483
|
+
*
|
|
484
|
+
* - an open run silent for {@link SessionTimings.idleSealMs} is sealed —
|
|
485
|
+
* idleness, never age: one long tool call sends no hooks;
|
|
486
|
+
* - a session with no open run is forgotten once it has been silent for
|
|
487
|
+
* {@link SessionTimings.forgetMs}, or {@link SessionTimings.endedForgetMs}
|
|
488
|
+
* after its `SessionEnd`;
|
|
489
|
+
* - past {@link SessionTimings.maxSessions}, the least recently heard
|
|
490
|
+
* sessions without an open run go first.
|
|
491
|
+
*/
|
|
492
|
+
reap(now?: number): void;
|
|
493
|
+
/** Drop a session and everything keyed on it. Its last run was sealed and drained already. */
|
|
494
|
+
private forgetSession;
|
|
167
495
|
/** Open a run for `session`, or return the open one. */
|
|
168
496
|
private ensureRun;
|
|
169
497
|
private runMetadata;
|
|
170
498
|
/**
|
|
171
499
|
* A similar-prompt hit means the service created no run, so every later step
|
|
172
|
-
* post would 404. Stop recording it — that half is v1 and is mandatory
|
|
500
|
+
* post would 404. Stop recording it — that half is v1 and is mandatory, and
|
|
501
|
+
* it is applied whenever the answer lands, however late: a hit that arrived
|
|
502
|
+
* after the prompt's budget used to leave recording on, and every step of the
|
|
503
|
+
* turn then failed against a run that did not exist.
|
|
173
504
|
*
|
|
174
505
|
* What replaces the recording is v2 (docs/calculatedReplay.md): the matched
|
|
175
506
|
* scenario is put through the gate ladder and, if every gate passes, armed as a
|
|
@@ -179,6 +510,100 @@ export declare class ControlServer {
|
|
|
179
510
|
* Resolves with the steering directive to inject, or undefined.
|
|
180
511
|
*/
|
|
181
512
|
private watchForMatch;
|
|
513
|
+
/**
|
|
514
|
+
* The v2 half of {@link watchForMatch}: wait for the match — briefly, and not
|
|
515
|
+
* at all while the service is failing — and arm what it found.
|
|
516
|
+
*
|
|
517
|
+
* Both waits are bounded by {@link promptWaitMs} from the start of the
|
|
518
|
+
* match. A match that misses its budget, or a plan still deriving when the
|
|
519
|
+
* budget runs out, is never delivered: the turn runs as an ordinary one, and
|
|
520
|
+
* one `replay.decision` line says why (docs/calculatedReplay.md §10).
|
|
521
|
+
*/
|
|
522
|
+
private armFromMatch;
|
|
523
|
+
/**
|
|
524
|
+
* The match gate's verdict on one waited match: `ms` it took to answer, or
|
|
525
|
+
* undefined when it did not answer inside its budget. Slow opens a hold —
|
|
526
|
+
* {@link MatchGateTimings.holdMs}, doubled on each slow match that follows a
|
|
527
|
+
* hold, up to {@link MatchGateTimings.maxHoldMs}; fast ends it. Each
|
|
528
|
+
* transition is one line; the prompts in between say nothing at line level.
|
|
529
|
+
*
|
|
530
|
+
* A slow match that `startedAt` before the current hold began is ignored:
|
|
531
|
+
* prompts already waiting when the service turned slow are one slowness,
|
|
532
|
+
* not several, so the hold doubles at most once per hold (on its probe).
|
|
533
|
+
*/
|
|
534
|
+
private noteMatchLatency;
|
|
535
|
+
/**
|
|
536
|
+
* {@link armFromMatch} with replay off: nothing can be announced, and no hook
|
|
537
|
+
* waits for the answer (`onPrompt` has already returned). So there is no
|
|
538
|
+
* budget and no `awaitWithin` — a slow create-run here is not a wait anyone
|
|
539
|
+
* had, and must not mark the service slow (that would open the process-wide
|
|
540
|
+
* cool-down, and `/health` would report a degradation the user never had).
|
|
541
|
+
* A hit still books its decline — the ledger's baseline sample — whenever
|
|
542
|
+
* it lands, as long as the turn is still open to carry it.
|
|
543
|
+
*/
|
|
544
|
+
private noteDisabledMatch;
|
|
545
|
+
/**
|
|
546
|
+
* A `direct` plan is announced as "call mcp__bir__run_scenario". In a project
|
|
547
|
+
* with no `bir` server that is a tool the model does not have: it would say
|
|
548
|
+
* so, or reach for its own tools — and that first call would diverge and run
|
|
549
|
+
* the scenario unattended, in a project that never installed replay's
|
|
550
|
+
* delivery tool. Such a plan is declined here, before it is announced, and
|
|
551
|
+
* the agent does the task (a baseline sample, as any decline).
|
|
552
|
+
*
|
|
553
|
+
* Returns true when it declined.
|
|
554
|
+
*/
|
|
555
|
+
private declineWithoutDeliveryTool;
|
|
556
|
+
/**
|
|
557
|
+
* Whether the tool a direct plan is delivered through is connected here
|
|
558
|
+
* right now. See {@link ControlServerOptions.deliveryTool}: the caller's
|
|
559
|
+
* boolean when it gave one; otherwise the configuration must not say there
|
|
560
|
+
* is none, and a `bir-scenario` must have proved it serves the tool.
|
|
561
|
+
*/
|
|
562
|
+
private deliveryToolAvailable;
|
|
563
|
+
/**
|
|
564
|
+
* Whether this project's configuration has the `bir` server, as far as
|
|
565
|
+
* anything here knows: a hook that named one of its tools, the caller's
|
|
566
|
+
* answer, the file the background recorder reads. Unknown means yes — the
|
|
567
|
+
* live evidence decides.
|
|
568
|
+
*/
|
|
569
|
+
private deliveryConfigured;
|
|
570
|
+
/**
|
|
571
|
+
* The `bir-scenario` processes whose hello is recent and whose pid still
|
|
572
|
+
* runs, newest first. A dead or stale one is forgotten here. Pid checks
|
|
573
|
+
* only — no other I/O.
|
|
574
|
+
*/
|
|
575
|
+
private liveHellos;
|
|
576
|
+
/**
|
|
577
|
+
* `POST /scenario/hello` — a running `bir-scenario` saying what it serves
|
|
578
|
+
* (K3): on `initialize`, on `tools/list`, when its off state flips and every
|
|
579
|
+
* few minutes. It is the only proof that a direct plan's delivery tool is
|
|
580
|
+
* connected, so a direct plan is announced only while one that lists a tool
|
|
581
|
+
* and is not off still runs. Always `{ ok: true }`; never slow.
|
|
582
|
+
*/
|
|
583
|
+
private onScenarioHello;
|
|
584
|
+
/**
|
|
585
|
+
* Re-read whether this project configures the `bir` server — only when
|
|
586
|
+
* ~/.claude.json or .mcp.json changed since the last read. A file the host is
|
|
587
|
+
* halfway through rewriting keeps the previous answer.
|
|
588
|
+
*/
|
|
589
|
+
private refreshDeliveryTool;
|
|
590
|
+
/** `arming`, never rejecting: a broken arm is logged and is no plan at all. */
|
|
591
|
+
private armOrLog;
|
|
592
|
+
/**
|
|
593
|
+
* The prompt hook answered without a directive: retire any prompt-armed plan
|
|
594
|
+
* of `run` that could still steer. `armFromMatch` arms one live only while
|
|
595
|
+
* that hook waits, so this finds nothing in the ordinary course; it is what
|
|
596
|
+
* keeps a directive-less plan from ever running as a later divergence, even
|
|
597
|
+
* if an arm and the hook's answer cross.
|
|
598
|
+
*/
|
|
599
|
+
private retireUnannounced;
|
|
600
|
+
/**
|
|
601
|
+
* A prompt plan that armed after its hook had answered. The model was never
|
|
602
|
+
* told of it, so it must never steer or diverge a call: it is kept only as
|
|
603
|
+
* what it now is — a matched turn the agent does itself, which still owes the
|
|
604
|
+
* ledger its baseline sample (§11.1) — exactly like a declined match.
|
|
605
|
+
*/
|
|
606
|
+
private shelve;
|
|
182
607
|
/**
|
|
183
608
|
* Close the run's books, without waiting for the network.
|
|
184
609
|
*
|
|
@@ -218,23 +643,131 @@ export declare class ControlServer {
|
|
|
218
643
|
/** Transcript cost since `mark`; unmeasured (and zero) without one. */
|
|
219
644
|
private costSince;
|
|
220
645
|
private reportExecution;
|
|
221
|
-
/**
|
|
222
|
-
|
|
646
|
+
/**
|
|
647
|
+
* Seal the session's run now, and drain it to the service in the background.
|
|
648
|
+
*
|
|
649
|
+
* NO HOOK EVER WAITS FOR THE NETWORK TO CLOSE A RUN. The prompt after an
|
|
650
|
+
* interrupted turn (Claude Code sends no `Stop` on Esc) and `SessionEnd`
|
|
651
|
+
* used to await the whole drain — the seal's answer wait, the queue, and
|
|
652
|
+
* every send still in flight for the run — so an Esc followed by a new prompt
|
|
653
|
+
* cost 0.5-2 s on a healthy service and the full hook timeout on a hung one,
|
|
654
|
+
* and `/clear` or exit waited up to 30 s. The daemon outlives the hook, so
|
|
655
|
+
* the drain simply finishes after it: sealing is synchronous and already
|
|
656
|
+
* queues the final answer, the finish and the execution report.
|
|
657
|
+
*
|
|
658
|
+
* `detach` also forgets the run on the session — but only if it is still
|
|
659
|
+
* the run that was sealed, never one another hook opened meanwhile.
|
|
660
|
+
*/
|
|
661
|
+
private closeRun;
|
|
662
|
+
/**
|
|
663
|
+
* Start {@link drainRun} for `run`, tracked so `close()` can wait for it.
|
|
664
|
+
* Each entry leaves {@link drains} the moment its drain settles, so the set
|
|
665
|
+
* holds the drains still running — never one per turn the recorder has seen.
|
|
666
|
+
*/
|
|
667
|
+
private drainInBackground;
|
|
668
|
+
/**
|
|
669
|
+
* Everything queued for `run` reaches the service, or fails trying. Never
|
|
670
|
+
* rejects. The seal resolves its answer text off-queue, so it is awaited
|
|
671
|
+
* before the queue is flushed — otherwise the flush would finish before the
|
|
672
|
+
* finish call had even been enqueued.
|
|
673
|
+
*/
|
|
674
|
+
private drainRun;
|
|
223
675
|
private onSessionStart;
|
|
224
676
|
private onPrompt;
|
|
225
677
|
/**
|
|
226
678
|
* `PreToolUse`. Two shapes of answer:
|
|
227
679
|
*
|
|
228
|
-
* - **Wrapped MCP tool** →
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
680
|
+
* - **Wrapped MCP tool** → remember the call so the proxy's report and this
|
|
681
|
+
* hook's view join into one step (Phase 6). No step is recorded yet: the
|
|
682
|
+
* proxy owns the result and allocates the index when it reports. The join
|
|
683
|
+
* is by fingerprint, and the hook answers with **no decision**: the user's
|
|
684
|
+
* permission prompt for the tool is exactly what it would be without the
|
|
685
|
+
* runner. A call id rides in `updatedInput` only where answering `allow`
|
|
686
|
+
* changes nothing, or the operator opted in (see `injectionDecision`).
|
|
232
687
|
* - **Anything else** (built-ins, unwrapped MCP) → allocate the pair now and
|
|
233
688
|
* record `tool_selected`. The hook is this step's only observer.
|
|
689
|
+
*
|
|
690
|
+
* `onLate` tells the route's deadline guard what to answer if this call is
|
|
691
|
+
* still running when the deadline passes — see {@link replayOverran}.
|
|
234
692
|
*/
|
|
235
693
|
private onToolPre;
|
|
694
|
+
/** This run's plan, when it is armed, still live, and announced to the model. */
|
|
695
|
+
private livePlan;
|
|
696
|
+
/** Whether a `run_scenario` pre for `state` is still waiting for its `/scenario/run`. */
|
|
697
|
+
private hasDirectCall;
|
|
698
|
+
/**
|
|
699
|
+
* Retire `state` without running anything: the model's `run_scenario` was
|
|
700
|
+
* refused or failed. Books a baseline sample (`not_steered`) — the agent did
|
|
701
|
+
* the task — never a divergence.
|
|
702
|
+
*/
|
|
703
|
+
private vetoPlan;
|
|
704
|
+
/**
|
|
705
|
+
* A steer plan whose pin the guard declined, now facing a call to another
|
|
706
|
+
* tool: retire it, running nothing, and let this call run as written.
|
|
707
|
+
*
|
|
708
|
+
* The declined call was the model doing the step its own way (told that the
|
|
709
|
+
* system supplies the arguments, it often writes a guess), so the model has
|
|
710
|
+
* moved on while the plan still waits at that step. Left armed, this call to
|
|
711
|
+
* the *next* step's tool reads as a divergence: the model's call is denied,
|
|
712
|
+
* the rest of the scenario runs unattended inside this hook, and the step it
|
|
713
|
+
* already did comes back as a stale recorded output. Retiring here costs
|
|
714
|
+
* only the rest of this replay; the model is told on the next PostToolUse
|
|
715
|
+
* that the arguments are its own from now on. A call to the step's own tool
|
|
716
|
+
* keeps the plan armed — that may be the step at last.
|
|
717
|
+
*/
|
|
718
|
+
private abandonAfterDeclinedPin;
|
|
719
|
+
/** An intent plan whose directive never reached the model: it did nothing, and reports nothing. */
|
|
720
|
+
private shelveUnannounced;
|
|
721
|
+
/**
|
|
722
|
+
* How `PreToolUse` should answer while carrying a call id for `serverName`,
|
|
723
|
+
* or undefined to carry none (the report then joins by fingerprint).
|
|
724
|
+
*
|
|
725
|
+
* THE RUNNER MUST NOT CHANGE WHAT THE USER IS ASKED. Answering `allow` skips
|
|
726
|
+
* the permission prompt for the call, so an id rides along — with `allow` —
|
|
727
|
+
* only where that changes nothing: a replay-pinned step (replay is its own
|
|
728
|
+
* consent, and its `updatedInput` must be applied), or a session in
|
|
729
|
+
* `bypassPermissions` mode, where nothing prompts anyway. Otherwise only an
|
|
730
|
+
* operator's explicit `BIR_CORRELATION_DECISION` (see
|
|
731
|
+
* {@link ControlServerOptions.correlation}). Never in `plan` mode, and never
|
|
732
|
+
* into a server with no live proxy to strip the id — an upstream reached
|
|
733
|
+
* directly would receive an argument it never declared.
|
|
734
|
+
*/
|
|
735
|
+
private injectionDecision;
|
|
736
|
+
/** See {@link ControlServerOptions.correlation}. */
|
|
737
|
+
private correlationPolicy;
|
|
738
|
+
/**
|
|
739
|
+
* Whether a proxy that registered for `serverName` is still running. Only a
|
|
740
|
+
* live proxy strips a call id before the upstream sees it; a server the user
|
|
741
|
+
* unwrapped while this recorder ran stays in {@link wrapped} forever, and
|
|
742
|
+
* must never receive one. Cached for a moment — this is on every wrapped call.
|
|
743
|
+
*/
|
|
744
|
+
private hasLiveProxy;
|
|
745
|
+
/**
|
|
746
|
+
* Remember `pid` as a proxy serving `serverName` on this recorder — from its
|
|
747
|
+
* registration or its poll, the two requests that carry one. Only these make
|
|
748
|
+
* {@link hasLiveProxy} true: a proxy started under `bir off` is a pure pipe
|
|
749
|
+
* that sends neither, and must never be sent a call id it cannot strip.
|
|
750
|
+
*/
|
|
751
|
+
private noteProxyPid;
|
|
236
752
|
/** A controller decision as a `PreToolUse` answer, or undefined to carry on. */
|
|
237
753
|
private preToolAnswer;
|
|
754
|
+
/**
|
|
755
|
+
* What `PreToolUse` answers when a steering plan is still working as the
|
|
756
|
+
* route's deadline passes.
|
|
757
|
+
*
|
|
758
|
+
* The one case that matters is a divergence: the plan retires, then runs
|
|
759
|
+
* its remaining steps for real inside this hook (§8), and a slow tool or a
|
|
760
|
+
* slow source-run fetch can take that past the host's timeout. Answering
|
|
761
|
+
* `{}` would let the model's own call run and leave it believing nothing
|
|
762
|
+
* happened, while the scenario's steps — writes included — keep landing
|
|
763
|
+
* behind its back, and it would then do them all again. So the call is
|
|
764
|
+
* answered with the truth instead: which steps are done, that the rest may
|
|
765
|
+
* still be running, and that the task is the model's from here.
|
|
766
|
+
*
|
|
767
|
+
* Before the divergence starts (the plan still armed) nothing has run, and
|
|
768
|
+
* `{}` is the honest answer.
|
|
769
|
+
*/
|
|
770
|
+
private replayOverran;
|
|
238
771
|
/**
|
|
239
772
|
* Recordings this turn must not count a step hit against (segmented.md
|
|
240
773
|
* R-HIT-7).
|
|
@@ -271,8 +804,34 @@ export declare class ControlServer {
|
|
|
271
804
|
* builds an intent from the tool name and arguments, and a step the agent took
|
|
272
805
|
* without narrating it is exactly as much a recurrence as one it explained.
|
|
273
806
|
* Every failure is a miss.
|
|
807
|
+
*
|
|
808
|
+
* THE PROBE IS NOT WAITED ON unless it may arm ({@link intentArming}). Hit
|
|
809
|
+
* counting needs no answer at all, and waiting for one put a service round
|
|
810
|
+
* trip — 70-450 ms healthy, the whole 4 s budget on a hung service — in front
|
|
811
|
+
* of every Read, Grep, Edit and Bash of every signed-in user. When it may arm,
|
|
812
|
+
* the wait is {@link intentArmBudgetMs} at most, and none while the service is
|
|
813
|
+
* failing. Delivering a match on a *later* call instead is not an option: the
|
|
814
|
+
* matched call has run by then, and the plan's first step would repeat it.
|
|
274
815
|
*/
|
|
275
816
|
private tryIntentMatch;
|
|
817
|
+
/**
|
|
818
|
+
* Whether the model's call is plausibly `state`'s first step: its tool, and
|
|
819
|
+
* arguments the pin guard would pin to that step's input. The input is
|
|
820
|
+
* computed only for a step of the plan's own frame (a called segment's first
|
|
821
|
+
* step would build that segment's parameters here); anything that cannot be
|
|
822
|
+
* computed is "no" — the directive then rides on the call's PostToolUse.
|
|
823
|
+
*/
|
|
824
|
+
private isFirstStep;
|
|
825
|
+
/**
|
|
826
|
+
* Whether an intent probe may arm a plan in its own `PreToolUse` — and so
|
|
827
|
+
* whether that hook waits for it. See {@link ControlServerOptions.intentArm}.
|
|
828
|
+
*/
|
|
829
|
+
private intentArming;
|
|
830
|
+
/** How long an arming probe is waited on: `BIR_INTENT_ARM_BUDGET_MS`, never past the probe's own budget. */
|
|
831
|
+
private intentArmBudgetMs;
|
|
832
|
+
/** The bookkeeping half of a probe's answer: what `/health` and the log show. */
|
|
833
|
+
private noteProbe;
|
|
834
|
+
private noteProbeUnsafe;
|
|
276
835
|
/**
|
|
277
836
|
* Schedule a fragment once a matched turn's plan has handed over (fallbk.md
|
|
278
837
|
* D7). Only a turn that is not already recording needs one: an unmatched turn
|
|
@@ -285,8 +844,15 @@ export declare class ControlServer {
|
|
|
285
844
|
* The run id is swapped in place: steps allocated before belonged to a turn
|
|
286
845
|
* the service never created a run for, and nothing of theirs may land in the
|
|
287
846
|
* fragment — so pending correlations are latched and built-ins forgotten.
|
|
847
|
+
*
|
|
848
|
+
* Whether the service created the fragment decides only whether it is
|
|
849
|
+
* recorded, which the user never sees — so this call waits
|
|
850
|
+
* {@link FRAGMENT_CREATE_MS} for the answer at most, and not at all while the
|
|
851
|
+
* service is failing. A later answer is applied when it lands.
|
|
288
852
|
*/
|
|
289
853
|
private openFragment;
|
|
854
|
+
/** What the fragment's create call said: `created: false` is a hit, not a recording. */
|
|
855
|
+
private applyFragmentCreation;
|
|
290
856
|
/** Resolve with `p`, or null once `ms` passes or it rejects. */
|
|
291
857
|
private withinBudget;
|
|
292
858
|
/**
|
|
@@ -294,6 +860,8 @@ export declare class ControlServer {
|
|
|
294
860
|
* output reaches the model at once, as `additionalContext` (fallbk.md D4).
|
|
295
861
|
*/
|
|
296
862
|
private onToolPost;
|
|
863
|
+
/** Whether `run` opened a step for (or deliberately skipped) this `tool_use_id`. */
|
|
864
|
+
private knowsToolUse;
|
|
297
865
|
/**
|
|
298
866
|
* `PostToolUse` / `PostToolUseFailure`. For a built-in this closes the pair.
|
|
299
867
|
* For a correlated MCP call it normally does nothing — the proxy owns that
|
|
@@ -302,6 +870,11 @@ export declare class ControlServer {
|
|
|
302
870
|
* {@link PROXY_REPORT_GRACE_MS} rather than the step being lost entirely.
|
|
303
871
|
*/
|
|
304
872
|
private toolPost;
|
|
873
|
+
/**
|
|
874
|
+
* Tell the replay controller the host finished `toolUseId` (`PostToolUse`
|
|
875
|
+
* or `PostToolUseFailure`). Bookkeeping for its pin waits; never throws.
|
|
876
|
+
*/
|
|
877
|
+
private hostCompleted;
|
|
305
878
|
private errorText;
|
|
306
879
|
/**
|
|
307
880
|
* `SubagentStart` / `SubagentStop`. These carry `agent_id`, which is the only
|
|
@@ -315,6 +888,11 @@ export declare class ControlServer {
|
|
|
315
888
|
*/
|
|
316
889
|
private onAgent;
|
|
317
890
|
private onStop;
|
|
891
|
+
/**
|
|
892
|
+
* `SessionEnd` — `/clear`, exit, logout. Answered at once: the host is
|
|
893
|
+
* waiting on it to exit, and this process outlives the session, so the run
|
|
894
|
+
* is sealed here and reaches the service in the background.
|
|
895
|
+
*/
|
|
318
896
|
private onSessionEnd;
|
|
319
897
|
private onProxyRegister;
|
|
320
898
|
/**
|
|
@@ -326,6 +904,13 @@ export declare class ControlServer {
|
|
|
326
904
|
* would be invisible next to the upstream's own round trip (D12).
|
|
327
905
|
*/
|
|
328
906
|
private onProxyPoll;
|
|
907
|
+
/** Remember that `proxyId` registered with this recorder (see {@link registeredProxies}). */
|
|
908
|
+
private noteRegisteredProxy;
|
|
909
|
+
/**
|
|
910
|
+
* Park a poll for as long as it asked to be held (never past
|
|
911
|
+
* {@link POLL_HOLD_MS}), or until its caller goes away or this server closes.
|
|
912
|
+
*/
|
|
913
|
+
private holdPoll;
|
|
329
914
|
/** `POST /proxy/result` — the answer to one dispatched `tools/call`. */
|
|
330
915
|
private onProxyResult;
|
|
331
916
|
/**
|
|
@@ -335,8 +920,47 @@ export declare class ControlServer {
|
|
|
335
920
|
* already owns its upstream, so the model spends nothing beyond the turn that
|
|
336
921
|
* reads the results. There is no hook timeout here, which is exactly why the
|
|
337
922
|
* derivation wait was deferred to it (§10).
|
|
923
|
+
*
|
|
924
|
+
* WHOSE PLAN. bir-scenario cannot say which session called it — a stdio MCP
|
|
925
|
+
* server is never told — and taking "the first armed plan" ran one session's
|
|
926
|
+
* plan, with its parameters, for another session's call, and returned its
|
|
927
|
+
* results there. The `PreToolUse` for `run_scenario` always reaches this
|
|
928
|
+
* recorder first, with its session, so the run takes the plan of the session
|
|
929
|
+
* that just asked (see {@link takeDirectCall}). No such pre, no run: a call
|
|
930
|
+
* the host never let through is not one to act on.
|
|
931
|
+
*
|
|
932
|
+
* ONE RUN PER PLAN. A second call while the plan is executing would run
|
|
933
|
+
* every step again beside the first — duplicated writes — and is told so.
|
|
934
|
+
*
|
|
935
|
+
* `signal` aborts when `bir-scenario`'s request goes away — the host
|
|
936
|
+
* cancelled the call (Esc) or its own timeout fired — so no further step is
|
|
937
|
+
* started for a caller that can no longer read the result.
|
|
338
938
|
*/
|
|
339
939
|
private onScenarioRun;
|
|
940
|
+
/**
|
|
941
|
+
* A direct run whose caller went away (Esc, or bir-scenario's own timeout)
|
|
942
|
+
* stopped part-way, and the note that says which steps already ran — their
|
|
943
|
+
* side effects are in place — reached nobody. When the turn goes on, its
|
|
944
|
+
* next tool call carries it (the run's own path). When Esc ended the turn,
|
|
945
|
+
* the next prompt seals that run, so the note is kept on the session too,
|
|
946
|
+
* for its next `UserPromptSubmit`, `PostToolUse`, or `PreToolUse` of one of
|
|
947
|
+
* the plan's own tools (in that call's place): without it the model would
|
|
948
|
+
* run those steps a second time.
|
|
949
|
+
*/
|
|
950
|
+
private stashCancelledNote;
|
|
951
|
+
/** Mark a known session as heard from now (see {@link SessionState.lastSeenAt}). */
|
|
952
|
+
private touchSession;
|
|
953
|
+
/**
|
|
954
|
+
* The `run_scenario` pre this `/scenario/run` answers, removed from the
|
|
955
|
+
* waiting list — or undefined when there is none whose plan can still run.
|
|
956
|
+
*
|
|
957
|
+
* Exact when the caller names it (a `bir-scenario` that forwards the host's
|
|
958
|
+
* `tool_use_id` or session id). Otherwise only when every pre still waiting
|
|
959
|
+
* is one session's: the oldest of the fresh ones, and when none is fresh (a
|
|
960
|
+
* user who took a while to approve the call), the newest. Pres waiting in
|
|
961
|
+
* two sessions at once are never guessed between — see below.
|
|
962
|
+
*/
|
|
963
|
+
private takeDirectCall;
|
|
340
964
|
/**
|
|
341
965
|
* `POST /scenario/replay` — `bir replay`, i.e. a scenario nobody matched.
|
|
342
966
|
*
|
|
@@ -348,33 +972,85 @@ export declare class ControlServer {
|
|
|
348
972
|
*/
|
|
349
973
|
private onScenarioReplay;
|
|
350
974
|
/**
|
|
351
|
-
* A proxy's `ProxyStepReport`.
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
975
|
+
* A proxy's `ProxyStepReport`. The step is joined to the call a hook saw,
|
|
976
|
+
* in whichever session made it — never simply "the first session with an
|
|
977
|
+
* open run", which put one window's MCP steps into another's recording:
|
|
978
|
+
* 1. the `callId` a hook injected (exact), looked up in every session;
|
|
979
|
+
* 2. the fingerprint `(server, tool, hash(args))` across every session's
|
|
980
|
+
* current turn — lossy under identical concurrent calls, and documented
|
|
981
|
+
* as such;
|
|
982
|
+
* 3. nothing — record it standalone, in the session this proxy is known to
|
|
983
|
+
* serve, or else the most recently active one. That is what an SDK
|
|
984
|
+
* session with no hooks produces, and a complete MCP step in its own
|
|
985
|
+
* right.
|
|
357
986
|
*/
|
|
358
987
|
private onProxyStep;
|
|
988
|
+
/** Merge a report into the call a hook saw: thread it into a plan if pinned, record it once. */
|
|
989
|
+
private joinReport;
|
|
990
|
+
/**
|
|
991
|
+
* The session, run and call a report belongs to, or undefined when no hook
|
|
992
|
+
* saw it. An injected id is looked up everywhere first; then the
|
|
993
|
+
* fingerprint, across every session's current and just-sealed turn.
|
|
994
|
+
*/
|
|
995
|
+
private ownerOf;
|
|
996
|
+
/**
|
|
997
|
+
* The fingerprint a report joins by, and whether it is over the raw
|
|
998
|
+
* arguments. A proxy that sends `argsFingerprint` hashed them before
|
|
999
|
+
* redaction and clipping, exactly as the hook side hashes `tool_input`; an
|
|
1000
|
+
* older one did not, and its report's `args` are hashed as reported.
|
|
1001
|
+
*/
|
|
1002
|
+
private reportKey;
|
|
359
1003
|
/**
|
|
360
|
-
* Tie
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
* guess is exactly what cannot be trusted.
|
|
1004
|
+
* Tie a reporting proxy to the session whose call it just carried, so that
|
|
1005
|
+
* session's replay steps run on this proxy's upstream and not on another
|
|
1006
|
+
* session's (replay/executor.ts), and so its standalone reports are recorded
|
|
1007
|
+
* there. A join is the only evidence of which session a proxy serves.
|
|
365
1008
|
*/
|
|
366
|
-
private
|
|
1009
|
+
private bindProxy;
|
|
1010
|
+
/** Sessions, most recently heard from first. */
|
|
1011
|
+
private recentSessions;
|
|
367
1012
|
/**
|
|
368
|
-
* Which session a proxy
|
|
1013
|
+
* Which session a standalone proxy step belongs to.
|
|
369
1014
|
*
|
|
370
1015
|
* A proxy has no session id of its own — stdio MCP servers are never told one
|
|
371
|
-
* — so the control server decides
|
|
1016
|
+
* — so the control server decides: the session this proxy's earlier calls
|
|
1017
|
+
* were joined to, else the most recently active session with a turn in
|
|
1018
|
+
* progress, else the most recently heard session. Preferring *any known*
|
|
372
1019
|
* session over minting a new one is what stops a step that lands between
|
|
373
|
-
* `SessionStart` and the first prompt from opening a second, parallel run
|
|
374
|
-
* which would split one session's steps across two recordings.
|
|
1020
|
+
* `SessionStart` and the first prompt from opening a second, parallel run.
|
|
375
1021
|
*/
|
|
376
1022
|
private sessionForProxyStep;
|
|
1023
|
+
/**
|
|
1024
|
+
* The call a report joins by fingerprint, across every session.
|
|
1025
|
+
*
|
|
1026
|
+
* Among the calls whose PostToolUse has arrived — they certainly ran — the
|
|
1027
|
+
* oldest. Otherwise the newest call made before the proxy started this one:
|
|
1028
|
+
* a call the user refused never runs and is never reported, and must not
|
|
1029
|
+
* take the report of the identical call they approved after it; and a call
|
|
1030
|
+
* made after the start is some other call. Only calls made within
|
|
1031
|
+
* {@link CONSENT_WINDOW_MS} of the proxy starting the call qualify — the gap
|
|
1032
|
+
* is the user reading a permission prompt — and one made up to
|
|
1033
|
+
* {@link FINGERPRINT_SKEW_MS} after it only when nothing earlier does.
|
|
1034
|
+
*
|
|
1035
|
+
* A report for a call the hook's fallback already recorded (the report was
|
|
1036
|
+
* slower than {@link PROXY_REPORT_GRACE_MS}) finds that call too, and is
|
|
1037
|
+
* deduped rather than recorded a second time.
|
|
1038
|
+
*/
|
|
377
1039
|
private matchByFingerprint;
|
|
1040
|
+
/**
|
|
1041
|
+
* A report with no `argsFingerprint` — from a proxy older than this
|
|
1042
|
+
* recorder — whose arguments hash to no call: joined to the one call of the
|
|
1043
|
+
* same server and tool that no report has claimed yet, when there is exactly
|
|
1044
|
+
* one (UPG-8).
|
|
1045
|
+
*
|
|
1046
|
+
* Such a proxy redacted its copy of the arguments by its own, older rules,
|
|
1047
|
+
* so a value those rules kept and today's redact (a DSN's password, a
|
|
1048
|
+
* token in a URL, Basic auth) never hashes the same on the two sides.
|
|
1049
|
+
* Recorded standalone, the call would be in the run twice: once from this
|
|
1050
|
+
* report, once from its PostToolUse's fallback. Two candidates or more are
|
|
1051
|
+
* never guessed between.
|
|
1052
|
+
*/
|
|
1053
|
+
private soleCandidate;
|
|
378
1054
|
/** Record the merged pair for a correlation and latch it against re-entry. */
|
|
379
1055
|
private settle;
|
|
380
1056
|
/** The one place a tool step pair is written. Redaction and caps live here. */
|