* feat(kernel): 模块化内核 + 编译期依赖图定序
把单体 init 序列拆成「模块自声明依赖 + 编译期生成依赖图 + 运行期只读图」。
## 为什么
原 cmd/homed/main.go 里 17 步 init 是**手写顺序**,依赖关系只存在于
注释和人脑里。实测踩到的坑(见 docs/zh/kernel-modularization-design.md):
· nlp.defaultParser 被 initONNXParser 写、被 distiller 读,两者相距 60 行,
只靠 ticker 10min 才没炸(隐式依赖,无声明)
· 加一个模块要人肉找"该插在哪",插错的表现是运行期 nil 解引用
· 关停是 defer ks.CloseAll(),而热更新路径 os.Exit(42) **跳过 defer**
⇒ docStore/distiller/textMem 的 flush 全部丢失(每次热更新都发生)
## 怎么改
- internal/kernelboot/:KernelState(一个结构体收全部包级共享变量,字段
nil 安全)、Module 接口、graphfile(依赖图编解码 + 与二进制 hash 对账)、
scheduler(按 BFS 层并行 Init)、syncbus(层边界写通知)、
lifecycle(Quiesce/Resume 可逆伪关机,区别于一次性 CloseAll)
- internal/kernelmod/<name>/:每个模块一个文件夹,Requires/Provides 自声明;
all/all.go 是唯一的 import 清单(Go 循环导入约束)
- cmd/homed-depgraph:编译期生成依赖图,Makefile 注入 digest
- 运行期只**读**图,不计算顺序(顺序是编译期决定的,源码里没有中央清单)
## 判据
kernelboot 8 条 + 图失败模式 7 条(全部拒绝并给准确错误)+ 模块目录数
与注册表一致性。变异测试 15/15 咬住,含:
· 删掉 AddCloser 的锁 → -race 报数据竞争
· Scheduler 同批跑去重 → 203 vs 200
· 图 hash 不匹配 → 拒绝启动
## 顺带修的缺陷
热更新路径漏调模块关闭 ⇒ 显式 stopKernelModules(),并用 AST 判据钉住
"必须在 os.Exit 之前"(字符串检查在本仓已骗过我三次)。
实测:冷启动 1630ms(5 层/17 模块,原 17 步串行);生产 22 插件全部建链。
* feat(security): 密钥防火墙 + 配置加密 + close 纪律
## 密钥防火墙
模型只见别名、不见明文;插件工具调用入口(StageHost.ExecuteTool)做入向
回填。三维能力:
· 出向:工具输出里的密钥被替换成 [[secret:name]] 别名后才进上下文
· 入向:模型传别名,入口处填回真值(插件完全无感知)
· 存储:config.db 里不存明文(0644 的库可读),敏感值独立 0600 文件
模式库「宁漏勿误伤」:宁可漏掉一次替换,也不把正常文本误当密钥。
## 配置加密
- internal/security/{codec,key,keyring,store,global}.go:AES-256-GCM,
enc:v1: 前缀,密钥轮换(OldKeys 解码 + 新钥加密)
- harden.go:数据目录权限收紧
- embedded.go:支持内嵌主密钥(可选,仍保留外部 key 文件路径)
- EncryptExistingSecrets / AuditSecrets:启动时幂等迁移 + 审计
## close 纪律(用户明确要求:只要调 close 就要有前置检查)
internal/safeclose:Safe / SafeFunc。全仓 37 处裸 close(chan) → 0。
关键教训(都写进注释和判据):
· `select { case <-ch: default: close(ch) }` **不是**并发安全的
(两个 goroutine 可同时命中 default)—— healthcheck 原先是这种写法
· `sync.Once` **不可重入**:X.Do(func(){ Safe(&X, ...) }) 会自锁,
症状是 go test 直接挂住(3 个包 600s 超时),栈里只有嵌套的 Once.Do
· map 里按请求持有的 channel(mcp/stdio.go、clawhubadapter/sidecar.go)
改成 *pendingRPC 包装,每通道一个 once
## 判据
security 全量 + TestNoBareCloseOutsideSafeclose(AST 级扫描,含注释剥离)
+ TestNoNestedSelfOnceDeadlock(正则修正:\w+ 不匹配点号,捕获组要含 '.')。
变异 2/2 咬住。全量 52 包 ok。
* feat(plugin+deploy): 插件三态生命周期、数据目录迁移、发行包修复
## 插件三态生命周期(用户要求:自动启动 / 启用但不自动启动 / 禁用)
- Manifest 新增 requires_facilities / auto_start(*bool,用于区分
"未声明"与"显式 false")
- plugin-host 模块(internal/kernelmod/pluginhost):用 kernelboot.Scheduler
按依赖时机拉起,**不再手写两阶段**
- 运行态停启 StopPlugin/StartPlugin **可逆**:不写 disabled_plugins
(不改用户决定)、不置 shuttingDown(之后还能加载)、会摘掉注册面
- 与 StopAll 的区别写进注释 —— StopAll 置 shuttingDown 后**永久无法加载**,
那是进程退出用的,误用于"暂停"就是关了就死
## 数据目录迁移(伪关机 → 迁移 → 唤醒)
- kernelboot/lifecycle.go:Quiesce/Resume(可逆,区别于一次性 CloseAll),
逆序暂停、逆序恢复、Pause 失败回滚
- kernelboot/migrate.go:BeginMigration/End 编排(先停插件 → 再停内核写者
→ 等在途归零)
- distiller/textmem 增 Pause/Resume 门(不是 cancel —— cancel 不可逆,
恢复要重建整个对象)
- 模块 migration:体检 + 生成可执行迁移脚本
★ 迁移脚本的三个实测教训(都写成了判据):
1. 无条件改 /etc/systemd/system ⇒ 演练把**生产** -data 改成了演练目录。
加 --dry-run(零改动)+ 强制校验 systemd 当前 -data == OLD
2. 本机没装 rsync,脚本在"服务已停、数据未搬"处硬失败 ⇒ cp -a 回退,
并明确警告不保硬链接/ACL(生产 plugindev 有大量 git 硬链接)
## 发行包修复(全部经实测确认)
1. package-linux.sh **完全漏打 depgraph.bin** ⇒ 内核 exit 1
("生产构建(内嵌 hash 非空)但读不到依赖图")
2. build_homed 不走 Makefile ⇒ 单独修 Makefile 无效,必须同时生成图+注入
3. build.sh 的 LDFLAGS 用**历史模块路径 gitcode.com** ⇒ -X 静默 no-op
⇒ --version 显示 unknown
4. Makefile -ldflags 用**单引号** ⇒ 注入了字面 $GRAPH_HASH
## ★ build.sh 的版本号必须校验(真实事故)
原 `VERSION="$(git describe --tags --dirty)"`。无 tag 分支上它返回**分支名**
`backup/pre-identity-rewrite-20261005-51-g03d87a4e-dirty`,被注入 meta.Version
⇒ 插件握手判据 MajorVersion() 把整串当主版本 ⇒ **22 个子进程插件全部
握手失败**("主版本不匹配")。内核活着、systemd active、日志每条都像
插件的问题 —— 实际是发行包带着非法版本号。
修法:git describe 只在形如 vX.Y.Z 时采用,否则回退 meta.Version(权威源),
并对最终值做形态守卫。deploy/packaging/version_guard_test.go 3 条判据,
变异 2/2 咬住。
## deb 的 systemd unit 改为 guard 模式
ExecStart=/usr/bin/homed --role=guard --data /var/lib/homeagent
(worker exit 42 → guard 重拉,不经过 systemd、不计失败)
含 RestartSteps/RestartMaxDelaySec/TimeoutStopSec + 硬化
(NoNewPrivileges/ProtectSystem/ProtectHome/DevicePolicy/ReadWritePaths)。
## 其他
- internal/plugin/proc:非法 JSON 帧的报错**可定位**(内容摘要 + 可能原因 +
修法)。生产实测 1038 条非法帧(插件往 stdout 打日志 = 污染协议通道)
- 示例插件 4 个 14 处 fmt.Printf → log.Printf
- hmapdev 模板同步修复;scaffold 判据剥离注释(模板注释里就含 fmt.Printf)
* chore: 收尾(主密钥轮换命令、IPC/知识库加固、SDK 对齐)
- cmd/homed-key-rotate:把 security 里的 RotateKey/RotateKeyFile/RekeySecrets
串成一条**可执行**的运维路径。没有它,E-1 就只是库里的能力而不是
"出事时能做的事",而轮换的价值恰恰在于能执行。
- cmd/homed:kmod_runtime(模块装配入口)、plugins_scheduled(failback
白名单)、memcandidate、bootstrap_order_test / shutdown_flush_test
- internal/ipc、internal/devicebridge、internal/knowledge:配套加固
- third_party/homeagent-sdk/sdk:PluginMgrAPI 增三态能力(mgrStop/mgrStart/
mgrIsRuntimeStopped)+ settings 对齐
判据:bootstrap_order_test(启动顺序不依赖手写序列)、
shutdown_flush_test(停机的两条路径必须对称 —— exit 42 会跳过 defer,
热更新路径下模块关闭/flush 曾全部丢失)。
* test(security): 测试夹具改用不可误认的占位值(修 push protection 拦截)
GitHub secret scanning 拦住了推送:三处测试夹具用了"看起来像真密钥"的
字面量:
internal/security/prefilter_test.go:39
internal/security/store_test.go:177
internal/security/survey_test.go:20
它们全是**假密钥**(`AKIAIOSFODNN7EXAMPLE` 是 AWS 官方文档的标准示例,
其余是 `abcdefghij...` 这类可读占位符),但扫描器无法区分 —— 它只看形态。
★ 不绕过(不用 unblock URL、不加 allowlist):直接改夹具。
原因:一旦允许"假密钥可以通过",真密钥混进来时就没有最后一道门了。
改法:保留**模式库匹配所需的结构**,把可识别内容换成 FAKE:
glpat-abcdefghijklmnopqrst → glpat-FAKE-TEST-TOKEN-NOT-REAL
sk-abcdefghij...012345 → sk-FAKE-TEST-KEY-NOT-REAL-0000000
AKIAIOSFODNN7EXAMPLE → AKIA0000FAKE0000TEST (保持 20 字符)
ASIAIOSFODNN7EXAMPLE → ASIA0000FAKE0000TEST
xoxb-123456789012-abcdefghijklmnop → xoxb-FAKE-TEST-TOKEN-NOT-REAL
★ 中途踩到一次:AWS 键的模式是 `\b(AKIA|ASIA)[0-9A-Z]{16}\b`(共 20 字符),
我第一次替换成 19 字符的 `AKIAFAKETESTKEY0000` ⇒ 模式匹配不上 ⇒
TestSurvey_WhatIsCaught / TestRecognizesCommonShapes /
TestPrefilterNeverBlocksRealMatches 三条当场变红。
判据正确地拒绝了"为了过扫描器而削弱覆盖"。
顺带核实:全仓 grep 无任何真实密钥泄漏。
* fix(packaging): package-linux.sh 的版本解析与 build.sh 统一
上一版只修了 build.sh,漏了 package-linux.sh —— 它仍只看 git describe:
VERSION="${VERSION:-$(git describe --tags --dirty)}"
无 tag 分支上 git describe 返回**分支名**
(backup/pre-identity-rewrite-20261005-56-g98850e1),守卫会正确拒绝,
但要求人**手动传 VERSION** 才能打包。而 build.sh 早已能自动回退 meta.Version
⇒ 同一个版本,走 build.sh 打得出来、走 package-linux.sh 打不出来。
危害不只是"要手动传参":运维为了跑通会去掉守卫,而分支名一旦进
meta.Version 就是**插件协议主版本**的来源 ⇒ 22 个子进程插件全部握手失败
(这是真实发生过的事故,见 3efa4e5)。
修法:两个脚本用同一套规则(git describe 仅在 vX.Y.Z 形态时采用,
否则回退 meta.Version),并对最终值做形态守卫。
判据:TestBothPackagingScriptsShareVersionRules —— 逐个读两个脚本,
要求都能从 meta/meta.go 取权威版本、都校验 git describe 形态。
变异(把 package-linux.sh 改回旧规则)咬住并准确指出是哪个脚本。
实测:不传 VERSION 跑 package-linux.sh,输出
[PKG] warn: git describe 给出非版本串(...),改用 meta.Version=1.4.0
Version: 1.4.0
* fix(test): 跨仓判据在 CI 上应 skip 而非 fail(修 PR 双红)
CI 上 "Go build / vet / test" 与 "Build linux/amd64" 双红:
--- FAIL: TestHmapdevTemplateImplementsAllPluginMgrMethods
bridge_sync_test.go:170: 读模板: open
../../../third_party/homeagent-sdk/tools/hmapdev/templates/proc_main.go.tmpl:
no such file or directory
## 根因:判据依赖了一个**被 gitignore 的路径**
`.gitignore:33` 有 `third_party/homeagent-sdk/tools/`(SDK 仓自治,本仓不跟踪)。
于是:
· 本机(完整 SDK 检出)有该文件 ⇒ 判据通过
· CI(只 checkout 本仓)没有该文件 ⇒ Fatalf ⇒ **必红**
我提交时只在本机跑测试、没看 CI,所以这个问题拖到 PR 才暴露。
## 修法
文件缺失时 `t.Skipf` 而不是 `t.Fatalf`。
★ 这不是"放弃检查":
· 跨仓一致性由 **SDK 仓自己的判据**守着
(tools/hmapdev/scaffold_stdout_test.go 等),那里才是模板的真实归属地
· 本判据在本机开发时**仍然生效**(已验证:有文件时 PASS,缺文件时 SKIP)
## 验证
· 本机(有文件) → PASS(判据真跑)
· 模拟 CI(移走 tools/)→ SKIP(不再 fail)
· 全量 53 包 ok / 0 fail
## 顺带
同目录还有 e2e_template_test.go 也读 tools/,但它是 `//go:build` 门控的
e2e 判据,CI 默认不跑,无需处理。
---------
Co-authored-by: JianFeeeee <JianFeeeee@users.noreply.gitcode.com>
50 KiB
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--/<ts>_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 <pkg> 把 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=工作区)⇒ 写 <workdir>/.pi/settings.json;内层 pi 带 --approve 跑,调用该包的工具返回 PKG-OK |
| 全局安装插件可行 | pi install <pkg>(不带 -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 |
两个必须避开的陷阱:
ToolAPI是单例、per-agent 数据靠「最后注入者」 ——builtin_toolapi.go:117-119自陈该局限,而SpawnResident也会注入。⇒ 不要经 ToolAPI 调resident_agents: 它可能打到某个驻留子的表上。- 不要以为插件稳拿 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-<project>-<slug>)
├─ workdir <插件数据目录>/program/<hash>-<name>
│ ★ hash = H(项目+分支) 承担**唯一性** ⇒ 同仓库不同分支必然是不同目录
│ ★ name = 分支名派生,只做可读性(不承担唯一性,派生后可能撞)
│ 独立 clone(本地 clone 硬链接派生,非 worktree、非 --reference)
│ **合入后整个目录删除**
├─ session_id pi 会话(★ 续投的唯一依据;漂移即失败)
├─ runtime local(默认,内嵌 pi) | ssh(配置切换,远端建目录拉 pi)
├─ host runtime=ssh 时的目标主机
└─ channels 插件为该工程对话注册的 **一对通道**
├─ inputch commondev/<id> ← pi → agent(可用斜杠;只流向该子)
└─ outputch commondev_<id> ← 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/<hash>-<name>;<hash> 含分支 ⇒ 同仓库不同分支不同目录)
② 拉起 pi commondev_agent_up(内嵌 sidecar / remote 时 ssh)
→ pi 起来并拿到/恢复 **session_id**
③ ★ 注册通道(此时才有)
RegisterInputChannel("commondev/<id>", ChannelDef{NoMemory: true})
★ 可以用斜杠(inputch 不做工具名)
★ NoMemory:通道控制流不该进记忆(避免污染召回/场面指纹)
RegisterOutputChannel("commondev_<id>", caps, "工程对话回程", def, handler)
★ 下划线,不能带斜杠 —— 它会拼成 output_send__<name>
④ 划给驻留子 resident_agents create input_chs=commondev/<id>
allowed_outputs=commondev_<id>(这一步由主 agent 调)
⑤ 投递任务 子调 output_send__commondev_<id>(payload=…)
→ 插件 handler → sidecar.prompt(session_id, text)
⑥ pi 产出 → 插件 InjectTextTo("commondev/<id>", …) → 路由回该子
为何必须先有 session:通道是“与某个具体 pi 会话对话的入口”——
没 session 就没有投递目标,注册出来的通道是空壳;handler 也无法“包一下”
(查台账拿不到 session_id)。
⇒ 台账在 ② 写入 session_id,在 ③ 补写通道名,两步不能合并。
⇒ commondev_agent_up 要在通道注册之前、在子创建之前——否则子拿到的
output_send__commondev_<id> 会指向一个还不存在的通道。
回投与小中断:插件用
InjectTextTo(source="commondev", channel="commondev/<id>", 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-<br-id>
input_chs = commondev/<br-id> ← 划入该 inputch(Owner 变成这个子)
allowed_outputs = commondev_<br-id> ← 授权该 outputch(★ 下划线)
capacity = 64
task_prompt = "你在 <workdir> 推进 <work>。下任务给自己用
output_send__commondev_<br-id>(payload=…),
pi 的结果会回投到你的 commondev/<br-id>;
有结论用 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__<name>,而上游要求函数名满足
^[a-zA-Z0-9_-]{1,64}$(SDK RegisterOutputChannel 注释已警告)。
能用 / 吗 |
依据 | |
|---|---|---|
| inputch | ✅ 可以(内核自己就用 child/<id>、sub/<id>) |
resident.go:253/447 |
| outputch | ❌ 绝对不行 | 否则上游 400,整条请求被拒、agent 整体失语 |
⇒ 本稿前版写的 commondev/<id> 作为输出通道名是非法的。正确用法:
inputch : commondev/<br-id> (可以用斜杠,路由单位)
outputch : commondev_<br-id> ★ 下划线,保证 output_send__ 合法
(<br-id> 本身也必须限定在 [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_<id>(内核原生) |
| pi 返回 | sidecar 回调 / tool_execution_* / agent_settled |
InjectTextTo(source, "commondev/<id>", …) |
| pi 报错/卡住 | sidecar 错误事件 | 同一个 output handler,或注入为输入 |
| 中断 pi(改主意、叫停) | steer(steer 到当前轮之后) |
InjectInterruptTextOpts(..., Priority: L3) |
| 音声/图像产物 | pi 可产出的媒体 | OutputCapabilities() 声明 `CapText |
| 工具级证据 | 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 秒,且硬编码无配置项:
// 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}) 显式指定:
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> ——
即 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/<id>", 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 只需要回答两个问题:
- “这条消息投给了哪个 session?”(投递前读、投递后验);
- “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.go9 条 —— 含"参数被吞""未知 runtime 不降级" "未知 CLI 不回退 pi""新增 CLI 不改核心"。 - 真端到端(
HA_SSH_E2E=1,需 sshd + localhost 免密):ssh_e2e_test.go5 条 —— 真起 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/<name>.git |
<ssh_workdir_root>/.mirrors/<name>.git(同一远端多条线共享) |
| 派生 | clone mirror workdir |
远端 clone mirror workdir |
| 切分支 | ensureOnBranch |
ensureRemoteOnBranch |
| 记 base_sha | captureBaseSHA |
remoteCaptureBaseSHA |
★ 三条纪律:
runPrepareWorkdir必须按 runtime 分派,且远端分支必须return—— 不返回就会继续落到本地路径(本地 clone 成功、远端目录仍为空)。- 台账
Workdir必须改写成远端路径,否则agent_up把远端 pi 的 cwd 指到一个本地目录名上。 - 缺
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 就错」,属于能装不能跑。
修法(复用既有通道,不新增概念):
sidecar.mjs放进rawfile/runtime/<cli>/,随包分发;locateSidecarScript建立查找链:
| 顺序 | 位置 | 用途 |
|---|---|---|
| ① | <插件数据>/runtime/<cli>/<script> |
用户自备 / 覆盖随包版本 |
| ② | <插件目录>/rawfile/runtime/<cli>/<script> |
随包分发(内置 pi 靠这条) |
| ③ | <可执行文件同目录>/<script> |
开发期(hmapdev debug) |
③ 全部落空时,错误列出找过哪里并给出解决办法 —— 否则用户只能猜。
★ 与 nodeBin 走同一机制(rawFiles()):rawfile 已是"随包任意资源"
的通道,不必为脚本再开一个约定目录。
判据:4 条(随包存在且与工程根一致 / 查找链命中 / 用户自备优先 / 错误可执行)+ 变异咬住(去掉 rawfile 回退、颠倒优先级)。 真机验证:用随包脚本真的拉起 sidecar 并完成握手(pi 0.99.2)。
3.2 形态对照与硬边界
| 本地内嵌(默认) | 远程 SSH(配置切换) | |
|---|---|---|
| sidecar 位置 | 本机 | 远端主机 |
| workdir | <插件数据目录>/program/<hash>-<name> |
远端 ~/.commondev/program/<hash>-<name> |
| pi 实例 | sidecar 进程内 | 远端拉起(cwd = 远端 workdir) |
| 会话库 | 本地 --session-dir 指向 workdir |
远端(同样指向 workdir) |
| 前置条件 | 本机 node + pi | 远端 node + pi(缺时先问用户,§3.4) |
| 传输 | exec(stdio) |
ssh <host> + stdio |
两形态都必须遵守的硬约束:
- 侧车崩了要能恢复同一 session(
SessionManager导入已有 jsonl); - stdout 必须持续读(不读会堵住 pi);
- 只有
agent_settled才算「这轮真的完了」; - 同一个 session_id 绝不并发投递(§1.1 实测会写坏会话)。
远程额外约束:启动时校验远端 pi --version,不匹配响亮失败。
不要静默降级到本地跑 —— 那会让人以为活派到了远端,其实在本机跑。
3.3 投递纪律(必须做成机制,不能靠自觉)
agent_send(id, text, deliver_key)
① 查台账拿 (workdir, session_id, runtime, host) ← session_id 是唯一依据
② 单一投递 goroutine + mutex(per 工程对话) ← 结构性禁止并发
③ 写 inbox 文件(持久化,进程崩了不丢)
④ sidecar 投递(内嵌 prompt / RPC prompt)
⑤ ★ 重读会话,断言 session_id 未变 —— 变了即**响亮失败**(不静默改投新会话)
⑥ 记 deliveries(deliver_key, 状态, 结果摘要) ← 幂等键,重复投递不重复执行
两条必备判据(配变异):
TestAgentSendSerializedPerSession:同 session 并发投递必须排队(变异:去掉 mutex → 必须报红);TestAgentSendRejectsSessionDrift:会话漂移必须报错(变异:删掉断言 → 必须报红)。
3.4 ★ 远端环境缺失:询问用户,不得擅自安装
已核:SDK 没有"向用户提问"的专用 API(插件是子进程,不能直接弹 UI)。
现有机制是:插件把「需要决策」的消息注入给 agent(PriorityL2/L3),
由 agent 去问用户(本机先例:cli/plugin.go:473 的 /stop 用 L3,
timer/plugin.go:118 定时器用 L3)。
⇒ commondev 的 preflight 契约:
remote_preflight(host)
① ssh 连通性 → 不通:报错(属配置错误,不是缺环境)
② 远端 pi --version → 缺 pi :注入 L3「远端 <host> 未装 pi,是否安装?」
③ 远端 node --version → 缺 node:同一询问
④ 远端目录可写 → 不可写:报错(权限问题,需人处理)
三条纪律(缺一条就会出事故):
- 不擅自安装。缺环境时只询问,并把「装什么、装哪里、需要什么权限」说清楚,
由人决定。擅自
curl | sh到别人的机器上,是这类工具最容易犯的越界。 - 不冒充成功。环境缺失时
agent_up要响亮失败并留下一条待决策项, 不是静默降级到本地(那会让人以为活派到了远端,其实在本机跑)。 - 问完要能续。用户答"装"之后,安装动作本身也该是可见的一步
(能查、能重跑),而不是藏在
agent_up里的隐式副作用。
[待定 U2]安装的具体形态:由 commondev 执行(需在远端跑包管理器)/ 只输出可复制的命令让人去跑/两者都要。本文倾向后者默认、前者需显式授权。
3.5 ★ 自包含运行时(插件自带 node 与 pi)
[已定 2026-10-13 用户拍板]插件内嵌独立的 nodejs 与独立的 pi,都安装在插件工作目录;
pi 的插件也装到这个目录。这是"自包含"的含义:不依赖宿主机的 node/pi 版本,
也不污染用户的 ~/.pi。
3.5.1 目录布局
[已定 U7:随包分发]独立的 node 与 pi 随插件包一起分发(不首次下载、不依赖用户装)。
<插件数据目录>/ ← sdk.Settings().DataDir()
runtime/
node/ # 独立的 nodejs(随包分发)
pi/ # 独立的 pi(随包分发)
agentdir/ # ★ pi 的 agent dir(PI_CODING_AGENT_DIR 指这里)
settings.json # ← pi 自己的配置:偏好 / 默认 model / 「全局」包声明
models.json # ← pi 自己的配置:兼容端点与模型(LLM 源写这里)
auth.json # ← pi 自己的配置:API key / OAuth(0600)
mcp.json # ← pi 自己的配置:MCP 服务器
skills/ prompts/ themes/ extensions/
program/
mirrors/<platform>-<repo>.git/
<hash>-<name>/ # 每个工程对话一份;.pi/settings.json = 「临时」安装
为什么只用一个 agentdir:它就是 pi 的「用户级配置目录」,pi 自己的配置系统 已经给了我们所需的一切(§3.6)。插件不自建第二套配置存储。 根目录权限 0700(里面有
auth.json/models.json,均含明文凭据)。
3.5.2 ★ 两种安装粒度(用户明确要求)
| 粒度 | 作用域 | 落到实处 | 适用 |
|---|---|---|---|
| 临时(项目级) | 一个工程对话 | <workdir>/.pi/settings.json(pi install -l) |
只这个工程需要的插件 |
| 全局(插件级) | 插件自带 pi 的所有工程 | <agentdir>/settings.json(pi install + PI_CODING_AGENT_DIR) |
通用能力(如 pi-lens、subagents) |
⇒ 工具面:
commondev_pi_plugin_install source, scope=project|plugin, workdir? # ✅ 已实现
# 支持 npm / git / url / 本地路径(见下)
commondev_pi_plugin_remove source, scope # ✅ 已实现
commondev_pi_plugin_list scope? # ✅ 已实现
commondev_pi_plugin_update source?, scope # ✅ 已实现(PI_CODING_AGENT_DIR 隔离)
必须把 PI_CODING_AGENT_DIR 传给每个内层 pi 进程,否则「全局」安装会写进
用户的 ~/.pi/agent/settings.json —— 那是对宿主的越界。
3.5.3 支持的源类型 [已核 pi install --help]
| 类型 | 写法 | 备注 |
|---|---|---|
| npm | npm:@scope/pkg@1.0.0 |
带版本号即钉住 |
| git | git:github.com/u/r@v1 或 git:git@github.com:u/r |
tag/commit 钉住;更新不移动 ref |
| URL | https://github.com/u/r |
当作 git 源处理 |
| ssh | ssh://git@github.com/u/r |
|
| 本地 | ./path 或文件路径 |
不复制,直接引用;文件路径 = 单个扩展 |
三个安全纪律(包可执行扩展代码,而这是往工作区/插件里装代码):
- 默认不为工程静默安装:安装是要显式调用的一步,不能藏在
agent_up里; - 安装前说明来源:npm 包名 / 仓库 URL / 版本钉点,让人看得见;
- 来源不可信时不装:与 §3.4 同一条纪律(先问,不擅自)。
3.6 ★ LLM 源:默认用 HomeAgent 内部配置,支持添加与选默认
[已定 2026-10-13 用户拍板]插件内置 pi 默认使用 HomeAgent 内部配置的 llms 源, 支持添加其他源,且支持在插件中配置默认用哪个源。
3.6.1 源的真实位置 [已核]
内核把 LLM 源存在 config 表(不是 config_<plugin>),键前缀 core.llm.:
core.llm.provider = llmsproxy ← ★ 当前默认源(这就是"默认用哪个")
core.llm.sources.<name>.base_url = http://127.0.0.1:8081/v1
core.llm.sources.<name>.api_key = sk-gw-...
core.llm.sources.<name>.model = AUTO
core.llm.sources.<name>.adapter = openai
core.llm.sources.<name>.adapter_path = adapters/openai.lua
core.llm.sources.<name>.context_window = 1048576 (可选)
core.llm.sources.<name>.priority = 100 (可选)
core.llm.sources.<name>.vision|audio = true|false(可选)
core.llm.sources.<name>.thinking_enabled = false (可选)
内核侧已有现成控制面(不是新造):llm_list_sources / llm_set_source /
llm_current_source(spawn.go:338 起),背后是 ProviderManager
(Register / SetDefault / List)。
3.6.2 到 pi 的字段映射
内层 pi 要的是 models.json(providers.<name>.{baseUrl,api,apiKey,models[]})。
源一字段一对:
内核(core.llm.sources.<n>.*) |
pi(models.json) |
|---|---|
base_url |
providers.<n>.baseUrl |
api_key |
providers.<n>.apiKey |
adapter=openai |
providers.<n>.api = "openai-completions" |
model |
providers.<n>.models[].id |
context_window |
providers.<n>.models[].contextWindow |
vision |
providers.<n>.models[].input(含 "image") |
生成物是一个文件:<agentdir>/models.json(插件自己的,不动用户的)。
3.6.3 落地:直接写 pi 的配置文件,不另建存储
[已定 2026-10-13 用户拍板]pi 配置的源直接写入配置文件。
本稿前版提议的 config_commondev 已废弃 —— pi 本身就有完整的配置系统,
再建一套只会制造两处真相。
| 需求 | 落到实处 |
|---|---|
| 默认用内核的源 | 首次拉起时,把 core.llm.sources.<n>.* 写进 <agentdir>/models.json,key 写进 <agentdir>/auth.json |
| 支持添加其他源 | 直接追加到同一个 models.json(同文件、同格式),不另存 |
| 在插件中配置默认源 | 写 <agentdir>/settings.json 的 defaultProvider / defaultModel(pi 原生字段) |
⇒ 结果:内层 pi 看到的就是自己的配置,插件只是它的受控写入入口。
没有第二处真相、没有同步问题、pi auth check / pi list 等原生命令全部照常工作。
3.6.4 ★ 配套工具:插件作为 pi 配置系统的受控入口
[已定]插件要提供对应的 tool 进行对应的配置。 工具与 pi 的配置文件一一对应:
# —— 源与模型(→ models.json / auth.json / settings.json)——
commondev_pi_source_add name, base_url, api_key, model, [adapter, context_window, vision, ...]
# 写 models.json(+ key 写 auth.json)
commondev_pi_source_list [--with-key] # 列出来源(默认**不返回** key)
commondev_pi_source_remove name
commondev_pi_source_default name, [model] # 写 settings.json 的 defaultProvider/defaultModel
commondev_pi_source_import from_kernel # 把 HomeAgent 内部配置的源批量导入
# —— 其他 pi 配置面(同一套纪律)——
commondev_pi_config_get key, scope=plugin|project, workdir?
commondev_pi_config_set key, value, scope # 直接落 pi 配置文件
commondev_pi_package_install source, scope # 详见 §3.5.2(pi install)
commondev_pi_mcp_add/remove/list # → mcp.json
四条实现纪律:
- 写的就是 pi 的配置文件,不造中间层;格式与 pi 自己写出来的保持一致 (否则 pi 重写文件时会把我们的自定义字段抹掉)。
- 改前备份、改后校验:json 写坏了会让内层 pi 直接起不来; 写后必须回读校验,失败则回滚并响亮报错。
- key 不进日志/不进返回值:
source_list默认不返回 api_key(与commondev_account_list同一条纪律)。 - 不改用户全局:所有写入都带
PI_CODING_AGENT_DIR=<agentdir>或显式 scope, 绝不碰~/.pi/agent/。
3.6.5 ★ 两条安全红线
- API key 不能明文落盘到无保护的位置。
models.json/auth.json里的 key 是明文 —— 它们落在<agentdir>/,因此该目录 0700、文件 0600;与凭据文件同一套纪律 (前篇 §7.1)。内核那套是明文存config.db(且该库 644 全局可读), 不要把这套惯例带进来。 - 不得静默回退到宿主凭据:如果导入失败/源不可用,要报错,
而不是偷偷用
~/.pi/agent/auth.json里的用户凭据跑起来。
为何不复用用户的
~/.pi:那是宿主自己的 agent 环境,凭据归宿主。 插件作为独立工具,拿宿主凭据 = 权限越界(前篇 §7.1 同一理由)。
4. 生命周期与孤儿清理
[已核]内核的硬约束是「子不得比父活得久 / 无孤儿 goroutine / 无残留 temp」
(resident-subagent-design.md §10,写成 S16 判据)。commondev 必须按同一条纪律
管住自己拉起的进程:
| 层 | 谁销毁 | 何时 |
|---|---|---|
| pi 进程 / sidecar | commondev Stop() |
插件停止、工程对话 close |
| ssh 会话 | commondev | 同上 |
workdir(program/<hash>-<name>) |
commondev_workdir_clean |
合入(或废弃)后立即删 |
| 内核驻留子 | 父 agent(resident_agents destroy) |
主管决定;插件不能代劳 |
| 划入的 inputch | 内核在子销毁时归还 | 自动 |
workdir 删除时机是用户指定的:该分支的修改提交合入后直接删除目录 —— 不保留、不归档。目录内不得留有可复用状态(尤其是凭据,见前篇 §7.2)。
⇒ 插件 Stop() 必须是幂等的全清,并写成判据:TestStopKillsAllRuntimes
(变异:删掉某个 repo 的清理 → 必须报红)。
5. 语义缺口(必须显式取舍,不能假装等价)
[已核]内核的两类输入语义与 pi 的两种投递不等价:
| 内核 | pi | 差异 |
|---|---|---|
| 中断(有级别,抢占 + 保存现场 + LIFO 恢复) | steer(插到当前轮之后、下次 LLM 前) |
pi 不挂起/不恢复,是「并入当前上下文继续」 |
| 排队(无级别,FIFO) | follow_up(agent 空闲后投递) |
语义接近 |
⇒ 「打断 pi → 事后恢复原任务」这个内核语义,pi 没有。三个选项:
- (i) 接受降级:steer 即"插话",不承诺恢复(简单,但主管必须知道断点在哪);
- (ii) 插件补保存/恢复:记下 steer 前的目标,之后用
follow_up补投; - (iii) 用
--fork分叉:纠偏走新分支,原会话留作对照(代价:会话数增长)。
[待定]见 §6 U4。
6. 未决问题(需用户拍板)
| 编号 | 问题 | 本文建议 |
|---|---|---|
| U1 | commondev 的 L4 上限:接受 L3 / 改内置插件 / 加插件侧"上级"通路 | 先接受 L3 跑通;L4 等需求真出现再做(SetKernelSource 属 ABI 变更,要按"只增不减"+ 同步 SDK 仓 + hmapdev 模板) |
| U2 | 远端环境缺失时的安装形态 | 默认只输出可复制命令供人去跑;由插件代装需显式授权(§3.4)。环境缺失时先询问用户,不擅自安装 |
| U3 | pi 进程常驻保活 还是用完即弃(靠 session-id 保连续) | ✅ 已定:常驻(刀 1 已如此:sidecar 持续存活并复用同一会话,branch_close 才收) |
| U4 | steer 的语义降级取舍 | (i) 接受降级,但把"断点"写进台账供主管复查 |
| U5 | skill 的分发目标 | 双投:主管侧(HomeAgent <data_dir>/skills/)+ 执行侧(工作区 .pi/skills/) |
| U6 | 「工程对话」的用户可寻址名 | ✅ 已实现:sidecar 接收 --session-name,agent_up 返回并记账 session_name |
| U7 | 独立 node/pi 的获取方式 | ✅ 已定:随包分发(不首次下载、不依赖用户装) |
| U8 | 内层 pi 的版本锁定 | 部分实现:pi_version 已从 sidecar 回显并记账;随包独立 pi 二进制的发布锁定仍待发布流程完成,解析不到时显式警告 |
| U9 | auth.json/models.json 中 key 的处理 |
✅ 已定:常驻文件 + 0700/0600(实测生效:目录 0700、文件 0600 + 回读校验) |
| U10 | 插件私有源的存储位置 | ✅ 已定:不存在“私有源存储” —— 直接写 pi 自己的配置文件(§3.6.3)。插件自身的 Git 凭据存独立 0600 文件 <data>/credentials.json(已实现,启动时校验权限) |
7. 自检清单(逐条可复现)
# §1.2 自包含运行时:agent dir 可重定向、不污染用户全局
mkdir -p /tmp/st/{agentdir,work,pkg/extensions}
# 建一个最小包(package.json 带 keywords:["pi-package"] + pi.extensions)
cd /tmp/st/work && PI_CODING_AGENT_DIR=/tmp/st/agentdir pi install /tmp/st/pkg
cat /tmp/st/agentdir/settings.json # 期望 packages 写在这里
python3 -c "import json;print([p for p in json.load(open('/root/.pi/agent/settings.json'))['packages'] if 'st' in str(p)])"
# 期望 [] ⇒ 用户全局零污染
# §1.2 ⚠ 安装是复制:改源目录不影响已装的(我曾因此误判"插件不生效")
# §3.5.2 两种粒度
cd <workdir> && pi install -l ./pkg # 临时 ⇒ <workdir>/.pi/settings.json
PI_CODING_AGENT_DIR=<agentdir> pi install /abs/pkg # 全局 ⇒ <agentdir>/settings.json
# §3.6.1 LLM 源的真实位置
sqlite3 /home/newqqagent/config.db \
"select key,substr(value,1,60) from config where key like 'core.llm.%' order by key;"
# 期望:core.llm.provider=llmsproxy + core.llm.sources.<n>.{base_url,api_key,model,adapter,...}
# §1.1 会话续投(同一 session-id)
mkdir -p /tmp/p1 && cd /tmp/p1
pi -p --session-id chk-01 "记住暗号:紫罗兰-7734" # → 记住了
pi -p --session-id chk-01 "刚才的暗号?" # → 紫罗兰-7734
ls ~/.pi/agent/sessions/--tmp-p1--/ # 只有一个 chk-01 文件
# §1.1 并发打同一 session 会写坏(判据的反例)
# 两条 -p 同 --session-id 并发;检查 jsonl 里 user 消息是否相邻、assistant 是否错序
# §1.1 会话库可搬且不污染全局
pi -p --session-dir /tmp/sd-store --session-id sd-01 "…"
ls /root/.pi/agent/sessions/ | grep -i sd-test # 期望无结果
# §1.3 可接入面
grep -n "func (s \*PluginSDK) RegisterInputChannel" third_party/homeagent-sdk/sdk/plugin.go
grep -rn "resident_agents" internal/plugin/proc/ # 期望无结果 ⇒ 插件调不到
# §2.4 L4 归属
grep -n "isKernelLevelSource" -A20 internal/agent/core/scheduler.go # 期望两条分支
grep -n "clampExternalPriority" -A10 internal/plugin/proc/corehandler.go
# §2.1 inputch 路由发生在进内核之前
cat internal/agent/core/inputroute.go
8. 给接手者的三条提醒
- 不要另起一套调度器。内核已有四级中断 / 两类输入 / 可抢占 / 中断栈 / 驻留子 / 处理表 —— 插件的活是把 pi 接进 inputch 路由,不是重实现。
- 不要用提示词软约定替代机制。「让 agent 记得传
--session-id」是软约定; 台账记session_id+ 投递后断言未变,才是机制(§3.3)。 - 孤儿进程 = 插件的责任。内核对自己的子有硬约束(S16),插件对自己的进程 必须同样硬(§4)。