@basein/runner 0.3.25 → 0.3.27

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 (69) hide show
  1. package/README.md +27 -26
  2. package/dist/auth/client.d.ts +4 -1
  3. package/dist/auth/client.js +83 -14
  4. package/dist/bin/bir-hooks.js +4 -2
  5. package/dist/bin/bir.js +153 -37
  6. package/dist/bin/investigate.d.ts +13 -0
  7. package/dist/bin/investigate.js +72 -2
  8. package/dist/bin/scenario-edit.d.ts +5 -0
  9. package/dist/bin/scenario-edit.js +17 -1
  10. package/dist/config/adapters/claude-code.d.ts +51 -7
  11. package/dist/config/adapters/claude-code.js +105 -18
  12. package/dist/config/generate.d.ts +61 -13
  13. package/dist/config/generate.js +108 -20
  14. package/dist/config/resolve.d.ts +5 -0
  15. package/dist/config/resolve.js +7 -0
  16. package/dist/control/correlation.js +0 -0
  17. package/dist/control/daemon.d.ts +28 -0
  18. package/dist/control/daemon.js +62 -0
  19. package/dist/control/paths.d.ts +19 -1
  20. package/dist/control/paths.js +20 -2
  21. package/dist/control/pin-guard.js +8 -2
  22. package/dist/control/recorder-main.js +17 -5
  23. package/dist/control/server.d.ts +75 -11
  24. package/dist/control/server.js +440 -77
  25. package/dist/control/transcript.d.ts +64 -3
  26. package/dist/control/transcript.js +318 -37
  27. package/dist/jsonrpc/framing.d.ts +18 -3
  28. package/dist/jsonrpc/framing.js +57 -4
  29. package/dist/proxy/intercept.d.ts +4 -0
  30. package/dist/proxy/intercept.js +4 -0
  31. package/dist/proxy/relay.d.ts +13 -0
  32. package/dist/proxy/relay.js +12 -1
  33. package/dist/proxy/session.d.ts +8 -1
  34. package/dist/proxy/session.js +67 -10
  35. package/dist/record/recorder.d.ts +6 -1
  36. package/dist/record/redact.d.ts +18 -4
  37. package/dist/record/redact.js +538 -79
  38. package/dist/record/remote-recorder.d.ts +26 -5
  39. package/dist/record/remote-recorder.js +174 -18
  40. package/dist/record/truncate.js +3 -1
  41. package/dist/replay/bash-channel.d.ts +7 -1
  42. package/dist/replay/bash-channel.js +9 -3
  43. package/dist/replay/builtins.d.ts +14 -0
  44. package/dist/replay/builtins.js +460 -95
  45. package/dist/replay/bundle.d.ts +15 -11
  46. package/dist/replay/bundle.js +167 -33
  47. package/dist/replay/controller.d.ts +92 -1
  48. package/dist/replay/controller.js +246 -47
  49. package/dist/replay/derive.js +46 -4
  50. package/dist/replay/handover.d.ts +13 -0
  51. package/dist/replay/handover.js +18 -0
  52. package/dist/replay/plan.d.ts +41 -0
  53. package/dist/replay/plan.js +171 -3
  54. package/dist/replay/pricing.d.ts +61 -11
  55. package/dist/replay/pricing.js +135 -42
  56. package/dist/replay/reads-only.d.ts +48 -0
  57. package/dist/replay/reads-only.js +161 -0
  58. package/dist/replay/safety.js +43 -5
  59. package/dist/upstream/http-client.d.ts +6 -3
  60. package/dist/upstream/http-client.js +54 -13
  61. package/dist/upstream/stdio-client.d.ts +7 -0
  62. package/dist/upstream/stdio-client.js +1 -1
  63. package/dist/util/atomic-write.d.ts +9 -1
  64. package/dist/util/atomic-write.js +9 -6
  65. package/dist/util/service-health.d.ts +7 -0
  66. package/dist/util/service-health.js +10 -3
  67. package/package.json +1 -1
  68. package/production.md +2 -2
  69. package/scripts/install.ps1 +14 -2
package/dist/bin/bir.js CHANGED
@@ -46,9 +46,9 @@ import { ControlClient } from "../control/client.js";
46
46
  import { pidAlive, readDiscovery } from "../control/discovery.js";
47
47
  import * as daemonApi from "../control/daemon.js";
48
48
  import { activeSessions, daemonStatus, ensureDaemon, probeHealth, readPortProblem, stopDaemon, } from "../control/daemon.js";
49
- import { configDir, controlDir, controlKey, discoveryPath, installedPath, normalizePath } from "../control/paths.js";
49
+ import { configDir, controlDir, controlKey, DEFAULT_CONTROL_PORT, discoveryPath, installedPath, normalizePath } from "../control/paths.js";
50
50
  import { offFlagPath, offSource, projectOffFlagPath, setRunnerOff } from "../util/off-switch.js";
51
- import { allocateFreeProjectPort, backupIsCreation, backupOriginalText, configSha, envProxyHint, exemptLoopbackFromProxy, isNpxEntry, isScenarioServer, isWrapped, nodeFlagsToCarry, normaliseRecordKeys, npxHookInvocation, protectBackupText, recordKey, sameServerConfig, PACKAGE_NAME, projectRecord, readSidecar, redactSecrets, restoreSecrets, remoteWrapBlocker, scenarioEntry, editingEnabled, setProjectRecord, settleEditing, settleReplaySwitch, sidecarKey, unwrapEntry, UPSTREAM_HEADERS_ENV, wrapEntry, writeSidecar, SCENARIO_SERVER_KEY, } from "../config/generate.js";
51
+ import { allocateFreeProjectPort, backupIsCreation, backupOriginalText, configSha, envProxyHint, exemptLoopbackFromProxy, isNpxEntry, isScenarioServer, isWrapped, nodeFlagsToCarry, normaliseRecordKeys, npxHookInvocation, protectBackupText, recordKey, sameServerConfig, PACKAGE_NAME, projectRecord, readSidecar, redactSecrets, restoreSecrets, remoteWrapBlocker, scenarioEntry, editingEnabled, setProjectRecord, settleEditing, settleReplaySwitch, sidecarKey, sidecarReadError, stashStands, unwrapEntry, UPSTREAM_HEADERS_ENV, wrapEntry, writeSidecar, SCENARIO_SERVER_KEY, } from "../config/generate.js";
52
52
  import { chooseNodePath } from "../config/node-path.js";
53
53
  import { claudeUserSettingsPath, managedMcpPolicyBlocker, mcpjsonApprovals } from "../config/mcp-approval.js";
54
54
  import { checkOverrides, recordServerName } from "../config/overrides.js";
@@ -56,11 +56,10 @@ import { awaitWithin, isUnhealthyStatus, serviceHealth, serviceTimeoutMs, timeou
56
56
  import { runSetup } from "./setup.js";
57
57
  import { runUpdate } from "./update.js";
58
58
  import { claudeConfigHome, claudeProjectKey, claudeProjectRoot, isRemote, linkedWorktrees, parseJsonFile, projectKeyFor, projectKeysFor, projectScope, resolveServers, } from "../config/resolve.js";
59
- import { addAllowRules, allowRules, assertEditableJson, BIR_TOOLS_RULE, buildHooksBlock, claudeCodePaths, claudeCodeSetUp, disableMcpjsonServers, enableMcpjsonServers, ENSURE_ARG, ensureGitExcludeLine, hasBirHooks, installHooks, isBirHook, LOCAL_SETTINGS_EXCLUDE, otherHosts, readTextOrNull, removeAllowRules, removeGitExclude, setLocalEntries, setServerEntry, sha256, uninstallHooks, } from "../config/adapters/claude-code.js";
59
+ import { addAllowRules, allowRules, assertEditableJson, BIR_TOOLS_RULE, buildHooksBlock, claudeCodePaths, claudeCodeSetUp, disableMcpjsonServers, enableMcpjsonServers, ENSURE_ARG, ensureGitExcludeLine, hasBirHooks, installHooks, isBirHook, LOCAL_SETTINGS_EXCLUDE, otherHosts, readJsonOrUndefined, readTextOrNull, removeAllowRules, removeGitExclude, setLocalEntries, setServerEntry, sha256, uninstallHooks, UnreadableFileError, } from "../config/adapters/claude-code.js";
60
60
  import { writeFileAtomic } from "../util/atomic-write.js";
61
61
  import { readGenericServers, setGenericServerEntry } from "../config/adapters/generic.js";
62
62
  import { AUTH_URL_HINT, DeviceFlowAborted, DeviceFlowUnsupported, authenticate, clearCredentials, deviceLogin, describeAuthService, inspectAuthService, legacyPasswordLogin, tokenLogin, normalizeAuthUrl, rememberAuthUrl, resolveAuthUrl, } from "../auth/client.js";
63
- import { DEFAULT_CONTROL_PORT } from "../control/server.js";
64
63
  import { errText, logLine } from "../util/log.js";
65
64
  import { journalPath, readJournal } from "../util/journal.js";
66
65
  import { investigateCommand } from "./investigate.js";
@@ -287,10 +286,19 @@ function parseArgs(argv) {
287
286
  case "--no-correlation":
288
287
  args.noCorrelation = true;
289
288
  break;
290
- case "--port":
291
- args.port = Number(argv[++i]);
289
+ case "--port": {
290
+ // Digits, and a port a URL can name. `Number("abc")` and a forgotten
291
+ // value are both NaN, and install then wrote every hook as
292
+ // http://127.0.0.1:NaN/… — no URL at all, so every hook failed, and
293
+ // none was recognised as ours to take out again (isBirHook).
294
+ const raw = argv[++i] ?? "";
295
+ const port = Number(raw);
296
+ if (!/^\d+$/.test(raw) || port < 1 || port > 65535)
297
+ usage(2);
298
+ args.port = port;
292
299
  args.portExplicit = true;
293
300
  break;
301
+ }
294
302
  case "--auth-url":
295
303
  args.authUrl = argv[++i];
296
304
  if (!args.authUrl)
@@ -523,11 +531,16 @@ function isClaudeJson(file, claudeJson) {
523
531
  * a copy of it in installed.json was also a copy of every project's secrets.
524
532
  * Neither is a `--config` file (`entryOnly`), which is usually a client's one
525
533
  * global config, with every server's credentials in it. And a `.mcp.json`
526
- * (the `--mcp-json` shape) is kept without its env and header VALUES
527
- * (generate.ts, protectBackupText): uninstall reads them back from the file's
528
- * own entries, and restores it byte-for-byte only when that gives the original
529
- * exactly. A backup an older runner kept verbatim is converted the same way
530
- * the next time the file is touched.
534
+ * (the `--mcp-json` shape) is kept without its env and header VALUES, a
535
+ * settings file without the values of its own `env` block (generate.ts,
536
+ * protectBackupText): uninstall reads them back from the file as it is then,
537
+ * and restores it byte-for-byte only when that gives the original exactly. A
538
+ * backup an older runner kept verbatim is converted the same way the next
539
+ * time the file is touched.
540
+ *
541
+ * A file that is there and cannot be read throws (readTextOrNull). Taken for a
542
+ * missing one, it went on record as "created by install" — nothing to restore —
543
+ * while the write that followed replaced it.
531
544
  */
532
545
  function backupFile(sidecar, file, claudeJson, entryOnly = false) {
533
546
  sidecar.files ??= {};
@@ -616,6 +629,26 @@ function recordedOriginal(record, wrapped) {
616
629
  return record.original;
617
630
  return restoreSecrets(record.original, wrapped);
618
631
  }
632
+ /**
633
+ * The server a wrapped entry stands for: the original its record stashed,
634
+ * while the entry still says what that stash says (generate.ts, stashStands);
635
+ * otherwise what the entry itself says now. `record`: the one that describes
636
+ * this entry, if there is one.
637
+ *
638
+ * The stash used to win for good. A server changed after its first install —
639
+ * another script after `--`, a key rotated in the entry's env — was then put
640
+ * back as its first-install self by the next `bir setup`, which re-wrapped the
641
+ * stash over it ("re-wrapping: its runner moved"), and by `bir uninstall`.
642
+ * Kept while it stands, it still supplies what a wrapped entry cannot carry
643
+ * (`oauth`, `headersHelper`, `type: "stdio"`).
644
+ */
645
+ function wrappedOriginal(record, wrapped) {
646
+ const now = unwrapEntry(wrapped);
647
+ const stash = record ? recordedOriginal(record, wrapped) : undefined;
648
+ if (!stash || !now)
649
+ return stash ?? now;
650
+ return stashStands(stash, now) ? stash : now;
651
+ }
619
652
  /** Why an existing wrapped entry is being written again, for the log line. */
620
653
  function rewrapReason(current, next) {
621
654
  if (!current || isRemote(current) || !isWrapped(current))
@@ -665,10 +698,32 @@ function scenarioServerHere(args, cwd) {
665
698
  return false;
666
699
  }
667
700
  }
701
+ /**
702
+ * installed.json, for a command that is about to change what it describes —
703
+ * or a refusal. One that is there and cannot be read comes back empty
704
+ * (generate.ts, readSidecar): an install would then hand this project a new
705
+ * port and token and rewrite every hook and entry with them, an uninstall
706
+ * would find no record of anything, and neither could save what it had done.
707
+ * Both stop here instead, before their first write.
708
+ */
709
+ function readSidecarOrRefuse() {
710
+ const sidecar = readSidecar();
711
+ const unread = sidecarReadError(sidecar);
712
+ if (unread) {
713
+ throw new UnreadableFileError(`${unread}; nothing was changed. Make it readable (its permissions, or the program that holds it open), then run this again.`);
714
+ }
715
+ return sidecar;
716
+ }
668
717
  // ── install ────────────────────────────────────────────────────────────────
669
718
  async function install(args) {
670
719
  const cwd = process.cwd();
671
- const sidecar = readSidecar();
720
+ // `--config` as a full path from here on. It goes on record, and `bir
721
+ // uninstall --all` reads that record from whichever directory it is run in:
722
+ // kept as typed, `client.json` was looked for there, reported as gone, and
723
+ // the real file stayed wrapped behind an "Uninstalled".
724
+ if (args.configPath)
725
+ args = { ...args, configPath: resolvePath(args.configPath) };
726
+ const sidecar = readSidecarOrRefuse();
672
727
  const before = projectRecord(sidecar, cwd);
673
728
  const invocation = resolveInvocation(args, before?.invocation, before?.invocationChosen);
674
729
  const flagged = args.local || args.global || args.npx;
@@ -696,7 +751,8 @@ async function install(args) {
696
751
  }
697
752
  // REFUSE BEFORE WRITING ANYTHING. A config with comments or a trailing comma
698
753
  // is not JSON; rewriting it would drop the comments (or, worse, the file).
699
- // Checking every file up front means a refusal leaves nothing half-done.
754
+ // One that is there and cannot be read would be replaced outright. Checking
755
+ // every file up front means a refusal leaves nothing half-done.
700
756
  try {
701
757
  if (args.configPath)
702
758
  assertEditableJson(args.configPath);
@@ -708,8 +764,10 @@ async function install(args) {
708
764
  }
709
765
  }
710
766
  catch (err) {
711
- process.stderr.write(`[bir] ${errText(err)}\n` +
712
- "[bir] refusing to rewrite it: fix or remove the comments/trailing commas, then run this again. Nothing was changed.\n");
767
+ const fix = err instanceof UnreadableFileError
768
+ ? "make it readable (its permissions, or the program that holds it open)"
769
+ : "fix or remove the comments/trailing commas";
770
+ process.stderr.write(`[bir] ${errText(err)}\n[bir] refusing to rewrite it: ${fix}, then run this again. Nothing was changed.\n`);
713
771
  return 1;
714
772
  }
715
773
  // An uninstall that found this project's recorder still serving an open
@@ -951,7 +1009,7 @@ function installExplicit(args, cwd, sidecar, gen, named, claudeJson) {
951
1009
  const found = findRecord(sidecar, cwd, name);
952
1010
  let source = config;
953
1011
  if (isWrapped(config)) {
954
- source = (found ? recordedOriginal(found.record, config) : undefined) ?? unwrapEntry(config);
1012
+ source = wrappedOriginal(found?.record, config);
955
1013
  if (!source) {
956
1014
  out(` = ${name} — already wrapped`);
957
1015
  continue;
@@ -982,7 +1040,9 @@ function installExplicit(args, cwd, sidecar, gen, named, claudeJson) {
982
1040
  const reason = rewrapReason(config, proxied);
983
1041
  backupFile(sidecar, file, claudeJson, true);
984
1042
  noteWritten(sidecar, file, setGenericServerEntry(file, name, proxied), claudeJson, true);
985
- const kept = redactSecrets(found?.record.original ?? source);
1043
+ // What was just wrapped, never an earlier install's stash: the entry may
1044
+ // have been replaced by another server since (see wrappedOriginal).
1045
+ const kept = redactSecrets(source);
986
1046
  putRecord(sidecar, cwd, name, found, {
987
1047
  original: kept.config,
988
1048
  scope: "explicit",
@@ -1133,26 +1193,42 @@ function installClaudeCode(args, cwd, sidecar, gen, named, paths, mcpJsonMode) {
1133
1193
  let source;
1134
1194
  let target;
1135
1195
  const unwrapped = (c) => c !== undefined && isWrapped(c) ? unwrapEntry(c) : c;
1136
- if (L !== undefined && isWrapped(L) && found?.record.override) {
1196
+ // The record that describes the local entry, if one does: a record of
1197
+ // another scope (an older install's, a `--config` file's) says nothing
1198
+ // about it.
1199
+ const own = found?.record.scope === "local" ? found.record : undefined;
1200
+ // A wrapped local entry is a COPY of the server under it when its record
1201
+ // says so — and, with no record of it (installed.json lost, or
1202
+ // ~/.baseinstrunner deleted), when it still says exactly what that server
1203
+ // says. Without the second half `bir setup` took a record-less copy for
1204
+ // the person's own server and froze the team's definition into its stash:
1205
+ // no later change to `.mcp.json` reached this project, and uninstall left
1206
+ // a plain copy shadowing the team's entry for good. The same comparison
1207
+ // uninstall makes for an entry no record explains (uninstallProject).
1208
+ const copy = L !== undefined && isWrapped(L) && (own ? own.override !== undefined : sameServerConfig(unwrapEntry(L), unwrapped(P ?? U)));
1209
+ if (copy) {
1137
1210
  source = unwrapped(P ?? U);
1138
1211
  target = { kind: "override", from: P !== undefined ? "project" : "user", keys: [...new Set([...overrideKeys, ...holders])] };
1139
1212
  if (source === undefined) {
1140
1213
  // The server it overrode is gone from .mcp.json and the user scope.
1141
1214
  setLocalEntries(claudeJson, target.keys, name, null, (cur) => cur !== undefined && isWrapped(cur));
1142
- delete sidecar.servers[found.key];
1215
+ if (found)
1216
+ delete sidecar.servers[found.key];
1143
1217
  out(` - ${name} — its local copy is removed: the server is no longer configured anywhere else`);
1144
1218
  continue;
1145
1219
  }
1146
1220
  }
1147
1221
  else if (L !== undefined) {
1148
- source = isWrapped(L)
1149
- ? (found?.record.scope === "local" ? recordedOriginal(found.record, L) : undefined) ?? unwrapEntry(L)
1150
- : L;
1222
+ source = isWrapped(L) ? wrappedOriginal(own, L) : L;
1151
1223
  target = { kind: "inplace", keys: holders };
1152
1224
  }
1225
+ else if (P !== undefined && mcpJsonMode) {
1226
+ source = isWrapped(P) ? wrappedOriginal(found?.record.scope === "project" ? found.record : undefined, P) : P;
1227
+ target = { kind: "mcpjson" };
1228
+ }
1153
1229
  else if (P !== undefined) {
1154
1230
  source = unwrapped(P);
1155
- target = mcpJsonMode ? { kind: "mcpjson" } : { kind: "override", from: "project", keys: overrideKeys };
1231
+ target = { kind: "override", from: "project", keys: overrideKeys };
1156
1232
  }
1157
1233
  else {
1158
1234
  source = unwrapped(U);
@@ -1247,7 +1323,9 @@ function installClaudeCode(args, cwd, sidecar, gen, named, paths, mcpJsonMode) {
1247
1323
  }
1248
1324
  backupFile(sidecar, paths.mcpJson, claudeJson);
1249
1325
  noteWritten(sidecar, paths.mcpJson, setServerEntry("project", cwd, name, proxied, paths).text, claudeJson);
1250
- const kept = redactSecrets((found?.record.scope === "project" ? found.record.original : undefined) ?? source);
1326
+ // The stash is what was just wrapped (see wrappedOriginal), not what an
1327
+ // earlier install found here.
1328
+ const kept = redactSecrets(source);
1251
1329
  putRecord(sidecar, cwd, name, found, {
1252
1330
  original: kept.config,
1253
1331
  scope: "project",
@@ -1283,7 +1361,10 @@ function installClaudeCode(args, cwd, sidecar, gen, named, paths, mcpJsonMode) {
1283
1361
  });
1284
1362
  }
1285
1363
  else {
1286
- const kept = redactSecrets((found?.record.scope === "local" ? found.record.original : undefined) ?? source);
1364
+ // The stash is what is wrapped now. Kept from the first install for
1365
+ // good, it was written back over a server the person had since
1366
+ // replaced: by the next setup, and by uninstall (see wrappedOriginal).
1367
+ const kept = redactSecrets(source);
1287
1368
  putRecord(sidecar, cwd, name, found, {
1288
1369
  original: kept.config,
1289
1370
  scope: "local",
@@ -1607,7 +1688,9 @@ function runnerLeftovers(cwd, sidecar, configPath, otherClaude = []) {
1607
1688
  for (const { file, other } of claudeFiles) {
1608
1689
  let claude = {};
1609
1690
  try {
1610
- claude = parseJsonFile(file) ?? {};
1691
+ // Strict: one that is there and cannot be read is listed as such, never
1692
+ // read as a file with nothing of ours in it.
1693
+ claude = readJsonOrUndefined(file) ?? {};
1611
1694
  }
1612
1695
  catch (err) {
1613
1696
  unreadable.push(`${file} (${errText(err)})`);
@@ -1634,7 +1717,7 @@ function runnerLeftovers(cwd, sidecar, configPath, otherClaude = []) {
1634
1717
  }
1635
1718
  }
1636
1719
  try {
1637
- const mcp = parseJsonFile(paths.mcpJson)?.mcpServers;
1720
+ const mcp = readJsonOrUndefined(paths.mcpJson)?.mcpServers;
1638
1721
  for (const [name, config] of Object.entries(mcp ?? {})) {
1639
1722
  if (ourEntry(config))
1640
1723
  found.push({ name, scope: "project", config, file: paths.mcpJson });
@@ -1690,6 +1773,14 @@ function uninstallProject(cwd, sidecar, opts) {
1690
1773
  const here = normalizePath(cwd);
1691
1774
  const narrow = opts.servers.length > 0;
1692
1775
  const wanted = (name) => !narrow || opts.servers.includes(name);
1776
+ // A `--config` file that an install before this one recorded as it was
1777
+ // typed: relative to the directory that install ran in, not to the one this
1778
+ // runs in (`bir uninstall --all` runs anywhere).
1779
+ for (const record of Object.values(sidecar.servers)) {
1780
+ if (record.scope === "explicit" && typeof record.file === "string" && !isAbsolute(record.file)) {
1781
+ record.file = resolvePath(record.cwd, record.file);
1782
+ }
1783
+ }
1693
1784
  const mine = Object.entries(sidecar.servers)
1694
1785
  .filter(([, record]) => normalizePath(record.cwd) === here)
1695
1786
  .map(([key, record]) => ({ key, record, name: recordName(key, record) }));
@@ -1705,7 +1796,10 @@ function uninstallProject(cwd, sidecar, opts) {
1705
1796
  const claudeFileOf = (record) => (record.scope === "local" || record.scope === "user") && typeof record.file === "string" && record.file
1706
1797
  ? record.file
1707
1798
  : claudeJson;
1708
- const readClaude = (file = claudeJson) => parseJsonFile(file) ?? {};
1799
+ // Strict, as every read on the way to an undo: a file that is there and
1800
+ // cannot be read throws, and the entry it holds is reported as left, not
1801
+ // as "changed since install" with its record dropped.
1802
+ const readClaude = (file = claudeJson) => readJsonOrUndefined(file) ?? {};
1709
1803
  const currentEntry = (record, name) => {
1710
1804
  if (record.scope === "explicit")
1711
1805
  return readGenericServers(record.file)[name];
@@ -1720,7 +1814,7 @@ function uninstallProject(cwd, sidecar, opts) {
1720
1814
  }
1721
1815
  if (record.scope === "user")
1722
1816
  return readClaude(claudeFileOf(record)).mcpServers?.[name];
1723
- return parseJsonFile(record.file)?.mcpServers?.[name];
1817
+ return readJsonOrUndefined(record.file)?.mcpServers?.[name];
1724
1818
  };
1725
1819
  /** Put one entry back (or take it out), touching it only while it is still ours. */
1726
1820
  const repair = (record, name) => {
@@ -1736,9 +1830,11 @@ function uninstallProject(cwd, sidecar, opts) {
1736
1830
  const now = currentEntry(record, name);
1737
1831
  if (!ourEntry(now))
1738
1832
  return { what: `left as it is (changed since install)${where}`, done: true };
1739
- // The stash first; else the original rebuilt from the wrapped entry's own
1740
- // arguments. An entry left wrapped breaks the day the runner is removed.
1741
- const restored = record.original === null ? null : (recordedOriginal(record, now) ?? unwrapEntry(now));
1833
+ // The stash, while the wrapped entry still says what it says; else the
1834
+ // original rebuilt from the entry's own arguments (wrappedOriginal) — which
1835
+ // is also what a server changed inside its wrapped entry comes back as.
1836
+ // An entry left wrapped breaks the day the runner is removed.
1837
+ const restored = record.original === null ? null : wrappedOriginal(record, now);
1742
1838
  if (restored === undefined)
1743
1839
  return { what: `left as it is (its original could not be rebuilt)${where}`, done: false };
1744
1840
  if (record.scope === "explicit")
@@ -1786,7 +1882,17 @@ function uninstallProject(cwd, sidecar, opts) {
1786
1882
  for (const [file, backup] of Object.entries(sidecar.files ?? {})) {
1787
1883
  if (isClaudeJson(file, claudeJson) || othersFiles.has(normalizePath(file)) || !ownsFile(file))
1788
1884
  continue;
1789
- const current = readTextOrNull(file);
1885
+ let current;
1886
+ try {
1887
+ current = readTextOrNull(file);
1888
+ }
1889
+ catch (err) {
1890
+ // There and not readable: not restored, and not done. The steps below
1891
+ // repair its entries if they can, and this keeps the snapshot and
1892
+ // the records for the next run, which may find it readable.
1893
+ remaining.set(`read:${file}`, `${file} could not be read, so it was not restored (${errText(err)})`);
1894
+ continue;
1895
+ }
1790
1896
  if (current === null || !backup.writtenSha || backup.stale || sha256(current) !== backup.writtenSha)
1791
1897
  continue;
1792
1898
  if (backupIsCreation(backup)) {
@@ -1935,7 +2041,17 @@ function uninstallProject(cwd, sidecar, opts) {
1935
2041
  if (!narrow) {
1936
2042
  for (const file of [paths.settingsLocal, paths.settings]) {
1937
2043
  const key = `hooks:${normalizePath(file)}`;
1938
- if (hasBirHooks(file) && !remaining.has(key))
2044
+ if (remaining.has(key))
2045
+ continue;
2046
+ try {
2047
+ readTextOrNull(file);
2048
+ }
2049
+ catch (err) {
2050
+ // hasBirHooks reads such a file as one without hooks.
2051
+ remaining.set(key, `${file} could not be read, so hooks of ours may still be in it (${errText(err)})`);
2052
+ continue;
2053
+ }
2054
+ if (hasBirHooks(file))
1939
2055
  remaining.set(key, `hooks of ours are still in ${file}`);
1940
2056
  }
1941
2057
  }
@@ -2100,7 +2216,7 @@ async function uninstall(args) {
2100
2216
  if (args.all)
2101
2217
  return uninstallAll(args);
2102
2218
  const cwd = process.cwd();
2103
- const sidecar = readSidecar();
2219
+ const sidecar = readSidecarOrRefuse();
2104
2220
  // `--replay` on its own is a narrow removal: take the scenario server out and
2105
2221
  // leave every proxy and the hooks exactly as they are. Somebody turning replay
2106
2222
  // off should not have to reinstall recording afterwards.
@@ -2365,7 +2481,7 @@ async function uninstallAll(args) {
2365
2481
  process.stderr.write("[bir] --all removes everything; it takes no --server, --replay or --config\n");
2366
2482
  return 2;
2367
2483
  }
2368
- const sidecar = readSidecar();
2484
+ const sidecar = readSidecarOrRefuse();
2369
2485
  const paths = claudeCodePaths(process.cwd());
2370
2486
  const projects = installedProjects(sidecar);
2371
2487
  // Every `.claude.json` an install wrote to: this environment's, and any other
@@ -2830,7 +2946,7 @@ function hookFacts(paths) {
2830
2946
  facts.ports.push(port);
2831
2947
  }
2832
2948
  catch {
2833
- /* isBirHook parsed it already */
2949
+ /* ours, and no URL (isBirHook: a `--port` that was not one): it names no port */
2834
2950
  }
2835
2951
  }
2836
2952
  }
@@ -102,6 +102,19 @@ export interface Turn {
102
102
  of?: string;
103
103
  reason?: string;
104
104
  };
105
+ /**
106
+ * The plan was retired before any step ran (`replay.vetoed`): the model
107
+ * changed something itself first, or its `run_scenario` never reached the
108
+ * runner. `code` from runners after 0.3.26; the reason text always.
109
+ */
110
+ vetoed?: {
111
+ why?: string;
112
+ code?: string;
113
+ };
114
+ /** A direct plan waited through a call that only reads (`replay.direct_waits`, B2 is11.md). */
115
+ waited?: {
116
+ tool?: string;
117
+ };
105
118
  /** `replay.*_failed`, `replay.arm_failed`, `replay.diverge`, `replay.handover` … */
106
119
  incidents: JournalEntry[];
107
120
  events: JournalEntry[];
@@ -164,6 +164,12 @@ export function turnsFromJournal(entries) {
164
164
  case "run.repeat":
165
165
  turn.repeat = { runId: str(e.run), of: str(e.of), reason: str(e.reason) };
166
166
  break;
167
+ case "replay.vetoed":
168
+ turn.vetoed ??= { why: str(e.why), code: str(e.code) };
169
+ break;
170
+ case "replay.direct_waits":
171
+ turn.waited ??= { tool: str(e.tool) };
172
+ break;
167
173
  case "run.finish":
168
174
  turn.finishedAt = e.at;
169
175
  turn.finish = {
@@ -241,6 +247,11 @@ export const DECLINES = {
241
247
  cause: "the plan was not ready before the hook that would have announced it had to answer: the prompt hook waits 7.5 s in all, match included, and a tool call that starts a plan mid-task waits 1.5 s. What arming waits for is the reading of the prompt's values (the derivation). A plan that was not announced is never run.",
242
248
  fix: "run the prompt again. If it keeps happening the reading is slow: the recorder's log (`bir status` names the file) shows when its `replay.derived` line landed, and a dry replay shows what the service's reading took, under `reading` (`bir scenario replay <scnId> --prompt \"…\" --dry --json`). BIR_HOOK_DERIVE_BUDGET_MS does not help here, because the hook's own limit was reached first. To read values on this machine instead of asking the service, set BIR_DERIVE_API_KEY where Claude Code starts.",
243
249
  },
250
+ // The control server's verdict too: the same plan was cancelled a moment ago.
251
+ after_cancel: {
252
+ cause: "the same plan was cancelled a few minutes earlier (Esc on run_scenario, or its caller timed out) after some of its steps had run, so it was not started again: those steps had done their work, and running them twice would add a second row or send a second mail. The agent was told which steps ran, and did the task.",
253
+ fix: "nothing to do. Ask again after ten minutes if you want the plan to run from its first step. The recorder's log shows the earlier run's `replay.cancelled` line.",
254
+ },
244
255
  // Nor is this one: the match itself was late, so nothing was armed at all.
245
256
  match_late: {
246
257
  cause: "the service's answer to the match landed too late: after the prompt hook had stopped waiting for it (3 s, BIR_MATCH_BUDGET_MS); while prompts were not waiting on the match at all — after one slow answer they stop waiting for a few minutes (`replay.match_gate state=holding`); or with the hook not waiting from the start, because a failure to reach the service was still on record (a `service.degraded` line, perhaps hours before, and no `service.recovered` until after this prompt) or the match request's first attempt failed. The agent did the task itself.",
@@ -335,6 +346,46 @@ function readingDecline(code, why) {
335
346
  }
336
347
  return undefined;
337
348
  }
349
+ /**
350
+ * Why a plan that armed ran no step (`replay.vetoed`). The recording page
351
+ * once said "the runner did not say why" of such a turn, and guessed wrong
352
+ * (B2 is11.md): the runner had said, here, and only here.
353
+ */
354
+ function vetoFinding(turn, v) {
355
+ const why = (v.why ?? "").replace(/ — the plan is retired without running any step$/, "");
356
+ const scenarioId = turn.plan?.scenarioId ?? turn.matched?.scenarioId ?? "<scnId>";
357
+ if (v.code === "run_refused" || (!v.code && /\brun_scenario (was called|failed|answered)\b/.test(why))) {
358
+ return {
359
+ code: "vetoed_run_refused",
360
+ severity: "info",
361
+ title: `The plan was not run: ${why}.`,
362
+ cause: "run_scenario was refused — by the person, or by a deny rule — or it failed before it reached the runner. Refused means refused: the plan ran no step, and the agent did the task.",
363
+ fix: "nothing, if it was refused on purpose. If it failed: `bir doctor` shows whether the bir server runs here, and `bir setup` lets Claude Code run it without asking.",
364
+ evidence: { code: v.code ?? null },
365
+ };
366
+ }
367
+ const tool = /\bfirst call (?:that may change something )?was (\S+), not run_scenario\b/.exec(why)?.[1];
368
+ if (!v.code) {
369
+ // Runners up to 0.3.26: any first call retired the plan, and no reason
370
+ // went with the report.
371
+ return {
372
+ code: "vetoed_first_call",
373
+ severity: "warn",
374
+ title: `The plan armed, and was not run: ${why}.`,
375
+ cause: "this runner retires a direct plan at the agent's first call that is not run_scenario, whatever it is — a file read included. A careful agent looks before it runs a plan that writes, the more so when the note names the recording's values where this request has others (another person, another date), and the replay is lost for it: the steps would have taken this request's values.",
376
+ fix: `update the runner when a version after 0.3.26 is out (\`bir update\`): it keeps the plan through calls that only read, and its note names this request's values in place of the recording's. \`bir scenario show ${scenarioId}\` shows which values the steps read from the request.`,
377
+ evidence: { tool: tool ?? null },
378
+ };
379
+ }
380
+ return {
381
+ code: "vetoed_changed_first",
382
+ severity: "warn",
383
+ title: `The plan armed, and was not run: ${why}.`,
384
+ cause: `the agent changed something itself (${tool ?? "a call"}) before it called run_scenario. Run after that, the plan could do the same work twice, so it ran no step and the agent did the task. Calls that only read before it would have kept the plan.`,
385
+ fix: `nothing is wrong with the plan. Why the agent went its own way is in its first lines of the turn; \`bir scenario show ${scenarioId}\` shows which values the steps read from the request.`,
386
+ evidence: { code: v.code, tool: tool ?? null },
387
+ };
388
+ }
338
389
  export const LOW_SAVING_PCT = 0.3;
339
390
  const usd = (v) => `$${v.toFixed(4)}`;
340
391
  const pct = (v) => `${(v * 100).toFixed(1)}%`;
@@ -380,6 +431,17 @@ export function localFindings(turn) {
380
431
  fix: "Check the incidents below and `bir doctor`; the service section says whether the scenario is ready.",
381
432
  });
382
433
  }
434
+ if (turn.vetoed)
435
+ out.push(vetoFinding(turn, turn.vetoed));
436
+ else if (turn.waited) {
437
+ out.push({
438
+ code: "direct_waited",
439
+ severity: "info",
440
+ title: `The agent's first call only read (${turn.waited.tool ?? "a read"}), and the plan waited for run_scenario.`,
441
+ cause: "A call that only reads does not retire a direct plan: it ran as the agent wrote it, and the agent was told once that the plan was still ready.",
442
+ fix: "",
443
+ });
444
+ }
383
445
  if (turn.plan) {
384
446
  const coverage = turn.plan.coverage ?? [];
385
447
  const tools = turn.plan.tools ?? [];
@@ -414,10 +476,16 @@ export function localFindings(turn) {
414
476
  });
415
477
  }
416
478
  if (turn.plan.mode === "direct") {
479
+ const steps = turn.plan.steps ?? coverage.length;
480
+ // A plan that armed ran nothing when it was retired first, or the turn
481
+ // reported it not steered: "28 steps ran" of such a turn was false.
482
+ const ranNone = turn.vetoed !== undefined || turn.reported?.outcome === "not_steered";
417
483
  out.push({
418
484
  code: "direct_mode",
419
485
  severity: "info",
420
- title: `The plan armed in direct mode: ${turn.plan.steps ?? coverage.length} steps ran on the proxies and the runner, no model in the loop.`,
486
+ title: ranNone
487
+ ? `The plan armed in direct mode, ${steps} steps on the proxies and the runner, and none of them ran.`
488
+ : `The plan armed in direct mode: ${steps} steps ran on the proxies and the runner, no model in the loop.`,
421
489
  cause: "Every step is a wrapped MCP tool this session has a proxy for, or a built-in the runner runs itself.",
422
490
  fix: "",
423
491
  });
@@ -809,7 +877,9 @@ export async function investigateCommand(args, deps) {
809
877
  : t.decision?.verdict === "no-steer"
810
878
  ? `declined ${t.decision.code ?? ""}`.trim()
811
879
  : t.plan
812
- ? `${t.plan.mode} ${t.done?.outcome ?? t.reported?.outcome ?? "armed"}`
880
+ ? `${t.plan.mode} ${t.done?.outcome ?? t.reported?.outcome ?? "armed"}${
881
+ // Retired before any step ran: the reason, where the runner gave one.
882
+ t.vetoed ? ` (${t.vetoed.code ?? "not run"})` : ""}`
813
883
  : "matched";
814
884
  const p = savedPct(t);
815
885
  const saved = t.reported?.savedUsd != null ? ` saved ${usd(t.reported.savedUsd)}${p != null ? ` (${pct(p)})` : ""}` : "";
@@ -68,6 +68,11 @@ export interface EditReport {
68
68
  before: InputStatus;
69
69
  after: InputStatus;
70
70
  regressed: boolean;
71
+ /** Its test (autocorrect.md AC5) said yes over the recording and no longer does: then `after` is `not_checked`. */
72
+ test?: {
73
+ says: "no" | "throws" | "not_run";
74
+ error?: string;
75
+ };
71
76
  }>;
72
77
  answer: {
73
78
  informativeBefore: boolean;
@@ -480,7 +480,10 @@ export function renderReport(out, r, opts = {}) {
480
480
  }
481
481
  else {
482
482
  for (const l of regressed) {
483
- out(label("later", `step ${l.stepIndex} (${l.toolName ?? "?"}) reproduced before; now it ${laterPhrase(l.after)}`));
483
+ // A step stopped by its test was not run, so "reproduced before; now it
484
+ // cannot be checked" said neither what happened nor what was before.
485
+ const what = l.test ? laterTestPhrase(l.test) : `reproduced before; now it ${laterPhrase(l.after)}`;
486
+ out(label("later", `step ${l.stepIndex} (${l.toolName ?? "?"}) ${what}`));
484
487
  }
485
488
  }
486
489
  if (r.answer?.informativeBefore && !(brief && r.answer.informativeAfter)) {
@@ -532,6 +535,19 @@ function laterPhrase(status) {
532
535
  return status;
533
536
  }
534
537
  }
538
+ /** What a later step's test says now, over what the changed step emits. */
539
+ function laterTestPhrase(test) {
540
+ switch (test.says) {
541
+ case "no":
542
+ return "— its test said yes over the recording, and now says no";
543
+ case "throws":
544
+ return `— its test said yes over the recording, and now throws${test.error ? `: ${test.error}` : ""}`;
545
+ case "not_run":
546
+ return "— its test could not be run (it overran a few minutes ago and is held), so it is not known to pass";
547
+ default:
548
+ return `— its test now says ${test.says}`;
549
+ }
550
+ }
535
551
  /** A report's `MarkState` or a history entry's `StepState`, in the words the report uses. */
536
552
  const markWord = (m) => (m.marked ?? m.nondeterministic)
537
553
  ? `needs a judgement (${m.why ?? m.nondeterministicWhy ?? "?"})`