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,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 };
|