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,622 @@
1
+ // What the deck learns about a session that its hooks never say: the model,
2
+ // the token spend, the session's name and recap, and the context it is
3
+ // carrying — each read off the session's transcript (or, for a Codex session's
4
+ // memory files, off its working tree) and sent on as a synthetic event.
5
+ //
6
+ // This lived in src/server/index.mjs, between the log rotation and the Codex
7
+ // rollout reader. pushEvent asks it to look at every live hook event, and it
8
+ // answers through pushEvent, which it reaches through event-sink.mjs so that
9
+ // the two need not import each other. Its per-session caches are its own:
10
+ // pushEvent asks knownModelId for the stamp, forgetSession
11
+ // (session-tracking.mjs) calls forgetEnrichment when it forgets a session, and
12
+ // handleClear (clear-route.mjs) calls clearEnrichmentGates on a Clear. The
13
+ // readers, the throttles and the emits are unchanged.
14
+ import { readdir } from "node:fs/promises";
15
+ import { join } from "node:path";
16
+ // Which memory files a session has in scope, CLAUDE.md or AGENTS.md — see
17
+ // memory-files.mjs.
18
+ import { scanAgentsMdFiles, scanClaudeMdFiles } from "./memory-files.mjs";
19
+ // Claude Code's "※ recap:" line — see session-recap.mjs.
20
+ import { RECAP_MARK } from "./session-recap.mjs";
21
+ // The shared transcript cursor every pass below reads through — see
22
+ // transcript-scan.mjs.
23
+ import { hasSpend, mergeUsageByModel, newUsageTotals, scanTranscript } from "./transcript-scan.mjs";
24
+ // event-pipeline.mjs's pushEvent, reached without importing it — see
25
+ // event-sink.mjs.
26
+ import { pushEvent } from "./event-sink.mjs";
27
+ // One read per session at a time, and none inside the window — the gate each
28
+ // pass below keeps. See session-read-gate.mjs.
29
+ import { sessionReadGate } from "./session-read-gate.mjs";
30
+
31
+ /**
32
+ * The session a hook payload is about and the transcript it names, or null when
33
+ * it names either one not at all. The model, usage, naming and context passes
34
+ * below each begin here, because each reads that transcript for that session
35
+ * and has nothing to do without both; it was the same four lines at the top of
36
+ * all four.
37
+ *
38
+ * Pure and exported for its test. The passes reduce it to "a read was
39
+ * scheduled or it was not", which a test can only watch through a throttle and
40
+ * a filesystem.
41
+ */
42
+ export function transcriptTarget(payload) {
43
+ if (!payload || typeof payload !== "object") return null;
44
+ const sid = payload.session_id;
45
+ const tp = payload.transcript_path;
46
+ if (!sid || !tp) return null;
47
+ return { sid, tp };
48
+ }
49
+
50
+ // ─── Model enrichment ────────────────────────────────────────────────────
51
+ // CC's hook payloads never carry the `model` field — but every hook
52
+ // references a `transcript_path` JSONL that contains lines like
53
+ // `"model":"claude-opus-4-7"`. We re-read the tail of that file on every
54
+ // event for the session (throttled to MODEL_READ_THROTTLE_MS), cache the
55
+ // resolved root + subagent models, and (a) inject `model` into subsequent
56
+ // payloads for that session before broadcasting, (b) emit a synthetic
57
+ // `ModelObserved` event whenever that set CHANGES, so the client backfills
58
+ // agents created before the model was resolved. The cache is the emit
59
+ // filter, not a read filter: reading once per session meant a subagent
60
+ // model that only appears after the root is known was never picked up.
61
+ const modelBySession = new Map(); // sessionId -> { rootModel, subsSig }
62
+ const MODEL_READ_THROTTLE_MS = 2500;
63
+ const modelReads = sessionReadGate(MODEL_READ_THROTTLE_MS);
64
+
65
+ /** The cache entry is `{ rootModel, subsSig }`, but `payload.model` is a
66
+ * model *string* — that is the only shape the client's recursive scanner
67
+ * reads. Returns the root model id, or null when the session resolved
68
+ * only subagent models and has no root model yet. */
69
+ export function cachedModelId(cached) {
70
+ const rootModel = cached?.rootModel;
71
+ return typeof rootModel === "string" && rootModel ? rootModel : null;
72
+ }
73
+
74
+ /** The root model already resolved for this session, or null — what pushEvent
75
+ * stamps on a payload that arrives without one. */
76
+ function knownModelId(sid) {
77
+ return cachedModelId(modelBySession.get(sid));
78
+ }
79
+
80
+ /** Read the main session JSONL. Returns the root model and any
81
+ * legacy-schema subagent models (older CC versions kept subagent blocks
82
+ * inline with `isSidechain:true` + `parentToolUseID`). Current CC versions
83
+ * store subagents in `<sessionDir>/subagents/agent-<id>.jsonl` — those are
84
+ * handled by `readSubagentsFromDir` below. */
85
+ export async function readModelFromTranscript(path) {
86
+ const state = await scanTranscript(path);
87
+ if (!state) return null;
88
+ const rootModel = state.rootModel ?? state.lastModel;
89
+ const subagentModels = { ...state.subagentModels };
90
+ if (!rootModel && Object.keys(subagentModels).length === 0) return null;
91
+ return { rootModel, subagentModels };
92
+ }
93
+
94
+ /** Newer CC schema (~2026-06): each subagent turn writes its OWN file at
95
+ * `<projects>/<slug>/<sessionId>/subagents/agent-<agentId>.jsonl` with a
96
+ * sidecar `.meta.json` carrying `{agentType, description}`. The hook
97
+ * payload's `agent_id` matches the file's <agentId>, so the reducer can
98
+ * attribute via the existing `subagentModels` map (it keys by parentToolUseId
99
+ * but the reducer looks up `${sessionId}::${key}` and the subagent node id
100
+ * is built from `agent_id` — identical lookup either way).
101
+ *
102
+ * Returns `{ models: { [agentId]: model }, usage: <summed totals|null> }` over
103
+ * every agent-*.jsonl in the directory, or null when there is no such
104
+ * directory — which is every Codex session and every legacy-schema Claude one.
105
+ *
106
+ * WHY IT RETURNS USAGE AND NOT ONLY MODELS (#685). `scanTranscript` folds a
107
+ * file's usage totals whether or not anyone asks for them, so this walk has
108
+ * been computing every subagent's spend and dropping it on the floor, while
109
+ * the session's totals came from the main transcript alone — and the main
110
+ * transcript does not restate a subagent's turns. Measured on one real
111
+ * session on the reporter's machine: 40.4M cache-read tokens in the main
112
+ * JSONL against 217.7M across its twenty subagent files, so the deck was
113
+ * reporting about a sixth of what the session actually cost.
114
+ *
115
+ * WHY THE TOTALS ARE SUMMED HERE RATHER THAN SHIPPED PER AGENT. The obvious
116
+ * shape is a `{ [agentId]: totals }` map so each subagent card can price its
117
+ * own spend, and the cost of that shape is set by CC and not by us: #611
118
+ * measured live sessions holding 198 and 133 of these files, and this machine
119
+ * has one holding 397. At ~200 bytes an entry on a 2.5 s cadence that is
120
+ * ~80 KB per pass into the SSE fan-out, the event ring and events.jsonl —
121
+ * ~115 MB an hour into a log that rotates at 50 MB. One summed object is
122
+ * ~150 bytes whatever the session delegates, so the session total is exact
123
+ * and constant-cost, and per-card attribution stays a separate question.
124
+ *
125
+ * `usageByModel` is summed on the same terms and for the same reason (#686):
126
+ * a subagent runs on whatever model its Task was given, which is routinely not
127
+ * its parent's — the deck already reads a per-agent `models` map three lines
128
+ * up precisely because of that — so folding delegated tokens into the session
129
+ * total without their model would price a Haiku subagent's spend at the root's
130
+ * Opus rate. It stays constant-cost the way the flat total does: the key count
131
+ * is the number of MODELS a session touched, two or three, not the number of
132
+ * files it delegated to. */
133
+ async function readSubagentsFromDir(transcriptPath) {
134
+ // Subagent dir sits next to the main jsonl: <dir>/<sessionId>/subagents/
135
+ // Derive from transcript_path by stripping the .jsonl suffix.
136
+ if (!transcriptPath || typeof transcriptPath !== "string") return null;
137
+ const sessionDir = transcriptPath.replace(/\.jsonl$/i, "");
138
+ const subDir = join(sessionDir, "subagents");
139
+ let entries;
140
+ try { entries = await readdir(subDir); } catch { return null; }
141
+ const models = {};
142
+ const usage = newUsageTotals();
143
+ const usageByModel = {};
144
+ let spent = false;
145
+ for (const f of entries) {
146
+ if (!/^agent-([0-9a-f]+)\.jsonl$/i.test(f)) continue;
147
+ const agentId = f.replace(/^agent-/, "").replace(/\.jsonl$/i, "");
148
+ const full = join(subDir, f);
149
+ try {
150
+ // Same incremental cursor as the main transcript. Last-seen claude-*
151
+ // model wins — subagents may switch model mid-turn (Sonnet → Haiku for
152
+ // tool-call fallback etc.).
153
+ const state = await scanTranscript(full);
154
+ if (!state) continue;
155
+ if (state.lastModel) models[agentId] = state.lastModel;
156
+ // The cursor makes this cumulative and idempotent: `state.usage` is the
157
+ // whole file's totals however many passes it took to fold them, so
158
+ // re-summing every pass restates the same number rather than growing it.
159
+ for (const k of Object.keys(usage)) usage[k] += state.usage[k];
160
+ // Idempotent on the same argument as the line above: each file's split is
161
+ // its own cumulative totals, so re-summing every pass restates the map
162
+ // rather than growing it.
163
+ mergeUsageByModel(usageByModel, state.usageByModel);
164
+ if (hasSpend(state.usage)) spent = true;
165
+ } catch { /* skip unreadable file */ }
166
+ }
167
+ const hasModels = Object.keys(models).length > 0;
168
+ if (!hasModels && !spent) return null;
169
+ return {
170
+ models: hasModels ? models : null,
171
+ usage: spent ? usage : null,
172
+ usageByModel: spent ? usageByModel : null,
173
+ };
174
+ }
175
+
176
+ // One walk of `<sessionDir>/subagents/` serves both the model pass and the
177
+ // usage pass. The two are throttled independently but fire from the same hook
178
+ // events, so without this the directory — up to a few hundred files — would be
179
+ // listed and stat-ed twice per window for the same answer. The TTL sits under
180
+ // MODEL_READ_THROTTLE_MS so two callers inside one window share a walk and the
181
+ // next window always gets a fresh one.
182
+ const subagentDirScans = new Map(); // transcriptPath -> { at, promise }
183
+ const SUBAGENT_DIR_TTL_MS = 2000;
184
+
185
+ function scanSubagentDir(transcriptPath) {
186
+ if (!transcriptPath || typeof transcriptPath !== "string") return Promise.resolve(null);
187
+ const now = Date.now();
188
+ // Expiry is the eviction rule: an entry outlives its window by nothing, so
189
+ // the map holds at most one entry per session read in the last two seconds.
190
+ for (const [k, v] of subagentDirScans) {
191
+ if (now - v.at >= SUBAGENT_DIR_TTL_MS) subagentDirScans.delete(k);
192
+ }
193
+ const memo = subagentDirScans.get(transcriptPath);
194
+ if (memo) return memo.promise;
195
+ const promise = readSubagentsFromDir(transcriptPath).catch(() => null);
196
+ subagentDirScans.set(transcriptPath, { at: now, promise });
197
+ return promise;
198
+ }
199
+
200
+ function maybeResolveModel(payload) {
201
+ const target = transcriptTarget(payload);
202
+ if (!target) return;
203
+ const { sid, tp } = target;
204
+ // Re-read on every event for this session — the cache was preventing us
205
+ // from picking up subagent models that arrive after the root is known.
206
+ // Throttle so we don't thrash the filesystem.
207
+ modelReads.run(sid, () => Promise.all([readModelFromTranscript(tp), scanSubagentDir(tp)])
208
+ .then(([result, dir]) => {
209
+ const rootModel = result?.rootModel ?? null;
210
+ // Merge legacy (inline isSidechain) + new (subagents/ dir) maps. Dir
211
+ // wins on conflict since current CC only writes to the dir.
212
+ const subagentModels = { ...(result?.subagentModels ?? {}), ...(dir?.models ?? {}) };
213
+ if (!rootModel && Object.keys(subagentModels).length === 0) return;
214
+ const prev = modelBySession.get(sid);
215
+ const subsSig = JSON.stringify(subagentModels);
216
+ if (prev && prev.rootModel === rootModel && prev.subsSig === subsSig) return;
217
+ modelBySession.set(sid, { rootModel, subsSig });
218
+ pushEvent({
219
+ hook_event_name: "ModelObserved",
220
+ session_id: sid,
221
+ model: rootModel,
222
+ subagentModels,
223
+ }, "internal");
224
+ }));
225
+ }
226
+
227
+ // ─── Usage enrichment ────────────────────────────────────────────────────
228
+ // Same story as the model: token counts (input/output/cache) are missing
229
+ // from every CC hook payload but present on every assistant message in
230
+ // the transcript JSONL as a `"usage":{…}` block. We sum them across the
231
+ // whole transcript and ship a synthetic UsageObserved event so the
232
+ // session's root agent gets accurate cumulative usage (and therefore the
233
+ // cost columns actually have something to multiply by).
234
+ const USAGE_READ_THROTTLE_MS = 2500;
235
+ const usageReads = sessionReadGate(USAGE_READ_THROTTLE_MS);
236
+
237
+ // Every entry carries its own usage object and we sum every occurrence, so
238
+ // the totals are cumulative over the whole transcript — the running state
239
+ // keeps them across passes and each pass only adds the newly appended blocks.
240
+ //
241
+ // ONE FILE, which is the whole file and no more. This reads the path it is
242
+ // given; the session's delegated spend lives in the sibling `subagents/`
243
+ // directory and is added by `sessionUsageTotals` below. Kept separate because
244
+ // the two are read on different cadences by different callers and the tests
245
+ // that pin the cursor arithmetic pin it one file at a time.
246
+ export async function readUsageFromTranscript(path) {
247
+ const state = await scanTranscript(path);
248
+ if (!state) return null;
249
+ const totals = { ...state.usage };
250
+ if (!hasSpend(totals)) return null;
251
+ return totals;
252
+ }
253
+
254
+ /** One transcript's totals broken out by the model that produced them, or null
255
+ * when the scan attributed nothing.
256
+ *
257
+ * A SECOND EXPORT rather than one more key on the object above, and the reason
258
+ * is that object's contract: `readUsageFromTranscript` is asserted with
259
+ * `toEqual` on its whole shape, so a key added to it is a key every caller and
260
+ * every fixture has to learn about. This costs no extra read — `scanTranscript`
261
+ * hands back the state it already folded and coalesces concurrent callers onto
262
+ * one pass — so the pair reads the file exactly as often as the single call
263
+ * did. */
264
+ export async function readUsageByModelFromTranscript(path) {
265
+ const state = await scanTranscript(path);
266
+ if (!state) return null;
267
+ const out = {};
268
+ for (const [model, u] of Object.entries(state.usageByModel)) {
269
+ if (!hasSpend(u)) continue;
270
+ out[model] = { ...u };
271
+ }
272
+ return Object.keys(out).length ? out : null;
273
+ }
274
+
275
+ /** The session's whole spend split by model — its own turns plus every
276
+ * subagent's, merged (#686).
277
+ *
278
+ * The counterpart of `sessionUsageTotals` below and read from the same two
279
+ * places, so the split and the total it splits are always a description of the
280
+ * same bytes. A subagent runs on the model its Task was given, which is
281
+ * routinely not its parent's, so a session sum that carried only the root's
282
+ * models would price delegated tokens at whatever the root is on now — the
283
+ * same mistake as the one being fixed, one level in. */
284
+ export async function sessionUsageByModel(transcriptPath) {
285
+ const [own, dir] = await Promise.all([
286
+ readUsageByModelFromTranscript(transcriptPath),
287
+ scanSubagentDir(transcriptPath),
288
+ ]);
289
+ if (!own && !dir?.usageByModel) return null;
290
+ const out = mergeUsageByModel({}, own);
291
+ mergeUsageByModel(out, dir?.usageByModel);
292
+ return Object.keys(out).length ? out : null;
293
+ }
294
+
295
+ /**
296
+ * Everything a Claude session has spent: its own turns plus every subagent it
297
+ * ran. Returns null when the session has spent nothing yet.
298
+ *
299
+ * WHAT THIS NUMBER MEANS, and why it is one number (#685). A session is a
300
+ * bill, and delegating work does not move any of it somewhere else — the
301
+ * subagent's tokens are charged to the same account on the same invoice. The
302
+ * two halves live in two places on disk because CC writes them there:
303
+ * `<sessionId>.jsonl` for the root's turns, `<sessionId>/subagents/agent-*.jsonl`
304
+ * for each delegated one, with no overlap between them (verified against real
305
+ * transcripts: a session holding 397 subagent files had zero `isSidechain`
306
+ * lines in its main JSONL). Summing the two therefore counts every token
307
+ * exactly once, and the reducer ASSIGNS the result rather than adding it, so a
308
+ * pass that lands twice restates the same total instead of doubling it.
309
+ *
310
+ * The one place the two files did overlap is the `toolUseResult` block a
311
+ * finished Task leaves on the parent's line, which restates the subagent's
312
+ * last turn; `billedUsageText` cuts that out of the scan so this sum stays a
313
+ * sum of distinct tokens.
314
+ */
315
+ export async function sessionUsageTotals(transcriptPath) {
316
+ const [own, dir] = await Promise.all([
317
+ readUsageFromTranscript(transcriptPath),
318
+ scanSubagentDir(transcriptPath),
319
+ ]);
320
+ const delegated = dir?.usage ?? null;
321
+ if (!own && !delegated) return null;
322
+ const totals = own ? { ...own } : newUsageTotals();
323
+ if (delegated) for (const k of Object.keys(totals)) totals[k] += delegated[k] ?? 0;
324
+ return totals;
325
+ }
326
+
327
+ function maybeResolveUsage(payload) {
328
+ const target = transcriptTarget(payload);
329
+ if (!target) return;
330
+ const { sid, tp } = target;
331
+ usageReads.run(sid, () => Promise.all([sessionUsageTotals(tp), sessionUsageByModel(tp)])
332
+ .then(([usage, usageByModel]) => {
333
+ if (!usage) return;
334
+ // `usageByModel` rides on the same event because it is the same
335
+ // measurement, read from the same two places by the same walk — the main
336
+ // transcript and the `subagents/` directory — so the split and the total
337
+ // it splits cannot describe two different moments of the session. Sent
338
+ // even when null: an absent split has to CLEAR a stale one on the client,
339
+ // for the reason the flat totals are assigned rather than added.
340
+ pushEvent({ hook_event_name: "UsageObserved", session_id: sid, usage, usageByModel }, "internal");
341
+ }));
342
+ }
343
+
344
+ // ─── Session-name enrichment ─────────────────────────────────────────────
345
+ // WHY THIS IS NOT A TAIL READ. The obvious shape for "get the newest naming
346
+ // record out of a 46 MB file" is to read the last N KB and parse backwards, and
347
+ // it is the wrong shape here for two reasons, one of them measured.
348
+ //
349
+ // The measured one: the density is not uniform, so no N is safe. Back-scanning
350
+ // from an arbitrary point in the 46.4 MB transcript to the nearest `ai-title`
351
+ // costs p50 38 KB but p95 227 KB, p99 511 KB and 723 KB worst case, because a
352
+ // single line in that file reaches 710 KB — one big tool result evicts every
353
+ // naming record from any window you picked. A 256 KB tail covers 95.9% of
354
+ // positions, 512 KB covers 99.1%, and the last percent still needs a megabyte.
355
+ //
356
+ // The structural one: it would be a second reader of bytes this process is
357
+ // already reading. `scanTranscript` keeps a per-path CURSOR and folds each
358
+ // appended line exactly once, for the model, the usage totals and the context
359
+ // counts alike. Folding two more fields into that pass (see
360
+ // `foldSessionNamingLine`) costs no read at all, sees every record rather than
361
+ // a window of them, and cannot be defeated by a 710 KB line.
362
+ //
363
+ // That also settles the trigger. With a tail read the trigger IS the cost
364
+ // control, so you have to pick a boundary and `Stop` is the honest one. With a
365
+ // cursor the bytes are read once whoever asks, so the trigger only decides how
366
+ // LATE the name appears — and gating on `Stop` would hold a name the scan
367
+ // already has until the turn ends, for a saving of zero. So this runs off any
368
+ // hook event like its three neighbours, throttled per session, and the throttle
369
+ // matches MODEL_READ_THROTTLE_MS so the two passes coincide and share one
370
+ // in-flight scan.
371
+ //
372
+ // The emit is what is actually kept rare, and it is gated on a CHANGE: with 685
373
+ // records carrying 2 distinct values, a per-pass emit would be ~683 events
374
+ // saying nothing. Sessions that never get named emit nothing at all.
375
+ const nameBySession = new Map(); // sid -> `${agentName}\0${aiTitle}`
376
+ const nameReads = sessionReadGate(MODEL_READ_THROTTLE_MS);
377
+
378
+ /** The naming the cursor has folded so far, or null when the scan has nothing.
379
+ *
380
+ * NOT exported, and the comment here used to say it was — "beside
381
+ * readContextFromTranscript … the rule is worth pinning directly rather than
382
+ * through a live server" — which stated a test contract no test had (#798).
383
+ * What is worth pinning is the parsing, and that is `foldSessionNamingLine`,
384
+ * which IS exported and which session-name.test.ts drives line at a time the
385
+ * way `foldTranscriptLine` does. This wrapper is a scan and two null checks.
386
+ *
387
+ * The recap comes back beside the naming because it comes off the same scan:
388
+ * `naming` is null when the transcript has no naming record, `recap` when no
389
+ * recap is standing. See session-recap.mjs, and `noteRecap` below. */
390
+ async function readSessionNamingFromTranscript(path) {
391
+ const state = await scanTranscript(path);
392
+ if (!state) return null;
393
+ const naming = (state.agentName || state.aiTitle)
394
+ ? { agentName: state.agentName, aiTitle: state.aiTitle }
395
+ : null;
396
+ return { naming, recap: state.recap ?? null };
397
+ }
398
+
399
+ function maybeResolveSessionName(payload) {
400
+ const target = transcriptTarget(payload);
401
+ if (!target) return;
402
+ const { sid, tp } = target;
403
+ nameReads.run(sid, () => readSessionNamingFromTranscript(tp)
404
+ .then(read => {
405
+ if (!read) return;
406
+ noteRecap(sid, read.recap);
407
+ const naming = read.naming;
408
+ if (!naming) return;
409
+ const sig = `${naming.agentName ?? ""}\u0000${naming.aiTitle ?? ""}`;
410
+ if (nameBySession.get(sid) === sig) return;
411
+ nameBySession.set(sid, sig);
412
+ pushEvent({
413
+ hook_event_name: "SessionNamed",
414
+ session_id: sid,
415
+ sessionName: naming.agentName ?? null,
416
+ sessionTitle: naming.aiTitle ?? null,
417
+ }, "internal");
418
+ }));
419
+ }
420
+
421
+ // ─── Session recap ───────────────────────────────────────────────────────
422
+ // Claude Code's "※ recap:" line (see session-recap.mjs). It comes off the
423
+ // transcript cursor like the naming above, and its emit is gated the same way:
424
+ // on a CHANGE, keyed by the recap's own timestamp, so a pass that re-reads a
425
+ // recap already sent says nothing. A session that never had one emits nothing
426
+ // at all — the first word about a session's recap is always a recap.
427
+ //
428
+ // TWO ROADS IN, BECAUSE NO HOOK FIRES WHEN IT LANDS. The cursor runs off hook
429
+ // events, and a recap is written three minutes into a silence that has none.
430
+ // So the output watch, which already stats these files between events, hands
431
+ // every tail it reads to `onRecapTail`, and a tail carrying a recap asks the
432
+ // cursor to fold the file. The cursor stays the one place the rule lives: a
433
+ // recap a later turn retired is retired whichever road found it.
434
+ const recapBySession = new Map(); // sid -> `${at}` last sent, "" once retired
435
+
436
+ function noteRecap(sid, recap) {
437
+ const sig = recap ? String(recap.at) : "";
438
+ const prev = recapBySession.get(sid);
439
+ if (prev === sig) return;
440
+ if (prev === undefined && !recap) return;
441
+ recapBySession.set(sid, sig);
442
+ pushEvent({
443
+ hook_event_name: "SessionRecapped",
444
+ session_id: sid,
445
+ recap: recap ? { text: recap.text, at: recap.at } : null,
446
+ }, "internal");
447
+ }
448
+
449
+ /** The output watch's tap. Only a tail carrying the recap mark costs a scan,
450
+ * and the scan is the cursor's own: it folds the bytes it has not seen yet. */
451
+ function onRecapTail(sid, text, path) {
452
+ if (!path || !text.includes(RECAP_MARK)) return;
453
+ scanTranscript(path)
454
+ .then(state => { if (state) noteRecap(sid, state.recap ?? null); })
455
+ .catch(() => {});
456
+ }
457
+
458
+ // ─── Context enrichment ──────────────────────────────────────────────────
459
+ // Approximation of `/context` since CC doesn't expose its breakdown via
460
+ // hooks. We scan the transcript JSONL for message counts (user / assistant
461
+ // / tool_use / tool_result / system-reminders) and walk up from cwd for
462
+ // any CLAUDE.md files in scope. Cumulative token totals come from
463
+ // UsageObserved; this scan produces the structural counts ("what does the
464
+ // context contain") plus the current window size — currentContextTokens, the
465
+ // last usage block after the most recent /clear or /compact, which is what
466
+ // the context donut and the modal's percentage are drawn from.
467
+ const CONTEXT_READ_THROTTLE_MS = 4000;
468
+ const contextReads = sessionReadGate(CONTEXT_READ_THROTTLE_MS);
469
+
470
+ // The counts reset at every `/clear` or `/compact` marker (see foldContextLine
471
+ // in transcript-scan.mjs): CC resets its in-memory window there while the
472
+ // JSONL keeps growing, and reading the pre-reset blocks made the donut report
473
+ // ~100% on an empty context.
474
+ export async function readContextFromTranscript(path) {
475
+ const state = await scanTranscript(path);
476
+ // Nothing folded yet — the file is empty, unreadable, or has no complete
477
+ // line. Callers treat that as "no breakdown", same as before.
478
+ if (!state || state.offset === 0) return null;
479
+ return { ...state.ctx };
480
+ }
481
+
482
+ function maybeResolveContext(payload) {
483
+ const target = transcriptTarget(payload);
484
+ if (!target) return;
485
+ const { sid, tp } = target;
486
+ const cwd = payload.cwd;
487
+ contextReads.run(sid, () => Promise.all([readContextFromTranscript(tp), scanClaudeMdFiles(cwd)])
488
+ .then(([breakdown, memoryFiles]) => {
489
+ if (!breakdown && (!memoryFiles || memoryFiles.length === 0)) return;
490
+ pushEvent({
491
+ hook_event_name: "ContextObserved",
492
+ session_id: sid,
493
+ context: {
494
+ ...(breakdown ?? {}),
495
+ memoryFiles: memoryFiles ?? [],
496
+ },
497
+ }, "internal");
498
+ }));
499
+ }
500
+
501
+ // Throttle state for the Codex half of the same question. A gate of its own
502
+ // rather than sharing maybeResolveContext's, because the two run on different
503
+ // triggers — a hook payload there, a batch of appended rollout lines here — and
504
+ // one session cannot be both.
505
+ const codexMemoryReads = sessionReadGate(CONTEXT_READ_THROTTLE_MS);
506
+
507
+ /**
508
+ * Emit the memory files a Codex session has in scope, throttled per session.
509
+ *
510
+ * WHY THIS IS NOT maybeResolveContext (#399). That function early-returns
511
+ * without `payload.transcript_path`, a field only Claude Code sends, and it
512
+ * would scan for CLAUDE.md if it got that far. It is also unreachable from here
513
+ * on a second count: pushEvent gates all of its enrichment on `source ===
514
+ * "hook"`, and the Codex rollout watcher emits with source "codex" because it is
515
+ * not a hook stream at all. So this is called from the watcher's own scan loop,
516
+ * next to the lazy root, rather than off the back of an event.
517
+ *
518
+ * There is no structural breakdown alongside the file list, and that is the
519
+ * honest answer rather than a gap: the counts the Claude side reports —
520
+ * user/assistant messages, tool uses, tool results, system-reminders — come from
521
+ * a regex scan of a transcript this deck has read from byte zero, and the
522
+ * watcher deliberately SKIPS a pre-existing session's history at startup
523
+ * (`state.offset = st.size`). Counting from the moment the deck attached would
524
+ * produce five confident numbers that are all short by however much of the
525
+ * session happened first, on a panel whose whole purpose is to say what is in
526
+ * the window. ContextModal says so in the slot those counts would have used;
527
+ * see codex-approval.ts for the same move on a different unanswerable question.
528
+ *
529
+ * WHY `persist` IS A PARAMETER (#447). Every other event the rollout watcher
530
+ * produces goes out through emitCodexEvent, which carries the per-batch verdict
531
+ * of writesCodexLog: several decks tailing one rollout all DRAW it, and exactly
532
+ * one of them appends it to the events.jsonl they share. This function pushed
533
+ * with no opts at all, so pushEvent fell back to writesLogFor — and that answers
534
+ * from `foreignSessions`, a set only ever filled by noteLogWriter off an
535
+ * incoming hook POST marked `?persist=0`. A Codex rollout never touches an HTTP
536
+ * handler and Codex hooks are not installed any more (installer.mjs keeps the
537
+ * provider for uninstall only), so a Codex session id can never appear in that
538
+ * set and every deck answered "yes, mine" for this one event. The caller already
539
+ * holds the verdict for the whole batch, so it is threaded in rather than
540
+ * re-derived: recomputing it here would cost a second directory listing and
541
+ * could disagree with the roots and tool calls the same batch just emitted.
542
+ *
543
+ * Omitting the argument keeps the old behaviour — write. That is the same
544
+ * fail-safe writesCodexLog itself takes when a deck cannot find its own
545
+ * discovery record: a line written twice is recoverable, a deck that quietly
546
+ * records nothing is not.
547
+ */
548
+ function maybeResolveCodexMemory(sid, cwd, persist) {
549
+ if (!sid || !cwd) return;
550
+ codexMemoryReads.run(sid, () => scanAgentsMdFiles(cwd)
551
+ .then(memoryFiles => {
552
+ // Nothing found is not a fact worth an event: the reducer merges a
553
+ // ContextObserved into whatever the session already had, and an empty list
554
+ // would only ever overwrite a real one with nothing. A repo that grows its
555
+ // first AGENTS.md mid-session is picked up by the next throttled pass.
556
+ if (!memoryFiles.length) return;
557
+ // `persist` false means another deck tailing this same rollout was elected
558
+ // to record it. The event is still buffered and broadcast from here, so
559
+ // every deck's context modal lists the AGENTS.md files — it is only the
560
+ // second copy on disk that is dropped, exactly as emitCodexEvent does it.
561
+ pushEvent({
562
+ hook_event_name: "ContextObserved",
563
+ session_id: sid,
564
+ provider: "codex",
565
+ context: { memoryFiles },
566
+ }, "internal", { persist });
567
+ }));
568
+ }
569
+
570
+ /**
571
+ * Everything the enrichment passes above keep for one session, dropped. Its
572
+ * one caller is forgetSession, which says when a session is forgotten and why.
573
+ */
574
+ function forgetEnrichment(sid) {
575
+ modelBySession.delete(sid);
576
+ // The two the session-naming work added (#520/#522) and did not list here.
577
+ // Both are keyed by session id and nothing else ever removed an entry, which
578
+ // is the exact leak the comment above forgetSession (session-tracking.mjs)
579
+ // says this mechanism exists to end — every sibling cache is capped at
580
+ // MAX_TRACKED_SESSIONS and these two were not. The functional half is worse
581
+ // than the leak: nameBySession gates the SessionNamed emit on "has this
582
+ // changed", so a live session evicted past the cap and then heard from again
583
+ // re-emits its model (modelBySession was cleared) and never re-emits its
584
+ // name. A tab that connects after the event ring has rolled past the
585
+ // original SessionNamed shows that session unnamed for the rest of its life.
586
+ nameBySession.delete(sid);
587
+ // The recap's gate, for the same reason — see noteRecap.
588
+ recapBySession.delete(sid);
589
+ nameReads.forget(sid);
590
+ modelReads.forget(sid);
591
+ usageReads.forget(sid);
592
+ contextReads.forget(sid);
593
+ codexMemoryReads.forget(sid);
594
+ }
595
+
596
+ /**
597
+ * Every session's "has this changed" gates, and the read stamps that would hold
598
+ * the next re-read back, dropped at once. Its one caller is handleClear, which
599
+ * says why a Clear has to.
600
+ */
601
+ function clearEnrichmentGates() {
602
+ nameBySession.clear();
603
+ recapBySession.clear();
604
+ modelBySession.clear();
605
+ // The read stamps go with them. Clearing only the signatures would leave
606
+ // the next hook event inside MODEL_READ_THROTTLE_MS, so the transcript
607
+ // would not be re-read at all and the name would stay missing until the
608
+ // throttle expired — a clear followed by a keystroke is exactly when a
609
+ // user is watching.
610
+ nameReads.forgetAll();
611
+ modelReads.forgetAll();
612
+ }
613
+
614
+ // What event-pipeline.mjs, session-tracking.mjs, clear-route.mjs and the
615
+ // rollout watcher call besides the readers exported above. Listed rather than
616
+ // marked at each declaration, so the declarations read as they did where they
617
+ // came from.
618
+ export {
619
+ clearEnrichmentGates, forgetEnrichment, knownModelId, maybeResolveCodexMemory,
620
+ maybeResolveContext, maybeResolveModel, maybeResolveSessionName, maybeResolveUsage,
621
+ onRecapTail,
622
+ };
@@ -0,0 +1,49 @@
1
+ // The gate every enrichment pass puts in front of its read: one read per
2
+ // session at a time, and none sooner than a window after the last one began.
3
+ //
4
+ // It was written out six times — the model, usage, session-name and context
5
+ // passes in session-enrichment.mjs, the Codex memory scan beside them, and the
6
+ // Codex usage read in codex-enrichment.mjs — each as its own pair of a
7
+ // `last…ReadAt` map and a `pending…Reads` set with the same eight lines in
8
+ // front of its read. Six copies of one rule are six places for it to drift,
9
+ // and the only thing that differed between them was the window. So the pair
10
+ // lives here, once, behind the three operations its callers used on it.
11
+
12
+ /**
13
+ * A per-session read gate with a window of `ms`.
14
+ *
15
+ * `run(sid, read)` calls `read()` unless a read for `sid` is still in flight or
16
+ * the last one began less than `ms` ago. The stamp is taken as the read starts,
17
+ * so a read that fails or finds nothing still holds the next one back for the
18
+ * window — that is the throttle. `read` is called before `run` returns, as
19
+ * every copy called it, and returns a promise. Whatever that rejects with is
20
+ * swallowed, because nothing awaits a pass fired off the hot path, and the
21
+ * session is let through again once it settles.
22
+ *
23
+ * `forget(sid)` drops one session's stamp and `forgetAll()` drops every stamp,
24
+ * so the next event for it reads at once. Neither touches a read in flight: it
25
+ * settles and lets its session through exactly as it would have.
26
+ */
27
+ export function sessionReadGate(ms) {
28
+ const lastReadAt = new Map(); // sid -> ms timestamp the last read began
29
+ const pending = new Set(); // sid currently being read
30
+ return {
31
+ run(sid, read) {
32
+ if (pending.has(sid)) return;
33
+ const now = Date.now();
34
+ const last = lastReadAt.get(sid) ?? 0;
35
+ if (now - last < ms) return;
36
+ lastReadAt.set(sid, now);
37
+ pending.add(sid);
38
+ read()
39
+ .catch(() => {})
40
+ .finally(() => pending.delete(sid));
41
+ },
42
+ forget(sid) {
43
+ lastReadAt.delete(sid);
44
+ },
45
+ forgetAll() {
46
+ lastReadAt.clear();
47
+ },
48
+ };
49
+ }