@basein/runner 0.2.12 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/README.md +540 -369
  2. package/dist/auth/client.d.ts +65 -3
  3. package/dist/auth/client.js +305 -53
  4. package/dist/bin/bir-hooks.d.ts +72 -31
  5. package/dist/bin/bir-hooks.js +176 -284
  6. package/dist/bin/bir-proxy.d.ts +17 -2
  7. package/dist/bin/bir-proxy.js +222 -28
  8. package/dist/bin/bir-scenario.d.ts +61 -21
  9. package/dist/bin/bir-scenario.js +593 -122
  10. package/dist/bin/bir.d.ts +12 -6
  11. package/dist/bin/bir.js +2935 -489
  12. package/dist/bin/investigate.js +40 -15
  13. package/dist/bin/scenario-edit.d.ts +7 -1
  14. package/dist/bin/scenario-edit.js +43 -21
  15. package/dist/bin/scenario-liveness.d.ts +194 -0
  16. package/dist/bin/scenario-liveness.js +459 -0
  17. package/dist/bin/scenario-stderr.d.ts +43 -0
  18. package/dist/bin/scenario-stderr.js +76 -0
  19. package/dist/bin/service-read.d.ts +88 -0
  20. package/dist/bin/service-read.js +128 -0
  21. package/dist/bin/setup.d.ts +19 -0
  22. package/dist/bin/setup.js +146 -20
  23. package/dist/config/adapters/claude-code.d.ts +92 -14
  24. package/dist/config/adapters/claude-code.js +291 -101
  25. package/dist/config/adapters/generic.d.ts +4 -1
  26. package/dist/config/adapters/generic.js +13 -14
  27. package/dist/config/generate.d.ts +392 -14
  28. package/dist/config/generate.js +666 -19
  29. package/dist/config/mcp-approval.d.ts +76 -0
  30. package/dist/config/mcp-approval.js +210 -0
  31. package/dist/config/node-path.d.ts +42 -0
  32. package/dist/config/node-path.js +102 -0
  33. package/dist/config/overrides.d.ts +62 -0
  34. package/dist/config/overrides.js +136 -0
  35. package/dist/config/resolve.d.ts +70 -8
  36. package/dist/config/resolve.js +186 -15
  37. package/dist/control/client.d.ts +22 -0
  38. package/dist/control/client.js +26 -7
  39. package/dist/control/correlation.d.ts +23 -5
  40. package/dist/control/correlation.js +0 -0
  41. package/dist/control/daemon.d.ts +400 -18
  42. package/dist/control/daemon.js +1105 -102
  43. package/dist/control/discovery.d.ts +77 -5
  44. package/dist/control/discovery.js +186 -11
  45. package/dist/control/ensure-hook.d.ts +61 -0
  46. package/dist/control/ensure-hook.js +160 -8
  47. package/dist/control/paths.d.ts +26 -0
  48. package/dist/control/paths.js +34 -0
  49. package/dist/control/pin-guard.d.ts +83 -0
  50. package/dist/control/pin-guard.js +534 -0
  51. package/dist/control/recorder-keeper.d.ts +229 -0
  52. package/dist/control/recorder-keeper.js +721 -0
  53. package/dist/control/recorder-main.d.ts +73 -0
  54. package/dist/control/recorder-main.js +778 -0
  55. package/dist/control/server.d.ts +707 -31
  56. package/dist/control/server.js +2787 -302
  57. package/dist/control/transcript.d.ts +21 -1
  58. package/dist/control/transcript.js +378 -112
  59. package/dist/index.d.ts +5 -1
  60. package/dist/index.js +5 -1
  61. package/dist/jsonrpc/framing.d.ts +76 -12
  62. package/dist/jsonrpc/framing.js +216 -59
  63. package/dist/proxy/intercept.d.ts +49 -5
  64. package/dist/proxy/intercept.js +188 -59
  65. package/dist/proxy/relay.d.ts +16 -0
  66. package/dist/proxy/relay.js +77 -5
  67. package/dist/proxy/revive.d.ts +122 -0
  68. package/dist/proxy/revive.js +251 -0
  69. package/dist/proxy/session.d.ts +222 -14
  70. package/dist/proxy/session.js +708 -99
  71. package/dist/record/recorder.d.ts +51 -3
  72. package/dist/record/recorder.js +3 -0
  73. package/dist/record/redact.d.ts +103 -9
  74. package/dist/record/redact.js +659 -30
  75. package/dist/record/remote-recorder.d.ts +327 -32
  76. package/dist/record/remote-recorder.js +1517 -187
  77. package/dist/record/switchable-recorder.d.ts +126 -0
  78. package/dist/record/switchable-recorder.js +351 -0
  79. package/dist/record/truncate.d.ts +40 -1
  80. package/dist/record/truncate.js +111 -4
  81. package/dist/replay/bash-channel.d.ts +35 -0
  82. package/dist/replay/bash-channel.js +133 -0
  83. package/dist/replay/bundle.d.ts +10 -0
  84. package/dist/replay/bundle.js +10 -0
  85. package/dist/replay/controller.d.ts +369 -20
  86. package/dist/replay/controller.js +1176 -107
  87. package/dist/replay/derive.d.ts +1 -0
  88. package/dist/replay/derive.js +81 -24
  89. package/dist/replay/executor.d.ts +51 -16
  90. package/dist/replay/executor.js +114 -41
  91. package/dist/replay/handover.d.ts +54 -1
  92. package/dist/replay/handover.js +55 -5
  93. package/dist/replay/logic.d.ts +62 -10
  94. package/dist/replay/logic.js +140 -22
  95. package/dist/replay/plan.d.ts +201 -27
  96. package/dist/replay/plan.js +581 -141
  97. package/dist/replay/safety.d.ts +72 -0
  98. package/dist/replay/safety.js +303 -0
  99. package/dist/replay/source-run.d.ts +10 -0
  100. package/dist/replay/source-run.js +80 -35
  101. package/dist/replay/targets.d.ts +15 -1
  102. package/dist/replay/targets.js +62 -4
  103. package/dist/replay/tool-error.d.ts +15 -1
  104. package/dist/replay/tool-error.js +33 -1
  105. package/dist/replay/types.d.ts +7 -4
  106. package/dist/upstream/client.d.ts +56 -3
  107. package/dist/upstream/client.js +61 -4
  108. package/dist/upstream/headers.d.ts +43 -0
  109. package/dist/upstream/headers.js +90 -0
  110. package/dist/upstream/http-client.d.ts +213 -22
  111. package/dist/upstream/http-client.js +1034 -168
  112. package/dist/upstream/http-transport.d.ts +143 -0
  113. package/dist/upstream/http-transport.js +391 -0
  114. package/dist/upstream/lazy-client.d.ts +8 -0
  115. package/dist/upstream/lazy-client.js +29 -1
  116. package/dist/upstream/sse.d.ts +78 -0
  117. package/dist/upstream/sse.js +189 -0
  118. package/dist/upstream/stdio-client.d.ts +61 -1
  119. package/dist/upstream/stdio-client.js +356 -33
  120. package/dist/util/atomic-write.d.ts +29 -0
  121. package/dist/util/atomic-write.js +96 -0
  122. package/dist/util/journal.d.ts +15 -0
  123. package/dist/util/journal.js +48 -3
  124. package/dist/util/log.d.ts +10 -0
  125. package/dist/util/log.js +28 -1
  126. package/dist/util/off-switch.d.ts +46 -0
  127. package/dist/util/off-switch.js +91 -0
  128. package/dist/util/service-health.d.ts +133 -0
  129. package/dist/util/service-health.js +217 -0
  130. package/docs/BaseInstRunner.md +694 -621
  131. package/docs/calculatedReplay.md +1625 -1304
  132. package/docs/calculatedReplayGuide.md +1212 -1087
  133. package/docs/installRun.md +1013 -772
  134. package/docs/loginWeb.md +627 -607
  135. package/docs/mcpmark.md +49 -16
  136. package/docs/my-first-sample.md +569 -545
  137. package/docs/quickstart.md +384 -311
  138. package/docs/t-bench.md +406 -394
  139. package/package.json +58 -57
  140. package/production.md +1371 -0
  141. package/scripts/install.ps1 +97 -6
  142. package/scripts/install.sh +104 -7
@@ -19,7 +19,12 @@
19
19
  * STALENESS. A discovery file outlives its process on a crash. Every read checks
20
20
  * that the recorded pid is still alive (`kill(pid, 0)`) and that the file is not
21
21
  * older than {@link MAX_DISCOVERY_AGE_MS}; a stale file is treated as absent, so
22
- * a dead session can never capture a live proxy's steps.
22
+ * a dead session can never capture a live proxy's steps. The age rule is for
23
+ * proxies, which cannot afford a network probe per start; the ensure hook and
24
+ * `bir down` ask the recorder itself (`readDiscovery(cwd, { ignoreAge: true })`
25
+ * plus `/health`), because a laptop that slept over a weekend wakes with a live
26
+ * recorder whose file reads as days old — and treating that as dead started a
27
+ * duplicate that could not bind, and hid the live one from every proxy.
23
28
  *
24
29
  * SECURITY (§9). Files are mode 0600 and carry the loopback bearer token. A local
25
30
  * port that accepts unauthenticated step reports is a local exfiltration channel.
@@ -29,11 +34,17 @@ export declare const MAX_DISCOVERY_AGE_MS: number;
29
34
  /**
30
35
  * How often a running control server rewrites its discovery file.
31
36
  *
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.
37
+ * Well inside {@link MAX_DISCOVERY_AGE_MS}, so a dead recorder is still
38
+ * forgotten within a day. It is measured on the wall clock, not by a timer
39
+ * alone: on macOS and Linux a timer's clock stops while the machine sleeps, so
40
+ * a recorder woken after a weekend would wait up to this long for its next beat
41
+ * while its file read as stale. `bir-hooks` checks every
42
+ * {@link DISCOVERY_CHECK_MS} and rewrites when this much wall time has passed
43
+ * since the last write, or when the file has gone missing.
35
44
  */
36
45
  export declare const HEARTBEAT_MS: number;
46
+ /** How often a running control server checks its discovery file (see {@link HEARTBEAT_MS}). */
47
+ export declare const DISCOVERY_CHECK_MS: number;
37
48
  export interface DiscoveryInfo {
38
49
  url: string;
39
50
  /** Random 32 bytes hex; required on every control-server route. */
@@ -61,13 +72,74 @@ export interface DiscoveryInfo {
61
72
  }
62
73
  export declare function writeDiscovery(info: DiscoveryInfo): string;
63
74
  export declare function removeDiscovery(cwd: string): void;
75
+ /**
76
+ * Remove the discovery file for `cwd` only if it still names `pid`.
77
+ *
78
+ * A recorder that is shutting down must not delete the file of the one that
79
+ * replaced it, and a process that found this project's port already served by
80
+ * this project's recorder must leave that recorder's file alone.
81
+ */
82
+ export declare function removeDiscoveryIfOwned(cwd: string, pid: number): void;
64
83
  /** True when a pid is still running (or when we cannot tell, which we treat as alive). */
65
84
  export declare function pidAlive(pid: number): boolean;
85
+ export interface ReadDiscoveryOptions {
86
+ /**
87
+ * Skip the {@link MAX_DISCOVERY_AGE_MS} rule and let the caller decide by
88
+ * asking the process (`/health`). The pid must still be alive.
89
+ */
90
+ ignoreAge?: boolean;
91
+ }
66
92
  /**
67
93
  * Read the discovery file for `cwd`, or undefined when there is none, it is
68
94
  * malformed, or it is stale.
69
95
  */
70
- export declare function readDiscovery(cwd: string): DiscoveryInfo | undefined;
96
+ export declare function readDiscovery(cwd: string, opts?: ReadDiscoveryOptions): DiscoveryInfo | undefined;
97
+ /** Newest of a record's two stamps, epoch ms (0 when it has neither). */
98
+ export declare function discoveryStamp(info: Pick<DiscoveryInfo, "startedAt" | "heartbeatAt">): number;
99
+ /**
100
+ * Delete the control directory's records of recorders that are gone.
101
+ *
102
+ * Each discovery file carries its project's loopback token, and nothing ever
103
+ * removed the file of a recorder that crashed, or of a project that was
104
+ * deleted: one machine had seven of them, months old. Removed: records whose
105
+ * pid is dead, unreadable records older than a day, and temp files a writer
106
+ * left behind. A record whose pid is alive is kept whatever its age — that
107
+ * process is asked, not guessed about (see {@link readDiscovery}).
108
+ *
109
+ * Never throws. Returns how many files it removed.
110
+ */
111
+ export declare function sweepStaleDiscovery(now?: number): number;
112
+ /**
113
+ * A recorder that no longer serves the port its project's hooks post to — a
114
+ * reinstall (`bir install --port N`, `bir uninstall` + `bir setup`) moved
115
+ * them while a Claude Code window was open — and is kept for the windows
116
+ * opened before, whose hooks still name its port. The project's discovery
117
+ * file names the new recorder only, so without this record the old one could
118
+ * not be listed or stopped. Mode 0600: it carries the old token.
119
+ */
120
+ export interface SupersededRecorder {
121
+ cwd: string;
122
+ pid: number;
123
+ port: number;
124
+ url: string;
125
+ /** Its loopback token, so it can be asked to stop rather than killed. */
126
+ token: string;
127
+ /** When it was first seen superseded, epoch ms. */
128
+ since: number;
129
+ version?: string;
130
+ logFile?: string;
131
+ }
132
+ /** Write (or rewrite) the record of a superseded recorder. Never throws; the path, or undefined. */
133
+ export declare function writeSupersededRecord(record: SupersededRecorder): string | undefined;
134
+ /** Remove the record of superseded recorder `pid` of `cwd`. Never throws. */
135
+ export declare function removeSupersededRecord(cwd: string, pid: number): void;
136
+ /**
137
+ * The superseded recorders of `cwd` still running — or of every project when
138
+ * `cwd` is undefined — oldest first. A record whose process is gone is removed
139
+ * on the way. Nothing here asks the process who it is: stopping one goes
140
+ * through `/health` first (daemon.ts, stopSupersededRecorder).
141
+ */
142
+ export declare function listSupersededRecorders(cwd?: string): SupersededRecorder[];
71
143
  /**
72
144
  * Resolve the control server for `cwd`, retrying until `timeoutMs` elapses.
73
145
  * `BIR_CONTROL_URL` short-circuits the search (with `BIR_CONTROL_TOKEN` for auth).
@@ -19,24 +19,37 @@
19
19
  * STALENESS. A discovery file outlives its process on a crash. Every read checks
20
20
  * that the recorded pid is still alive (`kill(pid, 0)`) and that the file is not
21
21
  * older than {@link MAX_DISCOVERY_AGE_MS}; a stale file is treated as absent, so
22
- * a dead session can never capture a live proxy's steps.
22
+ * a dead session can never capture a live proxy's steps. The age rule is for
23
+ * proxies, which cannot afford a network probe per start; the ensure hook and
24
+ * `bir down` ask the recorder itself (`readDiscovery(cwd, { ignoreAge: true })`
25
+ * plus `/health`), because a laptop that slept over a weekend wakes with a live
26
+ * recorder whose file reads as days old — and treating that as dead started a
27
+ * duplicate that could not bind, and hid the live one from every proxy.
23
28
  *
24
29
  * SECURITY (§9). Files are mode 0600 and carry the loopback bearer token. A local
25
30
  * port that accepts unauthenticated step reports is a local exfiltration channel.
26
31
  */
27
- import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
28
- import { controlDir, discoveryPath, ensureDir } from "./paths.js";
32
+ import { existsSync, readFileSync, readdirSync, statSync, unlinkSync } from "node:fs";
33
+ import { join } from "node:path";
34
+ import { controlDir, controlKey, discoveryPath, ensureDir, normalizePath, supersededPath } from "./paths.js";
29
35
  import { logDetail, errText } from "../util/log.js";
36
+ import { writeFileAtomic } from "../util/atomic-write.js";
30
37
  /** A discovery file older than this is ignored even if its pid is somehow alive. */
31
38
  export const MAX_DISCOVERY_AGE_MS = 24 * 60 * 60 * 1000;
32
39
  /**
33
40
  * How often a running control server rewrites its discovery file.
34
41
  *
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.
42
+ * Well inside {@link MAX_DISCOVERY_AGE_MS}, so a dead recorder is still
43
+ * forgotten within a day. It is measured on the wall clock, not by a timer
44
+ * alone: on macOS and Linux a timer's clock stops while the machine sleeps, so
45
+ * a recorder woken after a weekend would wait up to this long for its next beat
46
+ * while its file read as stale. `bir-hooks` checks every
47
+ * {@link DISCOVERY_CHECK_MS} and rewrites when this much wall time has passed
48
+ * since the last write, or when the file has gone missing.
38
49
  */
39
50
  export const HEARTBEAT_MS = 30 * 60 * 1000;
51
+ /** How often a running control server checks its discovery file (see {@link HEARTBEAT_MS}). */
52
+ export const DISCOVERY_CHECK_MS = 60 * 1000;
40
53
  export function writeDiscovery(info) {
41
54
  ensureDir(controlDir());
42
55
  const path = discoveryPath(info.cwd);
@@ -44,9 +57,9 @@ export function writeDiscovery(info) {
44
57
  // The heartbeat rewrites this every half hour, and a proxy or the ensure
45
58
  // hook reading an empty file in that instant would conclude "no recorder"
46
59
  // 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);
60
+ // writeFileAtomic also retries the rename that a reader's open handle can
61
+ // refuse for a moment on Windows.
62
+ writeFileAtomic(path, JSON.stringify(info, null, 2), { mode: 0o600 });
50
63
  return path;
51
64
  }
52
65
  export function removeDiscovery(cwd) {
@@ -59,6 +72,26 @@ export function removeDiscovery(cwd) {
59
72
  /* best effort */
60
73
  }
61
74
  }
75
+ /**
76
+ * Remove the discovery file for `cwd` only if it still names `pid`.
77
+ *
78
+ * A recorder that is shutting down must not delete the file of the one that
79
+ * replaced it, and a process that found this project's port already served by
80
+ * this project's recorder must leave that recorder's file alone.
81
+ */
82
+ export function removeDiscoveryIfOwned(cwd, pid) {
83
+ try {
84
+ const path = discoveryPath(cwd);
85
+ if (!existsSync(path))
86
+ return;
87
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
88
+ if (parsed.pid === pid)
89
+ unlinkSync(path);
90
+ }
91
+ catch {
92
+ /* best effort */
93
+ }
94
+ }
62
95
  /** True when a pid is still running (or when we cannot tell, which we treat as alive). */
63
96
  export function pidAlive(pid) {
64
97
  if (!Number.isInteger(pid) || pid <= 0)
@@ -76,7 +109,7 @@ export function pidAlive(pid) {
76
109
  * Read the discovery file for `cwd`, or undefined when there is none, it is
77
110
  * malformed, or it is stale.
78
111
  */
79
- export function readDiscovery(cwd) {
112
+ export function readDiscovery(cwd, opts = {}) {
80
113
  const path = discoveryPath(cwd);
81
114
  try {
82
115
  if (!existsSync(path))
@@ -92,7 +125,7 @@ export function readDiscovery(cwd) {
92
125
  // every `HEARTBEAT_MS` precisely so this check keeps saying so. A file
93
126
  // whose process died keeps its last stamp and ages out as before.
94
127
  const freshest = Math.max(parsed.startedAt ?? 0, parsed.heartbeatAt ?? 0);
95
- if (Date.now() - freshest > MAX_DISCOVERY_AGE_MS) {
128
+ if (!opts.ignoreAge && Date.now() - freshest > MAX_DISCOVERY_AGE_MS) {
96
129
  logDetail("discovery.stale", { path, reason: "too old" });
97
130
  return undefined;
98
131
  }
@@ -107,6 +140,148 @@ export function readDiscovery(cwd) {
107
140
  return undefined;
108
141
  }
109
142
  }
143
+ /** Newest of a record's two stamps, epoch ms (0 when it has neither). */
144
+ export function discoveryStamp(info) {
145
+ return Math.max(info.startedAt ?? 0, info.heartbeatAt ?? 0);
146
+ }
147
+ /** A temp file older than this was left by a writer that died mid-write. */
148
+ const TMP_STALE_MS = 60 * 60 * 1000;
149
+ /**
150
+ * Delete the control directory's records of recorders that are gone.
151
+ *
152
+ * Each discovery file carries its project's loopback token, and nothing ever
153
+ * removed the file of a recorder that crashed, or of a project that was
154
+ * deleted: one machine had seven of them, months old. Removed: records whose
155
+ * pid is dead, unreadable records older than a day, and temp files a writer
156
+ * left behind. A record whose pid is alive is kept whatever its age — that
157
+ * process is asked, not guessed about (see {@link readDiscovery}).
158
+ *
159
+ * Never throws. Returns how many files it removed.
160
+ */
161
+ export function sweepStaleDiscovery(now = Date.now()) {
162
+ let removed = 0;
163
+ let names;
164
+ try {
165
+ names = readdirSync(controlDir());
166
+ }
167
+ catch {
168
+ return 0;
169
+ }
170
+ for (const name of names) {
171
+ const path = join(controlDir(), name);
172
+ try {
173
+ if (name.endsWith(".tmp")) {
174
+ if (now - statSync(path).mtimeMs > TMP_STALE_MS) {
175
+ unlinkSync(path);
176
+ removed += 1;
177
+ }
178
+ continue;
179
+ }
180
+ // Discovery records and superseded recorders' records only: `<key>.json`
181
+ // and `<key>.superseded.<pid>.json` — not `<key>.port.json`, a lock, a
182
+ // down marker, or the journal directory.
183
+ if (!/^[0-9a-f]{16}(?:\.superseded\.\d+)?\.json$/.test(name))
184
+ continue;
185
+ let pid;
186
+ try {
187
+ pid = JSON.parse(readFileSync(path, "utf8")).pid;
188
+ }
189
+ catch {
190
+ if (now - statSync(path).mtimeMs > MAX_DISCOVERY_AGE_MS) {
191
+ unlinkSync(path);
192
+ removed += 1;
193
+ }
194
+ continue;
195
+ }
196
+ if (typeof pid !== "number" || !pidAlive(pid)) {
197
+ unlinkSync(path);
198
+ removed += 1;
199
+ }
200
+ }
201
+ catch {
202
+ /* best effort: somebody else removed it, or is writing it */
203
+ }
204
+ }
205
+ if (removed > 0)
206
+ logDetail("discovery.swept", { removed });
207
+ return removed;
208
+ }
209
+ /** Write (or rewrite) the record of a superseded recorder. Never throws; the path, or undefined. */
210
+ export function writeSupersededRecord(record) {
211
+ try {
212
+ ensureDir(controlDir());
213
+ const path = supersededPath(record.cwd, record.pid);
214
+ writeFileAtomic(path, JSON.stringify(record, null, 2), { mode: 0o600 });
215
+ return path;
216
+ }
217
+ catch (err) {
218
+ logDetail("discovery.superseded_write_failed", { error: errText(err) });
219
+ return undefined;
220
+ }
221
+ }
222
+ /** Remove the record of superseded recorder `pid` of `cwd`. Never throws. */
223
+ export function removeSupersededRecord(cwd, pid) {
224
+ try {
225
+ const path = supersededPath(cwd, pid);
226
+ if (existsSync(path))
227
+ unlinkSync(path);
228
+ }
229
+ catch {
230
+ /* best effort; the sweep removes it once the pid is gone */
231
+ }
232
+ }
233
+ /**
234
+ * The superseded recorders of `cwd` still running — or of every project when
235
+ * `cwd` is undefined — oldest first. A record whose process is gone is removed
236
+ * on the way. Nothing here asks the process who it is: stopping one goes
237
+ * through `/health` first (daemon.ts, stopSupersededRecorder).
238
+ */
239
+ export function listSupersededRecorders(cwd) {
240
+ let names;
241
+ try {
242
+ names = readdirSync(controlDir());
243
+ }
244
+ catch {
245
+ return [];
246
+ }
247
+ const prefix = cwd === undefined ? undefined : `${controlKey(cwd)}.superseded.`;
248
+ const found = [];
249
+ for (const name of names) {
250
+ if (!/^[0-9a-f]{16}\.superseded\.\d+\.json$/.test(name))
251
+ continue;
252
+ if (prefix && !name.startsWith(prefix))
253
+ continue;
254
+ const path = join(controlDir(), name);
255
+ try {
256
+ const r = JSON.parse(readFileSync(path, "utf8"));
257
+ if (typeof r.pid !== "number" ||
258
+ typeof r.url !== "string" ||
259
+ typeof r.token !== "string" ||
260
+ typeof r.cwd !== "string" ||
261
+ (cwd !== undefined && normalizePath(r.cwd) !== normalizePath(cwd))) {
262
+ continue;
263
+ }
264
+ if (!pidAlive(r.pid)) {
265
+ unlinkSync(path);
266
+ continue;
267
+ }
268
+ found.push({
269
+ cwd: r.cwd,
270
+ pid: r.pid,
271
+ port: typeof r.port === "number" ? r.port : Number(new URL(r.url).port) || 0,
272
+ url: r.url,
273
+ token: r.token,
274
+ since: typeof r.since === "number" ? r.since : 0,
275
+ version: typeof r.version === "string" ? r.version : undefined,
276
+ logFile: typeof r.logFile === "string" ? r.logFile : undefined,
277
+ });
278
+ }
279
+ catch {
280
+ /* unreadable, or removed meanwhile */
281
+ }
282
+ }
283
+ return found.sort((a, b) => a.since - b.since);
284
+ }
110
285
  /**
111
286
  * Resolve the control server for `cwd`, retrying until `timeoutMs` elapses.
112
287
  * `BIR_CONTROL_URL` short-circuits the search (with `BIR_CONTROL_TOKEN` for auth).
@@ -17,12 +17,65 @@
17
17
  * stderr and a session that records Tier 2, exactly what happened before this
18
18
  * hook existed.
19
19
  *
20
+ * THE BUDGET. Claude Code waits for this hook — the session does not start
21
+ * until it answers — up to the timeout `bir install` wrote (15 s). The whole
22
+ * hook stays inside {@link ENSURE_HOOK_BUDGET_MS}: reading the payload, finding
23
+ * or starting the recorder, the relay, and the override check (which is
24
+ * skipped when little is left). A warm start (the recorder is
25
+ * running) is one loopback round trip; a cold one is the recorder's start-up,
26
+ * about a second, because the recorder listens before it touches the network.
27
+ * Nothing here waits on the BaseIn service. When the budget runs out the hook
28
+ * exits 0 and the recorder finishes coming up in the background; the next
29
+ * hook finds it.
30
+ *
31
+ * THE OFF SWITCH (`bir off`) does not skip this hook. The recorder is still
32
+ * started, because it is what answers every other hook at once with `{}` while
33
+ * off — without it Claude Code would show each of them a refused connection.
34
+ * Only the override check below is skipped while off. `BIR_OFF=1` in the
35
+ * environment Claude Code was started with is this window's switch, not the
36
+ * recorder's: the recorder serves every window of the project and never
37
+ * inherits it (daemon.ts, daemonEnv), so the relay tells it that this
38
+ * session is off (`bir_off: true`) and it answers this session's hooks `{}`.
39
+ *
40
+ * STALE OVERRIDES. Once the recorder is started or kept, the hook looks at the
41
+ * local-scope copies `bir install` made of this project's `.mcp.json` and
42
+ * user-scope servers (config/overrides.ts, checkOverrides): a copy whose
43
+ * source changed, or whose source is gone or no longer approved, is named in
44
+ * one line of the project's recorder log, with the fix (`bir setup`). It is
45
+ * read-only. It used to repair them here, and that meant rewriting
46
+ * `~/.claude.json` at the very moment Claude Code, starting, writes its own
47
+ * state there — without Claude Code's lock, so either write could be lost.
48
+ * `bir setup` and `bir doctor` repair; local files only, never throws, and
49
+ * only with enough of the budget left.
50
+ *
20
51
  * STDOUT IS CONTEXT. For SessionStart, Claude Code adds whatever a command hook
21
52
  * prints on stdout to the model's context. So nothing is printed unless the
22
53
  * control server answered with something (today it answers `{}`), and then it
23
54
  * is the answer, verbatim, as JSON.
24
55
  */
25
56
  import { type EnsureOptions } from "./daemon.js";
57
+ /**
58
+ * The whole hook's budget. Inside the 15 s SessionStart timeout `bir install`
59
+ * writes (C9: the hook's own work stays at or under 12 s), with room for node
60
+ * to start and load this module.
61
+ */
62
+ export declare const ENSURE_HOOK_BUDGET_MS = 11000;
63
+ /**
64
+ * The least of the budget the override check needs to be started. It is
65
+ * synchronous and local — a few JSON files, plus a `git ls-files` capped at
66
+ * 2 s in the rare case an approval depends on it — so with less than this
67
+ * left it waits for the next session start instead of running past the hook's
68
+ * budget.
69
+ */
70
+ export declare const OVERRIDE_CHECK_RESERVE_MS = 3000;
71
+ /** The earlier name of {@link OVERRIDE_CHECK_RESERVE_MS}. */
72
+ export declare const RESYNC_RESERVE_MS = 3000;
73
+ /** What the override check reports: one entry per local copy that no longer copies its source. */
74
+ export type StaleOverrides = Array<{
75
+ name: string;
76
+ state: string;
77
+ why?: string;
78
+ }>;
26
79
  export interface EnsureHookOptions extends EnsureOptions {
27
80
  stdin?: NodeJS.ReadableStream & {
28
81
  isTTY?: boolean;
@@ -31,6 +84,14 @@ export interface EnsureHookOptions extends EnsureOptions {
31
84
  stderr?: NodeJS.WritableStream;
32
85
  /** How long to wait for the payload on stdin before going on without it. */
33
86
  stdinTimeoutMs?: number;
87
+ /** The whole hook's budget. Defaults to {@link ENSURE_HOOK_BUDGET_MS}. */
88
+ budgetMs?: number;
89
+ /**
90
+ * The read-only stale-override check run once the recorder is started or
91
+ * kept: the stale entries of `checkOverrides` by default, `false` to skip
92
+ * it. Injected by tests. It must not write anything.
93
+ */
94
+ overrideCheck?: ((projectCwd: string) => StaleOverrides) | false;
34
95
  }
35
96
  /**
36
97
  * Run the hook for `cwd`. Resolves the exit code, which is always 0.
@@ -17,13 +17,146 @@
17
17
  * stderr and a session that records Tier 2, exactly what happened before this
18
18
  * hook existed.
19
19
  *
20
+ * THE BUDGET. Claude Code waits for this hook — the session does not start
21
+ * until it answers — up to the timeout `bir install` wrote (15 s). The whole
22
+ * hook stays inside {@link ENSURE_HOOK_BUDGET_MS}: reading the payload, finding
23
+ * or starting the recorder, the relay, and the override check (which is
24
+ * skipped when little is left). A warm start (the recorder is
25
+ * running) is one loopback round trip; a cold one is the recorder's start-up,
26
+ * about a second, because the recorder listens before it touches the network.
27
+ * Nothing here waits on the BaseIn service. When the budget runs out the hook
28
+ * exits 0 and the recorder finishes coming up in the background; the next
29
+ * hook finds it.
30
+ *
31
+ * THE OFF SWITCH (`bir off`) does not skip this hook. The recorder is still
32
+ * started, because it is what answers every other hook at once with `{}` while
33
+ * off — without it Claude Code would show each of them a refused connection.
34
+ * Only the override check below is skipped while off. `BIR_OFF=1` in the
35
+ * environment Claude Code was started with is this window's switch, not the
36
+ * recorder's: the recorder serves every window of the project and never
37
+ * inherits it (daemon.ts, daemonEnv), so the relay tells it that this
38
+ * session is off (`bir_off: true`) and it answers this session's hooks `{}`.
39
+ *
40
+ * STALE OVERRIDES. Once the recorder is started or kept, the hook looks at the
41
+ * local-scope copies `bir install` made of this project's `.mcp.json` and
42
+ * user-scope servers (config/overrides.ts, checkOverrides): a copy whose
43
+ * source changed, or whose source is gone or no longer approved, is named in
44
+ * one line of the project's recorder log, with the fix (`bir setup`). It is
45
+ * read-only. It used to repair them here, and that meant rewriting
46
+ * `~/.claude.json` at the very moment Claude Code, starting, writes its own
47
+ * state there — without Claude Code's lock, so either write could be lost.
48
+ * `bir setup` and `bir doctor` repair; local files only, never throws, and
49
+ * only with enough of the budget left.
50
+ *
20
51
  * STDOUT IS CONTEXT. For SessionStart, Claude Code adds whatever a command hook
21
52
  * prints on stdout to the model's context. So nothing is printed unless the
22
53
  * control server answered with something (today it answers `{}`), and then it
23
54
  * is the answer, verbatim, as JSON.
24
55
  */
25
- import { ensureDaemon } from "./daemon.js";
26
- import { errText } from "../util/log.js";
56
+ import { appendFileSync } from "node:fs";
57
+ import { Console } from "node:console";
58
+ import { dirname } from "node:path";
59
+ import { Writable } from "node:stream";
60
+ import { ensureDaemon, RecorderBusyError } from "./daemon.js";
61
+ import { daemonLogPath, ensureDir } from "./paths.js";
62
+ import { checkOverrides } from "../config/overrides.js";
63
+ import { isRunnerOff } from "../util/off-switch.js";
64
+ import { timeoutSignal } from "../util/service-health.js";
65
+ import { errText, logLine } from "../util/log.js";
66
+ /**
67
+ * The whole hook's budget. Inside the 15 s SessionStart timeout `bir install`
68
+ * writes (C9: the hook's own work stays at or under 12 s), with room for node
69
+ * to start and load this module.
70
+ */
71
+ export const ENSURE_HOOK_BUDGET_MS = 11_000;
72
+ /** Kept back from the recorder start for the relay that follows it. */
73
+ const RELAY_RESERVE_MS = 1_000;
74
+ /** The relay's own ceiling; the control server answers SessionStart in well under this. */
75
+ const RELAY_TIMEOUT_MS = 2_500;
76
+ /** Claude Code writes the payload and closes stdin at once; a person at a terminal writes nothing. */
77
+ const STDIN_TIMEOUT_MS = 2_000;
78
+ /**
79
+ * The least of the budget the override check needs to be started. It is
80
+ * synchronous and local — a few JSON files, plus a `git ls-files` capped at
81
+ * 2 s in the rare case an approval depends on it — so with less than this
82
+ * left it waits for the next session start instead of running past the hook's
83
+ * budget.
84
+ */
85
+ export const OVERRIDE_CHECK_RESERVE_MS = 3_000;
86
+ /** The earlier name of {@link OVERRIDE_CHECK_RESERVE_MS}. */
87
+ export const RESYNC_RESERVE_MS = OVERRIDE_CHECK_RESERVE_MS;
88
+ /** The copies `checkOverrides` finds stale. Reads files only. */
89
+ function staleOverrides(projectCwd) {
90
+ return checkOverrides(projectCwd)
91
+ .filter((c) => c.state !== "current")
92
+ .map((c) => ({ name: c.name, state: c.state, why: c.why }));
93
+ }
94
+ /**
95
+ * Run `fn` with every audit line it logs appended to `file` — the project's
96
+ * recorder log, where `bir doctor` and a person looking for "what changed my
97
+ * MCP config" read — instead of this hook's stderr, which nobody sees.
98
+ * Synchronous and best-effort: a line that cannot be written is dropped, and
99
+ * never costs the hook anything.
100
+ */
101
+ function logInto(file, fn) {
102
+ ensureDir(dirname(file));
103
+ const sink = new Writable({
104
+ write(chunk, _encoding, callback) {
105
+ try {
106
+ appendFileSync(file, chunk);
107
+ }
108
+ catch {
109
+ /* dropped: the log is not worth a failed session start */
110
+ }
111
+ callback();
112
+ },
113
+ });
114
+ const saved = globalThis.console;
115
+ globalThis.console = new Console({ stdout: sink, stderr: sink });
116
+ try {
117
+ fn();
118
+ }
119
+ finally {
120
+ globalThis.console = saved;
121
+ }
122
+ }
123
+ /**
124
+ * The stale-override check (see STALE OVERRIDES above): one line in the
125
+ * project's log naming what is stale and the fix. Skipped while the runner is
126
+ * off and when less than {@link OVERRIDE_CHECK_RESERVE_MS} of the budget is
127
+ * left. Never throws; writes nothing to stdout, and nothing to Claude Code's
128
+ * files.
129
+ */
130
+ function reportStaleOverrides(projectCwd, logFile, msLeft, check) {
131
+ if (msLeft < OVERRIDE_CHECK_RESERVE_MS)
132
+ return;
133
+ try {
134
+ if (isRunnerOff(projectCwd))
135
+ return;
136
+ const stale = check(projectCwd);
137
+ if (stale.length === 0)
138
+ return;
139
+ logInto(logFile, () => {
140
+ logLine("overrides.stale", {
141
+ servers: stale.map((s) => `${s.name} (${s.state})`).join(", "),
142
+ why: stale
143
+ .map((s) => s.why)
144
+ .filter(Boolean)
145
+ .join("; ") || undefined,
146
+ fix: `run \`bir setup\` in ${projectCwd} — it copies them again (or removes them); \`bir doctor\` names each`,
147
+ effect: "until then this project's sessions run the old copies",
148
+ });
149
+ });
150
+ }
151
+ catch {
152
+ /* a check that failed leaves things as they were; `bir doctor` names what is stale */
153
+ }
154
+ }
155
+ /** True when this process's own environment switches the runner off (`BIR_OFF=1` or `true`). */
156
+ function offInThisEnvironment() {
157
+ const value = (process.env.BIR_OFF ?? "").trim().toLowerCase();
158
+ return value === "1" || value === "true";
159
+ }
27
160
  /** Read stdin to its end, or give up after `timeoutMs` with what arrived. */
28
161
  function readStdin(stream, timeoutMs) {
29
162
  // A person running `bir-hooks ensure` by hand has nothing to paste; do not
@@ -53,26 +186,34 @@ function readStdin(stream, timeoutMs) {
53
186
  * Run the hook for `cwd`. Resolves the exit code, which is always 0.
54
187
  */
55
188
  export async function runEnsureHook(cwd, opts = {}) {
189
+ const startedAt = Date.now();
190
+ const budgetMs = opts.budgetMs ?? ENSURE_HOOK_BUDGET_MS;
191
+ const left = () => budgetMs - (Date.now() - startedAt);
56
192
  const stdout = opts.stdout ?? process.stdout;
57
193
  const stderr = opts.stderr ?? process.stderr;
58
- const raw = await readStdin(opts.stdin ?? process.stdin, opts.stdinTimeoutMs ?? 3_000);
194
+ const raw = await readStdin(opts.stdin ?? process.stdin, Math.min(opts.stdinTimeoutMs ?? STDIN_TIMEOUT_MS, Math.max(100, budgetMs / 4)));
59
195
  let payload = {};
60
196
  if (raw.trim()) {
61
197
  try {
62
- payload = JSON.parse(raw);
198
+ const parsed = JSON.parse(raw);
199
+ payload = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
63
200
  }
64
201
  catch {
65
202
  // Not our problem to diagnose; the control server copes with an empty one.
66
203
  payload = {};
67
204
  }
68
205
  }
206
+ /** Set once the recorder is started or kept: the project it serves, and its log. */
207
+ let kept;
69
208
  try {
70
209
  const { status, started, restarted } = await ensureDaemon(cwd, {
71
210
  hooksScript: opts.hooksScript,
72
211
  env: opts.env,
73
- timeoutMs: opts.timeoutMs,
212
+ timeoutMs: Math.min(opts.timeoutMs ?? Number.POSITIVE_INFINITY, Math.max(1_000, left() - RELAY_RESERVE_MS)),
74
213
  restartOnSkew: opts.restartOnSkew ?? true,
75
214
  });
215
+ const project = status.info.cwd || cwd;
216
+ kept = { project, logFile: status.info.logFile ?? daemonLogPath(project) };
76
217
  if (started) {
77
218
  stderr.write(`[bir] recorder ${restarted ? "restarted" : "started"} in the background (pid ${status.info.pid}, log ${status.info.logFile ?? "?"})\n`);
78
219
  }
@@ -82,8 +223,9 @@ export async function runEnsureHook(cwd, opts = {}) {
82
223
  "content-type": "application/json",
83
224
  authorization: `Bearer ${status.info.token}`,
84
225
  },
85
- body: JSON.stringify(payload),
86
- signal: AbortSignal.timeout(5_000),
226
+ // BIR_OFF=1 where this window was started is this session's switch (K2).
227
+ body: JSON.stringify(offInThisEnvironment() ? { ...payload, bir_off: true } : payload),
228
+ signal: timeoutSignal(Math.max(300, Math.min(RELAY_TIMEOUT_MS, left()))),
87
229
  });
88
230
  const answer = (await res.json().catch(() => ({})));
89
231
  if (res.ok && answer && typeof answer === "object" && Object.keys(answer).length > 0) {
@@ -91,7 +233,17 @@ export async function runEnsureHook(cwd, opts = {}) {
91
233
  }
92
234
  }
93
235
  catch (err) {
94
- stderr.write(`[bir] ensure: ${errText(err)} — this session records standalone (Tier 2)\n`);
236
+ // stderr of a command hook that exits 0 is not shown in the session; it is
237
+ // for someone running this by hand, or Claude Code's debug output.
238
+ const tail = err instanceof RecorderBusyError
239
+ ? " — the hooks reach it once it catches up"
240
+ : " — this session records standalone (Tier 2)";
241
+ stderr.write(`[bir] ensure: ${errText(err)}${tail}\n`);
242
+ }
243
+ // After the relay, so it never takes the relay's time; whether or not the
244
+ // relay answered, the recorder is there.
245
+ if (kept && opts.overrideCheck !== false) {
246
+ reportStaleOverrides(kept.project, kept.logFile, left(), opts.overrideCheck ?? staleOverrides);
95
247
  }
96
248
  return 0;
97
249
  }