Files
HomeAgent/docs/zh/dshplgadapter-plan.md
JianFeeeee e8d07cd8a0 docs(plan): dshplgadapter 规划书 —— 按外部插件形态重写
## 方向

把 dsh(DeepSeek Harness)的 Cordis 插件生态接入 HologramAgent,
参考原型是刚迁出的 clawhubadapter(两者同构:兼容层 + sidecar + 转译注册)。

## 本文件在被推翻过一次,纠正记在正文里

初版结论是「必须做成内置插件」,依据是「外部插件发不了事件,所以
skill 移交只能靠概率」。该依据实测被推翻:

  · 外部插件自带 skill 由 pluginmgr 在安装/启动时移交,与插件发不发事件无关
  · clawhubadapter 已实际迁成外部插件并端到端跑通

所以 v2 改成外部插件,并把原因写进 §0 结论 2(保留纠错痕迹,
下次有人拿旧结论当依据时会看到为什么它不成立)。

## 内容的依据

全部来自实测,不凭记忆:

  dsh 0.2.1-rc.2 / cordis 4.0.5 / dsh-tools 的 defineTool 签名
  /opt/agentmail/plugins/dsh-mail-bridge/src/index.ts(2586 行生产代码,
  是 dsh 插件侧 API 最可靠的文档)
  third_party/hologramagentsdk 的 capability-boundary.md 与 proc_main.go.tmpl
  (外部插件实际拿到什么能力,全在那一个文件里)

## 最该先做的一件事

不是写代码,是探针 0.1:验证 fd 3 做协议通道。

理由:dsh 生态插件大量用 console.log,而子进程 stdout 是 JSON-RPC 通道。
本仓为这个坑付过代价(生产累计 1038 条「非法 JSON 帧」报错,定位真因
花了一整轮)。clawhubadapter 靠「模拟器把 logger 接到 stderr」躲过去,
但那只管模拟器内的调用,插件直接 process.stdout.write 仍会漏。
这条不解决就不该进阶段 1。
2026-11-04 15:24:00 +08:00

29 KiB
Raw Blame History

dshplgadapter 插件项目规划书

状态:规划(未开始实现)

定位:把 DeepSeek Harness(下称 dsh)的 Cordis 插件生态接入 HologramAgent。 参考原型就是本仓的 internal/plugins/clawhubadapter/ —— 它把 OpenClaw 生态 接入 HA,dshplgadapter 把 dsh 生态接入 HA,两者是同一个模式的两次实例化。

建立于:2026-11-04。

本文所有事实均来自实测命令,不凭记忆。关键实测附命令原文,便于复查。


〇、两个前置结论(先钉死,否则后面全是空谈)

结论 1:clawhubadapter 的设计可以整套照搬

它解决的问题与 dshplgadapter 的完全同构:

共同点 clawhubadapter(OpenClaw) dshplgadapter(dsh)
外部原型 一插件一目录,main.js 导出 {register(api)} 一插件一包,Node ESM 导出 apply(ctx)
插件格式 JSON 清单 + JS 入口 package.json + cordis.patch.yml
运行需要 Node 宿主 Cordis 宿主(Node)
拿到什么 工具、通道、Provider、阶段钩子 工具(ctx.tools)、事件(ctx.on)、服务
内核怎么办 起 sidecar 子进程 + JSON-RPC over stdio 完全相同

所以不需要重新设计架构。要做的只是换掉三样东西: ① 子进程里的"假宿主"(simulator/main.js → 一个 Cordis Context); ② 注册通知的类型表(OpenClaw 的 type → Cordis 的注册面); ③ 包发现与安装逻辑(main.js/openclaw.plugin.json → package.json+cordis.patch)。

结论 2:它必须是外部插件(plugin.bin),不是内置插件

本节 2026-11-04 重写过一次。 原结论是“必须内置”,依据是“外部插件不能发 事件、所以移交 skill 只能靠概率”。该依据被实测推翻:外部插件自带的 skill 由 pluginmgr 在安装/启动时自己移交,与插件发不发事件无关。

而 clawhubadapter 已经实打实迁成了外部插件并端到端跑通(见 §0.3), 所以 dshplgadapter 同形态。

2.1 外部插件确实发不了事件(这条仍然成立,且已实测)

三层证据:

  1. capability_test.go:279 把 events.publish 列进 forbiddenPrefixes —— 内核刻意不向外部插件提供事件发布,且有测试守着不许加回去。
  2. proc 协议表只有 MethodEventsSubscribe / MethodEventsUnsubscribe, 没有 events.publish(internal/plugin/proc/protocol.go:235-236)。
  3. 外部插件模板 proc_main.go.tmpl 不注入 event subscriber ⇒ Events() 恒为 nil(builtin-only.md:25 权威裁定)。
  4. 更早一道:公开的 EventSubscriber 接口没有 Publish 方法, 所以这是编译期就拦住(实测变异:把 s.Events().Publish 加回去, hmapdev build 直接报 s.Events().Publish undefined)。

2.2 但“发不了事件”不阻碍外部化 —— 因为不用发

实测得到的真实链路(隔离实例,2026-11-04):

15:06:27 [pluginmgr] clawhubadapter: handed off skill clawhub-tools to skillmgr
15:06:27 [pluginmgr] startup rescan: re-handed off 1 bundled skill(s)
15:06:27 [skillmgr] registered via skill_detected event: clawhub-tools
15:06:27 [plugin] loaded SKILL: clawhub-tools v1.0.0

注意这条链上没有一行来自插件自己:

  • 插件只把 SKILL 放在工程根 skill/<名字>/SKILL.md,hmapdev build 打进包;
  • 内核/pluginmgr 在安装与每次启动时扫 <插件目录>/skill/, 自己发 skill_detected(pluginmgr/skill_handout.go);
  • 内置插件走另一条路 plugin/builtin_skill.go 的 SkillProvider(编译期 embed)。

⇒ “插件带 skill”与“插件发事件”是两件不相干的事。前者是分发物约定, 后者是运行期能力。外部插件做前者完全没问题。

2.3 迁移实测:三处差异,全部可绕

把 internal/plugins/clawhubadapter/ 搬到 hmapdev 骨架上,只改 import, hmapdev build 报四个错:

./plugin.go:1103: too many arguments in call to s.InjectInputSync
        have (string, string, string, map[string]interface{})
        want (string, string, string)
./plugin.go:1106: invalid operation: out != nil (mismatched types string and untyped nil)
./plugin.go:1107: out.Payload undefined (type string has no field or method Payload)
./plugin.go:1323: s.Events().Publish undefined (type sdk.EventSubscriber has no field or method Publish)
差异 修法 代价
InjectInputSync 签名不同 用外部签名,返回 string,空串即“没回复” 改一处
Events().Publish 不存在 删掉,不需要 见 2.2
internal/safeclose 拿不到 自写 safeClose[T any] 新文件 40 行
init() 自注册不可用 改为 NewPluginFactory 改一处

2.4 迁移结果(已验证)

go vet / gofmt                          干净
go test -race                           ok(20 条原有 + 4 条外部形态判据)
hmapdev build                           packaged clawhubadapter_bundle.hmap
隔离实例端到端                           loaded: clawhubadapter + 10 个工具全注册
                                      loaded SKILL: clawhub-tools v1.0.0

决策:dshplgadapter 做成外部插件,与迁移后的 clawhubadapter 同形态。 工程放独立目录 + plg.json,用 hmapdev build 产出 .hmap。

结论 3:外部插件带 skill 走 skill/ 目录,不走事件

把这条单列,因为它是上面推翻结论 2 的关键事实,而且很容易搞混:

谁带 skill 放在哪 谁负责登记
外部插件 工程根 skill/<名字>/SKILL.md(打进 .hmap) pluginmgr 安装时 + 每次启动重扫
内置插件 Go 源码 embed + 实现 SkillProvider 内核 InstallBuiltinSkill(随插件启停出入)
用户自己写 <data_dir>/skills/ skillmgr 启动全扫

三者的共同终点都是 skillmgr 的 loadOne(幂等),但入口完全不同。 写外部插件时只需要第一种。

一条实操纪律(hmapdev build 会检查):auto_start=false 的插件必须带 skill/ 目录,否则用户装完不知道该先 plugin_enable —— 等价于没装。 clawhubadapter 是 auto_start 默认 true,所以不强制;但带上 skill 仍是对的。


一、参考原型:clawhubadapter 的实际结构

先看清楚要抄什么。它的四个文件是分层的,每层职责单一:

文件 行数 职责 dshplgadapter 是否照搬
plugin.go 1526 生命周期 + 工具注册 + 包管理 照搬骨架,换包发现逻辑
sidecar.go 420 JSON-RPC over stdio + pending-id 配对 原样照搬
registry.go 326 注册通知 → SDK 调用的转译表 换转译表内容
simulator/main.js 422 子进程里的假宿主 换成 Cordis Context

1.1 sidecar.go 是最有价值的部分(原样照搬)

它解决了"跨进程调用"的全部脏活,与具体插件格式无关:

sidecarRequest / sidecarResponse       JSON-RPC 帧
pendingResp                            每请求一个 channel + 自己的 close 守卫
readLoop                               读 stdout,按 id 派发响应,其余当通知
call(method, params)                   发请求并等响应
NotifyChan()                           把主动通知推给上层
waitReady()                            ping 探活,30s 超时
Close()                                优雅关停

它踩过的坑(照搬时不要重犯):

  • pendingResp 的 once 必须每个请求一个,不能共用全局 Once —— close(ch) 二次即 panic,而 Close() 有多条路径会走。
  • waitReady 超时从 5s 提到 30s(2915006):慢机器上 Node 冷启动会超时。 注释里写了取法依据:比 systemd TimeoutStopSec(25s) 宽,与用户可感知的 "卡住"阈值(约 30s)持平。
  • 读循环里 stdout 只能有 JSON,任何 console.log 都会坏帧。 这一条 dsh 侧尤其危险 —— 见 §2.3。

1.2 registry.go 的转译表(照抄结构,换内容)

它把"子进程发来的注册通知"分派成 SDK 调用:

func (d *RegistryDispatcher) Dispatch(typeStr string, data json.RawMessage, ...) {
    switch typeStr {
    case "tool":     d.toolReg.Dispatch(...)      // → s.RegisterTool
    case "channel":  d.channelReg.Dispatch(...)   // → s.RegisterInputChannel
    case "provider", ...: d.providerReg.Dispatch(...)
    case "hook", "lifecycle", ...: d.stageReg.Dispatch(...)  // → s.RegisterStage
    default: d.capRecorder.Record(...)            // 未知类型:只记账
    }
}

注意 default 分支:未知注册类型不报错,只记进 CapRecorder。 这是刻意的 —— 外部生态的注册面比我们的多,硬拒绝会让插件直接不可用。 dsh 侧注册类型更多(见 §3.2),必须保留这个宽容策略,否则第一天就崩。

1.3 simulator/main.js 是"假宿主"(换成 Cordis)

它做的事情本质上是骗过插件,让插件以为自己跑在真宿主里:

const entry = pluginEntry.default || pluginEntry;
if (typeof entry !== 'object' || typeof entry.register !== 'function') {
    process.stderr.write(`[simulator] plugin entry must export {default: {register(api)}}\n`);
    process.exit(1);
}
const api = {
    id, name, version, description, source, rootDir, config, pluginConfig,
    logger: { debug, info, warn, error },
    resolvePath, registerTool, registerProvider, registerChannel, ...
};
entry.register(api);

dsh 这一层不需要手写假宿主,因为 Cordis 的 Context 可以无参构造:

$ grep "export declare class Context" @deepseek-ai/cordis/lib/types/context.d.ts
40:export declare class Context {
60:    constructor();

这是个关键利好:dsh 的宿主是真的能跑起来的,不用伪造。 但代价是初始化更重(Cordis 有自己的 loader / fiber / service 体系), 需要实测哪些服务必须注册、哪些可以缺失。


二、dsh 侧的事实(全部实测,2026-11-04 本机)

2.1 版本与包布局

$ python3 -c "import json;print(json.load(open('/usr/lib/node_modules/@deepseek-ai/dsh/package.json'))['version'])"
0.2.1-rc.2
  • @deepseek-ai/cordis 4.0.5 —— 插件框架
  • @deepseek-ai/dsh-* 约 200 个包(内核按 plugin 拆得极细)
  • @deepseek-ai/dsh-tools 提供 defineTool()

2.2 插件形态:包 + cordis.patch.yml

一个真实外部插件(dsh-mobile-nav):

// package.json
{
  "name": "@dsh-external/dsh-mobile-nav",
  "type": "module",
  "main": "lib/index.js",
  "exports": { ".": {...}, "./client": {...}, "./cordis.patch.yml": "./cordis.patch.yml" },
  "dsh": { "client": ... }          // ← 客户端半边的声明
}
# cordis.patch.yml
- insert:
    - id: dsh-mobile-nav
      name: '@dsh-external/dsh-mobile-nav'
// lib/index.js —— 主入口
export function apply(ctx) {
    ctx.effect(() => installResponseCompression(), 'dsh-mobile-nav: response compression');
}

装载链路(实测 profile 配置):

/root/.dsh/profiles/web/package.json
  ├─ dependencies:      @dsh-external/xxx, dsh-mail-bridge, ...
  └─ dsh.profile.bundles: [ "@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app", ... ]

/root/.dsh/profiles/web/cordis.yml      → 空数组(root,由 patch 组合)
/root/.dsh/profiles/web/cordis.patch.yml → 覆盖层
/root/.dsh/profiles/web/node_modules/@dsh-external/*  → 软链到真实目录

⇒ dshplgadapter 的"扫描目标"就是 <profile>/node_modules/ (含 @dsh-external/、@scope/、无 scope 的),加上 dsh.profile.bundles 清单。

2.3 ★ dsh 插件与 clawhubadapter 最大的不同:日志会污染协议通道

这是必须提前设计的风险,不是实现细节。

clawhubadapter 的 OpenClaw 模拟器把 logger 接到 stderr:

logger: { debug: () => {}, info: () => {}, warn: () => {},
          error: (...a) => process.stderr.write(`[plugin] ${a.join(' ')}\n`) }

而 dsh 生态的插件大量使用 console.log,且 dsh 自己的 logger 语义就是 "写到进程输出"。一旦子进程 stdout 被日志污染:

内核 readLoop 收到非 JSON 文本 ⇒ 报「收到非法 JSON 帧」并丢弃
⇒ 该行日志丢了,还多一条看起来像协议故障的报错

本仓已经为这个坑付过代价(hologram-plugin-dev skill 记载:生产累计 1038 条 这类报错,定位真因花了一整轮排查)。

设计方案:给协议单独一条通道,不让它和 stdout 抢。

三条路,优先级从高到低:

方案 做法 代价
A(推荐) 子进程用 fd 3 做协议通道,stdout/stderr 全部当日志 要确认 Node 侧能拿 fd 3(fs.writeSync(3) 可以做)
B 起子进程时把 stdout 重定向到文件,协议走另一条 需要 sidecar 支持独立管道
C 不改协议,但在 simulator 里劫持 console.log 指到 stderr 只能拦住模拟器内的调用,插件用 process.stdout.write 仍然漏

A 的可行性要先验证(这是阶段 0 的第一件事): node -e "require('fs').writeSync(3,'x')" 在 Go 侧 ExtraFiles 注入 fd 3 后是否可用。

2.4 dsh 插件的注册面(要转译的东西)

从 dsh-mail-bridge(最重要的参照物,2586 行,已生产运行)实测:

export const inject = ['agents', 'tools'];   // ← Cordis 要求声明依赖服务
export const name = 'dsh-mail-bridge';
export function apply(ctx, config) { ... }

它用到的 ctx 面:

API 用途 dshplgadapter 转译成
ctx.tools.register(defineTool({...})) 注册工具 s.RegisterTool
ctx.on('session/event', fn) 会话事件 阶段钩子 / 事件订阅
ctx.on('agent/status', fn) agent 状态变化 阶段钩子
ctx.on('approval/request', fn, next) 权限请求(有 next 链) 需确认 HA 侧对应物
ctx.on('tools/execute', fn, next) 工具执行前后(中间件链) before_toolcall / after_toolcall
ctx.on('tools/post-execute', fn) 工具执行后 after_toolcall
ctx.effect(disposer, label) 注册清理函数 插件 Stop 时调用
ctx.agents.create/get/list/resume 建/查 agent 需确认是否暴露
ctx.logger.info/warn/error 日志 转 stderr / 内核日志
ctx.sessionQuery 会话查询 可选
ctx.userQuestions 向用户提问 可选

注意 ctx.on(..., next) 的中间件链:dsh 的 approval/request 和 tools/execute 都是 (payload, next) => Promise 形态,插件可以拦截并决定 是否放行。这与 HA 的 before_toolcall(能拒绝/改参)语义接近,但不是一一对应 —— dsh 的链是异步 promise 链,可以有多个插件串联;HA 的阶段钩子是同步返回。 ⇒ 转译时不能在链中间阻塞等 HA 的异步判定,否则会死锁。需要设计成: "先收集所有插件的判定,再一次性提交给 HA"(类似 §1.2 的记账策略)。

2.5 已存在的同类先例(务必读)

/opt/agentmail/plugins/dsh-mail-bridge/ —— 2586 行 TypeScript, 生产运行中,做的是 dsh 插件 ↔ AgentMail 平台桥接。

它不解决"dsh 插件进 HA",但它解决了**"如何在 dsh 插件里正确使用 ctx 面"**:

  • inject = ['agents','tools'] 的声明方式
  • defineTool 的正确用法(含一个历史坑:参数 spec 必须转成合法 JSON Schema)
  • ctx.on 的五个事件、ctx.effect 的清理约定
  • ctx.logger 不进 journalctl,日志要用 console.error(它踩过, 注释里写得很清楚)

⇒ 它的 src/index.ts 是 dsh 插件侧 API 的最可靠文档,比 dsh 官方文档更实在。


三、设计

3.1 总体形态(与 clawhubadapter 逐层对照)

                    HologramAgent 内核(homed)
                             │
                  dshplgadapter(外部插件,plugin.bin 子进程)
                             │
        ┌────────────────────┴────────────────────┐
        │                                          │
   发现与安装                                   运行期
   (扫描 profile)                        (每 dsh 插件一个 sidecar)
        │                                          │
   读 package.json                         fork node <simulator>
   读 cordis.patch.yml                       ↕ JSON-RPC(fd 3)
   判断是否有 apply()                      Cordis Context + 插件
        │                                          │
        └──── 纯 SKILL 条目 → skillmgr ←──────────┘
                (同 clawhubadapter 的职责链)

3.2 注册转译表(dsh → HA)

dsh 侧的注册 HA 侧的落点 备注
ctx.tools.register(defineTool(...)) s.RegisterTool 参数 schema 要转 JSON Schema
ctx.on('tools/execute', fn, next) StageBeforeToolcall + StageAfterToolcall 见 §2.4 的异步链注意
ctx.on('approval/request', fn, next) 待定(HA 无直接对应) 阶段 3 再定
ctx.on('session/event', fn) Subscribe(只读)
ctx.on('agent/status', fn) 阶段钩子
ctx.effect(fn, label) 记下来,Stop() 时逆序调用
ctx.logger.* 转 stderr(不要走 stdout)
其他未知 记进 CapRecorder,不报错 同 clawhubadapter 的 default 分支

3.3 包发现规则

扫描 <profile>/node_modules/,一个目录算"dsh 插件"当且仅当:

  1. package.json 能解析,且
  2. (package.json 有 dsh 字段)或(存在 cordis.patch.yml)或 (入口 lib/index.js / main 里能 static-scan 到 export function apply)

第 3 条不做静态扫描,改为"试着加载,加载失败就当普通包跳过" —— 理由:ESM 的静态分析在正则层面不可靠,容易把非插件误判成插件(或反之)。 加载失败必须只打日志、不中断扫描(同 clawhubadapter Start 里 continue 的处理)。

纯 SKILL 条目(有 SKILL.md 但无 apply)→ 走 publishSkillDetected 交给 skillmgr,与 clawhubadapter 完全一致。

3.4 安全(把 clawhubadapter 刚修的两个洞从一开始就堵上)

本仓 2026-11-04 刚修了 clawhubadapter 的两个真实漏洞,dshplgadapter 必须 从一开始就带,不要重犯:

漏洞 教训 dshplgadapter 的做法
ZIP Slip(解压任意路径写) filepath.Join(dest, entry) 不校验 复用 safeArchiveTarget 思路(可考虑提到公共包)
路径穿越(name=".." 删数据目录) 名字来自模型,拼路径前必须校验 包名一律走 resolveUnderRoot
无校验和的远程安装 下载即执行 阶段 1 只支持本地已安装包;远程安装推迟到阶段 4 并带 sha256

一条额外的、dsh 特有的风险:dsh 插件是 Node 代码,apply() 里可以 import('node:child_process') 直接执行任意命令。这与 HA 的沙箱模型直接冲突 (沙箱目前唯一真实生效的是 files 插件自己的 pathWithinSandbox,见 plan.md 的记录)。⇒ 阶段 1 必须先做"要不要允许 dsh 插件跑任意代码"的决策, 不能默认允许后再补救。


四、分阶段实施

每个阶段独立可交付、独立可验证。不合并交付。

阶段 0:可行性探针(不改产品代码)

目的:把三个"不确定"变成"确定"。任何一条失败都要回来改设计。

# 探针 判据
0.1 fd 3 做协议通道 Go ExtraFiles 注入 + Node fs.writeSync(3) 能双向通信
0.2 裸 new Context() 能承载插件 加载 dsh-mobile-nav 的 apply 不报错
0.3 defineTool 的 schema 能转成 HA 的 JSON Schema 拿 dsh-mail-bridge 的一个真实工具做往返
0.4 inject: ['agents','tools'] 缺服务时的行为 确认是"报错退出"还是"降级",决定要不要伪造服务

产物:docs/zh/dshplgadapter-probe.md(实测记录 + 结论)。

阶段 1:最小闭环(只支持工具)

  • internal/plugins/dshplgadapter/ 骨架(抄 clawhubadapter 的 4 文件结构)
  • 扫描 node_modules/,找到插件,起 sidecar
  • 只转译 ctx.tools.register → s.RegisterTool
  • 一个测试插件(自己写的最小 dsh 插件)验证"装了 → 模型能调"

判据(行为级,非实现级):

  • TestDiscoversDSHPlugin —— 造一个带 apply 的包,必须被扫描到
  • TestRegistersToolFromPlugin —— 插件注册的工具出现在 HA 工具表
  • TestUnknownRegisterTypeDoesNotCrash —— 未知注册类型只记账不崩
  • TestPluginLoadFailureDoesNotAbortScan —— 一个坏包不影响其他包

阶段 2:事件与钩子

  • ctx.on('tools/execute'|'tools/post-execute') → HA 的两个工具阶段
  • ctx.on('session/event') → 只读订阅
  • 异步链的语义问题解决(§2.4 提到的死锁风险)

判据:

  • TestToolExecuteHookCanDeny —— 插件拒绝后 HA 不执行该工具
  • TestHookChainDoesNotDeadlock —— 多插件串联不卡死(带超时)

阶段 3:纯 SKILL 交接 + 生命周期

  • 非插件包 → publishSkillDetected(内置身份,可用 Events)
  • ctx.effect 的清理函数在 Stop 时逆序调用
  • 热重载

判据:

  • TestSkillHandoffToSkillmgr —— 造一个只有 SKILL.md 的目录,skillmgr 收到
  • TestEffectsRunInReverseOnStop

阶段 4(可选,需单独决策):远程安装

  • 从 npm / dsh 插件市场安装
  • 必须带 sha256 校验,且默认关闭

五、参考清单(写代码前该读什么)

5.1 必读(按顺序)

internal/plugins/clawhubadapter/ 已于 2026-11-04 迁出内核仓, 现位于 /home/program/clawhubadapter-plugin/clawhubadapter/。 下面的路径都是迁移后的新位置。

文件 为什么
<ext>/sidecar.go 协议层原型,原样照搬(JSON-RPC + pending-id 配对 + NotifyChan + 优雅 Close)
<ext>/registry.go 转译表原型,换内容照抄结构(含 default 宽容分支)
<ext>/plugin.go 生命周期、包发现、工具注册的骨架
<ext>/simulator/main.js 假宿主原型,换成 Cordis Context
<ext>/skill/clawhub-tools/SKILL.md 随包 skill 的真实写法(外部插件带 skill 的范本)
internal/plugins/pluginmgr/skill_handout.go 外部插件随包 skill 的移交机制(安装时 + 启动重扫)
internal/plugins/skillmgr/plugin.go 交接口终点(loadOne、isNativeSkillPath、scanExisting)

<ext> = /home/program/clawhubadapter-plugin/clawhubadapter。

5.2 外部参考(dsh 侧)

路径 为什么
/opt/agentmail/plugins/dsh-mail-bridge/src/index.ts dsh 插件侧 API 的最可靠文档(2586 行生产代码)。inject 声明、ctx.tools.register、ctx.on 五种事件、ctx.effect 清理、logger 不进 journalctl 的坑
/root/.dsh/profiles/web/package.json 装载配置的真实形状(dsh.profile.bundles)
/root/.dsh/profiles/web/node_modules/@dsh-external/dsh-mobile-nav/ 一个最小真实插件(含 cordis.patch.yml)
@deepseek-ai/dsh-tools/lib/types/schema.d.ts:248 defineTool 的签名与 DefineToolOptions 全字段
@deepseek-ai/cordis/lib/types/context.d.ts:40 Context 类(constructor() 无参,是阶段 0.2 的依据)

5.3 SDK 侧(third_party/hologramagentsdk/,即完整 SDK 仓)

文档 为什么要看
docs/guide/capability-boundary.md 先看这个。决定什么能做、什么只能内置
docs/api/builtin-only.md 仅内置可用的 API 清单(Events() 恒 nil 的裁定在此,第 25 行)
docs/guide/capability-boundary.md §「判定依据」 三条权威来源(proc 模板 / internal/sdk / proc 协议表)—— 有疑问就按这三条查
docs/api/tools.md / docs/api/stages.md ToolDef / 阶段钩子的字段全集
sdk/plugin.go 字段以源码为准,grep -A30 "type ToolDef struct"
tools/hmapdev/templates/proc_main.go.tmpl 外部插件实际拿到什么能力,全在这一个文件里(buildPluginSDK 的注入清单)

5.4 hmapdev 要问的东西

hmapdev version          # 工具链 + SDK 模块 + 构建用 Go(实测 1.4.0 / go1.27.1)
hmapdev sdk current      # 当前活跃 SDK(实测 v1.4.0)
hmapdev sdk list         # 已装版本(v1.4.0 / v1.3.0 / v1.2.0 / v0.8.0)
hmapdev --help           # ★ build/debug **不支持** --help,看这个
hmapdev init <name>      # 拿脚手架(会生成 plugin.go + plg.json + go.mod + rawfile/)
hmapdev build            # 产出 dist/<name>_bundle.hmap(ZIP,含多平台 plugin.bin.<os>.<arch>)

两个实测踩过的坑:

  1. go build 在插件工程里不成立 —— 缺 main,入口由 hmapdev build 注入生成(z_proc_gen.go)。所以"能不能编译"要用 hmapdev build 判断, 不要用 go build。
  2. proc 运行时调 NewPluginFactory(不是 NewPlugin)。手写入口时名字必须对, 否则 undefined: NewPluginFactory。

dshplgadapter 是外部插件,所以 hmapdev 全程参与构建: hmapdev init 生成骨架 → hmapdev build 产出 .hmap(内含 plugin.bin.<os>.<arch> 与随包 skill/)。与本仓 internal/plugins/ 的内置插件(走主仓 go build) 是两套不同的构建路径,不要混。

5.5 可直接照抄的迁移范本(先读这个再开工)

2026-11-04 已把 clawhubadapter 完整迁为外部插件并跑通,工程在 /home/program/clawhubadapter-plugin/clawhubadapter/。dshplgadapter 开工前 建议先读这三个文件,它们是迁移后已验证可编译、可运行的真实形态:

文件 看什么
plugin.go 开头的 NewPluginFactory 外部入口的正确写法(含注册改工厂的差异)
safeclose.go 内核 internal/safeclose 的外部替代(泛型覆盖三种 channel)
external_form_test.go 四条外部形态判据,直接照搬改包名

还有一个实操细节:plg.json 的 name_en 决定产物文件名 (toSnake(name_en) → <snake>_bundle.hmap)。如果 name_en 写成 "Dsh Plg Adapter",产物会是 dsh_plg_adapter_bundle.hmap,与包名 dshplgadapter 不一致 —— 安装脚本里很容易对不上。把 name_en 设成与 name 相同。


六、风险清单

风险 影响 处置
stdout 被 dsh 插件日志污染 协议坏帧,插件整体不可用 阶段 0.1 先验证 fd 3 方案;未解决不进阶段 1
dsh 插件可执行任意 Node 代码 绕过 HA 沙箱 阶段 1 前先做允许与否的决策(§3.4)
dsh 版本漂移(本机 0.2.1-rc.2,RC 版会变) 插件 API 变,适配层碎 适配层按 inject 声明动态发现能力,不硬编码包名
@@deepseek-ai/dsh-* 200+ 包的加载开销 内存/启动时间 只加载被 bundles 声明的,不全扫
异步中间件链与 HA 同步阶段语义冲突 死锁或语义丢失 阶段 2 专项;先在探针里复现
本仓 channels.lua 类问题:加了代码但静默退化 难查的 nil 照 luac 那次的教训,每条新路径都要有行为判据

七、明确不做

  • 不做 dsh 的 client 半边(exports["./client"]、浏览器 UI)。 那是 dsh 前端的事,HA 没有对应位置。
  • 不实现 Cordis 的完整服务树。只提供插件声明了 inject 的那几个服务, 其余缺失即报错退出(fail-loud),不伪造。
  • 不在阶段 1-3 做远程安装(§3.4 第三条)。
  • 不为了兼容 dsh 而改 HA 的阶段钩子语义。转译层负责适配,内核不动。

八、判据总表(贯穿各阶段)

判据 钉什么
TestDiscoversDSHPlugin 扫描真的能认出 dsh 插件
TestRegistersToolFromPlugin 工具真的进了 HA 工具表(不是只记日志)
TestUnknownRegisterTypeDoesNotCrash 宽容策略
TestPluginLoadFailureDoesNotAbortScan 一个坏包不毁全部
TestProtocolSurvivesPluginLogging ★ 插件狂打日志时协议仍通(对应最大风险)
TestPackageNameCannotEscapeRoot 路径穿越(抄 clawhubadapter 的教训)
TestArchiveExtractionStaysInsideRoot ZIP Slip(同上)
TestEffectsRunInReverseOnStop 清理顺序
TestSkillHandoffToSkillmgr 职责链交接

每条判据都要做变异验证:把对应实现改回错误形态,判据必须变红。 只写判据不做变异,等于没验证(本仓 agentcli-terminal 那次教训: 第一版变异"只改一句话"没红,因为关键词在文中另有 13 处,那次变异无效)。