kiterunner1/dsh-attention-beep

dsh-attention-beep

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

Uncategorizedagentai-agentdeepseek-harness

dsh-attention-beep

中文 · English

Gets you out when a command hangs; gets your attention when a task finishes.

A DeepSeek Harness (DSH) 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.

Why you need it

Once long tasks are running, three things keep biting:

What happens to youWhat it does
A long task finishes while you are in another tab and you never noticePlays 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 thereConfirms "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 timeRefuses it before spawn, returning in 0 s with the replacement

1. Beeps

EventWhen it ringsDefault sound
taskEndA turn finishedvoice:taskEnd
toolWarnA command has run long enough to enter the warning windowvoice:toolWarn
toolTimeoutA stuck call was confirmed and abortedvoice:toolTimeout
recoveredThe resume note was written into the tool result and the task continuesvoice:recovered
stallA whole turn produced no progressvoice:stall
questionThe model asks you something via ask_user_question (client half listens on user-questions/request)voice:question
approvalA 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:

GateDefaultMeaning
warnMs120sStill not returned after this long → ring + page banner (warning window opens)
preWarnMs60sTime to stop it manually after the ring
killMs180sEarliest moment it may act, counted from the call's start
silenceMs60sThe 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:

IdiomTime
Get-ChildItem -Recurse -Include *.js>90s
Get-ChildItem -Recurse -Filter *.js6.7s
the native grep tool0.23s

The rules, each naming the real risk it prevents:

RuleWhat it refusesThe fix
tilde-native-pathnode ~/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 failUse 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-processSelect-Object -Last piped from a test runnerRedirect to a file and read its tail, or go background
foreground-long-sleepforeground Start-Sleep -Seconds >= 60A polling script with a timeout, or go background
explicit-long-timeoutan explicit timeoutMs >= 180000 on a command outside the protect listStart 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

# 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/<name>/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). The user layer overrides by id, or you edit it in Settings → Beeps (written to $DSH_HOME/settings.yaml, effective live):

- 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 = <DSH_HOME>/logs/attention-beep.log; "" disables
    events:
      toolTimeout: { enabled: true, sound: voice:toolTimeout }

sound accepts three forms: voice:<key> (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:

CapabilityOn non-Windows
BeepsDepend on System.Media.SoundPlayer / WPF MediaPlayer; no equivalent implementation
WatchdogThe probe uses Get-CimInstance Win32_Process; elsewhere probe.supported = false and auto-abort does nothing (no crash, just no action)
Command guardThe 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

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.