@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
@@ -22,8 +22,11 @@
22
22
  * with a working directory that must equal the project's, each needing the
23
23
  * person to know which one they are on. The hook already runs in the right
24
24
  * directory with the right environment at exactly the right moment, and Claude
25
- * Code already supervises it. What is lost is restart-on-crash mid-session,
26
- * which the proxies already survive (they fall to Tier 2 and say so).
25
+ * Code already supervises it. Restart-on-crash mid-session is the proxies'
26
+ * part: a bound proxy (or `bir-scenario`) that finds the recorder gone —
27
+ * refusing connections, its pid dead — spawns `bir-hooks revive`
28
+ * ({@link reviveDaemon}), rate limited, unless `bir down` stopped it on
29
+ * purpose.
27
30
  *
28
31
  * HOW IT IS LAUNCHED. The daemon opens its own log file (`BIR_DAEMON_LOG`) and
29
32
  * inherits nothing from our stdio. On POSIX that is a `detached` spawn with
@@ -32,139 +35,821 @@
32
35
  * short-lived `powershell.exe`: libuv's CreateProcess inherits *every*
33
36
  * inheritable handle of the parent, not only the three it was told about, and
34
37
  * a node started under a PowerShell layer (the `irm | iex` bootstrap, a `.ps1`
35
- * shim, a captured `bir up`) carries an extra copy of its own stdout pipe. A
36
- * daemon that inherits it holds that pipe open for days, and whoever is reading
37
- * it never sees EOF. `Start-Process` passes the environment and no handles.
38
+ * shim, a captured `bir up`, a `$r = claude -p …`) carries an extra copy of its
39
+ * own stdout pipe. A daemon that inherits it holds that pipe open for days, and
40
+ * whoever is reading it never sees EOF. `Start-Process` passes the environment
41
+ * and no handles. It costs about a second, once per project per boot; a direct
42
+ * spawn is faster but only safe when nothing above the hook captured its
43
+ * output, so it is an opt-in (`BIR_WINDOWS_LAUNCH=spawn`), not the default.
44
+ *
45
+ * WHERE IT RUNS. The daemon's working directory is the runner's own state
46
+ * directory, never the project: on Windows a process's current directory
47
+ * cannot be renamed or deleted, and an invisible recorder holding the project
48
+ * folder made "delete the folder", `git worktree remove` and a re-clone fail
49
+ * with "in use by another program". The project is passed as `BIR_DAEMON_CWD`.
50
+ *
51
+ * WHAT IT MAY NOT DO. Every path is bounded: the SessionStart hook has a budget
52
+ * well inside Claude Code's hook timeout (ensure-hook.ts), a recorder that is
53
+ * alive but slow is never mistaken for a dead one (that started a duplicate
54
+ * that could not bind), and nothing here touches the network — the recorder
55
+ * listens before it signs in (recorder-keeper.ts).
38
56
  */
39
57
  import { spawn, spawnSync } from "node:child_process";
40
- import { existsSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeSync, closeSync, } from "node:fs";
41
- import { dirname, join, resolve as resolvePath } from "node:path";
58
+ import { Console } from "node:console";
59
+ import { closeSync, createWriteStream, existsSync, fstatSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, writeSync, } from "node:fs";
60
+ import { Writable } from "node:stream";
61
+ import { dirname, isAbsolute, join, resolve as resolvePath } from "node:path";
42
62
  import { fileURLToPath } from "node:url";
43
63
  import { ControlClient } from "./client.js";
44
- import { pidAlive, readDiscovery, removeDiscovery } from "./discovery.js";
45
- import { controlDir, controlKey, daemonLogPath, ensureDir, logsDir } from "./paths.js";
46
- import { nodeFlagsToCarry } from "../config/generate.js";
64
+ import { listSupersededRecorders, pidAlive, readDiscovery, removeDiscovery, removeDiscoveryIfOwned, removeSupersededRecord, writeDiscovery, writeSupersededRecord, } from "./discovery.js";
65
+ import { configDir, controlDir, controlKey, daemonLogPath, downMarkerPath, ensureDir, logsDir, normalizePath, portProblemPath, } from "./paths.js";
66
+ import { nodeFlagsToCarry, projectRecord, projectToken, readSidecar, } from "../config/generate.js";
67
+ import { writeFileAtomic } from "../util/atomic-write.js";
68
+ import { timeoutSignal } from "../util/service-health.js";
47
69
  import { errText, logLine } from "../util/log.js";
48
70
  import { packageVersion } from "../util/version.js";
49
- /** A log file larger than this is rolled over to `.1` when a daemon starts. */
71
+ export { listSupersededRecorders } from "./discovery.js";
72
+ /** A log file larger than this is rolled over to `.1` (at start, and while it runs). */
50
73
  export const LOG_ROTATE_BYTES = 8 * 1024 * 1024;
51
74
  /** Marks a `bir-hooks` as started by this module; it says so in its discovery file. */
52
75
  export const DAEMON_ENV = "BIR_DAEMON";
53
76
  /** Where that process writes its audit lines. It opens the file itself. */
54
77
  export const DAEMON_LOG_ENV = "BIR_DAEMON_LOG";
78
+ /**
79
+ * The project a background recorder serves. It runs in the runner's state
80
+ * directory, not the project (see WHERE IT RUNS above), so it cannot learn the
81
+ * project from its own working directory.
82
+ */
83
+ export const DAEMON_CWD_ENV = "BIR_DAEMON_CWD";
55
84
  /**
56
85
  * The setup token's environment variable (`bir setup` reads it when `--token`
57
86
  * is absent). Never handed to the recorder: it is a one-use secret for signing
58
87
  * in, and a process that lives for days has no business holding it.
59
88
  */
60
89
  export const SETUP_TOKEN_ENV = "BIR_SETUP_TOKEN";
90
+ /**
91
+ * An Anthropic key meant for the runner's derivation, and nothing else. Set
92
+ * explicitly; it is the only key the recorder uses by default.
93
+ */
94
+ export const DERIVE_KEY_ENV = "BIR_DERIVE_API_KEY";
95
+ /**
96
+ * `1` lets the recorder use an `ANTHROPIC_API_KEY` from its environment for
97
+ * derivation, as it did before. Off by default: the hook-started recorder has
98
+ * Claude Code's environment, and an API-billed Claude Code has its key there —
99
+ * using it silently spent the person's own money on something the service
100
+ * does for a signed-in runner.
101
+ */
102
+ export const USE_API_KEY_ENV = "BIR_USE_ANTHROPIC_API_KEY";
103
+ /** `spawn` launches the Windows daemon directly instead of through PowerShell. */
104
+ export const WINDOWS_LAUNCH_ENV = "BIR_WINDOWS_LAUNCH";
105
+ /**
106
+ * `bir-hooks revive --cwd <project>`: bring the project's recorder back when
107
+ * it is gone. Spawned detached by a bound `bir-proxy` or `bir-scenario` whose
108
+ * recorder refuses connections and whose pid is dead (see {@link reviveDaemon}).
109
+ */
110
+ export const REVIVE_ARG = "revive";
61
111
  /** A start lock older than this belongs to a starter that died; it is ignored. */
62
112
  const LOCK_STALE_MS = 30_000;
113
+ /** How long a recorder has to answer `/health` before it counts as busy. */
114
+ const HEALTH_TIMEOUT_MS = 3_000;
115
+ /**
116
+ * {@link EnsureOptions.probeIdle}: the gap between the two `/health` probes
117
+ * that tell a recorder that only *reports* a run open from one doing work.
118
+ */
119
+ export const IDLE_PROBE_GAP_MS = 2_500;
120
+ /** What must be left of the budget after that gap to stop and start a recorder. */
121
+ const IDLE_PROBE_RESERVE_MS = 6_000;
122
+ /** The recorder is running but did not answer in time: busy, not gone. */
123
+ export class RecorderBusyError extends Error {
124
+ pid;
125
+ constructor(pid) {
126
+ super(`the recorder (pid ${pid}) is running but did not answer within ${HEALTH_TIMEOUT_MS / 1000}s — ` +
127
+ "it is busy, not gone, so no second one was started");
128
+ this.name = "RecorderBusyError";
129
+ this.pid = pid;
130
+ }
131
+ }
132
+ /** A revive found the project's recorder stopped on purpose (`bir down`): it is left down. */
133
+ export class RecorderStoppedError extends Error {
134
+ constructor() {
135
+ super("the recorder was stopped on purpose (`bir down`); the next session start or `bir up` starts it");
136
+ this.name = "RecorderStoppedError";
137
+ }
138
+ }
63
139
  /** Absolute path of `dist/bin/bir-hooks.js` in the package this module runs from. */
64
140
  export function hooksScriptPath() {
65
141
  return resolvePath(dirname(fileURLToPath(import.meta.url)), "..", "bin", "bir-hooks.js");
66
142
  }
143
+ /**
144
+ * `GET /health` with the bearer token, bounded, with the failure kept apart:
145
+ * "refused" means gone, "timeout" means busy, and the difference is the one
146
+ * that decides whether a second recorder is started.
147
+ */
148
+ export async function probeHealth(url, token, timeoutMs = HEALTH_TIMEOUT_MS) {
149
+ let res;
150
+ try {
151
+ res = await fetch(`${url.replace(/\/+$/, "")}/health`, {
152
+ headers: { authorization: `Bearer ${token}` },
153
+ signal: timeoutSignal(Math.max(1, timeoutMs)),
154
+ });
155
+ }
156
+ catch (err) {
157
+ const e = err;
158
+ if (e?.name === "TimeoutError" || e?.name === "AbortError")
159
+ return { kind: "timeout" };
160
+ return { kind: "refused", reason: e?.cause?.code ?? errText(err) };
161
+ }
162
+ if (res.status === 401 || res.status === 403) {
163
+ await res.body?.cancel().catch(() => undefined);
164
+ return { kind: "unauthorized", status: res.status };
165
+ }
166
+ if (!res.ok) {
167
+ await res.body?.cancel().catch(() => undefined);
168
+ return { kind: "foreign", status: res.status };
169
+ }
170
+ let body;
171
+ try {
172
+ body = await res.json();
173
+ }
174
+ catch (err) {
175
+ const e = err;
176
+ if (e?.name === "TimeoutError" || e?.name === "AbortError")
177
+ return { kind: "timeout" };
178
+ return { kind: "foreign", status: res.status };
179
+ }
180
+ if (body && typeof body === "object" && typeof body.pid === "number") {
181
+ return { kind: "ok", health: body };
182
+ }
183
+ return { kind: "foreign", status: res.status };
184
+ }
185
+ async function lookup(cwd, timeoutMs = HEALTH_TIMEOUT_MS) {
186
+ // Age is not evidence here: a recorder woken after a weekend is live with a
187
+ // days-old file. The pid and the recorder's own answer decide.
188
+ const info = readDiscovery(cwd, { ignoreAge: true });
189
+ if (!info)
190
+ return { kind: "none" };
191
+ const probe = await probeHealth(info.url, info.token, timeoutMs);
192
+ if (probe.kind === "ok" && probe.health.pid === info.pid) {
193
+ return { kind: "live", status: { info, health: probe.health } };
194
+ }
195
+ // A timeout is busy whatever the file's age. The pid is alive (readDiscovery
196
+ // checked), and a recorder just woken from a long sleep has an old file
197
+ // *and* a busy event loop — the first minute after a wake is exactly when
198
+ // its heartbeat has not caught up yet. Calling that dead removed the live
199
+ // recorder's file and started a duplicate that could not bind.
200
+ if (probe.kind === "timeout")
201
+ return { kind: "busy", info };
202
+ return { kind: "dead", info };
203
+ }
67
204
  /**
68
205
  * The live control server for `cwd`, or undefined.
69
206
  *
70
- * "Live" means the discovery file is fresh, its pid is alive, **and** it
71
- * answers `/health` with its token. A file left by a crash fails the second
72
- * test; a process that hung, or a recycled pid, fails the third.
207
+ * "Live" means the discovery file names a pid that is alive **and** that
208
+ * answers `/health` with its token. A file left by a crash fails the first
209
+ * test; a process that hung, or a recycled pid, fails the second.
73
210
  */
74
211
  export async function daemonStatus(cwd) {
75
- const info = readDiscovery(cwd);
76
- if (!info)
212
+ const found = await lookup(cwd);
213
+ return found.kind === "live" ? found.status : undefined;
214
+ }
215
+ /**
216
+ * This project's recorder, found by its port rather than its discovery file,
217
+ * and its file written again. For a recorder whose file was removed (by hand,
218
+ * by an older runner that took a slow recorder for a dead one): starting a
219
+ * second one would only find the port taken.
220
+ *
221
+ * A file that already names the recorder that answered is left as it is. What
222
+ * is written here is rebuilt from `/health`, which does not say whether the
223
+ * process runs in the background or where its log is: overwriting the
224
+ * recorder's own, complete record with it made `bir status` lose the log path
225
+ * and the start time, and hid the recorder from the reinstall check (which
226
+ * only replaces background recorders) — for the half hour until its own
227
+ * heartbeat. The recorder also rewrites a record of itself that differs from
228
+ * what it would write (recorder-main.ts), so what this writes is short-lived.
229
+ */
230
+ export async function rediscover(cwd, port, token, timeoutMs = HEALTH_TIMEOUT_MS) {
231
+ const url = `http://127.0.0.1:${port}`;
232
+ const probe = await probeHealth(url, token, timeoutMs);
233
+ if (probe.kind !== "ok")
77
234
  return undefined;
78
- const health = await new ControlClient(info.url, info.token, 3_000).health();
79
- if (!health || health.pid !== info.pid)
235
+ const h = probe.health;
236
+ if (typeof h.cwd !== "string" || normalizePath(h.cwd) !== normalizePath(cwd))
80
237
  return undefined;
81
- return { info, health };
238
+ const pid = h.pid;
239
+ if (!pidAlive(pid))
240
+ return undefined;
241
+ const existing = readDiscovery(cwd, { ignoreAge: true });
242
+ if (existing && existing.pid === pid && portOf(existing.url) === port && existing.token === token) {
243
+ return { info: existing, health: h };
244
+ }
245
+ const now = Date.now();
246
+ const info = {
247
+ url,
248
+ token,
249
+ sessionId: typeof h.sessionId === "string" ? h.sessionId : "",
250
+ pid,
251
+ startedAt: now,
252
+ heartbeatAt: now,
253
+ cwd,
254
+ version: typeof h.hooksVersion === "string" ? h.hooksVersion : undefined,
255
+ };
256
+ try {
257
+ writeDiscovery(info);
258
+ }
259
+ catch (err) {
260
+ logLine("daemon.rediscover_failed", { error: errText(err) });
261
+ }
262
+ logLine("daemon.rediscovered", {
263
+ pid,
264
+ url,
265
+ why: "this project's recorder was running without its discovery file; wrote it again",
266
+ });
267
+ return { info, health: h };
82
268
  }
83
269
  /** How many of the recorder's sessions are mid-run right now. */
84
270
  export function activeSessions(health) {
85
271
  const sessions = health.sessions ?? [];
86
272
  return sessions.filter((s) => s.active === true).length;
87
273
  }
274
+ /**
275
+ * How many sessions the recorder has seen at all, mid-run or between turns.
276
+ * The control server keeps a session until it exits, so this counts every
277
+ * Claude Code window that may still send it a hook.
278
+ */
279
+ function knownSessions(health) {
280
+ const sessions = health.sessions;
281
+ return Array.isArray(sessions) ? sessions.length : 0;
282
+ }
283
+ /**
284
+ * `a` against `b` as versions: -1, 0 or 1, or undefined when either is not
285
+ * `x.y.z` (with an optional pre-release, which sorts below its release, and
286
+ * build metadata, which is ignored).
287
+ */
288
+ export function compareVersions(a, b) {
289
+ const parse = (v) => {
290
+ const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(v.trim());
291
+ if (!m)
292
+ return undefined;
293
+ return { nums: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4]?.split(".") };
294
+ };
295
+ const x = parse(a);
296
+ const y = parse(b);
297
+ if (!x || !y)
298
+ return undefined;
299
+ for (let i = 0; i < 3; i += 1) {
300
+ if (x.nums[i] !== y.nums[i])
301
+ return x.nums[i] < y.nums[i] ? -1 : 1;
302
+ }
303
+ if (!x.pre && !y.pre)
304
+ return 0;
305
+ if (!x.pre)
306
+ return 1;
307
+ if (!y.pre)
308
+ return -1;
309
+ for (let i = 0; i < Math.max(x.pre.length, y.pre.length); i += 1) {
310
+ const p = x.pre[i];
311
+ const q = y.pre[i];
312
+ if (p === undefined)
313
+ return -1;
314
+ if (q === undefined)
315
+ return 1;
316
+ const pn = /^\d+$/.test(p);
317
+ const qn = /^\d+$/.test(q);
318
+ if (pn && qn) {
319
+ if (Number(p) !== Number(q))
320
+ return Number(p) < Number(q) ? -1 : 1;
321
+ continue;
322
+ }
323
+ if (pn !== qn)
324
+ return pn ? -1 : 1;
325
+ if (p !== q)
326
+ return p < q ? -1 : 1;
327
+ }
328
+ return 0;
329
+ }
330
+ /**
331
+ * True for the `/health` of a recorder that predates the idle bound (0.2.12
332
+ * and older): it lists sessions, and none of them says how long it has been
333
+ * idle. Its `active` is then "a run is open", with nothing to close one that
334
+ * was abandoned. An empty list says nothing either way (and counts none active).
335
+ */
336
+ export function isLegacyHealth(health) {
337
+ const sessions = health.sessions;
338
+ if (!Array.isArray(sessions) || sessions.length === 0)
339
+ return false;
340
+ return sessions.every((s) => !s || typeof s !== "object" || typeof s.idleMs !== "number");
341
+ }
342
+ /** How many of the proxies a recorder lists as registered are alive right now. */
343
+ function liveRegisteredProxies(health) {
344
+ const list = health.registeredProxies;
345
+ if (!Array.isArray(list))
346
+ return 0;
347
+ const pids = new Set();
348
+ for (const r of list) {
349
+ const pid = r?.pid;
350
+ if (typeof pid === "number" && pidAlive(pid))
351
+ pids.add(pid);
352
+ }
353
+ return pids.size;
354
+ }
355
+ /** Every listed session with its step count, in one comparable string. */
356
+ function stepsSignature(health) {
357
+ const sessions = Array.isArray(health.sessions) ? health.sessions : [];
358
+ return sessions
359
+ .map((s) => `${String(s?.sessionId ?? "")}:${typeof s?.steps === "number" ? s.steps : "?"}`)
360
+ .sort()
361
+ .join("|");
362
+ }
363
+ /**
364
+ * {@link EnsureOptions.probeIdle}: whether a legacy recorder that reports a
365
+ * run open is in fact doing nothing — no registered proxy alive (an open
366
+ * window's MCP servers are its proxies), and nothing moved between two probes
367
+ * {@link IDLE_PROBE_GAP_MS} apart. False whenever it cannot tell, or when the
368
+ * budget has no room for the wait and a restart after it.
369
+ */
370
+ async function legacyIdle(live, deadline) {
371
+ if (!isLegacyHealth(live.health))
372
+ return false;
373
+ if (liveRegisteredProxies(live.health) > 0)
374
+ return false;
375
+ if (remaining(deadline) < IDLE_PROBE_GAP_MS + IDLE_PROBE_RESERVE_MS)
376
+ return false;
377
+ const before = stepsSignature(live.health);
378
+ await sleep(IDLE_PROBE_GAP_MS);
379
+ const again = await probeHealth(live.info.url, live.info.token, clamp(remaining(deadline), 250, HEALTH_TIMEOUT_MS));
380
+ if (again.kind !== "ok" || again.health.pid !== live.info.pid)
381
+ return false;
382
+ if (liveRegisteredProxies(again.health) > 0)
383
+ return false;
384
+ return stepsSignature(again.health) === before;
385
+ }
386
+ /** A TCP port number, or undefined for anything that is not one. */
387
+ export function validPort(value) {
388
+ if (value === undefined || value === null || value === "")
389
+ return undefined;
390
+ const n = Number(value);
391
+ return Number.isInteger(n) && n > 0 && n < 65536 ? n : undefined;
392
+ }
393
+ /** The port a recorder URL listens on, or undefined when it names none. */
394
+ function portOf(url) {
395
+ try {
396
+ return validPort(new URL(url).port);
397
+ }
398
+ catch {
399
+ return undefined;
400
+ }
401
+ }
402
+ /**
403
+ * `BIR_CONTROL_PORT` read once, for everything that reads it: `"0"` is any
404
+ * free port, a valid port is that port, and anything else (unset, empty, not
405
+ * a number, out of range) is no setting at all.
406
+ */
407
+ export function controlPortFromEnv(env = process.env) {
408
+ const raw = env.BIR_CONTROL_PORT?.trim();
409
+ if (raw === "0")
410
+ return 0;
411
+ return validPort(raw);
412
+ }
413
+ /**
414
+ * The one reading of "which port and token does this project use", shared by
415
+ * the recorder (which binds it) and by {@link ensureDaemon} (which checks a
416
+ * running recorder against it). They used to read `BIR_CONTROL_PORT`
417
+ * differently: the recorder took an empty or invalid value as "the default
418
+ * port, pinned", the check ignored it and used the project's port — and every
419
+ * session start then "replaced" a recorder that could only ever bind the
420
+ * default port again. An invalid value is now ignored by both, and `0` means
421
+ * "any free port" to both (see {@link ProjectEndpoint.ephemeral}).
422
+ */
423
+ export function projectEndpoint(cwd, env = process.env, sidecar) {
424
+ const setting = controlPortFromEnv(env);
425
+ const ephemeral = setting === 0;
426
+ const fromEnv = ephemeral ? undefined : setting;
427
+ let installed = sidecar;
428
+ if (!installed) {
429
+ try {
430
+ installed = readSidecar();
431
+ }
432
+ catch {
433
+ installed = undefined;
434
+ }
435
+ }
436
+ const record = installed ? projectRecord(installed, cwd) : undefined;
437
+ const port = ephemeral ? undefined : (fromEnv ?? validPort(record?.port));
438
+ return {
439
+ port,
440
+ token: installed && record ? projectToken(installed, cwd) : undefined,
441
+ pinned: port !== undefined,
442
+ ...(ephemeral ? { ephemeral: true } : {}),
443
+ };
444
+ }
445
+ /**
446
+ * Why a running background recorder does not serve this project's hooks as
447
+ * they are written now, or undefined when it does.
448
+ *
449
+ * `port` it listens where the hooks no longer post (`bir install --port N`,
450
+ * or `bir uninstall` + `bir setup`, which picks a new port);
451
+ * `token` it listens on the right port but refuses the token the hooks now
452
+ * send (the project was installed again).
453
+ *
454
+ * A recorder that also accepts the project's current token (a control server
455
+ * that re-reads it on a mismatch) is not a mismatch: it serves both the
456
+ * sessions that started before the reinstall and the ones after. A recorder
457
+ * somebody runs in a terminal is theirs to manage and is left alone.
458
+ */
459
+ async function endpointMismatch(info, expected, timeoutMs) {
460
+ const port = portMismatch(info, expected);
461
+ if (port)
462
+ return { kind: "port", why: port };
463
+ if (info.daemon !== true)
464
+ return undefined;
465
+ if (expected.token !== undefined && info.token !== expected.token) {
466
+ const probe = await probeHealth(info.url, expected.token, timeoutMs);
467
+ if (probe.kind === "ok" && probe.health.pid === info.pid)
468
+ return undefined;
469
+ return {
470
+ kind: "token",
471
+ why: "its token is not the one this project's hooks send (the project was installed again)",
472
+ };
473
+ }
474
+ return undefined;
475
+ }
476
+ /** {@link endpointMismatch} for the port alone — no network. */
477
+ function portMismatch(info, expected) {
478
+ if (info.daemon !== true || expected.port === undefined)
479
+ return undefined;
480
+ const port = portOf(info.url);
481
+ return port === expected.port ? undefined : `it listens on port ${port ?? "?"}, and the hooks post to ${expected.port}`;
482
+ }
483
+ function remaining(deadline) {
484
+ return Math.max(0, deadline - Date.now());
485
+ }
486
+ function clamp(n, lo, hi) {
487
+ return Math.min(hi, Math.max(lo, n));
488
+ }
88
489
  /**
89
490
  * The control server for `cwd`, started in the background if none is live.
90
491
  *
91
- * Idempotent: a live one is returned as-is. Throws when a fresh process did
92
- * not come up in time, with the tail of its log, because "it did not start" is
93
- * the one message that must carry its own diagnosis.
492
+ * Idempotent: a live one is returned as-is — unless it is an older version
493
+ * with nothing mid-run (`restartOnSkew`; with `probeIdle`, a legacy
494
+ * recorder whose "mid-run" is a run nobody will finish counts as idle), or a
495
+ * background recorder that the project's current hooks do not reach
496
+ * (another port or token). Any call but a revive ends a `bir down`.
497
+ *
498
+ * WHAT A REINSTALL MAY NOT BREAK. Claude Code reads a session's hooks when the
499
+ * session starts and keeps them: every window opened before `bir install
500
+ * --port N` or `bir uninstall` + `bir setup` still posts to the old port with
501
+ * the old token, until it is closed. The old recorder is what answers them. So
502
+ *
503
+ * - another **port**: the old recorder is left running for the sessions it
504
+ * has seen, and a second one is started on the new port — two ports, no
505
+ * conflict. The new one takes the discovery file; the old one steps back
506
+ * from it, is recorded as superseded (`listSupersededRecorders`, so
507
+ * `bir status`/`bir down` can find it), writes a log of its own, and
508
+ * exits once it has had nothing left to serve for a day
509
+ * (recorder-main.ts). A recorder that has never seen a session is simply
510
+ * replaced;
511
+ * - another **token** on the same port: only one process can listen there.
512
+ * It is replaced between turns (a restart then costs the old windows their
513
+ * next hooks, and gives every new one working hooks), never mid-run.
514
+ *
515
+ * A recorder that is alive but slow to answer is never replaced and never
516
+ * duplicated on its own port: this throws {@link RecorderBusyError} instead.
517
+ *
518
+ * Throws when a fresh process did not come up within the budget, with the
519
+ * tail of its log, because "it did not start" is the one message that must
520
+ * carry its own diagnosis.
94
521
  */
95
522
  export async function ensureDaemon(cwd, opts = {}) {
96
- const timeoutMs = opts.timeoutMs ?? 15_000;
523
+ const budgetMs = opts.timeoutMs ?? 15_000;
524
+ const deadline = Date.now() + budgetMs;
525
+ const env = opts.env ?? process.env;
526
+ const expected = projectEndpoint(cwd, env);
97
527
  let restarted = false;
98
- const live = await daemonStatus(cwd);
99
- if (live) {
528
+ // Whoever asks for a recorder here — a session start, `bir up`, `bir setup`
529
+ // — ends a `bir down`. A revive is the one caller that must honour it.
530
+ if (opts.revive) {
531
+ if (isMarkedDown(cwd))
532
+ throw new RecorderStoppedError();
533
+ }
534
+ else {
535
+ clearDownMarker(cwd);
536
+ }
537
+ const found = await lookup(cwd, clamp(remaining(deadline), 250, HEALTH_TIMEOUT_MS));
538
+ if (found.kind === "live") {
539
+ const live = found.status;
540
+ const mismatch = await endpointMismatch(live.info, expected, clamp(remaining(deadline), 250, HEALTH_TIMEOUT_MS));
100
541
  const theirs = String(live.health.hooksVersion ?? live.info.version ?? "");
101
- const skewed = opts.restartOnSkew && theirs && theirs !== packageVersion();
102
- if (!skewed || activeSessions(live.health) > 0) {
542
+ const ours = packageVersion();
543
+ // Only an older recorder is replaced. Two runner copies on one machine
544
+ // (an old global install beside the install line's own) used to replace
545
+ // each other's recorder at every session start, each swap refusing the
546
+ // other windows' hooks for a moment. An unreadable version is skew, as before.
547
+ const order = theirs ? compareVersions(theirs, ours) : undefined;
548
+ const newer = order !== undefined && order > 0;
549
+ const skewed = Boolean(opts.restartOnSkew && theirs && (order === undefined ? theirs !== ours : order < 0));
550
+ const active = activeSessions(live.health);
551
+ const known = knownSessions(live.health);
552
+ // A legacy recorder's "mid-run" can be a run nobody will ever finish
553
+ // (EnsureOptions.probeIdle). Asked at most once, and only where it decides.
554
+ let idle;
555
+ const idleDespiteActive = async () => {
556
+ if (active === 0 || opts.probeIdle !== true)
557
+ return false;
558
+ idle ??= await legacyIdle(live, deadline);
559
+ return idle;
560
+ };
561
+ if (mismatch?.kind === "port" && known > 0) {
562
+ logLine("daemon.start_alongside", {
563
+ cwd,
564
+ pid: live.info.pid,
565
+ sessions: known,
566
+ why: `${mismatch.why}; it keeps serving the ${known} session(s) that started before that, ` +
567
+ "and a recorder for the new port is started beside it",
568
+ });
569
+ // Findable and stoppable from now on (`bir status`, `bir down`): the
570
+ // discovery file is about to name the new one only. It rewrites this
571
+ // itself once it notices, and removes it when it exits.
572
+ noteSuperseded(cwd, live.info, theirs);
573
+ }
574
+ else if (mismatch?.kind === "token" && active > 0 && !(await idleDespiteActive())) {
575
+ logLine("daemon.restart_deferred", {
576
+ cwd,
577
+ pid: live.info.pid,
578
+ why: `${mismatch.why}; a session is mid-run, so it is replaced at the next session start between turns`,
579
+ });
580
+ return { status: live, started: false, restarted: false };
581
+ }
582
+ else if (!mismatch && (!skewed || (active > 0 && !(await idleDespiteActive())))) {
583
+ if (opts.restartOnSkew && newer) {
584
+ logLine("daemon.newer_kept", {
585
+ cwd,
586
+ pid: live.info.pid,
587
+ running: theirs,
588
+ package: ours,
589
+ why: "the running recorder is a newer version than this package (another runner copy on this machine); it is kept",
590
+ });
591
+ }
103
592
  return { status: live, started: false, restarted: false };
104
593
  }
105
- // An upgraded package with last week's recorder still running is the
106
- // "half-upgraded machine" of docs/installRun.md §8 — the update that looks
107
- // like success. Nothing is mid-run, so this is the free moment to fix it.
108
- logLine("daemon.restart", {
594
+ else {
595
+ // An upgraded package with last week's recorder still running is the
596
+ // "half-upgraded machine" of docs/installRun.md §8 — the update that
597
+ // looks like success. Nothing is mid-run, so this is the free moment to
598
+ // fix it. The same moment replaces a recorder the hooks no longer reach.
599
+ logLine("daemon.restart", {
600
+ cwd,
601
+ from: theirs,
602
+ to: ours,
603
+ why: mismatch?.why ??
604
+ (idle
605
+ ? "the recorder is an older version than the package; it predates the idle bound and reports a run " +
606
+ "open, but nothing moved between two looks and no proxy of it is alive"
607
+ : "the recorder is an older version than the package; no session is mid-run"),
608
+ });
609
+ const stopped = await stopDaemon(cwd, {
610
+ timeoutMs: clamp(remaining(deadline) - 5_000, 1_000, 10_000),
611
+ markDown: false,
612
+ });
613
+ // A recorder on another port is no obstacle to the new one; on the same
614
+ // port it is, so keep the one that works rather than start one that
615
+ // cannot bind.
616
+ if (!stopped.stopped && mismatch?.kind !== "port") {
617
+ logLine("daemon.restart_skipped", {
618
+ pid: live.info.pid,
619
+ why: "the old recorder did not stop; it keeps serving, and the next session tries again",
620
+ });
621
+ return { status: live, started: false, restarted: false };
622
+ }
623
+ restarted = stopped.stopped;
624
+ }
625
+ }
626
+ else if (found.kind === "busy") {
627
+ // Busy on the port the hooks post to: a second recorder could not bind
628
+ // it, and the busy one answers once it catches up. Busy somewhere else:
629
+ // it is in nobody's way, and the hooks need a recorder where they post.
630
+ const elsewhere = portMismatch(found.info, expected);
631
+ if (!elsewhere)
632
+ throw new RecorderBusyError(found.info.pid);
633
+ logLine("daemon.start_alongside", {
109
634
  cwd,
110
- from: theirs,
111
- to: packageVersion(),
112
- why: "the recorder is an older version than the package; no session is mid-run",
635
+ pid: found.info.pid,
636
+ why: `${elsewhere}; it is busy, and is left running — a recorder for the new port is started beside it`,
113
637
  });
114
- await stopDaemon(cwd);
115
- restarted = true;
638
+ noteSuperseded(cwd, found.info, found.info.version ?? "");
116
639
  }
117
- else if (readDiscovery(cwd)) {
118
- // A file whose pid is alive but that nothing answers for: a crash whose pid
119
- // was recycled. Gone now, so no proxy resolves it in the moments before the
120
- // new one writes its own.
121
- removeDiscovery(cwd);
640
+ else if (found.kind === "dead") {
641
+ // A file whose pid is gone, or alive but not answering as ours (a crash
642
+ // whose pid was recycled). Gone now, so no proxy resolves it in the
643
+ // moments before the new one writes its own — and only that file: several
644
+ // revives may race here, and the recorder one of them just started must
645
+ // keep the record it wrote.
646
+ removeDiscoveryIfOwned(cwd, found.info.pid);
647
+ }
648
+ // No usable record (or only one for another port) — but this project's
649
+ // recorder may still be serving the port the hooks name. Ask the port before
650
+ // starting a second one that could not bind.
651
+ if (!restarted && expected.port !== undefined && expected.token !== undefined) {
652
+ const serving = await rediscover(cwd, expected.port, expected.token, clamp(remaining(deadline), 250, HEALTH_TIMEOUT_MS));
653
+ if (serving)
654
+ return { status: serving, started: false, restarted: false };
122
655
  }
656
+ // What is waited for below must be where the hooks post: a recorder left
657
+ // running on the old port (above) still owns the discovery file until the
658
+ // new one writes its own, and is not the answer.
659
+ const accept = (s) => portMismatch(s.info, expected) === undefined;
123
660
  // Two Claude Code windows opened together in one project both run the
124
661
  // SessionStart hook. The first to take the lock spawns; the second waits for
125
- // that spawn rather than racing it onto another port.
662
+ // that spawn rather than racing it onto another port — but only while the
663
+ // first is alive and holds the lock, and never past this call's budget.
126
664
  const lock = lockPath(cwd);
665
+ const logFile = daemonLogPath(cwd);
127
666
  if (!acquireLock(lock)) {
128
- const theirs = await waitForDaemon(cwd, undefined, timeoutMs);
129
- if (theirs)
130
- return { status: theirs, started: false, restarted: false };
131
- releaseLock(lock); // theirs died or hung; the lock is ours to take
667
+ const holder = lockHolder(lock);
668
+ const waited = await waitForDaemon(cwd, {
669
+ deadline,
670
+ accept,
671
+ stopWhen: () => !existsSync(lock) || (holder !== undefined && !pidAlive(holder)),
672
+ });
673
+ if (waited.status)
674
+ return { status: waited.status, started: false, restarted };
675
+ if (holder !== undefined && pidAlive(holder) && existsSync(lock)) {
676
+ throw new Error(`another start of the recorder is still in progress (pid ${holder}) — ${tailOf(logFile)}`);
677
+ }
678
+ releaseLock(lock); // the holder died without releasing it
132
679
  if (!acquireLock(lock))
133
680
  throw new Error("another recorder start is in progress — try again");
134
681
  }
135
682
  try {
683
+ // A `bir down` that landed while this revive looked around wins.
684
+ if (opts.revive && isMarkedDown(cwd))
685
+ throw new RecorderStoppedError();
136
686
  const script = opts.hooksScript ?? hooksScriptPath();
137
- const logFile = daemonLogPath(cwd);
138
687
  ensureDir(logsDir());
139
688
  rotateLog(logFile);
140
- const env = {
141
- ...(opts.env ?? process.env),
142
- [DAEMON_ENV]: "1",
143
- [DAEMON_LOG_ENV]: logFile,
144
- };
145
- delete env[SETUP_TOKEN_ENV];
146
- const launched = process.platform === "win32"
147
- ? launchWindows(script, cwd, env)
148
- : launchPosix(script, cwd, env);
149
- const status = await waitForDaemon(cwd, launched.pid, timeoutMs, () => {
150
- if (launched.exited()) {
151
- throw new Error(`the recorder exited before it was ready — ${tailOf(logFile)}`);
152
- }
153
- });
154
- if (!status) {
155
- throw new Error(`the recorder did not answer within ${Math.round(timeoutMs / 1000)}s — ${tailOf(logFile)}`);
689
+ const childEnv = daemonEnv(env, cwd, logFile);
690
+ const launchedAt = Date.now();
691
+ const launched = process.platform === "win32" && childEnv[WINDOWS_LAUNCH_ENV] !== "spawn"
692
+ ? launchWindows(script, childEnv, clamp(remaining(deadline) - 1_000, 2_000, 8_000))
693
+ : launchDetached(script, childEnv);
694
+ const waited = await waitForDaemon(cwd, { pid: launched.pid, deadline, exited: launched.exited, accept });
695
+ if (waited.status)
696
+ return { status: waited.status, started: waited.status.info.pid === launched.pid, restarted };
697
+ // Only a problem this launch reported; an older one may since have cleared.
698
+ const problem = readPortProblem(cwd);
699
+ const why = problem && problem.at >= launchedAt - 1_000 ? ` — ${problem.detail}; ${problem.fix}` : "";
700
+ if (waited.exited) {
701
+ throw new Error(`the recorder exited before it was ready${why} — ${tailOf(logFile)}`);
156
702
  }
157
- return { status, started: true, restarted };
703
+ throw new Error(`the recorder did not answer within ${Math.round(budgetMs / 1000)}s${why} — ${tailOf(logFile)}`);
158
704
  }
159
705
  finally {
160
706
  releaseLock(lock);
161
707
  }
162
708
  }
163
- function launchPosix(script, cwd, env) {
164
- const child = spawn(process.execPath, [...nodeFlagsToCarry(), script], {
709
+ /** Record a recorder the hooks moved away from as superseded (see discovery.ts). */
710
+ function noteSuperseded(cwd, info, version) {
711
+ const port = portOf(info.url);
712
+ if (port === undefined)
713
+ return;
714
+ writeSupersededRecord({
165
715
  cwd,
716
+ pid: info.pid,
717
+ port,
718
+ url: info.url,
719
+ token: info.token,
720
+ since: Date.now(),
721
+ version: version || info.version,
722
+ logFile: info.logFile,
723
+ });
724
+ }
725
+ // ── `bir down`, and bringing a recorder back ───────────────────────────────
726
+ /**
727
+ * Mark `cwd`'s recorder as stopped on purpose. {@link stopDaemon} does it; a
728
+ * revive then leaves the project without a recorder until something that
729
+ * asks for one on purpose — a session start, `bir up`, `bir setup` — clears
730
+ * it. Never throws.
731
+ */
732
+ export function markDown(cwd) {
733
+ try {
734
+ ensureDir(controlDir());
735
+ writeFileSync(downMarkerPath(cwd), `${process.pid} ${new Date().toISOString()}\n`, { mode: 0o600 });
736
+ }
737
+ catch {
738
+ /* without the marker a proxy may bring it back; that is all */
739
+ }
740
+ }
741
+ /** Remove the {@link markDown} marker. Never throws. */
742
+ export function clearDownMarker(cwd) {
743
+ try {
744
+ unlinkSync(downMarkerPath(cwd));
745
+ }
746
+ catch {
747
+ /* not there */
748
+ }
749
+ }
750
+ /** True while `cwd`'s recorder is marked as stopped on purpose. */
751
+ export function isMarkedDown(cwd) {
752
+ try {
753
+ return existsSync(downMarkerPath(cwd));
754
+ }
755
+ catch {
756
+ return false;
757
+ }
758
+ }
759
+ /**
760
+ * `bir-hooks revive --cwd <project>` (K1): the recorder of an installed
761
+ * project, started again when it is gone.
762
+ *
763
+ * Every hook but SessionStart is an HTTP hook to the recorder, and a refused
764
+ * one is shown in the session as a hook error — on every prompt and every tool
765
+ * call — until the next SessionStart. A recorder killed mid-session (an
766
+ * antivirus, the OOM killer, a restart that missed its budget) used to stay
767
+ * gone until then. A bound `bir-proxy` or `bir-scenario` that finds it
768
+ * refusing connections with its pid dead spawns this, detached and rate
769
+ * limited, so the window of refused hooks is seconds.
770
+ *
771
+ * {@link ensureDaemon} with `restartOnSkew: false`, under the same start lock,
772
+ * so several revives at once start one recorder. Only for a project with an
773
+ * install record, and never after `bir down` (the down marker). Started even
774
+ * while the runner is off: the off recorder is what answers every hook `{}`.
775
+ * Never throws.
776
+ */
777
+ export async function reviveDaemon(cwd, opts = {}) {
778
+ try {
779
+ let installed = false;
780
+ try {
781
+ installed = projectRecord(readSidecar(), cwd) !== undefined;
782
+ }
783
+ catch {
784
+ installed = false;
785
+ }
786
+ if (!installed)
787
+ return { outcome: "not_installed" };
788
+ if (isMarkedDown(cwd))
789
+ return { outcome: "stopped_on_purpose" };
790
+ const result = await ensureDaemon(cwd, { ...opts, restartOnSkew: false, revive: true });
791
+ if (result.started) {
792
+ logLine("daemon.revived", {
793
+ cwd,
794
+ pid: result.status.info.pid,
795
+ url: result.status.info.url,
796
+ why: "the recorder was gone mid-session (its pid dead, its port refusing); a proxy of an open window started it again",
797
+ });
798
+ }
799
+ return { outcome: result.started ? "started" : "running", pid: result.status.info.pid };
800
+ }
801
+ catch (err) {
802
+ if (err instanceof RecorderStoppedError)
803
+ return { outcome: "stopped_on_purpose" };
804
+ if (err instanceof RecorderBusyError)
805
+ return { outcome: "busy", pid: err.pid };
806
+ logLine("daemon.revive_failed", { cwd, error: errText(err) });
807
+ return { outcome: "failed", error: errText(err) };
808
+ }
809
+ }
810
+ /**
811
+ * The environment a background recorder gets: the caller's, marked as a
812
+ * daemon, told its project, and without the secrets it has no use for.
813
+ *
814
+ * Nor `BIR_OFF` (K2), in any spelling: the recorder serves every window of
815
+ * the project, and a `BIR_OFF=1` in the environment of whichever window
816
+ * happened to start it switched all of them off for the recorder's life. That
817
+ * window's SessionStart hook tells the recorder its own session is off
818
+ * instead (ensure-hook.ts); the flag files still switch everything.
819
+ */
820
+ export function daemonEnv(base, cwd, logFile) {
821
+ const env = { ...base };
822
+ deleteEnv(env, SETUP_TOKEN_ENV);
823
+ if (base[USE_API_KEY_ENV] !== "1")
824
+ deleteEnv(env, "ANTHROPIC_API_KEY");
825
+ for (const key of Object.keys(env)) {
826
+ if (key.toUpperCase() === "BIR_OFF")
827
+ delete env[key];
828
+ }
829
+ // The recorder runs in another directory than its starter, so nothing it is
830
+ // handed may be relative to the starter's.
831
+ if (env.BIR_HOME && !isAbsolute(env.BIR_HOME))
832
+ env.BIR_HOME = resolvePath(env.BIR_HOME);
833
+ env[DAEMON_ENV] = "1";
834
+ env[DAEMON_LOG_ENV] = resolvePath(logFile);
835
+ env[DAEMON_CWD_ENV] = isAbsolute(cwd) ? cwd : resolvePath(cwd);
836
+ return env;
837
+ }
838
+ /** Delete `name` from an env copy — case-insensitively on Windows, where env names are. */
839
+ function deleteEnv(env, name) {
840
+ for (const key of Object.keys(env)) {
841
+ if (key === name || (process.platform === "win32" && key.toUpperCase() === name.toUpperCase())) {
842
+ delete env[key];
843
+ }
844
+ }
845
+ }
846
+ function launchDetached(script, env) {
847
+ const child = spawn(process.execPath, [...nodeFlagsToCarry(), script], {
848
+ cwd: resolvePath(configDir()),
166
849
  detached: true,
167
850
  stdio: "ignore",
851
+ // A detached child on Windows would get a console window of its own.
852
+ windowsHide: true,
168
853
  env,
169
854
  });
170
855
  let exited = false;
@@ -181,43 +866,65 @@ function launchPosix(script, cwd, env) {
181
866
  function psQuote(value) {
182
867
  return `'${value.replace(/'/g, "''")}'`;
183
868
  }
184
- function launchWindows(script, cwd, env) {
869
+ function launchWindows(script, env, timeoutMs) {
185
870
  // Start-Process joins -ArgumentList with spaces and quotes nothing, so the
186
871
  // script path carries its own double quotes.
187
872
  const argumentList = [...nodeFlagsToCarry().map(psQuote), psQuote(`"${script}"`)].join(", ");
188
873
  const command = `$p = Start-Process -FilePath ${psQuote(process.execPath)} ` +
189
874
  `-ArgumentList @(${argumentList}) ` +
190
- `-WorkingDirectory ${psQuote(cwd)} -WindowStyle Hidden -PassThru; ` +
875
+ `-WorkingDirectory ${psQuote(resolvePath(configDir()))} -WindowStyle Hidden -PassThru; ` +
191
876
  `Write-Output $p.Id`;
192
- const result = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", command], { env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true, timeout: 30_000 });
877
+ const result = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", command],
878
+ // Bounded well inside the hook's own budget: a PowerShell that hangs (an
879
+ // endpoint scanner, a policy prompt) must not hold SessionStart with it.
880
+ { env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true, timeout: timeoutMs });
193
881
  const pid = Number((result.stdout ?? "").trim().split(/\s+/).pop());
194
882
  if (result.status !== 0 || !Number.isInteger(pid) || pid <= 0) {
195
- // No PowerShell, or it refused: fall back to a plain spawn. It records;
196
- // the handle it may inherit is the lesser evil next to not recording.
883
+ // No PowerShell, or it refused: fall back to a plain hidden spawn. It
884
+ // records; the handle it may inherit is the lesser evil next to not recording.
197
885
  logLine("daemon.start_fallback", {
198
886
  why: "Start-Process failed; using a plain spawn",
199
887
  detail: (result.stderr ?? "").trim().slice(0, 200) || errText(result.error),
200
888
  });
201
- return launchPosix(script, cwd, env);
889
+ return launchDetached(script, env);
202
890
  }
203
891
  return { pid, exited: () => !pidAlive(pid) };
204
892
  }
205
893
  /**
206
- * Poll until the recorder for `cwd` answers — with `pid` when the caller knows
207
- * which process it is waiting for — or `timeoutMs` passes.
894
+ * Poll until the recorder for `cwd` answers — the one with `pid` when the
895
+ * caller launched it — or the deadline passes. Fast at first (a recorder is
896
+ * usually up in a few hundred milliseconds), slower after.
897
+ *
898
+ * When the launched process has exited, any live recorder for the project is
899
+ * taken: `bir-hooks` steps aside, exit 0, when it finds this project's
900
+ * recorder already serving the port.
208
901
  */
209
- async function waitForDaemon(cwd, pid, timeoutMs, check) {
210
- const deadline = Date.now() + timeoutMs;
902
+ async function waitForDaemon(cwd, opts) {
903
+ const startedAt = Date.now();
904
+ const probeMs = () => clamp(remaining(opts.deadline), 200, HEALTH_TIMEOUT_MS);
905
+ const usable = (found) => found.kind === "live" && (!opts.accept || opts.accept(found.status)) ? found.status : undefined;
906
+ // One more look once the thing waited on has ended: what it did before
907
+ // ending (wrote a discovery file, released the lock) happened after the
908
+ // look this round was based on.
909
+ const lastLook = async () => usable(await lookup(cwd, probeMs()));
211
910
  for (;;) {
212
- const status = await daemonStatus(cwd);
213
- if (status && (pid === undefined || status.info.pid === pid))
214
- return status;
215
- check?.();
216
- if (Date.now() >= deadline)
217
- return undefined;
218
- await sleep(200);
911
+ const live = usable(await lookup(cwd, probeMs()));
912
+ if (live && (opts.pid === undefined || live.info.pid === opts.pid))
913
+ return { status: live };
914
+ if (opts.exited?.()) {
915
+ const status = live ?? (await lastLook());
916
+ return status ? { status } : { exited: true };
917
+ }
918
+ if (opts.stopWhen?.()) {
919
+ const status = live ?? (await lastLook());
920
+ return status ? { status } : {};
921
+ }
922
+ if (Date.now() >= opts.deadline)
923
+ return live && opts.pid === undefined ? { status: live } : {};
924
+ await sleep(Math.min(Date.now() - startedAt < 1_000 ? 50 : 200, remaining(opts.deadline)));
219
925
  }
220
926
  }
927
+ // ── stop ───────────────────────────────────────────────────────────────────
221
928
  /**
222
929
  * Stop the control server for `cwd`.
223
930
  *
@@ -227,42 +934,325 @@ async function waitForDaemon(cwd, pid, timeoutMs, check) {
227
934
  * the polite route exists. Only a process that answers `/health` as the
228
935
  * recorder is ever signalled: a discovery file survives a crash, and its pid
229
936
  * is recycled — on Windows quickly — so "the pid in the file is alive" is not
230
- * evidence that it is ours.
937
+ * evidence that it is ours. One that is alive but too busy to answer is asked
938
+ * and waited for, never signalled.
231
939
  */
232
940
  export async function stopDaemon(cwd, opts = {}) {
233
- const info = readDiscovery(cwd);
941
+ const info = readDiscovery(cwd, { ignoreAge: true });
234
942
  if (!info)
235
943
  return { stopped: false };
236
- const timeoutMs = opts.timeoutMs ?? 10_000;
237
- const client = new ControlClient(info.url, info.token, 3_000);
238
- const health = await client.health();
239
- if (!health || health.pid !== info.pid) {
944
+ // Before it is asked: once it is gone, a proxy of an open window may look.
945
+ if (opts.markDown !== false)
946
+ markDown(cwd);
947
+ const result = await stopVerified(info, opts.timeoutMs ?? 10_000);
948
+ if (result.stale) {
240
949
  removeDiscovery(cwd);
241
- return { stopped: false, pid: info.pid, stale: true };
950
+ }
951
+ else if (result.stopped) {
952
+ // Its own shutdown removes the file; a killed process could not.
953
+ removeDiscoveryIfOwned(cwd, info.pid);
954
+ }
955
+ return { ...result, pid: info.pid };
956
+ }
957
+ /**
958
+ * Stop the recorder at `target` — asked first, then signalled only if it
959
+ * answered `/health` as that very pid; one too busy to answer is asked and
960
+ * waited for, never signalled. `stale`: nothing there answers as it.
961
+ */
962
+ async function stopVerified(target, timeoutMs) {
963
+ const client = new ControlClient(target.url, target.token, HEALTH_TIMEOUT_MS);
964
+ const probe = await probeHealth(target.url, target.token);
965
+ if (probe.kind === "timeout") {
966
+ // Its queue will get to the request; its own shutdown then exits.
967
+ void client.stop();
968
+ const deadline = Date.now() + timeoutMs;
969
+ while (pidAlive(target.pid) && Date.now() < deadline)
970
+ await sleep(100);
971
+ return { stopped: !pidAlive(target.pid) };
972
+ }
973
+ if (probe.kind !== "ok" || probe.health.pid !== target.pid) {
974
+ return { stopped: false, stale: true };
242
975
  }
243
976
  const asked = await client.stop();
244
977
  const deadline = Date.now() + timeoutMs;
245
- while (asked && pidAlive(info.pid) && Date.now() < deadline) {
978
+ while (asked && pidAlive(target.pid) && Date.now() < deadline) {
246
979
  await sleep(100);
247
980
  }
248
981
  let forced = false;
249
- if (pidAlive(info.pid)) {
982
+ if (pidAlive(target.pid)) {
250
983
  forced = true;
251
984
  try {
252
- process.kill(info.pid, "SIGTERM");
985
+ process.kill(target.pid, "SIGTERM");
253
986
  }
254
987
  catch (err) {
255
- logLine("daemon.kill_failed", { pid: info.pid, error: errText(err) });
988
+ logLine("daemon.kill_failed", { pid: target.pid, error: errText(err) });
256
989
  }
257
- const hardDeadline = Date.now() + 3_000;
258
- while (pidAlive(info.pid) && Date.now() < hardDeadline) {
990
+ let hardDeadline = Date.now() + 3_000;
991
+ while (pidAlive(target.pid) && Date.now() < hardDeadline) {
259
992
  await sleep(100);
260
993
  }
994
+ if (pidAlive(target.pid) && process.platform !== "win32") {
995
+ // POSIX only: a recorder stuck in its own shutdown ignores a repeated
996
+ // SIGTERM. (On Windows the SIGTERM above already was a hard kill.)
997
+ try {
998
+ process.kill(target.pid, "SIGKILL");
999
+ }
1000
+ catch (err) {
1001
+ logLine("daemon.kill_failed", { pid: target.pid, signal: "SIGKILL", error: errText(err) });
1002
+ }
1003
+ hardDeadline = Date.now() + 2_000;
1004
+ while (pidAlive(target.pid) && Date.now() < hardDeadline) {
1005
+ await sleep(100);
1006
+ }
1007
+ }
1008
+ }
1009
+ return { stopped: !pidAlive(target.pid), forced };
1010
+ }
1011
+ /**
1012
+ * Stop one superseded recorder (see {@link listSupersededRecorders}) the same
1013
+ * way {@link stopDaemon} stops the project's: asked, and signalled only when
1014
+ * it answers `/health` as its own pid. Its record is removed once it is gone
1015
+ * (its own exit does that too), or when nothing answers as it.
1016
+ */
1017
+ export async function stopSupersededRecorder(record, opts = {}) {
1018
+ const result = await stopVerified(record, opts.timeoutMs ?? 10_000);
1019
+ if (result.stopped || result.stale)
1020
+ removeSupersededRecord(record.cwd, record.pid);
1021
+ return { ...result, pid: record.pid };
1022
+ }
1023
+ /**
1024
+ * Stop every superseded recorder of `cwd` (or of every project, without
1025
+ * one). For `bir down` and `bir uninstall`; the project's own recorder is
1026
+ * {@link stopDaemon}'s.
1027
+ */
1028
+ export async function stopSupersededRecorders(cwd, opts = {}) {
1029
+ const out = [];
1030
+ for (const record of listSupersededRecorders(cwd)) {
1031
+ const r = await stopSupersededRecorder(record, opts);
1032
+ out.push({ record, stopped: r.stopped, stale: r.stale, forced: r.forced });
1033
+ }
1034
+ return out;
1035
+ }
1036
+ export function readPortProblem(cwd) {
1037
+ try {
1038
+ const path = portProblemPath(cwd);
1039
+ if (!existsSync(path))
1040
+ return undefined;
1041
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
1042
+ if (typeof parsed.port !== "number" || typeof parsed.detail !== "string")
1043
+ return undefined;
1044
+ return parsed;
1045
+ }
1046
+ catch {
1047
+ return undefined;
1048
+ }
1049
+ }
1050
+ export function writePortProblem(cwd, problem) {
1051
+ try {
1052
+ ensureDir(controlDir());
1053
+ writeFileAtomic(portProblemPath(cwd), JSON.stringify(problem, null, 2), { mode: 0o600 });
1054
+ }
1055
+ catch {
1056
+ /* the log line says it too */
1057
+ }
1058
+ }
1059
+ export function clearPortProblem(cwd) {
1060
+ try {
1061
+ const path = portProblemPath(cwd);
1062
+ if (existsSync(path))
1063
+ unlinkSync(path);
1064
+ }
1065
+ catch {
1066
+ /* best effort */
1067
+ }
1068
+ }
1069
+ // ── the background recorder's own log ──────────────────────────────────────
1070
+ function silentConsole() {
1071
+ const sink = new Writable({
1072
+ write(_chunk, _encoding, callback) {
1073
+ callback();
1074
+ },
1075
+ });
1076
+ return new Console({ stdout: sink, stderr: sink });
1077
+ }
1078
+ /**
1079
+ * The log a background recorder writes its audit lines to.
1080
+ *
1081
+ * Three things the plain stream did not do. It never fails the process: a
1082
+ * file (or a logs directory) that cannot be opened or written — a full disk,
1083
+ * a read-only or locked profile — used to be an unhandled `'error'` that
1084
+ * killed the recorder at start, and with it every hook of the session. Now the
1085
+ * lines are dropped instead. It comes back: a failure is usually brief (the
1086
+ * disk full for a minute, an antivirus scan holding the file), and a recorder
1087
+ * that runs for days must not stay silent for all of them — the minute tick
1088
+ * ({@link rotateIfLarge}) opens the file again. And it stays bounded while the
1089
+ * recorder runs for weeks: rolled to `.1` once it passes
1090
+ * {@link LOG_ROTATE_BYTES}, not only when a new one starts.
1091
+ *
1092
+ * And it shares a path safely. Two recorders of one project can run at once
1093
+ * (a reinstall moved the hooks to another port while a window was open), and
1094
+ * `ensureDaemon` rolls the log before a start. Whoever rolled it, the other
1095
+ * writer's stream follows the renamed file — all its lines went to `.1`, which
1096
+ * then grew without bound, and on Windows the held `.1` made every later roll
1097
+ * fail. So the minute tick first checks that the file at the path is the one
1098
+ * it writes, and opens the path again when it is not. A recorder superseded
1099
+ * like that also moves to a file of its own ({@link moveTo}).
1100
+ */
1101
+ export class DaemonLog {
1102
+ stream;
1103
+ /** The descriptor {@link stream} writes to, once it is open. */
1104
+ fd;
1105
+ current;
1106
+ maxBytes;
1107
+ /** Set once lines have been dropped, so the reopen can say so. */
1108
+ droppedSince;
1109
+ constructor(path, maxBytes = LOG_ROTATE_BYTES) {
1110
+ this.current = path;
1111
+ this.maxBytes = maxBytes;
1112
+ }
1113
+ /** The file lines go to now. */
1114
+ get path() {
1115
+ return this.current;
1116
+ }
1117
+ /** Open the file and point `console` (every `logLine`) at it. Never throws. */
1118
+ open() {
1119
+ try {
1120
+ ensureDir(dirname(this.current));
1121
+ }
1122
+ catch {
1123
+ this.silence();
1124
+ return;
1125
+ }
1126
+ this.attach();
1127
+ }
1128
+ /**
1129
+ * Write to `path` from now on — a superseded recorder's own file, so the
1130
+ * project's log keeps one writer and one rotation. Never throws.
1131
+ */
1132
+ moveTo(path) {
1133
+ if (path === this.current)
1134
+ return;
1135
+ const old = this.stream;
1136
+ this.current = path;
1137
+ try {
1138
+ ensureDir(dirname(path));
1139
+ }
1140
+ catch {
1141
+ /* the open below fails and stays silent */
1142
+ }
1143
+ this.attach();
1144
+ old?.end();
1145
+ }
1146
+ silence() {
1147
+ this.stream = undefined;
1148
+ this.fd = undefined;
1149
+ this.droppedSince ??= Date.now();
1150
+ globalThis.console = silentConsole();
1151
+ }
1152
+ attach() {
1153
+ let stream;
1154
+ try {
1155
+ stream = createWriteStream(this.current, { flags: "a" });
1156
+ }
1157
+ catch {
1158
+ this.silence();
1159
+ return;
1160
+ }
1161
+ stream.on("error", () => {
1162
+ if (this.stream !== stream)
1163
+ return;
1164
+ this.silence();
1165
+ });
1166
+ stream.once("open", (fd) => {
1167
+ if (this.stream === stream)
1168
+ this.fd = fd;
1169
+ });
1170
+ this.stream = stream;
1171
+ this.fd = undefined;
1172
+ globalThis.console = new Console({ stdout: stream, stderr: stream });
1173
+ }
1174
+ /**
1175
+ * Roll the file to `.1` if it is past the limit, and open it again if it
1176
+ * failed earlier, or if the file at the path is no longer the one written
1177
+ * (another writer rolled or removed it). True when it rolled. Never throws.
1178
+ */
1179
+ rotateIfLarge() {
1180
+ const old = this.stream;
1181
+ if (!old) {
1182
+ this.reopen();
1183
+ return false;
1184
+ }
1185
+ this.droppedSince = undefined;
1186
+ let size;
1187
+ try {
1188
+ const onDisk = statSync(this.current, { bigint: true });
1189
+ if (!this.writes(onDisk)) {
1190
+ this.attach();
1191
+ old.end();
1192
+ return false;
1193
+ }
1194
+ size = Number(onDisk.size);
1195
+ }
1196
+ catch (err) {
1197
+ // Removed or rolled away by somebody else, and nothing there yet.
1198
+ if (err.code === "ENOENT") {
1199
+ this.attach();
1200
+ old.end();
1201
+ }
1202
+ return false;
1203
+ }
1204
+ if (size <= this.maxBytes)
1205
+ return false;
1206
+ try {
1207
+ // The open stream keeps writing to the renamed file until it is ended
1208
+ // below; libuv opens files shareable for delete, so this works on
1209
+ // Windows too.
1210
+ renameSync(this.current, `${this.current}.1`);
1211
+ }
1212
+ catch {
1213
+ return false;
1214
+ }
1215
+ this.attach();
1216
+ old.end();
1217
+ return true;
1218
+ }
1219
+ /** Whether `onDisk` is the file this log's stream writes (true when it cannot tell). */
1220
+ writes(onDisk) {
1221
+ if (this.fd === undefined)
1222
+ return true;
1223
+ try {
1224
+ const mine = fstatSync(this.fd, { bigint: true });
1225
+ // A file system without file ids says nothing either way.
1226
+ if (mine.ino === 0n || onDisk.ino === 0n)
1227
+ return true;
1228
+ return mine.ino === onDisk.ino && mine.dev === onDisk.dev;
1229
+ }
1230
+ catch {
1231
+ return true;
1232
+ }
1233
+ }
1234
+ /** After a failure: try the file again. A stream that fails again just stays silent. */
1235
+ reopen() {
1236
+ try {
1237
+ ensureDir(dirname(this.current));
1238
+ }
1239
+ catch {
1240
+ return;
1241
+ }
1242
+ this.attach();
1243
+ // Its own `'error'` arrives later if the file still cannot be written;
1244
+ // then this line is dropped too, and the next tick tries again. The
1245
+ // start of the gap is forgotten only once a stream has lasted a tick.
1246
+ if (this.stream && this.droppedSince !== undefined) {
1247
+ logLine("control.log_reopened", {
1248
+ dropped_since: new Date(this.droppedSince).toISOString(),
1249
+ why: "the log could not be written for a while; lines from that time are missing",
1250
+ });
1251
+ }
1252
+ }
1253
+ close() {
1254
+ this.stream?.end();
261
1255
  }
262
- // Its own shutdown removes the file; a killed process could not.
263
- if (!pidAlive(info.pid))
264
- removeDiscovery(cwd);
265
- return { stopped: !pidAlive(info.pid), pid: info.pid, forced };
266
1256
  }
267
1257
  // ── the start lock ─────────────────────────────────────────────────────────
268
1258
  function lockPath(cwd) {
@@ -288,9 +1278,12 @@ function acquireLock(path) {
288
1278
  // go ahead uncoordinated rather than sit out the whole timeout.
289
1279
  if (err.code !== "EEXIST")
290
1280
  return true;
291
- // Somebody holds it. Alive and recent: theirs. Old: they died holding it.
1281
+ // Somebody holds it. Alive and recent: theirs. Old, or its process
1282
+ // gone: they died holding it.
292
1283
  try {
293
- if (Date.now() - statSync(path).mtimeMs < LOCK_STALE_MS)
1284
+ const holder = lockHolder(path);
1285
+ const fresh = Date.now() - statSync(path).mtimeMs < LOCK_STALE_MS;
1286
+ if (fresh && (holder === undefined || pidAlive(holder)))
294
1287
  return false;
295
1288
  unlinkSync(path);
296
1289
  }
@@ -301,6 +1294,16 @@ function acquireLock(path) {
301
1294
  }
302
1295
  return false;
303
1296
  }
1297
+ /** The pid that wrote the lock, when it can be read. */
1298
+ function lockHolder(path) {
1299
+ try {
1300
+ const pid = Number(readFileSync(path, "utf8").trim().split(/\s+/)[0]);
1301
+ return Number.isInteger(pid) && pid > 0 ? pid : undefined;
1302
+ }
1303
+ catch {
1304
+ return undefined;
1305
+ }
1306
+ }
304
1307
  function releaseLock(path) {
305
1308
  try {
306
1309
  if (existsSync(path))