Files
homeagent-sdk/example/commondev/examples/custom-cli/zcode/sidecar.mjs
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

288 lines
11 KiB
JavaScript
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.

#!/usr/bin/env node
// ZCode sidecar:把 **ZCode CLI** 包成与 pi sidecar **同一个 JSON-RPC 契约**的服务。
//
// ## 为什么必须同契约
//
// 插件的 `startSidecar` / `sc.call("prompt")` 是唯一的调用面 ——
// 只要这个脚本实现 ping/prompt/state/close,**插件核心一行不用改**
// 就能跑 ZCode。这就是「通过不同 sidecar 支持不同 CLI」的落点。
//
// ## 与 pi sidecar 的关键差异(实测契约,来自 agentmail/zcode-mail-bridge)
//
// pi sidecar ZCode sidecar
// ──────────────────── ──────────────────────────────
// 常驻进程 + prompt() **每轮起一个新进程**(CLI 是一次性的)
// --session-id 建会话 --resume <id> 延续;首轮不给
// SDK 直接返回文本 --output-format stream-json,末行 {type:"result"}
// 无授权概念 --mode **必传**(不传 = yolo,绕过全部授权询问)
//
// ## 会话 id 的处理(★ 与插件的契约)
//
// 插件在 prompt 后**断言 session_id 未变**(防会话漂移)。
// ZCode 首轮才产生真实 sessionId,所以这里:
// · 首轮:把 CLI 返回的 sess_xxx 回报(插件接受它作为会话标识)
// · 后续:用 --resume 传回去,并回报同一个 id
//
// ## 超时必须杀进程树
//
// ZCode 会派生工具子进程(bash 等)。只杀父进程会留下一堆孤儿继续跑,
// 而且它们仍占着工作目录。
import { spawn } from "node:child_process";
import { createInterface } from "node:readline";
import { existsSync } from "node:fs";
import path from "node:path";
function parseArgs(argv) {
const out = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--workdir") out.workdir = argv[++i];
else if (a === "--session-id") out.sessionId = argv[++i];
else if (a === "--session-name") out.sessionName = argv[++i];
else if (a === "--cli") out.cliPath = argv[++i];
else if (a === "--mode") out.mode = argv[++i];
}
return out;
}
const args = parseArgs(process.argv.slice(2));
const workdir = args.workdir || process.cwd();
// ★ mode 必须显式给:不传时 CLI 默认 **yolo**(源码 src/run.ts:
// DEFAULT_HEADLESS_PROMPT_MODE),那会绕过全部授权询问。
// 合法值:build | edit | plan | yolo | guarded。默认用 guarded。
const VALID_MODES = ["build", "edit", "plan", "yolo", "guarded"];
const mode = args.mode || process.env.ZCODE_MODE || "guarded";
if (!VALID_MODES.includes(mode)) {
throw new Error(`--mode 必须是 ${VALID_MODES.join("|")} 之一(收到 ${mode})`);
}
// CLI 入口:驱动可经 --cli 指定,否则按常见安装位置找。
//
// ★ 返回 {path, tried} 而不是裸字符串:**必须知道路径是"声明的"还是"探测到的"**,
// 才能在报错里说清楚该改哪里。
//
// 实测真缺陷(2026-10-16):随包 driver.json 声明了 `{rawfile}/zcode/zcode.cjs`,
// 但包里**没有**这个文件(28MB,按需分发)。旧实现 `if (args.cliPath) return args.cliPath`
// 直接把它当有效路径返回 ⇒ Node 在 spawn 时抛模块解析失败,用户看到的是一坨
// `Module._resolveFilename` 堆栈,**完全看不出"CLI 不存在"**,更不知道去哪儿配。
// 声明的路径同样必须验存。
function findCLI() {
const cands = [
{ p: args.cliPath, source: "driver.json 的 cliPath" },
{ p: process.env.ZCODE_CLI, source: "环境变量 ZCODE_CLI" },
{ p: path.join(process.env.HOME || "", ".zcode/runtime/bin/zcode.mjs"), source: "默认探测" },
{ p: "/opt/ZCode/resources/glm/zcode.cjs", source: "默认探测" },
{ p: "/usr/local/bin/zcode", source: "默认探测" },
];
const tried = [];
for (const c of cands) {
if (!c.p) continue;
tried.push(`${c.p}(${c.source})`);
if (existsSync(c.p)) return { path: c.p, tried };
}
return { path: "", tried };
}
// ★ 必须区分两个不同的东西:
//
// desiredSessionId —— 插件传进来的**标签**(br-<project>-<branch>),
// 它是插件台账里的会话名,**不是** ZCode 的会话 id
// zcodeSessionId —— ZCode 真实产出的 `sess_...`,只有它能用于 --resume
//
// 初版把 args.sessionId 直接当成 --resume 的目标,于是**首轮就带上
// `--resume br-xxx`**,ZCode 报 `Session not found: br-xxx` ——
// 表现为"第一次投递必定失败",而第二次才可能成功(如果插件把
// 上一轮返回的 sess_... 传回来了)。
//
// 实测(2026-10-16):真实 ZCode 0.16.9 上首轮即失败。
// 契约(参考实现在注释里也写了):**首轮不给 --resume**,
// 从 `{type:"result"}` 行取回 sess_...,之后才用它续谈。
let desiredSessionId = args.sessionId || "";
let zcodeSessionId = "";
let turnCount = 0;
function send(obj) {
process.stdout.write(JSON.stringify(obj) + "\n");
}
/** 杀**进程树**(ZCode 会派生工具子进程)。 */
function killTree(child) {
if (!child || child.killed) return;
try {
// 负号 = 进程组;spawn 时 detached 建了独立进程组。
process.kill(-child.pid, "SIGTERM");
} catch {
try { child.kill("SIGTERM"); } catch {}
}
setTimeout(() => {
try { process.kill(-child.pid, "SIGKILL"); } catch {}
}, 3000).unref?.();
}
/** 跑一轮 ZCode,返回 {sessionId, response}。 */
function runTurn(text, timeoutMs) {
return new Promise((resolve, reject) => {
const found = findCLI();
if (!found.path) {
reject(new Error(
"找不到 ZCode CLI。已找过:\n - " + found.tried.join("\n - ") +
"\n请把 driver.json 的 cliPath 指向真实 CLI(本机覆盖写到 " +
"<插件数据>/runtime/zcode/driver.json),或设 ZCODE_CLI 环境变量。"));
return;
}
const argv = [
found.path,
"--prompt", text,
"--output-format", "stream-json",
"--cwd", workdir,
"--mode", mode,
];
// ★ 会话延续:首轮不给 --resume(还没有 id),后续都给。
// ★ 只有拿到 ZCode 真实 id 才 resume;首轮没有 → 不传。
if (zcodeSessionId) argv.push("--resume", zcodeSessionId);
const child = spawn(process.execPath, argv, {
cwd: workdir,
detached: true, // 独立进程组 ⇒ 能整组杀
stdio: ["ignore", "pipe", "pipe"],
});
let out = "";
let err = "";
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
killTree(child);
reject(new Error(`ZCode 回合超时(${Math.round(timeoutMs / 1000)}s)`));
}, timeoutMs);
child.stdout.on("data", (d) => { out += d.toString(); });
child.stderr.on("data", (d) => { err += d.toString(); });
child.on("error", (e) => {
if (settled) return;
settled = true;
clearTimeout(timer);
reject(e);
});
child.on("close", (code) => {
if (settled) return;
settled = true;
clearTimeout(timer);
// 逐行解析 stream-json:事件行随时来,末行是 {type:"result"}
let result = null;
let events = 0;
for (const line of out.split("\n")) {
const t = line.trim();
if (!t || t[0] !== "{") continue;
let obj;
try { obj = JSON.parse(t); } catch { continue; }
if (obj && obj.type === "result") result = obj;
else events++;
}
if (!result) {
reject(new Error(
`ZCode 未返回 result 行(exit=${code},事件 ${events} 条)` +
(err ? `;stderr: ${err.slice(-400)}` : "")));
return;
}
resolve({
sessionId: typeof result.sessionId === "string" && result.sessionId
? result.sessionId
: zcodeSessionId,
response: typeof result.response === "string" ? result.response : "",
events,
});
});
// 供 close 时中断当前回合
currentChild = child;
});
}
let currentChild = null;
async function handle(method, params) {
switch (method) {
case "ping": {
// 不启动 CLI:握手要快且确定(10s 上限)。
// 报出 CLI 版本 —— 插件的 ping 处理会认 pi_version/cli_version/version 三者之一。
return {
ok: true,
cwd: workdir,
// 握手阶段还没有 ZCode 会话;报插件给我们的标签即可
// (插件侧台账用它,真实 sess_... 在首轮 prompt 后才产生)。
session_id: zcodeSessionId || desiredSessionId || null,
cli_version: process.env.ZCODE_VERSION || null,
session_name: args.sessionName || null,
backend: "zcode",
};
}
case "prompt": {
const text = params?.text ?? "";
// ★ params.session_id 是**插件的标签**(br-xxx),不是 ZCode 的会话 id。
// 绝不能拿它当 --resume 目标 —— 那正是首轮必然失败的根因。
// 只在还没有真实 id 时记下它,供握手/state 回报用。
if (params?.session_id && !zcodeSessionId) {
desiredSessionId = String(params.session_id);
}
const timeoutMs = Number(params?.timeout_ms) > 0
? Number(params.timeout_ms)
: 20 * 60 * 1000;
const r = await runTurn(text, timeoutMs);
// ★ 只在拿到真实 sess_... 时更新;空值不能覆盖已有 id
// (否则一次失败就把续谈能力抹掉)。
if (r.sessionId) zcodeSessionId = r.sessionId;
turnCount++;
return {
ok: true,
// ★ 必须回报 session_id:Go 侧据此断言「会话未漂移」
// 报真实 id(有则报它,否则退回插件的标签)。
session_id: zcodeSessionId || desiredSessionId || null,
reply: r.response,
};
}
case "state": {
return {
ok: true,
session_id: zcodeSessionId || desiredSessionId || null,
messages: turnCount,
};
}
case "close": {
killTree(currentChild);
return { ok: true };
}
default:
throw new Error(`unknown method: ${method}`);
}
}
const rl = createInterface({ input: process.stdin });
rl.on("line", async (line) => {
line = line.trim();
if (!line) return;
let req;
try { req = JSON.parse(line); } catch { return; }
try {
const result = await handle(req.method, req.params || {});
send({ jsonrpc: "2.0", id: req.id, result });
if (req.method === "close") process.exit(0);
} catch (e) {
send({
jsonrpc: "2.0",
id: req.id,
error: { code: -32000, message: String(e?.message || e) },
});
}
});
rl.on("close", () => {
killTree(currentChild);
process.exit(0);
});