@promptctl/cc-candybar 1.17.4 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptctl/cc-candybar",
3
- "version": "1.17.4",
3
+ "version": "1.18.0",
4
4
  "description": "Statusline renderer for Claude Code — a JSON5-configurable DSL with daemon-cached data sources, byte-clean palette-aware composition, and OSC8 click verbs.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.mjs",
@@ -24,6 +24,10 @@
24
24
  "test:coverage": "jest --coverage",
25
25
  "demo": "tsx src/demo/dsl.ts",
26
26
  "benchmark:timing": "scripts/benchmark_timing.sh",
27
+ "preloadtest": "pnpm build",
28
+ "loadtest": "node --import tsx scripts/daemon-load-harness.ts",
29
+ "preloadtest:gate": "pnpm build",
30
+ "loadtest:gate": "node --import tsx scripts/daemon-load-harness.ts --sessions 25 --interval 300 --duration 20 --churn --transcript-lines 5000",
27
31
  "check:protocol": "node scripts/check-protocol.mjs",
28
32
  "gen:schema": "tsx scripts/gen-schema.ts",
29
33
  "check:schema": "tsx scripts/check-schema.ts",
@@ -91,10 +95,10 @@
91
95
  "mobx": "^6.15.0"
92
96
  },
93
97
  "optionalDependencies": {
94
- "@promptctl/cc-candybar-darwin-arm64": "1.17.4",
95
- "@promptctl/cc-candybar-darwin-x64": "1.17.4",
96
- "@promptctl/cc-candybar-linux-x64": "1.17.4",
97
- "@promptctl/cc-candybar-linux-arm64": "1.17.4"
98
+ "@promptctl/cc-candybar-darwin-arm64": "1.18.0",
99
+ "@promptctl/cc-candybar-darwin-x64": "1.18.0",
100
+ "@promptctl/cc-candybar-linux-x64": "1.18.0",
101
+ "@promptctl/cc-candybar-linux-arm64": "1.18.0"
98
102
  },
99
103
  "pnpm": {
100
104
  "supportedArchitectures": {
@@ -627,15 +627,18 @@ export const DEFAULT_DSL_CONFIG = {
627
627
  fg: "foreground",
628
628
  when: '{{ or (ne .git.prUrl "") (ne .git.prError "") }}',
629
629
  },
630
- // Quick-action tray — copy the session id / cwd, open the project dir /
631
- // transcript in the editor. [LAW:locality-or-seam] The glyph is the
632
- // REPRESENTATION; the named action (below) is the BEHAVIOR; the action
633
- // name is the seam between them. Re-glyph without touching behavior;
634
- // re-target without touching this template. Each `{{ action … }}` emits
635
- // one OSC-8 clickable region whose URL the wire codec owns end-to-end.
630
+ // Quick-action tray — the default bar's interactivity: copy the session id,
631
+ // open the project dir / transcript (this session's jsonl) in the editor.
632
+ // (copyDir — copy the cwd — stays declared as an action below for users who
633
+ // want a fourth glyph; it is simply not in the default tray.)
634
+ // [LAW:locality-or-seam] The glyph is the REPRESENTATION; the named action
635
+ // (below) is the BEHAVIOR; the action name is the seam between them. Re-glyph
636
+ // without touching behavior; re-target without touching this template. Each
637
+ // `{{ action … }}` emits one OSC-8 clickable region whose URL the wire codec
638
+ // owns end-to-end.
636
639
  toolbar: {
637
640
  template:
638
- '{{ action "copySession" "⎘ id" }} {{ action "copyDir" "⎘ cwd" }}' +
641
+ '{{ action "copySession" "⎘ id" }}' +
639
642
  ' {{ action "openProject" "↗ proj" }} {{ action "openTranscript" "↗ log" }}',
640
643
  bg: "surface",
641
644
  fg: "foreground",
@@ -808,11 +811,24 @@ export const DEFAULT_DSL_CONFIG = {
808
811
  // Default layout — the canonical LayoutNode tree (`satisfies DslConfig`
809
812
  // requires the lowered form here; the terse Option-A `{ h/v/seg }` grammar is
810
813
  // the loader's authoring surface for user JSON, not this typed literal).
811
- // [LAW:dataflow-not-control-flow] One always-on control row. The styleControl's
812
- // {{ menu }} disclosure drops its picker body onto the line directly below this
813
- // row when open openness is the value of the derived menus.* key, NOT a when-
814
- // gated reveal row. The picker draws the ✕/←/→ affordances from the page + term
815
- // width; the vertical container stacks the dropped body under row 0.
814
+ //
815
+ // Two rows stacked by the vertical container: an IDENTITY + ACTIONS row
816
+ // (where am I / what can I do here the directory, the verbose `gitaculous`
817
+ // line (repo, sha, working-tree, upstream, stash, time-since-commit), then the
818
+ // quick-action tray: copy session id, open project / transcript in the editor)
819
+ // over a STATUS row (what's happening now — model, context-window fill,
820
+ // prompt-cache warmth, and the 5h / 7d rate-limit quotas). The tray sits on
821
+ // the identity row because its actions are workspace-scoped (this session,
822
+ // this project), not usage metrics. Each row zips its segments through the
823
+ // powerline joiner; `\n` separates the rows.
824
+ //
825
+ // [LAW:dataflow-not-control-flow] Every status segment is when-gated on its
826
+ // own signal (no repo → the identity row is just the directory + tray; no
827
+ // active rate-limit window → block/weekly drop; no cache activity → cacheTimer
828
+ // drops). A row therefore only ever shows the segments that have real data —
829
+ // the layout is chosen by the data, not by branches — so the default never
830
+ // paints an empty or placeholder cell. The directory and the tray have no
831
+ // `when`, so row 1 always anchors the bar.
816
832
  root: {
817
833
  kind: "container",
818
834
  direction: "vertical",
@@ -822,13 +838,19 @@ export const DEFAULT_DSL_CONFIG = {
822
838
  direction: "horizontal",
823
839
  children: [
824
840
  { kind: "segment", name: "directory" },
825
- { kind: "segment", name: "git" },
841
+ { kind: "segment", name: "gitaculous" },
842
+ { kind: "segment", name: "toolbar" },
843
+ ],
844
+ },
845
+ {
846
+ kind: "container",
847
+ direction: "horizontal",
848
+ children: [
826
849
  { kind: "segment", name: "model" },
827
- { kind: "segment", name: "session" },
828
- { kind: "segment", name: "today" },
829
850
  { kind: "segment", name: "context" },
830
- { kind: "segment", name: "toolbar" },
831
- { kind: "segment", name: "styleControl" },
851
+ { kind: "segment", name: "cacheTimer" },
852
+ { kind: "segment", name: "block" },
853
+ { kind: "segment", name: "weekly" },
832
854
  ],
833
855
  },
834
856
  ],
@@ -1,23 +1,26 @@
1
- import fs from "node:fs";
2
- import { join } from "node:path";
1
+ import { dirname, join } from "node:path";
3
2
 
4
3
  import {
5
- SessionProvider,
6
4
  type SessionInfo,
7
- type SessionUsage,
8
5
  type UsageInfo,
9
6
  type TokenBreakdown,
10
7
  } from "../../segments/session";
8
+ import { PricingService } from "../../segments/pricing";
11
9
  import {
12
10
  getClaudePaths,
13
11
  findProjectPaths,
12
+ findAgentTranscripts,
13
+ readAppendedEntries,
14
14
  type ClaudeHookData,
15
+ type ParsedEntry,
16
+ type TranscriptCursor,
15
17
  } from "../../utils/claude";
16
18
  // [LAW:single-enforcer] The once-per-day seed's directory walk shares the same
17
19
  // in-flight-I/O budget (gn4.2) as every other transcript scan.
18
20
  import {
19
21
  readdir as gatedReaddir,
20
22
  stat as gatedStat,
23
+ statMtimeMs,
21
24
  } from "../../utils/transcript-fs";
22
25
  import { SingleFlight } from "../../utils/single-flight";
23
26
  import { ABSENT, failed, ok, type Outcome } from "../../utils/outcome";
@@ -105,6 +108,10 @@ interface DayUsage {
105
108
  }
106
109
 
107
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>;
108
115
  sessionInfo: SessionInfo;
109
116
  days: Map<string, DayUsage>;
110
117
  transcriptMtime: number;
@@ -152,33 +159,119 @@ function seedCutoffMs(): number {
152
159
  return d.getTime();
153
160
  }
154
161
 
155
- function statMtimeMs(filePath: string | undefined): number {
156
- if (!filePath) return 0;
157
- try {
158
- return fs.statSync(filePath).mtimeMs;
159
- } catch {
160
- return 0;
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)
162
177
  }
163
178
 
164
- // dayKey strings sort lexically == chronologically, so "keep recent" is a
165
- // string comparison against yesterday's key.
166
- function bucketByDay(usage: SessionUsage): Map<string, DayUsage> {
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.
167
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();
168
202
  const days = new Map<string, DayUsage>();
169
- for (const entry of usage.entries) {
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;
170
217
  const key = dayKey(new Date(entry.timestamp));
171
218
  if (key < keep) continue;
172
219
  const d = days.get(key) ?? { ...EMPTY_DAY };
173
- const u = entry.message.usage;
174
- d.cost += entry.costUSD ?? 0;
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;
175
225
  d.input += u.input_tokens || 0;
176
226
  d.output += u.output_tokens || 0;
177
227
  d.cacheCreation += u.cache_creation_input_tokens || 0;
178
228
  d.cacheRead += u.cache_read_input_tokens || 0;
179
229
  days.set(key, d);
180
230
  }
181
- return days;
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
+ };
182
275
  }
183
276
 
184
277
  // Bounded-concurrency fan-out for the seed: at most `limit` parses in flight.
@@ -201,7 +294,6 @@ async function mapPool<T>(
201
294
  }
202
295
 
203
296
  export class SessionUsageStore {
204
- private readonly sessions = new SessionProvider();
205
297
  private readonly entries = new Map<string, SessionRecord>();
206
298
  // [LAW:one-source-of-truth] Coalesces concurrent MISSES for the same
207
299
  // (session, observed mtime) onto one parse; cleared on settle (a coalescer,
@@ -424,15 +516,49 @@ export class SessionUsageStore {
424
516
  if (!transcriptPath) return existing ? ok(existing) : ABSENT;
425
517
 
426
518
  this.misses++;
427
- const aggregate = await this.flight.run(`${sessionId}:${mtime}`, () =>
428
- this.aggregate(sessionId, transcriptPath),
429
- );
430
- if (aggregate.kind !== "ok") return aggregate;
431
- // Tag with the mtime observed AFTER the read so the next render that sees
432
- // the same mtime is a safe hit.
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;
433
557
  const record: SessionRecord = {
434
- ...aggregate.value,
435
- transcriptMtime: statMtimeMs(transcriptPath),
558
+ files: outcome.value.files,
559
+ sessionInfo: outcome.value.sessionInfo,
560
+ days: outcome.value.days,
561
+ transcriptMtime: outcome.value.mainMtime,
436
562
  transcriptPath,
437
563
  lastSeenAt: Date.now(),
438
564
  };
@@ -442,23 +568,54 @@ export class SessionUsageStore {
442
568
  return ok(record);
443
569
  }
444
570
 
445
- private async aggregate(
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(
446
579
  sessionId: string,
447
580
  transcriptPath: string,
581
+ prior: SessionRecord | undefined,
448
582
  ): Promise<
449
- Outcome<{ sessionInfo: SessionInfo; days: Map<string, DayUsage> }>
583
+ Outcome<{
584
+ files: Map<string, FileFold>;
585
+ sessionInfo: SessionInfo;
586
+ days: Map<string, DayUsage>;
587
+ mainMtime: number;
588
+ }>
450
589
  > {
451
- const usage = await this.sessions.getSessionUsageFromPath(
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(
452
596
  sessionId,
453
- transcriptPath,
597
+ dirname(transcriptPath),
598
+ prior === undefined ? undefined : new Set(prior.files.keys()),
454
599
  );
455
- // getSessionUsageFromPath never produces absent (an empty transcript is a
456
- // real zero-entry usage); failed passes through to the boundary.
457
- if (usage.kind !== "ok") return usage;
458
- return ok({
459
- sessionInfo: this.sessions.toSessionInfo(usage.value),
460
- days: bucketByDay(usage.value),
461
- });
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 });
462
619
  }
463
620
 
464
621
  private ensureSeeded(day: string): Promise<void> {
@@ -1,10 +1,14 @@
1
1
  import type { ClaudeHookData, ParsedEntry } from "../utils/claude";
2
2
 
3
- // [LAW:one-source-of-truth] Metrics reads the transcript through the shared
4
- // mtime-keyed parse LRU the same parse the session/context segments already
5
- // performed this render instead of a private readFile+parse. One parse path,
6
- // one cache; the multi-MB content arrays are dropped at parse time.
7
- import { parseJsonlFile } from "../utils/claude";
3
+ // [LAW:one-source-of-truth] Metrics reads the transcript through the SAME
4
+ // incremental append reader the usage store uses, folding only the bytes added
5
+ // since last render into a running message count + a bounded recent-entry ring —
6
+ // never a whole-file re-parse. Before this, metrics re-parsed the entire growing
7
+ // transcript every render (free only while it piggybacked the store's cache hit);
8
+ // once the store went incremental, a full re-parse here would have re-inherited
9
+ // the very stall the store fix removed. Now both consumers are O(new bytes).
10
+ import { readAppendedEntries, type TranscriptCursor } from "../utils/claude";
11
+ import { statMtimeMs } from "../utils/transcript-fs";
8
12
  import { debug } from "../utils/logger";
9
13
  import { ABSENT, failed, ok, type Outcome } from "../utils/outcome";
10
14
 
@@ -21,6 +25,25 @@ export interface MetricsInfo {
21
25
  linesRemoved: number;
22
26
  }
23
27
 
28
+ // lastResponseTime only inspects the most recent turns for a user→assistant
29
+ // pair; a bounded tail of non-sidechain entries is all it needs. The ring caps
30
+ // per-session retained memory at O(1) regardless of transcript length.
31
+ const RECENT_WINDOW = 20;
32
+ // Bound the per-session fold map like the usage store's records — a long-lived
33
+ // daemon sees many sessions; the ring is tiny, but the map must not grow without
34
+ // limit. LRU by insertion order (delete+set on write), evict oldest past the cap.
35
+ const MAX_SESSIONS = 256;
36
+
37
+ // [LAW:one-source-of-truth] The incremental fold of one session's metrics: the
38
+ // byte cursor already consumed, the running real-user message count, and the
39
+ // recent-entry ring. Canonical; MetricsInfo's transcript-derived fields derive
40
+ // from it, the hookData-derived fields (durations, lines) are always fresh.
41
+ interface MetricsState {
42
+ cursor: TranscriptCursor;
43
+ messageCount: number;
44
+ recent: ParsedEntry[];
45
+ }
46
+
24
47
  // A real user turn vs. a tool_result echoed back as a "user" line. The
25
48
  // discriminator is metrics-local policy over the shared ParsedEntry scalars.
26
49
  function isRealUserMessage(entry: ParsedEntry): boolean {
@@ -31,19 +54,17 @@ function isRealUserMessage(entry: ParsedEntry): boolean {
31
54
  }
32
55
 
33
56
  export class MetricsProvider {
34
- private calculateMessageCount(entries: ParsedEntry[]): number {
35
- return entries.filter(isRealUserMessage).length;
36
- }
57
+ // [LAW:no-shared-mutable-globals] Single owner, hard cap, LRU eviction — the
58
+ // per-session incremental fold state. One instance per daemon.
59
+ private readonly state = new Map<string, MetricsState>();
37
60
 
38
61
  private calculateLastResponseTime(entries: ParsedEntry[]): number | null {
39
62
  if (entries.length === 0) return null;
40
63
 
41
- const recentEntries = entries.slice(-20);
42
-
43
64
  let lastUserTime: Date | null = null;
44
65
  let bestResponseTime: number | null = null;
45
66
 
46
- for (const entry of recentEntries) {
67
+ for (const entry of entries) {
47
68
  const messageType =
48
69
  entry.type || entry.message?.role || entry.message?.type;
49
70
 
@@ -61,6 +82,80 @@ export class MetricsProvider {
61
82
  return bestResponseTime;
62
83
  }
63
84
 
85
+ // [LAW:dataflow-not-control-flow] Fold the appended entries onto the prior
86
+ // state, producing a FRESH state (prior is never mutated) so two concurrent
87
+ // renders of one session both fold from the same prior and last-writer-wins is
88
+ // a correct answer, never a double-count. `reset` (a /compact rewrite) folds
89
+ // from empty. Returns the transcript-derived fields the caller composes with
90
+ // the always-fresh hookData fields.
91
+ private async foldMetrics(
92
+ sessionId: string,
93
+ transcriptPath: string,
94
+ ): Promise<
95
+ // [LAW:types-are-the-program] Never `absent` — a missing transcript is a real
96
+ // zero-count fold (ok), so the caller has exactly two arms to handle.
97
+ | { kind: "ok"; value: { messageCount: number; recent: ParsedEntry[] } }
98
+ | { kind: "failed"; reason: string }
99
+ > {
100
+ const prior = this.state.get(sessionId);
101
+ const mtime = statMtimeMs(transcriptPath);
102
+ // [LAW:no-ambient-temporal-coupling] Fast hit: the transcript is unchanged
103
+ // since we last folded it, so the message count + ring stand.
104
+ if (prior && mtime !== 0 && prior.cursor.mtimeMs === mtime) {
105
+ // [LAW:no-ambient-temporal-coupling] Re-order on the hit so LRU eviction
106
+ // reflects READ recency, not just write recency — an active session whose
107
+ // transcript is momentarily unchanged must not be evicted ahead of idle
108
+ // ones (matches the usage store's hit path).
109
+ this.state.delete(sessionId);
110
+ this.state.set(sessionId, prior);
111
+ // [LAW:no-shared-mutable-globals] Return a COPY of the stored ring, never
112
+ // the reference — a caller mutating the returned array would silently
113
+ // corrupt the retained state (a heisenbug in the next render's
114
+ // lastResponseTime). Every path (hit/miss/absent) copies. 20 entries.
115
+ return {
116
+ kind: "ok",
117
+ value: { messageCount: prior.messageCount, recent: [...prior.recent] },
118
+ };
119
+ }
120
+
121
+ const read = await readAppendedEntries(transcriptPath, prior?.cursor);
122
+ if (read.kind === "failed") return read;
123
+ if (read.kind === "absent") {
124
+ // No transcript yet (fresh session) — keep prior if any, don't cache a
125
+ // cursor so the next render retries when the file appears. Copy the ring
126
+ // (like the hit/miss paths) so no stored reference escapes.
127
+ return {
128
+ kind: "ok",
129
+ value: {
130
+ messageCount: prior?.messageCount ?? 0,
131
+ recent: [...(prior?.recent ?? [])],
132
+ },
133
+ };
134
+ }
135
+
136
+ const { entries, cursor, reset } = read.value;
137
+ const base = prior && !reset ? prior : undefined;
138
+ let messageCount = base?.messageCount ?? 0;
139
+ let recent = base ? [...base.recent] : [];
140
+ for (const entry of entries) {
141
+ if (entry.isSidechain) continue;
142
+ if (isRealUserMessage(entry)) messageCount++;
143
+ recent.push(entry);
144
+ }
145
+ if (recent.length > RECENT_WINDOW) recent = recent.slice(-RECENT_WINDOW);
146
+
147
+ this.state.delete(sessionId);
148
+ this.state.set(sessionId, { cursor, messageCount, recent });
149
+ while (this.state.size > MAX_SESSIONS) {
150
+ const oldest = this.state.keys().next().value;
151
+ if (oldest === undefined) break;
152
+ this.state.delete(oldest);
153
+ }
154
+ // [LAW:no-shared-mutable-globals] `recent` is now the STORED array — copy it
155
+ // out so the returned value shares no reference with retained state.
156
+ return { kind: "ok", value: { messageCount, recent: [...recent] } };
157
+ }
158
+
64
159
  // [LAW:no-silent-failure] A hook payload with no cost block is `absent`
65
160
  // (old clients); a transcript parse error is `failed`, carried to the
66
161
  // payload boundary — the old catch dressed it as the same all-null record
@@ -69,33 +164,24 @@ export class MetricsProvider {
69
164
  sessionId: string,
70
165
  hookData: ClaudeHookData,
71
166
  ): Promise<Outcome<MetricsInfo>> {
72
- try {
73
- debug(`Getting metrics from hook data for session: ${sessionId}`);
167
+ debug(`Getting metrics from hook data for session: ${sessionId}`);
74
168
 
75
- if (!hookData.cost) {
76
- return ABSENT;
77
- }
169
+ if (!hookData.cost) {
170
+ return ABSENT;
171
+ }
78
172
 
79
- // parseJsonlFile keeps sidechain entries (usage needs them); metrics
80
- // counts only main-thread turns, so the sidechain exclusion is local.
81
- const entries = (await parseJsonlFile(hookData.transcript_path)).filter(
82
- (entry) => !entry.isSidechain,
83
- );
84
- const messageCount = this.calculateMessageCount(entries);
85
- const lastResponseTime = this.calculateLastResponseTime(entries);
86
-
87
- return ok({
88
- responseTime: hookData.cost.total_api_duration_ms / 1000,
89
- lastResponseTime,
90
- sessionDuration: hookData.cost.total_duration_ms / 1000,
91
- messageCount,
92
- linesAdded: hookData.cost.total_lines_added,
93
- linesRemoved: hookData.cost.total_lines_removed,
94
- });
95
- } catch (error) {
96
- return failed(
97
- `metrics (${sessionId}): ${error instanceof Error ? error.message : String(error)}`,
98
- );
173
+ const folded = await this.foldMetrics(sessionId, hookData.transcript_path);
174
+ if (folded.kind === "failed") {
175
+ return failed(`metrics (${sessionId}): ${folded.reason}`);
99
176
  }
177
+
178
+ return ok({
179
+ responseTime: hookData.cost.total_api_duration_ms / 1000,
180
+ lastResponseTime: this.calculateLastResponseTime(folded.value.recent),
181
+ sessionDuration: hookData.cost.total_duration_ms / 1000,
182
+ messageCount: folded.value.messageCount,
183
+ linesAdded: hookData.cost.total_lines_added,
184
+ linesRemoved: hookData.cost.total_lines_removed,
185
+ });
100
186
  }
101
187
  }