@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.
- package/README.md +540 -369
- package/dist/auth/client.d.ts +65 -3
- package/dist/auth/client.js +305 -53
- package/dist/bin/bir-hooks.d.ts +72 -31
- package/dist/bin/bir-hooks.js +176 -284
- package/dist/bin/bir-proxy.d.ts +17 -2
- package/dist/bin/bir-proxy.js +222 -28
- package/dist/bin/bir-scenario.d.ts +61 -21
- package/dist/bin/bir-scenario.js +593 -122
- package/dist/bin/bir.d.ts +12 -6
- package/dist/bin/bir.js +2935 -489
- package/dist/bin/investigate.js +40 -15
- package/dist/bin/scenario-edit.d.ts +7 -1
- package/dist/bin/scenario-edit.js +43 -21
- package/dist/bin/scenario-liveness.d.ts +194 -0
- package/dist/bin/scenario-liveness.js +459 -0
- package/dist/bin/scenario-stderr.d.ts +43 -0
- package/dist/bin/scenario-stderr.js +76 -0
- package/dist/bin/service-read.d.ts +88 -0
- package/dist/bin/service-read.js +128 -0
- package/dist/bin/setup.d.ts +19 -0
- package/dist/bin/setup.js +146 -20
- package/dist/config/adapters/claude-code.d.ts +92 -14
- package/dist/config/adapters/claude-code.js +291 -101
- package/dist/config/adapters/generic.d.ts +4 -1
- package/dist/config/adapters/generic.js +13 -14
- package/dist/config/generate.d.ts +392 -14
- package/dist/config/generate.js +666 -19
- package/dist/config/mcp-approval.d.ts +76 -0
- package/dist/config/mcp-approval.js +210 -0
- package/dist/config/node-path.d.ts +42 -0
- package/dist/config/node-path.js +102 -0
- package/dist/config/overrides.d.ts +62 -0
- package/dist/config/overrides.js +136 -0
- package/dist/config/resolve.d.ts +70 -8
- package/dist/config/resolve.js +186 -15
- package/dist/control/client.d.ts +22 -0
- package/dist/control/client.js +26 -7
- package/dist/control/correlation.d.ts +23 -5
- package/dist/control/correlation.js +0 -0
- package/dist/control/daemon.d.ts +400 -18
- package/dist/control/daemon.js +1105 -102
- package/dist/control/discovery.d.ts +77 -5
- package/dist/control/discovery.js +186 -11
- package/dist/control/ensure-hook.d.ts +61 -0
- package/dist/control/ensure-hook.js +160 -8
- package/dist/control/paths.d.ts +26 -0
- package/dist/control/paths.js +34 -0
- package/dist/control/pin-guard.d.ts +83 -0
- package/dist/control/pin-guard.js +534 -0
- package/dist/control/recorder-keeper.d.ts +229 -0
- package/dist/control/recorder-keeper.js +721 -0
- package/dist/control/recorder-main.d.ts +73 -0
- package/dist/control/recorder-main.js +778 -0
- package/dist/control/server.d.ts +707 -31
- package/dist/control/server.js +2786 -302
- package/dist/control/transcript.d.ts +21 -1
- package/dist/control/transcript.js +378 -112
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/jsonrpc/framing.d.ts +76 -12
- package/dist/jsonrpc/framing.js +216 -59
- package/dist/proxy/intercept.d.ts +49 -5
- package/dist/proxy/intercept.js +188 -59
- package/dist/proxy/relay.d.ts +16 -0
- package/dist/proxy/relay.js +77 -5
- package/dist/proxy/revive.d.ts +122 -0
- package/dist/proxy/revive.js +251 -0
- package/dist/proxy/session.d.ts +222 -14
- package/dist/proxy/session.js +708 -99
- package/dist/record/recorder.d.ts +51 -3
- package/dist/record/recorder.js +3 -0
- package/dist/record/redact.d.ts +103 -9
- package/dist/record/redact.js +659 -30
- package/dist/record/remote-recorder.d.ts +327 -32
- package/dist/record/remote-recorder.js +1517 -187
- package/dist/record/switchable-recorder.d.ts +126 -0
- package/dist/record/switchable-recorder.js +351 -0
- package/dist/record/truncate.d.ts +40 -1
- package/dist/record/truncate.js +111 -4
- package/dist/replay/bash-channel.d.ts +35 -0
- package/dist/replay/bash-channel.js +133 -0
- package/dist/replay/bundle.d.ts +10 -0
- package/dist/replay/bundle.js +10 -0
- package/dist/replay/controller.d.ts +369 -20
- package/dist/replay/controller.js +1176 -107
- package/dist/replay/derive.d.ts +1 -0
- package/dist/replay/derive.js +81 -24
- package/dist/replay/executor.d.ts +51 -16
- package/dist/replay/executor.js +114 -41
- package/dist/replay/handover.d.ts +54 -1
- package/dist/replay/handover.js +55 -5
- package/dist/replay/logic.d.ts +62 -10
- package/dist/replay/logic.js +140 -22
- package/dist/replay/plan.d.ts +201 -27
- package/dist/replay/plan.js +581 -141
- package/dist/replay/safety.d.ts +72 -0
- package/dist/replay/safety.js +303 -0
- package/dist/replay/source-run.d.ts +10 -0
- package/dist/replay/source-run.js +80 -35
- package/dist/replay/targets.d.ts +15 -1
- package/dist/replay/targets.js +62 -4
- package/dist/replay/tool-error.d.ts +15 -1
- package/dist/replay/tool-error.js +33 -1
- package/dist/replay/types.d.ts +7 -4
- package/dist/upstream/client.d.ts +56 -3
- package/dist/upstream/client.js +61 -4
- package/dist/upstream/headers.d.ts +43 -0
- package/dist/upstream/headers.js +90 -0
- package/dist/upstream/http-client.d.ts +213 -22
- package/dist/upstream/http-client.js +1034 -168
- package/dist/upstream/http-transport.d.ts +143 -0
- package/dist/upstream/http-transport.js +391 -0
- package/dist/upstream/lazy-client.d.ts +8 -0
- package/dist/upstream/lazy-client.js +29 -1
- package/dist/upstream/sse.d.ts +78 -0
- package/dist/upstream/sse.js +189 -0
- package/dist/upstream/stdio-client.d.ts +61 -1
- package/dist/upstream/stdio-client.js +356 -33
- package/dist/util/atomic-write.d.ts +29 -0
- package/dist/util/atomic-write.js +96 -0
- package/dist/util/journal.d.ts +15 -0
- package/dist/util/journal.js +48 -3
- package/dist/util/log.d.ts +10 -0
- package/dist/util/log.js +28 -1
- package/dist/util/off-switch.d.ts +46 -0
- package/dist/util/off-switch.js +91 -0
- package/dist/util/service-health.d.ts +133 -0
- package/dist/util/service-health.js +217 -0
- package/docs/BaseInstRunner.md +694 -621
- package/docs/calculatedReplay.md +1625 -1304
- package/docs/calculatedReplayGuide.md +1212 -1087
- package/docs/installRun.md +1013 -772
- package/docs/loginWeb.md +627 -607
- package/docs/mcpmark.md +49 -16
- package/docs/my-first-sample.md +569 -545
- package/docs/quickstart.md +384 -311
- package/docs/t-bench.md +406 -394
- package/package.json +58 -57
- package/production.md +1371 -0
- package/scripts/install.ps1 +97 -6
- package/scripts/install.sh +104 -7
package/dist/control/daemon.js
CHANGED
|
@@ -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.
|
|
26
|
-
*
|
|
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
|
|
36
|
-
* daemon that inherits it holds that pipe open for days, and
|
|
37
|
-
* it never sees EOF. `Start-Process` passes the environment
|
|
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 {
|
|
41
|
-
import {
|
|
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
|
-
|
|
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
|
|
71
|
-
* answers `/health` with its token. A file left by a crash fails the
|
|
72
|
-
* test; a process that hung, or a recycled pid, fails the
|
|
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
|
|
76
|
-
|
|
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
|
|
79
|
-
if (
|
|
235
|
+
const h = probe.health;
|
|
236
|
+
if (typeof h.cwd !== "string" || normalizePath(h.cwd) !== normalizePath(cwd))
|
|
80
237
|
return undefined;
|
|
81
|
-
|
|
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
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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
|
|
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
|
-
|
|
99
|
-
|
|
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
|
|
102
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
115
|
-
restarted = true;
|
|
638
|
+
noteSuperseded(cwd, found.info, found.info.version ?? "");
|
|
116
639
|
}
|
|
117
|
-
else if (
|
|
118
|
-
// A file whose pid is alive but
|
|
119
|
-
// was recycled. Gone now, so no proxy resolves it in the
|
|
120
|
-
// new one writes its own
|
|
121
|
-
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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,
|
|
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(
|
|
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],
|
|
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
|
|
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
|
|
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
|
|
207
|
-
*
|
|
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,
|
|
210
|
-
const
|
|
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
|
|
213
|
-
if (
|
|
214
|
-
return status;
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
return
|
|
218
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
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(
|
|
978
|
+
while (asked && pidAlive(target.pid) && Date.now() < deadline) {
|
|
246
979
|
await sleep(100);
|
|
247
980
|
}
|
|
248
981
|
let forced = false;
|
|
249
|
-
if (pidAlive(
|
|
982
|
+
if (pidAlive(target.pid)) {
|
|
250
983
|
forced = true;
|
|
251
984
|
try {
|
|
252
|
-
process.kill(
|
|
985
|
+
process.kill(target.pid, "SIGTERM");
|
|
253
986
|
}
|
|
254
987
|
catch (err) {
|
|
255
|
-
logLine("daemon.kill_failed", { pid:
|
|
988
|
+
logLine("daemon.kill_failed", { pid: target.pid, error: errText(err) });
|
|
256
989
|
}
|
|
257
|
-
|
|
258
|
-
while (pidAlive(
|
|
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
|
|
1281
|
+
// Somebody holds it. Alive and recent: theirs. Old, or its process
|
|
1282
|
+
// gone: they died holding it.
|
|
292
1283
|
try {
|
|
293
|
-
|
|
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))
|