ccdeck 1.42.0 → 1.44.1

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