副标题:一文摸清 DeepSeek Harness 的插件体系------含三个"测试全绿、真机全挂"的真实踩坑复盘
前言
DeepSeek 开源了它的编程智能体框架 deepseek-harness,README 里只有一句口号:
DeepSeek Harness: Everything is a Plugin.
口号谁都会喊。到底是真插件化,还是"配置文件换个叫法"?我花了一个周末,给它连写了三个插件,一个比一个深:
- 余额胶囊 ------ 顶栏常驻一颗胶囊显示 DeepSeek 平台账户余额,悬停出详情,外加一个
/balance斜杠命令; - 任务面板 ------ 右侧边栏加一个"任务"标签页,实时镜像模型的任务清单,完成一项自动打勾;
- 番茄时钟 ------ 顶栏胶囊 + 浮窗 + 设置页 +
/pomodoro命令 + 给模型用的工具,宿主端权威计时、跨重启持久化。
三个插件写下来,结论是:这句口号是真的。会话、文件编辑、斜杠命令、连顶栏每一颗按钮,在 Harness 里都是插件;而第三方插件和官方插件走的是同一条装载路径,没有任何"内部通道"。
这篇文章按三个插件的顺序复盘整个开发过程------从 profile 结构、宿主/客户端双端协议,到最后"测试 50 个用例全绿、真机却三个功能全挂"的踩坑故事。所有坑都是真实踩过的,代码都在本机的插件目录里,可以照着一步步复刻。
环境说明:dsh 0.1.5-rc.3,Windows 11,Node 22。插件目录约定为
dsh-<名字>,安装目标是默认的webprofile。
一、五分钟看懂 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 个用例没拦住?
两个原因,都很典型:
- 测试替身必须模拟真实契约。 我的假 remote 服务接受参数对象,就永远测不出位置参数 bug。替身再好也是替身------写完插件至少装机实测一圈,把每个按钮点一遍;
- 测试要隔离环境。 番茄钟状态写在
$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(全栈六件套),照文章顺序读,每个都比前一个多一层。