@basein/runner 0.3.0 → 0.3.2

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.
package/production.md CHANGED
@@ -1,1371 +1,1373 @@
1
- # @basein/runner: production readiness
2
-
3
- This document is for the people who release, operate and support @basein/runner: the recording MCP proxy (`bir-proxy`), the Claude Code hook recorder (`bir-hooks`), the `bir` CLI, the first-party `bir` MCP server (`bir-scenario`) and calculated replay, all recording to the hosted BaseIn service at `api.bi2202.com`. It says what "production ready" now guarantees, what changed since 0.2.12, how to operate and release the runner, and what is still open.
4
-
5
- ## 1. Status
6
-
7
- The base is 0.2.12 (commit `9235953`). The release candidate is the change set this document ships with, versioned 0.3.0 in `package.json`. Automated verification is complete on the Windows development machine: 1,039 tests (1,033 pass, 0 fail, 6 skipped) and the real-process smoke run (`npm run test:smoke`) pass. The six skips are POSIX-only, Git-Bash or symlink-privilege tests that run on the Linux and macOS CI jobs. Still to do before release: the CI matrix and the end-to-end checks with the real Claude Code in §10.
8
-
9
- **Versioned 0.3.0, not 0.2.13.** Defaults changed in ways users and headless fleets will notice. Wrapped MCP calls are no longer auto-approved. Wrapped entries move to Claude Code's local scope. Hook timeouts changed, and replay no longer serves recorded outputs. Going back to 0.2.12 also needs a step first (§7.2). A minor-version bump tells people to read the release notes.
10
-
11
- ---
12
-
13
- ## 2. Summary
14
-
15
- ### 2.1 The requirement
16
-
17
- > "the most important thing is that the users will not have latency, broken experience, errors, things not working, because of using the runner/basic2202."
18
-
19
- In practice: the runner must never be the reason a Claude Code session is slow, shows an error, loses a working MCP server, or asks or skips a permission question it would not otherwise have. Where the runner cannot do its job (the service is down, the user is signed out, the recorder died), it steps aside silently and says so in the audit log, `bir doctor`, `bir status` and `/health`. Everything in this document is organised around that.
20
-
21
- ### 2.2 What is now guaranteed
22
-
23
- 1. **No tool call waits on the BaseIn service.** PreToolUse, PostToolUse, Stop, SessionEnd and the subagent hooks never await the network. The one exception is a replay plan that the service already matched for this turn: its steps may run inside PreToolUse, under a 9 s budget.
24
- 2. **A prompt waits on the service at most once per incident, and never when replay is off.** With replay on, UserPromptSubmit waits up to 1.5 s for the similar-prompt match. It does not wait at all while the service is failing: a process-wide breaker opens for 5 s after a failure, doubling to 60 s. It also does not wait while the service is slow: a match slower than 800 ms stops prompts waiting for 5 minutes, doubling to 30 minutes.
25
- 3. **Every hook answers inside a hard deadline below its Claude Code timeout.** It answers `200 {}` for anything it cannot use: a stale token, an unknown event, an oversize or malformed body, or the runner switched off. While the recorder is running, no runner-side condition produces a hook error.
26
- 4. **The recorder never waits on the service to start.** It listens in about 150 ms and signs in in the background. The SessionStart hook is capped at 11.5 s, always exits 0 and prints nothing into the session.
27
- 5. **No silent approvals.** Wrapped MCP calls get the user's own permission prompts. A `.mcp.json` server is routed through the runner only after Claude Code has approved it. Plan mode never gets `allow`. The runner answers `allow` only in three cases: a scenario step the model was told about, a `bypassPermissions` session, or an explicit opt-in.
28
- 6. **A wrapped server fails the way an unwrapped one does.** If the server dies, `bir-proxy` exits with its status. Answers up to 128 MiB are relayed, and a larger one ends the call with a clear tool error instead of a hang. Remote servers that Claude Code signs in itself are left unwrapped.
29
- 7. **The runner can be taken out of the way at once.** `bir off` (every project), `bir off --here` (one project) or `BIR_OFF=1` (one session) makes hooks answer `{}` within about a second. Nothing is uninstalled.
30
- 8. **Nothing the runner writes can tear a file Claude Code reads.** Every configuration and state file is replaced atomically. The team's `.mcp.json` and the user scope are never rewritten. Uninstall repairs entry by entry, and `bir uninstall --all` removes the runner from every project.
31
- 9. **Secrets stay local.** Uploads are redacted and size-capped, and so are derivation prompts. `credentials.json` is mode 0600. Stashed originals keep no header or env values.
32
- 10. **A recorder that dies mid-session comes back by itself.** The session's own proxies and `bir-scenario` restart it, except after a deliberate `bir down`.
33
-
34
- ### 2.3 Headline numbers
35
-
36
- | What | 0.2.12 | Now |
37
- |---|---|---|
38
- | Service hung (accepts, never answers): each prompt | 2,510 ms on every prompt | 1,508-1,518 ms on the first prompt of the outage only |
39
- | Service hung: each tool call (PreToolUse) | 4,010 ms on every call | 1.5-1.7 ms median* |
40
- | Service answering every request in 1.0 s: 2-minute run, one turn every 8 s (92 hooks) | every prompt about 1,020 ms | 1 hook over 100 ms |
41
- | First prompt of an outage (refused, 503, 503 + Retry-After) | 5-23 ms | 12-26 ms |
42
- | Recorder start while the service hangs | not listening for about 304 s, so every hook is a hook error | listening in 150 ms* |
43
- | SessionEnd while the service hangs | Claude Code timeout (> 30 s), 5 of 5 | about 0.8 ms* |
44
- | Wrapped MCP call in default permission mode | answered `allow` with rewritten arguments, 10 of 10 | no decision: the user's own rules apply* |
45
- | 33 MB tool result through the proxy | never answered, 5 of 5 | answered in about 2.4 s (2.26 s direct)* |
46
-
47
- \* Measured on the build before the final review's fixes. Those fixes targeted the prompt path, which was re-measured (§5.3).
48
-
49
- ### 2.4 Still open (details in §11)
50
-
51
- - **Healthy-service latency.** With replay on, a healthy service still costs every prompt one create-and-match round trip: 288-464 ms from the development machine. Removing it needs a service-side "this user or project has no scenarios" hint.
52
- - **Hang outages.** The first prompt of a hang outage waits the full 1.5 s match budget.
53
- - **Scenario logic isolation.** Scenario logic runs inside the recorder process with a time limit, but with no sandbox and no memory limit.
54
- - **Version-manager Node paths.** A Node path from nvm, fnm, Volta or asdf breaks the entries after a Node change. `bir doctor` flags it and `bir setup` repairs it.
55
- - **Stale local copies.** A local-scope copy goes stale after `.mcp.json` is edited, until `bir setup` runs. `bir doctor` flags it.
56
- - **Corporate proxies.** Proxy support depends on the Node version and on NO_PROXY exempting 127.0.0.1.
57
- - **Unparseable stdio answers.** An unparseable stdout answer line from a stdio server still hangs that one call, as it would unwrapped.
58
-
59
- ### 2.5 Must happen before release (details in §10)
60
-
61
- - CI green on ubuntu, windows and macos with Node 20, and on ubuntu with Node 24.
62
- - Release notes for 0.3.0 built from §7 (the version is already 0.3.0).
63
- - README and `docs/` matching this behaviour.
64
- - The service's served install scripts updated.
65
- - The manual end-to-end checks with the real Claude Code.
66
- - The service team told about the id-deduplication and "no scenarios" asks.
67
-
68
- ---
69
-
70
- ## 3. The production contract
71
-
72
- ### 3.1 Principles
73
-
74
- **Fail open.** Every failure path ends in "as if the runner were not there":
75
-
76
- - Hook routes answer `200 {}`. The route catch-all logs `control.route_failed`.
77
- - The SessionStart hook exits 0 and prints nothing.
78
- - `bir-proxy` relays the server's own answers verbatim and exits the way the server did.
79
- - `run_scenario` answers a neutral "do the task normally" text, never `isError`.
80
- - Process-level `uncaughtException` and `unhandledRejection` handlers log and keep serving in `bir-hooks`, `bir-proxy` and `bir-scenario`.
81
-
82
- **Fail fast.**
83
-
84
- - Every service request has a deadline (`BIR_SERVICE_TIMEOUT_MS`, 10 s; probes 5 s).
85
- - Every hot path asks the process-wide breaker (`src/util/service-health.ts`) before it waits, and waits only inside a budget.
86
- - The breaker opens on the first failure: a network error, a timeout, a 5xx, 408 or 429, or a body that is not the API's, such as an HTML page. It stays open for 5 s, doubling per further failure up to 60 s, and one real answer closes it. A late answer to a request that was already in flight when a hook gave up does not close it: that is the slowness arriving, not a recovery.
87
- - The prompt match gate adds a second rule for a service that answers, but slowly (§3.3).
88
-
89
- **Degrade silently, to the audit log.** A failure the user cannot act on is never shown in the session. It is logged, rate-limited (one line per transition, not per step), and visible in `bir doctor`, `bir status` and `/health`. Proxy stderr is quiet by default, because Claude Code files every stderr line of an MCP server as an error in its MCP log.
90
-
91
- **No silent auto-approval.**
92
-
93
- - By default the runner never answers `allow` for an ordinary wrapped MCP call. It joins the proxy's report to the hook by argument fingerprint instead.
94
- - It answers `allow` only in three cases:
95
- - a steered step of a scenario the model was told about in this turn, and only for a call that plausibly *is* that step (the pin guard, `src/control/pin-guard.ts`);
96
- - `run_scenario` when a direct plan was announced;
97
- - a `bypassPermissions` session, where `allow` changes nothing the user is asked.
98
- - It never answers `allow` in plan mode.
99
- - It never copies a `.mcp.json` server into the local scope unless Claude Code already approved it, because local-scope servers start without Claude Code's approval question.
100
-
101
- **No runner internals in the session.**
102
-
103
- - SessionStart prints nothing.
104
- - Hooks answer `{}` unless they are delivering a replay directive or hand-over note.
105
- - The `bir` MCP server lists only `run_scenario`, whose description says to call it only when told.
106
- - Timestamped audit lines never reach the model.
107
- - The one visible trace is the reserved `__bir_call_id__` property ("Reserved. Leave unset.") in wrapped tools' schemas while a recorder may inject ids (accepted, §11).
108
-
109
- **One switch takes it out of the way.**
110
-
111
- - `bir off` / `bir off --here` / `BIR_OFF=1` (§8.1): hooks answer `{}`, proxies stop recording and start as plain pipes, and `bir-scenario` lists no tools.
112
- - No service call is made, and nothing is uninstalled.
113
- - The recorder deliberately stays up while off, so hooks get an instant `{}` rather than a refused connection, which Claude Code would show as a hook error.
114
-
115
- ### 3.2 Hook routes
116
-
117
- "May wait on" is everything the route can await. "Worst case" is the most the route itself can take before its deadline guard answers. At the deadline the route answers `{}` (on PreToolUse mid-replay, an honest hand-over note) and the rest finishes in the background, logged as `control.hook_deadline`. Claude Code waits for a hung hook up to its timeout and then shows a hook error. The server's deadlines therefore sit well inside the timeouts `bir install` writes, which are the second line of defence, not a budget.
118
-
119
- | Hook (route) | May wait on | Normal cost | Worst case | Server deadline | Timeout `bir install` writes (0.2.12) |
120
- |---|---|---|---|---|---|
121
- | SessionStart: command hook `bir-hooks ensure` | the payload on stdin (≤ 2 s); finding or starting the recorder (local only, never the service); relaying the payload (≤ 2.5 s); a read-only stale-copy check (only with ≥ 3 s left) | warm about 0.1 s (Node start-up); cold about 0.7 s on Windows, once per project per boot | 11.5 s (11 s budget plus a 0.5 s hard exit). npx installs pass `--budget-ms 8000`, after npx's own start-up | `/session/start`: 2 s | 15 s (30) |
122
- | SessionStart: HTTP form (older installs only; `bir install` now always writes the command hook, and `bir setup` replaces the HTTP one) | nothing | milliseconds | 2 s | 2 s | 5 s (10) |
123
- | UserPromptSubmit | replay on: the similar-prompt match (≤ 1.5 s; skipped while the breaker is open or the match gate holds). For a matched scenario: arming, derivation (≤ 4 s) and the directive's argument preview (≤ 1.2 s), all inside 6 s. Replay off: nothing | about 17 ms with an instant local service; plus one service round trip (about 0.3-0.5 s) with replay on | 6 s, only for a matched scenario that must derive; 1.5 s on the first prompt of a hang | 7 s | 10 s (15) |
124
- | PreToolUse | nothing, unless a replay plan steers this turn. Then: in-hook steps (≤ 9 s, `BIR_PRETOOL_REPLAY_BUDGET_MS`), derivation at the first call (≤ 4 s), the previous pinned step (≤ 1.75 s), and after a hand-over the new fragment run's create (≤ 0.3 s, breaker-gated). Intent arming only when opted in (≤ 0.5 s probe + 1.5 s arming) | 2-5 ms | 12 s, replay only | 12 s | 15 s (30) |
125
- | PostToolUse, PostToolUseFailure | nothing: the proxy's report is joined in the background | 1-2 ms | 2 s | 2 s | 5 s (30) |
126
- | SubagentStart, SubagentStop | nothing | not measured (bookkeeping only) | 2 s | 2 s | 3 s (5) |
127
- | Stop, StopFailure | nothing: seals the run, and the upload drains in the background | 2-3 ms | 2 s | 2 s | 5 s (10) |
128
- | SessionEnd | nothing: seals the run, background drain | about 1 ms (0.2.12: 0.5 s healthy, over 30 s hung) | 1 s | 1 s | 3 s (30) |
129
-
130
- Hard deadlines are in `HOOK_DEADLINE_MS` (`src/control/server.ts`). The timeouts are in `HOOK_ROUTES` and `ENSURE_TIMEOUT_S` (`src/config/adapters/claude-code.ts`). Re-running `bir setup` rewrites the timeouts of an existing install.
131
-
132
- ### 3.3 Failure modes
133
-
134
- | Condition | What the user sees | What the runner does | Where it shows |
135
- |---|---|---|---|
136
- | Service unreachable, 5xx, 429 or an HTML page (captive portal, WAF, CDN) | Nothing. The first prompt of the outage takes 12-26 ms. | The first failure opens the breaker, and hooks stop waiting. Steps queue in the background: at most 500 sends or 32 MiB, oldest dropped. A send still facing a cool-down after 60 s is dropped unsent. The runner retries once, only for 429/503 or a connection that never reached the service. Recording resumes by itself. | `bir status` "Service : not answering"; doctor warning; `/health.service`, `/health.sending`; `service.degraded` / `service.recovered`, `recorder.backlog_full`, `recorder.run_incomplete`, `recorder.run_abandoned reason=service_down` |
137
- | Service hung (accepts, never answers) | One prompt per outage waits 1.5 s. Nothing else waits. | The match overrun marks the service slow, which opens the breaker and the match gate. Background requests time out at 10 s. | `/health.service`, `/health.matchGate`; `replay.decision code=match_timeout`, `replay.match_gate state=holding` |
138
- | Service slow but answering (match over 800 ms) | One prompt waits about the service's time, then none for 5 min. The hold doubles to 30 min while the service stays slow. | Match gate: prompts stop waiting for the match. It is still asked in the background, so a hit still stops that turn's duplicate recording. Nothing is steered meanwhile. A fast match ends the hold. | `/health.matchGate {holding, holdMs, until}`; `replay.match_gate`; `replay.decision code=match_slow` (verbose) |
139
- | Sign-in refused (the service's own JSON 400/401 to a refresh) | Nothing | `credentials.json` is re-read once. Recording then pauses (`auth_rejected`) and is retried in the background; `bir login` resumes it at once. An HTML or non-JSON 4xx counts as "unreachable", never as a sign-out. | `/health.recording=false`, `/health.sending.state`; doctor warning "nowhere to send steps"; `auth.refresh_refused`, `recorder.paused` |
140
- | Signed out, or no service configured | Nothing. Hooks take about 1-3 ms. | No network calls. After `bir login` or `bir setup`, the running recorder records from the next prompt with no restart. After `bir logout`, it stops sending within about 2 s. | `/health.recording=false`; doctor warning; `recorder.signed_out`, `recorder.enabled` |
141
- | Recorder killed mid-session | "hook error" lines on prompts and tool calls; the session keeps working | Once the pid is dead and the port refuses connections, the session's bound proxies and `bir-scenario` launch `bir-hooks revive` (detached). This never happens after `bir down`, and not in `bir-scenario`'s first 12 s. The next SessionStart (`/clear`, a new session) also starts the recorder. | doctor; `proxy.recorder_revive`, `scenario.recorder_revive` (MCP log); `daemon.revived` (recorder log) |
142
- | A 0.2.12 recorder still running after the upgrade | 0.2.12 behaviour continues: auto-approval, waits on the service, ignores `bir off` | Replaced at the next SessionStart when no session is mid-run. `bir setup`, `bir up` and `bir off` probe it twice, 2.5 s apart, and replace it when it is idle. | doctor warning "the recorder running here is 0.2.12"; `bir status`, and `bir off` names it; `daemon.restart` |
143
- | Hooks from another install (stale token, unknown event), non-POST, over 64 MB or malformed body | Nothing | Answers `200 {}` unprocessed. The project's current token from `installed.json` is accepted within about 3 s, so a same-port reinstall needs no restart. | `/health.hooks {unauthorized, unknownRoute, oversize}`; `control.hook_unauthorized`, `control.hook_unknown`, `control.hook_oversize` |
144
- | A route runs past its deadline | At most the deadline | Answers `{}` (or a hand-over note) and finishes in the background. A plan that was not announced in time is retired and never runs. | `/health.hooks.overran`; `control.hook_deadline`, `replay.pin_overran`, `replay.diverge_overran`, `replay.decision code=arm_timeout` |
145
- | Runner switched off | Nothing: no recording, no replay | See §8.1. | `bir status` "Runner : OFF"; doctor note; `/health.off`, `/health.hooks.off`, `/health.offSessions` |
146
- | Wrapped stdio server dies or fails to start | As without the runner: the server shows as failed in `/mcp`, with a reconnect offered | `bir-proxy` answers the calls in flight, then exits with the server's exit status (1 if it had none). `BIR_PROXY_STAY_UP=1` restores the old behaviour. | `proxy.upstream_gone` (Claude Code's MCP log for that server) |
147
- | Tool result over 128 MiB | The call ends with the tool error "The result was too large to deliver (N bytes; the limit is M bytes)" | The frame is discarded without being buffered. A remote upstream gives "upstream answer too large". An oversize notification is dropped; an oversize server request is refused with -32600. | MCP log |
148
- | Remote server that Claude Code signs in (OAuth, headersHelper, `${VAR}` headers, no credential header) | Nothing: it runs unwrapped | Not wrapped; the hooks still record its calls. If wrapped anyway with `--server`, a 401 reads "upstream requires sign-in (HTTP 401)". | doctor PROBLEM (wrapped without credentials) or warning (`--server`); `upstream.auth_required` |
149
- | Remote server forgets the session (404) or drops its stream | Nothing | One transparent re-initialise and a single retry. Legacy SSE reconnects with backoff (0.5 s doubling to 30 s) and re-initialises. | `upstream.session_expired`, `upstream.session_reinitialized` (verbose once routine), `upstream.standing_stream_failed` (at most once an hour per server) |
150
- | Corporate proxy (HTTPS_PROXY) | Works when NO_PROXY exempts 127.0.0.1 and Node is new enough | `--use-env-proxy` is carried into entries, hooks and launchers only when NO_PROXY exempts 127.0.0.1; otherwise install prints the NO_PROXY hint. Remote upstreams honour the proxy on Node 24.5+ and 22.21+. Loopback always connects directly. A malformed proxy variable is ignored. | install output; `upstream.proxy_env_ignored`, `upstream.proxy_env_invalid` |
151
- | Windows | A cold SessionStart costs about 0.7 s, once per project per boot; no console windows | See the Windows notes after this table. | none |
152
- | Project port held by another program or reserved by the system | Hook errors until the port changes | The recorder records the holder in `control/<key>.port.json` with the fix (`bir setup --port <n>`, ideally below 49152), and new sessions use the new port. | doctor PROBLEM, `bir status` "Port :", `control.port_busy` |
153
- | A replay step fails, times out, or its server refuses it | The model reads a `[BaseInstRunner calculated replay]` hand-over note and continues the task itself | The plan stops at that step and names the steps that ran. A timed-out step "may or may not have completed". Nothing after it runs. | `replay.handover`; `bir investigate` |
154
- | `bir` scenario server not connected | Nothing | Direct plans are declined (`no_delivery_tool`). A direct plan whose first model call is not `run_scenario` is retired; nothing is executed. | `/health.deliveryTool`, `/health.scenarioServers` |
155
- | `.mcp.json` edited after install | The server keeps its old configuration until `bir setup`, because the local copy outranks `.mcp.json` | SessionStart logs `overrides.stale` and does not rewrite anything. | doctor PROBLEM; `bir status` STALE |
156
-
157
- **Windows notes.**
158
-
159
- - The recorder starts through a hidden PowerShell `Start-Process`, for handle-inheritance safety. `BIR_WINDOWS_LAUNCH=spawn` opts into a faster direct spawn.
160
- - `.cmd` servers get cmd.exe-safe quoting, so `%VAR%` and trailing backslashes arrive intact.
161
- - PATH, PATHEXT and ComSpec are read case-insensitively.
162
- - On shutdown, the proxy kills the server's whole process tree.
163
- - MCP entries use plain `npx`.
164
- - The login link opens with `rundll32`, never `cmd /c start`, and only when it is https.
165
-
166
- ---
167
-
168
- ## 4. How this was done
169
-
170
- 1. **Audit.** Nine areas were audited:
171
- - the stdio proxy;
172
- - remote (http/sse) upstreams;
173
- - the hook server;
174
- - the recorder (daemon) lifecycle;
175
- - recording and auth;
176
- - calculated replay;
177
- - CLI and install;
178
- - the `bir` MCP server;
179
- - an empirical area that measured latency and failure behaviour against a local fake service, before any change.
180
-
181
- It produced 143 findings. Independent adversarial verifiers confirmed 142 of them and found 19 more, for 162 in total: 11 critical, 42 high, 72 medium, 37 low.
182
- 2. **Fix.** Fixes were made per component, each owning its own files. The places where components meet were fixed as explicit contracts. The contracts are:
183
- - one process-wide service-health breaker (`src/util/service-health.ts`);
184
- - one off switch (`src/util/off-switch.ts`);
185
- - one atomic writer (`src/util/atomic-write.ts`);
186
- - a live token getter;
187
- - an abort signal from `/scenario/run` into the replay run.
188
-
189
- Each component's fixes were reviewed adversarially, fixed again, and then integrated.
190
- 3. **Whole-system review.** A review of the integrated build used six lenses:
191
- - invisibility under failure;
192
- - upgrade from 0.2.12;
193
- - security and privacy;
194
- - replay correctness;
195
- - platform;
196
- - lifecycle.
197
-
198
- It confirmed 45 more findings: 2 high (both introduced by the change, and fixed), 14 medium and 29 low. It also measured the build before and after against 0.2.12, which found 3 problems on the prompt path. All of these were fixed, and the prompt path was re-measured.
199
- 4. **Result.** 207 findings: 193 fixed, 12 partially fixed, 1 not fixed and 1 accepted. Appendix A lists them all; §11 explains everything that is not fully fixed.
200
-
201
- **What "verified" means here.**
202
-
203
- - A finding counts only after a second reviewer reproduced it, or tried and failed to refute it. The evidence is a test, a measurement, or code reading with line references. Two examples: the PostToolUseFailure event-name finding was proven against Claude Code's own hook-validation code, and the latency findings were measured against real processes.
204
- - A fix counts only when both hold: tests that fail without it pass, and an adversarial reviewer could not break it.
205
- - The suite grew from 449 tests (447 pass, 2 skipped) to 1,039 (1,033 pass, 0 fail, 6 skipped). The real-process smoke run, which exercises calculated replay end to end, passes.
206
-
207
- **What the audit found already solid, and kept.** Nothing on the MCP call path awaits the network: relay overhead is +0.08 ms (Tier 2) and +0.26 ms (Tier 1) per call, median. Proxy stdout carried 0 non-JSON bytes in every run. `__bir_call_id__` never reaches an upstream in any tier. Killing the control server never breaks a tool call. Hooks bind 127.0.0.1 with a per-project bearer token. The SessionStart hook uses exec form, so paths with spaces are safe. Signed-out installs make no network calls. Install refuses a malformed config before writing anything. The install scripts run nothing from a truncated download and check the private Node's digest.
208
-
209
- ---
210
-
211
- ## 5. Measured results
212
-
213
- ### 5.1 Headline
214
-
215
- | Scenario | 0.2.12 | Release candidate |
216
- |---|---|---|
217
- | First UserPromptSubmit of an outage: connection refused | 5-6 ms | 12-14 ms |
218
- | First UserPromptSubmit of an outage: 503 | 8-12 ms | 17-20 ms |
219
- | First UserPromptSubmit of an outage: 503 with Retry-After | 9-23 ms | 17-26 ms |
220
- | Service hung: prompts | 2,510 ms, every prompt | 1,508-1,518 ms, once per outage |
221
- | Service hung: PreToolUse (Bash / MCP / Read) | 4,016 / 4,012 / 4,008 ms, every call | 1.7 / 1.5 / 1.6 ms median* |
222
- | 2-minute run, one turn every 8 s (92 hooks): service hung | every prompt and tool call waits | 1 hook over 100 ms (the first prompt) |
223
- | 2-minute run, one turn every 8 s (92 hooks): service answering in 1.0 s | every prompt about 1,020 ms | 1 hook over 100 ms |
224
- | Service slow 1 s, replay off: UserPromptSubmit | 1,026 ms median | 2.4 ms median* |
225
- | SessionEnd / prompt after an interrupted turn, service hung | Claude Code timeout, 5 of 5 (> 30 s / > 15 s) | 0.8 / 5.1 ms median* |
226
- | Recorder time to listen, service hung | about 304 s (every hook refused meanwhile) | 150 ms* |
227
- | SessionStart ensure, cold, service hung | 15.7 s, and 6 recorders after 6 SessionStarts | 0.76 s, 1 recorder* |
228
- | Proxy tools/call round trip, small result (Tier 1 / Tier 2; direct 0.05 ms) | 0.23 / 0.14 ms median | 0.30 / 0.12 ms median* |
229
- | Proxy re-binds after the recorder restarts on the same port | never (> 45 s, 5 of 5) | 10.6 s median* |
230
- | Healthy instant local service: UserPromptSubmit | 10.5 ms median | 17.8 ms median* (worse, still far under 100 ms) |
231
-
232
- \* Measured on the build before the final review's fixes (§5.2); not re-measured on the release candidate.
233
-
234
- ### 5.2 Full before/after table
235
-
236
- The table below compares 0.2.12 with the build *before* the final review's fixes. Three of its rows were "worse" on the first prompt of an outage: connection refused, 503, and 503 with Retry-After. Its hung-service rows also show the 2.5 s first-prompt wait. These were fixed and re-measured on the release candidate (§5.3). The other rows cover paths the final fixes did not target, and were not re-measured.
237
-
238
- | Scenario | Metric | 0.2.12 | After | Verdict |
239
- |---|---|---|---|---|
240
- | Hooks, healthy local fake service | UserPromptSubmit ms, median / worst (n=10; repeat runs n=20 x2) | 10.5 / 16.8 (repeats 10.9 / 19.2, 10.6 / 14.4) | 17.8 / 21.0 (repeats 16.2 / 20.6, 17.0 / 22.4) | worse (+6 ms, still under 100 ms) |
241
- | Hooks, healthy local fake service | PreToolUse Bash / PreToolUse wrapped MCP ms, median / worst (n=15 / 10) | 15.4 / 20.4 ; 5.0 / 13.9 | 2.5 / 4.9 ; 2.2 / 4.6 | improved |
242
- | Hooks, healthy local fake service | PostToolUse / Stop ms, median / worst | 1.5 / 2.4 ; 3.8 / 4.1 | 1.7 / 2.3 ; 2.3 / 3.5 | same |
243
- | Hooks, healthy local fake service | SessionEnd / prompt after an interrupted turn ms, median / worst (n=5 each) | 510 / 513 ; 533 / 533 | 1.3 / 1.5 ; 17.6 / 19.1 | improved |
244
- | Hooks, service slow 1 s (replay on) | UserPromptSubmit ms, median / worst | 1021 / 1035 | 1027 / 1037 (23 of 23 prompts over 3 min at 1009-1035) | same (still adds 1 s to every prompt) |
245
- | Hooks, service slow 1 s (replay on) | PreToolUse Bash / MCP ms, median / worst | 1018 / 1031 ; 1017 / 1025 | 3.6 / 5.6 ; 2.5 / 3.3 | improved |
246
- | Hooks, service slow 1 s (replay on) | PostToolUse / Stop / SessionEnd / prompt after interrupt ms, median / worst | 2.6 / 3.6 ; 3.9 / 5.6 ; 4072 / 4081 ; 3564 / 3585 | 1.5 / 2.3 ; 1.9 / 2.7 ; 1.05 / 1.3 ; 1027 / 1033 | improved |
247
- | Hooks, service slow 1 s, BIR_REPLAY=0 | UserPromptSubmit / SessionEnd / prompt after interrupt ms, median / worst | 1026 / 1033 ; 4087 / 4098 ; 4584 / 4590 | 2.4 / 4.3 ; 0.9 / 1.3 ; 3.3 / 7.6 | improved |
248
- | Hooks, service hung (accept, never answer), replay on | any hook while the recorder starts | recorder not listening for 304 s: every hook gets ECONNREFUSED (a hook error) in 5-7 ms | listening in 150 ms; hooks answer 200 {} | improved |
249
- | Hooks, service hung (BEFORE: hang-but-/health, since full hang never listens; AFTER: both) | UserPromptSubmit ms, median / worst | 2509 / 2520 (every prompt; 18 of 18 over 8 min) | 2.4 / 2517 (only the first prompt of the outage: 1 of 140 hooks in 3 min, 1 of 32 with 90 s gaps) | improved |
250
- | Hooks, service hung | PreToolUse Bash / MCP / Read ms, median / worst | 4016 / 4022 ; 4012 / 4018 ; 4008 / 4017 | 1.7 / 8.3 ; 1.5 / 2.4 ; 1.6 / 2.2 (full hang: 1.8 / 4.8 ; 1.5 / 3.1) | improved |
251
- | Hooks, service hung | PostToolUse / Stop ms, median / worst | 2.2 / 4.4 ; 4.5 / 8.7 | 0.9 / 2.5 ; 1.7 / 2.7 | same |
252
- | Hooks, service hung | SessionEnd / prompt after interrupt ms, median / worst (n=5) | host timeout 5 of 5 (> 30 s) ; host timeout 5 of 5 (> 15 s) | 0.83 / 0.9 ; 5.1 / 6.8 | improved |
253
- | Hooks, service hung, BIR_REPLAY=0 | UserPromptSubmit / SessionEnd / prompt after interrupt ms, median / worst | 2515 / 2518 ; timeout 5 of 5 (> 30 s) ; timeout 5 of 5 (> 15 s) | 2.5 / 4.6 ; 0.98 / 1.1 ; 3.8 / 4.2 (full hang: 2.2 / 4.6 ; 0.86 / 1.1 ; 5.0 / 7.1) | improved |
254
- | Hooks, service 503 (immediate) | UserPromptSubmit ms, median / worst ; first prompt of the outage (5 fresh recorders) | 11.9 / 17.4 ; 11 / 12 | 2.7 / 381 ; 503 / 666 | worse (first prompt of the outage) |
255
- | Hooks, service 503 with Retry-After: 2 | first UserPromptSubmit of the outage ms, median / worst (n=5) | 11 / 23 | 2046 / 2055 | worse |
256
- | Hooks, service 503 | PreToolUse Bash / SessionEnd / prompt after interrupt ms, median / worst | 14.9 / 16.4 ; 508 / 522 ; 517 / 545 | 1.8 / 4.8 ; 0.88 / 1.2 ; 6.0 / 6.5 | improved |
257
- | Hooks, service HTML 200 | UserPromptSubmit / PreToolUse Bash / SessionEnd / prompt after interrupt ms, median / worst | 10.8 / 14.8 ; 14.9 / 16.2 ; 509 / 520 ; 531 / 532 | 2.4 / 19.8 ; 1.9 / 3.8 ; 1.2 / 1.6 ; 7.6 / 8.1 | improved |
258
- | Hooks, service connection refused | UserPromptSubmit ms, median / worst ; first prompt of the outage (5 fresh recorders) | 3.2 / 5.8 ; 6 / 6 | 2.5 / 767 ; 696 / 729 | worse (first prompt of the outage) |
259
- | Hooks, service connection refused | PreToolUse Bash / SessionEnd / prompt after interrupt ms, median / worst | 3.1 / 5.6 ; 476 / 489 ; 490 / 491 | 1.9 / 5.8 ; 0.86 / 1.1 ; 6.4 / 9.5 | improved |
260
- | Hooks, signed out | UserPromptSubmit / PreToolUse Bash / MCP / PostToolUse / Stop ms, median / worst | 2.0 / 3.6 ; 2.0 / 4.0 ; 1.5 / 2.1 ; 1.1 / 2.0 ; 2.9 / 3.8 | 2.6 / 4.0 ; 1.8 / 4.6 ; 1.5 / 2.2 ; 1.0 / 1.7 ; 2.1 / 3.3 | same |
261
- | Hooks, signed out | SessionEnd / prompt after interrupt ms, median / worst | 488 / 490 ; 487 / 490 | 1.3 / 1.7 ; 6.7 / 7.1 | improved |
262
- | Hooks, expired token + hung service (AFTER only; BEFORE did not listen for about 306 s in the audit) | time to listen / UserPromptSubmit / others ms | not measured (audit: about 306 s not listening) | 147 ; 2.4 / 2523 (first only) ; pre 1.9 / 8.8, end 0.9 / 1.0 | improved |
263
- | bir-hooks start-up, service hung | time to listen ms, median / worst | 304,416 / 304,706 (n=6); a hook at +1 s and +5 s gets ECONNREFUSED | 150 / 156 (n=5); hooks at +1 s and +5 s get 200 {} | improved |
264
- | bir-hooks start-up, signed out | time to listen ms, median / worst | 126 / 150 (n=4) | 148 / 154 (n=5) | same |
265
- | SessionStart ensure, signed out | cold ms (n=5) ; warm ms (n=9), median / worst | 846 / 1376 ; 111 / 119 | 716 / 1395 ; 101 / 137 | same |
266
- | SessionStart ensure, service hung | cold ms (n=5 + 2 reruns) ; warm ms (n=9 + 3), median / worst ; recorders alive | 15,671 / 15,702 ('did not answer within 15s', Tier 2) ; 15,677 / 21,812 ; 6 recorders after 6 SessionStarts | 758 / 1363 ; 100 / 119 ; 1 recorder | improved |
267
- | SessionStart ensure, healthy service | cold ms (n=5) ; warm ms (n=9), median / worst | 833 / 935 ; 115 / 155 | 727 / 791 ; 106 / 116 | same |
268
- | bir-proxy tools/call, small result (direct median 0.05 ms) | Tier 2 (--standalone, signed out) round trip ms, median / worst (n=300) ; added median | 0.14 / 0.47 ; +0.09 | 0.12 / 0.49 ; +0.07 | same |
269
- | bir-proxy tools/call, small result | Tier 2 signed in (healthy service) ms, median / worst (n=300) | 0.24 / 2.08 | 0.24 / 2.41 | same |
270
- | bir-proxy tools/call, small result | Tier 1 bound (signed out / signed in recorder) ms, median / worst (n=300) | 0.23 / 1.82 ; 0.30 / 1.99 | 0.30 / 1.74 ; 0.24 / 2.03 | same |
271
- | bir-proxy 33 MB tools/call result (direct 2,256 / 2,289 ms) | answered? ms, median / worst (n=5, 20 s cap) | no answer 5 of 5, Tier 2 and Tier 1 ('line exceeded 33554432 bytes' in stderr only) | answered 5 of 5: Tier 2 2,394 / 2,471 ; Tier 1 2,387 / 2,523 | improved |
272
- | bir-proxy 30 MB / 10 MB result (direct 1,878 / 234 ms) | Tier 2 ms, median (n=3) | 4,194 / 510 | 1,957 / 266 | improved |
273
- | Control server killed mid-session (Tier 1 proxy) | first tools/call after the kill ms, median / worst (n=5) ; next 50 calls median / worst ; failures | 0.80 / 1.19 ; 0.18 / 0.59 ; 0 of 255 | 0.81 / 0.94 ; 0.19 / 0.75 ; 0 of 255 | same |
274
- | Control server killed mid-session | while dead, per 20 s: connection attempts to the dead port ; proxy CPU s ; stderr lines | 80 ; 0.31 ; 77-89 | 7 ; 0.125 ; 3 | improved |
275
- | Control server killed, then restarted on the same port | proxy re-binds (ms after the new recorder starts, n=5) ; steps recorded by the new recorder | never (> 45 s, 5 of 5) ; 0 | 10,584 median / 11,674 worst ; 2 of 2 | improved |
276
- | Tier 2 proxy signed in, service hung, 2,000 calls of 30 KB | tools/call ms, median / worst ; failures | 0.45 / 4.36 ; 0 | 0.35 / 4.42 ; 0 | same |
277
- | Tier 2 proxy signed in, service hung | proxy working set at 2,000 calls (hang ; hang-but-health) | 68 -> 176 MB ; 68 -> 173 MB (growing linearly) | 70 -> 220 MB ; 70 -> 218 MB (flat from about 1,500 calls) | worse at 2,000 calls |
278
- | Tier 2 proxy signed in, service hung, 4,000 calls | proxy working set ; shutdown after host stdin end | 68 -> 269 MB, still growing ; 3,058 ms | 70 -> 243 MB, about 215-250 MB plateau ; 1,131 ms (2,539 ms at 2,000 calls vs 3,042) | improved (bounded) |
279
- | User-visible: PreToolUse wrapped MCP, default permission mode (every service mode) | decision / rewrite in the hook answer | permissionDecision=allow + updatedInput with __bir_call_id__ on 10 of 10 calls in every mode (silent auto-approve + rewrite) | none: {callId, correlation:'fingerprint'} only (allow + id only under bypassPermissions) | improved |
280
- | User-visible: hook errors in failure modes | host-visible errors / timeouts | hung: SessionEnd timed out 5 of 5 (30 s), prompt after interrupt timed out 5 of 5 (15 s); recorder start-up with hung service: ECONNREFUSED on every hook for 304 s; stale token 401, unknown route 404 | none in any mode (0 timeouts, 0 non-200); stale token and unknown route answer 200 {} | improved |
281
- | User-visible: injected text | additionalContext / systemMessage / block decisions in failure modes; SessionStart stdout | none ; empty | none ; empty | same |
282
-
283
- ### 5.3 Re-measurement after the final fixes
284
-
285
- The final review found three prompt-path problems in the build above:
286
-
287
- 1. **The first prompt of an outage waited for a background retry.** It waited 250-750 ms for the run-create's retry, or up to 2,055 ms behind a Retry-After.
288
- 2. **A slow service that still answered cost every prompt its full latency.** The breaker closed on each late answer.
289
- 3. **A hung service cost the full 2.5 s match budget once per outage.**
290
-
291
- The fixes:
292
-
293
- 1. The match now resolves "no match" as soon as the run-create's first attempt fails. A match found later by the retry is never delivered to a hook; it is logged as `recorder.matched_after_retry`.
294
- 2. The late answer of a request sent before a hook gave up no longer closes the breaker. The match gate (800 ms, 5 min doubling to 30 min) stops prompts waiting on a slow service.
295
- 3. The match budget went from 2.5 s to 1.5 s.
296
-
297
- The release-candidate column was re-measured with a new build, isolated sandboxes and the local fake service. The other two columns come from the measurements in §5.2.
298
-
299
- | First UserPromptSubmit of an outage | 0.2.12 | Build before the fixes | Release candidate |
300
- |---|---|---|---|
301
- | Connection refused | 5-6 ms | 346-729 ms | 12-14 ms |
302
- | 503 | 8-12 ms | 473-666 ms | 17-20 ms |
303
- | 503 with Retry-After: 2 | 9-23 ms | 2,034-2,055 ms | 17-26 ms |
304
- | Hung (accepts, never answers) | 2,510 ms on every prompt, and 4,010 ms on every tool call | 2,512-2,524 ms once per outage | 1,508-1,518 ms once per outage |
305
-
306
- Over 2-minute runs with one turn every 8 s (92 hooks):
307
-
308
- - **Hung service:** 1 hook over 100 ms, the first prompt.
309
- - **Service answering every request in 1.0 s:** 1 hook over 100 ms. 0.2.12 and the build before the fixes both took about 1,020 ms on every prompt.
310
-
311
- **Against the real service, from the development machine:**
312
-
313
- - A create-and-match round trip took 288-464 ms. A cold TLS connection adds about 225-265 ms.
314
- - This is the per-prompt cost of replay on a healthy service, well under the 800 ms gate.
315
- - `BIR_REPLAY=0` or `bir replay off` removes it: the prompt then takes about 2.4 ms.
316
-
317
- ### 5.4 Method
318
-
319
- **Setup.**
320
-
321
- - Both versions ran from their own built `dist/` on the same Windows 11 machine.
322
- - Each version had a sandboxed home: `HOME`, `USERPROFILE`, `BIR_HOME` and `BIR_CLAUDE_JSON` were all sandboxed, and inherited `BIR_*` and `ANTHROPIC_*` variables were stripped.
323
- - A local fake BaseIn service ran in these modes: healthy, slow 1 s, hung, hung but answering `/health`, 503, 503 with Retry-After, an HTML 200, connection refused, signed out, and an expired token with a hung service.
324
- - The real service and the default port 53411 were never used.
325
-
326
- **Harness.**
327
-
328
- - Both versions got the same hook payloads, routes and fake upstreams.
329
- - Each POST was capped at the hook timeout that version's `bir install` writes, plus 250 ms.
330
- - Every answer was classified: permission decision, `updatedInput`, `additionalContext`, error, or timeout.
331
-
332
- **Runs.**
333
-
334
- - Hooks: five sessions per mode. Each session had a SessionStart, a normal turn (Bash, MCP and Read calls, then Stop), an interrupted turn, the prompt after it, and SessionEnd.
335
- - Three-minute cool-down runs, with 8 s and 90 s gaps between turns.
336
- - The first prompt of an outage, with 5 fresh recorders per mode.
337
- - Time to listen, with hook probes at +1 s and +5 s.
338
- - `bir-hooks ensure` cold (5 runs) and warm (9 runs).
339
- - 300 echo calls through the proxy per tier.
340
- - Results of 1, 10, 30 and 33 MB.
341
- - Killing and restarting the control server mid-session.
342
- - A standalone proxy under a hung service for 2,000 and 4,000 calls.
343
- - The raw hook answer bodies.
344
-
345
- **Adaptations and caveats.**
346
-
347
- - 0.2.12 never listens while the service is fully hung (about 304 s), so its hook latencies for that mode were taken with the service hung but answering `/health`. The release candidate was measured in both modes.
348
- - Latency-sensitive runs were sequential.
349
- - One run interrupted by the machine's Modern Standby was discarded and re-run.
350
- - Every process started for the measurement was stopped afterwards.
351
-
352
- ---
353
-
354
- ## 6. What changed, by component
355
-
356
- ### 6.1 Hook server / control server (`src/control/server.ts`, `pin-guard.ts`, `transcript.ts`)
357
-
358
- **Deadlines and fail-open answers.**
359
-
360
- - **Deadlines.** Hard per-route deadlines apply: start 2 s, prompt 7 s, pre 12 s, post, agent and stop 2 s, end 1 s. Past a deadline the route answers `{}`, or an honest note mid-replay.
361
- - **Fail-open answers.** Stale or wrong tokens, unknown routes, non-POST requests, bodies over 64 MB (read and discarded) and non-object bodies all get `200 {}`. They are counted in `/health.hooks`. Non-hook routes keep 401, 404 and 405.
362
- - **Event name.** Every answer's `hookEventName` matches the event that fired. The final review found a PostToolUseFailure answered as PostToolUse, which Claude Code rejects with a visible hook error that dumps the runner's JSON.
363
- - **Token.** The project's current token is read from `installed.json`: re-read at most every 3 s, and only when the file changed. A reinstall on the same port needs no recorder restart.
364
-
365
- **Prompt path.**
366
-
367
- - The match waits up to 1.5 s, and only while the breaker allows it.
368
- - The match gate: over 800 ms, prompts stop waiting for 5 min, doubling to 30 min.
369
- - Arming a matched scenario, including derivation and the directive's argument preview, stays inside 6 s.
370
- - A plan that is ready only after the prompt was answered is retired unannounced, so it never steers (`replay.decision code=arm_unannounced` or `arm_timeout`).
371
- - With replay off, the prompt never waits.
372
- - The prompt after an interrupted turn, and SessionEnd, seal synchronously and drain in the background. They used to wait for the whole backlog to reach the service (0.5 s healthy, over 30 s hung).
373
-
374
- **Correlation and approvals.**
375
-
376
- - By default PreToolUse answers `{callId, correlation:'fingerprint'}`, with no permission decision.
377
- - Ids are injected only in three cases: under `bypassPermissions`, for replay-pinned steps, or with `BIR_CORRELATION_DECISION=allow|ask`.
378
- - Even then, ids are injected only into a server whose proxy is alive, and never in plan mode.
379
- - The fingerprint join hashes the raw hook input against the proxy's `argsFingerprint`. Its consent window is 10 minutes, so a user who reads the permission prompt before approving is not an error.
380
-
381
- **Replay safety in the hooks.**
382
-
383
- - **The pin guard.** A steered step is applied only to a call that plausibly is that step:
384
- - the same command head, compared command by command for chained shells;
385
- - an exact copy for a step with a command substitution;
386
- - the same file for writes;
387
- - shared argument keys, plus the same statement verb, for query and shell MCP tools.
388
-
389
- Other calls run as written. After a declined pin, calling a different tool retires the plan.
390
- - **Direct plans.** A direct plan (run through `run_scenario`) is announced only while a live `bir-scenario` has sent `/scenario/hello` within 30 minutes. A direct plan whose first model call is not `run_scenario` is retired, and nothing is executed.
391
- - **`/scenario/run`.** It runs only the calling session's plan, bound by `toolUseId` or `sessionId`. If it cannot tell whose it is, it runs nothing (`replay.run_ambiguous`). A second concurrent run is refused, and Esc aborts the run.
392
- - **Plan mode.** Plan mode gets no directive, no steering and no `allow`.
393
-
394
- **Bounded state.**
395
-
396
- - **Transcript.** The transcript is read incrementally, only the bytes appended since the last hook. Retention is bounded: 128 messages of up to 16K characters each for text and thinking, 4,096 tool-use ids, and 32 tool results of up to 8K characters.
397
- - **Sessions.** A session is "active" when it has an open run and was heard from in the last 15 min.
398
- - Sessions are forgotten after 6 h idle, 10 min after SessionEnd, or beyond 500.
399
- - Open runs idle for 2 h are sealed.
400
- - `/health` lists at most 50 sessions.
401
- - **`BIR_OFF=1` sessions.** A session started with `BIR_OFF=1` is kept off for up to 7 days of silence, at most 2,000 of them.
402
-
403
- ### 6.2 Background recorder lifecycle (`src/control/daemon.ts`, `recorder-main.ts`, `recorder-keeper.ts`, `ensure-hook.ts`, `discovery.ts`, `bin/bir-hooks.ts`)
404
-
405
- **Start-up and sign-in.**
406
-
407
- - The recorder listens before it touches the network. It builds its recorder from `credentials.json` and `config.json`, and signs in and refreshes in the background.
408
- - An expired token no longer disables recording. An HTML or non-JSON 4xx on refresh counts as "unreachable". Only the service's own JSON refusal pauses recording, and it is re-checked.
409
- - `bir login` and `bir setup` make a running recorder record from the next prompt. `bir logout` stops it.
410
-
411
- **The SessionStart hook.**
412
-
413
- - Its whole budget is 11 s, with a hard exit at 11.5 s: stdin 2 s, relay 2.5 s.
414
- - It loads no control-server, replay or auth modules.
415
- - It never writes `~/.claude.json`. It only logs stale local copies (`overrides.stale ... fix=run bir setup`), because writing that file while Claude Code starts could lose either write.
416
-
417
- **Environment and working directory.**
418
-
419
- - The recorder runs in `~/.baseinstrunner`; the project is passed as `BIR_DAEMON_CWD`. The project folder can therefore be renamed, deleted or removed as a git worktree while the recorder runs.
420
- - The recorder never inherits `BIR_OFF`, the setup token, or `ANTHROPIC_API_KEY` (unless `BIR_USE_ANTHROPIC_API_KEY=1`).
421
-
422
- **Finding and replacing a recorder (`ensureDaemon`).**
423
-
424
- - A live recorder that is slow to answer `/health` (3 s) is never replaced or duplicated.
425
- - Only an *older* recorder is replaced for version skew, and only when no session is mid-run. With `bir up`, `bir setup` or `bir off`, a legacy recorder that claims a run is open is also replaced when two probes 2.5 s apart show it idle.
426
- - **Reinstall on another port.** The old recorder keeps serving the windows opened before, and a second one serves the new port. The old one is recorded as superseded, logs to its own file, and exits after 24 h with nothing left to serve (`BIR_SUPERSEDED_EXIT_MS`).
427
- - **Same port, new token.** The recorder is replaced only between turns.
428
-
429
- **Revive and `bir down`.**
430
-
431
- - `bir-hooks revive --cwd <dir>` starts the recorder again mid-session for installed projects. It is silent and always exits 0.
432
- - `bir down` writes a down marker, so nothing revives the recorder until a session start, `bir up` or `bir setup`.
433
-
434
- **Robustness.**
435
-
436
- - Shutdown gives `close()` 3 s, then hard-exits at 5 s. A second SIGINT or SIGTERM exits at once.
437
- - The log is rolled to `.1` at 8 MB (checked every minute) and reopened after write errors or a removal.
438
- - Discovery files are written atomically. They are rewritten when missing or after 30 minutes, reclaimed within a minute from an older or other-port recorder, and records of dead recorders are swept.
439
- - **Windows launcher.** The PowerShell launcher is bounded to 2-8 s and hidden; `BIR_WINDOWS_LAUNCH=spawn` opts into a direct spawn.
440
- - **Port conflicts.** A pinned port that cannot be bound is recorded in `control/<key>.port.json`, with its holder and the fix.
441
-
442
- ### 6.3 bir-proxy, stdio (`src/bin/bir-proxy.ts`, `src/proxy/*`, `src/jsonrpc/framing.ts`, `src/upstream/stdio-client.ts`)
443
-
444
- **Upstream failure and size limits.**
445
-
446
- - **Upstream death.** The proxy exits when its upstream dies for good, after answering the calls in flight, with the upstream's exit code. `BIR_PROXY_STAY_UP=1` keeps the old behaviour. `--restart` restarts a dead upstream once and replays the host's handshake to it.
447
- - **Size limit.** The frame cap went from 32 MiB to 128 MiB. A larger answer ends the call with a tool error; it used to hang. Framing is now linear, so a 20-30 MB result adds about 0.1-0.3 s instead of 0.8-1.7 s.
448
-
449
- **Binding to a recorder.**
450
-
451
- - The proxy binds to its own project's recorder through the discovery file. `BIR_CONTROL_URL` alone no longer decides where it registers. `BIR_CONTROL_URL` together with `BIR_CONTROL_TOKEN` (the SDK channel) still wins.
452
- - A late or restarted recorder is re-bound within 15 s. The discovery window is 10 s.
453
- - A proxy that sees its recorder dead (dead pid, refused port, installed project) launches `bir-hooks revive`: at most once per 30 s, backing off to 10 min, with 0-5 s of jitter.
454
-
455
- **Recording tiers.**
456
-
457
- - A user-scope entry wrapped by an older `bir install` records only in installed projects. Anywhere else it is a pure relay ("passthrough") and uploads nothing.
458
- - Entries written by `install --config`, local and project entries, and `bir wrap` entries still record standalone (Tier 2).
459
- - The step queue holds at most 250 steps. Step reports are built one tick after the answer is written. They are capped at 64 KB and carry `argsFingerprint`.
460
-
461
- **Schema, off switch and stderr.**
462
-
463
- - **Schema.** `tools/list` declares `__bir_call_id__` as `{type: string, description: 'Reserved. Leave unset.'}`, only while ids may be injected. It no longer forces `additionalProperties: true`.
464
- - **Off switch.** A stdio proxy started while off runs the real server with inherited stdio. A running proxy stops recording within about 1 s.
465
- - **Stderr.** Stderr is quiet: routine lines need `BIR_VERBOSE=1`. The decision and failure lines kept on stderr are `tier.downgrade`, `tier.rebound`, `run.lossy`, `upstream.exited`, `relay.undeliverable` and `proxy.upstream_gone`.
466
-
467
- **Windows and shutdown.**
468
-
469
- - **Windows.** PATH, PATHEXT and ComSpec are read case-insensitively. Environment overrides remove other spellings of the same key. `.cmd` and `.bat` arguments keep trailing backslashes and `%VAR%`.
470
- - **Shutdown.** Stdin EOF, then 1.5 s of grace, then a tree kill (`taskkill /T /F` on Windows, SIGTERM then SIGKILL elsewhere), 3 s in total.
471
-
472
- ### 6.4 Remote http/sse upstreams (`src/upstream/http-client.ts`, `http-transport.ts`, `sse.ts`, `headers.ts`)
473
-
474
- **Transport and ordering.**
475
-
476
- - **Transport.** It is `node:http`/`node:https` instead of `fetch`, with no hidden 300 s timeouts. Only connecting is bounded: 10 s for DNS, TCP and TLS (`BIR_UPSTREAM_CONNECT_TIMEOUT_MS`), and 15 s for the legacy SSE connect including the endpoint event.
477
- - **No proxy timeout on tool calls.** Nothing bounds a tool call after connect; Claude Code's own timeout and Esc govern it.
478
- - **Ordering.** Only the handshake is ordered: `initialize`, then `notifications/initialized`, with at most a 10 s gate. Every other message is independent, so parallel calls, cancellations and elicitation answers are not queued.
479
- - **Cancellation.** `notifications/cancelled` goes upstream at once, and the cancelled POST is released.
480
-
481
- **Streams and sessions.**
482
-
483
- - **Legacy SSE.** Sends wait for the endpoint event. A dropped stream reconnects with backoff (0.5 s doubling to 30 s) and re-initialises. An endpoint on another origin is refused.
484
- - **Session expiry.** A 404 after the handshake triggers one transparent re-initialise and a single retry.
485
- - **Protocol headers.** `MCP-Protocol-Version` is sent on every request after initialize, and `close()` sends a best-effort DELETE (1 s).
486
-
487
- **Answers and error texts.**
488
-
489
- - **Answer cap.** Answers are capped at 128 MiB (`BIR_UPSTREAM_MAX_MESSAGE_BYTES`). Batches are unpacked, and non-JSON-RPC bodies are never forwarded.
490
- - **Error texts.** They are short and never echo the server's body: "upstream unreachable: …", "upstream HTTP <status>", "upstream requires sign-in (HTTP 401)", "upstream returned an unexpected answer (HTTP 200, text/html)", and similar.
491
-
492
- **Proxies, redirects and credentials.**
493
-
494
- - **Proxy support.** HTTP(S)_PROXY and NO_PROXY are honoured on Node 24.5+ and 22.21+; `BIR_UPSTREAM_PROXY_ENV=0` turns this off. Loopback always connects directly. A malformed proxy variable is ignored rather than fatal.
495
- - **Idle sockets.** Keep-alive sockets close after 4 s idle.
496
- - **Redirects.** A redirect to another origin carries only protocol headers. An https-to-http redirect off loopback is refused.
497
- - **Credentials.** Wrapped remote entries carry their credentials in `env.BIR_UPSTREAM_HEADERS` (JSON) instead of `--header` arguments visible in process lists. The proxy removes the variable from its environment after reading it. A header Node cannot send is skipped and logged once, without its value.
498
-
499
- ### 6.5 Recording, auth and privacy (`src/record/*`, `src/auth/client.ts`)
500
-
501
- **Service calls and the breaker.**
502
-
503
- - **Deadlines.** Every recorder and auth request has a deadline: 10 s by default (`BIR_SERVICE_TIMEOUT_MS`), and 5 s for probes and the `/health` check.
504
- - **Breaker.** Every service call reports to the process-wide breaker. `getMatch`, `runCreated` and `matchIntent` answer at once while it is open.
505
- - **Probing.** While the service is half-open, only one probe goes out. The other send lanes follow after 0 to 2 s of jitter.
506
-
507
- **Backlog and flushing.**
508
-
509
- - **Backlog.** The backlog is bounded: 500 sends or 32 MiB, oldest steps dropped first. A queued send still facing a cool-down after 60 s is dropped unsent, not sent into the outage.
510
- - **Flush.** A flush waits at most 2 s (`BIR_FLUSH_TIMEOUT_MS`), and not at all while the service is cooling down.
511
- - **Retries.** A retry happens once, only after 429, 503, or a connection error that proves nothing reached the service. A 409 on the retry counts as "already landed". There is no retry after 502, 504 or a mid-request reset, until the service confirms it deduplicates ids (§10).
512
-
513
- **Size caps on uploads.**
514
-
515
- - Prompt: 32 KiB.
516
- - Answer: 64 KiB.
517
- - Reasoning and intent: 8 KiB.
518
- - Non-JSON tool errors: 8 KiB.
519
- - Report errors: 2 KiB.
520
- - Tool payloads: 64 KiB.
521
-
522
- Truncation is always marked with the original size.
523
-
524
- **Redaction.**
525
-
526
- - **Key matching.** Keys are matched on whole name segments, with 0.2.12's coverage restored for plurals, trailing `auth`, `oauth` and `auth_code`. Ordinary arguments such as `author`, `max_tokens` and `nextPageToken` survive.
527
- - **Value shapes.** Covered:
528
- - dotenv and shell assignments;
529
- - ini and YAML credential lines;
530
- - AWS credentials;
531
- - PEM private keys;
532
- - URL passwords;
533
- - Basic, Bearer and Cookie headers;
534
- - `curl -u` and `--token=` flags;
535
- - query-string tokens;
536
- - Azure connection strings;
537
- - JWTs;
538
- - common provider key formats.
539
- - **What is redacted.** Prompts, answers, reasoning, errors and derivation prompts are redacted too.
540
- - **JSON.** JSON payloads are redacted structurally, so recordings stay valid JSON and match live replay byte for byte.
541
- - **Idempotence.** Redaction is idempotent.
542
- - **Old rule.** `BIR_REDACT_LEGACY_KEYS=1` adds the old substring rule on top.
543
-
544
- **Credentials.**
545
-
546
- - `credentials.json` and `config.json` are written atomically, and credentials are mode 0600.
547
- - A running recorder follows `credentials.json`:
548
- - after `bir logout` it stops within about 2 s;
549
- - another account closes the open runs and continues under the new account;
550
- - a job is never sent under another account than the one it was queued under.
551
- - `bir logout` deletes the file first, then revokes at the service that issued the session, best effort, within 8 s.
552
- - Malformed sign-in answers are rejected instead of saved.
553
-
554
- **Housekeeping.**
555
-
556
- - Log fields are clipped at 2,000 characters.
557
- - Journals untouched for 30 days are deleted.
558
-
559
- ### 6.6 Calculated replay (`src/replay/*`)
560
-
561
- **No stale data.**
562
-
563
- - A recorded output no longer stands in for a step that produced no result. The plan stops at that step and hands over; `BIR_REPLAY_RECORDED_OUTPUTS=1` opts back in, but only for steps that could not be sent. `bir replay` keeps the stand-ins.
564
- - A plan that ran nothing live answers `ok:false`. Stand-ins never count as work in the ledger.
565
-
566
- **Honest hand-overs.**
567
-
568
- - Timeouts, abandoned calls and shutdowns are worded "may or may not have completed". A step whose output logic failed says "do not run it again".
569
- - A cancelled direct run's note is delivered on the session's next prompt or tool call.
570
- - An Esc-interrupted turn is never booked as a saving (`measured:false`).
571
-
572
- **Budgets.**
573
-
574
- - The hook derivation wait is 4 s. All replay work in one PreToolUse is capped at 9 s (maximum 10 s).
575
- - The direct plan budget is 100 s (was 120 s). Direct runs are clamped so that derivation, plan and slack stay within 270 s, under `run_scenario`'s 290 s client timeout.
576
- - The worst case by budget is 120 s: 8 s of derivation, 100 s of plan, and 12 s of slack.
577
-
578
- **Logic.**
579
-
580
- - Scenario logic has a synchronous time limit: `BIR_LOGIC_TIMEOUT_MS`, 1 s by default, 10 s maximum.
581
- - A body that returns a promise is refused.
582
-
583
- **Injection guard (`safety.ts`).** It covers plans with a shell, SQL, exec or file-write sink, detected from tool and server names in any naming convention.
584
-
585
- - Each derived value must be free of shell and SQL metacharacters, unless it equals its recorded sample.
586
- - It must also keep the sample's shape: numbers stay numbers, nothing starts with `-`, and no new whitespace, glob, brace or parenthesis appears.
587
- - File-write-only plans reject only line breaks and NUL.
588
- - Plans with a command sink do not feed recent tool results to derivation.
589
-
590
- **Steer mode** pins one call at a time. A call really running beside a pin hands the plan over, booked `fell_back` or `failed`. A failed pinned step (PostToolUseFailure, or a JSON-RPC error) hands over; nothing after it is approved.
591
-
592
- **Bash delivery.**
593
-
594
- - A hand-over longer than 6,000 characters goes through a 0600 file under `~/.baseinstrunner/tmp`, capped at 25,000 characters.
595
- - The recorder sweeps these files at start and daily.
596
-
597
- **Two sessions in one project.** A direct step for a session whose own proxy is not yet bound hands over instead of guessing.
598
-
599
- ### 6.7 CLI, install and config (`src/bin/bir.ts`, `setup.ts`, `src/config/*`, `scripts/install.*`)
600
-
601
- **Where entries go.**
602
-
603
- - **Local-scope overrides.** Wrapped servers are local-scope copies in `~/.claude.json` under Claude Code's project key: the canonical git root, with forward slashes on Windows. `.mcp.json` and the user scope are never rewritten. The `bir` scenario server is local too, with no `enabledMcpjsonServers` pre-approval. A subdirectory or linked worktree of a repository gets hooks only. `CLAUDE_CONFIG_DIR` is honoured.
604
- - **Approval (`mcp-approval.ts`).** Approval follows Claude Code's own rules, read from Claude Code 2.1.280:
605
- - a rejected `.mcp.json` server is never copied;
606
- - a pending one is left unwrapped with a message;
607
- - a managed policy that matches servers by command or URL means nothing is wrapped.
608
- - **Remote servers.** A remote server is wrapped only when it has a literal credential header, or when `--server` asks for it.
609
- - **Stale copies.** Copies record their source's hash. `bir doctor` reports a stale copy as a problem, and `bir status` marks it STALE.
610
-
611
- **Paths and flags.**
612
-
613
- - **Invocation.** A flag-less install from an installed package writes absolute-path entries and the self-starting SessionStart hook. `--npx` gives `npx --prefer-offline -y -p @basein/runner@<v>`.
614
- - **Node path.** The Node path is Homebrew's stable opt link, not the versioned Cellar path. nvm, fnm, Volta and asdf paths get a warning. `bir doctor` flags a missing node or script.
615
- - **Proxy flag.** `--use-env-proxy` is added only when NO_PROXY exempts 127.0.0.1. Processes started with it add `127.0.0.1,localhost,::1` to their own NO_PROXY.
616
-
617
- **Safe writes.**
618
-
619
- - Install refuses, with nothing changed, when any file it would edit is not plain JSON.
620
- - Every write is atomic, and idempotency is "regenerate and compare".
621
- - `installed.json` keeps no header or env values. Records with redacted stashes live under `v2|` keys, so a 0.2.12 binary cannot find and blank them.
622
-
623
- **Uninstall.**
624
-
625
- - `bir uninstall` touches only this project. It repairs `~/.claude.json` entry by entry, never restores a stale snapshot, and stops the project's recorder. If a session still uses the recorder, it leaves it running with a per-project off switch.
626
- - It exits 1 with "Not everything could be undone" if anything still runs through the runner.
627
- - `--keep-recorder` leaves the recorder alone. `bir uninstall --all` handles every project, every recorder and the user scope; with the install scripts, `BIR_UNINSTALL=1` does the same.
628
-
629
- **Commands.**
630
-
631
- - **New commands and flags:** `bir off`, `bir on` (with `--here`), `bir doctor --strict` and `--quick`, and `bir status` "Runner", "Elsewhere", "Service", "Port" and "Superseded" lines.
632
- - **`bir install` without `--replay`** stores replay off for the project unless the user said otherwise. `bir setup` keeps it on.
633
- - **`bir login` on Windows** opens the browser without cmd.exe, and only for https links.
634
- - **install.ps1** adds `--use-system-ca` only when the Node accepts it.
635
-
636
- ### 6.8 The `bir` MCP server (`src/bin/bir-scenario.ts`, `scenario-liveness.ts`, `service-read.ts`)
637
-
638
- **Tools listed.** `tools/list` returns only `run_scenario` by default. The read tools come with `bir scenario editing on` or `BIR_SCENARIO_TOOLS=read`, because every listed tool's definition rides along with every request of every session.
639
-
640
- **`run_scenario`.**
641
-
642
- - It never returns `isError`. With no plan it says "Nothing to run for this request; continue normally." Any other failure gives "The prepared results are not available for this request. Do the task normally with your tools." `BIR_SCENARIO_DEBUG=1` shows the raw reasons.
643
- - Its description says to call it only when a `[BaseInstRunner calculated replay]` note in this turn says so.
644
- - **Timeout and cancellation.** Its client timeout is 290 s (was 180 s). `notifications/cancelled` aborts the run, and the cancelled id gets no answer.
645
- - **Binding.** It forwards Claude Code's `params._meta['claudecode/toolUseId']`, so the recorder runs exactly that call's plan.
646
-
647
- **Liveness and revive.**
648
-
649
- - It sends `POST /scenario/hello` on initialize, on `tools/list`, on an off-switch flip, on each new recorder, and every 5 min. Each hello is bounded at 1 s and never awaited.
650
- - It revives a dead recorder at most once per 30 s, never in its first 12 s, and never after `bir down`.
651
-
652
- **Off switch.** While off, it lists no tools. A flip sends `notifications/tools/list_changed` within about 2 s. Read tools answer a neutral sentence; changing tools answer MethodNotFound.
653
-
654
- **Read tools.**
655
-
656
- - Read tools time out at `BIR_READ_TIMEOUT_MS` + 10 s, 25 s in total. Changing tools keep 120 s.
657
- - Read-tool output is capped at 24,000 characters by default, or 1.5 × `MAX_MCP_OUTPUT_TOKENS`.
658
- - bir's audit lines go to stderr, never to the model.
659
-
660
- ---
661
-
662
- ## 7. Behaviour changes and upgrade notes
663
-
664
- ### 7.1 Defaults that changed
665
-
666
- | What changed | Why | How to get the old behaviour |
667
- |---|---|---|
668
- | Wrapped MCP calls are no longer auto-approved. PreToolUse gives no decision, and steps join by argument fingerprint. `bypassPermissions` sessions still get the id with `allow`; plan mode never gets `allow`. | 0.2.12 answered `allow` and rewrote the arguments of every wrapped call, so users were silently not asked about tools they would otherwise be asked about. The fingerprint join is lossy only for identical concurrent calls. | `BIR_CORRELATION_DECISION=allow` in the recorder's environment, then `bir up --restart` (logged as `control.correlation_allow`). `ask` injects the id and keeps the prompt. |
669
- | Wrapped entries are local-scope overrides in `~/.claude.json`. `.mcp.json` and the user scope are untouched, and the scenario server is local, with no pre-approval. | Teams commit `.mcp.json`, and 0.2.12 wrote this machine's absolute paths and control URL into it. User-scope rewrites applied to every project. | `--mcp-json` or `BIR_WRAP_MCP_JSON=1`: the old in-place shape, never to be committed. The user scope is never rewritten in either mode. |
670
- | A `.mcp.json` server is copied only once Claude Code approved it. A rejected one is never copied; a pending one is left unwrapped with a message. A managed policy by command or URL means nothing is wrapped. | A local-scope server starts without Claude Code's approval question, so a copy of an unapproved server would start silently. | `--server <name>` wraps a pending (not rejected) server and remembers it. `--mcp-json` also works, because Claude Code's question still applies there. |
671
- | A subdirectory or linked worktree of a repository gets hooks only, with no wrapped copies. | Claude Code keys the local scope by the git root, so a copy would apply to the whole repository and its worktrees. | Run `bir setup` in the repository root, or use `--mcp-json`. |
672
- | Remote servers without a literal credential header (OAuth, headersHelper, `${VAR}`) are left unwrapped. Earlier unasked wraps are undone. | The proxy has none of Claude Code's OAuth credentials; wrapped, these servers answered 401 to every call. | `--server <name>` (remembered). |
673
- | A flag-less install from an installed package writes absolute-path entries and the self-starting SessionStart hook, for `bir wrap` too. | No npx registry round trip per server start (about 1 s), and it works offline. npx installs had nothing to start the recorder. | `--npx`. A checkout, or `bir` run through npx, still writes npx. |
674
- | Hook timeouts: SessionStart command 15 s (was 30), HTTP 5 (10), UserPromptSubmit 10 (15), PreToolUse 15 (30), PostToolUse and PostToolUseFailure 5 (30), Subagent 3 (5), Stop 5 (10), SessionEnd 3 (30). | The timeout is the user's worst case during an incident. The server's deadlines sit inside it. | Not configurable; `bir setup` rewrites them. |
675
- | Mid-turn intent arming is opt-in, and the intent probe is never waited on by default. | 0.2.12 held every PreToolUse up to 4 s for the probe. | `BIR_INTENT_ARM=1` (or `BIR_SEGMENT_ARM=1`) with `BIR_INTENT_ARM_BUDGET_MS=4000`. |
676
- | The prompt match budget is 1.5 s (was 2.5 s), and there is a match gate: over 800 ms, prompts stop waiting for 5 min, doubling to 30 min. | A healthy service answers in 300-450 ms. A slower one would otherwise cost every prompt. A false trip costs one hold of replay, never latency. | `BIR_MATCH_BUDGET_MS=2500`. Raising `BIR_MATCH_SLOW_MS` above the budget effectively disables the gate. |
677
- | Recorded outputs no longer stand in for a step that produced no result; the plan hands over at that step. | Last week's output presented as this turn's result is wrong data. | `BIR_REPLAY_RECORDED_OUTPUTS=1`, only for steps that could not be sent. `bir replay` keeps the stand-ins. |
678
- | The direct plan budget is 100 s (was 120 s), and direct runs are clamped to 270 s. | Keeps a direct run under Claude Code's 2-minute auto-background of an MCP call, and inside `run_scenario`'s client timeout. | `BIR_REPLAY_PLAN_BUDGET_MS=120000` (or `BIR_REPLAY_BUDGET_MS`). |
679
- | The background recorder ignores an inherited `ANTHROPIC_API_KEY` for derivation. | That key is Claude Code's, so derivation billed the user's own account. | `BIR_DERIVE_API_KEY=<key>`, or `BIR_USE_ANTHROPIC_API_KEY=1`. A `bir-hooks` run by hand still uses the terminal's key. |
680
- | `run_scenario` never returns `isError`. | An error result makes the model retry or report a failure the user cannot act on. The reason is in `scenario.answered`. | `BIR_SCENARIO_DEBUG=1`. |
681
- | The `bir` server lists only `run_scenario`. | Tool definitions ride along with every request. | `BIR_SCENARIO_TOOLS=read`, or `bir scenario editing on`. |
682
- | The `run_scenario` client timeout is 290 s (was 180 s). | It stays under Claude Code's 5-minute MCP idle timeout, above the direct-run worst case. | `BIR_SCENARIO_RUN_TIMEOUT_MS=180000`. |
683
- | `bir-proxy` exits when its upstream dies. | Claude Code then shows the server as failed and offers a reconnect, as unwrapped. Staying up left tools listed that only answered errors. | `BIR_PROXY_STAY_UP=1`. |
684
- | Proxy stderr is quiet: routine lines only with verbose logging. | Claude Code files every stderr line as a "Server stderr" error. | `BIR_VERBOSE=1`. |
685
- | `bir doctor` exits 1 only for what breaks a session; warnings exit 0. | A doctor that failed working machines was noise and broke scripts that gate on it. | `bir doctor --strict`. |
686
- | `bir uninstall` stops this project's recorder, touches only this project, and exits 1 if anything still runs through the runner. | An orphaned recorder broke reinstalls, and "Uninstalled." was printed when it was not true. | `--keep-recorder`; `--all` for every project. |
687
- | `bir install` without `--replay` stores replay off for the project (`bir setup` keeps it on). | A project without the delivery tool should not steer or run direct replays. | `bir replay on`, or `bir install --replay`. Replay switches take effect on `bir up --restart`. |
688
- | Redaction matches key-name segments, with many new value shapes; numbers under session-id, plural and counter keys are kept. | The substring rule redacted ordinary arguments and missed many secret shapes. | `BIR_REDACT_LEGACY_KEYS=1` adds the old rule on top. |
689
- | Direct plans are announced only while a live `bir-scenario` has said hello (`/scenario/hello`) within 30 min. | A plan announced through a tool the model does not have diverged and ran unattended. | None. For about 5 min after a recorder restart, direct plans are declined until the hello arrives. |
690
- | `BIR_OFF=1` in Claude Code's environment switches off only that session. | The recorder serves every window of the project. | `bir off --here`, or `bir off`. |
691
- | `bir down` stays down until the next session start, `bir up` or `bir setup`. | A deliberate stop must not be undone by the mid-session revive. | `bir up`. |
692
- | Recorder retries happen once, only for 429, 503 and connections that never reached the service. A flush waits at most 2 s. | Without service-side deduplication, a retry after a 502, 504 or reset could record a step twice. 0.2.12 never retried, and waited unbounded. | `BIR_FLUSH_TIMEOUT_MS`. Retries are not configurable. |
693
- | Remote upstreams honour HTTP(S)_PROXY and NO_PROXY. | Corporate networks. | `BIR_UPSTREAM_PROXY_ENV=0`. |
694
- | Answer caps are 128 MiB, both stdio frames and remote answers (stdio was 32 MiB). | A 33 MB result used to hang the call. | `BIR_UPSTREAM_MAX_MESSAGE_BYTES` for remote answers. |
695
- | Remote credentials travel in the entry's env (`BIR_UPSTREAM_HEADERS`), not in `--header` arguments. | Command lines are visible in process listings. | Old `--header` entries still work until the next `bir setup` rewrites them. |
696
- | Hook routes answer `200 {}` to stale tokens, unknown routes, non-POST requests, oversize bodies and malformed bodies (was 401, 404 or a reset). | Each of these was a hook error on every prompt and tool call until the recorder restarted. | None. |
697
-
698
- ### 7.2 Upgrading from 0.2.12
699
-
700
- **What `bir setup` migrates.** Re-running the install line, or `bir setup` in each project, does the following:
701
-
702
- - **`.mcp.json`.** Servers wrapped in place in `.mcp.json` are put back, restoring the team's original bytes. They are then copied to the local scope, if Claude Code approved them.
703
- - **User scope.** Servers wrapped in the user scope are put back, then copied to this project's local scope.
704
- - **Remote servers.** Remote servers wrapped without being asked are un-wrapped. `--header` credentials move into `BIR_UPSTREAM_HEADERS`.
705
- - **Scenario server.** The scenario server moves from `.mcp.json` to the local scope.
706
- - **Hooks.** Hook timeouts are rewritten. Every install, npx included, gets the self-starting exec-form SessionStart hook.
707
- - **Invocation.** An npx invocation nobody chose becomes the absolute-path global one.
708
- - **`installed.json`.** Any `bir` command moves redacted-stash records to `v2|` keys. A verbatim `.mcp.json` backup is converted to a redacted one the next time the file is touched.
709
- - **One-time note.** Install prints a one-time note: wrapped MCP calls are no longer auto-approved.
710
-
711
- **`.mcp.json` servers pending approval.** A server Claude Code has not asked about yet is left unwrapped. The hooks still record its calls. To wrap it:
712
-
713
- 1. Open Claude Code and approve the server.
714
- 2. Run `bir setup` again.
715
-
716
- **A running 0.2.12 recorder.**
717
-
718
- - It keeps 0.2.12 behaviour until it is replaced: it auto-approves wrapped calls, waits on the service in hooks, and ignores `bir off`.
719
- - The next SessionStart replaces it when no session is mid-run.
720
- - `bir setup`, `bir up` and `bir off` also replace a 0.2.12 recorder that claims an open turn, when two probes 2.5 s apart show it idle.
721
- - Otherwise it keeps running until `bir up --restart`. `bir doctor` warns about a recorder of another version, and `bir off` and `bir uninstall` name it.
722
- - The new recorder takes the discovery file back from an older one within a minute.
723
- - 0.2.12 proxies still running are served by the new recorder, and their reports join by fingerprint.
724
-
725
- **Headless fleets (`claude -p`).** Wrapped MCP calls used to be auto-approved by the runner.
726
-
727
- - In an untrusted workspace, where `permissions.allow` is ignored, those calls now fall to Claude Code's permission system and are denied.
728
- - Remedies, in the order to recommend them:
729
- 1. `permissions.allow` in a trusted workspace, or `--allowedTools`.
730
- 2. `bypassPermissions`, where the runner still injects ids with `allow`.
731
- 3. `BIR_CORRELATION_DECISION=allow` where the recorder starts.
732
- - Fleets that start from fresh clones also need their `.mcp.json` servers approved (`enableAllProjectMcpServers` in user or managed settings), or `--server <name>`. Otherwise those servers are not wrapped.
733
-
734
- **Downgrade.** Run `bir uninstall --all` with the *new* version first, then install the old one. The new version keeps redacted records under keys 0.2.12 does not read. A 0.2.12 uninstall would therefore find nothing and leave the wrapped entries, with their credentials, in place. The install script says so when `BIR_VERSION` is set.
735
-
736
- ---
737
-
738
- ## 8. Operating the runner
739
-
740
- ### 8.1 The kill switch
741
-
742
- | Command | Scope | Stored as |
743
- |---|---|---|
744
- | `bir off` | every project on this machine | `~/.baseinstrunner/off` |
745
- | `bir off --here` | this project; from a subdirectory, the installed project that contains it | `~/.baseinstrunner/off.d/<key>` |
746
- | `bir on` | removes the machine-wide switch *and* every project switch | none |
747
- | `bir on --here` | this project only | none |
748
- | `BIR_OFF=1` in the environment Claude Code starts in | that session only (see below) | relayed as `bir_off` at SessionStart |
749
- | `BIR_OFF=0` in a shell | processes started from it ignore the flag files (debugging); the background recorder never inherits `BIR_OFF` | none |
750
-
751
- **What each part does while off.**
752
-
753
- - **Hooks.** Every hook route answers `{}` before reading its body, with no transcript read and no service call. SessionStart still starts the recorder, so later hooks get `{}` instead of a refused connection.
754
- - **Recorder.** It drops proxy step reports (`{ok:true, off:true}`) and makes no service calls, including token refreshes.
755
- - **Wrapped servers.** Running proxies stop recording and stop declaring the call-id key within about 1 s. A stdio proxy started while off spawns the real server with inherited stdio, a pure pipe with the same exit code. A remote proxy started while off relays without recording and still strips the call-id key.
756
- - **`bir-scenario`.** It lists no tools and sends `tools/list_changed` within about 2 s. Read tools answer "switched off here"; changing tools answer MethodNotFound.
757
- - **Nothing is uninstalled.** After `bir on`, hooks and running proxies record again within about a second. Proxies that were started while off stay pure pipes until their next start.
758
-
759
- **How fast.**
760
-
761
- - The switch is read with a 1 s cache, so hooks take effect within about a second, in open sessions too.
762
- - A stdio proxy becomes a pure pipe only from its next start: a new session, or `/mcp` then reconnect. Until then it relays and records nothing.
763
- - Running wrapped servers stop recording within about a second, and `bir-scenario` withdraws its tools within a few seconds; `bir off` says both.
764
- - **Per-session `BIR_OFF=1`.** The SessionStart hook relays `bir_off:true`, and the recorder answers that session `{}` for up to 7 days of silence, or until 10 min after its SessionEnd. It is held in the recorder's memory, so a recorder restart loses it until that session's next SessionStart. Proxies and `bir-scenario` started in that environment are off too.
765
- - **0.2.12 recorders ignore the switch.** `bir off` replaces an idle one and names a busy one ("keeps recording and approving until `bir up --restart`").
766
- - **Uninstall and the switch.** `bir uninstall` uses a per-project switch marked "set-by-uninstall" when a session still uses the recorder. `bir install` and `bir down` remove only that kind.
767
-
768
- **`bir off --here` versus `bir down`.**
769
-
770
- - `bir down` stops the recorder but leaves the hooks. A session open in that directory then shows a "hook error" on every prompt and tool call until it ends, although the session keeps working.
771
- - The quiet way to take the runner out of a session is `bir up`, then `bir off --here`.
772
-
773
- ### 8.2 `bir doctor` and `bir status`
774
-
775
- **`bir doctor`** asks the project's recorder (`/health`, 3 s timeout) and reads the local files. It prints three kinds of findings:
776
-
777
- - **Problems** mean a Claude Code session here is broken, and doctor exits 1:
778
- - a config Claude Code cannot read;
779
- - a recorder that does not answer `/health`;
780
- - hooks installed with nothing to start the recorder;
781
- - a stale or orphaned local copy (`.mcp.json` or user-scope source changed or removed);
782
- - a SessionStart hook or wrapped entry whose Node or script is gone;
783
- - a remote server wrapped without credentials that nobody asked for;
784
- - hooks posting to a port the recorder is not on;
785
- - a port the recorder could not take (with its holder and fix).
786
- - **Warnings** mean sessions work but the runner is not doing all its job. They exit 0, or 1 with `--strict`:
787
- - a proxy not registered while sessions run;
788
- - a shadowed entry;
789
- - a managed-policy conflict;
790
- - an unapproved local copy;
791
- - a linked worktree defining a server differently;
792
- - "the recorder has nowhere to send steps";
793
- - the service not answering the recorder;
794
- - a recorder older than the off switch, or of another version;
795
- - the shell's `BIR_AUTH_URL` not being a BaseIn service;
796
- - a `--server`-wrapped remote;
797
- - user-scope wrapping;
798
- - an install into another `CLAUDE_CONFIG_DIR`;
799
- - hooks on several ports;
800
- - `installed.json` naming another port than the hooks;
801
- - hook calls carrying a token the recorder does not know (`/health.hooks.unauthorized`: the hooks were reinstalled while it kept running, and those calls were answered `{}` and not recorded — `bir up --restart`).
802
- - **Notes** are facts:
803
- - hook calls a deadline guard answered early (`/health.hooks.overran`);
804
- - the runner is OFF;
805
- - the recorder is not running yet (the next session starts it);
806
- - where recording goes, and as whom;
807
- - when the service last answered;
808
- - who approves wrapped MCP calls (the recorder's correlation mode);
809
- - whether sub-tasks are observe-only;
810
- - superseded recorders;
811
- - an npx-started scenario server;
812
- - other installed projects.
813
-
814
- `--quick` makes no call to the service; the local recorder is still asked. `--json` prints `{ok, problems, warnings, notes, health, wrapped, off, service, otherProjects, shellAuthUrl, shellAuthProblem, portProblem}`. The full `/health` answer is under `health`, including every hook counter (the text output reports only the unauthorized and overran ones).
815
-
816
- **`bir status`** gives the recorder's view, with a 1.5 s timeout:
817
-
818
- - `Directory`, `BaseIn`, `Control` (URL and pid, background or not, answering or not), `Port` (a port problem), `Superseded` (one line per superseded recorder), `Log`.
819
- - `Service` (answering the recorder, or not, with the last error).
820
- - `Runner` (on, or OFF with its source; also when the recorder reports itself OFF or predates the switch).
821
- - `Elsewhere` (other installed projects).
822
- - `Servers`, each `[wrapped]` or `[ raw ]`, with STALE when a local copy no longer matches its source.
823
-
824
- `--json` prints:
825
-
826
- - `cwd`, `authUrl`;
827
- - `controlServer {url, pid, answering}`;
828
- - `discoveryFile`, `portProblem`, `stateDir`;
829
- - `off {source, detail}`;
830
- - `recorder {service, off, version}`;
831
- - `superseded[]`, `otherProjects`;
832
- - `servers[] {name, scope, shadowed, wrapped, upstream, stale}`.
833
-
834
- ### 8.3 The control server's `/health`
835
-
836
- `GET /health` on the project's recorder needs the bearer token from `~/.baseinstrunner/control/<key>.json`. The easiest way to read it is `bir doctor --json`, under `health`.
837
-
838
- | Field | Meaning |
839
- |---|---|
840
- | `recording`, `account`, `authUrl` | whether anything is being saved, as whom, and where |
841
- | `service` | `{healthy, consecutiveFailures, openUntil, lastError, lastErrorAt, lastOkAt}`. `healthy:false` means the breaker is open and hooks are not waiting on the service. |
842
- | `off` | `"global"`, `"project"` or `"env"` when switched off; `null` when on (always present) |
843
- | `offSessions` | sessions started with `BIR_OFF=1` that are being answered `{}` |
844
- | `hooks` | `{unauthorized, unknownRoute, oversize, overran, off}`: hook requests answered `{}` without processing, by reason. `unauthorized > 0` means hooks from another install. `overran > 0` means a deadline guard answered. |
845
- | `sending` | the recorder backlog `{state, pendingSends, pendingBytes, droppedSends, failedSends}`, or `null`. `state` is `recording`, `signed_out`, `auth_rejected` or `other_service`. |
846
- | `lossy`, `queued` | steps were dropped (queue overflow or dropped sends); the local queue size |
847
- | `matchGate` | `{holding, holdMs, until}`: whether prompts have stopped waiting on the match because the service is slow |
848
- | `correlation` | `auto`, `allow` or `ask`, or `fingerprint` when correlation is off: who approves wrapped calls |
849
- | `deliveryTool`, `scenarioServers[]` | whether a live `bir-scenario` has said hello (`{pid, tools, off, version, ageMs}`). An empty list means no direct plan is announced. |
850
- | `replay` | `{enabled, source, minSimilarity, allowServers, deriveKey, deriveVia, pollingProxies}`. `deriveVia` is `service`, `key` or `samples`; `samples` means no scenario with a target can run. |
851
- | `registeredProxies[]`, `wrappedServers` | proxies that registered (`serverName`, `pid`, `cwd`, `version`) and the wrapped server names |
852
- | `hooksVersion`, `pid`, `cwd`, `url` | the recorder's version and identity |
853
- | `segmentArm` | whether service-found sub-tasks may run mid-task (default: observe only) |
854
- | `sessionCount`, `sessions[]` | at most 50, active ones first: `{sessionId, runId, active, idleMs, ended, steps, recording, replay, intent, fragment}`. `replay` includes `armed`, `unannounced`, `running`, `outcome` and `handover`. |
855
-
856
- ### 8.4 Logs and audit lines
857
-
858
- | Log | Where | Rotation |
859
- |---|---|---|
860
- | Background recorder | `~/.baseinstrunner/logs/<key>.log`. `<key>` is the first 16 hex digits of the sha256 of the normalised project path, and the path is printed by `bir status` and `bir up`. | Rolled to `.1` at 8 MB (one generation), checked every minute. Reopened after write errors or removal. |
861
- | Superseded recorder | `~/.baseinstrunner/logs/<key>.superseded-<port>.log` | same |
862
- | Turn journal (read by `bir investigate`) | `~/.baseinstrunner/control/journal/<key>.jsonl`: `run.*`, `replay.*`, `plan.*` and `execution.*` events | 4 MB, halving; journals untouched for 30 days are deleted |
863
- | `bir-proxy`, `bir-scenario` | stderr, which Claude Code files under that MCP server's log | Claude Code's |
864
- | `bir-hooks` run in a terminal | stderr | none |
865
-
866
- Lines read `[bir] <ISO time> <event> key=value …`, with each field clipped at 2,000 characters. `BIR_VERBOSE=1` adds detail lines. Key lines by topic:
867
-
868
- | Topic | Lines and what they mean |
869
- |---|---|
870
- | Service health | `service.degraded` (first failure: reason, cool-down), `service.recovered`. `replay.decision code=service_unhealthy` (prompt did not wait: breaker open), `code=match_timeout` (prompt waited its budget), `code=match_late` (the answer landed after the prompt), `code=match_slow` (verbose: gate holding). `replay.match_gate state=holding\|open`. |
871
- | Recording backlog | `recorder.backlog_full` / `recorder.backlog_recovered` (oldest steps dropped). `recorder.run_incomplete` (a run lost steps). `recorder.run_abandoned` (`reason=service_down`: a create given up after a minute of cool-down). `recorder.flush_deferred`, `recorder.flush_incomplete`, `recorder.send_failed` (once per run), `recorder.matched_after_retry`, `run.lossy`. |
872
- | Sign-in | `auth.ok`, `auth.settling` (plain-http address being checked for https), `auth.refresh_deferred`, `auth.refresh_refused`, `auth.recovered`. `recorder.enabled`, `recorder.signed_out`, `recorder.disabled`, `recorder.paused` / `recorder.resumed`, `recorder.account_changed`, `recorder.report_dropped`, `derive.api_key_ignored`. |
873
- | Hooks | `control.listening` (port, `recorder=<state>`, `correlation=`). `control.correlation_allow` (auto-approval opted in). `control.hook_deadline`, `control.hook_unauthorized` (a token from another install), `control.hook_unknown`, `control.hook_oversize`, `control.hook_event_mismatch`, `control.route_failed`, `control.drain_abandoned`. |
874
- | Recorder lifecycle | `daemon.restart` (replaced an older recorder), `daemon.restart_deferred` (mid-run: next start), `daemon.start_alongside`, `daemon.newer_kept`, `daemon.revived`. `control.port_busy` (holder and fix), `control.already_running`, `control.superseded`, `control.discovery_reclaimed`, `control.log_reopened`, `control.fatal`. `overrides.stale` (a local copy no longer matches its source: run `bir setup`). |
875
- | Proxy (MCP log) | `proxy.upstream_gone` (exiting with the server), `proxy.recorder_revive`, `tier.downgrade`, `tier.rebound`, `relay.undeliverable`. `upstream.auth_required` (the remote refused the handshake: credentials), `upstream.needs_sign_in`, `upstream.headers_invalid`, `upstream.proxy_env_ignored` (this Node cannot use HTTPS_PROXY), `upstream.proxy_env_invalid`, `upstream.standing_stream_failed` (at most hourly). Verbose only: `proxy.started`, `tier.bound`, `tier.passthrough`, `run.start` / `run.finish`, `upstream.stream_*`. |
876
- | Replay | `replay.enabled` (at start: servers, threshold, who derives). `plan.armed`. `replay.decision` (also `arm_timeout`, `arm_unannounced`, `no_directive`, `no_delivery_tool`, `missing_target`). `replay.pin`, `replay.pin_declined`, `replay.pin_abandoned`, `replay.pin_overran`, `replay.diverge`, `replay.diverge_overran`, `replay.handover`, `replay.vetoed`, `replay.unsafe_param`, `replay.derive_timeout`, `replay.budget_clamped`, `replay.concurrent_call`, `replay.no_session_proxy`, `replay.run_ambiguous`, `replay.run_abandoned`. |
877
- | `bir-scenario` (MCP log) | `scenario.answered` (`ok` means results were delivered; also `partial`, `bound`), `scenario.recorder_revive`, `scenario.switched`, `scenario.hello_failed`, `scenario.tool_timeout`, `scenario.tool_truncated`. |
878
-
879
- ### 8.5 Incident playbooks
880
-
881
- **The service is slow or down.**
882
-
883
- 1. Run `bir status` in an affected project. Check the `Service :` line, and in `bir doctor --json`, `health.service` and `health.matchGate`.
884
- 2. Confirm the user impact. Each recorder waits at most once per incident: up to 1.5 s on one prompt. After that, prompts and tool calls do not wait. `health.hooks.overran` should stay at or near 0.
885
- 3. Nothing is needed on the client. Recording resumes by itself when the service answers. Steps held longer than a minute of cool-down, or beyond 500 sends or 32 MiB, are dropped and logged (`recorder.run_incomplete`, `recorder.run_abandoned`).
886
- 4. If a user wants no per-prompt wait at all, even with a healthy service: `bir replay off` in the project, then `bir up --restart`. Or set `BIR_REPLAY=0` where Claude Code starts; it takes effect when the recorder next starts.
887
- 5. On the service side, check `/health` and the create-and-match latency. Above 800 ms, every recorder stops steering for 5-30 minutes.
888
-
889
- **Hook errors in a session.**
890
-
891
- 1. Run `bir doctor` in the project. The problems section names the cause:
892
- - a recorder that does not answer;
893
- - hooks posting to the wrong port;
894
- - a port held by another program;
895
- - a Node or script path that is gone.
896
- 2. **Recorder not running:** `bir up`. The next hook succeeds. If a `bir down` was intended, `bir up` then `bir off --here` silences the hooks instead.
897
- 3. **Port problem:** `bir setup --port <n>`, ideally below 49152, then start a new session. Running sessions keep their old hook URL.
898
- 4. **Missing Node or script** (after a Node upgrade or a version-manager switch): `bir setup` in the project.
899
- 5. **A hook error whose text includes runner JSON** is a regression of the event-name fix. Collect `~/.baseinstrunner/logs/<key>.log` and the Claude Code version.
900
-
901
- **The recorder died mid-session.**
902
-
903
- 1. It normally comes back within seconds. The session's wrapped proxies, or `bir-scenario` after its first 12 s, see a dead pid and a refused port, and launch `bir-hooks revive`. Look for `proxy.recorder_revive` or `scenario.recorder_revive`, then `daemon.revived`.
904
- 2. If nothing revives it (no wrapped server, or `bir down` was used), use `/clear`, a new session, or `bir up`.
905
- 3. Find out why it died: look for `control.fatal`, or an abrupt end, in the recorder log, and check antivirus or OOM kills.
906
-
907
- **A replay did something wrong.**
908
-
909
- 1. **Stop it now:** `bir off --here`, which takes effect in about a second.
910
- 2. **Find out what happened:**
911
- - `bir investigate` (the newest turn here), or `bir investigate <run_…|scn_…|sexec_…>`;
912
- - the journal and the log lines `replay.decision`, `replay.pin`, `replay.pin_declined`, `replay.diverge`, `replay.handover`, `replay.unsafe_param`.
913
- 3. **Keep it off, or narrow it:**
914
- - `bir replay off`, then `bir up --restart`;
915
- - or limit unattended servers with `bir replay allow a,b`.
916
- 4. **Fix the scenario:** `bir scenario show <id>`, `bir scenario check|edit <id> --step <n> …`, `bir scenario undo <id>`.
917
- 5. **Include in the report:** the scenario id, the permission mode, whether it was steer or direct, and the `replay.*` lines.
918
-
919
- **An MCP server broke after install.**
920
-
921
- 1. Run `bir doctor` for problems: a missing Node or script, a stale local copy, a remote wrapped without credentials, a managed-policy conflict.
922
- 2. Rule the runner in or out: `bir off --here`, then `/mcp` and reconnect the server. It now runs as a plain pipe.
923
- 3. Take one server out: `bir uninstall --server <name>`. Take the whole project out: `bir uninstall`.
924
- 4. After `.mcp.json` changed, or a user-scope token was rotated: `bir setup`, which re-copies stale local copies.
925
- 5. Read the server's MCP log for `upstream.auth_required`, `upstream.headers_invalid` and `proxy.upstream_gone`.
926
-
927
- **After an upgrade.**
928
-
929
- 1. Re-run the install line, or `bir setup`, in each project.
930
- 2. Run `bir doctor`. A "recorder running here is 0.2.12" warning means running `bir up --restart` once no turn is mid-run.
931
- 3. Headless `claude -p` fleets: allow the wrapped tools (`permissions.allow`, `--allowedTools`), or set `BIR_CORRELATION_DECISION=allow` where the recorder starts (§7.2).
932
- 4. Projects whose `.mcp.json` servers were not yet approved: approve them in Claude Code, then run `bir setup` again.
933
- 5. Check `bir status` for STALE servers and `Superseded:` recorders. `bir down` stops superseded ones.
934
-
935
- **Behind a corporate proxy.**
936
-
937
- 1. Before the install line or `bir setup`, set `HTTPS_PROXY` and `NO_PROXY=127.0.0.1,localhost,::1,<your entries>`. `localhost` alone is not enough.
938
- 2. For a TLS-inspecting proxy, set `NODE_EXTRA_CA_CERTS` to the company CA bundle. install.ps1 also adds `--use-system-ca` when the Node supports it.
939
- 3. Remote MCP servers go through the proxy only on Node 24.5+ or 22.21+. The installer's private Node is 24.21.0. Otherwise `upstream.proxy_env_ignored` names the cause.
940
- 4. `bir doctor` and the install output say when `--use-env-proxy` was not carried.
941
-
942
- **Removing the runner.**
943
-
944
- 1. On the whole machine: run `bir uninstall --all`, or the install line with `BIR_UNINSTALL=1`, *before* deleting `~/.baseinstrunner` or running `npm rm -g @basein/runner`. Otherwise every other project's entries and hooks point at files that are gone.
945
- 2. If it exits 1 with "Not everything could be undone", fix what it lists and run it again. Do not remove the package yet.
946
- 3. One project only: `bir uninstall`. If a session still uses the recorder, it is left running and switched off for the project: end the session, then run `bir down`. `--keep-recorder` skips stopping it.
947
- 4. Going back to 0.2.12: `bir uninstall --all` with the current version first (§7.2).
948
-
949
- ---
950
-
951
- ## 9. Configuration reference
952
-
953
- **Read by.**
954
-
955
- - **recorder:** `bir-hooks`. When the SessionStart hook starts it, it inherits the environment Claude Code started in, minus `BIR_OFF`, the setup token and `ANTHROPIC_API_KEY`. With `bir up`, it inherits that shell's environment.
956
- - **proxy:** `bir-proxy`, from the MCP entry's `env` plus Claude Code's environment.
957
- - **scenario:** `bir-scenario`.
958
- - **CLI:** `bir`.
959
-
960
- Defaults were checked in `src/`.
961
-
962
- | Variable | Read by | Default | Meaning |
963
- |---|---|---|---|
964
- | `BIR_HOME` | all | `~/.baseinstrunner` | State directory: credentials, `installed.json`, logs, control files. |
965
- | `BIR_AUTH_URL` | all | the address `bir setup` or `bir login` stored in `config.json` | BaseIn API address (`https://api.bi2202.com`). |
966
- | `BIR_OFF` | all | unset | `1` or `true`: off. For the recorder, only for the session whose SessionStart relayed it; the background recorder never inherits the variable. `0` or `false`: on regardless of the flag files. |
967
- | `BIR_VERBOSE` | all | unset | `1` adds routine detail lines. |
968
- | `BIR_CONTROL_PORT` | recorder | the project's port from `installed.json`, else 53411 | Listening port. `0` means any free port (tests and smoke only, because hooks name a fixed port). Invalid values are ignored. |
969
- | `BIR_CORRELATION_DECISION` | recorder | unset, meaning `auto` | `auto`: no decision for ordinary wrapped calls; fingerprint join. `allow`: 0.2.12 auto-approval. `ask`: inject the id and keep the prompt. Trimmed, any case. |
970
- | `BIR_NO_CORRELATION` | recorder | unset | `1`: never inject call ids. |
971
- | `BIR_SERVICE_TIMEOUT_MS` | recorder, proxy, CLI | 10000 | Deadline for one service request off the hot path: sends, refresh, device-login polls, `bir scenario`, `bir investigate`. |
972
- | `BIR_FLUSH_TIMEOUT_MS` | recorder, proxy | 2000 | Longest flush at turn end, session end or shutdown when no budget is given. |
973
- | `BIR_RECORDER_MAX_PENDING` | recorder, proxy | 500 | Unsent sends kept; the oldest steps are dropped first. |
974
- | `BIR_RECORDER_MAX_PENDING_BYTES` | recorder, proxy | 33554432 (32 MiB) | The same bound, in bytes. |
975
- | `BIR_REDACT_LEGACY_KEYS` | recorder, proxy | unset | `1` adds 0.2.12's substring key rule on top. |
976
- | `BIR_SUPERSEDED_EXIT_MS` | recorder | 86400000 (24 h) | Idle time before a superseded recorder exits. |
977
- | `BIR_WINDOWS_LAUNCH` | SessionStart hook, `bir up` | unset (PowerShell Start-Process) | `spawn`: a direct hidden spawn, about 1 s faster cold. Unsafe when something above Claude Code captures its output. |
978
- | `BIR_DERIVE_API_KEY` | recorder | unset | An Anthropic key for derivation on this machine. Otherwise the signed-in service derives. |
979
- | `BIR_USE_ANTHROPIC_API_KEY` | recorder | unset | `1`: the background recorder may use an inherited `ANTHROPIC_API_KEY`. |
980
- | `ANTHROPIC_API_KEY` | recorder | none | Used only by a `bir-hooks` run by hand, or with the previous variable; otherwise removed (`derive.api_key_ignored`). |
981
- | `BIR_DERIVE_MODEL` | recorder | `claude-haiku-4-5-20251001` | Model for derivation on this machine. |
982
- | `BIR_REPLAY` | recorder | on, or the project's stored switch | `0` turns replay off. Any other value forces it on over the stored switch. |
983
- | `BIR_REPLAY_ALLOW_SERVERS` | recorder | all wrapped servers | Servers that direct runs and divergence recovery may call unattended (comma-separated). |
984
- | `BIR_MIN_STEER_SIMILARITY` | recorder | 0.92 | Minimum match similarity to replay. |
985
- | `BIR_MATCH_BUDGET_MS` | recorder | 1500 | Longest a prompt waits for the match. |
986
- | `BIR_MATCH_SLOW_MS` | recorder | 800 | A match slower than this, or one that timed out, stops prompts waiting for 5 min, doubling to 30 min. |
987
- | `BIR_DERIVE_BUDGET_MS` | recorder | 8000 | Derivation wait for `/scenario/run`. Hooks use the smaller of this and `BIR_HOOK_DERIVE_BUDGET_MS`. |
988
- | `BIR_HOOK_DERIVE_BUDGET_MS` | recorder | 4000 | Derivation wait inside a hook: prompt arming, and the first PreToolUse. |
989
- | `BIR_PRETOOL_REPLAY_BUDGET_MS` | recorder | 9000 (capped at 10000) | All replay work inside one PreToolUse. |
990
- | `BIR_REPLAY_PLAN_BUDGET_MS` | recorder | 100000 | Whole-plan budget for a direct run. |
991
- | `BIR_REPLAY_BUDGET_MS` | recorder | unset | Older name; overrides the plan budget when set. Direct runs are clamped to 270 s in total (`replay.budget_clamped`); `bir replay` is not clamped. |
992
- | `BIR_STEP_TIMEOUT_MS` | recorder | 60000 | One direct `tools/call`. |
993
- | `BIR_LOGIC_TIMEOUT_MS` | recorder | 1000 (capped at 10000) | Synchronous time limit for one scenario logic body. |
994
- | `BIR_REPLAY_RECORDED_OUTPUTS` | recorder | unset | `1`: a step that could not be sent may use its recorded output, labelled as such. |
995
- | `BIR_DERIVE_RECENT_RESULTS` | recorder | 5 | How many of this prompt's tool results derivation may read (`0`: none). Never for plans with a command sink. |
996
- | `BIR_INTENT_MATCH` | recorder | on | `0` turns mid-turn intent probes off. |
997
- | `BIR_INTENT_MATCH_BUDGET_MS` | recorder | 4000 | One probe's own ceiling. It is not a hook wait. |
998
- | `BIR_INTENT_MATCHES_PER_TURN` | recorder | 3 | Plans intent may arm per turn. |
999
- | `BIR_INTENT_REQUESTS_PER_TURN` | recorder | 40 | Probes per turn. |
1000
- | `BIR_INTENT_SOURCES` | recorder | `text,thinking,tool` | Which text an intent may be read from. |
1001
- | `BIR_INTENT_ARM` | recorder | unset (follows `BIR_SEGMENT_ARM`) | `1`: a probe may arm a plan in its own PreToolUse, and that hook waits for it. `0`: never. |
1002
- | `BIR_INTENT_ARM_BUDGET_MS` | recorder | 500 | How long that PreToolUse waits for the probe. |
1003
- | `BIR_SEGMENT_ARM` | recorder | unset (observe only) | `1`: recurring sub-tasks the service found may run mid-task. |
1004
- | `BIR_MIN_SEGMENT_STEER_SIMILARITY` | recorder | 0.95 | Minimum similarity to steer a segment. |
1005
- | `BIR_CONTROL_URL` | proxy, scenario | written into wrapped entries | The project's control server. Together with `BIR_CONTROL_TOKEN` it is the SDK channel and wins over discovery. |
1006
- | `BIR_CONTROL_TOKEN` | proxy, scenario | unset | Bearer token for the SDK channel. |
1007
- | `BIR_PROXY_STAY_UP` | proxy | unset | `1`: keep answering errors after the upstream dies (0.2.12). |
1008
- | `BIR_UPSTREAM_HEADERS` | proxy | written into wrapped remote entries | A JSON object of header name to value for a remote upstream. `--header` wins on a clash. Removed from the environment once read. |
1009
- | `BIR_UPSTREAM_CONNECT_TIMEOUT_MS` | proxy | 10000 | DNS, TCP and TLS (and proxy tunnel) for a remote upstream. The legacy SSE connect has its own fixed 15 s. |
1010
- | `BIR_UPSTREAM_MAX_MESSAGE_BYTES` | proxy | 134217728 (128 MiB) | Largest remote answer relayed. |
1011
- | `BIR_UPSTREAM_PROXY_ENV` | proxy | on | `0`, `false`, `off` or `no`: remote upstreams ignore HTTP(S)_PROXY. |
1012
- | `BIR_HOST_APP`, `BIR_HOST_VERSION` | proxy | `unknown` | Host metadata on standalone (Tier 2) runs. |
1013
- | `BIR_SCENARIO_DEBUG` | scenario | unset | `1`: raw failure reasons, flagged `isError`. |
1014
- | `BIR_SCENARIO_TOOLS` | scenario | unset | `read` (or `1`, `on`, `true`): list the read tools without editing on. |
1015
- | `BIR_SCENARIO_RUN_TIMEOUT_MS` | scenario | 290000 (valid 1-600000) | `run_scenario` request ceiling. |
1016
- | `BIR_READ_TIMEOUT_MS` | scenario, CLI | 15000 (valid 1-600000) | One reading command (`bir scenario show` or `edits`, `bir investigate`), shared across its reads. MCP read tools time out at this + 10 s. |
1017
- | `BIR_SCENARIO_MAX_OUTPUT_CHARS` | scenario | 24000 (1000-400000) | Most of a read tool's answer that reaches the model. |
1018
- | `MAX_MCP_OUTPUT_TOKENS` | scenario (Claude Code's variable) | unset | When set, and the previous variable is not, the cap is 1.5 characters per token. |
1019
- | `BIR_WRAP_MCP_JSON` | CLI | unset | `1` is the same as `--mcp-json`. |
1020
- | `CLAUDE_CONFIG_DIR` | CLI (Claude Code's variable) | unset | Honoured: `$CLAUDE_CONFIG_DIR/.claude.json` and `settings.json`. |
1021
- | `HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY` | all | none | See §8.5, "Behind a corporate proxy". |
1022
- | `NODE_EXTRA_CA_CERTS` | Node | none | Company CA bundle for TLS-inspecting networks. |
1023
- | `BIR_SETUP_TOKEN`, `BIR_VERSION`, `BIR_PROJECT`, `BIR_NODE_VERSION` (24.21.0), `BIR_NODE_ARCHIVE`, `BIR_TARBALL`, `BIR_NO_CLAUDE`, `BIR_UNINSTALL`, `BIR_DRY_RUN` | install scripts | none | Documented in the headers of `scripts/install.sh` and `install.ps1`. `BIR_UNINSTALL=1` runs `bir uninstall --all` and nothing else. |
1024
- | `BIR_DAEMON`, `BIR_DAEMON_LOG`, `BIR_DAEMON_CWD` | recorder | set by the launcher | Internal: marks the background recorder, its log and its project. |
1025
- | `BIR_MCP` | CLI | set by `bir-scenario` | Internal: compact JSON output for the model. |
1026
- | `BIR_CLAUDE_JSON`, `BIR_CLAUDE_SETTINGS`, `BIR_CLAUDE_MANAGED_DIR` | CLI | Claude Code's own paths | **Test/sandbox.** Where Claude Code's config, user settings and managed settings are. An empty `BIR_CLAUDE_MANAGED_DIR` disables managed settings. |
1027
- | `BIR_QUIET` | all | unset | **Test only.** `1` silences the audit log. |
1028
- | `BIR_AUTH_DISABLE` | recorder, proxy, scenario | unset | **Development/test only.** `1`: a fake local session, no service. |
1029
- | `BIR_RECORDER_TICK_MS` | recorder | 60000 | **Test only.** The recorder's minute tick. |
1030
-
1031
- ---
1032
-
1033
- ## 10. Release checklist
1034
-
1035
- **Automated.**
1036
-
1037
- 1. **CI.** Open the release pull request. `ci.yml` runs typecheck, build, the test suite and the real-process smoke run on ubuntu, windows and macos with Node 20, plus ubuntu with Node 24. All jobs must be green. The six POSIX-only, Git-Bash and symlink-privilege tests skipped on Windows run only there.
1038
- 2. **Flaky-test check.** The test step retries once on failure. Read each job's log for the warning "first attempt failed; retrying once". A pass on the retry is a flaky test to fix, not a green run.
1039
- 3. **Version.** `package.json` and `package-lock.json` are at 0.3.0; `bir --version`, the recorder's `/health.hooksVersion` and `bir-scenario` all read it from `package.json`. The release workflow refuses a tag that disagrees with `package.json`. Tag `v0.3.0` to publish to npm and attach the tarball.
1040
- 4. **Release notes.** Build them from §7: the changed-defaults table, "Upgrading from 0.2.12", the downgrade rule, and the headless-fleet remedies.
1041
- 5. **Documentation.** README.md and `docs/` (installRun, quickstart, calculatedReplay, calculatedReplayGuide, mcpmark, BaseInstRunner) and the `installInst*.md` guides must match this document. Check at least:
1042
- - that auto-approval is gone;
1043
- - the local-scope overrides and the `.mcp.json` approval rule;
1044
- - the 1.5 s match budget and the 800 ms gate;
1045
- - the hook timeouts;
1046
- - that recorded outputs are no longer served;
1047
- - `bir off`, `bir on` and `bir uninstall --all`;
1048
- - the doctor exit codes;
1049
- - the NO_PROXY requirement;
1050
- - `BIR_UPSTREAM_HEADERS`;
1051
- - the `run_scenario` answers.
1052
- 6. **Served install scripts.** The API serves copies of `install.sh` and `install.ps1` with the version and address filled in. Update the served copies with the new scripts; they gained `BIR_UNINSTALL` and the NO_PROXY hint.
1053
-
1054
- **Manual end-to-end checks with the real Claude Code.** These are behaviours of Claude Code itself, which the automated tests simulate but cannot prove. Use the Claude Code version you will support; the approval rules and hook schema were read from 2.1.280.
1055
-
1056
- | # | Check | Pass when |
1057
- |---|---|---|
1058
- | 1 | An HTTP hook answering `{}` on every route: with a stale token, with the recorder off, and at a deadline | Nothing is shown in the session: no hook error, no text |
1059
- | 2 | A PostToolUseFailure answered with `hookEventName: "PostToolUseFailure"` and `additionalContext` (a failed pinned replay step) | No hook error, and the model receives the hand-over note |
1060
- | 3 | A wrapped MCP call in the default permission mode | Claude Code asks exactly as it does unwrapped. The step appears in the recording, joined by fingerprint, with its result. |
1061
- | 4 | A `bypassPermissions` session calling a wrapped MCP tool | `updatedInput` with `__bir_call_id__` is accepted, the upstream never sees the key, and the step is joined by id |
1062
- | 5 | `bir off` mid-session | The next hooks answer `{}` within about 1 s, `run_scenario` disappears after `tools/list_changed`, and no new steps are recorded |
1063
- | 6 | Kill the recorder mid-session (in a project with a wrapped server) | Hook errors stop within seconds without user action (`proxy.recorder_revive`, `daemon.revived`). Measure the window. |
1064
- | 7 | A direct replay with the real `bir-scenario` | `/health.scenarioServers` lists it. `run_scenario` runs without a permission prompt. `/scenario/run` carries the tool-use id. The bundle reaches the model. |
1065
- | 8 | `/exit` while the service is slow | SessionEnd returns within its 3 s, and the run is finished on the service afterwards |
1066
- | 9 | Windows cold SessionStart after a reboot | About 0.7 s. No console window flashes, and the recorder survives the hook's exit. |
1067
- | 10 | Upgrade a live 0.2.12 install with a session open, then run `bir setup` | Entries migrate, `.mcp.json` is byte-identical to the team's, the one-time approval note is printed, and the 0.2.12 recorder is replaced when idle |
1068
- | 11 | `bir uninstall --all` on a machine with several projects | Exit 0, every file restored, no runner entries left in any `.claude.json`, all recorders stopped |
1069
- | 12 | A fresh clone with `.mcp.json`: `bir setup`, approve in Claude Code, `bir setup` again | The first run leaves the server unwrapped with a message; the second wraps it |
1070
- | 13 | `claude -p` in an untrusted workspace with wrapped tools | Calls are denied without `permissions.allow` or `--allowedTools`, and allowed with them, as the release notes say |
1071
- | 14 | A 2-minute direct run | Check how Claude Code treats a `run_scenario` call near 2 minutes (auto-background) and 5 minutes (MCP idle timeout) |
1072
-
1073
- **Asks to the service team.**
1074
-
1075
- 1. **Deduplicate client-generated `run_`, `step_` and execution-ticket ids.** Answer 409 to a repeat, so that one transient retry cannot record twice. The runner treats a 409 on a retry as "already landed". Once this is in place, the runner can safely retry after 502, 504 and connection resets, which it does not do today.
1076
- 2. **A cheap "this user or project has no scenarios" hint**, for example on sign-in or in the run-create answer, cacheable. With it, a prompt can skip the create-and-match wait: the last per-prompt service round trip (288-464 ms) with replay on.
1077
- 3. **Keep create-and-match under 800 ms at p95.** Above that, each recorder's match gate stops steering for 5 to 30 minutes.
1078
- 4. **Keep `GET /health` answering JSON.** The runner uses it to tell the API from a website: an HTML answer means "not a BaseIn service". Refuse a spent refresh token with a JSON 400 or 401. The runner treats an HTML or non-JSON 4xx as "unreachable", so a WAF or CDN challenge never signs users out.
1079
- 5. **Send Retry-After on 429 and 503.** The runner honours up to 2 s for its one background retry.
1080
- 6. **Sandbox or validate scenario logic on the service side.** On users' machines it runs in the recorder process with only a synchronous time limit.
1081
- 7. **Expect redacted text.** Derivation prompts and uploads now arrive redacted (`[redacted]` in place of secrets).
1082
- 8. **Keep `/scenarios/match-intent` compatible.** A 404 there disables intent probing for 10 minutes.
1083
-
1084
- ---
1085
-
1086
- ## 11. Known limitations and remaining risks
1087
-
1088
- **Partial, not fixed and accepted findings (Appendix A).**
1089
-
1090
- | Item | Status | What remains, and why |
1091
- |---|---|---|
1092
- | Per-prompt round trip with replay on (`replay-steady-state-hot-path-latency`, `prompt-waits-createrun-rtt`) | partial / not fixed | Tool calls never wait on the service, and a slow service is gated. With replay on, a healthy service still costs every prompt one create-and-match round trip (288-464 ms measured), even for users with no scenarios. Removing it needs the service-side hint (§10). `bir replay off` removes it per project. |
1093
- | Node paths (`daemon-hook-node-path-versioned`, `cli-baked-node-paths`) | partial | Entries and hooks name Node by absolute path. Homebrew now gets the stable opt link, and hooks and proxies use one Node. nvm, fnm, Volta and asdf paths change with the version and are only warned about. After a Node change, `bir doctor` reports a PROBLEM and `bir setup` repairs it. The install line avoids this by using a private Node. |
1094
- | Corporate proxy for the runner's own traffic (`cli-env-proxy-ignored`) | partial | `--use-env-proxy` is carried only when the Node knows the flag and NO_PROXY exempts 127.0.0.1, because without that exemption the runner's own loopback calls would be sent to the proxy. When the condition is not met, install prints the hint and the runner's service traffic connects directly, bypassing the proxy. |
1095
- | Remote upstreams behind HTTPS_PROXY (`upstream-http-ignores-https-proxy`) | partial | Honoured on Node 24.5+ and 22.21+ (`http.Agent` proxyEnv). Older Nodes connect directly and log `upstream.proxy_env_ignored`. |
1096
- | Unusable stdio answer (`upstream-unanswerable-2xx-hangs-host`) | partial | The HTTP side and oversized lines are answered. A stdio server that prints an unparseable (non-JSON) answer line still leaves that one call waiting, exactly as it would unwrapped. |
1097
- | Scenario logic isolation (`replay-logic-no-time-limit`) | partial | Logic bodies run in the recorder process (`vm.runInThisContext`) with a synchronous time limit (1 s, maximum 10 s). There is no sandbox and no memory limit, and continuations a body queues are not bounded. A body that returns a promise is refused. |
1098
- | Port conflicts (`daemon-pinned-port-no-selfheal`, `daemon-foreign-listener-exposure`) | partial | The recorder records who holds the port, and `bir setup` moves the project, but a running session keeps its hook URL. Claude Code still posts hooks, with the bearer token, to whatever holds the project port. The runner never trusts an unidentified listener, and doctor names the holder. |
1099
- | Recorder lifetime (`daemon-cwd-lock-immortal`) | partial | The project folder is no longer locked, because the recorder runs in `~/.baseinstrunner`. The project's main recorder still lives until reboot, by design: an idle exit would turn the next hook into a refused connection. Superseded recorders do exit. |
1100
- | Windows cold start (`daemon-windows-launch-latency`, `empirical-cold-sessionstart-powershell`) | partial | The PowerShell `Start-Process` launch (about 0.7 s once per project per boot) is kept by default for handle-inheritance safety. `BIR_WINDOWS_LAUNCH=spawn` opts into a faster launch. |
1101
- | `__bir_call_id__` in tool schemas (`call-id-schema-visible-by-default`) | accepted | The reserved property stays declared while ids may be injected, so `bypassPermissions` and pinned calls can carry an id. Narrowing it risks an undeclared key after a mid-session switch to `bypassPermissions`. |
1102
-
1103
- **Other residual risks.**
1104
-
1105
- - **First prompt of a hang outage.** It waits 1.5 s, once per recorder per outage. Every other hook is free.
1106
- - **Replay responses are not validated.** Response parameters taken from live step outputs are fed into the next step's input without the injection guard, which checks derived parameters only.
1107
- - **Standalone (Tier 2) proxy memory during a long outage.** A proxy with no recorder grows to about 220 MB during a long outage, flat from about 1,500 calls (0.2.12: 176 MB at 2,000 calls, still growing). This is V8 heap slack from per-call garbage, not the bounded backlog. `--max-old-space-size=64` flattens it at 131 MB, but the median call then takes 3 ms.
1108
- - **Stale local copies.** After `.mcp.json` is edited, or a user-scope token rotated, the old copy keeps running until `bir setup`. Doctor reports a PROBLEM, `bir status` shows STALE, and the SessionStart hook logs `overrides.stale`. The hook deliberately does not write `~/.claude.json`.
1109
- - **Writes to `~/.claude.json` can race Claude Code.** `bir setup`, `install` and `uninstall` replace the file atomically. A write made while Claude Code is running can still race Claude Code's own write of the same file, and the later writer wins. Prefer running them with no Claude Code session open.
1110
- - **Per-session `BIR_OFF=1` lives in the recorder's memory.** A recorder restart loses it until that session's next SessionStart.
1111
- - **Two sessions in one project.** Direct replays for a session whose own proxy has not yet bound hand over instead of running.
1112
- - **Revive gaps.** `bir-scenario` does not revive in its first 12 s; those first hooks are left to the SessionStart hook. A session with no wrapped server and no `bir` server is not revived until `/clear` or a new session. The revive window was not measured with real Claude Code.
1113
- - **Direct-run worst case.** The worst case by budget is 120 s: 8 s of derivation, 100 s of plan, and 12 s of slack. It is reached only if every logic body hits its limit, and sits at Claude Code's 2-minute MCP auto-background. Setting `BIR_REPLAY_BUDGET_MS=120000` (the old default) makes it 140 s.
1114
- - **Retries are conservative.** A send lost to a 502, 504 or mid-request reset is not retried until the service deduplicates ids.
1115
- - **Healthy-service prompt cost.** With an instant local service the prompt hook is about 6 ms slower than 0.2.12 (about 17 vs 11 ms median); the cause was not isolated. Under 503 or slow answers the recorder also keeps more idle keep-alive sockets. Both are small, but visible in measurements.
1116
-
1117
- ---
1118
-
1119
- ## Appendix A: Findings ledger
1120
-
1121
- Legend: *Round* is `audit` (component audit plus verification, 162 findings) or `final review` (whole-system review, 45 findings). *Status* is `fixed`, `partial` (what remains is in §11), `not fixed`, or `accepted`. The finding text is the first part of each full finding.
1122
-
1123
- | Round | Severity | Area | ID | Finding | Status |
1124
- |---|---|---|---|---|---|
1125
- | audit | critical | cli | `upstream-oauth-remote-servers-lose-auth` | Wrapping a remote MCP server that Claude Code signs in with OAuth removes its credentials, so the server fails on every session | fixed |
1126
- | audit | critical | cli | `cli-remote-oauth-servers-broken` | bir setup wraps OAuth-authenticated remote MCP servers in a stdio proxy that has no credentials, which breaks them | fixed |
1127
- | audit | critical | cli | `cli-stale-snapshot-restore-data-loss` | uninstall restores a file to its FIRST-install snapshot after a later re-install. It deletes MCP servers the user added in between and, for ~/.claude.json, reverts all of Claude Co | fixed |
1128
- | audit | critical | http | `proxy-sse-initialize-before-endpoint` | Legacy SSE upstreams fail at initialize: the host's first POST goes out before the SSE endpoint is announced, and a stream that ends normally is never noticed | fixed |
1129
- | audit | critical | http | `proxy-remote-oauth-servers-broken` | Wrapping a remote http/sse server that relies on Claude Code's OAuth breaks it: the proxy has no credentials, and the host just gets a JSON-RPC error with the upstream's HTML body | fixed — CLI leaves OAuth remotes unwrapped; transport answers 401/403 with a fixed message |
1130
- | audit | critical | http | `upstream-legacy-sse-initialize-races-endpoint` | Legacy SSE: the host's initialize is POSTed to the SSE GET URL before the endpoint event arrives, so the server never connects | fixed |
1131
- | audit | critical | record | `recording-no-circuit-breaker` | No fail-fast or circuit breaker: while the service is blackholed or hanging, every prompt costs +2.5 s and every tool call +4 s, and a revoked refresh token costs 2 extra round tri | fixed — process-wide ServiceHealth breaker on every service call |
1132
- | audit | critical | record | `recording-redaction-gaps` | Secrets in built-in tool results, commands, prompts, answers and error messages are sent to the service unredacted | fixed |
1133
- | audit | critical | server | `hookserver-intent-probe-blocks-every-tool-call` | Every PreToolUse (every Bash/Read/Edit/MCP call) waits up to 4 s for the service's intent probe | fixed |
1134
- | audit | critical | server | `hookserver-prompt-awaits-network-flush-after-interrupt` | After an interrupted turn, the next UserPromptSubmit (and SessionEnd) wait for the whole recorder backlog to reach the service | fixed |
1135
- | audit | critical | server | `replay-no-circuit-breaker` | A slow, blackholed or signed-out service costs 2.5 s on every prompt and 4 s on every tool call, indefinitely | fixed |
1136
- | audit | high | cli | `install-machine-paths-in-shared-mcp-json` | (cross-area: install) `bir setup` writes machine-specific absolute node and runner paths plus BIR_CONTROL_URL into the project's .mcp.json, the file teams commit, so teammates' and | fixed |
1137
- | audit | high | cli | `daemon-hook-node-path-versioned` | The SessionStart hook runs node by its resolved, versioned path, so a routine `brew upgrade node` removes it | partial — stable Homebrew opt path and one node for hooks and proxies; nvm/fnm/volta/asdf paths only warned; doctor flags a missing node |
1138
- | audit | high | cli | `cli-baked-node-paths` | Wrapped entries and hooks bake in absolute Node and package paths: after a Node upgrade or removal, every wrapped MCP server fails to start, and doctor does not check for it | partial — as above |
1139
- | audit | high | cli | `cli-user-scope-wrap-global` | Wrapping a user-scope server from one project rewrites it for every project: other projects' calls get recorded, and the entry carries the first project's control URL | fixed |
1140
- | audit | high | cli | `cli-team-mcp-json-machine-paths` | Wrapping project-scope servers writes this machine's absolute Node and runner paths into the team-committed .mcp.json | fixed |
1141
- | audit | high | cli | `cli-hook-timeouts` | The hook timeouts install writes (SessionEnd 30s, Pre/PostToolUse 30s, UserPromptSubmit 15s) become the user's wait whenever the service is slow | fixed |
1142
- | audit | high | cli | `cli-env-proxy-ignored` | Behind a corporate proxy the runner's own traffic ignores HTTPS_PROXY, although the docs say it passes through | partial — --use-env-proxy carried when the Node supports it and NO_PROXY exempts 127.0.0.1; otherwise a hint is printed |
1143
- | audit | high | cli | `cli-uninstall-leaves-wrapped` | `bir uninstall` can print "Uninstalled." while MCP entries stay wrapped, when the cwd is spelled differently or an earlier install failed | fixed |
1144
- | audit | high | cli | `cli-removal-breaks-other-projects` | Removing the software as the docs describe (uninstall in one project, then delete ~/.baseinstrunner or `npm rm -g`) breaks every other installed project's MCP servers and every use | fixed |
1145
- | audit | high | daemon | `hookserver-startup-blocks-on-network` | bir-hooks signs in over the network (no timeouts) before it listens, so a slow or blackholed service makes every hook fail for the whole start-up and can disable recording for days | fixed |
1146
- | audit | high | daemon | `hookserver-reinstall-orphans-live-recorder` | `bir uninstall` leaves the recorder running; a reinstall then writes hooks with a new token (and, by default, a new port) that the old recorder never serves, and ensureDaemon keeps | fixed — current sidecar token accepted; stale recorder replaced or started alongside; superseded recorders exit |
1147
- | audit | high | daemon | `daemon-auth-before-listen` | The recorder calls the BaseIn service with no timeout before it listens, so a slow or down service holds up SessionStart and leaves every hook failing for up to 10 minutes | fixed |
1148
- | audit | high | daemon | `daemon-stale-after-reinstall` | After `bir uninstall` and `bir setup`, the old recorder is kept on the old port and token; the new hooks refuse on every call, indefinitely | fixed |
1149
- | audit | high | daemon | `recording-daemon-start-blocks-on-auth` | The recorder only starts listening after two unbounded network calls, so SessionStart blocks for 15 s and hooks are refused while the service is unreachable | fixed |
1150
- | audit | high | daemon | `empirical-hooks-not-listening-while-auth-hangs` | bir-hooks does not listen for ~5 minutes when the service accepts connections but never answers; each SessionStart waits 16.7 s and starts another daemon | fixed |
1151
- | audit | high | http | `proxy-http-upstream-serialises-all-requests` | Streamable-HTTP upstream: every POST is serialised and waits for the full response, so parallel tool calls run one at a time, cancellations and pings queue behind a slow call, and | fixed |
1152
- | audit | high | http | `upstream-unanswerable-2xx-hangs-host` | An upstream reply the proxy cannot use (HTML 200, SSE stream ending or resetting before the response, an unparseable or oversized stdout line) leaves the host's request waiting for | partial — HTTP side and oversized stdio lines answered; an unparseable (non-JSON) stdio answer line still hangs that call, as it would unwrapped |
1153
- | audit | high | http | `upstream-http-posts-serialised-head-of-line` | All POSTs to a remote upstream are serialised, so one slow tool call delays every parallel call, ping and cancellation to that server | fixed |
1154
- | audit | high | http | `upstream-http-serialised-posts-deadlock-server-requests` | Streamable HTTP: a server-initiated request sent during a tool call deadlocks that call, because the host's answer is queued behind the call's own POST | fixed |
1155
- | audit | high | proxy | `proxy-records-in-projects-without-optin` | User-scope servers wrapped with one project's BIR_CONTROL_URL fall to Tier 2 in every other project and upload those projects' MCP tool arguments and results to the BaseIn service | fixed |
1156
- | audit | high | record | `recording-fetch-no-timeout` | No recorder or auth fetch has a timeout: a blackholed service costs 10.7 s per request, and a service that accepts but never answers costs up to 300 s | fixed |
1157
- | audit | high | replay | `replay-recorded-output-masks-failure` | Outputs recorded in an earlier run replace live failures, including writes, and the model is told not to call tools | fixed |
1158
- | audit | high | replay | `replay-empty-params-after-derive-timeout` | A derivation timeout or a paramsLogic error runs the plan with `{}` parameters, not the recorded samples | fixed |
1159
- | audit | high | replay | `replay-steer-ignores-failure-signals` | Steer mode treats failed steps as successes and keeps auto-approving the next steps | fixed — host/proxy failure passed to the controller; the plan hands over |
1160
- | audit | high | replay | `replay-skipped-steps-vanish-from-bundle` | A direct step that cannot run and has no recorded output disappears from the result, which still says 'every side effect is in place — do NOT call any tools' | fixed |
1161
- | audit | high | scenario | `scenario-server-spontaneous-call-red-error` | The model calls run_scenario on its own (seen in production on every first run) and the user sees a red error, 5 s late when no recorder is running | fixed |
1162
- | audit | high | server | `proxy-correlation-autoapproves-mcp-calls` | Id-injection correlation answers PreToolUse with permissionDecision:"allow" by default, so every wrapped MCP call skips the user's permission prompt | fixed |
1163
- | audit | high | server | `hookserver-prompt-match-no-circuit-breaker` | Every prompt waits the full 2.5 s match budget while the service is slow or blackholed; a matched prompt can wait 10.5 s, and the first tool call after arming up to 8 s more | fixed — breaker + sticky slow + match-latency gate + 1.5 s default budget |
1164
- | audit | high | server | `hookserver-correlation-allow-auto-approves` | By default every wrapped MCP tool call is answered permissionDecision 'allow', so the user's permission prompts for those tools disappear | fixed |
1165
- | audit | high | server | `hookserver-pin-by-name-rewrites-and-approves` | Steer mode pins any call to the expected tool by name alone, rewrites it to the recorded step's input and auto-approves it | fixed |
1166
- | audit | high | server | `daemon-sessionend-unbounded-flush` | The SessionEnd hook waits for every queued service send, and those sends have no timeout, so /exit and /clear can take up to 30 s when the BaseIn service is slow or unreachable | fixed |
1167
- | audit | high | server | `recording-prompt-waits-on-flush` | After an interrupted turn, the next UserPromptSubmit waits for the transcript and for a network flush with no deadline (2.4 s healthy, over 15 s when the service is unreachable) | fixed |
1168
- | audit | high | server | `replay-scenario-run-wrong-session` | run_scenario runs whichever session's plan was armed first, with that session's parameters | fixed |
1169
- | audit | high | server | `replay-steady-state-hot-path-latency` | Every prompt and every tool call waits for a service round trip, even when replay is off or no scenario exists | partial — tool calls never wait on the service; with replay on, a healthy service still costs one create/match round trip per prompt (service-side hint needed) |
1170
- | audit | high | server | `replay-run-scenario-permission-prompt-and-veto-bypass` | run_scenario is not pre-approved, and denying it still runs the plan | fixed |
1171
- | audit | high | server | `replay-prompt-waits-flush-after-interrupt` | The first prompt after an Esc-interrupted turn waits for the previous run to flush to the network (cross-area) | fixed |
1172
- | audit | high | server | `replay-diverge-unbounded-inside-pretool` | Divergence recovery runs every remaining step inside the 30 s PreToolUse hook with no deadline; on timeout the model's own call runs while the daemon keeps executing the scenario | fixed — same as above |
1173
- | audit | high | server | `scenario-server-direct-without-delivery-tool` | Direct replay is armed and injected even when no `bir` server exists (after --no-replay, uninstall --replay, a disabled server, or one that failed to spawn) | fixed — direct plans need a live /scenario/hello from bir-scenario |
1174
- | audit | high | server | `scenario-server-run-scenario-no-hook-allow` | No hook ever allows mcp__bir__run_scenario, so direct replay (and every spontaneous no-plan call) goes through Claude Code's permission system: an approval prompt interactively, a | fixed |
1175
- | audit | high | server | `empirical-pretooluse-waits-on-intent-probe` | Signed in with replay on (the default), every PreToolUse waits for a full service round trip of up to 4 s | fixed |
1176
- | audit | high | server | `empirical-prompt-waits-on-create-run` | UserPromptSubmit waits up to 2.5 s for the create-run round trip, even with BIR_REPLAY=0; a matched scenario can then add up to 8 s for derivation | fixed |
1177
- | audit | high | server | `empirical-prompt-after-interrupt-finalizes-inline` | The first prompt after an interrupted turn waits for the previous run to finish: 0.5-2 s when healthy, up to the 15 s hook timeout when the service is slow | fixed |
1178
- | audit | medium | cli | `daemon-npx-install-no-ensure` | `bir install` with no flags (the README's replay recipe) installs no self-starting hook, so every hook fails until `bir up`, after every reboot | fixed |
1179
- | audit | medium | cli | `cli-nonatomic-config-writes` | Every config and state write is a non-atomic read-modify-write: it races Claude Code on ~/.claude.json and can tear installed.json and credentials.json | fixed — writeFileAtomic for every user/state file incl. credentials |
1180
- | audit | medium | cli | `cli-no-kill-switch` | There is no single switch that makes the runner inert, and `bir down` makes things worse | fixed |
1181
- | audit | medium | cli | `cli-npx-default` | `bir install` and `bir wrap` default to a pinned npx entry: about 1s more per MCP server start, a network fetch on a cold cache, and no recorder that starts itself | fixed |
1182
- | audit | medium | cli | `cli-uninstall-cross-project` | `bir uninstall` in one folder removes the hooks and restores the files of every installed project, wipes all backups, and leaves its own recorder running | fixed |
1183
- | audit | medium | cli | `cli-port-permanent` | A project's hook port is fixed forever: if it becomes unavailable, or changes while the old recorder is still running, every hook of every session fails | fixed — `bir setup` moves an unbindable port and rewrites hooks |
1184
- | audit | medium | cli | `cli-doctor-accuracy` | `bir doctor` reports a false port mismatch when a machine has several projects, and misses the failures that actually break sessions | fixed |
1185
- | audit | medium | cli | `cli-sidecar-secret-copies` | installed.json keeps full copies of ~/.claude.json and of remote servers' auth headers, and the headers also appear on the proxy's command line | fixed — no ~/.claude.json copy; stashed secrets redacted and kept out of 0.2.12 lookups |
1186
- | audit | medium | cli | `cli-setup-symlinked-project` | `bir setup --project <path through a symlink>`, which install.sh passes, waits 15s, reports failure, and stores --allow-servers under a key the recorder never reads | fixed |
1187
- | audit | medium | cli | `scenario-server-mcp-json-shared` | The `bir` entry goes into the committable .mcp.json with this machine's absolute Node and script paths, so teammates get an approval prompt and 'failed to connect' | fixed |
1188
- | audit | medium | cli | `scenario-server-npx-entry` | A flag-less `bir install --replay` writes an npx `bir` entry: about 13 s cold start, fails offline, and on native Windows lacks the documented `cmd /c` wrapper | fixed |
1189
- | audit | medium | cli | `empirical-npx-entry-offline-breaks-mcp` | Plain `bir install` writes pinned-npx entries: every MCP server start takes +0.85 s, and 70.8 s when the network is offline or hanging | fixed |
1190
- | audit | medium | daemon | `daemon-pinned-port-no-selfheal` | When the project's fixed port cannot be bound, the recorder exits and nothing recovers, so every session's hooks fail until `bir setup --port` | partial — recorder records who holds the port; `bir setup` moves the project; the running session keeps its hook URL |
1191
- | audit | medium | daemon | `daemon-nullrecorder-forever` | A recorder that starts while signed out or offline records nothing for its whole multi-day life, even after the service recovers or the user signs in | fixed |
1192
- | audit | medium | daemon | `daemon-replay-token-captured` | Replay reads the access token from the startup session object, which is never updated, so service derivation gets 401 once that token expires | fixed |
1193
- | audit | medium | daemon | `daemon-live-treated-dead` | A recorder that is alive but slow to answer, or whose discovery file aged over a long sleep, is treated as dead: its discovery file is deleted and a duplicate is started that canno | fixed |
1194
- | audit | medium | daemon | `daemon-cwd-lock-immortal` | Recorders never exit and hold the project folder as their working directory, so on Windows the folder cannot be renamed or deleted | partial — recorder runs in ~/.baseinstrunner (folder no longer locked); main recorder still lives until reboot by design; superseded recorders exit |
1195
- | audit | medium | daemon | `daemon-foreign-listener-exposure` | If another program holds the project's port, Claude Code sends it every hook payload and the bearer token | partial — the runner never trusts an unidentified listener and doctor names the holder; Claude Code itself still posts hooks to whatever holds the port |
1196
- | audit | medium | daemon | `recording-nullrecorder-for-daemon-lifetime` | A transient failure at recorder start leaves recording and replay off for the recorder's whole life (days), and the log wrongly tells the user to sign in | fixed |
1197
- | audit | medium | daemon | `recording-replay-token-stale` | Replay reads the startup access token forever, so service-side derivation and source-run outputs fail with 401 after the first 24 h | fixed |
1198
- | audit | medium | daemon | `replay-stale-access-token` | Replay's service derivation and source-run fetches keep the startup access token after the recorder refreshes | fixed |
1199
- | audit | medium | http | `proxy-http-non-jsonrpc-200-hangs` | A remote upstream that answers HTTP 200 with a body that isn't a single JSON-RPC response (HTML from an SSO or captive proxy, a batch array, or an SSE body that ends early) leaves | fixed |
1200
- | audit | medium | http | `upstream-sse-streams-never-reestablished` | SSE streams are never re-established: a dropped legacy SSE stream breaks the server for the rest of the session, and a dropped standing GET silently loses server-initiated messages | fixed |
1201
- | audit | medium | http | `upstream-http-implicit-300s-timeout` | A slow remote tool is cut off at 300 s by Node fetch's default headers/body timeouts and reported as "upstream unreachable: fetch failed" | fixed |
1202
- | audit | medium | http | `upstream-http-session-expiry-never-recovers` | Streamable HTTP: after the server drops the session (404 on Mcp-Session-Id), every later call fails for the rest of the Claude Code session | fixed |
1203
- | audit | medium | http | `upstream-http-ignores-https-proxy` | Wrapped remote servers bypass the corporate HTTPS_PROXY: Node fetch ignores it unless NODE_USE_ENV_PROXY is set, so remote upstreams fail behind a mandatory proxy | partial — honoured on Node 24.5+/22.21+ (http.Agent proxyEnv); older Node connects directly and logs upstream.proxy_env_ignored |
1204
- | audit | medium | proxy | `proxy-oversized-upstream-response-hangs-call` | An upstream response over 32 MiB is silently dropped, so the host's tools/call never gets an answer (an indefinite hang) | fixed |
1205
- | audit | medium | proxy | `proxy-quadratic-framing-and-sync-scrub` | Large results pay O(n^2) Buffer.concat framing plus a synchronous redact and deep-copy before the answer is written: +66 ms at 5 MB, +0.8 s at 20 MB, +1.7 s at 30 MB | fixed |
1206
- | audit | medium | proxy | `proxy-schema-relaxation-leaks-internals` | tools/list schema relaxation shows runner internals to the model ('Internal BaseInstRunner correlation id — ignore.', additionalProperties:true) and depends on a startup race | fixed |
1207
- | audit | medium | proxy | `proxy-tier2-one-way-and-duplicate-runs` | Tier 2 is a one-way door: a late or restarted daemon is never re-bound, and queued reports each re-run becomeStandalone, creating several orphan runs and several authenticate() cal | fixed |
1208
- | audit | medium | proxy | `proxy-tier2-recorder-unbounded-and-refresh-storm` | Tier 2 recorder: sends have no timeout, the per-run chain bypasses the 1000-item queue bound, and a failed token refresh is retried on every step (23 'refresh failed: HTTP 401' in | fixed — bounded backlog, timeouts, single-flight refresh |
1209
- | audit | medium | proxy | `proxy-windows-path-case-sensitive` | Windows: whichSync reads env.PATH on a plain-object copy of the environment, so with the usual `Path` key npx/.cmd servers fail with spawn ENOENT | fixed |
1210
- | audit | medium | proxy | `proxy-replay-timeout-no-cancel` | A replay step that times out is not cancelled on the upstream, so it can still run after the daemon substitutes a recorded output; under --lazy, replay responses are swallowed | fixed |
1211
- | audit | medium | proxy | `upstream-cmd-quoting-mangles-args` | Windows: the .cmd wrapper's quoting breaks arguments that end in a backslash and expands %VAR% | fixed |
1212
- | audit | medium | proxy | `upstream-request-abort-leaves-work-running` | A timed-out replay step is never cancelled upstream: the tool keeps running (possibly after a recorded output was substituted), and on HTTP it blocks the user's next calls | fixed — base upstream client sends notifications/cancelled on abort for stdio and HTTP |
1213
- | audit | medium | proxy | `upstream-dead-upstream-keeps-proxy-alive` | When a stdio upstream dies for good the proxy stays up, so Claude Code keeps offering its tools and every call returns a runner-phrased error | fixed |
1214
- | audit | medium | proxy | `empirical-framing-quadratic` | The JSON-RPC line reader is O(n²) on large frames: the proxy adds 0.1-2 s to 5-30 MB tool results | fixed |
1215
- | audit | medium | proxy | `empirical-oversized-result-never-answered` | A tool result over 32 MiB is dropped by the proxy and the host's request is never answered | fixed |
1216
- | audit | medium | record | `hookserver-recorder-backlog-unbounded` | The step queue's 1000-item bound never engages; the real backlog is in RemoteRecorder's unbounded per-run promise chains, which have no fetch timeout | fixed — RemoteRecorder bound + /health.sending |
1217
- | audit | medium | record | `daemon-refresh-drops-authurl-binding` | The recorder's token refresh saves credentials without the authUrl binding, which quietly disables the protection against sending tokens to a different service | fixed |
1218
- | audit | medium | record | `recording-refresh-drops-authurl-binding` | An in-process token refresh removes the credentials' authUrl binding, after which authenticate() hands the live session to any origin | fixed |
1219
- | audit | medium | record | `recording-concurrent-refresh-race` | Concurrent 401s in one process each spend the rotating refresh token, the losers' sends are dropped, and there is no proactive refresh | fixed |
1220
- | audit | medium | record | `recording-unbounded-send-backlog` | The StepQueue bound never engages: the real buffer is RemoteRecorder's unbounded per-run promise chain | fixed |
1221
- | audit | medium | record | `recording-no-transient-retry` | One transient failure drops a send, and a dropped create turns the rest of the turn into 404s | fixed |
1222
- | audit | medium | record | `recording-logout-keeps-recording` | `bir logout` leaves running recorders sending under the old session, so recording and replay continue for up to the access token's lifetime after sign-out | fixed |
1223
- | audit | medium | record | `recording-uncapped-free-text` | Prompt, final answer, context and thinking intent are sent uncapped; tool payloads alone are capped at 64 KiB | fixed |
1224
- | audit | medium | record | `empirical-unbounded-recorder-backlog` | With a hung service, the recording backlog grows without bound in both the proxy and the daemon (StepQueue's cap does not apply) | fixed |
1225
- | audit | medium | replay | `replay-double-pin-concurrent-calls` | Two calls to the expected tool in flight at once are both pinned to the same step, so it runs twice | fixed |
1226
- | audit | medium | replay | `replay-logic-no-time-limit` | Scenario logic runs on the daemon's main thread with no time or memory limit | partial — synchronous time limit (BIR_LOGIC_TIMEOUT_MS); still not a sandbox and no memory limit |
1227
- | audit | medium | replay | `replay-cancel-does-not-stop-direct-run` | Esc does not stop a direct replay, and a client timeout leaves the steps running while the model retries | fixed — Esc aborts /scenario/run; no further step starts; the undelivered note reaches the next turn |
1228
- | audit | medium | replay | `replay-bash-channel-oversize-bundle` | The Bash delivery channel embeds a bundle of up to 60 K chars in the command string, and it gets cut | fixed |
1229
- | audit | medium | replay | `replay-targets-regex-misses-destructuring` | The missing-target gate misses destructured or whole-object reads of `parameters`, so a plan runs with a null target | fixed |
1230
- | audit | medium | replay | `replay-derived-params-injection-auto-approved` | Tool-result text feeds parameter derivation, and derived values are templated into auto-approved Bash, Edit and SQL calls | fixed |
1231
- | audit | medium | scenario | `scenario-server-run-cancel-ignored` | Esc does not stop run_scenario: the recorder keeps running the plan's tool calls after the user cancels | fixed |
1232
- | audit | medium | scenario | `scenario-server-internal-error-text` | run_scenario failures pass raw internals to the model and the user ('fetch failed', 'not_ready', 'control server answered HTTP 404') | fixed |
1233
- | audit | medium | scenario | `scenario-server-read-tools-no-timeouts` | Read tools can hang for 120 s on a slow or unreachable service, even when signed out | fixed — read tools bounded (BIR_READ_TIMEOUT_MS) and answer at once when signed out |
1234
- | audit | medium | scenario | `scenario-server-unbounded-output` | scenario_show and investigate results have no size limit, both in the model's context and in the server's memory | fixed |
1235
- | audit | medium | server | `hooks-inject-call-id-into-unwrapped-server` | __bir_call_id__ leaks to a real upstream: the daemon's `wrapped` set is read once at start and only grows, so a server the user unwraps or re-adds while the long-lived daemon runs | fixed |
1236
- | audit | medium | server | `hookserver-transcript-full-reparse-per-hook` | Every hook re-reads and JSON-parses the entire session transcript synchronously, blocking the daemon's event loop | fixed |
1237
- | audit | medium | server | `hookserver-proxy-step-joined-in-wrong-session` | Proxy step reports are joined only against the first session with an open run, so with two sessions (or one stale one) MCP steps land in the wrong recording and replay threads the | fixed |
1238
- | audit | medium | server | `hookserver-diverge-executes-inside-pretooluse-without-deadline` | Divergence executes the rest of the scenario inside one PreToolUse with no deadline, overrunning Claude Code's 30 s timeout and double-running side effects | fixed — 12 s route guard, 9 s controller budget, abort signal stops further steps |
1239
- | audit | medium | server | `hookserver-stale-sessions-never-pruned` | Sessions are never removed and interrupted runs are never sealed, so one dead window corrupts attribution, blocks upgrade restarts and leaks memory | fixed |
1240
- | audit | medium | server | `hookserver-hook-routes-return-non-2xx` | Hook routes answer 401, 404, 405, or a reset socket for over-size bodies, and Claude Code shows each one as a hook error | fixed |
1241
- | audit | medium | server | `daemon-stale-active-blocks-restart` | A turn that ends without Stop counts as 'active' forever, so version-skew restarts never happen and an upgrade never takes effect | fixed |
1242
- | audit | medium | server | `replay-intent-arm-denies-live-call` | An intent match mid-task denies or rewrites the model's own tool call to deliver the directive | fixed — mid-task intent arms never deny or rewrite an unrelated live call |
1243
- | audit | medium | server | `replay-transcript-full-parse-per-hook` | Each PreToolUse reads and JSON-parses the whole session transcript synchronously (115-145 ms at 30 MB) | fixed |
1244
- | audit | medium | server | `scenario-server-concurrent-sessions-wrong-plan` | /scenario/run runs whichever session's plan is armed first, not the caller's, so two sessions in one project can swap results | fixed |
1245
- | audit | medium | server | `scenario-server-run-not-single-flight` | No single-flight guard: a second run_scenario while the first is still executing runs the same plan again in parallel | fixed |
1246
- | audit | medium | server | `empirical-sessionend-waits-network-flush` | SessionEnd blocks until the whole recording chain reaches the service: 4 s, 18 s, or past the host's 30 s timeout when the service is slow | fixed |
1247
- | audit | medium | server | `empirical-transcript-full-reparse-per-hook` | Every PreToolUse, prompt and Stop re-reads and JSON-parses the whole transcript on the daemon's single thread, even when signed out | fixed |
1248
- | audit | medium | server | `missed-fragment-open-awaits-create-on-pretooluse` | After a replay hand-over, the next tool call's PreToolUse waits up to 2.5 s for the fragment's create-run round trip | fixed |
1249
- | audit | medium | server | `missed-intent-arm-awaits-derive-on-pretooluse` | An intent match on a scenario with targets holds that PreToolUse for up to 8 s of derivation, on top of the 4 s probe | fixed |
1250
- | audit | low | cli | `cli-isbirhook-overbroad` | isBirHook matches any hook URL that ends in a common route, and install drops the whole matcher group, deleting the user's own hooks (even in the team settings.json) | fixed |
1251
- | audit | low | cli | `cli-generic-drops-fields` | Wrapping replaces the whole entry, so fields such as `disabled`, `autoApprove` and `timeout` (in --config clients) are lost | fixed |
1252
- | audit | low | cli | `scenario-server-stale-entry-undetected` | `bir doctor` never checks the `bir` entry's Node and script paths, and entries use process.execPath where the hook uses the resolved real path | fixed |
1253
- | audit | low | daemon | `hookserver-shutdown-no-deadline` | shutdown() waits on an unbounded flush, and on POSIX a second SIGTERM is ignored, so `bir down` and restart-on-skew cannot stop a recorder stuck on a slow service | fixed |
1254
- | audit | low | daemon | `daemon-ensure-budget` | The SessionStart hook waits far longer than it needs to, and some paths exceed Claude Code's 30 s hook timeout | fixed |
1255
- | audit | low | daemon | `daemon-windows-launch-latency` | Windows cold start spends about 1 s starting powershell.exe; the fallback launch opens a visible console window | partial — no visible console, bounded launcher; PowerShell launch (~0.7 s once per project per boot) kept by default, BIR_WINDOWS_LAUNCH=spawn opt-in |
1256
- | audit | low | daemon | `daemon-shutdown-unbounded` | Shutdown waits on network sends with no deadline, SIGTERM is ignored once shutdown has started, and a skew restart ignores a failed stop | fixed |
1257
- | audit | low | daemon | `daemon-log-hygiene` | The recorder log only rotates at start and has no error handler; stale discovery files are never cleaned up | fixed |
1258
- | audit | low | daemon | `daemon-inherits-api-key` | The background recorder inherits Claude Code's ANTHROPIC_API_KEY, so replay derivation silently runs on the user's own key instead of the service | fixed |
1259
- | audit | low | daemon | `empirical-cold-sessionstart-powershell` | Cold SessionStart on Windows takes about 2.1 s, of which about 1.9 s is the PowerShell Start-Process launcher | partial — as above |
1260
- | audit | low | http | `upstream-sse-parser-quadratic-and-cr` | The SSE parser re-scans its whole buffer on every chunk (large results become slow), has no size cap, and hangs on legal lone-CR line endings | fixed |
1261
- | audit | low | http | `upstream-remote-credential-and-protocol-hygiene` | Remote upstream hygiene: bearer headers passed on the command line and stored in installed.json; auth sent to whatever origin the SSE endpoint names; no DELETE on close; no MCP-Pro | fixed — headers moved from argv to BIR_UPSTREAM_HEADERS; cross-origin endpoint refused; credentials dropped on cross-origin redirect; DELETE on close |
1262
- | audit | low | proxy | `proxy-upstream-death-masked` | A dead upstream is masked: the proxy stays alive answering 'not available' forever, so the host never sees the server fail; with --restart, in-flight calls hang and the new child i | fixed |
1263
- | audit | low | proxy | `proxy-small-leaks` | Small unbounded state: calls-map entries leak for requests to a dead upstream, and the upstream-stderr forwarder buffers without limit when no newline arrives | fixed |
1264
- | audit | low | proxy | `proxy-audit-lines-surface-as-server-errors` | Routine proxy audit lines on stderr appear in Claude Code's MCP logs as 'Server stderr' error entries for every wrapped server | fixed |
1265
- | audit | low | proxy | `upstream-windows-path-key-case-breaks-cmd-resolution` | Windows: .cmd resolution (npx, npm-installed server bins) fails whenever the inherited environment spells the key `Path`, and spawn fails with ENOENT | fixed |
1266
- | audit | low | proxy | `upstream-windows-close-orphans-real-server` | Windows: close() kills only cmd.exe, leaving the real MCP server (and anything it launched) running; the kill is also immediate, with no graceful stdin-close window | fixed |
1267
- | audit | low | proxy | `upstream-restart-flag-hangs-and-skips-initialize` | --restart: calls in flight at the crash and calls sent during the restart window never get an answer, and the restarted server is never initialised | fixed |
1268
- | audit | low | proxy | `replay-poll-busy-loop-when-disabled` | Proxies poll the daemon 4 times per second when a restarted daemon has replay turned off | fixed |
1269
- | audit | low | proxy | `empirical-proxy-repeated-downgrade-no-rebind` | When the recorder dies, a proxy downgrades once per queued step and never re-binds when the recorder comes back | fixed |
1270
- | audit | low | proxy | `empirical-redact-before-truncate-on-hot-path` | Large payloads are redacted in full before truncation, synchronously, on PostToolUse and before the proxy writes the result | fixed — proxy reports after the answer is written; truncate before redact |
1271
- | audit | low | proxy | `empirical-schema-relaxation-exposes-internals` | In Tier 1 every wrapped tool's schema shown to the model gains `__bir_call_id__` and `additionalProperties: true` | fixed |
1272
- | audit | low | record | `recording-credentials-write-unsafe` | credentials.json is written non-atomically, and refresh responses are saved without validation | fixed |
1273
- | audit | low | record | `recording-per-run-map-leak` | The per-run maps and the pendingSeals array are only cleared by flush, which normal Stop-ended turns never call | fixed — lanes and per-run maps released on drain/finish |
1274
- | audit | low | record | `recording-log-volume` | Failure lines embed whole response bodies (HTML error pages), and the daemon log is rotated only when a recorder starts | fixed — bodies described/clipped, once-per-run failure lines, runtime log rotation |
1275
- | audit | low | record | `recording-redact-over-matches-keys` | Substring key matching redacts ordinary arguments (author, max_tokens, nextPageToken, tokenizer), damaging recordings that replay then reuses | fixed |
1276
- | audit | low | record | `replay-probe-text-unredacted` | The intent probe sends the model's reasoning or thinking text unredacted | fixed |
1277
- | audit | low | replay | `replay-directive-unclipped-reasoning` | The steering directive embeds each step's recorded reasoning without any clip | fixed |
1278
- | audit | low | scenario | `scenario-server-read-tools-token-cost` | The three read-only tools are listed in every session and make up 87% of the `bir` server's per-request tool-definition cost | fixed |
1279
- | audit | low | scenario | `scenario-server-null-body-no-answer` | A control-port reply of `null` makes run_scenario never answer | fixed |
1280
- | audit | low | scenario | `scenario-server-run-timeout-shorter-than-plan` | The client's 180 s timeout is shorter than the recorder's worst case (~188 s), so a finished replay can arrive as 'This operation was aborted' | fixed — client 290 s vs ~111 s worst case |
1281
- | audit | low | server | `hookserver-late-hook-swallows-next-prompt` | A tool hook that arrives after Stop opens a prompt-less run, and the next prompt is then never matched and is recorded with an empty input | fixed |
1282
- | audit | low | server | `hookserver-stale-wrapped-set-injects-call-id` | The wrapped-server set is computed once and never shrinks, so after a config change the call id is injected into servers that are no longer proxied | fixed |
1283
- | audit | low | server | `daemon-unbounded-session-state` | The long-lived recorder never forgets sessions or finished turns (including matched scenario payloads) | fixed |
1284
- | audit | low | server | `replay-direct-step-on-other-sessions-proxy` | A session with no bound proxy takes the newest unbound proxy, which may belong to another open session | fixed — no newest-unbound guess when other sessions are live |
1285
- | audit | low | server | `replay-match-timeout-silent` | A match that misses its budget logs nothing, and a late hit leaves recording on, so every step POST returns 404 | fixed |
1286
- | audit | low | server | `empirical-daemon-session-state-never-pruned` | The long-lived daemon keeps every session forever, so memory and /health grow without bound | fixed |
1287
- | final review | high | invisible | `posttoolusefailure-wrong-event-name` | /tool/post answers PostToolUseFailure with hookEventName "PostToolUse". Claude Code rejects the answer, shows the user a hook error that dumps the runner's JSON, and the replay not | fixed |
1288
- | final review | high | upgrade | `UPG-1` | A 0.2.12 `bir` that runs after the upgrade (rollback, or a second copy on PATH) blanks the user's MCP credentials, because the new installed.json keeps redacted stashes in the same | fixed |
1289
- | final review | medium | invisible | `slow-service-defeats-breaker` | A slow service that still answers (2.5-10 s) adds the full 2.5 s match budget to every prompt: the answer's own ok() closes the cool-down that slow() just opened | fixed |
1290
- | final review | medium | invisible | `bir-off-env-not-honoured-by-running-recorder` | BIR_OFF=1 in a session's environment never reaches a recorder that is already running, so hooks keep recording and replay can steer. A recorder started from a BIR_OFF=1 session sta | fixed |
1291
- | final review | medium | invisible | `direct-plan-without-live-delivery-tool` | A direct plan is announced whenever the MCP config lists the bir server. If that server is not actually connected, the model's first ordinary call diverges, and the runner executes | fixed |
1292
- | final review | medium | invisible | `recorder-down-midsession-hook-errors` | If the recorder dies mid-session, every later HTTP hook is shown to the user as a hook error until the next SessionStart. Nothing restarts it mid-session. | fixed |
1293
- | final review | medium | lifecycle | `LC-1` | A recorder superseded by a port change never exits and cannot be found or stopped, and while it lives the project's shared recorder log stops rotating | fixed |
1294
- | final review | medium | platform | `PLAT-1` | Windows `cmd /c npx` entries pass the user's own server arguments through cmd.exe unescaped: `&`, `\|`, `<`, `>`, `^` and `%VAR%` get mangled | fixed |
1295
- | final review | medium | platform | `PLAT-2` | The claude.json path now follows CLAUDE_CONFIG_DIR at call time, so `bir uninstall` (and `--all`, BIR_UNINSTALL=1) from a different environment reports success and leaves wrapped e | fixed |
1296
- | final review | medium | replay | `replay-esc-direct-run-effects-undisclosed` | After Esc on run_scenario, the steps that already ran are never disclosed to the model, and the interrupted turn is booked as a 'diverged' saving | fixed — note stashed on the session and delivered on the next prompt; not booked as a saving |
1297
- | final review | medium | security | `SEC-1` | Key-segment redaction no longer catches several secret key names that 0.2.12 redacted, so those values are uploaded in clear | fixed |
1298
- | final review | medium | security | `SEC-2` | The derived-parameter injection guard lets argument injection and unquoted SQL through, and SQL plans still derive from recent tool results | fixed |
1299
- | final review | medium | security | `SEC-3` | The pin guard still rewrites and auto-approves unrelated calls: Bash compares only the first command's head, and single-argument MCP tools pin on any shared key | fixed |
1300
- | final review | medium | upgrade | `UPG-2` | The upgrade never replaces a 0.2.12 recorder that has an abandoned open run, so 0.2.12 auto-approval and latencies carry on after `bir setup`, and setup promises otherwise | fixed — setup/up probe a 0.2.12 recorder twice and replace it when idle |
1301
- | final review | medium | upgrade | `UPG-3` | `bir off` and `bir uninstall` tell the user the running recorder is now inert, but a surviving 0.2.12 recorder ignores the off switch and keeps recording, auto-approving and replay | fixed |
1302
- | final review | medium | upgrade | `UPG-6` | The upgrade silently removes auto-approval of wrapped MCP calls; headless `claude -p` fleets that relied on it start getting their MCP calls denied, with no notice | fixed |
1303
- | final review | low | invisible | `prompt-waits-createrun-rtt` | With replay on, every prompt waits a full create-run round trip to the service, even for a user or project with no calculated scenarios | not fixed — with replay on, a healthy service costs one round trip per prompt (100-500 ms); removing it needs a service-side "no scenarios for this user/project" hint |
1304
- | final review | low | invisible | `npx-ensure-latency` | npx installs now run `npx ... bir-hooks ensure` on every SessionStart, /clear and compaction. npx start-up is outside the ensure budget. | fixed — npx hooks run `ensure --budget-ms 8000` |
1305
- | final review | low | invisible | `ensure-writes-claude-json-at-sessionstart` | The SessionStart ensure hook may rewrite ~/.claude.json (override resync) while Claude Code is starting, and so writing that file too | fixed |
1306
- | final review | low | invisible | `call-id-schema-visible-by-default` | Every wrapped tool's schema still advertises __bir_call_id__ ("Reserved. Leave unset.") to the model, although by default ids are now injected only for pins or bypassPermissions | accepted — the reserved __bir_call_id__ property ("Reserved. Leave unset.") stays declared so bypassPermissions and pinned calls can carry an id |
1307
- | final review | low | lifecycle | `LC-2` | A keeper refresh that finishes after `bir logout` or a sign-in to another account installs a backend that ignores credentials.json | fixed |
1308
- | final review | low | lifecycle | `LC-3` | A remote MCP server behind a load balancer with a 60 s idle timeout makes bir-proxy write a stderr line every minute for the whole session | fixed |
1309
- | final review | low | lifecycle | `LC-4` | While the service is down, every run with unsent steps retries at the same moment each time the cool-down ends | fixed |
1310
- | final review | low | platform | `PLAT-3` | On macOS/Linux, setting up inside a linked git worktree writes a junk `home/.../.git/worktrees/<name>/info/exclude` tree into the user's worktree and never excludes settings.local. | fixed |
1311
- | final review | low | platform | `PLAT-4` | install.ps1 adds --use-system-ca for Node 23.0–23.7, which do not have the flag, so the npm install and launchers fail | fixed |
1312
- | final review | low | replay | `replay-blocked-pin-false-ran-note` | A pinned step that never runs (blocked by the user's deny rule or their own PreToolUse hook) is later reported as done, and the model's next call is rewritten to deliver that claim | fixed |
1313
- | final review | low | replay | `replay-steer-abandoned-call-worded-failed` | A pinned MCP call that its upstream abandoned mid-call gets a 'failed / No step ran' hand-over; the new 'may or may not have completed' wording never fires on the real server path | fixed |
1314
- | final review | low | replay | `replay-diverge-nothing-ran-booked-diverged` | A steer divergence that executes nothing and delivers nothing is still booked 'diverged' (a ledger saving) and is removed from the baseline | fixed |
1315
- | final review | low | replay | `replay-recorded-standins-inflate-ledger` | With BIR_REPLAY_RECORDED_OUTPUTS=1, recorded stand-ins count as work done: a run where nothing ran live books fell_back or diverged | fixed |
1316
- | final review | low | replay | `replay-intent-inplace-rewrites-unrelated-call` | With intent arming opted in, in-place delivery checks only the tool name, so the model's unrelated live call is rewritten or denied, bypassing the pin guard's plausibility rule | fixed |
1317
- | final review | low | replay | `replay-steer-preview-missing-with-service-derive` | Steer directives almost never show argument previews when derivation goes to the service, so the new pin guard declines the model's placeholder calls and steer plans retire unused | fixed — matched steer plans get up to 1.2 s (inside the prompt budget) to settle for the preview |
1318
- | final review | low | replay | `replay-pretool-budget-unclamped` | BIR_PRETOOL_REPLAY_BUDGET_MS is not held under the 12 s /tool/pre deadline, so divergence steps keep starting after the model has been told to carry on | fixed |
1319
- | final review | low | security | `SEC-4` | `bir logout` sends the refresh token to BIR_AUTH_URL without checking the session's authUrl binding | fixed |
1320
- | final review | low | security | `SEC-5` | On Windows, `bir login` passes the service-supplied verification URL to `cmd /c start`, so an `&` in it runs a command | fixed |
1321
- | final review | low | security | `SEC-6` | Derivation requests send the raw prompt, and the raw thinking text on intent arms, while the recorder redacts the same text | fixed |
1322
- | final review | low | security | `SEC-7` | Bash-channel hand-over files, which hold live tool outputs, are swept only when another oversized delivery happens, so they can stay on disk indefinitely | fixed — hand-over files swept by the recorder at start and daily |
1323
- | final review | low | security | `SEC-8` | In `--mcp-json` mode, installed.json keeps the full pre-install .mcp.json text, including literal header and env secrets | fixed |
1324
- | final review | low | security | `SEC-9` | The remote upstream's redirect follower keeps custom credential headers (X-Api-Key, Private-Token, …) across origins, including an https→http downgrade | fixed |
1325
- | final review | low | security | `SEC-10` | The logic time limit covers only synchronous work: promise and timer continuations queued by a body escape it | fixed |
1326
- | final review | low | upgrade | `UPG-4` | When a 0.2.12 recorder is left running beside the new one, its heartbeat takes back the discovery file every 30 min and the new recorder never reclaims it, so new sessions' proxies | fixed |
1327
- | final review | low | upgrade | `UPG-5` | `bir setup` moves the project to a new port when the running 0.2.12 recorder's discovery file is more than 24 h old (after a long sleep), leaving two recorders | fixed — setup ignores discovery age when checking who holds the port |
1328
- | final review | low | upgrade | `UPG-7` | 0.2.12 proxies still running after the recorder restart busy-poll it at 4 req/s when the upgrade turned replay off | fixed |
1329
- | final review | low | upgrade | `UPG-8` | Once the recorder has restarted as the new version, reports from 0.2.12 proxies still running whose arguments contain DSNs, token query params or Basic auth no longer join their ho | fixed |
1330
- | final review | low | upgrade | `UPG-9` | The version-skew restart runs in both directions, so two runner copies on one machine can keep replacing each other's recorder | fixed |
1331
- | final review | low | upgrade | `UPG-10` | The migration leaves the team's .mcp.json reformatted (2-space) instead of byte-identical, so git shows it modified until uninstall | fixed |
1332
-
1333
- ## Appendix B: Files changed since 0.2.12
1334
-
1335
- 114 files changed: 46,564 insertions and 3,047 deletions. Source and scripts account for 67 files (+25,330 / -2,890), tests for 47 files (+21,234 / -157).
1336
-
1337
- | Component | Files (insertions / deletions) | Lines |
1338
- |---|---|---|
1339
- | Shared foundations | **new** `src/util/service-health.ts` (+260), **new** `src/util/off-switch.ts` (+94), **new** `src/util/atomic-write.ts` (+110), `src/util/log.ts`, `src/util/journal.ts`, `src/index.ts` | +555 / -5 |
1340
- | Hook server / control server | `src/control/server.ts` (+3,092 / -348), **new** `src/control/pin-guard.ts` (+547), `src/control/transcript.ts`, `src/control/client.ts`, `src/control/correlation.ts` | +4,082 / -462 |
1341
- | Background recorder lifecycle | `src/control/daemon.ts` (+1,274 / -110), **new** `src/control/recorder-main.ts` (+847), **new** `src/control/recorder-keeper.ts` (+805), `src/control/discovery.ts`, `src/control/ensure-hook.ts`, `src/control/paths.ts`, `src/bin/bir-hooks.ts` | +3,523 / -450 |
1342
- | bir-proxy (stdio) | `src/proxy/session.ts` (+718 / -101), **new** `src/proxy/revive.ts` (+270), `src/proxy/intercept.ts`, `src/proxy/relay.ts`, `src/jsonrpc/framing.ts`, `src/upstream/stdio-client.ts`, `src/upstream/client.ts`, `src/upstream/lazy-client.ts`, `src/bin/bir-proxy.ts` | +2,214 / -294 |
1343
- | Remote http/sse upstreams | `src/upstream/http-client.ts` (+1,135 / -161), **new** `src/upstream/http-transport.ts` (+453), **new** `src/upstream/sse.ts` (+201), **new** `src/upstream/headers.ts` (+97) | +1,886 / -161 |
1344
- | Recording, auth and privacy | `src/record/remote-recorder.ts` (+1,582 / -204), `src/record/redact.ts` (+671 / -36), **new** `src/record/switchable-recorder.ts` (+391), `src/record/truncate.ts`, `src/record/recorder.ts`, `src/auth/client.ts` | +3,136 / -307 |
1345
- | Calculated replay | `src/replay/controller.ts` (+1,440 / -122), `src/replay/plan.ts` (+673 / -175), **new** `src/replay/safety.ts` (+304), **new** `src/replay/bash-channel.ts` (+137), `logic.ts`, `executor.ts`, `handover.ts`, `derive.ts`, `source-run.ts`, `targets.ts`, `tool-error.ts`, `types.ts`, `bundle.ts` | +3,243 / -449 |
1346
- | CLI, install and config | `src/bin/bir.ts` (+3,067 / -414), `src/config/generate.ts` (+822 / -30), **new** `src/config/mcp-approval.ts` (+225), **new** `src/config/overrides.ts` (+174), **new** `src/config/node-path.ts` (+114), **new** `src/bin/service-read.ts` (+143), `src/config/adapters/claude-code.ts`, `src/config/adapters/generic.ts`, `src/config/resolve.ts`, `src/bin/setup.ts`, `src/bin/investigate.ts`, `src/bin/scenario-edit.ts`, `scripts/install.sh`, `scripts/install.ps1` | +5,490 / -632 |
1347
- | The `bir` MCP server | `src/bin/bir-scenario.ts` (+631 / -130), **new** `src/bin/scenario-liveness.ts` (+488), **new** `src/bin/scenario-stderr.ts` (+82) | +1,201 / -130 |
1348
- | Tests | 32 new test files; 13 existing test files, `test/smoke-replay.mjs` and `test/quiet.env` changed | +21,234 / -157 |
1349
-
1350
- Git shows `src/control/correlation.ts` as binary because it contains two literal NUL bytes, used as a hash separator; 0.2.12 has them too. Review its diff with `git diff --text`.
1351
-
1352
- **New test files, by component.**
1353
-
1354
- - Shared foundations: `production-base`.
1355
- - Hook server: `prod-server`, `prod-server-b`, `prod-server-c`, `prod-server-transcript`, `integ-server`, `final-server`.
1356
- - Recorder lifecycle: `prod-daemon-process`, `prod-daemon-recorder`, `integ-daemon`, `final-daemon-process`, `final-daemon-units`.
1357
- - Proxy: `prod-proxy`, `prod-proxy-process`, `integ-proxy`, `integ-proxy-process`, `final-proxy`.
1358
- - Remote upstreams: `prod-http`.
1359
- - Recording: `prod-record`, `prod-record-review`, `final-record`.
1360
- - Replay: `prod-replay`, `integ-replay`, `final-replay`.
1361
- - CLI: `prod-cli-install`, `prod-cli-commands`, `prod-cli-overrides`, `integ-cli`, `final-cli`.
1362
- - `bir` MCP server: `prod-scenario`, `integ-scenario`, `final-scenario`.
1363
-
1364
- All are under `test/` with the `.test.ts` suffix.
1365
-
1366
- **Existing tests changed on purpose**, each to follow a behaviour change in §7:
1367
-
1368
- - `bir-scenario`, `calls`, `cli`, `config`, `correlation`, `fallback`, `http`, `probe`, `recorder`, `replay-server`, `replay`, `segments`, `setup`;
1369
- - `smoke-replay.mjs`.
1370
-
1371
- None weakens an existing assertion except where the behaviour itself changed. Examples: the default tool list is now `["run_scenario"]`, and wrapped MCP calls no longer get `allow`.
1
+ # @basein/runner: production readiness
2
+
3
+ This document is for the people who release, operate and support @basein/runner: the recording MCP proxy (`bir-proxy`), the Claude Code hook recorder (`bir-hooks`), the `bir` CLI, the first-party `bir` MCP server (`bir-scenario`) and calculated replay, all recording to the hosted BaseIn service at `api.bi2202.com`. It says what "production ready" now guarantees, what changed since 0.2.12, how to operate and release the runner, and what is still open.
4
+
5
+ ## 1. Status
6
+
7
+ The base is 0.2.12 (commit `9235953`). The release candidate is the change set this document ships with, versioned 0.3.0 in `package.json`. Automated verification is complete on the Windows development machine: 1,039 tests (1,033 pass, 0 fail, 6 skipped) and the real-process smoke run (`npm run test:smoke`) pass. The six skips are POSIX-only, Git-Bash or symlink-privilege tests that run on the Linux and macOS CI jobs. Still to do before release: the CI matrix and the end-to-end checks with the real Claude Code in §10.
8
+
9
+ **Versioned 0.3.0, not 0.2.13.** Defaults changed in ways users and headless fleets will notice. Wrapped MCP calls are no longer auto-approved. Wrapped entries move to Claude Code's local scope. Hook timeouts changed, and replay no longer serves recorded outputs. Going back to 0.2.12 also needs a step first (§7.2). A minor-version bump tells people to read the release notes.
10
+
11
+ ---
12
+
13
+ ## 2. Summary
14
+
15
+ ### 2.1 The requirement
16
+
17
+ > "the most important thing is that the users will not have latency, broken experience, errors, things not working, because of using the runner/basic2202."
18
+
19
+ In practice: the runner must never be the reason a Claude Code session is slow, shows an error, loses a working MCP server, or asks or skips a permission question it would not otherwise have. Where the runner cannot do its job (the service is down, the user is signed out, the recorder died), it steps aside silently and says so in the audit log, `bir doctor`, `bir status` and `/health`. Everything in this document is organised around that.
20
+
21
+ ### 2.2 What is now guaranteed
22
+
23
+ 1. **No tool call waits on the BaseIn service.** PreToolUse, PostToolUse, Stop, SessionEnd and the subagent hooks never await the network. The one exception is a replay plan that the service already matched for this turn: its steps may run inside PreToolUse, under a 9 s budget.
24
+ 2. **A prompt waits on the service at most once per incident, and never when replay is off.** With replay on, UserPromptSubmit waits up to 3 s for the similar-prompt match. It does not wait at all while the service is failing: a process-wide breaker opens for 5 s after a failure, doubling to 60 s. It also does not wait while the service is slow: a match slower than 2 s stops prompts waiting for 5 minutes, doubling to 30 minutes.
25
+ 3. **Every hook answers inside a hard deadline below its Claude Code timeout.** It answers `200 {}` for anything it cannot use: a stale token, an unknown event, an oversize or malformed body, or the runner switched off. While the recorder is running, no runner-side condition produces a hook error.
26
+ 4. **The recorder never waits on the service to start.** It listens in about 150 ms and signs in in the background. The SessionStart hook is capped at 11.5 s, always exits 0 and prints nothing into the session.
27
+ 5. **No silent approvals.** Wrapped MCP calls get the user's own permission prompts. A `.mcp.json` server is routed through the runner only after Claude Code has approved it. Plan mode never gets `allow`. The runner answers `allow` only in three cases: a scenario step the model was told about, a `bypassPermissions` session, or an explicit opt-in.
28
+ 6. **A wrapped server fails the way an unwrapped one does.** If the server dies, `bir-proxy` exits with its status. Answers up to 128 MiB are relayed, and a larger one ends the call with a clear tool error instead of a hang. Remote servers that Claude Code signs in itself are left unwrapped.
29
+ 7. **The runner can be taken out of the way at once.** `bir off` (every project), `bir off --here` (one project) or `BIR_OFF=1` (one session) makes hooks answer `{}` within about a second. Nothing is uninstalled.
30
+ 8. **Nothing the runner writes can tear a file Claude Code reads.** Every configuration and state file is replaced atomically. The team's `.mcp.json` and the user scope are never rewritten. Uninstall repairs entry by entry, and `bir uninstall --all` removes the runner from every project.
31
+ 9. **Secrets stay local.** Uploads are redacted and size-capped, and so are derivation prompts. `credentials.json` is mode 0600. Stashed originals keep no header or env values.
32
+ 10. **A recorder that dies mid-session comes back by itself.** The session's own proxies and `bir-scenario` restart it, except after a deliberate `bir down`.
33
+
34
+ ### 2.3 Headline numbers
35
+
36
+ | What | 0.2.12 | Now |
37
+ |---|---|---|
38
+ | Service hung (accepts, never answers): each prompt | 2,510 ms on every prompt | 1,508-1,518 ms on the first prompt of the outage only |
39
+ | Service hung: each tool call (PreToolUse) | 4,010 ms on every call | 1.5-1.7 ms median* |
40
+ | Service answering every request in 1.0 s: 2-minute run, one turn every 8 s (92 hooks) | every prompt about 1,020 ms | 1 hook over 100 ms |
41
+ | First prompt of an outage (refused, 503, 503 + Retry-After) | 5-23 ms | 12-26 ms |
42
+ | Recorder start while the service hangs | not listening for about 304 s, so every hook is a hook error | listening in 150 ms* |
43
+ | SessionEnd while the service hangs | Claude Code timeout (> 30 s), 5 of 5 | about 0.8 ms* |
44
+ | Wrapped MCP call in default permission mode | answered `allow` with rewritten arguments, 10 of 10 | no decision: the user's own rules apply* |
45
+ | 33 MB tool result through the proxy | never answered, 5 of 5 | answered in about 2.4 s (2.26 s direct)* |
46
+
47
+ \* Measured on the build before the final review's fixes. Those fixes targeted the prompt path, which was re-measured (§5.3).
48
+
49
+ ### 2.4 Still open (details in §11)
50
+
51
+ - **Healthy-service latency.** With replay on, a healthy service still costs every prompt one create-and-match round trip: 288-464 ms from the development machine. Removing it needs a service-side "this user or project has no scenarios" hint.
52
+ - **Hang outages.** The first prompt of a hang outage waits the full 3 s match budget.
53
+ - **Scenario logic isolation.** Scenario logic runs inside the recorder process with a time limit, but with no sandbox and no memory limit.
54
+ - **Version-manager Node paths.** A Node path from nvm, fnm, Volta or asdf breaks the entries after a Node change. `bir doctor` flags it and `bir setup` repairs it.
55
+ - **Stale local copies.** A local-scope copy goes stale after `.mcp.json` is edited, until `bir setup` runs. `bir doctor` flags it.
56
+ - **Corporate proxies.** Proxy support depends on the Node version and on NO_PROXY exempting 127.0.0.1.
57
+ - **Unparseable stdio answers.** An unparseable stdout answer line from a stdio server still hangs that one call, as it would unwrapped.
58
+
59
+ ### 2.5 Must happen before release (details in §10)
60
+
61
+ - CI green on ubuntu, windows and macos with Node 20, and on ubuntu with Node 24.
62
+ - Release notes for 0.3.0 built from §7 (the version is already 0.3.0).
63
+ - README and `docs/` matching this behaviour.
64
+ - The service's served install scripts updated.
65
+ - The manual end-to-end checks with the real Claude Code.
66
+ - The service team told about the id-deduplication and "no scenarios" asks.
67
+
68
+ ---
69
+
70
+ ## 3. The production contract
71
+
72
+ ### 3.1 Principles
73
+
74
+ **Fail open.** Every failure path ends in "as if the runner were not there":
75
+
76
+ - Hook routes answer `200 {}`. The route catch-all logs `control.route_failed`.
77
+ - The SessionStart hook exits 0 and prints nothing.
78
+ - `bir-proxy` relays the server's own answers verbatim and exits the way the server did.
79
+ - `run_scenario` answers a neutral "do the task normally" text, never `isError`.
80
+ - Process-level `uncaughtException` and `unhandledRejection` handlers log and keep serving in `bir-hooks`, `bir-proxy` and `bir-scenario`.
81
+
82
+ **Fail fast.**
83
+
84
+ - Every service request has a deadline (`BIR_SERVICE_TIMEOUT_MS`, 10 s; probes 5 s).
85
+ - Every hot path asks the process-wide breaker (`src/util/service-health.ts`) before it waits, and waits only inside a budget.
86
+ - The breaker opens on the first failure: a network error, a timeout, a 5xx, 408 or 429, or a body that is not the API's, such as an HTML page. It stays open for 5 s, doubling per further failure up to 60 s, and one real answer closes it. A late answer to a request that was already in flight when a hook gave up does not close it: that is the slowness arriving, not a recovery.
87
+ - The prompt match gate adds a second rule for a service that answers, but slowly (§3.3).
88
+
89
+ **Degrade silently, to the audit log.** A failure the user cannot act on is never shown in the session. It is logged, rate-limited (one line per transition, not per step), and visible in `bir doctor`, `bir status` and `/health`. Proxy stderr is quiet by default, because Claude Code files every stderr line of an MCP server as an error in its MCP log.
90
+
91
+ **No silent auto-approval.**
92
+
93
+ - By default the runner never answers `allow` for an ordinary wrapped MCP call. It joins the proxy's report to the hook by argument fingerprint instead.
94
+ - It answers `allow` only in three cases:
95
+ - a steered step of a scenario the model was told about in this turn, and only for a call that plausibly *is* that step (the pin guard, `src/control/pin-guard.ts`);
96
+ - `run_scenario` when a direct plan was announced;
97
+ - a `bypassPermissions` session, where `allow` changes nothing the user is asked.
98
+ - It never answers `allow` in plan mode.
99
+ - It never copies a `.mcp.json` server into the local scope unless Claude Code already approved it, because local-scope servers start without Claude Code's approval question.
100
+
101
+ **No runner internals in the session.**
102
+
103
+ - SessionStart prints nothing.
104
+ - Hooks answer `{}` unless they are delivering a replay directive or hand-over note.
105
+ - The `bir` MCP server lists only `run_scenario`, whose description says to call it only when told.
106
+ - Timestamped audit lines never reach the model.
107
+ - The one visible trace is the reserved `__bir_call_id__` property ("Reserved. Leave unset.") in wrapped tools' schemas while a recorder may inject ids (accepted, §11).
108
+
109
+ **One switch takes it out of the way.**
110
+
111
+ - `bir off` / `bir off --here` / `BIR_OFF=1` (§8.1): hooks answer `{}`, proxies stop recording and start as plain pipes, and `bir-scenario` lists no tools.
112
+ - No service call is made, and nothing is uninstalled.
113
+ - The recorder deliberately stays up while off, so hooks get an instant `{}` rather than a refused connection, which Claude Code would show as a hook error.
114
+
115
+ ### 3.2 Hook routes
116
+
117
+ "May wait on" is everything the route can await. "Worst case" is the most the route itself can take before its deadline guard answers. At the deadline the route answers `{}` (on PreToolUse mid-replay, an honest hand-over note) and the rest finishes in the background, logged as `control.hook_deadline`. Claude Code waits for a hung hook up to its timeout and then shows a hook error. The server's deadlines therefore sit well inside the timeouts `bir install` writes, which are the second line of defence, not a budget.
118
+
119
+ | Hook (route) | May wait on | Normal cost | Worst case | Server deadline | Timeout `bir install` writes (0.2.12) |
120
+ |---|---|---|---|---|---|
121
+ | SessionStart: command hook `bir-hooks ensure` | the payload on stdin (≤ 2 s); finding or starting the recorder (local only, never the service); relaying the payload (≤ 2.5 s); a read-only stale-copy check (only with ≥ 3 s left) | warm about 0.1 s (Node start-up); cold about 0.7 s on Windows, once per project per boot | 11.5 s (11 s budget plus a 0.5 s hard exit). npx installs pass `--budget-ms 8000`, after npx's own start-up | `/session/start`: 2 s | 15 s (30) |
122
+ | SessionStart: HTTP form (older installs only; `bir install` now always writes the command hook, and `bir setup` replaces the HTTP one) | nothing | milliseconds | 2 s | 2 s | 5 s (10) |
123
+ | UserPromptSubmit | replay on: the similar-prompt match (≤ 3 s; skipped while the breaker is open or the match gate holds). For a matched scenario: arming, derivation (≤ 4 s) and the directive's argument preview (≤ 1.2 s), all inside 6 s. Replay off: nothing | about 17 ms with an instant local service; plus one service round trip (about 0.3-0.5 s) with replay on | 6 s, only for a matched scenario that must derive; 3 s on the first prompt of a hang | 7 s | 10 s (15) |
124
+ | PreToolUse | nothing, unless a replay plan steers this turn. Then: in-hook steps (≤ 9 s, `BIR_PRETOOL_REPLAY_BUDGET_MS`), derivation at the first call (≤ 4 s), the previous pinned step (≤ 1.75 s), and after a hand-over the new fragment run's create (≤ 0.3 s, breaker-gated). Intent arming only when opted in (≤ 0.5 s probe + 1.5 s arming) | 2-5 ms | 12 s, replay only | 12 s | 15 s (30) |
125
+ | PostToolUse, PostToolUseFailure | nothing: the proxy's report is joined in the background | 1-2 ms | 2 s | 2 s | 5 s (30) |
126
+ | SubagentStart, SubagentStop | nothing | not measured (bookkeeping only) | 2 s | 2 s | 3 s (5) |
127
+ | Stop, StopFailure | nothing: seals the run, and the upload drains in the background | 2-3 ms | 2 s | 2 s | 5 s (10) |
128
+ | SessionEnd | nothing: seals the run, background drain | about 1 ms (0.2.12: 0.5 s healthy, over 30 s hung) | 1 s | 1 s | 3 s (30) |
129
+
130
+ Hard deadlines are in `HOOK_DEADLINE_MS` (`src/control/server.ts`). The timeouts are in `HOOK_ROUTES` and `ENSURE_TIMEOUT_S` (`src/config/adapters/claude-code.ts`). Re-running `bir setup` rewrites the timeouts of an existing install.
131
+
132
+ ### 3.3 Failure modes
133
+
134
+ | Condition | What the user sees | What the runner does | Where it shows |
135
+ |---|---|---|---|
136
+ | Service unreachable, 5xx, 429 or an HTML page (captive portal, WAF, CDN) | Nothing. The first prompt of the outage takes 12-26 ms. | The first failure opens the breaker, and hooks stop waiting. Steps queue in the background: at most 500 sends or 32 MiB, oldest dropped. A send still facing a cool-down after 60 s is dropped unsent. The runner retries once, only for 429/503 or a connection that never reached the service. Recording resumes by itself. | `bir status` "Service : not answering"; doctor warning; `/health.service`, `/health.sending`; `service.degraded` / `service.recovered`, `recorder.backlog_full`, `recorder.run_incomplete`, `recorder.run_abandoned reason=service_down` |
137
+ | Service hung (accepts, never answers) | One prompt per outage waits 3 s. Nothing else waits. | The match overrun marks the service slow, which opens the breaker and the match gate. Background requests time out at 10 s. | `/health.service`, `/health.matchGate`; `replay.decision code=match_timeout`, `replay.match_gate state=holding` |
138
+ | Service slow but answering (match over 2 s) | One prompt waits about the service's time, then none for 5 min. The hold doubles to 30 min while the service stays slow. | Match gate: prompts stop waiting for the match. It is still asked in the background, so a hit still stops that turn's duplicate recording. Nothing is steered meanwhile. A fast match ends the hold. | `/health.matchGate {holding, holdMs, until}`; `replay.match_gate`; `replay.decision code=match_slow` (verbose) |
139
+ | Sign-in refused (the service's own JSON 400/401 to a refresh) | Nothing | `credentials.json` is re-read once. Recording then pauses (`auth_rejected`) and is retried in the background; `bir login` resumes it at once. An HTML or non-JSON 4xx counts as "unreachable", never as a sign-out. | `/health.recording=false`, `/health.sending.state`; doctor warning "nowhere to send steps"; `auth.refresh_refused`, `recorder.paused` |
140
+ | Signed out, or no service configured | Nothing. Hooks take about 1-3 ms. | No network calls. After `bir login` or `bir setup`, the running recorder records from the next prompt with no restart. After `bir logout`, it stops sending within about 2 s. | `/health.recording=false`; doctor warning; `recorder.signed_out`, `recorder.enabled` |
141
+ | Recorder killed mid-session | "hook error" lines on prompts and tool calls; the session keeps working | Once the pid is dead and the port refuses connections, the session's bound proxies and `bir-scenario` launch `bir-hooks revive` (detached). This never happens after `bir down`, and not in `bir-scenario`'s first 12 s. The next SessionStart (`/clear`, a new session) also starts the recorder. | doctor; `proxy.recorder_revive`, `scenario.recorder_revive` (MCP log); `daemon.revived` (recorder log) |
142
+ | A 0.2.12 recorder still running after the upgrade | 0.2.12 behaviour continues: auto-approval, waits on the service, ignores `bir off` | Replaced at the next SessionStart when no session is mid-run. `bir setup`, `bir up` and `bir off` probe it twice, 2.5 s apart, and replace it when it is idle. | doctor warning "the recorder running here is 0.2.12"; `bir status`, and `bir off` names it; `daemon.restart` |
143
+ | Hooks from another install (stale token, unknown event), non-POST, over 64 MB or malformed body | Nothing | Answers `200 {}` unprocessed. The project's current token from `installed.json` is accepted within about 3 s, so a same-port reinstall needs no restart. | `/health.hooks {unauthorized, unknownRoute, oversize}`; `control.hook_unauthorized`, `control.hook_unknown`, `control.hook_oversize` |
144
+ | A route runs past its deadline | At most the deadline | Answers `{}` (or a hand-over note) and finishes in the background. A plan that was not announced in time is retired and never runs. | `/health.hooks.overran`; `control.hook_deadline`, `replay.pin_overran`, `replay.diverge_overran`, `replay.decision code=arm_timeout` |
145
+ | Runner switched off | Nothing: no recording, no replay | See §8.1. | `bir status` "Runner : OFF"; doctor note; `/health.off`, `/health.hooks.off`, `/health.offSessions` |
146
+ | Wrapped stdio server dies or fails to start | As without the runner: the server shows as failed in `/mcp`, with a reconnect offered | `bir-proxy` answers the calls in flight, then exits with the server's exit status (1 if it had none). `BIR_PROXY_STAY_UP=1` restores the old behaviour. | `proxy.upstream_gone` (Claude Code's MCP log for that server) |
147
+ | Tool result over 128 MiB | The call ends with the tool error "The result was too large to deliver (N bytes; the limit is M bytes)" | The frame is discarded without being buffered. A remote upstream gives "upstream answer too large". An oversize notification is dropped; an oversize server request is refused with -32600. | MCP log |
148
+ | Remote server that Claude Code signs in (OAuth, headersHelper, `${VAR}` headers, no credential header) | Nothing: it runs unwrapped | Not wrapped; the hooks still record its calls. If wrapped anyway with `--server`, a 401 reads "upstream requires sign-in (HTTP 401)". | doctor PROBLEM (wrapped without credentials) or warning (`--server`); `upstream.auth_required` |
149
+ | Remote server forgets the session (404) or drops its stream | Nothing | One transparent re-initialise and a single retry. Legacy SSE reconnects with backoff (0.5 s doubling to 30 s) and re-initialises. | `upstream.session_expired`, `upstream.session_reinitialized` (verbose once routine), `upstream.standing_stream_failed` (at most once an hour per server) |
150
+ | Corporate proxy (HTTPS_PROXY) | Works when NO_PROXY exempts 127.0.0.1 and Node is new enough | `--use-env-proxy` is carried into entries, hooks and launchers only when NO_PROXY exempts 127.0.0.1; otherwise install prints the NO_PROXY hint. Remote upstreams honour the proxy on Node 24.5+ and 22.21+. Loopback always connects directly. A malformed proxy variable is ignored. | install output; `upstream.proxy_env_ignored`, `upstream.proxy_env_invalid` |
151
+ | Windows | A cold SessionStart costs about 0.7 s, once per project per boot; no console windows | See the Windows notes after this table. | none |
152
+ | Project port held by another program or reserved by the system | Hook errors until the port changes | The recorder records the holder in `control/<key>.port.json` with the fix (`bir setup --port <n>`, ideally below 49152), and new sessions use the new port. | doctor PROBLEM, `bir status` "Port :", `control.port_busy` |
153
+ | A replay step fails, times out, or its server refuses it | The model reads a `[BaseInstRunner calculated replay]` hand-over note and continues the task itself | The plan stops at that step and names the steps that ran. A timed-out step "may or may not have completed". Nothing after it runs. | `replay.handover`; `bir investigate` |
154
+ | `bir` scenario server not connected | Nothing | Direct plans are declined (`no_delivery_tool`). A direct plan whose first model call is not `run_scenario` is retired; nothing is executed. | `/health.deliveryTool`, `/health.scenarioServers` |
155
+ | `.mcp.json` edited after install | The server keeps its old configuration until `bir setup`, because the local copy outranks `.mcp.json` | SessionStart logs `overrides.stale` and does not rewrite anything. | doctor PROBLEM; `bir status` STALE |
156
+
157
+ **Windows notes.**
158
+
159
+ - The recorder starts through a hidden PowerShell `Start-Process`, for handle-inheritance safety. `BIR_WINDOWS_LAUNCH=spawn` opts into a faster direct spawn.
160
+ - `.cmd` servers get cmd.exe-safe quoting, so `%VAR%` and trailing backslashes arrive intact.
161
+ - PATH, PATHEXT and ComSpec are read case-insensitively.
162
+ - On shutdown, the proxy kills the server's whole process tree.
163
+ - MCP entries use plain `npx`.
164
+ - The login link opens with `rundll32`, never `cmd /c start`, and only when it is https.
165
+
166
+ ---
167
+
168
+ ## 4. How this was done
169
+
170
+ 1. **Audit.** Nine areas were audited:
171
+ - the stdio proxy;
172
+ - remote (http/sse) upstreams;
173
+ - the hook server;
174
+ - the recorder (daemon) lifecycle;
175
+ - recording and auth;
176
+ - calculated replay;
177
+ - CLI and install;
178
+ - the `bir` MCP server;
179
+ - an empirical area that measured latency and failure behaviour against a local fake service, before any change.
180
+
181
+ It produced 143 findings. Independent adversarial verifiers confirmed 142 of them and found 19 more, for 162 in total: 11 critical, 42 high, 72 medium, 37 low.
182
+ 2. **Fix.** Fixes were made per component, each owning its own files. The places where components meet were fixed as explicit contracts. The contracts are:
183
+ - one process-wide service-health breaker (`src/util/service-health.ts`);
184
+ - one off switch (`src/util/off-switch.ts`);
185
+ - one atomic writer (`src/util/atomic-write.ts`);
186
+ - a live token getter;
187
+ - an abort signal from `/scenario/run` into the replay run.
188
+
189
+ Each component's fixes were reviewed adversarially, fixed again, and then integrated.
190
+ 3. **Whole-system review.** A review of the integrated build used six lenses:
191
+ - invisibility under failure;
192
+ - upgrade from 0.2.12;
193
+ - security and privacy;
194
+ - replay correctness;
195
+ - platform;
196
+ - lifecycle.
197
+
198
+ It confirmed 45 more findings: 2 high (both introduced by the change, and fixed), 14 medium and 29 low. It also measured the build before and after against 0.2.12, which found 3 problems on the prompt path. All of these were fixed, and the prompt path was re-measured.
199
+ 4. **Result.** 207 findings: 193 fixed, 12 partially fixed, 1 not fixed and 1 accepted. Appendix A lists them all; §11 explains everything that is not fully fixed.
200
+
201
+ **What "verified" means here.**
202
+
203
+ - A finding counts only after a second reviewer reproduced it, or tried and failed to refute it. The evidence is a test, a measurement, or code reading with line references. Two examples: the PostToolUseFailure event-name finding was proven against Claude Code's own hook-validation code, and the latency findings were measured against real processes.
204
+ - A fix counts only when both hold: tests that fail without it pass, and an adversarial reviewer could not break it.
205
+ - The suite grew from 449 tests (447 pass, 2 skipped) to 1,039 (1,033 pass, 0 fail, 6 skipped). The real-process smoke run, which exercises calculated replay end to end, passes.
206
+
207
+ **What the audit found already solid, and kept.** Nothing on the MCP call path awaits the network: relay overhead is +0.08 ms (Tier 2) and +0.26 ms (Tier 1) per call, median. Proxy stdout carried 0 non-JSON bytes in every run. `__bir_call_id__` never reaches an upstream in any tier. Killing the control server never breaks a tool call. Hooks bind 127.0.0.1 with a per-project bearer token. The SessionStart hook uses exec form, so paths with spaces are safe. Signed-out installs make no network calls. Install refuses a malformed config before writing anything. The install scripts run nothing from a truncated download and check the private Node's digest.
208
+
209
+ ---
210
+
211
+ ## 5. Measured results
212
+
213
+ ### 5.1 Headline
214
+
215
+ | Scenario | 0.2.12 | Release candidate |
216
+ |---|---|---|
217
+ | First UserPromptSubmit of an outage: connection refused | 5-6 ms | 12-14 ms |
218
+ | First UserPromptSubmit of an outage: 503 | 8-12 ms | 17-20 ms |
219
+ | First UserPromptSubmit of an outage: 503 with Retry-After | 9-23 ms | 17-26 ms |
220
+ | Service hung: prompts | 2,510 ms, every prompt | 1,508-1,518 ms, once per outage |
221
+ | Service hung: PreToolUse (Bash / MCP / Read) | 4,016 / 4,012 / 4,008 ms, every call | 1.7 / 1.5 / 1.6 ms median* |
222
+ | 2-minute run, one turn every 8 s (92 hooks): service hung | every prompt and tool call waits | 1 hook over 100 ms (the first prompt) |
223
+ | 2-minute run, one turn every 8 s (92 hooks): service answering in 1.0 s | every prompt about 1,020 ms | 1 hook over 100 ms |
224
+ | Service slow 1 s, replay off: UserPromptSubmit | 1,026 ms median | 2.4 ms median* |
225
+ | SessionEnd / prompt after an interrupted turn, service hung | Claude Code timeout, 5 of 5 (> 30 s / > 15 s) | 0.8 / 5.1 ms median* |
226
+ | Recorder time to listen, service hung | about 304 s (every hook refused meanwhile) | 150 ms* |
227
+ | SessionStart ensure, cold, service hung | 15.7 s, and 6 recorders after 6 SessionStarts | 0.76 s, 1 recorder* |
228
+ | Proxy tools/call round trip, small result (Tier 1 / Tier 2; direct 0.05 ms) | 0.23 / 0.14 ms median | 0.30 / 0.12 ms median* |
229
+ | Proxy re-binds after the recorder restarts on the same port | never (> 45 s, 5 of 5) | 10.6 s median* |
230
+ | Healthy instant local service: UserPromptSubmit | 10.5 ms median | 17.8 ms median* (worse, still far under 100 ms) |
231
+
232
+ \* Measured on the build before the final review's fixes (§5.2); not re-measured on the release candidate.
233
+
234
+ ### 5.2 Full before/after table
235
+
236
+ The table below compares 0.2.12 with the build *before* the final review's fixes. Three of its rows were "worse" on the first prompt of an outage: connection refused, 503, and 503 with Retry-After. Its hung-service rows also show the 2.5 s first-prompt wait. These were fixed and re-measured on the release candidate (§5.3). The other rows cover paths the final fixes did not target, and were not re-measured.
237
+
238
+ | Scenario | Metric | 0.2.12 | After | Verdict |
239
+ |---|---|---|---|---|
240
+ | Hooks, healthy local fake service | UserPromptSubmit ms, median / worst (n=10; repeat runs n=20 x2) | 10.5 / 16.8 (repeats 10.9 / 19.2, 10.6 / 14.4) | 17.8 / 21.0 (repeats 16.2 / 20.6, 17.0 / 22.4) | worse (+6 ms, still under 100 ms) |
241
+ | Hooks, healthy local fake service | PreToolUse Bash / PreToolUse wrapped MCP ms, median / worst (n=15 / 10) | 15.4 / 20.4 ; 5.0 / 13.9 | 2.5 / 4.9 ; 2.2 / 4.6 | improved |
242
+ | Hooks, healthy local fake service | PostToolUse / Stop ms, median / worst | 1.5 / 2.4 ; 3.8 / 4.1 | 1.7 / 2.3 ; 2.3 / 3.5 | same |
243
+ | Hooks, healthy local fake service | SessionEnd / prompt after an interrupted turn ms, median / worst (n=5 each) | 510 / 513 ; 533 / 533 | 1.3 / 1.5 ; 17.6 / 19.1 | improved |
244
+ | Hooks, service slow 1 s (replay on) | UserPromptSubmit ms, median / worst | 1021 / 1035 | 1027 / 1037 (23 of 23 prompts over 3 min at 1009-1035) | same (still adds 1 s to every prompt) |
245
+ | Hooks, service slow 1 s (replay on) | PreToolUse Bash / MCP ms, median / worst | 1018 / 1031 ; 1017 / 1025 | 3.6 / 5.6 ; 2.5 / 3.3 | improved |
246
+ | Hooks, service slow 1 s (replay on) | PostToolUse / Stop / SessionEnd / prompt after interrupt ms, median / worst | 2.6 / 3.6 ; 3.9 / 5.6 ; 4072 / 4081 ; 3564 / 3585 | 1.5 / 2.3 ; 1.9 / 2.7 ; 1.05 / 1.3 ; 1027 / 1033 | improved |
247
+ | Hooks, service slow 1 s, BIR_REPLAY=0 | UserPromptSubmit / SessionEnd / prompt after interrupt ms, median / worst | 1026 / 1033 ; 4087 / 4098 ; 4584 / 4590 | 2.4 / 4.3 ; 0.9 / 1.3 ; 3.3 / 7.6 | improved |
248
+ | Hooks, service hung (accept, never answer), replay on | any hook while the recorder starts | recorder not listening for 304 s: every hook gets ECONNREFUSED (a hook error) in 5-7 ms | listening in 150 ms; hooks answer 200 {} | improved |
249
+ | Hooks, service hung (BEFORE: hang-but-/health, since full hang never listens; AFTER: both) | UserPromptSubmit ms, median / worst | 2509 / 2520 (every prompt; 18 of 18 over 8 min) | 2.4 / 2517 (only the first prompt of the outage: 1 of 140 hooks in 3 min, 1 of 32 with 90 s gaps) | improved |
250
+ | Hooks, service hung | PreToolUse Bash / MCP / Read ms, median / worst | 4016 / 4022 ; 4012 / 4018 ; 4008 / 4017 | 1.7 / 8.3 ; 1.5 / 2.4 ; 1.6 / 2.2 (full hang: 1.8 / 4.8 ; 1.5 / 3.1) | improved |
251
+ | Hooks, service hung | PostToolUse / Stop ms, median / worst | 2.2 / 4.4 ; 4.5 / 8.7 | 0.9 / 2.5 ; 1.7 / 2.7 | same |
252
+ | Hooks, service hung | SessionEnd / prompt after interrupt ms, median / worst (n=5) | host timeout 5 of 5 (> 30 s) ; host timeout 5 of 5 (> 15 s) | 0.83 / 0.9 ; 5.1 / 6.8 | improved |
253
+ | Hooks, service hung, BIR_REPLAY=0 | UserPromptSubmit / SessionEnd / prompt after interrupt ms, median / worst | 2515 / 2518 ; timeout 5 of 5 (> 30 s) ; timeout 5 of 5 (> 15 s) | 2.5 / 4.6 ; 0.98 / 1.1 ; 3.8 / 4.2 (full hang: 2.2 / 4.6 ; 0.86 / 1.1 ; 5.0 / 7.1) | improved |
254
+ | Hooks, service 503 (immediate) | UserPromptSubmit ms, median / worst ; first prompt of the outage (5 fresh recorders) | 11.9 / 17.4 ; 11 / 12 | 2.7 / 381 ; 503 / 666 | worse (first prompt of the outage) |
255
+ | Hooks, service 503 with Retry-After: 2 | first UserPromptSubmit of the outage ms, median / worst (n=5) | 11 / 23 | 2046 / 2055 | worse |
256
+ | Hooks, service 503 | PreToolUse Bash / SessionEnd / prompt after interrupt ms, median / worst | 14.9 / 16.4 ; 508 / 522 ; 517 / 545 | 1.8 / 4.8 ; 0.88 / 1.2 ; 6.0 / 6.5 | improved |
257
+ | Hooks, service HTML 200 | UserPromptSubmit / PreToolUse Bash / SessionEnd / prompt after interrupt ms, median / worst | 10.8 / 14.8 ; 14.9 / 16.2 ; 509 / 520 ; 531 / 532 | 2.4 / 19.8 ; 1.9 / 3.8 ; 1.2 / 1.6 ; 7.6 / 8.1 | improved |
258
+ | Hooks, service connection refused | UserPromptSubmit ms, median / worst ; first prompt of the outage (5 fresh recorders) | 3.2 / 5.8 ; 6 / 6 | 2.5 / 767 ; 696 / 729 | worse (first prompt of the outage) |
259
+ | Hooks, service connection refused | PreToolUse Bash / SessionEnd / prompt after interrupt ms, median / worst | 3.1 / 5.6 ; 476 / 489 ; 490 / 491 | 1.9 / 5.8 ; 0.86 / 1.1 ; 6.4 / 9.5 | improved |
260
+ | Hooks, signed out | UserPromptSubmit / PreToolUse Bash / MCP / PostToolUse / Stop ms, median / worst | 2.0 / 3.6 ; 2.0 / 4.0 ; 1.5 / 2.1 ; 1.1 / 2.0 ; 2.9 / 3.8 | 2.6 / 4.0 ; 1.8 / 4.6 ; 1.5 / 2.2 ; 1.0 / 1.7 ; 2.1 / 3.3 | same |
261
+ | Hooks, signed out | SessionEnd / prompt after interrupt ms, median / worst | 488 / 490 ; 487 / 490 | 1.3 / 1.7 ; 6.7 / 7.1 | improved |
262
+ | Hooks, expired token + hung service (AFTER only; BEFORE did not listen for about 306 s in the audit) | time to listen / UserPromptSubmit / others ms | not measured (audit: about 306 s not listening) | 147 ; 2.4 / 2523 (first only) ; pre 1.9 / 8.8, end 0.9 / 1.0 | improved |
263
+ | bir-hooks start-up, service hung | time to listen ms, median / worst | 304,416 / 304,706 (n=6); a hook at +1 s and +5 s gets ECONNREFUSED | 150 / 156 (n=5); hooks at +1 s and +5 s get 200 {} | improved |
264
+ | bir-hooks start-up, signed out | time to listen ms, median / worst | 126 / 150 (n=4) | 148 / 154 (n=5) | same |
265
+ | SessionStart ensure, signed out | cold ms (n=5) ; warm ms (n=9), median / worst | 846 / 1376 ; 111 / 119 | 716 / 1395 ; 101 / 137 | same |
266
+ | SessionStart ensure, service hung | cold ms (n=5 + 2 reruns) ; warm ms (n=9 + 3), median / worst ; recorders alive | 15,671 / 15,702 ('did not answer within 15s', Tier 2) ; 15,677 / 21,812 ; 6 recorders after 6 SessionStarts | 758 / 1363 ; 100 / 119 ; 1 recorder | improved |
267
+ | SessionStart ensure, healthy service | cold ms (n=5) ; warm ms (n=9), median / worst | 833 / 935 ; 115 / 155 | 727 / 791 ; 106 / 116 | same |
268
+ | bir-proxy tools/call, small result (direct median 0.05 ms) | Tier 2 (--standalone, signed out) round trip ms, median / worst (n=300) ; added median | 0.14 / 0.47 ; +0.09 | 0.12 / 0.49 ; +0.07 | same |
269
+ | bir-proxy tools/call, small result | Tier 2 signed in (healthy service) ms, median / worst (n=300) | 0.24 / 2.08 | 0.24 / 2.41 | same |
270
+ | bir-proxy tools/call, small result | Tier 1 bound (signed out / signed in recorder) ms, median / worst (n=300) | 0.23 / 1.82 ; 0.30 / 1.99 | 0.30 / 1.74 ; 0.24 / 2.03 | same |
271
+ | bir-proxy 33 MB tools/call result (direct 2,256 / 2,289 ms) | answered? ms, median / worst (n=5, 20 s cap) | no answer 5 of 5, Tier 2 and Tier 1 ('line exceeded 33554432 bytes' in stderr only) | answered 5 of 5: Tier 2 2,394 / 2,471 ; Tier 1 2,387 / 2,523 | improved |
272
+ | bir-proxy 30 MB / 10 MB result (direct 1,878 / 234 ms) | Tier 2 ms, median (n=3) | 4,194 / 510 | 1,957 / 266 | improved |
273
+ | Control server killed mid-session (Tier 1 proxy) | first tools/call after the kill ms, median / worst (n=5) ; next 50 calls median / worst ; failures | 0.80 / 1.19 ; 0.18 / 0.59 ; 0 of 255 | 0.81 / 0.94 ; 0.19 / 0.75 ; 0 of 255 | same |
274
+ | Control server killed mid-session | while dead, per 20 s: connection attempts to the dead port ; proxy CPU s ; stderr lines | 80 ; 0.31 ; 77-89 | 7 ; 0.125 ; 3 | improved |
275
+ | Control server killed, then restarted on the same port | proxy re-binds (ms after the new recorder starts, n=5) ; steps recorded by the new recorder | never (> 45 s, 5 of 5) ; 0 | 10,584 median / 11,674 worst ; 2 of 2 | improved |
276
+ | Tier 2 proxy signed in, service hung, 2,000 calls of 30 KB | tools/call ms, median / worst ; failures | 0.45 / 4.36 ; 0 | 0.35 / 4.42 ; 0 | same |
277
+ | Tier 2 proxy signed in, service hung | proxy working set at 2,000 calls (hang ; hang-but-health) | 68 -> 176 MB ; 68 -> 173 MB (growing linearly) | 70 -> 220 MB ; 70 -> 218 MB (flat from about 1,500 calls) | worse at 2,000 calls |
278
+ | Tier 2 proxy signed in, service hung, 4,000 calls | proxy working set ; shutdown after host stdin end | 68 -> 269 MB, still growing ; 3,058 ms | 70 -> 243 MB, about 215-250 MB plateau ; 1,131 ms (2,539 ms at 2,000 calls vs 3,042) | improved (bounded) |
279
+ | User-visible: PreToolUse wrapped MCP, default permission mode (every service mode) | decision / rewrite in the hook answer | permissionDecision=allow + updatedInput with __bir_call_id__ on 10 of 10 calls in every mode (silent auto-approve + rewrite) | none: {callId, correlation:'fingerprint'} only (allow + id only under bypassPermissions) | improved |
280
+ | User-visible: hook errors in failure modes | host-visible errors / timeouts | hung: SessionEnd timed out 5 of 5 (30 s), prompt after interrupt timed out 5 of 5 (15 s); recorder start-up with hung service: ECONNREFUSED on every hook for 304 s; stale token 401, unknown route 404 | none in any mode (0 timeouts, 0 non-200); stale token and unknown route answer 200 {} | improved |
281
+ | User-visible: injected text | additionalContext / systemMessage / block decisions in failure modes; SessionStart stdout | none ; empty | none ; empty | same |
282
+
283
+ ### 5.3 Re-measurement after the final fixes
284
+
285
+ The final review found three prompt-path problems in the build above:
286
+
287
+ 1. **The first prompt of an outage waited for a background retry.** It waited 250-750 ms for the run-create's retry, or up to 2,055 ms behind a Retry-After.
288
+ 2. **A slow service that still answered cost every prompt its full latency.** The breaker closed on each late answer.
289
+ 3. **A hung service cost the full 2.5 s match budget once per outage.**
290
+
291
+ The fixes:
292
+
293
+ 1. The match now resolves "no match" as soon as the run-create's first attempt fails. A match found later by the retry is never delivered to a hook; it is logged as `recorder.matched_after_retry`.
294
+ 2. The late answer of a request sent before a hook gave up no longer closes the breaker. The match gate (800 ms, 5 min doubling to 30 min) stops prompts waiting on a slow service.
295
+ 3. The match budget went from 2.5 s to 1.5 s.
296
+
297
+ The release-candidate column was re-measured with a new build, isolated sandboxes and the local fake service. The other two columns come from the measurements in §5.2.
298
+
299
+ | First UserPromptSubmit of an outage | 0.2.12 | Build before the fixes | Release candidate |
300
+ |---|---|---|---|
301
+ | Connection refused | 5-6 ms | 346-729 ms | 12-14 ms |
302
+ | 503 | 8-12 ms | 473-666 ms | 17-20 ms |
303
+ | 503 with Retry-After: 2 | 9-23 ms | 2,034-2,055 ms | 17-26 ms |
304
+ | Hung (accepts, never answers) | 2,510 ms on every prompt, and 4,010 ms on every tool call | 2,512-2,524 ms once per outage | 1,508-1,518 ms once per outage |
305
+
306
+ Over 2-minute runs with one turn every 8 s (92 hooks):
307
+
308
+ - **Hung service:** 1 hook over 100 ms, the first prompt.
309
+ - **Service answering every request in 1.0 s:** 1 hook over 100 ms. 0.2.12 and the build before the fixes both took about 1,020 ms on every prompt.
310
+
311
+ **Against the real service, from the development machine:**
312
+
313
+ - A create-and-match round trip took 288-464 ms. A cold TLS connection adds about 225-265 ms.
314
+ - This is the per-prompt cost of replay on a healthy service, well under the 2 s gate.
315
+ - `BIR_REPLAY=0` or `bir replay off` removes it: the prompt then takes about 2.4 ms.
316
+
317
+ ### 5.4 Method
318
+
319
+ **Setup.**
320
+
321
+ - Both versions ran from their own built `dist/` on the same Windows 11 machine.
322
+ - Each version had a sandboxed home: `HOME`, `USERPROFILE`, `BIR_HOME` and `BIR_CLAUDE_JSON` were all sandboxed, and inherited `BIR_*` and `ANTHROPIC_*` variables were stripped.
323
+ - A local fake BaseIn service ran in these modes: healthy, slow 1 s, hung, hung but answering `/health`, 503, 503 with Retry-After, an HTML 200, connection refused, signed out, and an expired token with a hung service.
324
+ - The real service and the default port 53411 were never used.
325
+
326
+ **Harness.**
327
+
328
+ - Both versions got the same hook payloads, routes and fake upstreams.
329
+ - Each POST was capped at the hook timeout that version's `bir install` writes, plus 250 ms.
330
+ - Every answer was classified: permission decision, `updatedInput`, `additionalContext`, error, or timeout.
331
+
332
+ **Runs.**
333
+
334
+ - Hooks: five sessions per mode. Each session had a SessionStart, a normal turn (Bash, MCP and Read calls, then Stop), an interrupted turn, the prompt after it, and SessionEnd.
335
+ - Three-minute cool-down runs, with 8 s and 90 s gaps between turns.
336
+ - The first prompt of an outage, with 5 fresh recorders per mode.
337
+ - Time to listen, with hook probes at +1 s and +5 s.
338
+ - `bir-hooks ensure` cold (5 runs) and warm (9 runs).
339
+ - 300 echo calls through the proxy per tier.
340
+ - Results of 1, 10, 30 and 33 MB.
341
+ - Killing and restarting the control server mid-session.
342
+ - A standalone proxy under a hung service for 2,000 and 4,000 calls.
343
+ - The raw hook answer bodies.
344
+
345
+ **Adaptations and caveats.**
346
+
347
+ - 0.2.12 never listens while the service is fully hung (about 304 s), so its hook latencies for that mode were taken with the service hung but answering `/health`. The release candidate was measured in both modes.
348
+ - Latency-sensitive runs were sequential.
349
+ - One run interrupted by the machine's Modern Standby was discarded and re-run.
350
+ - Every process started for the measurement was stopped afterwards.
351
+
352
+ ---
353
+
354
+ ## 6. What changed, by component
355
+
356
+ ### 6.1 Hook server / control server (`src/control/server.ts`, `pin-guard.ts`, `transcript.ts`)
357
+
358
+ **Deadlines and fail-open answers.**
359
+
360
+ - **Deadlines.** Hard per-route deadlines apply: start 2 s, prompt 7 s, pre 12 s, post, agent and stop 2 s, end 1 s. Past a deadline the route answers `{}`, or an honest note mid-replay.
361
+ - **Fail-open answers.** Stale or wrong tokens, unknown routes, non-POST requests, bodies over 64 MB (read and discarded) and non-object bodies all get `200 {}`. They are counted in `/health.hooks`. Non-hook routes keep 401, 404 and 405.
362
+ - **Event name.** Every answer's `hookEventName` matches the event that fired. The final review found a PostToolUseFailure answered as PostToolUse, which Claude Code rejects with a visible hook error that dumps the runner's JSON.
363
+ - **Token.** The project's current token is read from `installed.json`: re-read at most every 3 s, and only when the file changed. A reinstall on the same port needs no recorder restart.
364
+
365
+ **Prompt path.**
366
+
367
+ - The match waits up to 3 s, and only while the breaker allows it.
368
+ - The match gate: over 2 s, prompts stop waiting for 5 min, doubling to 30 min.
369
+ - Arming a matched scenario, including derivation and the directive's argument preview, stays inside 6 s.
370
+ - A plan that is ready only after the prompt was answered is retired unannounced, so it never steers (`replay.decision code=arm_unannounced` or `arm_timeout`).
371
+ - With replay off, the prompt never waits.
372
+ - The prompt after an interrupted turn, and SessionEnd, seal synchronously and drain in the background. They used to wait for the whole backlog to reach the service (0.5 s healthy, over 30 s hung).
373
+
374
+ **Correlation and approvals.**
375
+
376
+ - By default PreToolUse answers `{callId, correlation:'fingerprint'}`, with no permission decision.
377
+ - Ids are injected only in three cases: under `bypassPermissions`, for replay-pinned steps, or with `BIR_CORRELATION_DECISION=allow|ask`.
378
+ - Even then, ids are injected only into a server whose proxy is alive, and never in plan mode.
379
+ - The fingerprint join hashes the raw hook input against the proxy's `argsFingerprint`. Its consent window is 10 minutes, so a user who reads the permission prompt before approving is not an error.
380
+
381
+ **Replay safety in the hooks.**
382
+
383
+ - **The pin guard.** A steered step is applied only to a call that plausibly is that step:
384
+ - the same command head, compared command by command for chained shells;
385
+ - an exact copy for a step with a command substitution;
386
+ - the same file for writes;
387
+ - shared argument keys, plus the same statement verb, for query and shell MCP tools.
388
+
389
+ Other calls run as written. After a declined pin, calling a different tool retires the plan.
390
+ - **Direct plans.** A direct plan (run through `run_scenario`) is announced only while a live `bir-scenario` has sent `/scenario/hello` within 30 minutes. A direct plan whose first model call is not `run_scenario` is retired, and nothing is executed.
391
+ - **`/scenario/run`.** It runs only the calling session's plan, bound by `toolUseId` or `sessionId`. If it cannot tell whose it is, it runs nothing (`replay.run_ambiguous`). A second concurrent run is refused, and Esc aborts the run.
392
+ - **Plan mode.** Plan mode gets no directive, no steering and no `allow`.
393
+
394
+ **Bounded state.**
395
+
396
+ - **Transcript.** The transcript is read incrementally, only the bytes appended since the last hook. Retention is bounded: 128 messages of up to 16K characters each for text and thinking, 4,096 tool-use ids, and 32 tool results of up to 8K characters.
397
+ - **Sessions.** A session is "active" when it has an open run and was heard from in the last 15 min.
398
+ - Sessions are forgotten after 6 h idle, 10 min after SessionEnd, or beyond 500.
399
+ - Open runs idle for 2 h are sealed.
400
+ - `/health` lists at most 50 sessions.
401
+ - **`BIR_OFF=1` sessions.** A session started with `BIR_OFF=1` is kept off for up to 7 days of silence, at most 2,000 of them.
402
+
403
+ ### 6.2 Background recorder lifecycle (`src/control/daemon.ts`, `recorder-main.ts`, `recorder-keeper.ts`, `ensure-hook.ts`, `discovery.ts`, `bin/bir-hooks.ts`)
404
+
405
+ **Start-up and sign-in.**
406
+
407
+ - The recorder listens before it touches the network. It builds its recorder from `credentials.json` and `config.json`, and signs in and refreshes in the background.
408
+ - An expired token no longer disables recording. An HTML or non-JSON 4xx on refresh counts as "unreachable". Only the service's own JSON refusal pauses recording, and it is re-checked.
409
+ - `bir login` and `bir setup` make a running recorder record from the next prompt. `bir logout` stops it.
410
+
411
+ **The SessionStart hook.**
412
+
413
+ - Its whole budget is 11 s, with a hard exit at 11.5 s: stdin 2 s, relay 2.5 s.
414
+ - It loads no control-server, replay or auth modules.
415
+ - It never writes `~/.claude.json`. It only logs stale local copies (`overrides.stale ... fix=run bir setup`), because writing that file while Claude Code starts could lose either write.
416
+
417
+ **Environment and working directory.**
418
+
419
+ - The recorder runs in `~/.baseinstrunner`; the project is passed as `BIR_DAEMON_CWD`. The project folder can therefore be renamed, deleted or removed as a git worktree while the recorder runs.
420
+ - The recorder never inherits `BIR_OFF`, the setup token, or `ANTHROPIC_API_KEY` (unless `BIR_USE_ANTHROPIC_API_KEY=1`).
421
+
422
+ **Finding and replacing a recorder (`ensureDaemon`).**
423
+
424
+ - A live recorder that is slow to answer `/health` (3 s) is never replaced or duplicated.
425
+ - Only an *older* recorder is replaced for version skew, and only when no session is mid-run. With `bir up`, `bir setup` or `bir off`, a legacy recorder that claims a run is open is also replaced when two probes 2.5 s apart show it idle.
426
+ - **Reinstall on another port.** The old recorder keeps serving the windows opened before, and a second one serves the new port. The old one is recorded as superseded, logs to its own file, and exits after 24 h with nothing left to serve (`BIR_SUPERSEDED_EXIT_MS`).
427
+ - **Same port, new token.** The recorder is replaced only between turns.
428
+
429
+ **Revive and `bir down`.**
430
+
431
+ - `bir-hooks revive --cwd <dir>` starts the recorder again mid-session for installed projects. It is silent and always exits 0.
432
+ - `bir down` writes a down marker, so nothing revives the recorder until a session start, `bir up` or `bir setup`.
433
+
434
+ **Robustness.**
435
+
436
+ - Shutdown gives `close()` 3 s, then hard-exits at 5 s. A second SIGINT or SIGTERM exits at once.
437
+ - The log is rolled to `.1` at 8 MB (checked every minute) and reopened after write errors or a removal.
438
+ - Discovery files are written atomically. They are rewritten when missing or after 30 minutes, reclaimed within a minute from an older or other-port recorder, and records of dead recorders are swept.
439
+ - **Windows launcher.** The PowerShell launcher is bounded to 2-8 s and hidden; `BIR_WINDOWS_LAUNCH=spawn` opts into a direct spawn.
440
+ - **Port conflicts.** A pinned port that cannot be bound is recorded in `control/<key>.port.json`, with its holder and the fix.
441
+
442
+ ### 6.3 bir-proxy, stdio (`src/bin/bir-proxy.ts`, `src/proxy/*`, `src/jsonrpc/framing.ts`, `src/upstream/stdio-client.ts`)
443
+
444
+ **Upstream failure and size limits.**
445
+
446
+ - **Upstream death.** The proxy exits when its upstream dies for good, after answering the calls in flight, with the upstream's exit code. `BIR_PROXY_STAY_UP=1` keeps the old behaviour. `--restart` restarts a dead upstream once and replays the host's handshake to it.
447
+ - **Size limit.** The frame cap went from 32 MiB to 128 MiB. A larger answer ends the call with a tool error; it used to hang. Framing is now linear, so a 20-30 MB result adds about 0.1-0.3 s instead of 0.8-1.7 s.
448
+
449
+ **Binding to a recorder.**
450
+
451
+ - The proxy binds to its own project's recorder through the discovery file. `BIR_CONTROL_URL` alone no longer decides where it registers. `BIR_CONTROL_URL` together with `BIR_CONTROL_TOKEN` (the SDK channel) still wins.
452
+ - A late or restarted recorder is re-bound within 15 s. The discovery window is 10 s.
453
+ - A proxy that sees its recorder dead (dead pid, refused port, installed project) launches `bir-hooks revive`: at most once per 30 s, backing off to 10 min, with 0-5 s of jitter.
454
+
455
+ **Recording tiers.**
456
+
457
+ - A user-scope entry wrapped by an older `bir install` records only in installed projects. Anywhere else it is a pure relay ("passthrough") and uploads nothing.
458
+ - Entries written by `install --config`, local and project entries, and `bir wrap` entries still record standalone (Tier 2).
459
+ - The step queue holds at most 250 steps. Step reports are built one tick after the answer is written. They are capped at 64 KB and carry `argsFingerprint`.
460
+
461
+ **Schema, off switch and stderr.**
462
+
463
+ - **Schema.** `tools/list` declares `__bir_call_id__` as `{type: string, description: 'Reserved. Leave unset.'}`, only while ids may be injected. It no longer forces `additionalProperties: true`.
464
+ - **Off switch.** A stdio proxy started while off runs the real server with inherited stdio. A running proxy stops recording within about 1 s.
465
+ - **Stderr.** Stderr is quiet: routine lines need `BIR_VERBOSE=1`. The decision and failure lines kept on stderr are `tier.downgrade`, `tier.rebound`, `run.lossy`, `upstream.exited`, `relay.undeliverable` and `proxy.upstream_gone`.
466
+
467
+ **Windows and shutdown.**
468
+
469
+ - **Windows.** PATH, PATHEXT and ComSpec are read case-insensitively. Environment overrides remove other spellings of the same key. `.cmd` and `.bat` arguments keep trailing backslashes and `%VAR%`.
470
+ - **Shutdown.** Stdin EOF, then 1.5 s of grace, then a tree kill (`taskkill /T /F` on Windows, SIGTERM then SIGKILL elsewhere), 3 s in total.
471
+
472
+ ### 6.4 Remote http/sse upstreams (`src/upstream/http-client.ts`, `http-transport.ts`, `sse.ts`, `headers.ts`)
473
+
474
+ **Transport and ordering.**
475
+
476
+ - **Transport.** It is `node:http`/`node:https` instead of `fetch`, with no hidden 300 s timeouts. Only connecting is bounded: 10 s for DNS, TCP and TLS (`BIR_UPSTREAM_CONNECT_TIMEOUT_MS`), and 15 s for the legacy SSE connect including the endpoint event.
477
+ - **No proxy timeout on tool calls.** Nothing bounds a tool call after connect; Claude Code's own timeout and Esc govern it.
478
+ - **Ordering.** Only the handshake is ordered: `initialize`, then `notifications/initialized`, with at most a 10 s gate. Every other message is independent, so parallel calls, cancellations and elicitation answers are not queued.
479
+ - **Cancellation.** `notifications/cancelled` goes upstream at once, and the cancelled POST is released.
480
+
481
+ **Streams and sessions.**
482
+
483
+ - **Legacy SSE.** Sends wait for the endpoint event. A dropped stream reconnects with backoff (0.5 s doubling to 30 s) and re-initialises. An endpoint on another origin is refused.
484
+ - **Session expiry.** A 404 after the handshake triggers one transparent re-initialise and a single retry.
485
+ - **Protocol headers.** `MCP-Protocol-Version` is sent on every request after initialize, and `close()` sends a best-effort DELETE (1 s).
486
+
487
+ **Answers and error texts.**
488
+
489
+ - **Answer cap.** Answers are capped at 128 MiB (`BIR_UPSTREAM_MAX_MESSAGE_BYTES`). Batches are unpacked, and non-JSON-RPC bodies are never forwarded.
490
+ - **Error texts.** They are short and never echo the server's body: "upstream unreachable: …", "upstream HTTP <status>", "upstream requires sign-in (HTTP 401)", "upstream returned an unexpected answer (HTTP 200, text/html)", and similar.
491
+
492
+ **Proxies, redirects and credentials.**
493
+
494
+ - **Proxy support.** HTTP(S)_PROXY and NO_PROXY are honoured on Node 24.5+ and 22.21+; `BIR_UPSTREAM_PROXY_ENV=0` turns this off. Loopback always connects directly. A malformed proxy variable is ignored rather than fatal.
495
+ - **Idle sockets.** Keep-alive sockets close after 4 s idle.
496
+ - **Redirects.** A redirect to another origin carries only protocol headers. An https-to-http redirect off loopback is refused.
497
+ - **Credentials.** Wrapped remote entries carry their credentials in `env.BIR_UPSTREAM_HEADERS` (JSON) instead of `--header` arguments visible in process lists. The proxy removes the variable from its environment after reading it. A header Node cannot send is skipped and logged once, without its value.
498
+
499
+ ### 6.5 Recording, auth and privacy (`src/record/*`, `src/auth/client.ts`)
500
+
501
+ **Service calls and the breaker.**
502
+
503
+ - **Deadlines.** Every recorder and auth request has a deadline: 10 s by default (`BIR_SERVICE_TIMEOUT_MS`), and 5 s for probes and the `/health` check.
504
+ - **Breaker.** Every service call reports to the process-wide breaker. `getMatch`, `runCreated` and `matchIntent` answer at once while it is open.
505
+ - **Probing.** While the service is half-open, only one probe goes out. The other send lanes follow after 0 to 2 s of jitter.
506
+
507
+ **Backlog and flushing.**
508
+
509
+ - **Backlog.** The backlog is bounded: 500 sends or 32 MiB, oldest steps dropped first. A queued send still facing a cool-down after 60 s is dropped unsent, not sent into the outage.
510
+ - **Flush.** A flush waits at most 2 s (`BIR_FLUSH_TIMEOUT_MS`), and not at all while the service is cooling down.
511
+ - **Retries.** A retry happens once, only after 429, 503, or a connection error that proves nothing reached the service. A 409 on the retry counts as "already landed". There is no retry after 502, 504 or a mid-request reset, until the service confirms it deduplicates ids (§10).
512
+
513
+ **Size caps on uploads.**
514
+
515
+ - Prompt: 32 KiB.
516
+ - Answer: 64 KiB.
517
+ - Reasoning and intent: 8 KiB.
518
+ - Non-JSON tool errors: 8 KiB.
519
+ - Report errors: 2 KiB.
520
+ - Tool payloads: 64 KiB.
521
+
522
+ Truncation is always marked with the original size.
523
+
524
+ **Redaction.**
525
+
526
+ - **Key matching.** Keys are matched on whole name segments, with 0.2.12's coverage restored for plurals, trailing `auth`, `oauth` and `auth_code`. Ordinary arguments such as `author`, `max_tokens` and `nextPageToken` survive.
527
+ - **Value shapes.** Covered:
528
+ - dotenv and shell assignments;
529
+ - ini and YAML credential lines;
530
+ - AWS credentials;
531
+ - PEM private keys;
532
+ - URL passwords;
533
+ - Basic, Bearer and Cookie headers;
534
+ - `curl -u` and `--token=` flags;
535
+ - query-string tokens;
536
+ - Azure connection strings;
537
+ - JWTs;
538
+ - common provider key formats.
539
+ - **What is redacted.** Prompts, answers, reasoning, errors and derivation prompts are redacted too.
540
+ - **JSON.** JSON payloads are redacted structurally, so recordings stay valid JSON and match live replay byte for byte.
541
+ - **Idempotence.** Redaction is idempotent.
542
+ - **Old rule.** `BIR_REDACT_LEGACY_KEYS=1` adds the old substring rule on top.
543
+
544
+ **Credentials.**
545
+
546
+ - `credentials.json` and `config.json` are written atomically, and credentials are mode 0600.
547
+ - A running recorder follows `credentials.json`:
548
+ - after `bir logout` it stops within about 2 s;
549
+ - another account closes the open runs and continues under the new account;
550
+ - a job is never sent under another account than the one it was queued under.
551
+ - `bir logout` deletes the file first, then revokes at the service that issued the session, best effort, within 8 s.
552
+ - Malformed sign-in answers are rejected instead of saved.
553
+
554
+ **Housekeeping.**
555
+
556
+ - Log fields are clipped at 2,000 characters.
557
+ - Journals untouched for 30 days are deleted.
558
+
559
+ ### 6.6 Calculated replay (`src/replay/*`)
560
+
561
+ **No stale data.**
562
+
563
+ - A recorded output no longer stands in for a step that produced no result. The plan stops at that step and hands over; `BIR_REPLAY_RECORDED_OUTPUTS=1` opts back in, but only for steps that could not be sent. `bir replay` keeps the stand-ins.
564
+ - A plan that ran nothing live answers `ok:false`. Stand-ins never count as work in the ledger.
565
+
566
+ **Honest hand-overs.**
567
+
568
+ - Timeouts, abandoned calls and shutdowns are worded "may or may not have completed". A step whose output logic failed says "do not run it again".
569
+ - A cancelled direct run's note is delivered on the session's next prompt or tool call.
570
+ - An Esc-interrupted turn is never booked as a saving (`measured:false`).
571
+
572
+ **Budgets.**
573
+
574
+ - The hook derivation wait is 4 s. All replay work in one PreToolUse is capped at 9 s (maximum 10 s).
575
+ - The direct plan budget is 100 s (was 120 s). Direct runs are clamped so that derivation, plan and slack stay within 270 s, under `run_scenario`'s 290 s client timeout.
576
+ - The worst case by budget is 120 s: 8 s of derivation, 100 s of plan, and 12 s of slack.
577
+
578
+ **Logic.**
579
+
580
+ - Scenario logic has a synchronous time limit: `BIR_LOGIC_TIMEOUT_MS`, 1 s by default, 10 s maximum.
581
+ - A body that returns a promise is refused.
582
+
583
+ **Injection guard (`safety.ts`).** It covers plans with a shell, SQL, exec or file-write sink, detected from tool and server names in any naming convention.
584
+
585
+ - Each derived value must be free of shell and SQL metacharacters, unless it equals its recorded sample.
586
+ - It must also keep the sample's shape: numbers stay numbers, nothing starts with `-`, and no new whitespace, glob, brace or parenthesis appears.
587
+ - File-write-only plans reject only line breaks and NUL.
588
+ - Plans with a command sink do not feed recent tool results to derivation.
589
+
590
+ **Steer mode** pins one call at a time. A call really running beside a pin hands the plan over, booked `fell_back` or `failed`. A failed pinned step (PostToolUseFailure, or a JSON-RPC error) hands over; nothing after it is approved.
591
+
592
+ **Bash delivery.**
593
+
594
+ - A hand-over longer than 6,000 characters goes through a 0600 file under `~/.baseinstrunner/tmp`, capped at 25,000 characters.
595
+ - The recorder sweeps these files at start and daily.
596
+
597
+ **Two sessions in one project.** A direct step for a session whose own proxy is not yet bound hands over instead of guessing.
598
+
599
+ ### 6.7 CLI, install and config (`src/bin/bir.ts`, `setup.ts`, `src/config/*`, `scripts/install.*`)
600
+
601
+ **Where entries go.**
602
+
603
+ - **Local-scope overrides.** Wrapped servers are local-scope copies in `~/.claude.json` under Claude Code's project key: the canonical git root, with forward slashes on Windows. `.mcp.json` and the user scope are never rewritten. The `bir` scenario server is local too, with no `enabledMcpjsonServers` pre-approval. A subdirectory or linked worktree of a repository gets hooks only. `CLAUDE_CONFIG_DIR` is honoured.
604
+ - **Approval (`mcp-approval.ts`).** Approval follows Claude Code's own rules, read from Claude Code 2.1.280:
605
+ - a rejected `.mcp.json` server is never copied;
606
+ - a pending one is left unwrapped with a message;
607
+ - a managed policy that matches servers by command or URL means nothing is wrapped.
608
+ - **Remote servers.** A remote server is wrapped only when it has a literal credential header, or when `--server` asks for it.
609
+ - **Stale copies.** Copies record their source's hash. `bir doctor` reports a stale copy as a problem, and `bir status` marks it STALE.
610
+
611
+ **Paths and flags.**
612
+
613
+ - **Invocation.** A flag-less install from an installed package writes absolute-path entries and the self-starting SessionStart hook. `--npx` gives `npx --prefer-offline -y -p @basein/runner@<v>`.
614
+ - **Node path.** The Node path is Homebrew's stable opt link, not the versioned Cellar path. nvm, fnm, Volta and asdf paths get a warning. `bir doctor` flags a missing node or script.
615
+ - **Proxy flag.** `--use-env-proxy` is added only when NO_PROXY exempts 127.0.0.1. Processes started with it add `127.0.0.1,localhost,::1` to their own NO_PROXY.
616
+
617
+ **Safe writes.**
618
+
619
+ - Install refuses, with nothing changed, when any file it would edit is not plain JSON.
620
+ - Every write is atomic, and idempotency is "regenerate and compare".
621
+ - `installed.json` keeps no header or env values. Records with redacted stashes live under `v2|` keys, so a 0.2.12 binary cannot find and blank them.
622
+
623
+ **Uninstall.**
624
+
625
+ - `bir uninstall` touches only this project. It repairs `~/.claude.json` entry by entry, never restores a stale snapshot, and stops the project's recorder. If a session still uses the recorder, it leaves it running with a per-project off switch.
626
+ - It exits 1 with "Not everything could be undone" if anything still runs through the runner.
627
+ - `--keep-recorder` leaves the recorder alone. `bir uninstall --all` handles every project, every recorder and the user scope; with the install scripts, `BIR_UNINSTALL=1` does the same.
628
+
629
+ **Commands.**
630
+
631
+ - **New commands and flags:** `bir off`, `bir on` (with `--here`), `bir doctor --strict` and `--quick`, and `bir status` "Runner", "Elsewhere", "Service", "Port" and "Superseded" lines.
632
+ - **`bir install` without `--replay`** stores replay off for the project unless the user said otherwise. `bir setup` keeps it on.
633
+ - **`bir login` on Windows** opens the browser without cmd.exe, and only for https links.
634
+ - **install.ps1** adds `--use-system-ca` only when the Node accepts it.
635
+
636
+ ### 6.8 The `bir` MCP server (`src/bin/bir-scenario.ts`, `scenario-liveness.ts`, `service-read.ts`)
637
+
638
+ **Tools listed.** `tools/list` returns only `run_scenario` by default. The read tools come with `bir scenario editing on` or `BIR_SCENARIO_TOOLS=read`, because every listed tool's definition rides along with every request of every session.
639
+
640
+ **`run_scenario`.**
641
+
642
+ - It never returns `isError`. With no plan it says "Nothing to run for this request; continue normally." Any other failure gives "The prepared results are not available for this request. Do the task normally with your tools." `BIR_SCENARIO_DEBUG=1` shows the raw reasons.
643
+ - Its description says to call it only when a `[BaseInstRunner calculated replay]` note in this turn says so.
644
+ - **Timeout and cancellation.** Its client timeout is 290 s (was 180 s). `notifications/cancelled` aborts the run, and the cancelled id gets no answer.
645
+ - **Binding.** It forwards Claude Code's `params._meta['claudecode/toolUseId']`, so the recorder runs exactly that call's plan.
646
+
647
+ **Liveness and revive.**
648
+
649
+ - It sends `POST /scenario/hello` on initialize, on `tools/list`, on an off-switch flip, on each new recorder, and every 5 min. Each hello is bounded at 1 s and never awaited.
650
+ - It revives a dead recorder at most once per 30 s, never in its first 12 s, and never after `bir down`.
651
+
652
+ **Off switch.** While off, it lists no tools. A flip sends `notifications/tools/list_changed` within about 2 s. Read tools answer a neutral sentence; changing tools answer MethodNotFound.
653
+
654
+ **Read tools.**
655
+
656
+ - Read tools time out at `BIR_READ_TIMEOUT_MS` + 10 s, 25 s in total. Changing tools keep 120 s.
657
+ - Read-tool output is capped at 24,000 characters by default, or 1.5 × `MAX_MCP_OUTPUT_TOKENS`.
658
+ - bir's audit lines go to stderr, never to the model.
659
+
660
+ ---
661
+
662
+ ## 7. Behaviour changes and upgrade notes
663
+
664
+ ### 7.1 Defaults that changed
665
+
666
+ | What changed | Why | How to get the old behaviour |
667
+ |---|---|---|
668
+ | Wrapped MCP calls are no longer auto-approved. PreToolUse gives no decision, and steps join by argument fingerprint. `bypassPermissions` sessions still get the id with `allow`; plan mode never gets `allow`. | 0.2.12 answered `allow` and rewrote the arguments of every wrapped call, so users were silently not asked about tools they would otherwise be asked about. The fingerprint join is lossy only for identical concurrent calls. | `BIR_CORRELATION_DECISION=allow` in the recorder's environment, then `bir up --restart` (logged as `control.correlation_allow`). `ask` injects the id and keeps the prompt. |
669
+ | Wrapped entries are local-scope overrides in `~/.claude.json`. `.mcp.json` and the user scope are untouched, and the scenario server is local, with no pre-approval. | Teams commit `.mcp.json`, and 0.2.12 wrote this machine's absolute paths and control URL into it. User-scope rewrites applied to every project. | `--mcp-json` or `BIR_WRAP_MCP_JSON=1`: the old in-place shape, never to be committed. The user scope is never rewritten in either mode. |
670
+ | A `.mcp.json` server is copied only once Claude Code approved it. A rejected one is never copied; a pending one is left unwrapped with a message. A managed policy by command or URL means nothing is wrapped. | A local-scope server starts without Claude Code's approval question, so a copy of an unapproved server would start silently. | `--server <name>` wraps a pending (not rejected) server and remembers it. `--mcp-json` also works, because Claude Code's question still applies there. |
671
+ | A subdirectory or linked worktree of a repository gets hooks only, with no wrapped copies. | Claude Code keys the local scope by the git root, so a copy would apply to the whole repository and its worktrees. | Run `bir setup` in the repository root, or use `--mcp-json`. |
672
+ | Remote servers without a literal credential header (OAuth, headersHelper, `${VAR}`) are left unwrapped. Earlier unasked wraps are undone. | The proxy has none of Claude Code's OAuth credentials; wrapped, these servers answered 401 to every call. | `--server <name>` (remembered). |
673
+ | A flag-less install from an installed package writes absolute-path entries and the self-starting SessionStart hook, for `bir wrap` too. | No npx registry round trip per server start (about 1 s), and it works offline. npx installs had nothing to start the recorder. | `--npx`. A checkout, or `bir` run through npx, still writes npx. |
674
+ | Hook timeouts: SessionStart command 15 s (was 30), HTTP 5 (10), UserPromptSubmit 10 (15), PreToolUse 15 (30), PostToolUse and PostToolUseFailure 5 (30), Subagent 3 (5), Stop 5 (10), SessionEnd 3 (30). | The timeout is the user's worst case during an incident. The server's deadlines sit inside it. | Not configurable; `bir setup` rewrites them. |
675
+ | Mid-turn intent arming is opt-in, and the intent probe is never waited on by default. | 0.2.12 held every PreToolUse up to 4 s for the probe. | `BIR_INTENT_ARM=1` (or `BIR_SEGMENT_ARM=1`) with `BIR_INTENT_ARM_BUDGET_MS=4000`. |
676
+ | The prompt match budget is 3 s (was 2.5 s), and there is a match gate: over 2 s, prompts stop waiting for 5 min, doubling to 30 min. | A healthy service answers in 300-450 ms. A slower one would otherwise cost every prompt. A false trip costs one hold of replay, never latency. | `BIR_MATCH_BUDGET_MS=2500`. Raising `BIR_MATCH_SLOW_MS` above the budget effectively disables the gate. |
677
+ | Recorded outputs no longer stand in for a step that produced no result; the plan hands over at that step. | Last week's output presented as this turn's result is wrong data. | `BIR_REPLAY_RECORDED_OUTPUTS=1`, only for steps that could not be sent. `bir replay` keeps the stand-ins. |
678
+ | The direct plan budget is 100 s (was 120 s), and direct runs are clamped to 270 s. | Keeps a direct run under Claude Code's 2-minute auto-background of an MCP call, and inside `run_scenario`'s client timeout. | `BIR_REPLAY_PLAN_BUDGET_MS=120000` (or `BIR_REPLAY_BUDGET_MS`). |
679
+ | The background recorder ignores an inherited `ANTHROPIC_API_KEY` for derivation. | That key is Claude Code's, so derivation billed the user's own account. | `BIR_DERIVE_API_KEY=<key>`, or `BIR_USE_ANTHROPIC_API_KEY=1`. A `bir-hooks` run by hand still uses the terminal's key. |
680
+ | `run_scenario` never returns `isError`. | An error result makes the model retry or report a failure the user cannot act on. The reason is in `scenario.answered`. | `BIR_SCENARIO_DEBUG=1`. |
681
+ | The `bir` server lists only `run_scenario`. | Tool definitions ride along with every request. | `BIR_SCENARIO_TOOLS=read`, or `bir scenario editing on`. |
682
+ | The `run_scenario` client timeout is 290 s (was 180 s). | It stays under Claude Code's 5-minute MCP idle timeout, above the direct-run worst case. | `BIR_SCENARIO_RUN_TIMEOUT_MS=180000`. |
683
+ | `bir-proxy` exits when its upstream dies. | Claude Code then shows the server as failed and offers a reconnect, as unwrapped. Staying up left tools listed that only answered errors. | `BIR_PROXY_STAY_UP=1`. |
684
+ | Proxy stderr is quiet: routine lines only with verbose logging. | Claude Code files every stderr line as a "Server stderr" error. | `BIR_VERBOSE=1`. |
685
+ | `bir doctor` exits 1 only for what breaks a session; warnings exit 0. | A doctor that failed working machines was noise and broke scripts that gate on it. | `bir doctor --strict`. |
686
+ | `bir uninstall` stops this project's recorder, touches only this project, and exits 1 if anything still runs through the runner. | An orphaned recorder broke reinstalls, and "Uninstalled." was printed when it was not true. | `--keep-recorder`; `--all` for every project. |
687
+ | `bir install` without `--replay` stores replay off for the project (`bir setup` keeps it on). | A project without the delivery tool should not steer or run direct replays. | `bir replay on`, or `bir install --replay`. Replay switches take effect on `bir up --restart`. |
688
+ | Redaction matches key-name segments, with many new value shapes; numbers under session-id, plural and counter keys are kept. | The substring rule redacted ordinary arguments and missed many secret shapes. | `BIR_REDACT_LEGACY_KEYS=1` adds the old rule on top. |
689
+ | Direct plans are announced only while a live `bir-scenario` has said hello (`/scenario/hello`) within 30 min. | A plan announced through a tool the model does not have diverged and ran unattended. | None. For about 5 min after a recorder restart, direct plans are declined until the hello arrives. |
690
+ | `BIR_OFF=1` in Claude Code's environment switches off only that session. | The recorder serves every window of the project. | `bir off --here`, or `bir off`. |
691
+ | `bir down` stays down until the next session start, `bir up` or `bir setup`. | A deliberate stop must not be undone by the mid-session revive. | `bir up`. |
692
+ | Recorder retries happen once, only for 429, 503 and connections that never reached the service. A flush waits at most 2 s. | Without service-side deduplication, a retry after a 502, 504 or reset could record a step twice. 0.2.12 never retried, and waited unbounded. | `BIR_FLUSH_TIMEOUT_MS`. Retries are not configurable. |
693
+ | Remote upstreams honour HTTP(S)_PROXY and NO_PROXY. | Corporate networks. | `BIR_UPSTREAM_PROXY_ENV=0`. |
694
+ | Answer caps are 128 MiB, both stdio frames and remote answers (stdio was 32 MiB). | A 33 MB result used to hang the call. | `BIR_UPSTREAM_MAX_MESSAGE_BYTES` for remote answers. |
695
+ | Remote credentials travel in the entry's env (`BIR_UPSTREAM_HEADERS`), not in `--header` arguments. | Command lines are visible in process listings. | Old `--header` entries still work until the next `bir setup` rewrites them. |
696
+ | Hook routes answer `200 {}` to stale tokens, unknown routes, non-POST requests, oversize bodies and malformed bodies (was 401, 404 or a reset). | Each of these was a hook error on every prompt and tool call until the recorder restarted. | None. |
697
+
698
+ ### 7.2 Upgrading from 0.2.12
699
+
700
+ **What `bir setup` migrates.** Re-running the install line, or `bir setup` in each project, does the following:
701
+
702
+ - **`.mcp.json`.** Servers wrapped in place in `.mcp.json` are put back, restoring the team's original bytes. They are then copied to the local scope, if Claude Code approved them.
703
+ - **User scope.** Servers wrapped in the user scope are put back, then copied to this project's local scope.
704
+ - **Remote servers.** Remote servers wrapped without being asked are un-wrapped. `--header` credentials move into `BIR_UPSTREAM_HEADERS`.
705
+ - **Scenario server.** The scenario server moves from `.mcp.json` to the local scope.
706
+ - **Hooks.** Hook timeouts are rewritten. Every install, npx included, gets the self-starting exec-form SessionStart hook.
707
+ - **Invocation.** An npx invocation nobody chose becomes the absolute-path global one.
708
+ - **`installed.json`.** Any `bir` command moves redacted-stash records to `v2|` keys. A verbatim `.mcp.json` backup is converted to a redacted one the next time the file is touched.
709
+ - **One-time note.** Install prints a one-time note: wrapped MCP calls are no longer auto-approved.
710
+
711
+ **`.mcp.json` servers pending approval.** A server Claude Code has not asked about yet is left unwrapped. The hooks still record its calls. To wrap it:
712
+
713
+ 1. Open Claude Code and approve the server.
714
+ 2. Run `bir setup` again.
715
+
716
+ **A running 0.2.12 recorder.**
717
+
718
+ - It keeps 0.2.12 behaviour until it is replaced: it auto-approves wrapped calls, waits on the service in hooks, and ignores `bir off`.
719
+ - The next SessionStart replaces it when no session is mid-run.
720
+ - `bir setup`, `bir up` and `bir off` also replace a 0.2.12 recorder that claims an open turn, when two probes 2.5 s apart show it idle.
721
+ - Otherwise it keeps running until `bir up --restart`. `bir doctor` warns about a recorder of another version, and `bir off` and `bir uninstall` name it.
722
+ - The new recorder takes the discovery file back from an older one within a minute.
723
+ - 0.2.12 proxies still running are served by the new recorder, and their reports join by fingerprint.
724
+
725
+ **Headless fleets (`claude -p`).** Wrapped MCP calls used to be auto-approved by the runner.
726
+
727
+ - In an untrusted workspace, where `permissions.allow` is ignored, those calls now fall to Claude Code's permission system and are denied.
728
+ - Remedies, in the order to recommend them:
729
+ 1. `permissions.allow` in a trusted workspace, or `--allowedTools`.
730
+ 2. `bypassPermissions`, where the runner still injects ids with `allow`.
731
+ 3. `BIR_CORRELATION_DECISION=allow` where the recorder starts.
732
+ - Fleets that start from fresh clones also need their `.mcp.json` servers approved (`enableAllProjectMcpServers` in user or managed settings), or `--server <name>`. Otherwise those servers are not wrapped.
733
+
734
+ **Downgrade.** Run `bir uninstall --all` with the *new* version first, then install the old one. The new version keeps redacted records under keys 0.2.12 does not read. A 0.2.12 uninstall would therefore find nothing and leave the wrapped entries, with their credentials, in place. The install script says so when `BIR_VERSION` is set.
735
+
736
+ ---
737
+
738
+ ## 8. Operating the runner
739
+
740
+ ### 8.1 The kill switch
741
+
742
+ | Command | Scope | Stored as |
743
+ |---|---|---|
744
+ | `bir off` | every project on this machine | `~/.baseinstrunner/off` |
745
+ | `bir off --here` | this project; from a subdirectory, the installed project that contains it | `~/.baseinstrunner/off.d/<key>` |
746
+ | `bir on` | removes the machine-wide switch *and* every project switch | none |
747
+ | `bir on --here` | this project only | none |
748
+ | `BIR_OFF=1` in the environment Claude Code starts in | that session only (see below) | relayed as `bir_off` at SessionStart |
749
+ | `BIR_OFF=0` in a shell | processes started from it ignore the flag files (debugging); the background recorder never inherits `BIR_OFF` | none |
750
+
751
+ **What each part does while off.**
752
+
753
+ - **Hooks.** Every hook route answers `{}` before reading its body, with no transcript read and no service call. SessionStart still starts the recorder, so later hooks get `{}` instead of a refused connection.
754
+ - **Recorder.** It drops proxy step reports (`{ok:true, off:true}`) and makes no service calls, including token refreshes.
755
+ - **Wrapped servers.** Running proxies stop recording and stop declaring the call-id key within about 1 s. A stdio proxy started while off spawns the real server with inherited stdio, a pure pipe with the same exit code. A remote proxy started while off relays without recording and still strips the call-id key.
756
+ - **`bir-scenario`.** It lists no tools and sends `tools/list_changed` within about 2 s. Read tools answer "switched off here"; changing tools answer MethodNotFound.
757
+ - **Nothing is uninstalled.** After `bir on`, hooks and running proxies record again within about a second. Proxies that were started while off stay pure pipes until their next start.
758
+
759
+ **How fast.**
760
+
761
+ - The switch is read with a 1 s cache, so hooks take effect within about a second, in open sessions too.
762
+ - A stdio proxy becomes a pure pipe only from its next start: a new session, or `/mcp` then reconnect. Until then it relays and records nothing.
763
+ - Running wrapped servers stop recording within about a second, and `bir-scenario` withdraws its tools within a few seconds; `bir off` says both.
764
+ - **Per-session `BIR_OFF=1`.** The SessionStart hook relays `bir_off:true`, and the recorder answers that session `{}` for up to 7 days of silence, or until 10 min after its SessionEnd. It is held in the recorder's memory, so a recorder restart loses it until that session's next SessionStart. Proxies and `bir-scenario` started in that environment are off too.
765
+ - **0.2.12 recorders ignore the switch.** `bir off` replaces an idle one and names a busy one ("keeps recording and approving until `bir up --restart`").
766
+ - **Uninstall and the switch.** `bir uninstall` uses a per-project switch marked "set-by-uninstall" when a session still uses the recorder. `bir install` and `bir down` remove only that kind.
767
+
768
+ **`bir off --here` versus `bir down`.**
769
+
770
+ - `bir down` stops the recorder but leaves the hooks. A session open in that directory then shows a "hook error" on every prompt and tool call until it ends, although the session keeps working.
771
+ - The quiet way to take the runner out of a session is `bir up`, then `bir off --here`.
772
+
773
+ ### 8.2 `bir doctor` and `bir status`
774
+
775
+ **`bir doctor`** asks the project's recorder (`/health`, 3 s timeout) and reads the local files. It prints three kinds of findings:
776
+
777
+ - **Problems** mean a Claude Code session here is broken, and doctor exits 1:
778
+ - a config Claude Code cannot read;
779
+ - a recorder that does not answer `/health`;
780
+ - hooks installed with nothing to start the recorder;
781
+ - a stale or orphaned local copy (`.mcp.json` or user-scope source changed or removed);
782
+ - a SessionStart hook or wrapped entry whose Node or script is gone;
783
+ - a remote server wrapped without credentials that nobody asked for;
784
+ - hooks posting to a port the recorder is not on;
785
+ - a port the recorder could not take (with its holder and fix).
786
+ - **Warnings** mean sessions work but the runner is not doing all its job. They exit 0, or 1 with `--strict`:
787
+ - a proxy not registered while sessions run;
788
+ - a shadowed entry;
789
+ - a managed-policy conflict;
790
+ - an unapproved local copy;
791
+ - a linked worktree defining a server differently;
792
+ - "the recorder has nowhere to send steps";
793
+ - the service not answering the recorder;
794
+ - a recorder older than the off switch, or of another version;
795
+ - the shell's `BIR_AUTH_URL` not being a BaseIn service;
796
+ - a `--server`-wrapped remote;
797
+ - user-scope wrapping;
798
+ - an install into another `CLAUDE_CONFIG_DIR`;
799
+ - hooks on several ports;
800
+ - `installed.json` naming another port than the hooks;
801
+ - hook calls carrying a token the recorder does not know (`/health.hooks.unauthorized`: the hooks were reinstalled while it kept running, and those calls were answered `{}` and not recorded — `bir up --restart`).
802
+ - **Notes** are facts:
803
+ - hook calls a deadline guard answered early (`/health.hooks.overran`);
804
+ - the runner is OFF;
805
+ - the recorder is not running yet (the next session starts it);
806
+ - where recording goes, and as whom;
807
+ - when the service last answered;
808
+ - who approves wrapped MCP calls (the recorder's correlation mode);
809
+ - whether sub-tasks are observe-only;
810
+ - superseded recorders;
811
+ - an npx-started scenario server;
812
+ - other installed projects.
813
+
814
+ `--quick` makes no call to the service; the local recorder is still asked. `--json` prints `{ok, problems, warnings, notes, health, wrapped, off, service, otherProjects, shellAuthUrl, shellAuthProblem, portProblem}`. The full `/health` answer is under `health`, including every hook counter (the text output reports only the unauthorized and overran ones).
815
+
816
+ **`bir status`** gives the recorder's view, with a 1.5 s timeout:
817
+
818
+ - `Directory`, `BaseIn`, `Control` (URL and pid, background or not, answering or not), `Port` (a port problem), `Superseded` (one line per superseded recorder), `Log`.
819
+ - `Service` (answering the recorder, or not, with the last error).
820
+ - `Runner` (on, or OFF with its source; also when the recorder reports itself OFF or predates the switch).
821
+ - `Elsewhere` (other installed projects).
822
+ - `Servers`, each `[wrapped]` or `[ raw ]`, with STALE when a local copy no longer matches its source.
823
+
824
+ `--json` prints:
825
+
826
+ - `cwd`, `authUrl`;
827
+ - `controlServer {url, pid, answering}`;
828
+ - `discoveryFile`, `portProblem`, `stateDir`;
829
+ - `off {source, detail}`;
830
+ - `recorder {service, off, version}`;
831
+ - `superseded[]`, `otherProjects`;
832
+ - `servers[] {name, scope, shadowed, wrapped, upstream, stale}`.
833
+
834
+ ### 8.3 The control server's `/health`
835
+
836
+ `GET /health` on the project's recorder needs the bearer token from `~/.baseinstrunner/control/<key>.json`. The easiest way to read it is `bir doctor --json`, under `health`.
837
+
838
+ | Field | Meaning |
839
+ |---|---|
840
+ | `recording`, `account`, `authUrl` | whether anything is being saved, as whom, and where |
841
+ | `service` | `{healthy, consecutiveFailures, openUntil, lastError, lastErrorAt, lastOkAt}`. `healthy:false` means the breaker is open and hooks are not waiting on the service. |
842
+ | `off` | `"global"`, `"project"` or `"env"` when switched off; `null` when on (always present) |
843
+ | `offSessions` | sessions started with `BIR_OFF=1` that are being answered `{}` |
844
+ | `hooks` | `{unauthorized, unknownRoute, oversize, overran, off}`: hook requests answered `{}` without processing, by reason. `unauthorized > 0` means hooks from another install. `overran > 0` means a deadline guard answered. |
845
+ | `sending` | the recorder backlog `{state, pendingSends, pendingBytes, droppedSends, failedSends}`, or `null`. `state` is `recording`, `signed_out`, `auth_rejected` or `other_service`. |
846
+ | `lossy`, `queued` | steps were dropped (queue overflow or dropped sends); the local queue size |
847
+ | `matchGate` | `{holding, holdMs, until}`: whether prompts have stopped waiting on the match because the service is slow |
848
+ | `correlation` | `auto`, `allow` or `ask`, or `fingerprint` when correlation is off: who approves wrapped calls |
849
+ | `deliveryTool`, `scenarioServers[]` | whether a live `bir-scenario` has said hello (`{pid, tools, off, version, ageMs}`). An empty list means no direct plan is announced. |
850
+ | `replay` | `{enabled, source, minSimilarity, allowServers, deriveKey, deriveVia, pollingProxies}`. `deriveVia` is `service`, `key` or `samples`; `samples` means no scenario with a target can run. |
851
+ | `registeredProxies[]`, `wrappedServers` | proxies that registered (`serverName`, `pid`, `cwd`, `version`) and the wrapped server names |
852
+ | `hooksVersion`, `pid`, `cwd`, `url` | the recorder's version and identity |
853
+ | `segmentArm` | whether service-found sub-tasks may run mid-task (default: observe only) |
854
+ | `sessionCount`, `sessions[]` | at most 50, active ones first: `{sessionId, runId, active, idleMs, ended, steps, recording, replay, intent, fragment}`. `replay` includes `armed`, `unannounced`, `running`, `outcome` and `handover`. |
855
+
856
+ ### 8.4 Logs and audit lines
857
+
858
+ | Log | Where | Rotation |
859
+ |---|---|---|
860
+ | Background recorder | `~/.baseinstrunner/logs/<key>.log`. `<key>` is the first 16 hex digits of the sha256 of the normalised project path, and the path is printed by `bir status` and `bir up`. | Rolled to `.1` at 8 MB (one generation), checked every minute. Reopened after write errors or removal. |
861
+ | Superseded recorder | `~/.baseinstrunner/logs/<key>.superseded-<port>.log` | same |
862
+ | Turn journal (read by `bir investigate`) | `~/.baseinstrunner/control/journal/<key>.jsonl`: `run.*`, `replay.*`, `plan.*` and `execution.*` events | 4 MB, halving; journals untouched for 30 days are deleted |
863
+ | `bir-proxy`, `bir-scenario` | stderr, which Claude Code files under that MCP server's log | Claude Code's |
864
+ | `bir-hooks` run in a terminal | stderr | none |
865
+
866
+ Lines read `[bir] <ISO time> <event> key=value …`, with each field clipped at 2,000 characters. `BIR_VERBOSE=1` adds detail lines. Key lines by topic:
867
+
868
+ | Topic | Lines and what they mean |
869
+ |---|---|
870
+ | Service health | `service.degraded` (first failure: reason, cool-down), `service.recovered`. `replay.decision code=service_unhealthy` (prompt did not wait: breaker open), `code=match_timeout` (prompt waited its budget), `code=match_late` (the answer landed after the prompt), `code=match_slow` (verbose: gate holding). `replay.match_gate state=holding\|open`. |
871
+ | Recording backlog | `recorder.backlog_full` / `recorder.backlog_recovered` (oldest steps dropped). `recorder.run_incomplete` (a run lost steps). `recorder.run_abandoned` (`reason=service_down`: a create given up after a minute of cool-down). `recorder.flush_deferred`, `recorder.flush_incomplete`, `recorder.send_failed` (once per run), `recorder.matched_after_retry`, `run.lossy`. |
872
+ | Sign-in | `auth.ok`, `auth.settling` (plain-http address being checked for https), `auth.refresh_deferred`, `auth.refresh_refused`, `auth.recovered`. `recorder.enabled`, `recorder.signed_out`, `recorder.disabled`, `recorder.paused` / `recorder.resumed`, `recorder.account_changed`, `recorder.report_dropped`, `derive.api_key_ignored`. |
873
+ | Hooks | `control.listening` (port, `recorder=<state>`, `correlation=`). `control.correlation_allow` (auto-approval opted in). `control.hook_deadline`, `control.hook_unauthorized` (a token from another install), `control.hook_unknown`, `control.hook_oversize`, `control.hook_event_mismatch`, `control.route_failed`, `control.drain_abandoned`. |
874
+ | Recorder lifecycle | `daemon.restart` (replaced an older recorder), `daemon.restart_deferred` (mid-run: next start), `daemon.start_alongside`, `daemon.newer_kept`, `daemon.revived`. `control.port_busy` (holder and fix), `control.already_running`, `control.superseded`, `control.discovery_reclaimed`, `control.log_reopened`, `control.fatal`. `overrides.stale` (a local copy no longer matches its source: run `bir setup`). |
875
+ | Proxy (MCP log) | `proxy.upstream_gone` (exiting with the server), `proxy.recorder_revive`, `tier.downgrade`, `tier.rebound`, `relay.undeliverable`. `upstream.auth_required` (the remote refused the handshake: credentials), `upstream.needs_sign_in`, `upstream.headers_invalid`, `upstream.proxy_env_ignored` (this Node cannot use HTTPS_PROXY), `upstream.proxy_env_invalid`, `upstream.standing_stream_failed` (at most hourly). Verbose only: `proxy.started`, `tier.bound`, `tier.passthrough`, `run.start` / `run.finish`, `upstream.stream_*`. |
876
+ | Replay | `replay.enabled` (at start: servers, threshold, who derives). `plan.armed`. `replay.decision` (also `arm_timeout`, `arm_unannounced`, `no_directive`, `no_delivery_tool`, `missing_target`). `replay.pin`, `replay.pin_declined`, `replay.pin_abandoned`, `replay.pin_overran`, `replay.diverge`, `replay.diverge_overran`, `replay.handover`, `replay.vetoed`, `replay.unsafe_param`, `replay.derive_timeout`, `replay.budget_clamped`, `replay.concurrent_call`, `replay.no_session_proxy`, `replay.run_ambiguous`, `replay.run_abandoned`. |
877
+ | `bir-scenario` (MCP log) | `scenario.answered` (`ok` means results were delivered; also `partial`, `bound`), `scenario.recorder_revive`, `scenario.switched`, `scenario.hello_failed`, `scenario.tool_timeout`, `scenario.tool_truncated`. |
878
+
879
+ ### 8.5 Incident playbooks
880
+
881
+ **The service is slow or down.**
882
+
883
+ 1. Run `bir status` in an affected project. Check the `Service :` line, and in `bir doctor --json`, `health.service` and `health.matchGate`.
884
+ 2. Confirm the user impact. Each recorder waits at most once per incident: up to 3 s on one prompt. After that, prompts and tool calls do not wait. `health.hooks.overran` should stay at or near 0.
885
+ 3. Nothing is needed on the client. Recording resumes by itself when the service answers. Steps held longer than a minute of cool-down, or beyond 500 sends or 32 MiB, are dropped and logged (`recorder.run_incomplete`, `recorder.run_abandoned`).
886
+ 4. If a user wants no per-prompt wait at all, even with a healthy service: `bir replay off` in the project, then `bir up --restart`. Or set `BIR_REPLAY=0` where Claude Code starts; it takes effect when the recorder next starts.
887
+ 5. On the service side, check `/health` and the create-and-match latency. Above 2 s, every recorder stops steering for 5-30 minutes.
888
+
889
+ **Hook errors in a session.**
890
+
891
+ 1. Run `bir doctor` in the project. The problems section names the cause:
892
+ - a recorder that does not answer;
893
+ - hooks posting to the wrong port;
894
+ - a port held by another program;
895
+ - a Node or script path that is gone.
896
+ 2. **Recorder not running:** `bir up`. The next hook succeeds. If a `bir down` was intended, `bir up` then `bir off --here` silences the hooks instead.
897
+ 3. **Port problem:** `bir setup --port <n>`, ideally below 49152, then start a new session. Running sessions keep their old hook URL.
898
+ 4. **Missing Node or script** (after a Node upgrade or a version-manager switch): `bir setup` in the project.
899
+ 5. **A hook error whose text includes runner JSON** is a regression of the event-name fix. Collect `~/.baseinstrunner/logs/<key>.log` and the Claude Code version.
900
+
901
+ **The recorder died mid-session.**
902
+
903
+ 1. It normally comes back within seconds. The session's wrapped proxies, or `bir-scenario` after its first 12 s, see a dead pid and a refused port, and launch `bir-hooks revive`. Look for `proxy.recorder_revive` or `scenario.recorder_revive`, then `daemon.revived`.
904
+ 2. If nothing revives it (no wrapped server, or `bir down` was used), use `/clear`, a new session, or `bir up`.
905
+ 3. Find out why it died: look for `control.fatal`, or an abrupt end, in the recorder log, and check antivirus or OOM kills.
906
+
907
+ **A replay did something wrong.**
908
+
909
+ 1. **Stop it now:** `bir off --here`, which takes effect in about a second.
910
+ 2. **Find out what happened:**
911
+ - `bir investigate` (the newest turn here), or `bir investigate <run_…|scn_…|sexec_…>`;
912
+ - the journal and the log lines `replay.decision`, `replay.pin`, `replay.pin_declined`, `replay.diverge`, `replay.handover`, `replay.unsafe_param`.
913
+ 3. **Keep it off, or narrow it:**
914
+ - `bir replay off`, then `bir up --restart`;
915
+ - or limit unattended servers with `bir replay allow a,b`.
916
+ 4. **Fix the scenario:** `bir scenario show <id>`, `bir scenario check|edit <id> --step <n> …`, `bir scenario undo <id>`.
917
+ 5. **Include in the report:** the scenario id, the permission mode, whether it was steer or direct, and the `replay.*` lines.
918
+
919
+ **An MCP server broke after install.**
920
+
921
+ 1. Run `bir doctor` for problems: a missing Node or script, a stale local copy, a remote wrapped without credentials, a managed-policy conflict.
922
+ 2. Rule the runner in or out: `bir off --here`, then `/mcp` and reconnect the server. It now runs as a plain pipe.
923
+ 3. Take one server out: `bir uninstall --server <name>`. Take the whole project out: `bir uninstall`.
924
+ 4. After `.mcp.json` changed, or a user-scope token was rotated: `bir setup`, which re-copies stale local copies.
925
+ 5. Read the server's MCP log for `upstream.auth_required`, `upstream.headers_invalid` and `proxy.upstream_gone`.
926
+
927
+ **After an upgrade.**
928
+
929
+ 1. Re-run the install line, or `bir setup`, in each project.
930
+ 2. Run `bir doctor`. A "recorder running here is 0.2.12" warning means running `bir up --restart` once no turn is mid-run.
931
+ 3. Headless `claude -p` fleets: allow the wrapped tools (`permissions.allow`, `--allowedTools`), or set `BIR_CORRELATION_DECISION=allow` where the recorder starts (§7.2).
932
+ 4. Projects whose `.mcp.json` servers were not yet approved: approve them in Claude Code, then run `bir setup` again.
933
+ 5. Check `bir status` for STALE servers and `Superseded:` recorders. `bir down` stops superseded ones.
934
+
935
+ **Behind a corporate proxy.**
936
+
937
+ 1. Before the install line or `bir setup`, set `HTTPS_PROXY` and `NO_PROXY=127.0.0.1,localhost,::1,<your entries>`. `localhost` alone is not enough.
938
+ 2. For a TLS-inspecting proxy, set `NODE_EXTRA_CA_CERTS` to the company CA bundle. install.ps1 also adds `--use-system-ca` when the Node supports it.
939
+ 3. Remote MCP servers go through the proxy only on Node 24.5+ or 22.21+. The installer's private Node is 24.21.0. Otherwise `upstream.proxy_env_ignored` names the cause.
940
+ 4. `bir doctor` and the install output say when `--use-env-proxy` was not carried.
941
+
942
+ **Removing the runner.**
943
+
944
+ 1. On the whole machine: run `bir uninstall --all`, or the install line with `BIR_UNINSTALL=1`, *before* deleting `~/.baseinstrunner` or running `npm rm -g @basein/runner`. Otherwise every other project's entries and hooks point at files that are gone.
945
+ 2. If it exits 1 with "Not everything could be undone", fix what it lists and run it again. Do not remove the package yet.
946
+ 3. One project only: `bir uninstall`. If a session still uses the recorder, it is left running and switched off for the project: end the session, then run `bir down`. `--keep-recorder` skips stopping it.
947
+ 4. Going back to 0.2.12: `bir uninstall --all` with the current version first (§7.2).
948
+
949
+ ---
950
+
951
+ ## 9. Configuration reference
952
+
953
+ **Read by.**
954
+
955
+ - **recorder:** `bir-hooks`. When the SessionStart hook starts it, it inherits the environment Claude Code started in, minus `BIR_OFF`, the setup token and `ANTHROPIC_API_KEY`. With `bir up`, it inherits that shell's environment.
956
+ - **proxy:** `bir-proxy`, from the MCP entry's `env` plus Claude Code's environment.
957
+ - **scenario:** `bir-scenario`.
958
+ - **CLI:** `bir`.
959
+
960
+ Defaults were checked in `src/`.
961
+
962
+ | Variable | Read by | Default | Meaning |
963
+ |---|---|---|---|
964
+ | `BIR_HOME` | all | `~/.baseinstrunner` | State directory: credentials, `installed.json`, logs, control files. |
965
+ | `BIR_AUTH_URL` | all | the address `bir setup` or `bir login` stored in `config.json` | BaseIn API address (`https://api.bi2202.com`). |
966
+ | `BIR_OFF` | all | unset | `1` or `true`: off. For the recorder, only for the session whose SessionStart relayed it; the background recorder never inherits the variable. `0` or `false`: on regardless of the flag files. |
967
+ | `BIR_VERBOSE` | all | unset | `1` adds routine detail lines. |
968
+ | `BIR_CONTROL_PORT` | recorder | the project's port from `installed.json`, else 53411 | Listening port. `0` means any free port (tests and smoke only, because hooks name a fixed port). Invalid values are ignored. |
969
+ | `BIR_CORRELATION_DECISION` | recorder | unset, meaning `auto` | `auto`: no decision for ordinary wrapped calls; fingerprint join. `allow`: 0.2.12 auto-approval. `ask`: inject the id and keep the prompt. Trimmed, any case. |
970
+ | `BIR_NO_CORRELATION` | recorder | unset | `1`: never inject call ids. |
971
+ | `BIR_SERVICE_TIMEOUT_MS` | recorder, proxy, CLI | 10000 | Deadline for one service request off the hot path: sends, refresh, device-login polls, `bir scenario`, `bir investigate`. |
972
+ | `BIR_FLUSH_TIMEOUT_MS` | recorder, proxy | 2000 | Longest flush at turn end, session end or shutdown when no budget is given. |
973
+ | `BIR_RECORDER_MAX_PENDING` | recorder, proxy | 500 | Unsent sends kept; the oldest steps are dropped first. |
974
+ | `BIR_RECORDER_MAX_PENDING_BYTES` | recorder, proxy | 33554432 (32 MiB) | The same bound, in bytes. |
975
+ | `BIR_REDACT_LEGACY_KEYS` | recorder, proxy | unset | `1` adds 0.2.12's substring key rule on top. |
976
+ | `BIR_SUPERSEDED_EXIT_MS` | recorder | 86400000 (24 h) | Idle time before a superseded recorder exits. |
977
+ | `BIR_WINDOWS_LAUNCH` | SessionStart hook, `bir up` | unset (PowerShell Start-Process) | `spawn`: a direct hidden spawn, about 1 s faster cold. Unsafe when something above Claude Code captures its output. |
978
+ | `BIR_DERIVE_API_KEY` | recorder | unset | An Anthropic key for derivation on this machine. Otherwise the signed-in service derives. |
979
+ | `BIR_USE_ANTHROPIC_API_KEY` | recorder | unset | `1`: the background recorder may use an inherited `ANTHROPIC_API_KEY`. |
980
+ | `ANTHROPIC_API_KEY` | recorder | none | Used only by a `bir-hooks` run by hand, or with the previous variable; otherwise removed (`derive.api_key_ignored`). |
981
+ | `BIR_DERIVE_MODEL` | recorder | `claude-haiku-4-5-20251001` | Model for derivation on this machine. |
982
+ | `BIR_REPLAY` | recorder | on, or the project's stored switch | `0` turns replay off. Any other value forces it on over the stored switch. |
983
+ | `BIR_REPLAY_ALLOW_SERVERS` | recorder | all wrapped servers | Servers that direct runs and divergence recovery may call unattended (comma-separated). |
984
+ | `BIR_MIN_STEER_SIMILARITY` | recorder | 0.92 | Minimum match similarity to replay. |
985
+ | `BIR_MATCH_BUDGET_MS` | recorder | 3000 | Longest a prompt waits for the match. |
986
+ | `BIR_MATCH_SLOW_MS` | recorder | 2000 | A match slower than this, or one that timed out, stops prompts waiting for 5 min, doubling to 30 min. |
987
+ | `BIR_DERIVE_BUDGET_MS` | recorder | 8000 | Derivation wait for `/scenario/run`. Hooks use the smaller of this and `BIR_HOOK_DERIVE_BUDGET_MS`. |
988
+ | `BIR_HOOK_DERIVE_BUDGET_MS` | recorder | 4000 | Derivation wait inside a hook: prompt arming, and the first PreToolUse. |
989
+ | `BIR_PRETOOL_REPLAY_BUDGET_MS` | recorder | 9000 (capped at 10000) | All replay work inside one PreToolUse. |
990
+ | `BIR_REPLAY_PLAN_BUDGET_MS` | recorder | 100000 | Whole-plan budget for a direct run. |
991
+ | `BIR_REPLAY_BUDGET_MS` | recorder | unset | Older name; overrides the plan budget when set. Direct runs are clamped to 270 s in total (`replay.budget_clamped`); `bir replay` is not clamped. |
992
+ | `BIR_STEP_TIMEOUT_MS` | recorder | 60000 | One direct `tools/call`. |
993
+ | `BIR_LOGIC_TIMEOUT_MS` | recorder | 1000 (capped at 10000) | Synchronous time limit for one scenario logic body. |
994
+ | `BIR_REPLAY_RECORDED_OUTPUTS` | recorder | unset | `1`: a step that could not be sent may use its recorded output, labelled as such. |
995
+ | `BIR_BUILTIN_TOOLS` | recorder | `Read,Glob,Grep` | Built-ins the runner executes itself during a replay, in the session's project directory and confined to it. Add `Edit,Write,Bash` deliberately — runner-side steps bypass the host's permission prompts. `none` turns it off. |
996
+ | `BIR_BASH_ALLOW` | recorder | unset | Command prefixes a runner-side `Bash` step may run (`*` for all). Unset, `Bash` never executes on the runner even when listed. |
997
+ | `BIR_DERIVE_RECENT_RESULTS` | recorder | 5 | How many of this prompt's tool results derivation may read (`0`: none). Never for plans with a command sink. |
998
+ | `BIR_INTENT_MATCH` | recorder | on | `0` turns mid-turn intent probes off. |
999
+ | `BIR_INTENT_MATCH_BUDGET_MS` | recorder | 4000 | One probe's own ceiling. It is not a hook wait. |
1000
+ | `BIR_INTENT_MATCHES_PER_TURN` | recorder | 3 | Plans intent may arm per turn. |
1001
+ | `BIR_INTENT_REQUESTS_PER_TURN` | recorder | 40 | Probes per turn. |
1002
+ | `BIR_INTENT_SOURCES` | recorder | `text,thinking,tool` | Which text an intent may be read from. |
1003
+ | `BIR_INTENT_ARM` | recorder | unset (follows `BIR_SEGMENT_ARM`) | `1`: a probe may arm a plan in its own PreToolUse, and that hook waits for it. `0`: never. |
1004
+ | `BIR_INTENT_ARM_BUDGET_MS` | recorder | 500 | How long that PreToolUse waits for the probe. |
1005
+ | `BIR_SEGMENT_ARM` | recorder | unset (observe only) | `1`: recurring sub-tasks the service found may run mid-task. |
1006
+ | `BIR_MIN_SEGMENT_STEER_SIMILARITY` | recorder | 0.95 | Minimum similarity to steer a segment. |
1007
+ | `BIR_CONTROL_URL` | proxy, scenario | written into wrapped entries | The project's control server. Together with `BIR_CONTROL_TOKEN` it is the SDK channel and wins over discovery. |
1008
+ | `BIR_CONTROL_TOKEN` | proxy, scenario | unset | Bearer token for the SDK channel. |
1009
+ | `BIR_PROXY_STAY_UP` | proxy | unset | `1`: keep answering errors after the upstream dies (0.2.12). |
1010
+ | `BIR_UPSTREAM_HEADERS` | proxy | written into wrapped remote entries | A JSON object of header name to value for a remote upstream. `--header` wins on a clash. Removed from the environment once read. |
1011
+ | `BIR_UPSTREAM_CONNECT_TIMEOUT_MS` | proxy | 10000 | DNS, TCP and TLS (and proxy tunnel) for a remote upstream. The legacy SSE connect has its own fixed 15 s. |
1012
+ | `BIR_UPSTREAM_MAX_MESSAGE_BYTES` | proxy | 134217728 (128 MiB) | Largest remote answer relayed. |
1013
+ | `BIR_UPSTREAM_PROXY_ENV` | proxy | on | `0`, `false`, `off` or `no`: remote upstreams ignore HTTP(S)_PROXY. |
1014
+ | `BIR_HOST_APP`, `BIR_HOST_VERSION` | proxy | `unknown` | Host metadata on standalone (Tier 2) runs. |
1015
+ | `BIR_SCENARIO_DEBUG` | scenario | unset | `1`: raw failure reasons, flagged `isError`. |
1016
+ | `BIR_SCENARIO_TOOLS` | scenario | unset | `read` (or `1`, `on`, `true`): list the read tools without editing on. |
1017
+ | `BIR_SCENARIO_RUN_TIMEOUT_MS` | scenario | 290000 (valid 1-600000) | `run_scenario` request ceiling. |
1018
+ | `BIR_READ_TIMEOUT_MS` | scenario, CLI | 15000 (valid 1-600000) | One reading command (`bir scenario show` or `edits`, `bir investigate`), shared across its reads. MCP read tools time out at this + 10 s. |
1019
+ | `BIR_SCENARIO_MAX_OUTPUT_CHARS` | scenario | 24000 (1000-400000) | Most of a read tool's answer that reaches the model. |
1020
+ | `MAX_MCP_OUTPUT_TOKENS` | scenario (Claude Code's variable) | unset | When set, and the previous variable is not, the cap is 1.5 characters per token. |
1021
+ | `BIR_WRAP_MCP_JSON` | CLI | unset | `1` is the same as `--mcp-json`. |
1022
+ | `CLAUDE_CONFIG_DIR` | CLI (Claude Code's variable) | unset | Honoured: `$CLAUDE_CONFIG_DIR/.claude.json` and `settings.json`. |
1023
+ | `HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY` | all | none | See §8.5, "Behind a corporate proxy". |
1024
+ | `NODE_EXTRA_CA_CERTS` | Node | none | Company CA bundle for TLS-inspecting networks. |
1025
+ | `BIR_SETUP_TOKEN`, `BIR_VERSION`, `BIR_PROJECT`, `BIR_NODE_VERSION` (24.21.0), `BIR_NODE_ARCHIVE`, `BIR_TARBALL`, `BIR_NO_CLAUDE`, `BIR_UNINSTALL`, `BIR_DRY_RUN` | install scripts | none | Documented in the headers of `scripts/install.sh` and `install.ps1`. `BIR_UNINSTALL=1` runs `bir uninstall --all` and nothing else. |
1026
+ | `BIR_DAEMON`, `BIR_DAEMON_LOG`, `BIR_DAEMON_CWD` | recorder | set by the launcher | Internal: marks the background recorder, its log and its project. |
1027
+ | `BIR_MCP` | CLI | set by `bir-scenario` | Internal: compact JSON output for the model. |
1028
+ | `BIR_CLAUDE_JSON`, `BIR_CLAUDE_SETTINGS`, `BIR_CLAUDE_MANAGED_DIR` | CLI | Claude Code's own paths | **Test/sandbox.** Where Claude Code's config, user settings and managed settings are. An empty `BIR_CLAUDE_MANAGED_DIR` disables managed settings. |
1029
+ | `BIR_QUIET` | all | unset | **Test only.** `1` silences the audit log. |
1030
+ | `BIR_AUTH_DISABLE` | recorder, proxy, scenario | unset | **Development/test only.** `1`: a fake local session, no service. |
1031
+ | `BIR_RECORDER_TICK_MS` | recorder | 60000 | **Test only.** The recorder's minute tick. |
1032
+
1033
+ ---
1034
+
1035
+ ## 10. Release checklist
1036
+
1037
+ **Automated.**
1038
+
1039
+ 1. **CI.** Open the release pull request. `ci.yml` runs typecheck, build, the test suite and the real-process smoke run on ubuntu, windows and macos with Node 20, plus ubuntu with Node 24. All jobs must be green. The six POSIX-only, Git-Bash and symlink-privilege tests skipped on Windows run only there.
1040
+ 2. **Flaky-test check.** The test step retries once on failure. Read each job's log for the warning "first attempt failed; retrying once". A pass on the retry is a flaky test to fix, not a green run.
1041
+ 3. **Version.** `package.json` and `package-lock.json` are at 0.3.0; `bir --version`, the recorder's `/health.hooksVersion` and `bir-scenario` all read it from `package.json`. The release workflow refuses a tag that disagrees with `package.json`. Tag `v0.3.0` to publish to npm and attach the tarball.
1042
+ 4. **Release notes.** Build them from §7: the changed-defaults table, "Upgrading from 0.2.12", the downgrade rule, and the headless-fleet remedies.
1043
+ 5. **Documentation.** README.md and `docs/` (installRun, quickstart, calculatedReplay, calculatedReplayGuide, mcpmark, BaseInstRunner) and the `installInst*.md` guides must match this document. Check at least:
1044
+ - that auto-approval is gone;
1045
+ - the local-scope overrides and the `.mcp.json` approval rule;
1046
+ - the 3 s match budget and the 2 s gate;
1047
+ - the hook timeouts;
1048
+ - that recorded outputs are no longer served;
1049
+ - `bir off`, `bir on` and `bir uninstall --all`;
1050
+ - the doctor exit codes;
1051
+ - the NO_PROXY requirement;
1052
+ - `BIR_UPSTREAM_HEADERS`;
1053
+ - the `run_scenario` answers.
1054
+ 6. **Served install scripts.** The API serves copies of `install.sh` and `install.ps1` with the version and address filled in. Update the served copies with the new scripts; they gained `BIR_UNINSTALL` and the NO_PROXY hint.
1055
+
1056
+ **Manual end-to-end checks with the real Claude Code.** These are behaviours of Claude Code itself, which the automated tests simulate but cannot prove. Use the Claude Code version you will support; the approval rules and hook schema were read from 2.1.280.
1057
+
1058
+ | # | Check | Pass when |
1059
+ |---|---|---|
1060
+ | 1 | An HTTP hook answering `{}` on every route: with a stale token, with the recorder off, and at a deadline | Nothing is shown in the session: no hook error, no text |
1061
+ | 2 | A PostToolUseFailure answered with `hookEventName: "PostToolUseFailure"` and `additionalContext` (a failed pinned replay step) | No hook error, and the model receives the hand-over note |
1062
+ | 3 | A wrapped MCP call in the default permission mode | Claude Code asks exactly as it does unwrapped. The step appears in the recording, joined by fingerprint, with its result. |
1063
+ | 4 | A `bypassPermissions` session calling a wrapped MCP tool | `updatedInput` with `__bir_call_id__` is accepted, the upstream never sees the key, and the step is joined by id |
1064
+ | 5 | `bir off` mid-session | The next hooks answer `{}` within about 1 s, `run_scenario` disappears after `tools/list_changed`, and no new steps are recorded |
1065
+ | 6 | Kill the recorder mid-session (in a project with a wrapped server) | Hook errors stop within seconds without user action (`proxy.recorder_revive`, `daemon.revived`). Measure the window. |
1066
+ | 7 | A direct replay with the real `bir-scenario` | `/health.scenarioServers` lists it. `run_scenario` runs without a permission prompt. `/scenario/run` carries the tool-use id. The bundle reaches the model. |
1067
+ | 8 | `/exit` while the service is slow | SessionEnd returns within its 3 s, and the run is finished on the service afterwards |
1068
+ | 9 | Windows cold SessionStart after a reboot | About 0.7 s. No console window flashes, and the recorder survives the hook's exit. |
1069
+ | 10 | Upgrade a live 0.2.12 install with a session open, then run `bir setup` | Entries migrate, `.mcp.json` is byte-identical to the team's, the one-time approval note is printed, and the 0.2.12 recorder is replaced when idle |
1070
+ | 11 | `bir uninstall --all` on a machine with several projects | Exit 0, every file restored, no runner entries left in any `.claude.json`, all recorders stopped |
1071
+ | 12 | A fresh clone with `.mcp.json`: `bir setup`, approve in Claude Code, `bir setup` again | The first run leaves the server unwrapped with a message; the second wraps it |
1072
+ | 13 | `claude -p` in an untrusted workspace with wrapped tools | Calls are denied without `permissions.allow` or `--allowedTools`, and allowed with them, as the release notes say |
1073
+ | 14 | A 2-minute direct run | Check how Claude Code treats a `run_scenario` call near 2 minutes (auto-background) and 5 minutes (MCP idle timeout) |
1074
+
1075
+ **Asks to the service team.**
1076
+
1077
+ 1. **Deduplicate client-generated `run_`, `step_` and execution-ticket ids.** Answer 409 to a repeat, so that one transient retry cannot record twice. The runner treats a 409 on a retry as "already landed". Once this is in place, the runner can safely retry after 502, 504 and connection resets, which it does not do today.
1078
+ 2. **A cheap "this user or project has no scenarios" hint**, for example on sign-in or in the run-create answer, cacheable. With it, a prompt can skip the create-and-match wait: the last per-prompt service round trip (288-464 ms) with replay on.
1079
+ 3. **Keep create-and-match under 2 s at p95.** Above that, each recorder's match gate stops steering for 5 to 30 minutes.
1080
+ 4. **Keep `GET /health` answering JSON.** The runner uses it to tell the API from a website: an HTML answer means "not a BaseIn service". Refuse a spent refresh token with a JSON 400 or 401. The runner treats an HTML or non-JSON 4xx as "unreachable", so a WAF or CDN challenge never signs users out.
1081
+ 5. **Send Retry-After on 429 and 503.** The runner honours up to 2 s for its one background retry.
1082
+ 6. **Sandbox or validate scenario logic on the service side.** On users' machines it runs in the recorder process with only a synchronous time limit.
1083
+ 7. **Expect redacted text.** Derivation prompts and uploads now arrive redacted (`[redacted]` in place of secrets).
1084
+ 8. **Keep `/scenarios/match-intent` compatible.** A 404 there disables intent probing for 10 minutes.
1085
+
1086
+ ---
1087
+
1088
+ ## 11. Known limitations and remaining risks
1089
+
1090
+ **Partial, not fixed and accepted findings (Appendix A).**
1091
+
1092
+ | Item | Status | What remains, and why |
1093
+ |---|---|---|
1094
+ | Per-prompt round trip with replay on (`replay-steady-state-hot-path-latency`, `prompt-waits-createrun-rtt`) | partial / not fixed | Tool calls never wait on the service, and a slow service is gated. With replay on, a healthy service still costs every prompt one create-and-match round trip (288-464 ms measured), even for users with no scenarios. Removing it needs the service-side hint (§10). `bir replay off` removes it per project. |
1095
+ | Node paths (`daemon-hook-node-path-versioned`, `cli-baked-node-paths`) | partial | Entries and hooks name Node by absolute path. Homebrew now gets the stable opt link, and hooks and proxies use one Node. nvm, fnm, Volta and asdf paths change with the version and are only warned about. After a Node change, `bir doctor` reports a PROBLEM and `bir setup` repairs it. The install line avoids this by using a private Node. |
1096
+ | Corporate proxy for the runner's own traffic (`cli-env-proxy-ignored`) | partial | `--use-env-proxy` is carried only when the Node knows the flag and NO_PROXY exempts 127.0.0.1, because without that exemption the runner's own loopback calls would be sent to the proxy. When the condition is not met, install prints the hint and the runner's service traffic connects directly, bypassing the proxy. |
1097
+ | Remote upstreams behind HTTPS_PROXY (`upstream-http-ignores-https-proxy`) | partial | Honoured on Node 24.5+ and 22.21+ (`http.Agent` proxyEnv). Older Nodes connect directly and log `upstream.proxy_env_ignored`. |
1098
+ | Unusable stdio answer (`upstream-unanswerable-2xx-hangs-host`) | partial | The HTTP side and oversized lines are answered. A stdio server that prints an unparseable (non-JSON) answer line still leaves that one call waiting, exactly as it would unwrapped. |
1099
+ | Scenario logic isolation (`replay-logic-no-time-limit`) | partial | Logic bodies run in the recorder process (`vm.runInThisContext`) with a synchronous time limit (1 s, maximum 10 s). There is no sandbox and no memory limit, and continuations a body queues are not bounded. A body that returns a promise is refused. |
1100
+ | Port conflicts (`daemon-pinned-port-no-selfheal`, `daemon-foreign-listener-exposure`) | partial | The recorder records who holds the port, and `bir setup` moves the project, but a running session keeps its hook URL. Claude Code still posts hooks, with the bearer token, to whatever holds the project port. The runner never trusts an unidentified listener, and doctor names the holder. |
1101
+ | Recorder lifetime (`daemon-cwd-lock-immortal`) | partial | The project folder is no longer locked, because the recorder runs in `~/.baseinstrunner`. The project's main recorder still lives until reboot, by design: an idle exit would turn the next hook into a refused connection. Superseded recorders do exit. |
1102
+ | Windows cold start (`daemon-windows-launch-latency`, `empirical-cold-sessionstart-powershell`) | partial | The PowerShell `Start-Process` launch (about 0.7 s once per project per boot) is kept by default for handle-inheritance safety. `BIR_WINDOWS_LAUNCH=spawn` opts into a faster launch. |
1103
+ | `__bir_call_id__` in tool schemas (`call-id-schema-visible-by-default`) | accepted | The reserved property stays declared while ids may be injected, so `bypassPermissions` and pinned calls can carry an id. Narrowing it risks an undeclared key after a mid-session switch to `bypassPermissions`. |
1104
+
1105
+ **Other residual risks.**
1106
+
1107
+ - **First prompt of a hang outage.** It waits 3 s, once per recorder per outage. Every other hook is free.
1108
+ - **Replay responses are not validated.** Response parameters taken from live step outputs are fed into the next step's input without the injection guard, which checks derived parameters only.
1109
+ - **Standalone (Tier 2) proxy memory during a long outage.** A proxy with no recorder grows to about 220 MB during a long outage, flat from about 1,500 calls (0.2.12: 176 MB at 2,000 calls, still growing). This is V8 heap slack from per-call garbage, not the bounded backlog. `--max-old-space-size=64` flattens it at 131 MB, but the median call then takes 3 ms.
1110
+ - **Stale local copies.** After `.mcp.json` is edited, or a user-scope token rotated, the old copy keeps running until `bir setup`. Doctor reports a PROBLEM, `bir status` shows STALE, and the SessionStart hook logs `overrides.stale`. The hook deliberately does not write `~/.claude.json`.
1111
+ - **Writes to `~/.claude.json` can race Claude Code.** `bir setup`, `install` and `uninstall` replace the file atomically. A write made while Claude Code is running can still race Claude Code's own write of the same file, and the later writer wins. Prefer running them with no Claude Code session open.
1112
+ - **Per-session `BIR_OFF=1` lives in the recorder's memory.** A recorder restart loses it until that session's next SessionStart.
1113
+ - **Two sessions in one project.** Direct replays for a session whose own proxy has not yet bound hand over instead of running.
1114
+ - **Revive gaps.** `bir-scenario` does not revive in its first 12 s; those first hooks are left to the SessionStart hook. A session with no wrapped server and no `bir` server is not revived until `/clear` or a new session. The revive window was not measured with real Claude Code.
1115
+ - **Direct-run worst case.** The worst case by budget is 120 s: 8 s of derivation, 100 s of plan, and 12 s of slack. It is reached only if every logic body hits its limit, and sits at Claude Code's 2-minute MCP auto-background. Setting `BIR_REPLAY_BUDGET_MS=120000` (the old default) makes it 140 s.
1116
+ - **Retries are conservative.** A send lost to a 502, 504 or mid-request reset is not retried until the service deduplicates ids.
1117
+ - **Healthy-service prompt cost.** With an instant local service the prompt hook is about 6 ms slower than 0.2.12 (about 17 vs 11 ms median); the cause was not isolated. Under 503 or slow answers the recorder also keeps more idle keep-alive sockets. Both are small, but visible in measurements.
1118
+
1119
+ ---
1120
+
1121
+ ## Appendix A: Findings ledger
1122
+
1123
+ Legend: *Round* is `audit` (component audit plus verification, 162 findings) or `final review` (whole-system review, 45 findings). *Status* is `fixed`, `partial` (what remains is in §11), `not fixed`, or `accepted`. The finding text is the first part of each full finding.
1124
+
1125
+ | Round | Severity | Area | ID | Finding | Status |
1126
+ |---|---|---|---|---|---|
1127
+ | audit | critical | cli | `upstream-oauth-remote-servers-lose-auth` | Wrapping a remote MCP server that Claude Code signs in with OAuth removes its credentials, so the server fails on every session | fixed |
1128
+ | audit | critical | cli | `cli-remote-oauth-servers-broken` | bir setup wraps OAuth-authenticated remote MCP servers in a stdio proxy that has no credentials, which breaks them | fixed |
1129
+ | audit | critical | cli | `cli-stale-snapshot-restore-data-loss` | uninstall restores a file to its FIRST-install snapshot after a later re-install. It deletes MCP servers the user added in between and, for ~/.claude.json, reverts all of Claude Co | fixed |
1130
+ | audit | critical | http | `proxy-sse-initialize-before-endpoint` | Legacy SSE upstreams fail at initialize: the host's first POST goes out before the SSE endpoint is announced, and a stream that ends normally is never noticed | fixed |
1131
+ | audit | critical | http | `proxy-remote-oauth-servers-broken` | Wrapping a remote http/sse server that relies on Claude Code's OAuth breaks it: the proxy has no credentials, and the host just gets a JSON-RPC error with the upstream's HTML body | fixed — CLI leaves OAuth remotes unwrapped; transport answers 401/403 with a fixed message |
1132
+ | audit | critical | http | `upstream-legacy-sse-initialize-races-endpoint` | Legacy SSE: the host's initialize is POSTed to the SSE GET URL before the endpoint event arrives, so the server never connects | fixed |
1133
+ | audit | critical | record | `recording-no-circuit-breaker` | No fail-fast or circuit breaker: while the service is blackholed or hanging, every prompt costs +2.5 s and every tool call +4 s, and a revoked refresh token costs 2 extra round tri | fixed — process-wide ServiceHealth breaker on every service call |
1134
+ | audit | critical | record | `recording-redaction-gaps` | Secrets in built-in tool results, commands, prompts, answers and error messages are sent to the service unredacted | fixed |
1135
+ | audit | critical | server | `hookserver-intent-probe-blocks-every-tool-call` | Every PreToolUse (every Bash/Read/Edit/MCP call) waits up to 4 s for the service's intent probe | fixed |
1136
+ | audit | critical | server | `hookserver-prompt-awaits-network-flush-after-interrupt` | After an interrupted turn, the next UserPromptSubmit (and SessionEnd) wait for the whole recorder backlog to reach the service | fixed |
1137
+ | audit | critical | server | `replay-no-circuit-breaker` | A slow, blackholed or signed-out service costs 2.5 s on every prompt and 4 s on every tool call, indefinitely | fixed |
1138
+ | audit | high | cli | `install-machine-paths-in-shared-mcp-json` | (cross-area: install) `bir setup` writes machine-specific absolute node and runner paths plus BIR_CONTROL_URL into the project's .mcp.json, the file teams commit, so teammates' and | fixed |
1139
+ | audit | high | cli | `daemon-hook-node-path-versioned` | The SessionStart hook runs node by its resolved, versioned path, so a routine `brew upgrade node` removes it | partial — stable Homebrew opt path and one node for hooks and proxies; nvm/fnm/volta/asdf paths only warned; doctor flags a missing node |
1140
+ | audit | high | cli | `cli-baked-node-paths` | Wrapped entries and hooks bake in absolute Node and package paths: after a Node upgrade or removal, every wrapped MCP server fails to start, and doctor does not check for it | partial — as above |
1141
+ | audit | high | cli | `cli-user-scope-wrap-global` | Wrapping a user-scope server from one project rewrites it for every project: other projects' calls get recorded, and the entry carries the first project's control URL | fixed |
1142
+ | audit | high | cli | `cli-team-mcp-json-machine-paths` | Wrapping project-scope servers writes this machine's absolute Node and runner paths into the team-committed .mcp.json | fixed |
1143
+ | audit | high | cli | `cli-hook-timeouts` | The hook timeouts install writes (SessionEnd 30s, Pre/PostToolUse 30s, UserPromptSubmit 15s) become the user's wait whenever the service is slow | fixed |
1144
+ | audit | high | cli | `cli-env-proxy-ignored` | Behind a corporate proxy the runner's own traffic ignores HTTPS_PROXY, although the docs say it passes through | partial — --use-env-proxy carried when the Node supports it and NO_PROXY exempts 127.0.0.1; otherwise a hint is printed |
1145
+ | audit | high | cli | `cli-uninstall-leaves-wrapped` | `bir uninstall` can print "Uninstalled." while MCP entries stay wrapped, when the cwd is spelled differently or an earlier install failed | fixed |
1146
+ | audit | high | cli | `cli-removal-breaks-other-projects` | Removing the software as the docs describe (uninstall in one project, then delete ~/.baseinstrunner or `npm rm -g`) breaks every other installed project's MCP servers and every use | fixed |
1147
+ | audit | high | daemon | `hookserver-startup-blocks-on-network` | bir-hooks signs in over the network (no timeouts) before it listens, so a slow or blackholed service makes every hook fail for the whole start-up and can disable recording for days | fixed |
1148
+ | audit | high | daemon | `hookserver-reinstall-orphans-live-recorder` | `bir uninstall` leaves the recorder running; a reinstall then writes hooks with a new token (and, by default, a new port) that the old recorder never serves, and ensureDaemon keeps | fixed — current sidecar token accepted; stale recorder replaced or started alongside; superseded recorders exit |
1149
+ | audit | high | daemon | `daemon-auth-before-listen` | The recorder calls the BaseIn service with no timeout before it listens, so a slow or down service holds up SessionStart and leaves every hook failing for up to 10 minutes | fixed |
1150
+ | audit | high | daemon | `daemon-stale-after-reinstall` | After `bir uninstall` and `bir setup`, the old recorder is kept on the old port and token; the new hooks refuse on every call, indefinitely | fixed |
1151
+ | audit | high | daemon | `recording-daemon-start-blocks-on-auth` | The recorder only starts listening after two unbounded network calls, so SessionStart blocks for 15 s and hooks are refused while the service is unreachable | fixed |
1152
+ | audit | high | daemon | `empirical-hooks-not-listening-while-auth-hangs` | bir-hooks does not listen for ~5 minutes when the service accepts connections but never answers; each SessionStart waits 16.7 s and starts another daemon | fixed |
1153
+ | audit | high | http | `proxy-http-upstream-serialises-all-requests` | Streamable-HTTP upstream: every POST is serialised and waits for the full response, so parallel tool calls run one at a time, cancellations and pings queue behind a slow call, and | fixed |
1154
+ | audit | high | http | `upstream-unanswerable-2xx-hangs-host` | An upstream reply the proxy cannot use (HTML 200, SSE stream ending or resetting before the response, an unparseable or oversized stdout line) leaves the host's request waiting for | partial — HTTP side and oversized stdio lines answered; an unparseable (non-JSON) stdio answer line still hangs that call, as it would unwrapped |
1155
+ | audit | high | http | `upstream-http-posts-serialised-head-of-line` | All POSTs to a remote upstream are serialised, so one slow tool call delays every parallel call, ping and cancellation to that server | fixed |
1156
+ | audit | high | http | `upstream-http-serialised-posts-deadlock-server-requests` | Streamable HTTP: a server-initiated request sent during a tool call deadlocks that call, because the host's answer is queued behind the call's own POST | fixed |
1157
+ | audit | high | proxy | `proxy-records-in-projects-without-optin` | User-scope servers wrapped with one project's BIR_CONTROL_URL fall to Tier 2 in every other project and upload those projects' MCP tool arguments and results to the BaseIn service | fixed |
1158
+ | audit | high | record | `recording-fetch-no-timeout` | No recorder or auth fetch has a timeout: a blackholed service costs 10.7 s per request, and a service that accepts but never answers costs up to 300 s | fixed |
1159
+ | audit | high | replay | `replay-recorded-output-masks-failure` | Outputs recorded in an earlier run replace live failures, including writes, and the model is told not to call tools | fixed |
1160
+ | audit | high | replay | `replay-empty-params-after-derive-timeout` | A derivation timeout or a paramsLogic error runs the plan with `{}` parameters, not the recorded samples | fixed |
1161
+ | audit | high | replay | `replay-steer-ignores-failure-signals` | Steer mode treats failed steps as successes and keeps auto-approving the next steps | fixed — host/proxy failure passed to the controller; the plan hands over |
1162
+ | audit | high | replay | `replay-skipped-steps-vanish-from-bundle` | A direct step that cannot run and has no recorded output disappears from the result, which still says 'every side effect is in place — do NOT call any tools' | fixed |
1163
+ | audit | high | scenario | `scenario-server-spontaneous-call-red-error` | The model calls run_scenario on its own (seen in production on every first run) and the user sees a red error, 5 s late when no recorder is running | fixed |
1164
+ | audit | high | server | `proxy-correlation-autoapproves-mcp-calls` | Id-injection correlation answers PreToolUse with permissionDecision:"allow" by default, so every wrapped MCP call skips the user's permission prompt | fixed |
1165
+ | audit | high | server | `hookserver-prompt-match-no-circuit-breaker` | Every prompt waits the full 2.5 s match budget while the service is slow or blackholed; a matched prompt can wait 10.5 s, and the first tool call after arming up to 8 s more | fixed — breaker + sticky slow + match-latency gate + 1.5 s default budget |
1166
+ | audit | high | server | `hookserver-correlation-allow-auto-approves` | By default every wrapped MCP tool call is answered permissionDecision 'allow', so the user's permission prompts for those tools disappear | fixed |
1167
+ | audit | high | server | `hookserver-pin-by-name-rewrites-and-approves` | Steer mode pins any call to the expected tool by name alone, rewrites it to the recorded step's input and auto-approves it | fixed |
1168
+ | audit | high | server | `daemon-sessionend-unbounded-flush` | The SessionEnd hook waits for every queued service send, and those sends have no timeout, so /exit and /clear can take up to 30 s when the BaseIn service is slow or unreachable | fixed |
1169
+ | audit | high | server | `recording-prompt-waits-on-flush` | After an interrupted turn, the next UserPromptSubmit waits for the transcript and for a network flush with no deadline (2.4 s healthy, over 15 s when the service is unreachable) | fixed |
1170
+ | audit | high | server | `replay-scenario-run-wrong-session` | run_scenario runs whichever session's plan was armed first, with that session's parameters | fixed |
1171
+ | audit | high | server | `replay-steady-state-hot-path-latency` | Every prompt and every tool call waits for a service round trip, even when replay is off or no scenario exists | partial — tool calls never wait on the service; with replay on, a healthy service still costs one create/match round trip per prompt (service-side hint needed) |
1172
+ | audit | high | server | `replay-run-scenario-permission-prompt-and-veto-bypass` | run_scenario is not pre-approved, and denying it still runs the plan | fixed |
1173
+ | audit | high | server | `replay-prompt-waits-flush-after-interrupt` | The first prompt after an Esc-interrupted turn waits for the previous run to flush to the network (cross-area) | fixed |
1174
+ | audit | high | server | `replay-diverge-unbounded-inside-pretool` | Divergence recovery runs every remaining step inside the 30 s PreToolUse hook with no deadline; on timeout the model's own call runs while the daemon keeps executing the scenario | fixed — same as above |
1175
+ | audit | high | server | `scenario-server-direct-without-delivery-tool` | Direct replay is armed and injected even when no `bir` server exists (after --no-replay, uninstall --replay, a disabled server, or one that failed to spawn) | fixed — direct plans need a live /scenario/hello from bir-scenario |
1176
+ | audit | high | server | `scenario-server-run-scenario-no-hook-allow` | No hook ever allows mcp__bir__run_scenario, so direct replay (and every spontaneous no-plan call) goes through Claude Code's permission system: an approval prompt interactively, a | fixed |
1177
+ | audit | high | server | `empirical-pretooluse-waits-on-intent-probe` | Signed in with replay on (the default), every PreToolUse waits for a full service round trip of up to 4 s | fixed |
1178
+ | audit | high | server | `empirical-prompt-waits-on-create-run` | UserPromptSubmit waits up to 2.5 s for the create-run round trip, even with BIR_REPLAY=0; a matched scenario can then add up to 8 s for derivation | fixed |
1179
+ | audit | high | server | `empirical-prompt-after-interrupt-finalizes-inline` | The first prompt after an interrupted turn waits for the previous run to finish: 0.5-2 s when healthy, up to the 15 s hook timeout when the service is slow | fixed |
1180
+ | audit | medium | cli | `daemon-npx-install-no-ensure` | `bir install` with no flags (the README's replay recipe) installs no self-starting hook, so every hook fails until `bir up`, after every reboot | fixed |
1181
+ | audit | medium | cli | `cli-nonatomic-config-writes` | Every config and state write is a non-atomic read-modify-write: it races Claude Code on ~/.claude.json and can tear installed.json and credentials.json | fixed — writeFileAtomic for every user/state file incl. credentials |
1182
+ | audit | medium | cli | `cli-no-kill-switch` | There is no single switch that makes the runner inert, and `bir down` makes things worse | fixed |
1183
+ | audit | medium | cli | `cli-npx-default` | `bir install` and `bir wrap` default to a pinned npx entry: about 1s more per MCP server start, a network fetch on a cold cache, and no recorder that starts itself | fixed |
1184
+ | audit | medium | cli | `cli-uninstall-cross-project` | `bir uninstall` in one folder removes the hooks and restores the files of every installed project, wipes all backups, and leaves its own recorder running | fixed |
1185
+ | audit | medium | cli | `cli-port-permanent` | A project's hook port is fixed forever: if it becomes unavailable, or changes while the old recorder is still running, every hook of every session fails | fixed — `bir setup` moves an unbindable port and rewrites hooks |
1186
+ | audit | medium | cli | `cli-doctor-accuracy` | `bir doctor` reports a false port mismatch when a machine has several projects, and misses the failures that actually break sessions | fixed |
1187
+ | audit | medium | cli | `cli-sidecar-secret-copies` | installed.json keeps full copies of ~/.claude.json and of remote servers' auth headers, and the headers also appear on the proxy's command line | fixed — no ~/.claude.json copy; stashed secrets redacted and kept out of 0.2.12 lookups |
1188
+ | audit | medium | cli | `cli-setup-symlinked-project` | `bir setup --project <path through a symlink>`, which install.sh passes, waits 15s, reports failure, and stores --allow-servers under a key the recorder never reads | fixed |
1189
+ | audit | medium | cli | `scenario-server-mcp-json-shared` | The `bir` entry goes into the committable .mcp.json with this machine's absolute Node and script paths, so teammates get an approval prompt and 'failed to connect' | fixed |
1190
+ | audit | medium | cli | `scenario-server-npx-entry` | A flag-less `bir install --replay` writes an npx `bir` entry: about 13 s cold start, fails offline, and on native Windows lacks the documented `cmd /c` wrapper | fixed |
1191
+ | audit | medium | cli | `empirical-npx-entry-offline-breaks-mcp` | Plain `bir install` writes pinned-npx entries: every MCP server start takes +0.85 s, and 70.8 s when the network is offline or hanging | fixed |
1192
+ | audit | medium | daemon | `daemon-pinned-port-no-selfheal` | When the project's fixed port cannot be bound, the recorder exits and nothing recovers, so every session's hooks fail until `bir setup --port` | partial — recorder records who holds the port; `bir setup` moves the project; the running session keeps its hook URL |
1193
+ | audit | medium | daemon | `daemon-nullrecorder-forever` | A recorder that starts while signed out or offline records nothing for its whole multi-day life, even after the service recovers or the user signs in | fixed |
1194
+ | audit | medium | daemon | `daemon-replay-token-captured` | Replay reads the access token from the startup session object, which is never updated, so service derivation gets 401 once that token expires | fixed |
1195
+ | audit | medium | daemon | `daemon-live-treated-dead` | A recorder that is alive but slow to answer, or whose discovery file aged over a long sleep, is treated as dead: its discovery file is deleted and a duplicate is started that canno | fixed |
1196
+ | audit | medium | daemon | `daemon-cwd-lock-immortal` | Recorders never exit and hold the project folder as their working directory, so on Windows the folder cannot be renamed or deleted | partial — recorder runs in ~/.baseinstrunner (folder no longer locked); main recorder still lives until reboot by design; superseded recorders exit |
1197
+ | audit | medium | daemon | `daemon-foreign-listener-exposure` | If another program holds the project's port, Claude Code sends it every hook payload and the bearer token | partial — the runner never trusts an unidentified listener and doctor names the holder; Claude Code itself still posts hooks to whatever holds the port |
1198
+ | audit | medium | daemon | `recording-nullrecorder-for-daemon-lifetime` | A transient failure at recorder start leaves recording and replay off for the recorder's whole life (days), and the log wrongly tells the user to sign in | fixed |
1199
+ | audit | medium | daemon | `recording-replay-token-stale` | Replay reads the startup access token forever, so service-side derivation and source-run outputs fail with 401 after the first 24 h | fixed |
1200
+ | audit | medium | daemon | `replay-stale-access-token` | Replay's service derivation and source-run fetches keep the startup access token after the recorder refreshes | fixed |
1201
+ | audit | medium | http | `proxy-http-non-jsonrpc-200-hangs` | A remote upstream that answers HTTP 200 with a body that isn't a single JSON-RPC response (HTML from an SSO or captive proxy, a batch array, or an SSE body that ends early) leaves | fixed |
1202
+ | audit | medium | http | `upstream-sse-streams-never-reestablished` | SSE streams are never re-established: a dropped legacy SSE stream breaks the server for the rest of the session, and a dropped standing GET silently loses server-initiated messages | fixed |
1203
+ | audit | medium | http | `upstream-http-implicit-300s-timeout` | A slow remote tool is cut off at 300 s by Node fetch's default headers/body timeouts and reported as "upstream unreachable: fetch failed" | fixed |
1204
+ | audit | medium | http | `upstream-http-session-expiry-never-recovers` | Streamable HTTP: after the server drops the session (404 on Mcp-Session-Id), every later call fails for the rest of the Claude Code session | fixed |
1205
+ | audit | medium | http | `upstream-http-ignores-https-proxy` | Wrapped remote servers bypass the corporate HTTPS_PROXY: Node fetch ignores it unless NODE_USE_ENV_PROXY is set, so remote upstreams fail behind a mandatory proxy | partial — honoured on Node 24.5+/22.21+ (http.Agent proxyEnv); older Node connects directly and logs upstream.proxy_env_ignored |
1206
+ | audit | medium | proxy | `proxy-oversized-upstream-response-hangs-call` | An upstream response over 32 MiB is silently dropped, so the host's tools/call never gets an answer (an indefinite hang) | fixed |
1207
+ | audit | medium | proxy | `proxy-quadratic-framing-and-sync-scrub` | Large results pay O(n^2) Buffer.concat framing plus a synchronous redact and deep-copy before the answer is written: +66 ms at 5 MB, +0.8 s at 20 MB, +1.7 s at 30 MB | fixed |
1208
+ | audit | medium | proxy | `proxy-schema-relaxation-leaks-internals` | tools/list schema relaxation shows runner internals to the model ('Internal BaseInstRunner correlation id — ignore.', additionalProperties:true) and depends on a startup race | fixed |
1209
+ | audit | medium | proxy | `proxy-tier2-one-way-and-duplicate-runs` | Tier 2 is a one-way door: a late or restarted daemon is never re-bound, and queued reports each re-run becomeStandalone, creating several orphan runs and several authenticate() cal | fixed |
1210
+ | audit | medium | proxy | `proxy-tier2-recorder-unbounded-and-refresh-storm` | Tier 2 recorder: sends have no timeout, the per-run chain bypasses the 1000-item queue bound, and a failed token refresh is retried on every step (23 'refresh failed: HTTP 401' in | fixed — bounded backlog, timeouts, single-flight refresh |
1211
+ | audit | medium | proxy | `proxy-windows-path-case-sensitive` | Windows: whichSync reads env.PATH on a plain-object copy of the environment, so with the usual `Path` key npx/.cmd servers fail with spawn ENOENT | fixed |
1212
+ | audit | medium | proxy | `proxy-replay-timeout-no-cancel` | A replay step that times out is not cancelled on the upstream, so it can still run after the daemon substitutes a recorded output; under --lazy, replay responses are swallowed | fixed |
1213
+ | audit | medium | proxy | `upstream-cmd-quoting-mangles-args` | Windows: the .cmd wrapper's quoting breaks arguments that end in a backslash and expands %VAR% | fixed |
1214
+ | audit | medium | proxy | `upstream-request-abort-leaves-work-running` | A timed-out replay step is never cancelled upstream: the tool keeps running (possibly after a recorded output was substituted), and on HTTP it blocks the user's next calls | fixed — base upstream client sends notifications/cancelled on abort for stdio and HTTP |
1215
+ | audit | medium | proxy | `upstream-dead-upstream-keeps-proxy-alive` | When a stdio upstream dies for good the proxy stays up, so Claude Code keeps offering its tools and every call returns a runner-phrased error | fixed |
1216
+ | audit | medium | proxy | `empirical-framing-quadratic` | The JSON-RPC line reader is O(n²) on large frames: the proxy adds 0.1-2 s to 5-30 MB tool results | fixed |
1217
+ | audit | medium | proxy | `empirical-oversized-result-never-answered` | A tool result over 32 MiB is dropped by the proxy and the host's request is never answered | fixed |
1218
+ | audit | medium | record | `hookserver-recorder-backlog-unbounded` | The step queue's 1000-item bound never engages; the real backlog is in RemoteRecorder's unbounded per-run promise chains, which have no fetch timeout | fixed — RemoteRecorder bound + /health.sending |
1219
+ | audit | medium | record | `daemon-refresh-drops-authurl-binding` | The recorder's token refresh saves credentials without the authUrl binding, which quietly disables the protection against sending tokens to a different service | fixed |
1220
+ | audit | medium | record | `recording-refresh-drops-authurl-binding` | An in-process token refresh removes the credentials' authUrl binding, after which authenticate() hands the live session to any origin | fixed |
1221
+ | audit | medium | record | `recording-concurrent-refresh-race` | Concurrent 401s in one process each spend the rotating refresh token, the losers' sends are dropped, and there is no proactive refresh | fixed |
1222
+ | audit | medium | record | `recording-unbounded-send-backlog` | The StepQueue bound never engages: the real buffer is RemoteRecorder's unbounded per-run promise chain | fixed |
1223
+ | audit | medium | record | `recording-no-transient-retry` | One transient failure drops a send, and a dropped create turns the rest of the turn into 404s | fixed |
1224
+ | audit | medium | record | `recording-logout-keeps-recording` | `bir logout` leaves running recorders sending under the old session, so recording and replay continue for up to the access token's lifetime after sign-out | fixed |
1225
+ | audit | medium | record | `recording-uncapped-free-text` | Prompt, final answer, context and thinking intent are sent uncapped; tool payloads alone are capped at 64 KiB | fixed |
1226
+ | audit | medium | record | `empirical-unbounded-recorder-backlog` | With a hung service, the recording backlog grows without bound in both the proxy and the daemon (StepQueue's cap does not apply) | fixed |
1227
+ | audit | medium | replay | `replay-double-pin-concurrent-calls` | Two calls to the expected tool in flight at once are both pinned to the same step, so it runs twice | fixed |
1228
+ | audit | medium | replay | `replay-logic-no-time-limit` | Scenario logic runs on the daemon's main thread with no time or memory limit | partial — synchronous time limit (BIR_LOGIC_TIMEOUT_MS); still not a sandbox and no memory limit |
1229
+ | audit | medium | replay | `replay-cancel-does-not-stop-direct-run` | Esc does not stop a direct replay, and a client timeout leaves the steps running while the model retries | fixed — Esc aborts /scenario/run; no further step starts; the undelivered note reaches the next turn |
1230
+ | audit | medium | replay | `replay-bash-channel-oversize-bundle` | The Bash delivery channel embeds a bundle of up to 60 K chars in the command string, and it gets cut | fixed |
1231
+ | audit | medium | replay | `replay-targets-regex-misses-destructuring` | The missing-target gate misses destructured or whole-object reads of `parameters`, so a plan runs with a null target | fixed |
1232
+ | audit | medium | replay | `replay-derived-params-injection-auto-approved` | Tool-result text feeds parameter derivation, and derived values are templated into auto-approved Bash, Edit and SQL calls | fixed |
1233
+ | audit | medium | scenario | `scenario-server-run-cancel-ignored` | Esc does not stop run_scenario: the recorder keeps running the plan's tool calls after the user cancels | fixed |
1234
+ | audit | medium | scenario | `scenario-server-internal-error-text` | run_scenario failures pass raw internals to the model and the user ('fetch failed', 'not_ready', 'control server answered HTTP 404') | fixed |
1235
+ | audit | medium | scenario | `scenario-server-read-tools-no-timeouts` | Read tools can hang for 120 s on a slow or unreachable service, even when signed out | fixed — read tools bounded (BIR_READ_TIMEOUT_MS) and answer at once when signed out |
1236
+ | audit | medium | scenario | `scenario-server-unbounded-output` | scenario_show and investigate results have no size limit, both in the model's context and in the server's memory | fixed |
1237
+ | audit | medium | server | `hooks-inject-call-id-into-unwrapped-server` | __bir_call_id__ leaks to a real upstream: the daemon's `wrapped` set is read once at start and only grows, so a server the user unwraps or re-adds while the long-lived daemon runs | fixed |
1238
+ | audit | medium | server | `hookserver-transcript-full-reparse-per-hook` | Every hook re-reads and JSON-parses the entire session transcript synchronously, blocking the daemon's event loop | fixed |
1239
+ | audit | medium | server | `hookserver-proxy-step-joined-in-wrong-session` | Proxy step reports are joined only against the first session with an open run, so with two sessions (or one stale one) MCP steps land in the wrong recording and replay threads the | fixed |
1240
+ | audit | medium | server | `hookserver-diverge-executes-inside-pretooluse-without-deadline` | Divergence executes the rest of the scenario inside one PreToolUse with no deadline, overrunning Claude Code's 30 s timeout and double-running side effects | fixed — 12 s route guard, 9 s controller budget, abort signal stops further steps |
1241
+ | audit | medium | server | `hookserver-stale-sessions-never-pruned` | Sessions are never removed and interrupted runs are never sealed, so one dead window corrupts attribution, blocks upgrade restarts and leaks memory | fixed |
1242
+ | audit | medium | server | `hookserver-hook-routes-return-non-2xx` | Hook routes answer 401, 404, 405, or a reset socket for over-size bodies, and Claude Code shows each one as a hook error | fixed |
1243
+ | audit | medium | server | `daemon-stale-active-blocks-restart` | A turn that ends without Stop counts as 'active' forever, so version-skew restarts never happen and an upgrade never takes effect | fixed |
1244
+ | audit | medium | server | `replay-intent-arm-denies-live-call` | An intent match mid-task denies or rewrites the model's own tool call to deliver the directive | fixed — mid-task intent arms never deny or rewrite an unrelated live call |
1245
+ | audit | medium | server | `replay-transcript-full-parse-per-hook` | Each PreToolUse reads and JSON-parses the whole session transcript synchronously (115-145 ms at 30 MB) | fixed |
1246
+ | audit | medium | server | `scenario-server-concurrent-sessions-wrong-plan` | /scenario/run runs whichever session's plan is armed first, not the caller's, so two sessions in one project can swap results | fixed |
1247
+ | audit | medium | server | `scenario-server-run-not-single-flight` | No single-flight guard: a second run_scenario while the first is still executing runs the same plan again in parallel | fixed |
1248
+ | audit | medium | server | `empirical-sessionend-waits-network-flush` | SessionEnd blocks until the whole recording chain reaches the service: 4 s, 18 s, or past the host's 30 s timeout when the service is slow | fixed |
1249
+ | audit | medium | server | `empirical-transcript-full-reparse-per-hook` | Every PreToolUse, prompt and Stop re-reads and JSON-parses the whole transcript on the daemon's single thread, even when signed out | fixed |
1250
+ | audit | medium | server | `missed-fragment-open-awaits-create-on-pretooluse` | After a replay hand-over, the next tool call's PreToolUse waits up to 2.5 s for the fragment's create-run round trip | fixed |
1251
+ | audit | medium | server | `missed-intent-arm-awaits-derive-on-pretooluse` | An intent match on a scenario with targets holds that PreToolUse for up to 8 s of derivation, on top of the 4 s probe | fixed |
1252
+ | audit | low | cli | `cli-isbirhook-overbroad` | isBirHook matches any hook URL that ends in a common route, and install drops the whole matcher group, deleting the user's own hooks (even in the team settings.json) | fixed |
1253
+ | audit | low | cli | `cli-generic-drops-fields` | Wrapping replaces the whole entry, so fields such as `disabled`, `autoApprove` and `timeout` (in --config clients) are lost | fixed |
1254
+ | audit | low | cli | `scenario-server-stale-entry-undetected` | `bir doctor` never checks the `bir` entry's Node and script paths, and entries use process.execPath where the hook uses the resolved real path | fixed |
1255
+ | audit | low | daemon | `hookserver-shutdown-no-deadline` | shutdown() waits on an unbounded flush, and on POSIX a second SIGTERM is ignored, so `bir down` and restart-on-skew cannot stop a recorder stuck on a slow service | fixed |
1256
+ | audit | low | daemon | `daemon-ensure-budget` | The SessionStart hook waits far longer than it needs to, and some paths exceed Claude Code's 30 s hook timeout | fixed |
1257
+ | audit | low | daemon | `daemon-windows-launch-latency` | Windows cold start spends about 1 s starting powershell.exe; the fallback launch opens a visible console window | partial — no visible console, bounded launcher; PowerShell launch (~0.7 s once per project per boot) kept by default, BIR_WINDOWS_LAUNCH=spawn opt-in |
1258
+ | audit | low | daemon | `daemon-shutdown-unbounded` | Shutdown waits on network sends with no deadline, SIGTERM is ignored once shutdown has started, and a skew restart ignores a failed stop | fixed |
1259
+ | audit | low | daemon | `daemon-log-hygiene` | The recorder log only rotates at start and has no error handler; stale discovery files are never cleaned up | fixed |
1260
+ | audit | low | daemon | `daemon-inherits-api-key` | The background recorder inherits Claude Code's ANTHROPIC_API_KEY, so replay derivation silently runs on the user's own key instead of the service | fixed |
1261
+ | audit | low | daemon | `empirical-cold-sessionstart-powershell` | Cold SessionStart on Windows takes about 2.1 s, of which about 1.9 s is the PowerShell Start-Process launcher | partial — as above |
1262
+ | audit | low | http | `upstream-sse-parser-quadratic-and-cr` | The SSE parser re-scans its whole buffer on every chunk (large results become slow), has no size cap, and hangs on legal lone-CR line endings | fixed |
1263
+ | audit | low | http | `upstream-remote-credential-and-protocol-hygiene` | Remote upstream hygiene: bearer headers passed on the command line and stored in installed.json; auth sent to whatever origin the SSE endpoint names; no DELETE on close; no MCP-Pro | fixed — headers moved from argv to BIR_UPSTREAM_HEADERS; cross-origin endpoint refused; credentials dropped on cross-origin redirect; DELETE on close |
1264
+ | audit | low | proxy | `proxy-upstream-death-masked` | A dead upstream is masked: the proxy stays alive answering 'not available' forever, so the host never sees the server fail; with --restart, in-flight calls hang and the new child i | fixed |
1265
+ | audit | low | proxy | `proxy-small-leaks` | Small unbounded state: calls-map entries leak for requests to a dead upstream, and the upstream-stderr forwarder buffers without limit when no newline arrives | fixed |
1266
+ | audit | low | proxy | `proxy-audit-lines-surface-as-server-errors` | Routine proxy audit lines on stderr appear in Claude Code's MCP logs as 'Server stderr' error entries for every wrapped server | fixed |
1267
+ | audit | low | proxy | `upstream-windows-path-key-case-breaks-cmd-resolution` | Windows: .cmd resolution (npx, npm-installed server bins) fails whenever the inherited environment spells the key `Path`, and spawn fails with ENOENT | fixed |
1268
+ | audit | low | proxy | `upstream-windows-close-orphans-real-server` | Windows: close() kills only cmd.exe, leaving the real MCP server (and anything it launched) running; the kill is also immediate, with no graceful stdin-close window | fixed |
1269
+ | audit | low | proxy | `upstream-restart-flag-hangs-and-skips-initialize` | --restart: calls in flight at the crash and calls sent during the restart window never get an answer, and the restarted server is never initialised | fixed |
1270
+ | audit | low | proxy | `replay-poll-busy-loop-when-disabled` | Proxies poll the daemon 4 times per second when a restarted daemon has replay turned off | fixed |
1271
+ | audit | low | proxy | `empirical-proxy-repeated-downgrade-no-rebind` | When the recorder dies, a proxy downgrades once per queued step and never re-binds when the recorder comes back | fixed |
1272
+ | audit | low | proxy | `empirical-redact-before-truncate-on-hot-path` | Large payloads are redacted in full before truncation, synchronously, on PostToolUse and before the proxy writes the result | fixed — proxy reports after the answer is written; truncate before redact |
1273
+ | audit | low | proxy | `empirical-schema-relaxation-exposes-internals` | In Tier 1 every wrapped tool's schema shown to the model gains `__bir_call_id__` and `additionalProperties: true` | fixed |
1274
+ | audit | low | record | `recording-credentials-write-unsafe` | credentials.json is written non-atomically, and refresh responses are saved without validation | fixed |
1275
+ | audit | low | record | `recording-per-run-map-leak` | The per-run maps and the pendingSeals array are only cleared by flush, which normal Stop-ended turns never call | fixed — lanes and per-run maps released on drain/finish |
1276
+ | audit | low | record | `recording-log-volume` | Failure lines embed whole response bodies (HTML error pages), and the daemon log is rotated only when a recorder starts | fixed — bodies described/clipped, once-per-run failure lines, runtime log rotation |
1277
+ | audit | low | record | `recording-redact-over-matches-keys` | Substring key matching redacts ordinary arguments (author, max_tokens, nextPageToken, tokenizer), damaging recordings that replay then reuses | fixed |
1278
+ | audit | low | record | `replay-probe-text-unredacted` | The intent probe sends the model's reasoning or thinking text unredacted | fixed |
1279
+ | audit | low | replay | `replay-directive-unclipped-reasoning` | The steering directive embeds each step's recorded reasoning without any clip | fixed |
1280
+ | audit | low | scenario | `scenario-server-read-tools-token-cost` | The three read-only tools are listed in every session and make up 87% of the `bir` server's per-request tool-definition cost | fixed |
1281
+ | audit | low | scenario | `scenario-server-null-body-no-answer` | A control-port reply of `null` makes run_scenario never answer | fixed |
1282
+ | audit | low | scenario | `scenario-server-run-timeout-shorter-than-plan` | The client's 180 s timeout is shorter than the recorder's worst case (~188 s), so a finished replay can arrive as 'This operation was aborted' | fixed — client 290 s vs ~111 s worst case |
1283
+ | audit | low | server | `hookserver-late-hook-swallows-next-prompt` | A tool hook that arrives after Stop opens a prompt-less run, and the next prompt is then never matched and is recorded with an empty input | fixed |
1284
+ | audit | low | server | `hookserver-stale-wrapped-set-injects-call-id` | The wrapped-server set is computed once and never shrinks, so after a config change the call id is injected into servers that are no longer proxied | fixed |
1285
+ | audit | low | server | `daemon-unbounded-session-state` | The long-lived recorder never forgets sessions or finished turns (including matched scenario payloads) | fixed |
1286
+ | audit | low | server | `replay-direct-step-on-other-sessions-proxy` | A session with no bound proxy takes the newest unbound proxy, which may belong to another open session | fixed — no newest-unbound guess when other sessions are live |
1287
+ | audit | low | server | `replay-match-timeout-silent` | A match that misses its budget logs nothing, and a late hit leaves recording on, so every step POST returns 404 | fixed |
1288
+ | audit | low | server | `empirical-daemon-session-state-never-pruned` | The long-lived daemon keeps every session forever, so memory and /health grow without bound | fixed |
1289
+ | final review | high | invisible | `posttoolusefailure-wrong-event-name` | /tool/post answers PostToolUseFailure with hookEventName "PostToolUse". Claude Code rejects the answer, shows the user a hook error that dumps the runner's JSON, and the replay not | fixed |
1290
+ | final review | high | upgrade | `UPG-1` | A 0.2.12 `bir` that runs after the upgrade (rollback, or a second copy on PATH) blanks the user's MCP credentials, because the new installed.json keeps redacted stashes in the same | fixed |
1291
+ | final review | medium | invisible | `slow-service-defeats-breaker` | A slow service that still answers (2.5-10 s) adds the full 2.5 s match budget to every prompt: the answer's own ok() closes the cool-down that slow() just opened | fixed |
1292
+ | final review | medium | invisible | `bir-off-env-not-honoured-by-running-recorder` | BIR_OFF=1 in a session's environment never reaches a recorder that is already running, so hooks keep recording and replay can steer. A recorder started from a BIR_OFF=1 session sta | fixed |
1293
+ | final review | medium | invisible | `direct-plan-without-live-delivery-tool` | A direct plan is announced whenever the MCP config lists the bir server. If that server is not actually connected, the model's first ordinary call diverges, and the runner executes | fixed |
1294
+ | final review | medium | invisible | `recorder-down-midsession-hook-errors` | If the recorder dies mid-session, every later HTTP hook is shown to the user as a hook error until the next SessionStart. Nothing restarts it mid-session. | fixed |
1295
+ | final review | medium | lifecycle | `LC-1` | A recorder superseded by a port change never exits and cannot be found or stopped, and while it lives the project's shared recorder log stops rotating | fixed |
1296
+ | final review | medium | platform | `PLAT-1` | Windows `cmd /c npx` entries pass the user's own server arguments through cmd.exe unescaped: `&`, `\|`, `<`, `>`, `^` and `%VAR%` get mangled | fixed |
1297
+ | final review | medium | platform | `PLAT-2` | The claude.json path now follows CLAUDE_CONFIG_DIR at call time, so `bir uninstall` (and `--all`, BIR_UNINSTALL=1) from a different environment reports success and leaves wrapped e | fixed |
1298
+ | final review | medium | replay | `replay-esc-direct-run-effects-undisclosed` | After Esc on run_scenario, the steps that already ran are never disclosed to the model, and the interrupted turn is booked as a 'diverged' saving | fixed — note stashed on the session and delivered on the next prompt; not booked as a saving |
1299
+ | final review | medium | security | `SEC-1` | Key-segment redaction no longer catches several secret key names that 0.2.12 redacted, so those values are uploaded in clear | fixed |
1300
+ | final review | medium | security | `SEC-2` | The derived-parameter injection guard lets argument injection and unquoted SQL through, and SQL plans still derive from recent tool results | fixed |
1301
+ | final review | medium | security | `SEC-3` | The pin guard still rewrites and auto-approves unrelated calls: Bash compares only the first command's head, and single-argument MCP tools pin on any shared key | fixed |
1302
+ | final review | medium | upgrade | `UPG-2` | The upgrade never replaces a 0.2.12 recorder that has an abandoned open run, so 0.2.12 auto-approval and latencies carry on after `bir setup`, and setup promises otherwise | fixed — setup/up probe a 0.2.12 recorder twice and replace it when idle |
1303
+ | final review | medium | upgrade | `UPG-3` | `bir off` and `bir uninstall` tell the user the running recorder is now inert, but a surviving 0.2.12 recorder ignores the off switch and keeps recording, auto-approving and replay | fixed |
1304
+ | final review | medium | upgrade | `UPG-6` | The upgrade silently removes auto-approval of wrapped MCP calls; headless `claude -p` fleets that relied on it start getting their MCP calls denied, with no notice | fixed |
1305
+ | final review | low | invisible | `prompt-waits-createrun-rtt` | With replay on, every prompt waits a full create-run round trip to the service, even for a user or project with no calculated scenarios | not fixed — with replay on, a healthy service costs one round trip per prompt (100-500 ms); removing it needs a service-side "no scenarios for this user/project" hint |
1306
+ | final review | low | invisible | `npx-ensure-latency` | npx installs now run `npx ... bir-hooks ensure` on every SessionStart, /clear and compaction. npx start-up is outside the ensure budget. | fixed — npx hooks run `ensure --budget-ms 8000` |
1307
+ | final review | low | invisible | `ensure-writes-claude-json-at-sessionstart` | The SessionStart ensure hook may rewrite ~/.claude.json (override resync) while Claude Code is starting, and so writing that file too | fixed |
1308
+ | final review | low | invisible | `call-id-schema-visible-by-default` | Every wrapped tool's schema still advertises __bir_call_id__ ("Reserved. Leave unset.") to the model, although by default ids are now injected only for pins or bypassPermissions | accepted — the reserved __bir_call_id__ property ("Reserved. Leave unset.") stays declared so bypassPermissions and pinned calls can carry an id |
1309
+ | final review | low | lifecycle | `LC-2` | A keeper refresh that finishes after `bir logout` or a sign-in to another account installs a backend that ignores credentials.json | fixed |
1310
+ | final review | low | lifecycle | `LC-3` | A remote MCP server behind a load balancer with a 60 s idle timeout makes bir-proxy write a stderr line every minute for the whole session | fixed |
1311
+ | final review | low | lifecycle | `LC-4` | While the service is down, every run with unsent steps retries at the same moment each time the cool-down ends | fixed |
1312
+ | final review | low | platform | `PLAT-3` | On macOS/Linux, setting up inside a linked git worktree writes a junk `home/.../.git/worktrees/<name>/info/exclude` tree into the user's worktree and never excludes settings.local. | fixed |
1313
+ | final review | low | platform | `PLAT-4` | install.ps1 adds --use-system-ca for Node 23.0–23.7, which do not have the flag, so the npm install and launchers fail | fixed |
1314
+ | final review | low | replay | `replay-blocked-pin-false-ran-note` | A pinned step that never runs (blocked by the user's deny rule or their own PreToolUse hook) is later reported as done, and the model's next call is rewritten to deliver that claim | fixed |
1315
+ | final review | low | replay | `replay-steer-abandoned-call-worded-failed` | A pinned MCP call that its upstream abandoned mid-call gets a 'failed / No step ran' hand-over; the new 'may or may not have completed' wording never fires on the real server path | fixed |
1316
+ | final review | low | replay | `replay-diverge-nothing-ran-booked-diverged` | A steer divergence that executes nothing and delivers nothing is still booked 'diverged' (a ledger saving) and is removed from the baseline | fixed |
1317
+ | final review | low | replay | `replay-recorded-standins-inflate-ledger` | With BIR_REPLAY_RECORDED_OUTPUTS=1, recorded stand-ins count as work done: a run where nothing ran live books fell_back or diverged | fixed |
1318
+ | final review | low | replay | `replay-intent-inplace-rewrites-unrelated-call` | With intent arming opted in, in-place delivery checks only the tool name, so the model's unrelated live call is rewritten or denied, bypassing the pin guard's plausibility rule | fixed |
1319
+ | final review | low | replay | `replay-steer-preview-missing-with-service-derive` | Steer directives almost never show argument previews when derivation goes to the service, so the new pin guard declines the model's placeholder calls and steer plans retire unused | fixed — matched steer plans get up to 1.2 s (inside the prompt budget) to settle for the preview |
1320
+ | final review | low | replay | `replay-pretool-budget-unclamped` | BIR_PRETOOL_REPLAY_BUDGET_MS is not held under the 12 s /tool/pre deadline, so divergence steps keep starting after the model has been told to carry on | fixed |
1321
+ | final review | low | security | `SEC-4` | `bir logout` sends the refresh token to BIR_AUTH_URL without checking the session's authUrl binding | fixed |
1322
+ | final review | low | security | `SEC-5` | On Windows, `bir login` passes the service-supplied verification URL to `cmd /c start`, so an `&` in it runs a command | fixed |
1323
+ | final review | low | security | `SEC-6` | Derivation requests send the raw prompt, and the raw thinking text on intent arms, while the recorder redacts the same text | fixed |
1324
+ | final review | low | security | `SEC-7` | Bash-channel hand-over files, which hold live tool outputs, are swept only when another oversized delivery happens, so they can stay on disk indefinitely | fixed — hand-over files swept by the recorder at start and daily |
1325
+ | final review | low | security | `SEC-8` | In `--mcp-json` mode, installed.json keeps the full pre-install .mcp.json text, including literal header and env secrets | fixed |
1326
+ | final review | low | security | `SEC-9` | The remote upstream's redirect follower keeps custom credential headers (X-Api-Key, Private-Token, …) across origins, including an https→http downgrade | fixed |
1327
+ | final review | low | security | `SEC-10` | The logic time limit covers only synchronous work: promise and timer continuations queued by a body escape it | fixed |
1328
+ | final review | low | upgrade | `UPG-4` | When a 0.2.12 recorder is left running beside the new one, its heartbeat takes back the discovery file every 30 min and the new recorder never reclaims it, so new sessions' proxies | fixed |
1329
+ | final review | low | upgrade | `UPG-5` | `bir setup` moves the project to a new port when the running 0.2.12 recorder's discovery file is more than 24 h old (after a long sleep), leaving two recorders | fixed — setup ignores discovery age when checking who holds the port |
1330
+ | final review | low | upgrade | `UPG-7` | 0.2.12 proxies still running after the recorder restart busy-poll it at 4 req/s when the upgrade turned replay off | fixed |
1331
+ | final review | low | upgrade | `UPG-8` | Once the recorder has restarted as the new version, reports from 0.2.12 proxies still running whose arguments contain DSNs, token query params or Basic auth no longer join their ho | fixed |
1332
+ | final review | low | upgrade | `UPG-9` | The version-skew restart runs in both directions, so two runner copies on one machine can keep replacing each other's recorder | fixed |
1333
+ | final review | low | upgrade | `UPG-10` | The migration leaves the team's .mcp.json reformatted (2-space) instead of byte-identical, so git shows it modified until uninstall | fixed |
1334
+
1335
+ ## Appendix B: Files changed since 0.2.12
1336
+
1337
+ 114 files changed: 46,564 insertions and 3,047 deletions. Source and scripts account for 67 files (+25,330 / -2,890), tests for 47 files (+21,234 / -157).
1338
+
1339
+ | Component | Files (insertions / deletions) | Lines |
1340
+ |---|---|---|
1341
+ | Shared foundations | **new** `src/util/service-health.ts` (+260), **new** `src/util/off-switch.ts` (+94), **new** `src/util/atomic-write.ts` (+110), `src/util/log.ts`, `src/util/journal.ts`, `src/index.ts` | +555 / -5 |
1342
+ | Hook server / control server | `src/control/server.ts` (+3,092 / -348), **new** `src/control/pin-guard.ts` (+547), `src/control/transcript.ts`, `src/control/client.ts`, `src/control/correlation.ts` | +4,082 / -462 |
1343
+ | Background recorder lifecycle | `src/control/daemon.ts` (+1,274 / -110), **new** `src/control/recorder-main.ts` (+847), **new** `src/control/recorder-keeper.ts` (+805), `src/control/discovery.ts`, `src/control/ensure-hook.ts`, `src/control/paths.ts`, `src/bin/bir-hooks.ts` | +3,523 / -450 |
1344
+ | bir-proxy (stdio) | `src/proxy/session.ts` (+718 / -101), **new** `src/proxy/revive.ts` (+270), `src/proxy/intercept.ts`, `src/proxy/relay.ts`, `src/jsonrpc/framing.ts`, `src/upstream/stdio-client.ts`, `src/upstream/client.ts`, `src/upstream/lazy-client.ts`, `src/bin/bir-proxy.ts` | +2,214 / -294 |
1345
+ | Remote http/sse upstreams | `src/upstream/http-client.ts` (+1,135 / -161), **new** `src/upstream/http-transport.ts` (+453), **new** `src/upstream/sse.ts` (+201), **new** `src/upstream/headers.ts` (+97) | +1,886 / -161 |
1346
+ | Recording, auth and privacy | `src/record/remote-recorder.ts` (+1,582 / -204), `src/record/redact.ts` (+671 / -36), **new** `src/record/switchable-recorder.ts` (+391), `src/record/truncate.ts`, `src/record/recorder.ts`, `src/auth/client.ts` | +3,136 / -307 |
1347
+ | Calculated replay | `src/replay/controller.ts` (+1,440 / -122), `src/replay/plan.ts` (+673 / -175), **new** `src/replay/safety.ts` (+304), **new** `src/replay/bash-channel.ts` (+137), `logic.ts`, `executor.ts`, `handover.ts`, `derive.ts`, `source-run.ts`, `targets.ts`, `tool-error.ts`, `types.ts`, `bundle.ts` | +3,243 / -449 |
1348
+ | CLI, install and config | `src/bin/bir.ts` (+3,067 / -414), `src/config/generate.ts` (+822 / -30), **new** `src/config/mcp-approval.ts` (+225), **new** `src/config/overrides.ts` (+174), **new** `src/config/node-path.ts` (+114), **new** `src/bin/service-read.ts` (+143), `src/config/adapters/claude-code.ts`, `src/config/adapters/generic.ts`, `src/config/resolve.ts`, `src/bin/setup.ts`, `src/bin/investigate.ts`, `src/bin/scenario-edit.ts`, `scripts/install.sh`, `scripts/install.ps1` | +5,490 / -632 |
1349
+ | The `bir` MCP server | `src/bin/bir-scenario.ts` (+631 / -130), **new** `src/bin/scenario-liveness.ts` (+488), **new** `src/bin/scenario-stderr.ts` (+82) | +1,201 / -130 |
1350
+ | Tests | 32 new test files; 13 existing test files, `test/smoke-replay.mjs` and `test/quiet.env` changed | +21,234 / -157 |
1351
+
1352
+ Git shows `src/control/correlation.ts` as binary because it contains two literal NUL bytes, used as a hash separator; 0.2.12 has them too. Review its diff with `git diff --text`.
1353
+
1354
+ **New test files, by component.**
1355
+
1356
+ - Shared foundations: `production-base`.
1357
+ - Hook server: `prod-server`, `prod-server-b`, `prod-server-c`, `prod-server-transcript`, `integ-server`, `final-server`.
1358
+ - Recorder lifecycle: `prod-daemon-process`, `prod-daemon-recorder`, `integ-daemon`, `final-daemon-process`, `final-daemon-units`.
1359
+ - Proxy: `prod-proxy`, `prod-proxy-process`, `integ-proxy`, `integ-proxy-process`, `final-proxy`.
1360
+ - Remote upstreams: `prod-http`.
1361
+ - Recording: `prod-record`, `prod-record-review`, `final-record`.
1362
+ - Replay: `prod-replay`, `integ-replay`, `final-replay`.
1363
+ - CLI: `prod-cli-install`, `prod-cli-commands`, `prod-cli-overrides`, `integ-cli`, `final-cli`.
1364
+ - `bir` MCP server: `prod-scenario`, `integ-scenario`, `final-scenario`.
1365
+
1366
+ All are under `test/` with the `.test.ts` suffix.
1367
+
1368
+ **Existing tests changed on purpose**, each to follow a behaviour change in §7:
1369
+
1370
+ - `bir-scenario`, `calls`, `cli`, `config`, `correlation`, `fallback`, `http`, `probe`, `recorder`, `replay-server`, `replay`, `segments`, `setup`;
1371
+ - `smoke-replay.mjs`.
1372
+
1373
+ None weakens an existing assertion except where the behaviour itself changed. Examples: the default tool list is now `["run_scenario"]`, and wrapped MCP calls no longer get `allow`.