← 返回

Technical notes · 2026 年 9 月

怎么给 dsh 写插件

DeepSeek Harness 的插件长什么样,装到哪里,能挂在哪些位置,以及一个从零写到跑通的示例插件。

基于 dsh 0.1.2-rc.1取样日 2026-09-08示例代码已在本机跑通

先说结论

dsh 的插件是一个导出 nameinjectapply 的 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.toolsctx.systemPromptctx.commandsctx.skillsctx.subagentsctx.llm 都在里面。apply 里注册的每一样东西都绑在 ctx 上,插件卸载时自动回收,热重载不用自己清理。

1.2 profile 是一摞补丁

profile 在 ~/.dsh/profiles/<名字>/,里面一个 package.json(装外部插件的地方,加一份有序的 dsh.profile.bundles 层列表)和一个 cordis.patch.yml(用户自己的补丁层)。

装配从一个空数组开始,每一层往上打补丁:insert 加行,按 id 定位的补丁改行。一个典型的 web profile 是这样:

空的 profile 根 [] + @deepseek-ai/dsh-base 85 行 + @deepseek-ai/dsh-web-app 87 行 + @deepseek-ai/dsh-subagent-claude-code 1 行 + 第三方插件(如 dsh-prose-guard) 1 行 + profiles/web/cordis.patch.yml 热加载 + ~/.dsh/cordis.patch.yml 优先级最高 = 装配好的插件树 146 行
靛蓝的三层是用户能改的。行数用 dsh --profile web --dump-config 数出来,取样日 2026-09-08。最后两层是纯 YAML,不用装包。
数据表
web profile 各层的行数
行数来源
dsh-base85包内 cordis.patch.yml
dsh-web-app87包内 cordis.patch.yml
dsh-subagent-claude-code1包内 cordis.patch.yml
装配结果146dump-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: 指向工作目录,改完重启服务即可,不用发版。

说明这里有个 pnpm 的坑:profile 目录是 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.mdCLAUDE.md,广到窄拼成第一条基线消息。已经在用 Claude Code 或 Codex 的人,把自己的全局指令文件复制一份到 ~/.dsh/AGENTS.md,dsh 就认得同一套规则。

技能

dsh-skill-filesystem 也在 base 层,默认扫五个根目录,其中一个是 ~/.agents/skills。这个目录是多个 agent 工具共用的约定位置,放进去的技能 dsh 直接能看到。目录带监听,加删技能不用重启。

技能和插件的分工是:技能要模型自己决定加载,插件是无条件触发。一条写作规则两边都可以有,技能负责“怎么写”,插件负责“写完了必须查一遍”。

一段部署人格

system-promptpersona 字段接受一段字符串模板,支持 {{model}}{{cwd}}。在 cordis.patch.yml 里写一行就行,不用建包。

差别MCP 服务器只能加工具。上面这些挂点里,改系统提示、截工具结果、加斜杠命令、换模型适配器,MCP 都做不到。要这几样才需要写插件。另外默认的 web 树里没有 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-executewriteeditstr_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:它会把整棵树挂起来、跑完 assertEntriesLoadedassertEntriesActivated,然后打印 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
顺带0.1.2-rc.1 之后 Web 界面加了一次性 token 鉴权,裸访问 127.0.0.1:3080 返回 401。重启服务会换一个新 token,从 ~/.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 的真实返回值形状,那条路径走的是参数回退分支。

  1. DeepSeek,《DeepSeek Harness》仓库,github.com/deepseek-ai/deepseek-harness
  2. DeepSeek,《DeepSeek Harness developer preview》,deepseek.com/harness/en
  3. DeepSeek Harness 文档站,deepseek-harness.github.io/deepseek-harness
  4. 0xsline,《awesome-deepseek-harness》,社区插件目录与打包约定,github.com/0xsline/awesome-deepseek-harness
  5. 本机包内文档:dsh-app-boot README 的 Profiles 一节(层叠顺序、热加载、整块替换规则)、dsh-tools README 与 lib/types/index.d.ts(四个扩展点、PostToolDecision)、dsh-agent-instructions README(AGENTS.md 读取与字节预算)、dsh-skill-filesystem README(五个技能根目录与排序)、dsh-time-contextlib/index.js(最小插件形态)