@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.
- package/LICENSE +21 -0
- package/README.md +145 -0
- package/bin/cc-candybar +6 -0
- package/dist/index.mjs +185 -0
- package/package.json +99 -0
- package/plugin/.claude-plugin/plugin.json +11 -0
- package/plugin/bin/preview.sh +305 -0
- package/plugin/commands/candybar.md +403 -0
- package/plugin/templates/config-essential.json +36 -0
- package/plugin/templates/config-full.json +55 -0
- package/plugin/templates/config-standard.json +39 -0
- package/plugin/templates/config-tui-compact.json +48 -0
- package/plugin/templates/config-tui-full.json +89 -0
- package/plugin/templates/config-tui-standard.json +56 -0
- package/plugin/templates/config-tui.json +18 -0
- package/plugin/templates/nerd-fonts-sample.txt +5 -0
- package/schema/cc-candybar.schema.json +1379 -0
- package/src/click/wire.ts +113 -0
- package/src/config/action.ts +91 -0
- package/src/config/cli.ts +170 -0
- package/src/config/default-dsl-config.ts +661 -0
- package/src/config/dsl-loader.ts +265 -0
- package/src/config/dsl-types.ts +425 -0
- package/src/config/loader/actions.ts +530 -0
- package/src/config/loader/cache.ts +206 -0
- package/src/config/loader/cross-ref.ts +326 -0
- package/src/config/loader/cycles.ts +148 -0
- package/src/config/loader/diagnostics.ts +99 -0
- package/src/config/loader/discovery.ts +182 -0
- package/src/config/loader/emit-schema.ts +63 -0
- package/src/config/loader/globals.ts +42 -0
- package/src/config/loader/helpers.ts +48 -0
- package/src/config/loader/layout.ts +688 -0
- package/src/config/loader/merge.ts +40 -0
- package/src/config/loader/refs.ts +96 -0
- package/src/config/loader/segments.ts +120 -0
- package/src/config/loader/validate-core.ts +674 -0
- package/src/config/loader/variables.ts +260 -0
- package/src/daemon/acquire.ts +411 -0
- package/src/daemon/cache/git.ts +553 -0
- package/src/daemon/cache/render.ts +449 -0
- package/src/daemon/cache/session-usage-store.ts +446 -0
- package/src/daemon/cache/watchers.ts +245 -0
- package/src/daemon/client-debug.ts +120 -0
- package/src/daemon/client-stats.ts +129 -0
- package/src/daemon/client-transport.ts +273 -0
- package/src/daemon/client.ts +75 -0
- package/src/daemon/debug-types.ts +91 -0
- package/src/daemon/debug.ts +264 -0
- package/src/daemon/limits.ts +154 -0
- package/src/daemon/log.ts +69 -0
- package/src/daemon/parent-watchdog.ts +80 -0
- package/src/daemon/paths.ts +127 -0
- package/src/daemon/protocol.ts +235 -0
- package/src/daemon/render-payload.ts +611 -0
- package/src/daemon/server.ts +1103 -0
- package/src/daemon/session-state-file.ts +108 -0
- package/src/daemon/session-state.ts +237 -0
- package/src/daemon/stats.ts +229 -0
- package/src/daemon/verbs/index.ts +458 -0
- package/src/daemon/verbs/state-validators.ts +708 -0
- package/src/demo/dsl.ts +117 -0
- package/src/demo/mock-data.ts +67 -0
- package/src/demo/statusline.json5 +92 -0
- package/src/dsl/node-registry.ts +281 -0
- package/src/dsl/render.ts +558 -0
- package/src/index.ts +206 -0
- package/src/install/index.ts +410 -0
- package/src/proc/launch.ts +451 -0
- package/src/proc/stats-handle.ts +13 -0
- package/src/render/action.ts +458 -0
- package/src/render/diagnostic-style.ts +23 -0
- package/src/render/diagnostic-text.ts +77 -0
- package/src/render/error-glyph.ts +53 -0
- package/src/render/outcome-plan.ts +45 -0
- package/src/render/picker.ts +231 -0
- package/src/render/split-lines.ts +51 -0
- package/src/render/strip.ts +103 -0
- package/src/segments/cache.ts +131 -0
- package/src/segments/context.ts +190 -0
- package/src/segments/git.ts +561 -0
- package/src/segments/metrics.ts +101 -0
- package/src/segments/pricing.ts +452 -0
- package/src/segments/session.ts +188 -0
- package/src/segments/tmux.ts +74 -0
- package/src/template-engine/cells.ts +90 -0
- package/src/template-engine/colors.ts +102 -0
- package/src/template-engine/engine.ts +108 -0
- package/src/template-engine/funcs.ts +216 -0
- package/src/template-engine/index.ts +11 -0
- package/src/template-engine/layout.ts +112 -0
- package/src/template-engine/scope.ts +62 -0
- package/src/themes/index.ts +19 -0
- package/src/themes/palette-resolvers.ts +86 -0
- package/src/themes/policy.ts +79 -0
- package/src/themes/session-random.ts +88 -0
- package/src/utils/cache.ts +206 -0
- package/src/utils/claude.ts +616 -0
- package/src/utils/color-support.ts +118 -0
- package/src/utils/formatters.ts +77 -0
- package/src/utils/logger.ts +5 -0
- package/src/utils/outcome.ts +33 -0
- package/src/utils/schema-validator.ts +126 -0
- package/src/utils/single-flight.ts +57 -0
- package/src/utils/terminal-width.ts +43 -0
- package/src/utils/terminal.ts +11 -0
- package/src/utils/transcript-fs.ts +162 -0
- package/src/var-system/index.ts +24 -0
- package/src/var-system/sources.ts +1038 -0
- package/src/var-system/store.ts +223 -0
- 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
|
+
}
|