npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

dsh-notify-me

v1.17.0

Published

离开 DSH 页面也能收到提醒:需要你操作 / 后台回复完成时发系统通知、标题标记与提示音,支持审批快捷裁决与外部推送,Web 版与桌面版均可用。

Downloads

14,133

Readme

dsh-notify-me

离开 DSH 页面,也能及时知道何时需要回来。

简体中文 | English

npm version npm downloads npm total downloads License: MIT test DSH Web and Desktop Package size DSH Compatibility Awesome DSH Plugin

安装 · 效果预览 · 配置 · 兼容性 · 已知限制 · 更新日志

DSH Web / Desktop 的通知插件,让你敢不开完整访问,也不必一直守着页面。

  • 需要你操作时:审批、方案确认、提问,用通知、提示音和标题标记提醒。
  • 后台回复完成时:及时回来查看结果,点击通知可回到对应对话;提醒时机可选「仅后台 / 仅当前对话静默 / 总是提醒」。
  • 人走开但窗口没走:窗口留在最前台、人却离开座位,超过 3 分钟没有输入也算离开,该提醒的照常提醒;收到的那条通知不会在你回来之前自己消失,一回到页面(点任务栏、Alt+Tab、点通知都算)就收起。
  • 离开电脑时:可选转发到 Discord、Bark、Gotify、飞书群机器人、Server酱、WxPusher 或自己的 Webhook。
  • 连页面都关掉时:可选让宿主进程兜底——页面不在时宿主继续把「需要你操作」「回复完成」推到你的网关;也可以只开同源中继,让页面够到要认证的网关。两项默认关,配置写在 profile 的 patch 层(见高级设置里的「页面关闭后:宿主侧兜底与同源中继」)。

页面或桌面应用需保持运行(后台 / 最小化可以);系统通知需要通知权限。审批按钮依赖 Service Worker,只有 Web 端有,桌面端的通知不带按钮,点通知回到对话里裁决。开启宿主侧兜底后,页面关闭仍能收到外部推送——本地通知与审批按钮仍要页面在。

效果预览

Windows 上经 Edge 显示的 DSH 普通通知,未包含审批按钮

普通通知示例 · Windows / Edge · 不含审批按钮。 查看原图

通知提醒设置页:页眉、提醒方式、什么时候提醒、会话静音、试一试与折叠的高级设置

只截插件自己的面板(真实组件,与宿主同款深色主题),不带 DSH 左侧导航栏;每行说明在鼠标停在标题文字上时才出现,所以这里只看到标题。

安装

DSH Web

已安装 DSH CLI 的用户,在终端执行:

# 安装并自动挂载进 web profile
dsh plugin --profile web add dsh-notify-me

重启 dsh web,然后硬刷新页面(Windows / Linux 是 Ctrl+Shift+R,macOS 是 Cmd+Shift+R)。如果你使用其他 Web profile,请把命令中的 web 换成实际名称。

DSH Desktop

  1. 在桌面应用打开 插件 → 添加插件。
  2. 输入 npm 包名 dsh-notify-me,按应用提示完成安装,并确认插件已启用。
  3. 完全退出并重新启动 DSH Desktop,使插件加载。

桌面端的 desktop profile 由 Electron 应用管理;不要把上面的 Web 命令改成 --profile desktop,CLI 会拒绝此操作。安装到 Web profile 也不会自动安装到桌面端。

首次验证

  1. 在 DSH 页面上点击一次,按提示允许通知;若此前拒绝过,在浏览器站点设置中允许通知。操作系统也需要允许对应浏览器 / DSH Desktop 显示通知。
  2. 打开 设置 → 通知提醒,确认出现该分区及底部插件版本号,保持「启用提醒」「提示音」开启。
  3. 点击设置页的「测试『回复完成』」按钮:应出现一条测试通知,并按当前音效设置播放声音。这个本地测试不会转发到外部通知端点,也不受「窗口在前台时也提醒」开关限制。

Web 用户可再测审批按钮,通知上会带「同意 / 拒绝」。桌面端拿不到 Service Worker,这条测试只发一条普通通知;真实审批的提醒同理——只弹通知,点它回到对话里裁决。

控制台验证(可选) — 支持 DevTools 的环境也可执行:

window.__dshNotifyMe.test("done")        // 「完成」示例
window.__dshNotifyMe.test("attention")   // 「需要你」示例
window.__dshNotifyMe.test("approval")    // 桥可用时带「同意 / 拒绝」按钮;否则只发普通通知(桌面端即如此)
window.__dshNotifyMe.debug()             // 桥接看 bridge(active = 按钮可用)、抬窗通道 raiseChannel、最近一次尝试 raiseLast、平台拒发原文 lastToastReject、设置是否真存下来 configStored、宿主协调看 hostBridge

没有测试通知时,先检查:通知权限是否被拒绝(在页面上点一下 → 选「允许」;或地址栏锁 → 站点设置 → 通知 → 允许 → 刷新)、装完没重启 DSH、系统设置里浏览器通知被关。

为什么需要它

  • 不开完整访问(workspace-write + ask):保留事前审批,但错过请求会让 agent 等待。
  • 开完整访问(full access):减少审批打断,同时放弃事前拦截能力。

dsh-notify-me 把「监督」和「守在屏幕前」解耦:只在真的出现决策点(审批 / 方案确认 / 提问)或回复跑完时,才用系统通知把你叫回来——让你敢不开完整访问,不必靠放弃拦截能力来换效率。它不降低 full access 本身的权限风险(它不是沙箱),只是减少 ask 模式下因错过审批而产生的等待。

提醒时机

| 事件 | 默认行为 | | --- | --- | | 🔔 需要你操作 | 页面可见也提醒;当前对话只留标题标记 | | ✅ 回复完成 | 页面隐藏 / 后台时提醒(可选「仅当前对话静默」或「总是提醒」) |

需要你操作包括审批请求、方案待确认(plan review)和提问(ask_user_question)。带按钮的快捷裁决只在 Web 端可用,桌面端点通知回到对话里裁决(见已知限制)。

回复完成也覆盖后台会话;完成但还没看时,标题带 ✅ 回复完成 · 标记,打开对应会话即撤。标题标记不受提醒策略限制。

通知正文的第一行是会话标题:当前正在看的会话和其他会话一样显示自己的标题,只有宿主还没给出标题的新会话才用「当前对话」占位符兜底。

后台子代理(subagent)子会话的等待与完成默认不提醒:DSH 把每个子代理子会话列成独立会话,但它们是所属主对话那一轮里的步骤,提醒只会是噪音。你当前打开的那个子代理会话不算在内,照常提醒。设置页「子代理会话不提醒」可关掉这个静音。

在 DSH 设置里改配置

打开 设置 → 通知提醒。页面按「提醒方式 → 什么时候提醒 → 试一试」三段排开,不常改的收进底部「高级设置」,点一下展开。每行只写标题,具体说明在鼠标停在标题文字上时弹出的小方框里(只有标题本身触发布局,整行空白处不会弹;触屏或键盘操作时留一份隐藏文本给读屏)。带 ▸ 的行点一下展开子项。

提醒方式

  • 启用提醒 总开关:关闭后不弹通知、不响提示音,也不改标签页标题;
  • 提示音 / 音量:开关直接切;音量滑块收在该行里,点一下标题(▸ 变成 ▾)展开。具体播什么由「高级设置 → 音效来源」决定;
  • 审批通知上直接裁决(默认开):审批带「同意 / 拒绝」,点一下即完成裁决,不用切回页面。Web 走带按钮的系统通知;桌面端 dsh-app:// 注册不了 Service Worker,通知上没有按钮,点通知回到对话里裁决。设置页「试一试」下面那行会如实说明当前环境能不能给按钮。

什么时候提醒

  • 窗口在前台时也提醒「需要你」(默认开):关掉后只有窗口转到后台才提醒;
  • 当前对话不弹通知(默认开):正看着的对话里的等待只留标签页标记,人离开后照常补发;
  • 「回复完成」什么时候提醒(默认「仅后台」):仅后台 / 仅当前对话静默 / 总是提醒,三选一;
  • 通知自动消失(默认「离开时保留」):离开时保留 / 总是关闭 / 从不关闭,三选一;三种设置只管「时间到点」那一次关闭,你回到页面时残留的通知一律收起;
  • 子代理会话不提醒(默认开):跳过后台子代理(subagent)会话,你正开着的那个照常提醒;
  • 点击通知回到对应对话(默认开):切到对应对话并把窗口带到前台,Windows 桌面走宿主路由原生抬窗、macOS / Linux 走 dsh://open 深链,多个标签页只调起一个。
  • 后台的判定:切到别的应用、被别的窗口盖住、最小化都算;窗口一直留在前台但 3 分钟没有任何键鼠输入同样算,idleMinutes: 0 退回只看窗口。
  • 「当前对话不弹通知」何时补发:窗口转到后台或闲置超时后照常提醒;若你已处理掉就作废。
  • 「回复完成」三档:「仅后台」=不在看页面才提醒;「仅当前对话静默」=屏幕上那条静默、别的对话照常提醒,多开对话时有用;「总是提醒」=从不静默。老配置里的「窗口在前台时也提醒『回复完成』」开关自动迁移到这一项。
  • 「离开时保留」:你人在页面前时,普通通知几秒后自己收;人不在时收到的那条留在通知中心等你回来。回到页面(点任务栏、Alt+Tab、点通知、还原最小化的窗口都算)就立刻收起,三种设置都不影响这一条——不看通知中心,也不会被遗留通知挡着。带「同意 / 拒绝」的通知不设自动撤回,留到你点按钮、回页面把请求答掉、或被新请求顶替为止;桌面端画不出按钮的审批通知按 12 秒收,人不在时同样不撤,回到页面同样收起。
  • 点击通知的兜底:桌面端抬不起来时只切对话,原因记在 debug().raiseLast 里;关掉这一项后点通知不做任何事。

静音某些对话

适合「后台跑着周期任务、但这次不用管它」的对话:只关掉那一行的提醒,别的照旧。

  • 静音:鼠标移到侧边栏的会话行上,右侧出现铃铛,点一下即静音(按钮读作「静音此会话的提醒」);再点一次恢复;
  • 标记:被静音的对话在标题前显示一个划掉的铃铛(读屏读作「此会话已静音」);
  • 范围:「需要你」「回复完成」、提示音、标签页标记、外部推送一起关掉;别的对话完全不受影响;「试一试」里的测试按钮照发(它就是用来当场验证渠道的);
  • 持久化:列表存在本地,刷新、重启都在;手工改坏的配置会被剪掉而不是让整页失效;
  • 关掉标签页之后:静音列表会随心跳交给宿主,所以「页面关闭后的宿主侧兜底」也不会为它推送——否则关掉页面反而开始响。

设置页「什么时候提醒」下面有一张「会话静音」卡片,显示当前静音了几个对话,可以一键「全部取消静音」。

  • 标记注在宿主的 sidebar.session.row.leading 槽位,宿主在「这条会话有运行状态」时用状态点占着这个位置,标记就不渲染——正在跑的后台任务看不到划掉的铃铛,停下后自动回来。静音本身不受影响,通知照旧不发。
  • 静音只看会话 id:重命名、换目录都不影响,删掉的会话留在列表里也不会报错(列表上限 200 条,恢复按钮随时清空)。
  • 静音不改变「回复完成」的 donePolicy 判定,也不改变「当前对话不弹通知」:两者叠加时以静音为准。

试一试

三个按钮分别发一条「需要你」「回复完成」「审批按钮」测试提醒,用当前设置立即发,不受「什么时候提醒」里的开关限制;下方一行如实说明当前哪种渠道能给审批按钮。

设置页底部常显插件版本号(dsh-notify-me v…)——报障、核对是否装上新版本时直接照抄这一行。

高级设置

默认收起,点一下展开。里面收着三块不常改的东西:音效来源、外部推送、运行环境与诊断——分别见下面三节。

音效

「需要你」与「回复完成」可独立选择内置合成音(默认)、自定义音频文件或系统通知音。自定义音频上限 1 MiB,存在浏览器本地,可试听与移除;选系统通知音时完全不播插件音效,通知照发,声音交给操作系统(音色跟着系统通知设置走),此时「试听」会改发一条测试通知。任一来源缺文件、读不出来或存不下时回落到内置合成音,提醒不会变成哑的。

外部推送

默认关闭。支持 Discord Webhook / Bark / Gotify / 飞书群机器人 / Server酱(Turbo 与 Server酱³)/ WxPusher / 自定义 Webhook;提醒正文会发往你填写的地址,只使用可信端点。

  • 外部通知(默认关):把提醒同时转发到你自己的推送端点,人不在电脑前也能收到;可加多个目标,每个目标可单独启停。「转发「需要你」」「转发「回复完成」」各自独立开关;发送测试通知往所有目标发一条测试消息(不受这两类开关限制,也不会冒充真提醒)。转发跟本地提醒走同一套触发规则(「当前对话不弹通知」等开关同样管它);提醒正文会原样发往你填的地址,只填你自己信任的端点;
  • 各目标的填法:Discord 填 Webhook 地址;Bark 填 https://api.day.app/<设备密钥>;Gotify 填 https://<服务器>/message?token=<应用令牌>;飞书填群机器人 Webhook,可选填签名校验密钥;Server酱 填 …/send/<SendKey>.send 完整地址;WxPusher 填 appToken 与接收者 UID;自定义端点收一条 POST JSON:{source, event, title, body, text, sessionId, time};
  • 可靠投递:明显是临时故障的失败(网络中断、5xx、429)按 2 秒 / 6 秒自动重试两次,其它错误立即停;关掉「外部通知」总开关或删掉某个目标后,已经排在定时器上的重试立刻作废,既不算失败也不进失败记录;重试仍失败会把这一条记进「失败记录」(最多 10 条,只留标题与正文摘要和主机名,不留完整端点),设置页能一眼看到并清空;
  • 服务码与「不可确认」:飞书 / Server酱 / WxPusher 的 2xx 还会读返回体里的服务码(飞书与 Server酱是 0,WxPusher 是 1000),码不对按失败记并带服务端文案;2xx 但正文不是该服务的 JSON(空白、代理或 WAF 的网页)记「已送出不可确认」,不冒充成功;
  • 每个目标的状态:目标行右侧显示它最近一次投递的结果(上次成功 / 上次已送出但不可确认 / 上次失败 + 原因),并各自带一个启停开关——某个端点临时不可用就单独关掉它,不用关掉整类事件;
  • 签名:自定义目标填了签名密钥时,请求带 X-DSH-Notify-Signature(对请求体做 HMAC-SHA256,sha256=<hex>);飞书填了签名密钥时按官方算法在请求体里带 timestamp / sign;
  • 消息模板(可选):飞书 / Server酱 / WxPusher / 自定义目标可填模板,占位符 {{title}} {{body}} {{event}} {{time}} {{session}},留空默认是「标题 + 换行 + 正文」;
  • 钉钉不在支持列表里:钉钉群机器人接口不返回任何 CORS 头,且只接受 application/json——浏览器页面既读不到响应,也无法发出符合要求的请求(no-cors 下 Content-Type 会被限制成 safelisted 类型)。要用钉钉,请在自己能控制的机器上搭一个中转,再把「自定义 Webhook」指向它。

页面关闭后:宿主侧兜底与同源中继

这两块能力住在宿主进程里(lib/host-fallback.js、lib/relay.js,都是包内的可选模块),默认关:没有配置时宿主只注册抬窗那一条路由,升级前后行为一致。它们的配置写在 profile 的 patch 层,不在设置页里——它们让宿主替页面干活,不是浏览器偏好;设置页只如实显示探测到的状态。

页面不在时页面半体不运行,提醒就断在这里。宿主侧兜底订阅宿主的会话事件流(session/event),在页面关闭后自己把同样两条提醒发到你配置的网关。

profile 覆盖示例(~/.dsh/profiles/<profile>/cordis.patch.yml)。config 是整块替换、不是深合并:patch 里写同一个 id 就把整个 config 赋过去,所以示例把三块都写全——漏写的键会回落到插件内的默认值(与随包 YAML 是同一份),但被一起冲掉的 gateway.target 不会自己回来。

- id: notify-me
  config:
    allowedOrigins: []            # 顶层:页面入口白名单,留空 = 只接受回环 / 绑定地址(三条宿主入口共用)
    gateway:
      target: ''                  # 例:https://cue.example/notify/<key>
      credentialsFile: ''         # 0600 JSON,见下
      headers: {}                 # 额外请求头,可选
    hostFallback:
      enabled: true
      target: ''                  # 留空 = 用 gateway.target
      credentialsFile: ''         # 留空 = 用 gateway.credentialsFile
      events: ['turn/end', 'approval/asked']
      includeSubagents: false
      turnEndThrottleMs: 15000
      titlePrefix: 'DSH'
      language: 'auto'            # auto | zh | en
      pageLeaseMs: 60000          # 0 = 不与页面协调(页面开着也会重复)
      healthPath: '/__dsh-notify-me/host-fallback'
    relay:
      enabled: false
      path: ''
      target: ''
      credentialsFile: ''

逐键说明(括号内是默认值):

  • enabled(false):关着时宿主不订阅任何事件,也不注册健康探针;
  • target / credentialsFile(空):留空回落到 gateway.*;两处都空等于没地方可推,模块干脆不挂载,只在宿主日志里留一行 warning——比每次投递都失败好查;
  • events(['turn/end', 'approval/asked']):要推的事件,只列一种就只有那一种;
  • includeSubagents(false):origin === 'subagent' 的会话默认跳过,理由与页面侧一致——它们是主对话一轮里的步骤;
  • turnEndThrottleMs(15000):同一会话的「回复完成」在这个窗口内只推一条;窗口只在真发出去时才消耗,被租约挡掉的那次不算。节流只管 completed——aborted / error / max-tokens 这些收尾不节流,它们本来就少,而且往往是此刻最该看到的那一条;
  • 文案与页面通知逐字一致:标题是 <前缀> · 回复完成 / <前缀> · 审批请求,正文「回复完成」只有会话名、「审批请求」是 会话名:工具名 · 原因——不含任何用户消息正文(用户消息里常带注入的 <system-reminder>、记忆上下文与时间戳,复制进通知就没法读了);非正常收尾(aborted / error / max-tokens…)会在正文尾部附一个短后缀,如 (error);
  • titlePrefix(DSH):标题是 <前缀> · 审批请求 / <前缀> · 回复完成(与页面通知同一套标签);
  • language(auto):auto 用页面最近一次心跳报的语言,没有心跳才回落到中文;zh / en 强制;
  • pageLeaseMs(60000):页面在线租约,见下面「去重」;
  • healthPath(/__dsh-notify-me/host-fallback):只读健康探针的路径。

凭据文件:宿主用户可读的 0600 JSON,内容是 {"user": "...", "password": "..."}(按 HTTP Basic 发)或 {"authorization": "Bearer ..."}(原样发;裸 token 会自动补上 Bearer )。它不能写进 patch 层——那一层世界可读、还会进 profile 目录。不填这个键时请求不带认证头:很多 webhook 端点本来就不要认证,把「没配凭据」当成错误会让每个提醒都失败;只有「配了路径却读不出来」才算凭据不可用。网关回 401 / 403 时宿主丢掉缓存的凭据、重读文件、只重试一次,所以轮换密码不需要重启宿主。

去重靠一条页面在线租约:页面加载后 GET /__dsh-notify-me/host-bridge 探测一次(之后每 60 秒复探,页面隐藏时不探、回到可见补探一次),只有回包 hostFallback.enabled === true 才开始心跳——body 是 {alive, language, muted}(muted 是被静音会话的 id 列表),间隔取 leaseMs/3、夹在 5–20 秒;pagehide 时发一条 {alive:false}(带 keepalive)立刻释放。宿主侧租约新鲜就不补推(页面自己在推),租约过期或收到 alive:false 才接手,最多等 pageLeaseMs 这么久。心跳带语言与静音列表,不带页面的推送开关:规则是「没有页面持有租约时宿主接手」,而页面的可见性与闲置判定宿主看不见,只照做一半会得到谁也算不清的规则;静音列表是例外,它是用户逐条点出来、写在配置里的确定状态,宿主照它跳过就是页面自己的意思。语言带过来是为了英文页面不收中文宿主提醒,language: 'auto' 时生效。列表在心跳里按字节截断(预算 8 KiB、最多 200 条):被截掉的会话仍在本页静音,只是不由兜底接管。pageLeaseMs: 0 关掉协调,宿主永远推——页面开着时会收到两份,只在调试时用。

两个路由的契约:页面探测的那条 GET /__dsh-notify-me/host-bridge 回 {ok, version, features, hostFallback:{enabled, leaseMs}, relay:{enabled, path, upstreamHost, upstreamPath}, language, page:{alive, ageMs}}(upstreamHost / upstreamPath 是宿主替你转发时的网关主机名与路径,只在中继启用时给出,凭据永不下发);它只在 hostFallback.enabled 或 relay.enabled 为真时注册,默认安装下是 404,页面按「未启用」处理而不是当故障。心跳 POST 要求意图头 x-dsh-notify-me-page: bridge(缺了回 403),坏 JSON 回 400,body 超 16 KiB 回 413,成功回 200 {ok, leaseUntil}。

健康探针:GET <healthPath> 回 {ok, version, enabled, events, includeSubagents, pageAlive, lastDelivery: {at, ok, status} | null},非 GET / HEAD 回 405。它和其它宿主入口共用同一道闸门:对端必须是本机(回环或监听器自己绑定的那个地址),Host 必须是回环名 / 绑定地址 / allowedOrigins 派生的公网域名,不符回 403 并带 reason(bad-remote / bad-host / cross-origin)。探针不收凭据、不转发,用它在终端确认「这个宿主进程到底有没有在推、上一次投递是什么结果」。

走隧道或反代时必须配 allowedOrigins:那种部署下发往宿主的 Host 是公网域名而不是回环地址,而 allowedOrigins 既是 Origin 白名单,也是可接受的 Host 来源。不写它,页面的心跳会被闸门判 bad-host(设置页那行显示「宿主未响应」,下方直接提示去顶层 allowedOrigins 补本页来源),宿主于是把开着页面的提醒也补推一遍——正是租约要避免的那种重复。

页面用自己的 fetch 往外部目标推,碰上要 Basic 认证的网关就是死路,三条 CORS 事实各堵一次:

  1. 把凭据写进 URL(https://user:pass@host/…):fetch 直接抛错,请求根本没发出去;
  2. 加 Authorization 头:请求变成预检,而 Apprise 这类网关不回任何 Access-Control-Allow-*,预检失败;
  3. 退到 no-cors:带不了自定义头,等于没有认证。

于是页面只能报「已送出不可确认」。同源中继把这一段搬到宿主:页面 POST 到与自己同源的路由,宿主补上凭据再打网关,然后把网关的真实状态回给页面。

启用需要 relay.enabled: true、relay.path(必须是绝对路径)、relay.target(留空回落到 gateway.target)和 relay.credentialsFile(留空回落到 gateway.credentialsFile)。路径本身就是能力——知道它就能让宿主替你发一条通知,价值等同口令,所以建议带一段随机 token:/__dsh-notify-me/relay/<32 hex token>。

用法:设置 → 通知提醒 → 高级设置 → 外部推送 里多出两行只读状态,「同源中继」一行显示 已启用 · 地址:<origin><relay.path>;把那个绝对地址填进同页「自定义 Webhook」目标的地址即可,页面不必知道凭据。

不想改地址也行:「自定义 Webhook」目标里如果填的是中继上游网关本身的地址,发送时会自动折叠到中继路由。这类地址在浏览器里注定失败(上面三条 CORS 事实各堵一次,其中 no-cors 兜底还会被 Apprise 当成跨站请求直接 403 Cross-site request rejected),旧版把它记成 unverified——设置页显示「上次已送出(不可确认)」、测试按钮报「已送出不可确认」,而手机一声不响。折叠之后请求走同源、凭据由宿主补,回执就是网关的真实状态。折叠要同时满足:目标类型是「自定义」、URL 主机名等于 relay.upstreamHost、路径也等于 relay.upstreamPath、目标没配签名密钥(签名在页面算,中继带不过去)、且目标不是本页同源。主机名或路径有一项对不上就原样直发(一个网关常按路径分多个 notify key,只比主机名会把 A key 的提醒发到 B key 去),设置页会在会被折叠的目标下写一行说明。

走隧道 / 反代时中继要和桥接路由过同一道 Host 闸门:把页面的公网来源写进顶层 allowedOrigins,否则探针与 POST 都会被判 bad-host。

回包口径(页面据此把「已送出不可确认」变成可核对的状态):

  • GET / HEAD <relay.path> → 200 {ok, route, version, enabled}:只读探针,不转发、不带凭据,但和其它宿主入口走同一道闸门——路径本身就是能力,不设闸门的探针会把「猜对了」告诉猜的人;拿它确认「这个进程挂的是不是我配的那条路径」;
  • POST → 读 body(上限 64 KiB)、要求是 JSON 对象,映射成网关的 {title, body, type:'info'}(页面载荷里的 tag 不透传:Apprise 把 tag 当服务过滤,不匹配会回 424),附上 gateway.headers 与凭据;请求必须带 x-dsh-notify-me-relay: relay 意图头,缺了回 403 {reason:'missing-intent'}——跨站表单发不出自定义头,要发就得多一次预检,而预检在网关这里必然失败;
  • 200 {ok, upstreamStatus, upstreamBody}(upstreamBody 最多 500 字符)=转发成功;上游非 2xx 回 502,同为这一形状;
  • 400(坏 JSON 或空 body)、413(body 超限)、405(方法不对)、403(闸门拒绝或缺少意图头)、503(配了凭据文件但读不出来)、502 {reason:'upstream-error'}(上游异常或网关回了 3xx——跳转不跟,因为跟过去会把 body 和 Authorization 重放到别处)。

凭据只在宿主:页面拿不到它,它也不会回显在响应或日志里。POST 走与其它宿主入口同一道闸门,所以跨站页面或 DNS rebinding 的伪造来源即使猜到路径也会被拒。

运行环境与诊断

在「高级设置」最后一块。默认自动识别当前是 Web 端还是桌面端(DSH Desktop,dsh-app://);识别不准时可手动指定——Windows 桌面点通知走宿主路由做原生抬窗、macOS / Linux 走 dsh://open 深链(见桌面端怎么抬窗的)、通知上不带快捷裁决按钮;Web 端走 window.focus() 与 Service Worker 桥。手动覆盖与自动识别不一致时,设置页会明示检测结果。

  • 通知语言:跟随界面 / 简体中文 / English——决定通知文字与 🔔 … 标题标记使用的语言;
  • 桌面端抬窗:Windows 走宿主路由做原生激活、macOS / Linux 走 dsh://open 深链;最小化、被别的窗口盖住的窗口在 Windows 与 Linux 上都实测能抬回来(见桌面端怎么抬窗的);
  • 恢复默认设置:把所有开关与音效设置还原成出厂值(不动外部推送目标和已选的自定义音频文件)。

提醒渠道

| 渠道 | 表现 | | --- | --- | | 系统通知 | 经浏览器进 Windows / macOS / Linux 的系统通知中心,点击回到对应对话;Web 端可带「同意 / 拒绝」 | | 提示音 | 默认 WebAudio 合成音,「需要你」与「完成」音型不同;可换自定义音频,或交给系统通知音 | | 标签页标题 | 🔔 需要你 · … / ✅ 回复完成 · …(随通知语言切换),打开对应会话即消失;两种标记可同时出现 | | 外部推送(可选,默认关) | 把同一条提醒转发到 Discord / Bark / Gotify / 飞书 / Server酱 / WxPusher / 自定义 Webhook | | 宿主侧兜底(可选,默认关) | 页面关闭后由宿主进程继续把「需要你操作」「回复完成」推到你的网关;要 profile 配置与 0600 凭据文件 | | 同源中继(可选,默认关) | 页面 POST 到宿主同源路由,由宿主补凭据转发给要认证的网关,并回传网关真实状态码 |

本插件以浏览器层为主:本地通知、提示音、标题标记仍只在页面开着时工作(最小化 / 后台即可——那正是它监听的「离开」状态);开启宿主侧兜底后,外部推送在页面关闭后由宿主进程接着发。

兼容性

已逐版实测在 0.1.1-rc.2 到 0.2.2-alpha.2 上真正激活(判定标准:插件条目进启动页 __DSH_BOOT__、注入依赖解析通过、/plugins/??dsh-notify-me/client.js 返回含新代码的构建、设置里出现「通知提醒」分区)。逐版适配记录、宿主代际的形状差异与两个会静默失效的坑,见 docs/compatibility.md。

控制台高级配置

设置页是第一入口。想脚本化批量改、恢复默认,或查外部通知目标的字段,见 docs/console-config.md——完整的配置键、decide() / testExternal() 接口、外部通知测试的开关边界,以及自定义音效和 debug().hostBridge 的读法都在那里。

工作原理

提醒核心监听会话状态与待处理交互;审批裁决由页面侧完成,Windows 桌面抬窗走宿主路由加 Win32 原生激活、macOS / Linux 走 dsh://open 深链。

提醒核心按宿主代际取源(对照表见 docs/compatibility.md),当前会话一律来自客户端 sessions 服务:

  • 当前会话 SessionSnapshot:running true→false = 回复完成;
  • 待办交互:uiSession 的 sessionStatus(≥ 0.1.6,逐会话 pendingInteraction)或 pendingInteractions(0.1.2 .. 0.1.5)出现新 key = 模型在等你,容器里带上审批工具名 / 理由、提问文本;旧宿主回落到控制器快照的 pending[];
  • 其它已列会话摘要:running true→false(或旧宿主的 completed 边沿)= 后台工作提醒。宿主的「完成未读」级别标志负责补漏与标题标记:≥ 0.1.6 用 sessionStatus 行的 completionUnread(后台会话完成即点亮、打开该会话/再次开跑/会话消失即清零),旧宿主用列表行的 completed 提醒位(选中会话即清零);页面没看见的完成边沿(插件热重载中途、快照合并跳变)由此补报一次,边沿与级别标志同来时按短时间窗合并,只响一声。
  • 子代理会话:列表行带 origin: 'subagent'(并有 parentId)的后台会话默认静音;你正打开着的那个子代理会话不算静音对象,它的提醒走当前对话的常规规则(页面可见时默认只留标题标记、转后台补发),切进来时积压的等待同样补上,切走后重新静音。只有 parentId、没有 origin 的分支(fork)会话属于你自己的对话,始终照常提醒。

提醒核心仍然零第三方运行时依赖、完全自包含:通知文字按所选语言(跟随界面 / 中文 / English)即时解析。设置页是可选的 React 呈现层——当 DSH web profile 提供 slots / locale / react 时才注册进「设置 → 通知提醒」,缺任一能力时插件自动降级为纯提醒(无设置页),不影响功能。

快捷裁决怎么走通的

带「同意 / 拒绝」按钮的通知只能由 Service Worker 弹出(往 new Notification() 里传 actions 会直接抛 TypeError),所以 bundle 的同一份文件再注册成一个只干转信的 worker,把「点了哪个按钮」带回页面。裁决仍在页面里发生,调的是审批面板里「允许一次 / 拒绝」用的同一个 PendingApproval.answer('allowed-once' | 'rejected')——「同意」就是「允许一次」,不多放行任何东西。

每条按钮通知按交互 key 记账,一次只认领一条等待;请求被页面里答掉、或被新请求顶替之后,残留通知上的按钮再点也不会误裁,只是安静地关掉。带按钮的通知不设自动撤回,等你点按钮或把请求答掉才消失;普通通知 5 秒自收,桌面端没有按钮的审批通知 12 秒自收。「通知自动消失」选「从不关闭」时,这两类都不因到点而收;三种设置都只管定时器那一次关闭,你回到页面时残留的通知一律收起。没来得及点就回页面里裁决,对话里的审批一直等着你。

点正文的「调窗口 + 切对话」也归 worker 这半侧调度:client.focus() 只调起一个窗口(优先当前可见的),也只让那个窗口切对话——多个 DSH 标签页不会互相抢焦点或一起乱切。

桌面端怎么抬窗的

DSH Desktop 是一层 Electron 壳,页面跑在 dsh-app://app 下:DOM 的 window.focus() 只动浏览器窗口对象、碰不到原生窗口,壳的 will-navigate 又把页面里发 dsh: 的常规出口堵上了。插件按平台走两条通道:Windows 走插件自己的宿主路由——页面 POST /__dsh-notify-me/focus-window,宿主起 powershell.exe 跑 lib/focus-window.ps1,由它枚举原生窗口、自适应激活;macOS / Linux 走壳自己的 dsh://open 深链——页面在点击手势里插一个隐藏 iframe 导航 dsh://open(子框架导航不经过壳的顶层 will-navigate 拦截),交给系统协议注册,壳的 second-instance / open-url 处理器据此 restore + show + focus 主窗口。Windows 上这条深链在 0.2.1-rc.2 复测 3/3 无效,1.14.0 起改走宿主路由;Linux(Plasma 6.8 / Wayland)已实测能把最小化或后台的窗口抬回前台,macOS 未实测。

Windows 的宿主路由:壳把 dsh-app://app 下除静态资源以外的所有路径原样转发给宿主 HTTP server(带 origin 校验、注入宿主 cookie),所以上面那个 POST 是同源请求,没有 CORS 问题;宿主半(lib/index.js)本来就跑在 Electron 主进程的子进程里,process.ppid 就是主窗口所属的浏览器进程。点通知时:页面 POST → 宿主起一次 powershell.exe 跑 lib/focus-window.ps1 -ProcessId <ppid> → 脚本枚举该 pid 下可见、无 owner、非 toolwindow 的顶层窗口(优先类名 Chrome_WidgetWin_1,再按 GetWindowRect 面积取最大)→ 自适应激活 → 比对 GetForegroundWindow() 打印 FOCUSED=1|0 → 宿主回 200 / 202。

Windows 的激活顺序是实的:已经在前台就什么都不做(最常见的情况,零闪烁);最小化了先 SW_RESTORE;然后合成一次 ALT 按键(keybd_event 按下抬起)给本线程前台权限,再 SetForegroundWindow;这一串都不行才退到 SW_MINIMIZE + SW_RESTORE 兜底。单独调 SetForegroundWindow 会被前台锁直接拒,按调用算、跟 ForegroundLockTimeout 无关;这也重新解释了 1.5.2 那套深链「时灵时不灵」——能抬起来的那些次,多半是系统在通知被点击时自己激活了应用。

实测(powershell.exe 5.1,同一轮跑完):窗口在前台 ×3、最小化 + 记事本在前台 ×3、可见但被盖住 ×3,全部 FOCUSED=1 且前台窗口比对一致,最小化那几个的 IsIconic 都从 true 变回 false;记事本最大化完全遮挡、最小化 + 完全遮挡、-ProcessId 0 自动探测同样成功。脚本内部耗时最坏 434 ms,进程启动到退出最坏 782 ms,都在 8 秒上限内;pwsh 7.6 对照一轮行为一致,只是 Add-Type 编译慢约 100 ms(脚本刻意不用 7 的语法)。不存在的 pid、存在但没有可见窗口的 pid 都稳定 FOCUSED=0 并 exit 0。

安全上只开一条 exact 路由。词表之外的请求一律拒:非 GET / HEAD / POST 回 405(包括预检的 OPTIONS,且从不回任何 CORS 头),非 Windows 宿主(Web 版、macOS / Linux、纯 Node)回 501(macOS / Linux 走深链,不经过这条路由),缺 x-dsh-notify-me-focus: focus 头回 403。

那个头只是「这是页面发来的」标记,不是防线——DNS rebinding 的恶意页对目标地址而言是同源的,能带任意自定义头、也不触发预检。真正的闸门是三层:对端地址必须是 loopback,或恰好是监听器自己绑定的那个 IP;Host 必须是 loopback 名(127.0.0.1 / localhost / [::1],可带端口)或同一个绑定地址,因为壳转发时 host 已被删掉、由 fetch 按它拨号的 URL 重填,而 rebinding 的页面 Host 仍是攻击者域名;带了 Origin 就必须和 Host 同源。任一条不符回 403,reason 分别是 bad-remote / bad-host / cross-origin。GET 只回 {ok, route, enabled, desktop, platform},装完可以先用浏览器打开确认路由在不在。400 毫秒内的重复调用合并成一次,脚本超过 8 秒没回话就 kill 掉按失败处理。

抬不起来的几种场合与已知限制里列的一致(提权窗口、另一虚拟桌面、前台被反复抢走、SW_HIDE 托盘隐藏),一律如实回 202、对话照常切。

已知限制

运行前提

  • 默认只在页面开着时提醒(后台标签 / 最小化可以,关标签页即失效——浏览器层方案固有限制)。开启宿主侧兜底后,页面关闭仍能收到外部推送:它要 profile 里 hostFallback.enabled: true、gateway.target(或 hostFallback.target)与一个 0600 凭据文件,而且宿主进程自己在跑。
  • 通知经由浏览器弹出,需允许浏览器通知权限;每次页面加载后第一次出声 / 弹通知前要先在页面上点过一下;未授权时只剩提示音与标题标记。
  • 系统通知横幅只画在主屏:Windows 没有设置项、Chromium 没有 API 能把它指到别的显示器(多屏时副屏收不到)。要跨屏只能靠外部推送转到手机(1.12.0 起页面里不再画审批卡片)。
  • 配置存在浏览器 localStorage:换浏览器 / 设备或清除站点数据后回到默认值(设置页可一键恢复默认)。

平台差异

  • 快捷裁决按钮要 Service Worker:http://127.0.0.1、https 可用,http://192.168.x.x 这类局域网地址不行;桌面端(dsh-app://)已实测无法注册 Service Worker(宿主未开放 allowServiceWorkers 特权),审批通知退回无按钮样式。宿主不渲染通知按钮时,debug() 的 bridge 字段会说明原因,其余功能不受影响。
  • Windows 的系统通知标签上限 64 字符,而 Chromium 交给平台的是 n#<origin>#<tag> 整串——桌面页面 dsh-app://app 一项就占去 16 个。插件按当前 location.origin 算剩余预算,超长时保留可读前缀、尾部换成短哈希,所以 debug() 里看到的标签可能被截短;平台仍然拒发时,原文记在 debug().lastToastReject,设置页的测试按钮也会如实报出来,不再「提示已发送但什么都没弹」。
  • 桌面端(Windows)点通知后窗口仍可能抬不起来:目标窗口属于更高完整性级别(提权 / UAC)、在另一个虚拟桌面、前台被别的进程反复抢回去,或窗口被 SW_HIDE 隐藏到托盘时,宿主脚本回 FOCUSED=0、路由回 202,对话照常切(详见桌面端怎么抬窗的)。
  • 桌面端(macOS / Linux)点通知依赖 dsh://open 深链:系统未注册 x-scheme-handler/dsh、或壳没有 second-instance / open-url 处理时抬不起来;Linux 上已实测正常,macOS 未实测。
  • macOS:提醒、提示音、标题标记、点通知跳转都没有平台假设,照常工作;但 DSH Desktop(Electron)的系统通知要求应用已签名,未签名的通知直接发不出去,官方 macOS 版是否满足这一前提未实测;审批按钮在 macOS 桌面宿主上的渲染同样未实测(Windows 桌面端已实测不渲染)。

提醒内容与时机

  • ≥ 0.1.6(含 0.2.0-rc.2、0.2.1-rc.2)的「回复完成」提醒正文只有会话名,不再附带回复摘要——宿主快照已不再提供 nodes;提醒本身照常触发。
  • 标题标记由宿主与插件共同写 document.title:宿主重算标题时标记可能被覆盖,直到下一次提醒事件重新写成。
  • 「当前对话不弹通知」按「窗口是否在前台」判定:被别的应用盖住、切到别的应用、最小化都算后台;回到前台或重新有键鼠输入后恢复静默。
  • 按对话静音的标记注在宿主的 sidebar.session.row.leading 槽位,而宿主在「这条会话有运行状态」时用状态点占着这个位置,标记就不渲染——正在跑的后台任务看不到划掉的铃铛,停下后自动回来;静音本身不受影响。

外部推送

  • 受浏览器跨域(CORS)限制:端点不放行时自动降级为「送出但不可确认」(no-cors),如实标为「已送出不可确认」;Discord、飞书、Server酱、WxPusher 放行跨域,可直接确认送达。钉钉不放行任何 CORS 头、又只收 application/json,页面发不出去,所以没有做成目标类型(自建中转 + 自定义目标才行)。临时失败(网络中断、5xx、429)按退避重试,重试仍失败进失败记录,其余错误立即停、不重试;转发在本地提醒决定要发之后才触发,所以被静音的提醒也不会外发。
  • 同源中继补上的只是「页面够不到要认证的网关」这一类:页面 POST 到同源路由,宿主补凭据并回传网关的真实状态码。仍然成立的边界有几条——中继与宿主侧兜底都要求宿主进程在运行(dsh web 或桌面端主进程),它们不是独立守护进程,宿主退出就都停;宿主跑在代理环境里时网关域名要进 no_proxy,否则投递会绕到代理出去;兜底只补外部推送,本地系统通知、提示音、标题标记与审批按钮仍要页面在;pageLeaseMs: 0 是调试开关,页面开着也会收到两份;中继路由路径等同于口令,别让它出现在公开记录里。

设置页图标补丁

  • 左侧导航行图标靠 DOM 补丁:外壳按分区 id 硬编码图标且 settings.section 没有图标字段,插件只能在面板渲染后把那一行的 <svg> 换成铃铛。命中依赖宿主当前的面板结构(data-shortcut-modal="settings" + 导航按钮里的标签 <span>),DSH 改了这套结构就会静默回落到默认齿轮,其余功能不受影响。标志与命名的取舍见 docs/brand/README.md。

开发自检

node --check lib/client.js
node --check lib/index.js
npm test                         # smoke-test(状态机)+ host-route-test(宿主半路由)+ page-bridge-test + host-fallback-test + relay-route-test + cordis-host-test
npm pack --dry-run               # 预览发布包

smoke/smoke-test.cjs 是离线状态机测试:三代宿主 + 主开关 + 中英语言用例 + 快捷裁决桥(Service Worker 半侧一并跑)+ 闲置判定与通知自收 + 回到页面即收起残留通知 + 通知标签长度收口 + 页面侧 host-bridge 探测/心跳/释放与 debug().hostBridge 形状。smoke/host-route-test.mjs 不需要本机 DSH,lib/index.js 的路由、探针与 403/405/501 闸门在任何平台都会跑。

新增的三个套件同样自带假 ctx、不联网、不需要本机 DSH:smoke/page-bridge-test.mjs 跑页面桥接路由的状态回包形状、心跳与释放、缺意图头 403、跨源 403、坏 JSON 400、超长 413,以及 allowedOrigins 派生的 Host(模拟隧道)放行与路径冲突;smoke/host-fallback-test.mjs 跑宿主兜底——审批去重、turn/end 节流、子代理跳过、租约新鲜时不发、租约过期后发、alive:false 后立刻发、语言跟随心跳、401 后重读凭据、健康探针;smoke/relay-route-test.mjs 跑中继——GET 探针、405、跨源 403、413、400、503、200 / 502 映射与「401 只重试一次」。

改到 lib/focus-window.ps1 时(仅 Windows),另需手动跑一次:

powershell -NoProfile -File lib/focus-window.ps1 -ProcessId 0   # 末行应是 FOCUSED=1

cordis-host-test.mjs 会从本机 DSH 安装借 @deepseek-ai/cordis(依次探测 npm 全局目录、~/.dsh/profiles/web/node_modules 与 ~/.dsh/profiles/node_modules 平铺回退目录);源码 checkout 里没有 profile 依赖时,用 DSH_CORDIS_PATH=<checkout>/vendor/cordis/lib/index.js 指定即可。

致谢与贡献须知

| 贡献者 | 贡献 | |:---:|:---| | | #7:宿主代际兼容(0.1.6+ / 0.2.0)的报障与复测 | | | #8:子代理会话静音(ignoreSubagent),含测试与文档 | | | #9:桌面端抬窗链路的逐层排查与真实壳复测 | | | #10:「音效来源」(内置合成音 / 自定义音频 / 系统通知音) | | | #13 / #14:多会话下空闲页面占用 CPU 的性能报告与修复 | | | #12:外部通知转发(Discord / Bark / Gotify / 自定义 Webhook) | | | #17:安卓端收消息的诉求,促成飞书 / Server酱 / WxPusher 三种目标 | | | #16:离开判定 idleMinutes、「回复完成」判定 donePolicy、自动消失 autoDismiss | | | #18:Windows 通知 tag 超过 64 字符上限、审批测试通知被平台静默拒收的报告 | | | #20:当前会话的通知正文用占位符、未取会话标题的报告 | | | #21 / #22:Linux 上 dsh://open 抬窗失效的报障与补丁,1.14.4 起 macOS / Linux 恢复深链抬窗 | | | #24 / #25:页面关闭后由宿主兜底推送、同源中继两个可选模块(默认关),含测试与文档 | | | #23:按对话静音的诉求(每会话一个开关、跨重启持久化、会话行上带标记),1.16.0 起支持 |

署名口径:本仓库收编外部 PR 时以维护者提交落地——不沿用原 commit、不挂 Co-authored-by,GitHub 的 contributors 图只显示维护者,贡献者以上面的致谢署名。

License

MIT — 见 LICENSE。