dsh-notice-center 1.2.0 → 1.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/README.en.md ADDED
@@ -0,0 +1,211 @@
1
+ # dsh-notice-center 🔔🐋
2
+
3
+ [中文](./README.md) | **English**
4
+
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
+ [![npm](https://img.shields.io/npm/v/dsh-notice-center.svg)](https://www.npmjs.com/package/dsh-notice-center)
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.
9
+
10
+ Package `dsh-notice-center`, plugin instance id `notice-center`, settings namespace `notice-center`.
11
+
12
+ ## Screenshots
13
+
14
+ Settings → **Notification center** (a sidebar entry):
15
+
16
+ <img src="docs/images/settings.png" width="440" alt="Notification center settings">
17
+
18
+ ## Features
19
+
20
+ ### 1. Whale status light (favicon state machine)
21
+
22
+ | Whale colour | Meaning | When it clears |
23
+ |---|---|---|
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):
29
+
30
+ ![Tab status light](docs/images/tab-status-light.png)
31
+
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.
35
+
36
+ ### 2. System notifications
37
+
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
47
+
48
+ | Finished (green) | Pending (amber) |
49
+ |---|---|
50
+ | ![Finished notification](docs/images/notification-done.png) | ![Pending notification](docs/images/notification-pending.png) |
51
+
52
+ > Notifications are raised by the browser (Chrome here), so the card shows the browser and the origin `127.0.0.1:3080`.
53
+
54
+ ### 3. Sound library
55
+
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.
57
+
58
+ | Pack | Entries |
59
+ |---|---|
60
+ | Built-in (synthesised) | Chime Up (default for finished, a rising two-tone), Chime Down (default for pending, a falling two-tone) |
61
+ | Alert | Alert 01–10 |
62
+ | Bip-bop | Bip-bop 01–10 |
63
+ | Staplebops | Staplebops 01–07 |
64
+ | Nope | Nope 01–12 |
65
+ | Yup | Yup 01–06 |
66
+
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
80
+
81
+ ## Installation
82
+
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.
84
+
85
+ ### 1. From npm (recommended)
86
+
87
+ ```sh
88
+ dsh plugin --profile web add dsh-notice-center
89
+ ```
90
+
91
+ Package page: <https://www.npmjs.com/package/dsh-notice-center> (`latest` is the stable line; pre-releases live on the `next` tag)
92
+
93
+ ### 2. Straight from GitHub (no publish needed)
94
+
95
+ ```sh
96
+ dsh plugin --profile web add github:SCP-QQ/dsh-notice-center
97
+ # 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
99
+ ```
100
+
101
+ ### 3. From a tarball (offline / internal distribution)
102
+
103
+ ```sh
104
+ npm pack # produces dsh-notice-center-<version>.tgz
105
+ dsh plugin --profile web add ./dsh-notice-center-<version>.tgz
106
+ ```
107
+
108
+ ### 4. Local link (while editing the code)
109
+
110
+ Add `"dsh-notice-center": "link:<project dir>/dsh-notice-center"` to the profile's `dependencies`, and `"dsh-notice-center"` to `dsh.profile.bundles`.
111
+
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.
113
+
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.
115
+
116
+ ### 5. A local `.npmrc` for manual publishing (optional)
117
+
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:
119
+
120
+ ```sh
121
+ //registry.npmjs.org/:_authToken=<your granular token>
122
+ ```
123
+
124
+ It is already listed in `.gitignore` — **never commit it** (especially not when it holds a real token).
125
+
126
+ ## Requirements
127
+
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
130
+
131
+ ## Uninstall
132
+
133
+ ```sh
134
+ dsh plugin --profile web remove dsh-notice-center
135
+ ```
136
+
137
+ Removing the dependency and the `dsh.profile.bundles` entry leaves nothing behind after a restart.
138
+
139
+ ## Known limitations
140
+
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
145
+
146
+ ## Development
147
+
148
+ ```sh
149
+ npm test # host-half + client-half smoke tests
150
+ npm run test:host # host half only
151
+ npm run test:client # client half only
152
+ ```
153
+
154
+ CI runs the same suite on push and pull requests (`.github/workflows/test.yml`).
155
+
156
+ ## Releasing
157
+
158
+ Releases are driven by **tags** through [`.github/workflows/publish.yml`](.github/workflows/publish.yml) — everyday commits and pushes **never publish**:
159
+
160
+ | Intent | Command | Result |
161
+ |---|---|---|
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** |
166
+
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.
168
+
169
+ ### One-time setup: npm Trusted Publishing (OIDC)
170
+
171
+ Publishing needs no npm token at all, so there is nothing to leak or rotate.
172
+
173
+ Open the package management page → **Publishing Access** → *Trusted publishers* → **Add a trusted publisher** → **GitHub Actions**:
174
+
175
+ <https://www.npmjs.com/package/dsh-notice-center/access>
176
+
177
+ | Field | Value |
178
+ |---|---|
179
+ | Organization or user | `SCP-QQ` |
180
+ | 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** |
184
+
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`.
187
+
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)
191
+
192
+ ## Layout
193
+
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
+ ```
205
+
206
+ ## License
207
+
208
+ MIT; the full text is in [`LICENSE`](./LICENSE).
209
+
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)).
package/README.md CHANGED
@@ -1,8 +1,11 @@
1
1
  # dsh-notice-center 🔔🐋
2
2
 
3
+ **中文** | [English](./README.en.md)
4
+
3
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
+ [![npm](https://img.shields.io/npm/v/dsh-notice-center.svg)](https://www.npmjs.com/package/dsh-notice-center)
4
7
 
5
- DeepSeek Harness 的**通知中心**:把标签页鲸鱼图标变成状态灯,并在你不在看的时候主动提醒你 —— 会话跑完了,或者有事情等你拍板。
8
+ DeepSeek Harness 的**通知中心**:把标签页上的鲸鱼图标变成状态灯,并在你不在看的时候主动提醒你 —— 会话跑完了,或者有事情等你拍板。
6
9
 
7
10
  包名 `dsh-notice-center`、插件实例 id `notice-center`、设置命名空间 `notice-center`。
8
11
 
@@ -18,28 +21,28 @@ DeepSeek Harness 的**通知中心**:把标签页鲸鱼图标变成状态灯
18
21
 
19
22
  | 鲸鱼颜色 | 含义 | 什么时候恢复 |
20
23
  |---|---|---|
21
- | 🟢 绿色 | 有会话在您没看它的时候**跑完了** | 点开该会话后自动恢复;全部看完回黑 |
22
- | 🟠 琥珀色 | 有会话**有事等您处理**:提问 / 审批 / 计划审核 | 处理完后自动消失 |
24
+ | 🟢 绿色 | 有会话在你没看它的时候**跑完了** | 该会话点开后自动恢复;全部点开过一遍即回到默认状态 |
25
+ | 🟠 琥珀色 | 有会话**有事等你处理**:提问 / 审批 / 计划审核 | 处理完后自动消失 |
23
26
  | ⚫ 黑色 | 一切正常(官方原版图标) | 默认状态 |
24
27
 
25
28
  标签页上的样子(绿 = 有会话跑完了):
26
29
 
27
30
  ![标签页状态灯](docs/images/tab-status-light.png)
28
31
 
29
- 颜色与侧边栏的绿点、琥珀点**用的是同一份官方信号**,永远同步。只响应主会话,子代理不影响。绿色与琥珀同时存在时显示绿色。
32
+ 颜色与侧边栏的绿点、琥珀点**用的是同一份官方信号**,永远同步。只统计主会话,子代理不计入。绿色与琥珀同时存在时**琥珀优先**——待处理是首要状态,有会话卡住等你时不会因为别的会话刚跑完而被掩盖。
30
33
 
31
34
  三种颜色都可以在设置页改(含「默认色」,不配置则沿用官方原版图标)。
32
35
 
33
36
  ### 2. 系统通知
34
37
 
35
- - **完成通知**:会话 `completed` 由 false → true 时触发
38
+ - **完成通知**:会话跑完时触发(官方 `completed` 由 false 变为 true;你正打开的那个会话官方不置位,由运行状态 true → false 判定)
36
39
  - **待处理通知**:出现新的待处理交互时触发
37
- - **通知文案**:标题=会话名(多条聚合时补 `+N`),正文=通知类型(完成=「会话已完成」;待处理=「待审批 / 向你提问 / 计划待审核」,未知类型回退到「有交互等待处理」)
38
- - **只在你不看的时候弹**(固定策略)——「前台」= 标签页可见**且**窗口有焦点;切到别的标签页、最小化、被别的应用压在后面时都会提醒,只有你正看着 DSH 时才不打扰
39
- - **聚合与去重**:300ms 窗口合并,同一会话同一类型只弹一次;多条合并为「标题 +N」
40
+ - **通知文案**:标题=会话名(多条聚合时补 `+N`),正文=通知类型(完成=「会话已完成」;待处理**按交互类型细化**=审批「待审批 · <工具名>」(拿不到工具名时就是「待审批」,工具名超 32 字截断)、提问按形态分「请你选择 / 请你多选 / 请你填写」或批量「向你提问(N 个)」、计划审核「计划待审核」,认不出的类型回退到「有交互等待处理」);**完成通知单独一条时**附本轮总用时(如「会话已完成 · 本轮总用时 2分13秒」),多条聚合时不附。格式与官方会话气泡的「本轮总用时」一致;起点靠观察 `running` 得到,刷新页面时已经在跑的会话算不出起点,就不显示这个数
41
+ - **默认只在你不看的时候弹**——「前台」= 标签页可见**且**窗口有焦点;切到别的标签页、最小化、被别的应用压在后面时都会提醒。想在前台也提醒(人还停在会话里、却已离开屏幕,例如在刷手机),打开「更多通知设置 → 前台提醒」
42
+ - **聚合与去重**:300ms 内的通知合并,同一会话同一类型只弹一次;多条合并为「标题 +N」
40
43
  - **点击通知** = 聚焦窗口并打开对应会话
41
- - **提示音**:完成 / 待处理各自可下拉选音效(**悬浮即试听**),音量可调;开启提示音时会静音系统提示音,改用插件自带音
42
- - **常驻可选**:可关闭「自动隐藏」,通知就一直留在屏幕上,直到你点击或手动关闭(默认开启自动隐藏,由系统自行收起)
44
+ - **提示音**:完成 / 待处理各自可下拉选音效(**悬浮即试听**),音量可调(**调完音量会按新音量试听当前的完成提示音**);开启后改播插件自带音效,系统默认提示音会被静音
45
+ - **常驻可选**:可关闭「自动隐藏」,通知就一直留在屏幕上,直到你点掉它(默认开启自动隐藏,由系统自行收起)
43
46
  - 通知图标跟随你配置的状态灯颜色
44
47
 
45
48
  | 完成通知(绿) | 待处理通知(琥珀) |
@@ -50,7 +53,7 @@ DeepSeek Harness 的**通知中心**:把标签页鲸鱼图标变成状态灯
50
53
 
51
54
  ### 3. 音效库
52
55
 
53
- 完成提示音与待处理提示音各有一个下拉,共 **47 个条目**:45 个 opencode 内置音效 + 2 个插件自带的合成音。
56
+ 完成提示音与待处理提示音各有一个下拉,共 **47 个条目**:45 个取自 opencode 内置音效库(就是 opencode 设置里那套 `Alert` / `Bip-bop` / `Staplebops` / `Nope` / `Yup`,同名同序号)+ 2 个插件自带的合成音。
54
57
 
55
58
  | 音效包 | 条目 |
56
59
  |---|---|
@@ -64,14 +67,14 @@ DeepSeek Harness 的**通知中心**:把标签页鲸鱼图标变成状态灯
64
67
  - **悬浮即试听**:鼠标划过下拉里的条目就播该音效(120ms 防抖,扫过整列不会连成一片)
65
68
  - **点选即生效**:选中后该类型通知就播这个音,当前项打 `✓`
66
69
  - **样式照官方**:触发按钮=官方语言下拉的 `.selector`(36px 高、18px 圆角胶囊);面板=官方 `Menu`(`bg-layer-3` + 0.5px 描边 + 6px 圆角),条目标高用官方的 hover 填充色
67
- - 音效由宿主半在 `/notice-center-sounds/<id>.mp3` 提供;路由不可用时**自动回退内置合成音**,不会变哑
70
+ - 宿主半通过 `/notice-center-sounds/<id>.mp3` 路由提供音效;路由不可用时**自动回退内置合成音**,不会变哑
68
71
  - 老配置无需迁移:没配过音效时仍用 Chime Up / Chime Down
69
72
 
70
73
  ## 使用
71
74
 
72
75
  1. **打开设置页** —— 设置 → **通知中心**
73
76
  2. **开启系统通知** —— 打开「系统通知」总开关,浏览器会弹出授权请求,选「允许」(被拒绝时设置页会提示,需要到浏览器的站点设置里恢复)
74
- 3. **调音量 / 选音效** —— 展开「更多通知设置」:拖音量条;在「完成提示音 / 待处理提示音」下拉里**鼠标划过即可试听**,点一下选中
77
+ 3. **调音量 / 选音效 / 前台提醒** —— 展开「更多通知设置」:拖音量条,**松手后会按新音量试听完成提示音**;在「完成提示音 / 待处理提示音」下拉里**鼠标划过即可试听**,点一下选中;想让正看着页面时也提醒,打开「前台提醒」
75
78
  4. **改颜色(可选)** —— 展开「鲸鱼状态灯」:点色块或直接填 `#RRGGBB`,行尾 ↺ 恢复默认
76
79
  5. **就这样** —— 之后你切走或最小化时,会话跑完 / 有事等你都会收到通知;点通知直接跳回那个会话
77
80
 
@@ -86,6 +89,8 @@ DeepSeek Harness 的**通知中心**:把标签页鲸鱼图标变成状态灯
86
89
  dsh plugin --profile web add dsh-notice-center
87
90
  ```
88
91
 
92
+ 包页:<https://www.npmjs.com/package/dsh-notice-center>(`latest` 为稳定版;预发布版在 `next` 通道)
93
+
89
94
  ### 2. 直接从 GitHub 装(无需先发布)
90
95
 
91
96
  ```sh
@@ -97,8 +102,8 @@ dsh plugin --profile web add git+ssh://git@github.com/SCP-QQ/dsh-notice-center.g
97
102
  ### 3. 从 tarball 装(离线 / 内网分发)
98
103
 
99
104
  ```sh
100
- npm pack # 生成 dsh-notice-center-1.2.0.tgz
101
- dsh plugin --profile web add ./dsh-notice-center-1.2.0.tgz
105
+ npm pack # 生成 dsh-notice-center-<版本>.tgz
106
+ dsh plugin --profile web add ./dsh-notice-center-<版本>.tgz
102
107
  ```
103
108
 
104
109
  ### 4. 本地 link(改代码时用)
@@ -112,6 +117,16 @@ profile 的 `dependencies` 加
112
117
 
113
118
  **重启要求分两半**:改浏览器半 `lib/client.cjs` 免重启(HMR 会 stat-poll 并通知浏览器重载该插件 bundle,刷新页面可确保干净重载);**改宿主半 `lib/index.mjs` 必须重启 Harness**(宿主半热重载依赖 `cordis-plugin-hmr`,需 loader 带 `--expose-internals`,实测未生效)。首次安装与任何改名也必须重启。
114
119
 
120
+ ### 5. 本机手动发布用的 `.npmrc`(可选)
121
+
122
+ CI 走 OIDC 发布,**不需要任何 token**。只有当你打算从本机手动 `npm publish` 时,才在项目根目录放一个 `.npmrc`:
123
+
124
+ ```sh
125
+ //registry.npmjs.org/:_authToken=<你的 granular token>
126
+ ```
127
+
128
+ 该文件已在 `.gitignore` 里,**绝不要提交**(仓库里有真实 token 时尤其注意)。
129
+
115
130
  ## 要求
116
131
 
117
132
  - 官方版本 DeepSeek Harness(本机实测:Harness 0.1.5-rc.1 + 客户端包 0.1.5-rc.2,适配范围 0.1.2-rc.1+)
@@ -129,7 +144,7 @@ dsh plugin --profile web remove dsh-notice-center
129
144
 
130
145
  - 浏览器**标签页关闭后收不到通知**(Web Push 需要服务端 + HTTPS,本地插件场景不现实;要覆盖需宿主 Toast 方案)
131
146
  - 非安全上下文(局域网 IP)下 Notification API 不可用
132
- - 刷新页面后绿色状态丢失:这是官方「已完成通知」的内存态语义,属正常现象
147
+ - 刷新页面后绿色状态丢失:该状态只存在内存中,刷新即丢,属官方信号本身的行为
133
148
  - 系统级勿扰模式(Windows 专注助手等)会吞掉通知,插件无法感知
134
149
 
135
150
  ## 开发
@@ -142,6 +157,42 @@ npm run test:client # 只跑浏览器半
142
157
 
143
158
  CI 在 push / PR 时跑同一套(`.github/workflows/test.yml`)。
144
159
 
160
+ ## 发布
161
+
162
+ 发布由 **tag 触发**的 [`.github/workflows/publish.yml`](.github/workflows/publish.yml) 负责 —— 日常 commit / push **不会发包**:
163
+
164
+ | 意图 | 命令 | 结果 |
165
+ |---|---|---|
166
+ | 只提交(功能还没验证完) | `git commit` + `git push` | 只跑 `test.yml`,npm 上毫无动静 |
167
+ | 预发布(给人试用) | 版本改 `1.3.0-rc.1` → commit → `git tag v1.3.0-rc.1 && git push origin v1.3.0-rc.1` | 发到 **`next`** 通道(`npm i dsh-notice-center@next`),`latest` 不变 |
168
+ | 正式发版 | 版本改 `1.3.0` → commit → `git tag v1.3.0 && git push origin v1.3.0` | 发到 **`latest`**,自动附带 provenance |
169
+ | 只验证发布流程 | Actions → publish → Run workflow | 装依赖 + 跑测试 + `npm pack --dry-run`,**不发布** |
170
+
171
+ 工作流内建三道闸:**tag 与 `package.json` 版本必须一致**、**tag 指向的提交必须已在 `main` 上**、**测试必须通过**。任一不过就发不出去。
172
+
173
+ ### 一次性配置:npm Trusted Publishing (OIDC)
174
+
175
+ 发布不需要任何 npm 令牌(也就没有令牌过期 / 泄漏的问题)。
176
+
177
+ 打开包管理页 → **Publishing Access** → *Trusted publishers* → **Add a trusted publisher** → 选 **GitHub Actions**:
178
+
179
+ <https://www.npmjs.com/package/dsh-notice-center/access>
180
+
181
+ | 字段 | 值 |
182
+ |---|---|
183
+ | Organization or user | `SCP-QQ` |
184
+ | Repository | `dsh-notice-center` |
185
+ | Workflow filename | `publish.yml`(**只填文件名**,不带路径;重命名该文件必须同步改这里,否则 403) |
186
+ | Environment name | `npm`(与工作流里的 `environment:` 一致) |
187
+ | **Allowed actions → Allow npm publish** | ✅ **必须勾** |
188
+
189
+ > ⚠️ 那个勾选框是 npm 新版行为:trusted publisher **默认只允许 `npm stage publish`(暂存)**,不勾 *Allow npm publish* 的话,本工作流里的 `npm publish` 会被拒。
190
+ > 若你更想要「只能暂存、必须人工在浏览器批准才真发布」,就别勾,并把工作流末步改成 `npm stage publish`。
191
+
192
+ - 走 OIDC 发布时 npm **自动附带 provenance**(无需 `--provenance`),包页会显示来源证明
193
+ - 想要「tag 推上去还要人工点一下才发」:仓库 Settings → Environments → `npm` → 加 **Required reviewers**;不加也能正常发布
194
+ - 本机手动发布仍可用 granular token 兜底(见上文「安装」第 5 节的 `.npmrc` 用法)
195
+
145
196
  ## 目录结构
146
197
 
147
198
  ```
package/lib/client.cjs CHANGED
@@ -29,6 +29,7 @@ const NOTIFY_DEFAULTS = {
29
29
  notifySound: true,
30
30
  notifyVolume: 0.6,
31
31
  notifyAutoHide: true,
32
+ notifyForeground: false,
32
33
  notifyDoneSound: "builtin-up",
33
34
  notifyPendingSound: "builtin-down"
34
35
  };
@@ -441,6 +442,24 @@ function playSound(id, volume, kind) {
441
442
  const notifySound = value.notifySound ?? NOTIFY_DEFAULTS.notifySound;
442
443
  const notifyVolume = normalizeVolume(value.notifyVolume ?? NOTIFY_DEFAULTS.notifyVolume);
443
444
  const notifyAutoHide = value.notifyAutoHide ?? NOTIFY_DEFAULTS.notifyAutoHide;
445
+ const notifyForeground = value.notifyForeground ?? NOTIFY_DEFAULTS.notifyForeground;
446
+ /** 音量试听的防抖计时器(拖动过程中不连响,停手后放一遍)。 */
447
+ const volumeTimer = (0, react.useRef)(void 0);
448
+ (0, react.useEffect)(() => () => {
449
+ if (volumeTimer.current !== void 0) clearTimeout(volumeTimer.current);
450
+ }, []);
451
+ /** 改音量后试听当前设置的完成提示音:250ms 防抖,只在停手后按新音量放一次。
452
+ * primeChime 必须在 onChange 这个用户手势里调,否则防抖后 resume 可能被自动播放策略拦。 */
453
+ const previewVolume = (next) => {
454
+ scope.set("notifyVolume", next);
455
+ primeChime();
456
+ if (volumeTimer.current !== void 0) clearTimeout(volumeTimer.current);
457
+ volumeTimer.current = setTimeout(() => {
458
+ volumeTimer.current = void 0;
459
+ const snapshot = scope.getSnapshot().value ?? {};
460
+ playSound(snapshot.notifyDoneSound ?? NOTIFY_DEFAULTS.notifyDoneSound, next, "done");
461
+ }, 250);
462
+ };
444
463
  /** 官方设置行规格(对齐「通用设置 → 权限」字段的 row/title/desc 样式)。 */
445
464
  const rowStyle = {
446
465
  display: "flex",
@@ -484,9 +503,16 @@ function playSound(id, volume, kind) {
484
503
  color: "var(--dsw-alias-label-secondary)",
485
504
  cursor: "pointer"
486
505
  };
487
- /** 分组条目:主标题 + 描述 + 官方 Switch + 折叠展开箭头(子项由展开态控制显示)。 */
506
+ /** 分组条目:主标题 + 描述 + 官方 Switch + 折叠展开箭头(子项由展开态控制显示)。
507
+ * 整行可点:鼠标落在行内任意处都能折叠/展开,不必去够最右边的箭头。
508
+ * 开关与箭头各自拦住冒泡 —— 否则点开关会连带折叠,点箭头会折叠两次(等于没反应)。
509
+ * 箭头仍是带 aria-expanded 的真按钮,键盘可达;整行点击只是给鼠标加热区。 */
488
510
  const sectionRow = (key, title, hint, checked, onChange, expanded, onToggle, expandLabel) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
489
- style: rowStyle,
511
+ style: {
512
+ ...rowStyle,
513
+ cursor: "pointer"
514
+ },
515
+ onClick: onToggle,
490
516
  children: [
491
517
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
492
518
  style: rowTextStyle,
@@ -501,17 +527,27 @@ function playSound(id, volume, kind) {
501
527
  })
502
528
  ]
503
529
  }),
504
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(primitives.Switch, {
505
- checked,
506
- label: title,
507
- onChange: (next) => onChange(typeof next === "boolean" ? next : !checked)
530
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
531
+ style: {
532
+ flex: "0 0 auto",
533
+ display: "inline-flex"
534
+ },
535
+ onClick: (event) => event?.stopPropagation?.(),
536
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(primitives.Switch, {
537
+ checked,
538
+ label: title,
539
+ onChange: (next) => onChange(typeof next === "boolean" ? next : !checked)
540
+ })
508
541
  }),
509
542
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
510
543
  type: "button",
511
544
  "aria-expanded": expanded,
512
545
  "aria-label": expandLabel,
513
546
  title: expandLabel,
514
- onClick: onToggle,
547
+ onClick: (event) => {
548
+ event?.stopPropagation?.();
549
+ onToggle();
550
+ },
515
551
  style: iconButtonStyle,
516
552
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
517
553
  style: {
@@ -572,7 +608,7 @@ function playSound(id, volume, kind) {
572
608
  step: 5,
573
609
  value: Math.round(notifyVolume * 100),
574
610
  "aria-label": t("notifyVolume"),
575
- onChange: (e) => scope.set("notifyVolume", Number(e.target.value) / 100),
611
+ onChange: (e) => previewVolume(Number(e.target.value) / 100),
576
612
  style: {
577
613
  flex: "0 0 auto",
578
614
  width: 140,
@@ -716,6 +752,7 @@ function playSound(id, volume, kind) {
716
752
  colorsExpanded ? colorRow("black", t("blackLabel"), () => scope.unset("black")) : null,
717
753
  sectionRow("notifyEnabled", t("groupNotify"), t("notifyEnabledHint"), notifyEnabled, setNotifyEnabled, notifyExpanded, () => setNotifyExpanded((current) => !current), t("notifyExpand")),
718
754
  notifyExpanded ? switchRow("notifyAutoHide", t("notifyAutoHide"), t("notifyAutoHideHint"), notifyAutoHide, (next) => scope.set("notifyAutoHide", next)) : null,
755
+ notifyExpanded ? switchRow("notifyForeground", t("notifyForeground"), t("notifyForegroundHint"), notifyForeground, (next) => scope.set("notifyForeground", next)) : null,
719
756
  notifyExpanded ? switchRow("notifySound", t("notifySound"), void 0, notifySound, (next) => scope.set("notifySound", next)) : null,
720
757
  notifyExpanded ? volumeRow() : null,
721
758
  notifyExpanded ? soundRow("done", t("notifyDoneSound")) : null,
@@ -747,6 +784,22 @@ function playSound(id, volume, kind) {
747
784
  "locale",
748
785
  "settingsScope"
749
786
  ];
787
+ /**
788
+ * 本轮用时文案(格式照官方 formatRunDuration:满 60 秒进位到分,秒补零)。
789
+ * 官方在会话气泡的「本轮用时和速度」里用的是同一套模板,两处读起来一致。
790
+ * @param ms - 毫秒;负数按 0 处理。
791
+ * @param t - 命名空间文案函数。
792
+ * @returns 形如「13秒」/「2分05秒」。
793
+ */
794
+ function formatRunDuration(ms, t) {
795
+ const total = Math.max(0, Math.floor(ms / 1e3));
796
+ const minutes = Math.floor(total / 60);
797
+ const seconds = total % 60;
798
+ return minutes > 0 ? t("durationMinutes", {
799
+ minutes,
800
+ seconds: String(seconds).padStart(2, "0")
801
+ }) : t("durationSeconds", { seconds });
802
+ }
750
803
  function apply(ctx) {
751
804
  const list = ctx.sessions.list;
752
805
  /** 绑定 `notice-center` 命名空间:读快照 + 写用户配置(宿主 schema 校验)。 */
@@ -766,6 +819,11 @@ function playSound(id, volume, kind) {
766
819
  let notifyTimer;
767
820
  /** 已通知过的会话完成态,用于识别 false → true 跃迁。 */
768
821
  const prevCompleted = new Map();
822
+ /** 本轮运行起点(running false→true 时记下)。首次观察时已在跑的会话不记 ——
823
+ * 那样只能算出「我们看了多久」而不是本轮用时,宁可不出这个数。 */
824
+ const runStartedAt = new Map();
825
+ /** 每会话最近一次运行的用时(毫秒):completed 跃迁那条路径晚于 running 边沿,需要它。 */
826
+ const lastRunMs = new Map();
769
827
  /** 上一轮待处理会话集合与首帧标记(首帧不通知,避免刷新页面时轰炸)。 */
770
828
  let prevPending = new Set();
771
829
  let pendingSeen = false;
@@ -783,6 +841,7 @@ function playSound(id, volume, kind) {
783
841
  sound: snapshot.notifySound ?? NOTIFY_DEFAULTS.notifySound,
784
842
  volume: normalizeVolume(snapshot.notifyVolume ?? NOTIFY_DEFAULTS.notifyVolume),
785
843
  autoHide: snapshot.notifyAutoHide ?? NOTIFY_DEFAULTS.notifyAutoHide,
844
+ foreground: snapshot.notifyForeground ?? NOTIFY_DEFAULTS.notifyForeground,
786
845
  doneSound: snapshot.notifyDoneSound ?? NOTIFY_DEFAULTS.notifyDoneSound,
787
846
  pendingSound: snapshot.notifyPendingSound ?? NOTIFY_DEFAULTS.notifyPendingSound
788
847
  };
@@ -793,8 +852,9 @@ function playSound(id, volume, kind) {
793
852
  * @param sessionId - 会话 id。
794
853
  * @param label - 会话显示名;聚合后作为通知**标题**。
795
854
  * @param typeLabel - 通知**正文**(仅 pending 用):交互类型文案;缺省回退到通用标题。
855
+ * @param durationMs - 本轮用时(仅 done 用);算不出起点时传 undefined,正文就不带这个数。
796
856
  */
797
- function queueNotification(kind, sessionId, label, typeLabel) {
857
+ function queueNotification(kind, sessionId, label, typeLabel, durationMs) {
798
858
  if (!notifyConfig().enabled) return;
799
859
  const key = sessionId + ":" + kind;
800
860
  if (notified.has(key)) return;
@@ -803,7 +863,8 @@ function playSound(id, volume, kind) {
803
863
  kind,
804
864
  sessionId,
805
865
  label,
806
- typeLabel
866
+ typeLabel,
867
+ durationMs
807
868
  });
808
869
  if (notifyTimer === void 0) notifyTimer = setTimeout(flushNotifications, 300);
809
870
  }
@@ -815,10 +876,10 @@ function playSound(id, volume, kind) {
815
876
  if (entries.length === 0) return;
816
877
  if (notificationSupport() !== "granted") return;
817
878
  const config = notifyConfig();
818
- /* 固定策略:只在页面不在前台时打扰(原先的可配置项已移除)。
819
- 「前台」= 标签页可见 且 窗口有焦点 —— 只看 visibilityState 会把「浏览器被
820
- 别的应用压在后面」误判成正在看,而那正是最该提醒的时候。 */
821
- if (isForeground()) return;
879
+ /* 前台策略:「前台」= 标签页可见 且 窗口有焦点(见 isForeground)。默认只在不在
880
+ 前台时打扰;开启「前台也提醒」(notifyForeground) 后正在看也弹 —— 用于人还停在
881
+ 当前会话、却已离开屏幕(刷手机等)的场景。 */
882
+ if (!config.foreground && isForeground()) return;
822
883
  const t = ctx.locale.bind(SETTINGS_NAMESPACE);
823
884
  const grouped = new Map();
824
885
  for (const entry of entries) {
@@ -832,7 +893,9 @@ function playSound(id, volume, kind) {
832
893
  /* 标题=会话名(聚合时补「 +N」);正文=通知类型。 */
833
894
  let title = head.label ?? head.sessionId;
834
895
  if (extra > 0) title = title + " +" + String(extra);
835
- const type = kind === "done" ? t("notifyDoneTitle") : head.typeLabel ?? t("notifyPendingTitle");
896
+ let type = kind === "done" ? t("notifyDoneTitle") : head.typeLabel ?? t("notifyPendingTitle");
897
+ /* 用时只在「单独一条完成通知」时附加:聚合时一条正文挂 N 个用时无从表达。 */
898
+ if (kind === "done" && extra === 0 && head.durationMs !== void 0) type = type + " · " + t("notifyDuration", { duration: formatRunDuration(head.durationMs, t) });
836
899
  try {
837
900
  const notification = new Notification(title, {
838
901
  body: type,
@@ -864,15 +927,39 @@ function playSound(id, volume, kind) {
864
927
  question: "pendingKindQuestion",
865
928
  "plan-review": "pendingKindPlanReview"
866
929
  };
930
+ /** 工具名上限:超长会撑破通知正文(Windows 通知一两行就截断),超出截断加省略号。 */
931
+ const TOOL_NAME_LIMIT = 32;
867
932
  /**
868
- * 待处理交互类型 → 通知正文文案(如「待审批」)。
869
- * 未知类型返回 undefined,调用方回退到通用标题 —— 官方新增交互域时不会显示错误的类型名。
870
- * @param interactionKind - pendingInteraction 的 kind 字段。
871
- * @returns 类型文案;未知类型为 undefined。
933
+ * 待处理交互 → 通知正文文案。
934
+ *
935
+ * kind 只到「审批 / 提问 / 计划待审核」这一层(官方 SessionPendingInteractionStatus
936
+ * 就这三种),更细的信息在 entry 的 domain 字段上:审批的 toolName、提问的 questions
937
+ * 批次。这里读这些字段做细化,**全部防御式读取** —— bundle 不 import 那些类型,
938
+ * 字段缺席、类型不符(第三方域自造 kind)时逐级降级,绝不显示错误的类型名。
939
+ * @param interaction - pendingInteractions 的 entry;缺席时为 undefined。
940
+ * @returns 正文文案;完全认不出时 undefined,调用方回退「有交互等待处理」。
872
941
  */
873
- function pendingTypeLabel(interactionKind) {
874
- const key = PENDING_KIND_KEYS[interactionKind];
875
- return key === void 0 ? void 0 : ctx.locale.bind(SETTINGS_NAMESPACE)(key);
942
+ function pendingTypeLabel(interaction) {
943
+ const t = ctx.locale.bind(SETTINGS_NAMESPACE);
944
+ const kind = interaction?.kind;
945
+ if (kind === "approval") {
946
+ const tool = interaction.toolName;
947
+ if (typeof tool !== "string" || tool === "") return t("pendingKindApproval");
948
+ const shown = tool.length > TOOL_NAME_LIMIT ? tool.slice(0, TOOL_NAME_LIMIT) + "…" : tool;
949
+ return t("pendingApprovalTool", { tool: shown });
950
+ }
951
+ if (kind === "question") {
952
+ const questions = Array.isArray(interaction.questions) ? interaction.questions : [];
953
+ if (questions.length > 1) return t("pendingQuestionBatch", { count: questions.length });
954
+ const first = questions[0];
955
+ /* 只有一条也要能读出「要不要动键盘」:有选项=选择,无选项=自己写。 */
956
+ if (first === null || typeof first !== "object") return t("pendingKindQuestion");
957
+ const options = Array.isArray(first.options) ? first.options : [];
958
+ if (options.length === 0) return t("pendingQuestionFill");
959
+ return first.multiSelect === true ? t("pendingQuestionMulti") : t("pendingQuestionChoose");
960
+ }
961
+ const key = PENDING_KIND_KEYS[kind];
962
+ return key === void 0 ? void 0 : t(key);
876
963
  }
877
964
  /** 状态跃迁检测:官方 completed 由 false 变 true、以及 pending store 新增会话。 */
878
965
  function detectTransitions(state) {
@@ -880,7 +967,7 @@ function playSound(id, volume, kind) {
880
967
  if (row.origin === "subagent") continue;
881
968
  const before = prevCompleted.get(row.id);
882
969
  const now = row.completed === true;
883
- if (before === false && now) queueNotification("done", row.id, row.displayTitle ?? row.title ?? row.id);
970
+ if (before === false && now) queueNotification("done", row.id, row.displayTitle ?? row.title ?? row.id, void 0, lastRunMs.get(row.id));
884
971
  if (!now) notified.delete(row.id + ":done");
885
972
  prevCompleted.set(row.id, now);
886
973
  }
@@ -895,7 +982,7 @@ function playSound(id, volume, kind) {
895
982
  const row = state.byId[id];
896
983
  if (row !== void 0 && row.origin === "subagent") continue;
897
984
  const label = row?.displayTitle ?? row?.title ?? id;
898
- queueNotification("pending", id, label, pendingTypeLabel(pendingMap?.get(id)?.kind));
985
+ queueNotification("pending", id, label, pendingTypeLabel(pendingMap?.get(id)));
899
986
  }
900
987
  for (const id of prevPending) if (!current.has(id)) notified.delete(id + ":pending");
901
988
  prevPending = current;
@@ -980,10 +1067,11 @@ function playSound(id, volume, kind) {
980
1067
  };
981
1068
  }
982
1069
  const uri = (hex) => `data:image/svg+xml,${encodeURIComponent(whaleSvg(hex))}`;
983
- /** running true→false 边沿跟踪:完成时恰好被选中且标签页不在台前 →
984
- * 记入 finishedWhileHidden。官方 syncCompletedNotifications 只报"未选中时
985
- * 完成"(`sessionId !== selected` 才 arm),选中的空缺在这里补齐。
986
- * 台前完成不记(V0-02);重新运行、会话移除均清除;只跟踪主会话。 */
1070
+ /** running true→false 边沿跟踪(只跟踪主会话):完成时恰好被选中 →
1071
+ * 绿灯记入 finishedWhileHidden(仅当标签页不在台前;台前完成不记,V0-02);
1072
+ * 通知则无条件入队,由 flush 按「前台也提醒」统一裁决。官方 syncCompletedNotifications
1073
+ * 只报"未选中时完成"(`sessionId !== selected` 才 arm),选中的空缺在这里补齐。
1074
+ * 重新运行、会话移除均清除。 */
987
1075
  function trackEdges(state) {
988
1076
  for (const row of Object.values(state.byId)) {
989
1077
  if (row.origin === "subagent") continue;
@@ -992,17 +1080,25 @@ function playSound(id, volume, kind) {
992
1080
  prevRunning.set(row.id, row.running);
993
1081
  continue;
994
1082
  }
1083
+ if (!prev && row.running) runStartedAt.set(row.id, Date.now());
995
1084
  if (prev && !row.running) {
996
- if (row.id === state.current && !isForeground()) {
997
- finishedWhileHidden.add(row.id);
998
- queueNotification("done", row.id, row.displayTitle ?? row.title ?? row.id);
999
- }
1085
+ const startedAt = runStartedAt.get(row.id);
1086
+ const elapsed = startedAt === void 0 ? void 0 : Date.now() - startedAt;
1087
+ runStartedAt.delete(row.id);
1088
+ if (elapsed !== void 0) lastRunMs.set(row.id, elapsed);
1089
+ /* 绿灯只记「没看着时跑完」;台前完成不记(V0-02)。 */
1090
+ if (row.id === state.current && !isForeground()) finishedWhileHidden.add(row.id);
1091
+ /* 通知另算:选中会话完成时官方不置 completed,这条是它的唯一来源,
1092
+ 因此「前台要不要打扰」交给 flush 按 notifyForeground 统一裁决。 */
1093
+ if (row.id === state.current) queueNotification("done", row.id, row.displayTitle ?? row.title ?? row.id, void 0, elapsed);
1000
1094
  } else if (row.running) finishedWhileHidden.delete(row.id);
1001
1095
  prevRunning.set(row.id, row.running);
1002
1096
  }
1003
1097
  for (const id of [...prevRunning.keys()]) if (!(id in state.byId)) {
1004
1098
  prevRunning.delete(id);
1005
1099
  finishedWhileHidden.delete(id);
1100
+ runStartedAt.delete(id);
1101
+ lastRunMs.delete(id);
1006
1102
  }
1007
1103
  }
1008
1104
  /** 回到前台(标签页可见且窗口有焦点)→ 选中会话的绿灯熄灭(V0-02)。 */
@@ -1015,18 +1111,21 @@ function playSound(id, volume, kind) {
1015
1111
  };
1016
1112
  document.addEventListener("visibilitychange", onForeground);
1017
1113
  window.addEventListener("focus", onForeground);
1018
- /** 绿/琥珀判定:主会话 only;绿优先。返回目标 href;null = 官方原版。 */
1114
+ /** 绿/琥珀判定:主会话 only;**琥珀优先于绿**。返回目标 href;null = 官方原版。 */
1019
1115
  function targetOf(state) {
1020
1116
  if ((scope.getSnapshot().value ?? {}).colorsEnabled === false) return null;
1021
1117
  const c = colors();
1022
1118
  const pendingMap = pendingStore?.getSnapshot();
1023
- let amber = false;
1119
+ let green = false;
1120
+ /* 顺序照官方 sessionStatuses:pending interaction 是首要状态,live activity
1121
+ 压过 completion reminder。favicon 是跨会话聚合,套用同一原则 —— 任意会话
1122
+ 「卡住等你」都压过任意会话「跑完了」,否则审批会被完成提醒整片掩盖。 */
1024
1123
  for (const row of Object.values(state.byId)) {
1025
1124
  if (row.origin === "subagent") continue;
1026
- if (row.completed === true || finishedWhileHidden.has(row.id)) return uri(c.green);
1027
- if (pendingMap?.has(row.id) || row.pendingInteraction !== void 0) amber = true;
1125
+ if (pendingMap?.has(row.id) || row.pendingInteraction !== void 0) return uri(c.amber);
1126
+ if (row.completed === true || finishedWhileHidden.has(row.id)) green = true;
1028
1127
  }
1029
- if (amber) return uri(c.amber);
1128
+ if (green) return uri(c.green);
1030
1129
  return c.black ? uri(c.black) : null;
1031
1130
  }
1032
1131
  function sync() {
@@ -1077,6 +1176,8 @@ trackEdges(state);
1077
1176
  notifySound: "提示音",
1078
1177
  notifyAutoHide: "自动隐藏",
1079
1178
  notifyAutoHideHint: "关闭后通知常驻,需手动关闭",
1179
+ notifyForeground: "前台提醒",
1180
+ notifyForegroundHint: "开启后,即使你正看着该页面也会提醒",
1080
1181
  notifyVolume: "音量",
1081
1182
  notifyDoneSound: "完成提示音",
1082
1183
  notifyPendingSound: "待处理提示音",
@@ -1088,9 +1189,17 @@ trackEdges(state);
1088
1189
  permissionUnsupported: "当前浏览器不支持系统通知",
1089
1190
  notifyDoneTitle: "会话已完成",
1090
1191
  notifyPendingTitle: "有交互等待处理",
1192
+ notifyDuration: "本轮总用时 {duration}",
1193
+ durationSeconds: "{seconds}秒",
1194
+ durationMinutes: "{minutes}分{seconds}秒",
1091
1195
  pendingKindApproval: "待审批",
1092
1196
  pendingKindQuestion: "向你提问",
1093
1197
  pendingKindPlanReview: "计划待审核",
1198
+ pendingApprovalTool: "待审批 · {tool}",
1199
+ pendingQuestionChoose: "请你选择",
1200
+ pendingQuestionMulti: "请你多选",
1201
+ pendingQuestionFill: "请你填写",
1202
+ pendingQuestionBatch: "向你提问({count} 个)",
1094
1203
  },
1095
1204
  en: {
1096
1205
  nav: "Notification center",
@@ -1110,6 +1219,8 @@ trackEdges(state);
1110
1219
  notifySound: "Sound",
1111
1220
  notifyAutoHide: "Auto hide",
1112
1221
  notifyAutoHideHint: "Off keeps it on screen until you close it",
1222
+ notifyForeground: "Notify in the foreground",
1223
+ notifyForegroundHint: "Also notify while you are looking at the page",
1113
1224
  notifyVolume: "Volume",
1114
1225
  notifyDoneSound: "Done sound",
1115
1226
  notifyPendingSound: "Pending sound",
@@ -1121,9 +1232,17 @@ trackEdges(state);
1121
1232
  permissionUnsupported: "This browser does not support notifications",
1122
1233
  notifyDoneTitle: "Session finished",
1123
1234
  notifyPendingTitle: "Something awaits you",
1235
+ notifyDuration: "turn took {duration}",
1236
+ durationSeconds: "{seconds}s",
1237
+ durationMinutes: "{minutes}m{seconds}s",
1124
1238
  pendingKindApproval: "Approval needed",
1125
1239
  pendingKindQuestion: "Question",
1126
1240
  pendingKindPlanReview: "Plan review",
1241
+ pendingApprovalTool: "Approval · {tool}",
1242
+ pendingQuestionChoose: "Choose an option",
1243
+ pendingQuestionMulti: "Choose options",
1244
+ pendingQuestionFill: "Type an answer",
1245
+ pendingQuestionBatch: "{count} questions",
1127
1246
  }
1128
1247
  }));
1129
1248
  ctx.slots.inject("settings.section", () => {
package/lib/index.mjs CHANGED
@@ -48,6 +48,8 @@ const NoticeSettingsSchema = z.object({
48
48
  notifyEnabled: z.boolean().default(false),
49
49
  /** 通知是否自动隐藏(默认 true=系统自行收起;false=常驻,需用户手动关闭)。 */
50
50
  notifyAutoHide: z.boolean().default(true),
51
+ /** 前台也提醒(默认 false=沿用原固定策略,页面在前台时保持安静;true=正在看也弹)。 */
52
+ notifyForeground: z.boolean().default(false),
51
53
  /** 通知提示音:完成与待处理为两种不同的预置音。 */
52
54
  notifySound: z.boolean().default(true),
53
55
  /** 提示音音量(0–1)。 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-notice-center",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "DeepSeek Harness 通知中心:标签页鲸鱼状态灯(绿=跑完 / 琥珀=待处理)+ 浏览器系统通知(完成/待处理提示、45 个 opencode 音效可选、音量可配)。",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -26,7 +26,8 @@
26
26
  "cordis.patch.yml",
27
27
  "assets",
28
28
  "LICENSE",
29
- "README.md"
29
+ "README.md",
30
+ "README.en.md"
30
31
  ],
31
32
  "dependencies": {
32
33
  "@deepseek-ai/schemastery": "^3.18.2"