@basein/runner 0.2.12 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/README.md +540 -369
  2. package/dist/auth/client.d.ts +65 -3
  3. package/dist/auth/client.js +305 -53
  4. package/dist/bin/bir-hooks.d.ts +72 -31
  5. package/dist/bin/bir-hooks.js +176 -284
  6. package/dist/bin/bir-proxy.d.ts +17 -2
  7. package/dist/bin/bir-proxy.js +222 -28
  8. package/dist/bin/bir-scenario.d.ts +61 -21
  9. package/dist/bin/bir-scenario.js +593 -122
  10. package/dist/bin/bir.d.ts +12 -6
  11. package/dist/bin/bir.js +2935 -489
  12. package/dist/bin/investigate.js +40 -15
  13. package/dist/bin/scenario-edit.d.ts +7 -1
  14. package/dist/bin/scenario-edit.js +43 -21
  15. package/dist/bin/scenario-liveness.d.ts +194 -0
  16. package/dist/bin/scenario-liveness.js +459 -0
  17. package/dist/bin/scenario-stderr.d.ts +43 -0
  18. package/dist/bin/scenario-stderr.js +76 -0
  19. package/dist/bin/service-read.d.ts +88 -0
  20. package/dist/bin/service-read.js +128 -0
  21. package/dist/bin/setup.d.ts +19 -0
  22. package/dist/bin/setup.js +146 -20
  23. package/dist/config/adapters/claude-code.d.ts +92 -14
  24. package/dist/config/adapters/claude-code.js +291 -101
  25. package/dist/config/adapters/generic.d.ts +4 -1
  26. package/dist/config/adapters/generic.js +13 -14
  27. package/dist/config/generate.d.ts +392 -14
  28. package/dist/config/generate.js +666 -19
  29. package/dist/config/mcp-approval.d.ts +76 -0
  30. package/dist/config/mcp-approval.js +210 -0
  31. package/dist/config/node-path.d.ts +42 -0
  32. package/dist/config/node-path.js +102 -0
  33. package/dist/config/overrides.d.ts +62 -0
  34. package/dist/config/overrides.js +136 -0
  35. package/dist/config/resolve.d.ts +70 -8
  36. package/dist/config/resolve.js +186 -15
  37. package/dist/control/client.d.ts +22 -0
  38. package/dist/control/client.js +26 -7
  39. package/dist/control/correlation.d.ts +23 -5
  40. package/dist/control/correlation.js +0 -0
  41. package/dist/control/daemon.d.ts +400 -18
  42. package/dist/control/daemon.js +1105 -102
  43. package/dist/control/discovery.d.ts +77 -5
  44. package/dist/control/discovery.js +186 -11
  45. package/dist/control/ensure-hook.d.ts +61 -0
  46. package/dist/control/ensure-hook.js +160 -8
  47. package/dist/control/paths.d.ts +26 -0
  48. package/dist/control/paths.js +34 -0
  49. package/dist/control/pin-guard.d.ts +83 -0
  50. package/dist/control/pin-guard.js +534 -0
  51. package/dist/control/recorder-keeper.d.ts +229 -0
  52. package/dist/control/recorder-keeper.js +721 -0
  53. package/dist/control/recorder-main.d.ts +73 -0
  54. package/dist/control/recorder-main.js +778 -0
  55. package/dist/control/server.d.ts +707 -31
  56. package/dist/control/server.js +2787 -302
  57. package/dist/control/transcript.d.ts +21 -1
  58. package/dist/control/transcript.js +378 -112
  59. package/dist/index.d.ts +5 -1
  60. package/dist/index.js +5 -1
  61. package/dist/jsonrpc/framing.d.ts +76 -12
  62. package/dist/jsonrpc/framing.js +216 -59
  63. package/dist/proxy/intercept.d.ts +49 -5
  64. package/dist/proxy/intercept.js +188 -59
  65. package/dist/proxy/relay.d.ts +16 -0
  66. package/dist/proxy/relay.js +77 -5
  67. package/dist/proxy/revive.d.ts +122 -0
  68. package/dist/proxy/revive.js +251 -0
  69. package/dist/proxy/session.d.ts +222 -14
  70. package/dist/proxy/session.js +708 -99
  71. package/dist/record/recorder.d.ts +51 -3
  72. package/dist/record/recorder.js +3 -0
  73. package/dist/record/redact.d.ts +103 -9
  74. package/dist/record/redact.js +659 -30
  75. package/dist/record/remote-recorder.d.ts +327 -32
  76. package/dist/record/remote-recorder.js +1517 -187
  77. package/dist/record/switchable-recorder.d.ts +126 -0
  78. package/dist/record/switchable-recorder.js +351 -0
  79. package/dist/record/truncate.d.ts +40 -1
  80. package/dist/record/truncate.js +111 -4
  81. package/dist/replay/bash-channel.d.ts +35 -0
  82. package/dist/replay/bash-channel.js +133 -0
  83. package/dist/replay/bundle.d.ts +10 -0
  84. package/dist/replay/bundle.js +10 -0
  85. package/dist/replay/controller.d.ts +369 -20
  86. package/dist/replay/controller.js +1176 -107
  87. package/dist/replay/derive.d.ts +1 -0
  88. package/dist/replay/derive.js +81 -24
  89. package/dist/replay/executor.d.ts +51 -16
  90. package/dist/replay/executor.js +114 -41
  91. package/dist/replay/handover.d.ts +54 -1
  92. package/dist/replay/handover.js +55 -5
  93. package/dist/replay/logic.d.ts +62 -10
  94. package/dist/replay/logic.js +140 -22
  95. package/dist/replay/plan.d.ts +201 -27
  96. package/dist/replay/plan.js +581 -141
  97. package/dist/replay/safety.d.ts +72 -0
  98. package/dist/replay/safety.js +303 -0
  99. package/dist/replay/source-run.d.ts +10 -0
  100. package/dist/replay/source-run.js +80 -35
  101. package/dist/replay/targets.d.ts +15 -1
  102. package/dist/replay/targets.js +62 -4
  103. package/dist/replay/tool-error.d.ts +15 -1
  104. package/dist/replay/tool-error.js +33 -1
  105. package/dist/replay/types.d.ts +7 -4
  106. package/dist/upstream/client.d.ts +56 -3
  107. package/dist/upstream/client.js +61 -4
  108. package/dist/upstream/headers.d.ts +43 -0
  109. package/dist/upstream/headers.js +90 -0
  110. package/dist/upstream/http-client.d.ts +213 -22
  111. package/dist/upstream/http-client.js +1034 -168
  112. package/dist/upstream/http-transport.d.ts +143 -0
  113. package/dist/upstream/http-transport.js +391 -0
  114. package/dist/upstream/lazy-client.d.ts +8 -0
  115. package/dist/upstream/lazy-client.js +29 -1
  116. package/dist/upstream/sse.d.ts +78 -0
  117. package/dist/upstream/sse.js +189 -0
  118. package/dist/upstream/stdio-client.d.ts +61 -1
  119. package/dist/upstream/stdio-client.js +356 -33
  120. package/dist/util/atomic-write.d.ts +29 -0
  121. package/dist/util/atomic-write.js +96 -0
  122. package/dist/util/journal.d.ts +15 -0
  123. package/dist/util/journal.js +48 -3
  124. package/dist/util/log.d.ts +10 -0
  125. package/dist/util/log.js +28 -1
  126. package/dist/util/off-switch.d.ts +46 -0
  127. package/dist/util/off-switch.js +91 -0
  128. package/dist/util/service-health.d.ts +133 -0
  129. package/dist/util/service-health.js +217 -0
  130. package/docs/BaseInstRunner.md +694 -621
  131. package/docs/calculatedReplay.md +1625 -1304
  132. package/docs/calculatedReplayGuide.md +1212 -1087
  133. package/docs/installRun.md +1013 -772
  134. package/docs/loginWeb.md +627 -607
  135. package/docs/mcpmark.md +49 -16
  136. package/docs/my-first-sample.md +569 -545
  137. package/docs/quickstart.md +384 -311
  138. package/docs/t-bench.md +406 -394
  139. package/package.json +58 -57
  140. package/production.md +1371 -0
  141. package/scripts/install.ps1 +97 -6
  142. package/scripts/install.sh +104 -7
@@ -0,0 +1,251 @@
1
+ /**
2
+ * revive — a bound proxy brings back the recorder that died under it.
3
+ *
4
+ * Every Claude Code hook but SessionStart is an HTTP post to the project's
5
+ * recorder. When that process dies mid-session (killed by an antivirus or the
6
+ * OOM killer, or a restart from another window that did not come back), every
7
+ * later prompt and tool call shows the user a hook error, and only a new
8
+ * session, `/clear` or a compaction — the next SessionStart — started it again.
9
+ * A bound proxy is the one long-lived process of the session that notices, so
10
+ * it asks for a new one: it launches `bir-hooks revive --cwd <project>`,
11
+ * detached, which starts the recorder under the same start lock the
12
+ * SessionStart hook uses, so any number of proxies (and bir-scenario) asking
13
+ * at once start one recorder.
14
+ *
15
+ * ONLY WHEN IT IS DEAD, NOT SLOW. All of these, checked in this order, cheapest
16
+ * first:
17
+ *
18
+ * - the project's discovery file is still there and names a pid that is no
19
+ * longer running. A recorder that stopped cleanly — `bir down`, a restart —
20
+ * removed its file, so a recorder the user stopped on purpose is never
21
+ * brought back from here; a pid that is alive (busy, or recycled by an
22
+ * unrelated process) is never second-guessed either;
23
+ * - the recorder's address refuses a TCP connection. Anything that accepts
24
+ * one — a recorder coming up, whatever else took the port — or does not
25
+ * answer within a couple of seconds is left alone;
26
+ * - `bir install` set this directory up (the sidecar has its project record).
27
+ *
28
+ * NEVER IN ANYONE'S WAY. {@link RecorderReviver.nudge} is synchronous and
29
+ * constant-time: it only arms a timer. The checks run on that timer, after a
30
+ * random 0-5 s (so the proxies of one session do not all launch in the same
31
+ * instant), and the launch is a detached, unref'd process with no stdio: the
32
+ * proxy neither waits for it nor learns how it went. At most one launch per
33
+ * {@link REVIVE_MIN_INTERVAL_MS} per process, backing off to
34
+ * {@link REVIVE_MAX_INTERVAL_MS} while launches keep not taking (a recorder
35
+ * that dies on start). Each launch is one audit line.
36
+ *
37
+ * Not for an explicit control channel (`BIR_CONTROL_URL` + `BIR_CONTROL_TOKEN`,
38
+ * the Agent SDK's): that control server is its owner's to run.
39
+ */
40
+ import { spawn } from "node:child_process";
41
+ import { readFile } from "node:fs/promises";
42
+ import { connect } from "node:net";
43
+ import { pidAlive } from "../control/discovery.js";
44
+ import { discoveryPath, installedPath } from "../control/paths.js";
45
+ import { projectRecord } from "../config/generate.js";
46
+ import { isLoopbackHost } from "../upstream/http-transport.js";
47
+ import { errText, logDetail, logLine } from "../util/log.js";
48
+ /** At most one launch per process in this long. */
49
+ export const REVIVE_MIN_INTERVAL_MS = 30_000;
50
+ /** ...doubling per launch that did not take, up to this. */
51
+ export const REVIVE_MAX_INTERVAL_MS = 10 * 60_000;
52
+ /** A launch is delayed by a random 0 to this, so sibling proxies spread out. */
53
+ export const REVIVE_MAX_JITTER_MS = 5_000;
54
+ /** The checks themselves (two small file reads, a loopback connect) run at most this often. */
55
+ const CHECK_INTERVAL_MS = 10_000;
56
+ /**
57
+ * A connect that neither succeeds nor is refused in this long is "slow", not
58
+ * "dead". Generous: nobody waits on it, and a refusal is not always instant.
59
+ */
60
+ const REFUSED_PROBE_MS = 2_500;
61
+ /**
62
+ * The project's discovery file as it is on disk, when the pid it names is no
63
+ * longer running. `readDiscovery` hides exactly these files — correctly, for
64
+ * binding — and they are the evidence here: a recorder that stopped cleanly
65
+ * removed its own.
66
+ */
67
+ export async function deadRecorder(cwd) {
68
+ let parsed;
69
+ try {
70
+ parsed = JSON.parse(await readFile(discoveryPath(cwd), "utf8"));
71
+ }
72
+ catch {
73
+ return undefined;
74
+ }
75
+ if (!parsed || typeof parsed.pid !== "number" || typeof parsed.url !== "string")
76
+ return undefined;
77
+ if (pidAlive(parsed.pid))
78
+ return undefined;
79
+ return { pid: parsed.pid, url: parsed.url };
80
+ }
81
+ /** Whether the install sidecar has a project record for `cwd`. Unreadable → no. */
82
+ export async function projectRecordedHere(cwd) {
83
+ try {
84
+ const parsed = JSON.parse(await readFile(installedPath(), "utf8"));
85
+ const sidecar = { version: 1, servers: {}, ...parsed };
86
+ return projectRecord(sidecar, cwd) !== undefined;
87
+ }
88
+ catch {
89
+ return false;
90
+ }
91
+ }
92
+ /**
93
+ * True only when a loopback `url`'s port actively refuses a TCP connection.
94
+ * Accepted, timed out, any other error, or not a loopback address: false.
95
+ */
96
+ export function refusesConnections(url, timeoutMs = REFUSED_PROBE_MS) {
97
+ let target;
98
+ try {
99
+ target = new URL(url);
100
+ }
101
+ catch {
102
+ return Promise.resolve(false);
103
+ }
104
+ if (!isLoopbackHost(target.hostname))
105
+ return Promise.resolve(false);
106
+ const port = Number(target.port || (target.protocol === "https:" ? 443 : 80));
107
+ const host = target.hostname.replace(/^\[|\]$/g, "");
108
+ return new Promise((resolve) => {
109
+ let settled = false;
110
+ const socket = connect({ host, port });
111
+ const done = (refused) => {
112
+ if (settled)
113
+ return;
114
+ settled = true;
115
+ clearTimeout(timer);
116
+ socket.destroy();
117
+ resolve(refused);
118
+ };
119
+ const timer = setTimeout(() => done(false), timeoutMs);
120
+ timer.unref?.();
121
+ socket.once("connect", () => done(false));
122
+ socket.once("error", (err) => done(err.code === "ECONNREFUSED"));
123
+ });
124
+ }
125
+ /**
126
+ * Launch `bir-hooks revive --cwd <cwd>` detached, and forget it. Never throws.
127
+ *
128
+ * On Windows the revive always starts the recorder through PowerShell's
129
+ * `Start-Process`, whatever `BIR_WINDOWS_LAUNCH` says: a process started from
130
+ * here inherits this proxy's handles — its stdout is the host's JSON-RPC pipe
131
+ * — and a recorder launched directly from it would hold that pipe for days
132
+ * (see HOW IT IS LAUNCHED in control/daemon.ts). The revive itself lives for
133
+ * seconds.
134
+ */
135
+ export async function launchRevive(cwd, opts = {}) {
136
+ try {
137
+ // Loaded here, not at the top: the daemon module is needed only on the
138
+ // rare occasion a recorder died, never on a proxy's start-up path.
139
+ const { hooksScriptPath, WINDOWS_LAUNCH_ENV } = await import("../control/daemon.js");
140
+ const env = { ...(opts.env ?? process.env) };
141
+ if (process.platform === "win32") {
142
+ // Windows variable names ignore case; a copy of the environment does not.
143
+ for (const key of Object.keys(env))
144
+ if (key.toUpperCase() === WINDOWS_LAUNCH_ENV)
145
+ delete env[key];
146
+ }
147
+ const script = opts.script ?? hooksScriptPath();
148
+ const child = spawn(process.execPath, [script, "revive", "--cwd", cwd], {
149
+ detached: true,
150
+ stdio: "ignore",
151
+ windowsHide: true,
152
+ env,
153
+ });
154
+ child.once("error", (err) => logDetail("proxy.recorder_revive_failed", { error: errText(err) }));
155
+ child.unref();
156
+ return true;
157
+ }
158
+ catch (err) {
159
+ logDetail("proxy.recorder_revive_failed", { error: errText(err) });
160
+ return false;
161
+ }
162
+ }
163
+ /** One per bound proxy: decides whether, and when, to launch a revive. */
164
+ export class RecorderReviver {
165
+ opts;
166
+ minIntervalMs;
167
+ maxIntervalMs;
168
+ maxJitterMs;
169
+ checkIntervalMs;
170
+ interval;
171
+ timer;
172
+ pending = false;
173
+ stopped = false;
174
+ nextLaunchAt = 0;
175
+ nextCheckAt = 0;
176
+ lastLaunchAt = 0;
177
+ /** How many revives this process launched. */
178
+ launches = 0;
179
+ constructor(opts) {
180
+ this.opts = opts;
181
+ this.minIntervalMs = opts.minIntervalMs ?? REVIVE_MIN_INTERVAL_MS;
182
+ this.maxIntervalMs = Math.max(this.minIntervalMs, opts.maxIntervalMs ?? REVIVE_MAX_INTERVAL_MS);
183
+ this.maxJitterMs = opts.maxJitterMs ?? REVIVE_MAX_JITTER_MS;
184
+ this.checkIntervalMs = opts.checkIntervalMs ?? CHECK_INTERVAL_MS;
185
+ this.interval = this.minIntervalMs;
186
+ }
187
+ /**
188
+ * The recorder did not answer. Arms the check, unless one is pending or
189
+ * the rate limit says not yet. Synchronous, constant-time, never throws.
190
+ */
191
+ nudge(why) {
192
+ if (this.stopped || this.pending)
193
+ return;
194
+ const now = Date.now();
195
+ if (now < this.nextLaunchAt || now < this.nextCheckAt)
196
+ return;
197
+ if (process.env.BIR_CONTROL_URL && process.env.BIR_CONTROL_TOKEN)
198
+ return;
199
+ this.pending = true;
200
+ const jitter = this.maxJitterMs > 0 ? Math.floor(Math.random() * (this.maxJitterMs + 1)) : 0;
201
+ this.timer = setTimeout(() => {
202
+ this.timer = undefined;
203
+ void this.attempt(why)
204
+ .catch((err) => logDetail("proxy.recorder_revive_failed", { server: this.opts.server, error: errText(err) }))
205
+ .finally(() => {
206
+ this.pending = false;
207
+ });
208
+ }, jitter);
209
+ this.timer.unref?.();
210
+ }
211
+ /** No more launches: the proxy is closing. */
212
+ stop() {
213
+ this.stopped = true;
214
+ clearTimeout(this.timer);
215
+ this.timer = undefined;
216
+ }
217
+ async attempt(why) {
218
+ this.nextCheckAt = Date.now() + this.checkIntervalMs;
219
+ if (this.stopped)
220
+ return;
221
+ const cwd = this.opts.cwd;
222
+ const dead = await deadRecorder(cwd);
223
+ if (!dead || this.stopped)
224
+ return;
225
+ if (!(await refusesConnections(dead.url)) || this.stopped)
226
+ return;
227
+ // Last: the sidecar can be large (it keeps backups of the files install edited).
228
+ if (!(await projectRecordedHere(cwd)) || this.stopped)
229
+ return;
230
+ const now = Date.now();
231
+ // A while without a launch: whatever went wrong before is history.
232
+ if (this.lastLaunchAt && now - this.lastLaunchAt > this.maxIntervalMs)
233
+ this.interval = this.minIntervalMs;
234
+ const launched = this.opts.launch
235
+ ? await this.opts.launch(cwd)
236
+ : await launchRevive(cwd, { env: this.opts.env });
237
+ if (!launched)
238
+ return;
239
+ this.launches += 1;
240
+ this.lastLaunchAt = now;
241
+ this.nextLaunchAt = now + this.interval;
242
+ logLine("proxy.recorder_revive", {
243
+ server: this.opts.server,
244
+ pid: dead.pid,
245
+ why,
246
+ effect: "the project's recorder had died; a new one is being started in the background",
247
+ });
248
+ this.interval = Math.min(this.maxIntervalMs, this.interval * 2);
249
+ }
250
+ }
251
+ //# sourceMappingURL=revive.js.map
@@ -9,21 +9,42 @@
9
9
  * the run; this proxy only reports steps. Built-ins and MCP land in one
10
10
  * ordered stream, with the prompt and the final answer.
11
11
  *
12
- * **Tier 2 — Standalone.** No control server inside the discovery window. The
13
- * proxy owns a run of its own and records MCP calls only — no prompt, no final
14
- * answer, no built-in steps. That is not a degraded bug; it is the honest
15
- * ceiling of what an MCP proxy can observe, and the run says so in
16
- * `metadata.tier` so nothing downstream mistakes it for a complete trace.
12
+ * **Tier 2 — Standalone.** No control server inside the discovery window, in
13
+ * a project that asked to be recorded. The proxy owns a run of its own and
14
+ * records MCP calls only — no prompt, no final answer, no built-in steps.
15
+ * That is not a degraded bug; it is the honest ceiling of what an MCP proxy
16
+ * can observe, and the run says so in `metadata.tier` so nothing downstream
17
+ * mistakes it for a complete trace.
18
+ *
19
+ * **Passthrough.** No control server, in a project that never opted in. A
20
+ * user-scope server is wrapped once, by `bir install` in *one* project, but
21
+ * Claude Code starts it in *every* project — and Tier 2 there meant uploading
22
+ * that project's tool arguments and results to the service without anyone
23
+ * having asked. So a hooks-wired user-scope entry records only where its
24
+ * directory was set up, or a recorder is running for it; anywhere else it
25
+ * relays and records nothing. Every other entry — another client's config
26
+ * file, a project-scope entry, a hand-pasted `bir wrap` — keeps Tier 2 (see
27
+ * `optedIn`).
17
28
  *
18
29
  * THE DISCOVERY WINDOW EXISTS BECAUSE OF A RACE: the host may spawn MCP servers
19
30
  * before `SessionStart` fires, and the ordering between the two is not
20
31
  * guaranteed. So steps are **buffered in memory** while the search runs, and
21
32
  * flushed into whichever owner wins. Nothing is lost to the race, and nothing
22
33
  * blocks: relaying never waits on this.
34
+ *
35
+ * AND IT IS NOT A ONE-WAY DOOR. A recorder that starts late, or is restarted
36
+ * mid-session (an upgrade), is found again: while not bound, the proxy looks
37
+ * for it every {@link REBIND_INTERVAL_MS} and binds when it answers. Losing it
38
+ * costs one downgrade and at most one standalone run per proxy, however many
39
+ * reports were queued when it went. A recorder that *died* under a bound proxy
40
+ * — its pid gone, its port refusing — is started again from here, in the
41
+ * background (proxy/revive.ts): until it is back, every hook of the session
42
+ * fails in front of the user.
23
43
  */
24
44
  import type { UpstreamClient } from "../upstream/client.js";
25
45
  import type { ProxyStepReport } from "../control/correlation.js";
26
46
  import { type Recorder } from "../record/recorder.js";
47
+ import { type ReviverOptions } from "./revive.js";
27
48
  /** How long a proxy waits for a control server before falling to Tier 2. */
28
49
  /**
29
50
  * Ten seconds, not five: the recorder is now started by the SessionStart hook,
@@ -33,7 +54,17 @@ import { type Recorder } from "../record/recorder.js";
33
54
  * dropped, and a host session is never waiting on this.
34
55
  */
35
56
  export declare const DISCOVERY_WINDOW_MS = 10000;
36
- export type Tier = "bound" | "standalone" | "pending";
57
+ /** While not bound, how often to look for a control server that came (back) up. */
58
+ export declare const REBIND_INTERVAL_MS = 15000;
59
+ /**
60
+ * Step reports waiting to be sent. Tier 1 sends go to loopback and take
61
+ * milliseconds; Tier 2 sends wait on the service (one step at a time, see
62
+ * `dispatch`). Either way a backlog this deep means the far end is not
63
+ * keeping up, and the oldest is dropped — and the run flagged lossy — rather
64
+ * than growing inside the user's MCP server process tree.
65
+ */
66
+ export declare const STEP_BACKLOG = 250;
67
+ export type Tier = "bound" | "standalone" | "pending" | "passthrough";
37
68
  export interface ProxySessionOptions {
38
69
  serverName: string;
39
70
  cwd: string;
@@ -42,6 +73,10 @@ export interface ProxySessionOptions {
42
73
  /** Suppress schema relaxation; the control server joins on fingerprints instead. */
43
74
  noCorrelation?: boolean;
44
75
  discoveryWindowMs?: number;
76
+ /** While not bound, how often to look for a control server again. Tests shorten it. */
77
+ rebindIntervalMs?: number;
78
+ /** The work loop's idle ceiling ({@link MAX_POLL_BACKOFF_MS}). Tests shorten it. */
79
+ maxPollBackoffMs?: number;
45
80
  /** Injected by tests. */
46
81
  recorderFactory?: () => Promise<Recorder | undefined>;
47
82
  /**
@@ -51,7 +86,50 @@ export interface ProxySessionOptions {
51
86
  * would have come here falls back to a recorded output.
52
87
  */
53
88
  upstream?: UpstreamClient;
89
+ /**
90
+ * Bringing back a recorder that died under this proxy (proxy/revive.ts).
91
+ * `false` turns it off; an object overrides its launch and pacing (tests).
92
+ */
93
+ revive?: false | Partial<Omit<ReviverOptions, "cwd" | "server">>;
54
94
  }
95
+ /**
96
+ * The service address a Tier 2 recorder should use for `configured`, the way
97
+ * the recorder (control/recorder-keeper.ts) and `bir login` bind sessions:
98
+ *
99
+ * - A saved session issued by the https form of a configured `http://host`
100
+ * is the same service, already upgraded — use its address. No request.
101
+ * - Any other plain-http address off this machine is checked once for a
102
+ * same-host https redirect ({@link normalizeAuthUrl}, bounded): following
103
+ * one would strip the bearer token from every request. Not when signed
104
+ * out: there is no token to send.
105
+ * - Everything else is used as configured.
106
+ */
107
+ export declare function normalizedServiceUrl(configured: string, fetchImpl?: typeof fetch): Promise<string>;
108
+ /** What the install sidecar says about recording here, for one wrapped server. */
109
+ export interface InstallFacts {
110
+ /** `bir install` set this directory up (a project record, or a server record made here). */
111
+ installedHere: boolean;
112
+ /**
113
+ * The scope of every install record for this server name, wherever it was
114
+ * installed: `user` for a `~/.claude.json` user-scope entry, `project` /
115
+ * `local` for Claude Code's per-project ones, `explicit` for another
116
+ * client's file (`install --config`). Empty when no install wrote an entry
117
+ * under this name — a `bir wrap` entry pasted into a client by hand.
118
+ */
119
+ scopes: string[];
120
+ }
121
+ /**
122
+ * Read the install sidecar's facts for `cwd` and `serverName`.
123
+ *
124
+ * Read asynchronously, and only when a decision needs it: the sidecar carries
125
+ * byte-exact backups of the files install edited — `~/.claude.json` among
126
+ * them, which can run to megabytes — and a proxy must not spend its start-up
127
+ * parsing that in front of the host's `initialize`. Unreadable → no facts,
128
+ * which every caller treats as "not installed, not written by an install".
129
+ */
130
+ export declare function readInstallFacts(cwd: string, serverName?: string): Promise<InstallFacts>;
131
+ /** Whether `bir install` set this directory up for recording. */
132
+ export declare function projectInstalledHere(cwd: string): Promise<boolean>;
55
133
  export declare class ProxySession {
56
134
  readonly serverName: string;
57
135
  /** This proxy process, as the control server tells proxies apart (replay/executor.ts). */
@@ -59,7 +137,11 @@ export declare class ProxySession {
59
137
  private readonly opts;
60
138
  private tierValue;
61
139
  private control?;
62
- /** Tier 2 only. */
140
+ /** Where `control` lives, for the log and to reuse a client across re-binds. */
141
+ private controlKey?;
142
+ /** The last control server this proxy was bound to; the work loop polls it. */
143
+ private pollClient?;
144
+ /** Tier 2 only. One per proxy, however often the tier changes. */
63
145
  private recorder?;
64
146
  private runId?;
65
147
  private ordering;
@@ -68,23 +150,111 @@ export declare class ProxySession {
68
150
  private buffer;
69
151
  private lossyValue;
70
152
  private negotiation?;
153
+ private downgrading?;
71
154
  private startedAt;
72
155
  /** The replay work loop, when one was started. */
73
156
  private workLoop?;
157
+ /** What the control server last said about calculated replay. */
158
+ private replayOn;
159
+ private pollHoldMs;
160
+ private rebindTimer?;
161
+ /** A discovery file for this cwd has been seen: a recorder was started here. */
162
+ private sawDiscovery;
163
+ /**
164
+ * May a control server inject call ids here? Decided from the project at
165
+ * start-up (see {@link declaresCallId}), not from how negotiation is going.
166
+ */
167
+ private expectsBinding;
168
+ /** `readInstallFacts(cwd, serverName)`, started once and shared by everything that asks. */
169
+ private installFactsPromise?;
74
170
  private closing;
171
+ /**
172
+ * The work loop's current pause between polls. An instance field, not a
173
+ * local, so that {@link bind} can reset it: a proxy that finds its recorder
174
+ * again must poll it now, not after a backoff it grew while it was gone.
175
+ */
176
+ private pollBackoff;
177
+ /** Ends the work loop's current pause early (see {@link pause}). */
178
+ private wakeLoop?;
179
+ /** A re-registration in flight, so a burst of triggers makes one request. */
180
+ private reregistering?;
181
+ /**
182
+ * Brings back a recorder that died under this proxy. Made at the first
183
+ * bind: only a proxy that has had a recorder asks for one back.
184
+ */
185
+ private reviver?;
75
186
  constructor(opts: ProxySessionOptions);
187
+ /** The install sidecar's facts for this directory and server. Read once, off the start-up path. */
188
+ private installFacts;
189
+ private isInstalled;
76
190
  get tier(): Tier;
77
191
  get lossy(): boolean;
78
192
  /**
79
193
  * Whether the schema-relaxation half of correlation should be applied to
80
- * `tools/list` (Phase 6). Only ever true in Tier 1 with correlation enabled:
81
- * relaxation is visible to the model on every call, so it is never paid for
82
- * unless it is actually buying a merge.
194
+ * `tools/list` (Phase 6). Only ever true in Tier 1 with correlation enabled.
83
195
  */
84
196
  get correlationActive(): boolean;
197
+ /**
198
+ * Whether `tools/list` should declare the call-id key. True where a control
199
+ * server may inject one: bound, or still negotiating in a project that is set
200
+ * up for it — decided from the project, not from whether registration
201
+ * happened to finish before the host listed tools, so a warm and a cold
202
+ * start show the model the same schemas.
203
+ */
204
+ get declaresCallId(): boolean;
205
+ /** False when nothing this proxy sees will be recorded (passthrough, or the runner is off). */
206
+ get recording(): boolean;
85
207
  /** Begin tier negotiation. Returns immediately; relaying never waits on it. */
86
208
  start(): void;
209
+ /** Cheap first guess, at construction, of whether this proxy will end up bound. */
210
+ private likelyBound;
211
+ /**
212
+ * May this proxy record on its own (Tier 2) when no control server answers?
213
+ *
214
+ * The one entry that must NOT is the one that leaks: a Claude Code
215
+ * **user-scope** server. `bir install` wraps it once, in one project, but
216
+ * Claude Code starts it in every project — and Tier 2 there meant uploading
217
+ * that project's tool arguments and results without anyone having asked.
218
+ * Everything else records as it always did:
219
+ *
220
+ * - An explicit SDK channel (`BIR_CONTROL_TOKEN`) or `--standalone`: yes.
221
+ * - An entry without `BIR_CONTROL_URL` (`install --no-hooks`, a `bir wrap`
222
+ * made before any install): Tier 2 is what it asked for.
223
+ * - This directory was installed, or a recorder has run for it: yes.
224
+ * - Otherwise it depends on where the entry came from, which the install
225
+ * sidecar knows by server name. Another client's file (`install
226
+ * --config`, whose host starts servers from any directory — Claude
227
+ * Desktop uses `/`), a project- or local-scope entry (which Claude Code
228
+ * only starts in its own project), or no install record at all (a `bir
229
+ * wrap` entry, which bakes `BIR_CONTROL_URL` too once any install has
230
+ * run): yes. Only when every record for this name is a user-scope one is
231
+ * this a global entry running somewhere nobody set up — and then no.
232
+ *
233
+ * Keying this on `BIR_CONTROL_URL` alone was wrong: `install --config` and
234
+ * `bir wrap` bake it as well, and those entries silently stopped recording
235
+ * whenever their host's directory differed from the install's.
236
+ */
237
+ private optedIn;
87
238
  private negotiate;
239
+ /** Where this cwd's control server is, if anywhere. */
240
+ private findControl;
241
+ /** Register with this cwd's control server, if one answers. True when bound. */
242
+ private tryBind;
243
+ private bind;
244
+ private maxIdleBackoff;
245
+ /**
246
+ * Tell the control server we exist again — it answered after a spell of not
247
+ * answering, so it is probably a restarted one that has never heard of this
248
+ * proxy (its `/health` would not list it, and it would not know this server
249
+ * has a proxy that can strip a call id). One request at a time; best-effort.
250
+ */
251
+ private reregister;
252
+ /** Sleep `ms`, or less if {@link wakeLoop} is called first. True when woken early. */
253
+ private pause;
254
+ /** No control server: record on our own if this project opted in, else stay out of the way. */
255
+ private settleUnbound;
256
+ /** While not bound, look for a control server again now and then. */
257
+ private scheduleRebind;
88
258
  /**
89
259
  * Take calculated-scenario steps from the control server and run them on the
90
260
  * upstream this proxy already owns (docs/calculatedReplay.md §16.2).
@@ -93,9 +263,23 @@ export declare class ProxySession {
93
263
  * allocates an id in the reserved `bir:` space and the transport consumes its
94
264
  * own response in `accept()` before the relay's handlers ever see it — so this
95
265
  * costs **zero tokens**, the model is not involved, and `RecordingInterceptor`
96
- * cannot double-record it. That interface comment says `request()` is "never
97
- * used by the relay"; it was built for `bir doctor` and tests, and this is its
98
- * second, load-bearing user.
266
+ * cannot double-record it.
267
+ *
268
+ * PACING. A long-poll that is held is free; one that is not must not become
269
+ * four requests a second for the rest of the session. So:
270
+ *
271
+ * - held (the server parked it until work or its deadline): poll again now;
272
+ * - unreachable (usually a recorder restarting): back off 250 ms → 5 s
273
+ * ({@link MAX_UNREACHABLE_BACKOFF_MS}), and when it answers again,
274
+ * register again so the new process knows about us;
275
+ * - answered at once, or `{ replay: false }`: back off to one poll every
276
+ * {@link MAX_POLL_BACKOFF_MS}. Never stop: replay is turned back on by a
277
+ * restart that leaves this proxy bound, and a loop that had stopped for
278
+ * good left every later scenario step falling back to recorded outputs
279
+ * until the host itself was restarted.
280
+ *
281
+ * {@link bind} resets the pace and ends a pause early, so a proxy that finds
282
+ * its recorder again polls it at once rather than after a stale backoff.
99
283
  */
100
284
  private startWorkLoop;
101
285
  /**
@@ -107,8 +291,20 @@ export declare class ProxySession {
107
291
  * buffered nor recordable, and every `tools/call` in that window is lost.
108
292
  * `dispatch` re-buffers defensively too — belt and braces on a race whose
109
293
  * failure mode is silent data loss.
294
+ *
295
+ * One run per proxy: a proxy that went standalone, re-bound, and lost its
296
+ * control server again carries on in the run it already owns.
110
297
  */
111
298
  private becomeStandalone;
299
+ /**
300
+ * Record nothing; relay only. Held steps are released, not kept.
301
+ *
302
+ * Detail, not a stderr line: this is the designed outcome for a user-scope
303
+ * server in a project nobody set up, and a line there would appear as a
304
+ * "Server stderr" error in a project that never asked for the runner at all.
305
+ * `bir doctor`, run in that directory, is where "not set up here" is told.
306
+ */
307
+ private becomePassthrough;
112
308
  private buildRecorder;
113
309
  /**
114
310
  * Record one completed `tools/call`. Fire-and-forget by contract: it is called
@@ -119,7 +315,19 @@ export declare class ProxySession {
119
315
  private bufferStep;
120
316
  private flushBuffer;
121
317
  private dispatch;
122
- /** Close the Tier 2 run, if we own one, and drain the queue. */
318
+ /**
319
+ * The control server stopped answering. Exactly once per loss, however many
320
+ * reports were queued against it: later failures wait for this one and are
321
+ * re-routed, instead of each building a recorder and opening a run.
322
+ */
323
+ private downgrade;
324
+ /**
325
+ * Close the Tier 2 run, if we own one, and drain the queue — within
326
+ * {@link CLOSE_BUDGET_MS} all told. bir-proxy gives shutdown a hard 3 s; a
327
+ * close that spent it waiting on a slow service never reached `finishRun`.
328
+ * Whatever has not been sent by then carries on in the background for as
329
+ * long as the process lives.
330
+ */
123
331
  close(): Promise<void>;
124
332
  }
125
333
  //# sourceMappingURL=session.d.ts.map