agent-dag 3.22.1 → 3.22.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.
- package/README.md +6 -477
- package/package.json +14 -48
- package/shim.js +107 -0
- package/LICENSE +0 -661
- package/LICENSING.md +0 -82
- package/THIRD_PARTY_NOTICES.md +0 -395
- package/bin/agent-dag.js +0 -626
- package/bin/deck.js +0 -1805
- package/dist/web/assets/index-CJYsv0lr.css +0 -1
- package/dist/web/assets/index-Ifm23DDC.js +0 -270
- package/dist/web/index.html +0 -49
- package/hook/hook.js +0 -542
- package/release-notes.json +0 -398
- package/src/server/activity.mjs +0 -52
- package/src/server/agent-activity.mjs +0 -522
- package/src/server/args.mjs +0 -183
- package/src/server/auto-update.mjs +0 -79
- package/src/server/block-notify.mjs +0 -173
- package/src/server/boot-deadline.mjs +0 -127
- package/src/server/brand.mjs +0 -16
- package/src/server/browser-history.mjs +0 -497
- package/src/server/browser-presence.mjs +0 -211
- package/src/server/browser-profiles.mjs +0 -279
- package/src/server/browser-react.mjs +0 -284
- package/src/server/browser-watch-store.mjs +0 -350
- package/src/server/browser-watch.mjs +0 -905
- package/src/server/ccusage.mjs +0 -1168
- package/src/server/claude-accounts.mjs +0 -951
- package/src/server/claude-dir.mjs +0 -213
- package/src/server/codex-auth.mjs +0 -388
- package/src/server/codex-dir.mjs +0 -171
- package/src/server/codex-quota.mjs +0 -449
- package/src/server/codex-usage.mjs +0 -512
- package/src/server/cswap-admin.mjs +0 -1562
- package/src/server/cswap-auto.mjs +0 -658
- package/src/server/cswap-install.mjs +0 -641
- package/src/server/deck-home.mjs +0 -243
- package/src/server/deck-prefs.mjs +0 -301
- package/src/server/deck-probe.mjs +0 -111
- package/src/server/detach.mjs +0 -244
- package/src/server/exec.mjs +0 -996
- package/src/server/global-install.mjs +0 -67
- package/src/server/hwmonitor.mjs +0 -56
- package/src/server/index.mjs +0 -6043
- package/src/server/installer.mjs +0 -912
- package/src/server/invoked-as.mjs +0 -144
- package/src/server/lan-about.mjs +0 -119
- package/src/server/lan-engine.mjs +0 -952
- package/src/server/lan-reach.mjs +0 -256
- package/src/server/lan-socket.mjs +0 -682
- package/src/server/lan-sync.mjs +0 -941
- package/src/server/lhm-parse.mjs +0 -91
- package/src/server/log-tail.mjs +0 -139
- package/src/server/log-writer.mjs +0 -322
- package/src/server/login-service.mjs +0 -473
- package/src/server/macmon.mjs +0 -310
- package/src/server/npx.mjs +0 -264
- package/src/server/open-url.mjs +0 -242
- package/src/server/presence.mjs +0 -40
- package/src/server/quota.mjs +0 -792
- package/src/server/relay-guard.mjs +0 -507
- package/src/server/reset-label.mjs +0 -78
- package/src/server/retire-sound-hook.mjs +0 -349
- package/src/server/running-deck.mjs +0 -234
- package/src/server/self-update.mjs +0 -1380
- package/src/server/stop-deck.mjs +0 -171
- package/src/server/supervisor.mjs +0 -392
- package/src/server/system-metrics.mjs +0 -1825
- package/src/server/term.mjs +0 -686
- 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);
|