@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.
- package/LICENSE +201 -0
- package/README.md +276 -0
- package/dist/auth/client.d.ts +85 -0
- package/dist/auth/client.js +284 -0
- package/dist/bin/bir-hooks.d.ts +48 -0
- package/dist/bin/bir-hooks.js +201 -0
- package/dist/bin/bir-proxy.d.ts +45 -0
- package/dist/bin/bir-proxy.js +207 -0
- package/dist/bin/bir-scenario.d.ts +24 -0
- package/dist/bin/bir-scenario.js +177 -0
- package/dist/bin/bir.d.ts +21 -0
- package/dist/bin/bir.js +876 -0
- package/dist/config/adapters/claude-code.d.ts +76 -0
- package/dist/config/adapters/claude-code.js +181 -0
- package/dist/config/adapters/generic.d.ts +17 -0
- package/dist/config/adapters/generic.js +36 -0
- package/dist/config/generate.d.ts +127 -0
- package/dist/config/generate.js +114 -0
- package/dist/config/resolve.d.ts +68 -0
- package/dist/config/resolve.js +132 -0
- package/dist/control/client.d.ts +56 -0
- package/dist/control/client.js +86 -0
- package/dist/control/correlation.d.ts +86 -0
- package/dist/control/correlation.js +0 -0
- package/dist/control/discovery.d.ts +50 -0
- package/dist/control/discovery.js +123 -0
- package/dist/control/ordering.d.ts +38 -0
- package/dist/control/ordering.js +44 -0
- package/dist/control/paths.d.ts +32 -0
- package/dist/control/paths.js +56 -0
- package/dist/control/server.d.ts +272 -0
- package/dist/control/server.js +1131 -0
- package/dist/control/transcript.d.ts +75 -0
- package/dist/control/transcript.js +241 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +32 -0
- package/dist/jsonrpc/framing.d.ts +49 -0
- package/dist/jsonrpc/framing.js +143 -0
- package/dist/jsonrpc/types.d.ts +52 -0
- package/dist/jsonrpc/types.js +46 -0
- package/dist/proxy/intercept.d.ts +55 -0
- package/dist/proxy/intercept.js +147 -0
- package/dist/proxy/relay.d.ts +97 -0
- package/dist/proxy/relay.js +166 -0
- package/dist/proxy/session.d.ts +116 -0
- package/dist/proxy/session.js +319 -0
- package/dist/record/housekeeping.d.ts +34 -0
- package/dist/record/housekeeping.js +39 -0
- package/dist/record/queue.d.ts +48 -0
- package/dist/record/queue.js +96 -0
- package/dist/record/recorder.d.ts +111 -0
- package/dist/record/recorder.js +39 -0
- package/dist/record/redact.d.ts +37 -0
- package/dist/record/redact.js +119 -0
- package/dist/record/remote-recorder.d.ts +110 -0
- package/dist/record/remote-recorder.js +301 -0
- package/dist/record/truncate.d.ts +36 -0
- package/dist/record/truncate.js +85 -0
- package/dist/replay/bundle.d.ts +36 -0
- package/dist/replay/bundle.js +89 -0
- package/dist/replay/controller.d.ts +300 -0
- package/dist/replay/controller.js +807 -0
- package/dist/replay/coverage.d.ts +41 -0
- package/dist/replay/coverage.js +56 -0
- package/dist/replay/derive.d.ts +58 -0
- package/dist/replay/derive.js +166 -0
- package/dist/replay/executor.d.ts +78 -0
- package/dist/replay/executor.js +233 -0
- package/dist/replay/logic.d.ts +31 -0
- package/dist/replay/logic.js +50 -0
- package/dist/replay/plan.d.ts +181 -0
- package/dist/replay/plan.js +397 -0
- package/dist/replay/pricing.d.ts +41 -0
- package/dist/replay/pricing.js +76 -0
- package/dist/replay/source-run.d.ts +50 -0
- package/dist/replay/source-run.js +98 -0
- package/dist/replay/tool-error.d.ts +22 -0
- package/dist/replay/tool-error.js +60 -0
- package/dist/replay/types.d.ts +116 -0
- package/dist/replay/types.js +35 -0
- package/dist/upstream/client.d.ts +78 -0
- package/dist/upstream/client.js +114 -0
- package/dist/upstream/http-client.d.ts +78 -0
- package/dist/upstream/http-client.js +261 -0
- package/dist/upstream/lazy-client.d.ts +31 -0
- package/dist/upstream/lazy-client.js +53 -0
- package/dist/upstream/stdio-client.d.ts +57 -0
- package/dist/upstream/stdio-client.js +203 -0
- package/dist/util/log.d.ts +27 -0
- package/dist/util/log.js +51 -0
- package/dist/util/version.d.ts +2 -0
- package/dist/util/version.js +40 -0
- package/docs/BaseInstRunner.md +621 -0
- package/docs/calculatedReplay.md +1185 -0
- package/docs/calculatedReplayGuide.md +448 -0
- package/docs/installRun.md +413 -0
- package/docs/mcpmark.md +752 -0
- package/docs/quickstart.md +201 -0
- package/docs/t-bench.md +394 -0
- 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
|