项目地址:https://github.com/ranshaodexiao/dsh-wechat-ilink
npm:dsh-wechat-ilink(dsh plugin --profile desktop add dsh-wechat-ilink一条命令安装)
⚠️ 先看这里:本文对应的版本
DSH 迭代非常快,插件 API 随时可能变。本文所有结论都基于下面这组版本实测,版本不同可能失效。
| 组件 | 本文实测版本 | 说明 |
|---|---|---|
| DeepSeek Harness | 0.2.0-rc.2 | 核心,dsh/dsh-agent-loop/dsh-llm/dsh-session同版本 |
| Cordis | 4.0.4 | 插件系统 |
| schemastery | 3.18.4 | 配置校验(可选依赖) |
| Node.js | 24.x | 插件要求 ≥ 22.13.0 |
| Electron | 44.0.0 | Desktop 外壳 |
| 本插件 | dsh-wechat-ilink@0.6.5 |
怎么查你自己的版本
# 插件声明的兼容范围dsh--version# 或者直接看已安装的 DSH 包版本node-e"console.log(require('@deepseek-ai/dsh/package.json').version)"本插件的package.json里声明了:
"dsh":{"engines":{"dsh":">=0.2.0-rc.2"}}⚠️注意:这个字段不会被 DSH 强制检查—— 它只是声明,不满足也不会拦你(详见 1.2 节)。
所以升级 DSH 后请务必看启动自检的日志(见 4.4 节),那是唯一能告诉你插件是否还活着的信号。
版本敏感度分级
文中标注了每个结论的版本敏感度:
| 标记 | 含义 |
|---|---|
| 🔴高 | 直接依赖 DSH 内部实现(源码行号、事件名、字段名),升级后必须重新验证 |
| 🟡中 | 依赖公开 API 或稳定约定,一般不会变,但值得留意 |
| 🟢低 | 通用工程实践(长轮询、日志、去重),与版本无关 |
前言:为什么写这篇
我想在微信里直接用DeepSeek Harness(以下简称 DSH)。
不是"把消息转发给某个 API"那种,而是真的在微信里跟 DSH 的会话对话—— 它能读写文件、跑命令、用工具,回复发回微信。
做完之后回头看,真正难的不是微信协议,而是DSH 插件的契约。我踩了 8 个 bug,其中 5 个在离线测试全绿的情况下被真实 DSH 打出来。
这篇文章把这些坑都写出来。如果你要开发 DeepSeek Harness 插件,这些坑你大概率也会踩。
一、先搞清楚 DSH 插件是什么
DSH 用的是Cordis插件系统。一个插件就是一个 JS 模块,导出四个东西:
exportconstname='wechat-clawbot'// 插件名(日志标签)exportconstinject=['agents','sessions']// 依赖的服务exportconstConfig=Schema.object({...})// 配置校验exportfunctionapply(ctx,config){...}// 入口打包成bundle:package.json里声明dsh.bundle.patch,指向一个cordis.patch.yml。
🟡中—— 插件契约本身稳定(Cordis 4.0.4 实测),但
inject的服务名会随 DSH 增加/改名。
1.1 第一个坑:inject是硬门槛
Cordis 的ctx是Proxy。你没在inject里声明的服务,属性访问会直接抛错:
Error: cannot get property "sessionQuery" without inject这不是"返回 undefined",是抛异常。
我一开始只想读ctx.agents,代码里顺手用了ctx.sessionQuery,直接炸。
正确做法:
// 必需的服务 → 写进 injectexportconstinject=['agents','sessions','sessionQuery']// 真正可选的服务 → 用 ctx.get(),它返回 undefined 而不抛错constattachments=ctx.get('attachments')if(attachments){/* ... */}我封装了一个
readOptionalService(ctx, name)处理这个,兼容 Cordis 的 Proxy 和普通对象(测试里用)。
1.2 第二个坑:peerDependencies会静默跳过整个 bundle
🔴高—— 静默失败,最难排查的一类问题。
bundle 的dsh.engines.dsh字段不会被强制检查。但如果你声明了@deepseek-ai/dsh-*的peerDependencies,不满足时会静默跳过整个 bundle—— 没有报错,插件就是不加载。
所以我的插件故意不声明任何dsh-*peer:
{"peerDependencies":{"@deepseek-ai/cordis":"~4.0.4","@deepseek-ai/schemastery":"~3.18.4"},"peerDependenciesMeta":{"@deepseek-ai/cordis":{"optional":true},"@deepseek-ai/schemastery":{"optional":true}}}两个都标 optional—— 因为我把它们做成了可降级依赖(不装 schemastery 就用内置校验器)。
1.3 第三个坑:Config校验必须同步
🔴高—— 依赖 Cordis / Standard Schema 的内部调用方式。
resolveConfig内部调的是 Standard Schema 的~standard.validate(),必须是同步的。
我第一版写了个async validate,直接挂。
二、微信侧:走官方 iLink Bot API
不要去 hook 个人微信协议 —— 封号、不稳定、也不道德。
微信 ClawBot 背后是腾讯的iLink Bot API,有公开的协议行为可参考(@tencent-weixin/openclaw-weixin,MIT)。
核心就几个端点:
| 端点 | 作用 |
|---|---|
get_bot_qrcode | 申请登录二维码 |
get_qrcode_status | 轮询扫码状态 |
getupdates | 长轮询收消息 |
sendmessage | 发消息 |
sendtyping | “正在输入” |
getuploadurl | 媒体上传 |
请求头:
iLink-App-Id: bot iLink-App-ClientVersion: <版本号> AuthorizationType: 1 Authorization: <token> X-WECHAT-UIN: <base64(随机 uint32 的十进制字符串)>几个实战要点:
errcode -14= 登录态失效,需要重新扫码。插件要能自动暂停轮询、清空游标- 媒体是 AES-128-ECB + PKCS7 加密的,
aes_key有两种线上编码(base64 的原始 16 字节 / base64 的 32 字符 hex 串),两种都要处理 - 回复必须带
context_token—— 这是会话路由锚点,只随入站消息下发。所以无法主动推送,只能"收到消息才回复"
三、真正的硬骨头:把微信接到 DSH 会话上
这是整个项目 80% 的 bug 来源。
🔴高(全节)—— 本节全部依赖 DSH agent 的内部契约。
下面每个 API 名、事件名、字段名都在DSH0.2.0-rc.2上实测。
升级 DSH 后这一节必须重新验证,尤其 3.2 和 3.3。
3.1 创建 / 恢复 agent
DSH 的 agent API(0.2.0-rc.2):
ctx.agents.get(id)// 拿活着的 agentctx.agents.create(options)// 新建ctx.agents.resume({resumeSessionId})// 恢复已持久化的会话坑:固定会话在重启后已持久化,不能再create,会抛SessionAlreadyExistsError。
⚠️ 这个异常来自
@deepseek-ai/dsh-session-persistence。我按名字判断而不是instanceof
—— 因为插件是link:装的,instanceof在模块实例不一致时会失效。
正确逻辑:
constexisting=ctx.agents.get(sessionId)if(existing)returnexistingif(awaitsessionExists(sessionId)){returnawaitctx.agents.resume({resumeSessionId:sessionId})}returnawaitctx.agents.create(options)3.2 坑中之坑:{{model}}和{{cwd}}
这是最难的 bug,也是所有"空回复"问题的根源。
DSH 的系统提示词里引用了三个变量,persona 模板含有{{model}}、{{cwd}}:
You are a coding agent powered by the {{model}} model. ... ... {{cwd}} ...而它们的值来自agent 对象(dsh-agent-loop/lib/index.js,0.2.0-rc.2 的 1564-1566 行):
ctx.systemPrompt.variable("provider",(context)=>context.agent?.options.provider);ctx.systemPrompt.variable("model",(context)=>context.agent?.options.model);ctx.systemPrompt.variable("cwd",(context)=>context.agent?.session.header.cwd);任何一个没值,提示词组装直接抛错—— 而这个错发生在任何模型请求之前。
症状:回合立刻结束、零模型调用、回复为空。日志里只有:
prompt variable "{{model}}" has no value for this assembly修复:
constoptions={agentOptions:{provider,model},// ← 提供 {{model}} / {{provider}}meta:{cwd:resolveCwd()},// ← 提供 {{cwd}}}{{cwd}}特别阴险:session.header.cwd在会话创建时就固定,resume无法补。所以修复前创建的会话是永久损坏的 —— 只能删掉重建。
而且 DSH 会把没有 cwd 的会话放在一个叫_no-cwd的目录下。这是我发现真相的关键线索:
~/.dsh/sessions/_no-cwd/wechat-clawbot ← 目录名本身就是铁证排查技巧:turn/end事件的reason.kind === 'error'后面就是 DSH 的原始原因。而会话日志是多帧 zstd(每次 flush 一个 frame),Node 的zstdDecompressSync只读第一帧 ——我一开始就栽在这里,以为事件没落盘。
🟡中——
_no-cwd目录名和多帧 zstd 是观测手段,即使 DSH 改了实现,"去看持久化事件而不是猜"这个方法依然成立。
3.3 回合边界:不能用whenIdle()
🔴高—— 这一节的结论完全建立在对 agent 循环内部实现的观测上。
我想等 agent 跑完一个回合,然后取最终文本。第一版用了agent.whenIdle()——在空闲 agent 上立刻返回,拿到空结果。
第二版改成"等任意turn/end"—— 又错了。因为 agent 循环有个特性(dsh-agent-loop,0.2.0-rc.2):
// 回合在 driver 唤醒时打开,claim 为空时立刻关闭if(phase.step===0&&decision.messages.length===0){turnEnds={kind:'completed'};returnfalse}也就是说会有一个不携带消息、不调模型的空回合,它的turn/end会抢先满足我的等待条件。
正确做法:等持久的边界事件 ——
- 先等
agent/inbox/spliced事件里出现我们那条消息的 id(证明它被接收了) - 再等那之后的
turn/end
// 等消息被 admitconstmessageId=message.id// 然后等这个回合的 turn/end然后从事件流里提取助手文本(取最后一个非空 content)。
💡这条规则即使 DSH 改了内部实现也大概率适用:不要依赖"agent 空闲了"这种
瞬时状态,要依赖持久化事件证明"我的消息被接收了"和"这个回合结束了"。
前者是观察,后者是契约。
3.4createUserMessage拿不到怎么办
🟡中—— 这是"打包环境"问题,不是 API 问题。
DSH 的@deepseek-ai/dsh-llm打包在 Electron 的app.asar里。而插件是以link:装进 profile 的 ——裸 import 会解析到插件自己的node_modules,找不到。
我的做法:优先用真实工厂(能解析到就用),否则用行为等价的本地实现。
关键是本地实现要逐字段对齐真实工厂(0.2.0-rc.2 的实现就是下面这三步):
functioncreateUserMessageLocal(input){returndeepFreeze(structuredClone({...input,id:randomUUID(),}))}⚠️ 如果你要抄这个 fallback,务必对着当前版本的
dsh-llm源码核对字段
—— 字段一旦增加,本地实现就会静默地少传东西。
四、长期运行的稳定性
4.1 长轮询不能饿死事件循环
🟢低—— 通用 Node.js 问题,与 DSH 版本无关。
getupdates是长轮询(几十秒)。如果在一个紧循环里跑,会饿死事件循环。
每轮迭代让出一次宏任务:
awaitnewPromise((resolve)=>setTimeout(resolve,0))我的实现里还挂了
AbortSignal,让停止时能立刻退出等待,而不是干等一个定时器。
4.2 去重和退避
🟢低
- 按
message_id去重(网络重试会发重复消息) - 失败退避(我用的 2s → 30s,连续失败 3 次后升到 30s)
- 游标持久化,重启后接着收
4.3 日志必须落文件
🟢低(做法)+ 🟡 中(路径)
插件跑在 DSH 进程内部,它的 stderr 你在外面看不到。
所以我把所有运行记录写到一个持久化文件:
%DSH_HOME%\clawbot\channel.log带 token 脱敏、2 MiB 上限、超限裁剪到 512 KiB。没有这个日志,线上问题只能靠猜—— 我前 3 个 bug 全靠它才定位到。
4.4 启动自检 —— 版本升级的第一道防线
🟡中(做法)+ 🔴 高(价值)
DSH 更新频繁,插件很容易被上游改动打挂。
我加了个启动自检:启动后跑一个合成回合,走真实的provider/model/cwd 提示词组装和真实模型调用,把结果写进日志。
SELFTEST PASS ms=4202 events=15 replyChars=146 SELFTEST FAIL (turn error) ... reason={"kind":"error",...}用独立的<sessionId>-selftest会话,不影响真实对话。成本是每次启动一次模型调用,但它是 DSH 升级后最早能发现问题的信号。
💡如果你在做 DSH 插件,强烈建议也加一个。
我所有 8 个 bug 里,有 5 个是在提示词组装环节炸的 —— 而自检恰好完整覆盖了那个环节。
DSH 升级后你不需要记得去验证任何东西,看日志里那一行就够了。
五、两个"看起来能用其实没用"的教训
5.1 自检报假 PASS
第一版自检的逻辑是"没抛异常就算成功"。结果它报:
SELFTEST PASS ... replyChars=0回复长度是 0 却报 PASS。这是我"验证"最典型的失败模式:判据本身是错的。
现在:空回复一律 FAIL,并打印turn/end的原始 reason。
5.2 离线测试全绿 ≠ 能用
这是整个项目最重要的一课。
我有 138 个离线测试(假 iLink 服务器、假 DSH 上下文),全绿。但它们证明不了插件在真实 DSH 上能跑。
8 个 bug 里有 5 个是在测试全绿的情况下被真实 DSH 打出来的:
| bug | 离线测试为什么没发现 |
|---|---|
inject缺失 | 假 ctx 是普通对象,不抛错 |
{{model}}无值 | 假 agent 不做提示词组装 |
{{cwd}}无值 | 同上 |
| 回合边界 | 假 agent 没有真实循环的空回合特性 |
blocked未识别 | 假 agent 不实现归档门 |
根本原因:测试替身比真实实现宽松。你写的假对象,只会实现你以为需要的东西。
出路是用真实的 DSH 代码做验证—— 我从app.asar里解出 DSH 的全部包,写了一批验证脚本,直接对着真实实现跑:
// 用真实 Cordisconst{Context}=awaitimport('.../cordis/lib/index.js')// 用真实 Sessionconstsession=Session.create(id,undefined,{header:{cwd:...}})⚠️ 注意
Session.create的签名是(id, seed, header, ...)——header是第三个参数,不是第二个。我第一版写成(id, { header }),直接报seed.entries is not a function。这类签名细节必须看源码,不能猜。
六、配得上"生产可用"的几件事
6.1/list/use/back:从微信远程接管另一个会话
🟡中—— 用的都是公开 API(agents.get/followup),设计思路与版本无关。
默认微信消息进插件自己的会话。但你可能正在 DSH 里干一个活,想在外面用手机看进度、下指令。
ctx.agents.get(sessionId)能拿到活着的 agent,对它followup()就能把微信消息注入那个会话:
/list 列出正在运行的会话 /use 1 接管第 1 个 现在到哪了? ← 这条进那个会话 /back 退回微信自己的会话设计要点:
- 只拦截精确的命令词。
/model、/new、/hello /use 1这些原样转发给 agent,不误吞 - 不打断正在跑的活——
followup()进的是"下一个回合"队列 /back不销毁别人的会话—— 只销毁插件自己创建的 agent- 默认只列正在运行的—— 一屏已停止的旧会话只是噪音
6.2 归档的会话会被拒绝执行
🔴高—— 完整依赖archived-session-gate这个内部插件的行为。
DSH 有个archived-session-gate(dsh-api-session-controller,0.2.0-rc.2):
ctx.on('agent/pre-step',(payload,next)=>underArchivedSession(ctx,payload.agent)?Promise.resolve({kind:'reject'}):next())归档的会话里任何步骤都被拒绝,回合以blocked结束,不调模型、无输出。
我踩这个坑是因为:/list第一版把所有会话都列出来(一堆噪音),我顺手把自己那个会话归档了 ——然后整个通道就死了,而且症状是"回复为空",看不出原因。
现在插件会提前检测归档状态并给出明确提示,同时/list过滤掉归档/子代理/插件自己的会话。
💡通用教训:
turn/end的reason.kind不止error和completed。
DSH 里至少有completed/aborted/blocked/error/max-tokens/interrupted/forked。
只判断"有没有报错"会漏掉blocked这类静默失败。我漏了一个版本才补上。
七、发布:GitHub 只放源码,npm 放编译产物
🟢低(做法)+ 🔴 高(一处依赖)
这是我认为最值得抄的一个实践:
| 渠道 | 内容 |
|---|---|
| GitHub | 只有源码(.gitignore排除lib/) |
| npm | 编译后的正式版(tarball 含lib/) |
{"scripts":{"prepare":"tsc -p tsconfig.json","prepublishOnly":"npm run verify"}}prepare:npm install和npm publish前自动编译prepublishOnly:发布前跑完整测试 ——构建或测试挂了就发不出去
好处:两个渠道不可能"不一致",因为 npm 的产物就是从这份源码编译出来的。
而且用户安装体验完全不同:
dsh plugin--profile desktop add dsh-wechat-ilink一条命令搞定。因为 tarball 里已经带着lib/,不需要任何构建步骤。
如果从 GitHub 直接装,pnpm 默认禁止依赖运行构建脚本,会卡在
prepare上,需要手工加allowBuilds配置。这就是为什么用户该走 npm。
额外发现:dsh plugin add会自动注册 bundle
🔴高—— 依赖dsh-plugin-manager的内部行为,但这条最值得验证。
我原以为用户还要手工编辑dsh.profile.bundles。读了官方 CLI 源码后发现不用(dsh-plugin-manager/lib/types/operations.js,0.2.0-rc.2 的第 503 行):
elseif(options.activateNewBundles!==false){awaitreconcile(before,dir,context.installAnchor,options);}dsh plugin add会自动把包名写进dsh.profile.bundles。我实测确认了:
dsh plugin --profile test add dsh-wechat-ilink → bundles: ["@deepseek-ai/dsh-base", "dsh-wechat-ilink"] ← 自动写入教训:别猜工具怎么工作,去读它的源码。我差点把一个不存在的手工步骤写进文档。
⚠️ 这个自动注册是当前版本的行为。如果将来变了,症状是"包装上了但插件不生效"
—— 那时候检查dsh.profile.bundles里有没有你的包名。
八、清单:开发 DSH 插件要注意的
Cordis 侧
inject里列全必需服务;可选服务用ctx.get()- 不要声明
@deepseek-ai/dsh-*的 peer(会静默跳过 bundle) Config校验必须同步- 用
ctx.effect()绑定后台任务的生命周期
DSH agent 侧(🔴 高风险区,升级必查)
create/resume要处理SessionAlreadyExistsError(按错误名判断,别用instanceof)- 必须提供
agentOptions(provider + model)和meta.cwd meta.cwd一旦漏传,会话永久损坏(header.cwd创建后不可改)- 回合边界等
agent/inbox/spliced+turn/end,不要用whenIdle() - 识别
turn/end的全部reason.kind:至少completed/error/blocked - 注意
archived-session-gate:归档会话会被拒绝执行 Session.create的签名是(id, seed, header, ...),别把 header 传成第二个参数
运维侧
- 日志落持久化文件(进程内 stderr 看不到)
- 加 token 脱敏
- 加启动自检(跑真实回合)←DSH 升级后最重要的一道防线
- 长轮询每轮让出事件循环
验证侧
- 离线测试全绿不代表能用—— 用真实 DSH 代码做验证
- 检查项别硬编码常量(包名、路径),从源头读取
- 验证脚本自己也会错 —— 它报 PASS 不等于真的验证过
升级 DSH 后
- 看启动自检日志:
SELFTEST PASS还是FAIL - 若 FAIL,
reason里有 DSH 的原始报错 - 检查
dsh.profile.bundles里插件名还在不在 - 重跑一遍本文 3.2 / 3.3 / 6.2 三节对应的行为
九、最后的复盘
这个项目从"能装上"到"真的能用",一共8 个 bug,其中最后 3 个本质是同一个错误:
我创建 agent 时只传了最少字段,然后一个个撞上 DSH 期望的输入。
如果一开始就把agentLoop.create的契约读清楚,后面三个都不会发生。
还有两次误判病根(“空回合抢跑”、完成判据选错),都是我拿着不确定的理解直接动手改,结果写了个"修复"却没修到点子上。两次都是靠真实数据推翻的—— 先是channel.log,然后是那个多帧 zstd 的会话文件(我第一次解压只读了第一帧,误以为事件没落盘)。
真正让项目往前走的一直是同一个动作:看数据,别猜。
几条我认为最值钱的经验:
- 测试替身比真实实现宽松—— 你写的假对象只实现你以为需要的东西。5 个 bug 因此漏网。
- 判据本身也可能是错的—— 自检报
replyChars=0却 PASS,就是判据错了。 - 工具的源码是最好的文档——
dsh plugin add会自动注册 bundle,这件事写在operations.js第 503 行,一个grep就能看到。 - 打算绕过去的那一步,后面往往藏着真问题—— 我因为没装 git,写了个"模拟 git 行为"的检查脚本;装上真实 git 后立刻发现两个问题(CRLF 规范化、CLI 缺 shebang,后者会让 Linux 用户直接跑不起来)。
最后关于版本:DSH 还在0.2.0-rc阶段,API 会变。本文标 🔴 的地方请务必自己重新验证一遍 —— 但 🟢 那些工程实践(长轮询让出、日志落盘、持久化事件边界、启动自检)无论版本怎么变都成立,那才是这篇文章能长期有效的部分。
附:项目信息
- GitHub:https://github.com/ranshaodexiao/dsh-wechat-ilink
- npm:https://www.npmjs.com/package/dsh-wechat-ilink
- 安装:
dsh plugin --profile desktop add dsh-wechat-ilink - 适配 DSH 版本:
0.2.0-rc.2(Cordis4.0.4、Node ≥ 22.13.0) - 规模:源码 16 个文件 / 3904 行,测试 7 个文件 / 2318 行 / 138 个用例
- 协议:腾讯官方 iLink Bot API(MIT 参考实现
@tencent-weixin/openclaw-weixin) - 不做:企业微信、公众号、小程序、个人号协议 hook、公网穿透
功能:微信 ↔ DSH 会话双向对话(文字 + 图片)、微信远程接管 DSH 里正在干活的会话、启动自检、持久化日志。