dsh-attention-beep
中文 · English
命令卡住时替你脱困,任务跑完时叫得动你。
一个 DeepSeek Harness(DSH) 插件,装完做三件事:
- 提示音 —— 任务结束、命令卡住、整轮无进展、模型向你提问或申请权限时,由 DSH 宿主进程直接在本机发声;即使浏览器标签在后台、被静音、最小化也能听到。
- 自动脱困 ——
pwsh命令确认卡住时,自动中断这一次工具调用,并把「发生了什么 + shell 现在什么状态 + 怎么继续」写回工具结果,让模型在同一轮里接着把任务做完。 - 命令守卫 —— 已知的性能陷阱写法在
spawn之前就被拒绝(约 0 ms 返回并附上改法),而不是白等几十秒到几分钟。
声音由宿主进程播放(System.Media.SoundPlayer / WPF MediaPlayer),不经过浏览器。
⚠️ 仅支持 Windows。 三块能力都依赖 Windows:进程探针走
Get-CimInstance Win32_Process,音频走 .NET / WPF 播放器,命令守卫针对 PowerShell 方言。package.json声明了os: ["win32"],在 macOS / Linux 上装不上——这是刻意的,比装完什么都不响要好。详见平台支持。
为什么需要它
长任务跑起来之后,有三件事会反复咬你:
| 你会遇到的 | 它怎么处理 |
|---|---|
| 长任务跑完了,你在别的标签页里,不知道 | 任务结束时本机发声,不依赖页面是否在前台 |
| 命令卡住了(等输入、死锁、网络挂死),整轮就那么干等着 | 确认「没进展」后自动中断这一次调用,并把恢复指引写回给模型 |
| 模型反复写出最贵的写法(递归枚举那类),一次赔上几分钟 | 在 spawn 之前直接拒绝,0 秒返回并给出改法 |
一、提示音
| 事件 | 什么时候响 | 默认声音 |
|---|---|---|
taskEnd |
一个任务(回合)跑完 | voice:taskEnd |
toolWarn |
命令跑太久,进入预警窗口 | voice:toolWarn |
toolTimeout |
确认卡死、自动中断时 | voice:toolTimeout |
recovered |
恢复说明已写回工具结果、任务继续 | voice:recovered |
stall |
整轮无进展 | voice:stall |
question |
模型用 ask_user_question 向你提问(客户端半边监听 user-questions/request) |
voice:question |
approval |
需要你授权一次工具调用(监听 approval/request) |
voice:approval |
人声文件是随包发布的静态资源(assets/voice/*.mp3,中文女声,约 180 KB),运行时不做任何合成:想换音色就替换同名文件,或在设置页每行「自定义文件…」填自己的 .wav / .mp3 绝对路径。每一行都有 ▶ 试听,听到的就是真实提醒音。
二、卡死看门狗
判据:不是「跑了多久」,而是「还有没有进展」
pwsh 的两种形态在运行中都不产生会话事件(一次性工具只有 tool/start → tool/result 两个点;常驻 shell 的 PTY 在宿主平面读不到 scrollback)。所以判据取自操作系统:枚举 DSH 进程的子孙进程,累加它们的 CPU 时间与 IO 字节,看还在不在涨。
两个限定条件缺一不可:
- 只算被监视调用自己的子树 ——「调用开始之后才创建」的那些进程及其子孙。整棵树里长期存活的无关进程(web server、MCP server、浏览器)一直在涨,若一起累加,每次采样都会判「有进展」,自动中断就形同不存在。
- 要超过空闲噪声底 —— 一条只是「还活着」的
pwsh自己每秒就烧约 23 ms CPU、动约 6 KB IO(连它的 conhost);真干活的命令高出一到两个数量级。所以「进展」的门槛是 >10% 单核 或 >64 B/ms。
于是:编译、下载、写文件 → 超过噪声底 → 不动手;等输入(git commit 没带 -m、Read-Host、pause)、死锁、网络挂死 → 只剩噪声 → 才认为是卡死。
闸门(默认值)
注意最早可动手时刻 = max(killMs, warnMs + preWarnMs) —— 只把 killMs 调小是无效的:
| 闸门 | 默认 | 含义 |
|---|---|---|
warnMs |
120s | 跑这么久还没返回 → 响铃 + 页面横幅(预警窗口开始) |
preWarnMs |
60s | 响铃后再给你这么久手动叫停 |
killMs |
180s | 从调用开始算,最早可动手的时刻 |
silenceMs |
60s | 被监视调用自己的子树的 CPU 与 IO 连续这么久都在噪声底之下,才算「确认无进展」 |
再加三层保护:protectList 命中的命令(npm install / pnpm build / git clone 这类「正常就慢且安静」的)只响铃、绝不自动打断;observeOnly 演练模式只响铃写日志;maxAutoActionsPerSession(默认 3)防止「杀 → 续跑 → 又跑同一条 → 再杀」的死循环。探针读不到数据时绝不打断。
动作:两级
- 一级(默认) —— 只中止这一次工具调用(不是整轮、不是
taskkill):工具自己的取消路径生效,常驻 shell 自行reset;同时把恢复说明附加到那条工具结果上,模型在同一轮里就知道:原命令、已运行多久、为什么被打断、部分输出拿不到、shell 是否被重置(cd/ 变量丢失、工作目录回到 workspace)、下一步怎么写(非交互 /run_in_background/Start-Job轮询)、不要原样重跑。 - 二级 —— 若中止后工具仍不结束(超过
escalateGraceMs),说明框架层已经卡住:取消整轮,再followup()一条消息唤醒会话继续。
人类等待:第三类,不在上面两级里
上面两级都假定「没有工具在跑 = 这一轮死了」。而 ask_user_question 是按设计停在那里等人——它没有工具在跑,于是用户思考被当成了卡死。
设计上分两层,互不依赖:
- 代码层
humanWaitTools(默认[ask_user_question]):这类工具在飞期间,整轮判定完全停摆(连响铃都不响),并在答案到达时重新起算静默时间。它不能塞进watchTools:那会被当成「该被监控的慢工具」,killMs一到就把它杀掉,更糟。 - 配置层
stallAutoResume: false(默认):整轮层任何情况下都只响铃、绝不取消回合,兜住「提问窗口之外、没想到的等待形态」。
⚠ 已知限制(第 1 层):在真实运行中观察到提问期间
watchdog.humanWaits始终为空——也就是第 1 层没有拿到ask_user_question的调用,它是否生效未经证实。单测覆盖的是直接调用watch()的路径,所以全绿也测不出这一点。追查止于宿主的工具分派层,根因尚未定位。因此实际兜底是第 2 层:
stallAutoResume: false保证不会再有「思考时整轮被取消」。残留症状只是偶发错误响铃(stall提示音);嫌吵就把stallTimeoutMs调大(例如 300000)。
想恢复整轮的自动取消,把 stallAutoResume 改回 true(第 1 层若确实生效,提问窗口仍会被豁免)。注意这只管整轮层——工具层(warnMs / killMs 掐掉卡住的 pwsh)完全不受影响,那才是本插件的主要价值,始终是全自动的。
整轮无进展(stall)
没有任何事件、没有工具在跑、进程也不动(stallTimeoutMs,默认 180s)时,只响铃提示。默认不取消回合,理由见上一节。
三、命令守卫
看门狗治「卡住」,守卫治「写法」。前台命令命中规则时根本不会 spawn,直接以工具错误返回并附上改法(约 0 ms)。run_in_background: true 是「确实要用原写法」的出口(后台不阻塞回合,守卫放行)。
同一个目录、同一批文件,不同写法的实测:
| 写法 | 耗时 |
|---|---|
Get-ChildItem -Recurse -Include *.js |
>90s |
Get-ChildItem -Recurse -Filter *.js |
6.7s |
原生 grep 工具 |
0.23s |
规则表(每条都说明「它防的是什么真实风险」):
| 规则 | 拒绝的写法 | 改法 |
|---|---|---|
tilde-native-path |
node ~/x、git -C ~/x、pwsh -File ~/x —— ~ 只有 cmdlet 的 Path 参数才展开,传给原生命令是字面量,必然失败 |
换成绝对路径(这条不提供「后台」出口:后台照样失败) |
recurse-include |
-Recurse + -Include |
-Filter,或原生 grep / glob 工具 |
select-last-unbounded-process |
Select-Object -Last + 测试运行器 |
重定向到文件读尾部,或后台 |
foreground-long-sleep |
前台 Start-Sleep -Seconds >= 60 |
改成带超时的轮询脚本,或后台 |
explicit-long-timeout |
非保护名单的命令显式设 timeoutMs >= 180000 |
用 run_in_background 启动 |
规则是量出来的,不是拍出来的。 第一版还拦了 -Recurse + Format-Table / + Select-String / + node_modules 三种,回放全部前台调用后删掉了:它们平均只跑 4.3–11.7 秒,而拒绝一次要模型重写一轮,改写成本 > 命令本身耗时——这三条占了 87% 的拒绝量却只带来 12% 的收益。它们罕见的长尾交给 hardCeilingMs 兜底。任何新规则都要重新量一遍才能加。
配置:guardEnabled(默认 true)、guardAllow(放行子串名单,默认空)。
配套建议:hardCeilingMs: 120000 —— 非保护命令硬上限 2 分钟。这一条专门治「模型自己把 timeoutMs 越加越长」。保护名单命中的命令不受硬上限约束。
安装
# 从 GitHub 装(推荐 pin 到 commit)
dsh plugin --profile web add github:kiterunner1/dsh-attention-beep
# 或先克隆再本地安装(改源码即生效)
git clone https://github.com/kiterunner1/dsh-attention-beep.git
cd dsh-attention-beep
dsh plugin --profile web add .
装插件时必须停掉正在运行的 DSH,否则 profiles/<name>/node_modules 会被删到一半失败,留下「当前能跑、重启必死」的 profile。
改宿主代码(lib/*.js)后必须重启 DSH 才生效(ESM 模块缓存);客户端(lib/client.js)刷新页面即可。
卸载:dsh plugin --profile web remove dsh-attention-beep,并把 dsh.profile.bundles 里的对应项移除。
配置
全部配置都在 loader 行的 config 里(默认值见 cordis.patch.yml),用户层按 id 覆盖,或直接在「设置 → 提示音」里改(写入 $DSH_HOME/settings.yaml,改动实时生效):
- id: attention-beep
config:
enabled: true
scope: root # 提示音:root | all(子代理是否也响)
watchScope: all # 看门狗:root | all(子代理卡死也管)
watchTools: [pwsh]
humanWaitTools: [ask_user_question] # 在飞期间整轮判定完全停摆(别塞进 watchTools)
warnMs: 120000
killMs: 180000
preWarnMs: 60000
silenceMs: 60000
sampleIntervalMs: 10000
hardCeilingMs: 120000 # 0 = 关闭;>0 时即使还在烧 CPU 也打断(防死循环)
autoKill: true
observeOnly: false # 演练模式:只响铃 / 写日志 / 发通知
escalateToTurnCancel: true
escalateGraceMs: 45000
maxAutoActionsPerSession: 3
stallTimeoutMs: 180000
stallAutoResume: false # 整轮层只响铃、绝不取消回合(防「你思考时被打断」)
guardEnabled: true
guardAllow: [] # 命令里含这些子串就跳过守卫
protectList: [npm install, pnpm build, git clone, ...] # 省略 = 用内置名单
logPath: '' # 空 = 默认 <DSH_HOME>/logs/attention-beep.log;"" 关闭
events:
toolTimeout: { enabled: true, sound: voice:toolTimeout }
sound 支持三种写法:voice:<key>(内置人声)、预设名(ding / chime / notify / tada / alarm / error / default / recycle / ring / beep,均为 %WINDIR%\Media\*.wav)、任意 .wav / .mp3 绝对路径。文件不存在时回退 beep。
事件日志:$DSH_HOME/logs/attention-beep.log,完整 JSONL 事件流(warn / action / settled-aborted / resume / sound / breaker / stall / guard),超过 2 MB 轮转一次。
平台支持
仅 Windows。 具体到三块能力:
| 能力 | 在非 Windows 上 |
|---|---|
| 提示音 | 依赖 System.Media.SoundPlayer / WPF MediaPlayer,无对应实现 |
| 卡死看门狗 | 探针用 Get-CimInstance Win32_Process,非 Windows 下 probe.supported = false,自动中断失效(不会崩,只是什么都不做) |
| 命令守卫 | 规则针对 PowerShell 语法;DSH 在其它平台提供的是 bash 工具,默认 watchTools: [pwsh] 根本不匹配 |
所以 package.json 里声明了 os: ["win32"]:非 Windows 上直接装不上。这是刻意的——比装完发现「什么都不响」要好。
另外,守卫里唯一可能误伤非 Windows 的规则(tilde-native-path)已经加了平台门槛:POSIX shell 会自己展开 ~,node ~/x 在那里是合法写法。
测试
node --test test/unit/*.test.mjs # 82 项:判据状态机、探针、音频解析、通知路由、守卫规则、报告文案
node test/e2e/run.mjs all # 真实 DSH:一次性 pwsh / 常驻 pwsh / 回归 三个场景
E2E 会启动独立 headless 配置,跑一条 600 秒的静默命令,然后核对:响铃记录、自动中断、模型在工具结果里收到恢复说明、调用耗时远小于 600s、没有残留进程。
说明与边界
- 插件只调用本机系统音与进程枚举,不发送任何网络请求(人声资源是随包发布的静态文件)。
- 探针每
sampleIntervalMs采样一次,且只在「有被监控调用在跑」时才采样;连续有进展时会自动退避到 3 倍间隔,停下立刻恢复。 - 探针读不到数据时绝不打断——「无法验证」不等于「没有进展」。
- 这个插件的判据全部基于操作系统事实(进程 CPU/IO),不看工具输出。所以对任何不产生输出的长命令都适用,不需要为每个工具单独适配。
FAQ
Q:会不会误杀正常的长任务?
判据是「自己的子树 CPU 与 IO 是否停止增长」,且要超过空闲噪声底,且要连续沉默 silenceMs。编译、下载、写文件都会持续产生 CPU/IO,不会被判卡死。再加上 protectList 会对已知「正常就慢且安静」的命令(npm install 等)完全禁用自动打断。
Q:为什么 stallAutoResume 默认是 false?
整轮的「没有事件 = 死了」这个假设不成立:模型在慢慢想、用户在打字回答,都表现为「没有事件」。整轮层默认只响铃,不替你做决定;真正有价值、也始终全自动的是工具层(掐掉卡住的那一条命令)。
Q:能只响铃、不自动中断吗?
能。observeOnly: true 进演练模式(只响铃 / 写日志 / 发通知),或 autoKill: false。
Q:换音色 / 关掉某类提示音?
设置页把对应事件的声音改成自己的 .wav / .mp3 路径,或把该事件的 enabled 关掉。每一行都有 ▶ 试听。
Q:它会不会拖慢 DSH? 探针是一个短命的 PowerShell 子进程,每 10 秒一次,且只在有被监控调用在跑时才采样;连续有进展时自动退避到 3 倍间隔。空闲时完全不采样。
License
MIT——见 LICENSE。
No comments yet. Be the first to write one.