给 DSH 写一个插件:从选型到登记的完整过程
一次完整的插件开发流水:三种插件形态怎么选、硬依赖为什么必须写进 inject、构建产物如何落盘即生效,以及为什么每次改动都要登记进版本兼容矩阵。
DSH(DeepSeek Harness)是那种"一切皆插件"的 agent harness,基于 Cordis 的依赖注入与组合层。听起来很优雅,但实际上真正开始写第一个插件时,我卡住的地方不是"怎么写 apply",而是三件更琐碎的事:这个插件该做成哪种形态、构建产物放在哪、做完之后要不要登记。
这篇文章按时间顺序记一遍。我要做的东西很具体:给 agent 加一组能操作外部系统的工具。这个功能后来变成了 dsh-device-control 这个仓库,不过本文只讲"做成一个能装进 profile 的插件"这条主线,工具设计细节留给另一篇。
先选形态:三种,差别比想象中大
翻 PLUGIN-DEV.md 的选型表,DSH 插件在实践里分三种形态:
| 形态 | 适用场景 | 参考仓库 |
|---|---|---|
文件式 bundle(esbuild 单文件,dsh.bundle patch 行 / link: 安装,不 import dsh 包) |
小工具类:Host 工具 + Client 面板,无 npm 发布需求 | dsh-device-control、dsh-cost-meter |
独立 npm 包(peerDependencies 声明 + tsc/tsdown 构建) |
需要发布、类型增强、复杂依赖 | dsh-context-guard、dsh-mobile |
动态插件(会话内 cordis_define / cordis_run,不落盘) |
临时运行时扩展 | 无 |
我选文件式 bundle,理由不是它更先进,而是它更小:不 import dsh 的任何包,运行时依赖全靠 Cordis 组合层注入,因此不需要把 dsh 包 link 进本地 workspace。
这一点是我做出决定的关键。当时 dsh 处于 developer preview,0.1.2-alpha.1 还没发布到 npm。独立包形态要正常工作,得在 pnpm-workspace.yaml 里用 overrides 把 @deepseek-ai/dsh-* 重定向到 link:../deepseek-harness/packages/<group>/<pkg>,而且 cordis 必须 link 到 ../deepseek-harness/vendor/cordis——用 registry 上那份就会出现双份 cordis,表现为 ctx.webServer、ctx.commands 类型失效,以及运行时服务身份割裂。这条链路我完全相信它能搭起来,但我不认为一个只做开与关的工具值得为它维护一套 workspace override。
先用动态插件试水也不合适:动态插件活在会话进程里,重启即失,而我要的是一个长期在线的能力。动态插件适合试验 API,不适合交付。
目录与仓库:按工作区约定摆
插件仓库放工作区平级,独立 git:
~/dsh-workspace/
├── deepseek-harness/ # dsh 核心 fork(父仓库不跟踪)
├── dsh-device-control/ # 本插件,独立 git
│ ├── package.json
│ ├── cordis.patch.yml
│ ├── src/{index.js,client.js,db.js,typert.js}
│ ├── scripts/build.mjs
│ └── lib/ # 构建产物(load 的是这里)
└── PLUGINS.md / CHANGES.md / ...建仓后立刻做两件收尾:父仓库 .gitignore 加 /dsh-device-control/(它有自己的 git,父仓库不跟踪),以及 PLUGINS.md 登记表加一行——名称、路径、remote、类型、对 dsh 的依赖声明方式。
顺带说一个改变调试习惯的机制:当 dsh 是从本地源码仓库构建出来的(全局安装指向仓库里的 CLI 入口),改 repo 的构建产物就是生效,子仓库构建完不需要重新安装。所以插件的 lib/ 一旦重建,行为立刻变化,不用怀疑"是不是装了个旧版本"。
构建:44 行脚本 + 一条体积红线
文件式 bundle 的构建入口是 scripts/build.mjs,做四件事:把 client 半边压缩,把 host 半边原样复制。
// scripts/build.mjs(节选,真实文件 44 行)
const { transform } = require('esbuild')
const src = readFileSync(resolve(root, 'src/client.js'), 'utf8')
const result = await transform(src, {
minify: true, keepNames: true, legalComments: 'none',
target: 'es2022', format: 'esm',
})
writeFileSync(resolve(libDir, 'client.js'), header + result.code)
if (result.code.length > 262144) {
console.error(`ERROR: lib/client.js still exceeds 262144 bytes (${result.code.length})`)
process.exit(1)
}那条 262144 字节(256KB)的检查不是我的洁癖,是 DSH STORE 的单文件大小上限。构建脚本里带体积门禁的好处是:尺寸超了会在构建期直接退出,而不是等到浏览器侧加载失败才发现。
package.json 里必须声明的三个出口和 dsh 字段:
{
"name": "dsh-device-control",
"main": "lib/index.js",
"exports": {
".": { "default": "./lib/index.js" },
"./client": { "default": "./lib/client.js" },
"./typert": { "default": "./lib/typert.js" }
},
"dsh": {
"bundle": { "patch": "./cordis.patch.yml" },
"client": { "platform": "web" }
},
"files": ["lib", "cordis.patch.yml"]
}cordis.patch.yml 只有一行有效内容,就是在组合层插入自己的 host 插件:
# dsh-device-control bundle patch:一行挂载 Host 插件(工具 + deviceControl 远程服务)
- insert:
- id: device-control
name: dsh-device-control我不建议在这里塞配置。patch 行只负责"把我挂上去",具体参数走环境变量或插件自己的 settings 命名空间——否则改一个阈值就要动 patch 文件,而 patch 文件在 profile 里是有加载顺序语义的。
装到 web profile
部署就是改 ~/.dsh/profiles/web/package.json 的两处,然后重装依赖:
{
"dependencies": { "dsh-device-control": "link:~/dsh-workspace/dsh-device-control" },
"dsh": { "profile": { "bundles": [
"@deepseek-ai/dsh-base",
"@deepseek-ai/dsh-web-app",
"dsh-mobile",
"dsh-energy-monitor",
"dsh-context-guard",
"dsh-device-control",
"dsh-web-guard",
"dsh-cost-meter"
] } }
}bundles 的顺序就是加载顺序——但别把它当依赖顺序用,真正的依赖要在 inject 里声明(后文会说为什么)。
cd dsh-device-control && npm install && node scripts/build.mjs
dsh plugin --profile web add "$PWD"
dsh --profile web restart验证按 PLUGIN-DEV.md 第 5 节的三件套:
curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3080 # 401/200 都正常
systemctl is-active dsh-web
tail ~/.dsh/profiles/web/.service/dsh-web.log这里有个热重载的边界值得记住:profiles/web/cordis.patch.yml、settings.yaml、skills/* 是热感知的,改了即时生效;而 package.json / pnpm-lock.yaml 有实际变更时会触发自动重启,大约 8 秒。也就是说改补丁不用重启,改清单会自己重启,我不需要手动 systemctl restart——真要手动就 touch ~/.dsh/profiles/web/.service/restart.trigger。
四个真踩到的坑
这些都不是"可能发生",是日志里留了痕的。
一、构建产物漏文件,报错信息会带偏你。 build.mjs 最初只复制了 src/index.js,忘了 src/db.js,结果是 ERR_MODULE_NOT_FOUND。我第一反应是"包没装上",查了半圈 lockfile 才发现是产物里少一个文件。修法是把这个复制循环写全,别漏:
for (const [from, to] of [['src/index.js', 'index.js'], ['src/db.js', 'db.js'], ['src/typert.js', 'typert.js']]) {
copyFileSync(resolve(root, from), resolve(libDir, to))
}二、link: 安装不解析插件目录自己的 dependencies。 插件里用了 zod,但 web profile 用 link:<绝对路径> 安装时,不会去装插件目录声明的 dependencies。表现是运行时 Cannot find module 'zod'。修法很土:在插件目录内先 npm install 一次,把 node_modules 装出来,dsh-offpeak-queue 那个插件也是靠同样的方式自带 node_modules/zod 才跑起来的。
三、client 半边要按"插槽"注册,而不是自己找 DOM。 client 半边跑在浏览器里,通过 slots.inject 往指定插槽注册组件。我往输入框左侧和全局浮层各挂了一个:
slots.inject('conversation.input.left', () => {
return slots.register(
{ name: 'conversation.input.left', id: 'device-panel-btn', order: 100 },
() => h(DevicePanelButton),
)
})并且 exports.inject = ['slots']——因为 loader 是并行激活各 client bundle 的,我这个包完全可能先于提供 slots 服务的 ui-renderer 启动。这句话我写在源码注释里了,因为它反直觉:bundles 列表里的顺序不构成保证。
四、apply 不抛异常,但失败要留痕。 外层 apply 整个 try/catch 兜住,异常只记日志、绝不冒泡——否则可能触发 quarantine。但"什么都不抛"等于"出问题没信号",所以我在服务端加了一条只追加日志的诊断路由,client 侧每个关键步骤都往上报一次(apply-start / slots-get / inject-left-fired / apply-done)。面板第一次没出来时,就是靠这个日志判断出是 ctx.get('slots') 拿到了 undefined。
登记:多花十分钟,省一次全量重验
做完功能我才真正理解为什么工作区把"登记"写成硬规则。新插件要动三处:
- 父仓库
.gitignore加路径; PLUGINS.md仓库清单加一行(名称/路径/remote/类型/依赖声明方式);PLUGINS.md版本兼容矩阵加一格,填已声明的前提是实际验证过。
版本矩阵现在长这样(列是 dsh 版本,行是插件):
| 仓库 | 0.1.1-rc.2 | 0.1.2-alpha.1 | 0.1.2-alpha.1-local.1 |
|---|---|---|---|
| dsh-device-control | -(后装) | 已声明(v0.1.0,运行于当前 web profile,link: 本地构建,2026-08-30;无 dsh 包依赖,组合层兼容) | 已声明(0.1.0 私源,2026-09-01) |
矩阵本身没什么技术含量,值钱的是它建立的一条规矩:dsh 版本变了,新增一列,逐行核对每个插件的 peerDependencies 是否包含新版本,不含就升级并标记不可用;全部插件通过之前,这次升级不算完成。对账依据唯一——PLUGINS.md 矩阵 + CHANGES.md,两边不一致时以实际验证为准并立刻回改。
我吃过这条规矩的反面。dsh 从 0.1.1-rc.2 到 0.1.2-alpha.4 之间破坏性变更很密:dsh.bundle 顶层字段改成了嵌套的 dsh、投影契约从 schema 改成 stateSchema、schemastery 的 .optional() 被移除、settings helper 被移除。哪一条落在你的插件上,只有真跑才知道。有个插件就是这么挂的:0.1.2-alpha.4 改了前端 settings 模块的依赖形状,插件里一段"缺依赖就报错"的断言直接把移动端配对后的首页重写打断成 502。如果当时矩阵里根本没这一行,我大概会在"升级完成"之后才从用户那里听说。
版本声明这里有个例外,我用得心虚
规则写的是插件必须用 peerDependencies 显式列出兼容版本,禁止 * 或过宽范围。但 PLUGINS.md 同时给了豁免:纯文件式 bundle(经 dsh.bundle patch 行加载、不 import dsh 包)不声明 peerDependencies,兼容性由 Cordis 组合层保证。dsh-device-control 和 dsh-energy-monitor 走的都是豁免路径。
这条豁免我觉得是合理的,因为约束的对象是"你 import 的东西":不 import,就没有 ABI 可谈。但它有反面案例——dsh-cost-meter 同为文件式 bundle,却 import 了 dsh 包,于是用 dependencies 声明了 0.1.2-alpha.4。PLUGINS.md 里给它的建议是"建议后续改为 peerDependencies",理由是运行时那份副本应该由宿主提供,而不是插件自己拖一份进来。
所以我的实际做法是条件分支,不是二选一:
- 不 import dsh 包 → 不声明,靠组合层;
- import 了任一
@deepseek-ai/dsh-*→ 显式列出实测过的版本,用||串起来,例如0.1.1-rc.2 || 0.1.2-alpha.1 || ...,不含新版本就别写进去; - 核心侧维护矩阵,升级时逐行核对。
第三个动作最烦也最值:它把"升级 dsh"从一次全量重验,压缩成"看哪一格还没标已验证"。
代价与遗留
诚实记几笔账。
每次改 client 都要重新构建。 client 半边是压缩过的单文件,改一行 UI 也要 node scripts/build.mjs 再让 profile 走一遍重启,大约 8 秒。host 半边是原样复制,改完直接生效,所以我把尽量多的逻辑放在 host 侧——面板只做展示,取数和决策在 host。
手写 client bundle 没有类型检查。 没有 tsc 兜底,只在构建期做体积门禁、在运行时做诊断上报。这是换取"零 workspace 依赖"的代价。真要类型,就该升级成独立 npm 包形态,用 tsc/tsdown 构建——但对一个 368 行的面板来说,那是为了工具链而工具链。
动态与静态的边界我还没想清楚。 现在有些一次性调试逻辑我仍然用动态插件跑,因为改文件再重启太慢;但动态插件不落盘、重启即失,很容易出现"调试时好、部署后没有"的错觉。我目前的纪律是:动态插件只用来试 API,任何要通过 dsh plugin add 装进 profile 的行为都必须落成文件式 bundle 并登记。
发布这一环我跳过了。 文件式 bundle 没有 npm 发布需求,PLUGIN-DEV.md 第 7 节的可选发布(npm publish,先核对 peerDependencies 范围)我没有执行。如果哪天想给社区用,第一步不是发版,而是把 dsh 字段、files 列表和诊断路由收拾干净——社区里 200 个开源插件仓库里,采用新式组合包(package.json 内含 dsh.bundle.patch)的有 147 个,是当前主流,我的结构跟它们是一致的。
小结
如果只留一句话:先把形态定下来,再写代码;写完之后一定要登记。选型决定了你要不要维护一套 workspace override,登记决定了半年后升级 dsh 时你是看矩阵还是看日志。中间那些构建、安装、验证的动作都很机械,成本大概半小时,换回来的是"这个插件现在为什么活着"这件事始终有据可查。