dsh-task-notifier
DSH 任务通知器:任务完成 / 需要你批准 / 需要你回答 / 出错时,用「应用内横幅 + 提示音 + 系统通知」提醒你。
通知哪四件事
| 事件(DSH 公开事件) | 触发条件 | 横幅标题 |
|---|---|---|
agent/status |
会话从 running 变回 idle,且不是在等你输入 |
任务完成 |
approval/request |
有工具调用需要你批准(沙箱/权限) | 需要你的批准 |
user-questions/request |
有工具在等你回答问题 | 需要你的回答 |
agent/error |
某一步或某一轮出错 | 执行出错 |
两个 waterfall 事件(approval / user-questions)只做通知,然后原样 next() 交回默认流程,不改变也不拦截既有批准语义;同一轮已经报过错时不再补一条"完成",避免双响。
"卡在等你输入"不当作任务完成:代理停下来等你批准 / 等你回答时,agent/status 也会落到 idle,
但那不是跑完了 —— 那种暂停已经由 approval / question 通知过一次,再补一条"任务完成"会让同一次
暂停被通知两遍且结论相反。所以 approval/request 与 user-questions/request 会把该会话标记为
awaitingUser,直到你回复(agent/inbox/claimed)或该会话重新 running 才清除;被标记期间的
running → idle 不发"完成"。/status 会把这个集合列出来,方便排查。
设置页:通知
插件在 设置 → 左侧导航里注册了一个「通知」页(客户端插件的 settings.section 槽位),可以自由配置:
| 分组 | 可配置项 |
|---|---|
| 通知哪些事 | 总开关;任务完成 / 需要我批准 / 需要我回答 / 出错失败 四个独立开关;是否包含子代理 |
| 怎么提醒我 | 应用内横幅;系统通知;横幅停留秒数 |
| 提示音 | 提示音开关;音色(清脆 / 铃铛 / 水滴 / 脉冲 / 静音)+ 试听;音量滑块(0–100%,实时生效) |
- 设置存在浏览器
localStorage(键dsh-task-notifier:settings:v1),改完立即生效,不需要重启客户端。 - 音色只改变音品(波形 / 包络 / 八度),四类事件仍用不同音高区分,所以换了音色也听得出是哪一类。
cordis.patch.yml里的config只作为初始默认值:只有当你从没在设置页里改过时才生效; 一旦动过任何一项,就以设置页为准。
它怎么工作
宿主进程 (lib/index.js) 页面半区 A (assets/notifier-client.js)
├─ 订阅 4 个事件 ┌─► ├─ EventSource('/dsh-notifier/events')
├─ webServer 注册 4 条路由 ─── SSE ─────┘ ├─ 应用内横幅(Shadow DOM,样式隔离)
└─ webserver/index-inject 注入 <script> ──► ├─ 提示音(WebAudio 合成,无音频资源)
└─ 系统通知(页面 Notification API)
▲
页面半区 B (lib/client.js) ── 设置页「通知」────────┘
dsh.client 声明的客户端插件,注册进 settings.section;
改动写 localStorage 并广播事件,半区 A 立即生效(试听也走事件,音色合成只有一份实现)
注入走两条并存的通道:
webserver/index-inject的内联 script 行 —— 官方桌面端(Electron)唯一生效的通道。桌面壳的index.html从安装包静态 dist 直出,不经过宿主renderIndex(),tapIndex到不了。 必须是内联行而不是script-src行:页面侧解释器对script-src是"加载失败即 reject 整个 boot", 内联行自己createElement并吞掉onerror,路由不在也只是静默失败。webServer.tapIndex—— 浏览器形态(dsh web)走renderIndex(),这条生效。
安装
插件以 bundle 形式装进某个 profile:该 profile 的 package.json 里 dsh.profile.bundles
会出现 dsh-task-notifier,依赖以 link:(软链)或 file:(拷贝)指向插件目录。
⚠️ 官方桌面端(Electron)读的是
desktopprofile,而这个 profile 由客户端独占管理,dsh plugin --profile desktop会被拒绝。正确做法是在 DSH 会话里让 DSH 自己装:
让 DSH 调用内置 plugin_manager:
install_bundle target = link:<插件目录绝对路径> # 软链安装:改完存盘即生效,最省事
install_bundle target = file:<插件目录绝对路径> # 拷贝安装:自包含,源码丢了插件照跑
生效方式:新装通常热生效(返回 "application":"applied");换版本、或改 package.json
(例如新增 dsh.client)需要重开客户端(返回 "restart-required")。
卸载:
plugin_manager remove_bundle target = dsh-task-notifier
配置
改 cordis.patch.yml 的 config 段(每项都可省略,省略即用 lib/index.js 里的默认值):
| 键 | 默认 | 说明 |
|---|---|---|
notifyOnDone |
true |
任务完成通知 |
notifyOnApproval |
true |
需要批准通知 |
notifyOnQuestion |
true |
需要回答通知 |
notifyOnError |
true |
出错通知 |
includeSubagents |
true |
子代理 / teammate 也通知;false = 只通知主会话 |
suppressDoneAfterError |
true |
同一轮已报错就不再补"完成" |
coalesceMs |
1200 |
同键事件在该窗口内只发一次,防重试/并行抖动刷屏 |
sound |
true |
提示音 |
systemNotification |
true |
系统通知(Windows 通知中心,点击切回窗口) |
toastDurationMs |
8000 |
横幅停留时长 |
errorToastDurationMs |
15000 |
出错类横幅停留时长 |
volume |
0.35 |
提示音音量 0~1 |
配置在重启客户端后生效(profile patch 改动需要一次 config reload,桌面端最稳的是重开客户端)。
自检
宿主侧(不需要看页面):
# 插件是否活着 + 当前配置 + 已连上的页面数 + 已发通知数 + 正卡在等谁输入
Invoke-RestMethod http://127.0.0.1:19387/dsh-notifier/status
# 回看最近发过哪些通知(排查"到底发了没 / 发了几条 / 是不是误报")
Invoke-RestMethod 'http://127.0.0.1:19387/dsh-notifier/recent?limit=20'
# 真的走一遍通知链路:页面会弹横幅 + 响铃 + 系统通知
Invoke-RestMethod 'http://127.0.0.1:19387/dsh-notifier/test?kind=approval'
# kind 可取 done | approval | question | error
进程外单元验证(不需要 DSH 在跑):
node test/smoke.mjs
排查
| 现象 | 原因 / 处理 |
|---|---|
/dsh-notifier/status 返回 404 |
插件没激活。看 profile 的 patch 里 dsh-task-notifier 是否 disabled: false,然后重开客户端 |
| 路由通了但页面不弹 | 页面半区没注入。桌面端注入表在宿主启动时收集一次,改完必须重开客户端;浏览器形态直接 Ctrl+F5 |
| 有横幅没声音 | 浏览器/Electron 要求先有用户手势才能播音频。在页面里点一下任意位置即可解锁 |
| 没有系统通知 | 页面侧 Notification.permission 不是 granted。点一下页面任意位置会触发一次授权请求,允许即可 |
改了 assets/notifier-client.js 不生效 |
宿主按 mtime 热读取,正常刷新页面即可;不生效再 Ctrl+F5 |
改了 lib/index.js 不生效 |
宿主进程的 ESM 模块缓存不会因文件改动失效 —— 必须重开客户端 |
改代码后怎么生效(开发工作流)
改哪个文件,决定你要做什么才能看到效果——这三者完全不同,别搞混:
| 你改了 | 怎么生效 | 为什么 |
|---|---|---|
assets/notifier-client.js |
刷新页面(F5)即可 | 宿主每次请求 /dsh-notifier/client.js 都按 mtime 重读文件 |
lib/client.js(设置页) |
刷新页面(F5);新增/删除 dsh.client 声明要重开客户端 |
客户端包体由 clientModules 提供,页面刷新即重新拉取 |
lib/index.js |
必须重开客户端 | Cordis 重新 apply 时命中 Node 的 ESM 模块缓存,文件改动不会失效 |
cordis.patch.yml(配置项) |
必须重开客户端 | profile patch 需要一次 config reload |
package.json(dsh.client / exports) |
必须重开客户端 | 客户端入口图在宿主启动时合成 |
| 任何文件 | 改完先跑 node test/smoke.mjs |
17 个用例覆盖事件、路由、客户端交付契约,几秒出结果 |
推荐的改 bug 流程:
# 1) 改代码
# 2) 先跑进程外回归(不需要 DSH 在跑,失败会非零退出)
node test/smoke.mjs
# 3) 只改了页面半区 → F5;动了宿主半区或 package.json → 重开客户端
# 4) 自检确认活着
Invoke-RestMethod http://127.0.0.1:19387/dsh-notifier/status
保存、备份与迁移
先记住一件事:这个插件的源码只有一份。 profile 里的
~/.dsh/profiles/desktop/node_modules/dsh-task-notifier 不是拷贝,而是一个指向
源码目录的 Junction(目录联接)。源码目录一旦被移动、改名或删除,插件立刻失效
(表现为启动时 entry 激活失败)。
所以源码必须放在一个你不会随手清理的固定位置:
- 开发期用
link:安装:改完存盘就生效(按上面的表决定要不要重启),最省事。 代价是源码目录不能动。 - 想彻底冻结/自包含用
file:安装:pnpm 会把包拷贝进 profile,源码丢了插件照跑。 代价是每次改完都要重新 install 一次才生效。
切换安装方式(两者都是让 DSH 调内置 plugin_manager):
# 换到新位置(绝对路径,路径含空格/CJK 都要原样写)
install_bundle target = link:<插件目录绝对路径>
# 或者改成拷贝安装,冻结当前版本
install_bundle target = file:<插件目录绝对路径>
迁移步骤(把源码搬出临时目录):先复制整个目录到新位置 → 用新路径重新 install_bundle
→ 确认 Invoke-RestMethod .../dsh-notifier/status 正常 → 再删旧目录。
版本管理
本仓库用 git 管理。养成这个习惯就够了:
cd <插件目录>
git add -A
git commit -m "fix: 描述这次改了什么"
改 package.json 的 version 并打 tag,便于回滚和对照:
git tag v0.1.1
想跨机器/长期保存,再推到一个私有仓库,之后就能像其他 DSH 插件一样从 GitHub 安装:
install_bundle target = github:<你的用户名>/<仓库名>
已知限制
- 宿主进程里的模块改动无法热重载:Cordis 重新 apply 时会命中 Node 的 ESM 缓存,改
lib/index.js只能重启客户端(或改文件名后重新 install)。 - 桌面端的 index 注入表是一次性的:宿主启动时
collectIndexInjections()经 IPC 交给渲染层,之后没有刷新路径。 - 桌面端拿不到 Electron 的
Notification:运行时是被桌面壳spawn出来的独立子进程,插件跑在子进程里。 从无包标识进程调 WinRT toast 会报0x80073D54,所以系统通知只能在页面侧用NotificationAPI。 - 路由带一层极简信任栅栏(只接受回环 Host、拒绝
Sec-Fetch-Site: cross-site),因为事件流里含会话路径。 - 设置存在浏览器 localStorage,不写回 profile 配置:换浏览器/清缓存会回到默认值。这是刻意的取舍 ——
走 Host 设置文档(
configForms)需要给条目声明 schemastery schema 并依赖宿主设置服务, 一旦版本不匹配会让整条 entry 激活失败;通知偏好属于纯展示层,不值得冒这个风险。
目录结构
dsh-task-notifier/
├── package.json # dsh.bundle.patch + dsh.client(exports["./client"])
├── cordis.patch.yml # 挂载声明 + 宿主侧可配置项
├── lib/index.js # 宿主半区:事件订阅 + 路由 + 注入
├── lib/client.js # 客户端半区:设置里的「通知」页(__ModuleLoader__ 格式)
├── assets/notifier-client.js # 页面半区:横幅 + 提示音 + 系统通知
└── test/smoke.mjs # 进程外冒烟测试(17 个用例)
许可证
MIT —— 见 LICENSE。
No comments yet. Be the first to write one.