@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/README.md +13 -9
- package/bin/cc-candybar +18 -6
- package/dist/index.mjs +91 -88
- package/package.json +6 -22
- package/src/check.ts +2 -1
- package/src/config/default-dsl-config.ts +120 -10
- package/src/config/dsl-loader.ts +13 -5
- package/src/config/loader/merge.ts +15 -7
- package/src/daemon/acquire.ts +148 -12
- package/src/daemon/cache/render.ts +5 -1
- package/src/daemon/paths.ts +11 -0
- package/src/daemon/server.ts +6 -0
- package/src/demo/dsl.ts +2 -1
- package/src/index.ts +8 -6
- package/src/install/index.ts +207 -85
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@promptctl/cc-candybar",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
99
|
-
"@promptctl/cc-candybar-darwin-x64": "1.
|
|
100
|
-
"@promptctl/cc-candybar-linux-x64": "1.
|
|
101
|
-
"@promptctl/cc-candybar-linux-arm64": "1.
|
|
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]
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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)
|
|
829
|
-
//
|
|
830
|
-
//
|
|
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
|
+
);
|
package/src/config/dsl-loader.ts
CHANGED
|
@@ -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
|
|
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
|
|
85
|
-
* (uniform merge against an empty raw, which is deep-equal to
|
|
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
|
|
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
|
|
2
|
-
// fill missing keys. A user file declares only
|
|
3
|
-
// (shallow-merge globals, by-name merge
|
|
4
|
-
// root replacement) is the one place
|
|
5
|
-
// This file changes when the merge
|
|
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
|
|
32
|
+
dflt: DslConfig,
|
|
25
33
|
): DslConfig {
|
|
26
34
|
return {
|
|
27
35
|
globals: { ...dflt.globals, ...(raw.globals ?? {}) },
|
package/src/daemon/acquire.ts
CHANGED
|
@@ -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
|
-
|
|
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 <
|
|
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
|
|
348
|
-
// spawn is permitted; false when an attempt
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
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
|
|
356
|
-
const
|
|
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(
|
|
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(
|
|
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();
|
package/src/daemon/paths.ts
CHANGED
|
@@ -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
|
}
|
package/src/daemon/server.ts
CHANGED
|
@@ -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
|
|
57
|
-
install One-shot setup:
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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).
|