# commondev 运行时与内核接入设计(工程对话 · 内嵌/SSH · inputch 路由) > **状态:架构已按前篇 §12 调整。** 本文描述的 pi 会话包装、通道注册与运行时包装 > **仍在 commondev 插件里**(它们是通用运行时能力,与 git 平台无关); > 移出插件的是**平台专有**部分(认证头、PR、分支保护)。 > 只讲两件事:**「工程对话」这个一等对象怎么落到运行时**,以及**插件怎么接进内核 > 已有的调度器 / 驻留子 / 通道体系**(而不是另起一套)。 > > 前篇讲的是工具面、台账 schema、判据与变异、PR 流程与安全;本文不重复。 > > **标记约定**(沿用本仓惯例): > - **[已核]** = 本文写作时已在本机实测 / 源码核对确认,附证据 > - **[待定]** = 需要决策,本文给出建议但未拍板 > - **[风险]** = 已知会让整套机制失效的点 > > 撰写时间:2026-10-13。事实基线:TrueAgent `/home/program/TrueAgent`, > 生产实例 `/home/newqqagent`,SDK 仓 `third_party/homeagent-sdk`。 --- ## 0. 与前篇的关系(一处修正) 前篇 §6.1 把「拉起 agent」写成 **CLI vs A2A/ACP** 的二选一,结论是「走 CLI」。 **该结论只论证了 `cwd` 一个维度,漏了「会话续投」这一维度,因此是错的**(不是结论方向错, 是论据不全):`pi -p` 每轮一个进程,**无法保证「再投一条还是同一个会话」** —— 而那正是 用户明确要求的能力。 正确的两轴是: | 轴 | 取值 | |---|---| | **会话粒度** | 一次性进程(`-p`) / 常驻进程(`--mode rpc`) / 进程内(Node SDK) | | **cwd 由谁承担** | `-p`/RPC:**进程 cwd**;SDK:`createAgentSession({cwd})` | A2A/ACP **依然排除**(前篇 §3.4 的「参数里没有 cwd」这条事实没变),但排除理由与本 设计无关:它们是**常驻 peer** 的模型,而本需求是**按工程对话拉起**。 --- ## 1. 已核实的事实(接手者可直接依赖) ### 1.1 会话续投 [已核 · 关键] | 事实 | 证据 | |---|---| | **`--session-id` 就是「续同一会话」的原语** | 两次 `pi -p --session-id X`,第二次准确答出第一次让记的暗号 `紫罗兰-7734`;会话文件 `--tmp-sidprobe--/_commondev-test-01.jsonl` | | **不带 `--session-id` 会静默开新会话** | 同一 cwd 下两次 `-p` 生成**两个不同** UUID 会话文件 | | `--session-dir` 可把会话库搬走且**不污染**全局 `~/.pi/agent/sessions/` | 实测落 `/tmp/sd-store/`,全局 sessions 无新目录 | | **★ 两个 `-p` 并发打同一 session-id 会写坏会话** | `/tmp/conc`:文件里 `user A`、`user B` 相邻,`assistant B` 在前、`assistant A` 落在最后 ⇒ B 那条 run 的**模型上下文里没有 A 的用户消息**,且 `parentId` 树链交错 | | 内层 pi **不继承**外层 `PI_SESSION_FILE` / `PI_SESSION_ID` | 外层跑完只有外层会话被写 | > ⇒ **纪律**:同一个 session_id **绝不允许并发投递**。这条必须做成**结构性保证** > (sidecar 内单一投递 goroutine + mutex),不能靠"调用方记得别并发"。 ### 1.2 两种运行时形态 [已核] > [已定 2026-10-13 用户拍板]**插件内嵌独立的 nodejs 与独立的 pi**, > **安装在插件工作目录**(不是复用系统 node/pi);**pi 的插件也装到这个目录**。 > 默认走**内嵌**,配置可切**远程 SSH**。 | 事实 | 证据 | |---|---| | `createAgentSession({cwd})` 可行(真·进程内) | `/tmp/sdktest/probe.mjs`:读工作区 `marker.txt` 得 `SDKMARK-42`,同会话追问仍得 `SDKMARK-42`,`session.messages.length === 8` | | **`PI_CODING_AGENT_DIR` 可重定向 pi 的 agent dir** | 设该变量后 `pi install ` 把 `packages` 写进了**自定义 agentdir**,用户全局 `~/.pi` **零污染** | | **新 agent dir 没有凭据** ⇒ 必须一并配齐 | 只重定向 agent dir 时内层 pi 报 `No API key found for the selected model`;补上 `models.json` + `auth.json` 后**同一命令返回 `SELF-OK`** | | **项目局部安装插件可行** | `pi install -l ./mypkg`(cwd=工作区)⇒ 写 `/.pi/settings.json`;内层 pi 带 `--approve` 跑,调用该包的工具返回 `PKG-OK` | | **全局安装插件可行** | `pi install `(不带 `-l`)⇒ 写 `~/.pi/agent/settings.json`;配合 `PI_CODING_AGENT_DIR` 可改为写插件自己的 agent dir | | ⚠ **安装是复制,不是引用** | `pi install ./mypkg` 会把包**复制**到工作区。改源目录不会影响已安装的那份(**我曾因此得出"插件不生效"的错误结论**);要更新必须重装 | | 不批准 trust 时项目包被挡 | `--no-approve` 时项目局部安装的扩展不加载 | | RPC 常驻 + 同会话两轮 | `pi --mode rpc --session-id rpc-probe-01`:`agent_settled` 1.6s 后第二轮答出 `蓝鲸-99` | | RPC 事件含**工具级**证据 | `docs/json.md:105-114`:`tool_execution_start`(args)/ `tool_execution_update` / `tool_execution_end`(result, isError) | | `agent_settled` 才是「这轮真的完了」 | `agent_end` 之后还可能有 retry / compaction / 排队消息(`docs/json.md`) | | 本机 sshd 在 `:22`,`ssh`/`sshpass` 都在 | `ss -tlnp`、`which` | | sidecar 模式本仓已有先例 | `internal/plugins/clawhubadapter/sidecar.go`(376 行):JSON-RPC over stdio + pending-id 配对 + `readLoop` + `NotifyChan` + 优雅 `Close` | ### 1.3 内核可接入面 [已核] | 能力 | 插件可用? | 依据 | |---|---|---| | `RegisterInputChannel(name, def)` | ✅ **公开**,一个插件可注册 N 个 | `sdk/plugin.go:665` | | `InjectText*` / `InjectInterrupt*Opts` | ✅ | `sdk/plugin.go:818-868` | | `InjectOptions.Priority`(L1–L3) | ✅ | `sdk/plugin.go:152` | | `RegisterOutputChannel` | ✅ | `sdk/plugin.go:632` | | `UnregisterOutputChannel` | ⚠️ **外部插件静默无效** | `capability-boundary.md`:桥只注入 registrar | | `resident_agents`(list/create/send/inspect/compress/reclaim/destroy) | ❌ **插件调不到** | proc 桥 `grep` 无 toolapi/resident 方法 | | `AssignInputChannel(name, agentID, cap)` | ❌ **未导出到公开 SDK** | 只在 `IOManager`(`io/channel.go:610`) | | `inputch_note` / `notify_parent` | ❌ 只有驻留子有 | `tooldefs.go:629`(`parentID != ""`) | | `Events()` | ❌ 恒 nil | 桥不注入 subscriber | **两个必须避开的陷阱**: 1. **`ToolAPI` 是单例、per-agent 数据靠「最后注入者」** —— `builtin_toolapi.go:117-119` 自陈该局限,而 `SpawnResident` 也会注入。⇒ **不要经 ToolAPI 调 `resident_agents`**: 它可能打到某个驻留子的表上。 2. **不要以为插件稳拿 L3**(真实是两跳两道闸,见 §3.2)。 ### 1.4 驻留子设计里可抄的纪律 [已核] | 纪律 | 出处 | |---|---| | `SendToResident` 固定 `Priority: "L4"`,子侧唯一 L4 来源 = 父 | `resident.go:440-455` | | `KernelSource = parentID`(父在子的阶梯上是唯一 L4) | `resident.go:190` | | **父 `Stop()` ⇒ 全部子销毁**,登记表清空、不留孤儿 | `resident.go`(S16 判据) | | `inspect` 是 pull,**不打断**子 | `resident_agents` 动作语义 | | `compress` = 保留语义(压上下文 + **清单**) | 前篇 §9 | | `reclaim` = 取消语义(父选 promote → 取消子) | 前篇 §9 | | `contextfull` → 父侧 **L4**(只推信号,状态靠 pull) | 前篇 §9 | | inputch 是**最基本的输入路由单位**,路由发生在**进内核之前** | `resident-subagent-design.md §4.1` | --- ## 2. ★「工程对话」是一等对象(本文核心) 前篇把单位定为「项目 → 分支线」。本文把它落到运行时,并给它一个用户可寻址的名字: **工程对话**。 **一个工程对话 = 四元组绑定,缺一不可**: ``` 工程对话 (id = br--) ├─ workdir <插件数据目录>/program/- │ ★ hash = H(项目+分支) 承担**唯一性** ⇒ 同仓库不同分支必然是不同目录 │ ★ name = 分支名派生,只做可读性(不承担唯一性,派生后可能撞) │ 独立 clone(本地 clone 硬链接派生,非 worktree、非 --reference) │ **合入后整个目录删除** ├─ session_id pi 会话(★ 续投的唯一依据;漂移即失败) ├─ runtime local(默认,内嵌 pi) | ssh(配置切换,远端建目录拉 pi) ├─ host runtime=ssh 时的目标主机 └─ channels 插件为该工程对话注册的 **一对通道** ├─ inputch commondev/ ← pi → agent(可用斜杠;只流向该子) └─ outputch commondev_ ← agent → pi(★ 下划线,会拼成 output_send__) ``` > **目录为何必须是独立 clone**(而非 worktree / `--reference`):用户要求 > 「合入后直接删除目录」,而实测 worktree 删后有 `.git/worktrees/` 残留(需 `prune`)、 > `--reference` 则有 alternates 外部依赖(mirror 被清即损坏)。**本地 clone 对象为硬链接** > ⇒ 省磁盘、无外部依赖、刪掉即彻底。详细实测见前篇 §4.3。 ### 2.1 为什么必须是"一对通道",而不是复用一个 [已核]inputch 是**最基本的输入路由单位**,路由按 `Owner` 在**进内核之前**完成 (`inputroute.go:23`):`lookupInputChannel(ch) → Owner → DeliverRouted`。 未分配或归自己的,才由本内核处理;归属到不存在的 agent 时**兜底给根并留日志** (不静默吞)。 ⇒ **每个工程对话一个独立 inputch**,就得到了: - **精确投递**:往 `commondev/br-1` 注入只到 br-1 的 agent,别人看不到; - **互不串台**:两条并行的工程对话不会互相污染上下文; - **可限额**:`Capacity` 独立; - **零内核改动**:`RegisterInputChannel` 是公开 API。 ### 2.2 ★ 顺序:先拉起 pi 拿到 session,**才**注册通道 [已定 2026-10-13 用户澄清]**通道注册依赖 session 已存在**。完整顺序: ``` ① 建工程 commondev_project_open / branch_open → 建 workdir(program/-; 含分支 ⇒ 同仓库不同分支不同目录) ② 拉起 pi commondev_agent_up(内嵌 sidecar / remote 时 ssh) → pi 起来并拿到/恢复 **session_id** ③ ★ 注册通道(此时才有) RegisterInputChannel("commondev/", ChannelDef{NoMemory: true}) ★ 可以用斜杠(inputch 不做工具名) ★ NoMemory:通道控制流不该进记忆(避免污染召回/场面指纹) RegisterOutputChannel("commondev_", caps, "工程对话回程", def, handler) ★ 下划线,不能带斜杠 —— 它会拼成 output_send__ ④ 划给驻留子 resident_agents create input_chs=commondev/ allowed_outputs=commondev_(这一步由主 agent 调) ⑤ 投递任务 子调 output_send__commondev_(payload=…) → 插件 handler → sidecar.prompt(session_id, text) ⑥ pi 产出 → 插件 InjectTextTo("commondev/", …) → 路由回该子 ``` **为何必须先有 session**:通道是“与**某个具体 pi 会话**对话的入口”—— 没 session 就没有投递目标,注册出来的通道是空壳;handler 也无法“包一下” (查台账拿不到 `session_id`)。 ⇒ **台账在 ② 写入 `session_id`,在 ③ 补写通道名**,两步不能合并。 ⇒ `commondev_agent_up` **要在通道注册之前**、**在子创建之前**——否则子拿到的 `output_send__commondev_` 会指向一个还不存在的通道。 **回投与小中断**:插件用 `InjectTextTo(source="commondev", channel="commondev/", text=…)` 把 pi 的 事件/进度回投给**该工程对话的子**;用 `InjectInterruptTextOpts(..., Priority:"L3")` 做需要抢占的通报。 ### 2.3 ★ skill 必须建议 agent:新建驻留子并**划入**这对通道 **注意顺序**:通道是 `commondev_agent_up` 拿到 session **之后**才注册的(§2.2), 所以你的动作序列是:**建工程 → 拉起 pi → 插件注册通道 → 你建子并划入**。 插件只能把通道**登记**出来(`RegisterInputChannel`),**划入(Assign)只有内核能做** [已核:`AssignInputChannel` 未导出到公开 SDK]。所以"通道 → 子 agent"这一步 **必须由 agent 自己调内核工具完成**。 ⇒ 因此配套 skill 的核心建议是: ``` 为每个工程对话新建一个驻留子,并把该工程对话的一对通道划给它: resident_agents create id = dev- input_chs = commondev/ ← 划入该 inputch(Owner 变成这个子) allowed_outputs = commondev_ ← 授权该 outputch(★ 下划线) capacity = 64 task_prompt = "你在 推进 。下任务给自己用 output_send__commondev_(payload=…), pi 的结果会回投到你的 commondev/; 有结论用 notify_parent 汇报。" ⇒ 此后该工程对话的输入**只**流向这个子内核(根 agent 看不到), 子在收到父的 send 时按 L4 被打断(KernelSource = 父)。 ``` **如果 agent 不建子会怎样**(skill 必须写清代价,否则它不会照做): - 所有工程对话的输入都涌向**根 agent**(`routeInputByOwner` 发现 Owner 为空 ⇒ 自己处理) ⇒ 根会成为瓶颈,多工程并行的意义消失; - 根 agent 的上下文被 N 条工程对话的细节淹没(正是前篇 §4.6 要避免的「主管被 diff 淹没」)。 ### 2.4 L4 落在谁身上(**能力表,别搞错**) | agent | 它的"内核" | 谁能产生 L4 | 机制 | |---|---|---|---| | 根 agent | 内核自身 + **内置**插件 | `raiseKernelInterrupt`、内置插件(如 WebUI 终止按钮) | `isKernelLevelSource` ① `pluginReg.IsBuiltinPlugin(name)` | | 驻留子 | **父 agent** | 父(`SendToResident` 固定 `Priority:"L4"`) | `isKernelLevelSource` ② `name == a.kernelSource` | | **commondev(外部插件)** | — | **永远不能** | proc 桥 `clampExternalPriority` 夹 L4→L3(`proc/corehandler.go:279`) | > **[风险]** commondev 作为**外部独立插件**,L4 被制度性移除。若 canonical 流程需要 > 「立即取消整条分支」,只能靠 **L3 打断主管 + 主管下令**,或改用内置插件形态。 > 见 §6 未决问题 U1。 ### 2.5 ★ 投递路径:用内核原生的输出门,不另造发送工具 [已定 2026-10-13 用户澄清]完整流程是: ``` ① 插件为每个工程对话注册一对通道 inputch commondev_br_1 ← pi 的进度/事件回这里 outputch commondev_br_1 ← ★ agent 发消息给 pi 就走它 ② 主 agent 调 resident_agents create,把该 inputch 划给驻留子 ③ 之后下任务 = 该子调 output_send__commondev_br_1(payload="…") ④ 插件收到 payload → 查台账 (workdir, session_id) → sidecar.prompt(...) ⑤ pi 产出 → 插件注入 commondev_br_1 → 路由回该子 ``` **关键:第 ③ 步不需要新工具。** `output_send__<通道>` 是**内核原生**的输出门 (`output.go:10`):agent 调它 → 内核查通道能力 → `EmitTextTo` → 打到 **插件注册的输出通道 handler**。插件的 handler 就是那个"包装"。 ⇒ **本稿前版设的 `commondev_agent_send`(面向 agent 的发送工具)已废除** —— 它是重复实现。保留的只有: | 方向 | 机制 | 谁实现 | |---|---|---| | **agent → pi** | `output_send__commondev_br_1(payload=…)` | **内核**(插件只写通道 handler) | | **pi → agent** | 插件 `InjectTextTo(source, "commondev_br_1", …)` | 内核路由(按 inputch Owner) | | **中断 pi** | `InjectInterruptTextOpts(..., Priority=L3)` | 内核调度器 | ⇒ 插件侧只剩**一个工具族**(配置/仓库/判据)+ **两个通道 handler**。 #### ⚠️ 通道命名约束(**本稿前版写错了**) 输出通道名会被拼进 LLM 函数名 `output_send__`,而上游要求函数名满足 `^[a-zA-Z0-9_-]{1,64}$`(SDK `RegisterOutputChannel` 注释已警告)。 | | 能用 `/` 吗 | 依据 | |---|---|---| | **inputch** | ✅ 可以(内核自己就用 `child/`、`sub/`) | `resident.go:253/447` | | **outputch** | ❌ **绝对不行** | 否则上游 400,**整条请求被拒、agent 整体失语** | ⇒ 本稿前版写的 `commondev/` **作为输出通道名是非法的**。正确用法: ``` inputch : commondev/ (可以用斜杠,路由单位) outputch : commondev_ ★ 下划线,保证 output_send__ 合法 ``` (`` 本身也必须限定在 `[A-Za-z0-9_-]`,不能出现斜杠/点/空格。) #### ⚠️ 另一条限制:`BindOutputTarget` 不开放给插件 “输出通道 → 目标 agent 的 inputch”的解析由 `ChannelRegistry.BindOutputTarget` 提供(`io/inputch.go:175`),但它**未导出到公开 SDK**。 ⇒ **插件不能自己把输出通道绑到某个子的 inputch**。 好在 canonical 流程**不需要它**:子 → 父/Z 方向走 `notify_parent`(内核已有), pi → 子的方向走 **inputch Owner 路由**(已可用)。若将来需要“经输出通道直接投给另一个 agent”, 得先把 `BindOutputTarget` 补到公开 SDK(属"只增不减"的追加)。 ### 2.6 commondev 与驻留子的能力映射 **总纲 [已定 2026-10-13 用户拍板]**:插件对 pi 做的事,**本质就是** > **「把给 pi 发消息包装一下、把 pi 返回包装一下」**,然后**深度融合内核的 IO 抽象与中断系统**。 > ⇒ **pi = 一个 Device**。不是自己造一套调度/会话/中断,而是**接到已有的那套上**。 对应关系因此是**结构上同构**的,不是比喻: | 内核概念 | pi 侧对应 | 落点(内核已有) | |---|---|---| | **给 pi 发消息** | `pi.prompt(text)` / `steer` / `follow_up` | `output_send__commondev_`(内核原生) | | **pi 返回** | sidecar 回调 / `tool_execution_*` / `agent_settled` | `InjectTextTo(source, "commondev/", …)` | | **pi 报错/卡住** | sidecar 错误事件 | 同一个 output handler,或注入为输入 | | **中断 pi**(改主意、叫停) | `steer`(steer 到当前轮之后) | `InjectInterruptTextOpts(..., Priority: L3)` | | **音声/图像产物** | pi 可产出的媒体 | `OutputCapabilities()` 声明 `CapText|CapImage|…` | | **工具级证据** | `tool_execution_start/update/end` | 经 output handler 回投,作台账证据 | ⇒ **本质是一次包装适配**:外壳是 Device,里面是 pi 进程 + sidecar。 所有调度/中断/路由/记忆策略都**交给内核**(`ChannelDef` + `InjectOptions` + `inputch` 路由)。 **与驻留子的对照**: | 内核驻留子 | pi 侧 | 谁提供 | |---|---|---| | 轻量内核(调度器/中断栈) | **不需要** —— 用内核自己的 | — | | pi 会话 | sidecar 持有的 `session_id` | commondev | | 父→子 = L4 打断 | **沿用**:父给子发消息就是 L4(插件不介入) | 内核 | | 子→父 = L3 汇报 | **沿用**:`notify_parent`(skill 建议子调用) | 内核 | | `inspect`(pull 不打断) | `get_state` + 台账 `deliveries` | commondev 经 sidecar | | `inputch_note` 处理表 | 台账 `deliveries` 表 | commondev | | `destroy` | 杀 sidecar / pi 进程 | commondev | | **父 `Stop()` ⇒ 全部子销毁** | **commondev 保证**(进程/ssh 无孤儿) | commondev(**孤儿属插件责任**) | > **一句话**:**插件只负责“把 pi 接成 Device”与“台账记 session_id”**, > 其余的调度、中断、路由、记忆策略**全是内核已经在做的事**。 ### 2.7 ★ 工具调用有 60 秒硬上限(实现约束) [已核 · 实测踩到] 内核把**每一次工具调用**限制在 **60 秒**,且**硬编码无配置项**: ```go // internal/agent/core/toolcall.go:62 case <-time.After(60 * time.Second): te := newToolError(ErrReasonTimeout, …) ``` ⇒ **凡是可能超过 60 秒的活儿,一律不能放在工具调用里同步做。** 实测踩到的场景:`git clone --mirror` 一个仓库耗时不定,`commondev_branch_open` 里同步 clone ⇒ 工具超时 60s 报 `工具 commondev_branch_open 执行超时(60秒)`, **但 mirror 其实已经建好了**(磁盘上 `cdtest.git` 存在)—— 即:超时后状态是“做了一半”,调用方却只看到失败。这是最坏的一种失败。 **受影响的操作**(都必须异步化): | 操作 | 为何慢 | |---|---| | `git clone --mirror` | 仓库大小 × 网络 | | 由 mirror 派生工作目录(即使硬链接,大量小文件仍慢) | 文件数 | | `git fetch` 更新 mirror | 网络 | | 跑判据 / 变异测试 | 测试本身耗时 | | **拉起 pi 并等它就绪** | 进程启动 + 首次初始化 | **设计结论**:工具只做**快操作**(查台账、登记、发起),慢操作**转后台**, 由状态工具报告进度。形态建议: ``` commondev_branch_open → 登记台账 + 建目录(快)+ **后台 clone**;返回 job_id commondev_job_status → 查后台任务进度(pending|running|done|failed) ``` ⇒ 这同时满足内核“工具要快返回”的设计与用户“开完就能用”的期望。 > **教训**(值得单独记):我曾把同步 clone 写进工具里,端到端跑时才发现超时。 > 若只看单测(不跑真机)根本发现不了 —— 这是“读码能过、真机不过”的典型。 ### 2.8 ★ 会话 id 必须**显式指定**(否则续投必失败) [已核 · 实测踩到] **事故**(首次投递): ``` 会话漂移:期望 br-cdtest-feat_alpha,实际 01a13959-bcc3-7007-9a92-06483e2150ba —— 拒绝继续(不静默改投新会话) ``` **根因**:sidecar 初版用 `createAgentSession({cwd})`,pi 会**自己生成一个 UUID** 作会话 id;而 Go 侧台账记的是我们请求的名字。两者本来就不相等 ⇒ “投递后断言会话未变”这条纪律拿两个不同名字比,**永远失败**。 **修法**(已验证):用 `SessionManager.create(cwd, sessionDir, {id})` 显式指定: ```js const manager = SessionManager.create(workdir, sessionDir, { id: sessionId }); const { session } = await createAgentSession({ cwd: workdir, sessionManager: manager }); ``` 实测:`manager id: MYFIXEDID`(不再是随机 UUID);pi 会话文件名 `…_br-cdtest-feat_alpha.jsonl`,且其中第 4 行就是 `user: ` —— 即 payload **真的进了那个会话**(不是只看工具返 `ok`)。 ⇒ 这同时给了“续投同一会话”一个**真保障**(而非运气):固定 id ⇒ 重启/重投能打开同一个会话文件。 > **注**:`pi -p --session-id X` 也是用 X 作会话 id(实测会话 header `"id":"X"`); > 两条路径一致。但 SDK 路径**必须手动传**,不会从环境变量推断。 --- ## 3. 运行时形态(内嵌为默认,SSH 为配置项) ### 3.1 架构(一次包装适配) [已定]**默认本地内嵌,配置切到远程 SSH**。两者**共用同一份 sidecar**, 差别只在**传输层**(本地 `exec` vs 远程 `ssh`): ``` 内核 ──InjectInputTo(source, "commondev/", text)──► 插件 ├─ 包一下 ──► sidecar.prompt(text) │ └─ 内嵌 createAgentSession │ (远程时:ssh 到远端同一份) └─ 包一下 ◄── sidecar 回调(pi 输出/事件) └─ output handler → 回投内核 ``` **两处“包一下”**(这是本设计的全部): | 方向 | 做什么 | 内核已有机制 | |---|---|---| | **入(给 pi 发消息)** | 找到 `(workdir, session_id, runtime) → sidecar.prompt/steer` | 入站由 `InjectInputTo` 到达,插件只是落地 | | **出(pi 返回)** | sidecar 回调 → 包成内核输出 | `RegisterOutputChannel` 的 handler | | **中断** | 不需新机制 | `InjectInterruptTextOpts(Priority=L3)` 走内核调度器 | ⇒ **插件不实现调度器、不实现中断栈、不实现会话路由** —— 那些都是内核的。 **台账只多做一件事**:记住 `session_id` 并在投递后断言它未变(§3.3)。 **为何不让插件自己管 RPC 循环**:那是重复实现内核已有的能力。 sidecar 只需要回答两个问题: 1. “这条消息投给了哪个 session?”(投递前读、投递后验); 2. “pi 现在在干什么?”(`get_state` / 事件流)。 > **为何不用 RPC 客户端另开一路**:最初设想 C(内嵌)/ B(`--mode rpc`) > **双模式**。用户拍板后**收敛为「一套 sidecar + 两种传输」**(内嵌为唯一实现)—— > 少一条运行时路径,少一半要维护的边界。RPC 仅在 sidecar 不可用时可作降级, > 且**不属于本设计的正式形态**。 #### 3.1.1 接线状态与两处实测缺口 [✅ 已接线 · 真实 sshd e2e] `runtime=ssh` 的**实现**早已写完(`sidecar_ssh.go` 全套:探测/推脚本/建链), `startSidecar` 里的 `if b.Runtime == "ssh"` 分派也接好了。**但 `branch_open` 硬编码 `Runtime: "local"` 且从不读 `ssh_host`** —— 于是 `runtime=ssh` 被**静默吞掉**:以为跑在远端,实际跑在本地,且没有任何提示。 (`runtimeOrDefault` 当时更是从未被调用的死代码。) > **这类「参数被吞」比报错危险**:报错立刻发现;吞掉会一路错下去, > 而症状(任务跑在本地、文件在本机被改)与预期完全不同却很晚才暴露。 已补:`branch_open` 解析 `runtime`/`ssh_host` → 缺 host 直接拒 → 未知 runtime **响亮拒绝**(不降级)→ 写台账 → `agent_state` 回显 `ssh_host`。 **另一处缺口**:`probeSSH` 硬编码探 `node`,**忽略 `ssh_node_path`**。 于是用户按提示配好了远端 node 路径,探测**仍然失败**并报 「远端不可达或未配免密」——把**配置问题伪装成连通性问题**,排查方向完全错。 已修(用 `t.NodePath`)。 **判据**: - 接线(不需环境):`ssh_wiring_test.go` 9 条 —— 含"参数被吞""未知 runtime 不降级" "未知 CLI 不回退 pi""新增 CLI 不改核心"。 - **真端到端**(`HA_SSH_E2E=1`,需 sshd + localhost 免密):`ssh_e2e_test.go` 5 条 —— 真起 ssh、真 scp 推脚本(并比对本地/远端字节一致)、真握手、真投 prompt 并断言**会话未漂移**;另含"远端缺 node 响亮报错""无免密响亮报错"。 实测握手耗时 ~20s(ssh 建链比本地慢,故 `handshakeTimeout` 单独放宽到 20s)。 **变异验证**(4/4 咬住):退回硬编码 local / 未知 runtime 降级 / 未知 CLI 回退 pi / `llmConfig` 不透传。 #### 3.1.2 ★ 第三处缺口:远端工作目录**从未被创建** [✅ 已补 · 真实 sshd 闭环] `prepareWorkdir` 只有**本地一套**(本机 `clone --mirror` + `clone mirror workdir`)。 对 `runtime=ssh` 的分支,远端 pi 的 cwd 指向一个**从未被创建**的远端目录。 症状是「agent 起来了,但一执行命令就说目录不存在」,而 **job 状态显示 done** (因为本地 clone 确实成功了)—— 两边各自看起来都成功,所以极难归因。 新增 `workdir_remote.go`,与本地路径**逐条对应**: | 步骤 | 本地 | 远端 | |---|---|---| | 建父目录 | `os.MkdirAll` | `ssh mkdir -p` | | mirror | `<插件数据>/mirrors/.git` | `/.mirrors/.git`(同一远端多条线共享) | | 派生 | `clone mirror workdir` | 远端 `clone mirror workdir` | | 切分支 | `ensureOnBranch` | `ensureRemoteOnBranch` | | 记 base_sha | `captureBaseSHA` | `remoteCaptureBaseSHA` | **★ 三条纪律**: 1. **`runPrepareWorkdir` 必须按 runtime 分派**,且远端分支必须 `return` —— 不返回就会继续落到本地路径(本地 clone 成功、远端目录仍为空)。 2. **台账 `Workdir` 必须改写成远端路径**,否则 `agent_up` 把远端 pi 的 cwd 指到一个本地目录名上。 3. **缺 `ssh_workdir_root` 响亮报错,不猜默认值** —— 猜错会把文件写到远端 别的项目的目录里,很难回滚。 **判据**:`workdir_remote_test.go` 7 条。其中真 sshd e2e 3 条 —— `TestSSHBranchWorkdirBecomesRemotePath`(远端真建目录/真 clone/真切分支/ 远端 mirror 存在)、`TestRemoteWorkdirReuseIsIdempotent`(复用幂等)、 `TestSSHFullLoop`(**完整闭环**:远端建目录 → 远端拉起 pi → 投递 → 断言会话未漂移,21s)。 **变异验证**(5/6 咬住,1 条不适用):远端 mirror 用本地路径 / 远端不切分支 / 缺配置猜默认 / `branch_open` 不传 runtime / 远端不改台账 Workdir。 **未咬住的那条**:在远端分支里删掉**一个** `return` —— 因为该分支还有别的 `return`,行为仍正确,判据不报红是**对的**。 **★ 判据自身的一次修正**:`TestPrepareWorkdirDispatchesByRuntime` 初版用 `Contains("runtime == \"ssh\"")` 判断分派 —— 而 `if false && runtime == "ssh"` 仍包含该子串,于是**变异照绿**。改为精确匹配 `if runtime == "ssh" {` 并追加 「不得有 `false &&` 前缀」后咬住。这是本仓反复出现的「判据通过 ≠ 检查到目标性质」。 **顺带修的既有缺陷**:`ssh_workdir_root`/`ssh_runtime_root`/`ssh_node_path` 三个配置项**只被 Get 读、从未 RegisterDef**,于是设置页里看不到, 用户只能去改 config.yaml 猜键名 —— 而缺 `ssh_workdir_root` 会让远端线直接失败。 已全部注册。 **另修自己新代码的 bug**:`runSSHCaptureOn` 初版在 `cmd.Start()` 之后调 `CombinedOutput()` ⇒ `exec: already started`(实测被自己的 e2e 咬出)。 改为 goroutine + `Wait()` + 超时 `Kill()`。 #### 3.1.3 ★ 第四处缺口:sidecar 脚本**没有随包分发** [✅ 已补 · 真实拉起验证] 打包只收 `rawfile/` 与 `skill/`,而 `sidecar.mjs` 在**工程根** ⇒ 没进包。 同时 `<插件数据>/runtime/pi/` 在部署时没人创建。三条查找路径全空 ⇒ > `agent_up` 在全新部署上**必然失败**,报 > 「找不到 pi 的 sidecar 脚本: /home/xxx/plugin_data/commondev/runtime/pi/sidecar.mjs」 > —— 一个用户从没听说过的路径。 表现为「插件装了、工具都在、但一拉 agent 就错」,属于**能装不能跑**。 **修法**(复用既有通道,不新增概念): 1. `sidecar.mjs` 放进 `rawfile/runtime//`,随包分发; 2. `locateSidecarScript` 建立查找链: | 顺序 | 位置 | 用途 | |---|---|---| | ① | `<插件数据>/runtime//