dsh-web-icon-indicator 0.1.0 → 0.2.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
@@ -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,19 @@ 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 BASE = null; // base.svg text; __COLOR__ replaced per frame
135
+ var RE = /__COLOR__/g;
136
+ var DEF_SPEED = 1200; // per-state cycle fallback when speed is omitted
137
+ var DEF_COLOR = "#1a1a1a"; // fallback primary color when a state has no colors
138
+
139
+ // Whale geometry (used as the transform pivot for scale/translate effects).
140
+ var CX = 27.889625, CY = 24.952640;
70
141
 
71
142
  function captureOriginal() {
72
143
  var link = document.querySelector("link[rel='icon']");
@@ -76,50 +147,165 @@ const INJECTED_SCRIPT = `
76
147
  var link = document.querySelector("link[rel='icon']");
77
148
  if (link && ORIGINAL) { link.setAttribute("href", ORIGINAL.href); if (ORIGINAL.type) link.setAttribute("type", ORIGINAL.type); }
78
149
  }
150
+ function linkEl() { return document.querySelector("link[rel='icon']"); }
79
151
  function setHref(uri) {
80
- var link = document.querySelector("link[rel='icon']");
152
+ var link = linkEl();
81
153
  if (!link) return;
82
154
  try { link.setAttribute("href", uri); link.setAttribute("type", "image/svg+xml"); } catch (e) {}
83
155
  }
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; });
156
+
157
+ // ---- color helpers ------------------------------------------------------
158
+ function hexToRgb(h) {
159
+ h = String(h).replace('#', '');
160
+ if (h.length === 3) h = h.charAt(0)+h.charAt(0)+h.charAt(1)+h.charAt(1)+h.charAt(2)+h.charAt(2);
161
+ var n = parseInt(h, 16);
162
+ return [(n>>16)&255, (n>>8)&255, n&255];
163
+ }
164
+ function rgbToHex(r, g, b) {
165
+ return '#' + [r, g, b].map(function (v) {
166
+ v = Math.round(v); if (v < 0) v = 0; if (v > 255) v = 255;
167
+ return ('0' + v.toString(16)).slice(-2);
168
+ }).join('');
169
+ }
170
+ function mix(a, b, t) {
171
+ var ca = hexToRgb(a), cb = hexToRgb(b);
172
+ return rgbToHex(ca[0]+(cb[0]-ca[0])*t, ca[1]+(cb[1]-ca[1])*t, ca[2]+(cb[2]-ca[2])*t);
173
+ }
174
+ function hslToHex(h, s, l) {
175
+ h = ((h % 360) + 360) % 360; s /= 100; l /= 100;
176
+ 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;
177
+ if (h < 60) { r = c; g = x; } else if (h < 120) { r = x; g = c; } else if (h < 180) { g = c; b = x; }
178
+ else if (h < 240) { g = x; b = c; } else if (h < 300) { r = x; b = c; } else { r = c; b = x; }
179
+ return rgbToHex((r+m)*255, (g+m)*255, (b+m)*255);
180
+ }
181
+ function hueOf(hex) {
182
+ var c = hexToRgb(hex).map(function (v) { return v/255; });
183
+ var max = Math.max.apply(null, c), min = Math.min.apply(null, c), d = max - min, h = 0;
184
+ if (d) {
185
+ if (max === c[0]) h = ((c[1] - c[2]) / d) % 6;
186
+ else if (max === c[1]) h = (c[2] - c[0]) / d + 2;
187
+ else h = (c[0] - c[1]) / d + 4;
188
+ h *= 60; if (h < 0) h += 360;
189
+ }
190
+ return h;
191
+ }
192
+
193
+ // ---- svg frame builder --------------------------------------------------
194
+ // Reuses the fetched base template; replaces its __COLOR__ token (and,
195
+ // for scale/translate effects, wraps the whale in a <g transform>).
196
+ function frameUri(fill, effect, t, speed) {
197
+ if (!BASE) return null;
198
+ var inner = BASE.replace(RE, fill);
199
+ if (effect === "heartbeat" || effect === "bounce") {
200
+ var gattr = "";
201
+ if (effect === "heartbeat") {
202
+ var tt = (t % speed) / speed;
203
+ var s = 1;
204
+ if (tt < 0.12) s = 1 + 0.16 * Math.sin(tt / 0.12 * Math.PI);
205
+ else if (tt < 0.25) s = 1 + 0.10 * Math.sin((tt - 0.12) / 0.13 * Math.PI);
206
+ gattr = 'transform="translate(' + CX + ' ' + CY + ') scale(' + s + ') translate(' + (-CX) + ' ' + (-CY) + ')"';
207
+ } else {
208
+ var dy = -Math.abs(Math.sin((t / speed) * 2 * Math.PI * 1.6)) * 6;
209
+ gattr = 'transform="translate(0 ' + dy.toFixed(2) + ')"';
210
+ }
211
+ inner = inner.replace('<path id="p"', '<g ' + gattr + '><path id="p"');
212
+ inner = inner.replace('</svg>', '</g></svg>');
213
+ }
214
+ return "data:image/svg+xml," + encodeURIComponent(inner);
215
+ }
216
+
217
+ // Consume a state's colors[] per effect. colors[0] is the primary color;
218
+ // multi-color effects read more entries and derive a fallback when absent.
219
+ function frameColor(cols, effect, t, speed) {
220
+ var c0 = cols[0] || DEF_COLOR;
221
+ var c1 = cols[1] || mix(c0, "#000000", 0.35); // derived second color if not provided
222
+ switch (effect) {
223
+ case "blink":
224
+ return ((t / speed) >> 0) % 2 === 0 ? c0 : c1;
225
+ case "breath": {
226
+ var k = 0.5 + 0.5 * Math.sin((t / speed) * 2 * Math.PI);
227
+ return mix(c0, c1, k);
228
+ }
229
+ case "rainbow":
230
+ return hslToHex(hueOf(c0) + (t / speed) * 360, 70, 58);
231
+ default:
232
+ return c0;
233
+ }
234
+ }
235
+
236
+ // ---- effects loop -------------------------------------------------------
237
+ function stateCfg(state) { return (CFG.states && CFG.states[state]) || {}; }
238
+ function isStatic(state) { return (stateCfg(state).effect || "static") === "static"; }
239
+ function frameAt(state, t) {
240
+ var st = stateCfg(state);
241
+ var effect = st.effect || "static";
242
+ var speed = st.speed || DEF_SPEED;
243
+ var cols = (st.colors && st.colors.length) ? st.colors : [DEF_COLOR];
244
+ return frameUri(frameColor(cols, effect, t, speed), effect, t, speed);
90
245
  }
91
- function stopAnim() { if (ANIM) { clearInterval(ANIM); ANIM = null; } ANIM_STATE = null; }
246
+ function stopAnim() { if (RAF) { cancelAnimationFrame(RAF); RAF = null; } }
92
247
  function apply(state) {
93
- var link = document.querySelector("link[rel='icon']");
248
+ var link = linkEl();
94
249
  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
- });
250
+ if (state == null) { stopAnim(); return; }
251
+ // Unchanged state: keep the running loop (don't reset the phase) — the
252
+ // 1 s poll would otherwise restart animated effects from phase 0 every
253
+ // second. Browsers PAUSE requestAnimationFrame in hidden tabs, so a
254
+ // background tab would freeze on the last frame: repaint here instead —
255
+ // animated states get a wall-clock frame (coarse ~1 Hz animation), static
256
+ // states repaint (self-heal / pick up changes that happened while hidden).
257
+ if (BASE && state === PREV_STATE) {
258
+ if (isStatic(state)) {
259
+ var sframe = frameAt(state, 0);
260
+ if (sframe && link.isConnected) setHref(sframe);
261
+ } else if (ANIM_START !== null) {
262
+ var aframe = frameAt(state, Date.now() - ANIM_START);
263
+ if (aframe && link.isConnected) setHref(aframe);
264
+ }
107
265
  return;
108
266
  }
267
+ PREV_STATE = state;
109
268
  stopAnim();
110
- iconUri(state).then(function (uri) { if (uri && link.isConnected) setHref(uri); });
269
+ if (!BASE) { // base.svg not loaded yet — fetch once, then start
270
+ fetch(location.origin + BASE_PATH + "?t=" + Date.now(), { cache: "no-store" })
271
+ .then(function (r) { if (!r.ok) throw new Error("base " + r.status); return r.text(); })
272
+ .then(function (txt) { BASE = txt; PREV_STATE = null; apply(state); })
273
+ .catch(function () {});
274
+ return;
275
+ }
276
+ var t0 = null;
277
+ ANIM_START = Date.now();
278
+ // Paint the first frame synchronously so a state change shows even when
279
+ // rAF never fires (e.g. the tab is hidden); the rAF loop then takes over
280
+ // and drives the effect when the tab is visible.
281
+ var initial = frameAt(state, 0);
282
+ if (initial && link.isConnected) setHref(initial);
283
+ // rAF drives every effect. For "static" we paint one frame and stop;
284
+ // animated effects keep looping.
285
+ var step = function (now) {
286
+ if (t0 === null) t0 = now;
287
+ var uri = frameAt(state, now - t0);
288
+ if (uri && link.isConnected) setHref(uri);
289
+ if (isStatic(state)) return; // single frame, no loop
290
+ RAF = requestAnimationFrame(step);
291
+ };
292
+ RAF = requestAnimationFrame(step);
111
293
  }
112
294
  function poll() {
113
295
  fetch(location.origin + STATUS_PATH, { cache: "no-store" })
114
296
  .then(function (r) { if (!r.ok) throw new Error("bad"); return r.json(); })
115
297
  .then(function (j) { apply(j.state || "idle"); })
116
- .catch(function () { stopAnim(); restore(); if (TIMER) { clearInterval(TIMER); TIMER = null; } });
298
+ // Transient failures (host restarting, network blip) must NOT kill the
299
+ // poll: restore the original icon and retry on the next tick. The SPA
300
+ // reconnects in place, so the tab survives a host restart — the icon
301
+ // has to come back on its own once the endpoint returns.
302
+ .catch(function () { stopAnim(); restore(); });
117
303
  }
118
304
  captureOriginal();
119
305
  TIMER = setInterval(poll, 1000);
120
306
  poll();
121
307
  window.addEventListener("beforeunload", function () { stopAnim(); if (TIMER) clearInterval(TIMER); restore(); });
122
- } catch ( e) {}
308
+ } catch (e) { }
123
309
  })();
124
310
  `;
125
311
 
@@ -143,26 +329,81 @@ export default {
143
329
  inject: ["webServer", "timer", "agents", "fs", "sandboxPolicy"],
144
330
  config: {
145
331
  askingHoldMs: DEFAULTS.askingHoldMs,
146
- askingBlinkMs: DEFAULTS.askingBlinkMs,
147
332
  doneHoldMs: DEFAULTS.doneHoldMs,
148
333
  statusPath: DEFAULTS.statusPath,
149
334
  iconPathPrefix: DEFAULTS.iconPathPrefix,
150
335
  iconsDir: undefined,
336
+ states: DEFAULTS.states,
151
337
  },
338
+ /** Settings namespace + validation schema (exported for reuse/tooling). */
339
+ SETTINGS_NAMESPACE,
340
+ CONFIG_SCHEMA,
152
341
  apply(ctx) {
153
- const cfg = { ...DEFAULTS, ...(ctx.get("config") || {}) };
342
+ const entry = ctx.get("config") || {};
343
+ let source = () => entry;
344
+ // Merge each configured state over its default: { effect, colors, speed }.
345
+ // Idempotent on schema-resolved values, so it doubles as the fallback
346
+ // normalization when the profile composes no settings service.
347
+ const resolveConfig = (raw) => {
348
+ const stateConfigs = {};
349
+ for (const name of STATE_NAMES) {
350
+ const merged = { ...(DEFAULTS.states[name] || {}), ...(raw.states?.[name] || {}) };
351
+ // Guard against unknown effect names: fall back to "static" so the
352
+ // browser never spins a pointless frame loop on a typo.
353
+ if (merged.effect && EFFECT_NAMES.indexOf(merged.effect) === -1) merged.effect = "static";
354
+ // colors: coerce a lone string into an array, drop invalid hex values,
355
+ // and fall back to the state default when nothing valid remains.
356
+ let cols = merged.colors;
357
+ if (typeof cols === "string") cols = [cols];
358
+ if (!Array.isArray(cols)) cols = [];
359
+ cols = cols.filter((c) => typeof c === "string" && /^#[0-9a-fA-F]{3,6}$/.test(c.trim()));
360
+ merged.colors = cols.length ? cols : [...(DEFAULTS.states[name]?.colors || ["#1a1a1a"])];
361
+ // speed: must be a positive number; otherwise use the default (1200).
362
+ if (typeof merged.speed !== "number" || !(merged.speed > 0)) delete merged.speed;
363
+ stateConfigs[name] = merged;
364
+ }
365
+ return { ...DEFAULTS, ...raw, states: stateConfigs };
366
+ };
367
+ let cfg = resolveConfig(entry);
368
+ // The injected script bakes config at injection time; rebuild it whenever
369
+ // the settings section changes so the NEXT page load picks the new values
370
+ // up (reload the tab — same contract as before this registration).
371
+ const buildScript = (c) => INJECTED_SCRIPT
372
+ .replace("__STATUS_PATH__", c.statusPath)
373
+ .replace("__BASE_PATH__", c.iconPathPrefix + "/base.svg")
374
+ .replace("__CFG__", JSON.stringify({ states: c.states }));
375
+ let script = buildScript(cfg);
376
+ let iconsDir = resolveIconsDir(cfg.iconsDir);
377
+ installSettingsSection(ctx, SETTINGS_NAMESPACE, CONFIG_SCHEMA, entry, {
378
+ setSource: (next) => { source = next; },
379
+ onChange: () => {
380
+ // Settings service mounted, or the section changed: re-resolve, then
381
+ // mutate cfg in place so the state-machine closures (asking/done hold)
382
+ // and the injected script see the new values immediately.
383
+ const next = resolveConfig(source());
384
+ cfg.askingHoldMs = next.askingHoldMs;
385
+ cfg.doneHoldMs = next.doneHoldMs;
386
+ cfg.statusPath = next.statusPath;
387
+ cfg.iconPathPrefix = next.iconPathPrefix;
388
+ if (next.iconsDir !== undefined) cfg.iconsDir = next.iconsDir;
389
+ cfg.states = next.states;
390
+ iconsDir = resolveIconsDir(cfg.iconsDir);
391
+ script = buildScript(cfg);
392
+ },
393
+ });
154
394
  const webServer = ctx.webServer;
155
395
  const agents = ctx.agents;
156
396
  const fs = ctx.fs;
157
397
  const sp = ctx.sandboxPolicy;
158
- const iconsDir = resolveIconsDir(cfg.iconsDir);
159
398
  const lastSeen = new Map();
399
+ void sp; // sandboxPolicy injection is currently unused — reserved for future file-access policy
160
400
 
161
401
  // -- State machine -------------------------------------------------------
162
402
  const states = new Map(); // agentId -> { state, since }
163
403
  const asking = new Set(); // agentIds whose icon is currently pinned to asking
164
404
  const askDone = new Set(); // agentIds whose ask_user_question tool call already returned
165
405
  const askTimers = new Map(); // agentId -> ctx.timer.timeout disposer
406
+ const pendingApprovals = new Set(); // agentIds with an open approval/asked (waiting on the user)
166
407
 
167
408
  const setState = (id, state) => {
168
409
  const prev = states.get(id);
@@ -237,27 +478,60 @@ export default {
237
478
  }
238
479
  });
239
480
 
481
+ // Approval waits (sandbox interceptions, permission prompts): the agent
482
+ // blocks on the user for an `approval/asked` decision, so pin the session
483
+ // into `asking` exactly like ask_user_question.
484
+ ctx.on("session/event", (session, event) => {
485
+ const id = session?.id ?? event?.sessionId ?? null;
486
+ if (id == null || !event || (event.type !== "approval/asked" && event.type !== "approval/decided")) return;
487
+ if (event.type === "approval/asked") {
488
+ if (pendingApprovals.has(id)) return;
489
+ pendingApprovals.add(id);
490
+ asking.add(id);
491
+ askDone.delete(id);
492
+ setState(id, "asking");
493
+ } else {
494
+ if (!pendingApprovals.has(id)) return;
495
+ pendingApprovals.delete(id);
496
+ asking.delete(id);
497
+ const live = agents.get(id);
498
+ setState(id, live && live.status === "running" ? "running" : "idle");
499
+ }
500
+ });
501
+
240
502
  ctx.on("agent/disposed", (payload) => {
241
503
  const id = payload.agent?.id ?? null;
242
504
  if (id == null) return;
243
505
  states.delete(id);
244
506
  asking.delete(id);
245
507
  askDone.delete(id);
508
+ pendingApprovals.delete(id);
246
509
  lastSeen.delete(id);
247
510
  const t = askTimers.get(id);
248
511
  if (t) { try { t(); } catch (e) {} askTimers.delete(id); }
249
512
  });
250
513
 
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.
514
+ // Authoritative pending-approval check: fold the live session's event log
515
+ // for an `approval/asked` that no `approval/decided` has closed.
516
+ const hasPendingApproval = (live) => {
517
+ const events = live?.session?.events;
518
+ if (!Array.isArray(events)) return false;
519
+ const open = new Set();
520
+ for (const ev of events) {
521
+ if (ev.type === "approval/asked") open.add(ev.data?.id);
522
+ else if (ev.type === "approval/decided") open.delete(ev.data?.id);
523
+ }
524
+ return open.size > 0;
525
+ };
526
+
527
+ // Reconcile running->idle transitions on every status request.
254
528
  const reconcile = () => {
255
529
  for (const a of agents.list()) {
256
530
  const id = a.id;
257
531
  const st = a.status;
258
532
  const prev = lastSeen.get(id);
259
533
  lastSeen.set(id, { status: st, at: Date.now() });
260
- if (asking.has(id)) continue;
534
+ if (asking.has(id) || hasPendingApproval(a)) continue;
261
535
  if (prev?.status === "running" && st === "idle") {
262
536
  setState(id, "done");
263
537
  } else if (st === "idle" && !states.has(id)) {
@@ -273,7 +547,7 @@ export default {
273
547
  const agentStateOf = (id) => {
274
548
  const live = agents.get(id);
275
549
  if (!live) return null;
276
- if (asking.has(id)) return "asking";
550
+ if (asking.has(id) || hasPendingApproval(live)) return "asking";
277
551
  return live.status === "running" ? "running" : "idle";
278
552
  };
279
553
 
@@ -309,6 +583,7 @@ export default {
309
583
  },
310
584
  }));
311
585
 
586
+ // Serve ONLY the base template (base.svg). The browser recolors/animate it.
312
587
  ctx.effect(() => webServer.register({
313
588
  kind: "prefix",
314
589
  path: cfg.iconPathPrefix,
@@ -316,7 +591,7 @@ export default {
316
591
  let pathname = "/";
317
592
  try { pathname = new URL(String(req?.url ?? "/"), "http://x").pathname; } catch (e) {}
318
593
  const name = pathname.replace(/^\/[^/]+\//, "");
319
- if (!/^(idle|running|asking|done)\.svg$/.test(name)) {
594
+ if (!/^base\.svg$/.test(name)) {
320
595
  res.statusCode = 404;
321
596
  res.setHeader("Content-Type", "text/plain; charset=utf-8");
322
597
  res.end("bad name: " + name);
@@ -345,11 +620,9 @@ export default {
345
620
  }));
346
621
 
347
622
  // -- 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
-
623
+ // `script` is rebuilt by the settings onChange hook; the tapIndex closure
624
+ // reads the current value on every request, so a settings edit reaches the
625
+ // next page load without a server restart.
353
626
  ctx.effect(() => webServer.tapIndex((html) => {
354
627
  if (html.indexOf("window.__DSH_WEB_ICON_INDICATOR__") !== -1) return html;
355
628
  const tag = `<script id="dsh-web-icon-indicator">${script}<\/script>`;
@@ -357,4 +630,4 @@ export default {
357
630
  return html + tag;
358
631
  }));
359
632
  },
360
- };
633
+ };
@@ -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;