ccdeck 3.29.10 → 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.
- package/README.md +1 -1
- package/bin/agent-dag.js +67 -118
- package/bin/cli/help.js +67 -0
- package/bin/cli/login-item.js +182 -0
- package/bin/cli/one-shot.js +254 -0
- package/bin/cli/package.js +39 -0
- package/bin/cli/pulse.js +98 -0
- package/bin/cli/restart.js +179 -0
- package/bin/cli/screen.js +166 -0
- package/bin/cli/second-start.js +120 -0
- package/bin/cli/startup.js +459 -0
- package/bin/cli/uninstall.js +169 -0
- package/bin/deck.js +112 -1559
- package/dist/web/assets/BrowserWatchModal-DP3YMtEi.js +1 -0
- package/dist/web/assets/UsageHistoryModal-CcdPVXAw.js +1 -0
- package/dist/web/assets/index-3vS6A0vt.css +1 -0
- package/dist/web/assets/index-BVTWHDta.js +298 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +15 -14
- package/package.json +1 -1
- package/release-notes.json +28 -0
- package/src/server/account-projects.mjs +25 -11
- package/src/server/account-routes.mjs +254 -0
- package/src/server/activity.mjs +3 -3
- package/src/server/append-budget.mjs +185 -0
- package/src/server/append-failures.mjs +147 -0
- package/src/server/args.mjs +37 -5
- package/src/server/atomic-write.mjs +300 -0
- package/src/server/block-notify.mjs +0 -5
- package/src/server/boot-lock.mjs +1 -1
- package/src/server/brand.mjs +7 -6
- package/src/server/browser-react.mjs +10 -2
- package/src/server/browser-watch-decks.mjs +146 -0
- package/src/server/browser-watch-feed.mjs +69 -0
- package/src/server/browser-watch-log.mjs +173 -0
- package/src/server/browser-watch-mtime.mjs +18 -0
- package/src/server/browser-watch-relay.mjs +112 -0
- package/src/server/browser-watch-routes.mjs +172 -0
- package/src/server/browser-watch-seen.mjs +127 -0
- package/src/server/browser-watch-store.mjs +14 -143
- package/src/server/browser-watch.mjs +278 -626
- package/src/server/canonical-path.mjs +150 -0
- package/src/server/ccusage-failure.mjs +64 -0
- package/src/server/ccusage-install.mjs +459 -0
- package/src/server/ccusage-runner.mjs +271 -0
- package/src/server/ccusage.mjs +21 -756
- package/src/server/claude-accounts.mjs +220 -464
- package/src/server/claude-collector.mjs +189 -0
- package/src/server/claude-dir.mjs +2 -2
- package/src/server/claude-identity.mjs +5 -4
- package/src/server/claude-reset-credits.mjs +130 -0
- package/src/server/claude-verdicts.mjs +174 -0
- package/src/server/clear-route.mjs +191 -0
- package/src/server/codex-auth.mjs +4 -4
- package/src/server/codex-dir.mjs +52 -8
- package/src/server/codex-enrichment.mjs +310 -0
- package/src/server/codex-quota.mjs +5 -3
- package/src/server/codex-translate.mjs +407 -0
- package/src/server/codex-usage.mjs +8 -5
- package/src/server/codex-watch.mjs +496 -0
- package/src/server/cswap-auto-loop.mjs +342 -0
- package/src/server/cswap-auto-readers.mjs +303 -0
- package/src/server/cswap-auto.mjs +51 -609
- package/src/server/cswap-install.mjs +69 -265
- package/src/server/cswap-layout.mjs +242 -0
- package/src/server/deck-home.mjs +1 -1
- package/src/server/deck-prefs.mjs +51 -184
- package/src/server/deck-scope.mjs +52 -0
- package/src/server/desktop-update-routes.mjs +75 -0
- package/src/server/detach.mjs +78 -0
- package/src/server/discovery.mjs +298 -0
- package/src/server/event-log.mjs +328 -0
- package/src/server/event-pipeline.mjs +235 -0
- package/src/server/event-ring.mjs +214 -0
- package/src/server/event-routes.mjs +352 -0
- package/src/server/event-sink.mjs +26 -0
- package/src/server/exec-children.mjs +128 -0
- package/src/server/exec-not-found.mjs +213 -0
- package/src/server/exec-output.mjs +23 -0
- package/src/server/exec-spec.mjs +341 -0
- package/src/server/exec.mjs +74 -661
- package/src/server/health-route.mjs +42 -0
- package/src/server/hook-challenge.mjs +37 -0
- package/src/server/http-io.mjs +104 -0
- package/src/server/index.mjs +172 -7620
- package/src/server/install-layout.mjs +506 -0
- package/src/server/installer.mjs +79 -608
- package/src/server/jsonl-chunks.mjs +139 -0
- package/src/server/lan-accounts.mjs +27 -0
- package/src/server/lan-beacon.mjs +457 -0
- package/src/server/lan-call.mjs +239 -0
- package/src/server/lan-copies.mjs +270 -0
- package/src/server/lan-deck.mjs +583 -0
- package/src/server/lan-dials.mjs +161 -0
- package/src/server/lan-engine.mjs +475 -1024
- package/src/server/lan-hearing.mjs +49 -0
- package/src/server/lan-inbound.mjs +100 -0
- package/src/server/lan-invite-offer.mjs +77 -0
- package/src/server/lan-invite.mjs +207 -0
- package/src/server/lan-lines.mjs +73 -0
- package/src/server/lan-manifest.mjs +74 -0
- package/src/server/lan-reach.mjs +82 -69
- package/src/server/lan-requests.mjs +249 -0
- package/src/server/lan-round-record.mjs +45 -0
- package/src/server/lan-round-timer.mjs +62 -0
- package/src/server/lan-routes.mjs +143 -0
- package/src/server/lan-serve.mjs +112 -0
- package/src/server/lan-socket.mjs +243 -925
- package/src/server/lan-sync.mjs +58 -1026
- package/src/server/lan-tailnet-poll.mjs +57 -0
- package/src/server/lan-turns.mjs +80 -0
- package/src/server/lan-wire.mjs +620 -0
- package/src/server/lifecycle.mjs +421 -0
- package/src/server/listen.mjs +110 -0
- package/src/server/live-decks.mjs +138 -0
- package/src/server/load-average.mjs +32 -0
- package/src/server/log-election.mjs +235 -0
- package/src/server/log-replay.mjs +374 -0
- package/src/server/log-writer.mjs +72 -557
- package/src/server/login-service.mjs +3 -2
- package/src/server/macmon.mjs +1 -1
- package/src/server/memory-files.mjs +163 -0
- package/src/server/memory-metrics.mjs +178 -0
- package/src/server/metrics-history.mjs +94 -0
- package/src/server/metrics-run.mjs +100 -0
- package/src/server/music-routes.mjs +134 -0
- package/src/server/network-metrics.mjs +5 -3
- package/src/server/network-sampler.mjs +256 -0
- package/src/server/npm-latest.mjs +385 -0
- package/src/server/npm-upgrade.mjs +352 -0
- package/src/server/npx.mjs +41 -7
- package/src/server/output-watch.mjs +5 -4
- package/src/server/pinned-build.mjs +108 -0
- package/src/server/prefs-path.mjs +14 -0
- package/src/server/prefs-refusal.mjs +77 -0
- package/src/server/prefs-routes.mjs +76 -0
- package/src/server/prefs-set-aside.mjs +86 -0
- package/src/server/prefs-state.mjs +51 -0
- package/src/server/process-command.mjs +104 -0
- package/src/server/process-list.mjs +547 -0
- package/src/server/pulse-line.mjs +132 -0
- package/src/server/purge-key.mjs +7 -7
- package/src/server/quota-cli.mjs +174 -0
- package/src/server/quota-oauth.mjs +190 -0
- package/src/server/quota-shape.mjs +229 -0
- package/src/server/quota-store-resets.mjs +206 -0
- package/src/server/quota.mjs +123 -509
- package/src/server/relay-guard.mjs +3 -3
- package/src/server/request-gates.mjs +441 -0
- package/src/server/restart-note.mjs +180 -0
- package/src/server/retire-sound-hook.mjs +3 -3
- package/src/server/ring-bounds.mjs +215 -0
- package/src/server/running-deck.mjs +1 -1
- package/src/server/running-version.mjs +24 -0
- package/src/server/self-update.mjs +39 -1371
- package/src/server/session-enrichment.mjs +622 -0
- package/src/server/session-read-gate.mjs +49 -0
- package/src/server/session-tracking.mjs +242 -0
- package/src/server/sse-clients.mjs +222 -0
- package/src/server/stale-discovery.mjs +30 -0
- package/src/server/static-serve.mjs +81 -0
- package/src/server/supervisor.mjs +73 -12
- package/src/server/system-metrics.mjs +169 -1935
- package/src/server/term.mjs +11 -249
- package/src/server/thermal-metrics.mjs +520 -0
- package/src/server/thermal-sampler.mjs +204 -0
- package/src/server/token-redact.mjs +123 -0
- package/src/server/transcript-gate.mjs +107 -0
- package/src/server/transcript-scan.mjs +563 -0
- package/src/server/usage-routes.mjs +93 -0
- package/src/server/uv-bootstrap.mjs +1 -1
- package/src/server/wordmark.mjs +127 -0
- package/dist/web/assets/BrowserWatchModal-CMn28FHb.js +0 -1
- package/dist/web/assets/UsageHistoryModal-C-sPhsKT.js +0 -1
- package/dist/web/assets/index-C1YFI9f4.js +0 -298
- package/dist/web/assets/index-Ck2RAla3.css +0 -1
|
@@ -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
|
+
}
|