@basein/runner 0.2.12 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/README.md +540 -369
  2. package/dist/auth/client.d.ts +65 -3
  3. package/dist/auth/client.js +305 -53
  4. package/dist/bin/bir-hooks.d.ts +72 -31
  5. package/dist/bin/bir-hooks.js +176 -284
  6. package/dist/bin/bir-proxy.d.ts +17 -2
  7. package/dist/bin/bir-proxy.js +222 -28
  8. package/dist/bin/bir-scenario.d.ts +61 -21
  9. package/dist/bin/bir-scenario.js +593 -122
  10. package/dist/bin/bir.d.ts +12 -6
  11. package/dist/bin/bir.js +2935 -489
  12. package/dist/bin/investigate.js +40 -15
  13. package/dist/bin/scenario-edit.d.ts +7 -1
  14. package/dist/bin/scenario-edit.js +43 -21
  15. package/dist/bin/scenario-liveness.d.ts +194 -0
  16. package/dist/bin/scenario-liveness.js +459 -0
  17. package/dist/bin/scenario-stderr.d.ts +43 -0
  18. package/dist/bin/scenario-stderr.js +76 -0
  19. package/dist/bin/service-read.d.ts +88 -0
  20. package/dist/bin/service-read.js +128 -0
  21. package/dist/bin/setup.d.ts +19 -0
  22. package/dist/bin/setup.js +146 -20
  23. package/dist/config/adapters/claude-code.d.ts +92 -14
  24. package/dist/config/adapters/claude-code.js +291 -101
  25. package/dist/config/adapters/generic.d.ts +4 -1
  26. package/dist/config/adapters/generic.js +13 -14
  27. package/dist/config/generate.d.ts +392 -14
  28. package/dist/config/generate.js +666 -19
  29. package/dist/config/mcp-approval.d.ts +76 -0
  30. package/dist/config/mcp-approval.js +210 -0
  31. package/dist/config/node-path.d.ts +42 -0
  32. package/dist/config/node-path.js +102 -0
  33. package/dist/config/overrides.d.ts +62 -0
  34. package/dist/config/overrides.js +136 -0
  35. package/dist/config/resolve.d.ts +70 -8
  36. package/dist/config/resolve.js +186 -15
  37. package/dist/control/client.d.ts +22 -0
  38. package/dist/control/client.js +26 -7
  39. package/dist/control/correlation.d.ts +23 -5
  40. package/dist/control/correlation.js +0 -0
  41. package/dist/control/daemon.d.ts +400 -18
  42. package/dist/control/daemon.js +1105 -102
  43. package/dist/control/discovery.d.ts +77 -5
  44. package/dist/control/discovery.js +186 -11
  45. package/dist/control/ensure-hook.d.ts +61 -0
  46. package/dist/control/ensure-hook.js +160 -8
  47. package/dist/control/paths.d.ts +26 -0
  48. package/dist/control/paths.js +34 -0
  49. package/dist/control/pin-guard.d.ts +83 -0
  50. package/dist/control/pin-guard.js +534 -0
  51. package/dist/control/recorder-keeper.d.ts +229 -0
  52. package/dist/control/recorder-keeper.js +721 -0
  53. package/dist/control/recorder-main.d.ts +73 -0
  54. package/dist/control/recorder-main.js +778 -0
  55. package/dist/control/server.d.ts +707 -31
  56. package/dist/control/server.js +2786 -302
  57. package/dist/control/transcript.d.ts +21 -1
  58. package/dist/control/transcript.js +378 -112
  59. package/dist/index.d.ts +5 -1
  60. package/dist/index.js +5 -1
  61. package/dist/jsonrpc/framing.d.ts +76 -12
  62. package/dist/jsonrpc/framing.js +216 -59
  63. package/dist/proxy/intercept.d.ts +49 -5
  64. package/dist/proxy/intercept.js +188 -59
  65. package/dist/proxy/relay.d.ts +16 -0
  66. package/dist/proxy/relay.js +77 -5
  67. package/dist/proxy/revive.d.ts +122 -0
  68. package/dist/proxy/revive.js +251 -0
  69. package/dist/proxy/session.d.ts +222 -14
  70. package/dist/proxy/session.js +708 -99
  71. package/dist/record/recorder.d.ts +51 -3
  72. package/dist/record/recorder.js +3 -0
  73. package/dist/record/redact.d.ts +103 -9
  74. package/dist/record/redact.js +659 -30
  75. package/dist/record/remote-recorder.d.ts +327 -32
  76. package/dist/record/remote-recorder.js +1517 -187
  77. package/dist/record/switchable-recorder.d.ts +126 -0
  78. package/dist/record/switchable-recorder.js +351 -0
  79. package/dist/record/truncate.d.ts +40 -1
  80. package/dist/record/truncate.js +111 -4
  81. package/dist/replay/bash-channel.d.ts +35 -0
  82. package/dist/replay/bash-channel.js +133 -0
  83. package/dist/replay/bundle.d.ts +10 -0
  84. package/dist/replay/bundle.js +10 -0
  85. package/dist/replay/controller.d.ts +369 -20
  86. package/dist/replay/controller.js +1176 -107
  87. package/dist/replay/derive.d.ts +1 -0
  88. package/dist/replay/derive.js +81 -24
  89. package/dist/replay/executor.d.ts +51 -16
  90. package/dist/replay/executor.js +114 -41
  91. package/dist/replay/handover.d.ts +54 -1
  92. package/dist/replay/handover.js +55 -5
  93. package/dist/replay/logic.d.ts +62 -10
  94. package/dist/replay/logic.js +140 -22
  95. package/dist/replay/plan.d.ts +201 -27
  96. package/dist/replay/plan.js +581 -141
  97. package/dist/replay/safety.d.ts +72 -0
  98. package/dist/replay/safety.js +303 -0
  99. package/dist/replay/source-run.d.ts +10 -0
  100. package/dist/replay/source-run.js +80 -35
  101. package/dist/replay/targets.d.ts +15 -1
  102. package/dist/replay/targets.js +62 -4
  103. package/dist/replay/tool-error.d.ts +15 -1
  104. package/dist/replay/tool-error.js +33 -1
  105. package/dist/replay/types.d.ts +7 -4
  106. package/dist/upstream/client.d.ts +56 -3
  107. package/dist/upstream/client.js +61 -4
  108. package/dist/upstream/headers.d.ts +43 -0
  109. package/dist/upstream/headers.js +90 -0
  110. package/dist/upstream/http-client.d.ts +213 -22
  111. package/dist/upstream/http-client.js +1034 -168
  112. package/dist/upstream/http-transport.d.ts +143 -0
  113. package/dist/upstream/http-transport.js +391 -0
  114. package/dist/upstream/lazy-client.d.ts +8 -0
  115. package/dist/upstream/lazy-client.js +29 -1
  116. package/dist/upstream/sse.d.ts +78 -0
  117. package/dist/upstream/sse.js +189 -0
  118. package/dist/upstream/stdio-client.d.ts +61 -1
  119. package/dist/upstream/stdio-client.js +356 -33
  120. package/dist/util/atomic-write.d.ts +29 -0
  121. package/dist/util/atomic-write.js +96 -0
  122. package/dist/util/journal.d.ts +15 -0
  123. package/dist/util/journal.js +48 -3
  124. package/dist/util/log.d.ts +10 -0
  125. package/dist/util/log.js +28 -1
  126. package/dist/util/off-switch.d.ts +46 -0
  127. package/dist/util/off-switch.js +91 -0
  128. package/dist/util/service-health.d.ts +133 -0
  129. package/dist/util/service-health.js +217 -0
  130. package/docs/BaseInstRunner.md +694 -621
  131. package/docs/calculatedReplay.md +1625 -1304
  132. package/docs/calculatedReplayGuide.md +1212 -1087
  133. package/docs/installRun.md +1013 -772
  134. package/docs/loginWeb.md +627 -607
  135. package/docs/mcpmark.md +49 -16
  136. package/docs/my-first-sample.md +569 -545
  137. package/docs/quickstart.md +384 -311
  138. package/docs/t-bench.md +406 -394
  139. package/package.json +58 -57
  140. package/production.md +1371 -0
  141. package/scripts/install.ps1 +97 -6
  142. package/scripts/install.sh +104 -7
@@ -0,0 +1,778 @@
1
+ /**
2
+ * recorder-main — the long-running half of `bir-hooks`: the control server
3
+ * that receives Claude Code's hooks and the proxies' step reports.
4
+ *
5
+ * Kept out of `bin/bir-hooks.ts` so the SessionStart hook (`bir-hooks ensure`),
6
+ * which runs on every session start, `/clear` and compaction, never loads the
7
+ * control server, the replay engine or the service client it does not use.
8
+ *
9
+ * THE ORDER OF START-UP IS THE POINT. Claude Code sends every hook of a
10
+ * session to this process's port. Until it listens, each one is refused, and
11
+ * Claude Code shows each refusal as a hook error. So, in order:
12
+ *
13
+ * 1. crash handlers — nothing after this can take the process down;
14
+ * 2. the log, the project's port and token, the journal — local files only;
15
+ * 3. the recorder, decided from disk alone (recorder-keeper.ts) — no network;
16
+ * 4. listen, and write the discovery file;
17
+ * 5. only then, in the background and bounded: refresh the token, check an
18
+ * http address, notice a sign-in appearing — none of it ever delays a hook.
19
+ *
20
+ * It used to sign in (two network calls, no timeouts) between 2 and 4, and a
21
+ * slow service held the port closed for up to five minutes.
22
+ */
23
+ import { hostname } from "node:os";
24
+ import { ControlServer, DEFAULT_CONTROL_PORT } from "./server.js";
25
+ import { DISCOVERY_CHECK_MS, HEARTBEAT_MS, pidAlive, readDiscovery, removeDiscoveryIfOwned, removeSupersededRecord, sweepStaleDiscovery, writeDiscovery, writeSupersededRecord, } from "./discovery.js";
26
+ import { DAEMON_CWD_ENV, DAEMON_ENV, DAEMON_LOG_ENV, DERIVE_KEY_ENV, DaemonLog, USE_API_KEY_ENV, clearPortProblem, compareVersions, probeHealth, projectEndpoint, rediscover, validPort, writePortProblem, } from "./daemon.js";
27
+ import { configDir, normalizePath, supersededLogPath } from "./paths.js";
28
+ import { RecorderKeeper } from "./recorder-keeper.js";
29
+ import { parseAllowList } from "../replay/coverage.js";
30
+ import { sweepDeliveries } from "../replay/bash-channel.js";
31
+ import { exemptLoopbackFromProxy, isWrapped, projectRecord, projectToken, readSidecar, } from "../config/generate.js";
32
+ import { resolveServers } from "../config/resolve.js";
33
+ import { isRecorderHealthReporter } from "../record/recorder.js";
34
+ import { errText, logLine, logOnce } from "../util/log.js";
35
+ import { openJournal } from "../util/journal.js";
36
+ import { packageVersion } from "../util/version.js";
37
+ /** How long `close()` may take on the way out: it flushes unfinished runs to the service. */
38
+ const SHUTDOWN_CLOSE_MS = 3_000;
39
+ /** After this the process exits whatever is still pending. */
40
+ const SHUTDOWN_HARD_MS = 5_000;
41
+ /**
42
+ * What a prompt waits for its scenario match by default (`BIR_MATCH_BUDGET_MS`).
43
+ * Past it the prompt goes on without one (K5): the service answering in 1-2 s
44
+ * must not add that to every prompt.
45
+ */
46
+ export const DEFAULT_MATCH_BUDGET_MS = 1_500;
47
+ /** How often records of dead recorders are swept from the control directory. */
48
+ const SWEEP_EVERY_MS = 24 * 60 * 60 * 1000;
49
+ /**
50
+ * How long a superseded recorder must have had nothing to serve before it
51
+ * exits (`BIR_SUPERSEDED_EXIT_MS`). "Nothing" is no open run, no session it
52
+ * still knows (the control server forgets one 10 min after its SessionEnd, or
53
+ * after 6 h of silence), no live proxy and nothing left to send. Generous on
54
+ * purpose: a window opened before the reinstall and left idle overnight still
55
+ * posts its hooks to this port when the person comes back, and a recorder
56
+ * that had exited would turn every one of them into a hook error.
57
+ */
58
+ const SUPERSEDED_EXIT_MS = 24 * 60 * 60 * 1000;
59
+ function positiveInt(value, fallback) {
60
+ const n = Number(value);
61
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
62
+ }
63
+ /** Like {@link positiveInt}, but 0 is a meaningful setting rather than unset. */
64
+ function nonNegativeInt(value, fallback) {
65
+ const n = Number(value);
66
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback;
67
+ }
68
+ function sleep(ms) {
69
+ return new Promise((resolve) => setTimeout(resolve, ms));
70
+ }
71
+ /** Config keys already wrapped for this cwd, so `/tool/pre` knows what to correlate. */
72
+ function wrappedServersFor(cwd) {
73
+ try {
74
+ return resolveServers(cwd)
75
+ .filter((s) => isWrapped(s.config))
76
+ .map((s) => s.name);
77
+ }
78
+ catch (err) {
79
+ logLine("config.unreadable", { error: errText(err) });
80
+ return [];
81
+ }
82
+ }
83
+ /**
84
+ * `BIR_INTENT_SOURCES` — which text a step's intent may be read from
85
+ * (segmented.md R-INTENT-3). Unset or unparseable means all three.
86
+ *
87
+ * It governs only what is *sent*. Turning every source off does not stop a
88
+ * probe: the request still goes out with an empty text and the server builds a
89
+ * line from the tool name and arguments (R-HIT-4).
90
+ */
91
+ function parseIntentSources(value) {
92
+ if (value === undefined)
93
+ return undefined;
94
+ const allowed = ["text", "thinking", "tool"];
95
+ const wanted = value
96
+ .split(",")
97
+ .map((s) => s.trim().toLowerCase())
98
+ .filter((s) => allowed.includes(s));
99
+ return new Set(wanted);
100
+ }
101
+ /**
102
+ * `BIR_CORRELATION_DECISION` as the operator set it — read the way the control
103
+ * server reads it (trimmed, any case) — or undefined when it is unset or not
104
+ * one of the three values.
105
+ */
106
+ function correlationSetting() {
107
+ const value = (process.env.BIR_CORRELATION_DECISION ?? "").trim().toLowerCase();
108
+ return value === "allow" || value === "ask" || value === "auto" ? value : undefined;
109
+ }
110
+ /**
111
+ * `BIR_CORRELATION_DECISION`, passed on only when it was set to `allow` or
112
+ * `ask`. Unset, nothing is passed and the control server decides — and by
113
+ * default (`auto`) it never answers `allow` for an ordinary wrapped call,
114
+ * which skipped the person's permission prompt.
115
+ */
116
+ export function correlationDecisionFromEnv() {
117
+ const value = correlationSetting();
118
+ return value === "allow" || value === "ask" ? value : undefined;
119
+ }
120
+ /**
121
+ * Which Anthropic key derivation may use, if any.
122
+ *
123
+ * In the background, only one the person set for this purpose. The
124
+ * hook-started recorder has Claude Code's environment, and an API-billed
125
+ * Claude Code carries its `ANTHROPIC_API_KEY` there: picking that up silently
126
+ * billed the person's own key for work the service does for a signed-in
127
+ * runner. It is dropped from this process (replay would otherwise read it
128
+ * from the environment) unless `BIR_USE_ANTHROPIC_API_KEY=1` says to use it.
129
+ *
130
+ * In a terminal (`bir-hooks` run by hand, `daemon` false) the environment is
131
+ * the operator's own, and older guides told them to set exactly this variable
132
+ * to keep derivation on the machine: it is used as it always was.
133
+ */
134
+ export function derivationKey(daemon = process.env[DAEMON_ENV] === "1") {
135
+ const own = process.env[DERIVE_KEY_ENV]?.trim();
136
+ if (own)
137
+ return { apiKey: own, source: `this machine (${DERIVE_KEY_ENV})` };
138
+ const inherited = process.env.ANTHROPIC_API_KEY;
139
+ if (inherited && process.env[USE_API_KEY_ENV] === "1") {
140
+ return { apiKey: inherited, source: `this machine (ANTHROPIC_API_KEY, ${USE_API_KEY_ENV}=1)` };
141
+ }
142
+ if (inherited && !daemon) {
143
+ return { apiKey: inherited, source: "this machine (ANTHROPIC_API_KEY, from this terminal)" };
144
+ }
145
+ if (inherited) {
146
+ delete process.env.ANTHROPIC_API_KEY;
147
+ logLine("derive.api_key_ignored", {
148
+ why: "ANTHROPIC_API_KEY is in this recorder's environment (usually Claude Code's) and is not used, " +
149
+ `so nothing is billed to it — set ${DERIVE_KEY_ENV}, or ${USE_API_KEY_ENV}=1, to derive with a key here`,
150
+ });
151
+ }
152
+ return {};
153
+ }
154
+ /**
155
+ * Assemble the replay configuration (docs/calculatedReplay.md §5.3 of the guide).
156
+ *
157
+ * The steer threshold defaults **above** the service's own detection threshold
158
+ * (0.9). Detection decides "do not record this again", which is cheap — the worst
159
+ * case is a lost recording. Steering decides "do not think about this again",
160
+ * which is a much stronger claim, and a 0.90-similar prompt usually covers only
161
+ * part of the live task.
162
+ */
163
+ function buildReplayOptions(keeper, policy, key) {
164
+ // The environment still wins — an operator at a terminal is the most
165
+ // deliberate signal there is — but a recorder started by the SessionStart
166
+ // hook has Claude Code's environment, not that terminal's, and it reads the
167
+ // switches the project stored instead of forgetting them (generate.ts).
168
+ const envReplay = process.env.BIR_REPLAY;
169
+ const envAllow = process.env.BIR_REPLAY_ALLOW_SERVERS;
170
+ const envMin = Number(process.env.BIR_MIN_STEER_SIMILARITY);
171
+ const fromEnv = envReplay !== undefined || envAllow !== undefined || Number.isFinite(envMin);
172
+ const source = fromEnv ? "env" : policy ? "sidecar" : "default";
173
+ const enabled = envReplay !== undefined ? envReplay !== "0" : policy?.enabled !== false;
174
+ const allowServers = envAllow !== undefined
175
+ ? parseAllowList(envAllow)
176
+ : policy?.allowServers
177
+ ? parseAllowList(policy.allowServers.join(","))
178
+ : undefined;
179
+ const minSimilarity = Number.isFinite(envMin) ? envMin : (policy?.minSimilarity ?? 0.92);
180
+ const opts = {
181
+ enabled,
182
+ minSimilarity,
183
+ allowServers,
184
+ budgets: {
185
+ // What a prompt may wait for its match (K5). Past it the prompt goes on
186
+ // without one; a service slower than this must not cost every prompt.
187
+ matchMs: positiveInt(process.env.BIR_MATCH_BUDGET_MS, DEFAULT_MATCH_BUDGET_MS),
188
+ deriveMs: positiveInt(process.env.BIR_DERIVE_BUDGET_MS, 8_000),
189
+ // Only when the operator set it: the controller's own default (100 s,
190
+ // BIR_REPLAY_PLAN_BUDGET_MS) keeps a direct run inside Claude Code's
191
+ // 2-minute auto-background of an MCP call, and 120 000 here would
192
+ // silently override it.
193
+ ...(process.env.BIR_REPLAY_BUDGET_MS
194
+ ? { planMs: positiveInt(process.env.BIR_REPLAY_BUDGET_MS, 100_000) }
195
+ : {}),
196
+ stepMs: positiveInt(process.env.BIR_STEP_TIMEOUT_MS, 60_000),
197
+ },
198
+ // Intent matching in the ReAct loop (fallbk.md §Runner 4). On with replay;
199
+ // BIR_INTENT_MATCH=0 turns it off on its own.
200
+ intentMatch: {
201
+ enabled: process.env.BIR_INTENT_MATCH !== "0",
202
+ // The probe's own ceiling, not a wait on the hook's path: `PreToolUse`
203
+ // sends it and answers without it (hit counting only) unless arming is
204
+ // on (BIR_SEGMENT_ARM=1 or BIR_INTENT_ARM=1), and then waits at most
205
+ // BIR_INTENT_ARM_BUDGET_MS (500 ms), never while the service is failing
206
+ // (control/server.ts). 4 000 leaves a request whose best segment lands
207
+ // in the not-clear band its one live question under the service's own
208
+ // 2 500 ms attempt (segmented.md R-HIT-11).
209
+ budgetMs: positiveInt(process.env.BIR_INTENT_MATCH_BUDGET_MS, 4_000),
210
+ maxPerTurn: positiveInt(process.env.BIR_INTENT_MATCHES_PER_TURN, 3),
211
+ maxRequestsPerTurn: positiveInt(process.env.BIR_INTENT_REQUESTS_PER_TURN, 40),
212
+ },
213
+ // Observe-only until an operator has read the logs and turned it on
214
+ // (segmented.md R-OUT-10, R-LIFE-7). Arming is never the way to find out.
215
+ segmentArm: process.env.BIR_SEGMENT_ARM === "1",
216
+ minSegmentSimilarity: (() => {
217
+ const n = Number(process.env.BIR_MIN_SEGMENT_STEER_SIMILARITY);
218
+ return Number.isFinite(n) ? n : 0.95;
219
+ })(),
220
+ // Both read live, never captured (C5). The recorder signs in after it
221
+ // listens, refreshes the token as the session outlives it, and may sign
222
+ // in or out while it runs; replay reads whatever holds *now*. The old
223
+ // closure read the start-up session object, which a refresh never
224
+ // updated, so derivation got 401 a day into every recorder's life.
225
+ get authUrl() {
226
+ return keeper.authUrl;
227
+ },
228
+ authToken: () => keeper.currentAccessToken() ?? "",
229
+ apiKey: key.apiKey,
230
+ };
231
+ if (enabled) {
232
+ // Loud, at startup, on the operator's own terminal. A capability that
233
+ // runs tool calls without asking must never be discoverable only from a
234
+ // log file. What it runs without asking is a matched scenario's own steps,
235
+ // and nothing else: an ordinary wrapped call keeps its permission prompt
236
+ // (see the correlation line under `control.listening`).
237
+ logLine("replay.enabled", {
238
+ minSimilarity: opts.minSimilarity,
239
+ allowServers: allowServers ? [...allowServers].join(",") : "(all wrapped)",
240
+ // Who reads what this turn acts on (segmented.md R-PARAM-5). Named at
241
+ // startup because the third state is a replay that silently never runs.
242
+ derive: key.source ??
243
+ (keeper.recording
244
+ ? "the service"
245
+ : "recorded sample values — sign in, or a scenario with a target will not run"),
246
+ intentMatch: opts.intentMatch?.enabled ? "on" : "off",
247
+ source,
248
+ why: "a prompt that matches a calculated scenario runs it: the scenario's own steps run without a " +
249
+ "permission prompt of their own (never in plan mode); every other tool call is asked about as " +
250
+ "usual — BIR_REPLAY=0 or `bir replay off` turns replay off",
251
+ });
252
+ }
253
+ return { opts, source };
254
+ }
255
+ /**
256
+ * The pinned port is taken. Find out by whom, say so where `bir doctor` and
257
+ * the next SessionStart will see it, and decide: `ours` (this project's
258
+ * recorder already serves it — step aside), `retry` (nothing answers: the
259
+ * holder is on its way out), or `exit`.
260
+ */
261
+ async function portBusy(cwd, port, token, code, final) {
262
+ const fix = `run \`bir setup --port <another port>\` in ${cwd} (below 49152 avoids the ranges the system reserves); ` +
263
+ "the next Claude Code session then uses it";
264
+ let problem;
265
+ if (code === "EACCES") {
266
+ problem = {
267
+ port,
268
+ code,
269
+ holder: "excluded",
270
+ at: Date.now(),
271
+ detail: `port ${port} is reserved by the system (on Windows, Hyper-V, WSL and Docker reserve port ranges at boot)`,
272
+ fix,
273
+ };
274
+ }
275
+ else {
276
+ const probe = await probeHealth(`http://127.0.0.1:${port}`, token, 2_000);
277
+ if (probe.kind === "ok") {
278
+ const theirCwd = typeof probe.health.cwd === "string" ? probe.health.cwd : "";
279
+ if (normalizePath(theirCwd) === normalizePath(cwd)) {
280
+ // This project's recorder is up already — started a moment earlier by
281
+ // another window, or running without its discovery file. Make sure it
282
+ // can be found, and leave.
283
+ await rediscover(cwd, port, token, 2_000);
284
+ clearPortProblem(cwd);
285
+ logLine("control.already_running", {
286
+ port,
287
+ pid: probe.health.pid,
288
+ why: "this project's recorder already serves this port; not starting a second one",
289
+ });
290
+ return "ours";
291
+ }
292
+ problem = {
293
+ port,
294
+ code,
295
+ holder: "other-project",
296
+ at: Date.now(),
297
+ detail: `port ${port} is served by the recorder of another project (${theirCwd || "unknown"})`,
298
+ fix,
299
+ };
300
+ }
301
+ else if (probe.kind === "refused" && !final) {
302
+ return "retry";
303
+ }
304
+ else if (probe.kind === "unauthorized") {
305
+ problem = {
306
+ port,
307
+ code,
308
+ holder: "unauthorized",
309
+ at: Date.now(),
310
+ detail: `port ${port} is held by a program that refused this project's token — possibly a recorder ` +
311
+ "left from an earlier install of this project, which stops at the next restart of the computer",
312
+ fix,
313
+ };
314
+ }
315
+ else if (probe.kind === "foreign") {
316
+ problem = {
317
+ port,
318
+ code,
319
+ holder: "foreign",
320
+ at: Date.now(),
321
+ detail: `port ${port} is held by another program (it answered HTTP ${probe.status}); until the port ` +
322
+ "changes, this project's hooks send their payloads — and this project's token — to it",
323
+ fix,
324
+ };
325
+ }
326
+ else {
327
+ problem = {
328
+ port,
329
+ code,
330
+ holder: "unknown",
331
+ at: Date.now(),
332
+ detail: `port ${port} is held by a program that did not answer`,
333
+ fix,
334
+ };
335
+ }
336
+ }
337
+ writePortProblem(cwd, problem);
338
+ logLine("control.port_busy", {
339
+ port,
340
+ code,
341
+ holder: problem.holder,
342
+ detail: problem.detail,
343
+ fix: problem.fix,
344
+ });
345
+ return "exit";
346
+ }
347
+ /**
348
+ * The control server's {@link RecorderActivity}. From its own accessor when it
349
+ * has one; otherwise the same, read from its `/health` body (heavier, and
350
+ * asked once a minute only while superseded).
351
+ */
352
+ export function activityOf(server) {
353
+ const accessor = server.activity;
354
+ if (typeof accessor === "function")
355
+ return accessor.call(server);
356
+ const h = server.health();
357
+ const sessions = Array.isArray(h.sessions) ? h.sessions : [];
358
+ const proxies = Array.isArray(h.registeredProxies) ? h.registeredProxies : [];
359
+ const pids = new Set();
360
+ for (const p of proxies)
361
+ if (typeof p?.pid === "number" && pidAlive(p.pid))
362
+ pids.add(p.pid);
363
+ return {
364
+ openRuns: sessions.filter((s) => s.active === true).length,
365
+ knownSessions: typeof h.sessionCount === "number" ? h.sessionCount : sessions.length,
366
+ liveProxyPids: pids.size,
367
+ };
368
+ }
369
+ /** Steps this process has not handed to the service yet (0 when it cannot say). */
370
+ function pendingSendsOf(recorder) {
371
+ try {
372
+ if (!isRecorderHealthReporter(recorder))
373
+ return 0;
374
+ const n = recorder.recorderHealth()?.pendingSends;
375
+ return typeof n === "number" && Number.isFinite(n) && n > 0 ? n : 0;
376
+ }
377
+ catch {
378
+ return 0;
379
+ }
380
+ }
381
+ /** The port of a recorder URL, or undefined. */
382
+ function portOfUrl(url) {
383
+ try {
384
+ return validPort(new URL(url).port);
385
+ }
386
+ catch {
387
+ return undefined;
388
+ }
389
+ }
390
+ export async function runRecorder() {
391
+ // First, before anything can throw: this process answers every hook of every
392
+ // session in the project, and a crash turns each of them into a visible hook
393
+ // error. Log and keep serving.
394
+ process.on("uncaughtException", (err) => logLine("control.uncaught", { error: errText(err) }));
395
+ process.on("unhandledRejection", (err) => logLine("control.unhandled_rejection", { error: errText(err) }));
396
+ // Started with --use-env-proxy (a launcher written behind a proxy) in an
397
+ // environment whose NO_PROXY does not name 127.0.0.1: every loopback fetch
398
+ // this process makes — asking who holds its port, finding its own recorder
399
+ // again — would go to the proxy and fail. undici re-reads NO_PROXY per
400
+ // request, so adding loopback to it here is enough (generate.ts). Calls to
401
+ // the service still go through the proxy, as the flag asks.
402
+ exemptLoopbackFromProxy();
403
+ const daemon = process.env[DAEMON_ENV] === "1";
404
+ // A background recorder is started in the runner's state directory and told
405
+ // its project (daemon.ts, WHERE IT RUNS): holding the project folder as its
406
+ // working directory made the folder impossible to rename or delete on Windows.
407
+ const cwd = (daemon && process.env[DAEMON_CWD_ENV]) || process.cwd();
408
+ if (daemon) {
409
+ try {
410
+ process.chdir(configDir());
411
+ }
412
+ catch {
413
+ /* stays where it was started; only folder locking is at stake */
414
+ }
415
+ }
416
+ const logFile = daemon ? process.env[DAEMON_LOG_ENV] : undefined;
417
+ // A background recorder owns its log. Nothing was inherited from whoever
418
+ // started it (control/daemon.ts says why), so the audit lines — every
419
+ // `logLine` goes through `console.error` — are pointed at the file here.
420
+ const log = logFile ? new DaemonLog(logFile) : undefined;
421
+ log?.open();
422
+ const sidecar = readSidecar();
423
+ const record = projectRecord(sidecar, cwd);
424
+ // The port this project's hooks were written with. A valid BIR_CONTROL_PORT
425
+ // still wins; the per-project record is what lets two projects record at
426
+ // once. Read exactly as the SessionStart hook reads it (daemon.ts,
427
+ // projectEndpoint) — when the two disagreed, every session start replaced
428
+ // a recorder that could only bind the same wrong port again.
429
+ // BIR_CONTROL_PORT=0 is any free port (tests, the smoke run): it used to
430
+ // fall through to the default, and a test run took the real 53411.
431
+ const endpoint = projectEndpoint(cwd, process.env, sidecar);
432
+ const pinned = endpoint.pinned;
433
+ const preferredPort = endpoint.ephemeral
434
+ ? 0
435
+ : (endpoint.port ?? validPort(sidecar.controlPort) ?? DEFAULT_CONTROL_PORT);
436
+ // Open before anything logs: the journal is what `bir investigate` reads
437
+ // later, and the startup lines are part of the story.
438
+ const journalFile = openJournal(cwd);
439
+ const key = derivationKey(daemon);
440
+ // From disk, no network: listening must never wait for the service.
441
+ const keeper = new RecorderKeeper({ cwd });
442
+ const replay = buildReplayOptions(keeper, record?.replay, key);
443
+ let closing = false;
444
+ let tick;
445
+ const finish = (event, exitCode) => {
446
+ removeDiscoveryIfOwned(cwd, process.pid);
447
+ removeSupersededRecord(cwd, process.pid);
448
+ logLine(event, {});
449
+ process.exit(exitCode);
450
+ };
451
+ const shutdown = async () => {
452
+ if (closing)
453
+ return;
454
+ closing = true;
455
+ if (tick)
456
+ clearInterval(tick);
457
+ keeper.stop();
458
+ // A deadline, not a hope. close() finalizes unfinished runs and flushes
459
+ // them to the service; a service that hangs must not keep `bir down`, a
460
+ // restart onto a new version, or the machine's shutdown waiting on it.
461
+ const hard = setTimeout(() => finish("control.stop_forced", 0), SHUTDOWN_HARD_MS);
462
+ await Promise.race([
463
+ server.close().catch((err) => logLine("control.close_failed", { error: errText(err) })),
464
+ sleep(SHUTDOWN_CLOSE_MS),
465
+ ]);
466
+ clearTimeout(hard);
467
+ finish("control.stopped", 0);
468
+ };
469
+ const onSignal = () => {
470
+ // A second signal while stopping means "now": on POSIX it used to be
471
+ // swallowed, and a recorder stuck in its shutdown could not be stopped.
472
+ if (closing)
473
+ finish("control.stop_forced", 1);
474
+ void shutdown();
475
+ };
476
+ const options = {
477
+ recorder: keeper.recorder,
478
+ cwd,
479
+ port: preferredPort,
480
+ // This project's token (generate.ts); the machine-wide one for a record
481
+ // written before tokens were per project.
482
+ token: projectToken(sidecar, cwd),
483
+ wrappedServers: wrappedServersFor(cwd),
484
+ host: { app: "claude-code" },
485
+ correlationDecision: correlationDecisionFromEnv(),
486
+ noCorrelation: process.env.BIR_NO_CORRELATION === "1",
487
+ deriveRecentResults: nonNegativeInt(process.env.BIR_DERIVE_RECENT_RESULTS, 5),
488
+ intentSources: parseIntentSources(process.env.BIR_INTENT_SOURCES),
489
+ replay: replay.opts,
490
+ replaySource: replay.source,
491
+ // Both live: the recorder may sign in, refresh or sign out while it runs.
492
+ // `/health` is how `bir doctor` and `bir setup` learn which.
493
+ get recording() {
494
+ return keeper.recording;
495
+ },
496
+ get account() {
497
+ return keeper.account;
498
+ },
499
+ // In the background with a port the hooks name, a fallback port is a
500
+ // recorder every hook but SessionStart would miss. Exit with a reason instead.
501
+ portFallback: !(daemon && pinned),
502
+ // 'bir down', and the SessionStart hook replacing an out-of-date recorder,
503
+ // ask over HTTP rather than sending a signal: on Windows a signal is a kill.
504
+ onStopRequested: () => void shutdown(),
505
+ };
506
+ const server = new ControlServer(options);
507
+ let address;
508
+ for (let attempt = 0;; attempt += 1) {
509
+ try {
510
+ address = await server.listen();
511
+ break;
512
+ }
513
+ catch (err) {
514
+ const code = err.code;
515
+ if (code !== "EADDRINUSE" && code !== "EACCES")
516
+ throw err;
517
+ // Nothing answering on a taken port is a holder on its way out (the
518
+ // recorder this one replaces, still closing): a few short retries.
519
+ const verdict = await portBusy(cwd, preferredPort, server.token, code, attempt >= 4);
520
+ if (verdict === "retry") {
521
+ await sleep(500);
522
+ continue;
523
+ }
524
+ process.exit(verdict === "ours" ? 0 : 1);
525
+ }
526
+ }
527
+ clearPortProblem(cwd);
528
+ const info = {
529
+ url: address.url,
530
+ token: address.token,
531
+ sessionId: address.sessionId,
532
+ pid: process.pid,
533
+ startedAt: Date.now(),
534
+ cwd,
535
+ version: packageVersion(),
536
+ ...(daemon ? { daemon: true, logFile } : {}),
537
+ };
538
+ let lastWrite = 0;
539
+ let discoveryFile;
540
+ /**
541
+ * False once the project's hooks post to another port than this recorder's
542
+ * (`bir install --port N`, or a reinstall that picked a new one). Such a
543
+ * recorder is kept running by the SessionStart hook for the sessions that
544
+ * started before — their hooks still name this port — but it is no longer
545
+ * the project's recorder: it does not put its record back when the file
546
+ * goes, so `bir down` of the new one is not undone a minute later.
547
+ */
548
+ const stillTheProjects = (now = projectEndpoint(cwd)) => {
549
+ if (!daemon)
550
+ return true;
551
+ return now.port === undefined || now.port === address.port;
552
+ };
553
+ /** A record of this process that says something else than it would (another writer wrote it). */
554
+ const differs = (onDisk) => onDisk.url !== info.url ||
555
+ onDisk.token !== info.token ||
556
+ onDisk.sessionId !== info.sessionId ||
557
+ onDisk.startedAt !== info.startedAt ||
558
+ onDisk.version !== info.version ||
559
+ Boolean(onDisk.daemon) !== Boolean(info.daemon) ||
560
+ onDisk.logFile !== info.logFile;
561
+ const publish = () => {
562
+ try {
563
+ discoveryFile = writeDiscovery({ ...info, heartbeatAt: Date.now() });
564
+ lastWrite = Date.now();
565
+ }
566
+ catch (err) {
567
+ // The hooks still reach this process at the port they name; only the
568
+ // proxies need the file. Keep serving and try again at the next check.
569
+ logLine("control.heartbeat_failed", { error: errText(err) });
570
+ }
571
+ };
572
+ publish();
573
+ /**
574
+ * Whether this recorder — the project's, where its hooks post — should take
575
+ * the discovery file back from the live recorder `onDisk` names. A 0.2.12
576
+ * recorder left running beside this one (a reinstall moved the port while a
577
+ * window was open) rewrites the file with its own address every half hour
578
+ * and never steps aside, and every proxy that read it then bound to the old
579
+ * recorder for its whole life. So: taken back when the record names a port
580
+ * the hooks do not post to, or an older (or unstated) version. Two recorders
581
+ * of this version never contend: only one can hold the hooks' port.
582
+ */
583
+ const mayReclaim = (onDisk, expected) => {
584
+ if (expected.port !== undefined) {
585
+ if (address.port !== expected.port)
586
+ return false; // not where the hooks post: not ours to take
587
+ if (portOfUrl(onDisk.url) !== expected.port)
588
+ return true;
589
+ }
590
+ if (!onDisk.version)
591
+ return true;
592
+ const order = compareVersions(onDisk.version, packageVersion());
593
+ return order !== undefined && order < 0;
594
+ };
595
+ const reclaimedFrom = new Set();
596
+ // ── superseded (LC-1) ────────────────────────────────────────────────────
597
+ // A recorder the hooks moved away from keeps serving the windows opened
598
+ // before — and used to do so invisibly until the machine restarted: out of
599
+ // `bir status`, `bir down` and `bir uninstall`, sharing (and breaking the
600
+ // rotation of) the project's log. Now it says where it is (a superseded
601
+ // record), writes a log of its own, and exits once it has had nothing to
602
+ // serve for SUPERSEDED_EXIT_MS.
603
+ const supersededGraceMs = nonNegativeInt(process.env.BIR_SUPERSEDED_EXIT_MS, SUPERSEDED_EXIT_MS);
604
+ let superseded;
605
+ let quietSince;
606
+ let exiting = false;
607
+ const becomeSuperseded = (now) => {
608
+ if (superseded)
609
+ return;
610
+ superseded = { since: now };
611
+ quietSince = undefined;
612
+ const ownLog = log ? supersededLogPath(cwd, address.port) : undefined;
613
+ const fields = {
614
+ port: address.port,
615
+ why: "this project's hooks now post to another port; this recorder keeps serving the sessions " +
616
+ "that started before that, no longer advertises itself, and exits once none is left",
617
+ log: ownLog,
618
+ };
619
+ logLine("control.superseded", fields);
620
+ if (log && ownLog) {
621
+ log.moveTo(ownLog);
622
+ logLine("control.superseded", fields);
623
+ }
624
+ writeSupersededRecord({
625
+ cwd,
626
+ pid: process.pid,
627
+ port: address.port,
628
+ url: address.url,
629
+ token: address.token,
630
+ since: now,
631
+ version: packageVersion(),
632
+ logFile: ownLog ?? logFile,
633
+ });
634
+ };
635
+ const backToTheProject = () => {
636
+ if (!superseded)
637
+ return;
638
+ superseded = undefined;
639
+ quietSince = undefined;
640
+ removeSupersededRecord(cwd, process.pid);
641
+ if (log && logFile)
642
+ log.moveTo(logFile);
643
+ logLine("control.project_recorder_again", {
644
+ port: address.port,
645
+ why: "this project's hooks post to this recorder's port again",
646
+ });
647
+ };
648
+ const maybeExitSuperseded = (now) => {
649
+ if (!superseded || exiting || closing)
650
+ return;
651
+ const a = activityOf(server);
652
+ const backlog = pendingSendsOf(keeper.recorder);
653
+ if (a.openRuns > 0 || a.knownSessions > 0 || a.liveProxyPids > 0 || backlog > 0) {
654
+ quietSince = undefined;
655
+ return;
656
+ }
657
+ quietSince ??= now;
658
+ if (now - quietSince < supersededGraceMs)
659
+ return;
660
+ exiting = true;
661
+ logLine("control.superseded_exit", {
662
+ port: address.port,
663
+ quiet_min: Math.round((now - quietSince) / 60_000),
664
+ why: "superseded, and no session, run, proxy or unsent step left to serve",
665
+ });
666
+ void shutdown();
667
+ };
668
+ // One check a minute, on the wall clock (discovery.ts, HEARTBEAT_MS): the
669
+ // discovery file rewritten when it has gone missing, when a heartbeat's
670
+ // worth of wall time has passed — timers stop while a laptop sleeps, the
671
+ // wall clock does not — or when another writer (daemon.ts, rediscover)
672
+ // wrote a record of this process without what only it knows (that it runs
673
+ // in the background, its log, its start time), or an older recorder took
674
+ // it; a superseded recorder noticed, recorded and eventually retired; the
675
+ // log rolled when it grows past its limit, or opened again after a failure
676
+ // or after another writer rolled it; and the control directory swept once
677
+ // a day. BIR_RECORDER_TICK_MS is for tests.
678
+ let lastSweep = Date.now();
679
+ tick = setInterval(() => {
680
+ try {
681
+ const now = Date.now();
682
+ const expected = projectEndpoint(cwd);
683
+ const onDisk = readDiscovery(cwd, { ignoreAge: true });
684
+ if (stillTheProjects(expected)) {
685
+ backToTheProject();
686
+ if (!onDisk) {
687
+ publish();
688
+ }
689
+ else if (onDisk.pid === process.pid) {
690
+ if (now - lastWrite >= HEARTBEAT_MS || differs(onDisk))
691
+ publish();
692
+ }
693
+ else if (mayReclaim(onDisk, expected)) {
694
+ publish();
695
+ if (!reclaimedFrom.has(onDisk.pid)) {
696
+ reclaimedFrom.add(onDisk.pid);
697
+ logLine("control.discovery_reclaimed", {
698
+ from_pid: onDisk.pid,
699
+ from: onDisk.url,
700
+ version: onDisk.version ?? null,
701
+ why: "the project's discovery file named an older recorder, or one on a port the hooks no longer " +
702
+ "post to; new proxies must bind to the recorder the hooks reach",
703
+ });
704
+ }
705
+ }
706
+ else {
707
+ logOnce("discovery.claimed", "control.discovery_claimed", {
708
+ pid: onDisk.pid,
709
+ why: "another live recorder has written this project's discovery file; leaving it alone",
710
+ });
711
+ }
712
+ }
713
+ else {
714
+ becomeSuperseded(now);
715
+ // Still named by the file (no recorder for the new port has started
716
+ // yet): keep the record fresh until one takes it.
717
+ if (onDisk?.pid === process.pid && (now - lastWrite >= HEARTBEAT_MS || differs(onDisk)))
718
+ publish();
719
+ maybeExitSuperseded(now);
720
+ }
721
+ log?.rotateIfLarge();
722
+ if (now - lastSweep >= SWEEP_EVERY_MS) {
723
+ lastSweep = now;
724
+ sweepStaleDiscovery();
725
+ // Hand-over files hold live tool outputs; a recorder that crashed
726
+ // before its own scheduled sweep must not leave them on disk.
727
+ sweepDeliveries();
728
+ }
729
+ }
730
+ catch (err) {
731
+ logLine("control.heartbeat_failed", { error: errText(err) });
732
+ }
733
+ }, positiveInt(process.env.BIR_RECORDER_TICK_MS, DISCOVERY_CHECK_MS));
734
+ tick.unref();
735
+ const sweepSoon = setTimeout(() => {
736
+ sweepStaleDiscovery();
737
+ sweepDeliveries();
738
+ }, 5_000);
739
+ sweepSoon.unref();
740
+ keeper.start();
741
+ logLine("control.listening", {
742
+ url: address.url,
743
+ cwd,
744
+ discovery: discoveryFile,
745
+ journal: journalFile,
746
+ machine: hostname(),
747
+ version: packageVersion(),
748
+ background: daemon ? "yes" : undefined,
749
+ recorder: keeper.state,
750
+ wrapped: wrappedServersFor(cwd).join(",") || "(none)",
751
+ port: endpoint.ephemeral ? "any free (BIR_CONTROL_PORT=0)" : undefined,
752
+ // How PreToolUse answers an ordinary wrapped call. `auto` (unset) never
753
+ // answers `allow` for one, so the person's permission prompts are theirs.
754
+ correlation: process.env.BIR_NO_CORRELATION === "1" ? "off" : (correlationSetting() ?? "auto"),
755
+ });
756
+ if (correlationSetting() === "allow" && process.env.BIR_NO_CORRELATION !== "1") {
757
+ // Loud, because it changes what the person is asked: an operator's
758
+ // explicit choice, never a default.
759
+ logLine("control.correlation_allow", {
760
+ why: "BIR_CORRELATION_DECISION=allow — every wrapped MCP call is answered `allow` and runs without " +
761
+ "the permission prompt it would otherwise get; unset it to keep the prompts",
762
+ });
763
+ }
764
+ if (!endpoint.ephemeral && (preferredPort !== DEFAULT_CONTROL_PORT || sidecar.controlPort || record)) {
765
+ if (preferredPort !== address.port) {
766
+ // The hook URLs in settings.json name a port; if we could not take it,
767
+ // the hooks will POST into the void. Say so rather than looking healthy.
768
+ logLine("control.port_mismatch", {
769
+ installed: preferredPort,
770
+ actual: address.port,
771
+ why: "hooks point at the installed port — re-run `bir install` or free that port",
772
+ });
773
+ }
774
+ }
775
+ process.on("SIGINT", onSignal);
776
+ process.on("SIGTERM", onSignal);
777
+ }
778
+ //# sourceMappingURL=recorder-main.js.map