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,421 @@
1
+ // How this process is replaced or ended: the version, upgrade, restart and
2
+ // stop routes, the latches that keep two asks from starting two of either, and
3
+ // the away-update that presses Upgrade or Restart by itself while nobody is
4
+ // looking.
5
+ //
6
+ // These lived in src/server/index.mjs — the latches beside startServer, the
7
+ // routes after the SSE stream, the away-update before pushEvent. They share one
8
+ // piece of state, the restart latch, which a press and the away-update both
9
+ // take, and none of them reads the ring, the SSE fan-out or the scanners; so
10
+ // they moved together and the latch stayed private. index.mjs arms them once
11
+ // per boot (armLifecycle, startAwayUpdate), routes to the five handlers, feeds
12
+ // `activity` from pushEvent, and re-exports the launcher's two calls,
13
+ // releaseRestart and markDeckReady. The bodies are unchanged.
14
+ import { dirname, join, resolve } from "node:path";
15
+ import { fileURLToPath, pathToFileURL } from "node:url";
16
+ import { createActivity } from "./activity.mjs";
17
+ import { AWAY_BOOT_GRACE_MS, AWAY_RECHECK_MS, AWAY_TICK_MS, awayGate, awayUpdateStep } from "./auto-update.mjs";
18
+ import { PRODUCT } from "./brand.mjs";
19
+ import { logWritableNow } from "./event-log.mjs";
20
+ import { readBody, send } from "./http-io.mjs";
21
+ import { invokedName, renameNotice } from "./invoked-as.mjs";
22
+ // The build this process runs, loaded before an install can replace it: the
23
+ // Upgrade press and the away-update wait on it, and markDeckReady starts it —
24
+ // see pinned-build.mjs.
25
+ import { pinRunningBuild } from "./pinned-build.mjs";
26
+ import { heldPrefs } from "./prefs-state.mjs";
27
+ import { createPresence } from "./presence.mjs";
28
+ import { presentsDeckToken } from "./request-gates.mjs";
29
+ // The version this process runs, read at boot by a leaf so that the LAN
30
+ // engine's card and /api/version give one answer — see running-version.mjs.
31
+ import { RUNNING_VERSION } from "./running-version.mjs";
32
+
33
+ // Resolved the way pinned-build.mjs resolves it, from a file in the same
34
+ // directory, so every lazy import below is the URL the pin has already evaluated.
35
+ const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
36
+
37
+ // Set from startServer's options. The server cannot restart itself — the
38
+ // process lifecycle belongs to the supervisor in bin/agent-dag.js, which is the
39
+ // only thing that can bring a replacement up on the same port without racing
40
+ // the dying listener. Absent (running the module directly, or under an older
41
+ // launcher), /api/restart answers 501 and the UI hides the control rather than
42
+ // offering a button that does nothing.
43
+ let _onRestart = null;
44
+ // How this process ends itself, handed down by bin/deck.js for the same reason
45
+ // _onRestart is: the server does not own the process lifecycle and must be told
46
+ // how to leave. Absent when nothing handed one down — running the module
47
+ // directly, or an embedder — and /api/shutdown then answers 501 rather than
48
+ // pretending.
49
+ let _onStop = null;
50
+ // A stop is in flight. Two sockets asking at once must not both start one, and
51
+ // the response's 'finish' and 'close' can both fire for the same request.
52
+ let _stopping = false;
53
+ // A restart is in flight. Several browser tabs watching the same deck will each
54
+ // ask; the second ask must not re-enter the shutdown.
55
+ let _restarting = false;
56
+ // Whether the launcher has finished booting — see markDeckReady. False for the
57
+ // whole window between this listener accepting its first connection and
58
+ // bin/deck.js reaching the end of its startup, which is a window /api/restart
59
+ // is reachable in and cannot answer for on its own.
60
+ let _deckReady = false;
61
+
62
+ /**
63
+ * What this boot's launcher handed down, and the latches reset for it.
64
+ *
65
+ * Lifted out of startServer, which calls it first, once per boot: the restart
66
+ * and the stop it may hand to, whether a restart would have a log to replay,
67
+ * and the two in-flight flags and the ready flag, all back to a fresh boot's.
68
+ */
69
+ function armLifecycle({ onRestart, onStop, persist }) {
70
+ _onRestart = typeof onRestart === "function" ? onRestart : null;
71
+ _onStop = typeof onStop === "function" ? onStop : null;
72
+ _stopping = false;
73
+ _canRestart = _onRestart != null && persist != null;
74
+ // A new listener is a new boot, whatever a previous one had got as far as
75
+ // reporting. Nothing but bin/deck.js ever sets this, and it does so once, at
76
+ // the end of the startup that begins with this call.
77
+ _deckReady = false;
78
+ }
79
+
80
+ // True only when a supervisor is listening AND the event log is being written.
81
+ // Without persistence a restart wipes the canvas irrecoverably — replayLog has
82
+ // no file to read — so the deck must not offer to do it. Whether the log is
83
+ // being written is event-log.mjs's to answer; see logWritableNow.
84
+ let _canRestart = false;
85
+
86
+ /**
87
+ * Can this deck be restarted without losing the canvas?
88
+ *
89
+ * Asked at the moment of the press rather than read off a boot-time flag,
90
+ * because the writable half of it changes while the deck runs, and in both
91
+ * directions: a drive unmounted or a volume filled mid-session is discovered by
92
+ * the appender, not by the probe, and so is one that comes back. See
93
+ * logWritableNow.
94
+ */
95
+ function canRestartNow() {
96
+ return _canRestart && logWritableNow();
97
+ }
98
+
99
+ async function handleVersion(req, res) {
100
+ const { versionReport } = await import(
101
+ pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href
102
+ );
103
+ // ?refresh=1 asks npm now rather than reusing the cached answer — what the
104
+ // version chip does when clicked, for the user who has just published or is
105
+ // wondering whether the check is working at all.
106
+ const force = new URL(req.url, "http://localhost").searchParams.get("refresh") === "1";
107
+ const report = await versionReport({ running: RUNNING_VERSION, pkgRoot: PKG_ROOT, force });
108
+ // Attached here rather than inside versionReport for the same reason
109
+ // canRestart is: the report is what this package root says about itself,
110
+ // while both of these are facts about the process serving it — the command
111
+ // the user typed lives in this process's environment and argv, not on disk.
112
+ //
113
+ // It is deliberately not folded into `name`, which is documented as the
114
+ // package an upgrade would install and is load-bearing for the update flow.
115
+ // The two answer different questions and give different answers: `name` is
116
+ // read off the install on disk and is always known, while this one is the
117
+ // command that was typed and is silent wherever it cannot be proven — every
118
+ // Windows global install, and every `npm i -g ccdeck`, whose stub spawns the
119
+ // deck by absolute path and leaves nothing in argv to read.
120
+ const invoked = invokedName({ pkgRoot: PKG_ROOT });
121
+ // The second half of that notice, computed here rather than in the browser.
122
+ //
123
+ // The banner needs one more fact than the name: whether typing `ccdeck` is
124
+ // enough (a global install ships all three commands, so it already is) or
125
+ // whether the whole answer is `npx ccdeck`. The browser used to derive that
126
+ // from `upgradeMode`, which reads like the same question and is not — it
127
+ // answers "may this copy install over itself", and `AGENTS_DECK_NO_INSTALL=1`
128
+ // makes it null for EVERY shape including npx, because upgradeBlockedReason
129
+ // tests the opt-out before it tests npx. So an npx user who opted out of
130
+ // installs was told the global-install line and sent to a command that does
131
+ // not exist on their machine, while the terminal row two feet away said
132
+ // `npx ccdeck` — one fact, two surfaces, opposite answers (#363).
133
+ //
134
+ // renameNotice is where that fact is decided for the terminal, so it decides
135
+ // it here too and the browser renders the string rather than a branch. No
136
+ // `dash` argument on purpose: the glyph tier is a terminal concern, and the
137
+ // default em dash is what a browser should get.
138
+ const rename = renameNotice({ invoked, pkgRoot: PKG_ROOT });
139
+ send(res, 200, {
140
+ ...report, canRestart: canRestartNow(), invokedAs: invoked, renameFix: rename?.fix ?? null,
141
+ });
142
+ }
143
+
144
+ // Runs `npm i -g <this deck>@latest`, and only that: the argument vector is
145
+ // fixed inside npm-upgrade.mjs — including which of the three published names
146
+ // it installs, which comes from the layout npm built rather than from the
147
+ // request. Answers immediately — progress is read back from /api/version.
148
+ async function handleUpgrade(_req, res) {
149
+ const { startUpgrade } = await import(
150
+ pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href
151
+ );
152
+ // Every module this process will ever load, loaded BEFORE npm starts
153
+ // rewriting the tree they come from — see pinned-build.mjs. On a deck that
154
+ // has finished booting this settled long ago; it only waits for a press that
155
+ // lands in the first moments of a boot, or on a server nothing marked ready.
156
+ await pinRunningBuild();
157
+ const out = startUpgrade({ pkgRoot: PKG_ROOT });
158
+ send(res, out.ok ? 200 : 409, out);
159
+ }
160
+
161
+ // Restart is a two-party act: this half answers before it stops listening, so
162
+ // the caller learns it was accepted rather than losing the socket mid-reply.
163
+ //
164
+ // `{ upgrade: true }` asks for the npx variant: come back through
165
+ // `npx -y <spec>@latest` instead of re-running the files already here. Granted
166
+ // only where that is genuinely how this copy updates — the mode is decided from
167
+ // the install on disk, never from the request.
168
+ async function handleRestart(req, res) {
169
+ if (!_onRestart) return send(res, 501, { ok: false, reason: "unsupervised" });
170
+ // Enforced here and not only in the UI. Under --no-persist a restart destroys
171
+ // the whole canvas — replayLog has no file to read — and a destructive act
172
+ // must not be prevented by a hidden button alone.
173
+ if (!_canRestart) return send(res, 409, { ok: false, reason: "no_persist" });
174
+ // A configured log that cannot be written is the same destruction by another
175
+ // route, and it deserves its own word: "no_persist" would tell a user who
176
+ // passed `--history` that they had not, which is the sort of answer that
177
+ // sends someone looking in the wrong place. See canRestartNow.
178
+ if (!canRestartNow()) return send(res, 409, { ok: false, reason: "log_unwritable" });
179
+
180
+ let wantUpgrade = false;
181
+ if (req.method === "POST") {
182
+ const body = await readBody(req, res).catch(() => null);
183
+ try { wantUpgrade = JSON.parse(body ?? "")?.upgrade === true; } catch { /* a plain restart */ }
184
+ }
185
+ let mode = null;
186
+ if (wantUpgrade) {
187
+ const { upgradeBlock, upgradeMode, npxRestartSpec } = await import(
188
+ pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href
189
+ );
190
+ if (upgradeMode(upgradeBlock(PKG_ROOT)) !== "npx" || !npxRestartSpec(PKG_ROOT)) {
191
+ return send(res, 409, { ok: false, reason: "not_npx" });
192
+ }
193
+ mode = "npx";
194
+ }
195
+
196
+ if (_restarting) return send(res, 202, { ok: true, already: true });
197
+ _restarting = true;
198
+ // Accepted either way — the ask is good and the launcher holds on to it — but
199
+ // the two are not the same event and answering 200 to both would be this
200
+ // window's second untruth rather than its first. Between this listener
201
+ // accepting its first connection and bin/deck.js finishing the rest of its
202
+ // startup there is a real stretch during which a restart cannot be run yet,
203
+ // and a caller that reads the body should be able to tell that it is waiting
204
+ // on a boot rather than on a supervisor. See markDeckReady, and requestRestart
205
+ // in bin/deck.js for the half that does the waiting.
206
+ if (_deckReady) send(res, 200, { ok: true, mode });
207
+ else send(res, 202, { ok: true, mode, booting: true, detail: "the deck is still starting up; the restart runs as soon as it has finished booting" });
208
+ // Let the response flush before the listener goes away.
209
+ handOffRestart(mode);
210
+ }
211
+
212
+ /**
213
+ * Hand a restart the latch has already admitted to the launcher — from a press
214
+ * (handleRestart, once its answer is on its way) or from the away-update
215
+ * (awayUpdateTick). The delay is the press's: its response has to leave before
216
+ * the listener does.
217
+ */
218
+ function handOffRestart(mode) {
219
+ setTimeout(() => {
220
+ try { _onRestart(mode); }
221
+ catch (err) {
222
+ // This catch is where #448 lived: it released the server's half of the
223
+ // latch and said nothing, while the launcher's half stayed set with
224
+ // nothing left to clear it, and every later restart answered "ok" and did
225
+ // nothing for the rest of the process's life. The launcher now owns its
226
+ // own failures; this stays as the outer net, and it says so — one line,
227
+ // message only, because the terminal underneath is repainted every 800ms
228
+ // by the pulse and a stack dumped into it is a stack nobody can read.
229
+ _restarting = false;
230
+ console.error(`${PRODUCT}: restart request failed: ${err?.message ?? err}`);
231
+ }
232
+ }, 120).unref();
233
+ }
234
+
235
+ /** The other end of the latch above, for the upgrade that never happened.
236
+ *
237
+ * An npx upgrade is now fetched before anything is torn down, so a fetch that
238
+ * fails leaves this process serving — with `_restarting` still set from a
239
+ * restart that is not coming. Nothing would ever clear it: the only path that
240
+ * did was the process ending. Every later click, from any tab, answered 202
241
+ * "already restarting" for the rest of the deck's life. */
242
+ export function releaseRestart() {
243
+ _restarting = false;
244
+ }
245
+
246
+ /** The launcher saying its boot is over: everything a shutdown would have to
247
+ * tear down now exists.
248
+ *
249
+ * This server starts accepting from inside startServer, before that call has
250
+ * even returned to bin/deck.js — so /api/restart is answerable for the whole
251
+ * of the startup that follows it, which on a cold boot includes the discovery
252
+ * file's first fsynced write and spawning the browser. The listener cannot see
253
+ * any of that from here; it has to be told. Called once, from bin/deck.js.
254
+ *
255
+ * Only /api/restart reads it, and only to answer honestly. Nothing is refused
256
+ * on the strength of it: the restart is still handed to the launcher, which
257
+ * holds it until it can run it (#448).
258
+ *
259
+ * IT IS ALSO WHERE THE BUILD IS PINNED (#1042) — see pinned-build.mjs. After
260
+ * the boot, so the laziness still keeps that work off the path to the socket;
261
+ * and before anything can replace the tree, because `npm i -g` typed in a
262
+ * terminal rewrites it exactly as startUpgrade's does, and the drift path then
263
+ * leaves this process serving until an idle moment just the same. The promise
264
+ * is handed back for the test that has to know when the pin has landed;
265
+ * bin/deck.js has no reason to wait for it, and it never rejects. */
266
+ export function markDeckReady() {
267
+ _deckReady = true;
268
+ return pinRunningBuild();
269
+ }
270
+
271
+ /**
272
+ * POST /api/shutdown — end this deck, from `ccdeck --stop`.
273
+ *
274
+ * THE TOKEN AND NOTHING ELSE. Every other mutating route accepts either the
275
+ * token or the deck's own page (isAuthorizedMutation), and this one must not:
276
+ * there is no button for it, so a page asking to end the deck is a page doing
277
+ * something no part of this product asks it to do. Refusing the browser half
278
+ * costs nothing and removes the whole class.
279
+ *
280
+ * WHY A ROUTE AND NOT A SIGNAL. Windows has none. `process.kill(pid, "SIGTERM")`
281
+ * there is TerminateProcess: the deck stops mid-instruction, its discovery file
282
+ * is left for the next boot to sweep, and its LAN beacon never says goodbye — so
283
+ * every paired colleague watches it time out instead of seeing it leave. One
284
+ * loopback POST behaves identically on all three platforms and ends in the
285
+ * deck's own shutdown(), which closes the listener, unlinks the registration and
286
+ * stops the beacon. The pid ladder still exists in `--stop`, as the fallback for
287
+ * a deck too wedged to answer this.
288
+ *
289
+ * ANSWERED BEFORE ANYTHING IS TORN DOWN. shutdown() calls
290
+ * server.closeAllConnections(), which would cut this very socket — so the
291
+ * teardown is hung off the response having left rather than run beside it, and
292
+ * the caller gets a 200 instead of a dropped connection it has to interpret.
293
+ */
294
+ async function handleStop(req, res) {
295
+ if (!presentsDeckToken(req?.headers ?? {})) return send(res, 401, { ok: false, reason: "unauthenticated" });
296
+ if (!_onStop) return send(res, 501, { ok: false, reason: "no_launcher" });
297
+ if (_stopping) return send(res, 200, { ok: true, pid: process.pid, already: true });
298
+ _stopping = true;
299
+ let left = false;
300
+ const leave = () => {
301
+ if (left) return;
302
+ left = true;
303
+ // A throw here must still end the process: the caller has already been told
304
+ // this deck is going, and a deck that answered "ok" and stayed up is worse
305
+ // than one that never answered.
306
+ try { _onStop(); } catch { process.exit(0); }
307
+ };
308
+ // 'finish' is the response handed to the OS; 'close' covers the caller that
309
+ // hung up before it got there. Either one means nothing is left to flush.
310
+ res.once("finish", leave);
311
+ res.once("close", leave);
312
+ send(res, 200, { ok: true, pid: process.pid });
313
+ }
314
+
315
+ // The two facts the away-update waits on (awayUpdateTick): whether a turn is
316
+ // running, fed from pushEvent, and whether a tab is being looked at, fed from
317
+ // POST /api/presence. They sat at the top of index.mjs while pushEvent was
318
+ // that file's, because pushEvent can run before the module holding it has
319
+ // finished evaluating; made here, they exist before event-pipeline.mjs's own
320
+ // body runs at all.
321
+ const activity = createActivity();
322
+ const presence = createPresence();
323
+
324
+ // ── updating while nobody is looking ────────────────────────────────────────
325
+ //
326
+ // auto-update.mjs has the why and the rules; this is the timer and the three
327
+ // effects — install, restart, npx relaunch — each through the code a press
328
+ // uses.
329
+ let _awayTry = null;
330
+ let _awayTimer = null;
331
+ let _bootedAt = Date.now();
332
+ // Until when a report that found nothing newer stands — see AWAY_RECHECK_MS.
333
+ let _awayNothingUntil = 0;
334
+
335
+ /**
336
+ * One tick of the away-update. Returns what it did, or null.
337
+ *
338
+ * Every clock is the real one: presence and activity are stamped with it, and
339
+ * the boot grace is AWAY_BOOT_GRACE_MS. This used to be exported and take the
340
+ * grace as an argument, so a test could drive it without waiting that out; the
341
+ * tests drive awayGate and awayUpdateStep in auto-update.mjs instead, and the
342
+ * one caller left is the `_awayTimer` interval.
343
+ */
344
+ async function awayUpdateTick() {
345
+ const now = Date.now();
346
+ if (!awayGate({
347
+ enabled: heldPrefs.current()?.autoUpdate !== false,
348
+ supervised: _onRestart != null && _canRestart,
349
+ restarting: _restarting,
350
+ sinceBootMs: now - _bootedAt,
351
+ looking: presence.looking(now),
352
+ busy: activity.busy(now),
353
+ quietMs: activity.quietMs(now),
354
+ graceMs: AWAY_BOOT_GRACE_MS,
355
+ })) return null;
356
+ // A standing "nothing newer", unless the clock has moved back past it.
357
+ if (now < _awayNothingUntil && _awayNothingUntil - now <= AWAY_RECHECK_MS) return null;
358
+ const su = await import(pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href);
359
+ const report = await su.versionReport({ running: RUNNING_VERSION, pkgRoot: PKG_ROOT });
360
+ if (!report.notice) {
361
+ _awayNothingUntil = now + AWAY_RECHECK_MS;
362
+ return null;
363
+ }
364
+ const step = awayUpdateStep({
365
+ notice: report.notice, mode: report.upgradeMode,
366
+ installing: report.upgrade?.state === "running", lastTry: _awayTry, now,
367
+ });
368
+ if (!step.act) return null;
369
+ // Asked again, because the lookup above can take seconds on a slow line and
370
+ // somebody may have sat down, or started a turn, in them.
371
+ const again = Date.now();
372
+ if (presence.looking(again) || activity.busy(again) || _restarting) return null;
373
+ _awayTry = { target: step.target, at: again };
374
+ if (step.act === "install") {
375
+ // Pinned before npm is spawned, never after it — see pinned-build.mjs. On a
376
+ // deck bin/deck.js has marked ready this is a promise that settled long ago.
377
+ await pinRunningBuild();
378
+ su.startUpgrade({ pkgRoot: PKG_ROOT });
379
+ return step.act;
380
+ }
381
+ // handleRestart's own check for the npx path, for the same reason: the mode
382
+ // comes from the install on disk, and a spec it cannot name is no relaunch.
383
+ if (step.act === "npx" && !su.npxRestartSpec(PKG_ROOT)) return null;
384
+ _restarting = true;
385
+ handOffRestart(step.act === "npx" ? "npx" : null);
386
+ return step.act;
387
+ }
388
+
389
+ /**
390
+ * The away-update's clock. The boot grace counts from here, and the timer is
391
+ * unref'd so it never keeps a process alive on its own. It does nothing until
392
+ * the launcher has handed down a restart — see awayGate's `supervised`.
393
+ *
394
+ * Lifted out of startServer, which calls it once per boot; a second boot in the
395
+ * same process replaces the timer rather than adding one.
396
+ */
397
+ function startAwayUpdate() {
398
+ _bootedAt = Date.now();
399
+ clearInterval(_awayTimer);
400
+ _awayTimer = setInterval(() => { awayUpdateTick().catch(() => {}); }, AWAY_TICK_MS);
401
+ _awayTimer.unref?.();
402
+ }
403
+
404
+ /** POST {tab, looking} — a tab saying whether it is being looked at. See
405
+ * presence.mjs, and src/web/presence.ts for the sender. */
406
+ async function handlePresence(req, res) {
407
+ const raw = await readBody(req, res).catch(() => null);
408
+ let body = null;
409
+ try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
410
+ if (!body || typeof body !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
411
+ const ok = presence.report(body.tab, body.looking === true, Date.now());
412
+ return send(res, ok ? 200 : 400, { ok });
413
+ }
414
+
415
+ // What index.mjs calls besides the two exported above. Listed rather than
416
+ // marked at each declaration, so the declarations read as they did where they
417
+ // came from.
418
+ export {
419
+ activity, armLifecycle, handlePresence, handleRestart, handleStop,
420
+ handleUpgrade, handleVersion, startAwayUpdate,
421
+ };
@@ -0,0 +1,110 @@
1
+ // Getting a port: the random fallback candidates, one listen attempt, and what
2
+ // to say when an attempt fails.
3
+ //
4
+ // These lived in src/server/index.mjs near startServer, whose listen loop is
5
+ // their only caller, and they ask nothing of the deck — a port range in, a
6
+ // bound server or a worded error out. The loop itself stays in startServer,
7
+ // because what it starts after the bind that takes is the deck's business, and
8
+ // the order of those starts is pinned there. portRetryable, listenHint and
9
+ // listenFailure were exported already, so that every platform's answer can be
10
+ // asserted on any of them.
11
+
12
+ export function randomPort(min, max) {
13
+ return Math.floor(Math.random() * (max - min + 1)) + min;
14
+ }
15
+
16
+ // One attempt, leaving the server with no more listeners on it than it started
17
+ // with. `server.listen(port, host, cb)` registers `cb` for a 'listening' event
18
+ // that a failed bind never emits, so the previous shape left one behind per
19
+ // attempt — eleven candidates printed Node's "MaxListenersExceededWarning:
20
+ // 11 listening listeners added to [Server]" into the middle of a boot that was
21
+ // already going wrong. Both sides are removed by whichever fires first.
22
+ export async function tryListen(server, port, host) {
23
+ return new Promise((res, rej) => {
24
+ const onListening = () => { server.removeListener("error", onError); res(); };
25
+ const onError = (err) => { server.removeListener("listening", onListening); rej(err); };
26
+ server.once("error", onError);
27
+ server.once("listening", onListening);
28
+ server.listen(port, host);
29
+ });
30
+ }
31
+
32
+ /**
33
+ * Whether another PORT could fix this failed `listen`.
34
+ *
35
+ * startServer builds ten random fallback candidates and they exist for exactly
36
+ * one situation: "this port is unavailable". Until #552 only EADDRINUSE reached
37
+ * them, which is the POSIX spelling of that answer and not the only one.
38
+ *
39
+ * WINDOWS. `winnat` hands out contiguous TCP blocks to Hyper-V, WSL2 and Docker
40
+ * Desktop, and a bind INSIDE one of those reserved exclusion ranges — the ones
41
+ * `netsh interface ipv4 show excludedportrange protocol=tcp` prints — is refused
42
+ * with WSAEACCES, which libuv reports as EACCES. Any machine with containers or
43
+ * WSL on it can therefore have 4317 blocked without a single socket being open
44
+ * on it. The loop rethrew on the first candidate, bin/deck.js printed
45
+ * `server failed: listen EACCES: permission denied 127.0.0.1:4317`, and the deck
46
+ * exited 1 with the fallback range untouched.
47
+ *
48
+ * On POSIX the same code is what a bind below 1024 gets without privilege.
49
+ * Retrying is right there too: 4317 and the whole fallback range are above 1024,
50
+ * so a random candidate is a port the user can actually have — and the deck
51
+ * coming up on 4322 beats it refusing to come up at all, which is already how
52
+ * EADDRINUSE on a privileged port behaves today.
53
+ *
54
+ * The other direction is the half that keeps this honest. EADDRNOTAVAIL,
55
+ * ENOTFOUND, EAI_AGAIN and EAFNOSUPPORT are about the HOST, not the port: the
56
+ * address does not exist on this machine or does not resolve, and no candidate
57
+ * can help. Walking eleven of them only delays the one sentence that would have
58
+ * explained it, so anything not named here stops the loop.
59
+ */
60
+ export const portRetryable = (err) =>
61
+ Boolean(err) && (err.code === "EADDRINUSE" || err.code === "EACCES");
62
+
63
+ /**
64
+ * The sentence that goes beside a listen errno, or "" when the errno says it
65
+ * all.
66
+ *
67
+ * Pure, and the platform is a parameter, for the reason every Windows answer in
68
+ * this repo is written that way: the branch that matters most is the one the
69
+ * author cannot run. A raw `listen EACCES: permission denied 127.0.0.1:4317` is
70
+ * true and useless — the thing the user needs is the name of the command that
71
+ * lists the ranges their machine has reserved.
72
+ */
73
+ export function listenHint(code, { host = "", port = 0, platform = process.platform } = {}) {
74
+ if (code === "EACCES") {
75
+ if (platform === "win32") {
76
+ return "a reserved port range is the usual cause on Windows: Hyper-V, WSL2 and Docker Desktop have winnat hold contiguous TCP blocks. Run `netsh interface ipv4 show excludedportrange protocol=tcp` to see them, then pass --port with a number outside every range";
77
+ }
78
+ if (port > 0 && port < 1024) return "ports below 1024 need root — pass --port with a number above 1024";
79
+ return "the OS refused the bind; a sandbox or a security policy is the usual cause";
80
+ }
81
+ if (code === "EADDRNOTAVAIL") {
82
+ return `no interface on this machine holds ${host || "that address"}, so no other port can help — pass --host with an address it does hold, or 127.0.0.1 for local only`;
83
+ }
84
+ if (code === "ENOTFOUND" || code === "EAI_AGAIN") {
85
+ return `${host || "that host"} does not resolve, so no other port can help — pass --host with an address rather than a name`;
86
+ }
87
+ return "";
88
+ }
89
+
90
+ /**
91
+ * The error startServer throws, with the hint already in it.
92
+ *
93
+ * `exhausted` is the difference between "every candidate was refused" and "this
94
+ * one was refused for a reason more candidates cannot fix". The exhausted
95
+ * message keeps its opening words — bin/deck.js prints them and
96
+ * boot-listen-before-report.test.ts reads them — and now names the LAST errno
97
+ * rather than asserting EADDRINUSE, which was a lie the moment EACCES could
98
+ * reach the end of the loop.
99
+ */
100
+ export function listenFailure(err, { host = "", port = 0, platform = process.platform, exhausted = false } = {}) {
101
+ const code = err?.code;
102
+ const head = exhausted
103
+ ? `all ports tried — none available (last: ${code ?? "unknown"} on ${host}:${port})`
104
+ : String(err?.message ?? err ?? "listen failed");
105
+ const hint = listenHint(code, { host, port, platform });
106
+ const out = new Error(hint ? `${head} — ${hint}` : head);
107
+ if (code !== undefined) out.code = code;
108
+ if (err) out.cause = err;
109
+ return out;
110
+ }
@@ -0,0 +1,138 @@
1
+ // Which decks are up right now, as their own discovery records spell them —
2
+ // and only the ones that proved, over their own port, that they are still the
3
+ // deck the record describes (#695).
4
+ //
5
+ // This lived in src/server/index.mjs, inside the Codex rollout watcher, and has
6
+ // two readers: the watcher's log election (writesCodexLog) in codex-watch.mjs,
7
+ // and logSharing in event-log.mjs, which tells the Clear dialog who owns the
8
+ // log. Neither needs anything from the server but the registry directory and
9
+ // the challenge, so it moved to a leaf both can import. The verdict cache moved with it and is still
10
+ // the only state.
11
+ import { readdir, readFile } from "node:fs/promises";
12
+ import { join } from "node:path";
13
+ import { claudeConfigDir } from "./claude-dir.mjs";
14
+ import { challengeDeck, isProcessAlive } from "./deck-probe.mjs";
15
+
16
+ // How long one record's challenge verdict is trusted. The rollout scan runs
17
+ // every 1.5s and would otherwise pay a round trip per record per tick for an
18
+ // answer that changes about once per deck lifetime.
19
+ //
20
+ // Both answers are cached, and neither one delays a takeover, because the pid
21
+ // probe runs first and is not cached at all: a deck killed with SIGTERM or
22
+ // SIGKILL loses its pid immediately and its record is dropped on the very next
23
+ // tick whatever this map says. What the TTL bounds is only the ghost case — a
24
+ // record whose pid was recycled by an unrelated process — where five seconds of
25
+ // believing a stale "yes" is at most a few unwritten lines, against a directory
26
+ // listing plus an HTTP round trip on every tick of every deck forever.
27
+ const DECK_PROOF_TTL_MS = 5000;
28
+ // `${pid}:${port}:${token}` -> { at, ok }. Keyed on the record's own identity so
29
+ // a rewritten record — a deck that restarted onto the same port with a fresh
30
+ // token — is a new question rather than an inherited answer.
31
+ const deckProofs = new Map();
32
+
33
+ /**
34
+ * Is the process listening on this record's port the deck the record describes?
35
+ *
36
+ * WHY THE SERVER ASKS AT ALL (#695). readLiveDecks used to trust the pid and
37
+ * nothing else, and a pid is not evidence: a record left behind by a deck that
38
+ * is gone — SIGKILL, an OOM kill, a power cut, a console window closed on
39
+ * Windows, none of which run the shutdown that unlinks it — passes a signal-0
40
+ * probe forever once the OS hands that number to some other long-lived process.
41
+ * writesCodexLog then elected it, because it named a lower port than any real
42
+ * deck, and every deck tailing the rollout drew the events while none of them
43
+ * appended a line. The canvas looked perfectly normal and events.jsonl stopped
44
+ * growing, so nothing said so until a restart replayed a log that had stopped
45
+ * days earlier.
46
+ *
47
+ * hook.js has had the answer to this since the handshake landed and the Codex
48
+ * half never got it — this is that half. The record carries the deck's own token
49
+ * in plaintext (mode 0600, same user, and it is written there precisely so that
50
+ * another process can challenge with it), so we can ask its port to hash that
51
+ * token against a nonce it has never seen. A stranger cannot answer; the deck
52
+ * that wrote the file can.
53
+ *
54
+ * Two records pass without being asked:
55
+ *
56
+ * • our own. We are the process the record describes, and challenging
57
+ * ourselves over loopback inside our own scan is a question we already know
58
+ * the answer to. It also must never fail: writesCodexLog looks itself up in
59
+ * this list, and a deck that cannot find its own record falls back to
60
+ * writing — so a self-challenge that timed out under load would turn one
61
+ * unwritten line into a duplicated one.
62
+ * • a record with no token, written by a deck older than the handshake. That
63
+ * is exactly requiresProof's fallback in hook.js, kept in step with it on
64
+ * purpose: refusing those would make every pre-1.33.71 deck invisible to the
65
+ * election, which elects a writer for a log those decks are still appending
66
+ * to.
67
+ *
68
+ * A timeout counts as a failure, like a refusal and a wrong answer. That is the
69
+ * same verdict hook.js reaches — a target that misses the deadline is not posted
70
+ * to either — and it errs the direction the server has always erred: the deck
71
+ * writes, and "a line written twice is recoverable; a deck that quietly stops
72
+ * recording anything is not" (writesCodexLog).
73
+ */
74
+ async function provesDeck(d) {
75
+ if (d.pid === process.pid) return true;
76
+ if (typeof d.token !== "string" || d.token === "") return true;
77
+
78
+ const key = `${d.pid}:${d.port}:${d.token}`;
79
+ const cached = deckProofs.get(key);
80
+ const now = Date.now();
81
+ if (cached && now - cached.at < DECK_PROOF_TTL_MS) return cached.ok;
82
+
83
+ const ok = await challengeDeck(d.port, d.token);
84
+ deckProofs.set(key, { at: now, ok });
85
+ // Records come and go; without this the map would grow by one entry per deck
86
+ // that ever registered on this machine while the process is up.
87
+ for (const [k, v] of deckProofs) {
88
+ if (now - v.at >= DECK_PROOF_TTL_MS) deckProofs.delete(k);
89
+ }
90
+ return ok;
91
+ }
92
+
93
+
94
+ /**
95
+ * Every deck registered right now, as its own discovery record spells it — and
96
+ * only the ones that proved they are still the deck their record describes.
97
+ *
98
+ * These are the files hook.js enumerates; this is the server reading them for
99
+ * itself, because the rollout watcher has no hook to do it and still has to know
100
+ * which other decks are tailing the same file into the same log. Dead pids are
101
+ * ignored rather than unlinked — sweepStaleDiscovery owns that, and a poll
102
+ * running every 1.5s is no place to be deleting other decks' registrations. A
103
+ * record that fails the challenge is likewise left alone HERE. The hook is the one
104
+ * reader that does delete a record, and only one that stayed silent through both
105
+ * of its challenge deadlines and has not been restamped for a minute, which a
106
+ * live deck does every five seconds (proveTargets in hook/hook.js, #1119). One
107
+ * missed answer on this poll's timer is not that evidence, so nothing here acts
108
+ * on it.
109
+ *
110
+ * The pid probe stays, in front of the challenge, because it is free and it is
111
+ * the one test that answers instantly when a deck is killed — which is what
112
+ * keeps the takeover immediate rather than TTL-bound. See provesDeck.
113
+ */
114
+ async function readLiveDecks() {
115
+ const dir = join(claudeConfigDir(), "agent-dag");
116
+ let files;
117
+ try { files = await readdir(dir); } catch { return []; }
118
+ const candidates = [];
119
+ for (const f of files) {
120
+ if (!f.endsWith(".json")) continue;
121
+ try {
122
+ const d = JSON.parse(await readFile(join(dir, f), "utf8"));
123
+ // Without both numbers there is nothing to group or tie-break on, so the
124
+ // record cannot take part in an election either way.
125
+ if (!d || typeof d.pid !== "number" || typeof d.port !== "number") continue;
126
+ if (!isProcessAlive(d.pid)) continue;
127
+ candidates.push(d);
128
+ } catch { /* corrupt, or gone between listing and read */ }
129
+ }
130
+ // In parallel: with several decks up this is the difference between one 400ms
131
+ // deadline for the scan and one per record.
132
+ const proven = await Promise.all(candidates.map(provesDeck));
133
+ return candidates.filter((_, i) => proven[i]);
134
+ }
135
+
136
+ // What index.mjs calls. Listed rather than marked at the declaration, so the
137
+ // declaration reads as it did where it came from.
138
+ export { readLiveDecks };