@basein/runner 0.2.12 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/README.md +540 -369
  2. package/dist/auth/client.d.ts +65 -3
  3. package/dist/auth/client.js +305 -53
  4. package/dist/bin/bir-hooks.d.ts +72 -31
  5. package/dist/bin/bir-hooks.js +176 -284
  6. package/dist/bin/bir-proxy.d.ts +17 -2
  7. package/dist/bin/bir-proxy.js +222 -28
  8. package/dist/bin/bir-scenario.d.ts +61 -21
  9. package/dist/bin/bir-scenario.js +593 -122
  10. package/dist/bin/bir.d.ts +12 -6
  11. package/dist/bin/bir.js +2935 -489
  12. package/dist/bin/investigate.js +40 -15
  13. package/dist/bin/scenario-edit.d.ts +7 -1
  14. package/dist/bin/scenario-edit.js +43 -21
  15. package/dist/bin/scenario-liveness.d.ts +194 -0
  16. package/dist/bin/scenario-liveness.js +459 -0
  17. package/dist/bin/scenario-stderr.d.ts +43 -0
  18. package/dist/bin/scenario-stderr.js +76 -0
  19. package/dist/bin/service-read.d.ts +88 -0
  20. package/dist/bin/service-read.js +128 -0
  21. package/dist/bin/setup.d.ts +19 -0
  22. package/dist/bin/setup.js +146 -20
  23. package/dist/config/adapters/claude-code.d.ts +92 -14
  24. package/dist/config/adapters/claude-code.js +291 -101
  25. package/dist/config/adapters/generic.d.ts +4 -1
  26. package/dist/config/adapters/generic.js +13 -14
  27. package/dist/config/generate.d.ts +392 -14
  28. package/dist/config/generate.js +666 -19
  29. package/dist/config/mcp-approval.d.ts +76 -0
  30. package/dist/config/mcp-approval.js +210 -0
  31. package/dist/config/node-path.d.ts +42 -0
  32. package/dist/config/node-path.js +102 -0
  33. package/dist/config/overrides.d.ts +62 -0
  34. package/dist/config/overrides.js +136 -0
  35. package/dist/config/resolve.d.ts +70 -8
  36. package/dist/config/resolve.js +186 -15
  37. package/dist/control/client.d.ts +22 -0
  38. package/dist/control/client.js +26 -7
  39. package/dist/control/correlation.d.ts +23 -5
  40. package/dist/control/correlation.js +0 -0
  41. package/dist/control/daemon.d.ts +400 -18
  42. package/dist/control/daemon.js +1105 -102
  43. package/dist/control/discovery.d.ts +77 -5
  44. package/dist/control/discovery.js +186 -11
  45. package/dist/control/ensure-hook.d.ts +61 -0
  46. package/dist/control/ensure-hook.js +160 -8
  47. package/dist/control/paths.d.ts +26 -0
  48. package/dist/control/paths.js +34 -0
  49. package/dist/control/pin-guard.d.ts +83 -0
  50. package/dist/control/pin-guard.js +534 -0
  51. package/dist/control/recorder-keeper.d.ts +229 -0
  52. package/dist/control/recorder-keeper.js +721 -0
  53. package/dist/control/recorder-main.d.ts +73 -0
  54. package/dist/control/recorder-main.js +778 -0
  55. package/dist/control/server.d.ts +707 -31
  56. package/dist/control/server.js +2786 -302
  57. package/dist/control/transcript.d.ts +21 -1
  58. package/dist/control/transcript.js +378 -112
  59. package/dist/index.d.ts +5 -1
  60. package/dist/index.js +5 -1
  61. package/dist/jsonrpc/framing.d.ts +76 -12
  62. package/dist/jsonrpc/framing.js +216 -59
  63. package/dist/proxy/intercept.d.ts +49 -5
  64. package/dist/proxy/intercept.js +188 -59
  65. package/dist/proxy/relay.d.ts +16 -0
  66. package/dist/proxy/relay.js +77 -5
  67. package/dist/proxy/revive.d.ts +122 -0
  68. package/dist/proxy/revive.js +251 -0
  69. package/dist/proxy/session.d.ts +222 -14
  70. package/dist/proxy/session.js +708 -99
  71. package/dist/record/recorder.d.ts +51 -3
  72. package/dist/record/recorder.js +3 -0
  73. package/dist/record/redact.d.ts +103 -9
  74. package/dist/record/redact.js +659 -30
  75. package/dist/record/remote-recorder.d.ts +327 -32
  76. package/dist/record/remote-recorder.js +1517 -187
  77. package/dist/record/switchable-recorder.d.ts +126 -0
  78. package/dist/record/switchable-recorder.js +351 -0
  79. package/dist/record/truncate.d.ts +40 -1
  80. package/dist/record/truncate.js +111 -4
  81. package/dist/replay/bash-channel.d.ts +35 -0
  82. package/dist/replay/bash-channel.js +133 -0
  83. package/dist/replay/bundle.d.ts +10 -0
  84. package/dist/replay/bundle.js +10 -0
  85. package/dist/replay/controller.d.ts +369 -20
  86. package/dist/replay/controller.js +1176 -107
  87. package/dist/replay/derive.d.ts +1 -0
  88. package/dist/replay/derive.js +81 -24
  89. package/dist/replay/executor.d.ts +51 -16
  90. package/dist/replay/executor.js +114 -41
  91. package/dist/replay/handover.d.ts +54 -1
  92. package/dist/replay/handover.js +55 -5
  93. package/dist/replay/logic.d.ts +62 -10
  94. package/dist/replay/logic.js +140 -22
  95. package/dist/replay/plan.d.ts +201 -27
  96. package/dist/replay/plan.js +581 -141
  97. package/dist/replay/safety.d.ts +72 -0
  98. package/dist/replay/safety.js +303 -0
  99. package/dist/replay/source-run.d.ts +10 -0
  100. package/dist/replay/source-run.js +80 -35
  101. package/dist/replay/targets.d.ts +15 -1
  102. package/dist/replay/targets.js +62 -4
  103. package/dist/replay/tool-error.d.ts +15 -1
  104. package/dist/replay/tool-error.js +33 -1
  105. package/dist/replay/types.d.ts +7 -4
  106. package/dist/upstream/client.d.ts +56 -3
  107. package/dist/upstream/client.js +61 -4
  108. package/dist/upstream/headers.d.ts +43 -0
  109. package/dist/upstream/headers.js +90 -0
  110. package/dist/upstream/http-client.d.ts +213 -22
  111. package/dist/upstream/http-client.js +1034 -168
  112. package/dist/upstream/http-transport.d.ts +143 -0
  113. package/dist/upstream/http-transport.js +391 -0
  114. package/dist/upstream/lazy-client.d.ts +8 -0
  115. package/dist/upstream/lazy-client.js +29 -1
  116. package/dist/upstream/sse.d.ts +78 -0
  117. package/dist/upstream/sse.js +189 -0
  118. package/dist/upstream/stdio-client.d.ts +61 -1
  119. package/dist/upstream/stdio-client.js +356 -33
  120. package/dist/util/atomic-write.d.ts +29 -0
  121. package/dist/util/atomic-write.js +96 -0
  122. package/dist/util/journal.d.ts +15 -0
  123. package/dist/util/journal.js +48 -3
  124. package/dist/util/log.d.ts +10 -0
  125. package/dist/util/log.js +28 -1
  126. package/dist/util/off-switch.d.ts +46 -0
  127. package/dist/util/off-switch.js +91 -0
  128. package/dist/util/service-health.d.ts +133 -0
  129. package/dist/util/service-health.js +217 -0
  130. package/docs/BaseInstRunner.md +694 -621
  131. package/docs/calculatedReplay.md +1625 -1304
  132. package/docs/calculatedReplayGuide.md +1212 -1087
  133. package/docs/installRun.md +1013 -772
  134. package/docs/loginWeb.md +627 -607
  135. package/docs/mcpmark.md +49 -16
  136. package/docs/my-first-sample.md +569 -545
  137. package/docs/quickstart.md +384 -311
  138. package/docs/t-bench.md +406 -394
  139. package/package.json +58 -57
  140. package/production.md +1371 -0
  141. package/scripts/install.ps1 +97 -6
  142. package/scripts/install.sh +104 -7
package/dist/bin/bir.js CHANGED
@@ -4,10 +4,11 @@
4
4
  *
5
5
  * bir setup [--auth-url <url>] [--token <t>] [--project <dir>] ← the one command
6
6
  * bir up [--restart] | bir down
7
+ * bir off [--here] | bir on [--here] ← the kill switch: inert at once, nothing uninstalled
7
8
  * bir install [--config <path>] [--server <name>]… [--local] [--no-hooks]
8
- * bir uninstall [--config <path>]
9
+ * bir uninstall [--config <path>] [--server <name>]… | bir uninstall --all
9
10
  * bir status
10
- * bir doctor
11
+ * bir doctor [--quick]
11
12
  * bir wrap --server-name <key> -- <command> [args…]
12
13
  * bir login | logout
13
14
  * bir --version
@@ -22,118 +23,157 @@
22
23
  * The `scenario` words that read or change one step live in scenario-edit.ts
23
24
  * (editSteps.md in the BaseIn repository); `investigate` in investigate.ts.
24
25
  *
25
- * `bir install` is reversible by construction: every file it edits is stashed
26
- * verbatim first, so `bir uninstall` restores it byte-for-byte unless somebody
27
- * else edited it in the meantime (in which case it repairs the entries and says
28
- * so). `bir doctor` proves the install *works* by asking the control server which
26
+ * `bir install` is reversible by construction: every project file it edits is
27
+ * stashed verbatim first, so `bir uninstall` restores it byte-for-byte unless
28
+ * somebody else edited it in the meantime (in which case it repairs the entries
29
+ * and says so); `~/.claude.json` and a `--config` file are always repaired entry
30
+ * by entry. What install writes is chosen so it cannot break a working setup:
31
+ * wrapped copies go to this project's local scope (never the team's `.mcp.json`,
32
+ * never the user scope), a remote server Claude Code signs in itself is left
33
+ * alone, and a file that is not plain JSON is refused rather than rewritten.
34
+ * `bir doctor` proves the install *works* by asking the control server which
29
35
  * proxies actually registered — never by reading the config file back, and never
30
36
  * from `serverInfo`, which must stay the upstream's (Phase 3).
31
37
  */
32
38
  import { randomBytes } from "node:crypto";
33
- import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
34
- import { dirname, join, resolve as resolvePath } from "node:path";
39
+ import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
40
+ import { dirname, isAbsolute, join, resolve as resolvePath } from "node:path";
35
41
  import { fileURLToPath } from "node:url";
36
42
  import { ControlClient } from "../control/client.js";
37
- import { readDiscovery } from "../control/discovery.js";
38
- import { ensureDaemon, stopDaemon } from "../control/daemon.js";
39
- import { configDir, discoveryPath, normalizePath } from "../control/paths.js";
40
- import { allocateFreeProjectPort, isScenarioServer, isWrapped, nodeFlagsToCarry, PACKAGE_NAME, packageSpec, projectRecord, readSidecar, scenarioEntry, setProjectRecord, sidecarKey, wrapEntry, writeSidecar, SCENARIO_SERVER_KEY, } from "../config/generate.js";
43
+ import { pidAlive, readDiscovery } from "../control/discovery.js";
44
+ import * as daemonApi from "../control/daemon.js";
45
+ import { activeSessions, daemonStatus, ensureDaemon, probeHealth, readPortProblem, stopDaemon, } from "../control/daemon.js";
46
+ import { configDir, controlDir, controlKey, discoveryPath, installedPath, normalizePath } from "../control/paths.js";
47
+ import { offFlagPath, offSource, projectOffFlagPath, setRunnerOff } from "../util/off-switch.js";
48
+ import { allocateFreeProjectPort, backupIsCreation, backupOriginalText, configSha, envProxyHint, exemptLoopbackFromProxy, isNpxEntry, isScenarioServer, isWrapped, nodeFlagsToCarry, normaliseRecordKeys, npxHookInvocation, protectBackupText, recordKey, sameServerConfig, PACKAGE_NAME, projectRecord, readSidecar, redactSecrets, restoreSecrets, remoteWrapBlocker, scenarioEntry, setProjectRecord, settleReplaySwitch, sidecarKey, unwrapEntry, UPSTREAM_HEADERS_ENV, wrapEntry, writeSidecar, SCENARIO_SERVER_KEY, } from "../config/generate.js";
49
+ import { chooseNodePath } from "../config/node-path.js";
50
+ import { managedMcpPolicyBlocker, mcpjsonApprovals } from "../config/mcp-approval.js";
51
+ import { checkOverrides, recordServerName } from "../config/overrides.js";
52
+ import { awaitWithin, isUnhealthyStatus, serviceHealth, serviceTimeoutMs, timeoutSignal } from "../util/service-health.js";
41
53
  import { runSetup } from "./setup.js";
42
- import { isRemote, parseJsonFile, resolveServers } from "../config/resolve.js";
43
- import { buildHooksBlock, claudeCodePaths, disableMcpjsonServers, enableMcpjsonServers, ensureGitExclude, fileForScope, hasBirHooks, installHooks, isBirHook, LOCAL_SETTINGS_EXCLUDE, readTextOrNull, removeGitExclude, setServerEntry, sha256, uninstallHooks, } from "../config/adapters/claude-code.js";
54
+ import { claudeConfigHome, claudeProjectKey, claudeProjectRoot, isRemote, linkedWorktrees, parseJsonFile, projectKeyFor, projectKeysFor, projectScope, resolveServers, } from "../config/resolve.js";
55
+ import { assertEditableJson, buildHooksBlock, claudeCodePaths, disableMcpjsonServers, enableMcpjsonServers, ENSURE_ARG, ensureGitExcludeLine, hasBirHooks, installHooks, isBirHook, LOCAL_SETTINGS_EXCLUDE, readTextOrNull, removeGitExclude, setLocalEntries, setServerEntry, sha256, uninstallHooks, } from "../config/adapters/claude-code.js";
56
+ import { writeFileAtomic } from "../util/atomic-write.js";
44
57
  import { readGenericServers, setGenericServerEntry } from "../config/adapters/generic.js";
45
- import { AUTH_URL_HINT, DeviceFlowAborted, DeviceFlowUnsupported, authenticate, deviceLogin, describeAuthService, legacyPasswordLogin, tokenLogin, logout, normalizeAuthUrl, rememberAuthUrl, resolveAuthUrl, } from "../auth/client.js";
58
+ import { AUTH_URL_HINT, DeviceFlowAborted, DeviceFlowUnsupported, authenticate, clearCredentials, deviceLogin, describeAuthService, legacyPasswordLogin, tokenLogin, normalizeAuthUrl, rememberAuthUrl, resolveAuthUrl, } from "../auth/client.js";
46
59
  import { DEFAULT_CONTROL_PORT } from "../control/server.js";
47
- import { errText } from "../util/log.js";
60
+ import { errText, logLine } from "../util/log.js";
48
61
  import { journalPath, readJournal } from "../util/journal.js";
49
62
  import { investigateCommand } from "./investigate.js";
50
63
  import { SCENARIO_EDIT_SUBCOMMANDS, scenarioEditCommand } from "./scenario-edit.js";
51
64
  import { loadCredentials } from "../auth/client.js";
52
65
  import { packageVersion } from "../util/version.js";
53
66
  const VERSION = packageVersion();
67
+ /**
68
+ * The npx SessionStart hook's own budget, passed as `ensure --budget-ms <n>`
69
+ * (bin/bir-hooks.ts, control/ensure-hook.ts). npx resolves the package — about
70
+ * a second cached, more when it has to look — before the hook's clock starts,
71
+ * and the host cancels the hook at ENSURE_TIMEOUT_S (15 s): the default 11 s
72
+ * budget plus its 0.5 s ceiling left npx under 3.5 s. 8 s leaves it 6.5.
73
+ */
74
+ const NPX_ENSURE_BUDGET_ARGS = ["--budget-ms", "8000"];
54
75
  const out = (line = "") => {
55
76
  process.stdout.write(line + "\n");
56
77
  };
57
78
  function usage(code = 0) {
58
- out(`bir ${VERSION} — a recording MCP proxy
59
-
60
- Commands:
61
- setup the one command: sign in, wire this project, start the recorder
62
- up start the recorder for this project in the background (--restart: replace it)
63
- down stop it
64
- install wrap this project's MCP servers and wire Claude Code's hooks
65
- uninstall restore everything install changed
66
- status what is installed for this directory
67
- doctor is it actually working right now?
68
- wrap print a proxied entry for one server (any MCP client)
69
- login sign in to the BaseIn service (opens your browser)
70
- logout revoke this machine's session and forget it
71
-
72
- scenario list recorded runs and their calculated scenarios
73
- scenario show <runId|scnId> [--step <n>]
74
- a scenario: intent, params, steps (--step: just that one)
75
- scenario calc <runId|scnId> [--force [--discard-edits]]
76
- calculate (or re-derive) a run's scenario; a scn_ id
77
- (a sub-task's too) is recalculated in place, with --force
78
- scenario check <runId|scnId> --step <n> [--input-logic <file|->] [--output-logic <file|->] [--unfreeze]
79
- try a change to one step against the recording; saves nothing
80
- scenario edit <runId|scnId> --step <n> [--input-logic <file|->] [--output-logic <file|->]
81
- [--freeze | --unfreeze] [--note "why"] [--force --note "why"] [--revision <n>]
82
- the same check, and save the change if it passes
83
- scenario edits <runId|scnId> a scenario's hand edits, newest first
84
- scenario undo <runId|scnId> [--edit <sedit_id> | --step <n>] [--force --note "why"]
85
- take back the newest edit (of step n); run it again to go
86
- one further back; undo an undo's sedit_ id to redo
87
- scenario editing on|off|status whether this project's bir MCP server offers the tools
88
- that change a scenario (restart Claude Code after)
89
- scenario replay <scnId> --prompt "…" [--dry]
90
- replay --scenario <scnId> --prompt "…" [--dry]
91
- replay status | on | off | allow <a,b> | allow all
92
- this project's replay switches, kept for the background recorder
93
-
94
- investigate [<id>] why the newest turn here (or run_/scn_/sexec_ <id>) did what
95
- it did, what it cost, what to fix — journal + service
96
- investigate list [--limit <n>] recent turns in this directory and their verdicts
97
- investigate executions [--limit <n>] [--user <email|id>]
98
- the ledger, newest first (--user: admins only)
99
-
100
- Options:
101
- --config <path> an explicit { "mcpServers": … } file, for clients with no adapter
102
- --server <name> restrict install/uninstall to one server (repeatable)
103
- --local invoke this checkout's bir-proxy instead of npx (development)
104
- --global invoke the installed package by absolute path instead of npx
105
- (no registry round trip, no PATH lookup — what a fleet wants)
106
- --no-hooks do not touch .claude/settings.local.json (Tier 2 recording only)
107
- --no-correlation never relax tool schemas; join steps on argument fingerprints
108
- --replay install/remove the scenario server, enabling calculated replay
109
- --port <n> install/setup: control-server port to write into the hook URLs
110
- (default: this project's, else the lowest free one from ${DEFAULT_CONTROL_PORT})
111
- --json machine-readable output for status / doctor / scenario / investigate
112
- (scenario check|edit|edits|undo: the service's answer, unchanged)
113
- --dry replay against recorded outputs only; run no real tools
114
- --force scenario calc: recalculate in place · scenario edit: save although
115
- the check refused it (needs --note; only for a recording that was wrong)
116
- · scenario undo: undo although a later step or the answer would break
117
- --step <n> scenario show|check|edit|undo: the step, by the stepIndex \`show\` prints
118
- --input-logic <file|-> scenario check|edit: the new input logic, from a file or stdin (-)
119
- --output-logic <file|-> scenario check|edit: the new output logic, from a file or stdin (-)
120
- --freeze scenario edit: mark the step "needs a judgement" (runs hand over before it)
121
- --unfreeze scenario check|edit: ask to remove that mark; the check decides
122
- --note <text> scenario edit|undo: why, kept in the history (required with --force)
123
- --revision <n> scenario edit|undo: only if the plan is still at this chainRevision
124
- --edit <id> scenario undo: the sedit_ id to undo (default: the newest edit that can be)
125
- --discard-edits scenario calc --force: recalculate a plan with hand edits, replacing them
126
- --no-browser login: print the link and code, open nothing (SSH, headless)
127
- --token <value> login: redeem a one-time setup token from the console (no browser)
128
- --password login: use the old email/password prompt (deprecated)
129
- --user <ref> investigate executions: another account, by email or id (admin)
130
- --limit <n> investigate: how many turns / executions to show
131
- --auth-url <url> setup: the BaseIn API address (else BIR_AUTH_URL, else the stored one)
132
- --project <dir> setup: the directory to record (default: this one)
133
- --no-replay setup: wire recording only; add no scenario server
134
- --allow-servers <a,b> setup: servers replay may call unattended (default: all wrapped)
135
- --no-daemon setup: wire everything, start nothing
136
- --restart up: replace a running recorder`);
79
+ out(`bir ${VERSION} — a recording MCP proxy
80
+
81
+ Commands:
82
+ setup the one command: sign in, wire this project, start the recorder
83
+ up start the recorder for this project in the background (--restart: replace it)
84
+ down stop it (open sessions here then show a hook error until they restart;
85
+ \`bir off --here\` is the quiet way to take it out of the way)
86
+ off take the runner out of the way at once, uninstalling nothing: hooks answer
87
+ with nothing within a second, wrapped servers stop recording at once and run
88
+ as plain pipes from their next start, nothing is recorded or replayed
89
+ (--here: this project only)
90
+ on undo \`bir off\` (without --here: every project on this machine)
91
+ install wrap this project's MCP servers and wire Claude Code's hooks
92
+ uninstall restore everything install changed here and stop this project's recorder
93
+ (--all: every project on this machine — run it before removing the package)
94
+ status what is installed for this directory
95
+ doctor is it actually working right now? (exits 1 only for what breaks a session;
96
+ --quick: no network)
97
+ wrap print a proxied entry for one server (any MCP client)
98
+ login sign in to the BaseIn service (opens your browser)
99
+ logout revoke this machine's session and forget it
100
+
101
+ scenario list recorded runs and their calculated scenarios
102
+ scenario show <runId|scnId> [--step <n>]
103
+ a scenario: intent, params, steps (--step: just that one)
104
+ scenario calc <runId|scnId> [--force [--discard-edits]]
105
+ calculate (or re-derive) a run's scenario; a scn_ id
106
+ (a sub-task's too) is recalculated in place, with --force
107
+ scenario check <runId|scnId> --step <n> [--input-logic <file|->] [--output-logic <file|->] [--unfreeze]
108
+ try a change to one step against the recording; saves nothing
109
+ scenario edit <runId|scnId> --step <n> [--input-logic <file|->] [--output-logic <file|->]
110
+ [--freeze | --unfreeze] [--note "why"] [--force --note "why"] [--revision <n>]
111
+ the same check, and save the change if it passes
112
+ scenario edits <runId|scnId> a scenario's hand edits, newest first
113
+ scenario undo <runId|scnId> [--edit <sedit_id> | --step <n>] [--force --note "why"]
114
+ take back the newest edit (of step n); run it again to go
115
+ one further back; undo an undo's sedit_ id to redo
116
+ scenario editing on|off|status whether this project's bir MCP server offers the tools
117
+ that change a scenario (restart Claude Code after)
118
+ scenario replay <scnId> --prompt "…" [--dry]
119
+ replay --scenario <scnId> --prompt "…" [--dry]
120
+ replay status | on | off | allow <a,b> | allow all
121
+ this project's replay switches, kept for the background recorder
122
+
123
+ investigate [<id>] why the newest turn here (or run_/scn_/sexec_ <id>) did what
124
+ it did, what it cost, what to fix — journal + service
125
+ investigate list [--limit <n>] recent turns in this directory and their verdicts
126
+ investigate executions [--limit <n>] [--user <email|id>]
127
+ the ledger, newest first (--user: admins only)
128
+
129
+ Options:
130
+ --config <path> an explicit { "mcpServers": … } file, for clients with no adapter
131
+ --server <name> restrict install/uninstall to one server (repeatable); also wraps a
132
+ remote server Claude Code signs in itself (OAuth), which is
133
+ otherwise left unwrapped — its calls are recorded by the hooks
134
+ --global invoke the installed package by absolute path (the default when
135
+ bir runs from an installed package: no registry, works offline)
136
+ --local invoke this checkout's bir-proxy (development)
137
+ --npx invoke a pinned npx package instead (~1 s more per server start)
138
+ --mcp-json wrap .mcp.json servers in place and add the scenario server there
139
+ (the old shape; never for a .mcp.json the team commits).
140
+ Default: a local-scope copy in ~/.claude.json, for this project only
141
+ --no-hooks do not touch .claude/settings.local.json (Tier 2 recording only)
142
+ --no-correlation never relax tool schemas; join steps on argument fingerprints
143
+ --replay install/remove the scenario server, enabling calculated replay
144
+ --port <n> install/setup: control-server port to write into the hook URLs
145
+ (default: this project's, else the lowest free one from ${DEFAULT_CONTROL_PORT})
146
+ --json machine-readable output for status / doctor / scenario / investigate
147
+ (scenario check|edit|edits|undo: the service's answer, unchanged)
148
+ --dry replay against recorded outputs only; run no real tools
149
+ --force scenario calc: recalculate in place · scenario edit: save although
150
+ the check refused it (needs --note; only for a recording that was wrong)
151
+ · scenario undo: undo although a later step or the answer would break
152
+ --step <n> scenario show|check|edit|undo: the step, by the stepIndex \`show\` prints
153
+ --input-logic <file|-> scenario check|edit: the new input logic, from a file or stdin (-)
154
+ --output-logic <file|-> scenario check|edit: the new output logic, from a file or stdin (-)
155
+ --freeze scenario edit: mark the step "needs a judgement" (runs hand over before it)
156
+ --unfreeze scenario check|edit: ask to remove that mark; the check decides
157
+ --note <text> scenario edit|undo: why, kept in the history (required with --force)
158
+ --revision <n> scenario edit|undo: only if the plan is still at this chainRevision
159
+ --edit <id> scenario undo: the sedit_ id to undo (default: the newest edit that can be)
160
+ --discard-edits scenario calc --force: recalculate a plan with hand edits, replacing them
161
+ --no-browser login: print the link and code, open nothing (SSH, headless)
162
+ --token <value> login: redeem a one-time setup token from the console (no browser)
163
+ --password login: use the old email/password prompt (deprecated)
164
+ --user <ref> investigate executions: another account, by email or id (admin)
165
+ --limit <n> investigate: how many turns / executions to show
166
+ --auth-url <url> setup: the BaseIn API address (else BIR_AUTH_URL, else the stored one)
167
+ --project <dir> setup: the directory to record (default: this one)
168
+ --no-replay setup: wire recording only; add no scenario server
169
+ --allow-servers <a,b> setup: servers replay may call unattended (default: all wrapped)
170
+ --no-daemon setup: wire everything, start nothing
171
+ --restart up: replace a running recorder
172
+ --here off/on: this project only (the installed project this directory is in)
173
+ --all uninstall: every project on this machine, every recorder, the user scope
174
+ --quick doctor: local checks only, no network call to the service
175
+ --strict doctor: exit 1 on warnings too (not recording, a proxy not registered, …)
176
+ --keep-recorder uninstall: leave this project's recorder running`);
137
177
  process.exit(code);
138
178
  }
139
179
  function parseArgs(argv) {
@@ -142,6 +182,8 @@ function parseArgs(argv) {
142
182
  servers: [],
143
183
  local: false,
144
184
  global: false,
185
+ npx: false,
186
+ mcpJson: false,
145
187
  hooks: true,
146
188
  noCorrelation: false,
147
189
  port: DEFAULT_CONTROL_PORT,
@@ -159,6 +201,11 @@ function parseArgs(argv) {
159
201
  freeze: false,
160
202
  unfreeze: false,
161
203
  discardEdits: false,
204
+ here: false,
205
+ all: false,
206
+ quick: false,
207
+ strict: false,
208
+ keepRecorder: false,
162
209
  };
163
210
  for (let i = 1; i < argv.length; i += 1) {
164
211
  const arg = argv[i];
@@ -206,6 +253,12 @@ function parseArgs(argv) {
206
253
  case "--global":
207
254
  args.global = true;
208
255
  break;
256
+ case "--npx":
257
+ args.npx = true;
258
+ break;
259
+ case "--mcp-json":
260
+ args.mcpJson = true;
261
+ break;
209
262
  case "--no-hooks":
210
263
  args.hooks = false;
211
264
  break;
@@ -302,6 +355,21 @@ function parseArgs(argv) {
302
355
  case "--discard-edits":
303
356
  args.discardEdits = true;
304
357
  break;
358
+ case "--here":
359
+ args.here = true;
360
+ break;
361
+ case "--all":
362
+ args.all = true;
363
+ break;
364
+ case "--quick":
365
+ args.quick = true;
366
+ break;
367
+ case "--strict":
368
+ args.strict = true;
369
+ break;
370
+ case "--keep-recorder":
371
+ args.keepRecorder = true;
372
+ break;
305
373
  default:
306
374
  if (arg.startsWith("-"))
307
375
  usage(2);
@@ -327,22 +395,26 @@ function localScenarioPath() {
327
395
  function localHooksPath() {
328
396
  return resolvePath(dirname(fileURLToPath(import.meta.url)), "bir-hooks.js");
329
397
  }
330
- /** True when this `bir` runs from an installed package rather than a checkout. */
398
+ /**
399
+ * True when this `bir` runs from npx's cache (`…/_npx/<hash>/node_modules/…`):
400
+ * installed-looking, but npm cleans that cache whenever it likes, so an
401
+ * absolute path into it is a path that will one day be gone.
402
+ */
403
+ function runsFromNpxCache() {
404
+ return /[\\/]_npx[\\/]/.test(localProxyPath());
405
+ }
406
+ /** True when this `bir` runs from an installed package rather than a checkout (or the npx cache). */
331
407
  function runsFromInstalledPackage() {
332
- return /[\\/]node_modules[\\/]/.test(localProxyPath());
408
+ return /[\\/]node_modules[\\/]/.test(localProxyPath()) && !runsFromNpxCache();
333
409
  }
334
410
  /**
335
- * The node the SessionStart hook will run. The real path, not the one this
336
- * process was started through: a version manager's per-shell shim directory
337
- * is gone when that shell is, and a hook that names it fails every session.
411
+ * What a flag-less first install (and `bir setup`) generates: an installed
412
+ * package names itself by absolute path — no registry round trip, no ~1 s npx
413
+ * start per server, works offline, and gets the self-starting SessionStart
414
+ * hook. The npx cache and a checkout keep pinned npx, which is what they are.
338
415
  */
339
- function hookNodePath() {
340
- try {
341
- return realpathSync(process.execPath);
342
- }
343
- catch {
344
- return process.execPath;
345
- }
416
+ function defaultInvocation() {
417
+ return runsFromInstalledPackage() ? "global" : "npx";
346
418
  }
347
419
  /**
348
420
  * Which invocation the generated entries use — and the guard that makes
@@ -357,49 +429,211 @@ function hookNodePath() {
357
429
  * that works today and breaks after a `git clean`. Hence the node_modules test:
358
430
  * it is the one cheap signal that says "somebody installed this".
359
431
  */
360
- function resolveInvocation(args, remembered) {
361
- if (args.local && args.global) {
362
- process.stderr.write("[bir] --local and --global are mutually exclusive\n");
432
+ function resolveInvocation(args, remembered, rememberedChosen) {
433
+ if ([args.local, args.global, args.npx].filter(Boolean).length > 1) {
434
+ process.stderr.write("[bir] --local, --global and --npx are mutually exclusive\n");
363
435
  process.exit(2);
364
436
  }
365
- // Neither flag: what this project was installed with last time. `bir setup`
437
+ if (args.forceInvocation)
438
+ return args.forceInvocation;
439
+ if (args.npx)
440
+ return "npx";
441
+ if (args.local)
442
+ return "local";
443
+ if (args.global) {
444
+ const proxy = localProxyPath();
445
+ if (!runsFromInstalledPackage()) {
446
+ process.stderr.write(`[bir] --global needs an installed package, but this bir runs from ${dirname(proxy)}` +
447
+ `${runsFromNpxCache() ? " (npx's cache, which npm cleans)" : ""}\n` +
448
+ ` install it first (npm install -g ${PACKAGE_NAME}@${VERSION}) and rerun,\n` +
449
+ ` or use --local to point at this checkout on purpose.\n`);
450
+ process.exit(2);
451
+ }
452
+ return "global";
453
+ }
454
+ // No flag: what this project was installed with last time. `bir setup`
366
455
  // installs with --global, and a later bare `bir install --replay` here must
367
- // not quietly turn every entry into a pinned-npx one and drop the
368
- // self-starting SessionStart hook with it.
369
- if (!args.global && !args.local && remembered) {
456
+ // not quietly turn every entry into a pinned-npx one.
457
+ if (remembered) {
370
458
  if (remembered === "global" && !runsFromInstalledPackage())
371
- return "local";
459
+ return runsFromNpxCache() ? "npx" : "local";
460
+ // A remembered npx nobody asked for is the old flag-less default; an
461
+ // installed `bir` replaces it with its absolute path (and the hook that
462
+ // starts the recorder), which is what it would pick today.
463
+ if (remembered === "npx" && !rememberedChosen && runsFromInstalledPackage())
464
+ return "global";
372
465
  return remembered;
373
466
  }
374
- if (!args.global)
375
- return args.local ? "local" : "npx";
376
- const proxy = localProxyPath();
377
- if (!/[\\/]node_modules[\\/]/.test(proxy)) {
378
- process.stderr.write(`[bir] --global needs an installed package, but this bir runs from ${dirname(proxy)}\n` +
379
- ` install it first (npm install -g ${PACKAGE_NAME}@${VERSION}) and rerun,\n` +
380
- ` or use --local to point at this checkout on purpose.\n`);
381
- process.exit(2);
382
- }
383
- return "global";
467
+ return defaultInvocation();
468
+ }
469
+ /** True when `file` is `~/.claude.json` (or whatever BIR_CLAUDE_JSON names). */
470
+ function isClaudeJson(file, claudeJson) {
471
+ return normalizePath(file) === normalizePath(claudeJson);
384
472
  }
385
- /** Record a file's pre-install text once, so uninstall can restore it exactly. */
386
- function backupFile(sidecar, file) {
473
+ /**
474
+ * Record a file's pre-install text once, so uninstall can restore it exactly —
475
+ * and notice when that snapshot has gone stale.
476
+ *
477
+ * The snapshot is taken at the FIRST install only. If somebody else changed
478
+ * the file after our last write (a server added by hand, Claude Code's own
479
+ * edits), a later install writes over their version, the file hashes to our
480
+ * write again, and a byte-for-byte restore would bring back the first-install
481
+ * text — deleting their change. So a changed file marks the snapshot stale, and
482
+ * uninstall repairs entries instead. `~/.claude.json` is never snapshotted: it
483
+ * is Claude Code's, rewritten constantly, and holds every project's settings;
484
+ * a copy of it in installed.json was also a copy of every project's secrets.
485
+ * Neither is a `--config` file (`entryOnly`), which is usually a client's one
486
+ * global config, with every server's credentials in it. And a `.mcp.json`
487
+ * (the `--mcp-json` shape) is kept without its env and header VALUES
488
+ * (generate.ts, protectBackupText): uninstall reads them back from the file's
489
+ * own entries, and restores it byte-for-byte only when that gives the original
490
+ * exactly. A backup an older runner kept verbatim is converted the same way
491
+ * the next time the file is touched.
492
+ */
493
+ function backupFile(sidecar, file, claudeJson, entryOnly = false) {
387
494
  sidecar.files ??= {};
388
- if (sidecar.files[file])
495
+ if (entryOnly || isClaudeJson(file, claudeJson)) {
496
+ delete sidecar.files[file];
497
+ return;
498
+ }
499
+ const existing = sidecar.files[file];
500
+ if (existing) {
501
+ if (!existing.stale && existing.writtenSha) {
502
+ const current = readTextOrNull(file);
503
+ if (current === null || sha256(current) !== existing.writtenSha) {
504
+ sidecar.files[file] = { originalText: null, writtenSha: existing.writtenSha, stale: true };
505
+ return;
506
+ }
507
+ }
508
+ if (existing.originalText !== null && !existing.stale) {
509
+ const kept = protectBackupText(existing.originalText);
510
+ if (kept.originalText === null) {
511
+ sidecar.files[file] = { ...kept, writtenSha: existing.writtenSha };
512
+ }
513
+ }
514
+ return;
515
+ }
516
+ const text = readTextOrNull(file);
517
+ if (text !== null && holdsRunnerEntries(file, text)) {
518
+ // Not an original: an install that stopped halfway, an older runner, or a
519
+ // teammate's commit already put entries of ours here. Restoring this text
520
+ // "byte-for-byte" would put the runner back and report it gone, so the
521
+ // file is repaired entry by entry, always.
522
+ sidecar.files[file] = { originalText: null, writtenSha: "", stale: true };
389
523
  return;
390
- sidecar.files[file] = { originalText: readTextOrNull(file), writtenSha: "" };
524
+ }
525
+ sidecar.files[file] = text === null ? { originalText: null, writtenSha: "" } : protectBackupText(text);
391
526
  }
392
- function noteWritten(sidecar, file, text) {
527
+ /** True when a config file's text already carries a proxy, the scenario server or one of our hooks. */
528
+ function holdsRunnerEntries(file, text) {
529
+ return /\bbir-(proxy|scenario|hooks)\b/.test(text) || hasBirHooks(file);
530
+ }
531
+ function noteWritten(sidecar, file, text, claudeJson, entryOnly = false) {
532
+ if (entryOnly || isClaudeJson(file, claudeJson))
533
+ return;
393
534
  sidecar.files ??= {};
394
535
  const backup = sidecar.files[file] ?? { originalText: null, writtenSha: "" };
395
536
  backup.writtenSha = sha256(text);
396
537
  sidecar.files[file] = backup;
397
538
  }
539
+ /** The server name a sidecar key names, given the record's own cwd. */
540
+ const recordName = recordServerName;
541
+ /**
542
+ * Store `record` for `name`, replacing `found` (its earlier record, under
543
+ * whichever key and cwd spelling that had). A record whose stash is redacted
544
+ * goes under the key an older runner never reads (generate.ts,
545
+ * REDACTED_KEY_PREFIX): that runner would write the blanked values back.
546
+ */
547
+ function putRecord(sidecar, cwd, name, found, record) {
548
+ if (found)
549
+ delete sidecar.servers[found.key];
550
+ sidecar.servers[recordKey(cwd, name, record.secretsRedacted === true)] = record;
551
+ }
552
+ /**
553
+ * This project's record for `name`: by the exact key, else by any key whose
554
+ * cwd is the same directory spelled differently (a lowercase drive letter).
555
+ */
556
+ function findRecord(sidecar, cwd, name) {
557
+ const exact = sidecarKey(cwd, name);
558
+ if (sidecar.servers[exact])
559
+ return { key: exact, record: sidecar.servers[exact] };
560
+ const want = normalizePath(cwd);
561
+ for (const [key, record] of Object.entries(sidecar.servers)) {
562
+ if (normalizePath(record.cwd) === want && recordName(key, record) === name)
563
+ return { key, record };
564
+ }
565
+ return undefined;
566
+ }
567
+ /**
568
+ * The original entry a record stashed, with its header and env values read
569
+ * back from the wrapped entry (the sidecar keeps names only). Undefined when
570
+ * those values cannot be recovered — then the entry is left alone rather than
571
+ * restored without its credentials.
572
+ */
573
+ function recordedOriginal(record, wrapped) {
574
+ if (!record.original)
575
+ return undefined;
576
+ if (!record.secretsRedacted)
577
+ return record.original;
578
+ return restoreSecrets(record.original, wrapped);
579
+ }
580
+ /** Why an existing wrapped entry is being written again, for the log line. */
581
+ function rewrapReason(current, next) {
582
+ if (!current || isRemote(current) || !isWrapped(current))
583
+ return undefined;
584
+ const was = current.env?.BIR_CONTROL_URL;
585
+ const now = next.env?.BIR_CONTROL_URL;
586
+ if (was !== now)
587
+ return `its control URL was ${was ?? "unset"}, now ${now ?? "unset"}`;
588
+ const argv = current.args ?? [];
589
+ if (argv.includes("--header") && next.env?.[UPSTREAM_HEADERS_ENV] !== undefined) {
590
+ // An older runner passed a remote server's headers as `--header K:V`,
591
+ // readable by any local user through `ps`; they travel in the env now.
592
+ return "its headers move off the command line, into its env";
593
+ }
594
+ if (current.command !== next.command || argv.join("\u0000") !== next.args.slice(0, argv.length).join("\u0000")) {
595
+ const at = argv.findIndex((a) => /bir-proxy/.test(a));
596
+ return `its runner moved (was ${`${current.command} ${at >= 0 ? argv.slice(0, at + 1).join(" ") : argv[0] ?? ""}`.trim()})`;
597
+ }
598
+ return "its configuration changed";
599
+ }
600
+ function sameEntry(a, b) {
601
+ return JSON.stringify(a) === JSON.stringify(b);
602
+ }
603
+ /**
604
+ * Every `projects` key that holds `name` for this directory: the keys Claude
605
+ * Code reads, then legacy spellings of the directory itself (where an older
606
+ * install may have put an entry).
607
+ */
608
+ function localHolders(claude, cwd, name) {
609
+ const projects = claude.projects ?? {};
610
+ const want = [normalizePath(claudeProjectKey(cwd)), normalizePath(cwd)];
611
+ return Object.keys(projects).filter((key) => want.includes(normalizePath(key)) && projects[key]?.mcpServers?.[name] !== undefined);
612
+ }
613
+ /**
614
+ * True when the servers this directory would use (or the `--config` file)
615
+ * include our scenario server — the delivery tool a direct replay needs.
616
+ * An unreadable configuration answers false: replay then stays off.
617
+ */
618
+ function scenarioServerHere(args, cwd) {
619
+ try {
620
+ const servers = args.configPath
621
+ ? Object.entries(readGenericServers(args.configPath)).map(([name, config]) => ({ name, config }))
622
+ : resolveServers(cwd);
623
+ return servers.some((s) => s.name === SCENARIO_SERVER_KEY && isScenarioServer(s.config));
624
+ }
625
+ catch {
626
+ return false;
627
+ }
628
+ }
398
629
  // ── install ────────────────────────────────────────────────────────────────
399
630
  async function install(args) {
400
631
  const cwd = process.cwd();
401
632
  const sidecar = readSidecar();
402
- const invocation = resolveInvocation(args, projectRecord(sidecar, cwd)?.invocation);
633
+ const before = projectRecord(sidecar, cwd);
634
+ const invocation = resolveInvocation(args, before?.invocation, before?.invocationChosen);
635
+ const flagged = args.local || args.global || args.npx;
636
+ const invocationChosen = flagged || (before?.invocationChosen === true && before.invocation === invocation);
403
637
  sidecar.token ??= randomBytes(32).toString("hex");
404
638
  // One port per project (generate.ts): an explicit --port is honoured, else
405
639
  // the one this directory already has, else the lowest free one no other has.
@@ -407,336 +641,1952 @@ async function install(args) {
407
641
  sidecar.controlPort = port;
408
642
  // One token per project too: a token in this project's settings file opens
409
643
  // this project's recorder, not every recorder on the machine.
410
- const token = projectRecord(sidecar, cwd)?.token ?? randomBytes(32).toString("hex");
411
- setProjectRecord(sidecar, cwd, { port, token, invocation });
644
+ const token = before?.token ?? randomBytes(32).toString("hex");
645
+ setProjectRecord(sidecar, cwd, { port, token, invocation, invocationChosen: invocationChosen || undefined });
412
646
  const controlUrl = `http://127.0.0.1:${port}`;
413
- const entries = args.configPath
414
- ? Object.entries(readGenericServers(args.configPath)).map(([name, config]) => ({
415
- name,
416
- config,
417
- scope: "explicit",
418
- shadowed: [],
419
- }))
420
- : resolveServers(cwd);
421
- const wanted = entries.filter((e) => args.servers.length === 0 || args.servers.includes(e.name));
422
- if (wanted.length === 0 && (args.configPath || !args.hooks)) {
423
- // An explicit config file, or proxies-only: with no server there is nothing
424
- // this install could change.
425
- out("No MCP servers found for this directory. Nothing to wrap.");
426
- if (!args.configPath) {
427
- out(" Looked in: ~/.claude.json (user + local scopes) and ./.mcp.json (project scope).");
647
+ const paths = claudeCodePaths(cwd);
648
+ const claudeJson = paths.claudeJson;
649
+ // The old shape, on request: wrap `.mcp.json` servers in place and put the
650
+ // scenario server there too. Only for a `.mcp.json` nobody commits.
651
+ const mcpJsonMode = args.mcpJson || process.env.BIR_WRAP_MCP_JSON === "1";
652
+ // A leftover snapshot of ~/.claude.json from an older install: never used
653
+ // again, and a full copy of every project's settings. Gone.
654
+ for (const file of Object.keys(sidecar.files ?? {})) {
655
+ if (isClaudeJson(file, claudeJson))
656
+ delete sidecar.files?.[file];
657
+ }
658
+ // REFUSE BEFORE WRITING ANYTHING. A config with comments or a trailing comma
659
+ // is not JSON; rewriting it would drop the comments (or, worse, the file).
660
+ // Checking every file up front means a refusal leaves nothing half-done.
661
+ try {
662
+ if (args.configPath)
663
+ assertEditableJson(args.configPath);
664
+ else {
665
+ assertEditableJson(claudeJson);
666
+ assertEditableJson(paths.mcpJson);
667
+ if (args.hooks)
668
+ assertEditableJson(paths.settingsLocal);
669
+ }
670
+ }
671
+ catch (err) {
672
+ process.stderr.write(`[bir] ${errText(err)}\n` +
673
+ "[bir] refusing to rewrite it: fix or remove the comments/trailing commas, then run this again. Nothing was changed.\n");
674
+ return 1;
675
+ }
676
+ // An uninstall that found this project's recorder still serving an open
677
+ // session left the project switched off (see retireRecorder). Installing
678
+ // again is the end of that; a switch the person set with `bir off` is theirs.
679
+ if (clearUninstallMark(cwd))
680
+ out(" ~ the runner is on again here (an earlier uninstall had switched it off)");
681
+ try {
682
+ return await installWriting(args, cwd, sidecar, { before, invocation, port, token, controlUrl, paths, mcpJsonMode });
683
+ }
684
+ catch (err) {
685
+ // WRITE-AHEAD, the cheap way: whatever this run already changed is in
686
+ // `sidecar` — the originals it stashed, the files it snapshotted. Losing
687
+ // that to an EBUSY or a Ctrl-C halfway through left entries wrapped with
688
+ // no record, which `bir uninstall` then reported as nothing to undo.
689
+ try {
690
+ writeSidecar(sidecar);
428
691
  }
692
+ catch {
693
+ /* the original error is the one to report */
694
+ }
695
+ throw err;
696
+ }
697
+ }
698
+ /** Everything `install` writes, after every file it will touch was checked. */
699
+ async function installWriting(args, cwd, sidecar, plan) {
700
+ const { before, invocation, port, token, controlUrl, paths, mcpJsonMode } = plan;
701
+ const claudeJson = paths.claudeJson;
702
+ const nodeFlags = nodeFlagsToCarry();
703
+ const nodeChoice = invocation === "npx" ? undefined : chooseNodePath();
704
+ const gen = {
705
+ controlUrl: args.hooks ? controlUrl : undefined,
706
+ noCorrelation: args.noCorrelation,
707
+ invocation,
708
+ proxyPath: invocation === "npx" ? undefined : localProxyPath(),
709
+ version: VERSION,
710
+ nodeFlags,
711
+ node: nodeChoice?.path,
712
+ };
713
+ const named = (name) => args.servers.includes(name);
714
+ let wrapped = 0;
715
+ const done = args.configPath
716
+ ? installExplicit(args, cwd, sidecar, gen, named, claudeJson)
717
+ : installClaudeCode(args, cwd, sidecar, gen, named, paths, mcpJsonMode);
718
+ if (done.nothing)
429
719
  return 0;
720
+ wrapped = done.wrapped;
721
+ // The scenario server (docs/calculatedReplay.md §6.3). Opt-in, and separate
722
+ // from the rest of install, because it is the one thing here the model can see:
723
+ // it adds `mcp__bir__run_scenario` to the tool list of every session in this
724
+ // project. That is a real cost, and it buys the delivery channel a fully
725
+ // wrapped scenario's results come back through.
726
+ installScenarioServer(args, cwd, sidecar, gen, paths, mcpJsonMode, done.theirBir, done.sharedKey);
727
+ if (!args.configPath) {
728
+ // An older install wrapped `.mcp.json` in place; the steps above put every
729
+ // entry back. Put the bytes back too, so git shows the team's file clean.
730
+ if (!mcpJsonMode)
731
+ restoreMcpJsonBytes(sidecar, paths);
732
+ // Which ~/.claude.json this install wrote to: CLAUDE_CONFIG_DIR moves it,
733
+ // and an uninstall from another environment must still find it.
734
+ const files = projectRecord(sidecar, cwd)?.claudeJsonFiles ?? [];
735
+ if (!files.some((f) => isClaudeJson(f, claudeJson))) {
736
+ setProjectRecord(sidecar, cwd, { port, claudeJsonFiles: [...files, claudeJson] });
737
+ }
738
+ }
739
+ // WHETHER REPLAY IS ON HERE — the switch the recorder reads at start
740
+ // (generate.ts, settleReplaySwitch). It used to be on in every installed
741
+ // project: `--no-replay` printed "not installed" while matched prompts were
742
+ // replayed anyway. Not asked for, it is off now; asked for, on — except
743
+ // where the person turned it off themselves.
744
+ const intent = args.replay ? "wanted" : args.noReplay ? "refused" : "unasked";
745
+ const replaySwitch = settleReplaySwitch(sidecar, cwd, intent, intent === "unasked" && scenarioServerHere(args, cwd));
746
+ if (replaySwitch === "on")
747
+ out(" ~ calculated replay is on again here (an earlier install had turned it off)");
748
+ else if (replaySwitch === "off") {
749
+ out(intent === "refused"
750
+ ? " - calculated replay is off here (--no-replay); `bir replay on` turns it on"
751
+ : " - calculated replay is off here: it was not asked for (`bir install --replay` adds it)");
752
+ }
753
+ // SessionStart starts the recorder itself when this directory has none
754
+ // (control/ensure-hook.ts). Every invocation gets it — an npx one through
755
+ // npx, once per session — because without it nothing starts the recorder,
756
+ // and every other hook fails against a port nobody listens on. The npx form
757
+ // asks for a smaller budget of its own: npx resolves the package before the
758
+ // hook's clock starts, and the two together must stay inside the host's
759
+ // ENSURE_TIMEOUT_S, or the session start shows the hook as timed out.
760
+ const ensure = invocation === "npx"
761
+ ? (() => {
762
+ const via = npxHookInvocation("bir-hooks", VERSION);
763
+ return { command: via.command, args: [...via.args, ENSURE_ARG, ...NPX_ENSURE_BUDGET_ARGS] };
764
+ })()
765
+ : { node: nodeChoice?.path ?? process.execPath, hooksScript: localHooksPath(), nodeFlags };
766
+ if (args.hooks && !args.configPath) {
767
+ // Hooks live in `.claude/settings.local.json` — the person's own file, the
768
+ // one Claude Code keeps out of git — not in the `settings.json` a team
769
+ // commits. They carry a bearer token and an absolute path to this machine's
770
+ // node; a teammate's clone wants neither. An older install put them in the
771
+ // shared file; take them out of there on the way.
772
+ if (hasBirHooks(paths.settings)) {
773
+ try {
774
+ backupFile(sidecar, paths.settings, claudeJson);
775
+ const cleaned = uninstallHooks(paths.settings);
776
+ if (cleaned !== undefined)
777
+ noteWritten(sidecar, paths.settings, cleaned, claudeJson);
778
+ out(` - hooks removed from ${paths.settings} (they live in settings.local.json now)`);
779
+ }
780
+ catch (err) {
781
+ out(` ! ${paths.settings} still holds hooks of ours and could not be cleaned: ${errText(err)}`);
782
+ }
783
+ }
784
+ backupFile(sidecar, paths.settingsLocal, claudeJson);
785
+ let text = installHooks(paths.settingsLocal, buildHooksBlock(controlUrl, token, { ensure }));
786
+ if (args.replay && mcpJsonMode) {
787
+ // Pre-approve our own scenario server. Claude Code asks before it uses a
788
+ // server from `.mcp.json`, and a "No" there leaves a direct replay with
789
+ // nowhere to deliver its results — and nothing to say so.
790
+ text = enableMcpjsonServers(paths.settingsLocal, [SCENARIO_SERVER_KEY]);
791
+ }
792
+ else if (!mcpJsonMode) {
793
+ // The scenario server is a local-scope server now, which Claude Code
794
+ // never asks about; an older install's pre-approval has nothing to approve.
795
+ text = disableMcpjsonServers(paths.settingsLocal, [SCENARIO_SERVER_KEY]) ?? text;
796
+ }
797
+ noteWritten(sidecar, paths.settingsLocal, text, claudeJson);
798
+ sidecar.hookFiles = [...new Set([...(sidecar.hookFiles ?? []), paths.settingsLocal])];
799
+ out(` + hooks → ${paths.settingsLocal} (control server on ${controlUrl}; the recorder starts itself with each session${invocation === "npx" ? ", through npx" : ""})`);
800
+ const exclude = ensureGitExcludeLine(cwd);
801
+ if (exclude) {
802
+ out(` + ${LOCAL_SETTINGS_EXCLUDE} is listed in ${exclude.path}`);
803
+ // Remember whether the line is ours, so uninstall removes only that. An
804
+ // install older than this field is not asked (undefined: removed, as then).
805
+ const excludeAdded = exclude.added ? true : before && before.excludeAdded === undefined ? undefined : (before?.excludeAdded ?? false);
806
+ setProjectRecord(sidecar, cwd, { port, excludeAdded });
807
+ }
808
+ }
809
+ if (nodeChoice?.warning)
810
+ out(` ! ${nodeChoice.warning}`);
811
+ const proxyHint = envProxyHint();
812
+ if (proxyHint)
813
+ out(` ! ${proxyHint}`);
814
+ // THE UPGRADE NOTE ABOUT APPROVAL, said once per project. 0.2.12's recorder
815
+ // answered every wrapped MCP call's PreToolUse with "allow"; this one leaves
816
+ // the decision to Claude Code (no silent auto-approval). Sessions that
817
+ // relied on it — headless `claude -p` in a workspace whose permissions.allow
818
+ // is not read — start getting those calls denied, and nothing else says why.
819
+ const approvalNote = args.hooks && !args.configPath && before !== undefined && before.approvalNoteShown !== true && wrappedHere(cwd);
820
+ if (args.hooks && !args.configPath && projectRecord(sidecar, cwd)?.approvalNoteShown !== true) {
821
+ setProjectRecord(sidecar, cwd, { port, approvalNoteShown: true });
822
+ }
823
+ writeSidecar(sidecar);
824
+ out();
825
+ out(`Wrapped ${wrapped} server${wrapped === 1 ? "" : "s"}.`);
826
+ if (approvalNote) {
827
+ out("Note: the runner no longer approves wrapped MCP tool calls itself (0.2.12 answered every one \"allow\"):");
828
+ out(" Claude Code asks about them as it would without the runner. For headless runs (`claude -p`), allow");
829
+ out(" them in permissions.allow or with --allowedTools, or opt back in with BIR_CORRELATION_DECISION=allow");
830
+ out(" in the recorder's environment (then `bir up --restart`).");
831
+ }
832
+ if (args.hooks && !args.configPath) {
833
+ out("Next: start your session here as usual. The recorder starts itself with it;");
834
+ out("`bir up` starts it now, `bir off --here` takes it out of the way, `bir doctor` checks it.");
835
+ }
836
+ else {
837
+ out("Recording will be standalone (Tier 2): MCP calls only, no prompt or final answer.");
430
838
  }
839
+ const off = offSource(cwd);
840
+ if (off) {
841
+ out(`Note: the runner is OFF ${describeOff(off, cwd)}, so nothing is recorded or replayed until \`bir on\`.`);
842
+ }
843
+ if (args.replay && projectRecord(sidecar, cwd)?.replay?.enabled === false) {
844
+ out();
845
+ out("Calculated replay is INSTALLED but OFF here (`bir replay off`); `bir replay on` turns it on.");
846
+ }
847
+ else if (args.replay) {
848
+ out();
849
+ out("Calculated replay is INSTALLED and ON. `bir replay off` turns it off here, and");
850
+ out("`bir replay allow <servers>` narrows what it may call unattended — read");
851
+ out("docs/calculatedReplayGuide.md §5.1 first: a replayed step is auto-approved,");
852
+ out("and a directly executed one never reaches the permission system at all.");
853
+ }
854
+ return 0;
855
+ }
856
+ /** `--config <file>`: the file is the whole configuration, wrapped in place. */
857
+ function installExplicit(args, cwd, sidecar, gen, named, claudeJson) {
858
+ const file = args.configPath;
859
+ const all = readGenericServers(file);
860
+ const wanted = Object.entries(all).filter(([name]) => args.servers.length === 0 || named(name));
431
861
  if (wanted.length === 0) {
862
+ out("No MCP servers found for this directory. Nothing to wrap.");
863
+ return { wrapped: 0, nothing: true };
864
+ }
865
+ let wrapped = 0;
866
+ let theirBir;
867
+ for (const [name, config] of wanted) {
868
+ if (name === SCENARIO_SERVER_KEY) {
869
+ if (isScenarioServer(config))
870
+ out(` = ${name} — the scenario server (calculated replay)`);
871
+ else {
872
+ theirBir = file;
873
+ out(` ! ${name} — reserved by BaseInstRunner; not wrapped. Rename it to record it`);
874
+ }
875
+ continue;
876
+ }
877
+ const found = findRecord(sidecar, cwd, name);
878
+ let source = config;
879
+ if (isWrapped(config)) {
880
+ source = (found ? recordedOriginal(found.record, config) : undefined) ?? unwrapEntry(config);
881
+ if (!source) {
882
+ out(` = ${name} — already wrapped`);
883
+ continue;
884
+ }
885
+ }
886
+ const blocked = isRemote(source) ? remoteWrapBlocker(source) : undefined;
887
+ const optedIn = named(name) || found?.record.optedIn === true;
888
+ if (blocked && !optedIn) {
889
+ if (isWrapped(config)) {
890
+ backupFile(sidecar, file, claudeJson, true);
891
+ noteWritten(sidecar, file, setGenericServerEntry(file, name, source), claudeJson, true);
892
+ if (found)
893
+ delete sidecar.servers[found.key];
894
+ out(` ~ ${name} — un-wrapped: ${blocked}`);
895
+ }
896
+ else {
897
+ out(` - ${name} — left unwrapped: ${blocked} (\`--server ${name}\` wraps it anyway)`);
898
+ }
899
+ continue;
900
+ }
901
+ if (blocked && named(name))
902
+ out(` ! ${name} — wrapping it because --server names it, although ${blocked}`);
903
+ const proxied = wrapEntry(source, { ...gen, serverName: name });
904
+ if (sameEntry(config, proxied)) {
905
+ out(` = ${name} — already wrapped`);
906
+ continue;
907
+ }
908
+ const reason = rewrapReason(config, proxied);
909
+ backupFile(sidecar, file, claudeJson, true);
910
+ noteWritten(sidecar, file, setGenericServerEntry(file, name, proxied), claudeJson, true);
911
+ const kept = redactSecrets(found?.record.original ?? source);
912
+ putRecord(sidecar, cwd, name, found, {
913
+ original: kept.config,
914
+ scope: "explicit",
915
+ file,
916
+ cwd,
917
+ installedAt: Date.now(),
918
+ ...(kept.redacted ? { secretsRedacted: true } : {}),
919
+ ...(blocked ? { optedIn: true } : {}),
920
+ });
921
+ const kind = isRemote(source) ? `${source.type} ${source.url}` : source.command;
922
+ out(reason ? ` ~ ${name} — re-wrapping: ${reason}` : ` + ${name} → bir-proxy (upstream: ${kind})`);
923
+ wrapped += 1;
924
+ }
925
+ return { wrapped, theirBir };
926
+ }
927
+ /**
928
+ * Claude Code: every server this directory would use, wrapped as a LOCAL-scope
929
+ * entry of the same name.
930
+ *
931
+ * Local scope wins over project and user scope for this project only, and it
932
+ * lives in `~/.claude.json`, which nobody commits. So:
933
+ *
934
+ * - a `.mcp.json` server is overridden, not rewritten — the team's file never
935
+ * carries this machine's node path, runner path or control port, and a
936
+ * teammate's clone keeps working;
937
+ * - a user-scope server is overridden for this project only — it used to be
938
+ * rewritten in place, which routed it through the proxy in EVERY project,
939
+ * including ones never set up;
940
+ * - a local-scope server is wrapped where it is.
941
+ *
942
+ * Entries an older install wrapped in `.mcp.json` or the user scope are put
943
+ * back first (from the stash, else rebuilt from the wrapped arguments).
944
+ */
945
+ function installClaudeCode(args, cwd, sidecar, gen, named, paths, mcpJsonMode) {
946
+ const claudeJson = paths.claudeJson;
947
+ const readClaude = () => parseJsonFile(claudeJson) ?? {};
948
+ let claude = readClaude();
949
+ const projectServers = () => (parseJsonFile(paths.mcpJson)?.mcpServers ?? {});
950
+ const overrideKeys = projectKeysFor(claude.projects, cwd);
951
+ const names = [
952
+ ...new Set([
953
+ ...Object.keys(projectScope(claude.projects, cwd)),
954
+ ...Object.keys(projectServers()),
955
+ ...Object.keys(claude.mcpServers ?? {}),
956
+ ]),
957
+ ].filter((name) => args.servers.length === 0 || named(name));
958
+ const servers = names.filter((n) => n !== SCENARIO_SERVER_KEY);
959
+ // Named as Claude Code finds it: CLAUDE_CONFIG_DIR moves it out of the home directory.
960
+ const claudeLabel = claudeConfigHome() && !process.env.BIR_CLAUDE_JSON ? claudeJson : "~/.claude.json";
961
+ if (servers.length === 0 && !args.hooks) {
962
+ out("No MCP servers found for this directory. Nothing to wrap.");
963
+ out(` Looked in: ${claudeLabel} (user + local scopes) and ./.mcp.json (project scope).`);
964
+ return { wrapped: 0, nothing: true };
965
+ }
966
+ if (servers.length === 0) {
432
967
  // Hooks alone are a recorder: built-in tools, the prompt and the answer are
433
968
  // all theirs, and a scenario over built-in steps steers a session the same
434
969
  // way. Stopping here used to leave such a project with nothing at all.
435
- out("No MCP servers to wrap here (looked in ~/.claude.json and ./.mcp.json).");
970
+ out(`No MCP servers to wrap here (looked in ${claudeLabel} and ./.mcp.json).`);
436
971
  out(" Hooks are still wired: built-in tools and prompts are recorded.");
437
972
  }
438
- const paths = claudeCodePaths(cwd);
439
- /**
440
- * Does a generated entry still name the runner that is running now? After
441
- * an upgrade, a Node moved by a version manager, or a switch from the
442
- * machine's npm prefix to the private one, the old absolute path (or the old
443
- * pinned npx version) is what the host would keep spawning — and "already
444
- * wrapped" is the last thing that should be printed about it.
445
- */
446
- const nodeFlags = nodeFlagsToCarry();
447
- const stillCurrent = (config, scriptPath) => {
448
- if (isRemote(config))
449
- return false;
450
- const argv = config.args ?? [];
451
- if (invocation === "npx")
452
- return config.command === "npx" && argv.includes(packageSpec(VERSION));
453
- const at = argv.indexOf(scriptPath);
454
- if (config.command !== process.execPath || at < 0)
455
- return false;
456
- // The node flags in front of the script must be the ones this run carries.
457
- return argv.slice(0, at).join(" ") === nodeFlags.join(" ");
973
+ // A SHARED KEY. Claude Code keeps the local scope per repository — the
974
+ // canonical git root — so for a subdirectory or a linked worktree, a local
975
+ // copy written here would also apply to the root checkout and every other
976
+ // worktree: directories nobody set up, whose sessions would then be routed
977
+ // through this project's proxies and recorder. Those are left unwrapped (the
978
+ // hooks still record their calls) unless the old `.mcp.json` shape is asked for.
979
+ const root = claudeProjectRoot(cwd);
980
+ const sharedKey = !mcpJsonMode && normalizePath(root) !== normalizePath(cwd);
981
+ if (sharedKey && servers.length > 0) {
982
+ out(` ! not wrapped here: Claude Code keeps local-scope servers per repository (${root}), so a ` +
983
+ "wrapped copy would also apply there and to its other worktrees. Their calls are still recorded " +
984
+ `by the hooks; \`bir setup\` in ${root} wraps them for the repository`);
985
+ }
986
+ // WHAT CLAUDE CODE ITSELF WOULD START WITHOUT ASKING (config/mcp-approval.ts).
987
+ // It asks before it starts a `.mcp.json` server, and never about a
988
+ // local-scope one — so a local copy of a server the person rejected, or was
989
+ // never asked about, would start silently. Only an approved one is copied.
990
+ const approval = mcpjsonApprovals(cwd, claude);
991
+ // An organisation's policy that matches servers by command: wrapping changes
992
+ // the command, so nothing is wrapped under it, `--server` or not.
993
+ const policy = managedMcpPolicyBlocker();
994
+ // Claude Code applies this project's local scope to its linked worktrees too.
995
+ const worktrees = sharedKey || mcpJsonMode ? [] : linkedWorktrees(root);
996
+ /** A worktree's own `.mcp.json` entry for `name`; null when that file is not plain JSON. */
997
+ const worktreeEntry = (dir, name) => {
998
+ try {
999
+ return parseJsonFile(join(dir, ".mcp.json"))?.mcpServers?.[name];
1000
+ }
1001
+ catch {
1002
+ return null;
1003
+ }
458
1004
  };
1005
+ let wroteLocal = false;
1006
+ let theirBir;
1007
+ const bir = projectScope(claude.projects, cwd)[SCENARIO_SERVER_KEY] ??
1008
+ projectServers()[SCENARIO_SERVER_KEY] ??
1009
+ claude.mcpServers?.[SCENARIO_SERVER_KEY];
1010
+ if (bir && !isScenarioServer(bir)) {
1011
+ // `mcp__bir__run_scenario` is how a direct replay's results reach the
1012
+ // model. Wrapping somebody else's `bir` server would route that call to
1013
+ // their server instead, silently — and shadowing it with ours would hide it.
1014
+ theirBir = "yours";
1015
+ out(` ! ${SCENARIO_SERVER_KEY} — reserved by BaseInstRunner; not wrapped. Rename it to record it`);
1016
+ }
459
1017
  let wrapped = 0;
460
- for (const entry of wanted) {
461
- if (entry.name === SCENARIO_SERVER_KEY && isScenarioServer(entry.config)) {
462
- if (!args.replay || stillCurrent(entry.config, localScenarioPath())) {
463
- out(` = ${entry.name} — the scenario server (calculated replay)`);
1018
+ for (const name of servers) {
1019
+ const found = findRecord(sidecar, cwd, name);
1020
+ let P = projectServers()[name];
1021
+ let U = claude.mcpServers?.[name];
1022
+ // 1. MIGRATION: an older install rewrote the shared scopes in place.
1023
+ if (P !== undefined && isWrapped(P) && !mcpJsonMode) {
1024
+ const restored = found?.record.scope === "project" ? recordedOriginal(found.record, P) : undefined;
1025
+ if (restored) {
1026
+ backupFile(sidecar, paths.mcpJson, claudeJson);
1027
+ noteWritten(sidecar, paths.mcpJson, setServerEntry("project", cwd, name, restored, paths).text, claudeJson);
1028
+ delete sidecar.servers[found.key];
1029
+ out(` ~ ${name} — put back as it was in ${paths.mcpJson}: a file a team commits must not carry this machine's paths`);
1030
+ P = restored;
1031
+ }
1032
+ else {
1033
+ out(` ! ${name} — ${paths.mcpJson} holds an entry wrapped by a runner, not by this install ` +
1034
+ "(another machine's, or an older copy): it is overridden here, but teammates without the runner need it put back");
1035
+ }
1036
+ }
1037
+ if (U !== undefined && isWrapped(U)) {
1038
+ const userRecords = Object.entries(sidecar.servers).filter(([key, r]) => r.scope === "user" && recordName(key, r) === name);
1039
+ const mineFirst = [...userRecords].sort((a, b) => Number(normalizePath(b[1].cwd) === normalizePath(cwd)) - Number(normalizePath(a[1].cwd) === normalizePath(cwd)));
1040
+ const restored = mineFirst.length ? recordedOriginal(mineFirst[0][1], U) ?? unwrapEntry(U) : undefined;
1041
+ if (restored) {
1042
+ setServerEntry("user", cwd, name, restored, paths);
1043
+ const others = userRecords.map(([, r]) => r.cwd).filter((c) => normalizePath(c) !== normalizePath(cwd));
1044
+ for (const [key] of userRecords)
1045
+ delete sidecar.servers[key];
1046
+ out(` ~ ${name} — put back as it was in your user scope, which applies to every project; recorded here through a local copy instead`);
1047
+ if (others.length)
1048
+ out(` (${[...new Set(others)].join(", ")} recorded it that way too — run \`bir setup\` there again)`);
1049
+ U = restored;
1050
+ claude = readClaude();
1051
+ }
1052
+ else {
1053
+ out(` ! ${name} — wrapped in your user scope (every project), not by this install; left as it is`);
1054
+ }
1055
+ }
1056
+ // 2. SOURCE (what to wrap) and TARGET (where the wrapped entry goes).
1057
+ const L = projectScope(claude.projects, cwd)[name];
1058
+ const holders = localHolders(claude, cwd, name);
1059
+ let source;
1060
+ let target;
1061
+ const unwrapped = (c) => c !== undefined && isWrapped(c) ? unwrapEntry(c) : c;
1062
+ if (L !== undefined && isWrapped(L) && found?.record.override) {
1063
+ source = unwrapped(P ?? U);
1064
+ target = { kind: "override", from: P !== undefined ? "project" : "user", keys: [...new Set([...overrideKeys, ...holders])] };
1065
+ if (source === undefined) {
1066
+ // The server it overrode is gone from .mcp.json and the user scope.
1067
+ setLocalEntries(claudeJson, target.keys, name, null, (cur) => cur !== undefined && isWrapped(cur));
1068
+ delete sidecar.servers[found.key];
1069
+ out(` - ${name} — its local copy is removed: the server is no longer configured anywhere else`);
1070
+ continue;
1071
+ }
1072
+ }
1073
+ else if (L !== undefined) {
1074
+ source = isWrapped(L)
1075
+ ? (found?.record.scope === "local" ? recordedOriginal(found.record, L) : undefined) ?? unwrapEntry(L)
1076
+ : L;
1077
+ target = { kind: "inplace", keys: holders };
1078
+ }
1079
+ else if (P !== undefined) {
1080
+ source = unwrapped(P);
1081
+ target = mcpJsonMode ? { kind: "mcpjson" } : { kind: "override", from: "project", keys: overrideKeys };
1082
+ }
1083
+ else {
1084
+ source = unwrapped(U);
1085
+ target = { kind: "override", from: "user", keys: overrideKeys };
1086
+ }
1087
+ if (source === undefined) {
1088
+ out(` = ${name} — already wrapped`);
1089
+ continue;
1090
+ }
1091
+ const current = target.kind === "mcpjson" ? P : L;
1092
+ if (sharedKey)
1093
+ continue; // said once, above
1094
+ // 3. Reasons to leave a server exactly as Claude Code would run it. Each
1095
+ // one also TAKES BACK a wrap an earlier install made, so re-running
1096
+ // `bir setup` is always the repair.
1097
+ const src = source;
1098
+ const tgt = target;
1099
+ const leaveUnwrapped = (why, after) => {
1100
+ if (current !== undefined && isWrapped(current)) {
1101
+ if (tgt.kind === "override")
1102
+ setLocalEntries(claudeJson, tgt.keys, name, null, (cur) => cur !== undefined && isWrapped(cur));
1103
+ else if (tgt.kind === "inplace")
1104
+ setLocalEntries(claudeJson, tgt.keys, name, src);
1105
+ else {
1106
+ backupFile(sidecar, paths.mcpJson, claudeJson);
1107
+ noteWritten(sidecar, paths.mcpJson, setServerEntry("project", cwd, name, src, paths).text, claudeJson);
1108
+ }
1109
+ if (found)
1110
+ delete sidecar.servers[found.key];
1111
+ claude = readClaude();
1112
+ out(` ~ ${name} — un-wrapped: ${why}. ${after.charAt(0).toUpperCase()}${after.slice(1)}`);
464
1113
  }
465
1114
  else {
466
- out(` ~ ${entry.name} — re-writing the scenario server: its runner moved`);
1115
+ out(` - ${name} — left unwrapped: ${why}; ${after}`);
467
1116
  }
1117
+ };
1118
+ if (policy) {
1119
+ leaveUnwrapped(policy, "its calls are still recorded by the hooks");
468
1120
  continue;
469
1121
  }
470
- if (entry.name === SCENARIO_SERVER_KEY) {
471
- // `mcp__bir__run_scenario` is how a direct replay's results reach the
472
- // model. Wrapping somebody else's `bir` server would route that call to
473
- // their server instead, silently.
474
- out(` ! ${entry.name} — reserved by BaseInstRunner; not wrapped. Rename it to record it`);
1122
+ const optedIn = named(name) || found?.record.optedIn === true;
1123
+ let pendingOptIn = false;
1124
+ if (target.kind === "override" && target.from === "project") {
1125
+ // 3a. Not approved in Claude Code: a local copy would skip its question.
1126
+ const verdict = approval(name);
1127
+ if (verdict.state === "rejected" || (verdict.state === "pending" && !optedIn)) {
1128
+ leaveUnwrapped(verdict.why, verdict.state === "rejected"
1129
+ ? "a local copy would start it anyway, so none is made"
1130
+ : `the hooks record its calls once you approve it there; run \`bir setup\` again after approving to wrap it (\`--server ${name}\` wraps it now)`);
1131
+ continue;
1132
+ }
1133
+ pendingOptIn = verdict.state === "pending";
1134
+ // 3b. A linked worktree whose own `.mcp.json` says something else would
1135
+ // run this checkout's copy instead of its own entry.
1136
+ const differs = worktrees.filter((dir) => {
1137
+ const theirs = worktreeEntry(dir, name);
1138
+ if (theirs === undefined)
1139
+ return false;
1140
+ return theirs === null || !sameServerConfig(isWrapped(theirs) ? unwrapEntry(theirs) : theirs, source);
1141
+ });
1142
+ if (differs.length > 0 && !named(name)) {
1143
+ leaveUnwrapped(`the worktree${differs.length === 1 ? "" : "s"} ${differs.join(", ")} define${differs.length === 1 ? "s" : ""} it differently, ` +
1144
+ "and Claude Code would run this checkout's copy there too", `its calls are still recorded by the hooks (\`--server ${name}\` wraps it anyway)`);
1145
+ continue;
1146
+ }
1147
+ }
1148
+ // 3c. A remote server Claude Code signs in itself is never wrapped by default.
1149
+ const blocked = isRemote(source) ? remoteWrapBlocker(source) : undefined;
1150
+ if (blocked && !optedIn) {
1151
+ // An older install may have wrapped it, which broke it: taken back.
1152
+ leaveUnwrapped(blocked, `its calls are still recorded by the hooks (\`--server ${name}\` wraps it anyway)`);
475
1153
  continue;
476
1154
  }
477
- if (isWrapped(entry.config)) {
478
- // Already wrapped — but the control URL may have moved since (a second
479
- // `install --port`). A stale `BIR_CONTROL_URL` is not cosmetic: discovery
480
- // short-circuits on it, so the proxy would register with whatever is on
481
- // the old port. If another project's control server is there, this
482
- // project's MCP steps land in *its* run. Re-wrap from the stashed original
483
- // so the entry is regenerated with the current URL.
484
- // A wrapped entry is always a stdio entry (the proxy is what the host
485
- // spawns), so `env` is present in practice; the cast is only to narrow the
486
- // remote-or-stdio union.
487
- const current = entry.config.env?.BIR_CONTROL_URL;
488
- const urlStale = args.hooks && !args.configPath && current !== controlUrl;
489
- const pathStale = !stillCurrent(entry.config, localProxyPath());
490
- const stashed = sidecar.servers[sidecarKey(cwd, entry.name)]?.original;
491
- if (!(urlStale || pathStale) || !stashed) {
492
- out(` = ${entry.name} — already wrapped`);
1155
+ if (blocked && named(name)) {
1156
+ out(` ! ${name} — wrapping it because --server names it, although ${blocked}; if it answers 401, \`bir uninstall --server ${name}\``);
1157
+ }
1158
+ if (pendingOptIn && named(name)) {
1159
+ out(` ! ${name} — wrapping it because --server names it, although Claude Code has not been told to use it: ` +
1160
+ "the local copy starts without asking");
1161
+ }
1162
+ const optIn = blocked || pendingOptIn ? { optedIn: true } : {};
1163
+ // 4. Generate, and write only when it differs — which is what makes a
1164
+ // re-run idempotent and catches every kind of staleness at once: a moved
1165
+ // port, a moved runner or node, changed node flags, a changed upstream.
1166
+ const proxied = wrapEntry(source, { ...gen, serverName: name });
1167
+ const reason = rewrapReason(current, proxied);
1168
+ const kind = isRemote(source) ? `${source.type} ${source.url}` : source.command;
1169
+ if (target.kind === "mcpjson") {
1170
+ if (current !== undefined && sameEntry(current, proxied)) {
1171
+ out(` = ${name} — already wrapped`);
493
1172
  continue;
494
1173
  }
495
- const was = `${entry.config.command} ${(entry.config.args ?? [])[0] ?? ""}`.trim();
496
- entry.config = stashed;
497
- out(urlStale
498
- ? ` ~ ${entry.name} — re-wrapping: its control URL was ${current ?? "unset"}, now ${controlUrl}`
499
- : ` ~ ${entry.name} — re-wrapping: its runner moved (was ${was})`);
500
- }
501
- // Phase 7.3: writing anywhere but the winning scope changes nothing.
502
- if (entry.shadowed.length > 0) {
503
- out(` ! ${entry.name} — also defined in ${entry.shadowed.join(", ")} scope; ` +
504
- `writing to ${entry.scope}, which is the one Claude Code uses`);
505
- }
506
- const proxied = wrapEntry(entry.config, {
507
- serverName: entry.name,
508
- controlUrl: args.hooks ? controlUrl : undefined,
509
- noCorrelation: args.noCorrelation,
510
- invocation,
511
- proxyPath: invocation === "npx" ? undefined : localProxyPath(),
512
- version: VERSION,
513
- nodeFlags,
514
- });
515
- let file;
516
- let text;
517
- if (args.configPath) {
518
- file = args.configPath;
519
- backupFile(sidecar, file);
520
- text = setGenericServerEntry(file, entry.name, proxied);
1174
+ backupFile(sidecar, paths.mcpJson, claudeJson);
1175
+ noteWritten(sidecar, paths.mcpJson, setServerEntry("project", cwd, name, proxied, paths).text, claudeJson);
1176
+ const kept = redactSecrets((found?.record.scope === "project" ? found.record.original : undefined) ?? source);
1177
+ putRecord(sidecar, cwd, name, found, {
1178
+ original: kept.config,
1179
+ scope: "project",
1180
+ file: paths.mcpJson,
1181
+ cwd,
1182
+ installedAt: Date.now(),
1183
+ ...(kept.redacted ? { secretsRedacted: true } : {}),
1184
+ ...optIn,
1185
+ });
521
1186
  }
522
1187
  else {
523
- file = fileForScope(entry.scope, paths);
524
- backupFile(sidecar, file);
525
- const written = setServerEntry(entry.scope, cwd, entry.name, proxied, paths);
526
- text = written.text;
527
- }
528
- noteWritten(sidecar, file, text);
529
- sidecar.servers[sidecarKey(cwd, entry.name)] = {
530
- original: entry.config,
531
- scope: entry.scope,
532
- file,
533
- cwd,
534
- installedAt: Date.now(),
535
- };
536
- const kind = isRemote(entry.config) ? `${entry.config.type} ${entry.config.url}` : entry.config.command;
537
- out(` + ${entry.name} → bir-proxy (${entry.scope} scope, upstream: ${kind})`);
1188
+ // Never over a user's own entry under another spelling of this project.
1189
+ const guard = target.kind === "override"
1190
+ ? (cur) => cur === undefined || isWrapped(cur)
1191
+ : (cur) => cur !== undefined;
1192
+ const { keys } = setLocalEntries(claudeJson, target.keys, name, proxied, guard);
1193
+ claude = readClaude();
1194
+ wroteLocal = true;
1195
+ const allKeys = [...new Set([...(found?.record.keys ?? []), ...keys, ...localHolders(claude, cwd, name)])];
1196
+ if (target.kind === "override") {
1197
+ putRecord(sidecar, cwd, name, found, {
1198
+ original: null,
1199
+ scope: "local",
1200
+ file: claudeJson,
1201
+ cwd,
1202
+ installedAt: Date.now(),
1203
+ override: { source: target.from },
1204
+ // What it copied, so `bir doctor` notices when that entry changes
1205
+ // (config/overrides.ts): the copy outranks it from now on.
1206
+ sourceSha: configSha(source),
1207
+ keys: allKeys,
1208
+ ...optIn,
1209
+ });
1210
+ }
1211
+ else {
1212
+ const kept = redactSecrets((found?.record.scope === "local" ? found.record.original : undefined) ?? source);
1213
+ putRecord(sidecar, cwd, name, found, {
1214
+ original: kept.config,
1215
+ scope: "local",
1216
+ file: claudeJson,
1217
+ cwd,
1218
+ installedAt: Date.now(),
1219
+ keys: allKeys,
1220
+ ...(kept.redacted ? { secretsRedacted: true } : {}),
1221
+ ...optIn,
1222
+ });
1223
+ }
1224
+ if (keys.length === 0) {
1225
+ out(` = ${name} — already wrapped`);
1226
+ continue;
1227
+ }
1228
+ }
1229
+ const where = target.kind === "override"
1230
+ ? `local scope, over the ${target.from === "project" ? ".mcp.json" : "user-scope"} entry`
1231
+ : target.kind === "mcpjson"
1232
+ ? "project scope, in .mcp.json"
1233
+ : "local scope";
1234
+ out(reason ? ` ~ ${name} — re-wrapping: ${reason}` : ` + ${name} → bir-proxy (${where}; upstream: ${kind})`);
538
1235
  wrapped += 1;
539
1236
  }
540
- // The scenario server (docs/calculatedReplay.md §6.3). Opt-in, and separate
541
- // from the rest of install, because it is the one thing here the model can see:
542
- // it adds `mcp__bir__run_scenario` to the tool list of every session in this
543
- // project. That is a real cost, and it buys the delivery channel a fully
544
- // wrapped scenario's results come back through.
545
- if (args.replay) {
546
- const entry = scenarioEntry({
547
- invocation,
548
- scenarioPath: invocation === "npx" ? undefined : localScenarioPath(),
549
- version: VERSION,
550
- controlUrl: args.hooks ? controlUrl : undefined,
551
- nodeFlags,
552
- });
553
- // PROJECT SCOPE, NOT LOCAL — this was a bug worth the comment.
554
- //
555
- // The local scope lives in `~/.claude.json` under `projects[<path>]`, and
556
- // Claude Code keys that map with FORWARD slashes, resolved to the project
557
- // root. `process.cwd()` on Windows gives backslashes and the actual working
558
- // directory, so an entry written from here lands under a key Claude Code
559
- // never looks at: `bir status` shows the server, `/mcp` does not, and a
560
- // direct replay has nowhere to deliver its results. Observed, not theorised
561
- // — see `paths.ts`, which warns about exactly this trap for the proxy.
562
- //
563
- // `.mcp.json` has no such ambiguity: it is a file in this directory, read by
564
- // path. It is also the file the proxies already go into, so the scenario
565
- // server sits beside them and `bir uninstall` removes both the same way.
566
- const scope = args.configPath ? "explicit" : "project";
567
- let file;
568
- let text;
569
- if (args.configPath) {
570
- file = args.configPath;
571
- backupFile(sidecar, file);
572
- text = setGenericServerEntry(file, SCENARIO_SERVER_KEY, entry);
573
- }
574
- else {
575
- file = fileForScope(scope, paths);
576
- backupFile(sidecar, file);
577
- text = setServerEntry(scope, cwd, SCENARIO_SERVER_KEY, entry, paths).text;
1237
+ if (wroteLocal && worktrees.length > 0) {
1238
+ // Not a refusal — the main checkout is where `bir setup` was asked for —
1239
+ // but sessions in those directories now run these entries too.
1240
+ out(` · Claude Code shares this local scope with the linked worktree${worktrees.length === 1 ? "" : "s"} ` +
1241
+ `${worktrees.join(", ")}: sessions there run the wrapped servers too, recorded by this project's recorder ` +
1242
+ "(`bir doctor` there says so)");
1243
+ }
1244
+ return { wrapped, theirBir, sharedKey };
1245
+ }
1246
+ /** The `bir` scenario server: local scope (or the explicit file, or `.mcp.json` on request). */
1247
+ function installScenarioServer(args, cwd, sidecar, gen, paths, mcpJsonMode, theirBir, sharedKey = false) {
1248
+ const claudeJson = paths.claudeJson;
1249
+ const found = findRecord(sidecar, cwd, SCENARIO_SERVER_KEY);
1250
+ // Migration: an older install put it in `.mcp.json`, the file a team commits.
1251
+ // A project that had it keeps it — a plain `bir install` (the by-hand path,
1252
+ // or `--server x`) must not take direct replay's delivery tool away while
1253
+ // saying it was moved — unless it cannot go to the local scope at all.
1254
+ let migrated = false;
1255
+ if (!args.configPath && !mcpJsonMode && found?.record.scope === "project") {
1256
+ const inMcp = parseJsonFile(paths.mcpJson)?.mcpServers?.[SCENARIO_SERVER_KEY];
1257
+ if (inMcp !== undefined && isScenarioServer(inMcp)) {
1258
+ backupFile(sidecar, paths.mcpJson, claudeJson);
1259
+ const text = setServerEntry("project", cwd, SCENARIO_SERVER_KEY, null, paths).text;
1260
+ noteWritten(sidecar, paths.mcpJson, text, claudeJson);
1261
+ removeIfOnlyOurs(sidecar, paths.mcpJson);
1262
+ migrated = true;
1263
+ const staysOut = sharedKey || Boolean(theirBir);
1264
+ out(staysOut
1265
+ ? ` ~ ${SCENARIO_SERVER_KEY} — removed from ${paths.mcpJson} (a file a team commits); it could not be put in the local scope (see below)`
1266
+ : ` ~ ${SCENARIO_SERVER_KEY} — moved out of ${paths.mcpJson} (a file a team commits) into the local scope`);
578
1267
  }
579
- noteWritten(sidecar, file, text);
1268
+ delete sidecar.servers[found.key];
1269
+ }
1270
+ if (!args.replay && !migrated) {
1271
+ const present = args.configPath
1272
+ ? readGenericServers(args.configPath)[SCENARIO_SERVER_KEY]
1273
+ : projectScope((parseJsonFile(claudeJson) ?? {}).projects, cwd)[SCENARIO_SERVER_KEY];
1274
+ if (present !== undefined && isScenarioServer(present))
1275
+ out(` = ${SCENARIO_SERVER_KEY} — the scenario server (calculated replay)`);
1276
+ return;
1277
+ }
1278
+ if (sharedKey && !args.configPath && !mcpJsonMode) {
1279
+ out(` ! ${SCENARIO_SERVER_KEY} — the scenario server was not added: in the local scope it would apply to the whole repository (see above); replay steers without it`);
1280
+ return;
1281
+ }
1282
+ if (theirBir) {
1283
+ out(` ! ${SCENARIO_SERVER_KEY} — the scenario server was not added: a server of yours is called ${SCENARIO_SERVER_KEY}`);
1284
+ return;
1285
+ }
1286
+ const entry = scenarioEntry({
1287
+ invocation: gen.invocation,
1288
+ scenarioPath: gen.invocation === "npx" ? undefined : localScenarioPath(),
1289
+ version: VERSION,
1290
+ controlUrl: gen.controlUrl,
1291
+ nodeFlags: gen.nodeFlags,
1292
+ node: gen.node,
1293
+ });
1294
+ const record = (scope, file, keys) => {
580
1295
  sidecar.servers[sidecarKey(cwd, SCENARIO_SERVER_KEY)] = {
581
1296
  original: null, // it did not exist before; uninstall removes rather than restores
582
1297
  scope,
583
1298
  file,
584
1299
  cwd,
585
1300
  installedAt: Date.now(),
1301
+ ...(keys ? { keys } : {}),
586
1302
  };
587
- out(` + ${SCENARIO_SERVER_KEY} → bir-scenario (calculated replay; ${scope} scope)`);
1303
+ };
1304
+ let changed;
1305
+ let current;
1306
+ if (args.configPath) {
1307
+ current = readGenericServers(args.configPath)[SCENARIO_SERVER_KEY];
1308
+ changed = !sameEntry(current, entry);
1309
+ if (changed) {
1310
+ backupFile(sidecar, args.configPath, claudeJson, true);
1311
+ noteWritten(sidecar, args.configPath, setGenericServerEntry(args.configPath, SCENARIO_SERVER_KEY, entry), claudeJson, true);
1312
+ }
1313
+ if (changed || !found)
1314
+ record("explicit", args.configPath);
1315
+ }
1316
+ else if (mcpJsonMode) {
1317
+ current = parseJsonFile(paths.mcpJson)?.mcpServers?.[SCENARIO_SERVER_KEY];
1318
+ changed = !sameEntry(current, entry);
1319
+ if (changed) {
1320
+ backupFile(sidecar, paths.mcpJson, claudeJson);
1321
+ noteWritten(sidecar, paths.mcpJson, setServerEntry("project", cwd, SCENARIO_SERVER_KEY, entry, paths).text, claudeJson);
1322
+ }
1323
+ record("project", paths.mcpJson);
1324
+ }
1325
+ else {
1326
+ // LOCAL SCOPE, under the key Claude Code itself reads (resolve.ts,
1327
+ // claudeProjectKey). An older runner wrote it under the raw working
1328
+ // directory — backslashes on Windows — which Claude Code never reads, and
1329
+ // moved it to `.mcp.json` to escape that; the key is right now, and
1330
+ // `.mcp.json` is a file a team commits.
1331
+ const claude = parseJsonFile(claudeJson) ?? {};
1332
+ const keys = projectKeysFor(claude.projects, cwd);
1333
+ current = projectScope(claude.projects, cwd)[SCENARIO_SERVER_KEY];
1334
+ const written = setLocalEntries(claudeJson, keys, SCENARIO_SERVER_KEY, entry, (cur) => cur === undefined || isScenarioServer(cur));
1335
+ changed = written.keys.length > 0;
1336
+ record("local", claudeJson, [...new Set([...keys, ...(found?.record.keys ?? [])])]);
1337
+ }
1338
+ if (!changed)
1339
+ out(` = ${SCENARIO_SERVER_KEY} — the scenario server (calculated replay)`);
1340
+ else if (current !== undefined && isScenarioServer(current))
1341
+ out(` ~ ${SCENARIO_SERVER_KEY} — re-writing the scenario server: its runner or control URL moved`);
1342
+ else
1343
+ out(` + ${SCENARIO_SERVER_KEY} → bir-scenario (calculated replay; ${args.configPath ? "explicit" : mcpJsonMode ? "project" : "local"} scope)`);
1344
+ }
1345
+ /** True when a server this directory's sessions would start runs through the runner. */
1346
+ function wrappedHere(cwd) {
1347
+ try {
1348
+ return resolveServers(cwd).some((s) => s.name !== SCENARIO_SERVER_KEY && isWrapped(s.config));
1349
+ }
1350
+ catch {
1351
+ return false;
1352
+ }
1353
+ }
1354
+ /** A JSON value with every object's keys sorted, for comparing two texts by content. */
1355
+ function canonicalJson(value) {
1356
+ const sort = (v) => Array.isArray(v)
1357
+ ? v.map(sort)
1358
+ : v && typeof v === "object"
1359
+ ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sort(v[k])]))
1360
+ : v;
1361
+ return JSON.stringify(sort(value));
1362
+ }
1363
+ /**
1364
+ * After a migration took every runner entry an older install wrote into
1365
+ * `.mcp.json` back out (each restored through a JSON edit, which re-indents
1366
+ * the whole file), put the file's own bytes back when it now says exactly
1367
+ * what it said before that install — so the team's committed file shows no
1368
+ * diff at all, as "put back as it was" promises. Only from a backup nobody
1369
+ * changed the file under (not stale), only once nothing of ours is left in
1370
+ * it, and only when the content is equal; anything else is left as written.
1371
+ */
1372
+ function restoreMcpJsonBytes(sidecar, paths) {
1373
+ try {
1374
+ const file = paths.mcpJson;
1375
+ const backup = sidecar.files?.[file];
1376
+ if (!backup || backup.stale || backupIsCreation(backup))
1377
+ return;
1378
+ const current = readTextOrNull(file);
1379
+ if (current === null || holdsRunnerEntries(file, current))
1380
+ return;
1381
+ if (!backup.writtenSha || sha256(current) !== backup.writtenSha)
1382
+ return;
1383
+ const original = backupOriginalText(backup, current);
1384
+ if (typeof original !== "string" || original === current)
1385
+ return;
1386
+ const strip = (t) => (t.charCodeAt(0) === 0xfeff ? t.slice(1) : t);
1387
+ if (canonicalJson(JSON.parse(strip(current))) !== canonicalJson(JSON.parse(strip(original))))
1388
+ return;
1389
+ writeFileAtomic(file, original);
1390
+ noteWritten(sidecar, file, original, paths.claudeJson);
1391
+ }
1392
+ catch {
1393
+ /* left as the migration wrote it: same content, other whitespace */
1394
+ }
1395
+ }
1396
+ /**
1397
+ * Delete a `.mcp.json` that an install created and that now holds nothing but
1398
+ * an empty `mcpServers` — what taking our entries out of it leaves behind.
1399
+ */
1400
+ function removeIfOnlyOurs(sidecar, file) {
1401
+ const backup = sidecar.files?.[file];
1402
+ if (!backup || !backupIsCreation(backup) || backup.stale)
1403
+ return;
1404
+ const parsed = parseJsonFile(file);
1405
+ if (!parsed)
1406
+ return;
1407
+ const keys = Object.keys(parsed);
1408
+ const servers = parsed.mcpServers;
1409
+ if (keys.length === 1 && keys[0] === "mcpServers" && servers && Object.keys(servers).length === 0) {
1410
+ try {
1411
+ rmSync(file, { force: true });
1412
+ delete sidecar.files?.[file];
1413
+ }
1414
+ catch {
1415
+ /* leave it */
1416
+ }
1417
+ }
1418
+ }
1419
+ // ── uninstall ──────────────────────────────────────────────────────────────
1420
+ /**
1421
+ * The project directory a settings file or `.mcp.json` sits in (normalised),
1422
+ * or undefined for any other file. What a file belongs to is decided by where
1423
+ * it is, not by whether it is somewhere under the directory being uninstalled:
1424
+ * a project nested inside another keeps its own settings, and uninstalling the
1425
+ * outer one must not take the inner one's hooks with it.
1426
+ */
1427
+ function fileProjectDir(file) {
1428
+ const n = normalizePath(file);
1429
+ return (/^(.*)\/\.claude\/settings(?:\.local)?\.json$/i.exec(n) ?? /^(.*)\/\.mcp\.json$/i.exec(n))?.[1];
1430
+ }
1431
+ /**
1432
+ * Every directory an install on this machine touched: normalised → as
1433
+ * spelled (a record's own spelling first, so messages name it as it was typed).
1434
+ */
1435
+ function installedProjects(sidecar) {
1436
+ const found = new Map();
1437
+ const add = (dir) => {
1438
+ if (!dir)
1439
+ return;
1440
+ const key = normalizePath(dir);
1441
+ if (!found.has(key))
1442
+ found.set(key, dir);
1443
+ };
1444
+ for (const record of Object.values(sidecar.servers))
1445
+ add(record.cwd);
1446
+ for (const file of sidecar.hookFiles ?? [])
1447
+ add(dirname(dirname(file)));
1448
+ for (const key of Object.keys(sidecar.projects ?? {}))
1449
+ add(key);
1450
+ return found;
1451
+ }
1452
+ /** The installed projects other than `cwd`, as spelled. */
1453
+ function otherProjects(sidecar, cwd) {
1454
+ const here = normalizePath(cwd);
1455
+ return [...installedProjects(sidecar)].filter(([key]) => key !== here).map(([, dir]) => dir);
1456
+ }
1457
+ /** A proxy or the scenario server: an entry that stops working when the runner is removed. */
1458
+ function ourEntry(e) {
1459
+ return e !== undefined && (isWrapped(e) || isScenarioServer(e));
1460
+ }
1461
+ function leftoverWhere(e) {
1462
+ const other = e.file ? ` of ${e.file}` : "";
1463
+ if (e.scope === "local")
1464
+ return `local scope${other}`;
1465
+ if (e.scope === "user")
1466
+ return `your user scope${other}, which every project uses`;
1467
+ return e.file ?? e.scope;
1468
+ }
1469
+ /**
1470
+ * The other `.claude.json` files this project's install wrote to — named by its
1471
+ * records and its project record, and not the one this environment reads
1472
+ * (claudeJsonPath follows CLAUDE_CONFIG_DIR). Only files that exist.
1473
+ */
1474
+ function otherClaudeFiles(sidecar, cwd, claudeJson) {
1475
+ const here = normalizePath(cwd);
1476
+ const named = [
1477
+ ...Object.values(sidecar.servers)
1478
+ .filter((r) => normalizePath(r.cwd) === here && (r.scope === "local" || r.scope === "user"))
1479
+ .map((r) => r.file),
1480
+ ...(projectRecord(sidecar, cwd)?.claudeJsonFiles ?? []),
1481
+ ];
1482
+ const seen = new Set([normalizePath(claudeJson)]);
1483
+ const found = [];
1484
+ for (const file of named) {
1485
+ if (typeof file !== "string" || !file || seen.has(normalizePath(file)))
1486
+ continue;
1487
+ seen.add(normalizePath(file));
1488
+ if (existsSync(file))
1489
+ found.push(file);
1490
+ }
1491
+ return found;
1492
+ }
1493
+ /**
1494
+ * Every entry of ours this directory's sessions could still spawn, scope by
1495
+ * scope — not only the winning one: taking a local copy out lets the
1496
+ * `.mcp.json` or user-scope entry under it win, and that one may be wrapped too.
1497
+ *
1498
+ * The local scope is read under this directory's own spellings, and under the
1499
+ * repository's key only when that repository is not an installed project of
1500
+ * its own: a subdirectory shares its root's local scope, and what the root's
1501
+ * install wrapped there is the root's to undo. A file that cannot be read is
1502
+ * returned as such, so the caller reports it instead of calling it clean.
1503
+ */
1504
+ function runnerLeftovers(cwd, sidecar, configPath, otherClaude = []) {
1505
+ const unreadable = [];
1506
+ const found = [];
1507
+ if (configPath) {
1508
+ try {
1509
+ for (const [name, config] of Object.entries(readGenericServers(configPath))) {
1510
+ if (ourEntry(config))
1511
+ found.push({ name, scope: "explicit", config, file: configPath });
1512
+ }
1513
+ }
1514
+ catch (err) {
1515
+ if (existsSync(configPath))
1516
+ unreadable.push(`${configPath} (${errText(err)})`);
1517
+ }
1518
+ return { leftovers: found, unreadable };
1519
+ }
1520
+ const paths = claudeCodePaths(cwd);
1521
+ const here = normalizePath(cwd);
1522
+ const root = normalizePath(claudeProjectRoot(cwd));
1523
+ const want = new Set([here]);
1524
+ if (root === here || !installedProjects(sidecar).has(root))
1525
+ want.add(normalizePath(claudeProjectKey(cwd)));
1526
+ // This environment's `.claude.json` first, then any other one this project's
1527
+ // install wrote to (another CLAUDE_CONFIG_DIR), named as such.
1528
+ const claudeFiles = [
1529
+ { file: paths.claudeJson, other: false },
1530
+ ...otherClaude.filter((f) => !isClaudeJson(f, paths.claudeJson)).map((file) => ({ file, other: true })),
1531
+ ];
1532
+ const users = [];
1533
+ for (const { file, other } of claudeFiles) {
1534
+ let claude = {};
1535
+ try {
1536
+ claude = parseJsonFile(file) ?? {};
1537
+ }
1538
+ catch (err) {
1539
+ unreadable.push(`${file} (${errText(err)})`);
1540
+ continue;
1541
+ }
1542
+ const local = new Map();
1543
+ for (const [key, project] of Object.entries(claude.projects ?? {})) {
1544
+ if (!want.has(normalizePath(key)))
1545
+ continue;
1546
+ for (const [name, config] of Object.entries(project?.mcpServers ?? {})) {
1547
+ if (!ourEntry(config))
1548
+ continue;
1549
+ const seen = local.get(name);
1550
+ if (seen)
1551
+ seen.keys?.push(key);
1552
+ else
1553
+ local.set(name, { name, scope: "local", config, keys: [key], ...(other ? { file } : {}) });
1554
+ }
1555
+ }
1556
+ found.push(...local.values());
1557
+ for (const [name, config] of Object.entries(claude.mcpServers ?? {})) {
1558
+ if (ourEntry(config))
1559
+ users.push({ name, scope: "user", config, ...(other ? { file } : {}) });
1560
+ }
1561
+ }
1562
+ try {
1563
+ const mcp = parseJsonFile(paths.mcpJson)?.mcpServers;
1564
+ for (const [name, config] of Object.entries(mcp ?? {})) {
1565
+ if (ourEntry(config))
1566
+ found.push({ name, scope: "project", config, file: paths.mcpJson });
1567
+ }
1568
+ }
1569
+ catch (err) {
1570
+ unreadable.push(`${paths.mcpJson} (${errText(err)})`);
1571
+ }
1572
+ found.push(...users);
1573
+ return { leftovers: found, unreadable };
1574
+ }
1575
+ /**
1576
+ * The `.mcp.json` or user-scope entry a local entry of `name` shadows, if any —
1577
+ * unwrapped when an older runner wrapped it there, so it compares with what a
1578
+ * local copy of it was made from. `claudeJson`: the file whose user scope is
1579
+ * below (default: this environment's).
1580
+ */
1581
+ function entryBelow(cwd, name, claudeJson) {
1582
+ try {
1583
+ const paths = claudeCodePaths(cwd, claudeJson);
1584
+ const mcp = parseJsonFile(paths.mcpJson)?.mcpServers;
1585
+ const below = mcp?.[name] ?? (parseJsonFile(paths.claudeJson)?.mcpServers ?? {})[name];
1586
+ return below !== undefined && isWrapped(below) ? unwrapEntry(below) : below;
1587
+ }
1588
+ catch {
1589
+ return undefined;
1590
+ }
1591
+ }
1592
+ /** True when another installed project lives in the same repository, and so shares its exclude file. */
1593
+ function sharesRepository(cwd, sidecar) {
1594
+ const root = normalizePath(claudeProjectRoot(cwd));
1595
+ return otherProjects(sidecar, cwd).some((dir) => normalizePath(claudeProjectRoot(dir)) === root);
1596
+ }
1597
+ /**
1598
+ * Undo `bir install` for ONE directory — the whole of it, or only
1599
+ * `opts.servers` — and leave every other project exactly as it is.
1600
+ *
1601
+ * In order: the byte-for-byte restores (a project file we wrote and nobody
1602
+ * touched since); the entries a record stashed; every entry of ours no record
1603
+ * explains, rebuilt from the wrapped entry's own arguments (an install that
1604
+ * stopped halfway, an older runner, a lost installed.json, a cwd spelled with
1605
+ * another drive-letter case); then the hooks. Last it READS BACK what a
1606
+ * session here would spawn, and reports anything of ours still there —
1607
+ * "Uninstalled." followed by removing the package is exactly how a user's
1608
+ * servers end up pointing at files that are gone.
1609
+ *
1610
+ * Bookkeeping is dropped only for what was undone, so running it again
1611
+ * retries the rest. Writes the sidecar; never touches the recorder.
1612
+ */
1613
+ function uninstallProject(cwd, sidecar, opts) {
1614
+ const paths = claudeCodePaths(cwd);
1615
+ const claudeJson = paths.claudeJson;
1616
+ const here = normalizePath(cwd);
1617
+ const narrow = opts.servers.length > 0;
1618
+ const wanted = (name) => !narrow || opts.servers.includes(name);
1619
+ const mine = Object.entries(sidecar.servers)
1620
+ .filter(([, record]) => normalizePath(record.cwd) === here)
1621
+ .map(([key, record]) => ({ key, record, name: recordName(key, record) }));
1622
+ const remaining = new Map();
1623
+ // Every `.claude.json` this project's install wrote to, besides this
1624
+ // environment's: known now, before the records that name them are dropped.
1625
+ const otherClaude = opts.configPath ? [] : otherClaudeFiles(sidecar, cwd, claudeJson);
1626
+ /**
1627
+ * The `.claude.json` a local- or user-scope record's entry is in: the file
1628
+ * the install wrote, which CLAUDE_CONFIG_DIR may have made another one than
1629
+ * this environment's — not whichever this shell happens to read.
1630
+ */
1631
+ const claudeFileOf = (record) => (record.scope === "local" || record.scope === "user") && typeof record.file === "string" && record.file
1632
+ ? record.file
1633
+ : claudeJson;
1634
+ const readClaude = (file = claudeJson) => parseJsonFile(file) ?? {};
1635
+ const currentEntry = (record, name) => {
1636
+ if (record.scope === "explicit")
1637
+ return readGenericServers(record.file)[name];
1638
+ if (record.scope === "local") {
1639
+ const claude = readClaude(claudeFileOf(record));
1640
+ for (const key of record.keys ?? [projectKeyFor(claude.projects, record.cwd)]) {
1641
+ const e = claude.projects?.[key]?.mcpServers?.[name];
1642
+ if (e !== undefined)
1643
+ return e;
1644
+ }
1645
+ return undefined;
1646
+ }
1647
+ if (record.scope === "user")
1648
+ return readClaude(claudeFileOf(record)).mcpServers?.[name];
1649
+ return parseJsonFile(record.file)?.mcpServers?.[name];
1650
+ };
1651
+ /** Put one entry back (or take it out), touching it only while it is still ours. */
1652
+ const repair = (record, name) => {
1653
+ if (record.scope === "explicit" && !existsSync(record.file)) {
1654
+ return { what: `its config file ${record.file} is gone; nothing to undo`, done: true };
1655
+ }
1656
+ const file = claudeFileOf(record);
1657
+ const elsewhere = (record.scope === "local" || record.scope === "user") && !isClaudeJson(file, claudeJson);
1658
+ if (elsewhere && !existsSync(file)) {
1659
+ return { what: `its config file ${file} is gone; nothing to undo`, done: true };
1660
+ }
1661
+ const where = elsewhere ? ` in ${file}, not this environment's ${claudeJson}` : "";
1662
+ const now = currentEntry(record, name);
1663
+ if (!ourEntry(now))
1664
+ return { what: `left as it is (changed since install)${where}`, done: true };
1665
+ // The stash first; else the original rebuilt from the wrapped entry's own
1666
+ // arguments. An entry left wrapped breaks the day the runner is removed.
1667
+ const restored = record.original === null ? null : (recordedOriginal(record, now) ?? unwrapEntry(now));
1668
+ if (restored === undefined)
1669
+ return { what: `left as it is (its original could not be rebuilt)${where}`, done: false };
1670
+ if (record.scope === "explicit")
1671
+ setGenericServerEntry(record.file, name, restored);
1672
+ else if (record.scope === "local") {
1673
+ const keys = record.keys ?? [projectKeyFor(readClaude(file).projects, record.cwd)];
1674
+ setLocalEntries(file, keys, name, restored, ourEntry);
1675
+ }
1676
+ else if (record.scope === "user")
1677
+ setServerEntry("user", record.cwd, name, restored, { ...paths, claudeJson: file });
1678
+ else
1679
+ setServerEntry(record.scope, record.cwd, name, restored, paths);
1680
+ return { what: `${restored === null ? "removed" : "restored"}${where}`, done: true };
1681
+ };
1682
+ const myRecordFiles = new Set(mine.map((m) => normalizePath(m.record.file)));
1683
+ const ownsFile = (file) => {
1684
+ const dir = fileProjectDir(file);
1685
+ if (dir !== undefined)
1686
+ return dir === here;
1687
+ return normalizePath(file).startsWith(`${here}/`) || myRecordFiles.has(normalizePath(file));
1688
+ };
1689
+ const myHookFiles = (sidecar.hookFiles ?? []).filter((f) => fileProjectDir(f) === here);
1690
+ const hookedNow = [paths.settingsLocal, paths.settings].filter((f) => hasBirHooks(f));
1691
+ const before = runnerLeftovers(cwd, sidecar, opts.configPath, otherClaude);
1692
+ if (mine.length === 0 &&
1693
+ myHookFiles.length === 0 &&
1694
+ hookedNow.length === 0 &&
1695
+ before.leftovers.length === 0 &&
1696
+ before.unreadable.length === 0) {
1697
+ return { nothing: true, remaining: [] };
1698
+ }
1699
+ // 1. Byte-for-byte: a project file we wrote, nobody touched since, and whose
1700
+ // snapshot is not stale goes back exactly as it was. Never ~/.claude.json,
1701
+ // and never a file another project's install also wrote.
1702
+ const restoredWhole = new Set();
1703
+ const othersFiles = new Set(Object.values(sidecar.servers)
1704
+ .filter((r) => normalizePath(r.cwd) !== here)
1705
+ .map((r) => normalizePath(r.file)));
1706
+ if (!narrow) {
1707
+ for (const [file, backup] of Object.entries(sidecar.files ?? {})) {
1708
+ if (isClaudeJson(file, claudeJson) || othersFiles.has(normalizePath(file)) || !ownsFile(file))
1709
+ continue;
1710
+ const current = readTextOrNull(file);
1711
+ if (current === null || !backup.writtenSha || backup.stale || sha256(current) !== backup.writtenSha)
1712
+ continue;
1713
+ if (backupIsCreation(backup)) {
1714
+ out(` - ${file} — created by install; leaving in place (now empty of our entries)`);
1715
+ continue;
1716
+ }
1717
+ // Kept without its env and header values (generate.ts,
1718
+ // protectBackupText): read back from the entries in the file as we left
1719
+ // it, and used only when that gives the original exactly.
1720
+ const original = backupOriginalText(backup, current);
1721
+ if (typeof original !== "string") {
1722
+ out(` ~ ${file} — its entries are repaired one by one (the values in it could not all be read back)`);
1723
+ continue;
1724
+ }
1725
+ try {
1726
+ writeFileAtomic(file, original);
1727
+ }
1728
+ catch (err) {
1729
+ out(` ! ${file} — could not be restored whole (${errText(err)}); repairing its entries instead`);
1730
+ continue;
1731
+ }
1732
+ restoredWhole.add(normalizePath(file));
1733
+ out(` ← ${file} — restored byte-for-byte`);
1734
+ }
1735
+ }
1736
+ // 2. What the records stashed.
1737
+ for (const { key, record, name } of mine) {
1738
+ if (!wanted(name))
1739
+ continue;
1740
+ if (restoredWhole.has(normalizePath(record.file))) {
1741
+ delete sidecar.servers[key];
1742
+ continue;
1743
+ }
1744
+ // Repair the entry rather than clobbering the file, and say what happened.
1745
+ let result;
1746
+ try {
1747
+ result = repair(record, name);
1748
+ }
1749
+ catch (err) {
1750
+ result = { what: `left as it is (${errText(err)})`, done: false };
1751
+ }
1752
+ out(` ← ${name} — ${result.what}`);
1753
+ if (result.done)
1754
+ delete sidecar.servers[key];
1755
+ else
1756
+ remaining.set(name, `${name} (${record.file}) — ${result.what}`);
1757
+ }
1758
+ // 3. Entries of ours that no record explains.
1759
+ for (const stray of runnerLeftovers(cwd, sidecar, opts.configPath, otherClaude).leftovers) {
1760
+ if (!wanted(stray.name) || remaining.has(stray.name))
1761
+ continue;
1762
+ if (stray.scope === "user" && !opts.all) {
1763
+ // Every project uses the user scope. One that another project's install
1764
+ // wrapped is that project's to undo.
1765
+ const owner = Object.entries(sidecar.servers).find(([key, r]) => r.scope === "user" && recordName(key, r) === stray.name && normalizePath(r.cwd) !== here);
1766
+ if (owner) {
1767
+ remaining.set(stray.name, `${stray.name} — wrapped in your user scope (every project) by the install in ${owner[1].cwd}: ` +
1768
+ "`bir uninstall` there, or `bir uninstall --all`");
1769
+ continue;
1770
+ }
1771
+ }
1772
+ let back = isScenarioServer(stray.config) ? null : unwrapEntry(stray.config);
1773
+ const where = leftoverWhere(stray);
1774
+ // Local and user scope: the `.claude.json` it sits in (another profile's, named as such).
1775
+ const strayClaude = stray.scope === "local" || stray.scope === "user" ? (stray.file ?? claudeJson) : claudeJson;
1776
+ if (back && stray.scope === "local" && sameServerConfig(back, entryBelow(cwd, stray.name, strayClaude))) {
1777
+ // An override (what install writes over a `.mcp.json` or user-scope
1778
+ // server) whose record is lost: the entry under it is the original, so
1779
+ // the copy goes rather than shadowing the team's entry from now on.
1780
+ back = null;
1781
+ }
1782
+ if (back === undefined) {
1783
+ remaining.set(stray.name, `${stray.name} (${where}) — its original could not be rebuilt from the wrapped entry; edit it by hand`);
1784
+ continue;
1785
+ }
1786
+ try {
1787
+ if (stray.scope === "explicit")
1788
+ setGenericServerEntry(stray.file, stray.name, back);
1789
+ else if (stray.scope === "local")
1790
+ setLocalEntries(strayClaude, stray.keys ?? [], stray.name, back, ourEntry);
1791
+ else
1792
+ setServerEntry(stray.scope, cwd, stray.name, back, { ...paths, claudeJson: strayClaude });
1793
+ const how = back !== null
1794
+ ? `put back as the plain server in ${where} (no record of it here; rebuilt from the wrapped entry)`
1795
+ : isScenarioServer(stray.config)
1796
+ ? `removed from ${where} (no record of it here)`
1797
+ : `its wrapped copy removed from ${where}; the entry under it is the original (no record of it here)`;
1798
+ out(` ← ${stray.name} — ${how}${stray.scope === "project" ? " — commit that if your team shares .mcp.json" : ""}`);
1799
+ }
1800
+ catch (err) {
1801
+ remaining.set(stray.name, `${stray.name} (${where}) — ${errText(err)}`);
1802
+ }
1803
+ }
1804
+ // 4. The hooks.
1805
+ if (!narrow) {
1806
+ const seen = new Set();
1807
+ for (const settings of [...myHookFiles, ...hookedNow]) {
1808
+ const norm = normalizePath(settings);
1809
+ if (seen.has(norm))
1810
+ continue;
1811
+ seen.add(norm);
1812
+ if (restoredWhole.has(norm) || !existsSync(settings))
1813
+ continue;
1814
+ try {
1815
+ const had = hasBirHooks(settings);
1816
+ uninstallHooks(settings);
1817
+ // The scenario server's pre-approval lives beside the hooks in the
1818
+ // person's own file; the team's settings.json never got one.
1819
+ if (norm !== normalizePath(paths.settings) || myHookFiles.some((f) => normalizePath(f) === norm)) {
1820
+ disableMcpjsonServers(settings, [SCENARIO_SERVER_KEY]);
1821
+ }
1822
+ if (had)
1823
+ out(` ← hooks removed from ${settings}`);
1824
+ }
1825
+ catch (err) {
1826
+ remaining.set(`hooks:${norm}`, `the hooks in ${settings} — ${errText(err)}`);
1827
+ }
1828
+ }
1829
+ }
1830
+ // 5. Read it back: what would a session here spawn now — in this
1831
+ // environment, and in any other profile the install wrote to?
1832
+ const after = runnerLeftovers(cwd, sidecar, opts.configPath, otherClaude);
1833
+ for (const e of after.leftovers) {
1834
+ if (!wanted(e.name) || remaining.has(e.name))
1835
+ continue;
1836
+ remaining.set(e.name, `${e.name} (${leftoverWhere(e)}) still runs through the runner`);
1837
+ }
1838
+ for (const file of after.unreadable)
1839
+ remaining.set(`read:${file}`, `${file} could not be read, so nothing in it was undone`);
1840
+ if (!narrow) {
1841
+ for (const file of [paths.settingsLocal, paths.settings]) {
1842
+ const key = `hooks:${normalizePath(file)}`;
1843
+ if (hasBirHooks(file) && !remaining.has(key))
1844
+ remaining.set(key, `hooks of ours are still in ${file}`);
1845
+ }
1846
+ }
1847
+ if (!opts.configPath) {
1848
+ // Not ours to undo from here, but a session here still spawns them: the
1849
+ // repository's install owns the local scope this subdirectory shares.
1850
+ const listed = new Set(after.leftovers.map((e) => e.name));
1851
+ let live = [];
1852
+ try {
1853
+ live = resolveServers(cwd);
1854
+ }
1855
+ catch {
1856
+ /* reported above as unreadable */
1857
+ }
1858
+ const theirs = live.filter((s) => ourEntry(s.config) && !listed.has(s.name) && wanted(s.name)).map((s) => s.name);
1859
+ if (theirs.length) {
1860
+ out(` · ${theirs.join(", ")} stay wrapped here: they belong to the install in ${claudeProjectRoot(cwd)} ` +
1861
+ "(Claude Code shares the local scope per repository) — `bir uninstall` there");
1862
+ }
1863
+ }
1864
+ // 6. The bookkeeping: only this project's, and only once nothing is left to
1865
+ // retry — a second run needs the snapshots and records to finish the job.
1866
+ if (!narrow) {
1867
+ sidecar.hookFiles = (sidecar.hookFiles ?? []).filter((f) => fileProjectDir(f) !== here || remaining.has(`hooks:${normalizePath(f)}`));
1868
+ if (remaining.size === 0) {
1869
+ for (const file of Object.keys(sidecar.files ?? {})) {
1870
+ if (isClaudeJson(file, claudeJson) || (ownsFile(file) && !othersFiles.has(normalizePath(file))))
1871
+ delete sidecar.files?.[file];
1872
+ }
1873
+ const record = projectRecord(sidecar, cwd);
1874
+ if (sidecar.projects)
1875
+ delete sidecar.projects[here];
1876
+ if (record?.excludeAdded !== false && existsSync(cwd) && !sharesRepository(cwd, sidecar)) {
1877
+ const exclude = removeGitExclude(cwd);
1878
+ if (exclude)
1879
+ out(` ← ${LOCAL_SETTINGS_EXCLUDE} removed from ${exclude}`);
1880
+ }
1881
+ }
1882
+ }
1883
+ writeSidecar(sidecar);
1884
+ return { nothing: false, remaining: [...remaining.values()] };
1885
+ }
1886
+ /**
1887
+ * True when a Claude Code session is using this recorder right now: a turn is
1888
+ * mid-run, or a registered proxy — a session's own child process — is alive.
1889
+ */
1890
+ function recorderInUse(health) {
1891
+ if (activeSessions(health) > 0)
1892
+ return true;
1893
+ const proxies = health.registeredProxies ?? [];
1894
+ return proxies.some((p) => typeof p.pid === "number" && p.pid > 0 && p.pid !== process.pid && pidAlive(p.pid));
1895
+ }
1896
+ /**
1897
+ * Stop this project's recorder at the end of an uninstall — unless a Claude
1898
+ * Code session here is still using it.
1899
+ *
1900
+ * An open session keeps the hooks it started with, and its MCP servers are
1901
+ * already running. Stop the recorder under it and every prompt and tool call
1902
+ * there shows a hook error until the session restarts. So a recorder that is
1903
+ * serving one is left running with this project switched OFF — it answers
1904
+ * those hooks with nothing at once and records nothing — and the person is
1905
+ * told to restart the session, then `bir down`. `bir install` here again, or
1906
+ * `bir on --here`, ends that switch.
1907
+ */
1908
+ async function retireRecorder(cwd) {
1909
+ // A recorder a reinstall on another port left serving an older window
1910
+ // (LC-1): stopped when nothing uses it, else it stays for that window and
1911
+ // is named — it is not reachable through the discovery file.
1912
+ for (const record of await supersededRecorders(cwd)) {
1913
+ const health = await supersededHealth(record);
1914
+ if (health && recorderInUse(health)) {
1915
+ out(` · a superseded recorder (${describeSuperseded(record)}) still serves a Claude Code session opened before ` +
1916
+ "the last reinstall; restart that session, then `bir down` here");
1917
+ continue;
1918
+ }
1919
+ const result = await stopSuperseded(record);
1920
+ if (result.stopped)
1921
+ out(` ← superseded recorder stopped (${describeSuperseded(record)})`);
1922
+ }
1923
+ const live = await daemonStatus(cwd);
1924
+ if (!live) {
1925
+ clearUninstallMark(cwd);
1926
+ return;
1927
+ }
1928
+ if (recorderInUse(live.health)) {
1929
+ try {
1930
+ markOffForUninstall(cwd);
1931
+ if (predatesOffSwitch(live.health)) {
1932
+ // 0.2.12 or older: it has no off switch, so the mark changes nothing
1933
+ // for it — it keeps recording that session, and approving its wrapped
1934
+ // MCP calls, as before. Said as it is; stopping it instead would put a
1935
+ // hook error on every prompt of a session the person is using.
1936
+ out(` · the recorder (pid ${live.info.pid}, ${recorderVersion(live.health, live.info) || "an older version"}) stays up for ` +
1937
+ "the Claude Code session open here. It predates the off switch, so it keeps recording that session " +
1938
+ "(and approving its wrapped MCP calls) until the session ends. Restart the session, then `bir down`");
1939
+ return;
1940
+ }
1941
+ out(` · the recorder (pid ${live.info.pid}) stays up for the Claude Code session open here, switched OFF: ` +
1942
+ "it answers that session's hooks with nothing and records nothing. Restart the session, then `bir down`");
1943
+ return;
1944
+ }
1945
+ catch {
1946
+ /* no switch to leave it with: stop it after all */
1947
+ }
1948
+ }
1949
+ const result = await stopDaemon(cwd);
1950
+ if (result.stopped || result.stale) {
1951
+ clearUninstallMark(cwd);
1952
+ if (result.stopped)
1953
+ out(` ← recorder stopped (pid ${live.info.pid})`);
1954
+ }
1955
+ else {
1956
+ out(` ! the recorder (pid ${live.info.pid}) did not stop — \`bir down\` here`);
1957
+ }
1958
+ }
1959
+ /**
1960
+ * True when a recorder's `/health` has no `off` field: it is older than the
1961
+ * off switch (0.2.12 and before) and ignores it — keeps recording, and
1962
+ * approving wrapped MCP calls, whatever `bir off` or an uninstall's mark says.
1963
+ */
1964
+ function predatesOffSwitch(health) {
1965
+ return !("off" in health);
1966
+ }
1967
+ /** The version a recorder reports, or "". */
1968
+ function recorderVersion(health, info) {
1969
+ const v = health.hooksVersion ?? info?.version;
1970
+ return typeof v === "string" ? v : "";
1971
+ }
1972
+ /**
1973
+ * A running recorder reads the replay switch once, when it starts
1974
+ * (control/recorder-main.ts), so after the switch went off it would go on
1975
+ * replaying until something restarted it. Restart one no session is using;
1976
+ * leave one that serves an open session — stopping it would put a hook error
1977
+ * on every prompt there — and say what is left to do.
1978
+ */
1979
+ async function restartForReplaySwitch(cwd) {
1980
+ const live = await daemonStatus(cwd);
1981
+ const replay = live?.health.replay;
1982
+ const want = projectRecord(readSidecar(), cwd)?.replay?.enabled !== false;
1983
+ // `source: "env"`: BIR_REPLAY where it was started outranks the switch, and
1984
+ // a restart from here would not carry that environment's answer.
1985
+ if (!live || typeof replay?.enabled !== "boolean" || replay.enabled === want || replay.source === "env")
1986
+ return;
1987
+ if (recorderInUse(live.health)) {
1988
+ out(` · the recorder (pid ${live.info.pid}) keeps replay ${replay.enabled ? "on" : "off"} for the Claude Code session ` +
1989
+ "open here — once it ends, `bir up --restart` applies the change");
1990
+ return;
1991
+ }
1992
+ try {
1993
+ await stopDaemon(cwd);
1994
+ const { status } = await ensureDaemon(cwd, { hooksScript: localHooksPath() });
1995
+ out(` ~ recorder restarted (pid ${status.info.pid}) so the replay switch applies`);
1996
+ }
1997
+ catch (err) {
1998
+ out(` ! the recorder did not restart (${errText(err)}); the next session here starts it with the new switch`);
1999
+ }
2000
+ }
2001
+ async function uninstall(args) {
2002
+ if (args.all)
2003
+ return uninstallAll(args);
2004
+ const cwd = process.cwd();
2005
+ const sidecar = readSidecar();
2006
+ // `--replay` on its own is a narrow removal: take the scenario server out and
2007
+ // leave every proxy and the hooks exactly as they are. Somebody turning replay
2008
+ // off should not have to reinstall recording afterwards.
2009
+ if (args.replay) {
2010
+ const target = findRecord(sidecar, cwd, SCENARIO_SERVER_KEY);
2011
+ // Switched off as well as taken out: without the switch the recorder kept
2012
+ // replaying matched prompts — steering, or running steps unattended for a
2013
+ // delivery tool that is no longer there. `bir replay on` turns it back on.
2014
+ const switched = settleReplaySwitch(sidecar, cwd, "refused", false);
2015
+ if (!target) {
2016
+ if (switched)
2017
+ writeSidecar(sidecar);
2018
+ out(`Calculated replay is not installed for this directory${switched ? "; it is switched off here now" : ""}.`);
2019
+ if (switched)
2020
+ await restartForReplaySwitch(cwd);
2021
+ return 0;
2022
+ }
2023
+ const result = uninstallProject(cwd, sidecar, { servers: [SCENARIO_SERVER_KEY], configPath: args.configPath });
2024
+ await restartForReplaySwitch(cwd);
2025
+ out();
2026
+ if (result.remaining.length) {
2027
+ for (const r of result.remaining)
2028
+ out(` ! ${r}`);
2029
+ return 1;
2030
+ }
2031
+ out("Calculated replay is uninstalled and switched off here. Recording is unchanged.");
2032
+ out("The prompt hook keeps its timeout; `bir install` rewrites it either way.");
2033
+ return 0;
2034
+ }
2035
+ const narrow = args.servers.length > 0;
2036
+ const result = uninstallProject(cwd, sidecar, { servers: args.servers, configPath: args.configPath });
2037
+ if (result.nothing) {
2038
+ out("Nothing installed for this directory.");
2039
+ const others = otherProjects(sidecar, cwd);
2040
+ if (others.length)
2041
+ out(`(The runner is set up in ${others.join(", ")}; \`bir uninstall --all\` removes it everywhere.)`);
2042
+ return 0;
2043
+ }
2044
+ if (!narrow && !args.keepRecorder)
2045
+ await retireRecorder(cwd);
2046
+ out();
2047
+ if (result.remaining.length) {
2048
+ out("Not everything could be undone. These still run through the runner here:");
2049
+ for (const r of result.remaining)
2050
+ out(` ! ${r}`);
2051
+ out("Fix what is named, then run `bir uninstall` again. Do not remove the runner before it says Uninstalled:");
2052
+ out("these entries name its files, and would stop working without them.");
2053
+ return 1;
2054
+ }
2055
+ out(narrow ? `Uninstalled ${args.servers.join(", ")}. Hooks and the other servers are unchanged.` : "Uninstalled.");
2056
+ if (!narrow) {
2057
+ const others = otherProjects(readSidecar(), cwd);
2058
+ if (others.length) {
2059
+ out(`The runner is still set up in ${others.length} other project${others.length === 1 ? "" : "s"}: ${others.join(", ")}.`);
2060
+ out("`bir uninstall --all` removes it from every one — run that before removing the package.");
2061
+ }
2062
+ }
2063
+ return 0;
2064
+ }
2065
+ /** Directories named in ~/.claude.json whose files hold something of ours, though no record here says so. */
2066
+ function projectsWithRunnerFiles(claude) {
2067
+ const found = [];
2068
+ for (const dir of Object.keys(claude.projects ?? {})) {
2069
+ try {
2070
+ if (!existsSync(dir))
2071
+ continue;
2072
+ const settings = claudeCodePaths(dir);
2073
+ if (hasBirHooks(settings.settingsLocal) || hasBirHooks(settings.settings)) {
2074
+ found.push(dir);
2075
+ continue;
2076
+ }
2077
+ const mcp = parseJsonFile(settings.mcpJson)?.mcpServers;
2078
+ if (Object.values(mcp ?? {}).some((c) => ourEntry(c)))
2079
+ found.push(dir);
2080
+ }
2081
+ catch {
2082
+ /* unreadable: not ours to guess at */
2083
+ }
2084
+ }
2085
+ return found;
2086
+ }
2087
+ /**
2088
+ * Put back every entry of ours left anywhere in ~/.claude.json: the user
2089
+ * scope (every project) and the local scope of every project, including ones
2090
+ * no record here knows — an older runner's, or a lost installed.json's.
2091
+ */
2092
+ function sweepClaudeJson(claudeJson, remaining) {
2093
+ let claude;
2094
+ try {
2095
+ claude = parseJsonFile(claudeJson) ?? {};
2096
+ }
2097
+ catch (err) {
2098
+ remaining.push(`${claudeJson} could not be read (${errText(err)}), so nothing in it was undone`);
2099
+ return 0;
2100
+ }
2101
+ let fixed = 0;
2102
+ // This file, whichever profile it is: not this environment's by default.
2103
+ const paths = claudeCodePaths(process.cwd(), claudeJson);
2104
+ for (const [name, config] of Object.entries(claude.mcpServers ?? {})) {
2105
+ if (!ourEntry(config))
2106
+ continue;
2107
+ const back = isScenarioServer(config) ? null : unwrapEntry(config);
2108
+ if (back === undefined) {
2109
+ remaining.push(`${name} in your user scope — its original could not be rebuilt; edit ${claudeJson} by hand`);
2110
+ continue;
2111
+ }
2112
+ try {
2113
+ setServerEntry("user", process.cwd(), name, back, paths);
2114
+ out(` ← ${name} — ${back === null ? "removed from" : "put back in"} your user scope`);
2115
+ fixed += 1;
2116
+ }
2117
+ catch (err) {
2118
+ remaining.push(`${name} in your user scope — ${errText(err)}`);
2119
+ }
2120
+ }
2121
+ for (const [key, project] of Object.entries(claude.projects ?? {})) {
2122
+ for (const [name, config] of Object.entries(project?.mcpServers ?? {})) {
2123
+ if (!ourEntry(config))
2124
+ continue;
2125
+ let back = isScenarioServer(config) ? null : unwrapEntry(config);
2126
+ if (back === undefined) {
2127
+ remaining.push(`${name} in the local scope of ${key} — its original could not be rebuilt; edit ${claudeJson} by hand`);
2128
+ continue;
2129
+ }
2130
+ // An override whose record is lost (what install writes over a
2131
+ // `.mcp.json` or user-scope server): the entry under it is the original,
2132
+ // so the copy goes. Rebuilt as a plain local entry instead, it would
2133
+ // shadow that entry for good — every later change to it hidden.
2134
+ let override = false;
2135
+ if (back && sameServerConfig(back, entryBelow(key, name, claudeJson))) {
2136
+ back = null;
2137
+ override = true;
2138
+ }
2139
+ try {
2140
+ setLocalEntries(claudeJson, [key], name, back, ourEntry);
2141
+ out(` ← ${name} — ${override ? "its wrapped copy removed; the entry under it is the original" : back === null ? "removed" : "put back"} (local scope of ${key})`);
2142
+ fixed += 1;
2143
+ }
2144
+ catch (err) {
2145
+ remaining.push(`${name} in the local scope of ${key} — ${errText(err)}`);
2146
+ }
2147
+ }
2148
+ }
2149
+ return fixed;
2150
+ }
2151
+ /**
2152
+ * control/daemon.ts's `listSupersededRecorders` and `stopSupersededRecorder`,
2153
+ * looked up at run time rather than imported by name: they arrive with the
2154
+ * daemon's side of LC-1, and until they do there is nothing to list — which
2155
+ * is exactly what a machine without that side has.
2156
+ */
2157
+ const supersededApi = daemonApi;
2158
+ /** The superseded recorders of `cwd` (of every project, without it) that are still alive. Never throws. */
2159
+ async function supersededRecorders(cwd) {
2160
+ try {
2161
+ const list = supersededApi.listSupersededRecorders;
2162
+ if (typeof list !== "function")
2163
+ return [];
2164
+ const found = await list(cwd);
2165
+ return Array.isArray(found)
2166
+ ? found.filter((r) => r && typeof r.pid === "number" && typeof r.url === "string" && typeof r.token === "string")
2167
+ : [];
2168
+ }
2169
+ catch {
2170
+ return [];
2171
+ }
2172
+ }
2173
+ /**
2174
+ * Stop one superseded recorder the way stopDaemon stops the project's own:
2175
+ * asked, and signalled only by the daemon's own code after it answered
2176
+ * `/health` as that very pid — never by a pid alone, which may be recycled.
2177
+ */
2178
+ async function stopSuperseded(record) {
2179
+ try {
2180
+ if (typeof supersededApi.stopSupersededRecorder === "function") {
2181
+ return await supersededApi.stopSupersededRecorder(record);
2182
+ }
2183
+ const probe = await probeHealth(record.url, record.token);
2184
+ if (probe.kind !== "ok" || probe.health.pid !== record.pid)
2185
+ return { stopped: false, stale: probe.kind !== "timeout" };
2186
+ const asked = await new ControlClient(record.url, record.token, 3_000).stop();
2187
+ const deadline = Date.now() + 10_000;
2188
+ while (asked && pidAlive(record.pid) && Date.now() < deadline)
2189
+ await new Promise((r) => setTimeout(r, 100));
2190
+ return { stopped: !pidAlive(record.pid) };
2191
+ }
2192
+ catch {
2193
+ return { stopped: false };
2194
+ }
2195
+ }
2196
+ /** `/health` of a superseded recorder, or undefined when it does not answer as itself. */
2197
+ async function supersededHealth(record) {
2198
+ try {
2199
+ const probe = await probeHealth(record.url, record.token);
2200
+ return probe.kind === "ok" && probe.health.pid === record.pid ? probe.health : undefined;
2201
+ }
2202
+ catch {
2203
+ return undefined;
2204
+ }
2205
+ }
2206
+ /** One line naming a superseded recorder. */
2207
+ function describeSuperseded(record) {
2208
+ return `pid ${record.pid} on ${record.url}${record.version ? `, version ${record.version}` : ""}${record.since ? `, superseded ${ago(record.since)}` : ""}`;
2209
+ }
2210
+ /** Stop every recorder this machine has a discovery file for, and every superseded one. */
2211
+ async function stopAllRecorders() {
2212
+ let files = [];
2213
+ try {
2214
+ files = readdirSync(controlDir()).filter((f) => f.endsWith(".json"));
2215
+ }
2216
+ catch {
2217
+ return 0;
2218
+ }
2219
+ let stopped = 0;
2220
+ for (const file of files) {
2221
+ let cwd;
2222
+ try {
2223
+ cwd = JSON.parse(readFileSync(join(controlDir(), file), "utf8")).cwd;
2224
+ }
2225
+ catch {
2226
+ continue;
2227
+ }
2228
+ if (typeof cwd !== "string" || !readDiscovery(cwd))
2229
+ continue;
2230
+ // stopDaemon signals only a process that answers /health as the recorder
2231
+ // with that file's pid: a recycled pid is never touched.
2232
+ const result = await stopDaemon(cwd);
2233
+ if (result.stopped) {
2234
+ out(` ← recorder stopped for ${cwd} (pid ${result.pid})`);
2235
+ stopped += 1;
2236
+ }
2237
+ else if (!result.stale) {
2238
+ out(` ! the recorder for ${cwd} (pid ${result.pid}) did not stop — \`bir down\` there, or end that process`);
2239
+ }
2240
+ }
2241
+ for (const record of await supersededRecorders()) {
2242
+ const result = await stopSuperseded(record);
2243
+ if (result.stopped) {
2244
+ out(` ← superseded recorder stopped for ${record.cwd} (${describeSuperseded(record)})`);
2245
+ stopped += 1;
2246
+ }
2247
+ else if (!result.stale) {
2248
+ out(` ! a superseded recorder for ${record.cwd} (pid ${record.pid}) did not stop — end that process`);
2249
+ }
2250
+ }
2251
+ return stopped;
2252
+ }
2253
+ /**
2254
+ * `bir uninstall --all`: the runner out of every project on this machine,
2255
+ * so that removing the package afterwards cannot break anything.
2256
+ *
2257
+ * Every installed project is uninstalled as `bir uninstall` would do it there
2258
+ * (its hooks, its entries, its files); then everything of ours still in
2259
+ * ~/.claude.json, for projects no record knows; then every recorder is
2260
+ * stopped and every off switch removed. What could not be undone is listed,
2261
+ * kept in installed.json for a second run, and the exit code says so — the
2262
+ * one thing this must never do is say "safe to remove" while something still
2263
+ * names the runner's files.
2264
+ */
2265
+ async function uninstallAll(args) {
2266
+ if (args.servers.length > 0 || args.replay || args.configPath) {
2267
+ process.stderr.write("[bir] --all removes everything; it takes no --server, --replay or --config\n");
2268
+ return 2;
2269
+ }
2270
+ const sidecar = readSidecar();
2271
+ const paths = claudeCodePaths(process.cwd());
2272
+ const projects = installedProjects(sidecar);
2273
+ // Every `.claude.json` an install wrote to: this environment's, and any other
2274
+ // profile's (CLAUDE_CONFIG_DIR) the records name — collected now, before the
2275
+ // records that name them are dropped.
2276
+ const claudeFiles = new Map([[normalizePath(paths.claudeJson), paths.claudeJson]]);
2277
+ const noteFile = (file) => {
2278
+ if (typeof file === "string" && file && !claudeFiles.has(normalizePath(file)) && existsSync(file)) {
2279
+ claudeFiles.set(normalizePath(file), file);
2280
+ }
2281
+ };
2282
+ for (const record of Object.values(sidecar.servers)) {
2283
+ if (record && (record.scope === "local" || record.scope === "user"))
2284
+ noteFile(record.file);
2285
+ }
2286
+ for (const project of Object.values(sidecar.projects ?? {}))
2287
+ for (const file of project?.claudeJsonFiles ?? [])
2288
+ noteFile(file);
2289
+ for (const file of claudeFiles.values()) {
2290
+ let claude = {};
2291
+ try {
2292
+ claude = parseJsonFile(file) ?? {};
2293
+ }
2294
+ catch {
2295
+ /* sweepClaudeJson reports it */
2296
+ }
2297
+ for (const dir of projectsWithRunnerFiles(claude)) {
2298
+ if (!projects.has(normalizePath(dir)))
2299
+ projects.set(normalizePath(dir), dir);
2300
+ }
2301
+ }
2302
+ out("Removing the runner from every project on this machine");
2303
+ const remaining = [];
2304
+ let count = 0;
2305
+ for (const dir of [...projects.values()].sort()) {
2306
+ out(`── ${dir}`);
2307
+ try {
2308
+ const result = uninstallProject(dir, sidecar, { servers: [], all: true });
2309
+ if (result.nothing)
2310
+ out(" (nothing of ours left here)");
2311
+ else
2312
+ count += 1;
2313
+ remaining.push(...result.remaining.map((r) => `${dir}: ${r}`));
2314
+ }
2315
+ catch (err) {
2316
+ remaining.push(`${dir}: ${errText(err)}`);
2317
+ }
2318
+ }
2319
+ for (const file of claudeFiles.values()) {
2320
+ out(isClaudeJson(file, paths.claudeJson)
2321
+ ? `── ${file} (your user scope, and projects no install record names)`
2322
+ : `── ${file} (another Claude Code profile an install wrote to: its user scope and projects)`);
2323
+ if (sweepClaudeJson(file, remaining) === 0)
2324
+ out(" (nothing of ours left)");
588
2325
  }
589
- // SessionStart starts the recorder itself when this directory has none
590
- // (control/ensure-hook.ts) — but only when the binary can be named by an
591
- // absolute path. A pinned-npx install keeps the HTTP hook: resolving a
592
- // package inside a hook is too slow to start a recorder from.
593
- const ensure = invocation === "npx" ? undefined : { node: hookNodePath(), hooksScript: localHooksPath(), nodeFlags };
594
- if (args.hooks && !args.configPath) {
595
- // Hooks live in `.claude/settings.local.json` — the person's own file, the
596
- // one Claude Code keeps out of git — not in the `settings.json` a team
597
- // commits. They carry a bearer token and an absolute path to this machine's
598
- // node; a teammate's clone wants neither. An older install put them in the
599
- // shared file; take them out of there on the way.
600
- if (hasBirHooks(paths.settings)) {
601
- backupFile(sidecar, paths.settings);
602
- const cleaned = uninstallHooks(paths.settings);
603
- if (cleaned !== undefined)
604
- noteWritten(sidecar, paths.settings, cleaned);
605
- out(` - hooks removed from ${paths.settings} (they live in settings.local.json now)`);
2326
+ out("── recorders");
2327
+ if ((await stopAllRecorders()) === 0)
2328
+ out(" (none running)");
2329
+ if (remaining.length === 0) {
2330
+ // The switches: a reinstall later must not start out switched off. Only
2331
+ // once nothing of ours is left — a person who turned the runner off because
2332
+ // it misbehaved, and whose uninstall could not undo everything, keeps the
2333
+ // switch holding what is still wrapped inert.
2334
+ try {
2335
+ for (const flag of readdirSync(dirname(projectOffFlagPath(process.cwd())))) {
2336
+ rmSync(join(dirname(projectOffFlagPath(process.cwd())), flag), { force: true });
2337
+ }
606
2338
  }
607
- backupFile(sidecar, paths.settingsLocal);
608
- let text = installHooks(paths.settingsLocal, buildHooksBlock(controlUrl, token, { ensure }));
609
- if (args.replay) {
610
- // Pre-approve our own scenario server. Claude Code asks before it uses a
611
- // server from `.mcp.json`, and a "No" there leaves a direct replay with
612
- // nowhere to deliver its results — and nothing to say so.
613
- text = enableMcpjsonServers(paths.settingsLocal, [SCENARIO_SERVER_KEY]);
2339
+ catch {
2340
+ /* no per-project switches */
614
2341
  }
615
- noteWritten(sidecar, paths.settingsLocal, text);
616
- sidecar.hookFiles = [...new Set([...(sidecar.hookFiles ?? []), paths.settingsLocal])];
617
- out(` + hooks → ${paths.settingsLocal} (control server on ${controlUrl}${ensure ? "; the recorder starts itself with each session" : ""})`);
618
- const exclude = ensureGitExclude(cwd);
619
- if (exclude)
620
- out(` + ${LOCAL_SETTINGS_EXCLUDE} is listed in ${exclude}`);
2342
+ try {
2343
+ setRunnerOff(false);
2344
+ }
2345
+ catch {
2346
+ /* nothing to remove */
2347
+ }
2348
+ // Nothing left to retry: the bookkeeping goes too. The sign-in stays
2349
+ // (`bir logout` revokes it) and so does the stored service address.
2350
+ sidecar.servers = {};
2351
+ sidecar.files = {};
2352
+ sidecar.hookFiles = [];
2353
+ sidecar.projects = {};
2354
+ delete sidecar.controlPort;
2355
+ delete sidecar.token;
621
2356
  }
622
2357
  writeSidecar(sidecar);
623
2358
  out();
624
- out(`Wrapped ${wrapped} server${wrapped === 1 ? "" : "s"}.`);
625
- if (args.hooks && !args.configPath && ensure) {
626
- out("Next: start your session here as usual. The recorder starts itself with it;");
627
- out("`bir up` starts it now, `bir down` stops it, `bir doctor` checks it.");
2359
+ if (remaining.length) {
2360
+ out("Not everything could be undone:");
2361
+ for (const r of remaining)
2362
+ out(` ! ${r}`);
2363
+ out("Fix what is named, then run `bir uninstall --all` again. Do not remove the runner before it");
2364
+ out("finishes cleanly: these entries name its files, and would stop working without them.");
2365
+ if (offSource())
2366
+ out("The off switch stays as you set it until then, so what is left stays inert.");
2367
+ return 1;
628
2368
  }
629
- else if (args.hooks && !args.configPath) {
630
- out("Next: run `bir-hooks` in this directory, then start your session.");
631
- out("Without it, proxies record standalone (Tier 2): MCP calls only, no prompt.");
2369
+ out(count === 0
2370
+ ? "Nothing of the runner's was set up on this machine."
2371
+ : `Uninstalled from ${count} project${count === 1 ? "" : "s"}. Nothing on this machine runs through the runner any more.`);
2372
+ out("Restart any Claude Code session that is open now: until then it may show a hook error on each prompt.");
2373
+ out(`You can now remove the runner: \`npm rm -g ${PACKAGE_NAME}\`, or delete ${configDir()}`);
2374
+ out("(the install script's copy lives there). `bir logout` first revokes this machine's sign-in.");
2375
+ return 0;
2376
+ }
2377
+ // ── off / on ───────────────────────────────────────────────────────────────
2378
+ /** The first word of a per-project off switch `bir uninstall` wrote (and `bir install` takes back). */
2379
+ const UNINSTALL_MARK = "set-by-uninstall";
2380
+ function markOffForUninstall(cwd) {
2381
+ setRunnerOff(true, cwd);
2382
+ writeFileSync(projectOffFlagPath(cwd), `${UNINSTALL_MARK} ${new Date().toISOString()}\n`, { mode: 0o600 });
2383
+ }
2384
+ function isUninstallMark(cwd) {
2385
+ try {
2386
+ return readFileSync(projectOffFlagPath(cwd), "utf8").startsWith(UNINSTALL_MARK);
632
2387
  }
633
- else {
634
- out("Recording will be standalone (Tier 2): MCP calls only, no prompt or final answer.");
2388
+ catch {
2389
+ return false;
635
2390
  }
636
- if (args.replay) {
637
- out();
638
- out("Calculated replay is INSTALLED and ON. `bir replay off` turns it off here, and");
639
- out("`bir replay allow <servers>` narrows what it may call unattended — read");
640
- out("docs/calculatedReplayGuide.md §5.1 first: a replayed step is auto-approved,");
641
- out("and a directly executed one never reaches the permission system at all.");
2391
+ }
2392
+ /** Remove this project's off switch when `bir uninstall` set it. True when it did. */
2393
+ function clearUninstallMark(cwd) {
2394
+ if (!isUninstallMark(cwd))
2395
+ return false;
2396
+ try {
2397
+ setRunnerOff(false, cwd);
2398
+ return true;
2399
+ }
2400
+ catch {
2401
+ return false;
642
2402
  }
643
- return 0;
644
2403
  }
645
- // ── uninstall ──────────────────────────────────────────────────────────────
646
- function uninstall(args) {
647
- const cwd = process.cwd();
2404
+ /** Why the runner is off, in words. */
2405
+ function describeOff(source, dir) {
2406
+ if (source === "env")
2407
+ return "(BIR_OFF=1 in this environment)";
2408
+ if (source === "global")
2409
+ return `everywhere on this machine (\`bir off\`: ${offFlagPath()})`;
2410
+ return isUninstallMark(dir)
2411
+ ? `for ${dir} (left by \`bir uninstall\` for a session still open there)`
2412
+ : `for ${dir} (\`bir off --here\`)`;
2413
+ }
2414
+ /**
2415
+ * The project directory `cwd` belongs to, as the recorder and the proxies key
2416
+ * it: an installed project that is `cwd` or contains it, else `cwd`. The
2417
+ * recorder runs where Claude Code was started — where `bir setup` ran — so a
2418
+ * `bir off --here` typed in a subdirectory must name that directory, or it
2419
+ * would switch off a project nothing runs in.
2420
+ */
2421
+ function projectDirFor(cwd, sidecar = readSidecar()) {
2422
+ const here = normalizePath(cwd);
2423
+ let best;
2424
+ for (const [key, dir] of installedProjects(sidecar)) {
2425
+ if ((here === key || here.startsWith(`${key}/`)) && (!best || key.length > best[0].length))
2426
+ best = [key, dir];
2427
+ }
2428
+ return !best || best[0] === here ? cwd : best[1];
2429
+ }
2430
+ /**
2431
+ * After `bir off`: the live recorders among `dirs` that predate the switch
2432
+ * (a 0.2.12 one left running across the upgrade), each replaced by a current
2433
+ * one when no session is using it (ensureDaemon's skew restart, with the
2434
+ * two-probe idle test for a recorder too old to say — control/daemon.ts), else
2435
+ * reported. A directory with no discovery file is not asked at all.
2436
+ */
2437
+ async function olderRecordersAfterOff(dirs) {
2438
+ const found = [];
2439
+ const seen = new Set();
2440
+ for (const dir of dirs) {
2441
+ const key = normalizePath(dir);
2442
+ if (seen.has(key))
2443
+ continue;
2444
+ seen.add(key);
2445
+ try {
2446
+ if (!readDiscovery(dir))
2447
+ continue;
2448
+ const live = await daemonStatus(dir);
2449
+ if (!live || !predatesOffSwitch(live.health))
2450
+ continue;
2451
+ let replaced = false;
2452
+ try {
2453
+ const opts = {
2454
+ hooksScript: localHooksPath(),
2455
+ restartOnSkew: true,
2456
+ probeIdle: true,
2457
+ };
2458
+ const result = await ensureDaemon(dir, opts);
2459
+ // Replaced means gone: one started beside it (another port) leaves it
2460
+ // serving its sessions, still ignoring the switch.
2461
+ replaced = !pidAlive(live.info.pid) && !predatesOffSwitch(result.status.health);
2462
+ }
2463
+ catch {
2464
+ replaced = false;
2465
+ }
2466
+ found.push({ dir, pid: live.info.pid, version: recorderVersion(live.health, live.info), replaced });
2467
+ }
2468
+ catch {
2469
+ /* nothing to say about a recorder that could not be asked */
2470
+ }
2471
+ }
2472
+ return found;
2473
+ }
2474
+ /**
2475
+ * `bir off [--here]` / `bir on [--here]` — the kill switch (util/off-switch.ts).
2476
+ *
2477
+ * Off is instant and reversible and uninstalls nothing: the hooks answer with
2478
+ * nothing within a second (the recorder checks the switch on every call), the
2479
+ * wrapped servers become plain pipes the next time they start, and the service
2480
+ * is never called. `on` without `--here` means on everywhere: the machine-wide
2481
+ * switch and every project's.
2482
+ */
2483
+ async function offCommand(args, off) {
648
2484
  const sidecar = readSidecar();
649
- const paths = claudeCodePaths(cwd);
650
- // `--replay` on its own is a narrow removal: take the scenario server out and
651
- // leave every proxy and the hooks exactly as they are. Somebody turning replay
652
- // off should not have to reinstall recording afterwards.
653
- if (args.replay) {
654
- const key = sidecarKey(cwd, SCENARIO_SERVER_KEY);
655
- const record = sidecar.servers[key];
656
- if (!record) {
657
- out("Calculated replay is not installed for this directory.");
658
- return 0;
2485
+ const dir = projectDirFor(process.cwd(), sidecar);
2486
+ if (off) {
2487
+ try {
2488
+ setRunnerOff(true, args.here ? dir : undefined);
659
2489
  }
660
- if (record.scope === "explicit") {
661
- setGenericServerEntry(record.file, SCENARIO_SERVER_KEY, null);
2490
+ catch (err) {
2491
+ process.stderr.write(`[bir] could not write the switch: ${errText(err)}\n`);
2492
+ return 1;
662
2493
  }
663
- else {
664
- setServerEntry(record.scope, cwd, SCENARIO_SERVER_KEY, null, paths);
2494
+ // A recorder older than the switch (0.2.12) ignores it. Asked, not
2495
+ // assumed: one no session uses is stopped (the next session starts a
2496
+ // current one, which honours the switch); one in use is named, and the
2497
+ // "answers with nothing" promise is not made for it.
2498
+ const older = await olderRecordersAfterOff(args.here ? [dir] : [...installedProjects(sidecar).values(), dir]);
2499
+ out(args.here ? `The runner is OFF for ${dir}.` : "The runner is OFF on this machine, in every project.");
2500
+ out(older.length
2501
+ ? " hooks answer at once with nothing, within about a second — in sessions already open too, except"
2502
+ : " hooks answer at once with nothing, within about a second — in sessions already open too;");
2503
+ out(older.length
2504
+ ? " the ones served by the older recorder named below; nothing else is recorded, replayed or sent"
2505
+ : " nothing is recorded, replayed or sent to the service");
2506
+ out(" MCP servers the ones it wraps stop recording within about a second, and run as plain pipes");
2507
+ out(" to the real server from their next start (a new session, or /mcp → reconnect)");
2508
+ out(" bir server withdraws its tools within a few seconds (it tells Claude Code its list changed)");
2509
+ out(`Nothing is uninstalled and your servers keep working. \`bir on${args.here ? " --here" : ""}\` turns it back on.`);
2510
+ for (const o of older) {
2511
+ out(o.replaced
2512
+ ? ` ~ the recorder for ${o.dir} (pid ${o.pid}, ${o.version || "an older version"}) predated the off switch; ` +
2513
+ "nothing was using it, so a current one replaced it, which honours the switch"
2514
+ : ` ! the running recorder for ${o.dir} (pid ${o.pid}, ${o.version || "an older version"}) predates the off switch ` +
2515
+ "and keeps recording and approving until `bir up --restart` (once the session open there ends)");
2516
+ }
2517
+ if (process.env.BIR_OFF === "0" || process.env.BIR_OFF === "false") {
2518
+ out(" ! BIR_OFF=0 is set in this shell: anything started from it ignores the switch");
665
2519
  }
666
- delete sidecar.servers[key];
667
- writeSidecar(sidecar);
668
- out(` ← ${SCENARIO_SERVER_KEY} — scenario server removed from ${record.file}`);
669
- out();
670
- out("Calculated replay is uninstalled. Recording is unchanged.");
671
- out("The prompt hook keeps its raised timeout; `bir install` rewrites it either way.");
672
- return 0;
673
- }
674
- const mine = Object.entries(sidecar.servers).filter(([key]) => key.startsWith(`${cwd}::`));
675
- if (mine.length === 0 && !(sidecar.hookFiles ?? []).length) {
676
- out("Nothing installed for this directory.");
677
2520
  return 0;
678
2521
  }
679
- // Byte-for-byte first: a file we wrote and nobody touched since goes back
680
- // exactly as it was, comments-in-JSON quirks and all.
681
- const restoredWhole = new Set();
682
- for (const [file, backup] of Object.entries(sidecar.files ?? {})) {
683
- const current = readTextOrNull(file);
684
- if (current === null || !backup.writtenSha || sha256(current) !== backup.writtenSha)
685
- continue;
686
- if (backup.originalText === null) {
687
- out(` - ${file} — created by install; leaving in place (now empty of our entries)`);
688
- continue;
2522
+ const turnedOn = [];
2523
+ try {
2524
+ if (args.here) {
2525
+ if (existsSync(projectOffFlagPath(dir)))
2526
+ turnedOn.push(dir);
2527
+ setRunnerOff(false, dir);
689
2528
  }
690
- writeFileSync(file, backup.originalText, "utf8");
691
- restoredWhole.add(file);
692
- out(` ← ${file} — restored byte-for-byte`);
693
- }
694
- for (const [key, record] of mine) {
695
- const name = key.slice(cwd.length + 2);
696
- if (args.servers.length > 0 && !args.servers.includes(name))
697
- continue;
698
- if (!restoredWhole.has(record.file)) {
699
- // Somebody edited the file after we did. Repair the entry rather than
700
- // clobbering their work, and say which path was taken.
701
- if (args.configPath || record.scope === "explicit") {
702
- setGenericServerEntry(record.file, name, record.original);
2529
+ else {
2530
+ const flags = dirname(projectOffFlagPath(dir));
2531
+ const names = new Map([...installedProjects(sidecar).values()].map((d) => [controlKey(d), d]));
2532
+ let entries = [];
2533
+ try {
2534
+ entries = readdirSync(flags);
703
2535
  }
704
- else {
705
- setServerEntry(record.scope, cwd, name, record.original, paths);
2536
+ catch {
2537
+ /* no per-project switches */
706
2538
  }
707
- out(` ← ${name} — entry restored (file changed since install)`);
2539
+ for (const flag of entries) {
2540
+ rmSync(join(flags, flag), { force: true });
2541
+ turnedOn.push(names.get(flag) ?? `a project (${flag})`);
2542
+ }
2543
+ if (existsSync(offFlagPath()))
2544
+ turnedOn.unshift("the machine-wide switch");
2545
+ setRunnerOff(false);
708
2546
  }
709
- delete sidecar.servers[key];
710
2547
  }
711
- for (const settings of sidecar.hookFiles ?? []) {
712
- if (restoredWhole.has(settings))
713
- continue;
714
- if (existsSync(settings)) {
715
- uninstallHooks(settings);
716
- disableMcpjsonServers(settings, [SCENARIO_SERVER_KEY]);
717
- out(` ← hooks removed from ${settings}`);
718
- }
719
- }
720
- // An install older than 0.2.9 wrote to the shared settings file; make sure
721
- // nothing of ours is left there either, whether or not it was recorded.
722
- if (!restoredWhole.has(paths.settings) && hasBirHooks(paths.settings)) {
723
- uninstallHooks(paths.settings);
724
- out(` ← hooks removed from ${paths.settings}`);
725
- }
726
- sidecar.hookFiles = [];
727
- sidecar.files = {};
728
- if (sidecar.projects)
729
- delete sidecar.projects[normalizePath(cwd)];
730
- writeSidecar(sidecar);
731
- const exclude = removeGitExclude(cwd);
732
- if (exclude)
733
- out(` ← ${LOCAL_SETTINGS_EXCLUDE} removed from ${exclude}`);
734
- out();
735
- out("Uninstalled.");
2548
+ catch (err) {
2549
+ process.stderr.write(`[bir] could not remove the switch: ${errText(err)}\n`);
2550
+ return 1;
2551
+ }
2552
+ const still = offSource(dir);
2553
+ if (still) {
2554
+ out(`The runner is still OFF here, ${describeOff(still, dir)}.`);
2555
+ out(still === "env" ? " Unset BIR_OFF (or set BIR_OFF=0) where Claude Code is started." : " `bir on` (without --here) turns that off too.");
2556
+ return 1;
2557
+ }
2558
+ out(args.here ? `The runner is ON for ${dir}.` : "The runner is ON, in every project on this machine.");
2559
+ if (turnedOn.length)
2560
+ out(` switched back on: ${turnedOn.join(", ")}`);
2561
+ out(" hooks record again within about a second, and so do wrapped MCP servers started before `bir off`;");
2562
+ out(" ones started while it was off stay plain pipes until their next start");
736
2563
  return 0;
737
2564
  }
2565
+ /**
2566
+ * What `bir down` leaves behind, said once — because it is not obvious.
2567
+ *
2568
+ * Stopping the recorder does not take the hooks out. A Claude Code session
2569
+ * open here keeps calling them, and each call now fails: a "hook error" line
2570
+ * on every prompt and tool call (the session itself keeps working) until the
2571
+ * session ends, and the next session's SessionStart hook starts the recorder
2572
+ * again. That is what `down` has always meant — stop this process — so it
2573
+ * stays; the quiet way to take the runner out of a session is `bir off
2574
+ * --here`, which keeps the recorder up to answer those hooks with nothing.
2575
+ */
2576
+ function explainDown(cwd, inUse) {
2577
+ const paths = claudeCodePaths(cwd);
2578
+ if (!hasBirHooks(paths.settingsLocal) && !hasBirHooks(paths.settings))
2579
+ return;
2580
+ out(inUse
2581
+ ? ' A Claude Code session is open here: until it ends it shows a "hook error" on each prompt and tool call'
2582
+ : ' Its hooks stay installed: a Claude Code session open here shows a "hook error" on each prompt and tool call');
2583
+ out(" (the session keeps working), and the next session here starts the recorder again.");
2584
+ out(" To take the runner out of the way without that: `bir up`, then `bir off --here`.");
2585
+ }
738
2586
  // ── status ─────────────────────────────────────────────────────────────────
739
- function status(args) {
2587
+ /** How long `bir status` waits for the recorder's /health: a status line, not a probe. */
2588
+ const STATUS_HEALTH_TIMEOUT_MS = 1_500;
2589
+ async function status(args) {
740
2590
  const cwd = process.cwd();
741
2591
  const entries = args.configPath
742
2592
  ? Object.entries(readGenericServers(args.configPath)).map(([name, config]) => ({
@@ -747,18 +2597,48 @@ function status(args) {
747
2597
  }))
748
2598
  : resolveServers(cwd);
749
2599
  const discovery = readDiscovery(cwd);
2600
+ const sidecar = readSidecar();
2601
+ const projectDir = projectDirFor(cwd, sidecar);
2602
+ const off = offSource(projectDir);
2603
+ // The recorder's own view (C8): whether the service answers it, and whether
2604
+ // it sees the runner off. Both fields are absent from an older recorder.
2605
+ const health = discovery
2606
+ ? await new ControlClient(discovery.url, discovery.token, STATUS_HEALTH_TIMEOUT_MS).health()
2607
+ : undefined;
2608
+ const serviceView = (health?.service ?? null);
2609
+ // Overrides whose source changed since they were copied (config/overrides.ts).
2610
+ const drift = new Map(args.configPath ? [] : checkOverrides(cwd, sidecar).filter((c) => c.state !== "current").map((c) => [c.name, c]));
2611
+ // The port a recorder here last could not take, and why (control/daemon.ts).
2612
+ const hookPorts = args.configPath ? [] : hookFacts(claudeCodePaths(cwd)).ports;
2613
+ const portProblem = openPortProblem(cwd, health ? portOf(discovery?.url) : undefined, hookPorts.length === 1 ? hookPorts[0] : undefined);
2614
+ // Recorders a reinstall on another port left serving older windows (LC-1).
2615
+ const superseded = await supersededRecorders(projectDir);
750
2616
  const report = {
751
2617
  cwd,
752
2618
  authUrl: resolveAuthUrl() || null,
753
- controlServer: discovery ? { url: discovery.url, pid: discovery.pid } : null,
2619
+ controlServer: discovery ? { url: discovery.url, pid: discovery.pid, answering: health !== undefined } : null,
754
2620
  discoveryFile: discoveryPath(cwd),
2621
+ // null while the recorder can take its port (and from a runner older than the file).
2622
+ portProblem: portProblem ?? null,
755
2623
  stateDir: configDir(),
2624
+ // The kill switch (`bir off`): null while the runner is on.
2625
+ off: off ? { source: off, detail: describeOff(off, projectDir) } : null,
2626
+ recorder: health
2627
+ ? {
2628
+ service: serviceView,
2629
+ off: typeof health.off === "string" ? health.off : health.off === null ? null : undefined,
2630
+ version: recorderVersion(health) || null,
2631
+ }
2632
+ : null,
2633
+ superseded: superseded.map((r) => ({ pid: r.pid, url: r.url, version: r.version ?? null, since: r.since ?? null })),
2634
+ otherProjects: otherProjects(sidecar, projectDir),
756
2635
  servers: entries.map((e) => ({
757
2636
  name: e.name,
758
2637
  scope: e.scope,
759
2638
  shadowed: e.shadowed,
760
2639
  wrapped: isWrapped(e.config),
761
2640
  upstream: isRemote(e.config) ? e.config.url : e.config.command,
2641
+ ...(drift.has(e.name) ? { stale: { state: drift.get(e.name).state, why: drift.get(e.name).why } } : {}),
762
2642
  })),
763
2643
  };
764
2644
  if (args.json) {
@@ -768,58 +2648,280 @@ function status(args) {
768
2648
  out(`Directory : ${report.cwd}`);
769
2649
  out(`BaseIn : ${report.authUrl ?? "(not set — run `bir setup`, or set BIR_AUTH_URL; nothing is recorded)"}`);
770
2650
  out(`Control : ${discovery
771
- ? `${discovery.url} (pid ${discovery.pid}${discovery.daemon ? "; background" : ""})`
2651
+ ? `${discovery.url} (pid ${discovery.pid}${discovery.daemon ? "; background" : ""})${health ? "" : " — not answering; `bir doctor` says more"}`
772
2652
  : "not running — `bir up` starts it"}`);
2653
+ if (portProblem)
2654
+ out(`Port : ${describePortProblem(portProblem)}`);
2655
+ for (const r of superseded) {
2656
+ out(`Superseded: ${describeSuperseded(r)} — serves windows opened before the last reinstall; \`bir down\` stops it`);
2657
+ }
773
2658
  if (discovery?.logFile)
774
2659
  out(`Log : ${discovery.logFile}`);
2660
+ if (serviceView) {
2661
+ out(`Service : ${serviceView.healthy === false
2662
+ ? `not answering the recorder (${serviceView.lastError ?? "no answer"}) — sessions do not wait for it meanwhile`
2663
+ : serviceView.lastOkAt
2664
+ ? `answering the recorder (last ${ago(serviceView.lastOkAt)})`
2665
+ : "no call yet"}`);
2666
+ }
2667
+ out(`Runner : ${report.off
2668
+ ? `OFF ${report.off.detail}: hooks answer nothing, wrapped servers are plain pipes — \`bir on${off === "project" ? " --here" : ""}\` turns it back on`
2669
+ : "on (`bir off --here` takes it out of the way without uninstalling)"}`);
2670
+ if (health && typeof health.off === "string" && !off) {
2671
+ out(` the recorder reports it OFF (${health.off}) — BIR_OFF=1 where it was started?`);
2672
+ }
2673
+ if (health && off && predatesOffSwitch(health)) {
2674
+ out(` but the running recorder (${recorderVersion(health) || "an older version"}) predates the off switch and keeps ` +
2675
+ "recording — `bir up --restart` replaces it");
2676
+ }
2677
+ if (report.otherProjects.length)
2678
+ out(`Elsewhere : also set up in ${report.otherProjects.join(", ")}`);
775
2679
  out("Servers :");
776
2680
  if (report.servers.length === 0)
777
2681
  out(" (none configured for this directory)");
778
2682
  for (const s of report.servers) {
779
2683
  const shadow = s.shadowed.length ? ` shadows:${s.shadowed.join("/")}` : "";
780
- out(` ${s.wrapped ? "[wrapped]" : "[ raw ]"} ${s.name} (${s.scope}${shadow}) → ${s.upstream}`);
2684
+ const stale = s.stale ? ` — STALE: ${s.stale.why}; \`bir setup\` here fixes it` : "";
2685
+ out(` ${s.wrapped ? "[wrapped]" : "[ raw ]"} ${s.name} (${s.scope}${shadow}) → ${s.upstream}${stale}`);
781
2686
  }
782
2687
  return 0;
783
2688
  }
784
- // ── doctor ─────────────────────────────────────────────────────────────────
2689
+ /**
2690
+ * Read the hooks as Claude Code will run them. The settings file is the
2691
+ * ground truth for the port: it is what every hook posts to, whatever
2692
+ * installed.json or the recorder believe.
2693
+ */
2694
+ function hookFacts(paths) {
2695
+ const facts = { files: [], selfStarting: false, ports: [], ensure: [], unreadable: [] };
2696
+ for (const file of [paths.settingsLocal, paths.settings]) {
2697
+ let parsed;
2698
+ try {
2699
+ parsed = parseJsonFile(file);
2700
+ }
2701
+ catch {
2702
+ facts.unreadable.push(file);
2703
+ continue;
2704
+ }
2705
+ let ours = false;
2706
+ for (const [event, matchers] of Object.entries(parsed?.hooks ?? {})) {
2707
+ if (!Array.isArray(matchers))
2708
+ continue;
2709
+ for (const matcher of matchers) {
2710
+ for (const hook of Array.isArray(matcher?.hooks) ? matcher.hooks : []) {
2711
+ if (!isBirHook(hook))
2712
+ continue;
2713
+ ours = true;
2714
+ if (hook.type === "command" && typeof hook.command === "string") {
2715
+ if (event === "SessionStart") {
2716
+ facts.selfStarting = true;
2717
+ facts.ensure.push({ file, command: hook.command, args: Array.isArray(hook.args) ? hook.args.map(String) : [] });
2718
+ }
2719
+ }
2720
+ else if (typeof hook.url === "string") {
2721
+ try {
2722
+ const port = Number(new URL(hook.url).port);
2723
+ if (port > 0 && !facts.ports.includes(port))
2724
+ facts.ports.push(port);
2725
+ }
2726
+ catch {
2727
+ /* isBirHook parsed it already */
2728
+ }
2729
+ }
2730
+ }
2731
+ }
2732
+ }
2733
+ if (ours)
2734
+ facts.files.push(file);
2735
+ }
2736
+ return facts;
2737
+ }
2738
+ /** "3 min ago", for a timestamp in the past. */
2739
+ function ago(at) {
2740
+ const s = Math.max(0, Math.round((Date.now() - at) / 1000));
2741
+ if (s < 90)
2742
+ return `${s} s ago`;
2743
+ if (s < 5400)
2744
+ return `${Math.round(s / 60)} min ago`;
2745
+ return `${Math.round(s / 3600)} h ago`;
2746
+ }
2747
+ /** The port of a loopback URL, or undefined. */
2748
+ function portOf(url) {
2749
+ if (!url)
2750
+ return undefined;
2751
+ try {
2752
+ const port = Number(new URL(url).port);
2753
+ return port > 0 ? port : undefined;
2754
+ }
2755
+ catch {
2756
+ return undefined;
2757
+ }
2758
+ }
2759
+ /**
2760
+ * The port this directory's recorder last could not take (control/daemon.ts,
2761
+ * readPortProblem), while it still matters: not once a recorder of ours
2762
+ * answers on it (the first one that binds it removes the file, but a status
2763
+ * may look in between), and not after the hooks moved to another port.
2764
+ * Absent — the usual case, or a runner older than the file — is undefined.
2765
+ */
2766
+ function openPortProblem(cwd, livePort, hookPort) {
2767
+ let problem;
2768
+ try {
2769
+ problem = readPortProblem(cwd);
2770
+ }
2771
+ catch {
2772
+ return undefined;
2773
+ }
2774
+ if (!problem)
2775
+ return undefined;
2776
+ if (livePort !== undefined && livePort === problem.port)
2777
+ return undefined;
2778
+ if (hookPort !== undefined && hookPort !== problem.port)
2779
+ return undefined;
2780
+ return problem;
2781
+ }
2782
+ /** One line for a person: what happened to the port, and what to run. */
2783
+ function describePortProblem(problem) {
2784
+ const when = typeof problem.at === "number" && problem.at > 0 ? ` (${ago(problem.at)})` : "";
2785
+ const fix = typeof problem.fix === "string" && problem.fix.trim() ? ` — ${problem.fix.trim()}` : "";
2786
+ return `the recorder could not take port ${problem.port}${when}: ${problem.detail}${fix}`;
2787
+ }
785
2788
  async function doctor(args) {
786
2789
  const cwd = process.cwd();
2790
+ // Three kinds of finding. PROBLEMS break a Claude Code session — a server or
2791
+ // hook that cannot start, hooks posting to a port nobody answers — and only
2792
+ // they make doctor exit 1. WARNINGS are the runner not doing all its job, or
2793
+ // a configuration it should fix; sessions work. NOTES are facts. A doctor
2794
+ // that failed machines where every session worked (a second project's port,
2795
+ // a recorder not started yet between sessions) was support noise, and it
2796
+ // failed the scripts that gate on it.
787
2797
  const problems = [];
2798
+ const warnings = [];
788
2799
  const notes = [];
789
- const entries = resolveServers(cwd);
2800
+ const paths = claudeCodePaths(cwd);
2801
+ const sidecar = readSidecar();
2802
+ const projectDir = projectDirFor(cwd, sidecar);
2803
+ const off = offSource(projectDir);
2804
+ let entries = [];
2805
+ try {
2806
+ entries = resolveServers(cwd);
2807
+ }
2808
+ catch (err) {
2809
+ problems.push(`${errText(err)} — Claude Code cannot read it either`);
2810
+ }
790
2811
  const wantWrapped = entries.filter((e) => isWrapped(e.config)).map((e) => e.name);
2812
+ const hooks = hookFacts(paths);
2813
+ for (const file of hooks.unreadable) {
2814
+ warnings.push(`${file} is not plain JSON, so its hooks could not be checked (and \`bir setup\` will not rewrite it)`);
2815
+ }
2816
+ if (off) {
2817
+ notes.push(`the runner is OFF ${describeOff(off, projectDir)}: hooks answer with nothing, wrapped servers record nothing ` +
2818
+ `(and run as plain pipes from their next start), nothing is replayed — \`bir on${off === "project" ? " --here" : ""}\` turns it back on`);
2819
+ }
791
2820
  const discovery = readDiscovery(cwd);
792
2821
  let health;
793
2822
  if (discovery) {
794
- health = await new ControlClient(discovery.url, discovery.token).health();
795
- if (!health)
796
- problems.push(`control server at ${discovery.url} did not answer /health`);
2823
+ health = await new ControlClient(discovery.url, discovery.token, 3_000).health();
2824
+ if (!health) {
2825
+ problems.push(`the control server at ${discovery.url} (pid ${discovery.pid}) did not answer /health — hooks here wait on it ` +
2826
+ "or fail; `bir up --restart` replaces it");
2827
+ }
2828
+ }
2829
+ else if (hooks.files.length > 0 && !hooks.selfStarting) {
2830
+ problems.push("hooks are installed here but nothing starts the recorder, so every hook fails until `bir up` — " +
2831
+ "run `bir setup` here for the SessionStart hook that starts it");
2832
+ }
2833
+ else if (hooks.files.length > 0) {
2834
+ notes.push("the recorder is not running; the SessionStart hook starts it with the next session (`bir up` starts it now)" +
2835
+ (wantWrapped.length && !off ? ` — ${wantWrapped.join(", ")} register with it then` : ""));
797
2836
  }
798
2837
  else {
799
- problems.push("no control server for this directory — recording will be Tier 2 (MCP only); " +
800
- "`bir up` starts one, and the SessionStart hook starts one with each session");
2838
+ notes.push("no hooks here: only MCP calls are recorded (standalone, Tier 2) — `bir setup` wires the hooks");
801
2839
  }
802
2840
  const registered = new Set((health?.registeredProxies ?? []).map((p) => p.serverName));
803
2841
  // A recorder with no session yet is the normal state right after `bir setup`
804
2842
  // and between sessions: the proxies are Claude Code's child processes, so
805
- // there is nothing to register until a session starts. Reporting that as a
806
- // problem made the first `bir doctor` a person ever ran exit non-zero on a
807
- // machine where nothing was wrong.
2843
+ // there is nothing to register until a session starts.
808
2844
  const idleRecorder = Boolean(health) && (health?.sessions ?? []).length === 0;
809
2845
  // The guarantee check: a *registered* proxy. Not the config file (which only
810
2846
  // says what should happen) and not `serverInfo` (which stays the upstream's).
811
- const unregistered = wantWrapped.filter((name) => !registered.has(name));
812
- if (unregistered.length > 0 && idleRecorder) {
813
- notes.push(`no Claude Code session is running here yet; ${unregistered.join(", ")} will register when one starts`);
814
- }
815
- else {
816
- for (const name of unregistered) {
817
- problems.push(`${name} is wrapped in config but no proxy has registered — is the session running?`);
2847
+ const unregistered = health ? wantWrapped.filter((name) => !registered.has(name)) : [];
2848
+ if (unregistered.length > 0) {
2849
+ if (off) {
2850
+ notes.push(`${unregistered.join(", ")} run as plain pipes while the runner is off, so they do not register`);
2851
+ }
2852
+ else if (idleRecorder) {
2853
+ notes.push(`no Claude Code session is running here yet; ${unregistered.join(", ")} will register when one starts`);
2854
+ }
2855
+ else {
2856
+ for (const name of unregistered) {
2857
+ warnings.push(`${name} is wrapped in config but no proxy has registered — is the session running? ` +
2858
+ "(one started before `bir setup` needs a restart; until then its calls record standalone)");
2859
+ }
818
2860
  }
819
2861
  }
820
2862
  for (const e of entries) {
821
2863
  if (!isWrapped(e.config) && e.shadowed.length > 0) {
822
- problems.push(`${e.name} is defined in ${e.shadowed.join(", ")} as well as ${e.scope}`);
2864
+ warnings.push(`${e.name} is defined in ${e.shadowed.join(", ")} as well as ${e.scope}`);
2865
+ }
2866
+ }
2867
+ // AN ORGANISATION POLICY THAT MATCHES SERVERS BY COMMAND (config/mcp-approval.ts).
2868
+ // A wrapped entry's command is the runner's: an allow-list then blocks it,
2869
+ // a deny-list no longer sees it. `bir setup` leaves such servers unwrapped.
2870
+ const policy = wantWrapped.length > 0 ? managedMcpPolicyBlocker() : undefined;
2871
+ if (policy) {
2872
+ warnings.push(`${wantWrapped.join(", ")} run through the runner, but ${policy} — Claude Code may refuse to start them; ` +
2873
+ "run `bir setup` here, which leaves them unwrapped");
2874
+ }
2875
+ // A LOCAL COPY THAT NO LONGER COPIES ITS SOURCE (config/overrides.ts). The
2876
+ // copy outranks the `.mcp.json` or user-scope entry it was made from, so a
2877
+ // teammate's change there, or a rotated token, never reaches this project:
2878
+ // Claude Code keeps starting the old server — broken because of the runner.
2879
+ const overrides = checkOverrides(cwd, sidecar);
2880
+ for (const c of overrides) {
2881
+ const src = c.record.override?.source === "project" ? ".mcp.json" : "your user scope";
2882
+ if (c.state === "changed") {
2883
+ problems.push(`${c.name}'s local copy is out of date with ${src}: ${c.why} — run \`bir setup\` here`);
2884
+ }
2885
+ else if (c.state === "removed") {
2886
+ problems.push(`${c.name}'s local copy outlives its source: ${c.why} — run \`bir setup\` here`);
2887
+ }
2888
+ else if (c.state === "unapproved") {
2889
+ warnings.push(`${c.name} runs here through a local copy although ${c.why} — so it starts without Claude Code asking; ` +
2890
+ "run `bir setup` here to take the copy out");
2891
+ }
2892
+ }
2893
+ // THE LOCAL SCOPE IS PER REPOSITORY. In a linked worktree or a subdirectory
2894
+ // Claude Code reads the main checkout's, so the wrapped entries here are
2895
+ // that install's; and from the main checkout, the worktrees that share them.
2896
+ const repoRoot = claudeProjectRoot(cwd);
2897
+ const localWrapped = entries.filter((e) => e.scope === "local" && ourEntry(e.config)).map((e) => e.name);
2898
+ if (localWrapped.length > 0 && normalizePath(repoRoot) !== normalizePath(cwd)) {
2899
+ notes.push(`${localWrapped.join(", ")} run through the runner here because Claude Code shares the local scope of ${repoRoot} ` +
2900
+ "with this directory — they record to that project's recorder; `bir uninstall` there takes them out");
2901
+ }
2902
+ else if (localWrapped.length > 0) {
2903
+ const shared = linkedWorktrees(repoRoot);
2904
+ if (shared.length > 0) {
2905
+ notes.push(`the linked worktree${shared.length === 1 ? "" : "s"} ${shared.join(", ")} share${shared.length === 1 ? "s" : ""} this ` +
2906
+ `local scope, so sessions there run ${localWrapped.join(", ")} through the runner too`);
2907
+ for (const dir of shared) {
2908
+ for (const c of overrides) {
2909
+ if (c.from !== "project" || !c.source)
2910
+ continue;
2911
+ let theirs;
2912
+ try {
2913
+ theirs = parseJsonFile(join(dir, ".mcp.json"))
2914
+ ?.mcpServers?.[c.name];
2915
+ }
2916
+ catch {
2917
+ theirs = undefined;
2918
+ }
2919
+ if (theirs !== undefined && !sameServerConfig(isWrapped(theirs) ? unwrapEntry(theirs) : theirs, c.source)) {
2920
+ warnings.push(`${dir} defines ${c.name} differently in its own .mcp.json, but sessions there run this checkout's copy — ` +
2921
+ `\`bir uninstall --server ${c.name}\` here if that worktree needs its own`);
2922
+ }
2923
+ }
2924
+ }
823
2925
  }
824
2926
  }
825
2927
  // IS ANYTHING ACTUALLY BEING SAVED?
@@ -831,8 +2933,8 @@ async function doctor(args) {
831
2933
  // was recording perfectly. Ask the recorder; it is the only process that knows.
832
2934
  if (health) {
833
2935
  if (health.recording === false) {
834
- problems.push("the recorder is running but has nowhere to send steps — run `bir setup` " +
835
- "(or set BIR_AUTH_URL and `bir login`), then `bir up --restart`");
2936
+ warnings.push("the recorder is running but has nowhere to send steps — run `bir setup` " +
2937
+ "(or set BIR_AUTH_URL and `bir login`); it starts recording by itself after that");
836
2938
  }
837
2939
  else {
838
2940
  notes.push(`recording to ${String(health.authUrl ?? "the configured BaseIn service")}${health.account ? ` as ${String(health.account)}` : ""}`);
@@ -840,6 +2942,68 @@ async function doctor(args) {
840
2942
  if (discovery?.daemon) {
841
2943
  notes.push(`the recorder runs in the background (pid ${discovery.pid}); its audit log is ${discovery.logFile ?? "under ~/.baseinstrunner/logs"}`);
842
2944
  }
2945
+ // WHETHER THE SERVICE IS ANSWERING THE RECORDER (util/service-health.ts).
2946
+ // A recorder older than that field says nothing, and nothing is claimed.
2947
+ const service = health.service;
2948
+ if (service && service.healthy === false) {
2949
+ warnings.push(`the BaseIn service is not answering the recorder (${service.lastError ?? "no answer"}; ` +
2950
+ `${service.consecutiveFailures ?? "several"} failure${service.consecutiveFailures === 1 ? "" : "s"} in a row). ` +
2951
+ "Hooks do not wait for it meanwhile, so sessions are unaffected; the recorder's log has the details");
2952
+ }
2953
+ else if (service?.lastOkAt) {
2954
+ notes.push(`the BaseIn service last answered the recorder ${ago(service.lastOkAt)}`);
2955
+ }
2956
+ // The recorder's own view of the switch. `off` is absent from a recorder
2957
+ // that predates it — which keeps recording whatever the switch says.
2958
+ if (off && predatesOffSwitch(health)) {
2959
+ warnings.push("this recorder is older than the off switch and keeps recording — `bir up --restart` replaces it");
2960
+ }
2961
+ else if (!off && typeof health.off === "string") {
2962
+ notes.push(`the recorder reports the runner OFF (${health.off}) — BIR_OFF=1 in the environment it was started with?`);
2963
+ }
2964
+ // A RECORDER OF ANOTHER VERSION. After an upgrade the old one keeps running
2965
+ // until something replaces it — and a 0.2.12 one still auto-approves every
2966
+ // wrapped MCP call, ignores `bir off`, and waits on the service in hooks.
2967
+ const theirs = recorderVersion(health, discovery ?? undefined);
2968
+ if (theirs && theirs !== VERSION) {
2969
+ warnings.push(`the recorder running here is ${theirs}, this bir is ${VERSION} — \`bir up --restart\` replaces it ` +
2970
+ "(once no Claude Code session here is mid-turn)");
2971
+ }
2972
+ // HOOKS THIS RECORDER DID NOT RECOGNISE. It answers them `{}` so the session
2973
+ // never shows an error, which also means nothing else would ever say so:
2974
+ // their steps are not recorded. A stale token is the usual cause (a
2975
+ // reinstall while this recorder kept running).
2976
+ const hookCounters = health.hooks;
2977
+ const unauthorized = typeof hookCounters?.unauthorized === "number" ? hookCounters.unauthorized : 0;
2978
+ if (unauthorized > 0) {
2979
+ warnings.push(`${unauthorized} hook call(s) carried a token this recorder does not know, so they were answered ` +
2980
+ "with nothing and not recorded — the hooks were reinstalled while it kept running: `bir up --restart`");
2981
+ }
2982
+ const overran = typeof hookCounters?.overran === "number" ? hookCounters.overran : 0;
2983
+ if (overran > 0) {
2984
+ notes.push(`${overran} hook call(s) hit their deadline and were answered early (control.hook_deadline in the log); ` +
2985
+ "the session was not held");
2986
+ }
2987
+ // WHO APPROVES A WRAPPED MCP CALL. 0.2.12 answered each one "allow"; now
2988
+ // Claude Code asks as it would without the runner, unless the recorder was
2989
+ // started with BIR_CORRELATION_DECISION=allow. A headless fleet that relied
2990
+ // on the old answer sees its MCP calls denied, so this is always said.
2991
+ const correlation = health.correlation;
2992
+ if (wantWrapped.length > 0 || correlation === "allow") {
2993
+ notes.push(correlation === "allow"
2994
+ ? "wrapped MCP calls are APPROVED by the recorder (BIR_CORRELATION_DECISION=allow where it was started): " +
2995
+ "Claude Code does not ask about them"
2996
+ : correlation === "ask"
2997
+ ? "wrapped MCP calls are left to Claude Code, which asks about each (BIR_CORRELATION_DECISION=ask)"
2998
+ : correlation === "fingerprint"
2999
+ ? "wrapped MCP calls are left to Claude Code's own permissions (correlation off: steps joined by fingerprint)"
3000
+ : correlation === undefined
3001
+ ? "this recorder predates the approval change: it answers every wrapped MCP call \"allow\" " +
3002
+ "(`bir up --restart` replaces it with one that leaves them to Claude Code)"
3003
+ : "wrapped MCP calls are not approved by the runner: Claude Code asks as it would without it. " +
3004
+ "For headless runs (`claude -p`) allow them in permissions.allow or --allowedTools, or set " +
3005
+ "BIR_CORRELATION_DECISION=allow where the recorder starts");
3006
+ }
843
3007
  // Whether a recurring sub-task the service found may actually run mid-task
844
3008
  // (segmented.md R-LIFE-8). Observe-only is the default and must be visible
845
3009
  // without reading a log: an operator turns arming on by reading what *would*
@@ -865,35 +3029,108 @@ async function doctor(args) {
865
3029
  // so this shell's BIR_AUTH_URL is evidence about them, if not about the
866
3030
  // recorder. The one mistake worth catching is a URL that is a website rather
867
3031
  // than the API: it answers `GET /health` with a page, so nothing else notices
868
- // until a password has been posted to it and it says `405`.
3032
+ // until a password has been posted to it and it says `405`. A warning, not a
3033
+ // problem: sessions do not use this shell. `--quick` skips the network call.
869
3034
  const shellAuthUrl = resolveAuthUrl();
870
- const shellAuthProblem = shellAuthUrl ? await describeAuthService(shellAuthUrl) : undefined;
3035
+ const shellAuthProblem = shellAuthUrl && !args.quick ? await describeAuthService(shellAuthUrl) : undefined;
871
3036
  if (shellAuthProblem) {
872
- problems.push(`BIR_AUTH_URL in this shell (${shellAuthUrl}) is not a BaseIn service: ${shellAuthProblem}. ` +
3037
+ warnings.push(`BIR_AUTH_URL in this shell (${shellAuthUrl}) is not a BaseIn service: ${shellAuthProblem}. ` +
873
3038
  `\`bir login\` and \`bir scenario\` here will fail — ${AUTH_URL_HINT}`);
874
3039
  }
875
3040
  // THE SESSIONSTART HOOK NAMES A NODE BY ABSOLUTE PATH. A version manager's
876
3041
  // per-shell directory, an uninstalled Node, a moved private Node: the hook
877
3042
  // then fails on every session and the recorder is never started, which
878
3043
  // records Tier 2 and looks like nothing at all.
879
- const paths = claudeCodePaths(cwd);
880
- for (const file of [paths.settingsLocal, paths.settings]) {
881
- let parsed;
882
- try {
883
- parsed = parseJsonFile(file);
3044
+ for (const hook of hooks.ensure) {
3045
+ if (isAbsolute(hook.command) && !existsSync(hook.command)) {
3046
+ problems.push(`the SessionStart hook in ${hook.file} points at a Node that is gone (${hook.command}) — run \`bir setup\` here again`);
884
3047
  }
885
- catch {
3048
+ const script = hook.args.find((a) => /\.js$/.test(a));
3049
+ if (script && isAbsolute(script) && !existsSync(script)) {
3050
+ problems.push(`the SessionStart hook in ${hook.file} runs a runner that is gone (${script}) — run \`bir setup\` here again`);
3051
+ }
3052
+ }
3053
+ // EVERY WRAPPED ENTRY NAMES A NODE AND A SCRIPT BY ABSOLUTE PATH, and the
3054
+ // host spawns them for the user's own servers now. A Node removed by a
3055
+ // version manager, or a runner moved by an upgrade, and every one of those
3056
+ // servers fails to start. Two `existsSync` calls each; no network.
3057
+ for (const e of entries) {
3058
+ if (isRemote(e.config) || !ourEntry(e.config))
3059
+ continue;
3060
+ const command = e.config.command;
3061
+ if (isAbsolute(command) && !existsSync(command)) {
3062
+ problems.push(`${e.name} cannot start: ${command} is gone — run \`bir setup\` here again`);
886
3063
  continue;
887
3064
  }
888
- for (const matcher of parsed?.hooks?.SessionStart ?? []) {
889
- for (const hook of matcher.hooks ?? []) {
890
- if (hook.type !== "command" || !hook.command || !isBirHook(hook))
891
- continue;
892
- if (!existsSync(hook.command)) {
893
- problems.push(`the SessionStart hook in ${file} points at a Node that is gone (${hook.command}) — run \`bir setup\` here again`);
894
- }
3065
+ const script = (e.config.args ?? []).find((a) => /bir-(proxy|scenario)\.js$/.test(a));
3066
+ if (script && isAbsolute(script) && !existsSync(script)) {
3067
+ problems.push(`${e.name} cannot start: ${script} is gone — run \`bir setup\` here again`);
3068
+ }
3069
+ }
3070
+ // A REMOTE SERVER WRAPPED WITHOUT CREDENTIALS. The proxy sends only what the
3071
+ // entry spells out; a server Claude Code signs in itself (OAuth) then answers
3072
+ // 401 to every call, and /mcp no longer offers to sign in. An older install
3073
+ // did this by default — `bir setup` un-wraps it; one wrapped because
3074
+ // `--server` named it is the person's call, and only a warning.
3075
+ for (const e of entries) {
3076
+ if (!isWrapped(e.config))
3077
+ continue;
3078
+ const original = unwrapEntry(e.config);
3079
+ if (!original || !isRemote(original))
3080
+ continue;
3081
+ const blocked = remoteWrapBlocker(original);
3082
+ if (!blocked)
3083
+ continue;
3084
+ if (findRecord(sidecar, cwd, e.name)?.record.optedIn === true) {
3085
+ warnings.push(`${e.name} is wrapped although ${blocked} (\`--server ${e.name}\` asked for it): if its calls answer 401, ` +
3086
+ `\`bir uninstall --server ${e.name}\``);
3087
+ }
3088
+ else {
3089
+ problems.push(`${e.name} is a remote server wrapped without credentials — ${blocked}, and the proxy cannot, so its calls ` +
3090
+ "fail (401). Run `bir setup` here: it un-wraps it (the hooks still record its calls)");
3091
+ }
3092
+ }
3093
+ // WRAPPED ENTRIES WHERE THEY DO NOT BELONG, left by an older install: the
3094
+ // user scope applies to every project on the machine, and `.mcp.json` is a
3095
+ // file a team commits — a teammate's clone would spawn this machine's paths.
3096
+ // `bir setup` moves both to this project's local scope.
3097
+ try {
3098
+ const userServers = (parseJsonFile(paths.claudeJson)
3099
+ ?.mcpServers ?? {});
3100
+ for (const [name, config] of Object.entries(userServers)) {
3101
+ if (isWrapped(config)) {
3102
+ warnings.push(`${name} is wrapped in your user scope, so it runs through the runner in EVERY project — run \`bir setup\` here ` +
3103
+ "to limit it to this one (and `bir uninstall --all` before removing the runner)");
895
3104
  }
896
3105
  }
3106
+ const mcpServers = (parseJsonFile(paths.mcpJson)
3107
+ ?.mcpServers ?? {});
3108
+ const inMcpJson = Object.entries(mcpServers).filter(([, c]) => ourEntry(c)).map(([n]) => n);
3109
+ if (inMcpJson.length > 0) {
3110
+ // A note, not a problem: `--mcp-json` writes this shape on purpose.
3111
+ notes.push(`${paths.mcpJson} holds runner entries (${inMcpJson.join(", ")}) with this machine's paths — do not commit ` +
3112
+ "it; `bir setup` (without --mcp-json) moves them to the local scope");
3113
+ }
3114
+ }
3115
+ catch {
3116
+ /* an unreadable config is reported by the checks above */
3117
+ }
3118
+ // ANOTHER CLAUDE CODE PROFILE. CLAUDE_CONFIG_DIR moves `.claude.json`, so an
3119
+ // install run with another value wrote its local-scope entries to another
3120
+ // file than the one this shell's Claude Code reads. Sessions started with
3121
+ // that profile run them; `bir uninstall` finds both.
3122
+ const elsewhere = otherClaudeFiles(sidecar, projectDir, paths.claudeJson);
3123
+ if (elsewhere.length > 0) {
3124
+ warnings.push(`this project was also installed into ${elsewhere.join(", ")} — not ${paths.claudeJson}, which Claude Code started ` +
3125
+ "from this shell reads (CLAUDE_CONFIG_DIR differs). Sessions of that profile run those entries; " +
3126
+ "`bir setup` from there updates them, and `bir uninstall` takes them out of both");
3127
+ }
3128
+ // RECORDERS A REINSTALL LEFT BEHIND (LC-1): a port change while a window was
3129
+ // open starts a second recorder for the new port; the old one keeps serving
3130
+ // that window and is found only through its own record.
3131
+ for (const r of await supersededRecorders(projectDir)) {
3132
+ notes.push(`a superseded recorder is still running (${describeSuperseded(r)}) for windows opened before the last ` +
3133
+ "reinstall — `bir down` stops it");
897
3134
  }
898
3135
  // THE SCENARIO SERVER IS ON THE CRITICAL PATH OF THE FIRST TURN. A plan armed
899
3136
  // at `UserPromptSubmit` is delivered through `mcp__bir__run_scenario`, so a
@@ -902,26 +3139,68 @@ async function doctor(args) {
902
3139
  // ~6s in. The turn does not fail; it just quietly runs the ordinary way, which
903
3140
  // is the failure this whole command exists to make visible.
904
3141
  const scenarioEntry = entries.find((e) => e.name === SCENARIO_SERVER_KEY);
905
- if (scenarioEntry && !isRemote(scenarioEntry.config) && scenarioEntry.config.command === "npx") {
3142
+ if (scenarioEntry && isNpxEntry(scenarioEntry.config)) {
906
3143
  notes.push("the bir scenario server starts via npx, which can take longer than the first tool call " +
907
- "of a turn — reinstall with `bir install --replay --global` to pin it to the installed copy");
3144
+ "of a turn — run `bir setup` (or `bir install --replay --global`) to pin it to the installed copy");
908
3145
  }
909
- const sidecar = readSidecar();
910
- if (sidecar.controlPort && discovery && !discovery.url.endsWith(`:${sidecar.controlPort}`)) {
911
- problems.push(`hooks were installed for port ${sidecar.controlPort} but the control server is on ${discovery.url}`);
3146
+ // THE PORT. The hooks' own URLs are the ground truth — they are what every
3147
+ // hook posts to — checked against the recorder actually answering here. Not
3148
+ // the machine-wide "last port any install wrote", which named another
3149
+ // project's port whenever two were installed and failed a working machine.
3150
+ const hookPort = hooks.ports.length === 1 ? hooks.ports[0] : undefined;
3151
+ if (hooks.ports.length > 1) {
3152
+ warnings.push(`the hooks here post to several ports (${hooks.ports.join(", ")}) — run \`bir setup\` here`);
3153
+ }
3154
+ if (hookPort !== undefined && discovery && health) {
3155
+ let livePort;
3156
+ try {
3157
+ livePort = Number(new URL(discovery.url).port);
3158
+ }
3159
+ catch {
3160
+ livePort = undefined;
3161
+ }
3162
+ if (livePort && livePort !== hookPort) {
3163
+ problems.push(`the hooks here post to port ${hookPort}, but this directory's recorder is on ${discovery.url}, so every hook ` +
3164
+ "fails — run `bir setup` here");
3165
+ }
3166
+ }
3167
+ const recordPort = projectRecord(sidecar, cwd)?.port;
3168
+ if (hookPort !== undefined && recordPort !== undefined && recordPort !== hookPort) {
3169
+ warnings.push(`installed.json gives this project port ${recordPort}, but its hooks name ${hookPort} — run \`bir setup\` here`);
3170
+ }
3171
+ // THE PORT THE RECORDER COULD NOT TAKE (control/daemon.ts). A recorder that
3172
+ // exits because the port its hooks name is busy or reserved says why, and by
3173
+ // whom; until one binds it, every hook here fails. Its own fix text names
3174
+ // the way out, which the generic advice above cannot.
3175
+ const portProblem = openPortProblem(cwd, health ? portOf(discovery?.url) : undefined, hookPort);
3176
+ if (portProblem)
3177
+ problems.push(describePortProblem(portProblem));
3178
+ // THE OTHER PROJECTS. Uninstalling here leaves them as they are; removing
3179
+ // the package without `bir uninstall --all` would break every one of them.
3180
+ const others = otherProjects(sidecar, cwd);
3181
+ if (others.length) {
3182
+ notes.push(`also set up on this machine: ${others.join(", ")} — \`bir uninstall --all\` removes the runner from every ` +
3183
+ "project (do that before removing the package)");
912
3184
  }
913
3185
  if (args.json) {
914
3186
  out(JSON.stringify({
915
3187
  ok: problems.length === 0,
916
3188
  problems,
3189
+ warnings,
917
3190
  notes,
918
3191
  health,
919
3192
  wrapped: wantWrapped,
3193
+ off: off ?? null,
3194
+ service: health?.service ?? null,
3195
+ otherProjects: others,
920
3196
  shellAuthUrl: shellAuthUrl || null,
921
3197
  shellAuthProblem: shellAuthProblem ?? null,
3198
+ portProblem: portProblem ?? null,
922
3199
  }, null, 2));
923
- return problems.length === 0 ? 0 : 1;
3200
+ return problems.length === 0 && !(args.strict && warnings.length > 0) ? 0 : 1;
924
3201
  }
3202
+ if (off)
3203
+ out(`Runner : OFF ${describeOff(off, projectDir)}`);
925
3204
  // Replay first, and loudly. It auto-approves tool calls in the session and
926
3205
  // bypasses the permission system entirely for direct steps
927
3206
  // (docs/calculatedReplay.md §13.2), so it must never be discoverable only by
@@ -935,20 +3214,21 @@ async function doctor(args) {
935
3214
  // it could describe then.
936
3215
  const via = replay.deriveVia ?? (replay.deriveKey ? "key" : "samples");
937
3216
  const derive = via === "key"
938
- ? "this machine (ANTHROPIC_API_KEY)"
3217
+ ? "this machine (a derivation key: BIR_DERIVE_API_KEY)"
939
3218
  : via === "service"
940
3219
  ? "the service (no key needed here)"
941
3220
  : "recorded sample values — scenarios with a target will NOT run";
942
3221
  out(` derive=${derive}`);
943
3222
  if (via === "samples") {
944
3223
  notes.push("nothing can read what this turn acts on: sign in with `bir login` so the " +
945
- "service derives, or set ANTHROPIC_API_KEY to derive here. Until then a " +
3224
+ "service derives, or set BIR_DERIVE_API_KEY where Claude Code starts to derive " +
3225
+ "here (the background recorder ignores an inherited ANTHROPIC_API_KEY). Until then a " +
946
3226
  "matched scenario with a target is declined and the agent does the task");
947
3227
  }
948
3228
  const idle = wantWrapped.filter((n) => !(replay.pollingProxies ?? []).includes(n));
949
- if (idle.length > 0 && !idleRecorder) {
3229
+ if (idle.length > 0 && !idleRecorder && !off) {
950
3230
  notes.push(`these proxies are not polling for replay work: ${idle.join(", ")} — ` +
951
- "their steps will fall back to recorded outputs");
3231
+ "a direct plan that needs one of them stops at that step and hands the task back to the agent");
952
3232
  }
953
3233
  }
954
3234
  else if (health) {
@@ -958,24 +3238,31 @@ async function doctor(args) {
958
3238
  out(`Registered proxies: ${[...registered].join(", ") || "(none)"}`);
959
3239
  out(`Control server : ${discovery?.url ?? "not running"}`);
960
3240
  out(`BaseIn (shell) : ${shellAuthUrl
961
- ? `${shellAuthUrl}${shellAuthProblem ? " — NOT a BaseIn service, see below" : " (answers /health)"}`
3241
+ ? `${shellAuthUrl}${shellAuthProblem ? " — NOT a BaseIn service, see below" : args.quick ? " (not checked: --quick)" : " (answers /health)"}`
962
3242
  : "(BIR_AUTH_URL not set in this shell)"}`);
963
3243
  out(`Recording tier : ${discovery && health ? "bound (Tier 1)" : "standalone (Tier 2)"}`);
964
- out(`Recording : ${health ? (health.recording === false ? "no — see below" : "yes") : "unknown (no control server to ask)"}`);
3244
+ out(`Recording : ${off ? "no — the runner is off" : health ? (health.recording === false ? "no — see below" : "yes") : "unknown (no control server to ask)"}`);
965
3245
  if (health?.lossy)
966
3246
  out("Lossy : yes — steps were dropped in this session");
967
3247
  out();
968
3248
  for (const n of notes)
969
3249
  out(` · ${n}`);
970
- if (problems.length === 0) {
3250
+ if (warnings.length) {
971
3251
  if (notes.length)
972
3252
  out();
3253
+ out("Warnings (sessions work, but the runner is not doing all it should):");
3254
+ for (const w of warnings)
3255
+ out(` ~ ${w}`);
3256
+ }
3257
+ if (problems.length === 0) {
3258
+ if (notes.length || warnings.length)
3259
+ out();
973
3260
  out("No problems found.");
974
- return 0;
3261
+ return args.strict && warnings.length > 0 ? 1 : 0;
975
3262
  }
976
- if (notes.length)
3263
+ if (notes.length || warnings.length)
977
3264
  out();
978
- out("Problems:");
3265
+ out("Problems (these break a Claude Code session here):");
979
3266
  for (const p of problems)
980
3267
  out(` ! ${p}`);
981
3268
  return 1;
@@ -999,15 +3286,35 @@ async function service(method, path, body) {
999
3286
  const session = await authenticate({ authUrl: baseUrl });
1000
3287
  if (!session)
1001
3288
  throw new Error("not signed in — run `bir login`");
1002
- const res = await fetch(`${baseUrl.replace(/\/+$/, "")}${path}`, {
1003
- method,
1004
- headers: {
1005
- "content-type": "application/json",
1006
- authorization: `Bearer ${session.accessToken}`,
1007
- },
1008
- body: body === undefined ? undefined : JSON.stringify(body),
1009
- });
1010
- const text = await res.text().catch(() => "");
3289
+ // A ceiling (BIR_SERVICE_TIMEOUT_MS): a service that accepts the connection
3290
+ // and never answers would otherwise hold `bir scenario list` for undici's
3291
+ // 300 s. The verdict goes to the process-wide breaker, like every call.
3292
+ let res;
3293
+ let text;
3294
+ try {
3295
+ res = await fetch(`${baseUrl.replace(/\/+$/, "")}${path}`, {
3296
+ method,
3297
+ headers: {
3298
+ "content-type": "application/json",
3299
+ authorization: `Bearer ${session.accessToken}`,
3300
+ },
3301
+ body: body === undefined ? undefined : JSON.stringify(body),
3302
+ signal: timeoutSignal(serviceTimeoutMs()),
3303
+ });
3304
+ text = await res.text();
3305
+ }
3306
+ catch (err) {
3307
+ serviceHealth.fail(errText(err));
3308
+ const timedOut = err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError");
3309
+ const ms = serviceTimeoutMs();
3310
+ throw new Error(timedOut
3311
+ ? `the BaseIn service did not answer within ${ms < 1_000 ? `${ms} ms` : `${Math.round(ms / 1_000)} s`} (BIR_SERVICE_TIMEOUT_MS)`
3312
+ : `the BaseIn service is unreachable (${errText(err)})`);
3313
+ }
3314
+ if (isUnhealthyStatus(res.status))
3315
+ serviceHealth.fail(`HTTP ${res.status}`);
3316
+ else
3317
+ serviceHealth.ok();
1011
3318
  let parsed = undefined;
1012
3319
  try {
1013
3320
  parsed = text ? JSON.parse(text) : undefined;
@@ -1127,7 +3434,11 @@ function replayPolicyCommand(args) {
1127
3434
  const word = args.positionals[0];
1128
3435
  const describe = (policy) => {
1129
3436
  out(`Replay policy for ${cwd}`);
1130
- out(` replay ${policy?.enabled === false ? "off" : "on"}`);
3437
+ out(` replay ${policy?.enabled !== false
3438
+ ? "on"
3439
+ : policy.disabledBy === "install"
3440
+ ? "off (not asked for at install: --no-replay, `bir uninstall --replay`, or no --replay) — `bir replay on` turns it on"
3441
+ : "off"}`);
1131
3442
  out(` direct servers ${policy?.allowServers?.length ? policy.allowServers.join(", ") : "(all wrapped)"}`);
1132
3443
  out(` minSimilarity ${policy?.minSimilarity ?? 0.92}`);
1133
3444
  const overrides = ["BIR_REPLAY", "BIR_REPLAY_ALLOW_SERVERS", "BIR_MIN_STEER_SIMILARITY"].filter((k) => process.env[k] !== undefined);
@@ -1147,10 +3458,13 @@ function replayPolicyCommand(args) {
1147
3458
  return 1;
1148
3459
  }
1149
3460
  const next = { ...(record.replay ?? {}) };
1150
- if (word === "on")
1151
- delete next.enabled;
1152
- else if (word === "off")
1153
- next.enabled = false;
3461
+ // On or off, the person's own answer: kept as they said it (true, not just
3462
+ // "absent"), so a later install without --replay does not overrule it, and
3463
+ // no longer marked as an install's (generate.ts, settleReplaySwitch).
3464
+ if (word === "on" || word === "off") {
3465
+ next.enabled = word === "on";
3466
+ delete next.disabledBy;
3467
+ }
1154
3468
  else if (word === "allow") {
1155
3469
  const list = args.positionals[1];
1156
3470
  if (!list) {
@@ -1280,20 +3594,86 @@ function wrap(args) {
1280
3594
  out("usage: bir wrap --server-name <key> -- <command> [args…]");
1281
3595
  process.exit(2);
1282
3596
  })();
1283
- const sidecar = readSidecar();
3597
+ // This directory's port, not the machine-wide "last port any install wrote",
3598
+ // which is another project's whenever two are installed. None recorded: no
3599
+ // control URL, and the proxy finds a recorder by its own directory.
3600
+ const port = projectRecord(readSidecar(), process.cwd())?.port;
3601
+ const invocation = resolveInvocation(args);
1284
3602
  const entry = wrapEntry(config, {
1285
3603
  serverName: name,
1286
- controlUrl: sidecar.controlPort ? `http://127.0.0.1:${sidecar.controlPort}` : undefined,
3604
+ controlUrl: port ? `http://127.0.0.1:${port}` : undefined,
1287
3605
  noCorrelation: args.noCorrelation,
1288
- invocation: resolveInvocation(args),
1289
- proxyPath: args.local || args.global ? localProxyPath() : undefined,
3606
+ invocation,
3607
+ proxyPath: invocation === "npx" ? undefined : localProxyPath(),
1290
3608
  version: VERSION,
3609
+ nodeFlags: invocation === "npx" ? undefined : nodeFlagsToCarry(),
3610
+ node: invocation === "npx" ? undefined : chooseNodePath().path,
1291
3611
  });
1292
3612
  out(JSON.stringify({ mcpServers: { [name]: entry } }, null, 2));
1293
3613
  return 0;
1294
3614
  }
3615
+ // ── logout ─────────────────────────────────────────────────────────────────
3616
+ /** How long `bir logout` waits on the service in all: finding its address, then the revoke. */
3617
+ const LOGOUT_REVOKE_BUDGET_MS = 8_000;
3618
+ /**
3619
+ * Ask the service to revoke a refresh token this machine no longer keeps.
3620
+ * Never throws: the local sign-out already happened, and a service that does
3621
+ * not answer only means the token lapses on its own; the audit log says so.
3622
+ */
3623
+ async function revokeRefreshToken(configured, refreshToken) {
3624
+ try {
3625
+ const authUrl = await normalizeAuthUrl(configured);
3626
+ const res = await fetch(`${authUrl}/auth/logout`, {
3627
+ method: "POST",
3628
+ headers: { "content-type": "application/json" },
3629
+ body: JSON.stringify({ refreshToken }),
3630
+ signal: timeoutSignal(LOGOUT_REVOKE_BUDGET_MS),
3631
+ });
3632
+ if (isUnhealthyStatus(res.status))
3633
+ serviceHealth.fail(`HTTP ${res.status}`);
3634
+ else
3635
+ serviceHealth.ok();
3636
+ res.body?.cancel().catch(() => undefined);
3637
+ }
3638
+ catch (err) {
3639
+ logLine("auth.logout_remote_failed", {
3640
+ error: errText(err),
3641
+ why: "the local credentials are cleared regardless",
3642
+ });
3643
+ }
3644
+ }
1295
3645
  // ── entry point ────────────────────────────────────────────────────────────
3646
+ /**
3647
+ * Move records whose stash is redacted out of an older runner's sight NOW
3648
+ * (generate.ts, REDACTED_KEY_PREFIX), not at the next install: a build before
3649
+ * this one kept them under the plain key, and a rollback to 0.2.12 could come
3650
+ * first — whose uninstall would write the blanked credentials back. One read
3651
+ * of installed.json; a write only when something moved. Never throws.
3652
+ */
3653
+ function migrateRecordKeys() {
3654
+ try {
3655
+ const path = installedPath();
3656
+ if (!existsSync(path))
3657
+ return;
3658
+ const raw = JSON.parse(readFileSync(path, "utf8"));
3659
+ const servers = raw && typeof raw === "object" ? raw.servers : undefined;
3660
+ if (!servers || typeof servers !== "object" || Array.isArray(servers))
3661
+ return;
3662
+ if (!normaliseRecordKeys({ ...servers }))
3663
+ return;
3664
+ writeSidecar(readSidecar());
3665
+ }
3666
+ catch {
3667
+ /* the next install moves them */
3668
+ }
3669
+ }
1296
3670
  async function main() {
3671
+ // Started with --use-env-proxy (a launcher written behind a proxy) in a shell
3672
+ // whose NO_PROXY does not name 127.0.0.1: without this, every call `bir`
3673
+ // makes to the recorder — `bir up`, `bir doctor`, setup's wait — goes to the
3674
+ // proxy and fails. Entries and hooks are still decided from the environment
3675
+ // as it was (generate.ts, nodeFlagsToCarry).
3676
+ exemptLoopbackFromProxy();
1297
3677
  const argv = process.argv.slice(2);
1298
3678
  if (argv.length === 0 || argv[0] === "-h" || argv[0] === "--help")
1299
3679
  usage(0);
@@ -1302,13 +3682,14 @@ async function main() {
1302
3682
  return 0;
1303
3683
  }
1304
3684
  const args = parseArgs(argv);
3685
+ migrateRecordKeys();
1305
3686
  switch (args.command) {
1306
3687
  case "install":
1307
3688
  return install(args);
1308
3689
  case "uninstall":
1309
- return uninstall(args);
3690
+ return await uninstall(args);
1310
3691
  case "status":
1311
- return status(args);
3692
+ return await status(args);
1312
3693
  case "doctor":
1313
3694
  return doctor(args);
1314
3695
  case "wrap":
@@ -1335,8 +3716,10 @@ async function main() {
1335
3716
  });
1336
3717
  case "setup": {
1337
3718
  // The invocation is decided here, not asked: an installed package gets
1338
- // absolute paths (--global), a checkout points at itself (--local).
1339
- const installed = runsFromInstalledPackage();
3719
+ // absolute paths (global), a checkout points at itself (local), and a
3720
+ // `npx @basein/runner setup` gets pinned npx — never an absolute path
3721
+ // into npx's cache, which npm deletes when it likes.
3722
+ const invocation = runsFromInstalledPackage() ? "global" : runsFromNpxCache() ? "npx" : "local";
1340
3723
  return runSetup({
1341
3724
  authUrl: args.authUrl,
1342
3725
  token: args.token,
@@ -1345,10 +3728,20 @@ async function main() {
1345
3728
  replay: !args.noReplay,
1346
3729
  daemon: !args.noDaemon,
1347
3730
  allowServers: args.allowServers,
3731
+ portExplicit: args.portExplicit,
1348
3732
  }, {
1349
3733
  out,
1350
3734
  err: (line) => process.stderr.write(`[bir] ${line}\n`),
1351
- install: ({ replay }) => install({ ...args, global: installed, local: !installed, replay, hooks: true, servers: [] }),
3735
+ install: ({ replay }) => install({
3736
+ ...args,
3737
+ global: false,
3738
+ local: false,
3739
+ npx: false,
3740
+ forceInvocation: invocation,
3741
+ replay,
3742
+ hooks: true,
3743
+ servers: [],
3744
+ }),
1352
3745
  hooksScript: localHooksPath(),
1353
3746
  });
1354
3747
  }
@@ -1356,30 +3749,58 @@ async function main() {
1356
3749
  const cwd = process.cwd();
1357
3750
  if (args.restart)
1358
3751
  await stopDaemon(cwd);
1359
- const { status, started, restarted } = await ensureDaemon(cwd, {
3752
+ // probeIdle: a recorder too old to say whether a turn is really open
3753
+ // (0.2.12 keeps an interrupted turn "active" for its whole life) is
3754
+ // replaced when two /health probes show it idle (control/daemon.ts).
3755
+ const ensureOpts = {
1360
3756
  hooksScript: localHooksPath(),
1361
3757
  restartOnSkew: true,
1362
- });
3758
+ probeIdle: true,
3759
+ };
3760
+ const { status, started, restarted } = await ensureDaemon(cwd, ensureOpts);
1363
3761
  const { info } = status;
1364
3762
  out(`Recorder ${started ? (restarted ? "restarted" : "started") : "already running"} for ${cwd}`);
1365
3763
  out(` ${info.url} (pid ${info.pid}${info.daemon ? "; background" : "; in a terminal"})`);
1366
3764
  if (info.logFile)
1367
3765
  out(` log ${info.logFile}`);
3766
+ const running = recorderVersion(status.health, info);
3767
+ if (!started && running && running !== VERSION) {
3768
+ out(` it is ${running}, not ${VERSION}: a Claude Code session here is mid-turn, so it was left running — ` +
3769
+ "`bir up --restart` once that turn ends replaces it");
3770
+ }
1368
3771
  if (status.health.recording === false) {
1369
- out(" !! it has no session to record with — run `bir setup` or `bir login`, then `bir up --restart`");
3772
+ out(" !! it has no session to record with — run `bir setup` or `bir login`; it starts recording by itself after that");
1370
3773
  return 1;
1371
3774
  }
1372
3775
  return 0;
1373
3776
  }
1374
3777
  case "down": {
1375
3778
  const cwd = process.cwd();
3779
+ // Superseded recorders first (LC-1): a reinstall on another port left
3780
+ // them serving older windows, and the discovery file no longer names them.
3781
+ let supersededStopped = 0;
3782
+ for (const record of await supersededRecorders(cwd)) {
3783
+ const result = await stopSuperseded(record);
3784
+ if (result.stopped) {
3785
+ supersededStopped += 1;
3786
+ out(`Superseded recorder stopped (${describeSuperseded(record)}).`);
3787
+ }
3788
+ else if (!result.stale) {
3789
+ out(`A superseded recorder (pid ${record.pid}) did not stop.`);
3790
+ }
3791
+ }
1376
3792
  const before = readDiscovery(cwd);
1377
3793
  if (!before) {
1378
- out("No recorder is running for this directory.");
3794
+ clearUninstallMark(cwd);
3795
+ if (supersededStopped === 0)
3796
+ out("No recorder is running for this directory.");
1379
3797
  return 0;
1380
3798
  }
3799
+ const live = await daemonStatus(cwd);
3800
+ const inUse = live ? recorderInUse(live.health) : false;
1381
3801
  const result = await stopDaemon(cwd);
1382
3802
  if (result.stale) {
3803
+ clearUninstallMark(cwd);
1383
3804
  out(`No recorder answered for this directory; a stale record (pid ${before.pid}) was removed.`);
1384
3805
  return 0;
1385
3806
  }
@@ -1387,11 +3808,16 @@ async function main() {
1387
3808
  out(`The recorder (pid ${before.pid}) did not stop.`);
1388
3809
  return 1;
1389
3810
  }
3811
+ clearUninstallMark(cwd);
1390
3812
  out(result.forced
1391
3813
  ? `Recorder stopped (pid ${before.pid}; forced — it did not answer, so its last steps may be missing).`
1392
3814
  : `Recorder stopped (pid ${before.pid}).`);
3815
+ explainDown(cwd, inUse);
1393
3816
  return 0;
1394
3817
  }
3818
+ case "off":
3819
+ case "on":
3820
+ return offCommand(args, args.command === "off");
1395
3821
  case "login": {
1396
3822
  // Settle the URL the way every other command does, then refuse to go on
1397
3823
  // when what is there is not the service. "Signed in" must mean the service
@@ -1468,11 +3894,31 @@ async function main() {
1468
3894
  return 0;
1469
3895
  }
1470
3896
  case "logout": {
1471
- // Revoke server-side before forgetting locally, so a stolen credentials
1472
- // file is dead rather than merely absent from this machine.
1473
- const configured = resolveAuthUrl();
1474
- await logout(configured ? await normalizeAuthUrl(configured) : undefined);
3897
+ // LOCAL FIRST, before anything touches the network: the session file
3898
+ // goes, then the service is asked to revoke the refresh token it held.
3899
+ // Finding the service's address is a /health round trip (a redirect to
3900
+ // https is followed), and a hung service or a Ctrl-C during it used to
3901
+ // leave this machine signed in. Running recorders notice the missing
3902
+ // file within seconds and stop sending.
3903
+ const cached = loadCredentials();
3904
+ clearCredentials();
1475
3905
  out("Logged out — cached credentials cleared.");
3906
+ const configured = resolveAuthUrl();
3907
+ // THE SERVICE THAT ISSUED IT, not whatever this shell names. A session is
3908
+ // bound to its service (auth/client.ts, issuedFor); a stale or mistyped
3909
+ // BIR_AUTH_URL here must not receive the refresh token, and the service
3910
+ // that can revoke it is the one that issued it. A file from before the
3911
+ // binding names none: the configured address, as before.
3912
+ const bound = (cached?.authUrl ?? "").replace(/\/+$/, "");
3913
+ const target = bound || configured;
3914
+ if (target && cached?.refreshToken) {
3915
+ if (bound && configured && bound !== configured.replace(/\/+$/, "")) {
3916
+ out(` revoking the session at ${bound}, the service that issued it (not ${configured})`);
3917
+ }
3918
+ // Best effort, and bounded as a whole: revoking makes a copy of the
3919
+ // file taken elsewhere dead rather than merely absent from here.
3920
+ await awaitWithin(revokeRefreshToken(target, cached.refreshToken), LOGOUT_REVOKE_BUDGET_MS, "logout");
3921
+ }
1476
3922
  return 0;
1477
3923
  }
1478
3924
  default: