@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/README.md +1 -1
- package/dist/index.mjs +37 -37
- package/package.json +6 -6
- package/src/config/dsl-types.ts +4 -4
- package/src/config/loader/helpers.ts +1 -1
- package/src/daemon/cache/render.ts +66 -5
- package/src/daemon/limits.ts +9 -8
- package/src/daemon/server.ts +21 -5
- package/src/dsl/render.ts +30 -30
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@promptctl/cc-candybar",
|
|
3
|
-
"version": "1.41.
|
|
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.
|
|
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.
|
|
95
|
-
"@promptctl/cc-candybar-darwin-x64": "1.41.
|
|
96
|
-
"@promptctl/cc-candybar-linux-x64": "1.41.
|
|
97
|
-
"@promptctl/cc-candybar-linux-arm64": "1.41.
|
|
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
|
}
|
package/src/config/dsl-types.ts
CHANGED
|
@@ -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 }}`
|
|
185
|
-
// whole set into
|
|
186
|
-
//
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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 (
|
|
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
|
-
//
|
|
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
|
package/src/daemon/limits.ts
CHANGED
|
@@ -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
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
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 =
|
|
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
|
package/src/daemon/server.ts
CHANGED
|
@@ -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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
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
|
-
// ───
|
|
216
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
217
217
|
|
|
218
218
|
// [LAW:single-enforcer] Compile the config's shared helper templates into ONE
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
|
|
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
|
-
):
|
|
234
|
-
let
|
|
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
|
|
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
|
|
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:
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
// shared
|
|
386
|
-
// there is exactly one boundary where helpers come into scope (and one place
|
|
387
|
-
// helper could fail to be visible). The
|
|
388
|
-
|
|
389
|
-
const
|
|
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
|