@promptctl/cc-candybar 1.42.1 → 1.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/dist/index.mjs +72 -71
  2. package/package.json +5 -6
  3. package/src/check.ts +0 -478
  4. package/src/cli-flags.ts +0 -8
  5. package/src/click/wire.ts +0 -158
  6. package/src/config/action.ts +0 -329
  7. package/src/config/cli.ts +0 -71
  8. package/src/config/default-dsl-config.ts +0 -1645
  9. package/src/config/disclosure.ts +0 -170
  10. package/src/config/dsl-loader.ts +0 -339
  11. package/src/config/dsl-types.ts +0 -581
  12. package/src/config/edit-chrome.ts +0 -559
  13. package/src/config/help.ts +0 -151
  14. package/src/config/ident.ts +0 -22
  15. package/src/config/layout-ops.ts +0 -177
  16. package/src/config/loader/actions.ts +0 -972
  17. package/src/config/loader/cache.ts +0 -206
  18. package/src/config/loader/cross-ref.ts +0 -714
  19. package/src/config/loader/cycles.ts +0 -148
  20. package/src/config/loader/diagnostics.ts +0 -99
  21. package/src/config/loader/discovery.ts +0 -182
  22. package/src/config/loader/edit-mode.ts +0 -137
  23. package/src/config/loader/emit-schema.ts +0 -68
  24. package/src/config/loader/globals.ts +0 -269
  25. package/src/config/loader/helpers.ts +0 -48
  26. package/src/config/loader/layout.ts +0 -693
  27. package/src/config/loader/looks.ts +0 -96
  28. package/src/config/loader/menu-synth.ts +0 -435
  29. package/src/config/loader/merge.ts +0 -115
  30. package/src/config/loader/persist-target.ts +0 -67
  31. package/src/config/loader/presets.ts +0 -119
  32. package/src/config/loader/refs.ts +0 -100
  33. package/src/config/loader/reserved-namespace.ts +0 -38
  34. package/src/config/loader/segments.ts +0 -120
  35. package/src/config/loader/validate-core.ts +0 -737
  36. package/src/config/loader/variables.ts +0 -260
  37. package/src/config/menu-keys.ts +0 -139
  38. package/src/config/option-domain.ts +0 -164
  39. package/src/config/presets.ts +0 -326
  40. package/src/config/settings-menu.ts +0 -775
  41. package/src/daemon/acquire.ts +0 -684
  42. package/src/daemon/cache/git.ts +0 -649
  43. package/src/daemon/cache/render.ts +0 -623
  44. package/src/daemon/cache/session-usage-store.ts +0 -720
  45. package/src/daemon/cache/watchers.ts +0 -249
  46. package/src/daemon/client-debug.ts +0 -120
  47. package/src/daemon/client-stats.ts +0 -130
  48. package/src/daemon/client-transport.ts +0 -273
  49. package/src/daemon/client.ts +0 -78
  50. package/src/daemon/config-overrides-store.ts +0 -663
  51. package/src/daemon/debug-types.ts +0 -91
  52. package/src/daemon/debug.ts +0 -264
  53. package/src/daemon/fork-bomb-breaker.ts +0 -351
  54. package/src/daemon/limits.ts +0 -211
  55. package/src/daemon/log.ts +0 -81
  56. package/src/daemon/parent-watchdog.ts +0 -87
  57. package/src/daemon/paths.ts +0 -211
  58. package/src/daemon/process-fingerprint.ts +0 -146
  59. package/src/daemon/protocol.ts +0 -292
  60. package/src/daemon/render-payload.ts +0 -1256
  61. package/src/daemon/server.ts +0 -1330
  62. package/src/daemon/session-state-file.ts +0 -108
  63. package/src/daemon/session-state.ts +0 -237
  64. package/src/daemon/socket-lease.ts +0 -209
  65. package/src/daemon/socket-ownership.ts +0 -209
  66. package/src/daemon/stats.ts +0 -235
  67. package/src/daemon/verbs/config-validators.ts +0 -250
  68. package/src/daemon/verbs/index.ts +0 -706
  69. package/src/daemon/verbs/state-validators.ts +0 -249
  70. package/src/daemon/verbs/validator-registry.ts +0 -457
  71. package/src/demo/dsl.ts +0 -143
  72. package/src/demo/mock-data.ts +0 -67
  73. package/src/demo/statusline.json5 +0 -94
  74. package/src/dsl/node-registry.ts +0 -374
  75. package/src/dsl/render.ts +0 -803
  76. package/src/help-text.ts +0 -90
  77. package/src/index.ts +0 -210
  78. package/src/install/currency.ts +0 -197
  79. package/src/install/index.ts +0 -557
  80. package/src/proc/launch.ts +0 -459
  81. package/src/proc/stats-handle.ts +0 -13
  82. package/src/render/action.ts +0 -883
  83. package/src/render/active-segment.ts +0 -78
  84. package/src/render/diagnostic-style.ts +0 -23
  85. package/src/render/diagnostic-text.ts +0 -77
  86. package/src/render/error-glyph.ts +0 -53
  87. package/src/render/menu.ts +0 -257
  88. package/src/render/outcome-plan.ts +0 -45
  89. package/src/render/picker.ts +0 -372
  90. package/src/render/segment-color.ts +0 -74
  91. package/src/render/split-lines.ts +0 -51
  92. package/src/render/strip.ts +0 -228
  93. package/src/segments/cache.ts +0 -131
  94. package/src/segments/context.ts +0 -190
  95. package/src/segments/git.ts +0 -1084
  96. package/src/segments/metrics.ts +0 -187
  97. package/src/segments/pricing.ts +0 -452
  98. package/src/segments/session.ts +0 -23
  99. package/src/segments/tmux.ts +0 -74
  100. package/src/template-engine/cells.ts +0 -90
  101. package/src/template-engine/colors.ts +0 -124
  102. package/src/template-engine/engine.ts +0 -108
  103. package/src/template-engine/funcs.ts +0 -232
  104. package/src/template-engine/index.ts +0 -11
  105. package/src/template-engine/layout.ts +0 -133
  106. package/src/template-engine/scope.ts +0 -62
  107. package/src/template-engine/sparkline.ts +0 -79
  108. package/src/themes/index.ts +0 -20
  109. package/src/themes/palette-resolvers.ts +0 -84
  110. package/src/themes/policy.ts +0 -393
  111. package/src/utils/cache.ts +0 -206
  112. package/src/utils/claude.ts +0 -683
  113. package/src/utils/color-support.ts +0 -118
  114. package/src/utils/formatters.ts +0 -99
  115. package/src/utils/logger.ts +0 -5
  116. package/src/utils/outcome.ts +0 -33
  117. package/src/utils/schema-validator.ts +0 -126
  118. package/src/utils/single-flight.ts +0 -57
  119. package/src/utils/terminal-width.ts +0 -51
  120. package/src/utils/terminal.ts +0 -11
  121. package/src/utils/transcript-fs.ts +0 -279
  122. package/src/var-system/index.ts +0 -24
  123. package/src/var-system/sources.ts +0 -1047
  124. package/src/var-system/store.ts +0 -223
  125. package/src/var-system/types.ts +0 -57
  126. package/src/version.ts +0 -17
@@ -1,623 +0,0 @@
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
- mergeWithDefault,
12
- applySegmentPaletteOverrides,
13
- ConfigError,
14
- } from "../../config/dsl-loader.js";
15
- import type { ValidatedConfig } from "../../config/dsl-types.js";
16
- import { DEFAULT_DSL_CONFIG } from "../../config/default-dsl-config.js";
17
- import { registerDslConfig, type CompiledConfig } from "../../dsl/render.js";
18
- import {
19
- deriveActionValidators,
20
- registerStateValidator,
21
- } from "../verbs/state-validators.js";
22
- import {
23
- deriveConfigActionValidators,
24
- registerConfigValidator,
25
- } from "../verbs/config-validators.js";
26
- import { loadOverrides } from "../config-overrides-store.js";
27
- import { configOverridesPath } from "../paths.js";
28
- import {
29
- applyPresetRootOpsOverrides,
30
- sanitizePersistedPresetOverride,
31
- sanitizePersistedPresetRootOps,
32
- } from "../../config/presets.js";
33
- import { VariableStore } from "../../var-system/store.js";
34
- import { SourceRegistry } from "../../var-system/sources.js";
35
- import type { GitDataProvider } from "./git.js";
36
- import type { SessionStateRW } from "../session-state.js";
37
- import type { WatcherRegistry, WatcherHandle } from "./watchers.js";
38
- import { dlog } from "../log.js";
39
-
40
- // [LAW:one-source-of-truth] Each cache entry owns the live DSL state for a
41
- // (projectDir, cwd) tuple: the parsed config, the variable store +
42
- // registry it was registered against, the compiled segment closures, and
43
- // the resolved base palette. registerDslConfig + renderDsl are the
44
- // single render path — the cache only holds state across calls.
45
- //
46
- // Capacity sized for "many concurrent sessions in many repos". Each entry
47
- // holds a SourceRegistry (timers, watchers) so the hard cap doubles as a
48
- // resource ceiling: at 256 active entries, fs watchers and TTL timers are
49
- // bounded by N × declarations-per-config.
50
- const MAX_ENTRIES = 256;
51
-
52
- // [LAW:single-enforcer] These are the cache-and-registry deps — git data
53
- // (for declareGit subscriptions), session state (for declareState atoms),
54
- // and the watcher registry (for hot-reload's config file watcher). Daemon-
55
- // owned data providers like the SessionUsageStore/git provider/etc. live in
56
- // `payloadDeps` (server.ts) and feed `buildRenderPayload`; they are not
57
- // part of cache identity or lifecycle.
58
- export interface RenderDeps {
59
- gitService: GitDataProvider;
60
- sessionState: SessionStateRW;
61
- watchers: WatcherRegistry;
62
- }
63
-
64
- // [LAW:no-ambient-temporal-coupling] The cache's outward lifecycle signal. A
65
- // reload is the one event the cache alone knows the completion of — it runs
66
- // from a debounced fs watcher, on the cache's own schedule — so anything that
67
- // must run AFTER a reload (an operator log of its outcome, a test asserting
68
- // on the state it wrote) needs the cache to say so, or it is left betting on
69
- // a clock. The same named-bag idiom as renderDsl's RenderObservers
70
- // [LAW:locality-or-seam]: a caller states what it passes by name, and a new
71
- // observer is one field here, not a constructor signature every caller
72
- // re-counts.
73
- export interface RenderCacheObservers {
74
- // Fires once per completed reload of an entry — the initial population in
75
- // getOrCreate and every watcher-driven reload alike, success (a fresh
76
- // `state`) and failure (`lastError` set, prior state preserved) alike —
77
- // after the entry's fields and watcher are settled. The entry is handed
78
- // over as ReloadedEntry so the observer reads the outcome from the one
79
- // place it lives and the type, not this comment, keeps it from writing
80
- // there. Trusted non-throwing (the same contract as onSegmentError): an
81
- // observer that throws is a caller bug surfaced loudly, never absorbed here.
82
- readonly onReload?: (entry: ReloadedEntry) => void;
83
- }
84
-
85
- // [LAW:types-are-the-program] The observer's view of an entry: an ALLOW-LIST
86
- // of the load's outcome. Nothing that owns a resource — the watcher handle,
87
- // the SourceRegistry, the validator disposers, the render-cell sink — crosses
88
- // this seam, so a handle added to either type later is closed by
89
- // construction, not by remembering to omit it (`Readonly` alone would not:
90
- // it cannot stop a method call such as `dispose()`).
91
- export type ReloadedEntry = Readonly<
92
- Pick<
93
- CacheEntry,
94
- | "projectDir"
95
- | "cwd"
96
- | "configFile"
97
- | "configFilePath"
98
- | "lastError"
99
- | "lastWarning"
100
- >
101
- > & { readonly state: Readonly<Pick<DslRenderState, "config">> | null };
102
-
103
- // [LAW:types-are-the-program] The DSL render state for an entry is one
104
- // optionally-null bundle, not five independently-optional fields. Either
105
- // every field is populated (a render is possible) or all are null (parse
106
- // failed and we never had a valid config) — the type makes any other
107
- // combination unrepresentable.
108
- //
109
- // `neededInputPaths` is the layout-reachable closure of input paths,
110
- // computed once at registration. The daemon's payload builder reads it
111
- // to gate provider invocation.
112
- //
113
- // `lastRenderCellsBySegment` is the per-segment StripCell sink that
114
- // renderDsl writes on each render — pre-layout cells, NOT serialized
115
- // ANSI. Storing cells (not strings) keeps the hot path free of the
116
- // per-segment renderStripCells call: the debug projection serializes on
117
- // demand only when a `debug segments` request actually arrives. The map
118
- // identity is stable for the entry's lifetime; renderDsl clears +
119
- // repopulates it in place. A segment hidden by `when` is absent from the
120
- // map — its presence in the keys is the "this segment rendered" signal.
121
- export interface DslRenderState {
122
- readonly config: ValidatedConfig;
123
- readonly store: VariableStore;
124
- readonly registry: SourceRegistry;
125
- readonly compiled: CompiledConfig;
126
- readonly neededInputPaths: ReadonlySet<string>;
127
- readonly lastRenderCellsBySegment: Map<string, readonly RichText[]>;
128
- // [LAW:one-source-of-truth] The accumulated-ops record this reload read
129
- // from the overrides file, sanitized against THIS config's declared
130
- // presets (sanitizePersistedPresetRootOps — never the raw file content: a
131
- // stale entry naming a preset from a different project must not surface
132
- // as "customized" here either) — the exact input
133
- // applyPresetRootOpsOverrides replayed into `config.presets[name].root`
134
- // above. Carried alongside the replayed config because the replay
135
- // CONSUMES the op count: by the time a preset's tree is spliced/
136
- // validated, nothing about it says how many ops (if any) produced it.
137
- // brandon-layout-edit-2gc.5 reads this per render (keyed by the active
138
- // preset name) to answer "does the bar's current arrangement differ from
139
- // what's literally in the user's file" without a second overrides read
140
- // (this entry rebuilds on the SAME watcher that rebuilds `config`, so the
141
- // two never drift).
142
- readonly presetRootOps: Readonly<Record<string, readonly string[]>>;
143
- // [LAW:single-enforcer] Disposers for the SessionState validators this config
144
- // installed (derived from its action table). Disposed on swap/eviction in the
145
- // same dispose-before-swap transaction as the SourceRegistry, so a reload
146
- // never leaks a stale writable-key entry or shadows the next config's keys.
147
- readonly validatorDisposers: ReadonlyArray<() => void>;
148
- }
149
-
150
- // [LAW:one-source-of-truth] Each entry tracks the last *valid* DSL state +
151
- // the last error AND last warning from a reload attempt. We never overwrite
152
- // a valid state with nothing — a parse error means "show the warning but
153
- // keep rendering with what we had". Errors are scoped to the cache key
154
- // (which includes cwd / projectDir) so a broken config in repo A cannot
155
- // pollute repo B.
156
- //
157
- // [LAW:one-type-per-behavior] error and warning are distinct severities, so
158
- // they get distinct channels. `lastError` is load-fatal (config didn't
159
- // parse / validate); `lastWarning` is advisory (e.g., extension collision —
160
- // load succeeded but something the user should know about). The render path
161
- // surfaces both through one diagnostics composer in src/daemon/server.ts.
162
- // [LAW:types-are-the-program] `projectDir` and `cwd` are required inputs to
163
- // every render request. The wire boundary in server.ts validates the
164
- // underlying hookData and returns BAD_REQUEST when either is absent, so by
165
- // the time a cache entry is built they are real non-empty paths. `configFile`
166
- // is the (`~`-expanded) value of the client's `--config` flag — present
167
- // when overriding the standard precedence chain, undefined otherwise. The
168
- // type carries the optionality where it actually exists.
169
- export interface CacheEntry {
170
- projectDir: string;
171
- cwd: string;
172
- configFile: string | undefined;
173
- configFilePath: string | null;
174
- lastError: string | null;
175
- lastWarning: string | null;
176
- state: DslRenderState | null;
177
- watcher: WatcherHandle | null;
178
- }
179
-
180
- // [LAW:one-source-of-truth] Cache key includes every input that affects DSL
181
- // resolution: projectDir, cwd, and the resolved `--config` file (if provided).
182
- // `projectDir`/`cwd` are real strings by construction (validated upstream);
183
- // `configFile` collapses absent → empty in the key, distinct from any real path.
184
- function cacheKey(
185
- projectDir: string,
186
- cwd: string,
187
- configFile: string | undefined,
188
- ): string {
189
- return projectDir + "\0" + cwd + "\0" + (configFile ?? "");
190
- }
191
-
192
- export class RenderCache {
193
- private readonly entries = new Map<string, CacheEntry>();
194
- private readonly deps: RenderDeps;
195
- private readonly maxEntries: number;
196
- private readonly observers: RenderCacheObservers;
197
-
198
- constructor(
199
- deps: RenderDeps,
200
- opts: { maxEntries?: number; observers?: RenderCacheObservers } = {},
201
- ) {
202
- this.deps = deps;
203
- this.maxEntries = opts.maxEntries ?? MAX_ENTRIES;
204
- this.observers = opts.observers ?? {};
205
- }
206
-
207
- // [LAW:dataflow-not-control-flow] One uniform shape: every entry has the
208
- // same fields, populated to nulls when reload failed. The renderer reads
209
- // the data; no special-case branches between "first load", "reload",
210
- // "reload-after-error".
211
- getOrCreate(
212
- projectDir: string,
213
- cwd: string,
214
- configFile: string | undefined,
215
- ): CacheEntry {
216
- const key = cacheKey(projectDir, cwd, configFile);
217
- const existing = this.entries.get(key);
218
- if (existing) {
219
- // Move to end (most recently used) for LRU eviction.
220
- this.entries.delete(key);
221
- this.entries.set(key, existing);
222
- return existing;
223
- }
224
-
225
- const entry: CacheEntry = {
226
- projectDir,
227
- cwd,
228
- configFile,
229
- configFilePath: null,
230
- lastError: null,
231
- lastWarning: null,
232
- state: null,
233
- watcher: null,
234
- };
235
- // [LAW:single-enforcer] Insert, bound, then load — in that order. From the
236
- // moment the entry owns live handles it is reachable for eviction and
237
- // dispose, so a throwing observer cannot strand a registry outside the
238
- // map, and a reentrant getOrCreate for this key finds the entry under
239
- // construction rather than building a duplicate. The bound runs before
240
- // the load because it is a fact about the MAP, complete at insertion:
241
- // nothing the load does (including an observer throwing) can skip it.
242
- // [LAW:dataflow-not-control-flow]
243
- this.entries.set(key, entry);
244
- if (this.entries.size > this.maxEntries) {
245
- const oldest = this.entries.keys().next().value;
246
- if (oldest !== undefined) {
247
- const evicted = this.entries.get(oldest);
248
- // [LAW:single-enforcer] dispose the registry on eviction — it owns
249
- // timers, fs watchers, and git subscriptions. Dropping the entry
250
- // without dispose leaks every async handle the config declared. The
251
- // validator disposers free this entry's writable-key entries too.
252
- evicted?.state?.registry.dispose();
253
- evicted?.state?.validatorDisposers.forEach((dispose) => dispose());
254
- evicted?.watcher?.release();
255
- this.entries.delete(oldest);
256
- }
257
- }
258
- this.reloadInto(entry);
259
-
260
- return entry;
261
- }
262
-
263
- // Populate (or re-populate) `entry` from the current state of disk.
264
- //
265
- // - Parse success: dispose the prior state (if any), build fresh store +
266
- // registry + compiled, clear lastError.
267
- // - Parse failure: keep the prior state, set lastError. First-time
268
- // failures leave state null (startup-error case).
269
- //
270
- // [LAW:single-enforcer] hot-reload contract: any reload that produces a
271
- // new DslConfig disposes the old SourceRegistry before constructing a new
272
- // one. The registry owns timers, watchers, MobX reactions, and git
273
- // subscriptions — dropping it without dispose leaks every handle.
274
- //
275
- // [LAW:single-enforcer] One publish point for "this reload completed":
276
- // loadFromDisk owns the outcome (it returns through more than one arm),
277
- // and this wrapper is the only caller, so the signal structurally cannot
278
- // be skipped by whichever arm a reload takes — or by an arm added later.
279
- private reloadInto(entry: CacheEntry): void {
280
- this.loadFromDisk(entry);
281
- this.observers.onReload?.(entry);
282
- }
283
-
284
- private loadFromDisk(entry: CacheEntry): void {
285
- const resolvedPath = resolveDslConfigPath(
286
- entry.projectDir,
287
- entry.cwd,
288
- entry.configFile,
289
- );
290
-
291
- // [LAW:dataflow-not-control-flow] Collision detection runs every reload,
292
- // independent of load success — even if the .json5 fails to parse, the
293
- // user still wants to know they have a shadowed .json sibling. Pure
294
- // file-existence checks, so cheap. The watcher already monitors every
295
- // candidate path, so creating/removing a duplicate triggers reload and
296
- // re-detection automatically; nothing else needs to invalidate this.
297
- entry.lastWarning = detectConfigCollisions(entry.projectDir, entry.cwd);
298
-
299
- // [LAW:dataflow-not-control-flow] One uniform shape: build the new
300
- // state into locals first, dispose the old registry ONLY after every
301
- // construction step has succeeded. A failure at any step — parse,
302
- // registration, palette resolution — leaves `entry.state` and
303
- // `entry.state.registry` untouched, so the daemon keeps rendering the
304
- // last-known-good config plus a warning icon (composeWithDiagnostics
305
- // reads `entry.lastError` and `entry.lastWarning`). The
306
- // "[LAW:single-enforcer] dispose before swap" contract holds for the
307
- // swap; the construction is upstream of it.
308
- let newState: DslRenderState;
309
- try {
310
- newState = this.buildState(entry, resolvedPath);
311
- } catch (err) {
312
- entry.lastError =
313
- err instanceof ConfigError
314
- ? err.message
315
- : err instanceof Error
316
- ? err.message
317
- : String(err);
318
- // Watch the broken file (and its sibling candidates) so an in-place
319
- // save OR a higher-precedence file appearing recovers.
320
- this.refreshWatcher(entry, resolvedPath);
321
- return;
322
- }
323
-
324
- // [LAW:single-enforcer] Dispose-before-swap: the old registry owns timers,
325
- // fs watchers, MobX reactions, and git subscriptions; the old validator
326
- // disposers own this entry's writable-key entries in the global registry.
327
- // Both are disposed in one step before the swap — dropping either reference
328
- // without disposing would leak handles or shadow the new config's keys.
329
- entry.state?.registry.dispose();
330
- entry.state?.validatorDisposers.forEach((dispose) => dispose());
331
- entry.lastError = null;
332
- entry.state = newState;
333
- // [LAW:dataflow-not-control-flow] Partial-load warnings (variable
334
- // declaration failures that didn't abort the load) flow through the same
335
- // warning channel as collision warnings, already set above. Append rather
336
- // than replace so both can be visible at once.
337
- if (newState.compiled.loadWarnings.length > 0) {
338
- const vw = newState.compiled.loadWarnings.join("\n");
339
- entry.lastWarning = entry.lastWarning
340
- ? entry.lastWarning + "\n" + vw
341
- : vw;
342
- }
343
- this.refreshWatcher(entry, resolvedPath);
344
- }
345
-
346
- // [LAW:single-enforcer] Construct the full new state — parsed config,
347
- // store, registry, compiled segments, palette — as one transaction. Any
348
- // failure inside disposes the partially-built registry so we don't leak
349
- // timers/watchers from a half-constructed reload, then rethrows so the
350
- // caller (loadFromDisk) preserves the prior `entry.state` unchanged.
351
- private buildState(
352
- entry: CacheEntry,
353
- resolvedPath: string | null,
354
- ): DslRenderState {
355
- // [LAW:dataflow-not-control-flow][LAW:single-enforcer] Three primitives,
356
- // straight-line composition. `loadConfig(null)` returns the bundled
357
- // default (uniform merge against empty raw); `validateConfig` is the
358
- // sole producer of `ValidatedConfig`. The renderer accepts only
359
- // `ValidatedConfig`, so the compiler enforces the chain — there is no
360
- // "skip validate" path that typechecks downstream.
361
- // [LAW:one-source-of-truth] Thread the source through to validateConfig so
362
- // cross-ref diagnostics on the daemon path carry real line numbers and the
363
- // authored-surface (root vs layout) discriminator works — the file is read
364
- // once inside loadConfig, not re-read here.
365
- const { config: merged, source } = loadConfig(
366
- resolvedPath,
367
- DEFAULT_DSL_CONFIG,
368
- );
369
- // [LAW:one-source-of-truth] The persistent config-overrides layer
370
- // (candybar-config-engine-71o.2) is a SECOND application of the SAME
371
- // mergeWithDefault cascade the user file already went through — no new
372
- // merge semantics, just one more layer at bundled-default < user-file <
373
- // overrides precedence (a `persist` write changes the DEFAULT; a session
374
- // pick still overrides it per-session via effective* resolution,
375
- // unchanged). Always applied, even when the overrides file is empty —
376
- // an empty overrides object merges as a no-op, so there is no
377
- // "has overrides?" branch [LAW:dataflow-not-control-flow]. One
378
- // loadOverrides read serves BOTH halves below (globals + segment-palette)
379
- // — the overrides file backs two different merge shapes, not two reads.
380
- const overrides = loadOverrides(configOverridesPath(), dlog);
381
- // [LAW:no-silent-failure] `preset` is a per-config domain riding a
382
- // machine-global overrides file (src/config/presets.ts —
383
- // sanitizePersistedPresetOverride) — a persisted pick from another
384
- // project's config must not fail THIS config's load. Every other
385
- // globals field is registry-static (palette/style/charset/…), so this
386
- // is the one field that needs sanitizing before the merge below.
387
- const sanitizedGlobalsOverride = sanitizePersistedPresetOverride(
388
- overrides.globals,
389
- merged.presets,
390
- );
391
- const withGlobalsOverrides = mergeWithDefault(
392
- { globals: sanitizedGlobalsOverride },
393
- merged,
394
- );
395
- // [LAW:one-source-of-truth] The segment-scoped half of the SAME overrides
396
- // file (candybar-config-engine-71o.6) — a later, narrower merge step, not
397
- // a second override layer: mergeWithDefault's `segments` cascade replaces
398
- // a named segment WHOLESALE, so a one-field palette override rides its
399
- // own overlay (applySegmentPaletteOverrides) against the already-merged
400
- // config instead, patching `palette` without dropping the segment's other
401
- // fields. Order versus the globals merge above doesn't matter — the two
402
- // touch disjoint parts of the config (`globals` vs `segments[name]`).
403
- const withOverrides = applySegmentPaletteOverrides(
404
- withGlobalsOverrides,
405
- overrides.segmentPalette,
406
- );
407
- // [LAW:no-silent-failure] Drop any entry naming a preset THIS config
408
- // never declared before replay ever sees it — the shared overrides file
409
- // outlives any one project's preset names (brandon-layout-edit-2gc.5 PR
410
- // review: without this, a stale entry from a DIFFERENT project reaches
411
- // applyPresetRootOpsOverrides -> presetRoot -> presetByName, which
412
- // throws for an undeclared name, failing this unrelated project's
413
- // ENTIRE render). Sanitized against `merged.presets` — the SAME config
414
- // sanitizePersistedPresetOverride checks `globals.preset` against, two
415
- // lines up, for the identical reason.
416
- const sanitizedPresetRootOps = sanitizePersistedPresetRootOps(
417
- overrides.presetRootOps,
418
- merged.presets,
419
- );
420
- // [LAW:one-source-of-truth] brandon-layout-edit-2gc.1's replay step —
421
- // the SAME "patch an already-merged config" cascade as the segment-
422
- // palette overlay above, one field over (a preset's `root` instead of a
423
- // segment's `palette`). Runs last so validateConfig's cross-ref walk
424
- // proves the OPS-PATCHED tree, not the pre-edit one — a structural edit
425
- // that referenced a segment removed by a later config change is caught
426
- // exactly like a hand-authored preset root naming the same segment would
427
- // be.
428
- const withPresetRootOps = applyPresetRootOpsOverrides(
429
- withOverrides,
430
- sanitizedPresetRootOps,
431
- );
432
- const config = validateConfig(
433
- withPresetRootOps,
434
- resolvedPath ?? "<default>",
435
- source,
436
- );
437
-
438
- const store = new VariableStore();
439
- // [LAW:single-enforcer] Inject the daemon's shared GitDataProvider so
440
- // every config's `kind: "git"` declarations route through one cache +
441
- // watcher pool (rather than each registry standing up its own). The
442
- // sessionState injection makes `kind: "state"` variables read/write the
443
- // same per-session store the click verbs mutate. `default_empty_value`
444
- // is honored from globals — it's the fallback used by input/env/etc.
445
- // sources when neither the path resolves nor the declaration carries
446
- // its own `default`. The loader validates it as a string; the registry
447
- // default ("") matches the historical behavior when omitted.
448
- const registry = new SourceRegistry(
449
- store,
450
- config.globals.default_empty_value ?? "",
451
- this.deps.gitService,
452
- this.deps.sessionState,
453
- );
454
-
455
- let compiled: CompiledConfig;
456
- // [LAW:single-enforcer] Validators this config installs (one per menu page
457
- // key) are part of the same construction transaction as the registry: any
458
- // failure (registration, a duplicate-key throw) disposes every handle built
459
- // so far — registry AND already-installed validators — before rethrowing, so
460
- // loadFromDisk preserves the prior last-known-good with nothing half-installed.
461
- const validatorDisposers: Array<() => void> = [];
462
- try {
463
- // [LAW:one-source-of-truth] The action runtime reads session.id + current
464
- // picker values from registry.variableStore — the same store this entry's
465
- // registry declares into — so no store reference is threaded separately.
466
- compiled = registerDslConfig(config, registry, {
467
- cwd: entry.cwd,
468
- });
469
- // [LAW:one-source-of-truth] Derive the writable-key validators from the
470
- // config's action table (the sole interaction authority) through one
471
- // coherence merge (deriveActionValidators), then register them so the click
472
- // wire accepts the picker's ←/→/apply-close writes and every other action
473
- // write alike. Merging before registration lets a trigger's literal "0" be
474
- // absorbed into a picker's int page gate instead of colliding.
475
- // registerStateValidator throws on a duplicate baseline key — caught here to
476
- // roll the whole reload back.
477
- for (const { key, spec } of deriveActionValidators(config)) {
478
- validatorDisposers.push(registerStateValidator(key, spec));
479
- }
480
- // [LAW:one-source-of-truth] The `persist` action table's twin
481
- // derivation, registered through the SAME dispose-before-swap
482
- // transaction — a config's persistent-config-writable-key surface
483
- // lives and dies with this cache entry exactly like its SessionState
484
- // surface does.
485
- for (const { key, spec } of deriveConfigActionValidators(config)) {
486
- validatorDisposers.push(registerConfigValidator(key, spec));
487
- }
488
- } catch (err) {
489
- for (const dispose of validatorDisposers) dispose();
490
- registry.dispose();
491
- throw err;
492
- }
493
-
494
- // [LAW:one-source-of-truth] basePalette is NOT frozen here. One cache entry
495
- // serves many sessions, but the effective theme is per-session SessionState;
496
- // freezing the palette per entry would let the rendered colors diverge from
497
- // the session's chosen theme. The server resolves basePalette per render
498
- // from the effective theme (paletteForThemeName ∘ effectiveThemeName).
499
- return {
500
- config,
501
- store,
502
- registry,
503
- compiled,
504
- neededInputPaths: buildNeededPrefixes(config),
505
- lastRenderCellsBySegment: new Map<string, readonly RichText[]>(),
506
- validatorDisposers,
507
- presetRootOps: sanitizedPresetRootOps,
508
- };
509
- }
510
-
511
- // [LAW:single-enforcer] One watcher-rebind decision per reload. If the
512
- // resolved path changed (including null↔non-null transitions), or no
513
- // watcher is currently held, install a fresh watcher set keyed by the
514
- // current resolved path (or `<none>` when nothing exists). The "watch all
515
- // candidates when no file exists" behavior lives in rebindWatcher.
516
- private refreshWatcher(entry: CacheEntry, resolvedPath: string | null): void {
517
- if (resolvedPath !== entry.configFilePath || entry.watcher === null) {
518
- entry.configFilePath = resolvedPath;
519
- this.rebindWatcher(entry, resolvedPath);
520
- }
521
- }
522
-
523
- private rebindWatcher(entry: CacheEntry, targetPath: string | null): void {
524
- if (entry.watcher) {
525
- entry.watcher.release();
526
- entry.watcher = null;
527
- }
528
- // [LAW:dataflow-not-control-flow] Two outcomes from one rule:
529
- // resolved path exists → watch THAT file + its parent dir (catches
530
- // in-place writes and atomic-rename writes that replace the inode)
531
- // no resolved path → watch EVERY candidate's parent dir so the
532
- // creation of any file in the resolution chain triggers reload.
533
- // Either way the watcher set is built from a single list of (dir,
534
- // filename-filter) tuples; the only variability is whether the
535
- // currently-resolved file is also added to `files` for inode-level
536
- // watching.
537
- // [LAW:dataflow-not-control-flow] fs.watch on a non-existent directory
538
- // throws; on a fresh install $XDG_CONFIG_HOME/cc-candybar doesn't exist
539
- // yet. Filter candidates to those whose parent directory exists *at
540
- // this moment* — that's the bounded set of locations we can usefully
541
- // watch. (A user creating the XDG dir later would only get hot-reload
542
- // for the project-local / cwd locations until the daemon next builds
543
- // an entry; this is a documented limitation, not a contract violation.)
544
- // [LAW:single-enforcer] Same enumerator the resolver uses, so the watcher
545
- // covers the exact same set of paths the next reload would consult — a
546
- // `--config` override collapses to one candidate; absent, the precedence
547
- // chain unfolds in full.
548
- //
549
- // [LAW:dataflow-not-control-flow] configOverridesPath() rides the SAME
550
- // candidate list, not a second watch branch: "reload rides the existing
551
- // watcher" (candybar-config-engine-71o.2) means a persistent config write
552
- // is just one more file in the resolution chain the loop below already
553
- // handles uniformly (existence-gated, watched via its parent dir so the
554
- // file's first-ever creation also triggers reload).
555
- const candidates = [
556
- ...dslConfigCandidatePaths(entry.projectDir, entry.cwd, entry.configFile),
557
- configOverridesPath(),
558
- ];
559
- const dirSet = new Map<string, Set<string>>();
560
- for (const candidate of candidates) {
561
- const dir = path.dirname(candidate);
562
- if (!fs.existsSync(dir)) continue;
563
- const base = path.basename(candidate);
564
- if (!dirSet.has(dir)) dirSet.set(dir, new Set());
565
- dirSet.get(dir)!.add(base);
566
- }
567
- const dirs = [...dirSet.entries()].map(([dirPath, names]) => ({
568
- path: dirPath,
569
- filenames: [...names],
570
- }));
571
-
572
- // [LAW:single-enforcer] Watcher keys are per-cache-entry, not per-file.
573
- // WatcherRegistry.acquire() is share-by-key — multiple entries that
574
- // resolve to the same config file would otherwise share one watcher
575
- // slot whose `onInvalidate` is overwritten by the last acquire, and
576
- // earlier entries would never reload on file changes. Including
577
- // (projectDir, cwd, configFile) in every key guarantees each entry owns
578
- // its own watcher slot bound to its own reload callback.
579
- const key = `config:${entry.projectDir}:${entry.cwd}:${entry.configFile ?? ""}:${targetPath ?? "<none>"}`;
580
-
581
- entry.watcher = this.deps.watchers.acquire(
582
- key,
583
- {
584
- files: targetPath !== null ? [targetPath] : [],
585
- dirs,
586
- },
587
- () => this.onConfigChanged(entry),
588
- );
589
- }
590
-
591
- // [LAW:single-enforcer] One reload dispatcher per cache entry. The
592
- // watcher fires on any change in any candidate dir matching the
593
- // CONFIG_FILENAME; the entry re-resolves its own resolution chain (so a
594
- // higher-precedence file appearing supersedes a lower one) and reloads.
595
- // We don't filter by which specific path changed because the
596
- // (projectDir, cwd) tuple already scopes the entry's watcher set —
597
- // sibling entries with different scopes don't share this watcher.
598
- private onConfigChanged(entry: CacheEntry): void {
599
- dlog(
600
- "info",
601
- `config change detected for entry projectDir=${entry.projectDir} cwd=${entry.cwd}`,
602
- );
603
- this.reloadInto(entry);
604
- }
605
-
606
- get size(): number {
607
- return this.entries.size;
608
- }
609
-
610
- // [LAW:single-enforcer] One read path for "any populated state" used by
611
- // the debug protocol's introspection (`debug vars` / `segments` / `config`).
612
- // Iterates existing entries — does NOT call getOrCreate, so debug
613
- // introspection never has the side effect of creating a fresh cache entry
614
- // (with its own SourceRegistry timers/watchers) tied to the daemon's own
615
- // `process.cwd()`. Returns null when the cache has no successfully-loaded
616
- // entry; debug responses are empty in that case by construction.
617
- firstPopulatedState(): DslRenderState | null {
618
- for (const entry of this.entries.values()) {
619
- if (entry.state !== null) return entry.state;
620
- }
621
- return null;
622
- }
623
- }