@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,663 +0,0 @@
1
- // [LAW:one-source-of-truth] The daemon is the SOLE writer of
2
- // configOverridesPath() — the hand-authored user config file is never
3
- // machine-edited (candybar-config-engine-71o's binding guardrail). A
4
- // persistent config write (the `persist` action, distinct from `set`'s
5
- // per-session write) lands here; RenderCache merges it on top of the user
6
- // file every reload (src/daemon/cache/render.ts), so this module owns only
7
- // the read/write/shape of the override layer, never the merge.
8
- //
9
- // [LAW:single-enforcer] Read-modify-write + atomic rename, synchronous. Writes
10
- // here are click-rate (rare), not render-rate (SessionState's every-render
11
- // atom) — there is no debounce to coalesce and no in-memory cache to keep
12
- // warm; RenderCache re-reads this file fresh on every reload, exactly as it
13
- // re-reads the user config file. One source, read where it's needed.
14
- //
15
- // [LAW:one-source-of-truth] The file is ONE flat dict keyed by whatever
16
- // string a `persist`/`reset` action names (candybar-config-engine-71o.6
17
- // generalized this from Globals-only fields to also admit
18
- // `segments.<name>.palette` keys — see loader/persist-target.ts, the shared
19
- // parser both this module and cross-ref.ts classify a key through). Keeping
20
- // ONE flat dict (rather than a nested `{globals, segments}` shape) means the
21
- // read-modify-write/atomic-rename plumbing below never needed to change
22
- // shape — only what keys/values count as valid grew.
23
-
24
- import fs from "node:fs";
25
- import path from "node:path";
26
- import type { Globals } from "../config/dsl-types.js";
27
- import { isGlobalsField } from "../config/loader/globals.js";
28
- import { parsePersistTarget } from "../config/loader/persist-target.js";
29
- import { debug } from "../utils/logger.js";
30
- import type { DaemonLogger } from "./log.js";
31
-
32
- const quietLogger: DaemonLogger = (_level, message) => debug(message);
33
-
34
- // [LAW:one-source-of-truth] Re-exported so every existing importer
35
- // (verbs/index.ts, verbs/config-validators.ts) keeps reading membership
36
- // through this module — but the membership check itself now has exactly ONE
37
- // implementation (loader/globals.ts's isGlobalsField, derived from
38
- // GLOBALS_SCHEMA), not two independently-authored tables that TypeScript's
39
- // per-table exhaustiveness only coincidentally kept in agreement.
40
- export { isGlobalsField } from "../config/loader/globals.js";
41
-
42
- // [LAW:types-are-the-program] Every Globals field's primitive WIRE TYPE,
43
- // keyed by `keyof Globals` — TypeScript forces this map to stay total over
44
- // Globals, so a field added to/removed from that interface is a compile
45
- // error here until this table is updated. This is the ONE place a `persist`
46
- // write's canonical string is coerced to the JS type Globals actually
47
- // declares (padding: number, autoWrap: boolean, everything else: string). A
48
- // segment-palette target has no matching row: it's always a NAME, so its
49
- // kind is "string" unconditionally — see coercePersistValue below. Membership
50
- // (which keys exist) is NOT re-declared here — see the re-exported
51
- // isGlobalsField above; this table only adds the per-field KIND membership
52
- // alone doesn't carry.
53
- const GLOBALS_FIELD_KIND: Readonly<
54
- Record<keyof Globals, "string" | "number" | "boolean">
55
- > = {
56
- default_bg: "string",
57
- default_fg: "string",
58
- default_empty_value: "string",
59
- default_separator: "string",
60
- default_truncate_marker: "string",
61
- palette: "string",
62
- look: "string",
63
- // The active arrangement — a NAME like palette/look, so `persist: "preset"`
64
- // makes a chosen preset the default every future session opens in.
65
- preset: "string",
66
- style: "string",
67
- autoWrap: "boolean",
68
- padding: "number",
69
- charset: "string",
70
- colorCompatibility: "string",
71
- };
72
-
73
- // [LAW:one-source-of-truth] The same four canonical boolean-ish inputs
74
- // validateBoolean (state-validators.ts) accepts — a `persist` action's gate
75
- // is an ALLOW-LIST (the declared `to`/`cycle` members pass through
76
- // membership-checked but otherwise VERBATIM, unlike validateBoolean's own
77
- // bespoke normalization), so a config author writing `cycle: ["true",
78
- // "false"]` or `to: "0"` reaches this boundary with the raw member string,
79
- // not a pre-canonicalized "1"/"". This is the ONE place that must accept the
80
- // full accepted-input set, not just the canonical pair.
81
- const BOOLEAN_TRUTHY = new Set(["1", "true"]);
82
- const BOOLEAN_FALSY = new Set(["0", "false", ""]);
83
-
84
- // [LAW:no-silent-fallbacks] The `persist` write's validator canonicalizes to
85
- // a STRING (the same wire currency `set` uses) — this is the boundary that
86
- // lifts it into the typed value its scope declares. An out-of-range/non-
87
- // numeric string for a "number" Globals field is a caller bug (the range
88
- // validator already canonicalized it), so it throws loudly rather than
89
- // writing a silently-wrong type into the overrides file. Replaces the old
90
- // Globals-only `coerceGlobalsValue`: a bare `string` key (not `keyof
91
- // Globals`) so a caller no longer needs a type-narrowing assertion before
92
- // calling this — parsePersistTarget does the classification internally.
93
- export function coercePersistValue(
94
- key: string,
95
- raw: string,
96
- ): string | number | boolean {
97
- const target = parsePersistTarget(key);
98
- if (target === null) {
99
- throw new Error(
100
- `coercePersistValue: "${key}" is not a valid persist target`,
101
- );
102
- }
103
- // [LAW:one-type-per-behavior] Both non-globals scopes are always a NAME/
104
- // TOKEN string — segment-palette's value is a palette name, preset-root-ops'
105
- // is one op token appended by the daemon's apply-layout-op verb handler
106
- // (never a bare `persist` write — see verbs/index.ts). Neither has a
107
- // GLOBALS_FIELD_KIND row because neither is a Globals field.
108
- if (
109
- target.scope === "segment-palette" ||
110
- target.scope === "preset-root-ops"
111
- ) {
112
- return raw;
113
- }
114
- const kind = GLOBALS_FIELD_KIND[target.field];
115
- if (kind === "string") return raw;
116
- if (kind === "number") {
117
- const n = Number(raw);
118
- if (!Number.isFinite(n)) {
119
- throw new Error(
120
- `coercePersistValue: "${key}" expects a number, got "${raw}"`,
121
- );
122
- }
123
- return n;
124
- }
125
- if (BOOLEAN_TRUTHY.has(raw)) return true;
126
- if (BOOLEAN_FALSY.has(raw)) return false;
127
- throw new Error(
128
- `coercePersistValue: "${key}" expects boolean-ish (1, 0, true, false), got "${raw}"`,
129
- );
130
- }
131
-
132
- // [LAW:no-silent-failure] Missing/corrupt/wrong-shape file → the empty
133
- // override set is the *defined* recovery (identical to a first-ever boot),
134
- // not a hidden fallback to different data. Every present key is classified
135
- // through parsePersistTarget (globals field or segment-palette) and its
136
- // value checked against that target's kind; a single malformed entry drops
137
- // the WHOLE file back to empty (mirrors FileSessionStorage's all-or-nothing
138
- // shape check) rather than guessing which entries to keep.
139
- function isValidOverrides(
140
- value: unknown,
141
- ): value is Readonly<Record<string, string | number | boolean>> {
142
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
143
- return false;
144
- }
145
- for (const [key, v] of Object.entries(value)) {
146
- const target = parsePersistTarget(key);
147
- if (target === null) return false;
148
- const kind =
149
- target.scope === "globals" ? GLOBALS_FIELD_KIND[target.field] : "string";
150
- if (kind === "number" && typeof v !== "number") return false;
151
- if (kind === "boolean" && typeof v !== "boolean") return false;
152
- if (kind === "string" && typeof v !== "string") return false;
153
- }
154
- return true;
155
- }
156
-
157
- // [LAW:single-enforcer] The ONE reader of the on-disk shape — every scoped
158
- // view (loadConfigOverrides, loadSegmentPaletteOverrides) and every writer
159
- // (writeConfigOverride, clearConfigOverride) reads through this, so the
160
- // flat-dict shape and its recovery-to-empty behavior are decided exactly once.
161
- function loadRawOverrides(
162
- filePath: string,
163
- logger: DaemonLogger,
164
- ): Readonly<Record<string, string | number | boolean>> {
165
- let raw: string;
166
- try {
167
- raw = fs.readFileSync(filePath, "utf8");
168
- } catch (e) {
169
- const code = (e as NodeJS.ErrnoException).code;
170
- if (code !== "ENOENT") {
171
- logger("warn", `config-overrides read failed (${code}); starting empty`);
172
- }
173
- return {};
174
- }
175
- try {
176
- const parsed: unknown = JSON.parse(raw);
177
- if (isValidOverrides(parsed)) return parsed;
178
- logger("warn", `config-overrides load: unexpected shape, starting empty`);
179
- return {};
180
- } catch {
181
- logger("warn", `config-overrides load: corrupt JSON, starting empty`);
182
- return {};
183
- }
184
- }
185
-
186
- // [LAW:one-source-of-truth] The Globals-scoped VIEW of a raw dict — kept as
187
- // `Partial<Globals>` so every existing caller (RenderCache's
188
- // mergeWithDefault({globals: ...}), stepConfig's range-seed lookup) keeps its
189
- // original, precisely-typed contract unchanged. Segment-palette entries in
190
- // the same file are invisible here by construction (isGlobalsField filters
191
- // them out) — see projectSegmentPaletteOverrides for that half. A pure
192
- // projection over an already-read dict (not a filePath) so a caller wanting
193
- // BOTH views (loadOverrides below) pays for exactly one read.
194
- //
195
- // [LAW:no-defensive-null-guards] exception: `Object.create(null)` — the key
196
- // being assigned comes from the on-disk overrides file, which a `persist`
197
- // write only ever populates from a real Globals field name (isGlobalsField
198
- // already excludes "__proto__"), but the accumulator itself gets the same
199
- // null-prototype hygiene src/dsl/render.ts's segment-keyed accumulator uses
200
- // ("segment names come from user config; a null-prototype object prevents
201
- // __proto__/constructor/prototype from being treated as segment data") —
202
- // one guard at the object, not a per-caller property-name check.
203
- function projectGlobalsOverrides(
204
- raw: Readonly<Record<string, string | number | boolean>>,
205
- ): Partial<Globals> {
206
- const out: Record<string, string | number | boolean> = Object.create(
207
- null,
208
- ) as Record<string, string | number | boolean>;
209
- for (const [key, value] of Object.entries(raw)) {
210
- if (isGlobalsField(key)) out[key] = value;
211
- }
212
- // [LAW:no-silent-fallbacks] exception: isValidOverrides already proved every
213
- // entry's runtime kind matches its target's declared kind (GLOBALS_FIELD_KIND)
214
- // before it ever reached the file — this cast states that proof, it doesn't
215
- // paper over an unchecked one.
216
- return out as Partial<Globals>;
217
- }
218
-
219
- // [LAW:one-source-of-truth] The segment-palette-scoped VIEW of the SAME raw
220
- // dict — segment name -> persisted palette name. RenderCache overlays this
221
- // onto the already-merged config's `segments[name].palette` field
222
- // (applySegmentPaletteOverrides in config/loader/merge.ts), never through
223
- // mergeWithDefault's wholesale per-name segment replacement.
224
- //
225
- // [LAW:no-defensive-null-guards] exception: `Object.create(null)` — unlike
226
- // projectGlobalsOverrides, the assigned key here (`target.segment`) is NOT
227
- // membership-checked against any closed set before the write (any string a
228
- // config declares as a segment name is legal), so a segment genuinely named
229
- // `__proto__` would otherwise hit the prototype setter on `out[key] =` —
230
- // the exact crash class the render.ts precedent (see above) already guards
231
- // against for segment-keyed objects.
232
- function projectSegmentPaletteOverrides(
233
- raw: Readonly<Record<string, string | number | boolean>>,
234
- ): Readonly<Record<string, string>> {
235
- const out: Record<string, string> = Object.create(null) as Record<
236
- string,
237
- string
238
- >;
239
- for (const [key, value] of Object.entries(raw)) {
240
- const target = parsePersistTarget(key);
241
- if (target?.scope === "segment-palette" && typeof value === "string") {
242
- out[target.segment] = value;
243
- }
244
- }
245
- return out;
246
- }
247
-
248
- // [LAW:one-source-of-truth] The preset-root-ops-scoped VIEW of the SAME raw
249
- // dict — preset name -> the accumulated op-token LIST (brandon-layout-edit-
250
- // 2gc.1's structural-edit log; see src/config/layout-ops.ts). This is a
251
- // SHAPE check only (well-formed JSON array of strings) — decoding each
252
- // token into a typed LayoutOp, and applying the ops to a tree, is presets.ts's
253
- // job, not this storage-layer module's [LAW:decomposition]. A stored value
254
- // that isn't a JSON array of strings drops for THAT preset only (a warn log,
255
- // never a crash of the whole overrides file) — the identical "the world
256
- // moved on since this was written" recovery projectSegmentPaletteOverrides
257
- // already gets, one level narrower.
258
- function projectPresetRootOpsOverrides(
259
- raw: Readonly<Record<string, string | number | boolean>>,
260
- logger: DaemonLogger,
261
- ): Readonly<Record<string, readonly string[]>> {
262
- const out: Record<string, readonly string[]> = Object.create(null) as Record<
263
- string,
264
- readonly string[]
265
- >;
266
- for (const [key, value] of Object.entries(raw)) {
267
- const target = parsePersistTarget(key);
268
- if (target?.scope !== "preset-root-ops" || typeof value !== "string") {
269
- continue;
270
- }
271
- try {
272
- const parsed: unknown = JSON.parse(value);
273
- if (Array.isArray(parsed) && parsed.every((t) => typeof t === "string")) {
274
- out[target.preset] = parsed;
275
- continue;
276
- }
277
- } catch {
278
- // fall through to the warn below
279
- }
280
- logger(
281
- "warn",
282
- `config-overrides: "${key}" is not a valid op-token list, dropping`,
283
- );
284
- }
285
- return out;
286
- }
287
-
288
- export function loadConfigOverrides(
289
- filePath: string,
290
- logger: DaemonLogger = quietLogger,
291
- ): Partial<Globals> {
292
- return projectGlobalsOverrides(loadRawOverrides(filePath, logger));
293
- }
294
-
295
- export function loadSegmentPaletteOverrides(
296
- filePath: string,
297
- logger: DaemonLogger = quietLogger,
298
- ): Readonly<Record<string, string>> {
299
- return projectSegmentPaletteOverrides(loadRawOverrides(filePath, logger));
300
- }
301
-
302
- // [LAW:carrying-cost] RenderCache wants ALL THREE views on every reload
303
- // (buildState merges globals overrides, overlays segment-palette overrides,
304
- // then replays preset-root-ops overrides) — calling the scoped loaders back
305
- // to back would read, parse, and shape-validate the same tiny file three
306
- // times per reload for no reason. One read, three projections.
307
- export interface Overrides {
308
- readonly globals: Partial<Globals>;
309
- readonly segmentPalette: Readonly<Record<string, string>>;
310
- readonly presetRootOps: Readonly<Record<string, readonly string[]>>;
311
- }
312
-
313
- export function loadOverrides(
314
- filePath: string,
315
- logger: DaemonLogger = quietLogger,
316
- ): Overrides {
317
- const raw = loadRawOverrides(filePath, logger);
318
- return {
319
- globals: projectGlobalsOverrides(raw),
320
- segmentPalette: projectSegmentPaletteOverrides(raw),
321
- presetRootOps: projectPresetRootOpsOverrides(raw, logger),
322
- };
323
- }
324
-
325
- // [LAW:no-silent-failure] The atomic write/rename dance, generalized over ANY
326
- // JSON-serializable value — both this module's flat overrides dict and its
327
- // history stack (below) go through this one primitive rather than each
328
- // re-implementing mkdir+tmp+chmod+rename. Owner-only mode, matching every
329
- // other daemon runtime file (session-state.json, pid, lease). `label` names
330
- // the failure in the log/thrown message (the caller's own vocabulary —
331
- // "config-overrides"/"config-overrides-history" — not derived from the path,
332
- // so the wording a test might match on stays stable across either file).
333
- // Unlike session-state.json's debounced best-effort flush (no synchronous
334
- // caller waiting on it), a `persist`/`undo`/`redo` write is directly caused
335
- // by a click that expects a truthful ack — a swallowed failure here would let
336
- // the verb handler log success for a write that didn't land. Logs at "error"
337
- // for the daemon-log breadcrumb, then RETHROWS so the caller (the click)
338
- // fails loudly instead of claiming a success that didn't happen.
339
- function writeJsonAtomic(
340
- filePath: string,
341
- label: string,
342
- value: unknown,
343
- logger: DaemonLogger,
344
- ): void {
345
- try {
346
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
347
- const tmp = `${filePath}.tmp`;
348
- fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 });
349
- fs.chmodSync(tmp, 0o600);
350
- fs.renameSync(tmp, filePath);
351
- } catch (e) {
352
- const message = `${label} write failed: ${(e as Error).message}`;
353
- logger("error", message);
354
- throw new Error(message);
355
- }
356
- }
357
-
358
- function writeOverrides(
359
- filePath: string,
360
- overrides: Readonly<Record<string, string | number | boolean>>,
361
- logger: DaemonLogger,
362
- ): void {
363
- writeJsonAtomic(filePath, "config-overrides", overrides, logger);
364
- }
365
-
366
- // [LAW:one-source-of-truth] The one place a key's value in the flat dict
367
- // changes (set-or-delete) — writeConfigOverride/clearConfigOverride/
368
- // restoreConfigOverrideValue all fold through here, so "what was the value
369
- // BEFORE this write" (the fact history needs) is captured at the one site
370
- // that reads-then-writes it, never re-derived. `value: undefined` deletes;
371
- // any other value sets. Returns the previous value (or undefined if the key
372
- // was absent) — the caller decides whether that fact matters.
373
- function mutateOverride(
374
- filePath: string,
375
- key: string,
376
- value: string | number | boolean | undefined,
377
- logger: DaemonLogger,
378
- ): string | number | boolean | undefined {
379
- const overrides = loadRawOverrides(filePath, logger);
380
- const prev = overrides[key];
381
- if (value === undefined) {
382
- if (!(key in overrides)) return prev;
383
- const next = { ...overrides };
384
- delete next[key];
385
- writeOverrides(filePath, next, logger);
386
- } else {
387
- writeOverrides(filePath, { ...overrides, [key]: value }, logger);
388
- }
389
- return prev;
390
- }
391
-
392
- // [LAW:one-source-of-truth] `persist`'s write, TRACKED: mutate the key, then
393
- // record the transition on the SAME global history undo/redo step
394
- // (brandon-layout-edit-2gc.2). This is the ONE enforcement point — every
395
- // current and future caller of writeConfigOverride (setConfig, stepConfig,
396
- // apply-layout-op's append) gets history for free, with zero edits to those
397
- // verb handlers, because the recording lives here rather than at each call
398
- // site. [LAW:locality-or-seam]
399
- export function writeConfigOverride(
400
- filePath: string,
401
- key: string,
402
- value: string | number | boolean,
403
- logger: DaemonLogger = quietLogger,
404
- ): void {
405
- const prev = mutateOverride(filePath, key, value, logger);
406
- pushHistoryEntry(filePath, { key, from: prev ?? null, to: value }, logger);
407
- }
408
-
409
- // [LAW:one-source-of-truth] `reset`'s write, TRACKED — mirrors
410
- // writeConfigOverride above. A clear that touches nothing (the key was
411
- // already absent) records no entry: nothing changed, so there is nothing to
412
- // undo back to.
413
- export function clearConfigOverride(
414
- filePath: string,
415
- key: string,
416
- logger: DaemonLogger = quietLogger,
417
- ): void {
418
- const overrides = loadRawOverrides(filePath, logger);
419
- if (!(key in overrides)) return;
420
- const prev = mutateOverride(filePath, key, undefined, logger);
421
- pushHistoryEntry(filePath, { key, from: prev ?? null, to: null }, logger);
422
- }
423
-
424
- // [LAW:one-source-of-truth] The UNTRACKED twin — restores a key to EXACTLY
425
- // `value` (or clears it, for `null`) without recording a new history entry.
426
- // The only legitimate callers are popPastEntry/popFutureEntry below: undo and
427
- // redo already know they're moving an entry between the past/future stacks,
428
- // so routing their own restoration back through the tracked writers would
429
- // record the undo/redo AS a new forward edit — burying the entry it just
430
- // popped and making the OTHER stack unreachable. This is a structurally
431
- // distinct function, not a boolean flag on the tracked ones
432
- // [LAW:no-mode-explosion] — its contract ("apply this exact value, no
433
- // bookkeeping") is different from theirs ("write this value, remember how to
434
- // undo it"), not a variant of the same one.
435
- function restoreConfigOverrideValue(
436
- filePath: string,
437
- key: string,
438
- value: string | number | boolean | null,
439
- logger: DaemonLogger,
440
- ): void {
441
- mutateOverride(filePath, key, value === null ? undefined : value, logger);
442
- }
443
-
444
- // ─── Undo/redo history (brandon-layout-edit-2gc.2) ────────────────────────
445
-
446
- // [LAW:types-are-the-program] ONE entry shape covers every scope the
447
- // overrides file holds — a globals field's snapshot overwrite (setConfig), a
448
- // segment-palette snapshot overwrite (same verb, different key shape), AND a
449
- // preset-root-ops APPEND (apply-layout-op's read-current-append-write) —
450
- // because at the STORAGE layer every one of those is indistinguishable from
451
- // "the value at `key` changed from `from` to `to`". apply-layout-op computes
452
- // its new array-of-tokens string by reading-then-appending one level up
453
- // (verbs/index.ts); by the time that string reaches writeConfigOverride, it
454
- // is just the next value at that key. Undo restoring `from` verbatim is
455
- // therefore ALSO the correct "pop the last op token" behavior for a rootOps
456
- // key, with no rootOps-specific code anywhere in this module — the ticket's
457
- // "one history over the overrides layer, not a layout-specific feature" falls
458
- // out of the shape, it isn't special-cased into it. `null` is the ABSENT
459
- // sentinel (a key with no prior/no resulting value): safe because no real
460
- // override value is ever `null` — see isValidOverrides's kind table.
461
- export interface HistoryEntry {
462
- readonly key: string;
463
- readonly from: string | number | boolean | null;
464
- readonly to: string | number | boolean | null;
465
- }
466
-
467
- interface HistoryState {
468
- readonly past: readonly HistoryEntry[];
469
- readonly future: readonly HistoryEntry[];
470
- }
471
-
472
- const EMPTY_HISTORY: HistoryState = { past: [], future: [] };
473
-
474
- // [LAW:carrying-cost] Resolves the ticket's "depth of the ring" question:
475
- // bounded so a long-running daemon's history file cannot grow without limit,
476
- // generous enough that no realistic editing session bumps into it. Oldest
477
- // entries fall off first (capPush below) — a silent, documented trim, not a
478
- // failure.
479
- const MAX_HISTORY_DEPTH = 50;
480
-
481
- // [LAW:one-source-of-truth] Resolves the ticket's "where it lives relative to
482
- // the overrides file" question: a SIBLING file in the same directory, derived
483
- // as a pure function of the overrides path already passed in — no reach to
484
- // paths.ts/global state, so every existing call site (and every existing
485
- // test's XDG_STATE_HOME isolation, which already isolates configOverridesPath())
486
- // isolates this file too, with zero additional test-harness surface. Kept
487
- // SEPARATE from the overrides file itself (rather than nesting it inside a
488
- // wrapper shape) so the overrides file's own on-disk shape — asserted by
489
- // name in existing tests and callers — never changes
490
- // [LAW:locality-or-seam]: a change to history storage must not ripple into
491
- // every existing reader of the flat overrides dict.
492
- function historyPathFor(overridesFilePath: string): string {
493
- return path.join(
494
- path.dirname(overridesFilePath),
495
- "config-overrides-history.json",
496
- );
497
- }
498
-
499
- function isValidHistoryValue(
500
- v: unknown,
501
- ): v is string | number | boolean | null {
502
- return (
503
- v === null ||
504
- typeof v === "string" ||
505
- typeof v === "number" ||
506
- typeof v === "boolean"
507
- );
508
- }
509
-
510
- function isValidHistoryEntry(v: unknown): v is HistoryEntry {
511
- if (v === null || typeof v !== "object") return false;
512
- const obj = v as Record<string, unknown>;
513
- return (
514
- typeof obj.key === "string" &&
515
- isValidHistoryValue(obj.from) &&
516
- isValidHistoryValue(obj.to)
517
- );
518
- }
519
-
520
- // [LAW:no-silent-failure] Missing/corrupt/wrong-shape file → the empty
521
- // history is the DEFINED recovery (mirrors isValidOverrides/loadRawOverrides'
522
- // identical "first-ever boot" treatment for the sibling file) — a single
523
- // malformed entry drops the WHOLE history, never a guess at which entries to
524
- // salvage.
525
- function isValidHistoryState(v: unknown): v is HistoryState {
526
- if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
527
- const obj = v as Record<string, unknown>;
528
- return (
529
- Array.isArray(obj.past) &&
530
- obj.past.every(isValidHistoryEntry) &&
531
- Array.isArray(obj.future) &&
532
- obj.future.every(isValidHistoryEntry)
533
- );
534
- }
535
-
536
- function loadHistoryState(
537
- overridesFilePath: string,
538
- logger: DaemonLogger,
539
- ): HistoryState {
540
- const filePath = historyPathFor(overridesFilePath);
541
- let raw: string;
542
- try {
543
- raw = fs.readFileSync(filePath, "utf8");
544
- } catch (e) {
545
- const code = (e as NodeJS.ErrnoException).code;
546
- if (code !== "ENOENT") {
547
- logger(
548
- "warn",
549
- `config-overrides-history read failed (${code}); starting empty`,
550
- );
551
- }
552
- return EMPTY_HISTORY;
553
- }
554
- try {
555
- const parsed: unknown = JSON.parse(raw);
556
- if (isValidHistoryState(parsed)) return parsed;
557
- logger(
558
- "warn",
559
- `config-overrides-history load: unexpected shape, starting empty`,
560
- );
561
- return EMPTY_HISTORY;
562
- } catch {
563
- logger(
564
- "warn",
565
- `config-overrides-history load: corrupt JSON, starting empty`,
566
- );
567
- return EMPTY_HISTORY;
568
- }
569
- }
570
-
571
- function writeHistoryState(
572
- overridesFilePath: string,
573
- state: HistoryState,
574
- logger: DaemonLogger,
575
- ): void {
576
- writeJsonAtomic(
577
- historyPathFor(overridesFilePath),
578
- "config-overrides-history",
579
- state,
580
- logger,
581
- );
582
- }
583
-
584
- // [LAW:no-mode-explosion] Bounded push, oldest-drops-first, shared by both
585
- // stacks (past grows on a fresh edit or a redo; future grows on an undo) —
586
- // one shape, not two near-duplicate arms.
587
- function capPush<T>(arr: readonly T[], entry: T, max: number): readonly T[] {
588
- const next = [...arr, entry];
589
- return next.length > max ? next.slice(next.length - max) : next;
590
- }
591
-
592
- // [LAW:one-source-of-truth] The ONLY caller is writeConfigOverride/
593
- // clearConfigOverride above — every tracked write lands here, so recording
594
- // cannot drift from mutation. A fresh edit TRUNCATES `future`: the classic
595
- // undo/redo branch — diverging from history by doing something NEW abandons
596
- // whatever was undone, rather than silently keeping it reachable from a
597
- // history state the new edit has already invalidated.
598
- function pushHistoryEntry(
599
- overridesFilePath: string,
600
- entry: HistoryEntry,
601
- logger: DaemonLogger,
602
- ): void {
603
- const state = loadHistoryState(overridesFilePath, logger);
604
- writeHistoryState(
605
- overridesFilePath,
606
- { past: capPush(state.past, entry, MAX_HISTORY_DEPTH), future: [] },
607
- logger,
608
- );
609
- }
610
-
611
- // [LAW:one-source-of-truth] The daemon-GLOBAL history is ONE stack, not
612
- // per-session: config-overrides.json already has exactly one writer (the
613
- // daemon) and no session-scoping (candybar-config-engine-71o's own binding
614
- // guardrail — a `persist` write is daemon-global by design), so undo/redo
615
- // stepping that SAME single-writer file inherits the same scope rather than
616
- // inventing a session axis the storage layer doesn't otherwise have. Two
617
- // sessions clicking undo do see each other's edits — a real, DELIBERATE
618
- // consequence of there being one bar default, not a bug: the alternative
619
- // (per-session history over daemon-global state) would let one session's
620
- // "undo" silently fail to undo what another session's click actually did.
621
- //
622
- // [LAW:no-silent-failure] Returns `null` at the bottom of the stack — the
623
- // verb handler (verbs/index.ts) turns that into a loud BadVerbArgs surfaced
624
- // through click.error, never a silent no-op.
625
- export function undoLastOverride(
626
- overridesFilePath: string,
627
- logger: DaemonLogger = quietLogger,
628
- ): HistoryEntry | null {
629
- const state = loadHistoryState(overridesFilePath, logger);
630
- const entry = state.past[state.past.length - 1];
631
- if (entry === undefined) return null;
632
- restoreConfigOverrideValue(overridesFilePath, entry.key, entry.from, logger);
633
- writeHistoryState(
634
- overridesFilePath,
635
- {
636
- past: state.past.slice(0, -1),
637
- future: capPush(state.future, entry, MAX_HISTORY_DEPTH),
638
- },
639
- logger,
640
- );
641
- return entry;
642
- }
643
-
644
- // [LAW:no-silent-failure] Redo's mirror of undo above — `null` at the top of
645
- // the stack, same loud surfacing contract.
646
- export function redoLastOverride(
647
- overridesFilePath: string,
648
- logger: DaemonLogger = quietLogger,
649
- ): HistoryEntry | null {
650
- const state = loadHistoryState(overridesFilePath, logger);
651
- const entry = state.future[state.future.length - 1];
652
- if (entry === undefined) return null;
653
- restoreConfigOverrideValue(overridesFilePath, entry.key, entry.to, logger);
654
- writeHistoryState(
655
- overridesFilePath,
656
- {
657
- past: capPush(state.past, entry, MAX_HISTORY_DEPTH),
658
- future: state.future.slice(0, -1),
659
- },
660
- logger,
661
- );
662
- return entry;
663
- }