ccdeck 3.29.11 → 3.30.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 (176) hide show
  1. package/README.md +1 -1
  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-DP3YMtEi.js +1 -0
  15. package/dist/web/assets/UsageHistoryModal-CcdPVXAw.js +1 -0
  16. package/dist/web/assets/index-3vS6A0vt.css +1 -0
  17. package/dist/web/assets/index-BVTWHDta.js +298 -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 +22 -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 +2 -2
  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 +1 -1
  67. package/src/server/deck-prefs.mjs +51 -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 +172 -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 +108 -0
  134. package/src/server/prefs-path.mjs +14 -0
  135. package/src/server/prefs-refusal.mjs +77 -0
  136. package/src/server/prefs-routes.mjs +76 -0
  137. package/src/server/prefs-set-aside.mjs +86 -0
  138. package/src/server/prefs-state.mjs +51 -0
  139. package/src/server/process-command.mjs +104 -0
  140. package/src/server/process-list.mjs +547 -0
  141. package/src/server/pulse-line.mjs +132 -0
  142. package/src/server/purge-key.mjs +7 -7
  143. package/src/server/quota-cli.mjs +174 -0
  144. package/src/server/quota-oauth.mjs +190 -0
  145. package/src/server/quota-shape.mjs +229 -0
  146. package/src/server/quota-store-resets.mjs +206 -0
  147. package/src/server/quota.mjs +123 -509
  148. package/src/server/relay-guard.mjs +3 -3
  149. package/src/server/request-gates.mjs +441 -0
  150. package/src/server/restart-note.mjs +180 -0
  151. package/src/server/retire-sound-hook.mjs +3 -3
  152. package/src/server/ring-bounds.mjs +215 -0
  153. package/src/server/running-deck.mjs +1 -1
  154. package/src/server/running-version.mjs +24 -0
  155. package/src/server/self-update.mjs +39 -1371
  156. package/src/server/session-enrichment.mjs +622 -0
  157. package/src/server/session-read-gate.mjs +49 -0
  158. package/src/server/session-tracking.mjs +242 -0
  159. package/src/server/sse-clients.mjs +222 -0
  160. package/src/server/stale-discovery.mjs +30 -0
  161. package/src/server/static-serve.mjs +81 -0
  162. package/src/server/supervisor.mjs +73 -12
  163. package/src/server/system-metrics.mjs +169 -1935
  164. package/src/server/term.mjs +11 -249
  165. package/src/server/thermal-metrics.mjs +520 -0
  166. package/src/server/thermal-sampler.mjs +204 -0
  167. package/src/server/token-redact.mjs +123 -0
  168. package/src/server/transcript-gate.mjs +107 -0
  169. package/src/server/transcript-scan.mjs +563 -0
  170. package/src/server/usage-routes.mjs +93 -0
  171. package/src/server/uv-bootstrap.mjs +1 -1
  172. package/src/server/wordmark.mjs +127 -0
  173. package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
  174. package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
  175. package/dist/web/assets/index-Ck2RAla3.css +0 -1
  176. package/dist/web/assets/index-jmrcEsCj.js +0 -298
@@ -0,0 +1,459 @@
1
+ // The once-per-session work, and the startup report that says how it went.
2
+ //
3
+ // Lifted out of bin/deck.js, which starts the work beside the server, prints
4
+ // the wordmark over it and then calls reportStartup, exactly as before. What the
5
+ // report needs to know about this boot — the workspace, which CLIs it serves,
6
+ // where the Codex watcher looks — comes in as arguments; the modules the jobs
7
+ // load are still imported off PKG_ROOT at the moment each job starts.
8
+ //
9
+ // The two flag rows are here too: they are the end of this report, printed by
10
+ // deck.js once the server row and the log row have landed under it.
11
+ import { join } from "node:path";
12
+ import { pathToFileURL } from "node:url";
13
+ import { budget, bootDeadlineMs } from "../../src/server/boot-deadline.mjs";
14
+ import { PRODUCT } from "../../src/server/brand.mjs";
15
+ import { renameNotice } from "../../src/server/invoked-as.mjs";
16
+ import { link } from "../../src/server/term.mjs";
17
+ import { wayBackNote } from "../../src/server/way-back.mjs";
18
+ import { COMMAND, INVOKED_AS, PKG_ROOT, PKG_VERSION } from "./package.js";
19
+ import { G, LINKS, P, cols, fileLink, row, step, write } from "./screen.js";
20
+
21
+ /**
22
+ * What is still happening after the report ended, in three words or so.
23
+ *
24
+ * #742 left one job able to outlive the boot — an install of claude-swap that
25
+ * the report stopped waiting for — and the terminal said nothing about it once
26
+ * the rows were done. That is the wrong way round: the row that had finished
27
+ * was the one blinking, and the thing that really was working sat still. Now
28
+ * the pulse line carries the label while the job runs and drops it when the job
29
+ * settles, which is also what decides whether the line moves at all. Kept
30
+ * here, beside the report that sets it; the pulse line in bin/deck.js reads it
31
+ * through busyLabel.
32
+ */
33
+ let pulseBusy = null;
34
+
35
+ /** The pulse line's label for work still running after the report, or null. */
36
+ export const busyLabel = () => pulseBusy;
37
+
38
+ /**
39
+ * The once-per-session work, all of it started at once and none of it awaited.
40
+ *
41
+ * Hook install, the claude-swap probe and the registry lookup have nothing to
42
+ * do with each other and nothing to do with the wordmark, so they run underneath
43
+ * the reveal instead of queueing behind it — the animation then costs the boot
44
+ * nothing and the deck is ready about when the last art row lands. Every one of
45
+ * them is given its rejection handler here, at the moment it is created, since a
46
+ * promise that settles before anything awaits it is otherwise an unhandled
47
+ * rejection.
48
+ */
49
+ export function startupWork({ wantClaude, installHooks, leftoverCodexHooks }) {
50
+ // Every job below this line serves Claude Code and only Claude Code: the
51
+ // hooks go in Claude Code's settings.json, claude-swap switches Claude
52
+ // accounts, and ccusage reads Claude Code's own session logs. On a machine
53
+ // without Claude Code all three are work done for a CLI that is not there —
54
+ // two of them installs the user did not ask for — so they are not started at
55
+ // all rather than started and then reported as failures. `null` is how each
56
+ // one says "not attempted", which reportStartup tells apart from "tried and
57
+ // could not".
58
+
59
+ // Settings the installer cannot parse are settings it cannot rewrite without
60
+ // losing them, so it refuses — and that refusal is reported rather than
61
+ // thrown, because it is the only thing the user can act on.
62
+ const hooks = wantClaude
63
+ ? installHooks({ provider: "claude" }).then(v => ({ ok: true, v }), err => ({ ok: false, err }))
64
+ : Promise.resolve(null);
65
+
66
+ // claude-swap backs the multi-account panel, and an empty store leaves that
67
+ // panel useless even when the tool is there — so the account already signed
68
+ // in is registered once. Bounded inside seedFirstAccount: empty store only,
69
+ // once ever, never with NO_INSTALL set.
70
+ //
71
+ // `cswapInstalling` is the other half, and it is what keeps a first run from
72
+ // spending the boot's whole deadline on a question that has already been
73
+ // answered: ensureCswap resolves it the moment it commits to an install, so
74
+ // the report can stop waiting then rather than eight seconds later. It never
75
+ // settles on the machines where there is nothing to install, which is every
76
+ // machine after the first run.
77
+ let sayInstalling;
78
+ const cswapInstalling = new Promise(r => { sayInstalling = r; });
79
+
80
+ const cswap = (async () => {
81
+ if (!wantClaude) return null;
82
+ const { ensureCswap } = await import(pathToFileURL(join(PKG_ROOT, "src/server/cswap-install.mjs")).href);
83
+ const cs = await ensureCswap({ onInstalling: () => sayInstalling() });
84
+ const usable = cs.state === "present" || cs.state === "installed" || cs.state === "upgrading";
85
+ if (!usable) return { cs, seed: null };
86
+ const { seedFirstAccount } = await import(pathToFileURL(join(PKG_ROOT, "src/server/claude-accounts.mjs")).href);
87
+ return { cs, seed: await seedFirstAccount().catch(() => ({ state: "failed" })) };
88
+ })().catch(() => null);
89
+
90
+ // WHEN THE TOOL IS QUIET, which is not the same as when the job above settles
91
+ // (#1043). The auto-switch drives this same claude-swap — every tick is
92
+ // `cswap auto --once`, which moves the user's live Claude credentials — and
93
+ // the server arms it the moment the port binds, while this job is still in
94
+ // ensureCswap. So the server holds every tick until this resolves, and it has
95
+ // two halves because the job covers only the first: ensureCswap answers
96
+ // "upgrading" as soon as it has FIRED an upgrade it deliberately does not
97
+ // await, and that upgrade is a uv or pipx environment being rewritten for
98
+ // tens of seconds after this job has returned. upgradeSettled() is the handle
99
+ // on it, and null when nothing was started.
100
+ //
101
+ // Not markDeckReady, the other place "the boot is over" gets said:
102
+ // reportStartup stops waiting for claude-swap the moment the install
103
+ // announces itself, so the deck is marked ready with the install still
104
+ // running. Never rejects — a job that failed has nothing left running.
105
+ const cswapQuiet = cswap.then(async () => {
106
+ if (!wantClaude) return;
107
+ const { upgradeSettled } = await import(pathToFileURL(join(PKG_ROOT, "src/server/cswap-install.mjs")).href);
108
+ await upgradeSettled();
109
+ }).catch(() => {});
110
+
111
+ // ccusage backs the usage-history modal. Primed at boot rather than on first
112
+ // open so a cold machine pays the install while the deck is still starting.
113
+ // Nothing is lost by skipping the prime: runCcusage falls back to npx, so the
114
+ // modal still answers if it is ever opened — it just pays the wait itself.
115
+ const ccusage = (async () => {
116
+ if (!wantClaude) return null;
117
+ if (process.env.AGENTS_DECK_NO_INSTALL === "1") return null;
118
+ const { primeCcusage } = await import(pathToFileURL(join(PKG_ROOT, "src/server/ccusage.mjs")).href);
119
+ return primeCcusage();
120
+ })().catch(() => null);
121
+
122
+ // A newer release on npm, said once, in the place the upgrade gets typed.
123
+ // Hard-capped so a slow registry cannot delay the server — the answer is
124
+ // usually already cached in ~/.agents-deck/.self-update-check anyway. It has
125
+ // to resolve BEFORE the pulse indicator starts writing over the last line.
126
+ const update = Promise.race([
127
+ import(pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href)
128
+ .then(m => m.versionReport({ running: PKG_VERSION, pkgRoot: PKG_ROOT }))
129
+ .then(r => (r?.notice?.kind === "upgrade" ? r : null))
130
+ .catch(() => null),
131
+ new Promise(r => setTimeout(() => r(null), 1200)),
132
+ ]);
133
+
134
+ // An older deck's Codex forwarders, looked for and never touched — see
135
+ // leftoverCodexHooks for why a boot names them rather than removing them.
136
+ // Asked with --no-codex too: the entries fire whether or not this deck is
137
+ // watching the rollouts, and one file read is the whole cost.
138
+ const codexHooks = leftoverCodexHooks().catch(() => null);
139
+
140
+ return { hooks, cswap, cswapInstalling, cswapQuiet, ccusage, update, codexHooks };
141
+ }
142
+
143
+ /** The same work, said out loud, in a fixed order — a boot whose rows arrive in
144
+ * whatever order the network settled is a boot nobody can scan twice. */
145
+ export async function reportStartup(jobs, { workspace, wantClaude, wantCodex, CODEX_SESSIONS_DIR }) {
146
+ // What the whole report may spend waiting, shared by every job under it
147
+ // rather than granted to each — four jobs at eight seconds each is a
148
+ // thirty-two second boot that no individual deadline would object to.
149
+ const left = budget(bootDeadlineMs());
150
+
151
+ write(row({
152
+ mark: G.ok, label: "workspace",
153
+ detail: workspace === "" ? "(all)" : workspace,
154
+ detailTone: workspace === "" ? P.warn : P.muted,
155
+ }));
156
+
157
+ const hooks = await step(`installing Claude hooks${G.ellipsis}`, jobs.hooks);
158
+ if (hooks === null) {
159
+ // Said in the same shape as the Codex row below, because it is the same
160
+ // sentence: this deck is not watching that CLI, and here is why. It also
161
+ // retires the one boot failure a Codex-only machine could hit — an
162
+ // unparseable or unwritable settings.json used to exit(1) below, killing a
163
+ // deck over a file belonging to a CLI the user does not run.
164
+ write(row({ label: "Claude hooks", detail: `skipped ${G.dash} no Claude Code found, or --no-claude` }));
165
+ } else if (!hooks.ok) {
166
+ // The file it names is one only the user can repair, and every Claude Code
167
+ // session on this machine is reading it too.
168
+ write(row({ mark: G.fail, tone: P.err, label: "Claude hooks", detail: "not installed" }));
169
+ // THE WAY OUT, printed where the wall is. The installer's message is good —
170
+ // the path, the reason, "fix the file or move it aside" — and it is a file
171
+ // the user may not be able to edit: root-owned, on read-only media, or
172
+ // simply not theirs. This deck knows the remedy and used to keep it in a
173
+ // comment: --no-claude runs everything else, which on a Codex-only machine
174
+ // is the whole deck, over a settings.json belonging to a CLI they do not
175
+ // use.
176
+ console.error(`\n ${PRODUCT}: ${hooks.err.message}`);
177
+ console.error(` Or start with --no-claude to run without Claude hooks.\n`);
178
+ process.exit(1);
179
+ } else {
180
+ write(row({ mark: G.ok, label: "Claude hooks", detail: fileLink(hooks.v.hookPath) }));
181
+ }
182
+
183
+ // Codex CLI hooks never fire on Windows (sandbox refuses to spawn the hook
184
+ // command). Instead the server tails Codex's rollout JSONL files directly, so
185
+ // there's nothing to install and no /hooks trust step. We just confirm Codex
186
+ // is present and let the watcher pick up sessions.
187
+ if (wantCodex) {
188
+ // The directory the watcher actually tails, imported from the module that
189
+ // owns it rather than rebuilt from homedir() here. CODEX_HOME relocates the
190
+ // whole tree, and this row is the only diagnostic the deck prints about
191
+ // Codex — so when sessions do not show up, a path computed a second way
192
+ // sends the user to inspect a directory the deck never opened.
193
+ write(row({ mark: G.ok, label: "Codex sessions", detail: `watching ${fileLink(CODEX_SESSIONS_DIR)}` }));
194
+ } else {
195
+ write(row({ label: "Codex sessions", detail: `skipped ${G.dash} no ~/.codex/, or --no-codex` }));
196
+ }
197
+
198
+ // #983. A machine that ran a deck from before #253 still has that deck's
199
+ // forwarders in Codex's hooks.json, and nothing but `--uninstall` ever looked.
200
+ // Where Codex honours the file, each one posts the session to /api/event while
201
+ // the watcher above reads the same session off disk, so it arrives twice. Both
202
+ // copies go into the ring and events.jsonl; the reducer's two-second
203
+ // redelivery windows fold some of them on the card and not others, and a
204
+ // duplicate that shows only sometimes reads as a reducer bug, which is where
205
+ // it would otherwise be chased. Saying nothing was the one answer the issue
206
+ // ruled out. So: one row, in the place the deck already talks about Codex,
207
+ // naming the file and the command. `keep`, because a remedy cut off by an
208
+ // ellipsis is no remedy. The clause about arriving twice is said only while
209
+ // the watcher runs, since without it the forwarders are the only copy.
210
+ const leftover = await jobs.codexHooks;
211
+ if (leftover) {
212
+ const twice = wantCodex ? ", so Codex sessions can arrive twice" : "";
213
+ write(row({
214
+ mark: G.warn, tone: P.warn, label: "Codex hooks", keep: true,
215
+ detail: `left by an older deck in ${fileLink(leftover.settingsPath)}${twice} ${G.dash} \`${COMMAND} --uninstall\` takes them out`,
216
+ }));
217
+ }
218
+
219
+ // Bounded, because this is the job that made a first boot look hung: on a
220
+ // machine with neither claude-swap nor a Python toolchain it fetches a uv
221
+ // binary and then builds an environment with it, and the report used to wait
222
+ // out both. See src/server/boot-deadline.mjs. Nothing is cancelled — the
223
+ // install carries on and says how it went when it knows.
224
+ const swapWait = await step(`checking claude-swap${G.ellipsis}`, Promise.race([
225
+ left.within(jobs.cswap),
226
+ // The install announcing itself. Not a timeout — a decision, arriving in
227
+ // about a second on the boot that would otherwise have paid the full
228
+ // deadline for news it already had.
229
+ jobs.cswapInstalling.then(() => ({ done: false, installing: true })),
230
+ ]));
231
+ if (swapWait.done) writeSwapRows(swapWait.value, { wantClaude });
232
+ else {
233
+ write(row({
234
+ label: "claude-swap",
235
+ // Both halves earn their place, and both have to survive an 80-column
236
+ // terminal: what the job is doing, and that waiting for it is not the
237
+ // user's problem. The row that only said the first is the row this
238
+ // replaces — a spinner at "checking claude-swap…" says that much.
239
+ detail: swapWait.installing
240
+ ? `installing in the background ${G.dash} the deck is ready`
241
+ : `still setting up ${G.dash} the deck is ready`,
242
+ }));
243
+ // Handed to the pulse line, which is the only thing still on screen once
244
+ // the rows are done — and taken back the moment the job settles, whichever
245
+ // way it settled.
246
+ pulseBusy = swapWait.installing ? "installing claude-swap" : "setting up claude-swap";
247
+ jobs.cswap.then(
248
+ late => { pulseBusy = null; writeSwapRows(late, { wantClaude, late: true }); },
249
+ () => { pulseBusy = null; },
250
+ );
251
+ }
252
+
253
+ const cu = await left.within(jobs.ccusage);
254
+ writeCcusageRow(cu.done ? cu.value : null);
255
+
256
+ const upgrade = await jobs.update;
257
+ if (upgrade) {
258
+ write(row({
259
+ mark: G.up, tone: P.warn, label: "update",
260
+ detail: `v${upgrade.notice.to} available ${G.dash} ${upgrade.command}`,
261
+ }));
262
+ }
263
+
264
+ // Which name this deck was started under, when that is knowable — a notice,
265
+ // never a refusal. 95% of installs are on the two old names and the update
266
+ // path runs through this very process, so a build that declined to boot under
267
+ // one of them would kill the deck on the machine where the deck is what would
268
+ // have explained why. Nothing at all is printed wherever the typed name
269
+ // cannot be proven (a Windows global install, a git checkout): telling
270
+ // somebody who already types `ccdeck` to type `ccdeck` is the one failure
271
+ // that would make this row worth ignoring. See src/server/invoked-as.mjs.
272
+ const rename = renameNotice({ invoked: INVOKED_AS, pkgRoot: PKG_ROOT, dash: G.dash });
273
+ if (rename) {
274
+ write(row({ mark: G.warn, tone: P.warn, label: "name", detail: rename.said }));
275
+ // The line that carries the value: for a global install there is nothing to
276
+ // install and nothing to download, only six different characters to type.
277
+ write(row({ label: "", detail: rename.fix }));
278
+ }
279
+ }
280
+
281
+ /**
282
+ * The claude-swap rows, wherever in the boot they end up being printed.
283
+ *
284
+ * `late` is the one difference, and it is not cosmetic: by the time a late row
285
+ * arrives the pulse indicator owns the last line and repaints it with `\r`, so
286
+ * a row written without a newline first would be drawn over on the next beat.
287
+ * Every other thing that speaks after boot — reportUnregistered,
288
+ * reportReregistered — opens with the same newline for the same reason.
289
+ */
290
+ function writeSwapRows(swap, { wantClaude, late = false }) {
291
+ const cs = swap?.cs;
292
+ // Collected rather than written one at a time, because a late report opens
293
+ // with a newline and there is exactly one of those however many rows follow.
294
+ let out = "";
295
+ if (!wantClaude) {
296
+ // claude-swap is a Python tool that switches Claude Code accounts, and the
297
+ // deck used to fetch a uv binary to install it on machines with no Claude
298
+ // Code at all. Saying so is the point of the row: it is the one place a
299
+ // user can learn that the accounts panel is missing on purpose.
300
+ out += row({ label: "claude-swap", detail: `skipped ${G.dash} accounts are Claude-only` });
301
+ } else if (cs?.state === "present") {
302
+ out += row({ mark: G.ok, label: "claude-swap", detail: `v${cs.version} (accounts panel enabled)` });
303
+ } else if (cs?.state === "installed") {
304
+ out += row({ mark: G.ok, label: "claude-swap", detail: `installed v${cs.version} via ${cs.via}` });
305
+ } else if (cs?.state === "upgrading") {
306
+ out += row({ mark: G.ok, label: "claude-swap", detail: `v${cs.version}, upgrading to v${cs.latest} in background` });
307
+ } else if (cs?.state === "skipped") {
308
+ out += row({ mark: G.ok, label: "claude-swap", detail: "not installed (AGENTS_DECK_NO_INSTALL=1)" });
309
+ } else {
310
+ const how = cs?.reason === "no_installer"
311
+ ? `not installed ${G.dash} the accounts panel needs it`
312
+ : cs?.reason === "not_on_path"
313
+ ? `installed via ${cs.via} but not on PATH ${G.dash} add ${
314
+ process.platform === "win32" ? "%USERPROFILE%\\.local\\bin" : "~/.local/bin"
315
+ }`
316
+ // The deck asked for one version and a different one answered, so it
317
+ // declines to drive it. Naming both is the whole content of the row:
318
+ // "install failed" would be a lie about an install that succeeded, and
319
+ // the number that arrived is the thing a person needs to see.
320
+ : cs?.reason === "unexpected_version"
321
+ ? `refused v${cs.version} ${G.dash} asked for ${cs.want}, not driving it`
322
+ : `install failed${cs?.via ? ` via ${cs.via}` : ""}`;
323
+ out += row({ mark: G.fail, tone: P.warn, label: "claude-swap", detail: how });
324
+ // A URL is not an answer when someone just wants the panel to work. Print
325
+ // the command for THIS machine, picked from what is already on it.
326
+ if (cs?.hint) out += row({ label: "", detail: cs.hint });
327
+ }
328
+
329
+ if (swap?.seed?.state === "added") {
330
+ out += row({ mark: G.ok, label: "accounts", detail: "registered the signed-in account (cswap add)" });
331
+ } else if (swap?.seed?.state === "failed" || swap?.seed?.state === "nothing-to-add") {
332
+ out += row({ label: "accounts", detail: `panel empty ${G.dash} sign in to Claude Code, then run cswap add` });
333
+ }
334
+
335
+ write(late ? "\n" + out : out);
336
+ }
337
+
338
+ /**
339
+ * The ccusage row.
340
+ *
341
+ * `null` covers both of the ways there is nothing to say — the job was not
342
+ * attempted, and the job had not answered by the time the boot's deadline ran
343
+ * out. Neither deserves a row: unlike claude-swap there is no install to wait
344
+ * for here, because primeCcusage starts one and returns without it, so a
345
+ * ccusage that is slow to answer is slow at resolving a path and will be
346
+ * resolved again the first time the usage modal is opened.
347
+ */
348
+ function writeCcusageRow(cu) {
349
+ if (cu?.state === "present") write(row({ mark: G.ok, label: "ccusage", detail: `v${cu.version}` }));
350
+ else if (cu?.state === "updating") write(row({ mark: G.ok, label: "ccusage", detail: `v${cu.version}, checking for update` }));
351
+ // A ccusage the user provided, named rather than versioned — reading a
352
+ // version out of it means running it, and a status row is not worth a spawn.
353
+ // Naming the file is the more useful half anyway: it is the answer to "which
354
+ // ccusage is this deck actually going to run", which is a question a machine
355
+ // with a managed install AND a PATH copy could not answer before #433.
356
+ else if (cu?.state === "user") write(row({ mark: G.ok, label: "ccusage", detail: `your own copy ${G.dash} ${cu.bin}` }));
357
+ else if (cu?.state === "installing") write(row({ mark: G.ok, label: "ccusage", detail: "installing in background" }));
358
+ }
359
+
360
+ /**
361
+ * Every token the parser did not recognise, named, one row each.
362
+ *
363
+ * Said rather than acted on: the deck goes on booting and still exits 0. It is
364
+ * not a one-shot command that can afford the usual contract. `bin/agent-dag.js`
365
+ * hands its own argv to every worker it spawns — including the npx relaunch,
366
+ * which starts a NEWER version of the package on the argv the user typed
367
+ * against an older one — and the README recommends running it from a wrapper.
368
+ * Refusing to boot over one token would turn a typo into a dark dashboard, and
369
+ * an argument the newer build no longer knows into a failed upgrade that costs
370
+ * the port and the session. The deck already holds that position once, in the
371
+ * `--all` flag: a flag the deck stopped needing is still accepted rather than
372
+ * made fatal.
373
+ *
374
+ * So it goes where the deck puts everything else it decided on your behalf —
375
+ * the startup report — and it goes at the END of it. reportStartup writes its
376
+ * rows in a fixed order and three more land underneath them (the server, the
377
+ * log, the browser), so a warning printed among those rows is a warning the
378
+ * rows scroll over. Here it is the last line before the pulse indicator takes
379
+ * the bottom of the screen and stops repainting anything above it.
380
+ */
381
+ export function reportUnknownFlags(unknown) {
382
+ for (const token of unknown) {
383
+ write(row({
384
+ mark: G.warn, tone: P.warn, label: "unknown option",
385
+ detail: `${token} ${G.dash} see \`${COMMAND} --help\``,
386
+ }));
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Every value-taking flag that was given no value it could use, named, one row
392
+ * each — and printed beside the unknown ones because it is the same failure
393
+ * wearing a different hat.
394
+ *
395
+ * #697: `--workspace`, `--history` and `--port` used to consume the following
396
+ * token whatever it was, so `ccdeck --workspace $PROJ --no-persist` with `PROJ`
397
+ * unset scoped the deck to a directory called `--no-persist`, kept persisting to
398
+ * the shared log, and reported neither. Nothing landed in `unknown`, because the
399
+ * token that belonged there had been eaten. The parser refuses that value now
400
+ * and lists the flag here instead.
401
+ *
402
+ * Said rather than acted on, under exactly the argument reportUnknownFlags makes
403
+ * above: the flag falls back to its documented default and the deck still boots.
404
+ * The row is what makes the fallback a decision the user can see, and the rows
405
+ * around it show its consequence — `workspace (all)` and the `log` line are
406
+ * printed by the same report.
407
+ */
408
+ export function reportIncompleteFlags(incomplete) {
409
+ for (const { flag, expects } of incomplete ?? []) {
410
+ write(row({
411
+ mark: G.warn, tone: P.warn, label: "missing value",
412
+ detail: `${flag} ${G.dash} expected ${expects}; using the default`,
413
+ }));
414
+ }
415
+ }
416
+
417
+ /**
418
+ * The one line a respawn prints instead of the whole report — the same session
419
+ * continuing — and the flag rows, which it does not skip.
420
+ */
421
+ export function reportRestarted({ url, flags }) {
422
+ 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`);
423
+ // A respawn skips the whole startup report, but not this: the argv is the
424
+ // same argv, the typo in it is still there, and a deck that mentioned it once
425
+ // and then went quiet for every restart afterwards is back to hiding it from
426
+ // anyone who was not watching the first boot.
427
+ reportUnknownFlags(flags.unknown);
428
+ reportIncompleteFlags(flags.incomplete);
429
+ }
430
+
431
+ /**
432
+ * The end of the startup report, once the port is bound: the server and log
433
+ * rows, the flag rows under them, the way back, and whether a browser opens.
434
+ */
435
+ export function reportReady({ url, persist, openBrowser, flags }) {
436
+ // The URL is the one detail an ellipsis would destroy — half an address is
437
+ // not a shorter address — so it keeps its own line when the terminal is too
438
+ // narrow to hold it beside the label. See statusLine's `keep`.
439
+ write(row({
440
+ mark: G.ok, label: "server ready",
441
+ detail: link(url, url, LINKS), detailTone: `${P.accent}${P.bold}`, keep: true,
442
+ }));
443
+ if (persist) write(row({ label: "log", detail: fileLink(persist) }));
444
+ // Last of the rows, on purpose — see reportUnknownFlags.
445
+ reportUnknownFlags(flags.unknown);
446
+ reportIncompleteFlags(flags.incomplete);
447
+ // AFTER the warnings and outside the rows, because it is neither. It is the
448
+ // one thing on this screen that is about next week rather than about this
449
+ // boot — see way-back.mjs — and putting it above a typo warning would be
450
+ // spending the reader's last line of attention on the calmer of the two.
451
+ write(`\n ${P.muted}${G.dash} ${wayBackNote({
452
+ command: COMMAND, dash: G.dash, columns: cols(),
453
+ })}${P.reset}\n`);
454
+ // Only when one is actually being opened. Under --no-open — which is how an
455
+ // npx update relaunches, with a tab already waiting — this was announcing
456
+ // something that never happened.
457
+ if (openBrowser) write(`\n ${P.ok}${P.bold}${G.play} opening browser${G.ellipsis}${P.reset}\n\n`);
458
+ else write("\n");
459
+ }
@@ -0,0 +1,169 @@
1
+ // `--uninstall`, and `--purge` with it: every trace of the deck that is not
2
+ // somebody's data, taken off the machine, and every one that could not be,
3
+ // named.
4
+ //
5
+ // Lifted out of bin/deck.js, which still answers it before the migration and
6
+ // well before the heavy imports, exactly where the block used to sit. Every
7
+ // module the uninstall touches is still imported off PKG_ROOT at the moment it
8
+ // is needed, so `--uninstall` loads only what it uses and starts nothing.
9
+ import { join } from "node:path";
10
+ import { pathToFileURL } from "node:url";
11
+ import { PRODUCT } from "../../src/server/brand.mjs";
12
+ import { COMMAND, PKG_ROOT } from "./package.js";
13
+ import { G } from "./screen.js";
14
+ import { removedRecord } from "./login-item.js";
15
+
16
+ /**
17
+ * Remove this deck's hooks, its login item and, with `flags.purge`, the files
18
+ * holding its LAN private key — and say what happened to each.
19
+ *
20
+ * Resolves to the exit code rather than exiting, so the caller owns the
21
+ * process: 1 when any half of it refused, 0 otherwise.
22
+ */
23
+ export async function uninstall(flags) {
24
+ const { uninstallHooks, hasCodexInstalled } = await import(pathToFileURL(join(PKG_ROOT, "src/server/installer.mjs")).href);
25
+ // Anything that could not be taken out. An uninstall that removed nothing
26
+ // because it could not read the file has not uninstalled anything, and both
27
+ // the wording and the exit code have to say so: a user who is told it worked
28
+ // and still has our hooks firing on every event is worse off than one who is
29
+ // told it failed, because they have stopped looking.
30
+ let refused = false;
31
+ // Files already reported as unparseable. The Claude hooks and the sound hook
32
+ // live in the SAME settings.json, so a stray comma refuses both, and printing
33
+ // the whole path-plus-parser-error twice buries the one line that differs —
34
+ // which of our two installations is still in there.
35
+ const named = new Set();
36
+ // The screen's dash, by the name every line below uses: bin/cli/screen.js
37
+ // answers it when it loads, so it is there before the boot draws anything.
38
+ const { dash: gDash } = G;
39
+ /** Report one provider's outcome. `ok` first — see uninstallHooks. */
40
+ const report = (res, label) => {
41
+ if (res.ok === false) {
42
+ refused = true;
43
+ named.add(res.settingsPath);
44
+ console.error(`${PRODUCT}: ${label} hooks NOT removed ${gDash} ${res.settingsPath} could not be read as JSON (${res.why}).`);
45
+ console.error(`${PRODUCT}: the __agent-dag hook entries are still in that file and keep firing on every ${label} event.`);
46
+ return;
47
+ }
48
+ console.log(res.changed
49
+ ? `${PRODUCT}: hooks removed from ${res.settingsPath}`
50
+ : `${PRODUCT}: no ${label} hooks to remove`);
51
+ };
52
+ report(await uninstallHooks({ provider: "claude" }), "Claude");
53
+ // The old finish sound was a second entry in the same file, marked
54
+ // __agent-dag-sound rather than __agent-dag, and uninstallHooks does not know
55
+ // that mark — so it used to be left behind, playing on every turn after the
56
+ // deck was supposedly gone. #704 retired the mechanism outright, but the entry
57
+ // is still on every machine that had it, so removing it is still this
58
+ // command's job. So is the other half: turning the sound on parked the user's
59
+ // own afplay/PowerShell Stop hooks, and once the deck is uninstalled nothing
60
+ // else on the machine knows where they went.
61
+ // The login item goes too, and this is the one place the uninstall's
62
+ // documented narrowness has to bend. "Hook entries only" is right for the
63
+ // event log and the port registry, which are data somebody may still want —
64
+ // but a login item left behind after an uninstall is not data, it is a
65
+ // machine that keeps starting a deck whose hooks were just removed.
66
+ {
67
+ const svc = await import(pathToFileURL(join(PKG_ROOT, "src/server/login-service.mjs")).href);
68
+ const { deckDataDir: dataDir } = await import(pathToFileURL(join(PKG_ROOT, "src/server/deck-home.mjs")).href);
69
+ if (svc.readServiceRecord(dataDir()) !== null) {
70
+ const gone = svc.uninstallService();
71
+ svc.writeServiceRecord(dataDir(), removedRecord());
72
+ // Silent when there was nothing on the machine: an uninstall that reports
73
+ // removing a login item this deck never had is the same lie the explicit
74
+ // command used to tell, in the place a reader is least able to check it.
75
+ if (!gone.ok) {
76
+ // `gDash`, not an em dash: this runs before the boot, on a console that
77
+ // may be the legacy Windows one, and #797 is the rule that a printed
78
+ // string never carries punctuation the terminal may not have.
79
+ console.error(`${PRODUCT}: could NOT remove the login item ${gDash} ${gone.reason} (${gone.path})`);
80
+ } else if (gone.existed) {
81
+ console.log(`${PRODUCT}: no longer starts at login`);
82
+ }
83
+ if (!gone.ok) refused = true;
84
+ }
85
+ }
86
+
87
+ const { retireSoundHook } = await import(pathToFileURL(join(PKG_ROOT, "src/server/retire-sound-hook.mjs")).href);
88
+ const sound = await retireSoundHook();
89
+ if (sound.removed) console.log(`${PRODUCT}: sound hook removed`);
90
+ if (sound.restored) console.log(`${PRODUCT}: restored ${sound.restored} of your own sound hook(s)`);
91
+ if (sound.ok === false) {
92
+ refused = true;
93
+ // Two different refusals, and saying the wrong one sends the user to the
94
+ // wrong file. `settings_unreadable` means nothing was touched at all — and
95
+ // when the forwarders already named that same file, the whole path and
96
+ // parser error would only bury the one line that differs. `parked_unreadable`
97
+ // is the other file: our entry IS out (the lines above said so), and what is
98
+ // still owed is the user's own hooks, which stay parked until they repair it.
99
+ if (sound.reason === "settings_unreadable") {
100
+ console.error(named.has(sound.settingsPath)
101
+ ? `${PRODUCT}: the sound hook is still in that file too.`
102
+ // `gDash`, like every other line here (#797, #1431).
103
+ : `${PRODUCT}: sound hook left in place ${gDash} ${sound.message}`);
104
+ named.add(sound.settingsPath);
105
+ } else {
106
+ console.error(`${PRODUCT}: your own sound hooks were NOT restored ${gDash} ${sound.message}`);
107
+ }
108
+ }
109
+ if (hasCodexInstalled()) {
110
+ report(await uninstallHooks({ provider: "codex" }), "Codex");
111
+ }
112
+ // ── THE PRIVATE KEY ────────────────────────────────────────────────────────
113
+ //
114
+ // The one thing left on the disk that is a CREDENTIAL rather than data. Every
115
+ // deck paired with this one has pinned the key in prefs.json, and until #959
116
+ // nothing in this command — or in the `--help` text, or in the README's
117
+ // uninstall paragraph — named the file, so somebody who followed the
118
+ // instructions to the letter believed the machine was clean and it was not.
119
+ //
120
+ // NAMED, NOT REMOVED, unless somebody asked. prefs.json also holds the
121
+ // pairings, the aliases and which accounts this deck offers, and deleting a
122
+ // user's settings out from under a command documented as "hook entries only"
123
+ // is a different complaint of the same size. So the default is to say exactly
124
+ // where the key is — resolved for THIS machine, both copies, because
125
+ // `migrateDeckFiles` copies rather than moves and the old one is in a
126
+ // directory nothing calls the deck's state — and `--purge` is the sentence
127
+ // somebody types when they mean it.
128
+ {
129
+ const { keyDirs, findKeyFiles, purgeKeyFiles } =
130
+ await import(pathToFileURL(join(PKG_ROOT, "src/server/purge-key.mjs")).href);
131
+ const found = await findKeyFiles(keyDirs());
132
+ if (flags.purge) {
133
+ const { removed, failed } = await purgeKeyFiles(found);
134
+ for (const path of removed) console.log(`${PRODUCT}: removed ${path}`);
135
+ for (const f of failed) {
136
+ refused = true;
137
+ // `gDash`, not an em dash: before the boot, possibly the legacy Windows
138
+ // console (#797).
139
+ console.error(`${PRODUCT}: could NOT remove ${f.path} ${gDash} ${f.why}`);
140
+ console.error(`${PRODUCT}: this deck's LAN private key is still in that file.`);
141
+ }
142
+ if (removed.length === 0 && failed.length === 0) {
143
+ console.log(`${PRODUCT}: no ${PRODUCT} state to purge`);
144
+ }
145
+ } else {
146
+ // "no-key" files are not mentioned. Telling somebody their private key is
147
+ // on the machine when the file has none is the same defect as the silence,
148
+ // pointing the other way. "unknown" IS mentioned: an unparseable file is
149
+ // the one nobody can rule a key out of.
150
+ const holding = found.filter(f => f.holds !== "no-key");
151
+ if (holding.length > 0) {
152
+ console.log(`${PRODUCT}: this deck's LAN private key is still on this machine:`);
153
+ for (const f of holding) {
154
+ console.log(`${PRODUCT}: ${f.path}${f.holds === "unknown" ? ` (unreadable ${gDash} ${f.why})` : ""}`);
155
+ }
156
+ console.log(`${PRODUCT}: every deck you paired with has pinned that key. \`${COMMAND} --uninstall --purge\` removes the file(s) above.`);
157
+ }
158
+ }
159
+ }
160
+
161
+ // The remedy last and once, after every symptom above it, rather than once
162
+ // per refusal in the middle of the list.
163
+ if (named.size > 0) {
164
+ console.error(`${PRODUCT}: repair the JSON (or move the file aside), then run \`${COMMAND} --uninstall\` again.`);
165
+ }
166
+ // Non-zero when any half of it refused, so `ccdeck --uninstall && …` and every
167
+ // CI step that runs this stops on the failure instead of continuing past it.
168
+ return refused ? 1 : 0;
169
+ }