@basein/runner 0.1.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 (100) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +276 -0
  3. package/dist/auth/client.d.ts +85 -0
  4. package/dist/auth/client.js +284 -0
  5. package/dist/bin/bir-hooks.d.ts +48 -0
  6. package/dist/bin/bir-hooks.js +201 -0
  7. package/dist/bin/bir-proxy.d.ts +45 -0
  8. package/dist/bin/bir-proxy.js +207 -0
  9. package/dist/bin/bir-scenario.d.ts +24 -0
  10. package/dist/bin/bir-scenario.js +177 -0
  11. package/dist/bin/bir.d.ts +21 -0
  12. package/dist/bin/bir.js +876 -0
  13. package/dist/config/adapters/claude-code.d.ts +76 -0
  14. package/dist/config/adapters/claude-code.js +181 -0
  15. package/dist/config/adapters/generic.d.ts +17 -0
  16. package/dist/config/adapters/generic.js +36 -0
  17. package/dist/config/generate.d.ts +127 -0
  18. package/dist/config/generate.js +114 -0
  19. package/dist/config/resolve.d.ts +68 -0
  20. package/dist/config/resolve.js +132 -0
  21. package/dist/control/client.d.ts +56 -0
  22. package/dist/control/client.js +86 -0
  23. package/dist/control/correlation.d.ts +86 -0
  24. package/dist/control/correlation.js +0 -0
  25. package/dist/control/discovery.d.ts +50 -0
  26. package/dist/control/discovery.js +123 -0
  27. package/dist/control/ordering.d.ts +38 -0
  28. package/dist/control/ordering.js +44 -0
  29. package/dist/control/paths.d.ts +32 -0
  30. package/dist/control/paths.js +56 -0
  31. package/dist/control/server.d.ts +272 -0
  32. package/dist/control/server.js +1131 -0
  33. package/dist/control/transcript.d.ts +75 -0
  34. package/dist/control/transcript.js +241 -0
  35. package/dist/index.d.ts +37 -0
  36. package/dist/index.js +32 -0
  37. package/dist/jsonrpc/framing.d.ts +49 -0
  38. package/dist/jsonrpc/framing.js +143 -0
  39. package/dist/jsonrpc/types.d.ts +52 -0
  40. package/dist/jsonrpc/types.js +46 -0
  41. package/dist/proxy/intercept.d.ts +55 -0
  42. package/dist/proxy/intercept.js +147 -0
  43. package/dist/proxy/relay.d.ts +97 -0
  44. package/dist/proxy/relay.js +166 -0
  45. package/dist/proxy/session.d.ts +116 -0
  46. package/dist/proxy/session.js +319 -0
  47. package/dist/record/housekeeping.d.ts +34 -0
  48. package/dist/record/housekeeping.js +39 -0
  49. package/dist/record/queue.d.ts +48 -0
  50. package/dist/record/queue.js +96 -0
  51. package/dist/record/recorder.d.ts +111 -0
  52. package/dist/record/recorder.js +39 -0
  53. package/dist/record/redact.d.ts +37 -0
  54. package/dist/record/redact.js +119 -0
  55. package/dist/record/remote-recorder.d.ts +110 -0
  56. package/dist/record/remote-recorder.js +301 -0
  57. package/dist/record/truncate.d.ts +36 -0
  58. package/dist/record/truncate.js +85 -0
  59. package/dist/replay/bundle.d.ts +36 -0
  60. package/dist/replay/bundle.js +89 -0
  61. package/dist/replay/controller.d.ts +300 -0
  62. package/dist/replay/controller.js +807 -0
  63. package/dist/replay/coverage.d.ts +41 -0
  64. package/dist/replay/coverage.js +56 -0
  65. package/dist/replay/derive.d.ts +58 -0
  66. package/dist/replay/derive.js +166 -0
  67. package/dist/replay/executor.d.ts +78 -0
  68. package/dist/replay/executor.js +233 -0
  69. package/dist/replay/logic.d.ts +31 -0
  70. package/dist/replay/logic.js +50 -0
  71. package/dist/replay/plan.d.ts +181 -0
  72. package/dist/replay/plan.js +397 -0
  73. package/dist/replay/pricing.d.ts +41 -0
  74. package/dist/replay/pricing.js +76 -0
  75. package/dist/replay/source-run.d.ts +50 -0
  76. package/dist/replay/source-run.js +98 -0
  77. package/dist/replay/tool-error.d.ts +22 -0
  78. package/dist/replay/tool-error.js +60 -0
  79. package/dist/replay/types.d.ts +116 -0
  80. package/dist/replay/types.js +35 -0
  81. package/dist/upstream/client.d.ts +78 -0
  82. package/dist/upstream/client.js +114 -0
  83. package/dist/upstream/http-client.d.ts +78 -0
  84. package/dist/upstream/http-client.js +261 -0
  85. package/dist/upstream/lazy-client.d.ts +31 -0
  86. package/dist/upstream/lazy-client.js +53 -0
  87. package/dist/upstream/stdio-client.d.ts +57 -0
  88. package/dist/upstream/stdio-client.js +203 -0
  89. package/dist/util/log.d.ts +27 -0
  90. package/dist/util/log.js +51 -0
  91. package/dist/util/version.d.ts +2 -0
  92. package/dist/util/version.js +40 -0
  93. package/docs/BaseInstRunner.md +621 -0
  94. package/docs/calculatedReplay.md +1185 -0
  95. package/docs/calculatedReplayGuide.md +448 -0
  96. package/docs/installRun.md +413 -0
  97. package/docs/mcpmark.md +752 -0
  98. package/docs/quickstart.md +201 -0
  99. package/docs/t-bench.md +394 -0
  100. package/package.json +56 -0
@@ -0,0 +1,1131 @@
1
+ /**
2
+ * control/server — the local control plane (Phase 5) and the Tier 1 recorder.
3
+ *
4
+ * WHY THIS EXISTS AT ALL (§3.2). Three proxies recording independently would
5
+ * produce three interleaved step streams with no shared ordering, and would
6
+ * double-record every call the hook also sees. One control server gives one
7
+ * `stepIndex` sequence, one run lifecycle, one HTTP chain to the recorder, and
8
+ * one place to dedupe.
9
+ *
10
+ * It is two things in one process, deliberately: the **Claude Code hook
11
+ * receiver** (Claude Code POSTs each lifecycle event to an HTTP hook) and the
12
+ * **control plane** the proxies report to. They share the run, so they must
13
+ * share the process.
14
+ *
15
+ * RUN BOUNDARIES follow RRepeat's: a Claude Code *session* holds many prompts,
16
+ * and each `UserPromptSubmit → Stop` turn is one run. Creation is deferred to
17
+ * the first prompt so the server's similar-prompt detection runs against the
18
+ * real prompt rather than an empty string; a proxy step that arrives before any
19
+ * prompt (an SDK session with no hooks) lazily opens a run with empty input,
20
+ * which is honest rather than lossy.
21
+ *
22
+ * SECURITY (§9): binds 127.0.0.1 only, requires the discovery file's bearer
23
+ * token on every route, and never echoes the token back. A loopback port that
24
+ * accepts unauthenticated step reports is a local exfiltration channel.
25
+ */
26
+ import { createServer } from "node:http";
27
+ import { randomBytes, randomUUID } from "node:crypto";
28
+ import { FINGERPRINT_WINDOW_MS, fingerprint, newCallId, parseQualifiedName, qualifyToolName, } from "./correlation.js";
29
+ import { StepIndexAllocator } from "./ordering.js";
30
+ import { contextForToolUse, markTranscriptUsage, settledLastAssistantText, usageSince, } from "./transcript.js";
31
+ import { isHousekeeping } from "../record/housekeeping.js";
32
+ import { redact } from "../record/redact.js";
33
+ import { serializeCapped } from "../record/truncate.js";
34
+ import { StepQueue } from "../record/queue.js";
35
+ import { NullRecorder, isMatchAware, isScenarioReporter, } from "../record/recorder.js";
36
+ import { ReplayController, POLL_HOLD_MS, } from "../replay/controller.js";
37
+ import { calculateCostUsd } from "../replay/pricing.js";
38
+ import { logDetail, logLine, errText } from "../util/log.js";
39
+ import { packageVersion } from "../util/version.js";
40
+ /** How long a `/tool/post` waits for the proxy's own report before recording its own view. */
41
+ const PROXY_REPORT_GRACE_MS = 1_500;
42
+ export class ControlServer {
43
+ token;
44
+ sessionId = "birsess_" + randomUUID();
45
+ opts;
46
+ recorder;
47
+ queue;
48
+ sessions = new Map();
49
+ /** Config keys known to be wrapped: from install config plus live registrations. */
50
+ wrapped = new Set();
51
+ /** serverName → registration facts, for `bir doctor`. */
52
+ registrations = new Map();
53
+ server;
54
+ address;
55
+ lossy = false;
56
+ /** Seals whose answer text is still being waited for. Awaited before any flush. */
57
+ pendingSeals = [];
58
+ /** Calculated replay. Inert unless `opts.replay.enabled`. */
59
+ replay;
60
+ constructor(opts) {
61
+ this.opts = opts;
62
+ this.recorder = opts.recorder;
63
+ this.token = opts.token ?? randomBytes(32).toString("hex");
64
+ for (const name of opts.wrappedServers ?? [])
65
+ this.wrapped.add(name);
66
+ this.replay = new ReplayController(opts.replay ?? { enabled: false, minSimilarity: 1 });
67
+ this.queue = new StepQueue({
68
+ onDrop: (dropped) => {
69
+ // §10: drop oldest, flag the run lossy. The flag is louder than a
70
+ // verbose line because a lossy run must never be mistaken for a whole
71
+ // one — see the README's note on `metadata.lossy` and open question 1.
72
+ if (!this.lossy)
73
+ logLine("run.lossy", { why: "step queue overflowed", dropped });
74
+ this.lossy = true;
75
+ },
76
+ onError: (err) => logLine("control.step_failed", { error: errText(err) }),
77
+ });
78
+ }
79
+ // ── lifecycle ────────────────────────────────────────────────────────────
80
+ listen() {
81
+ const preferred = this.opts.port ?? 0;
82
+ return new Promise((resolve, reject) => {
83
+ const server = createServer((req, res) => void this.handle(req, res));
84
+ this.server = server;
85
+ const bind = (port, allowFallback) => {
86
+ server.removeAllListeners("error");
87
+ server.once("error", (err) => {
88
+ if (allowFallback && (err.code === "EADDRINUSE" || err.code === "EACCES")) {
89
+ logLine("control.port_busy", { port, why: "falling back to an ephemeral port" });
90
+ bind(0, false);
91
+ return;
92
+ }
93
+ reject(err);
94
+ });
95
+ server.listen(port, "127.0.0.1", () => {
96
+ const addr = server.address();
97
+ const actual = typeof addr === "object" && addr ? addr.port : port;
98
+ this.address = {
99
+ url: `http://127.0.0.1:${actual}`,
100
+ port: actual,
101
+ token: this.token,
102
+ sessionId: this.sessionId,
103
+ };
104
+ resolve(this.address);
105
+ });
106
+ };
107
+ bind(preferred, preferred !== 0);
108
+ });
109
+ }
110
+ async close() {
111
+ // Release parked pollers first: a `/proxy/poll` held for 25 s would otherwise
112
+ // keep the HTTP server's `close` callback waiting for exactly that long.
113
+ this.replay.close();
114
+ for (const session of this.sessions.values()) {
115
+ if (session.run && !session.run.finished)
116
+ await this.finalizeRun(session);
117
+ }
118
+ await this.queue.flush();
119
+ const server = this.server;
120
+ if (!server)
121
+ return;
122
+ await new Promise((resolve) => server.close(() => resolve()));
123
+ this.server = undefined;
124
+ }
125
+ /** Facts for `bir doctor` / `GET /health`. */
126
+ health() {
127
+ const sessions = [...this.sessions.values()].map((s) => ({
128
+ sessionId: s.sessionId,
129
+ runId: s.run?.runId,
130
+ steps: s.run?.ordering.next ?? 0,
131
+ recording: s.run?.recording ?? false,
132
+ replay: s.run?.replay
133
+ ? {
134
+ scenarioId: s.run.replay.scenarioId,
135
+ mode: s.run.replay.mode,
136
+ similarity: s.run.replay.similarity,
137
+ outcome: s.run.replay.outcome,
138
+ stepsPlanned: s.run.replay.stepsPlanned,
139
+ stepsPinned: s.run.replay.stepsPinned,
140
+ armed: Boolean(s.run.replay.plan) && !s.run.replay.retired,
141
+ }
142
+ : null,
143
+ }));
144
+ return {
145
+ ok: true,
146
+ recorder: "baseinstrunner",
147
+ tier: "bound",
148
+ // The authoritative answer to "is anything actually being saved?".
149
+ recording: this.opts.recording ?? !(this.recorder instanceof NullRecorder),
150
+ authUrl: process.env.BIR_AUTH_URL ?? null,
151
+ sessionId: this.sessionId,
152
+ pid: process.pid,
153
+ cwd: this.opts.cwd,
154
+ url: this.address?.url,
155
+ wrappedServers: [...this.wrapped],
156
+ hooksVersion: packageVersion(),
157
+ registeredProxies: [...this.registrations].map(([serverName, r]) => ({
158
+ serverName,
159
+ pid: r.pid,
160
+ cwd: r.cwd,
161
+ registeredAt: r.at,
162
+ version: r.version,
163
+ })),
164
+ // A capability this sharp must never be discoverable only by reading a log
165
+ // (docs/calculatedReplay.md §13.2, mitigation 3).
166
+ replay: {
167
+ enabled: this.replay.enabled,
168
+ minSimilarity: this.opts.replay?.minSimilarity ?? null,
169
+ allowServers: this.opts.replay?.allowServers ? [...this.opts.replay.allowServers] : null,
170
+ deriveKey: Boolean(this.opts.replay?.apiKey ?? process.env.ANTHROPIC_API_KEY),
171
+ pollingProxies: this.replay.work.pollingServers(),
172
+ },
173
+ lossy: this.lossy,
174
+ queued: this.queue.size,
175
+ sessions,
176
+ };
177
+ }
178
+ // ── HTTP plumbing ────────────────────────────────────────────────────────
179
+ async handle(req, res) {
180
+ const route = (req.url ?? "").split("?")[0];
181
+ try {
182
+ const auth = req.headers["authorization"];
183
+ if (auth !== `Bearer ${this.token}`) {
184
+ this.send(res, 401, { error: "unauthorized" });
185
+ return;
186
+ }
187
+ if (route === "/health") {
188
+ this.send(res, 200, this.health());
189
+ return;
190
+ }
191
+ if (req.method !== "POST") {
192
+ this.send(res, 405, { error: "method_not_allowed" });
193
+ return;
194
+ }
195
+ const body = await this.readJson(req);
196
+ switch (route) {
197
+ case "/session/start":
198
+ this.send(res, 200, this.onSessionStart(body));
199
+ return;
200
+ case "/session/prompt":
201
+ this.send(res, 200, await this.onPrompt(body));
202
+ return;
203
+ case "/tool/pre":
204
+ this.send(res, 200, await this.onToolPre(body));
205
+ return;
206
+ case "/tool/post":
207
+ this.send(res, 200, this.onToolPost(body));
208
+ return;
209
+ case "/session/agent":
210
+ this.send(res, 200, this.onAgent(body));
211
+ return;
212
+ case "/session/stop":
213
+ this.send(res, 200, this.onStop(body));
214
+ return;
215
+ case "/session/end":
216
+ this.send(res, 200, await this.onSessionEnd(body));
217
+ return;
218
+ case "/proxy/register":
219
+ this.send(res, 200, this.onProxyRegister(body));
220
+ return;
221
+ case "/proxy/step":
222
+ this.send(res, 200, this.onProxyStep(body));
223
+ return;
224
+ case "/proxy/poll": {
225
+ // A long poll is the one route whose client can vanish while we hold
226
+ // it. Tie an abort signal to the request so a dead proxy's parked poll
227
+ // is dropped rather than being handed work it can never run.
228
+ const gone = new AbortController();
229
+ const onClose = () => gone.abort();
230
+ res.once("close", onClose);
231
+ try {
232
+ const answer = await this.onProxyPoll(body, gone.signal);
233
+ if (gone.signal.aborted)
234
+ return; // nobody left to answer
235
+ this.send(res, 200, answer);
236
+ }
237
+ finally {
238
+ res.off("close", onClose);
239
+ }
240
+ return;
241
+ }
242
+ case "/proxy/result":
243
+ this.send(res, 200, this.onProxyResult(body));
244
+ return;
245
+ case "/scenario/run":
246
+ this.send(res, 200, await this.onScenarioRun());
247
+ return;
248
+ case "/scenario/replay":
249
+ this.send(res, 200, await this.onScenarioReplay(body));
250
+ return;
251
+ default:
252
+ this.send(res, 404, { error: "not_found" });
253
+ return;
254
+ }
255
+ }
256
+ catch (err) {
257
+ // Rule 4 of §1: never be the reason a host session fails. A 200 with an
258
+ // empty hook object lets Claude Code proceed exactly as if we were absent.
259
+ logLine("control.route_failed", { route, error: errText(err) });
260
+ this.send(res, 200, {});
261
+ }
262
+ }
263
+ send(res, status, body) {
264
+ const text = JSON.stringify(body ?? {});
265
+ res.writeHead(status, {
266
+ "content-type": "application/json",
267
+ "content-length": Buffer.byteLength(text),
268
+ });
269
+ res.end(text);
270
+ }
271
+ readJson(req) {
272
+ return new Promise((resolve, reject) => {
273
+ const chunks = [];
274
+ let bytes = 0;
275
+ req.on("data", (chunk) => {
276
+ bytes += chunk.length;
277
+ if (bytes > 64 * 1024 * 1024) {
278
+ reject(new Error("request body too large"));
279
+ req.destroy();
280
+ return;
281
+ }
282
+ chunks.push(chunk);
283
+ });
284
+ req.on("end", () => {
285
+ const raw = Buffer.concat(chunks).toString("utf8");
286
+ if (!raw.trim()) {
287
+ resolve({});
288
+ return;
289
+ }
290
+ try {
291
+ resolve(JSON.parse(raw));
292
+ }
293
+ catch (err) {
294
+ reject(err instanceof Error ? err : new Error(String(err)));
295
+ }
296
+ });
297
+ req.on("error", reject);
298
+ });
299
+ }
300
+ // ── session / run state ──────────────────────────────────────────────────
301
+ ensureSession(payload) {
302
+ const sessionId = payload.session_id ?? "unknown-session";
303
+ let session = this.sessions.get(sessionId);
304
+ if (!session) {
305
+ session = { sessionId };
306
+ this.sessions.set(sessionId, session);
307
+ }
308
+ if (payload.transcript_path)
309
+ session.transcriptPath = payload.transcript_path;
310
+ if (payload.cwd)
311
+ session.cwd = payload.cwd;
312
+ return session;
313
+ }
314
+ /** Open a run for `session`, or return the open one. */
315
+ ensureRun(session, input = "") {
316
+ if (session.run && !session.run.finished)
317
+ return session.run;
318
+ const runId = this.recorder.startRun(input, this.runMetadata(session));
319
+ const run = {
320
+ runId,
321
+ input,
322
+ startedAtMs: Date.now(),
323
+ ordering: new StepIndexAllocator(),
324
+ recording: true,
325
+ finished: false,
326
+ promptSeen: input.length > 0,
327
+ builtIns: new Map(),
328
+ correlations: new Map(),
329
+ // Taken here, beside `startedAtMs`, so the cost window and the duration
330
+ // window are the same window (docs/calculatedReplay.md §11.4).
331
+ usageMark: markTranscriptUsage(session.transcriptPath),
332
+ };
333
+ session.run = run;
334
+ logLine("run.start", { run: runId, sess: session.sessionId, tier: "bound" });
335
+ // Started here so the round trip overlaps whatever the caller does next;
336
+ // `onPrompt` awaits the same memoized promise under its own budget.
337
+ void this.watchForMatch(run);
338
+ return run;
339
+ }
340
+ runMetadata(session) {
341
+ return {
342
+ recorder: "baseinstrunner",
343
+ tier: "bound",
344
+ host: this.opts.host ?? { app: "claude-code" },
345
+ wrappedServers: [...this.wrapped],
346
+ // Known-at-start value. A run that turns lossy later says so on the audit
347
+ // log; carrying it here too would need a metadata patch the service does
348
+ // not expose in v1 (docs §13, open question 1).
349
+ lossy: this.lossy,
350
+ sessionId: session.sessionId,
351
+ cwd: session.cwd ?? this.opts.cwd,
352
+ };
353
+ }
354
+ /**
355
+ * A similar-prompt hit means the service created no run, so every later step
356
+ * post would 404. Stop recording it — that half is v1 and is mandatory.
357
+ *
358
+ * What replaces the recording is v2 (docs/calculatedReplay.md): the matched
359
+ * scenario is put through the gate ladder and, if every gate passes, armed as a
360
+ * plan. Memoized, because `ensureRun` is reached from five different hooks and
361
+ * arming twice would run the scenario twice.
362
+ *
363
+ * Resolves with the steering directive to inject, or undefined.
364
+ */
365
+ watchForMatch(run, prompt = "") {
366
+ if (run.matchWatch)
367
+ return run.matchWatch;
368
+ if (!isMatchAware(this.recorder))
369
+ return Promise.resolve(undefined);
370
+ const pending = this.recorder.getMatch(run.runId);
371
+ run.matchWatch = this.replay
372
+ .awaitMatch(pending)
373
+ .then((match) => {
374
+ if (!match)
375
+ return undefined;
376
+ run.recording = false;
377
+ logLine("run.matched", {
378
+ run: run.runId,
379
+ matchedRun: match.runId,
380
+ scenario: match.scenarioId ?? undefined,
381
+ similarity: match.similarity,
382
+ why: "similar prompt — the service kept its own run; not recording this one",
383
+ });
384
+ run.replay = this.replay.arm(match, prompt || run.input, this.wrapped);
385
+ return this.replay.directiveFor(run.replay);
386
+ })
387
+ .catch((err) => {
388
+ // Replay is an optimisation; a broken one must never cost a turn.
389
+ logLine("replay.arm_failed", { run: run.runId, error: errText(err) });
390
+ return undefined;
391
+ });
392
+ return run.matchWatch;
393
+ }
394
+ /**
395
+ * Close the run's books, without waiting for the network.
396
+ *
397
+ * SEALING IS SPLIT FROM FLUSHING ON PURPOSE. `Stop` — the end of the turn, and
398
+ * the run boundary this whole design names — is a synchronous hook the session
399
+ * is waiting on, so it must not block on an HTTP flush. And `SessionEnd`, the
400
+ * event that used to be the *only* thing that finished a run, does not reliably
401
+ * arrive at all: it fires as the host process is exiting and loses the race.
402
+ * A run whose completeness depends on winning that race is a run that is
403
+ * routinely left half-open, which is what happened in practice.
404
+ *
405
+ * Idempotent: the second caller sees `finished` and does nothing.
406
+ */
407
+ sealRun(session) {
408
+ const run = session.run;
409
+ if (!run || run.finished)
410
+ return;
411
+ run.finished = true;
412
+ for (const correlation of run.correlations.values()) {
413
+ if (correlation.fallback)
414
+ clearTimeout(correlation.fallback);
415
+ if (correlation.threadFallback)
416
+ clearTimeout(correlation.threadFallback);
417
+ }
418
+ const durationMs = Date.now() - run.startedAtMs;
419
+ // What this turn cost the model. Computed once, used twice: it is this run's
420
+ // `originalCostUsd` when recorded, and the `sessionCostUsd` of a matched
421
+ // turn's execution report.
422
+ const cost = this.runCost(session, run);
423
+ // A matched turn records nothing, but it still owes the ledger a number —
424
+ // including when it declined to steer, which is a *baseline* sample and is
425
+ // what every saving is measured against (docs/calculatedReplay.md §11).
426
+ this.reportExecution(run, durationMs, cost);
427
+ if (run.recording) {
428
+ const transcriptPath = session.transcriptPath;
429
+ // Resolve the answer *outside* the queue: the wait below is up to two
430
+ // seconds, and the queue is serial — blocking it here would delay the
431
+ // first step of the user's next turn by that much.
432
+ this.pendingSeals.push(settledLastAssistantText(transcriptPath)
433
+ .catch(() => "")
434
+ .then((answer) => {
435
+ this.queue.push(() => {
436
+ if (answer) {
437
+ this.recorder.recordFinalAnswer(run.runId, run.ordering.allocate(), { answer });
438
+ }
439
+ // WITHOUT `costUsd` THE SERVICE STORES ZERO, and a zero here is
440
+ // not harmless: `original_cost_usd` is what the UI shows as
441
+ // "LLM $/run", and it is the service's fallback baseline for a
442
+ // scenario that has no samples yet. A run recorded at $0 makes
443
+ // every later saving measured against nothing.
444
+ this.recorder.finishRun(run.runId, answer || undefined, {
445
+ durationMs,
446
+ costUsd: cost.measured ? cost.usd : undefined,
447
+ });
448
+ });
449
+ }));
450
+ }
451
+ logLine("run.finish", {
452
+ run: run.runId,
453
+ steps: run.ordering.next,
454
+ durationMs,
455
+ lossy: this.lossy,
456
+ recorded: run.recording,
457
+ });
458
+ }
459
+ /**
460
+ * Report what a matched turn cost (docs/calculatedReplay.md §11).
461
+ *
462
+ * The live session's tokens come from the transcript **delta** since this run
463
+ * opened — never the whole file, which would bill run 3 for runs 1+2+3 and hand
464
+ * the ledger a baseline several times the truth (§11.4). A run with no
465
+ * watermark (one opened lazily by a proxy step, before any prompt) is reported
466
+ * `measured: false`: saying "unmeasured" is better than quietly inflating a
467
+ * baseline.
468
+ *
469
+ * Queued rather than awaited — `Stop` is a synchronous hook the session is
470
+ * waiting on, and a report is never worth a stall.
471
+ */
472
+ /**
473
+ * What this turn cost the model, from the transcript **delta** since the run
474
+ * opened — never the whole file, which would bill run 3 for runs 1+2+3
475
+ * (docs/calculatedReplay.md §11.4).
476
+ *
477
+ * No watermark means the delta is unknowable, so this reports `measured:
478
+ * false` and zero rather than a number that is confidently wrong.
479
+ */
480
+ runCost(session, run) {
481
+ const measurable = run.usageMark !== undefined;
482
+ if (!measurable) {
483
+ logLine("run.unmeasured", {
484
+ run: run.runId,
485
+ why: "no transcript watermark — this run opened before any hook carried a transcript path",
486
+ });
487
+ return { usd: 0, measured: false };
488
+ }
489
+ const deltas = usageSince(session.transcriptPath, run.usageMark);
490
+ const usd = deltas.reduce((sum, u) => sum + calculateCostUsd(u.model, u), 0);
491
+ return { usd, measured: deltas.length > 0 };
492
+ }
493
+ reportExecution(run, durationMs, cost) {
494
+ const state = run.replay;
495
+ if (!state || !isScenarioReporter(this.recorder))
496
+ return;
497
+ const report = this.replay.buildReport(state, {
498
+ sessionCostUsd: cost.usd,
499
+ measured: cost.measured,
500
+ durationMs,
501
+ prompt: run.input || undefined,
502
+ });
503
+ if (!report)
504
+ return;
505
+ const recorder = this.recorder;
506
+ this.queue.push(() => recorder.reportExecution(report));
507
+ }
508
+ /** Seal the run *and* wait for everything queued to reach the service. */
509
+ async finalizeRun(session) {
510
+ const runId = session.run?.runId;
511
+ this.sealRun(session);
512
+ // Seals resolve their answer text off-queue, so they must be awaited before
513
+ // the queue is flushed — otherwise the flush finishes before the finish call
514
+ // has even been enqueued.
515
+ await Promise.all(this.pendingSeals.splice(0, this.pendingSeals.length)).catch(() => undefined);
516
+ await this.queue.flush();
517
+ if (runId)
518
+ await this.recorder.flush(runId).catch(() => undefined);
519
+ }
520
+ // ── hook events ──────────────────────────────────────────────────────────
521
+ onSessionStart(payload) {
522
+ const session = this.ensureSession(payload);
523
+ // Run creation is deferred to the first prompt so the service's
524
+ // similar-prompt detection sees the real prompt, not "".
525
+ logLine("session.start", {
526
+ sess: session.sessionId,
527
+ cwd: session.cwd,
528
+ why: "run deferred to prompt",
529
+ });
530
+ return {};
531
+ }
532
+ async onPrompt(payload) {
533
+ const session = this.ensureSession(payload);
534
+ const input = payload.prompt ?? "";
535
+ const open = session.run && !session.run.finished ? session.run : undefined;
536
+ if (open?.promptSeen) {
537
+ // A second prompt in the same session: close the previous turn's run and
538
+ // open a fresh one. Claude Code carries no prompt id, so UserPromptSubmit
539
+ // → Stop is the only honest run boundary.
540
+ await this.finalizeRun(session);
541
+ session.run = undefined;
542
+ }
543
+ else if (open && open.ordering.next === 0) {
544
+ // A run was opened early by a proxy step (the host spawned MCP servers
545
+ // before the prompt landed) and has recorded nothing. Close it and start
546
+ // again with the real prompt: `input` is what the service embeds, and a
547
+ // run created with "" would never match anything. The empty run is below
548
+ // the service's recording threshold and is pruned there.
549
+ await this.finalizeRun(session);
550
+ session.run = undefined;
551
+ }
552
+ else if (open) {
553
+ // Steps are already recorded under an empty input. Keep them — losing real
554
+ // steps to gain a prompt is the wrong trade — and say so.
555
+ open.promptSeen = true;
556
+ open.input = input;
557
+ logLine("run.prompt_late", {
558
+ run: open.runId,
559
+ steps: open.ordering.next,
560
+ why: "steps preceded the prompt; this run's input stays empty",
561
+ });
562
+ return {};
563
+ }
564
+ const run = this.ensureRun(session, input);
565
+ run.promptSeen = true;
566
+ run.input = input;
567
+ logDetail("session.prompt", { sess: session.sessionId, run: run.runId, chars: input.length });
568
+ // The one moment a directive can be injected. Bounded by the match budget so
569
+ // a slow or unreachable service costs the user nothing but an ordinary turn
570
+ // (docs/calculatedReplay.md §10); `additionalContext` carries no `decision`,
571
+ // so the prompt still reaches the model either way.
572
+ const directive = await this.watchForMatch(run, input);
573
+ if (!directive)
574
+ return {};
575
+ return {
576
+ hookSpecificOutput: {
577
+ hookEventName: "UserPromptSubmit",
578
+ additionalContext: directive,
579
+ },
580
+ };
581
+ }
582
+ /**
583
+ * `PreToolUse`. Two shapes of answer:
584
+ *
585
+ * - **Wrapped MCP tool** → mint a call id and ask Claude Code to carry it in
586
+ * the tool's arguments, so the proxy's report and this hook's view join into
587
+ * one step (Phase 6). No step is recorded yet: the proxy owns the result and
588
+ * allocates the index when it reports.
589
+ * - **Anything else** (built-ins, unwrapped MCP) → allocate the pair now and
590
+ * record `tool_selected`. The hook is this step's only observer.
591
+ */
592
+ async onToolPre(payload) {
593
+ const session = this.ensureSession(payload);
594
+ const run = this.ensureRun(session);
595
+ const toolName = payload.tool_name ?? "unknown";
596
+ const toolUseId = payload.tool_use_id ?? "";
597
+ const modelArgs = payload.tool_input ?? {};
598
+ // The host's own bookkeeping is not a step of anything (see
599
+ // `record/housekeeping.ts`). Recorded, `ToolSearch` becomes step 0 of the
600
+ // calculated scenario: its generated reasoning is injected into every
601
+ // matched turn's steering directive, and because it is not an MCP tool it
602
+ // drags the whole scenario out of `direct` mode. Observed in production.
603
+ if (isHousekeeping(toolName)) {
604
+ logDetail("tool.pre.skipped", { run: run.runId, tool: toolName, why: "host housekeeping" });
605
+ return {};
606
+ }
607
+ const agentId = payload.agent_id ?? session.agentId;
608
+ const reasoning = contextForToolUse(session.transcriptPath, toolUseId);
609
+ const context = agentId ? `[agent ${agentId}] ${reasoning}`.trim() : reasoning;
610
+ // ── replay steering (docs/calculatedReplay.md §7) ────────────────────────
611
+ // Runs before anything else, because two of its four answers end the call.
612
+ let pinned;
613
+ if (run.replay?.plan && !run.replay.retired) {
614
+ const action = await this.replay.preTool(run.replay, toolName, toolUseId);
615
+ switch (action.kind) {
616
+ case "pin":
617
+ pinned = action.input;
618
+ break;
619
+ case "bash":
620
+ return {
621
+ hookSpecificOutput: {
622
+ hookEventName: "PreToolUse",
623
+ permissionDecision: "allow",
624
+ updatedInput: { command: action.command },
625
+ },
626
+ };
627
+ case "deny":
628
+ return {
629
+ hookSpecificOutput: {
630
+ hookEventName: "PreToolUse",
631
+ permissionDecision: "deny",
632
+ permissionDecisionReason: action.reason,
633
+ },
634
+ };
635
+ case "abort":
636
+ case "passthrough":
637
+ break;
638
+ }
639
+ }
640
+ // The arguments that will actually run — which is what must be correlated and
641
+ // recorded. Recording the arguments the model *proposed* rather than the ones
642
+ // that ran would put a lie in the corpus (§7.1).
643
+ const args = pinned ?? modelArgs;
644
+ const mcp = parseQualifiedName(toolName);
645
+ if (mcp && this.wrapped.has(mcp.serverName)) {
646
+ const correlation = {
647
+ callId: newCallId(),
648
+ toolUseId,
649
+ serverName: mcp.serverName,
650
+ toolName: mcp.toolName,
651
+ qualifiedName: toolName,
652
+ hookArgs: args,
653
+ context: context || undefined,
654
+ createdAt: Date.now(),
655
+ settled: false,
656
+ fingerprint: fingerprint(mcp.serverName, mcp.toolName, args),
657
+ };
658
+ run.correlations.set(correlation.callId, correlation);
659
+ logDetail("tool.pre.correlate", {
660
+ run: run.runId,
661
+ tool: toolName,
662
+ callId: correlation.callId,
663
+ mode: this.opts.noCorrelation ? "fingerprint" : "injected",
664
+ pinned: pinned ? true : undefined,
665
+ });
666
+ if (this.opts.noCorrelation) {
667
+ // No id ride-along, so no schema relaxation is needed either. The join
668
+ // happens on `(server, tool, hash(args))` — lossy under identical
669
+ // concurrent calls, and documented as such. Pinning still works: it is
670
+ // the `updatedInput` that steers, and only the *join* degrades here.
671
+ if (!pinned)
672
+ return { callId: correlation.callId, correlation: "fingerprint" };
673
+ return {
674
+ callId: correlation.callId,
675
+ correlation: "fingerprint",
676
+ hookSpecificOutput: {
677
+ hookEventName: "PreToolUse",
678
+ permissionDecision: "allow",
679
+ updatedInput: pinned,
680
+ },
681
+ };
682
+ }
683
+ return {
684
+ callId: correlation.callId,
685
+ correlation: "injected",
686
+ hookSpecificOutput: {
687
+ hookEventName: "PreToolUse",
688
+ // A pinned call must be allowed, or the host may drop `updatedInput`
689
+ // and the step would run with the model's own arguments.
690
+ permissionDecision: pinned ? "allow" : (this.opts.correlationDecision ?? "allow"),
691
+ updatedInput: { ...args, __bir_call_id__: correlation.callId },
692
+ },
693
+ };
694
+ }
695
+ // Built-in (or unwrapped MCP) step: the hook is the only observer.
696
+ const pair = run.ordering.allocatePair();
697
+ run.builtIns.set(toolUseId || `anon_${pair.selected}`, {
698
+ stepIndex: pair.selected,
699
+ responseIndex: pair.response,
700
+ toolName,
701
+ });
702
+ if (run.recording) {
703
+ this.queue.push(() => {
704
+ this.recorder.recordToolSelected(run.runId, pair.selected, {
705
+ toolName,
706
+ toolInput: serializeCapped(redact(args)),
707
+ context: context || undefined,
708
+ });
709
+ });
710
+ }
711
+ logDetail("tool.pre", { run: run.runId, tool: toolName, step: pair.selected });
712
+ if (pinned) {
713
+ return {
714
+ stepIndex: pair.selected,
715
+ hookSpecificOutput: {
716
+ hookEventName: "PreToolUse",
717
+ permissionDecision: "allow",
718
+ updatedInput: pinned,
719
+ },
720
+ };
721
+ }
722
+ return { stepIndex: pair.selected };
723
+ }
724
+ /**
725
+ * `PostToolUse` / `PostToolUseFailure`. For a built-in this closes the pair.
726
+ * For a correlated MCP call it normally does nothing — the proxy owns that
727
+ * result — but it arms a short fallback: if the proxy never reports (it died,
728
+ * or the call never reached it), the hook's own view is recorded after
729
+ * {@link PROXY_REPORT_GRACE_MS} rather than the step being lost entirely.
730
+ */
731
+ onToolPost(payload) {
732
+ const session = this.ensureSession(payload);
733
+ const run = this.ensureRun(session);
734
+ const toolUseId = payload.tool_use_id ?? "";
735
+ const toolName = payload.tool_name ?? "unknown";
736
+ // `onToolPre` opened no step for these, so closing one here would record a
737
+ // `tool_response` with no `tool_selected` before it.
738
+ if (isHousekeeping(toolName))
739
+ return {};
740
+ const failed = payload.hook_event_name === "PostToolUseFailure" || payload.error !== undefined;
741
+ // ── replay threading (docs/calculatedReplay.md §7.2) ─────────────────────
742
+ // `toolOutputLogic` was authored against the bytes that were *recorded* for
743
+ // this step. For a wrapped MCP step those are the proxy's whole
744
+ // `CallToolResult`; the hook's `tool_response` is Claude Code's rendering,
745
+ // and feeding the wrong one derives nothing — silently — so every later step
746
+ // reading `respParams` gets a wrong input. Thread from the source that
747
+ // recorded, and only fall back to the hook's view if no report arrives.
748
+ const replay = run.replay;
749
+ const pinnedHere = replay && this.replay.isPinned(replay, toolUseId);
750
+ const correlation = [...run.correlations.values()].find((c) => c.toolUseId && c.toolUseId === toolUseId);
751
+ if (pinnedHere && replay) {
752
+ if (correlation && this.replay.threadsFromProxy(replay, toolUseId)) {
753
+ correlation.threadToolUseId = toolUseId;
754
+ correlation.threadFallback = setTimeout(() => {
755
+ if (!this.replay.isPinned(replay, toolUseId))
756
+ return;
757
+ logLine("replay.thread_fallback", {
758
+ run: run.runId,
759
+ tool: toolName,
760
+ why: "no proxy report — threading the hook's view; shapes may differ",
761
+ });
762
+ this.replay.postTool(replay, toolUseId, serializeCapped(payload.tool_response ?? {}));
763
+ }, PROXY_REPORT_GRACE_MS);
764
+ correlation.threadFallback.unref?.();
765
+ }
766
+ else {
767
+ this.replay.postTool(replay, toolUseId, serializeCapped(payload.tool_response ?? {}));
768
+ }
769
+ }
770
+ if (correlation) {
771
+ if (correlation.settled) {
772
+ logDetail("tool.post.deduped", { run: run.runId, tool: toolName });
773
+ return { deduped: true };
774
+ }
775
+ // Respond now; decide later. Blocking the hook on the proxy's report would
776
+ // put recording latency on the session's critical path.
777
+ correlation.fallback = setTimeout(() => {
778
+ if (correlation.settled)
779
+ return;
780
+ logLine("tool.post.fallback", {
781
+ run: run.runId,
782
+ tool: correlation.qualifiedName,
783
+ why: "no proxy report — recording the hook's view instead",
784
+ });
785
+ this.settle(run, correlation, {
786
+ args: correlation.hookArgs,
787
+ result: payload.tool_response,
788
+ isError: failed,
789
+ errorMessage: failed ? this.errorText(payload) : undefined,
790
+ durationMs: Date.now() - correlation.createdAt,
791
+ });
792
+ }, PROXY_REPORT_GRACE_MS);
793
+ correlation.fallback.unref?.();
794
+ return { pending: true };
795
+ }
796
+ const builtIn = run.builtIns.get(toolUseId) ?? run.builtIns.get(`anon_${toolName}`);
797
+ const responseIndex = builtIn ? builtIn.responseIndex : run.ordering.allocate();
798
+ if (run.recording) {
799
+ const output = serializeCapped(redact(payload.tool_response ?? {}));
800
+ this.queue.push(() => {
801
+ this.recorder.recordToolResponse(run.runId, responseIndex, {
802
+ toolName,
803
+ toolOutput: failed ? undefined : output,
804
+ toolError: failed ? this.errorText(payload) : undefined,
805
+ });
806
+ });
807
+ }
808
+ run.builtIns.delete(toolUseId);
809
+ logDetail("tool.post", { run: run.runId, tool: toolName, step: responseIndex, failed });
810
+ return { stepIndex: responseIndex };
811
+ }
812
+ errorText(payload) {
813
+ if (typeof payload.error === "string")
814
+ return payload.error;
815
+ if (payload.error !== undefined)
816
+ return serializeCapped(redact(payload.error));
817
+ return serializeCapped(redact(payload.tool_response ?? {}));
818
+ }
819
+ /**
820
+ * `SubagentStart` / `SubagentStop`. These carry `agent_id`, which is the only
821
+ * way to tell a subagent's tool calls from the main thread's — Claude Code
822
+ * interleaves them into one hook stream otherwise.
823
+ *
824
+ * v1 attributes rather than partitions: the active agent id is remembered and
825
+ * prefixed onto each step's `context`, because the recording schema has no
826
+ * agent column and inventing one server-side is out of scope (D6). The
827
+ * partition is available in the audit log for anyone who needs it sooner.
828
+ */
829
+ onAgent(payload) {
830
+ const session = this.ensureSession(payload);
831
+ const starting = payload.hook_event_name === "SubagentStart";
832
+ session.agentId = starting ? payload.agent_id : undefined;
833
+ logDetail("session.agent", {
834
+ sess: session.sessionId,
835
+ agent: payload.agent_id,
836
+ event: payload.hook_event_name,
837
+ });
838
+ return {};
839
+ }
840
+ onStop(payload) {
841
+ const session = this.ensureSession(payload);
842
+ const run = session.run;
843
+ if (!run || run.finished)
844
+ return {};
845
+ logDetail("session.stop", { run: run.runId });
846
+ // The final answer is written by {@link sealRun}, which waits for the
847
+ // transcript to catch up — reading it here, synchronously, is what produced
848
+ // runs with no answer at all.
849
+ // The turn is over, so the run is over — that is the boundary §bindClaude
850
+ // names, and sealing here is what stops a run depending on `SessionEnd`
851
+ // arriving. A later prompt in the same session opens a fresh run, exactly as
852
+ // it did before.
853
+ this.sealRun(session);
854
+ return {};
855
+ }
856
+ async onSessionEnd(payload) {
857
+ const session = this.ensureSession(payload);
858
+ await this.finalizeRun(session);
859
+ return {};
860
+ }
861
+ // ── proxy routes ─────────────────────────────────────────────────────────
862
+ onProxyRegister(body) {
863
+ const serverName = String(body.serverName ?? "");
864
+ if (!serverName)
865
+ return { error: "serverName required" };
866
+ this.wrapped.add(serverName);
867
+ const proxyVersion = body.version === undefined ? undefined : String(body.version);
868
+ this.registrations.set(serverName, {
869
+ pid: Number(body.pid ?? 0),
870
+ cwd: String(body.cwd ?? ""),
871
+ at: Date.now(),
872
+ version: proxyVersion,
873
+ });
874
+ logLine("proxy.registered", { server: serverName, pid: body.pid, version: proxyVersion });
875
+ // A half-upgraded machine is the update failure that looks like success:
876
+ // `npm i -g` replaced the package, but a config still points at an older
877
+ // copy, or this hooks process predates the upgrade and was never
878
+ // restarted. Both keep working, and both quietly run code you did not
879
+ // ship. Say so once per registering proxy; never fail on it.
880
+ const own = packageVersion();
881
+ if (proxyVersion && proxyVersion !== own) {
882
+ logLine("version.skew", {
883
+ server: serverName,
884
+ proxy: proxyVersion,
885
+ hooks: own,
886
+ fix: "restart bir-hooks after upgrading, and re-run `bir install` if the entry shape changed",
887
+ });
888
+ }
889
+ // The run may not exist yet (a proxy is usually spawned before the first
890
+ // prompt). That is fine: the proxy needs the session, and gets a run id only
891
+ // once one exists.
892
+ const run = [...this.sessions.values()].find((s) => s.run && !s.run.finished)?.run;
893
+ return {
894
+ ok: true,
895
+ sessionId: this.sessionId,
896
+ runId: run?.runId,
897
+ correlation: this.opts.noCorrelation ? "fingerprint" : "injected",
898
+ // Tells the proxy whether to open its work loop. A proxy that never polls
899
+ // is not an error — its steps simply fall back to recorded outputs.
900
+ replay: this.replay.enabled,
901
+ pollHoldMs: POLL_HOLD_MS,
902
+ };
903
+ }
904
+ /**
905
+ * `POST /proxy/poll` — the control→proxy channel (docs/calculatedReplay.md §16.2).
906
+ *
907
+ * The proxy dials out and parks; the server answers with work, or empty at the
908
+ * hold deadline so the proxy re-polls. Keeping the direction outbound is what
909
+ * avoids N new loopback listeners and N new tokens for a latency saving that
910
+ * would be invisible next to the upstream's own round trip (D12).
911
+ */
912
+ async onProxyPoll(body, signal) {
913
+ const serverName = String(body.serverName ?? "");
914
+ if (!serverName)
915
+ return { error: "serverName required" };
916
+ if (!this.replay.enabled)
917
+ return {};
918
+ const hold = Number(body.holdMs);
919
+ const work = await this.replay.work.waitForWork(serverName, Number.isFinite(hold) && hold > 0 ? Math.min(hold, POLL_HOLD_MS) : POLL_HOLD_MS, signal);
920
+ return work ? { work } : {};
921
+ }
922
+ /** `POST /proxy/result` — the answer to one dispatched `tools/call`. */
923
+ onProxyResult(body) {
924
+ const workId = String(body.workId ?? "");
925
+ if (!workId)
926
+ return { error: "workId required" };
927
+ const error = typeof body.error === "string" ? body.error : undefined;
928
+ // An unknown id is a late answer to work that already timed out. Not an
929
+ // error — the caller has long since fallen back.
930
+ const claimed = this.replay.work.complete(workId, body.result, error);
931
+ return { ok: true, claimed };
932
+ }
933
+ /**
934
+ * `POST /scenario/run` — `bir-scenario` asking for the armed plan to run.
935
+ *
936
+ * This is `direct` mode's whole execution: every step goes to the proxy that
937
+ * already owns its upstream, so the model spends nothing beyond the turn that
938
+ * reads the results. There is no hook timeout here, which is exactly why the
939
+ * derivation wait was deferred to it (§10).
940
+ */
941
+ async onScenarioRun() {
942
+ // The session whose plan is *armed*, not merely the first unfinished one. A
943
+ // `claude -p` run is never sealed when its process exits, so the previous
944
+ // trial's session stays first in insertion order with its plan retired —
945
+ // and picking it answered `no_plan` while the live plan sat at index 1
946
+ // (docs/mcpmark.md §12).
947
+ const live = [...this.sessions.values()]
948
+ .map((s) => s.run)
949
+ .filter((r) => r !== undefined && !r.finished);
950
+ const run = live.find((r) => r.replay?.plan && !r.replay.retired) ??
951
+ live.find((r) => r.replay) ??
952
+ live[0];
953
+ if (!run?.replay)
954
+ return { ok: false, why: "no_plan" };
955
+ const result = await this.replay.runArmed(run.replay);
956
+ return result;
957
+ }
958
+ /**
959
+ * `POST /scenario/replay` — `bir replay`, i.e. a scenario nobody matched.
960
+ *
961
+ * The CLI holds the credentials, so it fetches the scenario and posts it here;
962
+ * this process holds the proxies, so it runs it. Enabled independently of
963
+ * `BIR_REPLAY`: the operator typed a scenario id, which is a far more explicit
964
+ * consent than a similarity score, and the gate that flag guards is automatic
965
+ * arming rather than execution itself.
966
+ */
967
+ async onScenarioReplay(body) {
968
+ const scenario = body.scenario;
969
+ const prompt = String(body.prompt ?? "");
970
+ if (!scenario || typeof scenario !== "object")
971
+ return { ok: false, why: "scenario required" };
972
+ const result = await this.replay.runAdHoc(scenario, prompt, this.wrapped);
973
+ return result;
974
+ }
975
+ /**
976
+ * A proxy's `ProxyStepReport`. Three joins, tried in order:
977
+ * 1. the injected `callId` (Tier 1, exact);
978
+ * 2. the fingerprint, inside a 30 s window (`--no-correlation`, lossy under
979
+ * identical concurrent calls — §6 says so, and so does this code);
980
+ * 3. nothing — record it standalone, which is what an SDK session with no
981
+ * hooks produces and is a complete MCP step in its own right.
982
+ */
983
+ onProxyStep(report) {
984
+ const session = this.sessionForProxyStep();
985
+ const run = this.ensureRun(session);
986
+ this.wrapped.add(report.serverName);
987
+ let correlation = report.callId ? run.correlations.get(report.callId) : undefined;
988
+ if (!correlation)
989
+ correlation = this.matchByFingerprint(run, report);
990
+ if (correlation) {
991
+ // The truer output for a replay step: exactly the bytes that were recorded
992
+ // for it when the scenario was built (§7.2).
993
+ //
994
+ // Keyed off "is this call still pinned", not off the flag `/tool/post`
995
+ // sets, because the two events race: the proxy's report is fire-and-forget
996
+ // HTTP and can land either side of `PostToolUse`. Waiting for the flag
997
+ // would let a fast proxy fall through to the 1.5 s hook-view fallback —
998
+ // the degraded path firing precisely when everything worked.
999
+ const toolUseId = correlation.threadToolUseId ?? correlation.toolUseId;
1000
+ if (run.replay && toolUseId && this.replay.isPinned(run.replay, toolUseId)) {
1001
+ correlation.threadToolUseId = undefined;
1002
+ if (correlation.threadFallback) {
1003
+ clearTimeout(correlation.threadFallback);
1004
+ correlation.threadFallback = undefined;
1005
+ }
1006
+ this.replay.postTool(run.replay, toolUseId, serializeCapped(redact(report.result)));
1007
+ }
1008
+ if (correlation.settled) {
1009
+ logDetail("proxy.step.deduped", { run: run.runId, tool: report.qualifiedName });
1010
+ return { deduped: true };
1011
+ }
1012
+ const pair = this.settle(run, correlation, {
1013
+ args: correlation.hookArgs ?? report.args,
1014
+ result: report.result,
1015
+ isError: report.isError,
1016
+ errorMessage: report.errorMessage,
1017
+ durationMs: report.durationMs,
1018
+ });
1019
+ return { stepIndex: pair.selected, merged: true };
1020
+ }
1021
+ // Standalone: no hook ever saw this call.
1022
+ const pair = run.ordering.allocatePair();
1023
+ this.record(run, pair, {
1024
+ toolName: report.qualifiedName || qualifyToolName(report.serverName, report.toolName),
1025
+ args: report.args,
1026
+ result: report.result,
1027
+ isError: report.isError,
1028
+ errorMessage: report.errorMessage,
1029
+ });
1030
+ logDetail("proxy.step", {
1031
+ run: run.runId,
1032
+ tool: report.qualifiedName,
1033
+ step: pair.selected,
1034
+ merged: false,
1035
+ ms: report.durationMs,
1036
+ });
1037
+ return { stepIndex: pair.selected, merged: false };
1038
+ }
1039
+ /**
1040
+ * Which session a proxy's step belongs to.
1041
+ *
1042
+ * A proxy has no session id of its own — stdio MCP servers are never told one
1043
+ * — so the control server decides. The order matters: preferring *any known*
1044
+ * session over minting a new one is what stops a step that lands between
1045
+ * `SessionStart` and the first prompt from opening a second, parallel run,
1046
+ * which would split one session's steps across two recordings.
1047
+ */
1048
+ sessionForProxyStep() {
1049
+ const withOpenRun = [...this.sessions.values()].find((s) => s.run && !s.run.finished);
1050
+ if (withOpenRun)
1051
+ return withOpenRun;
1052
+ const known = [...this.sessions.values()][0];
1053
+ if (known)
1054
+ return known;
1055
+ // No hook has ever fired: an SDK or non-Claude host. We own the session.
1056
+ return this.ensureSession({ session_id: this.sessionId, cwd: this.opts.cwd });
1057
+ }
1058
+ matchByFingerprint(run, report) {
1059
+ const key = fingerprint(report.serverName, report.toolName, report.args);
1060
+ const now = Date.now();
1061
+ let best;
1062
+ for (const candidate of run.correlations.values()) {
1063
+ if (candidate.settled)
1064
+ continue;
1065
+ if (candidate.fingerprint !== key)
1066
+ continue;
1067
+ if (now - candidate.createdAt > FINGERPRINT_WINDOW_MS)
1068
+ continue;
1069
+ // Oldest unsettled match wins — FIFO is the best guess available, and it
1070
+ // is a guess: two identical concurrent calls are indistinguishable here.
1071
+ if (!best || candidate.createdAt < best.createdAt)
1072
+ best = candidate;
1073
+ }
1074
+ return best;
1075
+ }
1076
+ /** Record the merged pair for a correlation and latch it against re-entry. */
1077
+ settle(run, correlation, data) {
1078
+ correlation.settled = true;
1079
+ if (correlation.fallback)
1080
+ clearTimeout(correlation.fallback);
1081
+ if (correlation.threadFallback)
1082
+ clearTimeout(correlation.threadFallback);
1083
+ const pair = run.ordering.allocatePair();
1084
+ this.record(run, pair, {
1085
+ toolName: correlation.qualifiedName,
1086
+ args: data.args,
1087
+ result: data.result,
1088
+ isError: data.isError,
1089
+ errorMessage: data.errorMessage,
1090
+ context: correlation.context,
1091
+ });
1092
+ logDetail("proxy.step.merged", {
1093
+ run: run.runId,
1094
+ tool: correlation.qualifiedName,
1095
+ step: pair.selected,
1096
+ ms: data.durationMs,
1097
+ });
1098
+ return pair;
1099
+ }
1100
+ /** The one place a tool step pair is written. Redaction and caps live here. */
1101
+ record(run, pair, d) {
1102
+ if (!run.recording)
1103
+ return;
1104
+ const toolInput = serializeCapped(redact(d.args));
1105
+ const toolOutput = d.isError ? undefined : serializeCapped(redact(d.result));
1106
+ const toolError = d.isError
1107
+ ? (d.errorMessage ?? serializeCapped(redact(d.result)))
1108
+ : undefined;
1109
+ this.queue.push(() => {
1110
+ this.recorder.recordToolSelected(run.runId, pair.selected, {
1111
+ toolName: d.toolName,
1112
+ toolInput,
1113
+ context: d.context,
1114
+ });
1115
+ this.recorder.recordToolResponse(run.runId, pair.response, {
1116
+ toolName: d.toolName,
1117
+ toolOutput,
1118
+ toolError,
1119
+ });
1120
+ });
1121
+ }
1122
+ }
1123
+ /**
1124
+ * The port `bir install` writes into the hook URLs, and the one `bir-hooks`
1125
+ * prefers. Claude Code's `settings.json` needs a *static* URL, so the control
1126
+ * server cannot be purely ephemeral: it asks for this port and falls back to an
1127
+ * ephemeral one only if it is taken — in which case the installed hooks point at
1128
+ * a port nobody is listening on, and both `bir-hooks` and `bir doctor` say so.
1129
+ */
1130
+ export const DEFAULT_CONTROL_PORT = 53411;
1131
+ //# sourceMappingURL=server.js.map