@promptctl/cc-candybar 1.42.1 → 1.43.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 (126) hide show
  1. package/dist/index.mjs +72 -71
  2. package/package.json +5 -6
  3. package/src/check.ts +0 -478
  4. package/src/cli-flags.ts +0 -8
  5. package/src/click/wire.ts +0 -158
  6. package/src/config/action.ts +0 -329
  7. package/src/config/cli.ts +0 -71
  8. package/src/config/default-dsl-config.ts +0 -1645
  9. package/src/config/disclosure.ts +0 -170
  10. package/src/config/dsl-loader.ts +0 -339
  11. package/src/config/dsl-types.ts +0 -581
  12. package/src/config/edit-chrome.ts +0 -559
  13. package/src/config/help.ts +0 -151
  14. package/src/config/ident.ts +0 -22
  15. package/src/config/layout-ops.ts +0 -177
  16. package/src/config/loader/actions.ts +0 -972
  17. package/src/config/loader/cache.ts +0 -206
  18. package/src/config/loader/cross-ref.ts +0 -714
  19. package/src/config/loader/cycles.ts +0 -148
  20. package/src/config/loader/diagnostics.ts +0 -99
  21. package/src/config/loader/discovery.ts +0 -182
  22. package/src/config/loader/edit-mode.ts +0 -137
  23. package/src/config/loader/emit-schema.ts +0 -68
  24. package/src/config/loader/globals.ts +0 -269
  25. package/src/config/loader/helpers.ts +0 -48
  26. package/src/config/loader/layout.ts +0 -693
  27. package/src/config/loader/looks.ts +0 -96
  28. package/src/config/loader/menu-synth.ts +0 -435
  29. package/src/config/loader/merge.ts +0 -115
  30. package/src/config/loader/persist-target.ts +0 -67
  31. package/src/config/loader/presets.ts +0 -119
  32. package/src/config/loader/refs.ts +0 -100
  33. package/src/config/loader/reserved-namespace.ts +0 -38
  34. package/src/config/loader/segments.ts +0 -120
  35. package/src/config/loader/validate-core.ts +0 -737
  36. package/src/config/loader/variables.ts +0 -260
  37. package/src/config/menu-keys.ts +0 -139
  38. package/src/config/option-domain.ts +0 -164
  39. package/src/config/presets.ts +0 -326
  40. package/src/config/settings-menu.ts +0 -775
  41. package/src/daemon/acquire.ts +0 -684
  42. package/src/daemon/cache/git.ts +0 -649
  43. package/src/daemon/cache/render.ts +0 -623
  44. package/src/daemon/cache/session-usage-store.ts +0 -720
  45. package/src/daemon/cache/watchers.ts +0 -249
  46. package/src/daemon/client-debug.ts +0 -120
  47. package/src/daemon/client-stats.ts +0 -130
  48. package/src/daemon/client-transport.ts +0 -273
  49. package/src/daemon/client.ts +0 -78
  50. package/src/daemon/config-overrides-store.ts +0 -663
  51. package/src/daemon/debug-types.ts +0 -91
  52. package/src/daemon/debug.ts +0 -264
  53. package/src/daemon/fork-bomb-breaker.ts +0 -351
  54. package/src/daemon/limits.ts +0 -211
  55. package/src/daemon/log.ts +0 -81
  56. package/src/daemon/parent-watchdog.ts +0 -87
  57. package/src/daemon/paths.ts +0 -211
  58. package/src/daemon/process-fingerprint.ts +0 -146
  59. package/src/daemon/protocol.ts +0 -292
  60. package/src/daemon/render-payload.ts +0 -1256
  61. package/src/daemon/server.ts +0 -1330
  62. package/src/daemon/session-state-file.ts +0 -108
  63. package/src/daemon/session-state.ts +0 -237
  64. package/src/daemon/socket-lease.ts +0 -209
  65. package/src/daemon/socket-ownership.ts +0 -209
  66. package/src/daemon/stats.ts +0 -235
  67. package/src/daemon/verbs/config-validators.ts +0 -250
  68. package/src/daemon/verbs/index.ts +0 -706
  69. package/src/daemon/verbs/state-validators.ts +0 -249
  70. package/src/daemon/verbs/validator-registry.ts +0 -457
  71. package/src/demo/dsl.ts +0 -143
  72. package/src/demo/mock-data.ts +0 -67
  73. package/src/demo/statusline.json5 +0 -94
  74. package/src/dsl/node-registry.ts +0 -374
  75. package/src/dsl/render.ts +0 -803
  76. package/src/help-text.ts +0 -90
  77. package/src/index.ts +0 -210
  78. package/src/install/currency.ts +0 -197
  79. package/src/install/index.ts +0 -557
  80. package/src/proc/launch.ts +0 -459
  81. package/src/proc/stats-handle.ts +0 -13
  82. package/src/render/action.ts +0 -883
  83. package/src/render/active-segment.ts +0 -78
  84. package/src/render/diagnostic-style.ts +0 -23
  85. package/src/render/diagnostic-text.ts +0 -77
  86. package/src/render/error-glyph.ts +0 -53
  87. package/src/render/menu.ts +0 -257
  88. package/src/render/outcome-plan.ts +0 -45
  89. package/src/render/picker.ts +0 -372
  90. package/src/render/segment-color.ts +0 -74
  91. package/src/render/split-lines.ts +0 -51
  92. package/src/render/strip.ts +0 -228
  93. package/src/segments/cache.ts +0 -131
  94. package/src/segments/context.ts +0 -190
  95. package/src/segments/git.ts +0 -1084
  96. package/src/segments/metrics.ts +0 -187
  97. package/src/segments/pricing.ts +0 -452
  98. package/src/segments/session.ts +0 -23
  99. package/src/segments/tmux.ts +0 -74
  100. package/src/template-engine/cells.ts +0 -90
  101. package/src/template-engine/colors.ts +0 -124
  102. package/src/template-engine/engine.ts +0 -108
  103. package/src/template-engine/funcs.ts +0 -232
  104. package/src/template-engine/index.ts +0 -11
  105. package/src/template-engine/layout.ts +0 -133
  106. package/src/template-engine/scope.ts +0 -62
  107. package/src/template-engine/sparkline.ts +0 -79
  108. package/src/themes/index.ts +0 -20
  109. package/src/themes/palette-resolvers.ts +0 -84
  110. package/src/themes/policy.ts +0 -393
  111. package/src/utils/cache.ts +0 -206
  112. package/src/utils/claude.ts +0 -683
  113. package/src/utils/color-support.ts +0 -118
  114. package/src/utils/formatters.ts +0 -99
  115. package/src/utils/logger.ts +0 -5
  116. package/src/utils/outcome.ts +0 -33
  117. package/src/utils/schema-validator.ts +0 -126
  118. package/src/utils/single-flight.ts +0 -57
  119. package/src/utils/terminal-width.ts +0 -51
  120. package/src/utils/terminal.ts +0 -11
  121. package/src/utils/transcript-fs.ts +0 -279
  122. package/src/var-system/index.ts +0 -24
  123. package/src/var-system/sources.ts +0 -1047
  124. package/src/var-system/store.ts +0 -223
  125. package/src/var-system/types.ts +0 -57
  126. package/src/version.ts +0 -17
@@ -1,720 +0,0 @@
1
- import { dirname, join } from "node:path";
2
-
3
- import {
4
- type SessionInfo,
5
- type UsageInfo,
6
- type TokenBreakdown,
7
- } from "../../segments/session";
8
- import { PricingService } from "../../segments/pricing";
9
- import {
10
- getClaudePaths,
11
- findProjectPaths,
12
- findAgentTranscripts,
13
- readAppendedEntries,
14
- type ClaudeHookData,
15
- type ParsedEntry,
16
- type TranscriptCursor,
17
- } from "../../utils/claude";
18
- // [LAW:single-enforcer] The once-per-day seed's directory walk shares the same
19
- // in-flight-I/O budget (gn4.2) as every other transcript scan.
20
- import {
21
- readdir as gatedReaddir,
22
- stat as gatedStat,
23
- statMtimeMs,
24
- } from "../../utils/transcript-fs";
25
- import { SingleFlight } from "../../utils/single-flight";
26
- import { ABSENT, failed, ok, type Outcome } from "../../utils/outcome";
27
- import { dlog } from "../log";
28
-
29
- // [LAW:one-source-of-truth] The daemon's single owner of per-session usage.
30
- // Per-session records are canonical; the `session` projection (whole-session
31
- // totals) and the `today` projection (cross-session sum of today's per-day
32
- // buckets) are BOTH folds over this one store. There is no second usage cache
33
- // and no per-render whole-tree scan: a render observes the active session's
34
- // change through the single mtime stat it already does, re-parses only that one
35
- // session, and folds in-memory records for everything else.
36
- //
37
- // [LAW:dataflow-not-control-flow] `today` stops being "recompute-if-stale"
38
- // (where the staleness probe — a whole-tree mtime sweep — cost as much as the
39
- // recompute it guarded). The aggregate is derived state maintained
40
- // incrementally: the whole transcript tree is scanned EXACTLY ONCE, lazily, to
41
- // seed records for sessions that did work before this daemon saw them; every
42
- // render after that is a single-file stat plus a fold.
43
-
44
- // [LAW:types-are-the-program] An `ok` TodayInfo always carries real totals —
45
- // "no usage recorded today" is the `absent` outcome arm, not a bag of nulls.
46
- export interface TodayInfo {
47
- cost: number;
48
- tokens: number;
49
- tokenBreakdown: TokenBreakdown;
50
- date: string;
51
- }
52
-
53
- // [LAW:one-source-of-truth] One observation of the active session's cumulative
54
- // token counts at a single instant. tok/s is the delta between two of these —
55
- // the prior sample lives in this store (the single owner of per-session token
56
- // totals), never in a parallel counter. `input` folds the cache lanes into the
57
- // prompt-side total so `total === input + output`. `atMs` is the render's clock
58
- // instant (the daemon's single-enforcer clock), so a frozen test clock makes
59
- // the rate deterministic.
60
- export interface SpeedSample {
61
- readonly input: number;
62
- readonly output: number;
63
- readonly total: number;
64
- readonly atMs: number;
65
- }
66
-
67
- // The prior observation (absent on the very first render of a session), the one
68
- // just taken, and the recent ring (oldest→newest, INCLUDING `cur`). The pure
69
- // projections live at the render-payload boundary; this store only remembers and
70
- // reports. [LAW:one-source-of-truth] `prev === samples[samples.length - 2]` — the
71
- // tok/s baseline and the burn-rate history fold from the SAME owned ring, not two
72
- // parallel stores. tok/s reads the last pair; the sparkline reads every pair.
73
- export interface SpeedObservation {
74
- readonly prev?: SpeedSample;
75
- readonly cur: SpeedSample;
76
- readonly samples: readonly SpeedSample[];
77
- }
78
-
79
- // How many recent samples the burn-rate ring retains per session. A render-cadence
80
- // trend, not an archive: enough to fill a wide sparkline cell, capped so an
81
- // idle-but-alive session can't grow it without bound. The window the sparkline
82
- // draws is a tail slice of this (the `width` arg), so this only sets the ceiling.
83
- const SPEED_RING_CAPACITY = 64;
84
-
85
- function speedSampleOf(
86
- breakdown: TokenBreakdown | null,
87
- atMs: number,
88
- ): SpeedSample {
89
- // Prompt-side = raw input plus both cache lanes (all tokens fed to the model);
90
- // output = generated. total = the same sum the store's `tokens` projection
91
- // uses, so `total === input + output`. [LAW:one-source-of-truth]
92
- const input = breakdown
93
- ? breakdown.input + breakdown.cacheCreation + breakdown.cacheRead
94
- : 0;
95
- const output = breakdown ? breakdown.output : 0;
96
- return { input, output, total: input + output, atMs };
97
- }
98
-
99
- // Per-(session, day) scalar contribution — the only granularity the today fold
100
- // needs. Raw entries are discarded after bucketing, so per-session retained
101
- // memory is O(retained-days), not O(entries).
102
- interface DayUsage {
103
- cost: number;
104
- input: number;
105
- output: number;
106
- cacheCreation: number;
107
- cacheRead: number;
108
- }
109
-
110
- interface SessionRecord {
111
- // [LAW:one-source-of-truth] `files` is canonical (main transcript + agent
112
- // sidechains, each its own incremental FileFold); `sessionInfo`/`days` are
113
- // derived from mergeFolds and re-synced on every ingest — the single writer.
114
- files: Map<string, FileFold>;
115
- sessionInfo: SessionInfo;
116
- days: Map<string, DayUsage>;
117
- transcriptMtime: number;
118
- transcriptPath: string | undefined;
119
- lastSeenAt: number;
120
- }
121
-
122
- const DEFAULT_MAX_ENTRIES = 256;
123
- const DEFAULT_STALE_AGE_MS = 24 * 60 * 60 * 1000;
124
- const DEFAULT_SWEEP_INTERVAL_MS = 5 * 60 * 1000;
125
- // Mirrors the transcript-fs gate width: the seed's parse fan-out is bounded by
126
- // the same constant as the I/O it drives, so the once-a-day scan can never
127
- // re-create the unbounded burst gn4 exists to kill.
128
- const SEED_CONCURRENCY = 8;
129
-
130
- const EMPTY_DAY: DayUsage = {
131
- cost: 0,
132
- input: 0,
133
- output: 0,
134
- cacheCreation: 0,
135
- cacheRead: 0,
136
- };
137
-
138
- const EMPTY_SESSION_INFO: SessionInfo = {
139
- cost: null,
140
- calculatedCost: null,
141
- officialCost: null,
142
- tokens: null,
143
- tokenBreakdown: null,
144
- };
145
-
146
- function dayKey(date: Date): string {
147
- const year = date.getFullYear();
148
- const month = String(date.getMonth() + 1).padStart(2, "0");
149
- const day = String(date.getDate()).padStart(2, "0");
150
- return `${year}-${month}-${day}`;
151
- }
152
-
153
- // Files modified before this can hold no entry that lands in "today", so the
154
- // seed never parses them. A full day of slack absorbs timezone/rollover skew.
155
- function seedCutoffMs(): number {
156
- const d = new Date();
157
- d.setHours(0, 0, 0, 0);
158
- d.setDate(d.getDate() - 1);
159
- return d.getTime();
160
- }
161
-
162
- function emptyBreakdown(): TokenBreakdown {
163
- return { input: 0, output: 0, cacheCreation: 0, cacheRead: 0 };
164
- }
165
-
166
- // [LAW:one-source-of-truth] The incremental fold of ONE append-only transcript
167
- // file (a session's main transcript or one agent sidechain). `cursor` marks the
168
- // bytes already folded; the sums run over every usage-bearing entry seen so far.
169
- // The session aggregate is the MERGE of its files' folds (mergeFolds), so a file
170
- // rewritten by /compact re-folds in isolation without disturbing the others.
171
- interface FileFold {
172
- cursor: TranscriptCursor;
173
- entries: number; // usage-bearing entries folded (0 ⇒ empty session, all-null)
174
- cost: number;
175
- breakdown: TokenBreakdown;
176
- days: Map<string, DayUsage>; // keys >= seed cutoff only (pruned on fold)
177
- }
178
-
179
- // [LAW:effects-at-boundaries] Pure fold of NEW entries onto a prior file fold.
180
- // Returns a FRESH FileFold (prior is never mutated) so two concurrent refolds
181
- // sharing one prior snapshot can't corrupt each other. `reset` (file rewritten)
182
- // discards the prior sums and folds `entries` — the whole new file — from zero.
183
- //
184
- // [LAW:one-source-of-truth] Session cost and day cost use the SAME priced value:
185
- // an entry lacking costUSD is priced once (PricingService) and that figure feeds
186
- // BOTH the session total and its day bucket. This matches the old path exactly —
187
- // it mutated entry.costUSD to the priced cost before bucketByDay read it — so
188
- // `today` is not silently under-counted for un-costed entries.
189
- async function foldFile(
190
- prior: FileFold | undefined,
191
- reset: boolean,
192
- entries: readonly ParsedEntry[],
193
- cursor: TranscriptCursor,
194
- ): Promise<FileFold> {
195
- // dayKey strings sort lexically == chronologically, so "keep recent" is a
196
- // string comparison against yesterday's key.
197
- const keep = dayKey(new Date(seedCutoffMs()));
198
- const base = prior && !reset ? prior : undefined;
199
- let count = base?.entries ?? 0;
200
- let cost = base?.cost ?? 0;
201
- const breakdown = base ? { ...base.breakdown } : emptyBreakdown();
202
- const days = new Map<string, DayUsage>();
203
- if (base) {
204
- for (const [k, v] of base.days) if (k >= keep) days.set(k, { ...v });
205
- }
206
- for (const entry of entries) {
207
- const u = entry.message?.usage;
208
- if (!u) continue;
209
- count++;
210
- const priced =
211
- entry.costUSD ?? (await PricingService.calculateCostForEntry(entry.raw));
212
- cost += priced;
213
- breakdown.input += u.input_tokens || 0;
214
- breakdown.output += u.output_tokens || 0;
215
- breakdown.cacheCreation += u.cache_creation_input_tokens || 0;
216
- breakdown.cacheRead += u.cache_read_input_tokens || 0;
217
- const key = dayKey(new Date(entry.timestamp));
218
- if (key < keep) continue;
219
- const d = days.get(key) ?? { ...EMPTY_DAY };
220
- // [LAW:one-source-of-truth] Day cost uses the PRICED value, same as session
221
- // cost — the old path mutated entry.costUSD to the priced cost before
222
- // bucketByDay read it, so an un-costed entry contributed its priced value to
223
- // `today`, not 0. Using `priced` here preserves that (session and day agree).
224
- d.cost += priced;
225
- d.input += u.input_tokens || 0;
226
- d.output += u.output_tokens || 0;
227
- d.cacheCreation += u.cache_creation_input_tokens || 0;
228
- d.cacheRead += u.cache_read_input_tokens || 0;
229
- days.set(key, d);
230
- }
231
- return { cursor, entries: count, cost, breakdown, days };
232
- }
233
-
234
- // [LAW:effects-at-boundaries] Pure merge of a session's file folds into the
235
- // derived record shape the read path serves. `entries === 0` across all files is
236
- // the empty session (all-null SessionInfo, whose fields the payload drops) — the
237
- // same distinction the old `entries.length === 0` arm carried.
238
- function mergeFolds(files: ReadonlyMap<string, FileFold>): {
239
- sessionInfo: SessionInfo;
240
- days: Map<string, DayUsage>;
241
- } {
242
- let count = 0;
243
- let cost = 0;
244
- const bd = emptyBreakdown();
245
- const days = new Map<string, DayUsage>();
246
- for (const f of files.values()) {
247
- count += f.entries;
248
- cost += f.cost;
249
- bd.input += f.breakdown.input;
250
- bd.output += f.breakdown.output;
251
- bd.cacheCreation += f.breakdown.cacheCreation;
252
- bd.cacheRead += f.breakdown.cacheRead;
253
- for (const [k, v] of f.days) {
254
- const d = days.get(k) ?? { ...EMPTY_DAY };
255
- d.cost += v.cost;
256
- d.input += v.input;
257
- d.output += v.output;
258
- d.cacheCreation += v.cacheCreation;
259
- d.cacheRead += v.cacheRead;
260
- days.set(k, d);
261
- }
262
- }
263
- if (count === 0) return { sessionInfo: EMPTY_SESSION_INFO, days };
264
- const tokens = bd.input + bd.output + bd.cacheCreation + bd.cacheRead;
265
- return {
266
- sessionInfo: {
267
- cost,
268
- calculatedCost: cost,
269
- officialCost: null,
270
- tokens,
271
- tokenBreakdown: bd,
272
- },
273
- days,
274
- };
275
- }
276
-
277
- // Bounded-concurrency fan-out for the seed: at most `limit` parses in flight.
278
- async function mapPool<T>(
279
- items: readonly T[],
280
- limit: number,
281
- fn: (item: T) => Promise<unknown>,
282
- ): Promise<void> {
283
- let cursor = 0;
284
- const worker = async (): Promise<void> => {
285
- // The while-guard proves the index is in range; the `!` discharges
286
- // noUncheckedIndexedAccess, it is not a defensive guard.
287
- while (cursor < items.length) {
288
- const item = items[cursor++]!;
289
- await fn(item);
290
- }
291
- };
292
- const width = Math.min(limit, items.length);
293
- await Promise.all(Array.from({ length: width }, worker));
294
- }
295
-
296
- export class SessionUsageStore {
297
- private readonly entries = new Map<string, SessionRecord>();
298
- // [LAW:one-source-of-truth] Coalesces concurrent MISSES for the same
299
- // (session, observed mtime) onto one parse; cleared on settle (a coalescer,
300
- // not a cache — the records map IS the cache).
301
- private readonly flight = new SingleFlight();
302
- // [LAW:dataflow-not-control-flow] Per-day memo of the one seed scan. Unlike
303
- // SingleFlight this RETAINS the resolved promise for the day, so after the
304
- // first seed completes every later read awaits an already-settled promise —
305
- // zero rescan. A rejected seed is dropped so the next read retries.
306
- private readonly seeded = new Map<string, Promise<void>>();
307
- // [LAW:one-source-of-truth] The recent tok/s observations per session, a
308
- // bounded ring (oldest→newest). tok/s is a derivative of the SAME token totals
309
- // the records map already owns; the baseline (prior counts + time) is the ring's
310
- // last element, not a parallel counter. The burn-rate sparkline folds over the
311
- // whole ring; tok/s folds over its final pair. One call to observeSpeed appends;
312
- // the pure delta math is render-payload's.
313
- private readonly speedRings = new Map<string, SpeedSample[]>();
314
- // [LAW:no-ambient-temporal-coupling] Explicit owner of observe/commit ordering
315
- // for the speed sample. Concurrent renders observing the SAME transcript state
316
- // (key = `${sessionId}:${mtime}`) share ONE observation and commit the baseline
317
- // exactly once — so they return the same prev+cur and render identical,
318
- // deterministic throughput instead of the second clobbering the first.
319
- private readonly speedFlight = new SingleFlight();
320
- private readonly maxEntries: number;
321
- private readonly staleAgeMs: number;
322
- private hits = 0;
323
- private misses = 0;
324
- private sweeps = 0;
325
- private seeds = 0;
326
- private sweepTimer: NodeJS.Timeout | null = null;
327
-
328
- constructor(
329
- opts: {
330
- maxEntries?: number;
331
- staleAgeMs?: number;
332
- sweepIntervalMs?: number;
333
- } = {},
334
- ) {
335
- this.maxEntries = opts.maxEntries ?? DEFAULT_MAX_ENTRIES;
336
- this.staleAgeMs = opts.staleAgeMs ?? DEFAULT_STALE_AGE_MS;
337
- const interval = opts.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS;
338
- if (interval > 0) {
339
- this.sweepTimer = setInterval(() => this.sweepStale(), interval);
340
- this.sweepTimer.unref();
341
- }
342
- }
343
-
344
- getStats(): {
345
- size: number;
346
- hits: number;
347
- misses: number;
348
- sweeps: number;
349
- seeds: number;
350
- } {
351
- return {
352
- size: this.entries.size,
353
- hits: this.hits,
354
- misses: this.misses,
355
- sweeps: this.sweeps,
356
- seeds: this.seeds,
357
- };
358
- }
359
-
360
- // The `session` projection: whole-session totals for the active session.
361
- // [LAW:no-silent-failure] A failed transcript parse flows out as `failed`
362
- // (the payload boundary logs it); an unknown/empty session is the all-null
363
- // SessionInfo whose fields the boundary drops per-field — top-level
364
- // `absent` is reserved for ingest, since the native officialCost overlay
365
- // applies even with no record.
366
- async getUsageInfo(
367
- sessionId: string,
368
- hookData?: ClaudeHookData,
369
- ): Promise<Outcome<UsageInfo>> {
370
- const record = await this.ingest(sessionId, hookData?.transcript_path);
371
- if (record.kind === "failed") return record;
372
- const base =
373
- record.kind === "ok" ? record.value.sessionInfo : EMPTY_SESSION_INFO;
374
- // [LAW:one-source-of-truth] Claude's reported total_cost_usd is the
375
- // authoritative cost of the active session. base.cost (transcript entries
376
- // priced by PricingService against a hand-maintained rate table) is a
377
- // reimplementation — kept ONLY as a fallback for clients that omit cost,
378
- // and to feed the cross-session `today` total, which has no native source
379
- // (past sessions expose only their transcripts, not a live cost figure).
380
- // The native cost is overlaid at READ time, not frozen into the mtime-keyed
381
- // record, because it changes every render while the transcript total moves
382
- // only when the file does.
383
- const officialCost = hookData?.cost?.total_cost_usd ?? null;
384
- return ok({
385
- session: { ...base, cost: officialCost ?? base.cost, officialCost },
386
- });
387
- }
388
-
389
- // The `today` projection: cross-session sum of every record's today bucket.
390
- // [LAW:no-silent-failure] A failed seed or a failed active-session ingest
391
- // makes the whole projection `failed` — a total silently missing today's
392
- // main work would be a confident wrong number, worse than a loud gap.
393
- async getTodayInfo(hookData?: ClaudeHookData): Promise<Outcome<TodayInfo>> {
394
- const today = dayKey(new Date());
395
- try {
396
- await this.ensureSeeded(today);
397
- } catch (error) {
398
- return failed(
399
- `usage seed: ${error instanceof Error ? error.message : String(error)}`,
400
- );
401
- }
402
- // Keep the active session fresh: the seed runs once per day, so after it
403
- // every render's freshness for the active session comes from here (a hit
404
- // when its transcript is unchanged). Empty sessionId no-ops in ingest.
405
- const active = await this.ingest(
406
- hookData?.session_id ?? "",
407
- hookData?.transcript_path,
408
- );
409
- if (active.kind === "failed") return active;
410
-
411
- const total: DayUsage = { ...EMPTY_DAY };
412
- let any = false;
413
- for (const record of this.entries.values()) {
414
- any = any || record.days.has(today);
415
- const d = record.days.get(today) ?? EMPTY_DAY;
416
- total.cost += d.cost;
417
- total.input += d.input;
418
- total.output += d.output;
419
- total.cacheCreation += d.cacheCreation;
420
- total.cacheRead += d.cacheRead;
421
- }
422
-
423
- if (!any) return ABSENT;
424
- return ok({
425
- cost: total.cost,
426
- tokens:
427
- total.input + total.output + total.cacheCreation + total.cacheRead,
428
- tokenBreakdown: {
429
- input: total.input,
430
- output: total.output,
431
- cacheCreation: total.cacheCreation,
432
- cacheRead: total.cacheRead,
433
- },
434
- date: today,
435
- });
436
- }
437
-
438
- // Take one tok/s observation of the active session: ingest its current
439
- // cumulative counts, return the prior sample alongside, and record this one as
440
- // the new baseline. [LAW:no-silent-failure] A failed transcript parse flows out
441
- // as `failed` (the boundary logs it and the segment reads "—"); an unknown
442
- // session yields a zero-count sample, so a first-ever render establishes a
443
- // baseline without fabricating a rate. `nowMs` is the caller's single-enforcer
444
- // clock instant — the store never reads the clock for tok/s timing itself.
445
- async observeSpeed(
446
- sessionId: string,
447
- transcriptPath: string | undefined,
448
- nowMs: number,
449
- ): Promise<Outcome<SpeedObservation>> {
450
- // [LAW:no-ambient-temporal-coupling] Key the observation by the same
451
- // (session, mtime) tuple ingest uses, so concurrent renders at one transcript
452
- // state coalesce onto a single observe-and-commit — the read of `prev` and
453
- // the write of `cur` happen exactly once for that state, with the flight as
454
- // the sole owner of ordering. A distinct mtime is a genuinely new sample and
455
- // gets its own key.
456
- const mtime = statMtimeMs(transcriptPath);
457
- return this.speedFlight.run(`${sessionId}:${mtime}`, async () => {
458
- const record = await this.ingest(sessionId, transcriptPath, mtime);
459
- if (record.kind === "failed") return record;
460
- const breakdown =
461
- record.kind === "ok" ? record.value.sessionInfo.tokenBreakdown : null;
462
- const cur = speedSampleOf(breakdown, nowMs);
463
- const ring = this.speedRings.get(sessionId) ?? [];
464
- // [LAW:no-ambient-temporal-coupling] Observation time (atMs, the render
465
- // clock) owns ring order — NOT ingest-completion order. Two concurrent
466
- // observes with different mtimes don't coalesce in speedFlight and each
467
- // awaits ingest before this mutation, so a plain append would record
468
- // samples in whichever-ingest-settled-first order and invert oldest→newest.
469
- // prev is the latest sample strictly before this observation; the post-
470
- // insert sort by atMs makes the ring's order independent of completion
471
- // order. (get→insert→set is synchronous after the await, so each resumed
472
- // continuation mutates atomically — no lost update.)
473
- let prev: SpeedSample | undefined;
474
- for (const s of ring) {
475
- if (s.atMs < cur.atMs && (prev === undefined || s.atMs > prev.atMs)) {
476
- prev = s;
477
- }
478
- }
479
- ring.push(cur);
480
- ring.sort((a, b) => a.atMs - b.atMs);
481
- // Drop oldest (smallest atMs ⇒ ring[0]) beyond the cap — a tail window,
482
- // not an archive.
483
- if (ring.length > SPEED_RING_CAPACITY) ring.shift();
484
- this.speedRings.set(sessionId, ring);
485
- return ok({
486
- ...(prev !== undefined && { prev }),
487
- cur,
488
- samples: [...ring],
489
- });
490
- });
491
- }
492
-
493
- // mtime-gated, coalesced re-parse of ONE session. `ok` is its record,
494
- // `absent` is an unknown empty session (or no sessionId), `failed` is a
495
- // transcript that exists but couldn't be parsed — NOT cached (only ok
496
- // records enter the map; the next render retries, same rule as the git
497
- // cache). This is the single write-path into the records map.
498
- private async ingest(
499
- sessionId: string,
500
- transcriptPath: string | undefined,
501
- knownMtime?: number,
502
- ): Promise<Outcome<SessionRecord>> {
503
- if (!sessionId) return ABSENT;
504
-
505
- const mtime = knownMtime ?? statMtimeMs(transcriptPath);
506
- const existing = this.entries.get(sessionId);
507
- if (existing && mtime !== 0 && existing.transcriptMtime === mtime) {
508
- existing.lastSeenAt = Date.now();
509
- this.entries.delete(sessionId);
510
- this.entries.set(sessionId, existing);
511
- this.hits++;
512
- return ok(existing);
513
- }
514
- // No path to read fresh content — preserve the last-known record rather
515
- // than blank it. (The session will refresh when its transcript reappears.)
516
- if (!transcriptPath) return existing ? ok(existing) : ABSENT;
517
-
518
- this.misses++;
519
- // [LAW:no-ambient-temporal-coupling] The flight thunk snapshots the prior
520
- // record when it RUNS (first caller); coalesced callers share that one
521
- // refold. Two concurrent DIFFERENT-mtime refolds each read the same prior and
522
- // last-writer-wins. This is safe — and specifically NOT a double-count —
523
- // because `refold` writes the byte cursor and the fold as ONE atomic pair
524
- // (both from a single refold result), and the fold always covers exactly
525
- // [0, cursor). A losing writer's fold is discarded WITH its cursor, not left
526
- // beside a stale one. So even when last-writer-wins rewinds the stored cursor,
527
- // it rewinds the paired fold with it; the next refold folds [cursor, end) onto
528
- // a fold that lacks those bytes — each byte folded once. `foldFile` is pure
529
- // (fresh FileFold, prior never mutated), so the shared prior can't corrupt.
530
- // Pinned by the concurrency test in test/transcript-incremental.test.ts.
531
- // The prior is read when the thunk RUNS. If evictIfNeeded dropped this
532
- // session between thunk creation and run, prior is undefined and refold does
533
- // one whole-file re-fold — still correct, just non-incremental that once.
534
- // (Eviction only fires after a successful write, so this can bite only on a
535
- // first miss under cap pressure.)
536
- // [LAW:no-silent-failure] ingest MUST stay total — every caller reads
537
- // `.kind`, so a rejected promise (e.g. PricingService.calculateCostForEntry
538
- // throwing on a bad rate table or network hiccup inside foldFile) would
539
- // escape the Outcome contract entirely. The deleted getSessionUsageFromPath
540
- // wrapped this; keep that guarantee by mapping any throw to `failed`.
541
- let outcome: Outcome<{
542
- files: Map<string, FileFold>;
543
- sessionInfo: SessionInfo;
544
- days: Map<string, DayUsage>;
545
- mainMtime: number;
546
- }>;
547
- try {
548
- outcome = await this.flight.run(`${sessionId}:${mtime}`, () =>
549
- this.refold(sessionId, transcriptPath, this.entries.get(sessionId)),
550
- );
551
- } catch (error) {
552
- return failed(
553
- `usage refold (${sessionId}): ${error instanceof Error ? error.message : String(error)}`,
554
- );
555
- }
556
- if (outcome.kind !== "ok") return outcome;
557
- const record: SessionRecord = {
558
- files: outcome.value.files,
559
- sessionInfo: outcome.value.sessionInfo,
560
- days: outcome.value.days,
561
- transcriptMtime: outcome.value.mainMtime,
562
- transcriptPath,
563
- lastSeenAt: Date.now(),
564
- };
565
- this.entries.delete(sessionId);
566
- this.entries.set(sessionId, record);
567
- this.evictIfNeeded();
568
- return ok(record);
569
- }
570
-
571
- // [LAW:dataflow-not-control-flow] Re-fold ONLY the bytes appended since the
572
- // prior record — the transcript (and each agent sidechain) is append-only, so
573
- // per-render work is O(new bytes), not O(file). The whole-file case (cold
574
- // record, or a first-ever read) is the same path with an empty prior: the
575
- // reader yields the whole file from offset 0. A `failed` read on any file
576
- // fails the fold (the boundary logs it); an `absent` file (not yet written /
577
- // agent removed) keeps its prior fold and contributes nothing new.
578
- private async refold(
579
- sessionId: string,
580
- transcriptPath: string,
581
- prior: SessionRecord | undefined,
582
- ): Promise<
583
- Outcome<{
584
- files: Map<string, FileFold>;
585
- sessionInfo: SessionInfo;
586
- days: Map<string, DayUsage>;
587
- mainMtime: number;
588
- }>
589
- > {
590
- // File set for this fold: the main transcript plus the session's current
591
- // agent sidechains. Files no longer in the set (a removed sidechain) drop
592
- // out of `newFiles`, so a rewritten/renamed file cannot linger in the merge.
593
- // Pass the prior file set so already-verified agent sidechains skip their
594
- // first-line re-read — per-render agent discovery is O(new sidechains).
595
- const agentPaths = await findAgentTranscripts(
596
- sessionId,
597
- dirname(transcriptPath),
598
- prior === undefined ? undefined : new Set(prior.files.keys()),
599
- );
600
- const newFiles = new Map<string, FileFold>();
601
- let mainMtime = 0;
602
- for (const filePath of [transcriptPath, ...agentPaths]) {
603
- const priorFold = prior?.files.get(filePath);
604
- const read = await readAppendedEntries(filePath, priorFold?.cursor);
605
- if (read.kind === "failed") return read;
606
- if (read.kind === "absent") {
607
- // The file has no bytes yet (fresh main) or vanished (removed agent
608
- // sidechain). Carry a prior fold forward; otherwise it contributes
609
- // nothing. The main transcript's absence leaves mainMtime 0, so the next
610
- // render's mtime gate re-attempts rather than caching an empty read.
611
- if (priorFold) newFiles.set(filePath, priorFold);
612
- continue;
613
- }
614
- const { entries, cursor, reset } = read.value;
615
- newFiles.set(filePath, await foldFile(priorFold, reset, entries, cursor));
616
- if (filePath === transcriptPath) mainMtime = cursor.mtimeMs;
617
- }
618
- return ok({ files: newFiles, ...mergeFolds(newFiles), mainMtime });
619
- }
620
-
621
- private ensureSeeded(day: string): Promise<void> {
622
- const existing = this.seeded.get(day);
623
- if (existing) return existing;
624
- // Drop other days' memos so the map holds at most the current day.
625
- this.seeded.clear();
626
- const promise = this.seed(day);
627
- this.seeded.set(day, promise);
628
- promise.catch(() => {
629
- if (this.seeded.get(day) === promise) this.seeded.delete(day);
630
- });
631
- return promise;
632
- }
633
-
634
- // The one and only whole-tree scan: lazily, once per day, ingest every
635
- // session whose transcript was touched recently enough to hold a today entry.
636
- private async seed(_day: string): Promise<void> {
637
- const cutoff = seedCutoffMs();
638
- const projectPaths = await findProjectPaths(getClaudePaths());
639
- const candidates: Array<{
640
- sessionId: string;
641
- path: string;
642
- mtime: number;
643
- }> = [];
644
-
645
- for (const dir of projectPaths) {
646
- let files: string[];
647
- try {
648
- files = await gatedReaddir(dir);
649
- } catch {
650
- continue;
651
- }
652
- for (const file of files) {
653
- if (!file.endsWith(".jsonl")) continue;
654
- const filePath = join(dir, file);
655
- let mtime: number;
656
- try {
657
- mtime = (await gatedStat(filePath)).mtimeMs;
658
- } catch {
659
- continue;
660
- }
661
- if (mtime < cutoff) continue;
662
- candidates.push({
663
- sessionId: file.slice(0, -".jsonl".length),
664
- path: filePath,
665
- mtime,
666
- });
667
- }
668
- }
669
-
670
- // [LAW:no-silent-failure] The seed is its own effect edge (timer/lazy
671
- // driven, no render boundary to carry the outcome to), so its per-session
672
- // parse failures are logged here.
673
- await mapPool(candidates, SEED_CONCURRENCY, async (c) => {
674
- const outcome = await this.ingest(c.sessionId, c.path, c.mtime);
675
- if (outcome.kind === "failed") {
676
- dlog("warn", `usageStore seed: ${outcome.reason}`);
677
- }
678
- });
679
- this.seeds++;
680
- dlog("info", `usageStore seed sessions=${candidates.length}`);
681
- }
682
-
683
- // Public for tests; called periodically from the timer.
684
- sweepStale(): number {
685
- const now = Date.now();
686
- let dropped = 0;
687
- for (const [sid, record] of this.entries) {
688
- if (now - record.lastSeenAt > this.staleAgeMs) {
689
- this.entries.delete(sid);
690
- this.speedRings.delete(sid);
691
- dropped++;
692
- }
693
- }
694
- if (dropped > 0) {
695
- this.sweeps++;
696
- dlog("info", `usageStore sweep dropped=${dropped}`);
697
- }
698
- return dropped;
699
- }
700
-
701
- private evictIfNeeded(): void {
702
- while (this.entries.size > this.maxEntries) {
703
- const oldest = this.entries.keys().next().value;
704
- if (oldest === undefined) break;
705
- this.entries.delete(oldest);
706
- this.speedRings.delete(oldest);
707
- dlog("info", `usageStore evict ${oldest}`);
708
- }
709
- }
710
-
711
- close(): void {
712
- if (this.sweepTimer) {
713
- clearInterval(this.sweepTimer);
714
- this.sweepTimer = null;
715
- }
716
- this.entries.clear();
717
- this.seeded.clear();
718
- this.speedRings.clear();
719
- }
720
- }