Files
HomeAgent/docs/zh/dshplgadapter-plan.md
JianFeeeee 82d08ebc45 docs(plan): 修正 dshplgadapter 规划书的仓库引用 + 补交接须知
## 原稿的四类引用错误(交给别人会直接卡住)

  ① 说 clawhubadapter 在 internal/plugins/ —— 已迁出,那里没有了(a0077a2)
  ② 指向 /home/program/clawhubadapter-plugin/ —— 本机路径,别人打不开
  ③ 指向 /opt/agentmail/plugins/dsh-mail-bridge/ —— 本机生产拷贝
  ④ ★ 完全没提 deepseek-ai/deepseek-harness —— 漏掉唯一的 dsh 官方文档源

第 ④ 条最严重:dsh / dsh-tools / cordis 三个包的 package.json 的
repository 字段全指向那个 monorepo(公开,master,2026-11-03 仍在更新),
里面有 docs/cordis-tutorial/01..07 的**七步中文教程**、tool-execution-pipeline
(tools/execute 异步链的权威说明)、cordis-primer、api/、cookbook/ ——
正是阶段 1-3 的直接依据,原稿一条都没有。

## 现在的引用(逐个实测 HTTP 200)

  github.com/JianFeeeee/HologramAgent          内核仓
  github.com/JianFeeeee/hologramagentsdk       SDK 仓
  github.com/JianFeeeee/clawhubadapter         参考插件(原型)
  github.com/deepseek-ai/deepseek-harness      dsh 官方
  github.com/QIKO0721/dsh-mail-bridge          mail-bridge 公开镜像

只剩两处本机路径,且都写了适用条件、标明不是阻塞项
(dsh 的 ~/.dsh/profiles 固定布局;mail-bridge 的本机生产拷贝)。

## 新增 §9 交接须知

给没有本机环境、没有上下文的人:

  · 9.1 需要的 6 样东西 + 哪些阻塞(只有 mail-bridge 一项依赖特定机器)
  · 9.2 开工前的环境自检命令(含期望输出)
  · 9.3 三条别再踩的坑(go build 不成立 / NewPluginFactory / stdout 只能 JSON)
  · 9.5 一个前置决策:dsh 插件能跑任意 Node 代码 = 给本机执行权限,
        必须在阶段 1 前决定,不能"先跑通再说"
  · 9.6 已验证 vs 推测的诚实标注

9.6 是本轮最重要的补充。四条"推测"(裸 Context 承载插件、fd3 通道、
defineTool schema 无损转换、异步链不死锁)就是阶段 0 存在的理由 ——
不标出来,接手人会把推测当事实,直接跳到阶段 1。

## 结构校验

表格 0 处列数不一致;代码围栏 38 个(偶数,配对完整);766 行。
2026-11-04 15:49:40 +08:00

766 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# dshplgadapter 插件项目规划书
> **状态**:规划(未开始实现)
>
> **定位**:把 DeepSeek Harness(下称 dsh)的 Cordis 插件生态接入 HologramAgent。
> 参考原型是 **clawhubadapter**(<https://github.com/JianFeeeee/clawhubadapter>)——
> 它把 OpenClaw 生态接入 HA,dshplgadapter 把 dsh 生态接入 HA,
> 两者是**同一个模式的两次实例化**。
>
> ⚠️ **该插件已从内核仓迁出**(内核仓 commit `a0077a2`)。
> 本文中凡引用 clawhubadapter 源码的地方,**一律以上述仓库为准**,
> 不要到内核仓的 `internal/plugins/` 里找(那里已经没有了)。
>
> **建立于**: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):
```text
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 迁移实测:三处差异,全部可绕
把 clawhubadapter(迁出前的内部版)搬到 `hmapdev` 骨架上,只改 import,
`hmapdev build` 报四个错:
```text
./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 迁移结果(已验证)
```text
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` 是最有价值的部分(原样照搬)
它解决了"跨进程调用"的全部脏活,与具体插件格式**无关**:
```text
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 调用:
```go
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)
它做的事情本质上是**骗过插件**,让插件以为自己跑在真宿主里:
```js
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` 可以无参构造:
```bash
$ 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 版本与包布局
```bash
$ 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`):
```jsonc
// package.json
{
"name": "@dsh-external/dsh-mobile-nav",
"type": "module",
"main": "lib/index.js",
"exports": { ".": {...}, "./client": {...}, "./cordis.patch.yml": "./cordis.patch.yml" },
"dsh": { "client": ... } // ← 客户端半边的声明
}
```
```yaml
# cordis.patch.yml
- insert:
- id: dsh-mobile-nav
name: '@dsh-external/dsh-mobile-nav'
```
```js
// lib/index.js —— 主入口
export function apply(ctx) {
ctx.effect(() => installResponseCompression(), 'dsh-mobile-nav: response compression');
}
```
**装载链路**(实测 profile 配置。`<profile>` = `~/.dsh/profiles/<名字>`,
本机为 `web` profile,即 `/root/.dsh/profiles/web`;其他机器上只是 `$HOME` 不同):
```bash
<profile>/package.json
├─ dependencies: @dsh-external/xxx, dsh-mail-bridge, ...
└─ dsh.profile.bundles: [ "@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app", ... ]
<profile>/cordis.yml → 空数组(root,由 patch 组合)
<profile>/cordis.patch.yml → 覆盖层
<profile>/node_modules/@dsh-external/* → 软链到真实目录
```
⇒ **dshplgadapter 的"扫描目标"就是 `<profile>/node_modules/`**
(含 `@dsh-external/`、`@scope/`、无 scope 的),加上 `dsh.profile.bundles` 清单。
### 2.3 ★ dsh 插件与 clawhubadapter 最大的不同:日志会污染协议通道
这是**必须提前设计**的风险,不是实现细节。
clawhubadapter 的 OpenClaw 模拟器把 `logger` 接到 stderr:
```js
logger: { debug: () => {}, info: () => {}, warn: () => {},
error: (...a) => process.stderr.write(`[plugin] ${a.join(' ')}\n`) }
```
而 dsh 生态的插件**大量使用 `console.log`**,且 dsh 自己的 logger 语义就是
"写到进程输出"。一旦子进程 stdout 被日志污染:
```text
内核 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 行,已生产运行)实测:
```js
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 已存在的同类先例(务必读)
**`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`(它踩过,
注释里写得很清楚)
#### 在哪里拿这个参考
⚠️ **它没有在 HologramAgent 组织下,也不在本仓。** 三个可选源,按可靠性排序:
| 源 | 地址 | 说明 |
| --- | --- | --- |
| **本机生产拷贝**(最完整) | `/opt/agentmail/plugins/dsh-mail-bridge/current/src/index.ts` | 2586 行,这台机器上有。若接手的人在**同一台机器**上开发,直接用这个 |
| 公开镜像 | <https://github.com/QIKO0721/dsh-mail-bridge> | 同名同用途(909 KB,2026-10-15)。**不是同一份**(行数与目录不同),但 ctx 用法可直接参考 |
| 自己装一份 | `dsh plugin --profile web add <包名>` 后读 `node_modules/` | 依赖已发布到 npm 时可行 |
> 如果接手的人不在这台机器上,**公开镜像 + dsh 官方 tutorial 已经够用**,
> 不依赖本机路径。不要把它写成“必读”阻断项。
---
## 三、设计
### 3.1 总体形态(与 clawhubadapter 逐层对照)
```text
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:最小闭环(只支持工具)
- 工程骨架:用 `hmapdev init dshplgadapter` 生成(**不是** `internal/plugins/dshplgadapter/`
—— 它是外部插件,不进内核仓)。把它会拿到的目录结构与
<https://github.com/JianFeeeee/clawhubadapter> 对照(后者是已验证跑通的同形态工程)。
- 扫描 `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 参考实现:clawhubadapter(已验证跑通)
> **源码仓库:<https://github.com/JianFeeeee/clawhubadapter>**
>
> ⚠️ 它已于 2026-11-04 从内核仓迁出(commit `a0077a2`)。
> **不要到内核仓的 `internal/plugins/` 里找**(已经删了)。
> 下文以 `<ext>` 代指该仓库根目录。
| 文件 | 为什么 |
| --- | --- |
| `<ext>/sidecar.go` | **协议层原型**,原样照搬(JSON-RPC + pending-id 配对 + NotifyChan + 优雅 Close) |
| `<ext>/registry.go` | **转译表原型**,换内容照抄结构(含 `default` 宽容分支) |
| `<ext>/plugin.go` | 生命周期、包发现、工具注册的骨架;文件头就是 `NewPluginFactory` |
| `<ext>/safeclose.go` | 内核 `internal/safeclose` 的外部替代(泛型覆盖 3 种 channel) |
| `<ext>/simulator/main.js` | 假宿主原型,换成 Cordis Context |
| `<ext>/skill/clawhub-tools/SKILL.md` | 随包 skill 的真实写法(外部插件带 skill 的范本) |
| `<ext>/external_form_test.go` | 四条外部形态判据,**直接照搬改包名** |
下面两个文件**仍在内核仓**(它们是内核侧机制,不能迁出):
| 文件 | 为什么 |
| --- | --- |
| `internal/plugins/pluginmgr/skill_handout.go` | 外部插件随包 skill 的**移交机制**(安装时 + 启动重扫) |
| `internal/plugins/skillmgr/plugin.go` | 交接口终点(`loadOne`、`isNativeSkillPath`、`scanExisting`) |
### 5.2 外部参考(dsh 侧)
#### ★ 5.2.1 dsh 官方文档站(一手资料,**必读**)
仓库:**<https://github.com/deepseek-ai/deepseek-harness>**(公开,master 分支,
2026-11-03 仍在更新)。三个包(`dsh` / `dsh-tools` / `cordis`)全部来自这个 monorepo
(看它们的 `package.json` 的 `repository` 字段即可证实)。
| 路径 | 为什么读 |
| --- | --- |
| `docs/cordis-tutorial/01-first-plugin.zh.md` ~ `07-into-the-harness.zh.md` | **七步中文教程**,从插件生命期到 effects 到服务到事件到配置到 HMR。这是阶段 1-3 的直接依据 |
| `docs/cordis-primer.zh.md` | Cordis 框架入门(Context / service / inject 的心智模型) |
| `docs/tool-execution-pipeline.md` | **工具执行管道**。§2.4 提到的 `tools/execute` 异步中间件链到底怎么跑,以这个为准 |
| `docs/api/`(含 `cordis-api/`) | `ctx` 面各服务的接口定义 |
| `docs/cookbook/adding-a-tool.zh.md` | 新增工具的官方做法 |
| `docs/capability-seams.md` | 能力接缝(“什么东西可以插进来”) |
| `docs/architecture.zh.md` / `docs/module-graph.md` | 整体架构与模块图 |
> 这份仓库里的文档大多有 `.zh.md` 中文版,直接看中文即可。
#### 5.2.2 插件侧 API 的真实用法参考
| 源 | 为什么 |
| --- | --- |
| `dsh-mail-bridge` 的 `src/index.ts` | 2586 行生产代码,**dsh 插件侧 API 的最可靠参考**。`inject` 声明、`ctx.tools.register`、`ctx.on` 五种事件、`ctx.effect` 清理、`logger` 不进 journalctl 的坑。见 §2.5 的**三个可选源**(本机拷贝 / 公开镜像 `QIKO0721/dsh-mail-bridge` / 自己装一份) |
| `@deepseek-ai/dsh-tools/lib/types/schema.d.ts:248` | `defineTool` 签名与 `DefineToolOptions` 全字段(本地 node_modules,随 dsh 安装) |
| `@deepseek-ai/cordis/lib/types/context.d.ts:40` | `Context` 类(`constructor()` 无参,是阶段 0.2 的依据) |
| `<profile>/node_modules/@dsh-external/dsh-mobile-nav/` | 一个最小真实插件(含 `cordis.patch.yml`)。`<profile>` = `~/.dsh/profiles/web` |
> 后三条是**安装 dsh 后本地就有**的路径,不是某个人的家目录约定:
> `~/.dsh/profiles/<profile>/` 是 dsh 的固定布局。
### 5.3 SDK 侧
**仓库:<https://github.com/JianFeeeee/hologramagentsdk>**
两种拿法,任选:
- 克隆到内核仓的 `third_party/hologramagentsdk`(`go.mod` 里的 `replace` 默认指向这里)
- 或直接读 GitHub(文档与 `sdk/*.go` 都在仓里,不必克隆)
另外:本机装了 `hmapdev` 后,它会在 `~/.homeagent/hmapdev/sdk/<版本>/`
放一份对应版本的 SDK(本机为 `v1.4.0`)—— 那是**构建用**副本,内容与上述仓一致。
| 文档 | 为什么要看 |
| --- | --- |
| `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 要问的东西
```bash
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 完整迁为外部插件并跑通,
源码在 **<https://github.com/JianFeeeee/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 处,那次变异无效)。
---
## 九、交接须知(给接手实现的人)
这一节是给**别人**看的,假定对方没有本机环境、没有上下文。
### 9.1 你需要的东西,按重要性
| # | 东西 | 在哪 | 是否阻塞 |
| --- | --- | --- | --- |
| 1 | 内核仓 | <https://github.com/JianFeeeee/HologramAgent> | **阻塞**(必须能编译 homed) |
| 2 | SDK 仓 | <https://github.com/JianFeeeee/hologramagentsdk> | **阻塞**(`hmapdev` 要用) |
| 3 | 参考插件 clawhubadapter | <https://github.com/JianFeeeee/clawhubadapter> | **阻塞**(原型,照抄结构) |
| 4 | dsh 官方仓库 | <https://github.com/deepseek-ai/deepseek-harness> | **阻塞**(`docs/cordis-tutorial/` 是唯一权威教程) |
| 5 | 本机 dsh 安装 | `npm i -g @deepseek-ai/dsh` | 探针需要 |
| 6 | `dsh-mail-bridge` 参考 | 见 §2.5 三个源 | 不阻塞(有公开镜像) |
**只有第 6 项依赖特定机器**,其余都能从公开网络拿到。
### 9.2 环境自检(开工前跑一遍)
```bash
# 工具链
hmapdev version # 期望 1.4.0+;SDK 模块必须是 hologramagentsdk
hmapdev sdk current # 期望 v1.4.0+
# dsh
node -e "console.log(require('@deepseek-ai/dsh/package.json').version)" # 期望 0.2.1-rc.2
node -e "const c=require('@deepseek-ai/cordis'); const ctx=new c.Context(); console.log('Context ok', !!ctx)" # 阶段 0.2 的前提
# 内核仓能编译
cd <HologramAgent> && go build ./...
# 参考插件能编
cd <clawhubadapter> && hmapdev build # 产出 dist/*.hmap 即通过
```
任何一条不过,先解决再进入阶段 0。
### 9.3 三条别再踩的坑(都是本仓真实付过代价的)
1. **`go build` 在插件工程里不成立** —— 缺 `main`,入口由 `hmapdev build`
注入生成。判断"能不能编译"必须用 `hmapdev build`,不要用 `go build`。
2. **入口函数名是 `NewPluginFactory`,不是 `NewPlugin`** —— proc 运行时按名调用,
写错报 `undefined: NewPluginFactory`(错误指向生成代码,很难一眼看出)。
3. **子进程 stdout 是协议通道,只能有 JSON** —— 任何 `console.log` 都会造成
"非法 JSON 帧"。这是本方案**最大的技术风险**(见 §2.3),也是阶段 0.1
必须先验掉的原因。
### 9.4 建议的推进顺序(与 §四 一致)
```text
阶段 0 ← 先做这个,别跳
├─ 0.1 fd3 协议通道 ← 不过就不要往下走
├─ 0.2 裸 Context 承载插件
├─ 0.3 defineTool schema 往返
└─ 0.4 inject 缺服务时的行为
阶段 1 最小闭环(只 tools)
阶段 2 事件与钩子(注意 §2.4 的异步链死锁风险)
阶段 3 纯 SKILL 交接 + 生命周期
阶段 4 远程安装(需单独决策)
```
### 9.5 一个前置决策(不要默认放行)
dsh 插件是 **Node 代码**,`apply()` 里可以 `import('node:child_process')`
执行任意命令。这与 HologramAgent 的沙箱模型冲突(当前内核里真正生效的沙箱
只有 files 插件自己的 `pathWithinSandbox`)。
**在阶段 1 开工前必须先决定**:允不允许 dsh 插件跑任意代码?
- 允许 ⇒ 必须明确写进文档,并告知部署方(等价于"装 dsh 插件 = 给本机执行权限")
- 不允许 ⇒ 需要一个进程级限制方案,工作量显著增加,要单独评估
不要"先跑通再说"——那等于默认给了执行权限,事后收不回来。
### 9.6 这个方案里哪些是"已验证",哪些是"推测"
诚实标注,避免接手人把推测当事实:
| 结论 | 状态 | 依据 |
| --- | --- | --- |
| clawhubadapter 能做成外部插件 | ✅ **已验证** | 已迁出并生产运行(本仓 commit `a0077a2`) |
| 外部插件自带 skill 由 pluginmgr 移交 | ✅ **已验证** | 生产日志三段链路(§0.3) |
| `Events().Publish` 外部不可用 | ✅ **已验证** | 编译期拦截 + `capability_test.go` 禁令 |
| `Context` 可无参构造 | ✅ **已验证** | 读 `.d.ts` 确认签名(但**未实际加载过插件**) |
| dsh 插件能在裸 Context 里跑起来 | ⚠️ **推测** | 阶段 0.2 要验 |
| fd3 通道可行 | ⚠️ **推测** | 阶段 0.1 要验 |
| `defineTool` schema 可无损转换 | ⚠️ **推测** | 阶段 0.3 要验 |
| 异步中间件链不与应用层死锁 | ⚠️ **推测** | 阶段 2 要验 |
⇒ **阶段 0 的全部意义就是把下面四个"推测"变成"已验证"**。