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