# dsh-attention-beep

中文 · English

repo size last commit MIT license Windows only DSH version

> **命令卡住时替你脱困,任务跑完时叫得动你。** 一个 [DeepSeek Harness(DSH)](https://github.com/deepseek-ai/deepseek-harness) 插件,装完做三件事: - **提示音** —— 任务结束、命令卡住、整轮无进展、模型向你提问或申请权限时,由 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)防止「杀 → 续跑 → 又跑同一条 → 再杀」的死循环。**探针读不到数据时绝不打断。** ### 动作:两级 1. **一级(默认)** —— 只中止**这一次**工具调用(不是整轮、不是 `taskkill`):工具自己的取消路径生效,常驻 shell 自行 `reset`;同时把恢复说明**附加到那条工具结果**上,模型在同一轮里就知道:原命令、已运行多久、为什么被打断、部分输出拿不到、shell 是否被重置(`cd` / 变量丢失、工作目录回到 workspace)、下一步怎么写(非交互 / `run_in_background` / `Start-Job` 轮询)、不要原样重跑。 2. **二级** —— 若中止后工具仍不结束(超过 `escalateGraceMs`),说明框架层已经卡住:取消整轮,再 `followup()` 一条消息唤醒会话继续。 ### 人类等待:第三类,不在上面两级里 上面两级都假定「没有工具在跑 = 这一轮死了」。而 `ask_user_question` 是**按设计**停在那里等人——它没有工具在跑,于是**用户思考被当成了卡死**。 设计上分两层,互不依赖: 1. **代码层 `humanWaitTools`**(默认 `[ask_user_question]`):这类工具在飞期间,整轮判定**完全停摆**(连响铃都不响),并在答案到达时**重新起算**静默时间。它不能塞进 `watchTools`:那会被当成「该被监控的慢工具」,`killMs` 一到就把它杀掉,更糟。 2. **配置层 `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` 越加越长」。保护名单命中的命令不受硬上限约束。 ## 安装 ```powershell # 从 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//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`](./cordis.patch.yml)),用户层按 id 覆盖,或直接在「设置 → 提示音」里改(写入 `$DSH_HOME/settings.yaml`,改动实时生效): ```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: '' # 空 = 默认 /logs/attention-beep.log;"" 关闭 events: toolTimeout: { enabled: true, sound: voice:toolTimeout } ``` `sound` 支持三种写法:`voice:`(内置人声)、预设名(`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` 在那里是合法写法。 ## 测试 ```powershell 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](LICENSE)。