@promptctl/cc-candybar 1.41.1 → 1.41.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptctl/cc-candybar",
3
- "version": "1.41.1",
3
+ "version": "1.41.3",
4
4
  "description": "Statusline renderer for Claude Code — a JSON5-configurable DSL with daemon-cached data sources, byte-clean palette-aware composition, and OSC8 click verbs.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.mjs",
@@ -85,15 +85,15 @@
85
85
  "tsdown": "^0.21.4",
86
86
  "tsx": "^4.21.0",
87
87
  "typescript": "^5.0.0",
88
- "@promptctl/go-template-js": "^0.7.0",
88
+ "@promptctl/go-template-js": "^0.8.0",
89
89
  "@promptctl/rich-js": "^0.7.0",
90
90
  "json5": "^2.2.3",
91
91
  "mobx": "^6.15.0"
92
92
  },
93
93
  "optionalDependencies": {
94
- "@promptctl/cc-candybar-darwin-arm64": "1.41.1",
95
- "@promptctl/cc-candybar-darwin-x64": "1.41.1",
96
- "@promptctl/cc-candybar-linux-x64": "1.41.1",
97
- "@promptctl/cc-candybar-linux-arm64": "1.41.1"
94
+ "@promptctl/cc-candybar-darwin-arm64": "1.41.3",
95
+ "@promptctl/cc-candybar-darwin-x64": "1.41.3",
96
+ "@promptctl/cc-candybar-linux-x64": "1.41.3",
97
+ "@promptctl/cc-candybar-linux-arm64": "1.41.3"
98
98
  }
99
99
  }
@@ -181,9 +181,9 @@ export interface RawDslConfig {
181
181
  // per session exactly like theme/style (session key `look`).
182
182
  readonly looks?: Readonly<Record<string, ThemeKey>>;
183
183
  // [LAW:single-enforcer] Config-level shared helper templates: name → Go-template
184
- // body. Each compiles to one `{{ define "name" }}body{{ end }}` block, and the
185
- // whole set into a single output-neutral preamble prepended to every template
186
- // this config parses — so a formatter (`{{ template "formatCost" .x }}`) is
184
+ // body. Each compiles to one `{{ define "name" }}body{{ end }}` unit, and the
185
+ // whole set into one shared define set every template this config parses
186
+ // inherits — so a formatter (`{{ template "formatCost" .x }}`) is
187
187
  // defined ONCE and callable from any segment/predicate, never re-inlined per
188
188
  // segment. Absent ≡ no helpers; merges by-name (user overrides a helper).
189
189
  readonly helpers?: Readonly<Record<string, string>>;
@@ -240,7 +240,7 @@ export interface DslConfig {
240
240
  // [LAW:dataflow-not-control-flow].
241
241
  readonly editGlobals: Partial<Globals>;
242
242
  // [LAW:single-enforcer] The effective helper set: a name → template-body map
243
- // compiled to a defines-preamble at registerDslConfig. Empty when no config
243
+ // compiled to one shared define set at registerDslConfig. Empty when no config
244
244
  // declares helpers — an absent `helpers` key merges to `{}` (same cascade as
245
245
  // actions). The single definition site for each formatter/transform a template
246
246
  // calls via `{{ template "name" .arg }}`.
@@ -2,7 +2,7 @@
2
2
  // record of name → template-body STRING. Each value is a Go-template source the
3
3
  // renderer compiles into a `{{ define }}` block; whether the body PARSES (and
4
4
  // whether a `{{ template "name" }}` reference resolves) is a render-time concern
5
- // (registerDslConfig parses the preamble and throws a per-helper diagnostic).
5
+ // (registerDslConfig parses each helper and throws a per-helper diagnostic).
6
6
  // This file changes only if the helper authoring shape changes.
7
7
 
8
8
  import { findKeyLine } from "./diagnostics.js";
@@ -61,6 +61,45 @@ export interface RenderDeps {
61
61
  watchers: WatcherRegistry;
62
62
  }
63
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
+
64
103
  // [LAW:types-are-the-program] The DSL render state for an entry is one
65
104
  // optionally-null bundle, not five independently-optional fields. Either
66
105
  // every field is populated (a render is possible) or all are null (parse
@@ -154,10 +193,15 @@ export class RenderCache {
154
193
  private readonly entries = new Map<string, CacheEntry>();
155
194
  private readonly deps: RenderDeps;
156
195
  private readonly maxEntries: number;
196
+ private readonly observers: RenderCacheObservers;
157
197
 
158
- constructor(deps: RenderDeps, opts: { maxEntries?: number } = {}) {
198
+ constructor(
199
+ deps: RenderDeps,
200
+ opts: { maxEntries?: number; observers?: RenderCacheObservers } = {},
201
+ ) {
159
202
  this.deps = deps;
160
203
  this.maxEntries = opts.maxEntries ?? MAX_ENTRIES;
204
+ this.observers = opts.observers ?? {};
161
205
  }
162
206
 
163
207
  // [LAW:dataflow-not-control-flow] One uniform shape: every entry has the
@@ -188,9 +232,15 @@ export class RenderCache {
188
232
  state: null,
189
233
  watcher: null,
190
234
  };
191
- this.reloadInto(entry);
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]
192
243
  this.entries.set(key, entry);
193
-
194
244
  if (this.entries.size > this.maxEntries) {
195
245
  const oldest = this.entries.keys().next().value;
196
246
  if (oldest !== undefined) {
@@ -205,6 +255,7 @@ export class RenderCache {
205
255
  this.entries.delete(oldest);
206
256
  }
207
257
  }
258
+ this.reloadInto(entry);
208
259
 
209
260
  return entry;
210
261
  }
@@ -220,7 +271,17 @@ export class RenderCache {
220
271
  // new DslConfig disposes the old SourceRegistry before constructing a new
221
272
  // one. The registry owns timers, watchers, MobX reactions, and git
222
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.
223
279
  private reloadInto(entry: CacheEntry): void {
280
+ this.loadFromDisk(entry);
281
+ this.observers.onReload?.(entry);
282
+ }
283
+
284
+ private loadFromDisk(entry: CacheEntry): void {
224
285
  const resolvedPath = resolveDslConfigPath(
225
286
  entry.projectDir,
226
287
  entry.cwd,
@@ -286,7 +347,7 @@ export class RenderCache {
286
347
  // store, registry, compiled segments, palette — as one transaction. Any
287
348
  // failure inside disposes the partially-built registry so we don't leak
288
349
  // timers/watchers from a half-constructed reload, then rethrows so the
289
- // caller (reloadInto) preserves the prior `entry.state` unchanged.
350
+ // caller (loadFromDisk) preserves the prior `entry.state` unchanged.
290
351
  private buildState(
291
352
  entry: CacheEntry,
292
353
  resolvedPath: string | null,
@@ -396,7 +457,7 @@ export class RenderCache {
396
457
  // key) are part of the same construction transaction as the registry: any
397
458
  // failure (registration, a duplicate-key throw) disposes every handle built
398
459
  // so far — registry AND already-installed validators — before rethrowing, so
399
- // reloadInto preserves the prior last-known-good with nothing half-installed.
460
+ // loadFromDisk preserves the prior last-known-good with nothing half-installed.
400
461
  const validatorDisposers: Array<() => void> = [];
401
462
  try {
402
463
  // [LAW:one-source-of-truth] The action runtime reads session.id + current
@@ -21,17 +21,18 @@ import { dlog, type DaemonLogger } from "./log";
21
21
  // The cap sits at HEAP_CAP_OVER_RSS × the backstop, a margin wide enough that
22
22
  // the graceful path fires first under any growth the 60 s poll can see (a
23
23
  // burst that doubles RSS inside one poll window can still reach the hard cap).
24
- // Before this the two were unrelated literals (400 MB heap in
25
- // each spawner, 512 MB RSS here): a cold daemon seeding a large transcript tree
26
- // for a dozen sessions blew the heap in seconds, aborted silently, and crash-
27
- // looped on every render tick while the backstop — a 60 s poll — never got a
28
- // turn. Raising the env override raises BOTH, because both spawners derive the
29
- // cap through heapCapMb below. The Rust client mirrors RSS_LIMIT_ENV,
30
- // DEFAULT_RSS_LIMIT_MB, and HEAP_CAP_OVER_RSS as literals
24
+ // Before this the two were unrelated literals (400 MB heap in each spawner,
25
+ // 512 MB RSS here), and the 2026-09-03 outage found the gap: a daemon holding
26
+ // twenty configs' worth of duplicated helper-template ASTs (since fixed in
27
+ // src/dsl/render.ts compileHelpers) blew the heap in seconds, aborted
28
+ // silently, and crash-looped on every render tick while the backstop — a 60 s
29
+ // poll — never got a turn. Raising the env override raises BOTH, because both
30
+ // spawners derive the cap through heapCapMb below. The Rust client mirrors
31
+ // RSS_LIMIT_ENV, DEFAULT_RSS_LIMIT_MB, and HEAP_CAP_OVER_RSS as literals
31
32
  // (rust-client/src/launch.rs); scripts/check-protocol.mjs fails the build on
32
33
  // drift.
33
34
  export const RSS_LIMIT_ENV = "CC_CANDYBAR_RSS_LIMIT_MB";
34
- export const DEFAULT_RSS_LIMIT_MB = 2048;
35
+ export const DEFAULT_RSS_LIMIT_MB = 512;
35
36
  export const HEAP_CAP_OVER_RSS = 2;
36
37
 
37
38
  // [LAW:parse-dont-validate] Absent → default; a positive integer → that; present
@@ -131,11 +131,27 @@ const sessionState = new SessionState();
131
131
  const contextProvider = new ContextProvider();
132
132
  const metricsProvider = new MetricsProvider();
133
133
  const tmuxService = new TmuxService();
134
- const renderCache = new RenderCache({
135
- gitService,
136
- sessionState,
137
- watchers: watcherRegistry,
138
- });
134
+ const renderCache = new RenderCache(
135
+ {
136
+ gitService,
137
+ sessionState,
138
+ watchers: watcherRegistry,
139
+ },
140
+ {
141
+ observers: {
142
+ // [LAW:no-silent-failure] Every config (re)load's outcome lands in
143
+ // daemon.log beside the "config change detected" line that preceded it
144
+ // — the operator's only record of whether a save was picked up cleanly,
145
+ // kept rendering last-known-good behind an error, or resolved to a
146
+ // different file. Same info level as the detection line.
147
+ onReload: (entry) =>
148
+ dlog(
149
+ "info",
150
+ `config loaded projectDir=${entry.projectDir} cwd=${entry.cwd} file=${entry.configFilePath ?? "<bundled default>"} error=${entry.lastError === null ? "none" : JSON.stringify(entry.lastError)} warning=${entry.lastWarning === null ? "none" : JSON.stringify(entry.lastWarning)}`,
151
+ ),
152
+ },
153
+ },
154
+ );
139
155
 
140
156
  const REQUEST_TIMEOUT_MS = 200;
141
157
  const BIN_CHECK_INTERVAL_MS = 60 * 1000;
package/src/dsl/render.ts CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  import type { RichText, Palette, ThemeKey } from "@promptctl/rich-js";
15
15
  import { ColorSpec, Style, lighten, IDENTITY } from "@promptctl/rich-js";
16
- import type { Engine, Template } from "@promptctl/go-template-js";
16
+ import { Defines, type Engine, type Template } from "@promptctl/go-template-js";
17
17
  import type {
18
18
  ValidatedConfig,
19
19
  VariableDecl,
@@ -213,38 +213,40 @@ function declareOne(
213
213
  }
214
214
  }
215
215
 
216
- // ─── Helper preamble ─────────────────────────────────────────────────────────
216
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
217
217
 
218
218
  // [LAW:single-enforcer] Compile the config's shared helper templates into ONE
219
- // output-neutral preamble: each name→body becomes a `{{ define "name" }}body{{ end }}`
220
- // block, concatenated with no interstitial text so the preamble emits nothing.
221
- // Prepended to every template this config parses, the defines resolve a
222
- // `{{ template "name" .arg }}` call locally — go-template-js scopes defines to a
223
- // single parse unit, so the define and the call MUST share one parse.
224
- // [LAW:no-silent-fallbacks] Each body is parsed in ISOLATION first, so a malformed
225
- // helper surfaces a per-helper diagnostic rather than a confusing error blamed on
226
- // the first segment that happens to call it.
227
- // [LAW:dataflow-not-control-flow] Empty helpers ⇒ "" ⇒ `engine.parse("" + src)`
228
- // is byte-identical to `engine.parse(src)`: existing configs are unaffected with
229
- // no special-case branch.
230
- function compileHelperPreamble(
219
+ // define set: each name→body is parsed as its own `{{ define "name" }}body{{ end }}`
220
+ // unit, chained onto the previous helpers' set, so the result is one `Defines`
221
+ // every template this config parses inherits (`engine.parse(src, helpers)`).
222
+ // [LAW:one-source-of-truth] Inherited by link, never by copy. The previous
223
+ // shape — the defines' SOURCE prepended to every parse — re-parsed the whole
224
+ // helper block into every template: ~100 KB of AST per parse for the 2 KB
225
+ // stdlib block, ~287 parses per config, ~30 MB per config, and a daemon
226
+ // holding twenty configs sat at 600 MB of nothing but duplicated helper ASTs
227
+ // (the 2026-09-02 RSS-breach snapshot and the 2026-09-03 crash-loop).
228
+ // [LAW:no-silent-fallbacks] Each body is parsed in ISOLATION, so a malformed
229
+ // helper surfaces a per-helper diagnostic rather than a confusing error blamed
230
+ // on the first segment that happens to call it; the redefinition check the
231
+ // parser applies against the inherited set is what makes helper names unique.
232
+ function compileHelpers(
231
233
  engine: Engine<RichText>,
232
234
  helpers: Readonly<Record<string, string>>,
233
- ): string {
234
- let preamble = "";
235
+ ): Defines {
236
+ let defines = Defines.EMPTY;
235
237
  for (const [name, body] of Object.entries(helpers)) {
236
- const define = `{{ define "${name}" }}${body}{{ end }}`;
237
238
  try {
238
- engine.parse(define);
239
+ defines = engine
240
+ .parse(`{{ define "${name}" }}${body}{{ end }}`, defines)
241
+ .defines();
239
242
  } catch (e) {
240
243
  throw new Error(
241
244
  `Template parse error in helpers.${name}: ${(e as Error).message}`,
242
245
  { cause: e },
243
246
  );
244
247
  }
245
- preamble += define;
246
248
  }
247
- return preamble;
249
+ return defines;
248
250
  }
249
251
 
250
252
  // ─── registerDslConfig ────────────────────────────────────────────────────────
@@ -379,16 +381,14 @@ export function registerDslConfig(
379
381
  },
380
382
  opts?.clock,
381
383
  );
382
- // [LAW:single-enforcer] THE one parse path for this config: prepend the helper
383
- // preamble so every template — segment template/when/bg/fg, node `when`, and
384
- // action copy/open — resolves `{{ template "name" }}` calls against the same
385
- // shared helpers. One closure, not raw engine.parse scattered across sites, so
386
- // there is exactly one boundary where helpers come into scope (and one place a
387
- // helper could fail to be visible). The preamble is compiled ONCE here, not per
388
- // parse, and is "" when no helpers are declared.
389
- const helperPreamble = compileHelperPreamble(engine, config.helpers);
390
- const parse = (src: string): Template<RichText> =>
391
- engine.parse(helperPreamble + src);
384
+ // [LAW:single-enforcer] THE one parse path for this config: every template —
385
+ // segment template/when/bg/fg, node `when`, and action copy/open — inherits
386
+ // the same helper define set, so `{{ template "name" }}` resolves against one
387
+ // shared AST. One closure, not raw engine.parse scattered across sites, so
388
+ // there is exactly one boundary where helpers come into scope (and one place
389
+ // a helper could fail to be visible). The helpers are parsed ONCE here.
390
+ const helpers = compileHelpers(engine, config.helpers);
391
+ const parse = (src: string): Template<RichText> => engine.parse(src, helpers);
392
392
  // [LAW:one-source-of-truth] Map each SessionState key → the variable that
393
393
  // reads it, so an option picker marks its current selection by reading the
394
394
  // SAME value the templates read — independent of whether the config named the