DeepSeek Harness (dsh) 插件开发实战:从零写一个自动调用 Codex 的插件
本文基于 DeepSeek Harness
0.1.0-rc.5源码实测。示例是一个真实可用的插件:用户一提到 “codex”,就让 dsh 自动把任务交给 WSL 里的 Codex CLI 完成。
为什么是插件
DeepSeek Harness(dsh)的核心哲学只有一句话:everything is a plugin。它构建在开源插件框架 Cordis 之上,框架本身是一个微内核——工具、LLM 适配器、沙箱、终端、UI、命令、skill、权限策略……所有功能都是插件,通过配置组合在一起。这意味着:想给 dsh 加能力,你只需要写一个插件,然后在配置文件里把它点亮。
插件不是一次性的扩展点:你写的每个插件都是 Citizen(一等公民),和官方内置插件共享同一套机制。
插件是什么
一个插件就是一个导出 apply(ctx) 函数的 TypeScript 模块。框架加载时调用 apply,把 ctx(上下文对象)交给你,你通过它注册一切能力:
// hello-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!')
}
就这么多。没有任何框架启动代码——插件只描述自己的贡献,组合方式是配置的事。
插件的三种形态
函数形态最常用,但你也可以导出对象或类(类形态用于对外提供服务,见下文):
import { Service, type Context } from '@deepseek-ai/cordis'
// 1. 函数形态
export function apply(ctx: Context) {}
// 2. 对象形态
export default {
name: 'my-plugin',
apply(ctx: Context) { /* ... */ },
}
// 3. 类形态:向其他插件提供服务
export class MyService extends Service {
constructor(ctx: Context) {
super(ctx, 'myService') // 服务名 = ctx.myService
}
}
生命周期与自动清理
所有通过 ctx 做的注册——事件监听、工具注册、定时器——在插件卸载时都会被自动清理。你不需要手动 removeListener 或 clearInterval:
export function apply(ctx: Context) {
// 事件监听:卸载时自动移除
ctx.on('some-event', handler)
// 自定义资源:返回的清理函数在卸载时执行
ctx.effect(() => {
const timer = setInterval(() => console.log('heartbeat'), 5000)
return () => clearInterval(timer)
})
}
需要依赖其他服务时,声明 inject,框架会等依赖就绪后再加载你的插件:
export const inject = ['tools', 'llm'] // 等 ctx.tools 和 ctx.llm 就绪
export function apply(ctx: Context) {
ctx.tools.register(/* ... */) // 这里 ctx.tools 已可用
}
配置:让插件可被部署调整
凡是"不同部署可能需要不同取值"的参数,都必须定义成配置字段——这是 dsh 的硬性约定。用 Schemastery 定义 schema,默认值直接写在 schema 里,框架会在加载时校验并填充默认值:
import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'
export interface Config {
greeting: string
maxRetries: number
verbose?: boolean
}
export const 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 默认值
}
第一个插件:注册一个工具
工具是模型能调用的函数。用 defineTool 定义,schema 自动推导出类型化参数并做运行时校验:
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}!` // args 的类型由 schema 推断
},
}))
}
几个要点:
execute(args)返回的是规范 JSON 值,人类可读的文案放在output.render里,不要混在一起- 遵守
exec.signal及时取消;长任务走run_in_background+ctx.jobs - 部署策略(允许/拒绝/审批)不要写进工具,用
tools/pre-execute等钩子实现
让插件在 dsh 中生效
插件写好后,不会自动加载——必须显式声明在某个配置层里。dsh 的配置按层叠加,后层覆盖前层:
- profile 的 bundle 列表(如
@deepseek-ai/dsh-base、@deepseek-ai/dsh-web-app) - profile 自己的
cordis.patch.yml($DSH_HOME/profiles/<name>/) - 机器级的
$DSH_HOME/cordis.patch.yml - 每次启动用
--patch传入的 overlay
最直接的方式:在 profile 的 cordis.patch.yml 里插入一行(注意 Windows 上路径必须是 file:/// URL):
- insert:
- id: hello
name: 'file:///F:/my-project/hello-plugin/src/index.ts' # 绝对路径
config:
greeting: 'Hi there'
重启 dsh web 后,终端出现 [hello-plugin] plugin loaded! 即生效。
验证组合结果可以先用 --dump-config 看每一行来自哪个文件、被谁修改过:
pnpm dsh --profile web --dump-config
进阶:按会话生效(Agent Preset)
如果你只想让某个会话使用某个插件,而不是全进程生效,用 agent preset。preset 是一个目录 + 一份 agent.cordis.yml(插件行列表),新建会话时在 Web UI 里选中它,这个会话就按 preset 组装——工具、skill、命令目录都随 preset 变化:
# ~/.dsh/.agent-presets/my-code/agent.cordis.yml
- id: my-tool
name: '@deepseek-ai/dsh-tool-bash-persistent'
- id: hello
name: 'file:///F:/my-project/hello-plugin/src/index.ts'
已产出内容的会话不能切换 preset(会留下新工具集无法执行的历史),只有空白会话可以在 Web UI 里切换。
打包分发:把插件交给别人
要交付可安装的插件,把它打包成 bundle:一个带 dsh.bundle manifest 的 npm 包:
hello-plugin/
├── package.json # 声明 dsh.bundle
├── cordis.patch.yml # 这个包贡献的配置层
└── index.js # 插件本体
{
"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" } }
}
用户安装进自己的 profile:
dsh plugin --profile web add ./hello-plugin # 或 github:you/hello-plugin
dsh plugin 会把它追加到 profile 的 dsh.profile.bundles 列表。从 git 安装时要注意:拉的是源码不是构建产物,作者要提供自包含的 prepare 脚本,用户还要在 profile 的 pnpm-workspace.yaml 里显式授权构建。
实战:自动调用 Codex 的插件
结合上面所有知识,这是我今天完成的一个真实插件:当用户提到 “codex” 时,自动把任务交给 WSL 里的 Codex CLI。它演示了工具注册 + 自动触发钩子 + 配置 + 跨进程调用四个能力。
import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
export const name = 'wsl-codex'
export interface Config {
distro: string // WSL 发行版
keyword: string // 触发关键词
codexBin: string // WSL 内的 codex 可执行文件
timeoutMs: number // 单次执行超时
skipGitRepoCheck: boolean
}
export const Config = Schema.object({
distro: Schema.string().default('Ubuntu-22.04'),
keyword: Schema.string().default('codex'),
codexBin: Schema.string().default('codex'),
timeoutMs: Schema.number().default(600_000),
skipGitRepoCheck: Schema.boolean().default(true),
})
export const inject = ['tools', 'subprocess', 'agents']
const injectedFor = new Set<string>()
function winToWslPath(winPath: string): string {
const normalized = winPath.replaceAll('\\', '/')
const drive = /^([a-zA-Z]):\//.exec(normalized)
if (drive) return `/mnt/${drive[1].toLowerCase()}/${normalized.slice(3)}`
return normalized.startsWith('/') ? normalized : normalized
}
export function apply(ctx: Context, config: Config) {
// 1. 注册工具:模型调用它把任务交给 WSL Codex
ctx.tools.register(defineTool({
name: 'wsl_codex',
description:
'Run OpenAI Codex inside WSL on a self-contained task and return its final answer. ' +
'Use this whenever the user mentions Codex or asks to delegate work to Codex.',
parameters: {
task: { type: 'string', required: true, description: 'The task to hand to Codex.' },
cwd: { type: 'string', description: 'Working dir (WSL path or Windows path, auto-mapped).' },
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args, exec) {
const wslCwd = winToWslPath(args.cwd ?? exec.agent?.session.header.cwd ?? process.cwd())
// 关键坑:wsl.exe 会把 Windows 的 HOME 传进 WSL,
// 导致 codex 读到 Windows 侧配置。必须在 bash 里重设 HOME。
const script = [
'export HOME="$(getent passwd "$(id -un)" | cut -d: -f6)"',
`cd "${wslCwd}"`,
`${config.codexBin} exec${config.skipGitRepoCheck ? ' --skip-git-repo-check' : ''}`,
].join(' && ')
const controller = new AbortController()
exec.signal.addEventListener('abort', () => controller.abort(exec.signal.reason), { once: true })
const timer = setTimeout(() => controller.abort(new Error(`timeout ${config.timeoutMs}ms`)), config.timeoutMs)
try {
const handle = ctx.subprocess.spawn({
argv: ['wsl.exe', '-d', config.distro, '--cd', wslCwd, '--', 'bash', '-lc', script],
cwd: process.cwd(),
stdio: {
stdin: { data: args.task },
stdout: { maxBytes: 200_000 },
stderr: { maxBytes: 50_000 },
},
graceMs: 5_000,
signal: controller.signal,
})
const outcome = await handle.done
const stdout = handle.collected.stdout?.readFrom(0).text ?? ''
if (outcome.exitCode !== 0) throw new Error(`codex exited ${outcome.exitCode}`)
return stdout.trim()
} finally {
clearTimeout(timer)
exec.signal.removeEventListener('abort', () => controller.abort(exec.signal.reason))
}
},
}))
// 2. 自动触发:用户消息命中关键词时,注入上下文让模型调用 wsl_codex
ctx.on('session/event', (session, event) => {
if (event.type !== 'user/message') return
if (event.data.source.kind !== 'user') return
const text = event.data.content
.map(b => (b.type === 'text' ? b.text : ''))
.join('\n')
if (!text.toLowerCase().includes(config.keyword.toLowerCase())) return
const agent = ctx.agents.get(session.id)
if (!agent || injectedFor.has(session.id)) return
injectedFor.add(session.id) // 每个会话只提醒一次
agent.inject(createUserMessage({
content: [{
type: 'text',
text: 'The user mentioned Codex. Use the wsl_codex tool to delegate the work.',
}],
source: { kind: 'plugin', plugin: name },
}))
})
}
在 profile 的 cordis.patch.yml 里点亮它:
- insert:
- id: wsl-codex
name: 'file:///F:/my-project/wsl-codex/src/index.ts'
config:
distro: Ubuntu-22.04
keyword: codex
timeoutMs: 600000
踩过的坑(Windows + WSL 场景)
- 插件路径必须是
file:///URL。Windows 上写F:/...会被当成f:协议抛ERR_UNSUPPORTED_ESM_URL_SCHEME。 wsl.exe直启会污染 HOME。Windows 的HOME=C:\Users\<you>\...被传进 WSL,codex 会去读 Windows 侧的~/.codex配置。解法:bash -lc里用getent passwd $(id -un)动态取 WSL 用户目录重设 HOME。- 配置层按行整替。patch 覆盖一行是替换整个
config对象,不是深合并——覆盖内置插件时要重述它需要的全部键。 - 一切注册都是 effect。利用好自动清理,热更新(改源码)时旧实例的注册会被干净移除。
最佳实践速查
- 注册即 effect:所有贡献走
ctx.on/ctx.effect/ctx.tools.register,卸载自动清理 - 配置驱动:部署可变的取值都是
Config字段,能从cordis.yml改到才算合格 - 配置错误要响亮:无效配置在加载期失败,不静默跳过
- 别把策略写进工具:权限/审批用
tools/pre-execute等钩子 - Model 可见 ⟺ 必须可记录:进入模型请求的内容要能从会话日志重建
- 能力拆三件套:Service Definition / Provider / Consumer(如 bash 的
dsh-shell/dsh-bash-local/dsh-tool-bash),但简单插件不用拆
参考资料
- 官方文档:
docs/user/develop/basic/(第一个插件 / 工具 / 配置 / 打包) - 工具参考:
docs/cookbook/adding-a-tool.md - 能力三角色:
docs/user/develop/practice/index.md - Cordis 教程:
docs/cordis-tutorial/01-first-plugin.md
插件给了 dsh 无限的可能性。把它写成一个目录、一行配置、一个工具,剩下的交给组合。
推荐使用opencode辅助开发
如果您觉得有用,欢迎 点赞、转发、评论、关注。
更多推荐




所有评论(0)