dsh-approval-sound — 权限提示音
给 dsh Web GUI 的审批提示音插件:当工具请求越权执行、页面弹出「等待审批」面板时,立刻响一声,并把请求按工具类型配成不同的音色(命令行升级、文件写入、网络请求、子代理各不一样),这样你不用一直盯着屏幕也知道有人在等你点「允许一次」。
声音是浏览器端用 Web Audio 实时合成的:不打包任何音频文件、不走网络、不需要额外依赖。
为什么需要它
DSH 的审批面板出现在输入框上方,但默认没有任何声音。如果窗口在后台、或者你正好切到别的程序,一个需要批准的 bash 升权请求会一直静默地卡在那里,直到你回到窗口才看见。
这个插件补的就是这一段:审批被发起的那一刻就出声。
它是怎么知道"弹窗了"的
它是怎么知道"弹窗了"的
弹窗不是页面自己判断出来的,而是宿主侧的瀑布事件。DSH 里有两条"正在等人类决定"的链路,本插件两条都监听:
| 链路 | 什么时候触发 | 你看到的界面 |
|---|
approval/request | 某个工具请求越权执行(ctx.approval.request()) | 带「允许一次 / 拒绝」的审批面板 |
user-questions/request | 助手要问你问题(ask_user_question),计划审核也走这条 | 带选项按钮、跳过 / 提交 的提问面板 |
为什么要两条都听:对用户来说这两者没区别——都是"有个对话框在等我点"。如果只监听审批那条,而你的会话把审批策略设成了 never,那么审批服务会在派发瀑布之前就返回 'rejected'(只往会话日志里写一条 approval/asked),根本没有审批弹窗存在,于是插件永远不响——哪怕你屏幕上明明有一个提问面板在等答案。这正是本插件 0.2.0 的真实缺陷。
宿主半区为每条链路注册一个只观察、不干预的监听器:
- 它先记录这次请求(归属链路 + 一个短标签 + 一个依次递增的编号),然后原样调用
next(),把后续 waterfall 的结果一模一样地还给服务;
- 请求结束(发起方的
AbortSignal 触发,或应答者返回)时把它从待弹窗集合里移走;
- 页面每 700ms 拉一次
/api/dsh-approval-sound/pending,看到新的编号就响。
短标签从请求里就地取:审批用工具名(bash / write…),提问优先用问题的 header,计划审核用 plan-review。问题的正文不会送到浏览器——音色只需要一个短标签,而正文是你自己的提问内容。
这样做的结果是:弹窗的判定和行为完全没有被改动。审批策略是 ask 还是 never、失败是否 fail-closed,全和没装这个插件时一致(插件只是把 next() 的结果原样透传)。
为什么响得"刚刚好一次"
页面端用三层去重,专门对付现实里的各种抖动:
| 情况 | 行为 |
|---|
| 页面加载时已经有一个待弹窗 | 不响(第一轮成功轮询把已有的静默登记,你已经在看它了) |
| 页面加载时宿主不可达 | 不响,并且不认为已经"登记过";恢复后第一次拿到列表时静默登记 |
| 同一个弹窗,后续每次轮询都还在 | 只响一次 |
| 中间丢了 1~2 次轮询 | 不重复响(宿主编号是单调递增的,用高水位判断) |
| 短时间内连续弹好几个 | 由「最短间隔」压成一声,避免叠成噪音 |
| 标签页在后台 | 照常轮询(有意为之:后台才最需要提醒);只有「仅窗口不在前台时提醒」打开时才响 |
⚠️ 测不出声音时先看这一条:「仅窗口不在前台时提醒」如果开着,你盯着窗口等弹窗时是不会响的(因为你就在前台)。想边看边测,把它关掉。
音色
先按链路分family,再按标签细分——"助手在问我问题"和"某个工具想要越权"是两种不同性质的打断,听感不该一样:
| 音色 | 触发 | 音高 | 听感 |
|---|
| 提问面板 | user-questions/request 的普通提问 | 0.80× | 明显偏低,最"温和" |
| 计划审核 | 同上,且标签是 plan-review | 0.80× | 同上 |
| 命令行升级 | bash、pwsh、cmd、shell、terminal_*、run_code、job_* | 1.00× | 急促双响,最"扎耳" |
| 文件写入 | write、edit、read、glob、grep、present | 1.04× | 同样的双响,音高略高 |
| 网络请求 | web_search、web_fetch | 1.08× | 再高一点 |
| 子代理 / 团队 | subagent、subagent_fork、send_message、workflow | 1.12× | 再高一点 |
| 通用 | 其它一切 | 1.16× | 最高 |
提问一族刻意压在审批一族下面,所以哪怕你没看屏幕,也能从音高听出"这次是要我回答"还是"这次是要我授权"。
音色本身也可以在设置里整体换掉:急促双响 / 清脆铃声 / 钟声 / 水滴 / 自定义音频 / 静音。
自定义音频(上传自己的铃声)
设置页里有独立的 「自定义音频」 卡片:
- 上传音频文件——支持
wav / mp3 / ogg / m4a / aac / flac / webm,最大 8 MiB;
- 上传成功后音色自动切到「自定义音频」(你刚挑的文件,显然就是想听它);
- 可以随时更换或移除;
- 文件只保存在本机:浏览器读到字节后直接 POST 给插件自己的 loopback 路由,落在
$DSH_HOME/dsh-approval-sound/custom.<扩展名>,不会上传到任何地方。
两个刻意的处理,让"随便一个音频文件"真的能当警报用:
- 按自身峰值归一化:导出音量很小的音频会被拉到接近满幅,不会变成"自定义铃声听起来比原来还轻";
- 上传时校验扩展名与文件头:两者必须一致(并单独校验 MP4 的
ftyp 品牌),所以改名骗过的文件在保存前就被拒绝,而不是变成以后一句"怎么没声音"。
只换扩展名(比如把 .wav 改名成 .mp3)会被拒绝,并给出明确原因。这是有意的——否则得到的是一段解不出来的字节。
宿主重启时会以磁盘上的文件为准重建元数据,所以手改 config.json 无法凭空"声明"一个不存在的铃声;真的丢了文件就会回落到内置音色。
音量:可以开过 100%
原来的上限是 100%,实测在"机器正在放别的声音"时不够醒目。现在:
- 音量范围 0 – 150%,默认 100%;
- 输出链上串了一个限幅器(
DynamicsCompressor,阈值 −6 dB、比率 20:1),所以超过 100% 时是把响度推上去、而不是把波形削平——尖峰被压住,听起来是"更响"而不是"更破";
- 合成的音色本身也抬了基频增益,配合限幅器大约比原来高 6–8 dB。
想要更响,除了拉满 150%,还可以把「重复次数」设成 2、间隔设短一点,让双响变成连续催。
前置条件
- DSH 0.1.5 或更高(需要
webServer 服务,也就是 Web 端 / 桌面端)。无头(headless)、ACP、SDK profile 没有 Web 层,本插件无事可做。
- 浏览器支持 Web Audio(所有现代浏览器都支持;不支持时设置页会明确说明,而不是静默失败)。
安装
# 从本地目录安装(开发/本地)
dsh plugin --profile desktop add link:E:/AI/DSH/插件/dsh-approval-sound
# CLI 的 web profile 用 web
dsh plugin --profile web add link:E:/AI/DSH/插件/dsh-approval-sound
也可以直接在桌面端的「插件」管理界面里装本目录。
装完必须重启 dsh web / 桌面端。
宿主半区(host.js)只在启动时加载一次;改完插件、或者刚 add 进来之后,运行中的进程里跑的还是旧代码,只能靠重启换掉。客户端半区(client.js)在刷新页面时重新拉取,但宿主半区的路由没换,页面也拿不到数据。
不想用 CLI 的话,手工安装等价于:
- 把本目录整体拷进 profile 的
node_modules/dsh-approval-sound/;
- 在
~/.dsh/profiles/<profile>/package.json 的 dsh.profile.bundles 里加上 dsh-approval-sound;
- 重启
dsh web / 桌面端。
本包零构建、零运行时依赖:host.js 与 client.js 就是可直接加载的产物,不需要 pnpm build,也不 import 任何第三方包(宿主只用 Node 内置模块;浏览器的 React 由页面的模块表提供)。
这一点是刻意的:插件以 link: 安装时 pnpm 不会安装它自己的依赖,所以任何 import 'some-package' 都可能在某些 profile 里解析失败——而宿主半区 import 失败不会报错给你看,loader 只会把条目停在 inactive,表现为"插件装了但什么都没发生"。
配置
设置页 权限提示音 里可直接改,改动即时生效并持久化:
| 键 | 默认 | 含义 |
|---|
enabled | true | 总开关;关掉后不再出声(仍继续登记,方便排错) |
sound | alert | 音色:alert 急促双响 / chime 清脆铃声 / bell 钟声 / drop 水滴 / custom 自定义音频 / off 静音 |
volume | 1 | 音量 0–1.5(150%)。0 是合法的"先接好线、之后再开声" |
repeat | 1 | 重复次数:1 只响一次,2 连响两次……最多 6 |
repeatIntervalMs | 900 | 重复之间的间隔(120–5000ms) |
cooldownMs | 600 | 两次提醒之间的强制静默(0–10000ms),防止连续弹窗叠成噪音 |
onlyWhenUnfocused | false | 只在窗口不在前台时才提醒 |
customName | '' | 只读。已上传的文件名,由宿主按磁盘现状回填 |
customBytes | 0 | 只读。已上传文件的字节数 |
customAt | 0 | 只读。上传时间戳 |
三个 custom* 字段不接受客户端写入:磁盘上的文件是它们唯一的真源,否则一个页面就能"声明"一个并不存在的铃声,而表现只是静默无声。
设置页里还有:
- 试听 按钮——用它确认声音、音量、音色,同时解除浏览器的自动播放限制;
- 音频状态提示——只在需要时出现:不支持 Web Audio、音频被浏览器拦截、或自定义音频正在加载;
- 上传/解码失败提示——点了上传却没成功时给出一行原因,不会静默。
设置页只有设置:早期版本的「监听状态」和「诊断」两张卡片已按要求移除。宿主侧的诊断数据仍然保留在下面的 /diagnostics 接口里(供排错时用命令行查看),只是不再占用面板。
关于"音频被浏览器拦截"
浏览器不允许页面在用户没有任何交互前出声。所以:
- 插件在首次点击 / 按键时会自动解锁音频(监听器一直挂着,直到真正变成 running);
- 如果设置页显示「音频被浏览器拦截」,**点一下「试听」**即可,之后就一直正常;
- 正常使用中你总会点过页面(比如把审批面板点出来),所以这条几乎不会成为问题。
接口
全部为同源 JSON,且都带 loopback 围栏(非本机请求一律 403)。写入使用 POST,避免误触浏览导致改配置。
| 方法 | 路径 | 用途 |
|---|
| GET | /api/dsh-approval-sound/pending | 当前待审批集合、生效配置、编号水位(页面每 700ms 轮询) |
| GET | /api/dsh-approval-sound/diagnostics | 上面全部 + 监听器错误 + 最近 40 条事件(设置页按需拉取) |
| POST | /api/dsh-approval-sound/config | 局部更新配置并持久化 |
| POST | /api/dsh-approval-sound/sound | 上传自定义音频(原始 application/octet-stream 正文,文件名放在 x-dsh-sound-name 头里,encodeURIComponent 编码) |
| GET | /api/dsh-approval-sound/sound | 取回已安装的音频(无则 404),带正确的 content-type 与 nosniff |
| DELETE | /api/dsh-approval-sound/sound | 移除音频,并把音色回落到内置音色 |
实现备注(给改这个插件的人):这些路由必须注册成 kind: 'exact',不能用 prefix。组合出来的 DSH Web 应用自己也挂了一个 /api 前缀路由(浏览器会话鉴权 + RPC),它会用 401 抢先应答任何没被精确匹配的 /api/... 路径——插件的 /api/<名字>/ 前缀路由根本轮不到。精确路由在所有前缀之前匹配,所以这条路是稳的。
上传用 .tgz 之外的单文件报文而不是 multipart:一个文件就是全部负载,multipart 解析器只会带来额外攻击面。
实现备注 2(这条最坑,别改回去):监听这两条 waterfall 必须用 { prepend: true, global: true }。
prepend:waterfall 是一条链,监听器返回结果就等于"认领"这次请求并终止整条链(不再调用 next())。官方应答者(把请求转发到浏览器面板的那个桥)在启动时就注册了,位置在链首;它一认领,链就断在那里,排在它后面的监听器永远不会被调用。所以"只观察、不干预"的监听器必须排在认领者前面——否则它注册得好好的、ctx.on 不报错、事件总线也正常,但就是一次都不触发(observed 永远 0)。这正是本插件 0.1.0–0.3.0 始终不出声的真正原因,而且两条链路都中招(所以当初即使审批策略是 ask,审批面板也一样不会响)。
global:跳过总线按监听器上下文做的作用域过滤。本插件要观察进程里所有会话/agent 的弹窗,没有任何理由被过滤;被过滤掉的监听器同样是静默失效。
这两种失效方式都不报错,所以宿主侧内置了自检:/diagnostics 的 eventBus.attached 会直接从事件总线的监听器表里读回 {count, position}——position 必须是 0,不是 0 就说明又被排到认领者后面了。这条排错时用命令行看一眼即可(设置页不再显示它):
curl.exe -sS http://127.0.0.1:19387/api/dsh-approval-sound/diagnostics
数据
配置持久化在 $DSH_HOME/dsh-approval-sound/config.json(原子写入:临时文件 + fsync + rename);上传的音频是同目录下的 custom.<扩展名>(同样是临时文件 + fsync + rename,所以中途崩溃不会留下半截文件被当成铃声播出去)。
插件只读审批元数据:工具名和一个自增编号。审批的 reason 文本不会进浏览器;它也不写任何会话数据、不改审批策略、不影响任何决定。
已知限制
- 只在页面打开时出声。页面关掉就没人听了——这是所有浏览器端提醒的固有限制。想让"关掉页面也能提醒"变成系统级通知,需要走桌面端原生通知通道,不在本插件范围内。
- 只在
ask 策略下有意义。策略是 never 时审批根本不弹窗,插件自然也不会响(这正是 ask/never 该有的区别)。
- 轮询而不是推送。最坏情况有约 700ms 延迟(实际体验是"几乎同时")。宿主侧没有向页面推送事件的公开通道,轮询是唯一不依赖私有 API 的做法。
- 编号水位是进程级的。宿主重启后编号从 1 重新开始,页面此时也一起刷新了,所以不会互相干扰;但如果你用同一页面跨宿主重启(页面不刷新而宿主重启),水位会挡住重启后的头几个提醒,直到编号重新超过旧水位——刷新页面即可。
- 自定义音频只在页面能解码它时有效。浏览器不支持该编码时,设置页会给出错误、并且响铃会退回到内置音色(而不是静默);如果关掉窗口前刚上传、解码还没完成,那一次提醒会等解码完成后补响。
开发自测
两个 harness 覆盖了宿主和浏览器两半,都不需要启动 DSH:
node scripts/dev-harness.mjs # 宿主:94 项断言(两条链路、监听器位置、关闭边界、编号、围栏、配置、上传/取回/删除/重启对账、释放)
node scripts/dev-client-harness.mjs # 浏览器:74 项断言(静音登记、去重、音色、响度/限幅、自定义音频、提问面板、上传、焦点、降级)
想看到页面内部的决策过程,加上环境变量即可(输出 absorb / alert 之类的行):
DSH_HARNESS_TRACE=1 node scripts/dev-client-harness.mjs
scripts/ 不在 package.json 的 files 里,不会被发布。
宿主 harness 会给自己一个临时 $DSH_HOME,所以跑测试不会读到你真实配置(它有一条断言专门验证"手改 config.json 不能凭空声明铃声");浏览器 harness 用 node:vm 加一层最小页面桩(__ModuleLoader__、document、fetch、定时器、可录音的 AudioContext、可驱动的 input[type=file]),直接驱动真正的 client.js。
许可证
MIT