dsh-task-ask-notify
DSH 桌面端插件:任务完成或模型向你提问时,弹出 Windows 系统通知并播放提示音,提示音可自定义 | DSH (DeepSeek Harness) desktop plugin: pops up a Windows system notification and plays a customizable alert sound when a task completes or the model asks you a question
- Stars
- 1
- Language
- JavaScript
- Created
- Oct 1, 2026
- Updated
- Oct 2, 2026
Introduction
dsh-task-ask-notify
一个面向 Windows 的 DSH 宿主侧插件(DeepSeek Harness Host plugin):当一轮任务 完成,或模型向你提问时,同时弹出一条真实的 Windows 11 系统通知并播放一段提示音。
- 任务完成 —— 模型把这一轮交还给你,并且此后一段时间内没有任何新动静。
- 向你提问 —— 模型调用了
ask_user_question,此刻正阻塞等待你,所以立即提醒, 不走去抖延迟。
通知是归属 DSH 自己 AUMID 的普通 Windows toast,会进入操作中心,也遵循你的专注助手 设置。提示音是你完全可控的 WAV:往三个目录里丢文件,或者直接把目录指向你已有的文件夹。
环境要求
| 操作系统 | Windows 10 2004+ 或 Windows 11 |
| DSH | 0.2.0-rc.2 或更新(需要宿主插件的 Config 与 session/event) |
| PowerShell | 系统自带的 Windows PowerShell 5.1(%SystemRoot%\System32\WindowsPowerShell\v1.0\) |
| Node.js | 20+,仅用于跑测试和随包工具,运行插件本身不需要 |
命名说明:npm 包名是 dsh-task-ask-notify,而本仓库目录叫 dsh-task-ask-notify。目录名不影响
任何行为 —— file: 安装按路径识别,DSH 读取的一切信息都来自 package.json。
安装
安装方式就是一次 bundle 安装:DSH 会把包装进当前 profile、注册 loader 行,并热应用。 不要手工改 profile。
1. 把代码放到机器上
git clone <本仓库地址> "%USERPROFILE%\.dsh\dsh-plugins\dsh-task-ask-notify"
放哪个目录都行,上面的路径只是示例。没有构建步骤,也不需要先 npm install —— 唯一那个
依赖由 DSH 自己的安装器解析。
2. 装进一个 profile
在你想收到提醒的那个 profile 里对 Agent 说:
用
plugin_manager install_bundle安装%USERPROFILE%\.dsh\dsh-plugins\dsh-task-ask-notify这个 bundle。
或者在 GUI 里:设置 → 插件,把
%USERPROFILE%\.dsh\dsh-plugins\dsh-task-ask-notify 作为本地 bundle 目录加入并启用。
plugin_manager 返回 application: "applied" 表示改动已生效;返回 restart-required
就重启 DSH。用新代码替换一个已安装的同名包也需要重启,因为宿主会缓存模块实例。
3. 确认它能用
-
起一个短任务并让它跑完:应当有一条通知 + 一段提示音。
-
或者直接跑适配器自检 —— 它完全不经过 DSH,弹一条通知并播放内置提示音:
node tools/win-alert-selftest.mjs退出码
0表示适配器自报成功。注意:退出码0不等于通知真的显示了 —— 怎么从系统侧确认,见排错。
两件事都要成立才算成功:提醒确实出现,并且退出码为 0。如果什么都没出现,
排错第一节讲的就是那个会静默失败的原因。
卸载
plugin_manager remove_bundle dsh-task-ask-notify
profile 里不会留下任何残留。下面说的配置文件不属于安装内容,也不会被删除;想一并清掉就 自己删掉它。
提示音
目录约定
插件按相对名找这三个目录:
| 目录 | 播放时机 |
|---|---|
audio/complete/ | 一轮任务完成 |
audio/ask-single/ | 模型只问了一个问题 |
audio/ask-multiple/ | 模型问了两个及以上问题 |
每次从对应目录里等概率随机取一个文件(按文件名排序,因此同样的随机序列结果可复现)。
只认 .wav。增删、替换文件在下一次提醒就生效 —— 不需要重启,因为目录列表是按目录的
修改时间做缓存的。
相对路径会按顺序查两个位置:
- 你的音频目录 ——
%DSH_HOME%\plugin-data\dsh-task-ask-notify\—— 当该路径存在且里面 至少有一个.wav时采用; - 包内目录 —— 随插件一起安装的那份。
你的目录优先,是因为已安装的 bundle 是从 profile 里的那份拷贝运行的:装完之后再往克隆里
放文件,它是看不见的,否则就必须重装才生效。又因为只有真正装着音效时相对路径才会命中,
一个空目录绝不会把某个场景的音效悄悄变成静音。在 complete.dir、askSingle.dir、
askMultiple.dir、sound.fallback 里写绝对路径则完全绕过这个顺序。
本仓库只附带一个音频文件: audio/_default/chime.wav,由
tools/gen-default-chime.mjs 生成的双音提示(880 Hz → 1318.5 Hz)。当某个场景目录缺失或
为空时就用它兜底。三个场景目录本身存在但内容为空,且它们的内容被 git 忽略;
见仓库边界。
加自己的音效
两种方式,都不需要改配置:
导入进来。 如果你的素材包结构是 <包>/complete/、<包>/ask/single/、
<包>/ask/multiple/,随包的导入工具会把它们复制进你的音频目录,并对每一个拷贝做
SHA-256 校验:
node tools/import-audio.mjs --source "<你的素材包路径>"
node tools/import-audio.mjs --verify
默认写进 %DSH_HOME%\plugin-data\dsh-task-ask-notify\audio\...,因此下一次提醒就会用上新
音效,不需要重装。如果你是就地加载克隆里的插件,用 --target package。--dry-run
只报告不写盘。导入工具不会把你给的来源路径写进任何文件,所以本机路径不可能因此进入
提交。
或者直接指过去。 见下面的 complete.dir、askSingle.dir、askMultiple.dir。支持
绝对路径,因此完全可以一个文件都不复制。
重新生成默认提示音
node tools/gen-default-chime.mjs --check
--check 会重新渲染、报告每段音的主频,并在提交的 WAV 与生成结果不一致时报错。这是有意
的:一个没人能复现的二进制素材就是不可审查的黑盒。音色参数:880 Hz 持续 0.18 s,随后
1318.5 Hz 持续 0.35 s,各带 20 ms 指数起音与指数衰减至 −80 dB。见致谢。
配置
DSH 里的配置页
插件带浏览器半侧,所以可以直接在界面里配置:
插件列表 → dsh-task-ask-notify —— 表单就渲染在这个 bundle 自己的页面上;同一页也能从
插件行的「配置」入口打开。它能改日常用到的那几项:总开关、完成提醒与静默期、单/多问题提醒、
声音开关与音量与最小间隔、通知开关、正文是否附上问题、子代理会话是否提醒。
保存立即生效:改动会提交进正在运行的插件,下一次提醒就用新值 —— 不用重启、不用重载、 也不用改文件。
试听。 音量数字旁边有一个按钮,点一下就让设置凭耳朵判断,而不是靠猜。一次点击会先把页面上
的改动应用下去,再用真实提醒走的那同一条链路播放一次声音 —— 同样的目录、同样的等概率选取、
同一个适配器、同一个音量 —— 只是不弹通知,所以你听到的就是你以后会听到的。它从随机挑中的场景
(complete、ask-single、ask-multiple)里取音,也就是播放你自己导入的某一音频,而不是插件
内置的样板。它在 sound.enabled 关闭时依然可用:先把音量调好再打开声音,是合理的顺序。
这些字段在导出的 schema 里声明为 .volatile(),这既是 DSH 愿意为它们渲染表单的前提,也是
「改动能到达运行中的插件」的机制;页面通过 DSH 自己的 settings 服务写入,因此被拒绝或发生
冲突的写入会报出来,而不会静默丢失。不在页面上的字段(音效目录、通知文案、AUMID)走下面的
层,因为它们需要重载而不是热更新。
文件层
两层,逐字段后者覆盖前者:
-
插件行
cordis.patch.yml里的config(由 Loader 按导出的Configschema 校验, 这正是 DSH 自己的配置界面所投影的同一个 schema); -
一个属于你的、在仓库之外的可选 JSON 文件:
%DSH_HOME%\plugin-data\dsh-task-ask-notify\config.json%DSH_HOME%默认是%USERPROFILE%\.dsh。用环境变量DSH_TASK_ASK_NOTIFY_CONFIG可以整体替换这个路径。
该 JSON 文件在变化时会被重新读取,所以 DSH 运行期间也能改值。文件不是合法 JSON、或含 schema 拒绝的值时,会在日志里报告并被忽略 —— 上一份有效配置继续生效,日志会带上出错 的字段路径。JSON 层优先于配置页与插件行,所以你在那里钉住的字段就无法从界面改了。
所有字段都可选,省略即用表中的默认值。想在不卸载的前提下关掉全部提醒,写
{"enabled": false} 即可。
| 字段 | 默认值 | 含义 |
|---|---|---|
enabled | true | 三个场景的总开关。 |
complete.enabled | true | 一轮干净结束时提醒。 |
complete.dir | "audio/complete" | 音效目录。相对路径先查你的音频目录、再查包内;绝对路径按原值使用。 |
complete.debounceMs | 2000 | 干净结束后的静默期。多轮 goal 任务只想响一次就调大(例如 15000),代价是短任务也要等这么久。 |
askSingle.enabled | true | 模型问一个问题时提醒。 |
askSingle.dir | "audio/ask-single" | 音效目录。 |
askMultiple.enabled | true | 模型问两个及以上问题时提醒。 |
askMultiple.dir | "audio/ask-multiple" | 音效目录。 |
sound.enabled | true | 是否出声。 |
sound.volume | 100 | 音量,0–100 刻度:0 静音,100 为原声不衰减。已经存过的 (0, 1] 值会被当作旧刻度乘以 100,因此原有设置的响度不变。 |
sound.minGapMs | 400 | 间隔小于此值的两次提醒共用一次声音;两条通知照发。 |
sound.auditionAt | 0 | 诊断项,不是偏好设置:配置页的试听按钮把它设为点击时刻,插件在它变化时播放一次声音。 |
sound.fallback | "audio/_default/chime.wav" | 场景目录为空或缺失时的兜底;填目录也可以。解析顺序与场景目录相同。 |
sound.maxDurationMs | 10000 | 播放进程的最长存活时间上限。 |
notify.enabled | true | 是否弹系统通知。 |
notify.aumid | "com.deepseek.dsh" | 通知归属的 AUMID。只在 DSH 快捷方式变了时才需要改;见排错。 |
notify.silentSystemSound | true | 关掉系统通知音,因为插件自己播。 |
notify.bodyMaxChars | 80 | 正文截断前的最大长度。 |
notify.includeQuestionInBody | false | 把首个问题文本追加到正文。默认关:正文就是会话标题。 |
dedup.completeMs | 3000 | 同一会话在此窗口内的重复「完成」提醒会被丢弃。 |
sessions.includeSubagents | false | 是否为子代理会话也提醒。默认关,避免一堆后台助手刷屏。 |
messages.completeTitle | "任务完成" | 完成提醒的标题。 |
messages.askSingleTitle | "需要你回复" | 单问题提醒的标题。 |
messages.askMultipleTitleTemplate | "有 {count} 个问题等你回复" | 多问题提醒的标题,{count} 会被替换。 |
messages.fallbackBody | "DeepSeek Harness" | 会话既无标题也无工作区目录时的正文。 |
示例 —— 更安静、英文文案,并使用你已有的音效目录:
{
"complete": { "debounceMs": 15000, "dir": "D:\\my-sounds\\done" },
"askSingle": { "dir": "D:\\my-sounds\\ask" },
"askMultiple": { "dir": "D:\\my-sounds\\ask-many" },
"sound": { "volume": 0.5 },
"messages": {
"completeTitle": "Task finished",
"askSingleTitle": "Your input is needed",
"askMultipleTitleTemplate": "{count} questions are waiting",
"fallbackBody": "Session"
}
}
通知正文是会话标题,取不到时退化为会话工作区目录名,再退化到
messages.fallbackBody。
排错
完全没有反应
最可能的原因是:某个 AUMID 没有任何开始菜单快捷方式注册它。这种情况下 Windows 会接受 toast、接口也报告成功,但什么都不会显示。也正因如此,插件启动时会自检并写一条警告。你 可以自己确认:
# 这台机器上注册了哪些 AUMID?
$shell = New-Object -ComObject Shell.Application
$folder = $shell.NameSpace("$env:APPDATA\Microsoft\Windows\Start Menu\Programs")
$folder.Items() | ForEach-Object { "$($_.Name) -> $($folder.GetDetailsOf($_, 0))" }
# 操作中心里实际投递了什么?
[void][Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime]
[Windows.UI.Notifications.ToastNotificationManager]::History.GetHistory('com.deepseek.dsh') |
ForEach-Object { $_.Content.GetXml() }
算数的是第二条查询:如果通知出现在历史里、标题正文都对,说明投递链路是通的,问题在
别处。Notification.isSupported() 返回 true、或 Show() 没抛异常,都证明不了任何事。
如果历史是空的,确认 %APPDATA%\Microsoft\Windows\Start Menu\Programs 下有一个快捷方式带
System.AppUserModel.ID = com.deepseek.dsh。DSH 自己的快捷方式(DeepSeek Harness.lnk)
在安装时就会带上它。如果你的 DSH 是别的方式装的,把 notify.aumid 改成你快捷方式上实际
的那个值。
适配器退出码是 0,但什么都没有出现
不要用 detached: true 启动适配器。 DETACHED_PROCESS 没有控制台,在这种状态下
ToastNotificationManager.CreateToastNotifier(...).Show(...) 不抛异常就返回,而 Windows
直接把这条通知丢掉 —— 脚本自认成功,退出码是 0。这是 Windows 11 上实测的结论,测量方法
是每次运行前清空操作中心:所有带 detached: true 的启动变体都什么都没投递,所有不带它的
变体都投递成功。因此插件用的是普通子进程 + 仅 unref();unref() 已经足够让它不占用宿主
的事件循环。lib/os/win.js 里记录了这次实测,test/os-win.test.mjs 会在有人把 detached
加回来时失败。
通知弹了但没有声音
sound.enabled为true,sound.volume大于0。sound.minGapMs:距上一次提醒不足 400 ms 时会复用上一次的声音。- 已有一个播放进程在跑;两次声音不会重叠。
- 场景目录为空且
sound.fallback播不了。日志里会有no playable WAV。 - 那个文件其实不是 WAV。
MediaPlayer读的是文件自身的头信息,插件算出的时长只是提示。
中文变成乱码
这是编码问题,插件在两处做了防护:适配器脚本以 UTF-8 with BOM 保存,所有动态文本都以
单个 base64(UTF-8) 参数传入,不做命令行字符串拼接。.gitattributes 保证克隆后 BOM 仍在。
如果出现乱码,说明你的 lib/os/win-alert.ps1 丢了 BOM —— 重新 clone,或者把文件按
「UTF-8 with BOM」另存一次。
提醒比预期晚
「完成」提醒是有意去抖的:只有该会话在 complete.debounceMs 内再无任何动静才发。任何
新一轮、任何非干净结束、任何新提问都会取消它。这也是有意为之 —— 官方明确劝退把
agent/status 当完成信号,所以本插件改为等待「静默」。想更灵敏就调小
complete.debounceMs。
插件根本没加载
日志里搜 dsh-task-ask-notify。Config 校验失败,或缺少 @deepseek-ai/schemastery
依赖,都会让 apply() 不执行。
不要为了「修好」它去 import
electron。 DSH 宿主以ELECTRON_RUN_AS_NODE=1运行,require('electron')只会拿到可执行文件路径, 那里面没有Notification、没有app、没有BrowserWindow。本插件之所以要调用系统自带的 Windows PowerShell,正是因为这一点。另外 PowerShell 7(pwsh)无法投影 WinRT 的 toast 类型,所以解释器是显式指定的powershell.exe5.1。
后台助手的活干完了却不提醒
子代理会话默认被排除。想让每个助手也各提醒一次,把 sessions.includeSubagents 设为
true。
仓库边界
本仓库可以直接公开,而本节就是让这件事可核查、而不是一句口号的契约。
提交什么
源码、测试、工具、文档与元数据:
.gitattributes .gitignore LICENSE
README.md README.en.md
package.json cordis.patch.yml
icon.svg locale/{en,zh}.json
lib/** 插件本体
lib/client.js 浏览器半侧:配置页
lib/os/win-alert.ps1 Windows 适配器脚本
audio/_default/chime.wav 生成物、可复现,也是唯一提交的音频
audio/{complete,ask-single,ask-multiple}/.gitkeep 空目录占位
tools/** 提示音生成、音效导入、自检、边界检查、profile 回滚
test/** 测试
有意不提交什么
| 排除项 | 原因 |
|---|---|
DESIGN.md、docs/** | 内部方案笔记,引用了撰写时那台机器的绝对目录,公开即等于泄露本机目录结构。 |
audio/complete/*.wav、audio/ask-single/*.wav、audio/ask-multiple/*.wav | 导入的音效属于个人素材,其授权归属制作者。tools/import-audio.mjs 会把它们放进你的音频目录,.gitignore 负责让它们进不了提交。哈希记录 .audio-import.json 同理排除,因为它记录了这些文件名。 |
你的音频目录与 config.json | 两者都位于 %DSH_HOME%\plugin-data\dsh-task-ask-notify\,在任何克隆之外;这里再忽略一次作为第二道防线。 |
node_modules/、coverage/、dist/、build/ | 可由 package.json 复现,出现在 diff 里只是噪音。 |
package-lock.json、pnpm-lock.yaml、yarn.lock | 安装由 DSH 的 install_bundle(pnpm)负责。第二份锁文件只会描述另一个解析器并造成漂移。 |
.env*、*.pem、*.key、.credentials.yaml 等 | 凭据与本地环境。 |
本项目不含任何形式的密钥:它不保存账号、不保存 token、不保存 API key,也不发起网络请求。
自己验证这条边界
npm run verify-boundary # 命中本机路径、凭据、运行期状态、超大文件就失败
npm run verify-audio # 用记录的哈希复核已导入的音效
verify-boundary 检查的是 git 会发布的那批文件(git ls-files),而不是工作区 ——
未跟踪的本地文件(导入的音效、你自己的 config.json)恰恰是这条边界要挡在外面的东西。
它会报告本机绝对路径、形似凭据的文本、运行期状态与异常大的文件,命中任何一项就以非零码
退出。每次 push 之前跑一遍。
上面的目录设计就是为了让这项检查通过:全新克隆里没有本机绝对路径、没有个人素材、没有 运行期数据,而插件在克隆后即可使用,因为默认提示音是提交进仓库的。
开发
npm install # 仅为跑测试而装的唯一运行期依赖
npm test # 79 个测试,不联网、无副作用
测试套件不往仓库里写任何东西:临时文件全部落在系统临时目录。没有任何测试会弹通知或出声 —— 会出声、会弹窗的是下面这两条显式命令:
node tools/win-alert-selftest.mjs # 一条真通知 + 一次真播放
node tools/win-alert-selftest.mjs --no-audio # 只弹通知
node tools/gen-default-chime.mjs --check # 重新渲染并校验提示音
结构,以及为什么这样拆:
| 文件 | 职责 |
|---|---|
lib/index.js | 入口。读取 Config、订阅 session/event、负责销毁。 |
lib/config.js | schema、分层配置、热重载。 |
lib/signals.js | 事件到场景的判定。纯函数:没有时钟、没有文件系统、没有 OS。 |
lib/dispatch.js | 去抖、去重、声音仲裁。不含任何 OS 知识。 |
lib/policy.js | 目录约定、等概率选取、WAV 时长。 |
lib/os/win.js、lib/os/win-alert.ps1 | 唯一知道 Windows 存在的两个文件。换投递通道只会动这两个。 |
已知限制
- 仅 Windows。 投递通道是 Windows toast 加一个播放 WAV 的子进程,没有 macOS / Linux 适配器。
- 点击通知没有反应。 DSH 未注册 URI 协议,toast 没有可跳转的目标。通知按设计不带按钮、 不带深链。
- GUI 里没有设置页。 配置就是上面那个运行期会被重读的 JSON 文件;schema 是导出的, 因此 DSH 自己的配置界面能看到并校验它。
- 「完成」是静默期判定,不是状态机。 多轮任务若每轮间隔超过
complete.debounceMs, 就会响多次。调大该值可以合并;代价是短任务也要等这么久。 - 通知是发后不管的。 toast 没有投递回执;上面那条系统侧历史查询是最接近的验证手段, 插件自己这一半则通过日志记录适配器退出码。
致谢
- 默认提示音复刻了社区插件
@yangzhe1991/dsh-web-enhance(MIT)里「跑完提醒」的音色与 包络;该插件在浏览器里用 Web Audio 现场合成,而宿主插件没有 Web Audio,所以本仓库把同样 两段音离线渲染成 WAV 并提交。 - 通知投递遵循官方文档所述的「非打包 Win32 桌面 toast」路径:由一个系统自带的
PowerShell 5.1 子进程调用
ToastNotificationManager.CreateToastNotifier(aumid),AUMID 复用 DSH 已经注册好的那个。
许可证
MIT © 2026 deadbushxw。