CH 18 · 第一个插件 hello-plugin
本章目标
CH 17 你已经会装别人做好的插件了。这一章开始自己写——目标是最小的一个:写出一个 hello-plugin,让它真的被 dsh 加载起来。你不写工具、不碰界面,只验证一件事:你写的插件,能被 dsh 发现、加载、跑起来。这一步打通了,后面 CH 19 到 CH 23 那些花活(工具、钩子、UI、发布)都是在它基础上长出来的。
先记一个心态:插件不神秘。CH 08 说过"一切皆插件",那反过来——你想让 dsh 多一个能力,就是写一个导出 apply 函数的小模块。这就是插件的全部骨架。
动手:写一个 hello-plugin
在你想要的工作区里建目录(先 cd 到那个目录,再执行):
New-Item -ItemType Directory -Path "hello-plugin\src" -Force然后新建 hello-plugin\src\hello-plugin.js,写:
export const name = 'hello-plugin'
export function apply(ctx) {
console.log('[hello-plugin] plugin loaded!')
}就这两行核心逻辑:插件被加载时打印一句 [hello-plugin] plugin loaded!。能打出来,就证明"你的代码被 dsh 跑起来了"——这是第一个里程碑。
这里我们用 JS 而不是官方示例的 TS:全局安装的 dsh 没有内置 tsx 运行时,直接加载
.ts会报错;用.js免构建、零依赖,五分钟跑通。等我们写了更复杂的插件,再引入 TypeScript 和构建链(CH 20 会展开)。
把它加载进 dsh
光有文件 dsh 不知道要加载它。需要一个"覆盖层"告诉 dsh:额外加载这个插件。新建 hello-plugin\cordis.yml(下面 name 里的路径是示例,换成你自己的目录,注意点见后):
- insert:
- id: hello
name: 'file:///E:/software-workspace/DeepSeek%20harness%20demo/hello-plugin/src/hello-plugin.js'三个注意点:
name必须是file://开头的完整 URL,不能写E:\...或E:/...。Windows 上 dsh 的模块加载器只认file:///E:/...这种形式,直接写盘符路径会报Only URLs with a scheme in: file, data, and node are supported。- 路径里有空格要编码成
%20。比如你的目录是my work space,就要写成my%20work%20space。 - 这是绝对路径。patch 文件只贡献配置,模块的解析根还是 profile 目录,所以本地插件必须写全路径。
先跑一次 headless 快速验证
不碰 Web UI,先用 headless 验证插件真的被加载:
cd 你的工作区目录
dsh --profile headless --patch "./hello-plugin/cordis.yml" "只回复:hi"输出里你会看到这样两行:
[hello-plugin] plugin loaded!
hi第一行是你的插件在启动时打印的,第二行是模型完成任务后的回复。看到 [hello-plugin] plugin loaded!,你的第一个插件就跑通了。
再加载进 Web UI
headless 能验证,但插件最终是要在 Web UI 里用的。先把正在跑的 dsh web 停掉(否则端口占用),再带 patch 启动:
dsh web --patch "./hello-plugin/cordis.yml"打开 http://127.0.0.1:3080,启动 dsh 的那个终端里同样会打印 [hello-plugin] plugin loaded!。hello-plugin 暂时没有界面效果,它唯一的"产出"就是这句日志——但这证明它已经进入了 web 的插件树,和 CH 08 讲的那棵树的成员平起平坐。

看终端输出:第一行是 dsh 启动时打印的插件加载日志,下面两行是 Web UI 的就绪信息。
卸载时自动清理
用 ctx 注册的一切(事件监听、工具、定时器)在插件卸载时都会被框架自动清理,你不需要手动 removeListener 或 clearInterval。如果确实有需要手动释放的资源(比如一个网络连接),用 ctx.effect() 告诉框架怎么清理:
export function apply(ctx) {
ctx.effect(() => {
const timer = setInterval(() => {
console.log('heartbeat')
}, 5000)
// 返回的函数会在插件卸载时执行
return () => clearInterval(timer)
})
}effect 返回的清理函数,会在插件卸载那一刻被调用——这是 dsh 帮你管资源生命周期的标准姿势。
声明依赖:inject
如果插件要用别的能力(比如 tools、llm),要声明 inject,框架会保证依赖就绪后才加载你的插件:
export const name = 'my-tool-plugin'
export const inject = ['tools']
export function apply(ctx) {
// 到这里 ctx.tools 一定可用
ctx.tools.register(/* ... */)
}inject 是 Cordis 里"服务依赖"的入口。现在先混个脸熟,CH 20 写工具插件时会正式用到。
插件的三种形态
apply 函数是最常见的形态,但插件支持三种写法(CH 19 会逐个细讲,这里先看一眼全貌):
| 形态 | 长什么样 | 什么时候用 |
|---|---|---|
| 函数 | export function apply(ctx) {} | 默认选择,本教程用的就是它 |
| 对象 | export default { name, apply(ctx) {} } | 想顺带带点静态元数据时 |
| 类 | export default class extends Service {} | 要给其他插件提供服务时(CH 19 展开) |
现在记住一句就够:函数形态能解决 90% 的需求,服务形态留给"你的插件要被别的插件依赖"时再用。
让 dsh 自己搭:一条提示词搞定
上面这些步骤你自己手动走了一遍,该学的都学到了。但 dsh 本身就是个 Agent——写插件这种活它也能干,而且是"它自己写、自己验证"。
直接在 Web UI 的输入框发这条提示词:
在我当前的工作区帮我写一个最小的 hello-plugin 插件,让 dsh 能加载它。你先去 dsh 官方仓库把插件开发文档读一遍,搞清楚插件应该怎么写、怎么被加载,然后按官方规范实现,并自己验证它确实被加载了,最后告诉我结果和文件放在哪。你不需要告诉它任何技术细节——它自己会去读官方插件开发文档、自己决定怎么写、怎么加载、怎么验证。你只负责看它干活,然后打开文件检查它写的对不对。这正是"一切皆插件"的延伸:写插件的活,也能交给一个插件拼出来的 Agent 去干。

我实际跑了一次,右上角看它自己列出的参考资料和产出的文件(package.json、index.js、cordis.patch.yml)。它写完后,右侧文件面板直接就能看到工作区里新出现的 hello-plugin 目录和里面的文件——这正是 CH 17 装的侧边栏插件派上用场的地方,不用切到文件管理器就能核对它写了什么。
常见坑
| 问题 | 怎么回事 | 怎么处理 |
|---|---|---|
报 Only URLs with a scheme in: file... | Windows 上路径写成了盘符形式 | name 改成 file:///E:/... 的完整 URL |
| 报找不到文件 / 模块 | 路径里有空格没编码 | 空格写成 %20 |
| patch 后重启 dsh 没反应? | 插件路径或 yml 拼写错了 | 检查 id、name 两处拼写,用 dsh --profile web --patch ./hello-plugin/cordis.yml --dump-config 看插件有没有进配置树 |
直接加载 .ts 报错? | 全局 dsh 没内置 tsx 运行时 | 先用 .js 免构建跑通,需要 TS 时先构建成 .js 再加载 |
| 端口被占用起不来? | 之前的 dsh web 还在跑 | 先停掉旧进程再启动 |
这一章你学到了什么
能自己完成下面几条,就算过关:
- [ ] 能说出插件的最小形态:一个导出
apply(ctx)函数的模块 - [ ] 会建一个 hello-plugin,并在
cordis.yml里用file://URL 声明它 - [ ] 会用
dsh --profile headless --patch ...快速验证插件被加载 - [ ] 会带
--patch启动 web,让插件进入 Web UI 的插件树 - [ ] 知道
ctx.effect()做资源清理、inject声明服务依赖、插件有函数/对象/类三种形态
