@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.
- package/LICENSE +21 -0
- package/README.md +145 -0
- package/bin/cc-candybar +6 -0
- package/dist/index.mjs +185 -0
- package/package.json +99 -0
- package/plugin/.claude-plugin/plugin.json +11 -0
- package/plugin/bin/preview.sh +305 -0
- package/plugin/commands/candybar.md +403 -0
- package/plugin/templates/config-essential.json +36 -0
- package/plugin/templates/config-full.json +55 -0
- package/plugin/templates/config-standard.json +39 -0
- package/plugin/templates/config-tui-compact.json +48 -0
- package/plugin/templates/config-tui-full.json +89 -0
- package/plugin/templates/config-tui-standard.json +56 -0
- package/plugin/templates/config-tui.json +18 -0
- package/plugin/templates/nerd-fonts-sample.txt +5 -0
- package/schema/cc-candybar.schema.json +1379 -0
- package/src/click/wire.ts +113 -0
- package/src/config/action.ts +91 -0
- package/src/config/cli.ts +170 -0
- package/src/config/default-dsl-config.ts +661 -0
- package/src/config/dsl-loader.ts +265 -0
- package/src/config/dsl-types.ts +425 -0
- package/src/config/loader/actions.ts +530 -0
- package/src/config/loader/cache.ts +206 -0
- package/src/config/loader/cross-ref.ts +326 -0
- package/src/config/loader/cycles.ts +148 -0
- package/src/config/loader/diagnostics.ts +99 -0
- package/src/config/loader/discovery.ts +182 -0
- package/src/config/loader/emit-schema.ts +63 -0
- package/src/config/loader/globals.ts +42 -0
- package/src/config/loader/helpers.ts +48 -0
- package/src/config/loader/layout.ts +688 -0
- package/src/config/loader/merge.ts +40 -0
- package/src/config/loader/refs.ts +96 -0
- package/src/config/loader/segments.ts +120 -0
- package/src/config/loader/validate-core.ts +674 -0
- package/src/config/loader/variables.ts +260 -0
- package/src/daemon/acquire.ts +411 -0
- package/src/daemon/cache/git.ts +553 -0
- package/src/daemon/cache/render.ts +449 -0
- package/src/daemon/cache/session-usage-store.ts +446 -0
- package/src/daemon/cache/watchers.ts +245 -0
- package/src/daemon/client-debug.ts +120 -0
- package/src/daemon/client-stats.ts +129 -0
- package/src/daemon/client-transport.ts +273 -0
- package/src/daemon/client.ts +75 -0
- package/src/daemon/debug-types.ts +91 -0
- package/src/daemon/debug.ts +264 -0
- package/src/daemon/limits.ts +154 -0
- package/src/daemon/log.ts +69 -0
- package/src/daemon/parent-watchdog.ts +80 -0
- package/src/daemon/paths.ts +127 -0
- package/src/daemon/protocol.ts +235 -0
- package/src/daemon/render-payload.ts +611 -0
- package/src/daemon/server.ts +1103 -0
- package/src/daemon/session-state-file.ts +108 -0
- package/src/daemon/session-state.ts +237 -0
- package/src/daemon/stats.ts +229 -0
- package/src/daemon/verbs/index.ts +458 -0
- package/src/daemon/verbs/state-validators.ts +708 -0
- package/src/demo/dsl.ts +117 -0
- package/src/demo/mock-data.ts +67 -0
- package/src/demo/statusline.json5 +92 -0
- package/src/dsl/node-registry.ts +281 -0
- package/src/dsl/render.ts +558 -0
- package/src/index.ts +206 -0
- package/src/install/index.ts +410 -0
- package/src/proc/launch.ts +451 -0
- package/src/proc/stats-handle.ts +13 -0
- package/src/render/action.ts +458 -0
- package/src/render/diagnostic-style.ts +23 -0
- package/src/render/diagnostic-text.ts +77 -0
- package/src/render/error-glyph.ts +53 -0
- package/src/render/outcome-plan.ts +45 -0
- package/src/render/picker.ts +231 -0
- package/src/render/split-lines.ts +51 -0
- package/src/render/strip.ts +103 -0
- package/src/segments/cache.ts +131 -0
- package/src/segments/context.ts +190 -0
- package/src/segments/git.ts +561 -0
- package/src/segments/metrics.ts +101 -0
- package/src/segments/pricing.ts +452 -0
- package/src/segments/session.ts +188 -0
- package/src/segments/tmux.ts +74 -0
- package/src/template-engine/cells.ts +90 -0
- package/src/template-engine/colors.ts +102 -0
- package/src/template-engine/engine.ts +108 -0
- package/src/template-engine/funcs.ts +216 -0
- package/src/template-engine/index.ts +11 -0
- package/src/template-engine/layout.ts +112 -0
- package/src/template-engine/scope.ts +62 -0
- package/src/themes/index.ts +19 -0
- package/src/themes/palette-resolvers.ts +86 -0
- package/src/themes/policy.ts +79 -0
- package/src/themes/session-random.ts +88 -0
- package/src/utils/cache.ts +206 -0
- package/src/utils/claude.ts +616 -0
- package/src/utils/color-support.ts +118 -0
- package/src/utils/formatters.ts +77 -0
- package/src/utils/logger.ts +5 -0
- package/src/utils/outcome.ts +33 -0
- package/src/utils/schema-validator.ts +126 -0
- package/src/utils/single-flight.ts +57 -0
- package/src/utils/terminal-width.ts +43 -0
- package/src/utils/terminal.ts +11 -0
- package/src/utils/transcript-fs.ts +162 -0
- package/src/var-system/index.ts +24 -0
- package/src/var-system/sources.ts +1038 -0
- package/src/var-system/store.ts +223 -0
- 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[];
|