dsh-web-icon-indicator 0.2.2 → 0.3.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/CHANGELOG.md ADDED
@@ -0,0 +1,67 @@
1
+ # Changelog
2
+
3
+ All notable changes to **dsh-web-icon-indicator** are documented here.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.3.0](https://github.com/waknow/dsh-web-icon-indicator/compare/v0.2.3...v0.3.0) (2026-08-31)
11
+
12
+ ### Added
13
+
14
+ * show full-frame active count while multiple agents run ([d287620](https://github.com/waknow/dsh-web-icon-indicator/commit/d287620a6d66a6aa0b4ce8580adad77908ecdaab))
15
+
16
+ ### Changed
17
+
18
+ * add npm version/downloads/license badges to both READMEs ([e3dd298](https://github.com/waknow/dsh-web-icon-indicator/commit/e3dd29806413ea5f33dd36077caa340c24e8c70c))
19
+ * document automated release flow with commit-and-tag-version ([1754216](https://github.com/waknow/dsh-web-icon-indicator/commit/1754216c32003a722f619935ebf0ab1a402887f1))
20
+
21
+ ## [0.2.3](https://github.com/waknow/dsh-web-icon-indicator/compare/v0.2.2...v0.2.3) (2026-08-24)
22
+
23
+ ### Changed
24
+
25
+ * add CHANGELOG.md with release history; link it from READMEs ([262730c](https://github.com/waknow/dsh-web-icon-indicator/commit/262730c83449b36fa60442204b3362d8a8b43e8f))
26
+
27
+ ## [0.2.2] - 2026-08-24
28
+
29
+ ### Changed
30
+
31
+ - Added the [awesome · DSH plugin](https://awesome-dsh-plugin.com/badge.svg) listing badge to `README.md` and `README.zh.md` now that the plugin is featured in [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) ([9042fb3](https://github.com/waknow/dsh-web-icon-indicator/commit/9042fb3)).
32
+ - npm publishing is now fully automated on `v*` tags via GitHub Actions (OIDC trusted publishing, no token secrets) — verified end-to-end with the `0.2.2` release.
33
+
34
+ ## [0.2.1] - 2026-08-21
35
+
36
+ ### Added
37
+
38
+ - Settings card UX polish and favicon render robustness ([e584912](https://github.com/waknow/dsh-web-icon-indicator/commit/e584912)).
39
+ - Documentation: visualize the default config and all effects in the READMEs; settings card shows a cycle field only for animated states and swatches as native color pickers; Safari favicon dynamic-color feasibility research with browser support tables ([23da09e](https://github.com/waknow/dsh-web-icon-indicator/commit/23da09e), [d99546d](https://github.com/waknow/dsh-web-icon-indicator/commit/d99546d), [04a8e58](https://github.com/waknow/dsh-web-icon-indicator/commit/04a8e58)).
40
+ - CI: publish to npm from GitHub Actions on version tags; verify the tag matches `package.json` before publishing; switch `@deepseek-ai` deps to `peerDependencies` ([b96e90e](https://github.com/waknow/dsh-web-icon-indicator/commit/b96e90e), [61fc3bc](https://github.com/waknow/dsh-web-icon-indicator/commit/61fc3bc), [23da09e](https://github.com/waknow/dsh-web-icon-indicator/commit/23da09e)).
41
+
42
+ ### Fixed
43
+
44
+ - Keep the heartbeat/bounce whale centered when the CSS animation runs ([4a379aa](https://github.com/waknow/dsh-web-icon-indicator/commit/4a379aa)).
45
+
46
+ ## [0.2.0] - 2026-08-20
47
+
48
+ ### Added
49
+
50
+ - Full per-state visual configuration: `effect` / `colors[]` / `speed` per state, plus `askingHoldMs` / `doneHoldMs` timings ([dca6de3](https://github.com/waknow/dsh-web-icon-indicator/commit/dca6de3)).
51
+ - DSH settings service integration (`web-icon-indicator` namespace) with a settings-page card — validate, persist to `settings.yaml`, and apply live to the running tab ([7cf6962](https://github.com/waknow/dsh-web-icon-indicator/commit/7cf6962)).
52
+ - Sandbox-interception and approval waits surfaced as the `asking` state ([1f8ebba](https://github.com/waknow/dsh-web-icon-indicator/commit/1f8ebba)).
53
+ - Background-tab animation fallback: wall-clock frames for animated states while `requestAnimationFrame` is paused in hidden tabs ([4abc9b2](https://github.com/waknow/dsh-web-icon-indicator/commit/4abc9b2)).
54
+ - Polling resilience: a transient fetch failure restores the original icon and retries on the next tick instead of killing the poll ([d7b4188](https://github.com/waknow/dsh-web-icon-indicator/commit/d7b4188)).
55
+
56
+ ### Changed
57
+
58
+ - Replaced the individual per-state SVG icons with a single [base.svg](icons/base.svg) whale template, recolored and animated entirely in the browser as `data:image/svg+xml` URIs ([0a76d0b](https://github.com/waknow/dsh-web-icon-indicator/commit/0a76d0b)).
59
+ - Browser half rewritten to use shell UI primitives for the settings card ([971f3b6](https://github.com/waknow/dsh-web-icon-indicator/commit/971f3b6)).
60
+ - Added live SVG previews of every state and effect to the READMEs ([a1f67ce](https://github.com/waknow/dsh-web-icon-indicator/commit/a1f67ce)).
61
+
62
+ ## [0.1.0] - 2026-08-19
63
+
64
+ ### Added
65
+
66
+ - Initial release: browser tab favicon mirrors the DSH session state (`idle` / `running` / `asking` / `done`) with a single-color whale icon ([c8b66cb](https://github.com/waknow/dsh-web-icon-indicator/commit/c8b66cb)).
67
+ - Repository metadata and install-from-source instructions ([69f550c](https://github.com/waknow/dsh-web-icon-indicator/commit/69f550c)).
package/README.md CHANGED
@@ -1,8 +1,11 @@
1
1
  # dsh-web-icon-indicator
2
2
 
3
- > 📖 [中文文档](README.zh.md) · [English](README.md)
3
+ > 📖 [中文文档](README.zh.md) · [English](README.md) · 📝 [Changelog](CHANGELOG.md) · [Releases](https://github.com/waknow/dsh-web-icon-indicator/releases)
4
4
 
5
5
  [![awesome · DSH plugin](https://awesome-dsh-plugin.com/badge.svg)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
6
+ [![npm version](https://img.shields.io/npm/v/dsh-web-icon-indicator)](https://www.npmjs.com/package/dsh-web-icon-indicator)
7
+ [![npm downloads](https://img.shields.io/npm/dm/dsh-web-icon-indicator)](https://www.npmjs.com/package/dsh-web-icon-indicator)
8
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
6
9
 
7
10
  Browser tab favicon reflects the current DSH session state — `idle` / `running` / `asking` / `done` — so you can see at a glance whether a session needs your attention, even when the tab is in the background.
8
11
 
@@ -14,6 +17,7 @@ Browser tab favicon reflects the current DSH session state — `idle` / `running
14
17
  - **Fully configurable, applied live** — every state's color, effect and cycle speed, plus the asking/done hold timings, apply to the running tab within ~1 s — no reload, no restart.
15
18
  - **Built-in settings UI, zero YAML** — a *Favicon indicator* card in the DSH settings page edits the whole config with live color-swatch previews and persists it to `settings.yaml` for you (path below).
16
19
  - **Background-tab & restart-proof** — animated states keep a wall-clock fallback while `requestAnimationFrame` is paused in hidden tabs, and the status poll self-heals across host restarts.
20
+ - **Active-agent count at a glance** — while **more than one** agent is active (non-idle: `asking` / `running` / `done`), the favicon switches from the whale to a **full-frame number block** showing the live count (up to `99+`), colored and animated exactly like the whale would be in that state; back to the whale when 0–1 agents are active. (Same visual language as the *满幅数字* channel in [`demo/badge.html`](./demo/badge.html).)
17
21
 
18
22
  ### 🛠 Configuration UI — how to get there
19
23
 
@@ -151,7 +155,7 @@ service under the `web-icon-indicator` namespace (a schemastery schema in
151
155
  ## How it works
152
156
 
153
157
  - Host plugin with a small browser half: registers routes on the existing `webServer` — the status JSON endpoint, a static `/dsh-web-icon-indicator/base.svg` (the whale template), and one `tapIndex` that injects a small browser script into every served `index.html`. The config surface is registered with the DSH settings service (`web-icon-indicator` namespace) for validation, persistence, and the settings-page card (see above).
154
- - Status is aggregated across live `agents.list()` with priority `asking > running > done > idle`. The aggregation runs a `reconcile()` step on every request to detect running → idle transitions, because `agent/status`'s idle delivery is not guaranteed at turn end.
158
+ - Status is aggregated across live `agents.list()` with priority `asking > running > done > idle`. The aggregation runs a `reconcile()` step on every request to detect running → idle transitions, because `agent/status`'s idle delivery is not guaranteed at turn end. The status endpoint also reports `active` — the number of non-idle agents — and while that count is **> 1** the injected script renders a full-frame count block (the *满幅数字* channel of [`demo/badge.html`](./demo/badge.html): a rounded block filled with the same per-frame state color/effect as the whale, bold white count sized 31%–52% of the icon, capped at `99+`) instead of the whale, so the tab shows how many agents are busy at once even in a pinned 16px tab.
155
159
  - `ask_user_question` tool calls (via `tools/pre-execute` / `tools/result`) flip the session into `asking` with a configurable minimum-hold so the icon stays visible even when the user answers immediately.
156
160
  - Permission / **sandbox-interception** waits are also surfaced as `asking`: when the agent hits a sandbox denial and escalates (`sandbox_permissions` + `justification`), or any other tool asks for approval, the approval service appends an `approval/asked` session event and blocks the agent until you decide. The plugin watches `session/event` (with an authoritative fold over the live session log as a fallback) and pins the session into the `asking` state for that whole wait, clearing it on `approval/decided`.
157
161
  - The browser script polls `/dsh-web-icon-status.json` once a second, fetches `base.svg` once, and then on every `requestAnimationFrame` tick rebuilds the favicon as a `data:image/svg+xml,…` URI — replacing the `__COLOR__` placeholder with the state's configured color and applying the state's configured effect. The status response also echoes the current per-state visual config, so a settings save reaches the running tab on the next poll (~1 s) without a reload. Browsers don't play favicon SVG CSS animations, so all motion is JS-driven. Because browsers pause `requestAnimationFrame` in hidden tabs, the poll also repaints a wall-clock frame for animated states, so background tabs keep animating (coarsely) instead of freezing; full-speed animation resumes when the tab is visible again. The poll also survives host restarts: a transient fetch failure restores the original icon and retries on the next tick (the SPA reconnects in place, so the icon comes back without a manual refresh).
package/README.zh.md CHANGED
@@ -1,8 +1,11 @@
1
1
  # dsh-web-icon-indicator
2
2
 
3
- > 📖 [English](README.md) · [中文文档](README.zh.md)
3
+ > 📖 [English](README.md) · [中文文档](README.zh.md) · 📝 [更新记录](CHANGELOG.md) · [Releases](https://github.com/waknow/dsh-web-icon-indicator/releases)
4
4
 
5
5
  [![awesome · DSH plugin](https://awesome-dsh-plugin.com/badge.svg)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
6
+ [![npm version](https://img.shields.io/npm/v/dsh-web-icon-indicator)](https://www.npmjs.com/package/dsh-web-icon-indicator)
7
+ [![npm downloads](https://img.shields.io/npm/dm/dsh-web-icon-indicator)](https://www.npmjs.com/package/dsh-web-icon-indicator)
8
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
6
9
 
7
10
  浏览器标签页 favicon 实时反映 DSH 会话状态——`待机` / `运行中` / `提问` / `完成`——让你在标签页置于后台时也能一眼看出是否有会话需要处理。
8
11
 
@@ -14,6 +17,7 @@
14
17
  - **完全可配置、即时生效** —— 每个状态的颜色、特效、周期,以及提问 / 完成驻留时长,改动约 1 秒内同步到已打开的标签页——无需刷新、无需重启。
15
18
  - **内置配置 UI,无需手写 YAML** —— DSH 设置页里的 *标签页图标指示器* 卡片可编辑整套配置,带实时色块预览,保存后自动写入 `settings.yaml`(路径见下)。
16
19
  - **后台标签页与重启抗性** —— 隐藏标签页中 `requestAnimationFrame` 被暂停时,动画态会按墙钟时间补帧;状态轮询还能扛住 host 重启,图标自动恢复。
20
+ - **多 agent 一目了然** —— 当同时有**超过一个**活动 agent(非待机:`asking` / `running` / `done`)时,favicon 从鲸鱼切换为**占满整帧的数字块**,实时显示活动数(上限 `99+`),颜色与动画和该状态下鲸鱼完全一致;活动数回到 0–1 时恢复鲸鱼。(视觉与 [`demo/badge.html`](./demo/badge.html) 的「满幅数字」通道一致。)
17
21
 
18
22
  ### 🛠 配置界面——怎么找到它
19
23
 
@@ -145,7 +149,7 @@ config:
145
149
  ## 实现原理
146
150
 
147
151
  - Host 插件 + 一个小型浏览器半区:在现有 `webServer` 上注册路由——状态 JSON 端点、静态 `/dsh-web-icon-indicator/base.svg`(鲸鱼模板),以及一个 `tapIndex` 向每个 `index.html` 注入小段浏览器脚本。整套配置已注册进 DSH settings 服务(`web-icon-indicator` 命名空间)用于校验、持久化与设置页卡片(见上)。
148
- - 状态按 `agents.list()` 聚合,优先级 `asking > running > done > idle`。每次请求都会执行一次 `reconcile()` 检测 running → idle 的转换,因为 `agent/status` 的 idle 事件在回合结束时并不保证送达。
152
+ - 状态按 `agents.list()` 聚合,优先级 `asking > running > done > idle`。每次请求都会执行一次 `reconcile()` 检测 running → idle 的转换,因为 `agent/status` 的 idle 事件在回合结束时并不保证送达。状态端点还会上报 `active`——非待机 agent 数——当该数 **> 1** 时,注入脚本改为渲染占满整帧的数字块([`demo/badge.html`](./demo/badge.html) 的「满幅数字」通道:圆角色块,填充色与鲸鱼同源的逐帧状态色/特效,白色粗体数字约占图标高度 31%–52%,上限 `99+`),而不是鲸鱼,这样即使在 16px 的固定标签页里也能一眼看出同时有几个 agent 在忙。
149
153
  - `ask_user_question` 工具调用(通过 `tools/pre-execute` / `tools/result`)把会话置为 `asking`,带可配置的最小保持时长,即使你立刻回答,图标也会保持可见。
150
154
  - 权限 / **沙箱拦截**等待同样会显示为 `asking`:当 agent 命中沙箱拒绝并请求提权(`sandbox_permissions` + `justification`),或其他工具需要征得同意时,审批服务会先写入一条 `approval/asked` 会话事件并阻塞 agent,直到你做出决定。插件监听 `session/event`(并以实时会话日志的权威折叠作为兜底)在整个等待期间将会话置为 `asking` 状态,收到 `approval/decided` 后清除。
151
155
  - 浏览器脚本每秒轮询 `/dsh-web-icon-status.json`,首次获取 `base.svg`,然后每个 `requestAnimationFrame` 周期把 favicon 重建为 `data:image/svg+xml,…` URI——把 `__COLOR__` 占位符替换为状态配置的颜色,并应用该状态配置的特效。状态响应还会携带当前的每状态视觉配置,因此设置保存后约 1 秒内(下一个轮询 tick)即同步到已打开的标签页,无需刷新。浏览器不会播放 SVG favicon 的 CSS 动画,所以一切动画都由 JS 驱动。由于浏览器在**隐藏(后台)标签页会暂停 `requestAnimationFrame`**,轮询还会为动画态补绘一帧按墙钟时间计算的画面——后台标签页保持粗粒度动画(约每 1 秒)而不会冻结,切回前台后恢复满速动画。轮询还能**扛住 host 重启**:瞬时请求失败时先还原原始图标,并在下一个 tick 重试(SPA 原地重连,无需手动刷新图标即可恢复)。
package/lib/index.js CHANGED
@@ -22,8 +22,16 @@
22
22
  * session into `asking` while the agent waits on the user for a
23
23
  * permission decision (covers sandbox escalations too).
24
24
  * - `agent/turn-stopping` flips `running`/`asking` into `done`.
25
+ * - A lost `tools/result` cannot pin `asking` forever: the hold re-arms at
26
+ * most ASK_MAX_REARM times, then force-releases against live status.
25
27
  * - `reconcile()` (on every status request) watches `agents.list()` for
26
28
  * running→idle transitions as a fallback.
29
+ * - The status endpoint also reports `active` — the number of non-idle
30
+ * agents (asking / running / done). While more than one agent is active
31
+ * the favicon shows that count as a full-frame number block (filled with
32
+ * the aggregate state's color/effect, "满幅数字" per demo/badge.html)
33
+ * instead of the whale; with 0–1 active agents the whale is drawn as
34
+ * before.
27
35
  *
28
36
  * No Client half: the browser script runs outside the sandbox by being
29
37
  * injected into the served index.html, where `document` is freely available.
@@ -126,7 +134,10 @@ const INJECTED_SCRIPT = `
126
134
  var ORIGINAL = null;
127
135
  var TIMER = null;
128
136
  var RAF = null;
129
- var PREV_STATE = null; // state the current animation loop was started for
137
+ var PREV_KEY = null; // render key (state + "|" + active count) the current loop was started for
138
+ var ACTIVE = 0; // non-idle agent count from the status poll; >= BIG_NUM_MIN switches to the count block
139
+ var BIG_NUM_MIN = 2; // show the full-frame number while more than one agent is active
140
+ var BIG_NUM_RX = 11; // count-block corner radius (demo/badge.html bigNum channel)
130
141
  var ANIM_START = null; // wall-clock start of the current loop (hidden-tab fallback)
131
142
  var STATUS_PATH = "__STATUS_PATH__";
132
143
  var BASE_PATH = "__BASE_PATH__";
@@ -209,6 +220,30 @@ const INJECTED_SCRIPT = `
209
220
  }
210
221
 
211
222
  // ---- svg frame builder --------------------------------------------------
223
+ // Full-frame activity count ("满幅数字", per demo/badge.html bigNum
224
+ // channel): while more than one agent is active the whole icon becomes a
225
+ // state-colored rounded block with a bold count — no whale is drawn, so
226
+ // count changes never show the whale through the number. Digit size shrinks
227
+ // by width (31%–52% of the icon height), readable at 16px and in pinned
228
+ // tabs; cap at "99+". The fill comes from the SAME per-frame state effect
229
+ // as the whale, so blink/breath/rainbow animate the block identically;
230
+ // geometric transform effects (heartbeat/bounce) only wrap the whale and
231
+ // are deliberately skipped here — a full-frame rect cannot scale without
232
+ // cropping (mirrors the demo's svgFrame showWhale=false branch).
233
+ function bigNumUri(fill) {
234
+ var text = String(ACTIVE > 99 ? "99+" : ACTIVE);
235
+ var chars = text.length;
236
+ var fs = chars === 1 ? 26 : chars === 2 ? 20 : 15.5;
237
+ var rgb = hexToRgb(fill);
238
+ var lum = (0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255;
239
+ var fg = lum > 0.6 ? "#111111" : "#FFFFFF"; // auto-contrast on the state color
240
+ var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 50 50" fill="none">' +
241
+ '<rect x="0" y="0" width="50" height="50" rx="' + BIG_NUM_RX + '" fill="' + fill + '"/>' +
242
+ '<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>' +
243
+ '</svg>';
244
+ return "data:image/svg+xml," + encodeURIComponent(svg);
245
+ }
246
+
212
247
  // Reuses the fetched base template; replaces its __COLOR__ token (and,
213
248
  // for scale/translate effects, wraps the whale in a <g transform>).
214
249
  function frameUri(fill, effect, t, speed) {
@@ -259,20 +294,24 @@ const INJECTED_SCRIPT = `
259
294
  var effect = st.effect || "static";
260
295
  var speed = st.speed || DEF_SPEED;
261
296
  var cols = (st.colors && st.colors.length) ? st.colors : [DEF_COLOR];
262
- return frameUri(frameColor(cols, effect, t, speed), effect, t, speed);
297
+ var fill = frameColor(cols, effect, t, speed);
298
+ // More than one active agent: the count block replaces the whale frame.
299
+ if (ACTIVE >= BIG_NUM_MIN) return bigNumUri(fill);
300
+ return frameUri(fill, effect, t, speed);
263
301
  }
264
302
  function stopAnim() { if (RAF) { cancelAnimationFrame(RAF); RAF = null; } }
265
303
  function apply(state) {
266
304
  var link = linkEl();
267
305
  if (!link) return;
268
306
  if (state == null) { stopAnim(); return; }
269
- // Unchanged state: keep the running loop (don't reset the phase) — the
270
- // 1 s poll would otherwise restart animated effects from phase 0 every
271
- // second. Browsers PAUSE requestAnimationFrame in hidden tabs, so a
272
- // background tab would freeze on the last frame: repaint here instead —
273
- // animated states get a wall-clock frame (coarse ~1 Hz animation), static
274
- // states repaint (self-heal / pick up changes that happened while hidden).
275
- if (BASE && state === PREV_STATE) {
307
+ // Unchanged state AND unchanged activity count: keep the running loop
308
+ // (don't reset the phase) — the 1 s poll would otherwise restart
309
+ // animated effects from phase 0 every second. Browsers PAUSE
310
+ // requestAnimationFrame in hidden tabs, so a background tab would freeze
311
+ // on the last frame: repaint here instead animated states get a
312
+ // wall-clock frame (coarse ~1 Hz animation), static states repaint
313
+ // (self-heal / pick up changes that happened while hidden).
314
+ if (BASE && state + "|" + ACTIVE === PREV_KEY) {
276
315
  if (isStatic(state)) {
277
316
  var sframe = frameAt(state, 0);
278
317
  if (sframe && link.isConnected) setHref(sframe);
@@ -282,12 +321,12 @@ const INJECTED_SCRIPT = `
282
321
  }
283
322
  return;
284
323
  }
285
- PREV_STATE = state;
324
+ PREV_KEY = state + "|" + ACTIVE;
286
325
  stopAnim();
287
326
  if (!BASE) { // base.svg not loaded yet — fetch once, then start
288
327
  fetch(location.origin + BASE_PATH + "?t=" + Date.now(), { cache: "no-store" })
289
328
  .then(function (r) { if (!r.ok) throw new Error("base " + r.status); return r.text(); })
290
- .then(function (txt) { BASE = txt; PREV_STATE = null; apply(state); })
329
+ .then(function (txt) { BASE = txt; PREV_KEY = null; apply(state); })
291
330
  .catch(function () {});
292
331
  return;
293
332
  }
@@ -324,12 +363,19 @@ const INJECTED_SCRIPT = `
324
363
  if (next === CFG_JSON) return;
325
364
  CFG_JSON = next;
326
365
  CFG = { states: j.states };
327
- PREV_STATE = null; // force a full repaint on the next apply
366
+ PREV_KEY = null; // force a full repaint on the next apply
328
367
  }
329
368
  function poll() {
330
369
  fetch(location.origin + STATUS_PATH, { cache: "no-store" })
331
370
  .then(function (r) { if (!r.ok) throw new Error("bad"); return r.json(); })
332
- .then(function (j) { syncCfg(j); apply(j.state || "idle"); })
371
+ .then(function (j) {
372
+ syncCfg(j);
373
+ // Non-idle agent count: >= BIG_NUM_MIN switches the frame to the
374
+ // full-frame number. Older hosts omit the field; keep the last
375
+ // known count (initial 0 → whale only).
376
+ if (typeof j.active === "number") ACTIVE = j.active;
377
+ apply(j.state || "idle");
378
+ })
333
379
  // Transient failures (host restarting, network blip) must NOT kill the
334
380
  // poll: restore the original icon and retry on the next tick. The SPA
335
381
  // reconnects in place, so the tab survives a host restart — the icon
@@ -439,18 +485,36 @@ export default {
439
485
  const askDone = new Set(); // agentIds whose ask_user_question tool call already returned
440
486
  const askTimers = new Map(); // agentId -> ctx.timer.timeout disposer
441
487
  const pendingApprovals = new Set(); // agentIds with an open approval/asked (waiting on the user)
488
+ // Per-agent done-hold disposers, keyed like the other maps. Tracking them
489
+ // explicitly (instead of firing a never-cancelled timeout) lets a state
490
+ // leaving `done` cancel the hold, and `agent/disposed` cancel it too; a
491
+ // done→running→done round-trip no longer stacks two timers that race to
492
+ // truncate the hold early.
493
+ const doneTimers = new Map(); // agentId -> ctx.timer.timeout disposer
494
+
495
+ const cancelDoneHold = (id) => {
496
+ const d = doneTimers.get(id);
497
+ if (d) { try { d(); } catch (e) {} doneTimers.delete(id); }
498
+ };
442
499
 
443
500
  const setState = (id, state) => {
444
501
  const prev = states.get(id);
445
502
  const now = Date.now();
446
503
  if (state === "asking") asking.add(id);
504
+ // Same-state transition: keep `since` and — for done — the already-armed
505
+ // hold untouched (never stack a second timer; never cancel the live one).
447
506
  if (prev && prev.state === state) { states.set(id, { state, since: prev.since }); return; }
507
+ // Leaving `done` (to running/idle/asking) cancels any in-flight hold so a
508
+ // stale timer can't later force the state back to idle.
509
+ if (prev && prev.state === "done") cancelDoneHold(id);
448
510
  states.set(id, { state, since: now });
449
511
  if (state === "done") {
450
512
  const handle = ctx.timer.timeout(() => {
451
513
  const cur = states.get(id);
452
514
  if (cur && cur.state === "done") states.set(id, { state: "idle", since: Date.now() });
515
+ doneTimers.delete(id);
453
516
  }, cfg.doneHoldMs);
517
+ doneTimers.set(id, handle);
454
518
  ctx.effect(() => handle); // register for cleanup
455
519
  }
456
520
  };
@@ -477,13 +541,32 @@ export default {
477
541
 
478
542
  // Asking: visible minimum (askingHoldMs). Re-arm timer while user
479
543
  // is still answering; clean up to live agent status once returned.
544
+ // A call for a brand-new pin cancels any still-pending timer first, so a
545
+ // fast pre-execute→idle→pre-execute round-trip can't stack two timers.
546
+ // as a lost `tools/result` would otherwise re-arm forever and pin the
547
+ // icon to asking indefinitely.
548
+ const ASK_MAX_REARM = 2; // re-arm cap while waiting on a result
549
+ const askWaits = new Map(); // agentId -> consecutive re-arm count
480
550
  const scheduleAskCheck = (id) => {
481
551
  const prevHandle = askTimers.get(id);
482
- if (prevHandle) { try { prevHandle(); } catch (e) {} }
552
+ if (prevHandle) { try { prevHandle(); } catch (e) {} askTimers.delete(id); }
483
553
  const handle = ctx.timer.timeout(() => {
484
554
  askTimers.delete(id);
485
- if (!asking.has(id)) return;
486
- if (!askDone.has(id)) { scheduleAskCheck(id); return; }
555
+ if (!asking.has(id)) { askWaits.delete(id); return; }
556
+ if (!askDone.has(id)) {
557
+ const n = (askWaits.get(id) || 0) + 1;
558
+ if (n <= ASK_MAX_REARM) { askWaits.set(id, n); scheduleAskCheck(id); return; }
559
+ // Result never arrived: force-release the pin against live status so
560
+ // a lost tools/result cannot keep the icon blinking forever.
561
+ askWaits.delete(id);
562
+ asking.delete(id);
563
+ askDone.delete(id);
564
+ const live = agents.get(id);
565
+ if (live) setState(id, live.status === "running" ? "running" : "idle");
566
+ else setState(id, "idle");
567
+ return;
568
+ }
569
+ askWaits.delete(id);
487
570
  asking.delete(id);
488
571
  askDone.delete(id);
489
572
  const live = agents.get(id);
@@ -499,6 +582,7 @@ export default {
499
582
  if (id != null) {
500
583
  asking.add(id);
501
584
  askDone.delete(id);
585
+ askWaits.delete(id); // fresh pin restarts the re-arm budget
502
586
  setState(id, "asking");
503
587
  scheduleAskCheck(id);
504
588
  }
@@ -542,6 +626,8 @@ export default {
542
626
  askDone.delete(id);
543
627
  pendingApprovals.delete(id);
544
628
  lastSeen.delete(id);
629
+ askWaits.delete(id);
630
+ cancelDoneHold(id);
545
631
  const t = askTimers.get(id);
546
632
  if (t) { try { t(); } catch (e) {} askTimers.delete(id); }
547
633
  });
@@ -590,9 +676,16 @@ export default {
590
676
  reconcile();
591
677
  const order = ["asking", "running", "done", "idle"];
592
678
  let best = null;
679
+ // Non-idle agent count (asking / running / done): the favicon shows this
680
+ // as a full-frame number block while more than one agent is active.
681
+ let active = 0;
682
+ const counted = new Set();
593
683
  for (const a of agents.list()) {
594
684
  const id = a.id;
595
685
  const s = agentStateOf(id);
686
+ if (s !== "idle") {
687
+ if (!counted.has(id)) { active++; counted.add(id); }
688
+ }
596
689
  const rank = order.indexOf(s);
597
690
  if (rank === -1) continue;
598
691
  if (best === null || rank < best.rank) best = { rank, id, state: s };
@@ -600,10 +693,13 @@ export default {
600
693
  for (const [id, rec] of states) {
601
694
  if (rec.state === "done") {
602
695
  const rank = order.indexOf("done");
696
+ if (!counted.has(id)) active++; // done-hold agents not yet live-non-idle
603
697
  if (best === null || rank < best.rank) best = { rank, id, state: "done" };
604
698
  }
605
699
  }
606
- return best ? { state: best.state, since: (states.get(best.id) || {}).since || Date.now() } : { state: "idle", since: Date.now() };
700
+ return best
701
+ ? { state: best.state, since: (states.get(best.id) || {}).since || Date.now(), active }
702
+ : { state: "idle", since: Date.now(), active };
607
703
  };
608
704
 
609
705
  // -- Routes --------------------------------------------------------------
@@ -77,6 +77,14 @@ export interface DshWebIconIndicatorAggregate {
77
77
  state: DshWebIconStateName;
78
78
  /** Epoch millis when that aggregate state was first entered. */
79
79
  since: number;
80
+ /**
81
+ * Number of non-idle agents (asking / running / done). While it is > 1 the
82
+ * favicon shows this count as a full-frame number block (in the aggregate
83
+ * state's color/effect) instead of the whale; with 0–1 active agents the
84
+ * whale is drawn. Echoed by the status endpoint. Added in 0.3.x; older
85
+ * browser bundles ignore it.
86
+ */
87
+ active: number;
80
88
  /**
81
89
  * Current per-state visual config, echoed by the status endpoint so the
82
90
  * injected browser script can apply a settings save within ~1 s (no tab
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-web-icon-indicator",
3
3
  "description": "Browser tab favicon reflects the DSH session state: idle / running / asking / done, recolored and animated from one base SVG in the browser (fully configurable colors + effects). · 浏览器标签页 favicon 实时反映 DSH 会话状态:待机 / 运行中 / 提问 / 完成,由单个 base.svg 在浏览器中动态上色与动画(颜色与特效完全可配置)。",
4
- "version": "0.2.2",
4
+ "version": "0.3.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -30,8 +30,8 @@
30
30
  }
31
31
  },
32
32
  "peerDependencies": {
33
- "@deepseek-ai/schemastery": "^3.18.0",
34
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.7"
33
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.7",
34
+ "@deepseek-ai/schemastery": "^3.18.0"
35
35
  },
36
36
  "exports": {
37
37
  ".": {
@@ -52,6 +52,7 @@
52
52
  "assets",
53
53
  "docs",
54
54
  "cordis.patch.yml",
55
+ "CHANGELOG.md",
55
56
  "README.md",
56
57
  "README.zh.md",
57
58
  "LICENSE"
@@ -64,5 +65,16 @@
64
65
  "favicon",
65
66
  "icon-indicator",
66
67
  "browser-tab"
67
- ]
68
- }
68
+ ],
69
+ "scripts": {
70
+ "test": "node test/verify.js",
71
+ "release": "commit-and-tag-version",
72
+ "release:patch": "commit-and-tag-version --release-as patch",
73
+ "release:minor": "commit-and-tag-version --release-as minor",
74
+ "release:major": "commit-and-tag-version --release-as major",
75
+ "release:dry": "commit-and-tag-version --dry-run"
76
+ },
77
+ "devDependencies": {
78
+ "commit-and-tag-version": "^13.1.2"
79
+ }
80
+ }