@basein/runner 0.2.11 → 0.3.0

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 (144) hide show
  1. package/README.md +540 -350
  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 -10
  9. package/dist/bin/bir-scenario.js +704 -135
  10. package/dist/bin/bir.d.ts +14 -8
  11. package/dist/bin/bir.js +2937 -488
  12. package/dist/bin/investigate.js +41 -16
  13. package/dist/bin/scenario-edit.d.ts +43 -10
  14. package/dist/bin/scenario-edit.js +281 -80
  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 +2803 -306
  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/housekeeping.d.ts +71 -0
  72. package/dist/record/housekeeping.js +417 -0
  73. package/dist/record/recorder.d.ts +51 -3
  74. package/dist/record/recorder.js +3 -0
  75. package/dist/record/redact.d.ts +103 -9
  76. package/dist/record/redact.js +659 -30
  77. package/dist/record/remote-recorder.d.ts +327 -32
  78. package/dist/record/remote-recorder.js +1517 -187
  79. package/dist/record/switchable-recorder.d.ts +126 -0
  80. package/dist/record/switchable-recorder.js +351 -0
  81. package/dist/record/truncate.d.ts +40 -1
  82. package/dist/record/truncate.js +111 -4
  83. package/dist/replay/bash-channel.d.ts +35 -0
  84. package/dist/replay/bash-channel.js +133 -0
  85. package/dist/replay/bundle.d.ts +10 -0
  86. package/dist/replay/bundle.js +10 -0
  87. package/dist/replay/controller.d.ts +371 -20
  88. package/dist/replay/controller.js +1185 -111
  89. package/dist/replay/derive.d.ts +1 -0
  90. package/dist/replay/derive.js +81 -24
  91. package/dist/replay/executor.d.ts +51 -16
  92. package/dist/replay/executor.js +114 -41
  93. package/dist/replay/handover.d.ts +54 -1
  94. package/dist/replay/handover.js +55 -5
  95. package/dist/replay/logic.d.ts +62 -10
  96. package/dist/replay/logic.js +140 -22
  97. package/dist/replay/plan.d.ts +201 -27
  98. package/dist/replay/plan.js +581 -141
  99. package/dist/replay/safety.d.ts +72 -0
  100. package/dist/replay/safety.js +303 -0
  101. package/dist/replay/source-run.d.ts +10 -0
  102. package/dist/replay/source-run.js +80 -35
  103. package/dist/replay/targets.d.ts +15 -1
  104. package/dist/replay/targets.js +62 -4
  105. package/dist/replay/tool-error.d.ts +15 -1
  106. package/dist/replay/tool-error.js +33 -1
  107. package/dist/replay/types.d.ts +7 -4
  108. package/dist/upstream/client.d.ts +56 -3
  109. package/dist/upstream/client.js +61 -4
  110. package/dist/upstream/headers.d.ts +43 -0
  111. package/dist/upstream/headers.js +90 -0
  112. package/dist/upstream/http-client.d.ts +213 -22
  113. package/dist/upstream/http-client.js +1034 -168
  114. package/dist/upstream/http-transport.d.ts +143 -0
  115. package/dist/upstream/http-transport.js +391 -0
  116. package/dist/upstream/lazy-client.d.ts +8 -0
  117. package/dist/upstream/lazy-client.js +29 -1
  118. package/dist/upstream/sse.d.ts +78 -0
  119. package/dist/upstream/sse.js +189 -0
  120. package/dist/upstream/stdio-client.d.ts +61 -1
  121. package/dist/upstream/stdio-client.js +356 -33
  122. package/dist/util/atomic-write.d.ts +29 -0
  123. package/dist/util/atomic-write.js +96 -0
  124. package/dist/util/journal.d.ts +15 -0
  125. package/dist/util/journal.js +48 -3
  126. package/dist/util/log.d.ts +10 -0
  127. package/dist/util/log.js +28 -1
  128. package/dist/util/off-switch.d.ts +46 -0
  129. package/dist/util/off-switch.js +91 -0
  130. package/dist/util/service-health.d.ts +133 -0
  131. package/dist/util/service-health.js +217 -0
  132. package/docs/BaseInstRunner.md +694 -621
  133. package/docs/calculatedReplay.md +1625 -1244
  134. package/docs/calculatedReplayGuide.md +1212 -879
  135. package/docs/installRun.md +1013 -772
  136. package/docs/loginWeb.md +627 -607
  137. package/docs/mcpmark.md +49 -16
  138. package/docs/my-first-sample.md +569 -545
  139. package/docs/quickstart.md +384 -271
  140. package/docs/t-bench.md +406 -394
  141. package/package.json +58 -57
  142. package/production.md +1371 -0
  143. package/scripts/install.ps1 +97 -6
  144. package/scripts/install.sh +104 -7
@@ -0,0 +1,194 @@
1
+ /**
2
+ * scenario-liveness — the two things `bir-scenario` does besides answering MCP:
3
+ * it tells its project's recorder that a delivery tool is live
4
+ * (`POST /scenario/hello`), and it brings back a recorder that died
5
+ * mid-session (`bir-hooks revive`).
6
+ *
7
+ * WHY HELLO. A direct plan tells the model to call `mcp__bir__run_scenario`
8
+ * before any other tool. The control server used to announce one whenever the
9
+ * MCP config listed the `bir` server — but a configured server is not a
10
+ * connected one: a stale node path, a crash, the server disabled in `/mcp`, or
11
+ * a session run with `BIR_OFF=1` (which lists no tools) all leave the model
12
+ * told to call a tool it does not have. So this server says, on `initialize`,
13
+ * on `tools/list`, when the off switch flips, whenever its recorder changes,
14
+ * and every {@link HELLO_EVERY_MS} while it runs: this pid, this version, this
15
+ * many tools, off or not. The control server announces a direct plan only on
16
+ * the word of a live pid that lists a tool.
17
+ *
18
+ * WHY REVIVE. Every hook but SessionStart is an HTTP post to the recorder.
19
+ * With the recorder gone — killed by an antivirus, out of memory — Claude Code
20
+ * shows a hook error on every prompt and tool call until the next session
21
+ * start, `/clear` or compaction, because nothing else starts one mid-session.
22
+ * This server is the one long-lived process in every session of an installed
23
+ * project, wrapped servers or none, so it starts `bir-hooks revive --cwd <dir>`
24
+ * (which runs the ordinary ensure, under the ordinary start lock).
25
+ *
26
+ * NEITHER MAY COST THE SESSION ANYTHING.
27
+ * - A hello is fire-and-forget: nothing on the MCP path awaits it, it is
28
+ * bounded by {@link HELLO_TIMEOUT_MS}, one is in flight at a time, and a
29
+ * recorder that cannot take it (not up yet, busy, an older version with no
30
+ * such route) costs a retry later, never an error.
31
+ * - A revive is a detached child that is never waited for, and it is started
32
+ * only when the discovery file names a pid that is DEAD and the recorder's
33
+ * address REFUSES the connection. A recorder that is alive but slow is never
34
+ * replaced (that is how duplicates that cannot bind were made). A missing
35
+ * discovery file is never revived either: `bir down` removes it, and a
36
+ * recorder somebody stopped on purpose stays stopped. Only an installed
37
+ * project is revived, at most once per {@link REVIVE_EVERY_MS} per process
38
+ * (after a random wait of up to {@link REVIVE_JITTER_MS}, so the several
39
+ * processes of a session that see the same death do not all start one),
40
+ * less often each time a revive did not bring a recorder back, and not in
41
+ * the server's first {@link REVIVE_GRACE_MS}, while the SessionStart hook
42
+ * is starting the recorder itself.
43
+ * - Neither writes a byte to stdout (the host's JSON-RPC stream), and
44
+ * neither throws.
45
+ */
46
+ /** A hello waits no longer than this for the recorder's answer. */
47
+ export declare const HELLO_TIMEOUT_MS = 1000;
48
+ /** A hello is repeated this often while the server runs (the recorder trusts one for 30 min). */
49
+ export declare const HELLO_EVERY_MS: number;
50
+ /**
51
+ * How often the discovery file is looked at: whether the recorder changed (a
52
+ * hello is owed to the new one) or died (a revive may be). One `stat`, and a
53
+ * parse only when the file changed.
54
+ */
55
+ export declare const WATCH_EVERY_MS = 2000;
56
+ /** At most one revive per this long per process; doubled each time one did not help. */
57
+ export declare const REVIVE_EVERY_MS = 30000;
58
+ /** The longest a revive waits, at random, before it looks again and starts. */
59
+ export declare const REVIVE_JITTER_MS = 5000;
60
+ /** The longest wait between revives that did not bring a recorder back. */
61
+ export declare const REVIVE_MAX_EVERY_MS: number;
62
+ /**
63
+ * No revive in a server's first this-long: it starts with the session, and so
64
+ * does the SessionStart hook, whose own ensure (up to 11 s, ensure-hook.ts) is
65
+ * what starts the recorder then — typically over a dead record left from
66
+ * before a reboot. Reviving beside it would only race it.
67
+ */
68
+ export declare const REVIVE_GRACE_MS = 12000;
69
+ /** How long the "does anything listen there" probe waits before calling the recorder busy. */
70
+ export declare const REFUSED_PROBE_MS = 2000;
71
+ /** What a hello says. */
72
+ export interface HelloBody {
73
+ pid: number;
74
+ version: string;
75
+ /** How many tools this server lists now (0 when off). */
76
+ tools: number;
77
+ off: boolean;
78
+ }
79
+ /** A discovery record as written, its pid not yet checked. */
80
+ export interface RecorderRecord {
81
+ url: string;
82
+ token: string;
83
+ pid: number;
84
+ }
85
+ /** How a request to the recorder's address ended. */
86
+ export type Reach = "answered" | "refused" | "timeout" | "error";
87
+ /**
88
+ * This project's discovery record, whether or not its pid is alive — the
89
+ * shared reader drops a dead pid's record, and a dead pid is exactly what a
90
+ * revive needs to see. Parsed again only when the file changed. Never throws.
91
+ */
92
+ export declare function readRecorderRecord(cwd: string): RecorderRecord | undefined;
93
+ /** Whether `cwd` is a project `bir setup`/`bir install` put the runner in. Never throws. */
94
+ export declare function projectInstalled(cwd: string): boolean;
95
+ /**
96
+ * Start `bir-hooks revive --cwd <cwd>` from this package, detached, and forget
97
+ * it: it reads nothing, writes nothing we could see, and exits by itself.
98
+ */
99
+ export declare function launchRevive(cwd: string): Promise<void>;
100
+ export interface LivenessOptions {
101
+ /** The project this server runs for. */
102
+ cwd: string;
103
+ /** What this server lists now. Called for every hello. */
104
+ state: () => {
105
+ tools: number;
106
+ off: boolean;
107
+ };
108
+ now?: () => number;
109
+ random?: () => number;
110
+ fetch?: typeof fetch;
111
+ record?: (cwd: string) => RecorderRecord | undefined;
112
+ alive?: (pid: number) => boolean;
113
+ installed?: (cwd: string) => boolean;
114
+ launch?: (cwd: string) => Promise<void> | void;
115
+ sleep?: (ms: number) => Promise<void>;
116
+ env?: NodeJS.ProcessEnv;
117
+ /** Default {@link REVIVE_GRACE_MS}. */
118
+ reviveGraceMs?: number;
119
+ }
120
+ /** See the module comment. One per `bir-scenario` process. */
121
+ export declare class ScenarioLiveness {
122
+ private readonly opts;
123
+ private readonly cwd;
124
+ private readonly now;
125
+ private readonly random;
126
+ private readonly fetchImpl;
127
+ private readonly record;
128
+ private readonly alive;
129
+ private readonly installed;
130
+ private readonly launch;
131
+ private readonly sleep;
132
+ private readonly env;
133
+ private timer;
134
+ private stopped;
135
+ /** No revive before this (see {@link REVIVE_GRACE_MS}). */
136
+ private readonly reviveFrom;
137
+ private helloBusy;
138
+ /** A hello asked for while one was in flight: sent when it ends, with the state as it is then. */
139
+ private helloOwed;
140
+ /** The recorder the last hello was answered by (`url#pid`). */
141
+ private reached;
142
+ /** The recorder the last hello was sent to, answered or not. */
143
+ private tried;
144
+ private lastHelloAt;
145
+ private helloFailures;
146
+ private helloNotBefore;
147
+ private reviveBusy;
148
+ private reviveNotBefore;
149
+ /** Revives started since a recorder was last seen alive. */
150
+ private revivesInARow;
151
+ /** When this process last started one (0: never). */
152
+ private lastReviveAt;
153
+ /** Revives started by this process. */
154
+ revives: number;
155
+ /** Hellos a recorder answered. */
156
+ hellos: number;
157
+ constructor(opts: LivenessOptions);
158
+ /** Watch the discovery file every {@link WATCH_EVERY_MS}, on a timer that never keeps the process alive. */
159
+ start(everyMs?: number): void;
160
+ /** The host is gone: nothing more is sent or started. */
161
+ stop(): void;
162
+ /**
163
+ * Tell the recorder what this server lists, now — or, while a hello is in
164
+ * flight, as soon as it ends. Returns at once; never throws.
165
+ */
166
+ hello(why: string): void;
167
+ /**
168
+ * Something on the MCP path found no recorder (`run_scenario` found no live
169
+ * discovery file, or its request was refused): look now rather than at the
170
+ * next tick. Returns at once; never throws.
171
+ */
172
+ recorderGone(why: string): void;
173
+ /** One look at the discovery file. Exposed for tests; never throws. */
174
+ tick(): Promise<void>;
175
+ /**
176
+ * Where a hello goes, the way `run_scenario`'s request does: `BIR_CONTROL_URL`
177
+ * (with `BIR_CONTROL_TOKEN`, or the discovery file's token), else the
178
+ * discovery file — whose pid must be alive.
179
+ */
180
+ private target;
181
+ private state;
182
+ private sendHello;
183
+ /** Whether anything listens at `url`: only a refused connection counts as nothing. */
184
+ private probe;
185
+ /**
186
+ * A recorder is up: the revives before it helped (or were not needed), so the
187
+ * next death waits the plain {@link REVIVE_EVERY_MS} since the last one, not
188
+ * a doubled wait.
189
+ */
190
+ private recorderSeenAlive;
191
+ /** Start a revive if — and only if — the recorder is dead, refused and this project's. */
192
+ private checkRevive;
193
+ }
194
+ //# sourceMappingURL=scenario-liveness.d.ts.map
@@ -0,0 +1,459 @@
1
+ /**
2
+ * scenario-liveness — the two things `bir-scenario` does besides answering MCP:
3
+ * it tells its project's recorder that a delivery tool is live
4
+ * (`POST /scenario/hello`), and it brings back a recorder that died
5
+ * mid-session (`bir-hooks revive`).
6
+ *
7
+ * WHY HELLO. A direct plan tells the model to call `mcp__bir__run_scenario`
8
+ * before any other tool. The control server used to announce one whenever the
9
+ * MCP config listed the `bir` server — but a configured server is not a
10
+ * connected one: a stale node path, a crash, the server disabled in `/mcp`, or
11
+ * a session run with `BIR_OFF=1` (which lists no tools) all leave the model
12
+ * told to call a tool it does not have. So this server says, on `initialize`,
13
+ * on `tools/list`, when the off switch flips, whenever its recorder changes,
14
+ * and every {@link HELLO_EVERY_MS} while it runs: this pid, this version, this
15
+ * many tools, off or not. The control server announces a direct plan only on
16
+ * the word of a live pid that lists a tool.
17
+ *
18
+ * WHY REVIVE. Every hook but SessionStart is an HTTP post to the recorder.
19
+ * With the recorder gone — killed by an antivirus, out of memory — Claude Code
20
+ * shows a hook error on every prompt and tool call until the next session
21
+ * start, `/clear` or compaction, because nothing else starts one mid-session.
22
+ * This server is the one long-lived process in every session of an installed
23
+ * project, wrapped servers or none, so it starts `bir-hooks revive --cwd <dir>`
24
+ * (which runs the ordinary ensure, under the ordinary start lock).
25
+ *
26
+ * NEITHER MAY COST THE SESSION ANYTHING.
27
+ * - A hello is fire-and-forget: nothing on the MCP path awaits it, it is
28
+ * bounded by {@link HELLO_TIMEOUT_MS}, one is in flight at a time, and a
29
+ * recorder that cannot take it (not up yet, busy, an older version with no
30
+ * such route) costs a retry later, never an error.
31
+ * - A revive is a detached child that is never waited for, and it is started
32
+ * only when the discovery file names a pid that is DEAD and the recorder's
33
+ * address REFUSES the connection. A recorder that is alive but slow is never
34
+ * replaced (that is how duplicates that cannot bind were made). A missing
35
+ * discovery file is never revived either: `bir down` removes it, and a
36
+ * recorder somebody stopped on purpose stays stopped. Only an installed
37
+ * project is revived, at most once per {@link REVIVE_EVERY_MS} per process
38
+ * (after a random wait of up to {@link REVIVE_JITTER_MS}, so the several
39
+ * processes of a session that see the same death do not all start one),
40
+ * less often each time a revive did not bring a recorder back, and not in
41
+ * the server's first {@link REVIVE_GRACE_MS}, while the SessionStart hook
42
+ * is starting the recorder itself.
43
+ * - Neither writes a byte to stdout (the host's JSON-RPC stream), and
44
+ * neither throws.
45
+ */
46
+ import { spawn } from "node:child_process";
47
+ import { readFileSync, statSync } from "node:fs";
48
+ import { pidAlive } from "../control/discovery.js";
49
+ import { configDir, discoveryPath } from "../control/paths.js";
50
+ import { nodeFlagsToCarry, projectRecord, readSidecar } from "../config/generate.js";
51
+ import { errText, logDetail, logLine } from "../util/log.js";
52
+ import { packageVersion } from "../util/version.js";
53
+ /** A hello waits no longer than this for the recorder's answer. */
54
+ export const HELLO_TIMEOUT_MS = 1_000;
55
+ /** A hello is repeated this often while the server runs (the recorder trusts one for 30 min). */
56
+ export const HELLO_EVERY_MS = 5 * 60_000;
57
+ /**
58
+ * How often the discovery file is looked at: whether the recorder changed (a
59
+ * hello is owed to the new one) or died (a revive may be). One `stat`, and a
60
+ * parse only when the file changed.
61
+ */
62
+ export const WATCH_EVERY_MS = 2_000;
63
+ /** At most one revive per this long per process; doubled each time one did not help. */
64
+ export const REVIVE_EVERY_MS = 30_000;
65
+ /** The longest a revive waits, at random, before it looks again and starts. */
66
+ export const REVIVE_JITTER_MS = 5_000;
67
+ /** The longest wait between revives that did not bring a recorder back. */
68
+ export const REVIVE_MAX_EVERY_MS = 5 * 60_000;
69
+ /**
70
+ * No revive in a server's first this-long: it starts with the session, and so
71
+ * does the SessionStart hook, whose own ensure (up to 11 s, ensure-hook.ts) is
72
+ * what starts the recorder then — typically over a dead record left from
73
+ * before a reboot. Reviving beside it would only race it.
74
+ */
75
+ export const REVIVE_GRACE_MS = 12_000;
76
+ /** How long the "does anything listen there" probe waits before calling the recorder busy. */
77
+ export const REFUSED_PROBE_MS = 2_000;
78
+ /** The longest wait between hellos a recorder could not take. */
79
+ const HELLO_RETRY_MAX_MS = 60_000;
80
+ let recordCache;
81
+ /**
82
+ * This project's discovery record, whether or not its pid is alive — the
83
+ * shared reader drops a dead pid's record, and a dead pid is exactly what a
84
+ * revive needs to see. Parsed again only when the file changed. Never throws.
85
+ */
86
+ export function readRecorderRecord(cwd) {
87
+ try {
88
+ const path = discoveryPath(cwd);
89
+ let stamp;
90
+ try {
91
+ const st = statSync(path);
92
+ // The inode too: the file is replaced by a rename, and a coarse clock can
93
+ // give the replacement the same time and, by chance, the same size.
94
+ stamp = `${st.ino}:${st.mtimeMs}:${st.size}`;
95
+ }
96
+ catch {
97
+ recordCache = { path, stamp: "-", record: undefined };
98
+ return undefined;
99
+ }
100
+ if (recordCache && recordCache.path === path && recordCache.stamp === stamp)
101
+ return recordCache.record;
102
+ let record;
103
+ try {
104
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
105
+ record =
106
+ typeof parsed.url === "string" && typeof parsed.token === "string" && typeof parsed.pid === "number"
107
+ ? { url: parsed.url, token: parsed.token, pid: parsed.pid }
108
+ : undefined;
109
+ }
110
+ catch {
111
+ record = undefined; // being replaced this instant, or damaged: nothing to go on
112
+ }
113
+ recordCache = { path, stamp, record };
114
+ return record;
115
+ }
116
+ catch {
117
+ return undefined;
118
+ }
119
+ }
120
+ /** Whether `cwd` is a project `bir setup`/`bir install` put the runner in. Never throws. */
121
+ export function projectInstalled(cwd) {
122
+ try {
123
+ return projectRecord(readSidecar(), cwd) !== undefined;
124
+ }
125
+ catch {
126
+ return false;
127
+ }
128
+ }
129
+ /**
130
+ * Start `bir-hooks revive --cwd <cwd>` from this package, detached, and forget
131
+ * it: it reads nothing, writes nothing we could see, and exits by itself.
132
+ */
133
+ export async function launchRevive(cwd) {
134
+ // Loaded here, not at the top: a revive is rare, and the module graph behind
135
+ // the daemon is not needed to answer MCP.
136
+ const { hooksScriptPath } = await import("../control/daemon.js");
137
+ const child = spawn(process.execPath, [...nodeFlagsToCarry(), hooksScriptPath(), "revive", "--cwd", cwd], {
138
+ // Not the project: on Windows a process holds its current directory, and
139
+ // the project folder must stay free to rename or delete (daemon.ts).
140
+ cwd: configDir(),
141
+ detached: true,
142
+ stdio: "ignore",
143
+ // A detached child on Windows would get a console window of its own.
144
+ windowsHide: true,
145
+ env: process.env,
146
+ });
147
+ child.on("error", (err) => logLine("scenario.revive_failed", { error: errText(err) }));
148
+ child.unref();
149
+ }
150
+ /** How a fetch to the recorder failed: busy (timed out), refused (nothing listens), or otherwise. */
151
+ function reachOf(err) {
152
+ const e = err;
153
+ if (e?.name === "TimeoutError" || e?.name === "AbortError")
154
+ return "timeout";
155
+ if (e?.cause?.name === "TimeoutError")
156
+ return "timeout";
157
+ if (e?.cause?.code === "ECONNREFUSED")
158
+ return "refused";
159
+ // A host name with two addresses fails as one AggregateError of both.
160
+ const all = e?.cause?.errors;
161
+ if (Array.isArray(all) && all.length > 0 && all.every((x) => x?.code === "ECONNREFUSED"))
162
+ return "refused";
163
+ return "error";
164
+ }
165
+ function trimUrl(url) {
166
+ return url.replace(/\/+$/, "");
167
+ }
168
+ const unrefSleep = (ms) => new Promise((resolve) => {
169
+ const t = setTimeout(resolve, ms);
170
+ t.unref?.();
171
+ });
172
+ /** See the module comment. One per `bir-scenario` process. */
173
+ export class ScenarioLiveness {
174
+ opts;
175
+ cwd;
176
+ now;
177
+ random;
178
+ fetchImpl;
179
+ record;
180
+ alive;
181
+ installed;
182
+ launch;
183
+ sleep;
184
+ env;
185
+ timer;
186
+ stopped = false;
187
+ /** No revive before this (see {@link REVIVE_GRACE_MS}). */
188
+ reviveFrom;
189
+ // ── hello ──
190
+ helloBusy = false;
191
+ /** A hello asked for while one was in flight: sent when it ends, with the state as it is then. */
192
+ helloOwed;
193
+ /** The recorder the last hello was answered by (`url#pid`). */
194
+ reached;
195
+ /** The recorder the last hello was sent to, answered or not. */
196
+ tried;
197
+ lastHelloAt = 0;
198
+ helloFailures = 0;
199
+ helloNotBefore = 0;
200
+ // ── revive ──
201
+ reviveBusy = false;
202
+ reviveNotBefore = 0;
203
+ /** Revives started since a recorder was last seen alive. */
204
+ revivesInARow = 0;
205
+ /** When this process last started one (0: never). */
206
+ lastReviveAt = 0;
207
+ /** Revives started by this process. */
208
+ revives = 0;
209
+ /** Hellos a recorder answered. */
210
+ hellos = 0;
211
+ constructor(opts) {
212
+ this.opts = opts;
213
+ this.cwd = opts.cwd;
214
+ this.now = opts.now ?? Date.now;
215
+ this.random = opts.random ?? Math.random;
216
+ this.fetchImpl = opts.fetch ?? fetch;
217
+ this.record = opts.record ?? readRecorderRecord;
218
+ this.alive = opts.alive ?? pidAlive;
219
+ this.installed = opts.installed ?? projectInstalled;
220
+ this.launch = opts.launch ?? launchRevive;
221
+ this.sleep = opts.sleep ?? unrefSleep;
222
+ this.env = opts.env ?? process.env;
223
+ this.reviveFrom = this.now() + Math.max(0, opts.reviveGraceMs ?? REVIVE_GRACE_MS);
224
+ }
225
+ /** Watch the discovery file every {@link WATCH_EVERY_MS}, on a timer that never keeps the process alive. */
226
+ start(everyMs = WATCH_EVERY_MS) {
227
+ if (this.timer || this.stopped)
228
+ return;
229
+ this.timer = setInterval(() => void this.tick(), everyMs);
230
+ this.timer.unref();
231
+ }
232
+ /** The host is gone: nothing more is sent or started. */
233
+ stop() {
234
+ this.stopped = true;
235
+ if (this.timer)
236
+ clearInterval(this.timer);
237
+ this.timer = undefined;
238
+ }
239
+ /**
240
+ * Tell the recorder what this server lists, now — or, while a hello is in
241
+ * flight, as soon as it ends. Returns at once; never throws.
242
+ */
243
+ hello(why) {
244
+ if (this.stopped)
245
+ return;
246
+ if (this.helloBusy) {
247
+ this.helloOwed = why;
248
+ return;
249
+ }
250
+ this.helloBusy = true;
251
+ // On the next turn of the loop: the answer this follows goes out first,
252
+ // and not even reading the discovery file happens before it.
253
+ setImmediate(() => void this.sendHello(why));
254
+ }
255
+ /**
256
+ * Something on the MCP path found no recorder (`run_scenario` found no live
257
+ * discovery file, or its request was refused): look now rather than at the
258
+ * next tick. Returns at once; never throws.
259
+ */
260
+ recorderGone(why) {
261
+ if (this.stopped)
262
+ return;
263
+ setImmediate(() => void this.checkRevive(why));
264
+ }
265
+ /** One look at the discovery file. Exposed for tests; never throws. */
266
+ async tick() {
267
+ if (this.stopped)
268
+ return;
269
+ try {
270
+ const rec = this.record(this.cwd);
271
+ if (rec && !this.alive(rec.pid)) {
272
+ await this.checkRevive("the recorder's pid is gone");
273
+ return;
274
+ }
275
+ if (rec)
276
+ this.recorderSeenAlive();
277
+ const target = this.target();
278
+ if (!target)
279
+ return;
280
+ const now = this.now();
281
+ if (target.identity !== this.tried) {
282
+ this.hello("recorder");
283
+ return;
284
+ }
285
+ if (now < this.helloNotBefore)
286
+ return;
287
+ if (target.identity !== this.reached || now - this.lastHelloAt >= HELLO_EVERY_MS) {
288
+ this.hello(target.identity !== this.reached ? "retry" : "periodic");
289
+ }
290
+ }
291
+ catch (err) {
292
+ logDetail("scenario.liveness_failed", { error: errText(err) });
293
+ }
294
+ }
295
+ /**
296
+ * Where a hello goes, the way `run_scenario`'s request does: `BIR_CONTROL_URL`
297
+ * (with `BIR_CONTROL_TOKEN`, or the discovery file's token), else the
298
+ * discovery file — whose pid must be alive.
299
+ */
300
+ target() {
301
+ const rec = this.record(this.cwd);
302
+ const live = rec && this.alive(rec.pid) ? rec : undefined;
303
+ const envUrl = this.env.BIR_CONTROL_URL?.trim();
304
+ if (envUrl) {
305
+ const token = this.env.BIR_CONTROL_TOKEN || live?.token;
306
+ if (!token)
307
+ return undefined;
308
+ return { url: trimUrl(envUrl), token, identity: `${trimUrl(envUrl)}#${live?.pid ?? 0}` };
309
+ }
310
+ if (!live)
311
+ return undefined;
312
+ return { url: trimUrl(live.url), token: live.token, identity: `${trimUrl(live.url)}#${live.pid}` };
313
+ }
314
+ state() {
315
+ try {
316
+ const s = this.opts.state();
317
+ return { tools: Number.isInteger(s.tools) && s.tools >= 0 ? s.tools : 0, off: s.off === true };
318
+ }
319
+ catch {
320
+ return { tools: 0, off: false };
321
+ }
322
+ }
323
+ async sendHello(why) {
324
+ this.helloBusy = true;
325
+ try {
326
+ if (this.stopped)
327
+ return;
328
+ // No recorder yet (the host often starts this server before SessionStart
329
+ // starts the recorder): the watch says hello when one appears.
330
+ const target = this.target();
331
+ if (!target)
332
+ return;
333
+ const { tools, off } = this.state();
334
+ const body = { pid: process.pid, version: packageVersion(), tools, off };
335
+ this.tried = target.identity;
336
+ this.lastHelloAt = this.now();
337
+ let reach;
338
+ let status;
339
+ try {
340
+ const res = await this.fetchImpl(`${target.url}/scenario/hello`, {
341
+ method: "POST",
342
+ headers: { "content-type": "application/json", authorization: `Bearer ${target.token}` },
343
+ body: JSON.stringify(body),
344
+ signal: AbortSignal.timeout(HELLO_TIMEOUT_MS),
345
+ });
346
+ status = res.status;
347
+ // The answer is `{ok:true}` and nothing here needs it; read it so the
348
+ // connection can be reused, within the same bound.
349
+ await res.arrayBuffer().catch(() => undefined);
350
+ reach = "answered";
351
+ }
352
+ catch (err) {
353
+ reach = reachOf(err);
354
+ }
355
+ if (reach === "answered") {
356
+ this.reached = target.identity;
357
+ this.hellos += 1;
358
+ this.helloFailures = 0;
359
+ this.helloNotBefore = 0;
360
+ // A recorder from before this route answers 404: it does not ask for
361
+ // hellos, so there is nothing to retry until it is replaced.
362
+ logDetail("scenario.hello", { why, tools, off, status });
363
+ return;
364
+ }
365
+ this.helloFailures += 1;
366
+ this.helloNotBefore =
367
+ this.now() + Math.min(WATCH_EVERY_MS * 2 ** Math.min(this.helloFailures - 1, 10), HELLO_RETRY_MAX_MS);
368
+ logDetail("scenario.hello_failed", { why, reach });
369
+ if (reach === "refused")
370
+ this.recorderGone("the recorder refused a hello");
371
+ }
372
+ catch (err) {
373
+ logDetail("scenario.hello_failed", { why, error: errText(err) });
374
+ }
375
+ finally {
376
+ this.helloBusy = false;
377
+ const owed = this.helloOwed;
378
+ this.helloOwed = undefined;
379
+ if (owed !== undefined && !this.stopped)
380
+ this.hello(owed);
381
+ }
382
+ }
383
+ /** Whether anything listens at `url`: only a refused connection counts as nothing. */
384
+ async probe(url) {
385
+ try {
386
+ const res = await this.fetchImpl(`${trimUrl(url)}/health`, { signal: AbortSignal.timeout(REFUSED_PROBE_MS) });
387
+ await res.arrayBuffer().catch(() => undefined);
388
+ return "answered";
389
+ }
390
+ catch (err) {
391
+ return reachOf(err);
392
+ }
393
+ }
394
+ /**
395
+ * A recorder is up: the revives before it helped (or were not needed), so the
396
+ * next death waits the plain {@link REVIVE_EVERY_MS} since the last one, not
397
+ * a doubled wait.
398
+ */
399
+ recorderSeenAlive() {
400
+ if (this.revivesInARow === 0)
401
+ return;
402
+ this.revivesInARow = 0;
403
+ this.reviveNotBefore = Math.min(this.reviveNotBefore, this.lastReviveAt + REVIVE_EVERY_MS);
404
+ }
405
+ /** Start a revive if — and only if — the recorder is dead, refused and this project's. */
406
+ async checkRevive(why) {
407
+ if (this.stopped || this.reviveBusy)
408
+ return;
409
+ const now = this.now();
410
+ if (now < this.reviveNotBefore || now < this.reviveFrom)
411
+ return;
412
+ this.reviveBusy = true;
413
+ try {
414
+ // Every process of the session sees the same death at about the same
415
+ // moment. Each waits a moment of its own and looks again: whoever went
416
+ // first has usually brought a recorder back by then.
417
+ const wait = Math.floor(Math.max(0, Math.min(1, this.random())) * REVIVE_JITTER_MS);
418
+ if (wait > 0)
419
+ await this.sleep(wait);
420
+ if (this.stopped)
421
+ return;
422
+ const rec = this.record(this.cwd);
423
+ // No record: `bir down` removed it (stopped on purpose, so it stays
424
+ // stopped), or no recorder ever ran here. A live pid: busy, or back.
425
+ if (!rec)
426
+ return;
427
+ if (this.alive(rec.pid)) {
428
+ this.recorderSeenAlive();
429
+ return;
430
+ }
431
+ const every = Math.min(REVIVE_EVERY_MS * 2 ** Math.min(this.revivesInARow, 10), REVIVE_MAX_EVERY_MS);
432
+ this.reviveNotBefore = this.now() + every;
433
+ if (!this.installed(this.cwd))
434
+ return;
435
+ const reach = await this.probe(rec.url);
436
+ // Something answers there (a recorder that has not rewritten the file
437
+ // yet), or it is slow to: not ours to replace.
438
+ if (reach !== "refused" || this.stopped)
439
+ return;
440
+ logLine("scenario.recorder_revive", {
441
+ pid: rec.pid,
442
+ url: rec.url,
443
+ why,
444
+ detail: "the project's recorder is gone mid-session; starting it again so its hooks answer",
445
+ });
446
+ this.revives += 1;
447
+ this.revivesInARow += 1;
448
+ this.lastReviveAt = this.now();
449
+ await this.launch(this.cwd);
450
+ }
451
+ catch (err) {
452
+ logLine("scenario.revive_failed", { error: errText(err) });
453
+ }
454
+ finally {
455
+ this.reviveBusy = false;
456
+ }
457
+ }
458
+ }
459
+ //# sourceMappingURL=scenario-liveness.js.map