@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.
Files changed (142) hide show
  1. package/README.md +540 -369
  2. package/dist/auth/client.d.ts +65 -3
  3. package/dist/auth/client.js +305 -53
  4. package/dist/bin/bir-hooks.d.ts +72 -31
  5. package/dist/bin/bir-hooks.js +176 -284
  6. package/dist/bin/bir-proxy.d.ts +17 -2
  7. package/dist/bin/bir-proxy.js +222 -28
  8. package/dist/bin/bir-scenario.d.ts +61 -21
  9. package/dist/bin/bir-scenario.js +593 -122
  10. package/dist/bin/bir.d.ts +12 -6
  11. package/dist/bin/bir.js +2935 -489
  12. package/dist/bin/investigate.js +40 -15
  13. package/dist/bin/scenario-edit.d.ts +7 -1
  14. package/dist/bin/scenario-edit.js +43 -21
  15. package/dist/bin/scenario-liveness.d.ts +194 -0
  16. package/dist/bin/scenario-liveness.js +459 -0
  17. package/dist/bin/scenario-stderr.d.ts +43 -0
  18. package/dist/bin/scenario-stderr.js +76 -0
  19. package/dist/bin/service-read.d.ts +88 -0
  20. package/dist/bin/service-read.js +128 -0
  21. package/dist/bin/setup.d.ts +19 -0
  22. package/dist/bin/setup.js +146 -20
  23. package/dist/config/adapters/claude-code.d.ts +92 -14
  24. package/dist/config/adapters/claude-code.js +291 -101
  25. package/dist/config/adapters/generic.d.ts +4 -1
  26. package/dist/config/adapters/generic.js +13 -14
  27. package/dist/config/generate.d.ts +392 -14
  28. package/dist/config/generate.js +666 -19
  29. package/dist/config/mcp-approval.d.ts +76 -0
  30. package/dist/config/mcp-approval.js +210 -0
  31. package/dist/config/node-path.d.ts +42 -0
  32. package/dist/config/node-path.js +102 -0
  33. package/dist/config/overrides.d.ts +62 -0
  34. package/dist/config/overrides.js +136 -0
  35. package/dist/config/resolve.d.ts +70 -8
  36. package/dist/config/resolve.js +186 -15
  37. package/dist/control/client.d.ts +22 -0
  38. package/dist/control/client.js +26 -7
  39. package/dist/control/correlation.d.ts +23 -5
  40. package/dist/control/correlation.js +0 -0
  41. package/dist/control/daemon.d.ts +400 -18
  42. package/dist/control/daemon.js +1105 -102
  43. package/dist/control/discovery.d.ts +77 -5
  44. package/dist/control/discovery.js +186 -11
  45. package/dist/control/ensure-hook.d.ts +61 -0
  46. package/dist/control/ensure-hook.js +160 -8
  47. package/dist/control/paths.d.ts +26 -0
  48. package/dist/control/paths.js +34 -0
  49. package/dist/control/pin-guard.d.ts +83 -0
  50. package/dist/control/pin-guard.js +534 -0
  51. package/dist/control/recorder-keeper.d.ts +229 -0
  52. package/dist/control/recorder-keeper.js +721 -0
  53. package/dist/control/recorder-main.d.ts +73 -0
  54. package/dist/control/recorder-main.js +778 -0
  55. package/dist/control/server.d.ts +707 -31
  56. package/dist/control/server.js +2786 -302
  57. package/dist/control/transcript.d.ts +21 -1
  58. package/dist/control/transcript.js +378 -112
  59. package/dist/index.d.ts +5 -1
  60. package/dist/index.js +5 -1
  61. package/dist/jsonrpc/framing.d.ts +76 -12
  62. package/dist/jsonrpc/framing.js +216 -59
  63. package/dist/proxy/intercept.d.ts +49 -5
  64. package/dist/proxy/intercept.js +188 -59
  65. package/dist/proxy/relay.d.ts +16 -0
  66. package/dist/proxy/relay.js +77 -5
  67. package/dist/proxy/revive.d.ts +122 -0
  68. package/dist/proxy/revive.js +251 -0
  69. package/dist/proxy/session.d.ts +222 -14
  70. package/dist/proxy/session.js +708 -99
  71. package/dist/record/recorder.d.ts +51 -3
  72. package/dist/record/recorder.js +3 -0
  73. package/dist/record/redact.d.ts +103 -9
  74. package/dist/record/redact.js +659 -30
  75. package/dist/record/remote-recorder.d.ts +327 -32
  76. package/dist/record/remote-recorder.js +1517 -187
  77. package/dist/record/switchable-recorder.d.ts +126 -0
  78. package/dist/record/switchable-recorder.js +351 -0
  79. package/dist/record/truncate.d.ts +40 -1
  80. package/dist/record/truncate.js +111 -4
  81. package/dist/replay/bash-channel.d.ts +35 -0
  82. package/dist/replay/bash-channel.js +133 -0
  83. package/dist/replay/bundle.d.ts +10 -0
  84. package/dist/replay/bundle.js +10 -0
  85. package/dist/replay/controller.d.ts +369 -20
  86. package/dist/replay/controller.js +1176 -107
  87. package/dist/replay/derive.d.ts +1 -0
  88. package/dist/replay/derive.js +81 -24
  89. package/dist/replay/executor.d.ts +51 -16
  90. package/dist/replay/executor.js +114 -41
  91. package/dist/replay/handover.d.ts +54 -1
  92. package/dist/replay/handover.js +55 -5
  93. package/dist/replay/logic.d.ts +62 -10
  94. package/dist/replay/logic.js +140 -22
  95. package/dist/replay/plan.d.ts +201 -27
  96. package/dist/replay/plan.js +581 -141
  97. package/dist/replay/safety.d.ts +72 -0
  98. package/dist/replay/safety.js +303 -0
  99. package/dist/replay/source-run.d.ts +10 -0
  100. package/dist/replay/source-run.js +80 -35
  101. package/dist/replay/targets.d.ts +15 -1
  102. package/dist/replay/targets.js +62 -4
  103. package/dist/replay/tool-error.d.ts +15 -1
  104. package/dist/replay/tool-error.js +33 -1
  105. package/dist/replay/types.d.ts +7 -4
  106. package/dist/upstream/client.d.ts +56 -3
  107. package/dist/upstream/client.js +61 -4
  108. package/dist/upstream/headers.d.ts +43 -0
  109. package/dist/upstream/headers.js +90 -0
  110. package/dist/upstream/http-client.d.ts +213 -22
  111. package/dist/upstream/http-client.js +1034 -168
  112. package/dist/upstream/http-transport.d.ts +143 -0
  113. package/dist/upstream/http-transport.js +391 -0
  114. package/dist/upstream/lazy-client.d.ts +8 -0
  115. package/dist/upstream/lazy-client.js +29 -1
  116. package/dist/upstream/sse.d.ts +78 -0
  117. package/dist/upstream/sse.js +189 -0
  118. package/dist/upstream/stdio-client.d.ts +61 -1
  119. package/dist/upstream/stdio-client.js +356 -33
  120. package/dist/util/atomic-write.d.ts +29 -0
  121. package/dist/util/atomic-write.js +96 -0
  122. package/dist/util/journal.d.ts +15 -0
  123. package/dist/util/journal.js +48 -3
  124. package/dist/util/log.d.ts +10 -0
  125. package/dist/util/log.js +28 -1
  126. package/dist/util/off-switch.d.ts +46 -0
  127. package/dist/util/off-switch.js +91 -0
  128. package/dist/util/service-health.d.ts +133 -0
  129. package/dist/util/service-health.js +217 -0
  130. package/docs/BaseInstRunner.md +694 -621
  131. package/docs/calculatedReplay.md +1625 -1304
  132. package/docs/calculatedReplayGuide.md +1212 -1087
  133. package/docs/installRun.md +1013 -772
  134. package/docs/loginWeb.md +627 -607
  135. package/docs/mcpmark.md +49 -16
  136. package/docs/my-first-sample.md +569 -545
  137. package/docs/quickstart.md +384 -311
  138. package/docs/t-bench.md +406 -394
  139. package/package.json +58 -57
  140. package/production.md +1371 -0
  141. package/scripts/install.ps1 +97 -6
  142. package/scripts/install.sh +104 -7
@@ -25,22 +25,231 @@
25
25
  */
26
26
  import { createServer } from "node:http";
27
27
  import { createHash, randomBytes, randomUUID } from "node:crypto";
28
- import { FINGERPRINT_WINDOW_MS, fingerprint, newCallId, parseQualifiedName, qualifyToolName, } from "./correlation.js";
28
+ import { statSync } from "node:fs";
29
+ import { join } from "node:path";
30
+ import { BIR_CALL_ID, fingerprint, newCallId, parseQualifiedName, qualifyToolName, } from "./correlation.js";
29
31
  import { StepIndexAllocator } from "./ordering.js";
32
+ import { pidAlive } from "./discovery.js";
33
+ import { pinCompatible } from "./pin-guard.js";
30
34
  import { contextForToolUse, intentForToolUse, markTranscriptUsage, recentToolResults, settledLastAssistantText, usageSince, } from "./transcript.js";
31
35
  import { isHousekeepingCall } from "../record/housekeeping.js";
32
36
  import { redact } from "../record/redact.js";
33
- import { serializeCapped } from "../record/truncate.js";
37
+ import { DEFAULT_MAX_STRING_BYTES, serializeCapped, truncateStrings } from "../record/truncate.js";
34
38
  import { StepQueue } from "../record/queue.js";
35
- import { NullRecorder, isIntentMatcher, isMatchAware, isRunCreationAware, isScenarioReporter, } from "../record/recorder.js";
39
+ import { NullRecorder, isIntentMatcher, isMatchAware, isRecorderHealthReporter, isRunCreationAware, isScenarioReporter, } from "../record/recorder.js";
36
40
  import { ReplayController, POLL_HOLD_MS, } from "../replay/controller.js";
37
41
  import { calculateCostUsd } from "../replay/pricing.js";
38
- import { logDetail, logLine, errText } from "../util/log.js";
42
+ import { logDetail, logLine, logOnce, errText } from "../util/log.js";
39
43
  import { journal } from "../util/journal.js";
40
44
  import { packageVersion } from "../util/version.js";
41
45
  import { resolveAuthUrl } from "../auth/client.js";
46
+ import { awaitWithin, serviceHealth } from "../util/service-health.js";
47
+ import { offSource } from "../util/off-switch.js";
48
+ import { claudeJsonPath, resolveServers } from "../config/resolve.js";
49
+ import { SCENARIO_SERVER_KEY, isScenarioServer, projectToken, readSidecar } from "../config/generate.js";
50
+ import { installedPath } from "./paths.js";
42
51
  /** How long a `/tool/post` waits for the proxy's own report before recording its own view. */
43
52
  const PROXY_REPORT_GRACE_MS = 1_500;
53
+ /**
54
+ * The hard ceiling on how long each hook route may hold the session.
55
+ *
56
+ * THE RUNNER MUST NEVER BE WHY A SESSION WAITS. Claude Code waits for an HTTP
57
+ * hook up to the timeout `bir install` wrote for it (UserPromptSubmit 10 s,
58
+ * PreToolUse 15 s, PostToolUse / Stop 5 s, SessionEnd 3 s, subagents 3 s), and
59
+ * a hook that times out is shown to the user as a hook error. Each route's own
60
+ * budgets keep the ordinary case to milliseconds; these are the backstop,
61
+ * well inside those timeouts: past them the route answers — `{}`, or an honest
62
+ * note when a replay was mid-flight — and whatever was still running finishes
63
+ * in the background. A route not listed here is not a hook route.
64
+ */
65
+ const HOOK_DEADLINE_MS = {
66
+ "/session/start": 2_000,
67
+ "/session/prompt": 7_000,
68
+ "/tool/pre": 12_000,
69
+ "/tool/post": 2_000,
70
+ "/session/agent": 2_000,
71
+ "/session/stop": 2_000,
72
+ "/session/end": 1_000,
73
+ };
74
+ /**
75
+ * What a matched steer plan's parameter settle leaves the directive's argument
76
+ * preview (replay/plan.ts PREVIEW_BUDGET_MS is the preview's own ceiling).
77
+ */
78
+ const PREVIEW_RESERVE_MS = 500;
79
+ /**
80
+ * What `UserPromptSubmit` may spend on replay: the similar-prompt match plus
81
+ * any derivation a matched scenario with a target needs before its directive
82
+ * can go out (segmented.md R-PARAM-4). Below the route's 7 s deadline, so the
83
+ * decision to steer is always taken here and never by the guard.
84
+ */
85
+ const PROMPT_WAIT_MS = 6_000;
86
+ /**
87
+ * How long a `PreToolUse` waits for the intent probe when that probe may arm a
88
+ * plan in the call's place (`BIR_INTENT_ARM_BUDGET_MS`). A clear match or a
89
+ * miss answers well inside it; a probe that also asks the service a live
90
+ * question does not, and is counted without arming (segmented.md R-HIT-11).
91
+ */
92
+ const INTENT_ARM_PROBE_MS = 500;
93
+ /** What an intent-armed plan may spend arming — derivation — inside `PreToolUse`. */
94
+ const INTENT_ARM_DERIVE_MS = 1_500;
95
+ /** How long a fragment's `PreToolUse` waits to learn whether its run was created. */
96
+ const FRAGMENT_CREATE_MS = 300;
97
+ /** What `close()` spends draining the last runs to the service before it lets go. */
98
+ const CLOSE_DRAIN_MS = 5_000;
99
+ /** How long a background drain follows one run before it stops waiting for it. */
100
+ const DRAIN_MAX_MS = 120_000;
101
+ /**
102
+ * How long a proxy's report may follow the `PreToolUse` it joins by
103
+ * fingerprint. Not the 30 s the id-less join used to allow: without
104
+ * auto-approval the call runs only once the user has answered its permission
105
+ * prompt, and a user who reads before approving is not an error.
106
+ */
107
+ const CONSENT_WINDOW_MS = 10 * 60_000;
108
+ /** Clock skew tolerated between a proxy's `startedAt` and the hook's `createdAt`. */
109
+ const FINGERPRINT_SKEW_MS = 5_000;
110
+ /**
111
+ * How long a `PreToolUse` for `run_scenario` binds the `/scenario/run` that
112
+ * follows it to its session. Long, because a user's own `ask` rule for the
113
+ * tool still prompts; a record is consumed by its run long before this.
114
+ */
115
+ const DIRECT_CALL_TTL_MS = 10 * 60_000;
116
+ /**
117
+ * How long a session started with `BIR_OFF=1` (K2) stays off with no hook
118
+ * heard. Not the ordinary session's `forgetMs`: Claude Code fires no
119
+ * SessionStart when someone comes back to a session left open overnight, so
120
+ * nothing would relay `bir_off` again, and the session would be recorded.
121
+ */
122
+ const OFF_SESSION_TTL_MS = 7 * 24 * 60 * 60_000;
123
+ /** At most this many off sessions are remembered, the most recently heard kept. An entry is an id and two times. */
124
+ const OFF_SESSIONS_MAX = 2_000;
125
+ /** Whether an off session's last word was its `SessionEnd`. */
126
+ function offSessionEnded(off) {
127
+ return off.endedAt !== undefined && off.at <= off.endedAt;
128
+ }
129
+ /** A `run_scenario` pre this recent is "the one being run right now" (see {@link ControlServer.takeDirectCall}). */
130
+ const DIRECT_CALL_FRESH_MS = 30_000;
131
+ /** How often the background recorder re-reads whether this project has a `bir` server. */
132
+ const DELIVERY_TOOL_REFRESH_MS = 5 * 60_000;
133
+ /**
134
+ * How long a `/scenario/hello` from a running `bir-scenario` counts as proof
135
+ * that the delivery tool is connected. It says hello on `initialize`, on
136
+ * `tools/list`, when its off state flips and every five minutes, so a live one
137
+ * is never near this; a process that died without a word is caught by its pid.
138
+ */
139
+ const DELIVERY_HELLO_TTL_MS = 30 * 60_000;
140
+ /** At most this many `bir-scenario` processes are remembered, newest kept. */
141
+ const DELIVERY_HELLO_MAX = 256;
142
+ /*
143
+ * `slowMs` is 800: a healthy service answers the create-and-match in about
144
+ * 300-450 ms (a cold TLS connection adds ~250 ms), so an answer past 800 ms is
145
+ * a degraded service, and waiting on it would add that much to every prompt.
146
+ * A false trip costs only replay for one hold, never latency.
147
+ */
148
+ const DEFAULT_MATCH_GATE = {
149
+ slowMs: 800,
150
+ holdMs: 5 * 60_000,
151
+ maxHoldMs: 30 * 60_000,
152
+ };
153
+ /** Proxy ids that registered with this recorder, remembered for {@link ControlServer.onProxyPoll}. */
154
+ const REGISTERED_PROXIES_MAX = 1_024;
155
+ /**
156
+ * The two events Claude Code posts to `/tool/post`. Both accept the same
157
+ * `hookSpecificOutput` (`additionalContext`) — under their own name: the host
158
+ * rejects an answer whose `hookEventName` is not the event that fired.
159
+ */
160
+ const POST_EVENTS = new Set(["PostToolUse", "PostToolUseFailure"]);
161
+ /** How long "this server's proxy is alive" is believed before it is checked again. */
162
+ const LIVE_PROXY_CACHE_MS = 2_000;
163
+ /**
164
+ * How often a request carrying a token this recorder did not issue may look at
165
+ * the install sidecar for the project's *current* token (see
166
+ * {@link ControlServer.currentProjectToken}). The file is re-read only when
167
+ * its mtime or size changed, and stat'd at most this often.
168
+ */
169
+ const SIDECAR_TOKEN_RECHECK_MS = 3_000;
170
+ /**
171
+ * What a run's drain lets `recorder.flush` wait, by what sealed the run. No
172
+ * hook awaits a drain (see {@link ControlServer.closeRun}); these bound how long
173
+ * the recorder is asked to hold on for it, inside the hook budgets all the
174
+ * same, so a drain started on a hook path can never become that hook's wait.
175
+ * `close()` — `bir down`, an upgrade restart — may give it longer.
176
+ */
177
+ const FLUSH_MS = {
178
+ /** `SessionEnd`: the host is exiting. */
179
+ sessionEnd: 800,
180
+ /** A new prompt sealing the turn before it (Esc), `Stop`, the reaper. */
181
+ hook: 1_000,
182
+ /** `close()`. */
183
+ close: 3_000,
184
+ };
185
+ const DEFAULT_SESSION_TIMINGS = {
186
+ // Long enough for one slow tool call — the host auto-backgrounds an MCP call
187
+ // after 2 minutes and Bash tops out at 10 — so a working turn is never
188
+ // mistaken for an abandoned one by an upgrade restart.
189
+ activeMs: 15 * 60_000,
190
+ idleSealMs: 2 * 60 * 60_000,
191
+ forgetMs: 6 * 60 * 60_000,
192
+ endedForgetMs: 10 * 60_000,
193
+ maxSessions: 500,
194
+ reapEveryMs: 60_000,
195
+ };
196
+ /** `/health` lists at most this many sessions, busiest first. */
197
+ const HEALTH_SESSIONS_MAX = 50;
198
+ /** What the model hears once a steer plan it had already left behind is retired. */
199
+ const HANDBACK_NOTE = "[BaseInstRunner calculated replay] The recorded sequence is no longer being supplied: carry on with the task yourself, choosing each tool call's arguments from what you have seen so far.";
200
+ /** What the model hears once a direct plan it never asked to run is retired. */
201
+ const DIRECT_SKIPPED_NOTE = "[BaseInstRunner calculated replay] The recorded scenario is not being run for this request: carry on with the task yourself.";
202
+ /** What a second `run_scenario` for a plan that is already running is told. */
203
+ const ALREADY_RUNNING = "this scenario is already running for this turn — its results come back on the first run_scenario call; do not call it again or repeat its steps";
204
+ /** Request bodies past this are read and discarded, never buffered. */
205
+ const MAX_BODY_BYTES = 64 * 1024 * 1024;
206
+ /** The one rejection {@link ControlServer.readJson} gives an over-size body. */
207
+ class BodyTooLarge extends Error {
208
+ bytes;
209
+ constructor(bytes) {
210
+ super(`request body too large (${bytes} bytes)`);
211
+ this.bytes = bytes;
212
+ }
213
+ }
214
+ /**
215
+ * Whether `route` is one Claude Code posts hook events to. Those routes answer
216
+ * `200 {}` to anything they cannot or must not process — a stale token, an
217
+ * unknown event, an over-size body — because any other answer is shown to the
218
+ * user as a hook error. Every other route keeps strict HTTP answers.
219
+ */
220
+ function isHookRoute(route) {
221
+ return route.startsWith("/session/") || route.startsWith("/tool/");
222
+ }
223
+ /** Plan mode: the user asked to see a plan first, so nothing is approved on their behalf. */
224
+ function isPlanMode(payload) {
225
+ return payload.permission_mode === "plan";
226
+ }
227
+ /**
228
+ * Tool arguments as an older proxy reports them — redacted, long strings
229
+ * clipped — for joining a report that carries no `argsFingerprint` of its own.
230
+ */
231
+ function reportedArgs(args) {
232
+ return truncateStrings(redact(args), DEFAULT_MAX_STRING_BYTES);
233
+ }
234
+ /**
235
+ * A score the service sent, for a log line: three decimals, or undefined when
236
+ * it is not a number. The answer is untyped JSON from another process — an
237
+ * older or newer service may leave a field out — and formatting it must never
238
+ * be what throws.
239
+ */
240
+ function score(value) {
241
+ return typeof value === "number" && Number.isFinite(value) ? value.toFixed(3) : undefined;
242
+ }
243
+ function positiveEnv(name, fallback) {
244
+ const n = Number(process.env[name]);
245
+ return Number.isFinite(n) && n > 0 ? n : fallback;
246
+ }
247
+ /** A relayed flag that says yes: `true`, `1`, `"1"` or `"true"` (any case). */
248
+ function isOn(value) {
249
+ if (value === true || value === 1)
250
+ return true;
251
+ return typeof value === "string" && ["1", "true"].includes(value.trim().toLowerCase());
252
+ }
44
253
  /** One MCP call, as seen from up to two sides. */
45
254
  /**
46
255
  * Field separator for the probe's dedupe hash (segmented.md R-HIT-5). A NUL,
@@ -60,11 +269,113 @@ export class ControlServer {
60
269
  wrapped = new Set();
61
270
  /** serverName → registration facts, for `bir doctor`. */
62
271
  registrations = new Map();
272
+ /**
273
+ * serverName → every proxy pid that registered for it. A call id is carried
274
+ * only into a server one of these still serves: the proxy is what strips it,
275
+ * and a server unwrapped while this recorder ran must never receive it.
276
+ */
277
+ proxyPids = new Map();
278
+ /** serverName → the last answer of {@link hasLiveProxy}, briefly. */
279
+ liveProxyCache = new Map();
280
+ /**
281
+ * proxyId → the session its calls were joined to. Where a report no hook
282
+ * minted an id for is recorded — never "whichever session is first".
283
+ */
284
+ proxySessions = new Map();
285
+ /** `run_scenario` pres not yet consumed by a `/scenario/run`, oldest first. */
286
+ directCalls = [];
287
+ /** Plans a `/scenario/run` is executing right now: one run per plan. */
288
+ runningPlans = new Set();
289
+ /**
290
+ * Steer plans whose last pin the guard declined (control/pin-guard.ts): the
291
+ * model made the step's call its own way, so the plan is a step behind the
292
+ * model. See {@link abandonAfterDeclinedPin}.
293
+ */
294
+ declinedPins = new WeakSet();
295
+ /** Whether this project's `bir` server exists, from its MCP config (see {@link ControlServerOptions.deliveryTool}). */
296
+ deliveryFromConfig;
297
+ deliveryConfigStamp;
298
+ deliveryTimer;
299
+ /**
300
+ * A hook named an `mcp__bir__*` tool: the delivery tool is configured,
301
+ * whatever the config file said. Configured only — connected is what
302
+ * {@link scenarioHellos} proves.
303
+ */
304
+ birToolSeen = false;
305
+ /**
306
+ * `bir-scenario` pid → its last `/scenario/hello` (K3). The only evidence a
307
+ * direct plan's delivery tool is connected right now, rather than merely
308
+ * listed in a config file (see {@link deliveryToolAvailable}).
309
+ */
310
+ scenarioHellos = new Map();
311
+ /**
312
+ * Sessions started with `BIR_OFF=1` (K2), as the SessionStart ensure hook
313
+ * relays it, → when last heard, and when its `SessionEnd` came. Every hook
314
+ * of such a session is answered `{}` unprocessed, and its proxies' reports
315
+ * are dropped: the switch is the session's, and the long-lived recorder
316
+ * never inherits it. Kept for {@link OFF_SESSION_TTL_MS} of silence (not the
317
+ * ordinary `forgetMs`), or until a little after its `SessionEnd` — a resume
318
+ * fires SessionStart, which relays the switch again. Memory only: a
319
+ * recorder that restarts no longer knows the session is off until its next
320
+ * SessionStart.
321
+ */
322
+ offSessions = new Map();
323
+ /**
324
+ * Proxy ids that registered with *this* recorder (UPG-7). A poll from any
325
+ * other id is a proxy from before this recorder started — a 0.2.12 proxy
326
+ * never registers again — and is held rather than answered at once.
327
+ */
328
+ registeredProxies = new Map();
329
+ /** Releases for polls held with replay off (see {@link onProxyPoll}), for `close()`. */
330
+ heldPolls = new Set();
331
+ /**
332
+ * Direct plans whose `/scenario/run` caller went away mid-run (Esc). The
333
+ * turn was cut short by the user, so what it cost says nothing about the
334
+ * scenario: its report is sent unmeasured.
335
+ */
336
+ cancelledPlans = new WeakSet();
337
+ /** See {@link MatchGateTimings}. */
338
+ matchGateTimings;
339
+ /**
340
+ * The prompt match gate's state: until when prompts do not wait on the
341
+ * match, the hold that was last applied (doubles while slow), and when it
342
+ * was applied (a slow match that started before then says nothing new).
343
+ */
344
+ matchGate = { holdUntil: 0, holdMs: 0, since: 0 };
345
+ timings;
346
+ reaper;
63
347
  server;
64
348
  address;
65
349
  lossy = false;
66
- /** Seals whose answer text is still being waited for. Awaited before any flush. */
67
- pendingSeals = [];
350
+ /**
351
+ * The project's current token as the install sidecar last said, and the
352
+ * file stamp it was read at (see {@link currentProjectToken}).
353
+ */
354
+ sidecarToken;
355
+ /**
356
+ * Runs being drained to the service in the background, after a hook sealed
357
+ * them. Only `close()` waits for these, and only for so long.
358
+ */
359
+ drains = new Set();
360
+ /** Whether the service may be waited on right now (service-health.ts). */
361
+ service;
362
+ /**
363
+ * Hook requests answered `{}` without being processed, and why — surfaced on
364
+ * `/health`, because each is invisible to the user by design and `bir
365
+ * doctor` is where it has to be explained.
366
+ */
367
+ hookStats = {
368
+ /** A token this recorder did not issue: a reinstall while it ran. */
369
+ unauthorized: 0,
370
+ /** An event this recorder does not know: newer hooks, older recorder. */
371
+ unknownRoute: 0,
372
+ /** A body past {@link MAX_BODY_BYTES}. */
373
+ oversize: 0,
374
+ /** Answered by the deadline guard rather than by the route. */
375
+ overran: 0,
376
+ /** Answered at once because the runner is switched off. */
377
+ off: 0,
378
+ };
68
379
  /** Calculated replay. Inert unless `opts.replay.enabled`. */
69
380
  replay;
70
381
  /**
@@ -82,12 +393,19 @@ export class ControlServer {
82
393
  constructor(opts) {
83
394
  this.opts = opts;
84
395
  this.recorder = opts.recorder;
396
+ this.service = opts.serviceHealth ?? serviceHealth;
85
397
  this.token = opts.token ?? randomBytes(32).toString("hex");
86
398
  for (const name of opts.wrappedServers ?? [])
87
399
  this.wrapped.add(name);
88
400
  this.replay = new ReplayController(opts.replay ?? { enabled: false, minSimilarity: 1 });
89
401
  this.deriveRecentResults = opts.deriveRecentResults ?? 5;
90
402
  this.intentSources = opts.intentSources ?? new Set(["text", "thinking", "tool"]);
403
+ this.timings = { ...DEFAULT_SESSION_TIMINGS, ...(opts.sessionTimings ?? {}) };
404
+ this.matchGateTimings = {
405
+ ...DEFAULT_MATCH_GATE,
406
+ slowMs: positiveEnv("BIR_MATCH_SLOW_MS", DEFAULT_MATCH_GATE.slowMs),
407
+ ...(opts.matchGate ?? {}),
408
+ };
91
409
  this.queue = new StepQueue({
92
410
  onDrop: (dropped) => {
93
411
  // §10: drop oldest, flag the run lossy. The flag is louder than a
@@ -125,6 +443,7 @@ export class ControlServer {
125
443
  token: this.token,
126
444
  sessionId: this.sessionId,
127
445
  };
446
+ this.startHousekeeping();
128
447
  resolve(this.address);
129
448
  });
130
449
  };
@@ -132,14 +451,33 @@ export class ControlServer {
132
451
  });
133
452
  }
134
453
  async close() {
454
+ if (this.reaper)
455
+ clearInterval(this.reaper);
456
+ if (this.deliveryTimer)
457
+ clearInterval(this.deliveryTimer);
458
+ this.reaper = undefined;
459
+ this.deliveryTimer = undefined;
135
460
  // Release parked pollers first: a `/proxy/poll` held for 25 s would otherwise
136
461
  // keep the HTTP server's `close` callback waiting for exactly that long.
137
462
  this.replay.close();
463
+ for (const release of [...this.heldPolls])
464
+ release();
465
+ this.heldPolls.clear();
138
466
  for (const session of this.sessions.values()) {
139
- if (session.run && !session.run.finished)
140
- await this.finalizeRun(session);
467
+ if (session.run && !session.run.finished) {
468
+ this.closeRun(session, { detach: false, flushMs: FLUSH_MS.close });
469
+ }
470
+ }
471
+ // Bounded: a recorder being stopped must stop, and a service that is down
472
+ // would otherwise hold it for as long as its sockets take to give up.
473
+ const drained = Promise.all([...this.drains]).then(() => this.queue.flush());
474
+ if ((await this.withinBudget(drained, CLOSE_DRAIN_MS)) === null && this.drains.size > 0) {
475
+ logLine("control.drain_abandoned", {
476
+ runs: this.drains.size,
477
+ ms: CLOSE_DRAIN_MS,
478
+ why: "the service did not take the last steps in time; they are lost with this process",
479
+ });
141
480
  }
142
- await this.queue.flush();
143
481
  const server = this.server;
144
482
  if (!server)
145
483
  return;
@@ -148,13 +486,25 @@ export class ControlServer {
148
486
  }
149
487
  /** Facts for `bir doctor` / `GET /health`. */
150
488
  health() {
151
- const sessions = [...this.sessions.values()].map((s) => ({
489
+ const now = Date.now();
490
+ // Busiest first, and bounded: `bir-hooks ensure` reads this on every
491
+ // SessionStart, and a recorder that has seen a thousand sessions must not
492
+ // answer with all of them. Every session still mid-turn is always listed —
493
+ // that is what `ensureDaemon` counts before it restarts anything.
494
+ const listed = [...this.sessions.values()]
495
+ .sort((a, b) => Number(this.isActive(b, now)) - Number(this.isActive(a, now)) || b.lastSeenAt - a.lastSeenAt)
496
+ .filter((s, i) => i < HEALTH_SESSIONS_MAX || this.isActive(s, now));
497
+ const sessions = listed.map((s) => ({
152
498
  sessionId: s.sessionId,
153
499
  runId: s.run?.runId,
154
500
  // Mid-run right now. What 'ensureDaemon' reads before it dares to restart
155
501
  // an out-of-date recorder: a restart between turns costs nothing, one
156
- // during a turn loses the turn.
157
- active: Boolean(s.run) && !s.run.finished,
502
+ // during a turn loses the turn. A run whose session has gone quiet for
503
+ // longer than any tool call takes is an interrupted turn nobody came
504
+ // back to, and must not hold an upgrade off forever.
505
+ active: this.isActive(s, now),
506
+ idleMs: now - s.lastSeenAt,
507
+ ended: s.endedAt !== undefined ? true : undefined,
158
508
  steps: s.run?.ordering.next ?? 0,
159
509
  recording: s.run?.recording ?? false,
160
510
  replay: s.run?.replay
@@ -165,7 +515,11 @@ export class ControlServer {
165
515
  outcome: s.run.replay.outcome,
166
516
  stepsPlanned: s.run.replay.stepsPlanned,
167
517
  stepsPinned: s.run.replay.stepsPinned,
168
- armed: Boolean(s.run.replay.plan) && !s.run.replay.retired,
518
+ // Steering now: armed, and announced to the model. A plan an intent
519
+ // match armed mid-call waits for its PostToolUse to be announced.
520
+ armed: this.livePlan(s.run) !== undefined,
521
+ unannounced: s.run.pendingDirective?.state === s.run.replay ? true : undefined,
522
+ running: this.runningPlans.has(s.run.replay) ? true : undefined,
169
523
  armedBy: s.run.replay.armedBy,
170
524
  handover: s.run.replay.handover
171
525
  ? { kind: s.run.replay.handover.kind, stepIndex: s.run.replay.handover.stepIndex }
@@ -186,6 +540,7 @@ export class ControlServer {
186
540
  : null,
187
541
  fragment: s.run?.fragment ? { state: s.run.fragment.state } : null,
188
542
  }));
543
+ const sending = this.recorderBacklog();
189
544
  return {
190
545
  ok: true,
191
546
  recorder: "baseinstrunner",
@@ -231,17 +586,176 @@ export class ControlServer {
231
586
  : "samples",
232
587
  pollingProxies: this.replay.work.pollingServers(),
233
588
  },
234
- lossy: this.lossy,
589
+ // Steps were lost: this queue overflowed, or the recorder dropped sends
590
+ // (its backlog bound, a signed-out session, a run the service never made).
591
+ lossy: this.lossy || (sending?.droppedSends ?? 0) > 0,
235
592
  queued: this.queue.size,
593
+ // The real backlog. This server's queue drains into the recorder at
594
+ // once; what has not reached the service yet waits there. Null when the
595
+ // recorder cannot say (no service, or an older recorder).
596
+ sending,
597
+ // Whether hot paths may wait on the service right now, and its last
598
+ // failure: the one place a user who noticed nothing can see that the
599
+ // runner stepped aside during an incident. Always present.
600
+ service: this.service.snapshot(),
601
+ // `bir off` in force for this project, and from where; null when on.
602
+ // Always present.
603
+ off: offSource(this.opts.cwd) ?? null,
604
+ // Hook requests answered `{}` unprocessed, by reason (see `hookStats`).
605
+ hooks: { ...this.hookStats },
606
+ // Which PreToolUse answers may carry a correlation id (see `correlation`),
607
+ // and whether a direct plan has a delivery tool to be announced through.
608
+ correlation: this.opts.noCorrelation ? "fingerprint" : this.correlationPolicy(),
609
+ deliveryTool: this.deliveryToolAvailable(),
610
+ // What that answer rests on: the bir-scenario processes that said hello
611
+ // and still run (K3). Empty means no direct plan is announced.
612
+ scenarioServers: this.liveHellos(now).slice(0, 10).map(([pid, h]) => ({
613
+ pid,
614
+ tools: h.tools,
615
+ off: h.off,
616
+ version: h.version ?? null,
617
+ ageMs: now - h.at,
618
+ })),
619
+ // Whether prompts are waiting on the match right now (the K5 gate).
620
+ matchGate: {
621
+ holding: now < this.matchGate.holdUntil,
622
+ holdMs: this.matchGate.holdMs || null,
623
+ until: now < this.matchGate.holdUntil ? this.matchGate.holdUntil : null,
624
+ },
625
+ // Sessions started with BIR_OFF=1, whose hooks are answered `{}` (K2).
626
+ offSessions: this.offSessions.size,
627
+ sessionCount: this.sessions.size,
236
628
  sessions,
237
629
  };
238
630
  }
631
+ /**
632
+ * What this recorder is doing right now, for the daemon deciding whether a
633
+ * superseded recorder may exit (K4). Cheap: no I/O beyond pid checks.
634
+ *
635
+ * `knownSessions` counts the sessions started with `BIR_OFF=1` that have not
636
+ * ended, too: only this recorder knows they are off, and a recorder that
637
+ * replaced it would record them.
638
+ */
639
+ activity() {
640
+ let openRuns = 0;
641
+ for (const session of this.sessions.values()) {
642
+ if (session.run && !session.run.finished)
643
+ openRuns += 1;
644
+ }
645
+ let offLive = 0;
646
+ for (const [sessionId, off] of this.offSessions) {
647
+ if (!offSessionEnded(off) && !this.sessions.has(sessionId))
648
+ offLive += 1;
649
+ }
650
+ const live = new Set();
651
+ for (const [serverName, pids] of this.proxyPids) {
652
+ for (const pid of [...pids]) {
653
+ if (live.has(pid))
654
+ continue;
655
+ if (pidAlive(pid))
656
+ live.add(pid);
657
+ else
658
+ pids.delete(pid);
659
+ }
660
+ if (pids.size === 0)
661
+ this.proxyPids.delete(serverName);
662
+ }
663
+ return { openRuns, knownSessions: this.sessions.size + offLive, liveProxyPids: live.size };
664
+ }
665
+ /**
666
+ * The recorder's own sending state (record/recorder.ts `RecorderHealth`),
667
+ * for `/health`, or null when it has none to give. A recorder that swaps
668
+ * backends may answer nothing for one that has no service; and `/health`
669
+ * must answer whatever this throws.
670
+ */
671
+ recorderBacklog() {
672
+ try {
673
+ if (!isRecorderHealthReporter(this.recorder))
674
+ return null;
675
+ const h = this.recorder.recorderHealth();
676
+ if (!h || typeof h !== "object")
677
+ return null;
678
+ const count = (v) => (typeof v === "number" && Number.isFinite(v) && v > 0 ? v : 0);
679
+ return {
680
+ state: typeof h.state === "string" ? h.state : "unknown",
681
+ pendingSends: count(h.pendingSends),
682
+ pendingBytes: count(h.pendingBytes),
683
+ droppedSends: count(h.droppedSends),
684
+ failedSends: count(h.failedSends),
685
+ };
686
+ }
687
+ catch (err) {
688
+ logDetail("control.recorder_health_failed", { error: errText(err) });
689
+ return null;
690
+ }
691
+ }
239
692
  // ── HTTP plumbing ────────────────────────────────────────────────────────
693
+ /**
694
+ * Whether `header` carries a token this recorder accepts: the one it was
695
+ * started with, or — after a reinstall on the same port rewrote the hooks
696
+ * with a new one — the project's *current* token from the install sidecar.
697
+ * Without the second, every hook of every session answered `{}` unprocessed
698
+ * and nothing was recorded until the recorder happened to restart.
699
+ */
700
+ authorized(header) {
701
+ if (header === `Bearer ${this.token}`)
702
+ return true;
703
+ if (typeof header !== "string" || !header.startsWith("Bearer "))
704
+ return false;
705
+ const current = this.currentProjectToken();
706
+ if (!current || current === this.token || header !== `Bearer ${current}`)
707
+ return false;
708
+ logOnce(`control.token_current:${current.slice(0, 8)}`, "control.token_current", {
709
+ why: "a reinstall issued this project a new token — accepted, as the sidecar names it now",
710
+ });
711
+ return true;
712
+ }
713
+ /**
714
+ * This project's token as the install sidecar names it now. The sidecar is
715
+ * stat'd at most once per {@link SIDECAR_TOKEN_RECHECK_MS} and parsed only
716
+ * when its mtime or size changed, so a stale token on every hook costs a
717
+ * cached comparison. Never throws: an unreadable sidecar is "no token".
718
+ */
719
+ currentProjectToken() {
720
+ const now = Date.now();
721
+ const cached = this.sidecarToken;
722
+ if (cached && now - cached.checkedAt < SIDECAR_TOKEN_RECHECK_MS)
723
+ return cached.token;
724
+ let stamp = "-";
725
+ try {
726
+ const st = statSync(installedPath());
727
+ stamp = `${st.mtimeMs}:${st.size}`;
728
+ }
729
+ catch {
730
+ /* no sidecar: no current token */
731
+ }
732
+ if (cached && cached.stamp === stamp) {
733
+ cached.checkedAt = now;
734
+ return cached.token;
735
+ }
736
+ let token;
737
+ if (stamp !== "-") {
738
+ try {
739
+ const t = projectToken(readSidecar(), this.opts.cwd);
740
+ token = typeof t === "string" && t.length > 0 ? t : undefined;
741
+ }
742
+ catch {
743
+ token = undefined;
744
+ }
745
+ }
746
+ this.sidecarToken = { token, stamp, checkedAt: now };
747
+ return token;
748
+ }
240
749
  async handle(req, res) {
241
750
  const route = (req.url ?? "").split("?")[0];
751
+ if (isHookRoute(route)) {
752
+ await this.handleHook(route, req, res);
753
+ return;
754
+ }
242
755
  try {
243
756
  const auth = req.headers["authorization"];
244
- if (auth !== `Bearer ${this.token}`) {
757
+ if (!this.authorized(auth)) {
758
+ req.resume();
245
759
  this.send(res, 401, { error: "unauthorized" });
246
760
  return;
247
761
  }
@@ -250,6 +764,7 @@ export class ControlServer {
250
764
  return;
251
765
  }
252
766
  if (req.method !== "POST") {
767
+ req.resume();
253
768
  this.send(res, 405, { error: "method_not_allowed" });
254
769
  return;
255
770
  }
@@ -261,27 +776,6 @@ export class ControlServer {
261
776
  if (this.opts.onStopRequested)
262
777
  setImmediate(() => this.opts.onStopRequested?.());
263
778
  return;
264
- case "/session/start":
265
- this.send(res, 200, this.onSessionStart(body));
266
- return;
267
- case "/session/prompt":
268
- this.send(res, 200, await this.onPrompt(body));
269
- return;
270
- case "/tool/pre":
271
- this.send(res, 200, await this.onToolPre(body));
272
- return;
273
- case "/tool/post":
274
- this.send(res, 200, this.onToolPost(body));
275
- return;
276
- case "/session/agent":
277
- this.send(res, 200, this.onAgent(body));
278
- return;
279
- case "/session/stop":
280
- this.send(res, 200, this.onStop(body));
281
- return;
282
- case "/session/end":
283
- this.send(res, 200, await this.onSessionEnd(body));
284
- return;
285
779
  case "/proxy/register":
286
780
  this.send(res, 200, this.onProxyRegister(body));
287
781
  return;
@@ -309,12 +803,32 @@ export class ControlServer {
309
803
  case "/proxy/result":
310
804
  this.send(res, 200, this.onProxyResult(body));
311
805
  return;
312
- case "/scenario/run":
313
- this.send(res, 200, await this.onScenarioRun());
806
+ case "/scenario/run": {
807
+ // Like a long poll, a run can outlive its caller: bir-scenario drops
808
+ // the request when the host cancels the call or its timeout fires.
809
+ // Tie that to an abort, so the plan stops starting new steps.
810
+ const gone = new AbortController();
811
+ const onClose = () => gone.abort();
812
+ res.once("close", onClose);
813
+ try {
814
+ const answer = await this.onScenarioRun(body, gone.signal);
815
+ if (gone.signal.aborted) {
816
+ logLine("replay.run_abandoned", { why: "the caller went away; no further step was started" });
817
+ return;
818
+ }
819
+ this.send(res, 200, answer);
820
+ }
821
+ finally {
822
+ res.off("close", onClose);
823
+ }
314
824
  return;
825
+ }
315
826
  case "/scenario/replay":
316
827
  this.send(res, 200, await this.onScenarioReplay(body));
317
828
  return;
829
+ case "/scenario/hello":
830
+ this.send(res, 200, this.onScenarioHello(body));
831
+ return;
318
832
  default:
319
833
  this.send(res, 404, { error: "not_found" });
320
834
  return;
@@ -327,7 +841,314 @@ export class ControlServer {
327
841
  this.send(res, 200, {});
328
842
  }
329
843
  }
844
+ /**
845
+ * One Claude Code hook event. Always `200`, always inside the route's
846
+ * deadline, and never an error the user would see.
847
+ *
848
+ * Everything that is not a hook this recorder can process is answered `{}`
849
+ * without reading the payload: a token it did not issue and that is not the
850
+ * project's current one either (a reinstall's new token is accepted — see
851
+ * {@link authorized}), an event it does not know
852
+ * (settings from a newer `bir install`), a method that is not POST, a body
853
+ * past the size cap, and every hook while the runner is switched off. Each
854
+ * would otherwise be a "hook error" on every prompt and tool call until the
855
+ * recorder restarts, which the user can neither silence nor explain. They
856
+ * are counted on `/health` instead, where `bir doctor` reads them.
857
+ */
858
+ async handleHook(route, req, res) {
859
+ let answered = false;
860
+ /** The event that fired, once the payload is read: every answer is checked against it. */
861
+ let firing;
862
+ const answer = (body) => {
863
+ if (answered)
864
+ return;
865
+ answered = true;
866
+ this.send(res, 200, this.forEvent(route, firing, body));
867
+ };
868
+ const deadlineMs = this.deadlineFor(route);
869
+ if (!this.authorized(req.headers["authorization"])) {
870
+ this.hookStats.unauthorized += 1;
871
+ logOnce("control.hook_unauthorized", "control.hook_unauthorized", {
872
+ route,
873
+ why: "a hook carried a token this recorder neither issued nor finds in the install sidecar — answered {} unprocessed",
874
+ fix: "restart the recorder (bir down, then bir up) so it serves the installed hooks",
875
+ });
876
+ req.resume();
877
+ answer({});
878
+ return;
879
+ }
880
+ if (deadlineMs === undefined || req.method !== "POST") {
881
+ this.hookStats.unknownRoute += 1;
882
+ logOnce(`control.hook_unknown:${route}`, "control.hook_unknown", {
883
+ route,
884
+ method: req.method,
885
+ why: "not a hook this recorder serves — answered {} unprocessed; the hooks may be newer than the recorder",
886
+ });
887
+ req.resume();
888
+ answer({});
889
+ return;
890
+ }
891
+ // Off means inert, and inert means before the transcript, the queue or the
892
+ // service is touched. The recorder stays up only so that this answer is an
893
+ // instant `{}` rather than a refused connection.
894
+ if (offSource(this.opts.cwd)) {
895
+ this.hookStats.off += 1;
896
+ if (route === "/session/start") {
897
+ // Answered at once all the same. Its body is still read, after the
898
+ // answer: a session started with BIR_OFF=1 (K2) must stay off when
899
+ // `bir on` turns the rest back on.
900
+ answer({});
901
+ void this.readJson(req)
902
+ .then((payload) => void this.sessionOff(route, payload))
903
+ .catch(() => undefined);
904
+ return;
905
+ }
906
+ req.resume();
907
+ answer({});
908
+ return;
909
+ }
910
+ // What to answer if the route overruns. `{}` unless the route says better.
911
+ let late;
912
+ const timer = setTimeout(() => {
913
+ if (answered)
914
+ return;
915
+ this.hookStats.overran += 1;
916
+ let body = {};
917
+ try {
918
+ body = late?.() ?? {};
919
+ }
920
+ catch (err) {
921
+ logLine("control.route_failed", { route, error: errText(err) });
922
+ }
923
+ logLine("control.hook_deadline", {
924
+ route,
925
+ ms: deadlineMs,
926
+ why: "answered before the route finished, so the session is not held; the rest finishes in the background",
927
+ });
928
+ answer(body);
929
+ }, deadlineMs);
930
+ timer.unref?.();
931
+ try {
932
+ let payload;
933
+ try {
934
+ payload = (await this.readJson(req));
935
+ }
936
+ catch (err) {
937
+ if (err instanceof BodyTooLarge) {
938
+ this.hookStats.oversize += 1;
939
+ logLine("control.hook_oversize", {
940
+ route,
941
+ bytes: err.bytes,
942
+ why: "read and discarded, answered {} — this event is not recorded",
943
+ });
944
+ answer({});
945
+ return;
946
+ }
947
+ throw err;
948
+ }
949
+ // A hook the guard already answered while its body was still arriving is
950
+ // not processed: whatever it would decide can no longer reach the host.
951
+ if (answered)
952
+ return;
953
+ firing = typeof payload.hook_event_name === "string" ? payload.hook_event_name : undefined;
954
+ // A session started with BIR_OFF=1 (K2): inert, like `bir off`, but for
955
+ // this session only.
956
+ if (this.sessionOff(route, payload)) {
957
+ this.hookStats.off += 1;
958
+ answer({});
959
+ return;
960
+ }
961
+ const out = await this.dispatchHook(route, payload, (fn) => {
962
+ late = fn;
963
+ });
964
+ if (answered) {
965
+ logDetail("control.hook_late", { route, why: "finished after the deadline answer" });
966
+ return;
967
+ }
968
+ answer(out);
969
+ }
970
+ catch (err) {
971
+ // Rule 4 of §1: never be the reason a host session fails. A 200 with an
972
+ // empty hook object lets Claude Code proceed exactly as if we were absent.
973
+ logLine("control.route_failed", { route, error: errText(err) });
974
+ answer({});
975
+ }
976
+ finally {
977
+ clearTimeout(timer);
978
+ }
979
+ }
980
+ /**
981
+ * `body` as the event that fired may carry it.
982
+ *
983
+ * CLAUDE CODE CHECKS THE NAME. An answer whose `hookSpecificOutput.hookEventName`
984
+ * is not the event that fired is rejected: the user is shown a hook error
985
+ * with this answer's JSON in it, and the context never reaches the model.
986
+ * `/tool/post` serves both `PostToolUse` and `PostToolUseFailure`, which take
987
+ * the same output, so that name is echoed. Any other mismatch would be a
988
+ * bug here, and the output is dropped rather than shown to the user as one.
989
+ * A payload that names no event (an older host, a test) is answered as is.
990
+ */
991
+ forEvent(route, firing, body) {
992
+ const out = body.hookSpecificOutput;
993
+ if (!firing || !out || typeof out !== "object")
994
+ return body;
995
+ const named = out.hookEventName;
996
+ if (named === firing)
997
+ return body;
998
+ if (POST_EVENTS.has(firing) && typeof named === "string" && POST_EVENTS.has(named)) {
999
+ return { ...body, hookSpecificOutput: { ...out, hookEventName: firing } };
1000
+ }
1001
+ logOnce(`control.hook_event_mismatch:${route}:${firing}`, "control.hook_event_mismatch", {
1002
+ route,
1003
+ fired: firing,
1004
+ answered: typeof named === "string" ? named : undefined,
1005
+ why: "an answer named another event than the one that fired — its output was dropped, since the host would show it as a hook error",
1006
+ });
1007
+ const rest = { ...body };
1008
+ delete rest.hookSpecificOutput;
1009
+ return rest;
1010
+ }
1011
+ /**
1012
+ * Whether this hook belongs to a session started with `BIR_OFF=1` (K2), and
1013
+ * so is answered `{}` unprocessed. The SessionStart ensure hook relays that
1014
+ * as `bir_off: true` — the only way the long-lived recorder can learn it,
1015
+ * since HTTP hooks carry no environment. A SessionStart for the same id
1016
+ * without it (the session resumed from a shell without BIR_OFF) turns the
1017
+ * session back on.
1018
+ */
1019
+ sessionOff(route, payload) {
1020
+ const sessionId = typeof payload.session_id === "string" ? payload.session_id : undefined;
1021
+ if (!sessionId)
1022
+ return false;
1023
+ if (route === "/session/start") {
1024
+ if (isOn(payload.bir_off)) {
1025
+ this.markSessionOff(sessionId);
1026
+ return true;
1027
+ }
1028
+ if (this.offSessions.delete(sessionId)) {
1029
+ logLine("session.on", { sess: sessionId, why: "resumed without BIR_OFF=1 — recorded again" });
1030
+ }
1031
+ return false;
1032
+ }
1033
+ const off = this.offSessions.get(sessionId);
1034
+ if (!off)
1035
+ return false;
1036
+ const now = Date.now();
1037
+ off.at = now;
1038
+ if (route === "/session/end")
1039
+ off.endedAt = now;
1040
+ return true;
1041
+ }
1042
+ /** Mark `sessionId` off (see {@link sessionOff}), sealing whatever it had open. */
1043
+ markSessionOff(sessionId) {
1044
+ if (!this.offSessions.has(sessionId)) {
1045
+ logLine("session.off", {
1046
+ sess: sessionId,
1047
+ why: "started with BIR_OFF=1 — its hooks are answered {} and nothing of it is recorded or replayed",
1048
+ });
1049
+ }
1050
+ this.offSessions.set(sessionId, { at: Date.now() });
1051
+ const session = this.sessions.get(sessionId);
1052
+ if (session?.run && !session.run.finished)
1053
+ this.closeRun(session, { detach: true });
1054
+ }
1055
+ /** Whether `sessionId` is a session started with `BIR_OFF=1`. */
1056
+ isSessionOff(sessionId) {
1057
+ return sessionId !== undefined && this.offSessions.has(sessionId);
1058
+ }
1059
+ /** The route's hook deadline, or undefined when `route` is not a hook this recorder serves. */
1060
+ deadlineFor(route) {
1061
+ const builtIn = HOOK_DEADLINE_MS[route];
1062
+ if (builtIn === undefined)
1063
+ return undefined;
1064
+ const override = this.opts.hookDeadlinesMs?.[route];
1065
+ return override !== undefined && override > 0 ? Math.min(override, builtIn) : builtIn;
1066
+ }
1067
+ /** What the prompt hook may spend on the match and arming: a second inside its deadline. */
1068
+ promptWaitMs() {
1069
+ return Math.max(0, Math.min(PROMPT_WAIT_MS, (this.deadlineFor("/session/prompt") ?? 0) - 1_000));
1070
+ }
1071
+ async dispatchHook(route, payload, onLate) {
1072
+ switch (route) {
1073
+ case "/session/start":
1074
+ return this.onSessionStart(payload);
1075
+ case "/session/prompt": {
1076
+ // What a cancelled direct run left this session to tell (see
1077
+ // `SessionState.stashedNote`). Claimed before the wait, so the
1078
+ // deadline's answer carries it too.
1079
+ const stashed = this.claimStashedNote(payload.session_id);
1080
+ // The guard answering first means no directive went out: a plan that
1081
+ // arms after that must never steer a model that was not told of it.
1082
+ onLate(() => {
1083
+ const run = this.sessions.get(payload.session_id ?? "unknown-session")?.run;
1084
+ if (run) {
1085
+ run.matchExpired = true;
1086
+ run.awaitingDirective = false;
1087
+ }
1088
+ return stashed ? this.withContext({}, "UserPromptSubmit", stashed) : {};
1089
+ });
1090
+ const out = await this.onPrompt(payload);
1091
+ return stashed ? this.withContext(out, "UserPromptSubmit", stashed) : out;
1092
+ }
1093
+ case "/tool/pre":
1094
+ return await this.onToolPre(payload, onLate);
1095
+ case "/tool/post":
1096
+ return this.onToolPost(payload);
1097
+ case "/session/agent":
1098
+ return this.onAgent(payload);
1099
+ case "/session/stop":
1100
+ return this.onStop(payload);
1101
+ case "/session/end":
1102
+ return this.onSessionEnd(payload);
1103
+ default:
1104
+ return {};
1105
+ }
1106
+ }
1107
+ /**
1108
+ * `body` with `text` put in front of whatever `additionalContext` it
1109
+ * already carries, under `event` — the event that fired.
1110
+ */
1111
+ withContext(body, event, text) {
1112
+ const out = body.hookSpecificOutput;
1113
+ const existing = typeof out?.additionalContext === "string" && out.additionalContext ? out.additionalContext : undefined;
1114
+ return {
1115
+ ...body,
1116
+ hookSpecificOutput: {
1117
+ ...(out ?? {}),
1118
+ hookEventName: event,
1119
+ additionalContext: existing ? `${text}\n\n${existing}` : text,
1120
+ },
1121
+ };
1122
+ }
1123
+ /**
1124
+ * The note a cancelled direct run left `sessionId` (see
1125
+ * `SessionState.stashedNote`), claimed for delivery — or undefined when
1126
+ * there is none, or the run's own hooks already delivered it.
1127
+ *
1128
+ * With `forTool`, only when that tool is one of the cancelled plan's own:
1129
+ * a call that could repeat a step that already ran (see {@link onToolPre}).
1130
+ */
1131
+ claimStashedNote(sessionId, forTool) {
1132
+ const session = this.sessions.get(sessionId ?? "unknown-session");
1133
+ const state = session?.stashedNote;
1134
+ if (!session || !state)
1135
+ return undefined;
1136
+ if (forTool !== undefined && !state.plan?.usesTool(forTool))
1137
+ return undefined;
1138
+ session.stashedNote = undefined;
1139
+ const note = this.replay.takeNote(state);
1140
+ if (note) {
1141
+ logLine("replay.note_delivered", {
1142
+ sess: session.sessionId,
1143
+ scenario: state.scenarioId ?? undefined,
1144
+ why: "the steps a cancelled run already did, told to the model before it can repeat them",
1145
+ });
1146
+ }
1147
+ return note;
1148
+ }
330
1149
  send(res, status, body) {
1150
+ if (res.headersSent || res.writableEnded)
1151
+ return;
331
1152
  const text = JSON.stringify(body ?? {});
332
1153
  res.writeHead(status, {
333
1154
  "content-type": "application/json",
@@ -335,27 +1156,46 @@ export class ControlServer {
335
1156
  });
336
1157
  res.end(text);
337
1158
  }
1159
+ /**
1160
+ * The request body as JSON. Past {@link MAX_BODY_BYTES} the rest is read and
1161
+ * thrown away, then {@link BodyTooLarge} is rejected — the socket is never
1162
+ * destroyed, because a reset is what a hook's caller would show as an error.
1163
+ */
338
1164
  readJson(req) {
339
1165
  return new Promise((resolve, reject) => {
340
1166
  const chunks = [];
341
1167
  let bytes = 0;
1168
+ let overflow = false;
342
1169
  req.on("data", (chunk) => {
343
1170
  bytes += chunk.length;
344
- if (bytes > 64 * 1024 * 1024) {
345
- reject(new Error("request body too large"));
346
- req.destroy();
1171
+ if (overflow)
1172
+ return;
1173
+ if (bytes > MAX_BODY_BYTES) {
1174
+ overflow = true;
1175
+ chunks.length = 0;
347
1176
  return;
348
1177
  }
349
1178
  chunks.push(chunk);
350
1179
  });
351
1180
  req.on("end", () => {
1181
+ if (overflow) {
1182
+ reject(new BodyTooLarge(bytes));
1183
+ return;
1184
+ }
352
1185
  const raw = Buffer.concat(chunks).toString("utf8");
353
1186
  if (!raw.trim()) {
354
1187
  resolve({});
355
1188
  return;
356
1189
  }
357
1190
  try {
358
- resolve(JSON.parse(raw));
1191
+ const value = JSON.parse(raw);
1192
+ // A payload is an object. `null`, a number or an array is as
1193
+ // malformed as a torn body, and is answered the same way.
1194
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1195
+ reject(new Error("request body is not a JSON object"));
1196
+ return;
1197
+ }
1198
+ resolve(value);
359
1199
  }
360
1200
  catch (err) {
361
1201
  reject(err instanceof Error ? err : new Error(String(err)));
@@ -369,19 +1209,138 @@ export class ControlServer {
369
1209
  const sessionId = payload.session_id ?? "unknown-session";
370
1210
  let session = this.sessions.get(sessionId);
371
1211
  if (!session) {
372
- session = { sessionId };
1212
+ session = { sessionId, lastSeenAt: Date.now(), prompts: 0 };
373
1213
  this.sessions.set(sessionId, session);
374
1214
  }
1215
+ session.lastSeenAt = Date.now();
375
1216
  if (payload.transcript_path)
376
1217
  session.transcriptPath = payload.transcript_path;
377
1218
  if (payload.cwd)
378
1219
  session.cwd = payload.cwd;
379
1220
  return session;
380
1221
  }
1222
+ /**
1223
+ * Whether `session` is mid-turn: an open run, heard from recently enough to
1224
+ * be working rather than abandoned (see {@link SessionTimings.activeMs}).
1225
+ */
1226
+ isActive(session, now = Date.now()) {
1227
+ const run = session.run;
1228
+ if (!run || run.finished)
1229
+ return false;
1230
+ return now - session.lastSeenAt <= this.timings.activeMs;
1231
+ }
1232
+ /** Start the reaper and, for the background recorder, the delivery-tool refresh. Both unref'd. */
1233
+ startHousekeeping() {
1234
+ if (!this.reaper) {
1235
+ this.reaper = setInterval(() => {
1236
+ try {
1237
+ this.reap();
1238
+ }
1239
+ catch (err) {
1240
+ logLine("control.reap_failed", { error: errText(err) });
1241
+ }
1242
+ }, Math.max(10, this.timings.reapEveryMs));
1243
+ this.reaper.unref?.();
1244
+ }
1245
+ if (this.opts.deliveryTool === undefined && process.env.BIR_DAEMON === "1" && !this.deliveryTimer) {
1246
+ // Read once now, then again only when the files changed — and never on
1247
+ // a hook's path: ~/.claude.json is often megabytes and the host rewrites
1248
+ // it constantly.
1249
+ this.refreshDeliveryTool();
1250
+ this.deliveryTimer = setInterval(() => this.refreshDeliveryTool(), DELIVERY_TOOL_REFRESH_MS);
1251
+ this.deliveryTimer.unref?.();
1252
+ }
1253
+ }
1254
+ /**
1255
+ * Forget what a long-lived recorder no longer needs, and stop an abandoned
1256
+ * turn from looking like a live one.
1257
+ *
1258
+ * A turn interrupted with Esc gets no `Stop`, and a window closed after it
1259
+ * often gets no `SessionEnd` either: its run stayed open for the recorder's
1260
+ * whole life — counted `active`, so an upgrade never restarted the
1261
+ * recorder; first in line for another session's proxy steps; and holding
1262
+ * its plan and outputs in memory. And every `/clear` and every `claude -p`
1263
+ * added a session that was never removed. So:
1264
+ *
1265
+ * - an open run silent for {@link SessionTimings.idleSealMs} is sealed —
1266
+ * idleness, never age: one long tool call sends no hooks;
1267
+ * - a session with no open run is forgotten once it has been silent for
1268
+ * {@link SessionTimings.forgetMs}, or {@link SessionTimings.endedForgetMs}
1269
+ * after its `SessionEnd`;
1270
+ * - past {@link SessionTimings.maxSessions}, the least recently heard
1271
+ * sessions without an open run go first.
1272
+ */
1273
+ reap(now = Date.now()) {
1274
+ const t = this.timings;
1275
+ for (const session of [...this.sessions.values()]) {
1276
+ const run = session.run;
1277
+ if (run && !run.finished && now - session.lastSeenAt >= t.idleSealMs) {
1278
+ logLine("run.idle_sealed", {
1279
+ run: run.runId,
1280
+ sess: session.sessionId,
1281
+ idleMin: Math.round((now - session.lastSeenAt) / 60_000),
1282
+ why: "no hook for this session in a long time — the turn was interrupted and never resumed",
1283
+ });
1284
+ this.closeRun(session, { detach: false });
1285
+ }
1286
+ if (session.run && !session.run.finished)
1287
+ continue;
1288
+ const ended = session.endedAt !== undefined &&
1289
+ session.lastSeenAt <= session.endedAt &&
1290
+ now - session.endedAt >= t.endedForgetMs;
1291
+ if (ended || now - session.lastSeenAt >= t.forgetMs)
1292
+ this.forgetSession(session);
1293
+ }
1294
+ if (this.sessions.size > t.maxSessions) {
1295
+ const idle = [...this.sessions.values()]
1296
+ .filter((s) => !s.run || s.run.finished)
1297
+ .sort((a, b) => a.lastSeenAt - b.lastSeenAt);
1298
+ for (const session of idle) {
1299
+ if (this.sessions.size <= t.maxSessions)
1300
+ break;
1301
+ this.forgetSession(session);
1302
+ }
1303
+ }
1304
+ this.directCalls = this.directCalls.filter((c) => now - c.at < DIRECT_CALL_TTL_MS);
1305
+ // A BIR_OFF=1 session is NOT forgotten on `forgetMs`: coming back to one
1306
+ // left idle overnight fires no SessionStart, so nothing would say it is
1307
+ // off again. It goes a while after its SessionEnd (a resume relays the
1308
+ // switch again), after days of silence, or past the count cap.
1309
+ for (const [sessionId, off] of this.offSessions) {
1310
+ const ended = offSessionEnded(off) && now - off.endedAt >= t.endedForgetMs;
1311
+ if (ended || now - off.at >= OFF_SESSION_TTL_MS)
1312
+ this.offSessions.delete(sessionId);
1313
+ }
1314
+ if (this.offSessions.size > OFF_SESSIONS_MAX) {
1315
+ const oldest = [...this.offSessions].sort((a, b) => a[1].at - b[1].at);
1316
+ for (const [sessionId] of oldest.slice(0, this.offSessions.size - OFF_SESSIONS_MAX)) {
1317
+ this.offSessions.delete(sessionId);
1318
+ }
1319
+ }
1320
+ for (const [pid, hello] of this.scenarioHellos) {
1321
+ if (now - hello.at > DELIVERY_HELLO_TTL_MS || !pidAlive(pid))
1322
+ this.scenarioHellos.delete(pid);
1323
+ }
1324
+ }
1325
+ /** Drop a session and everything keyed on it. Its last run was sealed and drained already. */
1326
+ forgetSession(session) {
1327
+ this.sessions.delete(session.sessionId);
1328
+ this.replay.work.releaseSession(session.sessionId);
1329
+ this.directCalls = this.directCalls.filter((c) => c.sessionId !== session.sessionId);
1330
+ for (const [proxyId, sessionId] of this.proxySessions) {
1331
+ if (sessionId === session.sessionId)
1332
+ this.proxySessions.delete(proxyId);
1333
+ }
1334
+ logDetail("session.forgotten", { sess: session.sessionId });
1335
+ }
381
1336
  /** Open a run for `session`, or return the open one. */
382
1337
  ensureRun(session, input = "") {
383
1338
  if (session.run && !session.run.finished)
384
1339
  return session.run;
1340
+ // The sealed run stays reachable for a moment: a report or a post that
1341
+ // arrives just after its turn ended is that turn's, not this one's.
1342
+ if (session.run)
1343
+ session.previous = session.run;
385
1344
  const runId = this.recorder.startRun(input, this.runMetadata(session));
386
1345
  const run = {
387
1346
  runId,
@@ -427,7 +1386,10 @@ export class ControlServer {
427
1386
  }
428
1387
  /**
429
1388
  * A similar-prompt hit means the service created no run, so every later step
430
- * post would 404. Stop recording it — that half is v1 and is mandatory.
1389
+ * post would 404. Stop recording it — that half is v1 and is mandatory, and
1390
+ * it is applied whenever the answer lands, however late: a hit that arrived
1391
+ * after the prompt's budget used to leave recording on, and every step of the
1392
+ * turn then failed against a run that did not exist.
431
1393
  *
432
1394
  * What replaces the recording is v2 (docs/calculatedReplay.md): the matched
433
1395
  * scenario is put through the gate ladder and, if every gate passes, armed as a
@@ -441,37 +1403,465 @@ export class ControlServer {
441
1403
  return run.matchWatch;
442
1404
  if (!isMatchAware(this.recorder))
443
1405
  return Promise.resolve(undefined);
444
- const pending = this.recorder.getMatch(run.runId);
445
- run.matchWatch = this.replay
446
- .awaitMatch(pending)
447
- .then(async (match) => {
448
- if (!match)
449
- return undefined;
1406
+ const runId = run.runId;
1407
+ const startedAt = Date.now();
1408
+ const pending = this.recorder.getMatch(runId).catch(() => null);
1409
+ void pending.then((match) => {
1410
+ if (!match || run.runId !== runId)
1411
+ return;
450
1412
  run.recording = false;
451
1413
  logLine("run.matched", {
452
- run: run.runId,
1414
+ run: runId,
453
1415
  matchedRun: match.runId,
454
1416
  scenario: match.scenarioId ?? undefined,
455
1417
  similarity: match.similarity,
456
1418
  why: "similar prompt — the service kept its own run; not recording this one",
457
1419
  });
458
- // Awaited: a scenario with a target derives before the directive goes
459
- // out, so a plan that cannot find its target is never delivered
460
- // (segmented.md R-PARAM-4). Inside `UserPromptSubmit`'s 15 s.
461
- const state = await this.replay.arm(match, prompt || run.input, this.wrapped, "prompt", {
462
- recentResults: recentToolResults(session.transcriptPath, this.deriveRecentResults),
1420
+ });
1421
+ run.matchWatch = this.armFromMatch(session, run, pending, prompt, startedAt).catch((err) => {
1422
+ // Replay is an optimisation; a broken one must never cost a turn.
1423
+ logLine("replay.arm_failed", { run: run.runId, error: errText(err) });
1424
+ return undefined;
1425
+ });
1426
+ return run.matchWatch;
1427
+ }
1428
+ /**
1429
+ * The v2 half of {@link watchForMatch}: wait for the match — briefly, and not
1430
+ * at all while the service is failing — and arm what it found.
1431
+ *
1432
+ * Both waits are bounded by {@link promptWaitMs} from the start of the
1433
+ * match. A match that misses its budget, or a plan still deriving when the
1434
+ * budget runs out, is never delivered: the turn runs as an ordinary one, and
1435
+ * one `replay.decision` line says why (docs/calculatedReplay.md §10).
1436
+ */
1437
+ async armFromMatch(session, run, pending, prompt, startedAt) {
1438
+ if (!this.replay.enabled)
1439
+ return this.noteDisabledMatch(session, run, pending, prompt);
1440
+ const promptWaitMs = this.promptWaitMs();
1441
+ const matchBudget = Math.min(this.replay.budgets.matchMs, promptWaitMs);
1442
+ if (Date.now() < this.matchGate.holdUntil) {
1443
+ // THE MATCH GATE (K5). A service that answers, but slowly, never opens
1444
+ // the breaker for long — each late answer closes it again — so without
1445
+ // this every prompt would wait the whole match for as long as the
1446
+ // incident lasts. The match is still asked (a hit still stops the
1447
+ // recording, v1); it is only not waited for, and a plan it would arm
1448
+ // now could never be announced.
1449
+ logDetail("replay.decision", {
1450
+ verdict: "no-steer",
1451
+ code: "match_slow",
1452
+ run: run.runId,
1453
+ why: "the service has been answering matches slowly — not waited on; this turn runs normally",
1454
+ });
1455
+ void pending.then((late) => {
1456
+ if (!late)
1457
+ return;
1458
+ logLine("replay.decision", {
1459
+ verdict: "no-steer",
1460
+ code: "match_late",
1461
+ run: run.runId,
1462
+ scenario: late.scenarioId ?? undefined,
1463
+ ms: Date.now() - startedAt,
1464
+ why: "the match landed while prompts were not waiting on it — not steered, and not recorded",
1465
+ });
463
1466
  });
464
- state.sessionId = session.sessionId;
465
- run.replay = state;
466
- run.replays.push(state);
467
- return this.replay.directiveFor(state);
1467
+ return undefined;
1468
+ }
1469
+ const cooling = !this.service.canWait();
1470
+ const match = await awaitWithin(pending, matchBudget, "match", this.service);
1471
+ // Measured only when it was waited on: a match not asked for says nothing
1472
+ // about how fast the service is. Nor does an answer that came quickly
1473
+ // because the service failed (a run-create whose first attempt failed
1474
+ // answers "no match" at once, and opens the cool-down): that is not the
1475
+ // service answering fast, and must not end a hold.
1476
+ if (!cooling && matchBudget > 0 && (match === undefined || this.service.canWait())) {
1477
+ this.noteMatchLatency(match === undefined ? undefined : Date.now() - startedAt, startedAt);
1478
+ }
1479
+ if (match === undefined) {
1480
+ if (this.replay.enabled) {
1481
+ logLine("replay.decision", {
1482
+ verdict: "no-steer",
1483
+ code: cooling ? "service_unhealthy" : "match_timeout",
1484
+ run: run.runId,
1485
+ ms: cooling ? undefined : matchBudget,
1486
+ why: cooling
1487
+ ? "the service is failing — not waited on; this turn runs normally"
1488
+ : "the match did not answer inside its budget; this turn runs normally",
1489
+ });
1490
+ void pending.then((late) => {
1491
+ if (!late)
1492
+ return;
1493
+ logLine("replay.decision", {
1494
+ verdict: "no-steer",
1495
+ code: "match_late",
1496
+ run: run.runId,
1497
+ scenario: late.scenarioId ?? undefined,
1498
+ ms: Date.now() - startedAt,
1499
+ why: "the match landed after the prompt was answered — not steered, and not recorded",
1500
+ });
1501
+ });
1502
+ }
1503
+ return undefined;
1504
+ }
1505
+ if (!match)
1506
+ return undefined;
1507
+ // Awaited: a scenario with a target derives before the directive goes
1508
+ // out, so a plan that cannot find its target is never delivered
1509
+ // (segmented.md R-PARAM-4). Inside the prompt's own budget.
1510
+ const arming = this.armOrLog(run, this.replay.arm(match, prompt || run.input, this.wrapped, "prompt", {
1511
+ recentResults: recentToolResults(session.transcriptPath, this.deriveRecentResults),
1512
+ }));
1513
+ const remaining = Math.max(0, promptWaitMs - (Date.now() - startedAt));
1514
+ const state = await this.withinBudget(arming, remaining);
1515
+ if (state === undefined)
1516
+ return undefined; // it threw, and said so
1517
+ // Armed live only while the prompt hook is still waiting to announce it.
1518
+ // `onPrompt` stops waiting at its own budget (and the deadline guard at
1519
+ // the route's), so a plan that lands after that — or for a run a proxy
1520
+ // step opened, with no prompt hook to announce anything — was never told
1521
+ // to the model and must never steer or diverge a call.
1522
+ if (state === null || run.matchExpired || run.finished || !run.awaitingDirective) {
1523
+ if (this.replay.enabled) {
1524
+ const ready = state !== null;
1525
+ logLine("replay.decision", {
1526
+ verdict: "no-steer",
1527
+ code: ready ? "arm_unannounced" : "arm_timeout",
1528
+ run: run.runId,
1529
+ scenario: match.scenarioId ?? undefined,
1530
+ ms: Date.now() - startedAt,
1531
+ why: ready
1532
+ ? "the plan armed after the prompt had been answered without it — retired unannounced, so never run"
1533
+ : "the plan was not ready before the prompt had to be answered — never delivered, so never run",
1534
+ });
1535
+ }
1536
+ if (state) {
1537
+ this.shelve(session, run, state);
1538
+ }
1539
+ else {
1540
+ void arming.then((late) => {
1541
+ if (late)
1542
+ this.shelve(session, run, late);
1543
+ });
1544
+ }
1545
+ return undefined;
1546
+ }
1547
+ state.sessionId = session.sessionId;
1548
+ this.declineWithoutDeliveryTool(state);
1549
+ // A steer plan with a shell or file-write step can only be followed if the
1550
+ // directive shows its computed arguments (the pin guard holds those steps
1551
+ // to their exact shape), so give its parameters a moment to settle —
1552
+ // inside what is left of this prompt's budget, keeping room for the
1553
+ // preview itself. It returns at once for any other plan, or while the
1554
+ // service is cooling down.
1555
+ const settleMs = promptWaitMs - (Date.now() - startedAt) - PREVIEW_RESERVE_MS;
1556
+ if (state.plan && settleMs > 0)
1557
+ await this.replay.settleForPreview(state, settleMs);
1558
+ // The argument preview runs logic; it spends what is left of the prompt's
1559
+ // own budget, never time on top of it (under 10 ms left, none is shown).
1560
+ const previewMs = Math.max(0, promptWaitMs - (Date.now() - startedAt));
1561
+ const directive = state.plan ? this.replay.directiveFor(state, { previewMs }) : undefined;
1562
+ if (state.plan && !directive) {
1563
+ // A plan with nothing to announce is a plan the model is never told of.
1564
+ logLine("replay.decision", {
1565
+ verdict: "no-steer",
1566
+ code: "no_directive",
1567
+ run: run.runId,
1568
+ scenario: state.scenarioId ?? undefined,
1569
+ why: "the plan produced no directive to announce — retired unannounced, so never run",
1570
+ });
1571
+ this.shelve(session, run, state);
1572
+ return undefined;
1573
+ }
1574
+ run.replay = state;
1575
+ run.replays.push(state);
1576
+ return directive;
1577
+ }
1578
+ /**
1579
+ * The match gate's verdict on one waited match: `ms` it took to answer, or
1580
+ * undefined when it did not answer inside its budget. Slow opens a hold —
1581
+ * {@link MatchGateTimings.holdMs}, doubled on each slow match that follows a
1582
+ * hold, up to {@link MatchGateTimings.maxHoldMs}; fast ends it. Each
1583
+ * transition is one line; the prompts in between say nothing at line level.
1584
+ *
1585
+ * A slow match that `startedAt` before the current hold began is ignored:
1586
+ * prompts already waiting when the service turned slow are one slowness,
1587
+ * not several, so the hold doubles at most once per hold (on its probe).
1588
+ */
1589
+ noteMatchLatency(ms, startedAt) {
1590
+ const t = this.matchGateTimings;
1591
+ const gate = this.matchGate;
1592
+ const slow = ms === undefined || ms > t.slowMs;
1593
+ if (slow) {
1594
+ if (gate.holdMs > 0 && startedAt < gate.since)
1595
+ return;
1596
+ const holdMs = gate.holdMs > 0 ? Math.min(t.maxHoldMs, gate.holdMs * 2) : Math.min(t.maxHoldMs, t.holdMs);
1597
+ const now = Date.now();
1598
+ gate.holdMs = holdMs;
1599
+ gate.holdUntil = now + holdMs;
1600
+ gate.since = now;
1601
+ logLine("replay.match_gate", {
1602
+ state: "holding",
1603
+ matchMs: ms ?? "timeout",
1604
+ slowMs: t.slowMs,
1605
+ holdMs,
1606
+ why: "the similar-prompt match answered slowly — prompts stop waiting on it for a while (it is still asked, in the background)",
1607
+ });
1608
+ return;
1609
+ }
1610
+ if (gate.holdMs === 0)
1611
+ return;
1612
+ gate.holdMs = 0;
1613
+ gate.holdUntil = 0;
1614
+ gate.since = 0;
1615
+ logLine("replay.match_gate", {
1616
+ state: "open",
1617
+ matchMs: ms,
1618
+ why: "the match answered fast again — prompts wait on it as before",
1619
+ });
1620
+ }
1621
+ /**
1622
+ * {@link armFromMatch} with replay off: nothing can be announced, and no hook
1623
+ * waits for the answer (`onPrompt` has already returned). So there is no
1624
+ * budget and no `awaitWithin` — a slow create-run here is not a wait anyone
1625
+ * had, and must not mark the service slow (that would open the process-wide
1626
+ * cool-down, and `/health` would report a degradation the user never had).
1627
+ * A hit still books its decline — the ledger's baseline sample — whenever
1628
+ * it lands, as long as the turn is still open to carry it.
1629
+ */
1630
+ async noteDisabledMatch(session, run, pending, prompt) {
1631
+ const match = await pending;
1632
+ if (!match || run.finished)
1633
+ return undefined;
1634
+ // Declined at its first gate, before derivation: no transcript is read for it.
1635
+ const state = await this.armOrLog(run, this.replay.arm(match, prompt || run.input, this.wrapped, "prompt"));
1636
+ if (!state || run.finished || run.replays.includes(state))
1637
+ return undefined;
1638
+ if (state.plan) {
1639
+ // Never, with replay off — but a plan nobody announced must not steer
1640
+ // whatever the controller decided.
1641
+ this.shelve(session, run, state);
1642
+ return undefined;
1643
+ }
1644
+ state.sessionId = session.sessionId;
1645
+ run.replay = state;
1646
+ run.replays.push(state);
1647
+ return undefined;
1648
+ }
1649
+ /**
1650
+ * A `direct` plan is announced as "call mcp__bir__run_scenario". In a project
1651
+ * with no `bir` server that is a tool the model does not have: it would say
1652
+ * so, or reach for its own tools — and that first call would diverge and run
1653
+ * the scenario unattended, in a project that never installed replay's
1654
+ * delivery tool. Such a plan is declined here, before it is announced, and
1655
+ * the agent does the task (a baseline sample, as any decline).
1656
+ *
1657
+ * Returns true when it declined.
1658
+ */
1659
+ declineWithoutDeliveryTool(state) {
1660
+ if (!state.plan || state.mode !== "direct" || this.deliveryToolAvailable())
1661
+ return false;
1662
+ const configured = this.deliveryConfigured();
1663
+ logLine("replay.decision", {
1664
+ verdict: "no-steer",
1665
+ code: "no_delivery_tool",
1666
+ scenario: state.scenarioId ?? undefined,
1667
+ why: configured
1668
+ ? "a direct plan is delivered through mcp__bir__run_scenario, and no running bir server has said it serves that tool — the agent does this task"
1669
+ : "a direct plan is delivered through mcp__bir__run_scenario, and this project has no bir server — the agent does this task",
1670
+ fix: configured
1671
+ ? "check the bir server in /mcp (connected, not disabled) and that the session was not started with BIR_OFF=1"
1672
+ : "bir install --replay adds the bir server to this project",
1673
+ });
1674
+ state.plan = undefined;
1675
+ state.mode = "none";
1676
+ state.stepsPlanned = 0;
1677
+ state.pinned.clear();
1678
+ state.declined = "coverage";
1679
+ return true;
1680
+ }
1681
+ /**
1682
+ * Whether the tool a direct plan is delivered through is connected here
1683
+ * right now. See {@link ControlServerOptions.deliveryTool}: the caller's
1684
+ * boolean when it gave one; otherwise the configuration must not say there
1685
+ * is none, and a `bir-scenario` must have proved it serves the tool.
1686
+ */
1687
+ deliveryToolAvailable() {
1688
+ const opt = this.opts.deliveryTool;
1689
+ if (typeof opt === "boolean")
1690
+ return opt;
1691
+ if (!this.deliveryConfigured())
1692
+ return false;
1693
+ return this.liveHellos(Date.now()).some(([, h]) => h.tools >= 1 && !h.off);
1694
+ }
1695
+ /**
1696
+ * Whether this project's configuration has the `bir` server, as far as
1697
+ * anything here knows: a hook that named one of its tools, the caller's
1698
+ * answer, the file the background recorder reads. Unknown means yes — the
1699
+ * live evidence decides.
1700
+ */
1701
+ deliveryConfigured() {
1702
+ if (this.birToolSeen)
1703
+ return true;
1704
+ const opt = this.opts.deliveryTool;
1705
+ if (typeof opt === "boolean")
1706
+ return opt;
1707
+ if (typeof opt === "function") {
1708
+ try {
1709
+ const answer = opt();
1710
+ if (typeof answer === "boolean")
1711
+ return answer;
1712
+ }
1713
+ catch (err) {
1714
+ logDetail("replay.delivery_tool_unknown", { error: errText(err) });
1715
+ }
1716
+ }
1717
+ return this.deliveryFromConfig ?? true;
1718
+ }
1719
+ /**
1720
+ * The `bir-scenario` processes whose hello is recent and whose pid still
1721
+ * runs, newest first. A dead or stale one is forgotten here. Pid checks
1722
+ * only — no other I/O.
1723
+ */
1724
+ liveHellos(now) {
1725
+ const out = [];
1726
+ for (const [pid, hello] of this.scenarioHellos) {
1727
+ if (now - hello.at > DELIVERY_HELLO_TTL_MS || !pidAlive(pid)) {
1728
+ this.scenarioHellos.delete(pid);
1729
+ continue;
1730
+ }
1731
+ out.push([pid, hello]);
1732
+ }
1733
+ return out.sort((a, b) => b[1].at - a[1].at);
1734
+ }
1735
+ /**
1736
+ * `POST /scenario/hello` — a running `bir-scenario` saying what it serves
1737
+ * (K3): on `initialize`, on `tools/list`, when its off state flips and every
1738
+ * few minutes. It is the only proof that a direct plan's delivery tool is
1739
+ * connected, so a direct plan is announced only while one that lists a tool
1740
+ * and is not off still runs. Always `{ ok: true }`; never slow.
1741
+ */
1742
+ onScenarioHello(body) {
1743
+ const pid = Number(body.pid);
1744
+ if (!Number.isInteger(pid) || pid <= 0)
1745
+ return { ok: true };
1746
+ const toolsRaw = Number(body.tools);
1747
+ const tools = Number.isFinite(toolsRaw) && toolsRaw > 0 ? Math.floor(toolsRaw) : 0;
1748
+ const off = body.off === true;
1749
+ const version = typeof body.version === "string" ? body.version.slice(0, 40) : undefined;
1750
+ const before = this.scenarioHellos.get(pid);
1751
+ // Re-inserted, so the map's order is oldest hello first.
1752
+ this.scenarioHellos.delete(pid);
1753
+ this.scenarioHellos.set(pid, { at: Date.now(), tools, off, version });
1754
+ while (this.scenarioHellos.size > DELIVERY_HELLO_MAX) {
1755
+ const oldest = this.scenarioHellos.keys().next().value;
1756
+ if (oldest === undefined)
1757
+ break;
1758
+ this.scenarioHellos.delete(oldest);
1759
+ }
1760
+ const serves = tools >= 1 && !off;
1761
+ if (!before || (before.tools >= 1 && !before.off) !== serves) {
1762
+ logLine("replay.delivery_tool", {
1763
+ pid,
1764
+ tools,
1765
+ off: off || undefined,
1766
+ version,
1767
+ why: serves
1768
+ ? "a bir server is running and serves run_scenario: direct plans may be announced"
1769
+ : "a bir server is running but serves no tool (off, or none listed): it cannot deliver a direct plan",
1770
+ });
1771
+ }
1772
+ return { ok: true };
1773
+ }
1774
+ /**
1775
+ * Re-read whether this project configures the `bir` server — only when
1776
+ * ~/.claude.json or .mcp.json changed since the last read. A file the host is
1777
+ * halfway through rewriting keeps the previous answer.
1778
+ */
1779
+ refreshDeliveryTool() {
1780
+ const files = [claudeJsonPath(), join(this.opts.cwd, ".mcp.json")];
1781
+ const stamp = files
1782
+ .map((f) => {
1783
+ try {
1784
+ const st = statSync(f);
1785
+ return `${st.mtimeMs}:${st.size}`;
1786
+ }
1787
+ catch {
1788
+ return "-";
1789
+ }
468
1790
  })
469
- .catch((err) => {
1791
+ .join("|");
1792
+ if (stamp === this.deliveryConfigStamp)
1793
+ return;
1794
+ try {
1795
+ const found = resolveServers(this.opts.cwd).some((s) => s.name === SCENARIO_SERVER_KEY && isScenarioServer(s.config));
1796
+ this.deliveryConfigStamp = stamp;
1797
+ if (found !== this.deliveryFromConfig) {
1798
+ logLine("replay.delivery_tool", {
1799
+ present: found,
1800
+ why: found
1801
+ ? "this project has the bir server: direct plans may be announced while it runs"
1802
+ : "this project has no bir server: a direct plan is declined rather than announced",
1803
+ });
1804
+ }
1805
+ this.deliveryFromConfig = found;
1806
+ }
1807
+ catch (err) {
1808
+ logDetail("replay.delivery_tool_unread", { error: errText(err) });
1809
+ }
1810
+ }
1811
+ /** `arming`, never rejecting: a broken arm is logged and is no plan at all. */
1812
+ armOrLog(run, arming) {
1813
+ return arming.catch((err) => {
470
1814
  // Replay is an optimisation; a broken one must never cost a turn.
471
1815
  logLine("replay.arm_failed", { run: run.runId, error: errText(err) });
472
1816
  return undefined;
473
1817
  });
474
- return run.matchWatch;
1818
+ }
1819
+ /**
1820
+ * The prompt hook answered without a directive: retire any prompt-armed plan
1821
+ * of `run` that could still steer. `armFromMatch` arms one live only while
1822
+ * that hook waits, so this finds nothing in the ordinary course; it is what
1823
+ * keeps a directive-less plan from ever running as a later divergence, even
1824
+ * if an arm and the hook's answer cross.
1825
+ */
1826
+ retireUnannounced(session, run) {
1827
+ const states = run.replay && !run.replays.includes(run.replay) ? [...run.replays, run.replay] : run.replays;
1828
+ for (const state of [...states]) {
1829
+ if (state.armedBy !== "prompt" || !state.plan || state.retired)
1830
+ continue;
1831
+ logLine("replay.decision", {
1832
+ verdict: "no-steer",
1833
+ code: "arm_unannounced",
1834
+ run: run.runId,
1835
+ scenario: state.scenarioId ?? undefined,
1836
+ why: "the prompt was answered without this plan's directive — retired unannounced, so never run",
1837
+ });
1838
+ this.shelve(session, run, state);
1839
+ }
1840
+ }
1841
+ /**
1842
+ * A prompt plan that armed after its hook had answered. The model was never
1843
+ * told of it, so it must never steer or diverge a call: it is kept only as
1844
+ * what it now is — a matched turn the agent does itself, which still owes the
1845
+ * ledger its baseline sample (§11.1) — exactly like a declined match.
1846
+ */
1847
+ shelve(session, run, late) {
1848
+ if (late.plan && !late.retired) {
1849
+ logDetail("replay.shelved", {
1850
+ run: run.runId,
1851
+ scenario: late.scenarioId ?? undefined,
1852
+ why: "armed without its directive reaching the model — retired, never steers",
1853
+ });
1854
+ }
1855
+ late.sessionId = session.sessionId;
1856
+ late.plan = undefined;
1857
+ late.mode = "none";
1858
+ late.stepsPlanned = 0;
1859
+ late.retired = true;
1860
+ late.pinned.clear();
1861
+ if (run.finished || run.replays.includes(late))
1862
+ return;
1863
+ run.replays.push(late);
1864
+ run.replay ??= late;
475
1865
  }
476
1866
  /**
477
1867
  * Close the run's books, without waiting for the network.
@@ -497,6 +1887,14 @@ export class ControlServer {
497
1887
  if (correlation.threadFallback)
498
1888
  clearTimeout(correlation.threadFallback);
499
1889
  }
1890
+ // A plan whose directive never reached the model did nothing and was never
1891
+ // a choice the model made: it books nothing, like any unannounced plan.
1892
+ const unannounced = run.pendingDirective?.state;
1893
+ run.pendingDirective = undefined;
1894
+ if (unannounced)
1895
+ this.shelveUnannounced(unannounced);
1896
+ // A `run_scenario` still waiting for this run's plan will never get it.
1897
+ this.directCalls = this.directCalls.filter((c) => c.run !== run);
500
1898
  const durationMs = Date.now() - run.startedAtMs;
501
1899
  // What this turn cost the model. Computed once, used twice: it is this run's
502
1900
  // `originalCostUsd` when recorded, and the `sessionCostUsd` of a matched
@@ -515,11 +1913,17 @@ export class ControlServer {
515
1913
  const transcriptPath = session.transcriptPath;
516
1914
  // Resolve the answer *outside* the queue: the wait below is up to two
517
1915
  // seconds, and the queue is serial — blocking it here would delay the
518
- // first step of the user's next turn by that much.
519
- this.pendingSeals.push(settledLastAssistantText(transcriptPath)
1916
+ // first step of the user's next turn by that much. Kept on the run, so a
1917
+ // drain of this run waits for this seal and for nobody else's.
1918
+ run.sealing = settledLastAssistantText(transcriptPath)
520
1919
  .catch(() => "")
521
1920
  .then((answer) => {
1921
+ run.finishQueued = true;
522
1922
  this.queue.push(() => {
1923
+ // A similar-prompt hit that landed during the wait: the service
1924
+ // kept its own run, and this one does not exist to finish.
1925
+ if (!run.recording)
1926
+ return;
523
1927
  if (answer) {
524
1928
  this.recorder.recordFinalAnswer(run.runId, run.ordering.allocate(), { answer });
525
1929
  }
@@ -533,7 +1937,7 @@ export class ControlServer {
533
1937
  costUsd: recordedCost.measured ? recordedCost.usd : undefined,
534
1938
  });
535
1939
  });
536
- }));
1940
+ });
537
1941
  }
538
1942
  logLine("run.finish", {
539
1943
  run: run.runId,
@@ -619,7 +2023,9 @@ export class ControlServer {
619
2023
  }
620
2024
  const report = this.replay.buildReport(state, {
621
2025
  sessionCostUsd: windowCost.usd,
622
- measured: windowCost.measured,
2026
+ // A direct run the user cancelled cut its turn short: its cost is no
2027
+ // baseline, and no saving is measured against it.
2028
+ measured: windowCost.measured && !this.cancelledPlans.has(state),
623
2029
  durationMs: Math.max(0, windowMs),
624
2030
  prompt: run.input || undefined,
625
2031
  siblings: states,
@@ -637,21 +2043,75 @@ export class ControlServer {
637
2043
  this.queue.push(() => recorder.reportExecution(report));
638
2044
  });
639
2045
  }
640
- /** Seal the run *and* wait for everything queued to reach the service. */
641
- async finalizeRun(session) {
642
- const runId = session.run?.runId;
2046
+ /**
2047
+ * Seal the session's run now, and drain it to the service in the background.
2048
+ *
2049
+ * NO HOOK EVER WAITS FOR THE NETWORK TO CLOSE A RUN. The prompt after an
2050
+ * interrupted turn (Claude Code sends no `Stop` on Esc) and `SessionEnd`
2051
+ * used to await the whole drain — the seal's answer wait, the queue, and
2052
+ * every send still in flight for the run — so an Esc followed by a new prompt
2053
+ * cost 0.5-2 s on a healthy service and the full hook timeout on a hung one,
2054
+ * and `/clear` or exit waited up to 30 s. The daemon outlives the hook, so
2055
+ * the drain simply finishes after it: sealing is synchronous and already
2056
+ * queues the final answer, the finish and the execution report.
2057
+ *
2058
+ * `detach` also forgets the run on the session — but only if it is still
2059
+ * the run that was sealed, never one another hook opened meanwhile.
2060
+ */
2061
+ closeRun(session, opts) {
2062
+ const run = session.run;
2063
+ if (!run)
2064
+ return;
643
2065
  this.sealRun(session);
644
- // Seals resolve their answer text off-queue, so they must be awaited before
645
- // the queue is flushed — otherwise the flush finishes before the finish call
646
- // has even been enqueued.
647
- await Promise.all(this.pendingSeals.splice(0, this.pendingSeals.length)).catch(() => undefined);
648
- await this.queue.flush();
649
- if (runId)
650
- await this.recorder.flush(runId).catch(() => undefined);
2066
+ if (opts.detach && session.run === run) {
2067
+ session.run = undefined;
2068
+ session.previous = run;
2069
+ }
2070
+ this.drainInBackground(run, opts.flushMs ?? FLUSH_MS.hook);
2071
+ }
2072
+ /**
2073
+ * Start {@link drainRun} for `run`, tracked so `close()` can wait for it.
2074
+ * Each entry leaves {@link drains} the moment its drain settles, so the set
2075
+ * holds the drains still running — never one per turn the recorder has seen.
2076
+ */
2077
+ drainInBackground(run, flushMs) {
2078
+ const drain = this.drainRun(run, flushMs);
2079
+ this.drains.add(drain);
2080
+ void drain.finally(() => this.drains.delete(drain));
2081
+ }
2082
+ /**
2083
+ * Everything queued for `run` reaches the service, or fails trying. Never
2084
+ * rejects. The seal resolves its answer text off-queue, so it is awaited
2085
+ * before the queue is flushed — otherwise the flush would finish before the
2086
+ * finish call had even been enqueued.
2087
+ */
2088
+ async drainRun(run, flushMs) {
2089
+ try {
2090
+ await run.sealing;
2091
+ await this.queue.flush();
2092
+ // Bounded, so an outage cannot pin every sealed turn in memory until the
2093
+ // service returns; the recorder keeps sending on its own either way.
2094
+ // `timeoutMs` bounds how long the recorder waits for the sends, never
2095
+ // whether they happen; DRAIN_MAX_MS is the backstop for a recorder that
2096
+ // does not honour it.
2097
+ // A fragment swapped the run id mid-turn; the turn's first id still holds
2098
+ // its match (a whole scenario payload) in the recorder, and is forgotten
2099
+ // with it.
2100
+ const ids = run.turnId !== run.runId ? [run.runId, run.turnId] : [run.runId];
2101
+ const flushed = await this.withinBudget(Promise.all(ids.map((id) => this.recorder.flush(id, { timeoutMs: flushMs }))).then(() => true), DRAIN_MAX_MS);
2102
+ if (flushed === null) {
2103
+ logDetail("run.drain_abandoned", { run: run.runId, ms: DRAIN_MAX_MS });
2104
+ }
2105
+ }
2106
+ catch (err) {
2107
+ logDetail("run.drain_failed", { run: run.runId, error: errText(err) });
2108
+ }
651
2109
  }
652
2110
  // ── hook events ──────────────────────────────────────────────────────────
653
2111
  onSessionStart(payload) {
654
2112
  const session = this.ensureSession(payload);
2113
+ // A resumed session is a live one again, whatever its SessionEnd said.
2114
+ session.endedAt = undefined;
655
2115
  // Run creation is deferred to the first prompt so the service's
656
2116
  // similar-prompt detection sees the real prompt, not "".
657
2117
  logLine("session.start", {
@@ -668,9 +2128,10 @@ export class ControlServer {
668
2128
  if (open?.promptSeen) {
669
2129
  // A second prompt in the same session: close the previous turn's run and
670
2130
  // open a fresh one. Claude Code carries no prompt id, so UserPromptSubmit
671
- // → Stop is the only honest run boundary.
672
- await this.finalizeRun(session);
673
- session.run = undefined;
2131
+ // → Stop is the only honest run boundary. The previous turn was
2132
+ // interrupted — Stop does not fire on Esc — so it is sealed here, and its
2133
+ // drain runs behind this prompt rather than in front of it.
2134
+ this.closeRun(session, { detach: true });
674
2135
  }
675
2136
  else if (open && open.ordering.next === 0) {
676
2137
  // A run was opened early by a proxy step (the host spawned MCP servers
@@ -678,14 +2139,30 @@ export class ControlServer {
678
2139
  // again with the real prompt: `input` is what the service embeds, and a
679
2140
  // run created with "" would never match anything. The empty run is below
680
2141
  // the service's recording threshold and is pruned there.
681
- await this.finalizeRun(session);
682
- session.run = undefined;
2142
+ this.closeRun(session, { detach: true });
2143
+ }
2144
+ else if (open && session.prompts > 0) {
2145
+ // A run a stray hook opened after this session's last turn had ended — a
2146
+ // background task's late PostToolUse, a proxy report after Stop. Its
2147
+ // steps are not this prompt's, and keeping them would record this whole
2148
+ // turn under an empty input that never matches anything, and never ask
2149
+ // the service about this prompt at all. Sealed as what it is; this
2150
+ // prompt gets a run, and a match, of its own.
2151
+ logLine("run.stray_sealed", {
2152
+ run: open.runId,
2153
+ steps: open.ordering.next,
2154
+ why: "steps arrived between turns; kept apart from the new prompt's run",
2155
+ });
2156
+ this.closeRun(session, { detach: true });
683
2157
  }
684
2158
  else if (open) {
685
2159
  // Steps are already recorded under an empty input. Keep them — losing real
686
2160
  // steps to gain a prompt is the wrong trade — and say so.
687
2161
  open.promptSeen = true;
688
2162
  open.input = input;
2163
+ // This hook announces nothing, so no plan of this run may steer.
2164
+ open.matchExpired = true;
2165
+ this.retireUnannounced(session, open);
689
2166
  logLine("run.prompt_late", {
690
2167
  run: open.runId,
691
2168
  steps: open.ordering.next,
@@ -696,14 +2173,44 @@ export class ControlServer {
696
2173
  const run = this.ensureRun(session, input);
697
2174
  run.promptSeen = true;
698
2175
  run.input = input;
2176
+ session.prompts += 1;
699
2177
  logDetail("session.prompt", { sess: session.sessionId, run: run.runId, chars: input.length });
700
- // The one moment a directive can be injected. Bounded by the match budget so
701
- // a slow or unreachable service costs the user nothing but an ordinary turn
702
- // (docs/calculatedReplay.md §10); `additionalContext` carries no `decision`,
703
- // so the prompt still reaches the model either way.
704
- const directive = await this.watchForMatch(session, run, input);
705
- if (!directive)
2178
+ // With replay off there is nothing this hook could say, so it says nothing
2179
+ // at once: the match still stops recording on a hit (v1), in the
2180
+ // background, and no prompt waits on the service for it.
2181
+ const watch = this.watchForMatch(session, run, input);
2182
+ if (!this.replay.enabled)
706
2183
  return {};
2184
+ // Plan mode is the user asking to see a plan before anything runs. A
2185
+ // replay would run tools, auto-approved: nothing is announced, and a plan
2186
+ // that arms anyway is shelved unannounced (see `armFromMatch`).
2187
+ if (isPlanMode(payload)) {
2188
+ run.matchExpired = true;
2189
+ return {};
2190
+ }
2191
+ // The one moment a directive can be injected. Bounded by the match and
2192
+ // arming budgets — and not waited on at all while the service is failing —
2193
+ // so a slow or unreachable service costs the user nothing but an ordinary
2194
+ // turn (docs/calculatedReplay.md §10); `additionalContext` carries no
2195
+ // `decision`, so the prompt still reaches the model either way.
2196
+ //
2197
+ // `awaitingDirective` is what lets a plan arm live (see `armFromMatch`):
2198
+ // set before the wait, cleared the moment it ends either way, so a plan
2199
+ // that lands a tick after this hook gave up is retired, never armed.
2200
+ run.awaitingDirective = true;
2201
+ let directive;
2202
+ try {
2203
+ directive = await this.withinBudget(watch, this.promptWaitMs() + 250);
2204
+ }
2205
+ finally {
2206
+ run.awaitingDirective = false;
2207
+ }
2208
+ if (!directive) {
2209
+ // Whatever arms after this answer was never announced: it must not steer.
2210
+ run.matchExpired = true;
2211
+ this.retireUnannounced(session, run);
2212
+ return {};
2213
+ }
707
2214
  return {
708
2215
  hookSpecificOutput: {
709
2216
  hookEventName: "UserPromptSubmit",
@@ -714,19 +2221,29 @@ export class ControlServer {
714
2221
  /**
715
2222
  * `PreToolUse`. Two shapes of answer:
716
2223
  *
717
- * - **Wrapped MCP tool** → mint a call id and ask Claude Code to carry it in
718
- * the tool's arguments, so the proxy's report and this hook's view join into
719
- * one step (Phase 6). No step is recorded yet: the proxy owns the result and
720
- * allocates the index when it reports.
2224
+ * - **Wrapped MCP tool** → remember the call so the proxy's report and this
2225
+ * hook's view join into one step (Phase 6). No step is recorded yet: the
2226
+ * proxy owns the result and allocates the index when it reports. The join
2227
+ * is by fingerprint, and the hook answers with **no decision**: the user's
2228
+ * permission prompt for the tool is exactly what it would be without the
2229
+ * runner. A call id rides in `updatedInput` only where answering `allow`
2230
+ * changes nothing, or the operator opted in (see `injectionDecision`).
721
2231
  * - **Anything else** (built-ins, unwrapped MCP) → allocate the pair now and
722
2232
  * record `tool_selected`. The hook is this step's only observer.
2233
+ *
2234
+ * `onLate` tells the route's deadline guard what to answer if this call is
2235
+ * still running when the deadline passes — see {@link replayOverran}.
723
2236
  */
724
- async onToolPre(payload) {
2237
+ async onToolPre(payload, onLate) {
725
2238
  const session = this.ensureSession(payload);
726
2239
  const run = this.ensureRun(session);
727
2240
  const toolName = payload.tool_name ?? "unknown";
728
2241
  const toolUseId = payload.tool_use_id ?? "";
729
2242
  const modelArgs = payload.tool_input ?? {};
2243
+ const planning = isPlanMode(payload);
2244
+ // A hook naming one of the `bir` server's tools proves the server exists.
2245
+ if (toolName.startsWith(`mcp__${SCENARIO_SERVER_KEY}__`))
2246
+ this.birToolSeen = true;
730
2247
  // The host's own bookkeeping is not a step of anything (see
731
2248
  // `record/housekeeping.ts`). Recorded, `ToolSearch` becomes step 0 of the
732
2249
  // calculated scenario: its generated reasoning is injected into every
@@ -742,14 +2259,61 @@ export class ControlServer {
742
2259
  logDetail("tool.pre.skipped", { run: run.runId, tool: toolName, why: "host housekeeping" });
743
2260
  return {};
744
2261
  }
745
- // `run_scenario` with no live plan does nothing but answer "no scenario is
746
- // armed". Recorded, that answer is a failed tool call, and a recording with
747
- // one is never picked for calculation — observed in production on every
748
- // first run in a project with the scenario server installed. With a live
749
- // plan it is still recorded below, tagged `pinnedBy`.
750
- if (this.replay.isDirectTool(toolName) && (!run.replay?.plan || run.replay.retired)) {
751
- logDetail("tool.pre.skipped", { run: run.runId, tool: toolName, why: "no scenario armed" });
752
- return {};
2262
+ // What a cancelled direct run left this session to tell (see
2263
+ // `SessionState.stashedNote`), when it was stashed after the next prompt
2264
+ // had already gone by: a call of one of that plan's own tools could
2265
+ // repeat a step that already ran, so the note reaches the model in its
2266
+ // place — as the same-turn path delivers a hand-over. Any other call runs,
2267
+ // and its PostToolUse carries the note. Never in plan mode.
2268
+ if (!planning) {
2269
+ const stashed = this.claimStashedNote(session.sessionId, toolName);
2270
+ if (stashed)
2271
+ return this.preToolAnswer(this.replay.deliverInstead(stashed, toolName)) ?? {};
2272
+ }
2273
+ const live = this.livePlan(run);
2274
+ /** `run_scenario` for this session's own announced direct plan: allowed, see below. */
2275
+ let allowDirect = false;
2276
+ if (this.replay.isDirectTool(toolName)) {
2277
+ // `run_scenario` with no live plan does nothing but answer "no scenario
2278
+ // is armed". Recorded, that answer is a failed tool call, and a recording
2279
+ // with one is never picked for calculation — observed in production on
2280
+ // every first run in a project with the scenario server installed. A plan
2281
+ // the model was never told of is no plan for it either. With a live plan
2282
+ // it is still recorded below, tagged `pinnedBy`.
2283
+ if (!live) {
2284
+ logDetail("tool.pre.skipped", { run: run.runId, tool: toolName, why: "no scenario armed" });
2285
+ return {};
2286
+ }
2287
+ // Which session is about to ask `/scenario/run` for its plan. bir-scenario
2288
+ // cannot say — a stdio MCP server is never told its session — so this is
2289
+ // how its request finds the plan of the session that called it rather
2290
+ // than whichever plan was armed first.
2291
+ this.directCalls.push({ sessionId: session.sessionId, toolUseId, at: Date.now(), run, state: live });
2292
+ // The runner announced this call and asked for exactly it: it must not
2293
+ // arrive as a permission prompt for a tool the user never asked for (and
2294
+ // be denied outright under `-p`). Only for a direct plan — the one kind
2295
+ // `run_scenario` exists to deliver — never in plan mode, and never for a
2296
+ // plan already running. The user's own deny or ask rules still apply.
2297
+ allowDirect = live.mode === "direct" && !planning && !this.runningPlans.has(live);
2298
+ }
2299
+ else if (live?.mode === "direct" && !this.runningPlans.has(live) && this.hasDirectCall(live)) {
2300
+ // The model called `run_scenario`, and the run never came: the call was
2301
+ // refused (the user said no, or a rule denied it) or it failed on the way.
2302
+ // Refused means refused. The plan retires without running anything — it
2303
+ // must not come back as a divergence that runs every step unattended.
2304
+ this.vetoPlan(run, live, "run_scenario was called but never reached the runner — refused or failed");
2305
+ }
2306
+ else if (live?.mode === "direct" && !live.directRequested && !live.running && !this.runningPlans.has(live)) {
2307
+ // A direct plan whose first call is not `run_scenario`, and which never
2308
+ // asked for it. The likeliest reason is that the model has no such tool
2309
+ // — the bir server failed to start, was disabled in /mcp, or lists
2310
+ // nothing — and a divergence would then execute every step, writes
2311
+ // included, inside this hook with no permission prompt, and deny the
2312
+ // model's call: a broken delivery tool turned into an unattended run.
2313
+ // The plan retires running nothing; this call runs as written, and the
2314
+ // model hears on its PostToolUse that the task is its own.
2315
+ this.vetoPlan(run, live, `the model's first call was ${toolName}, not run_scenario`);
2316
+ run.handback = DIRECT_SKIPPED_NOTE;
753
2317
  }
754
2318
  const agentId = payload.agent_id ?? session.agentId;
755
2319
  // The real intent, read from every line sharing this message's id
@@ -765,35 +2329,137 @@ export class ControlServer {
765
2329
  const thinkingIntent = intent?.source === "thinking" ? { text: intent.text, source: "thinking" } : undefined;
766
2330
  // ── replay steering (docs/calculatedReplay.md §7) ────────────────────────
767
2331
  // Runs before anything else, because two of its four answers end the call.
2332
+ // Not in plan mode (nothing is approved on the user's behalf there), not
2333
+ // while `/scenario/run` is executing this very plan (a second execution of
2334
+ // it would repeat its steps), and not before its directive has reached the
2335
+ // model (see `RunState.pendingDirective`).
768
2336
  let pinned;
769
- if (run.replay?.plan && !run.replay.retired) {
770
- const action = await this.replay.preTool(run.replay, toolName, toolUseId, modelArgs);
771
- // A hand-over inside `preTool` (an input logic that threw) must still
772
- // schedule the fragment, even though this call ends here.
773
- this.noteHandover(run);
774
- if (action.kind === "pin")
775
- pinned = action.input;
776
- const answer = this.preToolAnswer(action);
777
- if (answer)
778
- return answer;
2337
+ /** The deadline guard answered this call already; see below. */
2338
+ let overran = false;
2339
+ const declinedEarlier = this.livePlan(run);
2340
+ if (declinedEarlier && !planning && !this.runningPlans.has(declinedEarlier)) {
2341
+ this.abandonAfterDeclinedPin(run, declinedEarlier, toolName);
779
2342
  }
780
- if (!pinned && (!run.replay?.plan || run.replay.retired)) {
2343
+ const steering = this.livePlan(run);
2344
+ /** Whether the deadline guard already has this call's late answer (and sets `overran`). */
2345
+ let lateSet = false;
2346
+ if (steering && !planning && !this.runningPlans.has(steering)) {
2347
+ let lateNote = false;
2348
+ lateSet = true;
2349
+ // The controller's own ceiling on replay work in this hook
2350
+ // (BIR_PRETOOL_REPLAY_BUDGET_MS) is already held under this route's
2351
+ // deadline. What it cannot know is when the guard has answered first:
2352
+ // from then on the model has been told to carry on, so no further step
2353
+ // may start beside its own work.
2354
+ const answeredLate = new AbortController();
2355
+ onLate?.(() => {
2356
+ overran = true;
2357
+ answeredLate.abort();
2358
+ const body = this.replayOverran(steering, toolName);
2359
+ lateNote = Object.keys(body).length > 0;
2360
+ return body;
2361
+ });
2362
+ const action = await this.replay.preTool(steering, toolName, toolUseId, modelArgs, {
2363
+ signal: answeredLate.signal,
2364
+ });
2365
+ if (overran) {
2366
+ // The host already has its answer. A divergence was answered with its
2367
+ // note, and that call did not run as written: nothing to record.
2368
+ if (lateNote)
2369
+ return {};
2370
+ // Otherwise it was answered `{}` and ran as the model wrote it. A pin
2371
+ // decided now never reached it, so the plan would be threading an
2372
+ // output its step did not produce: it stops here, and the call is
2373
+ // recorded below as the model's own.
2374
+ if (!steering.retired) {
2375
+ steering.pinned.clear();
2376
+ steering.retired = true;
2377
+ logLine("replay.pin_overran", {
2378
+ scenario: steering.scenarioId ?? undefined,
2379
+ tool: toolName,
2380
+ why: "the plan was not ready before the call had to go ahead — it steers nothing more this turn",
2381
+ });
2382
+ }
2383
+ }
2384
+ else {
2385
+ // A hand-over inside `preTool` (an input logic that threw) must still
2386
+ // schedule the fragment, even though this call ends here.
2387
+ this.noteHandover(run);
2388
+ if (action.kind === "pin") {
2389
+ // The same tool is not the same call. A pin swaps the model's
2390
+ // arguments for the step's and approves them, so it is kept only for
2391
+ // a call that is plausibly the step (control/pin-guard.ts); anything
2392
+ // else runs exactly as the model wrote it, and the plan stays armed
2393
+ // for the call that is.
2394
+ const verdict = pinCompatible(toolName, modelArgs, action.input);
2395
+ if (verdict.ok) {
2396
+ pinned = action.input;
2397
+ this.declinedPins.delete(steering);
2398
+ }
2399
+ else {
2400
+ steering.pinned.delete(toolUseId);
2401
+ this.declinedPins.add(steering);
2402
+ logLine("replay.pin_declined", {
2403
+ scenario: steering.scenarioId ?? undefined,
2404
+ tool: toolName,
2405
+ step: action.stepIndex,
2406
+ why: `not the step — ${verdict.why}; it runs as the model wrote it, and the plan stays armed for the call that is`,
2407
+ });
2408
+ }
2409
+ }
2410
+ else {
2411
+ const answer = this.preToolAnswer(action);
2412
+ if (answer)
2413
+ return answer;
2414
+ }
2415
+ }
2416
+ }
2417
+ // Past the deadline nothing new may start: a probe answered now could arm
2418
+ // a plan nobody would ever hear of.
2419
+ if (!overran && !pinned && (!run.replay?.plan || run.replay.retired)) {
781
2420
  // ── falling back to the model (fallbk.md) ──────────────────────────────
782
2421
  // 1. A hand-over noticed where no hook answer could carry it — a proxy
783
2422
  // report threaded after its PostToolUse had already answered — reaches
784
2423
  // the model in place of this call. Never silence (D4).
785
- const note = this.replay.takeNote(run.replay);
2424
+ const note = planning ? undefined : this.replay.takeNote(run.replay);
786
2425
  if (note) {
787
2426
  this.noteHandover(run);
788
2427
  return this.preToolAnswer(this.replay.deliverInstead(note, toolName));
789
2428
  }
790
2429
  // 2. The model is driving: look for a scenario by this iteration's intent.
791
- const armed = await this.tryIntentMatch(session, run, toolName, intent, modelArgs);
792
- if (armed)
793
- return armed;
2430
+ // Never in plan mode, where nothing may run on the user's behalf.
2431
+ // If the deadline answers first, it answers `{}`: the call runs as
2432
+ // written, and a plan armed in its place was never announced.
2433
+ if (!planning && !lateSet) {
2434
+ onLate?.(() => {
2435
+ overran = true;
2436
+ return {};
2437
+ });
2438
+ }
2439
+ const armed = planning
2440
+ ? undefined
2441
+ : await this.tryIntentMatch(session, run, toolName, intent, modelArgs);
2442
+ if (armed) {
2443
+ if (!overran)
2444
+ return armed;
2445
+ const late = run.replay;
2446
+ if (late && late.armedBy === "intent" && late.plan && !late.retired) {
2447
+ logLine("replay.decision", {
2448
+ verdict: "no-steer",
2449
+ code: "arm_unannounced",
2450
+ run: run.runId,
2451
+ scenario: late.scenarioId ?? undefined,
2452
+ why: "the call had to go ahead before this plan's directive could replace it — retired unannounced, so never run",
2453
+ });
2454
+ this.shelveUnannounced(late);
2455
+ }
2456
+ // The call ran as the model wrote it: recorded below as its own.
2457
+ }
794
2458
  // 3. No scenario took over: the model's own work after a hand-over is
795
2459
  // recorded as a fragment, starting with this call.
796
- if (run.fragment?.state === "due") {
2460
+ // Not for the call an intent match just armed a plan beside: the plan
2461
+ // runs next, and the fragment opens on the model's own work after it.
2462
+ if (run.fragment?.state === "due" && !run.pendingDirective) {
797
2463
  await this.openFragment(session, run, reasoning, toolName, modelArgs);
798
2464
  }
799
2465
  }
@@ -803,6 +2469,7 @@ export class ControlServer {
803
2469
  const args = pinned ?? modelArgs;
804
2470
  const mcp = parseQualifiedName(toolName);
805
2471
  if (mcp && this.wrapped.has(mcp.serverName)) {
2472
+ const decision = this.injectionDecision(payload, pinned !== undefined, mcp.serverName, session);
806
2473
  const correlation = {
807
2474
  callId: newCallId(),
808
2475
  toolUseId,
@@ -813,7 +2480,12 @@ export class ControlServer {
813
2480
  context: context || undefined,
814
2481
  createdAt: Date.now(),
815
2482
  settled: false,
2483
+ // Raw, as the proxy hashes its report's `argsFingerprint`: hashing the
2484
+ // redacted, clipped copy instead missed every call whose arguments
2485
+ // redaction or clipping changed, and recorded it twice.
816
2486
  fingerprint: fingerprint(mcp.serverName, mcp.toolName, args),
2487
+ reportedFingerprint: fingerprint(mcp.serverName, mcp.toolName, reportedArgs(args)),
2488
+ injected: decision !== undefined,
817
2489
  // Captured here because this is the last moment the code knows: by the
818
2490
  // time `settle` records the step, `postTool` has consumed the pin
819
2491
  // (segmented.md R-INTENT-7).
@@ -825,14 +2497,14 @@ export class ControlServer {
825
2497
  run: run.runId,
826
2498
  tool: toolName,
827
2499
  callId: correlation.callId,
828
- mode: this.opts.noCorrelation ? "fingerprint" : "injected",
2500
+ mode: decision ? "injected" : "fingerprint",
829
2501
  pinned: pinned ? true : undefined,
830
2502
  });
831
- if (this.opts.noCorrelation) {
832
- // No id ride-along, so no schema relaxation is needed either. The join
833
- // happens on `(server, tool, hash(args))` — lossy under identical
834
- // concurrent calls, and documented as such. Pinning still works: it is
835
- // the `updatedInput` that steers, and only the *join* degrades here.
2503
+ if (!decision) {
2504
+ // No id ride-along: the join happens on `(server, tool, hash(args))` —
2505
+ // lossy under identical concurrent calls, and documented as such.
2506
+ // Pinning still works: it is the `updatedInput` that steers, and only
2507
+ // the *join* degrades here.
836
2508
  if (!pinned)
837
2509
  return { callId: correlation.callId, correlation: "fingerprint" };
838
2510
  return {
@@ -852,8 +2524,8 @@ export class ControlServer {
852
2524
  hookEventName: "PreToolUse",
853
2525
  // A pinned call must be allowed, or the host may drop `updatedInput`
854
2526
  // and the step would run with the model's own arguments.
855
- permissionDecision: pinned ? "allow" : (this.opts.correlationDecision ?? "allow"),
856
- updatedInput: { ...args, __bir_call_id__: correlation.callId },
2527
+ permissionDecision: decision,
2528
+ updatedInput: { ...args, [BIR_CALL_ID]: correlation.callId },
857
2529
  },
858
2530
  };
859
2531
  }
@@ -894,8 +2566,168 @@ export class ControlServer {
894
2566
  },
895
2567
  };
896
2568
  }
2569
+ if (allowDirect) {
2570
+ return {
2571
+ stepIndex: pair.selected,
2572
+ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow" },
2573
+ };
2574
+ }
897
2575
  return { stepIndex: pair.selected };
898
2576
  }
2577
+ /** This run's plan, when it is armed, still live, and announced to the model. */
2578
+ livePlan(run) {
2579
+ const state = run.replay;
2580
+ if (!state?.plan || state.retired)
2581
+ return undefined;
2582
+ if (run.pendingDirective?.state === state)
2583
+ return undefined;
2584
+ return state;
2585
+ }
2586
+ /** Whether a `run_scenario` pre for `state` is still waiting for its `/scenario/run`. */
2587
+ hasDirectCall(state) {
2588
+ const now = Date.now();
2589
+ return this.directCalls.some((c) => c.state === state && now - c.at < DIRECT_CALL_TTL_MS);
2590
+ }
2591
+ /**
2592
+ * Retire `state` without running anything: the model's `run_scenario` was
2593
+ * refused or failed. Books a baseline sample (`not_steered`) — the agent did
2594
+ * the task — never a divergence.
2595
+ */
2596
+ vetoPlan(run, state, why) {
2597
+ if (state.retired)
2598
+ return;
2599
+ state.retired = true;
2600
+ state.retiredAt = Date.now();
2601
+ state.pinned.clear();
2602
+ this.directCalls = this.directCalls.filter((c) => c.state !== state);
2603
+ logLine("replay.vetoed", {
2604
+ run: run.runId,
2605
+ scenario: state.scenarioId ?? undefined,
2606
+ why: `${why} — the plan is retired without running any step`,
2607
+ });
2608
+ }
2609
+ /**
2610
+ * A steer plan whose pin the guard declined, now facing a call to another
2611
+ * tool: retire it, running nothing, and let this call run as written.
2612
+ *
2613
+ * The declined call was the model doing the step its own way (told that the
2614
+ * system supplies the arguments, it often writes a guess), so the model has
2615
+ * moved on while the plan still waits at that step. Left armed, this call to
2616
+ * the *next* step's tool reads as a divergence: the model's call is denied,
2617
+ * the rest of the scenario runs unattended inside this hook, and the step it
2618
+ * already did comes back as a stale recorded output. Retiring here costs
2619
+ * only the rest of this replay; the model is told on the next PostToolUse
2620
+ * that the arguments are its own from now on. A call to the step's own tool
2621
+ * keeps the plan armed — that may be the step at last.
2622
+ */
2623
+ abandonAfterDeclinedPin(run, state, toolName) {
2624
+ if (!this.declinedPins.has(state) || state.mode !== "steer" || state.retired)
2625
+ return;
2626
+ if (this.replay.isDirectTool(toolName) || state.plan?.expectedTool() === toolName)
2627
+ return;
2628
+ this.declinedPins.delete(state);
2629
+ state.retired = true;
2630
+ state.retiredAt = Date.now();
2631
+ state.pinned.clear();
2632
+ run.handback = HANDBACK_NOTE;
2633
+ logLine("replay.pin_abandoned", {
2634
+ run: run.runId,
2635
+ scenario: state.scenarioId ?? undefined,
2636
+ expected: state.plan?.expectedTool() ?? undefined,
2637
+ tool: toolName,
2638
+ why: "the model did the expected step its own way and moved on — the plan is retired without running anything, and this call runs as written",
2639
+ });
2640
+ }
2641
+ /** An intent plan whose directive never reached the model: it did nothing, and reports nothing. */
2642
+ shelveUnannounced(state) {
2643
+ state.plan = undefined;
2644
+ state.mode = "none";
2645
+ state.stepsPlanned = 0;
2646
+ state.retired = true;
2647
+ state.pinned.clear();
2648
+ state.reported = true;
2649
+ logDetail("replay.unannounced", {
2650
+ scenario: state.scenarioId ?? undefined,
2651
+ why: "the turn ended before the directive reached the model — never steered, never reported",
2652
+ });
2653
+ }
2654
+ /**
2655
+ * How `PreToolUse` should answer while carrying a call id for `serverName`,
2656
+ * or undefined to carry none (the report then joins by fingerprint).
2657
+ *
2658
+ * THE RUNNER MUST NOT CHANGE WHAT THE USER IS ASKED. Answering `allow` skips
2659
+ * the permission prompt for the call, so an id rides along — with `allow` —
2660
+ * only where that changes nothing: a replay-pinned step (replay is its own
2661
+ * consent, and its `updatedInput` must be applied), or a session in
2662
+ * `bypassPermissions` mode, where nothing prompts anyway. Otherwise only an
2663
+ * operator's explicit `BIR_CORRELATION_DECISION` (see
2664
+ * {@link ControlServerOptions.correlation}). Never in `plan` mode, and never
2665
+ * into a server with no live proxy to strip the id — an upstream reached
2666
+ * directly would receive an argument it never declared.
2667
+ */
2668
+ injectionDecision(payload, pinned, serverName, session) {
2669
+ if (this.opts.noCorrelation || isPlanMode(payload))
2670
+ return undefined;
2671
+ if (!this.hasLiveProxy(serverName))
2672
+ return undefined;
2673
+ // A registered proxy for this server is alive — but this session's calls
2674
+ // to it went through something that never echoed an id (see
2675
+ // `SessionState.noInjection`), so the id is not sent again.
2676
+ if (session?.noInjection?.has(serverName))
2677
+ return undefined;
2678
+ if (pinned || payload.permission_mode === "bypassPermissions")
2679
+ return "allow";
2680
+ const policy = this.correlationPolicy();
2681
+ return policy === "auto" ? undefined : policy;
2682
+ }
2683
+ /** See {@link ControlServerOptions.correlation}. */
2684
+ correlationPolicy() {
2685
+ if (this.opts.correlation)
2686
+ return this.opts.correlation;
2687
+ const env = (process.env.BIR_CORRELATION_DECISION ?? "").trim().toLowerCase();
2688
+ if (env === "allow" || env === "ask" || env === "auto")
2689
+ return env;
2690
+ return this.opts.correlationDecision === "ask" ? "ask" : "auto";
2691
+ }
2692
+ /**
2693
+ * Whether a proxy that registered for `serverName` is still running. Only a
2694
+ * live proxy strips a call id before the upstream sees it; a server the user
2695
+ * unwrapped while this recorder ran stays in {@link wrapped} forever, and
2696
+ * must never receive one. Cached for a moment — this is on every wrapped call.
2697
+ */
2698
+ hasLiveProxy(serverName) {
2699
+ const now = Date.now();
2700
+ const cached = this.liveProxyCache.get(serverName);
2701
+ if (cached && now - cached.at < LIVE_PROXY_CACHE_MS)
2702
+ return cached.live;
2703
+ const pids = this.proxyPids.get(serverName);
2704
+ let live = false;
2705
+ for (const pid of [...(pids ?? [])]) {
2706
+ if (pidAlive(pid)) {
2707
+ live = true;
2708
+ break;
2709
+ }
2710
+ pids?.delete(pid);
2711
+ }
2712
+ this.liveProxyCache.set(serverName, { live, at: now });
2713
+ return live;
2714
+ }
2715
+ /**
2716
+ * Remember `pid` as a proxy serving `serverName` on this recorder — from its
2717
+ * registration or its poll, the two requests that carry one. Only these make
2718
+ * {@link hasLiveProxy} true: a proxy started under `bir off` is a pure pipe
2719
+ * that sends neither, and must never be sent a call id it cannot strip.
2720
+ */
2721
+ noteProxyPid(serverName, pid) {
2722
+ if (!serverName || !Number.isInteger(pid) || pid <= 0)
2723
+ return;
2724
+ const pids = this.proxyPids.get(serverName) ?? new Set();
2725
+ if (pids.has(pid))
2726
+ return;
2727
+ pids.add(pid);
2728
+ this.proxyPids.set(serverName, pids);
2729
+ this.liveProxyCache.delete(serverName);
2730
+ }
899
2731
  /** A controller decision as a `PreToolUse` answer, or undefined to carry on. */
900
2732
  preToolAnswer(action) {
901
2733
  switch (action.kind) {
@@ -919,6 +2751,42 @@ export class ControlServer {
919
2751
  return undefined;
920
2752
  }
921
2753
  }
2754
+ /**
2755
+ * What `PreToolUse` answers when a steering plan is still working as the
2756
+ * route's deadline passes.
2757
+ *
2758
+ * The one case that matters is a divergence: the plan retires, then runs
2759
+ * its remaining steps for real inside this hook (§8), and a slow tool or a
2760
+ * slow source-run fetch can take that past the host's timeout. Answering
2761
+ * `{}` would let the model's own call run and leave it believing nothing
2762
+ * happened, while the scenario's steps — writes included — keep landing
2763
+ * behind its back, and it would then do them all again. So the call is
2764
+ * answered with the truth instead: which steps are done, that the rest may
2765
+ * still be running, and that the task is the model's from here.
2766
+ *
2767
+ * Before the divergence starts (the plan still armed) nothing has run, and
2768
+ * `{}` is the honest answer.
2769
+ */
2770
+ replayOverran(state, toolName) {
2771
+ if (!state.retired)
2772
+ return {};
2773
+ const done = [...state.stepResults.values()]
2774
+ .filter((s) => s.status === "ok")
2775
+ .map((s) => s.toolName || "a step");
2776
+ logLine("replay.diverge_overran", {
2777
+ scenario: state.scenarioId ?? undefined,
2778
+ done: done.length,
2779
+ why: "the recorded steps were still running at the hook's deadline — handed to the model",
2780
+ });
2781
+ const note = [
2782
+ "A recorded run of this task was re-running its remaining steps for you and did not finish in time.",
2783
+ done.length > 0
2784
+ ? `Already done: ${done.join(", ")}.`
2785
+ : "None of its steps had finished yet.",
2786
+ "Some of the rest may still complete in the background, so check the current state before repeating any step, then carry on with the task yourself.",
2787
+ ].join(" ");
2788
+ return this.preToolAnswer(this.replay.deliverInstead(note, toolName)) ?? {};
2789
+ }
922
2790
  /**
923
2791
  * Recordings this turn must not count a step hit against (segmented.md
924
2792
  * R-HIT-7).
@@ -1003,6 +2871,14 @@ export class ControlServer {
1003
2871
  * builds an intent from the tool name and arguments, and a step the agent took
1004
2872
  * without narrating it is exactly as much a recurrence as one it explained.
1005
2873
  * Every failure is a miss.
2874
+ *
2875
+ * THE PROBE IS NOT WAITED ON unless it may arm ({@link intentArming}). Hit
2876
+ * counting needs no answer at all, and waiting for one put a service round
2877
+ * trip — 70-450 ms healthy, the whole 4 s budget on a hung service — in front
2878
+ * of every Read, Grep, Edit and Bash of every signed-in user. When it may arm,
2879
+ * the wait is {@link intentArmBudgetMs} at most, and none while the service is
2880
+ * failing. Delivering a match on a *later* call instead is not an option: the
2881
+ * matched call has run by then, and the plan's first step would repeat it.
1006
2882
  */
1007
2883
  async tryIntentMatch(session, run, toolName, intent, modelArgs) {
1008
2884
  const opts = this.replay.intentMatch;
@@ -1028,9 +2904,17 @@ export class ControlServer {
1028
2904
  .digest("hex");
1029
2905
  if (probeHash === run.intent.lastProbeHash)
1030
2906
  return undefined;
1031
- run.intent.lastProbeHash = probeHash;
1032
2907
  if (run.intent.requests >= opts.maxRequestsPerTurn)
1033
2908
  return undefined;
2909
+ // A service in its cool-down is not asked at all. The probe would only add
2910
+ // a socket to an outage, and the hits it counts are lost with it anyway.
2911
+ // Neither the hash nor the cap is spent, so the same call probes again once
2912
+ // the service answers.
2913
+ if (!this.service.canWait()) {
2914
+ logDetail("replay.intent_skipped", { run: run.runId, tool: toolName, why: "service unhealthy" });
2915
+ return undefined;
2916
+ }
2917
+ run.intent.lastProbeHash = probeHash;
1034
2918
  run.intent.requests += 1;
1035
2919
  // Every scenario armed this turn, so a continuation cannot re-arm the plan
1036
2920
  // that just handed over.
@@ -1038,7 +2922,8 @@ export class ControlServer {
1038
2922
  .map((s) => s.scenarioId)
1039
2923
  .filter((id) => typeof id === "string");
1040
2924
  const startedAt = Date.now();
1041
- const answer = await this.withinBudget(this.recorder.matchIntent({
2925
+ const probe = this.recorder
2926
+ .matchIntent({
1042
2927
  text,
1043
2928
  source,
1044
2929
  // The same value twice: `currentToolName` is what today's server reads,
@@ -1053,49 +2938,33 @@ export class ControlServer {
1053
2938
  ranThrough: this.ranThroughFor(run),
1054
2939
  ...(this.replay.segmentArm ? { acceptSegments: true } : {}),
1055
2940
  supportsCalls: true,
1056
- }), opts.budgetMs);
1057
- if (!answer) {
1058
- logDetail("replay.intent_miss", { run: run.runId, tool: toolName });
2941
+ },
2942
+ // The probe's own budget (`BIR_INTENT_MATCH_BUDGET_MS`) ends the request
2943
+ // itself — waited on or not — so an abandoned probe releases its socket
2944
+ // rather than holding it for as long as the service takes to answer.
2945
+ { timeoutMs: opts.budgetMs })
2946
+ .catch(() => null);
2947
+ if (!this.intentArming()) {
2948
+ // Counted, never waited on: whatever the answer says is bookkeeping.
2949
+ void probe.then((late) => {
2950
+ if (late)
2951
+ this.noteProbe(run, toolName, source, late, startedAt, "not waited on — counting only");
2952
+ });
1059
2953
  return undefined;
1060
2954
  }
1061
- if (answer.stepHit) {
1062
- run.intent.lastStepHit = { ...answer.stepHit, at: Date.now() };
1063
- }
1064
- // What *would* have armed, had segments been armed (segmented.md R-OUT-10).
1065
- // Logged at line level, not detail: this is the whole observe-only period,
1066
- // and the decision to turn arming on is made by reading these. The turn
1067
- // then carries on with whatever else the answer holds (R-OUT-15).
1068
- if (answer.segmentWouldArm) {
1069
- const w = answer.segmentWouldArm;
1070
- logLine("replay.segment_would_arm", {
1071
- scenario: w.scenarioId,
1072
- run: w.runId,
1073
- key: w.key,
1074
- similarity: w.similarity.toFixed(3),
1075
- band: w.band,
1076
- verified: w.verified
1077
- ? w.verified.verdict === "skipped"
1078
- ? `skipped:${w.verified.reason}`
1079
- : `${w.verified.verdict}: ${w.verified.reason}`
1080
- : undefined,
1081
- tool: w.tool ?? toolName,
1082
- firstTool: w.firstTool,
1083
- stepFrom: w.stepFrom,
1084
- stepTo: w.stepTo,
1085
- hitCount: w.hitCount,
2955
+ const answer = await this.withinBudget(probe, this.intentArmBudgetMs());
2956
+ if (!answer) {
2957
+ logDetail("replay.intent_miss", { run: run.runId, tool: toolName });
2958
+ // A miss, or too slow to arm here. A late answer's hit still counts
2959
+ // where health shows it; a miss resolves null and notes nothing.
2960
+ void probe.then((late) => {
2961
+ if (late)
2962
+ this.noteProbe(run, toolName, source, late, startedAt, "answered after the call went ahead");
1086
2963
  });
2964
+ return undefined;
1087
2965
  }
2966
+ this.noteProbe(run, toolName, source, answer, startedAt);
1088
2967
  const match = answer.matched;
1089
- logDetail("replay.intent_probe", {
1090
- run: run.runId,
1091
- tool: toolName,
1092
- source,
1093
- stepHit: answer.stepHit ? `${answer.stepHit.runId}:${answer.stepHit.stepIndex}` : "none",
1094
- bestStepSimilarity: answer.bestStepSimilarity,
1095
- elapsedMs: Date.now() - startedAt,
1096
- matched: match?.scenarioId ?? "none",
1097
- kind: match?.kind,
1098
- });
1099
2968
  if (!match)
1100
2969
  return undefined;
1101
2970
  if (match.scenarioId && exclude.includes(match.scenarioId))
@@ -1108,16 +2977,43 @@ export class ControlServer {
1108
2977
  logLine("replay.intent_arm_capped", {
1109
2978
  scenario: match.scenarioId ?? undefined,
1110
2979
  kind: match.kind ?? "scenario",
1111
- similarity: match.similarity.toFixed(3),
2980
+ similarity: score(match.similarity),
1112
2981
  tool: toolName,
1113
2982
  });
1114
2983
  return undefined;
1115
2984
  }
1116
- const state = await this.replay.arm(match, text, this.wrapped, "intent", {
2985
+ // Bounded like the probe: derivation for a scenario with a target can take
2986
+ // seconds, and this call is waiting. A plan not armed in time is dropped
2987
+ // unused — it was never announced, so it never steers — and its ticket is
2988
+ // swept like any unredeemed one (R-OUT-12).
2989
+ const arming = this.armOrLog(run, this.replay.arm(match, text, this.wrapped, "intent", {
1117
2990
  liveCall: { toolName, toolInput },
1118
2991
  recentResults: recentToolResults(session.transcriptPath, this.deriveRecentResults),
1119
- });
2992
+ }));
2993
+ const state = await this.withinBudget(arming, INTENT_ARM_DERIVE_MS);
2994
+ if (state === undefined)
2995
+ return undefined; // it threw, and said so
2996
+ if (state === null) {
2997
+ logLine("replay.decision", {
2998
+ verdict: "no-steer",
2999
+ code: "arm_timeout",
3000
+ run: run.runId,
3001
+ scenario: match.scenarioId ?? undefined,
3002
+ kind: match.kind ?? "scenario",
3003
+ ms: INTENT_ARM_DERIVE_MS,
3004
+ why: "the plan was not ready before this call had to go ahead — never delivered, so never run",
3005
+ });
3006
+ void arming.then((late) => {
3007
+ if (!late)
3008
+ return;
3009
+ late.retired = true;
3010
+ late.pinned.clear();
3011
+ late.reported = true;
3012
+ });
3013
+ return undefined;
3014
+ }
1120
3015
  state.sessionId = session.sessionId;
3016
+ this.declineWithoutDeliveryTool(state);
1121
3017
  if (!state.plan) {
1122
3018
  // A declined intent hit reports nothing: the turn's cost is not a
1123
3019
  // measurement of that scenario's task, so it is no baseline sample either.
@@ -1146,18 +3042,129 @@ export class ControlServer {
1146
3042
  run.replay = state;
1147
3043
  run.intent.armed += 1;
1148
3044
  const directive = this.replay.directiveFor(state) ?? "";
3045
+ // HOW THE DIRECTIVE REACHES THE MODEL. Mid-task, the call that matched is
3046
+ // usually the model's own ordinary work — a `git status`, a file read —
3047
+ // and denying it, or swapping its output for a directive, shows the user a
3048
+ // blocked call and reads to the model like prompt injection. So the call
3049
+ // runs untouched and the directive rides on its PostToolUse, the channel a
3050
+ // hand-over note already uses; the plan starts at its first step after.
3051
+ //
3052
+ // The exception is a call that IS the plan's first step: letting it run
3053
+ // would have the plan run it a second time — a duplicated write. That one
3054
+ // is still answered in place. The same tool is not the same call, though:
3055
+ // it is held to the pin guard's rule (control/pin-guard.ts), so a `git
3056
+ // status` is never swapped for a deploy that happens to be a Bash step.
3057
+ const inPlace = this.isFirstStep(state, toolName, modelArgs);
1149
3058
  logLine("replay.intent_armed", {
1150
3059
  run: run.runId,
1151
3060
  scenario: state.scenarioId ?? undefined,
1152
- similarity: match.similarity.toFixed(3),
3061
+ similarity: score(match.similarity),
1153
3062
  mode: state.mode,
1154
- instead: toolName,
3063
+ instead: inPlace ? toolName : undefined,
3064
+ after: inPlace ? undefined : toolName,
1155
3065
  kind: match.kind ?? "scenario",
1156
3066
  key: match.key,
1157
3067
  verified: match.verified?.verdict,
1158
3068
  why: "the model's reasoning matched a calculated scenario's intent",
1159
3069
  });
1160
- return this.preToolAnswer(this.replay.deliverInstead(directive, toolName));
3070
+ if (inPlace)
3071
+ return this.preToolAnswer(this.replay.deliverInstead(directive, toolName));
3072
+ run.pendingDirective = { state, text: directive };
3073
+ return undefined;
3074
+ }
3075
+ /**
3076
+ * Whether the model's call is plausibly `state`'s first step: its tool, and
3077
+ * arguments the pin guard would pin to that step's input. The input is
3078
+ * computed only for a step of the plan's own frame (a called segment's first
3079
+ * step would build that segment's parameters here); anything that cannot be
3080
+ * computed is "no" — the directive then rides on the call's PostToolUse.
3081
+ */
3082
+ isFirstStep(state, toolName, modelArgs) {
3083
+ const plan = state.plan;
3084
+ if (!plan || plan.expectedTool() !== toolName)
3085
+ return false;
3086
+ try {
3087
+ const entry = plan.currentEntry();
3088
+ if (!entry || entry.depth !== 0)
3089
+ return false;
3090
+ const first = plan.toolInputForCurrentStep();
3091
+ return pinCompatible(toolName, modelArgs, first).ok;
3092
+ }
3093
+ catch (err) {
3094
+ logDetail("replay.first_step_unknown", { tool: toolName, error: errText(err) });
3095
+ return false;
3096
+ }
3097
+ }
3098
+ /**
3099
+ * Whether an intent probe may arm a plan in its own `PreToolUse` — and so
3100
+ * whether that hook waits for it. See {@link ControlServerOptions.intentArm}.
3101
+ */
3102
+ intentArming() {
3103
+ if (this.opts.intentArm !== undefined)
3104
+ return this.opts.intentArm;
3105
+ const env = process.env.BIR_INTENT_ARM;
3106
+ if (env === "1")
3107
+ return true;
3108
+ if (env === "0")
3109
+ return false;
3110
+ return this.replay.segmentArm;
3111
+ }
3112
+ /** How long an arming probe is waited on: `BIR_INTENT_ARM_BUDGET_MS`, never past the probe's own budget. */
3113
+ intentArmBudgetMs() {
3114
+ return Math.min(this.replay.intentMatch.budgetMs, positiveEnv("BIR_INTENT_ARM_BUDGET_MS", INTENT_ARM_PROBE_MS));
3115
+ }
3116
+ /** The bookkeeping half of a probe's answer: what `/health` and the log show. */
3117
+ noteProbe(run, toolName, source, answer, startedAt, late) {
3118
+ // Bookkeeping only, and often run from a floating `.then` after the hook
3119
+ // answered: a malformed answer is a log line, never an unhandled rejection
3120
+ // (which crashes any embedder without a handler) or a failed route.
3121
+ try {
3122
+ this.noteProbeUnsafe(run, toolName, source, answer, startedAt, late);
3123
+ }
3124
+ catch (err) {
3125
+ logDetail("replay.intent_note_failed", { run: run.runId, tool: toolName, error: errText(err) });
3126
+ }
3127
+ }
3128
+ noteProbeUnsafe(run, toolName, source, answer, startedAt, late) {
3129
+ if (answer.stepHit) {
3130
+ run.intent.lastStepHit = { ...answer.stepHit, at: Date.now() };
3131
+ }
3132
+ // What *would* have armed, had segments been armed (segmented.md R-OUT-10).
3133
+ // Logged at line level, not detail: this is the whole observe-only period,
3134
+ // and the decision to turn arming on is made by reading these. The turn
3135
+ // then carries on with whatever else the answer holds (R-OUT-15).
3136
+ if (answer.segmentWouldArm) {
3137
+ const w = answer.segmentWouldArm;
3138
+ logLine("replay.segment_would_arm", {
3139
+ scenario: w.scenarioId,
3140
+ run: w.runId,
3141
+ key: w.key,
3142
+ similarity: score(w.similarity),
3143
+ band: w.band,
3144
+ verified: w.verified
3145
+ ? w.verified.verdict === "skipped"
3146
+ ? `skipped:${w.verified.reason}`
3147
+ : `${w.verified.verdict}: ${w.verified.reason}`
3148
+ : undefined,
3149
+ tool: w.tool ?? toolName,
3150
+ firstTool: w.firstTool,
3151
+ stepFrom: w.stepFrom,
3152
+ stepTo: w.stepTo,
3153
+ hitCount: w.hitCount,
3154
+ });
3155
+ }
3156
+ const match = answer.matched;
3157
+ logDetail("replay.intent_probe", {
3158
+ run: run.runId,
3159
+ tool: toolName,
3160
+ source,
3161
+ stepHit: answer.stepHit ? `${answer.stepHit.runId}:${answer.stepHit.stepIndex}` : "none",
3162
+ bestStepSimilarity: answer.bestStepSimilarity,
3163
+ elapsedMs: Date.now() - startedAt,
3164
+ matched: match?.scenarioId ?? "none",
3165
+ kind: match?.kind,
3166
+ late,
3167
+ });
1161
3168
  }
1162
3169
  /**
1163
3170
  * Schedule a fragment once a matched turn's plan has handed over (fallbk.md
@@ -1179,6 +3186,11 @@ export class ControlServer {
1179
3186
  * The run id is swapped in place: steps allocated before belonged to a turn
1180
3187
  * the service never created a run for, and nothing of theirs may land in the
1181
3188
  * fragment — so pending correlations are latched and built-ins forgotten.
3189
+ *
3190
+ * Whether the service created the fragment decides only whether it is
3191
+ * recorded, which the user never sees — so this call waits
3192
+ * {@link FRAGMENT_CREATE_MS} for the answer at most, and not at all while the
3193
+ * service is failing. A later answer is applied when it lands.
1182
3194
  */
1183
3195
  async openFragment(session, run, reasoning, toolName, args) {
1184
3196
  const f = run.fragment;
@@ -1205,29 +3217,40 @@ export class ControlServer {
1205
3217
  step: f.stepIndex,
1206
3218
  why: "recording the agent's own work after the scenario handed over",
1207
3219
  });
1208
- if (isRunCreationAware(this.recorder)) {
1209
- const created = await this.withinBudget(this.recorder.runCreated(id), this.replay.budgets.matchMs);
1210
- if (created && !created.created) {
1211
- run.recording = false;
1212
- f.state = "hit";
1213
- // Which run it hit matters from here on: this turn must not count step
1214
- // hits against the fragment it is a repeat of (segmented.md R-HIT-7).
1215
- f.hitRunId = created.fragmentOf?.runId;
1216
- logLine("run.fragment_hit", {
1217
- run: id,
1218
- scenario: f.scenarioId,
1219
- step: f.stepIndex,
1220
- of: created.fragmentOf?.runId,
1221
- why: "a hand-over at this step was recorded before — counted as a hit, not recorded",
1222
- });
1223
- }
3220
+ if (!isRunCreationAware(this.recorder))
3221
+ return;
3222
+ const pending = this.recorder.runCreated(id).catch(() => ({ created: true }));
3223
+ const created = this.service.canWait() ? await this.withinBudget(pending, FRAGMENT_CREATE_MS) : null;
3224
+ if (created) {
3225
+ this.applyFragmentCreation(run, id, created);
3226
+ }
3227
+ else {
3228
+ void pending.then((late) => this.applyFragmentCreation(run, id, late));
1224
3229
  }
1225
3230
  }
3231
+ /** What the fragment's create call said: `created: false` is a hit, not a recording. */
3232
+ applyFragmentCreation(run, id, created) {
3233
+ const f = run.fragment;
3234
+ if (created.created || !f || run.runId !== id)
3235
+ return;
3236
+ run.recording = false;
3237
+ f.state = "hit";
3238
+ // Which run it hit matters from here on: this turn must not count step
3239
+ // hits against the fragment it is a repeat of (segmented.md R-HIT-7).
3240
+ f.hitRunId = created.fragmentOf?.runId;
3241
+ logLine("run.fragment_hit", {
3242
+ run: id,
3243
+ scenario: f.scenarioId,
3244
+ step: f.stepIndex,
3245
+ of: created.fragmentOf?.runId,
3246
+ why: "a hand-over at this step was recorded before — counted as a hit, not recorded",
3247
+ });
3248
+ }
1226
3249
  /** Resolve with `p`, or null once `ms` passes or it rejects. */
1227
3250
  async withinBudget(p, ms) {
1228
3251
  let timer;
1229
3252
  const budget = new Promise((resolve) => {
1230
- timer = setTimeout(() => resolve(null), ms);
3253
+ timer = setTimeout(() => resolve(null), Math.max(0, ms));
1231
3254
  timer.unref?.();
1232
3255
  });
1233
3256
  try {
@@ -1247,18 +3270,52 @@ export class ControlServer {
1247
3270
  */
1248
3271
  onToolPost(payload) {
1249
3272
  const out = this.toolPost(payload);
3273
+ const context = [];
3274
+ // What a cancelled direct run left this session to tell, whichever turn
3275
+ // this post belongs to (see `SessionState.stashedNote`).
3276
+ const stashed = this.claimStashedNote(payload.session_id);
3277
+ if (stashed)
3278
+ context.push(stashed);
1250
3279
  const run = this.sessions.get(payload.session_id ?? "unknown-session")?.run;
1251
- if (!run || run.finished)
1252
- return out;
1253
- this.noteHandover(run);
1254
- const note = this.replay.takeNote(run.replay);
1255
- if (!note)
3280
+ if (run && !run.finished) {
3281
+ this.noteHandover(run);
3282
+ const note = this.replay.takeNote(run.replay);
3283
+ if (note)
3284
+ context.push(note);
3285
+ if (run.handback) {
3286
+ context.push(run.handback);
3287
+ run.handback = undefined;
3288
+ }
3289
+ // A plan an intent match armed beside the model's own call: this is where
3290
+ // the model hears of it (see `tryIntentMatch`), and from here it steers.
3291
+ const pending = run.pendingDirective;
3292
+ if (pending) {
3293
+ run.pendingDirective = undefined;
3294
+ if (pending.state.plan && !pending.state.retired && pending.text)
3295
+ context.push(pending.text);
3296
+ }
3297
+ }
3298
+ if (context.length === 0)
1256
3299
  return out;
1257
3300
  return {
1258
3301
  ...out,
1259
- hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: note },
3302
+ hookSpecificOutput: {
3303
+ // The event that fired: this route serves PostToolUse and
3304
+ // PostToolUseFailure, and the host rejects an answer naming the other.
3305
+ hookEventName: payload.hook_event_name === "PostToolUseFailure" ? "PostToolUseFailure" : "PostToolUse",
3306
+ additionalContext: context.join("\n\n"),
3307
+ },
1260
3308
  };
1261
3309
  }
3310
+ /** Whether `run` opened a step for (or deliberately skipped) this `tool_use_id`. */
3311
+ knowsToolUse(run, toolUseId) {
3312
+ if (run.builtIns.has(toolUseId) || run.housekeepingUses?.has(toolUseId))
3313
+ return true;
3314
+ for (const c of run.correlations.values())
3315
+ if (c.toolUseId === toolUseId)
3316
+ return true;
3317
+ return false;
3318
+ }
1262
3319
  /**
1263
3320
  * `PostToolUse` / `PostToolUseFailure`. For a built-in this closes the pair.
1264
3321
  * For a correlated MCP call it normally does nothing — the proxy owns that
@@ -1268,9 +3325,49 @@ export class ControlServer {
1268
3325
  */
1269
3326
  toolPost(payload) {
1270
3327
  const session = this.ensureSession(payload);
1271
- const run = this.ensureRun(session);
1272
3328
  const toolUseId = payload.tool_use_id ?? "";
1273
3329
  const toolName = payload.tool_name ?? "unknown";
3330
+ if (toolName.startsWith(`mcp__${SCENARIO_SERVER_KEY}__`))
3331
+ this.birToolSeen = true;
3332
+ // Every completion, pinned or not, before anything below can return: it
3333
+ // is what tells a plan's next call apart from one running beside a pin.
3334
+ this.hostCompleted(session.run, toolUseId);
3335
+ // A post for a call of a turn that has already ended — a background task
3336
+ // finishing after Stop. It belongs to that turn, which is sealed; opening a
3337
+ // new, prompt-less run for it would swallow the next prompt (see onPrompt).
3338
+ if (toolUseId) {
3339
+ const open = session.run && !session.run.finished ? session.run : undefined;
3340
+ const sealed = [session.run, session.previous].find((r) => r !== undefined && r.finished && this.knowsToolUse(r, toolUseId));
3341
+ if (sealed && !(open && this.knowsToolUse(open, toolUseId))) {
3342
+ logDetail("tool.post.late", {
3343
+ run: sealed.runId,
3344
+ tool: toolName,
3345
+ why: "its turn had already ended — not recorded, and no new run is opened for it",
3346
+ });
3347
+ return {};
3348
+ }
3349
+ }
3350
+ const failed = payload.hook_event_name === "PostToolUseFailure" || payload.error !== undefined;
3351
+ if (this.replay.isDirectTool(toolName)) {
3352
+ // `run_scenario` answered while its `PreToolUse` record is still waiting:
3353
+ // its `/scenario/run` never arrived, because only that request consumes
3354
+ // the record. The call was refused, cancelled, or failed on the way —
3355
+ // and whether the host calls that a failure says nothing: bir-scenario
3356
+ // answers every failure (no recorder, a stale token, a timeout) as an
3357
+ // ordinary result, so it arrives as a plain `PostToolUse`. Either way
3358
+ // the plan never ran, and it must not run later as a divergence the user
3359
+ // never agreed to. Judged before anything below can return early.
3360
+ const waiting = this.directCalls.find((c) => c.sessionId === session.sessionId && c.toolUseId === toolUseId);
3361
+ if (waiting) {
3362
+ this.directCalls = this.directCalls.filter((c) => c !== waiting);
3363
+ if (!this.runningPlans.has(waiting.state)) {
3364
+ this.vetoPlan(waiting.run, waiting.state, failed
3365
+ ? "run_scenario failed before it reached the runner"
3366
+ : "run_scenario answered without ever reaching the runner");
3367
+ }
3368
+ }
3369
+ }
3370
+ const run = this.ensureRun(session);
1274
3371
  // `onToolPre` opened no step for these, so closing one here would record a
1275
3372
  // `tool_response` with no `tool_selected` before it. The id it remembered
1276
3373
  // decides first; the call itself is judged again only when no step was
@@ -1286,7 +3383,6 @@ export class ControlServer {
1286
3383
  // pre hook, so the test is whether that hook opened a step, not the plan.
1287
3384
  if (this.replay.isDirectTool(toolName) && !run.builtIns.has(toolUseId))
1288
3385
  return {};
1289
- const failed = payload.hook_event_name === "PostToolUseFailure" || payload.error !== undefined;
1290
3386
  // ── replay threading (docs/calculatedReplay.md §7.2) ─────────────────────
1291
3387
  // `toolOutputLogic` was authored against the bytes that were *recorded* for
1292
3388
  // this step. For a wrapped MCP step those are the proxy's whole
@@ -1298,6 +3394,9 @@ export class ControlServer {
1298
3394
  const pinnedHere = replay && this.replay.isPinned(replay, toolUseId);
1299
3395
  const correlation = [...run.correlations.values()].find((c) => c.toolUseId && c.toolUseId === toolUseId);
1300
3396
  if (pinnedHere && replay) {
3397
+ // The host's verdict, which outranks anything read from the output: a
3398
+ // step that failed must never thread as if it worked (controller.ts).
3399
+ const hostFailure = failed ? this.errorText(payload) : undefined;
1301
3400
  if (correlation && this.replay.threadsFromProxy(replay, toolUseId)) {
1302
3401
  correlation.threadToolUseId = toolUseId;
1303
3402
  correlation.threadFallback = setTimeout(() => {
@@ -1308,15 +3407,16 @@ export class ControlServer {
1308
3407
  tool: toolName,
1309
3408
  why: "no proxy report — threading the hook's view; shapes may differ",
1310
3409
  });
1311
- this.replay.postTool(replay, toolUseId, serializeCapped(payload.tool_response ?? {}));
3410
+ this.replay.postTool(replay, toolUseId, serializeCapped(payload.tool_response ?? {}), hostFailure);
1312
3411
  }, PROXY_REPORT_GRACE_MS);
1313
3412
  correlation.threadFallback.unref?.();
1314
3413
  }
1315
3414
  else {
1316
- this.replay.postTool(replay, toolUseId, serializeCapped(payload.tool_response ?? {}));
3415
+ this.replay.postTool(replay, toolUseId, serializeCapped(payload.tool_response ?? {}), hostFailure);
1317
3416
  }
1318
3417
  }
1319
3418
  if (correlation) {
3419
+ correlation.postSeen = true;
1320
3420
  if (correlation.settled) {
1321
3421
  logDetail("tool.post.deduped", { run: run.runId, tool: toolName });
1322
3422
  return { deduped: true };
@@ -1331,6 +3431,13 @@ export class ControlServer {
1331
3431
  tool: correlation.qualifiedName,
1332
3432
  why: "no proxy report — recording the hook's view instead",
1333
3433
  });
3434
+ if (correlation.injected) {
3435
+ // An id rode along and nothing echoed it: this session's calls to
3436
+ // the server may pass through a proxy that strips nothing (one
3437
+ // started under `bir off` is a pure pipe). No id goes there again
3438
+ // until a report proves otherwise.
3439
+ (session.noInjection ??= new Set()).add(correlation.serverName);
3440
+ }
1334
3441
  this.settle(run, correlation, {
1335
3442
  args: correlation.hookArgs,
1336
3443
  result: payload.tool_response,
@@ -1358,6 +3465,18 @@ export class ControlServer {
1358
3465
  logDetail("tool.post", { run: run.runId, tool: toolName, step: responseIndex, failed });
1359
3466
  return { stepIndex: responseIndex };
1360
3467
  }
3468
+ /**
3469
+ * Tell the replay controller the host finished `toolUseId` (`PostToolUse`
3470
+ * or `PostToolUseFailure`). Bookkeeping for its pin waits; never throws.
3471
+ */
3472
+ hostCompleted(run, toolUseId) {
3473
+ try {
3474
+ this.replay.hostCompleted?.(run?.replay, toolUseId);
3475
+ }
3476
+ catch (err) {
3477
+ logDetail("replay.host_completed_failed", { error: errText(err) });
3478
+ }
3479
+ }
1361
3480
  errorText(payload) {
1362
3481
  if (typeof payload.error === "string")
1363
3482
  return payload.error;
@@ -1398,15 +3517,28 @@ export class ControlServer {
1398
3517
  // The turn is over, so the run is over — that is the boundary §bindClaude
1399
3518
  // names, and sealing here is what stops a run depending on `SessionEnd`
1400
3519
  // arriving. A later prompt in the same session opens a fresh run, exactly as
1401
- // it did before.
1402
- this.sealRun(session);
3520
+ // it did before. The drain that follows is background work: it is what
3521
+ // lets the recorder forget the run once the service has it.
3522
+ this.closeRun(session, { detach: false });
1403
3523
  return {};
1404
3524
  }
1405
- async onSessionEnd(payload) {
3525
+ /**
3526
+ * `SessionEnd` — `/clear`, exit, logout. Answered at once: the host is
3527
+ * waiting on it to exit, and this process outlives the session, so the run
3528
+ * is sealed here and reaches the service in the background.
3529
+ */
3530
+ onSessionEnd(payload) {
1406
3531
  const session = this.ensureSession(payload);
1407
- await this.finalizeRun(session);
1408
3532
  // Its proxies outlive it after a `/clear`, and serve the next session.
1409
3533
  this.replay.work.releaseSession(session.sessionId);
3534
+ for (const [proxyId, sessionId] of this.proxySessions) {
3535
+ if (sessionId === session.sessionId)
3536
+ this.proxySessions.delete(proxyId);
3537
+ }
3538
+ this.closeRun(session, { detach: false, flushMs: FLUSH_MS.sessionEnd });
3539
+ // Forgotten a little later by the reaper, not now: a late report or post
3540
+ // for its last turn must still find that turn rather than open a new one.
3541
+ session.endedAt = Date.now();
1410
3542
  return {};
1411
3543
  }
1412
3544
  // ── proxy routes ─────────────────────────────────────────────────────────
@@ -1416,12 +3548,18 @@ export class ControlServer {
1416
3548
  return { error: "serverName required" };
1417
3549
  this.wrapped.add(serverName);
1418
3550
  const proxyVersion = body.version === undefined ? undefined : String(body.version);
3551
+ const pid = Number(body.pid ?? 0);
1419
3552
  this.registrations.set(serverName, {
1420
- pid: Number(body.pid ?? 0),
3553
+ pid,
1421
3554
  cwd: String(body.cwd ?? ""),
1422
3555
  at: Date.now(),
1423
3556
  version: proxyVersion,
1424
3557
  });
3558
+ // Every registered proxy of this server, not only the newest: any live one
3559
+ // is a proxy that strips an injected id (see `hasLiveProxy`).
3560
+ this.noteProxyPid(serverName, pid);
3561
+ if (typeof body.proxyId === "string" && body.proxyId)
3562
+ this.noteRegisteredProxy(body.proxyId);
1425
3563
  logLine("proxy.registered", {
1426
3564
  server: serverName,
1427
3565
  pid: body.pid,
@@ -1444,8 +3582,8 @@ export class ControlServer {
1444
3582
  }
1445
3583
  // The run may not exist yet (a proxy is usually spawned before the first
1446
3584
  // prompt). That is fine: the proxy needs the session, and gets a run id only
1447
- // once one exists.
1448
- const run = [...this.sessions.values()].find((s) => s.run && !s.run.finished)?.run;
3585
+ // once one exists — the most recently active one, never merely the oldest.
3586
+ const run = this.recentSessions().find((s) => this.isActive(s))?.run;
1449
3587
  return {
1450
3588
  ok: true,
1451
3589
  sessionId: this.sessionId,
@@ -1467,13 +3605,28 @@ export class ControlServer {
1467
3605
  */
1468
3606
  async onProxyPoll(body, signal) {
1469
3607
  const serverName = String(body.serverName ?? "");
3608
+ const pid = Number(body.pid);
3609
+ // A polling proxy is a bound one, and a bound proxy strips a call id: as
3610
+ // good a sign of life as its registration (which a recorder that restarted
3611
+ // under it may never have seen). A set lookup when already known.
3612
+ this.noteProxyPid(serverName, pid);
3613
+ // Replay off: say so, so the proxy slows its loop right down
3614
+ // (proxy/session.ts) — and nothing is logged, since it asks again later.
3615
+ // At once for a proxy that registered with this recorder. One that never
3616
+ // did is from before it started, and a 0.2.12 proxy — which never
3617
+ // registers again, and polls again 250 ms after any answer without work —
3618
+ // would ask four times a second until its session ended: it is held
3619
+ // first, as a poll with no work always was.
3620
+ if (!this.replay.enabled) {
3621
+ const proxyId = typeof body.proxyId === "string" ? body.proxyId : undefined;
3622
+ if (!proxyId || !this.registeredProxies.has(proxyId))
3623
+ await this.holdPoll(body, signal);
3624
+ return { replay: false };
3625
+ }
1470
3626
  if (!serverName)
1471
3627
  return { error: "serverName required" };
1472
- if (!this.replay.enabled)
1473
- return {};
1474
3628
  const hold = Number(body.holdMs);
1475
3629
  const startedAt = Number(body.startedAt);
1476
- const pid = Number(body.pid);
1477
3630
  const work = await this.replay.work.waitForWork(serverName, Number.isFinite(hold) && hold > 0 ? Math.min(hold, POLL_HOLD_MS) : POLL_HOLD_MS, signal, {
1478
3631
  proxyId: typeof body.proxyId === "string" ? body.proxyId : undefined,
1479
3632
  startedAt: Number.isFinite(startedAt) ? startedAt : undefined,
@@ -1481,6 +3634,46 @@ export class ControlServer {
1481
3634
  });
1482
3635
  return work ? { work } : {};
1483
3636
  }
3637
+ /** Remember that `proxyId` registered with this recorder (see {@link registeredProxies}). */
3638
+ noteRegisteredProxy(proxyId) {
3639
+ this.registeredProxies.delete(proxyId);
3640
+ this.registeredProxies.set(proxyId, Date.now());
3641
+ while (this.registeredProxies.size > REGISTERED_PROXIES_MAX) {
3642
+ const oldest = this.registeredProxies.keys().next().value;
3643
+ if (oldest === undefined)
3644
+ break;
3645
+ this.registeredProxies.delete(oldest);
3646
+ }
3647
+ }
3648
+ /**
3649
+ * Park a poll for as long as it asked to be held (never past
3650
+ * {@link POLL_HOLD_MS}), or until its caller goes away or this server closes.
3651
+ */
3652
+ holdPoll(body, signal) {
3653
+ const asked = Number(body.holdMs);
3654
+ const ms = Number.isFinite(asked) && asked > 0 ? Math.min(asked, POLL_HOLD_MS) : POLL_HOLD_MS;
3655
+ logOnce("proxy.poll_held", "proxy.poll_held", {
3656
+ why: "replay is off, and a proxy that never registered with this recorder is polling — held for its poll's hold, so an older proxy does not ask four times a second",
3657
+ });
3658
+ return new Promise((resolve) => {
3659
+ if (signal?.aborted) {
3660
+ resolve();
3661
+ return;
3662
+ }
3663
+ let timer;
3664
+ const release = () => {
3665
+ if (timer)
3666
+ clearTimeout(timer);
3667
+ signal?.removeEventListener("abort", release);
3668
+ this.heldPolls.delete(release);
3669
+ resolve();
3670
+ };
3671
+ timer = setTimeout(release, ms);
3672
+ timer.unref?.();
3673
+ signal?.addEventListener("abort", release, { once: true });
3674
+ this.heldPolls.add(release);
3675
+ });
3676
+ }
1484
3677
  /** `POST /proxy/result` — the answer to one dispatched `tools/call`. */
1485
3678
  onProxyResult(body) {
1486
3679
  const workId = String(body.workId ?? "");
@@ -1499,23 +3692,139 @@ export class ControlServer {
1499
3692
  * already owns its upstream, so the model spends nothing beyond the turn that
1500
3693
  * reads the results. There is no hook timeout here, which is exactly why the
1501
3694
  * derivation wait was deferred to it (§10).
3695
+ *
3696
+ * WHOSE PLAN. bir-scenario cannot say which session called it — a stdio MCP
3697
+ * server is never told — and taking "the first armed plan" ran one session's
3698
+ * plan, with its parameters, for another session's call, and returned its
3699
+ * results there. The `PreToolUse` for `run_scenario` always reaches this
3700
+ * recorder first, with its session, so the run takes the plan of the session
3701
+ * that just asked (see {@link takeDirectCall}). No such pre, no run: a call
3702
+ * the host never let through is not one to act on.
3703
+ *
3704
+ * ONE RUN PER PLAN. A second call while the plan is executing would run
3705
+ * every step again beside the first — duplicated writes — and is told so.
3706
+ *
3707
+ * `signal` aborts when `bir-scenario`'s request goes away — the host
3708
+ * cancelled the call (Esc) or its own timeout fired — so no further step is
3709
+ * started for a caller that can no longer read the result.
1502
3710
  */
1503
- async onScenarioRun() {
1504
- // The session whose plan is *armed*, not merely the first unfinished one. A
1505
- // `claude -p` run is never sealed when its process exits, so the previous
1506
- // trial's session stays first in insertion order with its plan retired —
1507
- // and picking it answered `no_plan` while the live plan sat at index 1
1508
- // (docs/mcpmark.md §12).
1509
- const live = [...this.sessions.values()]
1510
- .map((s) => s.run)
1511
- .filter((r) => r !== undefined && !r.finished);
1512
- const run = live.find((r) => r.replay?.plan && !r.replay.retired) ??
1513
- live.find((r) => r.replay) ??
1514
- live[0];
1515
- if (!run?.replay)
3711
+ async onScenarioRun(body, signal) {
3712
+ const call = this.takeDirectCall(body);
3713
+ if (!call) {
3714
+ logDetail("replay.run_unbound", {
3715
+ why: "no run_scenario PreToolUse is waiting for a run — nothing is run for this call",
3716
+ });
1516
3717
  return { ok: false, why: "no_plan" };
1517
- const result = await this.replay.runArmed(run.replay);
1518
- return result;
3718
+ }
3719
+ const state = call.state;
3720
+ if (this.runningPlans.has(state)) {
3721
+ logLine("replay.run_refused", {
3722
+ scenario: state.scenarioId ?? undefined,
3723
+ sess: call.sessionId,
3724
+ why: "this plan is already running — a second run would repeat every step",
3725
+ });
3726
+ return { ok: false, why: ALREADY_RUNNING };
3727
+ }
3728
+ this.runningPlans.add(state);
3729
+ // The session is busy for as long as its plan runs, and sends no hook
3730
+ // meanwhile: the host is waiting on this very call.
3731
+ this.touchSession(call.sessionId);
3732
+ // The user cancelling the call (Esc) cuts the turn short: what it cost is
3733
+ // no measure of the scenario. Marked the moment it happens — the next
3734
+ // prompt may seal the turn before the step in flight has finished.
3735
+ const onCancel = () => {
3736
+ this.cancelledPlans.add(state);
3737
+ };
3738
+ if (signal?.aborted)
3739
+ onCancel();
3740
+ else
3741
+ signal?.addEventListener("abort", onCancel, { once: true });
3742
+ try {
3743
+ const result = await this.replay.runArmed(state, signal);
3744
+ if (signal?.aborted)
3745
+ this.stashCancelledNote(call.sessionId, state);
3746
+ return result;
3747
+ }
3748
+ finally {
3749
+ signal?.removeEventListener("abort", onCancel);
3750
+ this.runningPlans.delete(state);
3751
+ this.touchSession(call.sessionId);
3752
+ }
3753
+ }
3754
+ /**
3755
+ * A direct run whose caller went away (Esc, or bir-scenario's own timeout)
3756
+ * stopped part-way, and the note that says which steps already ran — their
3757
+ * side effects are in place — reached nobody. When the turn goes on, its
3758
+ * next tool call carries it (the run's own path). When Esc ended the turn,
3759
+ * the next prompt seals that run, so the note is kept on the session too,
3760
+ * for its next `UserPromptSubmit`, `PostToolUse`, or `PreToolUse` of one of
3761
+ * the plan's own tools (in that call's place): without it the model would
3762
+ * run those steps a second time.
3763
+ */
3764
+ stashCancelledNote(sessionId, state) {
3765
+ const h = state.handover;
3766
+ if (!h || h.delivered)
3767
+ return;
3768
+ const session = this.sessions.get(sessionId);
3769
+ if (!session)
3770
+ return;
3771
+ session.stashedNote = state;
3772
+ logLine("replay.note_kept", {
3773
+ sess: sessionId,
3774
+ scenario: state.scenarioId ?? undefined,
3775
+ why: "the run was cancelled part-way — which steps ran is told on this session's next prompt or tool result",
3776
+ });
3777
+ }
3778
+ /** Mark a known session as heard from now (see {@link SessionState.lastSeenAt}). */
3779
+ touchSession(sessionId) {
3780
+ const session = this.sessions.get(sessionId);
3781
+ if (session)
3782
+ session.lastSeenAt = Date.now();
3783
+ }
3784
+ /**
3785
+ * The `run_scenario` pre this `/scenario/run` answers, removed from the
3786
+ * waiting list — or undefined when there is none whose plan can still run.
3787
+ *
3788
+ * Exact when the caller names it (a `bir-scenario` that forwards the host's
3789
+ * `tool_use_id` or session id). Otherwise only when every pre still waiting
3790
+ * is one session's: the oldest of the fresh ones, and when none is fresh (a
3791
+ * user who took a while to approve the call), the newest. Pres waiting in
3792
+ * two sessions at once are never guessed between — see below.
3793
+ */
3794
+ takeDirectCall(body) {
3795
+ const now = Date.now();
3796
+ this.directCalls = this.directCalls.filter((c) => now - c.at < DIRECT_CALL_TTL_MS &&
3797
+ !c.run.finished &&
3798
+ c.state.plan !== undefined &&
3799
+ !c.state.retired &&
3800
+ c.run.replay === c.state);
3801
+ const toolUseId = typeof body.toolUseId === "string" ? body.toolUseId : undefined;
3802
+ const sessionId = typeof body.sessionId === "string" ? body.sessionId : undefined;
3803
+ let pick;
3804
+ if (toolUseId)
3805
+ pick = this.directCalls.find((c) => c.toolUseId === toolUseId);
3806
+ if (!pick && sessionId)
3807
+ pick = this.directCalls.find((c) => c.sessionId === sessionId);
3808
+ if (!pick && !toolUseId && !sessionId) {
3809
+ // Unnamed, and more than one session waiting: which one is asking is a
3810
+ // guess, and a wrong guess runs one session's plan, with its parameters,
3811
+ // and hands the results to the other's model. Nothing runs instead; the
3812
+ // record stays for its own PostToolUse, which retires the plan, and each
3813
+ // model does its task itself — the safe way to be wrong.
3814
+ const sessions = new Set(this.directCalls.map((c) => c.sessionId));
3815
+ if (sessions.size > 1) {
3816
+ logLine("replay.run_ambiguous", {
3817
+ sessions: sessions.size,
3818
+ why: "run_scenario is pending in more than one session and the request names neither — nothing is run",
3819
+ });
3820
+ return undefined;
3821
+ }
3822
+ const fresh = this.directCalls.filter((c) => now - c.at < DIRECT_CALL_FRESH_MS);
3823
+ pick = fresh.length > 0 ? fresh[0] : this.directCalls[this.directCalls.length - 1];
3824
+ }
3825
+ if (pick)
3826
+ this.directCalls = this.directCalls.filter((c) => c !== pick);
3827
+ return pick;
1519
3828
  }
1520
3829
  /**
1521
3830
  * `POST /scenario/replay` — `bir replay`, i.e. a scenario nobody matched.
@@ -1535,54 +3844,64 @@ export class ControlServer {
1535
3844
  return result;
1536
3845
  }
1537
3846
  /**
1538
- * A proxy's `ProxyStepReport`. Three joins, tried in order:
1539
- * 1. the injected `callId` (Tier 1, exact);
1540
- * 2. the fingerprint, inside a 30 s window (`--no-correlation`, lossy under
1541
- * identical concurrent calls — §6 says so, and so does this code);
1542
- * 3. nothing — record it standalone, which is what an SDK session with no
1543
- * hooks produces and is a complete MCP step in its own right.
3847
+ * A proxy's `ProxyStepReport`. The step is joined to the call a hook saw,
3848
+ * in whichever session made it — never simply "the first session with an
3849
+ * open run", which put one window's MCP steps into another's recording:
3850
+ * 1. the `callId` a hook injected (exact), looked up in every session;
3851
+ * 2. the fingerprint `(server, tool, hash(args))` across every session's
3852
+ * current turn — lossy under identical concurrent calls, and documented
3853
+ * as such;
3854
+ * 3. nothing — record it standalone, in the session this proxy is known to
3855
+ * serve, or else the most recently active one. That is what an SDK
3856
+ * session with no hooks produces, and a complete MCP step in its own
3857
+ * right.
1544
3858
  */
1545
3859
  onProxyStep(report) {
1546
- this.learnProxySession(report);
1547
- const session = this.sessionForProxyStep();
1548
- const run = this.ensureRun(session);
3860
+ // `bir off` makes the runner inert. A proxy started before it keeps
3861
+ // reporting (one started after is a pure pipe), and none of it is recorded
3862
+ // until the runner is back on.
3863
+ if (offSource(this.opts.cwd))
3864
+ return { ok: true, off: true };
1549
3865
  this.wrapped.add(report.serverName);
1550
- let correlation = report.callId ? run.correlations.get(report.callId) : undefined;
1551
- if (!correlation)
1552
- correlation = this.matchByFingerprint(run, report);
1553
- if (correlation) {
1554
- // The truer output for a replay step: exactly the bytes that were recorded
1555
- // for it when the scenario was built (§7.2).
1556
- //
1557
- // Keyed off "is this call still pinned", not off the flag `/tool/post`
1558
- // sets, because the two events race: the proxy's report is fire-and-forget
1559
- // HTTP and can land either side of `PostToolUse`. Waiting for the flag
1560
- // would let a fast proxy fall through to the 1.5 s hook-view fallback —
1561
- // the degraded path firing precisely when everything worked.
1562
- const toolUseId = correlation.threadToolUseId ?? correlation.toolUseId;
1563
- if (run.replay && toolUseId && this.replay.isPinned(run.replay, toolUseId)) {
1564
- correlation.threadToolUseId = undefined;
1565
- if (correlation.threadFallback) {
1566
- clearTimeout(correlation.threadFallback);
1567
- correlation.threadFallback = undefined;
1568
- }
1569
- this.replay.postTool(run.replay, toolUseId, serializeCapped(redact(report.result)));
1570
- this.noteHandover(run);
1571
- }
1572
- if (correlation.settled) {
1573
- logDetail("proxy.step.deduped", { run: run.runId, tool: report.qualifiedName });
1574
- return { deduped: true };
3866
+ const owner = this.ownerOf(report);
3867
+ // A session started with BIR_OFF=1 (K2) records nothing, whoever reports
3868
+ // for it. Its own proxies are pure pipes; this is any other one.
3869
+ const boundTo = report.proxyId ? this.proxySessions.get(report.proxyId) : undefined;
3870
+ if (this.isSessionOff(owner?.session.sessionId ?? boundTo))
3871
+ return { ok: true, off: true };
3872
+ if (owner) {
3873
+ const { session, run, correlation } = owner;
3874
+ session.lastSeenAt = Date.now();
3875
+ if (report.proxyId)
3876
+ this.bindProxy(report.proxyId, session.sessionId);
3877
+ if (run.finished && !correlation.settled && (run.finishQueued || !run.recording)) {
3878
+ // Its turn ended, and its finish was sent, before the report arrived.
3879
+ // Recording it now would land after that run's finish; opening a new
3880
+ // run for it would swallow the next prompt. It stays with its turn,
3881
+ // unrecorded, and says so. (Inside the seal's answer wait it is still
3882
+ // recorded, below — ahead of the finish.)
3883
+ correlation.settled = true;
3884
+ correlation.reported = true;
3885
+ logLine("proxy.step.late", {
3886
+ run: run.runId,
3887
+ tool: report.qualifiedName,
3888
+ why: "the report arrived after its turn was sealed — not recorded",
3889
+ });
3890
+ return { late: true };
1575
3891
  }
1576
- const pair = this.settle(run, correlation, {
1577
- args: correlation.hookArgs ?? report.args,
1578
- result: report.result,
1579
- isError: report.isError,
1580
- errorMessage: report.errorMessage,
1581
- durationMs: report.durationMs,
1582
- });
1583
- return { stepIndex: pair.selected, merged: true };
3892
+ return this.joinReport(run, correlation, report);
1584
3893
  }
1585
3894
  // Standalone: no hook ever saw this call.
3895
+ const session = this.sessionForProxyStep(report);
3896
+ if (this.isSessionOff(session.sessionId))
3897
+ return { ok: true, off: true };
3898
+ // A step is the session being heard from, exactly as a hook is. A host
3899
+ // with no hooks (an SDK, a Tier-2 MCP client) sends nothing else: without
3900
+ // this its busy run would read as idle on `/health` — an upgrade restart
3901
+ // could land mid-work — and the reaper would seal it on every sweep once
3902
+ // `idleSealMs` had passed, splitting its recording into a run a minute.
3903
+ session.lastSeenAt = Date.now();
3904
+ const run = this.ensureRun(session);
1586
3905
  const pair = run.ordering.allocatePair();
1587
3906
  this.record(run, pair, {
1588
3907
  toolName: report.qualifiedName || qualifyToolName(report.serverName, report.toolName),
@@ -1600,59 +3919,224 @@ export class ControlServer {
1600
3919
  });
1601
3920
  return { stepIndex: pair.selected, merged: false };
1602
3921
  }
3922
+ /** Merge a report into the call a hook saw: thread it into a plan if pinned, record it once. */
3923
+ joinReport(run, correlation, report) {
3924
+ // The truer output for a replay step: exactly the bytes that were recorded
3925
+ // for it when the scenario was built (§7.2).
3926
+ //
3927
+ // Keyed off "is this call still pinned", not off the flag `/tool/post`
3928
+ // sets, because the two events race: the proxy's report is fire-and-forget
3929
+ // HTTP and can land either side of `PostToolUse`. Waiting for the flag
3930
+ // would let a fast proxy fall through to the 1.5 s hook-view fallback —
3931
+ // the degraded path firing precisely when everything worked.
3932
+ const toolUseId = correlation.threadToolUseId ?? correlation.toolUseId;
3933
+ if (run.replay && toolUseId && this.replay.isPinned(run.replay, toolUseId)) {
3934
+ correlation.threadToolUseId = undefined;
3935
+ if (correlation.threadFallback) {
3936
+ clearTimeout(correlation.threadFallback);
3937
+ correlation.threadFallback = undefined;
3938
+ }
3939
+ this.replay.postTool(run.replay, toolUseId, serializeCapped(redact(report.result)),
3940
+ // The proxy's verdict: a step that failed must never thread as if it
3941
+ // worked (controller.ts `postTool`).
3942
+ report.isError === true
3943
+ ? typeof report.errorMessage === "string" && report.errorMessage
3944
+ ? report.errorMessage
3945
+ : "the tool reported an error"
3946
+ : undefined);
3947
+ this.noteHandover(run);
3948
+ }
3949
+ const duplicate = correlation.reported === true;
3950
+ correlation.reported = true;
3951
+ if (correlation.settled) {
3952
+ logDetail("proxy.step.deduped", {
3953
+ run: run.runId,
3954
+ tool: report.qualifiedName,
3955
+ why: duplicate ? "a second report for one call" : "already recorded from the hook's view",
3956
+ });
3957
+ return { deduped: true };
3958
+ }
3959
+ const pair = this.settle(run, correlation, {
3960
+ args: correlation.hookArgs ?? report.args,
3961
+ result: report.result,
3962
+ isError: report.isError,
3963
+ errorMessage: report.errorMessage,
3964
+ durationMs: report.durationMs,
3965
+ });
3966
+ return { stepIndex: pair.selected, merged: true };
3967
+ }
1603
3968
  /**
1604
- * Tie the reporting proxy to the session whose hook minted the step's call id,
1605
- * so that session's replay steps run on this proxy's upstream and not on
1606
- * another session's (replay/executor.ts). Searches every session, not just the
1607
- * one {@link sessionForProxyStep} records into: with two sessions open, that
1608
- * guess is exactly what cannot be trusted.
3969
+ * The session, run and call a report belongs to, or undefined when no hook
3970
+ * saw it. An injected id is looked up everywhere first; then the
3971
+ * fingerprint, across every session's current and just-sealed turn.
1609
3972
  */
1610
- learnProxySession(report) {
1611
- if (!report.proxyId || !report.callId)
1612
- return;
1613
- for (const session of this.sessions.values()) {
1614
- if (session.run?.correlations.has(report.callId)) {
1615
- this.replay.work.bindProxy(report.proxyId, session.sessionId);
1616
- return;
3973
+ ownerOf(report) {
3974
+ if (report.callId) {
3975
+ for (const session of this.sessions.values()) {
3976
+ for (const run of [session.run, session.previous]) {
3977
+ const correlation = run?.correlations.get(report.callId);
3978
+ if (run && correlation) {
3979
+ // An echoed id: this session's calls to the server do reach a
3980
+ // proxy that strips it.
3981
+ session.noInjection?.delete(correlation.serverName);
3982
+ return { session, run, correlation };
3983
+ }
3984
+ }
1617
3985
  }
1618
3986
  }
3987
+ return this.matchByFingerprint(report);
3988
+ }
3989
+ /**
3990
+ * The fingerprint a report joins by, and whether it is over the raw
3991
+ * arguments. A proxy that sends `argsFingerprint` hashed them before
3992
+ * redaction and clipping, exactly as the hook side hashes `tool_input`; an
3993
+ * older one did not, and its report's `args` are hashed as reported.
3994
+ */
3995
+ reportKey(report) {
3996
+ const sent = report.argsFingerprint;
3997
+ if (typeof sent === "string" && sent.length > 0)
3998
+ return { key: sent, raw: true };
3999
+ return { key: fingerprint(report.serverName, report.toolName, report.args), raw: false };
4000
+ }
4001
+ /**
4002
+ * Tie a reporting proxy to the session whose call it just carried, so that
4003
+ * session's replay steps run on this proxy's upstream and not on another
4004
+ * session's (replay/executor.ts), and so its standalone reports are recorded
4005
+ * there. A join is the only evidence of which session a proxy serves.
4006
+ */
4007
+ bindProxy(proxyId, sessionId) {
4008
+ this.proxySessions.set(proxyId, sessionId);
4009
+ this.replay.work.bindProxy(proxyId, sessionId);
4010
+ }
4011
+ /** Sessions, most recently heard from first. */
4012
+ recentSessions() {
4013
+ return [...this.sessions.values()].sort((a, b) => b.lastSeenAt - a.lastSeenAt);
1619
4014
  }
1620
4015
  /**
1621
- * Which session a proxy's step belongs to.
4016
+ * Which session a standalone proxy step belongs to.
1622
4017
  *
1623
4018
  * A proxy has no session id of its own — stdio MCP servers are never told one
1624
- * — so the control server decides. The order matters: preferring *any known*
4019
+ * — so the control server decides: the session this proxy's earlier calls
4020
+ * were joined to, else the most recently active session with a turn in
4021
+ * progress, else the most recently heard session. Preferring *any known*
1625
4022
  * session over minting a new one is what stops a step that lands between
1626
- * `SessionStart` and the first prompt from opening a second, parallel run,
1627
- * which would split one session's steps across two recordings.
1628
- */
1629
- sessionForProxyStep() {
1630
- const withOpenRun = [...this.sessions.values()].find((s) => s.run && !s.run.finished);
1631
- if (withOpenRun)
1632
- return withOpenRun;
1633
- const known = [...this.sessions.values()][0];
4023
+ * `SessionStart` and the first prompt from opening a second, parallel run.
4024
+ */
4025
+ sessionForProxyStep(report) {
4026
+ const bound = report.proxyId ? this.proxySessions.get(report.proxyId) : undefined;
4027
+ const known = bound ? this.sessions.get(bound) : undefined;
1634
4028
  if (known)
1635
4029
  return known;
4030
+ const recent = this.recentSessions();
4031
+ const active = recent.find((s) => this.isActive(s));
4032
+ if (active)
4033
+ return active;
4034
+ if (recent[0])
4035
+ return recent[0];
1636
4036
  // No hook has ever fired: an SDK or non-Claude host. We own the session.
1637
4037
  return this.ensureSession({ session_id: this.sessionId, cwd: this.opts.cwd });
1638
4038
  }
1639
- matchByFingerprint(run, report) {
1640
- const key = fingerprint(report.serverName, report.toolName, report.args);
1641
- const now = Date.now();
1642
- let best;
1643
- for (const candidate of run.correlations.values()) {
1644
- if (candidate.settled)
1645
- continue;
1646
- if (candidate.fingerprint !== key)
1647
- continue;
1648
- if (now - candidate.createdAt > FINGERPRINT_WINDOW_MS)
1649
- continue;
1650
- // Oldest unsettled match wins — FIFO is the best guess available, and it
1651
- // is a guess: two identical concurrent calls are indistinguishable here.
1652
- if (!best || candidate.createdAt < best.createdAt)
1653
- best = candidate;
4039
+ /**
4040
+ * The call a report joins by fingerprint, across every session.
4041
+ *
4042
+ * Among the calls whose PostToolUse has arrived — they certainly ran — the
4043
+ * oldest. Otherwise the newest call made before the proxy started this one:
4044
+ * a call the user refused never runs and is never reported, and must not
4045
+ * take the report of the identical call they approved after it; and a call
4046
+ * made after the start is some other call. Only calls made within
4047
+ * {@link CONSENT_WINDOW_MS} of the proxy starting the call qualify — the gap
4048
+ * is the user reading a permission prompt — and one made up to
4049
+ * {@link FINGERPRINT_SKEW_MS} after it only when nothing earlier does.
4050
+ *
4051
+ * A report for a call the hook's fallback already recorded (the report was
4052
+ * slower than {@link PROXY_REPORT_GRACE_MS}) finds that call too, and is
4053
+ * deduped rather than recorded a second time.
4054
+ */
4055
+ matchByFingerprint(report) {
4056
+ const { key, raw } = this.reportKey(report);
4057
+ const started = Number.isFinite(report.startedAt) && report.startedAt > 0 ? report.startedAt : Date.now();
4058
+ const open = [];
4059
+ const recorded = [];
4060
+ for (const session of this.sessions.values()) {
4061
+ for (const run of [session.run, session.previous]) {
4062
+ if (!run)
4063
+ continue;
4064
+ for (const correlation of run.correlations.values()) {
4065
+ const theirs = raw ? correlation.fingerprint : correlation.reportedFingerprint;
4066
+ if (theirs !== key || correlation.reported)
4067
+ continue;
4068
+ if (correlation.createdAt > started + FINGERPRINT_SKEW_MS)
4069
+ continue;
4070
+ if (started - correlation.createdAt > CONSENT_WINDOW_MS)
4071
+ continue;
4072
+ (correlation.settled ? recorded : open).push({ session, run, correlation });
4073
+ }
4074
+ }
4075
+ }
4076
+ const ran = open
4077
+ .filter((h) => h.correlation.postSeen)
4078
+ .sort((a, b) => a.correlation.createdAt - b.correlation.createdAt);
4079
+ if (ran[0])
4080
+ return ran[0];
4081
+ open.sort((a, b) => b.correlation.createdAt - a.correlation.createdAt);
4082
+ // Hook and proxy share this machine's clock, and the host sends a call
4083
+ // only after its PreToolUse was answered: a PreToolUse made *after* the
4084
+ // proxy started this call cannot be it. It may be the identical call of
4085
+ // another session whose user is still reading the permission prompt —
4086
+ // which, taken, would put this step in that session's recording and bind
4087
+ // this proxy to it. The skew window is for clocks that disagree, so it is
4088
+ // used only when no call made before the start qualifies.
4089
+ const before = open.find((h) => h.correlation.createdAt <= started);
4090
+ if (before)
4091
+ return before;
4092
+ if (open[0])
4093
+ return open[0];
4094
+ const settled = recorded.sort((a, b) => a.correlation.createdAt - b.correlation.createdAt)[0];
4095
+ if (settled || raw)
4096
+ return settled;
4097
+ return this.soleCandidate(report, started);
4098
+ }
4099
+ /**
4100
+ * A report with no `argsFingerprint` — from a proxy older than this
4101
+ * recorder — whose arguments hash to no call: joined to the one call of the
4102
+ * same server and tool that no report has claimed yet, when there is exactly
4103
+ * one (UPG-8).
4104
+ *
4105
+ * Such a proxy redacted its copy of the arguments by its own, older rules,
4106
+ * so a value those rules kept and today's redact (a DSN's password, a
4107
+ * token in a URL, Basic auth) never hashes the same on the two sides.
4108
+ * Recorded standalone, the call would be in the run twice: once from this
4109
+ * report, once from its PostToolUse's fallback. Two candidates or more are
4110
+ * never guessed between.
4111
+ */
4112
+ soleCandidate(report, started) {
4113
+ let found;
4114
+ for (const session of this.sessions.values()) {
4115
+ for (const run of [session.run, session.previous]) {
4116
+ if (!run)
4117
+ continue;
4118
+ for (const correlation of run.correlations.values()) {
4119
+ if (correlation.reported)
4120
+ continue;
4121
+ if (correlation.serverName !== report.serverName || correlation.toolName !== report.toolName)
4122
+ continue;
4123
+ if (correlation.createdAt > started + FINGERPRINT_SKEW_MS)
4124
+ continue;
4125
+ if (started - correlation.createdAt > CONSENT_WINDOW_MS)
4126
+ continue;
4127
+ if (found)
4128
+ return undefined;
4129
+ found = { session, run, correlation };
4130
+ }
4131
+ }
4132
+ }
4133
+ if (found) {
4134
+ logDetail("proxy.step.legacy_join", {
4135
+ tool: report.qualifiedName,
4136
+ why: "an older proxy's report matched no fingerprint — joined to the one open call of its tool",
4137
+ });
1654
4138
  }
1655
- return best;
4139
+ return found;
1656
4140
  }
1657
4141
  /** Record the merged pair for a correlation and latch it against re-entry. */
1658
4142
  settle(run, correlation, data) {