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
@@ -0,0 +1,254 @@
1
+ // The one-shot commands: `--status`, `--logs` and `--stop`, and the way in for
2
+ // the three login-item commands, which bin/cli/login-item.js answers.
3
+ //
4
+ // One-shot commands in the shape `--uninstall` already established: do the
5
+ // thing, print, exit, and never start a server. bin/deck.js asks them ABOVE the
6
+ // migration and well above the heavy imports, because a command that ends a
7
+ // deck has no business moving that deck's files on the way past, and because
8
+ // asking a server to stop should not require starting one.
9
+ //
10
+ // The glyphs and the palette are the boot's own, from bin/cli/screen.js, which
11
+ // answers them when it loads — so these lines can use them even though they run
12
+ // before the boot has drawn anything (#797).
13
+ import { readFileSync as readLog, statSync as statLog } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { pathToFileURL } from "node:url";
16
+ import { isPortValue } from "../../src/server/args.mjs";
17
+ import { sinceLabel } from "../../src/server/term.mjs";
18
+ import { installGlobally, loginItemCommand } from "./login-item.js";
19
+ import { COMMAND, PKG_ROOT } from "./package.js";
20
+ import { G, P } from "./screen.js";
21
+
22
+ /**
23
+ * Answer one of the six one-shot flags and resolve to the exit code. The
24
+ * caller exits with it; nothing here starts a server.
25
+ */
26
+ export async function oneShot(flags) {
27
+ const voice = oneShotVoice();
28
+ reportFlagRows(flags, voice);
29
+ if (flags.stop && refusesPort(flags, voice)) return 1;
30
+
31
+ const { deckDataDir, deckLogDir } = await import(pathToFileURL(join(PKG_ROOT, "src/server/deck-home.mjs")).href);
32
+
33
+ // --install, --install-service and --uninstall-service: the login item, in
34
+ // bin/cli/login-item.js. Answered before the registry is read, since neither
35
+ // is about a deck that is running.
36
+ if (flags.install) return installGlobally({ ...voice, deckDataDir, deckLogDir });
37
+ if (flags.installService || flags.uninstallService) return loginItemCommand(flags, { ...voice, deckDataDir, deckLogDir });
38
+ if (flags.logs) return printLogs(deckLogDir, voice);
39
+
40
+ const { liveDecks } = await import(pathToFileURL(join(PKG_ROOT, "src/server/running-deck.mjs")).href);
41
+ const decks = await liveDecks().catch(() => []);
42
+ if (flags.status) return printStatus(decks, await defaultShape(deckLogDir), voice);
43
+ return stopDecks(flags, decks, voice);
44
+ }
45
+
46
+ /** The screen's glyphs and palette, by the names the one-shots use, and `say`. */
47
+ function oneShotVoice() {
48
+ const { dash, ok: gOk, warn: gWarn, bullet, arrow, ellipsis: gEllipsis } = G;
49
+ const tone = P;
50
+ const say = (line) => process.stdout.write(`${line}\n`);
51
+ return { dash, gOk, gWarn, bullet, arrow, gEllipsis, tone, say };
52
+ }
53
+
54
+ const age = (d) => sinceLabel(Date.now() - Date.parse(d.startedAt ?? ""));
55
+ const where = (d) => (d.workspace ? d.workspace : "(all)");
56
+ const url = (d) => `http://127.0.0.1:${d.port}`;
57
+
58
+ // WHAT THE ONE-SHOTS USED TO SWALLOW.
59
+ //
60
+ // The boot's reportUnknownFlags and reportIncompleteFlags live in bin/deck.js,
61
+ // which is the script itself and cannot be imported, and they write the boot's
62
+ // label-column rows to stdout. So a one-shot's are written here, to stderr, in
63
+ // the one-shots' own shape.
64
+ //
65
+ // Without them `--help`'s closing promise — "Anything else on the command
66
+ // line is reported as an unknown option and then ignored" — held for a boot
67
+ // and for none of the nine one-shots: `ccdeck --status --prot 4317` printed
68
+ // the status and never mentioned the flag.
69
+ function reportFlagRows(flags, { tone, gWarn, dash }) {
70
+ for (const token of flags.unknown ?? []) {
71
+ process.stderr.write(` ${tone.warn}${gWarn} unknown option${tone.reset} ${token} ${dash} see \`${COMMAND} --help\`\n`);
72
+ }
73
+ for (const { flag, expects } of flags.incomplete ?? []) {
74
+ process.stderr.write(` ${tone.warn}${gWarn} missing value${tone.reset} ${flag} ${dash} expected ${expects}\n`);
75
+ }
76
+ }
77
+
78
+ // AND `--stop` FAILS CLOSED ON A PORT IT CANNOT USE.
79
+ //
80
+ // The selector below reads "no usable port" and "no port asked for" as the
81
+ // same thing, and the second means every deck — so `ccdeck --stop --port
82
+ // 431x`, or `--port $UNSET`, ended every deck on the machine and exited 0.
83
+ // The guard that refuses a bad port is in bin/deck.js, below the line that
84
+ // brings a one-shot here, and a one-shot always exits before reaching it.
85
+ //
86
+ // A narrowing flag that fails open to "everything" is the wrong default for
87
+ // an off switch: failing closed costs a retype, failing open costs a deck
88
+ // somebody else was watching. So this answers true, having said why, when
89
+ // `--stop` was pointed at a port it cannot use.
90
+ function refusesPort(flags, { dash }) {
91
+ const askedPort = (flags.incomplete ?? []).some(x => x.flag === "--port" || x.flag === "-p");
92
+ const badPort = flags.port != null && !isPortValue(flags.port);
93
+ if (badPort || askedPort) {
94
+ const shown = badPort ? ` ${flags.port}` : "";
95
+ console.error(`${COMMAND}: --port${shown}: not a port number ${dash} expected 0-65535.`);
96
+ console.error(`${COMMAND}: refusing to stop every deck when you asked for one.`);
97
+ return true;
98
+ }
99
+ return false;
100
+ }
101
+
102
+ // ── --logs ────────────────────────────────────────────────────────────────
103
+ // What the deck wrote where a terminal would have shown it. Printed RAW,
104
+ // escapes and all: the launcher passes its own colour tier down to a detached
105
+ // deck, so this file is a mirror of the terminal the deck was started from
106
+ // and re-rendering it is the whole point. The path goes last, because that is
107
+ // the line somebody copies into `tail -f`.
108
+ function printLogs(deckLogDir, { say, tone, dash, bullet }) {
109
+ const logPath = join(deckLogDir(), "deck.log");
110
+ let text = null;
111
+ try { text = readLog(logPath, "utf8"); } catch { /* never started, or swept */ }
112
+ if (text === null) {
113
+ say(`\n ${tone.muted}${dash} nothing logged yet ${dash} ${logPath}${tone.reset}\n`);
114
+ return 0;
115
+ }
116
+ // The tail, not the file. It is truncated at the first start on an idle
117
+ // machine, so it is normally small — but an attach appends to a running
118
+ // deck's log every time, and a long-lived deck's log is somebody's week.
119
+ const lines = text.split("\n");
120
+ const shown = lines.slice(Math.max(0, lines.length - 200));
121
+ if (shown.length < lines.length) {
122
+ say(` ${tone.muted}${dash} showing the last ${shown.length} of ${lines.length} lines${tone.reset}`);
123
+ }
124
+ process.stdout.write(shown.join("\n"));
125
+ if (!text.endsWith("\n")) say("");
126
+ const size = (() => { try { return statLog(logPath).size; } catch { return 0; } })();
127
+ say(` ${tone.muted}${dash} ${logPath} ${bullet} ${size} bytes${tone.reset}\n`);
128
+ return 0;
129
+ }
130
+
131
+ /**
132
+ * The shape a bare start would have, for the deck `--status` marks as the one
133
+ * it opens.
134
+ */
135
+ async function defaultShape(deckLogDir) {
136
+ const { canonicalLogPath } = await import(pathToFileURL(join(PKG_ROOT, "src/server/log-election.mjs")).href);
137
+ const { hasCodexInstalled, codexHomeField } = await import(pathToFileURL(join(PKG_ROOT, "src/server/installer.mjs")).href);
138
+ const { hasClaudeInstalled } = await import(pathToFileURL(join(PKG_ROOT, "src/server/claude-dir.mjs")).href);
139
+
140
+ // The default shape, spelled the same way deck.js's boot spells it. NOT the
141
+ // shape of the flags on THIS command line: `--stop --no-codex` is not a
142
+ // request to stop a Codex-less deck, it is a flag that means nothing here,
143
+ // and reading it as a selector would make `--stop` miss the deck it was
144
+ // pointed at and say "nothing is running".
145
+ const mine = {
146
+ workspace: "",
147
+ persist: canonicalLogPath(join(deckLogDir(), "events.jsonl")),
148
+ codex: hasCodexInstalled(),
149
+ claude: hasClaudeInstalled(),
150
+ };
151
+ // And the Codex tree, the one field of a start's shape that is not a flag. A
152
+ // bare start has passed it since #1123 and replaces a deck on another tree, so
153
+ // without it here every tree matched, and `--status` could mark as "opens this
154
+ // one" exactly the deck the next `ccdeck` stops (#1134). Worked out by the
155
+ // function the start and the discovery record use, so all three agree.
156
+ mine.codexHome = codexHomeField(mine.codex);
157
+ return mine;
158
+ }
159
+
160
+ /** `--status`: every deck on this machine, and which one a bare start would open. */
161
+ async function printStatus(decks, mine, { say, tone, dash, gOk, bullet, arrow }) {
162
+ const { sameShape } = await import(pathToFileURL(join(PKG_ROOT, "src/server/running-deck.mjs")).href);
163
+ if (!decks.length) {
164
+ say(`\n ${tone.muted}${dash} no deck is running ${dash} \`${COMMAND}\` starts one${tone.reset}\n`);
165
+ return 0;
166
+ }
167
+ // The one a bare `ccdeck` would open is marked, because with two decks up
168
+ // that is the only question this command is really being asked.
169
+ const opens = decks.find(d => sameShape(d, mine)) ?? null;
170
+ say("");
171
+ for (const d of decks) {
172
+ // The version chunk is dropped rather than printed as "v?" for a deck too
173
+ // old to publish one — same rule as the attach line, and for the same
174
+ // reason: a question mark beside two real facts reads as a fault.
175
+ const head = [d.version ? `v${d.version}` : "", `pid ${d.pid}`, `up ${age(d)}`]
176
+ .filter(Boolean).join(` ${bullet} `);
177
+ const mark = d === opens ? `${tone.ok}${gOk}${tone.reset}` : `${tone.muted}${bullet}${tone.reset}`;
178
+ const tail = d === opens ? `${tone.muted} ${arrow} \`${COMMAND}\` opens this one${tone.reset}` : "";
179
+ say(` ${mark} ${tone.muted}${head}${tone.reset}${tail}`);
180
+ say(` ${tone.accent}${tone.bold}${url(d)}${tone.reset}`);
181
+ say(` ${tone.muted}${where(d)} ${bullet} ${d.persist ?? "no log (--no-persist)"}${tone.reset}`);
182
+ }
183
+ say("");
184
+ return 0;
185
+ }
186
+
187
+ // ── --stop ────────────────────────────────────────────────────────────────
188
+ //
189
+ // WHICH DECK. Every one, unless `--port <n>` names one. A start keeps at most
190
+ // one deck now, so a second is a leftover from before that rule, and an off
191
+ // switch that ended one of two would leave the machine running. (`--all` is the
192
+ // legacy capture flag, a no-op since it became the default; beside `--stop` it
193
+ // always meant "every deck", and that is what the bare command means now.)
194
+ async function stopDecks(flags, decks, { say, tone, dash, gOk, gWarn, bullet }) {
195
+ const { stopDeck } = await import(pathToFileURL(join(PKG_ROOT, "src/server/stop-deck.mjs")).href);
196
+ // `flags.port`, not the `rawPort` deck.js computes: that folds in
197
+ // AGENT_DAG_PORT, which is how somebody RUNS a deck rather than which deck
198
+ // they mean to stop — and a deck started on a custom port still has the
199
+ // default SHAPE, so the matcher finds it without help. It is also a binding
200
+ // deck.js declares after it asks this, so it is not there to read (#797).
201
+ const named = flags.port != null && isPortValue(flags.port) ? Number(flags.port) : null;
202
+ // EVERY DECK, unless one is named. There is meant to be one — a start keeps
203
+ // at most one now — so a second here is a leftover from before that rule, and
204
+ // an off switch that ended one of two would leave the machine running. `--all`
205
+ // is still accepted, and now means what the bare command means.
206
+ const wanted = named !== null ? decks.filter(d => d.port === named) : decks;
207
+
208
+ if (!wanted.length) {
209
+ // Two different silences, and saying the wrong one sends the reader looking
210
+ // in the wrong place.
211
+ const why = named !== null && decks.length
212
+ ? `no deck is listening on ${named} ${dash} \`${COMMAND} --status\` lists them`
213
+ : `no deck is running`;
214
+ say(`\n ${tone.muted}${dash} ${why}${tone.reset}\n`);
215
+ return 0;
216
+ }
217
+
218
+ say("");
219
+ let refused = false;
220
+ for (const d of wanted) {
221
+ const was = age(d);
222
+ const out = await stopDeck(d);
223
+ if (!out.ok) {
224
+ refused = true;
225
+ say(` ${tone.err}${gWarn} could not stop pid ${d.pid} on ${d.port} ${dash} ${out.reason}${tone.reset}`);
226
+ continue;
227
+ }
228
+ // HOW it went out, not just that it did. "asked" means the deck closed its
229
+ // listener, unlinked its registration and left the LAN cleanly; anything
230
+ // else means none of that happened and the next boot has litter to sweep.
231
+ const how = out.how === "asked"
232
+ ? ""
233
+ : out.old
234
+ ? ` ${tone.muted}(${out.how} ${dash} that deck predates \`--stop\`)${tone.reset}`
235
+ : ` ${tone.muted}(${out.how} ${dash} it did not answer)${tone.reset}`;
236
+ say(` ${tone.ok}${gOk}${tone.reset} stopped${tone.muted} ${bullet} pid ${d.pid} ${bullet} port ${d.port} ${bullet} was up ${was}${tone.reset}${how}`);
237
+ }
238
+
239
+ // What is still up, named. A command that ends one of three decks and says
240
+ // only "stopped" leaves the reader believing the machine is clear.
241
+ const left = decks.filter(d => !wanted.includes(d));
242
+ if (left.length) {
243
+ say("");
244
+ say(` ${tone.muted}${dash} ${left.length} other deck${left.length === 1 ? "" : "s"} still running:${tone.reset}`);
245
+ for (const d of left) say(` ${tone.muted}pid ${d.pid} ${bullet} ${d.port} ${bullet} ${where(d)}${tone.reset}`);
246
+ // `--stop --all` used to be named here as the way to reach every deck. It
247
+ // is not: `--all` is a parsed no-op, and a bare `--stop` already ends every
248
+ // deck — which is why this line is only reachable after `--stop --port <n>`
249
+ // narrowed one. Naming the narrowing form is the half that is true.
250
+ say(` ${tone.muted}\`${COMMAND} --stop\` ends every deck ${bullet} \`--stop --port <n>\` ends one${tone.reset}`);
251
+ }
252
+ say("");
253
+ return refused ? 1 : 0;
254
+ }
@@ -0,0 +1,39 @@
1
+ // Which package this worker belongs to, and the name it was started under.
2
+ //
3
+ // Worked out from where THIS file is, so a copy of bin/ in another tree — an npx
4
+ // cache, a package a test assembled by hand — answers for that tree and not for
5
+ // the one it was copied from. Lifted out of bin/deck.js so that every file in
6
+ // bin/cli/ imports the three answers instead of having them handed down.
7
+ import { resolve, dirname, join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { readFileSync } from "node:fs";
10
+ import { PRODUCT } from "../../src/server/brand.mjs";
11
+ import { invokedName } from "../../src/server/invoked-as.mjs";
12
+
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+ export const PKG_ROOT = resolve(__dirname, "../..");
15
+
16
+ /**
17
+ * The version package.json names right now, or null when it cannot be read.
18
+ *
19
+ * Asked twice in a deck's life, and the second answer is only worth having
20
+ * because it can differ from the first: once at boot, for the version this
21
+ * process runs, and again as a restart is about to land, for the version it
22
+ * will land on — whatever an upgrade has installed underneath it since.
23
+ */
24
+ export function versionOnDisk() {
25
+ try { return JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8")).version ?? null; }
26
+ catch { return null; }
27
+ }
28
+
29
+ export const PKG_VERSION = versionOnDisk() ?? "0.0.0";
30
+
31
+ // The command the user typed, handed down by the supervisor — the worker's own
32
+ // argv[1] is bin/deck.js under every one of the three names. Null when it cannot be
33
+ // proven, and null is the answer that prints nothing.
34
+ export const INVOKED_AS = invokedName({ pkgRoot: PKG_ROOT });
35
+
36
+ // The command a hint tells somebody to type: the one they typed, or ours when
37
+ // that cannot be told (#1501). INVOKED_AS stays null in that case for the one
38
+ // caller that must say nothing rather than guess — the rename notice.
39
+ export const COMMAND = INVOKED_AS ?? PRODUCT;
@@ -0,0 +1,98 @@
1
+ // The pulse line: the last line of the boot's screen, and the only one that can
2
+ // still move once the report is done.
3
+ //
4
+ // Lifted out of bin/deck.js, which starts it at the same point in the boot.
5
+ //
6
+ // The whole line is rewritten each beat rather than just the dot: anything else
7
+ // on this deck that has something to say writes a newline first, and after that
8
+ // the line under the cursor is no longer the one we drew — a partial repaint
9
+ // would leave the message behind and pulse into empty space. Sized to the real
10
+ // terminal, because at 40 columns the old fixed 61-character line wrapped, and
11
+ // from then on \r only ever reached its second row.
12
+ //
13
+ // AND IT STOPS MOVING (#742). The dot alternated green and grey every 800ms for
14
+ // as long as the deck ran, and a blinking indicator beside a status line is the
15
+ // vocabulary of "working on it" — so a boot that finished in a second read as
16
+ // one that never finished, which is what people reported. Motion is now spent
17
+ // on the two states where something really is outstanding, and the frame is
18
+ // compared against what is already on screen so a deck at rest paints once and
19
+ // then leaves the terminal alone. See pulseMoves.
20
+ import { pulseDot, pulseText } from "../../src/server/pulse-line.mjs";
21
+ import { G, MOTION, P, UNICODE, cols, write } from "./screen.js";
22
+ import { busyLabel } from "./startup.js";
23
+
24
+ /**
25
+ * Put the pulse line up, when anything on this terminal is allowed to move.
26
+ *
27
+ * `registered` is asked on every beat rather than passed once: the discovery
28
+ * check changes it for as long as the deck runs, and the line says so.
29
+ */
30
+ export function startPulse({ registered: isRegistered, wantClaude }) {
31
+ if (!MOTION) return;
32
+ let pi = 0;
33
+ let painted = null;
34
+ // The line is on screen and is the last thing written to this terminal.
35
+ let ours = false;
36
+ // We are the one writing right now, so the guard below leaves us alone.
37
+ let writing = false;
38
+
39
+ // The pulse line's tenancy, enforced rather than agreed.
40
+ //
41
+ // The convention was that anything with something to say writes a newline
42
+ // first, so the pulse's `\r` never lands on somebody else's text. The boot
43
+ // keeps it everywhere. src/server/quota.mjs does not — it calls console.error
44
+ // directly — and a Windows user with no Claude Code sent a screenshot of the
45
+ // result: `listening — Ctrl+C to stop ccdeck quota: claude CLI failed`
46
+ // on one row, three times over, the pulse and the complaint interleaved.
47
+ //
48
+ // An invariant every writer has to remember is one a writer will forget, and
49
+ // the writers here are server modules that know nothing about a terminal. So
50
+ // it is enforced at the stream instead: while our line is the last thing on
51
+ // screen, anything else that speaks gets a newline first, and the memo is
52
+ // dropped so the next beat repaints the line under whatever was said.
53
+ //
54
+ // Both streams, because console.error goes to stderr and lands on the same
55
+ // screen. Only when MOTION is on — with no pulse there is no line to defend,
56
+ // and a piped deck must not have its output rewritten.
57
+ for (const stream of [process.stdout, process.stderr]) {
58
+ const real = stream.write.bind(stream);
59
+ stream.write = (chunk, ...rest) => {
60
+ if (!writing && ours) {
61
+ ours = false;
62
+ // Dropped, not kept: the line is no longer where we left it, so the
63
+ // next beat has to draw it again even though the frame is unchanged.
64
+ painted = null;
65
+ // Unless the speaker already did it. Every late message the boot writes
66
+ // opens with one, and two blank lines is its own kind of mess.
67
+ if (!String(chunk).startsWith("\n")) real("\n");
68
+ }
69
+ return real(chunk, ...rest);
70
+ };
71
+ }
72
+
73
+ setInterval(() => {
74
+ const registered = isRegistered();
75
+ // The colour follows the words. A Codex-only deck keeps saying "listening"
76
+ // when it is unregistered — see pulseText — and painting that sentence in
77
+ // the warning tone would restore the alarm the sentence just retired.
78
+ const alarm = !registered && wantClaude;
79
+ const state = { registered, claude: wantClaude, busy: busyLabel() };
80
+ const text = pulseText({ ...state, columns: cols(), unicode: UNICODE });
81
+ // At rest every beat is lit, which is what makes the line still: the frame
82
+ // is then identical to the one already on screen and the write below is
83
+ // skipped. See pulseDot.
84
+ const dot = pulseDot(pi++, state) === "on" ? (alarm ? P.warn : P.ok) : P.muted;
85
+ const tone = alarm ? P.warn : P.muted;
86
+ const frame = `\r ${dot}${G.pulse}${P.reset} ${tone}${text}${P.reset}`;
87
+ // Unchanged frames are not written at all. That is what makes "at rest"
88
+ // visible: one paint, and then a still line for as long as nothing happens.
89
+ // `painted` is dropped by the guard above whenever somebody else writes, so
90
+ // this can only skip a beat while the line is genuinely still where we left
91
+ // it.
92
+ if (frame === painted) return;
93
+ painted = frame;
94
+ writing = true;
95
+ try { write(frame); } finally { writing = false; }
96
+ ours = true;
97
+ }, 800).unref();
98
+ }
@@ -0,0 +1,179 @@
1
+ // The restart latch: one restart at a time, held until the boot can take it,
2
+ // and let go of on every way it can fail.
3
+ //
4
+ // Lifted out of bin/deck.js. The latch is the only state here, and the two
5
+ // things it acts through are handed in rather than reached for: `shutdown`,
6
+ // deck.js's own way out, which a restart ends in, and `releaseRestart`, the
7
+ // server's half of the same latch. What comes back is requestRestart, for
8
+ // startServer's onRestart, and the two calls deck.js makes when its boot ends.
9
+ import { oneLine } from "../../src/server/term.mjs";
10
+ import { RESTART_CODE, UPGRADE_CODE } from "../../src/server/supervisor.mjs";
11
+ import { PKG_VERSION, versionOnDisk } from "./package.js";
12
+ import { G, P, cols, write } from "./screen.js";
13
+
14
+ /**
15
+ * Arm the latch, and the supervisor's answer to an upgrade with it. Called once,
16
+ * before the server can take a request.
17
+ */
18
+ export function restartLatch({ shutdown, releaseRestart }) {
19
+ // Asking the supervisor to bring us back. It is the only party that can, and
20
+ // only after this process is gone — which is precisely what keeps the
21
+ // replacement from racing this listener onto a random fallback port.
22
+ let restarting = false;
23
+ // Outer bound on the supervisor's answer below. It cannot be reached today —
24
+ // the fetch has a deadline of its own and every path through it replies — but
25
+ // `restarting` is a latch, and a latch with no way out is how a deck ends up
26
+ // silently refusing every restart for the rest of its life.
27
+ const UPGRADE_ANSWER_MS = 150_000;
28
+ let upgradeTimer = null;
29
+
30
+ // Whether the boot has finished: markBooted, called by deck.js at its end.
31
+ //
32
+ // The server starts accepting connections from inside startServer, before
33
+ // that call has returned — so /api/restart is reachable for the whole of the
34
+ // boot that follows it: the startup report, the port report to the supervisor,
35
+ // the discovery file and its first fsynced write, and on a cold start the
36
+ // browser spawn. A restart landing in that window used to reach `shutdown`
37
+ // before the binding holding it was initialised and die of a ReferenceError,
38
+ // having already set the latch above, with nothing left to clear it — after
39
+ // which every restart from every tab was answered "ok" and did nothing, for the
40
+ // life of the process (#448).
41
+ //
42
+ // So an ask that arrives too early is held rather than run: the user asked for
43
+ // something this deck can genuinely give a moment later, and refusing outright
44
+ // would put back the same silence in a politer form. BOOT_RESTART_MS is the
45
+ // outer bound, for the reason UPGRADE_ANSWER_MS above is one. It used to be a
46
+ // bound that got used — before #742 the report waited out a real `uv tool
47
+ // install`, so the window it covers was minutes wide. It is now the boot
48
+ // deadline plus the browser spawn, comfortably inside ten seconds, and this
49
+ // stays as the thing that makes that a fact rather than a belief. Ten seconds
50
+ // in, the ask is run; the respawn skips the report entirely and is up in about
51
+ // a second.
52
+ let booted = false;
53
+ let heldRestart = null;
54
+ let bootTimer = null;
55
+ const BOOT_RESTART_MS = 10_000;
56
+
57
+ const requestRestart = (mode) => {
58
+ if (restarting) return;
59
+ restarting = true;
60
+ if (!booted) {
61
+ heldRestart = { mode };
62
+ bootTimer = setTimeout(() => { bootTimer = null; runHeldRestart(); }, BOOT_RESTART_MS);
63
+ bootTimer.unref?.();
64
+ // Said out loud for the same reason abandonUpgrade below is: the tab has
65
+ // already been told its restart was accepted, and a second of nothing
66
+ // happening on this terminal is otherwise indistinguishable from the bug
67
+ // this replaces.
68
+ write(`\n ${P.warn}${G.restart}${P.reset} ${P.muted}restart queued ${G.dash} still starting up${P.reset}\n`);
69
+ return;
70
+ }
71
+ beginRestart(mode);
72
+ };
73
+
74
+ // The restart itself, once there is a booted deck to end. Split out of
75
+ // requestRestart so the held ask above can re-enter it without tripping the
76
+ // latch it is already holding.
77
+ //
78
+ // Everything here runs inside one try: the whole point of #448 is that a throw
79
+ // on this path is not merely a failed restart but a permanent one, because the
80
+ // latch it leaves behind outlives it. There is no line in here worth dying for.
81
+ function beginRestart(mode) {
82
+ try {
83
+ // "npx" means the newer code is not on this disk at all, so it has to be
84
+ // fetched — and this process keeps serving while that happens. Exiting first
85
+ // is what made every failed upgrade an outage: the SSE stream dropped, hook
86
+ // events fired into the gap were lost outright (hook/hook.js is
87
+ // fire-and-forget with a 1s timeout and no retry), and the canvas came back
88
+ // with whatever was in flight stuck until the stale sweeper reaped it — all
89
+ // of it paid before anyone knew whether npm could even resolve the version.
90
+ // Nothing is torn down here now; the supervisor answers when it knows.
91
+ if (mode === "npx") {
92
+ upgradeTimer = setTimeout(() => abandonUpgrade("no answer from the supervisor"), UPGRADE_ANSWER_MS);
93
+ upgradeTimer.unref?.();
94
+ // Armed before the ask, not after: a send that throws is a supervisor that
95
+ // can no longer answer, and the deck has to come back out of the latch on
96
+ // its own rather than wait out an answer that cannot arrive.
97
+ try { process.send({ type: "upgrade" }); }
98
+ catch (err) { abandonUpgrade(err?.message ?? "the supervisor is no longer listening"); }
99
+ return;
100
+ }
101
+ // What a restart would land on. Read from disk now rather than remembered
102
+ // from boot, because the whole point is that the two differ.
103
+ const to = versionOnDisk();
104
+ write(`\n ${P.warn}${G.restart}${P.reset} ${P.muted}restarting${to ? ` ${G.arrow} v${to}` : ""}${G.ellipsis}${P.reset}\n`);
105
+ shutdown(RESTART_CODE);
106
+ } catch (err) {
107
+ abandonRestart(err);
108
+ }
109
+ }
110
+
111
+ // The ask that was waiting for the boot to finish, now that it has. Safe to
112
+ // call when nothing is waiting, which is every ordinary boot.
113
+ function runHeldRestart() {
114
+ if (!heldRestart) return;
115
+ const { mode } = heldRestart;
116
+ heldRestart = null;
117
+ clearTimeout(bootTimer);
118
+ bootTimer = null;
119
+ beginRestart(mode);
120
+ }
121
+
122
+ // A restart that could not be started, said out loud and then let go of.
123
+ //
124
+ // Both halves of the latch have to come down — this one's and the server's —
125
+ // because a latch nothing clears is precisely how one failed request turned
126
+ // into a deck that refused every restart afterwards while answering "ok" to
127
+ // each one (#448). The reason is folded onto one line by oneLine: the terminal
128
+ // under this is repainted every 800ms by the pulse, and a stack written into
129
+ // that is a stack nobody can read (#432).
130
+ //
131
+ // A declaration rather than a const, like deck.js's `shutdown` and for the same
132
+ // reason: this is the handler for a binding that was not there yet, and it must
133
+ // not be capable of becoming the next one.
134
+ function abandonRestart(err) {
135
+ clearTimeout(bootTimer);
136
+ bootTimer = null;
137
+ heldRestart = null;
138
+ restarting = false;
139
+ releaseRestart();
140
+ write(
141
+ `\n ${P.err}${G.fail}${P.reset} ${P.muted}restart failed ${G.dash} still on ${P.reset}v${PKG_VERSION}\n` +
142
+ ` ${P.muted}${oneLine(err?.stack ?? err, Math.max(20, cols() - 6), G.ellipsis)}${P.reset}\n`,
143
+ );
144
+ }
145
+
146
+ // The upgrade did not happen and this deck is still the deck. Said out loud
147
+ // because the terminal has just printed that a fetch was starting, and left
148
+ // unsaid it reads as a restart that hung.
149
+ const abandonUpgrade = (why) => {
150
+ clearTimeout(upgradeTimer);
151
+ restarting = false;
152
+ // The server's own latch, which no longer has an exiting process to clear it.
153
+ releaseRestart();
154
+ write(
155
+ `\n ${P.warn}${G.cancel}${P.reset} ${P.muted}update not applied ${G.dash} still on ${P.reset}v${PKG_VERSION}\n` +
156
+ (why ? ` ${P.muted}${why}${P.reset}\n` : ""),
157
+ );
158
+ };
159
+
160
+ // The supervisor's verdict on the fetch it was asked for. Only it can answer:
161
+ // the fetch is its child, and it is the process that will still be here when
162
+ // this one exits.
163
+ process.on("message", (m) => {
164
+ if (!restarting || !m || typeof m !== "object") return;
165
+ if (m.type === "upgrade-ready") {
166
+ clearTimeout(upgradeTimer);
167
+ // The replacement is on the machine now, so this is the last moment the
168
+ // port is worth holding: exiting hands it straight over.
169
+ write(`\n ${P.warn}${G.restart}${P.reset} ${P.muted}updating via npx${G.ellipsis}${P.reset}\n`);
170
+ shutdown(UPGRADE_CODE);
171
+ } else if (m.type === "upgrade-refused") {
172
+ abandonUpgrade(m.error);
173
+ }
174
+ });
175
+
176
+ /** The boot is over, so an ask that arrives from now on is run, not held. */
177
+ const markBooted = () => { booted = true; };
178
+ return { requestRestart, markBooted, runHeldRestart };
179
+ }