dsh
OpenPetsHQ/openpets将 DeepSeek Harness 的生命周期状态、错误与审批请求,桥接到本地运行的 OpenPets 桌面伙伴。
任务事件通知中心:6 类事件(提问 / 审批 / 完成 / 子代理完成 / 错误 / 轮次完成),双通道(浏览器通知 + 宿主系统 toast)外加 Bark/Webhook 推送频道(ntfy、Gotify、自建网关);支持免打扰时段与紧急例外、审批超时二次提醒、完成风暴聚合、通知文本脱敏。
审批/完成/错误事件通知:人不在浏览器前也能收到提醒。
简体中文 | English
dsh plugin --profile web add @wingsky-1/dsh-notifier安装 / 卸载 / 更新后都需重启一次 dsh web(bundle 层只在启动时组合)生效。使用前须知 · 部署与访问 · 最短上手 · 常用配置 · 验证与排障 · 详细参考 · 开发与架构
前提:已安装 DeepSeek Harness 且 dsh web 可正常启动(未全局安装 dsh 见下方「未全局安装 dsh」)。
插件所有接口都受 loopback 围栏保护:仅接受本机回环(127.0.0.1 / localhost)调用。因此**从局域网浏览器直连 http://:3080 时, /api/dsh-notifier/* 一律返回 403,通知通道不工作**——这是安全护栏的预期行为, 不是插件故障;此时页面内会给出引导提示。
请选用下列任一形态访问(均在设置卡片与 README 中给出提示):
形态 · 访问方式 · 说明 本机桌面 · http://127.0.0.1:3080 · 安全上下文:浏览器通知 + 系统通知均可用 局域网 HTTPS(推荐) · https://:3443(dsh-lan-proxy) · 经代理满足回环校验 + 安全上下文;移动端「添加到主屏幕」后可获 PWA 级通知 隧道 · ssh -L 3080:127.0.0.1:3080 后访问本机地址 · 回环 + 安全上下文,效果同本机
已验证(2026-08):https://:3443/api/dsh-notifier/health 返回 200,SSE 长连接(/api/dsh-notifier/events)经 3443 首帧正常。sanitizeContent 的规则表(路径 / PEM 私钥 / 连接串凭据 / 令牌 / 邮箱…)已删除——通知、历史落盘(含 suppressed 落史)与投递都是原文;正文不截断,长度由投递频道的展示上限截断。需要「日志里不出现某类文本」的部署,请自行在事件源侧处理GET /config 的 user + effective 与 PUT 成功响应中的频道凭据(bark deviceKey、webhook token / password / headerValue)一律掩码 ********,提交整值掩码 = 保持原值(按实例 id 对齐回填,防数组序变化串凭据),没有原值可还原的掩码(新实例、换型残留的跨 type 掩码)写面 400,dry-run 同一句——占位符不是凭据,绝不落盘(CHANNEL_SECRET_FIELDS 按频道类型单一事实源)detail 字段里(0.2.4 起理由结构化,见「投递可靠性」),会进服务端日志、状态文件(status.json)与通知历史(history.jsonl),并随 GET /status 与 GET /history 出到设置页;对错误文本外泄敏感的部署请按上一条自行处置channels 是整组提交,合并按裸 id 取首条、其余键沿用首条的值,于是第二条的真实凭据(bark deviceKey、webhook 凭据)被第一条覆盖,每次保存毁一条。跨类型同 id 更糟:掩码还原按裸 id 查找,一条 bark 写 id: "browser" 会命中内置 browser 条目,把它的掩码还原到 bark 的键上。写面因此对重复身份绝对 400(提示带 1 起的下标并指明「删掉其中一条」);升级步不再按重复身份静默去重(那会在升级那一刻把第二条连凭据一起从磁盘删掉);投递投影按身份只取首项,免得一次通知发两遍——代价是落选的那条从此不再投递,且占身份的是「数组里排在前面的那条」而非「能用的那条」:投递必需键为空串的半坏条目会先占掉身份,把后面一条健康的同 id 条目从投递池里挤掉(半坏条目本身则整条不进投递池)。没有任何一条路径会隐藏或删除你的数据:视图原样外发,删掉一张卡再保存即通过draft.channels 里的单条频道(掩码按 id 还原,跨类型残留掩码整体拒绝),结果同步返回且只进面板独立结果行(打标「草稿测试·未落盘」,通过不自动保存,改草稿即清、重载即丢、切 tab 保留)。回显反射残留(对端把请求体反射回来)与现行历史落盘同 exposure(原文截断后进 reason.detail),仅 loopback 可读,可接受remoteAddress 熔断(关 TOCTOU),TLS 的 SNI 与证书校验仍走原始主机名failed)并进通知记录的逐出口明细;一条命令都构造不出来时收成 skipped 且留一条 warn(0.2.4 起 linux / darwin 也有这条日志,此前只有 win32 分支有)。原生二进制缺失/不可执行(ENOENT 等)会被 error 事件接住,绝不冒泡成 unhandled error 把宿主进程打挂(见 issue #1)
osascript);macOS 无 notify-send,系统通知已用系统自带的 osascript 实现(无需安装)才有);iOS 上可用通道为「页面可见时横幅 + 提示音」及 HTTPS+A2HS 后的系统通知
/diagnostics 的 capabilities.host.sound.players 会列出探测命中的播放器可执行文件名(按回退链顺序给出全部命中者:paplay → pw-play → aplay → ffplay;darwin 恒 afplay),popup/sound 的 checked 会暴露装了 notify-send 与否;/health 只给摘要(verdict / unknownDimensions / popup.state / sound.state,无 players/checked 明细)。这是有意的设计取舍——用户要能看见「宿主放不出声」才谈得上处置——但请知悉它与 lan-proxy 的既有姿态叠加后的含义(该插件 README 已自述「经本插件转发的请求按设计视为受信」)。收敛手段:只出可执行文件名、音色只出布尔,绝不出绝对路径,remediation 的 params 只由内置数据表产生、不经输入透传(响应体里不会出现 /etc/os-release 或任何命令原文)org.freedesktop.DBus 发 NameHasOwner 与 ListActivatableNames 两个只读查询,不触发任何服务激活(不用 busctl status/list,不调 StartServiceByName);darwin/win32 上连这个子进程都不起wx 打开的 WAV(wx 拒绝已存在的路径与符号链接);播放结束立即删掉本次文件,并发的另一笔投递因此不受影响,目录留到进程退出 / 插件卸载时统一清理。/tmp 只读挂载时本次不落盘:只响不弹记为 skipped(reasonSystemToneUnwritable),弹+响仍算 ok 并留一条 warn(弹窗已经出去,声音属尽力而为)/@wingsky-1/dsh-notifier/ 与其下的数据文件(config.json、history.jsonl、status.json、seq.json、version)权限都不随 umask 漂移——新建的目录落 0700,文件每次先在同目录以 0600 建临时名再 rename 覆盖(config.json 与 status.json 内含 bark deviceKey、webhook 凭据与可能回显凭据的失败原因)。已存在的目录按「只保留 owner 三位」收紧(mode & 0700,永不新增任何权限位):旧安装的 0755/0777 会被追溯收紧到 0700,运维刻意设成只读的目录收成 0500、仍然只读,本包不会替他重新打开可写。这条掩码连特殊位一起清:本包走的是系统调用层的 chmod(Node fs.chmod),只按 mode 参数里出现的位设置、参数里没有的位一律清掉,故 owner 之外的 setuid/setgid/sticky 同样被清——实测 2770/1777/4755/7777 一律落成 0700,团队共享目录常见的 2770 的 setgid 也会被静默清掉(方向是加固:私有数据目录上这些位本无实际语义;这是已登记的行为变更)。目录若带 POSIX 扩展 ACL,ACL mask 还会被收紧为 0,命名条目的有效权限随之归零(条目本身仍在)。同一进程内,同一路径的异步写按提交顺序串行落盘(写链是进程内的内存表:rename 的先后在并发下本无保证,落到序号文件上就是客户端按 seq 静默丢帧;两个进程共用同一个 DSH_HOME 时不适用)。同步写 writeTextAtomicSync 不进这条链——同步函数没有 promise 可挂,它服务的是升级期的独占路径,所以热重载二次装配时它可以与在飞的异步写并发,这不在串行保证内。写失败不留临时文件残留(清理本身失败时会留下:文件名带随机后缀,不会被下次写覆盖,也不影响本次真正的失败原因)org.freedesktop.Notifications 的当前 owner。同一 UID 的进程先占住这个名字即可收到通知内容(跨 UID 抢占不成立:session bus 是每用户一个 socket)。对同机同用户下的进程隔离有要求的部署,请自行评估系统通道capabilities 只增不删键;客户端忽略不认识的组与不认识的 `verdict` 取值(渲染为「未知」而不是报错);旧服务端不带 capabilities 时设置页优雅降级。故升级服务端不需要同步升级客户端DSH.dsh-notifier(HKCU,无需管理员权限)——未注册的 AUMID 在 Win10/11 上 toast 会被系统静默丢弃。AUMID 采用 Company.Product 形态,避免在公共命名空间(HKCU\SOFTWARE\Classes\AppUserModelId)与其他同名软件冲突互覆;历史版本注册的旧键 DSH 残留无害(仅一个空注册表条目,不影响新 toast),如需清理可手动执行 Remove-Item -Path "HKCU:\SOFTWARE\Classes\AppUserModelId\DSH"dsh plugin --profile web add @wingsky-1/dsh-notifier安装 / 卸载 / 更新后都需重启一次 dsh web(bundle 层只在启动时组合)生效。按上方部署形态访问,打开「设置 → 插件 → dsh-notifier」,点击「请求通知权限」,发送测试通知,并在通知记录中查看逐频道结果。浏览器默认仅在页面隐藏时弹窗,需要时开启「页面可见也弹」;系统 toast 出现在宿主机而非远程浏览器设备。
curl -s http://127.0.0.1:3080/api/dsh-notifier/health在「设置 → 插件 → dsh-notifier」卡片修改。配置由插件自持,落在 /@wingsky-1/dsh-notifier/config.json;存储、迁移与未知键语义见详细参考。
配置项示例(默认值;channels / kindRoutes / allowKinds 为 M2 新增键):
{
"notifyAsk": true,
"notifyQuestion": true,
"notifyTaskDone": true,
"notifySubagentDone": false,
"notifyTaskError": true,
"notifyTurnEnd": false,
"quietHours": { "enabled": false, "windows": [{ "start": "22:00", "end": "08:00" }], "allowKinds": [] },
"historyMaxAgeDays": 0,
"channels": [
{ "type": "browser", "id": "browser", "enabled": true, "popup": true, "sound": true, "whenVisible": false },
{ "type": "system", "id": "system", "enabled": true, "popup": true, "sound": true }
],
"kindRoutes": {},
"allowKinds": []
}浏览器与系统通知就是 channels 里的两条内置条目:它们与 bark / webhook 实例同住一个数组、同一套渲染与判据,唯一特殊之处是不能删除(写面收到缺内置条目的 channels 会 400)。0.2.3 的 8 个顶层渠道键(systemEnabled/browserEnabled/systemNotify/browserNotify/
notifyWhenVisible/notifySound/browserSound/systemSound)在升级时被**搬进这两条条目
并删除**——升级一次做完,不留「旧键还能读」的第二处表达。升级后再提交这些键会得到 400
(页面停留在升级前时,刷新后重试即可)。
浏览器与系统通知是 channels 数组里的两个内置渠道条目,与 bark / webhook 实例同一份形状、 同一套渲染与判据:
{ "type": "browser", "id": "browser", "enabled": true, "popup": true, "sound": true, "whenVisible": false }
{ "type": "system", "id": "system", "enabled": true, "popup": true, "sound": true }字段 · 类型 · 语义 enabled · boolean · 渠道开关(发不发):每个渠道唯一的投递闸门,关掉 = 完全不投递 popup · boolean · 弹窗开关(弹不弹):关掉而声音开着 = 只响不弹 sound · boolean \ · 音色 id · 声音(响不响、用什么音色) whenVisible · boolean · 页面可见时是否也弹(仅浏览器渠道;随帧下发给页面执行)
「发什么」由渠道自己决定:裁决管线对每个渠道只判 enabled,弹窗与声音原样交给渠道,由它决定 这一次弹、响、只响不弹,还是什么都不发。所以「弹窗关 + 声音也关」的渠道照样会被投递——出口 判定这次没有可发的内容,历史里如实记一条 skipped(既不伪装成投递成功,也不在管线里替出口判形态)。
旧顶层键在升级时被搬走并删除:0.2.3 的 systemEnabled / browserEnabled / systemNotify / browserNotify / notifyWhenVisible / notifySound / browserSound / systemSound 会在 0.2.4 的升级链里搬进上面两条内置条目,随后从配置文件里删除——渠道形态只有条目一处表达。升级后再提交 这些键会得到 400(提示刷新),而不是静默无效。
取值:false = 静音(弹窗仍可弹、不发声);true = 跟随系统默认; 音色 id = 显式内置音色(ding / bell / chime / pop——4 音色全平台语义一致, 在设置卡每通道的「音色」下拉中选择,可点 试听:试听为浏览器本地 Web Audio 合成, 仅作听感参考,实际系统提示音随平台与系统设置)。
投递组合(启用关掉时不投递,与弹窗/声音无关;启用开着时按弹窗 × 声音决定形态):
启用 · 弹窗 · 声音 · 行为 关 · 任意 · 任意 · 完全不投递(发不发只看启用) 开 · 开 · false · 弹通知实体,静音 开 · 开 · true · 弹通知实体,发声交给系统默认 开 · 开 · 音色 id · 弹通知实体;应用自播对应音色(系统通知静音防双响) 开 · 关 · true/音色 id · 只响不弹:不弹实体、仅自播(页面存活 / 宿主自播) 开 · 关 · false · 进池但什么都不发:历史记 skipped,设置卡给出提示
sound上(按出口的 browserSound/systemSound 有值时以它们为准),随后旧键被删除——旧版关闭过 提示音的存量用户在升级后仍保持静音,不会「突然有声」。设置页从 0.2.4 起只写条目, 不再有全局声音开关。
true/音色自播需要页面音频已解锁——浏览器自动播放策略要求一次用户交互(打开通知中心 / 声音行任何交互都会解锁 AudioContext); 纯后台从未交互的页面,声音可能不可用(此时通知照常弹出、仅无声),属浏览器 策略约束而非插件缺陷。
(GNOME 默认无声 / KDE 2025 才支持 / Xfce 依赖 libcanberra),系统渠道的 sound: true 解释为「默认事件音自播」——宿主按 paplay → pw-play → aplay → ffplay 的回退链依次尝试、首个成功即停(防双响),不依赖守护进程。freedesktop 事件音 缺失时改用运行时合成的提示音(0.2.4 起),不再因缺声音主题而静默。 只探测到服务型播放器是一个已知降级:paplay/pw-play 在没有声音服务的 宿主上必失败,故能力面报 degraded 并给 host-only-sound-server-players——装一个 直连 ALSA 的播放器即可(alsa-utils 提供 aplay、ffmpeg 提供 ffplay; dnf 系上 ffmpeg 来自 RPM Fusion)。宿主没有音频设备时仍然无声。 存量 Linux 升级行为变化:之前系统通知无声(notify-send 无声音 hint),升级后 声音开 = 自播事件音。
音色 · 浏览器(Web Audio 合成) · macOS · Linux(事件音,缺失时合成) · Windows ding · 双短高音 · Glass(NSSound) · message-new-instant.oga · C:\Windows\Media\Windows Ding.wav bell · 单中高音 · Tink · bell.oga · Windows Chimes.wav chime · 三音上行 · Sosumi · complete.oga · Windows Chord.wav pop · 短促低音 · Pop · message.oga · Windows Balloon.wav true(跟随系统) · OS 默认(不 silent) · Glass(现状保留) · 默认事件音自播(缺失时合成) · toast 默认系统音;只响不弹时近似默认音 wav
macOS 发声受系统「允许通知声音」设置约束;Windows 音色经宿主 SoundPlayer 播放系统内置 wav(白名单路径,缺失静默);Linux 事件文件为 /usr/share/sounds/freedesktop/stereo/ 下基线包确定存在的 oga(多路径探测, 缺失时改用运行时合成的提示音)。自播一律数组传参(无 shell 拼接面),文件走 白名单路径。
/api/dsh-notifier/health 的 platform 字段(设置页系统卡按它显示平台提示——浏览器 OS 与宿主 OS 可能不同机,别混淆)。
kindRoutes:kind → channelId[] 稀疏路由(如 { "error": ["browser", "system", "bark:phone"] }); 未声明条目的 kind 广播全部启用频道;设置页事件区可双向编辑(与频道卡共享同一份配置)。 allowKinds:已确认的动态 kind 清单(其他插件注册的通知类型经你确认后持久化于此)。
设置 tab「通知中心 → 投递频道 → 添加 Bark 推送」配置(也可直接编辑上述配置 JSON)。 每实例字段:id(自动生成后锁定)、name(显示名)、baseUrl(Bark 服务器地址, http/https)、deviceKey(Bark App 内查看;响应中一律掩码 ********,提交掩码 = 保持原值)、enabled(默认 false——出站授权须显式开启)。
可选参数(全部缺省不发送;#1016 S2 起不再透传频道条目上的未知键——它们不进入 生效设置、不进入推送体,提交时 400;device_key/device_keys/ciphertext 为保留键, 话术与「从未合法」的键分开):
字段 · 说明 sound · 铃声名(Bark Sounds 列表) group · 分组(同组在手机上折叠) icon · 图标 URL(需手机网络可达,非服务器可达;SVG 需 iOS 17+;留空用 Bark 默认) url · 点击通知跳转 URL badge · App 角标数字 level · 实例级紧急度覆盖;缺省按事件 severity 自动映射:failure→timeSensitive、warning/success→active、info→passive levels · 按事件(kind)紧急度稀疏映射(见下)
levels(kind→level 稀疏映射矩阵):为具体事件类型指定 Bark 紧急度, 优先于实例级 `level` 与 severity 自动映射;未配置的类型走默认。适合「提问必响、 子任务完成静音」这类按事件差异化诉求:
{ "id": "phone", "type": "bark", "baseUrl": "https://api.day.app", "deviceKey": "…",
"enabled": true, "levels": { "question": "timeSensitive", "subagent-done": "passive" } }ask/question/done/subagent-done/error/turn-end/test 或动态 kind,任意字符串);值限 active / timeSensitive / passive / critical;至多 64 项(写面拒本次超限的提交, 磁盘上已存在的超限旧值不因一次无关保存被拒)、每键至多 64 字符。
levels[kind] > level > severity 映射 > 不携带。critical 需苹果特殊授权(普通 App 无法申请),未获授权时 Bark 可能降级/拒绝。kindRoutes(kind→channelId[] 路由)正交:路由决定「投给哪些频道」,levels 决定「在本实例上多响」。投递可靠性:10s 硬超时、网络错误/5xx 重试 ×2(4xx 不重试)、实例级在途并发 ≤2 (内置频道不受限);成功判定双查 HTTP 2xx + 响应体 code===200。 投递终态落盘到本插件的状态文件(见下「存储布局」),设置页 频道卡状态行在卡片加载与发送测试后经 GET /api/dsh-notifier/status 刷新(无轮询,D20 口径)。
POST {baseUrl}/push + JSON body(device_key 字段)——反代 access log 默认只记 URL 与 header,正文不落日志deviceKey 一律掩码 ********;提交整值掩码 = 保持原值(按实例 id 对齐回填,防止数组顺序变化串凭据)status.json,不再按 device key 字面替换,也不再有 sent 事件这一路出口(详见「安全模型」)baseUrl 限 http/https scheme、拒绝内嵌凭据的 URL(user:pass@host)。这些判据(另加 URL 解析失败)由出站前硬闸(deliver/url-gate.ts)在 fetch 之前逐条判定:不合规的目标根本不发,原因进失败理由的 detail(retryable=false,不重投——URL 是配置事实,重投只是把同一个错地址打三遍)。不做域名白名单——baseUrl 指向内网自建 bark-server 是合法场景baseUrl + "/push" 字符串拼接,base 带 query 时 /push 会掉进 query 串(实际打到被截断的路径),base 带尾斜杠时拼出 //push 双斜杠;改后 /push 一定落在 pathname 上,query 原样留在后面?token=、?api_key=)会随 URL 进对端访问日志,现在不再有任何机器拦截,只剩本 README 的「凭据只走请求头、不拼 URL」文档约定。凭据型 query 的诊断告警需新的展示面(配置批次范围),写面按关键字拦又会误伤用户自建网关的正常 ?api_key=,两侧均登记为 follow-up/test 触发一次对 baseUrl 的出站 POST(半盲,响应错误摘要仅回显一段截断后的原文)。对该风险敏感的部署可将插件 enabled 关闭或用独立端口方案(后续版本)设置 tab「通知中心 → 投递频道 → 添加 Webhook 推送」配置(也可直接编辑上述配置 JSON)。 用途:安卓经 ntfy / Gotify / 自建推送网关接收通知,补齐 Bark(iOS)未覆盖的推送 通道——每次投递向 url POST 一份 JSON body。
每实例字段(type 固定为 "webhook"):
字段 · 说明 id · 实例 id(2-32 位小写字母/数字/连字符,创建后锁定;kindRoutes 对齐键与掩码回填对齐键) name · 显示名(缺省回退 id) url · 目标地址(写面只校验非空串;出站前硬闸拒绝非 http(s) 与内嵌凭据 URL,query 原样保留) enabled · 是否启用(默认 false——出站授权须显式开启) auth · 认证方式:none(默认)/ bearer / basic / header token · Bearer 模式只填写裸 token(例如 tk-...),不要填 `Bearer tk-...`;插件会生成 Authorization: Bearer ,填写前缀会导致重复前缀与 401(secret:响应一律掩码 ********) username · Basic 认证用户名(非 secret) password · Basic 认证密码(secret:响应一律掩码) headerName / headerValue · 自定义请求头认证(约束见下)。 preset · 预设:ntfy(默认)/ gotify / custom(自建网关);决定 {{priority}} 映射与默认模板 template · JSON body 模板(≤8192 字符,写面拒本次超限的提交;留空 = 预设默认模板) timeoutSec · 投递超时秒(1-60,默认 10;服务端权威 clamp)
自定义请求头约束:自定义请求头认证(headerValue 为 secret:响应掩码);头名限字母/数字/连字符(≤64 字符),禁 content-type / content-length / host / cookie / authorization
预设与 {{priority}} 频道感知映射(按 preset 选择映射表;{{severity}} 恒为 severity 原文):
preset · info · success · warning · failure ntfy · 3 · 2 · 4 · 5 gotify · 3 · 3 · 7 · 9
custom 不映射——{{priority}} 直出 severity 原文,由网关自行处理。