Technical notes · 2026 年 9 月
怎么给 dsh 写插件
DeepSeek Harness 的插件长什么样,装到哪里,能挂在哪些位置,以及一个从零写到跑通的示例插件。
先说结论
dsh 的插件是一个导出 name、inject、apply 的 ES 模块,加一个 package.json 里的 dsh.bundle.patch 字段。装的时候一条命令,dsh plugin add 会自己把它追加进 profile 的层列表。
它比 MCP 深一层:MCP 只能加工具,插件能改系统提示、能截住每一次工具调用的结果、能加斜杠命令。本文的示例插件用的就是这个差别:agent 每写一个 .md,插件在同一步里跑一个写作检查脚本,把结果塞回工具结果,模型当场看到。
示例插件 427 行,零运行时依赖,15 项自测全过,在独立 profile 里真机跑通:模型写下一句触发规则的句子,同一步就收到了检查告警。
一、插件在 dsh 里是什么
dsh 0.1.2-rc.1 的包依赖树里,@deepseek-ai/dsh-* 有 214 个包。模型适配器、工具、会话存储、沙箱、压缩策略、斜杠命令、Web 前端的每一块面板,各自是一个包,各自是一个 Cordis 插件。启动时按一份 YAML 清单装配成一棵树。
这个结构的实际后果是:想改的东西基本都已经是一行配置,或者一个可以被自己的插件替换掉的行。不用 fork。
1.1 一个插件就是四个导出
Cordis 插件的最小形态是一个 ES 模块,导出四个东西。官方的 dsh-time-context(给模型注入当前时间的那个插件)就是这个形状:
const name = 'time-context' // 诊断信息里显示的名字
const inject = ['agents'] // 依赖哪些服务,服务到齐才激活
const Config = z.object({ ... }) // 配置格式,可选
function apply(ctx, config) { // 挂钩子、注册东西,全部随 ctx 一起销毁
ctx.on('agent/pre-step', async (payload, next) => { ... }, { prepend: true })
}
ctx 上挂着 69 个服务,ctx.tools、ctx.systemPrompt、ctx.commands、ctx.skills、ctx.subagents、ctx.llm 都在里面。apply 里注册的每一样东西都绑在 ctx 上,插件卸载时自动回收,热重载不用自己清理。
1.2 profile 是一摞补丁
profile 在 ~/.dsh/profiles/<名字>/,里面一个 package.json(装外部插件的地方,加一份有序的 dsh.profile.bundles 层列表)和一个 cordis.patch.yml(用户自己的补丁层)。
装配从一个空数组开始,每一层往上打补丁:insert 加行,按 id 定位的补丁改行。一个典型的 web profile 是这样:
dsh --profile web --dump-config 数出来,取样日 2026-09-08。最后两层是纯 YAML,不用装包。数据表
| 层 | 行数 | 来源 |
|---|---|---|
| dsh-base | 85 | 包内 cordis.patch.yml |
| dsh-web-app | 87 | 包内 cordis.patch.yml |
| dsh-subagent-claude-code | 1 | 包内 cordis.patch.yml |
| 装配结果 | 146 | dump-config 输出 |
两条会咬人的规则。按 id 定位的补丁是整块替换 config,要保留的字段得一起重写。用户那两层是热加载的,改完不用重启服务;包那几层要重启。
YAML 里可以写 !!js 表达式,在装配时求值,还能用 dshHomePath()。base 层里就有:
- id: session-persistence-jsonl
name: '@deepseek-ai/dsh-session-persistence-jsonl'
config:
root: !!js dshHomePath('sessions')
1.3 第三方插件靠一个字段进入这摞补丁
一个 npm 包想成为 profile 的一层,只要在 package.json 里声明:
"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }
然后 dsh plugin --profile web add <包>。这条命令把参数原样转给 pnpm,装完之后回头读一遍已装的依赖:凡是声明了 dsh.bundle.patch 的,自动追加进 dsh.profile.bundles;移除或者升级后丢了这个声明的,自动摘掉。不用手改 package.json。
包的来源 pnpm 支持什么就支持什么:npm、github:owner/repo#ref、本地路径、file:、link:。开发中的插件用 link: 指向工作目录,改完重启服务即可,不用发版。
packages: ['.'] 的单包 workspace,add 不带 -w 会报 ERR_PNPM_ADDING_TO_ROOT。二、能挂在哪里
核心的 15 个包一共声明了 47 个 Cordis 事件。写插件多数时候只用到其中一小把。
2.1 一次工具调用的四个口子
每一次工具调用都走同一条路:tools/pre-execute 决定放不放行,注册的守卫再过一遍,tools/execute 包在真正执行的外面,tools/post-execute 拿到结果,最后 tools/result 只读通知。
| 事件 | 模式 | 能做什么 | 典型用途 |
|---|---|---|---|
| tools/pre-execute | 瀑布 | 返回 allow / deny / ask | 权限、审批、危险命令拦截 |
| tools/execute | 瀑布 | 包住真正执行,可换 signal | 超时、重试、埋点 |
| tools/post-execute | 瀑布 | accept 原样过 / 换掉内容 / block 判错 / 附加上下文 | 结果检查、敏感信息遮掉、超长结果存成文件 |
| tools/result | 只读 | 看最终结果,改不了 | 统计、日志、外部通知 |
tools/post-execute 是示例插件用到的那个。它的返回值有三种形态,其中 additionalContexts 是关键:附上去的消息会进这一步的结果批次,模型下一次请求就看得到,不用等一轮对话。
type PostToolDecision =
| { kind: 'accept'; content?: ContentBlock[]; additionalContexts?: UserMessage[] }
| { kind: 'accept'; value: JsonValue; additionalContexts?: UserMessage[] }
| { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: UserMessage[] }
2.2 其他常用挂点
| 挂点 | 干什么 |
|---|---|
| ctx.tools.register | 注册一个模型可见的工具。restrict 按 agent 做黑白名单,guard 加一道只能否决的闸 |
| ctx.systemPrompt.section | 往系统提示里加一段,带排序号。persona 是 0,plan policy 是 500 |
| ctx.systemPrompt.context | 加动态上下文,每次装配重新求值 |
| ctx.commands.register | 注册斜杠命令。结果直接渲染给人看,不进模型历史 |
| agent/pre-step | 每一步请求组装前追加消息。dsh-time-context 用它注入当前时间 |
| ctx.sessionProjections.register | 注册会话投影,Web 界面从这里读状态。todo 列表就是一个投影 |
| ctx.subagents | 注册子 agent 后端。Claude Code 那个包就是一个 provider |
| fs/write-intent、fs/edit-intent | 文件系统层,比工具层更底,绕过工具的写入也拦得住 |
| system-prompt/assemble | 整份提示装配完之后再改一手 |
2.3 三件不用写插件的事
动手前先确认要做的事需不需要写代码。这三件都只要放文件。
全局指令
dsh-agent-instructions 已经在 base 层里,默认预算 65536 字节。它先读 ~/.dsh/AGENTS.md,再从项目根往下读每一层的 AGENTS.md 和 CLAUDE.md,广到窄拼成第一条基线消息。已经在用 Claude Code 或 Codex 的人,把自己的全局指令文件复制一份到 ~/.dsh/AGENTS.md,dsh 就认得同一套规则。
技能
dsh-skill-filesystem 也在 base 层,默认扫五个根目录,其中一个是 ~/.agents/skills。这个目录是多个 agent 工具共用的约定位置,放进去的技能 dsh 直接能看到。目录带监听,加删技能不用重启。
技能和插件的分工是:技能要模型自己决定加载,插件是无条件触发。一条写作规则两边都可以有,技能负责“怎么写”,插件负责“写完了必须查一遍”。
一段部署人格
system-prompt 的 persona 字段接受一段字符串模板,支持 {{model}}、{{cwd}}。在 cordis.patch.yml 里写一行就行,不用建包。
dsh-mcp-client,MCP 要用得先加一行。三、从零写一个:写完即检查
3.1 要解决的问题
很多团队有一份写作规范,还有一个配套的检查脚本:跑一遍 Markdown 文件,输出逐行的 文件:行:列 [严重度/类型] 说明。规则可能是禁用某些套话、要求中英文之间留空格、限制感叹号。
问题在触发时机。检查器要人记得跑。agent 写完一篇稿,人得先看到、先想起来、再让它查一遍,往返两三轮。规则写在技能里也只是“模型愿意的时候读一读”。
tools/post-execute 正好补上这一段:文件刚写完,插件立刻跑检查器,把结果作为一条 notice 附在这次调用结果上。模型下一步就看到了,中间没有人。这是插件相对技能和 MCP 的实际增量。
下面的示例插件叫 dsh-prose-guard。检查器本身不在插件里,通过配置项 linter 指向任何一个接受文件路径、按上面格式输出的脚本。
3.2 插件的三个面
- 规模
- index.js 427 行,测试 262 行
- 依赖
- 零。只用 Node 内置模块
- 状态
- 自测 15 项全过 独立 profile 真机跑通
写入即检查。挂在 tools/post-execute。write、edit、str_replace_editor 成功写完一个 .md,拿到写入路径,跑检查器,有 warning 就附结果。mode: block 时改成把这次写入判成错误,逼模型重写。
prose_lint 工具。模型主动查。传路径查磁盘上的文件,传文本查还没写进文件的草稿。
/prose 命令。Web 界面里手动查。不给路径就查本会话最近写过的 Markdown。
另外往系统提示里加一段,告诉模型这个检查器存在、结果会当场回来、以及什么时候可以不改。最后一句是有用的:检查器是正则,会误报,模型需要一个不硬改的出口。
3.3 写入钩子的全部逻辑
ctx.on('tools/post-execute', async (exec, result, next) => {
const decision = await next()
if (decision.kind !== 'accept') return decision
if (!config.watchTools.has(exec.name) || result.isError) return decision
const reported = writtenPath(exec, result)
if (reported === undefined) return decision
const path = absolute(reported)
if (!watched(path, config)) return decision
const report = await runLinter(config, [path], exec.signal)
if (!report.ok) return decision
if (exec.agent) lastWritten.set(exec.agent, path)
const findings = actionable(report, config)
if (findings.length === 0) return decision
const text = renderWriteReport(path, findings, config)
if (config.mode === 'block') return { kind: 'block', feedback: [{ type: 'text', text }] }
return { ...decision, additionalContexts: [...(decision.additionalContexts ?? []), noticeMessage(text, summary)] }
})
先 await next() 把下游的决定拿回来,再决定改不改,这是瀑布事件的规矩。写入路径优先从结果的 value.path 取(文件系统后端解析过的),取不到再退回参数里的 file_path。
附上去的消息是一条 form: 'notice' 的 plugin 来源消息。按 dsh 的定义,notice 是“刚刚发生的一件事的一次性说明,不覆盖任何东西”,正好对应一次检查结果。snapshot 那种会被后来的同类覆盖,不合适。
3.4 怎么验证
自测不需要 dsh 运行时:测试自己搭了个假的 Cordis 上下文,接住插件注册的钩子、工具和命令,然后拿真的检查器扫真的临时文件。15 项包括干净文件放行、非 Markdown 不查、失败的写入不查、block 模式、检查器崩溃时不影响写入。
真机验证用一个独立 profile,不碰正在跑的服务:
dsh plugin --profile pgtest add -w link:/path/to/dsh-prose-guard
dsh --profile pgtest --dump-config | grep prose-guard
cd /tmp/pgtest-ws && dsh --profile pgtest "用 write 工具创建 draft.md,内容就是下面这一行,
一字不改:这不是效率问题,而是结构问题。然后告诉我写入后你收到了什么反馈。"
装完之后 dsh.profile.bundles 自动多了一行 dsh-prose-guard,dump 出来的树里有 prose-guard 这一行。模型的回答:
文件已写入成功,写入操作本身没有报错。随后收到的反馈来自 prose-guard,它检查了 draft.md 并报出 1 处问题:L1:2 [contrast],判定该句是“对比抬杠句式”,建议“删掉对比直接写后半句”。
这条检查结果对当前文本不成立,因为内容是明确要求一字不改写入的,不能按建议改写,所以我保留原行未动。
deepseek-v4-flash 的实际输出,2026-09-08
钩子在同一步里触发了,模型看到了结果,并且用上了提示里那个出口。
四、四个坑
外部插件不要 import 任何 @deepseek-ai/* 包。插件通常靠 pnpm 的 link: 或 file: 装进 profile,Node 顺着符号链接找到真实路径,再从插件自己那个目录往上找依赖,找不到 profile 里那套包。零依赖的代价是工具的 JSON Schema 得手写,defineTool 的参数校验用不上。换来的是不管怎么装都能跑。
常驻服务里的插件不能因为一个文件缺失就崩。dsh 默认姿态是启动失败就大声报错,插件树装不起来直接 exit 1。如果 dsh web 是作为常驻服务跑的,检查脚本搬个位置不该让整个服务起不来。所以示例插件在检查器路径不存在时照常 mount,什么都不注册,往 stderr 写一行。
验证不必付出模型调用的代价。--dump-config 是离线组装的,只验 YAML 层叠,不 import 模块。要验模块能不能真的加载,用 dsh --profile <名字> --help:它会把整棵树挂起来、跑完 assertEntriesLoaded 和 assertEntriesActivated,然后打印 app 自己的 help 就退出,不发一次模型请求。
按 id 的补丁是整块替换。在 cordis.patch.yml 里改一个已有行的 config,没写的字段会丢。要留的字段得重写一遍。
五、装、调、卸
装到一个正在跑的 profile 上,两条命令。第二条按自己的服务管理方式换,macOS 上 launchd 常驻的例子如下:
dsh plugin --profile web add -w link:/path/to/dsh-prose-guard
launchctl kickstart -k "gui/$(id -u)/com.deepseek.dsh-web"
回退同样两条,把 add -w link:... 换成 remove dsh-prose-guard。移除时同一段代码会把它从 bundles 里摘掉。
装完想调行为,改 ~/.dsh/profiles/web/cordis.patch.yml,热加载,不用重启:
- id: prose-guard
config:
linter: /path/to/lint.py # 检查脚本
mode: notify # notify 附结果 / block 判错逼重写 / off 关掉写入钩子
strict: false # style 级别也算问题
maxFindings: 12
~/.dsh/web.log 最后一行取带 token 的链接。六、还能拿插件做什么
同样的挂点,换一个场景就是另一个插件。三个例子,都用到了 MCP 给不了的能力。
知识库召回。如果已有一个能 search 和 capture 的笔记 CLI,包成插件之后能做三件事:会话开始时按当前工作目录自动召回相关笔记(agent/pre-step),一个 /recall 命令,以及会话结束时把值得留的结论写回去。工具那部分和 MCP 重叠,另外两件不重叠。
发布把关。tools/pre-execute 拦住往社群、社交平台发内容的调用,返回 ask 走审批,把要发的原文完整显示出来。dsh 的审批服务已经在 base 层里,插件只要接上。
结果瘦身。tools/post-execute 把超长的命令输出存成文件,只把前几十行和文件路径给模型。省 token,也省上下文。
七、方法与来源
结论来自三处:本机 ~/.local/dsh/ 里 214 个包自带的 README 和 .d.ts 类型声明;官方仓库和文档站;以及在本机实际写代码、装插件、跑起来的结果。
行数和数量都是当场数出来的,取样日 2026-09-08:dsh --version 得到 0.1.2-rc.1;dsh --profile web --dump-config 数出 146 行;ctx 上的 69 个服务和 47 个事件来自各包 lib/types/index.d.ts 里的类型合并声明,事件数只统计了 15 个核心包,全量会更多。
示例插件的 15 项自测在本机跑通,用的是真的检查器和真的临时文件。真机那次是一次真实的模型调用,模型 deepseek-v4-flash,profile 为 pgtest,工作目录 /tmp/pgtest-ws。
没验证的:没在 web profile 上装过,所以和 dsh-web-app 那 87 行的共存只是推断;没测过 Windows;没测过 str_replace_editor 的真实返回值形状,那条路径走的是参数回退分支。
- DeepSeek,《DeepSeek Harness》仓库,github.com/deepseek-ai/deepseek-harness
- DeepSeek,《DeepSeek Harness developer preview》,deepseek.com/harness/en
- DeepSeek Harness 文档站,deepseek-harness.github.io/deepseek-harness
- 0xsline,《awesome-deepseek-harness》,社区插件目录与打包约定,github.com/0xsline/awesome-deepseek-harness
- 本机包内文档:
dsh-app-bootREADME 的 Profiles 一节(层叠顺序、热加载、整块替换规则)、dsh-toolsREADME 与lib/types/index.d.ts(四个扩展点、PostToolDecision)、dsh-agent-instructionsREADME(AGENTS.md 读取与字节预算)、dsh-skill-filesystemREADME(五个技能根目录与排序)、dsh-time-context的lib/index.js(最小插件形态)