给 DSH(DeepSeek Harness)用的 Windows 原生通知插件:任何对话把控制权交还给你的时候,弹一条 Windows 系统通知。
它监听的是整个 DSH 进程的事件,所以后台正在运行的对话同样会通知你 —— 你不用一直盯着某个会话窗口。
| 场景 | 通知标题 | 触发事件 |
|---|---|---|
| 任务完成,可以继续对话 | ✅ 对话已完成 | agent/status → idle |
智能体向你提问(ask_user_question) |
❓ 需要你的输入 | user-questions/request |
| 有操作等待你批准 | 🔐 需要你批准 | approval/request |
| 某一步/某一轮出错 | ❌ 对话出错 | agent/error |
| 本轮被中止(可选,默认关) | ⏹️ 本轮已中止 | agent/status + turn/end: aborted |
通知正文第一行是对话标题(取自 DSH 的会话标题,还没生成标题时回退到工作目录名),第二行是状态说明或具体内容(提问内容、工具名、错误信息、运行时长)。
默认不通知 subagent / workflow 产生的内部子会话 —— 一次后台委派会派生好几个子会话,全部通知会变成弹窗轰炸。需要的话把 includeSubagents 打开。
DSH 进程
└─ host 组合(所有 profile 共用)
└─ dsh-windows-notifier ← 这一个插件行
├─ ctx.on('internal/dispatch', …) 框架的派发公告(事件名 + 载荷)
└─ ctx.on('agent/status' | 'user-questions/request' | …) 直接监听(兜底)
│
└─ 一个短命的 powershell.exe → Windows Toast(WinRT)
DSH 的作用域事件(agent/status、user-questions/request、approval/request、agent/error)是带作用域载体派发的:Cordis 会把「上下文不在该载体作用域链上」的监听器直接丢掉。
这个过滤是真实可观测的:挂在 host 根上下文上的普通监听器能收到 agent/status,却收不到 user-questions/request —— 后者以提问的那个 agent 自身作为作用域键,而派发确实发生了,只是监听器被滤掉了(用 ctx.on('internal/dispatch', …) 能看到这次 waterfall 派发,但自己的监听器不响)。
所以插件同时观察两条通道:
internal/dispatch—— 框架自己的派发公告。任何非internal/事件都会在作用域过滤之前在这里公布一次,因此用{ global: true }注册的监听器能看到所有作用域的事件。这是「监听任意对话(含后台对话)」唯一可靠的入口(DSH 自己的 scope-invariant 插件也是这么监听的)。- 直接监听那四个事件 —— 兜底:万一某个版本不再公布派发流,直接监听仍然生效。
两条通道拿到的是同一个载荷对象,因此用一个 WeakSet 按对象身份去重:谁先到谁负责通知,另一条通道直接跳过 —— 不需要拍脑袋定一个时间窗。
其余特性:
- 零依赖:通知由
scripts/toast.ps1通过 Windows PowerShell 自带的 WinRTWindows.UI.Notifications类型弹出,不需要装 BurntToast、不需要注册 COM、不需要常驻进程。 - 不阻塞任何东西:所有监听器都只是旁观。追问/审批那条监听器仍会调用
next()把瀑布链传下去,所以正常问答完全不受影响(有测试专门盯着这点)。通知进程异步启动并带并发上限,插件卸载时会杀掉还在跑的进程。 - 点击通知可以打开 DSH 的 Web GUI(自动读取当前 Web 服务端口,也可用
launchUrl指定)。GUI 目前没有会话级路由,所以只能打开到首页。
DSH 的能力全是 cordis.yml 里的一行行插件。装一个第三方插件 = 装包 + 加一行。
# 从 GitHub 装(推荐)
dsh plugin --profile web add https://github.com/ADkun/dsh-windows-notifier.git
# 或者从本地目录装
dsh plugin --profile web add D:\path\to\dsh-windows-notifier
dsh plugin只是把参数转发给 profile 目录里的 pnpm。如果机器上没有 pnpm,可以手动把仓库目录放到 profile 的node_modules下(见下方「没有 pnpm 时」)。
编辑 $DSH_HOME/profiles/web/cordis.patch.yml(Windows 上通常是
C:\Users\<你>\.dsh\profiles\web\cordis.patch.yml):
- insert:
- id: windows-notifier
name: 'dsh-windows-notifier'patchReload: live 的 profile 会热加载这个改动,不用重启。
$DSH_HOME/cordis.patch.yml(即 C:\Users\<你>\.dsh\cordis.patch.yml)是机器级的 patch 层,在每个 profile 自己的 patch 之后应用。想一次管住所有 profile(web、headless、sdk…),就写在这里:
- insert:
- id: windows-notifier
name: 'dsh-windows-notifier'
config:
includeSubagents: false这样每个 profile 启动时都会挂载它。如果某个 profile 里没有 pnpm 装的包,需要保证包能被解析到 —— 最省事的做法是把它放进 profile 目录的公共 node_modules($DSH_HOME/profiles/node_modules/),Node 的解析会从每个 profile 目录向上找到它。
完整示例见 examples/cordis.patch.yml。
把仓库以目录联接(或直接复制)的方式放进公共模块目录即可:
New-Item -ItemType Junction `
-Path "$env:USERPROFILE\.dsh\profiles\node_modules\dsh-windows-notifier" `
-Target "D:\path\to\dsh-windows-notifier"插件本身零依赖、零构建,所以不需要 npm install / pnpm install。
- 改 patch 文件(开关、
logFile、launchUrl…)→patchReload: live会立刻重挂载,马上生效。 - 改界面里的设置(设置 → 插件 → 插件配置)→ 立刻生效,见下面「界面化配置」。
- 改插件源码 → 不会立即生效。DSH 的 loader 按「模块解析后的路径」缓存 ESM 模块,并且在插件行的
name没变时复用已经加载过的那个模块;同一个文件路径改内容也不会重新导入。要让新代码生效,要么重启对应 profile,要么把包放到一个新路径(换个目录、或复制一份到别处)再让行指向它。 浏览器半侧同理,而且更严格:客户端模块图按「解析后的说明符」缓存包元数据(包括"没有浏览器半侧"的否定结论),所以连"换个新路径的插件行"这条路也不通(实测:改成子路径说明符后这一行会导入失败),只能重启一次。
上面「目录联接」的写法适合开发:联接指向仓库时,增删文件(路径变化)就能被重新导入,改同一个文件则不行。
全部可省略,省略即用默认值。
| 选项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
enabled |
boolean | true |
总开关;false 时插件不做任何事 |
notifyOnActivate |
boolean | false |
激活时先弹一条「已启用」,用来确认通道正常 |
includeSubagents |
boolean | false |
是否也通知 subagent / workflow 子会话 |
notifyOnComplete |
boolean | true |
任务完成(idle)时通知 |
notifyOnQuestion |
boolean | true |
智能体提问时通知 |
notifyOnApproval |
boolean | true |
等待批准时通知 |
notifyOnError |
boolean | true |
出错时通知 |
notifyOnInterrupted |
boolean | false |
本轮被中止时通知 |
minTaskDurationMs |
number | 0 |
短于该时长的任务不通知(避免零星小任务刷屏),例如 5000 |
sound |
default | silent |
default |
是否播放通知音 |
disappearAfterMs |
number | 6000 |
通知停留时长(毫秒);0 = 一直留在屏幕上,直到你手动关掉 |
openOnClick |
boolean | true |
点击通知时打开 DSH Web 界面;false 让通知不可点击 |
appId |
string | Windows PowerShell 的 AUMID | 通知归属的应用标识;换成你注册过的 AUMID 就能改显示名 |
powershellPath |
string | 自动探测 | 指定 powershell.exe 路径(必须是 Windows PowerShell 5.1,pwsh 7 不支持 WinRT) |
scriptPath |
string | 包内 scripts/toast.ps1 |
指定自定义通知脚本 |
launchUrl |
string | 空(自动取当前 Web GUI 地址) | 点击通知打开的地址;支持 {sessionId} 占位符 |
logFile |
string | 空 | 追加调试日志到文件,排查用 |
maxConcurrent |
number | 1 |
同时运行的 powershell 进程上限 |
timeoutMs |
number | 15000 |
单个通知进程的超时时间 |
「停留时长」能做的事,受 Windows 自己限制。 横幅时长只有「约 5 秒 / 约 25 秒」两档,插件只能按
disappearAfterMs选最近的一档(> 7000 用长档);这个值同时通过ExpirationTime决定它在通知中心里保留多久,所以写 3000 并不会让横幅 3 秒就走。要让通知「无限等待」,用0:插件会带上scenario="reminder",通知就一直留在屏幕上直到你手动关闭——这是 Windows 上唯一能做到这件事的方式。
插件注册了一个 settings 命名空间 dsh-windows-notifier,并在 Web 界面的 设置 → 插件 → 插件配置 里贡献一张卡片。常用的开关都在卡片上:总开关、五类通知开关、停留时长、是否跳转、通知音、忽略短任务、点击地址。
- 卡片里保存的值写进
$DSH_HOME/settings.yaml,优先级高于 patch 里的config:;卡片上的「重置」让该字段重新继承 patch 的值。 - 保存后立刻生效,不用重启:
enabled关掉会真的把监听摘掉,重新打开再挂回去。 - 卡片是插件的浏览器半侧(
src/client.js),按 DSH 的 lazy-CJS 客户端模块格式手写,所以仓库仍然零构建。注册命名空间需要@deepseek-ai/schemastery(harness 自带);环境里没有它时插件照常工作,只是没有这张卡片。 - 装好后需要重启一次 DSH 卡片才会出现:客户端模块图按「解析后的说明符」缓存包元数据,其中也包括"这个包没有浏览器半侧"这个结论,所以新声明的浏览器半侧要等下一次启动才进入引导图。
先单独验证 Windows 侧通道:
node scripts/send-test-toast.mjs
node scripts/send-test-toast.mjs "自定义标题" "自定义正文"看到通知就说明通道没问题,剩下的只是让 DSH 把事件交给插件。
再验证插件本身:在配置里加 notifyOnActivate: true(或 logFile: D:\dsn.log),保存 patch 文件触发热加载,应该立刻弹出一条「已启用」通知 / 日志里出现 [dsh-windows-notifier] active。
logFile 会记录每一次决定,包括每一次"没通知"的原因,例如:
notify complete: ✅ 对话已完成 / 重构支付模块 | 已运行 2 分 13 秒,可以继续对话了。 -> http://127.0.0.1:3080
skip complete for session-…: not a user-visible conversation
skip complete for session-…: attached session has no settled turn
session … is not attached; reporting completion without a turn outcome
skip question: switch off
「为什么没弹」基本都能在这几行里找到答案。
- 从 patch 文件里删掉那一行(或加
disabled: true); dsh plugin --profile web remove dsh-windows-notifier(手动放的目录直接删掉);- 顺手可以删掉
$DSH_HOME/settings.yaml里残留的dsh-windows-notifier:分节(不删也无害,它只是没人读)。
- 仅 Windows:非 Windows 平台插件会安静地跳过,不会报错。
- 专注助手(Focus Assist)/「请勿打扰」会拦掉通知:这是系统行为,插件无法绕过。
- 通知显示的应用名是「Windows PowerShell」:因为用的是它现成的 AUMID,好处是零安装。想改成自己的名字,需要注册一个带
AppUserModelID的开始菜单快捷方式,然后把appId指过去。 - 每条通知会短暂启动一个
powershell.exe(约 0.3–1 秒)。对「一轮任务结束」这种频率完全够用;这也是它不需要任何依赖的代价。 - 必须在有 DSH 事件的前提下工作:headless / sdk 这类最小 profile 如果不发这些事件,插件会挂载但不产生通知。headless 收尾时会话可能已经 detach,这种情况下插件仍按「完成」通知(
idle只会在状态变化时发布,所以它本身就意味着有东西跑完过)。 - 点击通知只能打开 GUI 首页:DSH 的 Web GUI 把会话选择放在内存里,没有会话级路由,所以没有可深链的地址。
launchUrl里写{sessionId}会被替换,但目标页面目前不消费它。不想要这个跳转就把openOnClick关掉(patch 或界面里都行)。 - 横幅时长只有两档:见上面配置项下的说明;
0是唯一能"无限等待"的值(scenario="reminder")。 - 界面卡片的文案只有中文:插件没有注册 locale 词典,卡片上的文案是写死的(用户是中文用户,README 也是中文优先)。要双语的话得再注册一个 locale 命名空间。
- 通知不做「用户是否正在看这个会话」的判断 —— 前台会话结束同样会弹。
node --test test # 55 项测试:消息文案、配置归一化、事件接线、双通道去重、
# settings 命名空间与实时改配置、浏览器半侧的卡片契约与暂存/保存仓库结构:
src/plugin.js 插件本体:观察派发流 + 直接监听,决定要不要通知,并跟随界面设置
src/messages.js 纯函数:会话过滤、文案、时长格式化
src/config.js 配置归一化(任何脏值都回退到默认值,不炸 profile)
src/settings.js settings 命名空间:卡片能改哪些选项、默认值、组合层 base
src/client.js 浏览器半侧:手写的 lazy-CJS bundle,注册「插件配置」里的卡片
src/notify.js Windows Toast 传输层:队列 + powershell 进程生命周期
scripts/toast.ps1 WinRT 弹窗脚本(纯 ASCII,中文通过参数以 UTF-16 传入)
scripts/send-test-toast.mjs 手动验证通道
dsh-windows-notifier is a zero-build DSH plugin that raises a native Windows toast whenever
any conversation in the process hands control back to you: a turn finished, the agent asked a
question, an approval is pending, or a step errored. Subagent and workflow child sessions are
filtered out by default. It depends on nothing you have to install: the Windows side is Windows
PowerShell's built-in WinRT Windows.UI.Notifications, and the settings schema comes from the
harness's own @deepseek-ai/schemastery.
Its common switches are editable at Settings → Plugins → Plugin configuration, which writes to
$DSH_HOME/settings.yaml and applies live — including the notification lifetime (0 means "stay
until dismissed") and whether a click opens the Web GUI. That card is the plugin's browser half,
hand-written in DSH's lazy-CJS client-module format, so this repository still needs no build step.
Note that a browser half only enters the web boot graph at startup: the client module graph caches
per-package metadata per resolved specifier, so restart DSH once after installing.
DSH dispatches those events through a scope carrier, and Cordis drops listeners whose context
is outside the carrier's scope chain — a plain root-context listener sees agent/status but never
user-questions/request. The plugin therefore observes two channels: internal/dispatch, the
framework's own pre-filter dispatch announcement (which reaches every scope), plus direct
listeners as a fallback. Both carry the same payload object, so a WeakSet de-duplicates them by
identity. Toasts go through Windows PowerShell's built-in WinRT Windows.UI.Notifications types,
so nothing has to be installed, and a click opens the Web GUI.
Add the package to a profile and insert one row into the profile's cordis.patch.yml (or the
machine-wide $DSH_HOME/cordis.patch.yml to cover every profile):
- insert:
- id: windows-notifier
name: 'dsh-windows-notifier'MIT