@basein/runner 0.2.7 → 0.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +64 -21
  2. package/dist/auth/client.d.ts +40 -1
  3. package/dist/auth/client.js +77 -9
  4. package/dist/bin/bir-hooks.d.ts +18 -3
  5. package/dist/bin/bir-hooks.js +124 -38
  6. package/dist/bin/bir.d.ts +2 -0
  7. package/dist/bin/bir.js +362 -39
  8. package/dist/bin/investigate.js +5 -1
  9. package/dist/bin/setup.d.ts +72 -0
  10. package/dist/bin/setup.js +286 -0
  11. package/dist/config/adapters/claude-code.d.ts +90 -4
  12. package/dist/config/adapters/claude-code.js +164 -16
  13. package/dist/config/generate.d.ts +93 -1
  14. package/dist/config/generate.js +90 -3
  15. package/dist/control/client.d.ts +5 -0
  16. package/dist/control/client.js +8 -0
  17. package/dist/control/daemon.d.ts +116 -0
  18. package/dist/control/daemon.js +339 -0
  19. package/dist/control/discovery.d.ts +26 -0
  20. package/dist/control/discovery.js +41 -9
  21. package/dist/control/ensure-hook.d.ts +39 -0
  22. package/dist/control/ensure-hook.js +98 -0
  23. package/dist/control/paths.d.ts +14 -0
  24. package/dist/control/paths.js +20 -0
  25. package/dist/control/server.d.ts +28 -0
  26. package/dist/control/server.js +22 -6
  27. package/dist/proxy/session.d.ts +8 -1
  28. package/dist/proxy/session.js +28 -6
  29. package/dist/replay/controller.d.ts +24 -1
  30. package/dist/replay/controller.js +76 -20
  31. package/dist/replay/handover.js +5 -0
  32. package/dist/replay/plan.d.ts +2 -0
  33. package/dist/replay/plan.js +53 -6
  34. package/dist/replay/pricing.d.ts +1 -1
  35. package/dist/replay/pricing.js +12 -4
  36. package/dist/replay/tool-error.d.ts +15 -0
  37. package/dist/replay/tool-error.js +17 -0
  38. package/dist/replay/types.d.ts +48 -1
  39. package/docs/calculatedReplayGuide.md +157 -68
  40. package/docs/installRun.md +457 -111
  41. package/docs/loginWeb.md +1 -1
  42. package/docs/quickstart.md +193 -158
  43. package/package.json +2 -1
  44. package/scripts/install.ps1 +669 -0
  45. package/scripts/install.sh +586 -0
package/dist/bin/bir.js CHANGED
@@ -2,6 +2,8 @@
2
2
  /**
3
3
  * bir — the command line (Phase 7).
4
4
  *
5
+ * bir setup [--auth-url <url>] [--token <t>] [--project <dir>] ← the one command
6
+ * bir up [--restart] | bir down
5
7
  * bir install [--config <path>] [--server <name>]… [--local] [--no-hooks]
6
8
  * bir uninstall [--config <path>]
7
9
  * bir status
@@ -18,17 +20,19 @@
18
20
  * from `serverInfo`, which must stay the upstream's (Phase 3).
19
21
  */
20
22
  import { randomBytes } from "node:crypto";
21
- import { existsSync, writeFileSync } from "node:fs";
23
+ import { existsSync, realpathSync, writeFileSync } from "node:fs";
22
24
  import { dirname, join, resolve as resolvePath } from "node:path";
23
25
  import { fileURLToPath } from "node:url";
24
26
  import { ControlClient } from "../control/client.js";
25
27
  import { readDiscovery } from "../control/discovery.js";
26
- import { configDir, discoveryPath } from "../control/paths.js";
27
- import { isScenarioServer, isWrapped, PACKAGE_NAME, readSidecar, scenarioEntry, sidecarKey, wrapEntry, writeSidecar, SCENARIO_SERVER_KEY, } from "../config/generate.js";
28
- import { isRemote, resolveServers } from "../config/resolve.js";
29
- import { buildHooksBlock, claudeCodePaths, fileForScope, installHooks, readTextOrNull, setServerEntry, sha256, uninstallHooks, } from "../config/adapters/claude-code.js";
28
+ import { ensureDaemon, stopDaemon } from "../control/daemon.js";
29
+ import { configDir, discoveryPath, normalizePath } from "../control/paths.js";
30
+ import { allocateFreeProjectPort, isScenarioServer, isWrapped, nodeFlagsToCarry, PACKAGE_NAME, packageSpec, projectRecord, readSidecar, scenarioEntry, setProjectRecord, sidecarKey, wrapEntry, writeSidecar, SCENARIO_SERVER_KEY, } from "../config/generate.js";
31
+ import { runSetup } from "./setup.js";
32
+ import { isRemote, parseJsonFile, resolveServers } from "../config/resolve.js";
33
+ import { buildHooksBlock, claudeCodePaths, disableMcpjsonServers, enableMcpjsonServers, ensureGitExclude, fileForScope, hasBirHooks, installHooks, isBirHook, LOCAL_SETTINGS_EXCLUDE, readTextOrNull, removeGitExclude, setServerEntry, sha256, uninstallHooks, } from "../config/adapters/claude-code.js";
30
34
  import { readGenericServers, setGenericServerEntry } from "../config/adapters/generic.js";
31
- import { AUTH_URL_HINT, DeviceFlowAborted, DeviceFlowUnsupported, authenticate, deviceLogin, describeAuthService, legacyPasswordLogin, tokenLogin, logout, normalizeAuthUrl, resolveAuthUrl, } from "../auth/client.js";
35
+ import { AUTH_URL_HINT, DeviceFlowAborted, DeviceFlowUnsupported, authenticate, deviceLogin, describeAuthService, legacyPasswordLogin, tokenLogin, logout, normalizeAuthUrl, rememberAuthUrl, resolveAuthUrl, } from "../auth/client.js";
32
36
  import { DEFAULT_CONTROL_PORT } from "../control/server.js";
33
37
  import { errText } from "../util/log.js";
34
38
  import { journalPath, readJournal } from "../util/journal.js";
@@ -43,6 +47,9 @@ function usage(code = 0) {
43
47
  out(`bir ${VERSION} — a recording MCP proxy
44
48
 
45
49
  Commands:
50
+ setup the one command: sign in, wire this project, start the recorder
51
+ up start the recorder for this project in the background (--restart: replace it)
52
+ down stop it
46
53
  install wrap this project's MCP servers and wire Claude Code's hooks
47
54
  uninstall restore everything install changed
48
55
  status what is installed for this directory
@@ -56,6 +63,8 @@ Commands:
56
63
  scenario calc <runId> [--force] calculate (or re-derive) a run's scenario
57
64
  scenario replay <scnId> --prompt "…" [--dry]
58
65
  replay --scenario <scnId> --prompt "…" [--dry]
66
+ replay status | on | off | allow <a,b> | allow all
67
+ this project's replay switches, kept for the background recorder
59
68
 
60
69
  investigate [<id>] why the newest turn here (or run_/scn_/sexec_ <id>) did what
61
70
  it did, what it cost, what to fix — journal + service
@@ -69,17 +78,24 @@ Options:
69
78
  --local invoke this checkout's bir-proxy instead of npx (development)
70
79
  --global invoke the installed package by absolute path instead of npx
71
80
  (no registry round trip, no PATH lookup — what a fleet wants)
72
- --no-hooks do not touch .claude/settings.json (Tier 2 recording only)
81
+ --no-hooks do not touch .claude/settings.local.json (Tier 2 recording only)
73
82
  --no-correlation never relax tool schemas; join steps on argument fingerprints
74
83
  --replay install/remove the scenario server, enabling calculated replay
75
- --port <n> control-server port to write into the hook URLs (default ${DEFAULT_CONTROL_PORT})
84
+ --port <n> install/setup: control-server port to write into the hook URLs
85
+ (default: this project's, else the lowest free one from ${DEFAULT_CONTROL_PORT})
76
86
  --json machine-readable output for status / doctor
77
87
  --dry replay against recorded outputs only; run no real tools
78
88
  --no-browser login: print the link and code, open nothing (SSH, headless)
79
89
  --token <value> login: redeem a one-time setup token from the console (no browser)
80
90
  --password login: use the old email/password prompt (deprecated)
81
91
  --user <ref> investigate executions: another account, by email or id (admin)
82
- --limit <n> investigate: how many turns / executions to show`);
92
+ --limit <n> investigate: how many turns / executions to show
93
+ --auth-url <url> setup: the BaseIn API address (else BIR_AUTH_URL, else the stored one)
94
+ --project <dir> setup: the directory to record (default: this one)
95
+ --no-replay setup: wire recording only; add no scenario server
96
+ --allow-servers <a,b> setup: servers replay may call unattended (default: all wrapped)
97
+ --no-daemon setup: wire everything, start nothing
98
+ --restart up: replace a running recorder`);
83
99
  process.exit(code);
84
100
  }
85
101
  function parseArgs(argv) {
@@ -98,6 +114,10 @@ function parseArgs(argv) {
98
114
  dry: false,
99
115
  force: false,
100
116
  password: false,
117
+ noReplay: false,
118
+ noDaemon: false,
119
+ restart: false,
120
+ portExplicit: false,
101
121
  };
102
122
  for (let i = 1; i < argv.length; i += 1) {
103
123
  const arg = argv[i];
@@ -153,6 +173,31 @@ function parseArgs(argv) {
153
173
  break;
154
174
  case "--port":
155
175
  args.port = Number(argv[++i]);
176
+ args.portExplicit = true;
177
+ break;
178
+ case "--auth-url":
179
+ args.authUrl = argv[++i];
180
+ if (!args.authUrl)
181
+ usage(2);
182
+ break;
183
+ case "--project":
184
+ args.project = argv[++i];
185
+ if (!args.project)
186
+ usage(2);
187
+ break;
188
+ case "--no-replay":
189
+ args.noReplay = true;
190
+ break;
191
+ case "--allow-servers":
192
+ args.allowServers = argv[++i];
193
+ if (args.allowServers === undefined)
194
+ usage(2);
195
+ break;
196
+ case "--no-daemon":
197
+ args.noDaemon = true;
198
+ break;
199
+ case "--restart":
200
+ args.restart = true;
156
201
  break;
157
202
  case "--json":
158
203
  args.json = true;
@@ -186,6 +231,27 @@ function localProxyPath() {
186
231
  function localScenarioPath() {
187
232
  return resolvePath(dirname(fileURLToPath(import.meta.url)), "bir-scenario.js");
188
233
  }
234
+ /** Absolute path to this package's built `bir-hooks.js`, for the SessionStart hook. */
235
+ function localHooksPath() {
236
+ return resolvePath(dirname(fileURLToPath(import.meta.url)), "bir-hooks.js");
237
+ }
238
+ /** True when this `bir` runs from an installed package rather than a checkout. */
239
+ function runsFromInstalledPackage() {
240
+ return /[\\/]node_modules[\\/]/.test(localProxyPath());
241
+ }
242
+ /**
243
+ * The node the SessionStart hook will run. The real path, not the one this
244
+ * process was started through: a version manager's per-shell shim directory
245
+ * is gone when that shell is, and a hook that names it fails every session.
246
+ */
247
+ function hookNodePath() {
248
+ try {
249
+ return realpathSync(process.execPath);
250
+ }
251
+ catch {
252
+ return process.execPath;
253
+ }
254
+ }
189
255
  /**
190
256
  * Which invocation the generated entries use — and the guard that makes
191
257
  * `--global` mean something.
@@ -199,11 +265,20 @@ function localScenarioPath() {
199
265
  * that works today and breaks after a `git clean`. Hence the node_modules test:
200
266
  * it is the one cheap signal that says "somebody installed this".
201
267
  */
202
- function resolveInvocation(args) {
268
+ function resolveInvocation(args, remembered) {
203
269
  if (args.local && args.global) {
204
270
  process.stderr.write("[bir] --local and --global are mutually exclusive\n");
205
271
  process.exit(2);
206
272
  }
273
+ // Neither flag: what this project was installed with last time. `bir setup`
274
+ // installs with --global, and a later bare `bir install --replay` here must
275
+ // not quietly turn every entry into a pinned-npx one and drop the
276
+ // self-starting SessionStart hook with it.
277
+ if (!args.global && !args.local && remembered) {
278
+ if (remembered === "global" && !runsFromInstalledPackage())
279
+ return "local";
280
+ return remembered;
281
+ }
207
282
  if (!args.global)
208
283
  return args.local ? "local" : "npx";
209
284
  const proxy = localProxyPath();
@@ -229,13 +304,20 @@ function noteWritten(sidecar, file, text) {
229
304
  sidecar.files[file] = backup;
230
305
  }
231
306
  // ── install ────────────────────────────────────────────────────────────────
232
- function install(args) {
233
- const invocation = resolveInvocation(args);
307
+ async function install(args) {
234
308
  const cwd = process.cwd();
235
309
  const sidecar = readSidecar();
310
+ const invocation = resolveInvocation(args, projectRecord(sidecar, cwd)?.invocation);
236
311
  sidecar.token ??= randomBytes(32).toString("hex");
237
- sidecar.controlPort = args.port;
238
- const controlUrl = `http://127.0.0.1:${args.port}`;
312
+ // One port per project (generate.ts): an explicit --port is honoured, else
313
+ // the one this directory already has, else the lowest free one no other has.
314
+ const port = args.portExplicit ? args.port : await allocateFreeProjectPort(sidecar, cwd);
315
+ sidecar.controlPort = port;
316
+ // One token per project too: a token in this project's settings file opens
317
+ // this project's recorder, not every recorder on the machine.
318
+ const token = projectRecord(sidecar, cwd)?.token ?? randomBytes(32).toString("hex");
319
+ setProjectRecord(sidecar, cwd, { port, token, invocation });
320
+ const controlUrl = `http://127.0.0.1:${port}`;
239
321
  const entries = args.configPath
240
322
  ? Object.entries(readGenericServers(args.configPath)).map(([name, config]) => ({
241
323
  name,
@@ -245,18 +327,52 @@ function install(args) {
245
327
  }))
246
328
  : resolveServers(cwd);
247
329
  const wanted = entries.filter((e) => args.servers.length === 0 || args.servers.includes(e.name));
248
- if (wanted.length === 0) {
330
+ if (wanted.length === 0 && (args.configPath || !args.hooks)) {
331
+ // An explicit config file, or proxies-only: with no server there is nothing
332
+ // this install could change.
249
333
  out("No MCP servers found for this directory. Nothing to wrap.");
250
334
  if (!args.configPath) {
251
335
  out(" Looked in: ~/.claude.json (user + local scopes) and ./.mcp.json (project scope).");
252
336
  }
253
337
  return 0;
254
338
  }
339
+ if (wanted.length === 0) {
340
+ // Hooks alone are a recorder: built-in tools, the prompt and the answer are
341
+ // all theirs, and a scenario over built-in steps steers a session the same
342
+ // way. Stopping here used to leave such a project with nothing at all.
343
+ out("No MCP servers to wrap here (looked in ~/.claude.json and ./.mcp.json).");
344
+ out(" Hooks are still wired: built-in tools and prompts are recorded.");
345
+ }
255
346
  const paths = claudeCodePaths(cwd);
347
+ /**
348
+ * Does a generated entry still name the runner that is running now? After
349
+ * an upgrade, a Node moved by a version manager, or a switch from the
350
+ * machine's npm prefix to the private one, the old absolute path (or the old
351
+ * pinned npx version) is what the host would keep spawning — and "already
352
+ * wrapped" is the last thing that should be printed about it.
353
+ */
354
+ const nodeFlags = nodeFlagsToCarry();
355
+ const stillCurrent = (config, scriptPath) => {
356
+ if (isRemote(config))
357
+ return false;
358
+ const argv = config.args ?? [];
359
+ if (invocation === "npx")
360
+ return config.command === "npx" && argv.includes(packageSpec(VERSION));
361
+ const at = argv.indexOf(scriptPath);
362
+ if (config.command !== process.execPath || at < 0)
363
+ return false;
364
+ // The node flags in front of the script must be the ones this run carries.
365
+ return argv.slice(0, at).join(" ") === nodeFlags.join(" ");
366
+ };
256
367
  let wrapped = 0;
257
368
  for (const entry of wanted) {
258
369
  if (entry.name === SCENARIO_SERVER_KEY && isScenarioServer(entry.config)) {
259
- out(` = ${entry.name} — the scenario server (calculated replay)`);
370
+ if (!args.replay || stillCurrent(entry.config, localScenarioPath())) {
371
+ out(` = ${entry.name} — the scenario server (calculated replay)`);
372
+ }
373
+ else {
374
+ out(` ~ ${entry.name} — re-writing the scenario server: its runner moved`);
375
+ }
260
376
  continue;
261
377
  }
262
378
  if (entry.name === SCENARIO_SERVER_KEY) {
@@ -277,15 +393,18 @@ function install(args) {
277
393
  // spawns), so `env` is present in practice; the cast is only to narrow the
278
394
  // remote-or-stdio union.
279
395
  const current = entry.config.env?.BIR_CONTROL_URL;
280
- const stale = args.hooks && !args.configPath && current !== controlUrl;
396
+ const urlStale = args.hooks && !args.configPath && current !== controlUrl;
397
+ const pathStale = !stillCurrent(entry.config, localProxyPath());
281
398
  const stashed = sidecar.servers[sidecarKey(cwd, entry.name)]?.original;
282
- if (!stale || !stashed) {
399
+ if (!(urlStale || pathStale) || !stashed) {
283
400
  out(` = ${entry.name} — already wrapped`);
284
401
  continue;
285
402
  }
403
+ const was = `${entry.config.command} ${(entry.config.args ?? [])[0] ?? ""}`.trim();
286
404
  entry.config = stashed;
287
- out(` ~ ${entry.name} — re-wrapping: its control URL was ` +
288
- `${current ?? "unset"}, now ${controlUrl}`);
405
+ out(urlStale
406
+ ? ` ~ ${entry.name} — re-wrapping: its control URL was ${current ?? "unset"}, now ${controlUrl}`
407
+ : ` ~ ${entry.name} — re-wrapping: its runner moved (was ${was})`);
289
408
  }
290
409
  // Phase 7.3: writing anywhere but the winning scope changes nothing.
291
410
  if (entry.shadowed.length > 0) {
@@ -299,6 +418,7 @@ function install(args) {
299
418
  invocation,
300
419
  proxyPath: invocation === "npx" ? undefined : localProxyPath(),
301
420
  version: VERSION,
421
+ nodeFlags,
302
422
  });
303
423
  let file;
304
424
  let text;
@@ -336,6 +456,7 @@ function install(args) {
336
456
  scenarioPath: invocation === "npx" ? undefined : localScenarioPath(),
337
457
  version: VERSION,
338
458
  controlUrl: args.hooks ? controlUrl : undefined,
459
+ nodeFlags,
339
460
  });
340
461
  // PROJECT SCOPE, NOT LOCAL — this was a bug worth the comment.
341
462
  //
@@ -373,17 +494,47 @@ function install(args) {
373
494
  };
374
495
  out(` + ${SCENARIO_SERVER_KEY} → bir-scenario (calculated replay; ${scope} scope)`);
375
496
  }
497
+ // SessionStart starts the recorder itself when this directory has none
498
+ // (control/ensure-hook.ts) — but only when the binary can be named by an
499
+ // absolute path. A pinned-npx install keeps the HTTP hook: resolving a
500
+ // package inside a hook is too slow to start a recorder from.
501
+ const ensure = invocation === "npx" ? undefined : { node: hookNodePath(), hooksScript: localHooksPath(), nodeFlags };
376
502
  if (args.hooks && !args.configPath) {
377
- backupFile(sidecar, paths.settings);
378
- const text = installHooks(paths.settings, buildHooksBlock(controlUrl, sidecar.token));
379
- noteWritten(sidecar, paths.settings, text);
380
- sidecar.hookFiles = [...new Set([...(sidecar.hookFiles ?? []), paths.settings])];
381
- out(` + hooks → ${paths.settings} (control server on ${controlUrl})`);
503
+ // Hooks live in `.claude/settings.local.json` — the person's own file, the
504
+ // one Claude Code keeps out of git — not in the `settings.json` a team
505
+ // commits. They carry a bearer token and an absolute path to this machine's
506
+ // node; a teammate's clone wants neither. An older install put them in the
507
+ // shared file; take them out of there on the way.
508
+ if (hasBirHooks(paths.settings)) {
509
+ backupFile(sidecar, paths.settings);
510
+ const cleaned = uninstallHooks(paths.settings);
511
+ if (cleaned !== undefined)
512
+ noteWritten(sidecar, paths.settings, cleaned);
513
+ out(` - hooks removed from ${paths.settings} (they live in settings.local.json now)`);
514
+ }
515
+ backupFile(sidecar, paths.settingsLocal);
516
+ let text = installHooks(paths.settingsLocal, buildHooksBlock(controlUrl, token, { ensure }));
517
+ if (args.replay) {
518
+ // Pre-approve our own scenario server. Claude Code asks before it uses a
519
+ // server from `.mcp.json`, and a "No" there leaves a direct replay with
520
+ // nowhere to deliver its results — and nothing to say so.
521
+ text = enableMcpjsonServers(paths.settingsLocal, [SCENARIO_SERVER_KEY]);
522
+ }
523
+ noteWritten(sidecar, paths.settingsLocal, text);
524
+ sidecar.hookFiles = [...new Set([...(sidecar.hookFiles ?? []), paths.settingsLocal])];
525
+ out(` + hooks → ${paths.settingsLocal} (control server on ${controlUrl}${ensure ? "; the recorder starts itself with each session" : ""})`);
526
+ const exclude = ensureGitExclude(cwd);
527
+ if (exclude)
528
+ out(` + ${LOCAL_SETTINGS_EXCLUDE} is listed in ${exclude}`);
382
529
  }
383
530
  writeSidecar(sidecar);
384
531
  out();
385
532
  out(`Wrapped ${wrapped} server${wrapped === 1 ? "" : "s"}.`);
386
- if (args.hooks && !args.configPath) {
533
+ if (args.hooks && !args.configPath && ensure) {
534
+ out("Next: start your session here as usual. The recorder starts itself with it;");
535
+ out("`bir up` starts it now, `bir down` stops it, `bir doctor` checks it.");
536
+ }
537
+ else if (args.hooks && !args.configPath) {
387
538
  out("Next: run `bir-hooks` in this directory, then start your session.");
388
539
  out("Without it, proxies record standalone (Tier 2): MCP calls only, no prompt.");
389
540
  }
@@ -392,8 +543,8 @@ function install(args) {
392
543
  }
393
544
  if (args.replay) {
394
545
  out();
395
- out("Calculated replay is INSTALLED and ON by default once `bir-hooks` runs. Set");
396
- out("BIR_REPLAY=0 where you run `bir-hooks` to turn it off — and read");
546
+ out("Calculated replay is INSTALLED and ON. `bir replay off` turns it off here, and");
547
+ out("`bir replay allow <servers>` narrows what it may call unattended — read");
397
548
  out("docs/calculatedReplayGuide.md §5.1 first: a replayed step is auto-approved,");
398
549
  out("and a directly executed one never reaches the permission system at all.");
399
550
  }
@@ -470,12 +621,24 @@ function uninstall(args) {
470
621
  continue;
471
622
  if (existsSync(settings)) {
472
623
  uninstallHooks(settings);
624
+ disableMcpjsonServers(settings, [SCENARIO_SERVER_KEY]);
473
625
  out(` ← hooks removed from ${settings}`);
474
626
  }
475
627
  }
628
+ // An install older than 0.2.9 wrote to the shared settings file; make sure
629
+ // nothing of ours is left there either, whether or not it was recorded.
630
+ if (!restoredWhole.has(paths.settings) && hasBirHooks(paths.settings)) {
631
+ uninstallHooks(paths.settings);
632
+ out(` ← hooks removed from ${paths.settings}`);
633
+ }
476
634
  sidecar.hookFiles = [];
477
635
  sidecar.files = {};
636
+ if (sidecar.projects)
637
+ delete sidecar.projects[normalizePath(cwd)];
478
638
  writeSidecar(sidecar);
639
+ const exclude = removeGitExclude(cwd);
640
+ if (exclude)
641
+ out(` ← ${LOCAL_SETTINGS_EXCLUDE} removed from ${exclude}`);
479
642
  out();
480
643
  out("Uninstalled.");
481
644
  return 0;
@@ -511,8 +674,12 @@ function status(args) {
511
674
  return 0;
512
675
  }
513
676
  out(`Directory : ${report.cwd}`);
514
- out(`BaseIn : ${report.authUrl ?? "(BIR_AUTH_URL not set — nothing is recorded)"}`);
515
- out(`Control : ${discovery ? `${discovery.url} (pid ${discovery.pid})` : "not running"}`);
677
+ out(`BaseIn : ${report.authUrl ?? "(not set — run `bir setup`, or set BIR_AUTH_URL; nothing is recorded)"}`);
678
+ out(`Control : ${discovery
679
+ ? `${discovery.url} (pid ${discovery.pid}${discovery.daemon ? "; background" : ""})`
680
+ : "not running — `bir up` starts it"}`);
681
+ if (discovery?.logFile)
682
+ out(`Log : ${discovery.logFile}`);
516
683
  out("Servers :");
517
684
  if (report.servers.length === 0)
518
685
  out(" (none configured for this directory)");
@@ -526,6 +693,7 @@ function status(args) {
526
693
  async function doctor(args) {
527
694
  const cwd = process.cwd();
528
695
  const problems = [];
696
+ const notes = [];
529
697
  const entries = resolveServers(cwd);
530
698
  const wantWrapped = entries.filter((e) => isWrapped(e.config)).map((e) => e.name);
531
699
  const discovery = readDiscovery(cwd);
@@ -536,13 +704,24 @@ async function doctor(args) {
536
704
  problems.push(`control server at ${discovery.url} did not answer /health`);
537
705
  }
538
706
  else {
539
- problems.push("no control server for this directory — recording will be Tier 2 (MCP only)");
707
+ problems.push("no control server for this directory — recording will be Tier 2 (MCP only); " +
708
+ "`bir up` starts one, and the SessionStart hook starts one with each session");
540
709
  }
541
710
  const registered = new Set((health?.registeredProxies ?? []).map((p) => p.serverName));
711
+ // A recorder with no session yet is the normal state right after `bir setup`
712
+ // and between sessions: the proxies are Claude Code's child processes, so
713
+ // there is nothing to register until a session starts. Reporting that as a
714
+ // problem made the first `bir doctor` a person ever ran exit non-zero on a
715
+ // machine where nothing was wrong.
716
+ const idleRecorder = Boolean(health) && (health?.sessions ?? []).length === 0;
542
717
  // The guarantee check: a *registered* proxy. Not the config file (which only
543
718
  // says what should happen) and not `serverInfo` (which stays the upstream's).
544
- for (const name of wantWrapped) {
545
- if (!registered.has(name)) {
719
+ const unregistered = wantWrapped.filter((name) => !registered.has(name));
720
+ if (unregistered.length > 0 && idleRecorder) {
721
+ notes.push(`no Claude Code session is running here yet; ${unregistered.join(", ")} will register when one starts`);
722
+ }
723
+ else {
724
+ for (const name of unregistered) {
546
725
  problems.push(`${name} is wrapped in config but no proxy has registered — is the session running?`);
547
726
  }
548
727
  }
@@ -558,14 +737,16 @@ async function doctor(args) {
558
737
  // its own `BIR_AUTH_URL` is not evidence about either, and treating it as
559
738
  // evidence produced a confident "nothing will be recorded" on a session that
560
739
  // was recording perfectly. Ask the recorder; it is the only process that knows.
561
- const notes = [];
562
740
  if (health) {
563
741
  if (health.recording === false) {
564
- problems.push("the recorder is running but has nowhere to send steps — set BIR_AUTH_URL " +
565
- "and run `bir login` in the terminal where you start `bir-hooks`");
742
+ problems.push("the recorder is running but has nowhere to send steps — run `bir setup` " +
743
+ "(or set BIR_AUTH_URL and `bir login`), then `bir up --restart`");
566
744
  }
567
745
  else {
568
- notes.push(`recording to ${String(health.authUrl ?? "the configured BaseIn service")}`);
746
+ notes.push(`recording to ${String(health.authUrl ?? "the configured BaseIn service")}${health.account ? ` as ${String(health.account)}` : ""}`);
747
+ }
748
+ if (discovery?.daemon) {
749
+ notes.push(`the recorder runs in the background (pid ${discovery.pid}); its audit log is ${discovery.logFile ?? "under ~/.baseinstrunner/logs"}`);
569
750
  }
570
751
  // Whether a recurring sub-task the service found may actually run mid-task
571
752
  // (segmented.md R-LIFE-8). Observe-only is the default and must be visible
@@ -599,6 +780,29 @@ async function doctor(args) {
599
780
  problems.push(`BIR_AUTH_URL in this shell (${shellAuthUrl}) is not a BaseIn service: ${shellAuthProblem}. ` +
600
781
  `\`bir login\` and \`bir scenario\` here will fail — ${AUTH_URL_HINT}`);
601
782
  }
783
+ // THE SESSIONSTART HOOK NAMES A NODE BY ABSOLUTE PATH. A version manager's
784
+ // per-shell directory, an uninstalled Node, a moved private Node: the hook
785
+ // then fails on every session and the recorder is never started, which
786
+ // records Tier 2 and looks like nothing at all.
787
+ const paths = claudeCodePaths(cwd);
788
+ for (const file of [paths.settingsLocal, paths.settings]) {
789
+ let parsed;
790
+ try {
791
+ parsed = parseJsonFile(file);
792
+ }
793
+ catch {
794
+ continue;
795
+ }
796
+ for (const matcher of parsed?.hooks?.SessionStart ?? []) {
797
+ for (const hook of matcher.hooks ?? []) {
798
+ if (hook.type !== "command" || !hook.command || !isBirHook(hook))
799
+ continue;
800
+ if (!existsSync(hook.command)) {
801
+ problems.push(`the SessionStart hook in ${file} points at a Node that is gone (${hook.command}) — run \`bir setup\` here again`);
802
+ }
803
+ }
804
+ }
805
+ }
602
806
  // THE SCENARIO SERVER IS ON THE CRITICAL PATH OF THE FIRST TURN. A plan armed
603
807
  // at `UserPromptSubmit` is delivered through `mcp__bir__run_scenario`, so a
604
808
  // `bir` entry that starts with `npx` has to resolve and unpack the package
@@ -633,7 +837,8 @@ async function doctor(args) {
633
837
  const replay = health?.replay;
634
838
  if (replay?.enabled) {
635
839
  out(`Replay : ON servers=${replay.allowServers?.join(",") || "(all wrapped)"} ` +
636
- `minSimilarity=${replay.minSimilarity ?? "?"}`);
840
+ `minSimilarity=${replay.minSimilarity ?? "?"}` +
841
+ (replay.source ? ` source=${replay.source}` : ""));
637
842
  // An older control server sends only `deriveKey`; read it as the two states
638
843
  // it could describe then.
639
844
  const via = replay.deriveVia ?? (replay.deriveKey ? "key" : "samples");
@@ -649,7 +854,7 @@ async function doctor(args) {
649
854
  "matched scenario with a target is declined and the agent does the task");
650
855
  }
651
856
  const idle = wantWrapped.filter((n) => !(replay.pollingProxies ?? []).includes(n));
652
- if (idle.length > 0) {
857
+ if (idle.length > 0 && !idleRecorder) {
653
858
  notes.push(`these proxies are not polling for replay work: ${idle.join(", ")} — ` +
654
859
  "their steps will fall back to recorded outputs");
655
860
  }
@@ -810,7 +1015,63 @@ async function scenarioCommand(args) {
810
1015
  * Without `--dry` the same plan runs against the live proxies through the
811
1016
  * control server — the Tier 2 and debugging path (docs/calculatedReplay.md §12.1).
812
1017
  */
1018
+ /**
1019
+ * `bir replay status | on | off | allow <a,b> | allow all` — this project's
1020
+ * replay switches, stored in the sidecar so a recorder started by the
1021
+ * SessionStart hook keeps them (generate.ts, `ReplayPolicy`).
1022
+ */
1023
+ function replayPolicyCommand(args) {
1024
+ const cwd = process.cwd();
1025
+ const sidecar = readSidecar();
1026
+ const record = projectRecord(sidecar, cwd);
1027
+ const word = args.positionals[0];
1028
+ const describe = (policy) => {
1029
+ out(`Replay policy for ${cwd}`);
1030
+ out(` replay ${policy?.enabled === false ? "off" : "on"}`);
1031
+ out(` direct servers ${policy?.allowServers?.length ? policy.allowServers.join(", ") : "(all wrapped)"}`);
1032
+ out(` minSimilarity ${policy?.minSimilarity ?? 0.92}`);
1033
+ const overrides = ["BIR_REPLAY", "BIR_REPLAY_ALLOW_SERVERS", "BIR_MIN_STEER_SIMILARITY"].filter((k) => process.env[k] !== undefined);
1034
+ if (overrides.length) {
1035
+ out(` (set in this shell's environment, which a recorder started from it would prefer: ${overrides.join(", ")})`);
1036
+ }
1037
+ if (!record)
1038
+ out(" (this directory has not been installed — `bir setup` here first)");
1039
+ out("Restart the recorder to apply a change: `bir up --restart`.");
1040
+ };
1041
+ if (word === "status") {
1042
+ describe(record?.replay);
1043
+ return 0;
1044
+ }
1045
+ if (!record) {
1046
+ out("This directory has not been installed — run `bir setup` (or `bir install --replay`) here first.");
1047
+ return 1;
1048
+ }
1049
+ const next = { ...(record.replay ?? {}) };
1050
+ if (word === "on")
1051
+ delete next.enabled;
1052
+ else if (word === "off")
1053
+ next.enabled = false;
1054
+ else if (word === "allow") {
1055
+ const list = args.positionals[1];
1056
+ if (!list) {
1057
+ out("usage: bir replay allow <server,server> | bir replay allow all");
1058
+ return 2;
1059
+ }
1060
+ if (list === "all")
1061
+ delete next.allowServers;
1062
+ else
1063
+ next.allowServers = list.split(",").map((s) => s.trim()).filter(Boolean);
1064
+ }
1065
+ setProjectRecord(sidecar, cwd, { port: record.port, replay: next });
1066
+ writeSidecar(sidecar);
1067
+ describe(next);
1068
+ return 0;
1069
+ }
813
1070
  async function replayCommand(args) {
1071
+ const word = args.positionals[0];
1072
+ if (word === "status" || word === "on" || word === "off" || word === "allow") {
1073
+ return replayPolicyCommand(args);
1074
+ }
814
1075
  const scenarioId = args.scenarioId ?? args.positionals[0];
815
1076
  if (!scenarioId || !args.prompt) {
816
1077
  out('usage: bir replay --scenario <scnId> --prompt "…" [--dry]');
@@ -972,6 +1233,65 @@ async function main() {
972
1233
  }
973
1234
  },
974
1235
  });
1236
+ case "setup": {
1237
+ // The invocation is decided here, not asked: an installed package gets
1238
+ // absolute paths (--global), a checkout points at itself (--local).
1239
+ const installed = runsFromInstalledPackage();
1240
+ return runSetup({
1241
+ authUrl: args.authUrl,
1242
+ token: args.token,
1243
+ project: args.project,
1244
+ browser: args.browser,
1245
+ replay: !args.noReplay,
1246
+ daemon: !args.noDaemon,
1247
+ allowServers: args.allowServers,
1248
+ }, {
1249
+ out,
1250
+ err: (line) => process.stderr.write(`[bir] ${line}\n`),
1251
+ install: ({ replay }) => install({ ...args, global: installed, local: !installed, replay, hooks: true, servers: [] }),
1252
+ hooksScript: localHooksPath(),
1253
+ });
1254
+ }
1255
+ case "up": {
1256
+ const cwd = process.cwd();
1257
+ if (args.restart)
1258
+ await stopDaemon(cwd);
1259
+ const { status, started, restarted } = await ensureDaemon(cwd, {
1260
+ hooksScript: localHooksPath(),
1261
+ restartOnSkew: true,
1262
+ });
1263
+ const { info } = status;
1264
+ out(`Recorder ${started ? (restarted ? "restarted" : "started") : "already running"} for ${cwd}`);
1265
+ out(` ${info.url} (pid ${info.pid}${info.daemon ? "; background" : "; in a terminal"})`);
1266
+ if (info.logFile)
1267
+ out(` log ${info.logFile}`);
1268
+ if (status.health.recording === false) {
1269
+ out(" !! it has no session to record with — run `bir setup` or `bir login`, then `bir up --restart`");
1270
+ return 1;
1271
+ }
1272
+ return 0;
1273
+ }
1274
+ case "down": {
1275
+ const cwd = process.cwd();
1276
+ const before = readDiscovery(cwd);
1277
+ if (!before) {
1278
+ out("No recorder is running for this directory.");
1279
+ return 0;
1280
+ }
1281
+ const result = await stopDaemon(cwd);
1282
+ if (result.stale) {
1283
+ out(`No recorder answered for this directory; a stale record (pid ${before.pid}) was removed.`);
1284
+ return 0;
1285
+ }
1286
+ if (!result.stopped) {
1287
+ out(`The recorder (pid ${before.pid}) did not stop.`);
1288
+ return 1;
1289
+ }
1290
+ out(result.forced
1291
+ ? `Recorder stopped (pid ${before.pid}; forced — it did not answer, so its last steps may be missing).`
1292
+ : `Recorder stopped (pid ${before.pid}).`);
1293
+ return 0;
1294
+ }
975
1295
  case "login": {
976
1296
  // Settle the URL the way every other command does, then refuse to go on
977
1297
  // when what is there is not the service. "Signed in" must mean the service
@@ -1041,6 +1361,9 @@ async function main() {
1041
1361
  }
1042
1362
  if (!session)
1043
1363
  return 1;
1364
+ // From here on no terminal needs BIR_AUTH_URL: the address a session was
1365
+ // just issued by is the one every process should use.
1366
+ rememberAuthUrl(baseUrl);
1044
1367
  out(`Signed in to ${baseUrl} as ${session.user.email}.`);
1045
1368
  return 0;
1046
1369
  }
@@ -179,7 +179,11 @@ export const DECLINES = {
179
179
  },
180
180
  known_bad_first_step: {
181
181
  cause: "the chain's first step is parked after repeated failures.",
182
- fix: "recalculate the scenario (`bir scenario calc <runId> --force`).",
182
+ fix: "wait: the service retries it once an hour for a day, then repairs the plan from the next run of the prompt. To repair now: `bir scenario calc <runId> --force`.",
183
+ },
184
+ nondeterministic_first_step: {
185
+ cause: "the chain's first step needs a judgement the calculation could not write code for, so the plan cannot start.",
186
+ fix: "the agent does this task itself; a model step for such judgements is planned (plan-services.md W3.1).",
183
187
  },
184
188
  unusable_first_step: {
185
189
  cause: "the first step calls a sub-task that is gone, switched off or stale.",