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.
Files changed (178) hide show
  1. package/README.md +4 -3
  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-n63GjLhv.js +1 -0
  15. package/dist/web/assets/UsageHistoryModal-DEwBX4Fk.js +1 -0
  16. package/dist/web/assets/index-CZJf1Cpp.js +299 -0
  17. package/dist/web/assets/index-D4q1ElXU.css +1 -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 +40 -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 +7 -3
  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 +2 -2
  67. package/src/server/deck-prefs.mjs +58 -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 +174 -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 +110 -0
  134. package/src/server/prefs-give-back.mjs +206 -0
  135. package/src/server/prefs-path.mjs +14 -0
  136. package/src/server/prefs-refusal.mjs +86 -0
  137. package/src/server/prefs-routes.mjs +99 -0
  138. package/src/server/prefs-set-aside.mjs +86 -0
  139. package/src/server/prefs-state.mjs +70 -0
  140. package/src/server/process-command.mjs +104 -0
  141. package/src/server/process-list.mjs +547 -0
  142. package/src/server/provider-status.mjs +307 -0
  143. package/src/server/pulse-line.mjs +132 -0
  144. package/src/server/purge-key.mjs +7 -7
  145. package/src/server/quota-cli.mjs +174 -0
  146. package/src/server/quota-oauth.mjs +190 -0
  147. package/src/server/quota-shape.mjs +229 -0
  148. package/src/server/quota-store-resets.mjs +206 -0
  149. package/src/server/quota.mjs +123 -509
  150. package/src/server/relay-guard.mjs +3 -3
  151. package/src/server/request-gates.mjs +441 -0
  152. package/src/server/restart-note.mjs +180 -0
  153. package/src/server/retire-sound-hook.mjs +3 -3
  154. package/src/server/ring-bounds.mjs +215 -0
  155. package/src/server/running-deck.mjs +1 -1
  156. package/src/server/running-version.mjs +24 -0
  157. package/src/server/self-update.mjs +39 -1371
  158. package/src/server/session-enrichment.mjs +622 -0
  159. package/src/server/session-read-gate.mjs +49 -0
  160. package/src/server/session-tracking.mjs +242 -0
  161. package/src/server/sse-clients.mjs +222 -0
  162. package/src/server/stale-discovery.mjs +30 -0
  163. package/src/server/static-serve.mjs +81 -0
  164. package/src/server/supervisor.mjs +73 -12
  165. package/src/server/system-metrics.mjs +169 -1935
  166. package/src/server/term.mjs +11 -249
  167. package/src/server/thermal-metrics.mjs +520 -0
  168. package/src/server/thermal-sampler.mjs +204 -0
  169. package/src/server/token-redact.mjs +123 -0
  170. package/src/server/transcript-gate.mjs +107 -0
  171. package/src/server/transcript-scan.mjs +563 -0
  172. package/src/server/usage-routes.mjs +108 -0
  173. package/src/server/uv-bootstrap.mjs +1 -1
  174. package/src/server/wordmark.mjs +127 -0
  175. package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
  176. package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
  177. package/dist/web/assets/index-Ck2RAla3.css +0 -1
  178. package/dist/web/assets/index-jmrcEsCj.js +0 -298
@@ -0,0 +1,563 @@
1
+ // Reading a Claude Code transcript: one cursor per JSONL, and the fold of each
2
+ // appended line into the running totals. The bounded chunk read that moves the
3
+ // cursor is jsonl-chunks.mjs, which the Codex side reads rollouts through as
4
+ // well, and the rule for which paths are worth opening at all is
5
+ // transcript-gate.mjs.
6
+ //
7
+ // These lived in src/server/index.mjs, between the log rotation and the model
8
+ // enrichment, and nothing in them ever reached the event pipeline: the
9
+ // enrichment passes (session-enrichment.mjs now) ask `scanTranscript` for a
10
+ // state and decide what to emit from it, the Codex watcher borrows the chunked
11
+ // reader, and pushEvent asks the path gate before any of that runs. So the whole
12
+ // reader moved as one piece, with its caches, and its callers import the
13
+ // operations they call. The names are the ones the bodies always used, and
14
+ // index.mjs still re-exports the five it exported: two from here, and the
15
+ // rest from jsonl-chunks.mjs and transcript-gate.mjs, where they have since
16
+ // moved.
17
+ import { stat } from "node:fs/promises";
18
+ import { resolve } from "node:path";
19
+ // Claude Code's "※ recap:" line — see session-recap.mjs.
20
+ import { foldRecapLine } from "./session-recap.mjs";
21
+ // The bounded, cursor-moving chunk read every pass below goes through — see
22
+ // jsonl-chunks.mjs.
23
+ import { readAppendedLines } from "./jsonl-chunks.mjs";
24
+
25
+ // ─── Incremental transcript scanning ─────────────────────────────────────
26
+ // Model, usage and context enrichment all derive from the same append-only
27
+ // transcript JSONL, and each one used to re-read and re-parse the whole file
28
+ // on every throttled pass. A session's transcript grows to tens of MB, so
29
+ // that cost O(n) per pass, O(n²) over the session, and — because the parse
30
+ // loop is one synchronous block — it stalled SSE broadcasts and /api/event
31
+ // ingest for as long as it ran.
32
+ //
33
+ // Instead we keep one cursor per file plus the running totals derived so
34
+ // far, read only the bytes appended since the last pass, and fold them into
35
+ // that state. The three scanners share it, so the first one to run in a
36
+ // cycle pays for the read and the other two reuse the result. This mirrors
37
+ // the offset tailing the Codex rollout watcher in codex-watch.mjs already does.
38
+ const transcriptScans = new Map(); // path -> scan state
39
+ const transcriptScanInFlight = new Map(); // path -> in-progress scan promise
40
+ const transcriptScanSessions = new Map(); // session key -> Set<path>, LRU order
41
+
42
+ // What the cap protects is unbounded growth across the sessions a long-lived
43
+ // deck has seen and will never hear from again — so a SESSION is the unit it
44
+ // has to be counted in. It used to count paths, which is not the same thing:
45
+ // a session occupies one entry for its own JSONL plus one for every
46
+ // `subagents/agent-*.jsonl` beside it, and that count is set by how heavily
47
+ // the session delegates, not by anything the deck controls. Measured on this
48
+ // machine, the two live sessions that produced #611 hold 198 and 133 subagent
49
+ // files — 333 entries between them against a cap of 256, so each session's
50
+ // throttled pass evicted the other's cursors and re-read from byte 0. One of
51
+ // those directories alone is 130.8 MB and takes 6456 ms to fold cold against
52
+ // 18 ms warm, inside a 2500 ms throttle: the pass could not finish before the
53
+ // next one was due.
54
+ //
55
+ // So eviction drops a whole session at a time and never splits one. That is
56
+ // also the answer to a session whose subagent count exceeds any fixed number
57
+ // of entries: it is never evicted for being big, because the only thing a cap
58
+ // can take is some OTHER, older session. A session's cursors live and die
59
+ // together, which is the only grouping that makes the next pass cheap.
60
+ const MAX_TRANSCRIPT_SCAN_SESSIONS = 256;
61
+ // A session cap alone bounds identities, not bytes, so a second ceiling bounds
62
+ // the memory — again by dropping whole sessions, never part of one. A scan
63
+ // state retains 477 bytes measured, so 8192 entries is under 4 MB; it is also
64
+ // ten times every agent-*.jsonl that exists on this machine across its whole
65
+ // history (803), and 24x the 333 the two heaviest live sessions need.
66
+ const MAX_TRANSCRIPT_SCAN_ENTRIES = 8192;
67
+
68
+ // The transcript's `message.model`, and the only filter standing between it and
69
+ // every model the deck shows. Bedrock and Mantle put a provider namespace in
70
+ // front of the id — `us.anthropic.claude-opus-5`, `anthropic.claude-opus-5` —
71
+ // so a bare `^claude` dropped every line a Bedrock session writes and the deck
72
+ // showed those users no model, no context window and no cost at all (#475).
73
+ //
74
+ // The prefix list is `VENDOR_PREFIX_RE` in src/web/model-id.ts, written out a
75
+ // second time here rather than imported: this file is plain .mjs that node runs
76
+ // straight off disk with no build step, so it cannot reach a `.ts` module.
77
+ // bedrock-model-ids.test.ts sweeps both against one list of ids so the copies
78
+ // cannot drift apart.
79
+ const MODEL_ID_RE = /^(?:(?:us-gov|global|apac|us|eu|jp|au)\.anthropic\.|anthropic\.)?claude[-_]/i;
80
+ const USAGE_BLOCK_RE = /"usage"\s*:\s*\{([^}]+)\}/g;
81
+ // CC `/clear` and `/compact` write a marker into the transcript and reset the
82
+ // context window to ~0 while the JSONL keeps growing — everything before the
83
+ // last marker is stale.
84
+ const CONTEXT_RESET_RE = /<command-name>\s*\/(?:clear|compact)\s*<\/command-name>/g;
85
+ const TYPE_USER_RE = /"type"\s*:\s*"user"/g;
86
+ const TYPE_ASSISTANT_RE = /"type"\s*:\s*"assistant"/g;
87
+ const TYPE_TOOL_USE_RE = /"type"\s*:\s*"tool_use"/g;
88
+ const TYPE_TOOL_RESULT_RE = /"type"\s*:\s*"tool_result"/g;
89
+ const SYSTEM_REMINDER_RE = /<system-reminder>/g;
90
+ const USAGE_FIELD_RE = {
91
+ input_tokens: /"input_tokens"\s*:\s*(\d+)/,
92
+ output_tokens: /"output_tokens"\s*:\s*(\d+)/,
93
+ cache_read_input_tokens: /"cache_read_input_tokens"\s*:\s*(\d+)/,
94
+ cache_creation_input_tokens: /"cache_creation_input_tokens"\s*:\s*(\d+)/,
95
+ ephemeral_1h_input_tokens: /"ephemeral_1h_input_tokens"\s*:\s*(\d+)/,
96
+ ephemeral_5m_input_tokens: /"ephemeral_5m_input_tokens"\s*:\s*(\d+)/,
97
+ };
98
+ // The flat `cache_creation_input_tokens` says how many tokens were written to
99
+ // cache but not at which TTL, and Anthropic bills a 1-hour write at 2x input
100
+ // against the 5-minute 1.25x — CC writes 1-hour caches for most of its prefix,
101
+ // so pricing the whole lot at the cheaper rate under-reports the bill. The
102
+ // split lives in a `cache_creation` sub-object that USAGE_BLOCK_RE cannot
103
+ // reach: its `[^}]+` stops at the first `}`, which in a real transcript closes
104
+ // `server_tool_use`, several fields earlier. Match the sub-object on the raw
105
+ // line instead of on the extracted blob.
106
+ const CACHE_CREATION_BLOCK_RE = /"cache_creation"\s*:\s*\{([^}]*)\}/g;
107
+ // A finished `Task`/`Agent` call is written into the PARENT's transcript as a
108
+ // top-level `toolUseResult`, and that object carries a `usage` block of its own
109
+ // — the subagent's LAST API turn, restated on the parent's line. Those same
110
+ // tokens are already in the subagent's own `subagents/agent-<id>.jsonl`, which
111
+ // #685 folds into the session's totals, so counting the restated copy here
112
+ // would charge them twice. Measured on a real transcript: the restated block
113
+ // reports 181,387 cache-read tokens and the last usage block inside that
114
+ // subagent's file reports 181,387 — the same tokens, written twice.
115
+ //
116
+ // Matched as a TOP-LEVEL key: a `{` or `,` and then the name unescaped. The
117
+ // same text quoted inside a message — an assistant writing about this very
118
+ // field, as this comment does — reaches the line as `\"toolUseResult\"`, whose
119
+ // preceding character is a backslash, so a transcript that talks about the key
120
+ // is not mistaken for one that carries it.
121
+ const TOOL_USE_RESULT_KEY_RE = /[{,]"toolUseResult"\s*:/;
122
+
123
+ /** The stretch of a transcript line whose `usage` blocks are the model's own
124
+ * billing records — everything before a top-level `toolUseResult`. See
125
+ * TOOL_USE_RESULT_KEY_RE. A line without one is billed whole, which is every
126
+ * assistant line and therefore every line that legitimately has usage. */
127
+ function billedUsageText(line) {
128
+ const m = TOOL_USE_RESULT_KEY_RE.exec(line);
129
+ return m ? line.slice(0, m.index) : line;
130
+ }
131
+
132
+ function grabUsageField(blob, key) {
133
+ const m = blob.match(USAGE_FIELD_RE[key]);
134
+ return m ? Number(m[1]) : 0;
135
+ }
136
+
137
+ // How many DISTINCT models one transcript may keep a usage bucket for.
138
+ //
139
+ // A session reaches two or three: `/model` mid-run, CC dropping to Sonnet when
140
+ // the weekly Opus allowance runs low, a subagent turn on a different tier. The
141
+ // cap is not about those. `MODEL_ID_RE` accepts anything beginning `claude-`,
142
+ // and every line of a transcript is bytes this process was handed rather than
143
+ // bytes it wrote — so a file naming a fresh `claude-<n>` on every line would
144
+ // otherwise grow one bucket per line, inside the same scanner #674 put a
145
+ // ceiling on for exactly this shape of reason. Past the cap the extra models'
146
+ // tokens still reach `state.usage`, which is what every token count on screen
147
+ // reads; they simply stop being attributed, and `usageByModelEntries` on the
148
+ // client prices whatever the map does not explain at the session's current
149
+ // model — the behaviour the whole deck had before #686.
150
+ const MAX_TRANSCRIPT_USAGE_MODELS = 32;
151
+
152
+ /** The per-model usage bucket for `model`, created on first sight. Null for a
153
+ * line whose tokens we cannot attribute — no model seen yet in this file, or
154
+ * the cap above already reached. */
155
+ function usageBucketFor(state, model) {
156
+ if (!model) return null;
157
+ const existing = state.usageByModel[model];
158
+ if (existing) return existing;
159
+ if (Object.keys(state.usageByModel).length >= MAX_TRANSCRIPT_USAGE_MODELS) return null;
160
+ const fresh = newUsageTotals();
161
+ state.usageByModel[model] = fresh;
162
+ return fresh;
163
+ }
164
+
165
+ /** The counters a `"usage"` block carries flat, and the two its
166
+ * `cache_creation` sub-object splits cache writes into by TTL. */
167
+ const USAGE_BLOCK_FIELDS = ["input_tokens", "output_tokens", "cache_read_input_tokens", "cache_creation_input_tokens"];
168
+ const CACHE_SPLIT_FIELDS = ["ephemeral_1h_input_tokens", "ephemeral_5m_input_tokens"];
169
+
170
+ /** Charge `fields` of one usage blob to the file's flat totals and, when the
171
+ * tokens can be attributed, to their model's bucket too — the same number
172
+ * into both, read once, so the split and the total it splits go on summing
173
+ * to each other. */
174
+ function chargeUsage(state, bucket, blob, fields) {
175
+ for (const k of fields) {
176
+ const n = grabUsageField(blob, k);
177
+ state.usage[k] += n;
178
+ if (bucket) bucket[k] += n;
179
+ }
180
+ }
181
+
182
+ /** Add `src`'s per-model buckets into `dst`, key for key, and return `dst`.
183
+ * Under the same cap as the per-file map, so a session that delegates to
184
+ * hundreds of files cannot assemble an unbounded one out of bounded parts. */
185
+ function mergeUsageByModel(dst, src) {
186
+ if (!src) return dst;
187
+ for (const [model, u] of Object.entries(src)) {
188
+ let bucket = dst[model];
189
+ if (!bucket) {
190
+ if (Object.keys(dst).length >= MAX_TRANSCRIPT_USAGE_MODELS) continue;
191
+ bucket = newUsageTotals();
192
+ dst[model] = bucket;
193
+ }
194
+ for (const k of Object.keys(bucket)) bucket[k] += u[k] ?? 0;
195
+ }
196
+ return dst;
197
+ }
198
+
199
+ function newContextBreakdown() {
200
+ return {
201
+ msgsUser: 0,
202
+ msgsAssistant: 0,
203
+ toolUses: 0,
204
+ toolResults: 0,
205
+ systemReminders: 0,
206
+ currentContextTokens: 0,
207
+ };
208
+ }
209
+
210
+ /** A zeroed set of the six token counters a transcript reports. One shape, so
211
+ * the per-file totals and the per-session sum of them add key for key. */
212
+ function newUsageTotals() {
213
+ return {
214
+ input_tokens: 0, output_tokens: 0,
215
+ cache_read_input_tokens: 0, cache_creation_input_tokens: 0,
216
+ ephemeral_1h_input_tokens: 0, ephemeral_5m_input_tokens: 0,
217
+ };
218
+ }
219
+
220
+ /** Have these totals been billed for anything at all?
221
+ *
222
+ * The four flat counters are the whole of what a usage block charges. The two
223
+ * `ephemeral_*` counters split cache_creation_input_tokens by TTL rather than
224
+ * adding to it, so they are not asked. One file's totals, one model's bucket,
225
+ * each subagent file's totals and each line the Projects report folds
226
+ * (account-projects.mjs) are all asked this, and each of those four used to
227
+ * spell the four tests out for itself. */
228
+ function hasSpend(u) {
229
+ return USAGE_BLOCK_FIELDS.some(k => u[k] !== 0);
230
+ }
231
+
232
+ function newTranscriptState() {
233
+ return {
234
+ offset: 0, // bytes already folded in
235
+ midLine: false, // the cursor sits inside a line longer than MAX_SCAN_CHUNK
236
+ rootModel: null,
237
+ lastModel: null, // last claude-* model on any line, sidechain included
238
+ subagentModels: {},
239
+ aiTitle: null, // newest "ai-title" entry, the session's sentence title
240
+ agentName: null, // newest "agent-name" entry, the session's short name
241
+ recap: null, // newest "away_summary" no later turn retired — session-recap.mjs
242
+ usage: newUsageTotals(),
243
+ // The same totals, split by the model that produced them (#686). The flat
244
+ // bucket above stays the whole-transcript sum and is what every token count
245
+ // reads; this is what the DOLLARS are built from, because a session that
246
+ // switched model has tokens from two rate cards in one file and one rate
247
+ // applied to the lot is wrong by the ratio between them — measured at 60%
248
+ // under on a mostly-Opus session that ended on Sonnet, and 150% over on the
249
+ // mirror of it. Costs no extra read: `message.model` and the `"usage"` block
250
+ // are on the same line, and this pass already parses both.
251
+ usageByModel: {},
252
+ ctx: newContextBreakdown(),
253
+ };
254
+ }
255
+
256
+ // ─── Session naming ──────────────────────────────────────────────────────
257
+ // CC writes two whole-line records that name the session, and nothing else in
258
+ // the transcript carries either fact:
259
+ //
260
+ // {"type":"ai-title","aiTitle":"Inspect repository to understand current state","sessionId":"…"}
261
+ // {"type":"agent-name","agentName":"account-management-oauth-flow","sessionId":"…"}
262
+ //
263
+ // Both are re-emitted on roughly every turn rather than only when they change.
264
+ // Measured on the two largest transcripts on this machine: 685 `ai-title`
265
+ // entries carrying 2 DISTINCT values (46.4 MB session) and 406 carrying 1
266
+ // (19.6 MB session). So "the last one wins" is right, but the value is nearly
267
+ // always the value we already had — which is why the emit in
268
+ // session-enrichment.mjs is gated on a change rather than fired per pass.
269
+ //
270
+ // `aiTitle` is NOT reliably the sentence it looks like. In the 46.4 MB session
271
+ // the first 332 entries read "Inspect repository to understand current state"
272
+ // and every one of the last 353 reads "account-management-oauth-flow" —
273
+ // byte-identical to `agentName`, and 353 is exactly the `agent-name` count. CC
274
+ // overwrites the title with the slug once a session has a name. The client
275
+ // therefore has to treat "title equals name" as "no title", or the tooltip
276
+ // would repeat the card.
277
+ const AI_TITLE_MARK = '"ai-title"';
278
+ const AGENT_NAME_MARK = '"agent-name"';
279
+
280
+ /** Fold one line's naming records into `out`. Last value wins. Pure: `out` is
281
+ * the only thing written, and a line that is not one of the two records — or
282
+ * is a truncated fragment of one — leaves it untouched. */
283
+ export function foldSessionNamingLine(out, line) {
284
+ if (!line) return;
285
+ const hasTitle = line.includes(AI_TITLE_MARK);
286
+ const hasName = line.includes(AGENT_NAME_MARK);
287
+ if (!hasTitle && !hasName) return;
288
+ let obj = null;
289
+ try { obj = JSON.parse(line); } catch { return; }
290
+ if (!obj || typeof obj !== "object") return;
291
+ if (obj.type === "ai-title" && typeof obj.aiTitle === "string" && obj.aiTitle) {
292
+ out.aiTitle = obj.aiTitle;
293
+ } else if (obj.type === "agent-name" && typeof obj.agentName === "string" && obj.agentName) {
294
+ out.agentName = obj.agentName;
295
+ }
296
+ }
297
+
298
+ /** Fold one transcript line into the running state. Every fact the three
299
+ * scanners need lives on a single line, so line-at-a-time folding sees
300
+ * exactly what a whole-file pass would. */
301
+ function foldTranscriptLine(state, line) {
302
+ if (!line) return;
303
+
304
+ // Rides the scan that is already reading these bytes for the model, the usage
305
+ // totals and the context counts, so naming costs no read of its own — see the
306
+ // block above `maybeResolveSessionName` in session-enrichment.mjs for why
307
+ // that beat a tail read.
308
+ foldSessionNamingLine(state, line);
309
+ // The recap rides the same pass for the same reason, and costs the ordinary
310
+ // line two substring tests. See session-recap.mjs.
311
+ foldRecapLine(state, line);
312
+ // Parsed once, and only when the line mentions a model: no other line can
313
+ // change the model, and every assistant line names its model, so the usage
314
+ // and context folds read the record off this same parse.
315
+ const record = line.includes('"model"') ? parseRecord(line) : null;
316
+ // Model before usage, always: a usage block is charged to the model the same
317
+ // line names, which is the one foldModelLine has just read off it.
318
+ foldModelLine(state, record);
319
+ foldUsageLine(state, line, record);
320
+ foldContextLine(state, line, record);
321
+ }
322
+
323
+ /** One transcript line as the object it records, or null when it is not JSON. */
324
+ function parseRecord(line) {
325
+ try { return JSON.parse(line); } catch { return null; }
326
+ }
327
+
328
+ /** The model a line's record names, folded in: the newest one seen, and the
329
+ * root's or a legacy inline subagent's. `obj` is null for a line that names no
330
+ * model — only a line that mentions one can change it, and parsing the rest is
331
+ * what made the full rescan expensive. */
332
+ function foldModelLine(state, obj) {
333
+ const msg = obj && obj.message;
334
+ const model = (msg && typeof msg.model === "string" && MODEL_ID_RE.test(msg.model)) ? msg.model
335
+ : (obj && typeof obj.model === "string" && MODEL_ID_RE.test(obj.model)) ? obj.model
336
+ : null;
337
+ if (model) {
338
+ state.lastModel = model;
339
+ const isSide = obj.isSidechain === true || obj.is_sidechain === true;
340
+ const ptid = obj.parentToolUseID || obj.parent_tool_use_id || obj.parentToolUseId || null;
341
+ if (isSide && ptid) state.subagentModels[ptid] = model;
342
+ else if (!isSide) state.rootModel = model;
343
+ }
344
+ }
345
+
346
+ /**
347
+ * Does this record repeat the usage an earlier line of the same API request
348
+ * already carried?
349
+ *
350
+ * Claude Code writes one assistant line per content block of a response —
351
+ * thinking, text, each tool call — and every one of them carries the whole
352
+ * request's `usage`. Only the first, `apiBlockIndex` 0, bills it (#1641).
353
+ * Measured across the forty most recent transcripts on one machine: 6,765 of
354
+ * 14,138 assistant usage records were such repeats, identical to the block 0
355
+ * before them, and summing them all came to 1.94x the request-by-request
356
+ * total. This is the rule the Projects report (account-projects.mjs) and
357
+ * ccusage count by, and the Projects report asks this function. A record
358
+ * without the field was written before Claude Code marked its blocks, and is
359
+ * counted as it always was.
360
+ *
361
+ * The same lines are one reply, so the context breakdown asks this too before
362
+ * it counts an assistant message (#1650).
363
+ */
364
+ function repeatsRequestUsage(record) {
365
+ return record?.apiBlockIndex !== undefined && record.apiBlockIndex !== 0;
366
+ }
367
+
368
+ /** The usage blocks a line was billed for, charged to the file's totals and to
369
+ * their model's bucket. Runs after foldModelLine on the same line — see
370
+ * below for why the order is the attribution. `record` is the line parsed,
371
+ * or null when it names no model. */
372
+ function foldUsageLine(state, line, record) {
373
+ // A later content block of a request already charged — see
374
+ // repeatsRequestUsage.
375
+ if (repeatsRequestUsage(record)) return;
376
+ // Usage totals sum every block in the file, resets included — every block the
377
+ // model was actually billed for, which is why the `toolUseResult` tail is cut
378
+ // off first (see billedUsageText) — and are summed a second time into the
379
+ // bucket of the model that produced them (#686).
380
+ //
381
+ // `state.lastModel` is the attribution, and it is the LINE's model whenever
382
+ // the line has one: foldModelLine has already run on it and assigned it.
383
+ // That is the whole trick — CC writes `message.model` and `message.usage`
384
+ // into the same JSON object, so by the time this loop reads the tokens the
385
+ // model that produced them is the most recent thing the scanner saw. A usage
386
+ // block on a line naming no model falls back to the last model seen, which
387
+ // is the turn it belongs to; a usage block before ANY model line gets no
388
+ // bucket at all and stays in the flat total alone, where the client prices it
389
+ // at the session's current model exactly as it did before.
390
+ //
391
+ // The bucket takes exactly what the flat total takes, off the same `billed`
392
+ // text: a split that read a wider stretch of the line than the total it splits
393
+ // would re-introduce #685's double count on one side of the arithmetic only,
394
+ // and the two would stop summing to each other.
395
+ const billed = billedUsageText(line);
396
+ const bucket = usageBucketFor(state, state.lastModel);
397
+ for (const m of billed.matchAll(USAGE_BLOCK_RE)) {
398
+ chargeUsage(state, bucket, m[1], USAGE_BLOCK_FIELDS);
399
+ }
400
+ for (const m of billed.matchAll(CACHE_CREATION_BLOCK_RE)) {
401
+ chargeUsage(state, bucket, m[1], CACHE_SPLIT_FIELDS);
402
+ }
403
+ }
404
+
405
+ /** The line's share of the context breakdown. Context counts only what follows
406
+ * the most recent /clear or /compact. `record` is the line parsed, or null
407
+ * when it names no model — see foldTranscriptLine. */
408
+ function foldContextLine(state, line, record) {
409
+ let ctxText = line;
410
+ let resetEnd = -1;
411
+ for (const m of line.matchAll(CONTEXT_RESET_RE)) resetEnd = (m.index ?? -1) + m[0].length;
412
+ if (resetEnd >= 0) {
413
+ state.ctx = newContextBreakdown();
414
+ ctxText = line.slice(resetEnd);
415
+ }
416
+ const ctx = state.ctx;
417
+ ctx.msgsUser += (ctxText.match(TYPE_USER_RE) ?? []).length;
418
+ // One reply is one message, however many content blocks it was written in:
419
+ // a later block's line is a reply block 0 has already counted — see
420
+ // repeatsRequestUsage. Its tool call is its own, and is counted below.
421
+ if (!repeatsRequestUsage(record)) ctx.msgsAssistant += (ctxText.match(TYPE_ASSISTANT_RE) ?? []).length;
422
+ ctx.toolUses += (ctxText.match(TYPE_TOOL_USE_RE) ?? []).length;
423
+ ctx.toolResults += (ctxText.match(TYPE_TOOL_RESULT_RE) ?? []).length;
424
+ ctx.systemReminders += (ctxText.match(SYSTEM_REMINDER_RE) ?? []).length;
425
+ // Current context size = the LAST usage block after the reset. Stays 0
426
+ // right after a /clear, which is what CC's own /context reports.
427
+ let lastBlob = null;
428
+ for (const m of ctxText.matchAll(USAGE_BLOCK_RE)) lastBlob = m[1];
429
+ if (lastBlob) {
430
+ ctx.currentContextTokens =
431
+ grabUsageField(lastBlob, "input_tokens") +
432
+ grabUsageField(lastBlob, "cache_read_input_tokens") +
433
+ grabUsageField(lastBlob, "cache_creation_input_tokens");
434
+ }
435
+ }
436
+
437
+ /** The session a transcript path belongs to, and the unit eviction works in.
438
+ * CC's two layouts collapse onto the same key: the main transcript
439
+ * `<slug>/<sessionId>.jsonl` loses its extension, and a subagent's
440
+ * `<slug>/<sessionId>/subagents/agent-<id>.jsonl` loses everything below the
441
+ * session directory, so both land on `<slug>/<sessionId>`. Anything else — a
442
+ * Codex rollout, a shape we do not recognise — is a session of its own,
443
+ * which is the old one-entry-per-path accounting and the safe default.
444
+ *
445
+ * `resolve` first so the two halves agree on Windows. The main path arrives
446
+ * from the hook payload as CC wrote it while the subagent path is built with
447
+ * `join`, so `C:/x/y.jsonl` and `C:\x\y\subagents\agent-1.jsonl` would
448
+ * otherwise be two sessions instead of one; `resolve` settles the separator
449
+ * on all three platforms, and leaves a backslash inside a POSIX filename
450
+ * alone rather than reading it as a directory break. */
451
+ export function transcriptSessionKey(path) {
452
+ const full = resolve(path);
453
+ const sub = /^(.*)[\\/]subagents[\\/]agent-[0-9a-f]+\.jsonl$/i.exec(full);
454
+ return sub ? sub[1] : full.replace(/\.jsonl$/i, "");
455
+ }
456
+
457
+ /** Record a use of `path` and keep the cache under both caps. Re-inserting the
458
+ * session on every touch makes the Map's own insertion order the LRU order,
459
+ * so eviction reads one key instead of scanning for the smallest timestamp.
460
+ * Scanning a timestamp is also what broke the cache once: a state is created
461
+ * with no stamp yet, so the entry the scan had just inserted was the smallest
462
+ * of all and the one deleted, every time. Whatever the cache freezes on, the
463
+ * symptom is the same — every later transcript re-reads its whole JSONL from
464
+ * byte 0 on every throttled pass, the O(n)-per-pass stall the cursor exists
465
+ * to remove. */
466
+ function touchTranscriptScan(path, state) {
467
+ transcriptScans.set(path, state);
468
+ const key = transcriptSessionKey(path);
469
+ const paths = transcriptScanSessions.get(key) ?? new Set();
470
+ transcriptScanSessions.delete(key); // re-insert = move to the back of the LRU
471
+ paths.add(path);
472
+ transcriptScanSessions.set(key, paths);
473
+ pruneTranscriptScans();
474
+ }
475
+
476
+ function pruneTranscriptScans() {
477
+ // The last session standing is never evicted, however many files it holds:
478
+ // dropping the cursors of the session currently being scanned is precisely
479
+ // the re-read this cache exists to avoid, and no cap can make its file
480
+ // count smaller.
481
+ while (
482
+ transcriptScanSessions.size > 1 &&
483
+ (transcriptScanSessions.size > MAX_TRANSCRIPT_SCAN_SESSIONS ||
484
+ transcriptScans.size > MAX_TRANSCRIPT_SCAN_ENTRIES)
485
+ ) {
486
+ const oldest = transcriptScanSessions.keys().next().value;
487
+ for (const p of transcriptScanSessions.get(oldest)) transcriptScans.delete(p);
488
+ transcriptScanSessions.delete(oldest);
489
+ }
490
+ }
491
+
492
+ // How far ONE pass will walk a file that is behind by more than a chunk.
493
+ //
494
+ // The cursor makes catching up cheap in steady state, but the FIRST pass over
495
+ // an existing transcript has the whole file to fold, and a fix that made that
496
+ // take one throttle window (2500 ms) per 8 MiB would have turned a 130.8 MB
497
+ // transcript's first attach into forty seconds of a deck showing no model, no
498
+ // name and no cost — the reintroduction, by a different route, of exactly the
499
+ // stall #611 removed. So a pass loops over chunks until it is caught up, and
500
+ // this bounds what one hook event can be made to walk.
501
+ //
502
+ // 256 MiB is twice the heaviest transcript this repo has ever measured (130.8
503
+ // MB, #611), so an honest first attach never meets it and pays nothing for it.
504
+ // What it stops is a caller who has got past `isClaudeTranscriptPath` in
505
+ // transcript-gate.mjs pointing one POST at something arbitrarily large: the read still terminates,
506
+ // the cursor keeps whatever it reached, and the next throttled pass continues
507
+ // from there.
508
+ const MAX_SCAN_BYTES_PER_PASS = 256 * 1024 * 1024;
509
+
510
+ /** Bring a transcript's scan state up to date and return it. Concurrent
511
+ * callers share one read — folding the same appended bytes twice would
512
+ * double the usage totals. Never throws; an unreadable file just leaves
513
+ * the state where it was. */
514
+ function scanTranscript(path) {
515
+ if (!path || typeof path !== "string") return Promise.resolve(null);
516
+ const inFlight = transcriptScanInFlight.get(path);
517
+ if (inFlight) return inFlight;
518
+ const run = (async () => {
519
+ let state = transcriptScans.get(path);
520
+ if (!state) state = newTranscriptState();
521
+ touchTranscriptScan(path, state);
522
+ try {
523
+ const s = await stat(path);
524
+ // Shorter than the cursor means the file was truncated, rotated or
525
+ // replaced — the offset now points at unrelated bytes, so start over.
526
+ if (s.size < state.offset) {
527
+ state = newTranscriptState();
528
+ // Touch again rather than set: the await above yielded, and another
529
+ // path's scan may have re-ordered or evicted this entry meanwhile.
530
+ touchTranscriptScan(path, state);
531
+ }
532
+ if (s.size <= state.offset) return state;
533
+ // Chunk by chunk rather than in one allocation, and loop rather than
534
+ // leave the rest for the next throttle window — see MAX_SCAN_CHUNK in
535
+ // jsonl-chunks.mjs and MAX_SCAN_BYTES_PER_PASS above for why it is both
536
+ // of those and not either one alone. `readAppendedLines` advances the
537
+ // cursor BEFORE this folds, which is the same rule the single-shot read
538
+ // kept: a fold that throws half-way must not leave the cursor where the
539
+ // next pass would count those lines again. `advanced === 0` is "nothing
540
+ // complete to fold yet".
541
+ let budget = MAX_SCAN_BYTES_PER_PASS;
542
+ while (budget > 0 && state.offset < s.size) {
543
+ const { text, advanced } = await readAppendedLines(path, state, s.size);
544
+ if (advanced === 0) break;
545
+ budget -= advanced;
546
+ for (const line of text.split("\n")) foldTranscriptLine(state, line);
547
+ }
548
+ } catch { /* keep whatever we already folded */ }
549
+ return state;
550
+ })();
551
+ transcriptScanInFlight.set(path, run);
552
+ return run.finally(() => {
553
+ if (transcriptScanInFlight.get(path) === run) transcriptScanInFlight.delete(path);
554
+ });
555
+ }
556
+
557
+ // What session-enrichment.mjs calls, and the two rules the Projects report
558
+ // (account-projects.mjs) reads its own pass of the transcripts by. Listed here
559
+ // rather than marked at each declaration so that every declaration above reads
560
+ // exactly as it did where it came from.
561
+ export {
562
+ scanTranscript, newUsageTotals, hasSpend, mergeUsageByModel, repeatsRequestUsage,
563
+ };
@@ -0,0 +1,108 @@
1
+ // The usage panel's reads: Claude's quota windows, Codex's quota and usage,
2
+ // ccusage's daily history, and what the providers' status pages say (#1311).
3
+ //
4
+ // These lived in src/server/index.mjs after the shutdown route, and each is the
5
+ // same few lines: read `?refresh=1`, import the module that does the work,
6
+ // hand the flag on, and answer with what comes back. What bounds a forced read
7
+ // is those modules' business — codex-usage-forced-read-guard.test.ts keeps the
8
+ // census of it there — and each is imported lazily by the package-root URL
9
+ // PINNED_MODULES loads. The one rule decided here is isCliDate, because this is
10
+ // where a string somebody else chose enters on its way to a child's argv.
11
+ import { dirname, join, resolve } from "node:path";
12
+ import { fileURLToPath, pathToFileURL } from "node:url";
13
+ import { deckProviders } from "./deck-scope.mjs";
14
+ import { send } from "./http-io.mjs";
15
+
16
+ // Resolved the way pinned-build.mjs resolves it, from a file in the same
17
+ // directory, so every lazy import below is the URL the pin has already evaluated.
18
+ const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
19
+
20
+ /**
21
+ * WITH A DEADLINE OF ITS OWN (#1011). Source 3 is `claude --print /usage`, and
22
+ * its cost is the sum of three spawns under a 15-second timeout with two
23
+ * 1.2-second sleeps between them — 47.4 seconds, measured — which nothing here
24
+ * had ever added up. The budget is quota.mjs's, because the "not yet" answer it
25
+ * expires into is made of that module's own last-known-good reading; the read
26
+ * carries on behind it and publishes for the next poll.
27
+ */
28
+ export async function handleQuota(req, res) {
29
+ const { fetchClaudeQuota, QUOTA_DEADLINE_MS } = await import(
30
+ pathToFileURL(join(PKG_ROOT, "src/server/quota.mjs")).href
31
+ );
32
+ const url = new URL(req.url, "http://localhost");
33
+ const force = url.searchParams.get("refresh") === "1";
34
+ const quota = await fetchClaudeQuota({ force, deadlineMs: QUOTA_DEADLINE_MS });
35
+ send(res, 200, quota);
36
+ }
37
+
38
+ export async function handleCodexUsage(req, res) {
39
+ const { fetchCodexUsage } = await import(
40
+ pathToFileURL(join(PKG_ROOT, "src/server/codex-usage.mjs")).href
41
+ );
42
+ const url = new URL(req.url, "http://localhost");
43
+ const force = url.searchParams.get("refresh") === "1";
44
+ const usage = await fetchCodexUsage({ force });
45
+ send(res, 200, usage);
46
+ }
47
+
48
+ export async function handleCodexQuota(req, res) {
49
+ const { fetchCodexQuota } = await import(
50
+ pathToFileURL(join(PKG_ROOT, "src/server/codex-quota.mjs")).href
51
+ );
52
+ const url = new URL(req.url, "http://localhost");
53
+ const force = url.searchParams.get("refresh") === "1";
54
+ const quota = await fetchCodexQuota({ force });
55
+ send(res, 200, quota);
56
+ }
57
+
58
+ /**
59
+ * One line per watched CLI: whether its provider's status page reports an
60
+ * incident. Never a 500 for an unreachable page — provider-status.mjs files
61
+ * that as "unknown" — and no `?refresh=1`: the page's own cadence is the only
62
+ * one there is, so a button held down cannot turn into a poll of someone
63
+ * else's server.
64
+ */
65
+ export async function handleProviderStatus(req, res) {
66
+ const { providerStatusReport } = await import(
67
+ pathToFileURL(join(PKG_ROOT, "src/server/provider-status.mjs")).href
68
+ );
69
+ send(res, 200, await providerStatusReport({ providers: deckProviders() }));
70
+ }
71
+
72
+ /**
73
+ * ccusage's `--since`/`--until` grammar: a `YYYYMMDD` calendar date, or nothing
74
+ * at all — an absent parameter lets ccusage.mjs pick the default 30-day range.
75
+ *
76
+ * Eight digits is a shape, not a calendar, and deliberately so. What this gate
77
+ * is for is keeping anything that is not a date out of a child process's
78
+ * argument vector; `99999999` is exactly as inert an argument as `20260101`,
79
+ * and whether a date outside the logs means an empty chart is ccusage's
80
+ * question to answer rather than the deck's to guess at.
81
+ */
82
+ export const isCliDate = (v) => v === undefined || /^\d{8}$/.test(v);
83
+
84
+ export async function handleCcusage(req, res) {
85
+ const url = new URL(req.url, "http://localhost");
86
+ const force = url.searchParams.get("refresh") === "1";
87
+ const since = url.searchParams.get("since") || undefined;
88
+ const until = url.searchParams.get("until") || undefined;
89
+ // Refused here, at the boundary, because this is where a string chosen by
90
+ // someone else enters: /api/ccusage is a GET, so it needs no CORS, no
91
+ // preflight and no ability to read the response — any page the user has open
92
+ // can fire it at the loopback port — and both values end up in the argv of a
93
+ // spawned CLI. The spawn no longer goes through a shell (see ccusage.mjs), so
94
+ // this is the second lock rather than the only one, and it is the one that
95
+ // keeps the pair honest if a shell is ever reintroduced downstream.
96
+ if (!isCliDate(since) || !isCliDate(until)) {
97
+ return send(res, 400, {
98
+ ok: false,
99
+ reason: "bad_range",
100
+ error: "since and until must be YYYYMMDD dates, e.g. 20260101",
101
+ });
102
+ }
103
+ const { fetchCcusageDaily } = await import(
104
+ pathToFileURL(join(PKG_ROOT, "src/server/ccusage.mjs")).href
105
+ );
106
+ const data = await fetchCcusageDaily({ since, until, force });
107
+ send(res, 200, data);
108
+ }