前言

DeepSeek Harness(下文简称 dsh)采用插件化的方式扩展功能。

第一次接触 dsh 插件开发,最容易产生困惑的是这些概念之间的关系:

  • Plugin 到底是什么?
  • apply(ctx) 为什么是插件入口?
  • ctx 到底能做什么?
  • Tool 和 Plugin 有什么区别?
  • Config 和 Tool Parameters 为什么不是一回事?
  • cordis.yml--patch、Profile、Bundle 又分别负责什么?
  • 本地开发插件和正式安装插件,底层到底是不是两套机制?
  • Host 插件和 Web 客户端插件为什么要拆开?

因此本文以自己做的一个用量监控插件从“写出来”到“跑起来”再到“打包发布”的完整过程来讲。

从零开始写出并运行第一个插件

怎么写出一个最简单的插件(Plugin),并让 dsh 真正把它运行起来?

Plugin 是什么

在 DeepSeek Harness 中,插件本质上是一个 Cordis 插件

它需要提供一个 Harness 可以调用的入口,最基本的结构是:

import type { Context } from "@deepseek-ai/cordis";
export const name = "hello-plugin";
export function apply(ctx: Context) {
  console.log("[hello-plugin] plugin loaded!");
}

这里最重要的是两个东西:

name
  └── 标识插件
apply(ctx)
  └── 插件被加载时执行的入口

因此可以先把 Plugin 理解成:

一组由 Harness 负责加载和管理生命周期的扩展代码。

nameapply(ctx)

name

export const name = "hello-plugin";

name 是插件模块导出的名称。

它描述的是:

“这个模块是什么插件”

apply(ctx)

export function apply(ctx: Context) {
  console.log("plugin loaded");
}

插件被 Harness 加载时,会调用:

apply(ctx)

所以:

Harness 加载模块
      ↓
找到 apply()
      ↓
调用 apply(ctx)
      ↓
插件开始运行

后面当插件拥有配置以后, apply(ctx) 会变成 apply(ctx,config)

export function apply(ctx: Context, config: Config) {
  // ...
}

创建一个插件

开发阶段最方便的方式,是直接写一个本地 TypeScript 插件。

可以使用类似目录:

deepseek-harness/
└── scratch-plugin/
    ├── cordis.yml
    └── src/
        └── hello.ts

hello.ts

import type { Context } from "@deepseek-ai/cordis";
export const name = "hello-plugin";
export function apply(ctx: Context) {
  console.log("[hello-plugin] plugin loaded!");
}

创建 cordis.yml

接下来创建:

scratch-plugin/cordis.yml

内容:

- insert:
  - id: hello
    name: "/absolute/path/to/deepseek-harness/scratch-plugin/src/hello.ts"

暂时只需要这样理解:

id
  └── 这条插件配置叫什么
name
  └── 插件源码文件在哪里

使用 --patch 启动

运行:

pnpm dsh web --patch ./scratch-plugin/cordis.yml

或者:

npx @deepseek-ai/dsh web --patch ./scratch-plugin/cordis.yml

如果插件被正确加载,就应该看到:

[hello-plugin] plugin loaded!

到这里就已经完成了插件的开发和调用:

写插件
  ↓
告诉 dsh 插件在哪里
  ↓
启动 dsh
  ↓
插件被加载
  ↓
apply(ctx) 被调用

插件加载之后能做什么

第一章中,我们写了:

export function apply(ctx: Context) {
  // ...
}

那么这个 ctx 到底是什么呢,现在正式解释:

ctx 是插件与 Harness 交互的入口。

插件被加载之后,大部分能力都从这里开始。

Context(ctx)是插件与 Harness 的入口

在正式插件中,经常会看到:

export function apply(ctx: Context, config: Config) {
  ctx.on("llm/stream", (options, next) => {
    // ...
  });
  ctx.inject(["settings"], (settingsCtx) => {
    // ...
  });
  ctx.inject(["webServer"], (webCtx) => {
    // ...
  });
}

也就是说:

插件不是直接修改 Harness 内部代码,而是通过 Context 注册行为和使用系统能力。

可以这样理解:

                    Plugin
                      │
                      ▼
               apply(ctx, config)
                      │
          ┌───────────┼───────────┐
          ▼           ▼           ▼
       监听事件      使用服务      注册资源
       ctx.on       ctx.inject    ctx.effect

ctx.on():监听系统事件

例如:

ctx.on("llm/stream", (options, next) => {
  // 在模型流式调用过程中执行逻辑
});

这类事件机制可以用于:

  • 调用统计
  • 日志
  • 监控
  • 钩子
  • 在某类系统事件发生时执行额外逻辑

例如后面我自己做的 usage-monitor,就是通过模型调用相关事件统计 LLM API 用量。

可以这样理解:

Harness 发生事件
      ↓
   ctx.on(...)
      ↓
插件执行自己的逻辑

ctx.effect():让资源跟随插件生命周期

插件有时会注册一些资源,例如:

  • HTTP 路由
  • 监听器
  • 定时器
  • 外部资源

它们需要在卸载时清理,生命周期机制的目标是:

插件加载
   ├── 注册资源
   ▼
插件运行
   │
   ▼
插件卸载
   │
   └── 清理属于该插件的资源

因此 effect 更适合用来管理:

“插件运行期间注册,插件卸载时需要释放”的资源。

Service 与 inject

很多能力并不直接属于插件本身。

例如插件可能需要:

tools
settings
webServer

这些能力可以理解为由 Harness 核心或其他插件提供的 Service

可以把 Service 理解成:

供其他插件通过 Context 使用的一组能力。

例如:

Service
├── tools
├── settings
├── webServer
└── ...

插件需要某个 Service 时,可以通过依赖注入来使用。

示例:

ctx.inject(["webServer"], (webCtx) => {
  webCtx.effect(() =>
    webCtx.webServer.register({
      kind: "exact",
      path: "/api/example",
      handler: (req, res) => {
        // ...
      },
    })
  );
});

它表达的是:

Plugin
   ↓ 需要 webServer
ctx.inject(["webServer"])
   ↓
获得 webServer 能力
   ↓
注册 HTTP 路由

因此:

Context
   │
   └── inject
         │
         ├── tools
         ├── settings
         └── webServer

开发第一个 Tool

Plugin 是 Harness 的扩展单元;Tool 是暴露给 LLM 调用的具体动作。

所以两者不是同一个东西,它们的关系更像:

Plugin
│
├── 可以监听事件
├── 可以使用 Service
├── 可以注册 HTTP 路由
├── 可以注册 Settings
│
└── 可以注册 Tool
       │
       └── 给 LLM 调用

一个插件:

  • 可以没有 Tool;
  • 可以有一个 Tool;
  • 也可以注册多个 Tool。

一个最小 Tool 插件

例如我们写一个:

greet-plugin

示意代码:

import type { Context } from "@deepseek-ai/cordis";
import { defineTool } from "@deepseek-ai/dsh-tools";

export const name = "greet-plugin";
export const inject = ["tools"];

export function apply(ctx: Context) {
  ctx.tools.register(
    defineTool({
      name: "greet",

      parameters: {
        name: {
          type: "string",
          required: true,
        },
      },

      output: {
        schema: { type: "string" },
        render: (_args, value) => [{ type: "text", text: String(value) }],
      },

      async execute(args) {
        return `Hello, ${args.name}!`;
      },
    })
  );
}

这里有两个不同层级的入口:

export function apply(ctx)
          │
          └── Plugin 被 Harness 加载时执行


async execute(args)
          │
          └── Tool 被 LLM 调用时执行

Tool 的调用过程

假设用户说:

请使用 greet 工具向 Ada 打招呼。

大致过程:

用户
 ↓
LLM
 ↓ 判断需要调用 greet
greet({
  name: "Ada"
})
 ↓
execute(args)
 ↓
"Hello, Ada!"
 ↓
返回给 LLM

因此一个 Tool 最核心的四个部分通常是:

Tool
├── name
├── parameters
├── output
└── execute

分别代表:

name
  └── 工具叫什么

parameters
  └── LLM 调用工具时应该传什么

output
  └── 工具返回什么(返回值的 JSON Schema + 渲染方式,必填)

execute
  └── 工具真正执行什么(第二个参数 exec 携带 signal、agent 等执行上下文)

Tool 常见用途包括:

  • 查询数据库
  • 调用 API
  • 搜索
  • 读写文件
  • 获取业务状态
  • 执行项目内部动作
  • 调用外部服务

最关键的一点是:

Tool 是给 LLM 调用的。

给 Plugin 增加 Config

现在我们的 Tool 有一个问题:

return `Hello, ${args.name}!`;

其中 Hello 是被写死的字符。

如果这里用户希望配置别的值,就不应该要求每个人修改源码。

这就是 Plugin Config 的用途。

定义 Config

例如,类型与运行时 Schema 成对定义(dsh 使用 @deepseek-ai/schemastery,惯例以 z 作为导入名;interface Config 是类型、const Config 是运行时 schema,二者同名共存是 TS 的惯用写法):

import z from "@deepseek-ai/schemastery";

export interface Config {
  greeting: string;
}

export const Config: z<Config> = z.object({
  greeting: z.string().default("Hello"),
});

然后(别忘了 inject 声明,否则 ctx.tools 不可用):

export const inject = ["tools"];

export function apply(ctx: Context, config: Config) {
  ctx.tools.register(
    defineTool({
      name: "greet",

      parameters: {
        name: {
          type: "string",
          required: true,
        },
      },

      output: {
        schema: { type: "string" },
        render: (_args, value) => [{ type: "text", text: String(value) }],
      },

      async execute(args) {
        return `${config.greeting}, ${args.name}!`;
      },
    })
  );
}

现在的 Hello 不再被写死,而是来自:

config.greeting

Config 与 Tool Parameters 的区别

这是非常容易混淆的一点。

现在我们的代码同时出现:

config.greeting

和:

args.name

它们来源完全不同:

插件用户 / 管理员
        ↓ config
      Plugin
 apply(ctx, config)
        ↓ 注册
       Tool
        ↑ args
       LLM

对比:

维度 Plugin Config Tool Parameters
谁提供 插件用户 / 管理员 LLM
什么时候提供 插件加载或配置更新时 每次 Tool 调用时
生命周期 相对长期 单次调用
作用 决定插件怎么运行 决定这一次工具做什么
典型内容 API URL、API Key、时区、开关 查询词、用户名、文件名、日期
代码位置 apply(ctx, config) execute(args)

Config 是插件怎么运行;Tool Parameters 是这一次 Tool 做什么。

dsh 到底如何加载插件

在前面章节中我们使用过:

dsh web --patch ./scratch-plugin/cordis.yml

那么 --patch 到底干了什么?

这会自然引出 dsh 文档里经常出现的三个词:

Profile
Patch
Bundle

Patch:对插件树的一层修改

先从最熟悉的yml文件说起:

- insert:
  - id: hello
    name: "/path/to/hello.ts"

准确地说,这不是“完整插件配置”。它表达的是:

在现有插件树基础上,再做一次修改。

因此它其实就是 Patch ,可以把 Patch 理解成:

原始插件树
     ↓
   Patch
     ↓
修改后的插件树

Patch 可以用于:

  • 插入插件条目
  • 覆盖已有条目
  • 调整配置
  • 禁用条目

所以:

Patch 不是整棵插件树,而是对插件树的一层修改。

为什么 --patch 很适合开发

开发模式中:

dsh web --patch ./scratch-plugin/cordis.yml

表示:

系统原有配置
      ↓
额外叠加 cordis.yml
      ↓
本次启动最终配置

下一次启动如果不传:

--patch

这层临时配置就不会自动加入。

因此 --patch 很适合:

  • scratch 插件
  • 临时测试
  • 调试
  • 临时覆盖

Profile:一套具体运行环境

如果我希望某些依赖和插件配置长期保留,它们放在哪里?

这就是 Profile,可以把 Profile 理解成:

一套具体运行环境的依赖与插件配置集合。

例如 Web profile:

~/.dsh/profiles/web/

典型内容可能包括:

web/
├── package.json
├── cordis.patch.yml
└── node_modules/

因此一个 Profile 可以包含:

Profile
├── dependencies
│    └── 安装了哪些 npm 包
│
├── bundles
│    └── 哪些 Bundle 参与启动
│
└── cordis.patch.yml
     └── 这个 Profile 自己的补丁层

可以把 web 理解成一套独立运行环境。

Profile 自己的 cordis.patch.yml

除了命令行临时传:

--patch ./xxx.yml

Profile 目录还可以有自己的:

cordis.patch.yml

它可以理解成:

这个 Profile 长期存在的一层用户级 Patch。

于是:

临时测试
   └── --patch

长期保留
   └── profile/cordis.patch.yml

Bundle:一个携带 Patch 的包

接下来问题来了:

第三方 npm 插件安装进 Profile 以后,怎么自动把自己的插件配置带进来?

这就是 Bundle。

一个可安装插件包可能长这样:

dsh-usage-monitor/
├── package.json
├── cordis.patch.yml
├── lib/
│   └── index.js
└── src/
    └── index.ts

其中 package.json

{
  "name": "dsh-usage-monitor",
  "main": "lib/index.js",
  "files": ["lib/index.js", "cordis.patch.yml"],
  "dsh": {
    "bundle": {
      "patch": "./cordis.patch.yml"
    }
  }
}

关键是:

"dsh": {
  "bundle": {
    "patch": "./cordis.patch.yml"
  }
}

它表达的是:

这个包携带一份 dsh Patch。

因此可以把 Bundle 理解成:

一个能够给 Profile 提供 Patch 的包。

Bundle 和 Plugin 不是一回事

这是非常容易混淆的地方。

假设:

dsh-usage-monitor/
├── package.json
├── cordis.patch.yml
└── lib/index.js

其中:

Bundle 层
  └── cordis.patch.yml

Plugin 模块
  └── lib/index.js

Bundle 解决:

“要把哪些插件配置带进 Profile?”

Plugin 解决:

“插件真正被加载以后执行什么?”

完整过程:

Bundle
   ↓ 提供 Patch
Patch
   ↓ 插入 Plugin 条目
Plugin Loader
   ↓
lib/index.js
   ↓
apply(ctx, config)

所以:

Bundle 是包装 / 配置层;Plugin 是执行代码层。

一个 Bundle 可以包含多个插件条目

Bundle 并不等于一个 Plugin。

例如某个 Bundle 的 patch 完全可以写:

- insert:
  - id: search
    name: my-search-plugin
  - id: memory
    name: my-memory-plugin
  - id: analytics
    name: my-analytics-plugin

也就是:

1 Bundle
   │
   └── 1 Patch
         │
         ├── Plugin A
         ├── Plugin B
         └── Plugin C

因此 Bundle 更接近:

一组插件配置的分发单元。

Profile / Bundle / Patch 的完整关系

现在三个词可以放到一张图里:

Profile 一套运行环境
  ↓ 加载多个 Bundle
Bundle 携带 Patch 的包
  ↓ 提供 Patch
Patch 对插件树的一层修改
  ↓ 修改插件树
Plugin Tree
  ↓
Plugin
  ↓
apply(ctx, config)

开发模式与安装模式

现在再看“开发模式”和“安装模式”,就会发现它们不是两套完全不同的系统。

开发模式

本地 .ts
   ↓
自己写 Patch
   ↓
--patch
   ↓
最终插件树
   ↓
Plugin

适合:

  • 学习
  • 快速试验
  • 源码仓库内开发

安装模式

npm package
   ↓
Bundle
   ↓
Bundle 自带 Patch
   ↓
Profile
   ↓
最终插件树
   ↓
Plugin

适合:

  • 正式安装
  • 分发
  • 第三方插件

开发模式与安装模式对比

维度 开发模式(scratch) 安装模式(npm 包)
插件形态 .ts 源文件 编译后的 npm 包
Patch 自己准备 包中携带
Patch 中的 name 本地文件路径 裸包名
加载方式 --patch / Profile Patch dsh plugin add
持久性 --patch 通常是临时 安装进入 Profile 后长期生效
适合场景 学习、试验、源码开发 正式安装、分发、第三方插件

最重要的一句话:

开发模式和安装模式底层仍然是在构建同一棵插件树。

区别主要是:

插件在哪里
+
Patch 从哪里来

安装插件

从 npm 安装:

npx @deepseek-ai/dsh plugin --profile web add <plugin-name>

从 GitHub 安装:

npx @deepseek-ai/dsh plugin --profile web add github:用户名/仓库名

安装本地插件:

npx @deepseek-ai/dsh plugin --profile web add <absolute_path_to_plugin>

查看依赖关系:

npx @deepseek-ai/dsh plugin --profile web why <plugin-name>

移除:

npx @deepseek-ai/dsh plugin --profile web remove <plugin-name>

Bundle 自动进入 Profile

安装完成后,dsh 会根据 Profile 的依赖,以及这些包是否声明:

dsh.bundle.patch

来维护:

dsh.profile.bundles

例如:

{
  "dsh": {
    "profile": {
      "bundles": [
        "@deepseek-ai/dsh-base",
        "@deepseek-ai/dsh-web-app",
        "dsh-usage-monitor"
      ]
    }
  }
}

可以理解成:

Profile dependencies
       ↓ 哪些包声明 dsh.bundle.patch?
dsh.profile.bundles

因此:

安装一个声明了 Bundle Patch 的包后,它就可以自动参与 Profile 的启动加载。

最终 Patch Layer Stack

概念上,dsh 启动时可能形成:

Bundle 1 的 Patch
        ↓
Bundle 2 的 Patch
        ↓
第三方插件 Bundle 的 Patch
        ↓
Profile 自己的 cordis.patch.yml
        ↓
命令行 --patch
        ↓
最终插件树

越靠后的层,可以继续对前面的结果修改。

所以命令行 --patch 非常适合做最后一层临时覆盖。

Loader 最终做什么

经过 Patch 层叠加以后,最终得到的是一棵插件条目树。

然后 loader 根据每个条目的 name 去找到真正的插件模块,不管来源是哪一种,加载到的目标仍然是:

export const name = "...";

export const Config = ...; // 可选

export function apply(ctx, config) {
  // ...
}

实例:我的API用量监控插件 usage-monitor

假设我们要开发:

dsh-usage-monitor

用于统计 LLM API 用量。

需求

这个插件希望完成:

  1. 监听模型调用;
  2. 计算或记录用量;
  3. 允许用户设置币种、时区;
  4. 提供 HTTP API 给前端读取报表;
  5. 最终可以在 Web UI 中展示数据。

可以先画成:

              llm/stream
                  │
                  ▼
           usage-monitor
                  │
          记录 / 计算用量
                  │
          ┌───────┴────────┐
          ▼                ▼
      Settings          HTTP API
                            │
                            ▼
                          Web UI

Host 插件结构

Host 包可以是:

dsh-usage-monitor/
├── package.json
├── cordis.patch.yml
├── lib/
│   └── index.js
└── src/
    └── index.ts

其中:

src/index.ts
   └── TypeScript 源码

lib/index.js
   └── 编译后的分发产物

Plugin Config

例如:

import z from "@deepseek-ai/schemastery";

export interface Config {
  currency: string;
  tz: string;
}

export const Config: z<Config> = z.object({
  currency: z.string().default("CNY"),
  tz: z.string().default("Asia/Shanghai"),
});

这里的currencytz都是 Plugin Config。

因为它们描述的是:

这个 usage-monitor 插件实例应该怎么运行。

而不是 LLM 每次调用某个 Tool 时决定的参数。

监听 LLM 调用

示意:

export function apply(ctx: Context, config: Config) {
  ctx.on("llm/stream", (options, next) => {
    // 包装、记录或统计模型调用
  });
}

这使用的是前面讲过的:

Plugin
   ↓
Context
   ↓
ctx.on(...)
   ↓
Event Hook

注册 Settings

如果希望配置可以进入设置系统:

ctx.inject(["settings"], (settingsCtx) => {
  settingsCtx.settings.register(
    settingsNamespace("usage-monitor"),
    Config,
    { base: {} }
  );
});

这里的结构:

usage-monitor Plugin
      ↓ 需要 settings
ctx.inject(["settings"])
      ↓
settings Service
      ↓
注册 Config

所以:

Config 定义“有哪些配置”;Settings Service 负责“把这些配置接入设置系统”。

注册 HTTP API

例如:

ctx.inject(["webServer"], (webCtx) => {
  webCtx.effect(() =>
    webCtx.webServer.register({
      kind: "exact",
      path: "/api/usage-monitor/report",
      handler: (req, res) => {
        // 返回报表
      },
    })
  );
});

这里同时使用了两个前面讲过的概念:

inject
   └── 获取 webServer Service

effect
   └── 把路由注册与插件生命周期绑定

最终:

Browser
   ↓ HTTP
/api/usage-monitor/report
   ↓
usage-monitor
   ↓
返回统计数据

Host 插件的数据流

现在可以把 Host Plugin 看成:

Harness
   │
   │ llm/stream
   ▼
usage-monitor
   │
   ├── 读取 config
   │     ├── currency
   │     └── tz
   │
   ├── 记录模型调用
   │
   ├── 注册 Settings
   │
   └── 注册 HTTP API

这就是一个真实插件在 apply(ctx, config) 中完成的工作。

打包为 Bundle

Host 包的 package.json

{
  "name": "dsh-usage-monitor",
  "main": "lib/index.js",
  "files": [
    "lib/index.js",
    "cordis.patch.yml"
  ],
  "dsh": {
    "bundle": {
      "patch": "./cordis.patch.yml"
    }
  }
}

它告诉 dsh:

这是一个可作为 Bundle 使用的包
        │
        └── Patch 在 ./cordis.patch.yml

Bundle 自带的 Patch

例如:

- insert:
  - id: usage-monitor
    name: dsh-usage-monitor

开发模式中,可能是:

name: /absolute/path/to/plugin.ts

安装模式中则可以写:

name: dsh-usage-monitor

后者由模块解析机制从 Profile 的依赖中定位到真正的编译产物。

安装以后发生什么

安装:

npx @deepseek-ai/dsh plugin --profile web add dsh-usage-monitor

大致可以理解成:

dsh-usage-monitor
      ↓
进入 web Profile 的 dependencies
      ↓
发现它声明 dsh.bundle.patch
      ↓
进入 Profile 的 bundles
      ↓
启动时加载它的 Patch
      ↓
Patch 插入 usage-monitor Plugin
      ↓
loader 找到 lib/index.js
      ↓
apply(ctx, config)
      │
      ├── 监听 LLM
      ├── 注册 Settings
      └── 注册 HTTP API

到这里,Host 插件已经完整。

接下来如果还需要 Web UI,就进入客户端插件。

给插件增加 Web 界面

前面讲的主要是 Host 插件。

也就是:

Node.js / Harness 侧

如果插件还需要在 Web UI 中展示:

  • 会话页徽章
  • 自定义面板
  • 报表
  • 前端交互组件

就需要客户端插件。

为什么 Host 和 Client 要拆开

Host 和 Browser 是两个不同运行环境。

Host / Node.js
├── 监听 LLM 事件
├── 使用 Harness Service
├── 提供 HTTP API
└── 处理业务逻辑

Browser
├── 渲染 UI
├── 响应用户操作
└── 调用 Host API

所以一个完整插件可能拆成:

dsh-usage-monitor
    └── Host

dsh-usage-monitor-client
    └── Browser Client

Client 包结构

例如:

dsh-usage-monitor-client/
├── package.json
├── lib/
│   ├── index.js
│   └── client.js
└── src/
    └── ...

具体目录可以按项目构建方式调整。

dsh.client

客户端包可以在 package.json 声明:

{
  "name": "dsh-usage-monitor-client",
  "main": "lib/index.js",
  "exports": {
    "./client": "./lib/client.js"
  },
  "dsh": {
    "client": {
      "platform": "web",
      "inject": [
        "@deepseek-ai/dsh-client-locale",
        "@deepseek-ai/dsh-client-runtime"
      ]
    }
  }
}

它表达的是:

Client Package
      ↓ 声明 dsh.client
Web 启动阶段发现客户端模块
      ↓
加载 exports["./client"]
      ↓
Browser 运行客户端代码

Client 如何访问 Host

Host 已经暴露:

/api/usage-monitor/report

客户端可以:

Browser UI
    ↓ HTTP
Host Plugin API
    ↓
usage-monitor 数据
    ↓
渲染报表

因此 Host / Client 的职责可以这样分:

Host
  └── 数据和业务逻辑

Client
  └── UI 和用户交互

纯客户端包不一定是 Bundle

如果一个客户端包只声明:

"dsh": {
  "client": {
    "platform": "web"
  }
}

但没有:

"dsh": {
  "bundle": {
    "patch": "..."
  }
}

那么它只是一个客户端依赖,并不一定进入:

dsh.profile.bundles

这也说明:

Bundle 与 Client 是两个不同维度的声明。

最终项目结构

一个完整的 usage-monitor 可以抽象成:

usage-monitor/
│
├── host/
│   ├── package.json
│   ├── cordis.patch.yml
│   ├── src/
│   │   └── index.ts
│   └── lib/
│       └── index.js
│
└── client/
    ├── package.json
    ├── src/
    └── lib/
        └── client.js

逻辑关系:

                     DeepSeek Harness
                            │
                            ▼
                         Profile
                            │
                            ▼
                     usage-monitor Bundle
                            │
                            ▼
                          Patch
                            │
                            ▼
                    usage-monitor Plugin
                            │
                ┌───────────┼───────────┐
                ▼           ▼           ▼
            llm/stream   Settings    HTTP API
                                        │
                                        ▼
                                 usage-monitor-client
                                        │
                                        ▼
                                      Web UI

至此,一个从 Host 到 Browser 的完整插件结构就形成了。

附录 A:常用命令

使用临时 Patch 启动

pnpm dsh web --patch ./scratch-plugin/cordis.yml

或者:

npx @deepseek-ai/dsh web --patch ./scratch-plugin/cordis.yml

安装 npm 插件

npx @deepseek-ai/dsh plugin --profile web add <plugin-name>

安装 GitHub 插件

npx @deepseek-ai/dsh plugin --profile web add github:用户名/仓库名

安装本地插件

npx @deepseek-ai/dsh plugin --profile web add <absolute_path_to_plugin>

查看依赖关系

npx @deepseek-ai/dsh plugin --profile web why <plugin-name>

移除插件

npx @deepseek-ai/dsh plugin --profile web remove <plugin-name>

附录 B:定义 Tool 速查

import { defineTool } from "@deepseek-ai/dsh-tools"

注册,注意使用 ctx.tools 前需要 export const inject = ["tools"](或 ctx.inject(["tools"], ...)):

ctx.tools.register(defineTool({ ... }))
name、parameters、output({ schema, render },presentationMeta 可选)、execute
{ name: { type: "string", required: true } }

execute(args, exec) 的第二个参数 exec 携带 signal(取消信号)、agent 等执行上下文

返回值的 output.schema 是 JSON Schema;

output.render(args, value) 返回展示内容,如 [{ type: "text", text: ... }] 。