dsh-desktop-bridge
在屏幕右下角弹 Windows 系统通知:会话需要你确认时一条,任务完成时一条,执行失败时一条。
一个插件搞定,没有配套桌面程序、没有第二个端口、没有发现文件、没有要手动启动的东西。
为什么是这三类时机
DSH 页面里的确认面板只在你正看着 DSH 时才有用。真正会丢消息的情况是反过来的——你在别的窗口忙,会话卡在等你批准,或者已经跑完了、甚至已经报错了,而页面在后台。
所以这里只在三个边沿上打扰你:
- 需要你确认:
pendingInteraction从无到有。kind有approval(批准操作)、plan-review(确认计划)、question(回答问题)三种,通知正文会写明是哪一种。 - 任务完成:
running从 true 变 false,且这一轮没有报错。也看completionUnread,但它只对非主视图的会话设置,只看它会漏掉「你正看着它跑完」这个最常见的情况。 - 执行失败:同一时刻,但这一轮真的出错了。失败的轮次不会产出任何东西,通常还等着你重试,把这种结局叫作「任务已完成」是纯粹的误导——用户会以为活干完了。
失败是怎么认出来的
uiSession.sessionStatus 的快照只有 {running, pendingInteraction, completionUnread} 三个字段,没有失败位:干净跑完和报错停掉在它看来一模一样。所以失败信号来自客户端的另一条事件通道 api-session/error(宿主由 agent 循环的 agent/error 转发而来,每个真正失败的轮次一条,带错误文本)。
关键是它只在真出错时发射:用户自己按停止不会产生 agent/error(那条路径走 aborted),因此「你主动中断」不会被误报成失败。浏览器端收到错误就按会话记一笔,等随后的 running → idle 边沿到来时取用一次:
- 新的一轮开始时清掉旧记录,避免上一轮的失败给下一轮的健康收尾染色;
- 记录超过 2 分钟就作废,避免一个与轮次无关的错误(比如后台激活失败)在很久之后才误伤一次正常完成。
失败通知永远不受焦点门控影响:页面会把错误显示在对话里,而那正是走开的人不会看的地方。
一轮只弹一次
running → idle 和 completionUnread 说的是同一件事,但它们不是同一瞬间变真的:宿主在 idle 边沿之后才把这个标记落下去。所以同一轮会先后命中两个分支,弹出两条提示:
06:21:04 任务已完成 | 「删除会话插件来源」 已执行完毕:任务已完成
06:21:24 任务已完成 | 「删除会话插件来源」 已执行完毕:后台任务已完成
浏览器端因此维护一个「这一轮已经报过收尾」的集合:任一分支报过就不再报,只有新的一轮(running 由 false 变 true)才重新武装。这也顺带解决了「刷新页面时把已经完成的会话重播一遍」——首次观测到且已 idle 的会话直接算作已报过。
首次观测到的会话只记基线不通知,否则一刷页面就会把所有在等的会话重播一遍。同一会话同一类型还有冷却,避免状态反复发布把提示刷屏。
架构
浏览器渲染进程 宿主进程(DSH 自己)
────────────── ─────────────────────
uiSession.sessionStatus + api-session/error
│ 订阅、比对、取边沿(前者给时机,后者给成败)
▼
POST /__dsh-desktop-bridge/ingest
│(同源,带 Bearer token)
└───────────────────────────► 路由 → 调度器 → Toast
(去重/免打扰/限流/AUMID)
会话状态只存在于渲染进程(ui-session 的宿主半边是个空 apply() {}),而发系统通知只能在宿主进程里做。所以两边必须有一次进程内通信——这就是那条 HTTP 路由的由来。
两条信号分工明确:sessionStatus 说「什么时候结束」,api-session/error 说「结束得体不体面」。后者不能替代前者(它只在出错时发,干净跑完时什么都不发),前者也替代不了后者(它没有失败位)。
路由挂在 DSH 自带的 web 服务器上(ctx.webServer.register),不另开端口。浏览器端是同源请求,没有 CORS。
文件
| 路径 | 职责 |
|---|---|
lib/client.js |
浏览器半边:订阅 sessionStatus 取边沿、订阅 api-session/error 判成败、POST 事件 |
lib/index.js |
宿主入口:注册路由、注入 token、生命周期 |
lib/ingest.js |
路由处理:鉴权、限体积、把载荷规范化成事件 |
lib/notifier.js |
事件 → 通知:调度器、AUMID 预热、销毁 |
lib/host/toast.js |
常驻 PowerShell 5.1 工作进程发 toast(预热后 0 延迟) |
lib/host/dispatch.js |
去重、免打扰时段、焦点门控、限流 |
lib/host/aumid.js |
注册 AUMID,让通知标题显示为「DSH 任务提醒」 |
lib/host/activate.js |
把 DSH 窗口拉到前台(点通知时用) |
lib/host/state.js |
日志与 settings.json |
lib/host/ps.js |
守护式调用 PowerShell 5.1(PS 7 没有 WinRT,硬约束) |
路由
| 路由 | 认证 | 用途 |
|---|---|---|
POST /__dsh-desktop-bridge/ingest |
Bearer token | 浏览器端上报事件 |
GET /__dsh-desktop-bridge/health |
无 | 存活探测 + 各阶段计数 + 本进程接受的 kind,不泄露任何内容 |
POST /__dsh-desktop-bridge/probe |
Bearer token | 仅在 debug 开启时:浏览器端把每次状态发布的内容记进日志 |
"同源"不等于"可信"——本机任何进程都能连 127.0.0.1。所以宿主每个进程随机生成一个 token,用 webserver/index-inject 注入进 index.html,/ingest 要求它作 Bearer 凭证,随进程消亡。
globalThis.__DSH_DESKTOP_BRIDGE__ = {
token: "…",
protocol: 1,
kinds: ["waiting", "done", "failed"], // 宿主认得哪些 kind
debug: false,
}
每次渲染 index 都会重新注入,刷新页面后 token 不会失效。
kinds 是给「页面已经刷新、宿主还没重启」这段窗口准备的:浏览器端只发宿主声明支持的 kind,碰到不认识的就跳过并在控制台留一行,绝不把 failed 降级成 done。详见「已知边界」。
事件格式
{
"v": 1,
"kind": "waiting",
"sessionId": "abc123",
"title": "重构登录模块",
"detail": "等待你确认计划",
"at": 1792206130837,
"dedupeKey": "waiting:abc123:1792206130"
}
dedupeKey 由宿主按 kind:sessionId:秒 盖出来,浏览器端只管发前五个字段——重复投递由宿主识别。
kind 有三种取值:
| kind | 含义 | 正文 |
|---|---|---|
waiting |
会话卡在等你动手 | 需要你确认 · 「会话」正在等待你的操作:<等待你干什么> |
done |
这一轮干净跑完 | 任务已完成 · 「会话」已执行完毕:任务已完成 |
failed |
这一轮报错停掉 | 任务失败 · 「会话」执行出错了,需要你处理:<错误首行> |
failed 的 detail 是错误信息的第一行,超过 120 字截断——堆栈和多段散文在通知里都读不了。
title 取 sessions.list 的行投影,无标题时退化为 会话 <前8位id>。
isMain 由浏览器端补上(该会话是否正占据主视图)。宿主看得见窗口,但只有页面知道屏幕上摆的是哪个会话,焦点门控需要这一位——否则「你在看 DSH」会连带压掉其它会话的完成提醒。
提醒规则(lib/host/dispatch.js)
按顺序判定,先命中先返回:
- 去重:最近 200 条
dedupeKey命中即丢弃 - 免打扰:
quietHours内只记日志 - 焦点门控(默认关闭):只门控
done,且只在「该会话正是主视图那一个 + DSH 在前台」时压掉;waiting和failed从不门控——等待需要你动手、失败需要你重试,都跟你在看什么无关 - 限流:同类最小间隔 3 秒,超限的攒成一条摘要(「另外 2 个会话也在等你」)
设置写在 %LOCALAPPDATA%\dsh-desktop-notifier\settings.json(改完重启 DSH 生效):
{
"throttleMs": 3000,
"suppressWhenFocused": false,
"quietHours": { "enabled": false, "from": "23:00", "to": "08:00" },
"debug": false
}
suppressWhenFocused 默认 false:一轮任务结束时页面本身不给任何提示,这正是这个插件存在的理由——默认静音会把它变成一个看起来坏掉的功能。debug 打开后浏览器端会把每次 sessionStatus 发布的快照 POST 到 /probe,用来判断「渲染进程到底有没有看见这次状态变化」。
没弹窗怎么查
日志在 %LOCALAPPDATA%\dsh-desktop-notifier\notifier.log,一条事件的完整生命线都会留下痕迹:
| 日志 | 含义 |
|---|---|
完全没有 ingest: |
事件没离开浏览器(渲染半边未订阅上,或 token 没注入) |
auth: POST /ingest rejected |
token 不匹配,通常是页面缓存了上一个进程的 token,刷新即可 |
ingest: done "…" + dispatch: shown |
已交给系统,问题在 Windows 通知设置/专注助手 |
dispatch: suppressed (…quiet hours…) |
免打扰窗口命中 |
dispatch: suppressed (…on screen and DSH is focused…) |
焦点门控命中,把 suppressWhenFocused 改回 false |
dispatch: throttled |
3 秒限流,事件攒成了摘要 |
dispatch: duplicate |
同一事件重复投递,正常情况下不该出现 |
| 失败的一轮弹了「任务已完成」 | api-session/error 没到达渲染进程:控制台应有 [desktop-bridge] cannot subscribe to api-session/error |
第三种(failed)如果始终不出现,先确认 lib/client.js 的 inject 里有 remote——api-session/error 走这条通道,缺了它就只能退回「一律当作完成」。
GET /__dsh-desktop-bridge/health 用计数器把这几段分开:received(到达宿主)→ shown(真的弹了)/ suppressed。它还返回 kinds,也就是这个宿主进程真正接受的 kind 列表——刷新页面不用重启就能换浏览器半边,所以「我页面上是新的,宿主是不是旧的」是个真问题,答案就在这里:
(Invoke-RestMethod http://127.0.0.1:4814/__dsh-desktop-bridge/health).kinds
# ["waiting","done","failed"] -> 宿主已跟上
# ["waiting","done"] -> 宿主还是旧的,失败那一轮会保持安静,重启 DSH
已知边界
- 没有逐条 todo 清单。
todos是 session 作用域的 keyed projection,只有会话内的对话视图能读,这里拿不到。通知只能给会话级信号。 - 失败按「轮次」而不是按「错误」报。 一个轮次里连续报错多次只会在收尾时提示一次,正文取最近一次的错误信息。
title依赖投影就绪。 会话刚建立时投影可能还没算出来,此时标题退化为 id 片段。- 事件不落盘。 通知是即时的,补发过时通知只会制造噪音。
- 通知标题「DSH 任务提醒」来自本机 AUMID 注册,不是插件名。
- Windows 专属。 toast 走 WinRT,只有 Windows 有。
- 两半不同步。 刷新页面就换上了新的
lib/client.js,而lib/index.js要重启 DSH 才换。宿主注入的__DSH_DESKTOP_BRIDGE__.kinds声明它会收哪些 kind:页面碰到宿主还不认识的 kind 就不发(只在控制台留一行提示),不会降级成done——把失败说成「任务已完成」正是要修的那个 bug。所以刚升级完、还没重启时,失败那一轮是安静的;重启后立刻恢复。想知道当前运行中的宿主是哪一版,读/health的kinds即可。 - 失败提醒只在「轮次收尾」出现。 依赖宿主转发的
api-session/error,它在 agent loop 里由throwError发出,早于running → idle的相位切换;用户主动停止(aborted)不发这个事件,所以停止不会被报成失败。
出错时的行为
所有失败路径都只记日志、不抛异常。插件起不来绝不能拖垮 DSH——apply 抛错会触发红色「Failed to load plugins」横幅,还会连累同一 profile 的其它插件。
同样地,lib/client.js 必须保持 window.__ModuleLoader__.load({ id, factory }) 的经典脚本信封:一个顶层 export 就会让渲染进程抛 Unexpected token 'export',导致整个 web boot 失败(web boot: 1 entry did not activate)。这是本项目真实踩过的坑,test/contract.mjs 专门守着它。
兼容性
插件只依赖五个宿主/客户端 API,全部按 0.2.2-rc.1 的已装源码逐个核对(不是照文档猜的),宿主半边另在真实 cordis 4.1 运行时上跑过一遍 apply → 注入 → dispose 全生命周期。行为契约由 test/contract.mjs(23 项)、test/defaults.mjs 与 test/host-apply.mjs(16 项)覆盖。
| 依赖的 API | 用途 | 在 0.2.2-rc.1 上核对结果 |
|---|---|---|
webServer.register({kind, path, handler}) |
注册三条路由 | 仍在,冲突时仍抛 webserver: duplicate ${kind} route "<path>"(本插件用 kind:'exact'),仍返回 disposer |
webserver/index-inject 事件 |
把 token 注入 index.html | 签名从 (table) 扩成 (table, peer):collectIndexInjections() 里是 this.ctx.emit("webserver/index-inject", table, peer)。多出来的 peer 对本插件无意义(token 认证的是路由,不是 Peer),监听器只读第一个参数,两个版本都能工作 |
uiSession.sessionStatus 快照 |
取 running / pendingInteraction / completionUnread 边沿 |
三个字段名与含义未变,publishStatus() 结构逐字一致;pendingInteraction 仍是带 kind 的类实例(approval / plan-review / question) |
sessions.list 的 retainedBy.mainView |
判断会话是否在主视图 | 仍在,isMain() 仍按 retainedBy.mainView 判定 |
remote.$on("api-session/error", (sessionId, message)") |
判定失败 | 仍在 API_REMOTE_FORWARDED_EVENTS 白名单里(mode:'emit',resources.session),$on 签名未变(message 为 stringified error) |
0.2.2-rc.1 真正踩到的两处
这两处不是「文档说是这样」,是在真实 cordis 4.1 上跑出来的:
1. cordis 4.1 没有 ctx.off。 事件 mixin 只有 on / once / parallel / emit / serial / bail / waterfall(@deepseek-ai/cordis 4.0.4 与 4.1.0 都是如此)。旧代码的退订写成:
bus.on('webserver/index-inject', listener)
disposers.push(() => bus.off?.('webserver/index-inject', listener)) // off 不存在 → 什么都没做
?. 让这行静默通过,于是监听器活过了 dispose,插件卸载后每次渲染 index 都会再塞进一个已失效的 token。正确做法是用 on() 的返回值——它本身就是这个监听器的退订器,且随 fiber 自动释放:
const off = bus.on('webserver/index-inject', listener)
disposers.push(typeof off === 'function' ? off : () => bus.off?.(...))
订阅也改挂在插件自己的 ctx 上(事件总线在整棵树里是共用的,宿主 webServer.ctx 的 emit 一样能收到),这样 teardown 由 cordis 的 fiber 作用域兜底,而不是靠插件手写。
2. ctx.logger 上根本没有 .log。 它是可调用的 logger 服务,方法只有 error / warn / info / debug。旧代码按 ['info', 'log'] 依次找,找到 info 就 return,表面上「有日志」,实际全进了宿主控制台——而打包后的桌面版宿主子进程没有挂控制台,于是 notifier.log 里连一条 ingest: 都没有(历史日志实测:toast: 250 条,ingest: 0 条)。这也让 README「没弹窗怎么查」那张表失效:表里让你 grep 的 ingest: / dispatch: / auth: 三种行,当时一条都写不进文件。
现在 log() 同时写两处:notifier.log(README 让你读的那个文件)+ cordis logger(DSH 自己的启动诊断)。test/host-apply.mjs 增加了断言,直接检查这个文件里确实出现了那三种行。
验证过的版本:@deepseek-ai/dsh 0.2.2-rc.1(DeepSeek Harness 0.2.2-rc.1,Electron 44.7.0,cordis 4.1.0,Node 24.21.0)。
0.2.2-rc.1 上除此之外的核对结论(均不需要改代码):
webServer新增resource型路由与registerFallback({resource})、readResource()、serveResource();插件用的是handler型exact路由,语义与 disposer 都没变。client-modules新增包内./assets/*资源路由(/plugins/<pkg>/assets/<path>)与按 Loader entry 名的增量对账;插件只暴露一个client.js,不受影响。client-ui-session的客户端inject多了remoteAccess、apply()走ctx.slots.provideRoot(...);插件只读ctx.uiSession.sessionStatus,不受影响。api-remotes的白名单新增llm/models-updated、plugin-manager/update-state;api-session/error条目逐字未变。api-gateway客户端把privateEvents(ctx)从返回ctx改成返回ctx.events($on的落点),对外签名不变。
上一轮(0.2.1-rc.5)的核对结论
rc.3 → rc.5 之间那五个面无需改代码,rc.5 上的实测结论也仍然成立:
- 宿主半边:
GET /__dsh-desktop-bridge/health返回kinds:["waiting","done","failed"];test/host-apply.mjs全过并真的弹出两条通知。 - 浏览器半边:运行中页面的 combo URL
dsh-app://app/plugins/??…,dsh-desktop-bridge/client.js&rev=…已包含本插件,Code Cache 有对应编译产物。 - 真实投递:
notifier.log记录了升级后的「任务已完成」通知实际送达。
rc.3 里与本插件相关、但不影响行为的变化,升级时不必改代码:
webServer的 Config 收紧:host必须是具体 IP 字面量(不再接受0.0.0.0),并新增tls配置与getOrigin()/protocol;插件只用register,不受影响。client-modules的 bundle 路由开始拒绝client.node.js之类的包内 chunk,且 bundle 改为异步读取;插件只暴露一个client.js,不受影响。dsh-desktop-host改为--host 127.0.0.1 --port 0(端口每次随机),URL 用webServer.protocol拼;/health仍从 DSH 自带 web 服务器读出实际端口。
DSH 升级后如果通知失灵,先跑 /health 看 kinds;再对照上表看哪个 API 变了。升级最容易打破的三个点:
sessionStatus快照字段改名或增删 → 页面永远算不出边沿,received一直不涨api-session/error改事件名或参数顺序 → 失败退回「一律报完成」ctx.off这类不存在的方法被?.静默吞掉 → 功能看着正常,只在卸载/重载时泄露。这类问题测试比手读源码可靠,test/host-apply.mjs的 dispose 断言就是为它加的。
任务清单
维护一个功能时可以直接照着走:
- 基础提醒:会话需要确认 / 任务完成时弹 Windows 系统通知
- 合并为单插件:合并掉旧的双进程架构,无配套进程、无第二端口
- 完成提醒默认不被静音:
suppressWhenFocused默认 false(踩过:默认 true 让功能看起来完全坏掉) - 失败不再冒充成功:接
api-session/error,失败的一轮弹「任务失败」 - 两半版本不同步的保护:宿主声明
kinds,页面不向旧宿主发它不认识的 kind(宁可不弹,不弹错) -
/health可查宿主版本:返回kinds,把「宿主是新是旧」变成一次 GET - 一轮只弹一次:修掉
completionUnread晚到导致的重复提示 - 真实失败轮次现场验证:
06:20:59 toast: 任务失败 | 「问候交流」…:fetch failed - 适配 0.2.1-rc.2:逐个核对五个 API 面,均未变
- 适配 0.2.1-rc.3:五个 API 面按 rc.3 源码重新核对,均未变;同时把兼容性表里写错的冲突文案(实际是
duplicate <kind> route)和「四个 API」的笔误改正 - 适配 0.2.1-rc.5:五个 API 面按 rc.5 源码重新核对,均未变;运行中进程
/health正常、页面 combo URL 已含本插件、notifier.log记录升级后的真实送达 - 适配 0.2.2-rc.1:在真实 cordis 4.1 上跑出两处旧代码就存在的缺陷并修掉——
ctx.off不存在导致 index-inject 监听器无法解绑(用on()返回的 disposer 替代,并改挂插件自身 fiber),ctx.logger.log不存在导致诊断行只进控制台、notifier.log永远是空的(改为双写,README 的排查表恢复有效)。index-inject新签名(table, peer)已兼容;两项新增断言在旧代码上会失败,已验证 - 可选:点通知跳回对应会话(当前只把 DSH 窗口拉到前台,不定位会话)
- 可选:会话标题变化时更新去重键(当前同秒同名事件会被去重)
测试
node test/contract.mjs # 浏览器半边:信封格式、inject 完整性、边沿行为(含失败判定、一轮一次)
node test/defaults.mjs # 默认值与路由命名:默认静音这类回归由它守着
node test/host-apply.mjs # 宿主半边:路由注册、token 注入、鉴权、去重、kinds 声明
# 会真的弹两条通知,这是刻意的
node scripts/check-bundle.mjs # bundle 静态契约
node scripts/verify-profile.mjs # profile 接线是否完好
安装
// <profile>/package.json 里
"dependencies": { "dsh-desktop-bridge": "link:D:/Workspace/DeepSeek Harness/dsh-desktop-bridge" },
"dsh": { "profile": { "bundles": ["…", "dsh-desktop-bridge"] } }
# <profile>/cordis.patch.yml 里
- insert:
- id: dsh-desktop-bridge
name: dsh-desktop-bridge
改完需要重启 DSH(宿主半边不会热加载)。
许可
MIT
No comments yet. Be the first to write one.