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,352 @@
1
+ // The three routes the event pipeline is reached through: POST /api/event,
2
+ // which feeds it, and the two that read the ring back out — GET /events, the
3
+ // SSE stream with its Last-Event-ID resume, and GET /api/events, the JSON
4
+ // array — with the one serialization of an envelope both readers share.
5
+ //
6
+ // These lived in src/server/index.mjs, after pushEvent. The route table calls
7
+ // the handlers and writeJsonArray; the ring is read through event-ring.mjs's
8
+ // operations, and the subscribers' sets and backpressure are sse-clients.mjs's.
9
+ // The bodies are unchanged; isTrayRequest now sits above resumeSse's docblock
10
+ // instead of between it and its function.
11
+ import { PRODUCT } from "./brand.mjs";
12
+ // How every route reads a body and answers, and the answer for one that threw —
13
+ // see http-io.mjs.
14
+ import { OVERSIZE_DRAIN_MS, send, sendInternalError } from "./http-io.mjs";
15
+ import { presentsDeckToken } from "./request-gates.mjs";
16
+ // The ring the readers walk — see event-ring.mjs.
17
+ import { SEQ_EPOCH, ringHoldsNewerThan, ringSnapshot } from "./event-ring.mjs";
18
+ // The one door every event comes through — see event-pipeline.mjs.
19
+ import { pushEvent } from "./event-pipeline.mjs";
20
+ import { noteLogWriter } from "./event-log.mjs";
21
+ // The SSE subscribers and the backpressure every frame to them is written
22
+ // under — see sse-clients.mjs.
23
+ import { dropSse, sseClients, trayClients, writeResume, writeSse } from "./sse-clients.mjs";
24
+
25
+ /**
26
+ * Serialize one envelope, or a stub standing in for it.
27
+ *
28
+ * `JSON.stringify` throws on more than size. V8 walks a value recursively, so a
29
+ * payload nested about five thousand deep overflows the C++ stack and comes
30
+ * back as `RangeError: Maximum call stack size exceeded` — and a 36 KB POST to
31
+ * the open ingest route is enough to put one of those in the ring, measured on
32
+ * Node 22.14. Letting that throw escape would truncate the array mid-write and
33
+ * leave every later read of that ring answering with invalid JSON for as long
34
+ * as the event survives, which is a 36 KB way to poison a route permanently.
35
+ *
36
+ * So the envelope is replaced rather than dropped, and it keeps its `seq`: a
37
+ * caller paging with `?since=` still walks past it, instead of asking again for
38
+ * a hole it can never be given — the same bargain resumeSse makes about the
39
+ * events it cannot deliver. The reason is written to stderr and not to the
40
+ * wire, under the rule sendInternalError explains: this body is readable by a
41
+ * DNS-rebound page and error detail is not.
42
+ */
43
+ function envelopeJson(evt) {
44
+ try {
45
+ // `JSON.stringify(undefined)` is undefined, not a string, and inside an
46
+ // array literal that would be the text "undefined" — which is not JSON.
47
+ // `JSON.stringify([undefined])` says "null"; so does this.
48
+ return JSON.stringify(evt) ?? "null";
49
+ } catch (err) {
50
+ console.error(`${PRODUCT}: event ${evt?.seq} could not be serialized:`, err);
51
+ // Every field here is read defensively and typed to a primitive, because
52
+ // this is the path that must not throw twice: the status line has gone out
53
+ // and a second failure would leave the caller a truncated array.
54
+ return JSON.stringify({
55
+ seq: Number(evt?.seq) || 0,
56
+ epoch: typeof evt?.epoch === "string" ? evt.epoch : SEQ_EPOCH,
57
+ receivedAt: Number(evt?.receivedAt) || 0,
58
+ source: typeof evt?.source === "string" ? evt.source : "unknown",
59
+ payload: null,
60
+ unserializable: true,
61
+ });
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Answer with a JSON array without ever holding it as one string.
67
+ *
68
+ * `send` finishes a response by handing the whole body to a single
69
+ * `JSON.stringify`, which for every other route is a few hundred bytes and for
70
+ * `GET /api/events` is the entire ring buffer. V8 will not build a string
71
+ * longer than `2^29 - 24` characters, so past 536,870,888 characters of
72
+ * serialised envelopes that call throws `RangeError: Invalid string length`
73
+ * straight out of the request listener — and there, as requestUrl and `guard`
74
+ * both say in their own words, nothing catches it and the worker exits.
75
+ * Measured on Node 22.14 / macOS: 112 posts of 4,900,061 characters, which
76
+ * `POST /api/event` accepts from anyone with no credential at all, then one
77
+ * plain unauthenticated GET, and the deck was gone — SSE stream, hook ingest
78
+ * and event log with it. 112 events is a twentieth of MAX_BUFFER, so this is
79
+ * not an exotic ring: a deck watching sessions whose Read and Bash responses
80
+ * are "routinely a good fraction of" the five-million-character ingest cap
81
+ * reaches it on its own at an average of 268 KB an event.
82
+ *
83
+ * Writing the array element by element removes the ceiling rather than raising
84
+ * it. One envelope's worth of string exists at a time, so the limit applies per
85
+ * envelope — and ingest caps an envelope at a hundredth of it — and the peak
86
+ * cost is the ring plus one event instead of the ring plus a contiguous copy of
87
+ * itself, which is the quieter half of the same bug: a 400 MB ring used to need
88
+ * 800 MB and a synchronous stall on a route that feels free on a quiet deck.
89
+ * This is the shape resumeSse already uses for the SSE replay, for the same
90
+ * reason, and it borrows the same writeResume, so a caller that stops reading
91
+ * is held at MAX_CLIENT_BUFFER_BYTES here too rather than having the whole ring
92
+ * queued in userland on its behalf.
93
+ *
94
+ * `items` must be a snapshot the caller owns — eventsSince returns one, `filter`
95
+ * always allocating — because each await lets pushEvent splice the head off the
96
+ * live ring, and iterating that while it is spliced skips entries.
97
+ *
98
+ * No Content-Length: it is not knowable without building the string this exists
99
+ * to avoid, so the answer is chunked. HTTP/1.1 requires nothing more and every
100
+ * consumer reads to EOF.
101
+ *
102
+ * Exported so the one property that matters can be asserted directly, on a stub
103
+ * rather than through a socket — the same reason queuedBytes is. "Never builds
104
+ * the whole array as one string" is invisible from outside a real response,
105
+ * which is how it went unnoticed here for as long as it did.
106
+ */
107
+ export async function writeJsonArray(res, items) {
108
+ res.writeHead(200, {
109
+ "Content-Type": "application/json; charset=utf-8",
110
+ "Cache-Control": "no-store",
111
+ });
112
+ let frame = "[";
113
+ for (const item of items) {
114
+ if (res.destroyed) return;
115
+ if (!await writeResume(res, frame + envelopeJson(item))) {
116
+ // Stalled past REPLAY_DRAIN_MS with the cap full. Nothing useful can be
117
+ // said in-band — the status line went out long ago and the array is half
118
+ // written — so hang up, exactly as the replay does.
119
+ try { res.destroy(); } catch {}
120
+ return;
121
+ }
122
+ frame = ",";
123
+ }
124
+ if (res.destroyed) return;
125
+ res.end(frame === "[" ? "[]" : "]");
126
+ }
127
+
128
+ // `persist` is false when the hook posted this event to another deck as well
129
+ // and elected that one to write it to the log they share. The event is still
130
+ // buffered and broadcast here — every matching deck draws it — it is only the
131
+ // second copy on disk that is dropped.
132
+ export function handleEventIngest(req, res, persist = true) {
133
+ let body = "";
134
+ // Set once the cap is hit, because everything after that point is about an
135
+ // exchange that is already over: more `data` may still be in flight, and
136
+ // `end` must not go on to parse the truncated half.
137
+ let refused = false;
138
+ req.setEncoding("utf8");
139
+ req.on("data", c => {
140
+ if (refused) return;
141
+ body += c;
142
+ if (body.length > 5_000_000) {
143
+ refused = true;
144
+ body = ""; // nothing will read it now; let it go
145
+ // ANSWER, rather than vanish. `req.destroy()` on its own tore the socket
146
+ // down with no status line on it at all, so the poster learned only that
147
+ // the connection had gone — indistinguishable from a deck that died or
148
+ // was never there, and nothing in the exchange to tell those apart. `end`
149
+ // never fires on a destroyed request either, so the handler below got no
150
+ // second chance to speak. readBody was believed to get this right
151
+ // by rejecting into a caller that replies; it destroyed the socket first,
152
+ // so all eleven of its routes answered an oversized body with the same
153
+ // bare reset. It answers and drains now, in this shape.
154
+ send(res, 413, { error: "event too large" });
155
+ // Then keep reading, and throw it away. Answering is not enough on its
156
+ // own, because the poster is still mid-upload when the answer goes out:
157
+ // hang up now and its next write lands on a dead socket, it aborts with
158
+ // EPIPE, and the 413 that was already sitting in its receive buffer is
159
+ // discarded unread — the same disappearance in a different costume.
160
+ // `Connection: close` is the tempting version of hanging up and has
161
+ // exactly that effect: Node destroys the socket the moment such a
162
+ // response flushes. Draining lets the poster finish and then read the
163
+ // answer, which is the whole point of answering. `body` no longer grows,
164
+ // so it costs no memory.
165
+ req.resume();
166
+ // Bounded, because draining forever is its own denial of service: a
167
+ // poster that stops without ending would otherwise hold the socket for as
168
+ // long as it liked.
169
+ const grace = setTimeout(() => req.destroy(), OVERSIZE_DRAIN_MS);
170
+ grace.unref?.();
171
+ req.on("close", () => clearTimeout(grace));
172
+ }
173
+ });
174
+ req.on("end", () => {
175
+ if (refused) return;
176
+ let parsed;
177
+ try { parsed = JSON.parse(body); }
178
+ catch { return send(res, 400, { error: "invalid json" }); }
179
+ // Everything past the parse is inside one net, because this listener is the
180
+ // one place in the route table `guard` cannot reach. The route does wrap the
181
+ // call — `guard(handleEventIngest(req, res, …), res)` — but this function
182
+ // returns undefined and hands its work to a listener the event loop calls
183
+ // later, so `guard` has nothing to attach to and a synchronous throw in here
184
+ // is an uncaughtException: the whole deck, for one POST. That is exactly
185
+ // what the serialization inside pushEvent was until it was contained at the
186
+ // line itself, and this catch is what stops the next thing added below from
187
+ // costing a process the same way. sendInternalError puts the reason on
188
+ // stderr and a bare 500 on the wire, and does nothing but end the response
189
+ // if the status line has already gone out.
190
+ try {
191
+ noteLogWriter(parsed, persist);
192
+ const evt = pushEvent(parsed, "hook", { persist });
193
+ send(res, 200, { ok: true, seq: evt.seq });
194
+ } catch (err) {
195
+ sendInternalError(res, err);
196
+ }
197
+ });
198
+ // Guarded for the same reason `end` is, and more sharply: destroying the
199
+ // request above is itself what raises this, and answering a second time on a
200
+ // response already sent throws ERR_HTTP_HEADERS_SENT out of an error handler,
201
+ // where nothing is waiting to catch it. `refused` covers the 413 that
202
+ // destroyed the request; `headersSent` covers the other half of that
203
+ // sentence, an error arriving after `end` has already answered. That half
204
+ // resisted every attempt to drive it from a socket — once `end` has fired the
205
+ // message is complete and the failure goes to the socket rather than to the
206
+ // request — so it carries no test, and is guarded anyway on the strength of
207
+ // the hazard the sentence above already names: one condition against an
208
+ // uncaughtException, if it turns out to be reachable at all.
209
+ req.on("error", () => { if (!refused && !res.headersSent) send(res, 400, { error: "bad request" }); });
210
+ }
211
+
212
+ export function handleSse(req, res) {
213
+ res.writeHead(200, {
214
+ "Content-Type": "text/event-stream",
215
+ "Cache-Control": "no-cache, no-transform",
216
+ "Connection": "keep-alive",
217
+ "X-Accel-Buffering": "no",
218
+ });
219
+ res.write(`retry: 1500\n\n`);
220
+
221
+ // A stale or absent id replays the whole ring, and so does a malformed one:
222
+ // Number("nonsense") is NaN, every `seq <= NaN` is false, and the catch-up
223
+ // loop below would rather compare against a number.
224
+ //
225
+ // An id OLDER than the ring's oldest event is a stale id and takes exactly
226
+ // that path — nothing special-cases it, and #625 deliberately did not add a
227
+ // second answer when it gave the ring a byte budget. Every `e.seq <=
228
+ // sentThrough` test simply fails, so the client is handed everything still
229
+ // held, contiguously, and the sentinel behind it; the events that were
230
+ // evicted are missing from its HISTORY, never from its stream. The reducer's
231
+ // guard is `env.seq <= state.lastSeq`, so the gap costs it a step forward and
232
+ // nothing else. What the byte budget changed is how often and how far the
233
+ // head moves, not what happens to a client that lands behind it.
234
+ const asked = Number(req.headers["last-event-id"] ?? 0);
235
+ const lastId = Number.isFinite(asked) ? asked : 0;
236
+
237
+ // The replay waits on the socket now, so it can no longer be part of this
238
+ // synchronous handler. Nothing is waiting on the result here — the response
239
+ // is already committed to a 200 and its own failure path is to hang up — so
240
+ // start it, keep the router's contract of returning nothing, and make sure a
241
+ // rejection ends the stream rather than the process.
242
+ resumeSse(req, res, lastId, { tray: isTrayRequest(req) }).catch(() => dropSse(res));
243
+ }
244
+
245
+ /**
246
+ * Is this the desktop app's tray connection? Only with the deck's own token:
247
+ * a page cannot opt itself out of being counted, because that would let any
248
+ * tab switch on the closed-deck notifications over itself.
249
+ */
250
+ function isTrayRequest(req) {
251
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
252
+ return url.searchParams.get("role") === "tray" && presentsDeckToken(req.headers ?? {});
253
+ }
254
+
255
+ /**
256
+ * Drain the ring buffer into a newly connected client, then subscribe it.
257
+ *
258
+ * Two things had to change when this stopped being one synchronous burst.
259
+ * `close` is registered before the first frame, because a tab closed mid-replay
260
+ * has to stop it. And the replay repeats until it reaches the live tail: an
261
+ * actual wait lets pushEvent run, and an event that lands after we have walked
262
+ * past its place but before the client is in `sseClients` would otherwise be in
263
+ * neither stream — a hole the client cannot even ask for again, its last id
264
+ * having moved past it.
265
+ */
266
+ async function resumeSse(req, res, lastId, { tray = false } = {}) {
267
+ let sentThrough = lastId;
268
+ let ping = null;
269
+ let closed = false;
270
+ req.on("close", () => {
271
+ closed = true;
272
+ if (ping) clearInterval(ping);
273
+ sseClients.delete(res);
274
+ trayClients.delete(res);
275
+ });
276
+
277
+ for (;;) {
278
+ // A snapshot per pass, because a wait lets pushEvent splice the head of
279
+ // `events` off, and iterating an array being spliced from the front skips
280
+ // entries. Events evicted that way are gone for this client, which is the
281
+ // same bargain every resume against a rotated ring already makes.
282
+ //
283
+ // The snapshot earns more since #625 gave the ring a byte budget as well as
284
+ // a count. Under the count alone the head moved one entry per push; under
285
+ // the budget a single 5 MB event can evict hundreds at once. A replay
286
+ // already walking this array would have skipped every one of them — but the
287
+ // snapshot holds its own references, so the pass in flight still delivers
288
+ // what it was given and only a LATER pass sees the shortened ring. It also
289
+ // means a slow resumer pins one ring's worth of envelopes for as long as its
290
+ // pass lasts, which the budget bounds too: that pin used to be unbounded for
291
+ // the same reason the ring was.
292
+ const batch = ringSnapshot();
293
+ for (const e of batch) {
294
+ if (e.seq <= sentThrough) continue;
295
+ if (closed || res.destroyed) return;
296
+ // Marked with `replay:true` on the envelope for the page, which reads it in
297
+ // two places: the SSE handler coalesces renders while it is set and draws
298
+ // once at `replay-end`, and `chimeFor` stays quiet for it, so a reconnect
299
+ // does not play every Stop in the ring. The reducer never reads it — its
300
+ // turn cleanup keys on each event's own time, which comes out right for
301
+ // replayed and live events alike (HookEnvelope.replay in types.ts, and
302
+ // dead-surface-993 pins it). This used to say the reducer's
303
+ // UserPromptSubmit handler depended on the tag to tell a replayed prompt
304
+ // from a new turn; nothing there reads it.
305
+ const tagged = { ...e, replay: true };
306
+ // Through envelopeJson for the reason writeJsonArray is: the ring may be
307
+ // holding an envelope `JSON.stringify` cannot walk. pushEvent takes the
308
+ // payload out of every envelope it serializes, but it serializes nothing
309
+ // when there is no subscriber and no log — which is precisely the deck a
310
+ // browser is about to connect to — so the first read of such an entry can
311
+ // still be this one. Uncontained it rejected into handleSse's `.catch`,
312
+ // which drops the client; the browser reconnects on its own 1.5s timer,
313
+ // replays the same entry, and is dropped again, so a single small POST
314
+ // kept the canvas from ever loading. The stub loses the `replay` tag and
315
+ // nothing turns on that: it carries no payload for the reducer to act on,
316
+ // and the client's own replay gate runs until the `replay-end` sentinel
317
+ // regardless of what any one envelope says.
318
+ if (!await writeResume(res, `id: ${e.seq}\nevent: hook\ndata: ${envelopeJson(tagged)}\n\n`)) {
319
+ return dropSse(res);
320
+ }
321
+ sentThrough = e.seq;
322
+ }
323
+ // Caught up with the tail as it stands right now. Reached in one pass
324
+ // unless a wait let new events in, and it terminates for the same reason
325
+ // the client is still here at all: either it is taking bytes, in which case
326
+ // loopback outruns any hook, or it is not, in which case writeResume gives
327
+ // up on it.
328
+ if (!ringHoldsNewerThan(sentThrough)) break;
329
+ }
330
+
331
+ if (closed || res.destroyed) return;
332
+
333
+ // Sentinel: tells client "ring buffer drained, live stream starts now". It
334
+ // goes out under the same rule as the frames before it, so a client that has
335
+ // just been handed a large replay is not hung up on over the last 30 bytes of
336
+ // it before it has had the chance to read any.
337
+ //
338
+ // Subscribing before waiting on that write, rather than after, is what closes
339
+ // the last hole: writeResume puts the bytes on the socket before it returns,
340
+ // so the sentinel still precedes every live frame, and an event pushed while
341
+ // we wait reaches this client through the live fan-out instead of falling
342
+ // into the gap between the two. If the wait then ends in a drop, dropSse
343
+ // takes it back out of the set — the same exit writeSse uses.
344
+ const flushed = writeResume(res, `event: replay-end\ndata: {}\n\n`);
345
+ sseClients.add(res);
346
+ if (tray) trayClients.add(res);
347
+ // Through writeSse like every other frame: on a client that has stopped
348
+ // reading, the ping is the one thing still being written between events, and
349
+ // it is what eventually reveals the socket as unrecoverable.
350
+ ping = setInterval(() => writeSse(res, `: ping\n\n`), 15000);
351
+ if (!await flushed) dropSse(res);
352
+ }
@@ -0,0 +1,26 @@
1
+ // The door into the event pipeline for the modules the pipeline itself
2
+ // imports.
3
+ //
4
+ // pushEvent lives in src/server/event-pipeline.mjs, beside the ring, the SSE
5
+ // fan-out and the log it feeds, and for every hook event it asks
6
+ // session-enrichment.mjs to read the session's transcript. The enrichment
7
+ // answers with synthetic events of its own — ModelObserved, UsageObserved,
8
+ // SessionNamed, ContextObserved — and those go back through pushEvent. So the
9
+ // two need each other by nature, and src/server keeps no import cycles
10
+ // (boot-module-graph.test.ts). This is where the cycle is broken: a module that
11
+ // emits imports pushEvent from here, and event-pipeline.mjs connects the real
12
+ // one as it loads — before any event can exist, because nothing emits until a
13
+ // request or a timer that startServer arms asks it to.
14
+
15
+ let _sink = null;
16
+
17
+ /** Hand an event to the pipeline — event-pipeline.mjs's pushEvent, with its
18
+ * arguments and its answer unchanged. */
19
+ export function pushEvent(raw, source, opts) {
20
+ return _sink(raw, source, opts);
21
+ }
22
+
23
+ /** event-pipeline.mjs's, once, as it loads. */
24
+ export function connectEventSink(push) {
25
+ _sink = push;
26
+ }
@@ -0,0 +1,128 @@
1
+ // The children exec.mjs starts, and how they are stopped.
2
+ //
3
+ // Two things, and neither is about how a command is spelled: `killTree`, which
4
+ // on Windows has to reach the tool UNDER a cmd.exe wrapper as well as the
5
+ // wrapper, and the set of children still running, which shutdown() empties so a
6
+ // deadline that dies with the deck cannot leave its child orphaned (#1012).
7
+ // Every deadline in exec.mjs ends in one or the other.
8
+ //
9
+ // exec.mjs wraps each child it spawns in `watchChild` and re-exports the rest,
10
+ // so a caller that imports killTree, liveChildPids or killLiveChildren from
11
+ // there still does.
12
+ import { spawn } from "node:child_process";
13
+
14
+ /**
15
+ * Stop a child AND everything it started.
16
+ *
17
+ * On POSIX the child is the tool, so a signal to it is the whole job and this
18
+ * is exactly the `child.kill()` it replaces. On Windows a .cmd or .bat runs
19
+ * THROUGH cmd.exe (see viaCmd), so the tool — node, for the claude and npm
20
+ * shims — is a grandchild, and a kill is one TerminateProcess against the
21
+ * wrapper. Windows terminates no descendants and libuv's job object lets them
22
+ * break away, so the wrapper vanished and the work carried on: every cancelled
23
+ * or expired `claude auth login` left a node.exe blocked forever on the stdin
24
+ * pipe this process holds, and because that node.exe kept the inherited stdout
25
+ * handle open, the wrapper's 'close' never arrived either — the run that was
26
+ * reported dead never settled.
27
+ *
28
+ * `taskkill /T` is the descendant walk Windows does have, and `/F` is what
29
+ * stops a console app that is not pumping its message queue. Taking it from
30
+ * System32 rather than from PATH matters here: PATH is the user's, and this is
31
+ * the program we hand a pid to kill. If it cannot run at all, the plain kill
32
+ * still happens, which is no worse than before.
33
+ *
34
+ * A process group would be the tidier answer and is the wrong one on Windows:
35
+ * `detached` there only means CREATE_NEW_PROCESS_GROUP, and Node cannot signal
36
+ * a group — `process.kill(-pid)` is POSIX-only.
37
+ */
38
+ export function killTree(child, signal) {
39
+ const plain = () => { try { child?.kill(signal); } catch { /* already gone */ } };
40
+ if (process.platform !== "win32" || !child?.pid) return plain();
41
+ try {
42
+ const root = process.env.SystemRoot || process.env.systemroot;
43
+ const exe = root ? `${root}\\System32\\taskkill.exe` : "taskkill";
44
+ const killer = spawn(exe, ["/pid", String(child.pid), "/T", "/F"], {
45
+ stdio: "ignore", windowsHide: true,
46
+ });
47
+ killer.on("error", plain);
48
+ killer.on("exit", (code) => { if (code !== 0) plain(); });
49
+ killer.unref?.();
50
+ } catch {
51
+ plain();
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Every child exec.mjs started and has not yet seen the end of.
57
+ *
58
+ * A deadline is not a promise unless something is alive to enforce it, and the
59
+ * deadlines in exec.mjs all live in the parent: `run` states the outcome on a
60
+ * timer and only then kills, `runInteractive` does the same. Kill the parent
61
+ * and the timer dies with it, so the very case a deadline exists for — a tool
62
+ * that has hung — is the one case where nothing is left to stop it.
63
+ *
64
+ * Observed (#1012). A sandboxed deck, a quota poll in flight, SIGINT to the
65
+ * deck at 05:26:22.706. The deck was gone within 200ms; its `claude --print
66
+ * /usage` child, spawned at 05:26:18.078 under a 15-second deadline, was still
67
+ * running at 05:26:35.204 — reparented to init, past the deadline the dead
68
+ * parent would have enforced, and with nothing anywhere that would ever kill
69
+ * it. A whole Claude Code process, hundreds of MB resident, orphaned by a
70
+ * Ctrl+C.
71
+ *
72
+ * REMOVED ON 'close', NOT ON 'exit', and the difference is Windows'. A batch
73
+ * candidate runs THROUGH cmd.exe and the tool is a grandchild (see viaCmd and
74
+ * killTree): the wrapper can exit while the tool underneath goes on holding
75
+ * the inherited stdio, which is exactly the shape killTree's `taskkill /T`
76
+ * exists for. 'exit' there means "the wrapper is gone" and 'close' means "and
77
+ * so is everything it started" — forgetting on the first would drop a tree
78
+ * that is still running and still reachable. 'error' is the third way a child
79
+ * ends, and the only one after which 'close' may never come at all.
80
+ *
81
+ * `runDetached` is deliberately absent. It is the one function there named for
82
+ * outliving its call — a sound, or a collection whose result lands in a file
83
+ * the next poll reads — it holds no deadline, and it unrefs its child on
84
+ * purpose. Killing those on the way out would be this set overreaching.
85
+ */
86
+ const live = new Set();
87
+
88
+ /** Track one child until it ends. Returns it, so it can wrap a spawn inline. */
89
+ export function watchChild(cp) {
90
+ if (!cp) return cp;
91
+ live.add(cp);
92
+ const forget = () => { live.delete(cp); };
93
+ cp.once?.("close", forget);
94
+ cp.once?.("error", forget);
95
+ return cp;
96
+ }
97
+
98
+ /**
99
+ * The pids of the children this deck still has running.
100
+ *
101
+ * Exported for the test below this, and because it is the honest answer to
102
+ * "what is this deck still doing" that a future `--status` wants. It may
103
+ * over-report by one in the Windows case above — a wrapper whose 'close' never
104
+ * arrives stays here — which is the right direction to be wrong in: that entry
105
+ * names a process that really is still running.
106
+ */
107
+ export const liveChildPids = () => [...live].map(c => c?.pid).filter(Boolean);
108
+
109
+ /**
110
+ * Stop every child exec.mjs started, and everything they started.
111
+ *
112
+ * Called from shutdown(), where the alternative is the orphan above. It does
113
+ * not WAIT for the corpses, on purpose: a signal is delivered synchronously on
114
+ * POSIX, and on Windows killTree's taskkill is a process of its own that
115
+ * CreateProcess has already started by the time spawn() returns and that
116
+ * outlives this one. Waiting would buy nothing and would spend the ~200ms exit
117
+ * that is the rest of shutdown's good behaviour.
118
+ *
119
+ * The set is cleared first so a 'close' arriving mid-loop cannot mutate what
120
+ * is being iterated, and so a second call — shutdown can be reached twice —
121
+ * has nothing left to do.
122
+ */
123
+ export function killLiveChildren(signal) {
124
+ const doomed = [...live];
125
+ live.clear();
126
+ for (const cp of doomed) killTree(cp, signal);
127
+ return doomed.length;
128
+ }