본문으로 건너뛰기
Y

dsh-completion-alert

yimengqingfeng3-debug/dsh-completion-alert

DSH 工作完成提示:一轮工作结束播放「冰冰冰」提示音,并在右下角弹出可点击跳转的通知条;声音、音量、提示范围与自定义音源都在设置页里

설치

dsh plugin --profile web add github:yimengqingfeng3-debug/dsh-completion-alert

README

dsh-completion-alert

中文 | English

A dsh (DeepSeek Harness) client plugin that tells you when a round of work has finished:

  • plays a short alert tone — three ship by default: the "bing bing bing" meme tone plus two original synthesized crisp dings — the moment a session goes busy → idle;
  • raises a notice card in the bottom-right corner naming which session finished and how long it ran;
  • clicking the card opens that session in the main view;
  • every part of it is configurable in Settings: on/off, alert scope, sound on/off, volume, a tone library with instant preview, and a custom tone you can trim on its own waveform.

The tone is embedded in the client bundle, so the plugin needs no host route, no on-disk asset and no network access. The notice layer and the settings page use dsh's own theme tokens and slot system, so they match the desktop app's look.

a round of work ends  (session running: true -> false)
        │
        ├─ play the alert tone once (volume per settings)
        └─ notice card, bottom right:  "「Compress the icons」 finished · 1m 12s"
                                          └─ click -> uiWorkspace.openSession(id)

Install

From the registry (what the plugin manager does)

dsh-completion-alert

Paste that name into Settings → Built-in plugins → Install. The plugin manager runs pnpm add in the profile, records the dependency, and lists the package under dsh.profile.bundles.

From this repository (a checkout, no package manager)

git clone https://github.com/yimengqingfeng3-debug/dsh-completion-alert.git
cd dsh-completion-alert
powershell -NoProfile -ExecutionPolicy Bypass -File .\install.ps1            # desktop profile
powershell -NoProfile -ExecutionPolicy Bypass -File .\install.ps1 -Profile web

The installer is idempotent and backs up every file it edits:

  1. copies the package into <DSH_HOME>/profiles/<profile>/node_modules/dsh-completion-alert;
  2. appends the completion-alert insert to that profile's cordis.patch.yml (unless the profile already lists this package under dsh.profile.bundles, where the bundle's own patch inserts it — a duplicate row id is a hard boot failure);
  3. sets dsh.profile.patchReload = live when it is absent, so later edits recompose without an app restart.

Reload the dsh window once (Ctrl+R) so the browser fetches the new client bundle.

Manually

Copy lib/, assets/, package.json and cordis.patch.yml into the profile's node_modules/dsh-completion-alert, then add to the profile's cordis.patch.yml:

- insert:
    - id: completion-alert
      name: 'dsh-completion-alert'

Uninstall

Three ways out, all verified. None of them needs this plugin to cooperate, and none touches any other plugin.

1. The plugin manager's own button

Mount the plugin as a bundle first, or the button cannot work. The manager's uninstall disables the bundle, then checks that none of the bundle's rows is still loaded. A row that came from the profile's own cordis.patch.yml survives that step, so the removal fails with "other configurations are still using this bundle's components" (bundle-in-use) and the UI offers no way out.

install.ps1 used to append that manual row, which is what produced the failure. mount-as-bundle.ps1 moves an existing installation onto the bundle path:

# close dsh first: it holds the profile while it runs
powershell -NoProfile -ExecutionPolicy Bypass -File .\mount-as-bundle.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File .\mount-as-bundle.ps1 -WhatIf   # preview

It adds the package to dsh.profile.bundles, removes the manual insert row (the package's own cordis.patch.yml supplies it once it is bundle-listed, and a duplicate row id is a hard boot failure), and adds the release-age exemption for the mounted version. Backups end in .bak-bundle.

With the plugin bundle-mounted, the manager's button does the whole job: it unloads the plugin, drops the bundle listing and lets pnpm remove the package.

Settings → Built-in plugins → dsh-completion-alert → 卸载 / Uninstall. It removes the bundle listing and the patch row (so the plugin stops loading) and then asks pnpm to remove the package.

Known issue with that last step: the plugin manager drives the pnpm the app ships (11.7.0), and that version checks the profile against a 24-hour release-age quarantine. The check is cached for about a minute: a removal that follows a successful install usually passes, and one whose cache has expired fails with ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION. Because the quarantine is meant for newly published packages and this profile installs its own local builds, the reliable fix is to turn it off for the profile:

# <profile>/pnpm-workspace.yaml
minimumReleaseAge: 0

minimumReleaseAgeExclude alone is not enough: it is honoured during an install but not during the removal verification.

Note what the failure message does not mean: the plugin was still unloaded. Check dsh.profile.bundles and dependencies in the profile's package.json before assuming the removal did nothing.

Either way, the GUI's own state is correct after it returns. What can be left behind is the copy in node_modules and its entry in package.json / pnpm-lock.yaml — which is what the next two options clean up.

Close dsh, then:

powershell -NoProfile -ExecutionPolicy Bypass -File .\uninstall-all.ps1                 # desktop profile
powershell -NoProfile -ExecutionPolicy Bypass -File .\uninstall-all.ps1 -Profile web
powershell -NoProfile -ExecutionPolicy Bypass -File .\uninstall-all.ps1 -WhatIf        # show the plan, change nothing

It removes, and then verifies, every trace:

WhereWhat goes
node_modules/dsh-completion-alertthe package directory
cordis.patch.ymlevery completion-alert insert row (a failed install can leave more than one)
package.jsonthe dependency entry, if the plugin manager added one
pnpm-lock.yamlthe importer entry and the packages: / snapshots: blocks
pnpm-workspace.yamlthis package's minimumReleaseAgeExclude line

Every file it rewrites is backed up to <profile>\.completion-alert-backup\, it prints what it left alone by name — the balance plugin's directory, dependency, exemptions and lockfile entries are all reported as untouched — and it finishes with a "should be none" scan that lists any reference it failed to remove. Run with dsh closed: the app holds the profile while it runs, and a closing app can write the same files back.

3. By hand

Delete node_modules/dsh-completion-alert, remove its - insert: row from cordis.patch.yml, drop "dsh-completion-alert" from package.json (both dsh.profile.bundles and dependencies) and its entries from pnpm-lock.yaml. Leaving the lockfile entry behind is not fatal — pnpm reports "lockfile is not up to date" rather than doing the wrong thing — but the script above exists so you do not have to.

Verify it mounted

The host half serves a diagnostics route that the browser half reports its own activation to (mount facts only — no session content, no preferences):

GET http://127.0.0.1:<port>/api/completion-alert.diag
-> { "report": { "facts": { "watcher": true, "overlay": true, "settings": true } } }

watcher: true means completion detection is wired up; settings: true means preferences are bound to the host settings document. The loopback port is token-guarded, so read this from the app's own console or DevTools network tab rather than curl.


Settings

Settings → Completion alert, its own page beside General and Built-in plugins:

SettingMeaning
Completion alertMaster switch. Off means no tone and no notice
When to alertAll sessions: every session that finishes; Background only: stay quiet for the session you are looking at
Everything while backgroundedWith Background only selected: once the app is hidden or minimised, nothing is really "on screen", so any session finishing alerts you. The row shows the current state and is disabled while All sessions is selected
Play the toneMutes the sound only; the notice still appears
Volume0–100 %, applies to previews and alerts alike
Tone‹ current › steps through the tones and previews each step; the downward arrow opens the full library. Clicking the name replays it
All tonesEvery built-in tone with a preview button per row, then a Custom tone row that picks a local file
RepeatHow many times the tone plays per alert, 1 to 4. A repeat replays the slice you trimmed, and Stop cancels the ones still pending
Custom toneChoose an mp3 / wav / ogg, then trim it on its waveform and name it. Preview slice auditions exactly the range you selected, before anything is saved; the library's custom row also carries a rename button

A round stopped by hand is never announced. Only an outcome the Host reports as a completion (completed, blocked, max-tokens) raises a notice and a tone.

Preferences live in this plugin's own settings namespace (completion-alert) inside the profile's settings document, so they survive a restart and reach every open window. Writes go through ctx.configForms.get("completion-alert"), the same transport the shipped settings pages use.

The scope row says where the choices are going, so the three cases cannot be mistaken for each other:

ShownMeaning
saved in this profilewrites land in the profile's settings document
the deployment stores settings read-onlythe transport is up but refuses writes (a non-loopback page, for instance)
this deployment offers no settings storageno configForms and no settingsScope, so choices last for the window only

The last two are properties of the deployment, not of this plugin. In particular, configForms is provided by @deepseek-ai/dsh-client-ui-settings; a profile that disables that plugin (an enabled: false patch row) has no settings storage for any plugin, and this page will say so.


How it works

1. Completion detection reads uiSession.sessionStatus

No DOM scraping and no polling. The plugin subscribes to the client's own process-local session status projection — the same one the sidebar's status dots and the Stop shortcut's guard use:

status.subscribe(() => {
  // running: true -> false means one round of work just ended
});

That projection is fed by the host's api-session/status event (agent/status → status === "running"), so main-view, background and subagent sessions all report.

Two deliberate rules:

  • The first snapshot is a baseline, not a completion. A window opened while a session is already busy must not alert for work it never saw start.
  • Only the true → false edge counts. A round that ends waiting for approval, or that the user stops, also ends — and alerts like any other.

2. The tone: an embedded Ogg through Web Audio

The bundle carries a base64 Ogg payload in a marked chunk (//#region embedded-tone). It is decoded once with decodeAudioData and cached; at most one tone plays at a time, so a second completion cannot stack a second voice on top of the first.

Chromium refuses to start an AudioContext before the page has seen a user gesture, which is exactly the state a freshly loaded window is in. Instead of dropping that first alert, the plugin registers a one-shot gesture unlock and replays the pending tone when it fires, and shows a small "click anywhere to enable the alert sound" pill in the corner meanwhile. Once the window has been touched, the pill never comes back.

3. The notice and the navigation

The notice layer registers into shell.overlay — the frame's own overlay slot (position: absolute; inset: 0) — and the cards position themselves fixed in the bottom-right corner, with pointer events enabled on the card alone so the layer never blocks the UI.

Clicking a card calls uiWorkspace.openSession(sessionId), the same entry point the session browser and the fork action use. Hovering pauses the auto-dismiss timer, and the × dismisses it.

4. Card colours

The settings page rides the theme's --dsw-alias-* variables throughout. The corner card additionally probes the page's own background colour once (those variables do not resolve inside the overlay layer) for both light and dark modes, so the card blends with whatever skin is active instead of reading as a white block dropped on top.


The tones

Three tones ship with the plugin. Two of them are original additive synthesis — one clean bell-like ding, the character a system notification has — so nothing is sampled from another product and the package can carry them legally:

ToneSourceNotes
冰冰冰 (bingbingbing)assets/bingbingbing.oggthe meme tone, cut to one 1.06 s round, 12 642 bytes
Crisp (crisp-a)assets/crisp-a.ogg, synthesizedtwo ascending notes (F#6 -> F#7), the payment-confirmation shape, 0.50 s, 6 477 bytes
Crisp short (crisp-b)assets/crisp-b.ogg, synthesizedthree ascending marimba notes (D4 -> A4 -> D5), the message-alert shape, 0.58 s, 7 068 bytes

A tone for this machine only

A recording you have no right to redistribute still belongs in your own alert list. tools/use-local-tone.ps1 does exactly that:

powershell -NoProfile -ExecutionPolicy Bypass -File tools\use-local-tone.ps1 `
  -Id haqi -Label "哈气" -Hint "猫哈气" -Source C:\clips\haqi.wav -Recording `
  -Trim 2.30 2.72

It converts the file to Ogg, registers it in tools/tones.local.json, and bakes it into the bundle, which tools/embed-tones.ps1 merges on top of the shipped registry. The tone then behaves like a built-in one. What it does not do is leave this machine: tools/tones.local.json and assets/*.local.ogg are gitignored, and npm run prepublishOnly refuses to publish a bundle that carries one — this checkout is mounted in place, so its bundle does carry them.

Adding a tone to the package

tools/tones.json is the only place a shipped tone is declared:

  1. put an Ogg in assets/;
  2. add a row to tools/tones.json - id, label, hint, source, and kind (synth for generated work, recording for third-party material, which NOTICE must then document);
  3. powershell -NoProfile -ExecutionPolicy Bypass -File tools\embed-tones.ps1;
  4. bump the version and reload the window.

lib/client.js builds its library from TONE_DEFINITIONS in the generated block, so no code edit is needed, and both drift checks (tools/check-embedded-tone.mjs and the PowerShell -Check) read that same registry.

The synthesiser is tools/synthesize_tones.py. Each note is a stack of decaying partials plus a very short band-limited noise burst at the onset (what makes a bell read as crisp), summed into a track with hand-placed onsets and mixed down. Two details are worth keeping if you edit it:

  • Struck notes decay in dB, not in linear amplitude. A linear exp(-t / tau) stays near its peak for the first tau, so a short note reads as a swell out of silence instead of a strike. Every note here falls as 10 ** (-3 * t / tau), which puts the peak at the attack — the same mistake produced an audible "crescendo" on the last note during development, and it is visible in a rendered envelope immediately.

  • Never shape a whole track that is built from struck notes. A note already decays to silence, so any window applied across the track lands on a note's attack and turns that strike into a swell. Only the last 25 ms is faded, to avoid ending on a step.

  • A marimba's upper partials decay faster than its fundamental, which is why the strike of crisp-b reads a fifth above and settles onto the fundamental. The 3rd and 5th partials are the loudest of the stack.

Rebuild the assets with

python tools/synthesize_tones.py assets            # WAV masters
python tools/synthesize_tones.py assets <ffmpeg>   # WAV + the Ogg the plugin embeds

and re-embed them into the bundle's marked //#region embedded-tones block with

powershell -NoProfile -ExecutionPolicy Bypass -File .\tools\embed-tones.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File .\tools\embed-tones.ps1 -Check   # fails when they drift
powershell -NoProfile -ExecutionPolicy Bypass -File .\install.ps1

The script validates the OggS magic on every asset before writing. Ogg is required: Chromium's decodeAudioData does not decode mp3, and embedding one would be pointless. Cross-platform equivalent: node tools/check-embedded-tone.mjs.

Custom tones, trimmed in place

The settings offer a Custom tone entry. Picking a local file decodes it, draws its waveform, and opens a trim dialog: drag the start and end handles, audition exactly that slice, then save. Only the selected range is encoded (16-bit PCM WAV, the one container this plugin can write without an encoder) and stored in the settings document; 3 s or less is recommended, and anything over ~2 MB is refused rather than silently truncated.

See NOTICE for the meme tone's provenance. The synthesized tones carry no such caveat, and an end user can always upload their own instead.


Project layout

dsh-completion-alert/
├─ package.json             dsh.pluginType=client, dsh.client.inject, bundle patch
├─ cordis.patch.yml         inserts the completion-alert row into a profile
├─ install.ps1              install (backups, patchReload=live, idempotent); -Uninstall too
├─ uninstall-all.ps1        removes every trace from a profile, leaving other plugins alone
├─ assets/                  the tone sources the embedder reads
│  ├─ bingbingbing.ogg      the meme tone (see NOTICE)
│  ├─ crisp-a.ogg/.wav      synthesized: one bright bell
│  └─ crisp-b.ogg/.wav      synthesized: the same, shorter and higher
├─ lib/
│  ├─ index.js              host half: the volatile settings schema + diagnostics route
│  ├─ client.js             browser half: detector, player, notice layer, settings page, inline tones

├─ tools/
│  ├─ synthesize_tones.py   renders the crisp tones from scratch (numpy)
│  ├─ embed-tones.ps1       re-embeds assets/ into lib/client.js (-Check for drift)
│  ├─ embed-audio.ps1       shim that forwards to embed-tones.ps1
│  └─ check-embedded-tone.mjs  drift + Ogg-magic check (cross-platform, CI)
└─ test/
   ├─ host.test.mjs         schema surface, volatile marker, diagnostics route
   ├─ client.test.mjs       tone library, settings coercion, completion edges, persistence
   └─ loader.mjs / -hooks   resolves the schemastery peer dependency for the tests

Development

npm install          # pulls the schemastery peer dependency the host half imports
npm test             # 45 tests
node tools/check-embedded-tone.mjs

The test suite is behavioural rather than structural: the client tests load the real bundle into a vm sandbox with a stub React, a fake AudioContext and a fake dsh client context, then drive the stores to assert the things that decide behaviour — the tone library and its payloads against the packaged assets, the first-snapshot baseline, the busy → idle edge, the background only scope, debounced persistence into the plugin's own namespace, navigation through uiWorkspace, and the notice queue. The host tests validate the schema surface (including that a volatile node sits at a fixed path with no volatile field inside it, which the app rejects) and the diagnostics route round-trip.

CI (.github/workflows/test.yml) runs both plus the drift check on Node 24.

Troubleshooting

SymptomWhat to do
Nothing happens at allReload the dsh window (Ctrl+R); check that POST /api/completion-alert.diag reports watcher: true
Notices appear but there is no soundCheck Play the tone and the volume; if the "click anywhere to enable the alert sound" pill is up, click the window once
A preview button does nothingThe player now reports why (muted, no-audio-context, decode-failed, start-failed, awaiting-gesture) — look for a dsh-completion-alert line in the renderer console
The app will not start after installingA client bundle that requires something the module loader cannot resolve fails the whole web boot. Boot with Disable third-party plugins from the crash dialog, then send the newest %APPDATA%\@deepseek-ai\dsh-desktop\logs\crash-*-web-boot.log
The uninstall button reports failureIt already unloaded the plugin; run uninstall-all.ps1 with dsh closed to remove the leftovers — see Uninstall
A custom tone is refusedOver ~2 MB, or not decodable by the browser. Trim it shorter in the dialog

Known limitations

  • Custom tones are stored as WAV. The trim dialog writes 16-bit PCM because that is the only container the browser can encode without a library; 3 s or less keeps the settings document small.
  • Ogg only for the built-in replace path. Custom uploads accept mp3/wav/ogg for decoding (Chromium decodes mp3 fine), but a payload embedded at build time must be Ogg.
  • One tone at a time. A completion arriving while the previous tone still rings replaces it rather than mixing.
  • No OS-level notifications. The notice is a dsh overlay card, so the plugin needs no Electron notification permission and stays consistent across web and desktop builds.
  • One of the three tones is meme material. The crisp tones are original synthesis modelled on measured facts about two well-known notification sounds (see NOTICE); redistribute the meme tone only under the terms in NOTICE.

License

MIT for the code — see LICENSE. The bundled audio clip is third-party material; read NOTICE before redistributing.

관련 플러그인