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,242 @@
1
+ // Which sessions the deck still keeps anything for: the least-recently-heard
2
+ // cap every per-session cache is reaped against, the transcript watch that
3
+ // polls the live ones between their hook events, and POST /api/forget, the
4
+ // page telling the deck what left its board.
5
+ //
6
+ // These lived in src/server/index.mjs — the expiry section after the ring's
7
+ // reader, and the forget route after Clear. pushEvent touches a session on
8
+ // every live event and notes its transcript for the watch; a Clear empties
9
+ // the watch; startServer starts it. The caches being reaped are the
10
+ // enrichment's and the Codex watcher's, dropped through their own forget
11
+ // functions. The bodies are unchanged.
12
+ import { readBody, send } from "./http-io.mjs";
13
+ // What a session is producing between its tool calls — the 16.5% of measured
14
+ // time the hooks cannot see. See output-watch.mjs.
15
+ import { createOutputWatch } from "./output-watch.mjs";
16
+ import { forgetEnrichment, onRecapTail } from "./session-enrichment.mjs";
17
+ import { forgetCodexSession } from "./codex-enrichment.mjs";
18
+ // event-pipeline.mjs's pushEvent, reached without importing it — see
19
+ // event-sink.mjs.
20
+ import { pushEvent } from "./event-sink.mjs";
21
+
22
+ // ─── Per-session cache expiry ────────────────────────────────────────────
23
+ // Every enrichment cache (session-enrichment.mjs, codex-enrichment.mjs) is keyed by
24
+ // session id and nothing ever removed an entry: a deck left up for weeks — the 24/7 use this thing is
25
+ // built for — kept a model string, a subagent signature and four read-throttle
26
+ // stamps for every session it had ever seen, plus the Codex rollout path and
27
+ // model of each. SessionEnd is not a usable eviction signal (a killed CLI
28
+ // never sends one, and Codex has no such hook at all), so entries expire by
29
+ // least-recent use against a cap instead — the same shape pruneTranscriptScans
30
+ // already uses for its per-path state.
31
+ //
32
+ // AND A LIVE SESSION IS NEVER EVICTED, which this comment used to assert on the
33
+ // strength of the cap sitting "far above any plausible number of concurrent
34
+ // sessions". The cap held; the claim around it did not, and the 257th
35
+ // concurrent session is where that showed. forgetSession clears the three
36
+ // read-throttle stamps and pruneTranscriptScans drops the byte cursor, so an
37
+ // evicted session that speaks again does not "simply re-read its transcript" —
38
+ // it re-reads it on EVERY event, and those re-reads are themselves pushEvent
39
+ // calls, so the degradation feeds itself. Measured here, fresh deck per row,
40
+ // 16 events per session, 21 KB transcripts:
41
+ //
42
+ // N=200 posted=3200 synthetic=600 rchar=6 MB rps=5614 p50=17ms
43
+ // N=256 posted=4096 synthetic=768 rchar=7 MB rps=6341 p50=16ms
44
+ // N=300 posted=4800 synthetic=9159 rchar=77 MB rps=2308 p50=97ms
45
+ //
46
+ // A 17% increase in session count: 11.9x the derived events, 11x the transcript
47
+ // reads, ingest down 2.7x, p50 up 6x. Three per SESSION became three per EVENT.
48
+ // A cliff, not a slope, sitting exactly on the constant — and the server's cap
49
+ // (256) sits above the client's AGENT_CAP (200), so the UI cannot warn about a
50
+ // number it will not draw.
51
+ //
52
+ // So the cap reaps only what it was always described as reaping: sessions
53
+ // nothing has been heard from. A session with an event inside
54
+ // OUTPUT_WATCH_WINDOW_MS is live by the same definition outputWatchOnce already
55
+ // uses, and keeping it costs four numbers and a string.
56
+ const sessionTouchedAt = new Map(); // sid -> ms of the last event seen
57
+ const MAX_TRACKED_SESSIONS = 256;
58
+
59
+ /**
60
+ * The absolute ceiling, above which even a live session is dropped.
61
+ *
62
+ * The age rule alone is unbounded in principle — nothing stops a machine from
63
+ * having ten thousand sessions inside five minutes — and an unbounded map is
64
+ * the leak this whole mechanism exists to end. This is a backstop, not the
65
+ * working limit: reaching it means something is wrong in a way no cache policy
66
+ * fixes, and dropping the least-recently-seen entries is still the least bad
67
+ * answer. Eight times the cap, which is forty times the client's AGENT_CAP.
68
+ */
69
+ export const HARD_TRACKED_SESSIONS = MAX_TRACKED_SESSIONS * 8;
70
+
71
+ /** The transcript watch, and how far back a session stays worth polling.
72
+ *
73
+ * A session is polled while its last HOOK event is recent — and the window has
74
+ * to be generous for exactly the reason this watch exists: a session that is
75
+ * thinking has, by definition, not fired a hook. Measured on this machine's
76
+ * log, the gaps with no event at all run to p99 53s and 155s at the longest,
77
+ * so five minutes clears the whole measured distribution and still keeps the
78
+ * polled set to the sessions somebody is actually running. */
79
+ const outputWatch = createOutputWatch();
80
+ const OUTPUT_WATCH_WINDOW_MS = 5 * 60_000;
81
+ const OUTPUT_WATCH_MS = 1_500;
82
+ let outputWatchTimer = null;
83
+
84
+ /** How long a quiet session is still worth a `stat` for its recap, and how
85
+ * often it gets one.
86
+ *
87
+ * The five minutes above are sized to a session that is WORKING. A recap is
88
+ * the opposite case: Claude Code writes it once a finished turn has sat three
89
+ * minutes AND the terminal has lost focus, so it lands whenever the person
90
+ * walks away — a median 3.1 minutes after the turn on this machine, and an
91
+ * hour after it for somebody who stayed at the terminal first. A session that
92
+ * quiet is producing nothing, so every fourth tick is plenty: a recap reaches
93
+ * the deck within six seconds of being written, and a resting session costs a
94
+ * `stat` every six seconds rather than every one and a half. */
95
+ const RECAP_WATCH_WINDOW_MS = 12 * 60 * 60_000;
96
+ const RECAP_WATCH_EVERY = 4;
97
+ let outputWatchTicks = 0;
98
+
99
+ /** One tick: stat the recent sessions' transcripts, read only what grew, and
100
+ * say what landed. Everything expensive about this is guarded inside the watch
101
+ * — a session that wrote nothing costs one `stat`.
102
+ *
103
+ * Resting sessions ride along on every RECAP_WATCH_EVERY-th tick for their
104
+ * recap alone, and their blocks are not reported: this answers for the
105
+ * sessions it calls live, and a resting one that starts working again fires a
106
+ * hook first, which makes it live the ordinary way.
107
+ *
108
+ * A tick that starts while the last one is still reading gets nothing back:
109
+ * the watch refuses the overlap itself, where the offset is — see `poll` in
110
+ * output-watch.mjs for what two overlapping ticks used to report (#1089). */
111
+ async function outputWatchOnce() {
112
+ const now = Date.now();
113
+ const cutoff = now - OUTPUT_WATCH_WINDOW_MS;
114
+ const restingCutoff = now - RECAP_WATCH_WINDOW_MS;
115
+ const withResting = outputWatchTicks++ % RECAP_WATCH_EVERY === 0;
116
+ const live = new Set();
117
+ const polled = [];
118
+ for (const [sid, at] of sessionTouchedAt) {
119
+ if (at >= cutoff) { live.add(sid); polled.push(sid); }
120
+ else if (withResting && at >= restingCutoff) polled.push(sid);
121
+ }
122
+ if (!polled.length) return;
123
+ const found = await outputWatch.poll(polled, onRecapTail);
124
+ for (const f of found) {
125
+ if (!live.has(f.sid)) continue;
126
+ pushEvent({
127
+ hook_event_name: "OutputObserved",
128
+ session_id: f.sid,
129
+ kind: f.kind,
130
+ at: f.at,
131
+ }, "internal");
132
+ }
133
+ }
134
+
135
+ function startOutputWatch() {
136
+ if (outputWatchTimer) return outputWatchTimer;
137
+ outputWatchTimer = setInterval(() => { outputWatchOnce().catch(() => {}); }, OUTPUT_WATCH_MS);
138
+ // Unref'd like the Codex watcher's: a poll must never be the reason the
139
+ // process stays up.
140
+ if (outputWatchTimer.unref) outputWatchTimer.unref();
141
+ return outputWatchTimer;
142
+ }
143
+
144
+ function forgetSession(sid) {
145
+ outputWatch.forget(sid);
146
+ forgetEnrichment(sid);
147
+ forgetCodexSession(sid);
148
+ }
149
+
150
+ function touchSession(sid) {
151
+ if (!sid || typeof sid !== "string") return;
152
+ // Re-insert so the Map's own insertion order *is* the LRU order and eviction
153
+ // below is one key read rather than a scan of every session ever seen.
154
+ sessionTouchedAt.delete(sid);
155
+ const now = Date.now();
156
+ sessionTouchedAt.set(sid, now);
157
+ if (sessionTouchedAt.size <= MAX_TRACKED_SESSIONS) return;
158
+ // Insertion order IS recency, so the first entry still inside the window
159
+ // proves every entry after it is too, and the loop stops there rather than
160
+ // scanning. That is what keeps this O(evicted) and not O(sessions) on the hot
161
+ // path — which matters most in exactly the case that used to be worst.
162
+ const idleBefore = now - OUTPUT_WATCH_WINDOW_MS;
163
+ for (const [oldest, at] of sessionTouchedAt) {
164
+ if (sessionTouchedAt.size <= MAX_TRACKED_SESSIONS) break;
165
+ if (at >= idleBefore && sessionTouchedAt.size <= HARD_TRACKED_SESSIONS) break;
166
+ sessionTouchedAt.delete(oldest);
167
+ forgetSession(oldest);
168
+ }
169
+ }
170
+
171
+ /** The most session ids one press of this may name. A board holding more than
172
+ * the server tracks at all cannot be describing anything the server still
173
+ * remembers, and a body is not a reason to walk an unbounded list. */
174
+ const MAX_FORGET_IDS = MAX_TRACKED_SESSIONS;
175
+
176
+ /**
177
+ * POST /api/forget — the client's own pruners, saying what left the board.
178
+ *
179
+ * THE THIRD PLACE THE RULE HAD TO REACH (#1024). `handleClear`'s comment states
180
+ * it: "anything answering 'has this changed' has to appear in BOTH places that
181
+ * mean the client no longer has it — here (clearEnrichmentGates), and in
182
+ * forgetSession (forgetEnrichment)." There was a
183
+ * third, and the server never heard about it — `pruneDoneSessions` and
184
+ * `pruneOldAgents` in the page, which run every 250ms at cap 6 / grace 2
185
+ * minutes, and #445's own measurement says 7 of 20 evicted sessions went on to
186
+ * emit more events.
187
+ *
188
+ * Usage, context and the ROOT model all come back on their own: the first two
189
+ * are not change-gated, and `pushEvent` stamps `raw.model` on every payload.
190
+ * `sessionName`/`sessionTitle` and the per-subagent models do not, because
191
+ * `nameBySession` and `modelBySession` still hold the signature that gates the
192
+ * emit. Measured against a real deck over a real transcript, sandboxed HOME:
193
+ *
194
+ * SessionNamed while the session was on the board: ["reducer-audit"]
195
+ * --- the client prunes S5, and tells nobody ---
196
+ * events for S5 after the resume: UserPromptSubmit, UsageObserved,
197
+ * UserPromptSubmit, UsageObserved, ContextObserved, …
198
+ * SessionNamed among them: false
199
+ *
200
+ * A session evicted while idle and then resumed showed as unnamed in the sidebar
201
+ * and on the card for the rest of the day, with no way to recover but reloading
202
+ * the tab.
203
+ *
204
+ * `forgetSession` is the whole of the answer and it already exists — this route
205
+ * is the client's half of a call the server has been making to itself since the
206
+ * LRU cap was added. It drops the read stamps along with the signatures, for the
207
+ * reason `handleClear` lists them: clearing only the signatures would leave the
208
+ * next hook event inside MODEL_READ_THROTTLE_MS, so nothing would be re-read and
209
+ * the name would still be missing.
210
+ *
211
+ * NOT DROPPING THE CHANGE GATE INSTEAD, which was the other option in the
212
+ * report. The gate is what keeps a per-pass emit from becoming ~683 events
213
+ * saying nothing out of 685 records, and the reducer absorbing repeats correctly
214
+ * is not a reason to send them.
215
+ *
216
+ * Costs nothing when it is wrong. A session named here that the deck is still
217
+ * hearing from re-reads its transcript once and re-emits what it finds, which
218
+ * the reducer's assign-don't-append handlers apply idempotently.
219
+ */
220
+ async function handleForget(req, res) {
221
+ const raw = await readBody(req).catch(() => null);
222
+ let body = null;
223
+ try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
224
+ const ids = Array.isArray(body?.ids) ? body.ids : null;
225
+ if (!ids) return send(res, 400, { ok: false, reason: "bad_request" });
226
+ let forgotten = 0;
227
+ for (const sid of ids.slice(0, MAX_FORGET_IDS)) {
228
+ if (typeof sid !== "string" || sid === "") continue;
229
+ // Out of the LRU as well, so the cap is not spent on a session nothing is
230
+ // tracking any more. A session that speaks again is re-inserted by
231
+ // `touchSession` as what it now is: one the deck has just heard from.
232
+ sessionTouchedAt.delete(sid);
233
+ forgetSession(sid);
234
+ forgotten++;
235
+ }
236
+ return send(res, 200, { ok: true, forgotten });
237
+ }
238
+
239
+ // What index.mjs calls besides HARD_TRACKED_SESSIONS. Listed rather than
240
+ // marked at each declaration, so the declarations read as they did where they
241
+ // came from.
242
+ export { handleForget, outputWatch, startOutputWatch, touchSession };
@@ -0,0 +1,222 @@
1
+ // The deck's SSE subscribers, and the one discipline every frame to them is
2
+ // written under: a client too far behind is hung up on rather than buffered
3
+ // for, and a resuming client is waited for rather than dropped.
4
+ //
5
+ // These lived in src/server/index.mjs — the two client sets beside the ring,
6
+ // the backpressure section after the session expiry. The sets are shared, not
7
+ // copied: pushEvent broadcasts over them, the desktop-update routes and the
8
+ // notifier reach the app's tray connections through them, and resumeSse adds a
9
+ // client once its replay is done. The bodies are unchanged.
10
+
11
+ const sseClients = new Set(); // res handles
12
+ // The desktop app's own connections (#1160): subscribed like any client, so
13
+ // they receive every event, and ALSO kept here so they are never counted as a
14
+ // page. The app reads the stream to draw its tray icon; if that counted as a
15
+ // tab, the deck would believe a page was open whenever the app ran, and the
16
+ // notifications a closed deck raises (block-notify.mjs) would never fire —
17
+ // the one thing the app exists to deliver. See `pageCount`.
18
+ const trayClients = new Set();
19
+
20
+ /** Pages — deck tabs and the app's window — as opposed to the app's tray
21
+ * connection, which is a reader and not somebody looking. */
22
+ function pageCount() {
23
+ return sseClients.size - trayClients.size;
24
+ }
25
+
26
+ /** A notification for the desktop app to raise as itself, instead of the OS
27
+ * helper raising it as Script Editor (osascript) or PowerShell. */
28
+ function notifyTrays(title, body, { chime = null, who = null } = {}) {
29
+ // The app names itself above the notification, so the title is the session.
30
+ const line = `event: notify\ndata: ${JSON.stringify({ title: who ?? title, body, chime })}\n\n`;
31
+ for (const res of trayClients) writeSse(res, line);
32
+ return true;
33
+ }
34
+
35
+ // ─── SSE backpressure ────────────────────────────────────────────────────
36
+ // A client that stops reading without closing its socket — a frozen tab, a
37
+ // suspended machine, a stalled `ssh -L` tunnel — never fires 'close' and never
38
+ // makes write() throw, so the old `try { res.write(line) } catch {}` had no
39
+ // way to notice it. On loopback nothing times the connection out either, so
40
+ // every event (tool responses run to megabytes) queued in that socket's write
41
+ // buffer for as long as the process lived.
42
+ //
43
+ // We drop the client rather than the events. EventSource reconnects after the
44
+ // `retry: 1500` we send on connect and resumes from Last-Event-ID, so the ring
45
+ // buffer replays whatever it missed. Dropping individual events instead would
46
+ // leave a hole the resume path cannot even see, the client's last id having
47
+ // moved past it.
48
+ //
49
+ // The ceiling has to clear the largest SINGLE frame the deck can emit, because
50
+ // one write() of such a frame puts the whole of it in the queue with nothing
51
+ // having had the chance to drain any of it — a client reading at full speed
52
+ // looks, for that instant, exactly like a frozen tab. #588 was exactly that
53
+ // failure: queuedBytes below doubled every reading, so the real ceiling was
54
+ // 4 MiB and one 4 MiB tool response hung up on every subscribed tab at once.
55
+ //
56
+ // So the number is checked against what `POST /api/event` admits rather than
57
+ // left to feel. handleEventIngest caps a body at 5,000,000 CHARACTERS. The
58
+ // event is re-serialized before it goes out, and re-serializing a value that
59
+ // came from JSON.parse of an N-character document cannot exceed N characters —
60
+ // every escape the output needs was already paid for in the input, and \uXXXX
61
+ // input comes back shorter — so one frame is at most 5,000,000 characters, plus
62
+ // this deck's envelope, measured at 127, plus the id/event/data framing. 8 MiB
63
+ // clears that by a little over 1.6x, and is the number this constant has always
64
+ // named; what #588 changed is that it now means it.
65
+ //
66
+ // Characters, not bytes, which is the one misleading thing left in the name.
67
+ // writeSse and writeResume write STRINGS, and a Writable with decodeStrings
68
+ // false — which both an OutgoingMessage and the net.Socket under it are — adds
69
+ // `chunk.length`, i.e. UTF-16 units, to its queue. Measured on Node 22.14: a
70
+ // 4,800,000-character Read of CJK text is 14,400,184 bytes on the wire and
71
+ // `res.writableLength` reports 4,800,311. That is why comparing this against a
72
+ // character-denominated ingest limit is the right comparison and comparing it
73
+ // against a byte count would not be — and it is worth stating plainly, because
74
+ // assuming a unit for writableLength instead of measuring it is the whole
75
+ // shape of the bug this comment exists to explain. The memory behind a full
76
+ // buffer is larger than the number says, up to two bytes per unit while it is
77
+ // held as a string; that is not what the cap is for, which is noticing a client
78
+ // that has stopped reading at all.
79
+ //
80
+ // Exported, with queuedBytes, so the arithmetic can be asserted directly. #588
81
+ // survived because it could only be observed through a live socket, where the
82
+ // existing tests' tolerances were wider than the error.
83
+ export const MAX_CLIENT_BUFFER_BYTES = 8 * 1024 * 1024;
84
+
85
+ /**
86
+ * What this response has accepted and not yet handed to the kernel, in the
87
+ * units writableLength reports it in — see MAX_CLIENT_BUFFER_BYTES above, which
88
+ * is the number this is compared against.
89
+ *
90
+ * NOT the sum of the two writableLengths, which is what #588 was: Node's
91
+ * `OutgoingMessage.writableLength` getter is `outputSize + this[kChunkedLength]
92
+ * + (socket ? socket.writableLength : 0)`, so the socket's queue is already
93
+ * inside it. For an SSE response, whose outputSize is zero from the moment the
94
+ * headers flush, the two readings are the same number exactly — measured on
95
+ * Node 22.14 against a paused reader, `res.writableLength=4194615
96
+ * socket.writableLength=4194615` — so adding them reported exactly twice the
97
+ * real backlog and made an 8 MiB constant behave as a 4 MiB one.
98
+ *
99
+ * Why max and not simply `res.writableLength`, which is today's whole answer.
100
+ * That composition is a Node implementation detail and it has moved before, so
101
+ * the expression is chosen to survive it moving again. Read the two as an
102
+ * overlapping pair and take the larger:
103
+ * - composed as it is today, `own` already contains `sock`, so `own >= sock`
104
+ * and max is `own` — the exact total;
105
+ * - were the getter to stop including the socket term, `own` for a flushed
106
+ * SSE response is zero and max is `sock` — again the exact total;
107
+ * - with the socket detached (`res.socket` null, which happens between the
108
+ * response ending and the handle being released) max is `own`, the only
109
+ * reading there is.
110
+ * Every case is right, and the failure mode if some future composition makes
111
+ * both terms non-zero and disjoint is under-counting by at most 2x — a client
112
+ * held a little longer than intended, which is the harmless direction. Summing
113
+ * fails the other way, and dropping readers that are not behind is the bug.
114
+ */
115
+ export function queuedBytes(res) {
116
+ const own = typeof res.writableLength === "number" ? res.writableLength : 0;
117
+ const sock = res.socket && typeof res.socket.writableLength === "number" ? res.socket.writableLength : 0;
118
+ return Math.max(own, sock);
119
+ }
120
+
121
+ /** Hang up on a client we have decided not to keep. `delete` on a response
122
+ * that never made it into the set — the resume path (resumeSse, in
123
+ * event-routes.mjs) hangs up on clients before they are subscribed — is a
124
+ * harmless no-op. */
125
+ function dropSse(res) {
126
+ sseClients.delete(res);
127
+ trayClients.delete(res);
128
+ // Destroying the socket is what makes the request emit 'close', which is
129
+ // where the ping interval is cleared.
130
+ try { res.destroy(); } catch {}
131
+ try { res.socket?.destroy(); } catch {}
132
+ }
133
+
134
+ /** Write one SSE frame, hanging up on a client too far behind to keep. */
135
+ function writeSse(res, frame) {
136
+ try {
137
+ res.write(frame);
138
+ if (queuedBytes(res) <= MAX_CLIENT_BUFFER_BYTES) return;
139
+ } catch { /* already dead — drop it below */ }
140
+ dropSse(res);
141
+ }
142
+
143
+ // How long a resuming client is given to accept the bytes already queued for
144
+ // it before the deck concludes it is not reading at all. Generous on purpose:
145
+ // what it has to work through is a full MAX_CLIENT_BUFFER_BYTES, the link may
146
+ // be an `ssh -L` tunnel rather than loopback, and dropping a client that is
147
+ // merely slow costs it the whole replay. A tab that is genuinely frozen will
148
+ // not accept a byte in any budget, so the only thing a long one buys it is a
149
+ // few more seconds of holding its own buffer. The environment override exists
150
+ // so the tests can pin the drop without sitting through the real budget.
151
+ //
152
+ // It is a budget per awaited frame, and what that frame waits on is everything
153
+ // queued ahead of it draining — a full MAX_CLIENT_BUFFER_BYTES, by
154
+ // construction, since that is what the loop fills to before it stops. So this
155
+ // states a minimum rate a resuming client has to manage, and #588 doubled that
156
+ // flush in practice without touching this line: the cap it is sized against was
157
+ // really 4 MiB and is now the 8 MiB it always said. Still generous — measured
158
+ // on loopback a full cap flushes in about a quarter of a second.
159
+ const REPLAY_DRAIN_MS = Number(process.env.AGENTS_DECK_REPLAY_DRAIN_MS) > 0
160
+ ? Number(process.env.AGENTS_DECK_REPLAY_DRAIN_MS)
161
+ : 30_000;
162
+
163
+ /**
164
+ * Write one frame of the resume stream under the same ceiling the live path
165
+ * obeys, waiting rather than dropping when the client is at it. Resolves true
166
+ * while the client is worth keeping, false once it is not.
167
+ *
168
+ * Waiting is the whole difference from writeSse, and the replay is why. The
169
+ * live path writes one frame per event, so a full buffer there means the
170
+ * client stopped reading and the only answer is to hang up. Here the burst is
171
+ * ours: resumeSse's loop hands the socket the entire ring buffer in one turn of
172
+ * the event loop, so even a client reading at full speed sees its buffer fill
173
+ * — nothing has drained it yet, because nothing could. Dropping on that would
174
+ * hang up on healthy clients, and hang up on them again every time they came
175
+ * back: EventSource reconnects with the same Last-Event-ID, meets the same
176
+ * oversized replay, and is dropped again 1.5 seconds later, forever. So we
177
+ * stop writing until the socket has taken what it already has, and only give
178
+ * up on a client that takes nothing at all for REPLAY_DRAIN_MS.
179
+ *
180
+ * The wait is on write()'s completion callback rather than on a 'drain' event:
181
+ * 'drain' fires only after a write that was answered false, so waiting on it
182
+ * means depending on an answer this call may never have seen. The cap sits far
183
+ * above the stream's own 16 KiB high-water mark, so by the time queuedBytes is
184
+ * at the cap the `false` that a 'drain' would eventually answer belongs to some
185
+ * frame long since written, and its drain may already have come and gone. The
186
+ * callback fires once this chunk —
187
+ * and therefore everything queued ahead of it — has reached the OS, which is
188
+ * exactly the condition being waited for. It also fires, with an error we do
189
+ * not need to read, if the response is destroyed underneath us, so this cannot
190
+ * hang on a client that goes away.
191
+ */
192
+ async function writeResume(res, frame) {
193
+ // Below the cap this is the plain write it has always been. The `await` in
194
+ // the caller costs a microtask and nothing else: the checkpoint drains
195
+ // before the loop can accept I/O, so no live event can slip between two
196
+ // replay frames the way it could across a real wait.
197
+ if (queuedBytes(res) <= MAX_CLIENT_BUFFER_BYTES) {
198
+ try { res.write(frame); return true; } catch { return false; }
199
+ }
200
+ return new Promise(resolve => {
201
+ let settled = false;
202
+ const done = ok => {
203
+ if (settled) return;
204
+ settled = true;
205
+ clearTimeout(timer);
206
+ resolve(ok);
207
+ };
208
+ // Unref'd so a client that stopped reading can never be the reason the
209
+ // process stays alive, and cleared on every path out so a completed replay
210
+ // leaves no timer behind.
211
+ const timer = setTimeout(() => done(false), REPLAY_DRAIN_MS);
212
+ timer.unref?.();
213
+ try { res.write(frame, () => done(!res.destroyed)); }
214
+ catch { done(false); }
215
+ });
216
+ }
217
+
218
+ // What the server's other modules call besides the two exported above —
219
+ // pushEvent's fan-out, the event routes, health and the desktop-update routes.
220
+ // Listed rather than marked at each declaration, so the declarations read as
221
+ // they did where they came from.
222
+ export { dropSse, notifyTrays, pageCount, sseClients, trayClients, writeResume, writeSse };
@@ -0,0 +1,30 @@
1
+ // The discovery records of decks that are no longer running, removed as a
2
+ // deck starts.
3
+ //
4
+ // This lived in src/server/index.mjs, after the hook challenge; startServer
5
+ // calls it once, before it reads the log. Whether a record's process is alive
6
+ // is deck-probe.mjs's question. The body is unchanged.
7
+ import { readFile, readdir, unlink } from "node:fs/promises";
8
+ import { join } from "node:path";
9
+ import { claudeConfigDir } from "./claude-dir.mjs";
10
+ import { isProcessAlive } from "./deck-probe.mjs";
11
+
12
+ export async function sweepStaleDiscovery() {
13
+ // Same directory the installer writes and the hooks read — see claude-dir.mjs.
14
+ const dir = join(claudeConfigDir(), "agent-dag");
15
+ let files;
16
+ try { files = await readdir(dir); } catch { return 0; }
17
+ let removed = 0;
18
+ for (const f of files) {
19
+ if (!f.endsWith(".json")) continue;
20
+ const p = join(dir, f);
21
+ try {
22
+ const d = JSON.parse(await readFile(p, "utf8"));
23
+ if (d && typeof d.pid === "number" && !isProcessAlive(d.pid)) {
24
+ await unlink(p).catch(() => {});
25
+ removed++;
26
+ }
27
+ } catch { /* corrupt — leave it */ }
28
+ }
29
+ return removed;
30
+ }
@@ -0,0 +1,81 @@
1
+ // The deck's own page: dist/web served off disk, compressed and cached by name
2
+ // (#883), with index.html standing in for any path a client-side route owns.
3
+ //
4
+ // This lived in src/server/index.mjs — the type table near the top, the
5
+ // handler after the event stream's writers. It reads nothing of the server's,
6
+ // so it moved whole; the route table still decides what reaches it. The
7
+ // handler is unchanged.
8
+ import { readFile, stat } from "node:fs/promises";
9
+ import { dirname, extname, join, resolve } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { send } from "./http-io.mjs";
12
+ import { cacheControlFor, encodedBody, pickEncoding } from "./static-cache.mjs";
13
+
14
+ // Resolved the way pinned-build.mjs resolves its package root, from a file in
15
+ // the same directory.
16
+ const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
17
+ const WEB_DIST = resolve(PKG_ROOT, "dist", "web");
18
+
19
+ const MIME = {
20
+ ".html": "text/html; charset=utf-8",
21
+ ".js": "application/javascript; charset=utf-8",
22
+ ".mjs": "application/javascript; charset=utf-8",
23
+ ".css": "text/css; charset=utf-8",
24
+ ".json": "application/json; charset=utf-8",
25
+ ".svg": "image/svg+xml",
26
+ // The type is the whole of how a browser recognises a manifest: served as
27
+ // application/octet-stream — which is what the fallback below hands anything
28
+ // unlisted — Chrome fetches it, declines to parse it, and offers no install,
29
+ // with nothing in the console that names the reason.
30
+ ".webmanifest": "application/manifest+json; charset=utf-8",
31
+ ".png": "image/png",
32
+ ".jpg": "image/jpeg",
33
+ ".woff2": "font/woff2",
34
+ ".map": "application/json",
35
+ };
36
+
37
+ async function serveStatic(req, res, url) {
38
+ // Strip leading slash, default to index.html
39
+ let rel = url.pathname.replace(/^\/+/, "");
40
+ if (rel === "" || rel.endsWith("/")) rel = `${rel}index.html`;
41
+ const filePath = join(WEB_DIST, rel);
42
+ if (!filePath.startsWith(WEB_DIST)) return send(res, 403, { error: "forbidden" });
43
+
44
+ try {
45
+ const s = await stat(filePath);
46
+ if (s.isDirectory()) return send(res, 404, { error: "not found" });
47
+ const buf = await readFile(filePath);
48
+ const ext = extname(filePath).toLowerCase();
49
+ // Compressed when the browser takes it, and cached for good when the name
50
+ // is a content hash (#883) — see static-cache.mjs. `rel` is the URL's own
51
+ // spelling, forward slashes on every platform, which is what the hash
52
+ // pattern reads; `filePath` is only the cache key.
53
+ const encoding = pickEncoding(req.headers["accept-encoding"], ext);
54
+ const body = encoding ? encodedBody(filePath, s.mtimeMs, buf, encoding) : buf;
55
+ res.writeHead(200, {
56
+ "Content-Type": MIME[ext] ?? "application/octet-stream",
57
+ "Cache-Control": cacheControlFor(rel),
58
+ "Vary": "Accept-Encoding",
59
+ "Content-Length": body.length,
60
+ ...(encoding ? { "Content-Encoding": encoding } : {}),
61
+ });
62
+ res.end(body);
63
+ } catch {
64
+ // SPA fallback to index.html for client-side routes
65
+ try {
66
+ const idx = await readFile(join(WEB_DIST, "index.html"));
67
+ // Same no-cache as the normal path above, which this used to omit. It
68
+ // matters more since the deck upgrades itself: index.html is the file
69
+ // naming the hashed bundle, so a heuristically-cached copy sends the tab
70
+ // back to the OLD assets after an update and the reload achieves nothing.
71
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache" });
72
+ res.end(idx);
73
+ } catch {
74
+ send(res, 404, { error: "ui not built. run `pnpm build` or `npm run build`." });
75
+ }
76
+ }
77
+ }
78
+
79
+ // What the route table calls. Listed rather than marked at the declaration, so
80
+ // the declaration reads as it did where it came from.
81
+ export { serveStatic };