ccdeck 1.43.0 → 1.45.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.
@@ -0,0 +1,505 @@
1
+ #!/usr/bin/env node
2
+ // Supervisor. Owns one thing: the worker's lifecycle.
3
+ //
4
+ // Why this exists at all: Node caches every module at import, so a deck that is
5
+ // running when an upgrade lands keeps executing the old code until the process
6
+ // is replaced. The deck can now see that (GET /api/version) — this is the half
7
+ // that can act on it.
8
+ //
9
+ // The tempting shortcut is to have the server respawn itself and exit. Every
10
+ // version of that is worse than it looks:
11
+ // • the replacement races the dying listener, and startServer answers
12
+ // EADDRINUSE by binding one of ten RANDOM ports in 4318–4400 — so the tab
13
+ // you are looking at reconnects forever next to a healthy invisible server;
14
+ // • an orphan spawned from a dying parent leaves the shell's foreground
15
+ // process group, so Ctrl+C stops reaching it;
16
+ // • with stdio ignored it also loses the banner, the URL line and every
17
+ // console.error the server writes.
18
+ // A parent that stays alive avoids all three: the child is dead — and its
19
+ // listening socket released — before the next one is spawned, stdio is
20
+ // inherited so the terminal is unchanged, and Ctrl+C keeps working because the
21
+ // process group never changes.
22
+ //
23
+ // The upgrade path has one more constraint, learned the expensive way: the
24
+ // worker must not be torn down to find out whether an upgrade is possible. It
25
+ // used to be — the worker exited 76, and only then did npx discover it was
26
+ // offline — so every failed update cost a full outage and could be retried
27
+ // identically forever. Now the worker ASKS (an `upgrade` message), the fetch
28
+ // happens beside it while it keeps serving, and only a fetch that worked is
29
+ // answered with the exit that gives up the port. See prefetchUpgrade.
30
+ //
31
+ // Everything else the deck does still lives in bin/deck.js. This file must stay
32
+ // boring: it is the one process that is never replaced.
33
+ import { spawn } from "node:child_process";
34
+ import { existsSync } from "node:fs";
35
+ import { connect } from "node:net";
36
+ import { dirname, join } from "node:path";
37
+ import { fileURLToPath } from "node:url";
38
+ import { killTree } from "../src/server/exec.mjs";
39
+ import { invokedAs } from "../src/server/invoked-as.mjs";
40
+ import { npxFailureHint, npxFailureSummary, npxLaunch, npxPrefetch } from "../src/server/npx.mjs";
41
+ import {
42
+ bareSpecName, claimRestartFailureKey, clearRestartFailure, installedName, installedVersion,
43
+ lastKnownLatest, npxRestartSpec, readRestartFailure, recordRestartFailure, successorRoot,
44
+ } from "../src/server/self-update.mjs";
45
+ import { dieOfSignal, dieWithParent, replacedNote, upgradeAttempt, upgradeRefusalText, workerExitAction } from "../src/server/supervisor.mjs";
46
+ import { colorProfile, glyphs, palette, unicodeOK } from "../src/server/term.mjs";
47
+ import { PRODUCT } from "../src/server/brand.mjs";
48
+
49
+ const BIN_DIR = dirname(fileURLToPath(import.meta.url));
50
+ const WORKER = join(BIN_DIR, "deck.js");
51
+ const PKG_ROOT = dirname(BIN_DIR);
52
+
53
+ const VERSION = installedVersion(PKG_ROOT) ?? "?";
54
+
55
+ // Which of the three published commands the user typed, when that is knowable.
56
+ // This process is the one the shell exec'd, so its argv[1] is the only place
57
+ // the answer exists — the worker's is `…/bin/deck.js` under every one of them —
58
+ // and launch() carries it down rather than letting the worker ask a question it
59
+ // cannot answer. Null wherever it cannot be proven, which is a Windows global
60
+ // install and a git checkout; see src/server/invoked-as.mjs for why those two
61
+ // have to stay silent rather than guess.
62
+ const INVOKED_AS = invokedAs({ pkgRoot: PKG_ROOT, argv1: process.argv[1], platform: process.platform });
63
+
64
+ // The supervisor prints exactly one line of its own — the fetch — and it has to
65
+ // look like it came from the same product as the worker's rows. That is all the
66
+ // presentation this file gets: it is the process that is never replaced, and a
67
+ // colour profile plus a glyph tier is the most it can carry without becoming
68
+ // something that can fail. Everything else it says is an error, on stderr, in
69
+ // plain text, where it belongs.
70
+ const P = palette(colorProfile({ isTTY: Boolean(process.stdout.isTTY) }));
71
+ const G = glyphs(unicodeOK());
72
+
73
+ // Who the restart-failure note below belongs to. Several decks of the same
74
+ // package run out of one home directory — two `npx ccdeck` runs even share the
75
+ // _npx directory and therefore the version — so a note named after the package
76
+ // alone was read by every one of them, and a deck that had never asked for an
77
+ // update reported someone else's failed npx as its own. Our pid is unique among
78
+ // the decks alive on the machine, and putting it in our own environment is what
79
+ // carries it to the worker: launch() spawns with a copy of it.
80
+ claimRestartFailureKey();
81
+
82
+ // The port the worker actually bound, which is not necessarily the one it was
83
+ // asked for — the first launch falls back to a random port when 4317 is taken.
84
+ // Re-launching without this is how a restart silently moves the deck out from
85
+ // under an open tab.
86
+ let boundPort = null;
87
+ let restarts = 0;
88
+ let child = null;
89
+ // The npx process fetching a replacement, while the worker above keeps serving.
90
+ // Held separately from `child` for exactly that reason: for the length of a
91
+ // fetch there are two of them, and only one is the deck.
92
+ let fetching = null;
93
+ // The upgrade this supervisor has committed to, from the moment the pre-flight
94
+ // allowed it until the replacement is serving or the attempt has been written
95
+ // off. giveUp runs long after the decision that permitted the attempt and has
96
+ // to record that decision's count, not a fresh one.
97
+ let attempting = null;
98
+ // Set by the signal handlers, and the first thing every exit path asks. Without
99
+ // it, Ctrl+C during an npx fetch looks exactly like a failed fetch, and a
100
+ // worker that was already exiting 75 or 76 when the signal landed reads as a
101
+ // restart request — both resurrect the deck the user just stopped.
102
+ let stopping = false;
103
+
104
+ function launch(respawn) {
105
+ // Asked on every spawn rather than once at boot: the deletion this catches
106
+ // happens while the deck is running, so a constant read at import time would
107
+ // be the one answer that cannot see it. See supervisor.mjs for the whole case.
108
+ const replaced = replacedNote({
109
+ workerExists: existsSync(WORKER),
110
+ moved: successorRoot(PKG_ROOT),
111
+ product: PRODUCT,
112
+ command: INVOKED_AS ?? PRODUCT,
113
+ });
114
+ if (replaced) {
115
+ console.error(replaced);
116
+ process.exit(1);
117
+ }
118
+ const args = [WORKER, ...process.argv.slice(2)];
119
+ // Appended last so it wins: the worker's parser keeps the final --port.
120
+ if (respawn && boundPort != null) args.push("--port", String(boundPort));
121
+
122
+ const worker = spawn(process.execPath, args, {
123
+ // stdio inherited so the child owns the same terminal the user started:
124
+ // same banner, same colours, same Ctrl+C. The fourth slot adds an IPC
125
+ // channel — the only way the worker can tell us which port it got, since
126
+ // parsing its stdout would be guesswork.
127
+ stdio: ["inherit", "inherit", "inherit", "ipc"],
128
+ env: {
129
+ ...process.env,
130
+ // Boot did the slow, once-per-session work already (hook install, the
131
+ // claude-swap probe with its 8s timeout, the ccusage prime). Repeating it
132
+ // is what would make a restart feel like a restart.
133
+ AGENTS_DECK_RESPAWN: respawn ? "1" : "",
134
+ AGENTS_DECK_RESTARTS: String(restarts),
135
+ // Empty for "we could not tell", which knownCommand reads as unknown just
136
+ // like an absent one. Set on the worker's environment only: the npx
137
+ // relaunch below spawns a whole new supervisor, and that one has to ask
138
+ // its own _npx directory rather than inherit an answer about a cache
139
+ // directory it is not running from.
140
+ AGENTS_DECK_INVOKED_AS: INVOKED_AS ?? "",
141
+ },
142
+ });
143
+
144
+ child = worker;
145
+
146
+ worker.on("message", (m) => {
147
+ if (!m || typeof m !== "object") return;
148
+ if (m.type === "listening" && typeof m.port === "number") boundPort = m.port;
149
+ // The worker asking to be replaced, while it is still serving. Answered by
150
+ // prefetchUpgrade, which is the whole of this file's new shape: the fetch
151
+ // happens here, and only then does that worker exit — see UPGRADE_CODE.
152
+ else if (m.type === "upgrade") prefetchUpgrade(worker);
153
+ });
154
+
155
+ worker.on("exit", (code, signal) => {
156
+ child = null;
157
+ // A fetch is for the worker that asked for it. That worker is gone, so the
158
+ // download is spent effort and the process holding it has to be stopped:
159
+ // left running it would keep writing into the npx cache directory the next
160
+ // attempt reads, minutes after the deck stopped waiting for it.
161
+ if (fetching) { killTree(fetching); fetching = null; attempting = null; }
162
+ // `stopping` outranks the exit code — see supervisor.mjs. A restart and a
163
+ // Ctrl+C can land together, and honouring the code first is how the deck
164
+ // came back to life after the user stopped it.
165
+ const next = workerExitAction(code, stopping);
166
+ if (next.relaunch === "disk") {
167
+ restarts++;
168
+ launch(true);
169
+ return;
170
+ }
171
+ if (next.relaunch === "npx") {
172
+ restarts++;
173
+ launchNpx();
174
+ return;
175
+ }
176
+ // Anything else is the worker's own verdict and belongs to whoever started
177
+ // us — including the ccdeck wrapper, which exits with our code in turn. A
178
+ // worker killed by a signal is reported by dying of the same one; doing
179
+ // that naively re-enters the handlers below and exits 0 — see
180
+ // supervisor.mjs.
181
+ if (signal) dieOfSignal(signal);
182
+ else process.exit(next.code);
183
+ });
184
+
185
+ worker.on("error", (err) => {
186
+ console.error(`${PRODUCT}: could not start ${WORKER}: ${err.message}`);
187
+ process.exit(1);
188
+ });
189
+ }
190
+
191
+ /** Drop the two flags launchNpx sets itself. `--port` takes a value, and both
192
+ * spellings npm's parser accepts (`--port 4317`, `--port=4317`) have to go. */
193
+ function withoutPortAndOpen(args) {
194
+ const out = [];
195
+ for (let i = 0; i < args.length; i++) {
196
+ const a = args[i];
197
+ if (a === "--no-open") continue;
198
+ if (a === "--port") { i++; continue; } // and its value
199
+ if (a.startsWith("--port=")) continue;
200
+ out.push(a);
201
+ }
202
+ return out;
203
+ }
204
+
205
+ /**
206
+ * Which package an npx upgrade here names, and the spec that installs it — or
207
+ * null when this deck was not started by npx and there is nothing to re-run.
208
+ *
209
+ * One function because there is one answer, and the worker above us reaches it
210
+ * independently: `upgradeName` in self-update.mjs resolves the npx case as
211
+ * `bareSpecName(npxRestartSpec(pkgRoot, installedName(pkgRoot)))`, and
212
+ * everything registry-shaped on that side — the dist-tag it fetched, the marker
213
+ * it cached the answer in, the failure note the browser reads — is keyed by it.
214
+ * This supervisor keyed the same four things off `npxRestartSpec(PKG_ROOT)`
215
+ * with the module's own default, which is the literal string "agents-deck".
216
+ *
217
+ * Both read `_npx/<hash>/package.json`, so they agreed for as long as that
218
+ * metadata was readable, and #340 made the disagreement matter when it is not:
219
+ * the three names are one tarball now, so the manifest under a `npx ccdeck` run
220
+ * genuinely says `ccdeck` where it once always said `agents-deck`. A cache
221
+ * directory written by an npm whose `_npx.packages` layout differs — or a
222
+ * truncated file — split the two apart: the worker asked npm about `ccdeck` and
223
+ * read `.restart-failed-ccdeck-<pid>` while this process wrote
224
+ * `.restart-failed-agents-deck-<pid>`, took `lastKnownLatest("agents-deck")`
225
+ * off a marker nobody had written — so `target` was null and upgradeAttempt's
226
+ * per-target cap degraded to "any failure counts" — and relaunched
227
+ * `npx -y agents-deck@latest`, moving a `npx ccdeck` user onto a different
228
+ * published name. The note went where the browser never looks, which is the
229
+ * exact class of bug NOTE_PREFIX's naming exists to prevent.
230
+ *
231
+ * So the fallback is the name npm's rename wrote into this build's own manifest,
232
+ * which is what the worker falls back to as well. Read on each call rather than
233
+ * once at import for the reason installedVersion gives: the manifest is a file
234
+ * on disk, and this process outlives more than one of them.
235
+ */
236
+ function npxUpgrade() {
237
+ const self = installedName(PKG_ROOT);
238
+ const spec = npxRestartSpec(PKG_ROOT, self);
239
+ return spec ? { spec, pkgName: bareSpecName(spec) ?? self } : null;
240
+ }
241
+
242
+ /** The note the browser reads, written by the only process that knows why an
243
+ * upgrade did not happen. `failedAt` is when the FETCH failed, which a refusal
244
+ * restating an earlier failure carries forward — see upgradeAttempt. */
245
+ function noteFailure({ pkgName, spec, error, target, attempts, failedAt = Date.now() }) {
246
+ recordRestartFailure({
247
+ name: pkgName,
248
+ command: `npx -y ${spec}`,
249
+ error,
250
+ version: VERSION === "?" ? null : VERSION,
251
+ target,
252
+ attempts,
253
+ failedAt,
254
+ });
255
+ }
256
+
257
+ /**
258
+ * Fetch the replacement while the deck this supervisor started keeps serving.
259
+ *
260
+ * The order used to be: kill the working deck, attempt, fail, rebuild the deck.
261
+ * Every failed upgrade was therefore a real interruption — the SSE stream
262
+ * dropped, hook events fired into the gap lost outright, the canvas back with
263
+ * tools stuck in flight — and it was paid in full even when the update never
264
+ * had a chance of working. Reported from a terminal that had been left alone:
265
+ * the same version, the same ETARGET, the same teardown, four times over.
266
+ *
267
+ * npm can resolve and download without the deck dying. So the fetch is done
268
+ * first, and the worker is only asked to exit once there is something to hand
269
+ * the port to. There is never a second server: npxPrefetch installs the package
270
+ * and runs nothing out of it, and the replacement is spawned only after this
271
+ * process has watched the old worker's exit.
272
+ *
273
+ * A refusal costs nothing but the click. The deck keeps its port, its stream
274
+ * and its hooks; the failure reaches the browser through the note, exactly as a
275
+ * failed fetch always has.
276
+ */
277
+ async function prefetchUpgrade(worker) {
278
+ if (stopping || fetching || attempting) return;
279
+ const reply = (msg) => { try { worker.send?.(msg); } catch { /* the worker is gone */ } };
280
+
281
+ const upgrade = npxUpgrade();
282
+ // Not an npx run after all, so there is nothing to fetch and the files on
283
+ // disk are already the newest this deck can reach.
284
+ if (!upgrade) { reply({ type: "upgrade-refused", error: "this deck was not started by npx" }); return; }
285
+ const { spec, pkgName } = upgrade;
286
+ // What the banner offered, straight off the marker the version check writes.
287
+ // The note is keyed by it so that "this exact version already failed here" is
288
+ // a question anything can answer — and so a newer release clears the slate.
289
+ const target = lastKnownLatest(pkgName);
290
+
291
+ const note = readRestartFailure(pkgName);
292
+ const decision = upgradeAttempt({ note, target, now: Date.now() });
293
+ if (!decision.allow) {
294
+ const error = upgradeRefusalText(decision, target);
295
+ // Re-stamped even though nothing was attempted: the tab ends its attempt on
296
+ // a failure note it has not seen before, so a refusal that left the note
297
+ // untouched would leave the button reading "fetching…" for the full three
298
+ // minutes it allows, over a deck that never went anywhere.
299
+ noteFailure({
300
+ pkgName, spec, error, target,
301
+ attempts: decision.attempt,
302
+ failedAt: typeof note?.failedAt === "number" ? note.failedAt : Date.now(),
303
+ });
304
+ reply({ type: "upgrade-refused", error });
305
+ return;
306
+ }
307
+
308
+ attempting = { pkgName, spec, target, attempt: decision.attempt };
309
+ process.stdout.write(`\n ${P.warn}${G.restart}${P.reset} ${P.muted}fetching ${spec}${G.ellipsis}${P.reset}\n`);
310
+ const got = await npxPrefetch(spec, { onChild: (c) => { fetching = c; } });
311
+ fetching = null;
312
+ // Ctrl+C, or a worker that died on its own while npm was working: either way
313
+ // the deck this fetch was for is not there to be replaced, and the exit path
314
+ // that noticed has already had its say.
315
+ if (stopping || child !== worker) { attempting = null; return; }
316
+ if (got.ok) { reply({ type: "upgrade-ready" }); return; }
317
+
318
+ const error = [got.error, got.hint].filter(Boolean).join(" — ");
319
+ noteFailure({ pkgName, spec, error, target, attempts: decision.attempt });
320
+ attempting = null;
321
+ reply({ type: "upgrade-refused", error });
322
+ }
323
+
324
+ /**
325
+ * Come back on a newer version, for a deck that npx started.
326
+ *
327
+ * There is nothing to install here: npx unpacks each spec into its own
328
+ * content-addressed directory under _npx, so `npm i -g` would upgrade something
329
+ * this process could never reach. `npx -y <spec>@latest` resolves fresh, gets a
330
+ * DIFFERENT directory, and starts the deck there — on the port we hand it, so
331
+ * the tab that asked for the update reconnects to the same URL.
332
+ *
333
+ * This process stays as the parent rather than exec-ing, for the same reasons
334
+ * the file's header gives, plus one more: a fetch can fail (offline, registry
335
+ * down), and someone has to bring the working copy back when it does.
336
+ */
337
+ function launchNpx() {
338
+ const upgrade = npxUpgrade();
339
+ if (!upgrade) { launch(true); return; } // not an npx run after all
340
+ // The same package prefetchUpgrade fetched and the worker asked npm about —
341
+ // one answer, so the spec that is run, the note that records it and the
342
+ // marker its target came from all name one package. See npxUpgrade.
343
+ const { spec, pkgName } = upgrade;
344
+ // Our two are appended, so the originals are dropped rather than left to be
345
+ // overridden — `--port 4317 --no-open --port 4317 --no-open` works, but it is
346
+ // what the next person reads in `ps`.
347
+ const args = ["-y", spec, ...withoutPortAndOpen(process.argv.slice(2))];
348
+ if (boundPort != null) args.push("--port", String(boundPort));
349
+ // The tab that asked for this is open and reconnecting; a second one would be
350
+ // the deck talking over itself.
351
+ args.push("--no-open");
352
+
353
+ // A retry answers for itself: whatever the last attempt left on disk is about
354
+ // to be replaced by this attempt's outcome, and leaving it there would keep
355
+ // the browser showing an old failure over a running fetch.
356
+ clearRestartFailure(pkgName);
357
+
358
+ // No "fetching…" line here any more: prefetchUpgrade printed it and the
359
+ // tarball is already unpacked, so this resolves out of the npx cache and the
360
+ // next thing on screen is the new deck's own banner.
361
+ //
362
+ // npxLaunch prefers npm's own npx-cli.js next to this Node binary, which
363
+ // needs no PATH lookup and no batch shim; the PATH shim is the fallback and
364
+ // still goes through cmd.exe with each argument quoted.
365
+ //
366
+ // Not `shell: true` on either path. Everything after the spec is the user's
367
+ // own argv, and Node would paste it into one command line unquoted:
368
+ // `--workspace C:\Users\John Smith\proj` would reach the new deck as two
369
+ // arguments, the second one silently dropped by its parser — an upgraded deck
370
+ // watching the wrong directory.
371
+ const { file, args: argv, opts } = npxLaunch(args);
372
+ // stderr is piped rather than inherited so a crash can be summarised instead
373
+ // of dumped: npm's MODULE_NOT_FOUND stack, with its `requireStack` and its
374
+ // caret line, is not something a user of a DAG dashboard can act on. stdout
375
+ // stays inherited — the new deck's banner, colours and URL line are the whole
376
+ // point of the supervisor staying out of the way.
377
+ const started = spawn(file, argv, { stdio: ["inherit", "inherit", "pipe"], ...opts });
378
+ child = started;
379
+
380
+ // Held, not discarded: the moment the replacement is serving, everything it
381
+ // wrote goes to the terminal and every later byte passes straight through.
382
+ // Until then it is only evidence for a failure that may not happen.
383
+ let tail = "";
384
+ let teeing = false;
385
+ const tee = () => {
386
+ if (teeing) return;
387
+ teeing = true;
388
+ if (tail) process.stderr.write(tail);
389
+ };
390
+ started.stderr?.on("data", (d) => {
391
+ const s = String(d);
392
+ if (teeing) { process.stderr.write(s); return; }
393
+ tail = (tail + s).slice(-8000);
394
+ });
395
+
396
+ // Whether the replacement ever got as far as serving. An npx that cannot
397
+ // resolve exits in seconds having bound nothing; a deck the user stops with
398
+ // Ctrl+C exits non-zero too, and only this tells the two apart.
399
+ let served = false;
400
+ const probe = setInterval(() => {
401
+ if (boundPort == null || served) return;
402
+ const sock = connect({ port: boundPort, host: "127.0.0.1" });
403
+ sock.setTimeout(1000);
404
+ sock.on("connect", () => { served = true; tee(); sock.destroy(); });
405
+ sock.on("timeout", () => sock.destroy());
406
+ sock.on("error", () => { /* not up yet */ });
407
+ }, 1000);
408
+ probe.unref?.();
409
+
410
+ const giveUp = (why) => {
411
+ clearInterval(probe);
412
+ child = null;
413
+ if (stopping || served) return; // the user stopped it, or it ran and ended
414
+ const summary = npxFailureSummary(tail);
415
+ const hint = npxFailureHint(tail);
416
+ console.error(`${PRODUCT}: ${why} — staying on v${VERSION}`);
417
+ if (summary) console.error(` ${summary}`);
418
+ if (hint) console.error(` ${hint}`);
419
+ // Left for the worker about to be launched: it is the only way the browser
420
+ // learns this happened at all. See recordRestartFailure.
421
+ //
422
+ // This is the outage the pre-flight cannot spare anyone: the fetch worked,
423
+ // the port was handed over, and the replacement still did not serve. It
424
+ // counts against the same target as any other failed attempt, so a copy
425
+ // that starts and dies is not offered forever either.
426
+ noteFailure({
427
+ pkgName, spec,
428
+ error: [summary ?? why, hint].filter(Boolean).join(" — "),
429
+ target: attempting?.target ?? null,
430
+ attempts: attempting?.attempt ?? 1,
431
+ });
432
+ attempting = null;
433
+ restarts++;
434
+ launch(true);
435
+ };
436
+
437
+ started.on("exit", (code, signal) => {
438
+ clearInterval(probe);
439
+ child = null;
440
+ if (stopping || served) {
441
+ if (signal) dieOfSignal(signal);
442
+ else process.exit(code ?? 0);
443
+ return;
444
+ }
445
+ giveUp(`npx ${spec} exited ${code ?? signal}`);
446
+ });
447
+ started.on("error", (err) => giveUp(`could not run npx: ${err.message}`));
448
+ }
449
+
450
+ // Ctrl+C already reaches the child directly — it shares this process group — so
451
+ // forwarding would deliver it twice. These handlers exist only to keep the
452
+ // supervisor alive long enough for the child's own graceful shutdown to run and
453
+ // for its exit code to arrive.
454
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
455
+ process.on(sig, () => {
456
+ // On Windows none of these are delivered to a Node process, which is fine:
457
+ // there the console kills the whole tree and sweepStaleDiscovery cleans up
458
+ // on the next boot.
459
+ const second = stopping; // an impatient user pressing Ctrl+C again
460
+ stopping = true;
461
+ // A fetch in flight is not the deck and does not stop with it. On POSIX the
462
+ // Ctrl+C that reached us reached it too — it is in this process group — but
463
+ // a plain `kill` of the supervisor, or a systemd stop, is not, and on
464
+ // Windows the shim path leaves npm as a grandchild of cmd.exe that only
465
+ // killTree can reach. Stopped before the worker, since the worker's own
466
+ // exit path is what ends this process.
467
+ if (fetching) { killTree(fetching, second ? "SIGKILL" : sig); fetching = null; }
468
+ if (!child) { process.exit(0); return; }
469
+ try { child.kill(second ? "SIGKILL" : sig); } catch { /* already gone */ }
470
+ // The npx step is a shell that exec's the new deck, and a signal can land in
471
+ // the gap: the shell dies, the process replacing it never saw it. One more
472
+ // attempt a moment later catches that; it is a no-op when the first worked.
473
+ if (!second) {
474
+ setTimeout(() => {
475
+ if (stopping && child) { try { child.kill("SIGTERM"); } catch { /* gone */ } }
476
+ }, 2500).unref();
477
+ }
478
+ });
479
+ }
480
+
481
+ // And the same leash the worker is on, one link up (#702).
482
+ //
483
+ // Inert for every published way of starting the deck: `ccdeck`, `npx ccdeck` and
484
+ // the npx relaunch in launchNpx all spawn this file with no IPC channel, so
485
+ // dieWithParent declines to arm and this line costs nothing. It is armed by a
486
+ // harness that DOES hand one over — the suite's spawnSupervised — and what it
487
+ // buys there is the case no teardown can cover, because the teardown is not
488
+ // running: a vitest worker killed outright leaves this supervisor orphaned, and
489
+ // an orphaned supervisor keeps a worker alive under it.
490
+ //
491
+ // Taking the worker with us is the whole of the stop. `stopping` first, so the
492
+ // worker's exit is not read as a request to bring it back; the kill is the
493
+ // signal on POSIX and, through killTree, `taskkill /T /F` on Windows, which has
494
+ // neither SIGTERM nor a process group to aim at.
495
+ dieWithParent(() => {
496
+ stopping = true;
497
+ if (fetching) { killTree(fetching, "SIGTERM"); fetching = null; }
498
+ if (child) killTree(child, "SIGTERM");
499
+ // Long enough for the worker to unregister its discovery file and let the
500
+ // port go, short enough that nothing is waiting on us. Unref'd: if the worker
501
+ // leaves first its exit handler ends this process and the timer never fires.
502
+ setTimeout(() => process.exit(0), 3000).unref();
503
+ });
504
+
505
+ launch(false);