@basein/runner 0.2.8 → 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.
@@ -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
- writeFileSync(path, JSON.stringify(info, null, 2), { mode: 0o600 });
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
- if (Date.now() - (parsed.startedAt ?? 0) > MAX_DISCOVERY_AGE_MS) {
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 may still race the server's own startup, so it is verified
99
- // against the discovery file when one exists (that is where the token lives).
100
- const fromFile = readDiscovery(cwd);
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: process.env.BIR_CONTROL_TOKEN ?? fromFile?.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
@@ -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
  /**
@@ -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 {