dsh-notice-center 1.3.0 → 1.4.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,100 @@
1
+ # 更新记录
2
+
3
+ 每个**已发布**版本的改动都记在这里;本文件随包一起发布(`files` 里已包含),
4
+ 发版流程见 [README → 发版](./README.md#发版)。最上面一条就是 npm 上的当前版本。
5
+
6
+ 约定:正式版发到 `latest`,`-rc.N` 只发 `next`(不动 `latest`);括号里的短哈希是那次改动的提交。
7
+
8
+ ## 1.4.0 — 2026-09-27
9
+
10
+ > **兼容性**:`engines.dsh` 要求 `>=0.1.7-rc.2 <0.2.0-0`。设置表单改用 0.1.7 的
11
+ > `runtime.Config` 派生,**宿主 0.1.6 及更早请继续用 1.3.1**。
12
+
13
+ ### 修复
14
+
15
+ - **浅色系统上标签页状态灯永远不显色**(`981e8cd`)
16
+ 宿主 `index.html` 挂了两枚 `<link rel="icon">`(`favicon-dark.svg` / `favicon.svg`,
17
+ 靠 `media="(prefers-color-scheme: …)"` 区分),浏览器按系统主题只渲染其中一枚;旧代码
18
+ `querySelector("link[rel~=icon]")` 只拿到第一枚,而浅色主题下渲染的恰恰是第二枚 ——
19
+ 琥珀、绿、默认色全画在没人用的那枚上,标签页看着一直是默认图标。现在改**全部** icon,
20
+ 逐枚记住各自原始 `href`(还原时对号入座),页面上一枚都没有时自建一枚再上色。
21
+
22
+ - **音量滑杆「改了没反应、还回弹」**(`2cfc6b4`)
23
+ 每笔 `set` 都要重写整份 profile 的 `cordis.patch.yml` 并热重载插件条目(实测约 1s/笔),
24
+ 而原生滑杆每挪一格就发一次 `input`,一次拖动几十笔全排进写队列,回显被拖到几秒之后,
25
+ 受控滑杆又被旧快照拽回 —— 表现为「点一下固定差 5%,右滑也是下降」。现在按**手势合并写**:
26
+ 首笔立即写(点一下就生效),拖动中只攒最新值,静默 150ms 后补最后一笔(一次手势 1–2 笔),
27
+ 同时当帧画回显草稿,写入最终没落盘才回滚。
28
+
29
+ - **点「↺ 恢复默认」/ 取色后,预览鲸鱼要等一会儿才变色**(`c49231d`)
30
+ 与上一条同源:预览和取色器都只读快照。现在颜色也有当帧草稿,写入落定后交还快照、失败回滚;
31
+ 取色同样按手势合并(原生取色器拖动时会连发 `input`);复位是整笔覆盖,先丢掉排队中的取色值
32
+ 再立即 `unset`。
33
+
34
+ ### 变更
35
+
36
+ - 设置页迁移到 DSH 0.1.7 的设置 API:改读 `ctx.configForms.get("notice-center")`,`loading`
37
+ 不渲染,`unavailable` / 只读时给一行说明;宿主半只声明 Config schema,旧 `settings.yaml`
38
+ 由 `dsh-settings` 按同名条目 id 迁移。
39
+ - 宿主半每个字段补 `.volatile()`(`volatileForm` 只投影「最近的可变祖先」下的字段,漏标一个
40
+ 那项就会在设置页整条消失),`schemastery` 升到 `^3.18.4`;保留 `/notice-center-sounds`
41
+ 音效路由。
42
+ - 修正图标名:`…Outline14` → `…OutlineRegular`。宿主静态模块表里没有 `…14` 那套导出,
43
+ `jsx(undefined)` 会被插槽错误边界吞掉,整个设置页会变成空白(配置项一条都不显示)。
44
+ - 导航图标换成自定义铃铛(`viewBox 0 0 1024 1024`):铃身 + 底座合并成一条 `currentColor`
45
+ 路径跟随主题,高光保留 `#3399FF`;官方回退齿轮的 path 不够用时只追加克隆节点(不删 React
46
+ 管的节点),幂等跳过避免 rAF 死循环。
47
+ - `engines` / `peerDependencies` 收到 `>=0.1.7-rc.2 <0.2.0-0`。
48
+
49
+ ### 测试与工程
50
+
51
+ - 新增 `test/compat-range.smoke.mjs`:0.1.4 ~ 0.2.0 宿主兼容区间,`npm test` 一并跑。
52
+ - `primitives` 桩改成导出白名单(名单外的名字直接抛错,原来的 Proxy 对任何名字都造得出假组件);
53
+ `rAF` 桩改为入队 + 手动 flush,对齐真浏览器时序;新增导航图标 9 条断言。
54
+ - 浏览器半冒烟补:两枚 favicon 都换色 / 各自还原 / 一枚都没有时自建一枚;音量手势合并写与
55
+ 回显草稿;颜色当帧回显与合并写(第 17–19 节)。
56
+
57
+ ## 1.3.1 — 2026-09-24
58
+
59
+ - fix: 「自动隐藏」的提示文案只讲了关闭侧,改为两侧都写清楚(`92b0dae`)
60
+ - feat: 设置页页脚(插件名 + 版本号,点击开仓库)(`ce8109e`)
61
+ - test: 补「同一会话连续完成两次 → 弹两次」回归用例,把去重语义写精确 —— 管的是
62
+ 「同一次完成」,不是「同一会话」(`d44ae93`、`cd8f2f2`)
63
+ - docs: README 重构为「使用 / 开发」两部分并加目录导航,补齐中英文主图与真实设置页截图
64
+
65
+ ## 1.3.0 — 2026-09-19
66
+
67
+ - fix: 状态灯改为**待处理优先**,与官方 `sessionStatuses` 一致(`735007f`)
68
+ - feat: 完成通知附本轮总用时;待处理通知按交互类型细化;新增「前台提醒」开关;调完音量试听
69
+ 完成提示音;设置页分组行整行可点(不必去够最右边的箭头)
70
+ - test: 新增设置页渲染测试(记录型桩,零新依赖)(`ed7acd6`)
71
+ - docs: 新增英文 README(`README.en.md`)
72
+
73
+ ## 1.3.0-rc.1 — 2026-09-19
74
+
75
+ - ci: 新增 tag 触发的 OIDC 发布工作流(npm Trusted Publishing,无令牌 + 自动 provenance),
76
+ 并加幂等保护:该版本已在 registry 上就跳过(`a8a2f38`、`de49005`)
77
+ - 首次实测 OIDC 发布链路
78
+
79
+ ## 1.2.0 — 2026-09-19
80
+
81
+ - feat: 提示音可选 —— 45 个 opencode 内置音效,下拉里悬浮即试听(`40521ef`)
82
+ - docs: README 重写(功能 / 使用 / 截图)
83
+
84
+ ## 1.1.0 — 2026-09-18
85
+
86
+ - test: 补浏览器半冒烟测试并接入 CI(`d388890`)
87
+ - feat: 通知标题改为会话名、正文改为通知类型(`9a541e9`);新增「自动隐藏」开关,关闭则常驻
88
+ - fix: 通知去掉 `tag`、前台判定纳入窗口焦点 —— 修「同一对话只有第一条会弹」(`8b75353`)
89
+
90
+ ## 0.2.2 — 2026-08-22
91
+
92
+ - 设置页与「选中会话」信号灯的修复版(`f278787`)
93
+
94
+ ## 0.1.0 — 2026-08-16
95
+
96
+ - 首个正式版:标签页鲸鱼状态灯 + 浏览器系统通知(`83a2860`)
97
+
98
+ ---
99
+
100
+ > 0.1.0 – 1.1.0 是改名 / 独立收敛之前的产物;npm 上的 `dsh-notice-center` 从 1.2.0 开始。
package/README.en.md CHANGED
@@ -5,172 +5,349 @@
5
5
  [![test](https://github.com/SCP-QQ/dsh-notice-center/actions/workflows/test.yml/badge.svg)](https://github.com/SCP-QQ/dsh-notice-center/actions/workflows/test.yml)
6
6
  [![npm](https://img.shields.io/npm/v/dsh-notice-center.svg)](https://www.npmjs.com/package/dsh-notice-center)
7
7
 
8
- A **notification center** for DeepSeek Harness: it turns the tab's whale icon into a status light and gets your attention while you are looking elsewhere — a session finished, or something is waiting for your decision.
8
+ <img src="docs/images/banner-en.jpg" alt="dsh-notice-center — Get Every Message, Never Miss a Thing" width="100%">
9
9
 
10
- Package `dsh-notice-center`, plugin instance id `notice-center`, settings namespace `notice-center`.
10
+ **In one line**: a **notification center** for DeepSeek Harness — it turns the tab's whale icon into a status light and gets your attention while you are looking elsewhere.
11
11
 
12
- ## Screenshots
12
+ | | |
13
+ |---|---|
14
+ | Package | `dsh-notice-center` |
15
+ | Plugin instance id | `notice-center` |
16
+ | Settings namespace | `notice-center` |
17
+ | Install shapes | npm package / GitHub repo / tarball / local link — all four supported |
18
+
19
+ ## Contents
20
+
21
+ **Usage**
22
+
23
+ - [Why you need it](#why-you-need-it)
24
+ - [Features](#features)
25
+ - [Installation](#installation)
26
+ - [Getting started in three steps](#getting-started-in-three-steps)
27
+ - [Settings reference](#settings-reference)
28
+ - [Troubleshooting](#troubleshooting)
29
+ - [Known limitations](#known-limitations)
30
+
31
+ **Development**
32
+
33
+ - [Architecture](#architecture)
34
+ - [Project layout](#project-layout)
35
+ - [Life of a notification](#life-of-a-notification)
36
+ - [Local development](#local-development)
37
+ - [Tests](#tests)
38
+ - [Releasing](#releasing)
39
+ - [Changelog](./CHANGELOG.md)
40
+ - [Design constraints](#design-constraints)
41
+
42
+ ## Why you need it
43
+
44
+ Inside Harness, the two signals that matter — "a session finished" and "something is waiting for your decision" — only show up on the **sidebar dots** and **inside the session itself**. The moment you switch to another tab or another app, they are invisible.
45
+
46
+ Long tasks make this worse: you go do something else, come back, and the session finished ages ago — or an approval request has been sitting there for ten minutes.
13
47
 
14
- Settings → **Notification center** (a sidebar entry):
48
+ This plugin moves both signals somewhere you will actually see them:
15
49
 
16
- <img src="docs/images/settings.png" width="440" alt="Notification center settings">
50
+ | While you are away from the DSH page | What you get |
51
+ |---|---|
52
+ | A session finished | The tab whale turns **green**, plus a system notification (with the turn duration when it is a single notification) |
53
+ | Something needs you | The tab whale turns **amber**, plus a system notification (saying whether it is an approval / question / plan review) |
17
54
 
18
55
  ## Features
19
56
 
20
- ### 1. Whale status light (favicon state machine)
57
+ ### 1. Whale status light
58
+
59
+ Three colours are encoded directly into the tab icon, driven by **the same official signal** as the sidebar dots, so they can never drift apart:
21
60
 
22
61
  | Whale colour | Meaning | When it clears |
23
62
  |---|---|---|
24
- | 🟢 Green | A session **finished** while you were not watching it | Clears once you open that session; back to the default state once you have opened them all |
25
- | 🟠 Amber | A session **awaits you**: question / approval / plan review | Clears once handled |
26
- | ⚫ Black | All clear (the stock icon) | Default state |
27
-
28
- In the tab (green = a session finished):
63
+ | <img src="docs/images/whale-green.svg" width="20" alt="Green"> Green | A session **finished** while you were not watching it | Clears once you open that session; back to default once you have opened them all |
64
+ | <img src="docs/images/whale-amber.svg" width="20" alt="Amber"> Amber | A session **awaits you**: question / approval / plan review | Clears once handled |
65
+ | <img src="docs/images/whale-black.svg" width="20" alt="Black"> Black | All clear (the stock icon) | Default state |
29
66
 
30
- ![Tab status light](docs/images/tab-status-light.png)
67
+ ![Tab status light: top to bottom — finished (green), pending (amber), default (black)](docs/images/tab-status-light.png)
31
68
 
32
- The colours come from **the same official signal** as the sidebar's green and amber dots, so they never drift apart. Only main sessions count; subagents do not affect it. When both apply, **amber wins** — a pending interaction is the primary status, so a session waiting on you is never masked by another session that just finished.
33
-
34
- All three colours are configurable, including the default colour — leave it unset to keep the stock icon.
69
+ - Only **main sessions** count; subagents do not affect it
70
+ - When both apply, **amber wins** — a pending interaction is the primary status, so a session stuck waiting on you is never masked by another session that just finished
71
+ - All three colours are configurable (including the default colour: leave it unset to keep the stock icon)
35
72
 
36
73
  ### 2. System notifications
37
74
 
38
- - **Completion** — fires when a session finishes (the official `completed` goes false → true; the session you have open never flips it, so its run state going true → false counts instead)
39
- - **Pending** — fires when a new interaction starts waiting for you
40
- - **Text** — the title is the session name (with `+N` when several merge), the body is the kind of notification (finished → Session finished; pending is **refined per interaction**: approval → *Approval · Bash* (plain *Approval needed* when no tool name is available, truncated past 32 characters), a question → *Choose an option* / *Choose options* / *Type an answer*, or *2 questions* for a batch, plan review → *Plan review*; unknown kinds fall back to a generic line); a **single** finished notification also carries how long the turn took (*Session finished · turn took 2m13s*), formatted like the official in-conversation "turn time" readout and dropped when several merge. The start is observed from `running`, so a turn already in flight when the page reloads has no start and shows no figure
41
- - **Quiet by default while you are looking** — *in the foreground* means the tab is visible **and** the window has focus; switching tabs, minimising, or leaving the browser behind another app all notify you. To be notified in the foreground too (still sitting in a session but away from the screen), turn on *Notify in the foreground* under *More notification options*
42
- - **Batching and de-duplication** — events within a 300ms window are merged; the same session and kind only fires once; several sessions merge into *title +N*
43
- - **Clicking the notification** focuses the window and opens that session
44
- - **Sound** — finished and pending each have their own picker (**hover to preview**) and a shared volume (adjusting the volume replays the current finished sound at the new level); turning sound on silences the system notification sound and uses the plugin's own
45
- - **Optional persistence** — turn *Auto hide* off and the notification stays on screen until you dismiss it (auto hide is on by default, so the OS collects it)
46
- - The notification icon follows your configured status-light colour
75
+ Two events raise a notification, and by default **only while you are not looking**:
76
+
77
+ | Event | Trigger | What the notification says |
78
+ |---|---|---|
79
+ | **Finished** | A session completes | Title = session name; body = `Session finished`, with the turn duration appended when it is a single notification (e.g. `Session finished · turn took 3s`) |
80
+ | **Pending** | A new pending interaction appears | Title = session name; body = the interaction type: `Approval needed · <tool>` / `Choose an option` (`Choose options` for multi-select, `Type an answer` when there are no options, `N questions` for a batch) / `Plan review`; unknown types fall back to `Something awaits you` |
47
81
 
48
82
  | Finished (green) | Pending (amber) |
49
83
  |---|---|
50
- | ![Finished notification](docs/images/notification-done.png) | ![Pending notification](docs/images/notification-pending.png) |
84
+ | ![Finished notification](docs/images/notification-done-en.png) | ![Pending notification](docs/images/notification-pending-en.png) |
85
+
86
+ > Notifications are raised by the browser (Chrome here), so the card shows the browser and the site `127.0.0.1:3080` — that is expected.
87
+ > The screenshots show the English UI; notification copy is localised at runtime.
51
88
 
52
- > Notifications are raised by the browser (Chrome here), so the card shows the browser and the origin `127.0.0.1:3080`.
89
+ Other behaviour:
53
90
 
54
- ### 3. Sound library
91
+ - **Click a notification** = focus the window and open that session
92
+ - **Only while you are not looking** — "foreground" means the tab is *visible* **and** the window has focus. Switching tabs, minimising, or having another app on top all notify. To also notify while you are looking (you are parked in a session but away from the screen, e.g. on your phone), turn on **Notify in the foreground**
93
+ - **Aggregation**: events within 300ms are merged into one notification, with `+N` appended to the title
94
+ - **Optional persistence**: turn off **Auto hide** and the notification stays on screen until you dismiss it
95
+ - The notification icon follows your configured status-light colours
55
96
 
56
- Finished and pending each have a picker with **47 entries**: 45 taken from opencode's built-in sound library (the same `Alert` / `Bip-bop` / `Staplebops` / `Nope` / `Yup` set, with the same numbering as opencode's settings) plus two tones the plugin synthesises itself.
97
+ ### 3. Sounds
98
+
99
+ Finished and pending each have their own dropdown, **47 entries** in total: 2 built-in synthesised chimes plus 45 mp3s taken from opencode's bundled sound library.
57
100
 
58
101
  | Pack | Entries |
59
102
  |---|---|
60
- | Built-in (synthesised) | Chime Up (default for finished, a rising two-tone), Chime Down (default for pending, a falling two-tone) |
103
+ | Built-in (synthesised) | `Chime Up` (finished default, rising two-tone), `Chime Down` (pending default, falling two-tone) |
61
104
  | Alert | Alert 01–10 |
62
105
  | Bip-bop | Bip-bop 01–10 |
63
106
  | Staplebops | Staplebops 01–07 |
64
107
  | Nope | Nope 01–12 |
65
108
  | Yup | Yup 01–06 |
66
109
 
67
- - **Hover to preview** — moving over an entry plays it (120ms debounce, so sweeping the list does not machine-gun)
68
- - **Click to apply** — the chosen sound is what that kind of notification plays; the current one is ticked `✓`
69
- - **Styled like the official control** — the trigger is the official language selector's `.selector` (36px tall, 18px pill radius); the panel is the official `Menu` (`bg-layer-3`, 0.5px hairline, 6px radius), and entries highlight with the official hover fill
70
- - The host half serves the files at `/notice-center-sounds/<id>.mp3`; if that route is unavailable it **falls back to the built-in synthesised tone**, so you are never left silent
71
- - No migration needed: without a stored choice it still uses Chime Up / Chime Down
72
-
73
- ## Usage
74
-
75
- 1. **Open the settings page** — Settings → **Notification center**
76
- 2. **Turn system notifications on** — flip the master switch; the browser asks for permission, choose Allow (if it was denied, the page says so and you have to re-allow it in the browser's site settings)
77
- 3. **Volume, sounds and foreground alerts** — expand *More notification options*: drag the volume slider (**releasing it replays the finished sound at the new level**); in the finished / pending pickers **hovering an entry previews it**, clicking selects it; turn on *Notify in the foreground* to be alerted while the page is in front
78
- 4. **Colours (optional)** — expand *Tab whale light*: click the swatch or type a `#RRGGBB`, and ↺ at the end of the row restores the default
79
- 5. **That is it** — from then on, sessions finishing or waiting for you notify you while you are away; clicking the notification jumps back to that session
110
+ - **Hover to preview**: hovering a dropdown entry plays it (120ms debounce, so sweeping the list does not turn into noise)
111
+ - **Preview after adjusting volume**: releasing the volume slider plays the finished chime at the new volume
112
+ - With sound on, **the system notification sound is muted** and the plugin sound is used instead
113
+ - Audio is served by the host half at `/notice-center-sounds/<id>.mp3`; if that route is unavailable it **falls back to the built-in chime**, never to silence
114
+ - No migration needed: existing configs keep using `Chime Up` / `Chime Down`
80
115
 
81
116
  ## Installation
82
117
 
83
- `dsh plugin` is a **thin forwarder to pnpm** (it runs pnpm in the profile directory, then adds the new bundle to `dsh.profile.bundles`), so a registry name, a git repository, a tarball or a local path all work.
118
+ ### Requirements
119
+
120
+ - **Official DeepSeek Harness**: `1.4.0` requires **`0.1.7-rc.2+`**; `1.3.x` supports `0.1.2-rc.1+`
121
+ - The range is declared in `engines.dsh` and the `@deepseek-ai/dsh-*` peers, guarded by `test/compat-range.smoke.mjs`. The two lines split by host version: **the Market never offers 1.4.0 to an older host, and the DSH core refuses to load it** — older hosts simply stay on `1.3.x` with no update prompt.
122
+ - The version gate only compares version strings and cannot detect a removed API (0.1.7 dropped `settings.register` while the old declaration still passed), so never treat it as a compatibility guarantee on its own.
123
+ - System notifications need a **secure context** — `http://127.0.0.1:3080` or `localhost` works; over a **LAN IP** the browser's Notification API is unavailable (a browser restriction, not a plugin issue)
84
124
 
85
- ### 1. From npm (recommended)
125
+ ### Option 1: from npm (recommended)
86
126
 
87
127
  ```sh
88
128
  dsh plugin --profile web add dsh-notice-center
89
129
  ```
90
130
 
91
- Package page: <https://www.npmjs.com/package/dsh-notice-center> (`latest` is the stable line; pre-releases live on the `next` tag)
131
+ Package page: <https://www.npmjs.com/package/dsh-notice-center> (`latest` is stable; prereleases go to the `next` tag — install with `dsh-notice-center@next`)
92
132
 
93
- ### 2. Straight from GitHub (no publish needed)
133
+ ### Option 2: straight from GitHub
94
134
 
95
135
  ```sh
96
136
  dsh plugin --profile web add github:SCP-QQ/dsh-notice-center
97
137
  # or over SSH (when a proxy interferes with HTTPS on your machine)
98
- dsh plugin --profile web add git+ssh://git@github.com/SCP-QQ/dsh-notice-center.git
138
+ dsh plugin --profile web add git+ssh://git@github.com:SCP-QQ/dsh-notice-center.git
99
139
  ```
100
140
 
101
- ### 3. From a tarball (offline / internal distribution)
141
+ ### Option 3: from a tarball (offline / internal distribution)
102
142
 
103
143
  ```sh
104
144
  npm pack # produces dsh-notice-center-<version>.tgz
105
145
  dsh plugin --profile web add ./dsh-notice-center-<version>.tgz
106
146
  ```
107
147
 
108
- ### 4. Local link (while editing the code)
148
+ ### Option 4: local link (while editing the code)
109
149
 
110
- Add `"dsh-notice-center": "link:<project dir>/dsh-notice-center"` to the profile's `dependencies`, and `"dsh-notice-center"` to `dsh.profile.bundles`.
150
+ In the profile's `package.json`: add `"dsh-notice-center": "link:<project dir>"` to `dependencies` and `"dsh-notice-center"` to `dsh.profile.bundles`, then run `pnpm install` inside the profile directory.
111
151
 
112
- > The first three need a **Harness restart** afterwards. The host half's only runtime dependency is `@deepseek-ai/schemastery` (which exists on npm), so the profile does not have to provide any `@deepseek-ai/*` package.
152
+ > **All four options require a Harness restart to take effect.**
153
+ > Installing from npm / GitHub needs no extra `@deepseek-ai/*` packages from the profile — the host half's only runtime dependency is `@deepseek-ai/schemastery` (available on npm).
113
154
 
114
- **Restart rules differ per half**: editing the browser half `lib/client.cjs` needs no restart (HMR stat-polls the bundle and tells the browser to reload it; refreshing the page forces a clean reload); **editing the host half `lib/index.mjs` requires a Harness restart** (host-half hot reload depends on `cordis-plugin-hmr`, which needs the loader to run with `--expose-internals` — measured, it does not take effect). A first install, or any rename, also requires a restart.
155
+ ### Uninstall
115
156
 
116
- ### 5. A local `.npmrc` for manual publishing (optional)
157
+ ```sh
158
+ dsh plugin --profile web remove dsh-notice-center
159
+ ```
117
160
 
118
- CI publishes over OIDC and needs **no token at all**. Only if you want to run `npm publish` from this machine, drop a `.npmrc` into the project root:
161
+ Remove the dependency and the `dsh.profile.bundles` entry, then restart — nothing is left behind.
119
162
 
120
- ```sh
121
- //registry.npmjs.org/:_authToken=<your granular token>
163
+ ## Getting started in three steps
164
+
165
+ 1. **Open the settings page** — Settings → **Notification center** (a sidebar entry)
166
+ 2. **Turn on system notifications** — flip the **System notifications** master switch; the browser asks for permission, choose *Allow*. If it was denied, the settings page tells you, and you must re-enable it in the browser's **site settings** (browsers do not ask twice)
167
+ 3. **Tune as you like** — expand the two groups: change colours, pick sounds, set volume, toggle **Notify in the foreground** / **Auto hide**
168
+
169
+ <img src="docs/images/settings-en.png" width="440" alt="Notification center settings page">
170
+
171
+ After that, whenever you switch away or minimise, a finished session or a pending interaction notifies you — and clicking the notification jumps straight back to that session.
172
+
173
+ ## Settings reference
174
+
175
+ | Group | Setting | Key | Default | Notes |
176
+ |---|---|---|---|---|
177
+ | Whale status light | Master switch | `colorsEnabled` | on | When off, the stock icon is kept |
178
+ | | Finished | `green` | `#22C55E` | Finished status-light colour (official sidebar colour) |
179
+ | | Pending | `amber` | `#F59E0B` | Pending status-light colour (official sidebar colour) |
180
+ | | Default colour | `black` | unset | Unset = keep the stock `/favicon.svg` |
181
+ | System notifications | Master switch | `notifyEnabled` | **off** | opt-in; turning it on asks for browser permission |
182
+ | | Auto hide | `notifyAutoHide` | on | Off = the notification stays until you dismiss it |
183
+ | | Notify in the foreground | `notifyForeground` | off | On = also notify while you are looking at the page |
184
+ | | Sound | `notifySound` | on | When on, the system sound is muted and the plugin sound plays |
185
+ | | Volume | `notifyVolume` | `0.6` | 0–1 |
186
+ | | Finished sound | `notifyDoneSound` | `builtin-up` | one of 47 |
187
+ | | Pending sound | `notifyPendingSound` | `builtin-down` | one of 47 |
188
+
189
+ Settings are persisted by Harness' settings service into the `notice-center:` section of `<DSH_HOME>/settings.yaml`.
190
+ The settings page also shows a one-line footer at the very top with the plugin name and version (e.g. "Notification center v1.3.0") — clicking it opens the repository in a new tab.
191
+
192
+ ## Troubleshooting
193
+
194
+ | Symptom | Cause and fix |
195
+ |---|---|
196
+ | No "Notification center" entry in Settings | The plugin did not load. Make sure you **restarted Harness** after installing, then look for `settings namespace "notice-center" registered` in the Harness console |
197
+ | A switch does nothing / a new setting has no effect | After changing the **host half** (`lib/index.mjs`) you must restart Harness — settings writes go through the host schema |
198
+ | No notifications at all | Three conditions are required: ① the master switch is on; ② browser permission is *Allow*; ③ the page is not in the foreground when the event fires (use **Notify in the foreground** to relax this) |
199
+ | No notifications over a LAN IP | Non-secure context: the browser disables the Notification API, so system notifications are **entirely unavailable** (unrelated to *Notify in the foreground*). Use `127.0.0.1` / `localhost`; over a LAN only the tab status light works |
200
+ | Nothing after closing the tab | Known limitation, see below |
201
+ | The green light disappears after a refresh | Expected. That state is the official signal's in-memory state and is lost on refresh |
202
+ | The notification flashes by too fast | Turn **Auto hide** off and it stays until dismissed |
203
+ | No sound | Check in order: sound switch, volume not 0, whether the sound route is available (it falls back to the built-in chime, so it should never be fully silent) |
204
+ | Notifications swallowed by the OS | Windows Focus Assist / Do Not Disturb intercepts them; the plugin cannot detect it |
205
+
206
+ ## Known limitations
207
+
208
+ - **No notifications once the browser tab is closed** — Web Push needs a server plus HTTPS, which is not realistic for a local plugin
209
+ - **The Notification API is unavailable in a non-secure context** (LAN IP)
210
+ - **The green state is lost on refresh** — it lives in memory only, which is the behaviour of the official signal itself
211
+ - **System-level Do Not Disturb swallows notifications** (Windows Focus Assist etc.); the plugin cannot detect it
212
+ - **The auto-hide duration is not configurable** — the Web Notification spec has no duration parameter; the desktop decides (roughly 5–20 seconds)
213
+ - **The notification card cannot be styled** — the card is rendered by the browser with no CSS or theming hook; only title, body and icon are yours
214
+
215
+ ---
216
+
217
+ # Development
218
+
219
+ > Everything below is for maintainers: architecture, local development, tests and releasing. If you only want to use the plugin, you can stop here.
220
+
221
+ ## Architecture
222
+
223
+ The plugin has **two halves** running in different processes, exchanging configuration through Harness' settings service:
224
+
225
+ | Part | File | Runs in | Responsibility |
226
+ |---|---|---|---|
227
+ | **Host half** | `lib/index.mjs` | Harness main process (Node) | ① Declare the `Config` schema (settings form, write validation and the legacy `settings.yaml` import all derive from it); ② serve `/notice-center-sounds/<id>.mp3` |
228
+ | **Browser half** | `lib/client.cjs` | Browser (plugin bundle) | favicon state machine + notification state machine + sound library + settings page |
229
+ | **Bundle patch** | `cordis.patch.yml` | Loader layer | Insert one row `id: notice-center` into the plugin tree (that id IS the settings namespace) |
230
+
231
+ **The host half's two optional channels**:
232
+
233
+ - `ctx.inject(["settings"], …)` — declares "this plugin ships its own page": `settings.configure({ auto: false }, ctx.fiber)`. The owner must be the plugin's own fiber (`describe()` looks the policy up by `entry.fiber`, and the `inject` child is a different fiber)
234
+ - `ctx.inject(["webServer"], …)` — mounts the sound route. When the service is absent the route is not mounted and the browser half falls back to the built-in chime
235
+
236
+ **The settings contract (since 0.1.7 — the easiest thing to break silently here)**:
237
+
238
+ - The form is derived from the **plugin's own Config**: dsh-settings' `describe()` reads the entry fiber's `runtime.Config`, and the entry id is the namespace — the plugin **no longer registers a namespace** (the old `settings.register(ns, schema)` was removed in 0.1.7).
239
+ - **Every field must be `.volatile()`**: `volatileForm()` only projects fields under the nearest volatile ancestor, so one unmarked field disappears from the settings page entirely (`test/host-half.smoke.mjs` asserts this field by field). `.volatile()` requires **schemastery ≥ 3.18.4**.
240
+ - Existing users need no manual migration: at startup dsh-settings reads `<DSH_HOME>/settings.yaml`, writes each section into the entry carrying the **same id**, then renames the file to `settings.yaml.imported`. Our section name equals the entry id (`notice-center`).
241
+
242
+ **Why the host half does not import `@deepseek-ai/dsh-settings`**: it keeps the host half's only runtime dependency at `@deepseek-ai/schemastery` — and because profiles default to `autoInstallPeers: false`, resolving any `@deepseek-ai` peer would actually fail on a version mismatch and break loading. This is what makes a clean registry install possible.
243
+
244
+ **Where the browser half gets its data** (all official client services; the plugin holds no state of its own):
245
+
246
+ | Service | Provided by | Used for |
247
+ |---|---|---|
248
+ | `configForms` | `@deepseek-ai/dsh-client-ui-settings` | `configForms.get(entryId)` reads and writes settings (the old `settingsScope` was removed in 0.1.7); the snapshot carries `status` / `writable` |
249
+ | `sessions` | `@deepseek-ai/dsh-api-session-controller/client` | Session list snapshot: `origin` / `running` / `displayTitle` |
250
+ | `uiSession` | `@deepseek-ai/dsh-client-ui-session` | `sessionStatus`: `Map<sessionId, { running, pendingInteraction, completionUnread }>` — 0.1.7 removed both the row fields `completed` / `pendingInteraction` and the `pendingInteractions` store; the official workspace session rows use the same mapping (`completed` ≡ `completionUnread`) |
251
+
252
+ The sound route is deliberately narrow: only `^[a-z0-9-]+\.mp3$` filenames are served (no path traversal), and because the audio is a package constant it is cached `immutable`.
253
+
254
+ ## Project layout
255
+
256
+ ```
257
+ package.json package metadata (scripts: test / test:host / test:client)
258
+ cordis.patch.yml bundle patch layer (insert id: notice-center)
259
+ lib/index.mjs host half: settings schema + sound static route
260
+ lib/client.cjs browser half: favicon machine + notification machine + sound library + settings page
261
+ assets/audio/*.mp3 45 opencode sounds (MIT; see assets/audio/README.md)
262
+ docs/images/ README screenshots, banners and the social-preview card
263
+ test/host-half.smoke.mjs host-half smoke test (164 lines)
264
+ test/client-half.smoke.mjs browser-half smoke test: state machine + real settings-page render (687 lines)
265
+ .github/workflows/test.yml CI: runs pnpm test on push / PR
266
+ .github/workflows/publish.yml release: tag-triggered, OIDC, no token
122
267
  ```
123
268
 
124
- It is already listed in `.gitignore` — **never commit it** (especially not when it holds a real token).
269
+ ## Life of a notification
125
270
 
126
- ## Requirements
271
+ ```
272
+ sessions.list changes
273
+ └─ sync()
274
+ ├─ trackEdges() running true->false edge (compensation when the current session finishes)
275
+ ├─ detectTransitions() official completed false->true; new sessions in pendingInteractions
276
+ │ └─ queueNotification(kind, sessionId, label, typeLabel)
277
+ │ ├─ master switch off -> dropped
278
+ │ ├─ this same completion already queued -> dropped (no double-send)
279
+ │ └─ enqueued + 300ms aggregation window
280
+ └─ targetOf() -> favicon becomes green / amber / default
281
+
282
+ 300ms later, flushNotifications()
283
+ ├─ permission not granted -> dropped
284
+ ├─ page in foreground and foreground notify off -> dropped
285
+ ├─ group by kind -> one Notification per group (title = session name, body = event type)
286
+ └─ play the selected sound
287
+ ```
127
288
 
128
- - A stock DeepSeek Harness (measured here on Harness 0.1.5-rc.1 + client packages 0.1.5-rc.2; supported from 0.1.2-rc.1)
129
- - System notifications need a **secure context**: `http://127.0.0.1:3080` and `localhost` work; over a **LAN IP** the browser's Notification API is unavailable
289
+ Notifications deliberately carry **no `tag`**: the same session reuses the same tag across runs, and Windows/Chrome treats the later one as an *update* to an existing notification — it replaces silently and never raises a banner again.
130
290
 
131
- ## Uninstall
291
+ ## Local development
132
292
 
133
293
  ```sh
134
- dsh plugin --profile web remove dsh-notice-center
294
+ pnpm install # the only runtime dependency is @deepseek-ai/schemastery
295
+ npm test # host-half + browser-half smoke tests
135
296
  ```
136
297
 
137
- Removing the dependency and the `dsh.profile.bundles` entry leaves nothing behind after a restart.
298
+ **The restart rule has two halves — this is the easiest thing to trip over:**
138
299
 
139
- ## Known limitations
300
+ | What you changed | Restart Harness? | Why |
301
+ |---|---|---|
302
+ | `lib/client.cjs` (browser half) | **No** — a page refresh is cleanest | Client HMR stat-polls the bundle and tells the browser to reload that plugin |
303
+ | `lib/index.mjs` (host half) | **Yes, mandatory** | Host-half hot reload relies on `cordis-plugin-hmr` (needs the loader to run with `--expose-internals`), which does not work in practice. **Schema changes (adding/removing settings) also need a restart** — otherwise the settings page write fails validation, which looks like "the switch does nothing" |
304
+ | First install, any rename | **Yes, mandatory** | The plugin tree and bundle list changed |
140
305
 
141
- - **No notifications once the tab is closed** (Web Push needs a server plus HTTPS, which a local plugin cannot reasonably provide; covering that would need a host-side toast)
142
- - The Notification API is unavailable in a non-secure context (LAN IP)
143
- - The green light is lost on page reload: that is the memory-only semantics of the official finished signal, not a bug
144
- - System-wide do-not-disturb (Windows Focus Assist and friends) swallows notifications and the plugin cannot tell
306
+ Debugging: browser-half errors land in the DevTools console; a successful host-half registration logs `dsh-notice-center: settings namespace "notice-center" registered` to the Harness console.
145
307
 
146
- ## Development
308
+ ## Tests
147
309
 
148
310
  ```sh
149
- npm test # host-half + client-half smoke tests
311
+ npm test # both (this is what CI runs)
150
312
  npm run test:host # host half only
151
- npm run test:client # client half only
313
+ npm run test:client # browser half only
152
314
  ```
153
315
 
154
- CI runs the same suite on push and pull requests (`.github/workflows/test.yml`).
316
+ Both smoke tests are **zero-dependency, pure Node** — no browser needed:
317
+
318
+ - **Host half** (164 lines): actually executes `apply()`, asserts only the expected services are injected, checks schema defaults and validation, exercises the sound route (serves an mp3 / 404 for unknown / rejects path traversal / all 45 packaged files present) and verifies that an existing `settings.yaml` passes the schema **with every field preserved verbatim**
319
+ - **Browser half** (687 lines): stubs `window.__ModuleLoader__` / `document` / `Notification`, evaluates the bundle and takes its factory, assembles `apply()` with a fake `ctx`, then drives state transitions and asserts the `Notification` constructor arguments. The settings page is **really rendered** against a recording stub (React element tree), asserting switch order, that each control writes only its own field, collapse behaviour, the footer, and more
320
+
321
+ Invariants pinned by the tests — do not break these:
322
+
323
+ - `PLUGIN_VERSION` / `PLUGIN_NAME` / `PLUGIN_REPO` in `lib/client.cjs` must match `package.json`
324
+ - The `SOUND_PACKS` library must map **one-to-one** onto the files in `assets/audio/`; adding a sound means updating it in **two places**: `lib/client.cjs` and `test/client-half.smoke.mjs`
325
+ - The `zh` and `en` dictionaries must have identical key sets
155
326
 
156
327
  ## Releasing
157
328
 
158
- Releases are driven by **tags** through [`.github/workflows/publish.yml`](.github/workflows/publish.yml) — everyday commits and pushes **never publish**:
329
+ Releasing is driven by a **tag-triggered** [`publish.yml`](.github/workflows/publish.yml) — ordinary commits and pushes **never publish**:
159
330
 
160
331
  | Intent | Command | Result |
161
332
  |---|---|---|
162
- | Commit only (feature not verified yet) | `git commit` + `git push` | `test.yml` runs; npm is untouched |
163
- | Pre-release (for testers) | set the version to `1.3.0-rc.1` → commit → `git tag v1.3.0-rc.1 && git push origin v1.3.0-rc.1` | published to the **`next`** tag (`npm i dsh-notice-center@next`); `latest` stays put |
164
- | Real release | set the version to `1.3.0` → commit → `git tag v1.3.0 && git push origin v1.3.0` | published to **`latest`**, with provenance attached |
165
- | Validate the pipeline only | Actions → publish → Run workflow | install + tests + `npm pack --dry-run`, **no publish** |
333
+ | Commit only (feature not validated yet) | `git commit` + `git push` | Only `test.yml` runs; npm sees nothing |
334
+ | Prerelease (for testers) | set version to `1.3.0-rc.1` → commit → `git tag v1.3.0-rc.1 && git push origin v1.3.0-rc.1` | Published to the **`next`** tag; `latest` untouched |
335
+ | Stable release | set version to `1.3.0` → commit → `git tag v1.3.0 && git push origin v1.3.0` | Published to **`latest`**, with provenance attached |
336
+ | Validate the pipeline only | Actions → publish → Run workflow | Installs, tests and runs `npm pack --dry-run` — **publishes nothing** |
166
337
 
167
- Three gates are built into the workflow: **the tag must equal the `package.json` version**, **the tagged commit must already be on `main`**, and **the tests must pass**. Any of them failing stops the publish.
338
+ The workflow has **three gates**; failing any one blocks the release:
168
339
 
169
- ### One-time setup: npm Trusted Publishing (OIDC)
340
+ 1. The tag must match the `package.json` version
341
+ 2. The tagged commit must already be on `main`
342
+ 3. Tests must pass
170
343
 
171
- Publishing needs no npm token at all, so there is nothing to leak or rotate.
344
+ The publish step is also **idempotent**: if that version already exists on the registry it is skipped, so re-tagging or re-running never fails the build.
172
345
 
173
- Open the package management page → **Publishing Access** → *Trusted publishers* → **Add a trusted publisher** → **GitHub Actions**:
346
+ Before tagging, add this release to [`CHANGELOG.md`](./CHANGELOG.md) (user-visible changes plus compatibility notes) — it is the source of the release notes shown on the npm page and the GitHub Release.
347
+
348
+ ### One-time setup: npm Trusted Publishing (OIDC)
349
+
350
+ Publishing needs no npm token at all (so there is nothing to expire or leak). Open the package page → **Publishing Access** → *Trusted publishers* → **Add a trusted publisher** → choose **GitHub Actions**:
174
351
 
175
352
  <https://www.npmjs.com/package/dsh-notice-center/access>
176
353
 
@@ -178,34 +355,36 @@ Open the package management page → **Publishing Access** → *Trusted publishe
178
355
  |---|---|
179
356
  | Organization or user | `SCP-QQ` |
180
357
  | Repository | `dsh-notice-center` |
181
- | Workflow filename | `publish.yml` (the **filename only**, no path; renaming the file means updating this, or publishes get a 403) |
182
- | Environment name | `npm` (must match the `environment:` in the workflow) |
183
- | **Allowed actions → Allow npm publish** | ✅ **must be checked** |
358
+ | Workflow filename | `publish.yml` (**filename only**, no path; renaming the file requires updating this or publishing returns 403) |
359
+ | Environment name | `npm` (must match the workflow's `environment:`) |
360
+ | **Allowed actions → Allow npm publish** | ✅ **required** |
184
361
 
185
- > ⚠️ That checkbox is new npm behaviour: a trusted publisher may only `npm stage publish` unless *Allow npm publish* is ticked, and this workflow's `npm publish` would be rejected.
186
- > If you would rather have it stage-only and publish by hand in the browser, leave it unchecked and change the workflow's last step to `npm stage publish`.
362
+ > ⚠️ That checkbox is new npm behaviour: a trusted publisher may **only `npm stage publish`** by default, so without *Allow npm publish* the workflow's `npm publish` is rejected.
363
+ > If you would rather have "staged only, human approves in the browser", leave it unchecked and change the last step to `npm stage publish`.
187
364
 
188
- - Publishing over OIDC makes npm attach **provenance automatically** (no `--provenance` flag), and the package page shows it
189
- - To require a human click even after a tag is pushed, add **Required reviewers** to the `npm` environment under the repository's Settings → Environments; publishing works without it too
190
- - Manual local publishing can still fall back to a granular token (see the `.npmrc` usage in Installation, step 5)
365
+ - With OIDC, npm attaches **provenance automatically** (no `--provenance` flag); the package page shows the attestation
366
+ - To require a manual approval after pushing a tag: repository Settings → Environments → `npm` → add **Required reviewers** (optional)
367
+ - Publishing from your own machine is still possible with a granular token: put `//registry.npmjs.org/:_authToken=<token>` in an `.npmrc` at the project root. That file is already in `.gitignore` — **never commit it**
191
368
 
192
- ## Layout
369
+ ## Design constraints
193
370
 
194
- ```
195
- package.json package metadata (dsh-notice-center)
196
- cordis.patch.yml bundle patch layer (insert id: notice-center)
197
- lib/index.mjs host half: settings schema + the /notice-center-sounds route
198
- lib/client.cjs browser half: favicon state machine + notifications + sounds + settings UI
199
- assets/audio/*.mp3 45 opencode sound clips (MIT; see assets/audio/README.md)
200
- docs/images/*.png screenshots used by the READMEs
201
- test/host-half.smoke.mjs host-half smoke test
202
- test/client-half.smoke.mjs client-half smoke test (npm test runs both)
203
- .github/workflows/test.yml CI: pnpm test on push / PR
204
- ```
371
+ Read these before changing code — each one came from a real bug:
372
+
373
+ 1. **No `tag` on notifications** — the same tag is treated as an *update* and silently replaces (see "Life of a notification")
374
+ 2. **"Foreground" is `tab visible && window focused`** — using `visibilityState` alone misreads "browser buried behind another app" as "the user is looking", which is exactly when a notification matters most
375
+ 3. **Amber beats green** — matching the official `sessionStatuses`; a pending interaction is the primary status
376
+ 4. **The turn duration comes from observing the `running` edge** — a session already running when the page loads has no known start, so no duration is shown (never guessed)
377
+ 5. **The host half keeps zero `@deepseek-ai` runtime dependencies** — only `schemastery`, otherwise a registry install breaks on peer resolution
378
+ 6. **The sound route only allows `^[a-z0-9-]+\.mp3$`** — the filename goes straight into a path join, so it must be whitelisted
379
+
380
+ ## Feedback and contributing
381
+
382
+ - Issues and ideas: <https://github.com/SCP-QQ/dsh-notice-center/issues>
383
+ - Run `npm test` before proposing a change; settings-related changes should update the assertions in `test/*.smoke.mjs`
384
+ - Sound assets come from [anomalyco/opencode](https://github.com/anomalyco/opencode) (MIT) — keep [`assets/audio/README.md`](./assets/audio/README.md) in sync when replacing or removing them
205
385
 
206
386
  ## License
207
387
 
208
- MIT; the full text is in [`LICENSE`](./LICENSE).
388
+ MIT — see [`LICENSE`](./LICENSE) for the full text.
209
389
 
210
- The sound clips (`assets/audio/*.mp3`, 45 files) come from [anomalyco/opencode](https://github.com/anomalyco/opencode),
211
- specifically `packages/ui/src/assets/audio/`. Upstream ships them under the MIT licence, and the same licence and attribution are kept here; if upstream changes its licensing, replace or drop this directory (see [`assets/audio/README.md`](./assets/audio/README.md)).
390
+ The 45 sound files in `assets/audio/*.mp3` come from [anomalyco/opencode](https://github.com/anomalyco/opencode) (`packages/ui/src/assets/audio/`), released upstream under MIT; the same licence and attribution are kept here. If upstream changes its terms, replace or remove this directory.