DeepSeek Harness 教程 · 第20讲 开发第一个 Cordis 插件
第20讲 · 开发第一个 Cordis 插件
《DeepSeek Harness 从上手到精通》系列 · 第七阶 深度定制篇
从这一讲开始,你不再是 dsh 的"使用者",而是给它写新零件的人。Cordis 既是 dsh 的插件框架,也是 dsh 的灵魂——读懂它,就读懂了 dsh 为何能这么"可拆可装"。本讲带你从零搭出一个可发布、可测试的最小插件包。
一、插件包长什么样
dsh 里的"插件"不是一个特殊类型,它就是一个 标准的 monorepo 包,导出 apply(ctx, config) 函数。它的目录骨架长这样:
packages/
└── dsh-plugin-hello/ # 你的插件包
├── package.json
├── tsconfig.json # host 端编译(Node)
├── tsconfig.client.json # client 端编译(浏览器/WebView)
├── src/
│ ├── index.ts # 入口,导出 apply
│ ├── service.ts # 自定义服务定义
│ └── __tests__/ # 单测目录
└── tsdown.config.ts # 构建配置为什么有 两份 tsconfig?因为 dsh 同时跑在 Node 主机进程和 WebView 客户端里:前者做"重活"(LLM 调用、文件系统、子进程),后者做"轻活"(渲染 UI、捕获事件)。同一份源码要为两套目标分别打包,tsdown 会读这两份配置各跑一次。
二、最小可运行插件:helloworld
新建 src/index.ts,写三件事——服务定义 / 消费使用 / 注册生命周期:
import { Context, Service } from '@deepseek-ai/dsh-core'
// 1) 定义一个服务接口
export interface HelloService {
greet(name: string): string
}
// 2) 用 Service 装饰器把它挂到 ctx 上
export class Hello extends Service<Context> {
static inject = ['logger']
// ctx.logger 是 Cordis 自动注入的依赖
greet(name: string) {
this.ctx.logger.info(`greeting ${name}`)
return `Hello, ${name}!`
}
}
// 3) 插件入口
export function apply(ctx: Context) {
ctx.plugin(Hello)
// 注册一个事件监听(可逆副作用)
const off = ctx.on('ready', () => {
ctx.logger.info('hello plugin ready')
})
ctx.on('dispose', () => off())
}几个关键点:
static inject声明依赖,Cordis 据此推导加载顺序——你不用关心谁先谁后;ctx.plugin(Hello)把服务注册进服务定位器;之后任何地方ctx.hello都能拿到;ctx.on('dispose', ...)用来做可逆副作用的清理,下一节详解。
三、生命周期:apply / dispose 与可逆副作用
Cordis 的核心抽象是 effect(可逆副作用)。每当你"做了一件事",就要同时登记"撤销那件事的方法"。这样插件被卸载时,所有副作用自动回滚,不会留下泄漏。
export function apply(ctx: Context) {
// 注册服务(可逆:unregister)
ctx.plugin(Hello)
// 监听事件(可逆:off)
const off = ctx.on('session.start', e => {
ctx.logger.info('session started', e.id)
})
// 注册定时任务(可逆:clearInterval)
const timer = ctx.setInterval(() => {
ctx.logger.info('tick')
}, 60_000)
// 一次性登记所有清理函数
ctx.on('dispose', () => {
off()
clearInterval(timer)
})
}为什么这很重要? dsh 的很多高级玩法(fork 会话、热重载插件、subagent 隔离)都依赖 effect 的可逆性。养成"做任何事都登记清理"的习惯,是写好插件的基本功。
四、声明服务与消费服务:ctx.<key> 那套定位器
在 dsh 里,你几乎从不直接 import 别人的服务。你要哪个服务,就在 inject 里写它的名字,运行时 Cordis 会从 ctx 里找给你:
| ctx. | 作用 |
|---|---|
ctx.llm | 当前激活的 LLM 适配器(第 21 讲会替换它) |
ctx.fs | 文件读写接口(seam:可换成 E2B) |
ctx.tools | 工具注册表,第 11 讲你已打过交道 |
ctx.sessions | 会话管理(fork / resume 都走它) |
ctx.logger | 结构化日志(每个插件一个 scope) |
所以你的 Hello 服务想被消费时,别的插件只要在 inject 里写 'hello' 就行,不用 import——这就是服务定位器(service locator)的好处:插拔自由,循环依赖也没了。
五、本地开发流:热重载 + 单测
写插件最大的痛点是"改一行、重启一次"。dsh 准备了 vendor/hmr 子系统帮你做热重载:
# 启动本地 dev 模式(监听文件变化、热替换插件)
npx @deepseek-ai/dsh dev \
--plugin ./packages/dsh-plugin-hello \
--port 3080改完 src/index.ts 保存,dev 进程会先调用旧插件的 dispose,再加载新的 apply——你之前登记的所有可逆副作用都会被清理干净,等于一个微型"重启"。
单测用官方的 test-support 包,不用自己造 ctx:
import { test } from '@deepseek-ai/dsh-test-support'
import { apply, Hello } from '../src'
test('hello plugin greets', async ({ ctx }) => {
apply(ctx)
await ctx.start()
const hello = ctx.get(Hello)
expect(hello.greet('world')).toBe('Hello, world!')
await ctx.stop()
})六、动手练习:发布一个最小可用的 hello 插件
按顺序跑一遍:
- 克隆 dsh 仓库,进入
packages/;用pnpm create dsh-plugin dsh-plugin-hello起一个骨架; - 把上文
Hello服务的代码粘进src/index.ts; - 写一个单测(参考上面),
pnpm test通过; pnpm dev --plugin ./packages/dsh-plugin-hello启动热重载;改一行代码保存,观察日志里[hmr] reload;- 写一份 README,给仓库打上
dsh-pluginGitHub topic 发布。
七、常见坑
- 忘了 dispose,副作用泄漏:每个
ctx.on / setInterval / plugin()都要在 dispose 里清理,不然热重载几次后 ctx 会"发胖"; - import 别人的服务:破坏插件边界,让循环依赖找上门。永远用
inject; - tsconfig.client 报错:不要在客户端用 Node 内置模块(如
fs)。判断运行端用ctx.runtime; - pnpm 版本不对:仓库锁定 pnpm ≥ 9,否则 workspace 协议可能解析失败;
- test 里没 await ctx.start():很多插件在 ready 事件里才初始化,没等就拿不到服务,会出现 undefined。
八、官方文档对应章节
docs/cordis-tutorial/01-introduction.md至07-advanced.md(Cordis 七讲必读 1–3、5);docs/plugin-authoring.md与docs/effect-and-dispose.md;- 仓库
cookbook/下plugin-hello/示例。
本讲内容基于 DeepSeek Harness 官方文档与项目源码整理,仅供学习参考,具体命令与配置请以官方最新版本为准。源码仓库:https://github.com/deepseek-ai/deepseek-harness
咨询 DeepSeek Harness 相关问题,请加微信:A0qingfengyuan_01
个人观点,仅供参考,如有不对之处,请多包涵,欢迎指出。本文由 AI 协助完成!
欢迎扫码关注公众号与作者微信,获取更多实用干货

