@te-river/opencode-team-mode 1.7.6 → 1.7.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +1010 -1009
  2. package/README.zh-CN.md +907 -906
  3. package/dist/host/v2-browser-gate.js +2 -2
  4. package/dist/host/v2-guard.d.ts +135 -0
  5. package/dist/host/v2-guard.d.ts.map +1 -1
  6. package/dist/host/v2-guard.js +227 -2
  7. package/dist/host/v2-guard.js.map +1 -1
  8. package/dist/host/v2-offload.js +6 -6
  9. package/dist/host/v2-retry.d.ts +9 -1
  10. package/dist/host/v2-retry.d.ts.map +1 -1
  11. package/dist/host/v2-retry.js +10 -2
  12. package/dist/host/v2-retry.js.map +1 -1
  13. package/dist/host/v2-subagent.d.ts +21 -0
  14. package/dist/host/v2-subagent.d.ts.map +1 -1
  15. package/dist/host/v2-subagent.js +55 -9
  16. package/dist/host/v2-subagent.js.map +1 -1
  17. package/dist/host/v2.d.ts.map +1 -1
  18. package/dist/host/v2.js +77 -1
  19. package/dist/host/v2.js.map +1 -1
  20. package/dist/prompts/specialists.js +286 -286
  21. package/dist/task-offload.js +1 -1
  22. package/dist/tm/config-layers.d.ts +5 -2
  23. package/dist/tm/config-layers.d.ts.map +1 -1
  24. package/dist/tm/config-layers.js +10 -6
  25. package/dist/tm/config-layers.js.map +1 -1
  26. package/dist/tm/config.d.ts +3 -0
  27. package/dist/tm/config.d.ts.map +1 -1
  28. package/dist/tm/config.js +7 -1
  29. package/dist/tm/config.js.map +1 -1
  30. package/dist/tm/index.d.ts +5 -1
  31. package/dist/tm/index.d.ts.map +1 -1
  32. package/dist/tm/index.js +1 -1
  33. package/dist/tm/index.js.map +1 -1
  34. package/dist/tm/ledger.js +5 -5
  35. package/dist/tm/memory.js +9 -9
  36. package/dist/tm/search.d.ts +3 -0
  37. package/dist/tm/search.d.ts.map +1 -1
  38. package/dist/tm/search.js +67 -15
  39. package/dist/tm/search.js.map +1 -1
  40. package/dist/tm/stats.d.ts.map +1 -1
  41. package/dist/tm/stats.js +23 -0
  42. package/dist/tm/stats.js.map +1 -1
  43. package/dist/tm/tools.js +3 -3
  44. package/dist/tm/webfetch.d.ts +94 -5
  45. package/dist/tm/webfetch.d.ts.map +1 -1
  46. package/dist/tm/webfetch.js +285 -64
  47. package/dist/tm/webfetch.js.map +1 -1
  48. package/docs/installation-v2.md +2 -2
  49. package/docs/installation.md +505 -505
  50. package/docs/research/agent-data-exchange.md +117 -117
  51. package/docs/research/browser-pane.md +94 -94
  52. package/docs/research/host-http-api.md +132 -132
  53. package/docs/research/host-subagent-injection.md +187 -187
  54. package/docs/research/plugin-loader-contract.md +261 -261
  55. package/docs/research/v2-builtin-tools.md +447 -447
  56. package/docs/v2-port-notes.md +121 -78
  57. package/index.js +7 -7
  58. package/package.json +64 -64
  59. package/scripts/lib/config-surgery.cjs +239 -239
@@ -1,117 +1,117 @@
1
- # OpenCode 2.x 上「agent 之间能不能自由交换数据」—— 活体取证 + 与黑板策略的对比
2
-
3
- 问题:**OpenCode 2.x 是否允许各个 agent 之间自由交换数据?** 允许的话,与我们现有的"黑板"策略相比哪个更优、优在哪几方面。
4
-
5
- 取证环境:桌面版 2.0.16(宿主自带 CLI `resources/opencode-cli.exe --version` = `opencode v2.0.16`),内嵌 Node 26.3.0。
6
- 取证纪律(全部遵守):只用自建临时配置目录(`OPENCODE_CONFIG_DIR=<临时目录>/config`,实测被 `run --standalone` 采纳——把 provider 定义挪走后会报 `Model unavailable`),没有写用户的全局配置,没有跑 `scripts/install.*`,没有改 OpenCode 本体;探针插件放在临时目录里,导出 `{id, setup}` 双人格形状;`TM_STORE_RECLAIM=off`;临时 workspace 的 `TMPDIR/TEMP/TMP` 全部指向沙箱。记录里只出现名字/形状/计数/哈希,不出现用户路径、URL、命令行、环境变量值、会话正文。
7
- 残留清理已核验:探针期间产生的 18 条会话用 `opencode session delete --standalone` 全部删除(`session_v2 WHERE directory LIKE '%<沙箱>%'` = 0),探针写进宿主存储的 7 个键用 `ctx.storage.remove` 自己删干净(`kv` 表中探针键 = 0),探针文件与插件包随后删除。
8
-
9
- ---
10
-
11
- ## 一、直接答案
12
-
13
- **允许,而且不止一条路 —— 2.x 官方就有跨会话的数据面,插件侧不需要任何越权动作。** 具体三条彼此独立的通路(都测到了):
14
-
15
- 1. **`ctx.session.synthetic({sessionID, text})`** —— 往**任意一个别的会话**投一条 synthetic 消息。实测返回 `{id, sessionID, type:"synthetic", delivery:"steer", payload:{text}, time:{created}}`,随后目标会话的 `session.hook("context")` 里**我们的探针标记确实出现在它的消息中**(`msgsN:2, tokenInCtx:true`,agent 是 `general`),并且宿主发出 `session.inbox.enqueued` → `session.inbox.delivered` → `session.step.started`,也就是那条文本被当成一次真活跑了。**没有触发任何 `permission.evaluate`,没有弹窗。**
16
- 2. **`ctx.session.prompt({sessionID, text, files, agents, skills})`** —— 同一个队列的"派活"入口(我们的调用在 1.5 s 内没结算,但后续事件说明它入队并执行了)。底层就是宿主的 **`session_inbox`** 表:`{id, session_id, type, payload, delivery, enqueued_seq, time_created}`,REST 面还有 `session.inbox.list` = `GET /api/session/:sessionID/inbox`("List durable enqueued session work not yet delivered, ordered by enqueue sequence")和 `session.inbox.cancel`。**这是一个 durable、有序、按会话投递的官方消息队列 —— 就是"agent 之间交换数据"的官方答案。**
17
- 3. **`ctx.storage`(宿主 kv)** —— 键空间实测**跨会话、跨 agent、跨项目全部共享**,只有**插件 id** 是隔离边界:磁盘上 `kv.key` 的真实形状是 `plugin:<utf16be(插件id)>:<你的键>`,表里**没有任何 location/project/session 列**。同一个插件 id 在**不同进程**里读得到彼此写的值(跨 441 s、pid 25524 → 28492 读回成功);换一个插件 id 就 `scan` 不到(xch2/xch3 的键对 probe-xch4 完全不可见)。
18
-
19
- 另外两条"半通路":**`subagent` 工具**(父拿子的返回,信封形状见下表)和 **`ctx.event.subscribe()`**(实测是**全服务器**的事件流,一次订阅里看到了 ≥3 个不同 session 的事件,没有作用域过滤)。
20
-
21
- **但"能自由交换"不等于"该拿它当交付信道"。** 这几条路在六个维度上各有硬伤(见第四节),而且第 1、3 条恰恰踩在产品目标 6 上:**注入进去的文本以 user 角色进目标会话的上下文,宿主既不评估权限也不告诉我们它是谁写的** —— 一个不评估、不留痕、不可核对的通道,不能当团队的正式信道。
22
-
23
- ---
24
-
25
- ## 二、信道表
26
-
27
- 图例:✅ 实测可用(本次活体)/◐ 部分可用或只测到表面/❓ 证据不足/✋ 不可用(未观测到该面)。
28
- "谁能拒"= 这条通路上真正存在的拒绝点,不是"理论上应该有"。
29
-
30
- | # | 信道 | 可用性 | 证据(调用点 → 返回形状) | 谁能拒 | 生命周期 / 回收 | 正文会不会灌进上下文 |
31
- |---|---|---|---|---|---|---|
32
- | 0 | **黑板文件(`tm_board_write`,现状基线)** | ✅(我们自己的实现,`src/tm/board.ts`) | 回复只含**绝对路径 + 字节数 + 作者角色 + 轮次**,正文不回传;`board_write` 轨迹行带 bytes | **只有我们的代码**:realpath 必须落在 board root 内、永远 `.md`、不覆盖(撞名回"同一轮的并发写入")、`TM_BOARD_MAX_CHARS` 200 000 / 每会话 200 文件 / 40 任务;**宿主完全不参与**(`node:fs` 直写,`permission.evaluate` 一条都没评估到它) | `<repo>/.git/opencode-team/<session-key>/<task>/NN-<role>-<topic>[-rN].md`,不进工作树不进 commit;非 git 落 tmpdir **按 workspace 分片**;TTL 默认 **5 天**,启动 + 每小时扫;另有分片回收与升级遗留桶回收 | **不会**(这是它存在的全部理由;代价见 §三) |
33
- | 1 | 父 ← 子:`subagent` 工具返回值 | ✅ 工具存在(实测在 `build` 的请求面上,`general` 面上没有);信封 ◐ 今天未复测 | `session.hook("context").tools` 里 build 12 个(含 `subagent`/`question`),general 10 个(无 `subagent`);信封形状沿用先前测量:2.0.16 是 `<subagent sessionID="…" state="completed">`,v1 的 `<task id=` 在该二进制里 **0 次出现** | 宿主 `subagent` 权限动作(先前实测 `permission.evaluate {action:"subagent"}`)+ 我们的矩阵(5 个专家 = deny) | 正文活在子会话历史里;`opencode session delete` 级联删子会话(帮助文本原话"Delete a session and its child sessions") | **会**(宿主把子回复整段注入父会话)—— 所以我们用 `execute.after` 卸载它 |
34
- | 2 | `ctx.session.create / synthetic / prompt / update / switchAgent / switchModel / interrupt / wait / command / move` | ✅ 除 `wait`/`command`/`move` 只测到入参契约外全部实测 | `create()` 裸调用就成功;`create({title,agent})` 返回体带 `agent:"general"`;返回体成员:`cost id location projectID time title tokens`(**没有 parent 字段**);`synthetic({sessionID,text})` → `{delivery:"steer", id, payload:{text}, sessionID, time, type:"synthetic"}`;`interrupt({sessionID})` → `{interrupted}`;`update({sessionID,title})`、`switchAgent({agent,sessionID})` 均成功生效(后续读回 title/agent 已变);`command` 需要 `{name}`;`move` 需要 `{directory}`;`wait({sessionID})` 4 s 未返回(会话在跑时会阻塞) | **什么都没拒**:整轮 `permission.hook("evaluate")` 挂着,对这些调用**一条都没触发**;也没有弹窗(v2 插件本来就抬不起官方对话框) | 写进 `session_inbox`(durable + `enqueued_seq` 有序),投递后消费;`session.inbox.cancel` 可撤;随会话生死 | **一定会** —— 这条通道的语义就是"把正文塞进别人上下文",没有预算、没有预览、没有去重 |
35
- | 3 | `ctx.session.get / context`(跨会话读) | ✅ get;❓ context 语义未定 | `get({sessionID})` → 只有 Session.Info(`cost id location projectID time title tokens`,**无消息**);`context({sessionID})` 返回**数组**:空闲会话 = 0 项,注入之后 = 2 项、再到 6 项,项的成员不是 `{role,content}`(roles 读不出来),**没有 `messages`/`log`/`children`/`list`/`todo` 这些方法**(逐个试出来都是 absent) | 无评估(同上) | 读的是宿主存储,会话删了就没 | 读回来的东西进**我们自己工具**的上下文,不经宿主 —— 也就是说**只有我们 govern 它才不会灌**(v2 上 `tm_*` 之外没有第二道闸) |
36
- | 4 | `ctx.storage`(kv) | ✅ | `get/set/remove` 全部工作;`scan` **必须带 `{prefix}`**(`{limit}`、裸对象、字符串参数一律回 0 项 —— 所以插件枚举不了宿主整个命名空间);条目形状 `{key, value}` —— **scan 直接把值带回来**;跨进程持久(同一插件 id、不同 pid 读得回) | 无评估;隔离只有**插件 id**一层(磁盘键 `plugin:<hex(id)>:<key>`) | **没有任何 TTL/配额/回收**(观测 10 行,`time_updated` 只是记录不是过期);卸载插件后行还留在 `opencode.db` 里(我们自己的 `team-mode/selfcheck` 就是这个状态) | 不会自动进 —— 但 `set/get` 的正文要经模型参数/返回值,等价于工具层;**`scan` 是一次性把所有 value 拉进来的批量危险面** |
37
- | 5 | 宿主原生 `read`/`write`/`grep` 当共享面 | ✅ 面上存在(沙箱无 deny 时全给) | build 的请求面实测 12 个工具:`edit execute glob grep question read shell skill subagent webfetch websearch write`;general 10 个(少 `question`/`subagent`) | 宿主 `edit`(写+改)/`read,glob,grep`(本地发现)/`shell` 规则;**越出项目目录 = `external_directory`**,先前实测 `effect:"ask"` + 真 `permission.asked`(今天未复测) | 文件留在用户工作树里 —— 谁写谁负责删,**没有自动回收**,`git status` 看得见 | `read` 的结果**进上下文**(v2 上我们的 `execute.after` 才把它卸载);`write` 的正文由模型自己吐出来,**写侧 token 一分不省** |
38
- | 6 | 事件流 `ctx.event.subscribe()` | ✅ | 是 Promise 版插件 API 上的 **async iterable**(**不需要 Effect 运行时,不需要新增依赖** —— 本包 dependencies 仍为空);观测到的类型:`session.inbox.enqueued/delivered`、`session.execution.started/succeeded/failed`、`session.step.started/failed`、`session.retry.scheduled`、`session.instructions.updated`、`integration/model/provider/agent/command/skill/websearch/reference/plugin.updated`、`project.updated`、`models-dev.refreshed` | 无评估,无过滤入参(我们没找到按会话订阅的方式) | 一次订阅只覆盖一个进程;磁盘上有 `event` 表(`id aggregate_id seq type data created`),但插件面**没有回放**入口 | 事件载荷成员:`inboxID item sessionID assistantMessageID error delta …` —— `item` 是否带正文 **未测**(本轮 logger 只记名字) |
39
- | 7 | 官方共享对象:`reference` / `vcs` / `worktree` / `app` / `mcp` / `integration` / `skill` / `command` / `plugin` / `resource` / `experimental.terminal` / `generate` / `rpc` | ◐ 逐个探到面 | 成员实测:`reference{list,reload,transform}`、`vcs{base,branch,diff,get,reload,status,transform}`(`status` → `{data:[],location}`,`diff` 需要 `{mode}`)、`worktree{create,list,refresh,remove,reload,transform}`(`list` 需要 `{projectID}`)、`mcp{list}`、`integration{list,…}`(226 条)、`command{list}`(2)、`skill{list}`(2,条目含 `content description id name path`)、`plugin{list}`、`app{channel,name,version}` = 2.0.16、`location{directory,project{canonical,directory,id},workspaceID}`、`generate{text}`、`experimental.terminal{read}`(`read({sessionID})` → `null`)、`rpc`(函数 + `.register`);**`ctx.resource` 不存在** | 这些都是**只读注册表 / 定义面**(reference、skill、command、mcp、integration 都是 `list + transform(editor)`)—— 没有任何一个是 KV;能当"共享对象"用的只有 **`worktree`**(`worktree{project_id,directory,strategy}` 表 = 官方多目录机制)和 **vcs/diff**(观察工作树变更,是读取而非传递) | 随配置/项目 | 注册表类**不会**(除非我们把内容读进上下文);`worktree` 是**隔离**手段,不是交换手段 |
40
- | 8 | `todo`(宿主表) | ❓ 表存在,面不存在 | `opencode.db` 里有 `todo(session_id content status priority position …)`;但 `ctx.session` **没有** todo/messages/children 方法,REST 操作清单(从二进制里读的 `ao("<operationId>","/api/…")` 全集)里**没有 todo 操作**,模型面上也没有 `todowrite` | —— | —— | 这就是 #16 的现状:宿主的账本存储在那里,**插件拿不到**,所以 `tm_ledger` 只能用 kv |
41
-
42
- ---
43
-
44
- ## 三、对照组:黑板策略的具体代价(要换就得先还这笔账)
45
-
46
- | 项 | 实测/实测来源 | 数值 |
47
- |---|---|---|
48
- | 黑板说明(`blackboardNote`)每次请求进 **领队** 的 system | `dist/host/note.js` + 本包 `estimateTokens` | **1 184 字符 / 296 token**,且 `v2.ts:474` `noteAgents:["team"]` —— 只有领队拿,专家靠领队派发时把会话文件夹带下去 |
49
- | `tm_board_write` 的定义体 | `dist/tm/board.js` + `estimateTokens` | **1 278 字符 / 320 token**(v2 目录模式只露名字,`TM_V2_CODEMODE=direct` 才整段进) |
50
- | 写侧 token | 结构决定 | **省不掉**:正文必须由模型作为 `content` 参数产出一次 —— 黑板省的是**读侧**(路径回传 + 读取经 offload) |
51
- | 交付物落盘 | `src/tm/board.ts:297-303` | 文件头带 `<!-- tm_board_write · ISO · role= · session= -->`,回复只给 `路径 + bytes + 轮次 + 作者角色` |
52
- | 治理约束 | 同上 | realpath 必须在 root 内(符号链接目录**写入前**再核验一次)、永远 `.md`、绝不覆盖(修订是 `-rN` 新文件,轮次历史留在文件名里)、作者角色取宿主 `ctx.agent` 不取模型自报、原子 tmp+rename |
53
- | 回收 | `src/blackboard.ts:176-188` + `sweepStale` | 唯一回收路径 = TTL(默认 5 天)+ 启动扫 + 每小时扫;`v2` 也补上了这个 sweeper(注释原话:note 承诺了清扫却不带机制就是本产品在拒绝的那种夸大) |
54
- | 残留位置 | 同上 | git 工作区:`<repo>/.git/opencode-team/`(不进 commit);非 git:`<tmpdir>/opencode-team/w-<sha256(path)>` 分片 + 分片回收 + 升级遗留桶回收;`TM_STORE_RECLAIM=off` 关掉 |
55
- | 用户可核对性 | `src/tm/stats.ts`、桌面渲染层取证 | 插件工具在桌面里是**一行、点不开**(渲染器只注册 14 个内置名,`GenericTool` 从不读 `props.output`)—— 所以细节走盘:`tm_stats {recent:N}` 把 `tm_fetch` 句柄 **和 payload 绝对路径**一起打出来;黑板文件本身是用户能用编辑器直接打开的 `.md` |
56
- | 团队正式信道 | `src/prompts/shared.ts`(REPLY_CONTRACT) | STATUS/CHANGES/FINDINGS/EVIDENCE/HANDOFF 骨架是**主传输**,黑板只服务 >~50 行的超大交付 —— 也就是"混合"不是备选,是现状 |
57
-
58
- ---
59
-
60
- ## 四、对比结论:黑板 vs 每个可行信道(六维)
61
-
62
- 打分口径:**优 / 中 / 差**,只看这条信道相对"作为团队的交付信道"的表现。
63
-
64
- | 维度 | 0 黑板(基线) | 2 session_inbox / synthetic | 4 ctx.storage | 5 原生 write/read | 1 subagent 信封 | 6 事件流 |
65
- |---|---|---|---|---|---|---|
66
- | **token 成本** | **优**:正文不进上下文;固定成本 = 领队 296 token/请求 + 目录按需 | **差**:正文按设计进目标上下文,无预算/无预览 | **中**:不进上下文,但读回时同样要靠我们自己卸载;`scan` 会把所有 value 一次拉进来 | **中**:写侧和黑板一样要模型产出;读侧 `read` 默认整段进(靠我们的 `execute.after` 才不外溢) | **差→中**:宿主整段注入(这是我们花两套 offload 去补的那个洞) | **中**:类型名便宜,载荷成员未知 |
67
- | **可核对性(用户能否点开看到)** | **优**:回复里是绝对路径,`.md` 能用任何编辑器打开;轨迹有 bytes/role/session | 差:队列在宿主存储里,桌面没有"某会话待办箱"入口(未见,未证) | **最差**:`opencode.db` 的一个 blob,用户既打不开也不会去开 | **优**:工作树里的真文件,`git status`/侧栏都看得见 | **优**:task 卡片直连子会话,是**唯一**用户能点开的子代理形态 | 差:瞬时;`event` 表在盘上但没有 UI |
68
- | **并发安全** | **优**:绝不覆盖 + `NN` 序数 + `-rN` 家族 + 原子 rename;撞名显式拒绝并说明是"同一轮的并发写入" | **优**:durable 队列 + `enqueued_seq` 有序 + `delivery` 语义 —— 这是全场最好的写并发模型(追加,不是覆盖) | **差**:`set` 是无条件覆盖,无 CAS/版本;键还全局共享 → 两个窗口互相盖章已经在我们的 `selfcheck` 键上真实发生 | **差**:last-writer-wins,无 ordinals | 中:一次派发一个子会话,天然不撞 | 中:只读不写 |
69
- | **生命周期 / 卸载残留** | **优(唯一带机制的)**:TTL 清扫 + 分片回收 + 遗留桶回收,且落在 `.git` 下不污染 commit | **优**:随会话删除级联清理(`session delete` 的既有语义) | **差**:**零回收**(没有 TTL、没有配额、`scan` 都不给全量),卸载插件后键还在 | **差**:写在用户工作树里,删不删全看模型自觉(原则 8 就是为这个存在的) | 中:随会话;会话本身没人删就留着 | 中:磁盘 `event` 表由宿主管 |
70
- | **权限与红线** | **中**:门禁全在我们代码里(realpath/.md/上限),**宿主一次都不评估** —— 好处是不依赖 v2 缺失的官方弹窗,坏处是"越出黑板"这件事没有第二道闸 | **差(要看清)**:**没有任何评估、没有弹窗、没有来源标注** —— 谁都能往任何会话注一条 user 角色文本;把它当正式信道 = 把团队的握手协议架在一个不可问责的入口上 | **中**:按插件 id 隔离(实测换 id 就读不到),但对 agent/session/project **零隔离** —— 我们必须在键里自己写作用域 | **优**:这是唯一一条有真宿主权限的信道(`edit`/`read` 规则 + `external_directory` 的 `effect:"ask"` + 宿主自己抬的 `permission.asked`) | **优**:`subagent` 有权限动作,宿主有 kill switch、有卡片 —— 这正是 v1 删掉 `tm_dispatch` 的理由 | 中:只读面,红线不在它身上 |
71
- | **宿主升级脆弱度** | **低-中**:只依赖 `node:fs` + 我们的工具注册;唯一外部接缝是 `session.hook("context")` 推 note(v1→v2 已经换过一次写法) | **中**:类型化 API 调用(不是解析字符串),但 `synthetic/prompt/inbox` 都是 v2 新面,无稳定性承诺 | **低-中**:`get/set/remove/scan` 有文档;**"按插件 id 命名空间"和"全局共享"是从磁盘形状看出来的,没写在任何文档上** | **最低**:宿主自己的工具与权限动作 | **最高(我们已经吃过一次)**:信封字符串 1.18=`<task id=` ↔ 2.0.16=`<subagent sessionID=`,v1 的匹配器在 v2 上是**结构性死码**,而 `task_envelopes:0` 一度被当成"没有超大回复" | **中-高**:事件名在两代之间整批改过(v1 `session.idle/status/error` ↔ 实测 v2 `session.execution.*/step.*/inbox.*/retry.scheduled`;今天**没有看到一次 `session.idle`**) |
72
-
73
- **判决:混合,不换基线。**
74
-
75
- 1. **超大交付物继续走黑板,不改。** 它是六维里唯一在"不进上下文 + 用户可点开 + 有自动回收 + 有写并发协议"四项同时拿优的信道;`ctx.storage` 在可核对性和回收上直接归零,原生 `write` 在并发与残留上归零,inbox/信封在 token 上归零。宿主没给我们一个能替代它的东西。
76
- 2. **`session_inbox` / `ctx.session.synthetic` 应该吸收,但只吸收"信号",不吸收"正文"。** 它在我们最弱的一维(并发安全:追加 + `enqueued_seq` 有序 + `delivery` + `cancel`)严格优于现状 —— 现状是**解析文本信封 + 靠标题里的 ` ·tm` 标记认领子会话**(`src/tm/dispatch.ts` 的 `parseDispatchTitle` / `claimNamedChild` / `adoptFromHost`),那是全场最脆的东西(信封已经变过一次代)。把"谁结算了 / 下一棒是谁 / 这轮没等到全部结算"这类**短结构化信号**搬到官方队列上,能直接废掉一整套字符串匹配;而正文仍旧走路径。
77
- 3. **搬的时候必须自带三样东西**(因为宿主一样都没给):**(a) 长度上限**(synthetic 载荷要过我们自己的 offload 阈值,超了改投路径 + 句柄);**(b) 来源标注**(收到的一侧要在回复里写明"这段来自插件注入的 synthetic 消息,不是用户写的"——目标 6:一条注入进去的 `delivery:"steer"` 文本会以 user 角色被模型相信,这就是它不能当正式信道的全部理由);**(c) 轨迹账**(每一次写都记 `session_inbox` id + 目标会话哈希,否则"我通知过他了"又变成一个不可核对的已确认关闭)。
78
- 4. **`ctx.storage` 保持"索引/账本"角色(`tm_ledger` 已经是),但两个必修**:键必须自带作用域(实测它是**全局**的:`plugin:<id>:<key>`,没有 location 列 —— 我们今天的 `team-mode/selfcheck` 已经在两个窗口之间互相覆盖),并且**要我们自己写配额 + 启动清扫**(宿主一条都没有)。`scan` 在任何面向模型的封装里都必须只回键名,绝不把 `value` 原样端出去。
79
- 5. **`ctx.event.subscribe()` 只当结算探测器**(替掉 `tm_join` 里的 `session.status` 轮询),类型名走**白名单 + "没见过这个类型就上报"的活扣** —— 事件名整代换过,这是数据不是猜想。它不需要 Effect、不需要依赖,成本可以接受。
80
- 6. **不要用原生 `write` 写交付物**(污染工作树 + 无回收),**不要把任何同意/红线搬到 synthetic 上**(它不评估权限,v2 插件也抬不起对话框 —— 与 AGENTS"Goal 5 在 v2 不存在"一致)。
81
-
82
- 若要实施(本报告不实施),要动的模块:
83
- `src/host/v2-inbox.ts`(新增:带长度闸 + 来源标注 + 轨迹的 synthetic 写入)·
84
- `src/tm/dispatch.ts`(`tm_join` 结算改吃 `session_inbox` + 事件;`parseDispatchTitle`/`adoptFromHost` 降级为 v1 路径)·
85
- `src/task-offload.ts` + `src/host/v2-offload.ts`(信封匹配从"唯一机制"降为"兜底")·
86
- `src/tm/ledger.ts`(键作用域 + 配额 + 启动清扫;`scan` 输出剥 `value`)·
87
- `src/host/v2.ts`(`team-mode/selfcheck` 键加会话/项目作用域)·
88
- `src/host/note.ts`(note 增写信号信道 + "synthetic ≠ 用户"这一句)·
89
- `src/capabilities.ts` + `src/host/v2-probe.ts`(把 `session.synthetic`、事件类型集、kv 往返加进能力账)·
90
- `src/agents.ts`/`src/prompts/*` + `test-blackboard.mjs`(新提示词规则配新断言)·
91
- `test-v2-adapter.mjs` + `scripts/lib/fake-ctx.mjs`(`ctx.session` 需要真实形状的宿主替身:现在这个域在 fake 里是空的)。
92
-
93
- ---
94
-
95
- ## 五、证据不足项(下次补,别再当已知)
96
-
97
- 1. **`subagent` 返回信封今天没复测**:模型层整天不通(`lxns-uni/zai-org/GLM-5.3#max` 与 `lxns-maiden/...` 都是 `Transport: socket closed`,第三个 provider 明确回 `Invalid API-key provided`)。表里那一行沿用先前对 2.0.16 二进制的只读测量,**不是今天的活体**。
98
- 2. **`ctx.session.context({sessionID})` 到底是不是"读全量历史"**:空闲会话回 `[]`、注入后回 2 项、再回 6 项,但项的形状不是 `{role,content}`(role 读不出来),我**没解开它的项结构**,所以既不能说它是全量历史也不能说它不是。父读子完整历史这个问题因此**仍未结**。
99
- 3. **事件载荷 `item` 里有没有正文**:本轮 logger 只记成员名(隐私口径要求),`session.inbox.enqueued{inboxID,item,sessionID}` 的 `item` 内容未看。
100
- 4. **`external_directory` 今天的实测行为**:只有先前一轮的 `effect:"ask"` + `permission.asked`,今天没有成功跑出一个原生读。
101
- 5. **kv 的"全局"是从磁盘形状(`kv` 无 location 列 + 键前缀只有 plugin id)推出来的**,不是从一个项目在另一个项目里显式读成功 —— 差一次"两个 workspace、同一插件 id、A 写 B 读"的正向确认。
102
- 6. **`experimental.terminal.read({sessionID})`** 只证明它接受 `{sessionID}` 并回 `null`(沙箱里没有终端);它是否能读**别人的**终端输出(`/api/experimental/session/:sessionID/terminal/read` 的存在暗示可以)未证。
103
- 7. **`todo` 表有没有任何 agent/插件可达面**:表在,`ctx.session` 无 todo 方法,REST 操作全集(二进制里 `ao("…","/api/…")`)里没有 todo 操作 —— 所以 #16 的现状是"宿自有账本、插件拿不到",但没有穷尽其它入口(比如 MCP 面)。
104
- 8. **真 `subagent` 子会话的 `session_v2.parent_id` 是否被写入**:我只看到自己 `create()` 出来的会话 `parent_id` 为空(且返回体根本不暴露该字段),没有一次成功的宿主派发去对照。
105
- 9. **`worktree.create/remove` 是否被权限评估**、以及它能不能当作"每 agent 一个目录"的隔离面 —— 只测到 `list` 需要 `{projectID}`。
106
- 10. **MCP resource**:`ctx.resource` 不存在;`mcp` 域只有 `list/reload/transform`。宿主是否有别的路径暴露 MCP 资源未查(REST 里有 `mcp.resource.catalog` = `GET /api/mcp/resource`,但插件面没对上它)。
107
- 11. **插件级项目自动发现**:把探针放进 `<workspace>/.opencode/plugins/*.mjs` **没有被加载**(跑了三轮没一条日志;最后是用 `plugins:["file:///<目录>"]` 装进临时配置目录才生效)—— 文档说的"自动发现目录"这条在 2.0.16 的这个布局下**没复现**,值得单独确认(可能要求目录里就是一个包)。
108
-
109
- ---
110
-
111
- ## 六、本次取证用到的原始形状(供复现,全部只含名字/计数)
112
-
113
- - `ctx` 的域(实测成员):`agent aisdk app command event experimental generate integration location mcp model options permission plugin provider reference rpc session shell skill storage tool vcs websearch worktree`(**无 `resource`**,无 `ask`)。
114
- - `ctx.session` 方法:`command context create generate get hook interrupt move prompt switchAgent switchModel synthetic update wait`。
115
- - `session.hook("context")` 的事件成员:`agent messages model options sessionID system tools`;其中 `messages[i]` = `{content, id, metadata, role}`,`content` 是**数组**(不是 v1 的 parts 数组);`build` 面上 12 工具、`general` 面上 10 工具(`question`/`subagent` 只在 primary 上)。
116
- - 宿主 `opencode.db` 表名(`PRAGMA table_info` 只读):`kv(key,value,time_created,time_updated)`、`session_inbox(id,session_id,type,payload,delivery,enqueued_seq,time_created)`、`session_v2(id,project_id,workspace_id,parent_id,fork_session_id,fork_boundary,slug,directory,path,title,version,share_url,summary_*,metadata,cost,tokens_*,revert,permission,agent,model,time_*,idle_outcome)`、`message(id,session_id,…)`、`todo(session_id,content,status,priority,position,…)`、`instruction_entry(session_id,key,value,removed,…)`、`worktree(project_id,directory,strategy,…)`、`event(id,aggregate_id,seq,type,data,created)`。
117
- - REST 操作全集里与本问题相关的读取面(从二进制 `ao("<op>","/api/…")` 表读出,操作名 + 路径,无内容):`session.get /api/session/:sessionID`、`session.context /api/session/:sessionID/context`、`session.messages /api/session/:sessionID/message`、`session.message /api/session/:sessionID/message/:messageID`、`session.inbox.list /api/session/:sessionID/inbox`、`session.diff /api/session/:sessionID/diff`、`session.export /api/experimental/session/:sessionID/export`、`session.log /api/experimental/session/:sessionID/log`、`event.subscribe /api/event`、`fs.read /api/fs/read/*`、`fs.find fs.list`、`persistentPty.read /api/experimental/session/:sessionID/terminal/read`、`mcp.resource.catalog /api/mcp/resource`、`session.active session.list permission.request.list permission.saved.list`。**清单里没有 todo 操作。**
1
+ # OpenCode 2.x 上「agent 之间能不能自由交换数据」—— 活体取证 + 与黑板策略的对比
2
+
3
+ 问题:**OpenCode 2.x 是否允许各个 agent 之间自由交换数据?** 允许的话,与我们现有的"黑板"策略相比哪个更优、优在哪几方面。
4
+
5
+ 取证环境:桌面版 2.0.16(宿主自带 CLI `resources/opencode-cli.exe --version` = `opencode v2.0.16`),内嵌 Node 26.3.0。
6
+ 取证纪律(全部遵守):只用自建临时配置目录(`OPENCODE_CONFIG_DIR=<临时目录>/config`,实测被 `run --standalone` 采纳——把 provider 定义挪走后会报 `Model unavailable`),没有写用户的全局配置,没有跑 `scripts/install.*`,没有改 OpenCode 本体;探针插件放在临时目录里,导出 `{id, setup}` 双人格形状;`TM_STORE_RECLAIM=off`;临时 workspace 的 `TMPDIR/TEMP/TMP` 全部指向沙箱。记录里只出现名字/形状/计数/哈希,不出现用户路径、URL、命令行、环境变量值、会话正文。
7
+ 残留清理已核验:探针期间产生的 18 条会话用 `opencode session delete --standalone` 全部删除(`session_v2 WHERE directory LIKE '%<沙箱>%'` = 0),探针写进宿主存储的 7 个键用 `ctx.storage.remove` 自己删干净(`kv` 表中探针键 = 0),探针文件与插件包随后删除。
8
+
9
+ ---
10
+
11
+ ## 一、直接答案
12
+
13
+ **允许,而且不止一条路 —— 2.x 官方就有跨会话的数据面,插件侧不需要任何越权动作。** 具体三条彼此独立的通路(都测到了):
14
+
15
+ 1. **`ctx.session.synthetic({sessionID, text})`** —— 往**任意一个别的会话**投一条 synthetic 消息。实测返回 `{id, sessionID, type:"synthetic", delivery:"steer", payload:{text}, time:{created}}`,随后目标会话的 `session.hook("context")` 里**我们的探针标记确实出现在它的消息中**(`msgsN:2, tokenInCtx:true`,agent 是 `general`),并且宿主发出 `session.inbox.enqueued` → `session.inbox.delivered` → `session.step.started`,也就是那条文本被当成一次真活跑了。**没有触发任何 `permission.evaluate`,没有弹窗。**
16
+ 2. **`ctx.session.prompt({sessionID, text, files, agents, skills})`** —— 同一个队列的"派活"入口(我们的调用在 1.5 s 内没结算,但后续事件说明它入队并执行了)。底层就是宿主的 **`session_inbox`** 表:`{id, session_id, type, payload, delivery, enqueued_seq, time_created}`,REST 面还有 `session.inbox.list` = `GET /api/session/:sessionID/inbox`("List durable enqueued session work not yet delivered, ordered by enqueue sequence")和 `session.inbox.cancel`。**这是一个 durable、有序、按会话投递的官方消息队列 —— 就是"agent 之间交换数据"的官方答案。**
17
+ 3. **`ctx.storage`(宿主 kv)** —— 键空间实测**跨会话、跨 agent、跨项目全部共享**,只有**插件 id** 是隔离边界:磁盘上 `kv.key` 的真实形状是 `plugin:<utf16be(插件id)>:<你的键>`,表里**没有任何 location/project/session 列**。同一个插件 id 在**不同进程**里读得到彼此写的值(跨 441 s、pid 25524 → 28492 读回成功);换一个插件 id 就 `scan` 不到(xch2/xch3 的键对 probe-xch4 完全不可见)。
18
+
19
+ 另外两条"半通路":**`subagent` 工具**(父拿子的返回,信封形状见下表)和 **`ctx.event.subscribe()`**(实测是**全服务器**的事件流,一次订阅里看到了 ≥3 个不同 session 的事件,没有作用域过滤)。
20
+
21
+ **但"能自由交换"不等于"该拿它当交付信道"。** 这几条路在六个维度上各有硬伤(见第四节),而且第 1、3 条恰恰踩在产品目标 6 上:**注入进去的文本以 user 角色进目标会话的上下文,宿主既不评估权限也不告诉我们它是谁写的** —— 一个不评估、不留痕、不可核对的通道,不能当团队的正式信道。
22
+
23
+ ---
24
+
25
+ ## 二、信道表
26
+
27
+ 图例:✅ 实测可用(本次活体)/◐ 部分可用或只测到表面/❓ 证据不足/✋ 不可用(未观测到该面)。
28
+ "谁能拒"= 这条通路上真正存在的拒绝点,不是"理论上应该有"。
29
+
30
+ | # | 信道 | 可用性 | 证据(调用点 → 返回形状) | 谁能拒 | 生命周期 / 回收 | 正文会不会灌进上下文 |
31
+ |---|---|---|---|---|---|---|
32
+ | 0 | **黑板文件(`tm_board_write`,现状基线)** | ✅(我们自己的实现,`src/tm/board.ts`) | 回复只含**绝对路径 + 字节数 + 作者角色 + 轮次**,正文不回传;`board_write` 轨迹行带 bytes | **只有我们的代码**:realpath 必须落在 board root 内、永远 `.md`、不覆盖(撞名回"同一轮的并发写入")、`TM_BOARD_MAX_CHARS` 200 000 / 每会话 200 文件 / 40 任务;**宿主完全不参与**(`node:fs` 直写,`permission.evaluate` 一条都没评估到它) | `<repo>/.git/opencode-team/<session-key>/<task>/NN-<role>-<topic>[-rN].md`,不进工作树不进 commit;非 git 落 tmpdir **按 workspace 分片**;TTL 默认 **5 天**,启动 + 每小时扫;另有分片回收与升级遗留桶回收 | **不会**(这是它存在的全部理由;代价见 §三) |
33
+ | 1 | 父 ← 子:`subagent` 工具返回值 | ✅ 工具存在(实测在 `build` 的请求面上,`general` 面上没有);信封 ◐ 今天未复测 | `session.hook("context").tools` 里 build 12 个(含 `subagent`/`question`),general 10 个(无 `subagent`);信封形状沿用先前测量:2.0.16 是 `<subagent sessionID="…" state="completed">`,v1 的 `<task id=` 在该二进制里 **0 次出现** | 宿主 `subagent` 权限动作(先前实测 `permission.evaluate {action:"subagent"}`)+ 我们的矩阵(5 个专家 = deny) | 正文活在子会话历史里;`opencode session delete` 级联删子会话(帮助文本原话"Delete a session and its child sessions") | **会**(宿主把子回复整段注入父会话)—— 所以我们用 `execute.after` 卸载它 |
34
+ | 2 | `ctx.session.create / synthetic / prompt / update / switchAgent / switchModel / interrupt / wait / command / move` | ✅ 除 `wait`/`command`/`move` 只测到入参契约外全部实测 | `create()` 裸调用就成功;`create({title,agent})` 返回体带 `agent:"general"`;返回体成员:`cost id location projectID time title tokens`(**没有 parent 字段**);`synthetic({sessionID,text})` → `{delivery:"steer", id, payload:{text}, sessionID, time, type:"synthetic"}`;`interrupt({sessionID})` → `{interrupted}`;`update({sessionID,title})`、`switchAgent({agent,sessionID})` 均成功生效(后续读回 title/agent 已变);`command` 需要 `{name}`;`move` 需要 `{directory}`;`wait({sessionID})` 4 s 未返回(会话在跑时会阻塞) | **什么都没拒**:整轮 `permission.hook("evaluate")` 挂着,对这些调用**一条都没触发**;也没有弹窗(v2 插件本来就抬不起官方对话框) | 写进 `session_inbox`(durable + `enqueued_seq` 有序),投递后消费;`session.inbox.cancel` 可撤;随会话生死 | **一定会** —— 这条通道的语义就是"把正文塞进别人上下文",没有预算、没有预览、没有去重 |
35
+ | 3 | `ctx.session.get / context`(跨会话读) | ✅ get;❓ context 语义未定 | `get({sessionID})` → 只有 Session.Info(`cost id location projectID time title tokens`,**无消息**);`context({sessionID})` 返回**数组**:空闲会话 = 0 项,注入之后 = 2 项、再到 6 项,项的成员不是 `{role,content}`(roles 读不出来),**没有 `messages`/`log`/`children`/`list`/`todo` 这些方法**(逐个试出来都是 absent) | 无评估(同上) | 读的是宿主存储,会话删了就没 | 读回来的东西进**我们自己工具**的上下文,不经宿主 —— 也就是说**只有我们 govern 它才不会灌**(v2 上 `tm_*` 之外没有第二道闸) |
36
+ | 4 | `ctx.storage`(kv) | ✅ | `get/set/remove` 全部工作;`scan` **必须带 `{prefix}`**(`{limit}`、裸对象、字符串参数一律回 0 项 —— 所以插件枚举不了宿主整个命名空间);条目形状 `{key, value}` —— **scan 直接把值带回来**;跨进程持久(同一插件 id、不同 pid 读得回) | 无评估;隔离只有**插件 id**一层(磁盘键 `plugin:<hex(id)>:<key>`) | **没有任何 TTL/配额/回收**(观测 10 行,`time_updated` 只是记录不是过期);卸载插件后行还留在 `opencode.db` 里(我们自己的 `team-mode/selfcheck` 就是这个状态) | 不会自动进 —— 但 `set/get` 的正文要经模型参数/返回值,等价于工具层;**`scan` 是一次性把所有 value 拉进来的批量危险面** |
37
+ | 5 | 宿主原生 `read`/`write`/`grep` 当共享面 | ✅ 面上存在(沙箱无 deny 时全给) | build 的请求面实测 12 个工具:`edit execute glob grep question read shell skill subagent webfetch websearch write`;general 10 个(少 `question`/`subagent`) | 宿主 `edit`(写+改)/`read,glob,grep`(本地发现)/`shell` 规则;**越出项目目录 = `external_directory`**,先前实测 `effect:"ask"` + 真 `permission.asked`(今天未复测) | 文件留在用户工作树里 —— 谁写谁负责删,**没有自动回收**,`git status` 看得见 | `read` 的结果**进上下文**(v2 上我们的 `execute.after` 才把它卸载);`write` 的正文由模型自己吐出来,**写侧 token 一分不省** |
38
+ | 6 | 事件流 `ctx.event.subscribe()` | ✅ | 是 Promise 版插件 API 上的 **async iterable**(**不需要 Effect 运行时,不需要新增依赖** —— 本包 dependencies 仍为空);观测到的类型:`session.inbox.enqueued/delivered`、`session.execution.started/succeeded/failed`、`session.step.started/failed`、`session.retry.scheduled`、`session.instructions.updated`、`integration/model/provider/agent/command/skill/websearch/reference/plugin.updated`、`project.updated`、`models-dev.refreshed` | 无评估,无过滤入参(我们没找到按会话订阅的方式) | 一次订阅只覆盖一个进程;磁盘上有 `event` 表(`id aggregate_id seq type data created`),但插件面**没有回放**入口 | 事件载荷成员:`inboxID item sessionID assistantMessageID error delta …` —— `item` 是否带正文 **未测**(本轮 logger 只记名字) |
39
+ | 7 | 官方共享对象:`reference` / `vcs` / `worktree` / `app` / `mcp` / `integration` / `skill` / `command` / `plugin` / `resource` / `experimental.terminal` / `generate` / `rpc` | ◐ 逐个探到面 | 成员实测:`reference{list,reload,transform}`、`vcs{base,branch,diff,get,reload,status,transform}`(`status` → `{data:[],location}`,`diff` 需要 `{mode}`)、`worktree{create,list,refresh,remove,reload,transform}`(`list` 需要 `{projectID}`)、`mcp{list}`、`integration{list,…}`(226 条)、`command{list}`(2)、`skill{list}`(2,条目含 `content description id name path`)、`plugin{list}`、`app{channel,name,version}` = 2.0.16、`location{directory,project{canonical,directory,id},workspaceID}`、`generate{text}`、`experimental.terminal{read}`(`read({sessionID})` → `null`)、`rpc`(函数 + `.register`);**`ctx.resource` 不存在** | 这些都是**只读注册表 / 定义面**(reference、skill、command、mcp、integration 都是 `list + transform(editor)`)—— 没有任何一个是 KV;能当"共享对象"用的只有 **`worktree`**(`worktree{project_id,directory,strategy}` 表 = 官方多目录机制)和 **vcs/diff**(观察工作树变更,是读取而非传递) | 随配置/项目 | 注册表类**不会**(除非我们把内容读进上下文);`worktree` 是**隔离**手段,不是交换手段 |
40
+ | 8 | `todo`(宿主表) | ❓ 表存在,面不存在 | `opencode.db` 里有 `todo(session_id content status priority position …)`;但 `ctx.session` **没有** todo/messages/children 方法,REST 操作清单(从二进制里读的 `ao("<operationId>","/api/…")` 全集)里**没有 todo 操作**,模型面上也没有 `todowrite` | —— | —— | 这就是 #16 的现状:宿主的账本存储在那里,**插件拿不到**,所以 `tm_ledger` 只能用 kv |
41
+
42
+ ---
43
+
44
+ ## 三、对照组:黑板策略的具体代价(要换就得先还这笔账)
45
+
46
+ | 项 | 实测/实测来源 | 数值 |
47
+ |---|---|---|
48
+ | 黑板说明(`blackboardNote`)每次请求进 **领队** 的 system | `dist/host/note.js` + 本包 `estimateTokens` | **1 184 字符 / 296 token**,且 `v2.ts:474` `noteAgents:["team"]` —— 只有领队拿,专家靠领队派发时把会话文件夹带下去 |
49
+ | `tm_board_write` 的定义体 | `dist/tm/board.js` + `estimateTokens` | **1 278 字符 / 320 token**(v2 目录模式只露名字,`TM_V2_CODEMODE=direct` 才整段进) |
50
+ | 写侧 token | 结构决定 | **省不掉**:正文必须由模型作为 `content` 参数产出一次 —— 黑板省的是**读侧**(路径回传 + 读取经 offload) |
51
+ | 交付物落盘 | `src/tm/board.ts:297-303` | 文件头带 `<!-- tm_board_write · ISO · role= · session= -->`,回复只给 `路径 + bytes + 轮次 + 作者角色` |
52
+ | 治理约束 | 同上 | realpath 必须在 root 内(符号链接目录**写入前**再核验一次)、永远 `.md`、绝不覆盖(修订是 `-rN` 新文件,轮次历史留在文件名里)、作者角色取宿主 `ctx.agent` 不取模型自报、原子 tmp+rename |
53
+ | 回收 | `src/blackboard.ts:176-188` + `sweepStale` | 唯一回收路径 = TTL(默认 5 天)+ 启动扫 + 每小时扫;`v2` 也补上了这个 sweeper(注释原话:note 承诺了清扫却不带机制就是本产品在拒绝的那种夸大) |
54
+ | 残留位置 | 同上 | git 工作区:`<repo>/.git/opencode-team/`(不进 commit);非 git:`<tmpdir>/opencode-team/w-<sha256(path)>` 分片 + 分片回收 + 升级遗留桶回收;`TM_STORE_RECLAIM=off` 关掉 |
55
+ | 用户可核对性 | `src/tm/stats.ts`、桌面渲染层取证 | 插件工具在桌面里是**一行、点不开**(渲染器只注册 14 个内置名,`GenericTool` 从不读 `props.output`)—— 所以细节走盘:`tm_stats {recent:N}` 把 `tm_fetch` 句柄 **和 payload 绝对路径**一起打出来;黑板文件本身是用户能用编辑器直接打开的 `.md` |
56
+ | 团队正式信道 | `src/prompts/shared.ts`(REPLY_CONTRACT) | STATUS/CHANGES/FINDINGS/EVIDENCE/HANDOFF 骨架是**主传输**,黑板只服务 >~50 行的超大交付 —— 也就是"混合"不是备选,是现状 |
57
+
58
+ ---
59
+
60
+ ## 四、对比结论:黑板 vs 每个可行信道(六维)
61
+
62
+ 打分口径:**优 / 中 / 差**,只看这条信道相对"作为团队的交付信道"的表现。
63
+
64
+ | 维度 | 0 黑板(基线) | 2 session_inbox / synthetic | 4 ctx.storage | 5 原生 write/read | 1 subagent 信封 | 6 事件流 |
65
+ |---|---|---|---|---|---|---|
66
+ | **token 成本** | **优**:正文不进上下文;固定成本 = 领队 296 token/请求 + 目录按需 | **差**:正文按设计进目标上下文,无预算/无预览 | **中**:不进上下文,但读回时同样要靠我们自己卸载;`scan` 会把所有 value 一次拉进来 | **中**:写侧和黑板一样要模型产出;读侧 `read` 默认整段进(靠我们的 `execute.after` 才不外溢) | **差→中**:宿主整段注入(这是我们花两套 offload 去补的那个洞) | **中**:类型名便宜,载荷成员未知 |
67
+ | **可核对性(用户能否点开看到)** | **优**:回复里是绝对路径,`.md` 能用任何编辑器打开;轨迹有 bytes/role/session | 差:队列在宿主存储里,桌面没有"某会话待办箱"入口(未见,未证) | **最差**:`opencode.db` 的一个 blob,用户既打不开也不会去开 | **优**:工作树里的真文件,`git status`/侧栏都看得见 | **优**:task 卡片直连子会话,是**唯一**用户能点开的子代理形态 | 差:瞬时;`event` 表在盘上但没有 UI |
68
+ | **并发安全** | **优**:绝不覆盖 + `NN` 序数 + `-rN` 家族 + 原子 rename;撞名显式拒绝并说明是"同一轮的并发写入" | **优**:durable 队列 + `enqueued_seq` 有序 + `delivery` 语义 —— 这是全场最好的写并发模型(追加,不是覆盖) | **差**:`set` 是无条件覆盖,无 CAS/版本;键还全局共享 → 两个窗口互相盖章已经在我们的 `selfcheck` 键上真实发生 | **差**:last-writer-wins,无 ordinals | 中:一次派发一个子会话,天然不撞 | 中:只读不写 |
69
+ | **生命周期 / 卸载残留** | **优(唯一带机制的)**:TTL 清扫 + 分片回收 + 遗留桶回收,且落在 `.git` 下不污染 commit | **优**:随会话删除级联清理(`session delete` 的既有语义) | **差**:**零回收**(没有 TTL、没有配额、`scan` 都不给全量),卸载插件后键还在 | **差**:写在用户工作树里,删不删全看模型自觉(原则 8 就是为这个存在的) | 中:随会话;会话本身没人删就留着 | 中:磁盘 `event` 表由宿主管 |
70
+ | **权限与红线** | **中**:门禁全在我们代码里(realpath/.md/上限),**宿主一次都不评估** —— 好处是不依赖 v2 缺失的官方弹窗,坏处是"越出黑板"这件事没有第二道闸 | **差(要看清)**:**没有任何评估、没有弹窗、没有来源标注** —— 谁都能往任何会话注一条 user 角色文本;把它当正式信道 = 把团队的握手协议架在一个不可问责的入口上 | **中**:按插件 id 隔离(实测换 id 就读不到),但对 agent/session/project **零隔离** —— 我们必须在键里自己写作用域 | **优**:这是唯一一条有真宿主权限的信道(`edit`/`read` 规则 + `external_directory` 的 `effect:"ask"` + 宿主自己抬的 `permission.asked`) | **优**:`subagent` 有权限动作,宿主有 kill switch、有卡片 —— 这正是 v1 删掉 `tm_dispatch` 的理由 | 中:只读面,红线不在它身上 |
71
+ | **宿主升级脆弱度** | **低-中**:只依赖 `node:fs` + 我们的工具注册;唯一外部接缝是 `session.hook("context")` 推 note(v1→v2 已经换过一次写法) | **中**:类型化 API 调用(不是解析字符串),但 `synthetic/prompt/inbox` 都是 v2 新面,无稳定性承诺 | **低-中**:`get/set/remove/scan` 有文档;**"按插件 id 命名空间"和"全局共享"是从磁盘形状看出来的,没写在任何文档上** | **最低**:宿主自己的工具与权限动作 | **最高(我们已经吃过一次)**:信封字符串 1.18=`<task id=` ↔ 2.0.16=`<subagent sessionID=`,v1 的匹配器在 v2 上是**结构性死码**,而 `task_envelopes:0` 一度被当成"没有超大回复" | **中-高**:事件名在两代之间整批改过(v1 `session.idle/status/error` ↔ 实测 v2 `session.execution.*/step.*/inbox.*/retry.scheduled`;今天**没有看到一次 `session.idle`**) |
72
+
73
+ **判决:混合,不换基线。**
74
+
75
+ 1. **超大交付物继续走黑板,不改。** 它是六维里唯一在"不进上下文 + 用户可点开 + 有自动回收 + 有写并发协议"四项同时拿优的信道;`ctx.storage` 在可核对性和回收上直接归零,原生 `write` 在并发与残留上归零,inbox/信封在 token 上归零。宿主没给我们一个能替代它的东西。
76
+ 2. **`session_inbox` / `ctx.session.synthetic` 应该吸收,但只吸收"信号",不吸收"正文"。** 它在我们最弱的一维(并发安全:追加 + `enqueued_seq` 有序 + `delivery` + `cancel`)严格优于现状 —— 现状是**解析文本信封 + 靠标题里的 ` ·tm` 标记认领子会话**(`src/tm/dispatch.ts` 的 `parseDispatchTitle` / `claimNamedChild` / `adoptFromHost`),那是全场最脆的东西(信封已经变过一次代)。把"谁结算了 / 下一棒是谁 / 这轮没等到全部结算"这类**短结构化信号**搬到官方队列上,能直接废掉一整套字符串匹配;而正文仍旧走路径。
77
+ 3. **搬的时候必须自带三样东西**(因为宿主一样都没给):**(a) 长度上限**(synthetic 载荷要过我们自己的 offload 阈值,超了改投路径 + 句柄);**(b) 来源标注**(收到的一侧要在回复里写明"这段来自插件注入的 synthetic 消息,不是用户写的"——目标 6:一条注入进去的 `delivery:"steer"` 文本会以 user 角色被模型相信,这就是它不能当正式信道的全部理由);**(c) 轨迹账**(每一次写都记 `session_inbox` id + 目标会话哈希,否则"我通知过他了"又变成一个不可核对的已确认关闭)。
78
+ 4. **`ctx.storage` 保持"索引/账本"角色(`tm_ledger` 已经是),但两个必修**:键必须自带作用域(实测它是**全局**的:`plugin:<id>:<key>`,没有 location 列 —— 我们今天的 `team-mode/selfcheck` 已经在两个窗口之间互相覆盖),并且**要我们自己写配额 + 启动清扫**(宿主一条都没有)。`scan` 在任何面向模型的封装里都必须只回键名,绝不把 `value` 原样端出去。
79
+ 5. **`ctx.event.subscribe()` 只当结算探测器**(替掉 `tm_join` 里的 `session.status` 轮询),类型名走**白名单 + "没见过这个类型就上报"的活扣** —— 事件名整代换过,这是数据不是猜想。它不需要 Effect、不需要依赖,成本可以接受。
80
+ 6. **不要用原生 `write` 写交付物**(污染工作树 + 无回收),**不要把任何同意/红线搬到 synthetic 上**(它不评估权限,v2 插件也抬不起对话框 —— 与 AGENTS"Goal 5 在 v2 不存在"一致)。
81
+
82
+ 若要实施(本报告不实施),要动的模块:
83
+ `src/host/v2-inbox.ts`(新增:带长度闸 + 来源标注 + 轨迹的 synthetic 写入)·
84
+ `src/tm/dispatch.ts`(`tm_join` 结算改吃 `session_inbox` + 事件;`parseDispatchTitle`/`adoptFromHost` 降级为 v1 路径)·
85
+ `src/task-offload.ts` + `src/host/v2-offload.ts`(信封匹配从"唯一机制"降为"兜底")·
86
+ `src/tm/ledger.ts`(键作用域 + 配额 + 启动清扫;`scan` 输出剥 `value`)·
87
+ `src/host/v2.ts`(`team-mode/selfcheck` 键加会话/项目作用域)·
88
+ `src/host/note.ts`(note 增写信号信道 + "synthetic ≠ 用户"这一句)·
89
+ `src/capabilities.ts` + `src/host/v2-probe.ts`(把 `session.synthetic`、事件类型集、kv 往返加进能力账)·
90
+ `src/agents.ts`/`src/prompts/*` + `test-blackboard.mjs`(新提示词规则配新断言)·
91
+ `test-v2-adapter.mjs` + `scripts/lib/fake-ctx.mjs`(`ctx.session` 需要真实形状的宿主替身:现在这个域在 fake 里是空的)。
92
+
93
+ ---
94
+
95
+ ## 五、证据不足项(下次补,别再当已知)
96
+
97
+ 1. **`subagent` 返回信封今天没复测**:模型层整天不通(`lxns-uni/zai-org/GLM-5.3#max` 与 `lxns-maiden/...` 都是 `Transport: socket closed`,第三个 provider 明确回 `Invalid API-key provided`)。表里那一行沿用先前对 2.0.16 二进制的只读测量,**不是今天的活体**。
98
+ 2. **`ctx.session.context({sessionID})` 到底是不是"读全量历史"**:空闲会话回 `[]`、注入后回 2 项、再回 6 项,但项的形状不是 `{role,content}`(role 读不出来),我**没解开它的项结构**,所以既不能说它是全量历史也不能说它不是。父读子完整历史这个问题因此**仍未结**。
99
+ 3. **事件载荷 `item` 里有没有正文**:本轮 logger 只记成员名(隐私口径要求),`session.inbox.enqueued{inboxID,item,sessionID}` 的 `item` 内容未看。
100
+ 4. **`external_directory` 今天的实测行为**:只有先前一轮的 `effect:"ask"` + `permission.asked`,今天没有成功跑出一个原生读。
101
+ 5. **kv 的"全局"是从磁盘形状(`kv` 无 location 列 + 键前缀只有 plugin id)推出来的**,不是从一个项目在另一个项目里显式读成功 —— 差一次"两个 workspace、同一插件 id、A 写 B 读"的正向确认。
102
+ 6. **`experimental.terminal.read({sessionID})`** 只证明它接受 `{sessionID}` 并回 `null`(沙箱里没有终端);它是否能读**别人的**终端输出(`/api/experimental/session/:sessionID/terminal/read` 的存在暗示可以)未证。
103
+ 7. **`todo` 表有没有任何 agent/插件可达面**:表在,`ctx.session` 无 todo 方法,REST 操作全集(二进制里 `ao("…","/api/…")`)里没有 todo 操作 —— 所以 #16 的现状是"宿自有账本、插件拿不到",但没有穷尽其它入口(比如 MCP 面)。
104
+ 8. **真 `subagent` 子会话的 `session_v2.parent_id` 是否被写入**:我只看到自己 `create()` 出来的会话 `parent_id` 为空(且返回体根本不暴露该字段),没有一次成功的宿主派发去对照。
105
+ 9. **`worktree.create/remove` 是否被权限评估**、以及它能不能当作"每 agent 一个目录"的隔离面 —— 只测到 `list` 需要 `{projectID}`。
106
+ 10. **MCP resource**:`ctx.resource` 不存在;`mcp` 域只有 `list/reload/transform`。宿主是否有别的路径暴露 MCP 资源未查(REST 里有 `mcp.resource.catalog` = `GET /api/mcp/resource`,但插件面没对上它)。
107
+ 11. **插件级项目自动发现**:把探针放进 `<workspace>/.opencode/plugins/*.mjs` **没有被加载**(跑了三轮没一条日志;最后是用 `plugins:["file:///<目录>"]` 装进临时配置目录才生效)—— 文档说的"自动发现目录"这条在 2.0.16 的这个布局下**没复现**,值得单独确认(可能要求目录里就是一个包)。
108
+
109
+ ---
110
+
111
+ ## 六、本次取证用到的原始形状(供复现,全部只含名字/计数)
112
+
113
+ - `ctx` 的域(实测成员):`agent aisdk app command event experimental generate integration location mcp model options permission plugin provider reference rpc session shell skill storage tool vcs websearch worktree`(**无 `resource`**,无 `ask`)。
114
+ - `ctx.session` 方法:`command context create generate get hook interrupt move prompt switchAgent switchModel synthetic update wait`。
115
+ - `session.hook("context")` 的事件成员:`agent messages model options sessionID system tools`;其中 `messages[i]` = `{content, id, metadata, role}`,`content` 是**数组**(不是 v1 的 parts 数组);`build` 面上 12 工具、`general` 面上 10 工具(`question`/`subagent` 只在 primary 上)。
116
+ - 宿主 `opencode.db` 表名(`PRAGMA table_info` 只读):`kv(key,value,time_created,time_updated)`、`session_inbox(id,session_id,type,payload,delivery,enqueued_seq,time_created)`、`session_v2(id,project_id,workspace_id,parent_id,fork_session_id,fork_boundary,slug,directory,path,title,version,share_url,summary_*,metadata,cost,tokens_*,revert,permission,agent,model,time_*,idle_outcome)`、`message(id,session_id,…)`、`todo(session_id,content,status,priority,position,…)`、`instruction_entry(session_id,key,value,removed,…)`、`worktree(project_id,directory,strategy,…)`、`event(id,aggregate_id,seq,type,data,created)`。
117
+ - REST 操作全集里与本问题相关的读取面(从二进制 `ao("<op>","/api/…")` 表读出,操作名 + 路径,无内容):`session.get /api/session/:sessionID`、`session.context /api/session/:sessionID/context`、`session.messages /api/session/:sessionID/message`、`session.message /api/session/:sessionID/message/:messageID`、`session.inbox.list /api/session/:sessionID/inbox`、`session.diff /api/session/:sessionID/diff`、`session.export /api/experimental/session/:sessionID/export`、`session.log /api/experimental/session/:sessionID/log`、`event.subscribe /api/event`、`fs.read /api/fs/read/*`、`fs.find fs.list`、`persistentPty.read /api/experimental/session/:sessionID/terminal/read`、`mcp.resource.catalog /api/mcp/resource`、`session.active session.list permission.request.list permission.saved.list`。**清单里没有 todo 操作。**
@@ -1,94 +1,94 @@
1
- # 能不能把 tm_browser 显示进宿主侧边栏?(BrowserPane 取证)
2
-
3
- > **历史记录(2026-10-06 补注):自建浏览器 `tm_browser` 已于 1.7.0 移除**(提交 `d668817`,
4
- > 破坏性变更)。本页记录的是移除前的取证,保留是因为它解释了"为什么浏览器只能交给宿主原生
5
- > `browser_*`"——结论(插件无法把自己的页面注册进侧边栏)至今成立,且正是移除自建浏览器的依据。
6
- > 现在浏览器只剩宿主原生 `browser_*`,由 `src/host/v2-browser-gate.ts` 在 `execute.before` 上治理。
7
-
8
- 日期 2026-09-25。取证对象是用户机器上的安装体,只读、未修改宿主:
9
-
10
- | 证据 | 位置 |
11
- |---|---|
12
- | 桌面端(Electron main + renderer)| `C:\Users\34296\AppData\Local\Programs\@opencode-aidesktop\resources\app.asar`(118 MB,字节偏移见下) |
13
- | 服务端(插件运行其中)| 同目录 `opencode-cli.exe`(205 496 872 B),`opencode-cli.version` = 2.0.16 |
14
- | 官方 v2 插件文档 | `https://opencode.ai/v2/docs/build/plugins/migrate-v1`、`.../plugins/rpc`(用户提供的链接) |
15
-
16
- ## 结论
17
-
18
- **不能。** 那个侧边栏(宿主自己叫它 `BrowserPane`)绑定的是**宿主浏览器自己的一个 CDP/代理端点**,
19
- 注册动作发生在桌面端进程内部,插件面没有任何登记缝。能进面板的只有两件事:
20
- ①模型直接使宿主原生的 `browser_*` 工具(面板显示的就是它们驱动的那个浏览器);
21
- ②`browser_preview {path}`——它能把**服务端本地文件**(png/html/md/pdf/mermaid/csv…)渲染进面板,
22
- 所以 tm_browser 的截图可以借它露脸,但那是一张**静帧**,不是活的浏览器面板。
23
-
24
- ## 证据链
25
-
26
- 1. **面板的注册签名**(app.asar 偏移 78 280 813):
27
-
28
- ```js
29
- async register(e, c, d) {
30
- if (s || !xb(d.endpoint.url)) throw Error(`browser.pane.registration.invalid`)
31
- if (d.endpoint.username && !d.endpoint.password) throw Error(`browser.pane.endpoint.invalid`)
32
- if (r.has(c)) throw Error(`browser.pane.owner.invalid`)
33
- if (e.isDestroyed() || e.webContents.isDestroyed()) throw Error(`browser.pane.owner.unavailable`)
34
- const m = z.make(d.sessionID), h = `${d.serverKey}\n${m}` // 标签页状态按 serverKey+sessionID 持久化
35
- ...
36
- }
37
- ```
38
-
39
- 第一个参数是 **Electron `BrowserWindow`**,`d.endpoint` 是一个带可选 basic auth 的 URL(realm
40
- 字面量 `OpenCode Browser Proxy`,同一文件里 `setProxy({mode:"fixed_servers", proxyRules:n.url,
41
- proxyBypassRules:"<-loopback>"})` + `login` 事件里回填凭据)。也就是说:**注册者必须先持有一个
42
- 桌面窗口的句柄,并交出一个浏览器端点**。插件跑在服务端进程里,两者都没有。
43
-
44
- 2. **谁在调用它**(偏移 102 864 096,renderer 侧的 IPC 表):
45
-
46
- ```js
47
- browserPane: {
48
- request: (e) => U(`BrowserPane`, { request: e }),
49
- send: (e) => Ht(`BrowserPane`, { request: e }),
50
- onEvent: (e) => Ut(`BrowserPaneEvent`, (t) => e(t)),
51
- }
52
- ```
53
-
54
- `U/Ht/Ut` 是 `window.electron` 的 IPC 封装;`BrowserPaneEvent` 的定义在偏移 78 665 600:
55
- `class extends s("BrowserPaneEvent", { bindingID: o, event: ms })`,并且它出现在
56
- `DesktopEvents` 的联合里(与 `SshChanged`、`WslServersChanged`、`StorageChanged` 并列)。
57
- **通信两端都是桌面端(renderer ↔ main),不经过服务端,因此插件既发不出 `BrowserPane` 请求,
58
- 也收不到 `BrowserPaneEvent`。**
59
-
60
- 3. **服务端根本没有面板 API**(在 205 MB 的 `opencode-cli.exe` 上做字面量统计):
61
- `BrowserPane` / `browser.pane` / `browserPane` / `/experimental/browser` 命中数均为 **0**;
62
- `pane` 相关只有 `pane_id`(终端用的)。面板是桌面端独有实现,服务端不提供可被插件调用的注册面。
63
-
64
- 4. **官方文档口径一致**:migrate-v1 页列出的 v2 上下文域是
65
- `location / options / storage / event / tool / shell / session / permission / agent / provider /
66
- model / command / integration / mcp / reference / skill / catalog / vcs / websearch / worktree`
67
- ——**没有 browser、没有 panel、没有 webview**。rpc 页只给方法级 RPC
68
- (`ctx.rpc.register(Ns, {…})`、`ctx.rpc(Ns)`、`client.rpc(Ns)`、`registration.events.emit/on/subscribe`),
69
- 且该页对 browser/preview/panel/webview 的提及次数为 0。
70
- 注意:`ctx.rpc` 是**插件↔客户端/插件↔插件的过程调用**,不是渲染注册;桌面端不会因为一个插件
71
- 注册了某个命名空间就为它开面板。
72
-
73
- 5. **代价已经量过**(本仓库 `docs/research/v2-builtin-tools.md` §5):宿主 45 个 `browser_*` 工具共享
74
- `options.permission:"browser"` + `codemode:true`,而 `[L]` 那次活体观测里
75
- **`browser_*` 调用完全没有触发 `permission.evaluate`**。所以一旦把联网交给原生工具,我们的
76
- 域名门禁 / 逐次同意 / 子资源策略 / 租约与空闲回收都管不到它——能管的只剩请求层可见性
77
- (我们已经会在 `tm_browser` 被拒时摘掉整个 `browser_*` 目录)与结果层 JIT 卸载
78
- (`browser_*` 走前缀匹配,已在治理名单里)。
79
-
80
- ## 三条可走的路(等拍板)
81
-
82
- | 方案 | 侧边栏 | 治理 | 说明 |
83
- |---|---|---|---|
84
- | A 交给原生 `browser_*` | 有(真面板、活的) | **只剩请求层 + JIT**;逐次同意/域名门禁失效 | 最贴合"尽可能用原生"。v2 无确认框,本来也没有逐次同意可谈 |
85
- | B 保持现状 | 无(独立有头 Edge 窗口) | 全部保留 | 用户看得到窗口,只是不在应用里 |
86
- | C 混合(推荐) | 有(模型优先用原生;截图另走 `browser_preview`) | 原生走 A,需要门禁的动作走 tm_browser | 提示词里写死分工,两者都在面板里可见;`browser_preview` 露帧这一条**尚未活体验证**(需要用户在桌面端跑) |
87
-
88
- ## 还没证明的
89
-
90
- - `browser_preview {path}` 是否真能把**我们 run store 里的 PNG** 渲染进面板(路径限制写的是
91
- "server-local、相对 workspace 或绝对",我们的 payload 正好是服务端本地文件——但没在 GUI 里看过)。
92
- - 桌面端会不会把插件会话的 `browser_*` 调用与 `sessionID` 绑到同一个面板(面板按
93
- `serverKey+sessionID` 存标签页,理论上应该跟会话走)。
94
- - 原生 `browser_*` 在**有**桌面连接时是否会触发 `permission.evaluate`(`[L]` 那次是 standalone)。
1
+ # 能不能把 tm_browser 显示进宿主侧边栏?(BrowserPane 取证)
2
+
3
+ > **历史记录(2026-10-06 补注):自建浏览器 `tm_browser` 已于 1.7.0 移除**(提交 `d668817`,
4
+ > 破坏性变更)。本页记录的是移除前的取证,保留是因为它解释了"为什么浏览器只能交给宿主原生
5
+ > `browser_*`"——结论(插件无法把自己的页面注册进侧边栏)至今成立,且正是移除自建浏览器的依据。
6
+ > 现在浏览器只剩宿主原生 `browser_*`,由 `src/host/v2-browser-gate.ts` 在 `execute.before` 上治理。
7
+
8
+ 日期 2026-09-25。取证对象是用户机器上的安装体,只读、未修改宿主:
9
+
10
+ | 证据 | 位置 |
11
+ |---|---|
12
+ | 桌面端(Electron main + renderer)| `C:\Users\34296\AppData\Local\Programs\@opencode-aidesktop\resources\app.asar`(118 MB,字节偏移见下) |
13
+ | 服务端(插件运行其中)| 同目录 `opencode-cli.exe`(205 496 872 B),`opencode-cli.version` = 2.0.16 |
14
+ | 官方 v2 插件文档 | `https://opencode.ai/v2/docs/build/plugins/migrate-v1`、`.../plugins/rpc`(用户提供的链接) |
15
+
16
+ ## 结论
17
+
18
+ **不能。** 那个侧边栏(宿主自己叫它 `BrowserPane`)绑定的是**宿主浏览器自己的一个 CDP/代理端点**,
19
+ 注册动作发生在桌面端进程内部,插件面没有任何登记缝。能进面板的只有两件事:
20
+ ①模型直接使宿主原生的 `browser_*` 工具(面板显示的就是它们驱动的那个浏览器);
21
+ ②`browser_preview {path}`——它能把**服务端本地文件**(png/html/md/pdf/mermaid/csv…)渲染进面板,
22
+ 所以 tm_browser 的截图可以借它露脸,但那是一张**静帧**,不是活的浏览器面板。
23
+
24
+ ## 证据链
25
+
26
+ 1. **面板的注册签名**(app.asar 偏移 78 280 813):
27
+
28
+ ```js
29
+ async register(e, c, d) {
30
+ if (s || !xb(d.endpoint.url)) throw Error(`browser.pane.registration.invalid`)
31
+ if (d.endpoint.username && !d.endpoint.password) throw Error(`browser.pane.endpoint.invalid`)
32
+ if (r.has(c)) throw Error(`browser.pane.owner.invalid`)
33
+ if (e.isDestroyed() || e.webContents.isDestroyed()) throw Error(`browser.pane.owner.unavailable`)
34
+ const m = z.make(d.sessionID), h = `${d.serverKey}\n${m}` // 标签页状态按 serverKey+sessionID 持久化
35
+ ...
36
+ }
37
+ ```
38
+
39
+ 第一个参数是 **Electron `BrowserWindow`**,`d.endpoint` 是一个带可选 basic auth 的 URL(realm
40
+ 字面量 `OpenCode Browser Proxy`,同一文件里 `setProxy({mode:"fixed_servers", proxyRules:n.url,
41
+ proxyBypassRules:"<-loopback>"})` + `login` 事件里回填凭据)。也就是说:**注册者必须先持有一个
42
+ 桌面窗口的句柄,并交出一个浏览器端点**。插件跑在服务端进程里,两者都没有。
43
+
44
+ 2. **谁在调用它**(偏移 102 864 096,renderer 侧的 IPC 表):
45
+
46
+ ```js
47
+ browserPane: {
48
+ request: (e) => U(`BrowserPane`, { request: e }),
49
+ send: (e) => Ht(`BrowserPane`, { request: e }),
50
+ onEvent: (e) => Ut(`BrowserPaneEvent`, (t) => e(t)),
51
+ }
52
+ ```
53
+
54
+ `U/Ht/Ut` 是 `window.electron` 的 IPC 封装;`BrowserPaneEvent` 的定义在偏移 78 665 600:
55
+ `class extends s("BrowserPaneEvent", { bindingID: o, event: ms })`,并且它出现在
56
+ `DesktopEvents` 的联合里(与 `SshChanged`、`WslServersChanged`、`StorageChanged` 并列)。
57
+ **通信两端都是桌面端(renderer ↔ main),不经过服务端,因此插件既发不出 `BrowserPane` 请求,
58
+ 也收不到 `BrowserPaneEvent`。**
59
+
60
+ 3. **服务端根本没有面板 API**(在 205 MB 的 `opencode-cli.exe` 上做字面量统计):
61
+ `BrowserPane` / `browser.pane` / `browserPane` / `/experimental/browser` 命中数均为 **0**;
62
+ `pane` 相关只有 `pane_id`(终端用的)。面板是桌面端独有实现,服务端不提供可被插件调用的注册面。
63
+
64
+ 4. **官方文档口径一致**:migrate-v1 页列出的 v2 上下文域是
65
+ `location / options / storage / event / tool / shell / session / permission / agent / provider /
66
+ model / command / integration / mcp / reference / skill / catalog / vcs / websearch / worktree`
67
+ ——**没有 browser、没有 panel、没有 webview**。rpc 页只给方法级 RPC
68
+ (`ctx.rpc.register(Ns, {…})`、`ctx.rpc(Ns)`、`client.rpc(Ns)`、`registration.events.emit/on/subscribe`),
69
+ 且该页对 browser/preview/panel/webview 的提及次数为 0。
70
+ 注意:`ctx.rpc` 是**插件↔客户端/插件↔插件的过程调用**,不是渲染注册;桌面端不会因为一个插件
71
+ 注册了某个命名空间就为它开面板。
72
+
73
+ 5. **代价已经量过**(本仓库 `docs/research/v2-builtin-tools.md` §5):宿主 45 个 `browser_*` 工具共享
74
+ `options.permission:"browser"` + `codemode:true`,而 `[L]` 那次活体观测里
75
+ **`browser_*` 调用完全没有触发 `permission.evaluate`**。所以一旦把联网交给原生工具,我们的
76
+ 域名门禁 / 逐次同意 / 子资源策略 / 租约与空闲回收都管不到它——能管的只剩请求层可见性
77
+ (我们已经会在 `tm_browser` 被拒时摘掉整个 `browser_*` 目录)与结果层 JIT 卸载
78
+ (`browser_*` 走前缀匹配,已在治理名单里)。
79
+
80
+ ## 三条可走的路(等拍板)
81
+
82
+ | 方案 | 侧边栏 | 治理 | 说明 |
83
+ |---|---|---|---|
84
+ | A 交给原生 `browser_*` | 有(真面板、活的) | **只剩请求层 + JIT**;逐次同意/域名门禁失效 | 最贴合"尽可能用原生"。v2 无确认框,本来也没有逐次同意可谈 |
85
+ | B 保持现状 | 无(独立有头 Edge 窗口) | 全部保留 | 用户看得到窗口,只是不在应用里 |
86
+ | C 混合(推荐) | 有(模型优先用原生;截图另走 `browser_preview`) | 原生走 A,需要门禁的动作走 tm_browser | 提示词里写死分工,两者都在面板里可见;`browser_preview` 露帧这一条**尚未活体验证**(需要用户在桌面端跑) |
87
+
88
+ ## 还没证明的
89
+
90
+ - `browser_preview {path}` 是否真能把**我们 run store 里的 PNG** 渲染进面板(路径限制写的是
91
+ "server-local、相对 workspace 或绝对",我们的 payload 正好是服务端本地文件——但没在 GUI 里看过)。
92
+ - 桌面端会不会把插件会话的 `browser_*` 调用与 `sessionID` 绑到同一个面板(面板按
93
+ `serverKey+sessionID` 存标签页,理论上应该跟会话走)。
94
+ - 原生 `browser_*` 在**有**桌面连接时是否会触发 `permission.evaluate`(`[L]` 那次是 standalone)。