本文基于 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 做的注册——事件监听、工具注册、定时器——在插件卸载时都会被自动清理。你不需要手动 removeListenerclearInterval

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 的配置按层叠加,后层覆盖前层:

  1. profile 的 bundle 列表(如 @deepseek-ai/dsh-base@deepseek-ai/dsh-web-app
  2. profile 自己的 cordis.patch.yml$DSH_HOME/profiles/<name>/
  3. 机器级的 $DSH_HOME/cordis.patch.yml
  4. 每次启动用 --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 场景)

  1. 插件路径必须是 file:/// URL。Windows 上写 F:/... 会被当成 f: 协议抛 ERR_UNSUPPORTED_ESM_URL_SCHEME
  2. wsl.exe 直启会污染 HOME。Windows 的 HOME=C:\Users\<you>\... 被传进 WSL,codex 会去读 Windows 侧的 ~/.codex 配置。解法:bash -lc 里用 getent passwd $(id -un) 动态取 WSL 用户目录重设 HOME。
  3. 配置层按行整替。patch 覆盖一行是替换整个 config 对象,不是深合并——覆盖内置插件时要重述它需要的全部键。
  4. 一切注册都是 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辅助开发

邀请链接


如果您觉得有用,欢迎 点赞、转发、评论、关注

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐