dsh-plugin-notify:让 DeepSeek Harness 在你离开时叫你回来
项目地址:https://github.com/This-is-XiaoDeng/dsh-plugin-notify
许可证:MIT
运行时:Node.js ≥ 20,宿主需带 Web 面(任何常规的
dsh webprofile)
概述
dsh-plugin-notify 是给 DeepSeek Harness(DSH)写的会话状态通知插件。会话停止、因错误退出、向你提问或等待授权时,它同时推一条 WxPusher 消息(微信里)和一条真正的浏览器系统通知——后者带提示音和 requireInteraction,横幅会一直留到你手动关闭。
所有开关都在原生的「设置 → 通知」页面里,改完立即生效,不重启、不重挂插件。
背景
DSH 的会话经常跑很久:一次重构、一轮评测、一个十几分钟的构建、一篇长文。人不会一直盯着那个页面——切到别的标签页改别的东西,去开个会,或者干脆带着手机在外面。
等回到页面,常见的情况是:会话二十分钟前就停了,最后一条消息是在问你一个问题;或者某一步报错了,它自己安静地停在那儿。你要的其实不是「跑完了没有」这个状态,而是它停了、它出错了、它在等你的时候,你能不能马上知道。
桌面通知是最直接的答案,但它只在电脑前有用;出门在外,微信里的一条消息才叫得动人。所以这个插件做了两条通道,共用同一份内容。
四个触发点
| 事件 | 监听来源 | 通知里带什么 |
|---|---|---|
| 会话停止 | api-session/status → running: false | 会话标题 + 最后一条模型回复 |
| 因错误退出 | api-session/error、agent/error | 错误信息 |
| 收到提问 | user-questions/request | 第一个问题(多个时标 +N) |
| 等待授权 | approval/request | 工具名 |
停止通知里最有价值的是「最后一条模型回复」。长任务结束时,模型通常会在最后一段里总结「改了什么、还剩什么、哪里需要你决定」,把它直接放进通知,在手机上就能看完结论,不必再打开 DSH。
所以这部分的处理按通道分开:WxPusher 那条不截断、保留换行,用 white-space: pre-wrap 渲染;浏览器通知受系统通知的长度限制,按 maxDetailChars 截断(默认 300 字)。
几个降噪开关:
minRunMs(默认 2000):跑得比这还短的运行,不报告停止。stopDebounceMs(默认 1200):智能体进入空闲后先等一会儿再报;期间又开始运行就取消报告,避免一次请求内部的多次空闲反复打扰。- 错误优先:同一会话刚发过错误通知时,紧随的停止通知会被压掉(5 秒去重窗口)——错误那条信息更精确。
rootSessionsOnly(默认开):忽略子智能体会话,只关心你自己发起的那些。
两条通道,一份内容
宿主侧只在状态变化时检测一次,组一帧(frame),同一帧既发 WxPusher,也通过 SSE 广播给所有打开的页面。内容相同,呈现方式各自贴合场景:
- WxPusher:HTML 正文,微信里收到一张卡片消息,标题形如「会话已停止 · 会话标题」。
- 浏览器:
new Notification(...),PNG 图标、可选两声短提示音(WebAudio 合成,不用音频文件)、requireInteraction常驻,并用tag按会话合并同一会话的重复通知。
功能特性
- 🔔 四个触发点:停止、出错、提问、等待授权,各带独立开关。
- 📣 两条通道,一个 payload:电脑前看系统通知,手机上收微信推送,不会一边响一边不响。
- 🖥 真正的系统通知:不是页内 toast,是操作系统级的通知中心横幅;可以设为常驻,直到你手动关闭。
- ⚙️ 原生设置页:一个
settings.section页面,由插件自己的 Config schema 驱动;每个字段都是volatile,改完立即生效。 - 🔒 只写密文:WxPusher 的
appToken声明为role('secret')——存在 profile 里,但永远不会随响应返回;编辑走按路径寻址的写入,不会把没回显过的密文覆盖掉。 - 🧪 每条通道独立测试:浏览器和 WxPusher 各有一个测试按钮,结果各自报,不会测完还分不清测的是哪条。
- 📦 除 schema 库外零运行时依赖:一个宿主文件、一个客户端文件,没有构建步骤。
实现方式
宿主 浏览器
──── ───────
api-session/status ─┐
api-session/error ─┤
agent/error ─┼─► 检测 ─► 帧 ─┬─► WxPusher HTTPS 推送
user-questions/… ─┤ │
approval/request ─┘ └─► GET /dsh-plugin-notify/events(SSE)
│
▼
new Notification(title, {
body, icon, tag,
requireInteraction, silent,
}) + WebAudio 提示音几个设计上的取舍:
- 宿主侧注册两个精确路由:SSE 流
/dsh-plugin-notify/events,以及测试端点/dsh-plugin-notify/test(?channel=browser只广播、?channel=wxpusher只推送,不带则两者都做)。 - 客户端用文档相对路径开
EventSource:dsh-plugin-notify/events会跟着页面被挂在哪个前缀下解析,并沿用页面自身的鉴权,不需要在插件里再写一遍地址或鉴权逻辑。EventSource 自己会重连,宿主每 25 秒发一次: ping心跳。 - 只观察 waterfall 事件,不改变语义:
user-questions/request和approval/request是 Cordis 的 waterfall 事件,插件只旁听,并且始终next()委派——一个通知插件绝不能吞掉本该交给其他应答者的请求。 - 设置页自己找命名空间:通过
ctx.configForms.describe()解析,先按条目 id 匹配,再按它认识的那份 schema 匹配;即使 profile 把 bundle patch 嵌进 include 组、给条目 id 加了前缀,页面依然能找到自己。 - 没有自建 Remote 命名空间:浏览器半侧因此走上面两条 HTTP 路由,而不是类型化的 Remote 通道——SSE 本来就是「服务器单向推给页面」的场景。
安装
⚠️ npm 上已经有一个同名的包(另一个项目),所以 pnpm add dsh-plugin-notify 装到的不是这个。下面的命令都指定仓库地址。命令行,一行装好:
dsh plugin --profile web add github:This-is-XiaoDeng/dsh-plugin-notify这条命令把包写进该 profile 的 dependencies 和 dsh.profile.bundles,不需要再改别的文件;profile 开着 patchReload: live 时通常直接生效。
或者在「插件」页点添加,填同一个地址,再启用它带来的 bundle:
github:This-is-XiaoDeng/dsh-plugin-notify手动装进 profile 也可以:
cd ~/.dsh/profiles/web
pnpm add github:This-is-XiaoDeng/dsh-plugin-notify然后确认 package.json 的 dsh.profile.bundles 里有它:
{
"dsh": {
"profile": {
"bundles": [
"@deepseek-ai/dsh-base",
"@deepseek-ai/dsh-web-app",
"dsh-plugin-notify"
]
}
}
}前置条件:带 Web 面的宿主(@deepseek-ai/dsh-web-app)。没有 ctx.webServer 时插件仍然推 WxPusher,只是不往浏览器发。
配置
打开 设置 → 通知,页面直接写进插件所在的 profile 条目。
| 字段 | 默认 | 说明 |
|---|---|---|
enabled | true | 两条通道的总开关 |
events.stopped | true | 会话停止时提醒 |
events.error | true | 会话或某一步出错时提醒 |
events.question | true | 智能体提问时提醒 |
events.approval | false | 等待授权决定时提醒 |
browser.enabled | true | 是否发浏览器通知 |
browser.sound | true | 两声短提示音(WebAudio,无音频文件) |
browser.requireInteraction | true | 通知常驻,直到手动关闭 |
browser.onlyWhenHidden | true | 当前标签页可见时不打扰 |
wxpusher.enabled | false | 是否启用 WxPusher 通道 |
wxpusher.appToken | 空 | WxPusher appToken(只写密文) |
wxpusher.uids / topicIds | 空 | 接收者,每行一个 |
advanced.stopDebounceMs | 1200 | 判定「已停止」前的空闲宽限期 |
advanced.minRunMs | 2000 | 短于该时长的运行不通知 |
advanced.rootSessionsOnly | true | 忽略子智能体会话 |
advanced.includeLastModelMessage | true | 停止通知附带最后一条模型回复 |
advanced.maxDetailChars | 300 | 浏览器通知里错误/提问的截断长度(WxPusher 不截断) |
同样的值也存在于 profile patch 里,升级不会丢:
- id: dsh-plugin-notify
config:
events:
approval: true
wxpusher:
enabled: true
uids:
- UID_xxxxxxxx接上 WxPusher
- 登录 https://wxpusher.zjiecode.com,创建一个应用,复制它的 appToken。
- 用微信扫应用二维码关注,然后在应用的用户列表里复制自己的 UID(也可以改用主题 ID,一次推给多个订阅者)。
- 把 appToken 和 UID 填进设置页,点保存。
appToken 输入框每次加载都是空的——宿主只会告诉页面「是否已设置」,密文本身不出 profile。
测试
两张卡片各自带一个测试按钮,各报各的结果:
| 卡片 | 按钮 | 行为 |
|---|---|---|
| 浏览器通知 | 发送测试通知 | 向所有已连接页面广播一帧,并推一条 WxPusher 消息——整条链路一起测 |
| WxPusher 推送 | 发送测试推送 | 只推 WxPusher,不弹系统通知;wxpusher.enabled 还关着时也能用,方便先验凭据再开通道 |
按钮下面会报自己的结果:
浏览器:1 个页面收到 · WxPusher:已发送
WxPusher:未填写 appToken
WxPusher:被拒绝(appToken不正确)
浏览器:没有已连接的页面(浏览器通知可能已关闭) · WxPusher:未填写 UID 或主题 ID浏览器权限那一栏会显示当前的 Notification.permission:default 就点「请求权限」;denied 要去浏览器的站点设置里重新允许。提示音需要页面先有过一次点击或按键浏览器才允许启动 AudioContext,插件会挂上第一次手势来解锁。
已知限制
- WxPusher 正文用中文拼装(受众在手机上);浏览器通知通过 Harness 的 locale 服务本地化。
- 「停止」信号取自智能体进入空闲,所以多轮对话中途的空闲也会报一次,这是设计如此——
stopDebounceMs和minRunMs是调节旋钮。 - 除 profile 配置外不做持久化:没有历史记录,也不会补发错过的通知。
- 需要带 Web 面的宿主才有浏览器通知,纯 CLI 的 profile 只有 WxPusher。
最后
这个插件解决的是一个很小的问题:你不在看的时候,会话停了,谁来告诉你。
如果你也经常把 DSH 的会话放着跑、然后忘记回来看,可以装上试试。它会在该回来的时候叫你一声。
项目还在早期,欢迎 issue 和 PR。
项目地址:https://github.com/This-is-XiaoDeng/dsh-plugin-notify