我给 DeepSeek 的编程智能体写了三个插件:余额胶囊、任务面板、番茄钟

副标题:一文摸清 DeepSeek Harness 的插件体系------含三个"测试全绿、真机全挂"的真实踩坑复盘

前言

DeepSeek 开源了它的编程智能体框架 deepseek-harness,README 里只有一句口号:

DeepSeek Harness: Everything is a Plugin.

口号谁都会喊。到底是真插件化,还是"配置文件换个叫法"?我花了一个周末,给它连写了三个插件,一个比一个深:

  1. 余额胶囊 ------ 顶栏常驻一颗胶囊显示 DeepSeek 平台账户余额,悬停出详情,外加一个 /balance 斜杠命令;
  2. 任务面板 ------ 右侧边栏加一个"任务"标签页,实时镜像模型的任务清单,完成一项自动打勾;
  3. 番茄时钟 ------ 顶栏胶囊 + 浮窗 + 设置页 + /pomodoro 命令 + 给模型用的工具,宿主端权威计时、跨重启持久化。

三个插件写下来,结论是:这句口号是真的。会话、文件编辑、斜杠命令、连顶栏每一颗按钮,在 Harness 里都是插件;而第三方插件和官方插件走的是同一条装载路径,没有任何"内部通道"。

这篇文章按三个插件的顺序复盘整个开发过程------从 profile 结构、宿主/客户端双端协议,到最后"测试 50 个用例全绿、真机却三个功能全挂"的踩坑故事。所有坑都是真实踩过的,代码都在本机的插件目录里,可以照着一步步复刻。

环境说明:dsh 0.1.5-rc.3,Windows 11,Node 22。插件目录约定为 dsh-<名字>,安装目标是默认的 web profile。


一、五分钟看懂 Harness 的插件体系

1.1 一切皆插件,插槽即入口

Harness(命令行叫 dsh)启动时做的唯一一件事,就是把一堆插件按顺序"组合"成一颗系统树。你看到的聊天界面、文件编辑工具、/compact 命令,全都是别人写好的插件。所谓给 Harness 加功能,就是回答三个问题:

  • 我的代码在宿主端(Node)做什么?------注册命令、注册工具、挂服务、开定时器......
  • 我的代码在客户端(浏览器)画什么?------胶囊、面板、浮窗;
  • 两端怎么接?

1.2 profile:启动套餐

插件装进"profile"才生效。dsh web 用的是 web 这套套餐,配置在 ~/.dsh/profiles/web/:

json 复制代码
// ~/.dsh/profiles/web/package.json(装完插件后 dsh 自动维护)
{
  "dsh": {
    "profile": {
      "bundles": [
        "@deepseek-ai/dsh-base",
        "@deepseek-ai/dsh-web-app",
        "dsh-command-balance",     // ← 我们的插件,装完自动追加
        "dsh-panel-tasks",
        "dsh-pomodoro"
      ]
    }
  }
}

bundles 是一个补丁层叠:每个 bundle 自带一份补丁文档,声明"往系统树里插入哪一行",后写的覆盖先写的。这就是 Harness 的组合机制------像乐高说明书逐层叠加。

1.3 插件的最小骨架

一个插件 = 一个目录,四个文件起步:

bash 复制代码
dsh-command-balance/
├── package.json        ← 身份证:声明自己既是 bundle 又有客户端半侧
├── cordis.patch.yml    ← 安装说明书:把自己挂到系统树
├── lib/index.js        ← 宿主半侧(Node):注册命令、挂服务
└── lib/client.js       ← 客户端半侧(浏览器):画胶囊

package.json 里两份声明是灵魂:

json 复制代码
{
  "name": "dsh-command-balance",
  "type": "module",
  "main": "lib/index.js",
  "exports": { ".": "./lib/index.js", "./client": "./lib/client.js" },
  "dsh": {
    "bundle": { "patch": "./cordis.patch.yml" },
    "client": { "platform": "web" }
  }
}

dsh.bundle.patch 声明"我是功能包,装上即生效";dsh.client 声明"我还有一段跑在浏览器里的代码"。安装只要一条命令(需要 pnpm 在 PATH 里):

sh 复制代码
npm i -g pnpm
dsh plugin --profile web add D:\path\to\dsh-command-balance   # 必须绝对路径

dsh plugin add 本质是 pnpm 转发器:把你的目录链进 profile 的依赖,然后按"已安装状态"自动重算 bundles 列表 ------声明了 dsh.bundle 的包自动入列,删掉自动出列。


二、第一个插件:余额胶囊(宿主 + 客户端的最小闭环)

2.1 宿主半侧:cordis 三件套

任何宿主插件都导出三个东西,我称之为"三件套":

js 复制代码
const name = "command-balance";   // ① 插件编号
const inject = ["commands"];      // ② 依赖的服务,就绪后才轮到我 apply
function apply(ctx, config) {     // ③ 把功能接到插线板上
  ctx.commands.register({
    name: "balance",
    description: "查询 DeepSeek 平台账户余额",
    handler: async () => ({ kind: "success", text: "CNY 总余额 10.13" })
  });
}
export { apply, inject, name };

ctx.commands.register 注册的是人类专用 的斜杠命令:handler 直接对 UI 返回文本,不产生模型消息、不烧 token。返回 {kind: "success"|"error", text},界面原样渲染。

2.2 API key 绝不进代码

余额接口是 DeepSeek 开放平台的 GET /user/balance,需要 Bearer key。key 从哪来?Harness 有一个凭据保管库 (~/.dsh/.credentials.yaml,设置页的模型页写入),插件按名引用:

js 复制代码
async function resolveApiKey(ctx, apiKeyEnv) {
  const credentials = ctx.get("credentials");
  if (credentials !== undefined) {
    const hit = await credentials.resolve(apiKeyEnv);   // { value, source } | undefined
    if (hit?.value) return hit.value;
  }
  return process.env[apiKeyEnv];   // 凭据库没配置时回退环境变量
}

代码里只有钥匙的名字 ,没有钥匙的内容------这是整个体系最值得学的设计之一。

2.3 宿主端挂服务:SRC 模式,零框架导入

胶囊要显示余额,浏览器端就得拿到数据。key 不能进浏览器,所以方向反过来:浏览器问宿主要。宿主端把余额查出来挂成一个远程服务------有意思的是,这里可以做到零框架导入:

js 复制代码
class BalanceController {
  async get() { return { ok: true, fetchedAt: ..., payload: await queryBalance() }; }
}
// Gateway 的"源码发现模式"只认两样东西,都是纯数据:

// ① 实例上的绑定
controller.typertRemote = Object.freeze({
  service: controller, serviceKey: "balanceController", namespace: "balance"
});
// ② 原型上的方法标记
Object.defineProperty(BalanceController.prototype,
  "@deepseek-ai/dsh-typert-protocol/remote-methods", {
    value: Object.freeze({ version: 1, methods: Object.freeze([
      Object.freeze({ method: "get", invocation: Object.freeze({ kind: "direct" }) })
    ]) })
  });

ctx.provide("balanceController", controller);   // 挂进上下文,Gateway 自动发现

Gateway 扫描上下文里的服务,读到这两个标记就自动生成 balance/get 端点,参数按"源码模式"直通 JSON------不用生成器、不用装饰器、不 import 任何 @deepseek-ai 的包。这对我这种只想写个小插件的场景极其友好。

2.4 客户端半侧:插槽 + 信封

lib/client.js 必须按浏览器的模块格式包装:

js 复制代码
window.__ModuleLoader__.load({
  id: "dsh-command-balance",
  factory: (require) => {
    var module = { exports: {} };
    /* ...插件逻辑,require("react") 等平台模块由宿主注入... */
    exports.apply = apply;
    exports.inject = ["slots", "remote"];
    return module.exports;
  }
});

apply(ctx) 里做两件事------挂载描述符 (告诉客户端"存在 balance/get 这个方法")和贡献组件(把自己画进界面的插槽):

js 复制代码
async function apply(ctx) {
  await ctx.remote.$mount(CONTRIBUTION);      // 挂 balance/get 的客户端描述符
  ctx.slots.inject("conversation.session.header.utilities", () =>
    ctx.slots.register({ name: "conversation.session.header.utilities", id: "balance", order: 20 },
      BalancePill));                           // 顶栏工具区是 list 型插槽,order 决定排序
}

conversation.session.header.utilities 是"会话顶栏工具区",list 型插槽、所有条目都渲染。官方的"在应用中打开"插件也挂这里。组件里取数据用 ctx.get("remote.balance").get(),返回的是 RPC 信封:

js 复制代码
const envelope = await ctx.remote.balance.get();
// envelope = { ok: true, value: { ...业务数据... } }
//                            ^^^^ 真正的结果在 .value 里,别像我一上来直接读业务字段

余额接口本身没什么玄机:https://api.deepseek.com/user/balance,Bearer 头,返回 {is_available, balance_infos: [{currency, total_balance, granted_balance, topped_up_balance}]}。

2.5 装上,效果

重启 dsh web,进入任意会话:顶栏出现胶囊,悬停弹出余额详情气泡,60 秒缓存,/balance 随叫随到。第一个插件,不到两百行,闭环了。


三、第二个插件:任务面板(纯客户端,借官方的东风)

第二个插件我想做"任务列表面板:完成一项打勾一项"。调研后发现根本不需要自己存任务数据 ------Harness 的模型自带一个 todo_write 工具(规划多步工作、标记进行中、完成勾掉),而这份任务清单已经作为一个**会话投影(session projection)**暴露给了客户端,键名就叫 todos,还自带线上传输 schema。第三方面板直接订阅即可。

于是这个插件是纯客户端的:宿主半侧是空壳,只为了让加载器认领这个包。

3.1 把面板画进右侧边栏

右侧边栏的扩展点是两个插槽:sidebar.right.pane.tab(面板体)和 sidebar.right.pane.tab.title(标签标题)。先注册标签类型,再贡献两个插槽条目:

js 复制代码
const inject = ["slots", "sessions", "sidebarRightTabs", "sidebarRight"];

async function apply(ctx) {
  ctx.sidebarRightTabs.register({ id: "dsh-panel-tasks", kind: "tasks", title: () => "任务" });
  ctx.slots.inject("sidebar.right.pane.tab", () => ctx.slots.register({
    name: "sidebar.right.pane.tab",
    key: "dsh-panel-tasks",
    inject: (sessionId) => ({ faceOf: () => faceOfTodos(ctx, sessionId) })
  }, TasksBody));
  ctx.slots.inject("sidebar.right.pane.tab.title", () => ctx.slots.register({
    name: "sidebar.right.pane.tab.title", key: "dsh-panel-tasks"
  }, TasksTitle));
}

注意插槽的 inject 工厂:会话级插槽会给它传入 sessionId------这是拿到"当前会话"的正规入口。

3.2 订阅 todos 投影:官方同款数据

拿到 sessionId 后,通过 sessions 服务绑定会话、取出投影面:

js 复制代码
function faceOfTodos(ctx, sessionId) {
  return ctx.sessions.binding(sessionId)?.session?.projections?.faceOf("todos") ?? null;
}

投影面是标准的可观察源(getSnapshot / subscribe),一个 useSyncExternalStore 就能接进 React:

js 复制代码
function useFace(face) {
  const source = face ?? NULL_SOURCE;
  return useSyncExternalStore(
    (changed) => source.subscribe(changed),
    () => source.getSnapshot()
  );
}

然后渲染就只剩纯 UI:每项按状态画 ✓(completed,绿色划线)、▶(in_progress,高亮)、○(pending),头部一个 任务 N/M 进度条。

模型每调用一次 todo_write,投影就变,面板跟着重绘------"完成一项自动打勾"一行逻辑都不用写,数据本身就是响应式的。这让我很受触动:好架构的标志就是扩展者什么都不用造,只要"接"。

顺手在顶栏也放了一颗"任务"按钮(还是 header.utilities 插槽),点击用 ctx.sidebarRight.openTabFromTarget("tasks", target) 打开面板,免得用户找不到入口。


四、第三个插件:番茄钟(全栈,把"工具"也给了模型)

前两个插件都是"人 → 界面 → 数据"。第三个我想把模型也拉进来:对模型说"开始一个 25 分钟专注",它就应该真的把番茄钟打开。这就凑齐了完整闭环:宿主端权威计时 + 浏览器三处 UI + 一条斜杠命令 + 一个模型工具。

4.1 架构决策:时钟住在宿主端

番茄钟最容易写错的地方是把倒计时写在浏览器里(一个 setInterval 的事,对吧?)。但页面会刷新、笔记本会睡眠、窗口会关闭------计时器一丢全丢。正确做法:宿主端是唯一权威,状态里只存 endsAt,倒计时 = endsAt - now。

这样衍生出一串好性质:

  • 进程重启、笔记本睡一晚,任何一次读取都能按时钟追账------睡过头的三个循环按各自的计划边界逐个结算,而不是从唤醒时刻重新计一个整番茄;
  • 客户端只在两次轮询之间用本地 Date.now() 插值秒数,宿主 1 秒一次轮询、空闲 5 秒一次;
  • 状态持久化到 $DSH_HOME/pomodoro.json,临时文件 + rename 原子写,并发写串行排队。

4.2 三个界面,一个 store

顶栏胶囊(倒计时 + 控制弹层)、右下角浮窗(SVG 进度环 + 七天柱状图)、设置页一行------三个 React 组件全部订阅同一个轮询 store ,不管页面上挂了几个组件,永远只有一条远程轮询。轮询拿到的快照里 endsAt 是绝对时间,本地插值和宿主时钟天然对齐。

js 复制代码
contribute("conversation.session.header.utilities", { id: "pomodoro-pill", order: 19 }, PomodoroPill);
contribute("sidebar.footer.action",                 { id: "pomodoro-toggle", order: 30 }, SidebarToggle);
contribute("settings.general.item",                 { id: "pomodoro", order: 30 }, SettingsRow);
contribute("shell.overlay",                         { id: "pomodoro-card", order: 25 }, PomodoroCardGate);

每个插槽用 try/catch 包住:某个插槽在当前构建里不存在,只影响那一个界面,插件其余部分照常工作。

4.3 给模型的工具:注册进 ctx.tools

这是 Harness 最有意思的一步。ctx.tools.register 一个 pomodoro 工具,模型就能在对话里被自然语言驱动:

用户:"开始一个 25 分钟专注" 模型:调用 pomodoro({ action: "start", durationMinutes: 25 })

js 复制代码
ctx.tools.register({
  name: "pomodoro",
  description: "Read or control the user's pomodoro timer: ... Use it when the user mentions a pomodoro or asks how much time is left.",
  parameters: {                                   // 注意:这是 spec 方言,见第五节坑 3
    action: { type: "string", required: true,
              enum: ["status","start","break","pause","resume","stop","skip","configure"] },
    durationMinutes: { type: "number", description: "..." }
  },
  output: {
    schema: { /* JSON Schema,描述返回值 */ },
    render: (_args, value) => [{ type: "text", text: `${value.summary}\n剩余 ${value.remainingText}` }]
  },
  presentCall: (args) => ({ card: "generic", title: `番茄钟 · ${args.action}`, kind: "other", rawInput: args }),
  async execute(args) { /* 直接驱动同一个 PomodoroTimer 实例 */ }
});

工具、斜杠命令、远程服务,三个入口驱动同一个计时器实例------模型改了时长,浮窗下一秒就变。这种"人类和模型共享同一套控制面"的感觉,是我用过的智能体框架里第一次体验到的。

4.4 阶段边界的通知:只响一次

专注结束要响铃 + 桌面通知。难点在于:多个窗口都开着页面,谁响? 宿主给每个边界盖一个里程碑章,客户端读到后用 localStorage 认领,同一个里程碑只有第一个窗口响。加上 {ok, value} 信封里的计数器,跨窗口幂等。


五、踩坑复盘:测试 50 个用例全绿,真机三个功能全挂

番茄钟写完,自带测试(宿主状态机 + 客户端 bundle + 集成冒烟)全绿,装机一跑:界面一个都没出现。排查下来是三个"测试替身没有模拟到的真实契约"------每个都值得单独记一笔。

坑 1:不要往 ctx 上挂自己的属性(激活直接失败)

js 复制代码
ctx.togglePomodoroCard = () => { ... };
// ✗ 抛 "cannot set property "togglePomodoroCard" without provide"

ctx 是代理:读未声明的属性会查 inject,写未声明的属性直接拒绝。这行在 $mount 之前,于是整个插件激活失败,所有界面一起消失,页面上只留一条"Failed to load plugins"。

正确姿势 :插件私有状态用普通闭包对象,apply 里创建、传给需要的组件。一句话:ctx 上的每个名字都该是正经服务,自己的小状态走闭包。

坑 2:远程服务调用是"位置参数",不是"参数对象"

修好坑 1 后界面出来了,但一按按钮就报错。原因:namespace 服务按描述符里参数的声明顺序逐位取值:

js 复制代码
service.pause({});                        // ✗ "expected 0 argument(s), got 1"
service.start({ durationMs, phase });     // ✗ 整个字典被当成第一个参数 durationMs

最迷惑的是 get() 因为无参数而完全正常------特别有欺骗性。正确写法:维护"方法 → 参数名顺序"表,调用前展开:

js 复制代码
const PARAM_ORDER = new Map(CONTRIBUTION.descriptors.map(
  (d) => [d.method, d.parameters.map((p) => p.wire)]));
const positional = (PARAM_ORDER.get(method) ?? []).map((name) => args?.[name]);
const envelope = await service[method](...positional);   // start(25*60000, "focus")

配套细节:客户端参数 codec 要同时 带 schema(调用时用它校验入参)和 create: () => schema(激活时 registry 检查它)------缺一个分别在两个阶段炸。

坑 3:工具 parameters 要用官方 spec 方言

JSON-Schema 写法在这里不被接受:

js 复制代码
// ✗ 根级 type/required 不是官方方言
parameters: { type: "object", required: ["action"], properties: { action: {...} } }

// ✓ 官方言:根上就是"属性表",required: true 写在字段里
parameters: {
  action: { type: "string", required: true, enum: [...] },
  durationMinutes: { type: "number" }
}

教训:写工具前,先抄一个官方工具的 parameters 形状 (dsh-tool-web、dsh-tool-todo 都很短)。

为什么 50 个用例没拦住?

两个原因,都很典型:

  1. 测试替身必须模拟真实契约。 我的假 remote 服务接受参数对象,就永远测不出位置参数 bug。替身再好也是替身------写完插件至少装机实测一圈,把每个按钮点一遍;
  2. 测试要隔离环境。 番茄钟状态写在 $DSH_HOME/pomodoro.json,不隔离的测试会把你真实的使用状态覆盖成"60 秒测试状态"(我就是在真机上看到 00:58 才反应过来的)。一行修复:
js 复制代码
process.env.DSH_HOME = await mkdtemp(join(tmpdir(), "test-"));   // 在插件读它之前设置

六、写完三个插件后,我对这个架构的评价

好的地方,是真方便:

  • 插件和官方组件走同一条装载路径,没有二等公民;
  • 插槽体系粒度极细,顶栏一颗按钮、侧栏一个标签页、设置页一行,都有对应的扩展位;
  • 宿主/客户端的 RPC 有完整的描述符体系(严格 codec、失败词汇表、取消传播),但 SRC 模式又给小插件留了"零框架导入"的后门;
  • 会话投影是官方级的数据源:模型产生的状态(任务清单等)天然响应式地流到客户端。

要注意的,是契约密度:

双端协议的约束不少------信封解包、位置参数、codec 双字段、spec 方言、ctx 不可写------每一个单看都合理,合起来就是一份必须内化的"隐性接口文档"。我的建议是:先抄官方插件的形状,再开始改 。所有官方插件源码就躺在 ~/.dsh/profiles/node_modules/@deepseek-ai/ 下,每个都带中文 README,是最好的教材。

从产品视角,这套体系想象空间很大: 我把番茄钟计时器注册成工具之后,模型成了计时器的"司机"------我可以对它说"我专注 45 分钟,期间别打扰我,结束了提醒我喝水",它用同一个工具就能编排。当"界面、命令、工具、数据投影"全都插件化,智能体框架就不再是一个聊天窗口,而是一个可以被人机共同编程的桌面。


附:三分钟上手清单

sh 复制代码
# 0) 一次性准备
npm i -g pnpm

# 1) 建插件目录(最小四件套:package.json / cordis.patch.yml / lib/index.js / lib/client.js)
#    package.json 记得声明 dsh.bundle.patch 与 dsh.client

# 2) 装进 profile(绝对路径!)
dsh plugin --profile web add D:\path\to\your-plugin

# 3) 验证
dsh --profile web --dump-config | findstr your-plugin-id   # 组合树里有你这一层
dsh web                                                    # 启动,进会话看效果

# 4) 卸载
dsh plugin --profile web remove your-plugin-name

调试速查:语法检查 node --check lib/index.js;组合树确认 --dump-config;页面出现 "Failed to load plugins" 就在浏览器控制台找激活错误;改了客户端代码要重启 dsh + 强刷。


代码与文中行为基于 deepseek-harness 0.1.5-rc.3。三个插件的完整源码结构:dsh-command-balance(宿主+客户端)、dsh-panel-tasks(纯客户端)、dsh-pomodoro(全栈六件套),照文章顺序读,每个都比前一个多一层。

gitee.com/guanxi1971/...

相关推荐
4 小时前
为什么你的 AI 助手有时候回复很慢
ai编程
渔阳节度使4 小时前
AI Coding 零基础实战教程
ai编程
HelloWorld0014 小时前
不买昂贵的向量数据库:基于 pgvector + BM25 打造轻量级企业生产 RAG 混合检索系统
ai编程
hdsrhh4 小时前
在 Claude Code 中让第三方模型作为 subagent 与 Claude 协作
ai编程·claude
用户539418729075 小时前
Cursor 别只用来按 Tab:我每天高频在用的几个功能,附快捷键和踩坑
ai编程·cursor
用户4475248566755 小时前
在手机上跑 Claude Code:一个 Android/Termux 的 AI 编程助手
ai编程
wechatbot8885 小时前
企业微信HTTP协议接口完整接入教程|全量消息收发 API 实战
网络协议·http·微信·企业微信·ai编程
金字塔頂の蝸牛6 小时前
每周GitCode开源项目精选
开源·ai编程·gitcode
ZzT6 小时前
Claude 干活的时候,在终端里打砖块
人工智能·ai编程·claude