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>
This commit is contained in:
JianFeeeee
2026-10-17 19:44:15 +08:00
committed by GitHub
parent 65d2bd9e61
commit 81a3efa4df
91 changed files with 21184 additions and 224 deletions

View File

@ -0,0 +1,590 @@
package main
// sidecar:每个工程对话一份的 pi 运行时外壳。
//
// 协议照抄 internal/plugins/clawhubadapter/sidecar.go(本仓已验证的成熟做法):
// JSON-RPC over stdin/stdout + pending-id 配对 + readLoop + 优雅 Close。
// 不自创协议 —— 那边踩过的坑(长行截断、pending 表泄漏、退出时唤醒等待者)不必再踩。
//
// ## 为什么需要 sidecar
//
// pi 是 Node 程序。我们**不把 pi 的 RPC 循环搬进 Go**(那是重复实现),
// 只用一个薄薄的 Node 脚本把 pi 包成一个「可反复 prompt 的会话」,Go 侧用
// JSON-RPC 跟它说话。这样:
//
// Go 插件 ──JSON-RPC/stdio──► sidecar(node) ──► pi 会话(内嵌 createAgentSession)
//
// ## 刀 1 的形态
//
// 本地内嵌。远端 ssh 是后续刀次(同一份 sidecar,只换传输层)。
// sidecar 脚本路径:优先随包分发的 runtime/sidecar.mjs,其次插件目录下的 sidecar.mjs。
import (
"bufio"
"encoding/json"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
"sync"
"time"
)
type sidecarRequest struct {
JSONRPC string `json:"jsonrpc"`
ID int `json:"id"`
Method string `json:"method"`
Params interface{} `json:"params,omitempty"`
}
type sidecarResponse struct {
JSONRPC string `json:"jsonrpc"`
ID int `json:"id"`
Result *json.RawMessage `json:"result,omitempty"`
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error,omitempty"`
}
type sidecarProcess struct {
branchID string
workdir string
cmd *exec.Cmd
stdin *bufio.Writer
stdout io.Reader
mu sync.Mutex
nextID int
pending map[int]chan []byte
closed bool
stopped bool
exited chan struct{}
waitErr error
timeout time.Duration
// handshakeTimeout 用于 ping 握手(比普通调用短得多)。
// 为何单列:握手撞上工具调用的 60s 上限会退化成“超时但进程已起”。
handshakeTimeout time.Duration
}
func newSidecarProcess(branchID, workdir string, cmd *exec.Cmd,
stdin *bufio.Writer, stdout io.Reader, timeout time.Duration) *sidecarProcess {
sp := &sidecarProcess{
branchID: branchID,
workdir: workdir,
cmd: cmd,
stdin: stdin,
stdout: stdout,
pending: map[int]chan []byte{},
exited: make(chan struct{}),
timeout: timeout,
}
go sp.readLoop(stdout)
go func() {
sp.waitErr = cmd.Wait()
close(sp.exited)
}()
return sp
}
// readLoop 持续读 stdout。
//
// ⚠️ 必须持续读:不读会因管道缓冲填满而**堵住 pi**(这是 RPC 类协议最常见的死法)。
func (s *sidecarProcess) readLoop(r io.Reader) {
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for sc.Scan() {
line := sc.Bytes()
if len(line) == 0 {
continue
}
var resp sidecarResponse
if err := json.Unmarshal(line, &resp); err != nil {
// 不是协议记录:当诊断信息,不打断
log.Printf("[commondev] sidecar %s 非协议输出: %s", s.branchID, truncate(string(line), 200))
continue
}
if resp.ID == 0 {
// 主动通知(暂未使用,保留扩展位)
continue
}
s.mu.Lock()
ch := s.pending[resp.ID]
delete(s.pending, resp.ID)
s.mu.Unlock()
if ch != nil {
cp := make([]byte, len(line))
copy(cp, line)
ch <- cp // 不 close:避免 close/send 竞态
}
}
// stdout 结束 ⇒ 进程已亡,唤醒所有等待者
s.mu.Lock()
for id, ch := range s.pending {
delete(s.pending, id)
ch <- nil
}
s.mu.Unlock()
}
func (s *sidecarProcess) call(method string, params interface{}) (interface{}, error) {
if s.handshakeTimeout > 0 && method == "ping" {
return s.callWithTimeout(method, params, s.handshakeTimeout)
}
return s.callWithTimeout(method, params, s.timeout)
}
func (s *sidecarProcess) callWithTimeout(method string, params interface{}, timeout time.Duration) (interface{}, error) {
raw, err := s.callRawTimeout(method, params, timeout)
if err != nil {
return nil, err
}
if raw == nil {
return nil, nil
}
var resp sidecarResponse
if err := json.Unmarshal(raw, &resp); err != nil {
return nil, fmt.Errorf("sidecar %s 应答解析失败: %w", s.branchID, err)
}
if resp.Error != nil {
return nil, fmt.Errorf("sidecar %s: %s", s.branchID, resp.Error.Message)
}
if resp.Result == nil {
return nil, nil
}
var out interface{}
if err := json.Unmarshal(*resp.Result, &out); err != nil {
return nil, err
}
return out, nil
}
func (s *sidecarProcess) callRaw(method string, params interface{}) ([]byte, error) {
return s.callRawTimeout(method, params, s.timeout)
}
func (s *sidecarProcess) callRawTimeout(method string, params interface{}, timeout time.Duration) ([]byte, error) {
ch := make(chan []byte, 1)
s.mu.Lock()
if s.closed || s.stopped {
s.mu.Unlock()
return nil, fmt.Errorf("sidecar %s 已关闭", s.branchID)
}
s.nextID++
id := s.nextID
s.pending[id] = ch
req := sidecarRequest{JSONRPC: "2.0", ID: id, Method: method, Params: params}
data, err := json.Marshal(req)
if err != nil {
delete(s.pending, id)
s.mu.Unlock()
return nil, err
}
if _, err := s.stdin.Write(data); err != nil {
delete(s.pending, id)
s.mu.Unlock()
return nil, err
}
if _, err := s.stdin.Write([]byte("\n")); err != nil {
delete(s.pending, id)
s.mu.Unlock()
return nil, err
}
if err := s.stdin.Flush(); err != nil {
delete(s.pending, id)
s.mu.Unlock()
return nil, err
}
s.mu.Unlock()
select {
case raw := <-ch:
if raw == nil {
return nil, fmt.Errorf("sidecar %s 进程已退出", s.branchID)
}
return raw, nil
case <-time.After(timeout):
s.mu.Lock()
delete(s.pending, id)
s.mu.Unlock()
return nil, fmt.Errorf("sidecar %s 调用 %s 超时", s.branchID, method)
case <-s.exited:
return nil, fmt.Errorf("sidecar %s 进程已退出", s.branchID)
}
}
// Alive 报告进程是否还活着。
func (s *sidecarProcess) Alive() bool {
if s == nil {
return false
}
select {
case <-s.exited:
return false
default:
return true
}
}
// Close 优雅收尾:先关 stdin 请求有序退出,超时则强杀。
//
// ★ 孤儿清理是插件的责任:绝不允许留下 pi 进程。
func (s *sidecarProcess) Close() {
if s == nil {
return
}
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return
}
s.closed = true
s.mu.Unlock()
// ★ 关 stdin = 请求有序退出(与 clawhubadapter 同纪律)。
//
// 这里的 nil 判定不只是防御式编程:Close() 的签名承诺了 nil-safe
// (上面 `if s == nil`),但**未完整初始化的对象**(stdin 为 nil
// interface)同样会在这里 panic —— 而调用方看不到差别
// (两者都是 *sidecarProcess)。
//
// 实测来源:`deliverToPi` / `handleBranchClose` 都可能在 startSidecar
// 中途失败时拿到半初始化对象并调 Close;空指针 panic 会盖住真正的错误。
if s.stdin != nil {
_ = s.stdin.Flush()
}
// ★ 若从未真正启动(cmd/exited 为空),没有可等、可杀的东西 —— 直接收尾。
//
// 为何必须显式判:Close() 在 `if s == nil` 之后就不再检查内部字段,
// 而 nil-safe 的承诺对**半初始化对象**同样适用(调用方只看到
// *sidecarProcess,分不出"从未启动"与"已退出")。
// 实测:startSidecar 中途失败时,handleBranchClose 会拿到这种对象;
// 不判则 `s.cmd.Process` 解引用 nil 指针 —— panic 会盖住真正的错误。
if s.cmd == nil || s.exited == nil {
s.markStopped()
return
}
select {
case <-s.exited:
// 已退
case <-time.After(3 * time.Second):
if s.cmd.Process != nil {
_ = s.cmd.Process.Kill()
}
select {
case <-s.exited:
case <-time.After(2 * time.Second):
log.Printf("[commondev] sidecar %s 强杀后仍未退出", s.branchID)
}
}
s.markStopped()
}
// markStopped 置停止标记并唤醒所有挂起的调用(幂等)。
func (s *sidecarProcess) markStopped() {
s.mu.Lock()
s.stopped = true
for id, ch := range s.pending {
delete(s.pending, id)
ch <- nil
}
s.mu.Unlock()
}
// ---------------- 启动 ----------------
// startSidecar 拉起某个工程对话的运行时(**按 agent_cli 选驱动**)。
//
// ★ 本函数**不知道 pi 的存在**:它只读 driver.json(或内置 pi 描述),
// 按声明起进程、注环境、传参数。换 CLI = 换一份 driver.json,这里一行不改。
//
// 契约(sidecar 脚本必须实现,driver 只负责把进程拉起来):
//
// ping → {ok, session_id, ...}
// prompt → {ok, session_id, reply} ← session_id 必须回,Go 侧据此断言会话未漂移
// state → {ok, session_id, messages}
// close → {ok}
func (p *Plugin) startSidecar(b *Branch, sessionID string) (*sidecarProcess, error) {
p.mu.Lock()
existing := p.sidecars[b.ID]
p.mu.Unlock()
if existing != nil && existing.Alive() {
return existing, nil // 复用
}
cli := strings.TrimSpace(b.AgentCLI)
if cli == "" {
cli = defaultAgentCLI
}
drv, dir, err := p.loadDriver(cli)
if err != nil {
return nil, err
}
// 脚本查找链(按优先级):
//
// ① <插件数据>/runtime/<cli>/<script> 用户自备/覆盖(最高优先)
// ② <插件目录>/rawfile/runtime/<cli>/<script> ★ 随包分发(见下)
// ③ <可执行文件同目录>/<script> 开发期(hmapdev debug)
//
// ★ ② 是必需的,不是锦上添花:sidecar 脚本此前**没有随包分发**
// (打包只收 rawfile/ 与 skill/),而 ① 在部署时没人创建 ⇒
// `agent_up` 在全新部署上**必然失败**(报「找不到 sidecar 脚本」),
// 表现为"插件装了、工具在、但一拉 agent 就错"。
//
// 放在 rawfile/ 而不是新建约定目录:rawfile 已是"随包任意资源"的通道,
// 且 nodeBin 已经用同一机制定位内嵌 node —— 复用同一条路,不新增概念。
script, scriptErr := p.locateSidecarScript(drv, dir, cli)
if scriptErr != nil {
return nil, scriptErr
}
dataDir := filepath.Dir(p.path)
rawfileDir := ""
if rf := p.rawFiles(); rf != nil {
rawfileDir = rf.Dir()
}
ctx := driverCtx{
Workdir: b.Workdir, SessionID: sessionID,
SessionName: "br-" + b.ID, DataDir: dataDir, PluginDir: dir,
RawfileDir: rawfileDir,
}
// ★ 传输层分派:local(exec)| ssh(远端同一份 sidecar)。
// 驱动声明(跑什么)与传输(在哪跑)是正交的 —— 这里只分两路,
// 不随 CLI/后端数量增长。
if b.Runtime == "ssh" {
return p.startSSHSidecar(b, drv, ctx, cli)
}
node := p.resolveNode(drv)
if node == "" {
return nil, fmt.Errorf("找不到 node(随包分发应为 <插件>/rawfile/node/bin/node;也可设 node_bin 或用 PATH)")
}
// 会话目录放工作区内,避免污染用户全局会话库。
if drv.SessionDirSuffix != "" {
if err := os.MkdirAll(filepath.Join(b.Workdir, drv.SessionDirSuffix), 0o700); err != nil {
return nil, err
}
}
// LLM 源由**声明**决定谁准备(不是 if cli == "pi" 这种分支)。
if err := p.prepareLLMConfig(drv, ctx); err != nil {
return nil, err
}
args := append([]string{script}, drv.buildArgs(ctx)...)
cmd := exec.Command(node, args...)
cmd.Dir = b.Workdir
cmd.Stderr = os.Stderr
cmd.Env = append(os.Environ(), drv.buildEnv(ctx)...)
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("启动 sidecar 失败: %w", err)
}
timeout := defaultSidecarTimeout
if v, _ := p.sdk.Settings().Get("sidecar_timeout_sec"); v != nil {
if n, err := strconv.Atoi(fmt.Sprint(v)); err == nil && n > 0 {
timeout = time.Duration(n) * time.Second
}
}
sp := newSidecarProcess(b.ID, b.Workdir, cmd, bufio.NewWriter(stdin), stdout, timeout)
// ★ 握手必须短且确定 —— 绝不能用默认 timeout(那是 60s,正好撞上内核的
// 工具调用 60s 硬上限 ⇒ 工具超时但进程已起,状态"做了一半")。
sp.handshakeTimeout = 10 * time.Second
if ping, err := sp.call("ping", nil); err != nil {
sp.Close()
return nil, fmt.Errorf("%s sidecar 握手失败(10s): %w", cli, err)
} else if m, ok := ping.(map[string]interface{}); ok {
// 版本字段名按后端而异,两者都认(不要求后端改名)
for _, k := range []string{"pi_version", "cli_version", "version"} {
if v, ok := m[k].(string); ok && v != "" {
b.PiVersion = v
break
}
}
}
return sp, nil
}
// prepareLLMConfig 按驱动的 LLMConfig 声明准备 LLM 源。
//
// ★ 用**声明**而不是分支:核心只说「这个后端需要我先喂源」,
// 至于怎么喂(写哪些文件、什么格式)由各后端的实现决定。
// none = 后端自己管(如 ZCode 用内置 provider 配置)。
func (p *Plugin) prepareLLMConfig(drv driverSpec, ctx driverCtx) error {
switch drv.LLMConfig {
case "", "none":
return nil
case "pi":
agentDir := filepath.Join(ctx.DataDir, "runtime", "agentdir")
if err := os.MkdirAll(agentDir, 0o700); err != nil {
return err
}
// ★ 导入内核的 LLM 源(用户要求:默认用 homeagent 内部配置的源)。
// 不导入的话 pi 会报 `No API key found for the selected model`
//(实测:agentdir/auth.json 只有 2 字节)。
if err := p.ensurePiLLMConfig(agentDir); err != nil {
return fmt.Errorf("导入内核 LLM 源失败(不静默回退到宿主凭据): %w", err)
}
return nil
default:
// ★ 内嵌只实现 pi 的 LLM 喂法。别的后端要么自己管(none),
// 要么由用户在自己的 sidecar 里处理 —— 插件不替未知 CLI 猜格式。
return fmt.Errorf(
"未知的 llmConfig 声明 %q(内嵌只实现 pi | none)。"+
"自定义后端请用 none 并在自己的 sidecar 里准备配置 —— "+
"插件不替未知 CLI 猜配置文件格式(猜错会表现为「模型不回复」)",
drv.LLMConfig)
}
}
// ensurePiLLMConfig 确保内置 pi 有可用的 LLM 配置。
//
// 已配置就**不覆盖**(用户可能手工调过、或用了 commondev_pi_source_add 加的源)。
func (p *Plugin) ensurePiLLMConfig(agentDir string) error {
modelsPath := filepath.Join(agentDir, "models.json")
if b, err := os.ReadFile(modelsPath); err == nil {
var f piModelsFile
if json.Unmarshal(b, &f) == nil && len(f.Providers) > 0 {
return nil // 已有配置
}
}
n, def, err := p.importKernelSources(agentDir)
if err != nil {
return err
}
log.Printf("[commondev] 已导入内核 LLM 源:%d 个,默认=%s", n, def)
return nil
}
// sidecarScript 定位 sidecar 脚本。
func (p *Plugin) sidecarScript() string {
cands := []string{
filepath.Join(filepath.Dir(p.path), "runtime", "sidecar.mjs"),
filepath.Join(filepath.Dir(p.path), "sidecar.mjs"),
}
// 开发期:与插件源码同目录(hmapdev debug 场景)
if exe, err := os.Executable(); err == nil {
cands = append(cands, filepath.Join(filepath.Dir(exe), "sidecar.mjs"))
}
for _, c := range cands {
if _, err := os.Stat(c); err == nil {
return c
}
}
return ""
}
// nodeBin 定位 node:随包分发优先,其次 PATH。
// nodeBin 定位 node,**随包内嵌优先**。
//
// 优先级(用户要求:白板机器也能用):
//
// ① setting node_bin(显式覆盖,排查用)
// ② <插件目录>/rawfile/node/bin/node ← 随包内嵌(白板机器靠这条)
// ③ PATH 里的 node ← 回退(老部署/开发机)
//
// ★ ② 的路径由 rawfile 机制提供:hmapdev 把工程根 `rawfile/` 打进包,
// 内核解包保留结构 ⇒ 落在插件目录下。所以插件"内嵌 node"只需把
// node 放进工程的 rawfile/ 并从 plg.json 声明(或直接放 rawfile/ 下)。
func (p *Plugin) nodeBin() string {
if v, _ := p.sdk.Settings().Get("node_bin"); v != nil {
if s, _ := v.(string); strings.TrimSpace(s) != "" {
return s
}
}
// ② 随包内嵌(rawfile 机制)
if rf := p.rawFiles(); rf != nil && rf.Exists("node/bin/node") {
if cand := filepath.Join(rf.Dir(), "node", "bin", "node"); fileExists(cand) {
return cand
}
}
// 兼容旧路径(rawfile 之前的约定)
if cand := filepath.Join(filepath.Dir(p.path), "runtime", "node", "bin", "node"); fileExists(cand) {
return cand
}
if cand, err := exec.LookPath("node"); err == nil {
return cand
}
return ""
}
// rawFiles 取随包资源接口(可能为 nil —— 旧内核未注入)。
func (p *Plugin) rawFiles() sdk.RawFilesAPI {
if p.sdk == nil {
return nil
}
return p.sdk.RawFiles()
}
func fileExists(p string) bool {
st, err := os.Stat(p)
return err == nil && !st.IsDir()
}
// locateSidecarScript 按优先级链定位 sidecar 脚本。
//
// 查找链(按优先级):
//
// ① <插件数据>/runtime/<cli>/<script> 用户自备/覆盖(最高优先)
// ② <插件目录>/rawfile/runtime/<cli>/<script> ★ 随包分发
// ③ <可执行文件同目录>/<script> 开发期(hmapdev debug)
//
// ★ ② 是必需的,不是锦上添花。实测(2026-10-15)发现:sidecar 脚本此前
// **没有随包分发** —— 打包只收 rawfile/ 与 skill/,而 ① 在部署时没人创建。
// 于是全新部署上 `agent_up` **必然失败**(「找不到 pi 的 sidecar 脚本」),
// 表现为"插件装了、工具都在、但一拉 agent 就错",且错误信息指向一个
// 用户从没听说过的目录。
//
// 为什么放在 rawfile/ 而不是新开一个约定目录:rawfile 已经是
// "随包任意资源"的通道,且 nodeBin 已经用**同一机制**定位内嵌 node ——
// 复用同一条路,不新增概念、不新增内核理解负担。
func (p *Plugin) locateSidecarScript(drv driverSpec, cliDir, cli string) (string, error) {
// ① 用户自备(插件数据目录)——允许覆盖随包脚本
if cand := filepath.Join(cliDir, drv.Script); fileExists(cand) {
return cand, nil
}
// ② 随包分发(rawfile/runtime/<cli>/)
if rf := p.rawFiles(); rf != nil {
rel := filepath.ToSlash(filepath.Join("runtime", cli, drv.Script))
if rf.Exists(rel) {
return filepath.Join(rf.Dir(), "runtime", cli, drv.Script), nil
}
}
// ③ 开发期:与可执行文件同目录
if exe, e := os.Executable(); e == nil {
if alt := filepath.Join(filepath.Dir(exe), drv.Script); fileExists(alt) {
return alt, nil
}
}
// 全部落空:错误必须**列出找过哪里**,否则用户只能猜
return "", fmt.Errorf(
"找不到 %s 的 sidecar 脚本 %s;已找过:① %s ② <插件>/rawfile/runtime/%s/%s ③ <可执行文件目录>/%s。"+
"若要自带 CLI,请把 driver.json 与脚本放到 <插件数据>/runtime/%s/",
cli, drv.Script, filepath.Join(cliDir, drv.Script), cli, drv.Script, drv.Script, cli)
}