dsh-web-icon-indicator 0.1.0 → 0.2.1

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
@@ -1,55 +1,120 @@
1
1
  /**
2
- * * dsh-web-icon-indicator — Host-only plugin that mirrors the
3
- * current DSH session state onto the browser tab favicon. Four states:
4
- * `idle` (default favicon), `running` (static yellow whale),
5
- * `asking` (yellow/red blinking), `done` (static green whale, 5s).
6
- *
7
- * The plugin is self-contained: it reads four SVG icons from its
8
- * `icons/` directory (relative to the package) and serves them through a
9
- * static `/dsh-web-icon-indicator/<name>.svg` route on the existing webServer,
10
- * while injecting a small browser script that polls `/dsh-web-icon-status.json`
11
- * and applies the right icon to `<link rel="icon">`.
12
- *
13
- * State derivation:
14
- * - `agent/status` driving events update a per-session map.
15
- * - `tools/pre-execute` on `ask_user_question` flips the session into
16
- * `asking`; a 3.5 s minimum hold keeps the asking icon visible even
17
- * when the user answers immediately. `tools/result` lets the hold
18
- * expire cleanly, restoring the actual agent status.
19
- * - `agent/turn-stopping` flips `running`/`asking` into `done`.
20
- * - `reconcile()` (called on every status request) watches
21
- * `agents.list()` for running→idle transitions as a fallback, since
22
- * `agent/status`'s idle event is not reliably delivered at turn end.
23
- *
24
- * No Client half: the browser script runs outside the sandbox by being
25
- * injected into the served index.html, where `document` is freely available.
26
- * The Cordis Client sandbox is restricted to `ctx` / `React` / `host` /
27
- * `styles` / `console` and intentionally cannot touch the favicon.
28
- *
29
- * Configuration object (all optional):
30
- * iconsDir Absolute directory holding idle.svg / running.svg /
31
- * asking.svg / done.svg. Defaults to `<package>/icons/`.
32
- * statusPath JSON status endpoint path. Default: `/dsh-web-icon-status.json`.
33
- * iconPathPrefix URL prefix for static icon files. Default: `/dsh-web-icon-indicator`.
34
- * askingHoldMs Minimum visibility for the asking icon (ms). Default 3500.
35
- * askingBlinkMs Yellow/red switch interval for asking (ms). Default 400.
36
- * doneHoldMs How long the done icon stays before falling back to idle.
37
- * Default 5000.
38
- *
39
- * @module dsh-web-icon-indicator
40
- */
2
+ * * dsh-web-icon-indicator — Host-only plugin that mirrors the
3
+ * current DSH session state onto the browser tab favicon. Four states:
4
+ * `idle` (default favicon), `running` (static), `asking` (blinks),
5
+ * `done` (static, 5s hold).
6
+ *
7
+ * Unlike a per-state-SVG icon set, this plugin ships ONE base SVG
8
+ * (`icons/base.svg` — the DeepSeek whale outline with a `__COLOR__`
9
+ * placeholder) and recolors / animates it **directly in the browser**.
10
+ * The injected script builds each favicon frame as a `data:image/svg+xml,…`
11
+ * URI by replacing the fill color (and, for animated effects, injecting a
12
+ * transform) on every frame. Because a favicon is a plain image, SVG CSS
13
+ * animations never run inside the tab, so all motion is JS-driven: each
14
+ * state maps to a user-configurable color + effect.
15
+ *
16
+ * State derivation (unchanged):
17
+ * - `agent/status` driving events update a per-session map.
18
+ * - `tools/pre-execute` on `ask_user_question` flips the session into
19
+ * `asking`; a minimum hold keeps the asking effect visible even when the
20
+ * user answers immediately. `tools/result` lets the hold expire cleanly.
21
+ * - `session/event` on `approval/asked` / `approval/decided` pins the
22
+ * session into `asking` while the agent waits on the user for a
23
+ * permission decision (covers sandbox escalations too).
24
+ * - `agent/turn-stopping` flips `running`/`asking` into `done`.
25
+ * - `reconcile()` (on every status request) watches `agents.list()` for
26
+ * running→idle transitions as a fallback.
27
+ *
28
+ * No Client half: the browser script runs outside the sandbox by being
29
+ * injected into the served index.html, where `document` is freely available.
30
+ *
31
+ * Configuration object (all optional):
32
+ * iconsDir Absolute directory holding base.svg. Default `<package>/icons/`.
33
+ * statusPath JSON status endpoint path. Default `/dsh-web-icon-status.json`.
34
+ * iconPathPrefix URL prefix for static icon files. Default `/dsh-web-icon-indicator`.
35
+ * askingHoldMs Minimum visibility for the asking state (ms). Default 3500.
36
+ * doneHoldMs How long the done state stays before falling back to idle.
37
+ * Default 5000.
38
+ * states Per-state visual config, keyed by state name:
39
+ * states[idle] = { effect, colors[], speed? }
40
+ * states[running] = { effect, colors[], speed? }
41
+ * states[asking] = { effect, colors[], speed? }
42
+ * states[done] = { effect, colors[], speed? }
43
+ * - effect: one of `static | blink | breath | rainbow | heartbeat | bounce`.
44
+ * - colors: an ARRAY of hex colors; `colors[0]` is the primary.
45
+ * `blink` uses colors[0]⇄colors[1]; `breath` breathes colors[0]⇄colors[1]
46
+ * (each derives a darker second color if omitted); `rainbow` uses
47
+ * colors[0] only as the starting hue; the rest use colors[0].
48
+ * - speed: optional per-state cycle in ms (blink toggle interval too).
49
+ * Default 1200.
50
+ *
51
+ * The whole config surface is also registered with the DSH settings service
52
+ * (namespace `web-icon-indicator`): validated against a schemastery schema,
53
+ * persisted to the profile's `settings.yaml`, and editable from
54
+ * 设置 → 插件 → 插件配置 in the Web GUI (see the `dsh.client` browser half).
55
+ * While the profile composes no settings service, the plugin keeps working
56
+ * exactly as before, reading the composition entry directly.
57
+ *
58
+ * @module dsh-web-icon-indicator
59
+ */
41
60
  import { dirname, join } from "node:path";
42
61
  import { fileURLToPath } from "node:url";
62
+ import z from "@deepseek-ai/schemastery";
63
+ import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
43
64
 
44
65
  const __dirname = dirname(fileURLToPath(import.meta.url));
45
66
 
46
67
  const DEFAULTS = Object.freeze({
47
68
  askingHoldMs: 3500,
48
- askingBlinkMs: 400,
49
69
  doneHoldMs: 5000,
50
70
  statusPath: "/dsh-web-icon-status.json",
51
71
  iconPathPrefix: "/dsh-web-icon-indicator",
52
72
  iconsDir: join(__dirname, "..", "icons"),
73
+ // One entry per state. `colors` is an ARRAY so multi-color effects (blink,
74
+ // breath, rainbow) can configure as many colors as they need. `speed` is the
75
+ // per-state cycle length in ms (blink toggle interval too); defaults to 1200.
76
+ states: {
77
+ idle: { effect: "static", colors: ["#1a1a1a"] },
78
+ running: { effect: "static", colors: ["#FACC15"] },
79
+ asking: { effect: "blink", colors: ["#E5484D", "#FACC15"], speed: 400 },
80
+ done: { effect: "static", colors: ["#22A06B"] },
81
+ },
82
+ });
83
+
84
+ const DEFAULT_SPEED = 1200; // fallback cycle when a state omits `speed`
85
+ const EFFECT_NAMES = ["static", "blink", "breath", "rainbow", "heartbeat", "bounce"];
86
+ const STATE_NAMES = ["idle", "running", "asking", "done"];
87
+
88
+ /**
89
+ * Settings namespace under which this plugin's config is registered (the
90
+ * `web-icon-indicator:` section of the profile's settings.yaml, surfaced in
91
+ * 设置 → 插件 → 插件配置). Lowercase kebab, per the settings domain's rule.
92
+ */
93
+ const SETTINGS_NAMESPACE = settingsNamespace("web-icon-indicator");
94
+
95
+ /**
96
+ * Schemastery schema mirroring `DEFAULTS`. Registered with the settings
97
+ * service so the config is validated, persisted, and editable from the Web
98
+ * settings page; resolution order is schema defaults → composition `base`
99
+ * → user layer (`~/.dsh/settings.yaml`).
100
+ */
101
+ const STATE_CONFIG_SCHEMA = z.object({
102
+ effect: z.union(EFFECT_NAMES).default("static"),
103
+ colors: z.array(z.string()).default(["#1a1a1a"]),
104
+ // Missing keys are omitted by schemastery, so an absent `speed` stays
105
+ // unset (the browser falls back to DEFAULT_SPEED).
106
+ speed: z.number().min(1),
107
+ });
108
+
109
+ const CONFIG_SCHEMA = z.object({
110
+ askingHoldMs: z.number().min(0).default(DEFAULTS.askingHoldMs),
111
+ doneHoldMs: z.number().min(0).default(DEFAULTS.doneHoldMs),
112
+ statusPath: z.string().default(DEFAULTS.statusPath),
113
+ iconPathPrefix: z.string().default(DEFAULTS.iconPathPrefix),
114
+ iconsDir: z.string(),
115
+ // dict (not a four-key object): the user layer may override only some
116
+ // states; the plugin merges the resolved value over DEFAULTS anyway.
117
+ states: z.dict(STATE_CONFIG_SCHEMA).default(DEFAULTS.states),
53
118
  });
54
119
 
55
120
  /** Browser script injected into every served index.html response. */
@@ -60,13 +125,20 @@ const INJECTED_SCRIPT = `
60
125
  window.__DSH_WEB_ICON_INDICATOR__ = true;
61
126
  var ORIGINAL = null;
62
127
  var TIMER = null;
63
- var ANIM = null;
64
- var ANIM_STATE = null;
65
- var ICONS = {};
66
- var LAST_STATE = null;
128
+ var RAF = null;
129
+ var PREV_STATE = null; // state the current animation loop was started for
130
+ var ANIM_START = null; // wall-clock start of the current loop (hidden-tab fallback)
67
131
  var STATUS_PATH = "__STATUS_PATH__";
68
- var ICON_PREFIX = "__ICON_PREFIX__";
69
- var ASKING_BLINK_MS = __ASKING_BLINK_MS__;
132
+ var BASE_PATH = "__BASE_PATH__";
133
+ var CFG = __CFG__; // { states: { idle:{effect,colors,speed}, running:…, asking:…, done:… } }
134
+ var CFG_JSON = null; // serialized states last seen; null = not synced yet
135
+ var BASE = null; // base.svg text; __COLOR__ replaced per frame
136
+ var RE = /__COLOR__/g;
137
+ var DEF_SPEED = 1200; // per-state cycle fallback when speed is omitted
138
+ var DEF_COLOR = "#1a1a1a"; // fallback primary color when a state has no colors
139
+
140
+ // Whale geometry (used as the transform pivot for scale/translate effects).
141
+ var CX = 27.889625, CY = 24.952640;
70
142
 
71
143
  function captureOriginal() {
72
144
  var link = document.querySelector("link[rel='icon']");
@@ -76,50 +148,179 @@ const INJECTED_SCRIPT = `
76
148
  var link = document.querySelector("link[rel='icon']");
77
149
  if (link && ORIGINAL) { link.setAttribute("href", ORIGINAL.href); if (ORIGINAL.type) link.setAttribute("type", ORIGINAL.type); }
78
150
  }
151
+ function linkEl() { return document.querySelector("link[rel='icon']"); }
79
152
  function setHref(uri) {
80
- var link = document.querySelector("link[rel='icon']");
153
+ var link = linkEl();
81
154
  if (!link) return;
82
155
  try { link.setAttribute("href", uri); link.setAttribute("type", "image/svg+xml"); } catch (e) {}
83
156
  }
84
- function iconUri(name) {
85
- if (ICONS[name]) return Promise.resolve(ICONS[name]);
86
- return fetch(location.origin + ICON_PREFIX + "/" + name + ".svg?t=" + Date.now(), { cache: "no-store" })
87
- .then(function (r) { if (!r.ok) throw new Error("icon " + r.status); return r.text(); })
88
- .then(function (txt) { ICONS[name] = "data:image/svg+xml," + encodeURIComponent(txt); return ICONS[name]; })
89
- .catch(function () { return null; });
157
+
158
+ // ---- color helpers ------------------------------------------------------
159
+ function hexToRgb(h) {
160
+ h = String(h).replace('#', '');
161
+ if (h.length === 3) h = h.charAt(0)+h.charAt(0)+h.charAt(1)+h.charAt(1)+h.charAt(2)+h.charAt(2);
162
+ var n = parseInt(h, 16);
163
+ return [(n>>16)&255, (n>>8)&255, n&255];
164
+ }
165
+ function rgbToHex(r, g, b) {
166
+ return '#' + [r, g, b].map(function (v) {
167
+ v = Math.round(v); if (v < 0) v = 0; if (v > 255) v = 255;
168
+ return ('0' + v.toString(16)).slice(-2);
169
+ }).join('');
170
+ }
171
+ function mix(a, b, t) {
172
+ var ca = hexToRgb(a), cb = hexToRgb(b);
173
+ return rgbToHex(ca[0]+(cb[0]-ca[0])*t, ca[1]+(cb[1]-ca[1])*t, ca[2]+(cb[2]-ca[2])*t);
174
+ }
175
+ function hslToHex(h, s, l) {
176
+ h = ((h % 360) + 360) % 360; s /= 100; l /= 100;
177
+ var c = (1 - Math.abs(2*l - 1)) * s, x = c * (1 - Math.abs((h/60)%2 - 1)), m = l - c/2, r = 0, g = 0, b = 0;
178
+ if (h < 60) { r = c; g = x; } else if (h < 120) { r = x; g = c; } else if (h < 180) { g = c; b = x; }
179
+ else if (h < 240) { g = x; b = c; } else if (h < 300) { r = x; b = c; } else { r = c; b = x; }
180
+ return rgbToHex((r+m)*255, (g+m)*255, (b+m)*255);
181
+ }
182
+ function hueOf(hex) {
183
+ var c = hexToRgb(hex).map(function (v) { return v/255; });
184
+ var max = Math.max.apply(null, c), min = Math.min.apply(null, c), d = max - min, h = 0;
185
+ if (d) {
186
+ if (max === c[0]) h = ((c[1] - c[2]) / d) % 6;
187
+ else if (max === c[1]) h = (c[2] - c[0]) / d + 2;
188
+ else h = (c[0] - c[1]) / d + 4;
189
+ h *= 60; if (h < 0) h += 360;
190
+ }
191
+ return h;
192
+ }
193
+
194
+ // ---- svg frame builder --------------------------------------------------
195
+ // Reuses the fetched base template; replaces its __COLOR__ token (and,
196
+ // for scale/translate effects, wraps the whale in a <g transform>).
197
+ function frameUri(fill, effect, t, speed) {
198
+ if (!BASE) return null;
199
+ var inner = BASE.replace(RE, fill);
200
+ if (effect === "heartbeat" || effect === "bounce") {
201
+ var gattr = "";
202
+ if (effect === "heartbeat") {
203
+ var tt = (t % speed) / speed;
204
+ var s = 1;
205
+ if (tt < 0.12) s = 1 + 0.16 * Math.sin(tt / 0.12 * Math.PI);
206
+ else if (tt < 0.25) s = 1 + 0.10 * Math.sin((tt - 0.12) / 0.13 * Math.PI);
207
+ gattr = 'transform="translate(' + CX + ' ' + CY + ') scale(' + s + ') translate(' + (-CX) + ' ' + (-CY) + ')"';
208
+ } else {
209
+ var dy = -Math.abs(Math.sin((t / speed) * 2 * Math.PI * 1.6)) * 6;
210
+ gattr = 'transform="translate(0 ' + dy.toFixed(2) + ')"';
211
+ }
212
+ inner = inner.replace('<path id="p"', '<g ' + gattr + '><path id="p"');
213
+ inner = inner.replace('</svg>', '</g></svg>');
214
+ }
215
+ return "data:image/svg+xml," + encodeURIComponent(inner);
90
216
  }
91
- function stopAnim() { if (ANIM) { clearInterval(ANIM); ANIM = null; } ANIM_STATE = null; }
217
+
218
+ // Consume a state's colors[] per effect. colors[0] is the primary color;
219
+ // multi-color effects read more entries and derive a fallback when absent.
220
+ function frameColor(cols, effect, t, speed) {
221
+ var c0 = cols[0] || DEF_COLOR;
222
+ var c1 = cols[1] || mix(c0, "#000000", 0.35); // derived second color if not provided
223
+ switch (effect) {
224
+ case "blink":
225
+ return ((t / speed) >> 0) % 2 === 0 ? c0 : c1;
226
+ case "breath": {
227
+ var k = 0.5 + 0.5 * Math.sin((t / speed) * 2 * Math.PI);
228
+ return mix(c0, c1, k);
229
+ }
230
+ case "rainbow":
231
+ return hslToHex(hueOf(c0) + (t / speed) * 360, 70, 58);
232
+ default:
233
+ return c0;
234
+ }
235
+ }
236
+
237
+ // ---- effects loop -------------------------------------------------------
238
+ function stateCfg(state) { return (CFG.states && CFG.states[state]) || {}; }
239
+ function isStatic(state) { return (stateCfg(state).effect || "static") === "static"; }
240
+ function frameAt(state, t) {
241
+ var st = stateCfg(state);
242
+ var effect = st.effect || "static";
243
+ var speed = st.speed || DEF_SPEED;
244
+ var cols = (st.colors && st.colors.length) ? st.colors : [DEF_COLOR];
245
+ return frameUri(frameColor(cols, effect, t, speed), effect, t, speed);
246
+ }
247
+ function stopAnim() { if (RAF) { cancelAnimationFrame(RAF); RAF = null; } }
92
248
  function apply(state) {
93
- var link = document.querySelector("link[rel='icon']");
249
+ var link = linkEl();
94
250
  if (!link) return;
95
- LAST_STATE = state;
96
- if (state === "idle" || state == null) { stopAnim(); restore(); return; }
97
- if (state === "asking") {
98
- if (ANIM_STATE === "asking") return;
99
- stopAnim();
100
- ANIM_STATE = "asking";
101
- Promise.all([iconUri("running"), iconUri("asking")]).then(function (uris) {
102
- if (!uris[0] || !uris[1] || !link.isConnected) return;
103
- var i = 0;
104
- setHref(uris[i]);
105
- ANIM = setInterval(function () { i = 1 - i; setHref(uris[i]); }, ASKING_BLINK_MS);
106
- });
251
+ if (state == null) { stopAnim(); return; }
252
+ // Unchanged state: keep the running loop (don't reset the phase) — the
253
+ // 1 s poll would otherwise restart animated effects from phase 0 every
254
+ // second. Browsers PAUSE requestAnimationFrame in hidden tabs, so a
255
+ // background tab would freeze on the last frame: repaint here instead —
256
+ // animated states get a wall-clock frame (coarse ~1 Hz animation), static
257
+ // states repaint (self-heal / pick up changes that happened while hidden).
258
+ if (BASE && state === PREV_STATE) {
259
+ if (isStatic(state)) {
260
+ var sframe = frameAt(state, 0);
261
+ if (sframe && link.isConnected) setHref(sframe);
262
+ } else if (ANIM_START !== null) {
263
+ var aframe = frameAt(state, Date.now() - ANIM_START);
264
+ if (aframe && link.isConnected) setHref(aframe);
265
+ }
107
266
  return;
108
267
  }
268
+ PREV_STATE = state;
109
269
  stopAnim();
110
- iconUri(state).then(function (uri) { if (uri && link.isConnected) setHref(uri); });
270
+ if (!BASE) { // base.svg not loaded yet — fetch once, then start
271
+ fetch(location.origin + BASE_PATH + "?t=" + Date.now(), { cache: "no-store" })
272
+ .then(function (r) { if (!r.ok) throw new Error("base " + r.status); return r.text(); })
273
+ .then(function (txt) { BASE = txt; PREV_STATE = null; apply(state); })
274
+ .catch(function () {});
275
+ return;
276
+ }
277
+ var t0 = null;
278
+ ANIM_START = Date.now();
279
+ // Paint the first frame synchronously so a state change shows even when
280
+ // rAF never fires (e.g. the tab is hidden); the rAF loop then takes over
281
+ // and drives the effect when the tab is visible.
282
+ var initial = frameAt(state, 0);
283
+ if (initial && link.isConnected) setHref(initial);
284
+ // rAF drives every effect. For "static" we paint one frame and stop;
285
+ // animated effects keep looping.
286
+ var step = function (now) {
287
+ if (t0 === null) t0 = now;
288
+ var uri = frameAt(state, now - t0);
289
+ if (uri && link.isConnected) setHref(uri);
290
+ if (isStatic(state)) return; // single frame, no loop
291
+ RAF = requestAnimationFrame(step);
292
+ };
293
+ RAF = requestAnimationFrame(step);
294
+ }
295
+ // Live config sync: the status response carries the current per-state
296
+ // visual config (the "states" field). When it differs from what this tab
297
+ // renders, swap it in and reset the animation phase so the next apply()
298
+ // repaints from scratch — a settings-card save takes effect within one
299
+ // poll tick, no tab reload needed. A response without "states" (older
300
+ // host) leaves the baked __CFG__ in place.
301
+ function syncCfg(j) {
302
+ if (!j || typeof j.states !== "object" || j.states === null) return;
303
+ var next = JSON.stringify(j.states);
304
+ if (next === CFG_JSON) return;
305
+ CFG_JSON = next;
306
+ CFG = { states: j.states };
307
+ PREV_STATE = null; // force a full repaint on the next apply
111
308
  }
112
309
  function poll() {
113
310
  fetch(location.origin + STATUS_PATH, { cache: "no-store" })
114
311
  .then(function (r) { if (!r.ok) throw new Error("bad"); return r.json(); })
115
- .then(function (j) { apply(j.state || "idle"); })
116
- .catch(function () { stopAnim(); restore(); if (TIMER) { clearInterval(TIMER); TIMER = null; } });
312
+ .then(function (j) { syncCfg(j); apply(j.state || "idle"); })
313
+ // Transient failures (host restarting, network blip) must NOT kill the
314
+ // poll: restore the original icon and retry on the next tick. The SPA
315
+ // reconnects in place, so the tab survives a host restart — the icon
316
+ // has to come back on its own once the endpoint returns.
317
+ .catch(function () { stopAnim(); restore(); });
117
318
  }
118
319
  captureOriginal();
119
320
  TIMER = setInterval(poll, 1000);
120
321
  poll();
121
322
  window.addEventListener("beforeunload", function () { stopAnim(); if (TIMER) clearInterval(TIMER); restore(); });
122
- } catch ( e) {}
323
+ } catch (e) { }
123
324
  })();
124
325
  `;
125
326
 
@@ -143,26 +344,81 @@ export default {
143
344
  inject: ["webServer", "timer", "agents", "fs", "sandboxPolicy"],
144
345
  config: {
145
346
  askingHoldMs: DEFAULTS.askingHoldMs,
146
- askingBlinkMs: DEFAULTS.askingBlinkMs,
147
347
  doneHoldMs: DEFAULTS.doneHoldMs,
148
348
  statusPath: DEFAULTS.statusPath,
149
349
  iconPathPrefix: DEFAULTS.iconPathPrefix,
150
350
  iconsDir: undefined,
351
+ states: DEFAULTS.states,
151
352
  },
353
+ /** Settings namespace + validation schema (exported for reuse/tooling). */
354
+ SETTINGS_NAMESPACE,
355
+ CONFIG_SCHEMA,
152
356
  apply(ctx) {
153
- const cfg = { ...DEFAULTS, ...(ctx.get("config") || {}) };
357
+ const entry = ctx.get("config") || {};
358
+ let source = () => entry;
359
+ // Merge each configured state over its default: { effect, colors, speed }.
360
+ // Idempotent on schema-resolved values, so it doubles as the fallback
361
+ // normalization when the profile composes no settings service.
362
+ const resolveConfig = (raw) => {
363
+ const stateConfigs = {};
364
+ for (const name of STATE_NAMES) {
365
+ const merged = { ...(DEFAULTS.states[name] || {}), ...(raw.states?.[name] || {}) };
366
+ // Guard against unknown effect names: fall back to "static" so the
367
+ // browser never spins a pointless frame loop on a typo.
368
+ if (merged.effect && EFFECT_NAMES.indexOf(merged.effect) === -1) merged.effect = "static";
369
+ // colors: coerce a lone string into an array, drop invalid hex values,
370
+ // and fall back to the state default when nothing valid remains.
371
+ let cols = merged.colors;
372
+ if (typeof cols === "string") cols = [cols];
373
+ if (!Array.isArray(cols)) cols = [];
374
+ cols = cols.filter((c) => typeof c === "string" && /^#[0-9a-fA-F]{3,6}$/.test(c.trim()));
375
+ merged.colors = cols.length ? cols : [...(DEFAULTS.states[name]?.colors || ["#1a1a1a"])];
376
+ // speed: must be a positive number; otherwise use the default (1200).
377
+ if (typeof merged.speed !== "number" || !(merged.speed > 0)) delete merged.speed;
378
+ stateConfigs[name] = merged;
379
+ }
380
+ return { ...DEFAULTS, ...raw, states: stateConfigs };
381
+ };
382
+ let cfg = resolveConfig(entry);
383
+ // The injected script bakes config at injection time; rebuild it whenever
384
+ // the settings section changes so the NEXT page load picks the new values
385
+ // up (reload the tab — same contract as before this registration).
386
+ const buildScript = (c) => INJECTED_SCRIPT
387
+ .replace("__STATUS_PATH__", c.statusPath)
388
+ .replace("__BASE_PATH__", c.iconPathPrefix + "/base.svg")
389
+ .replace("__CFG__", JSON.stringify({ states: c.states }));
390
+ let script = buildScript(cfg);
391
+ let iconsDir = resolveIconsDir(cfg.iconsDir);
392
+ installSettingsSection(ctx, SETTINGS_NAMESPACE, CONFIG_SCHEMA, entry, {
393
+ setSource: (next) => { source = next; },
394
+ onChange: () => {
395
+ // Settings service mounted, or the section changed: re-resolve, then
396
+ // mutate cfg in place so the state-machine closures (asking/done hold)
397
+ // and the injected script see the new values immediately.
398
+ const next = resolveConfig(source());
399
+ cfg.askingHoldMs = next.askingHoldMs;
400
+ cfg.doneHoldMs = next.doneHoldMs;
401
+ cfg.statusPath = next.statusPath;
402
+ cfg.iconPathPrefix = next.iconPathPrefix;
403
+ if (next.iconsDir !== undefined) cfg.iconsDir = next.iconsDir;
404
+ cfg.states = next.states;
405
+ iconsDir = resolveIconsDir(cfg.iconsDir);
406
+ script = buildScript(cfg);
407
+ },
408
+ });
154
409
  const webServer = ctx.webServer;
155
410
  const agents = ctx.agents;
156
411
  const fs = ctx.fs;
157
412
  const sp = ctx.sandboxPolicy;
158
- const iconsDir = resolveIconsDir(cfg.iconsDir);
159
413
  const lastSeen = new Map();
414
+ void sp; // sandboxPolicy injection is currently unused — reserved for future file-access policy
160
415
 
161
416
  // -- State machine -------------------------------------------------------
162
417
  const states = new Map(); // agentId -> { state, since }
163
418
  const asking = new Set(); // agentIds whose icon is currently pinned to asking
164
419
  const askDone = new Set(); // agentIds whose ask_user_question tool call already returned
165
420
  const askTimers = new Map(); // agentId -> ctx.timer.timeout disposer
421
+ const pendingApprovals = new Set(); // agentIds with an open approval/asked (waiting on the user)
166
422
 
167
423
  const setState = (id, state) => {
168
424
  const prev = states.get(id);
@@ -237,27 +493,60 @@ export default {
237
493
  }
238
494
  });
239
495
 
496
+ // Approval waits (sandbox interceptions, permission prompts): the agent
497
+ // blocks on the user for an `approval/asked` decision, so pin the session
498
+ // into `asking` exactly like ask_user_question.
499
+ ctx.on("session/event", (session, event) => {
500
+ const id = session?.id ?? event?.sessionId ?? null;
501
+ if (id == null || !event || (event.type !== "approval/asked" && event.type !== "approval/decided")) return;
502
+ if (event.type === "approval/asked") {
503
+ if (pendingApprovals.has(id)) return;
504
+ pendingApprovals.add(id);
505
+ asking.add(id);
506
+ askDone.delete(id);
507
+ setState(id, "asking");
508
+ } else {
509
+ if (!pendingApprovals.has(id)) return;
510
+ pendingApprovals.delete(id);
511
+ asking.delete(id);
512
+ const live = agents.get(id);
513
+ setState(id, live && live.status === "running" ? "running" : "idle");
514
+ }
515
+ });
516
+
240
517
  ctx.on("agent/disposed", (payload) => {
241
518
  const id = payload.agent?.id ?? null;
242
519
  if (id == null) return;
243
520
  states.delete(id);
244
521
  asking.delete(id);
245
522
  askDone.delete(id);
523
+ pendingApprovals.delete(id);
246
524
  lastSeen.delete(id);
247
525
  const t = askTimers.get(id);
248
526
  if (t) { try { t(); } catch (e) {} askTimers.delete(id); }
249
527
  });
250
528
 
251
- // Reconcile running->idle transitions on every status request. The
252
- // `agent/status` event's idle delivery is not guaranteed, so polling
253
- // agents.list() is the authoritative fallback.
529
+ // Authoritative pending-approval check: fold the live session's event log
530
+ // for an `approval/asked` that no `approval/decided` has closed.
531
+ const hasPendingApproval = (live) => {
532
+ const events = live?.session?.events;
533
+ if (!Array.isArray(events)) return false;
534
+ const open = new Set();
535
+ for (const ev of events) {
536
+ if (ev.type === "approval/asked") open.add(ev.data?.id);
537
+ else if (ev.type === "approval/decided") open.delete(ev.data?.id);
538
+ }
539
+ return open.size > 0;
540
+ };
541
+
542
+ // Reconcile running->idle transitions on every status request.
254
543
  const reconcile = () => {
255
544
  for (const a of agents.list()) {
256
545
  const id = a.id;
257
546
  const st = a.status;
258
547
  const prev = lastSeen.get(id);
259
548
  lastSeen.set(id, { status: st, at: Date.now() });
260
- if (asking.has(id)) continue;
549
+ if (asking.has(id) || hasPendingApproval(a)) continue;
261
550
  if (prev?.status === "running" && st === "idle") {
262
551
  setState(id, "done");
263
552
  } else if (st === "idle" && !states.has(id)) {
@@ -273,7 +562,7 @@ export default {
273
562
  const agentStateOf = (id) => {
274
563
  const live = agents.get(id);
275
564
  if (!live) return null;
276
- if (asking.has(id)) return "asking";
565
+ if (asking.has(id) || hasPendingApproval(live)) return "asking";
277
566
  return live.status === "running" ? "running" : "idle";
278
567
  };
279
568
 
@@ -298,6 +587,10 @@ export default {
298
587
  };
299
588
 
300
589
  // -- Routes --------------------------------------------------------------
590
+ // The status endpoint also echoes the current per-state visual config
591
+ // (`states`): the injected script syncs it on every poll, so a settings
592
+ // save changes the favicon within ~1 s without a tab reload. `no-store`
593
+ // keeps every poll reading the freshest config.
301
594
  ctx.effect(() => webServer.register({
302
595
  kind: "exact",
303
596
  path: cfg.statusPath,
@@ -305,10 +598,11 @@ export default {
305
598
  res.statusCode = 200;
306
599
  res.setHeader("Content-Type", "application/json; charset=utf-8");
307
600
  res.setHeader("Cache-Control", "no-store");
308
- res.end(JSON.stringify(aggregate()));
601
+ res.end(JSON.stringify({ ...aggregate(), states: cfg.states }));
309
602
  },
310
603
  }));
311
604
 
605
+ // Serve ONLY the base template (base.svg). The browser recolors/animate it.
312
606
  ctx.effect(() => webServer.register({
313
607
  kind: "prefix",
314
608
  path: cfg.iconPathPrefix,
@@ -316,7 +610,7 @@ export default {
316
610
  let pathname = "/";
317
611
  try { pathname = new URL(String(req?.url ?? "/"), "http://x").pathname; } catch (e) {}
318
612
  const name = pathname.replace(/^\/[^/]+\//, "");
319
- if (!/^(idle|running|asking|done)\.svg$/.test(name)) {
613
+ if (!/^base\.svg$/.test(name)) {
320
614
  res.statusCode = 404;
321
615
  res.setHeader("Content-Type", "text/plain; charset=utf-8");
322
616
  res.end("bad name: " + name);
@@ -345,11 +639,9 @@ export default {
345
639
  }));
346
640
 
347
641
  // -- Script injection ----------------------------------------------------
348
- const script = INJECTED_SCRIPT
349
- .replace("__STATUS_PATH__", cfg.statusPath)
350
- .replace("__ICON_PREFIX__", cfg.iconPathPrefix)
351
- .replace("__ASKING_BLINK_MS__", String(cfg.askingBlinkMs));
352
-
642
+ // `script` is rebuilt by the settings onChange hook; the tapIndex closure
643
+ // reads the current value on every request, so a settings edit reaches the
644
+ // next page load without a server restart.
353
645
  ctx.effect(() => webServer.tapIndex((html) => {
354
646
  if (html.indexOf("window.__DSH_WEB_ICON_INDICATOR__") !== -1) return html;
355
647
  const tag = `<script id="dsh-web-icon-indicator">${script}<\/script>`;
@@ -357,4 +649,4 @@ export default {
357
649
  return html + tag;
358
650
  }));
359
651
  },
360
- };
652
+ };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Browser half of dsh-web-icon-indicator (dsh.client bundle).
3
+ *
4
+ * Registers one card into the shared plugin-configuration surface
5
+ * (`settings.plugin.item` slot, keyed by the `web-icon-indicator` settings
6
+ * namespace). The card is inert unless the host serves the namespace, which
7
+ * the dispatching tab (`dsh-client-ui-settings-plugins`) checks for us.
8
+ */
9
+
10
+ /** Cordis services this browser plugin injects. */
11
+ export declare const inject: readonly ["slots", "settingsScope", "locale"];
12
+
13
+ /** Mount the settings card for the `web-icon-indicator` namespace. */
14
+ export declare function apply(ctx: unknown): void;