dsh 插件报「未注册 hmr」又空白?两处根因排查与解决
关键词:DeepSeek Harness、dsh 客户端插件、CodeMirror 6、ESM/CJS 打包、inject 工厂
目录
- 问题现象
- 环境信息与复现
- [加载失败 牵连注销 hmr](#加载失败 牵连注销 hmr "#%E5%8A%A0%E8%BD%BD%E5%A4%B1%E8%B4%A5-%E7%89%B5%E8%BF%9E%E6%B3%A8%E9%94%80-hmr")
- [是 hmr 模块真的没装吗](#是 hmr 模块真的没装吗 "#%E6%98%AF-hmr-%E6%A8%A1%E5%9D%97%E7%9C%9F%E7%9A%84%E6%B2%A1%E8%A3%85%E5%90%97")
- [是 manifest 依赖边写错了吗](#是 manifest 依赖边写错了吗 "#%E6%98%AF-manifest-%E4%BE%9D%E8%B5%96%E8%BE%B9%E5%86%99%E9%94%99%E4%BA%86%E5%90%97")
- [根因 bundle 是 ESM 宿主只认经典脚本](#根因 bundle 是 ESM 宿主只认经典脚本 "#%E6%A0%B9%E5%9B%A0-bundle-%E6%98%AF-esm-%E5%AE%BF%E4%B8%BB%E5%8F%AA%E8%AE%A4%E7%BB%8F%E5%85%B8%E8%84%9A%E6%9C%AC")
- [解决方案一 esbuild 打成 CJS 经典脚本](#解决方案一 esbuild 打成 CJS 经典脚本 "#%E8%A7%A3%E5%86%B3%E6%96%B9%E6%A1%88%E4%B8%80-esbuild-%E6%89%93%E6%88%90-cjs-%E7%BB%8F%E5%85%B8%E8%84%9A%E6%9C%AC")
- 加载成功却标签页空白
- [是 slot 注册写错了吗](#是 slot 注册写错了吗 "#%E6%98%AF-slot-%E6%B3%A8%E5%86%8C%E5%86%99%E9%94%99%E4%BA%86%E5%90%97")
- [根因 inject 被当函数调用 崩溃被静默吞掉](#根因 inject 被当函数调用 崩溃被静默吞掉 "#%E6%A0%B9%E5%9B%A0-inject-%E8%A2%AB%E5%BD%93%E5%87%BD%E6%95%B0%E8%B0%83%E7%94%A8-%E5%B4%A9%E6%BA%83%E8%A2%AB%E9%9D%99%E9%BB%98%E5%90%9E%E6%8E%89")
- [解决方案二 inject 改工厂 修正双层嵌套](#解决方案二 inject 改工厂 修正双层嵌套 "#%E8%A7%A3%E5%86%B3%E6%96%B9%E6%A1%88%E4%BA%8C-inject-%E6%94%B9%E5%B7%A5%E5%8E%82-%E4%BF%AE%E6%AD%A3%E5%8F%8C%E5%B1%82%E5%B5%8C%E5%A5%97")
- 验证结果
- [收尾 插件能跑为什么先停用](#收尾 插件能跑为什么先停用 "#%E6%94%B6%E5%B0%BE-%E6%8F%92%E4%BB%B6%E8%83%BD%E8%B7%91%E4%B8%BA%E4%BB%80%E4%B9%88%E5%85%88%E5%81%9C%E7%94%A8")
问题现象
我想给 DeepSeek Harness(dsh)的右侧栏加一个可编辑 的代码标签页:按扩展名认领 .py 文件,用 CodeMirror 6 打开,能改、能标脏状态。插件装进 profile 重启 dsh web 后,遇到两类症状,且是先后出现的:
- 加载阶段直接报错 :浏览器 Console 里出现
加载插件失败 未注册 @deepseek-ai/dsh-client-hmr。注意dsh-client-hmr是 dsh 自带的模块,我一行代码都没写它------一个「没碰过的东西」报未注册,本身就是反常信号。 - 加载修好之后,标签页空白 :打开一个
.py文件,右侧栏出现了一个标签 chip(内容是空的),点进去面板全白,界面上没有任何报错、没有 fallback 文案,就像这一格根本不存在。
第二类症状最迷惑:界面一片安静,你甚至不知道是「没注册上」还是「注册了但崩了」。
环境信息与复现
- 操作系统:Windows 11(WSL2 下跑
dsh web同理) - 运行时:Node.js 22.x,pnpm 9.x
- 宿主:
@deepseek-ai/dsh当前装的是0.1.5-rc.1(开发早期一路从0.1.0-rc.7升上来,这里有个坑见后文) - 插件形态:dsh 的「Everything is a Plugin」架构,客户端插件是一个被宿主用同源
<script src>加载的 bundle,不是现代打包器直接import的 ES 模块 - 关键依赖:
@codemirror/*6.x、@deepseek-ai/cordis4.x
最小复现步骤:
- 按官方预留的扩展点写客户端插件,把
lib/client.js以 ES module(format: 'esm')产出; - 在 profile 的
dsh.profile.bundles里把插件名加到数组末尾(顺序即层叠顺序); - 重启
dsh web,进任意会话 (首页不挂右侧栏,必须进会话),打开一个.py; - 观察 Console ------ 先看到「未注册 hmr」,修掉后看到空白标签。
加载失败 牵连注销 hmr
是 hmr 模块真的没装吗
第一个直觉是:报「未注册 hmr」,那是不是 hmr 这个包丢了?我直接用 ls 看了 dsh 的安装目录:
text
D:\develop\NodeJS\node_modules\@deepseek-ai\
dsh
dsh-client-hmr # 在,没丢
dsh-client-ui-*
...
dsh-client-hmr 好好躺在 dsh 的依赖里,不是我的依赖,也从来不需要我去装。所以「模块没装」这条直接排除。一个我没写、也没声明依赖的模块报未注册,说明问题出在「加载顺序 / 加载方式」上,而不是它自己。
是 manifest 依赖边写错了吗
dsh 客户端插件有个 dsh.client 字段,里面 inject 是包名依赖边 (同时是 boot graph 的行序依据,也是 Cordis 组装边,但不是 Cordis 服务注入)。我核对了自己声明的边:
json
"dsh": {
"client": {
"platform": "web",
"inject": [
"@deepseek-ai/dsh-api-workspace-files",
"@deepseek-ai/dsh-client-ui-sidebar-right",
"@deepseek-ai/dsh-client-ui-session",
"@deepseek-ai/dsh-api-remotes"
]
}
}
列的都是我真正用到的包,没把 hmr 写错、也没漏写导致顺序错乱。manifest 这一层看起来是干净的。
根因 bundle 是 ESM 宿主只认经典脚本
把方向从「模块」转到「脚本怎么被加载」之后,根因清楚了:
宿主加载客户端插件的方式,是把所有 bundle 拼进同一段经典 <script> 里顺序执行,而不是当作独立的 ES 模块 import。 我的 lib/client.js 是 esbuild 以 format: 'esm' 产出的,文件开头就是顶层 import。在经典脚本上下文里,顶层 import 是语法错误 ,于是整段 combo 脚本解析失败------不止我这个 bundle,排在我后面的所有客户端插件都跟着没注册上。而 dsh-client-hmr 恰好排在加载顺序的后段,所以它成了「第一个被报未注册」的那个,误导我去查一个根本没问题的模块。
图一:ESM 打包导致整组客户端插件未注册

(图一:一个 ESM bundle 的顶层 import 让宿主拼出的整段经典脚本解析失败,连带把排在其后的所有客户端插件(含自带的 hmr)一起拖死,报错却指向「未注册 hmr」这种无关模块)
关键证据链:报错信息指向的模块不是我写的、也不在我依赖边里 → 排除「模块缺失 / 依赖边写错」→ 收敛到「加载方式」。这条链也解释了一个排查经验:当报错指向一个你完全没碰过的模块时,先怀疑「加载顺序或脚本形态」,而不是去修那个模块。
解决方案一 esbuild 打成 CJS 经典脚本
修法是让 bundle 变成宿主认得的经典脚本形态:一个 window.__ModuleLoader__.load({ id, factory }) 调用,factory 收到 require 去要平台模块表里的依赖,整个包体包在里面。
build.mjs 里把浏览器半区从 format: 'esm' 改成 format: 'cjs',并用 banner / footer 包成经典脚本:
js
// build.mjs ------ 浏览器半区必须是经典脚本,被宿主的 __ModuleLoader__ 收编
const CLIENT_BANNER = `window.__ModuleLoader__.load({ id: ${JSON.stringify(CLIENT_ID)}, factory: (require) => { var module = { exports: {} }; var exports = module.exports; Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });`
const CLIENT_FOOTER = 'return module.exports; } });'
const clientConfig = {
entryPoints: ['src/client/index.ts'],
outfile: 'lib/client.js',
format: 'cjs', // ← 关键:不是 esm
platform: 'browser',
jsx: 'automatic',
external: PLATFORM_MODULES, // 冻结的 9 个平台模块,见后文
banner: { js: CLIENT_BANNER },
footer: { js: CLIENT_FOOTER },
}
PLATFORM_MODULES 是宿主冻结共享的 9 个模块白名单:react、react/jsx-runtime、react-dom、react-dom/client、@deepseek-ai/cordis、dsh-client-store、dsh-client-ui-slots、dsh-client-ui-primitives、dsh-client-ui-dockkit。越界导入会在构建期直接报错,这反而是好事------它把「运行时解析不到模块」提前变成了「编译期失败」。
我还在构建末尾加了一道 assertWrapped 校验,产物开头不是 window.__ModuleLoader__.load({ id: 就直接让 build 失败,而不是等浏览器 Console 才暴露:
js
async function assertWrapped(file) {
const head = (await readFile(file, 'utf8')).slice(0, 200)
if (!head.startsWith('window.__ModuleLoader__.load({ id: ')) {
throw new Error(`client bundle is not wrapped for the module loader: ${head.slice(0, 80)}`)
}
}
改完重新 node build.mjs,报错消失,插件进入加载成功分支。
加载成功却标签页空白
是 slot 注册写错了吗
加载通了,但打开 .py 右侧栏还是空白。我先是怀疑两阶段注册写错。dsh 的标签页注册是分开的:类型进 ctx.sidebarRightTabs,主体进 keyed slot sidebar.right.pane.tab,chip 标题进 sidebar.right.pane.tab.title,三者都用类型 definition 的 id 做 key。我核对了 definition.ts:
ts
// definition.ts ------ 类型定义,patterns/priority 决定它能否盖过内置只读预览
export const EDITOR_ID = 'dsh-plugin-code-editor'
export const EDITOR_PATTERNS = ['*.py'] as const
export function editorDefinition(): SidebarRightTabDefinition {
return {
id: EDITOR_ID,
kind: 'code-editor',
patterns: [...EDITOR_PATTERNS],
priority: 'extension', // 比内置 fallback 预览优先级高
canOpen: address => parseFileAddressTarget(address)?.scope === 'session',
title: basenameOf,
}
}
名字、key、priority: 'extension' 都对,能正确盖过内置的只读预览。注册路径没问题。
根因 inject 被当函数调用 崩溃被静默吞掉
真正的问题在 index.ts 里给 slot 传的 inject。我最初写成了一个对象:
ts
// 错误写法:inject 是个面对象
ctx.slots.register(
{ name: 'sidebar.right.pane.tab', key: EDITOR_ID, locale: NS, inject: { readFile: ... } },
EditorBody,
)
但渲染器拿到条目后是把它当函数调用 的,源码里是 bindInjectSources(inject(...args))。对 scope: 'session' 且没声明 store 的 slot,参数是 [sessionId]。把一个对象当函数调 → TypeError: object is not a function → 这一条被框架 abdication(单条目崩溃隔离)静默移出渲染。
图二:slot 的 inject 工厂与对象调用对比

(图二:同一个渲染调用点 inject(...args),左边传工厂函数(正确,返回面对象并入 props),右边传对象(错误,被当函数调抛 TypeError,条目被 abdication 移出))
更阴的是 dsh 的 slot 框架对单条目崩溃是静默隔离 的:组件 render 或 inject 抛异常时,该条目被移除,不报错、不显示 fallback,就是一块空白。
图三:组件崩溃被静默隔离(abdication)

(图三:组件或 inject 抛异常 → 框架把该条目 abdication 移出渲染 → 界面上无任何痕迹,表现为空白标签 + 空白面板)
这正好解释了第二类症状:「标签 chip 空 + 面板全白」几乎必定是运行时抛了异常,而不是没注册上。这条规则很实用------遇到 dsh 里「某格空白且无任何提示」,别去查注册,直接奔浏览器 Console 看真正的异常栈。
另外还有两个连带小错:face.ts 把 RemoteResult 双层嵌套了(多包了一层 .ok 判断),以及缺了 ctx.locale 的类型导入导致 locale: NS 类型不过。这两个都不会让 build 失败(esbuild 不查类型),但会在运行期放大崩溃面。
解决方案二 inject 改工厂 修正双层嵌套
把 inject 改成工厂函数 ,让渲染器用 sessionId 调它、拿回面对象;业务数据从 apply 闭包的 ctx 取,sessionId 由工厂第一参拿到后被闭包持有,组件 props 里就不必出现它:
ts
// index.ts ------ inject 必须是工厂,不是对象
const injectFace = createEditorFace(ctx.remote)
ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register(
{ name: 'sidebar.right.pane.tab', key: EDITOR_ID, locale: NS, inject: injectFace },
EditorBody,
)), 'code-editor: editor tab body')
ts
// face.ts ------ 工厂:(sessionId) => ({ readFile }),且 RemoteResult 只判一层
export function createEditorFace(remote: WorkspaceFilesReadRemote): EditorFaceFactory {
return (sessionId: RemoteSessionId): EditorInjected => ({
async readFile(path, signal) {
const result = await remote.workspaceFiles.readAll(sessionId, path, signal)
return result.ok
? { ok: true, text: decodeBase64(result.value.data) }
: { ok: false, error: result.error }
},
})
}
标题那一处只传 { name, key } 即可,不必多传 locale / inject。
改完跑 tsc 类型检查,一次性抓出 8 个类型错误(含缺 ctx.locale 类型导入、双层嵌套的类型不匹配),全部清掉后 0 error。这里有个教训:esbuild 只剥类型不做检查,跳过 typecheck 等于盲跑 ------第 5 条那种 inject 形状错误,tsc 一眼就能抓,esbuild 只会安静编进产物、运行期才炸。
验证结果
修复后我用临时实例(带 --port 3081 的隔离 dsh 实例)做了两端验证:
- 加载验证 :boot graph 里出现了本插件的行(id = 包名),且因为我没写
immediately: true,它不进 parser-blocking 的 bootstrap 批次,不放大故障半径;字节级对比 wallpaper 插件的产物形态,开头window.__ModuleLoader__.load(一致,sourceMappingURL被宿主重写(说明已被正常处理)。 - 渲染验证 :进会话、打开
.py,右侧栏出现标签、CodeMirror 6 正常渲染、Python 语法高亮、底部脏状态标记都工作;Console 无异常栈。
顺手验证了一个设计边界:按 Ctrl+S,编辑器不会假装保存,而是提示「客户端没有写文件通道」。这引出了下面收尾里我为什么最终选择先停用。
收尾 插件能跑为什么先停用
插件本身跑通了------能加载、能编辑、能高亮、能标脏状态,这些当时都实机验证过。但我最后把它停用 了(只从 profile 的 dsh.profile.bundles 删掉一行,依赖和文件都保留,随时能恢复),原因是它存不下来。
dsh 客户端的 workspace-files 服务明确只暴露只读方法(changes / list / read / readAll / readBytes / readRelated / stat),没有任何写操作。也就是说,客户端对文件没有写通道,Ctrl+S 只能提示原因。一个开着编辑器、让用户以为「改了能存」、实际上存不下的东西,用起来可能比只读预览更糟------它会诱导用户丢失改动。
要不要为「保存」自建一条写通道(比如在插件宿主半区注册一个 HTTP 路由转调 ctx.fs.writeText,或复用 agent 的写路径),是个方向选择,不是技术债 。它涉及 dsh 对「人」的定位:在 dsh 的设计里,人的座位是审阅 / 批准 / 看 diff,而不是像 Cursor 那样直接改。给客户端开写通道,等于往反方向加东西,成本和收益得另外权衡。
所以这篇的收尾不是「修完了上生产」,而是「两处根因都定位并验证,但插件按设计意图先停在可逆的停用状态」。给后来改这个包的人留三条硬规范,也是这次踩坑真正沉淀下来的东西:
lib/client.js必须是经典脚本(window.__ModuleLoader__.load包裹),不是 ESM;inject是工厂函数不是对象,组件崩溃会被静默吞掉,空白症状直奔 Console;dsh.client.inject(包名依赖边)和源码里的export const inject(Cordis 服务注入)是两回事,别混;immediately只有true才有意义且 UI 插件别写;esbuild不查类型,改完先typecheck。
#DeepSeek Harness #dsh #CodeMirror #Cordis #插件开发