@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
@@ -7,16 +7,25 @@
7
7
  * runs. `resolveServers` reports the winner; this module writes there and warns
8
8
  * loudly when a lower scope also defines the name.
9
9
  *
10
- * Byte-for-byte reversibility: the *text* of every file touched is stashed before
11
- * the first edit, together with a hash of what we wrote. On uninstall, a file
12
- * still matching that hash is restored verbatim; a file edited since is repaired
13
- * entry-by-entry instead, with a warning. That is the difference between "we put
14
- * the entries back" and "the file is as you left it".
10
+ * Byte-for-byte reversibility: the *text* of every project file touched is
11
+ * stashed before the first edit, together with a hash of what we wrote. On
12
+ * uninstall, a file still matching that hash — and not changed by anybody else
13
+ * between two of our writes — is restored verbatim; a file edited since is
14
+ * repaired entry-by-entry instead, with a warning. `~/.claude.json` is always
15
+ * repaired entry by entry: it is Claude Code's own, rewritten constantly, and
16
+ * holds every project's settings.
17
+ *
18
+ * Where the wrapped entries go (bir.ts, install): the LOCAL scope, as an
19
+ * override of the same name — never the team's `.mcp.json`, which gets
20
+ * committed and would carry this machine's node and runner paths to every
21
+ * clone, and never the user scope, which applies to every project.
15
22
  */
16
23
  import { createHash } from "node:crypto";
17
- import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
18
- import { dirname, join, parse as parsePath } from "node:path";
19
- import { claudeJsonPath, parseJsonFile, projectKeyFor } from "../resolve.js";
24
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
25
+ import { dirname, join, parse as parsePath, resolve as resolvePath } from "node:path";
26
+ import { writeFileAtomic } from "../../util/atomic-write.js";
27
+ import { FIRST_PROJECT_PORT, PACKAGE_NAME } from "../generate.js";
28
+ import { claudeJsonPath, parseJsonFile, parseJsonText, projectKeyFor, } from "../resolve.js";
20
29
  export function claudeCodePaths(cwd, claudeJsonOverride) {
21
30
  return {
22
31
  claudeJson: claudeJsonPath(claudeJsonOverride),
@@ -34,12 +43,20 @@ export const LOCAL_SETTINGS_EXCLUDE = ".claude/settings.local.json";
34
43
  * `.git/info/exclude`, never `.gitignore`: the first is the person's own,
35
44
  * untracked list; the second is a file the team owns and would see in a diff.
36
45
  * 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
46
+ * pointer to the repository's common git directory (the only exclude file git
47
+ * reads for a linked worktree), and appends the line once. Returns the exclude file's path when the
38
48
  * line is there (added or already present), undefined outside a repository or
39
49
  * when the file could not be written — an exclude that fails is a note, not a
40
50
  * failed install.
41
51
  */
42
52
  export function ensureGitExclude(dir, line = LOCAL_SETTINGS_EXCLUDE) {
53
+ return ensureGitExcludeLine(dir, line)?.path;
54
+ }
55
+ /**
56
+ * {@link ensureGitExclude}, also saying whether the line was ADDED now (false:
57
+ * it was already there — somebody's own, which uninstall must leave alone).
58
+ */
59
+ export function ensureGitExcludeLine(dir, line = LOCAL_SETTINGS_EXCLUDE) {
43
60
  let gitDir;
44
61
  let current = dir;
45
62
  for (;;) {
@@ -51,9 +68,12 @@ export function ensureGitExclude(dir, line = LOCAL_SETTINGS_EXCLUDE) {
51
68
  }
52
69
  else {
53
70
  // A worktree or submodule: `.git` is a file naming the real directory.
71
+ // RESOLVED, not joined: `git worktree add` writes an absolute
72
+ // pointer, and join() would nest it under this directory — a junk
73
+ // `home/…/.git/worktrees/<name>/info/exclude` tree in the checkout.
54
74
  const pointer = readFileSync(candidate, "utf8").match(/^gitdir:\s*(.+)$/m)?.[1]?.trim();
55
75
  if (pointer)
56
- gitDir = join(current, pointer);
76
+ gitDir = commonGitDir(resolvePath(current, pointer));
57
77
  }
58
78
  }
59
79
  catch {
@@ -66,7 +86,9 @@ export function ensureGitExclude(dir, line = LOCAL_SETTINGS_EXCLUDE) {
66
86
  break;
67
87
  current = parent;
68
88
  }
69
- if (!gitDir)
89
+ // A pointer to nowhere (a moved or pruned repository) is no repository: its
90
+ // directory is never created here.
91
+ if (!gitDir || !existsSync(gitDir))
70
92
  return undefined;
71
93
  try {
72
94
  const infoDir = join(gitDir, "info");
@@ -77,13 +99,33 @@ export function ensureGitExclude(dir, line = LOCAL_SETTINGS_EXCLUDE) {
77
99
  if (!lines.includes(line)) {
78
100
  const sep = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
79
101
  appendFileSync(exclude, `${sep}${line}\n`);
102
+ return { path: exclude, added: true };
80
103
  }
81
- return exclude;
104
+ return { path: exclude, added: false };
82
105
  }
83
106
  catch {
84
107
  return undefined;
85
108
  }
86
109
  }
110
+ /**
111
+ * The git directory whose `info/exclude` git actually reads for `gitDir`. A
112
+ * linked worktree's own directory (`<repo>/.git/worktrees/<name>`) has a
113
+ * `commondir` file naming the repository's, and git reads only that one's
114
+ * exclude file — a line in the per-worktree one is ignored. A submodule's
115
+ * `.git/modules/<name>` has no `commondir` and is its own.
116
+ */
117
+ function commonGitDir(gitDir) {
118
+ try {
119
+ const commondir = join(gitDir, "commondir");
120
+ if (!existsSync(commondir))
121
+ return gitDir;
122
+ const common = readFileSync(commondir, "utf8").trim();
123
+ return common ? resolvePath(gitDir, common) : gitDir;
124
+ }
125
+ catch {
126
+ return gitDir;
127
+ }
128
+ }
87
129
  /** Undo {@link ensureGitExclude}: drop the line, leave everything else. */
88
130
  export function removeGitExclude(dir, line = LOCAL_SETTINGS_EXCLUDE) {
89
131
  const exclude = ensureGitExclude(dir, line); // finds the file (and would add the line, which is there)
@@ -94,7 +136,7 @@ export function removeGitExclude(dir, line = LOCAL_SETTINGS_EXCLUDE) {
94
136
  .split(/\r?\n/)
95
137
  .filter((l) => l.trim() !== line);
96
138
  const text = kept.join("\n").replace(/\n+$/, "");
97
- writeFileSync(exclude, text ? `${text}\n` : "");
139
+ writeFileAtomic(exclude, text ? `${text}\n` : "");
98
140
  return exclude;
99
141
  }
100
142
  catch {
@@ -110,24 +152,29 @@ export function removeGitExclude(dir, line = LOCAL_SETTINGS_EXCLUDE) {
110
152
  * settings, so the question is never asked. Returns the file's new text.
111
153
  */
112
154
  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);
155
+ return editJson(settingsPath, (parsed) => {
156
+ const current = Array.isArray(parsed.enabledMcpjsonServers)
157
+ ? parsed.enabledMcpjsonServers.map(String)
158
+ : [];
159
+ parsed.enabledMcpjsonServers = [...new Set([...current, ...names])];
160
+ });
119
161
  }
120
162
  /** Undo {@link enableMcpjsonServers} for `names`; drops the key when empty. */
121
163
  export function disableMcpjsonServers(settingsPath, names) {
122
164
  const parsed = parseJsonFile(settingsPath);
123
165
  if (!parsed || !Array.isArray(parsed.enabledMcpjsonServers))
124
166
  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);
167
+ if (!parsed.enabledMcpjsonServers.map(String).some((n) => names.includes(n)))
168
+ return undefined;
169
+ return editJson(settingsPath, (fresh) => {
170
+ if (!Array.isArray(fresh.enabledMcpjsonServers))
171
+ return;
172
+ const kept = fresh.enabledMcpjsonServers.map(String).filter((n) => !names.includes(n));
173
+ if (kept.length === 0)
174
+ delete fresh.enabledMcpjsonServers;
175
+ else
176
+ fresh.enabledMcpjsonServers = kept;
177
+ });
131
178
  }
132
179
  /** True when `settingsPath` holds any hook of ours. */
133
180
  export function hasBirHooks(settingsPath) {
@@ -167,11 +214,51 @@ export function readTextOrNull(path) {
167
214
  return null;
168
215
  }
169
216
  }
170
- function writeJson(path, value) {
171
- const text = JSON.stringify(value, null, 2) + "\n";
172
- mkdirSync(dirname(path), { recursive: true });
173
- writeFileSync(path, text, "utf8");
174
- return text;
217
+ /**
218
+ * Read-modify-write one JSON file, safely:
219
+ *
220
+ * - a file that is not plain JSON (comments, trailing commas, a torn write)
221
+ * is REFUSED with an error naming it, never rewritten from a guess;
222
+ * - the file is re-read just before the write, and the edit is applied again
223
+ * to the fresh text when somebody else changed it in between (Claude Code
224
+ * rewrites `~/.claude.json` constantly, and a lost update there loses a
225
+ * server the user just added, or ours);
226
+ * - the write itself is atomic (util/atomic-write.ts), so Claude Code never
227
+ * reads a half-written config, and a symlinked file stays a symlink.
228
+ *
229
+ * `mutate` gets a fresh parse each attempt and must not keep state between
230
+ * calls. Returns the text written, for the sidecar's hash.
231
+ */
232
+ export function editJson(path, mutate) {
233
+ for (let attempt = 0;; attempt += 1) {
234
+ const before = readTextOrNull(path);
235
+ const value = before === null ? {} : parseJsonText(before, path);
236
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
237
+ throw new Error(`refusing to rewrite ${path}: it is not a JSON object`);
238
+ }
239
+ const parsed = value;
240
+ mutate(parsed);
241
+ const text = JSON.stringify(parsed, null, 2) + "\n";
242
+ if (attempt < 3 && readTextOrNull(path) !== before)
243
+ continue;
244
+ mkdirSync(dirname(path), { recursive: true });
245
+ writeFileAtomic(path, text);
246
+ return text;
247
+ }
248
+ }
249
+ /**
250
+ * Throw (naming the file) when `path` exists and is not a plain JSON object —
251
+ * so an install can refuse up front, before it has written anything, instead
252
+ * of stopping halfway through with some files changed and others not.
253
+ */
254
+ export function assertEditableJson(path) {
255
+ const text = readTextOrNull(path);
256
+ if (text === null)
257
+ return;
258
+ const value = parseJsonText(text, path);
259
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
260
+ throw new Error(`${path} is not a JSON object`);
261
+ }
175
262
  }
176
263
  /**
177
264
  * Set (or delete, with `entry === null`) one MCP server entry in `scope`.
@@ -179,27 +266,78 @@ function writeJson(path, value) {
179
266
  */
180
267
  export function setServerEntry(scope, cwd, name, entry, paths) {
181
268
  const file = fileForScope(scope, paths);
182
- const parsed = parseJsonFile(file) ?? {};
183
- if (scope === "local") {
184
- const projects = parsed.projects ?? {};
185
- const key = projectKeyFor(projects, cwd);
186
- const project = projects[key] ?? {};
187
- const servers = { ...(project.mcpServers ?? {}) };
188
- if (entry === null)
189
- delete servers[name];
190
- else
191
- servers[name] = entry;
192
- parsed.projects = { ...projects, [key]: { ...project, mcpServers: servers } };
193
- }
194
- else {
195
- const servers = { ...(parsed.mcpServers ?? {}) };
269
+ const text = editJson(file, (parsed) => {
270
+ if (scope === "local") {
271
+ const projects = parsed.projects ?? {};
272
+ const key = projectKeyFor(projects, cwd);
273
+ const project = projects[key] ?? {};
274
+ const servers = { ...(project.mcpServers ?? {}) };
275
+ if (entry === null)
276
+ delete servers[name];
277
+ else
278
+ servers[name] = entry;
279
+ parsed.projects = { ...projects, [key]: { ...project, mcpServers: servers } };
280
+ }
281
+ else {
282
+ const servers = { ...(parsed.mcpServers ?? {}) };
283
+ if (entry === null)
284
+ delete servers[name];
285
+ else
286
+ servers[name] = entry;
287
+ parsed.mcpServers = servers;
288
+ }
289
+ });
290
+ return { file, text };
291
+ }
292
+ /**
293
+ * Set (or delete) one entry of the local scope under each of `keys` in
294
+ * `~/.claude.json`, in ONE atomic edit. `shouldTouch` sees the entry currently
295
+ * under a key (or undefined) and may veto that key — so a user's own entry
296
+ * under another spelling of the project is never overwritten or deleted.
297
+ * Returns the text written and the keys actually changed (none: no write).
298
+ */
299
+ export function setLocalEntries(claudeJson, keys, name, entry, shouldTouch = () => true) {
300
+ let changed = [];
301
+ const probe = parseJsonFile(claudeJson);
302
+ const wanted = keys.filter((key) => {
303
+ const current = probe?.projects?.[key]?.mcpServers?.[name];
304
+ if (!shouldTouch(current))
305
+ return false;
196
306
  if (entry === null)
197
- delete servers[name];
198
- else
199
- servers[name] = entry;
200
- parsed.mcpServers = servers;
201
- }
202
- return { file, text: writeJson(file, parsed) };
307
+ return current !== undefined;
308
+ return JSON.stringify(current) !== JSON.stringify(entry);
309
+ });
310
+ if (wanted.length === 0)
311
+ return { keys: [] };
312
+ const text = editJson(claudeJson, (parsed) => {
313
+ changed = [];
314
+ const projects = { ...(parsed.projects ?? {}) };
315
+ for (const key of keys) {
316
+ const project = projects[key] ?? {};
317
+ const current = project.mcpServers?.[name];
318
+ if (!shouldTouch(current))
319
+ continue;
320
+ if (entry === null && current === undefined)
321
+ continue;
322
+ const servers = { ...(project.mcpServers ?? {}) };
323
+ if (entry === null)
324
+ delete servers[name];
325
+ else
326
+ servers[name] = entry;
327
+ const next = { ...project, mcpServers: servers };
328
+ // A project key holding nothing but an empty `mcpServers` is one an
329
+ // install created before Claude Code ever ran there (Claude Code's own
330
+ // entries carry its trust and permission state). Taking our last entry
331
+ // out takes the key with it, so uninstall leaves the file as it found it.
332
+ if (entry === null && Object.keys(next).length === 1 && Object.keys(servers).length === 0)
333
+ delete projects[key];
334
+ else
335
+ projects[key] = next;
336
+ changed.push(key);
337
+ }
338
+ parsed.projects = projects;
339
+ });
340
+ return { text, keys: changed };
203
341
  }
204
342
  // ── hooks ──────────────────────────────────────────────────────────────────
205
343
  /**
@@ -210,49 +348,67 @@ export function setServerEntry(scope, cwd, name, entry, paths) {
210
348
  * only listens to the success one records a step that never closes.
211
349
  * `SubagentStart` / `SubagentStop` carry `agent_id`, which is what lets a
212
350
  * subagent's steps be attributed rather than silently interleaved.
351
+ *
352
+ * THE TIMEOUTS ARE THE USER'S WORST CASE. Claude Code waits for a hung hook up
353
+ * to its `timeout`, so each number here is how long a slow or blackholed
354
+ * service could make a person wait — at every prompt, every tool call, every
355
+ * `/exit`. The control server keeps its own per-route deadlines well inside
356
+ * them (prompt ≤ 7 s, tool/pre ≤ 12 s, tool/post and stop ≤ 2 s, end ≤ 1 s,
357
+ * start ≤ 2 s) and answers `{}` on overrun, so these are the second line of
358
+ * defence, not a budget to spend. SessionEnd matters twice: Claude Code's own
359
+ * default for it is 1.5 s, and whatever is written here RAISES that — the 30 it
360
+ * used to be turned every `/exit` during a service incident into a 30 s wait.
361
+ * A re-run of `bir setup` rewrites existing installs with these.
213
362
  */
214
363
  export const HOOK_ROUTES = [
215
- { event: "SessionStart", route: "/session/start", matcher: "", timeout: 10 },
216
- // 15s, not 5s, because this hook now awaits the similar-prompt match before it
217
- // can return a steering directive (docs/calculatedReplay.md §10). The wait is
218
- // itself bounded by `BIR_MATCH_BUDGET_MS` (2.5s), well inside this; the extra
219
- // headroom is for a slow first embed on the service, not for us to spend.
364
+ // The HTTP form, used only when no self-starting hook can be written.
365
+ { event: "SessionStart", route: "/session/start", matcher: "", timeout: 5 },
366
+ // Awaits the similar-prompt match and the derivation before it can return a
367
+ // steering directive (docs/calculatedReplay.md §10); the server caps the
368
+ // whole route at 7 s.
220
369
  //
221
370
  // It must NEVER become `async`. An async hook is fire-and-forget, so it cannot
222
371
  // return `additionalContext` at all — replay would silently never arm.
223
- { event: "UserPromptSubmit", route: "/session/prompt", matcher: "", timeout: 15 },
372
+ { event: "UserPromptSubmit", route: "/session/prompt", matcher: "", timeout: 10 },
224
373
  // PreToolUse and PostToolUse are likewise NOT async, and that is load-bearing
225
374
  // twice over: correlation needs `updatedInput` back, and a steered replay needs
226
375
  // step N's output threaded into `respParams` *before* step N+1's input is
227
- // computed (docs/calculatedReplay.md §7.3).
228
- { event: "PreToolUse", route: "/tool/pre", matcher: "*", timeout: 30 },
229
- { event: "PostToolUse", route: "/tool/post", matcher: "*", timeout: 30 },
230
- { event: "PostToolUseFailure", route: "/tool/post", matcher: "*", timeout: 30 },
231
- { event: "SubagentStart", route: "/session/agent", matcher: "", timeout: 5 },
232
- { event: "SubagentStop", route: "/session/agent", matcher: "", timeout: 5 },
233
- { event: "Stop", route: "/session/stop", matcher: "", timeout: 10 },
234
- { event: "StopFailure", route: "/session/stop", matcher: "", timeout: 10 },
376
+ // computed (docs/calculatedReplay.md §7.3). PreToolUse may run an in-hook
377
+ // replay step, capped by the server at 12 s.
378
+ { event: "PreToolUse", route: "/tool/pre", matcher: "*", timeout: 15 },
379
+ { event: "PostToolUse", route: "/tool/post", matcher: "*", timeout: 5 },
380
+ { event: "PostToolUseFailure", route: "/tool/post", matcher: "*", timeout: 5 },
381
+ { event: "SubagentStart", route: "/session/agent", matcher: "", timeout: 3 },
382
+ { event: "SubagentStop", route: "/session/agent", matcher: "", timeout: 3 },
383
+ { event: "Stop", route: "/session/stop", matcher: "", timeout: 5 },
384
+ { event: "StopFailure", route: "/session/stop", matcher: "", timeout: 5 },
235
385
  // NOT async. An async hook is fire-and-forget, and `SessionEnd` fires while the
236
386
  // host is exiting — the exit wins that race and the request is never sent, so
237
- // every run was left unfinished on the server. It is cheap to wait for now
238
- // (`Stop` has already sealed the run; this only drains the queue), and a run
239
- // that is actually finished is worth a few hundred milliseconds at shutdown.
240
- { event: "SessionEnd", route: "/session/end", matcher: "", timeout: 30 },
387
+ // every run was left unfinished on the server. The server answers within 1 s
388
+ // and finishes the upload in the background; 3 s is margin, not a wait.
389
+ { event: "SessionEnd", route: "/session/end", matcher: "", timeout: 3 },
241
390
  ];
242
391
  /**
243
392
  * The argument that makes a 'bir-hooks' invocation the SessionStart hook.
244
393
  * Exported so the binary and the adapter cannot drift apart on the spelling.
245
394
  */
246
395
  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;
396
+ /**
397
+ * How long SessionStart may wait for a background recorder to come up. The
398
+ * ensure hook keeps its own budget at 12 s or less and always exits 0; this is
399
+ * the host's ceiling on top of it.
400
+ */
401
+ export const ENSURE_TIMEOUT_S = 15;
249
402
  /** Build the 'hooks' block pointing at a control server. */
250
403
  export function buildHooksBlock(controlUrl, token, opts = {}) {
251
404
  const base = controlUrl.replace(/\/+$/, "");
252
405
  const block = {};
253
406
  for (const { event, route, matcher, timeout, async } of HOOK_ROUTES) {
254
407
  let hook;
255
- if (event === "SessionStart" && opts.ensure) {
408
+ if (event === "SessionStart" && opts.ensure && "command" in opts.ensure) {
409
+ hook = { type: "command", command: opts.ensure.command, args: [...opts.ensure.args], timeout: ENSURE_TIMEOUT_S };
410
+ }
411
+ else if (event === "SessionStart" && opts.ensure && "node" in opts.ensure) {
256
412
  hook = {
257
413
  type: "command",
258
414
  command: opts.ensure.node,
@@ -275,55 +431,89 @@ export function buildHooksBlock(controlUrl, token, opts = {}) {
275
431
  }
276
432
  return block;
277
433
  }
434
+ /** A loopback bearer token as `bir install` makes them: 32 random bytes, hex. */
435
+ const OUR_TOKEN = /^Bearer [0-9a-f]{64}$/i;
278
436
  /**
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.
437
+ * True when a hook entry is one of ours, and only then — install and uninstall
438
+ * delete what this says is ours, so a false positive deletes somebody's hook.
439
+ *
440
+ * HTTP: a loopback URL (127.0.0.1 / localhost / [::1]) whose path is exactly
441
+ * one of our routes, AND either our kind of bearer token or a port in
442
+ * the range `bir` allocates (the token-less 0.2.8 shape). A hook to
443
+ * `https://hooks.example.com/session/end` is somebody else's.
444
+ * exec: our self-starting SessionStart hook — `bir-hooks.js ensure`, or the
445
+ * npx form `… -p @basein/runner@<v> bir-hooks ensure`.
281
446
  */
282
447
  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;
448
+ const e = (entry ?? {});
449
+ if (typeof e.url === "string" && e.url) {
450
+ let url;
451
+ try {
452
+ url = new URL(e.url);
453
+ }
454
+ catch {
455
+ return false;
456
+ }
457
+ if (!["127.0.0.1", "localhost", "[::1]"].includes(url.hostname))
458
+ return false;
459
+ if (!HOOK_ROUTES.some(({ route }) => url.pathname === route))
460
+ return false;
461
+ const auth = e.headers?.Authorization;
462
+ if (typeof auth === "string" && OUR_TOKEN.test(auth))
463
+ return true;
464
+ const port = Number(url.port);
465
+ return port >= FIRST_PROJECT_PORT && port < FIRST_PROJECT_PORT + 200;
466
+ }
287
467
  if (Array.isArray(e.args)) {
288
468
  const args = e.args.map(String);
289
- return args.some((a) => /[\\/]bir-hooks\.js$/.test(a)) && args.includes(ENSURE_ARG);
469
+ if (!args.includes(ENSURE_ARG))
470
+ return false;
471
+ if (args.some((a) => /[\\/]bir-hooks\.js$/.test(a)))
472
+ return true;
473
+ return args.includes("bir-hooks") && args.some((a) => a.startsWith(PACKAGE_NAME));
290
474
  }
291
475
  return false;
292
476
  }
293
477
  /**
294
478
  * Merge our hooks into a settings file, leaving every other hook alone. Returns
295
479
  * the new text. Our own entries are replaced rather than appended, so repeated
296
- * installs stay idempotent.
480
+ * installs stay idempotent — hook by hook: a user's own hook that happens to
481
+ * share a matcher group with one of ours stays exactly where it is.
297
482
  */
298
483
  export function installHooks(settingsPath, block) {
299
- const parsed = parseJsonFile(settingsPath) ?? {};
300
- const hooks = { ...(parsed.hooks ?? {}) };
301
- for (const [event, matchers] of Object.entries(block)) {
302
- const existing = (hooks[event] ?? []).filter((m) => !(m.hooks ?? []).some((h) => isBirHook(h)));
303
- hooks[event] = [...existing, ...matchers];
304
- }
305
- parsed.hooks = hooks;
306
- return writeJson(settingsPath, parsed);
484
+ return editJson(settingsPath, (parsed) => {
485
+ const hooks = { ...(parsed.hooks ?? {}) };
486
+ for (const [event, matchers] of Object.entries(block)) {
487
+ const existing = (hooks[event] ?? [])
488
+ .map((m) => ({ ...m, hooks: (m.hooks ?? []).filter((h) => !isBirHook(h)) }))
489
+ .filter((m) => m.hooks.length > 0);
490
+ hooks[event] = [...existing, ...matchers];
491
+ }
492
+ parsed.hooks = hooks;
493
+ });
307
494
  }
308
495
  /** Remove only our hook entries, and drop events left empty. */
309
496
  export function uninstallHooks(settingsPath) {
310
497
  const parsed = parseJsonFile(settingsPath);
311
498
  if (!parsed?.hooks)
312
499
  return undefined;
313
- const hooks = { ...parsed.hooks };
314
- for (const [event, matchers] of Object.entries(hooks)) {
315
- const kept = (matchers ?? [])
316
- .map((m) => ({ ...m, hooks: (m.hooks ?? []).filter((h) => !isBirHook(h)) }))
317
- .filter((m) => m.hooks.length > 0);
318
- if (kept.length === 0)
319
- delete hooks[event];
500
+ return editJson(settingsPath, (fresh) => {
501
+ if (!fresh.hooks)
502
+ return;
503
+ const hooks = { ...fresh.hooks };
504
+ for (const [event, matchers] of Object.entries(hooks)) {
505
+ const kept = (matchers ?? [])
506
+ .map((m) => ({ ...m, hooks: (m.hooks ?? []).filter((h) => !isBirHook(h)) }))
507
+ .filter((m) => m.hooks.length > 0);
508
+ if (kept.length === 0)
509
+ delete hooks[event];
510
+ else
511
+ hooks[event] = kept;
512
+ }
513
+ if (Object.keys(hooks).length === 0)
514
+ delete fresh.hooks;
320
515
  else
321
- hooks[event] = kept;
322
- }
323
- if (Object.keys(hooks).length === 0)
324
- delete parsed.hooks;
325
- else
326
- parsed.hooks = hooks;
327
- return writeJson(settingsPath, parsed);
516
+ fresh.hooks = hooks;
517
+ });
328
518
  }
329
519
  //# sourceMappingURL=claude-code.js.map
@@ -8,7 +8,10 @@
8
8
  * user and never looks for one.
9
9
  *
10
10
  * There are no scopes here: the file given is the whole configuration, and it is
11
- * both the read source and the write target.
11
+ * both the read source and the write target. Writes go through the same safe
12
+ * edit as Claude Code's files (claude-code.ts, `editJson`): refused when the
13
+ * file is not plain JSON, re-applied if the client rewrote it meanwhile, and
14
+ * atomic.
12
15
  */
13
16
  import { type McpServerConfig } from "../resolve.js";
14
17
  export declare function readGenericServers(path: string): Record<string, McpServerConfig>;
@@ -8,10 +8,12 @@
8
8
  * user and never looks for one.
9
9
  *
10
10
  * There are no scopes here: the file given is the whole configuration, and it is
11
- * both the read source and the write target.
11
+ * both the read source and the write target. Writes go through the same safe
12
+ * edit as Claude Code's files (claude-code.ts, `editJson`): refused when the
13
+ * file is not plain JSON, re-applied if the client rewrote it meanwhile, and
14
+ * atomic.
12
15
  */
13
- import { mkdirSync, writeFileSync } from "node:fs";
14
- import { dirname } from "node:path";
16
+ import { editJson } from "./claude-code.js";
15
17
  import { parseJsonFile } from "../resolve.js";
16
18
  export function readGenericServers(path) {
17
19
  const parsed = parseJsonFile(path);
@@ -21,16 +23,13 @@ export function readGenericServers(path) {
21
23
  }
22
24
  /** Set or delete one entry, preserving every other key in the file. */
23
25
  export function setGenericServerEntry(path, name, entry) {
24
- const parsed = parseJsonFile(path) ?? {};
25
- const servers = { ...(parsed.mcpServers ?? {}) };
26
- if (entry === null)
27
- delete servers[name];
28
- else
29
- servers[name] = entry;
30
- parsed.mcpServers = servers;
31
- const text = JSON.stringify(parsed, null, 2) + "\n";
32
- mkdirSync(dirname(path), { recursive: true });
33
- writeFileSync(path, text, "utf8");
34
- return text;
26
+ return editJson(path, (parsed) => {
27
+ const servers = { ...(parsed.mcpServers ?? {}) };
28
+ if (entry === null)
29
+ delete servers[name];
30
+ else
31
+ servers[name] = entry;
32
+ parsed.mcpServers = servers;
33
+ });
35
34
  }
36
35
  //# sourceMappingURL=generic.js.map