@basein/runner 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +276 -0
  3. package/dist/auth/client.d.ts +85 -0
  4. package/dist/auth/client.js +284 -0
  5. package/dist/bin/bir-hooks.d.ts +48 -0
  6. package/dist/bin/bir-hooks.js +201 -0
  7. package/dist/bin/bir-proxy.d.ts +45 -0
  8. package/dist/bin/bir-proxy.js +207 -0
  9. package/dist/bin/bir-scenario.d.ts +24 -0
  10. package/dist/bin/bir-scenario.js +177 -0
  11. package/dist/bin/bir.d.ts +21 -0
  12. package/dist/bin/bir.js +876 -0
  13. package/dist/config/adapters/claude-code.d.ts +76 -0
  14. package/dist/config/adapters/claude-code.js +181 -0
  15. package/dist/config/adapters/generic.d.ts +17 -0
  16. package/dist/config/adapters/generic.js +36 -0
  17. package/dist/config/generate.d.ts +127 -0
  18. package/dist/config/generate.js +114 -0
  19. package/dist/config/resolve.d.ts +68 -0
  20. package/dist/config/resolve.js +132 -0
  21. package/dist/control/client.d.ts +56 -0
  22. package/dist/control/client.js +86 -0
  23. package/dist/control/correlation.d.ts +86 -0
  24. package/dist/control/correlation.js +0 -0
  25. package/dist/control/discovery.d.ts +50 -0
  26. package/dist/control/discovery.js +123 -0
  27. package/dist/control/ordering.d.ts +38 -0
  28. package/dist/control/ordering.js +44 -0
  29. package/dist/control/paths.d.ts +32 -0
  30. package/dist/control/paths.js +56 -0
  31. package/dist/control/server.d.ts +272 -0
  32. package/dist/control/server.js +1131 -0
  33. package/dist/control/transcript.d.ts +75 -0
  34. package/dist/control/transcript.js +241 -0
  35. package/dist/index.d.ts +37 -0
  36. package/dist/index.js +32 -0
  37. package/dist/jsonrpc/framing.d.ts +49 -0
  38. package/dist/jsonrpc/framing.js +143 -0
  39. package/dist/jsonrpc/types.d.ts +52 -0
  40. package/dist/jsonrpc/types.js +46 -0
  41. package/dist/proxy/intercept.d.ts +55 -0
  42. package/dist/proxy/intercept.js +147 -0
  43. package/dist/proxy/relay.d.ts +97 -0
  44. package/dist/proxy/relay.js +166 -0
  45. package/dist/proxy/session.d.ts +116 -0
  46. package/dist/proxy/session.js +319 -0
  47. package/dist/record/housekeeping.d.ts +34 -0
  48. package/dist/record/housekeeping.js +39 -0
  49. package/dist/record/queue.d.ts +48 -0
  50. package/dist/record/queue.js +96 -0
  51. package/dist/record/recorder.d.ts +111 -0
  52. package/dist/record/recorder.js +39 -0
  53. package/dist/record/redact.d.ts +37 -0
  54. package/dist/record/redact.js +119 -0
  55. package/dist/record/remote-recorder.d.ts +110 -0
  56. package/dist/record/remote-recorder.js +301 -0
  57. package/dist/record/truncate.d.ts +36 -0
  58. package/dist/record/truncate.js +85 -0
  59. package/dist/replay/bundle.d.ts +36 -0
  60. package/dist/replay/bundle.js +89 -0
  61. package/dist/replay/controller.d.ts +300 -0
  62. package/dist/replay/controller.js +807 -0
  63. package/dist/replay/coverage.d.ts +41 -0
  64. package/dist/replay/coverage.js +56 -0
  65. package/dist/replay/derive.d.ts +58 -0
  66. package/dist/replay/derive.js +166 -0
  67. package/dist/replay/executor.d.ts +78 -0
  68. package/dist/replay/executor.js +233 -0
  69. package/dist/replay/logic.d.ts +31 -0
  70. package/dist/replay/logic.js +50 -0
  71. package/dist/replay/plan.d.ts +181 -0
  72. package/dist/replay/plan.js +397 -0
  73. package/dist/replay/pricing.d.ts +41 -0
  74. package/dist/replay/pricing.js +76 -0
  75. package/dist/replay/source-run.d.ts +50 -0
  76. package/dist/replay/source-run.js +98 -0
  77. package/dist/replay/tool-error.d.ts +22 -0
  78. package/dist/replay/tool-error.js +60 -0
  79. package/dist/replay/types.d.ts +116 -0
  80. package/dist/replay/types.js +35 -0
  81. package/dist/upstream/client.d.ts +78 -0
  82. package/dist/upstream/client.js +114 -0
  83. package/dist/upstream/http-client.d.ts +78 -0
  84. package/dist/upstream/http-client.js +261 -0
  85. package/dist/upstream/lazy-client.d.ts +31 -0
  86. package/dist/upstream/lazy-client.js +53 -0
  87. package/dist/upstream/stdio-client.d.ts +57 -0
  88. package/dist/upstream/stdio-client.js +203 -0
  89. package/dist/util/log.d.ts +27 -0
  90. package/dist/util/log.js +51 -0
  91. package/dist/util/version.d.ts +2 -0
  92. package/dist/util/version.js +40 -0
  93. package/docs/BaseInstRunner.md +621 -0
  94. package/docs/calculatedReplay.md +1185 -0
  95. package/docs/calculatedReplayGuide.md +448 -0
  96. package/docs/installRun.md +413 -0
  97. package/docs/mcpmark.md +752 -0
  98. package/docs/quickstart.md +201 -0
  99. package/docs/t-bench.md +394 -0
  100. package/package.json +56 -0
@@ -0,0 +1,621 @@
1
+ # BaseInstRunnerMCP — Design & Implementation Plan
2
+
3
+ > A recording MCP **proxy**: it sits between any MCP client (Claude Code, Cursor,
4
+ > Codex, an Agent SDK session) and every MCP server that client uses, executes
5
+ > each call on the client's behalf, and records the run as a reusable scenario in
6
+ > the BaseIn service.
7
+ >
8
+ > **Status:** design. No code written yet.
9
+ > **Relationship to RRepeat:** a separate solution. RRepeat records by *observing*
10
+ > a Claude Code session through hooks; BaseInstRunnerMCP records by *being* the
11
+ > execution path for MCP. They share a recorder contract and can run side by side.
12
+
13
+ ---
14
+
15
+ ## 0. Decisions locked
16
+
17
+ | # | Decision | Choice | Consequence |
18
+ |---|---|---|---|
19
+ | D1 | Stack | TypeScript / Node, standalone repo | Ports `McpClient`, `McpBridge`, `RemoteRecorder`, the scope merge and the auth client from RRepeat instead of reinventing them |
20
+ | D2 | Scope | MCP proxy **+ companion hook binary** | The only combination that reaches non-MCP tools (`Bash`, `Read`, `Edit`) |
21
+ | D3 | Topology | Per-server shadow, **auto-generated** config | Preserves `mcp__<server>__<tool>` names; N proxy processes, one per upstream |
22
+ | D4 | v1 scope | **Record only** | Replay/divergence deferred to v2 |
23
+ | D5 | Run correlation | **Hook binary owns the run** | One monotonic step counter across built-in and MCP steps |
24
+ | D6 | Persistence | BaseIn auth-service HTTP | Reuses `/recordings/*`, tenancy and the savings pipeline |
25
+ | D7 | Protocol | **Full transparent passthrough** | Generic bidirectional relay, not a `tools/*` switch |
26
+ | D8 | Callers | **Any MCP client** | No assumptions about config paths or tool-name conventions |
27
+
28
+ ### 0.1 The tension in D5 + D8, and how it is resolved
29
+
30
+ D5 says the hook binary owns run identity. D8 says support any MCP client. Only
31
+ Claude Code has hooks. Both cannot hold universally, so run ownership is
32
+ **negotiated at proxy startup**, with two tiers:
33
+
34
+ | Tier | When | Run owner | What gets recorded |
35
+ |---|---|---|---|
36
+ | **Tier 1 — Bound** | A BIR control server is discoverable for this cwd (Claude Code or Agent SDK with `bir-hooks` installed) | Control server | Built-ins **and** MCP, one ordered step stream, prompt and final answer included |
37
+ | **Tier 2 — Standalone** | No control server found within the discovery window | The proxy itself | MCP calls only. No prompt, no final answer, no built-in steps — a partial scenario, explicitly flagged |
38
+
39
+ Tier 2 is not a degraded bug, it is the honest ceiling of what an MCP proxy can
40
+ observe. Scenarios record which tier produced them so downstream consumers never
41
+ mistake a partial trace for a complete one.
42
+
43
+ ---
44
+
45
+ ## 1. Goals and non-goals
46
+
47
+ **Goals**
48
+
49
+ 1. Execute every MCP `tools/call` the client makes, on the client's behalf, and
50
+ return the upstream's result **losslessly** — content blocks, `structuredContent`,
51
+ `isError`, `_meta` — so the client cannot tell the proxy is there.
52
+ 2. Record each call as a step in a BaseIn run: server, tool, arguments, result,
53
+ duration, error.
54
+ 3. Be transparent for everything that is not `tools/call`: prompts, resources,
55
+ logging, completion, progress, cancellation, and the **reverse** direction
56
+ (sampling, elicitation, roots, list-changed notifications).
57
+ 4. Never be the reason a host session fails. Every failure degrades to passthrough
58
+ or to not-recording; none propagates.
59
+ 5. Install itself: given a client's existing MCP configuration, produce the
60
+ proxied configuration automatically and reversibly.
61
+
62
+ **Non-goals for v1**
63
+
64
+ - Replay, divergence bundles, steering. (v2 — §12.)
65
+ - Intercepting non-MCP tools *without* the hook binary. Structurally impossible.
66
+ - Wrapping `claude-in-chrome`. It is `scope: "dynamic"`, present in no config file,
67
+ so there is no entry to rewrite.
68
+ - Model-side behaviour change. The proxy never edits tool descriptions to steer
69
+ selection.
70
+
71
+ ---
72
+
73
+ ## 2. The structural constraint
74
+
75
+ This is the single most important fact in the document, and every design choice
76
+ below follows from it.
77
+
78
+ **An MCP server sees only MCP traffic addressed to it.** The model does not choose
79
+ the proxy — it emits `mcp__chrome-devtools__navigate_page`, and the client routes
80
+ that name to whichever process is registered under the key `chrome-devtools`. That
81
+ makes interception a *configuration* guarantee, not a prompting one, which is
82
+ strong. But its reach is exactly the set of configured MCP servers:
83
+
84
+ | Traffic | Proxy sees it? | Why |
85
+ |---|---|---|
86
+ | `mcp__<wrapped>__*` | **Yes, always** | Routed by config key |
87
+ | `mcp__<unwrapped>__*` | No | Client connects to that server directly |
88
+ | `Bash`, `Read`, `Edit`, `Grep`, `Glob`, `Task`, `WebFetch` | **No** | Not MCP. Hook binary only |
89
+ | `claude-in-chrome` | No | Dynamic scope, no config entry |
90
+ | User prompt, model reasoning, final answer | **No** | Not tool traffic. Hook binary only |
91
+
92
+ Hence D2. The proxy and the hook binary are two halves of one recorder.
93
+
94
+ ---
95
+
96
+ ## 3. Architecture
97
+
98
+ ```mermaid
99
+ graph TB
100
+ subgraph host["Host application (Claude Code / Cursor / SDK)"]
101
+ M[model loop]
102
+ H["hooks (Claude Code only)"]
103
+ end
104
+
105
+ subgraph bir["BaseInstRunnerMCP"]
106
+ C["bir-hooks<br/>control server<br/>:PORT"]
107
+ P1["bir-proxy<br/>server: chrome-devtools"]
108
+ P2["bir-proxy<br/>server: github"]
109
+ end
110
+
111
+ U1[chrome-devtools-mcp]
112
+ U2["github mcp (http)"]
113
+ R[("BaseIn auth-service<br/>/recordings/*")]
114
+
115
+ M -->|"mcp__chrome-devtools__*"| P1
116
+ M -->|"mcp__github__*"| P2
117
+ H -->|"built-in steps, prompt, stop"| C
118
+ P1 <-->|stdio JSON-RPC| U1
119
+ P2 <-->|http/sse JSON-RPC| U2
120
+ P1 -->|"step report + call id"| C
121
+ P2 -->|"step report + call id"| C
122
+ C -->|ordered run| R
123
+ ```
124
+
125
+ ### 3.1 Components
126
+
127
+ | Component | Process | Responsibility |
128
+ |---|---|---|
129
+ | `bir-proxy` | one per wrapped upstream, spawned by the host | Bidirectional JSON-RPC relay. Executes `tools/call` against the upstream, reports the step, forwards everything else verbatim |
130
+ | `bir-hooks` | one per host session | Claude Code hook receiver **and** control server. Owns run identity and the monotonic step counter. Records built-in tool steps, prompt, final answer |
131
+ | `bir` | CLI | `install`, `uninstall`, `status`, `doctor`, `wrap` |
132
+ | Recorder | library, inside `bir-hooks` (Tier 1) or `bir-proxy` (Tier 2) | Streams steps to the BaseIn service |
133
+
134
+ ### 3.2 Why a control server, rather than each proxy recording directly
135
+
136
+ Three proxies recording independently would produce three interleaved step streams
137
+ with no shared ordering, and would double-record every call the hook also sees. A
138
+ single control server gives one `stepIndex` sequence, one run lifecycle, one HTTP
139
+ chain to the recorder, and one place to dedupe. It is the role RRepeat's
140
+ `HookServer` already plays, widened to accept proxy reports.
141
+
142
+ ---
143
+
144
+ ## 4. Solution layout
145
+
146
+ ```
147
+ BaseInstRunnerMCP/
148
+ ├─ package.json # bin: bir, bir-proxy, bir-hooks
149
+ ├─ tsconfig.json
150
+ ├─ src/
151
+ │ ├─ bin/
152
+ │ │ ├─ bir-proxy.ts # stdio MCP proxy entry point
153
+ │ │ ├─ bir-hooks.ts # hook receiver + control server entry point
154
+ │ │ └─ bir.ts # install / status / doctor / wrap
155
+ │ ├─ jsonrpc/
156
+ │ │ ├─ framing.ts # line-delimited JSON-RPC read/write
157
+ │ │ └─ types.ts # Request | Response | Notification
158
+ │ ├─ proxy/
159
+ │ │ ├─ relay.ts # bidirectional relay, default-forward
160
+ │ │ ├─ intercept.ts # the small intercept table
161
+ │ │ └─ session.ts # per-proxy lifecycle, tier negotiation
162
+ │ ├─ upstream/
163
+ │ │ ├─ client.ts # UpstreamClient interface
164
+ │ │ ├─ stdio-client.ts # spawn + framing
165
+ │ │ └─ http-client.ts # streamable-http and sse
166
+ │ ├─ control/
167
+ │ │ ├─ server.ts # local HTTP control plane
168
+ │ │ ├─ discovery.ts # ~/.baseinstrunner/control/<key>.json
169
+ │ │ ├─ correlation.ts # __bir_call_id__ inject/extract/dedupe
170
+ │ │ └─ ordering.ts # monotonic stepIndex allocator
171
+ │ ├─ record/
172
+ │ │ ├─ recorder.ts # Recorder interface
173
+ │ │ ├─ remote-recorder.ts # BaseIn HTTP implementation
174
+ │ │ ├─ redact.ts # secret scrubbing
175
+ │ │ └─ truncate.ts # payload size caps
176
+ │ ├─ config/
177
+ │ │ ├─ resolve.ts # merged scope resolution per client
178
+ │ │ ├─ generate.ts # wrap entries → proxied config
179
+ │ │ └─ adapters/
180
+ │ │ ├─ claude-code.ts # 3-scope merge, ~/.claude.json + .mcp.json
181
+ │ │ └─ generic.ts # explicit --config <path>
182
+ │ └─ auth/client.ts # login, token refresh
183
+ └─ test/
184
+ ├─ fixtures/ # golden JSON-RPC transcripts
185
+ ├─ fake-upstream.ts # scriptable MCP server for tests
186
+ └─ *.test.ts
187
+ ```
188
+
189
+ ---
190
+
191
+ ## 5. Core contracts
192
+
193
+ ```ts
194
+ // upstream/client.ts — every upstream transport implements this.
195
+ export interface UpstreamClient {
196
+ /** Resolves when the handshake completed; rejects if the upstream is unusable. */
197
+ readonly ready: Promise<InitializeResult>;
198
+ /** Send any request and get the raw JSON-RPC result. No shape assumptions. */
199
+ request(method: string, params?: unknown, signal?: AbortSignal): Promise<unknown>;
200
+ /** Fire-and-forget notification toward the upstream. */
201
+ notify(method: string, params?: unknown): void;
202
+ /** Requests and notifications the upstream originates (sampling, elicitation, ...). */
203
+ onIncoming(handler: (msg: JsonRpcMessage) => void): void;
204
+ close(): void;
205
+ }
206
+
207
+ // record/recorder.ts — the surface both tiers use. Mirrors RRepeat's RemoteRecorder
208
+ // so the BaseIn server needs no new endpoints for v1.
209
+ export interface Recorder {
210
+ startRun(input: string, metadata?: Record<string, unknown>): string;
211
+ recordToolSelected(runId: string, stepIndex: number,
212
+ d: { toolName: string; toolInput: string; context?: string }): string;
213
+ recordToolResponse(runId: string, stepIndex: number,
214
+ d: { toolName: string; toolOutput?: string; toolError?: string }): string;
215
+ recordFinalAnswer(runId: string, stepIndex: number, d: { answer: string }): string;
216
+ finishRun(runId: string, finalOutput?: string, metrics?: RunMetrics): void;
217
+ flush(runId: string): Promise<void>;
218
+ }
219
+
220
+ // control/correlation.ts
221
+ export const BIR_CALL_ID = "__bir_call_id__";
222
+
223
+ // The step a proxy reports to the control server.
224
+ export interface ProxyStepReport {
225
+ callId?: string; // present in Tier 1 when the hook injected one
226
+ serverName: string; // config key, e.g. "chrome-devtools"
227
+ toolName: string; // upstream-local name, e.g. "navigate_page"
228
+ qualifiedName: string; // client-rendered, e.g. "mcp__chrome-devtools__navigate_page"
229
+ args: unknown; // post-redaction
230
+ result?: unknown; // whole CallToolResult, post-redaction and truncation
231
+ isError: boolean;
232
+ errorMessage?: string;
233
+ startedAt: number; // epoch ms
234
+ durationMs: number;
235
+ }
236
+ ```
237
+
238
+ `qualifiedName` is stored **alongside** `serverName` + `toolName`, never instead of
239
+ them. `mcp__<server>__<tool>` is Claude Code's rendering; other clients differ.
240
+ Storing the parts keeps scenarios portable across hosts (D8).
241
+
242
+ ---
243
+
244
+ ## 6. Implementation, phase by phase
245
+
246
+ Each phase ends in something runnable and testable. Do not start a phase before
247
+ its predecessor's acceptance test passes.
248
+
249
+ ### Phase 0 — Scaffold
250
+
251
+ 1. `npm init`; TypeScript strict, ESM (`"type": "module"`), Node >= 20.
252
+ 2. `package.json` bin map:
253
+ ```json
254
+ { "bir": "dist/bin/bir.js",
255
+ "bir-proxy": "dist/bin/bir-proxy.js",
256
+ "bir-hooks": "dist/bin/bir-hooks.js" }
257
+ ```
258
+ 3. Zero runtime dependencies beyond `zod` if you want schema validation. Node's
259
+ built-in `fetch`, `child_process` and `http` cover the rest. Resist adding an MCP
260
+ SDK — full passthrough means handling unknown methods, which a typed SDK fights.
261
+
262
+ **Acceptance:** `npx bir --version` prints.
263
+
264
+ ### Phase 1 — Framing and the bidirectional relay
265
+
266
+ This is the heart, and it is deliberately dumb.
267
+
268
+ 1. `jsonrpc/framing.ts` — read newline-delimited JSON from a stream, write the same.
269
+ Handle partial reads, oversized lines, and invalid JSON (respond `-32700`, never
270
+ crash).
271
+ 2. `proxy/relay.ts` — connect two message ports (host stdio ↔ upstream) and forward
272
+ every message in both directions, **by default**.
273
+
274
+ The design rule that makes full passthrough tractable:
275
+
276
+ > **Default-forward, intercept by exception.** Do not enumerate the protocol.
277
+ > Forward any message you have no specific reason to touch. The intercept table
278
+ > is three entries long.
279
+
280
+ 3. Id handling. Because the relay is 1:1, host ids can pass through unchanged. Keep
281
+ a `Map<hostId, { method, startedAt }>` anyway — you need it to time `tools/call`
282
+ and to match `notifications/cancelled`. Reserve a disjoint id space (a `bir:`
283
+ prefix) for any request the proxy ever originates, so collisions are impossible.
284
+
285
+ **Acceptance:** `bir-proxy -- npx -y chrome-devtools-mcp@latest` behaves
286
+ byte-identically to running the upstream directly, verified by a golden transcript
287
+ in `test/fixtures/`.
288
+
289
+ ### Phase 2 — Upstream transports
290
+
291
+ 1. `stdio-client.ts` — `spawn(command, args, { env, stdio: ['pipe','pipe','pipe'] })`.
292
+ Forward upstream stderr to the proxy's own stderr **prefixed**, never to stdout;
293
+ a stray stdout byte corrupts the host's JSON-RPC stream. This is the single most
294
+ common way stdio MCP servers break.
295
+ 2. `http-client.ts` — streamable-http and sse. Carry `--header k:v` for auth, since
296
+ the host's own OAuth handling does not survive a proxy in front of it (§9).
297
+ 3. Lifecycle: upstream death is not proxy death. On exit, mark the upstream dead,
298
+ answer in-flight requests with a JSON-RPC error, optionally restart once with
299
+ backoff, then stay dead and answer `tools/list` with an error rather than exiting.
300
+
301
+ **Acceptance:** the Phase 1 transcript test passes against a stdio upstream, an
302
+ http upstream, and an upstream killed mid-call.
303
+
304
+ ### Phase 3 — The intercept table
305
+
306
+ Only three methods are touched. Everything else relays untouched, both directions.
307
+
308
+ | Method | Direction | Action |
309
+ |---|---|---|
310
+ | `initialize` | host → upstream | **Relay, and rewrite as little as possible.** Forward the host's `capabilities` verbatim so the upstream learns whether sampling/elicitation exist. Return the upstream's `capabilities` and `serverInfo` verbatim, so prompts/resources/logging stay advertised. Do **not** hard-code `{ tools: {} }` — that is exactly what makes a proxy silently swallow prompts and resources. |
311
+ | `tools/list` | host → upstream | Relay. If Tier 1 correlation is active, apply schema relaxation (§6, Phase 6). Otherwise leave schemas untouched. |
312
+ | `tools/call` | host → upstream | Extract and strip `__bir_call_id__`; time the call; relay; report the step; return the upstream result **verbatim**. |
313
+
314
+ Everything else — `prompts/*`, `resources/*`, `completion/complete`,
315
+ `logging/setLevel`, `ping`, `notifications/cancelled`, `notifications/progress`,
316
+ and the reverse-direction `sampling/createMessage`, `elicitation/create`,
317
+ `roots/list`, `notifications/*/list_changed`, `notifications/message` — passes
318
+ through with no special case. That is the point of D7: unknown methods work
319
+ *because* they are never enumerated.
320
+
321
+ Two rules that are easy to get wrong:
322
+
323
+ - **Never rewrite a `tools/call` result.** Return the upstream object as-is,
324
+ including `_meta` and `structuredContent`. Record a *copy*.
325
+ - **Never let recording block the response.** Report the step on a fire-and-forget
326
+ queue after the result is already on its way to the host.
327
+
328
+ **Acceptance:** an upstream exposing prompts and resources is fully usable through
329
+ the proxy — `prompts/list` and `resources/read` reach it and return.
330
+
331
+ ### Phase 4 — Recorder and auth
332
+
333
+ 1. Port RRepeat's `auth/client.ts` (login, `saveCredentials`, refresh-on-401).
334
+ 2. Port `RemoteRecorder` unchanged in shape: client-generated `run_`/`step_` uuids,
335
+ a per-run ordered promise chain, best-effort sends, one token refresh on 401.
336
+ Endpoints, already live: `POST /recordings/runs`,
337
+ `POST /recordings/runs/:id/steps`, `PATCH /recordings/runs/:id/steps/:stepId`,
338
+ `POST /recordings/runs/:id/finish`.
339
+ 3. `redact.ts` — scrub before anything leaves the process. Minimum: values under
340
+ keys matching `/(token|secret|password|api[_-]?key|authorization|cookie)/i`,
341
+ anything matching a bearer/JWT/`sk-` shape, and `env` blocks. Applied to
342
+ arguments *and* results.
343
+ 4. `truncate.ts` — cap each payload (suggest 64 KiB) with an explicit
344
+ `{ truncated: true, originalBytes }` marker. Base64 image blocks from browser
345
+ MCPs will otherwise dominate every run.
346
+
347
+ **Acceptance:** a Tier 2 run appears in the BaseIn service with correct steps and
348
+ no secret material, verified by a redaction fixture.
349
+
350
+ ### Phase 5 — Control plane and the hook binary (Tier 1)
351
+
352
+ **Discovery.** The control server writes
353
+ `~/.baseinstrunner/control/<sha256(cwd).slice(0,16)>.json`:
354
+
355
+ ```json
356
+ { "url": "http://127.0.0.1:53411",
357
+ "token": "<random 32 bytes, hex>",
358
+ "sessionId": "...",
359
+ "pid": 12345,
360
+ "startedAt": 1756600000000 }
361
+ ```
362
+
363
+ A proxy resolves it in this order: `BIR_CONTROL_URL` env (injected into generated
364
+ config, and the only reliable channel for SDK sessions) → the discovery file for
365
+ its cwd → give up. Give-up is bounded: **buffer steps in memory for up to 5 s while
366
+ retrying**, then fall to Tier 2 and flush the buffer into a self-owned run. The
367
+ window exists because the host may spawn MCP servers before `SessionStart` fires;
368
+ the ordering between the two is not guaranteed.
369
+
370
+ **Control server HTTP surface** (loopback only, ephemeral port, bearer token from
371
+ the discovery file required on every route):
372
+
373
+ | Route | Caller | Purpose |
374
+ |---|---|---|
375
+ | `POST /session/start` | hook | Open the run; returns `runId` |
376
+ | `POST /session/prompt` | hook | `UserPromptSubmit` — the run's `input` |
377
+ | `POST /tool/pre` | hook | Built-in step opened; returns `{ stepIndex, callId? }` |
378
+ | `POST /tool/post` | hook | Built-in step closed with its result |
379
+ | `POST /session/stop` | hook | Final answer |
380
+ | `POST /session/end` | hook | `finishRun` + flush |
381
+ | `POST /proxy/register` | proxy | `{ serverName, pid, cwd }` → `{ runId, sessionId }` |
382
+ | `POST /proxy/step` | proxy | A `ProxyStepReport`; allocates `stepIndex` |
383
+ | `GET /health` | CLI | `bir doctor` |
384
+
385
+ **Ordering.** `ordering.ts` hands out `stepIndex` from one counter shared by hook
386
+ and proxy reports. Allocation happens at *report* time, not call time, so
387
+ concurrent MCP calls are ordered by completion — deterministic, and honest about
388
+ what actually happened.
389
+
390
+ **Hook wiring.** `bir-hooks` registers `UserPromptSubmit`, `PreToolUse`,
391
+ `PostToolUse`, `PostToolUseFailure`, `Stop`, `StopFailure`, `SessionStart`,
392
+ `SessionEnd`. Use `PostToolUseFailure` rather than inferring failure from
393
+ `PostToolUse` shape, and `SubagentStart` / `SubagentStop` (which carry `agent_id`)
394
+ to attribute subagent steps correctly.
395
+
396
+ **Acceptance:** a Claude Code session running one built-in and one MCP tool
397
+ produces a single run whose steps are in true execution order.
398
+
399
+ ### Phase 6 — Correlation and dedupe
400
+
401
+ In Tier 1 a wrapped MCP call is seen **twice**: once by the hook at `PreToolUse`,
402
+ once by the proxy at `tools/call`. Without correlation you double-record.
403
+
404
+ **Mechanism.** At `PreToolUse` for a wrapped `mcp__*` tool, the hook returns
405
+ `permissionDecision: "allow"` with `updatedInput = { ...original, [BIR_CALL_ID]: id }`.
406
+ The proxy strips that key before relaying and echoes it on its step report. The
407
+ control server merges the two views into one step:
408
+
409
+ - the hook contributes identity, `tool_use_id`, ordering position, and the model's
410
+ original arguments;
411
+ - the proxy contributes the true upstream result, `isError`, `structuredContent`,
412
+ and real execution duration.
413
+
414
+ This is RRepeat's `RREPEAT_SENTINEL` trick generalised, so it is proven — but
415
+ inherit its cost knowingly: **carrying an extra argument requires relaxing the
416
+ tool's schema** (`additionalProperties: true` plus the declared key), and that
417
+ relaxation is visible to the model on every call, not only recorded ones.
418
+ Therefore:
419
+
420
+ - Apply relaxation **only** when Tier 1 correlation is active. Tier 2 never relaxes.
421
+ - Relax **only** wrapped servers.
422
+ - Provide `--no-correlation`, falling back to fingerprint matching —
423
+ `(serverName, toolName, hash(args))` inside a 30 s window — for hosts where
424
+ schema relaxation is unacceptable. Document it as lossy under identical
425
+ concurrent calls.
426
+
427
+ **Acceptance:** one MCP call in a Claude Code session produces exactly one step,
428
+ carrying both the model's arguments and the upstream's real result.
429
+
430
+ ### Phase 7 — Config generation and the CLI
431
+
432
+ 1. `config/adapters/claude-code.ts` — merge the three scopes in Claude Code's own
433
+ precedence order: **Local** (`~/.claude.json` → `projects[<cwd>].mcpServers`)
434
+ beats **Project** (`<cwd>/.mcp.json`) beats **User** (`~/.claude.json` top-level).
435
+ Match the local-scope key on a *normalised* path: Claude Code stores forward
436
+ slashes on Windows while `process.cwd()` returns backslashes. Getting this wrong
437
+ is the number one cause of "the proxy is installed but never runs".
438
+ 2. `config/generate.ts` — rewrite each resolved entry in place, keeping the key:
439
+ ```json
440
+ "chrome-devtools": {
441
+ "command": "npx",
442
+ "args": ["-y", "-p", "baseinstrunner", "bir-proxy",
443
+ "--server-name", "chrome-devtools", "--",
444
+ "<original command>", "<original args...>"],
445
+ "env": { "BIR_CONTROL_URL": "http://127.0.0.1:53411" }
446
+ }
447
+ ```
448
+ Remote entries become
449
+ `bir-proxy --server-name X --url <url> --transport http`.
450
+ 3. **Write to the winning scope.** If a local-scope entry shadows the project one,
451
+ rewriting `.mcp.json` changes nothing. Detect this and warn loudly.
452
+ 4. Reversibility: stash each original entry in a sidecar
453
+ (`~/.baseinstrunner/installed.json`) so `bir uninstall` restores exactly.
454
+ 5. `bir doctor` — resolve config, list which servers are wrapped, query the control
455
+ server's `/health`, and confirm every wrapped name has a *registered* proxy.
456
+ Registration at the control server is the guarantee check; do not infer it from
457
+ the config file, and do not infer it from `serverInfo` (which must stay the
458
+ upstream's, per Phase 3).
459
+ 6. `bir wrap -- <cmd>` — manual single-server mode for clients with no adapter.
460
+ This is what makes D8 real: the generic path never needs to know where a client
461
+ stores its settings.
462
+
463
+ **Acceptance:** `bir install` on a project with three MCP servers wraps all three,
464
+ `bir doctor` reports three registered proxies, and `bir uninstall` restores the
465
+ file byte-for-byte.
466
+
467
+ ### Phase 8 — Hardening
468
+
469
+ - **Backpressure.** Bound the step queue (suggest 1000). On overflow, drop oldest
470
+ and set `lossy: true` on the run. Never block a tool call on the recorder.
471
+ - **Degradation matrix** — §10, implemented as explicit branches, each with a test.
472
+ - **Startup cost.** N upstreams means N proxy processes *plus* N upstream processes.
473
+ Measure session-start latency; if it matters, add `--lazy` to defer the upstream
474
+ spawn until the first `tools/list`.
475
+ - **Windows.** `spawn` with `shell: false` and explicit `.cmd` resolution for `npx`;
476
+ path normalisation in the local-scope key; no assumptions about `$HOME`.
477
+
478
+ ---
479
+
480
+ ## 7. What a recorded step looks like
481
+
482
+ Mapped onto the existing BaseIn endpoints, one MCP call becomes two rows, exactly
483
+ as RRepeat records them today:
484
+
485
+ ```
486
+ POST /recordings/runs/:runId/steps
487
+ { id, stepIndex: 7, type: "tool_selected",
488
+ toolName: "mcp__chrome-devtools__navigate_page",
489
+ toolInput: "{\"url\":\"https://example.com\"}",
490
+ context: "<model reasoning, Tier 1 only>" }
491
+
492
+ POST /recordings/runs/:runId/steps
493
+ { id, stepIndex: 8, type: "tool_response",
494
+ toolName: "mcp__chrome-devtools__navigate_page",
495
+ toolOutput: "<serialised CallToolResult, redacted, truncated>" }
496
+ ```
497
+
498
+ Run `metadata` carries what is new, and what BaseIn-side consumers will need:
499
+
500
+ ```json
501
+ { "recorder": "baseinstrunner",
502
+ "tier": "bound",
503
+ "host": { "app": "claude-code", "version": "2.1.251" },
504
+ "wrappedServers": ["chrome-devtools", "github"],
505
+ "lossy": false }
506
+ ```
507
+
508
+ `tier` is the field that stops a Tier 2 partial trace being mistaken for a
509
+ complete one.
510
+
511
+ ---
512
+
513
+ ## 8. Full-passthrough method map
514
+
515
+ For implementers. The relay does not branch on these — the table documents what
516
+ must *survive* the relay, and is the basis of the conformance suite.
517
+
518
+ **Host → upstream:** `initialize`, `notifications/initialized`, `ping`,
519
+ `tools/list`, `tools/call`, `prompts/list`, `prompts/get`, `resources/list`,
520
+ `resources/templates/list`, `resources/read`, `resources/subscribe`,
521
+ `resources/unsubscribe`, `completion/complete`, `logging/setLevel`,
522
+ `notifications/cancelled`, `notifications/progress`,
523
+ `notifications/roots/list_changed`.
524
+
525
+ **Upstream → host:** `sampling/createMessage`, `elicitation/create`, `roots/list`,
526
+ `notifications/message`, `notifications/progress`, `notifications/cancelled`,
527
+ `notifications/tools/list_changed`, `notifications/resources/list_changed`,
528
+ `notifications/resources/updated`, `notifications/prompts/list_changed`.
529
+
530
+ The reverse direction is precisely why a `tools/*`-only bridge is not enough: an
531
+ upstream that issues `elicitation/create` and receives no answer **hangs**. Claude
532
+ Code surfaces these as the `Elicitation` hook event, so a dropped one is at least
533
+ observable.
534
+
535
+ ---
536
+
537
+ ## 9. Security
538
+
539
+ - The proxy sits on the credential path for every wrapped server. For remote
540
+ upstreams it holds the headers, because the host's own OAuth flow authenticates
541
+ the server it is *configured with* — which is now the proxy. Store nothing; read
542
+ from env or the OS keychain at spawn.
543
+ - Every argument and result crosses a process boundary and then the network.
544
+ Redaction (Phase 4.3) runs before both.
545
+ - The control server binds `127.0.0.1` only, on an ephemeral port, and requires the
546
+ token from the discovery file on every route. A local port that accepts
547
+ unauthenticated step reports is a local exfiltration channel.
548
+ - Discovery files are mode `0600`.
549
+ - `bir doctor` prints exactly which servers are wrapped, so a proxy can never sit
550
+ in the path invisibly.
551
+
552
+ ---
553
+
554
+ ## 10. Degradation matrix
555
+
556
+ The governing rule: **the host session must never fail because of BaseInstRunner.**
557
+
558
+ | Failure | Behaviour |
559
+ |---|---|
560
+ | Control server unreachable at startup | Buffer <= 5 s, then Tier 2; log once to stderr |
561
+ | Control server dies mid-session | Proxy switches to Tier 2 for the remainder; run flagged `lossy` |
562
+ | Recorder HTTP fails | Drop the send, log, continue. Never retry inline |
563
+ | Upstream fails to start | `initialize` still answers; `tools/list` returns an error. The host shows one broken server, not a broken session |
564
+ | Upstream dies mid-call | Return `isError: true` with the transport message as content — a tool failure the model can react to, not a protocol error |
565
+ | Unknown JSON-RPC method | Forward it. If the upstream rejects, relay the rejection |
566
+ | Step queue overflow | Drop oldest, flag the run `lossy` |
567
+ | Schema relaxation rejected by host | Disable correlation, fall back to fingerprint matching |
568
+
569
+ ---
570
+
571
+ ## 11. Testing
572
+
573
+ 1. **Transcript identity (the core test).** Record a golden JSON-RPC transcript
574
+ against a real upstream directly, then through the proxy. Every host-visible
575
+ byte except timing must match. Run it for stdio, http and sse.
576
+ 2. **`fake-upstream.ts`** — a scriptable MCP server that can advertise arbitrary
577
+ capabilities, emit `notifications/tools/list_changed`, issue
578
+ `elicitation/create`, delay, and die on command. Everything below builds on it.
579
+ 3. **Passthrough conformance** — one case per row of §8, both directions.
580
+ 4. **Correlation** — a fake hook injecting `__bir_call_id__`; assert exactly one
581
+ merged step. Then the `--no-correlation` fingerprint path, including the known
582
+ ambiguity under identical concurrent calls.
583
+ 5. **Config adapter** — fixture `~/.claude.json` + `.mcp.json` pairs covering scope
584
+ precedence, the Windows path-normalisation case, remote entries, already-wrapped
585
+ entries (idempotent install), and reserved names.
586
+ 6. **Redaction** — assert no fixture secret reaches the recorder.
587
+ 7. **Integration** — a real Claude Code session against `fake-upstream`, asserting
588
+ run shape end to end. The only test that catches host-behaviour drift.
589
+
590
+ ---
591
+
592
+ ## 12. Milestones
593
+
594
+ | Milestone | Phases | Deliverable |
595
+ |---|---|---|
596
+ | **M1 — Invisible proxy** | 0–2 | `bir-proxy` wraps any upstream with zero observable difference |
597
+ | **M2 — Standalone recording** | 3–4 | Tier 2 runs land in BaseIn. Works with any MCP client, today |
598
+ | **M3 — Bound recording** | 5–6 | Tier 1: built-ins + MCP, one ordered stream, deduped |
599
+ | **M4 — Product** | 7–8 | `bir install / doctor / uninstall`, hardened, documented |
600
+ | **v2 — Replay** | R0–R7 | Run a calculated scenario on a similar-meaning match. Designed in [calculatedReplay.md](calculatedReplay.md); the runbook is [calculatedReplayGuide.md](calculatedReplayGuide.md) |
601
+
602
+ M2 is the first genuinely useful release, and it does not depend on hooks at all —
603
+ ship it before touching the control plane.
604
+
605
+ ---
606
+
607
+ ## 13. Open questions
608
+
609
+ 1. **Does the BaseIn service need `tier` / `lossy` as real columns**, or is run
610
+ `metadata` enough for the savings pipeline to exclude partial traces from
611
+ baselines? A Tier 2 run has no prompt, so similarity matching on `input` behaves
612
+ differently.
613
+ 2. **Coexistence with RRepeat.** If both are installed, both record the same session
614
+ into the same service. Should `bir-hooks` detect `rrepeat-hooks` and refuse, or
615
+ should runs carry a `recorder` discriminator and be deduped server-side? §7's
616
+ metadata assumes the latter.
617
+ 3. **`--lazy` upstream spawn** — worth it, or are N idle upstream processes
618
+ acceptable? Needs a measurement on a realistic five-server config before deciding.
619
+ 4. **Fingerprint fallback under concurrency.** Two identical parallel `tools/call`s
620
+ are genuinely indistinguishable without a call id. Accept the ambiguity, or make
621
+ correlation mandatory for Tier 1?