dsh-task-chime
English | 中文
A plugin for DeepSeek Harness (DSH) that plays a real operating-system sound every time an agent turn finishes — so you can look away from the screen during a long task and still know the moment it is done.
Unlike a browser notification, the sound is produced by the Host process through the system audio device, so it reaches you when the DSH window is minimized, in the background, or on another virtual desktop.
turn ends ──► agent/status: idle ──► ctx.shell ──► 🔔 C:\Windows\Media\notify.wav
Features
| Real system sound | Played by the Host through ctx.shell (PowerShell System.Media.SoundPlayer / [console]::Beep on Windows), not by the web page — audible while DSH is in the background |
| Two distinct moments | Task finished and “the agent is blocked on you” (a question or a pending approval), each with its own sound and intensity so you can tell them apart by ear |
| 13 sound choices | System effects (notify, ding, chimes, tada, chord, calendar, messaging, exclamation, alarm, ring), two pure beep patterns that need no audio file, plus any custom audio path |
| 4 intensity levels | L1 light (1 play) · L2 standard (2 plays) · L3 strong (rising prelude + 3 plays) · L4 alarm (two-way prelude + 5 plays) |
| Auto escalation | A task over 2 minutes rings one level higher; over 10 minutes, two levels higher (capped at L4) |
| Trigger scope | Top-level sessions only (default), or include subagent sessions |
| Duration threshold | Stay silent for turns shorter than N seconds, so quick answers do not beep |
| No spurious chimes | A completion ring requires an observed running → idle transition, and an approval rings only if it is still pending after a grace period — so opening a session or an auto-answered policy check stays silent |
task_chime tool |
An optional model tool, so you can just say "ring me" and the agent triggers a chime on demand |
Why the second moment needs its own trigger
While the agent waits for you — ask_user_question, exit_plan_mode, an approval prompt — its status stays running. agent/status never reaches idle, so the completion chime cannot fire. Without a separate trigger, the one moment that actually needs you is the one moment that makes no sound.
Two ways to run it
The repository ships both halves of the same feature. Pick by how long you want it to live.
| A · Profile bundle | B · Dynamic plugin | |
|---|---|---|
| Entry | lib/index.js + cordis.patch.yml |
src/host.js + src/client.js |
| Install | dsh plugin add, then restart |
cordis_define + cordis_run, no install |
| Survives a DSH restart | yes | no |
| Applies to every session / project | yes | that one session |
| Configuration | a card in Settings → Plugins, settings.yaml (namespace task-chime), or the composition entry |
in-memory, lost on restart |
| UI | a settings card in Settings → Plugins | a panel in Settings and in the cordis_run card, additionally offering sound preview, temporary mute, and a chime log |
| Needs approval | no | yes (a Client half always asks) |
Mode A is the one to install. Mode B stays useful for trying a change instantly, or for the richer panel.
A · Install as a profile bundle (permanent)
dsh plugin --profile web add github:ruazero/dsh-task-chime
Then restart the Harness (quit and reopen DSH Desktop, or restart your dsh web process). That is all: the package declares dsh.bundle.patch, so its composition row is applied automatically — you never edit a composition file by hand.
Verify before restarting, without starting anything:
dsh --profile web --dump-config | grep -A2 task-chime
To remove it:
dsh plugin --profile web remove dsh-task-chime
Configuration
Three layers, in order of authority:
- The settings card — Settings → Plugins → Task completion chime. Every control writes immediately; a changed field shows a customized badge whose Reset removes the override instead of writing a default over it.
settings.yaml, namespacetask-chime— the same durable document the card writes, watched live: a hand edit takes effect on the next turn without a restart.- The composition entry in
cordis.patch.yml— the base layer, and the fallback whenever no settings service is mounted.
# settings.yaml
task-chime:
enabled: true # master switch for every chime
sound: notify # notify | ding | chimes | tada | chord | calendar |
# messaging | exclamation | alarm | ring |
# beep-triad | beep-low | custom
customPath: '' # absolute audio path, used only when sound is `custom`
level: 2 # 1 light · 2 standard · 3 strong · 4 alarm
autoEscalate: true # +1 level past 2 min, +2 past 10 min
minDurationSec: 0 # stay silent below this turn duration
scope: roots # roots = top-level sessions only · all = include subagents
registerTool: true # expose the on-demand `task_chime` tool
# ring when the agent is blocked on you
notifyOnInput: true # questions and pending approvals
inputSound: messaging # same catalog; keep it different from `sound`
inputLevel: 3 # its own intensity
inputTools: # tools that block until a human answers
- ask_user_question
- exit_plan_mode
approvalDelayMs: 1200 # grace period; a policy-answered approval stays silent
To pin a value in the composition instead, override the row by id in your profile's own cordis.patch.yml:
- id: task-chime
config:
level: 3
sound: chimes
Intensity levels
| Level | Prelude | Plays | Gap |
|---|---|---|---|
| L1 light | — | 1 | — |
| L2 standard | — | 2 | 0.22s |
| L3 strong | rising triad (C6–E6–G6) | 3 | 0.4s |
| L4 alarm | rising + falling triad | 5 | 0.65s |
B · Run as a dynamic plugin (this session only)
git clone https://github.com/ruazero/dsh-task-chime.git
Then paste this into a DSH session that has the Cordis tools (the shipped cordis preset does):
Read
src/host.jsandsrc/client.jsfrom<path to the clone>, then callcordis_definewithplugin.kind: "new",idPrefix: "chime",code.host= the full contents ofsrc/host.js, andcode.client= the full contents ofsrc/client.js. Then activate it withcordis_runinrunmode.
Approve the run when the card appears. This mode adds a custom control panel — sound picker with preview, the four intensity buttons, trigger scope, duration threshold, temporary mute (10/30/60 min), and a log of the last 12 chimes — in Settings → 任务提示音 and inside the cordis_run card.
Each src/*.js file is the plain-JavaScript function body the dynamic evaluator expects — it ends with return { apply(ctx) { … } }. Do not wrap it in a module and do not add import/require: neither exists in that sandbox. Details and troubleshooting: INSTALL.md.
How it works
Task finished
- The Host listens to the event
agent/status. It firesrunning⇄idle;idlemeans no driver remains scheduled or active — the turn is genuinely over, not merely between steps. - A start timestamp is recorded on
running. Onidlethe plugin computes the task duration and applies, in order: the enable flag, the trigger scope, the duration threshold, and long-task escalation. A ring requires a previously observed start, so a resumed session never rings on its own. - It builds a small shell script —
SoundPlayer.Load()plus repeatedPlaySync(), or[console]::Beeppatterns — and runs it throughctx.shell.resolve()+ctx.shell.run(), fire-and-forget: the agent never waits for the sound. - The sandbox policy is resolved from the completing session. Since the command writes nothing, a
read-onlysession is raised toworkspace-writefor the single purpose of keeping PowerShell in FullLanguage; nothing wider is ever requested. - Every capability is probed rather than assumed. No
shell, noagents, nosettings, or notoolsdegrades one feature instead of failing the plugin row.
Blocked on you
- A question is caught on the
tools/pre-executewaterfall: when the pending call's name is ininputTools, the input-request sound plays and the decision is delegated untouched — the plugin never influences whether a call is allowed. - A pending approval is caught on the
approval/requestwaterfall. Ringing immediately would fire for approvals a policy answers on its own, so the plugin arms a timer forapprovalDelayMsand clears it in afinallyblock: only a request still open after the grace period — i.e. genuinely waiting for you — makes a sound. Armed timers are cleared on fiber teardown, so none outlives the plugin.
The settings card
The shipped Plugins settings section enumerates the settings namespaces the Host serves and dispatches settings.plugin.item keyed by each namespace, rendering whichever card claims that key — a served namespace that no card claims renders nothing at all. The Host half registers the namespace task-chime, so lib/client.js claims exactly that key and the card appears beside the shipped shell and agent-loop cards.
lib/client.js is a browser module written directly in the client wire format (window.__ModuleLoader__.load({ id, factory })) rather than produced by a bundler: it requires nothing but react, so a build step would add a toolchain without adding behaviour. Writes go through the bound settings scope (ctx.settingsScope.bind({ namespace: 'task-chime' }) → set / unset), which is the same durable document the Host half watches — so there is no private RPC between the halves and no second source of truth.
Platform support
| Status | |
|---|---|
| Windows 10/11 | Verified. PowerShell System.Media.SoundPlayer + [console]::Beep, sounds from C:\Windows\Media\ |
| macOS | Best-effort, unverified: afplay with /System/Library/Sounds/*.aiff, osascript -e beep |
| Linux | Best-effort, unverified: paplay (falling back to aplay) with /usr/share/sounds/freedesktop/stereo/*.oga |
A report from macOS or Linux is a bug, not an expected limitation — please open an issue.
Developing
npm test # 25 checks, silent: no sound is played
test/smoke.mjs (17) drives the Host apply() against a fake Cordis context and asserts the exact command shape per level, custom-path quoting, the no-observed-start guard, scope filtering, the duration threshold, sandbox-policy handling, live settings precedence, and every degraded path.
test/client.mjs (8) loads the browser module the way the module loader does and checks the slot registration contract, that each control writes its own field through the settings scope, that a reset clears an override instead of writing a value, that a read-only or still-loading document locks every control, and that the card renders under real React.
Both halves resolve their dependencies from the host profile. For a local run, link them into ./node_modules (git-ignored):
# Windows example; point at your own profile's node_modules
$profile = "$env:APPDATA\dsh-desktop\harness\profiles\node_modules"
New-Item -ItemType Directory node_modules\@deepseek-ai -Force
foreach ($m in 'schemastery','dsh-tools','dsh-settings') {
New-Item -ItemType Junction "node_modules\@deepseek-ai\$m" -Target "$profile\@deepseek-ai\$m"
}
foreach ($m in 'react','react-dom','scheduler') {
New-Item -ItemType Junction "node_modules\$m" -Target "$profile\$m"
}
Limitations
- Mode B is ephemeral. A dynamic plugin and its configuration live in memory for the life of the DSH process. Mode A is the permanent form.
- No sound preview in the card. Previewing would need a Host call from the browser, which a composed plugin can only do through a published Remote service; ask the agent to call
task_chimeinstead. registerToolneeds a restart. It is read once when the row activates, so toggling it in the card takes effect on the next Harness start. Every other field applies to the next turn.PlaySyncblocks its own child process for the duration of the sound — by design, since that is how the repeat gaps stay accurate. It never blocks the agent.- One sound for every outcome. Success, error, and "waiting for your input" all ring the same way today.
Roadmap
- Verified macOS / Linux backends
- Per-outcome sounds (success vs. error vs. awaiting input)
- Optional Windows toast notification alongside the sound
- Temporary mute and sound preview in the composed mode (mode B already has both)
No comments yet. Be the first to write one.