Bundle
dsh-notify-on-complete
Desktop notifications for DeepSeek Harness (dsh): run completion, model questions, and approval requests. Zero runtime dependencies — all peers provided by the dsh CLI.
- Source
- pitetow
- stars
- 5 stars
- License
- MIT
- Updated
- Updated 3 days ago
Readme
# dsh-notify-on-complete — Desktop Notifications for DeepSeek Harness · DeepSeek Harness 桌面通知插件
<p align="center"><img src="assets/notify-cover.jpg" alt="dsh-notify-on-complete — 运行结束通知与系统提示音" width="640"></p>
Send desktop notifications from [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (`dsh`): get a system notification when a run finishes, and an immediate one when the model asks you a question (`ask_user_question`) or waits for approval (sandbox escalation / tool permission). The body reflects the result (completed / error / aborted / max-tokens).
DeepSeek Harness(`dsh`)桌面通知插件:运行结束时向系统发送桌面通知;会话进行中模型提问(`ask_user_question`)或等待审批(沙箱提权 / 工具权限)时也会即时提醒你回来处理。正文按结果区分(成功 / 失败 / 中止 / 达到 token 上限)。
> Author: [Luozy](https://github.com/pitetow) · License: [MIT](LICENSE) · 中文文档:[README.zh.md](README.zh.md)
- **Zero runtime dependencies**: no `dsh` internal packages, no `ctx.shell`. Notifications are fired via `child_process.spawn` as a detached child process — **non-blocking and unaffected by the harness exit path**.
- **Cross-platform**: the notifier command is picked from `process.platform` (macOS `osascript` / Linux `notify-send` → `kdialog` / Windows PowerShell). Unsupported platforms are skipped at load with a warning, never throwing per-event.
- **System sound**: macOS system sound (`sound name "Glass"`), Windows .NET `SystemSounds`, Linux `canberra-gtk-play` (falls back to `paplay`); disable with `sound: false`.
- **In-session blocking notifications**: fires immediately when the model calls `ask_user_question`, or when a sandbox escalation / tool permission waits for approval — so you know to come back. Controlled by `onBlocked` / `onQuestion` / `onApproval`.
- **Top-level runs only**: subagent sessions are filtered out (`header.origin === 'subagent'`), so a single CLI run produces a single notification.
## 功能特性(中文)
- **零运行时依赖**:不依赖 dsh 内部包,通知用 `child_process.spawn` 以 detached 子进程发出,不阻塞、不受 harness 退出影响。
- **跨平台**:macOS `osascript` / Linux `notify-send` → `kdialog` / Windows PowerShell 自动选择;不支持的平台加载时跳过并警告。
- **系统提示音**:macOS `sound name "Glass"` / Windows SystemSounds / Linux `canberra-gtk-play`(回退 `paplay`);可用 `sound: false` 关闭。
- **会话中阻塞即时通知**:模型提问或等待审批时立即提醒你回来;可用 `onBlocked` / `onQuestion` / `onApproval` 精细控制。
- **只通知顶层运行**:过滤子代理(`header.origin === 'subagent'`),一次 CLI 运行只弹一条。
完整中文文档见 [README.zh.md](README.zh.md)。
## How it works
The plugin listens to two events to decide when "a run has ended":
1. **`session/event` → `turn/end`**: records the latest `reason.kind` of a root session (`origin !== 'subagent'`). A run can span many turns (goal rounds, follow-ups, steering), each with its own `turn/end`; the plugin only remembers the **last** one.
2. **`agent/status` → `'idle'`**: the harness's own "run ended" signal (the web UI's running indicator and `agent.whenIdle()` both derive from it). When the root agent returns to idle, the whole activity has converged, so the plugin sends the recorded final result once and clears it.
So **one notification per complete run**, not per turn: a multi-round goal run fires once at the end, with the final result; intermediate "task completed" moments never fire early. Body format: `result — session title (session: sessionId)`, e.g. `任务已完成 — 修复登录bug (session: 3f9a…)`; if the title hasn't been generated yet it degrades to `result (session: sessionId)`. The title comes from the last `session/title` event in the session log — an async projection, so very early notifications (e.g. a question right at session start) may not have one yet. Notification commands run with `detached: true` + `unref()`, so a normal exit or crash never affects delivery.
| `reason.kind` | Notification body |
|---|---|
| `completed` | 任务已完成 (Task completed) |
| `error` | 任务失败 (Task failed) |
| `aborted` | 任务已中止 (Task aborted) |
| `max-tokens` | 任务达到 token 上限 (Task hit the token limit) |
| other (unknown) | 任务结束 (Task ended) |
## Requirements
- Node.js ^22 (same as DeepSeek Harness)
- An installed `dsh` CLI (any version — the plugin registers via Cordis events and does not depend on a specific CLI version)
- Peer dependency `@deepseek-ai/cordis@^4.0.1` (provided by the `dsh` CLI itself; pnpm resolves it automatically on install)
---
## Installation (one-liner, GitHub source distribution, no npm)
**Prerequisite**: DSH installed (`dsh web` runs), Node.js ^22 + pnpm.
**macOS / Linux / Windows (Git Bash or WSL)**:
```bash
curl -fsSL https://raw.githubusercontent.com/pitetow/dsh-notify-on-complete/main/scripts/install.sh | bash
```
Other profiles (default `web`):
```bash
curl -fsSL https://raw.githubusercontent.com/pitetow/dsh-notify-on-complete/main/scripts/install.sh | bash -s -- --profile headless
```
The script does 4 things (all idempotent, safe to re-run):
1. Downloads the source to `~/.dsh/plugins/dsh-notify-on-complete/` (skips if it exists — **never overwrites**; add `--force` to overwrite/update, which asks for confirmation first, or `--yes` to skip it);
2. Runs `pnpm install && pnpm build`;
3. Runs `dsh plugin --profile <name> add link:<dir>`: the CLI reads the package's `dsh.bundle.patch` declaration (`cordis.patch.yml`) and **auto-registers it into the profile's bundle stack**, so it mounts on the next start — no manual config file edits;
4. Idempotently removes any leftover manual mount lines to avoid double-mounting (two notifications per run).
`curl | bash` runs remote code — the script is open source (`scripts/install.sh`); download and review it first if you like.
### Verify
```bash
dsh --profile web --dump-config | grep -n notify-on-complete
```
Seeing `- id: notify-on-complete` followed by `name: dsh-notify-on-complete` means the plugin is in the composed tree. Run a real task and watch for a desktop notification to confirm.
Restart to take effect:
- **CLI one-shot runs**: the next `dsh --profile headless "task"` just works, no extra step.
- **Web GUI**: restart the web process (stop the current `dsh web`, then start it again). If HMR is enabled, saving files also picks it up automatically.
### Update
```bash
curl -fsSL https://raw.githubusercontent.com/pitetow/dsh-notify-on-complete/main/scripts/install.sh | bash -s -- --force
```
> `--force` deletes and re-downloads the source (local edits in that directory are lost) and **asks for confirmation first**; add `--yes` to skip it:
> `bash -s -- --force --yes`
Or manually: `cd ~/.dsh/plugins/dsh-notify-on-complete && git pull && pnpm install && pnpm run build`, then re-run `dsh plugin --profile web add link:.`.
### Uninstall
```bash
dsh plugin --profile web remove dsh-notify-on-complete
rm -rf ~/.dsh/plugins/dsh-notify-on-complete
```
Then restart dsh.
<details>
<summary><b>Manual install (from source / local development — alternative to the one-liner)</b></summary>
Point the dependency at local source (`link:` is a symlink, so rebuild after edits; good for debugging):
```bash
cd /path/to/dsh-notify-on-complete
pnpm install
pnpm run build # emits to lib/
dsh plugin --profile web add link:/path/to/dsh-notify-on-complete
```
Then check `~/.dsh/profiles/web/package.json` — `dsh-notify-on-complete` should appear in `dependencies`:
```bash
grep dsh-notify ~/.dsh/profiles/web/package.json
```
> If the CLI says `declares no dsh.bundle — installed as a plain dependency`, it wasn't auto-mounted; declare it manually in the profile user layer. Edit `~/.dsh/profiles/web/cordis.patch.yml`:
```yaml
# your profile user layer (cordis.patch.yml)
- id: notify-on-complete
name: dsh-notify-on-complete
config:
enabled: true # default true, omit to keep the default
title: DeepSeek Harness # notification title, omit to keep the default
```
> If you previously installed with the one-liner, manual mounting would double-mount (two notifications per run) — run `dsh plugin --profile web remove dsh-notify-on-complete` before switching channels.
</details>
---
## Settings Panel (Web GUI)
Open **dsh web → Settings → Plugins → Configurable** and expand the **notify-on-complete** card. Every option below is editable in the UI — no `cordis.patch.yml` edits needed:
- **enabled / title / sound / onBlocked / onQuestion / onApproval** — the same switches as the config file.
- **sounds** — per-tier sound names (macOS sound names like Glass / Sosumi / Ping / Funk, or `default`): completion, failure, and attention (question/approval) chimes. On macOS `default` means **no chime** — use it as a per-tier mute; Windows and Linux map `default` to their platform default sound.
- **quietHours** — `"HH:MM-HH:MM"` ranges (start after end crosses midnight); inside a range the plugin is fully silent (no banner, no chime). Example: `22:00-08:00, 12:00-13:00` (comma separated).
Values take precedence over the profile's `cordis.patch.yml` config; fields you never touch fall back to the config file, then to defaults. Profiles without a settings service (e.g. CLI one-shot) simply use the config file as before.
> The settings card is rendered by the plugin's browser half (`lib/client.js`) and reads/writes through the plugin's own JSON route (`GET/POST /notify-on-complete/api/config`) — the harness's settings API serves only an allowlist of namespaces to the web client, so third-party plugins expose their own route. It only takes effect in a web profile. After upgrading to a version that ships the card, **restart the dsh web process** so the browser half is loaded (see "Restart" above).
## Configuration
Configuration lives in the **profile's `cordis.patch.yml`** (the user layer, applied last, wins per row):
| profile | config file path |
|---|---|
| `web` (default, `dsh web`) | `~/.dsh/profiles/web/cordis.patch.yml` |
| `headless` (`dsh --profile headless`) | `~/.dsh/profiles/headless/cordis.patch.yml` |
| other `<name>` | `~/.dsh/profiles/<name>/cordis.patch.yml` |
> You can also use the home-level `$DSH_HOME/cordis.patch.yml` (default `~/.dsh/cordis.patch.yml`), shared by every profile.
Configure by **declaring/overriding the row with `id: notify-on-complete`**. Notes:
- A later layer **replaces the whole `config`** of the same-`id` row (no per-key deep merge), so either write the full `id` + `name` + `config`, or write only the keys you want to change and let the defaults fill the rest.
- `cordis.patch.yml` must be a **top-level YAML array** (start with `-`); if you delete everything, write `[]`.
Full example (all fields with defaults):
```yaml
# ~/.dsh/profiles/web/cordis.patch.yml
- id: notify-on-complete
name: dsh-notify-on-complete
config:
enabled: true # master switch; false disables everything
title: DeepSeek Harness
sound: true # play a sound; false = notification only
onBlocked: true # master switch for blocking notifications (question + approval)
onQuestion: true # question notifications (only when onBlocked: true)
onApproval: true # approval/permission notifications (only when onBlocked: true)
```
Common scenarios:
```yaml
# notify only, no sound
- id: notify-on-complete
name: dsh-notify-on-complete
config:
sound: false
# only notify when a run completes — no blocking (question/approval) notifications
- id: notify-on-complete
name: dsh-notify-on-complete
config:
onBlocked: false
# complete + question notifications, but not approval (sandbox escalation / tool permission)
- id: notify-on-complete
name: dsh-notify-on-complete
config:
onApproval: false
```
### Fields
| Field | Type | Default | Description |
|---|---|---|---|
| `enabled` | boolean | `true` | When `false`, the plugin registers no listeners at all — fully off |
| `title` | string | `DeepSeek Harness` | Notification title |
| `sound` | boolean | `true` | Play a system sound with the notification; `false` shows only the notification |
| `onBlocked` | boolean | `true` | Master switch for blocking notifications; `false` disables question + approval notifications |
| `onQuestion` | boolean | `true` | Question (`ask_user_question`) notifications; only applies when `onBlocked: true` |
| `onApproval` | boolean | `true` | Approval/permission notifications; only applies when `onBlocked: true` |
| `sounds` | object | `{completed: "Glass", error: "Sosumi", approval: "Ping"}` | 每档事件的音色(macOS 音色名或 `default`) |
| `quietHours` | string[] | `[]` | 勿扰时段 `"HH:MM-HH:MM"`(开始晚于结束表示跨天);时段内完全不通知 |
Config is validated at load time (fail loud): a type error fails startup instead of being silently ignored.
Restart after changing config: one-shot CLI runs pick it up next run; `dsh web` needs a web-process restart.
Verify it took effect:
```bash
dsh --profile web --dump-config | grep -n -A 10 notify-on-complete
```
If the output shows your `config:` values, it's applied.
## Platform commands
| Platform | Command | Notes |
|---|---|---|
| macOS | `osascript -e 'display notification …'` | native Notification Center, with system sound (`sound name "Glass"`) |
| Linux | `notify-send` | falls back to `kdialog --passivepopup` when missing; sound via `canberra-gtk-play` (falls back to `paplay`) |
| Windows | PowerShell `WScript.Shell.Popup` | no extra modules, auto-closes after 5 s, with .NET `SystemSounds` |
> On macOS you may need to grant the terminal app notification permission (System Settings → Notifications).
## FAQ
**Q: Two notifications per run?**
Double mount: the profile's `cordis.patch.yml` still has an old manual mount line. Delete that `- id: notify-on-complete` entry (the one-liner cleans it up automatically) and keep only the bundle auto-mount. `cordis.patch.yml` must stay a top-level YAML array — if you delete everything, write `[]`.
**Q: Installed but no notification?**
1. Confirm it loaded: `dsh --profile web --dump-config | grep notify-on-complete`.
2. Confirm it's a root-session run (CLI one-shots always qualify; subagent/background subtasks don't trigger).
3. macOS: check notification permission; Linux: make sure `notify-send` or `kdialog` exists; Windows: make sure PowerShell works.
4. Notifications are fire-and-forget — failures don't error out; run the platform command manually to verify the system side.
**Q: Why only root sessions, not subagents?**
One CLI run can contain several subagent sessions, each with its own `turn/end` and `agent/status`. The plugin filters subagents via `session.header.origin === 'subagent'` (the harness's own idiom) to notify only the top-level run.
**Q: How many notifications per run?**
One. It fires only when the root agent returns to `idle` (the whole activity has converged, all turns done), so multi-round goal runs don't spam; intermediate turns never fire "task completed" early.
**Q: Does it notify in the Web GUI?**
Yes. In the Web GUI each task (one run) ends with the root agent's `idle` state, same as the CLI; a multi-round goal run fires once when the whole run finishes.
**Q: `dsh plugin add` reports a peer dependency error?**
The plugin peers on `@deepseek-ai/cordis@^4.0.1`, which must be resolvable from npm. If your environment can't reach the npm registry, use `--offline` or pre-install cordis in the profile.
**Q: Will it also fire "needs approval" under headless / `never` approval policy?**
Possibly. `approval/asked` is logged even under the `never` policy or with no answerer (headless/CI), where it is actually rejected immediately rather than waiting for a human — a pure plugin cannot tell these apart from session events. In the Web GUI the answerer is always present and the policy defaults to `ask`, so the signal is reliable; in headless, use `onApproval: false` or `onBlocked: false`.
## Development
```bash
pnpm install
pnpm run test # vitest unit tests (result mapping / platform commands / run-end state machine / plugin entry)
pnpm run typecheck # tsc --noEmit
pnpm run build # tsc output to lib/ (the prepare hook runs this automatically on install)
```
Source layout:
```
src/index.ts plugin entry: name / Config validation / platform gate / event wiring
src/notifier.ts run-end state machine: records the final turn/end result, fires once at idle
src/notify.ts result mapping, platform command building, detached spawn (incl. Linux fallback)
src/types.ts structural event types (zero dependencies, no dsh internal packages)
cordis.patch.yml bundle auto-mount declaration (dsh.bundle.patch)
scripts/install.sh one-liner install script (GitHub source distribution)
tests/ vitest unit tests (result mapping / platform commands / state machine / plugin entry)
```
Install
dsh plugin --profile web add github:pitetow/dsh-notify-on-complete
Profile: web
With the hub plugin installed, ask your agent to install it by name — it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install dsh-notify-on-complete from the hub
- This package builds from source on install. pnpm will ask you to allow its build script — that is permission to run the package’s code on your machine, outside the agent sandbox. Only allow sources you trust.
- This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.