@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.
@@ -1,30 +1,7 @@
1
- import { debug } from "../utils/logger";
2
- import { PricingService } from "./pricing";
3
- import { failed, ok, type Outcome } from "../utils/outcome";
4
- import {
5
- findAgentTranscripts,
6
- parseJsonlFile,
7
- type ParsedEntry,
8
- } from "../utils/claude";
9
- import { dirname } from "node:path";
10
-
11
- export interface SessionUsageEntry {
12
- timestamp: string;
13
- message: {
14
- usage: {
15
- input_tokens: number;
16
- output_tokens: number;
17
- cache_creation_input_tokens?: number;
18
- cache_read_input_tokens?: number;
19
- };
20
- };
21
- costUSD?: number;
22
- }
23
-
24
- export interface SessionUsage {
25
- totalCost: number;
26
- entries: SessionUsageEntry[];
27
- }
1
+ // [LAW:decomposition] The session usage CONTRACT — the shapes the daemon's
2
+ // SessionUsageStore produces and the render payload reads. The transcript fold
3
+ // that produces them is incremental and lives in the store (its single owner);
4
+ // this module is the type seam both sides agree on.
28
5
 
29
6
  export interface TokenBreakdown {
30
7
  input: number;
@@ -44,145 +21,3 @@ export interface SessionInfo {
44
21
  export interface UsageInfo {
45
22
  session: SessionInfo;
46
23
  }
47
-
48
- function convertToSessionEntry(entry: ParsedEntry): SessionUsageEntry {
49
- return {
50
- timestamp: entry.timestamp.toISOString(),
51
- message: {
52
- usage: {
53
- input_tokens: entry.message?.usage?.input_tokens || 0,
54
- output_tokens: entry.message?.usage?.output_tokens || 0,
55
- cache_creation_input_tokens:
56
- entry.message?.usage?.cache_creation_input_tokens,
57
- cache_read_input_tokens: entry.message?.usage?.cache_read_input_tokens,
58
- },
59
- },
60
- costUSD: entry.costUSD,
61
- };
62
- }
63
-
64
- export class SessionProvider {
65
- // [LAW:one-source-of-truth] The transcript path is always in hand — the
66
- // daemon's SessionUsageStore holds it (hookData.transcript_path or its seed
67
- // scan) and passes it straight here. There is no lookup-by-id path: recovering
68
- // a path we already hold by scanning every project dir is the data-duplication
69
- // this method exists to avoid.
70
- // [LAW:no-silent-failure] A transcript that exists but can't be read or
71
- // parsed is `failed`, not an empty session — the old catch-to-null dressed
72
- // a read failure as "no usage", and the lie survived all the way to the
73
- // rendered bar. An empty transcript is a real (zero-entry) usage, not
74
- // absence.
75
- async getSessionUsageFromPath(
76
- sessionId: string,
77
- transcriptPath: string,
78
- ): Promise<Outcome<SessionUsage>> {
79
- try {
80
- debug(`Found transcript at: ${transcriptPath}`);
81
-
82
- const mainEntries = await parseJsonlFile(transcriptPath);
83
- const projectPath = dirname(transcriptPath);
84
- const agentTranscripts = await findAgentTranscripts(
85
- sessionId,
86
- projectPath,
87
- );
88
-
89
- debug(`Found ${agentTranscripts.length} agent transcripts for session`);
90
-
91
- // [LAW:one-source-of-truth] parseJsonlFile returns its cached entries
92
- // array BY REFERENCE; the parse cache is the canonical store of a file's
93
- // parsed entries and is shared across providers. Mutating that array
94
- // (the old `push`) corrupted the cache — agent entries leaked into the
95
- // main transcript's cached value and re-appended on every warm hit.
96
- // Build a fresh combined list instead so no shared array is touched.
97
- const agentEntries = (
98
- await Promise.all(agentTranscripts.map((p) => parseJsonlFile(p)))
99
- ).flat();
100
- const parsedEntries = [...mainEntries, ...agentEntries];
101
-
102
- if (parsedEntries.length === 0) {
103
- return ok({ totalCost: 0, entries: [] });
104
- }
105
-
106
- const entries: SessionUsageEntry[] = [];
107
- let totalCost = 0;
108
-
109
- for (const entry of parsedEntries) {
110
- if (entry.message?.usage) {
111
- const sessionEntry = convertToSessionEntry(entry);
112
-
113
- if (sessionEntry.costUSD !== undefined) {
114
- totalCost += sessionEntry.costUSD;
115
- } else {
116
- const cost = await PricingService.calculateCostForEntry(entry.raw);
117
- sessionEntry.costUSD = cost;
118
- totalCost += cost;
119
- }
120
-
121
- entries.push(sessionEntry);
122
- }
123
- }
124
-
125
- debug(
126
- `Parsed ${entries.length} usage entries, total cost: $${totalCost.toFixed(4)}`,
127
- );
128
- return ok({ totalCost, entries });
129
- } catch (error) {
130
- return failed(
131
- `session transcript (${sessionId}): ${error instanceof Error ? error.message : String(error)}`,
132
- );
133
- }
134
- }
135
-
136
- calculateTokenBreakdown(entries: SessionUsageEntry[]): TokenBreakdown {
137
- return entries.reduce(
138
- (breakdown, entry) => ({
139
- input: breakdown.input + (entry.message.usage.input_tokens || 0),
140
- output: breakdown.output + (entry.message.usage.output_tokens || 0),
141
- cacheCreation:
142
- breakdown.cacheCreation +
143
- (entry.message.usage.cache_creation_input_tokens || 0),
144
- cacheRead:
145
- breakdown.cacheRead +
146
- (entry.message.usage.cache_read_input_tokens || 0),
147
- }),
148
- { input: 0, output: 0, cacheCreation: 0, cacheRead: 0 },
149
- );
150
- }
151
-
152
- // [LAW:types-are-the-program] Pure projection SessionUsage → SessionInfo, no
153
- // I/O. The store computes it once per ingest from already-parsed usage; the
154
- // empty-usage arm yields the all-null SessionInfo (which the payload drops).
155
- toSessionInfo(sessionUsage: SessionUsage): SessionInfo {
156
- if (sessionUsage.entries.length === 0) {
157
- return {
158
- cost: null,
159
- calculatedCost: null,
160
- officialCost: null,
161
- tokens: null,
162
- tokenBreakdown: null,
163
- };
164
- }
165
-
166
- const tokenBreakdown = this.calculateTokenBreakdown(sessionUsage.entries);
167
- const totalTokens =
168
- tokenBreakdown.input +
169
- tokenBreakdown.output +
170
- tokenBreakdown.cacheCreation +
171
- tokenBreakdown.cacheRead;
172
-
173
- // [LAW:single-enforcer] Transcript-derived projection only. `cost` is the
174
- // priced-transcript total; the authoritative native cost (officialCost =
175
- // hook total_cost_usd) is overlaid by the store at READ time — it is
176
- // per-render and must not be frozen into this mtime-keyed record, so it is
177
- // null here by construction.
178
- const calculatedCost = sessionUsage.totalCost;
179
-
180
- return {
181
- cost: calculatedCost,
182
- calculatedCost,
183
- officialCost: null,
184
- tokens: totalTokens,
185
- tokenBreakdown,
186
- };
187
- }
188
- }
@@ -1,11 +1,12 @@
1
1
  import { existsSync, createReadStream } from "node:fs";
2
2
  // [LAW:single-enforcer] readdir/readFile/stat come from the gated transcript-fs
3
3
  // owner, not node:fs/promises — the in-flight-I/O bound lives at one seam.
4
- import { readdir, readFile, stat } from "./transcript-fs";
4
+ import { readdir, readFile, readAppended, stat } from "./transcript-fs";
5
5
  import { join } from "node:path";
6
6
  import { homedir } from "node:os";
7
7
  import { createInterface } from "node:readline";
8
8
  import { debug } from "./logger";
9
+ import { ok, type Outcome } from "./outcome";
9
10
 
10
11
  export interface ClaudeHookData {
11
12
  // cc-candybar internal — not part of Anthropic's schema
@@ -147,6 +148,12 @@ export async function findProjectPaths(
147
148
  export async function findAgentTranscripts(
148
149
  sessionId: string,
149
150
  projectPath: string,
151
+ // [LAW:one-source-of-truth] Agent-file session identity is IMMUTABLE — a file
152
+ // that belonged to this session never changes owner. Paths in `verified` skip
153
+ // the first-line readFile, so an incremental refold (which passes its prior
154
+ // file set) pays the O(file) verification only for NEWLY-appeared sidechains,
155
+ // not every sidechain every render. The readdir still runs (cheap).
156
+ verified?: ReadonlySet<string>,
150
157
  ): Promise<string[]> {
151
158
  const agentFiles: string[] = [];
152
159
 
@@ -160,6 +167,10 @@ export async function findAgentTranscripts(
160
167
 
161
168
  for (const fileName of agentFileNames) {
162
169
  const filePath = join(subagentsDir, fileName);
170
+ if (verified?.has(filePath)) {
171
+ agentFiles.push(filePath);
172
+ continue;
173
+ }
163
174
  try {
164
175
  const content = await readFile(filePath, "utf-8");
165
176
  const firstLine = content.split("\n")[0];
@@ -436,17 +447,13 @@ function makeEntry(parsed: Record<string, unknown>): ParsedEntry | null {
436
447
  };
437
448
  }
438
449
 
439
- async function parseJsonlFileInMemory(
440
- filePath: string,
441
- ): Promise<ParsedEntry[]> {
442
- const content = await readFile(filePath, "utf-8");
443
- const lines = content
444
- .trim()
445
- .split("\n")
446
- .filter((line) => line.trim());
450
+ // [LAW:one-source-of-truth] The one line→entry loop. Both the whole-file parse
451
+ // and the incremental append reader frame their text into lines through here, so
452
+ // a malformed-line policy or a projected field can never diverge between the two.
453
+ function parseJsonlLines(text: string): ParsedEntry[] {
447
454
  const entries: ParsedEntry[] = [];
448
-
449
- for (const line of lines) {
455
+ for (const line of text.split("\n")) {
456
+ if (!line.trim()) continue;
450
457
  try {
451
458
  const entry = makeEntry(JSON.parse(line));
452
459
  if (entry) entries.push(entry);
@@ -455,10 +462,70 @@ async function parseJsonlFileInMemory(
455
462
  continue;
456
463
  }
457
464
  }
458
-
459
465
  return entries;
460
466
  }
461
467
 
468
+ async function parseJsonlFileInMemory(
469
+ filePath: string,
470
+ ): Promise<ParsedEntry[]> {
471
+ const content = await readFile(filePath, "utf-8");
472
+ return parseJsonlLines(content);
473
+ }
474
+
475
+ // A byte cursor into an append-only transcript: the offset consumed through the
476
+ // last complete line, the mtime observed at that read, and the inode. The store
477
+ // keys its per-file fold by this — `offset` bounds the next read to only what's
478
+ // new; `mtimeMs` lets an unchanged file skip the read entirely; `ino` detects a
479
+ // rewrite (a rename-based /compact swaps the inode) so the fold resets instead
480
+ // of splicing new bytes onto a stale prefix.
481
+ export interface TranscriptCursor {
482
+ readonly offset: number;
483
+ readonly mtimeMs: number;
484
+ readonly ino: number;
485
+ }
486
+
487
+ // [LAW:dataflow-not-control-flow][LAW:one-source-of-truth] Read only the entries
488
+ // appended to an append-only transcript since `prior`. The returned `entries`
489
+ // are TRANSIENT — the caller folds them into its own compact aggregate and drops
490
+ // them, so this reader (unlike the retained parseCache) pins no O(file) memory:
491
+ // its cost is O(bytes appended since last render), which is the whole point.
492
+ //
493
+ // A partial trailing line (a render observing the file mid-write, before its
494
+ // newline) is NOT consumed: the cursor advances only through the last complete
495
+ // line, so the partial line is re-read intact once its newline lands. Cutting at
496
+ // a newline also guarantees clean UTF-8 boundaries (0x0A is never a continuation
497
+ // byte), so no multibyte codepoint is split across reads.
498
+ //
499
+ // `reset` (from readAppended) means the file shrank/was rewritten (a /compact):
500
+ // `entries` then cover the whole new file from offset 0 and the caller discards
501
+ // its prior fold for this file. Absent/failed pass straight through.
502
+ export async function readAppendedEntries(
503
+ filePath: string,
504
+ prior: TranscriptCursor | undefined,
505
+ ): Promise<
506
+ Outcome<{ entries: ParsedEntry[]; cursor: TranscriptCursor; reset: boolean }>
507
+ > {
508
+ const r = await readAppended(
509
+ filePath,
510
+ prior === undefined ? undefined : { offset: prior.offset, ino: prior.ino },
511
+ );
512
+ if (r.kind !== "ok") return r;
513
+ const { buf, start, mtimeMs, ino, reset } = r.value;
514
+ // Consume only through the last newline; a trailing partial line waits.
515
+ const lastNl = buf.lastIndexOf(0x0a);
516
+ if (lastNl < 0) {
517
+ return ok({ entries: [], cursor: { offset: start, mtimeMs, ino }, reset });
518
+ }
519
+ const entries = parseJsonlLines(
520
+ buf.subarray(0, lastNl + 1).toString("utf-8"),
521
+ );
522
+ return ok({
523
+ entries,
524
+ cursor: { offset: start + lastNl + 1, mtimeMs, ino },
525
+ reset,
526
+ });
527
+ }
528
+
462
529
  async function parseJsonlFileStreaming(
463
530
  filePath: string,
464
531
  ): Promise<ParsedEntry[]> {
@@ -5,8 +5,25 @@ import {
5
5
  stat as fsStat,
6
6
  } from "node:fs/promises";
7
7
  import type { FileHandle } from "node:fs/promises";
8
+ import { statSync } from "node:fs";
8
9
 
9
10
  import { ABSENT, failed, ok, type Outcome } from "./outcome";
11
+ import { debug } from "./logger";
12
+
13
+ // [LAW:single-enforcer] The one per-render freshness probe: a SYNC single-file
14
+ // stat's mtimeMs (0 when the file is absent or unreadable). Sync by design — the
15
+ // mtime gate must resolve before deciding whether to read, and a sync stat
16
+ // consumes no gate slot (the async `readAppended`/`readFile` bulk reads are what
17
+ // the limiter bounds). Both the usage store and the metrics provider key their
18
+ // incremental fold off this one helper so the ENOENT→0 policy lives in one place.
19
+ export function statMtimeMs(filePath: string | undefined): number {
20
+ if (!filePath) return 0;
21
+ try {
22
+ return statSync(filePath).mtimeMs;
23
+ } catch {
24
+ return 0;
25
+ }
26
+ }
10
27
 
11
28
  // [LAW:single-enforcer] One owner of the transcript-scanning in-flight-I/O
12
29
  // budget. Every readdir/stat/readFile over the ~/.claude/projects tree passes
@@ -120,6 +137,97 @@ export const stat = gated(fsStat);
120
137
  // (permissions, I/O) is a real read failure — `failed`, carrying its reason
121
138
  // to whichever boundary owns the log effect. Folding both into one null made
122
139
  // a broken transcript indistinguishable from a missing one.
140
+ // [LAW:single-enforcer] The forward complement of readTail, through the SAME
141
+ // gate: read the bytes of an append-only file from `priorOffset` to EOF (the
142
+ // whole file when `priorOffset` is undefined). One open→stat→read→close per
143
+ // gate slot, exactly like readTail. A transcript scanner that maintains a byte
144
+ // cursor to re-read only what was appended MUST use this, not raw node:fs, or it
145
+ // reintroduces the unbounded-fs state the gate forbids.
146
+ //
147
+ // [LAW:one-source-of-truth] `reset` is the single signal that the file shrank
148
+ // below the caller's cursor — a truncation or /compact rewrite — so the caller
149
+ // discards its prior fold for this file and re-folds from the returned bytes
150
+ // (which then start at offset 0). Append-only monotonicity makes every other
151
+ // case a pure suffix read: `start..size` is exactly what's new.
152
+ //
153
+ // [LAW:no-silent-failure] ENOENT (fresh session, no transcript yet) is `absent`;
154
+ // any other error is `failed`, carrying its reason to the boundary that logs.
155
+ export async function readAppended(
156
+ path: string,
157
+ prior: { offset: number; ino: number } | undefined,
158
+ ): Promise<
159
+ Outcome<{
160
+ buf: Buffer;
161
+ start: number;
162
+ size: number;
163
+ mtimeMs: number;
164
+ ino: number;
165
+ reset: boolean;
166
+ }>
167
+ > {
168
+ return gate.run(async () => {
169
+ let fh: FileHandle | null = null;
170
+ try {
171
+ fh = await fsOpen(path, "r");
172
+ const { size, mtimeMs, ino } = await fh.stat();
173
+ // [LAW:one-source-of-truth] The prior fold is a valid PREFIX of this file
174
+ // only while the file stays append-only. Two independent signals break
175
+ // that: the inode changed (a rename-based /compact or log rotation swapped
176
+ // the file under the path — the realistic rewrite mechanism), or the file
177
+ // shrank below our cursor (an in-place truncate). Either ⇒ re-read from 0;
178
+ // the suffix-only read would otherwise splice new bytes onto a fold of a
179
+ // file that no longer exists. (A cursor-only size check missed a rewrite
180
+ // that grew back to ≥ the cursor.)
181
+ const reset =
182
+ prior !== undefined && (ino !== prior.ino || size < prior.offset);
183
+ const start = reset || prior === undefined ? 0 : prior.offset;
184
+ if (start >= size) {
185
+ return ok({ buf: Buffer.alloc(0), start, size, mtimeMs, ino, reset });
186
+ }
187
+ const buf = Buffer.alloc(size - start);
188
+ // [LAW:no-silent-fallbacks] A single read may return short — parsing a
189
+ // zero-padded tail would fabricate entries. Loop until the window fills or
190
+ // EOF; on a short final read (the file shrank under us) return only the
191
+ // bytes actually read, never the zero padding.
192
+ let off = 0;
193
+ while (off < buf.length) {
194
+ const { bytesRead } = await fh.read(
195
+ buf,
196
+ off,
197
+ buf.length - off,
198
+ start + off,
199
+ );
200
+ if (bytesRead === 0) break;
201
+ off += bytesRead;
202
+ }
203
+ return ok({
204
+ buf: off === buf.length ? buf : buf.subarray(0, off),
205
+ start,
206
+ size,
207
+ mtimeMs,
208
+ ino,
209
+ reset,
210
+ });
211
+ } catch (e) {
212
+ if ((e as NodeJS.ErrnoException).code === "ENOENT") return ABSENT;
213
+ return failed(
214
+ `readAppended ${path}: ${e instanceof Error ? e.message : String(e)}`,
215
+ );
216
+ } finally {
217
+ // [LAW:no-silent-failure] A close() rejection must NOT override the typed
218
+ // Outcome the try returned — callers await this and read `.kind`, so a raw
219
+ // rejection would escape every failed-guard. A failed close after a good
220
+ // read doesn't invalidate the read, but it can signal a real fs problem
221
+ // (ENOSPC on fsync), so log it at debug rather than swallowing it blind.
222
+ await fh?.close().catch((e: unknown) => {
223
+ debug(
224
+ `transcript-fs: close failed: ${e instanceof Error ? e.message : String(e)}`,
225
+ );
226
+ });
227
+ }
228
+ });
229
+ }
230
+
123
231
  export async function readTail(
124
232
  path: string,
125
233
  maxBytes: number,
@@ -156,7 +264,16 @@ export async function readTail(
156
264
  `readTail ${path}: ${e instanceof Error ? e.message : String(e)}`,
157
265
  );
158
266
  } finally {
159
- await fh?.close();
267
+ // [LAW:no-silent-failure] A close() rejection must NOT override the typed
268
+ // Outcome the try returned — callers await this and read `.kind`, so a raw
269
+ // rejection would escape every failed-guard. A failed close after a good
270
+ // read doesn't invalidate the read, but it can signal a real fs problem
271
+ // (ENOSPC on fsync), so log it at debug rather than swallowing it blind.
272
+ await fh?.close().catch((e: unknown) => {
273
+ debug(
274
+ `transcript-fs: close failed: ${e instanceof Error ? e.message : String(e)}`,
275
+ );
276
+ });
160
277
  }
161
278
  });
162
279
  }