@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
@@ -0,0 +1,128 @@
1
+ /**
2
+ * service-read — what `bir scenario show|edits` and `bir investigate` share
3
+ * about asking the service and printing its answer, so a slow service, a
4
+ * signed-out machine or a large scenario never turns into a stall or a flood.
5
+ *
6
+ * WHY A BOUND OF THEIR OWN. The `service()` these commands are handed settles
7
+ * the service address with a `/health` probe, may refresh the session, and
8
+ * then makes its request; a service (or a corporate proxy in front of it) that
9
+ * accepts the connection and never answers held every one of those for as long
10
+ * as Node's fetch waits — measured at 120 s through the `bir` MCP server, which
11
+ * is 120 s of a turn stalled on a debugging tool. A read is safe to abandon:
12
+ * nothing was asked to change. So each one gets {@link readTimeoutMs} — a
13
+ * command that reads twice shares it between them ({@link ReadClock}) — and
14
+ * then an answer in words. Writes (`check`, `edit`, `undo`, `calc`) are not
15
+ * bounded here — abandoning one would leave "was it saved?" unanswerable — and
16
+ * the MCP server bounds the whole command instead.
17
+ *
18
+ * WHY COMPACT JSON UNDER THE MCP SERVER. The `bir` MCP server runs these
19
+ * commands with `BIR_MCP=1` and hands their `--json` output to the model.
20
+ * Indentation is for a person at a terminal; in a model's context it is a
21
+ * quarter of the tokens and none of the meaning.
22
+ */
23
+ /** Default bound on one command's reads of the service. `BIR_READ_TIMEOUT_MS` overrides it. */
24
+ export const DEFAULT_READ_TIMEOUT_MS = 15_000;
25
+ /**
26
+ * The most any timer here is set for from the environment. Node turns a delay
27
+ * past 2^31 - 1 ms into 1 ms, so a mistyped `BIR_READ_TIMEOUT_MS=3000000000`
28
+ * used to make every read give up at once; ten minutes is already far longer
29
+ * than anybody waits for a debugging tool.
30
+ */
31
+ export const MAX_ENV_TIMEOUT_MS = 600_000;
32
+ /**
33
+ * A number of milliseconds from the environment: positive and at most
34
+ * {@link MAX_ENV_TIMEOUT_MS}, or `fallback`. Out of range is a mistake, and
35
+ * the default is the value known to work.
36
+ */
37
+ export function envTimeoutMs(name, fallback) {
38
+ const n = Number(process.env[name]);
39
+ return Number.isFinite(n) && n > 0 && n <= MAX_ENV_TIMEOUT_MS ? n : fallback;
40
+ }
41
+ export function readTimeoutMs() {
42
+ return envTimeoutMs("BIR_READ_TIMEOUT_MS", DEFAULT_READ_TIMEOUT_MS);
43
+ }
44
+ /** True when the `bir` MCP server ran this command for a model (it sets `BIR_MCP=1`). */
45
+ export function underMcp() {
46
+ return process.env.BIR_MCP === "1";
47
+ }
48
+ /** `--json` output: indented for a person, compact for a model. */
49
+ export function jsonText(value) {
50
+ return underMcp() ? JSON.stringify(value) : JSON.stringify(value, null, 2);
51
+ }
52
+ /**
53
+ * `pending`, or `{ error }` when it takes longer than `ms`. The request itself
54
+ * is not cancelled — `bir` exits as soon as the command returns, which is what
55
+ * ends it — and a late rejection is observed here, so it can never surface as
56
+ * an unhandled one. `statedMs` is the bound the answer names, when `ms` is
57
+ * only what was left of it ({@link ReadClock}).
58
+ */
59
+ export async function readWithin(pending, ms = readTimeoutMs(), statedMs = ms) {
60
+ pending.catch(() => undefined);
61
+ let timer;
62
+ const late = new Promise((resolve) => {
63
+ timer = setTimeout(() => resolve({ error: `the BaseIn service did not answer within ${seconds(statedMs)} s` }), Math.max(0, ms));
64
+ timer.unref?.();
65
+ });
66
+ try {
67
+ return await Promise.race([pending, late]);
68
+ }
69
+ catch (err) {
70
+ return { error: err instanceof Error ? err.message : String(err) };
71
+ }
72
+ finally {
73
+ if (timer)
74
+ clearTimeout(timer);
75
+ }
76
+ }
77
+ /**
78
+ * One command's reads of the service, sharing one bound. A command that reads
79
+ * twice — `scenario edits` on a `run_` id finds the scenario, then its
80
+ * history; `investigate` tries the matched run after a "no" — used to give
81
+ * each read the whole bound, so a service that answered the first slowly and
82
+ * held the second took twice as long as the `bir` MCP server allows a
83
+ * reading tool, and was killed there with none of the answer the command
84
+ * already had (the journal half of `investigate`, say). Sharing the bound
85
+ * keeps the whole command inside it, and the timeout still names the bound,
86
+ * not the scrap of it the last read had left.
87
+ */
88
+ export class ReadClock {
89
+ totalMs;
90
+ deadline;
91
+ constructor(totalMs = readTimeoutMs()) {
92
+ this.totalMs = totalMs;
93
+ this.deadline = Date.now() + totalMs;
94
+ }
95
+ /** What is left of the bound, in ms (0 once it is spent). */
96
+ remaining() {
97
+ return Math.max(0, this.deadline - Date.now());
98
+ }
99
+ /** {@link readWithin}, with what is left of the command's bound. */
100
+ within(pending) {
101
+ return readWithin(pending, this.remaining(), this.totalMs);
102
+ }
103
+ }
104
+ /** Whether a `{ error }` means the service could not be reached at all, as opposed to answering. */
105
+ export function unreachable(error) {
106
+ return /did not answer within|fetch failed|ECONN|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH|socket|network|aborted|timed out|UND_ERR/i.test(error);
107
+ }
108
+ /**
109
+ * A failure to reach the service, in words a person (or a model) can act on.
110
+ * The transport's own words — "fetch failed", "This operation was aborted" —
111
+ * say nothing about what to do, so they are replaced; the sentences `bir`
112
+ * itself wrote ("not signed in — run `bir login`") are already the answer.
113
+ */
114
+ export function serviceProblem(error) {
115
+ if (/BIR_AUTH_URL is not set/.test(error)) {
116
+ return "no BaseIn service is configured on this machine — run `bir setup` or `bir login`";
117
+ }
118
+ if (/did not answer within/.test(error))
119
+ return error;
120
+ if (unreachable(error))
121
+ return "the BaseIn service could not be reached — check the network, or try again later";
122
+ return error;
123
+ }
124
+ function seconds(ms) {
125
+ const s = ms / 1000;
126
+ return Number.isInteger(s) ? String(s) : s.toFixed(1);
127
+ }
128
+ //# sourceMappingURL=service-read.js.map
@@ -43,7 +43,26 @@ export interface SetupArgs {
43
43
  daemon: boolean;
44
44
  /** `--allow-servers a,b`: the servers replay may call unattended, stored for this project. */
45
45
  allowServers?: string;
46
+ /**
47
+ * `--port` was given: the port is the person's, and is never moved. Without
48
+ * it a recorded port this machine no longer lets the recorder bind is
49
+ * replaced (see {@link runSetup}, step 4).
50
+ */
51
+ portExplicit?: boolean;
46
52
  }
53
+ /**
54
+ * The project directory every later step keys by: the PHYSICAL path on POSIX.
55
+ *
56
+ * After `chdir`, `process.cwd()` — which install keys the sidecar by and the
57
+ * recorder keys its discovery file by — is the resolved path. Keying setup's own
58
+ * steps by the logical one (a symlinked ~/code, macOS's /tmp → /private/tmp)
59
+ * made it wait 15 s for a discovery file that was written under the other
60
+ * spelling, report "the recorder did not start" while it ran, and store
61
+ * `--allow-servers` where the recorder never looks. Windows keeps the path as
62
+ * given: its cwd is not resolved through junctions, and resolving would only
63
+ * change the drive letter's case.
64
+ */
65
+ export declare function canonicalProject(dir: string, platform?: NodeJS.Platform): string;
47
66
  export interface SetupDeps {
48
67
  out: (line?: string) => void;
49
68
  err: (line: string) => void;
package/dist/bin/setup.js CHANGED
@@ -28,16 +28,41 @@
28
28
  * `cd <project>` and `bir setup`. The second needs no token; the session is
29
29
  * cached.
30
30
  */
31
- import { existsSync, statSync } from "node:fs";
31
+ import { existsSync, realpathSync, statSync } from "node:fs";
32
32
  import { homedir } from "node:os";
33
33
  import { delimiter, join, parse as parsePath, resolve as resolvePath } from "node:path";
34
+ import { pidAlive, readDiscovery } from "../control/discovery.js";
34
35
  import { AUTH_URL_HINT, DeviceFlowAborted, DeviceFlowUnsupported, authenticate, describeAuthService, deviceLogin, loadStoredConfig, normalizeAuthUrl, rememberAuthUrl, tokenLogin, } from "../auth/client.js";
35
- import { activeSessions, ensureDaemon, SETUP_TOKEN_ENV, stopDaemon, } from "../control/daemon.js";
36
+ import { activeSessions, compareVersions, ensureDaemon, SETUP_TOKEN_ENV, stopDaemon, } from "../control/daemon.js";
36
37
  import { normalizePath } from "../control/paths.js";
37
- import { isWrapped, projectRecord, readSidecar, SCENARIO_SERVER_KEY, setProjectRecord, writeSidecar, allocateFreeProjectPort, } from "../config/generate.js";
38
+ import { allocateReplacementPort, isScenarioServer, isWrapped, LOOPBACK_NO_PROXY, probeFreePort, proxyConfigured, projectRecord, readSidecar, SCENARIO_SERVER_KEY, setProjectRecord, writeSidecar, allocateFreeProjectPort, } from "../config/generate.js";
38
39
  import { resolveServers } from "../config/resolve.js";
39
40
  import { errText } from "../util/log.js";
41
+ import { offSource } from "../util/off-switch.js";
40
42
  import { packageVersion } from "../util/version.js";
43
+ /**
44
+ * The project directory every later step keys by: the PHYSICAL path on POSIX.
45
+ *
46
+ * After `chdir`, `process.cwd()` — which install keys the sidecar by and the
47
+ * recorder keys its discovery file by — is the resolved path. Keying setup's own
48
+ * steps by the logical one (a symlinked ~/code, macOS's /tmp → /private/tmp)
49
+ * made it wait 15 s for a discovery file that was written under the other
50
+ * spelling, report "the recorder did not start" while it ran, and store
51
+ * `--allow-servers` where the recorder never looks. Windows keeps the path as
52
+ * given: its cwd is not resolved through junctions, and resolving would only
53
+ * change the drive letter's case.
54
+ */
55
+ export function canonicalProject(dir, platform = process.platform) {
56
+ const logical = resolvePath(dir);
57
+ if (platform === "win32")
58
+ return logical;
59
+ try {
60
+ return realpathSync(logical);
61
+ }
62
+ catch {
63
+ return logical;
64
+ }
65
+ }
41
66
  /** True when `claude` (Claude Code) is on PATH or in its native install location. */
42
67
  export function claudeOnPath(env = process.env) {
43
68
  const names = process.platform === "win32" ? ["claude.exe", "claude.cmd", "claude"] : ["claude"];
@@ -93,7 +118,18 @@ export async function runSetup(args, deps) {
93
118
  const problem = await describeAuthService(baseUrl);
94
119
  if (problem) {
95
120
  err(`${baseUrl} is not a BaseIn service: ${problem}.`);
96
- err(AUTH_URL_HINT);
121
+ if (/unreachable/.test(problem) && proxyConfigured(env) && !process.execArgv.includes("--use-env-proxy")) {
122
+ // Behind a proxy-only network the likeliest cause, and not the address:
123
+ // Node's fetch ignores HTTPS_PROXY unless it is started with this flag.
124
+ // NO_PROXY too: with the flag, the runner's own calls to 127.0.0.1 would
125
+ // go to the proxy as well, and install leaves the flag out without it.
126
+ err("HTTPS_PROXY/HTTP_PROXY is set, but this node does not use it without --use-env-proxy: set " +
127
+ `NO_PROXY=${LOOPBACK_NO_PROXY} (plus your own entries) and run ` +
128
+ "`node --use-env-proxy <path to bir.js> setup …` (the install line does this for you).");
129
+ }
130
+ else {
131
+ err(AUTH_URL_HINT);
132
+ }
97
133
  return 1;
98
134
  }
99
135
  const envUrl = (env.BIR_AUTH_URL ?? "").replace(/\/+$/, "");
@@ -107,7 +143,7 @@ export async function runSetup(args, deps) {
107
143
  err(`BIR_AUTH_URL in this environment is ${envUrl}, but ${args.authUrl ? "this sets up" : "the stored address is"} ${args.authUrl ? baseUrl : storedUrl}.`);
108
144
  err("Remove that variable (check your shell profile), or the recorder will not record.");
109
145
  }
110
- const project = resolvePath(args.project ?? process.cwd());
146
+ const project = canonicalProject(args.project ?? process.cwd());
111
147
  if (!existsSync(project) || !statSync(project).isDirectory()) {
112
148
  err(`Project directory not found: ${project}`);
113
149
  return 1;
@@ -185,6 +221,33 @@ export async function runSetup(args, deps) {
185
221
  }
186
222
  // ── 4. the project ───────────────────────────────────────────────────────
187
223
  chdir(project);
224
+ if (!args.portExplicit) {
225
+ // A port chosen at the first install is kept on later ones — the hooks name
226
+ // it — but this machine may no longer let it be bound: Hyper-V, WSL and
227
+ // Docker reserve blocks inside the dynamic range, and those move across
228
+ // reboots. A recorder told to use it exits, and every hook of every session
229
+ // then fails. Setup is the upgrade and repair path, so it moves the project
230
+ // to a free port and lets install rewrite the hooks and entries with it —
231
+ // unless the port is busy because THIS project's recorder is on it.
232
+ const sidecar = readSidecar();
233
+ const recorded = projectRecord(sidecar, project);
234
+ if (recorded) {
235
+ // Age does not matter here, only a live pid: a recorder whose file went
236
+ // stale over a long sleep (or an older runner's, whose heartbeat is
237
+ // slower) still holds the port, and moving away from it would leave
238
+ // two recorders and hooks pointing at the new one.
239
+ const ours = readDiscovery(project, { ignoreAge: true });
240
+ const heldByUs = Boolean(ours && pidAlive(ours.pid) && ours.url.endsWith(`:${recorded.port}`));
241
+ if (!heldByUs && !(await probeFreePort(recorded.port))) {
242
+ const next = await allocateReplacementPort(sidecar, project, recorded.port);
243
+ if (next !== undefined) {
244
+ setProjectRecord(sidecar, project, { port: next });
245
+ writeSidecar(sidecar);
246
+ out(` port ${recorded.port} is not available on this machine; this project moves to ${next}`);
247
+ }
248
+ }
249
+ }
250
+ }
188
251
  if (args.allowServers !== undefined) {
189
252
  // Stored before install so the recorder started below reads it at start.
190
253
  const sidecar = readSidecar();
@@ -202,11 +265,17 @@ export async function runSetup(args, deps) {
202
265
  if (args.daemon) {
203
266
  out("==> Starting the recorder in the background");
204
267
  try {
205
- ensured = await ensureDaemon(project, {
268
+ // probeIdle: a recorder too old to say whether its "open" turn is real
269
+ // (0.2.12 never closes an interrupted one) is replaced when two /health
270
+ // probes a few seconds apart show it idle (control/daemon.ts). Setup has
271
+ // the time; the SessionStart hook does not ask for it.
272
+ const ensureOpts = {
206
273
  hooksScript: deps.hooksScript,
207
274
  env,
208
275
  restartOnSkew: true,
209
- });
276
+ probeIdle: true,
277
+ };
278
+ ensured = await ensureDaemon(project, ensureOpts);
210
279
  if (!ensured.started &&
211
280
  ensured.status.health.recording === false &&
212
281
  activeSessions(ensured.status.health) === 0) {
@@ -219,6 +288,36 @@ export async function runSetup(args, deps) {
219
288
  ensured = await ensureDaemon(project, { hooksScript: deps.hooksScript, env });
220
289
  ensured = { ...ensured, restarted: true };
221
290
  }
291
+ const port = projectRecord(readSidecar(), project)?.port;
292
+ if (port !== undefined &&
293
+ !ensured.status.info.url.endsWith(`:${port}`) &&
294
+ activeSessions(ensured.status.health) === 0) {
295
+ // A recorder still on the port this project had before (moved above,
296
+ // or by `--port`): the hooks now post to the new one, so it would
297
+ // receive nothing. Nothing is mid-run; replace it.
298
+ out(` the running recorder is on ${ensured.status.info.url}, not port ${port}; replacing it`);
299
+ await stopDaemon(project);
300
+ ensured = await ensureDaemon(project, { hooksScript: deps.hooksScript, env });
301
+ ensured = { ...ensured, restarted: true };
302
+ }
303
+ // The replay switch is read once, when a recorder starts: one started
304
+ // before this setup turned replay off (--no-replay) or on would go on
305
+ // doing what it did. `source: "env"` is BIR_REPLAY where it started,
306
+ // which outranks the switch — a restart from here would not carry it.
307
+ const wantReplay = projectRecord(readSidecar(), project)?.replay?.enabled !== false;
308
+ const running = ensured.status.health.replay;
309
+ if (typeof running?.enabled === "boolean" && running.enabled !== wantReplay && running.source !== "env") {
310
+ if (activeSessions(ensured.status.health) === 0) {
311
+ out(` the running recorder has replay ${running.enabled ? "on" : "off"}; replacing it`);
312
+ await stopDaemon(project);
313
+ ensured = await ensureDaemon(project, { hooksScript: deps.hooksScript, env });
314
+ ensured = { ...ensured, restarted: true };
315
+ }
316
+ else {
317
+ out(` the running recorder keeps replay ${running.enabled ? "on" : "off"} for the session open now; ` +
318
+ "`bir up --restart` once it ends applies the change");
319
+ }
320
+ }
222
321
  }
223
322
  catch (e) {
224
323
  err(`the recorder did not start: ${errText(e)}`);
@@ -227,40 +326,67 @@ export async function runSetup(args, deps) {
227
326
  }
228
327
  }
229
328
  // ── 6. the summary ───────────────────────────────────────────────────────
230
- const wrapped = resolveServers(project)
231
- .filter((s) => s.name !== SCENARIO_SERVER_KEY && isWrapped(s.config))
232
- .map((s) => s.name);
329
+ const live = resolveServers(project).filter((s) => s.name !== SCENARIO_SERVER_KEY);
330
+ const wrapped = live.filter((s) => isWrapped(s.config)).map((s) => s.name);
331
+ // Servers install left as Claude Code runs them (not approved there yet, a
332
+ // remote it signs in itself, …) are still here: "no MCP servers" would lie.
333
+ const unwrapped = live.filter((s) => !isWrapped(s.config)).map((s) => s.name);
233
334
  const health = ensured?.status.health;
234
335
  const replay = health?.replay;
235
336
  const recording = health ? health.recording !== false : undefined;
236
337
  const policy = projectRecord(readSidecar(), project)?.replay;
237
338
  const allow = replay?.allowServers ?? policy?.allowServers ?? null;
339
+ // The stored switch, which every recorder here reads at start — unless the
340
+ // one that answered was started with BIR_REPLAY, which outranks it.
341
+ const replayOn = replay?.source === "env" ? replay.enabled !== false : policy?.enabled !== false;
238
342
  out();
239
- out(`Done. Recording${args.replay ? " and replay are" : " is"} on for ${project}`);
343
+ out(`Done. Recording${args.replay && replayOn ? " and replay are" : " is"} on for ${project}`);
344
+ // The kill switch outranks all of it, and setup leaves it as the person set
345
+ // it: turning the runner back on is theirs to decide, not an upgrade's.
346
+ const off = offSource(project);
347
+ if (off) {
348
+ out(` !! but the runner is OFF (${off === "env" ? "BIR_OFF=1 in this environment" : off === "global" ? "`bir off`, every project" : "`bir off --here`"}): nothing is recorded or replayed until \`bir on${off === "project" ? " --here" : ""}\``);
349
+ }
240
350
  out(` account ${session.user.email} (${baseUrl})`);
241
- out(` wrapped ${wrapped.length ? wrapped.join(", ") : "(no MCP servers here)"}`);
351
+ out(` wrapped ${wrapped.length ? wrapped.join(", ") : unwrapped.length ? `(none — ${unwrapped.join(", ")} left as they are, see above)` : "(no MCP servers here)"}`);
242
352
  if (!args.replay) {
243
- out(" replay not installed (--no-replay)");
353
+ const scenarioLeft = resolveServers(project).some((s) => s.name === SCENARIO_SERVER_KEY && isScenarioServer(s.config));
354
+ out(replayOn
355
+ ? " replay ON despite --no-replay: BIR_REPLAY is set where the recorder started"
356
+ : ` replay off (--no-replay) — \`bir replay on\` turns it on${scenarioLeft ? "; the scenario server an earlier setup added stays (`bir uninstall --replay` removes it)" : ""}`);
357
+ }
358
+ else if (!replayOn) {
359
+ out(replay?.source === "env"
360
+ ? " replay off — BIR_REPLAY=0 where the recorder started"
361
+ : " replay off here (`bir replay off`) — `bir replay on` turns it on");
244
362
  }
245
363
  else if (wrapped.length === 0) {
246
- out(" replay steer mode only — no MCP servers wrapped here; add a .mcp.json and run `bir setup` again for direct replay");
364
+ out(unwrapped.length
365
+ ? " replay steer mode only — none of this project's MCP servers is wrapped (why is above); `bir setup` again once one can be, for direct replay"
366
+ : " replay steer mode only — no MCP servers wrapped here; add a .mcp.json and run `bir setup` again for direct replay");
247
367
  }
248
368
  else {
249
- const on = replay ? replay.enabled !== false : policy?.enabled !== false;
250
- out(` replay ${on ? "on" : "off"} — direct execution allowed for: ${allow?.length ? allow.join(", ") : `all wrapped (narrow it with \`bir replay allow ${wrapped[0]}\`)`}`);
369
+ out(` replay on — direct execution allowed for: ${allow?.length ? allow.join(", ") : `all wrapped (narrow it with \`bir replay allow ${wrapped[0]}\`)`}`);
251
370
  }
252
371
  if (ensured) {
253
372
  const { info, health: h } = ensured.status;
254
373
  const theirs = String(h.hooksVersion ?? info.version ?? "");
255
374
  out(` recorder ${ensured.started ? (ensured.restarted ? "restarted" : "started") : "already running"} in the background (pid ${info.pid})` +
256
375
  " — it starts itself with every Claude Code session");
257
- if (theirs && theirs !== packageVersion()) {
258
- out(` recorder ${theirs} is still serving an open Claude Code session; it restarts to ${packageVersion()} with the next session`);
376
+ // Only for an OLDER recorder: a newer one is kept on purpose
377
+ // (daemon.newer_kept) and nothing about it waits on a restart.
378
+ if (theirs && compareVersions(theirs, packageVersion()) === -1) {
379
+ // Said as it is: nothing replaces it by itself while that session runs —
380
+ // the next session's start keeps it too, as long as it reports a turn
381
+ // open — and until it is replaced it behaves as ${theirs} does (0.2.12:
382
+ // approves every wrapped MCP call, ignores `bir off`).
383
+ out(` it is still ${theirs}, serving a Claude Code session that is mid-turn; it keeps running as ${theirs} ` +
384
+ `until \`bir up --restart\` (run it once that session ends) replaces it with ${packageVersion()}`);
259
385
  }
260
386
  if (info.logFile)
261
387
  out(` log ${info.logFile}`);
262
388
  if (recording === false) {
263
- out(" !! the recorder has no session to record with — run `bir login`, then `bir up --restart`");
389
+ out(" !! the recorder has no session to record with — run `bir login`; it starts recording by itself after that");
264
390
  }
265
391
  }
266
392
  else {
@@ -280,7 +406,7 @@ export async function runSetup(args, deps) {
280
406
  out(' first time in this folder: say Yes to "trust this folder"');
281
407
  out(" just installed Claude Code? it asks you to sign in to Claude (it needs a Claude subscription)");
282
408
  out(` give it a task that takes 4+ tool calls — it appears ${console_ ? `at ${console_}/recordings` : "in the console's Recordings"} within a minute of finishing`);
283
- out("Any time: bir doctor bir investigate bir down (stop) bir uninstall (undo)");
409
+ out("Any time: bir doctor bir investigate bir off (out of the way, at once) bir uninstall (undo)");
284
410
  return recording === false ? 1 : 0;
285
411
  }
286
412
  //# sourceMappingURL=setup.js.map
@@ -7,11 +7,18 @@
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 { type McpServerConfig, type Scope } from "../resolve.js";
17
24
  export interface ClaudeCodePaths {
@@ -42,12 +49,21 @@ export declare const LOCAL_SETTINGS_EXCLUDE = ".claude/settings.local.json";
42
49
  * `.git/info/exclude`, never `.gitignore`: the first is the person's own,
43
50
  * untracked list; the second is a file the team owns and would see in a diff.
44
51
  * Walks up from `dir` to find the repository, follows a worktree's `gitdir:`
45
- * pointer, and appends the line once. Returns the exclude file's path when the
52
+ * pointer to the repository's common git directory (the only exclude file git
53
+ * reads for a linked worktree), and appends the line once. Returns the exclude file's path when the
46
54
  * line is there (added or already present), undefined outside a repository or
47
55
  * when the file could not be written — an exclude that fails is a note, not a
48
56
  * failed install.
49
57
  */
50
58
  export declare function ensureGitExclude(dir: string, line?: string): string | undefined;
59
+ /**
60
+ * {@link ensureGitExclude}, also saying whether the line was ADDED now (false:
61
+ * it was already there — somebody's own, which uninstall must leave alone).
62
+ */
63
+ export declare function ensureGitExcludeLine(dir: string, line?: string): {
64
+ path: string;
65
+ added: boolean;
66
+ } | undefined;
51
67
  /** Undo {@link ensureGitExclude}: drop the line, leave everything else. */
52
68
  export declare function removeGitExclude(dir: string, line?: string): string | undefined;
53
69
  /**
@@ -68,6 +84,28 @@ export declare function fileForScope(scope: Scope, paths: ClaudeCodePaths): stri
68
84
  export declare function sha256(text: string): string;
69
85
  /** Read a file's text, or null when it does not exist. */
70
86
  export declare function readTextOrNull(path: string): string | null;
87
+ /**
88
+ * Read-modify-write one JSON file, safely:
89
+ *
90
+ * - a file that is not plain JSON (comments, trailing commas, a torn write)
91
+ * is REFUSED with an error naming it, never rewritten from a guess;
92
+ * - the file is re-read just before the write, and the edit is applied again
93
+ * to the fresh text when somebody else changed it in between (Claude Code
94
+ * rewrites `~/.claude.json` constantly, and a lost update there loses a
95
+ * server the user just added, or ours);
96
+ * - the write itself is atomic (util/atomic-write.ts), so Claude Code never
97
+ * reads a half-written config, and a symlinked file stays a symlink.
98
+ *
99
+ * `mutate` gets a fresh parse each attempt and must not keep state between
100
+ * calls. Returns the text written, for the sidecar's hash.
101
+ */
102
+ export declare function editJson(path: string, mutate: (parsed: Record<string, unknown>) => void): string;
103
+ /**
104
+ * Throw (naming the file) when `path` exists and is not a plain JSON object —
105
+ * so an install can refuse up front, before it has written anything, instead
106
+ * of stopping halfway through with some files changed and others not.
107
+ */
108
+ export declare function assertEditableJson(path: string): void;
71
109
  /**
72
110
  * Set (or delete, with `entry === null`) one MCP server entry in `scope`.
73
111
  * Returns the file's new text so the caller can hash it for the sidecar.
@@ -76,6 +114,17 @@ export declare function setServerEntry(scope: Scope, cwd: string, name: string,
76
114
  file: string;
77
115
  text: string;
78
116
  };
117
+ /**
118
+ * Set (or delete) one entry of the local scope under each of `keys` in
119
+ * `~/.claude.json`, in ONE atomic edit. `shouldTouch` sees the entry currently
120
+ * under a key (or undefined) and may veto that key — so a user's own entry
121
+ * under another spelling of the project is never overwritten or deleted.
122
+ * Returns the text written and the keys actually changed (none: no write).
123
+ */
124
+ export declare function setLocalEntries(claudeJson: string, keys: string[], name: string, entry: McpServerConfig | null, shouldTouch?: (current: McpServerConfig | undefined) => boolean): {
125
+ text?: string;
126
+ keys: string[];
127
+ };
79
128
  /**
80
129
  * The hook events `bir-hooks` registers, and the control route each drives.
81
130
  *
@@ -84,6 +133,17 @@ export declare function setServerEntry(scope: Scope, cwd: string, name: string,
84
133
  * only listens to the success one records a step that never closes.
85
134
  * `SubagentStart` / `SubagentStop` carry `agent_id`, which is what lets a
86
135
  * subagent's steps be attributed rather than silently interleaved.
136
+ *
137
+ * THE TIMEOUTS ARE THE USER'S WORST CASE. Claude Code waits for a hung hook up
138
+ * to its `timeout`, so each number here is how long a slow or blackholed
139
+ * service could make a person wait — at every prompt, every tool call, every
140
+ * `/exit`. The control server keeps its own per-route deadlines well inside
141
+ * them (prompt ≤ 7 s, tool/pre ≤ 12 s, tool/post and stop ≤ 2 s, end ≤ 1 s,
142
+ * start ≤ 2 s) and answers `{}` on overrun, so these are the second line of
143
+ * defence, not a budget to spend. SessionEnd matters twice: Claude Code's own
144
+ * default for it is 1.5 s, and whatever is written here RAISES that — the 30 it
145
+ * used to be turned every `/exit` during a service incident into a 30 s wait.
146
+ * A re-run of `bir setup` rewrites existing installs with these.
87
147
  */
88
148
  export declare const HOOK_ROUTES: Array<{
89
149
  event: string;
@@ -121,8 +181,12 @@ export type HooksBlock = Record<string, Array<{
121
181
  * Exported so the binary and the adapter cannot drift apart on the spelling.
122
182
  */
123
183
  export declare const ENSURE_ARG = "ensure";
124
- /** How long SessionStart may wait for a background recorder to come up. */
125
- export declare const ENSURE_TIMEOUT_S = 30;
184
+ /**
185
+ * How long SessionStart may wait for a background recorder to come up. The
186
+ * ensure hook keeps its own budget at 12 s or less and always exits 0; this is
187
+ * the host's ceiling on top of it.
188
+ */
189
+ export declare const ENSURE_TIMEOUT_S = 15;
126
190
  export interface HooksBlockOptions {
127
191
  /**
128
192
  * Make SessionStart start the recorder itself when none is running.
@@ -134,27 +198,41 @@ export interface HooksBlockOptions {
134
198
  * **replaced**, not added to. Hooks for one event run in parallel, and an
135
199
  * HTTP hook racing the daemon it depends on would fail every first session.
136
200
  *
137
- * Omitted for an npx invocation: resolving a package inside a hook is too
138
- * slow to start a recorder from, so those installs keep the HTTP hook and a
139
- * recorder someone started.
201
+ * The `{ command, args }` form is an already-built exec-form invocation —
202
+ * what an npx install uses (`npx --prefer-offline -y -p <spec> bir-hooks
203
+ * ensure`, in `cmd /c` on Windows). It pays npx's start-up once per session,
204
+ * which is affordable there and not per tool call; without it nothing ever
205
+ * starts the recorder of an npx install, and every hook of every session
206
+ * fails against a port nobody listens on.
140
207
  */
141
208
  ensure?: {
142
209
  node: string;
143
210
  hooksScript: string;
144
211
  nodeFlags?: string[];
212
+ } | {
213
+ command: string;
214
+ args: string[];
145
215
  };
146
216
  }
147
217
  /** Build the 'hooks' block pointing at a control server. */
148
218
  export declare function buildHooksBlock(controlUrl: string, token: string, opts?: HooksBlockOptions): HooksBlock;
149
219
  /**
150
- * True when a hook entry is one of ours: an HTTP hook aimed at a control-server
151
- * route, or the exec-form command hook that starts the recorder.
220
+ * True when a hook entry is one of ours, and only then — install and uninstall
221
+ * delete what this says is ours, so a false positive deletes somebody's hook.
222
+ *
223
+ * HTTP: a loopback URL (127.0.0.1 / localhost / [::1]) whose path is exactly
224
+ * one of our routes, AND either our kind of bearer token or a port in
225
+ * the range `bir` allocates (the token-less 0.2.8 shape). A hook to
226
+ * `https://hooks.example.com/session/end` is somebody else's.
227
+ * exec: our self-starting SessionStart hook — `bir-hooks.js ensure`, or the
228
+ * npx form `… -p @basein/runner@<v> bir-hooks ensure`.
152
229
  */
153
230
  export declare function isBirHook(entry: unknown): boolean;
154
231
  /**
155
232
  * Merge our hooks into a settings file, leaving every other hook alone. Returns
156
233
  * the new text. Our own entries are replaced rather than appended, so repeated
157
- * installs stay idempotent.
234
+ * installs stay idempotent — hook by hook: a user's own hook that happens to
235
+ * share a matcher group with one of ours stays exactly where it is.
158
236
  */
159
237
  export declare function installHooks(settingsPath: string, block: HooksBlock): string;
160
238
  /** Remove only our hook entries, and drop events left empty. */