@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,446 @@
1
+ import fs from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ import {
5
+ SessionProvider,
6
+ type SessionInfo,
7
+ type SessionUsage,
8
+ type UsageInfo,
9
+ type TokenBreakdown,
10
+ } from "../../segments/session";
11
+ import {
12
+ getClaudePaths,
13
+ findProjectPaths,
14
+ type ClaudeHookData,
15
+ } from "../../utils/claude";
16
+ // [LAW:single-enforcer] The once-per-day seed's directory walk shares the same
17
+ // in-flight-I/O budget (gn4.2) as every other transcript scan.
18
+ import {
19
+ readdir as gatedReaddir,
20
+ stat as gatedStat,
21
+ } from "../../utils/transcript-fs";
22
+ import { SingleFlight } from "../../utils/single-flight";
23
+ import { ABSENT, failed, ok, type Outcome } from "../../utils/outcome";
24
+ import { dlog } from "../log";
25
+
26
+ // [LAW:one-source-of-truth] The daemon's single owner of per-session usage.
27
+ // Per-session records are canonical; the `session` projection (whole-session
28
+ // totals) and the `today` projection (cross-session sum of today's per-day
29
+ // buckets) are BOTH folds over this one store. There is no second usage cache
30
+ // and no per-render whole-tree scan: a render observes the active session's
31
+ // change through the single mtime stat it already does, re-parses only that one
32
+ // session, and folds in-memory records for everything else.
33
+ //
34
+ // [LAW:dataflow-not-control-flow] `today` stops being "recompute-if-stale"
35
+ // (where the staleness probe — a whole-tree mtime sweep — cost as much as the
36
+ // recompute it guarded). The aggregate is derived state maintained
37
+ // incrementally: the whole transcript tree is scanned EXACTLY ONCE, lazily, to
38
+ // seed records for sessions that did work before this daemon saw them; every
39
+ // render after that is a single-file stat plus a fold.
40
+
41
+ // [LAW:types-are-the-program] An `ok` TodayInfo always carries real totals —
42
+ // "no usage recorded today" is the `absent` outcome arm, not a bag of nulls.
43
+ export interface TodayInfo {
44
+ cost: number;
45
+ tokens: number;
46
+ tokenBreakdown: TokenBreakdown;
47
+ date: string;
48
+ }
49
+
50
+ // Per-(session, day) scalar contribution — the only granularity the today fold
51
+ // needs. Raw entries are discarded after bucketing, so per-session retained
52
+ // memory is O(retained-days), not O(entries).
53
+ interface DayUsage {
54
+ cost: number;
55
+ input: number;
56
+ output: number;
57
+ cacheCreation: number;
58
+ cacheRead: number;
59
+ }
60
+
61
+ interface SessionRecord {
62
+ sessionInfo: SessionInfo;
63
+ days: Map<string, DayUsage>;
64
+ transcriptMtime: number;
65
+ transcriptPath: string | undefined;
66
+ lastSeenAt: number;
67
+ }
68
+
69
+ const DEFAULT_MAX_ENTRIES = 256;
70
+ const DEFAULT_STALE_AGE_MS = 24 * 60 * 60 * 1000;
71
+ const DEFAULT_SWEEP_INTERVAL_MS = 5 * 60 * 1000;
72
+ // Mirrors the transcript-fs gate width: the seed's parse fan-out is bounded by
73
+ // the same constant as the I/O it drives, so the once-a-day scan can never
74
+ // re-create the unbounded burst gn4 exists to kill.
75
+ const SEED_CONCURRENCY = 8;
76
+
77
+ const EMPTY_DAY: DayUsage = {
78
+ cost: 0,
79
+ input: 0,
80
+ output: 0,
81
+ cacheCreation: 0,
82
+ cacheRead: 0,
83
+ };
84
+
85
+ const EMPTY_SESSION_INFO: SessionInfo = {
86
+ cost: null,
87
+ calculatedCost: null,
88
+ officialCost: null,
89
+ tokens: null,
90
+ tokenBreakdown: null,
91
+ };
92
+
93
+ function dayKey(date: Date): string {
94
+ const year = date.getFullYear();
95
+ const month = String(date.getMonth() + 1).padStart(2, "0");
96
+ const day = String(date.getDate()).padStart(2, "0");
97
+ return `${year}-${month}-${day}`;
98
+ }
99
+
100
+ // Files modified before this can hold no entry that lands in "today", so the
101
+ // seed never parses them. A full day of slack absorbs timezone/rollover skew.
102
+ function seedCutoffMs(): number {
103
+ const d = new Date();
104
+ d.setHours(0, 0, 0, 0);
105
+ d.setDate(d.getDate() - 1);
106
+ return d.getTime();
107
+ }
108
+
109
+ function statMtimeMs(filePath: string | undefined): number {
110
+ if (!filePath) return 0;
111
+ try {
112
+ return fs.statSync(filePath).mtimeMs;
113
+ } catch {
114
+ return 0;
115
+ }
116
+ }
117
+
118
+ // dayKey strings sort lexically == chronologically, so "keep recent" is a
119
+ // string comparison against yesterday's key.
120
+ function bucketByDay(usage: SessionUsage): Map<string, DayUsage> {
121
+ const keep = dayKey(new Date(seedCutoffMs()));
122
+ const days = new Map<string, DayUsage>();
123
+ for (const entry of usage.entries) {
124
+ const key = dayKey(new Date(entry.timestamp));
125
+ if (key < keep) continue;
126
+ const d = days.get(key) ?? { ...EMPTY_DAY };
127
+ const u = entry.message.usage;
128
+ d.cost += entry.costUSD ?? 0;
129
+ d.input += u.input_tokens || 0;
130
+ d.output += u.output_tokens || 0;
131
+ d.cacheCreation += u.cache_creation_input_tokens || 0;
132
+ d.cacheRead += u.cache_read_input_tokens || 0;
133
+ days.set(key, d);
134
+ }
135
+ return days;
136
+ }
137
+
138
+ // Bounded-concurrency fan-out for the seed: at most `limit` parses in flight.
139
+ async function mapPool<T>(
140
+ items: readonly T[],
141
+ limit: number,
142
+ fn: (item: T) => Promise<unknown>,
143
+ ): Promise<void> {
144
+ let cursor = 0;
145
+ const worker = async (): Promise<void> => {
146
+ // The while-guard proves the index is in range; the `!` discharges
147
+ // noUncheckedIndexedAccess, it is not a defensive guard.
148
+ while (cursor < items.length) {
149
+ const item = items[cursor++]!;
150
+ await fn(item);
151
+ }
152
+ };
153
+ const width = Math.min(limit, items.length);
154
+ await Promise.all(Array.from({ length: width }, worker));
155
+ }
156
+
157
+ export class SessionUsageStore {
158
+ private readonly sessions = new SessionProvider();
159
+ private readonly entries = new Map<string, SessionRecord>();
160
+ // [LAW:one-source-of-truth] Coalesces concurrent MISSES for the same
161
+ // (session, observed mtime) onto one parse; cleared on settle (a coalescer,
162
+ // not a cache — the records map IS the cache).
163
+ private readonly flight = new SingleFlight();
164
+ // [LAW:dataflow-not-control-flow] Per-day memo of the one seed scan. Unlike
165
+ // SingleFlight this RETAINS the resolved promise for the day, so after the
166
+ // first seed completes every later read awaits an already-settled promise —
167
+ // zero rescan. A rejected seed is dropped so the next read retries.
168
+ private readonly seeded = new Map<string, Promise<void>>();
169
+ private readonly maxEntries: number;
170
+ private readonly staleAgeMs: number;
171
+ private hits = 0;
172
+ private misses = 0;
173
+ private sweeps = 0;
174
+ private seeds = 0;
175
+ private sweepTimer: NodeJS.Timeout | null = null;
176
+
177
+ constructor(
178
+ opts: {
179
+ maxEntries?: number;
180
+ staleAgeMs?: number;
181
+ sweepIntervalMs?: number;
182
+ } = {},
183
+ ) {
184
+ this.maxEntries = opts.maxEntries ?? DEFAULT_MAX_ENTRIES;
185
+ this.staleAgeMs = opts.staleAgeMs ?? DEFAULT_STALE_AGE_MS;
186
+ const interval = opts.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS;
187
+ if (interval > 0) {
188
+ this.sweepTimer = setInterval(() => this.sweepStale(), interval);
189
+ this.sweepTimer.unref();
190
+ }
191
+ }
192
+
193
+ getStats(): {
194
+ size: number;
195
+ hits: number;
196
+ misses: number;
197
+ sweeps: number;
198
+ seeds: number;
199
+ } {
200
+ return {
201
+ size: this.entries.size,
202
+ hits: this.hits,
203
+ misses: this.misses,
204
+ sweeps: this.sweeps,
205
+ seeds: this.seeds,
206
+ };
207
+ }
208
+
209
+ // The `session` projection: whole-session totals for the active session.
210
+ // [LAW:no-silent-failure] A failed transcript parse flows out as `failed`
211
+ // (the payload boundary logs it); an unknown/empty session is the all-null
212
+ // SessionInfo whose fields the boundary drops per-field — top-level
213
+ // `absent` is reserved for ingest, since the native officialCost overlay
214
+ // applies even with no record.
215
+ async getUsageInfo(
216
+ sessionId: string,
217
+ hookData?: ClaudeHookData,
218
+ ): Promise<Outcome<UsageInfo>> {
219
+ const record = await this.ingest(sessionId, hookData?.transcript_path);
220
+ if (record.kind === "failed") return record;
221
+ const base =
222
+ record.kind === "ok" ? record.value.sessionInfo : EMPTY_SESSION_INFO;
223
+ // [LAW:one-source-of-truth] Claude's reported total_cost_usd is the
224
+ // authoritative cost of the active session. base.cost (transcript entries
225
+ // priced by PricingService against a hand-maintained rate table) is a
226
+ // reimplementation — kept ONLY as a fallback for clients that omit cost,
227
+ // and to feed the cross-session `today` total, which has no native source
228
+ // (past sessions expose only their transcripts, not a live cost figure).
229
+ // The native cost is overlaid at READ time, not frozen into the mtime-keyed
230
+ // record, because it changes every render while the transcript total moves
231
+ // only when the file does.
232
+ const officialCost = hookData?.cost?.total_cost_usd ?? null;
233
+ return ok({
234
+ session: { ...base, cost: officialCost ?? base.cost, officialCost },
235
+ });
236
+ }
237
+
238
+ // The `today` projection: cross-session sum of every record's today bucket.
239
+ // [LAW:no-silent-failure] A failed seed or a failed active-session ingest
240
+ // makes the whole projection `failed` — a total silently missing today's
241
+ // main work would be a confident wrong number, worse than a loud gap.
242
+ async getTodayInfo(hookData?: ClaudeHookData): Promise<Outcome<TodayInfo>> {
243
+ const today = dayKey(new Date());
244
+ try {
245
+ await this.ensureSeeded(today);
246
+ } catch (error) {
247
+ return failed(
248
+ `usage seed: ${error instanceof Error ? error.message : String(error)}`,
249
+ );
250
+ }
251
+ // Keep the active session fresh: the seed runs once per day, so after it
252
+ // every render's freshness for the active session comes from here (a hit
253
+ // when its transcript is unchanged). Empty sessionId no-ops in ingest.
254
+ const active = await this.ingest(
255
+ hookData?.session_id ?? "",
256
+ hookData?.transcript_path,
257
+ );
258
+ if (active.kind === "failed") return active;
259
+
260
+ const total: DayUsage = { ...EMPTY_DAY };
261
+ let any = false;
262
+ for (const record of this.entries.values()) {
263
+ any = any || record.days.has(today);
264
+ const d = record.days.get(today) ?? EMPTY_DAY;
265
+ total.cost += d.cost;
266
+ total.input += d.input;
267
+ total.output += d.output;
268
+ total.cacheCreation += d.cacheCreation;
269
+ total.cacheRead += d.cacheRead;
270
+ }
271
+
272
+ if (!any) return ABSENT;
273
+ return ok({
274
+ cost: total.cost,
275
+ tokens:
276
+ total.input + total.output + total.cacheCreation + total.cacheRead,
277
+ tokenBreakdown: {
278
+ input: total.input,
279
+ output: total.output,
280
+ cacheCreation: total.cacheCreation,
281
+ cacheRead: total.cacheRead,
282
+ },
283
+ date: today,
284
+ });
285
+ }
286
+
287
+ // mtime-gated, coalesced re-parse of ONE session. `ok` is its record,
288
+ // `absent` is an unknown empty session (or no sessionId), `failed` is a
289
+ // transcript that exists but couldn't be parsed — NOT cached (only ok
290
+ // records enter the map; the next render retries, same rule as the git
291
+ // cache). This is the single write-path into the records map.
292
+ private async ingest(
293
+ sessionId: string,
294
+ transcriptPath: string | undefined,
295
+ knownMtime?: number,
296
+ ): Promise<Outcome<SessionRecord>> {
297
+ if (!sessionId) return ABSENT;
298
+
299
+ const mtime = knownMtime ?? statMtimeMs(transcriptPath);
300
+ const existing = this.entries.get(sessionId);
301
+ if (existing && mtime !== 0 && existing.transcriptMtime === mtime) {
302
+ existing.lastSeenAt = Date.now();
303
+ this.entries.delete(sessionId);
304
+ this.entries.set(sessionId, existing);
305
+ this.hits++;
306
+ return ok(existing);
307
+ }
308
+ // No path to read fresh content — preserve the last-known record rather
309
+ // than blank it. (The session will refresh when its transcript reappears.)
310
+ if (!transcriptPath) return existing ? ok(existing) : ABSENT;
311
+
312
+ this.misses++;
313
+ const aggregate = await this.flight.run(`${sessionId}:${mtime}`, () =>
314
+ this.aggregate(sessionId, transcriptPath),
315
+ );
316
+ if (aggregate.kind !== "ok") return aggregate;
317
+ // Tag with the mtime observed AFTER the read so the next render that sees
318
+ // the same mtime is a safe hit.
319
+ const record: SessionRecord = {
320
+ ...aggregate.value,
321
+ transcriptMtime: statMtimeMs(transcriptPath),
322
+ transcriptPath,
323
+ lastSeenAt: Date.now(),
324
+ };
325
+ this.entries.delete(sessionId);
326
+ this.entries.set(sessionId, record);
327
+ this.evictIfNeeded();
328
+ return ok(record);
329
+ }
330
+
331
+ private async aggregate(
332
+ sessionId: string,
333
+ transcriptPath: string,
334
+ ): Promise<
335
+ Outcome<{ sessionInfo: SessionInfo; days: Map<string, DayUsage> }>
336
+ > {
337
+ const usage = await this.sessions.getSessionUsageFromPath(
338
+ sessionId,
339
+ transcriptPath,
340
+ );
341
+ // getSessionUsageFromPath never produces absent (an empty transcript is a
342
+ // real zero-entry usage); failed passes through to the boundary.
343
+ if (usage.kind !== "ok") return usage;
344
+ return ok({
345
+ sessionInfo: this.sessions.toSessionInfo(usage.value),
346
+ days: bucketByDay(usage.value),
347
+ });
348
+ }
349
+
350
+ private ensureSeeded(day: string): Promise<void> {
351
+ const existing = this.seeded.get(day);
352
+ if (existing) return existing;
353
+ // Drop other days' memos so the map holds at most the current day.
354
+ this.seeded.clear();
355
+ const promise = this.seed(day);
356
+ this.seeded.set(day, promise);
357
+ promise.catch(() => {
358
+ if (this.seeded.get(day) === promise) this.seeded.delete(day);
359
+ });
360
+ return promise;
361
+ }
362
+
363
+ // The one and only whole-tree scan: lazily, once per day, ingest every
364
+ // session whose transcript was touched recently enough to hold a today entry.
365
+ private async seed(_day: string): Promise<void> {
366
+ const cutoff = seedCutoffMs();
367
+ const projectPaths = await findProjectPaths(getClaudePaths());
368
+ const candidates: Array<{
369
+ sessionId: string;
370
+ path: string;
371
+ mtime: number;
372
+ }> = [];
373
+
374
+ for (const dir of projectPaths) {
375
+ let files: string[];
376
+ try {
377
+ files = await gatedReaddir(dir);
378
+ } catch {
379
+ continue;
380
+ }
381
+ for (const file of files) {
382
+ if (!file.endsWith(".jsonl")) continue;
383
+ const filePath = join(dir, file);
384
+ let mtime: number;
385
+ try {
386
+ mtime = (await gatedStat(filePath)).mtimeMs;
387
+ } catch {
388
+ continue;
389
+ }
390
+ if (mtime < cutoff) continue;
391
+ candidates.push({
392
+ sessionId: file.slice(0, -".jsonl".length),
393
+ path: filePath,
394
+ mtime,
395
+ });
396
+ }
397
+ }
398
+
399
+ // [LAW:no-silent-failure] The seed is its own effect edge (timer/lazy
400
+ // driven, no render boundary to carry the outcome to), so its per-session
401
+ // parse failures are logged here.
402
+ await mapPool(candidates, SEED_CONCURRENCY, async (c) => {
403
+ const outcome = await this.ingest(c.sessionId, c.path, c.mtime);
404
+ if (outcome.kind === "failed") {
405
+ dlog("warn", `usageStore seed: ${outcome.reason}`);
406
+ }
407
+ });
408
+ this.seeds++;
409
+ dlog("info", `usageStore seed sessions=${candidates.length}`);
410
+ }
411
+
412
+ // Public for tests; called periodically from the timer.
413
+ sweepStale(): number {
414
+ const now = Date.now();
415
+ let dropped = 0;
416
+ for (const [sid, record] of this.entries) {
417
+ if (now - record.lastSeenAt > this.staleAgeMs) {
418
+ this.entries.delete(sid);
419
+ dropped++;
420
+ }
421
+ }
422
+ if (dropped > 0) {
423
+ this.sweeps++;
424
+ dlog("info", `usageStore sweep dropped=${dropped}`);
425
+ }
426
+ return dropped;
427
+ }
428
+
429
+ private evictIfNeeded(): void {
430
+ while (this.entries.size > this.maxEntries) {
431
+ const oldest = this.entries.keys().next().value;
432
+ if (oldest === undefined) break;
433
+ this.entries.delete(oldest);
434
+ dlog("info", `usageStore evict ${oldest}`);
435
+ }
436
+ }
437
+
438
+ close(): void {
439
+ if (this.sweepTimer) {
440
+ clearInterval(this.sweepTimer);
441
+ this.sweepTimer = null;
442
+ }
443
+ this.entries.clear();
444
+ this.seeded.clear();
445
+ }
446
+ }
@@ -0,0 +1,245 @@
1
+ import fs from "node:fs";
2
+ import { debug } from "../../utils/logger";
3
+
4
+ // Logger callable used for watcher lifecycle / failure events. Daemon
5
+ // injects `dlog` (writes to daemon.log at the requested level); other
6
+ // consumers (var-system tests, demo) take the default which routes
7
+ // through `debug` — present-but-quiet unless CC_CANDYBAR_DEBUG is set.
8
+ // [LAW:no-defensive-null-guards] Default is always non-null; injection
9
+ // replaces the implementation, never adds a "no logger" mode.
10
+ export type WatcherLogger = (
11
+ level: "info" | "warn" | "error",
12
+ message: string,
13
+ ) => void;
14
+
15
+ const defaultLogger: WatcherLogger = (_level, message) => debug(message);
16
+
17
+ // [LAW:single-enforcer] One registry owns *all* fs watchers for any consumer
18
+ // (git cache, config cache, ...). Scattered watchers across modules would leak
19
+ // FDs and miss cleanup at shutdown.
20
+
21
+ const DEBOUNCE_MS = 50;
22
+ const DEFAULT_MAX_WATCHERS = 128;
23
+
24
+ // Absolute filesystem paths the consumer wants invalidation for.
25
+ // `files`: regular files (existence required to watch).
26
+ // `dirs`: directories. Each may optionally restrict which child filenames
27
+ // should fire — without a filter every change in the dir fires (legacy
28
+ // git-refs behavior); with a filter only matching basenames fire (config
29
+ // case: avoids noise from sibling files in ~/.claude/).
30
+ export interface DirTarget {
31
+ path: string;
32
+ // If set, only fire when fs.watch reports a filename in this list.
33
+ // Filenames are basenames (not paths) — that's what fs.watch supplies.
34
+ filenames?: readonly string[];
35
+ }
36
+
37
+ export interface WatchTargets {
38
+ files: readonly string[];
39
+ dirs: readonly DirTarget[];
40
+ }
41
+
42
+ interface WatcherSlot {
43
+ key: string;
44
+ watchers: fs.FSWatcher[];
45
+ refcount: number;
46
+ debounceTimer: NodeJS.Timeout | null;
47
+ onInvalidate: () => void;
48
+ targets: WatchTargets;
49
+ // Last-seen accessed-at, so LRU eviction picks the staler one.
50
+ lastTouched: number;
51
+ }
52
+
53
+ export interface WatcherHandle {
54
+ release(): void;
55
+ }
56
+
57
+ export interface WatcherCounters {
58
+ watchersOpened: number;
59
+ watchersClosed: number;
60
+ watchersEvicted: number;
61
+ }
62
+
63
+ export class WatcherRegistry {
64
+ private readonly slots = new Map<string, WatcherSlot>();
65
+ private readonly maxWatchers: number;
66
+ private readonly counters?: WatcherCounters;
67
+ private readonly logger: WatcherLogger;
68
+ private closed = false;
69
+
70
+ constructor(
71
+ opts: {
72
+ maxWatchers?: number;
73
+ counters?: WatcherCounters;
74
+ // [LAW:single-enforcer] One injection point per consumer. The daemon
75
+ // passes `dlog` so watcher-failure events land in daemon.log; non-
76
+ // daemon consumers keep the default debug-routed logger and never
77
+ // touch daemon log files.
78
+ logger?: WatcherLogger;
79
+ } = {},
80
+ ) {
81
+ this.maxWatchers = opts.maxWatchers ?? DEFAULT_MAX_WATCHERS;
82
+ this.counters = opts.counters;
83
+ this.logger = opts.logger ?? defaultLogger;
84
+ }
85
+
86
+ // Acquire (or share) a watcher set keyed by `key`. Multiple acquires on the
87
+ // same key share a single underlying FSWatcher set; refcount tracks active
88
+ // consumers. Subsequent acquires *replace* onInvalidate so the latest
89
+ // consumer's callback is the one that fires — by design, callers funnel into
90
+ // a single cache module whose callback is a stable closure over the cache map.
91
+ // `targets` from the first acquire wins; subsequent acquires keep the
92
+ // original target set (consumers must use a fresh key if they need different
93
+ // targets).
94
+ acquire(
95
+ key: string,
96
+ targets: WatchTargets,
97
+ onInvalidate: () => void,
98
+ ): WatcherHandle {
99
+ if (this.closed) {
100
+ // Registry already shut down; return a no-op handle so callers don't
101
+ // crash mid-shutdown.
102
+ return { release: () => {} };
103
+ }
104
+
105
+ const existing = this.slots.get(key);
106
+ if (existing) {
107
+ existing.refcount++;
108
+ existing.onInvalidate = onInvalidate;
109
+ existing.lastTouched = Date.now();
110
+ // LRU bump.
111
+ this.slots.delete(key);
112
+ this.slots.set(key, existing);
113
+ return this.makeHandle(key);
114
+ }
115
+
116
+ const slot: WatcherSlot = {
117
+ key,
118
+ watchers: [],
119
+ refcount: 1,
120
+ debounceTimer: null,
121
+ onInvalidate,
122
+ targets,
123
+ lastTouched: Date.now(),
124
+ };
125
+ this.openWatchers(slot);
126
+ this.slots.set(key, slot);
127
+ if (this.counters) this.counters.watchersOpened++;
128
+ this.evictIfNeeded();
129
+ return this.makeHandle(key);
130
+ }
131
+
132
+ private makeHandle(key: string): WatcherHandle {
133
+ let released = false;
134
+ return {
135
+ release: () => {
136
+ if (released) return;
137
+ released = true;
138
+ const slot = this.slots.get(key);
139
+ if (!slot) return;
140
+ slot.refcount = Math.max(0, slot.refcount - 1);
141
+ if (slot.refcount === 0) {
142
+ this.closeSlot(slot);
143
+ this.slots.delete(key);
144
+ }
145
+ },
146
+ };
147
+ }
148
+
149
+ private openWatchers(slot: WatcherSlot): void {
150
+ const fire = () => {
151
+ if (slot.debounceTimer) return; // already pending
152
+ slot.debounceTimer = setTimeout(() => {
153
+ slot.debounceTimer = null;
154
+ try {
155
+ slot.onInvalidate();
156
+ } catch (e) {
157
+ this.logger(
158
+ "warn",
159
+ `watcher invalidate threw: ${(e as Error).message}`,
160
+ );
161
+ }
162
+ }, DEBOUNCE_MS);
163
+ slot.debounceTimer.unref();
164
+ };
165
+
166
+ for (const target of slot.targets.files) {
167
+ try {
168
+ const w = fs.watch(target, { persistent: false }, fire);
169
+ w.on("error", (e) => {
170
+ this.logger("warn", `watcher error ${target}: ${e.message}`);
171
+ });
172
+ slot.watchers.push(w);
173
+ } catch (e) {
174
+ this.logger("warn", `watch failed ${target}: ${(e as Error).message}`);
175
+ }
176
+ }
177
+
178
+ for (const target of slot.targets.dirs) {
179
+ try {
180
+ const filterSet = target.filenames ? new Set(target.filenames) : null;
181
+ const onDirEvent = (_evt: string, filename: string | null) => {
182
+ // [LAW:dataflow-not-control-flow] Filter is a value (Set) — same
183
+ // code path every event; the Set's .has() decides whether to fire.
184
+ if (filterSet && (!filename || !filterSet.has(filename))) return;
185
+ fire();
186
+ };
187
+ const w = fs.watch(target.path, { persistent: false }, onDirEvent);
188
+ w.on("error", (e) => {
189
+ this.logger("warn", `watcher error ${target.path}: ${e.message}`);
190
+ });
191
+ slot.watchers.push(w);
192
+ } catch (e) {
193
+ this.logger(
194
+ "warn",
195
+ `watch failed ${target.path}: ${(e as Error).message}`,
196
+ );
197
+ }
198
+ }
199
+ }
200
+
201
+ private closeSlot(slot: WatcherSlot): void {
202
+ if (slot.debounceTimer) {
203
+ clearTimeout(slot.debounceTimer);
204
+ slot.debounceTimer = null;
205
+ }
206
+ for (const w of slot.watchers) {
207
+ try {
208
+ w.close();
209
+ } catch {}
210
+ }
211
+ slot.watchers = [];
212
+ if (this.counters) this.counters.watchersClosed++;
213
+ }
214
+
215
+ private evictIfNeeded(): void {
216
+ while (this.slots.size > this.maxWatchers) {
217
+ // Map iteration order = insertion order = LRU order (we re-insert on
218
+ // access).
219
+ const oldest = this.slots.keys().next().value;
220
+ if (oldest === undefined) break;
221
+ const slot = this.slots.get(oldest)!;
222
+ this.closeSlot(slot);
223
+ this.slots.delete(oldest);
224
+ if (this.counters) this.counters.watchersEvicted++;
225
+ // Force the consumer to drop their entry too — without this the cache
226
+ // would keep stale data with no watcher behind it.
227
+ try {
228
+ slot.onInvalidate();
229
+ } catch {}
230
+ this.logger("info", `watcher LRU evict ${oldest}`);
231
+ }
232
+ }
233
+
234
+ size(): number {
235
+ return this.slots.size;
236
+ }
237
+
238
+ closeAll(): void {
239
+ this.closed = true;
240
+ for (const slot of this.slots.values()) {
241
+ this.closeSlot(slot);
242
+ }
243
+ this.slots.clear();
244
+ }
245
+ }