@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.
Files changed (45) hide show
  1. package/README.md +64 -21
  2. package/dist/auth/client.d.ts +40 -1
  3. package/dist/auth/client.js +77 -9
  4. package/dist/bin/bir-hooks.d.ts +18 -3
  5. package/dist/bin/bir-hooks.js +124 -38
  6. package/dist/bin/bir.d.ts +2 -0
  7. package/dist/bin/bir.js +362 -39
  8. package/dist/bin/investigate.js +5 -1
  9. package/dist/bin/setup.d.ts +72 -0
  10. package/dist/bin/setup.js +286 -0
  11. package/dist/config/adapters/claude-code.d.ts +90 -4
  12. package/dist/config/adapters/claude-code.js +164 -16
  13. package/dist/config/generate.d.ts +93 -1
  14. package/dist/config/generate.js +90 -3
  15. package/dist/control/client.d.ts +5 -0
  16. package/dist/control/client.js +8 -0
  17. package/dist/control/daemon.d.ts +116 -0
  18. package/dist/control/daemon.js +339 -0
  19. package/dist/control/discovery.d.ts +26 -0
  20. package/dist/control/discovery.js +41 -9
  21. package/dist/control/ensure-hook.d.ts +39 -0
  22. package/dist/control/ensure-hook.js +98 -0
  23. package/dist/control/paths.d.ts +14 -0
  24. package/dist/control/paths.js +20 -0
  25. package/dist/control/server.d.ts +28 -0
  26. package/dist/control/server.js +22 -6
  27. package/dist/proxy/session.d.ts +8 -1
  28. package/dist/proxy/session.js +28 -6
  29. package/dist/replay/controller.d.ts +24 -1
  30. package/dist/replay/controller.js +76 -20
  31. package/dist/replay/handover.js +5 -0
  32. package/dist/replay/plan.d.ts +2 -0
  33. package/dist/replay/plan.js +53 -6
  34. package/dist/replay/pricing.d.ts +1 -1
  35. package/dist/replay/pricing.js +12 -4
  36. package/dist/replay/tool-error.d.ts +15 -0
  37. package/dist/replay/tool-error.js +17 -0
  38. package/dist/replay/types.d.ts +48 -1
  39. package/docs/calculatedReplayGuide.md +157 -68
  40. package/docs/installRun.md +457 -111
  41. package/docs/loginWeb.md +1 -1
  42. package/docs/quickstart.md +193 -158
  43. package/package.json +2 -1
  44. package/scripts/install.ps1 +669 -0
  45. package/scripts/install.sh +586 -0
@@ -14,16 +14,135 @@
14
14
  * the entries back" and "the file is as you left it".
15
15
  */
16
16
  import { createHash } from "node:crypto";
17
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
- import { dirname, join } from "node:path";
17
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
18
+ import { dirname, join, parse as parsePath } from "node:path";
19
19
  import { claudeJsonPath, parseJsonFile, projectKeyFor } from "../resolve.js";
20
20
  export function claudeCodePaths(cwd, claudeJsonOverride) {
21
21
  return {
22
22
  claudeJson: claudeJsonPath(claudeJsonOverride),
23
23
  mcpJson: join(cwd, ".mcp.json"),
24
24
  settings: join(cwd, ".claude", "settings.json"),
25
+ settingsLocal: join(cwd, ".claude", "settings.local.json"),
25
26
  };
26
27
  }
28
+ /** The line `bir` adds to a repository's exclude file. */
29
+ export const LOCAL_SETTINGS_EXCLUDE = ".claude/settings.local.json";
30
+ /**
31
+ * Keep `.claude/settings.local.json` out of version control, the way Claude
32
+ * Code does when *it* creates the file — it only does so for files it wrote.
33
+ *
34
+ * `.git/info/exclude`, never `.gitignore`: the first is the person's own,
35
+ * untracked list; the second is a file the team owns and would see in a diff.
36
+ * Walks up from `dir` to find the repository, follows a worktree's `gitdir:`
37
+ * pointer, and appends the line once. Returns the exclude file's path when the
38
+ * line is there (added or already present), undefined outside a repository or
39
+ * when the file could not be written — an exclude that fails is a note, not a
40
+ * failed install.
41
+ */
42
+ export function ensureGitExclude(dir, line = LOCAL_SETTINGS_EXCLUDE) {
43
+ let gitDir;
44
+ let current = dir;
45
+ for (;;) {
46
+ const candidate = join(current, ".git");
47
+ if (existsSync(candidate)) {
48
+ try {
49
+ if (statSync(candidate).isDirectory()) {
50
+ gitDir = candidate;
51
+ }
52
+ else {
53
+ // A worktree or submodule: `.git` is a file naming the real directory.
54
+ const pointer = readFileSync(candidate, "utf8").match(/^gitdir:\s*(.+)$/m)?.[1]?.trim();
55
+ if (pointer)
56
+ gitDir = join(current, pointer);
57
+ }
58
+ }
59
+ catch {
60
+ /* unreadable — treat as no repository */
61
+ }
62
+ break;
63
+ }
64
+ const parent = dirname(current);
65
+ if (parent === current || parsePath(current).root === current)
66
+ break;
67
+ current = parent;
68
+ }
69
+ if (!gitDir)
70
+ return undefined;
71
+ try {
72
+ const infoDir = join(gitDir, "info");
73
+ mkdirSync(infoDir, { recursive: true });
74
+ const exclude = join(infoDir, "exclude");
75
+ const existing = existsSync(exclude) ? readFileSync(exclude, "utf8") : "";
76
+ const lines = existing.split(/\r?\n/).map((l) => l.trim());
77
+ if (!lines.includes(line)) {
78
+ const sep = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
79
+ appendFileSync(exclude, `${sep}${line}\n`);
80
+ }
81
+ return exclude;
82
+ }
83
+ catch {
84
+ return undefined;
85
+ }
86
+ }
87
+ /** Undo {@link ensureGitExclude}: drop the line, leave everything else. */
88
+ export function removeGitExclude(dir, line = LOCAL_SETTINGS_EXCLUDE) {
89
+ const exclude = ensureGitExclude(dir, line); // finds the file (and would add the line, which is there)
90
+ if (!exclude)
91
+ return undefined;
92
+ try {
93
+ const kept = readFileSync(exclude, "utf8")
94
+ .split(/\r?\n/)
95
+ .filter((l) => l.trim() !== line);
96
+ const text = kept.join("\n").replace(/\n+$/, "");
97
+ writeFileSync(exclude, text ? `${text}\n` : "");
98
+ return exclude;
99
+ }
100
+ catch {
101
+ return undefined;
102
+ }
103
+ }
104
+ /**
105
+ * Pre-approve project-scoped MCP servers by name (`enabledMcpjsonServers`).
106
+ *
107
+ * Claude Code asks before it uses a server from `.mcp.json`, and a "No" there
108
+ * silently leaves a direct replay with nowhere to deliver its results. The
109
+ * runner's own scenario server is approved here, in the person's local
110
+ * settings, so the question is never asked. Returns the file's new text.
111
+ */
112
+ export function enableMcpjsonServers(settingsPath, names) {
113
+ const parsed = parseJsonFile(settingsPath) ?? {};
114
+ const current = Array.isArray(parsed.enabledMcpjsonServers)
115
+ ? parsed.enabledMcpjsonServers.map(String)
116
+ : [];
117
+ parsed.enabledMcpjsonServers = [...new Set([...current, ...names])];
118
+ return writeJson(settingsPath, parsed);
119
+ }
120
+ /** Undo {@link enableMcpjsonServers} for `names`; drops the key when empty. */
121
+ export function disableMcpjsonServers(settingsPath, names) {
122
+ const parsed = parseJsonFile(settingsPath);
123
+ if (!parsed || !Array.isArray(parsed.enabledMcpjsonServers))
124
+ return undefined;
125
+ const kept = parsed.enabledMcpjsonServers.map(String).filter((n) => !names.includes(n));
126
+ if (kept.length === 0)
127
+ delete parsed.enabledMcpjsonServers;
128
+ else
129
+ parsed.enabledMcpjsonServers = kept;
130
+ return writeJson(settingsPath, parsed);
131
+ }
132
+ /** True when `settingsPath` holds any hook of ours. */
133
+ export function hasBirHooks(settingsPath) {
134
+ let parsed;
135
+ try {
136
+ parsed = parseJsonFile(settingsPath);
137
+ }
138
+ catch {
139
+ return false;
140
+ }
141
+ const hooks = parsed?.hooks;
142
+ if (!hooks)
143
+ return false;
144
+ return Object.values(hooks).some((matchers) => (matchers ?? []).some((m) => (m.hooks ?? []).some((h) => isBirHook(h))));
145
+ }
27
146
  /** Which file holds a given scope's `mcpServers` for this cwd. */
28
147
  export function fileForScope(scope, paths) {
29
148
  switch (scope) {
@@ -120,27 +239,56 @@ export const HOOK_ROUTES = [
120
239
  // that is actually finished is worth a few hundred milliseconds at shutdown.
121
240
  { event: "SessionEnd", route: "/session/end", matcher: "", timeout: 30 },
122
241
  ];
123
- /** Build the `hooks` block pointing at a control server. */
124
- export function buildHooksBlock(controlUrl, token) {
242
+ /**
243
+ * The argument that makes a 'bir-hooks' invocation the SessionStart hook.
244
+ * Exported so the binary and the adapter cannot drift apart on the spelling.
245
+ */
246
+ export const ENSURE_ARG = "ensure";
247
+ /** How long SessionStart may wait for a background recorder to come up. */
248
+ export const ENSURE_TIMEOUT_S = 30;
249
+ /** Build the 'hooks' block pointing at a control server. */
250
+ export function buildHooksBlock(controlUrl, token, opts = {}) {
125
251
  const base = controlUrl.replace(/\/+$/, "");
126
252
  const block = {};
127
253
  for (const { event, route, matcher, timeout, async } of HOOK_ROUTES) {
128
- const hook = {
129
- type: "http",
130
- url: `${base}${route}`,
131
- timeout,
132
- headers: { Authorization: `Bearer ${token}` },
133
- };
134
- if (async)
135
- hook.async = true;
254
+ let hook;
255
+ if (event === "SessionStart" && opts.ensure) {
256
+ hook = {
257
+ type: "command",
258
+ command: opts.ensure.node,
259
+ args: [...(opts.ensure.nodeFlags ?? []), opts.ensure.hooksScript, ENSURE_ARG],
260
+ timeout: ENSURE_TIMEOUT_S,
261
+ };
262
+ }
263
+ else {
264
+ const http = {
265
+ type: "http",
266
+ url: `${base}${route}`,
267
+ timeout,
268
+ headers: { Authorization: `Bearer ${token}` },
269
+ };
270
+ if (async)
271
+ http.async = true;
272
+ hook = http;
273
+ }
136
274
  block[event] = [{ matcher: matcher ?? "", hooks: [hook] }];
137
275
  }
138
276
  return block;
139
277
  }
140
- /** True when a hook entry points at a BaseInstRunner control server. */
141
- function isBirHook(entry) {
142
- const url = entry.url ?? "";
143
- return HOOK_ROUTES.some(({ route }) => url.endsWith(route));
278
+ /**
279
+ * True when a hook entry is one of ours: an HTTP hook aimed at a control-server
280
+ * route, or the exec-form command hook that starts the recorder.
281
+ */
282
+ export function isBirHook(entry) {
283
+ const e = entry;
284
+ const url = e.url ?? "";
285
+ if (HOOK_ROUTES.some(({ route }) => url.endsWith(route)))
286
+ return true;
287
+ if (Array.isArray(e.args)) {
288
+ const args = e.args.map(String);
289
+ return args.some((a) => /[\\/]bir-hooks\.js$/.test(a)) && args.includes(ENSURE_ARG);
290
+ }
291
+ return false;
144
292
  }
145
293
  /**
146
294
  * Merge our hooks into a settings file, leaving every other hook alone. Returns
@@ -60,7 +60,21 @@ export interface WrapOptions {
60
60
  proxyPath?: string;
61
61
  /** Pins the npx spec. Omitted only by tests that do not care which version. */
62
62
  version?: string;
63
+ /**
64
+ * Node flags written in front of the script for `local` and `global`
65
+ * entries — `--use-system-ca` on a network that inspects TLS, so the proxies
66
+ * trust what the install did (see {@link nodeFlagsToCarry}).
67
+ */
68
+ nodeFlags?: string[];
63
69
  }
70
+ /**
71
+ * The node flags this process was started with that everything it sets up
72
+ * should share. The install scripts run `bir setup` with `--use-system-ca`
73
+ * when the Node supports it; a hook, a proxy or a recorder started without it
74
+ * would then fail the first TLS-inspected upload while the install itself
75
+ * succeeded.
76
+ */
77
+ export declare function nodeFlagsToCarry(execArgv?: string[]): string[];
64
78
  /** A wrapped entry is always a stdio entry: the proxy is the process the host spawns. */
65
79
  export interface WrappedEntry {
66
80
  command: string;
@@ -84,6 +98,7 @@ export declare function scenarioEntry(opts: {
84
98
  scenarioPath?: string;
85
99
  controlUrl?: string;
86
100
  version?: string;
101
+ nodeFlags?: string[];
87
102
  }): WrappedEntry;
88
103
  /** True when this entry is the scenario server — makes `install --replay` idempotent. */
89
104
  export declare function isScenarioServer(config: McpServerConfig): boolean;
@@ -108,12 +123,58 @@ export interface FileBackup {
108
123
  originalText: string | null;
109
124
  writtenSha: string;
110
125
  }
126
+ /**
127
+ * The replay switches an operator set for one project, kept where a recorder
128
+ * started by a hook can read them. They used to be environment variables read
129
+ * by `bir-hooks` at start — which was fine while a person started it in a
130
+ * terminal, and silently reset an allow-list to "(all wrapped)" the first time
131
+ * the SessionStart hook started it with Claude Code's environment instead.
132
+ */
133
+ export interface ReplayPolicy {
134
+ /** `bir replay off` → false. Absent means on. */
135
+ enabled?: boolean;
136
+ /** Server keys eligible for direct execution. Absent means every wrapped one. */
137
+ allowServers?: string[];
138
+ /** `BIR_MIN_STEER_SIMILARITY`'s stored twin. */
139
+ minSimilarity?: number;
140
+ }
141
+ /** What `bir install` decided for one project directory. */
142
+ export interface ProjectRecord {
143
+ /** The control-server port written into this project's hook URLs. */
144
+ port: number;
145
+ /**
146
+ * This project's loopback bearer token. One per project, so a token in one
147
+ * project's settings file opens that project's recorder and no other. A
148
+ * record without one (written by 0.2.8) falls back to the machine-wide
149
+ * {@link InstalledSidecar.token}.
150
+ */
151
+ token?: string;
152
+ /** Replay switches for this project; see {@link ReplayPolicy}. */
153
+ replay?: ReplayPolicy;
154
+ /**
155
+ * How the entries were generated last time (`global`, `local` or `npx`), so
156
+ * a later install with neither flag keeps the same shape instead of turning
157
+ * a fleet's absolute paths into pinned-npx entries.
158
+ */
159
+ invocation?: Invocation;
160
+ }
111
161
  export interface InstalledSidecar {
112
162
  version: 1;
113
163
  /** Stable loopback token written into the hook settings at install time. */
114
164
  token?: string;
115
- /** Port the hook settings were written with. */
165
+ /**
166
+ * Port the most recent install wrote. Kept for a `bir-hooks` older than
167
+ * {@link InstalledSidecar.projects}; new code reads the per-project record.
168
+ */
116
169
  controlPort?: number;
170
+ /**
171
+ * Per-project decisions, keyed by the normalised directory. Two projects
172
+ * recorded at once used to share port 53411: the second control server fell
173
+ * back to an ephemeral port while both projects' hooks kept posting to the
174
+ * first, so one project's steps landed in the other's run. Each project now
175
+ * gets its own port, chosen once and reused.
176
+ */
177
+ projects?: Record<string, ProjectRecord>;
117
178
  /** `<cwd>::<serverName>` → record. */
118
179
  servers: Record<string, InstalledRecord>;
119
180
  /** Absolute path → the text that was there before we touched it. */
@@ -124,4 +185,35 @@ export interface InstalledSidecar {
124
185
  export declare function readSidecar(): InstalledSidecar;
125
186
  export declare function writeSidecar(sidecar: InstalledSidecar): void;
126
187
  export declare function sidecarKey(cwd: string, serverName: string): string;
188
+ /** The lowest port a project is given when none was asked for. */
189
+ export declare const FIRST_PROJECT_PORT = 53411;
190
+ /** This project's record, if an install has decided anything for it. */
191
+ export declare function projectRecord(sidecar: InstalledSidecar, cwd: string): ProjectRecord | undefined;
192
+ /**
193
+ * The port this project's hooks and control server should agree on.
194
+ *
195
+ * A port already chosen for this directory is kept — moving it would orphan
196
+ * the URLs in `settings.json`. Otherwise the lowest port from
197
+ * {@link FIRST_PROJECT_PORT} upward that no *other* project holds, so the first
198
+ * project on a machine keeps the documented default and the second one does not
199
+ * collide with it. Deterministic, so re-running install changes nothing.
200
+ */
201
+ export declare function allocateProjectPort(sidecar: InstalledSidecar, cwd: string): number;
202
+ /** True when nothing on this machine is bound to `port` on the loopback address. */
203
+ export declare function probeFreePort(port: number): Promise<boolean>;
204
+ /**
205
+ * {@link allocateProjectPort}, but a port nobody else already holds.
206
+ *
207
+ * The sidecar only knows about our own projects. 53411 sits in the dynamic
208
+ * range on every OS and inside the blocks Hyper-V and WSL reserve on Windows,
209
+ * so "unused by another project" is not "free": a recorder told to use a port
210
+ * the system has excluded exits, and every session records Tier 2 until
211
+ * somebody reads the log. A port that has been recorded for this project is
212
+ * kept as it is — the hooks name it — and the recorder says so if it is busy.
213
+ */
214
+ export declare function allocateFreeProjectPort(sidecar: InstalledSidecar, cwd: string): Promise<number>;
215
+ /** Merge `patch` into the record for `cwd`, creating it when absent. */
216
+ export declare function setProjectRecord(sidecar: InstalledSidecar, cwd: string, patch: Partial<ProjectRecord> & Pick<ProjectRecord, "port">): ProjectRecord;
217
+ /** The bearer token this project's hooks and control server share. */
218
+ export declare function projectToken(sidecar: InstalledSidecar, cwd: string): string | undefined;
127
219
  //# sourceMappingURL=generate.d.ts.map
@@ -12,7 +12,8 @@
12
12
  * BaseInstRunner never understood.
13
13
  */
14
14
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
15
- import { configDir, ensureDir, installedPath } from "../control/paths.js";
15
+ import { createServer } from "node:net";
16
+ import { configDir, ensureDir, installedPath, normalizePath } from "../control/paths.js";
16
17
  import { isRemote } from "./resolve.js";
17
18
  /** npm package name, as it appears in a generated `npx -p` invocation. */
18
19
  export const PACKAGE_NAME = "@basein/runner";
@@ -31,6 +32,16 @@ export function packageSpec(version) {
31
32
  * model, and a collision would route that call somewhere else entirely.
32
33
  */
33
34
  export const SCENARIO_SERVER_KEY = "bir";
35
+ /**
36
+ * The node flags this process was started with that everything it sets up
37
+ * should share. The install scripts run `bir setup` with `--use-system-ca`
38
+ * when the Node supports it; a hook, a proxy or a recorder started without it
39
+ * would then fail the first TLS-inspected upload while the install itself
40
+ * succeeded.
41
+ */
42
+ export function nodeFlagsToCarry(execArgv = process.execArgv) {
43
+ return execArgv.filter((a) => a === "--use-system-ca");
44
+ }
34
45
  /** True when this entry already points at `bir-proxy` — makes install idempotent. */
35
46
  export function isWrapped(config) {
36
47
  if (isRemote(config))
@@ -68,7 +79,7 @@ export function wrapEntry(config, opts) {
68
79
  }
69
80
  const invocation = opts.invocation ?? "npx";
70
81
  const entry = invocation !== "npx" && opts.proxyPath
71
- ? { command: process.execPath, args: [opts.proxyPath, ...proxyArgs] }
82
+ ? { command: process.execPath, args: [...(opts.nodeFlags ?? []), opts.proxyPath, ...proxyArgs] }
72
83
  : { command: "npx", args: ["-y", "-p", packageSpec(opts.version), "bir-proxy", ...proxyArgs] };
73
84
  if (Object.keys(env).length > 0)
74
85
  entry.env = env;
@@ -80,7 +91,7 @@ export function wrapEntry(config, opts) {
80
91
  */
81
92
  export function scenarioEntry(opts) {
82
93
  const entry = opts.invocation && opts.invocation !== "npx" && opts.scenarioPath
83
- ? { command: process.execPath, args: [opts.scenarioPath] }
94
+ ? { command: process.execPath, args: [...(opts.nodeFlags ?? []), opts.scenarioPath] }
84
95
  : { command: "npx", args: ["-y", "-p", packageSpec(opts.version), "bir-scenario"] };
85
96
  if (opts.controlUrl)
86
97
  entry.env = { BIR_CONTROL_URL: opts.controlUrl };
@@ -111,4 +122,80 @@ export function writeSidecar(sidecar) {
111
122
  export function sidecarKey(cwd, serverName) {
112
123
  return `${cwd}::${serverName}`;
113
124
  }
125
+ /** The lowest port a project is given when none was asked for. */
126
+ export const FIRST_PROJECT_PORT = 53411;
127
+ /** This project's record, if an install has decided anything for it. */
128
+ export function projectRecord(sidecar, cwd) {
129
+ return sidecar.projects?.[normalizePath(cwd)];
130
+ }
131
+ /**
132
+ * The port this project's hooks and control server should agree on.
133
+ *
134
+ * A port already chosen for this directory is kept — moving it would orphan
135
+ * the URLs in `settings.json`. Otherwise the lowest port from
136
+ * {@link FIRST_PROJECT_PORT} upward that no *other* project holds, so the first
137
+ * project on a machine keeps the documented default and the second one does not
138
+ * collide with it. Deterministic, so re-running install changes nothing.
139
+ */
140
+ export function allocateProjectPort(sidecar, cwd) {
141
+ const mine = projectRecord(sidecar, cwd);
142
+ if (mine)
143
+ return mine.port;
144
+ const key = normalizePath(cwd);
145
+ const taken = new Set(Object.entries(sidecar.projects ?? {})
146
+ .filter(([k]) => k !== key)
147
+ .map(([, r]) => r.port));
148
+ let port = FIRST_PROJECT_PORT;
149
+ while (taken.has(port))
150
+ port += 1;
151
+ return port;
152
+ }
153
+ /** True when nothing on this machine is bound to `port` on the loopback address. */
154
+ export function probeFreePort(port) {
155
+ return new Promise((resolve) => {
156
+ const server = createServer();
157
+ server.once("error", () => resolve(false));
158
+ server.listen(port, "127.0.0.1", () => {
159
+ server.close(() => resolve(true));
160
+ });
161
+ });
162
+ }
163
+ /**
164
+ * {@link allocateProjectPort}, but a port nobody else already holds.
165
+ *
166
+ * The sidecar only knows about our own projects. 53411 sits in the dynamic
167
+ * range on every OS and inside the blocks Hyper-V and WSL reserve on Windows,
168
+ * so "unused by another project" is not "free": a recorder told to use a port
169
+ * the system has excluded exits, and every session records Tier 2 until
170
+ * somebody reads the log. A port that has been recorded for this project is
171
+ * kept as it is — the hooks name it — and the recorder says so if it is busy.
172
+ */
173
+ export async function allocateFreeProjectPort(sidecar, cwd) {
174
+ const mine = projectRecord(sidecar, cwd);
175
+ if (mine)
176
+ return mine.port;
177
+ const key = normalizePath(cwd);
178
+ const taken = new Set(Object.entries(sidecar.projects ?? {})
179
+ .filter(([k]) => k !== key)
180
+ .map(([, r]) => r.port));
181
+ for (let port = FIRST_PROJECT_PORT; port < FIRST_PROJECT_PORT + 200; port += 1) {
182
+ if (taken.has(port))
183
+ continue;
184
+ if (await probeFreePort(port))
185
+ return port;
186
+ }
187
+ return allocateProjectPort(sidecar, cwd);
188
+ }
189
+ /** Merge `patch` into the record for `cwd`, creating it when absent. */
190
+ export function setProjectRecord(sidecar, cwd, patch) {
191
+ sidecar.projects ??= {};
192
+ const key = normalizePath(cwd);
193
+ const next = { ...(sidecar.projects[key] ?? {}), ...patch };
194
+ sidecar.projects[key] = next;
195
+ return next;
196
+ }
197
+ /** The bearer token this project's hooks and control server share. */
198
+ export function projectToken(sidecar, cwd) {
199
+ return projectRecord(sidecar, cwd)?.token ?? sidecar.token;
200
+ }
114
201
  //# sourceMappingURL=generate.js.map
@@ -50,6 +50,11 @@ export declare class ControlClient {
50
50
  /** Report one completed MCP call. Resolves false when the send was dropped. */
51
51
  report(step: ProxyStepReport): Promise<boolean>;
52
52
  health(): Promise<Record<string, unknown> | undefined>;
53
+ /**
54
+ * Ask the control server to shut down. Resolves true when it agreed; the
55
+ * exit itself follows once it has finished the run it was writing.
56
+ */
57
+ stop(): Promise<boolean>;
53
58
  /**
54
59
  * Park until the control server has a tool call for this proxy, or the hold
55
60
  * elapses (docs/calculatedReplay.md §16.2).
@@ -29,6 +29,14 @@ export class ControlClient {
29
29
  async health() {
30
30
  return (await this.request("GET", "/health"));
31
31
  }
32
+ /**
33
+ * Ask the control server to shut down. Resolves true when it agreed; the
34
+ * exit itself follows once it has finished the run it was writing.
35
+ */
36
+ async stop() {
37
+ const body = (await this.post("/control/stop", {}));
38
+ return body?.ok === true;
39
+ }
32
40
  /**
33
41
  * Park until the control server has a tool call for this proxy, or the hold
34
42
  * elapses (docs/calculatedReplay.md §16.2).
@@ -0,0 +1,116 @@
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 { type DiscoveryInfo } from "./discovery.js";
40
+ /** A log file larger than this is rolled over to `.1` when a daemon starts. */
41
+ export declare const LOG_ROTATE_BYTES: number;
42
+ /** Marks a `bir-hooks` as started by this module; it says so in its discovery file. */
43
+ export declare const DAEMON_ENV = "BIR_DAEMON";
44
+ /** Where that process writes its audit lines. It opens the file itself. */
45
+ export declare const DAEMON_LOG_ENV = "BIR_DAEMON_LOG";
46
+ /**
47
+ * The setup token's environment variable (`bir setup` reads it when `--token`
48
+ * is absent). Never handed to the recorder: it is a one-use secret for signing
49
+ * in, and a process that lives for days has no business holding it.
50
+ */
51
+ export declare const SETUP_TOKEN_ENV = "BIR_SETUP_TOKEN";
52
+ export interface DaemonStatus {
53
+ info: DiscoveryInfo;
54
+ health: Record<string, unknown>;
55
+ }
56
+ /** Absolute path of `dist/bin/bir-hooks.js` in the package this module runs from. */
57
+ export declare function hooksScriptPath(): string;
58
+ /**
59
+ * The live control server for `cwd`, or undefined.
60
+ *
61
+ * "Live" means the discovery file is fresh, its pid is alive, **and** it
62
+ * answers `/health` with its token. A file left by a crash fails the second
63
+ * test; a process that hung, or a recycled pid, fails the third.
64
+ */
65
+ export declare function daemonStatus(cwd: string): Promise<DaemonStatus | undefined>;
66
+ export interface EnsureOptions {
67
+ /** `dist/bin/bir-hooks.js` to run. Defaults to this package's. */
68
+ hooksScript?: string;
69
+ /** Environment for the new process. Defaults to this process's. */
70
+ env?: NodeJS.ProcessEnv;
71
+ /** How long to wait for a fresh process to answer `/health`. */
72
+ timeoutMs?: number;
73
+ /**
74
+ * Restart a running recorder whose version is not ours, provided no session
75
+ * is mid-run. Off by default: only `bir setup`/`bir up` and the SessionStart
76
+ * hook — the moments a restart costs nothing — ask for it.
77
+ */
78
+ restartOnSkew?: boolean;
79
+ }
80
+ export interface EnsureResult {
81
+ status: DaemonStatus;
82
+ /** True when this call started the process. */
83
+ started: boolean;
84
+ /** True when this call replaced a running process of another version. */
85
+ restarted: boolean;
86
+ }
87
+ /** How many of the recorder's sessions are mid-run right now. */
88
+ export declare function activeSessions(health: Record<string, unknown>): number;
89
+ /**
90
+ * The control server for `cwd`, started in the background if none is live.
91
+ *
92
+ * Idempotent: a live one is returned as-is. Throws when a fresh process did
93
+ * not come up in time, with the tail of its log, because "it did not start" is
94
+ * the one message that must carry its own diagnosis.
95
+ */
96
+ export declare function ensureDaemon(cwd: string, opts?: EnsureOptions): Promise<EnsureResult>;
97
+ /**
98
+ * Stop the control server for `cwd`.
99
+ *
100
+ * Asks first — `POST /control/stop`, which lets it finish the run it is
101
+ * writing and drain its queue — and only then, if it is still there, kills it.
102
+ * A `SIGTERM` on Windows is a hard kill with no handler, which is exactly why
103
+ * the polite route exists. Only a process that answers `/health` as the
104
+ * recorder is ever signalled: a discovery file survives a crash, and its pid
105
+ * is recycled — on Windows quickly — so "the pid in the file is alive" is not
106
+ * evidence that it is ours.
107
+ */
108
+ export declare function stopDaemon(cwd: string, opts?: {
109
+ timeoutMs?: number;
110
+ }): Promise<{
111
+ stopped: boolean;
112
+ pid?: number;
113
+ stale?: boolean;
114
+ forced?: boolean;
115
+ }>;
116
+ //# sourceMappingURL=daemon.d.ts.map