@henols/vice-mcp 0.1.4

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.
@@ -0,0 +1,126 @@
1
+ // GENERATED FILE -- DO NOT EDIT.
2
+ // Compiled by `tsc` from broker-epoch.mts. Edit the TypeScript source and rebuild;
3
+ // changes made directly to this file are silently overwritten by the next build, and are never
4
+ // deployed to the host on their own -- install-resources.mjs copies THIS file's on-disk contents
5
+ // verbatim to tools/, so an edit made only here reaches the host but is lost on the very next
6
+ // rebuild.
7
+ // broker-epoch.mts
8
+ //
9
+ // B / D-04: the per-instance epoch.json writer, held to the frozen
10
+ // eight-field contract captured in fixtures/ (task 1, before the bash
11
+ // writer that produced them is deleted later in this phase). Ports
12
+ // write_epoch()'s exact field shape and its atomic tmp-sibling-then-rename
13
+ // discipline -- the tmp file is created empty, mode tightened to
14
+ // owner-read-write BEFORE any content reaches it, content written, then
15
+ // renamed -- matching writeBrokerRecord()'s own choke point in
16
+ // vice-broker.mts exactly.
17
+ //
18
+ // Plan 03, Task 1 completes this module: the path derivations
19
+ // (epochPathFor/instanceLogDirFor, both built on the SAME private
20
+ // per-instance-directory helper so the epoch record's `log` field and the
21
+ // file actually written under that directory can never disagree) and the
22
+ // epoch-increment derivation (nextEpochFor) the per-child supervisor
23
+ // (broker-launch.mts, Task 2) needs to bump an instance's epoch on every
24
+ // respawn.
25
+ //
26
+ // T-01.6.2-17 (tampering, epoch record contents): the `pid` field is the
27
+ // emulator CHILD's own pid -- NEVER this broker's own pid. A recycle
28
+ // request resolves its target through this exact field (the grant -> its
29
+ // recorded epoch_file -> that file's pid chain, per
30
+ // handle_recycle_request()'s bash original), so a wrong value here would
31
+ // signal the wrong process. This module never fills that field itself (the
32
+ // caller -- broker-launch.mts's spawn+record path -- supplies it from the
33
+ // actually-spawned child's own pid); this comment exists so no future
34
+ // change quietly starts passing this broker's own process.pid instead.
35
+ import { writeFileSync, chmodSync, renameSync, mkdirSync, readFileSync } from "node:fs";
36
+ import { join } from "node:path";
37
+ /** The one place that knows the epoch file's name relative to its
38
+ * per-instance directory -- writeEpochRecord() and nextEpochFor() both call
39
+ * THIS, so the writer and the reader-for-increment can never name two
40
+ * different files. */
41
+ function epochFileIn(supervisorDir) {
42
+ return join(supervisorDir, "epoch.json");
43
+ }
44
+ /** The one shared per-instance directory derivation -- stateDir + port,
45
+ * exactly matching vice-broker.mts's own `join(stateDir, String(port))`
46
+ * (Plan 01/02). epochPathFor() and instanceLogDirFor() below both build on
47
+ * THIS, so a caller deriving one from the other (as broker-launch.mts's
48
+ * per-child supervisor does for the epoch record's own `log` field) can
49
+ * never observe the two disagree. Not exported: callers that already have a
50
+ * `stateDir`/`port` pair use epochPathFor()/instanceLogDirFor() directly;
51
+ * a caller that already has a resolved `supervisorDir` (e.g.
52
+ * writeEpochRecord()'s own caller) never needs to re-derive it. */
53
+ function instanceDirFor(stateDir, port) {
54
+ return join(stateDir, String(port));
55
+ }
56
+ /** Resolves the epoch file's path for a given instance directly from the
57
+ * state directory and port -- the exact per-instance directory shape
58
+ * vice-broker.mts's launch paths already build inline
59
+ * (`join(stateDir, String(port))`), named here as one function so a second,
60
+ * independently-drifted derivation never needs to exist. */
61
+ export function epochPathFor(stateDir, port) {
62
+ return epochFileIn(instanceDirFor(stateDir, port));
63
+ }
64
+ /** Resolves the per-instance logs directory -- the `logs/` subdirectory
65
+ * under the SAME per-instance directory epochPathFor() derives from, so a
66
+ * caller building the epoch record's `log` field (a path relative to the
67
+ * instance directory, e.g. `logs/x64sc-<ts>.log`) and the caller that
68
+ * actually opens that log file are guaranteed to agree on where `logs/`
69
+ * lives. */
70
+ export function instanceLogDirFor(stateDir, port) {
71
+ return join(instanceDirFor(stateDir, port), "logs");
72
+ }
73
+ /** Writes supervisorDir/epoch.json. Per RESEARCH assumption A4,
74
+ * supervisor_pid has no consumer that reads it for behaviour -- the field
75
+ * is kept and pointed at THIS broker's own pid, so a human reading the file
76
+ * by hand still finds a supervising process to look up, even though this
77
+ * broker spawns the emulator directly and there is no separate per-instance
78
+ * supervisor process any more. */
79
+ export function writeEpochRecord({ supervisorDir, record }) {
80
+ mkdirSync(supervisorDir, { recursive: true });
81
+ const finalPath = epochFileIn(supervisorDir);
82
+ const tmpPath = `${finalPath}.tmp-${process.pid}-${Date.now()}`;
83
+ writeFileSync(tmpPath, "");
84
+ chmodSync(tmpPath, 0o600);
85
+ writeFileSync(tmpPath, JSON.stringify(record, null, 2) + "\n");
86
+ renameSync(tmpPath, finalPath);
87
+ return finalPath;
88
+ }
89
+ /** The epoch-increment derivation the per-child supervisor calls on every
90
+ * respawn (and on an instance's very first launch): reads supervisorDir's
91
+ * current epoch.json if one is present, using the SAME never-throw posture
92
+ * already established for untrusted reads elsewhere in this codebase
93
+ * (readBrokerRecordMaybe() in vice-broker.mts, readEpoch() in vice.ts) --
94
+ * absence, an unreadable file, malformed JSON, a non-object shape, or a
95
+ * non-integer `epoch` field are ALL treated as "no usable prior record"
96
+ * rather than an error. A fresh instance must be able to start over an
97
+ * unreadable file; refusing to write because the OLD record looks wrong
98
+ * would strand the instance permanently. Returns one more than the epoch
99
+ * found, or 1 when there is no usable prior record -- matching
100
+ * read_prev_epoch()'s own "start at 0 so the first write becomes 1"
101
+ * behaviour exactly. */
102
+ export function nextEpochFor(supervisorDir) {
103
+ const path = epochFileIn(supervisorDir);
104
+ let raw;
105
+ try {
106
+ raw = readFileSync(path, "utf8");
107
+ }
108
+ catch {
109
+ return 1;
110
+ }
111
+ let parsed;
112
+ try {
113
+ parsed = JSON.parse(raw);
114
+ }
115
+ catch {
116
+ return 1;
117
+ }
118
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
119
+ return 1;
120
+ }
121
+ const epoch = parsed.epoch;
122
+ if (!Number.isInteger(epoch)) {
123
+ return 1;
124
+ }
125
+ return epoch + 1;
126
+ }
@@ -0,0 +1,491 @@
1
+ // GENERATED FILE -- DO NOT EDIT.
2
+ // Compiled by `tsc` from broker-kill.mts. Edit the TypeScript source and rebuild;
3
+ // changes made directly to this file are silently overwritten by the next build, and are never
4
+ // deployed to the host on their own -- install-resources.mjs copies THIS file's on-disk contents
5
+ // verbatim to tools/, so an edit made only here reaches the host but is lost on the very next
6
+ // rebuild.
7
+ // broker-kill.mts
8
+ //
9
+ // D (complete, this plan -- 01.6.2-04): the identity-verified kill discipline,
10
+ // ported from resources/vice-broker.sh's signal_recorded_pid()/
11
+ // signal_vice_child_pid(): zero-signal liveness check, identity check against
12
+ // the process's own argument string, SIGTERM, poll-then-SIGKILL. The
13
+ // expected-identity string always comes from the instance record (the
14
+ // resolved binary path recorded at spawn time by broker-launch.mts), never a
15
+ // module constant -- this broker spawns the emulator directly and there is
16
+ // no intermediate supervising script for an identity check to match against.
17
+ // See this module's own history: the bash original's PARAMETERISED sibling
18
+ // (signal_vice_child_pid, matched against a caller-supplied binary) is the
19
+ // model this ported; its hardcoded sibling (signal_recorded_pid, matched
20
+ // against $SUPERVISOR_SCRIPT) is NOT -- there is no supervisor script in
21
+ // this topology, so carrying that constant forward would make every kill
22
+ // silently refuse while logging a plausible-looking pid-reuse warning.
23
+ //
24
+ // This task also completes the module with two further concerns, both
25
+ // depending on the kill discipline above rather than replacing it:
26
+ // - shutdown()/registerShutdownHandlers(): every catchable teardown path
27
+ // (SIGTERM, SIGINT, SIGHUP, an uncaught exception, an unhandled
28
+ // rejection, normal exit) converges on one re-entrant-safe teardown that
29
+ // identity-verified-kills every instance and clears the map
30
+ // unconditionally (kill-never-recycle). The uncatchable signals (SIGKILL,
31
+ // SIGSTOP) are deliberately unhandled -- see registerShutdownHandlers()'s
32
+ // own comment.
33
+ // - reapOrphanedInstances()/discoverBandProcesses(): the unconditional
34
+ // startup reap (criterion I, D-15) that reaches instances this broker
35
+ // process has no in-memory record of, derived from the emulator port
36
+ // band plus process identity rather than from a registry a restart just
37
+ // lost.
38
+ import { execFileSync } from "node:child_process";
39
+ import { readFileSync, readdirSync } from "node:fs";
40
+ import { join } from "node:path";
41
+ const defaultIsAlive = (pid) => {
42
+ try {
43
+ process.kill(pid, 0);
44
+ return true;
45
+ }
46
+ catch {
47
+ return false;
48
+ }
49
+ };
50
+ const defaultReadProcessArgs = (pid) => {
51
+ try {
52
+ return execFileSync("ps", ["-o", "args=", "-p", String(pid)], { encoding: "utf8" });
53
+ }
54
+ catch {
55
+ return "";
56
+ }
57
+ };
58
+ const defaultKill = (pid, signal) => {
59
+ try {
60
+ process.kill(pid, signal);
61
+ }
62
+ catch {
63
+ // already gone -- idempotent by design, matching the bash version's `|| true`
64
+ }
65
+ };
66
+ const defaultSleepMs = (ms) => new Promise((r) => setTimeout(r, ms));
67
+ function resolveKillWaitS(override) {
68
+ if (typeof override === "number")
69
+ return override;
70
+ const raw = process.env.VICE_BROKER_KILL_WAIT_S;
71
+ const n = raw === undefined ? NaN : Number(raw);
72
+ return Number.isFinite(n) ? n : 5;
73
+ }
74
+ function defaultLog(line) {
75
+ process.stderr.write(`${line}\n`);
76
+ }
77
+ /** Implements the discipline exactly as signal_recorded_pid()/
78
+ * signal_vice_child_pid() do. An empty/null/non-positive pid, or a pid
79
+ * already gone, returns "already_exited" without ever signalling -- "the
80
+ * machine being gone is the goal", per the bash version's own comment. A
81
+ * live pid whose OWN argument string does not contain expectedIdentity is
82
+ * REFUSED -- never signalled -- and returns "identity_refused", the one
83
+ * outcome a caller must be able to tell apart from every other stage
84
+ * (possible pid reuse). Only a genuine identity match proceeds: SIGTERM,
85
+ * poll every 200ms up to killWaitS (default VICE_BROKER_KILL_WAIT_S / 5),
86
+ * SIGKILL on a survivor. */
87
+ export async function verifiedKill({ pid, expectedIdentity, deps = {} }) {
88
+ const isAlive = deps.isAlive ?? defaultIsAlive;
89
+ const readProcessArgs = deps.readProcessArgs ?? defaultReadProcessArgs;
90
+ const kill = deps.kill ?? defaultKill;
91
+ const sleepMs = deps.sleepMs ?? defaultSleepMs;
92
+ const killWaitS = resolveKillWaitS(deps.killWaitS);
93
+ if (pid === null || !Number.isFinite(pid) || pid <= 0) {
94
+ return "already_exited";
95
+ }
96
+ if (!isAlive(pid)) {
97
+ return "already_exited";
98
+ }
99
+ const args = readProcessArgs(pid);
100
+ if (!args.includes(expectedIdentity)) {
101
+ process.stderr.write(`vice-broker: refusing to signal pid ${pid} -- ps reports "${args.trim()}", which does not match expected identity "${expectedIdentity}" (possible pid reuse)\n`);
102
+ return "identity_refused";
103
+ }
104
+ kill(pid, "SIGTERM");
105
+ const limitMs = killWaitS * 1000;
106
+ let waitedMs = 0;
107
+ while (isAlive(pid)) {
108
+ if (waitedMs >= limitMs) {
109
+ process.stderr.write(`vice-broker: pid ${pid} did not exit within ${killWaitS}s of SIGTERM -- sending SIGKILL\n`);
110
+ kill(pid, "SIGKILL");
111
+ return "sigkill";
112
+ }
113
+ await sleepMs(200);
114
+ waitedMs += 200;
115
+ }
116
+ return "sigterm";
117
+ }
118
+ /** The single shutdown sequence every catchable entry point converges on
119
+ * (mirrors resources/vice-broker.sh's own broker_shutdown() ->
120
+ * reap_all_instances()). For every instance currently recorded: set the
121
+ * deliberate-kill marker BEFORE any signal reaches it (T-01.6.2-21) -- done
122
+ * as its own pass over every instance FIRST, before any kill is attempted,
123
+ * so a slow kill on instance A can never race a later-arriving signal that
124
+ * finds instance B's marker still unset -- so a supervisor's exit handler
125
+ * (broker-launch.mts's superviseChild()) treats the death as a deliberate
126
+ * teardown, never a crash to respawn. Then kill it identity-verified through
127
+ * verifiedKill() (or the injected stand-in). Then remove it from the map
128
+ * UNCONDITIONALLY, whatever stage word came back -- that unconditional
129
+ * removal IS the kill-never-recycle structural guarantee: the only way an
130
+ * instance becomes grantable again is a fresh launch, never a reset of this
131
+ * entry (mirrors teardown()'s own header comment in the bash original).
132
+ * Never throws past an individual kill failure -- one instance's kill
133
+ * rejecting must not stop every other instance from being torn down. */
134
+ export async function shutdown(deps) {
135
+ const kill = deps.kill ?? verifiedKill;
136
+ const log = deps.log ?? defaultLog;
137
+ const instances = Array.from(deps.state.instances.values());
138
+ for (const instance of instances) {
139
+ instance.deliberateKill = true;
140
+ }
141
+ let killed = 0;
142
+ for (const instance of instances) {
143
+ try {
144
+ const stage = await kill({ pid: instance.pid, expectedIdentity: instance.expectedIdentity });
145
+ if (stage === "sigterm" || stage === "sigkill")
146
+ killed++;
147
+ }
148
+ catch (e) {
149
+ log(`vice-broker: shutdown -- kill of port ${instance.port} threw: ${e.message}`);
150
+ }
151
+ finally {
152
+ deps.state.instances.delete(instance.port);
153
+ }
154
+ }
155
+ log(`vice-broker: shutdown complete -- ${instances.length} instance(s) processed, ${killed} signalled`);
156
+ }
157
+ /** The six catchable entry points every real broker process registers
158
+ * shutdown() against. Not exported: registerShutdownHandlers() below is the
159
+ * only caller, and no other module needs to enumerate these by name -- a
160
+ * structural test reads this array directly via a test-only re-export
161
+ * rather than duplicating the literal list. */
162
+ const HANDLED_SIGNALS = ["SIGTERM", "SIGINT", "SIGHUP"];
163
+ /** Exported ONLY for the structural test asserting no handler is registered
164
+ * for the uncatchable kill/stop signals -- reading this array is how that
165
+ * test enumerates "the registered signal names from the module's own
166
+ * registration function" without parsing source text. */
167
+ export const _HANDLED_SIGNALS = HANDLED_SIGNALS;
168
+ /** Registers the single shutdown sequence against every CATCHABLE entry
169
+ * point: SIGTERM, SIGINT, SIGHUP, an uncaught exception, an unhandled
170
+ * rejection, and normal exit -- six listeners, one shutdown function,
171
+ * mirroring the bash original's single `trap broker_shutdown EXIT HUP INT
172
+ * TERM` (extended here with the two JS-only failure modes bash has no
173
+ * equivalent of). Disarms re-entry FIRST, before any child is signalled --
174
+ * the bash version disarms its own trap (`trap - EXIT HUP INT TERM`) as its
175
+ * first statement for exactly this reason, and an interrupt followed by a
176
+ * terminate a hundred milliseconds later is a shape this project has
177
+ * already seen (2026-08-02).
178
+ *
179
+ * Registers NOTHING for SIGKILL or SIGSTOP, and builds no mechanism to
180
+ * prevent orphans after one -- both are UNCATCHABLE at the OS level; a
181
+ * process receiving either executes no handler, no exit hook, no cleanup
182
+ * block, and in a one-process design there is no supervisor left standing
183
+ * to be told anything happened. Orphaned emulators after such a kill are
184
+ * accepted and cleaned up by hand (T-01.6.2-29) -- the NEXT broker start's
185
+ * unconditional reap (reapOrphanedInstances() below) is the actual recovery
186
+ * mechanism, not anything registered here. Two kernel-level alternatives (a
187
+ * watchdog process, a cgroup-wide kill) were considered during this phase's
188
+ * own design discussion and dropped as over-engineering, not deferred.
189
+ *
190
+ * The 'exit' listener is registered identically to the other five, but
191
+ * carries an honest limitation worth stating rather than hiding: Node's
192
+ * 'exit' event fires synchronously and cannot keep the event loop alive for
193
+ * pending async work, so on a REAL process exit only shutdown()'s
194
+ * synchronous prefix (marking every instance deliberately-killed, issuing
195
+ * the initial SIGTERM to each) is guaranteed to run before the process is
196
+ * actually gone -- the SIGTERM-wait-then-SIGKILL escalation's own polling
197
+ * cannot complete there. This is a real Node platform limitation, not a gap
198
+ * in this module; it is why the 'exit' path is exercised in this module's
199
+ * own tests via the injectable `proc` seam (a plain EventEmitter, which CAN
200
+ * await async work in its own listeners) rather than a real process exit.
201
+ *
202
+ * Returns a cleanup function that removes every listener this call
203
+ * registered -- used by this module's own tests so successive test cases
204
+ * never accumulate listeners on the same `proc` object; a real broker
205
+ * process never calls it (registered once, for the process's whole life). */
206
+ export function registerShutdownHandlers(deps) {
207
+ // Whether this call is wired to the REAL Node process global (true, the
208
+ // real-broker wiring site in vice-broker.mts) or a test's injected
209
+ // process-like stand-in (false). This is what the default `exit` below
210
+ // uses to decide whether it may actually call process.exit() -- see that
211
+ // default's own comment for why the answer differs by caller.
212
+ const usingRealProcess = deps.proc === undefined;
213
+ const proc = deps.proc ?? process;
214
+ // Default exit: always records the intended exit code on `proc.exitCode`
215
+ // first (matching this codebase's own "never process.exit(), always
216
+ // process.exitCode" convention used elsewhere, e.g. main()'s
217
+ // argument-parsing error paths, so any pending stdout/stderr write has a
218
+ // chance to flush) -- but ONLY when wired to the real Node process does it
219
+ // ALSO call the real process.exit(code) explicitly. This is deliberate,
220
+ // not an inconsistency: a long-lived broker keeps its heartbeat/poll
221
+ // setInterval timers alive for as long as the process lives, so merely
222
+ // setting `process.exitCode` after a signal would never actually end the
223
+ // process -- the event loop has nothing left that would let it drain on
224
+ // its own. A deliberate, fully-sequenced shutdown (every instance killed,
225
+ // every marker set, the log line written) is exactly the point at which an
226
+ // explicit process.exit() is the right primitive, not a shortcut around
227
+ // it. Tests that inject their own `proc` (a fake, never the real Node
228
+ // process) never take this branch, so emitting 'exit'/'uncaughtException'
229
+ // on a fake stand-in never terminates the actual test-runner process.
230
+ const exit = deps.exit ?? ((code) => {
231
+ proc.exitCode = code;
232
+ if (usingRealProcess) {
233
+ process.exit(code);
234
+ }
235
+ });
236
+ const log = deps.log ?? defaultLog;
237
+ let running = false;
238
+ const run = (reasonForLog, exitCode) => {
239
+ if (running)
240
+ return;
241
+ running = true;
242
+ shutdown(deps)
243
+ .catch((e) => {
244
+ log(`vice-broker: shutdown error during ${reasonForLog}: ${e.message}`);
245
+ })
246
+ .finally(() => {
247
+ exit(exitCode);
248
+ });
249
+ };
250
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches
251
+ // ProcessLike#once's own listener shape (node:events' EventEmitter#once).
252
+ const registrations = [];
253
+ const register = (event, listener) => {
254
+ proc.once(event, listener);
255
+ registrations.push([event, listener]);
256
+ };
257
+ for (const sig of HANDLED_SIGNALS) {
258
+ register(sig, () => run(`signal ${sig}`, 0));
259
+ }
260
+ register("uncaughtException", (err) => {
261
+ log(`vice-broker: uncaught exception: ${err && err.message ? err.message : String(err)}`);
262
+ run("uncaughtException", 1);
263
+ });
264
+ register("unhandledRejection", (reason) => {
265
+ log(`vice-broker: unhandled rejection: ${reason instanceof Error ? reason.message : String(reason)}`);
266
+ run("unhandledRejection", 1);
267
+ });
268
+ register("exit", () => run("exit", 0));
269
+ return () => {
270
+ const p = proc;
271
+ if (typeof p.removeListener === "function") {
272
+ for (const [event, listener] of registrations) {
273
+ p.removeListener(event, listener);
274
+ }
275
+ }
276
+ };
277
+ }
278
+ /** D-25's mandatory start-time banner: printed unconditionally, before the
279
+ * control listener begins accepting, naming exactly what a keyboard
280
+ * interrupt or a closed terminal destroys. On 2026-08-02 a `^C` produced
281
+ * "reap saw 4 recorded instance(s), terminated 4" and killed a live
282
+ * session -- the incident was not caused by missing machinery, it was
283
+ * caused by nobody being told. Detaching stays the operator's own
284
+ * nohup/setsid/systemd choice (D-25) -- this banner names that choice
285
+ * rather than offering a flag; the launcher stays thin.
286
+ *
287
+ * D-25/P-13 (01.6.2.1-05-PLAN.md): the one place naming the retired
288
+ * warm-floor environment variable does not weaken D-10/D-11's clean break --
289
+ * the line added below reports the variable's mere PRESENCE, never its
290
+ * value, and no reader anywhere in this broker still consults it (the
291
+ * structural gate in broker-kill.test.ts proves that). Without it, an
292
+ * operator with the retired variable set in a shell profile would silently
293
+ * get the default instead of their configured value, with nothing saying
294
+ * so -- the exact failure mode the developer was shown when choosing the
295
+ * clean break over a fallback read. */
296
+ export function startupBanner() {
297
+ const lines = [
298
+ "vice-broker: WARNING -- this broker runs in the FOREGROUND of this process.",
299
+ "vice-broker: a keyboard interrupt (Ctrl-C), a closed terminal, or an ending SSH/VS Code",
300
+ "vice-broker: session will TERMINATE EVERY EMULATOR this broker launched -- including",
301
+ "vice-broker: instances leased to OTHER AGENTS' LIVE SESSIONS, which then lose their",
302
+ "vice-broker: accumulated context.",
303
+ "vice-broker: a broker that dies voids every session it was serving -- there is no",
304
+ "vice-broker: reconnect. A session whose broker dies must be restarted, not resumed.",
305
+ "vice-broker: to run this broker outside the current terminal session, use your own",
306
+ "vice-broker: nohup/setsid/systemd -- this launcher does not offer a --detach flag.",
307
+ ];
308
+ if (process.env.VICE_BROKER_SPARES !== undefined) { // banner-only presence check (D-25/P-13) -- never reads the value
309
+ lines.push("vice-broker: NOTE -- the VICE_BROKER_SPARES environment variable is set and is IGNORED; it was retired with no alias or fallback. Use VICE_BROKER_WARM_FLOOR instead.");
310
+ }
311
+ return lines.join("\n");
312
+ }
313
+ /** Real default: `ps -eo pid=,args=` -- every process on the host, pid plus
314
+ * its full argument string. Never throws: an unreadable `ps` (e.g. no
315
+ * processes visible under this container's pid namespace) yields an empty
316
+ * list rather than aborting the reap. */
317
+ function defaultListProcesses() {
318
+ let raw;
319
+ try {
320
+ raw = execFileSync("ps", ["-eo", "pid=,args="], { encoding: "utf8" });
321
+ }
322
+ catch {
323
+ return [];
324
+ }
325
+ const out = [];
326
+ for (const line of raw.split("\n")) {
327
+ const trimmed = line.trimStart();
328
+ if (trimmed === "")
329
+ continue;
330
+ const m = /^(\d+)\s+(.*)$/.exec(trimmed);
331
+ if (!m)
332
+ continue;
333
+ const pid = Number(m[1]);
334
+ if (!Number.isFinite(pid))
335
+ continue;
336
+ out.push({ pid, args: m[2] });
337
+ }
338
+ return out;
339
+ }
340
+ /** True iff `args` contains a bare numeric token whose value is >= basePort.
341
+ * This is a substring/token scan over the process's own argument string --
342
+ * the same class of untrusted-but-locally-observed check this module's
343
+ * identity check already performs -- not a parse of any particular VICE
344
+ * flag shape, so it holds regardless of whether the port arrived via
345
+ * `-mcpserverport N` or a raw VICE_ARGS override naming the port some other
346
+ * way. */
347
+ function argsNamePortAtOrAbove(args, basePort) {
348
+ const matches = args.match(/\d+/g);
349
+ if (!matches)
350
+ return false;
351
+ return matches.some((token) => {
352
+ const n = Number(token);
353
+ return Number.isFinite(n) && n >= basePort;
354
+ });
355
+ }
356
+ function resolveBasePortForReap(override) {
357
+ if (typeof override === "number")
358
+ return override;
359
+ const raw = process.env.VICE_BROKER_BASE_PORT;
360
+ if (raw === undefined || raw === "")
361
+ return 6600;
362
+ const n = Number(raw);
363
+ return Number.isFinite(n) ? n : 6600;
364
+ }
365
+ function resolveViceBinForReap(override) {
366
+ return override ?? process.env.VICE_BIN ?? "x64sc";
367
+ }
368
+ /** Two-condition selection (T-01.6.2-25/-26): a process qualifies ONLY when
369
+ * its own argument string BOTH names the configured emulator binary AND
370
+ * names a port at or above the allocation band's base. A process matching
371
+ * only one condition is left alone -- this is the whole point: the
372
+ * 6510-6599 band below the base is reserved by convention for an emulator a
373
+ * human launched for their own work (D-18), and reaping one of those would
374
+ * be exactly the squatting problem that band separation exists to prevent;
375
+ * conversely an unrelated process that merely happens to mention a
376
+ * matching-looking port is never a target either. */
377
+ export async function discoverBandProcesses(options = {}) {
378
+ const listProcesses = options.listProcesses ?? defaultListProcesses;
379
+ const viceBin = resolveViceBinForReap(options.viceBin);
380
+ const basePort = resolveBasePortForReap(options.basePort);
381
+ const entries = await listProcesses();
382
+ return entries.filter((entry) => entry.args.includes(viceBin) && argsNamePortAtOrAbove(entry.args, basePort));
383
+ }
384
+ function defaultListInstanceDirs(stateDir) {
385
+ let names;
386
+ try {
387
+ names = readdirSync(stateDir, { withFileTypes: true })
388
+ .filter((entry) => entry.isDirectory())
389
+ .map((entry) => entry.name);
390
+ }
391
+ catch {
392
+ return [];
393
+ }
394
+ return names.filter((name) => /^\d+$/.test(name)).map(Number);
395
+ }
396
+ function readExistingEpochFieldsMaybe(path) {
397
+ let raw;
398
+ try {
399
+ raw = readFileSync(path, "utf8");
400
+ }
401
+ catch {
402
+ return null;
403
+ }
404
+ try {
405
+ const parsed = JSON.parse(raw);
406
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
407
+ return parsed;
408
+ }
409
+ }
410
+ catch {
411
+ /* malformed -- treated as "nothing to preserve", matching nextEpochFor()'s
412
+ * own never-throw posture */
413
+ }
414
+ return null;
415
+ }
416
+ /** Bumps a single in-band instance directory's epoch by exactly one
417
+ * (via the injected nextEpochFor(), the SAME derivation superviseChild()
418
+ * uses on every respawn), preserving every OTHER field already on disk when
419
+ * readable so a human inspecting the file afterwards still finds the
420
+ * instance's real spawned_at/pid/vice_bin/log -- and degrading to
421
+ * reasonable placeholders when the directory carries no prior epoch.json at
422
+ * all (the exact case this reap exists to still cover: a directory the
423
+ * broker has no in-memory record of). This bump is what carries the void
424
+ * into the existing MachineRestartedError path (vice.ts's
425
+ * assertSameMachine()) -- no second notion of "recoverable" is invented
426
+ * here. */
427
+ function bumpEpochForInstanceDir(deps, stateDir, port) {
428
+ const supervisorDir = join(stateDir, String(port));
429
+ const path = deps.epochPathFor(stateDir, port);
430
+ const existing = readExistingEpochFieldsMaybe(path);
431
+ const nextEpoch = deps.nextEpochFor(supervisorDir);
432
+ const record = {
433
+ epoch: nextEpoch,
434
+ spawned_at: typeof existing?.spawned_at === "string" ? existing.spawned_at : new Date().toISOString(),
435
+ pid: typeof existing?.pid === "number" ? existing.pid : 0,
436
+ supervisor_pid: typeof existing?.supervisor_pid === "number" ? existing.supervisor_pid : process.pid,
437
+ vice_bin: typeof existing?.vice_bin === "string" ? existing.vice_bin : "",
438
+ vice_args: Array.isArray(existing?.vice_args) ? existing.vice_args : [],
439
+ log: typeof existing?.log === "string" ? existing.log : "",
440
+ dry_run: typeof existing?.dry_run === "boolean" ? existing.dry_run : false,
441
+ };
442
+ deps.writeEpochRecord({ supervisorDir, record });
443
+ }
444
+ /** The unconditional startup reap (criterion I, D-15). Runs on every broker
445
+ * start, before the control listener accepts and before anything is
446
+ * launched -- unconditional because a broker killed with SIGKILL never runs
447
+ * a shutdown path, so "was the last shutdown clean" is unanswerable, and a
448
+ * marker file recording that answer would itself be the class of file-based
449
+ * liveness claim this phase retires (consults NO such file; the seam this
450
+ * module offers is the process listing and the on-disk instance
451
+ * directories, nothing else).
452
+ *
453
+ * Enumerates host processes via the injected/real process-listing
454
+ * dependency, selects the two-condition matches (discoverBandProcesses()
455
+ * above), and kills each one identity-verified against the configured
456
+ * emulator binary. Then bumps the epoch of EVERY instance directory under
457
+ * `stateDir` whose port falls in the band -- including directories this
458
+ * broker process has no in-memory record of, which is the exact case this
459
+ * seed (.planning/seeds/broker-restart-reaps-and-voids.md) flags: the void
460
+ * has to reach instances a registry-free restart never heard of.
461
+ *
462
+ * Logs one line naming the count found and the count killed, including the
463
+ * zero case -- both the 2026-08-01 and 2026-08-02 incidents were diagnosed
464
+ * from broker log lines, and a silent reap would be exactly the kind of
465
+ * thing impossible to reconstruct afterwards. */
466
+ export async function reapOrphanedInstances(options) {
467
+ const kill = options.kill ?? verifiedKill;
468
+ const log = options.log ?? defaultLog;
469
+ const viceBin = resolveViceBinForReap(options.viceBin);
470
+ const basePort = resolveBasePortForReap(options.basePort);
471
+ const listInstanceDirs = options.listInstanceDirs ?? defaultListInstanceDirs;
472
+ const matched = await discoverBandProcesses({
473
+ listProcesses: options.listProcesses,
474
+ viceBin,
475
+ basePort,
476
+ });
477
+ let killed = 0;
478
+ for (const entry of matched) {
479
+ const stage = await kill({ pid: entry.pid, expectedIdentity: viceBin });
480
+ if (stage === "sigterm" || stage === "sigkill")
481
+ killed++;
482
+ }
483
+ const ports = listInstanceDirs(options.stateDir);
484
+ for (const port of ports) {
485
+ if (port >= basePort) {
486
+ bumpEpochForInstanceDir(options, options.stateDir, port);
487
+ }
488
+ }
489
+ log(`vice-broker: startup reap found ${matched.length} process(es) in the emulator port band, terminated ${killed}`);
490
+ return { found: matched.length, killed };
491
+ }