DSH HUB
HomePlugin StorePlugin PacksCommunityRankingsResourcesPublish Guide
Plugin source
Back to catalog

ElioFanyu /

ElioFanyu/dsh-desktop-bridge

Verified

This plugin has no description yet.

★ 0 Stars0 Forks0 IssuesN/A Community rating0 Confirmed installs
View on GitHub
READMESource: main@6b25cc32

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)

按顺序判定,先命中先返回:

  1. 去重:最近 200 条 dedupeKey 命中即丢弃
  2. 免打扰:quietHours 内只记日志
  3. 焦点门控(默认关闭):只门控 done,且只在「该会话正是主视图那一个 + DSH 在前台」时压掉;waiting 和 failed 从不门控——等待需要你动手、失败需要你重试,都跟你在看什么无关
  4. 限流:同类最小间隔 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 变了。升级最容易打破的三个点:

  1. sessionStatus 快照字段改名或增删 → 页面永远算不出边沿,received 一直不涨
  2. api-session/error 改事件名或参数顺序 → 失败退回「一律报完成」
  3. 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

—/ 5

No ratings yet

Verified DSH bundle

Commit 6b25cc323057

Community comments

No comments yet. Be the first to write one.

DSH HUB

A community index for DSH plugins. Not an official GitHub or DeepSeek AI product.

CommunityResourcesAPIAbout