chore: 规划书不进仓库(与 plan.md / kernelplan.md 同惯例)

## 为什么

规划/设计在交接前会反复改。入库后有两个实际问题:

  · 每改一次就是一条"改文档"提交,把真实代码提交埋掉
  · 规划与实现必然漂移,读了仓库里的人会拿到过期结论

## 既有惯例

本仓早就是这样处理的,不是新立的规矩:

  /plan.md         曾在 7901651「移除过程文档」被清出
  /kernelplan.md   从未入库
  两者都在 .gitignore 里,都只存在于工作区

## 做法

  · docs/zh/dshplgadapter-plan.md 从索引移除(文件本身留在工作区)
  · 挪到仓库根 dshplgadapter-plan.md,与 plan.md / kernelplan.md 同位置
  · .gitignore 加 /dshplgadapter-plan.md,并写明理由

实现落地后,结论会进 docs/zh/ 下的正式设计文档 —— 那里才是该留档的地方。

## 影响

内核仓历史里 e8d07cd、82d08eb 两个提交只含这份规划书,现已从工作树移除。
如需追查当时的结论,git show e8d07cd 仍可读到(历史不重写)。
This commit is contained in:
JianFeeeee
2026-11-04 15:54:56 +08:00
parent 1b6cd57dca
commit af17af5082
2 changed files with 6 additions and 765 deletions

6
.gitignore vendored
View File

@ -135,3 +135,9 @@ dist/
# 内核升级 CLI 的本地构建产物(与 /homed 同理,不入库)
/homed-upgrade
/kernelplan.md
# dshplgadapter 规划书(工作文档,不进仓库)
# 与 /plan.md、/kernelplan.md 同一惯例:规划/设计在交接前会反复改,
# 入库后要么产生一堆"改文档"提交,要么与实际实现漂移。
# 实际实现落地时,结论会进 docs/zh/ 下的正式设计文档。
/dshplgadapter-plan.md

View File

@ -1,765 +0,0 @@
# 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 的全部意义就是把下面四个"推测"变成"已验证"**。