ccdeck 3.29.11 → 3.31.0

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