@te-river/opencode-team-mode 1.6.0 → 1.6.2

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 (150) hide show
  1. package/README.md +80 -15
  2. package/README.zh-CN.md +67 -18
  3. package/dist/host/note.d.ts +7 -0
  4. package/dist/host/note.d.ts.map +1 -0
  5. package/dist/host/note.js +31 -0
  6. package/dist/host/note.js.map +1 -0
  7. package/dist/host/v1.d.ts +14 -0
  8. package/dist/host/v1.d.ts.map +1 -0
  9. package/dist/host/v1.js +340 -0
  10. package/dist/host/v1.js.map +1 -0
  11. package/dist/host/v2-browser-gate.d.ts +45 -0
  12. package/dist/host/v2-browser-gate.d.ts.map +1 -0
  13. package/dist/host/v2-browser-gate.js +319 -0
  14. package/dist/host/v2-browser-gate.js.map +1 -0
  15. package/dist/host/v2-capabilities.d.ts +78 -0
  16. package/dist/host/v2-capabilities.d.ts.map +1 -0
  17. package/dist/host/v2-capabilities.js +173 -0
  18. package/dist/host/v2-capabilities.js.map +1 -0
  19. package/dist/host/v2-client.d.ts +53 -0
  20. package/dist/host/v2-client.d.ts.map +1 -0
  21. package/dist/host/v2-client.js +193 -0
  22. package/dist/host/v2-client.js.map +1 -0
  23. package/dist/host/v2-events.d.ts +56 -0
  24. package/dist/host/v2-events.d.ts.map +1 -0
  25. package/dist/host/v2-events.js +158 -0
  26. package/dist/host/v2-events.js.map +1 -0
  27. package/dist/host/v2-guard.d.ts +101 -0
  28. package/dist/host/v2-guard.d.ts.map +1 -0
  29. package/dist/host/v2-guard.js +199 -0
  30. package/dist/host/v2-guard.js.map +1 -0
  31. package/dist/host/v2-offload.d.ts +148 -0
  32. package/dist/host/v2-offload.d.ts.map +1 -0
  33. package/dist/host/v2-offload.js +288 -0
  34. package/dist/host/v2-offload.js.map +1 -0
  35. package/dist/host/v2-permissions.d.ts +123 -0
  36. package/dist/host/v2-permissions.d.ts.map +1 -0
  37. package/dist/host/v2-permissions.js +190 -0
  38. package/dist/host/v2-permissions.js.map +1 -0
  39. package/dist/host/v2-probe.d.ts +85 -0
  40. package/dist/host/v2-probe.d.ts.map +1 -0
  41. package/dist/host/v2-probe.js +268 -0
  42. package/dist/host/v2-probe.js.map +1 -0
  43. package/dist/host/v2-scope.d.ts +51 -0
  44. package/dist/host/v2-scope.d.ts.map +1 -0
  45. package/dist/host/v2-scope.js +58 -0
  46. package/dist/host/v2-scope.js.map +1 -0
  47. package/dist/host/v2-session-client.d.ts +105 -0
  48. package/dist/host/v2-session-client.d.ts.map +1 -0
  49. package/dist/host/v2-session-client.js +390 -0
  50. package/dist/host/v2-session-client.js.map +1 -0
  51. package/dist/host/v2-session.d.ts +85 -0
  52. package/dist/host/v2-session.d.ts.map +1 -0
  53. package/dist/host/v2-session.js +171 -0
  54. package/dist/host/v2-session.js.map +1 -0
  55. package/dist/host/v2-subagent.d.ts +157 -0
  56. package/dist/host/v2-subagent.d.ts.map +1 -0
  57. package/dist/host/v2-subagent.js +286 -0
  58. package/dist/host/v2-subagent.js.map +1 -0
  59. package/dist/host/v2-tool.d.ts +67 -0
  60. package/dist/host/v2-tool.d.ts.map +1 -0
  61. package/dist/host/v2-tool.js +209 -0
  62. package/dist/host/v2-tool.js.map +1 -0
  63. package/dist/host/v2-transcript.d.ts +73 -0
  64. package/dist/host/v2-transcript.d.ts.map +1 -0
  65. package/dist/host/v2-transcript.js +151 -0
  66. package/dist/host/v2-transcript.js.map +1 -0
  67. package/dist/host/v2-types.d.ts +229 -0
  68. package/dist/host/v2-types.d.ts.map +1 -0
  69. package/dist/host/v2-types.js +17 -0
  70. package/dist/host/v2-types.js.map +1 -0
  71. package/dist/host/v2.d.ts +42 -0
  72. package/dist/host/v2.d.ts.map +1 -0
  73. package/dist/host/v2.js +853 -0
  74. package/dist/host/v2.js.map +1 -0
  75. package/dist/index.d.ts +23 -17
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +29 -366
  78. package/dist/index.js.map +1 -1
  79. package/dist/prompts/lead.d.ts +1 -1
  80. package/dist/prompts/lead.d.ts.map +1 -1
  81. package/dist/prompts/lead.js +456 -426
  82. package/dist/prompts/lead.js.map +1 -1
  83. package/dist/prompts/shared.d.ts +2 -2
  84. package/dist/prompts/shared.d.ts.map +1 -1
  85. package/dist/prompts/shared.js +38 -11
  86. package/dist/prompts/shared.js.map +1 -1
  87. package/dist/prompts/specialists.d.ts +2 -2
  88. package/dist/prompts/specialists.d.ts.map +1 -1
  89. package/dist/prompts/specialists.js +7 -6
  90. package/dist/prompts/specialists.js.map +1 -1
  91. package/dist/task-offload.d.ts +16 -0
  92. package/dist/task-offload.d.ts.map +1 -1
  93. package/dist/task-offload.js +51 -0
  94. package/dist/task-offload.js.map +1 -1
  95. package/dist/tm/browser.d.ts.map +1 -1
  96. package/dist/tm/browser.js +41 -10
  97. package/dist/tm/browser.js.map +1 -1
  98. package/dist/tm/dispatch.d.ts +86 -1
  99. package/dist/tm/dispatch.d.ts.map +1 -1
  100. package/dist/tm/dispatch.js +360 -39
  101. package/dist/tm/dispatch.js.map +1 -1
  102. package/dist/tm/dupe-guard.d.ts +16 -3
  103. package/dist/tm/dupe-guard.d.ts.map +1 -1
  104. package/dist/tm/dupe-guard.js +27 -7
  105. package/dist/tm/dupe-guard.js.map +1 -1
  106. package/dist/tm/index.d.ts +40 -1
  107. package/dist/tm/index.d.ts.map +1 -1
  108. package/dist/tm/index.js +40 -11
  109. package/dist/tm/index.js.map +1 -1
  110. package/dist/tm/ledger.d.ts +114 -0
  111. package/dist/tm/ledger.d.ts.map +1 -0
  112. package/dist/tm/ledger.js +308 -0
  113. package/dist/tm/ledger.js.map +1 -0
  114. package/dist/tm/perm-ask.d.ts +1 -3
  115. package/dist/tm/perm-ask.d.ts.map +1 -1
  116. package/dist/tm/perm-ask.js +10 -1
  117. package/dist/tm/perm-ask.js.map +1 -1
  118. package/dist/tm/preview.d.ts +55 -0
  119. package/dist/tm/preview.d.ts.map +1 -1
  120. package/dist/tm/preview.js +225 -0
  121. package/dist/tm/preview.js.map +1 -1
  122. package/dist/tm/search.d.ts.map +1 -1
  123. package/dist/tm/search.js +40 -10
  124. package/dist/tm/search.js.map +1 -1
  125. package/dist/tm/serp-loop.d.ts +45 -0
  126. package/dist/tm/serp-loop.d.ts.map +1 -0
  127. package/dist/tm/serp-loop.js +80 -0
  128. package/dist/tm/serp-loop.js.map +1 -0
  129. package/dist/tm/stats.d.ts +29 -0
  130. package/dist/tm/stats.d.ts.map +1 -1
  131. package/dist/tm/stats.js +278 -4
  132. package/dist/tm/stats.js.map +1 -1
  133. package/dist/tm/webfetch.d.ts +4 -0
  134. package/dist/tm/webfetch.d.ts.map +1 -1
  135. package/dist/tm/webfetch.js +75 -8
  136. package/dist/tm/webfetch.js.map +1 -1
  137. package/docs/installation-v2.md +445 -0
  138. package/docs/installation.md +12 -0
  139. package/docs/research/agent-data-exchange.md +117 -0
  140. package/docs/research/browser-pane.md +89 -0
  141. package/docs/research/host-http-api.md +132 -0
  142. package/docs/research/host-subagent-injection.md +187 -0
  143. package/docs/research/plugin-loader-contract.md +261 -0
  144. package/docs/research/v2-builtin-tools.md +447 -0
  145. package/docs/v2-port-notes.md +413 -0
  146. package/index.js +7 -0
  147. package/package.json +64 -61
  148. package/scripts/gen-v2-config.mjs +304 -0
  149. package/scripts/lib/config-surgery.cjs +239 -0
  150. package/scripts/lib/fake-ctx.mjs +169 -0
@@ -0,0 +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 操作。**
@@ -0,0 +1,89 @@
1
+ # 能不能把 tm_browser 显示进宿主侧边栏?(BrowserPane 取证)
2
+
3
+ 日期 2026-09-25。取证对象是用户机器上的安装体,只读、未修改宿主:
4
+
5
+ | 证据 | 位置 |
6
+ |---|---|
7
+ | 桌面端(Electron main + renderer)| `C:\Users\34296\AppData\Local\Programs\@opencode-aidesktop\resources\app.asar`(118 MB,字节偏移见下) |
8
+ | 服务端(插件运行其中)| 同目录 `opencode-cli.exe`(205 496 872 B),`opencode-cli.version` = 2.0.16 |
9
+ | 官方 v2 插件文档 | `https://opencode.ai/v2/docs/build/plugins/migrate-v1`、`.../plugins/rpc`(用户提供的链接) |
10
+
11
+ ## 结论
12
+
13
+ **不能。** 那个侧边栏(宿主自己叫它 `BrowserPane`)绑定的是**宿主浏览器自己的一个 CDP/代理端点**,
14
+ 注册动作发生在桌面端进程内部,插件面没有任何登记缝。能进面板的只有两件事:
15
+ ①模型直接使宿主原生的 `browser_*` 工具(面板显示的就是它们驱动的那个浏览器);
16
+ ②`browser_preview {path}`——它能把**服务端本地文件**(png/html/md/pdf/mermaid/csv…)渲染进面板,
17
+ 所以 tm_browser 的截图可以借它露脸,但那是一张**静帧**,不是活的浏览器面板。
18
+
19
+ ## 证据链
20
+
21
+ 1. **面板的注册签名**(app.asar 偏移 78 280 813):
22
+
23
+ ```js
24
+ async register(e, c, d) {
25
+ if (s || !xb(d.endpoint.url)) throw Error(`browser.pane.registration.invalid`)
26
+ if (d.endpoint.username && !d.endpoint.password) throw Error(`browser.pane.endpoint.invalid`)
27
+ if (r.has(c)) throw Error(`browser.pane.owner.invalid`)
28
+ if (e.isDestroyed() || e.webContents.isDestroyed()) throw Error(`browser.pane.owner.unavailable`)
29
+ const m = z.make(d.sessionID), h = `${d.serverKey}\n${m}` // 标签页状态按 serverKey+sessionID 持久化
30
+ ...
31
+ }
32
+ ```
33
+
34
+ 第一个参数是 **Electron `BrowserWindow`**,`d.endpoint` 是一个带可选 basic auth 的 URL(realm
35
+ 字面量 `OpenCode Browser Proxy`,同一文件里 `setProxy({mode:"fixed_servers", proxyRules:n.url,
36
+ proxyBypassRules:"<-loopback>"})` + `login` 事件里回填凭据)。也就是说:**注册者必须先持有一个
37
+ 桌面窗口的句柄,并交出一个浏览器端点**。插件跑在服务端进程里,两者都没有。
38
+
39
+ 2. **谁在调用它**(偏移 102 864 096,renderer 侧的 IPC 表):
40
+
41
+ ```js
42
+ browserPane: {
43
+ request: (e) => U(`BrowserPane`, { request: e }),
44
+ send: (e) => Ht(`BrowserPane`, { request: e }),
45
+ onEvent: (e) => Ut(`BrowserPaneEvent`, (t) => e(t)),
46
+ }
47
+ ```
48
+
49
+ `U/Ht/Ut` 是 `window.electron` 的 IPC 封装;`BrowserPaneEvent` 的定义在偏移 78 665 600:
50
+ `class extends s("BrowserPaneEvent", { bindingID: o, event: ms })`,并且它出现在
51
+ `DesktopEvents` 的联合里(与 `SshChanged`、`WslServersChanged`、`StorageChanged` 并列)。
52
+ **通信两端都是桌面端(renderer ↔ main),不经过服务端,因此插件既发不出 `BrowserPane` 请求,
53
+ 也收不到 `BrowserPaneEvent`。**
54
+
55
+ 3. **服务端根本没有面板 API**(在 205 MB 的 `opencode-cli.exe` 上做字面量统计):
56
+ `BrowserPane` / `browser.pane` / `browserPane` / `/experimental/browser` 命中数均为 **0**;
57
+ `pane` 相关只有 `pane_id`(终端用的)。面板是桌面端独有实现,服务端不提供可被插件调用的注册面。
58
+
59
+ 4. **官方文档口径一致**:migrate-v1 页列出的 v2 上下文域是
60
+ `location / options / storage / event / tool / shell / session / permission / agent / provider /
61
+ model / command / integration / mcp / reference / skill / catalog / vcs / websearch / worktree`
62
+ ——**没有 browser、没有 panel、没有 webview**。rpc 页只给方法级 RPC
63
+ (`ctx.rpc.register(Ns, {…})`、`ctx.rpc(Ns)`、`client.rpc(Ns)`、`registration.events.emit/on/subscribe`),
64
+ 且该页对 browser/preview/panel/webview 的提及次数为 0。
65
+ 注意:`ctx.rpc` 是**插件↔客户端/插件↔插件的过程调用**,不是渲染注册;桌面端不会因为一个插件
66
+ 注册了某个命名空间就为它开面板。
67
+
68
+ 5. **代价已经量过**(本仓库 `docs/research/v2-builtin-tools.md` §5):宿主 45 个 `browser_*` 工具共享
69
+ `options.permission:"browser"` + `codemode:true`,而 `[L]` 那次活体观测里
70
+ **`browser_*` 调用完全没有触发 `permission.evaluate`**。所以一旦把联网交给原生工具,我们的
71
+ 域名门禁 / 逐次同意 / 子资源策略 / 租约与空闲回收都管不到它——能管的只剩请求层可见性
72
+ (我们已经会在 `tm_browser` 被拒时摘掉整个 `browser_*` 目录)与结果层 JIT 卸载
73
+ (`browser_*` 走前缀匹配,已在治理名单里)。
74
+
75
+ ## 三条可走的路(等拍板)
76
+
77
+ | 方案 | 侧边栏 | 治理 | 说明 |
78
+ |---|---|---|---|
79
+ | A 交给原生 `browser_*` | 有(真面板、活的) | **只剩请求层 + JIT**;逐次同意/域名门禁失效 | 最贴合"尽可能用原生"。v2 无确认框,本来也没有逐次同意可谈 |
80
+ | B 保持现状 | 无(独立有头 Edge 窗口) | 全部保留 | 用户看得到窗口,只是不在应用里 |
81
+ | C 混合(推荐) | 有(模型优先用原生;截图另走 `browser_preview`) | 原生走 A,需要门禁的动作走 tm_browser | 提示词里写死分工,两者都在面板里可见;`browser_preview` 露帧这一条**尚未活体验证**(需要用户在桌面端跑) |
82
+
83
+ ## 还没证明的
84
+
85
+ - `browser_preview {path}` 是否真能把**我们 run store 里的 PNG** 渲染进面板(路径限制写的是
86
+ "server-local、相对 workspace 或绝对",我们的 payload 正好是服务端本地文件——但没在 GUI 里看过)。
87
+ - 桌面端会不会把插件会话的 `browser_*` 调用与 `sessionID` 绑到同一个面板(面板按
88
+ `serverKey+sessionID` 存标签页,理论上应该跟会话走)。
89
+ - 原生 `browser_*` 在**有**桌面连接时是否会触发 `permission.evaluate`(`[L]` 那次是 standalone)。
@@ -0,0 +1,132 @@
1
+ # OpenCode 2.0.18 — the host's own HTTP API, and what it changes for a plugin
2
+
3
+ **Status: mapped, not called.** Every route below was read out of the installed host's
4
+ embedded JS (the SDK client factory: `method:"GET", path:"/api/…"` entries, both literal and
5
+ template-literal), cross-checked against the official v2 API page for the plugin endpoints. **No
6
+ request has been made to any of them** — so "exists in the client" is not the same claim as
7
+ "works from a plugin process", and nothing here is a verified capability.
8
+
9
+ **Why it matters.** The v2 plugin context is thin: no SDK client, no `session.children`, no
10
+ message domain, no dialog. Several conclusions this repo recorded as host limitations are
11
+ actually *ctx* limitations — the server behind the ctx has a much larger surface. This page
12
+ separates the two so a future change does not re-accept a limitation that is really a missing
13
+ transport.
14
+
15
+ ## 1. Discovery and authentication
16
+
17
+ `[B]`+`[D]` `GET /api/info` returns "the server identity, connection URLs, paths, and
18
+ readiness status". The CLI reaches a server through a helper that yields `{url, password}`, and
19
+ the transport is HTTP Basic with a fixed username:
20
+
21
+ ```js
22
+ { type: "basic", username: "opencode", password: e.password }
23
+ await fetch(new URL("/api/info", e.url), { headers: X(i), signal: AbortSignal.timeout(r) })
24
+ ```
25
+
26
+ `[B]` The password is **not** per-process random-only: `<global-config>/service.json` holds a
27
+ single `password` string (43 chars on this machine). The port is dynamic (`serve --stdio
28
+ --port 0`), which is why `/api/info` exists.
29
+
30
+ `[U]` Whether a plugin can obtain `{url, password}` from its own context instead of reading
31
+ that file is the open question, and it is the whole cost of every item below: **reading the
32
+ user's credential file to call back into the host is a new trust boundary**, and a plugin that
33
+ does it silently would be doing exactly what goal #6 forbids. Nothing in this repo does it
34
+ today, and `src/host/v2-session-client.ts` says so where the temptation is documented.
35
+
36
+ ## 2. The routes that touch our recorded conclusions
37
+
38
+ | route family (`[B]`) | what it offers | which claim it reopens |
39
+ |---|---|---|
40
+ | `GET /api/plugin`, `POST /api/plugin/check`, `POST /api/plugin/update` | the enabled server plugins **with live status**, plus update checking | the install-verification story: this is a cleaner arrival check than grepping `msg="loading plugin"` (which is what `docs/installation-v2.md` currently tells an agent to do) |
41
+ | `GET /api/session/{id}/message`, `GET /api/session/{id}/message/{messageID}`, `GET /api/session/{id}/context` | read a session's messages — including a CHILD session | `tm_join`'s "the body arrives by injection, we cannot read it" limit. The in-process `ctx.session.context` is tried first (see §4); the API is the fallback with a price |
42
+ | `GET /api/permission/request`, `POST /api/session/{id}/permission`, `GET /api/permission/saved`, `DELETE /api/permission/saved/{id}` | the permission queue, answering it, and the saved-rule store | **"a plugin cannot raise a dialog on v2"** — that was measured against the ctx (`options.permission` on our own tool produced no evaluation). The server clearly has a dialog surface; whether a plugin may drive it is unmeasured, and driving it *for* the user would be self-allowing, which this project never does |
43
+ | `GET /api/pty`, `POST /api/pty`, `GET /api/pty/{id}/output?`, `GET /api/experimental/persistent-pty/{id}`, `.../snapshot`, `.../connect-token`, `POST /api/experimental/persistent-pty/{id}/connect-token` | terminal sessions with **snapshot/read**, i.e. exactly the capture capability `tm_pty` lacked | task #21 ("v2 has no `client.pty` → retire `tm_pty`") was decided on the ctx surface. The host has a persistent-pty API. Retirement still looks right (a plugin spawning terminals the user cannot see is its own problem), but the reason is now "no in-process seam + trust boundary", not "the host has no such thing" |
44
+ | `GET /api/fs/read/{path}`, `GET /api/fs/list`, `GET /api/fs/find`, `POST /api/experimental/fs/write` | file access through the host's own permission layer | reinforces the 1.7.x decision to retire `tm_read`/`tm_grep`/`tm_bash` in favour of the native ladder — the native path is where the host's `external_directory` dialog lives |
45
+ | `POST /api/websearch`, `GET /api/websearch/provider` | server-side search | all four native providers need a key/`/connect`, so `tm_search`'s zero-key front stays (unchanged conclusion, now with the reason visible) |
46
+ | `POST /api/session/{id}/wait`, `POST /api/experimental/session/{id}/wait`, `GET /api/experimental/session/stats`, `GET /api/experimental/session/{id}/export`, `POST /api/session/{id}/synthetic`, `.../interrupt`, `.../compact`, `.../fork` | waiting on and injecting into sessions | the shape of what a *session-owning* API looks like — useful for judging whether `tm_join`'s bounded wait is fighting the host or duplicating it |
47
+ | `GET /api/event` | the SSE feed | `ctx.event.subscribe()` is this stream, in-process. No reason to prefer HTTP |
48
+
49
+ ## 3. The question the user actually asked: is there a todo list?
50
+
51
+ **No.** `[B]` Across **136 distinct `METHOD /path` pairs** in the scanned window (55 literal +
52
+ 81 template-literal paths, deduplicated — a lower bound, since the scan covered 2 MB of the
53
+ embedded JS rather than the whole client factory) there is no `todo`, no `plan`, no `task`
54
+ resource; the only `message` routes are `GET /api/session/{id}/message` and
55
+ `GET /api/session/{id}/message/{messageID}`. `[D]` The official API page lists none either. The
56
+ v1 REST endpoint `GET /session/{id}/todo` that `tm_join`'s goal tripwire was written against
57
+ does not exist on 2.x, which is the same fact as "the host has no `todowrite` tool" seen from
58
+ the other side. So `tm_ledger` is not a workaround we settled for: it is the only ledger there
59
+ is. What *is* available for the same purpose is `GET /api/session/{id}/context` (in-process)
60
+ and `/message` (HTTP) — reading what the model was told, rather than a list the model
61
+ maintains.
62
+
63
+ ## 4. What was verified instead, in-process
64
+
65
+ `[B]` The host's own call sites give the plugin-facing argument shape, and that fixed a bridge
66
+ that had never worked:
67
+
68
+ ```
69
+ te = (schema, fn) => (input) => decode(schema, input ?? {}) → fn(decoded)
70
+ await e.session.get({ sessionID: n })
71
+ await e.message.list({ sessionID: n, limit: 200, cursor: r })
72
+ ```
73
+
74
+ `ctx.session.get` therefore takes a **single flat object `{sessionID}`** — not v1's
75
+ `{path:{id}}`, and not an array. `src/host/v2-session-client.ts` had both wrong, which is why
76
+ every lookup failed silently for a release cycle.
77
+
78
+ `[L]` **The full ctx domain set, measured 2026-09-26 with zero model tokens** (a plugin that
79
+ only inspects its own context, booted under `--standalone --model nope/nope`; every method
80
+ called with `{}` so the host's own decode error names the required key):
81
+
82
+ | domain | what the host gives a plugin |
83
+ |---|---|
84
+ | `session` | `hook create get switchAgent switchModel prompt generate command synthetic interrupt update move wait context` |
85
+ | `permission` | `hook list get reply` — all of `list/get/reply` decode as `{sessionID}` |
86
+ | `experimental` | `terminal.read` (decodes as `{sessionID}`; a terminal-id spelling has not been found) |
87
+ | `storage` | `get set remove scan` |
88
+ | `agent` / `model` / `command` / `mcp` / `worktree` | `list/get/transform/reload` families |
89
+ | `event` | `subscribe` (an async iterable, already used by `v2-events.ts`) |
90
+ | `rpc` | a single function of arity 1 — its calling convention has NOT been read out |
91
+
92
+ `[L]` **`ctx.session.context({sessionID})` answers with an ARRAY of flat items**, measured
93
+ against a real background child from a live round:
94
+
95
+ ```
96
+ array(5) < { id: string, time: { created: number }, text: string, type: string } // type "user" …
97
+ ```
98
+
99
+ That is NOT v1's `[{info:{role},parts:[…]}]`, and it is the reason a child's report was lost for
100
+ a release: `v2-session-client.ts` already called `context`, then handed the array to
101
+ `lastAssistantMessage`, which tolerates an unknown shape by returning no text. The seam
102
+ ANSWERED and the body was discarded, while `tm_join` told the lead 正文不经本工具 — our decode
103
+ miss reported as the host's limit. The flat shape is now normalised at the seam
104
+ (`normaliseContextMessages`), and no `time.completed` is invented there, because that field *is*
105
+ the settle verdict.
106
+
107
+ ## 5. Deliberate non-goals
108
+
109
+ `[U]` No HTTP call is made by this plugin today. The reasons are recorded rather than implied:
110
+ the credential read is a trust boundary the user has not been asked about; a plugin answering
111
+ `/api/permission/request` would be self-allowing; and a plugin that can `POST
112
+ /api/experimental/fs/write` bypasses the very `session.hook("context")` trim that keeps a
113
+ denied role from being offered a tool. If any of these is ever pursued, it goes through the
114
+ same gate as everything else here: Team-scoped, counted, and visible in `tm_stats`.
115
+
116
+ ## 6. The #32 decision, and what closed it
117
+
118
+ The user authorised the HTTP API on one condition — 保证不会被泄露. That condition is the reason
119
+ §4's probe happened first, and the probe made the condition moot:
120
+
121
+ - **子会话正文**: `ctx.session.context` in process (§4). No credential exists to leak.
122
+ - **权限队列**: `ctx.permission.list({sessionID})` answers in process — verified as a seam,
123
+ NOT yet used. Reading it to tell the user "N dialogs are waiting" is legitimate; calling
124
+ `reply` is not, because that is self-allowing, and `reply` stays uncalled.
125
+ - **pty 捕获**: `ctx.experimental.terminal.read({sessionID})` exists but its second argument
126
+ has not been found, so `tm_pty`'s retirement decision (§21) is unchanged for now — the
127
+ reason is updated from "the host has no such thing" to "the plugin seam is unread".
128
+
129
+ What the scan still settles on its own: there is **no todo/plan/task resource anywhere**, so
130
+ `tm_ledger` is not a compromise. And `~/.config/opencode/service.json` has never been opened by
131
+ this plugin, which `test-v2-adapter.mjs` group 12 asserts against the shipped source.
132
+
@@ -0,0 +1,187 @@
1
+ # OpenCode 2.x — how a background sub-agent is dispatched, acknowledged, and answered
2
+
3
+ **Scope:** one exported desktop session (`e-f.json`, provided by the user on 2026-09-26),
4
+ read as evidence. Nothing in the host was modified and nothing here was inferred from the
5
+ v1 behaviour: every claim below is either quoted from that export or measured against it.
6
+ The session's workspace path is deliberately not reproduced.
7
+
8
+ **Why this page exists.** On v2 a plugin cannot create an agent and cannot create a
9
+ session it is allowed to read, so delegation goes through the host's own `subagent` tool.
10
+ `tm_join` — the collect side — therefore needed to know what that tool gives a plugin at
11
+ each of three moments: when the call is made, when the host acknowledges it, and when the
12
+ child's answer comes back. Getting the second one wrong is what made the feature look
13
+ impossible: the ack is small and the answer is not a tool result at all.
14
+
15
+ ## 1. The dispatch, as the plugin sees it
16
+
17
+ `[L]`+`[B]` `execute.before` carries the model's own arguments under **`input`** — the host's
18
+ trigger is `{tool, sessionID, agent, messageID, id, input}`, where `sessionID`/`agent` are the
19
+ CALLER's and `input` is the argument object:
20
+
21
+ ```
22
+ { tool: "subagent",
23
+ sessionID: "ses_…", // the lead's own session
24
+ agent: "team",
25
+ input: { agent: "architect", background: true,
26
+ description: "注入形状取证", prompt: "…" } }
27
+ ```
28
+
29
+ `description` is a task name, `prompt` is the work. Only `description` is worth keeping
30
+ and only for the lead's own table: `prompt` is free text and never enters the trajectory
31
+ (the R6 rule binds a diagnostic as much as a guard).
32
+
33
+ ## 2. The ack — this is the seam that makes claiming a child possible
34
+
35
+ `[L]` The synchronous result of a background dispatch is small (98 tokens measured on an
36
+ earlier host build) and carries the child's identity twice:
37
+
38
+ ```
39
+ content[0].text = "The subagent is working in the background (sessionID: ses_f2417df8…).
40
+ You will be notified automatically when it finishes.
41
+ DO NOT sleep, poll for progress, ask the subagent for status, or
42
+ duplicate this subagent's work; …"
43
+ metadata = { sessionID: "ses_f2417df8…", status: "running", truncated: false }
44
+ ```
45
+
46
+ Two consequences that changed the design:
47
+
48
+ 1. **`execute.after` on tool `subagent` yields the child session id.** No `session.get`,
49
+ no `session.children`, no guessing: the host hands it over. Our `ctx.session.get`
50
+ bridge never resolved a shape on a live host, so a tree walk was not an option, and
51
+ this made the question moot.
52
+ 2. **`status:"running"` is the host telling us the work is open.** A synchronous child is
53
+ NOT distinguished by a missing id — read out of 2.0.18, the `subagent` result always
54
+ carries `metadata:{sessionID,status}`, and the completed case wraps the body itself:
55
+
56
+ ```js
57
+ content: G.status==="completed"
58
+ ? `<subagent sessionID="${G.sessionID}" state="completed">.${G.output}.</subagent>`
59
+ : G.output,
60
+ metadata:{ sessionID: G.sessionID, status: G.status }
61
+ ```
62
+
63
+ So what makes a synchronous child un-collectable is `status:"completed"` (the reply is
64
+ already in hand), not an absent id. Counting it as `settledAtOnce` rather than
65
+ `noChildId` is what keeps the two cases from being conflated in `tm_stats`.
66
+ 3. **The `execute.before` field is `input`, not `args`.** Also from the host's own trigger:
67
+
68
+ ```js
69
+ e.trigger("tool","execute.before",{tool,sessionID,agent,messageID,id,input:m})
70
+ ```
71
+
72
+ Reading `event.args` was the first live round's failure: the stash stayed empty, every
73
+ ack arrived unpaired, and each child was registered under a generic label instead of the
74
+ role and task it was dispatched for — `tm_stats` said "登记 1 个(1 个没配到派发行)" and
75
+ the unit test that now pins the paired label would have been red. A fake ctx that mirrors
76
+ the guessed shape passes a bug like this; the host's code does not.
77
+
78
+ The id is read from `metadata` first and from the ack sentence second, because a field
79
+ shape is exactly what a host upgrade changes.
80
+
81
+ ## 3. The answer is a message, not a tool result
82
+
83
+ `[L]` When the child finishes, the parent receives a synthetic message. From the export,
84
+ the message's own fields:
85
+
86
+ ```
87
+ type: "synthetic"
88
+ description: "注入形状取证"
89
+ metadata: { source: "subagent", childID: "ses_f2417df8…", agent: "architect", state: "completed" }
90
+ text: <subagent sessionID="ses_f2417df8…" state="completed" description="注入形状取证">
91
+ PROBE-OK
92
+ </subagent>
93
+ ```
94
+
95
+ Note the key names differ between the two moments: the ack says `sessionID`, the
96
+ injection says `childID` (and adds `source`, `agent`, `state`). A matcher that assumes one
97
+ spelling for both is the bug class this page exists to record — `parseHostEnvelope` in
98
+ `src/task-offload.ts` accepts both the v1 `<task id=` and the v2 `<subagent sessionID=`
99
+ wrapper for exactly this reason, and rewrites only the body, because the session id inside
100
+ the envelope is the pointer the lead uses to fetch the reply back.
101
+
102
+ What the plugin does NOT get on v2: a hook that hands us this message BEFORE the host
103
+ persists it. v1 was given `chat.message` at exactly that moment; the v2 plugin surface has no
104
+ equivalent. What we DO get is the message one step later, inside the parent's assembled
105
+ history at `session.hook("context")` — measured, because `applyV2CompletionWatch` settles the
106
+ child from it (§7). The distinction is the whole difference between the two claims we can
107
+ make: we can OBSERVE that the host wrote a completion (so `tm_join` may say 已完成 and name
108
+ where it read that), and we cannot REWRITE the body the model is about to receive. `tm_join`
109
+ therefore reports the child's state and provenance and says where the text is, rather than
110
+ printing a reply it never read.
111
+
112
+ ## 4. Ordering, measured, and the one inference it licenses
113
+
114
+ `[L]` From the export's timestamps (same session, milliseconds):
115
+
116
+ | moment | ts |
117
+ |---|---|
118
+ | child injected into the parent | …824730 |
119
+ | parent's next assistant message streamed | …824763 → …829026 |
120
+ | parent session `idle` | …829038 |
121
+
122
+ So the injection precedes the parent's idle by construction — the parent cannot go idle
123
+ with an undelivered notification. That single ordering fact is what licenses the fallback
124
+ in `src/tm/dispatch.ts`: a host-dispatched child still marked running when ITS PARENT goes
125
+ idle is settled as `settleSource: "parent-idle"` and printed as 推定已结算, with the reason
126
+ in the same line. The child's own `session.idle` on the event feed is the measured path for a
127
+ child THIS process dispatched (`settleSource: "event"`) — for a host `subagent` child it does
128
+ not arrive at all, which §7 records; the two are never printed the same way, because one is an
129
+ observation and the other is an inference from a host ordering we happened to see once.
130
+
131
+ ## 6. Live verification, 2026-09-26 (host 2.0.18, `lxns-uni/zai-org/GLM-5.3#max`)
132
+
133
+ Run against a sandbox config dir (`OPENCODE_CONFIG_DIR`, plugin loaded from
134
+ `vendor/team-mode`) with the user's real profile supplying credentials, so nothing was
135
+ written to the live global config. Same three-step prompt twice, before and after the
136
+ `input` fix:
137
+
138
+ | | acks seen | registered | unpaired | audit line's `agent` | the row `tm_join` printed |
139
+ |---|---|---|---|---|---|
140
+ | before | 1 | 1 | **1** | `subagent` (generic) | child id + (宿主 subagent 派发), role lost |
141
+ | after | 1 | 1 | **0** | `architect` | `ses_f237aec68ffe… · architect · "认领复验2" · 运行中 22s ·(宿主 subagent 派发)` |
142
+
143
+ So the claiming and the pairing are both observed on a live host, and the settle path was
144
+ observed in its honest state: the child was still `运行中` when the snapshot ran (its own
145
+ `session.idle` had not arrived and the parent had not gone idle yet), which is the row
146
+ saying what it knows rather than guessing.
147
+
148
+ **One observability caveat, measured in the same runs:** a single CLI invocation boots more
149
+ than one server process, each writing its own `v2-surface` row with ITS OWN counters. The
150
+ model pasted `确认 0 次 → 登记 0 个` from the sibling process while the process that
151
+ registered the child reported `1 → 1`. `tm_stats` therefore prints the run id beside those
152
+ counters — a bare 0 in a multi-process host is otherwise readable as "nothing was
153
+ dispatched", which is exactly the silence this feature removes.
154
+
155
+
156
+ ## 7. What is settled, and what is still open
157
+
158
+ `[L]` **The host does NOT forward a child session's `session.idle` to a plugin subscriber.**
159
+ Measured on 2.0.18 (round 5, 2026-09-26): a background child finished, its
160
+ `<subagent … state="completed">` injection reached the parent with the full report, and the
161
+ plugin feed produced neither an `idle` nor an `idle_presumed` line for that child — `tm_join`
162
+ kept showing it 运行中. So neither of the two paths this module originally offered works for a
163
+ host child mid-turn: the event never arrives, and the parent-idle presumption cannot fire
164
+ while the parent is still working. That is what `applyV2CompletionWatch` exists to fix: the
165
+ envelope the host itself writes, read out of the parent's assembled message history.
166
+
167
+ `[L]` **The seam that carries it is `session.hook("context")`, and settle-by-injection is
168
+ live-verified.** Getting there took two wrong answers, and both are counted so a wrong seam is
169
+ visible rather than silent:
170
+
171
+ | round | what was watched | what the host did |
172
+ |---|---|---|
173
+ | 6 | `session.prompt` + `session.model.request` | `prompt` fired **0** times; `model.request` fired **6** and carried no message list at all (the history is assembled separately from that payload) |
174
+ | 8 | `session.context` (+ `prompt` kept, counted, still 0) | fired 6, `completion_settled=1`, audit line `{"event":"idle_injection","child":"ses_f23386a7…","agent":"researcher","ms":53102}` |
175
+
176
+ The line is the third source of truth in `src/tm/dispatch.ts` (`settleSource: "injection"`,
177
+ printed as 已完成 with its provenance), and the `ms` is the child's own elapsed time, not our
178
+ wait. `completion_skipped` grows on every later round (4, then 5) because the scan is skipped
179
+ while nothing is open — which is the point of counting the skip: "did not look" and "looked and
180
+ saw nothing" stay distinguishable, and a hook that exists but is the WRONG one is exactly the
181
+ failure round 6 nearly hid.
182
+
183
+ `[U]` The full `status` vocabulary: 2.0.18's own code applies the `<subagent …>` wrapper only
184
+ when `G.status === "completed"`, so anything else is treated as open by
185
+ `hostChildIsOpen()` — whether `error` reaches a plugin-visible ack has not been seen.
186
+ `[U]` Whether a child of a child is ever dispatched: the config gives nesting depth one, and
187
+ the T3 rule says only the lead dispatches, so this page describes one level.