@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,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* service-read — what `bir scenario show|edits` and `bir investigate` share
|
|
3
|
+
* about asking the service and printing its answer, so a slow service, a
|
|
4
|
+
* signed-out machine or a large scenario never turns into a stall or a flood.
|
|
5
|
+
*
|
|
6
|
+
* WHY A BOUND OF THEIR OWN. The `service()` these commands are handed settles
|
|
7
|
+
* the service address with a `/health` probe, may refresh the session, and
|
|
8
|
+
* then makes its request; a service (or a corporate proxy in front of it) that
|
|
9
|
+
* accepts the connection and never answers held every one of those for as long
|
|
10
|
+
* as Node's fetch waits — measured at 120 s through the `bir` MCP server, which
|
|
11
|
+
* is 120 s of a turn stalled on a debugging tool. A read is safe to abandon:
|
|
12
|
+
* nothing was asked to change. So each one gets {@link readTimeoutMs} — a
|
|
13
|
+
* command that reads twice shares it between them ({@link ReadClock}) — and
|
|
14
|
+
* then an answer in words. Writes (`check`, `edit`, `undo`, `calc`) are not
|
|
15
|
+
* bounded here — abandoning one would leave "was it saved?" unanswerable — and
|
|
16
|
+
* the MCP server bounds the whole command instead.
|
|
17
|
+
*
|
|
18
|
+
* WHY COMPACT JSON UNDER THE MCP SERVER. The `bir` MCP server runs these
|
|
19
|
+
* commands with `BIR_MCP=1` and hands their `--json` output to the model.
|
|
20
|
+
* Indentation is for a person at a terminal; in a model's context it is a
|
|
21
|
+
* quarter of the tokens and none of the meaning.
|
|
22
|
+
*/
|
|
23
|
+
/** Default bound on one command's reads of the service. `BIR_READ_TIMEOUT_MS` overrides it. */
|
|
24
|
+
export const DEFAULT_READ_TIMEOUT_MS = 15_000;
|
|
25
|
+
/**
|
|
26
|
+
* The most any timer here is set for from the environment. Node turns a delay
|
|
27
|
+
* past 2^31 - 1 ms into 1 ms, so a mistyped `BIR_READ_TIMEOUT_MS=3000000000`
|
|
28
|
+
* used to make every read give up at once; ten minutes is already far longer
|
|
29
|
+
* than anybody waits for a debugging tool.
|
|
30
|
+
*/
|
|
31
|
+
export const MAX_ENV_TIMEOUT_MS = 600_000;
|
|
32
|
+
/**
|
|
33
|
+
* A number of milliseconds from the environment: positive and at most
|
|
34
|
+
* {@link MAX_ENV_TIMEOUT_MS}, or `fallback`. Out of range is a mistake, and
|
|
35
|
+
* the default is the value known to work.
|
|
36
|
+
*/
|
|
37
|
+
export function envTimeoutMs(name, fallback) {
|
|
38
|
+
const n = Number(process.env[name]);
|
|
39
|
+
return Number.isFinite(n) && n > 0 && n <= MAX_ENV_TIMEOUT_MS ? n : fallback;
|
|
40
|
+
}
|
|
41
|
+
export function readTimeoutMs() {
|
|
42
|
+
return envTimeoutMs("BIR_READ_TIMEOUT_MS", DEFAULT_READ_TIMEOUT_MS);
|
|
43
|
+
}
|
|
44
|
+
/** True when the `bir` MCP server ran this command for a model (it sets `BIR_MCP=1`). */
|
|
45
|
+
export function underMcp() {
|
|
46
|
+
return process.env.BIR_MCP === "1";
|
|
47
|
+
}
|
|
48
|
+
/** `--json` output: indented for a person, compact for a model. */
|
|
49
|
+
export function jsonText(value) {
|
|
50
|
+
return underMcp() ? JSON.stringify(value) : JSON.stringify(value, null, 2);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* `pending`, or `{ error }` when it takes longer than `ms`. The request itself
|
|
54
|
+
* is not cancelled — `bir` exits as soon as the command returns, which is what
|
|
55
|
+
* ends it — and a late rejection is observed here, so it can never surface as
|
|
56
|
+
* an unhandled one. `statedMs` is the bound the answer names, when `ms` is
|
|
57
|
+
* only what was left of it ({@link ReadClock}).
|
|
58
|
+
*/
|
|
59
|
+
export async function readWithin(pending, ms = readTimeoutMs(), statedMs = ms) {
|
|
60
|
+
pending.catch(() => undefined);
|
|
61
|
+
let timer;
|
|
62
|
+
const late = new Promise((resolve) => {
|
|
63
|
+
timer = setTimeout(() => resolve({ error: `the BaseIn service did not answer within ${seconds(statedMs)} s` }), Math.max(0, ms));
|
|
64
|
+
timer.unref?.();
|
|
65
|
+
});
|
|
66
|
+
try {
|
|
67
|
+
return await Promise.race([pending, late]);
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
if (timer)
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* One command's reads of the service, sharing one bound. A command that reads
|
|
79
|
+
* twice — `scenario edits` on a `run_` id finds the scenario, then its
|
|
80
|
+
* history; `investigate` tries the matched run after a "no" — used to give
|
|
81
|
+
* each read the whole bound, so a service that answered the first slowly and
|
|
82
|
+
* held the second took twice as long as the `bir` MCP server allows a
|
|
83
|
+
* reading tool, and was killed there with none of the answer the command
|
|
84
|
+
* already had (the journal half of `investigate`, say). Sharing the bound
|
|
85
|
+
* keeps the whole command inside it, and the timeout still names the bound,
|
|
86
|
+
* not the scrap of it the last read had left.
|
|
87
|
+
*/
|
|
88
|
+
export class ReadClock {
|
|
89
|
+
totalMs;
|
|
90
|
+
deadline;
|
|
91
|
+
constructor(totalMs = readTimeoutMs()) {
|
|
92
|
+
this.totalMs = totalMs;
|
|
93
|
+
this.deadline = Date.now() + totalMs;
|
|
94
|
+
}
|
|
95
|
+
/** What is left of the bound, in ms (0 once it is spent). */
|
|
96
|
+
remaining() {
|
|
97
|
+
return Math.max(0, this.deadline - Date.now());
|
|
98
|
+
}
|
|
99
|
+
/** {@link readWithin}, with what is left of the command's bound. */
|
|
100
|
+
within(pending) {
|
|
101
|
+
return readWithin(pending, this.remaining(), this.totalMs);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** Whether a `{ error }` means the service could not be reached at all, as opposed to answering. */
|
|
105
|
+
export function unreachable(error) {
|
|
106
|
+
return /did not answer within|fetch failed|ECONN|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH|socket|network|aborted|timed out|UND_ERR/i.test(error);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* A failure to reach the service, in words a person (or a model) can act on.
|
|
110
|
+
* The transport's own words — "fetch failed", "This operation was aborted" —
|
|
111
|
+
* say nothing about what to do, so they are replaced; the sentences `bir`
|
|
112
|
+
* itself wrote ("not signed in — run `bir login`") are already the answer.
|
|
113
|
+
*/
|
|
114
|
+
export function serviceProblem(error) {
|
|
115
|
+
if (/BIR_AUTH_URL is not set/.test(error)) {
|
|
116
|
+
return "no BaseIn service is configured on this machine — run `bir setup` or `bir login`";
|
|
117
|
+
}
|
|
118
|
+
if (/did not answer within/.test(error))
|
|
119
|
+
return error;
|
|
120
|
+
if (unreachable(error))
|
|
121
|
+
return "the BaseIn service could not be reached — check the network, or try again later";
|
|
122
|
+
return error;
|
|
123
|
+
}
|
|
124
|
+
function seconds(ms) {
|
|
125
|
+
const s = ms / 1000;
|
|
126
|
+
return Number.isInteger(s) ? String(s) : s.toFixed(1);
|
|
127
|
+
}
|
|
128
|
+
//# sourceMappingURL=service-read.js.map
|
package/dist/bin/setup.d.ts
CHANGED
|
@@ -43,7 +43,26 @@ export interface SetupArgs {
|
|
|
43
43
|
daemon: boolean;
|
|
44
44
|
/** `--allow-servers a,b`: the servers replay may call unattended, stored for this project. */
|
|
45
45
|
allowServers?: string;
|
|
46
|
+
/**
|
|
47
|
+
* `--port` was given: the port is the person's, and is never moved. Without
|
|
48
|
+
* it a recorded port this machine no longer lets the recorder bind is
|
|
49
|
+
* replaced (see {@link runSetup}, step 4).
|
|
50
|
+
*/
|
|
51
|
+
portExplicit?: boolean;
|
|
46
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* The project directory every later step keys by: the PHYSICAL path on POSIX.
|
|
55
|
+
*
|
|
56
|
+
* After `chdir`, `process.cwd()` — which install keys the sidecar by and the
|
|
57
|
+
* recorder keys its discovery file by — is the resolved path. Keying setup's own
|
|
58
|
+
* steps by the logical one (a symlinked ~/code, macOS's /tmp → /private/tmp)
|
|
59
|
+
* made it wait 15 s for a discovery file that was written under the other
|
|
60
|
+
* spelling, report "the recorder did not start" while it ran, and store
|
|
61
|
+
* `--allow-servers` where the recorder never looks. Windows keeps the path as
|
|
62
|
+
* given: its cwd is not resolved through junctions, and resolving would only
|
|
63
|
+
* change the drive letter's case.
|
|
64
|
+
*/
|
|
65
|
+
export declare function canonicalProject(dir: string, platform?: NodeJS.Platform): string;
|
|
47
66
|
export interface SetupDeps {
|
|
48
67
|
out: (line?: string) => void;
|
|
49
68
|
err: (line: string) => void;
|
package/dist/bin/setup.js
CHANGED
|
@@ -28,16 +28,41 @@
|
|
|
28
28
|
* `cd <project>` and `bir setup`. The second needs no token; the session is
|
|
29
29
|
* cached.
|
|
30
30
|
*/
|
|
31
|
-
import { existsSync, statSync } from "node:fs";
|
|
31
|
+
import { existsSync, realpathSync, statSync } from "node:fs";
|
|
32
32
|
import { homedir } from "node:os";
|
|
33
33
|
import { delimiter, join, parse as parsePath, resolve as resolvePath } from "node:path";
|
|
34
|
+
import { pidAlive, readDiscovery } from "../control/discovery.js";
|
|
34
35
|
import { AUTH_URL_HINT, DeviceFlowAborted, DeviceFlowUnsupported, authenticate, describeAuthService, deviceLogin, loadStoredConfig, normalizeAuthUrl, rememberAuthUrl, tokenLogin, } from "../auth/client.js";
|
|
35
|
-
import { activeSessions, ensureDaemon, SETUP_TOKEN_ENV, stopDaemon, } from "../control/daemon.js";
|
|
36
|
+
import { activeSessions, compareVersions, ensureDaemon, SETUP_TOKEN_ENV, stopDaemon, } from "../control/daemon.js";
|
|
36
37
|
import { normalizePath } from "../control/paths.js";
|
|
37
|
-
import { isWrapped, projectRecord, readSidecar, SCENARIO_SERVER_KEY, setProjectRecord, writeSidecar, allocateFreeProjectPort, } from "../config/generate.js";
|
|
38
|
+
import { allocateReplacementPort, isScenarioServer, isWrapped, LOOPBACK_NO_PROXY, probeFreePort, proxyConfigured, projectRecord, readSidecar, SCENARIO_SERVER_KEY, setProjectRecord, writeSidecar, allocateFreeProjectPort, } from "../config/generate.js";
|
|
38
39
|
import { resolveServers } from "../config/resolve.js";
|
|
39
40
|
import { errText } from "../util/log.js";
|
|
41
|
+
import { offSource } from "../util/off-switch.js";
|
|
40
42
|
import { packageVersion } from "../util/version.js";
|
|
43
|
+
/**
|
|
44
|
+
* The project directory every later step keys by: the PHYSICAL path on POSIX.
|
|
45
|
+
*
|
|
46
|
+
* After `chdir`, `process.cwd()` — which install keys the sidecar by and the
|
|
47
|
+
* recorder keys its discovery file by — is the resolved path. Keying setup's own
|
|
48
|
+
* steps by the logical one (a symlinked ~/code, macOS's /tmp → /private/tmp)
|
|
49
|
+
* made it wait 15 s for a discovery file that was written under the other
|
|
50
|
+
* spelling, report "the recorder did not start" while it ran, and store
|
|
51
|
+
* `--allow-servers` where the recorder never looks. Windows keeps the path as
|
|
52
|
+
* given: its cwd is not resolved through junctions, and resolving would only
|
|
53
|
+
* change the drive letter's case.
|
|
54
|
+
*/
|
|
55
|
+
export function canonicalProject(dir, platform = process.platform) {
|
|
56
|
+
const logical = resolvePath(dir);
|
|
57
|
+
if (platform === "win32")
|
|
58
|
+
return logical;
|
|
59
|
+
try {
|
|
60
|
+
return realpathSync(logical);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return logical;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
41
66
|
/** True when `claude` (Claude Code) is on PATH or in its native install location. */
|
|
42
67
|
export function claudeOnPath(env = process.env) {
|
|
43
68
|
const names = process.platform === "win32" ? ["claude.exe", "claude.cmd", "claude"] : ["claude"];
|
|
@@ -93,7 +118,18 @@ export async function runSetup(args, deps) {
|
|
|
93
118
|
const problem = await describeAuthService(baseUrl);
|
|
94
119
|
if (problem) {
|
|
95
120
|
err(`${baseUrl} is not a BaseIn service: ${problem}.`);
|
|
96
|
-
|
|
121
|
+
if (/unreachable/.test(problem) && proxyConfigured(env) && !process.execArgv.includes("--use-env-proxy")) {
|
|
122
|
+
// Behind a proxy-only network the likeliest cause, and not the address:
|
|
123
|
+
// Node's fetch ignores HTTPS_PROXY unless it is started with this flag.
|
|
124
|
+
// NO_PROXY too: with the flag, the runner's own calls to 127.0.0.1 would
|
|
125
|
+
// go to the proxy as well, and install leaves the flag out without it.
|
|
126
|
+
err("HTTPS_PROXY/HTTP_PROXY is set, but this node does not use it without --use-env-proxy: set " +
|
|
127
|
+
`NO_PROXY=${LOOPBACK_NO_PROXY} (plus your own entries) and run ` +
|
|
128
|
+
"`node --use-env-proxy <path to bir.js> setup …` (the install line does this for you).");
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
err(AUTH_URL_HINT);
|
|
132
|
+
}
|
|
97
133
|
return 1;
|
|
98
134
|
}
|
|
99
135
|
const envUrl = (env.BIR_AUTH_URL ?? "").replace(/\/+$/, "");
|
|
@@ -107,7 +143,7 @@ export async function runSetup(args, deps) {
|
|
|
107
143
|
err(`BIR_AUTH_URL in this environment is ${envUrl}, but ${args.authUrl ? "this sets up" : "the stored address is"} ${args.authUrl ? baseUrl : storedUrl}.`);
|
|
108
144
|
err("Remove that variable (check your shell profile), or the recorder will not record.");
|
|
109
145
|
}
|
|
110
|
-
const project =
|
|
146
|
+
const project = canonicalProject(args.project ?? process.cwd());
|
|
111
147
|
if (!existsSync(project) || !statSync(project).isDirectory()) {
|
|
112
148
|
err(`Project directory not found: ${project}`);
|
|
113
149
|
return 1;
|
|
@@ -185,6 +221,33 @@ export async function runSetup(args, deps) {
|
|
|
185
221
|
}
|
|
186
222
|
// ── 4. the project ───────────────────────────────────────────────────────
|
|
187
223
|
chdir(project);
|
|
224
|
+
if (!args.portExplicit) {
|
|
225
|
+
// A port chosen at the first install is kept on later ones — the hooks name
|
|
226
|
+
// it — but this machine may no longer let it be bound: Hyper-V, WSL and
|
|
227
|
+
// Docker reserve blocks inside the dynamic range, and those move across
|
|
228
|
+
// reboots. A recorder told to use it exits, and every hook of every session
|
|
229
|
+
// then fails. Setup is the upgrade and repair path, so it moves the project
|
|
230
|
+
// to a free port and lets install rewrite the hooks and entries with it —
|
|
231
|
+
// unless the port is busy because THIS project's recorder is on it.
|
|
232
|
+
const sidecar = readSidecar();
|
|
233
|
+
const recorded = projectRecord(sidecar, project);
|
|
234
|
+
if (recorded) {
|
|
235
|
+
// Age does not matter here, only a live pid: a recorder whose file went
|
|
236
|
+
// stale over a long sleep (or an older runner's, whose heartbeat is
|
|
237
|
+
// slower) still holds the port, and moving away from it would leave
|
|
238
|
+
// two recorders and hooks pointing at the new one.
|
|
239
|
+
const ours = readDiscovery(project, { ignoreAge: true });
|
|
240
|
+
const heldByUs = Boolean(ours && pidAlive(ours.pid) && ours.url.endsWith(`:${recorded.port}`));
|
|
241
|
+
if (!heldByUs && !(await probeFreePort(recorded.port))) {
|
|
242
|
+
const next = await allocateReplacementPort(sidecar, project, recorded.port);
|
|
243
|
+
if (next !== undefined) {
|
|
244
|
+
setProjectRecord(sidecar, project, { port: next });
|
|
245
|
+
writeSidecar(sidecar);
|
|
246
|
+
out(` port ${recorded.port} is not available on this machine; this project moves to ${next}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
188
251
|
if (args.allowServers !== undefined) {
|
|
189
252
|
// Stored before install so the recorder started below reads it at start.
|
|
190
253
|
const sidecar = readSidecar();
|
|
@@ -202,11 +265,17 @@ export async function runSetup(args, deps) {
|
|
|
202
265
|
if (args.daemon) {
|
|
203
266
|
out("==> Starting the recorder in the background");
|
|
204
267
|
try {
|
|
205
|
-
|
|
268
|
+
// probeIdle: a recorder too old to say whether its "open" turn is real
|
|
269
|
+
// (0.2.12 never closes an interrupted one) is replaced when two /health
|
|
270
|
+
// probes a few seconds apart show it idle (control/daemon.ts). Setup has
|
|
271
|
+
// the time; the SessionStart hook does not ask for it.
|
|
272
|
+
const ensureOpts = {
|
|
206
273
|
hooksScript: deps.hooksScript,
|
|
207
274
|
env,
|
|
208
275
|
restartOnSkew: true,
|
|
209
|
-
|
|
276
|
+
probeIdle: true,
|
|
277
|
+
};
|
|
278
|
+
ensured = await ensureDaemon(project, ensureOpts);
|
|
210
279
|
if (!ensured.started &&
|
|
211
280
|
ensured.status.health.recording === false &&
|
|
212
281
|
activeSessions(ensured.status.health) === 0) {
|
|
@@ -219,6 +288,36 @@ export async function runSetup(args, deps) {
|
|
|
219
288
|
ensured = await ensureDaemon(project, { hooksScript: deps.hooksScript, env });
|
|
220
289
|
ensured = { ...ensured, restarted: true };
|
|
221
290
|
}
|
|
291
|
+
const port = projectRecord(readSidecar(), project)?.port;
|
|
292
|
+
if (port !== undefined &&
|
|
293
|
+
!ensured.status.info.url.endsWith(`:${port}`) &&
|
|
294
|
+
activeSessions(ensured.status.health) === 0) {
|
|
295
|
+
// A recorder still on the port this project had before (moved above,
|
|
296
|
+
// or by `--port`): the hooks now post to the new one, so it would
|
|
297
|
+
// receive nothing. Nothing is mid-run; replace it.
|
|
298
|
+
out(` the running recorder is on ${ensured.status.info.url}, not port ${port}; replacing it`);
|
|
299
|
+
await stopDaemon(project);
|
|
300
|
+
ensured = await ensureDaemon(project, { hooksScript: deps.hooksScript, env });
|
|
301
|
+
ensured = { ...ensured, restarted: true };
|
|
302
|
+
}
|
|
303
|
+
// The replay switch is read once, when a recorder starts: one started
|
|
304
|
+
// before this setup turned replay off (--no-replay) or on would go on
|
|
305
|
+
// doing what it did. `source: "env"` is BIR_REPLAY where it started,
|
|
306
|
+
// which outranks the switch — a restart from here would not carry it.
|
|
307
|
+
const wantReplay = projectRecord(readSidecar(), project)?.replay?.enabled !== false;
|
|
308
|
+
const running = ensured.status.health.replay;
|
|
309
|
+
if (typeof running?.enabled === "boolean" && running.enabled !== wantReplay && running.source !== "env") {
|
|
310
|
+
if (activeSessions(ensured.status.health) === 0) {
|
|
311
|
+
out(` the running recorder has replay ${running.enabled ? "on" : "off"}; replacing it`);
|
|
312
|
+
await stopDaemon(project);
|
|
313
|
+
ensured = await ensureDaemon(project, { hooksScript: deps.hooksScript, env });
|
|
314
|
+
ensured = { ...ensured, restarted: true };
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
out(` the running recorder keeps replay ${running.enabled ? "on" : "off"} for the session open now; ` +
|
|
318
|
+
"`bir up --restart` once it ends applies the change");
|
|
319
|
+
}
|
|
320
|
+
}
|
|
222
321
|
}
|
|
223
322
|
catch (e) {
|
|
224
323
|
err(`the recorder did not start: ${errText(e)}`);
|
|
@@ -227,40 +326,67 @@ export async function runSetup(args, deps) {
|
|
|
227
326
|
}
|
|
228
327
|
}
|
|
229
328
|
// ── 6. the summary ───────────────────────────────────────────────────────
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
329
|
+
const live = resolveServers(project).filter((s) => s.name !== SCENARIO_SERVER_KEY);
|
|
330
|
+
const wrapped = live.filter((s) => isWrapped(s.config)).map((s) => s.name);
|
|
331
|
+
// Servers install left as Claude Code runs them (not approved there yet, a
|
|
332
|
+
// remote it signs in itself, …) are still here: "no MCP servers" would lie.
|
|
333
|
+
const unwrapped = live.filter((s) => !isWrapped(s.config)).map((s) => s.name);
|
|
233
334
|
const health = ensured?.status.health;
|
|
234
335
|
const replay = health?.replay;
|
|
235
336
|
const recording = health ? health.recording !== false : undefined;
|
|
236
337
|
const policy = projectRecord(readSidecar(), project)?.replay;
|
|
237
338
|
const allow = replay?.allowServers ?? policy?.allowServers ?? null;
|
|
339
|
+
// The stored switch, which every recorder here reads at start — unless the
|
|
340
|
+
// one that answered was started with BIR_REPLAY, which outranks it.
|
|
341
|
+
const replayOn = replay?.source === "env" ? replay.enabled !== false : policy?.enabled !== false;
|
|
238
342
|
out();
|
|
239
|
-
out(`Done. Recording${args.replay ? " and replay are" : " is"} on for ${project}`);
|
|
343
|
+
out(`Done. Recording${args.replay && replayOn ? " and replay are" : " is"} on for ${project}`);
|
|
344
|
+
// The kill switch outranks all of it, and setup leaves it as the person set
|
|
345
|
+
// it: turning the runner back on is theirs to decide, not an upgrade's.
|
|
346
|
+
const off = offSource(project);
|
|
347
|
+
if (off) {
|
|
348
|
+
out(` !! but the runner is OFF (${off === "env" ? "BIR_OFF=1 in this environment" : off === "global" ? "`bir off`, every project" : "`bir off --here`"}): nothing is recorded or replayed until \`bir on${off === "project" ? " --here" : ""}\``);
|
|
349
|
+
}
|
|
240
350
|
out(` account ${session.user.email} (${baseUrl})`);
|
|
241
|
-
out(` wrapped ${wrapped.length ? wrapped.join(", ") : "(no MCP servers here)"}`);
|
|
351
|
+
out(` wrapped ${wrapped.length ? wrapped.join(", ") : unwrapped.length ? `(none — ${unwrapped.join(", ")} left as they are, see above)` : "(no MCP servers here)"}`);
|
|
242
352
|
if (!args.replay) {
|
|
243
|
-
|
|
353
|
+
const scenarioLeft = resolveServers(project).some((s) => s.name === SCENARIO_SERVER_KEY && isScenarioServer(s.config));
|
|
354
|
+
out(replayOn
|
|
355
|
+
? " replay ON despite --no-replay: BIR_REPLAY is set where the recorder started"
|
|
356
|
+
: ` replay off (--no-replay) — \`bir replay on\` turns it on${scenarioLeft ? "; the scenario server an earlier setup added stays (`bir uninstall --replay` removes it)" : ""}`);
|
|
357
|
+
}
|
|
358
|
+
else if (!replayOn) {
|
|
359
|
+
out(replay?.source === "env"
|
|
360
|
+
? " replay off — BIR_REPLAY=0 where the recorder started"
|
|
361
|
+
: " replay off here (`bir replay off`) — `bir replay on` turns it on");
|
|
244
362
|
}
|
|
245
363
|
else if (wrapped.length === 0) {
|
|
246
|
-
out(
|
|
364
|
+
out(unwrapped.length
|
|
365
|
+
? " replay steer mode only — none of this project's MCP servers is wrapped (why is above); `bir setup` again once one can be, for direct replay"
|
|
366
|
+
: " replay steer mode only — no MCP servers wrapped here; add a .mcp.json and run `bir setup` again for direct replay");
|
|
247
367
|
}
|
|
248
368
|
else {
|
|
249
|
-
|
|
250
|
-
out(` replay ${on ? "on" : "off"} — direct execution allowed for: ${allow?.length ? allow.join(", ") : `all wrapped (narrow it with \`bir replay allow ${wrapped[0]}\`)`}`);
|
|
369
|
+
out(` replay on — direct execution allowed for: ${allow?.length ? allow.join(", ") : `all wrapped (narrow it with \`bir replay allow ${wrapped[0]}\`)`}`);
|
|
251
370
|
}
|
|
252
371
|
if (ensured) {
|
|
253
372
|
const { info, health: h } = ensured.status;
|
|
254
373
|
const theirs = String(h.hooksVersion ?? info.version ?? "");
|
|
255
374
|
out(` recorder ${ensured.started ? (ensured.restarted ? "restarted" : "started") : "already running"} in the background (pid ${info.pid})` +
|
|
256
375
|
" — it starts itself with every Claude Code session");
|
|
257
|
-
|
|
258
|
-
|
|
376
|
+
// Only for an OLDER recorder: a newer one is kept on purpose
|
|
377
|
+
// (daemon.newer_kept) and nothing about it waits on a restart.
|
|
378
|
+
if (theirs && compareVersions(theirs, packageVersion()) === -1) {
|
|
379
|
+
// Said as it is: nothing replaces it by itself while that session runs —
|
|
380
|
+
// the next session's start keeps it too, as long as it reports a turn
|
|
381
|
+
// open — and until it is replaced it behaves as ${theirs} does (0.2.12:
|
|
382
|
+
// approves every wrapped MCP call, ignores `bir off`).
|
|
383
|
+
out(` it is still ${theirs}, serving a Claude Code session that is mid-turn; it keeps running as ${theirs} ` +
|
|
384
|
+
`until \`bir up --restart\` (run it once that session ends) replaces it with ${packageVersion()}`);
|
|
259
385
|
}
|
|
260
386
|
if (info.logFile)
|
|
261
387
|
out(` log ${info.logFile}`);
|
|
262
388
|
if (recording === false) {
|
|
263
|
-
out(" !! the recorder has no session to record with — run `bir login
|
|
389
|
+
out(" !! the recorder has no session to record with — run `bir login`; it starts recording by itself after that");
|
|
264
390
|
}
|
|
265
391
|
}
|
|
266
392
|
else {
|
|
@@ -280,7 +406,7 @@ export async function runSetup(args, deps) {
|
|
|
280
406
|
out(' first time in this folder: say Yes to "trust this folder"');
|
|
281
407
|
out(" just installed Claude Code? it asks you to sign in to Claude (it needs a Claude subscription)");
|
|
282
408
|
out(` give it a task that takes 4+ tool calls — it appears ${console_ ? `at ${console_}/recordings` : "in the console's Recordings"} within a minute of finishing`);
|
|
283
|
-
out("Any time: bir doctor bir investigate bir
|
|
409
|
+
out("Any time: bir doctor bir investigate bir off (out of the way, at once) bir uninstall (undo)");
|
|
284
410
|
return recording === false ? 1 : 0;
|
|
285
411
|
}
|
|
286
412
|
//# sourceMappingURL=setup.js.map
|
|
@@ -7,11 +7,18 @@
|
|
|
7
7
|
* runs. `resolveServers` reports the winner; this module writes there and warns
|
|
8
8
|
* loudly when a lower scope also defines the name.
|
|
9
9
|
*
|
|
10
|
-
* Byte-for-byte reversibility: the *text* of every file touched is
|
|
11
|
-
* the first edit, together with a hash of what we wrote. On
|
|
12
|
-
* still matching that hash
|
|
13
|
-
*
|
|
14
|
-
*
|
|
10
|
+
* Byte-for-byte reversibility: the *text* of every project file touched is
|
|
11
|
+
* stashed before the first edit, together with a hash of what we wrote. On
|
|
12
|
+
* uninstall, a file still matching that hash — and not changed by anybody else
|
|
13
|
+
* between two of our writes — is restored verbatim; a file edited since is
|
|
14
|
+
* repaired entry-by-entry instead, with a warning. `~/.claude.json` is always
|
|
15
|
+
* repaired entry by entry: it is Claude Code's own, rewritten constantly, and
|
|
16
|
+
* holds every project's settings.
|
|
17
|
+
*
|
|
18
|
+
* Where the wrapped entries go (bir.ts, install): the LOCAL scope, as an
|
|
19
|
+
* override of the same name — never the team's `.mcp.json`, which gets
|
|
20
|
+
* committed and would carry this machine's node and runner paths to every
|
|
21
|
+
* clone, and never the user scope, which applies to every project.
|
|
15
22
|
*/
|
|
16
23
|
import { type McpServerConfig, type Scope } from "../resolve.js";
|
|
17
24
|
export interface ClaudeCodePaths {
|
|
@@ -42,12 +49,21 @@ export declare const LOCAL_SETTINGS_EXCLUDE = ".claude/settings.local.json";
|
|
|
42
49
|
* `.git/info/exclude`, never `.gitignore`: the first is the person's own,
|
|
43
50
|
* untracked list; the second is a file the team owns and would see in a diff.
|
|
44
51
|
* Walks up from `dir` to find the repository, follows a worktree's `gitdir:`
|
|
45
|
-
* pointer
|
|
52
|
+
* pointer to the repository's common git directory (the only exclude file git
|
|
53
|
+
* reads for a linked worktree), and appends the line once. Returns the exclude file's path when the
|
|
46
54
|
* line is there (added or already present), undefined outside a repository or
|
|
47
55
|
* when the file could not be written — an exclude that fails is a note, not a
|
|
48
56
|
* failed install.
|
|
49
57
|
*/
|
|
50
58
|
export declare function ensureGitExclude(dir: string, line?: string): string | undefined;
|
|
59
|
+
/**
|
|
60
|
+
* {@link ensureGitExclude}, also saying whether the line was ADDED now (false:
|
|
61
|
+
* it was already there — somebody's own, which uninstall must leave alone).
|
|
62
|
+
*/
|
|
63
|
+
export declare function ensureGitExcludeLine(dir: string, line?: string): {
|
|
64
|
+
path: string;
|
|
65
|
+
added: boolean;
|
|
66
|
+
} | undefined;
|
|
51
67
|
/** Undo {@link ensureGitExclude}: drop the line, leave everything else. */
|
|
52
68
|
export declare function removeGitExclude(dir: string, line?: string): string | undefined;
|
|
53
69
|
/**
|
|
@@ -68,6 +84,28 @@ export declare function fileForScope(scope: Scope, paths: ClaudeCodePaths): stri
|
|
|
68
84
|
export declare function sha256(text: string): string;
|
|
69
85
|
/** Read a file's text, or null when it does not exist. */
|
|
70
86
|
export declare function readTextOrNull(path: string): string | null;
|
|
87
|
+
/**
|
|
88
|
+
* Read-modify-write one JSON file, safely:
|
|
89
|
+
*
|
|
90
|
+
* - a file that is not plain JSON (comments, trailing commas, a torn write)
|
|
91
|
+
* is REFUSED with an error naming it, never rewritten from a guess;
|
|
92
|
+
* - the file is re-read just before the write, and the edit is applied again
|
|
93
|
+
* to the fresh text when somebody else changed it in between (Claude Code
|
|
94
|
+
* rewrites `~/.claude.json` constantly, and a lost update there loses a
|
|
95
|
+
* server the user just added, or ours);
|
|
96
|
+
* - the write itself is atomic (util/atomic-write.ts), so Claude Code never
|
|
97
|
+
* reads a half-written config, and a symlinked file stays a symlink.
|
|
98
|
+
*
|
|
99
|
+
* `mutate` gets a fresh parse each attempt and must not keep state between
|
|
100
|
+
* calls. Returns the text written, for the sidecar's hash.
|
|
101
|
+
*/
|
|
102
|
+
export declare function editJson(path: string, mutate: (parsed: Record<string, unknown>) => void): string;
|
|
103
|
+
/**
|
|
104
|
+
* Throw (naming the file) when `path` exists and is not a plain JSON object —
|
|
105
|
+
* so an install can refuse up front, before it has written anything, instead
|
|
106
|
+
* of stopping halfway through with some files changed and others not.
|
|
107
|
+
*/
|
|
108
|
+
export declare function assertEditableJson(path: string): void;
|
|
71
109
|
/**
|
|
72
110
|
* Set (or delete, with `entry === null`) one MCP server entry in `scope`.
|
|
73
111
|
* Returns the file's new text so the caller can hash it for the sidecar.
|
|
@@ -76,6 +114,17 @@ export declare function setServerEntry(scope: Scope, cwd: string, name: string,
|
|
|
76
114
|
file: string;
|
|
77
115
|
text: string;
|
|
78
116
|
};
|
|
117
|
+
/**
|
|
118
|
+
* Set (or delete) one entry of the local scope under each of `keys` in
|
|
119
|
+
* `~/.claude.json`, in ONE atomic edit. `shouldTouch` sees the entry currently
|
|
120
|
+
* under a key (or undefined) and may veto that key — so a user's own entry
|
|
121
|
+
* under another spelling of the project is never overwritten or deleted.
|
|
122
|
+
* Returns the text written and the keys actually changed (none: no write).
|
|
123
|
+
*/
|
|
124
|
+
export declare function setLocalEntries(claudeJson: string, keys: string[], name: string, entry: McpServerConfig | null, shouldTouch?: (current: McpServerConfig | undefined) => boolean): {
|
|
125
|
+
text?: string;
|
|
126
|
+
keys: string[];
|
|
127
|
+
};
|
|
79
128
|
/**
|
|
80
129
|
* The hook events `bir-hooks` registers, and the control route each drives.
|
|
81
130
|
*
|
|
@@ -84,6 +133,17 @@ export declare function setServerEntry(scope: Scope, cwd: string, name: string,
|
|
|
84
133
|
* only listens to the success one records a step that never closes.
|
|
85
134
|
* `SubagentStart` / `SubagentStop` carry `agent_id`, which is what lets a
|
|
86
135
|
* subagent's steps be attributed rather than silently interleaved.
|
|
136
|
+
*
|
|
137
|
+
* THE TIMEOUTS ARE THE USER'S WORST CASE. Claude Code waits for a hung hook up
|
|
138
|
+
* to its `timeout`, so each number here is how long a slow or blackholed
|
|
139
|
+
* service could make a person wait — at every prompt, every tool call, every
|
|
140
|
+
* `/exit`. The control server keeps its own per-route deadlines well inside
|
|
141
|
+
* them (prompt ≤ 7 s, tool/pre ≤ 12 s, tool/post and stop ≤ 2 s, end ≤ 1 s,
|
|
142
|
+
* start ≤ 2 s) and answers `{}` on overrun, so these are the second line of
|
|
143
|
+
* defence, not a budget to spend. SessionEnd matters twice: Claude Code's own
|
|
144
|
+
* default for it is 1.5 s, and whatever is written here RAISES that — the 30 it
|
|
145
|
+
* used to be turned every `/exit` during a service incident into a 30 s wait.
|
|
146
|
+
* A re-run of `bir setup` rewrites existing installs with these.
|
|
87
147
|
*/
|
|
88
148
|
export declare const HOOK_ROUTES: Array<{
|
|
89
149
|
event: string;
|
|
@@ -121,8 +181,12 @@ export type HooksBlock = Record<string, Array<{
|
|
|
121
181
|
* Exported so the binary and the adapter cannot drift apart on the spelling.
|
|
122
182
|
*/
|
|
123
183
|
export declare const ENSURE_ARG = "ensure";
|
|
124
|
-
/**
|
|
125
|
-
|
|
184
|
+
/**
|
|
185
|
+
* How long SessionStart may wait for a background recorder to come up. The
|
|
186
|
+
* ensure hook keeps its own budget at 12 s or less and always exits 0; this is
|
|
187
|
+
* the host's ceiling on top of it.
|
|
188
|
+
*/
|
|
189
|
+
export declare const ENSURE_TIMEOUT_S = 15;
|
|
126
190
|
export interface HooksBlockOptions {
|
|
127
191
|
/**
|
|
128
192
|
* Make SessionStart start the recorder itself when none is running.
|
|
@@ -134,27 +198,41 @@ export interface HooksBlockOptions {
|
|
|
134
198
|
* **replaced**, not added to. Hooks for one event run in parallel, and an
|
|
135
199
|
* HTTP hook racing the daemon it depends on would fail every first session.
|
|
136
200
|
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
201
|
+
* The `{ command, args }` form is an already-built exec-form invocation —
|
|
202
|
+
* what an npx install uses (`npx --prefer-offline -y -p <spec> bir-hooks
|
|
203
|
+
* ensure`, in `cmd /c` on Windows). It pays npx's start-up once per session,
|
|
204
|
+
* which is affordable there and not per tool call; without it nothing ever
|
|
205
|
+
* starts the recorder of an npx install, and every hook of every session
|
|
206
|
+
* fails against a port nobody listens on.
|
|
140
207
|
*/
|
|
141
208
|
ensure?: {
|
|
142
209
|
node: string;
|
|
143
210
|
hooksScript: string;
|
|
144
211
|
nodeFlags?: string[];
|
|
212
|
+
} | {
|
|
213
|
+
command: string;
|
|
214
|
+
args: string[];
|
|
145
215
|
};
|
|
146
216
|
}
|
|
147
217
|
/** Build the 'hooks' block pointing at a control server. */
|
|
148
218
|
export declare function buildHooksBlock(controlUrl: string, token: string, opts?: HooksBlockOptions): HooksBlock;
|
|
149
219
|
/**
|
|
150
|
-
* True when a hook entry is one of ours
|
|
151
|
-
*
|
|
220
|
+
* True when a hook entry is one of ours, and only then — install and uninstall
|
|
221
|
+
* delete what this says is ours, so a false positive deletes somebody's hook.
|
|
222
|
+
*
|
|
223
|
+
* HTTP: a loopback URL (127.0.0.1 / localhost / [::1]) whose path is exactly
|
|
224
|
+
* one of our routes, AND either our kind of bearer token or a port in
|
|
225
|
+
* the range `bir` allocates (the token-less 0.2.8 shape). A hook to
|
|
226
|
+
* `https://hooks.example.com/session/end` is somebody else's.
|
|
227
|
+
* exec: our self-starting SessionStart hook — `bir-hooks.js ensure`, or the
|
|
228
|
+
* npx form `… -p @basein/runner@<v> bir-hooks ensure`.
|
|
152
229
|
*/
|
|
153
230
|
export declare function isBirHook(entry: unknown): boolean;
|
|
154
231
|
/**
|
|
155
232
|
* Merge our hooks into a settings file, leaving every other hook alone. Returns
|
|
156
233
|
* the new text. Our own entries are replaced rather than appended, so repeated
|
|
157
|
-
* installs stay idempotent
|
|
234
|
+
* installs stay idempotent — hook by hook: a user's own hook that happens to
|
|
235
|
+
* share a matcher group with one of ours stays exactly where it is.
|
|
158
236
|
*/
|
|
159
237
|
export declare function installHooks(settingsPath: string, block: HooksBlock): string;
|
|
160
238
|
/** Remove only our hook entries, and drop events left empty. */
|