@promptctl/cc-candybar 1.42.1 → 1.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/dist/index.mjs +72 -71
  2. package/package.json +5 -6
  3. package/src/check.ts +0 -478
  4. package/src/cli-flags.ts +0 -8
  5. package/src/click/wire.ts +0 -158
  6. package/src/config/action.ts +0 -329
  7. package/src/config/cli.ts +0 -71
  8. package/src/config/default-dsl-config.ts +0 -1645
  9. package/src/config/disclosure.ts +0 -170
  10. package/src/config/dsl-loader.ts +0 -339
  11. package/src/config/dsl-types.ts +0 -581
  12. package/src/config/edit-chrome.ts +0 -559
  13. package/src/config/help.ts +0 -151
  14. package/src/config/ident.ts +0 -22
  15. package/src/config/layout-ops.ts +0 -177
  16. package/src/config/loader/actions.ts +0 -972
  17. package/src/config/loader/cache.ts +0 -206
  18. package/src/config/loader/cross-ref.ts +0 -714
  19. package/src/config/loader/cycles.ts +0 -148
  20. package/src/config/loader/diagnostics.ts +0 -99
  21. package/src/config/loader/discovery.ts +0 -182
  22. package/src/config/loader/edit-mode.ts +0 -137
  23. package/src/config/loader/emit-schema.ts +0 -68
  24. package/src/config/loader/globals.ts +0 -269
  25. package/src/config/loader/helpers.ts +0 -48
  26. package/src/config/loader/layout.ts +0 -693
  27. package/src/config/loader/looks.ts +0 -96
  28. package/src/config/loader/menu-synth.ts +0 -435
  29. package/src/config/loader/merge.ts +0 -115
  30. package/src/config/loader/persist-target.ts +0 -67
  31. package/src/config/loader/presets.ts +0 -119
  32. package/src/config/loader/refs.ts +0 -100
  33. package/src/config/loader/reserved-namespace.ts +0 -38
  34. package/src/config/loader/segments.ts +0 -120
  35. package/src/config/loader/validate-core.ts +0 -737
  36. package/src/config/loader/variables.ts +0 -260
  37. package/src/config/menu-keys.ts +0 -139
  38. package/src/config/option-domain.ts +0 -164
  39. package/src/config/presets.ts +0 -326
  40. package/src/config/settings-menu.ts +0 -775
  41. package/src/daemon/acquire.ts +0 -684
  42. package/src/daemon/cache/git.ts +0 -649
  43. package/src/daemon/cache/render.ts +0 -623
  44. package/src/daemon/cache/session-usage-store.ts +0 -720
  45. package/src/daemon/cache/watchers.ts +0 -249
  46. package/src/daemon/client-debug.ts +0 -120
  47. package/src/daemon/client-stats.ts +0 -130
  48. package/src/daemon/client-transport.ts +0 -273
  49. package/src/daemon/client.ts +0 -78
  50. package/src/daemon/config-overrides-store.ts +0 -663
  51. package/src/daemon/debug-types.ts +0 -91
  52. package/src/daemon/debug.ts +0 -264
  53. package/src/daemon/fork-bomb-breaker.ts +0 -351
  54. package/src/daemon/limits.ts +0 -211
  55. package/src/daemon/log.ts +0 -81
  56. package/src/daemon/parent-watchdog.ts +0 -87
  57. package/src/daemon/paths.ts +0 -211
  58. package/src/daemon/process-fingerprint.ts +0 -146
  59. package/src/daemon/protocol.ts +0 -292
  60. package/src/daemon/render-payload.ts +0 -1256
  61. package/src/daemon/server.ts +0 -1330
  62. package/src/daemon/session-state-file.ts +0 -108
  63. package/src/daemon/session-state.ts +0 -237
  64. package/src/daemon/socket-lease.ts +0 -209
  65. package/src/daemon/socket-ownership.ts +0 -209
  66. package/src/daemon/stats.ts +0 -235
  67. package/src/daemon/verbs/config-validators.ts +0 -250
  68. package/src/daemon/verbs/index.ts +0 -706
  69. package/src/daemon/verbs/state-validators.ts +0 -249
  70. package/src/daemon/verbs/validator-registry.ts +0 -457
  71. package/src/demo/dsl.ts +0 -143
  72. package/src/demo/mock-data.ts +0 -67
  73. package/src/demo/statusline.json5 +0 -94
  74. package/src/dsl/node-registry.ts +0 -374
  75. package/src/dsl/render.ts +0 -803
  76. package/src/help-text.ts +0 -90
  77. package/src/index.ts +0 -210
  78. package/src/install/currency.ts +0 -197
  79. package/src/install/index.ts +0 -557
  80. package/src/proc/launch.ts +0 -459
  81. package/src/proc/stats-handle.ts +0 -13
  82. package/src/render/action.ts +0 -883
  83. package/src/render/active-segment.ts +0 -78
  84. package/src/render/diagnostic-style.ts +0 -23
  85. package/src/render/diagnostic-text.ts +0 -77
  86. package/src/render/error-glyph.ts +0 -53
  87. package/src/render/menu.ts +0 -257
  88. package/src/render/outcome-plan.ts +0 -45
  89. package/src/render/picker.ts +0 -372
  90. package/src/render/segment-color.ts +0 -74
  91. package/src/render/split-lines.ts +0 -51
  92. package/src/render/strip.ts +0 -228
  93. package/src/segments/cache.ts +0 -131
  94. package/src/segments/context.ts +0 -190
  95. package/src/segments/git.ts +0 -1084
  96. package/src/segments/metrics.ts +0 -187
  97. package/src/segments/pricing.ts +0 -452
  98. package/src/segments/session.ts +0 -23
  99. package/src/segments/tmux.ts +0 -74
  100. package/src/template-engine/cells.ts +0 -90
  101. package/src/template-engine/colors.ts +0 -124
  102. package/src/template-engine/engine.ts +0 -108
  103. package/src/template-engine/funcs.ts +0 -232
  104. package/src/template-engine/index.ts +0 -11
  105. package/src/template-engine/layout.ts +0 -133
  106. package/src/template-engine/scope.ts +0 -62
  107. package/src/template-engine/sparkline.ts +0 -79
  108. package/src/themes/index.ts +0 -20
  109. package/src/themes/palette-resolvers.ts +0 -84
  110. package/src/themes/policy.ts +0 -393
  111. package/src/utils/cache.ts +0 -206
  112. package/src/utils/claude.ts +0 -683
  113. package/src/utils/color-support.ts +0 -118
  114. package/src/utils/formatters.ts +0 -99
  115. package/src/utils/logger.ts +0 -5
  116. package/src/utils/outcome.ts +0 -33
  117. package/src/utils/schema-validator.ts +0 -126
  118. package/src/utils/single-flight.ts +0 -57
  119. package/src/utils/terminal-width.ts +0 -51
  120. package/src/utils/terminal.ts +0 -11
  121. package/src/utils/transcript-fs.ts +0 -279
  122. package/src/var-system/index.ts +0 -24
  123. package/src/var-system/sources.ts +0 -1047
  124. package/src/var-system/store.ts +0 -223
  125. package/src/var-system/types.ts +0 -57
  126. package/src/version.ts +0 -17
@@ -1,209 +0,0 @@
1
- import fs from "node:fs";
2
- import { type DaemonLogger } from "./log";
3
- import { type LeaseRead } from "./socket-lease";
4
-
5
- // ─── Socket ownership self-check ─────────────────────────────────────────────
6
- //
7
- // [LAW:single-enforcer] "Serving implies owning the socket path" is a temporal
8
- // invariant every daemon component assumes and none enforced. The socket-theft
9
- // storm (brandon-daemon-lifecycle-2b3) proved it false: a daemon whose socket
10
- // was unlinked + rebound by a reclaimer kept running forever (no idle shutdown,
11
- // no watchdog in production, RSS backstop only), holding ~400MB each — 28
12
- // daemons bound in one hour with ZERO voluntary exits. This module is that
13
- // invariant's single enforcer: a displaced daemon exits within one check
14
- // interval, making ANY displacement self-healing regardless of which bug caused
15
- // it.
16
- //
17
- // [FRAMING:representation] Ownership of a filesystem entry IS its kernel
18
- // identity: (st_dev, st_ino). We never unlink + rebind our OWN socket during our
19
- // lifetime, so that identity is stable for as long as we hold the path. A
20
- // reclaimer that displaces us unlinks the path and binds a fresh socket → a NEW
21
- // inode (or, briefly, no file at all). So "the identity now equals the identity
22
- // I bound" is the exact, load-independent test for continued ownership — unlike
23
- // a connect probe, which the sibling .1 removed precisely because it lied under
24
- // load.
25
- //
26
- // [FRAMING:representation] The inode alone leaves ONE hole (RESIDUAL 2, the
27
- // capture race): if a thief completes unlink + rebind inside the sub-millisecond
28
- // window between our bind() and our 'listening' callback, we stat the path and
29
- // capture the THIEF's inode as "ours" — so the inode check reads `owned` forever
30
- // against the wrong identity. We cannot make that captured inode trustworthy (an
31
- // AF_UNIX listener's fstat reports a different namespace's inode than stat(path),
32
- // so there is no race-free "the inode I truly bound"). But a real thief, being a
33
- // daemon, writes its OWN pid into the lease. So ownership requires a SECOND
34
- // condition: the lease must still name us. A displacer must touch one of the two
35
- // representations — steal the socket (inode changes) or overwrite the lease (pid
36
- // changes) — so requiring BOTH drains any orphan within a bounded number of
37
- // intervals. No live process can share our live pid, so the lease pid alone
38
- // distinguishes "the lease still names me" (the start-time fingerprint is only
39
- // needed by the sibling arbitration, which reasons about a possibly-DEAD pid).
40
-
41
- // The kernel identity of the bound socket file. (dev, ino) is the unique
42
- // identity of a filesystem entry; ino alone can be reused across devices.
43
- export interface SocketIdentity {
44
- dev: number;
45
- ino: number;
46
- }
47
-
48
- // What re-reading the socket path's identity yielded. Distinguishing
49
- // present / absent / unreadable keeps the ownership decision a full enumeration
50
- // over raw inputs (see checkOwnership) rather than collapsing the ambiguous
51
- // cases early — the same shape as socket-lease.ts's LeaseRead.
52
- export type IdentityRead =
53
- | { kind: "present"; identity: SocketIdentity }
54
- | { kind: "absent" }
55
- | { kind: "unreadable"; detail: string };
56
-
57
- // The self-check outcome. `owned` is the ONLY proof-of-ownership state; every
58
- // other input maps to `displaced` (fail toward exit).
59
- export type OwnershipCheck =
60
- | { kind: "owned" }
61
- | { kind: "displaced"; reason: string };
62
-
63
- export const DEFAULT_OWNERSHIP_CHECK_INTERVAL_MS = 5000;
64
-
65
- // [LAW:no-silent-failure] The sole effect (statSync) is lifted to a typed
66
- // boundary read: ENOENT → `absent` (the path was unlinked, not yet rebound),
67
- // any other error → `unreadable` with the reason inline, never a swallowed
68
- // default. statSync (not lstatSync) follows a symlink deliberately — it reads
69
- // the identity of whatever a CLIENT connecting to the path would actually reach,
70
- // which is exactly what "do I still own what clients hit" asks.
71
- export function readSocketIdentity(sockPath: string): IdentityRead {
72
- try {
73
- const st = fs.statSync(sockPath);
74
- return { kind: "present", identity: { dev: st.dev, ino: st.ino } };
75
- } catch (e) {
76
- const code = (e as NodeJS.ErrnoException).code;
77
- if (code === "ENOENT") return { kind: "absent" };
78
- return { kind: "unreadable", detail: (e as Error).message };
79
- }
80
- }
81
-
82
- // [LAW:effects-at-boundaries][LAW:dataflow-not-control-flow] A pure fold:
83
- // (identity captured at bind, current identity read, my pid, current lease read)
84
- // → decision. No effects, so every branch is exercised by input enumeration.
85
- // Ownership is the CONJUNCTION of two conditions; either failing → displaced:
86
- // inode: present + same identity as bound (we still hold the path we bound)
87
- // lease: owned + pid == mine (the lease still names us)
88
- // Inode failures: different inode / absent / unreadable → a reclaimer displaced
89
- // us (or we cannot prove otherwise). Lease failures: a thief overwrote the
90
- // lease with its own pid, or the lease vanished/broke — either way we no
91
- // longer hold the authority.
92
- //
93
- // [FRAMING:representation] Only "still holding my bound socket AND still named by
94
- // the lease" proves ownership; anything short is `displaced`. This is the
95
- // strongest-true theorem for "serving implies owning", checked against BOTH
96
- // mutable representations a displacer can touch. The failure direction is
97
- // deliberately safe — a false `displaced` (exit while actually owning) just lets
98
- // the real owner serve and the next client respawns; a false `owned` is the
99
- // immortal orphan we are killing. Always err toward exit.
100
- export function checkOwnership(
101
- bound: SocketIdentity,
102
- now: IdentityRead,
103
- myPid: number,
104
- lease: LeaseRead,
105
- ): OwnershipCheck {
106
- const inodeFailure = inodeDisplacement(bound, now);
107
- if (inodeFailure !== null) {
108
- return { kind: "displaced", reason: inodeFailure };
109
- }
110
- if (lease.kind !== "owned") {
111
- return {
112
- kind: "displaced",
113
- reason: `lease not held (${lease.kind}) — no longer the socket owner`,
114
- };
115
- }
116
- if (lease.pid !== myPid) {
117
- return {
118
- kind: "displaced",
119
- reason: `lease reassigned (names pid=${lease.pid}, we are pid=${myPid}) — a reclaimer overwrote it`,
120
- };
121
- }
122
- return { kind: "owned" };
123
- }
124
-
125
- // Returns the displacement reason if the current path identity no longer matches
126
- // what we bound, or null if the inode still proves ownership.
127
- function inodeDisplacement(
128
- bound: SocketIdentity,
129
- now: IdentityRead,
130
- ): string | null {
131
- if (now.kind === "present") {
132
- if (now.identity.dev === bound.dev && now.identity.ino === bound.ino) {
133
- return null;
134
- }
135
- return `socket replaced (bound dev=${bound.dev} ino=${bound.ino}, now dev=${now.identity.dev} ino=${now.identity.ino})`;
136
- }
137
- if (now.kind === "absent") {
138
- return "socket path gone (ENOENT) — unlinked by a reclaimer";
139
- }
140
- return `socket path unreadable (${now.detail}) — cannot prove ownership`;
141
- }
142
-
143
- // [LAW:locality-or-seam] The identity read, the shutdown funnel, and the log
144
- // sink are injected, not reached for ambiently — so a unit test drives every
145
- // branch (swap the inode, delete the path) against a fake shutdown with no real
146
- // daemon, mirroring makeLimits. The real wiring injects
147
- // `() => readSocketIdentity(sockPath)` and the daemon's single shutdown().
148
- export interface OwnershipWatchDeps {
149
- bound: SocketIdentity;
150
- myPid: number;
151
- readIdentity: () => IdentityRead;
152
- readLease: () => LeaseRead;
153
- shutdown: (code: number) => void;
154
- log: DaemonLogger;
155
- intervalMs?: number;
156
- }
157
-
158
- export interface OwnershipWatchHandle {
159
- // Run one check now; funnels through shutdown(0) on the first `displaced`.
160
- // Returns the decision so callers/tests can assert without timers.
161
- check(): OwnershipCheck;
162
- arm(intervalMs?: number): { disarm(): void };
163
- }
164
-
165
- export function makeOwnershipWatch(
166
- deps: OwnershipWatchDeps,
167
- ): OwnershipWatchHandle {
168
- // [LAW:single-enforcer] Latch like limits' `triggered`: displacement funnels
169
- // shutdown exactly once (one log line, one shutdown call), even though
170
- // shutdown() is itself idempotent. Serving implies owning through ONE exit.
171
- let displaced = false;
172
-
173
- function check(): OwnershipCheck {
174
- const decision = checkOwnership(
175
- deps.bound,
176
- deps.readIdentity(),
177
- deps.myPid,
178
- deps.readLease(),
179
- );
180
- if (decision.kind === "displaced" && !displaced) {
181
- displaced = true;
182
- deps.log(
183
- "warn",
184
- `ownership self-check: ${decision.reason}; shutting down`,
185
- );
186
- deps.shutdown(0);
187
- }
188
- return decision;
189
- }
190
-
191
- function arm(
192
- intervalMs: number = deps.intervalMs ?? DEFAULT_OWNERSHIP_CHECK_INTERVAL_MS,
193
- ): { disarm(): void } {
194
- const timer = setInterval(() => {
195
- // [LAW:no-ambient-temporal-coupling] Self-disarm once displaced: the watch
196
- // has funneled its single shutdown, so stop polling — no zombie statSync
197
- // fires during the shutdown window (a hung shutdown reaches its SIGKILL
198
- // backstop at 500ms). The timer's whole lifecycle lives here in arm(),
199
- // mirroring armBinaryWatch which clears its interval before shutting down.
200
- if (check().kind === "displaced") clearInterval(timer);
201
- }, intervalMs);
202
- // unref so the check never keeps the process alive on its own — it only ever
203
- // hastens an exit, never delays one.
204
- timer.unref();
205
- return { disarm: () => clearInterval(timer) };
206
- }
207
-
208
- return { check, arm };
209
- }
@@ -1,235 +0,0 @@
1
- // [LAW:single-enforcer] One mutator owns runtime counters. Server, caches, and
2
- // watchers each receive a tiny handle they're allowed to bump, but the
3
- // canonical object lives here. Stats are read-only after serialization.
4
-
5
- import type { LaunchCategory } from "../proc/launch";
6
- import type { LaunchStatsHandle } from "../proc/stats-handle";
7
- import { PROTOCOL_VERSION } from "./protocol";
8
- import { PACKAGE_VERSION } from "../version";
9
-
10
- // Rolling window for "last minute" counts. Keep timestamps for each launch in
11
- // a ring buffer; eviction happens lazily on read.
12
- //
13
- // [LAW:dataflow-not-control-flow] The cap is a hard bound on how many
14
- // launches can be counted in any 60-second window. Bursts above
15
- // ROLLING_BUFFER_CAP / (ROLLING_WINDOW_MS/1000) launches/sec overwrite
16
- // timestamps that are still inside the window, causing `lastMinute` to
17
- // undercount. At 16384 entries / 60s = ~273 sustained launches/sec the
18
- // undercount only kicks in under pathological load (well past anything kz8
19
- // is trying to detect). Bump this cap rather than the comment if a future
20
- // workload ever sustains higher rates.
21
- const ROLLING_WINDOW_MS = 60_000;
22
- const ROLLING_BUFFER_CAP = 16384;
23
-
24
- // Reservoir-sample histogram (16 entries) per category — sufficient for
25
- // rough p50/p99 without unbounded memory. Replacement uses a simple
26
- // counter-mod-N strategy: deterministic and adequate for human-eyeball
27
- // dashboards (no statistical claim of unbiased sampling).
28
- const HISTOGRAM_CAP = 16;
29
-
30
- export interface StatsSnapshot {
31
- pid: number;
32
- // [LAW:one-source-of-truth] The daemon's own baked package stamp — set
33
- // against `cc-candybar --version` to diagnose client-vs-daemon skew in one
34
- // step. The wire contract number is a different fact under its own name.
35
- version: string;
36
- protocolVersion: number;
37
- startedAt: string;
38
- uptimeSec: number;
39
- rssBytes: number;
40
- heapUsedBytes: number;
41
- heapTotalBytes: number;
42
- externalBytes: number;
43
- arrayBuffersBytes: number;
44
- requests: {
45
- total: number;
46
- errored: number;
47
- timedOut: number;
48
- inFlight: number;
49
- };
50
- gitCache: {
51
- size: number;
52
- hits: number;
53
- misses: number;
54
- invalidations: number;
55
- watchers: number;
56
- };
57
- usageCache: {
58
- size: number;
59
- hits: number;
60
- misses: number;
61
- sweeps: number;
62
- };
63
- renderCache: {
64
- size: number;
65
- };
66
- watchers: {
67
- active: number;
68
- opened: number;
69
- closed: number;
70
- evicted: number;
71
- };
72
- subprocesses: {
73
- total: number;
74
- inFlight: number;
75
- lastMinute: number;
76
- byCategory: Record<string, number>;
77
- p50DurationMs: Record<string, number>;
78
- p99DurationMs: Record<string, number>;
79
- };
80
- nextRestartReason: string | null;
81
- }
82
-
83
- export class RuntimeStats {
84
- readonly startedAt = new Date();
85
- requestsTotal = 0;
86
- requestsErrored = 0;
87
- requestsTimedOut = 0;
88
- inFlight = 0;
89
-
90
- watchersOpened = 0;
91
- watchersClosed = 0;
92
- watchersEvicted = 0;
93
-
94
- // [LAW:dataflow-not-control-flow] Subprocess metering. Every launch carries
95
- // its category through one boundary; the per-category state below is a
96
- // function of that data, not of which call site fired.
97
- subprocessTotal = 0;
98
- subprocessInFlight = 0;
99
- private readonly subprocessCount = new Map<LaunchCategory, number>();
100
- private readonly subprocessHistogram = new Map<LaunchCategory, number[]>();
101
- private readonly subprocessHistogramRotator = new Map<
102
- LaunchCategory,
103
- number
104
- >();
105
- private readonly rollingTimestamps: number[] = [];
106
- private rollingHead = 0;
107
-
108
- // [LAW:one-source-of-truth] The same object that owns the counters also
109
- // exposes the metering handle. The launch primitive calls these two
110
- // methods; nothing else mutates subprocess state.
111
- readonly launchStats: LaunchStatsHandle = {
112
- onStart: (category) => {
113
- this.subprocessTotal++;
114
- this.subprocessInFlight++;
115
- this.subprocessCount.set(
116
- category,
117
- (this.subprocessCount.get(category) ?? 0) + 1,
118
- );
119
- this.recordRollingNow();
120
- },
121
- onEnd: (category, durationMs) => {
122
- this.subprocessInFlight = Math.max(0, this.subprocessInFlight - 1);
123
- this.recordDuration(category, durationMs);
124
- },
125
- };
126
-
127
- private recordRollingNow(): void {
128
- const now = Date.now();
129
- if (this.rollingTimestamps.length < ROLLING_BUFFER_CAP) {
130
- this.rollingTimestamps.push(now);
131
- return;
132
- }
133
- // Ring buffer once cap hit. The overwritten slot may still be inside the
134
- // 60s window — see the cap-rationale comment at ROLLING_BUFFER_CAP.
135
- this.rollingTimestamps[this.rollingHead] = now;
136
- this.rollingHead = (this.rollingHead + 1) % ROLLING_BUFFER_CAP;
137
- }
138
-
139
- private recordDuration(category: LaunchCategory, durationMs: number): void {
140
- let hist = this.subprocessHistogram.get(category);
141
- if (!hist) {
142
- hist = [];
143
- this.subprocessHistogram.set(category, hist);
144
- }
145
- if (hist.length < HISTOGRAM_CAP) {
146
- hist.push(durationMs);
147
- return;
148
- }
149
- const idx =
150
- (this.subprocessHistogramRotator.get(category) ?? 0) % HISTOGRAM_CAP;
151
- hist[idx] = durationMs;
152
- this.subprocessHistogramRotator.set(category, idx + 1);
153
- }
154
-
155
- private snapshotSubprocesses(): StatsSnapshot["subprocesses"] {
156
- const cutoff = Date.now() - ROLLING_WINDOW_MS;
157
- let lastMinute = 0;
158
- for (const ts of this.rollingTimestamps) {
159
- if (ts >= cutoff) lastMinute++;
160
- }
161
-
162
- // [LAW:one-source-of-truth] Snapshot includes only categories that have
163
- // actually executed — same shape as p50/p99 below. Consumers wanting the
164
- // full closed list can read LAUNCH_CATEGORIES (exported from src/proc/launch)
165
- // and treat missing keys as zero.
166
- const byCategory: Record<string, number> = {};
167
- for (const [cat, n] of this.subprocessCount) {
168
- if (n > 0) byCategory[cat] = n;
169
- }
170
-
171
- const p50: Record<string, number> = {};
172
- const p99: Record<string, number> = {};
173
- for (const [cat, hist] of this.subprocessHistogram) {
174
- if (hist.length === 0) continue;
175
- const sorted = [...hist].sort((a, b) => a - b);
176
- const p50idx = Math.floor(sorted.length * 0.5);
177
- const p99idx = Math.min(
178
- sorted.length - 1,
179
- Math.floor(sorted.length * 0.99),
180
- );
181
- const p50val = sorted[p50idx];
182
- const p99val = sorted[p99idx];
183
- if (p50val !== undefined) p50[cat] = p50val;
184
- if (p99val !== undefined) p99[cat] = p99val;
185
- }
186
-
187
- return {
188
- total: this.subprocessTotal,
189
- inFlight: this.subprocessInFlight,
190
- lastMinute,
191
- byCategory,
192
- p50DurationMs: p50,
193
- p99DurationMs: p99,
194
- };
195
- }
196
-
197
- snapshot(extras: {
198
- gitCache: StatsSnapshot["gitCache"];
199
- usageCache: StatsSnapshot["usageCache"];
200
- renderCacheSize: number;
201
- watchersActive: number;
202
- nextRestartReason?: string | null;
203
- }): StatsSnapshot {
204
- const mem = process.memoryUsage();
205
- return {
206
- pid: process.pid,
207
- version: PACKAGE_VERSION,
208
- protocolVersion: PROTOCOL_VERSION,
209
- startedAt: this.startedAt.toISOString(),
210
- uptimeSec: Math.floor((Date.now() - this.startedAt.getTime()) / 1000),
211
- rssBytes: mem.rss,
212
- heapUsedBytes: mem.heapUsed,
213
- heapTotalBytes: mem.heapTotal,
214
- externalBytes: mem.external,
215
- arrayBuffersBytes: mem.arrayBuffers,
216
- requests: {
217
- total: this.requestsTotal,
218
- errored: this.requestsErrored,
219
- timedOut: this.requestsTimedOut,
220
- inFlight: this.inFlight,
221
- },
222
- gitCache: extras.gitCache,
223
- usageCache: extras.usageCache,
224
- renderCache: { size: extras.renderCacheSize },
225
- watchers: {
226
- active: extras.watchersActive,
227
- opened: this.watchersOpened,
228
- closed: this.watchersClosed,
229
- evicted: this.watchersEvicted,
230
- },
231
- subprocesses: this.snapshotSubprocesses(),
232
- nextRestartReason: extras.nextRestartReason ?? null,
233
- };
234
- }
235
- }
@@ -1,250 +0,0 @@
1
- // [LAW:one-source-of-truth] The persistent-config-write instance of the
2
- // shared keyed-validator registry (validator-registry.ts) — the twin of
3
- // state-validators.ts for `persist` actions instead of `set` actions.
4
- // Gates stay derived the same way as session writes: a `persist` action
5
- // carries its target key and value SOURCE as literal data, so the
6
- // writable-key gate DERIVES from the action table exactly like
7
- // deriveActionValidators does for `set`. No baseline keys: every config
8
- // globals field becomes writable ONLY when a config declares a `persist`
9
- // action for it — the epic's "zero engine edits to add a menu-able field"
10
- // goal, realized more strictly here than SessionState's legacy baseline
11
- // theme/style/toolbar-expanded keys.
12
-
13
- import { actionDestinations, type ActionDecl } from "../../config/action";
14
- import {
15
- perConfigDomainsFor,
16
- resolveOptionDomain,
17
- } from "../../config/option-domain";
18
- import { addableSegmentDomains } from "../../config/edit-chrome";
19
- import type { DslConfig } from "../../config/dsl-types";
20
- import { numericGlobalsSeeds } from "../../config/loader/globals";
21
- import { encodeLayoutOp } from "../../config/layout-ops";
22
- import {
23
- parsePersistTarget,
24
- presetRootOpsKey,
25
- } from "../../config/loader/persist-target";
26
- import {
27
- clampSeed,
28
- createValidatorRegistry,
29
- mergeContributions,
30
- type DerivedValidatorSpec,
31
- type KeySpecContribution,
32
- type RangeParams,
33
- type ValidateResult,
34
- } from "./validator-registry";
35
-
36
- // [LAW:no-silent-fallbacks] No baseline entries: a config globals field is
37
- // writable ONLY when some `persist` action names it.
38
- const registry = createValidatorRegistry({}, "config");
39
-
40
- export function listConfigKeys(): readonly string[] {
41
- return registry.listKeys();
42
- }
43
-
44
- export function validateConfigWrite(
45
- key: string,
46
- rawValue: string,
47
- ): ValidateResult {
48
- return registry.validate(key, rawValue);
49
- }
50
-
51
- export function rangeParamsForConfig(key: string): RangeParams | null {
52
- return registry.rangeParamsFor(key);
53
- }
54
-
55
- // [LAW:one-source-of-truth] The ONE place mapping a `persist` ACTION to the
56
- // validator key SPEC it declares — the mirror of state-validators.ts's
57
- // actionKeySpecs for `set`. `persist` has no `int` arm: a page cursor is a
58
- // UI-only paging concept with no meaning as a persisted config default.
59
- function actionKeySpecs(
60
- a: ActionDecl,
61
- seeds: ReadonlyMap<string, number>,
62
- perConfigDomains: ReadonlyMap<string, readonly string[]>,
63
- ): KeySpecContribution[] {
64
- if (!("persist" in a)) return [];
65
- if ("to" in a) {
66
- return [{ key: a.persist, spec: { kind: "allow-list", allowed: [a.to] } }];
67
- }
68
- if ("from" in a) {
69
- return [
70
- {
71
- key: a.persist,
72
- spec: {
73
- kind: "allow-list",
74
- allowed: resolveOptionDomain(a.from, perConfigDomains),
75
- },
76
- },
77
- ];
78
- }
79
- if ("cycle" in a) {
80
- return [{ key: a.persist, spec: { kind: "allow-list", allowed: a.cycle } }];
81
- }
82
- // [LAW:single-enforcer] brandon-layout-edit-2gc.1's structural-edit arms:
83
- // the op is fully literal at config-author time (removeSegment's target,
84
- // insertSegment's segment/anchor/relation), so — exactly like a literal
85
- // `to` — there is exactly ONE legal value this declared action can ever
86
- // request: its own encoded op token. Multiple layout actions targeting the
87
- // same "presets.<name>.rootOps" key each contribute one allow-list member,
88
- // unioned by mergeContributions below, same as multiple `to` actions on
89
- // one key already do.
90
- if ("removeSegment" in a) {
91
- return [
92
- {
93
- key: a.persist,
94
- spec: {
95
- kind: "allow-list",
96
- allowed: [encodeLayoutOp({ op: "remove", target: a.removeSegment })],
97
- },
98
- },
99
- ];
100
- }
101
- if ("insertSegment" in a) {
102
- return [
103
- {
104
- key: a.persist,
105
- spec: {
106
- kind: "allow-list",
107
- allowed: [
108
- encodeLayoutOp({
109
- op: "insert",
110
- segment: a.insertSegment,
111
- anchor: a.anchor,
112
- relation: a.relation,
113
- }),
114
- ],
115
- },
116
- },
117
- ];
118
- }
119
- // [LAW:one-source-of-truth] brandon-layout-edit-2gc.3's domain-sourced
120
- // sibling: the allow-list is the ENCODED op token for every domain member,
121
- // not the raw member — mirroring how a literal `insertSegment` contributes
122
- // its own single encoded token above. A click carrying an option this
123
- // domain never named — or naming a real segment but the wrong anchor/
124
- // relation — cannot decode to a member of this list, so it is rejected the
125
- // same loud way an unknown literal op token already is.
126
- if ("insertSegmentFrom" in a) {
127
- return [
128
- {
129
- key: a.persist,
130
- spec: {
131
- kind: "allow-list",
132
- allowed: resolveOptionDomain(
133
- a.insertSegmentFrom,
134
- perConfigDomains,
135
- ).map((segment) =>
136
- encodeLayoutOp({
137
- op: "insert",
138
- segment,
139
- anchor: a.anchor,
140
- relation: a.relation,
141
- }),
142
- ),
143
- },
144
- },
145
- ];
146
- }
147
- return [
148
- {
149
- key: a.persist,
150
- spec: {
151
- kind: "range",
152
- min: a.min,
153
- max: a.max,
154
- seed: clampSeed(seeds.get(a.persist), a.min, a.max),
155
- },
156
- },
157
- ];
158
- }
159
-
160
- // [LAW:one-source-of-truth] The unset seed for a bounded `persist` key (e.g.
161
- // a padding stepper) is the value that would render with NO override yet —
162
- // the merged config's OWN globals field, before this reload's overrides are
163
- // applied. Mirrors stateKeySeeds' "the bar's current display, not silently
164
- // min" rule for SessionState steppers.
165
- function configKeySeeds(config: DslConfig): ReadonlyMap<string, number> {
166
- return numericGlobalsSeeds(config.globals);
167
- }
168
-
169
- // [LAW:one-source-of-truth] Every preset a config's action table ALREADY
170
- // targets via a `presets.<name>.rootOps` key (persist OR reset — the
171
- // contribution is keyed off intent to use structural editing for that
172
- // preset, not off "this config has any presets block") stays a
173
- // registered key EVEN when that preset's CURRENT tree has no
174
- // addable/removable segment for removeChrome/insertChrome to contribute
175
- // from. Without this, a preset edited down to zero non-exempt segments
176
- // (spliceContainer then contributes nothing for it at all) would orphan
177
- // its OWN reset action: the one affordance meant to undo a fully-emptied
178
- // preset would throw "unknown config key" at the exact moment it's needed
179
- // most (brandon-layout-edit-2gc.5 PR review). An EMPTY allow-list
180
- // registers the key (so `reset-config`'s membership check —
181
- // src/daemon/verbs/index.ts's resetConfig — passes) without granting any
182
- // illegitimate WRITE: a real persist write still needs a real
183
- // removeSegment/insertSegment/insertSegmentFrom action elsewhere;
184
- // mergeContributions unions an empty array with whatever those contribute.
185
- //
186
- // [LAW:no-mode-explosion] Deliberately narrower than "every declared
187
- // preset" — a config with a `presets` block but ZERO persist/reset actions
188
- // over it has no structural-editing surface at all, so it registers
189
- // nothing here, preserving this module's own "zero baseline keys" floor
190
- // (a globals field, and now a preset's rootOps, is writable only because
191
- // SOME action names it).
192
- function presetRootOpsContributions(config: DslConfig): KeySpecContribution[] {
193
- const presets = new Set<string>();
194
- for (const a of writeDestinations(config)) {
195
- const key = "persist" in a ? a.persist : "reset" in a ? a.reset : null;
196
- if (key === null) continue;
197
- const target = parsePersistTarget(key);
198
- if (target?.scope === "preset-root-ops") presets.add(target.preset);
199
- }
200
- return [...presets].map((name) => ({
201
- key: presetRootOpsKey(name),
202
- spec: { kind: "allow-list", allowed: [] },
203
- }));
204
- }
205
-
206
- function actionContributions(config: DslConfig): KeySpecContribution[] {
207
- const seeds = configKeySeeds(config);
208
- // [LAW:one-source-of-truth] The "addable segment" domains
209
- // (edit-chrome.ts's `addableSegmentDomains`) merge in here alongside
210
- // looks/presets — the same per-preset seam `insertSegmentFrom` resolves
211
- // through at render (render.ts's registerDslConfig merges the identical
212
- // map), so the rendered picker options and the derived click gate can
213
- // never diverge over what's addable.
214
- const perConfigDomains = new Map([
215
- ...perConfigDomainsFor(config),
216
- ...addableSegmentDomains(config),
217
- ]);
218
- return [
219
- ...presetRootOpsContributions(config),
220
- ...writeDestinations(config).flatMap((a) =>
221
- actionKeySpecs(a, seeds, perConfigDomains),
222
- ),
223
- ];
224
- }
225
-
226
- // [LAW:single-enforcer] Every action as the single-destination declarations it
227
- // writes through — the SAME explosion state-validators.ts folds over, so a
228
- // dual-destination action (candybar-settings-ui-aok.3) contributes exactly the
229
- // `persist` spec its durable half would have contributed alone. One statement
230
- // of "what are this action's destinations", two derivations reading it.
231
- function writeDestinations(config: DslConfig): readonly ActionDecl[] {
232
- return Object.values(config.actions).flatMap(actionDestinations);
233
- }
234
-
235
- // [LAW:single-enforcer] The SOLE install-site derivation: a config's
236
- // persistent-config-writable-key surface is the merge of every `persist`
237
- // ACTION it declares, through the SAME coherence pass deriveActionValidators
238
- // uses for `set`.
239
- export function deriveConfigActionValidators(
240
- config: DslConfig,
241
- ): readonly KeySpecContribution[] {
242
- return mergeContributions(actionContributions(config), "config");
243
- }
244
-
245
- export function registerConfigValidator(
246
- key: string,
247
- spec: DerivedValidatorSpec,
248
- ): () => void {
249
- return registry.register(key, spec);
250
- }