dsh.so

插件开发

DeepSeek Harness 里一切皆插件。本教程带你走完整条链路——从最小本地插件,到配置与工具,再到发布 npm bundle——内容以官方文档为事实来源(2026-08-13 核实)。

什么是插件?

插件是一个导出 apply 函数的 TypeScript 模块。框架在加载插件时调用 apply,传入一个 context 对象(ctx),你通过它注册能力:

my-plugin.ts
import type { Context } from "@deepseek-ai/cordis"
export const name = "my-plugin"

export function apply(ctx: Context) {
// Register capabilities here.
}

这就是完整的契约。name 在日志与覆盖层中标识插件;apply 是你注册事件监听、工具、服务、定时器或 UI 的地方。所有通过 ctx 注册的东西都是 effect:插件卸载时会被自动撤销,因此插件可以组合而不会泄漏状态。

插件的三种形态

上面的函数形式覆盖了大多数场景。插件也可以是对象或类:

对象形式
export default {
name: "my-plugin",
inject: ["tools"],
apply(ctx) { /* ... */ },
}
类形式
import { Service, type Context } from "@deepseek-ai/cordis"

export default class MyService extends Service {
static inject = ["tools"]
constructor(ctx: Context) {
super(ctx, "myService")
// 在构造函数里做同步初始化。
}
}

当你的插件需要向外提供服务、供其他插件消费时,用类形式;服务名(这里是 myService)就是其他插件在 inject 里声明的名字。

第一个插件(本地)

从一个已完成从源码运行的仓库检出开始。创建临时项目:

zsh — dsh
$ mkdir -p scratch-plugin/src

创建 scratch-plugin/src/my-plugin.ts

my-plugin.ts
import type { Context } from "@deepseek-ai/cordis"

export const name = "hello-plugin"

export function apply(ctx: Context) {
console.log("[hello-plugin] plugin loaded!")
}

用覆盖层注册

在仓库根目录运行 pwd,然后创建 scratch-plugin/cordis.yml。插件路径必须是绝对路径——patch 文件只贡献配置,不会改变 loader 解析模块路径时使用的 profile 目录:

cordis.yml
- insert:
- id: hello
name: "/absolute/path/to/deepseek-harness/scratch-plugin/src/my-plugin.ts"

带覆盖层启动 Web UI:

zsh — dsh
$ pnpm dsh web --patch ./scratch-plugin/cordis.yml
[hello-plugin] plugin loaded!
$

打开 http://127.0.0.1:3080——启动期间终端会打印这行。--patch 覆盖层是在其他所有层之后应用的配置层,见下文加载顺序

Context:清理与依赖

自动清理

通过 ctx 注册的任何东西——事件监听、工具、定时器——都会在插件卸载时被自动清理。你永远不需要手动 removeListenerclearInterval。对于需要显式释放的资源(网络连接、文件监听),用 ctx.effect() 提供清理函数:

ctx.effect()
export function apply(ctx: Context) {
ctx.effect(() => {
const timer = setInterval(() => console.log("heartbeat"), 5000)
// 插件卸载时执行。
return () => clearInterval(timer)
})
}

这也是热替换(HMR)能工作的原因:配置变更会卸载旧实例并加载新实例,而注册都属于 effect,旧实例不会留下任何残留。

用 inject 声明依赖

如果你的插件消费某个服务——toolsllm 或另一个插件提供的服务——在 inject 里声明它。框架会等所有必需服务就绪后才加载你的插件:

inject
export const name = "my-tool-plugin"
export const inject = ["tools"]

export function apply(ctx: Context) {
// 此时 ctx.tools 已就绪。
ctx.tools.register(/* ... */)
}

配置

导出一个 Config 类型和同名的 Schemastery schema。默认值直接写在 schema 字段上;Cordis 在加载插件时校验用户配置并填充默认值:

config.ts
import type { Context } from "@deepseek-ai/cordis"
import Schema from "@deepseek-ai/schemastery"

export const name = "my-plugin"

export interface Config {
greeting: string
maxRetries: number
verbose?: boolean
}

export const Config: Schema<Config> = Schema.object({
greeting: Schema.string().default("Hello"),
maxRetries: Schema.number().default(3),
verbose: Schema.boolean().default(false),
})

export function apply(ctx: Context, config: Config) {
console.log(config.greeting) // 用户值或 schema 默认值
}

用户在 cordis.yml 的插件行中传入配置:

cordis.yml
- insert:
- id: hello
name: "./src/my-plugin.ts"
config:
greeting: "Hi there"
maxRetries: 5
不要导出普通对象作为 Config——它必须实现 Cordis 要求的 Standard Schema 接口。用 Schemastery 就能免费获得。

严格校验

Schemastery 支持更丰富的约束——必填字段、联合类型、带边界的数值。配置不合法时插件加载会失败并给出明确错误:

严格校验
export const Config = Schema.object({
apiKey: Schema.string().required(),
timeout: Schema.number().default(30000),
mode: Schema.union(["fast", "accurate"]).default("fast"),
})

设计原则

  • 不硬编码可调参数——任何不同部署可能取值不同的东西都必须是配置字段。检验标准:能否在 cordis.yml 中改变它而不改代码?
  • 错误要响亮——在 schema 中表达自足的约束,让无效配置在加载时失败,而不是等到首次使用时才报错。
  • 默认值选用户会保留的——后应用的配置层会替换整行 config(不做深合并),所以默认值要尽量贴合多数用户。

构建工具

工具是模型可以调用的能力。用 @deepseek-ai/dsh-toolsdefineTool 注册——DSL 会根据 parameters 推导并校验 args

greet 工具
import type { Context } from "@deepseek-ai/cordis"
import { defineTool } from "@deepseek-ai/dsh-tools"

export const name = "greet-tool"
export const inject = ["tools"]

export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: "greet",
description: "Greet someone by name.",
parameters: {
name: { type: "string", required: true, description: "The name to greet" },
},
output: {
schema: { type: "string" },
render: (_args, value) => [{ type: "text", text: value }],
},
async execute(args) {
return `Hello, ${args.name}!`
},
}))
}

重启 pnpm dsh web --patch ./scratch-plugin/cordis.yml,打开 http://127.0.0.1:3080,输入 “Use the greet tool to greet Ada.” 模型会调用 greet 并收到 Hello, Ada!

execute() 契约

  • 参数自动校验——类型、必填键、字面量、联合类型和嵌套值都会在 execute 运行前对照 parameters 校验。
  • 一个规范值——execute 只返回 output.schema 声明的值;output.render(args, value) 把它转换成面向模型的内容。不要在函数体里返回内容块。
  • 抛出 = isError——基础设施故障用抛错;成功的领域结果要用规范值表达(例如非零的进程退出码),哪怕渲染器需要解释它。
  • 响应 exec.signal——它触发时要取消进行中的工作(这是调用方拥有的中止信号)。
  • 异步通知——exec.agent.inject({ content, source }) 会追加持久上下文,供下一次模型请求看到;它不会唤醒空闲的 agent。

长耗时任务:把 run_in_background 用配置门控,通过 ctx.jobs.start({ kind, label, owner: exec.agent, run }) 注册——成功时后台分支返回类型化句柄,如 { kind: "background", jobId }。完整契约见工具编写参考

打包与发布

本地 --patch 流程只用于开发。分发建立在两个概念之上,都由 package.json 描述,但在 dsh 键下携带不同的 manifest:

Bundle(组合包)Profile
回答的问题“这个包贡献什么?”“这套配置由哪些 bundle 按什么顺序组成?”
Manifestdsh.bundle → 一个 patch 文件dsh.profile → 有序 bundles 列表
角色你编写并分发的东西用户用 dsh --profile <name> 启动的东西

bundle 是附带一个配置层的 npm 包。创建包:

hello-plugin/
hello-plugin/
├── package.json # 声明 dsh.bundle
├── cordis.patch.yml # profile 列出此 bundle 时应用的层
└── index.js # patch 行引用的插件模块
package.json
{
"name": "dsh-hello-plugin",
"version": "0.1.0",
"type": "module",
"main": "index.js",
"files": ["index.js", "cordis.patch.yml"],
"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }
}

patch 里按包名(而不是源码路径)引用插件,这样 Node 的模块解析能找到已安装的代码:

cordis.patch.yml
- insert:
- id: hello
name: dsh-hello-plugin
没有 dsh.bundle 声明的包仍然可以安装,但只作为普通依赖:dsh plugin 会打印警告且不激活任何层。供插件 import 的库用这种格式,用户启用的插件不要用。

安装进 profile

dsh plugin --profile <name> <args> 会在 profile 目录内转发给 pnpm,所以所有 pnpm 子命令都可用。首次使用会以 @deepseek-ai/dsh-base 作为第一个 bundle 初始化 profile:

zsh — dsh
$ dsh plugin --profile demo add ./hello-plugin
$ dsh --profile demo --dump-config # 不启动,先验证层
$ dsh --profile demo

dsh plugin --profile demo remove dsh-hello-plugin 同时移除依赖和对应的层。要分发,把 bundle 发布到 npm——用户用 dsh plugin --profile web add dsh-hello-plugin 安装。

加载顺序

生效配置在空根之上按以下顺序逐层组合:

  1. profile 的 dsh.profile.bundles 列表所列的各个 bundle patch,按列表顺序。
  2. profile 自己的 cordis.patch.yml
  3. home 级 $DSH_HOME/cordis.patch.yml——各 profile 共享的机器本地偏好。
  4. 每个 --patch <path> 覆盖层,按 argv 顺序。

后应用的层按行胜出,且 patch 会替换整行 config 而不是深合并各键。作为 bundle 作者,你可以按 id 覆盖前面各层的行——但必须重述该行需要的每一个键,而不只是改动的那个。

从 GitHub 安装

发布到 npm 不是必须的——用户可以直接从 git 主机安装:dsh plugin --profile demo add github:you/hello-plugin。但 git 安装获取的是源码而非构建产物:不会运行你的 build 脚本,所以 TypeScript 包到达时没有 lib/ 输出。两件事必须做:

  • 作者:提供 prepare 脚本,从源码自足地构建发布的入口(pnpm 会在 git 安装后运行它)。turtle-ui 是可用范例。
  • 用户:放行构建——pnpm ≥ 10 在显式允许前拒绝运行 git 依赖的 prepare。把 pnpm 打印的包键复制到 profile 的 pnpm-workspace.yaml,写成 allowBuilds: { dsh-hello-plugin: true },然后重新 add
这个放行意味着允许在安装时执行该包的代码。只放行你信任的包,并固定提交(github:you/hello-plugin#<sha>),防止后续 push 静默改变执行内容。更稳妥的做法是发布 npm 或用 pnpm pack 的 tarball,完全绕开放行。

真实示例与资源

研究注册表里的生产插件:modlensdsh.bundle)、dsh-taskboarddsh.client Web UI)。官方文档——第一个插件 · 配置 · 工具 · 打包与安装 · 工具编写参考 · Cordis 教程。准备好分享了?看提交插件

加入社区

问题或建议?官方 Discussions 是项目规范的支持渠道;Discord 里能找到活跃的社区成员。dsh.so 本身欢迎通过提交插件或反馈改进。