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