@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,706 +0,0 @@
1
- // [LAW:single-enforcer] One registry that maps click verb names to their
2
- // handlers. Adding a new verb is one entry — no branching in handleClick,
3
- // no scattered if/else in server.ts. The dispatcher does table lookup
4
- // only; verb semantics live in the per-verb handler functions.
5
- //
6
- // [LAW:dataflow-not-control-flow] The verb is data, the lookup is data;
7
- // the dispatcher runs the same operation every call (find handler, invoke
8
- // it). Variability lives entirely in the verb-name argument and in the
9
- // per-verb handler body — never in whether dispatch happens.
10
- //
11
- // [LAW:one-source-of-truth] The verb table is the single canonical list of
12
- // click verbs in the daemon. Tests assert against this table directly so
13
- // the live registry and the test enumeration cannot drift.
14
- //
15
- // Multi-arg verbs (set-state) carry their args as a single slash-delimited
16
- // `value` string on the wire — keeping ClickRequest shape-stable at
17
- // protocol v3 ({verb, value}). The per-verb handler parses its own value
18
- // into the typed args it needs. URL format mirrors:
19
- // cc-candybar://<verb>/<value> where <value> may itself contain `/`.
20
-
21
- import { launchSync } from "../../proc/launch";
22
- import type { Globals } from "../../config/dsl-types";
23
- import type { SessionStateRW } from "../session-state";
24
- import {
25
- listStateKeys,
26
- rangeParamsFor,
27
- validateStateWrite,
28
- } from "./state-validators";
29
- import {
30
- listConfigKeys,
31
- rangeParamsForConfig,
32
- validateConfigWrite,
33
- } from "./config-validators";
34
- import {
35
- clearConfigOverride,
36
- coercePersistValue,
37
- isGlobalsField,
38
- loadConfigOverrides,
39
- loadOverrides,
40
- redoLastOverride,
41
- undoLastOverride,
42
- writeConfigOverride,
43
- } from "../config-overrides-store";
44
- import { configOverridesPath } from "../paths";
45
- import { parsePersistTarget } from "../../config/loader/persist-target";
46
- import {
47
- decodeSegments,
48
- parseEffects,
49
- VERB_APPLY_LAYOUT_OP,
50
- VERB_COPY,
51
- VERB_DISPATCH,
52
- VERB_OPEN_VSCODE,
53
- VERB_LOAD_CONFIG,
54
- VERB_REDO,
55
- VERB_RESET_CONFIG,
56
- VERB_SET_CONFIG,
57
- VERB_SET_STATE,
58
- VERB_STEP_CONFIG,
59
- VERB_STEP_STATE,
60
- VERB_SHOW_CONFIG_ERROR,
61
- VERB_SHOW_CONFIG_WARNING,
62
- VERB_TOOLBAR_TOGGLE,
63
- VERB_UNDO,
64
- } from "../../click/wire";
65
-
66
- export interface VerbContext {
67
- readonly sessionState: SessionStateRW;
68
- readonly dlog: (level: "info" | "warn" | "error", msg: string) => void;
69
- }
70
-
71
- // [LAW:types-are-the-program] The handler IS the contract — it takes the
72
- // raw wire-level `value` string and the daemon's verb context; it returns
73
- // nothing (clicks have no payload). User-facing failures throw an Error;
74
- // the dispatcher in server.ts converts that to a RENDER_FAILED response.
75
- // Invalid-shape inputs (e.g. missing required slash-delimited subfield)
76
- // throw a BadVerbArgs error which the dispatcher surfaces as BAD_REQUEST.
77
- export type VerbHandler = (value: string, ctx: VerbContext) => void;
78
-
79
- // [LAW:types-are-the-program] Argument-shape failures are structurally
80
- // distinct from operational failures. The dispatcher uses `instanceof` to
81
- // route BadVerbArgs to BAD_REQUEST and any other Error to RENDER_FAILED.
82
- export class BadVerbArgs extends Error {
83
- constructor(message: string) {
84
- super(message);
85
- this.name = "BadVerbArgs";
86
- }
87
- }
88
-
89
- // ─── Argument decoders ───────────────────────────────────────────────────────
90
-
91
- // [LAW:single-enforcer] One place that validates "this string is a usable
92
- // session id." A session id has come from an untrusted URL; rejecting `/`
93
- // and `..` keeps it usable as a key in the SessionState map and forbids
94
- // path-traversal through any downstream code that ever joins it with fs
95
- // paths (the legacy flag-file path, now removed, was the original reason).
96
- function requireSessionId(value: string): string {
97
- if (!value) throw new BadVerbArgs("session id is required");
98
- if (value.includes("/") || value.includes(".."))
99
- throw new BadVerbArgs(`invalid session id "${value}"`);
100
- return value;
101
- }
102
-
103
- // [LAW:types-are-the-program] A single-argument verb (copy/open/toolbar/show-
104
- // config) carries ONE argument: the WHOLE value, decoded once. It must NOT split
105
- // on "/" the way the multi-arg set-state does — a single-arg value legitimately
106
- // contains "/" (a copy of "a/b", an open path), and an old direct `copy/a/b`
107
- // scrollback link would be truncated at the first slash if split. The verb's
108
- // arity picks the codec: 1 arg → decode the whole tail; N args → decodeSegments.
109
- // parseHandlerUrl no longer decodes the value, so the decode lives with the verb
110
- // that knows its shape [LAW:single-enforcer].
111
- function oneArg(value: string): string {
112
- return decodeWire(() => decodeURIComponent(value));
113
- }
114
-
115
- // [LAW:single-enforcer] One boundary reclassifies malformed wire encoding.
116
- // percent-decoding untrusted wire input throws a raw URIError on a bad escape
117
- // (`%ZZ`, a lone `%`); that is an argument-shape failure, not an operational
118
- // one, so it must reach the dispatcher as BadVerbArgs (→ BAD_REQUEST) like every
119
- // other bad-input shape. Both verb codecs (single-arg whole-value, multi-seg
120
- // set-state) funnel their decode through here so the reclassification lives once.
121
- function decodeWire<T>(decode: () => T): T {
122
- try {
123
- return decode();
124
- } catch (err) {
125
- if (err instanceof URIError)
126
- throw new BadVerbArgs(`malformed wire encoding: ${err.message}`);
127
- throw err;
128
- }
129
- }
130
-
131
- // ─── Verb handlers ───────────────────────────────────────────────────────────
132
-
133
- // [LAW:single-enforcer] One clipboard primitive, no decode — both the `copy`
134
- // verb (decodes a wire segment) and the diagnostic verbs (already hold a plain
135
- // message) funnel here so the launch + rate-limit handling lives in one place.
136
- function pbcopy(text: string, ctx: VerbContext): void {
137
- const result = launchSync({
138
- bin: "/usr/bin/pbcopy",
139
- stdinInput: text,
140
- category: "click.pbcopy",
141
- });
142
- // [LAW:dataflow-not-control-flow] Rate-limit rejection is one outcome among
143
- // many — the click is acknowledged and the rejection is logged. Other
144
- // failures are genuine errors that surface as RENDER_FAILED.
145
- if (!result.ok) {
146
- if (result.reason === "rate-limited") {
147
- ctx.dlog("warn", `click.pbcopy rate-limited: ${result.error ?? ""}`);
148
- return;
149
- }
150
- throw new Error(
151
- `pbcopy failed (${result.reason}, exit ${result.exitCode ?? "null"})`,
152
- );
153
- }
154
- }
155
-
156
- const copy: VerbHandler = (value, ctx) => pbcopy(oneArg(value), ctx);
157
-
158
- const openVscode: VerbHandler = (value, ctx) => {
159
- const result = launchSync({
160
- bin: "/usr/bin/open",
161
- args: ["-a", "Visual Studio Code", oneArg(value)],
162
- category: "click.open",
163
- });
164
- if (!result.ok) {
165
- if (result.reason === "rate-limited") {
166
- ctx.dlog("warn", `click.open rate-limited: ${result.error ?? ""}`);
167
- return;
168
- }
169
- throw new Error(
170
- `open -a "Visual Studio Code" failed (${result.reason}, exit ${result.exitCode ?? "null"})`,
171
- );
172
- }
173
- };
174
-
175
- // Click on the ⚠ in the bar copies the parse error to clipboard.
176
- const showConfigError: VerbHandler = (value, ctx) => pbcopy(oneArg(value), ctx);
177
-
178
- // [LAW:one-type-per-behavior] Warnings (advisory diagnostics — e.g. config
179
- // extension collision) and errors (load-fatal) are surfaced as distinct
180
- // icons in the bar so the operator can tell them apart at a glance. The
181
- // click behavior is the same — copy the message — but the diagnostic
182
- // categories are kept in separate channels through the render pipeline.
183
- const showConfigWarning: VerbHandler = (value, ctx) =>
184
- pbcopy(oneArg(value), ctx);
185
-
186
- // [LAW:one-source-of-truth] SessionState is the canonical store for
187
- // toolbar-expanded state (eir merge). Toggle via set/clear; the file-backed
188
- // storage owned by the daemon process persists the change automatically.
189
- const toolbarToggle: VerbHandler = (value, ctx) => {
190
- const sessionId = requireSessionId(oneArg(value));
191
- const expanded = ctx.sessionState.get(sessionId, "toolbar-expanded");
192
- if (expanded) ctx.sessionState.clear(sessionId, "toolbar-expanded");
193
- else ctx.sessionState.set(sessionId, "toolbar-expanded", "1");
194
- };
195
-
196
- // [LAW:single-enforcer] One verb writes SessionState — for every
197
- // registered key, for every pair in a batch. The per-key validator
198
- // registry in ./state-validators.ts is the single place that decides
199
- // what is a legal value for a given key; the body here is residue:
200
- // split args into pairs, validate each, write atomically, log.
201
- //
202
- // [LAW:dataflow-not-control-flow] The key is data flowing across the
203
- // boundary, not a discriminator that selects between verb handlers.
204
- // The pair count is data too — N=1 (single write) is the degenerate
205
- // form of the N≥2 batch; the parser walks pairs uniformly. A new
206
- // state-writable key is a registry row, not a new verb; a multi-write
207
- // click (e.g. menu action that writes the chosen value AND collapses
208
- // the menu) is one URL with multiple pairs, not multiple URLs.
209
- //
210
- // [LAW:types-are-the-program] The validator returns a discriminated
211
- // `ValidateResult`. The body cannot fabricate a value (the `ok: true`
212
- // branch's `value` is the only thing it may write) and cannot proceed
213
- // on `ok: false` (it throws BadVerbArgs with the reason verbatim,
214
- // naming the failing pair so the operator can localize the typo). The
215
- // dispatcher in server.ts maps BadVerbArgs to BAD_REQUEST.
216
- //
217
- // [LAW:no-silent-fallbacks] Batch atomicity: every pair is validated
218
- // BEFORE any write happens. Any single failure rejects the whole
219
- // batch — no half-applied state, no "first three writes landed and
220
- // the fourth failed." A widget click is one transactional intent;
221
- // partial application would leave the UI in a state no author wrote.
222
- //
223
- // Value shape (the raw tail after the verb): the percent-encoded segment run
224
- // <sessionId>/<k1>/<v1>[/<k2>/<v2>/...]. decodeSegments splits on `/` and
225
- // decodes each segment — a CODEC property: a `/` inside a segment rides as
226
- // `%2F` and is never read as a separator, so the wire itself is slash-safe.
227
- // This is NOT an end-to-end "slash-bearing state keys are supported" claim:
228
- // the loader and the state-validator factories reject slash-bearing keys and
229
- // option values upstream, so a slash never reaches here in practice. The N=1
230
- // form is the degenerate single-pair case — the parser walks pairs uniformly.
231
- const setState: VerbHandler = (rawValue, ctx) => {
232
- // [LAW:single-enforcer] Decode the whole encoded tail at this boundary; the
233
- // session id is the head, the rest are the (key,value) pairs. A malformed
234
- // escape in any segment is bad input, not a handler failure (decodeWire).
235
- const [sessionId = "", ...rest] = decodeWire(() => decodeSegments(rawValue));
236
- const sid = requireSessionId(sessionId);
237
- if (rest.length === 0)
238
- throw new BadVerbArgs(
239
- `set-state: <key>/<value> is required (have keys: ${listStateKeys().join(", ")})`,
240
- );
241
- // [LAW:dataflow-not-control-flow] The pair count emerges from the data. The
242
- // loop walks the same path for N=1 and N=K — no branch on "is this a batch."
243
- if (rest.length % 2 !== 0) {
244
- throw new BadVerbArgs(
245
- `set-state: expected even-count <key>/<value> pairs, got ${rest.length} ` +
246
- `segment(s) after session id (have keys: ${listStateKeys().join(", ")})`,
247
- );
248
- }
249
- // [LAW:types-are-the-program] Validate the entire batch before any
250
- // write. The "validated pairs" array IS the proof that every write
251
- // about to happen is legal — once it's built, the write loop is
252
- // forced (no branches, no failures possible).
253
- const validated: Array<{ key: string; value: string }> = [];
254
- for (let i = 0; i < rest.length; i += 2) {
255
- const key = rest[i]!;
256
- const incoming = rest[i + 1]!;
257
- // [LAW:types-are-the-program] An empty key is a structural error
258
- // (missing segment), not a semantic one (validator rejection of an
259
- // unknown key). Routing it to the unknown-key validator message
260
- // ("unknown state key \"\"") would mislead the operator about
261
- // where their mistake was. Catch it here, name the pair index so
262
- // batches are localizable.
263
- if (!key) {
264
- throw new BadVerbArgs(
265
- `set-state: empty key at pair ${i / 2 + 1} ` +
266
- `(expected <sessionId>/<key>/<value>[/<key>/<value>...] segments)`,
267
- );
268
- }
269
- const result = validateStateWrite(key, incoming);
270
- if (!result.ok) {
271
- throw new BadVerbArgs(`set-state: pair ${i / 2 + 1}: ${result.reason}`);
272
- }
273
- validated.push({ key, value: result.value });
274
- }
275
- // [LAW:single-enforcer] One write call, one log line format. setBatch
276
- // is the seam that owns reactive atomicity — every pair lands before
277
- // observers fire, so an autorun never sees half-applied batch state.
278
- // Partial application is unrepresentable: validation already passed,
279
- // and the seam guarantees the writes ship as one transaction.
280
- ctx.sessionState.setBatch(sid, validated);
281
- const summary = validated.map((p) => `${p.key}=${p.value}`).join(" ");
282
- ctx.dlog("info", `set-state: ${summary} (session=${sid})`);
283
- };
284
-
285
- // [LAW:single-enforcer] One integer-shape boundary, mirroring the range
286
- // validator's canonical `^-?\d+$`: the `by` delta and a stored current value are
287
- // integers or they are not values. Only an integer-shaped stored value is a
288
- // current value; absence (or a non-integer) is the genuine "unset" state, seeded
289
- // from the registry's configured default.
290
- const STEP_INT_RE = /^-?\d+$/;
291
-
292
- // [LAW:no-ambient-temporal-coupling] Stepping past a bound WRAPS to the other end
293
- // — the navigation owner is THIS handler (moved off the render side, which is no
294
- // longer the timing authority for the value). The range gate still owns the
295
- // [min,max] CLAMP; wrap is navigation, clamp is enforcement.
296
- function wrapStep(n: number, min: number, max: number): number {
297
- return n > max ? min : n < min ? max : n;
298
- }
299
-
300
- // [LAW:no-ambient-temporal-coupling] The RELEASE half of a durable write, run
301
- // by the durable handlers themselves AFTER their own write succeeded — never
302
- // as a separate effect beside them.
303
- //
304
- // A dual-destination control commits "make this the durable default AND stop
305
- // overriding it in this session". Those are one intent, and the session half
306
- // is destructive: dropping the session pick is only correct if the durable
307
- // value actually landed. Emitted as two effects, `dispatch` would run the
308
- // clear even when the persist failed (it runs every effect in a click by
309
- // design, for independent ones like "write value + close menu") — wiping the
310
- // user's pick with nothing durable in its place, a lost update whose error
311
- // message would not even mention it. Ordering that matters belongs inside one
312
- // handler, not in a hope about the dispatcher.
313
- //
314
- // Gated by key MEMBERSHIP (listStateKeys), exactly as reset-config is over the
315
- // config keyspace: there is no value to validate, only a legitimate target to
316
- // clear. Absent segment = nothing to release, which is every ordinary persist
317
- // click [LAW:dataflow-not-control-flow].
318
- function releaseSessionKey(
319
- release: string,
320
- sid: string,
321
- ctx: VerbContext,
322
- verb: string,
323
- ): void {
324
- if (!release) return;
325
- if (!listStateKeys().includes(release)) {
326
- throw new BadVerbArgs(
327
- `${verb}: unknown session key "${release}" to release (have: ${listStateKeys().join(", ")})`,
328
- );
329
- }
330
- ctx.sessionState.clear(sid, release);
331
- ctx.dlog("info", `${verb}: released session key ${release} (session=${sid})`);
332
- }
333
-
334
- // [LAW:one-source-of-truth] A RELATIVE nudge to a bounded state key. The link
335
- // carries ONLY the irreducible intent `[sessionId, key, by]` (no `current`
336
- // snapshot), so the SAME link string fires every render and N rapid clicks each
337
- // re-read live state and accumulate — the idempotent absolute-write bug is gone.
338
- // The absolute target is computed HERE: read the live value (seed an unset key
339
- // from the registry's configured default, NOT silently from min), wrap by the
340
- // signed delta against the registry's bounds, then route the result through
341
- // validateStateWrite so the one range gate owns the [min,max] clamp and the
342
- // canonical decimal form that persists.
343
- const stepState: VerbHandler = (rawValue, ctx) => {
344
- const [sessionId = "", key = "", byRaw = ""] = decodeWire(() =>
345
- decodeSegments(rawValue),
346
- );
347
- const sid = requireSessionId(sessionId);
348
- if (!key) {
349
- throw new BadVerbArgs(
350
- "step-state: <key> is required (shape: <sessionId>/<key>/<by>)",
351
- );
352
- }
353
- if (!STEP_INT_RE.test(byRaw)) {
354
- throw new BadVerbArgs(
355
- `step-state: delta must be an integer, got "${byRaw}"`,
356
- );
357
- }
358
- const by = parseInt(byRaw, 10);
359
- // [LAW:no-silent-fallbacks] A key with no range registration is not a stepper —
360
- // reject loudly rather than fabricate bounds or silently no-op.
361
- const params = rangeParamsFor(key);
362
- if (!params) {
363
- throw new BadVerbArgs(
364
- `step-state: key "${key}" is not a bounded (range) state key ` +
365
- `(have keys: ${listStateKeys().join(", ")})`,
366
- );
367
- }
368
- // [LAW:no-defensive-null-guards] "unset" is a real state — seed from the
369
- // configured default; only an integer-shaped stored value is a current value.
370
- const stored = ctx.sessionState.get(sid, key);
371
- const current =
372
- stored && STEP_INT_RE.test(stored)
373
- ? Math.max(params.min, Math.min(params.max, parseInt(stored, 10)))
374
- : params.seed;
375
- const next = wrapStep(current + by, params.min, params.max);
376
- const result = validateStateWrite(key, String(next));
377
- if (!result.ok) throw new BadVerbArgs(`step-state: ${result.reason}`);
378
- ctx.sessionState.set(sid, key, result.value);
379
- ctx.dlog(
380
- "info",
381
- `step-state: ${key} ${current}→${result.value} (by ${by}, session=${sid})`,
382
- );
383
- };
384
-
385
- // [LAW:no-defensive-null-guards] Range-bounded (stepper) persist targets are
386
- // globals-only by construction: loader/cross-ref.ts (candybar-config-engine-
387
- // 71o.6) rejects a `min`/`max`/`by` persist arm over a `segments.<name>.
388
- // palette` target at config-LOAD time, so a validated write reaching
389
- // stepConfig with a range spec is a real Globals field by construction —
390
- // this assertion is the type-narrowing boundary (for the `overrides[key]`
391
- // index below), not a runtime possibility. setConfig has no such need:
392
- // coercePersistValue classifies a bare string key itself.
393
- function assertGlobalsField(key: string): asserts key is keyof Globals {
394
- if (!isGlobalsField(key)) {
395
- throw new Error(
396
- `step-config: "${key}" validated as a bounded config-writable key but ` +
397
- `is not a Globals field — registration/loader invariant broken`,
398
- );
399
- }
400
- }
401
-
402
- // [LAW:single-enforcer] `persist`'s twin of setState: the SAME validate-then-
403
- // write shape, writing through config-overrides-store instead of
404
- // SessionState. The write is DURABLE — RenderCache's file watcher on
405
- // configOverridesPath() (src/daemon/cache/render.ts) picks it up on the next
406
- // reload, exactly as an edit to the hand-authored config file would.
407
- // [LAW:no-silent-fallbacks] Unknown key or out-of-domain value is a loud
408
- // BAD_REQUEST — the SAME gate `set-state` uses (validateConfigWrite),
409
- // derived from the SAME action table (deriveConfigActionValidators).
410
- const setConfig: VerbHandler = (rawValue, ctx) => {
411
- const [sessionId = "", key = "", incoming = "", release = ""] = decodeWire(
412
- () => decodeSegments(rawValue),
413
- );
414
- const sid = requireSessionId(sessionId);
415
- if (!key) {
416
- throw new BadVerbArgs(
417
- `set-config: <key>/<value> is required (have keys: ${listConfigKeys().join(", ")})`,
418
- );
419
- }
420
- const result = validateConfigWrite(key, incoming);
421
- if (!result.ok) throw new BadVerbArgs(`set-config: ${result.reason}`);
422
- const typed = coercePersistValue(key, result.value);
423
- writeConfigOverride(configOverridesPath(), key, typed, ctx.dlog);
424
- ctx.dlog("info", `set-config: ${key}=${result.value} (session=${sid})`);
425
- releaseSessionKey(release, sid, ctx, "set-config");
426
- };
427
-
428
- // [LAW:one-source-of-truth] `persist`'s twin of stepState: a RELATIVE nudge
429
- // against the current override (or the merged config's own value when
430
- // unset — rangeParamsForConfig's seed), wrapped and re-validated through the
431
- // SAME range gate, then written durably.
432
- const stepConfig: VerbHandler = (rawValue, ctx) => {
433
- const [sessionId = "", key = "", byRaw = "", release = ""] = decodeWire(() =>
434
- decodeSegments(rawValue),
435
- );
436
- const sid = requireSessionId(sessionId);
437
- if (!key) {
438
- throw new BadVerbArgs(
439
- "step-config: <key> is required (shape: <sessionId>/<key>/<by>)",
440
- );
441
- }
442
- if (!STEP_INT_RE.test(byRaw)) {
443
- throw new BadVerbArgs(
444
- `step-config: delta must be an integer, got "${byRaw}"`,
445
- );
446
- }
447
- const by = parseInt(byRaw, 10);
448
- const params = rangeParamsForConfig(key);
449
- if (!params) {
450
- throw new BadVerbArgs(
451
- `step-config: key "${key}" is not a bounded (range) config key ` +
452
- `(have keys: ${listConfigKeys().join(", ")})`,
453
- );
454
- }
455
- assertGlobalsField(key);
456
- const overrides = loadConfigOverrides(configOverridesPath(), ctx.dlog);
457
- const stored = overrides[key];
458
- const current =
459
- typeof stored === "number"
460
- ? Math.max(params.min, Math.min(params.max, stored))
461
- : params.seed;
462
- const next = wrapStep(current + by, params.min, params.max);
463
- const result = validateConfigWrite(key, String(next));
464
- if (!result.ok) throw new BadVerbArgs(`step-config: ${result.reason}`);
465
- const typed = coercePersistValue(key, result.value);
466
- writeConfigOverride(configOverridesPath(), key, typed, ctx.dlog);
467
- ctx.dlog(
468
- "info",
469
- `step-config: ${key} ${current}→${result.value} (by ${by}, session=${sid})`,
470
- );
471
- releaseSessionKey(release, sid, ctx, "step-config");
472
- };
473
-
474
- // [LAW:one-source-of-truth] The gated undo for `persist`: clears one
475
- // config-overrides key, restoring the user-file/bundled-default value on the
476
- // next reload. Gated by key MEMBERSHIP (listConfigKeys) rather than a value
477
- // domain — there is no value to validate, only a legitimate target to clear.
478
- const resetConfig: VerbHandler = (value, ctx) => {
479
- const [sessionId = "", key = ""] = decodeWire(() => decodeSegments(value));
480
- const sid = requireSessionId(sessionId);
481
- if (!key || !listConfigKeys().includes(key)) {
482
- throw new BadVerbArgs(
483
- `reset-config: unknown config key "${key}" (have: ${listConfigKeys().join(", ")})`,
484
- );
485
- }
486
- clearConfigOverride(configOverridesPath(), key, ctx.dlog);
487
- ctx.dlog("info", `reset-config: ${key} (session=${sid})`);
488
- };
489
-
490
- // [LAW:one-source-of-truth] brandon-layout-edit-2gc.1's structural-edit
491
- // write: a THIRD config-overrides write shape beside setConfig's overwrite
492
- // and stepConfig's numeric read-modify-write — read the current op-token
493
- // list at `key`, append the validated op, write the whole list back. Gated
494
- // by the SAME allow-list machinery setConfig uses (validateConfigWrite,
495
- // derived from a config's declared removeSegment/insertSegment actions) —
496
- // an op token no action declares is a loud BAD_REQUEST, never silently
497
- // appended. `key` must resolve to the preset-root-ops scope specifically
498
- // (never a globals/segment-palette key smuggled in through this verb) —
499
- // checked here rather than trusted from the gate, since the gate only
500
- // proves the VALUE is allowed for that key, not that the key's SCOPE
501
- // matches this verb's read-modify-write shape.
502
- const applyLayoutOp: VerbHandler = (rawValue, ctx) => {
503
- const [sessionId = "", key = "", opToken = ""] = decodeWire(() =>
504
- decodeSegments(rawValue),
505
- );
506
- const sid = requireSessionId(sessionId);
507
- if (!key) {
508
- throw new BadVerbArgs(
509
- `apply-layout-op: <key>/<op> is required (have: ${listConfigKeys().join(", ")})`,
510
- );
511
- }
512
- const result = validateConfigWrite(key, opToken);
513
- if (!result.ok) throw new BadVerbArgs(`apply-layout-op: ${result.reason}`);
514
- const target = parsePersistTarget(key);
515
- if (target === null || target.scope !== "preset-root-ops") {
516
- throw new BadVerbArgs(
517
- `apply-layout-op: "${key}" is not a "presets.<name>.rootOps" target`,
518
- );
519
- }
520
- const existing =
521
- loadOverrides(configOverridesPath(), ctx.dlog).presetRootOps[
522
- target.preset
523
- ] ?? [];
524
- const next = JSON.stringify([...existing, result.value]);
525
- writeConfigOverride(configOverridesPath(), key, next, ctx.dlog);
526
- ctx.dlog(
527
- "info",
528
- `apply-layout-op: ${key} += ${result.value} (session=${sid})`,
529
- );
530
- };
531
-
532
- // [LAW:one-source-of-truth] `reset`'s fine-grained sibling: step the ONE
533
- // global history over the overrides layer back one entry. No key, no value —
534
- // the history store (config-overrides-store.ts) owns which entry moves and
535
- // what it restores; this handler is pure plumbing between the wire and it.
536
- // [LAW:no-silent-failure] An empty stack is a loud BAD_REQUEST (dispatch's
537
- // aggregator turns it into a transient click.error), never a silent no-op —
538
- // the ticket's own done-gate.
539
- const undoConfig: VerbHandler = (value, ctx) => {
540
- const [sessionId = ""] = decodeWire(() => decodeSegments(value));
541
- const sid = requireSessionId(sessionId);
542
- const entry = undoLastOverride(configOverridesPath(), ctx.dlog);
543
- if (entry === null) {
544
- throw new BadVerbArgs("undo: history is empty, nothing to undo");
545
- }
546
- ctx.dlog("info", `undo: ${entry.key} (session=${sid})`);
547
- };
548
-
549
- // [LAW:one-source-of-truth] undo's mirror — steps the same global history
550
- // forward one entry.
551
- const redoConfig: VerbHandler = (value, ctx) => {
552
- const [sessionId = ""] = decodeWire(() => decodeSegments(value));
553
- const sid = requireSessionId(sessionId);
554
- const entry = redoLastOverride(configOverridesPath(), ctx.dlog);
555
- if (entry === null) {
556
- throw new BadVerbArgs("redo: nothing to redo");
557
- }
558
- ctx.dlog("info", `redo: ${entry.key} (session=${sid})`);
559
- };
560
-
561
- // ─── Registry ───────────────────────────────────────────────────────────────
562
-
563
- // [LAW:one-source-of-truth] The LEAF verbs — every click effect that does real
564
- // work. `dispatch` (below) is NOT here: it folds an effect list back through
565
- // THIS map, so a dispatch effect can never resolve to dispatch and nesting is
566
- // structurally impossible [LAW:types-are-the-program] — no recursion guard, the
567
- // shape forbids it.
568
- //
569
- // [LAW:types-are-the-program] `Map` is the dispatch type whose lookup is
570
- // `(verb) → VerbHandler | undefined` with no prototype chain. The wire-level
571
- // `verb` field is untrusted input; a `__proto__` or `constructor` value over a
572
- // plain object would be a truthy hit on Object.prototype that then throws on
573
- // invocation (RENDER_FAILED instead of BAD_REQUEST). Map makes the wrong
574
- // dispatch unrepresentable, matching src/daemon/session-state.ts.
575
- // [LAW:effects-at-boundaries] Per-session config override stored in SessionState.
576
- // Wire value: `<sessionId>/<percent-encoded-path>`. An empty path clears the
577
- // override, restoring the request-derived config for that session only.
578
- // Split at the FIRST slash — the session ID is slash-free (requireSessionId),
579
- // and the path contains slashes that must not be split.
580
- // [LAW:no-silent-failure] Path validation is at the verb boundary so a bad path
581
- // fails the click (BAD_REQUEST), not the next render.
582
- export const SESSION_CONFIG_OVERRIDE_KEY = "config-override";
583
- const loadConfig: VerbHandler = (value, ctx) => {
584
- const slash = value.indexOf("/");
585
- if (slash === -1) {
586
- throw new BadVerbArgs(
587
- "load-config: expected <sessionId>/<path> (missing separator)",
588
- );
589
- }
590
- const sid = requireSessionId(
591
- decodeWire(() => decodeURIComponent(value.slice(0, slash))),
592
- );
593
- const p = decodeWire(() => decodeURIComponent(value.slice(slash + 1))).trim();
594
- if (p !== "") {
595
- if (!p.startsWith("/")) {
596
- throw new BadVerbArgs(`load-config: path must be absolute, got "${p}"`);
597
- }
598
- if (!/\.(json5?|json)$/.test(p)) {
599
- throw new BadVerbArgs(
600
- `load-config: path must end with .json5 or .json, got "${p}"`,
601
- );
602
- }
603
- }
604
- if (p === "") {
605
- ctx.sessionState.clear(sid, SESSION_CONFIG_OVERRIDE_KEY);
606
- ctx.dlog("info", `load-config: override cleared (session=${sid})`);
607
- } else {
608
- ctx.sessionState.set(sid, SESSION_CONFIG_OVERRIDE_KEY, p);
609
- ctx.dlog("info", `load-config: ${p} (session=${sid})`);
610
- }
611
- };
612
-
613
- const LEAF_VERBS = new Map<string, VerbHandler>([
614
- [VERB_COPY, copy],
615
- [VERB_LOAD_CONFIG, loadConfig],
616
- [VERB_OPEN_VSCODE, openVscode],
617
- [VERB_SET_STATE, setState],
618
- [VERB_STEP_STATE, stepState],
619
- [VERB_SET_CONFIG, setConfig],
620
- [VERB_STEP_CONFIG, stepConfig],
621
- [VERB_RESET_CONFIG, resetConfig],
622
- [VERB_APPLY_LAYOUT_OP, applyLayoutOp],
623
- [VERB_UNDO, undoConfig],
624
- [VERB_REDO, redoConfig],
625
- [VERB_SHOW_CONFIG_ERROR, showConfigError],
626
- [VERB_SHOW_CONFIG_WARNING, showConfigWarning],
627
- [VERB_TOOLBAR_TOGGLE, toolbarToggle],
628
- ]);
629
-
630
- // [LAW:dataflow-not-control-flow] One click is an ordered list of effects; the
631
- // dispatcher folds the list, running EVERY effect through the leaf table. The
632
- // effect count is data — N=1 and N=100 walk the identical loop, no plain-vs-
633
- // compound branch. [LAW:no-silent-fallbacks] Every effect runs even if an
634
- // earlier one failed; failures accumulate in `errors`. An unknown or
635
- // non-leaf (e.g. nested `dispatch`) verb is a miss in LEAF_VERBS — reported,
636
- // never executed.
637
- //
638
- // [LAW:types-are-the-program] The aggregate PRESERVES the dispatcher's
639
- // input-vs-operational error classification: a leaf throws BadVerbArgs for bad
640
- // input (→ BAD_REQUEST) and a plain Error for an operational failure (e.g. a
641
- // pbcopy/open launch failure → RENDER_FAILED). If ANY effect failed
642
- // operationally, the whole click failed operationally (plain Error); only when
643
- // every failure is an input error does the aggregate stay BadVerbArgs. An
644
- // unknown verb is bad input — it does not flip the classification.
645
- //
646
- // [LAW:one-source-of-truth] Per-effect errors are written to session state
647
- // under 'click.error' so the next render shows WHICH effect(s) failed in the
648
- // bar transiently (one render, then cleared). Only possible when a session ID
649
- // is available from a set-state or toolbar-toggle effect in the same click.
650
- const dispatch: VerbHandler = (rawValue, ctx) => {
651
- const errors: string[] = [];
652
- let operational = false;
653
- let sessionId: string | null = null;
654
- for (const { verb, value } of parseEffects(rawValue)) {
655
- // Extract session ID from the first session-bearing effect for error display.
656
- // set-state, step-state, set-config, step-config, reset-config,
657
- // apply-layout-op, undo, redo, and toolbar-toggle all carry the session id
658
- // as their first segment, so a failing step surfaces in the bar like any
659
- // other.
660
- if (
661
- !sessionId &&
662
- (verb === VERB_SET_STATE ||
663
- verb === VERB_STEP_STATE ||
664
- verb === VERB_SET_CONFIG ||
665
- verb === VERB_STEP_CONFIG ||
666
- verb === VERB_RESET_CONFIG ||
667
- verb === VERB_APPLY_LAYOUT_OP ||
668
- verb === VERB_UNDO ||
669
- verb === VERB_REDO ||
670
- verb === VERB_TOOLBAR_TOGGLE)
671
- ) {
672
- const parts = decodeSegments(value);
673
- if (parts.length > 0 && parts[0]) sessionId = parts[0];
674
- }
675
- const handler = LEAF_VERBS.get(verb);
676
- if (!handler) {
677
- errors.push(`unknown effect verb "${verb}"`);
678
- continue;
679
- }
680
- try {
681
- handler(value, ctx);
682
- } catch (e) {
683
- if (!(e instanceof BadVerbArgs)) operational = true;
684
- errors.push(`${verb}: ${e instanceof Error ? e.message : String(e)}`);
685
- }
686
- }
687
- if (errors.length > 0) {
688
- if (sessionId) {
689
- ctx.sessionState.set(sessionId, "click.error", errors.join("\n"));
690
- }
691
- const message = `dispatch: ${errors.join("; ")}`;
692
- throw operational ? new Error(message) : new BadVerbArgs(message);
693
- }
694
- };
695
-
696
- // [LAW:one-source-of-truth] The full dispatch table the daemon looks up against:
697
- // every leaf verb plus the one `dispatch` wrapper. Old scrollback links that
698
- // name a leaf verb directly still resolve here; new renders all emit `dispatch`.
699
- export const VERBS: ReadonlyMap<string, VerbHandler> = new Map<
700
- string,
701
- VerbHandler
702
- >([...LEAF_VERBS, [VERB_DISPATCH, dispatch]]);
703
-
704
- export const VERB_NAMES: readonly string[] = Object.freeze([
705
- ...VERBS.keys(),
706
- ]) as readonly string[];