Skip to content

Latest commit

 

History

History
178 lines (149 loc) · 58.6 KB

design-harness-plugins.md

File metadata and controls

178 lines (149 loc) · 58.6 KB

Design: Codex 支持缺口 + 多 harness 抽象层 + 插件系统(2026-09-05 调研)

触发:GPT-6 Astra 发布(2026-09-03),owner 将把部分会话转到 Codex 上跑,并要求 (1) 盘点 VibeSpace 各功能在 Codex 上支持不好的地方 (2) 研究多 agent harness 抽象层 (3) 研究插件系统——未来 OpenCode、Gemini CLI 等 harness 以插件形式接入,插件还要支持界面自定义等复杂功能(参考 VS Code)。 调研产物:8 个并行 agent(5 个读代码:chat 管线 / 运维面 / 客户端的 Codex 分支、harness 接缝、现有插件系统;3 个外部调研:Codex app-server 协议与 GPT-6、OpenCode/Gemini/ACP、VS Code/Theia/Zed/Obsidian 扩展模型),代码行号为 2.369.16 master。上一轮调研 docs/design-backend-parity.md(2026-08-24)的 P0–P4 切片 1–3 已交付,本文在其上扩展;每节末尾标注跟踪归属(feedback_design_doc_debt 法则:无归属的节 = 红旗)。

0. 结论速览

  • Codex 今天就有一个 P0:codex-chat-wrapper 以 workspaceWrite 沙箱启动线程,而 codex 的 seccomp 网络策略默认禁 loopback(本机实测 codex sandbox -c sandbox_mode="workspace-write" 下 connect 127.0.0.1 → EPERM,CODEX_SANDBOX_NETWORK_DISABLED=1;AF_UNIX 同样 EPERM;加 sandbox_workspace_write.network_access=true 后 CONNECT OK)。结果:除 yolo 外每个 codex 会话里 vibespace-status/-task/-ask/-job/-msg/-page/-docs 全部 EPERM,而我们每 turn 还在教它用这些工具、Stop 时催它记账。2.369.17 修复(§1 P0)。
  • Codex 版本漂移是第二个坑:本机 codex-cli 0.149.1,最新 0.153.4;gpt-6-astra 从 0.153.1 起进 catalog,且 owner 账号的远程 catalog(2026-09-05 拉取)里没有 gpt-6-astra——要用 Astra 需先升级 CLI 并确认账号权限;0.153.4 起无 model 配置时默认模型翻成 gpt-6-astra(默认 effort LOW、Fast 档 2× 用量)——我们必须显式传 model/effort。(✅ B-21e4 item 4: wrapper 每个 turn/start 都显式带 model+effort——未命令的 effort 从 thread/resume 回复的 reasoningEffort 采纳; ws-create resume 无 effort 时带 lastCodexTurnEffort; gate test-codex-0153 ④)
  • 抽象层:注册表已覆盖 adapter/caps/normalizer/peerDelivery/客户端 META;剩余 if-chain 集中在 accounts.js(25)、ws-create.js(27)、发现/转录定位/用量 walker/stdout 管线/上下文注入,客户端还有 ~165 处 id 字面比较。第三 harness 今天要碰 ~15 个文件。目标形态是 HarnessDescriptor(一个对象 = 一个 harness 的全部声明 + 代码钩子)+ 一致性套件(每个注册的 harness 跑同一组断言)。
  • 通用第三方 harness 协议选 ACP(Agent Client Protocol, Zed+JetBrains 治理,v1 稳定 2026-06,SDK 1.0):Gemini CLI --acp、OpenCode acp、Copilot CLI、Cursor、Kiro、codex-acp 原生支持;Claude 不走 ACP(claude-agent-acp 基于 Agent SDK = 计量计费,违反 program-use 法则)。一个 ACP adapter ≈ 一次性接入 6+ 个 harness。
  • 插件系统:今天的 "plugin"(src/plugins.js)是两个硬编码 host 守护进程(tailscale/frp)的管理器,不是扩展机制;客户端 18 个天然贡献点全是编译期字面量/switch。目标是 VS Code 形态:一个 manifest + 派生激活 + 分层隔离(声明式 → 沙箱 iframe UI → 受信客户端模块 → 独立进程服务端插件 → harness 插件),贡献点直接映射到现有注册表。

1. Codex 支持缺口盘点(按严重度,去重后)

级 功能 Codex 现状 证据 修法
P0 agent 工具(status/task/ask/job/msg/page/docs)在会话内可达 default/safe-yolo/read-only 下沙箱禁 loopback → 全部 EPERM;只有 yolo 能用;同时终端模式因 codex-linux-sandbox PATH 探测失败被降级成 danger-full-access(同一"default"选择,chat 有沙箱、终端没有) codex-chat-wrapper.js:112-118,342,823;cli-env.js:90-105;adapters/codex.js:20-31,853-854;实测 EPERM wrapper 的 readOnly/workspaceWrite 策略带 networkAccess:true(VIBESPACE_API 存在时);终端路径加 -c sandbox_workspace_write.network_access=true;探测改为 codex sandbox -- true 功能探测;gate:test-codex-sandbox-net
P1 图片粘贴 / >64KB 帧 帧文件旁路按 backend id 排除 codex,wrapper 无 _frame_file 动词,不可解析行静默 continue —— 79928a2b 38MB 粉碎类事故对 codex 仍开放且无声 ws-handler.js:380;codex-chat-wrapper.js:187,1178 wrapper 实现 _frame_file + 宣告 caps.frameFile;旁路只看 wrapperCaps().frameFile;不可解析行记日志+task_failed —— 已交付(gate:test-chat-frame-guard §2b/§4;kb-bugfix-invariants "CODEX EXCLUDED FROM THE FRAME-FILE BYPASS BY BACKEND ID")
P1 peer/通知卡片(vibespace-msg、Background Work、auto-resume 公告) CodexMessageManager 无 injectPeerCard → feedPeerCard 静默 false:auto-resume 的"已安排/已续跑"在 codex 会话里永远不出现,job 通知无卡片进上下文,互聊消息成匿名 user 气泡 normalizers.js:37;codex-message-manager.js 实现 injectPeerCard(同 noticeKind);rpc-queue 通道传 fromName/cardText 让 wrapper 记带标签的记录 — 已交付:codex injectPeerCard(claude 同形卡)+ 帧带 fromName/cardText + wrapper 记录上的 webui_peer 标记(recordKey/mergeCodexRecords 剥掉它保持 buffer/rollout 双副本去重,两种时间序都钉)+ 无标记副本按锚定服务器 frame 回退取名;gate test-peer-msg-card 52 / test-peer-delivery 21
P1 ✅2026-09-06 每条消息的元数据 / 计费账号 / 使用模型(owner 2026-09-06:"codex会话是不是依然看不到每条消息的详细信息、计费账号、使用模型") CodexMessageManager._create 不带 meta → 左栏右键弹窗只有 Role/Time/uuid,Billing 行落到一个对所有 backend 都返回 null 的 session 回退(读的是 auth 从未有过的 accountName/kind 字段);数据其实全在 rollout 里(turn_context.model/effort、每个响应一条 token_count、0.153 的 token_usage_record 带 response_id) codex-message-manager.js;usage-walker.js / vibespace-usage-scan;account-usage-routes rid-info;chat-view _showMsgMeta 已交付:normalizer 在 token_count 处把同一 meta 挂到该响应自上次 token_count 以来创建的全部 assistant/tool 消息(requestId = 与账本完全相同的 cx:<thread>:<cumulative> 合成键并标 requestIdKind:'ledger',msgId = 前置 token_usage_record 的 resp_… 并标 msgIdKind:'response',usage 用账本口径 fresh=input−cached + reasoning,effort/model 随 turn_context;活流走 claude 同款 edit op);账本 walker v3 三处同步加 mid+effort、rid 不动(永久账本的去重键);rid-info 加 be/model/effort;弹窗新增 Reasoning tokens / Effort / Ledger request key / Response ID 行(claude 行不变),sessionBilling 改读真实 auth.source;gate test-codex-history 48 / test-usage-walk-parity 32 / test-codex-p2-wrapper 33
P1 ✅B-7473 子代理消息的归属(0.153 multi-agent v2) 根窗口把子代理的 FINAL_ANSWER 当成自己的 assistant 回复渲染(owner: “根本没区分出这是subagent消息”),部分还出现两次 —— wrapper 的 item/started completed 处理器只取 params.item 而丢掉 params.threadId(app-server 转发它托管的每个线程的通知),normalizer 又把 rollout 里 /root/x → /root 的 agent_message 明文当普通卡;item_completed 整类被 skip,而它是 0.153.4 里 SubAgentActivity 的唯一 rollout 载体(本机 25 个 0.153.4 rollout 全扫:214 条 item_completed/SubAgentActivity,0 条 event_msg sub_agent_activity) codex-chat-wrapper.js:801-806,565-580;codex-message-manager.js:143(SKIPPED_EVENT_TYPES);真实 rollout 01a0733f…(2072 记录 / 12 子代理 / 137 条 agent_message,110 密文 + 22 明文 FINAL_ANSWER)+ 生产 buffer sess-4-1788639964629.buf 第 414/859 行(同一文本在根 rollout 里是 /root/usecases_v4 → /root 的 agent_message)
P1 codex 池化账号的 UI 2.368.20 冷切池无任何客户端操作面(切目标/成员/自动切换);三处图标/标签错 accounts.list() pooled 分支;manage-agents _renderCodexAccounts list() 先评估 pooled 分支;codex 账号面复用 claude 的池菜单块
P1 resume 双写保护 resume-already-live 守卫 + 预 resume writer sweep 都豁免 codex → 两个 app-server 压一个 rollout(B-4058 类)无拒绝无警告 ws-create.js:48;writer-sweep.js 守卫按 backendSessionId 覆盖 codex;sweep 加 codex 臂(fd 扫 rollout-*-$RID.jsonl + codex app-server argv)
P2 ✅2.369.20 → 两种发送模式 ✅2026-09-06 忙碌时发送 Review/Compact 进行中的输入被 ActiveTurnNotSteerable 拒绝,文本丢失;Regular turn 时是 steer 不是 queue(与 claude 语义不同且 UI 未说明) codex-chat-wrapper.js:867-882 有 activeTurnId 时走 thread/queue/add(镜像 peer-message 路径),UI 显示"已排队" —— 2026-09-06 补完 owner ask「codex 有 QUEUED / STEERED 两种发送模式,队列卡片上要能转成 steer」:能力化(不是 backend id)backend-caps.inputModes {queue, steer, queueOps}(codex 三真 / opencode 只 remove / claude 全无队列面 / shell 无);adapter 动词 formatQueueOp({op,id})(能力不允许的带理由拒绝,绝不发一个 wrapper 会静默丢弃的帧);ws 唯一新 case queue-op 按能力校验并回 code:'queue-op-unsupported';wrapper 拥有队列(app-server 才是真相): 实测 0.153.4 无 thread/queue/remove——删除动词是 thread/queue/delete {threadId, queuedSubmissionId},thread/queue/changed 只带 threadId 故每次变更都重新 list,turn/steer 不会把队列项摘掉(即使带同一 clientUserMessageId)所以 steer 成功后必须自己 delete,一个 turn 内允许多次 steer(所以 steer-all = 按队列顺序逐条 steer,各自保留自己的 input),idle 时 add 会由 app-server 自己起 turn(排空由它做,我们从不为此调 turn/start);顺序 = 先 steer 成功再 delete(review/compact 的 ActiveTurnNotSteerable 与 turn 已结束都是设计内路径,失败时该项必须原位留在队列);normalizer 把 queue_changed 变成 meta{subtype:'queue'} 会话状态并给原气泡打 queued/steered/removed 芯片,客户端在输入框上方渲染队列条(Steer now / Remove / >1 时 Steer all,Enter 可 steer 聚焦行);多条队列语义 = steer 第 N 条只注入第 N 条,其余保持相对顺序、turn 结束后照常运行,被 steer 的项离开队列(绝不跑两次);ACP 那半边同批交付(wrapper 的 promptQueue 现在也 publish + 支持 remove,steer 由能力挡在 ws 层)。2026-09-07 owner 定案「stop clear queue 没啥问题」——Stop 现在在每个 harness 上都清空队列:codex 的 interrupt 先 clearQueueForStop()(list + 逐条 thread/queue/delete)再 turn/interrupt——顺序是修复本身:app-server 在 turn 结束时会自己排空队列(被 interrupt 结束的 turn 也算),所以先 interrupt 再删就输掉了这场竞态(stub 故意保留 interrupt 后的排空,负控会看到 Stop 后 session 仍在跑被排空的那条);每条掉队项发与 ACP 同一帧 queue_op_result {op:'remove', ok:true, msg_id, reason:'stopped'},且必须在清空版 queue_changed 之前(无结果地离开队列 = normalizer 判定"它跑了"并清掉芯片);delete 失败 = ok:false + journal(该项仍在队列且会运行),绝不静默;队列里的 peer/job 消息按显式 remove 的老规矩退回投递梯(peer_message_result ok:false → 重新 stash)。2026-09-07 round-3(对抗验证):第二次 Stop 是常态(双击 / 第二个客户端),而 stdin 派发 handleInput 是不 await 的 —— 第二次扫描会把第一次正在删的队列重新 list 出来,然后把刚被报告 stopped 的条目报成 gone("它已经跑了"),正是本轮要消灭的那句假话,且 gone 这条路上 peer 消息不回退投递梯 = 又丢一条已承诺的消息;修复 = clearQueueForStop 单飞(第二帧搭车;它清掉的是那次扫描 list 到的东西,之后新入队的由收尾 publish 如实列出) + interruptTurn(turnId) 按turn + 在飞 RPC 合并(绝不用时间窗;RPC 已应答而 turn 还活着 = 真重试,照发) + 扫描抛错也绝不吞掉 interrupt;steerOne 同样读 delete 的裁决({deleted:false} = app-server 已排空 ⇒ 它会跑两次 ⇒ steered-not-dequeued 出声,r2 只在扫描里读了);客户端 Stop 按钮在 interrupt 在飞期间 disabled + "Stopping…",由 turn 结束或 8s 兜底解除(showTyping 每次换 label 都重绘整行状态,正是它把可点的按钮还回去的)。gate:test-queue-steer(新,含 headless-chrome 的按钮腿) / test-codex-p2-wrapper(②e 慢 stub 双 Stop + ②f 被排空的 steer) / test-acp-harness / test-peer-delivery
P2 ✅2.369.20 活流里 MCP 调用 / 动态工具 / 图片查看 / 上下文压缩不可见 只在重 attach 合并 rollout 后出现;活 turn 里几分钟只有"thinking…" wrapper 事件映射 wrapper 把 mcpToolCall/dynamicToolCall/imageView/contextCompaction 记成可渲染记录
P2 ✅2.369.20 斜杠命令 零可发现命令;/compact 显示误导标签且浪费一个模型 turn chat-input/normalizer init normalizer init 合成命令表(/goal /compact /review /model /effort),/compact→thread/compact/start
P2 ✅S4 auto-resume 预触发探测 pre-fire 用陈旧快照;getQuotaProbe 固定走 claude 的 claude -p /usage(对 codex 身份是白烧一个 claude 进程) usage-pool-engine getQuotaProbe 已修(S4):engine probeQuotaForKey 按 harness 注册表的 quota.probe 分发——'rpc-rate-limits' → 活 codex 会话 codex-read-limits 并等 rate_limits_updated(cache 写入后才 settle);'cli-usage' 保持 refreshViaCliPanel;shell/未知 = 不跑;wall 验证探测 / 探测阶梯 / beforeAutoResumeFire 三处同一分发器;gate test-quota-source
P1 ✅2.369.58 ServerRequest 回包形状 respondToServerRequest 只按名字认一个方法,其余一律 {decision:…}——十一个方法里有四个的 schema 根本没有 decision 字段(permissions 要 GRANT、requestUserInput 要裸 answers、elicitation 要 action、v1 那对说 ReviewDecision),服务端反序列化失败 = turn 永久挂起且无人能解释;currentTime/read 之类只有客户端能答的请求还被做成人工卡片 codex-chat-wrapper.js respondToServerRequest SERVER_REQUEST_SPEC 每方法一条显式分支;到达时即分类 auto / unsupported(JSON-RPC −32601 错误 + 可见系统行,绝不错形状也绝不挂起)/ ask;MCP elicitation 走现有提问卡片族,答案按 requestedSchema 定型;gate test-harness-honesty
P2 ✅2.369.58 ACP 未知 sessionUpdate default: return; 静默丢弃——新上游种类变成看不见的产品缺口(claude/codex 两侧都被这一类咬过) acp-message-manager.js _processUpdate 每 kind 一次 telemetry acp-unknown-update:<kind> + 一行 console;opencode zod union 的第 11 种 session_info_update 显式认领为"已知但不出卡"
P2 ✅2.369.58 codex 活/rollout 孪生:imageGeneration + sleep wrapper 的 item 链两者都没有分支——生成的图在流式期间完全不可见(要 re-attach 合并 rollout 才出现),20 分钟的 clock.sleep 看起来就是卡死 codex-chat-wrapper handleItemStarted/_handleItemCompletedInner;codex-thread-read;codex-message-manager 三个生产者统一到 rollout 自己的拼法(item_completed 的 Extension/image_gen.generation|clock.sleep)→ 一条卡片路径;图走 view_image 的多媒体卡(缩略图 + .src 缩放),sleep 走活倒计时行、完成即冻结;base64 result 永不进卡片;gate test-harness-honesty 三方 shape-equal
P2 stdin ack wrapper 冷启动(大线程 resume)>5s 无输出 → 服务器判 pty 坏、重发同一 chat-input → 就绪后处理两次(重复 user 气泡 + 第二条 steer 第一条) codex-chat-wrapper.js stdin handler stdin 行处理器顶部立即 emit _stdin_ack(服务端已消费)
P2 ✅2.369.21 新手引导 / 计费切换器 / fork 引导只认 claude 命名账号;切换器 codex 行不显示配额;codex fork 服务端已通但 caps.fork=false setup-flows.js:265;session-lifecycle usageFor;agent-meta caps per-backend 计算 namedLoggedIn;usageFor 读 _codexAccountUsage;真 wrapper 测过 thread/fork 后翻 caps.fork
P2 发现热路径 5s /api/sessions 同步走 ~/.codex/sessions 树 + /proc(NFS 下每次 poll 都卡);localDiscovery 开关对 codex 无效 codex-session-store.js:46-118 走 transcript worker + mtime 缓存;agentd.localDiscovery 时消费 devSnap.codexRollouts
P2 远程 / 配对设备 远程 codex 线程无名字、host 终端里活着的线程显示 stopped(Resume 造第二个 writer);无 dial/daemon pipe codex 会话;incident 冻结拿不到 codex 转录 discovery-facts / remote-shell / incident.js ssh 脚本与 daemon 快照发 NC/CO 行;R6 按 streamProtocol 路由 pipe 会话;incident ✅ B-8ebb: incident.js 经 harness 注册表 store.locate/remoteFind 冻结 codex rollout(本机+远程探测, .zst 含),无 backend 三目
P3 ✅2.369.58 personality ↔ 输出风格(本行已修:写死的 personality:'pragmatic' 覆盖用户 ~/.codex/config.toml 已删——值来自 claude outputStyle 用的同一个 per-session 槽,默认 unset ⇒ 整个 key 不上线;thread/settings/update 让运行中会话热切风格,菜单的"重启生效"行按 backend-caps responseStyle.live 门控绝不按 backend id;枚举取自 codex app-server generate-json-schema)、状态标签、权限模式下拉首帧、effort/model-lock/设置前缀的 id 分支、远程 ⟳、终端 idle 检测、autoResume 设置命名、占位文案、design-kit 教学、host 配额快照、转录 rescue、keeper 重认领、远程活投递、外部 codex 终止、sidecar todos 恢复、workflow 芯片 cap、侧栏 backend 过滤默认 各 1 处,见 §附录 A 原始条目 — 随 §2 S7(客户端 caps 描述符)与 S3/S4 一起消化

外部调研带来的额外硬约束(Codex 侧):

  • 版本:0.153.0 起 rollout 可能是 .jsonl.zst;fork 带 forked_from_id + forked_from_ordinal_exclusive;(✅ B-21e4: extractCodexThreadMeta 读 forked_from_id/forked_from_ordinal_exclusive/history_base/subagent_history_start_ordinal, 只读线程自己的 session_meta; resolveCodexForkAncestry 把 Referenced fork 的父前缀按 ordinal 截断后前置, wrapper 链整体合并不变, 原生父线程不隐藏; gate test-codex-0153 ①)新行类型(world_state、inter_agent_communication*、retained_context、token_usage_record…)——session-store/usage-walker/parity 扫描器必须容错并支持 zst。(✅ B-21e4 item 2: CodexMessageManager 显式 skip 集 + 未知类型 telemetry codex-unknown-record:<type> 一次; agent_message / sub_agent_activity 渲染为 'agent' 折叠类的完整 tool 卡; gate test-codex-0153 ②。B-7473 起这两类改成 collab 行/汇报卡, 且 item_completed 从 skip 集移出改为路由——它是 0.153.4 里 SubAgentActivity 的唯一 rollout 载体)
  • effort 枚举扩到 none/minimal/low/medium/high/xhigh/max/ultra/persistent/custom,ultra = 主动多 agent 委派(额外子线程烧量)——控件与用量表要接受未知值并对 ultra 提示。(✅ B-21e4 item 3: 三处选择器(新建会话/设置/状态栏)从 models_cache 的 supported_reasoning_levels 并集/当前模型列表出 ultra, 标签经 agent-meta effortLabel 带 META.effortHints 提示, zh/ja 已补; gate test-codex-0153 ③)
  • resume:分页时代用 thread/resume {excludeTurns:true} + thread/turns|items/list;全量 hydration 已 deprecated;thread/read {includeTurns} 是死会话只读回放的正解。(✅ B-21e4 item 5: src/codex-thread-read.js — 无 rollout 文件的线程经 descriptor store.warmTranscript 用一次有界 codex app-server thread/read 读回并映射成 rollout 记录, 本机 only, 缓存; excludeTurns 分页未接——我们的读者是整文件/尾部读, thread/turns|items/list 留给 R6 daemon-pipe 期; gate test-codex-0153 ⑤)
  • 配额被动捕获免费:每条 rollout token_count 与 account/rateLimits/updated 都带 primary/secondary/resets_at/plan_type/credits/rateLimitReachedType——可直接喂 usage-anchors/estimator(今天只进 sidecar 展示)。
  • 进程模型机会:codex app-server daemon + unix 控制 socket + TUI 重连(0.153.0)= 一台机器一个长命 app-server 拥有多线程并跨 VibeSpace 重启——R6 daemon-pipe 可对接该 socket 而非每会话一个 app-server。
  • 审批卡新变体:decision ∈ accept|acceptForSession|decline|cancel|acceptWithExecpolicyAmendment、网络审批(networkApprovalContext)、item/permissions/requestApproval、item/tool/requestUserInput(阻塞 1–3 问)。
  • 上下文注入:thread/inject_items 已在用;turn/start.additionalContext 与 developerInstructions 是更干净的 per-turn 通道;hooks 在 codex 已一等(SessionStart/UserPromptSubmit/Stop… 且需 config.toml [hooks.state] 内容哈希信任)。

跟踪:P0 → 2.369.17(本次);P1 四项 → B-codex-p1(新);P2 → B-codex-p2(新);P3 并入 §2 S7;版本/zst/effort → B-codex-0153(新)。原 B-c158 由这三项替代。

2. 多 harness 抽象层

2.1 现状(接缝分类)

  • 注册表(drop-in):adapters/index.js(但 cmd 解析/wrapper 路径/探测在 cli-env.js 另一份)、backend-caps.js、normalizers.js、conversation-deliver 的 peerDelivery 通道(唯一有文档化合同的通道,其它接缝的范本)、客户端 BACKEND_META。
  • 接口:base.js 只声明 7 个 stdin 格式化方法("一个后端"含义的 ~20%);ws-create 实际还要求 buildSessionArgs(未声明)。
  • 孪生文件:chat-wrapper.js / codex-chat-wrapper.js(stdin 动词集各自私有,codex-only 动词在 ws-handler 里字面分发);session-store.js / codex-session-store.js(${backend}:${id} 会话键在 ≥7 处重实现)。
  • if-chain(行为分叉,第三 harness 必改核心):accounts.js(25)、ws-create.js(27)、routes/sessions.js 发现、transcript-service 定位/拉取/搜索三目、usage-walker 双 walker 内联块、session-stdout 两条 ~300 行管线、agent-routes 注入拓扑、客户端 ~165 处(chat-status-bar 折叠、session-lifecycle 计费切换器对第三 id 直接 return、layout.js || 'claude' 默认填充)。

2.2 目标形态:HarnessDescriptor + 一致性套件

// src/harnesses/<id>/index.js — 一个 harness = 一个描述符(声明 + 代码钩子)
module.exports = {
  id: 'gemini', label: 'Gemini CLI', icon: 'brand/gemini.svg', brandColor: '#…',
  caps: { ...BACKEND_CAPS 行, frameFile, fork, effortLevels|effortSource, permissionModes,
          outputStyle: 'style'|'personality'|null, slashCommands: 'cli'|'synth'|null,
          inject: 'hooks'|'wrapper'|'acp', workflows: false, quotaRefresh, sandbox },
  spawn:   { buildSessionArgs(opts) → {cmd,args,env,wrapper,cwd,mode}, remotePrelude, probes() },
  wrapper: 'data/bin/<id>-chat-wrapper.js',      // WRAPPER CONTRACT(sidecar meta + stdin 动词 + streamProtocol 事件)
  normalizer: <class>,                            // NORMALIZER CONTRACT(onOp/processLive/convertHistory[Async]/tail/turnMap/goalState/injectPeerCard/taskState/collapseKind)
  store:   { discover(machine), locate(id,cwd), parse(records) → SessionMessages 形状, writerSweep(id), forkChain(id) },
  accounts:{ credsScheme, readAuth(dir), spawnEnv(dir), loginFlow, shipRemote },
  usage:   { walk(records) → ledger 事件, pricing, quota: { normalize(raw) → {fiveHour,sevenDay,scopedWeekly}, onStreamSignal, probe(session), classifyAuthFailure } },
  inject:  { teach(session, ctx) },               // hooks 注册 或 wrapper 注入 或 ACP prompt 前缀
  client:  { META 行(含 caps/fallbackModels/permissionModes/placeholders/settingsPrefix), settingsKeys }
};
  • 合同用基类固定(BaseWrapperContract 在 wrapper 侧以协议测试固定,BaseNormalizer、BaseStore),src/harnesses/index.js 是唯一注册表:内建 claude/codex/shell + 插件贡献的 harness;核心任何地方禁止 || 'claude' 默认(未知 = 响亮失败,gemini-as-claude 类事故根除)。
  • scripts/test-harness-contract.mjs:对每个注册 harness 跑同一组断言(描述符完整性、wrapper 动词集、normalizer 方法集、store/usage 纯函数在 fixture 上的形状、客户端 META 完整性)——"twin-sets=0 是指标不是状态"的通用化。
  • stdout 管线:session-stdout 的两条内联管线改为 descriptor.stream.parse(line) → 归一化事件(id 采纳/todos/配额/墙信号/peer 去重各一份消费者),daemon 侧共用同一模块(三层法则)。S5 落地形态(见 §2.4):descriptor 只用 caps.streamProtocol 命名协议,src/server/stdout/index.js 注册表解析出消费者;消费者留在 ORCH(依赖 engine/hosts/ledger),daemon 侧不共用——设备流仍走 session-brain 的 claudeSideEffects。

2.3 通用第三方 harness 协议:ACP v1(2026-09-05 二次调研后定案,owner 拍板)

三组调研(中文生态+DeepSeek / 开源通用 / 厂商 CLI)结论一致:ACP v1(stdio JSON-RPC,schema 1.7.0,TS SDK 1.4.0,Zed+JetBrains 治理)是唯一跨厂商覆盖"会话生命周期 + 流式工具调用(kind/status) + 权限往返(allow_once/always/reject) + 配置项(model/mode)"的协议;ACP 注册表 40 个 agent / 99 个 client。v2 草案(2026-07)更合我们的模型但零实现,按版本协商预留。远程传输仍是 RFD(Streamable HTTP/WS),与 R6 daemon-pipe 一致:拥有机器上的 agentd 做 ACP client。

harness 版本 (2026-09-05) ACP 备注 / 特殊处理
OpenCode (anomalyco) 1.18.29(本机已装) 原生 opencode acp(load/list/fork) 首发目标:models.dev 覆盖 DeepSeek V4/Kimi/Qwen/GLM/MiniMax 含 coding-plan 端点;另有 opencode serve HTTP+SSE(S9 原生 adapter);ChatGPT/Claude OAuth 登录在 OpenCode 内属厂商 ToS 灰区,不作为我们的默认
Kimi Code CLI (Moonshot) 0.41.0 原生 kimi acp,SDK 1.3:ACP 面最全(load/list/resume/fork/close/delete、image、elicitation、set_config_option/set_model) 需 kimi login(会员);kimi web REST+WS
Qwen Code (Alibaba) 0.23.0 原生 qwen --acp(SDK 0.14,loadSession,无 fork→spawn 时 --fork-session) Qwen OAuth 免费层 2026-04-15 停;qwen serve 含 ACP Streamable-HTTP /acp
Qoder CLI / CodeBuddy Code 1.1.45 / 2.146.0 原生 --acp PAT / API key;CodeBuddy 有 --serve HTTP
DeepSeek Harness (dsh) 0.1.2-rc.1(dev preview) 原生但"automation-only"(无 load/fork/modes/terminal/elicitation) API key only;会话日志 zstd JSONL;社区 dsh-acp-enhanced 补齐 load/list/流式——存在时优先
Goose / Cline / Kilo / Mistral Vibe 1.49.0 / … 原生(Goose 另有 ACP-over-HTTP goose serve)
Copilot CLI / Cursor CLI / Kiro CLI / Devin CLI / Junie CLI 1.0.83 / rolling / 2.21.0 / 3000.6.14 / 1468.30.0 原生 v1 Copilot 另有 TCP;Cursor/Kiro 无 list/fork
Gemini CLI 0.58.0 原生 --acp 消费者 OAuth 2026-06-18 起拒绝,仅 API key/企业;继任 Antigravity CLI 无 ACP → 不再作为首发
ZCode (Zhipu) 3.11.2 桌面 非原生:zcode-acp-server 桥接其 app-server 或注册表的 glm-acp-agent
Codex 0.153.4 非原生:codex-acp(ACP 组维护,非 OpenAI) 保留我们的原生 app-server adapter(能力更全、无翻译层)
Claude Code 2.1.257 非原生:claude-agent-acp = Agent SDK 通道 永不(计量计费,违反 program-use 法则)
Amp / Aider / Crush / Trae / iFlow / MiniMax — 社区桥 / 已弃 / 无 不做 adapter;模型经 OpenCode

形态:src/harnesses/acp/(一个描述符模板 + per-agent 小配置:可执行/参数/auth 方法/store 位置/能力探测);data/bin/acp-wrapper.js(在 dtach/daemon 下拥有 stdio,把 session/update 记录进 buffer 文件 + sidecar,实现 WRAPPER CONTRACT 动词:chat-input→session/prompt、interrupt→session/cancel、permission-response→request_permission 回复、set-model/set-mode→set_config_option、peer-message→prompt 前缀、_frame_file、_stdin_ack);ACP normalizer(tool_call kind → collapseKind;permission 选项 → harness 中立权限卡;agent_thought_chunk → 思考块;plan → todos;available_commands_update → 斜杠命令);resume 用 session/load(无 load 的 agent 走 session/resume 或按 store 回放);adapter 按 initialize 返回的 agentCapabilities 决定 fork/list/load/image 等 caps(能力驱动,不写死)。gate 用 mock ACP agent(scripts 内一个最小 ACP v1 agent:initialize/session/new/prompt→tool_call→request_permission→agent_message_chunk→end),真 agent 用手动脚本(OpenCode 装好即跑)。

跟踪:S8 = B-a140(首发改 OpenCode,mock-agent gate);S9 = B-03f2。

2.4 切片(每片一个 gate,可独立发版)

切片 内容 gate
S1 ✅2.369.18 HarnessDescriptor + src/harnesses 注册表,把 claude/codex/shell 原样包进去(零行为变化);删除 cli-env 里的第二份 adapter 配置 test-harness-contract(3 harness)
S2 ✅2.369.27 (creds descriptor: dirs/auth/files/seed/remote-ship/host-facts/bump/oat; 保留 resolveForSpawn 分派 + claude-only hostSubs) accounts.js 凭据策略对象(25 处三目收口:credsScheme/readAuth/spawnEnv/loginFlow/shipRemote) test-account-pool + test-harness-contract S2 pins
S3 ✅2.369.31 (store descriptor + worker-side codex discovery + .zst + NC/CO remote facts) store:discover/locate/parse/writerSweep/forkChain 归描述符;routes/sessions 与 transcript-service 只调描述符;zst 支持 test-transcript-parity / test-discovery-interpret 扩到每 harness
S4 ✅ usage/quota:QuotaSignalSource(normalize/signalFromStream/probe/classifyAuthFailure);getQuotaProbe 按 caps 分发(顺带修 §1 P2 的 auto-resume 探测)。已交付:src/harnesses/{index,claude,codex,shell}.js 最小注册表(S1 未落地时先行,get() 未知 id 响亮失败)+ claude-quota/codex-quota(claude 与 codex 的 normalizer 原样迁入,usage-routes 只重导出;claude 的 stream 信号=rate_limit_event+banner,codex=rate_limits_updated/task_failed typed enum/reset_credit_result);engine 的 recordRateLimitEvent/recordCodexQuotaSignal/notePoolAuthFailure 经注册表取信号与分类器;probeQuotaForKey 分发器替换三处 getQuotaProbe 直呼。零行为变化(signal 分类与旧分支逐一等价)。未动:session-stdout 的两条管线仍各自喂 engine(S5),codex auth-failure 仍只上报不驱逐(credsTokenSig 是 claude creds 路径,S2 归属) test-quota-source(新)+ test-codex-quota / test-codex-pool / test-auto-resume / test-pool-auto / test-vendor-whitelist
S5 ✅ (stdout consumer registry;descriptor 只命名) session-stdout 的三条内联管线(claude stream-json / codex-events / acp-events)原样迁入 src/server/stdout/.js(主体逐行 === HEAD,仅缩进),注册表 src/server/stdout/index.js 按 descriptor 的 caps.streamProtocol 解析出唯一消费者(create(deps) → {protocol, attach(session,id,pty,helpers)};descriptor 不加 stdout:{protocol} 副本——caps 行是唯一真源,contract 套件钉死没有副本),setupSessionPty 只剩查表 + attach;未知协议在会话启动时响亮失败(console.error + chat-protocol-no-consumer 事件 + RAW 透传,绝不回落 stream-json;无协议沿用原文案)。刻意偏离本表原文:"descriptor.stream.parse + daemon 共用"未做:三个消费者都依赖 orchestrator(pool/quota engine、hosts.harvestUsage、ledger、goal sync、delivery ladder),SHARED 描述符装不下(test-architecture 禁止 SHARED 上探 ORCH),设备侧仍由 session-brain 的 claudeSideEffects 经 sbSeenFirst 补位(本片未动)。零行为变化:per-attach 闭包状态(lineBuf / ANSI stripper / subagent watcher 绑定)留在 attach 闭包,其余字段仍在 session 上 test-stdout-registry(新:注册表形状 + 未知协议响亮(带负控) + 每消费者假 pty 喂真实 normalizer 代表记录 + id 采纳/streaming/_stdin_ack/todos/engine 调用 + wiring pins)+ test-harness-contract(每个 chat harness 的协议有消费者、无死行、无副本)+ test-session-brain-dark 不变 + 迁移的 pins(attach-rebuild / chat-frame-guard / acp-harness / tool-progress / compaction-ux / auto-resume / codex-pool / peer-delivery / peer-msg-card / quota-source)
S6 ✅2.369.22 上下文注入策略对象(hooks/wrapper/acp);agent-tool-generators 的 harness→hook 文件映射进描述符 test-agent-hooks
S7 ✅2.369.23 (pins 2.369.26) 客户端:META caps 补齐(effortSource/modelLock/settingsPrefix/accounts/usageBucketType/permissionModes/placeholders/workflows/terminalIdle),消灭 ~165 处 id 比较;test-architecture 断言 src/lib 里零 `=== 'claude' 'codex'`
S8 ✅2.369.29 (generic ACP v1 harness + OpenCode; 剩: stopped会话发现/远程spawn) ACP harness(Gemini CLI 首发)作为第三 harness 的证明;acp-wrapper + normalizer + store facts test-acp-harness(真子进程:initialize → prompt → tool_call → permission → cancel)
S9 ✅✅ (store facts 2.369.50 + REMAINDER 全部落地 B-eac2) OpenCode serve-mode STORE: src/opencode-serve.js(127.0.0.1 client 1.5s 预算 + locator/keeper: data/opencode-serve.json 复用或首次 discovery 时懒启动 opencode serve --port <free> --hostname 127.0.0.1、backoff、5 次崩溃 parked、退出即停 + 'acp-events' 合成:text→agent_message_chunk/user、tool→tool_call+tool_call_update、reasoning→agent_thought_chunk)+ opencode.js store(discover 10s 缓存/10s 负缓存/挂起不阻塞轮询、createReader=serve 回填的 AcpSessionMessages 只读、forkSession=POST /session/{sessionID}/fork、forkChain=[]、locate 仍 null);ws-create 先 mint fork id 再 resume(失败响亮拒绝,绝不退化成双写 resume);capsOf('opencode').fork 只按 serve OpenAPI 证据翻(setVerifiedCap)+ 'harness-caps-updated' 广播;S9 REMAINDER(B-eac2, 2026-09-07, owner「能做就都做了」)——上面这排「未接」全部接完, 每条都先在真 1.18.29 上量过:
⒜ revert/unrevert(revertTo/unrevert + POST /api/opencode/{revert,unrevert}):/proc 实测 v1 POST /session/:id/revert 不起 instance(16线程/0索引线程, 前后一致)却真的把工作树恢复了, 而 v2 三件套里随便一个(/api/session/:id/revert/clear)就 16→37 线程/0→19 索引线程/+165MB ⇒ 只接 v1, v2 的 stage/clear/commit 明确不接(v1 的 unrevert 就是 clear; v1 没有 commit, 下一条 prompt 即 commit——UI 照实说)。staged 状态挂在 Session 自己的 revert 字段上, messagesToAcpRecords 在回滚边界前插一条 notice(noticeKind revert), discover 条目带 opencode.revert 供聊天菜单只在真有 staged 时才显示「恢复」。
⒝ question(GET /question + reply/reject, 都不起 instance):question 工具部件 → 与 claude AskUserQuestion 同一张卡(permission_request kind user_input), 带 via:'opencode-serve' 让 ws 层走 serve 路由而不是会话 stdin(只读打开的已停止对话根本没有活进程)。真实形状实测:pending 是 {id:'que_…',sessionID,questions:[…],tool:{messageID,callID}}, 已答部件的 state.metadata.answers = [["Blue"]](每问一个标签数组, 按序), reply {answers:[["Blue"]]} 真的让那个 turn 继续。刷新存活:转录只有 callID、活路由要 que_…, 由 readConversation 做联接(活映射冷时补读一次 GET /question); 联接不上就打 stale, 卡片不给 Submit。可达性(实测记录, 不藏):opencode acp(我们活会话跑的模式)根本不提供 question 工具, 且 question 事件是每进程的 ⇒ 这条道只对 serve 驱动的 turn 生效。
⒞ pty:GET /pty/{id}/connect 在未加密的 loopback serve 上免 ticket 直接升级(而 connect-token 反而 PtyForbiddenError);TEXT 帧=终端输出, BINARY 帧=\0+控制 json(渲染出去就是往用户 shell 里画 json), 写进去的帧真的进 pty;resize 不在 socket 上而是 PUT /pty/{id}。src/server/opencode-pty-bridge.js 在服务端连这个 ws 并交出与 daemon pipe 同形的 shim ⇒ ws-create 直接喂 setupSessionPty, xterm/输入/resize/缓冲文件零改动, serve 端口/auth/ticket 永不下发浏览器。第三轮修正:404/410 的 upgrade 是终局不是抖动——shell 退出后 upgrade 恒 404(实测), 有界重连却把五级 ≈12s 全烧在上面还打五条假警告;纯函数 ptyGone() 分类, mock serve 也照真 serve 回 404。已知边界:仅本机(远端 serve 的 ws 打不到, 设备要走 port-forward 数据面)、server 重启后不回收已存在的 pty。
⒟ SSE 取代 10s 轮询(src/opencode-events.js):/global/event 不起 instance、不需要 directory、约 10s 一个 server.heartbeat(未文档化, 线上抓到)⇒ 45s 无帧即判死重连(1s·2^n, 每次重问 locator, 所以换端口也跟得上)。但它是每进程的:实测 serve A 订阅、serve B 在同一 sqlite 上建会话, A 的流 30s 只有 connected+heartbeat 而 A 的 GET /session 立刻列到了 ⇒ 只靠 SSE 换不掉轮询, 第二条道是对 store 目录的 fs.watch(只吃 mtime 事件不读库, 400ms 去抖, 目录不可 watch 就大声降级并在下次重连重试)。两条都健康时定时刷新彻底不跑(只有 1s 串行毛刺地板), 任一条断掉就结构性退回定时器——坏掉的道不能把侧边栏冻住。classifyEvent 是纯表, 未知事件类型=other+零 dirty 位(OpenCode 升级永远不会让我们丢道)。dirty 要 NOTIFY(cli-env 的 onChange → opencode-updated {kind:'store'} 广播, 2s 合并、streamed part 不推): 侧边栏也缓存这份清单, 别的进程(TUI)写的改动必须推到每个浏览器而不是等它自己那 5s 轮询;每次推送=每个客户端一次发现扫描, 而我们自己的 opencode turn 也在写同一个 sqlite, 所以地板不是越小越好。跟随 serve 用边沿: 没东西可连时流在退避(至多 30s), 所以 install() 包住 locator 的 onState, 端口 READY 或换号时 lane.kick()(开插件/keeper 换端口立刻活); 必须是边沿——runaway guard 每分钟采一次 /proc, 每次 notify 都 kick 会白白重开 SSE 连接。第三轮修正:kick 还要叫醒退避的那个 setTimeout(退避期间没有 fetch 可 abort, 原来只是缩短了下一次等待)——插件默认关, 所以退避到 30s 就是首次使用前的常态, 实测 serve ready→lane healthy 25.1s, 修后 51ms。重连即补 /session/status: 忙碌图只有 session.status 这一个来源, 流起来之前就在跑的 turn 会在整个 turn 里读成 stopped((f) 的第一条腿恰好在最要紧的时候瞎)。
⒠ 远端一份实现(hostId 是参数):src/opencode-remote.js 的 OPENCODE_OPS 表 + runOpencodeOp 是唯一机器侧实现——本机 facts / opencode-serve agentd op(daemon 打包同一个模块跑同一个函数)/ data/bin/vibespace-opencode-op(无 checkout 的 ssh 主机, 镜像该表, 就是 usage-scan 那类"已文档化的单文件例外")。parity 套件解析脚本自己的 OPS 对象比对 op 名/必填参数/结果键, 并逐 rung 对同一 mock serve 比结果形状(read 由 access 层用同一个 messagesToAcpRecords 合成, 证明逐条记录相同)。pty 三个 op 在远端大声拒绝并说原因。daemon 侧的 facts 挂模块级 ocFacts, 绝不挂 this——Mux 控制回调里的 this 是连接, 每次重连都会重建一份并再 arm 一条 live lane(SSE+fs.watch)而旧的还在跑, dial 设备每次链路抖动都重连 ⇒ 静默无界泄漏。
⒡ 'external' 可探测了, 但只认正证据:live=我们自己的会话;external= 本 serve 的 /session/status 报 busy/retry(进程内真相, 无 directory, 不起 instance)或 该行 time.updated 在不是我们动它的情况下前进(90s 窗口, 跨进程唯一信号——别人的事件到不了我们的总线, 但它写的 sqlite 行到得了我们的列表, store-watch 让我们 ~0.4s 内重读);其余一律 stopped, 证据自行衰减, 完全看不见时由 state().liveLaneHealthy 在面板上说实话而不是让每一行撒谎。两个真 serve 同 store 验证过:外部写→external, 自己写→live, 证据过期→stopped。第三轮修正:「不是我们动它」里的「我们」必须包含用户自己经我们路由的每一次写——对已停止对话做回滚/恢复/答 ask 也在动同一个 time.updated, 于是用户自己的点击被归给陌生人 90 秒(真路由 3/3 复现, 卡片变灰+隐藏 Fork…+被过滤出侧栏);noteOwnWrite/ownWriteVerdict 按值认(实测 revert/unrevert 回包即该行最终 time.updated, 之后不再变动), 只有不回 Session 的动作退回 10s 窗口, 越过我们那个值的第一次移动照旧 external。
不接且写明理由:v2 revert 三件套(上面的实测);POST /sync/history——那是跨进程的持久事件日志(实测:第二个 serve 建的会话出现在第一个 serve 的历史里), 但首读没有"从现在开始"的游标、会把整份日志拉下来, 而 store-watch 触发的那次有界列表重读回答的是同一个问题。2.369.50 资源纪律(owner 生产事故,详见 kb-bugfix-invariants):OpenCode 按目录起 instance 并递归索引 + inotify watch 整棵树;/proc 逐路由实测——v1 路由零开销,每个 /api/session/{id}/… v2 路由都为该会话 directory 起 instance;故 ①命名改 v1 整表 + 1MiB 流式上限(v2 asc 端点永久禁用于发现路径)②serve 跑在自己的空目录 data/opencode-serve/cwd(真 git init:OpenCode 向上找 git worktree,假 .git 不管用,无 repo 则落到 '/' )③listAllSessions 先走无 directory 列表,只对带 vcs 的真项目下钻(绝不 '/'、$HOME、tmpdir、无 vcs 的 'global' 行)④fork 后 POST /instance/dispose ⑤keeper 每 60s 采 /proc:>150% CPU 持续 5min 或 RSS>2GB ⇒ 停 + parked:runaway + telemetry opencode-serve-runaway + harness-store-updated 吐司 + 一小时最多重启一次 ⑥开机自愈 worktree 为 '/' 或 $HOME 的遗留记录 ⑦开关(2026-09-07 owner 定案,默认 OFF):后台 serve 是内建插件 opencode-serve(label "OpenCode background service",src/plugins.js,与 tailscale/frp 同形),autostart 由 decideAutostart({env, pluginWantsUp}) 一处决定——ops 覆盖 VIBESPACE_OPENCODE_SERVE=0/1 优先,其余看插件记录 enabled && desiredUp;旧设置 agents.opencodeServeAutostart 与 VIBESPACE_SKIP_AGENT_HOOKS 冒烟护栏一并删除(默认 OFF 后新实例本就不起任何守护进程)。descriptor 用 store.servicePlugin 声明它的控制面,cli-env 把 plugins.serviceState() 挂到 /api/home 的 harness 行上;客户端首次使用 OpenCode(新建/fork/打开已停止对话)弹一次对话框(createModalShell,Enable & start / Not now),答案是实例级状态(data/plugins.json + 广播,非 localStorage),启用后继续原动作;关闭即停进程(含 adopt 来的),侧边栏常驻一行"已停止的 OpenCode 对话被隐藏"带 Enable(ops 强制关的实例上这一行不带按钮——点了也没用);对抗验证两条跟进:⒜ 默认值一翻,所有由它派生的条件语义都变了——/api/home 的"store 坏了"条件里 !ready && autostart===false 原本只等于 ops 开关,默认 OFF 后成了常态,而客户端把每条 storeReason 都弹成红色错误吐司 ⇒ 每次刷新页面都弹;现在 storeFailureReason() 是 /api/home 与实时推送共用的唯一谓词且只对 parked(崩溃/runaway)为真,"没开"走 row.service 被动展示 ⒝ kill switch 必须管 adopt 不只管 spawn——复用记录那一档在 autostart 门之前,VIBESPACE_OPENCODE_SERVE=0 于是照旧接管上次重启遗留的 serve,而面板正因为 forced off 而禁用了全部控件(一个没有任何控制面的第三方守护进程);现在开关下直接 SIGTERM + 清记录,且 install() 开机就跑一次这条梯子 test-opencode-serve 135 + test-opencode-s9(remainder 全套:回滚/恢复含"v2 家族从未被请求"、ask 卡端到端含 stale 与"含逗号自由文本不被拆"负控、pty 桥跑真 websocket、活道取代轮询且带定时器兜底负控、liveness 三条负控、wiring pins, 外加真二进制段——没装 opencode 时带证据 SKIP)+ test-opencode-remote(三条 rung 不许漂移:解析 shipped 脚本自己的 OPS 表比对、逐 rung 结果键比对、三触碰规则、他人机器上的资源纪律)+ test-opencode-plugin(真 server 起停/replay/env 覆盖 + headless chrome 首用对话框只弹一次)(mock serve 会建模 instance 启动:任一 v2 per-session 路由记入 state.instances,故"整个发现 tick 起 0 个 instance"是真断言;client / 映射经真 normalizer / discover 缓存·负缓存·挂起 ≤2s / keeper 复用·spawn·崩溃 park·stop / spawn cwd 间谍 / '/'-worktree 开机自愈 / 假 /proc + 假时钟驱动 runaway / dispose / 字节上限 / serve reader / caps 判定 + wiring pins)+ 手动 scripts/dev/opencode-serve-live.mjs --measure [秒](真实 store 上打印 CPU/RSS/线程/索引线程/watch 数)

跟踪:S1–S7 → B-6967(已有,改为本表);S8 → B-acp-gemini(新);S9 → B-opencode(新);B-7dbc(第三方模型/多账号全 harness 生效)随 S2/S4 交付。

3. 插件系统(VS Code 式)

3.1 今天

src/plugins.js = tailscale/frp 两个硬编码 host 守护进程的管理器(defs() 字面量、每个生命周期方法 id 分支、无 manifest、无卸载、下载无校验、进程内运行于 ORCH 层、只收 {dataDir, broadcast})。客户端 18 个天然贡献点(窗口类型/rail 面板/文件查看器/右键菜单/状态栏芯片/customize 区域/gs-menu/设置/主题/快捷键/斜杠命令/i18n/图标/agent 工具/路由/ws 类型/backend/agentd ops)中只有主题、斜杠命令、agent 工具是数据驱动的;其余都是编译期字面量或 switch(ws-handler 的 switch 无 default,未知类型静默忽略)。

3.2 目标模型

Manifest(vibespace-plugin.json,VS Code 形):id(<publisher>.<name>,小写连字符,= 目录名)、version、engines.vibespace(最低宿主版本)、description/icon/repository、contributes、capabilities、tier(instance|device)。激活派生自贡献(VS Code ≥1.74 的教训):纯声明式插件永不加载代码;服务端在首个路由/工具/hook/harness 命中或 onStartup 激活;客户端在贡献的窗口/面板/查看器/命令首次调用时激活。

贡献点 → 现有注册表(核心先把自己的字面量改成注册调用,dogfooding):

contributes 映射 备注
backends[] src/harnesses 注册表(§2) 最深层,仅 trusted-server
windows[] {type, icon, openSpec, ui:'iframe' 'trusted'} TYPE_ICONS + replayOpenSpec(switch → 注册表,加 default 响亮)
panels[] sidebar-rail(四个平行字面量 → 一个注册表)
viewers[] {ext[], viewer} file-types REGISTRY(私有常量 → register())
commands[] + menus[] {where, when, group} 命令注册表 + showContextMenu items(已是数据驱动)+ gs-menu when 谓词参考 VS Code
statusChips[] chat-status-bar render() 的 parts design 芯片是首个迁移对象
settings settings-schema(含分类)+ serverSetting 命名空间 plugin.<id>.*
themes[] / keybindings[] / slashCommands[] / i18n 已是数据 / command-mode → 键位注册表 / chat-input / DICTS
agentTools[] {name, args schema, docs} 声明 + 生成的 shim CLI(data/bin/vibespace-tool-<plugin>-<name> POST 到插件路由,带 vsst_)——不是任意二进制;随 AGENT_TOOLS 分发到远程主机
routes / ws /api/plugins/<id>/* 反代到插件进程;ws 类型命名空间 plugin:<id>:* 走正常 cookie/vsst 鉴权之后
deviceOps[] agentd 的通用 plugin-op(三触法则:id 键回复、推送分支、重连重臂) tier:device

分层隔离(由弱到强的信任):

  1. 声明式(主题/键位/设置/菜单/i18n)——无代码。
  2. 沙箱 iframe UI(默认):/plugins/<id>/… 用 published-pages 同一 CSP sandbox allow-scripts allow-forms allow-modals allow-popups allow-downloads(不透明源,永不 allow-same-origin),postMessage 桥 vibespace-plugin-api v1(settings get/set、会话列表/订阅、开窗、通知、命名空间存储(SyncStore → 多客户端广播)、主题变量)。geo/storage 桥(2.369.7)就是原型。
  3. 受信客户端模块:import('/plugins/<id>/client.js') 同源 ES 模块(零构建),Obsidian 模型;等价于提交代码——安装时 owner 显式同意 + 展示能力清单;所有 XSS/innerHTML/DOMPurify/escHtml 法则适用;这是"深度界面自定义"(自定义聊天渲染器、原生观感的 rail 面板)唯一可行层。
  4. 服务端插件 = 独立进程:child_process.fork(server.js, {serialization:'advanced', env: agentEnv()+插件变量}),一进程一插件,IPC-only API(路由代理、ws 命名空间、设置、存储、会话事件订阅、host ops),进程崩溃不带走 orchestrator;node --permission --allow-fs-* 做安全带(非安全保证)。vm/vm2/isolated-vm/worker_threads 都不是隔离边界(调研结论)。
  5. harness 插件:tier-4 进程提供 HarnessDescriptor 的代码钩子 + wrapper 文件(wrapper 仍在 dtach/daemon 下跑);只允许 trusted;这是 Gemini/OpenCode 接入的形态。

能力声明(Zed + WebExtensions):capabilities: { server.fs:{read:[globs],write:[globs]}(默认仅插件目录 + data/plugins/<id>/), server.spawn:[{command,args}], server.net:[{host,path}], server.routes, agent.tools, client:'iframe'|'trusted' },安装时展示,越权调用返回错误;plugins.allowTrusted 设置默认关。

打包/分发/版本:目录或 zip(.vsp)= manifest + 资产;从本地文件、git URL、GitHub release 资产(tag == manifest version)安装;无 marketplace(先不做);更新 = 重装;API 单一版本号 api.version 加法演进,实验 API 走 proposedApi[] + 设置门。解压必须防 Zip Slip(Zed 的教训)。

架构红线进红测:test-architecture 加 PLUGIN 行——插件只能触达 src/plugin-api.js(服务端)/ src/lib/plugin-bridge.js(客户端);loader 绝不 require() 插件代码进 orchestrator(harness 插件的描述符除外,且经合同套件)。今天的 tailscale/frp 折叠成内建 server 插件(它们本来就符合:安装步骤、持久状态、boot 重放、引导登录)。

不借鉴:Obsidian 式第三方直接 DOM(多客户端应用 = 全船存储型 XSS 面);OpenCode 式进程内顺序 hook 改共享对象(零隔离);VS Code 运行时其实也无沙箱(防线全在 marketplace)——我们没有 marketplace,所以隔离必须在运行时。

3.3 分期

期 内容 依赖
Ph1 核心贡献点注册表化(窗口类型 ✅2.369.19 window-types.js / rail 面板 / 查看器 / 命令+菜单 when+键位+ws default ✅ src/lib/contributions.js:registerCommand/registerMenuItem({menu, command, group, order, when})/menuItems(menu, ctx)/registerKeybinding+一个 document 级分发器(app 生命周期 AbortController)/runCommand;核心已把 session-card 右键菜单(session.* 命令,session-card.js)、窗口标题栏/taskbar/窗口列表菜单(window.* 命令,taskbar.js)、⚙ 菜单(gear-menu.js,含插件窗口 expand 行)迁成注册项且字节级一致(scripts/test-contributions.mjs 用逐字复制的旧 builder 在状态矩阵上做 diff);command mode/Ctrl+K 只注册为命令、键位分发原地保留(旧检查对修饰键宽松+capture 顺序 load-bearing,迁移即行为变化);分隔符是显式贡献(VS Code 自动分组规则无法复现窗口菜单无会话时 Minimize/Close 之间无线的形状);plugin-client 受信模块 host API api.registerCommand/registerMenuItem/registerKeybinding/runCommand(id 命名空间 plugin:<id>:<slug>,全部绑到插件 signal);ws-handler switch(data.type) 加 default:(限频日志+telemetry ws-unknown-type+回 {type:'error', code:'unknown-type'} 不带 sessionId)。未迁:file-explorer 菜单(file-explorer-ops.js,超出本片)、状态芯片 / rail 面板 / 查看器注册表 / 状态芯片)——与 §2 S1/S7 同批,无 loader —
Ph2 ✅2.369.24 最小 + ✅2.369.30 全部(受信层/设置主题贡献/安装UI/能力执行) manifest + loader:声明式 + iframe UI + 独立进程服务端插件 + agentTools shim + 安装/卸载 UI;tailscale/frp 迁入 Ph1
Ph3 harness 插件:ACP/Gemini 作为首个外部 harness 插件(= §2 S8 的交付形态) S1–S6, Ph2
Ph4 ✅2.369.30 (受信模块+同意+打包安装源+能力清单UI+--permission执行+远程shim) 受信客户端模块 + 打包/安装源 + 能力清单 UI Ph2

跟踪:Ph1 → B-plugin-registries(新);Ph2 → B-plugin-loader(新);Ph3 = B-acp-gemini;Ph4 → B-plugin-trusted(新)。

4. owner 决策(2026-09-05 已拍板)

  1. ACP 作为通用第三方 harness 协议(Claude 不走、Codex 保留 app-server 原生 adapter)——✅ 同意;二次调研后首发改 OpenCode(§2.3)。
  2. 插件"受信客户端模块"层——✅ 允许,默认关、安装时展示能力清单。
  3. 优先级——owner:无优先级,全部推进(并行切片 + 每片 gate)。
  4. GPT-6 Astra 前置——✅ 本机已升 0.153.4,model/list 含 gpt-6-astra;真实会话 e2e 通过(scripts/dev/codex-e2e-live.mjs);0.153 命名注入 <recommended_plugins> 已修。

附录 A:原始盘点条目

完整三镜头条目(含每条的 file:line 证据与修法)与外部调研来源列表保存在调研会话的本机 workflow journal(run id wf_01ba5210-a8d,未入库——§1 表已收录全部 P0–P2 条目,P3 条目按 §2 S7 消化)。