dsh-task-ask-notify
A Host-side DeepSeek Harness plugin for Windows: when a turn finishes, or when the model asks you a question, it pops a real Windows 11 notification and plays a sound at the same time.
- Completion — the model handed control back and nothing else happened for a configurable quiet period.
- A question — the model called
ask_user_questionand is now blocked waiting for you, so this alert fires immediately instead of after a delay.
Notifications are ordinary Windows toasts owned by DSH's own Application User Model ID, so they land in the Action Center and follow your Focus Assist settings like any other app's notification. The sound is a WAV you control: drop files into three directories, or point a directory at a folder you already have.
Requirements
| OS | Windows 10 2004+ or Windows 11 |
| DSH | 0.2.0-rc.2 or newer (Host plugins with Config and session/event) |
| PowerShell | the in-box Windows PowerShell 5.1 (%SystemRoot%\System32\WindowsPowerShell\v1.0\) |
| Node.js | 20+ — only for the tests and the bundled tools, not to run the plugin |
The package name, the repository directory, the Loader row, the display name, and
the configuration page all use the same identifier — dsh-task-ask-notify —
so there is exactly one name to search for. The directory name is irrelevant to
the loader: file: installs are keyed by path, and everything DSH reads comes
from package.json.
Install
Installation is a bundle install: DSH writes the package into the active profile, registers the loader row, and hot-applies it. Do not hand-edit the profile.
1. Get the code onto the machine
git clone <this-repository-url> "%USERPROFILE%\.dsh\dsh-plugins\dsh-task-ask-notify"
Any directory works; the path below is only an example. There is no build step and
no npm install is required — DSH's own installer resolves the single dependency.
2. Install it into a profile
Ask the Agent in the profile you want the alerts in:
Install the bundle at
%USERPROFILE%\.dsh\dsh-plugins\dsh-task-ask-notifywithplugin_manager install_bundle.
Or, in the GUI: Settings → Plugins, add the package directory
%USERPROFILE%\.dsh\dsh-plugins\dsh-task-ask-notify as a local bundle and enable it.
plugin_manager reports application: "applied" when the change is live. If it
reports restart-required, restart DSH. Replacing an already-installed package
with new code also requires a restart, because the Host caches module instances.
3. Confirm it works
Start a short task and let it finish. One toast and one sound.
Or run the adapter's own self-test, which shows a toast and plays the bundled chime without involving DSH at all:
node tools/win-alert-selftest.mjsExit code
0means the adapter reported success. Note that exit code0is not proof the toast was visible — see Troubleshooting for how to check that from the system side.
Both signals must be true: the reminder appears, and the exit code is 0. If
nothing appears, the Troubleshooting section starts with the
one cause that fails silently.
Uninstall
plugin_manager remove_bundle dsh-task-ask-notify
Nothing is left behind inside the profile. Your configuration file (below) is not part of the install and is not removed; delete it if you want it gone too.
Sounds
The directory convention
The plugin looks for these three directories, by relative name:
| Directory | Played when |
|---|---|
audio/complete/ |
a turn finished |
audio/ask-single/ |
the model asked exactly one question |
audio/ask-multiple/ |
the model asked two or more questions |
One file from the matching directory is chosen uniformly at random, ordered by
name so a given choice is reproducible. .wav only. Adding, replacing, or removing
files takes effect on the next alert — no restart, because the listing is keyed to
the directory's modification time.
A relative path is looked up in two places, in this order:
- your audio folder —
%DSH_HOME%\plugin-data\dsh-task-ask-notify\— used when the path exists there and holds at least one.wav; - the package folder — the copy shipped inside the plugin.
Your folder comes first because an installed bundle runs from the profile's copy of
the package: a file added to your clone afterwards is not visible to it and would
otherwise need a reinstall. Because a relative path only wins when it actually
holds a sound, an empty folder never silently disables a scenario. An absolute
path in complete.dir, askSingle.dir, askMultiple.dir, or sound.fallback
bypasses this order entirely.
This repository ships exactly one sound file: audio/_default/chime.wav, a
two-tone chime (880 Hz then 1318.5 Hz) rendered by tools/gen-default-chime.mjs.
It is used whenever a scenario directory is missing or empty. The three scenario
directories exist but are empty by design, and their contents are ignored by git;
see Repository boundary.
Adding your own sounds
Two ways, both with no configuration required:
Import them. If you keep a pack laid out as <pack>/complete/,
<pack>/ask/single/, and <pack>/ask/multiple/, the bundled importer copies them
into your audio folder and verifies every copy with SHA-256:
node tools/import-audio.mjs --source "<path to your sound pack>"
node tools/import-audio.mjs --verify
It writes to %DSH_HOME%\plugin-data\dsh-task-ask-notify\audio\... by default, so
the next alert uses the new sounds without a reinstall. Use --target package if
you load the plugin in place from your clone instead. --dry-run reports what
would happen without writing. The importer never records where the files came from,
so a path from your machine cannot end up in a commit.
Or point at a directory you already have — see complete.dir,
askSingle.dir, and askMultiple.dir below. Absolute paths are supported, so
nothing needs to be copied at all.
Regenerating the default chime
node tools/gen-default-chime.mjs --check
The --check flag re-renders the file, reports the dominant frequency of each
tone, and fails if the committed WAV no longer matches what the generator
produces. That is deliberate: a binary asset nobody can reproduce is an
unreviewable blob. Tone parameters: 880 Hz for 0.18 s then 1318.5 Hz for 0.35 s,
each with a 20 ms exponential attack and an exponential decay to −80 dB. See
Credits.
Configuration
The configuration page in DSH
The plugin ships a browser half, so it is configured from the interface:
Plugins list → dsh-task-ask-notify — the form is rendered directly on the
bundle's page, and the same page is also reachable from the configure control on
the plugin's row. It edits the everyday settings: the master switch, the
completion alert and its quiet period, the single/multiple question alerts, sound
on/off, volume and the minimum gap, notification on/off, the question-in-body
option, and whether subagent sessions alert.
Saving takes effect immediately: a change is committed into the running plugin, so the next alert already uses it. No restart, no reload, no file editing.
Listen. Next to the volume number, one button plays a sound so the setting can
be judged by ear instead of guessed at. One click applies whatever is on the page
and plays one sound through the same path a real alert takes — same directories,
same uniform choice, same adapter, same volume — with the notification suppressed,
so what you hear is what you will hear. It draws its sound from a randomly chosen
scenario (complete, ask-single, ask-multiple), which means it plays one of the
sounds you imported, not a sample shipped with the plugin. It deliberately still
works when sound.enabled is off, since calibrating the volume before switching
sound on is a reasonable order to do things in.
Those fields are declared .volatile() in the exported schema, which is what
makes DSH offer a form for them at all and what lets an edit reach a running
plugin; the page writes through DSH's own settings service, so a refused or
conflicting write is reported rather than silently lost. Everything not on the
page — sound directories, notification wording, the AUMID — is changed in the
layers below, because those need a reload rather than a live update.
The file layers
Two layers, the later one winning per field:
the plugin row's
configincordis.patch.yml(validated by the Loader against the exportedConfigschema — this is the same schema DSH's own configuration surfaces project); andan optional JSON file you own, outside this repository:
%DSH_HOME%\plugin-data\dsh-task-ask-notify\config.json%DSH_HOME%is%USERPROFILE%\.dshby default. Override the whole path with theDSH_TASK_ASK_NOTIFY_CONFIGenvironment variable.
The JSON file is re-read whenever it changes, so you can correct a value while DSH is running. A file that is not valid JSON, or holds a value the schema rejects, is reported in the log and then ignored — the last good configuration stays in force, and the reason is printed with the offending field path. The JSON layer wins over the page and over the row, so a field you pin there is not editable from the interface.
Every field is optional; omitted fields fall back to the value in the table. To
turn every alert off without uninstalling, write {"enabled": false}.
| Field | Default | Meaning |
|---|---|---|
enabled |
true |
Master switch for all three scenarios. |
complete.enabled |
true |
Alert when a turn ends cleanly. |
complete.dir |
"audio/complete" |
Sound directory. Relative paths prefer your audio folder, then the package; absolute paths are used as given. |
complete.debounceMs |
2000 |
Quiet period after a clean turn end before alerting. Raise it (for example to 15000) if a multi-turn goal task should produce one alert instead of several; short tasks then wait that long too. |
askSingle.enabled |
true |
Alert when the model asks one question. |
askSingle.dir |
"audio/ask-single" |
Sound directory. |
askMultiple.enabled |
true |
Alert when the model asks two or more questions. |
askMultiple.dir |
"audio/ask-multiple" |
Sound directory. |
sound.enabled |
true |
Play a sound at all. |
sound.volume |
100 |
Playback volume on a 0–100 scale: 0 is silent, 100 is the file at full amplitude. A stored value in (0, 1] is read as the old 0–1 scale and multiplied by 100, so an existing setting keeps its loudness. |
sound.minGapMs |
400 |
Two alerts closer than this share one sound. Both still notify. |
sound.auditionAt |
0 |
Diagnostic, not a preference: the page's Listen button sets this to the time of the click, and the plugin plays one sound whenever it changes. |
sound.fallback |
"audio/_default/chime.wav" |
Used when a scenario directory is empty or missing. A directory here is also accepted. Resolved the same way as the scenario directories. |
sound.maxDurationMs |
10000 |
Hard cap on how long a playback process may live. |
notify.enabled |
true |
Show a Windows notification. |
notify.aumid |
"com.deepseek.dsh" |
The AUMID that owns the toast. Change it only if DSH's shortcut changes; see Troubleshooting. |
notify.silentSystemSound |
true |
Suppress the Windows notification sound, because the plugin plays its own file. |
notify.bodyMaxChars |
80 |
Longest notification body kept before truncation. |
notify.includeQuestionInBody |
false |
Append the first question to the notification body. Off by default: the body is the session title. |
dedup.completeMs |
3000 |
Repeat completion alerts for one session inside this window are dropped. |
sessions.includeSubagents |
false |
Also alert for subagent sessions. Off by default so a fan-out of background helpers does not produce a wall of notifications. |
messages.completeTitle |
"任务完成" |
Notification title for a finished turn. |
messages.askSingleTitle |
"需要你回复" |
Notification title for one question. |
messages.askMultipleTitleTemplate |
"有 {count} 个问题等你回复" |
Notification title for several; {count} is replaced. |
messages.fallbackBody |
"DeepSeek Harness" |
Body used when the session has neither a title nor a workspace directory. |
Example — quieter, English wording, and a directory you already keep sounds in:
{
"complete": { "debounceMs": 15000, "dir": "D:\\my-sounds\\done" },
"askSingle": { "dir": "D:\\my-sounds\\ask" },
"askMultiple": { "dir": "D:\\my-sounds\\ask-many" },
"sound": { "volume": 0.5 },
"messages": {
"completeTitle": "Task finished",
"askSingleTitle": "Your input is needed",
"askMultipleTitleTemplate": "{count} questions are waiting",
"fallbackBody": "Session"
}
}
The notification body is the session title, falling back to the name of the
session's workspace directory, then to messages.fallbackBody.
Troubleshooting
Nothing appears at all
The overwhelmingly likely cause is an AUMID that no Start Menu shortcut registers. Windows will accept the toast and report success while showing nothing, which is why the plugin checks this at startup and logs a warning. Verify it yourself:
# Which AUMIDs are registered on this machine?
$shell = New-Object -ComObject Shell.Application
$folder = $shell.NameSpace("$env:APPDATA\Microsoft\Windows\Start Menu\Programs")
$folder.Items() | ForEach-Object { "$($_.Name) -> $($folder.GetDetailsOf($_, 0))" }
# What has actually been delivered to the Action Center?
[void][Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime]
[Windows.UI.Notifications.ToastNotificationManager]::History.GetHistory('com.deepseek.dsh') |
ForEach-Object { $_.Content.GetXml() }
The history query is the check that counts: if your notification is listed there
with the right title and body, the delivery path works and the problem is
elsewhere. Notification.isSupported() and a Show() call that returns without
throwing prove nothing.
If the history is empty, confirm that a shortcut under
%APPDATA%\Microsoft\Windows\Start Menu\Programs carries
System.AppUserModel.ID = com.deepseek.dsh. DSH's own shortcut
(DeepSeek Harness.lnk) does this at install time. If your DSH was installed
differently, set notify.aumid to the AUMID your shortcut actually carries.
The adapter exits 0 but nothing is ever shown
Do not spawn the adapter with detached: true. A DETACHED_PROCESS has no
console, and in that state ToastNotificationManager.CreateToastNotifier(...) .Show(...) returns without throwing while Windows discards the notification — the
script reports success and the exit code is 0. This is measured on Windows 11
with the Action Center cleared between runs: every spawn variant using
detached: true delivered nothing, and every variant without it delivered. The
plugin therefore uses a plain child that is only unref()ed, which is enough to
keep it out of the Host's event loop; lib/os/win.js records the measurement and
test/os-win.test.mjs fails if detachment is reintroduced.
The toast shows but there is no sound
sound.enabledistrueandsound.volumeis above0.sound.minGapMs: a second alert within 400 ms of the first intentionally shares the first sound.- A playback process is already running; two sounds never overlap.
- The scenario directory is empty and
sound.fallbackcannot be played. Check the log forno playable WAV. - The file is not actually a WAV.
MediaPlayerreads the file's own headers; the plugin's duration calculation is only a hint.
Chinese text shows as mojibake
This is an encoding failure, and the plugin defends against it in two places: the
adapter script is stored as UTF-8 with a BOM, and all dynamic text reaches it
as a single base64(UTF-8) argument rather than being interpolated into a command
line. .gitattributes keeps the BOM intact for clones. If you see mojibake, your
copy of lib/os/win-alert.ps1 has lost its BOM — re-clone, or re-add it while
saving the file as "UTF-8 with BOM".
The alert arrives later than expected
A completion alert is deliberately debounced. It fires only after
complete.debounceMs of no further activity in that session, and any new turn, any
non-clean turn end, or any question cancels it. This is intentional: DSH's own
guidance is that agent/status must not be used as a completion signal, so the
plugin waits for quiet instead. Lower complete.debounceMs for a snappier feel.
The plugin does not load at all
Check the log for dsh-task-ask-notify. A Config that fails validation, or a
missing @deepseek-ai/schemastery dependency, stops apply() from running.
Do not "fix" the plugin by importing
electron. The DSH Host runs withELECTRON_RUN_AS_NODE=1, sorequire('electron')yields the executable path and nothing else — there is noNotification,app, orBrowserWindowin that process. That is exactly why this plugin talks to the in-box Windows PowerShell engine. PowerShell 7 (pwsh) also cannot project the WinRT toast types, which is why the interpreter is named explicitly aspowershell.exe5.1.
No alert for a background helper's work
Subagent sessions are excluded by default. Set sessions.includeSubagents to
true if you want one alert per helper as well.
Repository boundary
This repository is safe to publish as-is, and this section is the contract that makes that checkable rather than a claim.
What is committed
Source, tests, tooling, documentation, and metadata:
.gitattributes .gitignore LICENSE
README.md README.zh.md
package.json cordis.patch.yml
icon.svg locale/{en,zh}.json
lib/** the plugin itself
lib/client.js the browser half: the configuration page
lib/os/win-alert.ps1 the Windows adapter script
audio/_default/chime.wav generated, reproducible, the only committed sound
audio/{complete,ask-single,ask-multiple}/.gitkeep empty directory placeholders
tools/** chime generator, audio importer, self-test,
boundary checker, profile rollback script
test/** the test suite
What is deliberately not committed
| Kept out | Why |
|---|---|
DESIGN.md, docs/** |
Internal planning notes. They quote the absolute directories of the machine they were written on, so publishing them would leak that machine's layout. |
audio/complete/*.wav, audio/ask-single/*.wav, audio/ask-multiple/*.wav |
Imported sound files are personal media, licensed to whoever made them. tools/import-audio.mjs puts them in your audio folder; .gitignore keeps them out of commits. .audio-import.json (the hash record) is excluded for the same reason — it lists those file names. |
your audio folder and config.json |
Both live under %DSH_HOME%\plugin-data\dsh-task-ask-notify\, outside any clone, and are ignored here as a second line of defence. |
node_modules/, coverage/, dist/, build/ |
Reproducible from package.json; noise in a diff. |
package-lock.json, pnpm-lock.yaml, yarn.lock |
Installation is owned by DSH's install_bundle (pnpm). A second lockfile would describe a different resolver and drift. |
.env*, *.pem, *.key, .credentials.yaml, … |
Credentials and local environment. |
There are no secrets of any kind in this project: it stores no account, no token, and no API key, and it makes no network requests.
How to verify the boundary yourself
npm run verify-boundary # fails on machine paths, credentials, state, big files
npm run verify-audio # re-checks imported audio against its recorded hashes
verify-boundary reads the set of files git would publish (git ls-files),
not the working tree — untracked local files such as imported audio and your own
config.json are precisely what the boundary is meant to keep out. It reports
machine-specific absolute paths, credential-shaped text, runtime state, and
unexpectedly large files, and it exits non-zero on anything it finds. Run it
before every push.
The same check is what the layout above is designed to satisfy: a fresh clone contains no local absolute path, no personal media, and no runtime data, and the plugin works from that clone because the default chime is committed.
Development
npm install # the single runtime dependency, for the tests only
npm test # 79 tests, no network and no side effects
Nothing in the test suite writes into the repository: fixtures go to the system temporary directory. No test shows a notification or plays a sound — the two things that do are explicit, opt-in commands:
node tools/win-alert-selftest.mjs # one real toast and one real sound
node tools/win-alert-selftest.mjs --no-audio # toast only
node tools/gen-default-chime.mjs --check # re-render and verify the chime
Structure, and why it is split this way:
| File | Responsibility |
|---|---|
lib/index.js |
Entry point. Reads Config, subscribes to session/event, owns teardown. |
lib/config.js |
Schema, layered configuration, hot reload. |
lib/signals.js |
Events to scenarios. Pure: no clock, no filesystem, no OS. |
lib/dispatch.js |
Debounce, de-duplication, sound arbitration. Owns no OS knowledge. |
lib/policy.js |
Directory convention, uniform choice, WAV duration. |
lib/os/win.js, lib/os/win-alert.ps1 |
The only files that know Windows exists. Swapping the transport touches these two and nothing else. |
Known limitations
- Windows only. The delivery channel is a Windows toast plus a WAV playback process; there is no macOS or Linux adapter.
- Clicking the notification does nothing. DSH registers no URI protocol, so there is nowhere for the toast to navigate to. The notification carries no buttons and no deep link by design.
- No settings page inside the GUI. Configuration is the JSON file described above, which is re-read while DSH runs. The schema is exported, so DSH's own configuration surfaces can see and validate it.
- Completion is a quiet-period judgement, not a state. A multi-turn task whose
turns are more than
complete.debounceMsapart produces more than one alert. Raisecomplete.debounceMsto merge them; short tasks then wait that long. - A notification is fire-and-forget. No delivery receipt is available for a toast; the system-side history query above is the closest thing, and the plugin logs the adapter's exit code for its own half.
Credits
- The default chime reproduces the tone and envelope of the "run finished" sound
in the community plugin
@yangzhe1991/dsh-web-enhance(MIT), which synthesises it in the browser with Web Audio. A Host plugin has no Web Audio, so this repository renders the same two tones offline and commits the WAV. - Notification delivery follows the documented non-packaged Win32 desktop toast
path: an in-box PowerShell 5.1 child process calling
ToastNotificationManager.CreateToastNotifier(aumid)for the AUMID DSH already registers.
License
MIT © 2026 deadbushxw.
No comments yet. Be the first to write one.