dsh-remind
任务做完了 / DSH 需要你批准或回答,但你在看别的窗口 —— 用 Windows 原生通知(带提示音)提醒你。
DSH 的会话跑完不会主动告诉你,切出去以后只能靠回来瞄一眼;DSH 等你批准或回答时更是干等着。这个插件把「这一轮真的做完了」和「DSH 需要你处理」都变成一条 Windows 通知,而且只在你不在看 DSH 的时候才打扰。
它做什么
| 能力 | 说明 |
|---|---|
| 完成任务提醒 | 顶层会话的 agent 从 running 落回 idle(这一轮真的结束);后台任务(jobs)落地时也会补一条 |
| 审批提醒 | DSH 请求批准时提醒。只有真的在等你决定才提醒:先等 800ms 确认窗口,期间被自动批准/拒绝/自动审批器决定掉就不打扰 |
| 提问提醒 | DSH 调用 ask_user_question 向你提问时提醒,通知里带问题原文(多个问题显示第一条 + 数量) |
| 提示音 | 三类通知都可配提示音(default / reminder / alarm / im / mail / sms / call … 或 none 静音);默认任务完成用 default,审批/提问用更醒目的 reminder |
| 只在该提醒时提醒 | 用 Win32 GetForegroundWindow 读当前前台窗口的标题与进程名,命中 focusMarkers(默认 DeepSeek Harness)就认为你正在看 DSH,不弹通知(审批/提问可单独配置) |
| 通知形态 | Windows 原生 Toast(进操作中心、可关闭、可点击) |
| 通知内容 | 任务完成:DSH · 任务完成 + 会话标题 · 工作区 · 用时;审批:DSH · 需要批准 + 工具名 · 会话;提问:DSH · 需要你的回答 + 问题文本 · 会话 |
防误报设计
一轮「结束」到真正「没事干了」之间可能还有后续动作,所以判定做了四层:
- 只认顶层会话:
ctx.agents.roots()排除子代理——子代理结束时父轮还在跑,不该打扰。 - 静默期复查(默认 700ms):复查时仍须满足「agent 仍是 idle、inbox 没有排队的
nextTurn/nextStep、该会话没有running|stopping的 job、workspace/session-activity里没有turn/job/subagent」。任何一条不满足就放弃这次提醒。 - 同会话冷却(默认 5s):避免 turn 结束与 job 落地撞在一起弹两条。
- 宽限期(默认 1500ms):弹通知前先等 1.5 秒,你刚切回 DSH 就不打扰。
审批 / 提问提醒怎么判断「真的在等你」
- 审批:订阅
approval/asked/approval/decided审计对。注意approval/asked对每一次审批都会追加,包括策略为never(直接拒绝)或自动审批器立即决定的情况——所以不是「一见到 asked 就弹」,而是先静默attentionSettleMs(默认 800ms):期间收到同 id 的approval/decided就当作没发生。 - 提问:订阅
tool/call,且工具名是ask_user_question。解析其参数 JSON 取问题文本;同样先静默 800ms,期间收到同callId的tool/result(你已经答完或工具已返回)就取消。 - 子代理里发出的审批/提问会沿
session.header.parentSession上溯,展示到顶层会话的标题上;turn/end或会话销毁时清掉该会话所有待发提醒。
架构与关键取舍
src/index.js 宿主端 cordis 插件:订阅事件、注册 /api/dsh-remind.* 路由、拼通知
src/detect.js 「任务完成」判定(纯逻辑,可脱离 DSH 单测)
src/attention.js 「审批 / 提问」判定(确认窗口、去重、子代理上溯)
src/notify.js Windows 通知层:调助手脚本、提示音归一化、解析结果
scripts/dsh-remind.ps1 单次 PowerShell 助手:宽限 → 前台判定 → WinRT Toast + 提示音(一次进程完成)
src/config.js 配置读写(原子写、BOM 容错、非法值夹紧)
src/contract.js 对 DSH 宿主接口的全部假设清单(升级体检用)
test/ 单测 + 真实通知实测
tools/ 侦察与验证工具(读 app.asar、带鉴权调路由、真实宿主端到端、doctor)
为什么是 PowerShell,而不是 Electron 的 Notification?
DSH 桌面端把宿主跑在独立的 Node 进程里(@deepseek-ai/dsh-desktop-host),插件拿不到 Electron API;DSH 自身也没有对插件开放的 OS 级通知接口(整个壳里只有更新提示那一处 Notification)。所以走系统自带能力:Windows PowerShell 5.1 的 WinRT Toast API。整轮(含前台判定)实测约 300ms,任务完成这种低频事件完全够用。
为什么不用 koffi FFI 直接调 user32?
koffi 的原生 .node 在 DSH 安装目录里有两套(3.1.1 / 3.3.2,互不兼容),且只能按绝对路径加载;插件里引它会把「DSH 升级 / 换机器」变成故障源。PowerShell 是每台 Windows 都有的稳定面。
前台判定为什么看窗口标题而不是页面可见性?
GUI 可能在 Electron 窗口里,也可能在浏览器标签页里(比如 Edge 打开 http://127.0.0.1:19387)。两种情况的活动窗口标题都来自页面 <title>DeepSeek Harness</title>,所以标题匹配同时覆盖两种形态;浏览器里切到别的标签页时,窗口标题变成那个标签页的标题,于是正确判定为「不在看 DSH」。
安装
本机已安装并已生效(desktop profile):
dsh plugin --profile desktop add <克隆下来的仓库目录>
这一步会:把包以 link: 形式加进 %USERPROFILE%\.dsh\profiles\desktop\package.json 的 dependencies,并把 dsh-remind 追加到 dsh.profile.bundles。插件的 cordis.patch.yml 负责把自己挂进 profile 组合树:
- insert:
- id: dsh-remind
name: dsh-remind
卸载:dsh plugin --profile desktop remove dsh-remind,或在 GUI 的 Plugins 页里移除。
改完插件源码要重启 DSH 才生效:插件以
link:方式安装,profile 热重载会重新执行apply(),但 Node 的 ESM 模块缓存不会失效——已加载过的模块仍是旧代码。实测:改动src/后触发 profile 重载,日志里出现的仍是旧版ready行;重启桌面端后才是新代码。纯配置改动(config.json或setConfig路由)不受影响。
配置
配置文件:%USERPROFILE%\.dsh\plugins\dsh-remind\config.json(改完重启 DSH 生效;也可以用下面的 setConfig 路由热改)。
| 字段 | 默认 | 含义 |
|---|---|---|
enabled |
true |
总开关 |
onlyWhenUnfocused |
true |
只在「前台不是 DSH」时提醒;false = 每次都提醒 |
focusMarkers |
["DeepSeek Harness"] |
命中即视为「正在看 DSH」的窗口标题/进程名片段(子串匹配) |
graceMs |
1500 |
弹通知前的宽限期:你马上切回来就不打扰 |
settleMs |
700 |
判定「真的做完了」前的静默期 |
minRunMs |
0 |
短于此时长的任务不提醒(0 = 不限制) |
cooldownMs |
5000 |
同一会话两次提醒的最小间隔 |
notifyOnJob |
true |
后台任务全部落地时也提醒 |
title |
DSH · 任务完成 |
通知标题 |
bodyTemplate |
{{session}} · {{workspace}} · 用时 {{duration}} |
正文模板,支持 {{session}} {{workspace}} {{duration}} {{cause}};空片段连同分隔符自动省略 |
sound |
default |
任务完成通知的提示音:default / im / mail / reminder / sms / alarm / alarm2…alarm10 / call / call2…call10 / loop / none |
notifyOnApproval |
true |
DSH 请求批准时提醒 |
notifyOnQuestion |
true |
DSH 向你提问时提醒 |
attentionOnlyWhenUnfocused |
true |
审批/提问是否也遵守前台判定(false = 正在看 DSH 也弹) |
attentionGraceMs |
300 |
审批/提问通知的宽限期(比任务完成更短) |
attentionSettleMs |
800 |
审批/提问的确认窗口:期间被决定/回答就不打扰 |
approvalTitle |
DSH · 需要批准 |
审批提醒标题 |
approvalBodyTemplate |
{{tool}} · {{session}} |
审批正文模板,支持 {{tool}} {{reason}} {{session}} {{workspace}} |
questionTitle |
DSH · 需要你的回答 |
提问提醒标题 |
questionBodyTemplate |
{{question}} · {{session}} |
提问正文模板,支持 {{question}} {{session}} {{workspace}} |
attentionSound |
reminder |
审批/提问提醒的提示音 |
openOnClick |
false |
开启后通知可点击,用默认浏览器打开带令牌的 DSH 地址 |
actionLabel |
打开 DSH |
可点击时的按钮文案 |
aumid |
"" |
Toast 的 AUMID;留空用系统 PowerShell 已注册的那个 |
debug |
false |
把每次判定写进 events.jsonl,排查「为什么没提醒」时打开 |
自检与排查
1) 立刻弹一条测试通知 —— 在 DSH 页面(浏览器/桌面窗口均可)按 F12 打开控制台执行:
// 任务完成样式
fetch('/api/dsh-remind.test', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
.then(r => r.json()).then(console.log)
// 审批 / 提问样式(带 reminder 提示音)
fetch('/api/dsh-remind.test', { method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ kind: 'approval' }) }).then(r => r.json()).then(console.log)
fetch('/api/dsh-remind.test', { method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ kind: 'question', sound: 'alarm' }) }).then(r => r.json()).then(console.log)
2) 看当前状态、前台判定与最近判定:
fetch('/api/dsh-remind.status', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
.then(r => r.json()).then(x => console.log(x.result))
返回里的 foreground 就是「现在算不算在看 DSH」的判定依据(窗口标题、进程名、是否命中);recent 是最近若干次任务完成判定(notified / focused / queued-work / jobs-running / session-active / cooldown …);attention.recent 是审批/提问判定,attention.pending 是还在确认窗口里的条数。
3) 热改配置(不重启):
fetch('/api/dsh-remind.setConfig', { method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ onlyWhenUnfocused: false, debug: true }) }).then(r => r.json()).then(console.log)
4) 判定轨迹:debug: true 时,%USERPROFILE%\.dsh\plugins\dsh-remind\events.jsonl 会记录加载与每次判定。
5) 命令行测试:
node --test # 单测(判定逻辑、配置、路由装配)
$env:DSH_REMIND_E2E='1'; node --test test/notify.test.mjs # 真实弹一条 Windows 通知
验证记录(本机实测)
| 项目 | 结果 |
|---|---|
| 前台窗口探测 | ✅ 正确读出前台窗口标题与进程名,耗时 ~250ms |
| 原生 Toast(中文 + emoji) | ✅ 弹出并进入操作中心历史(History.GetHistory 可查) |
| 提示音 | ✅ Toast XML 带 <audio src="ms-winsoundevent:Notification.Reminder" loop="false"/>,实测可闻 |
| 通知层实测 | ✅ node --test 全部通过(含前台命中时正确跳过、提示音参数) |
| 真实宿主加载 | ✅ desktop(DSH 0.2.1-rc.2)宿主启动即加载,events.jsonl 记录 ready: … approval=true question=true sound=default/reminder |
| 真实任务端到端 | ✅ 临时 profile 跑一轮真实任务:status running → status idle → notified,并真实弹出通知 |
| 真实提问端到端 | ✅ 临时 web profile 里让 agent 真的调用 ask_user_question → 插件 attention.recent 记录 kind=question, action=notify,通知里带问题原文与 reminder 提示音 |
| 审批/提问通知样式 | ✅ /api/dsh-remind.test 的 `kind=approval |
| 插件路由 | ✅ /api/dsh-remind.{status,config,setConfig,probe,test} 在真实宿主里可用(信封与裸 JSON 两种请求体都支持) |
| 升级稳定性 | ✅ 零依赖 + 零 peerDependencies,不会被 DSH 的版本兼容校验拦下(evaluatePluginCompatibility 无 peer 直接放行);bundle 解析失败只会「跳过 + stderr 一行」,不会导致 profile 启动失败 |
| 契约体检 | ✅ node tools/doctor.mjs 五层全 PASS:20 条公开契约对着新版本 DSH 自带 API 目录逐条核对通过,另有 2 条实现核对(审批/提问事件名、提问工具名)通过 |
DSH 升级后的稳定性
结论:升级不会把插件卸载或禁用,真正的风险是宿主接口漂移;后者已用「契约清单 + 体检脚本」兜住。
升级不会动它(已核对 DSH 自身源码)
| 事实 | 依据 |
|---|---|
| 插件代码在本地克隆目录里,不在 DSH 程序目录里;升级只替换程序目录 | 桌面端更新走 app-update.yml 的 generic 源,替换安装目录;DSH home 与 profile 属用户数据 |
| 升级后 DSH 会重启宿主,插件从源码重新加载(等于一次干净的重载) | 壳里的更新流程装完即重启 |
零 npm 依赖、零 peerDependencies → 永远不会被「版本不兼容」拦下 |
dsh-app-boot 的 evaluatePluginCompatibility() 第一句就是 if (!Object.hasOwn(fields, "peerDependencies")) return undefined,没有 peer 直接放行(对比:dsh-plugin-subscriptions 就因为有 peer 范围而被判不兼容、启动时拒绝激活) |
| bundle 解析只看「profile 的 node_modules 里能不能找到」 | resolveBundleDir() 依次从安装锚点与 profile 目录解析,与版本无关 |
| 最坏情况是「被跳过」而不是「启动失败」 | loadProfileDirectory() 对每个 bundle 逐个 try/catch:解析失败/缺 dsh.bundle/peer 不兼容 → 该 bundle 进 skippedBundles,profile 照常启动,stderr 打一行 dsh: skipping profile bundle "dsh-remind": … |
真正的风险:宿主接口漂移
插件的每条假设都写在 src/contract.js 里,分三类:
- 20 条公开契约(DSH 自己的 API 目录
dsh-tool-cordis/lib/types/api-catalog.js里有声明):agent/status事件、AgentStatus取值、agents.roots()/get()、jobs.list()/events.subscribe()、sessions.get()、SessionHeader.cwd/parentSession、sessionTitle.get()、sessionProjections.stateOf()、workspace/session-activity瀑布、approval/request瀑布、session/event与tool/call事件、connection.fetch.register()等。 - 3 条运行时形状(目录里没声明,属于
Agent对象的内部字段):agent.status、agent.inbox.nextTurn/nextStep、agent.session。这三条是最大变数。 - 2 条实现核对(API 目录里没声明、但插件依赖的实现细节):审批审计事件名(
approval/asked/approval/decided)、提问工具名(ask_user_question)。doctor 直接读新版本 DSH 的实现文件确认关键字串还在。
关键假设失效时的降级路径:
| 假设失效 | 后果(降级,而不是崩溃) |
|---|---|
agent.status 没了 |
静默期复查放宽 → 可能多提醒一次 |
agent.inbox 没了 |
当作「没有排队工作」 → 宁可多提醒,不漏 |
agent.session 没了 |
通知正文退化成「会话已完成」 |
sessionTitle / cwd 没了 |
正文里对应片段自动省略 |
connection.fetch.register 没了 |
只是没有自检路由,核心通知照常 |
| 审批事件名 / 提问工具名变了 | 只是审批或提问提醒失效,任务完成提醒照常 |
agent/status 事件本身改名/改语义 |
任务完成提醒会「静默失效」 → 由下面的体检负责第一时间发现 |
另外,workspace/session-activity 的判断刻意用否定表:只有已知不算干活的 schedule(已排期提醒)被忽略,其余任何种类都当作「还有活在跑」。所以新版本引入新活动种类时,插件会保守地少打扰,而不会把新种类的活误判成「已完成」。
升级后跑一次体检
node tools/doctor.mjs # 五层体检,10 秒出结论
node tools/doctor.mjs --toast # 顺带真弹一条通知
- L1 包完整性:入口能 import、
dsh.bundle.patch与助手脚本齐全、powershell 就位 - L2 profile 注册:依赖与
dsh.profile.bundles条目、node_modules/dsh-remind链接都在 - L3 宿主契约:直接读新版本 DSH 自带的 API 目录,逐条核对上面 20 条公开契约;再读 DSH 的审批/提问实现文件核对 2 条实现细节 —— 哪条没了就点名哪条,并说明后果
- L4 通知通道:真调一次 Windows 助手(前台探测;
--toast才弹) - L5 运行痕迹:读
events.jsonl最近一次加载与判定;给了--url <带令牌地址>还能问运行中的宿主要health
退出码 0 = 可用。运行期同一份清单也在生效:插件加载时逐项探测,缺失项会写进宿主日志,并出现在 /api/dsh-remind.status 的 health 字段里。
万一 L3 报某条契约失效
插件是本地 link: 的,改完源码重启 DSH 即生效:按 contract.js 里那条的 why 定位受影响代码 → 适配新接口 → node --test → node tools/doctor.mjs 复验。若 L1/L2 报缺失(例如 profile 被重建),一条命令装回来:
dsh plugin --profile desktop add <克隆下来的仓库目录>
已知边界
- 通知卡片的“应用名”显示为 Windows PowerShell:Windows 要求 Toast 必须带一个已注册的 AUMID,而 DSH 桌面端从未调用
setAppUserModelId(壳里零命中),com.deepseek.dsh只写在 package.json 里、运行时没用到。所以默认借用系统 PowerShell 的注册 AUMID。想显示成 “DeepSeek Harness”,需要给开始菜单快捷方式注册一个 AUMID(后续可做)。 - 审批提醒只在「真的在等你决定」时出现:审批策略为
never(当前会话常见)或自动审批器瞬间决定时,approval/asked之后同一刻就会approval/decided,确认窗口会把它过滤掉——这是刻意的,避免为不需要你操作的审批弹窗。只有当审批真的挂起等待 UI 应答时才会提醒。 - 提问提醒只认
ask_user_question工具:这是 DSH 的标准提问工具;如果某个 profile 不加载它(例如 headless 模板的极简工具集),自然也不会有提问提醒。 - 专注助手/勿扰模式会照常拦截 Toast(系统行为,插件无法绕过)。
- 前台判定基于窗口标题/进程名子串。若你把 GUI 套在标题不含
DeepSeek Harness的外壳里,请把该标题加进focusMarkers。 openOnClick默认关闭:开启后点击通知会用默认浏览器打开带令牌的地址(等于新开一个已登录的 DSH 页面,而不是聚焦已有窗口)。- 一轮结束后若宿主进程在静默期内直接退出(如
dsh headless "..."一次跑完即退),判定来不及执行——短命进程的固有限制;桌面端宿主长期驻留,不受影响。
开发工具(tools/)
| 工具 | 用途 |
|---|---|
doctor.mjs |
升级后体检:五层核对(包完整性 / profile 注册 / 新版本 API 目录契约核对 / 通知通道 / 运行痕迹) |
asar.mjs |
不装 Electron 也能列/读/解 app.asar(list / read / extract;也可当模块 import) |
api.mjs |
带启动令牌换 cookie 后调插件路由(Windows 命令行会吃掉裸 JSON 的引号,复杂请求体用 @文件) |
e2e-turn.mjs |
在真实宿主里建会话、发提示、轮询「任务完成」判定 |
e2e-attention.mjs |
在真实宿主里让 agent 调用 ask_user_question,轮询「提问提醒」判定(兼容新旧两种 RPC 信封) |
live-reload-check.mjs |
临时摘除/加回 bundle 以触发 profile 热重载(用于确认插件是否已在运行中的宿主里生效) |
verify-toast.ps1 |
查某个 AUMID 的操作中心历史,确认 Toast 真的被系统接受 |
No comments yet. Be the first to write one.