@promptctl/cc-candybar 1.0.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 (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +145 -0
  3. package/bin/cc-candybar +6 -0
  4. package/dist/index.mjs +185 -0
  5. package/package.json +99 -0
  6. package/plugin/.claude-plugin/plugin.json +11 -0
  7. package/plugin/bin/preview.sh +305 -0
  8. package/plugin/commands/candybar.md +403 -0
  9. package/plugin/templates/config-essential.json +36 -0
  10. package/plugin/templates/config-full.json +55 -0
  11. package/plugin/templates/config-standard.json +39 -0
  12. package/plugin/templates/config-tui-compact.json +48 -0
  13. package/plugin/templates/config-tui-full.json +89 -0
  14. package/plugin/templates/config-tui-standard.json +56 -0
  15. package/plugin/templates/config-tui.json +18 -0
  16. package/plugin/templates/nerd-fonts-sample.txt +5 -0
  17. package/schema/cc-candybar.schema.json +1379 -0
  18. package/src/click/wire.ts +113 -0
  19. package/src/config/action.ts +91 -0
  20. package/src/config/cli.ts +170 -0
  21. package/src/config/default-dsl-config.ts +661 -0
  22. package/src/config/dsl-loader.ts +265 -0
  23. package/src/config/dsl-types.ts +425 -0
  24. package/src/config/loader/actions.ts +530 -0
  25. package/src/config/loader/cache.ts +206 -0
  26. package/src/config/loader/cross-ref.ts +326 -0
  27. package/src/config/loader/cycles.ts +148 -0
  28. package/src/config/loader/diagnostics.ts +99 -0
  29. package/src/config/loader/discovery.ts +182 -0
  30. package/src/config/loader/emit-schema.ts +63 -0
  31. package/src/config/loader/globals.ts +42 -0
  32. package/src/config/loader/helpers.ts +48 -0
  33. package/src/config/loader/layout.ts +688 -0
  34. package/src/config/loader/merge.ts +40 -0
  35. package/src/config/loader/refs.ts +96 -0
  36. package/src/config/loader/segments.ts +120 -0
  37. package/src/config/loader/validate-core.ts +674 -0
  38. package/src/config/loader/variables.ts +260 -0
  39. package/src/daemon/acquire.ts +411 -0
  40. package/src/daemon/cache/git.ts +553 -0
  41. package/src/daemon/cache/render.ts +449 -0
  42. package/src/daemon/cache/session-usage-store.ts +446 -0
  43. package/src/daemon/cache/watchers.ts +245 -0
  44. package/src/daemon/client-debug.ts +120 -0
  45. package/src/daemon/client-stats.ts +129 -0
  46. package/src/daemon/client-transport.ts +273 -0
  47. package/src/daemon/client.ts +75 -0
  48. package/src/daemon/debug-types.ts +91 -0
  49. package/src/daemon/debug.ts +264 -0
  50. package/src/daemon/limits.ts +154 -0
  51. package/src/daemon/log.ts +69 -0
  52. package/src/daemon/parent-watchdog.ts +80 -0
  53. package/src/daemon/paths.ts +127 -0
  54. package/src/daemon/protocol.ts +235 -0
  55. package/src/daemon/render-payload.ts +611 -0
  56. package/src/daemon/server.ts +1103 -0
  57. package/src/daemon/session-state-file.ts +108 -0
  58. package/src/daemon/session-state.ts +237 -0
  59. package/src/daemon/stats.ts +229 -0
  60. package/src/daemon/verbs/index.ts +458 -0
  61. package/src/daemon/verbs/state-validators.ts +708 -0
  62. package/src/demo/dsl.ts +117 -0
  63. package/src/demo/mock-data.ts +67 -0
  64. package/src/demo/statusline.json5 +92 -0
  65. package/src/dsl/node-registry.ts +281 -0
  66. package/src/dsl/render.ts +558 -0
  67. package/src/index.ts +206 -0
  68. package/src/install/index.ts +410 -0
  69. package/src/proc/launch.ts +451 -0
  70. package/src/proc/stats-handle.ts +13 -0
  71. package/src/render/action.ts +458 -0
  72. package/src/render/diagnostic-style.ts +23 -0
  73. package/src/render/diagnostic-text.ts +77 -0
  74. package/src/render/error-glyph.ts +53 -0
  75. package/src/render/outcome-plan.ts +45 -0
  76. package/src/render/picker.ts +231 -0
  77. package/src/render/split-lines.ts +51 -0
  78. package/src/render/strip.ts +103 -0
  79. package/src/segments/cache.ts +131 -0
  80. package/src/segments/context.ts +190 -0
  81. package/src/segments/git.ts +561 -0
  82. package/src/segments/metrics.ts +101 -0
  83. package/src/segments/pricing.ts +452 -0
  84. package/src/segments/session.ts +188 -0
  85. package/src/segments/tmux.ts +74 -0
  86. package/src/template-engine/cells.ts +90 -0
  87. package/src/template-engine/colors.ts +102 -0
  88. package/src/template-engine/engine.ts +108 -0
  89. package/src/template-engine/funcs.ts +216 -0
  90. package/src/template-engine/index.ts +11 -0
  91. package/src/template-engine/layout.ts +112 -0
  92. package/src/template-engine/scope.ts +62 -0
  93. package/src/themes/index.ts +19 -0
  94. package/src/themes/palette-resolvers.ts +86 -0
  95. package/src/themes/policy.ts +79 -0
  96. package/src/themes/session-random.ts +88 -0
  97. package/src/utils/cache.ts +206 -0
  98. package/src/utils/claude.ts +616 -0
  99. package/src/utils/color-support.ts +118 -0
  100. package/src/utils/formatters.ts +77 -0
  101. package/src/utils/logger.ts +5 -0
  102. package/src/utils/outcome.ts +33 -0
  103. package/src/utils/schema-validator.ts +126 -0
  104. package/src/utils/single-flight.ts +57 -0
  105. package/src/utils/terminal-width.ts +43 -0
  106. package/src/utils/terminal.ts +11 -0
  107. package/src/utils/transcript-fs.ts +162 -0
  108. package/src/var-system/index.ts +24 -0
  109. package/src/var-system/sources.ts +1038 -0
  110. package/src/var-system/store.ts +223 -0
  111. package/src/var-system/types.ts +57 -0
@@ -0,0 +1,458 @@
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 { SessionStateRW } from "../session-state";
23
+ import {
24
+ listStateKeys,
25
+ rangeParamsFor,
26
+ validateStateWrite,
27
+ } from "./state-validators";
28
+ import {
29
+ decodeSegments,
30
+ parseEffects,
31
+ VERB_COPY,
32
+ VERB_DISPATCH,
33
+ VERB_OPEN_VSCODE,
34
+ VERB_LOAD_CONFIG,
35
+ VERB_SET_STATE,
36
+ VERB_STEP_STATE,
37
+ VERB_SHOW_CONFIG_ERROR,
38
+ VERB_SHOW_CONFIG_WARNING,
39
+ VERB_TOOLBAR_TOGGLE,
40
+ } from "../../click/wire";
41
+
42
+ export interface VerbContext {
43
+ readonly sessionState: SessionStateRW;
44
+ readonly dlog: (level: "info" | "warn" | "error", msg: string) => void;
45
+ }
46
+
47
+ // [LAW:types-are-the-program] The handler IS the contract — it takes the
48
+ // raw wire-level `value` string and the daemon's verb context; it returns
49
+ // nothing (clicks have no payload). User-facing failures throw an Error;
50
+ // the dispatcher in server.ts converts that to a RENDER_FAILED response.
51
+ // Invalid-shape inputs (e.g. missing required slash-delimited subfield)
52
+ // throw a BadVerbArgs error which the dispatcher surfaces as BAD_REQUEST.
53
+ export type VerbHandler = (value: string, ctx: VerbContext) => void;
54
+
55
+ // [LAW:types-are-the-program] Argument-shape failures are structurally
56
+ // distinct from operational failures. The dispatcher uses `instanceof` to
57
+ // route BadVerbArgs to BAD_REQUEST and any other Error to RENDER_FAILED.
58
+ export class BadVerbArgs extends Error {
59
+ constructor(message: string) {
60
+ super(message);
61
+ this.name = "BadVerbArgs";
62
+ }
63
+ }
64
+
65
+ // ─── Argument decoders ───────────────────────────────────────────────────────
66
+
67
+ // [LAW:single-enforcer] One place that validates "this string is a usable
68
+ // session id." A session id has come from an untrusted URL; rejecting `/`
69
+ // and `..` keeps it usable as a key in the SessionState map and forbids
70
+ // path-traversal through any downstream code that ever joins it with fs
71
+ // paths (the legacy flag-file path, now removed, was the original reason).
72
+ function requireSessionId(value: string): string {
73
+ if (!value) throw new BadVerbArgs("session id is required");
74
+ if (value.includes("/") || value.includes(".."))
75
+ throw new BadVerbArgs(`invalid session id "${value}"`);
76
+ return value;
77
+ }
78
+
79
+ // [LAW:types-are-the-program] A single-argument verb (copy/open/toolbar/show-
80
+ // config) carries ONE argument: the WHOLE value, decoded once. It must NOT split
81
+ // on "/" the way the multi-arg set-state does — a single-arg value legitimately
82
+ // contains "/" (a copy of "a/b", an open path), and an old direct `copy/a/b`
83
+ // scrollback link would be truncated at the first slash if split. The verb's
84
+ // arity picks the codec: 1 arg → decode the whole tail; N args → decodeSegments.
85
+ // parseHandlerUrl no longer decodes the value, so the decode lives with the verb
86
+ // that knows its shape [LAW:single-enforcer].
87
+ function oneArg(value: string): string {
88
+ return decodeWire(() => decodeURIComponent(value));
89
+ }
90
+
91
+ // [LAW:single-enforcer] One boundary reclassifies malformed wire encoding.
92
+ // percent-decoding untrusted wire input throws a raw URIError on a bad escape
93
+ // (`%ZZ`, a lone `%`); that is an argument-shape failure, not an operational
94
+ // one, so it must reach the dispatcher as BadVerbArgs (→ BAD_REQUEST) like every
95
+ // other bad-input shape. Both verb codecs (single-arg whole-value, multi-seg
96
+ // set-state) funnel their decode through here so the reclassification lives once.
97
+ function decodeWire<T>(decode: () => T): T {
98
+ try {
99
+ return decode();
100
+ } catch (err) {
101
+ if (err instanceof URIError)
102
+ throw new BadVerbArgs(`malformed wire encoding: ${err.message}`);
103
+ throw err;
104
+ }
105
+ }
106
+
107
+ // ─── Verb handlers ───────────────────────────────────────────────────────────
108
+
109
+ // [LAW:single-enforcer] One clipboard primitive, no decode — both the `copy`
110
+ // verb (decodes a wire segment) and the diagnostic verbs (already hold a plain
111
+ // message) funnel here so the launch + rate-limit handling lives in one place.
112
+ function pbcopy(text: string, ctx: VerbContext): void {
113
+ const result = launchSync({
114
+ bin: "/usr/bin/pbcopy",
115
+ stdinInput: text,
116
+ category: "click.pbcopy",
117
+ });
118
+ // [LAW:dataflow-not-control-flow] Rate-limit rejection is one outcome among
119
+ // many — the click is acknowledged and the rejection is logged. Other
120
+ // failures are genuine errors that surface as RENDER_FAILED.
121
+ if (!result.ok) {
122
+ if (result.reason === "rate-limited") {
123
+ ctx.dlog("warn", `click.pbcopy rate-limited: ${result.error ?? ""}`);
124
+ return;
125
+ }
126
+ throw new Error(
127
+ `pbcopy failed (${result.reason}, exit ${result.exitCode ?? "null"})`,
128
+ );
129
+ }
130
+ }
131
+
132
+ const copy: VerbHandler = (value, ctx) => pbcopy(oneArg(value), ctx);
133
+
134
+ const openVscode: VerbHandler = (value, ctx) => {
135
+ const result = launchSync({
136
+ bin: "/usr/bin/open",
137
+ args: ["-a", "Visual Studio Code", oneArg(value)],
138
+ category: "click.open",
139
+ });
140
+ if (!result.ok) {
141
+ if (result.reason === "rate-limited") {
142
+ ctx.dlog("warn", `click.open rate-limited: ${result.error ?? ""}`);
143
+ return;
144
+ }
145
+ throw new Error(
146
+ `open -a "Visual Studio Code" failed (${result.reason}, exit ${result.exitCode ?? "null"})`,
147
+ );
148
+ }
149
+ };
150
+
151
+ // Click on the ⚠ in the bar copies the parse error to clipboard.
152
+ const showConfigError: VerbHandler = (value, ctx) => pbcopy(oneArg(value), ctx);
153
+
154
+ // [LAW:one-type-per-behavior] Warnings (advisory diagnostics — e.g. config
155
+ // extension collision) and errors (load-fatal) are surfaced as distinct
156
+ // icons in the bar so the operator can tell them apart at a glance. The
157
+ // click behavior is the same — copy the message — but the diagnostic
158
+ // categories are kept in separate channels through the render pipeline.
159
+ const showConfigWarning: VerbHandler = (value, ctx) =>
160
+ pbcopy(oneArg(value), ctx);
161
+
162
+ // [LAW:one-source-of-truth] SessionState is the canonical store for
163
+ // toolbar-expanded state (eir merge). Toggle via set/clear; the file-backed
164
+ // storage owned by the daemon process persists the change automatically.
165
+ const toolbarToggle: VerbHandler = (value, ctx) => {
166
+ const sessionId = requireSessionId(oneArg(value));
167
+ const expanded = ctx.sessionState.get(sessionId, "toolbar-expanded");
168
+ if (expanded) ctx.sessionState.clear(sessionId, "toolbar-expanded");
169
+ else ctx.sessionState.set(sessionId, "toolbar-expanded", "1");
170
+ };
171
+
172
+ // [LAW:single-enforcer] One verb writes SessionState — for every
173
+ // registered key, for every pair in a batch. The per-key validator
174
+ // registry in ./state-validators.ts is the single place that decides
175
+ // what is a legal value for a given key; the body here is residue:
176
+ // split args into pairs, validate each, write atomically, log.
177
+ //
178
+ // [LAW:dataflow-not-control-flow] The key is data flowing across the
179
+ // boundary, not a discriminator that selects between verb handlers.
180
+ // The pair count is data too — N=1 (single write) is the degenerate
181
+ // form of the N≥2 batch; the parser walks pairs uniformly. A new
182
+ // state-writable key is a registry row, not a new verb; a multi-write
183
+ // click (e.g. menu action that writes the chosen value AND collapses
184
+ // the menu) is one URL with multiple pairs, not multiple URLs.
185
+ //
186
+ // [LAW:types-are-the-program] The validator returns a discriminated
187
+ // `ValidateResult`. The body cannot fabricate a value (the `ok: true`
188
+ // branch's `value` is the only thing it may write) and cannot proceed
189
+ // on `ok: false` (it throws BadVerbArgs with the reason verbatim,
190
+ // naming the failing pair so the operator can localize the typo). The
191
+ // dispatcher in server.ts maps BadVerbArgs to BAD_REQUEST.
192
+ //
193
+ // [LAW:no-silent-fallbacks] Batch atomicity: every pair is validated
194
+ // BEFORE any write happens. Any single failure rejects the whole
195
+ // batch — no half-applied state, no "first three writes landed and
196
+ // the fourth failed." A widget click is one transactional intent;
197
+ // partial application would leave the UI in a state no author wrote.
198
+ //
199
+ // Value shape (the raw tail after the verb): the percent-encoded segment run
200
+ // <sessionId>/<k1>/<v1>[/<k2>/<v2>/...]. decodeSegments splits on `/` and
201
+ // decodes each segment — a CODEC property: a `/` inside a segment rides as
202
+ // `%2F` and is never read as a separator, so the wire itself is slash-safe.
203
+ // This is NOT an end-to-end "slash-bearing state keys are supported" claim:
204
+ // the loader and the state-validator factories reject slash-bearing keys and
205
+ // option values upstream, so a slash never reaches here in practice. The N=1
206
+ // form is the degenerate single-pair case — the parser walks pairs uniformly.
207
+ const setState: VerbHandler = (rawValue, ctx) => {
208
+ // [LAW:single-enforcer] Decode the whole encoded tail at this boundary; the
209
+ // session id is the head, the rest are the (key,value) pairs. A malformed
210
+ // escape in any segment is bad input, not a handler failure (decodeWire).
211
+ const [sessionId = "", ...rest] = decodeWire(() => decodeSegments(rawValue));
212
+ const sid = requireSessionId(sessionId);
213
+ if (rest.length === 0)
214
+ throw new BadVerbArgs(
215
+ `set-state: <key>/<value> is required (have keys: ${listStateKeys().join(", ")})`,
216
+ );
217
+ // [LAW:dataflow-not-control-flow] The pair count emerges from the data. The
218
+ // loop walks the same path for N=1 and N=K — no branch on "is this a batch."
219
+ if (rest.length % 2 !== 0) {
220
+ throw new BadVerbArgs(
221
+ `set-state: expected even-count <key>/<value> pairs, got ${rest.length} ` +
222
+ `segment(s) after session id (have keys: ${listStateKeys().join(", ")})`,
223
+ );
224
+ }
225
+ // [LAW:types-are-the-program] Validate the entire batch before any
226
+ // write. The "validated pairs" array IS the proof that every write
227
+ // about to happen is legal — once it's built, the write loop is
228
+ // forced (no branches, no failures possible).
229
+ const validated: Array<{ key: string; value: string }> = [];
230
+ for (let i = 0; i < rest.length; i += 2) {
231
+ const key = rest[i]!;
232
+ const incoming = rest[i + 1]!;
233
+ // [LAW:types-are-the-program] An empty key is a structural error
234
+ // (missing segment), not a semantic one (validator rejection of an
235
+ // unknown key). Routing it to the unknown-key validator message
236
+ // ("unknown state key \"\"") would mislead the operator about
237
+ // where their mistake was. Catch it here, name the pair index so
238
+ // batches are localizable.
239
+ if (!key) {
240
+ throw new BadVerbArgs(
241
+ `set-state: empty key at pair ${i / 2 + 1} ` +
242
+ `(expected <sessionId>/<key>/<value>[/<key>/<value>...] segments)`,
243
+ );
244
+ }
245
+ const result = validateStateWrite(key, incoming);
246
+ if (!result.ok) {
247
+ throw new BadVerbArgs(`set-state: pair ${i / 2 + 1}: ${result.reason}`);
248
+ }
249
+ validated.push({ key, value: result.value });
250
+ }
251
+ // [LAW:single-enforcer] One write call, one log line format. setBatch
252
+ // is the seam that owns reactive atomicity — every pair lands before
253
+ // observers fire, so an autorun never sees half-applied batch state.
254
+ // Partial application is unrepresentable: validation already passed,
255
+ // and the seam guarantees the writes ship as one transaction.
256
+ ctx.sessionState.setBatch(sid, validated);
257
+ const summary = validated.map((p) => `${p.key}=${p.value}`).join(" ");
258
+ ctx.dlog("info", `set-state: ${summary} (session=${sid})`);
259
+ };
260
+
261
+ // [LAW:single-enforcer] One integer-shape boundary, mirroring the range
262
+ // validator's canonical `^-?\d+$`: the `by` delta and a stored current value are
263
+ // integers or they are not values. Only an integer-shaped stored value is a
264
+ // current value; absence (or a non-integer) is the genuine "unset" state, seeded
265
+ // from the registry's configured default.
266
+ const STEP_INT_RE = /^-?\d+$/;
267
+
268
+ // [LAW:no-ambient-temporal-coupling] Stepping past a bound WRAPS to the other end
269
+ // — the navigation owner is THIS handler (moved off the render side, which is no
270
+ // longer the timing authority for the value). The range gate still owns the
271
+ // [min,max] CLAMP; wrap is navigation, clamp is enforcement.
272
+ function wrapStep(n: number, min: number, max: number): number {
273
+ return n > max ? min : n < min ? max : n;
274
+ }
275
+
276
+ // [LAW:one-source-of-truth] A RELATIVE nudge to a bounded state key. The link
277
+ // carries ONLY the irreducible intent `[sessionId, key, by]` (no `current`
278
+ // snapshot), so the SAME link string fires every render and N rapid clicks each
279
+ // re-read live state and accumulate — the idempotent absolute-write bug is gone.
280
+ // The absolute target is computed HERE: read the live value (seed an unset key
281
+ // from the registry's configured default, NOT silently from min), wrap by the
282
+ // signed delta against the registry's bounds, then route the result through
283
+ // validateStateWrite so the one range gate owns the [min,max] clamp and the
284
+ // canonical decimal form that persists.
285
+ const stepState: VerbHandler = (rawValue, ctx) => {
286
+ const [sessionId = "", key = "", byRaw = ""] = decodeWire(() =>
287
+ decodeSegments(rawValue),
288
+ );
289
+ const sid = requireSessionId(sessionId);
290
+ if (!key) {
291
+ throw new BadVerbArgs(
292
+ "step-state: <key> is required (shape: <sessionId>/<key>/<by>)",
293
+ );
294
+ }
295
+ if (!STEP_INT_RE.test(byRaw)) {
296
+ throw new BadVerbArgs(
297
+ `step-state: delta must be an integer, got "${byRaw}"`,
298
+ );
299
+ }
300
+ const by = parseInt(byRaw, 10);
301
+ // [LAW:no-silent-fallbacks] A key with no range registration is not a stepper —
302
+ // reject loudly rather than fabricate bounds or silently no-op.
303
+ const params = rangeParamsFor(key);
304
+ if (!params) {
305
+ throw new BadVerbArgs(
306
+ `step-state: key "${key}" is not a bounded (range) state key ` +
307
+ `(have keys: ${listStateKeys().join(", ")})`,
308
+ );
309
+ }
310
+ // [LAW:no-defensive-null-guards] "unset" is a real state — seed from the
311
+ // configured default; only an integer-shaped stored value is a current value.
312
+ const stored = ctx.sessionState.get(sid, key);
313
+ const current =
314
+ stored && STEP_INT_RE.test(stored)
315
+ ? Math.max(params.min, Math.min(params.max, parseInt(stored, 10)))
316
+ : params.seed;
317
+ const next = wrapStep(current + by, params.min, params.max);
318
+ const result = validateStateWrite(key, String(next));
319
+ if (!result.ok) throw new BadVerbArgs(`step-state: ${result.reason}`);
320
+ ctx.sessionState.set(sid, key, result.value);
321
+ ctx.dlog(
322
+ "info",
323
+ `step-state: ${key} ${current}→${result.value} (by ${by}, session=${sid})`,
324
+ );
325
+ };
326
+
327
+ // ─── Registry ───────────────────────────────────────────────────────────────
328
+
329
+ // [LAW:one-source-of-truth] The LEAF verbs — every click effect that does real
330
+ // work. `dispatch` (below) is NOT here: it folds an effect list back through
331
+ // THIS map, so a dispatch effect can never resolve to dispatch and nesting is
332
+ // structurally impossible [LAW:types-are-the-program] — no recursion guard, the
333
+ // shape forbids it.
334
+ //
335
+ // [LAW:types-are-the-program] `Map` is the dispatch type whose lookup is
336
+ // `(verb) → VerbHandler | undefined` with no prototype chain. The wire-level
337
+ // `verb` field is untrusted input; a `__proto__` or `constructor` value over a
338
+ // plain object would be a truthy hit on Object.prototype that then throws on
339
+ // invocation (RENDER_FAILED instead of BAD_REQUEST). Map makes the wrong
340
+ // dispatch unrepresentable, matching src/daemon/session-state.ts.
341
+ // [LAW:effects-at-boundaries] Per-session config override stored in SessionState.
342
+ // Wire value: `<sessionId>/<percent-encoded-path>`. An empty path clears the
343
+ // override, restoring the request-derived config for that session only.
344
+ // Split at the FIRST slash — the session ID is slash-free (requireSessionId),
345
+ // and the path contains slashes that must not be split.
346
+ // [LAW:no-silent-failure] Path validation is at the verb boundary so a bad path
347
+ // fails the click (BAD_REQUEST), not the next render.
348
+ export const SESSION_CONFIG_OVERRIDE_KEY = "config-override";
349
+ const loadConfig: VerbHandler = (value, ctx) => {
350
+ const slash = value.indexOf("/");
351
+ if (slash === -1) {
352
+ throw new BadVerbArgs(
353
+ "load-config: expected <sessionId>/<path> (missing separator)",
354
+ );
355
+ }
356
+ const sid = requireSessionId(
357
+ decodeWire(() => decodeURIComponent(value.slice(0, slash))),
358
+ );
359
+ const p = decodeWire(() => decodeURIComponent(value.slice(slash + 1))).trim();
360
+ if (p !== "") {
361
+ if (!p.startsWith("/")) {
362
+ throw new BadVerbArgs(`load-config: path must be absolute, got "${p}"`);
363
+ }
364
+ if (!/\.(json5?|json)$/.test(p)) {
365
+ throw new BadVerbArgs(
366
+ `load-config: path must end with .json5 or .json, got "${p}"`,
367
+ );
368
+ }
369
+ }
370
+ if (p === "") {
371
+ ctx.sessionState.clear(sid, SESSION_CONFIG_OVERRIDE_KEY);
372
+ ctx.dlog("info", `load-config: override cleared (session=${sid})`);
373
+ } else {
374
+ ctx.sessionState.set(sid, SESSION_CONFIG_OVERRIDE_KEY, p);
375
+ ctx.dlog("info", `load-config: ${p} (session=${sid})`);
376
+ }
377
+ };
378
+
379
+ const LEAF_VERBS = new Map<string, VerbHandler>([
380
+ [VERB_COPY, copy],
381
+ [VERB_LOAD_CONFIG, loadConfig],
382
+ [VERB_OPEN_VSCODE, openVscode],
383
+ [VERB_SET_STATE, setState],
384
+ [VERB_STEP_STATE, stepState],
385
+ [VERB_SHOW_CONFIG_ERROR, showConfigError],
386
+ [VERB_SHOW_CONFIG_WARNING, showConfigWarning],
387
+ [VERB_TOOLBAR_TOGGLE, toolbarToggle],
388
+ ]);
389
+
390
+ // [LAW:dataflow-not-control-flow] One click is an ordered list of effects; the
391
+ // dispatcher folds the list, running EVERY effect through the leaf table. The
392
+ // effect count is data — N=1 and N=100 walk the identical loop, no plain-vs-
393
+ // compound branch. [LAW:no-silent-fallbacks] Every effect runs even if an
394
+ // earlier one failed; failures accumulate in `errors`. An unknown or
395
+ // non-leaf (e.g. nested `dispatch`) verb is a miss in LEAF_VERBS — reported,
396
+ // never executed.
397
+ //
398
+ // [LAW:types-are-the-program] The aggregate PRESERVES the dispatcher's
399
+ // input-vs-operational error classification: a leaf throws BadVerbArgs for bad
400
+ // input (→ BAD_REQUEST) and a plain Error for an operational failure (e.g. a
401
+ // pbcopy/open launch failure → RENDER_FAILED). If ANY effect failed
402
+ // operationally, the whole click failed operationally (plain Error); only when
403
+ // every failure is an input error does the aggregate stay BadVerbArgs. An
404
+ // unknown verb is bad input — it does not flip the classification.
405
+ //
406
+ // [LAW:one-source-of-truth] Per-effect errors are written to session state
407
+ // under 'click.error' so the next render shows WHICH effect(s) failed in the
408
+ // bar transiently (one render, then cleared). Only possible when a session ID
409
+ // is available from a set-state or toolbar-toggle effect in the same click.
410
+ const dispatch: VerbHandler = (rawValue, ctx) => {
411
+ const errors: string[] = [];
412
+ let operational = false;
413
+ let sessionId: string | null = null;
414
+ for (const { verb, value } of parseEffects(rawValue)) {
415
+ // Extract session ID from the first session-bearing effect for error display.
416
+ // set-state, step-state, and toolbar-toggle all carry the session id as their
417
+ // first segment, so a failing step surfaces in the bar like any other.
418
+ if (
419
+ !sessionId &&
420
+ (verb === VERB_SET_STATE ||
421
+ verb === VERB_STEP_STATE ||
422
+ verb === VERB_TOOLBAR_TOGGLE)
423
+ ) {
424
+ const parts = decodeSegments(value);
425
+ if (parts.length > 0 && parts[0]) sessionId = parts[0];
426
+ }
427
+ const handler = LEAF_VERBS.get(verb);
428
+ if (!handler) {
429
+ errors.push(`unknown effect verb "${verb}"`);
430
+ continue;
431
+ }
432
+ try {
433
+ handler(value, ctx);
434
+ } catch (e) {
435
+ if (!(e instanceof BadVerbArgs)) operational = true;
436
+ errors.push(`${verb}: ${e instanceof Error ? e.message : String(e)}`);
437
+ }
438
+ }
439
+ if (errors.length > 0) {
440
+ if (sessionId) {
441
+ ctx.sessionState.set(sessionId, "click.error", errors.join("\n"));
442
+ }
443
+ const message = `dispatch: ${errors.join("; ")}`;
444
+ throw operational ? new Error(message) : new BadVerbArgs(message);
445
+ }
446
+ };
447
+
448
+ // [LAW:one-source-of-truth] The full dispatch table the daemon looks up against:
449
+ // every leaf verb plus the one `dispatch` wrapper. Old scrollback links that
450
+ // name a leaf verb directly still resolve here; new renders all emit `dispatch`.
451
+ export const VERBS: ReadonlyMap<string, VerbHandler> = new Map<
452
+ string,
453
+ VerbHandler
454
+ >([...LEAF_VERBS, [VERB_DISPATCH, dispatch]]);
455
+
456
+ export const VERB_NAMES: readonly string[] = Object.freeze([
457
+ ...VERBS.keys(),
458
+ ]) as readonly string[];