@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
@@ -5,7 +5,9 @@
5
5
  * Normally nobody runs it: `bir install` makes it the SessionStart hook
6
6
  * (`bir-hooks ensure`), which starts it in the background for the project when no
7
7
  * recorder is running there, and `bir up` / `bir down` start and stop that
8
- * background process by hand. Its audit lines then go to
8
+ * background process by hand. `bir-hooks revive --cwd <project>` starts it
9
+ * again mid-session: a bound proxy spawns it, detached, when the recorder it
10
+ * reported to is gone (control/daemon.ts, reviveDaemon). Its audit lines then go to
9
11
  * ~/.baseinstrunner/logs/<project-key>.log. It can still be run in a terminal:
10
12
  *
11
13
  * bir-hooks # start; Ctrl-C to stop
@@ -16,31 +18,69 @@
16
18
  * 2. accepts step reports from every `bir-proxy` that finds it;
17
19
  * 3. owns run identity, the monotonic `stepIndex`, and the recorder chain (D5).
18
20
  *
21
+ * The recorder listens first and signs in afterwards, in the background, so a
22
+ * slow or unreachable BaseIn service never holds a hook; it starts recording
23
+ * by itself when a sign-in appears (`bir login`) — no restart
24
+ * (control/recorder-main.ts, control/recorder-keeper.ts).
25
+ *
19
26
  * Environment:
20
27
  * BIR_AUTH_URL BaseIn auth-service. Unset, the address `bir setup`
21
28
  * stored in ~/.baseinstrunner/config.json is used
22
- * BIR_CONTROL_PORT preferred port (default 53411; falls back if busy)
23
- * BIR_CORRELATION_DECISION `allow` (default) or `ask` — see below
29
+ * BIR_CONTROL_PORT the port to listen on, over the project's own
30
+ * (default: the project's, else 53411, falling
31
+ * back if busy). 0 is any free port — the one
32
+ * taken is in the discovery file, /health and the
33
+ * log (tests and the smoke run; a project's hooks
34
+ * name a fixed port). A value that is not a port
35
+ * is ignored
36
+ * BIR_CORRELATION_DECISION unset (default: `auto`), `auto`, `allow` or
37
+ * `ask` — see below
24
38
  * BIR_NO_CORRELATION=1 never inject a call id; join on fingerprints
25
39
  * BIR_VERBOSE=1 per-step detail lines
40
+ * BIR_SERVICE_TIMEOUT_MS ceiling for one call to the service made off a
41
+ * hook's path (default 10000)
42
+ * BIR_SUPERSEDED_EXIT_MS how long a recorder the project's hooks moved
43
+ * away from (a reinstall on another port) must
44
+ * have had no session, run, live proxy or unsent
45
+ * step before it exits (default 86400000, a day)
46
+ * BIR_WINDOWS_LAUNCH=spawn Windows only: start the background recorder with
47
+ * a direct hidden spawn instead of PowerShell's
48
+ * Start-Process (faster by about a second; unsafe
49
+ * only when something above Claude Code captures
50
+ * its output — see control/daemon.ts)
26
51
  *
27
52
  * BIR_REPLAY=0 turn OFF calculated replay — running a scenario on
28
53
  * a similar-meaning match (docs/calculatedReplay.md).
29
54
  * ON by default; unset or any value other than "0"
30
- * keeps it on. Read §13.2: a replayed step is
31
- * auto-approved, and a directly executed one never
32
- * reaches the permission system at all.
55
+ * keeps it on (`bir replay off` stores the same
56
+ * for one project). Read §13.2: only when a prompt
57
+ * matches a scenario, that scenario's own steps run
58
+ * without a prompt of their own — a steered step is
59
+ * answered `allow`, a directly executed one never
60
+ * reaches the permission system. Never in plan
61
+ * mode; every other tool call is asked as usual.
33
62
  * BIR_REPLAY_ALLOW_SERVERS comma-separated server keys eligible for *direct*
34
63
  * execution. Unset means every wrapped server
35
64
  * BIR_MIN_STEER_SIMILARITY minimum match similarity to replay (default 0.92)
36
- * ANTHROPIC_API_KEY OPTIONAL. Derivation — reading what this turn acts
65
+ * BIR_DERIVE_API_KEY OPTIONAL. Derivation — reading what this turn acts
37
66
  * on — is done by the service for a signed-in
38
- * runner. Set this only to keep that reading on this
39
- * machine; signed out and unset, a scenario with a
40
- * target does not run (segmented.md R-PARAM-5)
67
+ * runner. Set this (an Anthropic key) only to keep
68
+ * that reading on this machine; signed out and
69
+ * unset, a scenario with a target does not run
70
+ * (segmented.md R-PARAM-5)
71
+ * BIR_USE_ANTHROPIC_API_KEY=1 let the background recorder use an
72
+ * ANTHROPIC_API_KEY from its environment for
73
+ * derivation, as older versions did. Off by
74
+ * default: a recorder the SessionStart hook starts
75
+ * has Claude Code's environment, and using the key
76
+ * found there billed the person's own account. A
77
+ * `bir-hooks` run by hand in a terminal uses that
78
+ * terminal's ANTHROPIC_API_KEY as it always did
41
79
  * BIR_DERIVE_MODEL derivation model (default claude-haiku-4-5-…)
42
- * BIR_MATCH_BUDGET_MS prompt-hook match wait (default 2500)
43
- * BIR_DERIVE_BUDGET_MS first PreToolUse derivation wait (default 8000)
80
+ * BIR_MATCH_BUDGET_MS prompt-hook match wait (default 3000)
81
+ * BIR_DERIVE_BUDGET_MS derivation wait before a direct run (default 8000);
82
+ * inside a hook it is at most BIR_HOOK_DERIVE_BUDGET_MS
83
+ * (default 4000)
44
84
  * BIR_DERIVE_RECENT_RESULTS how many of this prompt's tool results derivation
45
85
  * reads, so a target named by an earlier step can be
46
86
  * found (default 5; 0 off)
@@ -49,310 +89,162 @@
49
89
  * BIR_INTENT_REQUESTS_PER_TURN probes one turn may send (default 40).
50
90
  * Separate from BIR_INTENT_MATCHES_PER_TURN: arming
51
91
  * is a claim on the turn, counting hits is not
92
+ * BIR_INTENT_MATCH_BUDGET_MS one intent probe's own ceiling (default 4000).
93
+ * Not a wait: PreToolUse does not wait for a probe
94
+ * unless arming is on (BIR_SEGMENT_ARM=1 or
95
+ * BIR_INTENT_ARM=1), and then at most
96
+ * BIR_INTENT_ARM_BUDGET_MS (default 500)
52
97
  * BIR_SEGMENT_ARM 1 lets a handed-out segment actually run mid-task.
53
98
  * Unset is observe-only: what would have armed is
54
99
  * logged and nothing is replaced
55
100
  * BIR_MIN_SEGMENT_STEER_SIMILARITY minimum similarity to steer a segment
56
101
  * (default 0.95)
57
- * BIR_REPLAY_BUDGET_MS whole-plan ceiling, direct mode (default 120000)
102
+ * BIR_REPLAY_BUDGET_MS whole-plan ceiling, direct mode (unset: the
103
+ * controller's 100000, BIR_REPLAY_PLAN_BUDGET_MS)
58
104
  * BIR_STEP_TIMEOUT_MS one direct tools/call (default 60000)
59
105
  *
60
- * ABOUT `BIR_CORRELATION_DECISION`. Correlating a wrapped MCP call means
61
- * answering `PreToolUse` with `permissionDecision: "allow"` plus an extra
62
- * argument (Phase 6). That is what the design specifies — and it also
63
- * **auto-approves the call**, so a tool the user would have been asked about is
64
- * no longer asked about. Set `BIR_CORRELATION_DECISION=ask` to keep the prompt;
65
- * hosts that ignore `updatedInput` on a non-allow decision then fall back to
66
- * fingerprint matching, which is lossy under identical concurrent calls.
106
+ * ABOUT `BIR_CORRELATION_DECISION`. Correlating a wrapped MCP call exactly
107
+ * means answering `PreToolUse` with an extra argument, and Claude Code only
108
+ * takes that together with `permissionDecision: "allow"` — which also
109
+ * **auto-approves the call**, so a tool the user would have been asked about
110
+ * is no longer asked about. Unset (or `auto`), nothing is passed to the
111
+ * control server, which never does that for an ordinary call: it injects only
112
+ * where `allow` changes nothing the user is asked (a `bypassPermissions`
113
+ * session) and otherwise joins the proxy's report by fingerprint. `allow` is
114
+ * the behaviour of 0.2.12 and earlier, an explicit opt-in that is logged loudly at start
115
+ * — every wrapped call auto-approved and exactly correlated; `ask` keeps the
116
+ * prompt (a host that ignores the injection on a non-allow answer falls back
117
+ * to fingerprints).
67
118
  */
68
- import { hostname } from "node:os";
119
+ import { appendFileSync } from "node:fs";
69
120
  import { Console } from "node:console";
70
- import { createWriteStream } from "node:fs";
71
- import { ControlServer, DEFAULT_CONTROL_PORT } from "../control/server.js";
72
- import { HEARTBEAT_MS, removeDiscovery, writeDiscovery, } from "../control/discovery.js";
73
- import { DAEMON_ENV, DAEMON_LOG_ENV } from "../control/daemon.js";
74
- import { runEnsureHook } from "../control/ensure-hook.js";
121
+ import { dirname, resolve } from "node:path";
122
+ import { Writable } from "node:stream";
75
123
  import { ENSURE_ARG } from "../config/adapters/claude-code.js";
76
- import { authenticate, clearCredentials, normalizeAuthUrl, resolveAuthUrl, } from "../auth/client.js";
77
- import { packageVersion } from "../util/version.js";
78
- import { NullRecorder } from "../record/recorder.js";
79
- import { RemoteRecorder } from "../record/remote-recorder.js";
80
- import { parseAllowList } from "../replay/coverage.js";
81
- import { projectRecord, projectToken, readSidecar } from "../config/generate.js";
82
- import { resolveServers } from "../config/resolve.js";
83
- import { isWrapped } from "../config/generate.js";
84
- import { logLine, errText } from "../util/log.js";
85
- import { openJournal } from "../util/journal.js";
86
- function parsePort(value) {
87
- const n = Number(value);
88
- return Number.isInteger(n) && n > 0 && n < 65536 ? n : DEFAULT_CONTROL_PORT;
124
+ import { exemptLoopbackFromProxy } from "../config/generate.js";
125
+ import { REVIVE_ARG } from "../control/daemon.js";
126
+ import { daemonLogPath, ensureDir } from "../control/paths.js";
127
+ import { errText, logLine } from "../util/log.js";
128
+ /**
129
+ * The SessionStart hook. Claude Code holds the session start until it exits,
130
+ * so whatever happens it exits 0 and never outlives its budget.
131
+ */
132
+ async function ensure(argv = []) {
133
+ const bail = (err) => {
134
+ try {
135
+ process.stderr.write(`[bir] ensure: ${errText(err)}\n`);
136
+ }
137
+ catch {
138
+ /* nothing left to tell */
139
+ }
140
+ process.exit(0);
141
+ };
142
+ process.on("uncaughtException", bail);
143
+ process.on("unhandledRejection", bail);
144
+ // Loaded here, not at the top: this runs on every session start, /clear and
145
+ // compaction, and the control server's module graph is not needed for it.
146
+ const { runEnsureHook, ENSURE_HOOK_BUDGET_MS } = await import("../control/ensure-hook.js");
147
+ // `--budget-ms <n>`: an npx-form hook spends part of the hook's timeout on
148
+ // npx itself before this code runs, so `bir install --npx` writes a smaller
149
+ // budget. It can only shorten the default, never lengthen it.
150
+ const asked = budgetArg(argv);
151
+ const budgetMs = asked !== undefined ? Math.min(asked, ENSURE_HOOK_BUDGET_MS) : ENSURE_HOOK_BUDGET_MS;
152
+ const ceiling = setTimeout(() => process.exit(0), budgetMs + 500);
153
+ ceiling.unref();
154
+ process.exitCode = await runEnsureHook(process.cwd(), { budgetMs });
89
155
  }
90
- /** Config keys already wrapped for this cwd, so `/tool/pre` knows what to correlate. */
91
- function wrappedServersFor(cwd) {
92
- try {
93
- return resolveServers(cwd)
94
- .filter((s) => isWrapped(s.config))
95
- .map((s) => s.name);
96
- }
97
- catch (err) {
98
- logLine("config.unreadable", { error: errText(err) });
99
- return [];
156
+ /** The value of `--budget-ms <n>` or `--budget-ms=<n>` when it is a positive integer. */
157
+ function budgetArg(argv) {
158
+ for (let i = 0; i < argv.length; i += 1) {
159
+ const a = argv[i];
160
+ const raw = a === "--budget-ms" ? argv[i + 1] : a.startsWith("--budget-ms=") ? a.slice("--budget-ms=".length) : undefined;
161
+ if (raw === undefined)
162
+ continue;
163
+ const n = Number(raw);
164
+ return Number.isInteger(n) && n > 0 ? n : undefined;
100
165
  }
166
+ return undefined;
101
167
  }
102
- async function buildRecorder() {
103
- // BIR_AUTH_URL, or the address 'bir setup' stored. Read the same way by the
104
- // proxies and the CLI, so a recorder started by a hook — with whatever
105
- // environment the host had — still knows where the steps go.
106
- const configured = resolveAuthUrl();
107
- if (!configured) {
108
- logLine("recorder.disabled", {
109
- why: "no service address — run 'bir setup', or set BIR_AUTH_URL; running without recording",
110
- });
111
- return { recorder: new NullRecorder() };
168
+ /** The longest a revive may take: {@link ensureDaemon}'s default budget, and a margin. */
169
+ const REVIVE_CEILING_MS = 20_000;
170
+ /** The value of `--cwd <dir>` or `--cwd=<dir>`, or undefined. */
171
+ function cwdArg(argv) {
172
+ for (let i = 0; i < argv.length; i += 1) {
173
+ const a = argv[i];
174
+ if (a === "--cwd" && argv[i + 1] !== undefined)
175
+ return argv[i + 1];
176
+ if (a.startsWith("--cwd="))
177
+ return a.slice("--cwd=".length);
112
178
  }
113
- // A plain-http URL fronted by a TLS proxy answers `308 → https`, and following
114
- // that strips the bearer token: sign-in succeeds and every later call fails
115
- // `401 missing_token`. Settle the base URL before anything authenticates.
116
- const baseUrl = await normalizeAuthUrl(configured);
117
- const session = await authenticate({ authUrl: baseUrl });
118
- if (!session) {
119
- logLine("recorder.disabled", { why: "no BaseIn session — run `bir login`" });
120
- return { recorder: new NullRecorder() };
121
- }
122
- logLine("auth.ok", { user: session.user.email });
123
- return { recorder: new RemoteRecorder({ baseUrl, session }), session, baseUrl };
124
- }
125
- function positiveInt(value, fallback) {
126
- const n = Number(value);
127
- return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
128
- }
129
- /** Like {@link positiveInt}, but 0 is a meaningful setting rather than unset. */
130
- function nonNegativeInt(value, fallback) {
131
- const n = Number(value);
132
- return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback;
179
+ return undefined;
133
180
  }
134
181
  /**
135
- * `BIR_INTENT_SOURCES` — which text a step's intent may be read from
136
- * (segmented.md R-INTENT-3). Unset or unparseable means all three.
137
- *
138
- * It governs only what is *sent*. Turning every source off does not stop a
139
- * probe: the request still goes out with an empty text and the server builds a
140
- * line from the tool name and arguments (R-HIT-4).
182
+ * `bir-hooks revive --cwd <project>` (K1): start the project's recorder
183
+ * again when it is gone mid-session (control/daemon.ts, reviveDaemon).
184
+ * Spawned detached, with no stdio, by a bound `bir-proxy` or `bir-scenario`
185
+ * — possibly by several at once. It reads no stdin, writes nothing to stdout
186
+ * (its audit lines go to the project's recorder log), never throws, and exits
187
+ * 0 whatever happens, within {@link REVIVE_CEILING_MS}.
141
188
  */
142
- function parseIntentSources(value) {
143
- if (value === undefined)
144
- return undefined;
145
- const allowed = ["text", "thinking", "tool"];
146
- const wanted = value
147
- .split(",")
148
- .map((s) => s.trim().toLowerCase())
149
- .filter((s) => allowed.includes(s));
150
- return new Set(wanted);
151
- }
152
- function buildReplayOptions(auth, policy) {
153
- // The environment still wins — an operator at a terminal is the most
154
- // deliberate signal there is — but a recorder started by the SessionStart
155
- // hook has Claude Code's environment, not that terminal's, and it reads the
156
- // switches the project stored instead of forgetting them (generate.ts).
157
- const envReplay = process.env.BIR_REPLAY;
158
- const envAllow = process.env.BIR_REPLAY_ALLOW_SERVERS;
159
- const envMin = Number(process.env.BIR_MIN_STEER_SIMILARITY);
160
- const fromEnv = envReplay !== undefined || envAllow !== undefined || Number.isFinite(envMin);
161
- const source = fromEnv ? "env" : policy ? "sidecar" : "default";
162
- const enabled = envReplay !== undefined ? envReplay !== "0" : policy?.enabled !== false;
163
- const allowServers = envAllow !== undefined
164
- ? parseAllowList(envAllow)
165
- : policy?.allowServers
166
- ? parseAllowList(policy.allowServers.join(","))
167
- : undefined;
168
- const minSimilarity = Number.isFinite(envMin) ? envMin : (policy?.minSimilarity ?? 0.92);
169
- const opts = {
170
- enabled,
171
- minSimilarity,
172
- allowServers,
173
- budgets: {
174
- matchMs: positiveInt(process.env.BIR_MATCH_BUDGET_MS, 2_500),
175
- deriveMs: positiveInt(process.env.BIR_DERIVE_BUDGET_MS, 8_000),
176
- planMs: positiveInt(process.env.BIR_REPLAY_BUDGET_MS, 120_000),
177
- stepMs: positiveInt(process.env.BIR_STEP_TIMEOUT_MS, 60_000),
189
+ async function revive(argv) {
190
+ const done = () => process.exit(0);
191
+ process.on("uncaughtException", done);
192
+ process.on("unhandledRejection", done);
193
+ const ceiling = setTimeout(done, REVIVE_CEILING_MS);
194
+ ceiling.unref();
195
+ const cwd = resolve(cwdArg(argv) ?? process.cwd());
196
+ // Nobody reads this process's stdio: every line it logs (the start, why it
197
+ // did not) goes where `bir doctor` and a person looking read.
198
+ const file = daemonLogPath(cwd);
199
+ ensureDir(dirname(file));
200
+ const sink = new Writable({
201
+ write(chunk, _encoding, callback) {
202
+ try {
203
+ appendFileSync(file, chunk);
204
+ }
205
+ catch {
206
+ /* dropped */
207
+ }
208
+ callback();
178
209
  },
179
- // Intent matching in the ReAct loop (fallbk.md §Runner 4). On with replay;
180
- // BIR_INTENT_MATCH=0 turns it off on its own.
181
- intentMatch: {
182
- enabled: process.env.BIR_INTENT_MATCH !== "0",
183
- // 4 000, not 1 500: a request whose best segment lands in the not-clear
184
- // band has one live question to ask under the server's own 2 500 ms
185
- // attempt, and must still answer inside the hook's 30 s (segmented.md
186
- // R-HIT-11).
187
- budgetMs: positiveInt(process.env.BIR_INTENT_MATCH_BUDGET_MS, 4_000),
188
- maxPerTurn: positiveInt(process.env.BIR_INTENT_MATCHES_PER_TURN, 3),
189
- maxRequestsPerTurn: positiveInt(process.env.BIR_INTENT_REQUESTS_PER_TURN, 40),
190
- },
191
- // Observe-only until an operator has read the logs and turned it on
192
- // (segmented.md R-OUT-10, R-LIFE-7). Arming is never the way to find out.
193
- segmentArm: process.env.BIR_SEGMENT_ARM === "1",
194
- minSegmentSimilarity: (() => {
195
- const n = Number(process.env.BIR_MIN_SEGMENT_STEER_SIMILARITY);
196
- return Number.isFinite(n) ? n : 0.95;
197
- })(),
198
- authUrl: auth.baseUrl,
199
- // Read late, not captured: `RemoteRecorder` refreshes the access token as the
200
- // session outlives it, and a snapshot taken here would go stale mid-run.
201
- authToken: auth.session ? () => auth.session.accessToken : undefined,
202
- };
203
- if (enabled) {
204
- // Loud, at startup, on the operator's own terminal. A capability that
205
- // auto-approves tool calls must never be discoverable only from a log file.
206
- logLine("replay.enabled", {
207
- minSimilarity: opts.minSimilarity,
208
- allowServers: allowServers ? [...allowServers].join(",") : "(all wrapped)",
209
- // Who reads what this turn acts on (segmented.md R-PARAM-5). Named at
210
- // startup because the third state is a replay that silently never runs.
211
- derive: process.env.ANTHROPIC_API_KEY
212
- ? "this machine (ANTHROPIC_API_KEY)"
213
- : auth.session && auth.baseUrl
214
- ? "the service"
215
- : "recorded sample values — sign in, or a scenario with a target will not run",
216
- intentMatch: opts.intentMatch?.enabled ? "on" : "off",
217
- source,
218
- why: "matched prompts will run their calculated scenario — steered steps are auto-approved",
219
- });
220
- }
221
- return { opts, source };
210
+ });
211
+ globalThis.console = new Console({ stdout: sink, stderr: sink });
212
+ const { reviveDaemon } = await import("../control/daemon.js");
213
+ await reviveDaemon(cwd);
214
+ done();
222
215
  }
223
216
  async function main() {
217
+ // Before any fetch: started with --use-env-proxy (a launcher written behind a
218
+ // proxy) where NO_PROXY does not name 127.0.0.1, the SessionStart hook's
219
+ // look at its own recorder and its relay would go to the proxy and fail —
220
+ // and a recorder it could not see is one it starts twice. As bir.ts does;
221
+ // the recorder does it again for itself (recorder-main.ts).
222
+ exemptLoopbackFromProxy();
224
223
  const argv = process.argv.slice(2);
225
224
  if (argv[0] === "logout") {
225
+ const { clearCredentials } = await import("../auth/client.js");
226
226
  clearCredentials();
227
227
  process.stderr.write("[bir] logged out — cached credentials cleared.\n");
228
228
  return;
229
229
  }
230
230
  if (argv[0] === ENSURE_ARG) {
231
- // The SessionStart hook: start the recorder for this directory if there is
232
- // none, then relay the payload. Always exit 0 (control/ensure-hook.ts).
233
- process.exitCode = await runEnsureHook(process.cwd());
231
+ await ensure(argv.slice(1));
234
232
  return;
235
233
  }
236
- const cwd = process.cwd();
237
- const daemon = process.env[DAEMON_ENV] === "1";
238
- const logFile = daemon ? process.env[DAEMON_LOG_ENV] : undefined;
239
- if (logFile) {
240
- // A background recorder owns its log. Nothing was inherited from whoever
241
- // started it (control/daemon.ts says why), so the audit lines — every
242
- // `logLine` goes through `console.error` — are pointed at the file here.
243
- const stream = createWriteStream(logFile, { flags: "a" });
244
- globalThis.console = new Console({ stdout: stream, stderr: stream });
245
- }
246
- const sidecar = readSidecar();
247
- // The port this project's hooks were written with. An explicit BIR_CONTROL_PORT
248
- // still wins; the per-project record is what lets two projects record at once.
249
- const pinned = process.env.BIR_CONTROL_PORT !== undefined || projectRecord(sidecar, cwd) !== undefined;
250
- const preferredPort = parsePort(process.env.BIR_CONTROL_PORT ??
251
- String(projectRecord(sidecar, cwd)?.port ?? sidecar.controlPort ?? ""));
252
- // Open before anything logs: the journal is what `bir investigate` reads
253
- // later, and the startup lines are part of the story.
254
- const journalFile = openJournal(cwd);
255
- const auth = await buildRecorder();
256
- const replay = buildReplayOptions(auth, projectRecord(sidecar, cwd)?.replay);
257
- const server = new ControlServer({
258
- recorder: auth.recorder,
259
- cwd,
260
- port: preferredPort,
261
- // This project's token (generate.ts); the machine-wide one for a record
262
- // written before tokens were per project.
263
- token: projectToken(sidecar, cwd),
264
- wrappedServers: wrappedServersFor(cwd),
265
- host: { app: "claude-code" },
266
- correlationDecision: process.env.BIR_CORRELATION_DECISION === "ask" ? "ask" : "allow",
267
- noCorrelation: process.env.BIR_NO_CORRELATION === "1",
268
- deriveRecentResults: nonNegativeInt(process.env.BIR_DERIVE_RECENT_RESULTS, 5),
269
- intentSources: parseIntentSources(process.env.BIR_INTENT_SOURCES),
270
- replay: replay.opts,
271
- replaySource: replay.source,
272
- account: auth.session?.user.email,
273
- // In the background with a port the hooks name, a fallback port is a
274
- // recorder every hook but SessionStart would miss. Exit with a reason instead.
275
- portFallback: !(daemon && pinned),
276
- // 'bir down', and the SessionStart hook replacing an out-of-date recorder,
277
- // ask over HTTP rather than sending a signal: on Windows a signal is a kill.
278
- onStopRequested: () => void shutdown(),
279
- });
280
- let address;
281
- try {
282
- address = await server.listen();
283
- }
284
- catch (err) {
285
- const code = err.code;
286
- if (code === "EADDRINUSE" || code === "EACCES") {
287
- logLine("control.port_busy", {
288
- port: preferredPort,
289
- code,
290
- fix: `port ${preferredPort} is held by another program or excluded by the system — run \`bir setup --port <another>\` in this directory`,
291
- });
292
- process.exit(1);
293
- }
294
- throw err;
295
- }
296
- const info = {
297
- url: address.url,
298
- token: address.token,
299
- sessionId: address.sessionId,
300
- pid: process.pid,
301
- startedAt: Date.now(),
302
- cwd,
303
- version: packageVersion(),
304
- ...(daemon ? { daemon: true, logFile: process.env[DAEMON_LOG_ENV] } : {}),
305
- };
306
- const discoveryFile = writeDiscovery(info);
307
- // A background recorder outlives the discovery file's 24-hour freshness
308
- // window; the heartbeat is what keeps it findable (discovery.ts).
309
- const heartbeat = setInterval(() => {
310
- try {
311
- writeDiscovery({ ...info, heartbeatAt: Date.now() });
312
- }
313
- catch (err) {
314
- logLine("control.heartbeat_failed", { error: errText(err) });
315
- }
316
- }, HEARTBEAT_MS);
317
- heartbeat.unref();
318
- logLine("control.listening", {
319
- url: address.url,
320
- cwd,
321
- discovery: discoveryFile,
322
- journal: journalFile,
323
- machine: hostname(),
324
- version: packageVersion(),
325
- background: daemon ? "yes" : undefined,
326
- wrapped: wrappedServersFor(cwd).join(",") || "(none)",
327
- });
328
- if (preferredPort !== DEFAULT_CONTROL_PORT || sidecar.controlPort || projectRecord(sidecar, cwd)) {
329
- if (preferredPort !== address.port) {
330
- // The hook URLs in settings.json name a port; if we could not take it,
331
- // the hooks will POST into the void. Say so rather than looking healthy.
332
- logLine("control.port_mismatch", {
333
- installed: preferredPort,
334
- actual: address.port,
335
- why: "hooks point at the installed port — re-run `bir install` or free that port",
336
- });
337
- }
338
- }
339
- let closing = false;
340
- async function shutdown() {
341
- if (closing)
342
- return;
343
- closing = true;
344
- clearInterval(heartbeat);
345
- await server.close().catch((err) => logLine("control.close_failed", { error: errText(err) }));
346
- removeDiscovery(cwd);
347
- logLine("control.stopped", {});
348
- process.exit(0);
234
+ if (argv[0] === REVIVE_ARG) {
235
+ await revive(argv.slice(1));
236
+ return;
349
237
  }
350
- process.on("SIGINT", () => void shutdown());
351
- process.on("SIGTERM", () => void shutdown());
352
- process.on("uncaughtException", (err) => logLine("control.uncaught", { error: errText(err) }));
353
- process.on("unhandledRejection", (err) => logLine("control.unhandled_rejection", { error: errText(err) }));
238
+ const { runRecorder } = await import("../control/recorder-main.js");
239
+ await runRecorder();
354
240
  }
355
241
  void main().catch((err) => {
242
+ if (process.argv[2] === REVIVE_ARG)
243
+ process.exit(0);
244
+ if (process.argv[2] === ENSURE_ARG) {
245
+ process.stderr.write(`[bir] ensure: ${errText(err)}\n`);
246
+ process.exit(0);
247
+ }
356
248
  logLine("control.fatal", { error: errText(err) });
357
249
  process.exit(1);
358
250
  });
@@ -15,17 +15,32 @@
15
15
  * --no-correlation never relax tool schemas; join on argument fingerprints
16
16
  * --lazy create the upstream on the first host message, not at
17
17
  * startup (see upstream/lazy-client.ts for the caveat)
18
- * --restart restart a dead stdio upstream once
19
- * --discovery-ms <n> how long to look for a control server (default 5000)
18
+ * --restart restart a dead stdio upstream once (in-flight calls
19
+ * are answered; the host's handshake is replayed to it)
20
+ * --discovery-ms <n> how long to look for a control server (default 10000)
20
21
  *
21
22
  * Environment:
22
23
  * BIR_CONTROL_URL / BIR_CONTROL_TOKEN control server, bypassing discovery
23
24
  * BIR_AUTH_URL BaseIn service, for Tier 2 recording
25
+ * BIR_UPSTREAM_HEADERS a remote upstream's headers, as a JSON
26
+ * object of name → value; `--header`
27
+ * wins on a clash. Read once, then
28
+ * removed from the environment: no
29
+ * process started from here sees it
30
+ * BIR_OFF=1 the kill switch (also `bir off`): run
31
+ * the server as if it were not wrapped
32
+ * BIR_PROXY_STAY_UP=1 keep answering after the upstream
33
+ * dies, instead of exiting with it
24
34
  * BIR_VERBOSE=1 per-message detail on stderr
25
35
  *
26
36
  * STDOUT IS SACRED. It is the host's JSON-RPC stream; every log line, every
27
37
  * upstream stderr line and every diagnostic goes to stderr instead. A single
28
38
  * stray byte here corrupts the session and surfaces somewhere else entirely.
39
+ *
40
+ * STDERR IS NOT FREE EITHER. Claude Code files every stderr line of an MCP
41
+ * server as a "Server stderr" error in its MCP logs, so routine lines (started,
42
+ * spawned, bound, stopped) are `BIR_VERBOSE`-only; what is left on stderr is a
43
+ * decision or a failure somebody may need to read.
29
44
  */
30
45
  interface ProxyArgs {
31
46
  serverName: string;