ccdeck 3.38.1 → 3.38.2

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.
@@ -64,8 +64,8 @@
64
64
  stored === "light" || (stored !== "dark" && light) ? "light" : "dark");
65
65
  })();
66
66
  </script>
67
- <script type="module" crossorigin src="/assets/index-DnnkOgia.js"></script>
68
- <link rel="stylesheet" crossorigin href="/assets/index-D-HX6Mea.css">
67
+ <script type="module" crossorigin src="/assets/index-5Bss6_uX.js"></script>
68
+ <link rel="stylesheet" crossorigin href="/assets/index-6_mJZ_aP.css">
69
69
  </head>
70
70
  <body>
71
71
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccdeck",
3
- "version": "3.38.1",
3
+ "version": "3.38.2",
4
4
  "description": "Local dashboard for Claude Code and Codex CLI sessions: which Claude Code session needs input, every tool call and Claude Code subagent live, token cost and quota, several Claude accounts. Run npx ccdeck.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -71,7 +71,8 @@
71
71
  "3.36.9",
72
72
  "3.37.0",
73
73
  "3.38.0",
74
- "3.38.1"
74
+ "3.38.1",
75
+ "3.38.2"
75
76
  ],
76
77
  "3.37.1": [
77
78
  {
@@ -9,6 +9,9 @@
9
9
  // readers get a copy or an answer rather than the array. The bodies are
10
10
  // unchanged.
11
11
  import { ENVELOPE_CHARS, MAX_BUFFER, MAX_BUFFER_CHARS, MAX_RING_ENTRIES, isEnrichment, payloadChars } from "./ring-bounds.mjs";
12
+ // The newest enrichment of each kind per session that eviction takes off the
13
+ // head, for a page that connects after it went — see evicted-enrichment.mjs.
14
+ import { clearEvicted, noteEvicted } from "./evicted-enrichment.mjs";
12
15
 
13
16
  // ─── The event ring buffer ─────────────────────────────────────────────────
14
17
  // Its two bounds, MAX_BUFFER and MAX_BUFFER_CHARS, and the charge an event is
@@ -62,6 +65,8 @@ export function clearEventBuffer() {
62
65
  events.length = 0;
63
66
  bufferedChars = 0;
64
67
  bufferedHookEvents = 0;
68
+ // And what eviction kept back from it: a cleared deck has nothing to resend.
69
+ clearEvicted();
65
70
  }
66
71
 
67
72
  /**
@@ -206,9 +211,13 @@ export function admitEvent(raw, source, receivedAt) {
206
211
  drop++;
207
212
  }
208
213
  if (drop > 0) {
209
- events.splice(0, drop);
214
+ const evicted = events.splice(0, drop);
210
215
  bufferedChars -= freed;
211
216
  bufferedHookEvents -= freedHookEvents;
217
+ // What leaves is gone for every page that had not been sent it — except
218
+ // the enrichment, whose newest value per session is kept back for those
219
+ // pages, because an idle session never sends it again.
220
+ for (const e of evicted) if (e[ENRICHMENT]) noteEvicted(e, e[CHARS]);
212
221
  }
213
222
  return evt;
214
223
  }
@@ -22,6 +22,9 @@ import { noteLogWriter } from "./event-log.mjs";
22
22
  import { deckProviders } from "./deck-scope.mjs";
23
23
  // The names only the server may send — see ring-bounds.mjs.
24
24
  import { isReservedEventName } from "./ring-bounds.mjs";
25
+ // The enrichment the ring evicted, handed to a page that connects behind its
26
+ // head — see withEvictedEnrichment.
27
+ import { evictedEnrichment } from "./evicted-enrichment.mjs";
25
28
  // The SSE subscribers and the backpressure every frame to them is written
26
29
  // under — see sse-clients.mjs.
27
30
  import { dropSse, sseClients, trayClients, writeResume, writeSse } from "./sse-clients.mjs";
@@ -253,6 +256,11 @@ export function handleSse(req, res) {
253
256
  // guard is `env.seq <= state.lastSeq`, so the gap costs it a step forward and
254
257
  // nothing else. What the byte budget changed is how often and how far the
255
258
  // head moves, not what happens to a client that lands behind it.
259
+ //
260
+ // One thing in the gap is not history: the newest model, name, usage and the
261
+ // rest of each session's last-value-wins enrichment, which an idle session
262
+ // never sends again. That is put back in front of the replay — see
263
+ // withEvictedEnrichment.
256
264
  const asked = Number(req.headers["last-event-id"] ?? 0);
257
265
  const lastId = Number.isFinite(asked) ? asked : 0;
258
266
 
@@ -311,7 +319,8 @@ async function resumeSse(req, res, lastId, { tray = false } = {}) {
311
319
  // means a slow resumer pins one ring's worth of envelopes for as long as its
312
320
  // pass lasts, which the budget bounds too: that pin used to be unbounded for
313
321
  // the same reason the ring was.
314
- const batch = ringSnapshot();
322
+ // Behind the ring's head, the enrichment that fell off it goes first.
323
+ const batch = withEvictedEnrichment(ringSnapshot(), sentThrough);
315
324
  for (const e of batch) {
316
325
  if (e.seq <= sentThrough) continue;
317
326
  if (closed || res.destroyed) return;
@@ -372,3 +381,38 @@ async function resumeSse(req, res, lastId, { tray = false } = {}) {
372
381
  ping = setInterval(() => writeSse(res, `: ping\n\n`), 15000);
373
382
  if (!await flushed) dropSse(res);
374
383
  }
384
+
385
+ /**
386
+ * A replay pass's snapshot, with what this page lost to eviction put in front
387
+ * of it.
388
+ *
389
+ * When the ring's oldest event is newer than the next one this page needs —
390
+ * a fresh tab on a busy deck, a reload, a laptop waking — everything between
391
+ * was evicted, and a session's model, name, usage, context, activity line and
392
+ * job went with it if they were in there: they are sent when they change, so
393
+ * an idle session's card would be drawn without them for as long as it stayed
394
+ * idle. The newest value of each, for every session this snapshot holds an
395
+ * event of (the cards the page will draw), is put in front under its own seq
396
+ * (see evicted-enrichment.mjs), so the loop below sends it as one more replay
397
+ * frame, in order, before the ring. Every pass asks, against what this page
398
+ * has been sent so far: a slow replay the head overtook between passes is
399
+ * handed what it lost then too, and one that is caught up is handed nothing.
400
+ *
401
+ * The page holds a value that arrives before its card and gives it to the
402
+ * card when the card is made (parked-enrichment.ts).
403
+ */
404
+ function withEvictedEnrichment(batch, sentThrough) {
405
+ if (batch.length === 0 || batch[0].seq <= sentThrough + 1) return batch;
406
+ try {
407
+ const sessions = new Set();
408
+ for (const e of batch) {
409
+ const sid = e.payload?.session_id;
410
+ if (typeof sid === "string" && sid) sessions.add(sid);
411
+ }
412
+ const lost = evictedEnrichment({ after: sentThrough, before: batch[0].seq, sessions });
413
+ return lost.length > 0 ? lost.concat(batch) : batch;
414
+ } catch {
415
+ // A page is never refused its replay for this.
416
+ return batch;
417
+ }
418
+ }
@@ -0,0 +1,213 @@
1
+ // The newest value of each last-value-wins enrichment the ring has evicted,
2
+ // per session — what a page that connects behind the ring's head is handed
3
+ // ahead of the replay, so it is not left without them.
4
+ //
5
+ // WHY THESE GO MISSING. A session's model, name, usage, context, activity line
6
+ // and background job (LAST_VALUE_WINS in ring-bounds.mjs) are each sent when
7
+ // they change, and the scanners gate most of them on that: an idle session's
8
+ // ModelObserved and SessionNamed went out once, near its start. The ring keeps
9
+ // the newest MAX_BUFFER hook events, so on a busy deck those events fall off
10
+ // its head while the session's later hook events are still held. A reload or a
11
+ // second tab is replayed what is left: the card is drawn, from its surviving
12
+ // hook events, with no model chip, no name and a stale token count, and nothing
13
+ // sends them again until the session does something.
14
+ //
15
+ // WHAT IS KEPT. Every enrichment envelope the ring evicts is folded here into
16
+ // the newest value of its kind for its session (foldEnrichment), and keeps the
17
+ // seq, time and source of the last one folded. A page whose Last-Event-ID is
18
+ // older than what was evicted is sent these BEFORE the ring, under their own
19
+ // seq — see withEvictedEnrichment in event-routes.mjs — so the page applies
20
+ // them exactly where the ring would have, and every newer event still in the
21
+ // ring lands on top of them as it would have. Nothing is pushed into the ring,
22
+ // nothing is broadcast to the pages already connected and nothing is logged;
23
+ // and because each keeps its own `receivedAt`, none of them reads to the page
24
+ // as the session being heard from just now.
25
+ //
26
+ // Fed by admitEvent (event-ring.mjs) as it evicts, and emptied with the ring by
27
+ // clearEventBuffer, so a Clear forgets these as it forgets the ring.
28
+ import { payloadChars } from "./ring-bounds.mjs";
29
+
30
+ /** The kinds kept: the session's own last-value-wins enrichment, each read by
31
+ * its applier in transcript-events.ts. Named here rather than read off
32
+ * LAST_VALUE_WINS, because the fold below is written for exactly these and a
33
+ * kind added there is not folded right by rules it was never written for. */
34
+ const KINDS = new Set([
35
+ "ModelObserved", "UsageObserved", "ContextObserved", "SessionNamed",
36
+ "ActivityObserved", "JobObserved",
37
+ ]);
38
+
39
+ /** The most sessions kept, least recently folded dropped first — the deck's
40
+ * own working number for the sessions it tracks (MAX_TRACKED_SESSIONS in
41
+ * session-tracking.mjs), well above the 200 cards a page draws. */
42
+ export const MAX_EVICTED_SESSIONS = 256;
43
+
44
+ /** What everything kept may weigh, in the characters payloadChars charges,
45
+ * and what one value may. Real values are a few kilobytes — a usage total
46
+ * split by model, a context breakdown with its memory files, a session's
47
+ * subagent models — so the cap on one value is only ever met by a payload no
48
+ * scanner wrote; `POST /api/event` takes enrichment names from anyone, and
49
+ * this must not become a second ring with no budget. */
50
+ export const MAX_EVICTED_CHARS = 8 * 1024 * 1024;
51
+ export const MAX_EVICTED_VALUE_CHARS = 256 * 1024;
52
+
53
+ // sid -> Map<hook_event_name, { seq, epoch, receivedAt, source, payload, chars }>,
54
+ // both in least-recently-folded order.
55
+ const kept = new Map();
56
+ let keptChars = 0;
57
+
58
+ const isObject = v => v !== null && typeof v === "object" && !Array.isArray(v);
59
+ const nonEmpty = v => typeof v === "string" && v.trim() !== "";
60
+
61
+ /** The facts the reducer stamps off whatever payload carries them
62
+ * (stampSessionFacts), each with the test it is stamped under: anything else
63
+ * says nothing, so it never replaces a value that does. */
64
+ const STAMPED = [
65
+ ["model_context_window", v => typeof v === "number" && v > 0],
66
+ ["approval_policy", v => typeof v === "string" && v !== ""],
67
+ ["context_tokens", v => typeof v === "number" && v >= 0],
68
+ ];
69
+
70
+ const JOB_STATES = new Set(["working", "blocked", "done", "failed", "stopped"]);
71
+ const validActivity = a => isObject(a) && typeof a.text === "string" && a.text.trim() !== "" && Number.isFinite(a.at);
72
+
73
+ /**
74
+ * Two enrichment payloads of one kind, for one session, as the one payload
75
+ * that leaves a card where the two in a row would have.
76
+ *
77
+ * The rules are the reducer's own (transcript-events.ts), and the page folds
78
+ * what waits for a card by the same ones (parked-enrichment.ts):
79
+ * - a field the newer payload leaves out, or leaves undefined, says nothing;
80
+ * - the stamped facts, a ModelObserved's model and a SessionNamed's two
81
+ * names count only when they hold a value;
82
+ * - a context breakdown merges key by key, and subagent models by subagent;
83
+ * - a SessionNamed title that repeats the name is dropped, as the card drops it;
84
+ * - an activity line never goes backwards in time;
85
+ * - a job of `null` is the job gone, and one the reducer would refuse says nothing;
86
+ * - a usage total replaces the last one whole, its split by model included,
87
+ * and a UsageObserved without one says nothing about either.
88
+ */
89
+ export function foldEnrichment(prev, next) {
90
+ if (!isObject(prev)) return next;
91
+ const out = { ...prev };
92
+ for (const [k, v] of Object.entries(next)) if (v !== undefined) out[k] = v;
93
+ const keep = k => { if (k in prev) out[k] = prev[k]; else delete out[k]; };
94
+ for (const [k, ok] of STAMPED) if (!ok(next[k])) keep(k);
95
+ switch (next.hook_event_name) {
96
+ case "ModelObserved":
97
+ if (!(typeof next.model === "string" && next.model !== "")) keep("model");
98
+ if (isObject(prev.subagentModels) || isObject(next.subagentModels)) {
99
+ out.subagentModels = {
100
+ ...(isObject(prev.subagentModels) ? prev.subagentModels : {}),
101
+ ...(isObject(next.subagentModels) ? next.subagentModels : {}),
102
+ };
103
+ }
104
+ break;
105
+ case "ContextObserved": {
106
+ if (!isObject(next.context)) { keep("context"); break; }
107
+ const ctx = isObject(prev.context) ? { ...prev.context } : {};
108
+ for (const [k, v] of Object.entries(next.context)) if (typeof v === "number" || Array.isArray(v)) ctx[k] = v;
109
+ out.context = ctx;
110
+ break;
111
+ }
112
+ case "SessionNamed": {
113
+ const name = nonEmpty(next.sessionName) ? next.sessionName : (prev.sessionName ?? null);
114
+ let title = nonEmpty(next.sessionTitle) ? next.sessionTitle : (prev.sessionTitle ?? null);
115
+ if (nonEmpty(name) && nonEmpty(title) && title.trim().toLowerCase() === name.trim().toLowerCase()) title = null;
116
+ out.sessionName = name;
117
+ out.sessionTitle = title;
118
+ break;
119
+ }
120
+ case "ActivityObserved":
121
+ if (!validActivity(next.activity) || (validActivity(prev.activity) && next.activity.at < prev.activity.at)) keep("activity");
122
+ break;
123
+ case "JobObserved":
124
+ if (!(next.job === null || (isObject(next.job) && JOB_STATES.has(next.job.state)))) keep("job");
125
+ break;
126
+ case "UsageObserved":
127
+ if (!isObject(next.usage)) { keep("usage"); keep("usageByModel"); }
128
+ else if (next.usageByModel === undefined) delete out.usageByModel;
129
+ break;
130
+ }
131
+ return out;
132
+ }
133
+
134
+ function drop(sid, name) {
135
+ const kinds = kept.get(sid);
136
+ const entry = kinds?.get(name);
137
+ if (!entry) return;
138
+ keptChars -= entry.chars;
139
+ kinds.delete(name);
140
+ if (kinds.size === 0) kept.delete(sid);
141
+ }
142
+
143
+ function dropSession(sid) {
144
+ for (const name of [...(kept.get(sid)?.keys() ?? [])]) drop(sid, name);
145
+ }
146
+
147
+ /**
148
+ * One envelope the ring has just evicted. Anything that is not one of KINDS,
149
+ * or names no session, is not kept. `charged` is what the ring charged the
150
+ * envelope, so a payload no scanner could have written is let go without
151
+ * being walked a second time — and takes the older value of its kind with it,
152
+ * since a page told the older one would be told something the deck has since
153
+ * heard replaced.
154
+ */
155
+ export function noteEvicted(evt, charged = 0) {
156
+ const p = evt?.payload;
157
+ if (!isObject(p) || !KINDS.has(p.hook_event_name)) return;
158
+ const sid = p.session_id;
159
+ if (typeof sid !== "string" || sid === "") return;
160
+ const name = p.hook_event_name;
161
+ if (charged > MAX_EVICTED_VALUE_CHARS) { drop(sid, name); return; }
162
+ const prev = kept.get(sid)?.get(name);
163
+ const payload = foldEnrichment(prev?.payload, p);
164
+ const chars = payloadChars(payload);
165
+ drop(sid, name);
166
+ if (chars > MAX_EVICTED_VALUE_CHARS) return;
167
+ // Re-inserted, so both maps' own order is the order things were last folded
168
+ // in and the caps below drop the session heard from longest ago.
169
+ const kinds = kept.get(sid) ?? new Map();
170
+ kept.delete(sid);
171
+ kept.set(sid, kinds);
172
+ kinds.set(name, { seq: evt.seq, epoch: evt.epoch, receivedAt: evt.receivedAt, source: evt.source, payload, chars });
173
+ keptChars += chars;
174
+ while (kept.size > MAX_EVICTED_SESSIONS || keptChars > MAX_EVICTED_CHARS) {
175
+ const oldest = kept.keys().next().value;
176
+ if (oldest === undefined) break;
177
+ dropSession(oldest);
178
+ }
179
+ }
180
+
181
+ /** Forget everything kept — the ring was cleared. */
182
+ export function clearEvicted() {
183
+ kept.clear();
184
+ keptChars = 0;
185
+ }
186
+
187
+ /**
188
+ * What a page is missing, as envelopes, oldest first: for each of `sessions`,
189
+ * the newest value of each kind whose last fold is newer than `after` (what
190
+ * the page was last sent) and older than `before` (the oldest seq the page is
191
+ * about to be replayed). A page that saw every one of them is sent none.
192
+ *
193
+ * @param {{ after: number, before: number, sessions: Set<string> }} range
194
+ */
195
+ export function evictedEnrichment({ after, before, sessions }) {
196
+ const out = [];
197
+ for (const sid of sessions) {
198
+ const kinds = kept.get(sid);
199
+ if (!kinds) continue;
200
+ for (const e of kinds.values()) {
201
+ if (e.seq <= after || e.seq >= before) continue;
202
+ out.push({ seq: e.seq, epoch: e.epoch, receivedAt: e.receivedAt, source: e.source, payload: e.payload });
203
+ }
204
+ }
205
+ return out.sort((a, b) => a.seq - b.seq);
206
+ }
207
+
208
+ /** What is kept right now, for the suite: sessions, values and their weight. */
209
+ export function evictedStats() {
210
+ let values = 0;
211
+ for (const kinds of kept.values()) values += kinds.size;
212
+ return { sessions: kept.size, values, chars: keptChars };
213
+ }