dsh-web-icon-indicator 0.4.2 → 0.5.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/lib/index.js CHANGED
@@ -40,10 +40,20 @@
40
40
  * Configuration object (all optional):
41
41
  * iconsDir Absolute directory holding base.svg. Default `<package>/icons/`.
42
42
  * statusPath JSON status endpoint path. Default `/dsh-web-icon-status.json`.
43
+ * Registration-time: composition entry only (never settings.yaml).
43
44
  * iconPathPrefix URL prefix for static icon files. Default `/dsh-web-icon-indicator`.
45
+ * Registration-time: composition entry only (never settings.yaml).
44
46
  * askingHoldMs Minimum visibility for the asking state (ms). Default 3500.
45
47
  * doneHoldMs How long the done state stays before falling back to idle.
46
48
  * Default 5000.
49
+ * defaultColor Default icon color — the idle whale's primary color, as
50
+ * `#rgb` / `#rrggbb`. Absent = the idle state's own colors[0]
51
+ * (today's behavior). Give each DSH instance (its own profile /
52
+ * settings.yaml) a different value to tell their browser tabs
53
+ * apart. A value perceptually too close to another state's
54
+ * color raises a WARNING — surfaced in the settings card, in
55
+ * the host log, and as `warnings` on the status endpoint — but
56
+ * is still honored.
47
57
  * states Per-state visual config, keyed by state name:
48
58
  * states[idle] = { effect, colors[], speed? }
49
59
  * states[running] = { effect, colors[], speed? }
@@ -121,8 +131,18 @@ const STATE_CONFIG_SCHEMA = z.object({
121
131
  const CONFIG_SCHEMA = z.object({
122
132
  askingHoldMs: z.number().min(0).default(DEFAULTS.askingHoldMs),
123
133
  doneHoldMs: z.number().min(0).default(DEFAULTS.doneHoldMs),
124
- statusPath: z.string().default(DEFAULTS.statusPath),
125
- iconPathPrefix: z.string().default(DEFAULTS.iconPathPrefix),
134
+ // Default icon color (`#rgb` / `#rrggbb`). Deliberately WITHOUT a schema
135
+ // default: an absent value keeps the idle state's own colors[0], exactly as
136
+ // before this key existed. `resolveConfig` folds a valid value into
137
+ // `states.idle.colors[0]` and drops an invalid one with a warning.
138
+ defaultColor: z.string(),
139
+ // `statusPath` / `iconPathPrefix` are deliberately NOT part of this schema.
140
+ // They are baked at registration time (the route table and the injected
141
+ // script's URLs are built in apply()), so a settings-document change to them
142
+ // could never take effect: the browser would poll the new path while the
143
+ // server still served the old one — a dead favicon. Keeping them out of the
144
+ // schema means the settings surface cannot advertise a key it cannot honor;
145
+ // they stay composition-entry only (see the README config table).
126
146
  iconsDir: z.string(),
127
147
  // dict (not a four-key object): the user layer may override only some
128
148
  // states; the plugin merges the resolved value over DEFAULTS anyway.
@@ -156,6 +176,23 @@ const INJECTED_SCRIPT = `
156
176
  // Whale geometry (used as the transform pivot for scale/translate effects).
157
177
  var CX = 27.889625, CY = 24.952640;
158
178
 
179
+ // Every request this script makes is deadline-bounded: a fetch that hangs
180
+ // (half-open TCP, a suspended host, a captive portal) would otherwise hold
181
+ // the poll chain and the base.svg load forever, freezing the icon with no
182
+ // recovery. Aborting surfaces as a rejection, so the existing retry paths
183
+ // (poll keeps its interval; base.svg retries on the next tick) take over.
184
+ function timedFetch(url, init) {
185
+ if (typeof AbortController === "undefined") return fetch(url, init);
186
+ var ctrl = new AbortController();
187
+ var timer = setTimeout(function () { try { ctrl.abort(); } catch (e) {} }, 8000);
188
+ var opts = init || {};
189
+ opts.signal = ctrl.signal;
190
+ return fetch(url, opts).then(
191
+ function (r) { clearTimeout(timer); return r; },
192
+ function (e) { clearTimeout(timer); throw e; }
193
+ );
194
+ }
195
+
159
196
  // Capture the shell's own favicon ONCE, together with an offline-safe
160
197
  // data-URI copy of it. The copy matters: while the DSH host is stopped
161
198
  // the original href (a URL served by that same host) is unreachable, so
@@ -175,7 +212,7 @@ const INJECTED_SCRIPT = `
175
212
  // keeps the last plugin frame instead of risking a dead URL.
176
213
  try {
177
214
  if (!href || typeof Blob === "undefined" || typeof FileReader === "undefined") return;
178
- fetch(href)
215
+ timedFetch(href)
179
216
  .then(function (r) { if (!r.ok) throw new Error("orig " + r.status); return r.blob(); })
180
217
  .then(function (blob) {
181
218
  return new Promise(function (resolve, reject) {
@@ -263,6 +300,27 @@ const INJECTED_SCRIPT = `
263
300
  }
264
301
 
265
302
  // ---- svg frame builder --------------------------------------------------
303
+ // Frame-URI cache. The base template is re-read and re-encoded on every
304
+ // animation frame, but most effects cycle through a tiny set of fills
305
+ // (static → 1, blink → 2, breath/geometric effects → 1 per color pair), so
306
+ // the encoded data URI is stable and can be memoized. rainbow is the
307
+ // exception — its fill is a fresh hue every frame — and is left uncached so
308
+ // the map can never grow without bound.
309
+ var URI_CACHE = new Map();
310
+ function cachedUri(kind, fill, build) {
311
+ var key = kind + "|" + fill;
312
+ var hit = URI_CACHE.get(key);
313
+ if (hit === undefined) {
314
+ hit = build();
315
+ // Keep the cache small (state count × a handful of colors); a settings
316
+ // edit can introduce new colors, and this script runs for the life of
317
+ // the tab.
318
+ if (URI_CACHE.size > 64) URI_CACHE.clear();
319
+ URI_CACHE.set(key, hit);
320
+ }
321
+ return hit;
322
+ }
323
+
266
324
  // Full-frame activity count ("满幅数字", per demo/badge.html bigNum
267
325
  // channel): while more than one agent is active the whole icon becomes a
268
326
  // state-colored rounded block with a bold count — no whale is drawn, so
@@ -275,22 +333,28 @@ const INJECTED_SCRIPT = `
275
333
  // cropping (mirrors the demo's svgFrame showWhale=false branch).
276
334
  function bigNumUri(fill) {
277
335
  var text = String(ACTIVE > 99 ? "99+" : ACTIVE);
278
- var chars = text.length;
279
- var fs = chars === 1 ? 26 : chars === 2 ? 20 : 15.5;
280
- var rgb = hexToRgb(fill);
281
- var lum = (0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255;
282
- var fg = lum > 0.6 ? "#111111" : "#FFFFFF"; // auto-contrast on the state color
283
- var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 50 50" fill="none">' +
284
- '<rect x="0" y="0" width="50" height="50" rx="' + BIG_NUM_RX + '" fill="' + fill + '"/>' +
285
- '<text x="25" y="' + (25 + fs * 0.34).toFixed(1) + '" text-anchor="middle" font-family="system-ui, sans-serif" font-size="' + fs + '" font-weight="800" fill="' + fg + '">' + text + '</text>' +
286
- '</svg>';
287
- return "data:image/svg+xml," + encodeURIComponent(svg);
336
+ return cachedUri("n" + text, fill, function () {
337
+ var chars = text.length;
338
+ var fs = chars === 1 ? 26 : chars === 2 ? 20 : 15.5;
339
+ var rgb = hexToRgb(fill);
340
+ var lum = (0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255;
341
+ var fg = lum > 0.6 ? "#111111" : "#FFFFFF"; // auto-contrast on the state color
342
+ var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 50 50" fill="none">' +
343
+ '<rect x="0" y="0" width="50" height="50" rx="' + BIG_NUM_RX + '" fill="' + fill + '"/>' +
344
+ '<text x="25" y="' + (25 + fs * 0.34).toFixed(1) + '" text-anchor="middle" font-family="system-ui, sans-serif" font-size="' + fs + '" font-weight="800" fill="' + fg + '">' + text + '</text>' +
345
+ '</svg>';
346
+ return "data:image/svg+xml," + encodeURIComponent(svg);
347
+ });
288
348
  }
289
349
 
290
350
  // Reuses the fetched base template; replaces its __COLOR__ token (and,
291
351
  // for scale/translate effects, wraps the whale in a <g transform>).
292
352
  function frameUri(fill, effect, t, speed) {
293
353
  if (!BASE) return null;
354
+ if (effect === "static") {
355
+ // No time component: one URI per color for the life of the tab.
356
+ return cachedUri("w", fill, function () { return "data:image/svg+xml," + encodeURIComponent(BASE.replace(RE, fill)); });
357
+ }
294
358
  var inner = BASE.replace(RE, fill);
295
359
  if (effect === "heartbeat" || effect === "bounce") {
296
360
  var gattr = "";
@@ -353,7 +417,12 @@ const INJECTED_SCRIPT = `
353
417
  // requestAnimationFrame in hidden tabs, so a background tab would freeze
354
418
  // on the last frame: repaint here instead — animated states get a
355
419
  // wall-clock frame (coarse ~1 Hz animation), static states repaint
356
- // (self-heal / pick up changes that happened while hidden).
420
+ // (self-heal / pick up changes that happened while hidden). A state
421
+ // change that happens while the tab is away (like the done hold
422
+ // expiring) paints on the next poll; the visibilitychange listener at
423
+ // the bottom fetches immediately when the tab comes back to the
424
+ // foreground, so that revert shows at once instead of waiting for a
425
+ // background-throttled tick.
357
426
  if (BASE && state + "|" + ACTIVE === PREV_KEY) {
358
427
  if (isStatic(state)) {
359
428
  var sframe = frameAt(state, 0);
@@ -366,8 +435,12 @@ const INJECTED_SCRIPT = `
366
435
  }
367
436
  PREV_KEY = state + "|" + ACTIVE;
368
437
  stopAnim();
369
- if (!BASE) { // base.svg not loaded yet — fetch once, then start
370
- fetch(location.origin + BASE_PATH + "?t=" + Date.now(), { cache: "no-store" })
438
+ // The count block is drawn from scratch, so it needs no base.svg: only the
439
+ // whale path waits for the template. Loading it here (and retrying on the
440
+ // next poll, since PREV_KEY was already set) keeps a failed/timed-out
441
+ // template fetch from blanking the tab while agents are busy.
442
+ if (!BASE && ACTIVE < BIG_NUM_MIN) {
443
+ timedFetch(location.origin + BASE_PATH + "?t=" + Date.now(), { cache: "no-store" })
371
444
  .then(function (r) { if (!r.ok) throw new Error("base " + r.status); return r.text(); })
372
445
  .then(function (txt) { BASE = txt; PREV_KEY = null; apply(state); })
373
446
  .catch(function () {});
@@ -409,7 +482,7 @@ const INJECTED_SCRIPT = `
409
482
  PREV_KEY = null; // force a full repaint on the next apply
410
483
  }
411
484
  function poll() {
412
- fetch(location.origin + STATUS_PATH, { cache: "no-store" })
485
+ timedFetch(location.origin + STATUS_PATH, { cache: "no-store" })
413
486
  .then(function (r) { if (!r.ok) throw new Error("bad"); return r.json(); })
414
487
  .then(function (j) {
415
488
  syncCfg(j);
@@ -437,6 +510,16 @@ const INJECTED_SCRIPT = `
437
510
  captureOriginal();
438
511
  TIMER = setInterval(poll, 1000);
439
512
  poll();
513
+ // A BACKGROUND tab's timers are throttled (Chrome clamps setInterval to
514
+ // ~1/min after ~5 min hidden) and rAF is paused, so a state change that
515
+ // happened while the tab was away may take a while to reach the favicon —
516
+ // e.g. the green "done" frame sits there until the next poll finally fires.
517
+ // The moment the tab becomes visible again, fetch the fresh status and
518
+ // repaint right away instead of waiting for the next (possibly throttled)
519
+ // poll tick. Cheap, idempotent, and it also covers browsers that froze the
520
+ // page entirely while hidden (their first task on unfreeze is this poll).
521
+ function onVisible() { if (document.visibilityState === "visible") poll(); }
522
+ window.addEventListener("visibilitychange", onVisible);
440
523
  window.addEventListener("beforeunload", function () { stopAnim(); if (TIMER) clearInterval(TIMER); restore(); });
441
524
  } catch (e) { }
442
525
  })();
@@ -453,19 +536,178 @@ function resolveIconsDir(configured) {
453
536
  return join(__dirname, "..", "icons");
454
537
  }
455
538
 
539
+ // ---- Color math for the default-color similarity check ---------------------
540
+ // lib/client.js carries its OWN copy of this block: the two halves run in
541
+ // different processes, there is no shared module and no build step (docs/main.js
542
+ // already duplicates the same color math for the showcase site). Keep the
543
+ // thresholds in sync when tuning either copy.
544
+ /** Accepted defaultColor shape: `#rgb` or `#rrggbb` (case-insensitive). */
545
+ const DEFAULT_COLOR_RX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
546
+ /** CIE76 ΔE below which two colors are practically the same. */
547
+ const SIMILAR_DE_STRONG = 12;
548
+ /** CIE76 ΔE below which two colors are easily confused at favicon size. */
549
+ const SIMILAR_DE_WARN = 25;
550
+ /** Lab chroma above which a default color collides with a rainbow sweep. */
551
+ const RAINBOW_CHROMA_MIN = 15;
552
+ /** The states a default color is compared against (idle is the default itself). */
553
+ const OTHER_STATES = STATE_NAMES.filter((name) => name !== "idle");
554
+
555
+ /** `#rgb` / `#rrggbb` → lowercase 6-digit hex, or null when malformed. */
556
+ function normalizeHex(value) {
557
+ if (typeof value !== "string") return null;
558
+ const text = value.trim();
559
+ if (!DEFAULT_COLOR_RX.test(text)) return null;
560
+ const body = text.slice(1).toLowerCase();
561
+ return body.length === 3
562
+ ? "#" + body[0] + body[0] + body[1] + body[1] + body[2] + body[2]
563
+ : "#" + body;
564
+ }
565
+
566
+ function hexToRgb(hex) {
567
+ let body = String(hex).replace("#", "");
568
+ // Expand #rgb exactly like the browser half does — every host caller feeds
569
+ // normalizeHex()'s 6-digit output today, but the shortcut is a trap for the
570
+ // next caller (and this copy must stay equivalent to lib/client.js's).
571
+ if (body.length === 3) body = body[0] + body[0] + body[1] + body[1] + body[2] + body[2];
572
+ const n = parseInt(body, 16);
573
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
574
+ }
575
+
576
+ function rgbToHex(r, g, b) {
577
+ return "#" + [r, g, b].map((v) => {
578
+ const c = Math.max(0, Math.min(255, Math.round(v)));
579
+ return ("0" + c.toString(16)).slice(-2);
580
+ }).join("");
581
+ }
582
+
583
+ /** Linear blend between two hex colors (mirrors the browser's `mix()`). */
584
+ function mixHex(a, b, t) {
585
+ const ca = hexToRgb(a);
586
+ const cb = hexToRgb(b);
587
+ return rgbToHex(ca[0] + (cb[0] - ca[0]) * t, ca[1] + (cb[1] - ca[1]) * t, ca[2] + (cb[2] - ca[2]) * t);
588
+ }
589
+
590
+ /** CIELAB (D65) for an sRGB hex color. */
591
+ function labOf(hex) {
592
+ const rgb = hexToRgb(hex);
593
+ const lin = (v) => {
594
+ const c = v / 255;
595
+ return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
596
+ };
597
+ const r = lin(rgb[0]);
598
+ const g = lin(rgb[1]);
599
+ const b = lin(rgb[2]);
600
+ const x = (0.4124564 * r + 0.3575761 * g + 0.1804375 * b) / 0.95047;
601
+ const y = 0.2126729 * r + 0.7151522 * g + 0.072175 * b;
602
+ const z = (0.0193339 * r + 0.119192 * g + 0.9503041 * b) / 1.08883;
603
+ const f = (t) => (t > 216 / 24389 ? Math.cbrt(t) : (841 / 108) * t + 4 / 29);
604
+ const fx = f(x);
605
+ const fy = f(y);
606
+ const fz = f(z);
607
+ return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
608
+ }
609
+
610
+ /** CIE76 ΔE (Euclidean distance in CIELAB). */
611
+ function deltaE(a, b) {
612
+ const la = labOf(a);
613
+ const lb = labOf(b);
614
+ return Math.sqrt(
615
+ Math.pow(la[0] - lb[0], 2) + Math.pow(la[1] - lb[1], 2) + Math.pow(la[2] - lb[2], 2)
616
+ );
617
+ }
618
+
619
+ function labChroma(hex) {
620
+ const l = labOf(hex);
621
+ return Math.sqrt(l[1] * l[1] + l[2] * l[2]);
622
+ }
623
+
624
+ /**
625
+ * The fills a state actually paints, mirroring the browser's `frameColor()`:
626
+ * `blink` toggles colors[0] ⇄ colors[1] (a darker second color is derived when
627
+ * colors[1] is omitted), `breath` interpolates between them, everything else
628
+ * uses colors[0]. `rainbow` is flagged instead — it sweeps every hue.
629
+ */
630
+ function stateFills(state) {
631
+ const cols = Array.isArray(state?.colors) ? state.colors : [];
632
+ const effect = state?.effect || "static";
633
+ const c0 = normalizeHex(cols[0] || "");
634
+ const rainbow = effect === "rainbow";
635
+ // A rainbow state never paints its own colors[0] literally (that value is
636
+ // only the starting hue — the sweep covers the whole wheel), so it has no
637
+ // comparable fills: the chroma rule in colorWarnings() covers it instead.
638
+ if (!c0 || rainbow) return { fills: [], rainbow };
639
+ const c1 = normalizeHex(cols[1] || "") || mixHex(c0, "#000000", 0.35);
640
+ if (effect === "blink") return { fills: [c0, c1], rainbow: false };
641
+ if (effect === "breath") {
642
+ // The browser paints the CONTINUOUS mix, so a sparse sample can only ever
643
+ // over-estimate the distance: a blue->green breath was reported ΔE 37 away
644
+ // from a default colour it actually passes through (ΔE 0). 33 samples keep
645
+ // the worst reported-vs-true gap around 5 ΔE (see lib/client.js's copy).
646
+ const fills = [];
647
+ for (let i = 0; i <= 32; i += 1) fills.push(mixHex(c0, c1, i / 32));
648
+ return { fills, rainbow: false };
649
+ }
650
+ return { fills: [c0], rainbow };
651
+ }
652
+
653
+ /**
654
+ * Similarity warnings for the effective default color (the idle primary).
655
+ * Only the default is compared against the other states — NOT the states
656
+ * against each other: the shipped defaults deliberately share #FACC15 between
657
+ * `running` and the `asking` blink, so a pairwise check would warn forever.
658
+ */
659
+ function colorWarnings(states) {
660
+ const warnings = [];
661
+ const base = normalizeHex(states?.idle?.colors?.[0] || "");
662
+ if (!base) return warnings;
663
+ // Idle itself on `rainbow` (composition entry / hand-written settings.yaml —
664
+ // the card never exposes it): the idle icon sweeps every hue, so the default
665
+ // colour can never stay distinguishable from it. One advisory replaces the
666
+ // pairwise pass, which would judge a colour idle never paints literally.
667
+ if ((states?.idle?.effect || "static") === "rainbow") {
668
+ if (labChroma(base) >= RAINBOW_CHROMA_MIN) {
669
+ warnings.push({ code: "rainbow-overlap", state: "idle", base, level: "warn" });
670
+ }
671
+ return warnings;
672
+ }
673
+ for (const name of OTHER_STATES) {
674
+ const { fills, rainbow } = stateFills(states?.[name]);
675
+ if (rainbow && labChroma(base) >= RAINBOW_CHROMA_MIN) {
676
+ warnings.push({ code: "rainbow-overlap", state: name, base, level: "warn" });
677
+ }
678
+ let closest = null;
679
+ for (const fill of fills) {
680
+ const de = deltaE(base, fill);
681
+ if (closest === null || de < closest.deltaE) closest = { color: fill, deltaE: de };
682
+ }
683
+ if (closest && closest.deltaE < SIMILAR_DE_WARN) {
684
+ warnings.push({
685
+ code: "color-too-close",
686
+ state: name,
687
+ base,
688
+ color: closest.color,
689
+ deltaE: Math.round(closest.deltaE * 10) / 10,
690
+ level: closest.deltaE < SIMILAR_DE_STRONG ? "strong" : "warn",
691
+ });
692
+ }
693
+ }
694
+ return warnings;
695
+ }
696
+
456
697
  /**
457
698
  * Cordis plugin entry. Returns the standard `{ apply, inject, config }`
458
699
  * shape so the host composition can mount it once at startup.
459
700
  */
460
701
  export default {
461
702
  name: "dsh-web-icon-indicator",
462
- inject: ["webServer", "timer", "agents", "fs", "sandboxPolicy"],
703
+ inject: ["webServer", "timer", "agents", "fs"],
463
704
  config: {
464
705
  askingHoldMs: DEFAULTS.askingHoldMs,
465
706
  doneHoldMs: DEFAULTS.doneHoldMs,
466
707
  statusPath: DEFAULTS.statusPath,
467
708
  iconPathPrefix: DEFAULTS.iconPathPrefix,
468
709
  iconsDir: undefined,
710
+ defaultColor: undefined,
469
711
  states: DEFAULTS.states,
470
712
  },
471
713
  /** Settings namespace + validation schema (exported for reuse/tooling). */
@@ -486,6 +728,7 @@ export default {
486
728
  // normalization when the profile composes no settings service.
487
729
  const resolveConfig = (raw) => {
488
730
  const stateConfigs = {};
731
+ const warnings = [];
489
732
  for (const name of STATE_NAMES) {
490
733
  const merged = { ...(DEFAULTS.states[name] || {}), ...(raw.states?.[name] || {}) };
491
734
  // Guard against unknown effect names: fall back to "static" so the
@@ -496,15 +739,75 @@ export default {
496
739
  let cols = merged.colors;
497
740
  if (typeof cols === "string") cols = [cols];
498
741
  if (!Array.isArray(cols)) cols = [];
499
- cols = cols.filter((c) => typeof c === "string" && /^#[0-9a-fA-F]{3,6}$/.test(c.trim()));
742
+ // Strict `#rgb` / `#rrggbb` only — the same rule normalizeHex() and the
743
+ // card use. The old lenient 3-6 digit test let a "#1234" through and
744
+ // parseInt() painted a bogus colour from it, which the card (correctly)
745
+ // refused to reason about.
746
+ cols = cols.filter((c) => typeof c === "string" && DEFAULT_COLOR_RX.test(c.trim()));
500
747
  merged.colors = cols.length ? cols : [...(DEFAULTS.states[name]?.colors || ["#1a1a1a"])];
501
748
  // speed: must be a positive number; otherwise use the default (1200).
502
749
  if (typeof merged.speed !== "number" || !(merged.speed > 0)) delete merged.speed;
503
750
  stateConfigs[name] = merged;
504
751
  }
505
- return { ...DEFAULTS, ...raw, states: stateConfigs };
752
+ // `undefined` values must not shadow a DEFAULTS entry: the plugin's own
753
+ // static `config` row carries `iconsDir: undefined` by design, and a
754
+ // schema-resolved settings value may omit optional keys entirely. Only
755
+ // keys the caller actually set may override a default.
756
+ const defined = {};
757
+ for (const [key, value] of Object.entries(raw || {})) {
758
+ if (value !== undefined) defined[key] = value;
759
+ }
760
+ const resolved = { ...DEFAULTS, ...defined, states: stateConfigs };
761
+ // `defaultColor` is sugar for the idle state's PRIMARY color. Fold it into
762
+ // `states.idle.colors[0]` here instead of teaching the browser a second
763
+ // rendering path: the injected script, the baked `__CFG__` payload and
764
+ // even an already-baked older bundle therefore keep working unchanged,
765
+ // and the live settings sync (`cfg.states = next.states`) carries it for
766
+ // free. A malformed value is dropped with a warning, never painted.
767
+ delete resolved.defaultColor;
768
+ const requestedDefault = typeof defined.defaultColor === "string"
769
+ ? defined.defaultColor.trim()
770
+ : defined.defaultColor;
771
+ // "" / whitespace written by hand into the composition entry means "not
772
+ // set" — not a malformed color to warn about. Neither does an empty YAML
773
+ // value, which parses as null (`defaultColor:`).
774
+ if (requestedDefault != null && requestedDefault !== "") {
775
+ const normalized = normalizeHex(requestedDefault);
776
+ if (normalized) {
777
+ const idle = stateConfigs.idle;
778
+ stateConfigs.idle = { ...idle, colors: [normalized, ...(idle.colors || []).slice(1)] };
779
+ resolved.defaultColor = normalized;
780
+ } else {
781
+ warnings.push({ code: "invalid-color", value: String(requestedDefault) });
782
+ }
783
+ }
784
+ warnings.push(...colorWarnings(stateConfigs));
785
+ resolved.warnings = warnings;
786
+ return resolved;
787
+ };
788
+ // Surface the similarity / validity warnings on the host as well: the
789
+ // settings card computes its own live copy, while this covers configs that
790
+ // arrive through the composition entry (a hand-written cordis.yml) and
791
+ // leaves a server-side record. `ctx.logger` is cordis' core logging
792
+ // service — guard it, since the zero-dep test harness drives `apply()`
793
+ // with a ctx that has none.
794
+ const logWarnings = (list) => {
795
+ if (!Array.isArray(list) || list.length === 0) return;
796
+ try {
797
+ if (typeof ctx.logger !== "function") return;
798
+ const logger = ctx.logger("dsh-web-icon-indicator");
799
+ for (const w of list) {
800
+ const text = w.code === "invalid-color"
801
+ ? `defaultColor ${JSON.stringify(w.value)} is not a 3- or 6-digit hex color — ignored`
802
+ : w.code === "rainbow-overlap"
803
+ ? `defaultColor ${w.base} is too close to the "${w.state}" state: its rainbow effect sweeps every hue`
804
+ : `defaultColor ${w.base} is ${w.level === "strong" ? "nearly identical to" : "too close to"} the "${w.state}" state color ${w.color} (ΔE ${w.deltaE})`;
805
+ if (logger && typeof logger.warn === "function") logger.warn(text);
806
+ }
807
+ } catch (e) {}
506
808
  };
507
809
  let cfg = resolveConfig(entry);
810
+ logWarnings(cfg.warnings);
508
811
  // The injected script bakes config at injection time; rebuild it whenever
509
812
  // the settings section changes so the NEXT page load picks the new values
510
813
  // up (reload the tab — same contract as before this registration).
@@ -531,24 +834,30 @@ export default {
531
834
  // Settings service mounted, or the section changed: re-resolve, then
532
835
  // mutate cfg in place so the state-machine closures (asking/done hold)
533
836
  // and the injected script see the new values immediately.
837
+ // `statusPath` / `iconPathPrefix` are NOT taken from the settings
838
+ // source: they are baked into the route table and the injected script
839
+ // at registration time, so honoring a settings edit here would break
840
+ // the live poll (see CONFIG_SCHEMA).
534
841
  const next = resolveConfig(source());
535
842
  cfg.askingHoldMs = next.askingHoldMs;
536
843
  cfg.doneHoldMs = next.doneHoldMs;
537
- cfg.statusPath = next.statusPath;
538
- cfg.iconPathPrefix = next.iconPathPrefix;
539
844
  if (next.iconsDir !== undefined) cfg.iconsDir = next.iconsDir;
540
845
  cfg.states = next.states;
846
+ // `defaultColor` / `warnings` ride along with `states`: the color is
847
+ // already folded into `states.idle.colors[0]`, so the browser needs no
848
+ // new key — they are kept for observability (status echo + log).
849
+ cfg.defaultColor = next.defaultColor;
850
+ cfg.warnings = next.warnings;
541
851
  iconsDir = resolveIconsDir(cfg.iconsDir);
542
852
  script = buildScript(cfg);
853
+ logWarnings(cfg.warnings);
543
854
  },
544
855
  });
545
856
  });
546
857
  const webServer = ctx.webServer;
547
858
  const agents = ctx.agents;
548
859
  const fs = ctx.fs;
549
- const sp = ctx.sandboxPolicy;
550
860
  const lastSeen = new Map();
551
- void sp; // sandboxPolicy injection is currently unused — reserved for future file-access policy
552
861
 
553
862
  // -- State machine -------------------------------------------------------
554
863
  const states = new Map(); // agentId -> { state, since }
@@ -620,31 +929,46 @@ export default {
620
929
  // leaves `running`, so a lost `tools/result` cannot pin `asking` forever.
621
930
  const scheduleAskCheck = (id) => {
622
931
  const prevHandle = askTimers.get(id);
623
- if (prevHandle) { try { prevHandle(); } catch (e) {} askTimers.delete(id); }
932
+ // Never cancel a handle whose callback is executing right now: it is the
933
+ // very call re-arming the pin, and its `finally` would then delete the
934
+ // fresh handle and leave the session pinned to `asking` with no timer to
935
+ // release it. A stale (non-running) handle is still cancelled, so a
936
+ // repeated pre-execute cannot stack two timers.
937
+ if (prevHandle && !prevHandle.running()) {
938
+ try { prevHandle(); } catch (e) {}
939
+ askTimers.delete(id);
940
+ }
941
+ let running = false;
624
942
  const handle = ctx.timer.timeout(() => {
943
+ running = true;
625
944
  askTimers.delete(id);
626
- if (!asking.has(id)) return;
627
- if (!askDone.has(id)) {
628
- const live = agents.get(id);
629
- // Result not yet reported AND the agent is still mid-turn: the user
630
- // may still be deciding — keep the pin (no re-arm budget; a human
631
- // question can legitimately outlast many hold windows).
632
- if (live && live.status === "running") { scheduleAskCheck(id); return; }
633
- // Result never arrived and the turn ended (agent idle / gone): a lost
634
- // tools/result cannot keep the icon blinking forever — force-release
635
- // the pin against live status.
945
+ try {
946
+ if (!asking.has(id)) return;
947
+ if (!askDone.has(id)) {
948
+ const live = agents.get(id);
949
+ // Result not yet reported AND the agent is still mid-turn: the user
950
+ // may still be deciding — keep the pin (no re-arm budget; a human
951
+ // question can legitimately outlast many hold windows).
952
+ if (live && live.status === "running") { scheduleAskCheck(id); return; }
953
+ // Result never arrived and the turn ended (agent idle / gone): a lost
954
+ // tools/result cannot keep the icon blinking forever — force-release
955
+ // the pin against live status.
956
+ asking.delete(id);
957
+ askDone.delete(id);
958
+ if (live) setState(id, live.status === "running" ? "running" : "idle");
959
+ else setState(id, "idle");
960
+ return;
961
+ }
636
962
  asking.delete(id);
637
963
  askDone.delete(id);
964
+ const live = agents.get(id);
638
965
  if (live) setState(id, live.status === "running" ? "running" : "idle");
639
966
  else setState(id, "idle");
640
- return;
967
+ } finally {
968
+ running = false;
641
969
  }
642
- asking.delete(id);
643
- askDone.delete(id);
644
- const live = agents.get(id);
645
- if (live) setState(id, live.status === "running" ? "running" : "idle");
646
- else setState(id, "idle");
647
970
  }, cfg.askingHoldMs);
971
+ handle.running = () => running;
648
972
  askTimers.set(id, handle);
649
973
  };
650
974
 
@@ -696,6 +1020,7 @@ export default {
696
1020
  asking.delete(id);
697
1021
  askDone.delete(id);
698
1022
  pendingApprovals.delete(id);
1023
+ approvalCursor.delete(id);
699
1024
  lastSeen.delete(id);
700
1025
  cancelDoneHold(id);
701
1026
  const t = askTimers.get(id);
@@ -707,17 +1032,34 @@ export default {
707
1032
  // 0.1.2-alpha.4 `Session.events` is gone — read the log on demand through
708
1033
  // `session.snapshotEvents()` (a frozen half-open range snapshot). Guard on
709
1034
  // the method so older/newer hosts degrade gracefully.
1035
+ //
1036
+ // The fold is INCREMENTAL: `snapshotEvents()` deep-freezes every event it
1037
+ // returns, and reconcile runs on every 1 s status poll, so re-reading the
1038
+ // whole log per poll would clone the entire history each second. A per-agent
1039
+ // cursor keeps only the delta (the log is append-only, so the open-approval
1040
+ // set is a pure fold over the events seen so far).
1041
+ const approvalCursor = new Map(); // agentId -> { seq, open: Set<approvalId> }
710
1042
  const hasPendingApproval = (live) => {
711
1043
  const session = live?.session;
712
1044
  if (!session || typeof session.snapshotEvents !== "function") return false;
713
- const events = session.snapshotEvents();
714
- if (!Array.isArray(events)) return false;
715
- const open = new Set();
1045
+ const id = live?.id ?? null;
1046
+ const cursor = (id != null && approvalCursor.get(id)) || { seq: 0, open: new Set() };
1047
+ let events;
1048
+ try {
1049
+ events = session.snapshotEvents(cursor.seq);
1050
+ } catch (e) {
1051
+ return cursor.open.size > 0; // keep the last good fold on a read failure
1052
+ }
1053
+ if (!Array.isArray(events)) return cursor.open.size > 0;
1054
+ let seq = cursor.seq;
716
1055
  for (const ev of events) {
717
- if (ev.type === "approval/asked") open.add(ev.data?.id);
718
- else if (ev.type === "approval/decided") open.delete(ev.data?.id);
1056
+ if (typeof ev?.seq === "number" && ev.seq >= seq) seq = ev.seq + 1;
1057
+ if (ev?.type === "approval/asked") cursor.open.add(ev.data?.id);
1058
+ else if (ev?.type === "approval/decided") cursor.open.delete(ev.data?.id);
719
1059
  }
720
- return open.size > 0;
1060
+ cursor.seq = seq;
1061
+ if (id != null) approvalCursor.set(id, cursor);
1062
+ return cursor.open.size > 0;
721
1063
  };
722
1064
 
723
1065
  // Reconcile running->idle transitions on every status request.
@@ -789,7 +1131,16 @@ export default {
789
1131
  res.statusCode = 200;
790
1132
  res.setHeader("Content-Type", "application/json; charset=utf-8");
791
1133
  res.setHeader("Cache-Control", "no-store");
792
- res.end(JSON.stringify({ ...aggregate(), states: cfg.states }));
1134
+ // `defaultColor` + `warnings` are additive: older browser bundles ignore
1135
+ // unknown keys, and the live config sync only reads `states`.
1136
+ // `defaultColor` is echoed as null when none is configured so the
1137
+ // documented shape has the key unconditionally.
1138
+ res.end(JSON.stringify({
1139
+ ...aggregate(),
1140
+ states: cfg.states,
1141
+ defaultColor: cfg.defaultColor ?? null,
1142
+ warnings: cfg.warnings || [],
1143
+ }));
793
1144
  },
794
1145
  }));
795
1146