@promptctl/cc-candybar 1.22.0 → 1.24.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptctl/cc-candybar",
3
- "version": "1.22.0",
3
+ "version": "1.24.0",
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",
@@ -31,8 +31,6 @@
31
31
  "check:protocol": "node scripts/check-protocol.mjs",
32
32
  "gen:schema": "tsx scripts/gen-schema.ts",
33
33
  "check:schema": "tsx scripts/check-schema.ts",
34
- "prepack": "node scripts/write-bin-placeholder.mjs",
35
- "postinstall": "node scripts/postinstall.mjs",
36
34
  "prepublishOnly": "npm run lint && npm run typecheck && npm run check:protocol && npm run check:schema && npm run build"
37
35
  },
38
36
  "keywords": [
@@ -86,30 +84,16 @@
86
84
  "ts-jest": "^29.4.1",
87
85
  "tsdown": "^0.21.4",
88
86
  "tsx": "^4.21.0",
89
- "typescript": "^5.0.0"
90
- },
91
- "dependencies": {
87
+ "typescript": "^5.0.0",
92
88
  "@promptctl/go-template-js": "^0.7.0",
93
89
  "@promptctl/rich-js": "^0.6.0",
94
90
  "json5": "^2.2.3",
95
91
  "mobx": "^6.15.0"
96
92
  },
97
93
  "optionalDependencies": {
98
- "@promptctl/cc-candybar-darwin-arm64": "1.22.0",
99
- "@promptctl/cc-candybar-darwin-x64": "1.22.0",
100
- "@promptctl/cc-candybar-linux-x64": "1.22.0",
101
- "@promptctl/cc-candybar-linux-arm64": "1.22.0"
102
- },
103
- "pnpm": {
104
- "supportedArchitectures": {
105
- "os": [
106
- "darwin",
107
- "linux"
108
- ],
109
- "cpu": [
110
- "x64",
111
- "arm64"
112
- ]
113
- }
94
+ "@promptctl/cc-candybar-darwin-arm64": "1.24.0",
95
+ "@promptctl/cc-candybar-darwin-x64": "1.24.0",
96
+ "@promptctl/cc-candybar-linux-x64": "1.24.0",
97
+ "@promptctl/cc-candybar-linux-arm64": "1.24.0"
114
98
  }
115
99
  }
package/src/check.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  ConfigError,
26
26
  } from "./config/dsl-loader.js";
27
27
  import { expandHome } from "./config/loader/discovery.js";
28
+ import { DEFAULT_DSL_CONFIG } from "./config/default-dsl-config.js";
28
29
  import { VariableStore } from "./var-system/store.js";
29
30
  import { SourceRegistry } from "./var-system/sources.js";
30
31
  import { SessionState } from "./daemon/session-state.js";
@@ -250,7 +251,7 @@ function loadRegisterRender(
250
251
  cwd: string,
251
252
  warnings: string[],
252
253
  ): string {
253
- const { config: merged, source } = loadConfig(configPath);
254
+ const { config: merged, source } = loadConfig(configPath, DEFAULT_DSL_CONFIG);
254
255
  const config = validateConfig(merged, configPath ?? "<default>", source);
255
256
 
256
257
  const store = new VariableStore();
@@ -19,12 +19,16 @@
19
19
  // (add its name to the children), not new code. The same data flows through
20
20
  // the same render path whether the root has 1 leaf or 16.
21
21
  //
22
- // [LAW:types-are-the-program] `satisfies DslConfig` (not an annotation)
23
- // preserves the literal's narrow keys for downstream consumers every
24
- // declared segment name shows up as a known key, every variable name shows
25
- // up in the input set the daemon must populate.
22
+ // [LAW:types-are-the-program] The authored literal (RAW_DEFAULT_DSL_CONFIG)
23
+ // uses `satisfies DslConfig` (not an annotation) so every declared segment
24
+ // and variable name is checked against the real shape at the point of
25
+ // authoring. The exported DEFAULT_DSL_CONFIG is that literal run through the
26
+ // loader's own synthesis pass (see the bottom of this file) — a `DslConfig`,
27
+ // the same effective shape every user config resolves to.
26
28
 
27
- import type { DslConfig } from "./dsl-types.js";
29
+ import type { DslConfig, SegmentDecl } from "./dsl-types.js";
30
+ import { parseDslConfig } from "./dsl-loader.js";
31
+ import { mergeWithDefault } from "./loader/merge.js";
28
32
 
29
33
  // ─── Shared template fragments ───────────────────────────────────────────────
30
34
  //
@@ -144,7 +148,15 @@ function etaHeatFg(etaRef: string, warnRef: string): string {
144
148
 
145
149
  // ─── The default config ──────────────────────────────────────────────────────
146
150
 
147
- export const DEFAULT_DSL_CONFIG = {
151
+ // [LAW:one-source-of-truth] The AUTHORED literal, pre-synthesis. Production
152
+ // code wants the synthesized DEFAULT_DSL_CONFIG below; this is exported only
153
+ // for tests that round-trip "what a user would get by copy-pasting the
154
+ // bundled default into their own file" through the real per-file parse —
155
+ // that round-trip must start from the AUTHORED declarations, never from
156
+ // DEFAULT_DSL_CONFIG's own already-synthesized `menus.*` entries (reparsing
157
+ // those would trip the reserved-namespace guard, which exists to catch a
158
+ // user hand-declaring a name only synthesis may write).
159
+ export const RAW_DEFAULT_DSL_CONFIG = {
148
160
  globals: {
149
161
  // Picked by the daemon's basePalette resolution; user overrides in their
150
162
  // own config. catppuccin-latte ships every spec name the default
@@ -814,20 +826,56 @@ export const DEFAULT_DSL_CONFIG = {
814
826
  bg: "surface",
815
827
  fg: "foreground",
816
828
  },
829
+ // Theme control — the palette switcher, wired into the DEFAULT bar
830
+ // (brandon-theming-8uj.1) so theme selection is discoverable without
831
+ // reading docs/interaction-authoring.md or hand-authoring a config.
832
+ // [LAW:one-source-of-truth] The trigger reads `.theme.effective` — the
833
+ // SAME daemon-resolved name (effectiveThemeName) the rendered basePalette
834
+ // is built from — so the label and the colors can never drift; unlike
835
+ // styleControl (no "effective style" input exists), no extra `state`
836
+ // variable is needed here. Shares the "pickers" accordion key with
837
+ // lookControl so opening one closes the other, the docs' canonical
838
+ // two-menu pairing.
839
+ themeControl: {
840
+ template:
841
+ "🎨 {{ .theme.effective }} " +
842
+ '{{ menu "applyTheme" (dict "key" "pickers") }}',
843
+ bg: "surface",
844
+ fg: "foreground",
845
+ },
846
+ // Look control — the theme-ADAPTATION switcher (see the `looks` block
847
+ // below), the exact twin of themeControl one dimension over:
848
+ // `.look.effective` is the daemon-resolved name (effectiveLookName) the
849
+ // rendered ThemeKey composes from. closeOnPick collapses the drop after a
850
+ // pick — looks are tried one at a time against the chosen theme, not
851
+ // stacked open.
852
+ lookControl: {
853
+ template:
854
+ "◐ {{ .look.effective }} " +
855
+ '{{ menu "applyLook" (dict "key" "pickers" "closeOnPick" true) }}',
856
+ bg: "surface",
857
+ fg: "foreground",
858
+ },
817
859
  },
818
860
 
819
861
  // Default layout — the canonical LayoutNode tree (`satisfies DslConfig`
820
862
  // requires the lowered form here; the terse Option-A `{ h/v/seg }` grammar is
821
863
  // the loader's authoring surface for user JSON, not this typed literal).
822
864
  //
823
- // Two rows stacked by the vertical container: an IDENTITY + ACTIONS row
865
+ // Three rows stacked by the vertical container: an IDENTITY + ACTIONS row
824
866
  // (where am I / what can I do here — the directory, the verbose `gitaculous`
825
867
  // line (repo, sha, working-tree, upstream, stash, time-since-commit), then the
826
868
  // quick-action tray: copy session id, open project / transcript in the editor)
827
869
  // over a STATUS row (what's happening now — model, context-window fill,
828
- // prompt-cache warmth, and the 5h / 7d rate-limit quotas). The tray sits on
829
- // the identity row because its actions are workspace-scoped (this session,
830
- // this project), not usage metrics. Each row zips its segments through the
870
+ // prompt-cache warmth, and the 5h / 7d rate-limit quotas) over an APPEARANCE
871
+ // row (theme + look pickers, brandon-theming-8uj.1 the bundled default's
872
+ // only on-bar affordance to discover and use the theme/look feature without
873
+ // reading docs or hand-authoring a config). The tray sits on the identity
874
+ // row because its actions are workspace-scoped (this session, this
875
+ // project), not usage metrics; the pickers get their own row rather than
876
+ // crowding the identity row because a `{{ menu }}` drops its picker body
877
+ // onto the line immediately below its row, and that drop must not land on
878
+ // top of an unrelated row's content. Each row zips its segments through the
831
879
  // powerline joiner; `\n` separates the rows.
832
880
  //
833
881
  // [LAW:dataflow-not-control-flow] Every status segment is when-gated on its
@@ -861,6 +909,14 @@ export const DEFAULT_DSL_CONFIG = {
861
909
  { kind: "segment", name: "weekly" },
862
910
  ],
863
911
  },
912
+ {
913
+ kind: "container",
914
+ direction: "horizontal",
915
+ children: [
916
+ { kind: "segment", name: "themeControl" },
917
+ { kind: "segment", name: "lookControl" },
918
+ ],
919
+ },
864
920
  ],
865
921
  },
866
922
 
@@ -895,6 +951,17 @@ export const DEFAULT_DSL_CONFIG = {
895
951
  // rendered click and the wire gate share that one source — a template cannot
896
952
  // smuggle an un-gated style write.
897
953
  applyStyle: { set: "style", from: "styles" },
954
+
955
+ // [LAW:locality-or-seam] The theme/look pickers' behaviors, decoupled by
956
+ // NAME from themeControl/lookControl {{ menu }}s above — same seam as
957
+ // applyStyle. "theme" is the baseline permanent state key (validateTheme,
958
+ // registered in state-validators.ts); dropBaselineAllowLists reuses that
959
+ // gate for an allow-list contribution instead of re-registering it, so
960
+ // this action derives nothing new. "look" has no baseline entry, so this
961
+ // action derives a fresh allow-list validator ranging the merged `looks`
962
+ // block's names — the same derivation test/dsl-looks.test.ts exercises.
963
+ applyTheme: { set: "theme", from: "themes" },
964
+ applyLook: { set: "look", from: "looks" },
898
965
  },
899
966
 
900
967
  // ─── Looks ───────────────────────────────────────────────────────────────
@@ -1058,3 +1125,46 @@ export const DEFAULT_DSL_CONFIG = {
1058
1125
  "{{ else }}{{ . }}m{{ end }}",
1059
1126
  },
1060
1127
  } satisfies DslConfig;
1128
+
1129
+ // [LAW:locality-or-seam] The palette names this module-level parse is allowed
1130
+ // to accept — DERIVED from RAW_DEFAULT_DSL_CONFIG itself (globals.palette +
1131
+ // every per-segment palette: pin), never from the live theme registry
1132
+ // (listResolvablePaletteNames()). A real user file must validate against the
1133
+ // live registry (an author can type any name); this file validates against
1134
+ // ITSELF (every name here is a literal we wrote and every render test below
1135
+ // exercises against the real registry already). This is what keeps the
1136
+ // module-load parse below from ever depending on the registry being healthy
1137
+ // at import time — a registry-loading bug elsewhere would surface where it
1138
+ // actually matters (a real render failing), never as an uncatchable crash on
1139
+ // every importer of this file before any daemon/CLI error handling runs.
1140
+ const AUTHORED_PALETTE_NAMES = new Set(
1141
+ [
1142
+ RAW_DEFAULT_DSL_CONFIG.globals.palette,
1143
+ ...(Object.values(RAW_DEFAULT_DSL_CONFIG.segments) as SegmentDecl[]).map(
1144
+ (s) => s.palette,
1145
+ ),
1146
+ ].filter((name): name is string => name !== undefined),
1147
+ );
1148
+
1149
+ // [LAW:single-enforcer] Run the authored literal through the SAME
1150
+ // parse → synthesize pipeline every user config goes through (JSON5 stage +
1151
+ // synthesizeMenuDecls' `menus.*` synthesis, and any future group/menu
1152
+ // synthesis pass) instead of hand-duplicating that logic here. Without this,
1153
+ // the zero-config daemon path (loadConfig: no config file found ⇒ raw={},
1154
+ // merged directly against this constant — see src/config/dsl-loader.ts and
1155
+ // src/config/loader/merge.ts) would ship an UNSYNTHESIZED default: a
1156
+ // `{{ menu (dict "key" …) }}` accordion pairing (themeControl/lookControl,
1157
+ // brandon-theming-8uj.1) would render its glyph, but clicking it would reject
1158
+ // with "unknown state key" — the synthesis that derives a menu's `menus.*`
1159
+ // state var + cycle action only ever ran over TEXT a user typed, never over
1160
+ // this TS literal. Round-tripping through JSON is exactly what
1161
+ // test/default-dsl-config.test.ts's SERIALIZED-based tests already exercise,
1162
+ // so this is the same well-tested path, run once here instead of skipped.
1163
+ export const DEFAULT_DSL_CONFIG: DslConfig = mergeWithDefault(
1164
+ parseDslConfig(
1165
+ "<default>",
1166
+ JSON.stringify(RAW_DEFAULT_DSL_CONFIG),
1167
+ AUTHORED_PALETTE_NAMES,
1168
+ ),
1169
+ RAW_DEFAULT_DSL_CONFIG,
1170
+ );
@@ -30,7 +30,6 @@ import {
30
30
  type RawDslConfig,
31
31
  type ValidatedConfig,
32
32
  } from "./dsl-types.js";
33
- import { DEFAULT_DSL_CONFIG } from "./default-dsl-config.js";
34
33
  import { listResolvablePaletteNames } from "../themes/policy.js";
35
34
  import {
36
35
  ConfigError,
@@ -78,14 +77,23 @@ export {
78
77
  // ─── Three-stage pipeline ────────────────────────────────────────────────────
79
78
 
80
79
  /**
81
- * Load a JSON5 DSL config file from disk and merge it with the bundled
80
+ * Load a JSON5 DSL config file from disk and merge it with the given
82
81
  * default. Returns the effective DslConfig AND the raw source text.
83
82
  *
84
- * `path = null` means "no user file exists" — returns the default unchanged
85
- * (uniform merge against an empty raw, which is deep-equal to the default) and
83
+ * `path = null` means "no user file exists" — returns `dflt` unchanged
84
+ * (uniform merge against an empty raw, which is deep-equal to `dflt`) and
86
85
  * an empty source. No consumer branches on file presence; that branch lives
87
86
  * inside loadConfig exactly once.
88
87
  *
88
+ * [LAW:one-way-deps] `dflt` is a required parameter, not a default pointing at
89
+ * DEFAULT_DSL_CONFIG: this module is generic merge/parse machinery, and
90
+ * DEFAULT_DSL_CONFIG is a specific, higher-level instance built ON TOP of it
91
+ * (default-dsl-config.ts imports parseDslConfig/mergeWithDefault to
92
+ * synthesize itself — see that file). A default param here pointing back at
93
+ * DEFAULT_DSL_CONFIG would make this generic module depend on its own
94
+ * specific consumer — a cycle every caller who wants "the bundled default"
95
+ * resolves explicitly by importing DEFAULT_DSL_CONFIG themselves.
96
+ *
89
97
  * [LAW:one-source-of-truth] The source is returned alongside the config so the
90
98
  * caller can hand it to validateConfig — cross-ref diagnostics (line numbers,
91
99
  * the authored-surface discriminator) are derived from it, and the file is read
@@ -99,7 +107,7 @@ export {
99
107
  */
100
108
  export function loadConfig(
101
109
  path: string | null,
102
- dflt: DslConfig = DEFAULT_DSL_CONFIG,
110
+ dflt: DslConfig,
103
111
  allowedPalettes?: ReadonlySet<string>,
104
112
  ): { config: DslConfig; source: string } {
105
113
  const source = path === null ? "" : fs.readFileSync(path, "utf-8");
@@ -1,11 +1,19 @@
1
- // [LAW:one-source-of-truth] The single point that consults DEFAULT_DSL_CONFIG to
2
- // fill missing keys. A user file declares only what differs; the cascade here
3
- // (shallow-merge globals, by-name merge variables/segments/actions, wholesale
4
- // root replacement) is the one place "absent means inherit" is decided.
5
- // This file changes when the merge semantics change.
1
+ // [LAW:one-source-of-truth] The single point that merges a raw user config
2
+ // onto a default DslConfig to fill missing keys. A user file declares only
3
+ // what differs; the cascade here (shallow-merge globals, by-name merge
4
+ // variables/segments/actions, wholesale root replacement) is the one place
5
+ // "absent means inherit" is decided. This file changes when the merge
6
+ // semantics change.
7
+ //
8
+ // [LAW:one-way-deps] `dflt` is a required parameter — this module is generic
9
+ // merge machinery and does not know about DEFAULT_DSL_CONFIG, the specific
10
+ // bundled instance built ON TOP of it (default-dsl-config.ts imports this
11
+ // function to synthesize itself). A default param pointing back at
12
+ // DEFAULT_DSL_CONFIG would make this generic module depend on its own
13
+ // specific consumer, a cycle. Callers who want "the bundled default" import
14
+ // DEFAULT_DSL_CONFIG from default-dsl-config.ts and pass it explicitly.
6
15
 
7
16
  import { type DslConfig, type RawDslConfig } from "../dsl-types.js";
8
- import { DEFAULT_DSL_CONFIG } from "../default-dsl-config.js";
9
17
 
10
18
  /**
11
19
  * Merge a RawDslConfig on top of a default DslConfig. Pure function.
@@ -21,7 +29,7 @@ import { DEFAULT_DSL_CONFIG } from "../default-dsl-config.js";
21
29
  */
22
30
  export function mergeWithDefault(
23
31
  raw: RawDslConfig,
24
- dflt: DslConfig = DEFAULT_DSL_CONFIG,
32
+ dflt: DslConfig,
25
33
  ): DslConfig {
26
34
  return {
27
35
  globals: { ...dflt.globals, ...(raw.globals ?? {}) },
@@ -7,6 +7,7 @@ import {
7
7
  socketPath,
8
8
  spawnLockPath,
9
9
  spawnCooldownPath,
10
+ spawnBackoffPath,
10
11
  daemonDir,
11
12
  } from "./paths";
12
13
 
@@ -335,32 +336,167 @@ export type CooldownDecision =
335
336
  | { kind: "allow-future-garbage"; futureMs: number }
336
337
  | { kind: "deny" };
337
338
 
338
- export function cooldownDecision(ageMs: number | null): CooldownDecision {
339
+ // [LAW:types-are-the-program] `cooldownMs` is the required window, not a
340
+ // captured constant — the decision is the same pure fold whether the caller
341
+ // is checking against the base SPAWN_COOLDOWN_MS or a backed-off window from
342
+ // effectiveCooldownMs(streak) below. Generalizing the threshold into a
343
+ // parameter is what let brandon-daemon-lifecycle-gad.3 add exponential
344
+ // backoff without touching this function's tested boundary arithmetic.
345
+ export function cooldownDecision(
346
+ ageMs: number | null,
347
+ cooldownMs: number,
348
+ ): CooldownDecision {
339
349
  if (ageMs === null) return { kind: "allow" };
340
350
  if (ageMs < -STALE_LOCK_MS)
341
351
  return { kind: "allow-future-garbage", futureMs: -ageMs };
342
- if (ageMs < SPAWN_COOLDOWN_MS) return { kind: "deny" };
352
+ if (ageMs < cooldownMs) return { kind: "deny" };
343
353
  return { kind: "allow" };
344
354
  }
345
355
 
356
+ // ─── Spawn backoff (consecutive non-convergence widens the cooldown) ────────
357
+ //
358
+ // [LAW:one-source-of-truth] spawn.cooldown's mtime answers "when was a spawn
359
+ // last attempted"; this streak answers "how many attempts in a row have
360
+ // failed to converge on a live daemon" — a fact spawn.cooldown's mtime alone
361
+ // cannot carry (mtime is overwritten on every attempt, losing the count). One
362
+ // small file, one fact, read/written by both runtimes exactly like
363
+ // spawn.cooldown itself.
364
+ //
365
+ // [LAW:single-enforcer] The daemon is the only process that can know
366
+ // "convergence achieved" (it just bound the socket and is about to serve) —
367
+ // see resetSpawnBackoff(), called once from server.ts's onListening(). A
368
+ // client-side reset would need a full successful render round-trip on the
369
+ // hot path to detect convergence, adding fs I/O to the common case for a
370
+ // signal the daemon already has for free at boot.
371
+ //
372
+ // Growth is capped at SPAWN_BACKOFF_MAX_STREAK shifts so effectiveCooldownMs
373
+ // never has to reason about an unbounded streak (a multi-day outage would
374
+ // otherwise grow the stored integer without bound) and so Rust's mirrored
375
+ // `<<` cannot overflow. 3_000ms << 5 = 96_000ms, already past the 60s cap, so
376
+ // 5 is sufficient — not tuned to any particular outage length.
377
+ export const SPAWN_BACKOFF_CAP_MS = 60_000;
378
+ export const SPAWN_BACKOFF_MAX_STREAK = 5;
379
+
380
+ // [LAW:behavior-not-structure] Pure over the streak; no filesystem. Mirrors
381
+ // Rust's effective_cooldown_ms exactly (diffed by check-protocol for the two
382
+ // constants; the arithmetic itself is pinned by the boundary unit tests on
383
+ // both sides, matching cooldownDecision's existing pattern).
384
+ export function effectiveCooldownMs(streak: number): number {
385
+ const capped = Math.min(Math.max(streak, 0), SPAWN_BACKOFF_MAX_STREAK);
386
+ return Math.min(SPAWN_COOLDOWN_MS * 2 ** capped, SPAWN_BACKOFF_CAP_MS);
387
+ }
388
+
389
+ // [LAW:no-defensive-null-guards] Number(raw) — not parseInt — so trailing
390
+ // garbage ("5abc", "5.0") fails closed to NaN instead of being silently
391
+ // truncated to a plausible-looking integer. parseInt's truncation is exactly
392
+ // the bug daemonCeiling() (fork-bomb-breaker.ts, brandon-daemon-lifecycle-gad.2)
393
+ // fixed for the same "small integer parsed from an untrusted local file" shape;
394
+ // repeating parseInt here would reintroduce it. The clamp to
395
+ // SPAWN_BACKOFF_MAX_STREAK also bounds every value this function can ever
396
+ // return, so no caller — including `streak + 1` — needs its own re-clamp to
397
+ // stay overflow-safe (Rust's mirror clamps at the identical boundary, since a
398
+ // raw u32 parsed from disk has no such guarantee otherwise).
399
+ // Exported so the parsing-strictness contract (Number, not parseInt — see
400
+ // the comment above) has a direct test independent of any caller's file
401
+ // content, matching cooldownDecision/effectiveCooldownMs's own exported-for-
402
+ // testing precedent.
403
+ export function readBackoffStreak(filePath: string): number {
404
+ // [LAW:no-silent-failure] A missing or garbage streak file is NOT
405
+ // ambiguous the way a missing cooldown mtime is: falling back to 0 always
406
+ // fails toward the SAME safe direction as the rest of this module (spawn
407
+ // permitted at the base rate, never wedged) — matching cooldownDecision's
408
+ // own `ageMs === null → allow`. Never loud here; the failure mode is
409
+ // "one extra spawn," which bind() already arbitrates.
410
+ try {
411
+ const raw = fs.readFileSync(filePath, "utf8").trim();
412
+ const n = Number(raw);
413
+ if (!Number.isInteger(n) || n < 0) return 0;
414
+ return Math.min(n, SPAWN_BACKOFF_MAX_STREAK);
415
+ } catch {
416
+ return 0;
417
+ }
418
+ }
419
+
420
+ // [LAW:no-ambient-temporal-coupling] The read-then-write here (and in
421
+ // claimSpawnCooldown below) is not atomic across process boundaries — two
422
+ // client processes racing through a daemon-miss window can both read the
423
+ // same streak and both write the same increment, undercounting by one. This
424
+ // is an accepted, bounded trade, not an oversight: the ONLY failure direction
425
+ // is undercounting (the streak can never advance faster than reality), so a
426
+ // race just means backoff ramps a little slower than ideal — it can never
427
+ // permit MORE spawning than a race-free count would. The hard rate ceiling
428
+ // remains spawn.cooldown's mtime gate, which spawn.lock already serializes
429
+ // for the common case; this file, like spawn.lock's own documented
430
+ // thundering-herd tolerance, is a best-effort optimization on top of that,
431
+ // not a second load-bearing lock.
432
+ function writeBackoffStreak(filePath: string, streak: number): void {
433
+ try {
434
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
435
+ fs.writeFileSync(filePath, String(streak), { mode: 0o600 });
436
+ } catch (e) {
437
+ process.stderr.write(
438
+ `cc-candybar: could not record spawn.backoff: ${(e as Error).message}\n`,
439
+ );
440
+ }
441
+ }
442
+
443
+ // Called once by the daemon (server.ts onListening) the moment it binds the
444
+ // socket — the one process-wide fact that answers "did an outage just end."
445
+ // Deletes rather than writes "0": absence already reads as streak 0 via
446
+ // readBackoffStreak's catch branch, so there is no separate reset format to
447
+ // keep in sync with the normal write path.
448
+ //
449
+ // [LAW:no-ambient-temporal-coupling] This fires on bind, not on confirmed
450
+ // sustained liveness — in the vanishingly rare socket-capture race
451
+ // documented above armOwnershipWatch (server.ts), a daemon that only THINKS
452
+ // it converged resets the streak early. That daemon self-heals the same way
453
+ // ownership does (armOwnershipWatch drains it once the mismatch is detected);
454
+ // worst case is one redundant reset in an already-rare race window, not a
455
+ // wrong steady-state outcome.
456
+ export function resetSpawnBackoff(): void {
457
+ try {
458
+ fs.unlinkSync(spawnBackoffPath());
459
+ } catch (e) {
460
+ if ((e as NodeJS.ErrnoException).code !== "ENOENT") {
461
+ process.stderr.write(
462
+ `cc-candybar: could not reset spawn.backoff: ${(e as Error).message}\n`,
463
+ );
464
+ }
465
+ }
466
+ }
467
+
346
468
  // [LAW:single-enforcer] The sole authority on daemon-spawn RATE. Returns true —
347
- // and RECORDS the attempt (updating spawn.cooldown's mtime to now) — when a
348
- // spawn is permitted; false when an attempt was recorded within
349
- // SPAWN_COOLDOWN_MS. Recording-on-grant (BEFORE the caller spawns) is
350
- // load-bearing: a spawn that then throws or returns false still counts against
351
- // the rate, so a broken binary is not retried in a tight loop. A future-mtime
352
- // garbage record warns loudly and falls toward ALLOWING the spawn
353
- // [LAW:no-silent-failure].
469
+ // and RECORDS the attempt (updating spawn.cooldown's mtime to now, advancing
470
+ // spawn.backoff's streak) — when a spawn is permitted; false when an attempt
471
+ // was recorded within the EFFECTIVE cooldown window (SPAWN_COOLDOWN_MS,
472
+ // widened by effectiveCooldownMs(streak) once consecutive attempts have
473
+ // failed to converge see brandon-daemon-lifecycle-gad.3). Recording-on-grant
474
+ // (BEFORE the caller spawns) is load-bearing: a spawn that then throws or
475
+ // returns false still counts against the rate, so a broken binary is not
476
+ // retried in a tight loop. A future-mtime garbage record warns loudly and
477
+ // falls toward ALLOWING the spawn [LAW:no-silent-failure].
354
478
  function claimSpawnCooldown(): boolean {
355
- const path = spawnCooldownPath();
356
- const decision = cooldownDecision(cooldownAgeMs(path));
479
+ const cooldownPath = spawnCooldownPath();
480
+ const backoffPath = spawnBackoffPath();
481
+ const streak = readBackoffStreak(backoffPath);
482
+ const decision = cooldownDecision(
483
+ cooldownAgeMs(cooldownPath),
484
+ effectiveCooldownMs(streak),
485
+ );
357
486
  if (decision.kind === "deny") return false;
358
487
  if (decision.kind === "allow-future-garbage") {
359
488
  process.stderr.write(
360
489
  `cc-candybar: spawn.cooldown mtime is ${decision.futureMs}ms in the future — ignoring and spawning\n`,
361
490
  );
362
491
  }
363
- recordSpawnAttempt(path);
492
+ recordSpawnAttempt(cooldownPath);
493
+ // [LAW:dataflow-not-control-flow] Every granted spawn advances the streak
494
+ // by exactly one, unconditionally — the cap lives in the read side
495
+ // (effectiveCooldownMs) and here (Math.min), never as a skip.
496
+ writeBackoffStreak(
497
+ backoffPath,
498
+ Math.min(streak + 1, SPAWN_BACKOFF_MAX_STREAK),
499
+ );
364
500
  return true;
365
501
  }
366
502
 
@@ -11,6 +11,7 @@ import {
11
11
  ConfigError,
12
12
  } from "../../config/dsl-loader.js";
13
13
  import type { ValidatedConfig } from "../../config/dsl-types.js";
14
+ import { DEFAULT_DSL_CONFIG } from "../../config/default-dsl-config.js";
14
15
  import { registerDslConfig, type CompiledConfig } from "../../dsl/render.js";
15
16
  import {
16
17
  deriveActionValidators,
@@ -272,7 +273,10 @@ export class RenderCache {
272
273
  // cross-ref diagnostics on the daemon path carry real line numbers and the
273
274
  // authored-surface (root vs layout) discriminator works — the file is read
274
275
  // once inside loadConfig, not re-read here.
275
- const { config: merged, source } = loadConfig(resolvedPath);
276
+ const { config: merged, source } = loadConfig(
277
+ resolvedPath,
278
+ DEFAULT_DSL_CONFIG,
279
+ );
276
280
  const config = validateConfig(merged, resolvedPath ?? "<default>", source);
277
281
 
278
282
  const store = new VariableStore();
@@ -185,6 +185,17 @@ export function spawnCooldownPath(): string {
185
185
  return path.join(stateDir(), SPAWN_COOLDOWN_FILE);
186
186
  }
187
187
 
188
+ // [LAW:one-source-of-truth] Sibling of spawn.cooldown: that file's mtime
189
+ // answers "when was a spawn last attempted"; this file's content answers
190
+ // "how many attempts in a row have failed to converge on a live daemon" —
191
+ // the consecutive-non-convergence streak that widens the cooldown window
192
+ // (see effectiveCooldownMs in acquire.ts). Same filename mirrored TS↔Rust,
193
+ // diffed by scripts/check-protocol.mjs.
194
+ const SPAWN_BACKOFF_FILE = "spawn.backoff";
195
+ export function spawnBackoffPath(): string {
196
+ return path.join(stateDir(), SPAWN_BACKOFF_FILE);
197
+ }
198
+
188
199
  export function logPath(): string {
189
200
  return path.join(stateDir(), "daemon.log");
190
201
  }
@@ -48,6 +48,7 @@ import { WatcherRegistry } from "./cache/watchers";
48
48
  import { RuntimeStats } from "./stats";
49
49
  import { makeLimits, realLimitsDeps, type LimitsHandle } from "./limits";
50
50
  import { armParentWatchdog, anchorFromEnv, pidAlive } from "./parent-watchdog";
51
+ import { resetSpawnBackoff } from "./acquire";
51
52
  import { SessionState } from "./session-state";
52
53
  import { FileSessionStorage } from "./session-state-file";
53
54
  import { VERBS, BadVerbArgs, SESSION_CONFIG_OVERRIDE_KEY } from "./verbs";
@@ -377,6 +378,11 @@ function onListening(sockPath: string): void {
377
378
  "info",
378
379
  `daemon up: pid=${process.pid} v=${PROTOCOL_VERSION} sock=${sockPath}`,
379
380
  );
381
+ // [LAW:single-enforcer] This bind is the one process-wide fact that answers
382
+ // "did an outage just end" — see resetSpawnBackoff's doc comment in
383
+ // acquire.ts. Any consecutive-spawn backoff accumulated getting here no
384
+ // longer applies once a daemon is actually serving.
385
+ resetSpawnBackoff();
380
386
  armBinaryWatch();
381
387
  armLimits();
382
388
  armOwnershipWatch(sockPath, boundRead.identity);
package/src/demo/dsl.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  mergeWithDefault,
26
26
  validateConfig,
27
27
  } from "../config/dsl-loader.js";
28
+ import { DEFAULT_DSL_CONFIG } from "../config/default-dsl-config.js";
28
29
  import { VariableStore } from "../var-system/store.js";
29
30
  import { SourceRegistry } from "../var-system/sources.js";
30
31
  import { SessionState } from "../daemon/session-state.js";
@@ -60,7 +61,7 @@ const source = readFileSync(configPath, "utf-8");
60
61
  // only `ValidatedConfig`, so the chain is type-enforced.
61
62
  const ALLOWED = new Set(listResolvablePaletteNames());
62
63
  const raw = parseDslConfig(configPath, source, ALLOWED);
63
- const merged = mergeWithDefault(raw);
64
+ const merged = mergeWithDefault(raw, DEFAULT_DSL_CONFIG);
64
65
  const config = validateConfig(merged, configPath, source, ALLOWED);
65
66
 
66
67
  // One Claude Code status-line hook event, faked. The `input` vars in the
package/src/index.ts CHANGED
@@ -53,12 +53,14 @@ Configuration:
53
53
  to point at a specific file. See the default config for all available options:
54
54
  node dist/index.mjs debug --project-dir . --cwd .
55
55
 
56
- Subcommands (macOS):
57
- install One-shot setup: creates the URL handler app, registers
58
- the cc-candybar:// scheme, and writes the statusLine
59
- command into ~/.claude/settings.json.
60
- install-url-handler Just create + register the URL handler app
61
- (~/Applications/CCCandybarURLHandler.app).
56
+ Subcommands:
57
+ install One-shot setup: stages the runtime (native render
58
+ binary + dist bundle) at a stable path, creates the
59
+ URL handler app + cc-candybar:// scheme (macOS), and
60
+ writes the staged entry as the statusLine command in
61
+ ~/.claude/settings.json. Re-run to update.
62
+ install-url-handler Just stage the runtime and create + register the URL
63
+ handler app (macOS only).
62
64
  url-handle URL Internal — invoked by the URL handler app on
63
65
  cmd-click. Parses cc-candybar://<verb>/<value> and
64
66
  dispatches (currently: copy to clipboard).