@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,1038 @@
1
+ // [LAW:one-source-of-truth] Source kinds are the bridge between external
2
+ // data (render payload, environment, static config) and the VariableStore.
3
+ // All payload ingestion goes through applyInput — there is no other path
4
+ // that writes input-kind boxes during a render.
5
+ //
6
+ // Two concerns deliberately separated:
7
+ // - VariableStore: reactivity primitives (boxes, computeds, MobX scheduling)
8
+ // - SourceRegistry: source-kind semantics (path resolution, fallback chain,
9
+ // last_error tracking)
10
+
11
+ import { launch } from "../proc/launch";
12
+ import { debug } from "../utils/logger";
13
+ import { readFile as fsReadFile } from "fs/promises";
14
+ import { watch as fsWatch, type FSWatcher } from "fs";
15
+ import { setInterval, clearInterval } from "timers";
16
+ import { reaction, type IReactionDisposer } from "mobx";
17
+ import type { RichText } from "@promptctl/rich-js";
18
+ import {
19
+ typeOf,
20
+ toString,
21
+ toNumber,
22
+ toBool,
23
+ type VarType,
24
+ type VarValue,
25
+ } from "./types.js";
26
+ import type { VariableStore } from "./store.js";
27
+ import { createCcCandybarEngine } from "../template-engine/engine.js";
28
+ import { buildScope } from "../template-engine/scope.js";
29
+ import { GitDataProvider } from "../daemon/cache/git.js";
30
+ import type { GitInfo } from "../segments/git.js";
31
+ import { orElse } from "../utils/outcome.js";
32
+ import type { SessionStateReader } from "../daemon/session-state.js";
33
+
34
+ // ─── CachePolicy ─────────────────────────────────────────────────────────────
35
+
36
+ // [LAW:one-type-per-behavior] One discriminated union covers all cache policies.
37
+ // The config layer normalises external string representations (e.g. ttl:"5s")
38
+ // before calling declareShell / declareFile.
39
+ export type CachePolicy =
40
+ | { readonly kind: "ttl"; readonly durationMs: number }
41
+ | { readonly kind: "watch_file"; readonly path: string }
42
+ | { readonly kind: "key"; readonly template: string }
43
+ | { readonly kind: "depends_on"; readonly varNames: readonly string[] }
44
+ | { readonly kind: "never" };
45
+
46
+ // [LAW:single-enforcer] Minimum allowed TTL for user-shell sources. User
47
+ // config can request shorter values; declareShell clamps to this floor and
48
+ // emits a debug warning. The floor exists to prevent unbounded subprocess
49
+ // churn from a misconfigured `ttl: 50ms` against a 200ms command (which would
50
+ // silently overlap and stack up). [LAW:no-mode-explosion] not a config knob.
51
+ export const MIN_SHELL_TTL_MS = 500;
52
+
53
+ // [LAW:single-enforcer] Apply the shell-source TTL floor at declare-time.
54
+ // If the policy is anything other than `ttl`, the input is returned unchanged
55
+ // — the floor only applies to time-driven shell refresh. If `ttl.durationMs`
56
+ // is already at or above MIN_SHELL_TTL_MS, the input is returned unchanged.
57
+ // [LAW:dataflow-not-control-flow] Same code path every call; the result is a
58
+ // function of the input policy + floor constant.
59
+ function clampShellCache(name: string, policy: CachePolicy): CachePolicy {
60
+ if (policy.kind !== "ttl") return policy;
61
+ if (policy.durationMs >= MIN_SHELL_TTL_MS) return policy;
62
+ debug(
63
+ `declareShell "${name}": ttl ${policy.durationMs}ms below floor ${MIN_SHELL_TTL_MS}ms; clamping`,
64
+ );
65
+ return { kind: "ttl", durationMs: MIN_SHELL_TTL_MS };
66
+ }
67
+
68
+ // Parse a duration string to milliseconds. Accepted units: ms, s, m, h.
69
+ export function parseDuration(s: string): number {
70
+ const m = /^(\d+(?:\.\d+)?)(ms|s|m|h)$/.exec(s);
71
+ if (!m) throw new RangeError(`Invalid duration: "${s}"`);
72
+ const v = parseFloat(m[1]!);
73
+ switch (m[2]) {
74
+ case "ms":
75
+ return v;
76
+ case "s":
77
+ return v * 1_000;
78
+ case "m":
79
+ return v * 60_000;
80
+ case "h":
81
+ return v * 3_600_000;
82
+ default:
83
+ throw new RangeError(`Unexpected duration unit: "${m[2]}"`);
84
+ }
85
+ }
86
+
87
+ // ─── Source-kind option bags ──────────────────────────────────────────────────
88
+
89
+ export interface ShellOptions {
90
+ readonly regex?: string;
91
+ readonly cache: CachePolicy;
92
+ readonly varDefault?: string;
93
+ }
94
+
95
+ export interface FileOptions {
96
+ // Ignored when regex is set (regex implies whole-file scan).
97
+ readonly readMode?: "whole" | "first-line";
98
+ readonly regex?: string;
99
+ readonly cache: CachePolicy;
100
+ readonly varDefault?: string;
101
+ }
102
+
103
+ export interface TemplateOptions {
104
+ readonly varDefault?: string;
105
+ }
106
+
107
+ export interface TimeOptions {
108
+ // Go reference-time layout string (e.g. "15:04:05", "2006-01-02").
109
+ // Reference time: Mon Jan 2 15:04:05 MST 2006
110
+ readonly format: string;
111
+ // Refresh interval. Defaults to 1 000 ms.
112
+ readonly ttlMs?: number;
113
+ readonly varDefault?: string;
114
+ }
115
+
116
+ // [LAW:one-type-per-behavior] Six git fields — each has a fixed inferred type.
117
+ // branch/sha are strings; dirty is boolean; ahead/behind/stash are numbers.
118
+ export type GitField =
119
+ | "branch"
120
+ | "sha"
121
+ | "dirty"
122
+ | "ahead"
123
+ | "behind"
124
+ | "stash";
125
+
126
+ export interface GitOptions {
127
+ readonly field: GitField;
128
+ // Working directory whose git repo to query. Resolved at declaration time.
129
+ readonly cwd: string;
130
+ readonly varDefault?: VarValue;
131
+ }
132
+
133
+ // [LAW:one-source-of-truth] state vars read through to SessionState; the
134
+ // reactive contract is owned by SessionState's internal atom. The computed
135
+ // reads the canonical session-id variable (SESSION_ID_VAR_NAME) and
136
+ // dispatches through SessionStateReader.get.
137
+ export interface StateOptions {
138
+ readonly key: string;
139
+ readonly varDefault?: string;
140
+ }
141
+
142
+ // [LAW:one-source-of-truth] The conventional name DSL configs use for the
143
+ // hook payload's session_id input variable. State-kind variables resolve
144
+ // "which session am I in" from this name — no per-decl override.
145
+ // [LAW:no-mode-explosion] One axis of variability less; configs cannot
146
+ // drift on which variable carries the session id.
147
+ export const SESSION_ID_VAR_NAME = "session.id";
148
+
149
+ // ─── Private infrastructure ───────────────────────────────────────────────────
150
+
151
+ // Execute command in /bin/sh; resolve with stdout (raw) and exit code.
152
+ // [LAW:no-defensive-null-guards] Errors surface as exitCode=1 + empty stdout
153
+ // rather than throwing — the caller owns the fallback chain.
154
+ async function execShell(
155
+ command: string,
156
+ ): Promise<{ stdout: string; exitCode: number }> {
157
+ const r = await launch({
158
+ bin: "/bin/sh",
159
+ args: ["-c", command],
160
+ category: "user-shell",
161
+ });
162
+ if (r.ok) return { stdout: r.stdout, exitCode: r.exitCode ?? 0 };
163
+ return { stdout: r.stdout, exitCode: r.exitCode ?? 1 };
164
+ }
165
+
166
+ interface FileResult {
167
+ content?: string;
168
+ error?: string;
169
+ }
170
+
171
+ // Read a file and extract the relevant text fragment.
172
+ // Returns {error} on I/O failure or regex no-match; {content} on success.
173
+ async function readFileContent(
174
+ filePath: string,
175
+ readMode: "whole" | "first-line" | undefined,
176
+ regex: string | undefined,
177
+ ): Promise<FileResult> {
178
+ let raw: string;
179
+ try {
180
+ raw = await fsReadFile(filePath, "utf8");
181
+ } catch {
182
+ return { error: `file unreadable: ${filePath}` };
183
+ }
184
+
185
+ if (regex !== undefined) {
186
+ const m = new RegExp(regex).exec(raw);
187
+ if (!m?.[1]) return { error: `regex no-match in "${filePath}"` };
188
+ return { content: m[1].replace(/\n/g, " ") };
189
+ }
190
+
191
+ if (readMode === "first-line") {
192
+ return { content: (raw.split("\n")[0] ?? "").trim() };
193
+ }
194
+
195
+ // whole (default): newlines → spaces, trailing whitespace stripped
196
+ return { content: raw.replace(/\n/g, " ").trim() };
197
+ }
198
+
199
+ // ─── Go reference-time formatter ─────────────────────────────────────────────
200
+
201
+ const MONTHS_FULL = [
202
+ "January",
203
+ "February",
204
+ "March",
205
+ "April",
206
+ "May",
207
+ "June",
208
+ "July",
209
+ "August",
210
+ "September",
211
+ "October",
212
+ "November",
213
+ "December",
214
+ ] as const;
215
+ const MONTHS_SHORT = [
216
+ "Jan",
217
+ "Feb",
218
+ "Mar",
219
+ "Apr",
220
+ "May",
221
+ "Jun",
222
+ "Jul",
223
+ "Aug",
224
+ "Sep",
225
+ "Oct",
226
+ "Nov",
227
+ "Dec",
228
+ ] as const;
229
+ const WEEKDAYS_FULL = [
230
+ "Sunday",
231
+ "Monday",
232
+ "Tuesday",
233
+ "Wednesday",
234
+ "Thursday",
235
+ "Friday",
236
+ "Saturday",
237
+ ] as const;
238
+ const WEEKDAYS_SHORT = [
239
+ "Sun",
240
+ "Mon",
241
+ "Tue",
242
+ "Wed",
243
+ "Thu",
244
+ "Fri",
245
+ "Sat",
246
+ ] as const;
247
+
248
+ // [LAW:single-enforcer] One Go-reference-time formatter shared by all time
249
+ // source kinds. Tokens are matched longest-first in a single left-to-right
250
+ // pass so overlapping prefixes ("January" before "Jan") never conflict.
251
+ //
252
+ // Reference time components:
253
+ // 2006 → 4-digit year 06 → 2-digit year
254
+ // January / Jan → month 01 → 2-digit month 1 → 1/2-digit month
255
+ // Monday / Mon → weekday
256
+ // 02 → 2-digit day 2 → 1/2-digit day
257
+ // 15 → 24h hour (00-23) 3 → 12h hour (1-12)
258
+ // 04 → 2-digit minute 4 → minute
259
+ // 05 → 2-digit second 5 → second
260
+ // PM / pm → AM/PM marker
261
+ export function formatGoTime(layout: string, d: Date): string {
262
+ type Token = readonly [string, (d: Date) => string];
263
+ const tokens: readonly Token[] = [
264
+ ["2006", (d) => String(d.getFullYear())],
265
+ ["January", (d) => MONTHS_FULL[d.getMonth()]!],
266
+ ["Monday", (d) => WEEKDAYS_FULL[d.getDay()]!],
267
+ ["Jan", (d) => MONTHS_SHORT[d.getMonth()]!],
268
+ ["Mon", (d) => WEEKDAYS_SHORT[d.getDay()]!],
269
+ ["15", (d) => String(d.getHours()).padStart(2, "0")],
270
+ ["06", (d) => String(d.getFullYear() % 100).padStart(2, "0")],
271
+ ["01", (d) => String(d.getMonth() + 1).padStart(2, "0")],
272
+ ["02", (d) => String(d.getDate()).padStart(2, "0")],
273
+ ["04", (d) => String(d.getMinutes()).padStart(2, "0")],
274
+ ["05", (d) => String(d.getSeconds()).padStart(2, "0")],
275
+ ["PM", (d) => (d.getHours() < 12 ? "AM" : "PM")],
276
+ ["pm", (d) => (d.getHours() < 12 ? "am" : "pm")],
277
+ ["1", (d) => String(d.getMonth() + 1)],
278
+ ["2", (d) => String(d.getDate())],
279
+ ["3", (d) => String(d.getHours() % 12 || 12)],
280
+ ["4", (d) => String(d.getMinutes())],
281
+ ["5", (d) => String(d.getSeconds())],
282
+ ];
283
+
284
+ let result = "";
285
+ let i = 0;
286
+ while (i < layout.length) {
287
+ let consumed = false;
288
+ for (const [token, fn] of tokens) {
289
+ if (layout.startsWith(token, i)) {
290
+ result += fn(d);
291
+ i += token.length;
292
+ consumed = true;
293
+ break;
294
+ }
295
+ }
296
+ if (!consumed) {
297
+ result += layout[i];
298
+ i++;
299
+ }
300
+ }
301
+ return result;
302
+ }
303
+
304
+ // ─── Git helpers ─────────────────────────────────────────────────────────────
305
+
306
+ // [LAW:one-source-of-truth] One type map; the field discriminator determines
307
+ // the box type at declaration time — no runtime coercion needed.
308
+ const GIT_FIELD_TYPE: Readonly<Record<GitField, VarType>> = {
309
+ branch: "string",
310
+ sha: "string",
311
+ dirty: "boolean",
312
+ ahead: "number",
313
+ behind: "number",
314
+ stash: "number",
315
+ };
316
+
317
+ // [LAW:one-source-of-truth] Git data flows through GitDataProvider. The
318
+ // projection below is the only mapping from GitInfo (segments' shape) to
319
+ // var-system's six-field model. Pre-kz8.3 var-system maintained its own
320
+ // parallel fleet (execGit + fetchGitSnapshot + GitPoller + WatchManager
321
+ // subscriptions); the provider now owns the cache, the watcher, and the
322
+ // single launch category "git".
323
+
324
+ // Project a GitInfo snapshot down to a single var-system GitField value.
325
+ // Returns the typed fallback when info is null (not a repo or unresolved).
326
+ function projectGitField(
327
+ info: GitInfo | null,
328
+ field: GitField,
329
+ varDefault: VarValue | undefined,
330
+ defaultEmptyValue: VarValue,
331
+ ): VarValue {
332
+ if (info === null) {
333
+ if (varDefault !== undefined) return varDefault;
334
+ const type = GIT_FIELD_TYPE[field];
335
+ try {
336
+ return coerceToType(defaultEmptyValue, type);
337
+ } catch {
338
+ return zeroValue(type);
339
+ }
340
+ }
341
+ switch (field) {
342
+ case "branch":
343
+ // GitService emits the literal "detached" when HEAD is not on a
344
+ // branch; var-system's prior contract was empty string in that case.
345
+ // (Caveat: a branch literally named "detached" would also map to "" —
346
+ // preserving the pre-kz8.3 behavior, which had the same ambiguity in
347
+ // a different shape.)
348
+ return info.branch === "detached" ? "" : info.branch;
349
+ // [LAW:dataflow-not-control-flow] Outcome fields fold via orElse: this
350
+ // surface only renders values, so absent and failed both collapse to the
351
+ // typed zero (the provider's delivery edge already logged any failure).
352
+ case "sha":
353
+ return orElse(info.sha, "");
354
+ case "dirty":
355
+ return info.status !== "clean";
356
+ case "ahead":
357
+ return orElse(info.aheadBehind, { ahead: 0, behind: 0 }).ahead;
358
+ case "behind":
359
+ return orElse(info.aheadBehind, { ahead: 0, behind: 0 }).behind;
360
+ case "stash":
361
+ return orElse(info.stashCount, 0);
362
+ }
363
+ }
364
+
365
+ // ─── WatchManager ─────────────────────────────────────────────────────────────
366
+
367
+ // [LAW:single-enforcer] One fs.watch handle per path regardless of subscriber
368
+ // count. Multiple shell/file variables can share one watcher on the same path.
369
+ class WatchManager {
370
+ private readonly watchers = new Map<
371
+ string,
372
+ { watcher: FSWatcher; callbacks: Set<() => void> }
373
+ >();
374
+
375
+ subscribe(filePath: string, callback: () => void): () => void {
376
+ let entry = this.watchers.get(filePath);
377
+ if (!entry) {
378
+ const callbacks = new Set<() => void>();
379
+ let watcher: FSWatcher;
380
+ try {
381
+ watcher = fsWatch(filePath, () => {
382
+ for (const cb of callbacks) cb();
383
+ });
384
+ } catch {
385
+ // File may not exist yet; silently skip watch setup.
386
+ return () => {};
387
+ }
388
+ entry = { watcher, callbacks };
389
+ this.watchers.set(filePath, entry);
390
+ }
391
+ entry.callbacks.add(callback);
392
+ return () => this.unsubscribe(filePath, callback);
393
+ }
394
+
395
+ private unsubscribe(filePath: string, callback: () => void): void {
396
+ const entry = this.watchers.get(filePath);
397
+ if (!entry) return;
398
+ entry.callbacks.delete(callback);
399
+ if (entry.callbacks.size === 0) {
400
+ entry.watcher.close();
401
+ this.watchers.delete(filePath);
402
+ }
403
+ }
404
+
405
+ dispose(): void {
406
+ for (const { watcher } of this.watchers.values()) watcher.close();
407
+ this.watchers.clear();
408
+ }
409
+
410
+ size(): number {
411
+ return this.watchers.size;
412
+ }
413
+ }
414
+
415
+ // ─── TtlBucketManager ────────────────────────────────────────────────────────
416
+
417
+ // [LAW:single-enforcer] One setInterval per unique TTL duration, shared by all
418
+ // variables with that TTL. Multiple variables at the same interval fire on
419
+ // one timer tick rather than N separate timers.
420
+ class TtlBucketManager {
421
+ private readonly buckets = new Map<
422
+ number,
423
+ { timer: ReturnType<typeof setInterval>; callbacks: Set<() => void> }
424
+ >();
425
+
426
+ subscribe(durationMs: number, callback: () => void): () => void {
427
+ let entry = this.buckets.get(durationMs);
428
+ if (!entry) {
429
+ const callbacks = new Set<() => void>();
430
+ const timer = setInterval(() => {
431
+ for (const cb of callbacks) cb();
432
+ }, durationMs);
433
+ entry = { timer, callbacks };
434
+ this.buckets.set(durationMs, entry);
435
+ }
436
+ entry.callbacks.add(callback);
437
+ return () => this.unsubscribe(durationMs, callback);
438
+ }
439
+
440
+ private unsubscribe(durationMs: number, callback: () => void): void {
441
+ const entry = this.buckets.get(durationMs);
442
+ if (!entry) return;
443
+ entry.callbacks.delete(callback);
444
+ if (entry.callbacks.size === 0) {
445
+ clearInterval(entry.timer);
446
+ this.buckets.delete(durationMs);
447
+ }
448
+ }
449
+
450
+ dispose(): void {
451
+ for (const { timer } of this.buckets.values()) clearInterval(timer);
452
+ this.buckets.clear();
453
+ }
454
+
455
+ bucketCount(): number {
456
+ return this.buckets.size;
457
+ }
458
+ }
459
+
460
+ // ─── Shared metadata ──────────────────────────────────────────────────────────
461
+
462
+ export interface LastError {
463
+ readonly timestamp: number; // Date.now() epoch ms
464
+ readonly message: string;
465
+ }
466
+
467
+ interface InputMeta {
468
+ readonly path: string;
469
+ readonly varDefault: VarValue | undefined;
470
+ }
471
+
472
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
473
+
474
+ // Recursively resolves a dotted path through a plain object.
475
+ // Returns undefined if any segment is absent or the traversed value is not an object.
476
+ function resolvePath(obj: unknown, path: string): unknown {
477
+ let cur: unknown = obj;
478
+ for (const part of path.split(".")) {
479
+ if (cur == null || typeof cur !== "object") return undefined;
480
+ cur = (cur as Record<string, unknown>)[part];
481
+ }
482
+ return cur;
483
+ }
484
+
485
+ // Coerces an external primitive to a typed VarValue using the cast helpers
486
+ // from types.ts. Throws for non-primitive runtypes or impossible casts.
487
+ function coerceToType(raw: unknown, type: VarType): VarValue {
488
+ // [LAW:no-defensive-null-guards] Trust-boundary check: payload values must be
489
+ // primitives. Non-primitive means malformed input — fail loudly.
490
+ if (
491
+ typeof raw !== "string" &&
492
+ typeof raw !== "number" &&
493
+ typeof raw !== "boolean"
494
+ ) {
495
+ throw new TypeError(
496
+ `Expected string|number|boolean from payload, got ${typeof raw}`,
497
+ );
498
+ }
499
+ if (type === "string") return toString(raw);
500
+ if (type === "number") return toNumber(raw);
501
+ return toBool(raw);
502
+ }
503
+
504
+ // Type-appropriate zero used as the final backstop in the fallback chain when
505
+ // neither per-variable default nor defaultEmptyValue can be coerced.
506
+ function zeroValue(type: VarType): VarValue {
507
+ if (type === "number") return 0;
508
+ if (type === "boolean") return false;
509
+ return "";
510
+ }
511
+
512
+ // ─── SourceRegistry ───────────────────────────────────────────────────────────
513
+
514
+ // [LAW:single-enforcer] One SourceRegistry per daemon, sharing one
515
+ // VariableStore. Multiple registries on the same store would produce
516
+ // duplicate box definitions for input-kind variables.
517
+
518
+ export class SourceRegistry {
519
+ private readonly inputMetas = new Map<string, InputMeta>();
520
+ private readonly lastErrors = new Map<string, LastError>();
521
+
522
+ // Infrastructure for shell/file/git source kinds:
523
+ private readonly watchMgr = new WatchManager();
524
+ private readonly ttlMgr = new TtlBucketManager();
525
+ // [LAW:single-enforcer] One subscription per cwd — every git variable
526
+ // pointing at the same working directory shares one GitDataProvider
527
+ // subscription, and the provider in turn shares one watcher + one cache
528
+ // entry across N subscribers in the same repo.
529
+ private readonly gitSubscriptions = new Map<
530
+ string,
531
+ {
532
+ fieldSubs: Map<
533
+ GitField,
534
+ Array<{ name: string; varDefault: VarValue | undefined }>
535
+ >;
536
+ unsubscribe: () => void;
537
+ }
538
+ >();
539
+ // Collects all cleanup callbacks (TTL unsubscribes, watch unsubscribes,
540
+ // MobX reaction disposers) so dispose() tears everything down in one call.
541
+ private readonly cleanups: Array<() => void> = [];
542
+ // Guards against concurrent executions of the same async source.
543
+ private readonly inFlight = new Set<string>();
544
+ // Shared engine instance — parse() is expensive; the engine is reused for
545
+ // all key: template compilations.
546
+ // [LAW:one-source-of-truth] One engine per registry, not one per variable.
547
+ private readonly engine = createCcCandybarEngine();
548
+
549
+ private readonly gitProvider: GitDataProvider;
550
+ private readonly ownsGitProvider: boolean;
551
+ // [LAW:locality-or-seam] sessionState is injected (not constructed here)
552
+ // so tests can substitute a fake and the daemon shares its singleton.
553
+ // Absent in non-daemon contexts; declareState() rejects loudly in that case
554
+ // rather than silently returning empty strings.
555
+ private readonly sessionState: SessionStateReader | undefined;
556
+
557
+ // defaultEmptyValue is the global fallback of last resort — the config-level
558
+ // `default_empty_value` from the proposal. Defaults to empty string.
559
+ //
560
+ // gitProvider lets the daemon inject its shared instance; when omitted (e.g.
561
+ // in tests, or pre-daemon-wired runtimes), a private one is constructed so
562
+ // the registry remains self-contained.
563
+ //
564
+ // sessionState lets the daemon inject its singleton so state-kind variables
565
+ // share one MobX atom and one disk-persistence layer with the click verbs.
566
+ // Omitted in tests that don't exercise state vars.
567
+ constructor(
568
+ private readonly store: VariableStore,
569
+ private readonly defaultEmptyValue: VarValue = "",
570
+ gitProvider?: GitDataProvider,
571
+ sessionState?: SessionStateReader,
572
+ ) {
573
+ if (gitProvider) {
574
+ this.gitProvider = gitProvider;
575
+ this.ownsGitProvider = false;
576
+ } else {
577
+ this.gitProvider = new GitDataProvider({ sanityIntervalMs: 0 });
578
+ this.ownsGitProvider = true;
579
+ }
580
+ this.sessionState = sessionState;
581
+ }
582
+
583
+ // ─── Synchronous source kinds ─────────────────────────────────────────────
584
+
585
+ // literal: type inferred from value; box written once at declaration and never again.
586
+ declareLiteral(name: string, value: VarValue): void {
587
+ this.store.defineBox(name, typeOf(value), value);
588
+ }
589
+
590
+ // input: per-render box; initial value from fallback chain (path not yet resolved).
591
+ // At each render, applyInput resolves path against the payload and updates the box.
592
+ declareInput(
593
+ name: string,
594
+ path: string,
595
+ type: VarType,
596
+ varDefault?: VarValue,
597
+ ): void {
598
+ // [LAW:dataflow-not-control-flow] Initialize to the fallback value so the
599
+ // box always holds a valid typed value — even before the first render push.
600
+ const initial =
601
+ varDefault !== undefined ? varDefault : this.defaultFor(type);
602
+ this.store.defineBox(name, type, initial);
603
+ this.inputMetas.set(name, { path, varDefault });
604
+ }
605
+
606
+ // env: resolved once at declaration from process.env; box written once, never again.
607
+ // type is always 'string' — env vars are text by nature.
608
+ declareEnv(name: string, envVar: string, varDefault?: string): void {
609
+ const raw = process.env[envVar];
610
+ if (raw !== undefined) {
611
+ this.store.defineBox(name, "string", raw);
612
+ return;
613
+ }
614
+ // Env var absent: apply fallback chain, record last_error.
615
+ const fallback =
616
+ varDefault !== undefined
617
+ ? varDefault
618
+ : typeof this.defaultEmptyValue === "string"
619
+ ? this.defaultEmptyValue
620
+ : "";
621
+ this.store.defineBox(name, "string", fallback);
622
+ this.recordError(name, `env var "${envVar}" is not set`);
623
+ }
624
+
625
+ // ─── Async source kinds ───────────────────────────────────────────────────
626
+
627
+ // shell: spawn command in /bin/sh; capture stdout; optional regex group-1 extract;
628
+ // newlines → spaces. Box initialises to fallback; async execution fills it in.
629
+ // [LAW:dataflow-not-control-flow] Box always holds a valid value; the cache
630
+ // policy drives when it is refreshed, not whether the box exists.
631
+ declareShell(name: string, command: string, opts: ShellOptions): void {
632
+ const cache = clampShellCache(name, opts.cache);
633
+ this.store.defineBox(name, "string", this.stringInitial(opts.varDefault));
634
+ const update = () =>
635
+ void this.updateFromShell(name, command, opts.regex, opts.varDefault);
636
+ update(); // initial run
637
+ this.registerCachePolicy(name, cache, update);
638
+ }
639
+
640
+ // file: read file at path; whole / first-line / regex group-1 extract; newlines → spaces.
641
+ // Box initialises to fallback; async read fills it in.
642
+ // [LAW:dataflow-not-control-flow] Same invariant as declareShell.
643
+ declareFile(name: string, filePath: string, opts: FileOptions): void {
644
+ this.store.defineBox(name, "string", this.stringInitial(opts.varDefault));
645
+ const update = () =>
646
+ void this.updateFromFile(
647
+ name,
648
+ filePath,
649
+ opts.readMode,
650
+ opts.regex,
651
+ opts.varDefault,
652
+ );
653
+ update(); // initial run
654
+ this.registerCachePolicy(name, opts.cache, update);
655
+ }
656
+
657
+ // template: a variable whose value is derived by evaluating a go-template
658
+ // against the current variable store. MobX auto-tracks every store.read()
659
+ // made during evaluation — no explicit dep declarations needed.
660
+ // [LAW:dataflow-not-control-flow] defineComputed registers a MobX computed;
661
+ // the invalidation graph builds itself from the template's read pattern.
662
+ declareTemplate(
663
+ name: string,
664
+ template: string,
665
+ opts: TemplateOptions = {},
666
+ ): void {
667
+ // Parse once at declaration time — parse() is expensive; evaluate() is cheap.
668
+ // A ParseError propagates here so invalid templates fail at config load, not
669
+ // at the first render.
670
+ const parsedTpl = this.engine.parse(template);
671
+ this.store.defineComputed(name, "string", (_read) => {
672
+ const scope = buildScope(this.store);
673
+ try {
674
+ const result = (parsedTpl.evaluate(scope) as RichText[])
675
+ .map((f) => f.plain)
676
+ .join("");
677
+ this.lastErrors.delete(name);
678
+ return result;
679
+ } catch (e) {
680
+ // [LAW:no-defensive-null-guards] Template eval failures (including
681
+ // MobX cycle detection) surface as last_error; the box still holds
682
+ // a safe fallback rather than propagating the throw to the renderer.
683
+ this.recordError(name, e instanceof Error ? e.message : String(e));
684
+ return this.stringInitial(opts.varDefault);
685
+ }
686
+ });
687
+ // Force eager evaluation so any cycle is detected here (at config load)
688
+ // rather than silently at the first render. MobX keepAlive computeds are
689
+ // otherwise lazy.
690
+ this.store.read(name);
691
+ }
692
+
693
+ // time: current wall-clock time formatted with a Go reference-time layout.
694
+ // Box initialises to the current time; the TTL timer refreshes it.
695
+ // [LAW:dataflow-not-control-flow] Box always holds a valid formatted string.
696
+ declareTime(name: string, opts: TimeOptions): void {
697
+ const ttlMs = opts.ttlMs ?? 1_000;
698
+ const format = (d: Date): string => {
699
+ try {
700
+ return formatGoTime(opts.format, d);
701
+ } catch {
702
+ return this.stringInitial(opts.varDefault);
703
+ }
704
+ };
705
+ this.store.defineBox(name, "string", format(new Date()));
706
+ const update = (): void => {
707
+ try {
708
+ this.store.setBox(name, formatGoTime(opts.format, new Date()));
709
+ this.lastErrors.delete(name);
710
+ } catch (e) {
711
+ this.applyFallback(
712
+ name,
713
+ "string",
714
+ opts.varDefault,
715
+ e instanceof Error ? e.message : String(e),
716
+ );
717
+ }
718
+ };
719
+ const unsub = this.ttlMgr.subscribe(ttlMs, update);
720
+ this.cleanups.push(unsub);
721
+ }
722
+
723
+ // git: first-class git fields delivered by the shared GitDataProvider.
724
+ // All git boxes for the same cwd ride one provider subscription; the
725
+ // provider in turn collapses N subscribers in the same repo onto one
726
+ // watcher + one cache entry.
727
+ // [LAW:dataflow-not-control-flow] Box always holds a valid typed value;
728
+ // the provider's watcher (HEAD + index under the resolved gitDir, plus
729
+ // refs/heads/ when it exists — see src/daemon/cache/git.ts:watcherTargets)
730
+ // drives when the snapshot is refreshed.
731
+ declareGit(name: string, opts: GitOptions): void {
732
+ const type = GIT_FIELD_TYPE[opts.field];
733
+ // [LAW:single-enforcer] Coerce the user-supplied default to the field's
734
+ // native type — same logic projectGitField already applies to the
735
+ // defaultEmptyValue fallback. The schema constrains `default` to string,
736
+ // so `"0"` is the only legal form for number fields; coerce it here so
737
+ // defineBox receives a type-correct initial value.
738
+ const initial =
739
+ opts.varDefault !== undefined
740
+ ? coerceToType(opts.varDefault, type)
741
+ : zeroValue(type);
742
+ this.store.defineBox(name, type, initial);
743
+
744
+ let sub = this.gitSubscriptions.get(opts.cwd);
745
+ if (!sub) {
746
+ const fieldSubs = new Map<
747
+ GitField,
748
+ Array<{ name: string; varDefault: VarValue | undefined }>
749
+ >();
750
+ const unsubscribe = this.gitProvider.subscribe(opts.cwd, (info) => {
751
+ // [LAW:dataflow-not-control-flow] One runInAction per delivery; the
752
+ // snapshot value decides each box's content, not whether code runs.
753
+ this.store.runInAction(() => {
754
+ for (const [field, subs] of fieldSubs) {
755
+ for (const { name: subName, varDefault } of subs) {
756
+ this.store.setBox(
757
+ subName,
758
+ projectGitField(
759
+ info,
760
+ field,
761
+ varDefault,
762
+ this.defaultEmptyValue,
763
+ ),
764
+ );
765
+ }
766
+ }
767
+ });
768
+ });
769
+ sub = { fieldSubs, unsubscribe };
770
+ this.gitSubscriptions.set(opts.cwd, sub);
771
+ }
772
+
773
+ let fieldList = sub.fieldSubs.get(opts.field);
774
+ if (!fieldList) {
775
+ fieldList = [];
776
+ sub.fieldSubs.set(opts.field, fieldList);
777
+ }
778
+ fieldList.push({ name, varDefault: opts.varDefault });
779
+ }
780
+
781
+ // state: read-through to SessionState. The computed reads two deps — the
782
+ // canonical session-id input variable (SESSION_ID_VAR_NAME, refreshed per
783
+ // render from input) and SessionState itself (MobX-tracked via its
784
+ // internal atom). A click verb that mutates SessionState invalidates this
785
+ // computed; a sessionId change (per-render) also invalidates it.
786
+ // Persistence rides on SessionState's disk backing — no extra wiring.
787
+ //
788
+ // [LAW:dataflow-not-control-flow] Same body every evaluation; values
789
+ // determine the result, never whether code runs.
790
+ declareState(name: string, opts: StateOptions): void {
791
+ if (!this.sessionState) {
792
+ throw new Error(
793
+ `declareState("${name}"): SourceRegistry was constructed without a SessionState — ` +
794
+ `state-kind variables require a SessionState (the daemon provides one; tests must supply one)`,
795
+ );
796
+ }
797
+ const sessionState = this.sessionState;
798
+ const fallback = opts.varDefault ?? this.stringInitial(undefined);
799
+ this.store.defineComputed(name, "string", (read) => {
800
+ // [LAW:types-are-the-program] By convention session.id is declared as
801
+ // a string-typed input variable. The var-system's type discipline
802
+ // (assertType in store.ts) enforces that at declaration; we read its
803
+ // value as a string here. A user who redeclares session.id as a
804
+ // non-string variable receives empty state lookups — the failure
805
+ // mode is loud-by-absence rather than silently coerced.
806
+ const sessionId = read(SESSION_ID_VAR_NAME);
807
+ if (typeof sessionId !== "string" || !sessionId) return fallback;
808
+ const value = sessionState.get(sessionId, opts.key);
809
+ return value !== null ? value : fallback;
810
+ });
811
+ }
812
+
813
+ // ─── Render-cycle driver ──────────────────────────────────────────────────
814
+
815
+ // Called at the start of each render request. Pushes all input-kind boxes in
816
+ // a single runInAction so their dependents invalidate exactly once.
817
+ // [LAW:dataflow-not-control-flow] Variability lives in the payload values,
818
+ // not in whether the update runs — every input box is refreshed every render.
819
+ applyInput(payload: unknown): void {
820
+ this.store.runInAction(() => {
821
+ for (const [name, meta] of this.inputMetas) {
822
+ const raw = resolvePath(payload, meta.path);
823
+ const type = this.store.getType(name);
824
+ if (raw !== undefined) {
825
+ try {
826
+ this.store.setBox(name, coerceToType(raw, type));
827
+ this.lastErrors.delete(name);
828
+ } catch (e) {
829
+ this.applyFallback(
830
+ name,
831
+ type,
832
+ meta.varDefault,
833
+ e instanceof Error ? e.message : String(e),
834
+ );
835
+ }
836
+ } else {
837
+ this.applyFallback(
838
+ name,
839
+ type,
840
+ meta.varDefault,
841
+ `input path "${meta.path}" not found in payload`,
842
+ );
843
+ }
844
+ }
845
+ });
846
+ }
847
+
848
+ // ─── Diagnostics ─────────────────────────────────────────────────────────
849
+
850
+ // Returns the recorded error for a variable, or undefined if the last
851
+ // resolution succeeded (or the variable has never been resolved).
852
+ getLastError(name: string): LastError | undefined {
853
+ return this.lastErrors.get(name);
854
+ }
855
+
856
+ // ─── Lifecycle ────────────────────────────────────────────────────────────
857
+
858
+ // Tear down all TTL timers, fs watchers, MobX reactions, and git
859
+ // subscriptions registered by async source kinds. Call when the registry
860
+ // is no longer needed (e.g. on daemon shutdown or config hot-reload).
861
+ dispose(): void {
862
+ for (const cleanup of this.cleanups) cleanup();
863
+ this.cleanups.length = 0;
864
+ for (const sub of this.gitSubscriptions.values()) sub.unsubscribe();
865
+ this.gitSubscriptions.clear();
866
+ this.watchMgr.dispose();
867
+ this.ttlMgr.dispose();
868
+ if (this.ownsGitProvider) this.gitProvider.close();
869
+ }
870
+
871
+ // ─── Private helpers ──────────────────────────────────────────────────────
872
+
873
+ // [LAW:single-enforcer] One place that maps every CachePolicy kind to its
874
+ // trigger mechanism. Adding a new policy kind means adding one case here.
875
+ private registerCachePolicy(
876
+ name: string,
877
+ policy: CachePolicy,
878
+ update: () => void,
879
+ ): void {
880
+ switch (policy.kind) {
881
+ case "never":
882
+ // Initial run in declare* is the only execution.
883
+ break;
884
+
885
+ case "ttl": {
886
+ const unsub = this.ttlMgr.subscribe(policy.durationMs, update);
887
+ this.cleanups.push(unsub);
888
+ break;
889
+ }
890
+
891
+ case "watch_file": {
892
+ const unsub = this.watchMgr.subscribe(policy.path, update);
893
+ this.cleanups.push(unsub);
894
+ break;
895
+ }
896
+
897
+ case "key": {
898
+ // Parse template once; reaction re-evaluates it whenever any
899
+ // variable it reads changes. If the rendered key string changes,
900
+ // the source is recomputed.
901
+ // [LAW:dataflow-not-control-flow] The key template is the sole
902
+ // selector — no manual dep declarations, no conditional checks.
903
+ const parsedKey = this.engine.parse(policy.template);
904
+ const disposer: IReactionDisposer = reaction(() => {
905
+ const scope = buildScope(this.store);
906
+ try {
907
+ return (parsedKey.evaluate(scope) as RichText[])
908
+ .map((f) => f.plain)
909
+ .join("");
910
+ } catch {
911
+ return "";
912
+ }
913
+ }, update);
914
+ this.cleanups.push(disposer);
915
+ break;
916
+ }
917
+
918
+ case "depends_on": {
919
+ // [LAW:dataflow-not-control-flow] reaction re-runs update whenever
920
+ // any named variable changes. Variability lives in the dep values,
921
+ // not in whether the update executes — the update always runs when
922
+ // the joined snapshot changes.
923
+ const disposer: IReactionDisposer = reaction(
924
+ () =>
925
+ policy.varNames.map((n) => String(this.store.read(n))).join(","),
926
+ update,
927
+ );
928
+ this.cleanups.push(disposer);
929
+ break;
930
+ }
931
+ }
932
+ }
933
+
934
+ private async updateFromShell(
935
+ name: string,
936
+ command: string,
937
+ regex: string | undefined,
938
+ varDefault: string | undefined,
939
+ ): Promise<void> {
940
+ if (this.inFlight.has(name)) return;
941
+ this.inFlight.add(name);
942
+ try {
943
+ const { stdout, exitCode } = await execShell(command);
944
+ if (exitCode !== 0) {
945
+ this.applyFallback(
946
+ name,
947
+ "string",
948
+ varDefault,
949
+ `shell "${command}" exited with code ${exitCode}`,
950
+ );
951
+ return;
952
+ }
953
+ if (regex !== undefined) {
954
+ const m = new RegExp(regex).exec(stdout);
955
+ if (!m?.[1]) {
956
+ this.applyFallback(
957
+ name,
958
+ "string",
959
+ varDefault,
960
+ `regex no-match in output of "${command}"`,
961
+ );
962
+ return;
963
+ }
964
+ this.store.setBox(name, m[1].replace(/\n/g, " "));
965
+ } else {
966
+ this.store.setBox(name, stdout.replace(/\n/g, " ").trim());
967
+ }
968
+ this.lastErrors.delete(name);
969
+ } finally {
970
+ this.inFlight.delete(name);
971
+ }
972
+ }
973
+
974
+ private async updateFromFile(
975
+ name: string,
976
+ filePath: string,
977
+ readMode: "whole" | "first-line" | undefined,
978
+ regex: string | undefined,
979
+ varDefault: string | undefined,
980
+ ): Promise<void> {
981
+ if (this.inFlight.has(name)) return;
982
+ this.inFlight.add(name);
983
+ try {
984
+ const result = await readFileContent(filePath, readMode, regex);
985
+ if (result.error !== undefined) {
986
+ this.applyFallback(name, "string", varDefault, result.error);
987
+ return;
988
+ }
989
+ this.store.setBox(name, result.content ?? "");
990
+ this.lastErrors.delete(name);
991
+ } finally {
992
+ this.inFlight.delete(name);
993
+ }
994
+ }
995
+
996
+ // Failure chain: per-variable default → defaultEmptyValue coerced to type → zero.
997
+ // [LAW:no-defensive-null-guards] Each fallback level is deliberate; the zero
998
+ // backstop is the only "silent" path and exists because the caller has already
999
+ // recorded the error — downstream reads get a safe typed value, not an exception.
1000
+ private applyFallback(
1001
+ name: string,
1002
+ type: VarType,
1003
+ varDefault: VarValue | undefined,
1004
+ errorMessage: string,
1005
+ ): void {
1006
+ this.recordError(name, errorMessage);
1007
+ if (varDefault !== undefined) {
1008
+ this.store.setBox(name, varDefault);
1009
+ return;
1010
+ }
1011
+ try {
1012
+ this.store.setBox(name, coerceToType(this.defaultEmptyValue, type));
1013
+ } catch {
1014
+ this.store.setBox(name, zeroValue(type));
1015
+ }
1016
+ }
1017
+
1018
+ // Initial value for an input box before the first render push.
1019
+ private defaultFor(type: VarType): VarValue {
1020
+ try {
1021
+ return coerceToType(this.defaultEmptyValue, type);
1022
+ } catch {
1023
+ return zeroValue(type);
1024
+ }
1025
+ }
1026
+
1027
+ // Initial string value for shell/file boxes before the first async run.
1028
+ private stringInitial(varDefault: string | undefined): string {
1029
+ if (varDefault !== undefined) return varDefault;
1030
+ if (typeof this.defaultEmptyValue === "string")
1031
+ return this.defaultEmptyValue;
1032
+ return "";
1033
+ }
1034
+
1035
+ private recordError(name: string, message: string): void {
1036
+ this.lastErrors.set(name, { timestamp: Date.now(), message });
1037
+ }
1038
+ }