agent-dag 3.22.1 → 3.22.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +6 -477
  2. package/package.json +14 -48
  3. package/shim.js +107 -0
  4. package/LICENSE +0 -661
  5. package/LICENSING.md +0 -82
  6. package/THIRD_PARTY_NOTICES.md +0 -395
  7. package/bin/agent-dag.js +0 -626
  8. package/bin/deck.js +0 -1805
  9. package/dist/web/assets/index-CJYsv0lr.css +0 -1
  10. package/dist/web/assets/index-Ifm23DDC.js +0 -270
  11. package/dist/web/index.html +0 -49
  12. package/hook/hook.js +0 -542
  13. package/release-notes.json +0 -398
  14. package/src/server/activity.mjs +0 -52
  15. package/src/server/agent-activity.mjs +0 -522
  16. package/src/server/args.mjs +0 -183
  17. package/src/server/auto-update.mjs +0 -79
  18. package/src/server/block-notify.mjs +0 -173
  19. package/src/server/boot-deadline.mjs +0 -127
  20. package/src/server/brand.mjs +0 -16
  21. package/src/server/browser-history.mjs +0 -497
  22. package/src/server/browser-presence.mjs +0 -211
  23. package/src/server/browser-profiles.mjs +0 -279
  24. package/src/server/browser-react.mjs +0 -284
  25. package/src/server/browser-watch-store.mjs +0 -350
  26. package/src/server/browser-watch.mjs +0 -905
  27. package/src/server/ccusage.mjs +0 -1168
  28. package/src/server/claude-accounts.mjs +0 -951
  29. package/src/server/claude-dir.mjs +0 -213
  30. package/src/server/codex-auth.mjs +0 -388
  31. package/src/server/codex-dir.mjs +0 -171
  32. package/src/server/codex-quota.mjs +0 -449
  33. package/src/server/codex-usage.mjs +0 -512
  34. package/src/server/cswap-admin.mjs +0 -1562
  35. package/src/server/cswap-auto.mjs +0 -658
  36. package/src/server/cswap-install.mjs +0 -641
  37. package/src/server/deck-home.mjs +0 -243
  38. package/src/server/deck-prefs.mjs +0 -301
  39. package/src/server/deck-probe.mjs +0 -111
  40. package/src/server/detach.mjs +0 -244
  41. package/src/server/exec.mjs +0 -996
  42. package/src/server/global-install.mjs +0 -67
  43. package/src/server/hwmonitor.mjs +0 -56
  44. package/src/server/index.mjs +0 -6043
  45. package/src/server/installer.mjs +0 -912
  46. package/src/server/invoked-as.mjs +0 -144
  47. package/src/server/lan-about.mjs +0 -119
  48. package/src/server/lan-engine.mjs +0 -952
  49. package/src/server/lan-reach.mjs +0 -256
  50. package/src/server/lan-socket.mjs +0 -682
  51. package/src/server/lan-sync.mjs +0 -941
  52. package/src/server/lhm-parse.mjs +0 -91
  53. package/src/server/log-tail.mjs +0 -139
  54. package/src/server/log-writer.mjs +0 -322
  55. package/src/server/login-service.mjs +0 -473
  56. package/src/server/macmon.mjs +0 -310
  57. package/src/server/npx.mjs +0 -264
  58. package/src/server/open-url.mjs +0 -242
  59. package/src/server/presence.mjs +0 -40
  60. package/src/server/quota.mjs +0 -792
  61. package/src/server/relay-guard.mjs +0 -507
  62. package/src/server/reset-label.mjs +0 -78
  63. package/src/server/retire-sound-hook.mjs +0 -349
  64. package/src/server/running-deck.mjs +0 -234
  65. package/src/server/self-update.mjs +0 -1380
  66. package/src/server/stop-deck.mjs +0 -171
  67. package/src/server/supervisor.mjs +0 -392
  68. package/src/server/system-metrics.mjs +0 -1825
  69. package/src/server/term.mjs +0 -686
  70. package/src/server/uv-bootstrap.mjs +0 -337
package/bin/deck.js DELETED
@@ -1,1805 +0,0 @@
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, pulseDot, pulseText, spinnerFrames, statusLine, supportsHyperlinks, termColumns,
15
- elapsedSuffix, sinceLabel, unicodeOK, unregisteredDetail, visibleWidth, 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
- import { budget, bootDeadlineMs } from "../src/server/boot-deadline.mjs";
20
- // A leaf — fs, path and claude-dir.mjs, nothing else — so it is imported here
21
- // with the rest rather than fetched later. Deliberately NOT the other way
22
- // round: it takes the handshake as a parameter precisely so that it never has
23
- // to import the server. See the note at the top of that file.
24
- import { asksForOwnDeck, runningDeck, versionNote } from "../src/server/running-deck.mjs";
25
-
26
- const __dirname = dirname(fileURLToPath(import.meta.url));
27
- const PKG_ROOT = resolve(__dirname, "..");
28
- const PKG_VERSION = (() => {
29
- try { return JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8")).version ?? "0.0.0"; }
30
- catch { return "0.0.0"; }
31
- })();
32
-
33
- // The command the user typed, handed down by the supervisor — our own argv[1]
34
- // is this file under every one of the three names. Null when it cannot be
35
- // proven, and null is the answer that prints nothing.
36
- const INVOKED_AS = invokedName({ pkgRoot: PKG_ROOT });
37
-
38
- const argv = process.argv.slice(2);
39
- const flags = parseArgs(argv);
40
-
41
- // Exit codes the supervisor reads as "bring me back": 75 from the files on
42
- // disk, 76 through npx — which is the only way an npx run reaches a newer
43
- // version, since its directory is never upgraded in place. Anything else it
44
- // forwards.
45
- const RESTART_CODE = 75;
46
- const UPGRADE_CODE = 76;
47
- const RESPAWN = process.env.AGENTS_DECK_RESPAWN === "1";
48
- const SUPERVISED = typeof process.send === "function";
49
-
50
- if (flags.help) {
51
- printHelp();
52
- process.exit(0);
53
- }
54
-
55
- // The version, on stdout, and then nothing — no hooks, no port, no browser.
56
- //
57
- // This is the first thing anyone types at a CLI they do not know and the second
58
- // thing anyone types when filing a bug, and until now it was the one question
59
- // the deck answered by starting a server and never exiting. The banner has
60
- // always carried the number, but only underneath a running deck, which is not
61
- // an answer: it cannot be piped, and the process it comes with has to be killed.
62
- //
63
- // Bare, unprefixed, one line: `ccdeck --version` is read by scripts as often as
64
- // by people, and `node --version` style ornamentation is what those scripts
65
- // then have to strip. PKG_VERSION is the same read the banner uses.
66
- if (flags.version) {
67
- console.log(PKG_VERSION);
68
- process.exit(0);
69
- }
70
-
71
- if (flags.uninstall) {
72
- const { uninstallHooks, hasCodexInstalled } = await import(pathToFileURL(join(PKG_ROOT, "src/server/installer.mjs")).href);
73
- // Anything that could not be taken out. An uninstall that removed nothing
74
- // because it could not read the file has not uninstalled anything, and both
75
- // the wording and the exit code have to say so: a user who is told it worked
76
- // and still has our hooks firing on every event is worse off than one who is
77
- // told it failed, because they have stopped looking.
78
- let refused = false;
79
- // Files already reported as unparseable. The Claude hooks and the sound hook
80
- // live in the SAME settings.json, so a stray comma refuses both, and printing
81
- // the whole path-plus-parser-error twice buries the one line that differs —
82
- // which of our two installations is still in there.
83
- const named = new Set();
84
- // Its own glyphs for the same reason the bad-port line has them: this block
85
- // runs at module top level, well before `G` is declared (#797).
86
- const { dash: gDash } = glyphs(unicodeOK());
87
- /** Report one provider's outcome. `ok` first — see uninstallHooks. */
88
- const report = (res, label) => {
89
- if (res.ok === false) {
90
- refused = true;
91
- named.add(res.settingsPath);
92
- console.error(`${PRODUCT}: ${label} hooks NOT removed ${gDash} ${res.settingsPath} could not be read as JSON (${res.why}).`);
93
- console.error(`${PRODUCT}: the __agent-dag hook entries are still in that file and keep firing on every ${label} event.`);
94
- return;
95
- }
96
- console.log(res.changed
97
- ? `${PRODUCT}: hooks removed from ${res.settingsPath}`
98
- : `${PRODUCT}: no ${label} hooks to remove`);
99
- };
100
- report(await uninstallHooks({ provider: "claude" }), "Claude");
101
- // The old finish sound was a second entry in the same file, marked
102
- // __agent-dag-sound rather than __agent-dag, and uninstallHooks does not know
103
- // that mark — so it used to be left behind, playing on every turn after the
104
- // deck was supposedly gone. #704 retired the mechanism outright, but the entry
105
- // is still on every machine that had it, so removing it is still this
106
- // command's job. So is the other half: turning the sound on parked the user's
107
- // own afplay/PowerShell Stop hooks, and once the deck is uninstalled nothing
108
- // else on the machine knows where they went.
109
- // The login item goes too, and this is the one place the uninstall's
110
- // documented narrowness has to bend. "Hook entries only" is right for the
111
- // event log and the port registry, which are data somebody may still want —
112
- // but a login item left behind after an uninstall is not data, it is a
113
- // machine that keeps starting a deck whose hooks were just removed.
114
- {
115
- const svc = await import(pathToFileURL(join(PKG_ROOT, "src/server/login-service.mjs")).href);
116
- const { deckDataDir: dataDir } = await import(pathToFileURL(join(PKG_ROOT, "src/server/deck-home.mjs")).href);
117
- if (svc.readServiceRecord(dataDir()) !== null) {
118
- const gone = svc.uninstallService();
119
- svc.writeServiceRecord(dataDir(), { removed: new Date().toISOString(), version: PKG_VERSION });
120
- // Silent when there was nothing on the machine: an uninstall that reports
121
- // removing a login item this deck never had is the same lie the explicit
122
- // command used to tell, in the place a reader is least able to check it.
123
- if (!gone.ok) {
124
- // `gDash`, not an em dash: this block runs at module top level, on a
125
- // console that may be the legacy Windows one, and #797 is the rule that
126
- // a printed string never carries punctuation the terminal may not have.
127
- console.error(`${PRODUCT}: could NOT remove the login item ${gDash} ${gone.reason} (${gone.path})`);
128
- } else if (gone.existed) {
129
- console.log(`${PRODUCT}: no longer starts at login`);
130
- }
131
- if (!gone.ok) refused = true;
132
- }
133
- }
134
-
135
- const { retireSoundHook } = await import(pathToFileURL(join(PKG_ROOT, "src/server/retire-sound-hook.mjs")).href);
136
- const sound = await retireSoundHook();
137
- if (sound.removed) console.log(`${PRODUCT}: sound hook removed`);
138
- if (sound.restored) console.log(`${PRODUCT}: restored ${sound.restored} of your own sound hook(s)`);
139
- if (sound.ok === false) {
140
- refused = true;
141
- // Two different refusals, and saying the wrong one sends the user to the
142
- // wrong file. `settings_unreadable` means nothing was touched at all — and
143
- // when the forwarders already named that same file, the whole path and
144
- // parser error would only bury the one line that differs. `parked_unreadable`
145
- // is the other file: our entry IS out (the lines above said so), and what is
146
- // still owed is the user's own hooks, which stay parked until they repair it.
147
- if (sound.reason === "settings_unreadable") {
148
- console.error(named.has(sound.settingsPath)
149
- ? `${PRODUCT}: the sound hook is still in that file too.`
150
- : `${PRODUCT}: sound hook left in place — ${sound.message}`);
151
- named.add(sound.settingsPath);
152
- } else {
153
- console.error(`${PRODUCT}: your own sound hooks were NOT restored ${gDash} ${sound.message}`);
154
- }
155
- }
156
- if (hasCodexInstalled()) {
157
- report(await uninstallHooks({ provider: "codex" }), "Codex");
158
- }
159
- // The remedy last and once, after every symptom above it, rather than once
160
- // per refusal in the middle of the list.
161
- if (named.size > 0) {
162
- console.error(`${PRODUCT}: repair the JSON (or move the file aside), then run \`${PRODUCT} --uninstall\` again.`);
163
- }
164
- // Non-zero when any half of it refused, so `ccdeck --uninstall && …` and every
165
- // CI step that runs this stops on the failure instead of continuing past it.
166
- process.exit(refused ? 1 : 0);
167
- }
168
-
169
- // ── the two questions that are about another deck, not this one ──────────────
170
- //
171
- // One-shot commands in the shape `--uninstall` already established: do the
172
- // thing, print, exit, and never start a server. They sit ABOVE the migration
173
- // and well above the heavy imports, because a command that ends a deck has no
174
- // business moving that deck's files on the way past, and because asking a
175
- // server to stop should not require starting one.
176
- //
177
- // Their own glyphs and palette rather than `G`/`P`, for the reason the bad-port
178
- // block below gives (#797): those are declared two hundred lines further down
179
- // and this runs at module top level.
180
- //
181
- // WHICH DECK. The shape a bare `ccdeck` would have built — same `sameShape` the
182
- // attach uses, so `ccdeck` and `ccdeck --stop` name the same deck and there is
183
- // one model to hold. `--port <n>` names one directly; `--all` takes every deck
184
- // on the machine. (`--all` is the legacy capture flag, a no-op since it became
185
- // the default; beside `--stop` it can only mean this, and it is the word a
186
- // person reaches for.)
187
- if (flags.stop || flags.status || flags.logs || flags.install || flags.installService || flags.uninstallService) {
188
- const { dash, ok: gOk, warn: gWarn, bullet, arrow, ellipsis: gEllipsis } = glyphs(unicodeOK());
189
- const tone = palette(colorProfile({ isTTY: Boolean(process.stdout.isTTY) }));
190
- const say = (line) => process.stdout.write(`${line}\n`);
191
-
192
- const { deckDataDir, deckLogDir } = await import(pathToFileURL(join(PKG_ROOT, "src/server/deck-home.mjs")).href);
193
-
194
- // ── --install ─────────────────────────────────────────────────────────────
195
- //
196
- // The one command that turns an npx run into a deck that comes back after a
197
- // reboot. `npx ccdeck` cannot start at login — a login item must name a path
198
- // that will still be there tomorrow, and npx runs out of a cache npm deletes
199
- // whenever it likes — so this puts the package on PATH and points the service
200
- // at THAT.
201
- //
202
- // BEHIND A FLAG, because `npx` means "run without installing" and a tool that
203
- // installs itself anyway is the tool people uninstall. Somebody typed this.
204
- if (flags.install) {
205
- const gi = await import(pathToFileURL(join(PKG_ROOT, "src/server/global-install.mjs")).href);
206
- const svc = await import(pathToFileURL(join(PKG_ROOT, "src/server/login-service.mjs")).href);
207
- const { installedName } = await import(pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href);
208
- const { run } = await import(pathToFileURL(join(PKG_ROOT, "src/server/exec.mjs")).href);
209
- // The name they typed, not ours. Three packages publish this deck, and
210
- // installing `ccdeck` for somebody who ran `npx agent-dag` hands them a
211
- // command they did not ask for.
212
- const pkg = installedName(PKG_ROOT, PRODUCT);
213
-
214
- say(`\n ${tone.muted}${dash} installing ${pkg} globally${gEllipsis}${tone.reset}`);
215
- const got = await run("npm", ["i", "-g", pkg], { timeout: gi.INSTALL_TIMEOUT_MS });
216
- if (!got?.ok) {
217
- say(` ${tone.err}${gWarn} ${gi.installFailure(got, { pkg })}${tone.reset}\n`);
218
- process.exit(1);
219
- }
220
- say(` ${tone.ok}${gOk}${tone.reset} ${pkg} is on your PATH${tone.muted} ${bullet} type \`${pkg}\` to start it${tone.reset}`);
221
-
222
- // WHERE npm PUT IT, asked rather than assumed: a prefix the user set
223
- // themselves is common and nothing here can guess it.
224
- const root = gi.readGlobalRoot(await run("npm", ["root", "-g"], { timeout: 30_000 }));
225
- const script = gi.globalScript(root, pkg);
226
- if (!script) {
227
- say(` ${tone.warn}${gWarn} installed, but npm did not say where ${dash} run \`${pkg} --install-service\` to start it at login${tone.reset}\n`);
228
- process.exit(0);
229
- }
230
- const out = svc.installService({ script, logPath: join(deckLogDir(), "deck.log"), product: PRODUCT });
231
- if (!out.ok) {
232
- say(` ${tone.warn}${gWarn} installed, but it will not start at login ${dash} ${out.reason}${tone.reset}\n`);
233
- process.exit(0);
234
- }
235
- svc.writeServiceRecord(deckDataDir(), { installed: PKG_VERSION, at: new Date().toISOString(), path: out.path });
236
- say(` ${tone.ok}${gOk}${tone.reset} and starts when you log in${tone.muted} ${bullet} ${out.path}${tone.reset}`);
237
- if (svc.lingerState() === "off") {
238
- say(` ${tone.warn}${gWarn} systemd tears your session down at logout, so the deck goes with it.${tone.reset}`);
239
- say(` ${tone.muted}\`sudo loginctl enable-linger $USER\` keeps it running when you are logged out.${tone.reset}`);
240
- }
241
- say(` ${tone.muted}\`${pkg} --uninstall-service\` undoes the login part${tone.reset}\n`);
242
- process.exit(0);
243
- }
244
-
245
- // ── --install-service / --uninstall-service ───────────────────────────────
246
- // Answered before the registry is read: neither one is about a deck that is
247
- // running, and both are as meaningful on a machine with no deck up as on one
248
- // with three.
249
- if (flags.installService || flags.uninstallService) {
250
- const svc = await import(pathToFileURL(join(PKG_ROOT, "src/server/login-service.mjs")).href);
251
- const { isGitCheckout, isNpxInstall } = await import(pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href);
252
- if (flags.uninstallService) {
253
- const out = svc.uninstallService();
254
- // Recorded either way. The record is what stops the next ordinary start
255
- // putting back what was just taken away, and a tool that argues with its
256
- // user about a login item is a tool that gets uninstalled entirely.
257
- svc.writeServiceRecord(deckDataDir(), { removed: new Date().toISOString(), version: PKG_VERSION });
258
- // `existed` rather than the record: the record says what THIS tool last
259
- // did, and the machine is what actually has a login item on it. Somebody
260
- // who removed the plist by hand should be told the truth about the
261
- // machine, not about our bookkeeping.
262
- say(!out.ok
263
- ? `\n ${tone.err}${gWarn} could not remove it ${dash} ${out.reason}${tone.reset}\n`
264
- : out.existed
265
- ? `\n ${tone.ok}${gOk}${tone.reset} no longer starts at login${tone.muted} ${bullet} ${out.path}${tone.reset}\n`
266
- : `\n ${tone.muted}${dash} it was not starting at login${tone.reset}\n`);
267
- process.exit(out.ok ? 0 : 1);
268
- }
269
- if (isGitCheckout(PKG_ROOT)) {
270
- // Not refused outright — somebody running from a checkout may genuinely
271
- // want this — but not done silently either: the item would name a working
272
- // tree, and that is worth knowing before it is written.
273
- say(`\n ${tone.warn}${gWarn} this is a checkout, so the login item would name ${PKG_ROOT}${tone.reset}`);
274
- say(` ${tone.muted}a renamed, moved or deleted working tree leaves a login item pointing at nothing${tone.reset}\n`);
275
- }
276
- if (isNpxInstall(PKG_ROOT)) {
277
- // The item would name a path inside ~/.npm/_npx/<hash>/, which npm deletes
278
- // whenever it feels like it — a login item pointing at nothing, forever,
279
- // on a machine where nothing was ever installed.
280
- say(`\n ${tone.warn}${gWarn} an npx run cannot start at login ${dash} its files live in npm's cache and are deleted without warning.${tone.reset}`);
281
- say(` ${tone.muted}install it first: \`npm i -g ${INVOKED_AS ?? PRODUCT}\`${tone.reset}\n`);
282
- process.exit(1);
283
- }
284
- const out = svc.installService({
285
- script: join(PKG_ROOT, "bin", "agent-dag.js"),
286
- logPath: join(deckLogDir(), "deck.log"),
287
- product: PRODUCT,
288
- });
289
- if (!out.ok) {
290
- say(`\n ${tone.err}${gWarn} could not set it up ${dash} ${out.reason}${tone.reset}\n`);
291
- process.exit(1);
292
- }
293
- svc.writeServiceRecord(deckDataDir(), { installed: PKG_VERSION, at: new Date().toISOString(), path: out.path });
294
- say(`\n ${tone.ok}${gOk}${tone.reset} starts when you log in${tone.muted} ${bullet} ${out.path}${tone.reset}`);
295
- if (out.how === "file-only") {
296
- // The file is on disk and both launchd and systemd read their directories
297
- // at the next login, so this works from then on. Said rather than hidden:
298
- // "it will work tomorrow" is a different promise from "it works now".
299
- say(` ${tone.warn}${gWarn} not started now ${dash} ${out.reason}. It will come up at your next login.${tone.reset}`);
300
- }
301
- // The one place the three platforms genuinely differ in what this buys you.
302
- if (svc.lingerState() === "off") {
303
- say(` ${tone.warn}${gWarn} systemd tears your session down at logout, so the deck goes with it.${tone.reset}`);
304
- say(` ${tone.muted}\`sudo loginctl enable-linger $USER\` keeps it running when you are logged out.${tone.reset}`);
305
- }
306
- say(` ${tone.muted}\`${INVOKED_AS ?? PRODUCT} --uninstall-service\` undoes it${tone.reset}\n`);
307
- process.exit(0);
308
- }
309
- const { canonicalLogPath } = await import(pathToFileURL(join(PKG_ROOT, "src/server/log-writer.mjs")).href);
310
- const { hasCodexInstalled } = await import(pathToFileURL(join(PKG_ROOT, "src/server/installer.mjs")).href);
311
- const { hasClaudeInstalled } = await import(pathToFileURL(join(PKG_ROOT, "src/server/claude-dir.mjs")).href);
312
- const { liveDecks, sameShape } = await import(pathToFileURL(join(PKG_ROOT, "src/server/running-deck.mjs")).href);
313
-
314
- // The default shape, spelled the same way the boot below spells it. NOT the
315
- // shape of the flags on THIS command line: `--stop --no-codex` is not a
316
- // request to stop a Codex-less deck, it is a flag that means nothing here,
317
- // and reading it as a selector would make `--stop` miss the deck it was
318
- // pointed at and say "nothing is running".
319
- const mine = {
320
- workspace: "",
321
- persist: canonicalLogPath(join(deckLogDir(), "events.jsonl")),
322
- codex: hasCodexInstalled(),
323
- claude: hasClaudeInstalled(),
324
- };
325
-
326
- // ── --logs ────────────────────────────────────────────────────────────────
327
- // What the deck wrote where a terminal would have shown it. Printed RAW,
328
- // escapes and all: the launcher passes its own colour tier down to a detached
329
- // deck, so this file is a mirror of the terminal the deck was started from
330
- // and re-rendering it is the whole point. The path goes last, because that is
331
- // the line somebody copies into `tail -f`.
332
- if (flags.logs) {
333
- const { readFileSync: readLog, statSync: statLog } = await import("node:fs");
334
- const logPath = join(deckLogDir(), "deck.log");
335
- let text = null;
336
- try { text = readLog(logPath, "utf8"); } catch { /* never started, or swept */ }
337
- if (text === null) {
338
- say(`\n ${tone.muted}${dash} nothing logged yet ${dash} ${logPath}${tone.reset}\n`);
339
- process.exit(0);
340
- }
341
- // The tail, not the file. It is truncated at the first start on an idle
342
- // machine, so it is normally small — but an attach appends to a running
343
- // deck's log every time, and a long-lived deck's log is somebody's week.
344
- const lines = text.split("\n");
345
- const shown = lines.slice(Math.max(0, lines.length - 200));
346
- if (shown.length < lines.length) {
347
- say(` ${tone.muted}${dash} showing the last ${shown.length} of ${lines.length} lines${tone.reset}`);
348
- }
349
- process.stdout.write(shown.join("\n"));
350
- if (!text.endsWith("\n")) say("");
351
- const size = (() => { try { return statLog(logPath).size; } catch { return 0; } })();
352
- say(` ${tone.muted}${dash} ${logPath} ${bullet} ${size} bytes${tone.reset}\n`);
353
- process.exit(0);
354
- }
355
-
356
- const decks = await liveDecks().catch(() => []);
357
- const age = (d) => sinceLabel(Date.now() - Date.parse(d.startedAt ?? ""));
358
- const where = (d) => (d.workspace ? d.workspace : "(all)");
359
- const url = (d) => `http://127.0.0.1:${d.port}`;
360
-
361
- if (flags.status) {
362
- if (!decks.length) {
363
- say(`\n ${tone.muted}${dash} no deck is running ${dash} \`${INVOKED_AS ?? PRODUCT}\` starts one${tone.reset}\n`);
364
- process.exit(0);
365
- }
366
- // The one a bare `ccdeck` would open is marked, because with two decks up
367
- // that is the only question this command is really being asked.
368
- const opens = decks.find(d => sameShape(d, mine)) ?? null;
369
- say("");
370
- for (const d of decks) {
371
- // The version chunk is dropped rather than printed as "v?" for a deck too
372
- // old to publish one — same rule as the attach line, and for the same
373
- // reason: a question mark beside two real facts reads as a fault.
374
- const head = [d.version ? `v${d.version}` : "", `pid ${d.pid}`, `up ${age(d)}`]
375
- .filter(Boolean).join(` ${bullet} `);
376
- const mark = d === opens ? `${tone.ok}${gOk}${tone.reset}` : `${tone.muted}${bullet}${tone.reset}`;
377
- const tail = d === opens ? `${tone.muted} ${arrow} \`${INVOKED_AS ?? PRODUCT}\` opens this one${tone.reset}` : "";
378
- say(` ${mark} ${tone.muted}${head}${tone.reset}${tail}`);
379
- say(` ${tone.accent}${tone.bold}${url(d)}${tone.reset}`);
380
- say(` ${tone.muted}${where(d)} ${bullet} ${d.persist ?? "no log (--no-persist)"}${tone.reset}`);
381
- }
382
- say("");
383
- process.exit(0);
384
- }
385
-
386
- // ── --stop ────────────────────────────────────────────────────────────────
387
- const { stopDeck } = await import(pathToFileURL(join(PKG_ROOT, "src/server/stop-deck.mjs")).href);
388
- // `flags.port`, not the `rawPort` computed below: that folds in
389
- // AGENT_DAG_PORT, which is how somebody RUNS a deck rather than which deck
390
- // they mean to stop — and a deck started on a custom port still has the
391
- // default SHAPE, so the matcher finds it without help. It is also declared
392
- // below this block, which would make reading it here a ReferenceError (#797).
393
- const named = flags.port != null && isPortValue(flags.port) ? Number(flags.port) : null;
394
- const wanted = flags.all
395
- ? decks
396
- : named !== null
397
- ? decks.filter(d => d.port === named)
398
- : decks.filter(d => sameShape(d, mine)).slice(0, 1);
399
-
400
- if (!wanted.length) {
401
- // Three different silences, and saying the wrong one sends the reader
402
- // looking in the wrong place. A machine with no deck at all is not a
403
- // machine whose deck is scoped differently.
404
- const why = !decks.length
405
- ? `no deck is running`
406
- : named !== null
407
- ? `no deck is listening on ${named} ${dash} \`${INVOKED_AS ?? PRODUCT} --status\` lists them`
408
- : `no deck of this shape is running ${dash} \`${INVOKED_AS ?? PRODUCT} --status\` lists the ${decks.length} that ${decks.length === 1 ? "is" : "are"}`;
409
- say(`\n ${tone.muted}${dash} ${why}${tone.reset}\n`);
410
- process.exit(0);
411
- }
412
-
413
- say("");
414
- let refused = false;
415
- for (const d of wanted) {
416
- const was = age(d);
417
- const out = await stopDeck(d);
418
- if (!out.ok) {
419
- refused = true;
420
- say(` ${tone.err}${gWarn} could not stop pid ${d.pid} on ${d.port} ${dash} ${out.reason}${tone.reset}`);
421
- continue;
422
- }
423
- // HOW it went out, not just that it did. "asked" means the deck closed its
424
- // listener, unlinked its registration and left the LAN cleanly; anything
425
- // else means none of that happened and the next boot has litter to sweep.
426
- const how = out.how === "asked"
427
- ? ""
428
- : out.old
429
- ? ` ${tone.muted}(${out.how} ${dash} that deck predates \`--stop\`)${tone.reset}`
430
- : ` ${tone.muted}(${out.how} ${dash} it did not answer)${tone.reset}`;
431
- say(` ${tone.ok}${gOk}${tone.reset} stopped${tone.muted} ${bullet} pid ${d.pid} ${bullet} port ${d.port} ${bullet} was up ${was}${tone.reset}${how}`);
432
- }
433
-
434
- // What is still up, named. A command that ends one of three decks and says
435
- // only "stopped" leaves the reader believing the machine is clear.
436
- const left = decks.filter(d => !wanted.includes(d));
437
- if (left.length) {
438
- say("");
439
- say(` ${tone.muted}${dash} ${left.length} other deck${left.length === 1 ? "" : "s"} still running:${tone.reset}`);
440
- for (const d of left) say(` ${tone.muted}pid ${d.pid} ${bullet} ${d.port} ${bullet} ${where(d)}${tone.reset}`);
441
- say(` ${tone.muted}\`${INVOKED_AS ?? PRODUCT} --stop --port <n>\` ends one ${bullet} \`--stop --all\` ends every deck${tone.reset}`);
442
- }
443
- say("");
444
- process.exit(refused ? 1 : 0);
445
- }
446
-
447
- // The port, and the one piece of argv the deck really does refuse to boot over.
448
- //
449
- // It refused before too — `--port banana` and `--port --no-open` both became
450
- // `Number(…)` → `NaN`, which survived the whole startup (hooks installed,
451
- // claude-swap installed, ccusage probed) and then killed the process from inside
452
- // `listen` with Node's own wording: "options.port should be >= 0 and < 65536.
453
- // Received type number (NaN)." That names neither the flag nor the value the
454
- // user typed, and arrives after a page of green ticks. Same outcome, said here:
455
- // early, in the deck's own voice, quoting the flag and the value back.
456
- //
457
- // An empty `AGENT_DAG_PORT` is an unset one — a variable that did not expand is
458
- // not a request for port zero. `--port ""` never reaches this, because the
459
- // parser records an empty value as `incomplete` and leaves the flag unset.
460
- const envPort = process.env.AGENT_DAG_PORT?.trim();
461
- const rawPort = flags.port ?? (envPort ? envPort : null);
462
- if (rawPort != null && !isPortValue(rawPort)) {
463
- const named = flags.port != null ? "--port" : "AGENT_DAG_PORT";
464
- // Its own glyphs, not `G` — that is declared a hundred lines below and this
465
- // runs at module top level, so reaching for it here would be a temporal dead
466
- // zone and a ReferenceError on the one path that reports a bad port. Both
467
- // helpers read the environment and nothing this file has parsed yet, so
468
- // asking twice on a path that exits immediately costs nothing (#797).
469
- const { dash } = glyphs(unicodeOK());
470
- console.error(`${PRODUCT}: ${named} ${rawPort}: not a port number ${dash} expected 0-65535.`);
471
- process.exit(1);
472
- }
473
- const port = rawPort == null ? 4317 : Number(rawPort);
474
- // Default = machine-wide (capture every CC session on this box). Pass
475
- // `--workspace <path>` (or `--scope`) to restrict to a single tree. Canonicalized
476
- // just below, once the module that owns that rule is loaded.
477
- const rawWorkspace = flags.workspace != null
478
- ? flags.workspace
479
- : (flags.scope ? process.cwd() : "");
480
- const openBrowser = flags.noOpen !== true;
481
- // The events log lives beside the discovery files, so it follows the Claude
482
- // config dir rather than assuming ~/.claude — see src/server/claude-dir.mjs.
483
- const { claudeConfigDir, hasClaudeInstalled } =
484
- await import(pathToFileURL(join(PKG_ROOT, "src/server/claude-dir.mjs")).href);
485
-
486
- // ── the deck's own files, brought to their own directory ────────────────────
487
- //
488
- // BEFORE ANYTHING READS THEM, which is why it is here and not in the server:
489
- // index.mjs reads the preferences at module load, so a migration inside it
490
- // would run after the file it is meant to find had already been looked for in
491
- // the wrong place. deck-home.mjs owns the reasoning; the shape of it is that
492
- // nothing is deleted and a destination that exists is never touched, so this is
493
- // safe to run on every start and does nothing at all on all but the first.
494
- //
495
- // A failure here is reported and then ignored. A read-only home or a missing
496
- // permission is not a reason for the deck not to start — the old paths are
497
- // still there, and the worst case is a deck that keeps using them.
498
- const { deckDataDir, deckLogDir, legacyDeckDir, migrateDeckFiles, sweepTempFiles } =
499
- await import(pathToFileURL(join(PKG_ROOT, "src/server/deck-home.mjs")).href);
500
- {
501
- const fsp = await import("node:fs/promises");
502
- const legacy = legacyDeckDir();
503
- const data = deckDataDir();
504
- const log = deckLogDir();
505
- const { moved } = await migrateDeckFiles({
506
- from: legacy, data, log, fs: fsp,
507
- onError: (name, err) => console.error(`${PRODUCT}: could not move ${name}:`, err?.message ?? err),
508
- });
509
- if (moved.length) console.error(`${PRODUCT}: moved ${moved.join(", ")} to ${data}`);
510
- // The litter an atomic write leaves when its process is killed between the
511
- // write and the rename. Nothing has ever swept it, because the code that
512
- // makes it is not running any more when it is made.
513
- await sweepTempFiles({ dirs: [legacy, data, log], fs: fsp }).catch(() => 0);
514
- }
515
- // CANONICALISED here rather than left as typed: the discovery file publishes
516
- // this path so the hook can tell which decks share one log and elect a single
517
- // writer for it, and two spellings of one file would read as two files.
518
- // startServer takes the value from here, so the two always name the same file.
519
- //
520
- // `resolve` alone was not enough and #793 is what that cost — see
521
- // canonicalLogPath, which owns the rule and explains it beside the election it
522
- // serves.
523
- const { canonicalLogPath } = await import(pathToFileURL(join(PKG_ROOT, "src/server/log-writer.mjs")).href);
524
- const persist = flags.noPersist
525
- ? null
526
- // The log follows the deck rather than Claude Code now — a hundred megabytes
527
- // of rotating events was never configuration, and it was living in another
528
- // application's configuration directory. `--history` still wins, and the
529
- // canonical form is still what the discovery file publishes, so decks that
530
- // share a path still elect one writer for it.
531
- : canonicalLogPath(flags.history ?? join(deckLogDir(), "events.jsonl"));
532
-
533
- const { installHooks, keepDiscovery, removeDiscovery, hasCodexInstalled } =
534
- await import(pathToFileURL(join(PKG_ROOT, "src/server/installer.mjs")).href);
535
- // CODEX_SESSIONS_DIR comes along because the banner below names the directory
536
- // the watcher tails, and the watcher lives in that module. Recomputing the path
537
- // here is how the banner came to print ~/.codex/sessions on machines whose
538
- // sessions are somewhere else entirely — see the row further down.
539
- const { startServer, hookToken, releaseRestart, markDeckReady, CODEX_SESSIONS_DIR, canonicalWorkspace } =
540
- await import(pathToFileURL(join(PKG_ROOT, "src/server/index.mjs")).href);
541
-
542
- // Resolved here rather than left as typed, for the reason the events log above
543
- // is: the discovery file publishes this path, and the hook that reads it runs in
544
- // a process whose cwd is the agent's — so a relative `--workspace ./sub` meant
545
- // one directory to the Codex watcher inside this process and a different one per
546
- // agent to the hook. One canonical spelling, computed in the one process that
547
- // knows what the user meant, is what both capture paths compare against. See
548
- // canonicalWorkspace.
549
- const workspace = canonicalWorkspace(rawWorkspace);
550
-
551
- // Whether the server starts the Codex rollout watcher. Nothing is installed
552
- // and no directory is created either way — Codex hooks are not used any more,
553
- // so `--codex` only means "watch even though ~/.codex/ is not there yet",
554
- // which is the right answer for a machine where Codex arrives later.
555
- const wantCodex = flags.noCodex
556
- ? false
557
- : (flags.codex === true || hasCodexInstalled());
558
-
559
- // The same question for the other CLI, and the one nobody was asking. README
560
- // offers "Claude Code CLI or OpenAI Codex CLI (or both)"; a Codex-only machine
561
- // nonetheless got a Python account-switcher installed for a CLI it does not
562
- // have, an accounts panel open on first run, and a banner line telling it to
563
- // sign into that CLI (#402). Everything the deck installs or opens on the
564
- // Claude side now hangs off this one answer, and it is stated in the banner so
565
- // a wrong answer is visible rather than mysterious.
566
- //
567
- // `--claude` is the escape hatch for a false negative, which is the failure
568
- // that matters: hasClaudeInstalled looks for the binary and for traces of use,
569
- // and a machine that hides Claude Code from both would otherwise lose its hooks
570
- // with no way to ask for them back. `--no-claude` is the opt-out the Claude side
571
- // never had — the mirror of --no-codex — and it is also what a Codex-only user
572
- // with a settings.json the installer refuses to rewrite needs, since that
573
- // refusal is fatal at boot on a component they do not use.
574
- const wantClaude = flags.noClaude
575
- ? false
576
- : (flags.claude === true || hasClaudeInstalled());
577
-
578
- const WEB_DIST = join(PKG_ROOT, "dist", "web", "index.html");
579
- if (!existsSync(WEB_DIST)) {
580
- console.error(`${PRODUCT}: ui not built. run \`npm run build\` (or \`pnpm build\`) first.`);
581
- process.exit(1);
582
- }
583
-
584
- // ── the terminal we are printing into ─────────────────────────────────────────
585
- // Asked once, degraded from there — see src/server/term.mjs, which is where all
586
- // of this is decided and asserted. Below this point the deck writes no escape of
587
- // its own: colour comes from `P`, glyphs from `G`, layout from statusLine. That
588
- // is what makes NO_COLOR, a pipe, a CI log and a legacy Windows console one
589
- // question rather than thirty separate ones nobody remembers to ask.
590
- const tty = Boolean(process.stdout.isTTY);
591
- const PROFILE = colorProfile({ isTTY: tty });
592
- const P = palette(PROFILE);
593
- const UNICODE = unicodeOK();
594
- const G = glyphs(UNICODE);
595
- const LINKS = supportsHyperlinks({ profile: PROFILE });
596
- // The terminal's prefers-reduced-motion: nothing sleeps, spins or repaints in a
597
- // pipe, under CI, or with NO_COLOR set.
598
- const MOTION = motionOK({ isTTY: tty, profile: PROFILE });
599
- const write = (s) => process.stdout.write(s);
600
- // Read per line, never cached: a terminal can be resized while the deck runs,
601
- // and the pulse below is still on screen hours later.
602
- const cols = () => termColumns(process.stdout);
603
- const sleep = ms => new Promise(r => setTimeout(r, ms));
604
- const fileLink = (path) => link(path, pathToFileURL(path).href, LINKS);
605
-
606
- /**
607
- * What is still happening after the report ended, in three words or so.
608
- *
609
- * #742 left one job able to outlive the boot — an install of claude-swap that
610
- * the report stopped waiting for — and the terminal said nothing about it once
611
- * the rows were done. That is the wrong way round: the row that had finished
612
- * was the one blinking, and the thing that really was working sat still. Now
613
- * the pulse line carries the label while the job runs and drops it when the job
614
- * settles, which is also what decides whether the line moves at all. Declared
615
- * here rather than beside `registered` below, because reportStartup sets it and
616
- * runs long before that line does.
617
- */
618
- let pulseBusy = null;
619
-
620
- // ── the cursor ────────────────────────────────────────────────────────────────
621
- // Hidden for as long as anything of ours is moving — the reveal, the spinner,
622
- // the pulse — and put back on every way out of this process: the ordinary exit,
623
- // all three signals, and an uncaught throw, which reaches 'exit' after Node has
624
- // printed it. Half of this is worse than none: a deck that dies with the cursor
625
- // hidden leaves the user's shell with no cursor and nothing to do about it but
626
- // `reset`.
627
- let cursorHidden = false;
628
- const showCursor = () => {
629
- if (!cursorHidden) return;
630
- cursorHidden = false;
631
- try { write(CURSOR_SHOW); } catch { /* stdout is gone; nothing left to restore */ }
632
- };
633
- if (MOTION) { cursorHidden = true; write(CURSOR_HIDE); }
634
- process.on("exit", showCursor);
635
- // SIGHUP is the one signal this process does not otherwise handle, so its
636
- // default action would end us before 'exit' could run. Handled only to put the
637
- // cursor back and then die of it exactly as before — the supervisor reads the
638
- // signal, not an exit code.
639
- process.on("SIGHUP", () => { showCursor(); dieOfSignal("SIGHUP"); });
640
-
641
- // ── rows ──────────────────────────────────────────────────────────────────────
642
- // The status column is computed from the longest label. It used to be counted
643
- // into each string as trailing spaces, so any new row, or any label a character
644
- // longer, silently broke the alignment of every other one.
645
- const LABELS = [
646
- "workspace", "Claude hooks", "Codex sessions", "claude-swap", "accounts",
647
- "ccusage", "update", "name", "server ready", "log", "unknown option",
648
- "missing value",
649
- ];
650
- const LABEL_W = labelColumn(LABELS);
651
-
652
- function row({ mark = " ", tone = P.ok, label = "", detail = "", detailTone = P.muted, keep = false }) {
653
- return statusLine({
654
- mark, label, detail, keep, labelWidth: LABEL_W, columns: cols(), ellipsis: G.ellipsis,
655
- paint: {
656
- mark: (s) => `${tone}${s}${P.reset}`,
657
- detail: (s) => `${detailTone}${s}${P.reset}`,
658
- },
659
- }) + "\n";
660
- }
661
-
662
- // ── the wordmark ──────────────────────────────────────────────────────────────
663
-
664
- /**
665
- * Hold anything else that wants to speak until the art is finished.
666
- *
667
- * #742, found while watching a first run through a pty: the startup jobs run
668
- * UNDER the reveal on purpose, and one of them failing writes to console.error
669
- * the moment it fails — which put
670
- *
671
- * ccdeck ccusage: install failed: npm install ccusage failed: spawn npm ENOENT
672
- *
673
- * between the second and third rows of the logo. A wordmark with a stack of
674
- * someone else's bad news through the middle of it is the first thing a new
675
- * user sees, and it reads as a crash rather than as a note.
676
- *
677
- * The window is the reveal and nothing else — about 180ms — so at worst a
678
- * message arrives a fifth of a second later than it would have, on the one
679
- * stretch of the boot where there is nowhere for it to go. Restored in a
680
- * `finally`, so a throw inside the reveal cannot leave the process mute.
681
- */
682
- function holdConsole() {
683
- const held = [];
684
- const real = { warn: console.warn, error: console.error, log: console.log };
685
- for (const k of Object.keys(real)) console[k] = (...args) => { held.push([k, args]); };
686
- return () => {
687
- Object.assign(console, real);
688
- for (const [k, args] of held) real[k](...args);
689
- };
690
- }
691
-
692
- async function printBanner() {
693
- const { lines } = wordmark({ columns: cols(), version: PKG_VERSION, profile: PROFILE, unicode: UNICODE, pal: P });
694
- const release = holdConsole();
695
- try {
696
- for (const line of lines) {
697
- write(line + "\n");
698
- // A reveal, not a wait. The once-per-session work is already running under
699
- // it (see startupWork), so the art costs the boot nothing and the deck is
700
- // ready about when the last row lands. What used to be here — 560ms of
701
- // spinner at "loading…" before a single art line — was dead time in a tool
702
- // whose documented entry point is `npx ccdeck`.
703
- if (MOTION && line) await sleep(45);
704
- }
705
- } finally {
706
- release();
707
- }
708
- }
709
-
710
- // ── a step, with a spinner only if it is slow enough to need one ──────────────
711
- // The interval's first frame is 80ms away, so anything already settled when we
712
- // get here paints nothing at all and the row below is the only trace of it.
713
-
714
- async function step(label, work) {
715
- if (!MOTION) return work;
716
- const frames = spinnerFrames(UNICODE);
717
- // Kept inside the terminal: a label that wraps is a label the \r below can
718
- // only half erase, and what is left of it stays under the row that follows.
719
- // Six columns for the indent and the spinner, four more so the elapsed
720
- // seconds have somewhere to go without pushing the label off the edge.
721
- const text = fit(label, cols() - 10, G.ellipsis);
722
- const started = Date.now();
723
- let i = 0;
724
- let widest = 0;
725
- const iv = setInterval(() => {
726
- const line = ` ${P.accent}${frames[i++ % frames.length]}${P.reset} ${P.muted}${text}${elapsedSuffix(Date.now() - started)}${P.reset}`;
727
- widest = Math.max(widest, visibleWidth(line));
728
- write(`\r${line}`);
729
- }, 80);
730
- try {
731
- return await work;
732
- } finally {
733
- clearInterval(iv);
734
- // Cleared rather than overwritten: the row that follows is a different
735
- // length, and relying on it to be the longer of the two is how a spinner
736
- // leaves its own tail on screen. Measured rather than computed, because the
737
- // line grows when the elapsed seconds appear and again when they reach two
738
- // digits. Nothing to clear if it never painted.
739
- if (i) write("\r" + " ".repeat(widest) + "\r");
740
- }
741
- }
742
-
743
- /**
744
- * The once-per-session work, all of it started at once and none of it awaited.
745
- *
746
- * Hook install, the claude-swap probe and the registry lookup have nothing to
747
- * do with each other and nothing to do with the wordmark, so they run underneath
748
- * the reveal instead of queueing behind it — the animation then costs the boot
749
- * nothing and the deck is ready about when the last art row lands. Every one of
750
- * them is given its rejection handler here, at the moment it is created, since a
751
- * promise that settles before anything awaits it is otherwise an unhandled
752
- * rejection.
753
- */
754
- function startupWork() {
755
- // Every job below this line serves Claude Code and only Claude Code: the
756
- // hooks go in Claude Code's settings.json, claude-swap switches Claude
757
- // accounts, and ccusage reads Claude Code's own session logs. On a machine
758
- // without Claude Code all three are work done for a CLI that is not there —
759
- // two of them installs the user did not ask for — so they are not started at
760
- // all rather than started and then reported as failures. `null` is how each
761
- // one says "not attempted", which reportStartup tells apart from "tried and
762
- // could not".
763
-
764
- // Settings the installer cannot parse are settings it cannot rewrite without
765
- // losing them, so it refuses — and that refusal is reported rather than
766
- // thrown, because it is the only thing the user can act on.
767
- const hooks = wantClaude
768
- ? installHooks({ provider: "claude" }).then(v => ({ ok: true, v }), err => ({ ok: false, err }))
769
- : Promise.resolve(null);
770
-
771
- // claude-swap backs the multi-account panel, and an empty store leaves that
772
- // panel useless even when the tool is there — so the account already signed
773
- // in is registered once. Bounded inside seedFirstAccount: empty store only,
774
- // once ever, never with NO_INSTALL set.
775
- //
776
- // `cswapInstalling` is the other half, and it is what keeps a first run from
777
- // spending the boot's whole deadline on a question that has already been
778
- // answered: ensureCswap resolves it the moment it commits to an install, so
779
- // the report can stop waiting then rather than eight seconds later. It never
780
- // settles on the machines where there is nothing to install, which is every
781
- // machine after the first run.
782
- let sayInstalling;
783
- const cswapInstalling = new Promise(r => { sayInstalling = r; });
784
-
785
- const cswap = (async () => {
786
- if (!wantClaude) return null;
787
- const { ensureCswap } = await import(pathToFileURL(join(PKG_ROOT, "src/server/cswap-install.mjs")).href);
788
- const cs = await ensureCswap({ onInstalling: () => sayInstalling() });
789
- const usable = cs.state === "present" || cs.state === "installed" || cs.state === "upgrading";
790
- if (!usable) return { cs, seed: null };
791
- const { seedFirstAccount } = await import(pathToFileURL(join(PKG_ROOT, "src/server/claude-accounts.mjs")).href);
792
- return { cs, seed: await seedFirstAccount().catch(() => ({ state: "failed" })) };
793
- })().catch(() => null);
794
-
795
- // ccusage backs the usage-history modal. Primed at boot rather than on first
796
- // open so a cold machine pays the install while the deck is still starting.
797
- // Nothing is lost by skipping the prime: runCcusage falls back to npx, so the
798
- // modal still answers if it is ever opened — it just pays the wait itself.
799
- const ccusage = (async () => {
800
- if (!wantClaude) return null;
801
- if (process.env.AGENTS_DECK_NO_INSTALL === "1") return null;
802
- const { primeCcusage } = await import(pathToFileURL(join(PKG_ROOT, "src/server/ccusage.mjs")).href);
803
- return primeCcusage();
804
- })().catch(() => null);
805
-
806
- // A newer release on npm, said once, in the place the upgrade gets typed.
807
- // Hard-capped so a slow registry cannot delay the server — the answer is
808
- // usually already cached in ~/.agents-deck/.self-update-check anyway. It has
809
- // to resolve BEFORE the pulse indicator starts writing over the last line.
810
- const update = Promise.race([
811
- import(pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href)
812
- .then(m => m.versionReport({ running: PKG_VERSION, pkgRoot: PKG_ROOT }))
813
- .then(r => (r?.notice?.kind === "upgrade" ? r : null))
814
- .catch(() => null),
815
- new Promise(r => setTimeout(() => r(null), 1200)),
816
- ]);
817
-
818
- return { hooks, cswap, cswapInstalling, ccusage, update };
819
- }
820
-
821
- /** The same work, said out loud, in a fixed order — a boot whose rows arrive in
822
- * whatever order the network settled is a boot nobody can scan twice. */
823
- async function reportStartup(jobs) {
824
- // What the whole report may spend waiting, shared by every job under it
825
- // rather than granted to each — four jobs at eight seconds each is a
826
- // thirty-two second boot that no individual deadline would object to.
827
- const left = budget(bootDeadlineMs());
828
-
829
- write(row({
830
- mark: G.ok, label: "workspace",
831
- detail: workspace === "" ? "(all)" : workspace,
832
- detailTone: workspace === "" ? P.warn : P.muted,
833
- }));
834
-
835
- const hooks = await step(`installing Claude hooks${G.ellipsis}`, jobs.hooks);
836
- if (hooks === null) {
837
- // Said in the same shape as the Codex row below, because it is the same
838
- // sentence: this deck is not watching that CLI, and here is why. It also
839
- // retires the one boot failure a Codex-only machine could hit — an
840
- // unparseable or unwritable settings.json used to exit(1) below, killing a
841
- // deck over a file belonging to a CLI the user does not run.
842
- write(row({ label: "Claude hooks", detail: `skipped ${G.dash} no Claude Code found, or --no-claude` }));
843
- } else if (!hooks.ok) {
844
- // The file it names is one only the user can repair, and every Claude Code
845
- // session on this machine is reading it too.
846
- write(row({ mark: G.fail, tone: P.err, label: "Claude hooks", detail: "not installed" }));
847
- // THE WAY OUT, printed where the wall is. The installer's message is good —
848
- // the path, the reason, "fix the file or move it aside" — and it is a file
849
- // the user may not be able to edit: root-owned, on read-only media, or
850
- // simply not theirs. This deck knows the remedy and used to keep it in a
851
- // comment: --no-claude runs everything else, which on a Codex-only machine
852
- // is the whole deck, over a settings.json belonging to a CLI they do not
853
- // use.
854
- console.error(`\n ${PRODUCT}: ${hooks.err.message}`);
855
- console.error(` Or start with --no-claude to run without Claude hooks.\n`);
856
- process.exit(1);
857
- } else {
858
- write(row({ mark: G.ok, label: "Claude hooks", detail: fileLink(hooks.v.hookPath) }));
859
- }
860
-
861
- // Codex CLI hooks never fire on Windows (sandbox refuses to spawn the hook
862
- // command). Instead the server tails Codex's rollout JSONL files directly, so
863
- // there's nothing to install and no /hooks trust step. We just confirm Codex
864
- // is present and let the watcher pick up sessions.
865
- if (wantCodex) {
866
- // The directory the watcher actually tails, imported from the module that
867
- // owns it rather than rebuilt from homedir() here. CODEX_HOME relocates the
868
- // whole tree, and this row is the only diagnostic the deck prints about
869
- // Codex — so when sessions do not show up, a path computed a second way
870
- // sends the user to inspect a directory the deck never opened.
871
- write(row({ mark: G.ok, label: "Codex sessions", detail: `watching ${fileLink(CODEX_SESSIONS_DIR)}` }));
872
- } else {
873
- write(row({ label: "Codex sessions", detail: `skipped ${G.dash} no ~/.codex/, or --no-codex` }));
874
- }
875
-
876
- // Bounded, because this is the job that made a first boot look hung: on a
877
- // machine with neither claude-swap nor a Python toolchain it fetches a uv
878
- // binary and then builds an environment with it, and the report used to wait
879
- // out both. See src/server/boot-deadline.mjs. Nothing is cancelled — the
880
- // install carries on and says how it went when it knows.
881
- const swapWait = await step(`checking claude-swap${G.ellipsis}`, Promise.race([
882
- left.within(jobs.cswap),
883
- // The install announcing itself. Not a timeout — a decision, arriving in
884
- // about a second on the boot that would otherwise have paid the full
885
- // deadline for news it already had.
886
- jobs.cswapInstalling.then(() => ({ done: false, installing: true })),
887
- ]));
888
- if (swapWait.done) writeSwapRows(swapWait.value);
889
- else {
890
- write(row({
891
- label: "claude-swap",
892
- // Both halves earn their place, and both have to survive an 80-column
893
- // terminal: what the job is doing, and that waiting for it is not the
894
- // user's problem. The row that only said the first is the row this
895
- // replaces — a spinner at "checking claude-swap…" says that much.
896
- detail: swapWait.installing
897
- ? `installing in the background ${G.dash} the deck is ready`
898
- : `still setting up ${G.dash} the deck is ready`,
899
- }));
900
- // Handed to the pulse line, which is the only thing still on screen once
901
- // the rows are done — and taken back the moment the job settles, whichever
902
- // way it settled.
903
- pulseBusy = swapWait.installing ? "installing claude-swap" : "setting up claude-swap";
904
- jobs.cswap.then(
905
- late => { pulseBusy = null; writeSwapRows(late, { late: true }); },
906
- () => { pulseBusy = null; },
907
- );
908
- }
909
-
910
- const cu = await left.within(jobs.ccusage);
911
- writeCcusageRow(cu.done ? cu.value : null);
912
-
913
- const upgrade = await jobs.update;
914
- if (upgrade) {
915
- write(row({
916
- mark: G.up, tone: P.warn, label: "update",
917
- detail: `v${upgrade.notice.to} available ${G.dash} ${upgrade.command}`,
918
- }));
919
- }
920
-
921
- // Which name this deck was started under, when that is knowable — a notice,
922
- // never a refusal. 95% of installs are on the two old names and the update
923
- // path runs through this very process, so a build that declined to boot under
924
- // one of them would kill the deck on the machine where the deck is what would
925
- // have explained why. Nothing at all is printed wherever the typed name
926
- // cannot be proven (a Windows global install, a git checkout): telling
927
- // somebody who already types `ccdeck` to type `ccdeck` is the one failure
928
- // that would make this row worth ignoring. See src/server/invoked-as.mjs.
929
- const rename = renameNotice({ invoked: INVOKED_AS, pkgRoot: PKG_ROOT, dash: G.dash });
930
- if (rename) {
931
- write(row({ mark: G.warn, tone: P.warn, label: "name", detail: rename.said }));
932
- // The line that carries the value: for a global install there is nothing to
933
- // install and nothing to download, only six different characters to type.
934
- write(row({ label: "", detail: rename.fix }));
935
- }
936
- }
937
-
938
- /**
939
- * The claude-swap rows, wherever in the boot they end up being printed.
940
- *
941
- * `late` is the one difference, and it is not cosmetic: by the time a late row
942
- * arrives the pulse indicator owns the last line and repaints it with `\r`, so
943
- * a row written without a newline first would be drawn over on the next beat.
944
- * Every other thing that speaks after boot — reportUnregistered,
945
- * reportReregistered — opens with the same newline for the same reason.
946
- */
947
- function writeSwapRows(swap, { late = false } = {}) {
948
- const cs = swap?.cs;
949
- // Collected rather than written one at a time, because a late report opens
950
- // with a newline and there is exactly one of those however many rows follow.
951
- let out = "";
952
- if (!wantClaude) {
953
- // claude-swap is a Python tool that switches Claude Code accounts, and the
954
- // deck used to fetch a uv binary to install it on machines with no Claude
955
- // Code at all. Saying so is the point of the row: it is the one place a
956
- // user can learn that the accounts panel is missing on purpose.
957
- out += row({ label: "claude-swap", detail: `skipped ${G.dash} accounts are Claude-only` });
958
- } else if (cs?.state === "present") {
959
- out += row({ mark: G.ok, label: "claude-swap", detail: `v${cs.version} (accounts panel enabled)` });
960
- } else if (cs?.state === "installed") {
961
- out += row({ mark: G.ok, label: "claude-swap", detail: `installed v${cs.version} via ${cs.via}` });
962
- } else if (cs?.state === "upgrading") {
963
- out += row({ mark: G.ok, label: "claude-swap", detail: `v${cs.version}, upgrading to v${cs.latest} in background` });
964
- } else if (cs?.state === "skipped") {
965
- out += row({ mark: G.ok, label: "claude-swap", detail: "not installed (AGENTS_DECK_NO_INSTALL=1)" });
966
- } else {
967
- const how = cs?.reason === "no_installer"
968
- ? `not installed ${G.dash} the accounts panel needs it`
969
- : cs?.reason === "not_on_path"
970
- ? `installed via ${cs.via} but not on PATH ${G.dash} add ${
971
- process.platform === "win32" ? "%USERPROFILE%\\.local\\bin" : "~/.local/bin"
972
- }`
973
- : `install failed${cs?.via ? ` via ${cs.via}` : ""}`;
974
- out += row({ mark: G.fail, tone: P.warn, label: "claude-swap", detail: how });
975
- // A URL is not an answer when someone just wants the panel to work. Print
976
- // the command for THIS machine, picked from what is already on it.
977
- if (cs?.hint) out += row({ label: "", detail: cs.hint });
978
- }
979
-
980
- if (swap?.seed?.state === "added") {
981
- out += row({ mark: G.ok, label: "accounts", detail: "registered the signed-in account (cswap add)" });
982
- } else if (swap?.seed?.state === "failed" || swap?.seed?.state === "nothing-to-add") {
983
- out += row({ label: "accounts", detail: `panel empty ${G.dash} sign in to Claude Code, then run cswap add` });
984
- }
985
-
986
- write(late ? "\n" + out : out);
987
- }
988
-
989
- /**
990
- * The ccusage row.
991
- *
992
- * `null` covers both of the ways there is nothing to say — the job was not
993
- * attempted, and the job had not answered by the time the boot's deadline ran
994
- * out. Neither deserves a row: unlike claude-swap there is no install to wait
995
- * for here, because primeCcusage starts one and returns without it, so a
996
- * ccusage that is slow to answer is slow at resolving a path and will be
997
- * resolved again the first time the usage modal is opened.
998
- */
999
- function writeCcusageRow(cu) {
1000
- if (cu?.state === "present") write(row({ mark: G.ok, label: "ccusage", detail: `v${cu.version}` }));
1001
- else if (cu?.state === "updating") write(row({ mark: G.ok, label: "ccusage", detail: `v${cu.version}, checking for update` }));
1002
- // A ccusage the user provided, named rather than versioned — reading a
1003
- // version out of it means running it, and a status row is not worth a spawn.
1004
- // Naming the file is the more useful half anyway: it is the answer to "which
1005
- // ccusage is this deck actually going to run", which is a question a machine
1006
- // with a managed install AND a PATH copy could not answer before #433.
1007
- else if (cu?.state === "user") write(row({ mark: G.ok, label: "ccusage", detail: `your own copy ${G.dash} ${cu.bin}` }));
1008
- else if (cu?.state === "installing") write(row({ mark: G.ok, label: "ccusage", detail: "installing in background" }));
1009
- }
1010
-
1011
- // Asking the supervisor to bring us back. It is the only party that can, and
1012
- // only after this process is gone — which is precisely what keeps the
1013
- // replacement from racing this listener onto a random fallback port.
1014
- let restarting = false;
1015
- // Outer bound on the supervisor's answer below. It cannot be reached today —
1016
- // the fetch has a deadline of its own and every path through it replies — but
1017
- // `restarting` is a latch, and a latch with no way out is how a deck ends up
1018
- // silently refusing every restart for the rest of its life.
1019
- const UPGRADE_ANSWER_MS = 150_000;
1020
- let upgradeTimer = null;
1021
-
1022
- // Whether the rest of this file has finished running.
1023
- //
1024
- // The server below starts accepting connections from inside startServer, before
1025
- // that call has returned — so /api/restart is reachable for the whole of the
1026
- // boot that follows it: the startup report, the port report to the supervisor,
1027
- // the discovery file and its first fsynced write, and on a cold start the
1028
- // browser spawn. A restart landing in that window used to reach `shutdown`
1029
- // before the binding holding it was initialised and die of a ReferenceError,
1030
- // having already set the latch above, with nothing left to clear it — after
1031
- // which every restart from every tab was answered "ok" and did nothing, for the
1032
- // life of the process (#448).
1033
- //
1034
- // So an ask that arrives too early is held rather than run: the user asked for
1035
- // something this deck can genuinely give a moment later, and refusing outright
1036
- // would put back the same silence in a politer form. BOOT_RESTART_MS is the
1037
- // outer bound, for the reason UPGRADE_ANSWER_MS above is one. It used to be a
1038
- // bound that got used — before #742 the report waited out a real `uv tool
1039
- // install`, so the window it covers was minutes wide. It is now the boot
1040
- // deadline plus the browser spawn, comfortably inside ten seconds, and this
1041
- // stays as the thing that makes that a fact rather than a belief. Ten seconds
1042
- // in, the ask is run; the respawn skips the report entirely and is up in about
1043
- // a second.
1044
- let booted = false;
1045
- let heldRestart = null;
1046
- let bootTimer = null;
1047
- const BOOT_RESTART_MS = 10_000;
1048
-
1049
- const requestRestart = (mode) => {
1050
- if (restarting) return;
1051
- restarting = true;
1052
- if (!booted) {
1053
- heldRestart = { mode };
1054
- bootTimer = setTimeout(() => { bootTimer = null; runHeldRestart(); }, BOOT_RESTART_MS);
1055
- bootTimer.unref?.();
1056
- // Said out loud for the same reason abandonUpgrade below is: the tab has
1057
- // already been told its restart was accepted, and a second of nothing
1058
- // happening on this terminal is otherwise indistinguishable from the bug
1059
- // this replaces.
1060
- write(`\n ${P.warn}${G.restart}${P.reset} ${P.muted}restart queued ${G.dash} still starting up${P.reset}\n`);
1061
- return;
1062
- }
1063
- beginRestart(mode);
1064
- };
1065
-
1066
- // The restart itself, once there is a booted deck to end. Split out of
1067
- // requestRestart so the held ask above can re-enter it without tripping the
1068
- // latch it is already holding.
1069
- //
1070
- // Everything here runs inside one try: the whole point of #448 is that a throw
1071
- // on this path is not merely a failed restart but a permanent one, because the
1072
- // latch it leaves behind outlives it. There is no line in here worth dying for.
1073
- function beginRestart(mode) {
1074
- try {
1075
- // "npx" means the newer code is not on this disk at all, so it has to be
1076
- // fetched — and this process keeps serving while that happens. Exiting first
1077
- // is what made every failed upgrade an outage: the SSE stream dropped, hook
1078
- // events fired into the gap were lost outright (hook/hook.js is
1079
- // fire-and-forget with a 1s timeout and no retry), and the canvas came back
1080
- // with whatever was in flight stuck until the stale sweeper reaped it — all
1081
- // of it paid before anyone knew whether npm could even resolve the version.
1082
- // Nothing is torn down here now; the supervisor answers when it knows.
1083
- if (mode === "npx") {
1084
- upgradeTimer = setTimeout(() => abandonUpgrade("no answer from the supervisor"), UPGRADE_ANSWER_MS);
1085
- upgradeTimer.unref?.();
1086
- // Armed before the ask, not after: a send that throws is a supervisor that
1087
- // can no longer answer, and the deck has to come back out of the latch on
1088
- // its own rather than wait out an answer that cannot arrive.
1089
- try { process.send({ type: "upgrade" }); }
1090
- catch (err) { abandonUpgrade(err?.message ?? "the supervisor is no longer listening"); }
1091
- return;
1092
- }
1093
- const to = restartTarget();
1094
- write(`\n ${P.warn}${G.restart}${P.reset} ${P.muted}restarting${to ? ` ${G.arrow} v${to}` : ""}${G.ellipsis}${P.reset}\n`);
1095
- shutdown(RESTART_CODE);
1096
- } catch (err) {
1097
- abandonRestart(err);
1098
- }
1099
- }
1100
-
1101
- // The ask that was waiting for the boot to finish, now that it has. Safe to
1102
- // call when nothing is waiting, which is every ordinary boot.
1103
- function runHeldRestart() {
1104
- if (!heldRestart) return;
1105
- const { mode } = heldRestart;
1106
- heldRestart = null;
1107
- clearTimeout(bootTimer);
1108
- bootTimer = null;
1109
- beginRestart(mode);
1110
- }
1111
-
1112
- // A restart that could not be started, said out loud and then let go of.
1113
- //
1114
- // Both halves of the latch have to come down — this file's and the server's —
1115
- // because a latch nothing clears is precisely how one failed request turned
1116
- // into a deck that refused every restart afterwards while answering "ok" to
1117
- // each one (#448). The reason is folded onto one line by oneLine: the terminal
1118
- // under this is repainted every 800ms by the pulse, and a stack written into
1119
- // that is a stack nobody can read (#432).
1120
- //
1121
- // A declaration rather than a const, like `shutdown` below and for the same
1122
- // reason: this is the handler for a binding that was not there yet, and it must
1123
- // not be capable of becoming the next one.
1124
- function abandonRestart(err) {
1125
- clearTimeout(bootTimer);
1126
- bootTimer = null;
1127
- heldRestart = null;
1128
- restarting = false;
1129
- releaseRestart();
1130
- write(
1131
- `\n ${P.err}${G.fail}${P.reset} ${P.muted}restart failed ${G.dash} still on ${P.reset}v${PKG_VERSION}\n` +
1132
- ` ${P.muted}${oneLine(err?.stack ?? err, Math.max(20, cols() - 6), G.ellipsis)}${P.reset}\n`,
1133
- );
1134
- }
1135
-
1136
- // The upgrade did not happen and this deck is still the deck. Said out loud
1137
- // because the terminal has just printed that a fetch was starting, and left
1138
- // unsaid it reads as a restart that hung.
1139
- const abandonUpgrade = (why) => {
1140
- clearTimeout(upgradeTimer);
1141
- restarting = false;
1142
- // The server's own latch, which no longer has an exiting process to clear it.
1143
- releaseRestart();
1144
- write(
1145
- `\n ${P.warn}${G.cancel}${P.reset} ${P.muted}update not applied ${G.dash} still on ${P.reset}v${PKG_VERSION}\n` +
1146
- (why ? ` ${P.muted}${why}${P.reset}\n` : ""),
1147
- );
1148
- };
1149
-
1150
- // The supervisor's verdict on the fetch it was asked for. Only it can answer:
1151
- // the fetch is its child, and it is the process that will still be here when
1152
- // this one exits.
1153
- process.on("message", (m) => {
1154
- if (!restarting || !m || typeof m !== "object") return;
1155
- if (m.type === "upgrade-ready") {
1156
- clearTimeout(upgradeTimer);
1157
- // The replacement is on the machine now, so this is the last moment the
1158
- // port is worth holding: exiting hands it straight over.
1159
- write(`\n ${P.warn}${G.restart}${P.reset} ${P.muted}updating via npx${G.ellipsis}${P.reset}\n`);
1160
- shutdown(UPGRADE_CODE);
1161
- } else if (m.type === "upgrade-refused") {
1162
- abandonUpgrade(m.error);
1163
- }
1164
- });
1165
-
1166
- // What a restart would land on. Read from disk now rather than remembered from
1167
- // boot, because the whole point is that the two differ.
1168
- function restartTarget() {
1169
- try { return JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8")).version ?? null; }
1170
- catch { return null; }
1171
- }
1172
-
1173
- // The three things `shutdown` has to tear down, named before the boot that
1174
- // fills them in rather than by it. From the line below onwards this process is
1175
- // answering HTTP, and /api/restart can therefore reach `shutdown` at any moment
1176
- // after it — including moments at which none of these exist yet. `let … = null`
1177
- // is what makes that a question shutdown can ask instead of a ReferenceError it
1178
- // dies of; the boot queue in requestRestart is what makes it a question it
1179
- // almost never has to ask. See #448.
1180
- let server = null;
1181
- let discovery = null;
1182
- let discoveryFile = null;
1183
-
1184
- // This worker does not outlive the supervisor that started it (#702).
1185
- //
1186
- // Armed HERE rather than beside the signal handlers at the bottom of the file,
1187
- // for the reason the three `let`s above are where they are: this is the first
1188
- // line from which `shutdown` can be called without dying of a temporal dead
1189
- // zone, and the window that matters is the boot — the supervisor can be killed
1190
- // while the report is still printing, and a worker orphaned in there is a
1191
- // worker that binds its port a moment later and then holds it for a day.
1192
- //
1193
- // A no-op with nothing supervising us, which is `node bin/deck.js` run by hand:
1194
- // dieWithParent arms only when there is a channel, the same question SUPERVISED
1195
- // asks above. Shutdown code 0 because there is nobody left to read it — the
1196
- // point is only that discovery is unregistered and the port let go of on the
1197
- // way out, rather than left for the next boot's stale sweep.
1198
- dieWithParent(() => shutdown(0));
1199
-
1200
- // The listen, begun HERE and awaited below the startup report rather than after
1201
- // it. The report is a narration; the port is the product, and it was queued
1202
- // behind three tool probes for no reason but the order these two statements
1203
- // were written in (#483).
1204
- //
1205
- // What that cost: `reportStartup` awaits `ensureCswap`, which on a machine with
1206
- // no Python tooling runs a real `uv tool install` under a 180-second timeout. So
1207
- // the deck printed its rows and then refused every connection until that install
1208
- // was over — on a first run, which is the one boot a new user judges the tool by.
1209
- // #476 stopped that job blocking the event loop; the socket was shut either way,
1210
- // because the bind had not been attempted yet.
1211
- //
1212
- // Nothing in the report has to finish before the socket opens, and the window
1213
- // this opens is narrow on purpose — the discovery file and the browser are both
1214
- // still written after the report, so the only callers who can arrive inside it
1215
- // are a tab left open by an earlier deck and the user's own curl:
1216
- //
1217
- // • the event log is replayed and the sequence counter primed INSIDE
1218
- // startServer, before it binds — so /events and /api/events answer from a
1219
- // full buffer from the first connection, not a growing one.
1220
- // • the hook install can only make hooks fire; a hook that fires early finds
1221
- // no discovery file and posts nowhere, exactly as it does today.
1222
- // • claude-swap: cswapBin memoizes only a lookup that WORKED, so a probe
1223
- // landing mid-install caches nothing and the next one asks again — see
1224
- // resetCswapBin's note. The accounts panel can report the tool missing for
1225
- // the second it is missing, and answers properly the moment it is not.
1226
- // • ccusage: getRunner awaits the very `_installing` promise primeCcusage
1227
- // started, so a request in this window joins that install rather than
1228
- // racing a second one.
1229
- //
1230
- // Settled into a tagged result rather than left bare, for the reason every job
1231
- // in startupWork carries its own handler: this promise now lives across the
1232
- // whole report, and a bind that fails in there with nothing attached to it is an
1233
- // unhandledRejection — which Node answers by killing the process over a port it
1234
- // could have named.
1235
- // ── is one of ours already up? ────────────────────────────────────────────────
1236
- // A bare `ccdeck` typed beside a deck that is already running used to build a
1237
- // second everything on a random port, and neither half mentioned the other.
1238
- // src/server/running-deck.mjs carries the whole argument, the registry read and
1239
- // the token handshake that makes the answer trustworthy; this is only where it
1240
- // is asked.
1241
- //
1242
- // ASKED EXACTLY HERE, and the position is the point. Everything the answer
1243
- // depends on is resolved above — the workspace, the canonical log path, which
1244
- // of the two CLIs this deck would serve — and nothing below has run yet: no
1245
- // port bound, no hooks installed, no tool probed, no banner painted, no
1246
- // discovery file written. An attach therefore leaves the machine precisely as
1247
- // it found it, which is what makes it safe to do without asking.
1248
- //
1249
- // A respawn is excluded on its own line rather than left to the flag check.
1250
- // The supervisor relaunches us with `--port <bound>`, which is a shaping flag
1251
- // and would be excluded anyway — but a restart is THIS deck coming back, and
1252
- // having that read as "somebody typed ccdeck twice" is an accident waiting for
1253
- // the day the supervisor stops passing the port.
1254
- if (!RESPAWN && !asksForOwnDeck(flags)) {
1255
- const live = await runningDeck({
1256
- want: { workspace, persist, codex: wantCodex, claude: wantClaude },
1257
- }).catch(() => null);
1258
- if (live) {
1259
- const liveUrl = `http://127.0.0.1:${live.port}`;
1260
- const note = versionNote(live.version, PKG_VERSION);
1261
- // Not the startup report's rows. That report has a label column because it
1262
- // has twelve lines to align; this has two, and borrowing the column would
1263
- // indent a three-line message behind a gutter sized for "Codex sessions".
1264
- //
1265
- // The version chunk is dropped rather than printed as "v?" when the running
1266
- // deck is too old to report one — see versionNote, which says nothing in the
1267
- // same case for the same reason.
1268
- const ident = [live.version ? `v${live.version}` : "", `pid ${live.pid}`]
1269
- .filter(Boolean).join(` ${G.bullet} `);
1270
- write(`\n ${P.ok}${G.ok}${P.reset} deck already running${P.muted} ${G.bullet} ${ident}${P.reset}\n`);
1271
- // Its own line, always. The URL is the one detail an ellipsis would destroy
1272
- // — half an address is not a shorter address — and this message has no
1273
- // report to hand it to. Same rule as statusLine's `keep`.
1274
- write(` ${P.accent}${P.bold}${link(liveUrl, liveUrl, LINKS)}${P.reset}\n`);
1275
- if (note) write(` ${P.warn}${G.warn} ${note}${P.reset}\n`);
1276
- // THE TYPO'S WARNING, on the path that has no startup report to carry it.
1277
- //
1278
- // asksForOwnDeck used to answer `true` for these two so the report would
1279
- // run and print them, and that is how `ccdeck --stpo` — a misspelling of
1280
- // the flag that STOPS a deck — came to build a second one. The warning was
1281
- // the requirement; the extra process never was. Printed here, in the same
1282
- // rows the report uses, so nothing is lost and nothing is started.
1283
- reportUnknownFlags(flags.unknown);
1284
- reportIncompleteFlags(flags.incomplete);
1285
- // The line that says a second deck was NOT started. Without it the command
1286
- // looks like it did nothing at all, which is the other way to be confusing
1287
- // about this — and it names the flag for the person who really did want two.
1288
- write(`\n ${P.muted}${G.dash} no second deck was started ${G.dash} \`${INVOKED_AS ?? PRODUCT} --new\` starts one${P.reset}\n`);
1289
- if (openBrowser) {
1290
- write(`\n ${P.ok}${P.bold}${G.play} opening browser${G.ellipsis}${P.reset}\n\n`);
1291
- try {
1292
- const { openUrl, LAUNCH_GRACE_MS } = await import(pathToFileURL(join(PKG_ROOT, "src/server/open-url.mjs")).href);
1293
- openUrl(liveUrl);
1294
- // Held for exactly as long as openUrl needs to fall through to its next
1295
- // launcher. Every child it spawns is unref'd, so an immediate exit ends
1296
- // this process before a missing xdg-open has been answered by gio — and
1297
- // then no browser opens and nothing says why. The boot path never had
1298
- // to think about this because it stays alive forever.
1299
- await sleep(LAUNCH_GRACE_MS);
1300
- } catch { /* the URL is on screen; it can be clicked or pasted */ }
1301
- } else {
1302
- write("\n");
1303
- }
1304
- process.exit(0);
1305
- }
1306
- }
1307
-
1308
- const starting = startServer({
1309
- port, persist, workspace, codex: wantCodex, claude: wantClaude,
1310
- // Withheld when nothing is supervising us: without a parent, exiting is just
1311
- // exiting, and /api/restart answers 501 so the UI hides the control.
1312
- onRestart: SUPERVISED ? requestRestart : null,
1313
- // NOT withheld, unlike the restart above. A restart needs a supervisor to
1314
- // bring the replacement up on the same port; ending is something any deck can
1315
- // do on its own, supervised or not, and `ccdeck --stop` must work on both.
1316
- // shutdown() is a hoisted declaration precisely so it is callable from the
1317
- // first instruction of this module — see the long note beside it (#448).
1318
- onStop: () => shutdown(0),
1319
- }).then(s => ({ ok: true, s }), err => ({ ok: false, err }));
1320
-
1321
- // Once-per-session setup — hook install, tool probes, registry lookups, and the
1322
- // banner it runs underneath. A respawn is the same session continuing, so it
1323
- // skips the lot and prints one line instead. This is the difference between a
1324
- // restart that feels instant and one that makes you wonder whether it worked.
1325
- if (!RESPAWN) {
1326
- const jobs = startupWork();
1327
- await printBanner();
1328
- await reportStartup(jobs);
1329
- }
1330
-
1331
- // Usually settled long ago by the time we get here, which is the point: `step`
1332
- // paints nothing for a promise that has already resolved, so the spinner this
1333
- // used to show is simply gone from the boots that were slow enough to need one.
1334
- const bound = await (RESPAWN ? starting : step(`starting server${G.ellipsis}`, starting));
1335
- if (!bound.ok) {
1336
- // stderr, not a row: a deck that could not bind is not a status line, and
1337
- // whatever launched it reads this stream.
1338
- console.error(`${PRODUCT}: server failed: ${bound.err.message}`);
1339
- process.exit(1);
1340
- }
1341
- server = bound.s;
1342
- const addr = server.address();
1343
- const realPort = typeof addr === "object" && addr ? addr.port : port;
1344
- const url = `http://127.0.0.1:${realPort}`;
1345
-
1346
- // The supervisor re-launches with this on --port. It has to be the port we
1347
- // actually got, not the one we asked for: those differ whenever the first
1348
- // launch found 4317 taken, and re-launching on the requested port would move
1349
- // the deck out from under every open tab.
1350
- try { process.send?.({ type: "listening", port: realPort }); } catch { /* not supervised */ }
1351
-
1352
- if (RESPAWN) {
1353
- 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`);
1354
- // A respawn skips the whole startup report, but not this: the argv is the
1355
- // same argv, the typo in it is still there, and a deck that mentioned it once
1356
- // and then went quiet for every restart afterwards is back to hiding it from
1357
- // anyone who was not watching the first boot.
1358
- reportUnknownFlags(flags.unknown);
1359
- reportIncompleteFlags(flags.incomplete);
1360
- } else {
1361
- // The URL is the one detail an ellipsis would destroy — half an address is
1362
- // not a shorter address — so it keeps its own line when the terminal is too
1363
- // narrow to hold it beside the label. See statusLine's `keep`.
1364
- write(row({
1365
- mark: G.ok, label: "server ready",
1366
- detail: link(url, url, LINKS), detailTone: `${P.accent}${P.bold}`, keep: true,
1367
- }));
1368
- if (persist) write(row({ label: "log", detail: fileLink(persist) }));
1369
- // Last of the rows, on purpose — see reportUnknownFlags.
1370
- reportUnknownFlags(flags.unknown);
1371
- reportIncompleteFlags(flags.incomplete);
1372
- // Only when one is actually being opened. Under --no-open — which is how an
1373
- // npx update relaunches, with a tab already waiting — this was announcing
1374
- // something that never happened.
1375
- if (openBrowser) write(`\n ${P.ok}${P.bold}${G.play} opening browser${G.ellipsis}${P.reset}\n\n`);
1376
- else write("\n");
1377
- }
1378
-
1379
- // The discovery file is the whole of how a hook finds this deck: hook.js
1380
- // enumerates that directory and nothing else. Writing it once at boot meant
1381
- // anything that later took it away left a deck that listened, served, and
1382
- // received not one event — with nothing on screen to say so. So it is checked
1383
- // on a timer, put back when it goes missing, and its absence is stated out loud
1384
- // rather than left to look like an idle afternoon.
1385
- //
1386
- // The token goes in with the port: it is what lets a hook tell this deck from
1387
- // whatever else may later be listening on the same number. See hookToken().
1388
- //
1389
- // The log path goes in with them, so a hook can see which decks share one events
1390
- // log and elect a single writer for it. See electWriters in hook/hook.js. The
1391
- // Codex setting goes in for the half of that election no hook is part of: the
1392
- // rollout files this deck tails itself, which a --no-codex deck must never be
1393
- // elected to record. See writesCodexLog in src/server/log-writer.mjs.
1394
- let registered = null;
1395
- discovery = keepDiscovery({
1396
- port: realPort,
1397
- workspace,
1398
- token: hookToken(),
1399
- persist,
1400
- codex: wantCodex,
1401
- // Both read by the next `ccdeck` on this machine, not by us: `claude` is half
1402
- // of the shape it compares before it will attach to us, and `version` is what
1403
- // it prints when the deck it found is not the one the user just launched. See
1404
- // src/server/running-deck.mjs.
1405
- claude: wantClaude,
1406
- version: PKG_VERSION,
1407
- // The supervisor above us, for `ccdeck --stop`'s fallback ladder: a worker
1408
- // killed under a live supervisor is a worker the supervisor puts back, so the
1409
- // parent has to go first and cannot be found without being told. Null when
1410
- // nothing is supervising, which is the same question `onRestart` asks.
1411
- parent: SUPERVISED ? process.ppid : null,
1412
- onState: (state) => {
1413
- const first = registered === null;
1414
- registered = state.ok;
1415
- if (!state.ok) reportUnregistered(state);
1416
- else if (!first) reportReregistered(state);
1417
- },
1418
- });
1419
- discoveryFile = discovery.file;
1420
- // Now, not in five seconds: nothing should reach the pulse line below without
1421
- // the deck knowing whether the hooks can see it.
1422
- await discovery.check();
1423
-
1424
- // Never on a respawn: the tab that asked for the restart is still open and
1425
- // reconnecting on its own. A second one would be the deck talking over itself.
1426
- if (openBrowser && !RESPAWN) {
1427
- // Not awaited, and not a dependency any more. `open@10` was this package's
1428
- // only runtime dependency and brought nine more with it, all of them fetched
1429
- // on a cold `npx ccdeck` before the deck's own tarball is unpacked — and the
1430
- // await under it held the boot behind a launcher that has nothing to report.
1431
- // See src/server/open-url.mjs.
1432
- try {
1433
- const { openUrl } = await import(pathToFileURL(join(PKG_ROOT, "src/server/open-url.mjs")).href);
1434
- openUrl(url);
1435
- } catch {}
1436
- }
1437
-
1438
- // ── the deck from before this version that is still running ──────────────────
1439
- //
1440
- // UPGRADE DAY, and without this it is a mystery. A deck older than the attach
1441
- // publishes no `claude` and no `version` in its discovery record, so sameShape
1442
- // cannot match it — deliberately, because a record that cannot be compared is
1443
- // not one to attach to. The consequence is that the first `ccdeck` after an
1444
- // upgrade starts a SECOND deck beside the one already running, opens a tab on
1445
- // it, and says nothing; and `ccdeck --stop` will not find the old one either,
1446
- // because it has the same shape problem.
1447
- //
1448
- // So it is named, once, with the command that actually clears it. Only for
1449
- // decks with no version: a deck started with `--new`, or one scoped to another
1450
- // workspace, is somebody's deliberate second deck and needs no explaining.
1451
- if (!RESPAWN) {
1452
- try {
1453
- const { liveDecks } = await import(pathToFileURL(join(PKG_ROOT, "src/server/running-deck.mjs")).href);
1454
- const older = (await liveDecks()).filter(d => !d.version);
1455
- if (older.length) {
1456
- const where = older.map(d => d.port).join(", ");
1457
- write(` ${P.warn}${G.warn}${P.reset} ${P.muted}${older.length === 1 ? "a deck" : `${older.length} decks`} from an older ${PRODUCT} ${older.length === 1 ? "is" : "are"} still running on ${where}${P.reset}\n`);
1458
- write(` ${P.muted}too old to be recognised, so this one started beside ${older.length === 1 ? "it" : "them"} ${G.dash} \`${INVOKED_AS ?? PRODUCT} --stop --all\` clears the lot${P.reset}\n\n`);
1459
- }
1460
- } catch { /* a question about other decks is never a reason to fail a boot */ }
1461
- }
1462
-
1463
- // ── starting at login ─────────────────────────────────────────────────────────
1464
- //
1465
- // ONCE PER MACHINE, EVER. The record in the deck's own data directory is what
1466
- // makes that true: without it, `--uninstall-service` would be undone by the next
1467
- // start, which is not an uninstall — it is a tool arguing with its user.
1468
- //
1469
- // After the boot rather than during it, and deliberately: a deck that could not
1470
- // come up has no business teaching the machine to start it at every login. By
1471
- // here the port is bound, the hooks are registered and the browser is open.
1472
- //
1473
- // npx is excluded and AGENTS_DECK_NO_INSTALL is honoured — see
1474
- // shouldOfferService, which owns both rules and says why. A failure is one line
1475
- // and nothing else: the deck is already running, and the worst case is the
1476
- // behaviour every version before this one had.
1477
- if (!RESPAWN) {
1478
- try {
1479
- const svc = await import(pathToFileURL(join(PKG_ROOT, "src/server/login-service.mjs")).href);
1480
- const { isGitCheckout, isNpxInstall } = await import(pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href);
1481
- if (svc.shouldOfferService({
1482
- record: svc.readServiceRecord(deckDataDir()),
1483
- npx: isNpxInstall(PKG_ROOT),
1484
- checkout: isGitCheckout(PKG_ROOT),
1485
- })) {
1486
- const out = svc.installService({
1487
- script: join(PKG_ROOT, "bin", "agent-dag.js"),
1488
- logPath: join(deckLogDir(), "deck.log"),
1489
- product: PRODUCT,
1490
- });
1491
- svc.writeServiceRecord(deckDataDir(), out.ok
1492
- ? { installed: PKG_VERSION, at: new Date().toISOString(), path: out.path }
1493
- : { failed: out.reason ?? "unknown", at: new Date().toISOString(), version: PKG_VERSION });
1494
- // Said once, on the one run that does it, and never again. A tool that
1495
- // adds itself to your login items and does not mention it is a tool you
1496
- // find later, in a settings pane, and stop trusting.
1497
- write(out.ok
1498
- ? ` ${P.muted}${G.dash} ${PRODUCT} will now start when you log in ${G.dash} \`${INVOKED_AS ?? PRODUCT} --uninstall-service\` undoes it${P.reset}\n\n`
1499
- : ` ${P.muted}${G.dash} could not set ${PRODUCT} to start at login (${out.reason}) ${G.dash} it still starts when you type it${P.reset}\n\n`);
1500
- }
1501
- } catch (err) {
1502
- // Never fatal. The deck is up; this is a convenience that did not happen.
1503
- console.error(`${PRODUCT}: could not check the login item:`, err?.message ?? err);
1504
- }
1505
- }
1506
-
1507
- // ── Pulse indicator ───────────────────────────────────────────────────────────
1508
- // The whole line is rewritten each beat rather than just the dot: anything else
1509
- // on this deck that has something to say writes a newline first, and after that
1510
- // the line under the cursor is no longer the one we drew — a partial repaint
1511
- // would leave the message behind and pulse into empty space. Sized to the real
1512
- // terminal, because at 40 columns the old fixed 61-character line wrapped, and
1513
- // from then on \r only ever reached its second row.
1514
- //
1515
- // AND IT STOPS MOVING (#742). The dot alternated green and grey every 800ms for
1516
- // as long as the deck ran, and a blinking indicator beside a status line is the
1517
- // vocabulary of "working on it" — so a boot that finished in a second read as
1518
- // one that never finished, which is what people reported. Motion is now spent
1519
- // on the two states where something really is outstanding, and the frame is
1520
- // compared against what is already on screen so a deck at rest paints once and
1521
- // then leaves the terminal alone. See pulseMoves.
1522
- if (MOTION) {
1523
- let pi = 0;
1524
- let painted = null;
1525
- // The line is on screen and is the last thing written to this terminal.
1526
- let ours = false;
1527
- // We are the one writing right now, so the guard below leaves us alone.
1528
- let writing = false;
1529
-
1530
- // The pulse line's tenancy, enforced rather than agreed.
1531
- //
1532
- // The convention was that anything with something to say writes a newline
1533
- // first, so the pulse's `\r` never lands on somebody else's text. bin/deck.js
1534
- // keeps it everywhere. src/server/quota.mjs does not — it calls console.error
1535
- // directly — and a Windows user with no Claude Code sent a screenshot of the
1536
- // result: `listening — Ctrl+C to stop ccdeck quota: claude CLI failed`
1537
- // on one row, three times over, the pulse and the complaint interleaved.
1538
- //
1539
- // An invariant every writer has to remember is one a writer will forget, and
1540
- // the writers here are server modules that know nothing about a terminal. So
1541
- // it is enforced at the stream instead: while our line is the last thing on
1542
- // screen, anything else that speaks gets a newline first, and the memo is
1543
- // dropped so the next beat repaints the line under whatever was said.
1544
- //
1545
- // Both streams, because console.error goes to stderr and lands on the same
1546
- // screen. Only when MOTION is on — with no pulse there is no line to defend,
1547
- // and a piped deck must not have its output rewritten.
1548
- for (const stream of [process.stdout, process.stderr]) {
1549
- const real = stream.write.bind(stream);
1550
- stream.write = (chunk, ...rest) => {
1551
- if (!writing && ours) {
1552
- ours = false;
1553
- // Dropped, not kept: the line is no longer where we left it, so the
1554
- // next beat has to draw it again even though the frame is unchanged.
1555
- painted = null;
1556
- // Unless the speaker already did it. Every late message in this file
1557
- // opens with one, and two blank lines is its own kind of mess.
1558
- if (!String(chunk).startsWith("\n")) real("\n");
1559
- }
1560
- return real(chunk, ...rest);
1561
- };
1562
- }
1563
-
1564
- setInterval(() => {
1565
- // The colour follows the words. A Codex-only deck keeps saying "listening"
1566
- // when it is unregistered — see pulseText — and painting that sentence in
1567
- // the warning tone would restore the alarm the sentence just retired.
1568
- const alarm = !registered && wantClaude;
1569
- const state = { registered, claude: wantClaude, busy: pulseBusy };
1570
- const text = pulseText({ ...state, columns: cols(), unicode: UNICODE });
1571
- // At rest every beat is lit, which is what makes the line still: the frame
1572
- // is then identical to the one already on screen and the write below is
1573
- // skipped. See pulseDot.
1574
- const dot = pulseDot(pi++, state) === "on" ? (alarm ? P.warn : P.ok) : P.muted;
1575
- const tone = alarm ? P.warn : P.muted;
1576
- const frame = `\r ${dot}${G.pulse}${P.reset} ${tone}${text}${P.reset}`;
1577
- // Unchanged frames are not written at all. That is what makes "at rest"
1578
- // visible: one paint, and then a still line for as long as nothing happens.
1579
- // `painted` is dropped by the guard above whenever somebody else writes, so
1580
- // this can only skip a beat while the line is genuinely still where we left
1581
- // it.
1582
- if (frame === painted) return;
1583
- painted = frame;
1584
- writing = true;
1585
- try { write(frame); } finally { writing = false; }
1586
- ours = true;
1587
- }, 800).unref();
1588
- }
1589
-
1590
- // Boot is over. Everything `shutdown` tears down exists, so a restart can be
1591
- // run rather than held — and the server is told, so /api/restart stops
1592
- // describing a deck that is still assembling itself. This line is exactly where
1593
- // the window opened at the top of this file closes; see requestRestart.
1594
- booted = true;
1595
- markDeckReady();
1596
- // And said out loud, one link up. A launcher that put this deck in the
1597
- // background has been tailing its log into the user's terminal since the spawn
1598
- // and is waiting for exactly this to stop and hand the prompt back — NOT for
1599
- // `listening`, which is sent before the server-ready row, the log row and the
1600
- // browser line are written, and would cut the last three lines off every boot.
1601
- // Inert when nothing is supervising us.
1602
- try { process.send?.({ type: "booted" }); } catch { /* no channel; nothing waiting */ }
1603
- runHeldRestart();
1604
-
1605
- /**
1606
- * A declaration, not the `const` arrow this was for eight months.
1607
- *
1608
- * The difference is the whole of #448: a const is in its temporal dead zone
1609
- * until the line declaring it runs, and every line above — the startup report,
1610
- * the port report, the discovery file, the browser spawn — executes with the
1611
- * server already accepting connections. A restart arriving in that window
1612
- * called this and got `ReferenceError: Cannot access 'shutdown' before
1613
- * initialization`, and the latch it had already set is what made that
1614
- * permanent. A declaration is hoisted, so from the first instruction of this
1615
- * module there is a function here to call.
1616
- *
1617
- * Hoisting alone would only have moved the fault one line down, onto `server`,
1618
- * `discovery` and `discoveryFile` — which is why those are `let … = null` above
1619
- * and asked about rather than assumed here. Between them, this is callable at
1620
- * any instant of this process's life and cannot end in a throw for the caller
1621
- * to lose.
1622
- */
1623
- async function shutdown(code = 0) {
1624
- // Also set as exitCode, not only passed to exit(): if the event loop empties
1625
- // on its own before either timer runs, Node would otherwise exit 0 and the
1626
- // supervisor would take that as "done" instead of "bring me back".
1627
- process.exitCode = code;
1628
- // Nothing inside a shutdown is worth staying alive for, and this one is
1629
- // called from three places that cannot handle a rejection — a signal handler,
1630
- // an IPC message handler, and a restart. An unhandled one there ends the
1631
- // process on Node's terms rather than ours, which is to say with the wrong
1632
- // exit code and therefore, half the time, without the supervisor bringing the
1633
- // deck back.
1634
- try {
1635
- // Before anything that can take time: a Ctrl+C the user has to watch for a
1636
- // second and a half is a second and a half without a cursor.
1637
- showCursor();
1638
- if (tty && code !== RESTART_CODE && code !== UPGRADE_CODE) {
1639
- write(`\n\n ${P.warn}${G.stop} shutting down${G.ellipsis}${P.reset}\n`);
1640
- }
1641
- // Stopped first, always: a tick landing after the unlink would re-register a
1642
- // deck that is on its way out, and leave the file behind for the hooks to
1643
- // find once nothing is listening.
1644
- //
1645
- // AWAITED, because clearing the interval only stops the NEXT tick. A tick
1646
- // that started a moment ago is inside the atomic write, and it re-creates
1647
- // the file after the unlink — the same stale registration, reached by the
1648
- // one route "stop first" does not cover. stop() answers with that check, so
1649
- // this waits for it and then removes what it wrote. Bounded: one check,
1650
- // which never rejects.
1651
- //
1652
- // Guarded on its own, because a discovery file this process cannot remove is
1653
- // a nuisance the next boot's stale sweep clears up — worth carrying on to
1654
- // the orderly close below rather than skipping to the abrupt one.
1655
- try {
1656
- await discovery?.stop();
1657
- if (discoveryFile) await removeDiscovery(discoveryFile);
1658
- } catch { /* the sweep at the next boot gets it */ }
1659
- // No server yet means nothing to drain and nothing to hand the port over to,
1660
- // so the exit is the whole of the shutdown.
1661
- if (!server) return process.exit(code);
1662
- server.close(() => process.exit(code));
1663
- // SSE connections never end by themselves, so close() alone would sit out the
1664
- // full 1500ms fallback on every restart. Hanging them up is safe — the stream
1665
- // sets retry: 1500 and replays from Last-Event-ID, so each tab reconnects and
1666
- // catches up without being told anything.
1667
- try { server.closeAllConnections?.(); } catch { /* Node < 18.2 */ }
1668
- setTimeout(() => process.exit(code), 1500).unref();
1669
- } catch {
1670
- process.exit(code);
1671
- }
1672
- }
1673
- process.on("SIGINT", () => shutdown(0));
1674
- process.on("SIGTERM", () => shutdown(0));
1675
- process.on("beforeExit", () => { discovery?.stop(); if (discoveryFile) removeDiscovery(discoveryFile); });
1676
-
1677
- // ── helpers ───────────────────────────────────────────────────────────────────
1678
-
1679
- // The deck is up and the discovery file could not be written. Said in full —
1680
- // the path, the reason — because the alternative is what this replaced: an
1681
- // ordinary-looking deck that simply never shows a session.
1682
- //
1683
- // What that costs depends on which CLI this deck watches, so the sentence comes
1684
- // from term.mjs, where both answers are written down and tested.
1685
- function reportUnregistered({ file, error }) {
1686
- const why = error?.message ? ` ${G.dash} ${error.message}` : "";
1687
- write(
1688
- `\n ${P.warn}${G.warn}${P.reset} ${P.bold}not registered${P.reset}${P.muted}${why}${P.reset}\n` +
1689
- ` ${P.muted}${unregisteredDetail({ file, claude: wantClaude, dash: G.dash })}${P.reset}\n`,
1690
- );
1691
- }
1692
-
1693
- function reportReregistered({ file }) {
1694
- write(`\n ${P.ok}${G.ok}${P.reset} ${P.muted}registered again ${G.arrow} ${fileLink(file)}${P.reset}\n`);
1695
- }
1696
-
1697
- /**
1698
- * Every token the parser did not recognise, named, one row each.
1699
- *
1700
- * Said rather than acted on: the deck goes on booting and still exits 0. It is
1701
- * not a one-shot command that can afford the usual contract. `bin/agent-dag.js`
1702
- * hands its own argv to every worker it spawns — including the npx relaunch,
1703
- * which starts a NEWER version of the package on the argv the user typed
1704
- * against an older one — and the README recommends running it from a wrapper.
1705
- * Refusing to boot over one token would turn a typo into a dark dashboard, and
1706
- * an argument the newer build no longer knows into a failed upgrade that costs
1707
- * the port and the session. The file already holds that position once, in the
1708
- * `--all` branch: a flag the deck stopped needing is still accepted rather than
1709
- * made fatal.
1710
- *
1711
- * So it goes where the deck puts everything else it decided on your behalf —
1712
- * the startup report — and it goes at the END of it. reportStartup writes its
1713
- * rows in a fixed order and three more land underneath them (the server, the
1714
- * log, the browser), so a warning printed among those rows is a warning the
1715
- * rows scroll over. Here it is the last line before the pulse indicator takes
1716
- * the bottom of the screen and stops repainting anything above it.
1717
- */
1718
- function reportUnknownFlags(unknown) {
1719
- for (const token of unknown) {
1720
- write(row({
1721
- mark: G.warn, tone: P.warn, label: "unknown option",
1722
- detail: `${token} ${G.dash} see \`${PRODUCT} --help\``,
1723
- }));
1724
- }
1725
- }
1726
-
1727
- /**
1728
- * Every value-taking flag that was given no value it could use, named, one row
1729
- * each — and printed beside the unknown ones because it is the same failure
1730
- * wearing a different hat.
1731
- *
1732
- * #697: `--workspace`, `--history` and `--port` used to consume the following
1733
- * token whatever it was, so `ccdeck --workspace $PROJ --no-persist` with `PROJ`
1734
- * unset scoped the deck to a directory called `--no-persist`, kept persisting to
1735
- * the shared log, and reported neither. Nothing landed in `unknown`, because the
1736
- * token that belonged there had been eaten. The parser refuses that value now
1737
- * and lists the flag here instead.
1738
- *
1739
- * Said rather than acted on, under exactly the argument reportUnknownFlags makes
1740
- * above: the flag falls back to its documented default and the deck still boots.
1741
- * The row is what makes the fallback a decision the user can see, and the rows
1742
- * around it show its consequence — `workspace (all)` and the `log` line are
1743
- * printed by the same report.
1744
- */
1745
- function reportIncompleteFlags(incomplete) {
1746
- for (const { flag, expects } of incomplete ?? []) {
1747
- write(row({
1748
- mark: G.warn, tone: P.warn, label: "missing value",
1749
- detail: `${flag} ${G.dash} expected ${expects}; using the default`,
1750
- }));
1751
- }
1752
- }
1753
-
1754
- function printHelp() {
1755
- process.stdout.write(`${PRODUCT} — live deck of Claude Code + Codex agents
1756
-
1757
- Usage:
1758
- ${PRODUCT} [options]
1759
-
1760
- Options:
1761
- -p, --port <number> Preferred port (default: 4317; falls back to random 4318–4400)
1762
- --no-open Don't open the browser automatically
1763
- --foreground Hold the terminal, the way every version before 3.20
1764
- did. Ctrl+C stops the deck again
1765
- --new Start a second deck even if one is already running.
1766
- Without it, a bare \`${PRODUCT}\` beside a deck that is
1767
- already up opens that deck's tab instead of building
1768
- a rival on another port
1769
- --stop Stop the deck a bare \`${PRODUCT}\` would open.
1770
- With --port <n>, stop that one; with --all, stop every
1771
- deck on this machine
1772
- --status What is running on this machine, and on which ports
1773
- --logs What the deck wrote where a terminal would have shown
1774
- it, and where that file is
1775
- --install Put the deck on your PATH and start it at login.
1776
- What an \`npx\` run needs to survive a reboot
1777
- --install-service Start the deck when you log in. Set up on first run;
1778
- this is only for putting it back
1779
- --uninstall-service Stop starting at login. \`--uninstall\` does this too
1780
- --workspace <path> Only capture sessions whose cwd is inside <path>
1781
- --scope Restrict to current working directory
1782
- --all Capture every session (default). Beside --stop it
1783
- means every deck rather than every session
1784
- --history <path> Override events log file (default: this platform's log directory)
1785
- --no-persist Don't write or replay events log (RAM-only)
1786
- --codex Force-enable Codex capture even if ~/.codex/ missing
1787
- --no-codex Skip Codex capture (Claude only)
1788
- --claude Force-enable Claude capture even if Claude Code wasn't found
1789
- --no-claude Skip Claude entirely: no hooks, no claude-swap, no accounts panel
1790
- --uninstall Remove ${PRODUCT}'s hooks from ~/.claude/settings.json and
1791
- ~/.codex/hooks.json, and restore any sound hooks of yours it parked.
1792
- Hook entries only: the forwarder script, ~/.claude/agent-dag/,
1793
- the events log, ~/.agents-deck/ and claude-swap all stay
1794
- -h, --help Show this help
1795
- -v, --version Print the version and exit
1796
-
1797
- Anything else on the command line is reported as an unknown option and then
1798
- ignored: the deck still starts.
1799
-
1800
- A flag that takes a value never swallows the next flag. If the value is missing,
1801
- empty, or itself looks like a flag — \`${PRODUCT} --workspace \$UNSET --no-persist\`
1802
- after the shell has dropped an unset variable — the flag is reported, left on its
1803
- default, and the token it would have eaten is parsed as the flag it is.
1804
- `);
1805
- }