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