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