@basein/runner 0.2.7 → 0.2.10
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 +64 -21
- package/dist/auth/client.d.ts +40 -1
- package/dist/auth/client.js +77 -9
- package/dist/bin/bir-hooks.d.ts +18 -3
- package/dist/bin/bir-hooks.js +124 -38
- package/dist/bin/bir.d.ts +2 -0
- package/dist/bin/bir.js +362 -39
- package/dist/bin/investigate.js +5 -1
- package/dist/bin/setup.d.ts +72 -0
- package/dist/bin/setup.js +286 -0
- package/dist/config/adapters/claude-code.d.ts +90 -4
- package/dist/config/adapters/claude-code.js +164 -16
- package/dist/config/generate.d.ts +93 -1
- package/dist/config/generate.js +90 -3
- package/dist/control/client.d.ts +5 -0
- package/dist/control/client.js +8 -0
- package/dist/control/daemon.d.ts +116 -0
- package/dist/control/daemon.js +339 -0
- package/dist/control/discovery.d.ts +26 -0
- package/dist/control/discovery.js +41 -9
- package/dist/control/ensure-hook.d.ts +39 -0
- package/dist/control/ensure-hook.js +98 -0
- package/dist/control/paths.d.ts +14 -0
- package/dist/control/paths.js +20 -0
- package/dist/control/server.d.ts +28 -0
- package/dist/control/server.js +22 -6
- package/dist/proxy/session.d.ts +8 -1
- package/dist/proxy/session.js +28 -6
- package/dist/replay/controller.d.ts +24 -1
- package/dist/replay/controller.js +76 -20
- package/dist/replay/handover.js +5 -0
- package/dist/replay/plan.d.ts +2 -0
- package/dist/replay/plan.js +53 -6
- package/dist/replay/pricing.d.ts +1 -1
- package/dist/replay/pricing.js +12 -4
- package/dist/replay/tool-error.d.ts +15 -0
- package/dist/replay/tool-error.js +17 -0
- package/dist/replay/types.d.ts +48 -1
- package/docs/calculatedReplayGuide.md +157 -68
- package/docs/installRun.md +457 -111
- package/docs/loginWeb.md +1 -1
- package/docs/quickstart.md +193 -158
- package/package.json +2 -1
- package/scripts/install.ps1 +669 -0
- package/scripts/install.sh +586 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* daemon — the recorder as a background process nobody has to keep a window for.
|
|
3
|
+
*
|
|
4
|
+
* `bir-hooks` was designed to run in a terminal of its own, and every guide
|
|
5
|
+
* said "leave this running". That was the one step in the install a person
|
|
6
|
+
* could not be spared, and it was also the one they forgot: a session started
|
|
7
|
+
* without it records Tier 2 — tool calls only, no prompt, nothing to match a
|
|
8
|
+
* scenario against — and looks fine until the console shows a run with no
|
|
9
|
+
* prompt. This module removes the window.
|
|
10
|
+
*
|
|
11
|
+
* Three verbs, one file so they cannot disagree about where things are:
|
|
12
|
+
*
|
|
13
|
+
* ensureDaemon(cwd) the control server for `cwd`, started if there is none.
|
|
14
|
+
* Called by `bir up`, by `bir setup`, and by the
|
|
15
|
+
* SessionStart hook on every session, so a recorder that
|
|
16
|
+
* died or was never started is running by the first prompt.
|
|
17
|
+
* stopDaemon(cwd) `bir down`. Asks it to stop; kills it if it will not.
|
|
18
|
+
* daemonStatus(cwd) what `bir status` and `bir doctor` say about it.
|
|
19
|
+
*
|
|
20
|
+
* WHY THE HOOK STARTS IT, rather than a service manager. A Scheduled Task, a
|
|
21
|
+
* launchd agent and a systemd unit are three recipes for three platforms, each
|
|
22
|
+
* with a working directory that must equal the project's, each needing the
|
|
23
|
+
* person to know which one they are on. The hook already runs in the right
|
|
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).
|
|
27
|
+
*
|
|
28
|
+
* HOW IT IS LAUNCHED. The daemon opens its own log file (`BIR_DAEMON_LOG`) and
|
|
29
|
+
* inherits nothing from our stdio. On POSIX that is a `detached` spawn with
|
|
30
|
+
* `stdio: "ignore"`, unref'd — the documented shape for a child that outlives
|
|
31
|
+
* its parent. On Windows it is `Start-Process -WindowStyle Hidden` inside a
|
|
32
|
+
* short-lived `powershell.exe`: libuv's CreateProcess inherits *every*
|
|
33
|
+
* inheritable handle of the parent, not only the three it was told about, and
|
|
34
|
+
* 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
|
+
*/
|
|
39
|
+
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";
|
|
42
|
+
import { fileURLToPath } from "node:url";
|
|
43
|
+
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";
|
|
47
|
+
import { errText, logLine } from "../util/log.js";
|
|
48
|
+
import { packageVersion } from "../util/version.js";
|
|
49
|
+
/** A log file larger than this is rolled over to `.1` when a daemon starts. */
|
|
50
|
+
export const LOG_ROTATE_BYTES = 8 * 1024 * 1024;
|
|
51
|
+
/** Marks a `bir-hooks` as started by this module; it says so in its discovery file. */
|
|
52
|
+
export const DAEMON_ENV = "BIR_DAEMON";
|
|
53
|
+
/** Where that process writes its audit lines. It opens the file itself. */
|
|
54
|
+
export const DAEMON_LOG_ENV = "BIR_DAEMON_LOG";
|
|
55
|
+
/**
|
|
56
|
+
* The setup token's environment variable (`bir setup` reads it when `--token`
|
|
57
|
+
* is absent). Never handed to the recorder: it is a one-use secret for signing
|
|
58
|
+
* in, and a process that lives for days has no business holding it.
|
|
59
|
+
*/
|
|
60
|
+
export const SETUP_TOKEN_ENV = "BIR_SETUP_TOKEN";
|
|
61
|
+
/** A start lock older than this belongs to a starter that died; it is ignored. */
|
|
62
|
+
const LOCK_STALE_MS = 30_000;
|
|
63
|
+
/** Absolute path of `dist/bin/bir-hooks.js` in the package this module runs from. */
|
|
64
|
+
export function hooksScriptPath() {
|
|
65
|
+
return resolvePath(dirname(fileURLToPath(import.meta.url)), "..", "bin", "bir-hooks.js");
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The live control server for `cwd`, or undefined.
|
|
69
|
+
*
|
|
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.
|
|
73
|
+
*/
|
|
74
|
+
export async function daemonStatus(cwd) {
|
|
75
|
+
const info = readDiscovery(cwd);
|
|
76
|
+
if (!info)
|
|
77
|
+
return undefined;
|
|
78
|
+
const health = await new ControlClient(info.url, info.token, 3_000).health();
|
|
79
|
+
if (!health || health.pid !== info.pid)
|
|
80
|
+
return undefined;
|
|
81
|
+
return { info, health };
|
|
82
|
+
}
|
|
83
|
+
/** How many of the recorder's sessions are mid-run right now. */
|
|
84
|
+
export function activeSessions(health) {
|
|
85
|
+
const sessions = health.sessions ?? [];
|
|
86
|
+
return sessions.filter((s) => s.active === true).length;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The control server for `cwd`, started in the background if none is live.
|
|
90
|
+
*
|
|
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.
|
|
94
|
+
*/
|
|
95
|
+
export async function ensureDaemon(cwd, opts = {}) {
|
|
96
|
+
const timeoutMs = opts.timeoutMs ?? 15_000;
|
|
97
|
+
let restarted = false;
|
|
98
|
+
const live = await daemonStatus(cwd);
|
|
99
|
+
if (live) {
|
|
100
|
+
const theirs = String(live.health.hooksVersion ?? live.info.version ?? "");
|
|
101
|
+
const skewed = opts.restartOnSkew && theirs && theirs !== packageVersion();
|
|
102
|
+
if (!skewed || activeSessions(live.health) > 0) {
|
|
103
|
+
return { status: live, started: false, restarted: false };
|
|
104
|
+
}
|
|
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", {
|
|
109
|
+
cwd,
|
|
110
|
+
from: theirs,
|
|
111
|
+
to: packageVersion(),
|
|
112
|
+
why: "the recorder is an older version than the package; no session is mid-run",
|
|
113
|
+
});
|
|
114
|
+
await stopDaemon(cwd);
|
|
115
|
+
restarted = true;
|
|
116
|
+
}
|
|
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);
|
|
122
|
+
}
|
|
123
|
+
// Two Claude Code windows opened together in one project both run the
|
|
124
|
+
// SessionStart hook. The first to take the lock spawns; the second waits for
|
|
125
|
+
// that spawn rather than racing it onto another port.
|
|
126
|
+
const lock = lockPath(cwd);
|
|
127
|
+
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
|
|
132
|
+
if (!acquireLock(lock))
|
|
133
|
+
throw new Error("another recorder start is in progress — try again");
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
const script = opts.hooksScript ?? hooksScriptPath();
|
|
137
|
+
const logFile = daemonLogPath(cwd);
|
|
138
|
+
ensureDir(logsDir());
|
|
139
|
+
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)}`);
|
|
156
|
+
}
|
|
157
|
+
return { status, started: true, restarted };
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
releaseLock(lock);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
function launchPosix(script, cwd, env) {
|
|
164
|
+
const child = spawn(process.execPath, [...nodeFlagsToCarry(), script], {
|
|
165
|
+
cwd,
|
|
166
|
+
detached: true,
|
|
167
|
+
stdio: "ignore",
|
|
168
|
+
env,
|
|
169
|
+
});
|
|
170
|
+
let exited = false;
|
|
171
|
+
child.once("exit", () => {
|
|
172
|
+
exited = true;
|
|
173
|
+
});
|
|
174
|
+
child.once("error", () => {
|
|
175
|
+
exited = true;
|
|
176
|
+
});
|
|
177
|
+
child.unref();
|
|
178
|
+
return { pid: child.pid, exited: () => exited };
|
|
179
|
+
}
|
|
180
|
+
/** Single-quote a value for a PowerShell string literal. */
|
|
181
|
+
function psQuote(value) {
|
|
182
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
183
|
+
}
|
|
184
|
+
function launchWindows(script, cwd, env) {
|
|
185
|
+
// Start-Process joins -ArgumentList with spaces and quotes nothing, so the
|
|
186
|
+
// script path carries its own double quotes.
|
|
187
|
+
const argumentList = [...nodeFlagsToCarry().map(psQuote), psQuote(`"${script}"`)].join(", ");
|
|
188
|
+
const command = `$p = Start-Process -FilePath ${psQuote(process.execPath)} ` +
|
|
189
|
+
`-ArgumentList @(${argumentList}) ` +
|
|
190
|
+
`-WorkingDirectory ${psQuote(cwd)} -WindowStyle Hidden -PassThru; ` +
|
|
191
|
+
`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 });
|
|
193
|
+
const pid = Number((result.stdout ?? "").trim().split(/\s+/).pop());
|
|
194
|
+
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.
|
|
197
|
+
logLine("daemon.start_fallback", {
|
|
198
|
+
why: "Start-Process failed; using a plain spawn",
|
|
199
|
+
detail: (result.stderr ?? "").trim().slice(0, 200) || errText(result.error),
|
|
200
|
+
});
|
|
201
|
+
return launchPosix(script, cwd, env);
|
|
202
|
+
}
|
|
203
|
+
return { pid, exited: () => !pidAlive(pid) };
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Poll until the recorder for `cwd` answers — with `pid` when the caller knows
|
|
207
|
+
* which process it is waiting for — or `timeoutMs` passes.
|
|
208
|
+
*/
|
|
209
|
+
async function waitForDaemon(cwd, pid, timeoutMs, check) {
|
|
210
|
+
const deadline = Date.now() + timeoutMs;
|
|
211
|
+
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);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Stop the control server for `cwd`.
|
|
223
|
+
*
|
|
224
|
+
* Asks first — `POST /control/stop`, which lets it finish the run it is
|
|
225
|
+
* writing and drain its queue — and only then, if it is still there, kills it.
|
|
226
|
+
* A `SIGTERM` on Windows is a hard kill with no handler, which is exactly why
|
|
227
|
+
* the polite route exists. Only a process that answers `/health` as the
|
|
228
|
+
* recorder is ever signalled: a discovery file survives a crash, and its pid
|
|
229
|
+
* is recycled — on Windows quickly — so "the pid in the file is alive" is not
|
|
230
|
+
* evidence that it is ours.
|
|
231
|
+
*/
|
|
232
|
+
export async function stopDaemon(cwd, opts = {}) {
|
|
233
|
+
const info = readDiscovery(cwd);
|
|
234
|
+
if (!info)
|
|
235
|
+
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) {
|
|
240
|
+
removeDiscovery(cwd);
|
|
241
|
+
return { stopped: false, pid: info.pid, stale: true };
|
|
242
|
+
}
|
|
243
|
+
const asked = await client.stop();
|
|
244
|
+
const deadline = Date.now() + timeoutMs;
|
|
245
|
+
while (asked && pidAlive(info.pid) && Date.now() < deadline) {
|
|
246
|
+
await sleep(100);
|
|
247
|
+
}
|
|
248
|
+
let forced = false;
|
|
249
|
+
if (pidAlive(info.pid)) {
|
|
250
|
+
forced = true;
|
|
251
|
+
try {
|
|
252
|
+
process.kill(info.pid, "SIGTERM");
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
logLine("daemon.kill_failed", { pid: info.pid, error: errText(err) });
|
|
256
|
+
}
|
|
257
|
+
const hardDeadline = Date.now() + 3_000;
|
|
258
|
+
while (pidAlive(info.pid) && Date.now() < hardDeadline) {
|
|
259
|
+
await sleep(100);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
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
|
+
}
|
|
267
|
+
// ── the start lock ─────────────────────────────────────────────────────────
|
|
268
|
+
function lockPath(cwd) {
|
|
269
|
+
return join(controlDir(), `${controlKey(cwd)}.lock`);
|
|
270
|
+
}
|
|
271
|
+
function acquireLock(path) {
|
|
272
|
+
try {
|
|
273
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
/* the open below reports it */
|
|
277
|
+
}
|
|
278
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
279
|
+
try {
|
|
280
|
+
const fd = openSync(path, "wx", 0o600);
|
|
281
|
+
writeSync(fd, `${process.pid} ${Date.now()}\n`);
|
|
282
|
+
closeSync(fd);
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
catch (err) {
|
|
286
|
+
// Anything but "it exists" means the lock cannot be kept at all (an
|
|
287
|
+
// unwritable state directory, say). Then there is nothing to wait for:
|
|
288
|
+
// go ahead uncoordinated rather than sit out the whole timeout.
|
|
289
|
+
if (err.code !== "EEXIST")
|
|
290
|
+
return true;
|
|
291
|
+
// Somebody holds it. Alive and recent: theirs. Old: they died holding it.
|
|
292
|
+
try {
|
|
293
|
+
if (Date.now() - statSync(path).mtimeMs < LOCK_STALE_MS)
|
|
294
|
+
return false;
|
|
295
|
+
unlinkSync(path);
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
function releaseLock(path) {
|
|
305
|
+
try {
|
|
306
|
+
if (existsSync(path))
|
|
307
|
+
unlinkSync(path);
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
/* best effort */
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
// ── helpers ────────────────────────────────────────────────────────────────
|
|
314
|
+
/** Roll a large log to `.1`. Best effort: a log that will not rotate is still a log. */
|
|
315
|
+
function rotateLog(path) {
|
|
316
|
+
try {
|
|
317
|
+
if (existsSync(path) && statSync(path).size > LOG_ROTATE_BYTES) {
|
|
318
|
+
renameSync(path, `${path}.1`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
/* keep appending to the big one */
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
/** The last few lines of the log, for an error message. */
|
|
326
|
+
function tailOf(path) {
|
|
327
|
+
try {
|
|
328
|
+
const text = readFileSync(path, "utf8");
|
|
329
|
+
const lines = text.trimEnd().split(/\r?\n/).slice(-3);
|
|
330
|
+
return lines.length ? `see ${path}:\n ${lines.join("\n ")}` : `see ${path}`;
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
return `see ${path}`;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
function sleep(ms) {
|
|
337
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
338
|
+
}
|
|
339
|
+
//# sourceMappingURL=daemon.js.map
|
|
@@ -26,6 +26,14 @@
|
|
|
26
26
|
*/
|
|
27
27
|
/** A discovery file older than this is ignored even if its pid is somehow alive. */
|
|
28
28
|
export declare const MAX_DISCOVERY_AGE_MS: number;
|
|
29
|
+
/**
|
|
30
|
+
* How often a running control server rewrites its discovery file.
|
|
31
|
+
*
|
|
32
|
+
* Well inside {@link MAX_DISCOVERY_AGE_MS}: a background recorder that misses
|
|
33
|
+
* a few beats (a laptop asleep over a weekend) is still found on Monday, and a
|
|
34
|
+
* dead one is still forgotten within a day.
|
|
35
|
+
*/
|
|
36
|
+
export declare const HEARTBEAT_MS: number;
|
|
29
37
|
export interface DiscoveryInfo {
|
|
30
38
|
url: string;
|
|
31
39
|
/** Random 32 bytes hex; required on every control-server route. */
|
|
@@ -34,9 +42,27 @@ export interface DiscoveryInfo {
|
|
|
34
42
|
pid: number;
|
|
35
43
|
startedAt: number;
|
|
36
44
|
cwd: string;
|
|
45
|
+
/**
|
|
46
|
+
* Last time the control server rewrote this file (see {@link readDiscovery}).
|
|
47
|
+
* Absent on a file written by a control server that predates the heartbeat.
|
|
48
|
+
*/
|
|
49
|
+
heartbeatAt?: number;
|
|
50
|
+
/** The control server's package version, so a hook can spot a stale daemon. */
|
|
51
|
+
version?: string;
|
|
52
|
+
/**
|
|
53
|
+
* True when the process was started in the background — by `bir up` or by
|
|
54
|
+
* the SessionStart hook — rather than in somebody's terminal. `bir status`
|
|
55
|
+
* and `bir doctor` say so, and name the log file, because "the recorder is
|
|
56
|
+
* running" is not a useful sentence when there is no window to look at.
|
|
57
|
+
*/
|
|
58
|
+
daemon?: boolean;
|
|
59
|
+
/** Where a background process's stderr goes. */
|
|
60
|
+
logFile?: string;
|
|
37
61
|
}
|
|
38
62
|
export declare function writeDiscovery(info: DiscoveryInfo): string;
|
|
39
63
|
export declare function removeDiscovery(cwd: string): void;
|
|
64
|
+
/** True when a pid is still running (or when we cannot tell, which we treat as alive). */
|
|
65
|
+
export declare function pidAlive(pid: number): boolean;
|
|
40
66
|
/**
|
|
41
67
|
* Read the discovery file for `cwd`, or undefined when there is none, it is
|
|
42
68
|
* malformed, or it is stale.
|
|
@@ -24,15 +24,29 @@
|
|
|
24
24
|
* SECURITY (§9). Files are mode 0600 and carry the loopback bearer token. A local
|
|
25
25
|
* port that accepts unauthenticated step reports is a local exfiltration channel.
|
|
26
26
|
*/
|
|
27
|
-
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
27
|
+
import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
28
28
|
import { controlDir, discoveryPath, ensureDir } from "./paths.js";
|
|
29
29
|
import { logDetail, errText } from "../util/log.js";
|
|
30
30
|
/** A discovery file older than this is ignored even if its pid is somehow alive. */
|
|
31
31
|
export const MAX_DISCOVERY_AGE_MS = 24 * 60 * 60 * 1000;
|
|
32
|
+
/**
|
|
33
|
+
* How often a running control server rewrites its discovery file.
|
|
34
|
+
*
|
|
35
|
+
* Well inside {@link MAX_DISCOVERY_AGE_MS}: a background recorder that misses
|
|
36
|
+
* a few beats (a laptop asleep over a weekend) is still found on Monday, and a
|
|
37
|
+
* dead one is still forgotten within a day.
|
|
38
|
+
*/
|
|
39
|
+
export const HEARTBEAT_MS = 30 * 60 * 1000;
|
|
32
40
|
export function writeDiscovery(info) {
|
|
33
41
|
ensureDir(controlDir());
|
|
34
42
|
const path = discoveryPath(info.cwd);
|
|
35
|
-
|
|
43
|
+
// Write beside, then rename over: a reader never sees a half-written file.
|
|
44
|
+
// The heartbeat rewrites this every half hour, and a proxy or the ensure
|
|
45
|
+
// hook reading an empty file in that instant would conclude "no recorder"
|
|
46
|
+
// and start a second one.
|
|
47
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
48
|
+
writeFileSync(tmp, JSON.stringify(info, null, 2), { mode: 0o600 });
|
|
49
|
+
renameSync(tmp, path);
|
|
36
50
|
return path;
|
|
37
51
|
}
|
|
38
52
|
export function removeDiscovery(cwd) {
|
|
@@ -46,7 +60,7 @@ export function removeDiscovery(cwd) {
|
|
|
46
60
|
}
|
|
47
61
|
}
|
|
48
62
|
/** True when a pid is still running (or when we cannot tell, which we treat as alive). */
|
|
49
|
-
function pidAlive(pid) {
|
|
63
|
+
export function pidAlive(pid) {
|
|
50
64
|
if (!Number.isInteger(pid) || pid <= 0)
|
|
51
65
|
return false;
|
|
52
66
|
try {
|
|
@@ -73,7 +87,12 @@ export function readDiscovery(cwd) {
|
|
|
73
87
|
typeof parsed.pid !== "number") {
|
|
74
88
|
return undefined;
|
|
75
89
|
}
|
|
76
|
-
|
|
90
|
+
// Aged from the newest of the two stamps. A background recorder that has
|
|
91
|
+
// run for a week is still the right one to talk to; it rewrites this file
|
|
92
|
+
// every `HEARTBEAT_MS` precisely so this check keeps saying so. A file
|
|
93
|
+
// whose process died keeps its last stamp and ages out as before.
|
|
94
|
+
const freshest = Math.max(parsed.startedAt ?? 0, parsed.heartbeatAt ?? 0);
|
|
95
|
+
if (Date.now() - freshest > MAX_DISCOVERY_AGE_MS) {
|
|
77
96
|
logDetail("discovery.stale", { path, reason: "too old" });
|
|
78
97
|
return undefined;
|
|
79
98
|
}
|
|
@@ -94,20 +113,33 @@ export function readDiscovery(cwd) {
|
|
|
94
113
|
*/
|
|
95
114
|
export async function resolveControl(cwd, timeoutMs, pollMs = 250) {
|
|
96
115
|
const envUrl = process.env.BIR_CONTROL_URL;
|
|
116
|
+
const deadline = Date.now() + timeoutMs;
|
|
97
117
|
if (envUrl) {
|
|
98
|
-
// The env channel
|
|
99
|
-
//
|
|
100
|
-
|
|
118
|
+
// The env channel names the address but not, on its own, the token: that
|
|
119
|
+
// lives in the discovery file the control server writes when it starts.
|
|
120
|
+
// Since the SessionStart hook is what starts the recorder, the host has
|
|
121
|
+
// usually spawned this proxy *before* that file exists — so wait for it
|
|
122
|
+
// the way the file-only path below does, rather than register with an
|
|
123
|
+
// empty token and be refused. `BIR_CONTROL_TOKEN` (the SDK channel) skips
|
|
124
|
+
// the wait; so does a file that is already there.
|
|
125
|
+
const fromEnv = process.env.BIR_CONTROL_TOKEN;
|
|
126
|
+
let fromFile = readDiscovery(cwd);
|
|
127
|
+
while (!fromEnv && !fromFile && Date.now() < deadline) {
|
|
128
|
+
await new Promise((resolve) => {
|
|
129
|
+
const t = setTimeout(resolve, Math.min(pollMs, Math.max(0, deadline - Date.now())));
|
|
130
|
+
t.unref?.();
|
|
131
|
+
});
|
|
132
|
+
fromFile = readDiscovery(cwd);
|
|
133
|
+
}
|
|
101
134
|
return {
|
|
102
135
|
url: envUrl.replace(/\/+$/, ""),
|
|
103
|
-
token:
|
|
136
|
+
token: fromEnv ?? fromFile?.token ?? "",
|
|
104
137
|
sessionId: fromFile?.sessionId ?? "",
|
|
105
138
|
pid: fromFile?.pid ?? 0,
|
|
106
139
|
startedAt: fromFile?.startedAt ?? Date.now(),
|
|
107
140
|
cwd,
|
|
108
141
|
};
|
|
109
142
|
}
|
|
110
|
-
const deadline = Date.now() + timeoutMs;
|
|
111
143
|
for (;;) {
|
|
112
144
|
const found = readDiscovery(cwd);
|
|
113
145
|
if (found)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ensure-hook — what `bir-hooks ensure` does when Claude Code fires SessionStart.
|
|
3
|
+
*
|
|
4
|
+
* Installed by `bir install` as the SessionStart hook, in exec form (no shell):
|
|
5
|
+
*
|
|
6
|
+
* { "type": "command", "command": "<node>", "args": ["<dist>/bin/bir-hooks.js", "ensure"] }
|
|
7
|
+
*
|
|
8
|
+
* It does two things the HTTP hook it replaces could not: it **starts the
|
|
9
|
+
* recorder** when this directory has none (see daemon.ts), and only then
|
|
10
|
+
* **relays the SessionStart payload** to it, so the control server still sees
|
|
11
|
+
* the event that used to reach it over HTTP. The two must be one hook: Claude
|
|
12
|
+
* Code runs every hook of an event in parallel, and an HTTP hook racing the
|
|
13
|
+
* daemon it depends on would fail on every first session of the day.
|
|
14
|
+
*
|
|
15
|
+
* THE ONE RULE: a host session never fails because of BaseInstRunner. Every
|
|
16
|
+
* path here ends in exit 0. A recorder that would not start is one line on
|
|
17
|
+
* stderr and a session that records Tier 2, exactly what happened before this
|
|
18
|
+
* hook existed.
|
|
19
|
+
*
|
|
20
|
+
* STDOUT IS CONTEXT. For SessionStart, Claude Code adds whatever a command hook
|
|
21
|
+
* prints on stdout to the model's context. So nothing is printed unless the
|
|
22
|
+
* control server answered with something (today it answers `{}`), and then it
|
|
23
|
+
* is the answer, verbatim, as JSON.
|
|
24
|
+
*/
|
|
25
|
+
import { type EnsureOptions } from "./daemon.js";
|
|
26
|
+
export interface EnsureHookOptions extends EnsureOptions {
|
|
27
|
+
stdin?: NodeJS.ReadableStream & {
|
|
28
|
+
isTTY?: boolean;
|
|
29
|
+
};
|
|
30
|
+
stdout?: NodeJS.WritableStream;
|
|
31
|
+
stderr?: NodeJS.WritableStream;
|
|
32
|
+
/** How long to wait for the payload on stdin before going on without it. */
|
|
33
|
+
stdinTimeoutMs?: number;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Run the hook for `cwd`. Resolves the exit code, which is always 0.
|
|
37
|
+
*/
|
|
38
|
+
export declare function runEnsureHook(cwd: string, opts?: EnsureHookOptions): Promise<number>;
|
|
39
|
+
//# sourceMappingURL=ensure-hook.d.ts.map
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ensure-hook — what `bir-hooks ensure` does when Claude Code fires SessionStart.
|
|
3
|
+
*
|
|
4
|
+
* Installed by `bir install` as the SessionStart hook, in exec form (no shell):
|
|
5
|
+
*
|
|
6
|
+
* { "type": "command", "command": "<node>", "args": ["<dist>/bin/bir-hooks.js", "ensure"] }
|
|
7
|
+
*
|
|
8
|
+
* It does two things the HTTP hook it replaces could not: it **starts the
|
|
9
|
+
* recorder** when this directory has none (see daemon.ts), and only then
|
|
10
|
+
* **relays the SessionStart payload** to it, so the control server still sees
|
|
11
|
+
* the event that used to reach it over HTTP. The two must be one hook: Claude
|
|
12
|
+
* Code runs every hook of an event in parallel, and an HTTP hook racing the
|
|
13
|
+
* daemon it depends on would fail on every first session of the day.
|
|
14
|
+
*
|
|
15
|
+
* THE ONE RULE: a host session never fails because of BaseInstRunner. Every
|
|
16
|
+
* path here ends in exit 0. A recorder that would not start is one line on
|
|
17
|
+
* stderr and a session that records Tier 2, exactly what happened before this
|
|
18
|
+
* hook existed.
|
|
19
|
+
*
|
|
20
|
+
* STDOUT IS CONTEXT. For SessionStart, Claude Code adds whatever a command hook
|
|
21
|
+
* prints on stdout to the model's context. So nothing is printed unless the
|
|
22
|
+
* control server answered with something (today it answers `{}`), and then it
|
|
23
|
+
* is the answer, verbatim, as JSON.
|
|
24
|
+
*/
|
|
25
|
+
import { ensureDaemon } from "./daemon.js";
|
|
26
|
+
import { errText } from "../util/log.js";
|
|
27
|
+
/** Read stdin to its end, or give up after `timeoutMs` with what arrived. */
|
|
28
|
+
function readStdin(stream, timeoutMs) {
|
|
29
|
+
// A person running `bir-hooks ensure` by hand has nothing to paste; do not
|
|
30
|
+
// sit waiting for a Ctrl-D they do not know to press.
|
|
31
|
+
if (stream.isTTY)
|
|
32
|
+
return Promise.resolve("");
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
let text = "";
|
|
35
|
+
let done = false;
|
|
36
|
+
const finish = () => {
|
|
37
|
+
if (done)
|
|
38
|
+
return;
|
|
39
|
+
done = true;
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
resolve(text);
|
|
42
|
+
};
|
|
43
|
+
const timer = setTimeout(finish, timeoutMs);
|
|
44
|
+
stream.setEncoding("utf8");
|
|
45
|
+
stream.on("data", (chunk) => {
|
|
46
|
+
text += chunk;
|
|
47
|
+
});
|
|
48
|
+
stream.on("end", finish);
|
|
49
|
+
stream.on("error", finish);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Run the hook for `cwd`. Resolves the exit code, which is always 0.
|
|
54
|
+
*/
|
|
55
|
+
export async function runEnsureHook(cwd, opts = {}) {
|
|
56
|
+
const stdout = opts.stdout ?? process.stdout;
|
|
57
|
+
const stderr = opts.stderr ?? process.stderr;
|
|
58
|
+
const raw = await readStdin(opts.stdin ?? process.stdin, opts.stdinTimeoutMs ?? 3_000);
|
|
59
|
+
let payload = {};
|
|
60
|
+
if (raw.trim()) {
|
|
61
|
+
try {
|
|
62
|
+
payload = JSON.parse(raw);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Not our problem to diagnose; the control server copes with an empty one.
|
|
66
|
+
payload = {};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
const { status, started, restarted } = await ensureDaemon(cwd, {
|
|
71
|
+
hooksScript: opts.hooksScript,
|
|
72
|
+
env: opts.env,
|
|
73
|
+
timeoutMs: opts.timeoutMs,
|
|
74
|
+
restartOnSkew: opts.restartOnSkew ?? true,
|
|
75
|
+
});
|
|
76
|
+
if (started) {
|
|
77
|
+
stderr.write(`[bir] recorder ${restarted ? "restarted" : "started"} in the background (pid ${status.info.pid}, log ${status.info.logFile ?? "?"})\n`);
|
|
78
|
+
}
|
|
79
|
+
const res = await fetch(`${status.info.url}/session/start`, {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: {
|
|
82
|
+
"content-type": "application/json",
|
|
83
|
+
authorization: `Bearer ${status.info.token}`,
|
|
84
|
+
},
|
|
85
|
+
body: JSON.stringify(payload),
|
|
86
|
+
signal: AbortSignal.timeout(5_000),
|
|
87
|
+
});
|
|
88
|
+
const answer = (await res.json().catch(() => ({})));
|
|
89
|
+
if (res.ok && answer && typeof answer === "object" && Object.keys(answer).length > 0) {
|
|
90
|
+
stdout.write(JSON.stringify(answer));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
stderr.write(`[bir] ensure: ${errText(err)} — this session records standalone (Tier 2)\n`);
|
|
95
|
+
}
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
//# sourceMappingURL=ensure-hook.js.map
|
package/dist/control/paths.d.ts
CHANGED
|
@@ -13,6 +13,20 @@ export declare function configDir(): string;
|
|
|
13
13
|
export declare function controlDir(): string;
|
|
14
14
|
/** The sidecar holding original MCP entries so `bir uninstall` can restore them. */
|
|
15
15
|
export declare function installedPath(): string;
|
|
16
|
+
/**
|
|
17
|
+
* Settings that outlive a shell: today, the service address.
|
|
18
|
+
*
|
|
19
|
+
* `bir setup` and `bir login` write it; every process reads it when
|
|
20
|
+
* `BIR_AUTH_URL` is unset. It is what lets a recorder started by a hook —
|
|
21
|
+
* with whatever environment the host happened to have — know where to send
|
|
22
|
+
* steps, and it is why "close the terminal and open a new one" is no longer a
|
|
23
|
+
* step in the install.
|
|
24
|
+
*/
|
|
25
|
+
export declare function configPath(): string;
|
|
26
|
+
/** Where a background `bir-hooks` writes its audit lines, one file per project. */
|
|
27
|
+
export declare function logsDir(): string;
|
|
28
|
+
/** The log file of the background recorder for `cwd`. */
|
|
29
|
+
export declare function daemonLogPath(cwd: string): string;
|
|
16
30
|
/** Create a directory with restrictive permissions. Idempotent, never throws. */
|
|
17
31
|
export declare function ensureDir(dir: string, mode?: number): void;
|
|
18
32
|
/**
|
package/dist/control/paths.js
CHANGED
|
@@ -23,6 +23,26 @@ export function controlDir() {
|
|
|
23
23
|
export function installedPath() {
|
|
24
24
|
return join(configDir(), "installed.json");
|
|
25
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Settings that outlive a shell: today, the service address.
|
|
28
|
+
*
|
|
29
|
+
* `bir setup` and `bir login` write it; every process reads it when
|
|
30
|
+
* `BIR_AUTH_URL` is unset. It is what lets a recorder started by a hook —
|
|
31
|
+
* with whatever environment the host happened to have — know where to send
|
|
32
|
+
* steps, and it is why "close the terminal and open a new one" is no longer a
|
|
33
|
+
* step in the install.
|
|
34
|
+
*/
|
|
35
|
+
export function configPath() {
|
|
36
|
+
return join(configDir(), "config.json");
|
|
37
|
+
}
|
|
38
|
+
/** Where a background `bir-hooks` writes its audit lines, one file per project. */
|
|
39
|
+
export function logsDir() {
|
|
40
|
+
return join(configDir(), "logs");
|
|
41
|
+
}
|
|
42
|
+
/** The log file of the background recorder for `cwd`. */
|
|
43
|
+
export function daemonLogPath(cwd) {
|
|
44
|
+
return join(logsDir(), `${controlKey(cwd)}.log`);
|
|
45
|
+
}
|
|
26
46
|
/** Create a directory with restrictive permissions. Idempotent, never throws. */
|
|
27
47
|
export function ensureDir(dir, mode = 0o700) {
|
|
28
48
|
try {
|