Install
Inside DeepSeek Harness, with dsh-market
dsh plugin --profile web add dshmarket
Or from the command line
dsh plugin --profile web add github:lijiawei255/dsh-status-chime
Installing runs third-party code with your own permissions — it can read your files, use your credentials and reach the network. Review the source first, and pin a commit (github:owner/repo#sha) when you can.
README
Spoken status alerts for DeepSeek Harness: when a turn finishes, fails, a background job ends, or the agent starts waiting for you, say it out loud instead of only flashing a taskbar icon.
Why this exists
DSH's built-in notifications are visual: a flashing taskbar icon and a system toast. Both carry a hidden assumption — that you are looking at the screen.
But the moments that actually need a notification are usually the moments you are not:
- A long run finally finishes
- A background job fails after you have moved on to something else
- The agent stops to ask you a question, and then waits there for half an hour
- A goal gets blocked and needs you before it can continue
So the alert is missed and time is wasted. Switching to audio turns "I have to keep watching" into "I only have to be within earshot" — you can go make tea, read something, or work on another machine.
Install it and it works. There is nothing to configure.
The eight scenes
The durations are deliberate: long = something needs you, short = something ended.
More precisely it is three bands, not "each clip a notch longer than the next":
| Band | Range | Scenes |
|---|---|---|
| Failed / blocked — needs you most | 4.8 – 8.0s | turn-error, job-failed, goal-blocked |
| Waiting for you to act | 3.0s | approval |
| Ended (or a simple question) | 1.6 – 2.6s | job-done, goal-complete, needs-input, turn-done |
Clips inside a band sit deliberately close, because they are the same urgency: goal-complete (2.16s) and needs-input (2.09s) differ by 0.072s, and the Chinese set differs by 0.072s too. Length tells you the band, not which clip.
| Scene | Fires when | Chinese | English | Urgency |
|---|---|---|---|---|
turn-error |
The turn failed, or hit the token ceiling | 7.97s | 7.90s | failed/blocked — longest |
job-failed |
A background job failed | 5.95s | 6.46s | failed/blocked |
goal-blocked |
A goal is blocked and needs you | 4.78s | 4.85s | failed/blocked |
approval |
An action is waiting for your approval | 3.00s | 3.00s | waiting for you |
job-done |
A background job finished | 2.59s | 2.59s | ended |
goal-complete |
The goal finished (once, not per round) | 2.16s | 2.16s | ended |
needs-input |
The agent stopped to ask you something | 2.09s | 2.09s | a quick question |
turn-done |
A turn you started finished normally | 1.58s | 1.92s | ended — shortest |
⚠️ The two sets have different durations — do not mix them up. The Chinese and English clips are synthesized separately (different voice, different language), so the order matches but the numbers do not: English job-failed is 6.46s against the Chinese 5.95s. Both columns are given above; read the one for the language you actually use.
Two filtering rules deserve a note, because they are what keeps this plugin from becoming a source of noise:
turn-errordoes not check who started the turn. A failure during an autonomous round still needs to reach you.turn-doneonly speaks for human-initiated turns. Otherwise a goal that runs 20 rounds would play "task complete" 20 times. Autonomous progress is reported at the goal level bygoal-complete/goal-blocked, not per round.
approval only fires under the ask approval policy; under never nothing is waiting for you, so nothing is spoken. It listens on the approval/asked session event, which is always emitted under ask, with the scoped approval/request waterfall kept as a fallback. Both paths play the same clip.
Audio samples: GitHub's Markdown cannot embed a player, so the clips are attached to the
v0.4.0 release as 16
audio files — the bare names are Chinese (the default set) and the .en names are English.
The link is pinned to a specific tag rather than latest, and every release re-attaches the
same 16 files: the audio has not changed by a single byte since 0.3.0 (0.4.0 changed only
code), so each release page can be listened to directly instead of depending on an older one
still carrying the attachments.
⚠️ Those attachments are for listening only, not an install artifact. Install with
dsh plugin add; the package already contains all 32 files (16 clips × mp3 + wav), so
nothing needs downloading from the release.
Two languages: Chinese (default) and English
One package ships both sets — all eight scenes in Chinese and in English. Switching does not require reinstalling. The default is Chinese.
/voice-alerts lang # show the current language
/voice-alerts lang en # switch to English
/voice-alerts lang zh # back to Chinese
The switch is written back to the config file (the language field), so it survives a restart. You can also just edit the config:
{ "language": "en" }
The English lines are rewritten, not translated
This matters, because it goes to the core of the design.
A literal translation of the Chinese runs long and flat in English, and clip length is the only channel this plugin has for conveying urgency. Once the English lengths drift, judging "do I need to go over there" from the sound alone stops working. So the eight English lines were rewritten for English rather than translated:
| Scene | Chinese | English |
|---|---|---|
turn-done |
任务完成。 | Turn complete. |
needs-input |
需要你回答。 | Waiting for your answer. |
goal-complete |
目标已完成。 | The goal is complete. |
job-done |
后台任务完成。 | The background job has finished. |
approval |
有操作等待你批准。 | An action is waiting for your approval. |
goal-blocked |
目标受阻,需要你介入处理后才能继续。 | The goal is blocked. It needs you before it can continue. |
job-failed |
后台任务失败,请回到 DSH 查看详情。 | The background job failed, and it needs your attention. Check DSH for details. |
turn-error |
任务执行出错,本轮未能完成,请回到 DSH 查看错误详情。 | The turn failed, so this round did not finish. Open DSH to see the error details, then try again. |
The English durations run 1.92s → 7.90s, monotonic in the same severity order as the Chinese. What the length actually separates is the three urgency bands: the step into the failed/blocked band is 38% (3.00s → 4.85s). Within a band the clips sit close on purpose — goal-complete (2.16s) against needs-input (2.09s) is 0.072s, and the Chinese set has the same near-tie. Length tells you the band, not which clip.
⚠️ One trap worth recording: the English
turn-errorstarted at 16 words / 6.55s whilejob-failedwas 6.46s — a 0.09s gap, inaudible. The Chinese pair differs by 2.02s (25% of the longer clip; every percentage in this file uses that convention). Lengthening the English error line to 19 words restored a 1.44s gap. If you edit the copy, re-measure the durations rather than trusting the word count.
The English set uses loongmary (a warm British voice) rather than having the Chinese voice read English. Three candidates were auditioned and ranked (scripts/qa.mjs rank; the output lands in qa/, which is not version-controlled — a repeat run kept the same order). The Chinese voice reading English scored lowest and was described as noticeably synthetic with unnatural rhythm: naturalness 4/10, character 5/10 (the two are easy to mix up — naturalness is 4, character is 5).
/voice-alerts status lists each language's clip availability:
Clip sets: zh (active): all clips present | en: all clips present
Three things to know first
- Platform: Windows 10/11. The guaranteed player is Windows' own PowerShell +
System.Media.SoundPlayer; there is no macOS or Linux support. With ffmpeg installed it prefersffplay, but that is an optional upgrade. - You need DSH Desktop and a terminal that can run
dsh. Installing a plugin goes throughdsh plugin, which shells out to pnpm (see the trap below). - The audio needs nothing installed — all 32 clips, both languages, ship in the package.
⚠️ Read once: the package name and the runtime name differ
This is the easiest thing to trip over, so it is worth stating plainly:
| Name | |
|---|---|
| npm package / GitHub repo | dsh-status-chime |
| Slash command | /voice-alerts |
| Log prefix | [voice-alerts] |
| Config file | $DSH_HOME/voice-alerts.config.json (the primary path) |
| Clip directory | $DSH_HOME/voice-alerts/clips/ |
| cordis id | voice-alerts |
There is also a legacy config location kept for compatibility:
$DSH_HOME/voice-alerts/voice-alerts.config.json. The plugin reads whichever it finds first (primary path first), so if the primary file already exists, edits to the legacy file are silently ignored.Writes go back to the file it actually read — that is, to the primary path when the primary file exists, otherwise to the legacy one; only when neither exists does it create the primary. So on an install that has only the legacy file,
/voice-alerts langandon/offupdate the legacy file, not the primary one. When in doubt, check theconfig <path>line in the startup log: it names the file that is really in use.
The package was renamed from dsh-voice-alerts to dsh-status-chime in 0.3.0 (the old name differed by one letter from an unrelated plugin in the community catalog, which the marketplace rules would have hidden). The rename touches only the package and repository names: the command, config paths and log prefix are all still voice-alerts, so an existing install keeps working.
When debugging, grep the log for voice-alerts, not dsh-status-chime.
Install
Option 1: hand it to your agent (recommended)
Send this to your DSH:
Install
https://github.com/lijiawei255/dsh-status-chimeinto my DSH desktop profile, and remind me to restart when it's done.
The repository includes INSTALL.md, which spells out every step. Its purpose is to make different agents produce the same result instead of improvising.
Option 2: run the commands yourself
Replace <PROFILE> with your own profile name (usually desktop). Look at what is actually under $DSH_HOME/profiles/ first rather than copying someone else's:
# straight from GitHub
dsh plugin --profile <PROFILE> add github:lijiawei255/dsh-status-chime
# or clone / download a ZIP first, then point at the local directory
git clone https://github.com/lijiawei255/dsh-status-chime
dsh plugin --profile <PROFILE> add .\dsh-status-chime
This installs the package into the profile and registers it as a profile layer (which works because the package declares dsh.bundle; this repository already does).
⚠️ If you hit ERR_PNPM_ADDING_TO_ROOT: the profile carries its own pnpm-workspace.yaml declaring packages: [.], so pnpm 9 refuses a plain add. Add -w:
dsh plugin --profile <PROFILE> add -w github:lijiawei255/dsh-status-chime
(Measured: pnpm 9 rejects it, pnpm 11.8.0 accepts it, and pnpm 10 was not tested - so -w is the safer fix.) See TROUBLESHOOTING.md item 11.
You must fully restart DSH Desktop afterwards, otherwise the plugin is not loaded.
After the restart
In the chat box, run:
/voice-alerts status
If you see Voice alerts: on (v0.4.0), Language: zh and Scenes (8), the install worked. To hear all eight:
/voice-alerts
Language: zhmeans the alerts will speak CHINESE. Chinese is the default on purpose — it is the plugin's primary audience — but you have to switch it yourself, and nothing else in this walkthrough will tell you that:/voice-alerts lang enRun that before the preview above if you want English. Without it, an English reader hears eight Chinese clips and understandably concludes the English audio is missing or broken. It is not; both sets ship, and
statuslists both (Clip sets: zh: all clips present | en (active): all clips present). See Two languages for what the two sets sound like.
Uninstall
dsh plugin --profile <PROFILE> remove dsh-status-chime
Uninstalling does not delete anything under your $DSH_HOME. To remove it all, delete these two:
# clips, play.ps1, clips.json
Remove-Item -Recurse -Force "$env:USERPROFILE\.dsh\voice-alerts"
# the runtime config (it is NOT inside that directory - it is a sibling file)
Remove-Item -Force "$env:USERPROFILE\.dsh\voice-alerts.config.json"
A full DSH Desktop restart is needed for the removal to take effect.
⚠️ QA reports are not under $DSH_HOME, and they are not shipped either:
qa/report.md is written by the development script scripts/qa.mjs into the qa/
directory of a repository checkout. That directory is in .gitignore and is not in
package.json's files, so a plugin installed with dsh plugin add has no such file —
running the quality gate means cloning the repository first.
How it works
DSH events ──▶ lib/index.js ──▶ coalesce / throttle / priority ──▶ player ──▶ audio files
Event sources (all public host-process events):
| Event | Used for |
|---|---|
session/event → turn/start / user/message / turn/end |
Whether the turn was human-initiated, and how it ended |
session/event → goal/change |
Goal complete and block |
jobs.events.subscribe → settled |
A background job ending; completed speaks, failed speaks, killed stays silent, as do awaited (a waiting caller already collected it) and cause: 'teardown' (the owner is going away) |
tools/pre-execute |
A tool name in waitingTools means the agent is about to wait for you |
session/event → approval/asked |
Primary path: always emitted when the approval policy is ask |
approval/request |
Fallback: the scoped waterfall, also only under ask; both play the same clip |
Playback rules: several events inside one 400 ms window collapse to the highest-priority scene only; the same scene will not repeat within 1.5 s; a new alert interrupts the one playing.
Zero network at runtime: the plugin reads local audio files and starts local players. It does not connect out, upload, or phone home.
The interesting part: using a large model to pre-screen the voice
This is the piece I think is most worth sharing.
Picking a voice for an alert sound is traditionally done by blind listening: try ten voices, rewrite the style description twenty times, tire your ears out, and then decide from a vague impression. This project does it differently — let a model do the first pass and the ranking, and keep the human for the final call among a handful of survivors.
Three concrete pieces:
1. A full-modality model as the reviewer (the core idea)
tools/qw_local_omni.py hands several candidate clips at once to Qwen-Omni and asks for a cross-comparison with per-dimension scores:
python tools/qw_local_omni.py preview/flash-mary-en.mp3 preview/flash-eva-en.mp3 preview/flash-yuanfei-en.mp3 `
--message "Compare these recordings against each other: rate clarity, naturalness, voice character and cleanliness, and rank them."
What comes back is a ranking, not a pile of isolated scores — "A is closer to the target than B" is far more useful than "A scored 7". In practice the reasoning is specific (summarised below):
A4 keeps the clarity and steadiness a system alert needs while sounding closest to the target; A2 is not distinctive enough, A5 is a little flat, and A1, A3 and A6 are excluded for being too bright or too young.
2. ASR read-back as an objective check
Sounding pleasant is not enough — TTS can swallow syllables, mispronounce, or spell an abbreviation out letter by letter. tools/qw_local_asr.py transcribes the audio and compares it against the intended line by character similarity:
python tools/qw_local_asr.py assets/clips/turn-error.mp3 --lang zh
This one is a legitimate hard gate, because it is mechanically decidable: the intended text is known, and the transcript either matches or it does not. All 16 clips in this project (8 scenes in each of 2 languages) score 1.000.
3. Duration as an information channel
The durations in the table above are not arbitrary. Severity is encoded in the audio itself: long = something needs you. That way, even with the phone beside you and the screen out of view, the length alone tells you whether to put down what you are holding.
The resulting workflow (two scripts, fully reproducible):
scripts/build.mjs audition produce several candidate voices
↓
scripts/qa.mjs rank cross-ranking by the omni model (no ASR; that happens next)
↓
a human listens to the top 2-3 and decides ← decision load compressed to very little
↓
scripts/build.mjs build batch synthesis + loudness normalisation
↓
scripts/qa.mjs clips quality gate over everything
One lesson worth passing on: do not make a model's subjective score a hard gate. The same clip was scored "naturalness 6 / character 4" on one run and "naturalness 9 / character 7" on the next, and four clearly different clips once received identical scores. So this project splits the metrics in two: objective measures (ASR similarity, clipping, clarity, cleanliness) gate the build, and subjective ones (naturalness, character, maturity) only inform the decision, with the final call left to ears. That division is what makes the workflow stable enough to rely on.
Three checks added later, each because a specific hole turned up first:
| Check | Tier | Why it exists |
|---|---|---|
| Silence floors (peak >= -30 dB, mean >= -35 dB) | hard gate | The peak check only looked for clipping, so a clip with a plausible length and no signal at all passed every gate — one of the classic TTS failure modes. Measured on the shipped clips: peak -4.2..-1.9 dB, mean -20.6..-16.6 dB, against -91 dB for true silence. The two margins are not equal: the peak floor clears the worst shipped clip by 25.8 dB (-4.2 against -30), the mean floor by 14.4 dB (-20.6 against -35) |
| Net speech rate (units per second of actual speech) | advisory | The Chinese set speaks deliberately slowly (rate 0.95), so the 3.2-5.5 units/s band from human broadcast-speech research would fail seven of the eight. What matters here is consistency between clips, because length carries the urgency, so each clip is compared against the median of its own language. Clips under 5 units are exempt, or a two-word clip would false-positive |
| UTMOS (a trained MOS predictor) | advisory | Gives naturalness a reproducible number, which is the gap the unreliable model score left. It must be compared per language: it scores every English clip above every Chinese one (4.38-4.51 against 3.75-4.28), so a cross-language comparison would read as a defect in the Chinese set |
All three have negative controls (scripts/qa-negative-control.mjs, offline and free): it injects a fully silent clip, a very quiet one, and a stretched one, then asserts the gates actually report them while a healthy clip is left alone. A gate that only ever says "pass" is worth nothing.
One detour worth recording: before adding the silence floors, an existing TTS QA tool (
ttsproof) was tried. It catches clipping and truncation but not a fully silent file — itsempty_audioflag only tests for a missing file or one under 44 bytes, never the content. So that floor is hand-written rather than inherited.
Configuration
Config file: $DSH_HOME/voice-alerts.config.json ($DSH_HOME is usually ~/.dsh).
When the file is absent the built-in defaults apply, so nothing has to be configured. It is re-read by modification time, so edits take effect immediately without a restart.
Full template: assets/voice-alerts.config.json. Common entries:
| Key | Default | Meaning |
|---|---|---|
enabled |
true |
Master switch |
volume |
85 |
0-100, ffplay only |
minIntervalMs |
1500 |
Repeat-suppression window per scene |
coalesceMs |
400 |
Coalescing window; only the top-priority scene inside it plays |
interrupt |
true |
Whether a new alert cuts off the current one |
scenes.<scene>.enabled |
true |
Turn a single scene off |
player |
"auto" |
auto / ffplay / powershell |
ffplayPath |
null |
Explicit ffplay path, for when it is not on PATH |
playPs1Path |
null |
Explicit path to the fallback script play.ps1 |
commandName |
"voice-alerts" |
Slash command name. A collision with another plugin loses only the command, not the alerts |
waitingTools |
["ask_user_question","exit_plan_mode"] |
A match means "waiting for you"; override if DSH renames a tool |
language |
"zh" |
Spoken language: zh / en. An unrecognised value falls back to zh rather than going silent |
watchApprovals |
true |
Whether an approval request is spoken (only reachable under the ask policy) |
clipsDir |
null |
Extra clip directory, highest precedence |
Commands
| Command | Effect |
|---|---|
/voice-alerts |
Play all eight in sequence (in the current language) |
/voice-alerts on / off |
Toggle immediately (writes back to the config file) |
/voice-alerts lang <zh|en> |
Switch the spoken language; written back to the config |
/voice-alerts status |
Show the detected player and whether every scene has audio in each language |
/voice-alerts test <scene> |
Play one scene, to check whether an event fires at all |
Using your own voice
You do not have to use the eight bundled clips. The full flow:
# 1. Edit assets/clips.json: lines under clips.<scene>.text, voice under model/voice/instruction
# 2. When changing voice, audition first (list candidates under voiceCandidates)
node scripts/build.mjs audition
node scripts/qa.mjs rank # the model ranks them; you pick the final one
# 3. Write the chosen model/voice/instruction back into clips.json, then build
node scripts/build.mjs build
node scripts/qa.mjs clips # quality gate
Generating audio requires ffmpeg (normalisation and transcoding), Python 3 (the QA helpers) and the Alibaba Cloud Bailian CLI (TTS/ASR/Omni). Those three are only needed if you generate your own — with the bundled clips, nothing has to be installed.
Each clip is produced as both mp3 and wav. Do not delete the wav files: the PowerShell fallback player only accepts uncompressed PCM, and it is what guarantees the plugin works on a clean Windows install.
Audio you generate into $DSH_HOME/voice-alerts/clips/ automatically takes precedence over the packaged files, per file, so you can replace just one scene.
The filename rule matters if you want to replace a single English clip:
| Language | Filename |
|---|---|
| Chinese (default) | <scene>.mp3 / .wav, e.g. turn-done.mp3 |
| English | <scene>.en.mp3 / .en.wav, e.g. turn-done.en.mp3 |
The default language keeps the bare name; every other language adds a .<code> infix. So to replace only the English approval, drop in approval.en.mp3 (and .wav if you rely on the PowerShell fallback player).
No sound?
Work through this order; it covers the overwhelming majority of cases. The full list is in TROUBLESHOOTING.md.
- Did you restart? The plugin file is not hot-loaded; a new install or edit needs a full DSH Desktop restart.
- Check the Player line from
/voice-alerts status.unavailablemeans neither playback path was detected. - The Windows volume mixer has
ffplay(orpowershell.exe) muted on its own — this is the most common cause. Right-click the taskbar volume icon → Open Volume Mixer, and check the matching entry. - The wrong output device is selected.
- That scene is disabled, or
enabledisfalse. - The log says
no <lang> audio for <scene>(e.g.no zh audio for turn-done) → that language's clip file is missing. - The log says
throttled <scene>→ the repeat-suppression window caught it. That is expected behaviour.
Log location: %APPDATA%\DSH Desktop\logs\host\dsh-<date>.log; search for voice-alerts.
⚠️ One thing to know up front: the absence of ffplay in the log is not a fault. ffplay ships with ffmpeg, which is a separate install; it only makes startup faster and gives you a separate volume. Without it the plugin uses the PowerShell player that ships with Windows, and everything still works.
Platform and dependencies
Supported: Windows 10 / 11.
On a clean Windows install no third-party dependency is needed to make sound, because the baseline player is Windows PowerShell 5.1 plus the .NET System.Media.SoundPlayer, both part of the OS.
| Situation | What is needed |
|---|---|
| Clean Windows 10/11 with only DSH | Nothing — the OS player is used, with roughly 0.4 s of startup overhead |
| ffmpeg installed | Nothing to configure; ffplay is used automatically: faster start, and volume takes effect |
| Generating your own audio | Additionally ffmpeg + Python 3 + the Bailian CLI |
| Network at runtime | None |
Backend selection order:
- ffplay (when detected) — plays mp3 directly and supports an independent volume
- Windows PowerShell + SoundPlayer — ships with the OS, accepts wav only, volume follows the system
It first tries
-File play.ps1(an auditable script on disk); if an execution policy or ACL blocks it, it retries once with an inline-EncodedCommand - Neither available → silent degradation with a single log line
The host process cannot tell whether the window is focused (DSH's native bridge exposes only things like notifyAttention), so this plugin always makes sound, including while you are looking at the window. If that is too much, use /voice-alerts off or lower volume.
Verification status
I find it more useful to separate "written" from "verified" than to claim vaguely that everything works. So, item by item — including an explicit statement of where the verification stops.
The table below is the summary. The per-item evidence, the measurements, and exactly how
far each check was taken live in docs/verification.md — go there
to check any individual number.
Verification boundary (read this first)
Every conclusion below comes from verification on a single machine:
| Operating system | Windows 11 |
| DSH | DSH Desktop (official build), @deepseek-ai/dsh 0.1.7-rc.2 |
| Extra software on that machine | ffmpeg, Python 3 and the Alibaba Cloud Bailian CLI are installed |
The previous release (0.3.0) was verified against 0.1.5-rc.2. DSH 0.1.7 removed
jobs.onJobDone, which silently killed both background-job scenes; 0.4.0 is the fix — it subscribes to 0.1.7'sjobs.events.subscribeand keeps a fallback to the old callback, so 0.1.5 and 0.1.6 still work too.
What that means:
- ✅ Code correctness, event mapping, audio quality — independent of what that machine happens to have installed, so these carry over to your machine.
- ✅ "Zero dependency on a clean Windows install" — the baseline player is Windows
PowerShell 5.1 plus .NET
System.Media.SoundPlayer, both operating-system components of Windows 10/11, and the no-ffmpeg case was verified by simulation. - ⚠️ DSH version — verified on 0.1.7-rc.2 and on the 0.1.5 line (the latter simulated by the suite's legacy-API phase, not measured on a real host). A later DSH that changes the event surface again could stop some scenes from firing. This is the one genuinely unknown variable, and it is not something a test on this machine can remove.
- ⚠️ Installing from GitHub on someone else's completely clean Windows — there was no second machine available, so this step has not been measured directly. CI (below) covers "install + load + backend detection" on a clean GitHub-hosted Windows runner, but a runner has no audio device, so it cannot verify that sound reached a speaker.
If you hit a problem on another DSH version or another machine, please open an issue with the
[voice-alerts] active … line from your log — it states the detected player and the config
path, which usually pinpoints the cause immediately.
Status by layer
| Layer | Status |
|---|---|
| Events | 7 of the 8 scenes have been verified by a real trigger: turn-done, turn-error, needs-input, job-done, goal-complete, goal-blocked, approval |
| Audio | All 16 clips (8 scenes in each of 2 languages) passed the automated quality gate (ASR similarity 1.000, clarity and cleanliness 10/10, no clipping). The Chinese set was additionally confirmed by ear, clip by clip, to play through completely; the English set has not had that listening pass — it passed the automated gate only |
| Backend | Forcing PowerShell selects .wav correctly; with no ffmpeg present it falls back and still plays; an explicitly requested but absent ffplay fails loudly instead of switching backends behind your back |
| Code | scripts/selftest.mjs drives the plugin through a mock context with 76 checks covering event mapping, filter rules, priority, throttling, the command, name collisions, asset resolution order, language switching with its fallback, and the probing of both job-event API generations |
| Language | Chinese is the default; after lang en the plugin genuinely resolves the English file (the test asserts on the resolved filename, not just the status text); an unrecognised language in the config falls back to Chinese instead of going silent |
| CI | .github/workflows/verify.yml verifies on a clean windows-latest: a real install that registers as a profile layer, BOM-free manifests, node-builtins-only imports, all 32 clips present, the PowerShell backend still detected with no ffplay, the 76-check suite, and the privacy scan |
On the approval scene specifically: it can only fire under the ask approval policy, and
although it was heard on a real approval request, which of the two paths delivered it is
unresolved — the approval/asked session event and the approval/request waterfall play
the same clip, so hearing it does not distinguish them. The behavior of the session-event
path (including subagent filtering) is covered by the offline suite.
What CI does and does not prove is written out at the top of the workflow file — including the fact that it cannot prove sound reaches a speaker.
One known defect, stated plainly
job-failed is effectively unreachable for a background shell command that exits non-zero.
Measured: a command run in the background that ended with exit 7 was recorded by DSH as completed, so what played was "background job finished". The reason is that a job has only three terminal statuses — completed, killed, failed — and the producer for background shell commands only ever emits the first two. failed is reserved for a background tool task reporting an error, or for a producer contract violation. This is framework behaviour, and 0.1.7 did not change it.
0.1.7 did add one observable: a job's detail now carries exit code: N, so non-zero exits are at least visible in the log. That string is deliberately not parsed here — it is written for a human, and treating it as a contract would break silently the next time it is reworded. The right fix is for DSH to expose the exit code as a status field, not for this plugin to guess at text.
The scene is kept (it does work for tool-task failures), but its trigger has never been reproduced, only checked at the source level. You can confirm the audio itself is fine with /voice-alerts test job-failed.
What 0.4.0 actually fixes
DSH 0.1.7 deleted jobs.onJobDone, and 0.3.0 knew only that API — so on 0.1.7 both the job-done and job-failed scenes went silent without a single error line (the host contains the throw per fiber). 0.4.0 subscribes to 0.1.7's jobs.events.subscribe({ owners: 'all' }) commit stream instead:
- Only
settledcounts;registered,progress,stopping,outputandremovedare ignored. awaited: true(a waiting caller already collected the result) andcause: 'teardown'(session archived, host exiting) both stay silent — otherwise every shutdown would speak, and everyjob_waitwould double-report.- The old API is kept as a fallback branch, taken only when
events.subscribeis absent, and the log says which one is in use. Both branches are covered by the suite.
Local and published: the mirror, and the one sanctioned difference
This project is two things at once: the repository other people install, and the copy running on the author's machine. When those diverge, the trouble is that nothing shows it — the version string stays identical while the code moves, so "what I tested locally" and "what you downloaded" quietly stop being the same artifact. The mirror is therefore a rule that can be executed, not a promise.
Mirrored byte for byte (node scripts/sync-profile.mjs --all --check / --apply):
| Local | Published |
|---|---|
the single-file install's <profile>/<name>.js |
lib/index.js |
$DSH_HOME/voice-alerts/clips/** |
assets/clips/** (32 audio files) |
$DSH_HOME/voice-alerts/play.ps1 |
assets/play.ps1 |
That rule is not decoration: it is how a local copy was caught three commits and one host-API migration behind the repository while both files carried the same version number.
Wording is the only sanctioned difference. $DSH_HOME/voice-alerts/clips.json is the
private master (written the author's way, not for publication); the published
assets/clips.json describes the same audio in neutral terms — the clips were not
regenerated, those 32 files are the ones that were auditioned. Differences are confined to
comments, the audition candidate list (ids, labels, instructions), the chosenCandidate name
and the style instruction.
And it is machine-checkable: sync-profile.mjs classifies every differing field path and
requires the functional count to be 0 — model, voice, rate, pitch, volume, format,
sampleRate, loudnorm, and every spoken line in both languages. A non-zero count exits 3,
because that is not "wording" and no copy can paper over it. The classification is deliberately
conservative: an unclassified field counts as functional, so a new key cannot slip through
as "probably just wording".
The forbidden-word list lives only locally, in $DSH_HOME/voice-alerts.scan.json (outside
every clone), enforced by node scripts/scan-sensitive.mjs .. The scanner reports file, line
and category only and never echoes what it matched, so its output is safe to paste into an
issue, a CI log or a chat — the check cannot become a second copy of the thing it checks.
Two limits, stated rather than papered over:
- CI cannot run this wording gate — a GitHub runner has no
$DSH_HOME, so CI applies the built-in rules only (credentials, key shapes, personal paths). The wording rules protect the author's machine at commit time. - Positive control: scanning the private directory
(
node scripts/scan-sensitive.mjs "$DSH_HOME/voice-alerts") must report hits. If it comes back clean, the local rules stopped loading and the gate is silently open.
The full rule, the per-field difference table, and what is not a wording difference
(maturity is a scoring dimension in qa.mjs and stays) are in
docs/mirror-policy.md.
Repository layout
dsh-status-chime/
├── lib/index.js # the plugin; the only runtime code
├── cordis.patch.yml # registers this package as a profile layer (dsh.bundle points here)
├── package.json # name, dsh.bundle declaration, engines
├── assets/
│ ├── clips/ # 8 scenes x 2 languages = 16 clips, mp3 + wav each (32 files)
│ ├── clips.json # single source of truth for lines, voice and settings
│ ├── voice-alerts.config.json # config template
│ └── play.ps1 # PowerShell fallback player (pure ASCII; see the file header)
├── tools/ # Bailian local-audio helpers (ASR / Omni / UTMOS)
├── scripts/ # build / qa / selftest / negative control / privacy scan
│ ├── build.mjs # synthesise clips (takes --lang)
│ ├── qa.mjs # quality gate (takes --lang)
│ ├── selftest.mjs # offline logic suite, 76 checks
│ ├── qa-negative-control.mjs # proves the gate actually rejects bad audio
│ ├── verify-local-install.mjs # verifies an installed single-file copy
│ ├── sync-profile.mjs # keeps a single-file install identical to this repo (--check / --apply)
│ ├── scan-sensitive.mjs # privacy / wording scan
│ └── scan-sensitive.verify.mjs # proves the scanner actually catches things
├── docs/verification.md # the per-item verification record
├── docs/mirror-policy.md # mirror rule: what is byte-identical, what may differ in wording
├── .github/workflows/verify.yml # CI: install + suite + scan on a clean Windows runner
├── INSTALL.md # install steps written for an agent
├── TROUBLESHOOTING.md # what to do when there is no sound
├── README.md # Chinese README
├── CHANGELOG.md
└── LICENSE # MIT
qa/, preview/ and tmp/ are scratch directories the scripts write; they are in
.gitignore and are not part of the package.
Listed in
Included in Awesome DSH Plugin
under the notify category: entry ·
submission #5553 (merged).
That list is human- and CI-reviewed rather than scraped: it requires a declared
dsh.bundle, real working code, a repository at least one day old, and a description
that matches the code.
License
Code and bundled audio are released under MIT.
The bundled audio was synthesized with Alibaba Cloud Bailian (Model Studio) text-to-speech. It is not a recording of any person's voice; if you intend to use it, check that your own use is consistent with the terms of the service it was generated with. Nothing here grants rights to the underlying voice model. You do not have to use it at all — scripts/build.mjs can generate your own set, and the plugin prefers files you place in $DSH_HOME/voice-alerts/clips/.
Contributing
Before submitting, run both of these. The first confirms no personal path or credential slipped in; the second confirms the logic suite is still green:
node scripts/scan-sensitive.mjs .
node scripts/selftest.mjs
scan-sensitive.mjs reports only a file, a line number and a matched category — never the
matched text — so its output is safe to paste into an issue. Its rule set can be extended
with strings private to your own machine via $DSH_HOME/voice-alerts.scan.json, which is not
committed. To confirm it actually catches things, run
node scripts/scan-sensitive.verify.mjs: it plants obviously fabricated credential-shaped
values, asserts the scanner reports them, and asserts the report never echoes them.
Issues and pull requests are welcome. Especially welcome: macOS / Linux playback backends (the backend layer is Windows-specific today).
Comments
Comments live in GitHub Discussions. Sign in with GitHub to post or react.