dsh-notify-me 1.6.0 → 1.7.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,38 @@
2
2
 
3
3
  All notable changes to **dsh-notify-me** are documented here.
4
4
 
5
+ ## [1.7.0] — 2026-10-16
6
+
7
+ ### 新增
8
+ - **外部通知转发**(issue #12):把「需要你」「回复完成」提醒同时转发到你自己的推送端点,临时离开电脑时手机上的推送渠道照样收到。支持 **Discord Webhook** / **Bark** / **Gotify** / **自定义 Webhook** 四类目标,可配多个、各选类型填地址;负载形状:Discord 走 `{"username":"DSH","content":…}`(正文超长截 1900 字符),Bark 走 `{title, body, group}`,Gotify 走 `{title, message, priority}`(「需要你」priority 8、「回复完成」5),自定义端点收一条 POST JSON `{source, event, title, body, sessionId, time}`。
9
+ - 新配置项(默认保守,不填端点就什么都不发):`externalEnabled`(转发主开关,默认 `false`)、`externalAttention` / `externalDone`(两类提醒各自可关,默认开)、`externalTargets`(推送目标列表)。
10
+ - 设置页新增「外部通知」卡片:主开关、「转发「需要你」」「转发「回复完成」」、目标列表(类型下拉 + 地址 + 删除)、添加目标、隐私提示(提醒正文会原样外发,只填自己信任的端点)、「发送测试通知」。
11
+ - `window.__dshNotifyMe.testExternal()`:向所有目标发一条测试消息(不受两类提醒开关限制,也不会冒充真提醒),返回 `{sent, unverified, failed, statuses}`;`window.__dshNotifyMe.debug()` 新增 `external` 块(开关状态、目标数/有效目标数、成功/不可确认/失败计数与最后状态)。
12
+
13
+ ### 变更
14
+ - 转发挂在 `fire()` 里、本地提醒决定要发之后触发:**跟本地提醒完全同一套触发规则**(「当前对话不弹通知」「页面打开时也提醒」等开关同样管它);设置页的「测试提醒」按钮与快捷裁决反馈走 `deliver()`,不会误发 webhook。
15
+ - 跨域(CORS)口径:先走能读回响应的 CORS POST JSON;被拒(CORS/网络错误)时降级 no-cors + `text/plain` 送出,如实标为「已送出不可确认」(响应对页面不可见是浏览器规则,读不回就没法确认);Bark 另有经典 GET(`<地址>/<标题>/<正文>`)兜底;HTTP 错误如实记失败、不盲重试。
16
+ - 版本号 1.6.1 → 1.7.0;`window.__dshNotifyMe.version` 与 `package.json` 同步;npm 关键词新增 external-notification / webhook / discord / bark / gotify / push-notification / 外部通知 / 推送提醒。
17
+ - README(中英)同步:搜索词、设置清单、提醒方式、控制台配置块(四个新键 + `testExternal()`)、已知限制的 CORS 说明;致谢名单新增 [@RiZhiZhaoYi](https://github.com/RiZhiZhaoYi)(issue #12 的功能建议)。
18
+
19
+ ### 测试
20
+ - `smoke/smoke-test.cjs` 新增两段:① 转发链路(默认零外发、toast 测试不触发转发、四类负载形状、真实提醒镜像与两类开关、主开关、no-cors 降级、HTTP 500 单次不重试、Bark GET 兜底、非法 URL 剔除与未知类型归为 custom);② 设置页「外部通知」卡片(渲染与空状态、目标增删改落配置、测试按钮反馈文案、两种静默原因的提示语)。
21
+ - 修掉测试脚手架一处坑:假 React 里 onChange 事件之间不重渲染的话,下一个处理器闭包里的 `cfg` 还是旧的、会把前一次编辑悄悄覆盖(真 React 每个事件之间都会重渲染,产品代码不受影响)。为此把 `settingsBoot` 提升到模块级并补 `renderNow()` / `find` / `findAll` / `configNow()`;`rowSwitch` 开关按钮补 `aria-label`——可及性修正,同时让测试能按名按到。
22
+ - `npm test` 两套件均通过(含 `smoke/cordis-host-test.mjs` 真 cordis 腿)。
23
+
24
+ ## [1.6.1] — 2026-10-16
25
+
26
+ ### 修复
27
+ - **列表变化里「当前会话」只推导一次,消掉 O(行数 × 会话数)**(性能,issue #13):`isSubagentSession()` 现在接收调用方已经推导好的当前会话 id。此前它对**每一行**都要重新调 `currentSessionId()`,而 `0.1.6+`(含 `0.2.0-rc.2`)的列表快照已无 `current`,只能 `for (var id in byId)` 扫完整份会话表——一次列表变化因此是「行数 × 会话数」。实测(DSH `0.2.0-rc.2`、**482 个会话**、页面空闲时用 CDP 采 25s 与 38s 两轮 CPU profile):`currentSessionId()` 自身耗时占 **5.9%~6.5%**,是该页面最热的 JS 函数;修复后同轮 profile 里它退到噪声水平。第二窗口/后台窗口下所有行 `mainView` 为 0 时,旧实现每次都要扫满全表再返回 `null`,这一档同样消失。
28
+ - `evalUiStatus()` / `syncDoneFromStatus()` 两个「每个会话一条」的循环同样改为每次 pass 只推导一次。
29
+
30
+ ### 测试
31
+ - `smoke/smoke-test.cjs` 新增断言「一次列表变化的 `byId` 枚举次数不随行数增长」(40 行与 4 行必须相等,不写死魔数)。本版基线上改动前该断言报 `9 vs 45`(PR 在 1.5.3 基线测得 `7 vs 43`),修复后为 `5 vs 5`(先写失败断言、先红后绿)。
32
+
33
+ ### 变更
34
+ - 版本号 1.6.0 → 1.6.1;`window.__dshNotifyMe.version` 同步。
35
+ - README(中英)致谢名单新增 [@yanzhaohui1999](https://github.com/yanzhaohui1999)(issue #13 的性能报告与 PR #14 的修复及回归断言)。
36
+
5
37
  ## [1.6.0] — 2026-10-15
6
38
 
7
39
  ### 新增
package/README.en.md CHANGED
@@ -28,7 +28,7 @@ dsh-notify-me decouples **supervision** from **sitting in front of the screen**:
28
28
 
29
29
  ---
30
30
 
31
- > 🔎 Looking for it? Search `dsh-notify-me`, **message alerts**, **notification**, **desktop notification**, **reminder**, **approval alert** or **可操作提醒 / 消息提醒 / 桌面通知 / 需要你操作** (npm keywords include both English and Chinese terms).
31
+ > 🔎 Looking for it? Search `dsh-notify-me`, **message alerts**, **notification**, **desktop notification**, **reminder**, **approval alert**, **external notifications**, **Discord / Bark / Gotify push** or **可操作提醒 / 消息提醒 / 桌面通知 / 需要你操作** (npm keywords include both English and Chinese terms).
32
32
 
33
33
  ## UI Preview
34
34
 
@@ -55,6 +55,7 @@ Open **Settings → Notify me** (refresh the page once after installing):
55
55
  - **Environment** (auto-detect by default) — identifies Web vs Desktop (DSH Desktop, `dsh-app://`) from the page protocol, with a manual override for hosts where detection comes out wrong: on the Desktop a toast click rides the `dsh://` deep link to raise the window and quick-decision buttons are unavailable; on the Web it is `window.focus()` plus the Service Worker bridge. Whenever the override disagrees with detection, the settings page says what detection saw;
56
56
  - **System notifications / Sound / Volume** — toast toggle, cue toggle and a volume slider;
57
57
  - **Alert sound** (picked separately for "needs you" and "reply finished") — built-in cue (default) / custom audio file / system notification sound. "Custom audio file" takes one local audio file in the settings page (1 MiB limit, stored in the browser, with preview and remove); "system notification sound" plays no plugin cue at all — the toast still appears and the operating system supplies the sound, so its timbre follows your system notification settings; in that mode "Preview" sends a test alert instead, because the sound only exists as part of a toast and that is the sound you hear. Any source that has no file, cannot be read, or does not fit falls back to the built-in cue, so an alert never goes silent;
58
+ - **External notifications** (default off) — forward each alert to your own push endpoints as well, so it reaches you away from the machine: **Discord Webhook** / **Bark** / **Gotify** / **custom webhook**, any number of targets, each with a type and URL (Discord takes the webhook URL; Bark `https://api.day.app/<device key>`; Gotify `https://<server>/message?token=<app token>`; a custom endpoint receives one POST of JSON: `{source, event, title, body, sessionId, time}`). "Forward 'needs you'" and "Forward 'reply finished'" switch independently; **Send test notification** sends one test message to every target (not limited by those two switches, and never pretending to be a real alert). Forwarding follows exactly the same trigger rules as local alerts ("stay quiet for the conversation on screen" and friends apply to it too); the alert text is sent as-is to the addresses you fill in, so **only use endpoints you trust**;
58
59
  - **"Needs you" alerts while the page is open** (default on) and **"Reply finished" alerts while the page is open** (default off);
59
60
  - **Stay quiet for the conversation on screen** (default on): a wait inside the conversation you are looking at keeps only the tab marker — no toast or sound landing on the approval card. Backgrounding the page puts that alert back at once, unless you already handled it. Waits in other background sessions are unaffected;
60
61
  - **Ignore subagent sessions** (default on): background subagent child sessions raise neither "needs you" nor "reply finished" — those are steps of the parent conversation's turn, and the parent's own alert already covers them. The subagent session you have open alerts as usual. Turn it off to alert for every session;
@@ -71,6 +72,7 @@ Open **Settings → Notify me** (refresh the page once after installing):
71
72
  - **System notification** — a toast through the browser into the Windows action center / macOS Notification Center (clicking it opens the conversation it came from; approval toasts can be decided straight from the buttons)
72
73
  - **Sound** — WebAudio beeps by default (distinct patterns for "needs you" vs "done"); each kind can instead play a picked audio file, or hand the sound over to the operating system's own notification sound
73
74
  - **Tab title marker** — while something is waiting on you, the tab title is prefixed with `🔔 Action needed · …` / `🔔 需要你 · …`; while a background session is **finished but unread**, with `✅ Reply finished · …` / `✅ 回复完成 · …`, gone once you open that session (or it starts running again). Both kinds can be up together, "needs you" first; a host title change is rebased under the markers
75
+ - **External notifications** (optional, default off) — the same alert is also forwarded to the push targets you configure: Discord Webhook / Bark / Gotify / custom webhook — so the push channel on your phone picks it up while you step away from the machine
74
76
 
75
77
  This is a **browser-layer** plugin: the DSH page must stay open (minimized or backgrounded is fine — that's exactly the "away" state it watches for).
76
78
 
@@ -129,10 +131,15 @@ window.__dshNotifyMe.setConfig({
129
131
  soundAttention: "synth", // "needs you" cue source, same values
130
132
  volume: 0.5, // 0..1
131
133
  autoFocus: true, // clicking a toast opens the conversation it came from
132
- quickActions: true // Approve / Reject buttons on approval toasts
134
+ quickActions: true, // Approve / Reject buttons on approval toasts
135
+ externalEnabled: false, // external-notification forwarding master switch (default off: no endpoint, nothing is sent)
136
+ externalAttention: true, // forward "needs you" (exactly the same trigger rules as local alerts)
137
+ externalDone: true, // forward "reply finished"
138
+ externalTargets: [] // push targets: [{id, type:'discord'|'bark'|'gotify'|'custom', url}]
133
139
  })
134
140
  window.__dshNotifyMe.resetConfig() // restore defaults (also drops any picked audio files)
135
141
  window.__dshNotifyMe.decide("approval:3", "allowed-once") // programmatic decision: "allowed-once" | "rejected"
142
+ window.__dshNotifyMe.testExternal() // send one test notification to every push target, resolves {sent, unverified, failed, statuses}
136
143
  ```
137
144
 
138
145
  > Picked audio files do not live in `dshNotifyMe.config`: each kind gets its own key (`dshNotifyMe.sound.done` / `dshNotifyMe.sound.attention`, holding `{name, size, data}` with a data URL), because a file inflates by ~1.37x once base64-encoded and would slow down every config read. `resetConfig()` clears both keys along with the config.
@@ -174,6 +181,7 @@ Verified on the real shell (DSH Desktop 0.2.0-rc.2, three rounds of retesting in
174
181
  - 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.
175
182
  - 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.
176
183
  - "Stay quiet for the conversation on screen" is judged by `document.hidden`: a window fully covered by other apps still counts as visible, so waits in the current conversation stay quiet there too.
184
+ - External notification forwarding is subject to browser CORS rules: an endpoint that does not allow cross-origin requests falls back to a "sent but unverifiable" delivery (no-cors) and is reported honestly as such; Discord webhooks do allow cross-origin, so delivery to them is confirmed. HTTP errors are recorded as failures and never blindly retried. Forwarding runs only after the local alert has decided to fire, so anything the rules silence is never sent out.
177
185
 
178
186
  ## Development
179
187
 
@@ -195,6 +203,8 @@ Contributors (by handle; the link points at the PR / issue they worked on):
195
203
  - [@d0ublecl1ck](https://github.com/d0ublecl1ck) — [#8](https://github.com/chromoany/dsh-notify-me/pull/8): muting subagent sessions (`ignoreSubagent`) with its tests and docs; the 1.4.0 refinement — mute only background subagents, alert as usual for the one you have open — is his suggestion from the #8 discussion, landed by the maintainer;
196
204
  - [@EliteOtaku](https://github.com/EliteOtaku) — [#9](https://github.com/chromoany/dsh-notify-me/issues/9): the layer-by-layer investigation and three rounds of real-shell retesting of the desktop raise path (1.5.2) — confirming that a toast click raises the window and switches conversations reliably, and pinning down the reproduction difference ("gesture-less scripted injection is silently dropped by Chromium"), which corrected the initial "the sandbox blocks it" reading;
197
205
  - [@Wh1stle05](https://github.com/Wh1stle05) — [#10](https://github.com/chromoany/dsh-notify-me/pull/10): the per-kind alert sound source (built-in synth / picked audio file / system notification sound, chosen independently for "needs you" and "reply done"), with its settings UI, fallback chain, tests and docs; the two refinements — a "system" preview sending a real test notification instead of doing nothing, and the fake React holding hook state by index across renders — come from the same PR.
206
+ - [@yanzhaohui1999](https://github.com/yanzhaohui1999) — [#13](https://github.com/chromoany/dsh-notify-me/issues/13) / [#14](https://github.com/chromoany/dsh-notify-me/pull/14): the 482-session idle-page CPU report (pinning `currentSessionId()` rescanning the whole table per row, 5.9%~6.5% of CPU) and the fix (derive the on-screen session once per list change), with a red-then-green regression assertion — a 4-row and a 40-row list change must cost the same (upstream reported 7 vs 43, 5 vs 5 after).
207
+ - [@RiZhiZhaoYi](https://github.com/RiZhiZhaoYi) — [#12](https://github.com/chromoany/dsh-notify-me/issues/12): the external-notification forwarding suggestion (Discord Webhook / Bark / Gotify / custom webhook) and its use case (1.7.0).
198
208
 
199
209
  Attribution policy: external PRs are landed as maintainer commits — the original commit is not reused and no Co-authored-by trailer is added, so GitHub's contributors graph lists the maintainer only and contributors are credited above.
200
210
 
package/README.md CHANGED
@@ -28,7 +28,7 @@ dsh-notify-me 把「监督」和「守在屏幕前」解耦:只在真的出现
28
28
 
29
29
  ---
30
30
 
31
- > 🔎 想找这个插件?可以搜:`dsh-notify-me`、**消息提醒**、**可操作提醒**、**桌面通知**、**需要你操作**、**回复完成提醒**、**后台完成提醒**(npm 关键词已含以上中英文词)。
31
+ > 🔎 想找这个插件?可以搜:`dsh-notify-me`、**消息提醒**、**可操作提醒**、**桌面通知**、**需要你操作**、**回复完成提醒**、**后台完成提醒**、**外部通知**、**Discord / Bark / Gotify 推送**(npm 关键词已含以上中英文词)。
32
32
 
33
33
  ## 效果预览
34
34
 
@@ -55,6 +55,7 @@ dsh-notify-me 把「监督」和「守在屏幕前」解耦:只在真的出现
55
55
  - **运行环境**(默认自动识别):按页面协议识别 Web 端 / 桌面端(DSH Desktop,`dsh-app://`),识别不准时可手动指定——桌面端点通知走 `dsh://` 深链抬窗、快捷裁决按钮不可用;Web 端走 `window.focus()` 与 Service Worker 桥。手动覆盖与自动识别不一致时,设置页会明示检测结果;
56
56
  - **系统通知 / 提示音 / 音量**:Toast、提示音开关与音量滑块;
57
57
  - **音效来源**(「需要你」与「回复完成」各自独立选):内置合成音(默认)/ 自定义音频文件 / 系统通知音。选「自定义音频文件」可在设置页挑一个本地音频(上限 1 MiB,存在浏览器本地,可试听与移除);选「系统通知音」则完全不播插件音效,通知照发,声音交给操作系统(音色跟着系统通知设置走),此时「试听」会改发一条测试通知——该模式没有插件可播的音,你听到的就是它。任一来源缺文件、读不出来或存不下时自动回落到内置合成音,提醒不会因此变成哑的;
58
+ - **外部通知**(默认关):把提醒同时转发到你自己的推送端点,人不在电脑前也能收到——支持 **Discord Webhook** / **Bark** / **Gotify** / **自定义 Webhook**,可加多个目标,每个选类型填地址(Discord 填 Webhook 地址;Bark 填 `https://api.day.app/<设备密钥>`;Gotify 填 `https://<服务器>/message?token=<应用令牌>`;自定义端点收一条 POST JSON:`{source, event, title, body, sessionId, time}`);「转发「需要你」」「转发「回复完成」」各自独立开关;**发送测试通知**往所有目标发一条测试消息(不受这两类开关限制,也不会冒充真提醒)。转发跟本地提醒走同一套触发规则(「当前对话不弹通知」等开关同样管它);提醒正文会原样发往你填的地址,**只填你自己信任的端点**;
58
59
  - **页面打开时也提醒「需要你」**(默认开)与 **页面打开时也提醒「回复完成」**(默认关);
59
60
  - **当前对话不弹通知**(默认开):等待就发生在你正看着的对话里时只留标题标记,不弹通知也不响音(免得盖住审批卡片);页面切到后台立刻补发,若你已经处理掉就作废。其它后台会话不受影响;
60
61
  - **子代理会话不提醒**(默认开):后台子代理(subagent)子会话的「需要你」与「回复完成」都不提醒——它们是主对话那一轮的步骤,完成由主对话自己的提醒覆盖;你当前打开的那个子代理会话照常提醒;关掉后恢复逐会话提醒;
@@ -71,6 +72,7 @@ dsh-notify-me 把「监督」和「守在屏幕前」解耦:只在真的出现
71
72
  - **系统通知**:经浏览器弹出的系统通知,进 Windows 通知中心或 macOS 通知中心(点击回到提醒所属的对话;审批的通知点按钮直接裁决)
72
73
  - **提示音**:默认是 WebAudio 合成音(「需要你」与「完成」使用不同音型);可在设置里为两类提醒分别换成自定义音频文件,或改成让操作系统播放它自己的通知音
73
74
  - **标签页标题标记**:有待处理事项时,标题前出现 `🔔 需要你 · …` / `🔔 Action needed · …`;后台会话**完成但还没看**时出现 `✅ 回复完成 · …` / `✅ Reply finished · …`,打开对应会话(或它再次开跑)即消失。两种标记可同时出现,「需要你」在前;宿主改写标题后标记基于新标题重建
75
+ - **外部通知**(可选,默认关):按设置里配的推送目标,把同一条提醒转发到 Discord Webhook / Bark / Gotify / 自定义 Webhook——临时离开电脑时,手机上的推送渠道照样收到
74
76
 
75
77
  本插件是**浏览器层**实现:DSH 页面需保持打开(最小化/后台即可——那正是它监听的「离开」状态)。
76
78
 
@@ -125,10 +127,15 @@ window.__dshNotifyMe.setConfig({
125
127
  soundAttention: "synth", // 「需要你」音效来源,取值同上
126
128
  volume: 0.5, // 音量 0~1
127
129
  autoFocus: true, // 点通知回到提醒所属的对话
128
- quickActions: true // 审批通知上的「同意 / 拒绝」按钮
130
+ quickActions: true, // 审批通知上的「同意 / 拒绝」按钮
131
+ externalEnabled: false, // 外部通知转发主开关(默认关:不填端点就什么都不发)
132
+ externalAttention: true, // 转发「需要你」(触发规则跟本地提醒完全一致)
133
+ externalDone: true, // 转发「回复完成」
134
+ externalTargets: [] // 推送目标:[{id, type:'discord'|'bark'|'gotify'|'custom', url}]
129
135
  })
130
136
  window.__dshNotifyMe.resetConfig() // 恢复默认(同时清掉已选的自定义音效文件)
131
137
  window.__dshNotifyMe.decide("approval:3", "allowed-once") // 程序化裁决:"allowed-once" | "rejected"
138
+ window.__dshNotifyMe.testExternal() // 向所有推送目标发一条测试通知,返回 {sent, unverified, failed, statuses}
132
139
  ```
133
140
 
134
141
  > 自定义音效文件不在 `dshNotifyMe.config` 里,而是按类别各存一个键(`dshNotifyMe.sound.done` / `dshNotifyMe.sound.attention`,内容是 `{name, size, data}` 的 data URL)——一个文件转成 data URL 会膨胀约 1.37 倍,塞进配置会拖慢每次读取。`resetConfig()` 会连这两个键一起清掉。
@@ -170,6 +177,7 @@ DSH Desktop 是一层 Electron 壳,页面跑在 `dsh-app://app` 下:DOM 的
170
177
  - `≥ 0.1.6`(含 `0.2.0-rc.2`)的「回复完成」提醒正文只有会话名,不再附带回复摘要——宿主快照已不再提供 `nodes`;提醒本身照常触发。
171
178
  - 标题标记由宿主与插件共同写 `document.title`:宿主重算标题时标记可能被覆盖,直到下一次提醒事件重新写成。
172
179
  - 「当前对话不弹通知」按 `document.hidden` 判定:窗口被别的应用完全盖住时仍算「页面可见」,此时当前对话的等待照样静默。
180
+ - 外部通知转发受浏览器跨域(CORS)限制:端点不放行时自动降级为「送出但不可确认」(no-cors),如实标为「已送出不可确认」;Discord Webhook 官方放行跨域,可直接确认送达。HTTP 错误如实记失败、不重试。转发在本地提醒决定要发之后才触发,所以被「当前对话不弹通知」等规则静音的提醒也不会外发。
173
181
 
174
182
  ## 开发自检
175
183
 
@@ -191,6 +199,8 @@ npm pack --dry-run # 预览发布包
191
199
  - [@d0ublecl1ck](https://github.com/d0ublecl1ck) — [#8](https://github.com/chromoany/dsh-notify-me/pull/8):子代理会话静音(`ignoreSubagent`)与配套测试、文档;1.4.0 里「只静音后台子代理、当前打开的照常提醒」的口径同样出自他在 #8 讨论里的建议,落地由维护者完成;
192
200
  - [@EliteOtaku](https://github.com/EliteOtaku) — [#9](https://github.com/chromoany/dsh-notify-me/issues/9):桌面端抬窗链路(1.5.2)的逐层排查与真实壳三轮复测——确认点通知抬窗 + 切会话稳定生效,并定位出「无用户手势的脚本化注入被 Chromium 静默丢弃」这一复现差异,纠正了最初「沙箱拦死」的判断;
193
201
  - [@Wh1stle05](https://github.com/Wh1stle05) — [#10](https://github.com/chromoany/dsh-notify-me/pull/10):「音效来源」功能(内置合成音 / 自定义音频文件 / 系统通知音,「需要你」与「回复完成」各自独立选),含设置页交互、回落链与测试、文档;「系统通知音」试听改发测试通知、假 React 按索引跨渲染保持 hook 状态这两处修正同样出自该 PR。
202
+ - [@yanzhaohui1999](https://github.com/yanzhaohui1999) — [#13](https://github.com/chromoany/dsh-notify-me/issues/13) / [#14](https://github.com/chromoany/dsh-notify-me/pull/14):482 个会话下空闲页面烧 CPU 的性能报告(定位到 `currentSessionId()` 每行重扫全表,实测占 CPU 5.9%~6.5%)与修复(一次列表变化只推导一次「当前会话」),并带先红后绿的回归断言——4 行与 40 行的列表变化代价必须相等(上游报 7 vs 43,修后 5 vs 5)。
203
+ - [@RiZhiZhaoYi](https://github.com/RiZhiZhaoYi) — [#12](https://github.com/chromoany/dsh-notify-me/issues/12):外部通知转发(Discord Webhook / Bark / Gotify / 自定义 Webhook)的功能建议与使用场景(1.7.0)。
194
204
 
195
205
  署名口径:本仓库收编外部 PR 时以维护者提交落地——不沿用原 commit、不挂 Co-authored-by,GitHub 的 contributors 图只显示维护者,贡献者以上面的致谢署名。
196
206
 
package/lib/client.js CHANGED
@@ -255,7 +255,16 @@
255
255
  // approval card sends). Buttons need persistent notifications, served
256
256
  // through the Service Worker half of this same file; where that cannot
257
257
  // run, the toast simply falls back to its plain form.
258
- quickActions: true
258
+ quickActions: true,
259
+ // External forwarding: mirror every alert the plugin actually delivers to
260
+ // webhook endpoints (Discord / Bark / Gotify / custom), so a phone or
261
+ // another box can tell the user to come back. Off by default — nothing
262
+ // leaves the browser until the user fills in an endpoint of their own.
263
+ externalEnabled: false,
264
+ externalAttention: true, // forward the "needs you" alerts
265
+ externalDone: true, // forward the "reply finished" alerts
266
+ // [{ id: string, type: 'discord' | 'bark' | 'gotify' | 'custom', url: string }]
267
+ externalTargets: []
259
268
  };
260
269
  function readConfig() {
261
270
  var cfg = {};
@@ -336,7 +345,9 @@
336
345
  testDone: "测试提醒 · 回复完成示例",
337
346
  testApproval: "这是一条测试审批:点通知上的「同意 / 拒绝」按钮试试快捷裁决",
338
347
  testApproved: "测试提醒 · 已模拟「同意」(仅测试,未改动任何会话)",
339
- testRejected: "测试提醒 · 已模拟「拒绝」(仅测试,未改动任何会话)"
348
+ testRejected: "测试提醒 · 已模拟「拒绝」(仅测试,未改动任何会话)",
349
+ testExternalTitle: "外部通知测试",
350
+ testExternalBody: "这是一条测试消息:外部通知通道工作正常"
340
351
  },
341
352
  en: {
342
353
  kind: { approval: "Approval request", "plan-review": "Plan review", question: "Question" },
@@ -360,7 +371,9 @@
360
371
  testDone: "Test alert · reply-finished sample",
361
372
  testApproval: "This is a test approval: try quick decisions with the Approve / Reject buttons on the toast",
362
373
  testApproved: "Test alert · simulated \"Approve\" (test only, nothing changed)",
363
- testRejected: "Test alert · simulated \"Reject\" (test only, nothing changed)"
374
+ testRejected: "Test alert · simulated \"Reject\" (test only, nothing changed)",
375
+ testExternalTitle: "External notification test",
376
+ testExternalBody: "Test message: the external notification channel works"
364
377
  }
365
378
  };
366
379
  // Locale service handle (registered on the client context by dsh-client-locale).
@@ -809,6 +822,136 @@
809
822
  window.addEventListener("pointerdown", onFirstGesture, true);
810
823
  window.addEventListener("keydown", onFirstGesture, true);
811
824
 
825
+ // ───────────────────── external webhook forwarding ─────────────────────
826
+ // Every alert fire() actually delivers is mirrored to the user's webhook
827
+ // targets (Discord / Bark / Gotify / custom), so being away from the
828
+ // machine does not mean missing the "come back" cue. Off by default:
829
+ // nothing leaves the browser until the user fills in an endpoint.
830
+ //
831
+ // CORS reality: a page can only read a response the endpoint allows it to
832
+ // read. Discord webhooks answer with Access-Control-Allow-Origin: * and
833
+ // work directly; a self-hosted endpoint may not. So each send tries a
834
+ // plain CORS POST first and, only when the browser itself refuses the
835
+ // exchange (a rejection from fetch — not an HTTP error status, which means
836
+ // the server did see the request), retries as a no-cors POST with a
837
+ // text/plain content type: the request is still delivered, only the reply
838
+ // is unreadable ("unverified"). Bark gets a third chance in the classic
839
+ // GET shape `url/<title>/<body>`, which needs no CORS at all. Real HTTP
840
+ // errors are reported as failures and never retried blindly.
841
+ var externalStat = { sent: 0, unverified: 0, failed: 0, lastAt: null, lastStatus: null };
842
+ // Valid, enabled targets only. A stale entry (bad URL, unknown type) is
843
+ // skipped rather than breaking the others; the settings page only ever
844
+ // writes well-formed entries.
845
+ function externalTargets() {
846
+ var out = [];
847
+ var list = config.externalTargets;
848
+ if (!Array.isArray(list)) return out;
849
+ for (var i = 0; i < list.length; i++) {
850
+ var t = list[i];
851
+ if (!t || typeof t !== "object" || t.enabled === false) continue;
852
+ var url = typeof t.url === "string" ? t.url.trim() : "";
853
+ if (!/^https?:\/\//i.test(url)) continue;
854
+ var type = t.type;
855
+ if (type !== "discord" && type !== "bark" && type !== "gotify") type = "custom";
856
+ out.push({ id: typeof t.id === "string" ? t.id : "", type: type, url: url });
857
+ }
858
+ return out;
859
+ }
860
+ // Per-endpoint JSON body, each shaped by that service's own convention;
861
+ // the custom type gets the full generic event so any JSON endpoint works.
862
+ function externalBody(type, ev) {
863
+ if (type === "discord") {
864
+ var content = ev.title + "\n" + ev.body;
865
+ if (content.length > 1900) content = content.slice(0, 1900) + "…";
866
+ return JSON.stringify({ username: "DSH", content: content });
867
+ }
868
+ if (type === "bark") {
869
+ return JSON.stringify({ title: ev.title, body: ev.body, group: "dsh-notify-me" });
870
+ }
871
+ if (type === "gotify") {
872
+ return JSON.stringify({ title: ev.title, message: ev.body, priority: ev.kind === "attention" ? 8 : 5 });
873
+ }
874
+ return JSON.stringify({
875
+ source: "dsh-notify-me",
876
+ version: api.version,
877
+ event: ev.kind,
878
+ title: ev.title,
879
+ body: ev.body,
880
+ sessionId: ev.sessionId || null,
881
+ time: ev.time
882
+ });
883
+ }
884
+ // Resolves to 'sent' | 'unverified' | 'failed (…)'; never rejects.
885
+ function postOnce(url, bodyStr, plain) {
886
+ return fetch(url, {
887
+ method: "POST",
888
+ mode: plain ? "no-cors" : "cors",
889
+ cache: "no-store",
890
+ headers: { "Content-Type": plain ? "text/plain;charset=UTF-8" : "application/json" },
891
+ body: bodyStr
892
+ }).then(function (res) {
893
+ if (plain) return "unverified"; // opaque: delivered, reply unreadable
894
+ if (res && res.ok) return "sent";
895
+ return "failed (HTTP " + (res ? res.status : "?") + ")";
896
+ });
897
+ }
898
+ // Bark's path form: url/<title>/<body>. A plain GET first (readable), then
899
+ // a no-cors GET — which the browser always hands back, so treat it as
900
+ // unverified rather than guaranteed.
901
+ function barkGet(url, ev) {
902
+ var u = url.replace(/\/+$/, "") + "/" + encodeURIComponent(ev.title || "DSH") + "/" + encodeURIComponent(ev.body || "");
903
+ return fetch(u, { method: "GET", cache: "no-store" }).then(function (res) {
904
+ return res && res.ok ? "sent" : Promise.reject(new Error("HTTP " + (res ? res.status : "?")));
905
+ }).catch(function () {
906
+ return fetch(u, { method: "GET", mode: "no-cors", cache: "no-store" }).then(function () { return "unverified"; });
907
+ });
908
+ }
909
+ function postExternal(target, ev) {
910
+ if (typeof fetch !== "function") return Promise.resolve("failed (no fetch)");
911
+ var bodyStr = externalBody(target.type, ev);
912
+ return postOnce(target.url, bodyStr, false).catch(function (err) {
913
+ var msg = "failed (" + (err && err.message ? err.message : String(err)) + ")";
914
+ return postOnce(target.url, bodyStr, true).then(null, function () {
915
+ if (target.type !== "bark") return msg;
916
+ return barkGet(target.url, ev).then(null, function () { return msg; });
917
+ });
918
+ }).then(function (status) {
919
+ if (status === "sent") externalStat.sent++;
920
+ else if (status === "unverified") externalStat.unverified++;
921
+ else externalStat.failed++;
922
+ externalStat.lastAt = new Date().toISOString();
923
+ externalStat.lastStatus = status;
924
+ return status;
925
+ });
926
+ }
927
+ // Mirror one delivered alert to every enabled target. The alert path is
928
+ // fire-and-forget — the toast must never wait on the network; `onDone`
929
+ // exists for the settings test button, which wants the verdict.
930
+ // The per-kind toggles gate real alerts only; the test button posts
931
+ // regardless (that is the point of a channel test).
932
+ function dispatchExternal(kind, title, body, sessionId, onDone) {
933
+ if (!config.externalEnabled) return;
934
+ if (kind === "attention" && !config.externalAttention) return;
935
+ if (kind === "done" && !config.externalDone) return;
936
+ sendExternalToAll(kind, title, body, sessionId, onDone);
937
+ }
938
+ function sendExternalToAll(kind, title, body, sessionId, onDone) {
939
+ var targets = externalTargets();
940
+ if (!targets.length) { if (typeof onDone === "function") onDone([]); return; }
941
+ var ev = {
942
+ kind: kind,
943
+ title: title,
944
+ body: body || "",
945
+ sessionId: sessionId || null,
946
+ time: new Date().toISOString()
947
+ };
948
+ var ps = [];
949
+ for (var i = 0; i < targets.length; i++) {
950
+ try { ps.push(postExternal(targets[i], ev)); } catch (e) { ps.push(Promise.resolve("failed (dispatch)")); }
951
+ }
952
+ if (typeof onDone === "function") Promise.all(ps).then(onDone, function () {});
953
+ }
954
+
812
955
  // ────────────────────────── title marker ─────────────────────────
813
956
  // The host owns document.title (it tracks the session/workspace name), so
814
957
  // the base title is re-read on every apply instead of being frozen once —
@@ -958,6 +1101,10 @@
958
1101
  if (kind === "attention" && quietedByVisibleSession(isCurrent)) return false;
959
1102
  if (kind === "done" && config.doneHiddenOnly && !hidden()) return false;
960
1103
  deliver(kind, title, body, sessionId, quick);
1104
+ // The alert reached the user locally (or would have, had the tab a
1105
+ // voice): mirror the same copy to the outside world. Fire-and-forget —
1106
+ // a slow endpoint must never delay or break the local alert.
1107
+ try { dispatchExternal(kind, title, body, sessionId); } catch (e) {}
961
1108
  return true;
962
1109
  }
963
1110
  // True when this alert must stay silent purely because it is about the
@@ -1033,9 +1180,14 @@
1033
1180
  // current-conversation quiet rule) and is never a mute candidate. Read live
1034
1181
  // rather than cached, so the settings switch and session switches both take
1035
1182
  // effect on the next event without rebinding anything.
1036
- function isSubagentSession(sid) {
1183
+ function isSubagentSession(sid, currentId) {
1037
1184
  if (!config.ignoreSubagent || !sid) return false;
1038
- if (currentSessionId() === sid) return false;
1185
+ // Reuse the caller's derivation when it has one. Every row walk already
1186
+ // knows the on-screen session (onListChanged derives it once and then
1187
+ // calls this per row), and deriving it again inside the row rescans the
1188
+ // whole list: O(rows x sessions) on 0.1.6+ hosts, where the list snapshot
1189
+ // has no `current` and the on-screen row has to be found by walking byId.
1190
+ if ((currentId === undefined ? currentSessionId() : currentId) === sid) return false;
1039
1191
  try {
1040
1192
  var st = listStore && listStore.getSnapshot();
1041
1193
  var sum = st && st.byId && st.byId[sid];
@@ -1188,14 +1340,14 @@
1188
1340
  } catch (e) {}
1189
1341
  return "";
1190
1342
  }
1191
- // Evaluate one row/face observation. row: {id, running, pending:[{key,kind,payload}], completed, label, face:bool}
1343
+ // Evaluate one row/face observation. row: {id, running, pending:[{key,kind,payload}], completed, label, face:bool, currentId?}
1192
1344
  function evaluateRow(row) {
1193
1345
  var prev = prevById[row.id];
1194
1346
  var runningNow = !!row.running;
1195
1347
  var pendingNow = row.pending || [];
1196
1348
  var prevPendingKeys = prev ? prev.pendingKeys : [];
1197
1349
  var wasRunning = prev ? prev.running : false;
1198
- var muted = isSubagentSession(row.id);
1350
+ var muted = isSubagentSession(row.id, row.currentId);
1199
1351
  var newKeys = [];
1200
1352
  // A muted row still feeds the baseline below, so lifting the mute
1201
1353
  // (turning the switch off, or opening that subagent session) resumes
@@ -1283,6 +1435,7 @@
1283
1435
  completed: false,
1284
1436
  face: true,
1285
1437
  faceSnap: snap,
1438
+ currentId: sessionId,
1286
1439
  label: "current"
1287
1440
  });
1288
1441
  }
@@ -1294,6 +1447,7 @@
1294
1447
  pending: sum.pendingInteraction ? [{ key: "list:" + sid + ":" + sum.pendingInteraction, kind: sum.pendingInteraction, payload: null }] : [],
1295
1448
  completed: !!sum.completed,
1296
1449
  face: false,
1450
+ currentId: currentId,
1297
1451
  label: sum.displayTitle || sum.title || sid
1298
1452
  });
1299
1453
  }
@@ -1467,6 +1621,7 @@
1467
1621
  if (!snap || typeof snap.forEach !== "function") return;
1468
1622
  if (found.source === "sessionStatus") syncDoneFromStatus(snap);
1469
1623
  var seen = Object.create(null);
1624
+ var cur = currentSessionId();
1470
1625
  snap.forEach(function (value, sid) {
1471
1626
  var it = pendingOf(value, found.source);
1472
1627
  // A session with no pending interaction is not "seen": leaving it out
@@ -1478,7 +1633,7 @@
1478
1633
  // announced before the mute was switched on (or before the user
1479
1634
  // switched away from that subagent session). Opening the session later
1480
1635
  // announces the outstanding wait then, like any first sighting.
1481
- if (isSubagentSession(sid)) return;
1636
+ if (isSubagentSession(sid, cur)) return;
1482
1637
  seen[sid] = true;
1483
1638
  var key = it.key || (it.kind + ":" + sid);
1484
1639
  var prev = uiBaseline[sid];
@@ -1520,6 +1675,7 @@
1520
1675
  function syncDoneFromStatus(snap) {
1521
1676
  var want = Object.create(null);
1522
1677
  try {
1678
+ var cur = currentSessionId();
1523
1679
  snap.forEach(function (value, sid) {
1524
1680
  if (!value) return;
1525
1681
  // A stop that awaits input (plan-review / question end the turn with
@@ -1527,7 +1683,7 @@
1527
1683
  // as the edge path's !hasPendingNow guard. The on-screen session is
1528
1684
  // never "unread" (the host clears the flag for it; its completion is
1529
1685
  // the face edge's job), and a background subagent step is muted.
1530
- if (!value.completionUnread || value.pendingInteraction || isSubagentSession(sid) || currentSessionId() === sid) return;
1686
+ if (!value.completionUnread || value.pendingInteraction || isSubagentSession(sid, cur) || cur === sid) return;
1531
1687
  want[sid] = true;
1532
1688
  markDoneUnread(sid);
1533
1689
  if (uiDoneBaseline[sid]) return; // this unread instance already reported
@@ -1567,7 +1723,7 @@
1567
1723
  });
1568
1724
  } catch (e) {}
1569
1725
  // keep in sync with package.json "version" on every release
1570
- api.version = "1.6.0";
1726
+ api.version = "1.7.0";
1571
1727
  api.setConfig = function (patch) { return setConfigLocal(patch); };
1572
1728
  api.resetConfig = function () {
1573
1729
  try {
@@ -1594,6 +1750,26 @@
1594
1750
  api.decide = function (key, outcome) {
1595
1751
  return decidePending(key, outcome === "rejected" ? "rejected" : "allowed-once");
1596
1752
  };
1753
+ // Channel test for the external webhooks: posts a synthetic event to every
1754
+ // configured target. An explicit console call counts as consent, so it runs
1755
+ // regardless of the master / per-kind toggles (those gate real alerts);
1756
+ // it still only ever talks to endpoints the user filled in themselves.
1757
+ api.testExternal = function () {
1758
+ return new Promise(function (resolve) {
1759
+ var targets = externalTargets();
1760
+ if (!targets.length) { resolve({ sent: 0, unverified: 0, failed: 0, statuses: [] }); return; }
1761
+ var d = texts();
1762
+ sendExternalToAll("attention", d.testExternalTitle, d.testExternalBody, currentSessionId(), function (statuses) {
1763
+ var out = { sent: 0, unverified: 0, failed: 0, statuses: statuses };
1764
+ for (var i = 0; i < statuses.length; i++) {
1765
+ if (statuses[i] === "sent") out.sent++;
1766
+ else if (statuses[i] === "unverified") out.unverified++;
1767
+ else out.failed++;
1768
+ }
1769
+ resolve(out);
1770
+ });
1771
+ });
1772
+ };
1597
1773
  // Live diagnostics for the "why did nothing pop" case. The needs-input
1598
1774
  // channel has three possible owners on current hosts (uiSession.sessionStatus
1599
1775
  // on 0.1.6+, uiSession.pendingInteractions on 0.1.2-0.1.5, the controller
@@ -1615,6 +1791,20 @@
1615
1791
  customSoundDone: !!readSound("done"),
1616
1792
  customSoundAttention: !!readSound("attention"),
1617
1793
  quickActions: config.quickActions,
1794
+ // external webhook forwarding: counts come from externalStat (tallied
1795
+ // on every send), targets is the raw vs validated count.
1796
+ external: {
1797
+ enabled: config.externalEnabled,
1798
+ forwardAttention: config.externalAttention,
1799
+ forwardDone: config.externalDone,
1800
+ targets: Array.isArray(config.externalTargets) ? config.externalTargets.length : 0,
1801
+ activeTargets: externalTargets().length,
1802
+ sent: externalStat.sent,
1803
+ unverified: externalStat.unverified,
1804
+ failed: externalStat.failed,
1805
+ lastAt: externalStat.lastAt,
1806
+ lastStatus: externalStat.lastStatus
1807
+ },
1618
1808
  // quick-decision bridge: 'active' is the only state in which approval
1619
1809
  // toasts carry working 同意/拒绝 buttons.
1620
1810
  bridge: swState,
@@ -1685,6 +1875,37 @@
1685
1875
  soundPreviewAttention: "试听「需要你」音效",
1686
1876
  soundOffHint: "「提示音」开关是关的:先打开它才能试听(关掉时提醒本身也确实不出声)。",
1687
1877
  soundPreviewSystem: "「系统通知音」是随通知由系统播放的,插件这边没有可播的音;已发一条测试提醒,声音就是它。",
1878
+ external: "外部通知",
1879
+ externalHint: "把提醒同时推送到 Discord Webhook / Bark / Gotify / 自定义 Webhook,人不在电脑前也能收到「回来一下」。与本地提醒同一套触发规则(上面的可见性开关一并生效)。",
1880
+ externalEnable: "转发外部通知",
1881
+ externalEnableHint: "总开关:关闭后不向任何外部端点发送。",
1882
+ externalAttentionLabel: "转发「需要你」",
1883
+ externalAttentionHint: "审批 / 方案待确认 / 提问时一并推送到外部端点。",
1884
+ externalDoneLabel: "转发「回复完成」",
1885
+ externalDoneHint: "回复跑完时一并推送到外部端点。",
1886
+ externalTargetsLabel: "推送目标",
1887
+ externalTargetsHint: "可加多个。Discord 填 Webhook URL;Bark 填 https://api.day.app/<设备密钥>(自建填 http://<服务器>/<密钥>);Gotify 填 https://<服务器>/message?token=<应用令牌>;自定义填任意收 JSON 的地址(POST:source / event / title / body / sessionId / time)。",
1888
+ externalPrivacy: "提醒标题与正文(含会话名与摘要)会原样发到你填写的地址——只填你自己信任的端点。",
1889
+ externalTypeDiscord: "Discord Webhook",
1890
+ externalTypeBark: "Bark",
1891
+ externalTypeGotify: "Gotify",
1892
+ externalTypeCustom: "自定义 Webhook",
1893
+ externalUrlPlaceholder: "https://…",
1894
+ externalAdd: "添加目标",
1895
+ externalRemove: "删除这个目标",
1896
+ externalTypeField: "推送类型",
1897
+ externalUrlField: "推送地址",
1898
+ externalEmpty: "还没有推送目标,点「添加目标」加一个。",
1899
+ externalTestLabel: "测一测外部通知",
1900
+ externalTestHint: "向上面所有推送目标各发一条测试消息(不受「转发」分类开关限制)。",
1901
+ externalTestBtn: "发送测试通知",
1902
+ externalSending: "发送中…",
1903
+ externalTestDone: "测试通知:",
1904
+ externalCountSent: "成功 ",
1905
+ externalCountUnverified: "已送出不可确认 ",
1906
+ externalCountFailed: "失败 ",
1907
+ externalNoTargets: "先添加至少一个推送目标。",
1908
+ externalOffHint: "先打开「转发外部通知」。",
1688
1909
  attentionVisible: "页面打开时也提醒「需要你」",
1689
1910
  attentionVisibleHint: "默认开启:模型在等你审批 / 确认方案 / 回答问题(含提问弹窗)时,即使页面在最前也会提醒。",
1690
1911
  currentQuiet: "当前对话不弹通知",
@@ -1762,6 +1983,37 @@
1762
1983
  soundPreviewAttention: "Preview the \"needs you\" sound",
1763
1984
  soundOffHint: "The Sound switch is off: turn it on to preview (with it off the alerts themselves stay silent too).",
1764
1985
  soundPreviewSystem: "The system sound plays with a toast, not from the plugin — a test alert was sent instead; that sound is it.",
1986
+ external: "External notifications",
1987
+ externalHint: "Mirror alerts to a Discord webhook / Bark / Gotify / custom webhook so \"come back\" reaches you away from the machine. Same trigger rules as the local alerts (the visibility toggles above apply too).",
1988
+ externalEnable: "Forward to external targets",
1989
+ externalEnableHint: "Master switch: when off, nothing is sent to any external endpoint.",
1990
+ externalAttentionLabel: "Forward \"needs you\"",
1991
+ externalAttentionHint: "Push approval / plan review / question alerts to the external endpoints too.",
1992
+ externalDoneLabel: "Forward \"reply finished\"",
1993
+ externalDoneHint: "Push reply-finished alerts to the external endpoints too.",
1994
+ externalTargetsLabel: "Push targets",
1995
+ externalTargetsHint: "Add as many as you like. Discord: the webhook URL; Bark: https://api.day.app/<device key> (self-hosted: http://<server>/<key>); Gotify: https://<server>/message?token=<app token>; custom: any endpoint accepting JSON (POST: source / event / title / body / sessionId / time).",
1996
+ externalPrivacy: "The alert title and body (including the session name and snippet) are sent as-is to the address you enter — only use endpoints you trust.",
1997
+ externalTypeDiscord: "Discord webhook",
1998
+ externalTypeBark: "Bark",
1999
+ externalTypeGotify: "Gotify",
2000
+ externalTypeCustom: "Custom webhook",
2001
+ externalUrlPlaceholder: "https://…",
2002
+ externalAdd: "Add target",
2003
+ externalRemove: "Remove this target",
2004
+ externalTypeField: "Target type",
2005
+ externalUrlField: "Endpoint URL",
2006
+ externalEmpty: "No push targets yet — press \"Add target\" to create one.",
2007
+ externalTestLabel: "Test the external channel",
2008
+ externalTestHint: "Send one test message to every push target above (the per-kind forward toggles do not apply to this button).",
2009
+ externalTestBtn: "Send test notification",
2010
+ externalSending: "Sending…",
2011
+ externalTestDone: "Test notification: ",
2012
+ externalCountSent: "ok ",
2013
+ externalCountUnverified: "sent-unverified ",
2014
+ externalCountFailed: "failed ",
2015
+ externalNoTargets: "Add at least one push target first.",
2016
+ externalOffHint: "Turn on \"Forward to external targets\" first.",
1765
2017
  attentionVisible: "Also alert \"needs you\" while the page is open",
1766
2018
  attentionVisibleHint: "On by default: alert even in the foreground when the model waits for approval / plan review / a question.",
1767
2019
  currentQuiet: "Stay quiet for the conversation on screen",
@@ -1826,6 +2078,9 @@
1826
2078
  ".dnm-btn[disabled]{opacity:.45;cursor:default}" +
1827
2079
  ".dnm-btnRow{display:flex;align-items:center;gap:10px;flex-wrap:wrap}" +
1828
2080
  ".dnm-file{font-size:12px;font-family:inherit;color:var(--dsw-alias-label-primary,rgba(20,20,20,.9));max-width:200px}" +
2081
+ ".dnm-targetRow{display:flex;align-items:center;gap:8px;padding:10px 14px;min-height:46px;box-sizing:border-box;flex-wrap:wrap}" +
2082
+ ".dnm-targetRow+.dnm-targetRow{border-top:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.35))}" +
2083
+ ".dnm-input{flex:1;min-width:180px;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.4));background:var(--dsw-alias-bg-layer-1,rgba(255,255,255,.03));color:var(--dsw-alias-label-primary,rgba(20,20,20,.9));border-radius:8px;padding:5px 8px;font-size:12px;font-family:inherit}" +
1829
2084
  ".dnm-hint{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,rgba(120,120,120,.9))}" +
1830
2085
  ".dnm-feedback{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,rgba(120,120,120,.9));min-height:18px}";
1831
2086
  (function injectStyle() {
@@ -1851,6 +2106,9 @@
1851
2106
  type: "button",
1852
2107
  role: "switch",
1853
2108
  "aria-checked": checked ? "true" : "false",
2109
+ // the knob span carries no text of its own, so the row label has to
2110
+ // name the button (and the test presser can find it)
2111
+ "aria-label": labelText,
1854
2112
  className: "dnm-switch" + (checked ? " on" : ""),
1855
2113
  onClick: function () { onChange(!checked); }
1856
2114
  }, h("span", { className: "dnm-knob" }))
@@ -2015,6 +2273,88 @@
2015
2273
  }
2016
2274
  setFeedback(msg);
2017
2275
  }
2276
+ // External forwarding rows. Target edits always patch with a fresh array
2277
+ // (readConfig may hand out the DEFAULTS entry itself; in-place mutation
2278
+ // would silently corrupt the defaults for the next reset).
2279
+ function externalRows() {
2280
+ var rows = [];
2281
+ var targets = Array.isArray(cfg.externalTargets) ? cfg.externalTargets : [];
2282
+ for (var i = 0; i < targets.length; i++) {
2283
+ (function (i) {
2284
+ var target = targets[i] || {};
2285
+ rows.push(h("div", { className: "dnm-targetRow" }, [
2286
+ h("select", {
2287
+ className: "dnm-select",
2288
+ value: target.type || "custom",
2289
+ "aria-label": t("externalTypeField"),
2290
+ onChange: function (e) { updateTarget(i, { type: e.currentTarget.value }); }
2291
+ }, [
2292
+ h("option", { value: "discord" }, t("externalTypeDiscord")),
2293
+ h("option", { value: "bark" }, t("externalTypeBark")),
2294
+ h("option", { value: "gotify" }, t("externalTypeGotify")),
2295
+ h("option", { value: "custom" }, t("externalTypeCustom"))
2296
+ ]),
2297
+ h("input", {
2298
+ className: "dnm-input",
2299
+ value: target.url || "",
2300
+ placeholder: t("externalUrlPlaceholder"),
2301
+ "aria-label": t("externalUrlField"),
2302
+ onChange: function (e) { updateTarget(i, { url: e.currentTarget.value }); }
2303
+ }),
2304
+ h("button", {
2305
+ type: "button",
2306
+ className: "dnm-btn",
2307
+ "aria-label": t("externalRemove"),
2308
+ onClick: function () { removeTarget(i); }
2309
+ }, t("externalRemove"))
2310
+ ]));
2311
+ })(i);
2312
+ }
2313
+ return rows;
2314
+ }
2315
+ function updateTarget(i, patchFields) {
2316
+ var list = (Array.isArray(cfg.externalTargets) ? cfg.externalTargets : []).slice();
2317
+ var cur = list[i] || {};
2318
+ var next = {};
2319
+ for (var k in cur) if (Object.prototype.hasOwnProperty.call(cur, k)) next[k] = cur[k];
2320
+ for (var p in patchFields) if (Object.prototype.hasOwnProperty.call(patchFields, p)) next[p] = patchFields[p];
2321
+ list[i] = next;
2322
+ patch({ externalTargets: list });
2323
+ }
2324
+ function removeTarget(i) {
2325
+ var list = (Array.isArray(cfg.externalTargets) ? cfg.externalTargets : []).slice();
2326
+ list.splice(i, 1);
2327
+ patch({ externalTargets: list });
2328
+ }
2329
+ function addTarget() {
2330
+ var list = (Array.isArray(cfg.externalTargets) ? cfg.externalTargets : []).slice();
2331
+ list.push({
2332
+ id: "ext" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
2333
+ type: "discord",
2334
+ url: ""
2335
+ });
2336
+ patch({ externalTargets: list });
2337
+ }
2338
+ // Explicit channel test: posts a synthetic event to every target even
2339
+ // when the per-kind forward toggles are off (they gate real alerts, not
2340
+ // this button). The button stays pressable whenever the plugin is on, so
2341
+ // the "why did nothing arrive" cases get a hint instead of a dead control.
2342
+ function runExternalTest() {
2343
+ if (!cfg.enabled) { setFeedback(t("offHint")); return; }
2344
+ if (!cfg.externalEnabled) { setFeedback(t("externalOffHint")); return; }
2345
+ if (!externalTargets().length) { setFeedback(t("externalNoTargets")); return; }
2346
+ setFeedback(t("externalSending"));
2347
+ var d = texts();
2348
+ sendExternalToAll("attention", d.testExternalTitle, d.testExternalBody, currentSessionId(), function (statuses) {
2349
+ var s = 0, u = 0, f = 0;
2350
+ for (var i = 0; i < statuses.length; i++) {
2351
+ if (statuses[i] === "sent") s++;
2352
+ else if (statuses[i] === "unverified") u++;
2353
+ else f++;
2354
+ }
2355
+ setFeedback(t("externalTestDone") + t("externalCountSent") + s + " · " + t("externalCountUnverified") + u + " · " + t("externalCountFailed") + f);
2356
+ });
2357
+ }
2018
2358
  var permOK = ("Notification" in window) ? Notification.permission === "granted" : false;
2019
2359
  var resolvedLang = effectiveLang();
2020
2360
  var resolvedLangName = resolvedLang === "en" ? t("langEn") : t("langZh");
@@ -2091,6 +2431,56 @@
2091
2431
  soundRows("done", t("soundDoneLabel")),
2092
2432
  soundRows("attention", t("soundAttentionLabel"))
2093
2433
  )),
2434
+ // External forwarding: the same alerts, mirrored to the user's own
2435
+ // webhook endpoints so "come back" reaches them away from the machine.
2436
+ card([].concat(
2437
+ [h("div", { className: "dnm-row" }, [
2438
+ h("div", { className: "dnm-rowText" }, [
2439
+ h("div", { className: "dnm-rowLabel" }, t("external")),
2440
+ h("div", { className: "dnm-rowHint" }, t("externalHint"))
2441
+ ])
2442
+ ]),
2443
+ rowSwitch("externalEnabled", t("externalEnable"), t("externalEnableHint"), cfg.externalEnabled, function (v) { patch({ externalEnabled: v }); }),
2444
+ rowSwitch("externalAttention", t("externalAttentionLabel"), t("externalAttentionHint"), cfg.externalAttention, function (v) { patch({ externalAttention: v }); }),
2445
+ rowSwitch("externalDone", t("externalDoneLabel"), t("externalDoneHint"), cfg.externalDone, function (v) { patch({ externalDone: v }); }),
2446
+ h("div", { className: "dnm-row" }, [
2447
+ h("div", { className: "dnm-rowText" }, [
2448
+ h("div", { className: "dnm-rowLabel" }, t("externalTargetsLabel")),
2449
+ h("div", { className: "dnm-rowHint" }, t("externalTargetsHint"))
2450
+ ])
2451
+ ])],
2452
+ externalRows(),
2453
+ [(Array.isArray(cfg.externalTargets) && cfg.externalTargets.length) ? h("div", { className: "dnm-row" }, [
2454
+ h("div", { className: "dnm-rowText" }, [h("div", { className: "dnm-hint" }, t("externalPrivacy"))])
2455
+ ]) : h("div", { className: "dnm-row" }, [
2456
+ h("div", { className: "dnm-rowText" }, [h("div", { className: "dnm-hint" }, t("externalEmpty"))])
2457
+ ]),
2458
+ h("div", { className: "dnm-row" }, [
2459
+ h("div", { className: "dnm-btnRow" }, [
2460
+ h("button", {
2461
+ type: "button",
2462
+ className: "dnm-btn",
2463
+ "aria-label": t("externalAdd"),
2464
+ onClick: addTarget
2465
+ }, t("externalAdd"))
2466
+ ])
2467
+ ]),
2468
+ h("div", { className: "dnm-row" }, [
2469
+ h("div", { className: "dnm-rowText" }, [
2470
+ h("div", { className: "dnm-rowLabel" }, t("externalTestLabel")),
2471
+ h("div", { className: "dnm-rowHint" }, t("externalTestHint"))
2472
+ ]),
2473
+ h("div", { className: "dnm-btnRow" }, [
2474
+ h("button", {
2475
+ type: "button",
2476
+ className: "dnm-btn",
2477
+ "aria-label": t("externalTestLabel"),
2478
+ disabled: !cfg.enabled,
2479
+ onClick: runExternalTest
2480
+ }, t("externalTestBtn"))
2481
+ ])
2482
+ ])
2483
+ ])),
2094
2484
  card([
2095
2485
  h("div", { className: "dnm-row" }, [
2096
2486
  h("div", { className: "dnm-rowText" }, [
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-notify-me",
3
- "version": "1.6.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.",
3
+ "version": "1.7.0",
4
+ "description": "DSH 桌面消息提醒插件:模型需要你操作(审批/方案确认/提问的可操作提醒)或回复在后台完成时,系统通知+提示音+标签页标题提醒你;权限审批的通知上可直接点「同意/拒绝」完成裁决;可把提醒转发到 Discord Webhook / Bark / Gotify / 自定义 Webhook 等外部通知;可在设置页开关并切换通知语言。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, forwarding to external webhooks (Discord / Bark / Gotify / custom); toggleable from Settings with zh/en notification language.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
@@ -36,6 +36,12 @@
36
36
  "approval-alert",
37
37
  "approval-actions",
38
38
  "background-alert",
39
+ "external-notification",
40
+ "webhook",
41
+ "discord",
42
+ "bark",
43
+ "gotify",
44
+ "push-notification",
39
45
  "桌面通知",
40
46
  "消息提醒",
41
47
  "可操作提醒",
@@ -44,6 +50,8 @@
44
50
  "需要你操作",
45
51
  "回复完成提醒",
46
52
  "后台完成提醒",
53
+ "外部通知",
54
+ "推送提醒",
47
55
  "提醒"
48
56
  ],
49
57
  "engines": {