系统弹窗 · dsh-system-notify
DSH 现在不会提醒你任何事:会话要授权、要操控你的电脑、任务跑完了、任务挂掉了、会话被终止了——统统没有声音、没有弹窗,全靠你盯着那个窗口。
这个插件补上这一环:在 agent 运行时盯着这些状态变化,用 Windows App SDK 弹出系统通知。它不是工具、不进 function calling、不改 system prompt——模型什么都不输出,通知照样会来。
- 包名:
@local/dsh-system-notify - 类型:宿主插件(没有客户端半边)
- 运行环境:Windows 10 1809+ / Windows 11,DSH 桌面端
- 第三方依赖:无(宿主侧零依赖,原生侧只用 Windows App SDK 与 .NET)
目录
它解决什么问题
| 你在等的事 | 之前 | 装上之后 |
|---|---|---|
| agent 要求授权(提权、写文件、跑命令) | 界面里静静躺着,你不看就永远卡着 | 「会话请求授权」弹窗,带工具名与原因 |
| agent 要操控电脑(OpenCU 之类) | 同上 | 「电脑操控请求」弹窗,单独一条规则 |
| 一轮任务跑完 | 只能盯着看它停没停 | 「任务完成」,带会话名与轮次 |
| 一轮任务失败/被阻塞/爆上下文 | 同上 | 「任务未完成」,带错误码与消息 |
| 会话被终止 | 同上 | 「会话已终止」(只在终止时还有一轮在跑时才弹) |
检测点
全部挂在 DSH 已经存在的运行时事件上,不新增任何"让模型做决定"的环节:
| 检测点 | 来源 | 覆盖场景 | 默认 |
|---|---|---|---|
approval/asked / approval/decided |
session/event(审批服务写的会话事件) |
要求授权、提权、电脑操控请求 | 开 |
turn/end |
session/event(agent-loop 在每轮收尾时追加) |
任务结束 / 失败 / 被阻塞 / 被中断 | 开 |
session/disposed |
cordis 事件 | 会话终止 | 开 |
agent/error |
cordis 事件 | 运行错误(turn/end 的 error 已覆盖大多数情况) | 关 |
tool/call |
session/event(每调用一个工具前追加) |
电脑操控:操控类工具一被调用就提醒 | 开 |
电脑操控为什么挂在 tool/call 上而不是审批事件上:本地运行时调研(docs/research/runtime-hooks.md)确认,OpenCU 那套电脑操控不经过 DSH 的审批服务——它自带一套 app-approvals,源码对 ctx.approval 零引用。所以"只看审批"就等于永远抓不到电脑操控。computer_use / opencu 这类工具被调用时,tool/call 是唯一纯事件级的挂点(tools/pre-execute 载荷更好,但要 return next() 才不打断调用,一个只读观察者没有理由插进那条链)。
同一会话里连续几十次 computer_use 不会刷屏:这条规则带 5 分钟冷却(cooldownMs),节流桶按会话合并。
两条与"贴心"有关的细节:
- 授权先等一小会儿再弹(
scope.approvalGraceMs,默认 1500 ms)。approval/asked之后往往几十毫秒就approval/decided——你本来就在界面前点了,或者会话策略是 never 直接自动拒绝。真正值得打扰的是"弹出来之后一秒半还没人决定",那时候 agent 是真卡住了。 - 子会话默认静默(
scope.includeChildSessions,默认 false)。一个 agent 拉起一堆 subagent 时,你不该被十条通知淹没。
为什么不是 tool
需求里写得很清楚:通知不能由 AI 发起。这个插件在结构上就做不到"被模型调用":
- 它没有注册任何工具,
ctx.tool、function calling 的 tools 列表里都没有它; - 它不读也不写 system prompt,模型不知道它存在;
- 它的触发源是 cordis 事件(
session/event、session/disposed、agent/error)——这些事件由 DSH 自己的审批服务、agent 循环、会话存储产生,模型输出什么、输不输出,都影响不了它们; - 监听器只读:不 append 事件、不改审批结果、不碰 agent 循环。cordis 的 emit 是"遇抛即停",所以每个监听器都被
contain()包住——这里出错只会变成一条警告,绝不饿死其他订阅者。
测试里有一条 test/plugin-contract.test.mjs 用 Proxy 卡死了这个边界:插件只要访问 ctx.tool 或 ctx.systemPrompt,测试就直接失败。
通知长什么样
标题由规则决定,正文由模板渲染({session} 是会话标题,没有标题时用 cwd 最后一段):
┌─────────────────────────────────────────┐
│ DeepSeek Harness │
│ 会话请求授权 │
│ pwsh 需要授权:danger-full-access · 迁移脚本 │
└─────────────────────────────────────────┘
紧急程度映射到 Windows 的通知场景与提示音:授权/失败类用 urgent(Scenario.Urgent),完成类用 default。
安装
插件是一个标准的 DSH bundle 包:package.json 里的 dsh.bundle.patch 指向 cordis.patch.yml,patch 里一条 insert 就把宿主半边挂进 profile。
方式一 · 在 DSH 桌面端里装(推荐)
用桌面端的插件管理器安装本目录。它会把这个包写进当前 profile 的 dsh.profile.bundles,不需要命令行。
方式二 · 手工写进 profile
在 profile 的 package.json 里加一条依赖和一个 bundle 条目(路径换成你本机的插件目录):
{
"dependencies": {
"@local/dsh-system-notify": "link:<本插件目录的绝对路径>"
},
"dsh": {
"profile": {
"bundles": ["...", "@local/dsh-system-notify"]
}
}
}
然后在 profile 目录里装一次依赖并重启 DSH。
首次使用:编译原生助手
通知真正的发送端是 native/ 下的 Windows App SDK 小助手(Node 自己弹不出系统通知)。有两种情况:
自动:
sink.autoBuild默认开启。第一次要发通知时,插件会自己跑native/build.ps1(没有这个脚本就退到dotnet build native/*.csproj -c Release),构建期间的通知会排队等它;手动:自己先编一次,之后启动零延迟:
需要 .NET SDK 10(原生侧目标 net10.0-windows10.0.19041.0)
pwsh -NoProfile -File native/build.ps1
构建产物落在 native/bin/Release/(已被 .gitignore 排除)。想确认发送端是否通,用诊断接口的 /test(见下),或直接跑助手自检:
nativeinReleaseDshNotificationSink.exe --selftest
配置
写在 profile 的 cordis.patch.yml 里,按 id 覆盖:
- id: system-notify
config:
enabled: true
sink:
provider: windows # windows | log | none
helperPath: '' # 留空=自动在 native/bin 下找
appName: DeepSeek Harness
appIconPath: '' # 留空=用 native/assets 里的图标
timeoutMs: 5000
autoBuild: true
fallbackProvider: log # windows 发送失败后降级到哪
throttle:
dedupeWindowMs: 8000 # 同一件事的重复通知窗口
minIntervalMs: 1000 # 同一类事件的最小间隔
maxPerMinute: 15 # 全局每分钟上限
quietHours:
start: '' # 'HH:mm',留空=不启用
end: ''
allowHighSeverity: false # 静音时段内高优先级是否仍放行
scope:
includeChildSessions: false # 子 agent 会话要不要弹
approvalGraceMs: 1500 # 授权通知的等待时间,0 = 立刻弹
limits:
maxTitleChars: 72
maxBodyChars: 220
queueLimit: 32
所有字段都有默认值,写错的值会被夹回合法范围并在日志里留一条 系统弹窗配置:… 说明——配置写错不会让插件挂不上,但也不会悄悄生效。
规则与开关
规则是一张有序分派表,第一条 match 命中的规则决定这条通知的归属与文案:
- 命中但
enabled: false=吞掉这条通知,不再往下找更宽的规则。于是"关掉电脑操控弹窗"就真的不会再收到电脑操控弹窗,而不是悄悄降级成一条泛泛的"会话请求授权"; match里没写的约束等于通配;toolName是不区分大小写的正则。
内置七条:
| id | 检测点 | 默认 | 说明 |
|---|---|---|---|
computer-control |
approval | 开 | 走审批链路的操控类工具(工具名匹配 `computer |
approval |
approval | 开 | 兜住其余所有授权请求 |
computer-control-call |
tool-call | 开 | 操控类工具被调用就提醒,5 分钟冷却 |
turn-completed |
turn-end | 开 | reason.kind = completed |
turn-failed |
turn-end | 开 | error / blocked / max-tokens |
turn-aborted |
turn-end | 关 | 你亲手按的停止,通常不需要再弹一次 |
session-end |
session-end | 开 | 会话销毁时那一轮还在跑 |
session-end-idle |
session-end | 关 | 会话销毁时本来就空闲(几乎每次退出程序都会命中,默认不打扰) |
agent-error |
agent-error | 关 | 与 turn-failed 重复,按需打开 |
内置一共九条。写自定义 rules 时 signal 可取值:approval、tool-call、turn-end、session-end、agent-error;cooldownMs(0–3600000)是该规则的冷却时间,0 表示沿用全局 throttle.minIntervalMs。
自定义规则(rules 一旦写了就整体替换内置表,想保留内置的请一并抄过去):
- id: system-notify
config:
rules:
- id: approval
enabled: true
signal: approval # approval | tool-call | turn-end | session-end | agent-error
match:
toolName: 'bash|pwsh' # 可选,正则
title: '要你点头'
body: '{tool} 在等授权{reasonText} · {session}'
tag: approval # tag 最长 16 字符(Windows 限制)
urgency: high # low | normal | high
scenario: urgent # default | urgent | reminder | alarm
可用占位符:{session} {project} {sessionId} {cwd} {tool} {reason} {reasonText} {turn} {reasonKind} {outcomeText} {error} {time}。写错的占位符会原样留在通知里,方便你一眼看出配置错了。
节流
规则通过之后还有四道闸门,命中即拒绝,拒绝原因都进统计(能在诊断接口里看到):
| 闸门 | 作用 | 默认 |
|---|---|---|
duplicate |
同一个 dedupeKey(同一次审批、同一轮结束)在窗口内重复 |
8000 ms |
interval |
同一个 throttleKey 距上次发送太近 |
1000 ms |
rate |
全局每分钟上限(滑动窗口) | 15 条 |
quiet |
落在静音时段内 | 不启用 |
被拒绝的信号不占名额:只有真正发出去的那一次才记账。想彻底静音一段时间,比 maxPerMinute: 1 更直接的办法是 quietHours:
throttle:
quietHours:
start: '23:00'
end: '07:00'
allowHighSeverity: true # 半夜的授权请求还是弹,完成通知不弹
发送是串行的:通知按事件顺序一条条发,队列满了(默认 32 条)就丢最旧的并警告一次。
诊断接口
挂了 connection 服务时(正常桌面端都有),插件注册三个 POST 路由:
# 配置摘要、规则表、节流统计、发送端状态、最近一次发送结果
curl -X POST http://127.0.0.1:<端口>/api/system-notify/status
# 立刻发一条测试通知(绕过节流,用来验证 Windows App SDK 链路)
curl -X POST http://127.0.0.1:<端口>/api/system-notify/test -d "{}"
# 清空节流状态
curl -X POST http://127.0.0.1:<端口>/api/system-notify/throttle/reset
/status 里最该先看的两处:sink.detail.helper.located(有没有找到原生助手)与 counters.skipped(到底是被关掉了、被节流压掉了,还是没有规则命中)。
排障
一条通知都没有
- 看
/api/system-notify/status的sink.detail.helper:located: null说明原生助手没构建成功,lastError里会写原因; - 看
counters.skipped:child-session表示全是子会话(打开scope.includeChildSessions),rule-disabled表示规则被你关掉了; - 看 Windows 设置 → 通知:
DeepSeek Harness这一项是不是被关掉了。
弹是弹了,但显示的是 PowerShell 的图标/名字
助手在启动早期还没有设置 AppUserModelID。--selftest 里的 aumidRegistration 与 processAumid 能确认注册情况;注册在 HKCU 下,普通用户权限即可,不需要管理员。
构建失败
dotnet --list-sdks 要有 .NET SDK 10(原生侧目标是 net10.0-windows10.0.19041.0)或更新版本,nuget.org 要可达。失败信息会以一条 系统弹窗:构建失败(退出码 …) 出现在 DSH 日志里,修好之后重启 DSH,或先手动跑一次 native/build.ps1。
插件调用 native/build.ps1 时优先用 pwsh(PowerShell 7)。原因:Windows PowerShell 5.1 会按系统 ANSI 代码页解码无 BOM 的 UTF-8 脚本,脚本里的中文注释会让它误报"缺少结束符 }"——这条错看起来像语法错,其实是编码错。没有 pwsh 时插件会退回 5.1 并在日志里提示。
我不想要通知了
- 暂时:
enabled: false(检测照跑,一条都不发); - 只关某类:把对应规则的
enabled设为false; - 彻底:从 profile 的
bundles里删掉这个包。
隐私
通知内容只在本机生成、只交给本机的原生助手,任何字段都不出网。默认只带:会话标题(或 cwd 最后一段)、工具名、授权原因、轮次与结束原因。不读取对话正文、不读取工具输出、不写数据库。
已知限制
- 只有 Windows 有发送端。其他平台
sink.provider会退到log(只写日志),检测与节流照常工作; - 通知里的按钮点了不会有任何回调:原生助手是"一次调用一个进程",
Show()之后进程即退出,没人监听NotificationInvoked。插件因此不发按钮(发了会让人误以为点了有用); - 原生助手是 framework-dependent 的,运行需要 .NET 10 运行时(装了 SDK 10 的构建机自带);另一台只部署不构建的机器上,要么装运行时,要么改成 self-contained 发布;
- 同
tag的通知不会互相替换,每条都是通知中心里的独立条目(Windows App SDK 不自动替换); - 单次发送约 0.5 秒(每次都要重新
Register()),所以通知是串行队列而不是并发;队列上限 32 条,超了丢最旧的; - Windows App SDK 路径失败时,原生助手会按设计降级到旧版
ToastNotificationManager(按钮、urgency 等特性可能不完全生效),降级信息写在 stderr 与/status里; session/disposed的真实语义:关会话标签、归档会话、子会话结束都不触发它;它只在关主窗口(≈退出程序)与插件重载时成批到达。所以session-end的文案是"第 N 轮没有跑完(会话已销毁)",而不是"你关掉了会话";空闲销毁由默认关闭的session-end-idle负责,否则每次退出都会抖一串。- 硬杀/崩溃没有
turn/end:进程被 SIGKILL 或崩溃时,那一轮不会写turn/end,也就不会通知。要兜住只能在下次启动做补偿自检(遍历会话找末尾未闭合的turn/start),当前版本没做。 - 退出路径上的通知是尽力而为:宿主关停预算约 5 秒,桌面主进程最多再等 10 秒就 SIGTERM→SIGKILL,插件在插件卸载时会放弃还没发出的队列(并留一条警告)。
测试
node --test "test/*.test.mjs"
72 条用例,覆盖配置归一化、信号翻译、规则优先级、节流(含跨零点静音)、文案渲染、判定顺序、发送端降级、插件契约(含"不许碰 ctx.tool / ctx.systemPrompt"的 Proxy 断言),以及一组真 cordis 上下文的冒烟测试(装得上、收得到事件、卸载后不再发)。
那组真 cordis 测试在裸仓库里会跳过(解析不到 @deepseek-ai/cordis)。想强制运行,把 DSH_CORDIS_PATH 指到一个能解析依赖的 cordis 目录即可。
除了单测,这几条是真的跑过的:
- 真 cordis 上下文 + windows 发送端:触发一次授权事件,Windows 通知中心里出现
tag=approval、scenario=urgent、正文为渲染结果的条目(--history可查); - 冷构建:删掉
native/bin后由插件自己autoBuild(走native/build.ps1)→ 找到 exe → 真实发出通知,全程约 4 秒(NuGet 缓存已热); - 突发压力:一轮里 20 次工具调用(含 6 次
computer_use)+ 5 次并发授权请求,最终只弹 2 条(冷却与节流按预期生效)。
结构
index.js 插件入口:订阅事件、串行发送、诊断路由
lib/config.js 配置归一化与默认值(手写校验,零依赖)
lib/signals.js 运行时事件 → 信号(会话标题/轮次的记忆)
lib/rules.js 有序规则表与匹配
lib/throttle.js 去重 / 间隔 / 每分钟上限 / 静音时段
lib/engine.js 判定顺序:信号 + 规则 + 节流 → 通知或拒绝理由
lib/format.js 占位符渲染、单行化、截断
lib/sinks.js windows / log / none 发送端与降级
lib/native.js 原生助手定位、构建、调用
native/ Windows App SDK 通知助手(C#)
test/ 72 条用例
docs/native-sink.md 原生端协议:stdin/stdout JSON、错误码、AUMID、降级路径
docs/research/ 运行时检测点调研(每条结论带 文件:行号)
docs/log/project-log.md 项目日志(按轮次追加)
License
MIT
No comments yet. Be the first to write one.