@promptctl/cc-candybar 1.0.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 (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +145 -0
  3. package/bin/cc-candybar +6 -0
  4. package/dist/index.mjs +185 -0
  5. package/package.json +99 -0
  6. package/plugin/.claude-plugin/plugin.json +11 -0
  7. package/plugin/bin/preview.sh +305 -0
  8. package/plugin/commands/candybar.md +403 -0
  9. package/plugin/templates/config-essential.json +36 -0
  10. package/plugin/templates/config-full.json +55 -0
  11. package/plugin/templates/config-standard.json +39 -0
  12. package/plugin/templates/config-tui-compact.json +48 -0
  13. package/plugin/templates/config-tui-full.json +89 -0
  14. package/plugin/templates/config-tui-standard.json +56 -0
  15. package/plugin/templates/config-tui.json +18 -0
  16. package/plugin/templates/nerd-fonts-sample.txt +5 -0
  17. package/schema/cc-candybar.schema.json +1379 -0
  18. package/src/click/wire.ts +113 -0
  19. package/src/config/action.ts +91 -0
  20. package/src/config/cli.ts +170 -0
  21. package/src/config/default-dsl-config.ts +661 -0
  22. package/src/config/dsl-loader.ts +265 -0
  23. package/src/config/dsl-types.ts +425 -0
  24. package/src/config/loader/actions.ts +530 -0
  25. package/src/config/loader/cache.ts +206 -0
  26. package/src/config/loader/cross-ref.ts +326 -0
  27. package/src/config/loader/cycles.ts +148 -0
  28. package/src/config/loader/diagnostics.ts +99 -0
  29. package/src/config/loader/discovery.ts +182 -0
  30. package/src/config/loader/emit-schema.ts +63 -0
  31. package/src/config/loader/globals.ts +42 -0
  32. package/src/config/loader/helpers.ts +48 -0
  33. package/src/config/loader/layout.ts +688 -0
  34. package/src/config/loader/merge.ts +40 -0
  35. package/src/config/loader/refs.ts +96 -0
  36. package/src/config/loader/segments.ts +120 -0
  37. package/src/config/loader/validate-core.ts +674 -0
  38. package/src/config/loader/variables.ts +260 -0
  39. package/src/daemon/acquire.ts +411 -0
  40. package/src/daemon/cache/git.ts +553 -0
  41. package/src/daemon/cache/render.ts +449 -0
  42. package/src/daemon/cache/session-usage-store.ts +446 -0
  43. package/src/daemon/cache/watchers.ts +245 -0
  44. package/src/daemon/client-debug.ts +120 -0
  45. package/src/daemon/client-stats.ts +129 -0
  46. package/src/daemon/client-transport.ts +273 -0
  47. package/src/daemon/client.ts +75 -0
  48. package/src/daemon/debug-types.ts +91 -0
  49. package/src/daemon/debug.ts +264 -0
  50. package/src/daemon/limits.ts +154 -0
  51. package/src/daemon/log.ts +69 -0
  52. package/src/daemon/parent-watchdog.ts +80 -0
  53. package/src/daemon/paths.ts +127 -0
  54. package/src/daemon/protocol.ts +235 -0
  55. package/src/daemon/render-payload.ts +611 -0
  56. package/src/daemon/server.ts +1103 -0
  57. package/src/daemon/session-state-file.ts +108 -0
  58. package/src/daemon/session-state.ts +237 -0
  59. package/src/daemon/stats.ts +229 -0
  60. package/src/daemon/verbs/index.ts +458 -0
  61. package/src/daemon/verbs/state-validators.ts +708 -0
  62. package/src/demo/dsl.ts +117 -0
  63. package/src/demo/mock-data.ts +67 -0
  64. package/src/demo/statusline.json5 +92 -0
  65. package/src/dsl/node-registry.ts +281 -0
  66. package/src/dsl/render.ts +558 -0
  67. package/src/index.ts +206 -0
  68. package/src/install/index.ts +410 -0
  69. package/src/proc/launch.ts +451 -0
  70. package/src/proc/stats-handle.ts +13 -0
  71. package/src/render/action.ts +458 -0
  72. package/src/render/diagnostic-style.ts +23 -0
  73. package/src/render/diagnostic-text.ts +77 -0
  74. package/src/render/error-glyph.ts +53 -0
  75. package/src/render/outcome-plan.ts +45 -0
  76. package/src/render/picker.ts +231 -0
  77. package/src/render/split-lines.ts +51 -0
  78. package/src/render/strip.ts +103 -0
  79. package/src/segments/cache.ts +131 -0
  80. package/src/segments/context.ts +190 -0
  81. package/src/segments/git.ts +561 -0
  82. package/src/segments/metrics.ts +101 -0
  83. package/src/segments/pricing.ts +452 -0
  84. package/src/segments/session.ts +188 -0
  85. package/src/segments/tmux.ts +74 -0
  86. package/src/template-engine/cells.ts +90 -0
  87. package/src/template-engine/colors.ts +102 -0
  88. package/src/template-engine/engine.ts +108 -0
  89. package/src/template-engine/funcs.ts +216 -0
  90. package/src/template-engine/index.ts +11 -0
  91. package/src/template-engine/layout.ts +112 -0
  92. package/src/template-engine/scope.ts +62 -0
  93. package/src/themes/index.ts +19 -0
  94. package/src/themes/palette-resolvers.ts +86 -0
  95. package/src/themes/policy.ts +79 -0
  96. package/src/themes/session-random.ts +88 -0
  97. package/src/utils/cache.ts +206 -0
  98. package/src/utils/claude.ts +616 -0
  99. package/src/utils/color-support.ts +118 -0
  100. package/src/utils/formatters.ts +77 -0
  101. package/src/utils/logger.ts +5 -0
  102. package/src/utils/outcome.ts +33 -0
  103. package/src/utils/schema-validator.ts +126 -0
  104. package/src/utils/single-flight.ts +57 -0
  105. package/src/utils/terminal-width.ts +43 -0
  106. package/src/utils/terminal.ts +11 -0
  107. package/src/utils/transcript-fs.ts +162 -0
  108. package/src/var-system/index.ts +24 -0
  109. package/src/var-system/sources.ts +1038 -0
  110. package/src/var-system/store.ts +223 -0
  111. package/src/var-system/types.ts +57 -0
@@ -0,0 +1,616 @@
1
+ import { existsSync, createReadStream } from "node:fs";
2
+ // [LAW:single-enforcer] readdir/readFile/stat come from the gated transcript-fs
3
+ // owner, not node:fs/promises — the in-flight-I/O bound lives at one seam.
4
+ import { readdir, readFile, stat } from "./transcript-fs";
5
+ import { join } from "node:path";
6
+ import { homedir } from "node:os";
7
+ import { createInterface } from "node:readline";
8
+ import { debug } from "./logger";
9
+
10
+ export interface ClaudeHookData {
11
+ // cc-candybar internal — not part of Anthropic's schema
12
+ hook_event_name: string;
13
+
14
+ // Always present per Anthropic schema
15
+ session_id: string;
16
+ transcript_path: string;
17
+ cwd: string;
18
+ model: {
19
+ id: string;
20
+ display_name: string;
21
+ };
22
+ workspace: {
23
+ current_dir: string;
24
+ project_dir: string;
25
+ // "Empty array if none have been added" — always present, not absent
26
+ added_dirs: string[];
27
+ // Absent when not inside a linked git worktree
28
+ git_worktree?: string;
29
+ };
30
+
31
+ // Optional per Anthropic schema (listed under "Fields that may be absent")
32
+ session_name?: string;
33
+ version?: string;
34
+ output_style?: {
35
+ name: string;
36
+ };
37
+ cost?: {
38
+ total_cost_usd: number;
39
+ total_duration_ms: number;
40
+ total_api_duration_ms: number;
41
+ total_lines_added: number;
42
+ total_lines_removed: number;
43
+ };
44
+ context_window?: {
45
+ total_input_tokens: number;
46
+ total_output_tokens: number;
47
+ context_window_size: number;
48
+ // Always present within context_window, but value may be null (schema: "Fields that may be null")
49
+ used_percentage: number | null;
50
+ remaining_percentage: number | null;
51
+ // Null before first API call and after /compact — present, not absent
52
+ current_usage: {
53
+ input_tokens: number;
54
+ output_tokens: number;
55
+ cache_creation_input_tokens: number;
56
+ cache_read_input_tokens: number;
57
+ } | null;
58
+ };
59
+ exceeds_200k_tokens?: boolean;
60
+ effort?: {
61
+ level: string;
62
+ };
63
+ thinking?: {
64
+ enabled: boolean;
65
+ };
66
+ rate_limits?: {
67
+ five_hour?: {
68
+ used_percentage: number;
69
+ resets_at: number;
70
+ };
71
+ seven_day?: {
72
+ used_percentage: number;
73
+ resets_at: number;
74
+ };
75
+ };
76
+ vim?: {
77
+ mode: string;
78
+ };
79
+ agent?: {
80
+ name: string;
81
+ };
82
+ worktree?: {
83
+ name: string;
84
+ path: string;
85
+ branch?: string;
86
+ original_cwd: string;
87
+ original_branch?: string;
88
+ };
89
+ }
90
+
91
+ export function getClaudePaths(): string[] {
92
+ const paths: string[] = [];
93
+
94
+ const envPath = process.env.CLAUDE_CONFIG_DIR;
95
+ if (envPath) {
96
+ envPath.split(",").forEach((path) => {
97
+ const trimmedPath = path.trim();
98
+ if (existsSync(trimmedPath)) {
99
+ paths.push(trimmedPath);
100
+ }
101
+ });
102
+ }
103
+
104
+ if (paths.length === 0) {
105
+ const homeDir = homedir();
106
+ const configPath = join(homeDir, ".config", "claude");
107
+ const claudePath = join(homeDir, ".claude");
108
+
109
+ if (existsSync(configPath)) {
110
+ paths.push(configPath);
111
+ }
112
+ if (existsSync(claudePath)) {
113
+ paths.push(claudePath);
114
+ }
115
+ }
116
+
117
+ return paths;
118
+ }
119
+
120
+ export async function findProjectPaths(
121
+ claudePaths: string[],
122
+ ): Promise<string[]> {
123
+ const projectPaths: string[] = [];
124
+
125
+ for (const claudePath of claudePaths) {
126
+ const projectsDir = join(claudePath, "projects");
127
+
128
+ if (existsSync(projectsDir)) {
129
+ try {
130
+ const entries = await readdir(projectsDir, { withFileTypes: true });
131
+
132
+ for (const entry of entries) {
133
+ if (entry.isDirectory()) {
134
+ const projectPath = join(projectsDir, entry.name);
135
+ projectPaths.push(projectPath);
136
+ }
137
+ }
138
+ } catch (error) {
139
+ debug(`Failed to read projects directory ${projectsDir}:`, error);
140
+ }
141
+ }
142
+ }
143
+
144
+ return projectPaths;
145
+ }
146
+
147
+ export async function findAgentTranscripts(
148
+ sessionId: string,
149
+ projectPath: string,
150
+ ): Promise<string[]> {
151
+ const agentFiles: string[] = [];
152
+
153
+ const subagentsDir = join(projectPath, sessionId, "subagents");
154
+
155
+ try {
156
+ const files = await readdir(subagentsDir);
157
+ const agentFileNames = files.filter(
158
+ (f) => f.startsWith("agent-") && f.endsWith(".jsonl"),
159
+ );
160
+
161
+ for (const fileName of agentFileNames) {
162
+ const filePath = join(subagentsDir, fileName);
163
+ try {
164
+ const content = await readFile(filePath, "utf-8");
165
+ const firstLine = content.split("\n")[0];
166
+ if (firstLine) {
167
+ const parsed = JSON.parse(firstLine);
168
+ if (parsed.sessionId === sessionId) {
169
+ agentFiles.push(filePath);
170
+ }
171
+ }
172
+ } catch {
173
+ debug(`Failed to check agent file ${filePath}`);
174
+ }
175
+ }
176
+ } catch (error) {
177
+ debug(`Failed to read subagents directory ${subagentsDir}:`, error);
178
+ }
179
+
180
+ return agentFiles;
181
+ }
182
+
183
+ export async function getEarliestTimestamp(
184
+ filePath: string,
185
+ ): Promise<Date | null> {
186
+ try {
187
+ const content = await readFile(filePath, "utf-8");
188
+ const lines = content.trim().split("\n");
189
+
190
+ let earliestDate: Date | null = null;
191
+ for (const line of lines) {
192
+ if (!line.trim()) continue;
193
+
194
+ try {
195
+ const json = JSON.parse(line);
196
+ if (json.timestamp && typeof json.timestamp === "string") {
197
+ const date = new Date(json.timestamp);
198
+ if (!isNaN(date.getTime())) {
199
+ if (earliestDate === null || date < earliestDate) {
200
+ earliestDate = date;
201
+ }
202
+ }
203
+ }
204
+ } catch {
205
+ continue;
206
+ }
207
+ }
208
+ return earliestDate;
209
+ } catch (error) {
210
+ debug(`Failed to get earliest timestamp for ${filePath}:`, error);
211
+ return null;
212
+ }
213
+ }
214
+
215
+ export async function sortFilesByTimestamp(
216
+ files: string[],
217
+ oldestFirst = true,
218
+ ): Promise<string[]> {
219
+ const filesWithTimestamps = await Promise.all(
220
+ files.map(async (file) => ({
221
+ file,
222
+ timestamp: await getEarliestTimestamp(file),
223
+ })),
224
+ );
225
+
226
+ return filesWithTimestamps
227
+ .sort((a, b) => {
228
+ if (a.timestamp === null && b.timestamp === null) return 0;
229
+ if (a.timestamp === null) return 1;
230
+ if (b.timestamp === null) return -1;
231
+ const sortOrder = oldestFirst ? 1 : -1;
232
+ return sortOrder * (a.timestamp.getTime() - b.timestamp.getTime());
233
+ })
234
+ .map((item) => item.file);
235
+ }
236
+
237
+ export async function getFileModificationDate(
238
+ filePath: string,
239
+ ): Promise<Date | null> {
240
+ try {
241
+ const stats = await stat(filePath);
242
+ return stats.mtime;
243
+ } catch {
244
+ return null;
245
+ }
246
+ }
247
+
248
+ // [LAW:types-are-the-program] exception: kept as type-aliases (not
249
+ // interfaces) so they're structurally assignable to `Record<string,
250
+ // unknown>` at the `extractModelId` boundary in segments/pricing.ts.
251
+ // Switching to `interface` removes the implicit index signature and
252
+ // breaks typecheck. A proper fix tightens that boundary to take
253
+ // `PrunedRaw` directly and is out of scope for the CI-fix branch.
254
+ // eslint-disable-next-line @typescript-eslint/consistent-type-definitions
255
+ type UsageCounts = {
256
+ input_tokens?: number;
257
+ output_tokens?: number;
258
+ cache_creation_input_tokens?: number;
259
+ cache_read_input_tokens?: number;
260
+ };
261
+
262
+ // [LAW:one-source-of-truth] The only fields ever read from raw are
263
+ // model, message.{id,model,usage}, and requestId. Storing the full
264
+ // parsed JSON (including content arrays with full message text) causes
265
+ // hundreds of MB of V8 heap churn per transcript re-parse. raw is
266
+ // pruned to this shape at parse time so the GC pressure is bounded.
267
+ // eslint-disable-next-line @typescript-eslint/consistent-type-definitions
268
+ export type PrunedRaw = {
269
+ model?: string;
270
+ message?: { id?: string; model?: string; usage?: UsageCounts };
271
+ requestId?: string;
272
+ };
273
+
274
+ export interface ParsedEntry {
275
+ timestamp: Date;
276
+ // [LAW:one-source-of-truth] The pruned projection carries every scalar any
277
+ // consumer reads. `type`, `message.role/type/firstContentType` are the
278
+ // message-classification discriminators the metrics segment needs; they are
279
+ // small enum-like strings, so projecting them keeps metrics on this one parse
280
+ // path WITHOUT retaining the multi-MB `message.content[]` arrays the pruning
281
+ // exists to drop. `firstContentType` is the `type` of the first content block
282
+ // only (undefined when content is text/absent) — never the array itself.
283
+ type?: string;
284
+ message?: {
285
+ id?: string;
286
+ usage?: UsageCounts;
287
+ model?: string;
288
+ role?: string;
289
+ type?: string;
290
+ firstContentType?: string;
291
+ };
292
+ costUSD?: number;
293
+ isSidechain?: boolean;
294
+ raw: PrunedRaw;
295
+ }
296
+
297
+ export function createUniqueHash(entry: ParsedEntry): string | null {
298
+ // Both message.id paths are now equivalent (makeEntry syncs them), but
299
+ // raw.message.id is kept as the canonical source to preserve call-site
300
+ // compatibility with callers that pass a PrunedRaw directly.
301
+ const messageId = entry.message?.id ?? entry.raw.message?.id;
302
+ const requestId = entry.raw.requestId;
303
+
304
+ if (!messageId || !requestId) {
305
+ return null;
306
+ }
307
+
308
+ return `${messageId}:${requestId}`;
309
+ }
310
+
311
+ const STREAMING_THRESHOLD_BYTES = 1024 * 1024;
312
+
313
+ // [LAW:no-shared-mutable-globals] Bounded LRU — single owner, hard cap, documented invariants.
314
+ // Key: filePath. Value: last-seen mtime+size for freshness check plus parsed entries.
315
+ // Files larger than PARSE_CACHE_SKIP_BYTES are streamed and not retained (too expensive).
316
+ const PARSE_CACHE_MAX = 16;
317
+ const PARSE_CACHE_SKIP_BYTES = 5 * 1024 * 1024;
318
+
319
+ interface ParseCacheEntry {
320
+ mtime: number;
321
+ size: number;
322
+ entries: ParsedEntry[];
323
+ }
324
+
325
+ const parseCache = new Map<string, ParseCacheEntry>();
326
+
327
+ export function clearParseCache(): void {
328
+ parseCache.clear();
329
+ }
330
+
331
+ export async function parseJsonlFile(filePath: string): Promise<ParsedEntry[]> {
332
+ try {
333
+ const stats = await stat(filePath);
334
+ const fileSizeBytes = stats.size;
335
+
336
+ const cached = parseCache.get(filePath);
337
+ if (
338
+ cached &&
339
+ cached.mtime === stats.mtimeMs &&
340
+ cached.size === fileSizeBytes
341
+ ) {
342
+ debug(`[parse-cache] hit ${filePath}`);
343
+ // LRU: move to most-recently-used end via delete+reinsert
344
+ parseCache.delete(filePath);
345
+ parseCache.set(filePath, cached);
346
+ return cached.entries;
347
+ }
348
+
349
+ let entries: ParsedEntry[];
350
+ if (fileSizeBytes > STREAMING_THRESHOLD_BYTES) {
351
+ debug(
352
+ `Using streaming parser for large file ${filePath} (${Math.round(fileSizeBytes / 1024)}KB)`,
353
+ );
354
+ entries = await parseJsonlFileStreaming(filePath);
355
+ } else {
356
+ entries = await parseJsonlFileInMemory(filePath);
357
+ }
358
+
359
+ debug(`Parsed ${entries.length} entries from ${filePath}`);
360
+
361
+ // Large files are already streamed — retaining parsed entries pins memory. Skip cache.
362
+ if (fileSizeBytes > PARSE_CACHE_SKIP_BYTES) {
363
+ return entries;
364
+ }
365
+
366
+ // Evict stale entry for this path (mtime changed) before measuring capacity.
367
+ parseCache.delete(filePath);
368
+ // Evict LRU entry if at cap.
369
+ if (parseCache.size >= PARSE_CACHE_MAX) {
370
+ parseCache.delete(parseCache.keys().next().value!);
371
+ }
372
+ parseCache.set(filePath, {
373
+ mtime: stats.mtimeMs,
374
+ size: fileSizeBytes,
375
+ entries,
376
+ });
377
+ return entries;
378
+ } catch (error) {
379
+ // [LAW:no-silent-failure] A transcript that doesn't exist yet is the
380
+ // domain's genuine "no entries" (new session pre-first-write) — every
381
+ // other read error propagates so the consuming provider classifies it
382
+ // as a failed outcome and the payload boundary logs it. The old
383
+ // catch-all-to-[] dressed EACCES/EIO as an empty session.
384
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
385
+ debug(`Transcript not present yet: ${filePath}`);
386
+ return [];
387
+ }
388
+ throw error;
389
+ }
390
+ }
391
+
392
+ // Build a ParsedEntry from a full parsed JSONL line, retaining only the
393
+ // fields actually used downstream. The full parsed object (which includes
394
+ // message.content arrays with complete LLM response text) is discarded so
395
+ // the GC can reclaim that memory promptly instead of pinning it via raw.
396
+ // [LAW:one-source-of-truth] All callers go through here; no second parse path.
397
+ function makeEntry(parsed: Record<string, unknown>): ParsedEntry | null {
398
+ if (!parsed.timestamp) return null;
399
+ const msg = parsed.message as Record<string, unknown> | undefined;
400
+ const usage = msg?.usage as UsageCounts | undefined;
401
+ // Project only the first content block's `type` scalar; the array (full LLM
402
+ // text) is never retained. Text content (a bare string) has no block type.
403
+ const content = msg?.content;
404
+ const firstBlock =
405
+ Array.isArray(content) && typeof content[0] === "object" && content[0]
406
+ ? (content[0] as { type?: unknown })
407
+ : undefined;
408
+ return {
409
+ timestamp: new Date(parsed.timestamp as string),
410
+ type: typeof parsed.type === "string" ? parsed.type : undefined,
411
+ message: msg
412
+ ? {
413
+ id: typeof msg.id === "string" ? msg.id : undefined,
414
+ model: typeof msg.model === "string" ? msg.model : undefined,
415
+ usage,
416
+ role: typeof msg.role === "string" ? msg.role : undefined,
417
+ type: typeof msg.type === "string" ? msg.type : undefined,
418
+ firstContentType:
419
+ typeof firstBlock?.type === "string" ? firstBlock.type : undefined,
420
+ }
421
+ : undefined,
422
+ costUSD: typeof parsed.costUSD === "number" ? parsed.costUSD : undefined,
423
+ isSidechain: parsed.isSidechain === true,
424
+ raw: {
425
+ model: typeof parsed.model === "string" ? parsed.model : undefined,
426
+ message: msg
427
+ ? {
428
+ id: typeof msg.id === "string" ? msg.id : undefined,
429
+ model: typeof msg.model === "string" ? msg.model : undefined,
430
+ usage,
431
+ }
432
+ : undefined,
433
+ requestId:
434
+ typeof parsed.requestId === "string" ? parsed.requestId : undefined,
435
+ },
436
+ };
437
+ }
438
+
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());
447
+ const entries: ParsedEntry[] = [];
448
+
449
+ for (const line of lines) {
450
+ try {
451
+ const entry = makeEntry(JSON.parse(line));
452
+ if (entry) entries.push(entry);
453
+ } catch (parseError) {
454
+ debug(`Failed to parse JSONL line: ${parseError}`);
455
+ continue;
456
+ }
457
+ }
458
+
459
+ return entries;
460
+ }
461
+
462
+ async function parseJsonlFileStreaming(
463
+ filePath: string,
464
+ ): Promise<ParsedEntry[]> {
465
+ return new Promise((resolve, reject) => {
466
+ const entries: ParsedEntry[] = [];
467
+ const fileStream = createReadStream(filePath, { encoding: "utf8" });
468
+ const rl = createInterface({
469
+ input: fileStream,
470
+ crlfDelay: Infinity,
471
+ });
472
+
473
+ rl.on("line", (line) => {
474
+ const trimmedLine = line.trim();
475
+ if (!trimmedLine) return;
476
+
477
+ try {
478
+ const entry = makeEntry(JSON.parse(trimmedLine));
479
+ if (entry) entries.push(entry);
480
+ } catch (parseError) {
481
+ debug(`Failed to parse JSONL line: ${parseError}`);
482
+ }
483
+ });
484
+
485
+ rl.on("close", () => {
486
+ resolve(entries);
487
+ });
488
+
489
+ rl.on("error", (error) => {
490
+ debug(`Streaming parser error for ${filePath}:`, error);
491
+ reject(error);
492
+ });
493
+
494
+ fileStream.on("error", (error) => {
495
+ debug(`File stream error for ${filePath}:`, error);
496
+ reject(error);
497
+ });
498
+ });
499
+ }
500
+
501
+ interface FileStat {
502
+ filePath: string;
503
+ mtime: Date;
504
+ }
505
+
506
+ async function statFile(filePath: string): Promise<FileStat | null> {
507
+ try {
508
+ const mtime = await getFileModificationDate(filePath);
509
+ return mtime ? { filePath, mtime } : null;
510
+ } catch {
511
+ return null;
512
+ }
513
+ }
514
+
515
+ async function collectProjectFiles(
516
+ projectPath: string,
517
+ fileFilter?: (filePath: string, modTime: Date) => boolean,
518
+ ): Promise<FileStat[]> {
519
+ try {
520
+ const entries = await readdir(projectPath, { withFileTypes: true });
521
+
522
+ const sessionFiles = entries
523
+ .filter((e) => !e.isDirectory() && e.name.endsWith(".jsonl"))
524
+ .map((e) => statFile(join(projectPath, e.name)));
525
+
526
+ const subagentFiles = entries
527
+ .filter((e) => e.isDirectory())
528
+ .map(async (e) => {
529
+ const subagentsDir = join(projectPath, e.name, "subagents");
530
+ try {
531
+ const files = await readdir(subagentsDir);
532
+ return files
533
+ .filter((f) => f.startsWith("agent-") && f.endsWith(".jsonl"))
534
+ .map((f) => statFile(join(subagentsDir, f)));
535
+ } catch {
536
+ return [];
537
+ }
538
+ });
539
+
540
+ const [sessionResults, subagentResults] = await Promise.all([
541
+ Promise.all(sessionFiles),
542
+ Promise.all(subagentFiles).then((nested) => Promise.all(nested.flat())),
543
+ ]);
544
+
545
+ return [...sessionResults, ...subagentResults].filter(
546
+ (s): s is FileStat =>
547
+ s !== null && (!fileFilter || fileFilter(s.filePath, s.mtime)),
548
+ );
549
+ } catch (dirError) {
550
+ debug(`Failed to read project directory ${projectPath}:`, dirError);
551
+ return [];
552
+ }
553
+ }
554
+
555
+ /**
556
+ * Loads entries from Claude projects with deterministic deduplication.
557
+ * @param timeFilter Optional filter to apply based on timestamp
558
+ * @param fileFilter Optional filter to apply based on file path and modification time
559
+ * @param sortFiles Whether to sort files by modification time
560
+ * @returns Deduplicated entries sorted by timestamp
561
+ * @note Sorts entries by timestamp before deduplication to ensure consistent
562
+ * duplicate selection. Otherwise, parallel file loading causes race conditions
563
+ * where different duplicates are kept on each run, leading to flickering values.
564
+ */
565
+ export async function loadEntriesFromProjects(
566
+ timeFilter?: (entry: ParsedEntry) => boolean,
567
+ fileFilter?: (filePath: string, modTime: Date) => boolean,
568
+ sortFiles = false,
569
+ ): Promise<ParsedEntry[]> {
570
+ const claudePaths = getClaudePaths();
571
+ const projectPaths = await findProjectPaths(claudePaths);
572
+ const processedHashes = new Set<string>();
573
+
574
+ const allFilesPromises = projectPaths.map((projectPath) =>
575
+ collectProjectFiles(projectPath, fileFilter),
576
+ );
577
+
578
+ const allFileResults = await Promise.all(allFilesPromises);
579
+ const allFilesWithMtime = allFileResults
580
+ .flat()
581
+ .filter((file): file is { filePath: string; mtime: Date } => file !== null);
582
+
583
+ if (sortFiles) {
584
+ allFilesWithMtime.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
585
+ }
586
+
587
+ const allFiles = allFilesWithMtime.map((file) => file.filePath);
588
+
589
+ const entries: ParsedEntry[] = [];
590
+
591
+ const filePromises = allFiles.map(async (filePath) => {
592
+ const fileEntries = await parseJsonlFile(filePath);
593
+ return fileEntries.filter((entry) => !timeFilter || timeFilter(entry));
594
+ });
595
+
596
+ const fileResults = await Promise.all(filePromises);
597
+ for (const fileEntries of fileResults) {
598
+ entries.push(...fileEntries);
599
+ }
600
+
601
+ entries.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
602
+
603
+ const deduplicatedEntries: ParsedEntry[] = [];
604
+ for (const entry of entries) {
605
+ const uniqueHash = createUniqueHash(entry);
606
+ if (uniqueHash && processedHashes.has(uniqueHash)) {
607
+ continue;
608
+ }
609
+ if (uniqueHash) {
610
+ processedHashes.add(uniqueHash);
611
+ }
612
+ deduplicatedEntries.push(entry);
613
+ }
614
+
615
+ return deduplicatedEntries;
616
+ }