@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.
- package/dist/index.mjs +72 -71
- package/package.json +5 -6
- package/src/check.ts +0 -478
- package/src/cli-flags.ts +0 -8
- package/src/click/wire.ts +0 -158
- package/src/config/action.ts +0 -329
- package/src/config/cli.ts +0 -71
- package/src/config/default-dsl-config.ts +0 -1645
- package/src/config/disclosure.ts +0 -170
- package/src/config/dsl-loader.ts +0 -339
- package/src/config/dsl-types.ts +0 -581
- package/src/config/edit-chrome.ts +0 -559
- package/src/config/help.ts +0 -151
- package/src/config/ident.ts +0 -22
- package/src/config/layout-ops.ts +0 -177
- package/src/config/loader/actions.ts +0 -972
- package/src/config/loader/cache.ts +0 -206
- package/src/config/loader/cross-ref.ts +0 -714
- package/src/config/loader/cycles.ts +0 -148
- package/src/config/loader/diagnostics.ts +0 -99
- package/src/config/loader/discovery.ts +0 -182
- package/src/config/loader/edit-mode.ts +0 -137
- package/src/config/loader/emit-schema.ts +0 -68
- package/src/config/loader/globals.ts +0 -269
- package/src/config/loader/helpers.ts +0 -48
- package/src/config/loader/layout.ts +0 -693
- package/src/config/loader/looks.ts +0 -96
- package/src/config/loader/menu-synth.ts +0 -435
- package/src/config/loader/merge.ts +0 -115
- package/src/config/loader/persist-target.ts +0 -67
- package/src/config/loader/presets.ts +0 -119
- package/src/config/loader/refs.ts +0 -100
- package/src/config/loader/reserved-namespace.ts +0 -38
- package/src/config/loader/segments.ts +0 -120
- package/src/config/loader/validate-core.ts +0 -737
- package/src/config/loader/variables.ts +0 -260
- package/src/config/menu-keys.ts +0 -139
- package/src/config/option-domain.ts +0 -164
- package/src/config/presets.ts +0 -326
- package/src/config/settings-menu.ts +0 -775
- package/src/daemon/acquire.ts +0 -684
- package/src/daemon/cache/git.ts +0 -649
- package/src/daemon/cache/render.ts +0 -623
- package/src/daemon/cache/session-usage-store.ts +0 -720
- package/src/daemon/cache/watchers.ts +0 -249
- package/src/daemon/client-debug.ts +0 -120
- package/src/daemon/client-stats.ts +0 -130
- package/src/daemon/client-transport.ts +0 -273
- package/src/daemon/client.ts +0 -78
- package/src/daemon/config-overrides-store.ts +0 -663
- package/src/daemon/debug-types.ts +0 -91
- package/src/daemon/debug.ts +0 -264
- package/src/daemon/fork-bomb-breaker.ts +0 -351
- package/src/daemon/limits.ts +0 -211
- package/src/daemon/log.ts +0 -81
- package/src/daemon/parent-watchdog.ts +0 -87
- package/src/daemon/paths.ts +0 -211
- package/src/daemon/process-fingerprint.ts +0 -146
- package/src/daemon/protocol.ts +0 -292
- package/src/daemon/render-payload.ts +0 -1256
- package/src/daemon/server.ts +0 -1330
- package/src/daemon/session-state-file.ts +0 -108
- package/src/daemon/session-state.ts +0 -237
- package/src/daemon/socket-lease.ts +0 -209
- package/src/daemon/socket-ownership.ts +0 -209
- package/src/daemon/stats.ts +0 -235
- package/src/daemon/verbs/config-validators.ts +0 -250
- package/src/daemon/verbs/index.ts +0 -706
- package/src/daemon/verbs/state-validators.ts +0 -249
- package/src/daemon/verbs/validator-registry.ts +0 -457
- package/src/demo/dsl.ts +0 -143
- package/src/demo/mock-data.ts +0 -67
- package/src/demo/statusline.json5 +0 -94
- package/src/dsl/node-registry.ts +0 -374
- package/src/dsl/render.ts +0 -803
- package/src/help-text.ts +0 -90
- package/src/index.ts +0 -210
- package/src/install/currency.ts +0 -197
- package/src/install/index.ts +0 -557
- package/src/proc/launch.ts +0 -459
- package/src/proc/stats-handle.ts +0 -13
- package/src/render/action.ts +0 -883
- package/src/render/active-segment.ts +0 -78
- package/src/render/diagnostic-style.ts +0 -23
- package/src/render/diagnostic-text.ts +0 -77
- package/src/render/error-glyph.ts +0 -53
- package/src/render/menu.ts +0 -257
- package/src/render/outcome-plan.ts +0 -45
- package/src/render/picker.ts +0 -372
- package/src/render/segment-color.ts +0 -74
- package/src/render/split-lines.ts +0 -51
- package/src/render/strip.ts +0 -228
- package/src/segments/cache.ts +0 -131
- package/src/segments/context.ts +0 -190
- package/src/segments/git.ts +0 -1084
- package/src/segments/metrics.ts +0 -187
- package/src/segments/pricing.ts +0 -452
- package/src/segments/session.ts +0 -23
- package/src/segments/tmux.ts +0 -74
- package/src/template-engine/cells.ts +0 -90
- package/src/template-engine/colors.ts +0 -124
- package/src/template-engine/engine.ts +0 -108
- package/src/template-engine/funcs.ts +0 -232
- package/src/template-engine/index.ts +0 -11
- package/src/template-engine/layout.ts +0 -133
- package/src/template-engine/scope.ts +0 -62
- package/src/template-engine/sparkline.ts +0 -79
- package/src/themes/index.ts +0 -20
- package/src/themes/palette-resolvers.ts +0 -84
- package/src/themes/policy.ts +0 -393
- package/src/utils/cache.ts +0 -206
- package/src/utils/claude.ts +0 -683
- package/src/utils/color-support.ts +0 -118
- package/src/utils/formatters.ts +0 -99
- package/src/utils/logger.ts +0 -5
- package/src/utils/outcome.ts +0 -33
- package/src/utils/schema-validator.ts +0 -126
- package/src/utils/single-flight.ts +0 -57
- package/src/utils/terminal-width.ts +0 -51
- package/src/utils/terminal.ts +0 -11
- package/src/utils/transcript-fs.ts +0 -279
- package/src/var-system/index.ts +0 -24
- package/src/var-system/sources.ts +0 -1047
- package/src/var-system/store.ts +0 -223
- package/src/var-system/types.ts +0 -57
- 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
|
-
}
|