Skip to content
dsh.fish
Bundle

dsh-attention-beep

DSH 提示音 + 卡死看门狗 + 命令守卫:任务结束或命令卡死时用人声提醒,自动中断卡住的 pwsh 调用并把「怎么继续」写回工具结果,并在 spawn 之前拦掉已知的性能陷阱写法。仅 Windows。 / Beeps, a stuck-command watchdog and a command guard for DeepSeek Harness. Windows only.

Source
kiterunner1
License
MIT
Updated
Updated 4 days ago

Readme

# dsh-attention-beep

<p align="center">
  <samp>
    <strong>中文</strong> ·
    <a href="./README.en.md">English</a>
  </samp>
</p>

<p align="center">
  <img src="https://img.shields.io/github/repo-size/kiterunner1/dsh-attention-beep?style=flat-square" alt="repo size" />
  <img src="https://img.shields.io/github/last-commit/kiterunner1/dsh-attention-beep?style=flat-square" alt="last commit" />
  <img src="https://img.shields.io/github/license/kiterunner1/dsh-attention-beep?style=flat-square" alt="MIT license" />
  <img src="https://img.shields.io/badge/platform-Windows-4D6BFE?style=flat-square" alt="Windows only" />
  <img src="https://img.shields.io/badge/dsh-0.1.5--rc.1-blue?style=flat-square" alt="DSH version" />
</p>

> **命令卡住时替你脱困,任务跑完时叫得动你。**

一个 [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/<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`](./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: ''              # 空 = 默认 <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` 在那里是合法写法。

## 测试

```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)。

Install

dsh plugin --profile web add github:kiterunner1/dsh-attention-beep#f5e992f6c206085e72094377d92e1720585790a2

Profile: web

Source