agent-dag 3.22.0 → 3.22.3

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 (70) hide show
  1. package/README.md +6 -477
  2. package/package.json +14 -48
  3. package/shim.js +107 -0
  4. package/LICENSE +0 -661
  5. package/LICENSING.md +0 -82
  6. package/THIRD_PARTY_NOTICES.md +0 -395
  7. package/bin/agent-dag.js +0 -626
  8. package/bin/deck.js +0 -1805
  9. package/dist/web/assets/index-3FWd7g_W.css +0 -1
  10. package/dist/web/assets/index-BOwtoP02.js +0 -266
  11. package/dist/web/index.html +0 -49
  12. package/hook/hook.js +0 -542
  13. package/release-notes.json +0 -392
  14. package/src/server/activity.mjs +0 -52
  15. package/src/server/agent-activity.mjs +0 -522
  16. package/src/server/args.mjs +0 -183
  17. package/src/server/auto-update.mjs +0 -79
  18. package/src/server/block-notify.mjs +0 -173
  19. package/src/server/boot-deadline.mjs +0 -127
  20. package/src/server/brand.mjs +0 -16
  21. package/src/server/browser-history.mjs +0 -497
  22. package/src/server/browser-presence.mjs +0 -211
  23. package/src/server/browser-profiles.mjs +0 -279
  24. package/src/server/browser-react.mjs +0 -284
  25. package/src/server/browser-watch-store.mjs +0 -350
  26. package/src/server/browser-watch.mjs +0 -905
  27. package/src/server/ccusage.mjs +0 -1168
  28. package/src/server/claude-accounts.mjs +0 -951
  29. package/src/server/claude-dir.mjs +0 -213
  30. package/src/server/codex-auth.mjs +0 -388
  31. package/src/server/codex-dir.mjs +0 -171
  32. package/src/server/codex-quota.mjs +0 -449
  33. package/src/server/codex-usage.mjs +0 -512
  34. package/src/server/cswap-admin.mjs +0 -1562
  35. package/src/server/cswap-auto.mjs +0 -658
  36. package/src/server/cswap-install.mjs +0 -641
  37. package/src/server/deck-home.mjs +0 -243
  38. package/src/server/deck-prefs.mjs +0 -301
  39. package/src/server/deck-probe.mjs +0 -111
  40. package/src/server/detach.mjs +0 -244
  41. package/src/server/exec.mjs +0 -996
  42. package/src/server/global-install.mjs +0 -67
  43. package/src/server/hwmonitor.mjs +0 -56
  44. package/src/server/index.mjs +0 -6043
  45. package/src/server/installer.mjs +0 -912
  46. package/src/server/invoked-as.mjs +0 -144
  47. package/src/server/lan-about.mjs +0 -119
  48. package/src/server/lan-engine.mjs +0 -952
  49. package/src/server/lan-reach.mjs +0 -256
  50. package/src/server/lan-socket.mjs +0 -682
  51. package/src/server/lan-sync.mjs +0 -941
  52. package/src/server/lhm-parse.mjs +0 -91
  53. package/src/server/log-tail.mjs +0 -139
  54. package/src/server/log-writer.mjs +0 -322
  55. package/src/server/login-service.mjs +0 -473
  56. package/src/server/macmon.mjs +0 -310
  57. package/src/server/npx.mjs +0 -264
  58. package/src/server/open-url.mjs +0 -242
  59. package/src/server/presence.mjs +0 -40
  60. package/src/server/quota.mjs +0 -792
  61. package/src/server/relay-guard.mjs +0 -507
  62. package/src/server/reset-label.mjs +0 -78
  63. package/src/server/retire-sound-hook.mjs +0 -349
  64. package/src/server/running-deck.mjs +0 -234
  65. package/src/server/self-update.mjs +0 -1380
  66. package/src/server/stop-deck.mjs +0 -171
  67. package/src/server/supervisor.mjs +0 -392
  68. package/src/server/system-metrics.mjs +0 -1825
  69. package/src/server/term.mjs +0 -686
  70. package/src/server/uv-bootstrap.mjs +0 -337
package/bin/agent-dag.js DELETED
@@ -1,626 +0,0 @@
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 { isOneShot, parseArgs } from "../src/server/args.mjs";
41
- import { npxFailureHint, npxFailureSummary, npxLaunch, npxPrefetch } from "../src/server/npx.mjs";
42
- import {
43
- bareSpecName, claimRestartFailureKey, clearRestartFailure, installedName, installedVersion,
44
- isNpxInstall, lastKnownLatest, npxRestartSpec, readRestartFailure, recordRestartFailure,
45
- successorRoot,
46
- } from "../src/server/self-update.mjs";
47
- import {
48
- CRASH_CEILING, CRASH_WINDOW_MS, crashPolicy, dieOfSignal, dieWithParent, isCrash, replacedNote,
49
- upgradeAttempt, upgradeRefusalText, workerExitAction,
50
- } from "../src/server/supervisor.mjs";
51
- import { colorProfile, glyphs, palette, termColumns, unicodeOK } from "../src/server/term.mjs";
52
- import { DETACHED_ENV, detachAndWatch, stopCommand } from "../src/server/detach.mjs";
53
- import { PRODUCT } from "../src/server/brand.mjs";
54
-
55
- const BIN_DIR = dirname(fileURLToPath(import.meta.url));
56
- const WORKER = join(BIN_DIR, "deck.js");
57
- const PKG_ROOT = dirname(BIN_DIR);
58
-
59
- const VERSION = installedVersion(PKG_ROOT) ?? "?";
60
-
61
- // Which of the three published commands the user typed, when that is knowable.
62
- // This process is the one the shell exec'd, so its argv[1] is the only place
63
- // the answer exists — the worker's is `…/bin/deck.js` under every one of them —
64
- // and launch() carries it down rather than letting the worker ask a question it
65
- // cannot answer. Null wherever it cannot be proven, which is a Windows global
66
- // install and a git checkout; see src/server/invoked-as.mjs for why those two
67
- // have to stay silent rather than guess.
68
- const INVOKED_AS = invokedAs({ pkgRoot: PKG_ROOT, argv1: process.argv[1], platform: process.platform });
69
-
70
- // The supervisor prints exactly one line of its own — the fetch — and it has to
71
- // look like it came from the same product as the worker's rows. That is all the
72
- // presentation this file gets: it is the process that is never replaced, and a
73
- // colour profile plus a glyph tier is the most it can carry without becoming
74
- // something that can fail. Everything else it says is an error, on stderr, in
75
- // plain text, where it belongs.
76
- const P = palette(colorProfile({ isTTY: Boolean(process.stdout.isTTY) }));
77
- const G = glyphs(unicodeOK());
78
-
79
- // Who the restart-failure note below belongs to. Several decks of the same
80
- // package run out of one home directory — two `npx ccdeck` runs even share the
81
- // _npx directory and therefore the version — so a note named after the package
82
- // alone was read by every one of them, and a deck that had never asked for an
83
- // update reported someone else's failed npx as its own. Our pid is unique among
84
- // the decks alive on the machine, and putting it in our own environment is what
85
- // carries it to the worker: launch() spawns with a copy of it.
86
- // ── the terminal stops being the deck's leash ────────────────────────────────
87
- //
88
- // Everything about why is in src/server/detach.mjs. Here is only the decision,
89
- // and it has exactly two ways out:
90
- //
91
- // ALREADY DETACHED — we ARE the background copy. Carry on as this file always
92
- // has: spawn the worker, supervise it, never come back here.
93
- //
94
- // A ONE-SHOT — `--version`, `--stop`, `--status`, `--help`, `--uninstall`.
95
- // Those answer and leave, and a one-shot that detached would print its answer
96
- // into a log file and hand the terminal back empty. They run in the
97
- // foreground exactly as they always have.
98
- //
99
- // Anything else is a start, and a start goes to the background.
100
- const DETACHED = process.env[DETACHED_ENV] === "1";
101
- // A parent already holding our lifecycle. `process.send` exists only when
102
- // somebody spawned us with an IPC channel, and that somebody has armed
103
- // dieWithParent below and is waiting on our exit code — running away from them
104
- // into our own process group is precisely the wrong answer to being supervised.
105
- // The suite's spawnSupervised is the caller that does this today.
106
- const LEASHED = typeof process.send === "function";
107
- // And the way to ask for the old behaviour out loud.
108
- //
109
- // Every version before this one held the terminal, and something out there
110
- // depends on that: a wrapper script, a CI step, a supervisor of somebody else's
111
- // that starts `ccdeck` and waits on it, a `ccdeck && open …`. Handing all of
112
- // those an immediate exit and no way to say otherwise would be a breaking change
113
- // with no escape hatch — and the marker above is an internal one, not something
114
- // to tell a user to export.
115
- const FLAGS = parseArgs(process.argv.slice(2));
116
- if (!DETACHED && !LEASHED && FLAGS.foreground !== true && !isOneShot(FLAGS)) {
117
- const { deckLogDir } = await import("../src/server/deck-home.mjs");
118
- const { registeredDecks } = await import("../src/server/running-deck.mjs");
119
- const isTTY = Boolean(process.stdout.isTTY);
120
- const profile = colorProfile({ isTTY });
121
- const tone = palette(profile);
122
- const npx = isNpxInstall(PKG_ROOT);
123
- const stop = stopCommand({ npx, invokedAs: INVOKED_AS, product: PRODUCT });
124
- // THE ONE THING AN NPX RUN CANNOT HAVE, said where it is missing. A login item
125
- // must name a path that will still be there tomorrow, and npx runs out of a
126
- // cache npm deletes whenever it likes — so an npx deck runs in the background
127
- // and cannot come back after a reboot. One line, no disk written, offered
128
- // rather than done: `npx` means "run without installing", and a tool that
129
- // installs itself anyway is the one people uninstall.
130
- const offer = npx
131
- ? ` ${tone.muted}\`${INVOKED_AS ?? PRODUCT} --install\` also starts it at login${tone.reset}\n`
132
- : "";
133
- const outcome = await detachAndWatch({
134
- file: fileURLToPath(import.meta.url),
135
- argv: process.argv.slice(2),
136
- logDir: deckLogDir(),
137
- // Only the count, and only to decide whether deck.log is anybody's — see
138
- // logMode. No handshake: this is a directory listing and a signal-0 each.
139
- liveCount: (await registeredDecks().catch(() => [])).length,
140
- isTTY,
141
- profile,
142
- columns: termColumns(process.stdout),
143
- backgroundLine: ` ${tone.muted}${G.dash} running in the background ${G.bullet} \`${stop}\` ends it${tone.reset}\n${offer}\n`,
144
- });
145
- // detachAndWatch never returns on the paths that worked. Reaching this line
146
- // means the log could not be opened at all — a read-only home, a full disk —
147
- // and a deck that will not start over a LOG is a worse answer than one that
148
- // stays in the terminal, which is what every version before this did anyway.
149
- console.error(`${PRODUCT}: could not open ${PRODUCT}'s log (${outcome.reason}) ${G.dash} staying in the foreground.`);
150
- }
151
-
152
- claimRestartFailureKey();
153
-
154
- // The port the worker actually bound, which is not necessarily the one it was
155
- // asked for — the first launch falls back to a random port when 4317 is taken.
156
- // Re-launching without this is how a restart silently moves the deck out from
157
- // under an open tab.
158
- let boundPort = null;
159
- let restarts = 0;
160
- let child = null;
161
- // The npx process fetching a replacement, while the worker above keeps serving.
162
- // Held separately from `child` for exactly that reason: for the length of a
163
- // fetch there are two of them, and only one is the deck.
164
- let fetching = null;
165
- // The upgrade this supervisor has committed to, from the moment the pre-flight
166
- // allowed it until the replacement is serving or the attempt has been written
167
- // off. giveUp runs long after the decision that permitted the attempt and has
168
- // to record that decision's count, not a fresh one.
169
- let attempting = null;
170
- // Set by the signal handlers, and the first thing every exit path asks. Without
171
- // it, Ctrl+C during an npx fetch looks exactly like a failed fetch, and a
172
- // worker that was already exiting 75 or 76 when the signal landed reads as a
173
- // restart request — both resurrect the deck the user just stopped.
174
- let stopping = false;
175
- // When this supervisor has put a crashed worker back, pruned to the window the
176
- // ceiling is counted in. See crashPolicy: a deck that dies once an hour comes
177
- // back every time; one that dies five times in ten minutes is broken, and the
178
- // answer to broken is to stop and say so rather than to spin.
179
- let crashes = [];
180
-
181
- function launch(respawn) {
182
- // Asked on every spawn rather than once at boot: the deletion this catches
183
- // happens while the deck is running, so a constant read at import time would
184
- // be the one answer that cannot see it. See supervisor.mjs for the whole case.
185
- const replaced = replacedNote({
186
- workerExists: existsSync(WORKER),
187
- moved: successorRoot(PKG_ROOT),
188
- product: PRODUCT,
189
- command: INVOKED_AS ?? PRODUCT,
190
- });
191
- if (replaced) {
192
- console.error(replaced);
193
- process.exit(1);
194
- }
195
- const args = [WORKER, ...process.argv.slice(2)];
196
- // Appended last so it wins: the worker's parser keeps the final --port.
197
- if (respawn && boundPort != null) args.push("--port", String(boundPort));
198
-
199
- const worker = spawn(process.execPath, args, {
200
- // stdio inherited so the child owns the same terminal the user started:
201
- // same banner, same colours, same Ctrl+C. The fourth slot adds an IPC
202
- // channel — the only way the worker can tell us which port it got, since
203
- // parsing its stdout would be guesswork.
204
- stdio: ["inherit", "inherit", "inherit", "ipc"],
205
- env: {
206
- ...process.env,
207
- // Boot did the slow, once-per-session work already (hook install, the
208
- // claude-swap probe with its 8s timeout, the ccusage prime). Repeating it
209
- // is what would make a restart feel like a restart.
210
- AGENTS_DECK_RESPAWN: respawn ? "1" : "",
211
- AGENTS_DECK_RESTARTS: String(restarts),
212
- // Empty for "we could not tell", which knownCommand reads as unknown just
213
- // like an absent one. Set on the worker's environment only: the npx
214
- // relaunch below spawns a whole new supervisor, and that one has to ask
215
- // its own _npx directory rather than inherit an answer about a cache
216
- // directory it is not running from.
217
- AGENTS_DECK_INVOKED_AS: INVOKED_AS ?? "",
218
- },
219
- });
220
-
221
- child = worker;
222
-
223
- worker.on("message", (m) => {
224
- if (!m || typeof m !== "object") return;
225
- if (m.type === "listening" && typeof m.port === "number") boundPort = m.port;
226
- // The worker saying its boot is finished — every row printed, the browser
227
- // launched. Forwarded to whoever detached us, who has been tailing the log
228
- // into the user's terminal and is waiting for exactly this to stop.
229
- //
230
- // GUARDED BY `process.connected`, AND GIVEN A CALLBACK, and it took a real
231
- // crash restart to find out why: the launcher disconnects the moment the
232
- // first boot finishes, so every LATER boot forwards this down a dead
233
- // channel — and `process.send` on a closed channel does not throw where the
234
- // call is, it emits 'error' on `process` a tick later. Unhandled, that ends
235
- // the supervisor. So a try/catch here was decoration: the guard is the
236
- // check, and the callback is what turns the remaining race (disconnect
237
- // between the check and the send) into a value nobody has to catch.
238
- else if (m.type === "booted" && process.connected) {
239
- try { process.send({ type: "booted" }, () => {}); } catch { /* the launcher left */ }
240
- }
241
- // The worker asking to be replaced, while it is still serving. Answered by
242
- // prefetchUpgrade, which is the whole of this file's new shape: the fetch
243
- // happens here, and only then does that worker exit — see UPGRADE_CODE.
244
- else if (m.type === "upgrade") prefetchUpgrade(worker);
245
- });
246
-
247
- worker.on("exit", (code, signal) => {
248
- child = null;
249
- // A fetch is for the worker that asked for it. That worker is gone, so the
250
- // download is spent effort and the process holding it has to be stopped:
251
- // left running it would keep writing into the npx cache directory the next
252
- // attempt reads, minutes after the deck stopped waiting for it.
253
- if (fetching) { killTree(fetching); fetching = null; attempting = null; }
254
- // `stopping` outranks the exit code — see supervisor.mjs. A restart and a
255
- // Ctrl+C can land together, and honouring the code first is how the deck
256
- // came back to life after the user stopped it.
257
- const next = workerExitAction(code, stopping);
258
- if (next.relaunch === "disk") {
259
- restarts++;
260
- launch(true);
261
- return;
262
- }
263
- if (next.relaunch === "npx") {
264
- restarts++;
265
- launchNpx();
266
- return;
267
- }
268
- // THE DECK FELL OVER, and nobody is watching it any more.
269
- //
270
- // Before this ran in the background a crash was self-reporting: the terminal
271
- // came back with the stack on it. Detached, the first sign is noticing hours
272
- // later that a day of work was never recorded — so it goes back up. The
273
- // whole of which crashes qualify is in isCrash, and the ceiling that stops
274
- // this becoming a spin loop is in crashPolicy.
275
- if (isCrash({ code, signal, served: boundPort != null, stopping })) {
276
- const verdict = crashPolicy(crashes);
277
- if (verdict.restart) {
278
- crashes = verdict.history;
279
- const how = signal ? `killed by ${signal}` : `exit ${code}`;
280
- console.error(`${PRODUCT}: the deck stopped on its own (${how}) ${G.dash} starting it again in ${Math.round(verdict.delayMs / 1000)}s (${verdict.recent}/${CRASH_CEILING}).`);
281
- restarts++;
282
- // Unref'd would be wrong here: this timer IS the supervisor's reason to
283
- // stay alive, and without it the event loop empties and the process
284
- // exits before the deck it promised to bring back.
285
- setTimeout(() => { if (!stopping) launch(true); }, verdict.delayMs);
286
- return;
287
- }
288
- // Said once, with the two numbers that make it actionable, and then this
289
- // process really does end — a supervisor that keeps trying forever is the
290
- // failure the ceiling exists to prevent.
291
- console.error(`${PRODUCT}: the deck has stopped ${CRASH_CEILING} times in ${Math.round(CRASH_WINDOW_MS / 60000)} minutes ${G.dash} not starting it again. Run \`${INVOKED_AS ?? PRODUCT}\` when you have looked at the log above.`);
292
- }
293
-
294
- // Anything else is the worker's own verdict and belongs to whoever started
295
- // us — including the ccdeck wrapper, which exits with our code in turn. A
296
- // worker killed by a signal is reported by dying of the same one; doing
297
- // that naively re-enters the handlers below and exits 0 — see
298
- // supervisor.mjs.
299
- if (signal) dieOfSignal(signal);
300
- else process.exit(next.code);
301
- });
302
-
303
- worker.on("error", (err) => {
304
- console.error(`${PRODUCT}: could not start ${WORKER}: ${err.message}`);
305
- process.exit(1);
306
- });
307
- }
308
-
309
- /** Drop the two flags launchNpx sets itself. `--port` takes a value, and both
310
- * spellings npm's parser accepts (`--port 4317`, `--port=4317`) have to go. */
311
- function withoutPortAndOpen(args) {
312
- const out = [];
313
- for (let i = 0; i < args.length; i++) {
314
- const a = args[i];
315
- if (a === "--no-open") continue;
316
- if (a === "--port") { i++; continue; } // and its value
317
- if (a.startsWith("--port=")) continue;
318
- out.push(a);
319
- }
320
- return out;
321
- }
322
-
323
- /**
324
- * Which package an npx upgrade here names, and the spec that installs it — or
325
- * null when this deck was not started by npx and there is nothing to re-run.
326
- *
327
- * One function because there is one answer, and the worker above us reaches it
328
- * independently: `upgradeName` in self-update.mjs resolves the npx case as
329
- * `bareSpecName(npxRestartSpec(pkgRoot, installedName(pkgRoot)))`, and
330
- * everything registry-shaped on that side — the dist-tag it fetched, the marker
331
- * it cached the answer in, the failure note the browser reads — is keyed by it.
332
- * This supervisor keyed the same four things off `npxRestartSpec(PKG_ROOT)`
333
- * with the module's own default, which is the literal string "agents-deck".
334
- *
335
- * Both read `_npx/<hash>/package.json`, so they agreed for as long as that
336
- * metadata was readable, and #340 made the disagreement matter when it is not:
337
- * the three names are one tarball now, so the manifest under a `npx ccdeck` run
338
- * genuinely says `ccdeck` where it once always said `agents-deck`. A cache
339
- * directory written by an npm whose `_npx.packages` layout differs — or a
340
- * truncated file — split the two apart: the worker asked npm about `ccdeck` and
341
- * read `.restart-failed-ccdeck-<pid>` while this process wrote
342
- * `.restart-failed-agents-deck-<pid>`, took `lastKnownLatest("agents-deck")`
343
- * off a marker nobody had written — so `target` was null and upgradeAttempt's
344
- * per-target cap degraded to "any failure counts" — and relaunched
345
- * `npx -y agents-deck@latest`, moving a `npx ccdeck` user onto a different
346
- * published name. The note went where the browser never looks, which is the
347
- * exact class of bug NOTE_PREFIX's naming exists to prevent.
348
- *
349
- * So the fallback is the name npm's rename wrote into this build's own manifest,
350
- * which is what the worker falls back to as well. Read on each call rather than
351
- * once at import for the reason installedVersion gives: the manifest is a file
352
- * on disk, and this process outlives more than one of them.
353
- */
354
- function npxUpgrade() {
355
- const self = installedName(PKG_ROOT);
356
- const spec = npxRestartSpec(PKG_ROOT, self);
357
- return spec ? { spec, pkgName: bareSpecName(spec) ?? self } : null;
358
- }
359
-
360
- /** The note the browser reads, written by the only process that knows why an
361
- * upgrade did not happen. `failedAt` is when the FETCH failed, which a refusal
362
- * restating an earlier failure carries forward — see upgradeAttempt. */
363
- function noteFailure({ pkgName, spec, error, target, attempts, failedAt = Date.now() }) {
364
- recordRestartFailure({
365
- name: pkgName,
366
- command: `npx -y ${spec}`,
367
- error,
368
- version: VERSION === "?" ? null : VERSION,
369
- target,
370
- attempts,
371
- failedAt,
372
- });
373
- }
374
-
375
- /**
376
- * Fetch the replacement while the deck this supervisor started keeps serving.
377
- *
378
- * The order used to be: kill the working deck, attempt, fail, rebuild the deck.
379
- * Every failed upgrade was therefore a real interruption — the SSE stream
380
- * dropped, hook events fired into the gap lost outright, the canvas back with
381
- * tools stuck in flight — and it was paid in full even when the update never
382
- * had a chance of working. Reported from a terminal that had been left alone:
383
- * the same version, the same ETARGET, the same teardown, four times over.
384
- *
385
- * npm can resolve and download without the deck dying. So the fetch is done
386
- * first, and the worker is only asked to exit once there is something to hand
387
- * the port to. There is never a second server: npxPrefetch installs the package
388
- * and runs nothing out of it, and the replacement is spawned only after this
389
- * process has watched the old worker's exit.
390
- *
391
- * A refusal costs nothing but the click. The deck keeps its port, its stream
392
- * and its hooks; the failure reaches the browser through the note, exactly as a
393
- * failed fetch always has.
394
- */
395
- async function prefetchUpgrade(worker) {
396
- if (stopping || fetching || attempting) return;
397
- const reply = (msg) => { try { worker.send?.(msg); } catch { /* the worker is gone */ } };
398
-
399
- const upgrade = npxUpgrade();
400
- // Not an npx run after all, so there is nothing to fetch and the files on
401
- // disk are already the newest this deck can reach.
402
- if (!upgrade) { reply({ type: "upgrade-refused", error: "this deck was not started by npx" }); return; }
403
- const { spec, pkgName } = upgrade;
404
- // What the banner offered, straight off the marker the version check writes.
405
- // The note is keyed by it so that "this exact version already failed here" is
406
- // a question anything can answer — and so a newer release clears the slate.
407
- const target = lastKnownLatest(pkgName);
408
-
409
- const note = readRestartFailure(pkgName);
410
- const decision = upgradeAttempt({ note, target, now: Date.now() });
411
- if (!decision.allow) {
412
- const error = upgradeRefusalText({ ...decision, dash: G.dash }, target);
413
- // Re-stamped even though nothing was attempted: the tab ends its attempt on
414
- // a failure note it has not seen before, so a refusal that left the note
415
- // untouched would leave the button reading "fetching…" for the full three
416
- // minutes it allows, over a deck that never went anywhere.
417
- noteFailure({
418
- pkgName, spec, error, target,
419
- attempts: decision.attempt,
420
- failedAt: typeof note?.failedAt === "number" ? note.failedAt : Date.now(),
421
- });
422
- reply({ type: "upgrade-refused", error });
423
- return;
424
- }
425
-
426
- attempting = { pkgName, spec, target, attempt: decision.attempt };
427
- process.stdout.write(`\n ${P.warn}${G.restart}${P.reset} ${P.muted}fetching ${spec}${G.ellipsis}${P.reset}\n`);
428
- const got = await npxPrefetch(spec, { onChild: (c) => { fetching = c; } });
429
- fetching = null;
430
- // Ctrl+C, or a worker that died on its own while npm was working: either way
431
- // the deck this fetch was for is not there to be replaced, and the exit path
432
- // that noticed has already had its say.
433
- if (stopping || child !== worker) { attempting = null; return; }
434
- if (got.ok) { reply({ type: "upgrade-ready" }); return; }
435
-
436
- const error = [got.error, got.hint].filter(Boolean).join(" — ");
437
- noteFailure({ pkgName, spec, error, target, attempts: decision.attempt });
438
- attempting = null;
439
- reply({ type: "upgrade-refused", error });
440
- }
441
-
442
- /**
443
- * Come back on a newer version, for a deck that npx started.
444
- *
445
- * There is nothing to install here: npx unpacks each spec into its own
446
- * content-addressed directory under _npx, so `npm i -g` would upgrade something
447
- * this process could never reach. `npx -y <spec>@latest` resolves fresh, gets a
448
- * DIFFERENT directory, and starts the deck there — on the port we hand it, so
449
- * the tab that asked for the update reconnects to the same URL.
450
- *
451
- * This process stays as the parent rather than exec-ing, for the same reasons
452
- * the file's header gives, plus one more: a fetch can fail (offline, registry
453
- * down), and someone has to bring the working copy back when it does.
454
- */
455
- function launchNpx() {
456
- const upgrade = npxUpgrade();
457
- if (!upgrade) { launch(true); return; } // not an npx run after all
458
- // The same package prefetchUpgrade fetched and the worker asked npm about —
459
- // one answer, so the spec that is run, the note that records it and the
460
- // marker its target came from all name one package. See npxUpgrade.
461
- const { spec, pkgName } = upgrade;
462
- // Our two are appended, so the originals are dropped rather than left to be
463
- // overridden — `--port 4317 --no-open --port 4317 --no-open` works, but it is
464
- // what the next person reads in `ps`.
465
- const args = ["-y", spec, ...withoutPortAndOpen(process.argv.slice(2))];
466
- if (boundPort != null) args.push("--port", String(boundPort));
467
- // The tab that asked for this is open and reconnecting; a second one would be
468
- // the deck talking over itself.
469
- args.push("--no-open");
470
-
471
- // A retry answers for itself: whatever the last attempt left on disk is about
472
- // to be replaced by this attempt's outcome, and leaving it there would keep
473
- // the browser showing an old failure over a running fetch.
474
- clearRestartFailure(pkgName);
475
-
476
- // No "fetching…" line here any more: prefetchUpgrade printed it and the
477
- // tarball is already unpacked, so this resolves out of the npx cache and the
478
- // next thing on screen is the new deck's own banner.
479
- //
480
- // npxLaunch prefers npm's own npx-cli.js next to this Node binary, which
481
- // needs no PATH lookup and no batch shim; the PATH shim is the fallback and
482
- // still goes through cmd.exe with each argument quoted.
483
- //
484
- // Not `shell: true` on either path. Everything after the spec is the user's
485
- // own argv, and Node would paste it into one command line unquoted:
486
- // `--workspace C:\Users\John Smith\proj` would reach the new deck as two
487
- // arguments, the second one silently dropped by its parser — an upgraded deck
488
- // watching the wrong directory.
489
- const { file, args: argv, opts } = npxLaunch(args);
490
- // stderr is piped rather than inherited so a crash can be summarised instead
491
- // of dumped: npm's MODULE_NOT_FOUND stack, with its `requireStack` and its
492
- // caret line, is not something a user of a DAG dashboard can act on. stdout
493
- // stays inherited — the new deck's banner, colours and URL line are the whole
494
- // point of the supervisor staying out of the way.
495
- const started = spawn(file, argv, { stdio: ["inherit", "inherit", "pipe"], ...opts });
496
- child = started;
497
-
498
- // Held, not discarded: the moment the replacement is serving, everything it
499
- // wrote goes to the terminal and every later byte passes straight through.
500
- // Until then it is only evidence for a failure that may not happen.
501
- let tail = "";
502
- let teeing = false;
503
- const tee = () => {
504
- if (teeing) return;
505
- teeing = true;
506
- if (tail) process.stderr.write(tail);
507
- };
508
- started.stderr?.on("data", (d) => {
509
- const s = String(d);
510
- if (teeing) { process.stderr.write(s); return; }
511
- tail = (tail + s).slice(-8000);
512
- });
513
-
514
- // Whether the replacement ever got as far as serving. An npx that cannot
515
- // resolve exits in seconds having bound nothing; a deck the user stops with
516
- // Ctrl+C exits non-zero too, and only this tells the two apart.
517
- let served = false;
518
- const probe = setInterval(() => {
519
- if (boundPort == null || served) return;
520
- const sock = connect({ port: boundPort, host: "127.0.0.1" });
521
- sock.setTimeout(1000);
522
- sock.on("connect", () => { served = true; tee(); sock.destroy(); });
523
- sock.on("timeout", () => sock.destroy());
524
- sock.on("error", () => { /* not up yet */ });
525
- }, 1000);
526
- probe.unref?.();
527
-
528
- const giveUp = (why) => {
529
- clearInterval(probe);
530
- child = null;
531
- if (stopping || served) return; // the user stopped it, or it ran and ended
532
- const summary = npxFailureSummary(tail);
533
- const hint = npxFailureHint(tail);
534
- console.error(`${PRODUCT}: ${why} ${G.dash} staying on v${VERSION}`);
535
- if (summary) console.error(` ${summary}`);
536
- if (hint) console.error(` ${hint}`);
537
- // Left for the worker about to be launched: it is the only way the browser
538
- // learns this happened at all. See recordRestartFailure.
539
- //
540
- // This is the outage the pre-flight cannot spare anyone: the fetch worked,
541
- // the port was handed over, and the replacement still did not serve. It
542
- // counts against the same target as any other failed attempt, so a copy
543
- // that starts and dies is not offered forever either.
544
- noteFailure({
545
- pkgName, spec,
546
- error: [summary ?? why, hint].filter(Boolean).join(" — "),
547
- target: attempting?.target ?? null,
548
- attempts: attempting?.attempt ?? 1,
549
- });
550
- attempting = null;
551
- restarts++;
552
- launch(true);
553
- };
554
-
555
- started.on("exit", (code, signal) => {
556
- clearInterval(probe);
557
- child = null;
558
- if (stopping || served) {
559
- if (signal) dieOfSignal(signal);
560
- else process.exit(code ?? 0);
561
- return;
562
- }
563
- giveUp(`npx ${spec} exited ${code ?? signal}`);
564
- });
565
- started.on("error", (err) => giveUp(`could not run npx: ${err.message}`));
566
- }
567
-
568
- // Ctrl+C already reaches the child directly — it shares this process group — so
569
- // forwarding would deliver it twice. These handlers exist only to keep the
570
- // supervisor alive long enough for the child's own graceful shutdown to run and
571
- // for its exit code to arrive.
572
- for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
573
- process.on(sig, () => {
574
- // On Windows none of these are delivered to a Node process, which is fine:
575
- // there the console kills the whole tree and sweepStaleDiscovery cleans up
576
- // on the next boot.
577
- const second = stopping; // an impatient user pressing Ctrl+C again
578
- stopping = true;
579
- // A fetch in flight is not the deck and does not stop with it. On POSIX the
580
- // Ctrl+C that reached us reached it too — it is in this process group — but
581
- // a plain `kill` of the supervisor, or a systemd stop, is not, and on
582
- // Windows the shim path leaves npm as a grandchild of cmd.exe that only
583
- // killTree can reach. Stopped before the worker, since the worker's own
584
- // exit path is what ends this process.
585
- if (fetching) { killTree(fetching, second ? "SIGKILL" : sig); fetching = null; }
586
- if (!child) { process.exit(0); return; }
587
- try { child.kill(second ? "SIGKILL" : sig); } catch { /* already gone */ }
588
- // The npx step is a shell that exec's the new deck, and a signal can land in
589
- // the gap: the shell dies, the process replacing it never saw it. One more
590
- // attempt a moment later catches that; it is a no-op when the first worked.
591
- if (!second) {
592
- setTimeout(() => {
593
- if (stopping && child) { try { child.kill("SIGTERM"); } catch { /* gone */ } }
594
- }, 2500).unref();
595
- }
596
- });
597
- }
598
-
599
- // And the same leash the worker is on, one link up (#702).
600
- //
601
- // Inert for every published way of starting the deck: `ccdeck`, `npx ccdeck` and
602
- // the npx relaunch in launchNpx all spawn this file with no IPC channel, so
603
- // dieWithParent declines to arm and this line costs nothing. It is armed by a
604
- // harness that DOES hand one over — the suite's spawnSupervised — and what it
605
- // buys there is the case no teardown can cover, because the teardown is not
606
- // running: a vitest worker killed outright leaves this supervisor orphaned, and
607
- // an orphaned supervisor keeps a worker alive under it.
608
- //
609
- // Taking the worker with us is the whole of the stop. `stopping` first, so the
610
- // worker's exit is not read as a request to bring it back; the kill is the
611
- // signal on POSIX and, through killTree, `taskkill /T /F` on Windows, which has
612
- // neither SIGTERM nor a process group to aim at.
613
- // NOT IN THE DETACHED COPY. There the parent is the launcher, which disconnects
614
- // and exits the moment the deck is up — by design — and arming this would have
615
- // the deck kill itself a second after every successful start.
616
- if (!DETACHED) dieWithParent(() => {
617
- stopping = true;
618
- if (fetching) { killTree(fetching, "SIGTERM"); fetching = null; }
619
- if (child) killTree(child, "SIGTERM");
620
- // Long enough for the worker to unregister its discovery file and let the
621
- // port go, short enough that nothing is waiting on us. Unref'd: if the worker
622
- // leaves first its exit handler ends this process and the timer never fires.
623
- setTimeout(() => process.exit(0), 3000).unref();
624
- });
625
-
626
- launch(false);