@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,62 @@
1
+ // [LAW:dataflow-not-control-flow] The scope Proxy converts a flat variable
2
+ // store (keys like "session.id", "git.branch", "cwd") into the nested object
3
+ // shape the template engine expects for ".session.id" field access.
4
+ // No data is materialised — the proxy navigates namespace prefixes lazily and
5
+ // calls store.read() only when a leaf is reached, so MobX dependency tracking
6
+ // fires at the point of actual reads inside a computed body.
7
+ //
8
+ // The engine's getField() uses `name in obj` before `obj[name]`, so the
9
+ // proxy must define a `has` trap as well as `get`.
10
+
11
+ import type { VariableStore } from "../var-system/store.js";
12
+
13
+ // Build the scope object the engine receives as `.` (dot).
14
+ // Call once per render; the returned object is a read-only view of the store
15
+ // at evaluation time — do not cache across renders.
16
+ export function buildScope(store: VariableStore): object {
17
+ const names = new Set(store.names());
18
+ return makeProxy(store, names, "");
19
+ }
20
+
21
+ function makeProxy(
22
+ store: VariableStore,
23
+ names: Set<string>,
24
+ prefix: string,
25
+ ): object {
26
+ return new Proxy(Object.create(null) as object, {
27
+ has(_, key: string | symbol): boolean {
28
+ if (typeof key !== "string") return false;
29
+ const fullKey = prefix ? `${prefix}.${key}` : key;
30
+ // Leaf: exact variable.
31
+ if (names.has(fullKey)) return true;
32
+ // Interior: a namespace prefix for at least one stored variable.
33
+ const nsPrefix = `${fullKey}.`;
34
+ for (const n of names) {
35
+ if (n.startsWith(nsPrefix)) return true;
36
+ }
37
+ return false;
38
+ },
39
+
40
+ get(_, key: string | symbol): unknown {
41
+ if (typeof key !== "string") return undefined;
42
+ const fullKey = prefix ? `${prefix}.${key}` : key;
43
+
44
+ // Leaf: exact variable in the store.
45
+ if (names.has(fullKey)) {
46
+ return store.read(fullKey);
47
+ }
48
+
49
+ // Interior: a namespace prefix for at least one stored variable.
50
+ // Return a nested proxy; MobX tracking fires only at the leaf read.
51
+ const nsPrefix = `${fullKey}.`;
52
+ for (const n of names) {
53
+ if (n.startsWith(nsPrefix)) {
54
+ return makeProxy(store, names, fullKey);
55
+ }
56
+ }
57
+
58
+ // Unknown: `has` returned false so the engine will throw MissingFieldError.
59
+ return undefined;
60
+ },
61
+ });
62
+ }
@@ -0,0 +1,19 @@
1
+ // [LAW:single-enforcer] Public barrel for the themes module. Name/string policy
2
+ // lives in policy.ts; memoized resolver construction (name→resolver and
3
+ // transposition) lives in palette-resolvers.ts. rich-js owns palettes,
4
+ // hydration, all color math, and the anchor rule — cc-candybar keeps no color
5
+ // arithmetic of its own.
6
+ export {
7
+ resolvePaletteName,
8
+ effectiveThemeName,
9
+ listResolvablePaletteNames,
10
+ listAvailableThemes,
11
+ pickRandomTheme,
12
+ STYLE_ORDER,
13
+ DISPLAY_STYLES,
14
+ } from "./policy.js";
15
+
16
+ export {
17
+ resolverForThemeName,
18
+ transposedResolver,
19
+ } from "./palette-resolvers.js";
@@ -0,0 +1,86 @@
1
+ // Memoized PaletteResolver construction over rich-js. cc-candybar moves theme
2
+ // NAMES and hue shifts (data); rich-js owns every color value operation. Two
3
+ // memos live here: a theme name -> base resolver, and a (base, hueShift) ->
4
+ // transposed resolver. They compose — the per-render base palette feeds the
5
+ // per-segment transposition.
6
+ //
7
+ // [LAW:no-shared-mutable-globals] Single owner: this module. Both Maps are pure
8
+ // memos of pure rich-js functions, keyed by immutable inputs (resolved theme
9
+ // name; palette name + hueShift). rich-js palettes are immutable registry
10
+ // singletons, so a cached resolver never goes stale. Key spaces are bounded by
11
+ // #themes and #themes × #distinct hueShifts (hueShift = segIndex*hueStep,
12
+ // segIndex bounded by layout) — both small. Shared on purpose: a theme's base
13
+ // resolver and its gruvbox+42° transposition are each computed once per process,
14
+ // not once per RenderCache entry or per render. Read/written only through the
15
+ // two functions below.
16
+
17
+ import {
18
+ PaletteResolver,
19
+ transposePalette,
20
+ getThemePalette,
21
+ } from "@promptctl/rich-js";
22
+ import type { ThemeKey } from "@promptctl/rich-js";
23
+ import { resolvePaletteName } from "./policy.js";
24
+
25
+ const baseCache = new Map<string, PaletteResolver>();
26
+ const transposeCache = new Map<string, PaletteResolver>();
27
+
28
+ /**
29
+ * The PaletteResolver for a theme name (aliases resolved). Memoized.
30
+ *
31
+ * [LAW:single-enforcer] The one place a theme name becomes a PaletteResolver —
32
+ * the per-render base palette and per-segment `palette:` overrides both flow
33
+ * through here. A name that does not resolve is registry/resolver drift, never
34
+ * user error: the loader validates `globals.palette` and the set-state verb
35
+ * validates session theme values against the resolvable set, so by the time a
36
+ * name reaches here it must resolve. [LAW:no-defensive-null-guards] the throw is
37
+ * the loud failure for that broken invariant, not a fallback.
38
+ */
39
+ export function resolverForThemeName(name: string): PaletteResolver {
40
+ const resolved = resolvePaletteName(name);
41
+ const hit = baseCache.get(resolved);
42
+ if (hit !== undefined) return hit;
43
+
44
+ const palette = getThemePalette(resolved);
45
+ if (palette === null) {
46
+ throw new Error(
47
+ `Palette "${name}" (resolved "${resolved}") did not resolve in the ` +
48
+ `theme registry — allowed names and the registry are inconsistent`,
49
+ );
50
+ }
51
+ const resolver = new PaletteResolver(palette);
52
+ baseCache.set(resolved, resolver);
53
+ return resolver;
54
+ }
55
+
56
+ /**
57
+ * The PaletteResolver for `base`'s palette transposed by `hueShift` degrees.
58
+ *
59
+ * [LAW:dataflow-not-control-flow] hueShift is data; 0 flows through
60
+ * transposePalette's identity fast-path (byte-exact, no round-trip) — no branch
61
+ * here. Chroma and lightness are held identity: only hue rotates. rich-js
62
+ * hue-locks ANCHORED_ROOTS (error/success/warning), so semantic meaning is
63
+ * preserved by construction — no local exemption list to drift.
64
+ *
65
+ * [LAW:single-enforcer] The sole place a transposed resolver is built. The memo
66
+ * miss (undefined) is genuine optionality — not-yet-computed — not a defended
67
+ * invariant.
68
+ */
69
+ export function transposedResolver(
70
+ base: PaletteResolver,
71
+ hueShift: number,
72
+ ): PaletteResolver {
73
+ const cacheKey = `${base.palette.name} ${hueShift}`;
74
+ const hit = transposeCache.get(cacheKey);
75
+ if (hit !== undefined) return hit;
76
+
77
+ const key: ThemeKey = {
78
+ hueShift,
79
+ chromaScale: 1,
80
+ lightnessScale: 1,
81
+ lightnessShift: 0,
82
+ };
83
+ const resolver = new PaletteResolver(transposePalette(base.palette, key));
84
+ transposeCache.set(cacheKey, resolver);
85
+ return resolver;
86
+ }
@@ -0,0 +1,79 @@
1
+ // [LAW:single-enforcer] The slim name/string policy layered on top of rich-js's
2
+ // theme registry. cc-candybar selects theme NAMES and style IDENTIFIERS here;
3
+ // every color *value* operation (hydrate hex, resolve specs, darken/contrast,
4
+ // hue/transpose) lives in rich-js. This module only moves strings — no
5
+ // PaletteResolver, no ColorRgba, no hex. The semantic/anchor knowledge
6
+ // (which tokens keep their hue) stays in rich-js (ANCHORED_ROOTS), not here.
7
+
8
+ import { listThemePalettes } from "@promptctl/rich-js";
9
+
10
+ // --- Theme name aliasing ---
11
+
12
+ const THEME_ALIASES: Record<string, string> = {
13
+ dark: "textual-dark",
14
+ light: "textual-light",
15
+ };
16
+
17
+ export function resolvePaletteName(name: string): string {
18
+ return THEME_ALIASES[name] ?? name;
19
+ }
20
+
21
+ // The theme name a render should use, as data. [LAW:dataflow-not-control-flow]
22
+ // The `??` chain is the precedence — session choice over config default over the
23
+ // built-in — with no "if the session has a theme" branch. The session value is
24
+ // the user's live per-session pick (null when unset); globals.palette is the
25
+ // config default; "textual-dark" is the always-present floor.
26
+ // [LAW:one-source-of-truth] The single definition of "which theme is effective";
27
+ // every render derives basePalette through this, so the rendered palette can
28
+ // never disagree with the chosen theme.
29
+ export function effectiveThemeName(
30
+ sessionTheme: string | null,
31
+ globalsPalette: string | undefined,
32
+ ): string {
33
+ return sessionTheme ?? globalsPalette ?? "textual-dark";
34
+ }
35
+
36
+ function listThemeAliases(): readonly string[] {
37
+ return Object.keys(THEME_ALIASES);
38
+ }
39
+
40
+ // [LAW:one-source-of-truth] The set of names that resolve to a concrete Palette
41
+ // is exactly registry names ∪ aliases — the same inputs resolvePaletteName +
42
+ // getThemePalette accept. "custom" and "random" are deliberately absent: neither
43
+ // names a concrete palette (custom needs inline colors; random is a per-session
44
+ // sentinel). Config validators that gate a palette PULL (DSL `palette:` field)
45
+ // must reuse this, not re-derive it.
46
+ export function listResolvablePaletteNames(): readonly string[] {
47
+ return [...listThemePalettes(), ...listThemeAliases()];
48
+ }
49
+
50
+ // Selectable theme names for the random pool + picker: registry names plus the
51
+ // "custom" sentinel. Aliases are excluded — they duplicate registry entries.
52
+ export function listAvailableThemes(): string[] {
53
+ const allNames = new Set<string>(listThemePalettes() as readonly string[]);
54
+ allNames.add("custom");
55
+ return [...allNames].sort();
56
+ }
57
+
58
+ // --- Style identifiers ---
59
+
60
+ export const STYLE_ORDER: readonly string[] = [
61
+ "surface",
62
+ "muted",
63
+ "button",
64
+ "hue",
65
+ ];
66
+
67
+ export const DISPLAY_STYLES: ReadonlyArray<
68
+ "minimal" | "powerline" | "capsule"
69
+ > = ["minimal", "powerline", "capsule"];
70
+
71
+ // --- Session-random pick ---
72
+
73
+ // [LAW:one-source-of-truth] The random pool derives from the same registry the
74
+ // rest of the system uses. "custom" is excluded because it requires inline
75
+ // colors.custom to be defined.
76
+ export function pickRandomTheme(): string {
77
+ const themes = listAvailableThemes().filter((t) => t !== "custom");
78
+ return themes[Math.floor(Math.random() * themes.length)]!;
79
+ }
@@ -0,0 +1,88 @@
1
+ // [LAW:single-enforcer] One place resolves the "random" sentinel into a
2
+ // concrete value, keyed by sessionId so the random pick is *stable for the
3
+ // life of that session* (pick once; clicks like set-state overwrite it).
4
+ // Each setting (theme / style / endpoints) flows through the same pattern:
5
+ // read sessionState → if cached return it → if config says "random" pick
6
+ // + cache → otherwise return config value as-is.
7
+ //
8
+ // The cache is sessionState itself: that means a) the set-state click
9
+ // verb continues to work (it overwrites the same key), and b) the
10
+ // dataflow is uniform — no parallel "random cache" to keep in sync with
11
+ // sessionState.
12
+
13
+ import { pickRandomTheme, DISPLAY_STYLES, STYLE_ORDER } from "./policy.js";
14
+
15
+ interface SessionStateRW {
16
+ get(sessionId: string, key: string): string | null;
17
+ set(sessionId: string, key: string, value: string): void;
18
+ }
19
+
20
+ const SENTINEL = "random";
21
+
22
+ // [LAW:dataflow-not-control-flow] Same shape every call — the only branch is
23
+ // "is this random?" and that's a value-driven question, not a control-flow
24
+ // special case. No early returns hide the cache write from later readers.
25
+ function resolve(
26
+ sessionId: string,
27
+ key: string,
28
+ configValue: string | undefined,
29
+ sessionState: SessionStateRW | undefined,
30
+ pick: () => string,
31
+ fallback: string,
32
+ ): string {
33
+ if (!sessionState) {
34
+ return configValue === SENTINEL ? pick() : (configValue ?? fallback);
35
+ }
36
+ const cached = sessionState.get(sessionId, key);
37
+ if (cached) return cached;
38
+ if (configValue !== SENTINEL) return configValue ?? fallback;
39
+ const chosen = pick();
40
+ sessionState.set(sessionId, key, chosen);
41
+ return chosen;
42
+ }
43
+
44
+ export function resolveSessionTheme(
45
+ sessionId: string,
46
+ configTheme: string | undefined,
47
+ sessionState: SessionStateRW | undefined,
48
+ ): string {
49
+ return resolve(
50
+ sessionId,
51
+ "theme",
52
+ configTheme,
53
+ sessionState,
54
+ pickRandomTheme,
55
+ "dark",
56
+ );
57
+ }
58
+
59
+ export function resolveSessionStyle(
60
+ sessionId: string,
61
+ configStyle: string | undefined,
62
+ sessionState: SessionStateRW | undefined,
63
+ ): string {
64
+ return resolve(
65
+ sessionId,
66
+ "style",
67
+ configStyle,
68
+ sessionState,
69
+ () => STYLE_ORDER[Math.floor(Math.random() * STYLE_ORDER.length)]!,
70
+ "surface",
71
+ );
72
+ }
73
+
74
+ export function resolveSessionDisplayStyle(
75
+ sessionId: string,
76
+ configDisplayStyle: string | undefined,
77
+ sessionState: SessionStateRW | undefined,
78
+ ): "minimal" | "powerline" | "capsule" {
79
+ const value = resolve(
80
+ sessionId,
81
+ "displayStyle",
82
+ configDisplayStyle,
83
+ sessionState,
84
+ () => DISPLAY_STYLES[Math.floor(Math.random() * DISPLAY_STYLES.length)]!,
85
+ "minimal",
86
+ );
87
+ return value === "powerline" || value === "capsule" ? value : "minimal";
88
+ }
@@ -0,0 +1,206 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { setTimeout } from "node:timers/promises";
5
+ import { debug } from "./logger";
6
+ import { cacheDir } from "../daemon/paths";
7
+
8
+ interface ErrnoError extends Error {
9
+ code?: string;
10
+ }
11
+
12
+ export interface CacheEntry<T> {
13
+ data: T;
14
+ timestamp: number;
15
+ }
16
+
17
+ export class CacheManager {
18
+ private static readonly CACHE_DIR = cacheDir();
19
+ private static readonly USAGE_CACHE_DIR = path.join(this.CACHE_DIR, "usage");
20
+ private static readonly LOCKS_DIR = path.join(this.CACHE_DIR, "locks");
21
+
22
+ private static isLocked(name: string): boolean {
23
+ const lockFile = path.join(this.LOCKS_DIR, name);
24
+ if (!fs.existsSync(lockFile)) {
25
+ return false;
26
+ }
27
+
28
+ try {
29
+ const lockContent = fs.readFileSync(lockFile, "utf-8");
30
+ const pid = parseInt(lockContent.trim(), 10);
31
+
32
+ if (isNaN(pid)) {
33
+ debug(`Invalid PID in lock file ${name}, removing stale lock`);
34
+ fs.unlinkSync(lockFile);
35
+ return false;
36
+ }
37
+
38
+ try {
39
+ process.kill(pid, 0);
40
+ return true;
41
+ } catch (error) {
42
+ if ((error as ErrnoError).code === "ESRCH") {
43
+ debug(`Removing stale lock file ${name} for dead process ${pid}`);
44
+ fs.unlinkSync(lockFile);
45
+ return false;
46
+ }
47
+ debug(`Error checking process ${pid} for lock ${name}:`, error);
48
+ return true;
49
+ }
50
+ } catch (error) {
51
+ debug(`Error reading lock file ${name}:`, error);
52
+ return true;
53
+ }
54
+ }
55
+
56
+ private static async acquireLock(
57
+ name: string,
58
+ timeout = 5000,
59
+ ): Promise<boolean> {
60
+ const RETRY_DELAY_MS = 50;
61
+ const FILE_CREATE_FLAG = "wx";
62
+
63
+ await this.ensureCacheDirectories();
64
+ const lockFile = path.join(this.LOCKS_DIR, name);
65
+ const startTime = Date.now();
66
+ const lockContent = String(process.pid);
67
+
68
+ while (Date.now() - startTime < timeout) {
69
+ try {
70
+ await fs.promises.writeFile(lockFile, lockContent, {
71
+ flag: FILE_CREATE_FLAG,
72
+ });
73
+ debug(`Lock acquired for ${name}`);
74
+ return true;
75
+ } catch (error) {
76
+ if ((error as ErrnoError).code === "EEXIST") {
77
+ await setTimeout(RETRY_DELAY_MS);
78
+ } else {
79
+ throw error;
80
+ }
81
+ }
82
+ }
83
+ debug(`Failed to acquire lock for ${name} within ${timeout}ms`);
84
+ return false;
85
+ }
86
+
87
+ private static async releaseLock(name: string): Promise<void> {
88
+ const lockFile = path.join(this.LOCKS_DIR, name);
89
+ try {
90
+ await fs.promises.unlink(lockFile);
91
+ debug(`Lock released for ${name}`);
92
+ } catch (error) {
93
+ if ((error as ErrnoError).code !== "ENOENT") {
94
+ debug(`Error releasing lock for ${name}:`, error);
95
+ }
96
+ }
97
+ }
98
+
99
+ static async ensureCacheDirectories(): Promise<void> {
100
+ try {
101
+ await Promise.all([
102
+ fs.promises.mkdir(this.CACHE_DIR, { recursive: true }),
103
+ fs.promises.mkdir(this.USAGE_CACHE_DIR, { recursive: true }),
104
+ fs.promises.mkdir(this.LOCKS_DIR, { recursive: true }),
105
+ ]);
106
+ } catch (error) {
107
+ debug("Failed to create cache directories:", error);
108
+ }
109
+ }
110
+
111
+ static createProjectHash(projectPath: string): string {
112
+ return createHash("md5").update(projectPath).digest("hex").substring(0, 8);
113
+ }
114
+
115
+ static async getUsageCache(
116
+ cacheType: "pricing",
117
+ latestMtime?: number,
118
+ ): Promise<unknown> {
119
+ const MAX_RETRIES = 3;
120
+ const RETRY_DELAY_MS = 75;
121
+ const FILE_ENCODING = "utf-8";
122
+
123
+ await this.ensureCacheDirectories();
124
+ const cachePath = path.join(this.USAGE_CACHE_DIR, `${cacheType}.json`);
125
+ const lockName = `${cacheType}.usage.lock`;
126
+
127
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
128
+ const isCurrentlyLocked = this.isLocked(lockName);
129
+ if (isCurrentlyLocked) {
130
+ debug(`Cache for ${cacheType} is locked, waiting...`);
131
+ await setTimeout(RETRY_DELAY_MS);
132
+ continue;
133
+ }
134
+
135
+ try {
136
+ const content = await fs.promises.readFile(cachePath, FILE_ENCODING);
137
+ const cached: CacheEntry<unknown> = JSON.parse(content);
138
+ const cacheIsValid = !latestMtime || cached.timestamp >= latestMtime;
139
+
140
+ if (cacheIsValid) {
141
+ debug(`[CACHE-HIT] ${cacheType} disk cache: found`);
142
+ return this.deserializeDates(cached.data);
143
+ } else {
144
+ debug(
145
+ `${cacheType} cache outdated: cache=${cached.timestamp}, latest=${latestMtime}`,
146
+ );
147
+ return null;
148
+ }
149
+ } catch (error) {
150
+ if ((error as ErrnoError).code === "ENOENT") {
151
+ debug(`No shared ${cacheType} usage cache found`);
152
+ return null;
153
+ }
154
+ const attemptNumber = attempt + 1;
155
+ debug(
156
+ `Attempt ${attemptNumber} failed to read ${cacheType} cache: ${(error as Error).message}. Retrying...`,
157
+ );
158
+ await setTimeout(RETRY_DELAY_MS);
159
+ }
160
+ }
161
+
162
+ debug(`Failed to read ${cacheType} cache after ${MAX_RETRIES} attempts.`);
163
+ return null;
164
+ }
165
+
166
+ private static deserializeDates(data: unknown): unknown {
167
+ if (Array.isArray(data)) {
168
+ return data.map((entry) => ({
169
+ ...entry,
170
+ timestamp: new Date(entry.timestamp),
171
+ }));
172
+ }
173
+ return data;
174
+ }
175
+
176
+ static async setUsageCache(
177
+ cacheType: "pricing",
178
+ data: unknown,
179
+ latestMtime?: number,
180
+ ): Promise<void> {
181
+ const lockName = `${cacheType}.usage.lock`;
182
+ const lockAcquired = await this.acquireLock(lockName);
183
+ if (!lockAcquired) {
184
+ debug(`Could not acquire lock to set usage cache for ${cacheType}`);
185
+ return;
186
+ }
187
+
188
+ try {
189
+ await this.ensureCacheDirectories();
190
+ const cachePath = path.join(this.USAGE_CACHE_DIR, `${cacheType}.json`);
191
+ const cacheTimestamp = latestMtime || Date.now();
192
+ const cacheEntry: CacheEntry<unknown> = {
193
+ data,
194
+ timestamp: cacheTimestamp,
195
+ };
196
+ const cacheContent = JSON.stringify(cacheEntry);
197
+
198
+ await fs.promises.writeFile(cachePath, cacheContent, "utf-8");
199
+ debug(`[CACHE-SET] ${cacheType} disk cache stored`);
200
+ } catch (error) {
201
+ debug(`Failed to save ${cacheType} usage cache:`, error);
202
+ } finally {
203
+ await this.releaseLock(lockName);
204
+ }
205
+ }
206
+ }