ccdeck 3.29.11 → 3.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- 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-n63GjLhv.js +1 -0
- package/dist/web/assets/UsageHistoryModal-DEwBX4Fk.js +1 -0
- package/dist/web/assets/index-CZJf1Cpp.js +299 -0
- package/dist/web/assets/index-D4q1ElXU.css +1 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +15 -14
- package/package.json +1 -1
- package/release-notes.json +40 -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 +7 -3
- 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 +2 -2
- package/src/server/deck-prefs.mjs +58 -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 +174 -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 +110 -0
- package/src/server/prefs-give-back.mjs +206 -0
- package/src/server/prefs-path.mjs +14 -0
- package/src/server/prefs-refusal.mjs +86 -0
- package/src/server/prefs-routes.mjs +99 -0
- package/src/server/prefs-set-aside.mjs +86 -0
- package/src/server/prefs-state.mjs +70 -0
- package/src/server/process-command.mjs +104 -0
- package/src/server/process-list.mjs +547 -0
- package/src/server/provider-status.mjs +307 -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 +108 -0
- package/src/server/uv-bootstrap.mjs +1 -1
- package/src/server/wordmark.mjs +127 -0
- package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
- package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
- package/dist/web/assets/index-Ck2RAla3.css +0 -1
- package/dist/web/assets/index-jmrcEsCj.js +0 -298
|
@@ -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
|
+
}
|