
字节笔记本
2026年10月6日 · 约 9 分钟读完
DeepSeek Harness 网页插件启动机制拆解
DeepSeek Harness(包名 @deepseek-ai/dsh,简称 dsh)是 DeepSeek AI 开源的 agent harness,在 GitHub 上以 MIT 协议发布,架构主张"一切皆插件",底层插件框架是 Cordis。大多数解读都围着 agent 主循环转,这篇换一个角度,拆它 Web GUI 栈里的一个可选机件:client 模块系统。它由包 dsh-client-modules 提供,以 ctx.clientModules(即 ClientModuleRegistry)的形式暴露,回答一个具体的问题:浏览器端插件是怎么被发现、怎么被组装成启动入口、又怎么被送进页面跑起来的。
先划清边界:这只是这套系统的 Node 半。同一个包还有浏览器半,即拉取并物化这些 bundle 的 ctx.modules 惰性 CJS 模块表,那部分属于内核机件,本文不展开。client 模块系统也不在 agent loop 主干上,它是 Web GUI 栈的可选能力,同时是 dsh-host-webserver 的消费方:Web 载体提供前缀路由与 index 转换,本服务在其上注册自己的资源。
一个服务的四个面

dsh-client-modules 做的其实是同一件事的四个侧面:扫描宿主 Loader 的 entry,找出声明了 dsh.client 的包;把这些包组合成 window.__DSH_BOOT__ 的 entry 图;在 /plugins/<id>/client.js 上提供各个 bundle;再经 index 转换把启动清单注入页面。
四个面共享同一份数据,也就是下一节的启动图。理解了这张图,四个面就都成了对它的不同操作:扫描产生它,组合维护它,路由分发它的行,注入把它送进浏览器。
wire:启动图是协议层的唯一真源
宿主从扫描到的包组合出 WebBootEntry 行,再把整张图作为 <head> 中的第一个脚本注入,形如 window.__DSH_BOOT__。注入时 < 会被转义,插件可控的字符串因此无法逃出 script 元素。浏览器壳在启动任何东西之前先解析它:没有有效 manifest 的页面无法启动,图缺失或畸形时,浏览器侧的解析器会大声抛错。
interface WebBootEntry {
/** Entry name == package name. */
id: string
/** Bundle endpoint, '/plugins/<id>/client.js?rev=<rev>'. */
url: string
/** Bundle content hash (cache-busting anchor). */
rev: string
/** Package-name dependency edges, informational. */
inject?: string[]
/** Stage-one prefetch mark. */
immediately?: boolean
}
interface WebBootGraph {
/** Consistency anchor over the whole graph. */
rev: string
entries: WebBootEntry[]
}每一行的 rev 是该 bundle 的内容哈希,并作为使缓存失效的查询参数附在 URL 上;图的 rev 对组合后的各行再做哈希,因此任何一行变化都会改变它。immediately 标记第一阶段预取档位:这些行在模块面启动期间就会被 fetch 并执行,但只做工厂登记;其余惰性行等到首次 import 时才拉取。
扫描:单包增量,没有全量重扫
包加入这张表的方式,是在自己的 package.json 里声明 dsh.client(其中 platform 为 'web',可选 inject 边与 immediately),并在 exports["./client"] 导出构建好的 bundle。包解析锚定在配置树的 ctx.baseUrl,即 cordis.yml 所在目录,该目录的包会把每个被组合的插件声明为依赖;这个锚点未设置时,构造直接抛错。
扫描是单包增量的,不存在全量重扫的代码路径。fiber 构造或 dispose(资源释放)时的每一次 cordis internal/plugin 发射,都会把该 fiber 的 entry 名标脏,随后一次微任务 flush 把每个脏名与实时 loader entry 对账。激活趟则把全部当前 entry 灌进同一个脏集合并同步 flush,因此初扫与稳态共享一条实现,但失败姿态相反:激活时,已加载 entry 中的畸形声明或缺失 bundle 会聚合为一个大声的 AggregateError,列出每个损坏的包,该 fiber 进入 FAILED,由启动的大声失败 sweep 上报;稳态下,损坏的包只记录一条警告,且不得殃及其他包。
包元数据的缓存策略也很果断:包括「这不是 client 包」这样的否定结论在内,一律按名缓存且永不过期,插件集合的变更在重启后生效。fiber 重启会原样复用其行与 rev,bundle 内容变更只有经 rebuilt() 才会到达图。
bundle 路由与 index 转换
GET 与 HEAD /plugins/<id>/client.js 以 no-cache 从磁盘提供已注册的 bundle,锚定一致性的是 rev 查询参数而不是 HTTP 缓存;其他方法返回 405。未知 id,或者已注册但 bundle 因尚未构建而不可读的行,会得到一个大声的 404,而不是让载体的 SPA 回退把 HTML 当作 JavaScript 发出去。index 转换在每次 index 渲染时注入当前图,因此刷新页面总是针对实时组合启动。
注册表服务:读面与重建面

ClientModuleRegistry 暴露读取面与重建面。graph() 返回当前组合出的图,两次变更之间是同一个稳定对象;clientPath(id) 返回某个 bundle 的绝对路径;rebuilt(id) 是 bundle 内容到达图的唯一入口,它对文件重新哈希,只有 rev 真正变化才会重新组合图并发出通知。
通知有两条路径:onRebuilt 按发生变化的 bundle 逐个触发并携带新 rev;onGraphChanged 在任何一次重新组合了图的 flush 之后触发,可能是行的增删,也可能是 rebuilt 带来的 rev 变化,并采用拉取模型,监听器自行重读 graph()。两条通知路径都会兜住监听器异常,一个抛错的订阅者既不能让后续订阅者被跳过,也不能杀死触发这次 flush 的一方。
开发态的 HMR 与生产态的差异
开发环境下,dsh-client-hmr 是注册表的监视驱动:它的 Node 半从同步取得的基线出发,对图中每一行的 bundle 做 stat 轮询,变化时调用 rebuilt(id),经 onGraphChanged 重新同步监视集合,并通过 SSE 把 rev 变化广播给浏览器半。生产环境的图完全不含 HMR 行,模块宿主自身从不监视文件。
把「网页插件如何跑起来」这件事收敛成一张以内容哈希锚定一致性的启动图,再用增量扫描和一个显式的 rebuilt() 通道维持实时性,这是 DeepSeek Harness 把「一切皆插件」贯彻到 Web GUI 的方式。想给 dsh 写网页插件,或者想研究前端按需加载的设计,这条链路都值得去源码里走一遍,入口在仓库的 packages/client/modules。



