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/README.md +83 -21
- package/README.zh.md +77 -21
- package/icons/base.svg +6 -0
- package/lib/client.js +602 -0
- package/lib/index.js +379 -87
- package/lib/types/client/index.d.ts +14 -0
- package/lib/types/index.d.ts +76 -13
- package/package.json +20 -2
- package/icons/asking.svg +0 -6
- package/icons/done.svg +0 -6
- package/icons/idle.svg +0 -8
- package/icons/running.svg +0 -6
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
|
|
5
|
-
* `
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* `icons
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* `
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* doneHoldMs How long the done
|
|
37
|
-
* Default 5000.
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
|
64
|
-
var
|
|
65
|
-
var
|
|
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
|
|
69
|
-
var
|
|
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 =
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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 =
|
|
249
|
+
var link = linkEl();
|
|
94
250
|
if (!link) return;
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
//
|
|
252
|
-
// `
|
|
253
|
-
|
|
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 (!/^
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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;
|