> [English](README.md) · 中文文档
# dsh-notify-on-complete

DeepSeek Harness 插件:每次 dsh 运行结束时向操作系统发送桌面通知,提示用户工作已完成;会话进行中模型提问或等待审批时也会即时通知提醒你回来处理。正文按结果区分(成功 / 失败 / 中止 / 达到 token 上限)。
> 作者:[Luozy](https://github.com/pitetow) · 协议:[MIT](LICENSE)
- 零运行时依赖:不依赖 dsh 内部包,也不依赖 `ctx.shell` 服务,通知用 `child_process.spawn` 以 detached 子进程发出,**不阻塞、也不被 harness 退出流程影响**。
- 跨平台:按 `process.platform` 自动选择通知命令(macOS `osascript` / Linux `notify-send`→`kdialog` / Windows PowerShell)。不支持的平台加载时跳过并打警告,不会在每个事件里抛错。
- 通知带系统提示音:macOS 用系统默认提示音(`sound name "Glass"`)、Windows 用 .NET SystemSounds、Linux 用 `canberra-gtk-play`(缺失时回退 `paplay`);可用 `sound: false` 关闭。
- 会话中阻塞即时通知:模型调用 `ask_user_question` 提问、或沙箱提权/工具权限等待审批时立即弹通知提醒你回来(正文含问题文本 / 工具名与原因),可用 `onBlocked` / `onQuestion` / `onApproval` 精细控制。
- 只通知顶层运行:子代理(subagent)会话被过滤(`header.origin === 'subagent'`),一次 CLI 运行只弹一条通知。
## 工作原理
插件监听两个事件,协同判定"一次运行结束":
1. **`session/event` → `turn/end`**:记录根会话(`origin !== 'subagent'`)最近一次轮次结束的 `reason.kind`。一次运行可能跨多个轮次(goal 多轮、follow-up、steering),每一轮都有自己的 `turn/end`,插件只记住**最后一次**的结果。
2. **`agent/status` → `'idle'`**:这是 harness 自己定义的"运行结束"信号(web 界面的 running 指示器、`agent.whenIdle()` 都基于它)。根 agent 回到 idle 表示整段活动(含所有轮次)收敛完成,此时把记下的最终结果发出去,并清除记录。
所以**每条通知对应一次完整的运行**,而不是每一轮:多轮 goal run 只在整场跑完时弹一条,且正文是最终结果;中途的"任务已完成"不会提前弹出。通知正文格式:`结果文本 — 会话标题 (session: 会话ID)`,例如 `任务已完成 — 修复登录bug (session: 3f9a…)`;会话标题还没生成时退化为 `结果文本 (session: 会话ID)`。标题来自会话日志里最后一条 `session/title` 事件,是异步投影——极早期通知(如会话刚开始就提问)可能还没有标题,属预期。通知命令以 `detached: true` + `unref()` 发出,harness 正常退出或崩溃都不会影响通知送达。
| `reason.kind` | 通知正文 |
|---|---|
| `completed` | 任务已完成 |
| `error` | 任务失败 |
| `aborted` | 任务已中止 |
| `max-tokens` | 任务达到 token 上限 |
| 其他(未知) | 任务结束 |
## 环境要求
- Node.js ^22(与 DeepSeek Harness 一致)
- 已安装的 dsh CLI(任意版本,插件通过 Cordis 事件注册,不依赖 CLI 特定版本)
- peer 依赖 `@deepseek-ai/cordis@^4.0.1`(由 dsh CLI 自身提供,安装时 pnpm 会自动解析)
---
## 安装(一键脚本,GitHub 源码分发,无需 npm)
**前置**:已装好 DSH(`dsh web` 能正常运行),Node.js ^22 + pnpm。
**macOS / Linux / Windows(Git Bash 或 WSL)**:
```bash
curl -fsSL https://raw.githubusercontent.com/pitetow/dsh-notify-on-complete/main/scripts/install.sh | bash
```
其他 profile(默认 `web`):
```bash
curl -fsSL https://raw.githubusercontent.com/pitetow/dsh-notify-on-complete/main/scripts/install.sh | bash -s -- --profile headless
```
脚本自动完成 4 件事(全部幂等,可安全重复执行):
1. 下载源码到 `~/.dsh/plugins/dsh-notify-on-complete/`(已存在则跳过,**不会覆盖**;加 `--force` 才覆盖更新,覆盖前会询问确认,`--yes` 跳过确认);
2. `pnpm install && pnpm build` 构建产物;
3. `dsh plugin --profile <名> add link:<目录>`:CLI 识别包内 `dsh.bundle.patch` 声明(`cordis.patch.yml`),**自动注册进 profile 的 bundle 栈**,下次启动自动挂载——不需要手动编辑任何配置文件;
4. 幂等移除旧版残留的手动挂载行,避免双挂载(一次运行弹两条通知)。
`curl | bash` 会执行远程代码——脚本随仓库开源(`scripts/install.sh`),可先下载审阅。
### 验证
```bash
dsh --profile web --dump-config | grep -n notify-on-complete
```
能输出 `- id: notify-on-complete` 及其后的 `name: dsh-notify-on-complete` 行,说明插件已进入合成树。再跑一次真实任务,看到桌面通知弹出即安装成功。
重启生效:
- **CLI 一次性运行**:下次运行 `dsh --profile headless "任务"` 时自然生效,无需额外操作。
- **Web GUI**:重启 web 进程(结束当前 `dsh web` 进程后重新启动)。若部署启用了 HMR 热更新,保存文件后会自动生效。
### 更新
```bash
curl -fsSL https://raw.githubusercontent.com/pitetow/dsh-notify-on-complete/main/scripts/install.sh | bash -s -- --force
```
> `--force` 会删除旧源码重新下载(该目录内的本地改动会丢失),**覆盖前会询问确认**;无人值守场景加 `--yes` 跳过确认:
> `bash -s -- --force --yes`
或手动:`cd ~/.dsh/plugins/dsh-notify-on-complete && git pull && pnpm install && pnpm run build` 后重跑 `dsh plugin --profile web add link:.`。
### 卸载
```bash
dsh plugin --profile web remove dsh-notify-on-complete
rm -rf ~/.dsh/plugins/dsh-notify-on-complete
```
然后重启 dsh 进程。
手动安装(从源码 / 本地开发调试,与一键脚本二选一)
把依赖指向本地源码(`link:` 是符号链接,改代码后重建即生效,适合调试):
```bash
cd /path/to/dsh-notify-on-complete
pnpm install
pnpm run build # 产物输出到 lib/
dsh plugin --profile web add link:/path/to/dsh-notify-on-complete
```
装完后检查 `~/.dsh/profiles/web/package.json`,dependencies 里应出现 `dsh-notify-on-complete`:
```bash
grep dsh-notify ~/.dsh/profiles/web/package.json
```
> 若 CLI 提示 `declares no dsh.bundle — installed as a plain dependency`,说明它没有自动挂载,需要在 profile 用户层手动声明。编辑 `~/.dsh/profiles/web/cordis.patch.yml`,加入:
```yaml
# 你的 profile 用户层(cordis.patch.yml)
- id: notify-on-complete
name: dsh-notify-on-complete
config:
enabled: true # 默认 true,不写也行
title: DeepSeek Harness # 通知标题,不写也行
```
> 若之前用一键脚本装过,再手动挂载会造成双挂载(一次运行弹两条通知)——切换通道前先 `dsh plugin --profile web remove dsh-notify-on-complete`。
---
## 设置面板(Web GUI)
打开 **dsh web → 设置 → 插件 → 配置**,展开 **运行完成通知** 卡片即可可视化配置,**无需手动编辑 `cordis.patch.yml`**:
- **enabled / title / sound / onBlocked / onQuestion / onApproval** —— 与配置文件相同的开关。
- **sounds** —— 每档事件音色(macOS 音色名如 Glass / Sosumi / Ping / Funk,或 `default`):完成、失败、提问/审批三档可分别更换。macOS 上 `default` 表示**不响铃**(可当作单档静音);Windows 与 Linux 会把 `default` 映射为各自的平台默认提示音。
- **quietHours** —— 勿扰时段 `"HH:MM-HH:MM"`(开始晚于结束表示跨天);时段内完全不弹通知也不响铃。示例:`22:00-08:00, 12:00-13:00`(多个用逗号分隔)。
面板值优先于 profile 的 `cordis.patch.yml`;没动过的字段回退到配置文件,再到默认值。无设置服务的场景(如 CLI 一次性运行)按配置文件工作,行为不变。
> 设置卡片由插件的浏览器端(client half,`lib/client.js`)渲染,数据经插件自带的 JSON 路由 `GET/POST /notify-on-complete/api/config` 读写(harness 的设置 API 只对白名单命名空间开放,第三方插件需自带路由)。仅在 web profile 中生效。升级后需**重启 dsh web 进程**(见上文"重启生效")。
## 配置
配置写在 **profile 的 `cordis.patch.yml`** 里(用户层,最后应用、按行胜出):
| profile | 配置文件路径 |
|---|---|
| `web`(默认,`dsh web`) | `~/.dsh/profiles/web/cordis.patch.yml` |
| `headless`(`dsh --profile headless`) | `~/.dsh/profiles/headless/cordis.patch.yml` |
| 其它 `<名>` | `~/.dsh/profiles/<名>/cordis.patch.yml` |
> 也可写在 home 级 `$DSH_HOME/cordis.patch.yml`(默认 `~/.dsh/cordis.patch.yml`),所有 profile 共享。
配置方式是**用 `id: notify-on-complete` 声明/覆盖这一行**。注意:
- 后应用的层会**整体替换**同名 `id` 行的 `config`(不是按键深度合并),所以要么写全 `id` + `name` + `config`,要么只写你想改的键、其余交给插件默认值。
- `cordis.patch.yml` 必须是**顶层 YAML 数组**(以 `-` 开头);全部删光后请写 `[]`。
完整配置示例(所有字段 + 默认值):
```yaml
# ~/.dsh/profiles/web/cordis.patch.yml
- id: notify-on-complete
name: dsh-notify-on-complete
config:
enabled: true # 总开关;false 完全关闭
title: DeepSeek Harness
sound: true # 提示音;false 只弹通知不出声
onBlocked: true # 阻塞通知总开关(提问 + 审批)
onQuestion: true # 提问类通知(仅 onBlocked: true 时生效)
onApproval: true # 审批/权限类通知(仅 onBlocked: true 时生效)
```
常用场景:
```yaml
# 仅提示、不要提示音
- id: notify-on-complete
name: dsh-notify-on-complete
config:
sound: false
# 仅任务完成后提示,提问 / 审批等阻塞不提示
- id: notify-on-complete
name: dsh-notify-on-complete
config:
onBlocked: false
# 完成后 + 提问都提示,但审批(沙箱提权 / 工具权限)不提示
- id: notify-on-complete
name: dsh-notify-on-complete
config:
onApproval: false
```
### 字段表
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| `enabled` | boolean | `true` | 设为 `false` 时插件不注册任何监听,完全关闭 |
| `title` | string | `DeepSeek Harness` | 系统通知的标题 |
| `sound` | boolean | `true` | 通知时同时播放系统提示音;设为 `false` 只弹通知不出声 |
| `onBlocked` | boolean | `true` | 阻塞通知总开关;设为 `false` 完全关闭提问+审批通知 |
| `onQuestion` | boolean | `true` | 提问类(`ask_user_question`)通知开关;仅在 `onBlocked: true` 时生效 |
| `onApproval` | boolean | `true` | 审批/权限类通知开关;仅在 `onBlocked: true` 时生效 |
| `sounds` | object | `{completed: "Glass", error: "Sosumi", approval: "Ping"}` | 每档事件的音色(macOS 音色名或 `default`) |
| `quietHours` | string[] | `[]` | 勿扰时段 `"HH:MM-HH:MM"`(开始晚于结束表示跨天);时段内完全不通知 |
配置校验在加载时执行(fail loud):类型错误会在启动时报错,不会静默忽略。
改完配置需重启生效:CLI 一次性运行下次自然生效;`dsh web` 需重启 web 进程。
验证配置是否生效:
```bash
dsh --profile web --dump-config | grep -n -A 10 notify-on-complete
```
输出里能看到你写的 `config:` 值即已生效。
## 平台命令
| 平台 | 命令 | 备注 |
|---|---|---|
| macOS | `osascript -e 'display notification …'` | 原生通知中心通知,带系统提示音(`sound name "Glass"`) |
| Linux | `notify-send` | 缺失时自动回退 `kdialog --passivepopup`;提示音走 `canberra-gtk-play`(缺失时回退 `paplay`) |
| Windows | PowerShell `WScript.Shell.Popup` | 无需额外模块,5 秒自动关闭,带 .NET SystemSounds 提示音 |
> macOS 首次使用可能需要给终端应用授予"通知"权限(系统设置 → 通知)。
## 常见问题
**Q:一次运行弹两条通知?**
双挂载:profile 的 `cordis.patch.yml` 里还留着旧的手动挂载行。删掉那段 `- id: notify-on-complete` 条目(一键脚本会自动清理),只保留 bundle 自动挂载即可。注意 `cordis.patch.yml` 必须是顶层 YAML 数组——全部删光后请写 `[]`。
**Q:装了但通知不弹?**
1. 先确认加载成功:`dsh --profile web --dump-config | grep notify-on-complete`。
2. 确认跑的是根会话任务(CLI 一次性运行一定满足;子代理/后台子任务不触发)。
3. macOS 检查通知权限;Linux 确认有 `notify-send` 或 `kdialog`;Windows 确认 PowerShell 可用。
4. 通知是 fire-and-forget 的,失败不会报错——可以在终端手动执行对应平台的命令验证系统侧可用。
**Q:为什么只在根会话触发,子代理不通知?**
CLI 一次运行可能包含多个子代理会话,每个都有自己的 `turn/end` 和 `agent/status`。插件用 `session.header.origin === 'subagent'` 过滤子代理(harness 自己的惯用口径),保证只对顶层运行通知。
**Q:一次运行会弹几条通知?**
一条。通知在根 agent 回到 `idle`(整段活动收敛、所有轮次结束)时才发出,多轮 goal run 也不会刷屏;中途轮次结束不会提前弹"任务已完成"。
**Q:Web GUI 里任务跑完会通知吗?**
会。Web GUI 中每次任务(一次运行)结束对应根 agent 的 `idle` 状态,与 CLI 行为一致;多轮 goal run 整场跑完才弹一条。
**Q:`dsh plugin add` 报 peer 依赖错误?**
插件 peer 依赖 `@deepseek-ai/cordis@^4.0.1`,需要能从 npm 解析。若你的网络环境访问不了 npm registry,改用 `--offline` 或在 profile 里预先安装 cordis。
**Q:headless / approval 策略为 never 时也会弹「需要批准」吗?**
可能。`approval/asked` 在策略为 never 或没有回答者(headless/CI)时同样会落日志,此时实际是立即拒绝而非真正等用户——纯插件无法从 session 事件分辨这一层。Web GUI 回答者恒在、策略默认 ask,信号可靠;headless 场景可用 `onApproval: false` 或 `onBlocked: false` 关闭。
## 开发
```bash
pnpm install
pnpm run test # vitest 单元测试(结果映射 / 平台命令 / 运行结束状态机 / 插件入口)
pnpm run typecheck # tsc --noEmit
pnpm run build # tsc 产物到 lib/(prepare 钩子在 install 时自动执行)
```
源码结构:
```
src/index.ts 插件入口:name / Config 校验 / 平台门禁 / 事件接线
src/notifier.ts 运行结束状态机:记录最终 turn/end 结果,agent idle 时发一次
src/notify.ts 结果映射、平台命令构建、detached spawn(含 Linux 回退)
src/types.ts 结构事件类型(零依赖,不依赖 dsh 内部包)
cordis.patch.yml bundle 自动挂载声明(dsh.bundle.patch)
scripts/install.sh 一键安装脚本(GitHub 源码分发)
tests/ vitest 单元测试(结果映射 / 平台命令 / 状态机 / 插件入口)
```