dsh-web-icon-indicator 0.4.2 → 0.5.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/CHANGELOG.md +25 -0
- package/README.md +132 -42
- package/README.zh.md +102 -36
- package/lib/client.js +856 -130
- package/lib/index.js +546 -118
- package/lib/types/client/index.d.ts +13 -7
- package/lib/types/index.d.ts +87 -9
- package/package.json +5 -4
- package/test/loader-hooks.mjs +21 -0
- package/test/stubs/dsh-settings.mjs +15 -0
- package/test/stubs/schemastery.mjs +12 -0
- package/test/verify.js +2273 -0
- package/docs/safari-favicon-research.md +0 -114
package/lib/index.js
CHANGED
|
@@ -40,10 +40,20 @@
|
|
|
40
40
|
* Configuration object (all optional):
|
|
41
41
|
* iconsDir Absolute directory holding base.svg. Default `<package>/icons/`.
|
|
42
42
|
* statusPath JSON status endpoint path. Default `/dsh-web-icon-status.json`.
|
|
43
|
+
* Registration-time (non-volatile): composition entry only.
|
|
43
44
|
* iconPathPrefix URL prefix for static icon files. Default `/dsh-web-icon-indicator`.
|
|
45
|
+
* Registration-time (non-volatile): composition entry only.
|
|
44
46
|
* askingHoldMs Minimum visibility for the asking state (ms). Default 3500.
|
|
45
47
|
* doneHoldMs How long the done state stays before falling back to idle.
|
|
46
48
|
* Default 5000.
|
|
49
|
+
* defaultColor Default icon color — the idle whale's primary color, as
|
|
50
|
+
* `#rgb` / `#rrggbb`. Absent = the idle state's own colors[0]
|
|
51
|
+
* (today's behavior). Give each DSH instance (its own profile /
|
|
52
|
+
* user settings layer) a different value to tell their browser
|
|
53
|
+
* tabs apart. A value perceptually too close to another state's
|
|
54
|
+
* color raises a WARNING — surfaced in the settings card, in
|
|
55
|
+
* the host log, and as `warnings` on the status endpoint — but
|
|
56
|
+
* is still honored.
|
|
47
57
|
* states Per-state visual config, keyed by state name:
|
|
48
58
|
* states[idle] = { effect, colors[], speed? }
|
|
49
59
|
* states[running] = { effect, colors[], speed? }
|
|
@@ -57,12 +67,23 @@
|
|
|
57
67
|
* - speed: optional per-state cycle in ms (blink toggle interval too).
|
|
58
68
|
* Default 1200.
|
|
59
69
|
*
|
|
60
|
-
* The whole config surface is
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
70
|
+
* The whole config surface is the plugin's Cordis `Config` schema (exported as
|
|
71
|
+
* both `Config` and `CONFIG_SCHEMA`), and the same schema is registered with
|
|
72
|
+
* the LEGACY settings service when the running host still exposes
|
|
73
|
+
* `installSection` — one bundle serves every published host generation:
|
|
74
|
+
*
|
|
75
|
+
* - DSH ≥ 0.1.7: the settings service projects the schema's `.volatile()`
|
|
76
|
+
* fields into a live form, persisted into the profile patch
|
|
77
|
+
* (`~/.dsh/profiles/<profile>/cordis.patch.yml`); a write mutates the
|
|
78
|
+
* running plugin's config references in place and emits
|
|
79
|
+
* `loader/volatile-update` instead of restarting it.
|
|
80
|
+
* - DSH ≤ 0.1.6-alpha.1: the plugin registers the namespace itself under
|
|
81
|
+
* `web-icon-indicator` and the service drives it through
|
|
82
|
+
* `setSource`/`onChange`, persisted into `~/.dsh/settings.yaml`.
|
|
83
|
+
*
|
|
84
|
+
* `statusPath` / `iconPathPrefix` stay non-volatile in both. When the profile
|
|
85
|
+
* composes no settings service at all, the plugin keeps working on the
|
|
86
|
+
* composition entry + defaults it was mounted with.
|
|
66
87
|
*
|
|
67
88
|
* @module dsh-web-icon-indicator
|
|
68
89
|
*/
|
|
@@ -94,21 +115,54 @@ const EFFECT_NAMES = ["static", "blink", "breath", "rainbow", "heartbeat", "boun
|
|
|
94
115
|
const STATE_NAMES = ["idle", "running", "asking", "done"];
|
|
95
116
|
|
|
96
117
|
/**
|
|
97
|
-
* Settings namespace
|
|
98
|
-
*
|
|
99
|
-
*
|
|
118
|
+
* Settings namespace of this plugin on the MODERN host line (DSH ≥ 0.1.7):
|
|
119
|
+
* the settings namespace IS the profile entry id (`schema(entry)` reads
|
|
120
|
+
* `entry.fiber.runtime.Config` and `describe()` keys every form by
|
|
121
|
+
* `entry.options.id`), and this plugin's bundle patch declares exactly one row:
|
|
122
|
+
* `dsh-web-icon-indicator`. The browser half reads the same namespace through
|
|
123
|
+
* `ctx.configForms.get(...)`, so the two halves must keep this literal in sync
|
|
124
|
+
* (it is also the row id in `cordis.patch.yml`).
|
|
125
|
+
*/
|
|
126
|
+
const SETTINGS_NAMESPACE = "dsh-web-icon-indicator";
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Settings namespace on the LEGACY host line (DSH ≤ 0.1.6-alpha.1), where the
|
|
130
|
+
* consumer-owned namespace is an arbitrary string it passes to
|
|
131
|
+
* `settings.installSection` and the settings document section is named after
|
|
132
|
+
* it. Kept at the historical value so an existing 0.5.x user's
|
|
133
|
+
* `web-icon-indicator:` section keeps working after this upgrade; the browser
|
|
134
|
+
* half binds its legacy scope to the same string.
|
|
135
|
+
*/
|
|
136
|
+
const LEGACY_SETTINGS_NAMESPACE = "web-icon-indicator";
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Mark one field as LIVE when the resolved schemastery supports it.
|
|
100
140
|
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
141
|
+
* `.volatile()` exists since schemastery 3.18.3 and is what makes the modern
|
|
142
|
+
* settings service render a field at all (`volatileForm()` keeps volatile
|
|
143
|
+
* fields only) and what makes a settings write mutate the running plugin's
|
|
144
|
+
* references + emit `loader/volatile-update` instead of restarting it. On the
|
|
145
|
+
* legacy host line the installed schemastery (3.18.2) has no `.volatile()`,
|
|
146
|
+
* and the legacy settings service owns the whole live-form lifecycle through
|
|
147
|
+
* `installSection`/`setSource`/`onChange`, so the same schema object works
|
|
148
|
+
* unwrapped there.
|
|
104
149
|
*/
|
|
105
|
-
const
|
|
150
|
+
const LIVE = (schema) => (typeof schema.volatile === "function" ? schema.volatile() : schema);
|
|
106
151
|
|
|
107
152
|
/**
|
|
108
|
-
* Schemastery schema mirroring `DEFAULTS`.
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
153
|
+
* Schemastery schema mirroring `DEFAULTS`. Exported as the plugin's `Config`,
|
|
154
|
+
* which is what the Cordis loader validates the composition row against and
|
|
155
|
+
* what the modern host settings service projects into its live form
|
|
156
|
+
* (`volatileForm` keeps only `.volatile()` fields, so the settings page can
|
|
157
|
+
* never advertise a key it cannot honor).
|
|
158
|
+
*
|
|
159
|
+
* `.volatile()` marks a field as LIVE: the loader commits a settings write
|
|
160
|
+
* into the running fiber's references and emits `loader/volatile-update`
|
|
161
|
+
* instead of restarting the plugin (see `apply`). Ordinary (non-volatile)
|
|
162
|
+
* fields — `statusPath` / `iconPathPrefix` — are baked into the route table
|
|
163
|
+
* and the injected script at registration time, so a document change to them
|
|
164
|
+
* could never take effect; keeping them non-volatile removes them from the
|
|
165
|
+
* live form while still validating the composition entry.
|
|
112
166
|
*/
|
|
113
167
|
const STATE_CONFIG_SCHEMA = z.object({
|
|
114
168
|
effect: z.union(EFFECT_NAMES).default("static"),
|
|
@@ -119,14 +173,24 @@ const STATE_CONFIG_SCHEMA = z.object({
|
|
|
119
173
|
});
|
|
120
174
|
|
|
121
175
|
const CONFIG_SCHEMA = z.object({
|
|
122
|
-
askingHoldMs: z.number().min(0).default(DEFAULTS.askingHoldMs),
|
|
123
|
-
doneHoldMs: z.number().min(0).default(DEFAULTS.doneHoldMs),
|
|
176
|
+
askingHoldMs: LIVE(z.number().min(0).default(DEFAULTS.askingHoldMs)),
|
|
177
|
+
doneHoldMs: LIVE(z.number().min(0).default(DEFAULTS.doneHoldMs)),
|
|
178
|
+
// Default icon color (`#rgb` / `#rrggbb`). Deliberately WITHOUT a schema
|
|
179
|
+
// default: an absent value keeps the idle state's own colors[0], exactly as
|
|
180
|
+
// before this key existed. `resolveConfig` folds a valid value into
|
|
181
|
+
// `states.idle.colors[0]` and drops an invalid one with a warning.
|
|
182
|
+
defaultColor: LIVE(z.string()),
|
|
183
|
+
iconsDir: LIVE(z.string()),
|
|
184
|
+
// dict (not a four-key object): the user layer may override only some
|
|
185
|
+
// states; the plugin merges the resolved value over DEFAULTS anyway. The
|
|
186
|
+
// whole dict is one volatile field, so the custom card can stage and write
|
|
187
|
+
// it atomically through `scope.mutate([{ op: 'set', path: ['states'], … }])`.
|
|
188
|
+
states: LIVE(z.dict(STATE_CONFIG_SCHEMA).default(DEFAULTS.states)),
|
|
189
|
+
// Registration-time keys: route table + injected script URLs are built in
|
|
190
|
+
// apply(), so they are composition-entry only (non-volatile ⇒ absent from
|
|
191
|
+
// the live settings form). See the README config table.
|
|
124
192
|
statusPath: z.string().default(DEFAULTS.statusPath),
|
|
125
193
|
iconPathPrefix: z.string().default(DEFAULTS.iconPathPrefix),
|
|
126
|
-
iconsDir: z.string(),
|
|
127
|
-
// dict (not a four-key object): the user layer may override only some
|
|
128
|
-
// states; the plugin merges the resolved value over DEFAULTS anyway.
|
|
129
|
-
states: z.dict(STATE_CONFIG_SCHEMA).default(DEFAULTS.states),
|
|
130
194
|
});
|
|
131
195
|
|
|
132
196
|
/** Browser script injected into every served index.html response. */
|
|
@@ -156,6 +220,23 @@ const INJECTED_SCRIPT = `
|
|
|
156
220
|
// Whale geometry (used as the transform pivot for scale/translate effects).
|
|
157
221
|
var CX = 27.889625, CY = 24.952640;
|
|
158
222
|
|
|
223
|
+
// Every request this script makes is deadline-bounded: a fetch that hangs
|
|
224
|
+
// (half-open TCP, a suspended host, a captive portal) would otherwise hold
|
|
225
|
+
// the poll chain and the base.svg load forever, freezing the icon with no
|
|
226
|
+
// recovery. Aborting surfaces as a rejection, so the existing retry paths
|
|
227
|
+
// (poll keeps its interval; base.svg retries on the next tick) take over.
|
|
228
|
+
function timedFetch(url, init) {
|
|
229
|
+
if (typeof AbortController === "undefined") return fetch(url, init);
|
|
230
|
+
var ctrl = new AbortController();
|
|
231
|
+
var timer = setTimeout(function () { try { ctrl.abort(); } catch (e) {} }, 8000);
|
|
232
|
+
var opts = init || {};
|
|
233
|
+
opts.signal = ctrl.signal;
|
|
234
|
+
return fetch(url, opts).then(
|
|
235
|
+
function (r) { clearTimeout(timer); return r; },
|
|
236
|
+
function (e) { clearTimeout(timer); throw e; }
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
159
240
|
// Capture the shell's own favicon ONCE, together with an offline-safe
|
|
160
241
|
// data-URI copy of it. The copy matters: while the DSH host is stopped
|
|
161
242
|
// the original href (a URL served by that same host) is unreachable, so
|
|
@@ -175,7 +256,7 @@ const INJECTED_SCRIPT = `
|
|
|
175
256
|
// keeps the last plugin frame instead of risking a dead URL.
|
|
176
257
|
try {
|
|
177
258
|
if (!href || typeof Blob === "undefined" || typeof FileReader === "undefined") return;
|
|
178
|
-
|
|
259
|
+
timedFetch(href)
|
|
179
260
|
.then(function (r) { if (!r.ok) throw new Error("orig " + r.status); return r.blob(); })
|
|
180
261
|
.then(function (blob) {
|
|
181
262
|
return new Promise(function (resolve, reject) {
|
|
@@ -263,6 +344,27 @@ const INJECTED_SCRIPT = `
|
|
|
263
344
|
}
|
|
264
345
|
|
|
265
346
|
// ---- svg frame builder --------------------------------------------------
|
|
347
|
+
// Frame-URI cache. The base template is re-read and re-encoded on every
|
|
348
|
+
// animation frame, but most effects cycle through a tiny set of fills
|
|
349
|
+
// (static → 1, blink → 2, breath/geometric effects → 1 per color pair), so
|
|
350
|
+
// the encoded data URI is stable and can be memoized. rainbow is the
|
|
351
|
+
// exception — its fill is a fresh hue every frame — and is left uncached so
|
|
352
|
+
// the map can never grow without bound.
|
|
353
|
+
var URI_CACHE = new Map();
|
|
354
|
+
function cachedUri(kind, fill, build) {
|
|
355
|
+
var key = kind + "|" + fill;
|
|
356
|
+
var hit = URI_CACHE.get(key);
|
|
357
|
+
if (hit === undefined) {
|
|
358
|
+
hit = build();
|
|
359
|
+
// Keep the cache small (state count × a handful of colors); a settings
|
|
360
|
+
// edit can introduce new colors, and this script runs for the life of
|
|
361
|
+
// the tab.
|
|
362
|
+
if (URI_CACHE.size > 64) URI_CACHE.clear();
|
|
363
|
+
URI_CACHE.set(key, hit);
|
|
364
|
+
}
|
|
365
|
+
return hit;
|
|
366
|
+
}
|
|
367
|
+
|
|
266
368
|
// Full-frame activity count ("满幅数字", per demo/badge.html bigNum
|
|
267
369
|
// channel): while more than one agent is active the whole icon becomes a
|
|
268
370
|
// state-colored rounded block with a bold count — no whale is drawn, so
|
|
@@ -275,22 +377,28 @@ const INJECTED_SCRIPT = `
|
|
|
275
377
|
// cropping (mirrors the demo's svgFrame showWhale=false branch).
|
|
276
378
|
function bigNumUri(fill) {
|
|
277
379
|
var text = String(ACTIVE > 99 ? "99+" : ACTIVE);
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
'<
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
380
|
+
return cachedUri("n" + text, fill, function () {
|
|
381
|
+
var chars = text.length;
|
|
382
|
+
var fs = chars === 1 ? 26 : chars === 2 ? 20 : 15.5;
|
|
383
|
+
var rgb = hexToRgb(fill);
|
|
384
|
+
var lum = (0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255;
|
|
385
|
+
var fg = lum > 0.6 ? "#111111" : "#FFFFFF"; // auto-contrast on the state color
|
|
386
|
+
var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 50 50" fill="none">' +
|
|
387
|
+
'<rect x="0" y="0" width="50" height="50" rx="' + BIG_NUM_RX + '" fill="' + fill + '"/>' +
|
|
388
|
+
'<text x="25" y="' + (25 + fs * 0.34).toFixed(1) + '" text-anchor="middle" font-family="system-ui, sans-serif" font-size="' + fs + '" font-weight="800" fill="' + fg + '">' + text + '</text>' +
|
|
389
|
+
'</svg>';
|
|
390
|
+
return "data:image/svg+xml," + encodeURIComponent(svg);
|
|
391
|
+
});
|
|
288
392
|
}
|
|
289
393
|
|
|
290
394
|
// Reuses the fetched base template; replaces its __COLOR__ token (and,
|
|
291
395
|
// for scale/translate effects, wraps the whale in a <g transform>).
|
|
292
396
|
function frameUri(fill, effect, t, speed) {
|
|
293
397
|
if (!BASE) return null;
|
|
398
|
+
if (effect === "static") {
|
|
399
|
+
// No time component: one URI per color for the life of the tab.
|
|
400
|
+
return cachedUri("w", fill, function () { return "data:image/svg+xml," + encodeURIComponent(BASE.replace(RE, fill)); });
|
|
401
|
+
}
|
|
294
402
|
var inner = BASE.replace(RE, fill);
|
|
295
403
|
if (effect === "heartbeat" || effect === "bounce") {
|
|
296
404
|
var gattr = "";
|
|
@@ -353,7 +461,12 @@ const INJECTED_SCRIPT = `
|
|
|
353
461
|
// requestAnimationFrame in hidden tabs, so a background tab would freeze
|
|
354
462
|
// on the last frame: repaint here instead — animated states get a
|
|
355
463
|
// wall-clock frame (coarse ~1 Hz animation), static states repaint
|
|
356
|
-
// (self-heal / pick up changes that happened while hidden).
|
|
464
|
+
// (self-heal / pick up changes that happened while hidden). A state
|
|
465
|
+
// change that happens while the tab is away (like the done hold
|
|
466
|
+
// expiring) paints on the next poll; the visibilitychange listener at
|
|
467
|
+
// the bottom fetches immediately when the tab comes back to the
|
|
468
|
+
// foreground, so that revert shows at once instead of waiting for a
|
|
469
|
+
// background-throttled tick.
|
|
357
470
|
if (BASE && state + "|" + ACTIVE === PREV_KEY) {
|
|
358
471
|
if (isStatic(state)) {
|
|
359
472
|
var sframe = frameAt(state, 0);
|
|
@@ -366,8 +479,12 @@ const INJECTED_SCRIPT = `
|
|
|
366
479
|
}
|
|
367
480
|
PREV_KEY = state + "|" + ACTIVE;
|
|
368
481
|
stopAnim();
|
|
369
|
-
|
|
370
|
-
|
|
482
|
+
// The count block is drawn from scratch, so it needs no base.svg: only the
|
|
483
|
+
// whale path waits for the template. Loading it here (and retrying on the
|
|
484
|
+
// next poll, since PREV_KEY was already set) keeps a failed/timed-out
|
|
485
|
+
// template fetch from blanking the tab while agents are busy.
|
|
486
|
+
if (!BASE && ACTIVE < BIG_NUM_MIN) {
|
|
487
|
+
timedFetch(location.origin + BASE_PATH + "?t=" + Date.now(), { cache: "no-store" })
|
|
371
488
|
.then(function (r) { if (!r.ok) throw new Error("base " + r.status); return r.text(); })
|
|
372
489
|
.then(function (txt) { BASE = txt; PREV_KEY = null; apply(state); })
|
|
373
490
|
.catch(function () {});
|
|
@@ -409,7 +526,7 @@ const INJECTED_SCRIPT = `
|
|
|
409
526
|
PREV_KEY = null; // force a full repaint on the next apply
|
|
410
527
|
}
|
|
411
528
|
function poll() {
|
|
412
|
-
|
|
529
|
+
timedFetch(location.origin + STATUS_PATH, { cache: "no-store" })
|
|
413
530
|
.then(function (r) { if (!r.ok) throw new Error("bad"); return r.json(); })
|
|
414
531
|
.then(function (j) {
|
|
415
532
|
syncCfg(j);
|
|
@@ -437,6 +554,16 @@ const INJECTED_SCRIPT = `
|
|
|
437
554
|
captureOriginal();
|
|
438
555
|
TIMER = setInterval(poll, 1000);
|
|
439
556
|
poll();
|
|
557
|
+
// A BACKGROUND tab's timers are throttled (Chrome clamps setInterval to
|
|
558
|
+
// ~1/min after ~5 min hidden) and rAF is paused, so a state change that
|
|
559
|
+
// happened while the tab was away may take a while to reach the favicon —
|
|
560
|
+
// e.g. the green "done" frame sits there until the next poll finally fires.
|
|
561
|
+
// The moment the tab becomes visible again, fetch the fresh status and
|
|
562
|
+
// repaint right away instead of waiting for the next (possibly throttled)
|
|
563
|
+
// poll tick. Cheap, idempotent, and it also covers browsers that froze the
|
|
564
|
+
// page entirely while hidden (their first task on unfreeze is this poll).
|
|
565
|
+
function onVisible() { if (document.visibilityState === "visible") poll(); }
|
|
566
|
+
window.addEventListener("visibilitychange", onVisible);
|
|
440
567
|
window.addEventListener("beforeunload", function () { stopAnim(); if (TIMER) clearInterval(TIMER); restore(); });
|
|
441
568
|
} catch (e) { }
|
|
442
569
|
})();
|
|
@@ -453,39 +580,214 @@ function resolveIconsDir(configured) {
|
|
|
453
580
|
return join(__dirname, "..", "icons");
|
|
454
581
|
}
|
|
455
582
|
|
|
583
|
+
// ---- Color math for the default-color similarity check ---------------------
|
|
584
|
+
// lib/client.js carries its OWN copy of this block: the two halves run in
|
|
585
|
+
// different processes, there is no shared module and no build step (docs/main.js
|
|
586
|
+
// already duplicates the same color math for the showcase site). Keep the
|
|
587
|
+
// thresholds in sync when tuning either copy.
|
|
588
|
+
/** Accepted defaultColor shape: `#rgb` or `#rrggbb` (case-insensitive). */
|
|
589
|
+
const DEFAULT_COLOR_RX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
|
590
|
+
/** CIE76 ΔE below which two colors are practically the same. */
|
|
591
|
+
const SIMILAR_DE_STRONG = 12;
|
|
592
|
+
/** CIE76 ΔE below which two colors are easily confused at favicon size. */
|
|
593
|
+
const SIMILAR_DE_WARN = 25;
|
|
594
|
+
/** Lab chroma above which a default color collides with a rainbow sweep. */
|
|
595
|
+
const RAINBOW_CHROMA_MIN = 15;
|
|
596
|
+
/** The states a default color is compared against (idle is the default itself). */
|
|
597
|
+
const OTHER_STATES = STATE_NAMES.filter((name) => name !== "idle");
|
|
598
|
+
|
|
599
|
+
/** `#rgb` / `#rrggbb` → lowercase 6-digit hex, or null when malformed. */
|
|
600
|
+
function normalizeHex(value) {
|
|
601
|
+
if (typeof value !== "string") return null;
|
|
602
|
+
const text = value.trim();
|
|
603
|
+
if (!DEFAULT_COLOR_RX.test(text)) return null;
|
|
604
|
+
const body = text.slice(1).toLowerCase();
|
|
605
|
+
return body.length === 3
|
|
606
|
+
? "#" + body[0] + body[0] + body[1] + body[1] + body[2] + body[2]
|
|
607
|
+
: "#" + body;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function hexToRgb(hex) {
|
|
611
|
+
let body = String(hex).replace("#", "");
|
|
612
|
+
// Expand #rgb exactly like the browser half does — every host caller feeds
|
|
613
|
+
// normalizeHex()'s 6-digit output today, but the shortcut is a trap for the
|
|
614
|
+
// next caller (and this copy must stay equivalent to lib/client.js's).
|
|
615
|
+
if (body.length === 3) body = body[0] + body[0] + body[1] + body[1] + body[2] + body[2];
|
|
616
|
+
const n = parseInt(body, 16);
|
|
617
|
+
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function rgbToHex(r, g, b) {
|
|
621
|
+
return "#" + [r, g, b].map((v) => {
|
|
622
|
+
const c = Math.max(0, Math.min(255, Math.round(v)));
|
|
623
|
+
return ("0" + c.toString(16)).slice(-2);
|
|
624
|
+
}).join("");
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** Linear blend between two hex colors (mirrors the browser's `mix()`). */
|
|
628
|
+
function mixHex(a, b, t) {
|
|
629
|
+
const ca = hexToRgb(a);
|
|
630
|
+
const cb = hexToRgb(b);
|
|
631
|
+
return rgbToHex(ca[0] + (cb[0] - ca[0]) * t, ca[1] + (cb[1] - ca[1]) * t, ca[2] + (cb[2] - ca[2]) * t);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** CIELAB (D65) for an sRGB hex color. */
|
|
635
|
+
function labOf(hex) {
|
|
636
|
+
const rgb = hexToRgb(hex);
|
|
637
|
+
const lin = (v) => {
|
|
638
|
+
const c = v / 255;
|
|
639
|
+
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
|
640
|
+
};
|
|
641
|
+
const r = lin(rgb[0]);
|
|
642
|
+
const g = lin(rgb[1]);
|
|
643
|
+
const b = lin(rgb[2]);
|
|
644
|
+
const x = (0.4124564 * r + 0.3575761 * g + 0.1804375 * b) / 0.95047;
|
|
645
|
+
const y = 0.2126729 * r + 0.7151522 * g + 0.072175 * b;
|
|
646
|
+
const z = (0.0193339 * r + 0.119192 * g + 0.9503041 * b) / 1.08883;
|
|
647
|
+
const f = (t) => (t > 216 / 24389 ? Math.cbrt(t) : (841 / 108) * t + 4 / 29);
|
|
648
|
+
const fx = f(x);
|
|
649
|
+
const fy = f(y);
|
|
650
|
+
const fz = f(z);
|
|
651
|
+
return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** CIE76 ΔE (Euclidean distance in CIELAB). */
|
|
655
|
+
function deltaE(a, b) {
|
|
656
|
+
const la = labOf(a);
|
|
657
|
+
const lb = labOf(b);
|
|
658
|
+
return Math.sqrt(
|
|
659
|
+
Math.pow(la[0] - lb[0], 2) + Math.pow(la[1] - lb[1], 2) + Math.pow(la[2] - lb[2], 2)
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function labChroma(hex) {
|
|
664
|
+
const l = labOf(hex);
|
|
665
|
+
return Math.sqrt(l[1] * l[1] + l[2] * l[2]);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* The fills a state actually paints, mirroring the browser's `frameColor()`:
|
|
670
|
+
* `blink` toggles colors[0] ⇄ colors[1] (a darker second color is derived when
|
|
671
|
+
* colors[1] is omitted), `breath` interpolates between them, everything else
|
|
672
|
+
* uses colors[0]. `rainbow` is flagged instead — it sweeps every hue.
|
|
673
|
+
*/
|
|
674
|
+
function stateFills(state) {
|
|
675
|
+
const cols = Array.isArray(state?.colors) ? state.colors : [];
|
|
676
|
+
const effect = state?.effect || "static";
|
|
677
|
+
const c0 = normalizeHex(cols[0] || "");
|
|
678
|
+
const rainbow = effect === "rainbow";
|
|
679
|
+
// A rainbow state never paints its own colors[0] literally (that value is
|
|
680
|
+
// only the starting hue — the sweep covers the whole wheel), so it has no
|
|
681
|
+
// comparable fills: the chroma rule in colorWarnings() covers it instead.
|
|
682
|
+
if (!c0 || rainbow) return { fills: [], rainbow };
|
|
683
|
+
const c1 = normalizeHex(cols[1] || "") || mixHex(c0, "#000000", 0.35);
|
|
684
|
+
if (effect === "blink") return { fills: [c0, c1], rainbow: false };
|
|
685
|
+
if (effect === "breath") {
|
|
686
|
+
// The browser paints the CONTINUOUS mix, so a sparse sample can only ever
|
|
687
|
+
// over-estimate the distance: a blue->green breath was reported ΔE 37 away
|
|
688
|
+
// from a default colour it actually passes through (ΔE 0). 33 samples keep
|
|
689
|
+
// the worst reported-vs-true gap around 5 ΔE (see lib/client.js's copy).
|
|
690
|
+
const fills = [];
|
|
691
|
+
for (let i = 0; i <= 32; i += 1) fills.push(mixHex(c0, c1, i / 32));
|
|
692
|
+
return { fills, rainbow: false };
|
|
693
|
+
}
|
|
694
|
+
return { fills: [c0], rainbow };
|
|
695
|
+
}
|
|
696
|
+
|
|
456
697
|
/**
|
|
457
|
-
*
|
|
458
|
-
*
|
|
698
|
+
* Similarity warnings for the effective default color (the idle primary).
|
|
699
|
+
* Only the default is compared against the other states — NOT the states
|
|
700
|
+
* against each other: the shipped defaults deliberately share #FACC15 between
|
|
701
|
+
* `running` and the `asking` blink, so a pairwise check would warn forever.
|
|
702
|
+
*/
|
|
703
|
+
function colorWarnings(states) {
|
|
704
|
+
const warnings = [];
|
|
705
|
+
const base = normalizeHex(states?.idle?.colors?.[0] || "");
|
|
706
|
+
if (!base) return warnings;
|
|
707
|
+
// Idle itself on `rainbow` (composition entry / hand-written settings.yaml —
|
|
708
|
+
// the card never exposes it): the idle icon sweeps every hue, so the default
|
|
709
|
+
// colour can never stay distinguishable from it. One advisory replaces the
|
|
710
|
+
// pairwise pass, which would judge a colour idle never paints literally.
|
|
711
|
+
if ((states?.idle?.effect || "static") === "rainbow") {
|
|
712
|
+
if (labChroma(base) >= RAINBOW_CHROMA_MIN) {
|
|
713
|
+
warnings.push({ code: "rainbow-overlap", state: "idle", base, level: "warn" });
|
|
714
|
+
}
|
|
715
|
+
return warnings;
|
|
716
|
+
}
|
|
717
|
+
for (const name of OTHER_STATES) {
|
|
718
|
+
const { fills, rainbow } = stateFills(states?.[name]);
|
|
719
|
+
if (rainbow && labChroma(base) >= RAINBOW_CHROMA_MIN) {
|
|
720
|
+
warnings.push({ code: "rainbow-overlap", state: name, base, level: "warn" });
|
|
721
|
+
}
|
|
722
|
+
let closest = null;
|
|
723
|
+
for (const fill of fills) {
|
|
724
|
+
const de = deltaE(base, fill);
|
|
725
|
+
if (closest === null || de < closest.deltaE) closest = { color: fill, deltaE: de };
|
|
726
|
+
}
|
|
727
|
+
if (closest && closest.deltaE < SIMILAR_DE_WARN) {
|
|
728
|
+
warnings.push({
|
|
729
|
+
code: "color-too-close",
|
|
730
|
+
state: name,
|
|
731
|
+
base,
|
|
732
|
+
color: closest.color,
|
|
733
|
+
deltaE: Math.round(closest.deltaE * 10) / 10,
|
|
734
|
+
level: closest.deltaE < SIMILAR_DE_STRONG ? "strong" : "warn",
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
return warnings;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Cordis plugin entry. Returns the standard `{ apply, inject, Config }`
|
|
743
|
+
* shape so the host composition can mount it once at startup. `Config` (the
|
|
744
|
+
* schemastery schema) is what the loader validates the row against and what
|
|
745
|
+
* the host settings service projects into its live form — a plugin without it
|
|
746
|
+
* has no server-declared configuration surface at all. One bundle serves both
|
|
747
|
+
* host lines: the legacy `settings.installSection` path is feature-detected at
|
|
748
|
+
* runtime (see `apply`).
|
|
459
749
|
*/
|
|
460
750
|
export default {
|
|
461
751
|
name: "dsh-web-icon-indicator",
|
|
462
|
-
inject: ["webServer", "timer", "agents", "fs"
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
doneHoldMs: DEFAULTS.doneHoldMs,
|
|
466
|
-
statusPath: DEFAULTS.statusPath,
|
|
467
|
-
iconPathPrefix: DEFAULTS.iconPathPrefix,
|
|
468
|
-
iconsDir: undefined,
|
|
469
|
-
states: DEFAULTS.states,
|
|
470
|
-
},
|
|
471
|
-
/** Settings namespace + validation schema (exported for reuse/tooling). */
|
|
752
|
+
inject: ["webServer", "timer", "agents", "fs"],
|
|
753
|
+
Config: CONFIG_SCHEMA,
|
|
754
|
+
/** Settings namespace (the profile entry id on ≥0.1.7) + schema, for tooling. */
|
|
472
755
|
SETTINGS_NAMESPACE,
|
|
756
|
+
/** Settings namespace used on the legacy (≤0.1.6-alpha.1) settings service. */
|
|
757
|
+
LEGACY_SETTINGS_NAMESPACE,
|
|
473
758
|
CONFIG_SCHEMA,
|
|
474
759
|
apply(ctx, config) {
|
|
475
|
-
// Cordis passes the resolved
|
|
476
|
-
// (`apply(ctx, config)`). `
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
//
|
|
480
|
-
//
|
|
481
|
-
//
|
|
482
|
-
|
|
483
|
-
|
|
760
|
+
// Cordis passes the schema-resolved config as the second argument
|
|
761
|
+
// (`apply(ctx, config)`). Every `.volatile()` field arrives as a live
|
|
762
|
+
// reference (`config.states.get()`), and the loader mutates those
|
|
763
|
+
// references in place when a settings write lands — that is what
|
|
764
|
+
// `loader/volatile-update` below reacts to. Unwrap the references once per
|
|
765
|
+
// read so the rest of this file keeps working on plain values. The
|
|
766
|
+
// `ctx.get("config")` fallback keeps the zero-dependency test harness (and
|
|
767
|
+
// any host that hands the row config through the service container) working.
|
|
768
|
+
const readRef = (value) =>
|
|
769
|
+
value !== null && typeof value === "object" && typeof value.get === "function" ? value.get() : value;
|
|
770
|
+
const readConfig = (raw) => {
|
|
771
|
+
const out = {};
|
|
772
|
+
for (const [key, value] of Object.entries(raw || {})) {
|
|
773
|
+
const plain = readRef(value);
|
|
774
|
+
if (plain !== undefined) out[key] = plain;
|
|
775
|
+
}
|
|
776
|
+
return out;
|
|
777
|
+
};
|
|
778
|
+
// Legacy host line (DSH ≤ 0.1.6-alpha.1): `settings.installSection` owns the
|
|
779
|
+
// namespace lifecycle and hands this plugin a live source through
|
|
780
|
+
// `setSource`. Until it does, the composition config passed to `apply` (or
|
|
781
|
+
// the zero-dep test harness's `ctx.get("config")`) is the source.
|
|
782
|
+
let legacySource = null;
|
|
783
|
+
const source = () =>
|
|
784
|
+
readConfig(legacySource ? legacySource() : config ?? ctx.get("config") ?? {});
|
|
484
785
|
// Merge each configured state over its default: { effect, colors, speed }.
|
|
485
786
|
// Idempotent on schema-resolved values, so it doubles as the fallback
|
|
486
787
|
// normalization when the profile composes no settings service.
|
|
487
788
|
const resolveConfig = (raw) => {
|
|
488
789
|
const stateConfigs = {};
|
|
790
|
+
const warnings = [];
|
|
489
791
|
for (const name of STATE_NAMES) {
|
|
490
792
|
const merged = { ...(DEFAULTS.states[name] || {}), ...(raw.states?.[name] || {}) };
|
|
491
793
|
// Guard against unknown effect names: fall back to "static" so the
|
|
@@ -496,59 +798,143 @@ export default {
|
|
|
496
798
|
let cols = merged.colors;
|
|
497
799
|
if (typeof cols === "string") cols = [cols];
|
|
498
800
|
if (!Array.isArray(cols)) cols = [];
|
|
499
|
-
|
|
801
|
+
// Strict `#rgb` / `#rrggbb` only — the same rule normalizeHex() and the
|
|
802
|
+
// card use. The old lenient 3-6 digit test let a "#1234" through and
|
|
803
|
+
// parseInt() painted a bogus colour from it, which the card (correctly)
|
|
804
|
+
// refused to reason about.
|
|
805
|
+
cols = cols.filter((c) => typeof c === "string" && DEFAULT_COLOR_RX.test(c.trim()));
|
|
500
806
|
merged.colors = cols.length ? cols : [...(DEFAULTS.states[name]?.colors || ["#1a1a1a"])];
|
|
501
807
|
// speed: must be a positive number; otherwise use the default (1200).
|
|
502
808
|
if (typeof merged.speed !== "number" || !(merged.speed > 0)) delete merged.speed;
|
|
503
809
|
stateConfigs[name] = merged;
|
|
504
810
|
}
|
|
505
|
-
|
|
811
|
+
// `undefined` values must not shadow a DEFAULTS entry: the plugin's own
|
|
812
|
+
// static `config` row carries `iconsDir: undefined` by design, and a
|
|
813
|
+
// schema-resolved settings value may omit optional keys entirely. Only
|
|
814
|
+
// keys the caller actually set may override a default.
|
|
815
|
+
const defined = {};
|
|
816
|
+
for (const [key, value] of Object.entries(raw || {})) {
|
|
817
|
+
if (value !== undefined) defined[key] = value;
|
|
818
|
+
}
|
|
819
|
+
const resolved = { ...DEFAULTS, ...defined, states: stateConfigs };
|
|
820
|
+
// `defaultColor` is sugar for the idle state's PRIMARY color. Fold it into
|
|
821
|
+
// `states.idle.colors[0]` here instead of teaching the browser a second
|
|
822
|
+
// rendering path: the injected script, the baked `__CFG__` payload and
|
|
823
|
+
// even an already-baked older bundle therefore keep working unchanged,
|
|
824
|
+
// and the live settings sync (`cfg.states = next.states`) carries it for
|
|
825
|
+
// free. A malformed value is dropped with a warning, never painted.
|
|
826
|
+
delete resolved.defaultColor;
|
|
827
|
+
const requestedDefault = typeof defined.defaultColor === "string"
|
|
828
|
+
? defined.defaultColor.trim()
|
|
829
|
+
: defined.defaultColor;
|
|
830
|
+
// "" / whitespace written by hand into the composition entry means "not
|
|
831
|
+
// set" — not a malformed color to warn about. Neither does an empty YAML
|
|
832
|
+
// value, which parses as null (`defaultColor:`).
|
|
833
|
+
if (requestedDefault != null && requestedDefault !== "") {
|
|
834
|
+
const normalized = normalizeHex(requestedDefault);
|
|
835
|
+
if (normalized) {
|
|
836
|
+
const idle = stateConfigs.idle;
|
|
837
|
+
stateConfigs.idle = { ...idle, colors: [normalized, ...(idle.colors || []).slice(1)] };
|
|
838
|
+
resolved.defaultColor = normalized;
|
|
839
|
+
} else {
|
|
840
|
+
warnings.push({ code: "invalid-color", value: String(requestedDefault) });
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
warnings.push(...colorWarnings(stateConfigs));
|
|
844
|
+
resolved.warnings = warnings;
|
|
845
|
+
return resolved;
|
|
846
|
+
};
|
|
847
|
+
// Surface the similarity / validity warnings on the host as well: the
|
|
848
|
+
// settings card computes its own live copy, while this covers configs that
|
|
849
|
+
// arrive through the composition entry (a hand-written cordis.yml) and
|
|
850
|
+
// leaves a server-side record. `ctx.logger` is cordis' core logging
|
|
851
|
+
// service — guard it, since the zero-dep test harness drives `apply()`
|
|
852
|
+
// with a ctx that has none.
|
|
853
|
+
const logWarnings = (list) => {
|
|
854
|
+
if (!Array.isArray(list) || list.length === 0) return;
|
|
855
|
+
try {
|
|
856
|
+
if (typeof ctx.logger !== "function") return;
|
|
857
|
+
const logger = ctx.logger("dsh-web-icon-indicator");
|
|
858
|
+
for (const w of list) {
|
|
859
|
+
const text = w.code === "invalid-color"
|
|
860
|
+
? `defaultColor ${JSON.stringify(w.value)} is not a 3- or 6-digit hex color — ignored`
|
|
861
|
+
: w.code === "rainbow-overlap"
|
|
862
|
+
? `defaultColor ${w.base} is too close to the "${w.state}" state: its rainbow effect sweeps every hue`
|
|
863
|
+
: `defaultColor ${w.base} is ${w.level === "strong" ? "nearly identical to" : "too close to"} the "${w.state}" state color ${w.color} (ΔE ${w.deltaE})`;
|
|
864
|
+
if (logger && typeof logger.warn === "function") logger.warn(text);
|
|
865
|
+
}
|
|
866
|
+
} catch (e) {}
|
|
506
867
|
};
|
|
507
|
-
let cfg = resolveConfig(
|
|
868
|
+
let cfg = resolveConfig(source());
|
|
869
|
+
logWarnings(cfg.warnings);
|
|
508
870
|
// The injected script bakes config at injection time; rebuild it whenever
|
|
509
|
-
// the
|
|
510
|
-
//
|
|
871
|
+
// the live config changes so the NEXT page load picks the new values up
|
|
872
|
+
// (reload the tab — the running tab is already carried by the status poll's
|
|
873
|
+
// `states` echo).
|
|
511
874
|
const buildScript = (c) => INJECTED_SCRIPT
|
|
512
875
|
.replace("__STATUS_PATH__", c.statusPath)
|
|
513
876
|
.replace("__BASE_PATH__", c.iconPathPrefix + "/base.svg")
|
|
514
877
|
.replace("__CFG__", JSON.stringify({ states: c.states }));
|
|
515
878
|
let script = buildScript(cfg);
|
|
516
879
|
let iconsDir = resolveIconsDir(cfg.iconsDir);
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
//
|
|
520
|
-
// schema
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
880
|
+
// Live settings, modern line (DSH ≥ 0.1.7). The host owns the whole
|
|
881
|
+
// configuration lifecycle: the loader validates the composition row against
|
|
882
|
+
// `Config`, the `settings` service (`SettingsForms`) derives the live form
|
|
883
|
+
// from the schema's `.volatile()` fields and writes them into the profile
|
|
884
|
+
// patch, and a volatile-only write is committed into the running fiber's
|
|
885
|
+
// references with a `loader/volatile-update` event instead of a restart.
|
|
886
|
+
// This listener rebuilds the cached `cfg` and the injected script from the
|
|
887
|
+
// same references; the legacy line below drives it through `onChange`
|
|
888
|
+
// instead. `statusPath` / `iconPathPrefix` are NOT volatile: they are baked
|
|
889
|
+
// into the route table and the injected script at registration time, so a
|
|
890
|
+
// document change to them re-applies the whole plugin instead (they stay
|
|
891
|
+
// outside the live form — see CONFIG_SCHEMA).
|
|
892
|
+
const refreshFromConfig = () => {
|
|
893
|
+
const next = resolveConfig(source());
|
|
894
|
+
cfg.askingHoldMs = next.askingHoldMs;
|
|
895
|
+
cfg.doneHoldMs = next.doneHoldMs;
|
|
896
|
+
if (next.iconsDir !== undefined) cfg.iconsDir = next.iconsDir;
|
|
897
|
+
cfg.states = next.states;
|
|
898
|
+
// `defaultColor` / `warnings` ride along with `states`: the color is
|
|
899
|
+
// already folded into `states.idle.colors[0]`, so the browser needs no
|
|
900
|
+
// new key — they are kept for observability (status echo + log).
|
|
901
|
+
cfg.defaultColor = next.defaultColor;
|
|
902
|
+
cfg.warnings = next.warnings;
|
|
903
|
+
iconsDir = resolveIconsDir(cfg.iconsDir);
|
|
904
|
+
script = buildScript(cfg);
|
|
905
|
+
logWarnings(cfg.warnings);
|
|
906
|
+
};
|
|
907
|
+
// A plain `ctx.on` listener is enough: the loader emits this event on the
|
|
908
|
+
// owning fiber's context only, so a volatile write to another plugin's
|
|
909
|
+
// config never reaches here. Guard for the zero-dep test harness, whose
|
|
910
|
+
// fake ctx carries no event bus.
|
|
911
|
+
if (typeof ctx.on === "function") ctx.on("loader/volatile-update", refreshFromConfig);
|
|
912
|
+
// Legacy host line (DSH ≤ 0.1.6-alpha.1): the `settings` service exposes
|
|
913
|
+
// `installSection`, and the plugin must register its namespace itself —
|
|
914
|
+
// that service method was removed in 0.1.7, where the exported `Config`
|
|
915
|
+
// schema plus `loader/volatile-update` carry the same lifecycle. Feature-
|
|
916
|
+
// detect instead of switching on a version: whichever service the running
|
|
917
|
+
// host provides decides the path, so one bundle serves both lines.
|
|
918
|
+
// The base layer is a RESOLVED PLAIN COPY (`resolveConfig(source())`), not
|
|
919
|
+
// the raw `apply` config: the legacy loader deep-freezes the config it
|
|
920
|
+
// resolved against the exported `Config` schema, and the legacy settings
|
|
921
|
+
// service resolves the schema over the base IN PLACE (its `mergeLayers`
|
|
922
|
+
// returns the base unchanged when no user section exists) — handing it a
|
|
923
|
+
// frozen object throws "Cannot assign to read only property".
|
|
924
|
+
if (typeof ctx.inject === "function") {
|
|
925
|
+
ctx.inject(["settings"], (settingsCtx) => {
|
|
926
|
+
const settings = settingsCtx.settings;
|
|
927
|
+
if (!settings || typeof settings.installSection !== "function") return;
|
|
928
|
+
settings.installSection(ctx, LEGACY_SETTINGS_NAMESPACE, CONFIG_SCHEMA, resolveConfig(source()), {
|
|
929
|
+
setSource: (next) => { legacySource = next; },
|
|
930
|
+
onChange: refreshFromConfig,
|
|
931
|
+
});
|
|
544
932
|
});
|
|
545
|
-
}
|
|
933
|
+
}
|
|
546
934
|
const webServer = ctx.webServer;
|
|
547
935
|
const agents = ctx.agents;
|
|
548
936
|
const fs = ctx.fs;
|
|
549
|
-
const sp = ctx.sandboxPolicy;
|
|
550
937
|
const lastSeen = new Map();
|
|
551
|
-
void sp; // sandboxPolicy injection is currently unused — reserved for future file-access policy
|
|
552
938
|
|
|
553
939
|
// -- State machine -------------------------------------------------------
|
|
554
940
|
const states = new Map(); // agentId -> { state, since }
|
|
@@ -620,31 +1006,46 @@ export default {
|
|
|
620
1006
|
// leaves `running`, so a lost `tools/result` cannot pin `asking` forever.
|
|
621
1007
|
const scheduleAskCheck = (id) => {
|
|
622
1008
|
const prevHandle = askTimers.get(id);
|
|
623
|
-
|
|
1009
|
+
// Never cancel a handle whose callback is executing right now: it is the
|
|
1010
|
+
// very call re-arming the pin, and its `finally` would then delete the
|
|
1011
|
+
// fresh handle and leave the session pinned to `asking` with no timer to
|
|
1012
|
+
// release it. A stale (non-running) handle is still cancelled, so a
|
|
1013
|
+
// repeated pre-execute cannot stack two timers.
|
|
1014
|
+
if (prevHandle && !prevHandle.running()) {
|
|
1015
|
+
try { prevHandle(); } catch (e) {}
|
|
1016
|
+
askTimers.delete(id);
|
|
1017
|
+
}
|
|
1018
|
+
let running = false;
|
|
624
1019
|
const handle = ctx.timer.timeout(() => {
|
|
1020
|
+
running = true;
|
|
625
1021
|
askTimers.delete(id);
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
1022
|
+
try {
|
|
1023
|
+
if (!asking.has(id)) return;
|
|
1024
|
+
if (!askDone.has(id)) {
|
|
1025
|
+
const live = agents.get(id);
|
|
1026
|
+
// Result not yet reported AND the agent is still mid-turn: the user
|
|
1027
|
+
// may still be deciding — keep the pin (no re-arm budget; a human
|
|
1028
|
+
// question can legitimately outlast many hold windows).
|
|
1029
|
+
if (live && live.status === "running") { scheduleAskCheck(id); return; }
|
|
1030
|
+
// Result never arrived and the turn ended (agent idle / gone): a lost
|
|
1031
|
+
// tools/result cannot keep the icon blinking forever — force-release
|
|
1032
|
+
// the pin against live status.
|
|
1033
|
+
asking.delete(id);
|
|
1034
|
+
askDone.delete(id);
|
|
1035
|
+
if (live) setState(id, live.status === "running" ? "running" : "idle");
|
|
1036
|
+
else setState(id, "idle");
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
636
1039
|
asking.delete(id);
|
|
637
1040
|
askDone.delete(id);
|
|
1041
|
+
const live = agents.get(id);
|
|
638
1042
|
if (live) setState(id, live.status === "running" ? "running" : "idle");
|
|
639
1043
|
else setState(id, "idle");
|
|
640
|
-
|
|
1044
|
+
} finally {
|
|
1045
|
+
running = false;
|
|
641
1046
|
}
|
|
642
|
-
asking.delete(id);
|
|
643
|
-
askDone.delete(id);
|
|
644
|
-
const live = agents.get(id);
|
|
645
|
-
if (live) setState(id, live.status === "running" ? "running" : "idle");
|
|
646
|
-
else setState(id, "idle");
|
|
647
1047
|
}, cfg.askingHoldMs);
|
|
1048
|
+
handle.running = () => running;
|
|
648
1049
|
askTimers.set(id, handle);
|
|
649
1050
|
};
|
|
650
1051
|
|
|
@@ -696,6 +1097,7 @@ export default {
|
|
|
696
1097
|
asking.delete(id);
|
|
697
1098
|
askDone.delete(id);
|
|
698
1099
|
pendingApprovals.delete(id);
|
|
1100
|
+
approvalCursor.delete(id);
|
|
699
1101
|
lastSeen.delete(id);
|
|
700
1102
|
cancelDoneHold(id);
|
|
701
1103
|
const t = askTimers.get(id);
|
|
@@ -707,17 +1109,34 @@ export default {
|
|
|
707
1109
|
// 0.1.2-alpha.4 `Session.events` is gone — read the log on demand through
|
|
708
1110
|
// `session.snapshotEvents()` (a frozen half-open range snapshot). Guard on
|
|
709
1111
|
// the method so older/newer hosts degrade gracefully.
|
|
1112
|
+
//
|
|
1113
|
+
// The fold is INCREMENTAL: `snapshotEvents()` deep-freezes every event it
|
|
1114
|
+
// returns, and reconcile runs on every 1 s status poll, so re-reading the
|
|
1115
|
+
// whole log per poll would clone the entire history each second. A per-agent
|
|
1116
|
+
// cursor keeps only the delta (the log is append-only, so the open-approval
|
|
1117
|
+
// set is a pure fold over the events seen so far).
|
|
1118
|
+
const approvalCursor = new Map(); // agentId -> { seq, open: Set<approvalId> }
|
|
710
1119
|
const hasPendingApproval = (live) => {
|
|
711
1120
|
const session = live?.session;
|
|
712
1121
|
if (!session || typeof session.snapshotEvents !== "function") return false;
|
|
713
|
-
const
|
|
714
|
-
|
|
715
|
-
|
|
1122
|
+
const id = live?.id ?? null;
|
|
1123
|
+
const cursor = (id != null && approvalCursor.get(id)) || { seq: 0, open: new Set() };
|
|
1124
|
+
let events;
|
|
1125
|
+
try {
|
|
1126
|
+
events = session.snapshotEvents(cursor.seq);
|
|
1127
|
+
} catch (e) {
|
|
1128
|
+
return cursor.open.size > 0; // keep the last good fold on a read failure
|
|
1129
|
+
}
|
|
1130
|
+
if (!Array.isArray(events)) return cursor.open.size > 0;
|
|
1131
|
+
let seq = cursor.seq;
|
|
716
1132
|
for (const ev of events) {
|
|
717
|
-
if (ev
|
|
718
|
-
|
|
1133
|
+
if (typeof ev?.seq === "number" && ev.seq >= seq) seq = ev.seq + 1;
|
|
1134
|
+
if (ev?.type === "approval/asked") cursor.open.add(ev.data?.id);
|
|
1135
|
+
else if (ev?.type === "approval/decided") cursor.open.delete(ev.data?.id);
|
|
719
1136
|
}
|
|
720
|
-
|
|
1137
|
+
cursor.seq = seq;
|
|
1138
|
+
if (id != null) approvalCursor.set(id, cursor);
|
|
1139
|
+
return cursor.open.size > 0;
|
|
721
1140
|
};
|
|
722
1141
|
|
|
723
1142
|
// Reconcile running->idle transitions on every status request.
|
|
@@ -789,7 +1208,16 @@ export default {
|
|
|
789
1208
|
res.statusCode = 200;
|
|
790
1209
|
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
791
1210
|
res.setHeader("Cache-Control", "no-store");
|
|
792
|
-
|
|
1211
|
+
// `defaultColor` + `warnings` are additive: older browser bundles ignore
|
|
1212
|
+
// unknown keys, and the live config sync only reads `states`.
|
|
1213
|
+
// `defaultColor` is echoed as null when none is configured so the
|
|
1214
|
+
// documented shape has the key unconditionally.
|
|
1215
|
+
res.end(JSON.stringify({
|
|
1216
|
+
...aggregate(),
|
|
1217
|
+
states: cfg.states,
|
|
1218
|
+
defaultColor: cfg.defaultColor ?? null,
|
|
1219
|
+
warnings: cfg.warnings || [],
|
|
1220
|
+
}));
|
|
793
1221
|
},
|
|
794
1222
|
}));
|
|
795
1223
|
|