@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,778 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* recorder-main — the long-running half of `bir-hooks`: the control server
|
|
3
|
+
* that receives Claude Code's hooks and the proxies' step reports.
|
|
4
|
+
*
|
|
5
|
+
* Kept out of `bin/bir-hooks.ts` so the SessionStart hook (`bir-hooks ensure`),
|
|
6
|
+
* which runs on every session start, `/clear` and compaction, never loads the
|
|
7
|
+
* control server, the replay engine or the service client it does not use.
|
|
8
|
+
*
|
|
9
|
+
* THE ORDER OF START-UP IS THE POINT. Claude Code sends every hook of a
|
|
10
|
+
* session to this process's port. Until it listens, each one is refused, and
|
|
11
|
+
* Claude Code shows each refusal as a hook error. So, in order:
|
|
12
|
+
*
|
|
13
|
+
* 1. crash handlers — nothing after this can take the process down;
|
|
14
|
+
* 2. the log, the project's port and token, the journal — local files only;
|
|
15
|
+
* 3. the recorder, decided from disk alone (recorder-keeper.ts) — no network;
|
|
16
|
+
* 4. listen, and write the discovery file;
|
|
17
|
+
* 5. only then, in the background and bounded: refresh the token, check an
|
|
18
|
+
* http address, notice a sign-in appearing — none of it ever delays a hook.
|
|
19
|
+
*
|
|
20
|
+
* It used to sign in (two network calls, no timeouts) between 2 and 4, and a
|
|
21
|
+
* slow service held the port closed for up to five minutes.
|
|
22
|
+
*/
|
|
23
|
+
import { hostname } from "node:os";
|
|
24
|
+
import { ControlServer, DEFAULT_CONTROL_PORT } from "./server.js";
|
|
25
|
+
import { DISCOVERY_CHECK_MS, HEARTBEAT_MS, pidAlive, readDiscovery, removeDiscoveryIfOwned, removeSupersededRecord, sweepStaleDiscovery, writeDiscovery, writeSupersededRecord, } from "./discovery.js";
|
|
26
|
+
import { DAEMON_CWD_ENV, DAEMON_ENV, DAEMON_LOG_ENV, DERIVE_KEY_ENV, DaemonLog, USE_API_KEY_ENV, clearPortProblem, compareVersions, probeHealth, projectEndpoint, rediscover, validPort, writePortProblem, } from "./daemon.js";
|
|
27
|
+
import { configDir, normalizePath, supersededLogPath } from "./paths.js";
|
|
28
|
+
import { RecorderKeeper } from "./recorder-keeper.js";
|
|
29
|
+
import { parseAllowList } from "../replay/coverage.js";
|
|
30
|
+
import { sweepDeliveries } from "../replay/bash-channel.js";
|
|
31
|
+
import { exemptLoopbackFromProxy, isWrapped, projectRecord, projectToken, readSidecar, } from "../config/generate.js";
|
|
32
|
+
import { resolveServers } from "../config/resolve.js";
|
|
33
|
+
import { isRecorderHealthReporter } from "../record/recorder.js";
|
|
34
|
+
import { errText, logLine, logOnce } from "../util/log.js";
|
|
35
|
+
import { openJournal } from "../util/journal.js";
|
|
36
|
+
import { packageVersion } from "../util/version.js";
|
|
37
|
+
/** How long `close()` may take on the way out: it flushes unfinished runs to the service. */
|
|
38
|
+
const SHUTDOWN_CLOSE_MS = 3_000;
|
|
39
|
+
/** After this the process exits whatever is still pending. */
|
|
40
|
+
const SHUTDOWN_HARD_MS = 5_000;
|
|
41
|
+
/**
|
|
42
|
+
* What a prompt waits for its scenario match by default (`BIR_MATCH_BUDGET_MS`).
|
|
43
|
+
* Past it the prompt goes on without one (K5): a service slower than 3 s must
|
|
44
|
+
* not add that to every prompt.
|
|
45
|
+
*/
|
|
46
|
+
export const DEFAULT_MATCH_BUDGET_MS = 3_000;
|
|
47
|
+
/** How often records of dead recorders are swept from the control directory. */
|
|
48
|
+
const SWEEP_EVERY_MS = 24 * 60 * 60 * 1000;
|
|
49
|
+
/**
|
|
50
|
+
* How long a superseded recorder must have had nothing to serve before it
|
|
51
|
+
* exits (`BIR_SUPERSEDED_EXIT_MS`). "Nothing" is no open run, no session it
|
|
52
|
+
* still knows (the control server forgets one 10 min after its SessionEnd, or
|
|
53
|
+
* after 6 h of silence), no live proxy and nothing left to send. Generous on
|
|
54
|
+
* purpose: a window opened before the reinstall and left idle overnight still
|
|
55
|
+
* posts its hooks to this port when the person comes back, and a recorder
|
|
56
|
+
* that had exited would turn every one of them into a hook error.
|
|
57
|
+
*/
|
|
58
|
+
const SUPERSEDED_EXIT_MS = 24 * 60 * 60 * 1000;
|
|
59
|
+
function positiveInt(value, fallback) {
|
|
60
|
+
const n = Number(value);
|
|
61
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
|
|
62
|
+
}
|
|
63
|
+
/** Like {@link positiveInt}, but 0 is a meaningful setting rather than unset. */
|
|
64
|
+
function nonNegativeInt(value, fallback) {
|
|
65
|
+
const n = Number(value);
|
|
66
|
+
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback;
|
|
67
|
+
}
|
|
68
|
+
function sleep(ms) {
|
|
69
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
70
|
+
}
|
|
71
|
+
/** Config keys already wrapped for this cwd, so `/tool/pre` knows what to correlate. */
|
|
72
|
+
function wrappedServersFor(cwd) {
|
|
73
|
+
try {
|
|
74
|
+
return resolveServers(cwd)
|
|
75
|
+
.filter((s) => isWrapped(s.config))
|
|
76
|
+
.map((s) => s.name);
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
logLine("config.unreadable", { error: errText(err) });
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* `BIR_INTENT_SOURCES` — which text a step's intent may be read from
|
|
85
|
+
* (segmented.md R-INTENT-3). Unset or unparseable means all three.
|
|
86
|
+
*
|
|
87
|
+
* It governs only what is *sent*. Turning every source off does not stop a
|
|
88
|
+
* probe: the request still goes out with an empty text and the server builds a
|
|
89
|
+
* line from the tool name and arguments (R-HIT-4).
|
|
90
|
+
*/
|
|
91
|
+
function parseIntentSources(value) {
|
|
92
|
+
if (value === undefined)
|
|
93
|
+
return undefined;
|
|
94
|
+
const allowed = ["text", "thinking", "tool"];
|
|
95
|
+
const wanted = value
|
|
96
|
+
.split(",")
|
|
97
|
+
.map((s) => s.trim().toLowerCase())
|
|
98
|
+
.filter((s) => allowed.includes(s));
|
|
99
|
+
return new Set(wanted);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* `BIR_CORRELATION_DECISION` as the operator set it — read the way the control
|
|
103
|
+
* server reads it (trimmed, any case) — or undefined when it is unset or not
|
|
104
|
+
* one of the three values.
|
|
105
|
+
*/
|
|
106
|
+
function correlationSetting() {
|
|
107
|
+
const value = (process.env.BIR_CORRELATION_DECISION ?? "").trim().toLowerCase();
|
|
108
|
+
return value === "allow" || value === "ask" || value === "auto" ? value : undefined;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* `BIR_CORRELATION_DECISION`, passed on only when it was set to `allow` or
|
|
112
|
+
* `ask`. Unset, nothing is passed and the control server decides — and by
|
|
113
|
+
* default (`auto`) it never answers `allow` for an ordinary wrapped call,
|
|
114
|
+
* which skipped the person's permission prompt.
|
|
115
|
+
*/
|
|
116
|
+
export function correlationDecisionFromEnv() {
|
|
117
|
+
const value = correlationSetting();
|
|
118
|
+
return value === "allow" || value === "ask" ? value : undefined;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Which Anthropic key derivation may use, if any.
|
|
122
|
+
*
|
|
123
|
+
* In the background, only one the person set for this purpose. The
|
|
124
|
+
* hook-started recorder has Claude Code's environment, and an API-billed
|
|
125
|
+
* Claude Code carries its `ANTHROPIC_API_KEY` there: picking that up silently
|
|
126
|
+
* billed the person's own key for work the service does for a signed-in
|
|
127
|
+
* runner. It is dropped from this process (replay would otherwise read it
|
|
128
|
+
* from the environment) unless `BIR_USE_ANTHROPIC_API_KEY=1` says to use it.
|
|
129
|
+
*
|
|
130
|
+
* In a terminal (`bir-hooks` run by hand, `daemon` false) the environment is
|
|
131
|
+
* the operator's own, and older guides told them to set exactly this variable
|
|
132
|
+
* to keep derivation on the machine: it is used as it always was.
|
|
133
|
+
*/
|
|
134
|
+
export function derivationKey(daemon = process.env[DAEMON_ENV] === "1") {
|
|
135
|
+
const own = process.env[DERIVE_KEY_ENV]?.trim();
|
|
136
|
+
if (own)
|
|
137
|
+
return { apiKey: own, source: `this machine (${DERIVE_KEY_ENV})` };
|
|
138
|
+
const inherited = process.env.ANTHROPIC_API_KEY;
|
|
139
|
+
if (inherited && process.env[USE_API_KEY_ENV] === "1") {
|
|
140
|
+
return { apiKey: inherited, source: `this machine (ANTHROPIC_API_KEY, ${USE_API_KEY_ENV}=1)` };
|
|
141
|
+
}
|
|
142
|
+
if (inherited && !daemon) {
|
|
143
|
+
return { apiKey: inherited, source: "this machine (ANTHROPIC_API_KEY, from this terminal)" };
|
|
144
|
+
}
|
|
145
|
+
if (inherited) {
|
|
146
|
+
delete process.env.ANTHROPIC_API_KEY;
|
|
147
|
+
logLine("derive.api_key_ignored", {
|
|
148
|
+
why: "ANTHROPIC_API_KEY is in this recorder's environment (usually Claude Code's) and is not used, " +
|
|
149
|
+
`so nothing is billed to it — set ${DERIVE_KEY_ENV}, or ${USE_API_KEY_ENV}=1, to derive with a key here`,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return {};
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Assemble the replay configuration (docs/calculatedReplay.md §5.3 of the guide).
|
|
156
|
+
*
|
|
157
|
+
* The steer threshold defaults **above** the service's own detection threshold
|
|
158
|
+
* (0.9). Detection decides "do not record this again", which is cheap — the worst
|
|
159
|
+
* case is a lost recording. Steering decides "do not think about this again",
|
|
160
|
+
* which is a much stronger claim, and a 0.90-similar prompt usually covers only
|
|
161
|
+
* part of the live task.
|
|
162
|
+
*/
|
|
163
|
+
function buildReplayOptions(keeper, policy, key) {
|
|
164
|
+
// The environment still wins — an operator at a terminal is the most
|
|
165
|
+
// deliberate signal there is — but a recorder started by the SessionStart
|
|
166
|
+
// hook has Claude Code's environment, not that terminal's, and it reads the
|
|
167
|
+
// switches the project stored instead of forgetting them (generate.ts).
|
|
168
|
+
const envReplay = process.env.BIR_REPLAY;
|
|
169
|
+
const envAllow = process.env.BIR_REPLAY_ALLOW_SERVERS;
|
|
170
|
+
const envMin = Number(process.env.BIR_MIN_STEER_SIMILARITY);
|
|
171
|
+
const fromEnv = envReplay !== undefined || envAllow !== undefined || Number.isFinite(envMin);
|
|
172
|
+
const source = fromEnv ? "env" : policy ? "sidecar" : "default";
|
|
173
|
+
const enabled = envReplay !== undefined ? envReplay !== "0" : policy?.enabled !== false;
|
|
174
|
+
const allowServers = envAllow !== undefined
|
|
175
|
+
? parseAllowList(envAllow)
|
|
176
|
+
: policy?.allowServers
|
|
177
|
+
? parseAllowList(policy.allowServers.join(","))
|
|
178
|
+
: undefined;
|
|
179
|
+
const minSimilarity = Number.isFinite(envMin) ? envMin : (policy?.minSimilarity ?? 0.92);
|
|
180
|
+
const opts = {
|
|
181
|
+
enabled,
|
|
182
|
+
minSimilarity,
|
|
183
|
+
allowServers,
|
|
184
|
+
budgets: {
|
|
185
|
+
// What a prompt may wait for its match (K5). Past it the prompt goes on
|
|
186
|
+
// without one; a service slower than this must not cost every prompt.
|
|
187
|
+
matchMs: positiveInt(process.env.BIR_MATCH_BUDGET_MS, DEFAULT_MATCH_BUDGET_MS),
|
|
188
|
+
deriveMs: positiveInt(process.env.BIR_DERIVE_BUDGET_MS, 8_000),
|
|
189
|
+
// Only when the operator set it: the controller's own default (100 s,
|
|
190
|
+
// BIR_REPLAY_PLAN_BUDGET_MS) keeps a direct run inside Claude Code's
|
|
191
|
+
// 2-minute auto-background of an MCP call, and 120 000 here would
|
|
192
|
+
// silently override it.
|
|
193
|
+
...(process.env.BIR_REPLAY_BUDGET_MS
|
|
194
|
+
? { planMs: positiveInt(process.env.BIR_REPLAY_BUDGET_MS, 100_000) }
|
|
195
|
+
: {}),
|
|
196
|
+
stepMs: positiveInt(process.env.BIR_STEP_TIMEOUT_MS, 60_000),
|
|
197
|
+
},
|
|
198
|
+
// Intent matching in the ReAct loop (fallbk.md §Runner 4). On with replay;
|
|
199
|
+
// BIR_INTENT_MATCH=0 turns it off on its own.
|
|
200
|
+
intentMatch: {
|
|
201
|
+
enabled: process.env.BIR_INTENT_MATCH !== "0",
|
|
202
|
+
// The probe's own ceiling, not a wait on the hook's path: `PreToolUse`
|
|
203
|
+
// sends it and answers without it (hit counting only) unless arming is
|
|
204
|
+
// on (BIR_SEGMENT_ARM=1 or BIR_INTENT_ARM=1), and then waits at most
|
|
205
|
+
// BIR_INTENT_ARM_BUDGET_MS (500 ms), never while the service is failing
|
|
206
|
+
// (control/server.ts). 4 000 leaves a request whose best segment lands
|
|
207
|
+
// in the not-clear band its one live question under the service's own
|
|
208
|
+
// 2 500 ms attempt (segmented.md R-HIT-11).
|
|
209
|
+
budgetMs: positiveInt(process.env.BIR_INTENT_MATCH_BUDGET_MS, 4_000),
|
|
210
|
+
maxPerTurn: positiveInt(process.env.BIR_INTENT_MATCHES_PER_TURN, 3),
|
|
211
|
+
maxRequestsPerTurn: positiveInt(process.env.BIR_INTENT_REQUESTS_PER_TURN, 40),
|
|
212
|
+
},
|
|
213
|
+
// Observe-only until an operator has read the logs and turned it on
|
|
214
|
+
// (segmented.md R-OUT-10, R-LIFE-7). Arming is never the way to find out.
|
|
215
|
+
segmentArm: process.env.BIR_SEGMENT_ARM === "1",
|
|
216
|
+
minSegmentSimilarity: (() => {
|
|
217
|
+
const n = Number(process.env.BIR_MIN_SEGMENT_STEER_SIMILARITY);
|
|
218
|
+
return Number.isFinite(n) ? n : 0.95;
|
|
219
|
+
})(),
|
|
220
|
+
// Both read live, never captured (C5). The recorder signs in after it
|
|
221
|
+
// listens, refreshes the token as the session outlives it, and may sign
|
|
222
|
+
// in or out while it runs; replay reads whatever holds *now*. The old
|
|
223
|
+
// closure read the start-up session object, which a refresh never
|
|
224
|
+
// updated, so derivation got 401 a day into every recorder's life.
|
|
225
|
+
get authUrl() {
|
|
226
|
+
return keeper.authUrl;
|
|
227
|
+
},
|
|
228
|
+
authToken: () => keeper.currentAccessToken() ?? "",
|
|
229
|
+
apiKey: key.apiKey,
|
|
230
|
+
};
|
|
231
|
+
if (enabled) {
|
|
232
|
+
// Loud, at startup, on the operator's own terminal. A capability that
|
|
233
|
+
// runs tool calls without asking must never be discoverable only from a
|
|
234
|
+
// log file. What it runs without asking is a matched scenario's own steps,
|
|
235
|
+
// and nothing else: an ordinary wrapped call keeps its permission prompt
|
|
236
|
+
// (see the correlation line under `control.listening`).
|
|
237
|
+
logLine("replay.enabled", {
|
|
238
|
+
minSimilarity: opts.minSimilarity,
|
|
239
|
+
allowServers: allowServers ? [...allowServers].join(",") : "(all wrapped)",
|
|
240
|
+
// Who reads what this turn acts on (segmented.md R-PARAM-5). Named at
|
|
241
|
+
// startup because the third state is a replay that silently never runs.
|
|
242
|
+
derive: key.source ??
|
|
243
|
+
(keeper.recording
|
|
244
|
+
? "the service"
|
|
245
|
+
: "recorded sample values — sign in, or a scenario with a target will not run"),
|
|
246
|
+
intentMatch: opts.intentMatch?.enabled ? "on" : "off",
|
|
247
|
+
source,
|
|
248
|
+
why: "a prompt that matches a calculated scenario runs it: the scenario's own steps run without a " +
|
|
249
|
+
"permission prompt of their own (never in plan mode); every other tool call is asked about as " +
|
|
250
|
+
"usual — BIR_REPLAY=0 or `bir replay off` turns replay off",
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
return { opts, source };
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* The pinned port is taken. Find out by whom, say so where `bir doctor` and
|
|
257
|
+
* the next SessionStart will see it, and decide: `ours` (this project's
|
|
258
|
+
* recorder already serves it — step aside), `retry` (nothing answers: the
|
|
259
|
+
* holder is on its way out), or `exit`.
|
|
260
|
+
*/
|
|
261
|
+
async function portBusy(cwd, port, token, code, final) {
|
|
262
|
+
const fix = `run \`bir setup --port <another port>\` in ${cwd} (below 49152 avoids the ranges the system reserves); ` +
|
|
263
|
+
"the next Claude Code session then uses it";
|
|
264
|
+
let problem;
|
|
265
|
+
if (code === "EACCES") {
|
|
266
|
+
problem = {
|
|
267
|
+
port,
|
|
268
|
+
code,
|
|
269
|
+
holder: "excluded",
|
|
270
|
+
at: Date.now(),
|
|
271
|
+
detail: `port ${port} is reserved by the system (on Windows, Hyper-V, WSL and Docker reserve port ranges at boot)`,
|
|
272
|
+
fix,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
const probe = await probeHealth(`http://127.0.0.1:${port}`, token, 2_000);
|
|
277
|
+
if (probe.kind === "ok") {
|
|
278
|
+
const theirCwd = typeof probe.health.cwd === "string" ? probe.health.cwd : "";
|
|
279
|
+
if (normalizePath(theirCwd) === normalizePath(cwd)) {
|
|
280
|
+
// This project's recorder is up already — started a moment earlier by
|
|
281
|
+
// another window, or running without its discovery file. Make sure it
|
|
282
|
+
// can be found, and leave.
|
|
283
|
+
await rediscover(cwd, port, token, 2_000);
|
|
284
|
+
clearPortProblem(cwd);
|
|
285
|
+
logLine("control.already_running", {
|
|
286
|
+
port,
|
|
287
|
+
pid: probe.health.pid,
|
|
288
|
+
why: "this project's recorder already serves this port; not starting a second one",
|
|
289
|
+
});
|
|
290
|
+
return "ours";
|
|
291
|
+
}
|
|
292
|
+
problem = {
|
|
293
|
+
port,
|
|
294
|
+
code,
|
|
295
|
+
holder: "other-project",
|
|
296
|
+
at: Date.now(),
|
|
297
|
+
detail: `port ${port} is served by the recorder of another project (${theirCwd || "unknown"})`,
|
|
298
|
+
fix,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
else if (probe.kind === "refused" && !final) {
|
|
302
|
+
return "retry";
|
|
303
|
+
}
|
|
304
|
+
else if (probe.kind === "unauthorized") {
|
|
305
|
+
problem = {
|
|
306
|
+
port,
|
|
307
|
+
code,
|
|
308
|
+
holder: "unauthorized",
|
|
309
|
+
at: Date.now(),
|
|
310
|
+
detail: `port ${port} is held by a program that refused this project's token — possibly a recorder ` +
|
|
311
|
+
"left from an earlier install of this project, which stops at the next restart of the computer",
|
|
312
|
+
fix,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
else if (probe.kind === "foreign") {
|
|
316
|
+
problem = {
|
|
317
|
+
port,
|
|
318
|
+
code,
|
|
319
|
+
holder: "foreign",
|
|
320
|
+
at: Date.now(),
|
|
321
|
+
detail: `port ${port} is held by another program (it answered HTTP ${probe.status}); until the port ` +
|
|
322
|
+
"changes, this project's hooks send their payloads — and this project's token — to it",
|
|
323
|
+
fix,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
problem = {
|
|
328
|
+
port,
|
|
329
|
+
code,
|
|
330
|
+
holder: "unknown",
|
|
331
|
+
at: Date.now(),
|
|
332
|
+
detail: `port ${port} is held by a program that did not answer`,
|
|
333
|
+
fix,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
writePortProblem(cwd, problem);
|
|
338
|
+
logLine("control.port_busy", {
|
|
339
|
+
port,
|
|
340
|
+
code,
|
|
341
|
+
holder: problem.holder,
|
|
342
|
+
detail: problem.detail,
|
|
343
|
+
fix: problem.fix,
|
|
344
|
+
});
|
|
345
|
+
return "exit";
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* The control server's {@link RecorderActivity}. From its own accessor when it
|
|
349
|
+
* has one; otherwise the same, read from its `/health` body (heavier, and
|
|
350
|
+
* asked once a minute only while superseded).
|
|
351
|
+
*/
|
|
352
|
+
export function activityOf(server) {
|
|
353
|
+
const accessor = server.activity;
|
|
354
|
+
if (typeof accessor === "function")
|
|
355
|
+
return accessor.call(server);
|
|
356
|
+
const h = server.health();
|
|
357
|
+
const sessions = Array.isArray(h.sessions) ? h.sessions : [];
|
|
358
|
+
const proxies = Array.isArray(h.registeredProxies) ? h.registeredProxies : [];
|
|
359
|
+
const pids = new Set();
|
|
360
|
+
for (const p of proxies)
|
|
361
|
+
if (typeof p?.pid === "number" && pidAlive(p.pid))
|
|
362
|
+
pids.add(p.pid);
|
|
363
|
+
return {
|
|
364
|
+
openRuns: sessions.filter((s) => s.active === true).length,
|
|
365
|
+
knownSessions: typeof h.sessionCount === "number" ? h.sessionCount : sessions.length,
|
|
366
|
+
liveProxyPids: pids.size,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
/** Steps this process has not handed to the service yet (0 when it cannot say). */
|
|
370
|
+
function pendingSendsOf(recorder) {
|
|
371
|
+
try {
|
|
372
|
+
if (!isRecorderHealthReporter(recorder))
|
|
373
|
+
return 0;
|
|
374
|
+
const n = recorder.recorderHealth()?.pendingSends;
|
|
375
|
+
return typeof n === "number" && Number.isFinite(n) && n > 0 ? n : 0;
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
return 0;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
/** The port of a recorder URL, or undefined. */
|
|
382
|
+
function portOfUrl(url) {
|
|
383
|
+
try {
|
|
384
|
+
return validPort(new URL(url).port);
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
return undefined;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
export async function runRecorder() {
|
|
391
|
+
// First, before anything can throw: this process answers every hook of every
|
|
392
|
+
// session in the project, and a crash turns each of them into a visible hook
|
|
393
|
+
// error. Log and keep serving.
|
|
394
|
+
process.on("uncaughtException", (err) => logLine("control.uncaught", { error: errText(err) }));
|
|
395
|
+
process.on("unhandledRejection", (err) => logLine("control.unhandled_rejection", { error: errText(err) }));
|
|
396
|
+
// Started with --use-env-proxy (a launcher written behind a proxy) in an
|
|
397
|
+
// environment whose NO_PROXY does not name 127.0.0.1: every loopback fetch
|
|
398
|
+
// this process makes — asking who holds its port, finding its own recorder
|
|
399
|
+
// again — would go to the proxy and fail. undici re-reads NO_PROXY per
|
|
400
|
+
// request, so adding loopback to it here is enough (generate.ts). Calls to
|
|
401
|
+
// the service still go through the proxy, as the flag asks.
|
|
402
|
+
exemptLoopbackFromProxy();
|
|
403
|
+
const daemon = process.env[DAEMON_ENV] === "1";
|
|
404
|
+
// A background recorder is started in the runner's state directory and told
|
|
405
|
+
// its project (daemon.ts, WHERE IT RUNS): holding the project folder as its
|
|
406
|
+
// working directory made the folder impossible to rename or delete on Windows.
|
|
407
|
+
const cwd = (daemon && process.env[DAEMON_CWD_ENV]) || process.cwd();
|
|
408
|
+
if (daemon) {
|
|
409
|
+
try {
|
|
410
|
+
process.chdir(configDir());
|
|
411
|
+
}
|
|
412
|
+
catch {
|
|
413
|
+
/* stays where it was started; only folder locking is at stake */
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
const logFile = daemon ? process.env[DAEMON_LOG_ENV] : undefined;
|
|
417
|
+
// A background recorder owns its log. Nothing was inherited from whoever
|
|
418
|
+
// started it (control/daemon.ts says why), so the audit lines — every
|
|
419
|
+
// `logLine` goes through `console.error` — are pointed at the file here.
|
|
420
|
+
const log = logFile ? new DaemonLog(logFile) : undefined;
|
|
421
|
+
log?.open();
|
|
422
|
+
const sidecar = readSidecar();
|
|
423
|
+
const record = projectRecord(sidecar, cwd);
|
|
424
|
+
// The port this project's hooks were written with. A valid BIR_CONTROL_PORT
|
|
425
|
+
// still wins; the per-project record is what lets two projects record at
|
|
426
|
+
// once. Read exactly as the SessionStart hook reads it (daemon.ts,
|
|
427
|
+
// projectEndpoint) — when the two disagreed, every session start replaced
|
|
428
|
+
// a recorder that could only bind the same wrong port again.
|
|
429
|
+
// BIR_CONTROL_PORT=0 is any free port (tests, the smoke run): it used to
|
|
430
|
+
// fall through to the default, and a test run took the real 53411.
|
|
431
|
+
const endpoint = projectEndpoint(cwd, process.env, sidecar);
|
|
432
|
+
const pinned = endpoint.pinned;
|
|
433
|
+
const preferredPort = endpoint.ephemeral
|
|
434
|
+
? 0
|
|
435
|
+
: (endpoint.port ?? validPort(sidecar.controlPort) ?? DEFAULT_CONTROL_PORT);
|
|
436
|
+
// Open before anything logs: the journal is what `bir investigate` reads
|
|
437
|
+
// later, and the startup lines are part of the story.
|
|
438
|
+
const journalFile = openJournal(cwd);
|
|
439
|
+
const key = derivationKey(daemon);
|
|
440
|
+
// From disk, no network: listening must never wait for the service.
|
|
441
|
+
const keeper = new RecorderKeeper({ cwd });
|
|
442
|
+
const replay = buildReplayOptions(keeper, record?.replay, key);
|
|
443
|
+
let closing = false;
|
|
444
|
+
let tick;
|
|
445
|
+
const finish = (event, exitCode) => {
|
|
446
|
+
removeDiscoveryIfOwned(cwd, process.pid);
|
|
447
|
+
removeSupersededRecord(cwd, process.pid);
|
|
448
|
+
logLine(event, {});
|
|
449
|
+
process.exit(exitCode);
|
|
450
|
+
};
|
|
451
|
+
const shutdown = async () => {
|
|
452
|
+
if (closing)
|
|
453
|
+
return;
|
|
454
|
+
closing = true;
|
|
455
|
+
if (tick)
|
|
456
|
+
clearInterval(tick);
|
|
457
|
+
keeper.stop();
|
|
458
|
+
// A deadline, not a hope. close() finalizes unfinished runs and flushes
|
|
459
|
+
// them to the service; a service that hangs must not keep `bir down`, a
|
|
460
|
+
// restart onto a new version, or the machine's shutdown waiting on it.
|
|
461
|
+
const hard = setTimeout(() => finish("control.stop_forced", 0), SHUTDOWN_HARD_MS);
|
|
462
|
+
await Promise.race([
|
|
463
|
+
server.close().catch((err) => logLine("control.close_failed", { error: errText(err) })),
|
|
464
|
+
sleep(SHUTDOWN_CLOSE_MS),
|
|
465
|
+
]);
|
|
466
|
+
clearTimeout(hard);
|
|
467
|
+
finish("control.stopped", 0);
|
|
468
|
+
};
|
|
469
|
+
const onSignal = () => {
|
|
470
|
+
// A second signal while stopping means "now": on POSIX it used to be
|
|
471
|
+
// swallowed, and a recorder stuck in its shutdown could not be stopped.
|
|
472
|
+
if (closing)
|
|
473
|
+
finish("control.stop_forced", 1);
|
|
474
|
+
void shutdown();
|
|
475
|
+
};
|
|
476
|
+
const options = {
|
|
477
|
+
recorder: keeper.recorder,
|
|
478
|
+
cwd,
|
|
479
|
+
port: preferredPort,
|
|
480
|
+
// This project's token (generate.ts); the machine-wide one for a record
|
|
481
|
+
// written before tokens were per project.
|
|
482
|
+
token: projectToken(sidecar, cwd),
|
|
483
|
+
wrappedServers: wrappedServersFor(cwd),
|
|
484
|
+
host: { app: "claude-code" },
|
|
485
|
+
correlationDecision: correlationDecisionFromEnv(),
|
|
486
|
+
noCorrelation: process.env.BIR_NO_CORRELATION === "1",
|
|
487
|
+
deriveRecentResults: nonNegativeInt(process.env.BIR_DERIVE_RECENT_RESULTS, 5),
|
|
488
|
+
intentSources: parseIntentSources(process.env.BIR_INTENT_SOURCES),
|
|
489
|
+
replay: replay.opts,
|
|
490
|
+
replaySource: replay.source,
|
|
491
|
+
// Both live: the recorder may sign in, refresh or sign out while it runs.
|
|
492
|
+
// `/health` is how `bir doctor` and `bir setup` learn which.
|
|
493
|
+
get recording() {
|
|
494
|
+
return keeper.recording;
|
|
495
|
+
},
|
|
496
|
+
get account() {
|
|
497
|
+
return keeper.account;
|
|
498
|
+
},
|
|
499
|
+
// In the background with a port the hooks name, a fallback port is a
|
|
500
|
+
// recorder every hook but SessionStart would miss. Exit with a reason instead.
|
|
501
|
+
portFallback: !(daemon && pinned),
|
|
502
|
+
// 'bir down', and the SessionStart hook replacing an out-of-date recorder,
|
|
503
|
+
// ask over HTTP rather than sending a signal: on Windows a signal is a kill.
|
|
504
|
+
onStopRequested: () => void shutdown(),
|
|
505
|
+
};
|
|
506
|
+
const server = new ControlServer(options);
|
|
507
|
+
let address;
|
|
508
|
+
for (let attempt = 0;; attempt += 1) {
|
|
509
|
+
try {
|
|
510
|
+
address = await server.listen();
|
|
511
|
+
break;
|
|
512
|
+
}
|
|
513
|
+
catch (err) {
|
|
514
|
+
const code = err.code;
|
|
515
|
+
if (code !== "EADDRINUSE" && code !== "EACCES")
|
|
516
|
+
throw err;
|
|
517
|
+
// Nothing answering on a taken port is a holder on its way out (the
|
|
518
|
+
// recorder this one replaces, still closing): a few short retries.
|
|
519
|
+
const verdict = await portBusy(cwd, preferredPort, server.token, code, attempt >= 4);
|
|
520
|
+
if (verdict === "retry") {
|
|
521
|
+
await sleep(500);
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
process.exit(verdict === "ours" ? 0 : 1);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
clearPortProblem(cwd);
|
|
528
|
+
const info = {
|
|
529
|
+
url: address.url,
|
|
530
|
+
token: address.token,
|
|
531
|
+
sessionId: address.sessionId,
|
|
532
|
+
pid: process.pid,
|
|
533
|
+
startedAt: Date.now(),
|
|
534
|
+
cwd,
|
|
535
|
+
version: packageVersion(),
|
|
536
|
+
...(daemon ? { daemon: true, logFile } : {}),
|
|
537
|
+
};
|
|
538
|
+
let lastWrite = 0;
|
|
539
|
+
let discoveryFile;
|
|
540
|
+
/**
|
|
541
|
+
* False once the project's hooks post to another port than this recorder's
|
|
542
|
+
* (`bir install --port N`, or a reinstall that picked a new one). Such a
|
|
543
|
+
* recorder is kept running by the SessionStart hook for the sessions that
|
|
544
|
+
* started before — their hooks still name this port — but it is no longer
|
|
545
|
+
* the project's recorder: it does not put its record back when the file
|
|
546
|
+
* goes, so `bir down` of the new one is not undone a minute later.
|
|
547
|
+
*/
|
|
548
|
+
const stillTheProjects = (now = projectEndpoint(cwd)) => {
|
|
549
|
+
if (!daemon)
|
|
550
|
+
return true;
|
|
551
|
+
return now.port === undefined || now.port === address.port;
|
|
552
|
+
};
|
|
553
|
+
/** A record of this process that says something else than it would (another writer wrote it). */
|
|
554
|
+
const differs = (onDisk) => onDisk.url !== info.url ||
|
|
555
|
+
onDisk.token !== info.token ||
|
|
556
|
+
onDisk.sessionId !== info.sessionId ||
|
|
557
|
+
onDisk.startedAt !== info.startedAt ||
|
|
558
|
+
onDisk.version !== info.version ||
|
|
559
|
+
Boolean(onDisk.daemon) !== Boolean(info.daemon) ||
|
|
560
|
+
onDisk.logFile !== info.logFile;
|
|
561
|
+
const publish = () => {
|
|
562
|
+
try {
|
|
563
|
+
discoveryFile = writeDiscovery({ ...info, heartbeatAt: Date.now() });
|
|
564
|
+
lastWrite = Date.now();
|
|
565
|
+
}
|
|
566
|
+
catch (err) {
|
|
567
|
+
// The hooks still reach this process at the port they name; only the
|
|
568
|
+
// proxies need the file. Keep serving and try again at the next check.
|
|
569
|
+
logLine("control.heartbeat_failed", { error: errText(err) });
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
publish();
|
|
573
|
+
/**
|
|
574
|
+
* Whether this recorder — the project's, where its hooks post — should take
|
|
575
|
+
* the discovery file back from the live recorder `onDisk` names. A 0.2.12
|
|
576
|
+
* recorder left running beside this one (a reinstall moved the port while a
|
|
577
|
+
* window was open) rewrites the file with its own address every half hour
|
|
578
|
+
* and never steps aside, and every proxy that read it then bound to the old
|
|
579
|
+
* recorder for its whole life. So: taken back when the record names a port
|
|
580
|
+
* the hooks do not post to, or an older (or unstated) version. Two recorders
|
|
581
|
+
* of this version never contend: only one can hold the hooks' port.
|
|
582
|
+
*/
|
|
583
|
+
const mayReclaim = (onDisk, expected) => {
|
|
584
|
+
if (expected.port !== undefined) {
|
|
585
|
+
if (address.port !== expected.port)
|
|
586
|
+
return false; // not where the hooks post: not ours to take
|
|
587
|
+
if (portOfUrl(onDisk.url) !== expected.port)
|
|
588
|
+
return true;
|
|
589
|
+
}
|
|
590
|
+
if (!onDisk.version)
|
|
591
|
+
return true;
|
|
592
|
+
const order = compareVersions(onDisk.version, packageVersion());
|
|
593
|
+
return order !== undefined && order < 0;
|
|
594
|
+
};
|
|
595
|
+
const reclaimedFrom = new Set();
|
|
596
|
+
// ── superseded (LC-1) ────────────────────────────────────────────────────
|
|
597
|
+
// A recorder the hooks moved away from keeps serving the windows opened
|
|
598
|
+
// before — and used to do so invisibly until the machine restarted: out of
|
|
599
|
+
// `bir status`, `bir down` and `bir uninstall`, sharing (and breaking the
|
|
600
|
+
// rotation of) the project's log. Now it says where it is (a superseded
|
|
601
|
+
// record), writes a log of its own, and exits once it has had nothing to
|
|
602
|
+
// serve for SUPERSEDED_EXIT_MS.
|
|
603
|
+
const supersededGraceMs = nonNegativeInt(process.env.BIR_SUPERSEDED_EXIT_MS, SUPERSEDED_EXIT_MS);
|
|
604
|
+
let superseded;
|
|
605
|
+
let quietSince;
|
|
606
|
+
let exiting = false;
|
|
607
|
+
const becomeSuperseded = (now) => {
|
|
608
|
+
if (superseded)
|
|
609
|
+
return;
|
|
610
|
+
superseded = { since: now };
|
|
611
|
+
quietSince = undefined;
|
|
612
|
+
const ownLog = log ? supersededLogPath(cwd, address.port) : undefined;
|
|
613
|
+
const fields = {
|
|
614
|
+
port: address.port,
|
|
615
|
+
why: "this project's hooks now post to another port; this recorder keeps serving the sessions " +
|
|
616
|
+
"that started before that, no longer advertises itself, and exits once none is left",
|
|
617
|
+
log: ownLog,
|
|
618
|
+
};
|
|
619
|
+
logLine("control.superseded", fields);
|
|
620
|
+
if (log && ownLog) {
|
|
621
|
+
log.moveTo(ownLog);
|
|
622
|
+
logLine("control.superseded", fields);
|
|
623
|
+
}
|
|
624
|
+
writeSupersededRecord({
|
|
625
|
+
cwd,
|
|
626
|
+
pid: process.pid,
|
|
627
|
+
port: address.port,
|
|
628
|
+
url: address.url,
|
|
629
|
+
token: address.token,
|
|
630
|
+
since: now,
|
|
631
|
+
version: packageVersion(),
|
|
632
|
+
logFile: ownLog ?? logFile,
|
|
633
|
+
});
|
|
634
|
+
};
|
|
635
|
+
const backToTheProject = () => {
|
|
636
|
+
if (!superseded)
|
|
637
|
+
return;
|
|
638
|
+
superseded = undefined;
|
|
639
|
+
quietSince = undefined;
|
|
640
|
+
removeSupersededRecord(cwd, process.pid);
|
|
641
|
+
if (log && logFile)
|
|
642
|
+
log.moveTo(logFile);
|
|
643
|
+
logLine("control.project_recorder_again", {
|
|
644
|
+
port: address.port,
|
|
645
|
+
why: "this project's hooks post to this recorder's port again",
|
|
646
|
+
});
|
|
647
|
+
};
|
|
648
|
+
const maybeExitSuperseded = (now) => {
|
|
649
|
+
if (!superseded || exiting || closing)
|
|
650
|
+
return;
|
|
651
|
+
const a = activityOf(server);
|
|
652
|
+
const backlog = pendingSendsOf(keeper.recorder);
|
|
653
|
+
if (a.openRuns > 0 || a.knownSessions > 0 || a.liveProxyPids > 0 || backlog > 0) {
|
|
654
|
+
quietSince = undefined;
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
quietSince ??= now;
|
|
658
|
+
if (now - quietSince < supersededGraceMs)
|
|
659
|
+
return;
|
|
660
|
+
exiting = true;
|
|
661
|
+
logLine("control.superseded_exit", {
|
|
662
|
+
port: address.port,
|
|
663
|
+
quiet_min: Math.round((now - quietSince) / 60_000),
|
|
664
|
+
why: "superseded, and no session, run, proxy or unsent step left to serve",
|
|
665
|
+
});
|
|
666
|
+
void shutdown();
|
|
667
|
+
};
|
|
668
|
+
// One check a minute, on the wall clock (discovery.ts, HEARTBEAT_MS): the
|
|
669
|
+
// discovery file rewritten when it has gone missing, when a heartbeat's
|
|
670
|
+
// worth of wall time has passed — timers stop while a laptop sleeps, the
|
|
671
|
+
// wall clock does not — or when another writer (daemon.ts, rediscover)
|
|
672
|
+
// wrote a record of this process without what only it knows (that it runs
|
|
673
|
+
// in the background, its log, its start time), or an older recorder took
|
|
674
|
+
// it; a superseded recorder noticed, recorded and eventually retired; the
|
|
675
|
+
// log rolled when it grows past its limit, or opened again after a failure
|
|
676
|
+
// or after another writer rolled it; and the control directory swept once
|
|
677
|
+
// a day. BIR_RECORDER_TICK_MS is for tests.
|
|
678
|
+
let lastSweep = Date.now();
|
|
679
|
+
tick = setInterval(() => {
|
|
680
|
+
try {
|
|
681
|
+
const now = Date.now();
|
|
682
|
+
const expected = projectEndpoint(cwd);
|
|
683
|
+
const onDisk = readDiscovery(cwd, { ignoreAge: true });
|
|
684
|
+
if (stillTheProjects(expected)) {
|
|
685
|
+
backToTheProject();
|
|
686
|
+
if (!onDisk) {
|
|
687
|
+
publish();
|
|
688
|
+
}
|
|
689
|
+
else if (onDisk.pid === process.pid) {
|
|
690
|
+
if (now - lastWrite >= HEARTBEAT_MS || differs(onDisk))
|
|
691
|
+
publish();
|
|
692
|
+
}
|
|
693
|
+
else if (mayReclaim(onDisk, expected)) {
|
|
694
|
+
publish();
|
|
695
|
+
if (!reclaimedFrom.has(onDisk.pid)) {
|
|
696
|
+
reclaimedFrom.add(onDisk.pid);
|
|
697
|
+
logLine("control.discovery_reclaimed", {
|
|
698
|
+
from_pid: onDisk.pid,
|
|
699
|
+
from: onDisk.url,
|
|
700
|
+
version: onDisk.version ?? null,
|
|
701
|
+
why: "the project's discovery file named an older recorder, or one on a port the hooks no longer " +
|
|
702
|
+
"post to; new proxies must bind to the recorder the hooks reach",
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
else {
|
|
707
|
+
logOnce("discovery.claimed", "control.discovery_claimed", {
|
|
708
|
+
pid: onDisk.pid,
|
|
709
|
+
why: "another live recorder has written this project's discovery file; leaving it alone",
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
else {
|
|
714
|
+
becomeSuperseded(now);
|
|
715
|
+
// Still named by the file (no recorder for the new port has started
|
|
716
|
+
// yet): keep the record fresh until one takes it.
|
|
717
|
+
if (onDisk?.pid === process.pid && (now - lastWrite >= HEARTBEAT_MS || differs(onDisk)))
|
|
718
|
+
publish();
|
|
719
|
+
maybeExitSuperseded(now);
|
|
720
|
+
}
|
|
721
|
+
log?.rotateIfLarge();
|
|
722
|
+
if (now - lastSweep >= SWEEP_EVERY_MS) {
|
|
723
|
+
lastSweep = now;
|
|
724
|
+
sweepStaleDiscovery();
|
|
725
|
+
// Hand-over files hold live tool outputs; a recorder that crashed
|
|
726
|
+
// before its own scheduled sweep must not leave them on disk.
|
|
727
|
+
sweepDeliveries();
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
catch (err) {
|
|
731
|
+
logLine("control.heartbeat_failed", { error: errText(err) });
|
|
732
|
+
}
|
|
733
|
+
}, positiveInt(process.env.BIR_RECORDER_TICK_MS, DISCOVERY_CHECK_MS));
|
|
734
|
+
tick.unref();
|
|
735
|
+
const sweepSoon = setTimeout(() => {
|
|
736
|
+
sweepStaleDiscovery();
|
|
737
|
+
sweepDeliveries();
|
|
738
|
+
}, 5_000);
|
|
739
|
+
sweepSoon.unref();
|
|
740
|
+
keeper.start();
|
|
741
|
+
logLine("control.listening", {
|
|
742
|
+
url: address.url,
|
|
743
|
+
cwd,
|
|
744
|
+
discovery: discoveryFile,
|
|
745
|
+
journal: journalFile,
|
|
746
|
+
machine: hostname(),
|
|
747
|
+
version: packageVersion(),
|
|
748
|
+
background: daemon ? "yes" : undefined,
|
|
749
|
+
recorder: keeper.state,
|
|
750
|
+
wrapped: wrappedServersFor(cwd).join(",") || "(none)",
|
|
751
|
+
port: endpoint.ephemeral ? "any free (BIR_CONTROL_PORT=0)" : undefined,
|
|
752
|
+
// How PreToolUse answers an ordinary wrapped call. `auto` (unset) never
|
|
753
|
+
// answers `allow` for one, so the person's permission prompts are theirs.
|
|
754
|
+
correlation: process.env.BIR_NO_CORRELATION === "1" ? "off" : (correlationSetting() ?? "auto"),
|
|
755
|
+
});
|
|
756
|
+
if (correlationSetting() === "allow" && process.env.BIR_NO_CORRELATION !== "1") {
|
|
757
|
+
// Loud, because it changes what the person is asked: an operator's
|
|
758
|
+
// explicit choice, never a default.
|
|
759
|
+
logLine("control.correlation_allow", {
|
|
760
|
+
why: "BIR_CORRELATION_DECISION=allow — every wrapped MCP call is answered `allow` and runs without " +
|
|
761
|
+
"the permission prompt it would otherwise get; unset it to keep the prompts",
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
if (!endpoint.ephemeral && (preferredPort !== DEFAULT_CONTROL_PORT || sidecar.controlPort || record)) {
|
|
765
|
+
if (preferredPort !== address.port) {
|
|
766
|
+
// The hook URLs in settings.json name a port; if we could not take it,
|
|
767
|
+
// the hooks will POST into the void. Say so rather than looking healthy.
|
|
768
|
+
logLine("control.port_mismatch", {
|
|
769
|
+
installed: preferredPort,
|
|
770
|
+
actual: address.port,
|
|
771
|
+
why: "hooks point at the installed port — re-run `bir install` or free that port",
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
process.on("SIGINT", onSignal);
|
|
776
|
+
process.on("SIGTERM", onSignal);
|
|
777
|
+
}
|
|
778
|
+
//# sourceMappingURL=recorder-main.js.map
|