@basein/runner 0.3.24 → 0.3.26

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 (67) 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.js +5 -0
  7. package/dist/bin/scenario-edit.d.ts +5 -0
  8. package/dist/bin/scenario-edit.js +17 -1
  9. package/dist/config/adapters/claude-code.d.ts +51 -7
  10. package/dist/config/adapters/claude-code.js +105 -18
  11. package/dist/config/generate.d.ts +61 -13
  12. package/dist/config/generate.js +108 -20
  13. package/dist/config/resolve.d.ts +5 -0
  14. package/dist/config/resolve.js +7 -0
  15. package/dist/control/correlation.js +0 -0
  16. package/dist/control/daemon.d.ts +28 -0
  17. package/dist/control/daemon.js +62 -0
  18. package/dist/control/paths.d.ts +19 -1
  19. package/dist/control/paths.js +20 -2
  20. package/dist/control/pin-guard.js +8 -2
  21. package/dist/control/recorder-main.js +17 -5
  22. package/dist/control/server.d.ts +63 -9
  23. package/dist/control/server.js +350 -62
  24. package/dist/control/transcript.d.ts +64 -3
  25. package/dist/control/transcript.js +318 -37
  26. package/dist/jsonrpc/framing.d.ts +18 -3
  27. package/dist/jsonrpc/framing.js +57 -4
  28. package/dist/proxy/intercept.d.ts +4 -0
  29. package/dist/proxy/intercept.js +4 -0
  30. package/dist/proxy/relay.d.ts +13 -0
  31. package/dist/proxy/relay.js +12 -1
  32. package/dist/proxy/session.d.ts +8 -1
  33. package/dist/proxy/session.js +67 -10
  34. package/dist/record/recorder.d.ts +6 -1
  35. package/dist/record/redact.d.ts +18 -4
  36. package/dist/record/redact.js +538 -79
  37. package/dist/record/remote-recorder.d.ts +26 -5
  38. package/dist/record/remote-recorder.js +174 -18
  39. package/dist/record/truncate.js +3 -1
  40. package/dist/replay/bash-channel.d.ts +7 -1
  41. package/dist/replay/bash-channel.js +9 -3
  42. package/dist/replay/builtins.d.ts +14 -0
  43. package/dist/replay/builtins.js +460 -95
  44. package/dist/replay/bundle.d.ts +80 -0
  45. package/dist/replay/bundle.js +455 -24
  46. package/dist/replay/controller.d.ts +64 -1
  47. package/dist/replay/controller.js +238 -38
  48. package/dist/replay/derive.js +46 -4
  49. package/dist/replay/handover.d.ts +13 -0
  50. package/dist/replay/handover.js +18 -0
  51. package/dist/replay/plan.d.ts +15 -0
  52. package/dist/replay/plan.js +48 -30
  53. package/dist/replay/pricing.d.ts +61 -11
  54. package/dist/replay/pricing.js +135 -42
  55. package/dist/replay/safety.js +43 -5
  56. package/dist/upstream/http-client.d.ts +6 -3
  57. package/dist/upstream/http-client.js +54 -13
  58. package/dist/upstream/stdio-client.d.ts +7 -0
  59. package/dist/upstream/stdio-client.js +1 -1
  60. package/dist/util/atomic-write.d.ts +9 -1
  61. package/dist/util/atomic-write.js +9 -6
  62. package/dist/util/service-health.d.ts +7 -0
  63. package/dist/util/service-health.js +10 -3
  64. package/docs/calculatedReplay.md +32 -0
  65. package/package.json +1 -1
  66. package/production.md +2 -2
  67. 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
  }
@@ -241,6 +241,11 @@ export const DECLINES = {
241
241
  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
242
  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
243
  },
244
+ // The control server's verdict too: the same plan was cancelled a moment ago.
245
+ after_cancel: {
246
+ 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.",
247
+ 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.",
248
+ },
244
249
  // Nor is this one: the match itself was late, so nothing was armed at all.
245
250
  match_late: {
246
251
  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.",
@@ -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 ?? "?"})`
@@ -126,13 +126,44 @@ export declare function otherHostOnly(cwd: string, sidecar: InstalledSidecar): b
126
126
  /** Which file holds a given scope's `mcpServers` for this cwd. */
127
127
  export declare function fileForScope(scope: Scope, paths: ClaudeCodePaths): string;
128
128
  export declare function sha256(text: string): string;
129
- /** Read a file's text, or null when it does not exist. */
129
+ /**
130
+ * A config file that is there and cannot be read: its permissions, or a lock
131
+ * another program holds on it. Its own class so an install can give the advice
132
+ * that fits it ("make it readable", not "remove the comments").
133
+ */
134
+ export declare class UnreadableFileError extends Error {
135
+ constructor(message: string);
136
+ }
137
+ /**
138
+ * Read a file's text; null when there is no such file, and only then.
139
+ *
140
+ * A file that is there and cannot be read throws {@link UnreadableFileError}.
141
+ * It used to answer null as well, which {@link editJson} takes for "no file":
142
+ * it built a new one from `{}` plus its own edit and renamed that over the
143
+ * real one. A settings file that could not be read for a moment was replaced
144
+ * by one holding nothing but our hooks, exit 0, and its backup said "created
145
+ * by install".
146
+ */
130
147
  export declare function readTextOrNull(path: string): string | null;
148
+ /**
149
+ * A JSON file's value for a change to it, or to undo one: undefined when there
150
+ * is no such file, and only then. One that is there and cannot be read throws
151
+ * ({@link readTextOrNull}), and so does one that is not JSON.
152
+ *
153
+ * parseJsonFile answers undefined for an unreadable file too, which is right
154
+ * for reading a config and wrong for undoing one: `bir uninstall` read a locked
155
+ * `~/.claude.json` as one with no entry of ours, said the entry was "changed
156
+ * since install", dropped its record and ended "Uninstalled." with the entry
157
+ * still wrapped.
158
+ */
159
+ export declare function readJsonOrUndefined(path: string): unknown;
131
160
  /**
132
161
  * Read-modify-write one JSON file, safely:
133
162
  *
134
163
  * - a file that is not plain JSON (comments, trailing commas, a torn write)
135
164
  * is REFUSED with an error naming it, never rewritten from a guess;
165
+ * - so is one that is there and cannot be read ({@link readTextOrNull}):
166
+ * only a file that does not exist is created;
136
167
  * - the file is re-read just before the write, and the edit is applied again
137
168
  * to the fresh text when somebody else changed it in between (Claude Code
138
169
  * rewrites `~/.claude.json` constantly, and a lost update there loses a
@@ -141,13 +172,18 @@ export declare function readTextOrNull(path: string): string | null;
141
172
  * reads a half-written config, and a symlinked file stays a symlink.
142
173
  *
143
174
  * `mutate` gets a fresh parse each attempt and must not keep state between
144
- * calls. Returns the text written, for the sidecar's hash.
175
+ * calls. `opts.mode` is for a file the edit puts a credential in: created
176
+ * with those permission bits, and set to them when it is already there
177
+ * (POSIX). Returns the text written, for the sidecar's hash.
145
178
  */
146
- export declare function editJson(path: string, mutate: (parsed: Record<string, unknown>) => void): string;
179
+ export declare function editJson(path: string, mutate: (parsed: Record<string, unknown>) => void, opts?: {
180
+ mode?: number;
181
+ }): string;
147
182
  /**
148
- * Throw (naming the file) when `path` exists and is not a plain JSON object —
149
- * so an install can refuse up front, before it has written anything, instead
150
- * of stopping halfway through with some files changed and others not.
183
+ * Throw (naming the file) when `path` exists and is not a plain JSON object,
184
+ * or cannot be read at all — so an install can refuse up front, before it has
185
+ * written anything, instead of stopping halfway through with some files
186
+ * changed and others not.
151
187
  */
152
188
  export declare function assertEditableJson(path: string): void;
153
189
  /**
@@ -267,7 +303,9 @@ export declare function buildHooksBlock(controlUrl: string, token: string, opts?
267
303
  * HTTP: a loopback URL (127.0.0.1 / localhost / [::1]) whose path is exactly
268
304
  * one of our routes, AND either our kind of bearer token or a port in
269
305
  * the range `bir` allocates (the token-less 0.2.8 shape). A hook to
270
- * `https://hooks.example.com/session/end` is somebody else's.
306
+ * `https://hooks.example.com/session/end` is somebody else's. One
307
+ * whose URL is no URL (`http://127.0.0.1:NaN/tool/pre`, from a
308
+ * `--port` that was not a number) needs the token as well as the route.
271
309
  * exec: our self-starting SessionStart hook — `bir-hooks.js ensure`, or the
272
310
  * npx form `… -p @basein/runner@<v> bir-hooks ensure`.
273
311
  */
@@ -277,6 +315,12 @@ export declare function isBirHook(entry: unknown): boolean;
277
315
  * the new text. Our own entries are replaced rather than appended, so repeated
278
316
  * installs stay idempotent — hook by hook: a user's own hook that happens to
279
317
  * share a matcher group with one of ours stays exactly where it is.
318
+ *
319
+ * The file is left readable by its owner only (0600). The block carries the
320
+ * recorder's bearer token, which is enough to make the recorder run a
321
+ * scenario, and its two other copies (the discovery file, installed.json) are
322
+ * 0600 already: at the default 0644 any other account on the machine could
323
+ * read it here.
280
324
  */
281
325
  export declare function installHooks(settingsPath: string, block: HooksBlock): string;
282
326
  /** Remove only our hook entries, and drop events left empty. */