@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,449 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import type { RichText } from "@promptctl/rich-js";
4
+ import { buildNeededPrefixes } from "../render-payload.js";
5
+ import {
6
+ loadConfig,
7
+ validateConfig,
8
+ resolveDslConfigPath,
9
+ dslConfigCandidatePaths,
10
+ detectConfigCollisions,
11
+ ConfigError,
12
+ } from "../../config/dsl-loader.js";
13
+ import type { ValidatedConfig } from "../../config/dsl-types.js";
14
+ import { registerDslConfig, type CompiledConfig } from "../../dsl/render.js";
15
+ import {
16
+ deriveActionValidators,
17
+ registerStateValidator,
18
+ } from "../verbs/state-validators.js";
19
+ import { VariableStore } from "../../var-system/store.js";
20
+ import { SourceRegistry } from "../../var-system/sources.js";
21
+ import type { GitDataProvider } from "./git.js";
22
+ import type { SessionStateRW } from "../session-state.js";
23
+ import type { WatcherRegistry, WatcherHandle } from "./watchers.js";
24
+ import { dlog } from "../log.js";
25
+
26
+ // [LAW:one-source-of-truth] Each cache entry owns the live DSL state for a
27
+ // (projectDir, cwd) tuple: the parsed config, the variable store +
28
+ // registry it was registered against, the compiled segment closures, and
29
+ // the resolved base palette. registerDslConfig + renderDsl are the
30
+ // single render path — the cache only holds state across calls.
31
+ //
32
+ // Capacity sized for "many concurrent sessions in many repos". Each entry
33
+ // holds a SourceRegistry (timers, watchers) so the hard cap doubles as a
34
+ // resource ceiling: at 256 active entries, fs watchers and TTL timers are
35
+ // bounded by N × declarations-per-config.
36
+ const MAX_ENTRIES = 256;
37
+
38
+ // [LAW:single-enforcer] These are the cache-and-registry deps — git data
39
+ // (for declareGit subscriptions), session state (for declareState atoms),
40
+ // and the watcher registry (for hot-reload's config file watcher). Daemon-
41
+ // owned data providers like the SessionUsageStore/git provider/etc. live in
42
+ // `payloadDeps` (server.ts) and feed `buildRenderPayload`; they are not
43
+ // part of cache identity or lifecycle.
44
+ export interface RenderDeps {
45
+ gitService: GitDataProvider;
46
+ sessionState: SessionStateRW;
47
+ watchers: WatcherRegistry;
48
+ }
49
+
50
+ // [LAW:types-are-the-program] The DSL render state for an entry is one
51
+ // optionally-null bundle, not five independently-optional fields. Either
52
+ // every field is populated (a render is possible) or all are null (parse
53
+ // failed and we never had a valid config) — the type makes any other
54
+ // combination unrepresentable.
55
+ //
56
+ // `neededInputPaths` is the layout-reachable closure of input paths,
57
+ // computed once at registration. The daemon's payload builder reads it
58
+ // to gate provider invocation.
59
+ //
60
+ // `lastRenderCellsBySegment` is the per-segment StripCell sink that
61
+ // renderDsl writes on each render — pre-layout cells, NOT serialized
62
+ // ANSI. Storing cells (not strings) keeps the hot path free of the
63
+ // per-segment renderStripCells call: the debug projection serializes on
64
+ // demand only when a `debug segments` request actually arrives. The map
65
+ // identity is stable for the entry's lifetime; renderDsl clears +
66
+ // repopulates it in place. A segment hidden by `when` is absent from the
67
+ // map — its presence in the keys is the "this segment rendered" signal.
68
+ export interface DslRenderState {
69
+ readonly config: ValidatedConfig;
70
+ readonly store: VariableStore;
71
+ readonly registry: SourceRegistry;
72
+ readonly compiled: CompiledConfig;
73
+ readonly neededInputPaths: ReadonlySet<string>;
74
+ readonly lastRenderCellsBySegment: Map<string, readonly RichText[]>;
75
+ // [LAW:single-enforcer] Disposers for the SessionState validators this config
76
+ // installed (derived from its action table). Disposed on swap/eviction in the
77
+ // same dispose-before-swap transaction as the SourceRegistry, so a reload
78
+ // never leaks a stale writable-key entry or shadows the next config's keys.
79
+ readonly validatorDisposers: ReadonlyArray<() => void>;
80
+ }
81
+
82
+ // [LAW:one-source-of-truth] Each entry tracks the last *valid* DSL state +
83
+ // the last error AND last warning from a reload attempt. We never overwrite
84
+ // a valid state with nothing — a parse error means "show the warning but
85
+ // keep rendering with what we had". Errors are scoped to the cache key
86
+ // (which includes cwd / projectDir) so a broken config in repo A cannot
87
+ // pollute repo B.
88
+ //
89
+ // [LAW:one-type-per-behavior] error and warning are distinct severities, so
90
+ // they get distinct channels. `lastError` is load-fatal (config didn't
91
+ // parse / validate); `lastWarning` is advisory (e.g., extension collision —
92
+ // load succeeded but something the user should know about). The render path
93
+ // surfaces both through one diagnostics composer in src/daemon/server.ts.
94
+ // [LAW:types-are-the-program] `projectDir` and `cwd` are required inputs to
95
+ // every render request. The wire boundary in server.ts validates the
96
+ // underlying hookData and returns BAD_REQUEST when either is absent, so by
97
+ // the time a cache entry is built they are real non-empty paths. `configFile`
98
+ // is the (`~`-expanded) value of the client's `--config` flag — present
99
+ // when overriding the standard precedence chain, undefined otherwise. The
100
+ // type carries the optionality where it actually exists.
101
+ export interface CacheEntry {
102
+ projectDir: string;
103
+ cwd: string;
104
+ configFile: string | undefined;
105
+ configFilePath: string | null;
106
+ lastError: string | null;
107
+ lastWarning: string | null;
108
+ state: DslRenderState | null;
109
+ watcher: WatcherHandle | null;
110
+ }
111
+
112
+ // [LAW:one-source-of-truth] Cache key includes every input that affects DSL
113
+ // resolution: projectDir, cwd, and the resolved `--config` file (if provided).
114
+ // `projectDir`/`cwd` are real strings by construction (validated upstream);
115
+ // `configFile` collapses absent → empty in the key, distinct from any real path.
116
+ function cacheKey(
117
+ projectDir: string,
118
+ cwd: string,
119
+ configFile: string | undefined,
120
+ ): string {
121
+ return projectDir + "\0" + cwd + "\0" + (configFile ?? "");
122
+ }
123
+
124
+ export class RenderCache {
125
+ private readonly entries = new Map<string, CacheEntry>();
126
+ private readonly deps: RenderDeps;
127
+ private readonly maxEntries: number;
128
+
129
+ constructor(deps: RenderDeps, opts: { maxEntries?: number } = {}) {
130
+ this.deps = deps;
131
+ this.maxEntries = opts.maxEntries ?? MAX_ENTRIES;
132
+ }
133
+
134
+ // [LAW:dataflow-not-control-flow] One uniform shape: every entry has the
135
+ // same fields, populated to nulls when reload failed. The renderer reads
136
+ // the data; no special-case branches between "first load", "reload",
137
+ // "reload-after-error".
138
+ getOrCreate(
139
+ projectDir: string,
140
+ cwd: string,
141
+ configFile: string | undefined,
142
+ ): CacheEntry {
143
+ const key = cacheKey(projectDir, cwd, configFile);
144
+ const existing = this.entries.get(key);
145
+ if (existing) {
146
+ // Move to end (most recently used) for LRU eviction.
147
+ this.entries.delete(key);
148
+ this.entries.set(key, existing);
149
+ return existing;
150
+ }
151
+
152
+ const entry: CacheEntry = {
153
+ projectDir,
154
+ cwd,
155
+ configFile,
156
+ configFilePath: null,
157
+ lastError: null,
158
+ lastWarning: null,
159
+ state: null,
160
+ watcher: null,
161
+ };
162
+ this.reloadInto(entry);
163
+ this.entries.set(key, entry);
164
+
165
+ if (this.entries.size > this.maxEntries) {
166
+ const oldest = this.entries.keys().next().value;
167
+ if (oldest !== undefined) {
168
+ const evicted = this.entries.get(oldest);
169
+ // [LAW:single-enforcer] dispose the registry on eviction — it owns
170
+ // timers, fs watchers, and git subscriptions. Dropping the entry
171
+ // without dispose leaks every async handle the config declared. The
172
+ // validator disposers free this entry's writable-key entries too.
173
+ evicted?.state?.registry.dispose();
174
+ evicted?.state?.validatorDisposers.forEach((dispose) => dispose());
175
+ evicted?.watcher?.release();
176
+ this.entries.delete(oldest);
177
+ }
178
+ }
179
+
180
+ return entry;
181
+ }
182
+
183
+ // Populate (or re-populate) `entry` from the current state of disk.
184
+ //
185
+ // - Parse success: dispose the prior state (if any), build fresh store +
186
+ // registry + compiled, clear lastError.
187
+ // - Parse failure: keep the prior state, set lastError. First-time
188
+ // failures leave state null (startup-error case).
189
+ //
190
+ // [LAW:single-enforcer] hot-reload contract: any reload that produces a
191
+ // new DslConfig disposes the old SourceRegistry before constructing a new
192
+ // one. The registry owns timers, watchers, MobX reactions, and git
193
+ // subscriptions — dropping it without dispose leaks every handle.
194
+ private reloadInto(entry: CacheEntry): void {
195
+ const resolvedPath = resolveDslConfigPath(
196
+ entry.projectDir,
197
+ entry.cwd,
198
+ entry.configFile,
199
+ );
200
+
201
+ // [LAW:dataflow-not-control-flow] Collision detection runs every reload,
202
+ // independent of load success — even if the .json5 fails to parse, the
203
+ // user still wants to know they have a shadowed .json sibling. Pure
204
+ // file-existence checks, so cheap. The watcher already monitors every
205
+ // candidate path, so creating/removing a duplicate triggers reload and
206
+ // re-detection automatically; nothing else needs to invalidate this.
207
+ entry.lastWarning = detectConfigCollisions(entry.projectDir, entry.cwd);
208
+
209
+ // [LAW:dataflow-not-control-flow] One uniform shape: build the new
210
+ // state into locals first, dispose the old registry ONLY after every
211
+ // construction step has succeeded. A failure at any step — parse,
212
+ // registration, palette resolution — leaves `entry.state` and
213
+ // `entry.state.registry` untouched, so the daemon keeps rendering the
214
+ // last-known-good config plus a warning icon (composeWithDiagnostics
215
+ // reads `entry.lastError` and `entry.lastWarning`). The
216
+ // "[LAW:single-enforcer] dispose before swap" contract holds for the
217
+ // swap; the construction is upstream of it.
218
+ let newState: DslRenderState;
219
+ try {
220
+ newState = this.buildState(entry, resolvedPath);
221
+ } catch (err) {
222
+ entry.lastError =
223
+ err instanceof ConfigError
224
+ ? err.message
225
+ : err instanceof Error
226
+ ? err.message
227
+ : String(err);
228
+ // Watch the broken file (and its sibling candidates) so an in-place
229
+ // save OR a higher-precedence file appearing recovers.
230
+ this.refreshWatcher(entry, resolvedPath);
231
+ return;
232
+ }
233
+
234
+ // [LAW:single-enforcer] Dispose-before-swap: the old registry owns timers,
235
+ // fs watchers, MobX reactions, and git subscriptions; the old validator
236
+ // disposers own this entry's writable-key entries in the global registry.
237
+ // Both are disposed in one step before the swap — dropping either reference
238
+ // without disposing would leak handles or shadow the new config's keys.
239
+ entry.state?.registry.dispose();
240
+ entry.state?.validatorDisposers.forEach((dispose) => dispose());
241
+ entry.lastError = null;
242
+ entry.state = newState;
243
+ // [LAW:dataflow-not-control-flow] Partial-load warnings (variable
244
+ // declaration failures that didn't abort the load) flow through the same
245
+ // warning channel as collision warnings, already set above. Append rather
246
+ // than replace so both can be visible at once.
247
+ if (newState.compiled.loadWarnings.length > 0) {
248
+ const vw = newState.compiled.loadWarnings.join("\n");
249
+ entry.lastWarning = entry.lastWarning
250
+ ? entry.lastWarning + "\n" + vw
251
+ : vw;
252
+ }
253
+ this.refreshWatcher(entry, resolvedPath);
254
+ }
255
+
256
+ // [LAW:single-enforcer] Construct the full new state — parsed config,
257
+ // store, registry, compiled segments, palette — as one transaction. Any
258
+ // failure inside disposes the partially-built registry so we don't leak
259
+ // timers/watchers from a half-constructed reload, then rethrows so the
260
+ // caller (reloadInto) preserves the prior `entry.state` unchanged.
261
+ private buildState(
262
+ entry: CacheEntry,
263
+ resolvedPath: string | null,
264
+ ): DslRenderState {
265
+ // [LAW:dataflow-not-control-flow][LAW:single-enforcer] Three primitives,
266
+ // straight-line composition. `loadConfig(null)` returns the bundled
267
+ // default (uniform merge against empty raw); `validateConfig` is the
268
+ // sole producer of `ValidatedConfig`. The renderer accepts only
269
+ // `ValidatedConfig`, so the compiler enforces the chain — there is no
270
+ // "skip validate" path that typechecks downstream.
271
+ // [LAW:one-source-of-truth] Thread the source through to validateConfig so
272
+ // cross-ref diagnostics on the daemon path carry real line numbers and the
273
+ // authored-surface (root vs layout) discriminator works — the file is read
274
+ // once inside loadConfig, not re-read here.
275
+ const { config: merged, source } = loadConfig(resolvedPath);
276
+ const config = validateConfig(merged, resolvedPath ?? "<default>", source);
277
+
278
+ const store = new VariableStore();
279
+ // [LAW:single-enforcer] Inject the daemon's shared GitDataProvider so
280
+ // every config's `kind: "git"` declarations route through one cache +
281
+ // watcher pool (rather than each registry standing up its own). The
282
+ // sessionState injection makes `kind: "state"` variables read/write the
283
+ // same per-session store the click verbs mutate. `default_empty_value`
284
+ // is honored from globals — it's the fallback used by input/env/etc.
285
+ // sources when neither the path resolves nor the declaration carries
286
+ // its own `default`. The loader validates it as a string; the registry
287
+ // default ("") matches the historical behavior when omitted.
288
+ const registry = new SourceRegistry(
289
+ store,
290
+ config.globals.default_empty_value ?? "",
291
+ this.deps.gitService,
292
+ this.deps.sessionState,
293
+ );
294
+
295
+ let compiled: CompiledConfig;
296
+ // [LAW:single-enforcer] Validators this config installs (one per menu page
297
+ // key) are part of the same construction transaction as the registry: any
298
+ // failure (registration, a duplicate-key throw) disposes every handle built
299
+ // so far — registry AND already-installed validators — before rethrowing, so
300
+ // reloadInto preserves the prior last-known-good with nothing half-installed.
301
+ const validatorDisposers: Array<() => void> = [];
302
+ try {
303
+ // [LAW:locality-or-seam] Pass the store so the config's `widget`
304
+ // references can read session.id + current picker values from the same
305
+ // source the rest of the render reads.
306
+ compiled = registerDslConfig(config, registry, {
307
+ cwd: entry.cwd,
308
+ store,
309
+ });
310
+ // [LAW:one-source-of-truth] Derive the writable-key validators from the
311
+ // config's action table (the sole interaction authority) through one
312
+ // coherence merge (deriveActionValidators), then register them so the click
313
+ // wire accepts the picker's ←/→/apply-close writes and every other action
314
+ // write alike. Merging before registration lets a trigger's literal "0" be
315
+ // absorbed into a picker's int page gate instead of colliding.
316
+ // registerStateValidator throws on a duplicate baseline key — caught here to
317
+ // roll the whole reload back.
318
+ for (const { key, spec } of deriveActionValidators(config)) {
319
+ validatorDisposers.push(registerStateValidator(key, spec));
320
+ }
321
+ } catch (err) {
322
+ for (const dispose of validatorDisposers) dispose();
323
+ registry.dispose();
324
+ throw err;
325
+ }
326
+
327
+ // [LAW:one-source-of-truth] basePalette is NOT frozen here. One cache entry
328
+ // serves many sessions, but the effective theme is per-session SessionState;
329
+ // freezing the palette per entry would let the rendered colors diverge from
330
+ // the session's chosen theme. The server resolves basePalette per render
331
+ // from the effective theme (resolverForThemeName ∘ effectiveThemeName).
332
+ return {
333
+ config,
334
+ store,
335
+ registry,
336
+ compiled,
337
+ neededInputPaths: buildNeededPrefixes(config),
338
+ lastRenderCellsBySegment: new Map<string, readonly RichText[]>(),
339
+ validatorDisposers,
340
+ };
341
+ }
342
+
343
+ // [LAW:single-enforcer] One watcher-rebind decision per reload. If the
344
+ // resolved path changed (including null↔non-null transitions), or no
345
+ // watcher is currently held, install a fresh watcher set keyed by the
346
+ // current resolved path (or `<none>` when nothing exists). The "watch all
347
+ // candidates when no file exists" behavior lives in rebindWatcher.
348
+ private refreshWatcher(entry: CacheEntry, resolvedPath: string | null): void {
349
+ if (resolvedPath !== entry.configFilePath || entry.watcher === null) {
350
+ entry.configFilePath = resolvedPath;
351
+ this.rebindWatcher(entry, resolvedPath);
352
+ }
353
+ }
354
+
355
+ private rebindWatcher(entry: CacheEntry, targetPath: string | null): void {
356
+ if (entry.watcher) {
357
+ entry.watcher.release();
358
+ entry.watcher = null;
359
+ }
360
+ // [LAW:dataflow-not-control-flow] Two outcomes from one rule:
361
+ // resolved path exists → watch THAT file + its parent dir (catches
362
+ // in-place writes and atomic-rename writes that replace the inode)
363
+ // no resolved path → watch EVERY candidate's parent dir so the
364
+ // creation of any file in the resolution chain triggers reload.
365
+ // Either way the watcher set is built from a single list of (dir,
366
+ // filename-filter) tuples; the only variability is whether the
367
+ // currently-resolved file is also added to `files` for inode-level
368
+ // watching.
369
+ // [LAW:dataflow-not-control-flow] fs.watch on a non-existent directory
370
+ // throws; on a fresh install $XDG_CONFIG_HOME/cc-candybar doesn't exist
371
+ // yet. Filter candidates to those whose parent directory exists *at
372
+ // this moment* — that's the bounded set of locations we can usefully
373
+ // watch. (A user creating the XDG dir later would only get hot-reload
374
+ // for the project-local / cwd locations until the daemon next builds
375
+ // an entry; this is a documented limitation, not a contract violation.)
376
+ // [LAW:single-enforcer] Same enumerator the resolver uses, so the watcher
377
+ // covers the exact same set of paths the next reload would consult — a
378
+ // `--config` override collapses to one candidate; absent, the precedence
379
+ // chain unfolds in full.
380
+ const candidates = dslConfigCandidatePaths(
381
+ entry.projectDir,
382
+ entry.cwd,
383
+ entry.configFile,
384
+ );
385
+ const dirSet = new Map<string, Set<string>>();
386
+ for (const candidate of candidates) {
387
+ const dir = path.dirname(candidate);
388
+ if (!fs.existsSync(dir)) continue;
389
+ const base = path.basename(candidate);
390
+ if (!dirSet.has(dir)) dirSet.set(dir, new Set());
391
+ dirSet.get(dir)!.add(base);
392
+ }
393
+ const dirs = [...dirSet.entries()].map(([dirPath, names]) => ({
394
+ path: dirPath,
395
+ filenames: [...names],
396
+ }));
397
+
398
+ // [LAW:single-enforcer] Watcher keys are per-cache-entry, not per-file.
399
+ // WatcherRegistry.acquire() is share-by-key — multiple entries that
400
+ // resolve to the same config file would otherwise share one watcher
401
+ // slot whose `onInvalidate` is overwritten by the last acquire, and
402
+ // earlier entries would never reload on file changes. Including
403
+ // (projectDir, cwd, configFile) in every key guarantees each entry owns
404
+ // its own watcher slot bound to its own reload callback.
405
+ const key = `config:${entry.projectDir}:${entry.cwd}:${entry.configFile ?? ""}:${targetPath ?? "<none>"}`;
406
+
407
+ entry.watcher = this.deps.watchers.acquire(
408
+ key,
409
+ {
410
+ files: targetPath !== null ? [targetPath] : [],
411
+ dirs,
412
+ },
413
+ () => this.onConfigChanged(entry),
414
+ );
415
+ }
416
+
417
+ // [LAW:single-enforcer] One reload dispatcher per cache entry. The
418
+ // watcher fires on any change in any candidate dir matching the
419
+ // CONFIG_FILENAME; the entry re-resolves its own resolution chain (so a
420
+ // higher-precedence file appearing supersedes a lower one) and reloads.
421
+ // We don't filter by which specific path changed because the
422
+ // (projectDir, cwd) tuple already scopes the entry's watcher set —
423
+ // sibling entries with different scopes don't share this watcher.
424
+ private onConfigChanged(entry: CacheEntry): void {
425
+ dlog(
426
+ "info",
427
+ `config change detected for entry projectDir=${entry.projectDir} cwd=${entry.cwd}`,
428
+ );
429
+ this.reloadInto(entry);
430
+ }
431
+
432
+ get size(): number {
433
+ return this.entries.size;
434
+ }
435
+
436
+ // [LAW:single-enforcer] One read path for "any populated state" used by
437
+ // the debug protocol's introspection (`debug vars` / `segments` / `config`).
438
+ // Iterates existing entries — does NOT call getOrCreate, so debug
439
+ // introspection never has the side effect of creating a fresh cache entry
440
+ // (with its own SourceRegistry timers/watchers) tied to the daemon's own
441
+ // `process.cwd()`. Returns null when the cache has no successfully-loaded
442
+ // entry; debug responses are empty in that case by construction.
443
+ firstPopulatedState(): DslRenderState | null {
444
+ for (const entry of this.entries.values()) {
445
+ if (entry.state !== null) return entry.state;
446
+ }
447
+ return null;
448
+ }
449
+ }