@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.
- package/README.md +540 -369
- package/dist/auth/client.d.ts +65 -3
- package/dist/auth/client.js +305 -53
- package/dist/bin/bir-hooks.d.ts +72 -31
- package/dist/bin/bir-hooks.js +176 -284
- package/dist/bin/bir-proxy.d.ts +17 -2
- package/dist/bin/bir-proxy.js +222 -28
- package/dist/bin/bir-scenario.d.ts +61 -21
- package/dist/bin/bir-scenario.js +593 -122
- package/dist/bin/bir.d.ts +12 -6
- package/dist/bin/bir.js +2935 -489
- package/dist/bin/investigate.js +40 -15
- package/dist/bin/scenario-edit.d.ts +7 -1
- package/dist/bin/scenario-edit.js +43 -21
- package/dist/bin/scenario-liveness.d.ts +194 -0
- package/dist/bin/scenario-liveness.js +459 -0
- package/dist/bin/scenario-stderr.d.ts +43 -0
- package/dist/bin/scenario-stderr.js +76 -0
- package/dist/bin/service-read.d.ts +88 -0
- package/dist/bin/service-read.js +128 -0
- package/dist/bin/setup.d.ts +19 -0
- package/dist/bin/setup.js +146 -20
- package/dist/config/adapters/claude-code.d.ts +92 -14
- package/dist/config/adapters/claude-code.js +291 -101
- package/dist/config/adapters/generic.d.ts +4 -1
- package/dist/config/adapters/generic.js +13 -14
- package/dist/config/generate.d.ts +392 -14
- package/dist/config/generate.js +666 -19
- package/dist/config/mcp-approval.d.ts +76 -0
- package/dist/config/mcp-approval.js +210 -0
- package/dist/config/node-path.d.ts +42 -0
- package/dist/config/node-path.js +102 -0
- package/dist/config/overrides.d.ts +62 -0
- package/dist/config/overrides.js +136 -0
- package/dist/config/resolve.d.ts +70 -8
- package/dist/config/resolve.js +186 -15
- package/dist/control/client.d.ts +22 -0
- package/dist/control/client.js +26 -7
- package/dist/control/correlation.d.ts +23 -5
- package/dist/control/correlation.js +0 -0
- package/dist/control/daemon.d.ts +400 -18
- package/dist/control/daemon.js +1105 -102
- package/dist/control/discovery.d.ts +77 -5
- package/dist/control/discovery.js +186 -11
- package/dist/control/ensure-hook.d.ts +61 -0
- package/dist/control/ensure-hook.js +160 -8
- package/dist/control/paths.d.ts +26 -0
- package/dist/control/paths.js +34 -0
- package/dist/control/pin-guard.d.ts +83 -0
- package/dist/control/pin-guard.js +534 -0
- package/dist/control/recorder-keeper.d.ts +229 -0
- package/dist/control/recorder-keeper.js +721 -0
- package/dist/control/recorder-main.d.ts +73 -0
- package/dist/control/recorder-main.js +778 -0
- package/dist/control/server.d.ts +707 -31
- package/dist/control/server.js +2787 -302
- package/dist/control/transcript.d.ts +21 -1
- package/dist/control/transcript.js +378 -112
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/jsonrpc/framing.d.ts +76 -12
- package/dist/jsonrpc/framing.js +216 -59
- package/dist/proxy/intercept.d.ts +49 -5
- package/dist/proxy/intercept.js +188 -59
- package/dist/proxy/relay.d.ts +16 -0
- package/dist/proxy/relay.js +77 -5
- package/dist/proxy/revive.d.ts +122 -0
- package/dist/proxy/revive.js +251 -0
- package/dist/proxy/session.d.ts +222 -14
- package/dist/proxy/session.js +708 -99
- package/dist/record/recorder.d.ts +51 -3
- package/dist/record/recorder.js +3 -0
- package/dist/record/redact.d.ts +103 -9
- package/dist/record/redact.js +659 -30
- package/dist/record/remote-recorder.d.ts +327 -32
- package/dist/record/remote-recorder.js +1517 -187
- package/dist/record/switchable-recorder.d.ts +126 -0
- package/dist/record/switchable-recorder.js +351 -0
- package/dist/record/truncate.d.ts +40 -1
- package/dist/record/truncate.js +111 -4
- package/dist/replay/bash-channel.d.ts +35 -0
- package/dist/replay/bash-channel.js +133 -0
- package/dist/replay/bundle.d.ts +10 -0
- package/dist/replay/bundle.js +10 -0
- package/dist/replay/controller.d.ts +369 -20
- package/dist/replay/controller.js +1176 -107
- package/dist/replay/derive.d.ts +1 -0
- package/dist/replay/derive.js +81 -24
- package/dist/replay/executor.d.ts +51 -16
- package/dist/replay/executor.js +114 -41
- package/dist/replay/handover.d.ts +54 -1
- package/dist/replay/handover.js +55 -5
- package/dist/replay/logic.d.ts +62 -10
- package/dist/replay/logic.js +140 -22
- package/dist/replay/plan.d.ts +201 -27
- package/dist/replay/plan.js +581 -141
- package/dist/replay/safety.d.ts +72 -0
- package/dist/replay/safety.js +303 -0
- package/dist/replay/source-run.d.ts +10 -0
- package/dist/replay/source-run.js +80 -35
- package/dist/replay/targets.d.ts +15 -1
- package/dist/replay/targets.js +62 -4
- package/dist/replay/tool-error.d.ts +15 -1
- package/dist/replay/tool-error.js +33 -1
- package/dist/replay/types.d.ts +7 -4
- package/dist/upstream/client.d.ts +56 -3
- package/dist/upstream/client.js +61 -4
- package/dist/upstream/headers.d.ts +43 -0
- package/dist/upstream/headers.js +90 -0
- package/dist/upstream/http-client.d.ts +213 -22
- package/dist/upstream/http-client.js +1034 -168
- package/dist/upstream/http-transport.d.ts +143 -0
- package/dist/upstream/http-transport.js +391 -0
- package/dist/upstream/lazy-client.d.ts +8 -0
- package/dist/upstream/lazy-client.js +29 -1
- package/dist/upstream/sse.d.ts +78 -0
- package/dist/upstream/sse.js +189 -0
- package/dist/upstream/stdio-client.d.ts +61 -1
- package/dist/upstream/stdio-client.js +356 -33
- package/dist/util/atomic-write.d.ts +29 -0
- package/dist/util/atomic-write.js +96 -0
- package/dist/util/journal.d.ts +15 -0
- package/dist/util/journal.js +48 -3
- package/dist/util/log.d.ts +10 -0
- package/dist/util/log.js +28 -1
- package/dist/util/off-switch.d.ts +46 -0
- package/dist/util/off-switch.js +91 -0
- package/dist/util/service-health.d.ts +133 -0
- package/dist/util/service-health.js +217 -0
- package/docs/BaseInstRunner.md +694 -621
- package/docs/calculatedReplay.md +1625 -1304
- package/docs/calculatedReplayGuide.md +1212 -1087
- package/docs/installRun.md +1013 -772
- package/docs/loginWeb.md +627 -607
- package/docs/mcpmark.md +49 -16
- package/docs/my-first-sample.md +569 -545
- package/docs/quickstart.md +384 -311
- package/docs/t-bench.md +406 -394
- package/package.json +58 -57
- package/production.md +1371 -0
- package/scripts/install.ps1 +97 -6
- 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
|
package/dist/proxy/session.d.ts
CHANGED
|
@@ -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
|
|
13
|
-
* proxy owns a run of its own and
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* `metadata.tier` so nothing downstream
|
|
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
|
-
|
|
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
|
-
/**
|
|
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.
|
|
97
|
-
*
|
|
98
|
-
*
|
|
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
|
-
/**
|
|
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
|