AI Agent··约 8 分钟·2,910

给 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-controldsh-cost-meter
独立 npm 包(peerDependencies 声明 + tsc/tsdown 构建) 需要发布、类型增强、复杂依赖 dsh-context-guarddsh-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.webServerctx.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.ymlsettings.yamlskills/* 是热感知的,改了即时生效;而 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。

登记:多花十分钟,省一次全量重验

做完功能我才真正理解为什么工作区把"登记"写成硬规则。新插件要动三处:

  1. 父仓库 .gitignore 加路径;
  2. PLUGINS.md 仓库清单加一行(名称/路径/remote/类型/依赖声明方式);
  3. 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.20.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-controldsh-energy-monitor 走的都是豁免路径。

这条豁免我觉得是合理的,因为约束的对象是"你 import 的东西":不 import,就没有 ABI 可谈。但它有反面案例——dsh-cost-meter 同为文件式 bundle,却 import 了 dsh 包,于是用 dependencies 声明了 0.1.2-alpha.4PLUGINS.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 时你是看矩阵还是看日志。中间那些构建、安装、验证的动作都很机械,成本大概半小时,换回来的是"这个插件现在为什么活着"这件事始终有据可查。

目录