Skip to content

dsh-notify-p

Verified

dsh-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 ──▶ 你的邮箱

它解决什么

多会话并跑时,最难受的是"不知道哪个会话干完了"。这个插件把这件事拆成三条:

  1. 该不该打扰:按结局分流(完成 / 出错 / 卡住 / 等审批),每类一个开关;
  2. 会不会吵:连续多轮只提醒一次(合并窗口)、几十个会话并发时全局限流、子代理默认不打扰;
  3. 点开能不能看懂:邮件正文写清会话标题 + 工作区 + 时间 + 用时 + 会话 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 用"应用专用密码"。

「我明明填了授权码,页面还显示未配置」是怎么回事

先记住一件事:页面上的「已配置 / 未配置」不是看着输入框里有没有字,而是去问凭据库。 所以「未配置」只意味着值没有落进凭据库,常见三种原因:

  1. 没有触发保存。密码框只在失焦或回车时写入(绝不每敲一个字符写一次凭据库)。 填完直接关设置面板或切页面,就可能没触发。填完点一下别处,或按回车。
  2. 写失败。页面会显示「保存失败:…」;比如同名环境变量已存在时,凭据服务会拒绝覆盖 (supplied read-only by the launching environment),这时要么去掉那个环境变量,要么直接用它。
  3. 状态还没读回来。写入凭据库和读回状态是两次独立请求,页面会自动复检几次; 你也可以在授权码行下面点「重新检查」立刻再读一次,或按 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 版同内容,直达会话 渲染成一个蓝色按钮。出错会多出"失败原因"块;审批会多出"工具 / 说明"块。

已知限制(都是刻意的)

  1. 审批邮件里没有命令正文:DSH 的 approval/asked 事件只带 { id, toolName, callId, reason },命令在 tool/call.arguments 里且是字符串,要按 callId 回查才能拿到。第一期不倒推状态机,只发工具名 + 原因。
  2. 不实现 ?session= 的接收端:那是深链插件的职责(见上文"安装")。
  3. 手机点链接需要额外一层:?session= 链接形如 http://127.0.0.1:3081/...,手机上的 127.0.0.1 指向手机自己。本版本的 web app 明确拒绝 --host 0.0.0.0(原生提示:那会把远程代码执行暴露到网络),所以别指望改 host;手机可用要靠反向代理 / 隧道,再把 链接对外地址 填成对应的域名或局域网地址。
  4. 简短任务可能静默:时长低于"最短时长"的完成不发(这是有意的,否则每个小问答都吵你)。
  5. 投递失败只能查账本:邮件通道自己坏了就无法用邮件告诉你——counts.failed 和 recent[].error 会写进账本。认证类错误(535 等)不会重试——重试会放大邮箱风控锁定。设置页的判决条会显示最近一次投递结果(含测试邮件),更早的历史仍要看账本。
  6. 插件日志不落 stdout:cordis 控制台 exporter 默认只输出 error 级,所以不要用 dsh web 的输出判断插件是否工作,看账本。
  7. 「发送测试邮件」需要重启一次: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