第20讲 · 开发第一个 Cordis 插件

《DeepSeek Harness 从上手到精通》系列 · 第七阶 深度定制篇

🎯 目标:从"用户"正式跨入"框架开发者" 难度 ★★★ ⏱ 约 45 分钟 前置:第 4/7/11 讲 · Node ≥ 22.19 · TS 基础

从这一讲开始,你不再是 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 会读这两份配置各跑一次。

插件架构:模块拼图与连接节点
图 1 插件的本质:可拼接的模块单元。ctx 把它们串成一个有向图。

二、最小可运行插件: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 插件

按顺序跑一遍:

  1. 克隆 dsh 仓库,进入 packages/;用 pnpm create dsh-plugin dsh-plugin-hello 起一个骨架;
  2. 把上文 Hello 服务的代码粘进 src/index.ts
  3. 写一个单测(参考上面),pnpm test 通过;
  4. pnpm dev --plugin ./packages/dsh-plugin-hello 启动热重载;改一行代码保存,观察日志里 [hmr] reload
  5. 写一份 README,给仓库打上 dsh-plugin GitHub 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.md07-advanced.md(Cordis 七讲必读 1–3、5);
  • docs/plugin-authoring.mddocs/effect-and-dispose.md
  • 仓库 cookbook/plugin-hello/ 示例。

本讲内容基于 DeepSeek Harness 官方文档与项目源码整理,仅供学习参考,具体命令与配置请以官方最新版本为准。源码仓库:https://github.com/deepseek-ai/deepseek-harness

咨询 DeepSeek Harness 相关问题,请加微信:A0qingfengyuan_01

个人观点,仅供参考,如有不对之处,请多包涵,欢迎指出。本文由 AI 协助完成!