第 4 章 动手实战(一):第一个插件与生命周期

本章开始跟随官方 Cordis 教程动手实验。准备工作:

git clone https://github.com/deepseek-ai/deepseek-harness.git
cd deepseek-harness
pnpm install
mkdir -p tmp/cordis-tutorial
cd tmp/cordis-tutorial

每一章都从这个目录运行同一条命令:

node --import tsx ../../vendor/cordis/bin.js

这个单文件启动器会创建根 Context、挂载 Loader 插件,并让它从当前目录加载 ./cordis.yml。--import tsx 让 Node 无需构建即可运行 TypeScript。

4.1 第一个插件

创建 hello.ts:

import type { Context } from '@deepseek-ai/cordis'

export const name = 'hello'

export function apply(ctx: Context) {
  console.log('hello from my first plugin')
}

创建 cordis.yml:

- name: './hello.ts'

运行:

hello from my first plugin

背后发生了什么:

  1. 启动器创建根 Context,挂载 Loader 插件;

  2. Loader 读取 cordis.yml,解析 ./hello.ts,将其作为子插件挂载;

  3. Cordis 调用你的 apply(ctx);

  4. 没有任何东西继续运行时,进程自行退出。

你的文件里没有框架启动代码:插件描述自己的贡献,cordis.yml 负责组合应用。官方的 dsh-base 就是一份更长的插件组合,由部署 overlay 对它进行修补。

注意:配置项会并发启动,列表位置不保证加载先后;顺序由 inject 服务依赖决定。

试错:让 apply 抛异常

export function apply(ctx: Context) {
  throw new Error('apply exploded')
}

再次运行,进程会因该错误而终止——插件加载失败会明确报错,不会静默跳过。

一个例外要尽早知道:如果配置项的模块无法被解析(路径或包名拼写错误),Cordis 会通过 logger 服务报告错误而不崩溃,且启动早期这条报告可能在 console 导出器开始观察之前丢失。新增配置项没效果时,先检查拼写。

4.2 生命周期与 Effect

Cordis 插件可能因修改配置、热重载、显式释放或所需服务消失而卸载。通过 Cordis API 建立的注册属于 effect,会随插件卸载自动撤销;在这些 API 之外管理的资源必须包装在 ctx.effect() 中。

创建 lifecycle.ts:

import type { Context } from '@deepseek-ai/cordis'

export const name = 'lifecycle-demo'

function heartbeat(ctx: Context) {
  console.log('heartbeat plugin loading')
  ctx.effect(() => {
    const timer = setInterval(() => console.log('tick'), 200)
    return () => {
      clearInterval(timer)
      console.log('heartbeat cleaned up')
    }
  })
}

export function apply(ctx: Context) {
  // 挂载子插件并保留 fiber,稍后手动 dispose
  const fiber = ctx.plugin(heartbeat)
  ctx.effect(() => {
    const timer = setTimeout(async () => {
      await fiber.dispose()
      console.log('disposed')
      process.exit(0)
    }, 700)
    return () => clearTimeout(timer)
  })
}

cordis.yml 改为 - name: './lifecycle.ts',运行输出:

heartbeat plugin loading
tick
tick
tick
heartbeat cleaned up
disposed

三个要点:

  • ctx.plugin(heartbeat) 把一个来自代码的函数挂载为插件——与 YAML loader 做的一样。函数插件不需要 apply 方法(只有对象形态才要求)。调用返回一个 fiber:已加载插件实例的运行时句柄。

  • effect 主体在加载期间运行,返回的 disposer 在卸载期间运行。生命周期与插件一致的资源,永远不需要你手动调用 disposer。

  • fiber.dispose() 会等该插件所有清理工作(含异步 disposer)完成后才结束,并递归卸载它挂载的所有子插件。

Fiber 状态机

PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED
                 ↘ FAILED
状态含义
PENDING已声明,但所需服务尚不可用
LOADING / ACTIVEapply 正在运行 / 已完成
FAILEDapply 或配置校验抛出异常
UNLOADING / DISPOSEDdisposer 正在运行 / 一切已拆除

PENDING 是"为什么我的插件没有输出"的最常见答案(第 7 章详解诊断方法)。

哪些操作已经是 effect

你很少需要手写 ctx.effect(),因为内置注册 API 本身就是 effect:

  • ctx.on(event, listener):监听器随插件卸载而移除;

  • ctx.plugin(child):子插件随父插件一同 dispose;

  • 服务注册与 harness 注册表(如 ctx.tools.register(...))会把返回的 disposer 附着到调用插件上,自动撤销。

释放顺序注意事项

disposer 按注册顺序的逆序启动,但多个异步 disposer 会并发运行。若拆除步骤必须按顺序执行,把它们放进同一个 disposer 里依次 await。


依据官方教程第 1、2 章(01-first-plugin、02-lifecycle-and-effects)整理。

DeepSeek Harness / 第 4 章 动手实战(一):第一个插件与生命周期 0 字 0 行 cosolar
2026-09-17T10:12:13.879665480Z 2026-09-17T10:39:03.114696414Z