Files
homeagent-sdk/example/commondev/examples/custom-cli/README.md
JianFeeeee 81a3efa4df feat(commondev): 主管式多仓库开发流水线 + sidecar 后端抽象(pi/zcode) (#3)
* feat(commondev): 主管式多仓库开发流水线 + sidecar 后端抽象(pi/zcode)

## 是什么

agent 统筹开发流程的插件:登记项目 → 开工程线(每条线一个隔离工作目录 +
一个 sidecar 子进程 + 一对通道)→ 投递任务 → 同会话续投 → 评审/合入。

38 个工具:project_*/branch_*/workdir_*/job_*/review_*/test_*/
endpoint_*/agent_*/pi_*/conflict_check。

## sidecar 后端抽象(本轮核心)

传输层(local/ssh)与后端(pi/zcode/自定义)**正交**,四个自由组合:

  driver.json 描述一个后端:script / cliPath / env / args / llmConfig
  三级查找:插件数据目录 → **随包 rawfile** → 内置 pi 描述
  占位符:{workdir} {dataDir} {pluginDir} {rawfile} {sessionId}

★ 内置 pi 回退**与默认后端解耦**(2026-10-17 修):原实现写
`if cli == defaultAgentCLI { return builtinPiDriver() }`,把"给谁兜底"
和"默认选谁"耦合到同一常量 —— 一旦有人改默认,pi 当场失去兜底,
显式 cli="pi" 开始报「驱动不存在」。现在判断字面 "pi"。

默认后端保持 **pi**(我们本地主用);交付方用 zcode 走两种局部开关:
 · 单线:commondev_branch_open(cli="zcode")
 · 按机器:commondev_endpoint_set(host=..., cli="zcode")

## zcode 接入(三条真机判据,全部要求真改工程文件)

 · TestZcodeFullPipeline(29-34s):随包 driver + 真实 ZCode CLI
 · TestZcodeOverSSH(39s):真 ssh + scp,远端 node 跑 zcode
 · TestZcodeOverSSHWithPerHostEndpoints(68s):两个 host 别名配不同
   cli_path,走端点表而不是全局 settings

★ 会话库必须隔离到工作区(sidecar.mjs 的 ensureWorkspaceSessionStore):
实测 ZCode 的会话库路径**硬编码 homedir()**,`ZCODE_DATA_BASE_DIR` 对它
完全无效(26 个会话全落全局库)。但 `<workdir>/.zcode/config.json` 的
`storage.sessionDbPath` **确实生效**。所以启动前写一份工作区配置,
一举解决:多线不共用历史 / 不污染全局 ~/.zcode / 删线不留孤儿会话。
只在不存在时写 —— 用户手工放的要尊重。

## 其他

 · rawfile:项目根 rawfile/ 默认全收,解到 <插件目录>/rawfile/,绝对路径拒绝
 · 凭证:命名引用 + ${ENV_VAR},独立 0600 文件,绝不进 config.db(0644)
 · secretshell:给不受信 agent 用的安全 shell(ackey + safeshell)
 · 冲突检查 / 评审判据 / 镜像 / 每条线独立 workdir(hash 命名,合并后删除)

判据 30+ 条,含 4 条「判据本身的判据」(防止判据假绿)。

* fix(sdk): 协议通道纪律 + hmapdev 模板升级 + 示例插件 stdout 污染

## 核心缺陷:插件往 stdout 打日志 = 污染 JSON-RPC 协议通道

子进程插件与内核用 stdout 传 NDJSON 帧,stderr 才是日志通道。
脚手架模板(及 4 个示例插件)用 `fmt.Printf` 打日志 ⇒ 内核收到非 JSON 文本:

    [proc] weather 收到非法 JSON 帧(21 字节): invalid character 'i'

**生产实测 1038 条**(huawei_smarthome 720 / weather 113 / rss 103 / calendar 102)。
后果不只是日志噪音:那一行日志被**丢弃**,插件看起来"没启动"。

修法:
- hmapdev 模板 `fmt.Printf` → `log.Printf`(9 处)
- 示例插件 4 个 14 处:ai_image(2) calendar(5) rss(4) weather(3)
  + 补 "log" import
- 内核侧(主仓)同步让报错可定位:内容摘要 + 可能原因 + 修法

判别法:`package main` = 子进程(协议通道,禁用 stdout 打日志);
普通库包 = 进程内插件(编译进内核,写真实终端,**不是**缺陷)——
`internal/plugins/cli` 的 `fmt.Println` 属后者。

## hmapdev

- `cmd_build.go` +136:平台产物精确匹配(`--target` 是**append** 语义,
  原脚本 `ls dist/*.hmap | head -1` 会按字典序抓到 amd64,
  **导致 arm64 包里装的是 x86-64 二进制**,且退出码 0、校验和通过)
- `cmd_init.go` +46:脚手架 stdio 纪律
- `templates/proc_main.go.tmpl`:补齐 mgrStop/mgrStart/mgrIsRuntimeStopped
  三态能力(插件管理器运行态停启)
- `templates/plg.json.tmpl` 删除(合并进 cmd_init)

## 判据

- `scaffold_stdout_test.go`(3 条):扫模板生成的代码不得写 stdout。
  ★ 判据自身先假红过一次:**模板的注释里就写了 `fmt.Printf`** ⇒
  必须 `stripTemplateComments()` 之后才判
- `example_stdout_test.go`(2 条):扫全部 20 个子进程示例插件,
  报错给**文件:行号**;变异咬住并给出准确行号
- `package_examples_test.go`(2 条):**执行真实打包脚本**
  (第一版测的是 hmapdev 而不是脚本 ⇒ 变异不咬)
- `cmd_build` / `cmd_init` 新判据

## 其他

- sdk/settings.go +15(三态能力透传)
- `docs/versions.md` +§示例插件协议通道纪律(含生产 1038 条实测表格)
- `skills/homeagent-plugin-dev/SKILL.md` +§协议通道纪律(正误代码对比)

* fix(examples): 恢复 go.mod 相对路径 + 移除 plg.json 的 sdk 固定(修 CI 红)

CI 上 `Examples (linux/amd64)` 与 `(darwin/arm64)` 双红,只有两个失败:

  ✗ commondev   构建失败
  ✗ editdoc     构建失败
  error: 项目声明 sdk=1.4.0,但本地 SDK 存储里没有这个版本

其余 20 个示例全部成功 —— 说明不是通用问题,而是这两个被改坏了。

## 根因(两个叠加)

**① go.mod 被本机绝对路径污染**

我在上一个提交里用 `git add -A`,把本机 `go build` **自动改写**过的
go.mod 一并提交了:

  require github.com/JianFeeeee/homeagentsdk v1.4.0
  replace github.com/JianFeeeee/homeagentsdk => /root/.homeagent/hmapdev/sdk/v1.4.0

基线(origin/main)是:

  require github.com/JianFeeeee/homeagentsdk v0.0.0
  replace github.com/JianFeeeee/homeagentsdk => ../../

本机那个目录真的存在 ⇒ 本地全绿;CI 没有 ⇒ 必红。

**② plg.json 声明了 `"sdk": "1.4.0"`**

一声明版本,hmapdev 就去**本地 SDK store** 找它,找不到直接拒绝构建。
全仓 20+ 示例**都不声明**,只有我改过的这两个声明了。
示例的正确依赖表达是 go.mod 的 `v0.0.0` + `replace ../../`。

## 修法

两个文件都恢复到与其余示例一致的写法。

## 判据(3 条,都是新加的)

 · `TestExampleGoModHasNoAbsoluteReplace`:任何示例 go.mod 不得含
   `/root/`、`/home/`、`/Users/`、`C:\`
 · `TestExampleGoModUsesRelativeSDKReplace`:replace 必须是相对路径 `../../`
 · `TestExamplePlgJsonDoesNotPinSDKVersion`:plg.json 不得声明 sdk

变异 2/2 咬住(重新引入绝对路径 / 重新声明 sdk,都准确报出文件路径)。

## 本地验证(模拟 CI 干净环境)

用干净 HOME 跑 `package/build-examples.sh`:
 · 修复前:成功 18 / 失败 4(commondev, editdoc, browser, rss)
 · 修复后:成功 20 / 失败 2(browser, rss —— **本机代理不通**,
   `proxy.golang.org` 超时;用本地 modcache 验证这两个可正常构建)

即 22/22 源码都能构建,CI 有网络不受影响。

## 教训

`git add -A` 会把「构建工具自动改写的文件」一起提交。
提交前必须 `git status` 逐个看,尤其 go.mod / go.sum。

---------

Co-authored-by: JianFeeeee <JianFeeeee@users.noreply.gitcode.com>
2026-10-17 19:44:15 +08:00

218 lines
10 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.

# 自定义 CLI 后端(示例:ZCode)
> ## ★ 边界:**内嵌只做 pi**
>
> 插件**内嵌支持的后端只有 pi**(`runtime/pi/`,不装 driver.json 也照常工作)。
> 本文及同目录示例是给**想让插件驱动别的 CLI** 的用户用的。
>
> 这不是"官方内嵌支持"——它们是**参考实现**:插件不承诺跟着上游 CLI 变更同步更新,
> 你要根据自己的 CLI 版本调整(尤其参数名与输出格式)。
>
> 沿用本仓的一条纪律:**内核/插件只保证机器必须保证的事,策略与平台知识交给能
> 独立演进的地方**(见 skill `commondev-discipline` 里"平台知识进 skill"的同一条理由)。
## 怎么加一个后端
插件按 **数据**(不是代码)选后端。加一个 CLI 只要放两个文件:
```
<插件数据目录>/runtime/<cli>/driver.json ← 声明:起什么命令、什么环境
<插件数据目录>/runtime/<cli>/sidecar.mjs ← 实现:把 CLI 包成 JSON-RPC
```
**插件核心一行都不用改**(这是可验证的:`TestCoreDoesNotHardcodePiLaunchDetails`
会断言 `startSidecar` 里不出现任何 pi 专有细节)。
开分支时指定后端:
```
commondev_branch_open(project="X", branch="feat/a", agent_cli="zcode", workdir=...)
```
### 两种来源:随包分发 vs 本机覆盖
`driver.json` 与 sidecar 脚本都按同一条优先级链找:
| 顺序 | 位置 | 用途 |
|---|---|---|
| ① | `<插件数据>/runtime/<cli>/` | **本机覆盖**(最高优先)—— 指到本机真实的 CLI 路径 |
| ② | `<插件目录>/rawfile/runtime/<cli>/` | **随包分发** —— 升级插件即带上 |
| ③ | `<可执行文件目录>/` | 开发期 |
**为什么要 ②**:`cliPath` 的随包默认值是 `{rawfile}/zcode/zcode.cjs`,
而 CLI 有 28MB、按需分发 —— 所以**多数机器上它不存在**,此时必须由 ① 覆盖:
```bash
# 本机覆盖:只改这两个文件,不影响随包内容(升级插件不会被覆盖)
mkdir -p <插件数据>/runtime/zcode
cp <随包>/rawfile/runtime/zcode/sidecar.mjs <插件数据>/runtime/zcode/
# 然后编辑 <插件数据>/runtime/zcode/driver.json,把 cliPath 指向本机 zcode.cjs
```
若两处都没有,错误会**列出找过的每个位置**并告诉你改哪里(不要让它静默失败)。
## sidecar 必须实现的契约
JSON-RPC over stdin/stdout,方法与 pi 的 sidecar **完全相同**:
| 方法 | 入参 | 必须返回 |
|---|---|---|
| `ping` | — | `{ok, session_id, cli_version?}`(握手,10s 内) |
| `prompt` | `{text, session_id?}` | `{ok, session_id, reply}` ← **`session_id` 必须回** |
| `state` | — | `{ok, session_id, messages}` |
| `close` | — | `{ok}` |
**为什么 `prompt` 必须回 `session_id`**:插件在投递后**断言会话未漂移**
(设计 §3.3)。不回或回错值,插件会响亮失败——那是防"悄悄换了个会话"的机制。
### ★ 会话 id 有两个**不同**的东西,别混
这是 ZCode 后端实测踩到的真坑(2026-10-16,真机复现):
| 名字 | 长什么样 | 谁产生 | 能当 `--resume` 目标吗 |
|---|---|---|---|
| **插件标签** | `br-<project>-<branch>` | 插件(`--session-id` 传进来) | ❌ **绝不能** |
| **后端真实 id** | `sess_xxxxxxxx-...` | 后端首轮返回 | ✅ 只有它能 |
初版把 `args.sessionId`(标签)直接当 `--resume` 目标 ⇒ 首轮就执行
`--resume br-xxx`,ZCode 报 `Session not found: br-xxx`——
**表现为"第一次投递必定失败"**,第二次才可能好,非常像"偶发网络问题"。
正确形状(示例就是这么写的):
```js
let desiredSessionId = args.sessionId || ""; // 插件标签,只用于回报
let zcodeSessionId = ""; // 真实 id,只有它能 resume
// 首轮:不传 --resume;从 {type:"result"} 行取回 sess_...
if (zcodeSessionId) argv.push("--resume", zcodeSessionId);
// 回报时:有真实 id 报它,否则退回标签
session_id: zcodeSessionId || desiredSessionId || null,
```
`params.session_id`(插件在 prompt 里传的)**同样是标签**,不要拿它赋值给
"真实 id"变量——那等于把上面的修法又改回去了。
### ★ 声明的 CLI 路径必须**验存**
`driver.json` 的 `cliPath` 可能指向一个**不存在的**文件(例如随包默认值
`{rawfile}/zcode/zcode.cjs`,而包里并没带那个 28MB 的 CLI)。
若 `if (args.cliPath) return args.cliPath` 直接返回,Node 会在 spawn 时抛
`MODULE_NOT_FOUND`,用户看到的是一坨 `Module._resolveFilename` 堆栈,
**完全看不出"CLI 不存在"**,也不知道去哪儿配。
正确做法:把"声明的"和"探测到的"一起验存,找不到时**列出找过的每个位置
及其来源**,并给出修复动作(改哪个文件 / 设哪个环境变量)。
## driver.json 字段
```jsonc
{
"name": "zcode", // 后端名(与目录名一致)
"script": "sidecar.mjs", // 相对本目录
"node": "auto", // auto = 随包优先;ssh 场景会走远端 PATH
"sessionNameFlag": false, // 声明是否支持 --session-name(不支持就不传)
"sessionDirSuffix": ".zcode-sessions",
"llmConfig": "none", // pi | zcode | none —— 谁准备 LLM 源
"env": { "ZCODE_DATA_BASE_DIR": "{dataDir}/runtime/zcode" },
"args": ["--workdir", "{workdir}", "--session-id", "{sessionId}", "--mode", "guarded"]
}
```
占位符:`{workdir}` `{sessionId}` `{sessionName}` `{dataDir}` `{pluginDir}`
- **`sessionNameFlag: false` 是真实的坑**:ZCode CLI 没有 `--session-name`,
传了会 `Unknown option` 直接退到 usage——**一行参数毁掉整条命令**。
- **`llmConfig`**:`pi` = 插件先写 `models.json`/`auth.json`(复用内核 LLM 源);
`none` = 后端自己管。**没有 `zcode` 这个实现**——我们只知道
`ZCODE_BUILTIN_PROVIDER_CONFIG_FILE` 这个环境变量,**没实测过它的文件格式**,
凭猜测写是"静默写坏"(ZCode 起不来会表现为"模型不回复",排查方向完全错)。
要用内核源,请先实测格式再实现写入器。
## ZCode 示例的实测契约(真机 `zcode 0.16.9`,非猜测)
```
zcode.cjs --prompt <文本> --output-format stream-json --cwd <目录> --mode <值> [--resume <sess_id>]
```
| 点 | 实况(真机核实) |
|---|---|
| `--mode` | 合法值 **`build\|edit\|plan\|yolo\|guarded`**;**不传默认 `yolo`**(`run.ts: DEFAULT_HEADLESS_PROMPT_MODE`)= 绕过全部授权询问。示例用 `guarded`。 |
| `--output-format` | **存在**(`--help` 里**不显示**,是隐藏参数 —— 别因为 help 没有就以为不存在)。`stream-json` 才有逐事件 NDJSON + **末行** `{type:"result", sessionId, response}`,sidecar 就靠这一行。 |
| `--resume` | 会话延续。**只接受真实 `sess_...` id**,首轮不能传(见上文会话 id 一节) |
| `--allowed-tools` | ⚠ **不存在**——只能用 `--disallowed-tools` |
| `--max-turns` | ⚠ **不存在** |
| `--session-name` | ⚠ **不存在**(`sessionNameFlag: false`) |
> **教训**:`--output-format` 这一条曾被基于 `--help` 误判为"不存在",
> 差点把 sidecar 改成解析非结构化文本。**判定参数存在性必须用真机试跑**
> (`--prompt hi --output-format stream-json` 直接看输出),`--help` 只覆盖公开参数。
> ⚠️ **超时必须杀进程树**:ZCode 会派生工具子进程(bash 等),
> 只杀父进程会留下孤儿继续跑,且仍占着工作目录。示例里用 `detached: true` +
> `process.kill(-pid)` 整组杀。
## 远端(SSH)场景的额外注意
`runtime=ssh` 时用的是**同一份** driver 声明与 sidecar 契约,但:
1. **环境变量必须显式前置**(`ssh host env K=V … node …`)——ssh 不继承本地环境。
2. **node 用远端的**(裸 `node` 走远端 PATH),不是本地随包分发的绝对路径。
3. **脚本要先推到远端**(插件会自动 `scp` 一次,见 `pushSidecar`)。
4. **不交互**:`BatchMode=yes`。没配免密必须立刻失败,否则会挂在密码提示上
直到工具调用超时——那表现为"拉起 agent 卡住"而不是"没配免密"。
## 验证
```bash
cd <插件源码>
# 结构性判据(不需要真 CLI)
go test -count=1 -run 'TestCoreDoesNotHardcode|TestSwappingDriverSwapsBackend|TestSSH|TestZcode|TestShippedDriver' ./...
# ★ 真机端到端(需要真实 ZCode CLI + 可用 provider 配置)
HA_ZCODE_CLI=<...>/zcode.cjs \
HA_ZCODE_DATA_DIR=<插件数据>/runtime/zcode \
go test -tags zcode_e2e -run TestZcodeFullPipeline -v .
# ★ SSH 远端(需要 sshd + localhost 免密)
HA_SSH_E2E=1 HA_ZCODE_CLI=... HA_ZCODE_DATA_DIR=... \
go test -tags zcode_e2e -run TestZcodeOverSSH -v .
```
`TestSwappingDriverSwapsBackend` 用一个假 CLI 端到端证明:
换 driver 就能换后端,插件核心零改动。
`TestZcodeSidecarArgvBehavior` 用**假 CLI 记录真实 argv** 来判"首轮不得 `--resume`"——
为什么不用源码字符串匹配:初版就是匹配字面串,把变量改个名就绕过了(判据假绿)。
## 给 ZCode 配 LLM 源(实测可用的最小配置)
ZCode 用自己的 provider 配置,**插件不碰它**(`llmConfig: "none"`)。
数据目录由 `ZCODE_DATA_BASE_DIR` 决定,配置落在`<该目录>/.zcode/v2/provider_config.json`:
```json
{
"schemaVersion": 1,
"config": {
"providerConfigRules": { "providerRules": [ {
"providerId": "llmsproxy", "providerName": "llmsproxy", "enabled": true,
"config": {
"group": "standard-personal",
"personalModelIds": ["AUTO"], "modelOrder": ["AUTO"],
"access": { "type": "api-key", "apiKey": "sk-..." },
"api": { "type": "openai-chat-completions", "baseUrl": "http://127.0.0.1:8081/v1" }
}
} ] },
"defaultModelSelection": { "providerId": "llmsproxy", "modelId": "AUTO" }
}
}
```
实测踩过的三个坑(全部真机复现):
1. **`schemaVersion` 必须有**,且 `providerRules` 嵌在 `config.providerConfigRules` 下
—— 放顶层会被静默"恢复"成默认值(文件被改写但你没察觉)。
2. **`group: "standard-personal"` 必须有**。少了它,配置**被读到但不被准入**,
报 `Error: Model creation failed`;加 `--verbose` 才能看到真因
`Select a model before continuing`。
3. `api.type` 合法值:`anthropic-messages` / `openai-chat-completions` / `openai-responses`。