Sends desktop notifications when a DSH turn finishes or fails, and sends 继续 automatically after the model-retry budget is exhausted.
Install
# from GitHub (first run asks for allowBuilds approval — follow the hint, retry)
dsh plugin --profile web add github:lizhijie429/dsh-task-notifier
Any plugin you install runs third-party code with your own permissions — it can read your files, use your credentials, and reach the network, and tool approvals don’t sandbox it. GitHub-sourced plugins also run build scripts at install time — pnpm blocks those until you allow them, so an install can stop with ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED or ERR_PNPM_IGNORED_BUILDS; dsh prints the exact key to add under allowBuilds in your profile’s pnpm-workspace.yaml, and the install works on the next run. Allowing a build is a trust decision: only install sources you trust, and pin a commit (github:owner/repo#sha).
README
A DSH (DeepSeek Harness) plugin that watches task execution (one task = one turn), raises a OS-level notification when the task finishes or fails, and — when the failure is caused by an exhausted model-request retry budget — automatically sends 继续 ("continue") to the session so the agent keeps working.
- npm package:
@lizhijie429/dsh-task-notifier; source: https://github.com/lizhijie429/dsh-task-notifier. - Zero runtime dependencies, plain ESM, single directory. Mount it by absolute path — no build or install step.
- Notification channels: Windows Toast (PowerShell 5.1 / WinRT), macOS
osascript, Linuxnotify-send, or plain logging. - Windows toasts are branded as DSH (registers the
DSH.TaskNotifierAUMID), no longer "Windows PowerShell". - Task state is derived only from durable session events (
turn/start,llm/retry,turn/end) — no guessing from live status.
Directory layout
dsh-task-notifier/
├── index.mjs # Cordis plugin entry: logging, config, observer, event wiring
├── package.json # dsh.bundle.patch points at the bundle patch (bundle installs)
├── cordis.patch.yml # bundle patch: inserts the default task-notifier row
├── lib/
│ ├── config.mjs # defaults, validation, normalization (fallback + warning)
│ ├── format.mjs # event classification, failure codes, notification copy, retry-exhaustion check
│ ├── notify.mjs # channel selection, command execution, AppleScript builder
│ ├── win-appid.mjs # Windows notification identity (AUMID) registration + icon lookup
│ ├── win-toast.ps1 # Windows Toast (WinRT) with tray-balloon fallback
│ ├── file-log.mjs # JSONL debug log (256 KB rotation)
│ └── observer.mjs # per-session turn state machine + auto-continue
├── assets/
│ ├── tray.ico # notification identity icon (shell icon)
│ └── icon.png # appLogoOverride image inside the toast
└── test/
├── config.test.mjs
├── format.test.mjs
├── notify.test.mjs
├── observer.test.mjs
└── e2e/ # local end-to-end patches and scripts (not part of unit tests)
Installation
Option 1: mount into the desktop profile (recommended for the current desktop app)
The desktop profile is owned by the Electron app, so dsh plugin / --dump-config reject it. Edit the user patch layer $env:USERPROFILE\.dsh\profiles\desktop\cordis.patch.yml directly (replace <user> with your Windows user name) and append:
- insert:
- id: task-notifier
name: 'C:\Users\<user>\.dsh\plugins\dsh-task-notifier\index.mjs'
config:
channel: auto
logFile: 'C:\Users\<user>\.dsh\plugins\dsh-task-notifier\state\task-notifier.log'
notifyOnStart: false
notifyOnComplete: true
notifyOnError: true
notifyOnAbort: false
notifyOnStep: false
notifySubagents: false
titlePrefix: DSH
maxBodyChars: 240
autoContinue:
enabled: true
message: 继续
maxAttempts: 3
cooldownMs: 5000
requireRetryExhausted: true
namemust be an absolute path (or a file URL). A package name also works, but the package must be a dependency of the profile.- After saving: the
desktopprofile'spatchReloaddefaults tolive, so the running app usually hot-loads the plugin. If not, restart DeepSeek Harness. - Verify:
state/task-notifier.logcontainsplugin.mounted.
You can also override config for the same id in your own profile (a patch replaces that row's config as a whole; omitted fields fall back to plugin defaults).
Option 2: install as a bundle into a regular profile (web / headless / custom)
After the npm publish (see "Publishing" at the end):
dsh plugin --profile web add "@lizhijie429/dsh-task-notifier"
Install straight from GitHub (no npm publish needed; the plugin has no build step):
dsh plugin --profile web add "github:lizhijie429/dsh-task-notifier"
Install from a local checkout:
cd "$env:USERPROFILE\.dsh\plugins\dsh-task-notifier"
dsh plugin --profile web add "file:$PWD"
dsh.bundle.patch in package.json makes the profile load the defaults from cordis.patch.yml (id task-notifier); engines.dsh requires DSH >=0.2.1.
Option 3: ad-hoc verification (touches no profile)
$pluginDir = "$env:USERPROFILE\.dsh\plugins\dsh-task-notifier"
$patch = @"
- insert:
- id: task-notifier
name: '$pluginDir\index.mjs'
config:
channel: log
logFile: '$pluginDir\state\e2e.log'
autoContinue: { enabled: true, message: 继续, maxAttempts: 2, cooldownMs: 0 }
"@
Set-Content -Path "$env:TEMP\task-notifier.patch.yml" -Value $patch -Encoding UTF8
dsh headless --patch "$env:TEMP\task-notifier.patch.yml" "只回复 OK,不要调用工具。"
Configuration
| Field | Default | Description |
|---|---|---|
notifyOnStart |
false |
Notify on turn/start (noisy; off by default) |
notifyOnComplete |
true |
Notify when turn/end has reason.kind = completed / max-tokens |
notifyOnError |
true |
Notify on error / blocked / interrupted |
notifyOnAbort |
false |
Notify when the user aborts (aborted) |
notifyOnStep |
false |
Notify on every step/start (debugging) |
notifySubagents |
false |
Notify subagent sessions too (main session only by default) |
channel |
auto |
auto / windows / macos / linux / log / none |
titlePrefix |
DSH |
Notification title prefix |
windowsAppId |
DSH.TaskNotifier |
AppUserModelID for Windows toasts (app name/icon in the toast) |
windowsAppName |
DSH |
App display name for Windows toasts |
maxBodyChars |
240 |
Body truncation length (60–2000) |
logFile |
<plugin dir>/state/task-notifier.log |
JSONL debug log; rotates at 256 KB to .1 |
autoContinue.enabled |
true |
Allow automatic continuation |
autoContinue.message |
继续 |
User message sent automatically |
autoContinue.maxAttempts |
3 |
Max continuations per task (hard cap 50) |
autoContinue.cooldownMs |
5000 |
Min gap between two continuations; 0 = immediate |
autoContinue.requireRetryExhausted |
true |
Only continue on exhausted model-retry budgets |
autoContinue.retryableCodes |
EMPTY_RESPONSE,RATE_LIMIT,SERVER,TIMEOUT,TRANSPORT |
Failure codes treated as retryable |
Invalid configuration never throws: each field falls back to its default and the warnings are summarized in the plugin.mounted log entry.
Decision logic
- Task = one
turn: multi-step tool calls are still one turn; exactly one notification is emitted onturn/end. - State classification (
reason.kind):completed→ ✅ task finished (duration, steps, last assistant text preview)error→ ❌ task failed (failure code/message)aborted→ ⏹️ task cancelled (optional; cancellation cause)blocked→ ⛔ task blockedmax-tokens→ ⚠️ task hit the output limit
- "Exhausted model-request retries" test (
lib/format.mjs#isRetryExhaustedFailure):- the failure must be an
errorwhose code is inretryableCodes; - the turn must contain
llm/retryevents and the last one must satisfyretry >= maxRetries(normal-mode budget exhausted); - with no
llm/retryevents the first request failed outright (maxRetries: 0or a transient failure), which also counts as exhausted; alwaysmode never "exhausts", so it never triggers auto-continue;- with
requireRetryExhausted: falseeveryerrorattempts a continuation (more aggressive; not the default).
- the failure must be an
- Where continuation is admitted: on the
agent/statusidleedge, viaagent.followup()— not inside theturn/endlistener.- Reason:
Session.append()forbids re-entrant appends whileturn/endis being published;followupwrites inbox events, so calling it directly throws. - The
idleedge is also what the built-in goal-round driver uses. Enqueuing there keepsagent.whenIdle()unresolved until the continuation turn runs, so headless runs also finish the continuation turn.
- Reason:
- Message source: automatic messages use
source = { kind: 'task-notifier', plugin: 'task-notifier' }. Persisted format v4 explicitly rejectskind: 'plugin'(format v4 message requires a producer-owned source kind), so the plugin uses its own producer kind.
Safety rails
maxAttemptsdefaults to 3, hard cap 50.cooldownMsdefaults to 5 s, preventing a tight "fail → continue → fail" loop.- Continuation is skipped (and recorded in the
auto-continue.skippedlog) when: the plugin is disabled, the failure is not retry exhaustion, attempts are used up, the agent left the registry, or the inbox already holds user input. - A new human message or a successful
completedturn resets the continuation budget. - Per-session state is cleaned up on
session/disposedor plugin unload; timers carry a generation check to ignore stale callbacks. - Subagent sessions are silent by default (
notifySubagents: false).
Notification channels
| Platform | Implementation | Notes |
|---|---|---|
| Windows | powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File lib/win-toast.ps1, WinRT Toast first, tray balloon fallback |
Title/body travel via the DSH_NOTIFY_TITLE / DSH_NOTIFY_BODY environment variables, avoiding command-line escaping. The notification identity is DSH: on mount the plugin registers DisplayName + IconUri (icon = assets/tray.ico) under HKCU\Software\Classes\AppUserModelId\DSH.TaskNotifier, so the toast header shows the DSH icon/name. If registration fails or an older mount passes no AUMID, the script probes that registry key and only then falls back to the Windows PowerShell identity. Focus Assist/notification settings may mute it. |
| macOS | osascript -e 'display notification …' |
Scripts must be allowed to send notifications |
| Linux | notify-send title body |
Requires a desktop notification daemon |
| log / none | Log only / fully silent | For debugging or CI |
Notifications are best-effort: a failure never affects task execution; it is only recorded as notification.failed/notification.log and in the DSH log.
Debug log
Default: <plugin dir>/state/task-notifier.log (JSONL, UTF-8, 256 KB rotation). Key events:
plugin.mounted/plugin.unmounted: mount info, final config, invalid-field warningsnotification.dispatch/notification.sent/notification.failed/notification.logauto-continue.pending/.sent/.skipped/.cooldown/.errorobserver.error: event-handling exceptions (never break the main flow)
Read UTF-8 directly in PowerShell: Get-Content <log> -Encoding UTF8.
Tests
cd "$env:USERPROFILE\.dsh\plugins\dsh-task-notifier"
node --test test/config.test.mjs test/format.test.mjs test/observer.test.mjs test/notify.test.mjs test/win-appid.test.mjs
All 49 cases currently pass (config normalization, notification copy, retry-exhaustion detection, the auto-continue state machine, channel selection, AppleScript escaping, command execution, AUMID registration).
End-to-end verification (performed on this machine)
- Success path:
test/e2e/e2e-ok.patch.yml(OpenAI-compatible provider, key via thePROVIDER_API_KEYenvironment variable,channel: windows) → headless returnsexit=0; the log showsnotification.dispatch(start/complete) andnotification.sent ... TOAST_OK. - Retry exhaustion + auto-continue:
test/e2e/e2e-continue.patch.yml(unreachable baseURL +maxRetries: 2) → after failures in turns 1/2 the plugin sends继续each time, then stops when turn 3 reachesmaxAttempts: 2; the persisted session contains twouser/message source={"kind":"task-notifier","plugin":"task-notifier"} text="继续"entries and headless finally exits withTRANSPORT(exit=1, as expected). - Windows icon branding: after registering
DSH.TaskNotifier, invokingwin-toast.ps1directly printsTOAST_OKand the toast header shows the DSH icon and the "DSH" name; withoutDSH_NOTIFY_AUMIDthe script self-detects the registry key and still works.
test/e2e/dump-session.cjs decompresses a session for inspection: node test/e2e/dump-session.cjs <session.v4.jsonl.zstd>.
Market index
DSH has no official plugin registry: the Discover page only reads index sources the user adds (off by default; enable Custom plugin indexes under Plugins → Experimental plugins). This repository publishes a format-1 index under docs/:
- Index URL (after GitHub Pages is enabled): https://lizhijie429.github.io/dsh-task-notifier/index.json
- Regenerate:
npm run market:index(pins the HEAD commit and commit time, copies the icon intodocs/icons/) - Hosting: repo Settings → Pages → Deploy from a branch →
main//docs - Subscribe: Plugins page → Experimental plugins → enable Custom plugin indexes → Discover → Index sources → add the URL → search
dsh-task-notifierand install - The index lists a Git source; the install spec is
git+https://github.com/lizhijie429/dsh-task-notifier.git#<commit>. Re-runnpm run market:indexafter each release and commitdocs/ - Validated with the official
@deepseek-ai/dsh-plugin-market-indexvalidator (parseMarketIndex+marketInstallTarget) - Community listings (PlugBox, awesome-dsh-plugin) are optional; DSH itself does not collect third-party indexes
Known limitations
- Windows Toast depends on PowerShell 5.1 and system notification settings; on enterprise images that disable notifications only the log is written.
- Windows 10 (build < 22000) requires a Start-menu shortcut for an unpackaged app's AUMID. If toasts still show "Windows PowerShell", create a Start-menu shortcut pointing at this plugin's
assets\tray.ico, or accept the fallback identity (the notification itself still works). Windows 11 works with the HKCU registration alone (verified on this machine). - Notifications are asynchronous fire-and-forget; delivery is not guaranteed before the process exits (very short headless tasks may only show
notification.dispatch). channel: windowsonly works on Windows; useautoorlogelsewhere.- Auto-continue only handles "model-request retries exhausted" failures;
AUTH,UNKNOWN, user aborts, etc. never continue. - If the user types right after a failure, the plugin detects pending user input and skips the continuation (intentional protection; this can look like "no auto-continue"). Background-job notices, time-context snapshots and other system messages do not block it.
- The
desktopprofile relies on the Electron app to run plugins; thedsh pluginCLI does not apply to it. - The running desktop app does not hot-reload an already loaded ESM module; restart DeepSeek Harness once for code changes to take effect (the icon branding already works with older mounts via the
win-toast.ps1self-detection).
Links
More in this category
xmanrui/dsh-im★ 3118
Connect IM bots to DeepSeek Harness via QR codes or bot credentials (9 channels: Feishu, WeChat, DingTalk, WeCom, QQ, Slack, Telegram, Discord, and WhatsApp).
shaobeichen/dsh-pocket★ 2685
Remote phone access to the DSH Web UI: scan a QR code for LAN or public (cloudflared tunnel) access with real-time sync, a mobile-adaptive layout, and a settings tab.
OpenPetsHQ/openpets#dsh★ 1373
Bridges DeepSeek Harness lifecycle status, errors, and approval requests to a locally running OpenPets desktop companion.
c1overzz/dsh-bili-scroll★ 1150
Let your DSH agent browse Bilibili on its own, read subtitles, write a daily digest, and @ you when it finds something worth your attention.
inclusionAI/Avernet#deepseek-harness-channel-bcn★ 599
Connects DeepSeek Harness to Avernet's Bot Collaboration Network over WebSocket V2, with automatic onboarding, isolated agent sessions, tool-call events, and multi-bot routing tools.
zaofan-make/dsh-qqbot★ 213
QQ official bot channel for DeepSeek Harness (feature-rich fork of tencent-connect/dsh-qqbot): group and private chat with the full agent, sticker library with auto-tagging and AI meme replies, group management (join approval and mute), botplay interactive card events with paginated catalogs, user extension registry (custom slash commands and QQ tools), scheduled wakeups, remote approvals over QQ, and a floating dock web UI.
Community comments
Comments are public GitHub Discussions. Loading them connects to GitHub and Giscus; a GitHub account is required to post.