dsh-notify-p
Verifieddsh-notify-p · v0.1.0 · MIT · Web UI
DSH 邮件通知:会话干完活 / 出错 / 卡住 / 等你审批时发一封邮件,正文写清是哪个会话,并附一条可点击直达该会话的链接。Email notification plugin for DeepSeek Harness.
Install
dsh plugin add dsh-notify-p Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Published to npm without a public repository. Inspect the package contents before installing.
Tags
Readme
dsh-notify-p
DSH 的邮件通知:会话干完活 / 出错 / 卡住 / 等你审批时,发一封邮件到你邮箱;正文写清"是哪个会话",并附一条可点击直达该会话的链接。
会话结束 ──▶ 判定(结局/子代理/消抖/去重) ──▶ 渲染(中英并列正文 + 直达链接) ──▶ SMTP / Resend ──▶ 你的邮箱
它解决什么
多会话并跑时,最难受的是"不知道哪个会话干完了"。这个插件把这件事拆成三条:
- 该不该打扰:按结局分流(完成 / 出错 / 卡住 / 等审批),每类一个开关;
- 会不会吵:连续多轮只提醒一次(合并窗口)、几十个会话并发时全局限流、子代理默认不打扰;
- 点开能不能看懂:邮件正文写清会话标题 + 工作区 + 时间 + 用时 + 会话 ID,并给一条可点击的直达链接。
安装
dsh plugin --profile web add file:/root/test/12000012_dsh通知/dsh-mail-notify
装完重启 dsh web 并刷新页面。不要手改 ~/.dsh/profiles/web/node_modules/——profile 用 pnpm 管依赖,手动改动会被回滚。
想让邮件里的链接真的能点开,再装一个深链插件
本插件只负责把 ?session=<会话ID> 拼进正文,识别它的是浏览器侧插件。二者选一:
dsh plugin --profile web add github:qyw233/dsh-deeplink # ?session= 查询参数式
# 或
dsh plugin --profile web add dsh-session-link # dsh:// 与 /s/<id> 协议式
不装也能用,只是链接点了会落到 Web UI 的"上次会话"而不是目标会话。
配置
打开 设置 → 邮件通知(左侧导航里独立一页,排在「icon 管理」之后)。
这一页只回答两个问题:配好了吗(每组标题右侧的结论)、能发出去吗(顶部判决条 + 发送测试邮件)。 其余细节收在折叠区里,界面文案跟随 DSH 的语言设置(中文界面只显示中文,不再中英同屏)。
没有保存 / 放弃按钮:改完即时生效,落盘到 $DSH_HOME/settings.yaml 的 dsh-notify-p 段。按字段类型区别对待:
| 控件 | 何时写回 |
|---|---|
| 开关 / 分段 / 下拉 | 点一下立刻写 |
| 文本框 / 数字框 | 停手约 0.5–0.6 秒后写;失焦立刻写 |
| SMTP 授权码 / API Key | 只在失焦或回车时写(绝不会每敲一个字符写一次凭据库) |
页面右上角常驻显示保存状态(已保存,N 秒前;还没动过就写「改动即时生效」);保存失败时会给出 保存失败:… 重新读取,
点一下就把本地改动丢掉、重新读 Host 上的真实值。底部另有「恢复全部默认」,清掉本插件在
settings.yaml 里的全部覆盖(会二次确认;凭据库里已存的授权码与 API Key 不受影响)。
页面结构
| 区块 | 里面是什么 |
|---|---|
| 顶部判决条 | 全页唯一的高亮块:状态点 + 一句话结论(最大的字)+ 为什么是这个结论 / 下一步动哪里 + 「最近一次发送」的读数 + 一个「下一步该干什么」的按钮(启用 / 去填写 / 选真实通道 / 发送测试邮件) |
| 收件人 | 一行一个邮箱;就地校验并列出格式不对的项,不用等到发信才发现 |
| 怎么发 | 只记录 / SMTP / Resend 分段切换;选 SMTP 后按邮箱服务商一键填入主机、端口、加密 |
| 什么时候发给我 | 默认打扰你的 4 种结局;另外 3 种(默认关)收进折叠区;「完成时长下限」缩进挂在「任务完成」下面,因为只对它生效 |
| 发件人与链接(折叠) | 发件人显示名 / 发件人地址 / 直达链接开关 / 链接对外地址 |
| 高级(折叠) | 合并窗口 / 发送速率上限 / 两个凭据引用名 / 投递账本路径 |
「最近一次投递」不再是独立区块——它是判决条里的一行读数(最近一次发送:成功,用时 900 毫秒,5 秒前),
因为它回答的正是判决条那个问题,放在别处只会让用户在两处之间来回对。
界面上没有 01/02/03 步骤号:这三段是并列的设置,不是一条必须按顺序走的流水线。 组与组之间靠标题、留白和发丝线分层,而不是靠卡片一层套一层。
界面为什么长这样(改样式前先读)
这一页是嵌在宿主设置面板里的,所以它不发明自己的皮肤,而是直接用宿主的主题 token 与度量:
| 约束 | 原因 |
|---|---|
颜色只准写 var(--dsw-alias-*),全页零硬编码色值 |
明暗两套主题都是宿主给的;自己配一套字面色,暗色下必然有一处失配 |
不许在 :root 上写 --x: var(--dsw-alias-…) 这种别名 |
宿主把主题 token 定义在 body 上,而自定义属性里的 var() 是在声明它的那个元素上求值的。写在 :root 上会解析成空值 → 高亮块背景变透明、发丝线回退成 currentColor(近黑)、输入框丢掉描边。这正是这一页曾经"做废"的根因 |
高亮块只能用 --dsw-alias-bg-module-platform |
浅色主题下 bg-layer-1/2/3 都是纯白,跟页面底色一模一样,拿它当高亮等于没有 |
宿主的 Input 原语是 content-box 的 inline-flex |
只想给 width:100% 会让它比父级宽出 18px、顶出内容列;必须连 box-sizing:border-box 一起给 |
| 全页只有一个填充面(判决条),其余都是发丝线行 | 一个页面只有一个重点;行高亮、卡片、阴影越多,用户越找不着该看哪里 |
这些约束都有离线审计盯着(test/client.test.mjs 里的 CSS 审计与 test/page-render.test.mjs 的渲染冒烟),
不是靠自觉:硬编码色值、:root 别名、判决条丢掉真实底色都会直接让测试变红。
发送测试邮件
有三种方式,都不需要真的等一个会话干完:
① 设置页里的按钮(常驻,推荐日常用):判决条上一个(次级按钮),填授权码/API Key 的那一行还有一个(文字链接, 手刚离开输入框就能就地验证,不用滚回页顶)。它不挑状态——哪怕页面还在说「还差 SMTP 授权码」,也照样能点: 点下去就按当前配置真发一封,并把通道、耗时、失败原因写回判决条里的「最近一次发送」。
这是刻意的:配置只填了一半时,"怎么验证"恰恰最需要出现。按钮的价值就是用结果告诉你还缺什么—— 缺授权码、认证失败(535)、发件人被拒(550)都会在同一次点击里变成一句能读的原因。
⚠️ 这个按钮依赖 Host 侧的
mailNotify端点,而 DSH 只在启动时扫描插件的 typert 清单。 所以第一次装上本插件(或更新到带该端点的版本)后,需要重启dsh web一次,按钮才会出现; 在此之前这里会显示「需要重启 DSH 后才能测试」,页面其余部分照常可用。
② 命令行(不用重启、不用开浏览器,适合先验邮箱配置):
node test/send-test.mjs --dry-run # 只打印"会用什么通道、发给谁、凭据找没找到",不连网络
node test/send-test.mjs # 读同一份 settings.yaml 与凭据库,真发一封
node test/send-test.mjs --to [email protected] # 临时换收件人
node test/send-test.mjs --transport smtp --smtp-host smtp.qq.com --smtp-user [email protected]
node test/send-test.mjs --json # 结果以 JSON 输出,便于脚本消费
凭据解析顺序与插件运行时一致(环境变量 > $DSH_HOME/.credentials.yaml 的 refs),
它不写投递账本(正在运行的 DSH 进程持有内存账本,两边同时写只会互相覆盖)。
账号和服务商对不上会被直接指出来
用 QQ 的服务器(smtp.qq.com)配 outlook 的账号,认证一定是 535 失败——但这个错误在点发送之前
就看得出来。设置页会在「用户名」下方就地标红,命令行 --dry-run 也会打印同一句提示:
smtp.qq.com是 QQ 邮箱的服务器,但账号是[email protected]:对不上,认证会失败。 把账号换成@qq.com结尾的邮箱,或把服务商改成账号所属的那一家。
只是提示不是错误:企业内部中继、同服务商的别名都可能合法,所以不拦发送。 不认识的 SMTP 主机(自建服务器)一律不吭声,插件不猜。
注:本插件不再在「插件 → 插件配置」里放卡片——那个位置的设置已经挪到上面这一页,避免同一份配置有两个入口。
第一步:先看它跑通(不用邮箱)
发送通道 默认是 log:整条链路(事件 → 判定 → 渲染 → 投递)都会执行,但只把邮件正文打进 DSH 日志,不真发信。
最快的验证是点判决条上的 发送测试邮件(log 通道下也会成功,用时与结果直接写回判决条的「最近一次发送」)。
想验真实事件流,就跑一个会话,然后看投递账本:
cat "$DSH_HOME/dsh-notify-p/state.json" # 默认 $DSH_HOME=/root/.dsh
{
"version": 1,
"startedAt": "...",
"counts": { "sent": 1, "failed": 0 },
"recent": [
{ "at": 1776000000000, "ok": true, "outcome": "completed",
"sessionId": "session-…", "subject": "[DSH] 任务完成 / completed · 修复登录页样式",
"recipients": ["[email protected]"], "transport": "log", "attempts": 1 }
]
}
⚠️ 别去 stdout 里找日志:cordis 的控制台 exporter 默认只输出 error 级, 插件的 info / warn 不会出现在
dsh web的输出里。账本是唯一可靠的"发没发出去"证据。 文件在插件加载时就会创建(counts全 0),所以"文件不存在"才等于"插件没加载"。
第二步:选通道
| 通道 | 适合 | 说明 |
|---|---|---|
log |
联调 | 只写日志,不烧邮件 |
smtp |
推荐 | 用自己的邮箱发(QQ/163/Gmail/Outlook/iCloud…),只需一个授权码,不需要域名 |
resend |
有自己域名时 | HTTP API,需要在 Resend 验证域名;未验证域名时只能发给你注册的那个邮箱 |
第三步:凭据(密钥永不进 settings)
配置里只存引用名,真值放凭据库。默认引用名 DSH_MAIL_NOTIFY_SMTP_PASSWORD。
三种写法任选:
# ① 设置页里的密码框(推荐,write-only,永不回显)
# 设置 → 邮件通知 → SMTP 授权码 → 填写 → 点别处(失焦即写入)
# ② 直接编辑 ~/.dsh/.credentials.yaml
refs:
DSH_MAIL_NOTIFY_SMTP_PASSWORD: "你的授权码"
# ③ 环境变量(同名即可)
export DSH_MAIL_NOTIFY_SMTP_PASSWORD='你的授权码'
授权码 ≠ 登录密码:QQ 邮箱 → 设置 → 账户 → 开启 "POP3/SMTP 服务" → 生成 16 位授权码;163 类似;Gmail 用"应用专用密码"。
「我明明填了授权码,页面还显示未配置」是怎么回事
先记住一件事:页面上的「已配置 / 未配置」不是看着输入框里有没有字,而是去问凭据库。 所以「未配置」只意味着值没有落进凭据库,常见三种原因:
- 没有触发保存。密码框只在失焦或回车时写入(绝不每敲一个字符写一次凭据库)。 填完直接关设置面板或切页面,就可能没触发。填完点一下别处,或按回车。
- 写失败。页面会显示「保存失败:…」;比如同名环境变量已存在时,凭据服务会拒绝覆盖
(
supplied read-only by the launching environment),这时要么去掉那个环境变量,要么直接用它。 - 状态还没读回来。写入凭据库和读回状态是两次独立请求,页面会自动复检几次;
你也可以在授权码行下面点「重新检查」立刻再读一次,或按
Ctrl/Cmd+R刷新页面。
排错最直接的两步:
grep -c DSH_MAIL_NOTIFY_SMTP_PASSWORD ~/.dsh/.credentials.yaml # 有值就该是 1
node test/send-test.mjs --dry-run # 会打印"凭据已找到(N 字符)"
发不出去时先看这一句
设置页的「发送测试邮件」会把真实失败原因原样显示出来,常见两种:
| 报错 | 原因 |
|---|---|
535 Authentication failed |
用的是登录密码而不是授权码;或服务商与账号域名对不上(页面会就地标红提示) |
550 The "From" header is missing or invalid |
发件人显示名里的非 ASCII 字符没有被正确编码。本插件已修(只对显示名做 RFC 2047 编码、地址保持裸 ASCII);如果还遇到,把「发件人显示名」先改成纯英文试一次 |
配置项一览
| 设置 | 默认 | 在页面的哪里 | 说明 |
|---|---|---|---|
| 总开关 | 开 | 判决条 | 关掉后只订阅、不动作 |
| 收件人 | 空(必填) | 收件人 | 一行一个,也接受逗号/分号;就地校验 |
| 发送通道 | log |
怎么发 | log / smtp / resend |
| SMTP 主机/端口/加密 | 465 / auto |
怎么发 | 选服务商一键填入;auto:465 走隐式 TLS,其余走 STARTTLS |
| SMTP 用户名 | 空(SMTP 必填) | 怎么发 | 通常是你的完整邮箱地址;缺它会在发信前就报错 |
| SMTP 授权码 | — | 怎么发 | 只写入凭据库;页面里有「怎么拿授权码?」的分服务商步骤 |
| Resend API Key | — | 怎么发 | 只写入凭据库 |
| 发件人显示名 | DSH 通知 | 发件人与链接(折叠) | 收件人看到的名字 |
| 发件人地址 | 空 | 发件人与链接(折叠) | SMTP 留空则用 SMTP 用户名;Resend 必填 |
| 链接对外地址 | 空 | 发件人与链接(折叠) | 留空读 DSH_WEB_URL;手机要能点就填局域网 IP |
| 邮件里带直达链接 | 开 | 发件人与链接(折叠) | 需要另装深链插件 |
| 任务完成 | 开 | 什么时候发给我 | turn/end reason=completed |
| 任务出错 | 开 | 什么时候发给我 | turn/end reason=error(与 agent/error 折叠成一封) |
| 卡住需要你介入 | 开 | 什么时候发给我 | reason=blocked |
| 等你审批 | 开 | 什么时候发给我 | approval/asked,立即发,不参与合并窗口 |
| 你主动停止 | 关 | 什么时候发给我 → 还有 3 种情况 | 仅 aborted.reason.kind === 'user' |
| 系统中断 | 关 | 什么时候发给我 → 还有 3 种情况 | 重启 / 热更 / 被父级取消(disposed/parent/hook) |
| 达到长度上限 | 关 | 什么时候发给我 → 还有 3 种情况 | reason=max-tokens |
| 也通知子代理会话 | 关 | 什么时候发给我 | 一个 workflow 能扇出几十个子会话 |
| 最短时长 | 2000 ms | 什么时候发给我 → 任务完成 下面 | 短于此的"完成"不打扰(出错/卡住不受此限) |
| 合并窗口 | 1500 ms | 高级(折叠) | 期间同一会话又开新轮 → 合并成一封 |
| 发送速率上限 | 10 封/分钟 | 高级(折叠) | 全局令牌桶,超出的排队 |
| 两个凭据引用名 | 见上 | 高级(折叠) | 改它不会动凭据库里已存的值 |
| 投递账本路径 | 空 | 高级(折叠) | 留空 = $DSH_HOME/dsh-notify-p/state.json |
邮件长什么样
主题:[DSH] 任务完成 / completed · 修复登录页样式
发生了什么 / Event:任务完成(completed)/ Task completed
会话标题 / Session:修复登录页样式
工作区 / Workspace:12000011_响应式网页适配
时间 / Time:2026-09-12 15:32:07
用时 / Duration:4 分 12 秒 / 4m 12s
轮次 / Turn:3
会话 ID / Session ID:session-a25bd099-cae9-4732-8179-f8eabd33541b
直达会话 / Open session:http://127.0.0.1:3081/?session=session-a25bd099-...
— 本邮件由 dsh-notify-p 自动发送 / sent by dsh-notify-p
HTML 版同内容,直达会话 渲染成一个蓝色按钮。出错会多出"失败原因"块;审批会多出"工具 / 说明"块。
已知限制(都是刻意的)
- 审批邮件里没有命令正文:DSH 的
approval/asked事件只带{ id, toolName, callId, reason },命令在tool/call.arguments里且是字符串,要按callId回查才能拿到。第一期不倒推状态机,只发工具名 + 原因。 - 不实现
?session=的接收端:那是深链插件的职责(见上文"安装")。 - 手机点链接需要额外一层:
?session=链接形如http://127.0.0.1:3081/...,手机上的127.0.0.1指向手机自己。本版本的 web app 明确拒绝--host 0.0.0.0(原生提示:那会把远程代码执行暴露到网络),所以别指望改 host;手机可用要靠反向代理 / 隧道,再把链接对外地址填成对应的域名或局域网地址。 - 简短任务可能静默:时长低于"最短时长"的完成不发(这是有意的,否则每个小问答都吵你)。
- 投递失败只能查账本:邮件通道自己坏了就无法用邮件告诉你——
counts.failed和recent[].error会写进账本。认证类错误(535 等)不会重试——重试会放大邮箱风控锁定。设置页的判决条会显示最近一次投递结果(含测试邮件),更早的历史仍要看账本。 - 插件日志不落 stdout:cordis 控制台 exporter 默认只输出 error 级,所以不要用
dsh web的输出判断插件是否工作,看账本。 - 「发送测试邮件」需要重启一次:typert 清单只在 DSH 启动时扫描,见上文说明。
开发
node --test "test/*.test.mjs" # 104 个测试:纯函数 + 假 SMTP 服务器 + 假 cordis 端到端 + 浏览器半离线 + 设置页渲染冒烟 + 命令行测试发送
node test/dryrun.mjs # 干跑:把四种典型结局的邮件正文打出来看
test/plugin.test.mjs 里的端到端用例是真的调用 apply():订阅 → 判定 → 消抖 → 去重 → 队列 → 投递,
包括"多轮只发一封""子代理不发""历史扫描不补发""上下文残缺也不抛异常"这些关键行为;
新增的 Remote 用例同样用假 ctx 驱动真实装配,并用 lib/typert.host.js 里声明的严格 schema
真的校验一遍返回值(否则形状漂移只会在浏览器里才炸)。
test/client.test.mjs 把 client/client.js 当 browser bundle 装进假环境(桩掉 window.__ModuleLoader__
与 document)后直接测:接线、zh/en 文案键一致、纯函数真值表,以及两条复发护栏——
CSS 里不许出现宿主主题不存在的 token(--dsw-alias-interact-* / -border-secondary 曾让暗色模式整页失配),
凭据写入目标不许从草稿里猜 smtpPasswordRef(那曾让 Resend 的 Key 写进 SMTP 的引用名)。
test/page-render.test.mjs 更进一步:用一个能跑状态、副作用与 ref 的 React 桩,
把真正的设置页组件渲染出来,断言"发送测试邮件是常驻入口""最近一次投递的失败原因留在页面上"
"凭据状态会被自动复检"——源码审计看不出这些,只渲染一次也看不出。
test/mime.test.mjs 里有一条真机踩出来的复发护栏:From 头只允许对显示名做 RFC 2047 编码,
地址必须保持裸 ASCII。整串 B-encode 会被 QQ 邮箱以 550 The "From" header is missing or invalid 拒收。
node_modules/ 下的 @deepseek-ai/schemastery、@deepseek-ai/dsh-typert-protocol、zod
只是指向本机 DSH 自带副本的软链,供本地跑测试用;真正运行时由 profile 提供。
代码结构
| 文件 | 角色 |
|---|---|
lib/index.js |
Host 入口:事件订阅、判定调度、去重、配置装配、测试投递 |
lib/config.js |
settings schema + 纯函数(收件人解析 / 链接拼接) |
lib/policy.js |
纯函数:turn/end → 结局、子代理判定、去重键 |
lib/render.js |
纯函数:渲染主题 / 文本 / HTML |
lib/queue.js |
串行队列 + 全局限流 + 分类退避重试 |
lib/mime.js |
RFC 2047 头编码(主题 + 发件人显示名)+ multipart/alternative |
lib/state.js |
投递账本($DSH_HOME/dsh-notify-p/state.json) |
lib/send/ |
log / smtp(零依赖)/ resend 三个通道 |
lib/test-send.js |
「发一封测试邮件」的唯一实现(设置页 / 命令行 / 测试共用) |
lib/service.js |
Host Remote 服务:mailNotify/test 与 mailNotify/state |
lib/typert.host.js |
手写 typert 清单(dsh-typert-loader 启动时扫描注册) |
client/client.js |
浏览器半:左侧导航里的「邮件通知」设置页(手写,无构建步骤,改完即时生效) |
test/send-test.mjs |
命令行测试发送(--dry-run 只打印计划,不连网络) |
test/page-render.test.mjs |
设置页离线渲染冒烟(常驻测试入口 / 投递结果 / 凭据复检) |
License
MIT