# dsh-attention-beep
中文 ·
English
> **Gets you out when a command hangs; gets your attention when a task finishes.**
A [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness) plugin that does three things:
- **Beeps** — when a turn finishes, a command hangs, a whole turn goes quiet, or the model asks you a question or requests permission, the **host process** plays a voice prompt on this machine; it works even when the browser tab is in the background, muted or minimised.
- **Self-rescue** — when a `pwsh` call is confirmed stuck, the plugin aborts **that one tool call** and writes "what happened + what state the shell is in + how to continue" back into the tool result, so the model can finish the job in the same turn.
- **Command guard** — known performance traps are refused **before** `spawn` (returning in ~0 ms with the fix), instead of costing minutes.
Sound is played by the harness process itself (`System.Media.SoundPlayer` / WPF `MediaPlayer`), not through the browser.
> ⚠️ **Windows only.** All three capabilities depend on Windows: the process probe uses `Get-CimInstance Win32_Process`, audio goes through .NET / WPF players, and the guard targets PowerShell idioms. `package.json` declares `os: ["win32"]`, so it will not install on macOS / Linux — deliberately, which beats installing and hearing nothing. See [Platform support](#platform-support).
## Why you need it
Once long tasks are running, three things keep biting:
| What happens to you | What it does |
| --- | --- |
| A long task finishes while you are in another tab and you never notice | Plays a sound **on this machine**; does not depend on the page being focused |
| A command hangs (waiting for input, deadlock, wedged network) and the turn just sits there | Confirms "no progress", **aborts that one call**, and writes recovery guidance back to the model |
| The model keeps writing the most expensive idioms (recursive enumeration and friends), costing minutes each time | **Refuses it before `spawn`**, returning in 0 s with the replacement |
## 1. Beeps
| Event | When it rings | Default sound |
| --- | --- | --- |
| `taskEnd` | A turn finished | `voice:taskEnd` |
| `toolWarn` | A command has run long enough to enter the warning window | `voice:toolWarn` |
| `toolTimeout` | A stuck call was confirmed and aborted | `voice:toolTimeout` |
| `recovered` | The resume note was written into the tool result and the task continues | `voice:recovered` |
| `stall` | A whole turn produced no progress | `voice:stall` |
| `question` | The model asks you something via `ask_user_question` (client half listens on `user-questions/request`) | `voice:question` |
| `approval` | A tool call needs your approval (listens on `approval/request`) | `voice:approval` |
The voice prompts are **static assets shipped with the package** (`assets/voice/*.mp3`, ~180 KB), synthesised at build time and never at runtime: replace a file with your own to change the voice, or point a settings row at any `.wav` / `.mp3` absolute path. Every row has a **▶ preview** button that plays exactly what you will hear.
## 2. Stuck-command watchdog
### The signal: progress, not elapsed time
Neither `pwsh` form produces session events while it runs (a one-shot call only has `tool/start` → `tool/result`; a persistent shell's PTY lives in a realm the host plane cannot read). So the signal comes from the OS instead: **enumerate the harness's descendant processes and sum their CPU time and I/O bytes**, then watch whether those totals still grow.
Two conditions are both required:
- **Only the watched call's own subtree** — processes created at or after the call started, plus their descendants. Long-lived unrelated processes in the same tree (web server, MCP servers, a browser) keep accruing CPU and I/O; summing them makes every single sample look like "progress", and the auto-abort can never fire.
- **It must clear the idle noise floor** — a `pwsh` that is merely alive burns roughly 23 ms of CPU and moves ~6 KB of I/O per second (including its conhost); a process actually working is one to two orders of magnitude above that. So "progress" means **>10% of one core** or **>64 B/ms**.
Hence: compiling, downloading or writing files → above the floor → **hands off**; waiting for input (`git commit` without `-m`, `Read-Host`, `pause`), deadlock or a wedged network wait → only noise → confirmed stuck.
### Gates (defaults)
Note that **the earliest moment it may act is `max(killMs, warnMs + preWarnMs)`** — lowering `killMs` alone does nothing:
| Gate | Default | Meaning |
| --- | --- | --- |
| `warnMs` | 120s | Still not returned after this long → ring + page banner (warning window opens) |
| `preWarnMs` | 60s | Time to stop it manually after the ring |
| `killMs` | 180s | Earliest moment it may act, counted from the call's start |
| `silenceMs` | 60s | The watched call's **own subtree** must stay below the noise floor this long to count as "confirmed no progress" |
Plus three layers of protection: commands matching `protectList` (`npm install` / `pnpm build` / `git clone` — slow but healthy and quiet) are **always allowed to ring but never auto-interrupted**; `observeOnly` mode rings and logs without acting; `maxAutoActionsPerSession` (default 3) prevents a "kill → resume → run the same thing → kill again" loop. And **if the probe cannot read data, nothing is ever interrupted.**
### Two levels of action
1. **Level one (default)** — abort **this one tool call** (not the turn, not `taskkill`): the tool's own cancellation path runs and a persistent shell resets itself. At the same time a resume note is **attached to that tool result**, so the model knows within the same turn: the original command, how long it ran, why it was interrupted, that partial output is unavailable, whether the shell was reset (`cd` / variables lost, working directory back to the workspace), how to proceed (non-interactive / `run_in_background` / polling a job), and not to re-run it verbatim.
2. **Level two** — if the call still does not settle after the abort (beyond `escalateGraceMs`), the framework layer is wedged: the turn is cancelled and a `followup()` message wakes the session to continue.
### Waiting on a human: the third class
Both levels above assume "no tool running = the turn is dead". But `ask_user_question` is **designed** to park there and wait for a person — no tool is running, so **the user's thinking gets mistaken for a hang**.
There are two layers by design, independent of each other:
1. **Code layer, `humanWaitTools`** (default `[ask_user_question]`): while such a tool is in flight, the whole-turn judgement is **fully suspended** (not even a ring), and the silence clock **restarts** when the answer arrives. It must not go into `watchTools`: there it would be treated as "a slow tool to watch", and `killMs` would kill it, which is worse.
2. **Config layer, `stallAutoResume: false`** (**the default**): the whole-turn layer **only rings, and never cancels the turn in any situation**, covering waiting shapes outside the question window that nobody anticipated.
> **⚠ Known limitation (layer 1)**: in a real run, `watchdog.humanWaits` was observed to be **empty for the whole duration of a question** — layer 1 never saw the `ask_user_question` call, so whether it works is **unconfirmed**. The unit tests exercise a direct call into `watch()`, which is why a fully green suite does not catch this. The trail ends at the host's tool-dispatch layer; the root cause is not located.
>
> So **the layer that actually protects you is layer 2**: `stallAutoResume: false` guarantees a turn is never cancelled while you are thinking. The residual symptom is an **occasional spurious ring** (the `stall` sound); if it bothers you, raise `stallTimeoutMs` (e.g. 300000).
To restore whole-turn auto-cancel, set `stallAutoResume` back to `true` (if layer 1 does work, the question window stays exempt). Note this governs only the **whole-turn** layer — the tool layer (`warnMs` / `killMs` cutting off a stuck `pwsh`) is unaffected and remains fully automatic, which is where the plugin's main value lives.
### Whole-turn stall
When there are no events, no tools running and no process movement (`stallTimeoutMs`, default 180s), it **only rings**. It does not cancel the turn by default; see the previous section for why.
## 3. Command guard
The watchdog treats "hangs"; the guard treats "idioms". A **foreground** command that hits a rule is **never spawned**: the guard answers with a tool error carrying the fix in ~0 ms. `run_in_background: true` is the escape hatch for when the slow form is genuinely wanted (background work cannot stall a turn, so the guard lets it through).
Same directory, same files, different idioms, measured:
| Idiom | Time |
| --- | --- |
| `Get-ChildItem -Recurse -Include *.js` | **>90s** |
| `Get-ChildItem -Recurse -Filter *.js` | 6.7s |
| the native `grep` tool | **0.23s** |
The rules, each naming the real risk it prevents:
| Rule | What it refuses | The fix |
| --- | --- | --- |
| `tilde-native-path` | `node ~/x`, `git -C ~/x`, `pwsh -File ~/x` — `~` is only expanded for a cmdlet's Path parameter, so handing it to a native command passes a literal and **must fail** | Use an absolute path (no "run it in the background" escape: backgrounding a broken path still fails) |
| `recurse-include` | `-Recurse` + `-Include` | `-Filter`, or the native `grep` / `glob` tools |
| `select-last-unbounded-process` | `Select-Object -Last` piped from a test runner | Redirect to a file and read its tail, or go background |
| `foreground-long-sleep` | foreground `Start-Sleep -Seconds >= 60` | A polling script with a timeout, or go background |
| `explicit-long-timeout` | an explicit `timeoutMs >= 180000` on a command outside the protect list | Start it with `run_in_background` |
**The rules were measured, not guessed.** The first cut also refused `-Recurse + Format-Table` / `+ Select-String` / `+ node_modules`; replaying the whole foreground command history showed they average only 4.3–11.7 s, while a refusal forces a rewrite round trip — **the rewrite costs more than running it**. Those three produced 87% of the refusals for 12% of the benefit, so they were removed; their rare long tail (117–121 s) is what `hardCeilingMs` is for. Any new rule has to be measured the same way before it ships.
Config: `guardEnabled` (default true), `guardAllow` (substrings that exempt a command, default empty).
**Recommended companion setting**: `hardCeilingMs: 120000` — a hard two-minute ceiling for non-protected commands, specifically to counter "the model keeps raising `timeoutMs`". Protected commands are exempt from the ceiling.
## Install
```powershell
# From GitHub (pin to a commit if you like)
dsh plugin --profile web add github:kiterunner1/dsh-attention-beep
# Or clone first and install locally (source edits take effect immediately)
git clone https://github.com/kiterunner1/dsh-attention-beep.git
cd dsh-attention-beep
dsh plugin --profile web add .
```
**Stop the running DSH** before installing plugins, otherwise `profiles//node_modules` gets deleted half-way and you are left with a profile that runs now and dies on restart.
Editing **host code** (`lib/*.js`) requires restarting DSH (ESM module cache); the client half (`lib/client.js`) only needs a page refresh.
Uninstall: `dsh plugin --profile web remove dsh-attention-beep`, and drop the matching entry from `dsh.profile.bundles`.
## Configuration
Everything lives in the loader row's `config` (defaults in [`cordis.patch.yml`](./cordis.patch.yml)). The user layer overrides by id, or you edit it in **Settings → Beeps** (written to `$DSH_HOME/settings.yaml`, effective live):
```yaml
- id: attention-beep
config:
enabled: true
scope: root # beeps: root | all (include sub-agents)
watchScope: all # watchdog: root | all (watch sub-agents too)
watchTools: [pwsh]
humanWaitTools: [ask_user_question] # suspends the whole-turn judgement (do NOT move into watchTools)
warnMs: 120000
killMs: 180000
preWarnMs: 60000
silenceMs: 60000
sampleIntervalMs: 10000
hardCeilingMs: 120000 # 0 = off; >0 interrupts even while CPU is burning (loop protection)
autoKill: true
observeOnly: false # dry run: ring / log / notify only
escalateToTurnCancel: true
escalateGraceMs: 45000
maxAutoActionsPerSession: 3
stallTimeoutMs: 180000
stallAutoResume: false # whole-turn layer only rings, never cancels (keeps your thinking safe)
guardEnabled: true
guardAllow: [] # substrings that skip the guard
protectList: [npm install, pnpm build, git clone, ...] # omit = built-in list
logPath: '' # empty = /logs/attention-beep.log; "" disables
events:
toolTimeout: { enabled: true, sound: voice:toolTimeout }
```
`sound` accepts three forms: `voice:` (bundled voice), a preset name (`ding` / `chime` / `notify` / `tada` / `alarm` / `error` / `default` / `recycle` / `ring` / `beep`, all `%WINDIR%\Media\*.wav`), or any absolute `.wav` / `.mp3` path. A missing file falls back to `beep`.
Event log: `$DSH_HOME/logs/attention-beep.log`, a full JSONL stream (`warn` / `action` / `settled-aborted` / `resume` / `sound` / `breaker` / `stall` / `guard`), rotated past 2 MB.
## Platform support
**Windows only.** Capability by capability:
| Capability | On non-Windows |
| --- | --- |
| Beeps | Depend on `System.Media.SoundPlayer` / WPF `MediaPlayer`; no equivalent implementation |
| Watchdog | The probe uses `Get-CimInstance Win32_Process`; elsewhere `probe.supported = false` and **auto-abort does nothing** (no crash, just no action) |
| Command guard | The rules target PowerShell syntax; DSH ships the `bash` tool on those platforms, and the default `watchTools: [pwsh]` never matches |
So `package.json` declares `os: ["win32"]`: it will not install elsewhere. That is deliberate — better than installing and hearing nothing.
The one guard rule that could misfire on POSIX (`tilde-native-path`) is additionally gated on the platform, because there the shell expands `~` itself and `node ~/x` is perfectly valid.
## Tests
```powershell
node --test test/unit/*.test.mjs # 82 tests: decision state machine, probe, audio resolution, notice routes, guard rules, report copy
node test/e2e/run.mjs all # real DSH: one-shot pwsh / persistent pwsh / regression
```
The E2E run starts an isolated headless profile, launches a 600-second silent command, then verifies: the ring was recorded, the call was auto-aborted, the model received the resume note inside the tool result, the call took far less than 600 s, and **no processes were left behind**.
## Notes and boundaries
- The plugin only touches local system sounds and process enumeration; it **makes no network requests at all** (the voice assets ship with the package).
- The probe samples once per `sampleIntervalMs`, and only while a watched call is running; it backs off to three times the interval while progress continues and resumes immediately when work stops.
- The probe **never interrupts when it cannot read data** — "unverifiable" is not "no progress".
- Every judgement is based on **operating-system facts** (process CPU / I/O), not tool output. So it works for any long command that produces no output, with no per-tool adaptation.
## FAQ
**Q: Will it kill a legitimately long task?**
The signal is "the call's own subtree stopped accruing CPU and I/O", it must clear the idle noise floor, and it must stay silent for `silenceMs`. Compiling, downloading and writing files all keep producing CPU/I/O and are never mistaken for hangs. `protectList` additionally disables auto-interruption entirely for commands that are known to be slow but quiet (`npm install` and friends).
**Q: Why is `stallAutoResume` `false` by default?**
The whole-turn assumption "no events = dead" does not hold: a model thinking slowly and a user typing an answer both look like "no events". The whole-turn layer only rings and leaves the decision to you. The layer that is genuinely valuable — and still fully automatic — is the **tool** layer, cutting off the one stuck command.
**Q: Can it ring without ever interrupting?**
Yes: `observeOnly: true` for a dry run (ring / log / notify), or `autoKill: false`.
**Q: How do I change the voice or mute an event?**
Point that event's sound at your own `.wav` / `.mp3` in the settings page, or set its `enabled` to false. Every row has a ▶ preview button.
**Q: Does it slow DSH down?**
The probe is a short-lived PowerShell child, once every 10 seconds, and **only while a watched call is running**; it backs off to 3× the interval while progress continues. When idle it does not sample at all.
## License
MIT — see [LICENSE](LICENSE).