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.
package/bin/deck.js ADDED
@@ -0,0 +1,1051 @@
1
+ #!/usr/bin/env node
2
+ // The deck itself: registers hooks, starts the server, opens the browser.
3
+ // Launched by the supervisor in bin/agent-dag.js, which restarts it when it
4
+ // exits with RESTART_CODE. On a respawn (AGENTS_DECK_RESPAWN=1) everything that
5
+ // was already done once this session is skipped — that is what makes a restart
6
+ // take about a second instead of the better part of ten.
7
+ import { resolve, dirname, join } from "node:path";
8
+ import { fileURLToPath, pathToFileURL } from "node:url";
9
+ import { existsSync, readFileSync } from "node:fs";
10
+ import { dieOfSignal, dieWithParent } from "../src/server/supervisor.mjs";
11
+ import { isPortValue, parseArgs } from "../src/server/args.mjs";
12
+ import {
13
+ CURSOR_HIDE, CURSOR_SHOW, colorProfile, fit, glyphs, labelColumn, link, motionOK, oneLine,
14
+ palette, pulseText, spinnerFrames, statusLine, supportsHyperlinks, termColumns, unicodeOK,
15
+ unregisteredDetail, wordmark,
16
+ } from "../src/server/term.mjs";
17
+ import { PRODUCT } from "../src/server/brand.mjs";
18
+ import { invokedName, renameNotice } from "../src/server/invoked-as.mjs";
19
+
20
+ const __dirname = dirname(fileURLToPath(import.meta.url));
21
+ const PKG_ROOT = resolve(__dirname, "..");
22
+ const PKG_VERSION = (() => {
23
+ try { return JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8")).version ?? "0.0.0"; }
24
+ catch { return "0.0.0"; }
25
+ })();
26
+
27
+ // The command the user typed, handed down by the supervisor — our own argv[1]
28
+ // is this file under every one of the three names. Null when it cannot be
29
+ // proven, and null is the answer that prints nothing.
30
+ const INVOKED_AS = invokedName({ pkgRoot: PKG_ROOT });
31
+
32
+ const argv = process.argv.slice(2);
33
+ const flags = parseArgs(argv);
34
+
35
+ // Exit codes the supervisor reads as "bring me back": 75 from the files on
36
+ // disk, 76 through npx — which is the only way an npx run reaches a newer
37
+ // version, since its directory is never upgraded in place. Anything else it
38
+ // forwards.
39
+ const RESTART_CODE = 75;
40
+ const UPGRADE_CODE = 76;
41
+ const RESPAWN = process.env.AGENTS_DECK_RESPAWN === "1";
42
+ const SUPERVISED = typeof process.send === "function";
43
+
44
+ if (flags.help) {
45
+ printHelp();
46
+ process.exit(0);
47
+ }
48
+
49
+ // The version, on stdout, and then nothing — no hooks, no port, no browser.
50
+ //
51
+ // This is the first thing anyone types at a CLI they do not know and the second
52
+ // thing anyone types when filing a bug, and until now it was the one question
53
+ // the deck answered by starting a server and never exiting. The banner has
54
+ // always carried the number, but only underneath a running deck, which is not
55
+ // an answer: it cannot be piped, and the process it comes with has to be killed.
56
+ //
57
+ // Bare, unprefixed, one line: `ccdeck --version` is read by scripts as often as
58
+ // by people, and `node --version` style ornamentation is what those scripts
59
+ // then have to strip. PKG_VERSION is the same read the banner uses.
60
+ if (flags.version) {
61
+ console.log(PKG_VERSION);
62
+ process.exit(0);
63
+ }
64
+
65
+ if (flags.uninstall) {
66
+ const { uninstallHooks, hasCodexInstalled } = await import(pathToFileURL(join(PKG_ROOT, "src/server/installer.mjs")).href);
67
+ // Anything that could not be taken out. An uninstall that removed nothing
68
+ // because it could not read the file has not uninstalled anything, and both
69
+ // the wording and the exit code have to say so: a user who is told it worked
70
+ // and still has our hooks firing on every event is worse off than one who is
71
+ // told it failed, because they have stopped looking.
72
+ let refused = false;
73
+ // Files already reported as unparseable. The Claude hooks and the sound hook
74
+ // live in the SAME settings.json, so a stray comma refuses both, and printing
75
+ // the whole path-plus-parser-error twice buries the one line that differs —
76
+ // which of our two installations is still in there.
77
+ const named = new Set();
78
+ /** Report one provider's outcome. `ok` first — see uninstallHooks. */
79
+ const report = (res, label) => {
80
+ if (res.ok === false) {
81
+ refused = true;
82
+ named.add(res.settingsPath);
83
+ console.error(`${PRODUCT}: ${label} hooks NOT removed — ${res.settingsPath} could not be read as JSON (${res.why}).`);
84
+ console.error(`${PRODUCT}: the __agent-dag hook entries are still in that file and keep firing on every ${label} event.`);
85
+ return;
86
+ }
87
+ console.log(res.changed
88
+ ? `${PRODUCT}: hooks removed from ${res.settingsPath}`
89
+ : `${PRODUCT}: no ${label} hooks to remove`);
90
+ };
91
+ report(await uninstallHooks({ provider: "claude" }), "Claude");
92
+ // The old finish sound was a second entry in the same file, marked
93
+ // __agent-dag-sound rather than __agent-dag, and uninstallHooks does not know
94
+ // that mark — so it used to be left behind, playing on every turn after the
95
+ // deck was supposedly gone. #704 retired the mechanism outright, but the entry
96
+ // is still on every machine that had it, so removing it is still this
97
+ // command's job. So is the other half: turning the sound on parked the user's
98
+ // own afplay/PowerShell Stop hooks, and once the deck is uninstalled nothing
99
+ // else on the machine knows where they went.
100
+ const { retireSoundHook } = await import(pathToFileURL(join(PKG_ROOT, "src/server/retire-sound-hook.mjs")).href);
101
+ const sound = await retireSoundHook();
102
+ if (sound.removed) console.log(`${PRODUCT}: sound hook removed`);
103
+ if (sound.restored) console.log(`${PRODUCT}: restored ${sound.restored} of your own sound hook(s)`);
104
+ if (sound.ok === false) {
105
+ refused = true;
106
+ // Two different refusals, and saying the wrong one sends the user to the
107
+ // wrong file. `settings_unreadable` means nothing was touched at all — and
108
+ // when the forwarders already named that same file, the whole path and
109
+ // parser error would only bury the one line that differs. `parked_unreadable`
110
+ // is the other file: our entry IS out (the lines above said so), and what is
111
+ // still owed is the user's own hooks, which stay parked until they repair it.
112
+ if (sound.reason === "settings_unreadable") {
113
+ console.error(named.has(sound.settingsPath)
114
+ ? `${PRODUCT}: the sound hook is still in that file too.`
115
+ : `${PRODUCT}: sound hook left in place — ${sound.message}`);
116
+ named.add(sound.settingsPath);
117
+ } else {
118
+ console.error(`${PRODUCT}: your own sound hooks were NOT restored — ${sound.message}`);
119
+ }
120
+ }
121
+ if (hasCodexInstalled()) {
122
+ report(await uninstallHooks({ provider: "codex" }), "Codex");
123
+ }
124
+ // The remedy last and once, after every symptom above it, rather than once
125
+ // per refusal in the middle of the list.
126
+ if (named.size > 0) {
127
+ console.error(`${PRODUCT}: repair the JSON (or move the file aside), then run \`${PRODUCT} --uninstall\` again.`);
128
+ }
129
+ // Non-zero when any half of it refused, so `ccdeck --uninstall && …` and every
130
+ // CI step that runs this stops on the failure instead of continuing past it.
131
+ process.exit(refused ? 1 : 0);
132
+ }
133
+
134
+ // The port, and the one piece of argv the deck really does refuse to boot over.
135
+ //
136
+ // It refused before too — `--port banana` and `--port --no-open` both became
137
+ // `Number(…)` → `NaN`, which survived the whole startup (hooks installed,
138
+ // claude-swap installed, ccusage probed) and then killed the process from inside
139
+ // `listen` with Node's own wording: "options.port should be >= 0 and < 65536.
140
+ // Received type number (NaN)." That names neither the flag nor the value the
141
+ // user typed, and arrives after a page of green ticks. Same outcome, said here:
142
+ // early, in the deck's own voice, quoting the flag and the value back.
143
+ //
144
+ // An empty `AGENT_DAG_PORT` is an unset one — a variable that did not expand is
145
+ // not a request for port zero. `--port ""` never reaches this, because the
146
+ // parser records an empty value as `incomplete` and leaves the flag unset.
147
+ const envPort = process.env.AGENT_DAG_PORT?.trim();
148
+ const rawPort = flags.port ?? (envPort ? envPort : null);
149
+ if (rawPort != null && !isPortValue(rawPort)) {
150
+ const named = flags.port != null ? "--port" : "AGENT_DAG_PORT";
151
+ console.error(`${PRODUCT}: ${named} ${rawPort}: not a port number — expected 0–65535.`);
152
+ process.exit(1);
153
+ }
154
+ const port = rawPort == null ? 4317 : Number(rawPort);
155
+ // Default = machine-wide (capture every CC session on this box). Pass
156
+ // `--workspace <path>` (or `--scope`) to restrict to a single tree. Canonicalized
157
+ // just below, once the module that owns that rule is loaded.
158
+ const rawWorkspace = flags.workspace != null
159
+ ? flags.workspace
160
+ : (flags.scope ? process.cwd() : "");
161
+ const openBrowser = flags.noOpen !== true;
162
+ // The events log lives beside the discovery files, so it follows the Claude
163
+ // config dir rather than assuming ~/.claude — see src/server/claude-dir.mjs.
164
+ const { claudeConfigDir, hasClaudeInstalled } =
165
+ await import(pathToFileURL(join(PKG_ROOT, "src/server/claude-dir.mjs")).href);
166
+ // Resolved here rather than left as typed: the discovery file publishes this
167
+ // path so the hook can tell which decks share one log and elect a single
168
+ // writer for it, and two spellings of one file would read as two files.
169
+ // startServer resolves it the same way, from this same process, so the two
170
+ // always name the same file.
171
+ const persist = flags.noPersist
172
+ ? null
173
+ : resolve(flags.history ?? join(claudeConfigDir(), "agent-dag", "events.jsonl"));
174
+
175
+ const { installHooks, keepDiscovery, removeDiscovery, hasCodexInstalled } =
176
+ await import(pathToFileURL(join(PKG_ROOT, "src/server/installer.mjs")).href);
177
+ // CODEX_SESSIONS_DIR comes along because the banner below names the directory
178
+ // the watcher tails, and the watcher lives in that module. Recomputing the path
179
+ // here is how the banner came to print ~/.codex/sessions on machines whose
180
+ // sessions are somewhere else entirely — see the row further down.
181
+ const { startServer, hookToken, releaseRestart, markDeckReady, CODEX_SESSIONS_DIR, canonicalWorkspace } =
182
+ await import(pathToFileURL(join(PKG_ROOT, "src/server/index.mjs")).href);
183
+
184
+ // Resolved here rather than left as typed, for the reason the events log above
185
+ // is: the discovery file publishes this path, and the hook that reads it runs in
186
+ // a process whose cwd is the agent's — so a relative `--workspace ./sub` meant
187
+ // one directory to the Codex watcher inside this process and a different one per
188
+ // agent to the hook. One canonical spelling, computed in the one process that
189
+ // knows what the user meant, is what both capture paths compare against. See
190
+ // canonicalWorkspace.
191
+ const workspace = canonicalWorkspace(rawWorkspace);
192
+
193
+ // Whether the server starts the Codex rollout watcher. Nothing is installed
194
+ // and no directory is created either way — Codex hooks are not used any more,
195
+ // so `--codex` only means "watch even though ~/.codex/ is not there yet",
196
+ // which is the right answer for a machine where Codex arrives later.
197
+ const wantCodex = flags.noCodex
198
+ ? false
199
+ : (flags.codex === true || hasCodexInstalled());
200
+
201
+ // The same question for the other CLI, and the one nobody was asking. README
202
+ // offers "Claude Code CLI or OpenAI Codex CLI (or both)"; a Codex-only machine
203
+ // nonetheless got a Python account-switcher installed for a CLI it does not
204
+ // have, an accounts panel open on first run, and a banner line telling it to
205
+ // sign into that CLI (#402). Everything the deck installs or opens on the
206
+ // Claude side now hangs off this one answer, and it is stated in the banner so
207
+ // a wrong answer is visible rather than mysterious.
208
+ //
209
+ // `--claude` is the escape hatch for a false negative, which is the failure
210
+ // that matters: hasClaudeInstalled looks for the binary and for traces of use,
211
+ // and a machine that hides Claude Code from both would otherwise lose its hooks
212
+ // with no way to ask for them back. `--no-claude` is the opt-out the Claude side
213
+ // never had — the mirror of --no-codex — and it is also what a Codex-only user
214
+ // with a settings.json the installer refuses to rewrite needs, since that
215
+ // refusal is fatal at boot on a component they do not use.
216
+ const wantClaude = flags.noClaude
217
+ ? false
218
+ : (flags.claude === true || hasClaudeInstalled());
219
+
220
+ const WEB_DIST = join(PKG_ROOT, "dist", "web", "index.html");
221
+ if (!existsSync(WEB_DIST)) {
222
+ console.error(`${PRODUCT}: ui not built. run \`npm run build\` (or \`pnpm build\`) first.`);
223
+ process.exit(1);
224
+ }
225
+
226
+ // ── the terminal we are printing into ─────────────────────────────────────────
227
+ // Asked once, degraded from there — see src/server/term.mjs, which is where all
228
+ // of this is decided and asserted. Below this point the deck writes no escape of
229
+ // its own: colour comes from `P`, glyphs from `G`, layout from statusLine. That
230
+ // is what makes NO_COLOR, a pipe, a CI log and a legacy Windows console one
231
+ // question rather than thirty separate ones nobody remembers to ask.
232
+ const tty = Boolean(process.stdout.isTTY);
233
+ const PROFILE = colorProfile({ isTTY: tty });
234
+ const P = palette(PROFILE);
235
+ const UNICODE = unicodeOK();
236
+ const G = glyphs(UNICODE);
237
+ const LINKS = supportsHyperlinks({ profile: PROFILE });
238
+ // The terminal's prefers-reduced-motion: nothing sleeps, spins or repaints in a
239
+ // pipe, under CI, or with NO_COLOR set.
240
+ const MOTION = motionOK({ isTTY: tty, profile: PROFILE });
241
+ const write = (s) => process.stdout.write(s);
242
+ // Read per line, never cached: a terminal can be resized while the deck runs,
243
+ // and the pulse below is still on screen hours later.
244
+ const cols = () => termColumns(process.stdout);
245
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
246
+ const fileLink = (path) => link(path, pathToFileURL(path).href, LINKS);
247
+
248
+ // ── the cursor ────────────────────────────────────────────────────────────────
249
+ // Hidden for as long as anything of ours is moving — the reveal, the spinner,
250
+ // the pulse — and put back on every way out of this process: the ordinary exit,
251
+ // all three signals, and an uncaught throw, which reaches 'exit' after Node has
252
+ // printed it. Half of this is worse than none: a deck that dies with the cursor
253
+ // hidden leaves the user's shell with no cursor and nothing to do about it but
254
+ // `reset`.
255
+ let cursorHidden = false;
256
+ const showCursor = () => {
257
+ if (!cursorHidden) return;
258
+ cursorHidden = false;
259
+ try { write(CURSOR_SHOW); } catch { /* stdout is gone; nothing left to restore */ }
260
+ };
261
+ if (MOTION) { cursorHidden = true; write(CURSOR_HIDE); }
262
+ process.on("exit", showCursor);
263
+ // SIGHUP is the one signal this process does not otherwise handle, so its
264
+ // default action would end us before 'exit' could run. Handled only to put the
265
+ // cursor back and then die of it exactly as before — the supervisor reads the
266
+ // signal, not an exit code.
267
+ process.on("SIGHUP", () => { showCursor(); dieOfSignal("SIGHUP"); });
268
+
269
+ // ── rows ──────────────────────────────────────────────────────────────────────
270
+ // The status column is computed from the longest label. It used to be counted
271
+ // into each string as trailing spaces, so any new row, or any label a character
272
+ // longer, silently broke the alignment of every other one.
273
+ const LABELS = [
274
+ "workspace", "Claude hooks", "Codex sessions", "claude-swap", "accounts",
275
+ "ccusage", "update", "name", "server ready", "log", "unknown option",
276
+ "missing value",
277
+ ];
278
+ const LABEL_W = labelColumn(LABELS);
279
+
280
+ function row({ mark = " ", tone = P.ok, label = "", detail = "", detailTone = P.muted, keep = false }) {
281
+ return statusLine({
282
+ mark, label, detail, keep, labelWidth: LABEL_W, columns: cols(), ellipsis: G.ellipsis,
283
+ paint: {
284
+ mark: (s) => `${tone}${s}${P.reset}`,
285
+ detail: (s) => `${detailTone}${s}${P.reset}`,
286
+ },
287
+ }) + "\n";
288
+ }
289
+
290
+ // ── the wordmark ──────────────────────────────────────────────────────────────
291
+ async function printBanner() {
292
+ const { lines } = wordmark({ columns: cols(), version: PKG_VERSION, profile: PROFILE, unicode: UNICODE, pal: P });
293
+ for (const line of lines) {
294
+ write(line + "\n");
295
+ // A reveal, not a wait. The once-per-session work is already running under
296
+ // it (see startupWork), so the art costs the boot nothing and the deck is
297
+ // ready about when the last row lands. What used to be here — 560ms of
298
+ // spinner at "loading…" before a single art line — was dead time in a tool
299
+ // whose documented entry point is `npx ccdeck`.
300
+ if (MOTION && line) await sleep(45);
301
+ }
302
+ }
303
+
304
+ // ── a step, with a spinner only if it is slow enough to need one ──────────────
305
+ // The interval's first frame is 80ms away, so anything already settled when we
306
+ // get here paints nothing at all and the row below is the only trace of it.
307
+ async function step(label, work) {
308
+ if (!MOTION) return work;
309
+ const frames = spinnerFrames(UNICODE);
310
+ // Kept inside the terminal: a label that wraps is a label the \r below can
311
+ // only half erase, and what is left of it stays under the row that follows.
312
+ const text = fit(label, cols() - 6, G.ellipsis);
313
+ let i = 0;
314
+ const iv = setInterval(() => {
315
+ write(`\r ${P.accent}${frames[i++ % frames.length]}${P.reset} ${P.muted}${text}${P.reset}`);
316
+ }, 80);
317
+ try {
318
+ return await work;
319
+ } finally {
320
+ clearInterval(iv);
321
+ // Cleared rather than overwritten: the row that follows is a different
322
+ // length, and relying on it to be the longer of the two is how a spinner
323
+ // leaves its own tail on screen. Nothing to clear if it never painted.
324
+ if (i) write("\r" + " ".repeat(text.length + 5) + "\r");
325
+ }
326
+ }
327
+
328
+ /**
329
+ * The once-per-session work, all of it started at once and none of it awaited.
330
+ *
331
+ * Hook install, the claude-swap probe and the registry lookup have nothing to
332
+ * do with each other and nothing to do with the wordmark, so they run underneath
333
+ * the reveal instead of queueing behind it — the animation then costs the boot
334
+ * nothing and the deck is ready about when the last art row lands. Every one of
335
+ * them is given its rejection handler here, at the moment it is created, since a
336
+ * promise that settles before anything awaits it is otherwise an unhandled
337
+ * rejection.
338
+ */
339
+ function startupWork() {
340
+ // Every job below this line serves Claude Code and only Claude Code: the
341
+ // hooks go in Claude Code's settings.json, claude-swap switches Claude
342
+ // accounts, and ccusage reads Claude Code's own session logs. On a machine
343
+ // without Claude Code all three are work done for a CLI that is not there —
344
+ // two of them installs the user did not ask for — so they are not started at
345
+ // all rather than started and then reported as failures. `null` is how each
346
+ // one says "not attempted", which reportStartup tells apart from "tried and
347
+ // could not".
348
+
349
+ // Settings the installer cannot parse are settings it cannot rewrite without
350
+ // losing them, so it refuses — and that refusal is reported rather than
351
+ // thrown, because it is the only thing the user can act on.
352
+ const hooks = wantClaude
353
+ ? installHooks({ provider: "claude" }).then(v => ({ ok: true, v }), err => ({ ok: false, err }))
354
+ : Promise.resolve(null);
355
+
356
+ // claude-swap backs the multi-account panel, and an empty store leaves that
357
+ // panel useless even when the tool is there — so the account already signed
358
+ // in is registered once. Bounded inside seedFirstAccount: empty store only,
359
+ // once ever, never with NO_INSTALL set.
360
+ const cswap = (async () => {
361
+ if (!wantClaude) return null;
362
+ const { ensureCswap } = await import(pathToFileURL(join(PKG_ROOT, "src/server/cswap-install.mjs")).href);
363
+ const cs = await ensureCswap();
364
+ const usable = cs.state === "present" || cs.state === "installed" || cs.state === "upgrading";
365
+ if (!usable) return { cs, seed: null };
366
+ const { seedFirstAccount } = await import(pathToFileURL(join(PKG_ROOT, "src/server/claude-accounts.mjs")).href);
367
+ return { cs, seed: await seedFirstAccount().catch(() => ({ state: "failed" })) };
368
+ })().catch(() => null);
369
+
370
+ // ccusage backs the usage-history modal. Primed at boot rather than on first
371
+ // open so a cold machine pays the install while the deck is still starting.
372
+ // Nothing is lost by skipping the prime: runCcusage falls back to npx, so the
373
+ // modal still answers if it is ever opened — it just pays the wait itself.
374
+ const ccusage = (async () => {
375
+ if (!wantClaude) return null;
376
+ if (process.env.AGENTS_DECK_NO_INSTALL === "1") return null;
377
+ const { primeCcusage } = await import(pathToFileURL(join(PKG_ROOT, "src/server/ccusage.mjs")).href);
378
+ return primeCcusage();
379
+ })().catch(() => null);
380
+
381
+ // A newer release on npm, said once, in the place the upgrade gets typed.
382
+ // Hard-capped so a slow registry cannot delay the server — the answer is
383
+ // usually already cached in ~/.agents-deck/.self-update-check anyway. It has
384
+ // to resolve BEFORE the pulse indicator starts writing over the last line.
385
+ const update = Promise.race([
386
+ import(pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href)
387
+ .then(m => m.versionReport({ running: PKG_VERSION, pkgRoot: PKG_ROOT }))
388
+ .then(r => (r?.notice?.kind === "upgrade" ? r : null))
389
+ .catch(() => null),
390
+ new Promise(r => setTimeout(() => r(null), 1200)),
391
+ ]);
392
+
393
+ return { hooks, cswap, ccusage, update };
394
+ }
395
+
396
+ /** The same work, said out loud, in a fixed order — a boot whose rows arrive in
397
+ * whatever order the network settled is a boot nobody can scan twice. */
398
+ async function reportStartup(jobs) {
399
+ write(row({
400
+ mark: G.ok, label: "workspace",
401
+ detail: workspace === "" ? "(all)" : workspace,
402
+ detailTone: workspace === "" ? P.warn : P.muted,
403
+ }));
404
+
405
+ const hooks = await step(`installing Claude hooks${G.ellipsis}`, jobs.hooks);
406
+ if (hooks === null) {
407
+ // Said in the same shape as the Codex row below, because it is the same
408
+ // sentence: this deck is not watching that CLI, and here is why. It also
409
+ // retires the one boot failure a Codex-only machine could hit — an
410
+ // unparseable or unwritable settings.json used to exit(1) below, killing a
411
+ // deck over a file belonging to a CLI the user does not run.
412
+ write(row({ label: "Claude hooks", detail: `skipped ${G.dash} no Claude Code found, or --no-claude` }));
413
+ } else if (!hooks.ok) {
414
+ // The file it names is one only the user can repair, and every Claude Code
415
+ // session on this machine is reading it too.
416
+ write(row({ mark: G.fail, tone: P.err, label: "Claude hooks", detail: "not installed" }));
417
+ console.error(`\n ${PRODUCT}: ${hooks.err.message}\n`);
418
+ process.exit(1);
419
+ } else {
420
+ write(row({ mark: G.ok, label: "Claude hooks", detail: fileLink(hooks.v.hookPath) }));
421
+ }
422
+
423
+ // Codex CLI hooks never fire on Windows (sandbox refuses to spawn the hook
424
+ // command). Instead the server tails Codex's rollout JSONL files directly, so
425
+ // there's nothing to install and no /hooks trust step. We just confirm Codex
426
+ // is present and let the watcher pick up sessions.
427
+ if (wantCodex) {
428
+ // The directory the watcher actually tails, imported from the module that
429
+ // owns it rather than rebuilt from homedir() here. CODEX_HOME relocates the
430
+ // whole tree, and this row is the only diagnostic the deck prints about
431
+ // Codex — so when sessions do not show up, a path computed a second way
432
+ // sends the user to inspect a directory the deck never opened.
433
+ write(row({ mark: G.ok, label: "Codex sessions", detail: `watching ${fileLink(CODEX_SESSIONS_DIR)}` }));
434
+ } else {
435
+ write(row({ label: "Codex sessions", detail: `skipped ${G.dash} no ~/.codex/, or --no-codex` }));
436
+ }
437
+
438
+ const swap = await step(`checking claude-swap${G.ellipsis}`, jobs.cswap);
439
+ const cs = swap?.cs;
440
+ if (!wantClaude) {
441
+ // claude-swap is a Python tool that switches Claude Code accounts, and the
442
+ // deck used to fetch a uv binary to install it on machines with no Claude
443
+ // Code at all. Saying so is the point of the row: it is the one place a
444
+ // user can learn that the accounts panel is missing on purpose.
445
+ write(row({ label: "claude-swap", detail: `skipped ${G.dash} accounts are Claude-only` }));
446
+ } else if (cs?.state === "present") {
447
+ write(row({ mark: G.ok, label: "claude-swap", detail: `v${cs.version} (accounts panel enabled)` }));
448
+ } else if (cs?.state === "installed") {
449
+ write(row({ mark: G.ok, label: "claude-swap", detail: `installed v${cs.version} via ${cs.via}` }));
450
+ } else if (cs?.state === "upgrading") {
451
+ write(row({ mark: G.ok, label: "claude-swap", detail: `v${cs.version}, upgrading to v${cs.latest} in background` }));
452
+ } else if (cs?.state === "skipped") {
453
+ write(row({ mark: G.ok, label: "claude-swap", detail: "not installed (AGENTS_DECK_NO_INSTALL=1)" }));
454
+ } else {
455
+ const how = cs?.reason === "no_installer"
456
+ ? `not installed ${G.dash} the accounts panel needs it`
457
+ : cs?.reason === "not_on_path"
458
+ ? `installed via ${cs.via} but not on PATH ${G.dash} add ${
459
+ process.platform === "win32" ? "%USERPROFILE%\\.local\\bin" : "~/.local/bin"
460
+ }`
461
+ : `install failed${cs?.via ? ` via ${cs.via}` : ""}`;
462
+ write(row({ mark: G.fail, tone: P.warn, label: "claude-swap", detail: how }));
463
+ // A URL is not an answer when someone just wants the panel to work. Print
464
+ // the command for THIS machine, picked from what is already on it.
465
+ if (cs?.hint) write(row({ label: "", detail: cs.hint }));
466
+ }
467
+
468
+ if (swap?.seed?.state === "added") {
469
+ write(row({ mark: G.ok, label: "accounts", detail: "registered the signed-in account (cswap add)" }));
470
+ } else if (swap?.seed?.state === "failed" || swap?.seed?.state === "nothing-to-add") {
471
+ write(row({ label: "accounts", detail: `panel empty ${G.dash} sign in to Claude Code, then run cswap add` }));
472
+ }
473
+
474
+ const cu = await jobs.ccusage;
475
+ if (cu?.state === "present") write(row({ mark: G.ok, label: "ccusage", detail: `v${cu.version}` }));
476
+ else if (cu?.state === "updating") write(row({ mark: G.ok, label: "ccusage", detail: `v${cu.version}, checking for update` }));
477
+ // A ccusage the user provided, named rather than versioned — reading a
478
+ // version out of it means running it, and a status row is not worth a spawn.
479
+ // Naming the file is the more useful half anyway: it is the answer to "which
480
+ // ccusage is this deck actually going to run", which is a question a machine
481
+ // with a managed install AND a PATH copy could not answer before #433.
482
+ else if (cu?.state === "user") write(row({ mark: G.ok, label: "ccusage", detail: `your own copy ${G.dash} ${cu.bin}` }));
483
+ else if (cu?.state === "installing") write(row({ mark: G.ok, label: "ccusage", detail: "installing in background" }));
484
+
485
+ const upgrade = await jobs.update;
486
+ if (upgrade) {
487
+ write(row({
488
+ mark: G.up, tone: P.warn, label: "update",
489
+ detail: `v${upgrade.notice.to} available ${G.dash} ${upgrade.command}`,
490
+ }));
491
+ }
492
+
493
+ // Which name this deck was started under, when that is knowable — a notice,
494
+ // never a refusal. 95% of installs are on the two old names and the update
495
+ // path runs through this very process, so a build that declined to boot under
496
+ // one of them would kill the deck on the machine where the deck is what would
497
+ // have explained why. Nothing at all is printed wherever the typed name
498
+ // cannot be proven (a Windows global install, a git checkout): telling
499
+ // somebody who already types `ccdeck` to type `ccdeck` is the one failure
500
+ // that would make this row worth ignoring. See src/server/invoked-as.mjs.
501
+ const rename = renameNotice({ invoked: INVOKED_AS, pkgRoot: PKG_ROOT, dash: G.dash });
502
+ if (rename) {
503
+ write(row({ mark: G.warn, tone: P.warn, label: "name", detail: rename.said }));
504
+ // The line that carries the value: for a global install there is nothing to
505
+ // install and nothing to download, only six different characters to type.
506
+ write(row({ label: "", detail: rename.fix }));
507
+ }
508
+ }
509
+
510
+ // Asking the supervisor to bring us back. It is the only party that can, and
511
+ // only after this process is gone — which is precisely what keeps the
512
+ // replacement from racing this listener onto a random fallback port.
513
+ let restarting = false;
514
+ // Outer bound on the supervisor's answer below. It cannot be reached today —
515
+ // the fetch has a deadline of its own and every path through it replies — but
516
+ // `restarting` is a latch, and a latch with no way out is how a deck ends up
517
+ // silently refusing every restart for the rest of its life.
518
+ const UPGRADE_ANSWER_MS = 150_000;
519
+ let upgradeTimer = null;
520
+
521
+ // Whether the rest of this file has finished running.
522
+ //
523
+ // The server below starts accepting connections from inside startServer, before
524
+ // that call has returned — so /api/restart is reachable for the whole of the
525
+ // boot that follows it: the startup report, the port report to the supervisor,
526
+ // the discovery file and its first fsynced write, and on a cold start the
527
+ // browser spawn. A restart landing in that window used to reach `shutdown`
528
+ // before the binding holding it was initialised and die of a ReferenceError,
529
+ // having already set the latch above, with nothing left to clear it — after
530
+ // which every restart from every tab was answered "ok" and did nothing, for the
531
+ // life of the process (#448).
532
+ //
533
+ // So an ask that arrives too early is held rather than run: the user asked for
534
+ // something this deck can genuinely give a moment later, and refusing outright
535
+ // would put back the same silence in a politer form. BOOT_RESTART_MS is the
536
+ // outer bound, for the reason UPGRADE_ANSWER_MS above is one — and since #483
537
+ // moved the listen in front of the report, it is a bound that gets used: a boot
538
+ // waiting out a real `uv tool install` is minutes long, and the restart is the
539
+ // right answer to it rather than a casualty of it. Ten seconds in, the ask is
540
+ // run; the respawn skips the report entirely and is up in about a second.
541
+ let booted = false;
542
+ let heldRestart = null;
543
+ let bootTimer = null;
544
+ const BOOT_RESTART_MS = 10_000;
545
+
546
+ const requestRestart = (mode) => {
547
+ if (restarting) return;
548
+ restarting = true;
549
+ if (!booted) {
550
+ heldRestart = { mode };
551
+ bootTimer = setTimeout(() => { bootTimer = null; runHeldRestart(); }, BOOT_RESTART_MS);
552
+ bootTimer.unref?.();
553
+ // Said out loud for the same reason abandonUpgrade below is: the tab has
554
+ // already been told its restart was accepted, and a second of nothing
555
+ // happening on this terminal is otherwise indistinguishable from the bug
556
+ // this replaces.
557
+ write(`\n ${P.warn}${G.restart}${P.reset} ${P.muted}restart queued ${G.dash} still starting up${P.reset}\n`);
558
+ return;
559
+ }
560
+ beginRestart(mode);
561
+ };
562
+
563
+ // The restart itself, once there is a booted deck to end. Split out of
564
+ // requestRestart so the held ask above can re-enter it without tripping the
565
+ // latch it is already holding.
566
+ //
567
+ // Everything here runs inside one try: the whole point of #448 is that a throw
568
+ // on this path is not merely a failed restart but a permanent one, because the
569
+ // latch it leaves behind outlives it. There is no line in here worth dying for.
570
+ function beginRestart(mode) {
571
+ try {
572
+ // "npx" means the newer code is not on this disk at all, so it has to be
573
+ // fetched — and this process keeps serving while that happens. Exiting first
574
+ // is what made every failed upgrade an outage: the SSE stream dropped, hook
575
+ // events fired into the gap were lost outright (hook/hook.js is
576
+ // fire-and-forget with a 1s timeout and no retry), and the canvas came back
577
+ // with whatever was in flight stuck until the stale sweeper reaped it — all
578
+ // of it paid before anyone knew whether npm could even resolve the version.
579
+ // Nothing is torn down here now; the supervisor answers when it knows.
580
+ if (mode === "npx") {
581
+ upgradeTimer = setTimeout(() => abandonUpgrade("no answer from the supervisor"), UPGRADE_ANSWER_MS);
582
+ upgradeTimer.unref?.();
583
+ // Armed before the ask, not after: a send that throws is a supervisor that
584
+ // can no longer answer, and the deck has to come back out of the latch on
585
+ // its own rather than wait out an answer that cannot arrive.
586
+ try { process.send({ type: "upgrade" }); }
587
+ catch (err) { abandonUpgrade(err?.message ?? "the supervisor is no longer listening"); }
588
+ return;
589
+ }
590
+ const to = restartTarget();
591
+ write(`\n ${P.warn}${G.restart}${P.reset} ${P.muted}restarting${to ? ` ${G.arrow} v${to}` : ""}${G.ellipsis}${P.reset}\n`);
592
+ shutdown(RESTART_CODE);
593
+ } catch (err) {
594
+ abandonRestart(err);
595
+ }
596
+ }
597
+
598
+ // The ask that was waiting for the boot to finish, now that it has. Safe to
599
+ // call when nothing is waiting, which is every ordinary boot.
600
+ function runHeldRestart() {
601
+ if (!heldRestart) return;
602
+ const { mode } = heldRestart;
603
+ heldRestart = null;
604
+ clearTimeout(bootTimer);
605
+ bootTimer = null;
606
+ beginRestart(mode);
607
+ }
608
+
609
+ // A restart that could not be started, said out loud and then let go of.
610
+ //
611
+ // Both halves of the latch have to come down — this file's and the server's —
612
+ // because a latch nothing clears is precisely how one failed request turned
613
+ // into a deck that refused every restart afterwards while answering "ok" to
614
+ // each one (#448). The reason is folded onto one line by oneLine: the terminal
615
+ // under this is repainted every 800ms by the pulse, and a stack written into
616
+ // that is a stack nobody can read (#432).
617
+ //
618
+ // A declaration rather than a const, like `shutdown` below and for the same
619
+ // reason: this is the handler for a binding that was not there yet, and it must
620
+ // not be capable of becoming the next one.
621
+ function abandonRestart(err) {
622
+ clearTimeout(bootTimer);
623
+ bootTimer = null;
624
+ heldRestart = null;
625
+ restarting = false;
626
+ releaseRestart();
627
+ write(
628
+ `\n ${P.err}${G.fail}${P.reset} ${P.muted}restart failed ${G.dash} still on ${P.reset}v${PKG_VERSION}\n` +
629
+ ` ${P.muted}${oneLine(err?.stack ?? err, Math.max(20, cols() - 6), G.ellipsis)}${P.reset}\n`,
630
+ );
631
+ }
632
+
633
+ // The upgrade did not happen and this deck is still the deck. Said out loud
634
+ // because the terminal has just printed that a fetch was starting, and left
635
+ // unsaid it reads as a restart that hung.
636
+ const abandonUpgrade = (why) => {
637
+ clearTimeout(upgradeTimer);
638
+ restarting = false;
639
+ // The server's own latch, which no longer has an exiting process to clear it.
640
+ releaseRestart();
641
+ write(
642
+ `\n ${P.warn}${G.cancel}${P.reset} ${P.muted}update not applied ${G.dash} still on ${P.reset}v${PKG_VERSION}\n` +
643
+ (why ? ` ${P.muted}${why}${P.reset}\n` : ""),
644
+ );
645
+ };
646
+
647
+ // The supervisor's verdict on the fetch it was asked for. Only it can answer:
648
+ // the fetch is its child, and it is the process that will still be here when
649
+ // this one exits.
650
+ process.on("message", (m) => {
651
+ if (!restarting || !m || typeof m !== "object") return;
652
+ if (m.type === "upgrade-ready") {
653
+ clearTimeout(upgradeTimer);
654
+ // The replacement is on the machine now, so this is the last moment the
655
+ // port is worth holding: exiting hands it straight over.
656
+ write(`\n ${P.warn}${G.restart}${P.reset} ${P.muted}updating via npx${G.ellipsis}${P.reset}\n`);
657
+ shutdown(UPGRADE_CODE);
658
+ } else if (m.type === "upgrade-refused") {
659
+ abandonUpgrade(m.error);
660
+ }
661
+ });
662
+
663
+ // What a restart would land on. Read from disk now rather than remembered from
664
+ // boot, because the whole point is that the two differ.
665
+ function restartTarget() {
666
+ try { return JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8")).version ?? null; }
667
+ catch { return null; }
668
+ }
669
+
670
+ // The three things `shutdown` has to tear down, named before the boot that
671
+ // fills them in rather than by it. From the line below onwards this process is
672
+ // answering HTTP, and /api/restart can therefore reach `shutdown` at any moment
673
+ // after it — including moments at which none of these exist yet. `let … = null`
674
+ // is what makes that a question shutdown can ask instead of a ReferenceError it
675
+ // dies of; the boot queue in requestRestart is what makes it a question it
676
+ // almost never has to ask. See #448.
677
+ let server = null;
678
+ let discovery = null;
679
+ let discoveryFile = null;
680
+
681
+ // This worker does not outlive the supervisor that started it (#702).
682
+ //
683
+ // Armed HERE rather than beside the signal handlers at the bottom of the file,
684
+ // for the reason the three `let`s above are where they are: this is the first
685
+ // line from which `shutdown` can be called without dying of a temporal dead
686
+ // zone, and the window that matters is the boot — the supervisor can be killed
687
+ // while the report is still printing, and a worker orphaned in there is a
688
+ // worker that binds its port a moment later and then holds it for a day.
689
+ //
690
+ // A no-op with nothing supervising us, which is `node bin/deck.js` run by hand:
691
+ // dieWithParent arms only when there is a channel, the same question SUPERVISED
692
+ // asks above. Shutdown code 0 because there is nobody left to read it — the
693
+ // point is only that discovery is unregistered and the port let go of on the
694
+ // way out, rather than left for the next boot's stale sweep.
695
+ dieWithParent(() => shutdown(0));
696
+
697
+ // The listen, begun HERE and awaited below the startup report rather than after
698
+ // it. The report is a narration; the port is the product, and it was queued
699
+ // behind three tool probes for no reason but the order these two statements
700
+ // were written in (#483).
701
+ //
702
+ // What that cost: `reportStartup` awaits `ensureCswap`, which on a machine with
703
+ // no Python tooling runs a real `uv tool install` under a 180-second timeout. So
704
+ // the deck printed its rows and then refused every connection until that install
705
+ // was over — on a first run, which is the one boot a new user judges the tool by.
706
+ // #476 stopped that job blocking the event loop; the socket was shut either way,
707
+ // because the bind had not been attempted yet.
708
+ //
709
+ // Nothing in the report has to finish before the socket opens, and the window
710
+ // this opens is narrow on purpose — the discovery file and the browser are both
711
+ // still written after the report, so the only callers who can arrive inside it
712
+ // are a tab left open by an earlier deck and the user's own curl:
713
+ //
714
+ // • the event log is replayed and the sequence counter primed INSIDE
715
+ // startServer, before it binds — so /events and /api/events answer from a
716
+ // full buffer from the first connection, not a growing one.
717
+ // • the hook install can only make hooks fire; a hook that fires early finds
718
+ // no discovery file and posts nowhere, exactly as it does today.
719
+ // • claude-swap: cswapBin memoizes only a lookup that WORKED, so a probe
720
+ // landing mid-install caches nothing and the next one asks again — see
721
+ // resetCswapBin's note. The accounts panel can report the tool missing for
722
+ // the second it is missing, and answers properly the moment it is not.
723
+ // • ccusage: getRunner awaits the very `_installing` promise primeCcusage
724
+ // started, so a request in this window joins that install rather than
725
+ // racing a second one.
726
+ //
727
+ // Settled into a tagged result rather than left bare, for the reason every job
728
+ // in startupWork carries its own handler: this promise now lives across the
729
+ // whole report, and a bind that fails in there with nothing attached to it is an
730
+ // unhandledRejection — which Node answers by killing the process over a port it
731
+ // could have named.
732
+ const starting = startServer({
733
+ port, persist, workspace, codex: wantCodex, claude: wantClaude,
734
+ // Withheld when nothing is supervising us: without a parent, exiting is just
735
+ // exiting, and /api/restart answers 501 so the UI hides the control.
736
+ onRestart: SUPERVISED ? requestRestart : null,
737
+ }).then(s => ({ ok: true, s }), err => ({ ok: false, err }));
738
+
739
+ // Once-per-session setup — hook install, tool probes, registry lookups, and the
740
+ // banner it runs underneath. A respawn is the same session continuing, so it
741
+ // skips the lot and prints one line instead. This is the difference between a
742
+ // restart that feels instant and one that makes you wonder whether it worked.
743
+ if (!RESPAWN) {
744
+ const jobs = startupWork();
745
+ await printBanner();
746
+ await reportStartup(jobs);
747
+ }
748
+
749
+ // Usually settled long ago by the time we get here, which is the point: `step`
750
+ // paints nothing for a promise that has already resolved, so the spinner this
751
+ // used to show is simply gone from the boots that were slow enough to need one.
752
+ const bound = await (RESPAWN ? starting : step(`starting server${G.ellipsis}`, starting));
753
+ if (!bound.ok) {
754
+ // stderr, not a row: a deck that could not bind is not a status line, and
755
+ // whatever launched it reads this stream.
756
+ console.error(`${PRODUCT}: server failed: ${bound.err.message}`);
757
+ process.exit(1);
758
+ }
759
+ server = bound.s;
760
+ const addr = server.address();
761
+ const realPort = typeof addr === "object" && addr ? addr.port : port;
762
+ const url = `http://127.0.0.1:${realPort}`;
763
+
764
+ // The supervisor re-launches with this on --port. It has to be the port we
765
+ // actually got, not the one we asked for: those differ whenever the first
766
+ // launch found 4317 taken, and re-launching on the requested port would move
767
+ // the deck out from under every open tab.
768
+ try { process.send?.({ type: "listening", port: realPort }); } catch { /* not supervised */ }
769
+
770
+ if (RESPAWN) {
771
+ write(` ${P.ok}${G.restart}${P.reset} ${P.muted}restarted ${G.arrow} ${P.reset}v${PKG_VERSION}${P.muted} ${G.bullet} ${link(url, url, LINKS)}${P.reset}\n`);
772
+ // A respawn skips the whole startup report, but not this: the argv is the
773
+ // same argv, the typo in it is still there, and a deck that mentioned it once
774
+ // and then went quiet for every restart afterwards is back to hiding it from
775
+ // anyone who was not watching the first boot.
776
+ reportUnknownFlags(flags.unknown);
777
+ reportIncompleteFlags(flags.incomplete);
778
+ } else {
779
+ // The URL is the one detail an ellipsis would destroy — half an address is
780
+ // not a shorter address — so it keeps its own line when the terminal is too
781
+ // narrow to hold it beside the label. See statusLine's `keep`.
782
+ write(row({
783
+ mark: G.ok, label: "server ready",
784
+ detail: link(url, url, LINKS), detailTone: `${P.accent}${P.bold}`, keep: true,
785
+ }));
786
+ if (persist) write(row({ label: "log", detail: fileLink(persist) }));
787
+ // Last of the rows, on purpose — see reportUnknownFlags.
788
+ reportUnknownFlags(flags.unknown);
789
+ reportIncompleteFlags(flags.incomplete);
790
+ // Only when one is actually being opened. Under --no-open — which is how an
791
+ // npx update relaunches, with a tab already waiting — this was announcing
792
+ // something that never happened.
793
+ if (openBrowser) write(`\n ${P.ok}${P.bold}${G.play} opening browser${G.ellipsis}${P.reset}\n\n`);
794
+ else write("\n");
795
+ }
796
+
797
+ // The discovery file is the whole of how a hook finds this deck: hook.js
798
+ // enumerates that directory and nothing else. Writing it once at boot meant
799
+ // anything that later took it away left a deck that listened, served, and
800
+ // received not one event — with nothing on screen to say so. So it is checked
801
+ // on a timer, put back when it goes missing, and its absence is stated out loud
802
+ // rather than left to look like an idle afternoon.
803
+ //
804
+ // The token goes in with the port: it is what lets a hook tell this deck from
805
+ // whatever else may later be listening on the same number. See hookToken().
806
+ //
807
+ // The log path goes in with them, so a hook can see which decks share one events
808
+ // log and elect a single writer for it. See electWriters in hook/hook.js. The
809
+ // Codex setting goes in for the half of that election no hook is part of: the
810
+ // rollout files this deck tails itself, which a --no-codex deck must never be
811
+ // elected to record. See writesCodexLog in src/server/log-writer.mjs.
812
+ let registered = null;
813
+ discovery = keepDiscovery({
814
+ port: realPort,
815
+ workspace,
816
+ token: hookToken(),
817
+ persist,
818
+ codex: wantCodex,
819
+ onState: (state) => {
820
+ const first = registered === null;
821
+ registered = state.ok;
822
+ if (!state.ok) reportUnregistered(state);
823
+ else if (!first) reportReregistered(state);
824
+ },
825
+ });
826
+ discoveryFile = discovery.file;
827
+ // Now, not in five seconds: nothing should reach the pulse line below without
828
+ // the deck knowing whether the hooks can see it.
829
+ await discovery.check();
830
+
831
+ // Never on a respawn: the tab that asked for the restart is still open and
832
+ // reconnecting on its own. A second one would be the deck talking over itself.
833
+ if (openBrowser && !RESPAWN) {
834
+ try {
835
+ const { default: open } = await import("open");
836
+ await open(url);
837
+ } catch {}
838
+ }
839
+
840
+ // ── Pulse indicator ───────────────────────────────────────────────────────────
841
+ // The whole line is rewritten each beat rather than just the dot: anything else
842
+ // on this deck that has something to say writes a newline first, and after that
843
+ // the line under the cursor is no longer the one we drew — a partial repaint
844
+ // would leave the message behind and pulse into empty space. Sized to the real
845
+ // terminal, because at 40 columns the old fixed 61-character line wrapped, and
846
+ // from then on \r only ever reached its second row.
847
+ if (MOTION) {
848
+ let pi = 0;
849
+ setInterval(() => {
850
+ // The colour follows the words. A Codex-only deck keeps saying "listening"
851
+ // when it is unregistered — see pulseText — and painting that sentence in
852
+ // the warning tone would restore the alarm the sentence just retired.
853
+ const alarm = !registered && wantClaude;
854
+ const text = pulseText({ registered, claude: wantClaude, columns: cols(), unicode: UNICODE });
855
+ const dot = pi++ % 2 === 0 ? (alarm ? P.warn : P.ok) : P.muted;
856
+ const tone = alarm ? P.warn : P.muted;
857
+ write(`\r ${dot}${G.pulse}${P.reset} ${tone}${text}${P.reset}`);
858
+ }, 800).unref();
859
+ }
860
+
861
+ // Boot is over. Everything `shutdown` tears down exists, so a restart can be
862
+ // run rather than held — and the server is told, so /api/restart stops
863
+ // describing a deck that is still assembling itself. This line is exactly where
864
+ // the window opened at the top of this file closes; see requestRestart.
865
+ booted = true;
866
+ markDeckReady();
867
+ runHeldRestart();
868
+
869
+ /**
870
+ * A declaration, not the `const` arrow this was for eight months.
871
+ *
872
+ * The difference is the whole of #448: a const is in its temporal dead zone
873
+ * until the line declaring it runs, and every line above — the startup report,
874
+ * the port report, the discovery file, the browser spawn — executes with the
875
+ * server already accepting connections. A restart arriving in that window
876
+ * called this and got `ReferenceError: Cannot access 'shutdown' before
877
+ * initialization`, and the latch it had already set is what made that
878
+ * permanent. A declaration is hoisted, so from the first instruction of this
879
+ * module there is a function here to call.
880
+ *
881
+ * Hoisting alone would only have moved the fault one line down, onto `server`,
882
+ * `discovery` and `discoveryFile` — which is why those are `let … = null` above
883
+ * and asked about rather than assumed here. Between them, this is callable at
884
+ * any instant of this process's life and cannot end in a throw for the caller
885
+ * to lose.
886
+ */
887
+ async function shutdown(code = 0) {
888
+ // Also set as exitCode, not only passed to exit(): if the event loop empties
889
+ // on its own before either timer runs, Node would otherwise exit 0 and the
890
+ // supervisor would take that as "done" instead of "bring me back".
891
+ process.exitCode = code;
892
+ // Nothing inside a shutdown is worth staying alive for, and this one is
893
+ // called from three places that cannot handle a rejection — a signal handler,
894
+ // an IPC message handler, and a restart. An unhandled one there ends the
895
+ // process on Node's terms rather than ours, which is to say with the wrong
896
+ // exit code and therefore, half the time, without the supervisor bringing the
897
+ // deck back.
898
+ try {
899
+ // Before anything that can take time: a Ctrl+C the user has to watch for a
900
+ // second and a half is a second and a half without a cursor.
901
+ showCursor();
902
+ if (tty && code !== RESTART_CODE && code !== UPGRADE_CODE) {
903
+ write(`\n\n ${P.warn}${G.stop} shutting down${G.ellipsis}${P.reset}\n`);
904
+ }
905
+ // Stopped first, always: a tick landing after the unlink would re-register a
906
+ // deck that is on its way out, and leave the file behind for the hooks to
907
+ // find once nothing is listening.
908
+ //
909
+ // AWAITED, because clearing the interval only stops the NEXT tick. A tick
910
+ // that started a moment ago is inside the atomic write, and it re-creates
911
+ // the file after the unlink — the same stale registration, reached by the
912
+ // one route "stop first" does not cover. stop() answers with that check, so
913
+ // this waits for it and then removes what it wrote. Bounded: one check,
914
+ // which never rejects.
915
+ //
916
+ // Guarded on its own, because a discovery file this process cannot remove is
917
+ // a nuisance the next boot's stale sweep clears up — worth carrying on to
918
+ // the orderly close below rather than skipping to the abrupt one.
919
+ try {
920
+ await discovery?.stop();
921
+ if (discoveryFile) await removeDiscovery(discoveryFile);
922
+ } catch { /* the sweep at the next boot gets it */ }
923
+ // No server yet means nothing to drain and nothing to hand the port over to,
924
+ // so the exit is the whole of the shutdown.
925
+ if (!server) return process.exit(code);
926
+ server.close(() => process.exit(code));
927
+ // SSE connections never end by themselves, so close() alone would sit out the
928
+ // full 1500ms fallback on every restart. Hanging them up is safe — the stream
929
+ // sets retry: 1500 and replays from Last-Event-ID, so each tab reconnects and
930
+ // catches up without being told anything.
931
+ try { server.closeAllConnections?.(); } catch { /* Node < 18.2 */ }
932
+ setTimeout(() => process.exit(code), 1500).unref();
933
+ } catch {
934
+ process.exit(code);
935
+ }
936
+ }
937
+ process.on("SIGINT", () => shutdown(0));
938
+ process.on("SIGTERM", () => shutdown(0));
939
+ process.on("beforeExit", () => { discovery?.stop(); if (discoveryFile) removeDiscovery(discoveryFile); });
940
+
941
+ // ── helpers ───────────────────────────────────────────────────────────────────
942
+
943
+ // The deck is up and the discovery file could not be written. Said in full —
944
+ // the path, the reason — because the alternative is what this replaced: an
945
+ // ordinary-looking deck that simply never shows a session.
946
+ //
947
+ // What that costs depends on which CLI this deck watches, so the sentence comes
948
+ // from term.mjs, where both answers are written down and tested.
949
+ function reportUnregistered({ file, error }) {
950
+ const why = error?.message ? ` ${G.dash} ${error.message}` : "";
951
+ write(
952
+ `\n ${P.warn}${G.warn}${P.reset} ${P.bold}not registered${P.reset}${P.muted}${why}${P.reset}\n` +
953
+ ` ${P.muted}${unregisteredDetail({ file, claude: wantClaude })}${P.reset}\n`,
954
+ );
955
+ }
956
+
957
+ function reportReregistered({ file }) {
958
+ write(`\n ${P.ok}${G.ok}${P.reset} ${P.muted}registered again ${G.arrow} ${fileLink(file)}${P.reset}\n`);
959
+ }
960
+
961
+ /**
962
+ * Every token the parser did not recognise, named, one row each.
963
+ *
964
+ * Said rather than acted on: the deck goes on booting and still exits 0. It is
965
+ * not a one-shot command that can afford the usual contract. `bin/agent-dag.js`
966
+ * hands its own argv to every worker it spawns — including the npx relaunch,
967
+ * which starts a NEWER version of the package on the argv the user typed
968
+ * against an older one — and the README recommends running it from a wrapper.
969
+ * Refusing to boot over one token would turn a typo into a dark dashboard, and
970
+ * an argument the newer build no longer knows into a failed upgrade that costs
971
+ * the port and the session. The file already holds that position once, in the
972
+ * `--all` branch: a flag the deck stopped needing is still accepted rather than
973
+ * made fatal.
974
+ *
975
+ * So it goes where the deck puts everything else it decided on your behalf —
976
+ * the startup report — and it goes at the END of it. reportStartup writes its
977
+ * rows in a fixed order and three more land underneath them (the server, the
978
+ * log, the browser), so a warning printed among those rows is a warning the
979
+ * rows scroll over. Here it is the last line before the pulse indicator takes
980
+ * the bottom of the screen and stops repainting anything above it.
981
+ */
982
+ function reportUnknownFlags(unknown) {
983
+ for (const token of unknown) {
984
+ write(row({
985
+ mark: G.warn, tone: P.warn, label: "unknown option",
986
+ detail: `${token} ${G.dash} see \`${PRODUCT} --help\``,
987
+ }));
988
+ }
989
+ }
990
+
991
+ /**
992
+ * Every value-taking flag that was given no value it could use, named, one row
993
+ * each — and printed beside the unknown ones because it is the same failure
994
+ * wearing a different hat.
995
+ *
996
+ * #697: `--workspace`, `--history` and `--port` used to consume the following
997
+ * token whatever it was, so `ccdeck --workspace $PROJ --no-persist` with `PROJ`
998
+ * unset scoped the deck to a directory called `--no-persist`, kept persisting to
999
+ * the shared log, and reported neither. Nothing landed in `unknown`, because the
1000
+ * token that belonged there had been eaten. The parser refuses that value now
1001
+ * and lists the flag here instead.
1002
+ *
1003
+ * Said rather than acted on, under exactly the argument reportUnknownFlags makes
1004
+ * above: the flag falls back to its documented default and the deck still boots.
1005
+ * The row is what makes the fallback a decision the user can see, and the rows
1006
+ * around it show its consequence — `workspace (all)` and the `log` line are
1007
+ * printed by the same report.
1008
+ */
1009
+ function reportIncompleteFlags(incomplete) {
1010
+ for (const { flag, expects } of incomplete ?? []) {
1011
+ write(row({
1012
+ mark: G.warn, tone: P.warn, label: "missing value",
1013
+ detail: `${flag} ${G.dash} expected ${expects}; using the default`,
1014
+ }));
1015
+ }
1016
+ }
1017
+
1018
+ function printHelp() {
1019
+ process.stdout.write(`${PRODUCT} — live deck of Claude Code + Codex agents
1020
+
1021
+ Usage:
1022
+ ${PRODUCT} [options]
1023
+
1024
+ Options:
1025
+ -p, --port <number> Preferred port (default: 4317; falls back to random 4318–4400)
1026
+ --no-open Don't open the browser automatically
1027
+ --workspace <path> Only capture sessions whose cwd is inside <path>
1028
+ --scope Restrict to current working directory
1029
+ --all Capture every session (default)
1030
+ --history <path> Override events log file (default: ~/.claude/agent-dag/events.jsonl)
1031
+ --no-persist Don't write or replay events log (RAM-only)
1032
+ --codex Force-enable Codex capture even if ~/.codex/ missing
1033
+ --no-codex Skip Codex capture (Claude only)
1034
+ --claude Force-enable Claude capture even if Claude Code wasn't found
1035
+ --no-claude Skip Claude entirely: no hooks, no claude-swap, no accounts panel
1036
+ --uninstall Remove ${PRODUCT}'s hooks from ~/.claude/settings.json and
1037
+ ~/.codex/hooks.json, and restore any sound hooks of yours it parked.
1038
+ Hook entries only: the forwarder script, ~/.claude/agent-dag/,
1039
+ the events log, ~/.agents-deck/ and claude-swap all stay
1040
+ -h, --help Show this help
1041
+ -v, --version Print the version and exit
1042
+
1043
+ Anything else on the command line is reported as an unknown option and then
1044
+ ignored: the deck still starts.
1045
+
1046
+ A flag that takes a value never swallows the next flag. If the value is missing,
1047
+ empty, or itself looks like a flag — \`${PRODUCT} --workspace \$UNSET --no-persist\`
1048
+ after the shell has dropped an unset variable — the flag is reported, left on its
1049
+ default, and the token it would have eaten is parsed as the flag it is.
1050
+ `);
1051
+ }