dsh-notify-me 1.2.1 → 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/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  All notable changes to **dsh-notify-me** are documented here.
4
4
 
5
+ ## [1.3.0] — 2026-10-03
6
+
7
+ ### 新增
8
+ - **审批通知上直接裁决**(设置页新开关「审批通知上直接裁决」,默认开启):权限审批的系统通知带上「同意 / 拒绝」按钮,点一下就完成裁决,不用切回页面。按钮调的是审批卡片自己的 `PendingApproval.answer('allowed-once' | 'rejected')`——「同意」即「允许一次」,只放行这一次,与卡片上的按钮完全同义;点通知正文仍是 1.1.8 的「回到对应对话」。
9
+ - 绕开的是一条浏览器硬限制:`actions` 只属于 Service Worker 弹的持久通知,往 `new Notification()` 里传 `actions` 直接抛 `TypeError`。所以 bundle 一份文件两用:页面里照常注册提醒工厂,同一份字节再注册成 Service Worker,只负责把「点了哪个按钮」(同意 / 拒绝 / 点正文)转告页面(BroadcastChannel 与客户端 postMessage 双通道),裁决本身仍发生在页面里。注册只用本插件自己的单条目 combo URL(`/plugins/??dsh-notify-me/client.js&rev=…`,取自 `__DSH_BOOT__.entries`)——多插件 combo 会在 worker 里执行别家的 factory,注册必挂。
10
+ - 按钮通知按交互 key 记账:一条等待一次只认领一张通知;页面里先答掉、或被新请求顶替之后,残留通知上的按钮再点不裁决任何东西,只把通知关掉。请求消失时残留通知一并关闭;带按钮的通知不设 15 秒自动消失,未裁决的审批留在通知中心等你。
11
+ - `window.__dshNotifyMe` 新增 `decide(key, outcome)`(按钮的程序化同款,`outcome` 取 `"allowed-once"` / `"rejected"`)与 `test("approval")`(设置页新增「测试『审批按钮』」按钮,一条通知端到端验证按钮链路);`debug()` 新增 `quickActions` / `bridge`(`active` = 按钮可用,否则给出不可用原因)/ `actionKeys`;`onEvent` 新增 `decision` 事件(`{key, outcome, sessionId}`,测试点击另带 `test: true`)。
12
+
13
+ ### 变更
14
+ - 带按钮的审批通知改用 `dsh-notify-me-attention-<key>` 独立标签,多条待审批互不顶掉;提问 / 方案确认沿用原来的 `dsh-notify-me-attention` 标签行为。
15
+ - 不支持 Service Worker 的环境(`http://` 局域网地址等)自动退回 1.2.x 的无按钮通知,其余功能不受影响,`debug().bridge` 显示具体原因。
16
+ - 版本号 1.2.1 → 1.3.0;`window.__dshNotifyMe.version` 同步。
17
+
18
+ ### 测试
19
+ - `smoke/smoke-test.cjs` 新增快捷裁决桥用例,把 bundle 的两半都在同一套件里跑起来(worker 半侧执行在 worker 形状的 vm 上下文,页面半侧走常规桩,两边用同一个 BroadcastChannel 桩对接):单条目 combo URL 注册、按钮文案与 `data` 携带 key/会话、点「同意」→ `answer('allowed-once')`、点「拒绝」→ `answer('rejected')`、陈旧 key 与未知 key 一律不裁决、页面里先答掉后残留按钮失效、`quickActions` 关闭或无 Service Worker 时退回普通通知且不带 `actions`(构造函数带它会抛 `TypeError`)、测试按钮走完整 worker 链路。
20
+ - `smoke/cordis-host-test.mjs` 在 `sessionStatus` 宿主上补真 cordis 语义的裁决用例:`decide()` 经 `ctx.get("uiSession")` 找到活着的交互并调它的 `answer()`,重复裁决与未知 key 拒绝,标记随之释放。
21
+
22
+ ### 已知限制
23
+ - 按钮要 Service Worker:`http://127.0.0.1`、`https` 可用,`http://192.168.x.x` 这类局域网地址不行;DSH Desktop(Electron)里按钮能否渲染未实测,不渲染时按上述降级走无按钮通知。
24
+
5
25
  ## [1.2.1] — 2026-10-03
6
26
 
7
27
  ### 修复
@@ -17,11 +37,9 @@ All notable changes to **dsh-notify-me** are documented here.
17
37
  - **`0.1.6` 及以后的宿主上认不出「当前会话」**:会话列表快照不再有 `current`(`SessionListState` 只剩 `ids / byId / phase / projectionsBySession`),于是当前会话面(face)订阅从未建立、`onListChanged()` 还把当前会话当成后台会话评估——后果是 1.1.7 的「当前对话不弹通知」规则失效(审批卡片就在屏幕上,Toast 仍会盖上去),标签页标记的归属也随之错位。现在按宿主自己的口径推导:有 `current` 用它,没有就取 `retainedBy.mainView > 0` 的那一行(与内置 `DocumentTitle` / `ui-workspace` / `ui-session` 的判定一致)。
18
38
  - **`0.1.6` 及以后点通知切不回对应会话**:`sessions.open(id)` 已被移除,导航改由 `uiWorkspace.openSession(id)` 承担;插件现在两个都试(`open` 优先,旧宿主行为不变),会话已不在列表时依旧不调用。`debug()` 新增 `currentSession` 便于核对。
19
39
 
20
- ### 新增
21
- - `sessionStatus` 适配完整:同一会话里等待被替换成新 key 时旧的标题标记随之释放,等待消失(`pendingInteraction` 变 `null`)时按 key 释放标记,重复通知不重复提醒。
22
-
23
40
  ### 变更
24
- - `peerDependencies` 放宽为 `^0.1.0-rc.6 || ^0.1.1-rc.2 || ^0.1.2-rc.1 || ^0.1.5-rc.1 || >=0.1.6-alpha.2 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0`:原范围在 `0.2.0-rc.2` 上被 `evaluatePluginCompatibility` 判为不兼容,`dsh plugin add` 会拒绝并回滚安装(`0.3` 线仍被拒)。`dsh.compatibility.dshReleases` 增加 `0.2.0-rc.2: compatible`。
41
+ - 提醒语义在适配后原样保持(1.1.x 既有逻辑,不是新行为):同一会话里等待被替换成新 key 时旧的标题标记随之释放,等待消失(`pendingInteraction` 变 `null`)时按 key 释放标记,重复通知不重复提醒。
42
+ - `peerDependencies` 放宽为 `^0.1.0-rc.6 || ^0.1.1-rc.2 || ^0.1.2-rc.1 || ^0.1.5-rc.1 || >=0.1.6-alpha.2 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0`:原范围在 `0.2.0-rc.2` 上被 `evaluatePluginCompatibility` 判为不兼容,`dsh plugin add` 会拒绝并回滚安装(`0.3` 线仍被拒)。`dsh.compatibility.dshReleases` 增加 `0.2.0-rc.2: compatible`。范围对未实测的 `0.2.x` 正式版同样放行,是否兼容以 `dshReleases` 的实测声明为准。
25
43
  - 版本号 1.1.8 → 1.2.0(新宿主代际);`window.__dshNotifyMe.version` 同步。
26
44
  - 设置页字典注册改为把 `locale.register()` 返回的 disposer 交给 `ctx.effect`(与官方 `ui-approval` 写法一致),客户端插件 HMR 重载时不会因「同名 namespace 已注册」而丢掉设置页。
27
45
  - `uiSession` 绑定的诊断降噪:真实 `0.2.0-rc.2` 页面上每次加载都会出现的那条 `uiSession is provided but not reachable` 警告其实是**启动竞态**(本插件的客户端条目常早于 `ui-session` 条目激活,此时 `ctx.get()` 按 cordis 的严格语义拿不到尚未激活的服务),并非缺陷。现在只有**能拿到服务实例、但它既没有 `sessionStatus` 也没有 `pendingInteractions`** 时才打一条警告(并提示附上 `debug()` 输出);「还没可见」保持安静,由 1.5s 重试定时器接管,`debug().uiSessionNote` 仍可查。
package/README.en.md CHANGED
@@ -6,12 +6,12 @@
6
6
  [![npm downloads](https://img.shields.io/npm/dm/dsh-notify-me?style=flat-square&label=downloads&color=1F883D)](https://www.npmjs.com/package/dsh-notify-me)
7
7
  ![License](https://img.shields.io/badge/license-MIT-blue)
8
8
  ![Platform](https://img.shields.io/badge/platform-browser-blue)
9
- ![Size](https://img.shields.io/badge/bundle-%7E37KB-green)
9
+ ![Size](https://img.shields.io/badge/bundle-%7E79KB-green)
10
10
  [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
11
11
 
12
12
  ---
13
13
 
14
- **Step away from the DeepSeek Harness web UI — and still know what's going on.** When the agent stops and needs your input, or when a reply finishes in the background while you're in another app, dsh-notify-me alerts you with a Windows desktop notification, a sound, and a tab-title marker. Toggle it and pick the notification language right from **Settings → Notify me**.
14
+ **Step away from the DeepSeek Harness web UI — and still know what's going on.** When the agent stops and needs your input, or when a reply finishes in the background while you're in another app, dsh-notify-me alerts you with a Windows desktop notification, a sound, and a tab-title marker. Approval notifications even carry Approve / Reject buttons, so you can settle the request without switching back. Toggle it and pick the notification language right from **Settings → Notify me**.
15
15
 
16
16
  ---
17
17
 
@@ -42,7 +42,7 @@ dsh-notify-me decouples **supervision** from **sitting in front of the screen**:
42
42
 
43
43
  | When | What you get | Default |
44
44
  | --- | --- | --- |
45
- | 🔔 **The agent needs your input** — sandbox approval / plan review / a question (`ask_user_question`) | Toast + sound + `🔔 Action needed ·` title marker | Alerts even while the page is visible |
45
+ | 🔔 **The agent needs your input** — sandbox approval / plan review / a question (`ask_user_question`) | Toast + sound + `🔔 Action needed ·` title marker; approval toasts also carry Approve / Reject buttons | Alerts even while the page is visible |
46
46
  | ✅ **A reply finishes** — a turn completes; background sessions finishing are also reported | Toast + sound | Alerts only while the page is hidden / backgrounded |
47
47
 
48
48
  ## Configure it from DSH Settings
@@ -52,14 +52,15 @@ Open **Settings → Notify me** (refresh the page once after installing):
52
52
  - **Enable reminders** — master switch; when off, no toasts, no sounds and the tab title is never touched;
53
53
  - **System notifications / Sound / Volume** — toast toggle, WebAudio beep toggle and a volume slider;
54
54
  - **"Needs you" alerts while the page is open** (default on) and **"Reply finished" alerts while the page is open** (default off);
55
+ - **Decide approvals from the toast** (default on): approval notifications carry Approve / Reject buttons, and one click settles the request exactly like the Allow once / Reject buttons on the approval card. Clicking the toast body still takes you back to the conversation;
55
56
  - **Notification language** — follow the interface / 简体中文 / English: controls the language of the alert text and the `🔔 …` title marker;
56
- - **Test buttons** — send one "needs you" or "reply finished" test alert with the current settings (**not** limited by the "while the page is open" toggles; the "needs you" test's title marker clears itself after ~6s).
57
+ - **Test buttons** — send one "needs you", "reply finished" or "approval buttons" test alert with the current settings (**not** limited by the "while the page is open" toggles; the "needs you" test's title marker clears itself after ~6s).
57
58
 
58
59
  > Notification permission is required: click once on the page → **Allow** (or address-bar lock → Site settings → Notifications → Allow → reload).
59
60
 
60
61
  ## How it alerts
61
62
 
62
- - **System notification** — native notification-center toast (clicking it brings the DSH window back to front)
63
+ - **System notification** — native notification-center toast (clicking it brings the DSH window back to front; approval toasts can be decided straight from the buttons)
63
64
  - **Sound** — WebAudio beeps (distinct patterns for "needs you" vs "done")
64
65
  - **Tab title marker** — while something is waiting on you, the tab title is prefixed with `🔔 Action needed · …` / `🔔 需要你 · …`
65
66
 
@@ -78,10 +79,12 @@ Restart `dsh web`, then hard-refresh the page (Ctrl+Shift+R).
78
79
 
79
80
  | Host generation | "Needs you" interaction source | Current session | Toast-click navigation |
80
81
  | --- | --- | --- | --- |
81
- | `<= 0.1.1-rc.2` | controller snapshot `pending[]` | snapshot `current` | `sessions.open()` |
82
- | `0.1.2-alpha.2 .. 0.1.5-rc.3` | `uiSession.pendingInteractions` | snapshot `current` | `sessions.open()` |
82
+ | `<= 0.1.2-alpha.1` | controller snapshot `pending[]` | snapshot `current` | `sessions.open()` |
83
+ | `0.1.2-alpha.2 .. 0.1.6-alpha.1` | `uiSession.pendingInteractions` | snapshot `current` | `sessions.open()` |
83
84
  | `>= 0.1.6-alpha.2` (incl. `0.2.0-rc.2`) | `uiSession.sessionStatus` | `retainedBy.mainView` | `uiWorkspace.openSession()` |
84
85
 
86
+ The ranges are illustrative: the plugin picks its source by store shape (whenever `sessionStatus` exists it takes the new path); the version numbers only mark where each shape appeared.
87
+
85
88
  Verified to actually activate on `0.1.2-rc.1`, `0.1.5-rc.2` and `0.2.0-rc.2` (each booted in its own profile: the Settings → Notifications section registers, i.e. the plugin's `apply` really runs), and previously on `0.1.1-rc.2`. Two traps are worth remembering: a package listed in `dsh.client.inject` that a newer runtime no longer ships parks the client entry at `pending (waiting for services: …)` forever — which is what broke 1.1.3 on `0.1.2-rc.1` and later; and when the host renames a store or a field nothing throws, alerts just go **silent** — `0.2.0-rc.2` moved `pendingInteractions`→`sessionStatus`, `list.current`→`retainedBy.mainView` and `sessions.open()`→`uiWorkspace.openSession()`. So every adaptation must confirm `window.__dshNotifyMe.debug()` reports `bound` and which source answered.
86
89
 
87
90
  **Verify** — open the DevTools console and run:
@@ -89,6 +92,8 @@ Verified to actually activate on `0.1.2-rc.1`, `0.1.5-rc.2` and `0.2.0-rc.2` (ea
89
92
  ```js
90
93
  window.__dshNotifyMe.test("done") // "reply finished" sample
91
94
  window.__dshNotifyMe.test("attention") // "needs your input" sample
95
+ window.__dshNotifyMe.test("approval") // "approval buttons" sample: try the Approve / Reject buttons on the toast
96
+ window.__dshNotifyMe.debug() // the bridge field reports the button channel: "active" = buttons work
92
97
  ```
93
98
 
94
99
  Nothing happened? 90% of the time it's one of:
@@ -111,9 +116,11 @@ window.__dshNotifyMe.setConfig({
111
116
  toast: true, // system-notification toggle
112
117
  sound: true, // sound toggle
113
118
  volume: 0.5, // 0..1
114
- autoFocus: true // clicking the toast focuses the DSH window
119
+ autoFocus: true, // clicking the toast focuses the DSH window
120
+ quickActions: true // Approve / Reject buttons on approval toasts
115
121
  })
116
122
  window.__dshNotifyMe.resetConfig() // restore defaults
123
+ window.__dshNotifyMe.decide("approval:3", "allowed-once") // programmatic decision: "allowed-once" | "rejected"
117
124
  ```
118
125
 
119
126
  ## How it works
@@ -126,12 +133,19 @@ The reminder core picks its interaction source per host generation (see the tabl
126
133
 
127
134
  The reminder core stays dependency-free and self-contained: notification copy resolves at alert time from the chosen language (follow-interface / Simplified Chinese / English). The Settings page is an **optional** React surface — it only registers into Settings when the web profile provides the `slots` / `locale` services and `react`; without them the plugin degrades gracefully to alerts-only (no Settings page).
128
135
 
136
+ ### Quick decisions, and how they reach the agent
137
+
138
+ Action buttons (`actions`) belong to persistent notifications shown through `ServiceWorkerRegistration.showNotification()`; passing `actions` to the `new Notification()` constructor throws a `TypeError`. That is a browser rule no plugin can route around, so the bundle holds two jobs in the same bytes: the page half registers the reminder factory as it always did, and the very same file is registered as a Service Worker whose only task is relaying which button was clicked back to the page. The decision itself happens in the page, through the same `PendingApproval.answer('allowed-once' | 'rejected')` the approval card uses — which is why "Approve" is exactly "Allow once" and nothing broader.
139
+
140
+ Every button toast is booked against its interaction key, so it can only decide the request it was raised for. Once the page answers that request or a newer one replaces it, clicking the leftover buttons decides nothing and just closes the notification. Button toasts also skip the auto-dismiss timer: an undecided approval stays in the notification center until you settle it.
141
+
129
142
  ## Known limitations
130
143
 
131
144
  - The page must be open for alerts to fire (background tab / minimized is fine; closing the tab stops it — that's inherent to a browser-layer plugin).
132
145
  - Notifications appear through the browser, so the browser needs "show notifications" permission in Windows settings, and notification-center "Do Not Disturb" must not suppress them.
133
146
  - The first sound/toast of each page load needs one user click on the page first (browser autoplay + permission policy).
134
147
  - Toasts are only produced once the notification permission is granted; declining means sound + title marker only.
148
+ - Quick-decision buttons need a Service Worker: they work on `http://127.0.0.1` and `https`, not on plain `http://192.168.x.x`-style LAN addresses, and some browsers or desktop hosts never render notification buttons. The `bridge` field of `window.__dshNotifyMe.debug()` says why; toasts then fall back to their buttonless form and everything else keeps working.
135
149
  - Config lives in browser `localStorage`: switching browsers/devices or clearing site data returns to defaults (one-click restore in the Settings page).
136
150
  - On `>= 0.1.6` (including `0.2.0-rc.2`) the "reply finished" toast body carries only the session label, no assistant snippet: host snapshots no longer expose `nodes`. The alert itself still fires.
137
151
  - The title marker is written into `document.title` alongside the host: when the host recomputes the title it can overwrite the marker until the next alert event rewrites it.
@@ -141,7 +155,7 @@ The reminder core stays dependency-free and self-contained: notification copy re
141
155
  ```powershell
142
156
  node --check lib\client.js
143
157
  node --check lib\index.js
144
- node smoke\smoke-test.cjs # offline state-machine test: three host generations + master switch + zh/en cases
158
+ node smoke\smoke-test.cjs # offline state-machine test: three host generations + master switch + zh/en cases + the quick-decision bridge (worker half included)
145
159
  node smoke\cordis-host-test.mjs # real-cordis end-to-end (skips when no local DSH installation is found)
146
160
  npm pack --dry-run # preview the published tarball
147
161
  ```
package/README.md CHANGED
@@ -6,12 +6,12 @@
6
6
  [![npm downloads](https://img.shields.io/npm/dm/dsh-notify-me?style=flat-square&label=downloads&color=1F883D)](https://www.npmjs.com/package/dsh-notify-me)
7
7
  ![License](https://img.shields.io/badge/license-MIT-blue)
8
8
  ![Platform](https://img.shields.io/badge/platform-browser-blue)
9
- ![Size](https://img.shields.io/badge/bundle-%7E37KB-green)
9
+ ![Size](https://img.shields.io/badge/bundle-%7E79KB-green)
10
10
  [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
11
11
 
12
12
  ---
13
13
 
14
- **离开 DSH 页面也不错过任何动静。** 当模型停下来需要你操作(审批 / 方案待确认 / 提问)、或你在别的软件时回复正好在后台完成,dsh-notify-me 会用系统通知 + 提示音 + 标签页标题标记提醒你——开关和通知语言都直接在 **DSH 设置 → 通知提醒** 里改。
14
+ **离开 DSH 页面也不错过任何动静。** 当模型停下来需要你操作(审批 / 方案待确认 / 提问)、或你在别的软件时回复正好在后台完成,dsh-notify-me 会用系统通知 + 提示音 + 标签页标题标记提醒你——权限审批的通知还带「同意 / 拒绝」按钮,点一下就完成裁决,不用切回页面。开关和通知语言都直接在 **DSH 设置 → 通知提醒** 里改。
15
15
 
16
16
  ---
17
17
 
@@ -42,7 +42,7 @@ dsh-notify-me 把「监督」和「守在屏幕前」解耦:只在真的出现
42
42
 
43
43
  | 时机 | 提醒内容 | 默认 |
44
44
  | --- | --- | --- |
45
- | 🔔 **模型需要你操作** — 审批请求 / 方案待确认(plan review)/ 提问(`ask_user_question`) | 通知 + 提示音 + `🔔 需要你 ·` 标题标记 | 页面可见也提醒 |
45
+ | 🔔 **模型需要你操作** — 审批请求 / 方案待确认(plan review)/ 提问(`ask_user_question`) | 通知 + 提示音 + `🔔 需要你 ·` 标题标记;审批的通知上直接带「同意 / 拒绝」按钮 | 页面可见也提醒 |
46
46
  | ✅ **回复完成** — 一轮回复跑完;后台会话完成也会报 | 通知 + 提示音 | 仅页面隐藏/后台时提醒 |
47
47
 
48
48
  ## 在 DSH 设置里改配置
@@ -52,14 +52,15 @@ dsh-notify-me 把「监督」和「守在屏幕前」解耦:只在真的出现
52
52
  - **启用提醒** 主开关:关闭后不再弹系统通知、不播放提示音、也不改标签页标题;
53
53
  - **系统通知 / 提示音 / 音量**:Toast、WebAudio 提示音与音量滑块;
54
54
  - **页面打开时也提醒「需要你」**(默认开)与 **页面打开时也提醒「回复完成」**(默认关);
55
+ - **审批通知上直接裁决**(默认开):权限审批的通知上带「同意 / 拒绝」按钮,点一下就按审批卡片的「允许一次 / 拒绝」直接裁决,不用切回页面;点通知正文仍是原来的「切回对应对话」;
55
56
  - **通知语言**:跟随界面 / 简体中文 / English——决定通知文字与 `🔔 …` 标题标记使用的语言;
56
- - **测试按钮**:用当前设置立即发一条「需要你」或「回复完成」测试提醒(**不受**上面「页面打开时也提醒」开关限制;「需要你」测试的标题标记约 6 秒后自动消失)。
57
+ - **测试按钮**:用当前设置立即发一条「需要你」「回复完成」或「审批按钮」测试提醒(**不受**上面「页面打开时也提醒」开关限制;「需要你」测试的标题标记约 6 秒后自动消失)。
57
58
 
58
59
  > 需要浏览器通知权限:页面上点一下 → 允许;或地址栏锁 → 站点设置 → 通知 → 允许 → 刷新。
59
60
 
60
61
  ## 提醒方式
61
62
 
62
- - **系统通知**:Windows 通知中心 Toast(点击可把 DSH 窗口切回前台)
63
+ - **系统通知**:Windows 通知中心 Toast(点击可把 DSH 窗口切回前台;审批的通知点按钮直接裁决)
63
64
  - **提示音**:WebAudio 合成音(「需要你」与「完成」使用不同音型)
64
65
  - **标签页标题标记**:有待处理事项时,标题前出现 `🔔 需要你 · …` / `🔔 Action needed · …`
65
66
 
@@ -78,10 +79,12 @@ dsh plugin --profile web add dsh-notify-me
78
79
 
79
80
  | 宿主代际 | 「需要你」交互来源 | 判断当前会话 | 点通知切会话 |
80
81
  | --- | --- | --- | --- |
81
- | `≤ 0.1.1-rc.2` | 控制器快照的 `pending[]` | 快照 `current` | `sessions.open()` |
82
- | `0.1.2-alpha.2 .. 0.1.5-rc.3` | `uiSession.pendingInteractions` | 快照 `current` | `sessions.open()` |
82
+ | `≤ 0.1.2-alpha.1` | 控制器快照的 `pending[]` | 快照 `current` | `sessions.open()` |
83
+ | `0.1.2-alpha.2 .. 0.1.6-alpha.1` | `uiSession.pendingInteractions` | 快照 `current` | `sessions.open()` |
83
84
  | `≥ 0.1.6-alpha.2`(含 `0.2.0-rc.2`) | `uiSession.sessionStatus` | `retainedBy.mainView` | `uiWorkspace.openSession()` |
84
85
 
86
+ 区间仅作示意:插件按 store 形状自动选源(`sessionStatus` 存在即走新路),版本号只是各形状的出现边界。
87
+
85
88
  实测**真正激活**(各自独立 profile 启动:设置里出现「通知提醒」分区,即插件的 `apply` 确实执行)于 `0.1.2-rc.1`、`0.1.5-rc.2` 与 `0.2.0-rc.2`;更早的 `0.1.1-rc.2` 亦验证过。两个坑值得记住:一是 `dsh.client.inject` 里列了新版运行时已不再提供的包,客户端条目会停在 `pending (waiting for services: …)` 而永不执行——1.1.3 在 `0.1.2-rc.1` 及以后正是这样失效的;二是宿主换存储/字段名时**不会报错**,提醒只会静默失效——`0.2.0-rc.2` 上 `pendingInteractions`→`sessionStatus`、`list.current`→`retainedBy.mainView`、`sessions.open()`→`uiWorkspace.openSession()` 都是这一类,所以每次适配都要用 `window.__dshNotifyMe.debug()` 确认真的是 `bound`、以及用的是哪个来源。
86
89
 
87
90
  **验证** — F12 控制台执行:
@@ -89,6 +92,8 @@ dsh plugin --profile web add dsh-notify-me
89
92
  ```js
90
93
  window.__dshNotifyMe.test("done") // 「完成」示例
91
94
  window.__dshNotifyMe.test("attention") // 「需要你」示例
95
+ window.__dshNotifyMe.test("approval") // 「审批按钮」示例:通知上有「同意 / 拒绝」,点着试试
96
+ window.__dshNotifyMe.debug() // 桥接状态看 bridge 字段,active = 按钮可用
92
97
  ```
93
98
 
94
99
  没反应?九成是:通知权限被拒绝(在页面上点一下 → 选「允许」;或地址栏锁 → 站点设置 → 通知 → 允许 → 刷新)、装完没重启 DSH、系统设置里浏览器通知被关。
@@ -107,9 +112,11 @@ window.__dshNotifyMe.setConfig({
107
112
  toast: true, // 系统通知开关
108
113
  sound: true, // 提示音开关
109
114
  volume: 0.5, // 音量 0~1
110
- autoFocus: true // 点通知切回 DSH 窗口
115
+ autoFocus: true, // 点通知切回 DSH 窗口
116
+ quickActions: true // 审批通知上的「同意 / 拒绝」按钮
111
117
  })
112
118
  window.__dshNotifyMe.resetConfig() // 恢复默认
119
+ window.__dshNotifyMe.decide("approval:3", "allowed-once") // 程序化裁决:"allowed-once" | "rejected"
113
120
  ```
114
121
 
115
122
  ## 工作原理
@@ -122,12 +129,19 @@ window.__dshNotifyMe.resetConfig() // 恢复默认
122
129
 
123
130
  提醒核心仍然零第三方运行时依赖、完全自包含:通知文字按所选语言(跟随界面 / 中文 / English)即时解析。设置页是**可选**的 React 呈现层——当 DSH web profile 提供 `slots` / `locale` / `react` 时才注册进「设置 → 通知提醒」,缺任一能力时插件自动降级为纯提醒(无设置页),不影响功能。
124
131
 
132
+ ### 快捷裁决怎么走通的
133
+
134
+ 通知按钮(`actions`)只属于 Service Worker 弹的持久通知,往 `new Notification()` 里传 `actions` 会直接抛 `TypeError`——这是绕不开的浏览器限制。所以 bundle 一份文件干两件事:页面里照常注册提醒工厂,同一份字节再注册成一个只干转信的 Service Worker,把「点了哪个按钮」(同意 / 拒绝 / 点正文)带回页面。真正的裁决发生在页面里,调的是审批卡片自己用的 `PendingApproval.answer('allowed-once' | 'rejected')`,所以「同意」就是「允许一次」,不多放行任何东西。
135
+
136
+ 每条按钮通知按交互 key 记账,一次只认领一条等待;请求被页面里答掉、或被新请求顶替之后,残留通知上的按钮再点也不会误裁,只是安静地关掉。带按钮的通知不设自动消失——审批没裁决就一直留在通知中心里等你。
137
+
125
138
  ## 已知限制
126
139
 
127
140
  - 页面必须开着才会提醒(后台标签/最小化可以;关标签页即失效——浏览器层方案固有限制)。
128
141
  - 通知经由浏览器弹出,需允许浏览器通知权限,且勿扰模式不能屏蔽它。
129
142
  - 每次页面加载后第一次出声/弹通知前,需在页面上点击过一次(浏览器自动播放与权限策略)。
130
143
  - 未授权通知权限时只有提示音与标题标记。
144
+ - 快捷裁决按钮要 Service Worker:`http://127.0.0.1`、`https` 可用,`http://192.168.x.x` 这类局域网地址不行;浏览器或桌面宿主不渲染通知按钮时,`window.__dshNotifyMe.debug()` 的 `bridge` 字段会说明原因,通知自动退回无按钮样式,其余功能不受影响。
131
145
  - 配置存在浏览器 `localStorage`:换浏览器/设备或清除站点数据后会回到默认值(设置页可一键恢复默认)。
132
146
  - `≥ 0.1.6`(含 `0.2.0-rc.2`)的「回复完成」提醒正文只有会话名,不再附带回复摘要——宿主快照已不再提供 `nodes`;提醒本身照常触发。
133
147
  - 标题标记由宿主与插件共同写 `document.title`:宿主重算标题时标记可能被覆盖,直到下一次提醒事件重新写成。
@@ -137,7 +151,7 @@ window.__dshNotifyMe.resetConfig() // 恢复默认
137
151
  ```powershell
138
152
  node --check lib\client.js
139
153
  node --check lib\index.js
140
- node smoke\smoke-test.cjs # 离线状态机冒烟测试:三代宿主 + 主开关 + 中英语言用例
154
+ node smoke\smoke-test.cjs # 离线状态机冒烟测试:三代宿主 + 主开关 + 中英语言用例 + 快捷裁决桥(Service Worker 半侧一并跑)
141
155
  node smoke\cordis-host-test.mjs # 真 cordis 端到端(找不到本机 DSH 时自动跳过)
142
156
  npm pack --dry-run # 预览发布包
143
157
  ```
package/lib/client.js CHANGED
@@ -35,6 +35,15 @@
35
35
  // - fires a Windows desktop Notification (+ optional sound) when the agent
36
36
  // needs the user's input — pending interaction kinds 'approval',
37
37
  // 'plan-review' or 'question' — and when a reply/turn finishes;
38
+ // - quick-decides approvals from the toast itself (option, on by default):
39
+ // approval notifications carry 同意/拒绝 action buttons and one click on a
40
+ // button settles the request through the interaction's own
41
+ // answer('allowed-once' | 'rejected') — the exact verb the in-page approval
42
+ // card uses. Action buttons only exist on persistent notifications, so this
43
+ // file is dual-context: registered as a Service Worker as well, whose
44
+ // notificationclick relays the chosen button back to this page (broadcast +
45
+ // client postMessage). Where a worker cannot run (insecure context, no
46
+ // bundle URL) the plugin degrades to today's plain toast;
38
47
  // - registers a "通知提醒 / Notify me" page into DSH Settings
39
48
  // (settings.section slot) exposing an on/off master switch, per-channel
40
49
  // toggles, volume and the notification language (follow-interface /
@@ -42,9 +51,71 @@
42
51
  // - preferences persist in localStorage (origin-scoped, like other
43
52
  // third-party client plugins) and are tweakable on window.__dshNotifyMe.
44
53
 
45
- window.__ModuleLoader__.load({
46
- id: "dsh-notify-me",
47
- factory: (require) => {
54
+ // One file, two jobs. The DSH module loader evaluates this script in the page
55
+ // (registering the factory below), and the very same served bytes are also
56
+ // registered as the plugin's Service Worker — see the bridge at the bottom of
57
+ // this wrapper.
58
+ (function (root) {
59
+ if (!root) return;
60
+
61
+ // ──────────────── service-worker half (notification bridge) ────────────
62
+ var BRIDGE_CHANNEL = "dsh-notify-me:bridge";
63
+ // A real Service Worker global is the only context with a registration and
64
+ // no document; the page (and any test harness) never matches.
65
+ function isServiceWorkerScope(g) {
66
+ return !!g && typeof g.registration === "object" && g.registration !== null &&
67
+ typeof g.addEventListener === "function" && typeof g.document === "undefined";
68
+ }
69
+ // The worker never decides anything: it only relays which button was
70
+ // clicked to the page that owns the pending interaction. Both a
71
+ // BroadcastChannel and a direct client postMessage are used — the channel
72
+ // covers every same-origin page, the postMessage covers environments where
73
+ // the worker cannot open one.
74
+ function installNotificationBridge(g) {
75
+ function relay(msg) {
76
+ try {
77
+ if (typeof g.BroadcastChannel === "function") {
78
+ var ch = new g.BroadcastChannel(BRIDGE_CHANNEL);
79
+ ch.postMessage(msg);
80
+ try { ch.close(); } catch (e) {}
81
+ }
82
+ } catch (e) {}
83
+ try {
84
+ if (g.clients && typeof g.clients.matchAll === "function") {
85
+ g.clients.matchAll({ type: "window", includeUncontrolled: true }).then(function (list) {
86
+ for (var i = 0; i < list.length; i++) {
87
+ try { list[i].postMessage(msg); } catch (e) {}
88
+ }
89
+ }, function () {});
90
+ }
91
+ } catch (e) {}
92
+ }
93
+ g.addEventListener("notificationclick", function (event) {
94
+ try {
95
+ var n = event.notification;
96
+ var data = (n && n.data) || {};
97
+ var msg = {
98
+ source: "dsh-notify-me",
99
+ type: "notification",
100
+ action: event.action || "",
101
+ key: data.key || null,
102
+ sessionId: data.sessionId || null
103
+ };
104
+ try { if (n && typeof n.close === "function") n.close(); } catch (e) {}
105
+ if (typeof event.waitUntil === "function") {
106
+ event.waitUntil(Promise.resolve().then(function () { relay(msg); }));
107
+ } else {
108
+ relay(msg);
109
+ }
110
+ } catch (e) {}
111
+ });
112
+ }
113
+ if (isServiceWorkerScope(root)) { installNotificationBridge(root); return; }
114
+ if (!root.__ModuleLoader__ || typeof root.__ModuleLoader__.load !== "function") return;
115
+
116
+ root.__ModuleLoader__.load({
117
+ id: "dsh-notify-me",
118
+ factory: (require) => {
48
119
  var module = { exports: {} };
49
120
  var exports = module.exports;
50
121
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
@@ -72,7 +143,13 @@ window.__ModuleLoader__.load({
72
143
  toast: true,
73
144
  sound: true,
74
145
  volume: 0.5,
75
- autoFocus: true // clicking the toast focuses the DSH window
146
+ autoFocus: true, // clicking the toast focuses the DSH window
147
+ // Approval toasts carry 同意/拒绝 buttons and settle the request on one
148
+ // click (the same answer('allowed-once' | 'rejected') the in-page
149
+ // approval card sends). Buttons need persistent notifications, served
150
+ // through the Service Worker half of this same file; where that cannot
151
+ // run, the toast simply falls back to its plain form.
152
+ quickActions: true
76
153
  };
77
154
  function readConfig() {
78
155
  var cfg = {};
@@ -114,8 +191,13 @@ window.__ModuleLoader__.load({
114
191
  background: "后台会话",
115
192
  marker: "🔔 需要你 · ",
116
193
  sep: ":",
194
+ approve: "同意",
195
+ reject: "拒绝",
117
196
  testAttention: "这是一条测试提醒:DSH 需要你的操作",
118
- testDone: "测试提醒 · 回复完成示例"
197
+ testDone: "测试提醒 · 回复完成示例",
198
+ testApproval: "这是一条测试审批:点通知上的「同意 / 拒绝」按钮试试快捷裁决",
199
+ testApproved: "测试提醒 · 已模拟「同意」(仅测试,未改动任何会话)",
200
+ testRejected: "测试提醒 · 已模拟「拒绝」(仅测试,未改动任何会话)"
119
201
  },
120
202
  en: {
121
203
  kind: { approval: "Approval request", "plan-review": "Plan review", question: "Question" },
@@ -132,8 +214,13 @@ window.__ModuleLoader__.load({
132
214
  background: "Background session",
133
215
  marker: "🔔 Action needed · ",
134
216
  sep: ": ",
217
+ approve: "Approve",
218
+ reject: "Reject",
135
219
  testAttention: "This is a test alert: DSH is waiting for your input",
136
- testDone: "Test alert · reply-finished sample"
220
+ testDone: "Test alert · reply-finished sample",
221
+ testApproval: "This is a test approval: try quick decisions with the Approve / Reject buttons on the toast",
222
+ testApproved: "Test alert · simulated \"Approve\" (test only, nothing changed)",
223
+ testRejected: "Test alert · simulated \"Reject\" (test only, nothing changed)"
137
224
  }
138
225
  };
139
226
  // Locale service handle (registered on the client context by dsh-client-locale).
@@ -200,12 +287,227 @@ window.__ModuleLoader__.load({
200
287
  if (ws && typeof ws.openSession === "function") ws.openSession(sessionId);
201
288
  } catch (e) {}
202
289
  }
203
- function showToast(kind, title, body, sessionId) {
290
+ // ─────────────── approval quick-decision bridge ───────────────
291
+ // Action buttons exist only on persistent notifications
292
+ // (ServiceWorkerRegistration.showNotification); the Notification()
293
+ // constructor throws on an `actions` option. The worker half lives at the
294
+ // bottom of this file's wrapper and only relays clicks back here, where the
295
+ // pending interaction's own answer() settles the request.
296
+ var BRIDGE_CHANNEL = "dsh-notify-me:bridge";
297
+ var swRegistration = null;
298
+ var swState = "idle"; // idle | disabled | registering | active | unsupported:* | failed:*
299
+ var bridgeListening = false;
300
+ var actionChannel = null;
301
+ // Keyed by interaction key: every toast that carries buttons has a record
302
+ // here, so a click can only ever decide the exact request it was raised
303
+ // for. { sessionId, interaction, tag, test }
304
+ var pendingActions = Object.create(null);
305
+ // The single-record combo URL of THIS bundle — the only URL that may be
306
+ // registered as a worker, since a multi-plugin combo would evaluate every
307
+ // other plugin's factory in the worker and fail the registration. The boot
308
+ // graph publishes it per entry ({id, url, rev}).
309
+ function selfBundleUrl() {
310
+ try {
311
+ var boot = window.__DSH_BOOT__;
312
+ var entries = boot && boot.entries;
313
+ if (Array.isArray(entries)) {
314
+ for (var i = 0; i < entries.length; i++) {
315
+ var e = entries[i];
316
+ if (e && e.id === "dsh-notify-me" && typeof e.url === "string" && e.url) return e.url;
317
+ }
318
+ }
319
+ } catch (e) {}
320
+ return null;
321
+ }
322
+ function ensureActionBridge() {
323
+ if (!config.quickActions) { swState = "disabled"; return; }
324
+ if (swState === "active" || swState === "registering") return;
325
+ listenBridge();
326
+ try {
327
+ if (typeof navigator === "undefined" || !navigator.serviceWorker) {
328
+ swState = "unsupported: no service worker";
329
+ return;
330
+ }
331
+ if (window.isSecureContext === false) {
332
+ swState = "unsupported: insecure context";
333
+ return;
334
+ }
335
+ var url = selfBundleUrl();
336
+ if (!url) {
337
+ swState = "unsupported: bundle url unknown";
338
+ return;
339
+ }
340
+ swState = "registering";
341
+ navigator.serviceWorker.register(url).then(function (reg) {
342
+ try {
343
+ swRegistration = reg || null;
344
+ if (!reg) { swState = "failed: no registration"; return; }
345
+ if (reg.active) { swState = "active"; return; }
346
+ var w = reg.installing || reg.waiting;
347
+ if (!w) { swState = "active"; return; }
348
+ var settle = function () {
349
+ try {
350
+ if (reg.active || w.state === "activated") swState = "active";
351
+ else if (w.state === "redundant") swState = "failed: worker redundant";
352
+ } catch (e) {}
353
+ };
354
+ try { w.addEventListener("statechange", settle); } catch (e) {}
355
+ settle();
356
+ } catch (e) {
357
+ swState = "failed: " + ((e && e.message) || e);
358
+ }
359
+ }, function (err) {
360
+ swState = "failed: " + ((err && err.message) || err);
361
+ });
362
+ } catch (e) {
363
+ swState = "failed: " + ((e && e.message) || e);
364
+ }
365
+ }
366
+ function bridgeActive() {
367
+ return !!(swRegistration && swState === "active");
368
+ }
369
+ // Both relay paths end here; the worker's broadcast goes to every
370
+ // same-origin page and the postMessage to every window client, so the
371
+ // handler must be idempotent (decidePending() is a one-shot per key).
372
+ function listenBridge() {
373
+ if (bridgeListening) return;
374
+ bridgeListening = true;
375
+ try {
376
+ if (typeof navigator !== "undefined" && navigator.serviceWorker &&
377
+ typeof navigator.serviceWorker.addEventListener === "function") {
378
+ navigator.serviceWorker.addEventListener("message", function (ev) {
379
+ try { onBridgeMessage(ev && ev.data); } catch (e) {}
380
+ });
381
+ }
382
+ } catch (e) {}
383
+ try {
384
+ if (typeof BroadcastChannel === "function") {
385
+ actionChannel = new BroadcastChannel(BRIDGE_CHANNEL);
386
+ actionChannel.onmessage = function (ev) {
387
+ try { onBridgeMessage(ev && ev.data); } catch (e) {}
388
+ };
389
+ }
390
+ } catch (e) {}
391
+ }
392
+ function onBridgeMessage(msg) {
393
+ if (!msg || msg.source !== "dsh-notify-me" || msg.type !== "notification") return;
394
+ if (msg.action === "approve") { decidePending(msg.key, "allowed-once"); return; }
395
+ if (msg.action === "reject") { decidePending(msg.key, "rejected"); return; }
396
+ if (config.autoFocus) focusSession(msg.sessionId || null);
397
+ }
398
+ // The live interaction wins over the reference captured at alert time (the
399
+ // wait may have been replaced), and a key with no live record is stale —
400
+ // the notification outlived its request, so the click must decide nothing.
401
+ function findPendingByKey(key) {
402
+ try {
403
+ var svc = lookupService("uiSession");
404
+ var found = interactionStore(svc);
405
+ if (!found) return null;
406
+ var snap = found.store.getSnapshot();
407
+ if (!snap || typeof snap.forEach !== "function") return null;
408
+ var hit = null;
409
+ snap.forEach(function (value) {
410
+ if (hit) return;
411
+ var it = pendingOf(value, found.source);
412
+ if (it && it.key === key) hit = it;
413
+ });
414
+ return hit;
415
+ } catch (e) { return null; }
416
+ }
417
+ function closeNotificationTag(tag) {
418
+ if (!swRegistration || typeof swRegistration.getNotifications !== "function") return;
419
+ try {
420
+ var done = function (list) {
421
+ for (var i = 0; i < (list || []).length; i++) {
422
+ try { list[i].close(); } catch (e) {}
423
+ }
424
+ };
425
+ var r = swRegistration.getNotifications({ tag: tag });
426
+ if (r && typeof r.then === "function") r.then(done, function () {});
427
+ else done(r);
428
+ } catch (e) {}
429
+ }
430
+ // One toast button press. outcome: 'allowed-once' | 'rejected' — exactly
431
+ // the two decisions the in-page approval card exposes.
432
+ function decidePending(key, outcome) {
433
+ var rec = key ? pendingActions[key] : null;
434
+ if (!rec) return false; // already answered elsewhere, or a stale toast
435
+ delete pendingActions[key];
436
+ if (!rec.test) closeNotificationTag(rec.tag);
437
+ if (rec.test) {
438
+ // Test toasts settle nothing real; the feedback toast IS the proof that
439
+ // the button clicked all the way through the worker bridge.
440
+ deliver("done", texts().doneTitle, outcome === "allowed-once" ? texts().testApproved : texts().testRejected, null, null);
441
+ notifyEvent("decision", { key: key, outcome: outcome, sessionId: rec.sessionId || null, test: true });
442
+ return true;
443
+ }
444
+ var target = findPendingByKey(key) || rec.interaction;
445
+ if (!target || target.key !== key || typeof target.answer !== "function") return false;
446
+ try {
447
+ var p = target.answer(outcome);
448
+ if (p && typeof p.catch === "function") p.catch(function () {});
449
+ } catch (e) { return false; }
450
+ unmarkAttention(key);
451
+ notifyEvent("decision", { key: key, outcome: outcome, sessionId: rec.sessionId || null });
452
+ return true;
453
+ }
454
+ // Attach buttons to one alert. Only real approval interactions qualify:
455
+ // they are the only kind whose decision is a plain binary, and the only one
456
+ // whose object answers answer('allowed-once' | 'rejected').
457
+ function quickFor(key, it, sessionId) {
458
+ if (!config.quickActions || !config.toast) return null;
459
+ if (!it || it.kind !== "approval" || typeof it.answer !== "function") return null;
460
+ var q = {
461
+ key: key,
462
+ sessionId: sessionId || null,
463
+ interaction: it,
464
+ tag: "dsh-notify-me-attention-" + key,
465
+ test: false
466
+ };
467
+ pendingActions[key] = q;
468
+ return q;
469
+ }
470
+ function quickForTest(key) {
471
+ var q = {
472
+ key: key,
473
+ sessionId: null,
474
+ interaction: null,
475
+ tag: "dsh-notify-me-attention-" + key,
476
+ test: true
477
+ };
478
+ pendingActions[key] = q;
479
+ return q;
480
+ }
481
+
482
+ function showToast(kind, title, body, sessionId, quick) {
204
483
  if (!canToast()) return;
205
484
  try {
485
+ var tag = quick ? quick.tag : "dsh-notify-me-" + kind;
486
+ if (quick && bridgeActive()) {
487
+ var d = texts();
488
+ try {
489
+ swRegistration.showNotification(title, {
490
+ body: body || "",
491
+ tag: tag,
492
+ renotify: true,
493
+ // The plugin owns the audio (see showToast's silent note below).
494
+ silent: true,
495
+ icon: (location.origin || "") + "/favicon.svg",
496
+ data: { key: quick.key, sessionId: sessionId || null, kind: kind, test: !!quick.test },
497
+ actions: [
498
+ { action: "approve", title: d.approve },
499
+ { action: "reject", title: d.reject }
500
+ ]
501
+ });
502
+ // No auto-close on purpose: an unanswered approval stays clickable
503
+ // (toast, then Action Center) until the request is settled — the
504
+ // wait-teardown path closes it through closeNotificationTag().
505
+ return;
506
+ } catch (e) { /* worker notifications unavailable -> plain toast */ }
507
+ }
206
508
  var n = new Notification(title, {
207
509
  body: body || "",
208
- tag: "dsh-notify-me-" + kind,
510
+ tag: tag,
209
511
  renotify: true,
210
512
  // The plugin owns the audio: its WebAudio cues already differ per
211
513
  // kind (attention vs done) and answer the Sound toggle and volume
@@ -327,6 +629,15 @@ window.__ModuleLoader__.load({
327
629
  }
328
630
  function unmarkAttention(key) {
329
631
  delete quietedAttention[key]; // the wait is over; a queued alert dies with it
632
+ // A toast that still carries buttons for this wait dies with it too —
633
+ // otherwise a settled request would leave a clickable (but stale)
634
+ // approval toast behind. Test toasts are exempt: their click is the
635
+ // proof, so they outlive the short title mark on purpose.
636
+ var rec = pendingActions[key];
637
+ if (rec) {
638
+ delete pendingActions[key];
639
+ if (!rec.test) closeNotificationTag(rec.tag);
640
+ }
330
641
  if (!attentionKeysMarked[key]) return;
331
642
  delete attentionKeysMarked[key];
332
643
  releaseTitleMark();
@@ -347,21 +658,24 @@ window.__ModuleLoader__.load({
347
658
  // buttons); respects the master switch and permission state. The title
348
659
  // marker is NOT touched here — its ownership belongs to the watchers
349
660
  // (markAttention / unmarkAttention), which keeps the counter balanced.
350
- function deliver(kind, title, body, sessionId) {
661
+ function notifyEvent(kind, payload) {
662
+ try {
663
+ if (window.__dshNotifyMe && window.__dshNotifyMe.onEvent) {
664
+ window.__dshNotifyMe.onEvent(kind, payload);
665
+ }
666
+ } catch (e) {}
667
+ }
668
+ function deliver(kind, title, body, sessionId, quick) {
351
669
  if (!config.enabled) return;
352
- showToast(kind, title, body, sessionId);
670
+ showToast(kind, title, body, sessionId, quick);
353
671
  if (kind === "attention") {
354
672
  playAttentionSound();
355
673
  } else {
356
674
  playDoneSound();
357
675
  }
358
- try {
359
- if (window.__dshNotifyMe && window.__dshNotifyMe.onEvent) {
360
- window.__dshNotifyMe.onEvent(kind, { title: title, body: body, sessionId: sessionId || null });
361
- }
362
- } catch (e) {}
676
+ notifyEvent(kind, { title: title, body: body, sessionId: sessionId || null });
363
677
  }
364
- function fire(kind, title, body, isCurrent, sessionId) {
678
+ function fire(kind, title, body, isCurrent, sessionId, quick) {
365
679
  if (!config.enabled) return false;
366
680
  if (kind === "attention" && config.attentionHiddenOnly && !hidden()) return false;
367
681
  // The wait belongs to the conversation the user is looking at right now:
@@ -370,7 +684,7 @@ window.__ModuleLoader__.load({
370
684
  // flushQuieted() re-delivers the alert the moment the page is backgrounded.
371
685
  if (kind === "attention" && quietedByVisibleSession(isCurrent)) return false;
372
686
  if (kind === "done" && config.doneHiddenOnly && !hidden()) return false;
373
- deliver(kind, title, body, sessionId);
687
+ deliver(kind, title, body, sessionId, quick);
374
688
  return true;
375
689
  }
376
690
  // True when this alert must stay silent purely because it is about the
@@ -390,7 +704,7 @@ window.__ModuleLoader__.load({
390
704
  for (var i = 0; i < keys.length; i++) {
391
705
  var copy = quietedAttention[keys[i]];
392
706
  delete quietedAttention[keys[i]];
393
- fire("attention", copy.title, copy.body, false, copy.sessionId);
707
+ fire("attention", copy.title, copy.body, false, copy.sessionId, copy.quick || null);
394
708
  }
395
709
  }
396
710
  // A display label for a session row ("current" maps to the localized
@@ -436,16 +750,17 @@ window.__ModuleLoader__.load({
436
750
  }
437
751
  // Returns null once the alert went out, or the copy it would have shown
438
752
  // when only the visible-session rule held it back — the caller queues that
439
- // copy by interaction key so it can still fire from the background.
440
- function fireAttention(kind, sessionLabel, detail, isCurrent, sessionId) {
753
+ // copy by interaction key so it can still fire from the background (the
754
+ // quick-decision payload rides along with the copy).
755
+ function fireAttention(kind, sessionLabel, detail, isCurrent, sessionId, quick) {
441
756
  var d = texts();
442
757
  var label = d.kind[kind] || d.kindDefault;
443
758
  var content = detail || d.bodyKind[kind] || d.bodyDefault;
444
759
  var who = sessionLabel || "";
445
760
  var title = "DSH · " + label;
446
761
  var body = (who ? who + d.sep : "") + content;
447
- if (quietedByVisibleSession(isCurrent)) return { title: title, body: body, sessionId: sessionId || null };
448
- fire("attention", title, body, isCurrent, sessionId);
762
+ if (quietedByVisibleSession(isCurrent)) return { title: title, body: body, sessionId: sessionId || null, quick: quick || null };
763
+ fire("attention", title, body, isCurrent, sessionId, quick);
449
764
  return null;
450
765
  }
451
766
  function fireDone(sessionLabel, snippet, sessionId) {
@@ -464,6 +779,20 @@ window.__ModuleLoader__.load({
464
779
  var TEST_MARK_KEY = "test:attention";
465
780
  function testAlert(kind) {
466
781
  var d = texts();
782
+ if (kind === "approval") {
783
+ // End-to-end proof of the quick-decision path: this toast carries the
784
+ // same 同意/拒绝 buttons as a real approval one, and clicking them runs
785
+ // the whole worker-bridge -> decidePending() chain (settling nothing
786
+ // real — the feedback toast says so).
787
+ var key = "test:approval:" + String(Date.now());
788
+ var quick = quickForTest(key);
789
+ deliver("attention", "DSH · " + d.kind.approval, d.testApproval, null, quick);
790
+ markAttention(key);
791
+ // The title mark drops like every other test's, but the toast is NOT
792
+ // closed with it — its buttons are the very thing being tested.
793
+ setTimeout(function () { unmarkAttention(key); }, TEST_MARK_RELEASE_MS);
794
+ return;
795
+ }
467
796
  if (kind === "attention") {
468
797
  deliver("attention", "DSH · " + d.kind.question, d.testAttention);
469
798
  markAttention(TEST_MARK_KEY);
@@ -578,7 +907,8 @@ window.__ModuleLoader__.load({
578
907
  // the title marker itself (see the uiSession watcher below).
579
908
  if (newKeys.length > 0) {
580
909
  var it = newKeys[0];
581
- var copy = fireAttention(it.kind, sessionLabelText(row), textFromPayload(it.payload), !!row.face, row.id);
910
+ var quick = quickFor(it.key, it, row.id);
911
+ var copy = fireAttention(it.kind, sessionLabelText(row), textFromPayload(it.payload), !!row.face, row.id, quick);
582
912
  if (copy) queueQuieted(it.key, copy);
583
913
  lastAttentionAt = Date.now();
584
914
  markAttention(it.key);
@@ -811,7 +1141,8 @@ window.__ModuleLoader__.load({
811
1141
  if (prev) unmarkAttention(prev.key);
812
1142
  if (attentionKeysFired[key]) { markAttention(key); return; } // already alerted, still needs its mark
813
1143
  attentionKeysFired[key] = true;
814
- var copy = fireAttention(it.kind, labelForSessionId(sid), textFromPayload(it), currentSessionId() === sid, sid);
1144
+ var quick = quickFor(key, it, sid);
1145
+ var copy = fireAttention(it.kind, labelForSessionId(sid), textFromPayload(it), currentSessionId() === sid, sid, quick);
815
1146
  if (copy) queueQuieted(key, copy);
816
1147
  lastAttentionAt = Date.now();
817
1148
  markAttention(key);
@@ -848,7 +1179,7 @@ window.__ModuleLoader__.load({
848
1179
  });
849
1180
  } catch (e) {}
850
1181
  // keep in sync with package.json "version" on every release
851
- api.version = "1.2.1";
1182
+ api.version = "1.3.0";
852
1183
  api.setConfig = function (patch) { return setConfigLocal(patch); };
853
1184
  api.resetConfig = function () {
854
1185
  try { localStorage.removeItem(LS_CONFIG); } catch (e) {}
@@ -858,10 +1189,15 @@ window.__ModuleLoader__.load({
858
1189
  // manual test; run it after clicking anywhere so permission can be granted.
859
1190
  // Test alerts fire regardless of the page-open visibility rules.
860
1191
  api.test = function (kind) {
861
- kind = kind === "attention" ? "attention" : "done";
1192
+ kind = (kind === "attention" || kind === "approval") ? kind : "done";
862
1193
  unlockOnce();
863
1194
  testAlert(kind);
864
- return "sent " + kind + " (lang=" + effectiveLang() + ", permission=" + ("Notification" in window ? Notification.permission : "n/a") + ")";
1195
+ return "sent " + kind + " (lang=" + effectiveLang() + ", permission=" + ("Notification" in window ? Notification.permission : "n/a") + ", bridge=" + swState + ")";
1196
+ };
1197
+ // Programmatic twin of a toast button press: settles the pending approval
1198
+ // behind `key` the same way the 同意/拒绝 buttons do.
1199
+ api.decide = function (key, outcome) {
1200
+ return decidePending(key, outcome === "rejected" ? "rejected" : "allowed-once");
865
1201
  };
866
1202
  // Live diagnostics for the "why did nothing pop" case. The needs-input
867
1203
  // channel has three possible owners on current hosts (uiSession.sessionStatus
@@ -878,6 +1214,11 @@ window.__ModuleLoader__.load({
878
1214
  doneHiddenOnly: config.doneHiddenOnly,
879
1215
  toast: config.toast,
880
1216
  sound: config.sound,
1217
+ quickActions: config.quickActions,
1218
+ // quick-decision bridge: 'active' is the only state in which approval
1219
+ // toasts carry working 同意/拒绝 buttons.
1220
+ bridge: swState,
1221
+ actionKeys: Object.keys(pendingActions),
881
1222
  permission: ("Notification" in window) ? Notification.permission : "n/a",
882
1223
  visibility: hidden() ? "hidden" : "visible",
883
1224
  uiSession: uiPending ? "bound" : "unbound",
@@ -915,6 +1256,11 @@ window.__ModuleLoader__.load({
915
1256
  doneVisibleHint: "默认关闭:「完成」只在页面隐藏或切到后台时才提醒,避免打扰。",
916
1257
  autoFocus: "点击通知回到对应对话",
917
1258
  autoFocusHint: "默认开启:点系统通知会把 DSH 带到前台,并直接切到这条提醒所属的对话;关掉后点击通知不做任何事。",
1259
+ quickActions: "审批通知上直接裁决",
1260
+ quickActionsHint: "默认开启:权限审批的通知上带「同意 / 拒绝」按钮,点一下即按审批卡片的「允许一次 / 拒绝」直接完成裁决,不用切回页面。需要支持 Service Worker 的浏览器(http://127.0.0.1 或 https);用不了时自动退回普通通知。",
1261
+ quickApprovalNote: "「同意」等价于审批卡片上的「允许一次」,只放行这一次。",
1262
+ bridgeOff: "快捷裁决按钮暂不可用(",
1263
+ bridgeOffTail: "):审批通知会退回不带按钮的普通样式。",
918
1264
  volume: "音量",
919
1265
  language: "通知语言",
920
1266
  langAuto: "跟随界面",
@@ -926,6 +1272,7 @@ window.__ModuleLoader__.load({
926
1272
  testHint: "用当前设置各发一条测试提醒(不受上面「页面打开时也提醒」开关限制)。",
927
1273
  testAttention: "测试「需要你」",
928
1274
  testDone: "测试「回复完成」",
1275
+ testApprovalBtn: "测试「审批按钮」",
929
1276
  reset: "恢复默认设置",
930
1277
  resetDone: "已恢复默认设置。",
931
1278
  sent: "已发送测试提醒 · 通知语言:",
@@ -949,6 +1296,11 @@ window.__ModuleLoader__.load({
949
1296
  doneVisibleHint: "Off by default: \"done\" only alerts while the page is hidden or backgrounded, to avoid noise.",
950
1297
  autoFocus: "Click a toast to open that conversation",
951
1298
  autoFocusHint: "On by default: clicking a toast brings DSH forward and switches to the conversation the alert came from. When off, clicking a toast does nothing.",
1299
+ quickActions: "Decide approvals from the toast",
1300
+ quickActionsHint: "On by default: approval notifications carry Approve / Reject buttons, and one click settles the request exactly like the Allow once / Reject buttons on the approval card. Needs a Service Worker capable browser (http://127.0.0.1 or https); falls back to plain toasts where unavailable.",
1301
+ quickApprovalNote: "\"Approve\" equals \"Allow once\" on the approval card — it allows that one call only.",
1302
+ bridgeOff: "Quick-decision buttons are unavailable right now (",
1303
+ bridgeOffTail: "); approval toasts fall back to their plain form.",
952
1304
  volume: "Volume",
953
1305
  language: "Notification language",
954
1306
  langAuto: "Follow the interface",
@@ -960,6 +1312,7 @@ window.__ModuleLoader__.load({
960
1312
  testHint: "Send one test alert with the current settings (not limited by the \"while the page is open\" toggles above).",
961
1313
  testAttention: "Test \"needs you\"",
962
1314
  testDone: "Test \"reply finished\"",
1315
+ testApprovalBtn: "Test approval buttons",
963
1316
  reset: "Restore defaults",
964
1317
  resetDone: "Defaults restored.",
965
1318
  sent: "Test alert sent · notification language: ",
@@ -1047,7 +1400,9 @@ window.__ModuleLoader__.load({
1047
1400
  // page is open, otherwise the button proves nothing.
1048
1401
  testAlert(kind);
1049
1402
  var langName = effectiveLang() === "en" ? t("langEn") : t("langZh");
1050
- setFeedback(t("sent") + langName);
1403
+ var msg = t("sent") + langName;
1404
+ if (kind === "approval" && !bridgeActive()) msg += " · " + t("bridgeOff") + swState + t("bridgeOffTail");
1405
+ setFeedback(msg);
1051
1406
  }
1052
1407
  var permOK = ("Notification" in window) ? Notification.permission === "granted" : false;
1053
1408
  var resolvedLang = effectiveLang();
@@ -1067,6 +1422,10 @@ window.__ModuleLoader__.load({
1067
1422
  rowSwitch("currentQuiet", t("currentQuiet"), t("currentQuietHint"), cfg.currentHiddenOnly, function (v) { patch({ currentHiddenOnly: v }); }),
1068
1423
  rowSwitch("doneVisible", t("doneVisible"), t("doneVisibleHint"), !cfg.doneHiddenOnly, function (v) { patch({ doneHiddenOnly: !v }); }),
1069
1424
  rowSwitch("autoFocus", t("autoFocus"), t("autoFocusHint"), cfg.autoFocus, function (v) { patch({ autoFocus: v }); }),
1425
+ rowSwitch("quickActions", t("quickActions"), t("quickActionsHint"), cfg.quickActions, function (v) {
1426
+ patch({ quickActions: v });
1427
+ if (v) ensureActionBridge();
1428
+ }),
1070
1429
  h("div", { className: "dnm-row" }, [
1071
1430
  h("div", { className: "dnm-rowText" }, [
1072
1431
  h("div", { className: "dnm-rowLabel" }, t("volume")),
@@ -1106,9 +1465,15 @@ window.__ModuleLoader__.load({
1106
1465
  ]),
1107
1466
  h("div", { className: "dnm-btnRow" }, [
1108
1467
  h("button", { type: "button", className: "dnm-btn", disabled: !cfg.enabled, onClick: function () { runTest("attention"); } }, t("testAttention")),
1109
- h("button", { type: "button", className: "dnm-btn", disabled: !cfg.enabled, onClick: function () { runTest("done"); } }, t("testDone"))
1468
+ h("button", { type: "button", className: "dnm-btn", disabled: !cfg.enabled, onClick: function () { runTest("done"); } }, t("testDone")),
1469
+ h("button", { type: "button", className: "dnm-btn", disabled: !cfg.enabled || !cfg.quickActions, onClick: function () { runTest("approval"); } }, t("testApprovalBtn"))
1110
1470
  ])
1111
1471
  ]),
1472
+ cfg.quickActions && cfg.toast && !bridgeActive() ? h("div", { className: "dnm-row" }, [
1473
+ h("div", { className: "dnm-rowText" }, [h("div", { className: "dnm-rowHint" }, t("bridgeOff") + swState + t("bridgeOffTail"))])
1474
+ ]) : h("div", { className: "dnm-row" }, [
1475
+ h("div", { className: "dnm-rowText" }, [h("div", { className: "dnm-rowHint" }, t("quickApprovalNote"))])
1476
+ ]),
1112
1477
  h("div", { className: "dnm-row" }, [
1113
1478
  h("div", { className: "dnm-rowText" }, [
1114
1479
  h("div", { className: "dnm-rowLabel" }, t("reset")),
@@ -1197,6 +1562,10 @@ window.__ModuleLoader__.load({
1197
1562
  var onVisibility = function () { try { flushQuieted(); } catch (e) {} };
1198
1563
  try { document.addEventListener("visibilitychange", onVisibility); } catch (e) {}
1199
1564
  unsubs.push(function () { try { document.removeEventListener("visibilitychange", onVisibility); } catch (e) {} });
1565
+ // Quick-decision bridge: register the worker half of this bundle (a
1566
+ // no-op where quickActions is off or a worker cannot run) and start
1567
+ // listening for toast-button relays.
1568
+ try { ensureActionBridge(); } catch (e) {}
1200
1569
  // Interaction source first: it owns the title marker once present, so
1201
1570
  // binding it before the controller scan keeps a single mark per wait.
1202
1571
  bindUiSession();
@@ -1225,5 +1594,6 @@ window.__ModuleLoader__.load({
1225
1594
  exports.inject = inject;
1226
1595
  exports.apply = apply;
1227
1596
  return module.exports;
1228
- }
1229
- });
1597
+ }
1598
+ });
1599
+ })(typeof window !== "undefined" ? window : typeof self !== "undefined" ? self : null);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-notify-me",
3
- "version": "1.2.1",
4
- "description": "DSH 桌面消息提醒插件:模型需要你操作(审批/方案确认/提问的可操作提醒)或回复在后台完成时,系统通知+提示音+标签页标题提醒你;可在设置页开关并切换通知语言。Desktop notification & message alerts for DeepSeek Harness web — toasts, sounds and a tab-title marker when the agent needs your input (approval / plan review / question) or a reply finishes in the background; toggleable from Settings with zh/en notification language.",
3
+ "version": "1.3.0",
4
+ "description": "DSH 桌面消息提醒插件:模型需要你操作(审批/方案确认/提问的可操作提醒)或回复在后台完成时,系统通知+提示音+标签页标题提醒你;权限审批的通知上可直接点「同意/拒绝」完成裁决;可在设置页开关并切换通知语言。Desktop notification & message alerts for DeepSeek Harness web — toasts, sounds and a tab-title marker when the agent needs your input (approval / plan review / question) or a reply finishes in the background, Approve/Reject buttons on approval toasts to decide right from the notification; toggleable from Settings with zh/en notification language.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
@@ -34,10 +34,12 @@
34
34
  "message-alert",
35
35
  "needs-your-input",
36
36
  "approval-alert",
37
+ "approval-actions",
37
38
  "background-alert",
38
39
  "桌面通知",
39
40
  "消息提醒",
40
41
  "可操作提醒",
42
+ "审批提醒",
41
43
  "提醒插件",
42
44
  "需要你操作",
43
45
  "回复完成提醒",