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/README.md +79 -21
- package/README.zh.md +74 -21
- package/icons/base.svg +6 -0
- package/lib/client.js +533 -0
- package/lib/index.js +358 -85
- package/lib/types/client/index.d.ts +14 -0
- package/lib/types/index.d.ts +70 -13
- package/package.json +19 -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,19 @@ 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 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 =
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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 (
|
|
246
|
+
function stopAnim() { if (RAF) { cancelAnimationFrame(RAF); RAF = null; } }
|
|
92
247
|
function apply(state) {
|
|
93
|
-
var link =
|
|
248
|
+
var link = linkEl();
|
|
94
249
|
if (!link) return;
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
//
|
|
252
|
-
// `
|
|
253
|
-
|
|
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 (!/^
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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;
|