@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
|
@@ -19,7 +19,12 @@
|
|
|
19
19
|
* STALENESS. A discovery file outlives its process on a crash. Every read checks
|
|
20
20
|
* that the recorded pid is still alive (`kill(pid, 0)`) and that the file is not
|
|
21
21
|
* older than {@link MAX_DISCOVERY_AGE_MS}; a stale file is treated as absent, so
|
|
22
|
-
* a dead session can never capture a live proxy's steps.
|
|
22
|
+
* a dead session can never capture a live proxy's steps. The age rule is for
|
|
23
|
+
* proxies, which cannot afford a network probe per start; the ensure hook and
|
|
24
|
+
* `bir down` ask the recorder itself (`readDiscovery(cwd, { ignoreAge: true })`
|
|
25
|
+
* plus `/health`), because a laptop that slept over a weekend wakes with a live
|
|
26
|
+
* recorder whose file reads as days old — and treating that as dead started a
|
|
27
|
+
* duplicate that could not bind, and hid the live one from every proxy.
|
|
23
28
|
*
|
|
24
29
|
* SECURITY (§9). Files are mode 0600 and carry the loopback bearer token. A local
|
|
25
30
|
* port that accepts unauthenticated step reports is a local exfiltration channel.
|
|
@@ -29,11 +34,17 @@ export declare const MAX_DISCOVERY_AGE_MS: number;
|
|
|
29
34
|
/**
|
|
30
35
|
* How often a running control server rewrites its discovery file.
|
|
31
36
|
*
|
|
32
|
-
* Well inside {@link MAX_DISCOVERY_AGE_MS}
|
|
33
|
-
*
|
|
34
|
-
*
|
|
37
|
+
* Well inside {@link MAX_DISCOVERY_AGE_MS}, so a dead recorder is still
|
|
38
|
+
* forgotten within a day. It is measured on the wall clock, not by a timer
|
|
39
|
+
* alone: on macOS and Linux a timer's clock stops while the machine sleeps, so
|
|
40
|
+
* a recorder woken after a weekend would wait up to this long for its next beat
|
|
41
|
+
* while its file read as stale. `bir-hooks` checks every
|
|
42
|
+
* {@link DISCOVERY_CHECK_MS} and rewrites when this much wall time has passed
|
|
43
|
+
* since the last write, or when the file has gone missing.
|
|
35
44
|
*/
|
|
36
45
|
export declare const HEARTBEAT_MS: number;
|
|
46
|
+
/** How often a running control server checks its discovery file (see {@link HEARTBEAT_MS}). */
|
|
47
|
+
export declare const DISCOVERY_CHECK_MS: number;
|
|
37
48
|
export interface DiscoveryInfo {
|
|
38
49
|
url: string;
|
|
39
50
|
/** Random 32 bytes hex; required on every control-server route. */
|
|
@@ -61,13 +72,74 @@ export interface DiscoveryInfo {
|
|
|
61
72
|
}
|
|
62
73
|
export declare function writeDiscovery(info: DiscoveryInfo): string;
|
|
63
74
|
export declare function removeDiscovery(cwd: string): void;
|
|
75
|
+
/**
|
|
76
|
+
* Remove the discovery file for `cwd` only if it still names `pid`.
|
|
77
|
+
*
|
|
78
|
+
* A recorder that is shutting down must not delete the file of the one that
|
|
79
|
+
* replaced it, and a process that found this project's port already served by
|
|
80
|
+
* this project's recorder must leave that recorder's file alone.
|
|
81
|
+
*/
|
|
82
|
+
export declare function removeDiscoveryIfOwned(cwd: string, pid: number): void;
|
|
64
83
|
/** True when a pid is still running (or when we cannot tell, which we treat as alive). */
|
|
65
84
|
export declare function pidAlive(pid: number): boolean;
|
|
85
|
+
export interface ReadDiscoveryOptions {
|
|
86
|
+
/**
|
|
87
|
+
* Skip the {@link MAX_DISCOVERY_AGE_MS} rule and let the caller decide by
|
|
88
|
+
* asking the process (`/health`). The pid must still be alive.
|
|
89
|
+
*/
|
|
90
|
+
ignoreAge?: boolean;
|
|
91
|
+
}
|
|
66
92
|
/**
|
|
67
93
|
* Read the discovery file for `cwd`, or undefined when there is none, it is
|
|
68
94
|
* malformed, or it is stale.
|
|
69
95
|
*/
|
|
70
|
-
export declare function readDiscovery(cwd: string): DiscoveryInfo | undefined;
|
|
96
|
+
export declare function readDiscovery(cwd: string, opts?: ReadDiscoveryOptions): DiscoveryInfo | undefined;
|
|
97
|
+
/** Newest of a record's two stamps, epoch ms (0 when it has neither). */
|
|
98
|
+
export declare function discoveryStamp(info: Pick<DiscoveryInfo, "startedAt" | "heartbeatAt">): number;
|
|
99
|
+
/**
|
|
100
|
+
* Delete the control directory's records of recorders that are gone.
|
|
101
|
+
*
|
|
102
|
+
* Each discovery file carries its project's loopback token, and nothing ever
|
|
103
|
+
* removed the file of a recorder that crashed, or of a project that was
|
|
104
|
+
* deleted: one machine had seven of them, months old. Removed: records whose
|
|
105
|
+
* pid is dead, unreadable records older than a day, and temp files a writer
|
|
106
|
+
* left behind. A record whose pid is alive is kept whatever its age — that
|
|
107
|
+
* process is asked, not guessed about (see {@link readDiscovery}).
|
|
108
|
+
*
|
|
109
|
+
* Never throws. Returns how many files it removed.
|
|
110
|
+
*/
|
|
111
|
+
export declare function sweepStaleDiscovery(now?: number): number;
|
|
112
|
+
/**
|
|
113
|
+
* A recorder that no longer serves the port its project's hooks post to — a
|
|
114
|
+
* reinstall (`bir install --port N`, `bir uninstall` + `bir setup`) moved
|
|
115
|
+
* them while a Claude Code window was open — and is kept for the windows
|
|
116
|
+
* opened before, whose hooks still name its port. The project's discovery
|
|
117
|
+
* file names the new recorder only, so without this record the old one could
|
|
118
|
+
* not be listed or stopped. Mode 0600: it carries the old token.
|
|
119
|
+
*/
|
|
120
|
+
export interface SupersededRecorder {
|
|
121
|
+
cwd: string;
|
|
122
|
+
pid: number;
|
|
123
|
+
port: number;
|
|
124
|
+
url: string;
|
|
125
|
+
/** Its loopback token, so it can be asked to stop rather than killed. */
|
|
126
|
+
token: string;
|
|
127
|
+
/** When it was first seen superseded, epoch ms. */
|
|
128
|
+
since: number;
|
|
129
|
+
version?: string;
|
|
130
|
+
logFile?: string;
|
|
131
|
+
}
|
|
132
|
+
/** Write (or rewrite) the record of a superseded recorder. Never throws; the path, or undefined. */
|
|
133
|
+
export declare function writeSupersededRecord(record: SupersededRecorder): string | undefined;
|
|
134
|
+
/** Remove the record of superseded recorder `pid` of `cwd`. Never throws. */
|
|
135
|
+
export declare function removeSupersededRecord(cwd: string, pid: number): void;
|
|
136
|
+
/**
|
|
137
|
+
* The superseded recorders of `cwd` still running — or of every project when
|
|
138
|
+
* `cwd` is undefined — oldest first. A record whose process is gone is removed
|
|
139
|
+
* on the way. Nothing here asks the process who it is: stopping one goes
|
|
140
|
+
* through `/health` first (daemon.ts, stopSupersededRecorder).
|
|
141
|
+
*/
|
|
142
|
+
export declare function listSupersededRecorders(cwd?: string): SupersededRecorder[];
|
|
71
143
|
/**
|
|
72
144
|
* Resolve the control server for `cwd`, retrying until `timeoutMs` elapses.
|
|
73
145
|
* `BIR_CONTROL_URL` short-circuits the search (with `BIR_CONTROL_TOKEN` for auth).
|
|
@@ -19,24 +19,37 @@
|
|
|
19
19
|
* STALENESS. A discovery file outlives its process on a crash. Every read checks
|
|
20
20
|
* that the recorded pid is still alive (`kill(pid, 0)`) and that the file is not
|
|
21
21
|
* older than {@link MAX_DISCOVERY_AGE_MS}; a stale file is treated as absent, so
|
|
22
|
-
* a dead session can never capture a live proxy's steps.
|
|
22
|
+
* a dead session can never capture a live proxy's steps. The age rule is for
|
|
23
|
+
* proxies, which cannot afford a network probe per start; the ensure hook and
|
|
24
|
+
* `bir down` ask the recorder itself (`readDiscovery(cwd, { ignoreAge: true })`
|
|
25
|
+
* plus `/health`), because a laptop that slept over a weekend wakes with a live
|
|
26
|
+
* recorder whose file reads as days old — and treating that as dead started a
|
|
27
|
+
* duplicate that could not bind, and hid the live one from every proxy.
|
|
23
28
|
*
|
|
24
29
|
* SECURITY (§9). Files are mode 0600 and carry the loopback bearer token. A local
|
|
25
30
|
* port that accepts unauthenticated step reports is a local exfiltration channel.
|
|
26
31
|
*/
|
|
27
|
-
import { existsSync, readFileSync,
|
|
28
|
-
import {
|
|
32
|
+
import { existsSync, readFileSync, readdirSync, statSync, unlinkSync } from "node:fs";
|
|
33
|
+
import { join } from "node:path";
|
|
34
|
+
import { controlDir, controlKey, discoveryPath, ensureDir, normalizePath, supersededPath } from "./paths.js";
|
|
29
35
|
import { logDetail, errText } from "../util/log.js";
|
|
36
|
+
import { writeFileAtomic } from "../util/atomic-write.js";
|
|
30
37
|
/** A discovery file older than this is ignored even if its pid is somehow alive. */
|
|
31
38
|
export const MAX_DISCOVERY_AGE_MS = 24 * 60 * 60 * 1000;
|
|
32
39
|
/**
|
|
33
40
|
* How often a running control server rewrites its discovery file.
|
|
34
41
|
*
|
|
35
|
-
* Well inside {@link MAX_DISCOVERY_AGE_MS}
|
|
36
|
-
*
|
|
37
|
-
*
|
|
42
|
+
* Well inside {@link MAX_DISCOVERY_AGE_MS}, so a dead recorder is still
|
|
43
|
+
* forgotten within a day. It is measured on the wall clock, not by a timer
|
|
44
|
+
* alone: on macOS and Linux a timer's clock stops while the machine sleeps, so
|
|
45
|
+
* a recorder woken after a weekend would wait up to this long for its next beat
|
|
46
|
+
* while its file read as stale. `bir-hooks` checks every
|
|
47
|
+
* {@link DISCOVERY_CHECK_MS} and rewrites when this much wall time has passed
|
|
48
|
+
* since the last write, or when the file has gone missing.
|
|
38
49
|
*/
|
|
39
50
|
export const HEARTBEAT_MS = 30 * 60 * 1000;
|
|
51
|
+
/** How often a running control server checks its discovery file (see {@link HEARTBEAT_MS}). */
|
|
52
|
+
export const DISCOVERY_CHECK_MS = 60 * 1000;
|
|
40
53
|
export function writeDiscovery(info) {
|
|
41
54
|
ensureDir(controlDir());
|
|
42
55
|
const path = discoveryPath(info.cwd);
|
|
@@ -44,9 +57,9 @@ export function writeDiscovery(info) {
|
|
|
44
57
|
// The heartbeat rewrites this every half hour, and a proxy or the ensure
|
|
45
58
|
// hook reading an empty file in that instant would conclude "no recorder"
|
|
46
59
|
// and start a second one.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
60
|
+
// writeFileAtomic also retries the rename that a reader's open handle can
|
|
61
|
+
// refuse for a moment on Windows.
|
|
62
|
+
writeFileAtomic(path, JSON.stringify(info, null, 2), { mode: 0o600 });
|
|
50
63
|
return path;
|
|
51
64
|
}
|
|
52
65
|
export function removeDiscovery(cwd) {
|
|
@@ -59,6 +72,26 @@ export function removeDiscovery(cwd) {
|
|
|
59
72
|
/* best effort */
|
|
60
73
|
}
|
|
61
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Remove the discovery file for `cwd` only if it still names `pid`.
|
|
77
|
+
*
|
|
78
|
+
* A recorder that is shutting down must not delete the file of the one that
|
|
79
|
+
* replaced it, and a process that found this project's port already served by
|
|
80
|
+
* this project's recorder must leave that recorder's file alone.
|
|
81
|
+
*/
|
|
82
|
+
export function removeDiscoveryIfOwned(cwd, pid) {
|
|
83
|
+
try {
|
|
84
|
+
const path = discoveryPath(cwd);
|
|
85
|
+
if (!existsSync(path))
|
|
86
|
+
return;
|
|
87
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
88
|
+
if (parsed.pid === pid)
|
|
89
|
+
unlinkSync(path);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
/* best effort */
|
|
93
|
+
}
|
|
94
|
+
}
|
|
62
95
|
/** True when a pid is still running (or when we cannot tell, which we treat as alive). */
|
|
63
96
|
export function pidAlive(pid) {
|
|
64
97
|
if (!Number.isInteger(pid) || pid <= 0)
|
|
@@ -76,7 +109,7 @@ export function pidAlive(pid) {
|
|
|
76
109
|
* Read the discovery file for `cwd`, or undefined when there is none, it is
|
|
77
110
|
* malformed, or it is stale.
|
|
78
111
|
*/
|
|
79
|
-
export function readDiscovery(cwd) {
|
|
112
|
+
export function readDiscovery(cwd, opts = {}) {
|
|
80
113
|
const path = discoveryPath(cwd);
|
|
81
114
|
try {
|
|
82
115
|
if (!existsSync(path))
|
|
@@ -92,7 +125,7 @@ export function readDiscovery(cwd) {
|
|
|
92
125
|
// every `HEARTBEAT_MS` precisely so this check keeps saying so. A file
|
|
93
126
|
// whose process died keeps its last stamp and ages out as before.
|
|
94
127
|
const freshest = Math.max(parsed.startedAt ?? 0, parsed.heartbeatAt ?? 0);
|
|
95
|
-
if (Date.now() - freshest > MAX_DISCOVERY_AGE_MS) {
|
|
128
|
+
if (!opts.ignoreAge && Date.now() - freshest > MAX_DISCOVERY_AGE_MS) {
|
|
96
129
|
logDetail("discovery.stale", { path, reason: "too old" });
|
|
97
130
|
return undefined;
|
|
98
131
|
}
|
|
@@ -107,6 +140,148 @@ export function readDiscovery(cwd) {
|
|
|
107
140
|
return undefined;
|
|
108
141
|
}
|
|
109
142
|
}
|
|
143
|
+
/** Newest of a record's two stamps, epoch ms (0 when it has neither). */
|
|
144
|
+
export function discoveryStamp(info) {
|
|
145
|
+
return Math.max(info.startedAt ?? 0, info.heartbeatAt ?? 0);
|
|
146
|
+
}
|
|
147
|
+
/** A temp file older than this was left by a writer that died mid-write. */
|
|
148
|
+
const TMP_STALE_MS = 60 * 60 * 1000;
|
|
149
|
+
/**
|
|
150
|
+
* Delete the control directory's records of recorders that are gone.
|
|
151
|
+
*
|
|
152
|
+
* Each discovery file carries its project's loopback token, and nothing ever
|
|
153
|
+
* removed the file of a recorder that crashed, or of a project that was
|
|
154
|
+
* deleted: one machine had seven of them, months old. Removed: records whose
|
|
155
|
+
* pid is dead, unreadable records older than a day, and temp files a writer
|
|
156
|
+
* left behind. A record whose pid is alive is kept whatever its age — that
|
|
157
|
+
* process is asked, not guessed about (see {@link readDiscovery}).
|
|
158
|
+
*
|
|
159
|
+
* Never throws. Returns how many files it removed.
|
|
160
|
+
*/
|
|
161
|
+
export function sweepStaleDiscovery(now = Date.now()) {
|
|
162
|
+
let removed = 0;
|
|
163
|
+
let names;
|
|
164
|
+
try {
|
|
165
|
+
names = readdirSync(controlDir());
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return 0;
|
|
169
|
+
}
|
|
170
|
+
for (const name of names) {
|
|
171
|
+
const path = join(controlDir(), name);
|
|
172
|
+
try {
|
|
173
|
+
if (name.endsWith(".tmp")) {
|
|
174
|
+
if (now - statSync(path).mtimeMs > TMP_STALE_MS) {
|
|
175
|
+
unlinkSync(path);
|
|
176
|
+
removed += 1;
|
|
177
|
+
}
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
// Discovery records and superseded recorders' records only: `<key>.json`
|
|
181
|
+
// and `<key>.superseded.<pid>.json` — not `<key>.port.json`, a lock, a
|
|
182
|
+
// down marker, or the journal directory.
|
|
183
|
+
if (!/^[0-9a-f]{16}(?:\.superseded\.\d+)?\.json$/.test(name))
|
|
184
|
+
continue;
|
|
185
|
+
let pid;
|
|
186
|
+
try {
|
|
187
|
+
pid = JSON.parse(readFileSync(path, "utf8")).pid;
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
if (now - statSync(path).mtimeMs > MAX_DISCOVERY_AGE_MS) {
|
|
191
|
+
unlinkSync(path);
|
|
192
|
+
removed += 1;
|
|
193
|
+
}
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (typeof pid !== "number" || !pidAlive(pid)) {
|
|
197
|
+
unlinkSync(path);
|
|
198
|
+
removed += 1;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
/* best effort: somebody else removed it, or is writing it */
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (removed > 0)
|
|
206
|
+
logDetail("discovery.swept", { removed });
|
|
207
|
+
return removed;
|
|
208
|
+
}
|
|
209
|
+
/** Write (or rewrite) the record of a superseded recorder. Never throws; the path, or undefined. */
|
|
210
|
+
export function writeSupersededRecord(record) {
|
|
211
|
+
try {
|
|
212
|
+
ensureDir(controlDir());
|
|
213
|
+
const path = supersededPath(record.cwd, record.pid);
|
|
214
|
+
writeFileAtomic(path, JSON.stringify(record, null, 2), { mode: 0o600 });
|
|
215
|
+
return path;
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
logDetail("discovery.superseded_write_failed", { error: errText(err) });
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/** Remove the record of superseded recorder `pid` of `cwd`. Never throws. */
|
|
223
|
+
export function removeSupersededRecord(cwd, pid) {
|
|
224
|
+
try {
|
|
225
|
+
const path = supersededPath(cwd, pid);
|
|
226
|
+
if (existsSync(path))
|
|
227
|
+
unlinkSync(path);
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
/* best effort; the sweep removes it once the pid is gone */
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* The superseded recorders of `cwd` still running — or of every project when
|
|
235
|
+
* `cwd` is undefined — oldest first. A record whose process is gone is removed
|
|
236
|
+
* on the way. Nothing here asks the process who it is: stopping one goes
|
|
237
|
+
* through `/health` first (daemon.ts, stopSupersededRecorder).
|
|
238
|
+
*/
|
|
239
|
+
export function listSupersededRecorders(cwd) {
|
|
240
|
+
let names;
|
|
241
|
+
try {
|
|
242
|
+
names = readdirSync(controlDir());
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return [];
|
|
246
|
+
}
|
|
247
|
+
const prefix = cwd === undefined ? undefined : `${controlKey(cwd)}.superseded.`;
|
|
248
|
+
const found = [];
|
|
249
|
+
for (const name of names) {
|
|
250
|
+
if (!/^[0-9a-f]{16}\.superseded\.\d+\.json$/.test(name))
|
|
251
|
+
continue;
|
|
252
|
+
if (prefix && !name.startsWith(prefix))
|
|
253
|
+
continue;
|
|
254
|
+
const path = join(controlDir(), name);
|
|
255
|
+
try {
|
|
256
|
+
const r = JSON.parse(readFileSync(path, "utf8"));
|
|
257
|
+
if (typeof r.pid !== "number" ||
|
|
258
|
+
typeof r.url !== "string" ||
|
|
259
|
+
typeof r.token !== "string" ||
|
|
260
|
+
typeof r.cwd !== "string" ||
|
|
261
|
+
(cwd !== undefined && normalizePath(r.cwd) !== normalizePath(cwd))) {
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (!pidAlive(r.pid)) {
|
|
265
|
+
unlinkSync(path);
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
found.push({
|
|
269
|
+
cwd: r.cwd,
|
|
270
|
+
pid: r.pid,
|
|
271
|
+
port: typeof r.port === "number" ? r.port : Number(new URL(r.url).port) || 0,
|
|
272
|
+
url: r.url,
|
|
273
|
+
token: r.token,
|
|
274
|
+
since: typeof r.since === "number" ? r.since : 0,
|
|
275
|
+
version: typeof r.version === "string" ? r.version : undefined,
|
|
276
|
+
logFile: typeof r.logFile === "string" ? r.logFile : undefined,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
/* unreadable, or removed meanwhile */
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return found.sort((a, b) => a.since - b.since);
|
|
284
|
+
}
|
|
110
285
|
/**
|
|
111
286
|
* Resolve the control server for `cwd`, retrying until `timeoutMs` elapses.
|
|
112
287
|
* `BIR_CONTROL_URL` short-circuits the search (with `BIR_CONTROL_TOKEN` for auth).
|
|
@@ -17,12 +17,65 @@
|
|
|
17
17
|
* stderr and a session that records Tier 2, exactly what happened before this
|
|
18
18
|
* hook existed.
|
|
19
19
|
*
|
|
20
|
+
* THE BUDGET. Claude Code waits for this hook — the session does not start
|
|
21
|
+
* until it answers — up to the timeout `bir install` wrote (15 s). The whole
|
|
22
|
+
* hook stays inside {@link ENSURE_HOOK_BUDGET_MS}: reading the payload, finding
|
|
23
|
+
* or starting the recorder, the relay, and the override check (which is
|
|
24
|
+
* skipped when little is left). A warm start (the recorder is
|
|
25
|
+
* running) is one loopback round trip; a cold one is the recorder's start-up,
|
|
26
|
+
* about a second, because the recorder listens before it touches the network.
|
|
27
|
+
* Nothing here waits on the BaseIn service. When the budget runs out the hook
|
|
28
|
+
* exits 0 and the recorder finishes coming up in the background; the next
|
|
29
|
+
* hook finds it.
|
|
30
|
+
*
|
|
31
|
+
* THE OFF SWITCH (`bir off`) does not skip this hook. The recorder is still
|
|
32
|
+
* started, because it is what answers every other hook at once with `{}` while
|
|
33
|
+
* off — without it Claude Code would show each of them a refused connection.
|
|
34
|
+
* Only the override check below is skipped while off. `BIR_OFF=1` in the
|
|
35
|
+
* environment Claude Code was started with is this window's switch, not the
|
|
36
|
+
* recorder's: the recorder serves every window of the project and never
|
|
37
|
+
* inherits it (daemon.ts, daemonEnv), so the relay tells it that this
|
|
38
|
+
* session is off (`bir_off: true`) and it answers this session's hooks `{}`.
|
|
39
|
+
*
|
|
40
|
+
* STALE OVERRIDES. Once the recorder is started or kept, the hook looks at the
|
|
41
|
+
* local-scope copies `bir install` made of this project's `.mcp.json` and
|
|
42
|
+
* user-scope servers (config/overrides.ts, checkOverrides): a copy whose
|
|
43
|
+
* source changed, or whose source is gone or no longer approved, is named in
|
|
44
|
+
* one line of the project's recorder log, with the fix (`bir setup`). It is
|
|
45
|
+
* read-only. It used to repair them here, and that meant rewriting
|
|
46
|
+
* `~/.claude.json` at the very moment Claude Code, starting, writes its own
|
|
47
|
+
* state there — without Claude Code's lock, so either write could be lost.
|
|
48
|
+
* `bir setup` and `bir doctor` repair; local files only, never throws, and
|
|
49
|
+
* only with enough of the budget left.
|
|
50
|
+
*
|
|
20
51
|
* STDOUT IS CONTEXT. For SessionStart, Claude Code adds whatever a command hook
|
|
21
52
|
* prints on stdout to the model's context. So nothing is printed unless the
|
|
22
53
|
* control server answered with something (today it answers `{}`), and then it
|
|
23
54
|
* is the answer, verbatim, as JSON.
|
|
24
55
|
*/
|
|
25
56
|
import { type EnsureOptions } from "./daemon.js";
|
|
57
|
+
/**
|
|
58
|
+
* The whole hook's budget. Inside the 15 s SessionStart timeout `bir install`
|
|
59
|
+
* writes (C9: the hook's own work stays at or under 12 s), with room for node
|
|
60
|
+
* to start and load this module.
|
|
61
|
+
*/
|
|
62
|
+
export declare const ENSURE_HOOK_BUDGET_MS = 11000;
|
|
63
|
+
/**
|
|
64
|
+
* The least of the budget the override check needs to be started. It is
|
|
65
|
+
* synchronous and local — a few JSON files, plus a `git ls-files` capped at
|
|
66
|
+
* 2 s in the rare case an approval depends on it — so with less than this
|
|
67
|
+
* left it waits for the next session start instead of running past the hook's
|
|
68
|
+
* budget.
|
|
69
|
+
*/
|
|
70
|
+
export declare const OVERRIDE_CHECK_RESERVE_MS = 3000;
|
|
71
|
+
/** The earlier name of {@link OVERRIDE_CHECK_RESERVE_MS}. */
|
|
72
|
+
export declare const RESYNC_RESERVE_MS = 3000;
|
|
73
|
+
/** What the override check reports: one entry per local copy that no longer copies its source. */
|
|
74
|
+
export type StaleOverrides = Array<{
|
|
75
|
+
name: string;
|
|
76
|
+
state: string;
|
|
77
|
+
why?: string;
|
|
78
|
+
}>;
|
|
26
79
|
export interface EnsureHookOptions extends EnsureOptions {
|
|
27
80
|
stdin?: NodeJS.ReadableStream & {
|
|
28
81
|
isTTY?: boolean;
|
|
@@ -31,6 +84,14 @@ export interface EnsureHookOptions extends EnsureOptions {
|
|
|
31
84
|
stderr?: NodeJS.WritableStream;
|
|
32
85
|
/** How long to wait for the payload on stdin before going on without it. */
|
|
33
86
|
stdinTimeoutMs?: number;
|
|
87
|
+
/** The whole hook's budget. Defaults to {@link ENSURE_HOOK_BUDGET_MS}. */
|
|
88
|
+
budgetMs?: number;
|
|
89
|
+
/**
|
|
90
|
+
* The read-only stale-override check run once the recorder is started or
|
|
91
|
+
* kept: the stale entries of `checkOverrides` by default, `false` to skip
|
|
92
|
+
* it. Injected by tests. It must not write anything.
|
|
93
|
+
*/
|
|
94
|
+
overrideCheck?: ((projectCwd: string) => StaleOverrides) | false;
|
|
34
95
|
}
|
|
35
96
|
/**
|
|
36
97
|
* Run the hook for `cwd`. Resolves the exit code, which is always 0.
|
|
@@ -17,13 +17,146 @@
|
|
|
17
17
|
* stderr and a session that records Tier 2, exactly what happened before this
|
|
18
18
|
* hook existed.
|
|
19
19
|
*
|
|
20
|
+
* THE BUDGET. Claude Code waits for this hook — the session does not start
|
|
21
|
+
* until it answers — up to the timeout `bir install` wrote (15 s). The whole
|
|
22
|
+
* hook stays inside {@link ENSURE_HOOK_BUDGET_MS}: reading the payload, finding
|
|
23
|
+
* or starting the recorder, the relay, and the override check (which is
|
|
24
|
+
* skipped when little is left). A warm start (the recorder is
|
|
25
|
+
* running) is one loopback round trip; a cold one is the recorder's start-up,
|
|
26
|
+
* about a second, because the recorder listens before it touches the network.
|
|
27
|
+
* Nothing here waits on the BaseIn service. When the budget runs out the hook
|
|
28
|
+
* exits 0 and the recorder finishes coming up in the background; the next
|
|
29
|
+
* hook finds it.
|
|
30
|
+
*
|
|
31
|
+
* THE OFF SWITCH (`bir off`) does not skip this hook. The recorder is still
|
|
32
|
+
* started, because it is what answers every other hook at once with `{}` while
|
|
33
|
+
* off — without it Claude Code would show each of them a refused connection.
|
|
34
|
+
* Only the override check below is skipped while off. `BIR_OFF=1` in the
|
|
35
|
+
* environment Claude Code was started with is this window's switch, not the
|
|
36
|
+
* recorder's: the recorder serves every window of the project and never
|
|
37
|
+
* inherits it (daemon.ts, daemonEnv), so the relay tells it that this
|
|
38
|
+
* session is off (`bir_off: true`) and it answers this session's hooks `{}`.
|
|
39
|
+
*
|
|
40
|
+
* STALE OVERRIDES. Once the recorder is started or kept, the hook looks at the
|
|
41
|
+
* local-scope copies `bir install` made of this project's `.mcp.json` and
|
|
42
|
+
* user-scope servers (config/overrides.ts, checkOverrides): a copy whose
|
|
43
|
+
* source changed, or whose source is gone or no longer approved, is named in
|
|
44
|
+
* one line of the project's recorder log, with the fix (`bir setup`). It is
|
|
45
|
+
* read-only. It used to repair them here, and that meant rewriting
|
|
46
|
+
* `~/.claude.json` at the very moment Claude Code, starting, writes its own
|
|
47
|
+
* state there — without Claude Code's lock, so either write could be lost.
|
|
48
|
+
* `bir setup` and `bir doctor` repair; local files only, never throws, and
|
|
49
|
+
* only with enough of the budget left.
|
|
50
|
+
*
|
|
20
51
|
* STDOUT IS CONTEXT. For SessionStart, Claude Code adds whatever a command hook
|
|
21
52
|
* prints on stdout to the model's context. So nothing is printed unless the
|
|
22
53
|
* control server answered with something (today it answers `{}`), and then it
|
|
23
54
|
* is the answer, verbatim, as JSON.
|
|
24
55
|
*/
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
56
|
+
import { appendFileSync } from "node:fs";
|
|
57
|
+
import { Console } from "node:console";
|
|
58
|
+
import { dirname } from "node:path";
|
|
59
|
+
import { Writable } from "node:stream";
|
|
60
|
+
import { ensureDaemon, RecorderBusyError } from "./daemon.js";
|
|
61
|
+
import { daemonLogPath, ensureDir } from "./paths.js";
|
|
62
|
+
import { checkOverrides } from "../config/overrides.js";
|
|
63
|
+
import { isRunnerOff } from "../util/off-switch.js";
|
|
64
|
+
import { timeoutSignal } from "../util/service-health.js";
|
|
65
|
+
import { errText, logLine } from "../util/log.js";
|
|
66
|
+
/**
|
|
67
|
+
* The whole hook's budget. Inside the 15 s SessionStart timeout `bir install`
|
|
68
|
+
* writes (C9: the hook's own work stays at or under 12 s), with room for node
|
|
69
|
+
* to start and load this module.
|
|
70
|
+
*/
|
|
71
|
+
export const ENSURE_HOOK_BUDGET_MS = 11_000;
|
|
72
|
+
/** Kept back from the recorder start for the relay that follows it. */
|
|
73
|
+
const RELAY_RESERVE_MS = 1_000;
|
|
74
|
+
/** The relay's own ceiling; the control server answers SessionStart in well under this. */
|
|
75
|
+
const RELAY_TIMEOUT_MS = 2_500;
|
|
76
|
+
/** Claude Code writes the payload and closes stdin at once; a person at a terminal writes nothing. */
|
|
77
|
+
const STDIN_TIMEOUT_MS = 2_000;
|
|
78
|
+
/**
|
|
79
|
+
* The least of the budget the override check needs to be started. It is
|
|
80
|
+
* synchronous and local — a few JSON files, plus a `git ls-files` capped at
|
|
81
|
+
* 2 s in the rare case an approval depends on it — so with less than this
|
|
82
|
+
* left it waits for the next session start instead of running past the hook's
|
|
83
|
+
* budget.
|
|
84
|
+
*/
|
|
85
|
+
export const OVERRIDE_CHECK_RESERVE_MS = 3_000;
|
|
86
|
+
/** The earlier name of {@link OVERRIDE_CHECK_RESERVE_MS}. */
|
|
87
|
+
export const RESYNC_RESERVE_MS = OVERRIDE_CHECK_RESERVE_MS;
|
|
88
|
+
/** The copies `checkOverrides` finds stale. Reads files only. */
|
|
89
|
+
function staleOverrides(projectCwd) {
|
|
90
|
+
return checkOverrides(projectCwd)
|
|
91
|
+
.filter((c) => c.state !== "current")
|
|
92
|
+
.map((c) => ({ name: c.name, state: c.state, why: c.why }));
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Run `fn` with every audit line it logs appended to `file` — the project's
|
|
96
|
+
* recorder log, where `bir doctor` and a person looking for "what changed my
|
|
97
|
+
* MCP config" read — instead of this hook's stderr, which nobody sees.
|
|
98
|
+
* Synchronous and best-effort: a line that cannot be written is dropped, and
|
|
99
|
+
* never costs the hook anything.
|
|
100
|
+
*/
|
|
101
|
+
function logInto(file, fn) {
|
|
102
|
+
ensureDir(dirname(file));
|
|
103
|
+
const sink = new Writable({
|
|
104
|
+
write(chunk, _encoding, callback) {
|
|
105
|
+
try {
|
|
106
|
+
appendFileSync(file, chunk);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
/* dropped: the log is not worth a failed session start */
|
|
110
|
+
}
|
|
111
|
+
callback();
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
const saved = globalThis.console;
|
|
115
|
+
globalThis.console = new Console({ stdout: sink, stderr: sink });
|
|
116
|
+
try {
|
|
117
|
+
fn();
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
globalThis.console = saved;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The stale-override check (see STALE OVERRIDES above): one line in the
|
|
125
|
+
* project's log naming what is stale and the fix. Skipped while the runner is
|
|
126
|
+
* off and when less than {@link OVERRIDE_CHECK_RESERVE_MS} of the budget is
|
|
127
|
+
* left. Never throws; writes nothing to stdout, and nothing to Claude Code's
|
|
128
|
+
* files.
|
|
129
|
+
*/
|
|
130
|
+
function reportStaleOverrides(projectCwd, logFile, msLeft, check) {
|
|
131
|
+
if (msLeft < OVERRIDE_CHECK_RESERVE_MS)
|
|
132
|
+
return;
|
|
133
|
+
try {
|
|
134
|
+
if (isRunnerOff(projectCwd))
|
|
135
|
+
return;
|
|
136
|
+
const stale = check(projectCwd);
|
|
137
|
+
if (stale.length === 0)
|
|
138
|
+
return;
|
|
139
|
+
logInto(logFile, () => {
|
|
140
|
+
logLine("overrides.stale", {
|
|
141
|
+
servers: stale.map((s) => `${s.name} (${s.state})`).join(", "),
|
|
142
|
+
why: stale
|
|
143
|
+
.map((s) => s.why)
|
|
144
|
+
.filter(Boolean)
|
|
145
|
+
.join("; ") || undefined,
|
|
146
|
+
fix: `run \`bir setup\` in ${projectCwd} — it copies them again (or removes them); \`bir doctor\` names each`,
|
|
147
|
+
effect: "until then this project's sessions run the old copies",
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
/* a check that failed leaves things as they were; `bir doctor` names what is stale */
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/** True when this process's own environment switches the runner off (`BIR_OFF=1` or `true`). */
|
|
156
|
+
function offInThisEnvironment() {
|
|
157
|
+
const value = (process.env.BIR_OFF ?? "").trim().toLowerCase();
|
|
158
|
+
return value === "1" || value === "true";
|
|
159
|
+
}
|
|
27
160
|
/** Read stdin to its end, or give up after `timeoutMs` with what arrived. */
|
|
28
161
|
function readStdin(stream, timeoutMs) {
|
|
29
162
|
// A person running `bir-hooks ensure` by hand has nothing to paste; do not
|
|
@@ -53,26 +186,34 @@ function readStdin(stream, timeoutMs) {
|
|
|
53
186
|
* Run the hook for `cwd`. Resolves the exit code, which is always 0.
|
|
54
187
|
*/
|
|
55
188
|
export async function runEnsureHook(cwd, opts = {}) {
|
|
189
|
+
const startedAt = Date.now();
|
|
190
|
+
const budgetMs = opts.budgetMs ?? ENSURE_HOOK_BUDGET_MS;
|
|
191
|
+
const left = () => budgetMs - (Date.now() - startedAt);
|
|
56
192
|
const stdout = opts.stdout ?? process.stdout;
|
|
57
193
|
const stderr = opts.stderr ?? process.stderr;
|
|
58
|
-
const raw = await readStdin(opts.stdin ?? process.stdin, opts.stdinTimeoutMs ??
|
|
194
|
+
const raw = await readStdin(opts.stdin ?? process.stdin, Math.min(opts.stdinTimeoutMs ?? STDIN_TIMEOUT_MS, Math.max(100, budgetMs / 4)));
|
|
59
195
|
let payload = {};
|
|
60
196
|
if (raw.trim()) {
|
|
61
197
|
try {
|
|
62
|
-
|
|
198
|
+
const parsed = JSON.parse(raw);
|
|
199
|
+
payload = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
63
200
|
}
|
|
64
201
|
catch {
|
|
65
202
|
// Not our problem to diagnose; the control server copes with an empty one.
|
|
66
203
|
payload = {};
|
|
67
204
|
}
|
|
68
205
|
}
|
|
206
|
+
/** Set once the recorder is started or kept: the project it serves, and its log. */
|
|
207
|
+
let kept;
|
|
69
208
|
try {
|
|
70
209
|
const { status, started, restarted } = await ensureDaemon(cwd, {
|
|
71
210
|
hooksScript: opts.hooksScript,
|
|
72
211
|
env: opts.env,
|
|
73
|
-
timeoutMs: opts.timeoutMs,
|
|
212
|
+
timeoutMs: Math.min(opts.timeoutMs ?? Number.POSITIVE_INFINITY, Math.max(1_000, left() - RELAY_RESERVE_MS)),
|
|
74
213
|
restartOnSkew: opts.restartOnSkew ?? true,
|
|
75
214
|
});
|
|
215
|
+
const project = status.info.cwd || cwd;
|
|
216
|
+
kept = { project, logFile: status.info.logFile ?? daemonLogPath(project) };
|
|
76
217
|
if (started) {
|
|
77
218
|
stderr.write(`[bir] recorder ${restarted ? "restarted" : "started"} in the background (pid ${status.info.pid}, log ${status.info.logFile ?? "?"})\n`);
|
|
78
219
|
}
|
|
@@ -82,8 +223,9 @@ export async function runEnsureHook(cwd, opts = {}) {
|
|
|
82
223
|
"content-type": "application/json",
|
|
83
224
|
authorization: `Bearer ${status.info.token}`,
|
|
84
225
|
},
|
|
85
|
-
|
|
86
|
-
|
|
226
|
+
// BIR_OFF=1 where this window was started is this session's switch (K2).
|
|
227
|
+
body: JSON.stringify(offInThisEnvironment() ? { ...payload, bir_off: true } : payload),
|
|
228
|
+
signal: timeoutSignal(Math.max(300, Math.min(RELAY_TIMEOUT_MS, left()))),
|
|
87
229
|
});
|
|
88
230
|
const answer = (await res.json().catch(() => ({})));
|
|
89
231
|
if (res.ok && answer && typeof answer === "object" && Object.keys(answer).length > 0) {
|
|
@@ -91,7 +233,17 @@ export async function runEnsureHook(cwd, opts = {}) {
|
|
|
91
233
|
}
|
|
92
234
|
}
|
|
93
235
|
catch (err) {
|
|
94
|
-
stderr
|
|
236
|
+
// stderr of a command hook that exits 0 is not shown in the session; it is
|
|
237
|
+
// for someone running this by hand, or Claude Code's debug output.
|
|
238
|
+
const tail = err instanceof RecorderBusyError
|
|
239
|
+
? " — the hooks reach it once it catches up"
|
|
240
|
+
: " — this session records standalone (Tier 2)";
|
|
241
|
+
stderr.write(`[bir] ensure: ${errText(err)}${tail}\n`);
|
|
242
|
+
}
|
|
243
|
+
// After the relay, so it never takes the relay's time; whether or not the
|
|
244
|
+
// relay answered, the recorder is there.
|
|
245
|
+
if (kept && opts.overrideCheck !== false) {
|
|
246
|
+
reportStaleOverrides(kept.project, kept.logFile, left(), opts.overrideCheck ?? staleOverrides);
|
|
95
247
|
}
|
|
96
248
|
return 0;
|
|
97
249
|
}
|