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.
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 +28 -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-CMn28FHb.js +0 -1
  174. package/dist/web/assets/UsageHistoryModal-C-sPhsKT.js +0 -1
  175. package/dist/web/assets/index-C1YFI9f4.js +0 -298
  176. package/dist/web/assets/index-Ck2RAla3.css +0 -1
@@ -0,0 +1,235 @@
1
+ // pushEvent, the one door every event comes through — a hook's POST, a Codex
2
+ // rollout line, the boot replay and the synthetic events the enrichment sends
3
+ // back — and what it does to each on the way in: the deck's token taken out,
4
+ // the model stamped, the ring, one serialization shared by the SSE fan-out and
5
+ // the log, the desktop notification when no page is open, the away-update's
6
+ // activity, and the transcript scans that answer through it.
7
+ //
8
+ // These lived in src/server/index.mjs, after the ring. The modules this one
9
+ // imports reach pushEvent through event-sink.mjs, connected below as this
10
+ // module loads; index.mjs's routes import it from here. The bodies are
11
+ // unchanged.
12
+ import { PRODUCT } from "./brand.mjs";
13
+ import { createBlockNotifier } from "./block-notify.mjs";
14
+ import { notify as osNotify } from "./browser-react.mjs";
15
+ import { notificationsOn } from "./deck-prefs.mjs";
16
+ // The settings as this process holds them — see prefs-state.mjs.
17
+ import { heldPrefs } from "./prefs-state.mjs";
18
+ // The deck's own token, taken back out of every event before the ring, the
19
+ // SSE fan-out or the log can hold it — see token-redact.mjs.
20
+ import { redactDeckToken } from "./token-redact.mjs";
21
+ // The ring every event is numbered and held in — see event-ring.mjs.
22
+ import { admitEvent } from "./event-ring.mjs";
23
+ // The SSE subscribers every event is broadcast to — see sse-clients.mjs.
24
+ import { notifyTrays, pageCount, sseClients, trayClients, writeSse } from "./sse-clients.mjs";
25
+ // Where the log is, which sessions this deck writes to it, and when it rolls
26
+ // over — see event-log.mjs.
27
+ import { eventLogPath, maybeRotatePersistFile, writesLogFor } from "./event-log.mjs";
28
+ import { appendLogLine } from "./log-writer.mjs";
29
+ // The away-update's word on whether a turn is running — see lifecycle.mjs.
30
+ import { activity } from "./lifecycle.mjs";
31
+ // The session LRU and the transcript watch — see session-tracking.mjs.
32
+ import { outputWatch, touchSession } from "./session-tracking.mjs";
33
+ // What the deck learns about a session that its hooks never say, read off the
34
+ // transcript and sent back through pushEvent — see session-enrichment.mjs.
35
+ import { knownModelId, maybeResolveContext, maybeResolveModel, maybeResolveSessionName, maybeResolveUsage } from "./session-enrichment.mjs";
36
+ // The Codex half: a session's rollout, found by id — see codex-enrichment.mjs.
37
+ import { maybeResolveCodex } from "./codex-enrichment.mjs";
38
+ // The gate pushEvent asks before a transcript path is followed — see
39
+ // transcript-gate.mjs.
40
+ import { isClaudeTranscriptPath, noteRefusedTranscript } from "./transcript-gate.mjs";
41
+ // How the enrichment reaches pushEvent without importing this file — see
42
+ // event-sink.mjs. Connected below, as this module loads.
43
+ import { connectEventSink } from "./event-sink.mjs";
44
+
45
+ // The modules that emit synthetic events send them here. pushEvent is a
46
+ // function declaration, so it already exists as this line runs, and nothing
47
+ // emits until a request or a timer startServer arms asks it to.
48
+ connectEventSink(pushEvent);
49
+
50
+ /**
51
+ * The desktop notifier, built once per process.
52
+ *
53
+ * `enabled` is read here and not per event, so a switch cannot change under a
54
+ * running server and let two events in the same second disagree about it. The
55
+ * OS call is browser-react.mjs's — the same one Browser Watch has shipped on
56
+ * all three platforms — so this is a second caller, not a second
57
+ * implementation, and the platform quirks it already handles (argv rather than
58
+ * interpolation on macOS, the WinRT toast on Windows, a missing notify-send on
59
+ * Linux) are handled once.
60
+ *
61
+ * A failure goes to stderr and no further. The user cannot act on "your desktop
62
+ * has no notification daemon" mid-session, the deck is not broken by it, and
63
+ * every in-page surface still says everything it said before.
64
+ */
65
+ const blockNotifier = createBlockNotifier({
66
+ // The desktop app, while it is connected, raises the notification itself —
67
+ // under its own name, icon and permission, with a click that opens its
68
+ // window. Only when no app is listening does the OS helper speak.
69
+ notify: (title, body, meta) => (trayClients.size > 0 ? notifyTrays(title, body, meta) : osNotify(title, body)),
70
+ product: PRODUCT,
71
+ // A function, not a boolean: this is a switch a person flips from the sound
72
+ // menu while the deck is running, and a mute that waited for a restart would
73
+ // not be a mute. The env var still wins inside `notificationsOn`.
74
+ enabled: () => notificationsOn(heldPrefs.current()),
75
+ onError: err => console.error(`${PRODUCT}: could not raise a desktop notification:`, err?.message ?? err),
76
+ });
77
+
78
+ export function pushEvent(raw, source, opts = {}) {
79
+ // First, before anything below can see it: the deck's own credential does not
80
+ // belong in a store that is served without one. Every entry point to the
81
+ // buffer, the SSE fan-out and events.jsonl passes through here, so this is
82
+ // the single line that keeps it out of all three. See redactDeckToken.
83
+ raw = redactDeckToken(raw);
84
+
85
+ // Synchronous enrichment: if we already know this session's model, stamp
86
+ // it on the payload so the client's recursive scanner picks it up.
87
+ if (raw && typeof raw === "object" && raw.session_id && !raw.model) {
88
+ const modelId = knownModelId(raw.session_id);
89
+ if (modelId) raw.model = modelId;
90
+ }
91
+
92
+ // Numbered, charged, held, and the ring evicted behind it — see admitEvent.
93
+ const evt = admitEvent(raw, source, opts.receivedAt ?? Date.now());
94
+ const seq = evt.seq;
95
+
96
+ // Does this event reach the log at all? Not on a replay (it came from
97
+ // there), not when the hook told us another deck owns this session's log,
98
+ // and not when we have no log. Decided before serializing because it is half
99
+ // of the answer to whether serializing is worth doing.
100
+ const persisting = eventLogPath() && !opts.replay && opts.persist !== false && writesLogFor(raw);
101
+
102
+ // One serialization, shared by both consumers — and skipped entirely when
103
+ // neither wants it. This used to stringify the whole envelope twice on the
104
+ // hottest path in the process (once for the SSE frame, once for the persist
105
+ // line), and built the frame even with nobody subscribed: a headless deck
106
+ // paid a full stringify per event for a string no one read, and boot replay
107
+ // — which runs before the listener exists and never broadcasts — paid one
108
+ // for every line of a log that rotates at 50MB. An event this deck is not
109
+ // logging is still broadcast, so a subscriber alone is reason enough.
110
+ //
111
+ // Contained, because this line was fatal. `JSON.stringify` walks a value
112
+ // recursively while `JSON.parse` does not, and the gap between the two is
113
+ // enormous: measured on Node 22.14, parse accepts a body nested 4,194,303
114
+ // deep and stringify gives up on the result at 4,021. So a payload in that
115
+ // window parses cleanly and then throws `RangeError: Maximum call stack size
116
+ // exceeded` out of here — and there is no promise on this path for the
117
+ // route's `guard` to catch, because pushEvent is reached from inside a raw
118
+ // `req.on("end")` listener. It was an uncaughtException, and Node's answer to
119
+ // those is to exit. Measured against the real server: one POST of 24,378
120
+ // bytes, nested 4,050 deep, to the credential-free `/api/event` — a
121
+ // two-hundredth of the 5,000,000-character ingest cap — and the deck was
122
+ // gone, with nothing on the socket to tell the poster why.
123
+ //
124
+ // The payload leaves the ring, not just this string, and that is the point.
125
+ // admitEvent above already took the envelope, and a value nothing can
126
+ // serialize is a value no reader can ever deliver: every `Last-Event-ID`
127
+ // resume that replays it and every `GET /api/events` that writes it would
128
+ // meet the same throw for as long as it stayed in the buffer, so one small
129
+ // POST would poison both routes for the life of the entry. The envelope
130
+ // therefore keeps its `seq` and loses its payload — the same replacement
131
+ // envelopeJson makes for a reader, made once at the write instead of on every
132
+ // read, and the same bargain about `seq`: a caller paging with `?since=`
133
+ // walks past the hole rather than asking forever for what it cannot be given.
134
+ //
135
+ // Admitted as a stub rather than refused at ingest with a 400, deliberately.
136
+ // Three of pushEvent's four callers have no HTTP peer to answer — Codex
137
+ // rollout lines read off disk, the boot replay of events.jsonl, the synthetic
138
+ // events the transcript scanners emit — so the containment has to live here
139
+ // whatever the ingest route does, and a 400 on top would be a second
140
+ // mechanism for a case this one already covers. It would also have to be paid
141
+ // for: knowing a payload will not serialize means serializing it, which is
142
+ // the second stringify per event on the hottest path in the process that the
143
+ // paragraph above exists to have removed. The reason goes to stderr and not
144
+ // to the wire, under the rule sendInternalError explains.
145
+ let json = null;
146
+ if (sseClients.size > 0 || persisting) {
147
+ try {
148
+ json = JSON.stringify(evt);
149
+ } catch (err) {
150
+ console.error(`${PRODUCT}: event ${seq} could not be serialized:`, err);
151
+ evt.payload = null;
152
+ evt.unserializable = true;
153
+ json = JSON.stringify(evt);
154
+ }
155
+ }
156
+
157
+ if (sseClients.size > 0) {
158
+ const line = `id: ${seq}\nevent: hook\ndata: ${json}\n\n`;
159
+ // writeSse may drop a client mid-loop; deleting from a Set while iterating
160
+ // it is well defined and skips only the entry removed.
161
+ for (const res of sseClients) writeSse(res, line);
162
+ }
163
+
164
+ // The desktop, when there is no page to tell.
165
+ //
166
+ // Placed here rather than in a route handler because every path that can
167
+ // produce a permission prompt comes through pushEvent — the hook POST, a
168
+ // replay, and the transcript scanners — and `sseClients.size` read at this
169
+ // exact point is the honest answer to "is anybody being shown this by any
170
+ // other means". The web notifier owns the case where a page exists, and this
171
+ // owns the case where none does; the two never both fire, and neither has to
172
+ // know the other exists. block-notify.mjs holds the gates and the cooldown.
173
+ blockNotifier.consider(raw, { clients: pageCount(), replay: !!opts.replay });
174
+
175
+ // Whether a turn is running, for the away-update. Not from a replay: the log
176
+ // is history, and a turn it shows open is one that ended in another process.
177
+ if (!opts.replay) activity.note(raw, evt.receivedAt);
178
+
179
+ if (persisting) {
180
+ // Fire-and-forget append. JSONL = newline-delimited JSON, so the whole line
181
+ // has to reach the file as one write — this used to be `appendFile`, which
182
+ // splits anything over 512 KiB into separate appends and let another
183
+ // event land in the middle of a large tool response. See appendLogLine.
184
+ //
185
+ // Note this runs AFTER redactDeckToken above, as every path to the log
186
+ // does: the string being written is the one serialization of the event the
187
+ // SSE frame also used, and the token was taken out of the payload before
188
+ // either existed.
189
+ const line = json + "\n";
190
+ appendLogLine(eventLogPath(), line);
191
+ // Throttled check — every 30s, or every fifth of the threshold written,
192
+ // whichever comes first. The byte arm is what keeps the 50 MB cap from
193
+ // being advisory at any real ingest rate; see maybeRotatePersistFile.
194
+ maybeRotatePersistFile(Buffer.byteLength(line));
195
+ }
196
+
197
+ // Note the session so the caches the scanners below fill can expire by
198
+ // least-recent use. Replays are excluded: they fill nothing, and a boot
199
+ // replay of a log spanning weeks would otherwise churn the whole LRU through
200
+ // dead session ids before the first live event even arrives.
201
+ if (!opts.replay && raw && typeof raw === "object") touchSession(raw.session_id);
202
+
203
+ // Kick off async transcript scans. Model and usage are both re-read
204
+ // periodically (throttled to 2.5s per session) so the cost columns track
205
+ // running totals as the session progresses and late subagent models still
206
+ // land; ModelObserved is only emitted when the resolved set changes. Both
207
+ // result in synthetic events.
208
+ // Provider gates the path: Claude reads transcript_path; Codex reads its
209
+ // rollout JSONL under ~/.codex/sessions/. The Claude scanners short-circuit
210
+ // when transcript_path is absent (always the case for Codex hooks).
211
+ if (source === "hook" && !opts.replay) {
212
+ if (raw && raw.provider === "codex") {
213
+ maybeResolveCodex(raw);
214
+ } else if (!raw?.transcript_path || isClaudeTranscriptPath(raw.transcript_path)) {
215
+ // The gate is here, once, rather than repeated in the four scanners
216
+ // below it: this is the single door a caller-chosen path comes through,
217
+ // and all four read the same field off the same payload. A payload with
218
+ // no transcript_path at all still goes through — every one of them
219
+ // early-returns without it, and Codex hooks never send one — so the
220
+ // ordinary event costs nothing but the absent-field test.
221
+ maybeResolveModel(raw);
222
+ maybeResolveUsage(raw);
223
+ maybeResolveContext(raw);
224
+ maybeResolveSessionName(raw);
225
+ // Where the transcript IS, learned from the one place it is free. The
226
+ // four scanners above read it on this event; the watch reads it between
227
+ // events, which is the whole of what it adds.
228
+ if (raw?.transcript_path) outputWatch.note(raw.session_id, raw.transcript_path);
229
+ } else {
230
+ noteRefusedTranscript(raw.transcript_path);
231
+ }
232
+ }
233
+
234
+ return evt;
235
+ }
@@ -0,0 +1,214 @@
1
+ // The event ring: every envelope this deck still holds, the seq numbering they
2
+ // are given, and the eviction that keeps the ring inside its bounds.
3
+ //
4
+ // These lived in src/server/index.mjs, at the head of the ring buffer section
5
+ // and inside pushEvent. The array and the two running totals beside it have to
6
+ // move together, so they are private to this module now and reached only
7
+ // through the operations below: admitEvent numbers, charges and holds one
8
+ // envelope and evicts behind it, clearEventBuffer empties all three, and the
9
+ // readers get a copy or an answer rather than the array. The bodies are
10
+ // unchanged.
11
+ import { ENVELOPE_CHARS, LAST_VALUE_WINS, MAX_BUFFER, MAX_BUFFER_CHARS, MAX_RING_ENTRIES, payloadChars } from "./ring-bounds.mjs";
12
+
13
+ // ─── The event ring buffer ─────────────────────────────────────────────────
14
+ // Its two bounds, MAX_BUFFER and MAX_BUFFER_CHARS, and the charge an event is
15
+ // counted in against the second, payloadChars, are ring-bounds.mjs's; the
16
+ // eviction in admitEvent keeps them.
17
+
18
+ const events = []; // ring buffer
19
+ // The running sum of what `events` holds, in the units payloadChars charges.
20
+ // Kept beside the array rather than recomputed, because the alternative is
21
+ // walking every buffered payload on every push. It and `events` have to move
22
+ // together and nothing outside this module may touch either — see
23
+ // clearEventBuffer for the one place that empties both, and what went wrong the
24
+ // day only one of them was emptied.
25
+ let bufferedChars = 0;
26
+
27
+ /**
28
+ * How many of the ring's entries are HOOK events — what MAX_BUFFER budgets.
29
+ *
30
+ * Third of the trio that has to move with `events`, for the same reason
31
+ * `bufferedChars` does and with the same consequence if it drifts: a count that
32
+ * names events the array no longer holds evicts against a budget already spent.
33
+ * See LAST_VALUE_WINS for what it excludes and why, and clearEventBuffer for
34
+ * the one place all three are emptied.
35
+ */
36
+ let bufferedHookEvents = 0;
37
+
38
+ /** Set on the envelopes MAX_BUFFER does not count, so eviction can decrement
39
+ * the right total without re-reading a payload it already charged. */
40
+ const ENRICHMENT = Symbol("ring enrichment");
41
+
42
+ // Where the charge rides. A Symbol key rather than an ordinary field, because
43
+ // the envelope is JSON.stringify'd on the hot path into both the SSE frame and
44
+ // the events.jsonl line, and JSON.stringify ignores symbol-keyed properties
45
+ // entirely. So the number stays welded to the envelope it describes — which is
46
+ // what makes it impossible for `bufferedChars` and `events` to drift apart —
47
+ // without reaching the wire, the log, the client's HookEnvelope type, or the
48
+ // 127-character envelope measurement MAX_CLIENT_BUFFER_BYTES is sized against.
49
+ const CHARS = Symbol("ring charge");
50
+
51
+ /**
52
+ * Empty the ring and the total measuring it, together.
53
+ *
54
+ * `/api/clear` used to be a bare `events.length = 0`, and with a running total
55
+ * beside the array that is a permanent debt: the total would still name events
56
+ * the array no longer holds, and every push after the first clear would evict
57
+ * against a budget already spent — a deck that answers one clear and then keeps
58
+ * a ring of one event for the rest of its life. The two variables move here and
59
+ * nowhere else.
60
+ */
61
+ export function clearEventBuffer() {
62
+ events.length = 0;
63
+ bufferedChars = 0;
64
+ bufferedHookEvents = 0;
65
+ }
66
+
67
+ /**
68
+ * What the ring holds right now — its length, what it is charged, and the seq
69
+ * range it spans.
70
+ *
71
+ * Exported from index.mjs alongside MAX_BUFFER and MAX_BUFFER_CHARS so a test
72
+ * can watch the bound hold through a real server instead of watching a process
73
+ * die, which is the only other way this bound is observable. The seq range is
74
+ * here for the property eviction has to keep and nothing else checks: what
75
+ * leaves is always a PREFIX, so `newest - oldest + 1` equals the count. An
76
+ * eviction that ever took from the middle would leave a hole no resuming
77
+ * client could ask for again, and that is exactly what `GET /api/events` and
78
+ * the replay loop would then hand out without noticing.
79
+ */
80
+ export function eventBufferStats() {
81
+ return {
82
+ events: events.length,
83
+ // What MAX_BUFFER budgets since #1032, and the only number a resuming
84
+ // client's window is actually measured in. `events` minus this is the
85
+ // enrichment riding along; the ratio between them is the amplification that
86
+ // used to spend the window — 2.6 measured at every session count tried.
87
+ hookEvents: bufferedHookEvents,
88
+ chars: bufferedChars,
89
+ oldestSeq: events.length > 0 ? events[0].seq : 0,
90
+ newestSeq: events.length > 0 ? events[events.length - 1].seq : 0,
91
+ };
92
+ }
93
+
94
+ let nextSeq = 1;
95
+ // Identity of *this* process's seq numbering. nextSeq restarts at 1 on every
96
+ // boot and is re-derived by replaying events.jsonl, so it is monotonic only
97
+ // within one process: rotation (50MB) or /api/clear shortens that log and the
98
+ // next boot starts numbering well below what an already-open tab saw. Stamped
99
+ // on every envelope so a client can tell "counter restarted" apart from "old
100
+ // duplicate" instead of silently dropping the live stream.
101
+ export const SEQ_EPOCH = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
102
+ /** Envelopes newer than `seq` from the ring buffer, oldest first. */
103
+ export function eventsSince(seq) {
104
+ const after = Number(seq) || 0;
105
+ return events.filter(e => e.seq > after);
106
+ }
107
+
108
+ /** The seq the newest event was given, 0 before the first — what
109
+ * `/api/health` reports as `seq`. */
110
+ export function lastSeq() {
111
+ return nextSeq - 1;
112
+ }
113
+
114
+ /** Every envelope the ring holds, oldest first, in an array the caller owns —
115
+ * a snapshot, so a splice of the live ring's head cannot skip an entry in it. */
116
+ export function ringSnapshot() {
117
+ return events.slice();
118
+ }
119
+
120
+ /** Whether the ring still holds an envelope newer than `seq` — false for an
121
+ * empty ring, whatever `seq` is. */
122
+ export function ringHoldsNewerThan(seq) {
123
+ return events.length > 0 && events[events.length - 1].seq > seq;
124
+ }
125
+
126
+ /**
127
+ * Number one payload, charge it, hold it, and evict behind it — the envelope
128
+ * pushEvent goes on to serialize, broadcast and log.
129
+ *
130
+ * `raw` is the payload as it will be stored: pushEvent has already taken the
131
+ * deck's token out of it and stamped the model, so what is measured here is
132
+ * what is kept.
133
+ */
134
+ export function admitEvent(raw, source, receivedAt) {
135
+ const seq = nextSeq++;
136
+ const evt = {
137
+ seq,
138
+ epoch: SEQ_EPOCH,
139
+ receivedAt,
140
+ source,
141
+ payload: raw,
142
+ // Charged once, here, and carried on the envelope so eviction never has to
143
+ // walk a payload a second time. Symbol-keyed, so it is invisible to the two
144
+ // JSON.stringify calls in pushEvent and to the `{ ...e }` the replay loop
145
+ // makes. Charged AFTER redactDeckToken, like everything else pushEvent does
146
+ // to an event: the payload being measured is the one that will be stored.
147
+ [CHARS]: ENVELOPE_CHARS + payloadChars(raw),
148
+ // Decided once, here, beside the charge and for the same reason: eviction
149
+ // must never have to look at a payload again to know what it is giving
150
+ // back. See LAST_VALUE_WINS.
151
+ [ENRICHMENT]: raw != null && typeof raw === "object" && LAST_VALUE_WINS.has(raw.hook_event_name),
152
+ };
153
+ events.push(evt);
154
+ bufferedChars += evt[CHARS];
155
+ if (!evt[ENRICHMENT]) bufferedHookEvents++;
156
+
157
+ // Evict oldest-first until BOTH bounds hold — the count that has always been
158
+ // here, and the byte budget #625 added. See MAX_BUFFER_CHARS for the numbers.
159
+ //
160
+ // Counted first and spliced once, rather than shifting in a loop, because the
161
+ // two bounds evict at very different scales. The count bound drops exactly
162
+ // one entry per push; the byte bound can drop hundreds, since twenty-seven
163
+ // maximum-size events fill the whole budget on their own, and a shift per
164
+ // entry would memmove the array once for each of them.
165
+ //
166
+ // `drop < events.length - 1` is what keeps the event just pushed, whatever it
167
+ // weighs. A single event is allowed to be larger than the entire budget —
168
+ // ingest admits 5,000,000 characters, and a Codex rollout line read off disk
169
+ // has no length bound at all — and evicting it on arrival would leave
170
+ // pushEvent returning an envelope that `GET /api/events` never shows and no
171
+ // resuming client can ever be handed: a hole with no id to ask for it again,
172
+ // which is the exact failure resumeSse is written to avoid. So the true
173
+ // ceiling is MAX_BUFFER_CHARS plus one event, and that is stated here rather
174
+ // than pretended away.
175
+ //
176
+ // What this does to a resuming client is what the count bound has always done
177
+ // to one, only sooner: the head of the ring moves, and events that fell off
178
+ // it are gone for anybody who had not been sent them yet. That is the
179
+ // existing bargain for a too-old Last-Event-ID — handleSse replays whatever
180
+ // is still held and the client's `lastSeq` steps forward over the gap — and
181
+ // the byte bound deliberately reuses it rather than inventing a second answer.
182
+ // The difference worth knowing is that the head can now move in jumps rather
183
+ // than one entry at a time; resumeSse's per-pass snapshot is what makes that
184
+ // safe for a replay already in flight.
185
+ //
186
+ // THE COUNT BOUND IS HOOK EVENTS, NOT ENTRIES (#1032). It was entries, and
187
+ // the deck pushes about 1.6 derived events per hook event — so the window
188
+ // this ring IS shrank with the session count, to 9.5 measured seconds at 200
189
+ // sessions. Counting only what a user actually did restores a 2,000-event
190
+ // window at any number of sessions, and costs the array room for the
191
+ // enrichment interleaved among them, which MAX_RING_ENTRIES and
192
+ // MAX_BUFFER_CHARS both still bound.
193
+ //
194
+ // Enrichment falls off the head exactly as it always did — no entry is
195
+ // skipped, held back or reordered, and the ring stays contiguous in seq.
196
+ // What changed is only which entries the budget is spent on.
197
+ let drop = 0;
198
+ let freed = 0;
199
+ let freedHookEvents = 0;
200
+ while (drop < events.length - 1
201
+ && (bufferedHookEvents - freedHookEvents > MAX_BUFFER
202
+ || events.length - drop > MAX_RING_ENTRIES
203
+ || bufferedChars - freed > MAX_BUFFER_CHARS)) {
204
+ freed += events[drop][CHARS];
205
+ if (!events[drop][ENRICHMENT]) freedHookEvents++;
206
+ drop++;
207
+ }
208
+ if (drop > 0) {
209
+ events.splice(0, drop);
210
+ bufferedChars -= freed;
211
+ bufferedHookEvents -= freedHookEvents;
212
+ }
213
+ return evt;
214
+ }