@te-river/opencode-team-mode 1.7.1 → 1.7.3

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 (167) hide show
  1. package/README.md +1009 -849
  2. package/README.zh-CN.md +906 -756
  3. package/dist/host/v2-browser-gate.d.ts +4 -1
  4. package/dist/host/v2-browser-gate.d.ts.map +1 -1
  5. package/dist/host/v2-browser-gate.js +1 -1
  6. package/dist/host/v2-browser-gate.js.map +1 -1
  7. package/dist/host/v2-capabilities.js +1 -1
  8. package/dist/host/v2-capabilities.js.map +1 -1
  9. package/dist/host/v2-guard.d.ts +3 -1
  10. package/dist/host/v2-guard.d.ts.map +1 -1
  11. package/dist/host/v2-guard.js +2 -2
  12. package/dist/host/v2-guard.js.map +1 -1
  13. package/dist/host/v2-offload.d.ts +12 -2
  14. package/dist/host/v2-offload.d.ts.map +1 -1
  15. package/dist/host/v2-offload.js +9 -9
  16. package/dist/host/v2-offload.js.map +1 -1
  17. package/dist/host/v2-probe-chain.d.ts +5 -1
  18. package/dist/host/v2-probe-chain.d.ts.map +1 -1
  19. package/dist/host/v2-probe-chain.js +4 -6
  20. package/dist/host/v2-probe-chain.js.map +1 -1
  21. package/dist/host/v2-probe.d.ts.map +1 -1
  22. package/dist/host/v2-probe.js +2 -1
  23. package/dist/host/v2-probe.js.map +1 -1
  24. package/dist/host/v2-prune.d.ts +5 -1
  25. package/dist/host/v2-prune.d.ts.map +1 -1
  26. package/dist/host/v2-prune.js +8 -14
  27. package/dist/host/v2-prune.js.map +1 -1
  28. package/dist/host/v2-retry.d.ts +9 -2
  29. package/dist/host/v2-retry.d.ts.map +1 -1
  30. package/dist/host/v2-retry.js +13 -15
  31. package/dist/host/v2-retry.js.map +1 -1
  32. package/dist/host/v2-session.d.ts +6 -2
  33. package/dist/host/v2-session.d.ts.map +1 -1
  34. package/dist/host/v2-session.js +7 -13
  35. package/dist/host/v2-session.js.map +1 -1
  36. package/dist/host/v2.d.ts +7 -2
  37. package/dist/host/v2.d.ts.map +1 -1
  38. package/dist/host/v2.js +38 -35
  39. package/dist/host/v2.js.map +1 -1
  40. package/dist/prompts/lead.d.ts +1 -1
  41. package/dist/prompts/lead.d.ts.map +1 -1
  42. package/dist/prompts/lead.js +23 -7
  43. package/dist/prompts/lead.js.map +1 -1
  44. package/dist/prompts/specialists.d.ts +1 -1
  45. package/dist/prompts/specialists.d.ts.map +1 -1
  46. package/dist/prompts/specialists.js +1 -1
  47. package/dist/tm/args-schema.d.ts +1 -1
  48. package/dist/tm/args-schema.js +6 -6
  49. package/dist/tm/args-schema.js.map +1 -1
  50. package/dist/tm/board.js +2 -2
  51. package/dist/tm/board.js.map +1 -1
  52. package/dist/tm/config-files.d.ts +41 -17
  53. package/dist/tm/config-files.d.ts.map +1 -1
  54. package/dist/tm/config-files.js +68 -73
  55. package/dist/tm/config-files.js.map +1 -1
  56. package/dist/tm/config-layers.d.ts +65 -33
  57. package/dist/tm/config-layers.d.ts.map +1 -1
  58. package/dist/tm/config-layers.js +165 -81
  59. package/dist/tm/config-layers.js.map +1 -1
  60. package/dist/tm/config-template.d.ts +32 -0
  61. package/dist/tm/config-template.d.ts.map +1 -0
  62. package/dist/tm/config-template.js +74 -0
  63. package/dist/tm/config-template.js.map +1 -0
  64. package/dist/tm/config.d.ts +115 -140
  65. package/dist/tm/config.d.ts.map +1 -1
  66. package/dist/tm/config.js +100 -200
  67. package/dist/tm/config.js.map +1 -1
  68. package/dist/tm/index.d.ts +11 -3
  69. package/dist/tm/index.d.ts.map +1 -1
  70. package/dist/tm/index.js +46 -16
  71. package/dist/tm/index.js.map +1 -1
  72. package/dist/tm/ledger.d.ts +7 -3
  73. package/dist/tm/ledger.d.ts.map +1 -1
  74. package/dist/tm/ledger.js +5 -4
  75. package/dist/tm/ledger.js.map +1 -1
  76. package/dist/tm/memory.js +3 -3
  77. package/dist/tm/pipelines.js +1 -1
  78. package/dist/tm/search.js +6 -6
  79. package/dist/tm/search.js.map +1 -1
  80. package/dist/tm/stats.js +2 -2
  81. package/dist/tm/stats.js.map +1 -1
  82. package/dist/tm/tools.js +2 -2
  83. package/dist/tm/webfetch.d.ts +5 -2
  84. package/dist/tm/webfetch.d.ts.map +1 -1
  85. package/dist/tm/webfetch.js +24 -18
  86. package/dist/tm/webfetch.js.map +1 -1
  87. package/docs/installation-v2.md +61 -38
  88. package/docs/installation.md +4 -4
  89. package/docs/v2-port-notes.md +31 -0
  90. package/package.json +1 -1
  91. package/scripts/gen-v2-config.mjs +40 -4
  92. package/dist/approval-gate.d.ts +0 -205
  93. package/dist/approval-gate.d.ts.map +0 -1
  94. package/dist/approval-gate.js +0 -627
  95. package/dist/approval-gate.js.map +0 -1
  96. package/dist/host/v1.d.ts +0 -14
  97. package/dist/host/v1.d.ts.map +0 -1
  98. package/dist/host/v1.js +0 -340
  99. package/dist/host/v1.js.map +0 -1
  100. package/dist/host/v2-client.d.ts +0 -53
  101. package/dist/host/v2-client.d.ts.map +0 -1
  102. package/dist/host/v2-client.js +0 -193
  103. package/dist/host/v2-client.js.map +0 -1
  104. package/dist/host/v2-transcript.d.ts +0 -73
  105. package/dist/host/v2-transcript.d.ts.map +0 -1
  106. package/dist/host/v2-transcript.js +0 -151
  107. package/dist/host/v2-transcript.js.map +0 -1
  108. package/dist/host-hooks.d.ts +0 -126
  109. package/dist/host-hooks.d.ts.map +0 -1
  110. package/dist/host-hooks.js +0 -284
  111. package/dist/host-hooks.js.map +0 -1
  112. package/dist/tm/bash-timeout.d.ts +0 -72
  113. package/dist/tm/bash-timeout.d.ts.map +0 -1
  114. package/dist/tm/bash-timeout.js +0 -100
  115. package/dist/tm/bash-timeout.js.map +0 -1
  116. package/dist/tm/browser.d.ts +0 -642
  117. package/dist/tm/browser.d.ts.map +0 -1
  118. package/dist/tm/browser.js +0 -2836
  119. package/dist/tm/browser.js.map +0 -1
  120. package/dist/tm/ptc/budgets.d.ts +0 -59
  121. package/dist/tm/ptc/budgets.d.ts.map +0 -1
  122. package/dist/tm/ptc/budgets.js +0 -138
  123. package/dist/tm/ptc/budgets.js.map +0 -1
  124. package/dist/tm/ptc/contract.d.ts +0 -171
  125. package/dist/tm/ptc/contract.d.ts.map +0 -1
  126. package/dist/tm/ptc/contract.js +0 -85
  127. package/dist/tm/ptc/contract.js.map +0 -1
  128. package/dist/tm/ptc/driver.d.ts +0 -40
  129. package/dist/tm/ptc/driver.d.ts.map +0 -1
  130. package/dist/tm/ptc/driver.js +0 -213
  131. package/dist/tm/ptc/driver.js.map +0 -1
  132. package/dist/tm/ptc/engines.d.ts +0 -43
  133. package/dist/tm/ptc/engines.d.ts.map +0 -1
  134. package/dist/tm/ptc/engines.js +0 -454
  135. package/dist/tm/ptc/engines.js.map +0 -1
  136. package/dist/tm/ptc/gate.d.ts +0 -28
  137. package/dist/tm/ptc/gate.d.ts.map +0 -1
  138. package/dist/tm/ptc/gate.js +0 -130
  139. package/dist/tm/ptc/gate.js.map +0 -1
  140. package/dist/tm/ptc/index.d.ts +0 -19
  141. package/dist/tm/ptc/index.d.ts.map +0 -1
  142. package/dist/tm/ptc/index.js +0 -15
  143. package/dist/tm/ptc/index.js.map +0 -1
  144. package/dist/tm/ptc/pscan.d.ts +0 -30
  145. package/dist/tm/ptc/pscan.d.ts.map +0 -1
  146. package/dist/tm/ptc/pscan.js +0 -341
  147. package/dist/tm/ptc/pscan.js.map +0 -1
  148. package/dist/tm/ptc/summary.d.ts +0 -17
  149. package/dist/tm/ptc/summary.d.ts.map +0 -1
  150. package/dist/tm/ptc/summary.js +0 -134
  151. package/dist/tm/ptc/summary.js.map +0 -1
  152. package/dist/tm/ptc/tool.d.ts +0 -66
  153. package/dist/tm/ptc/tool.d.ts.map +0 -1
  154. package/dist/tm/ptc/tool.js +0 -231
  155. package/dist/tm/ptc/tool.js.map +0 -1
  156. package/dist/tm/ptc.d.ts +0 -298
  157. package/dist/tm/ptc.d.ts.map +0 -1
  158. package/dist/tm/ptc.js +0 -865
  159. package/dist/tm/ptc.js.map +0 -1
  160. package/dist/tm/pty.d.ts +0 -70
  161. package/dist/tm/pty.d.ts.map +0 -1
  162. package/dist/tm/pty.js +0 -245
  163. package/dist/tm/pty.js.map +0 -1
  164. package/dist/tool-coerce.d.ts +0 -24
  165. package/dist/tool-coerce.d.ts.map +0 -1
  166. package/dist/tool-coerce.js +0 -63
  167. package/dist/tool-coerce.js.map +0 -1
@@ -56,7 +56,7 @@ implied:
56
56
  | Sub-agent settlement | the host `event` hook | `ctx.event.subscribe()` — zero-dependency async iterable, filtered by event-type name (`src/host/v2-events.ts`). Without it `tm_join` cannot tell a settled child from a running one; `tm_stats` reports what the feed forwarded and which type names went unrecognised (names only, never payloads) |
57
57
  | The task ledger | the host's `todowrite` | **`tm_ledger`**, stored in the host's `ctx.storage` (v2 has no `todowrite`) |
58
58
  | Asking the user | the host's official per-request dialog | **a plugin cannot open a dialog on v2.** Governed calls that would have asked instead **fail closed** with a refusal that says why. The dialogs you do see are raised by the host itself (permission rules, out-of-project access) |
59
- | Web access | a 22-host allowlist + the confirm dialog | **the domain gate is off** (`TM_WEBFETCH_ALLOWED_DOMAINS` defaults to `"*"`): a 2.x plugin cannot raise a dialog, so "approve to proceed" was an instruction to wait for a window that never opens. Address classes are still policed — cloud-metadata / link-local / reserved ranges are refused under every setting, and private space (RFC1918, CGNAT, ULA, IPv6 link-local, `.localhost`) is refused too, but the refusal now names both exits instead of promising a click: `TM_PRIVATE_SPACE=allow` (opens the class) or that one hostname in `TM_WEBFETCH_ALLOWED_DOMAINS` (opens exactly it). **Loopback (`127/8`, `::1`, `localhost`) is served by default** — it reaches only a service the user started on their own machine, and a gate whose only exit is a dialog the plugin cannot raise is a wall, not a gate |
59
+ | Web access | a 22-host allowlist + the confirm dialog | **the domain gate is off** (`webfetchAllowedDomains` defaults to `["*"]` on v2): a 2.x plugin cannot raise a dialog, so "approve to proceed" was an instruction to wait for a window that never opens. Address classes are still policed — cloud-metadata / link-local / reserved ranges are refused under every setting, and private space (RFC1918, CGNAT, ULA, IPv6 link-local, `.localhost`) is refused too, but the refusal now names both exits instead of promising a click: `"privateSpace": "allow"` in the global `team-mode.jsonc` (opens the class) or that one hostname in `webfetchAllowedDomains` (opens exactly it). **Loopback (`127/8`, `::1`, `localhost`) is served by default** — it reaches only a service the user started on their own machine, and a gate whose only exit is a dialog the plugin cannot raise is a wall, not a gate |
60
60
  | Sub-agents | needs `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true` | native; the plugin forces `background: true` on every `subagent` call, so **do not set that env var for 2.x** (it is a v1 workaround and changes nothing here) |
61
61
  | Effect on your other modes | the plugin's hooks are global | **none.** Every hook checks the session's owner first, so `build`, `plan` and any agent you installed yourself stay exactly as a fresh OpenCode leaves them — no tool is deleted from their requests, no temperature is set, nothing is offloaded, no permission is tightened, no dispatch is converted to background |
62
62
 
@@ -246,29 +246,40 @@ env-file line). It is zero-token by default; `--turns` adds the checks that need
246
246
 
247
247
  ### Layered configuration (`team-mode.jsonc`)
248
248
 
249
- Settings can live in a file instead of the environment. Two layers, mirroring the host's own config:
249
+ `team-mode.jsonc` is the **only** configuration source — every `TM_*` configuration environment
250
+ variable is gone. Two layers, mirroring the host's own config:
250
251
 
251
252
  | layer | file |
252
253
  |---|---|
253
- | global | `~/.config/opencode/team-mode.jsonc` |
254
+ | global | `~/.config/opencode/team-mode.jsonc` (honours `OPENCODE_CONFIG_DIR`) |
254
255
  | project | `<project>/team-mode.jsonc`, or `<project>/.opencode/team-mode.jsonc` (the `.opencode` one wins) |
255
256
 
256
- Precedence is `env < global < project`, and **a file beats an environment variable** — a value you
257
- put in a file wins over the matching `TM_*`. To get the environment back for one run, set
258
- `TM_CONFIG_ENV_ONLY=1`: it is read before any file, and a file cannot switch it off.
259
-
260
- Keys are the `TM_*` names in camelCase (`offloadThreshold`, `searchDefaultEngine`, …). Six keys are
261
- **red lines a file may not change** — `envProtect`, `r6FineAsk`, `privateSpace`,
262
- `webfetchAllowedDomains`, `browserAskEval`, `bashReadonlyAllowed`; a file value for one of them is
263
- ignored and reported. A file that fails to parse is skipped **whole** (half a config is worse than
264
- none), while a single bad key loses only itself. Unknown keys warn at boot and are never applied.
265
- `tm_stats` shows which layer each key came from.
266
-
267
- **R6 (env protection) is armed by default.** A Team role's native `read` of a `.env` is denied
257
+ Precedence is `global < project`. **The plugin writes the global file for you at boot** when it is
258
+ absent: a fully-commented, INERT template — every key appears as a comment (`// "key": <default>,`)
259
+ under its one-line doc, so the file sets nothing until you uncomment a line. It is idempotent and
260
+ never overwrites an existing file. Turn it off with the plugin option `autoCreate: false` (or the
261
+ internal `TM_CONFIG_AUTOCREATE=off`); the boot row records `config_autocreate` as
262
+ `created` / `present` / `off` / `failed`.
263
+
264
+ Keys are camelCase (`offloadThreshold`, `searchDefaultEngine`, …) — the registry in
265
+ `src/tm/config-layers.ts` is the single source of truth (59 keys, each with its type, default and
266
+ doc). Five keys are **red lines a PROJECT file may not change** — `envProtect`, `r6FineAsk`,
267
+ `privateSpace`, `webfetchAllowedDomains`, `bashReadonlyAllowed`; they are settable only in the
268
+ global file, and a project value is ignored and reported. A file that fails to parse is skipped
269
+ **whole** (half a config is worse than none), while a single bad key loses only itself. Unknown keys
270
+ warn at boot and are never applied. `tm_stats` shows which layer each key came from, the red-line
271
+ keys a file tried to set and lost, the unknown keys, the skipped layers, and the auto-create state.
272
+
273
+ **R6 (env protection) is armed by default** (`envProtect` defaults to `"strict"`; the canonical
274
+ values are `strict` / `standard` / `off`). A Team role's native `read` of a `.env` is denied
268
275
  outright, and a shell command that reads the environment goes to the host's own permission prompt.
269
- Turn it off with the plugin option `envProtect: false` or with `TM_ENV_PROTECT=off`; on 2.x the
270
- shell face is an `ask` (the host's dialog — a plugin cannot raise one) while the env-FILE face is
271
- a hard `deny` with no consent path.
276
+ Turn it off with the plugin option `envProtect: false` or with `"envProtect": "off"` in the
277
+ **global** file (it is a red-line key); on 2.x the shell face is an `ask` (the host's dialog — a
278
+ plugin cannot raise one) while the env-FILE face is a hard `deny` with no consent path.
279
+
280
+ The only `TM_*` environment variables left are internal/test switches, not configuration:
281
+ `TM_STORE_RECLAIM` (test override of the `storeReclaim` key), `TM_V2_PROBE` (name-level JSONL
282
+ dump) and `TM_CONFIG_AUTOCREATE` (the auto-create off switch).
272
283
 
273
284
  ### Uninstalling
274
285
 
@@ -287,11 +298,12 @@ a hard `deny` with no consent path.
287
298
  | Roles are not in the picker | Step 2 never ran, or ran against a different `--dir`. `ls ~/.config/opencode/agents` |
288
299
  | The default silently became `build` | `default_agent` names an agent that does not exist — re-run Step 2, then Step 3 |
289
300
  | A hand-written `agents/Team.md` was not updated | By design: the generator refuses files it did not create. Move yours aside, or pass `--force` |
301
+ | The picker shows the lead as `team`, or Team is not the default | The host takes an agent's ID from the FILE NAME. A machine that ever carried the pre-1.7 `team.md` registers the lead as `team`, while the installer writes `default_agent: "Team"` — and the documented behaviour for a default naming an agent it cannot find is a silent fall back to `build`. **Fixed in 1.7.2**: the generator renames a case-only entry it has proven is the same file to the canonical spelling. On 1.7.1 or older a re-run does NOT fix it (a write through `Team.md` keeps the old spelling on Windows) — rename by hand in two steps: `team.md` → `Team.md` |
290
302
  | `/team-plan` etc. run but not as the specialist | 2.x documents `mode: subagent` as "runs only in a child session"; if a command selecting a specialist does not behave, the fix is `mode: "all"` in the role definition (report it — this is the one item in this flow still unverified on a live host) |
291
303
  | `tm_webfetch` on an odd site says it refused **without asking anyone** | Expected on 2.x: a plugin cannot raise a dialog. Use a source that works, or let the host's own permission rule allow it; the address red line (metadata / private) has no consent path at all, on either generation |
292
304
  | `task`/`subagent` seems to block the lead | On 2.x the plugin forces `background: true`; if you also set the old `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS`, unset it — it is a v1 flag and only confuses the picture |
293
- | A tool's output arrives un-offloaded | `TM_NATIVE_OFFLOAD=off` (or a missing `tool.execute.after` seam on an older 2.x build) — `tm_stats` names which |
294
- | Sessions used to compact at 75% on their own; they do not any more | That trigger was **removed in 1.7.1** (it shipped in 1.7.0). The host owns when a conversation is summarized again, exactly as if the plugin were not installed. The three knobs that drove it (`TM_COMPACT_TRIGGER`, `TM_COMPACT_AT_PERCENT`, `TM_COMPACT_MIN_MS`) are gone, and a `team-mode.jsonc` that still names them reports them as unknown keys at boot instead of applying them. What stays is additive and only runs when the HOST compacts: `TM_COMPACTION_CONTEXT` pushes the must-survive list, `TM_COMPACTION_AUTOCONTINUE` controls the host's silent resume |
305
+ | A tool's output arrives un-offloaded | `"nativeOffload": "off"` in the config file (or a missing `tool.execute.after` seam on an older 2.x build) — `tm_stats` names which |
306
+ | Sessions used to compact at 75% on their own; they do not any more | That trigger was **removed in 1.7.1** (it shipped in 1.7.0). The host owns when a conversation is summarized again, exactly as if the plugin were not installed. The three knobs that drove it (`TM_COMPACT_TRIGGER`, `TM_COMPACT_AT_PERCENT`, `TM_COMPACT_MIN_MS`) are gone, and a `team-mode.jsonc` that still names them reports them as unknown keys at boot instead of applying them. What stays is additive and only runs when the HOST compacts: the survival list is pushed on `session.hook("compaction")` (the `compactionContext` key is a v1-only parse-compat entry) |
295
307
  | The plugin behaves oddly — two dialogs, doubled hooks, tools registered twice | It is loaded twice. `opencode.json` and `opencode.jsonc` are merged, and the host's dedupe matches only an identical string, so the same package under two spellings (or in both files) is two personalities. Count the Team entries across BOTH files; `msg="loading plugin"` appearing twice for one id is the proof |
296
308
  | You wrote the entry and the host says nothing at all about it | Two silent causes, both by design: the key is singular `plugin` (2.x reads `plugins`), or a directory entry without a root `index.js`. The loader resolves a directory as `<dir>/index` only and drops it with no message when that fails — absence of an error is not a success signal here |
297
309
  | A background sub-agent's reply is huge | The child is registered, collectable AND readable: `tm_join` lists it (marked 宿主 subagent 派发), reads its report back through `ctx.session.context`, and credits the seam it came from (`正文来源=ctx.session.context`) — so the reply arrives through the same JIT pipeline as everything else (offload + ≤80-token preview + `tm_fetch` handle when it is oversized). What v2 still never gives a plugin is the injected message BEFORE the host persists it, so we can read a report but cannot rewrite the injection — which is why a >~50-line DELIVERABLE still belongs on the blackboard file with its path in the reply |
@@ -323,7 +335,7 @@ a hard `deny` with no consent path.
323
335
  | 子代理结算检测 | 宿主的 `event` 钩子 | `ctx.event.subscribe()`——零依赖的 async iterable,按事件类型名过滤(`src/host/v2-events.ts`)。没有它 `tm_join` 分不清「已结算」和「仍在跑」;`tm_stats` 会给出转发了多少、哪些类型名没认出来(只记名字,绝不记负载) |
324
336
  | 任务清单 | 宿主 `todowrite` | **`tm_ledger`**,存在宿主的 `ctx.storage` 里(v2 不给插件 `todowrite`) |
325
337
  | 征求用户同意 | 宿主官方逐次弹窗 | **插件在 v2 弹不出对话框。** 原本该问的受治理调用一律**直接拒绝**,并说明是"没人可问"而不是"问了被拒"。你看到的弹窗都来自宿主自己(权限规则、越出项目目录) |
326
- | 联网 | 22 个域名白名单 + 确认弹窗 | **域名门禁关掉**(`TM_WEBFETCH_ALLOWED_DOMAINS` 默认 `"*"`):2.x 插件弹不出确认框,"批准后放行"等于让 agent 去等一个永远不会出现的窗口。地址类别照管——元数据 / 链路本地 / 保留网段在任何设置下都拒;私网(RFC1918、CGNAT、ULA、IPv6 链路本地、`.localhost`)也拒,但拒绝语现在给出两条出口而不是承诺点击:`TM_PRIVATE_SPACE=allow`(放开整段),或把那一台主机名写进 `TM_WEBFETCH_ALLOWED_DOMAINS`(只放开它自己)。**回环(`127/8`、`::1`、`localhost`)默认放行**——它只打到用户自己机器上的服务,而一道唯一出口是"插件弹不出来的确认框"的门禁,是墙不是门 |
338
+ | 联网 | 22 个域名白名单 + 确认弹窗 | **域名门禁关掉**(`webfetchAllowedDomains` 在 v2 上默认 `["*"]`):2.x 插件弹不出确认框,"批准后放行"等于让 agent 去等一个永远不会出现的窗口。地址类别照管——元数据 / 链路本地 / 保留网段在任何设置下都拒;私网(RFC1918、CGNAT、ULA、IPv6 链路本地、`.localhost`)也拒,但拒绝语现在给出两条出口而不是承诺点击:在全局 `team-mode.jsonc` 里写 `"privateSpace": "allow"`(放开整段),或把那一台主机名写进 `webfetchAllowedDomains`(只放开它自己)。**回环(`127/8`、`::1`、`localhost`)默认放行**——它只打到用户自己机器上的服务,而一道唯一出口是"插件弹不出来的确认框"的门禁,是墙不是门 |
327
339
  | 非阻塞命令 | `tm_pty`——**已随 v1 支持一起移除** | **完全不注册**——v2 插件上下文没有 pty 域,这个工具只能报自己缺缝。慢步骤请一条一个 `shell` 调用(各自带 `timeout`),并把输出 tee 到日志好回读 |
328
340
  | 子代理 | 需要 `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true` | 原生能力;插件对每次 `subagent` 强制 `background: true`,所以 **2.x 不要去设那个环境变量**(那是 v1 的补丁,在这里什么也不改变,只会让人误判) |
329
341
  | 对你其它模式的影响 | 插件钩子是全局的 | **没有影响。** 每个钩子都先看会话归属,所以 `build`、`plan` 和你自己装的 agent 都保持刚装好 OpenCode 时的样子——不会从它们的请求里删工具、不会设温度、不会卸载结果、不会收紧权限、也不会把它们的派发改成后台 |
@@ -486,24 +498,30 @@ opencode reload
486
498
 
487
499
  ### 分层配置(`team-mode.jsonc`)
488
500
 
489
- 设置可以写在文件里,而不是环境变量里。两层,与宿主自己的配置同构:
501
+ `team-mode.jsonc` 是**唯一**的配置源——所有 `TM_*` 配置环境变量都已移除。两层,与宿主自己的配置同构:
490
502
 
491
503
  | 层 | 文件 |
492
504
  |---|---|
493
- | 全局 | `~/.config/opencode/team-mode.jsonc` |
505
+ | 全局 | `~/.config/opencode/team-mode.jsonc`(认 `OPENCODE_CONFIG_DIR`) |
494
506
  | 项目 | `<project>/team-mode.jsonc`,或 `<project>/.opencode/team-mode.jsonc`(后者胜) |
495
507
 
496
- 优先级 `env < 全局 < 项目`,并且**文件赢环境变量**——写进文件的值会盖过同名的 `TM_*`。想让某一次运行回到纯环境变量,设
497
- `TM_CONFIG_ENV_ONLY=1`:它在任何文件被读取之前求值,文件关不掉它。
508
+ 优先级 `全局 < 项目`。**全局文件由插件在启动时自动生成**(文件不存在时):一份全注释的**惰性**模板——
509
+ 每个键都以注释形式出现(`// "key": <默认值>,`),注释行就是它的一行说明,所以在你取消注释之前,这个文件什么都不设置。
510
+ 幂等、原子写入,绝不覆盖已存在的文件。关闭方式:插件选项 `autoCreate: false`(或内部开关 `TM_CONFIG_AUTOCREATE=off`);
511
+ 启动行会记录 `config_autocreate`(`created` / `present` / `off` / `failed`)。
498
512
 
499
- 键名就是 `TM_*` 的 camelCase 写法(`offloadThreshold`、`searchDefaultEngine` …)。有六个键是**文件不许改写的红线**——
500
- `envProtect`、`r6FineAsk`、`privateSpace`、`webfetchAllowedDomains`、`browserAskEval`、`bashReadonlyAllowed`;文件里写了也会被忽略并报告。
513
+ 键名是 camelCase(`offloadThreshold`、`searchDefaultEngine` …)——`src/tm/config-layers.ts` 里的注册表是唯一事实来源
514
+ (59 个键,每个都带类型、默认值与说明)。有五个键是**项目文件不许改写的红线**——`envProtect`、`r6FineAsk`、
515
+ `privateSpace`、`webfetchAllowedDomains`、`bashReadonlyAllowed`;它们只能在全局文件里设置,项目文件里写了会被忽略并报告。
501
516
  解析失败的那一层**整层跳过**(半份配置比没有配置更危险),单个坏键只丢它自己。未知键在启动时告警、**不应用**。
502
- `tm_stats` 会显示每个键最终来自哪一层。
517
+ `tm_stats` 会显示每个键最终来自哪一层、被忽略的红线键、未知键、被跳过的层,以及自动生成的状态。
503
518
 
504
- **R6(环境变量保护)默认开启。** Team 角色用原生 `read` 读 `.env` 会被直接拒;读环境变量的 shell 命令会走宿主自己的权限提示。
505
- 关闭方式:插件选项 `envProtect: false`,或 `TM_ENV_PROTECT=off`。2.x 上 shell 面是 `ask`(宿主的对话框——插件弹不出来),
506
- env-FILE 面是硬 `deny`、没有同意路径。
519
+ **R6(环境变量保护)默认开启**(`envProtect` 默认 `"strict"`;规范取值 `strict` / `standard` / `off`)。Team 角色用原生 `read` 读 `.env` 会被直接拒;
520
+ 读环境变量的 shell 命令会走宿主自己的权限提示。关闭方式:插件选项 `envProtect: false`,或在**全局**文件里写 `"envProtect": "off"`(它是红线键)。
521
+ 2.x 上 shell 面是 `ask`(宿主的对话框——插件弹不出来),env-FILE 面是硬 `deny`、没有同意路径。
522
+
523
+ 仅存的 `TM_*` 环境变量是内部/测试开关,不是配置:`TM_STORE_RECLAIM`(测试覆盖 `storeReclaim` 键)、
524
+ `TM_V2_PROBE`(只记名字的 JSONL 转储)、`TM_CONFIG_AUTOCREATE`(自动生成的关闭开关)。
507
525
 
508
526
  ### 卸载
509
527
 
@@ -522,11 +540,12 @@ env-FILE 面是硬 `deny`、没有同意路径。
522
540
  | 选择器里没有那六个角色 | 第 2 步没跑,或者 `--dir` 指到了别处。`ls ~/.config/opencode/agents` |
523
541
  | 默认 agent 悄悄变成 `build` | `default_agent` 指向了一个不存在的角色——重跑第 2 步,再做第 3 步 |
524
542
  | 手写的 `agents/Team.md` 没被更新 | 这是设计:生成器拒绝覆盖不是它生成的文件。把你的文件挪开,或显式加 `--force` |
543
+ | 选择器里领队显示成 `team`,或者 Team 不是默认 | 宿主按**文件名**取 agent id。机器上只要曾经有过 1.7 之前的 `team.md`,领队就以 `team` 注册,而安装器写的是 `default_agent: "Team"`——按文档,默认值指向一个找不到的 agent 时会**静默回退 build**。**1.7.2 已修**:生成器会把"已确认是同一个文件、仅大小写不同"的条目标成规范拼写。1.7.1 及更早版本重跑**修不好**(Windows 上从 `Team.md` 写入会保留旧拼写),需要手工两步改名:`team.md` → `Team.md` |
525
544
  | `/team-plan` 能跑但不是以那个专家身份跑 | 2.x 把 `mode: subagent` 文档化为"只在子会话里运行"。如果选定专家的命令行为不对,改法是角色定义里用 `mode: "all"`(请回报——这是本流程里唯一还没在活体宿主上验证过的一项) |
526
545
  | `tm_webfetch` 说它"没问任何人就直接拒绝" | v2 的预期行为:插件弹不出对话框。换一个不需要这次访问的源,或让宿主自己的权限规则放行;地址红线(元数据 / 私网)在两代宿主上都没有授权路径 |
527
546
  | `subagent` 好像把领队挡住了 | v2 上插件会强制 `background: true`;如果你顺手设了老的 `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS`,请取消它——那是 v1 的开关,在这里只会误导判断 |
528
- | 某个工具的输出没被卸载 | `TM_NATIVE_OFFLOAD=off`(或者那个 2.x 构建没有 `tool.execute.after` 缝)——`tm_stats` 会告诉你是哪一种 |
529
- | 会话不再自己提前压缩了(1.7.0 曾是模型窗口的 75%) | 那个触发器在 **1.7.1 已整体移除**(它随 1.7.0 发布)。压缩时机重新完全归宿主,跟没装插件一样。驱动它的三个开关(`TM_COMPACT_TRIGGER`、`TM_COMPACT_AT_PERCENT`、`TM_COMPACT_MIN_MS`)已删除;`team-mode.jsonc` 里若还留着它们,启动时会报为未知键而不是照旧生效。保留下来的是"只在宿主自己压缩时"才追加的两项:`TM_COMPACTION_CONTEXT` 推送必须存活清单,`TM_COMPACTION_AUTOCONTINUE` 控制宿主压缩后是否静默续跑 |
547
+ | 某个工具的输出没被卸载 | 配置文件里写 `"nativeOffload": "off"`(或者那个 2.x 构建没有 `tool.execute.after` 缝)——`tm_stats` 会告诉你是哪一种 |
548
+ | 会话不再自己提前压缩了(1.7.0 曾是模型窗口的 75%) | 那个触发器在 **1.7.1 已整体移除**(它随 1.7.0 发布)。压缩时机重新完全归宿主,跟没装插件一样。驱动它的三个开关(`TM_COMPACT_TRIGGER`、`TM_COMPACT_AT_PERCENT`、`TM_COMPACT_MIN_MS`)已删除;`team-mode.jsonc` 里若还留着它们,启动时会报为未知键而不是照旧生效。保留下来的是"只在宿主自己压缩时"才追加的一项:必须存活清单在 `session.hook("compaction")` 上推送(`compactionContext` 键是 v1-only 的解析兼容项) |
530
549
  | 插件行为怪:两次弹窗、钩子重复、工具像注册了两遍 | 它被加载了两次。`opencode.json` 与 `opencode.jsonc` 是合并读取的,而宿主去重只认完全相同的字符串,所以同一个包换两种写法(或者同时躺在两个文件里)就是两套人格。把两个文件里指向 Team 的条目一起数一遍;`msg="loading plugin"` 对同一个 id 出现两条就是证据 |
531
550
  | 写了条目,但宿主日志里关于它一个字都没有 | 两个"设计上静默"的原因:键名写成了单数 `plugin`(2.x 读 `plugins`),或者目录条目缺根 `index.js`。宿主解析目录只试 `<dir>/index`,试不出来就直接丢掉、不留任何消息 —— 在这里"没报错"不是成功信号 |
532
551
  | 后台子代理的回复太长 | 子会话现在会被登记、可被收集(`tm_join` 会列出它,行上标着「宿主 subagent 派发」),但它的**正文**仍然是宿主的注入消息,v2 不会在落盘前把这条消息交给插件;我们也不改写发出的消息(猜错那一层的形状等于静默删证据)。所以超限交付走黑板文件:正文进文件、回复里带路径,领队读那个文件 |
@@ -536,10 +555,14 @@ env-FILE 面是硬 `deny`、没有同意路径。
536
555
 
537
556
  ### v2 专属的开关
538
557
 
539
- | 变量 / 配置 | 默认 | 作用 |
558
+ 配置键写在 `team-mode.jsonc` 里(红线键只能在全局文件里设);环境变量只剩三个内部/测试开关。
559
+
560
+ | 键 / 变量 | 默认 | 作用 |
540
561
  |---|---|---|
541
- | `TM_WEBFETCH_ALLOWED_DOMAINS` | `*`(**仅 v2**) | 域名门禁默认关掉,只留地址红线;显式给了值就以它为准(v1 仍是 24 个种子域名) |
542
- | `TM_NATIVE_OFFLOAD` | on | 用 `tool.execute.after` 治理宿主原生工具的结果;off 就退回宿主原样 |
543
- | `TM_R6_FINE_ASK` | on(v2) | 由按命令分类器决定哪条 shell 要问;`off` 回到"每条 shell 都问",宿主没有 `permission.hook` 时也自动回到这一档 |
544
- | `TM_V2_PROBE` | 未设 | 把宿主真实工具面/动作名记成 JSONL(只有名字和计数) |
562
+ | `webfetchAllowedDomains`(配置) | `["*"]`(**仅 v2**) | 域名门禁默认关掉,只留地址红线;显式给了值就以它为准(v1 仍是 24 个种子域名) |
563
+ | `nativeOffload`(配置) | on | 用 `tool.execute.after` 治理宿主原生工具的结果;off 就退回宿主原样 |
564
+ | `r6FineAsk`(配置) | `""`(自动) | 由按命令分类器决定哪条 shell 要问;`off` 回到"每条 shell 都问",宿主没有 `permission.hook` 时也自动回到这一档 |
565
+ | `TM_V2_PROBE`(环境,内部) | 未设 | 把宿主真实工具面/动作名记成 JSONL(只有名字和计数) |
566
+ | `TM_STORE_RECLAIM`(环境,内部) | on | 测试覆盖 `storeReclaim` 键:off 时启动不回收存储分片 |
567
+ | `TM_CONFIG_AUTOCREATE`(环境,内部) | on | off 时不在启动时自动生成全局 `team-mode.jsonc` |
545
568
  | `{ "package": "@te-river/opencode-team-mode@latest", "options": { "defaultAgent": false } }` | 不设置 = Team 永远默认 | 退出默认位抢占(2.x 必须用对象形状;1.x 的元组会被宿主拒:`path=$.plugins.1 kind=invalid`) |
@@ -97,7 +97,7 @@ Report which checks passed. If verification fails, see Troubleshooting.
97
97
  OpenCode's own `task { background: true }` is the only sub-agent its interface
98
98
  can **show**: the card links to the live child session, it does not block the
99
99
  lead, and the host wakes the parent with the result. TeamMode then keeps that
100
- result inside the token budget (`TM_TASK_OFFLOAD`).
100
+ result inside the token budget (`taskOffload`).
101
101
 
102
102
  It rides an OpenCode **experimental flag** read from the host process
103
103
  environment at startup, so a plugin cannot set it — the installer writes it for
@@ -273,7 +273,7 @@ never re-resolved on its own).
273
273
  | Version is old despite `@latest` | Plugin cache never invalidates | See Updating — delete the cache dir (recursively; both layouts) |
274
274
  | Still the old version after bumping the version or re-running the installer | The scoped cache `packages/@te-river/opencode-team-mode@latest` is nested below the top-level `packages/*opencode-team-mode*` glob, so the stale copy was never purged | Recursively delete that cache dir (`Get-ChildItem -Recurse -Filter '*opencode-team-mode*'` / `find -type d -name '*opencode-team-mode*' -prune`), then restart — the current installers already recurse |
275
275
  | Node version error on startup | npm needs Node ≥ 18 | Upgrade Node |
276
- | Env-var commands suddenly prompt for confirmation | That's the plugin's R6 protection working as designed | Approve once per operation, or set `TM_ENV_PROTECT=off` (not recommended) |
276
+ | Env-var commands suddenly prompt for confirmation | That's the plugin's R6 protection working as designed | Approve once per operation, or set `envProtect: "off"` in `team-mode.jsonc` (not recommended) |
277
277
  | A user-defined agent named `team`/`architect`/… got shadowed | Plugin injects defaults; your own definitions always win | The plugin never clobbers user agents — check for typos in your agent names |
278
278
 
279
279
  ---
@@ -342,7 +342,7 @@ never re-resolved on its own).
342
342
 
343
343
  OpenCode 自带的 `task { background: true }` 是唯一能在它界面上**看见**的子代理:
344
344
  卡片直接链到那个子会话、不阻塞 lead、完成时宿主把父会话唤醒。TeamMode 再把
345
- 回来的结果压在 token 预算内(`TM_TASK_OFFLOAD`)。
345
+ 回来的结果压在 token 预算内(`taskOffload`)。
346
346
 
347
347
  它依赖一个 OpenCode 的**实验性开关**,由宿主进程启动时读环境变量决定,插件无法
348
348
  自己打开——所以安装脚本替你写:
@@ -501,5 +501,5 @@ https://raw.githubusercontent.com/Te-River/Opencode-TeamMode/main/docs/installat
501
501
  | 用着 `@latest` 版本还是旧的 | 插件缓存永不失效 | 见"更新"——递归删除缓存目录(两种布局) |
502
502
  | 改了版本/重跑安装器后仍是旧版 | 作用域缓存 `packages/@te-river/opencode-team-mode@latest` 嵌在顶层 `packages/*opencode-team-mode*` 通配符扫不到的下一层,旧副本根本没被清掉 | 递归删除该缓存目录(`Get-ChildItem -Recurse -Filter '*opencode-team-mode*'` / `find -type d -name '*opencode-team-mode*' -prune`)后重启——当前安装器已改为递归清理 |
503
503
  | 启动报 Node 版本错误 | npm 需要 Node ≥ 18 | 升级 Node |
504
- | 环境变量命令突然弹确认框 | 这是插件的 R6 保护在正常工作 | 每个操作选"一次",或设 `TM_ENV_PROTECT=off`(不推荐) |
504
+ | 环境变量命令突然弹确认框 | 这是插件的 R6 保护在正常工作 | 每个操作选"一次",或设 `envProtect: "off"`(不推荐) |
505
505
  | 用户自定义的 `team`/`architect` 等 agent 被遮蔽 | 插件注入默认值;你的定义永远优先 | 插件从不覆盖用户 agent——检查你的 agent 名字有没有拼错 |
@@ -544,3 +544,34 @@ CALLED" note — a future layer would otherwise have to re-probe a seam the 2.0.
544
544
  settled. Context Pruning (`TM_PRUNE`, default 70%) is a different mechanism: it rewrites settled
545
545
  message BODIES into pointers inside an outgoing request and never asks the host to summarize
546
546
  anything. `[R]`
547
+
548
+ ### 2026-10-07 — the agent ID is the FILE NAME, and a case-only difference is not cosmetic `[L][R]`
549
+
550
+ **Measured on 2.0.24 / 2026-10-07 (this machine).** The global config directory held
551
+ `agents/team.md` — lowercase, left over from the pre-1.7 naming — while the generator had been
552
+ "writing" `agents/Team.md` on every install since. The host was asked directly:
553
+
554
+ - `opencode-cli run --standalone --agent Team --model nope/nope "x"` → `Agent not found: "Team"`
555
+ - `opencode-cli run --standalone --agent team --model nope/nope "x"` → resolves (fails only on
556
+ the fake model: `Model unavailable: nope/nope`)
557
+ - `opencode-cli api get /api/agent?directory=…` → `{"id":"team","name":"team",…}` for the lead
558
+
559
+ So the on-disk spelling IS the registered id, and the installer's own
560
+ `default_agent: "Team"` named an agent that did not exist. Per the agents doc the fallback for
561
+ an unresolvable default is `build` — silent, which is exactly the failure the installer's
562
+ "write `default_agent` LAST, and read it back" rule exists to prevent; the read-back cannot see
563
+ it, because the key IS on disk and the ID is what is wrong. `[L]`
564
+
565
+ **Why every install failed to fix it.** `writeFileSync(<dir>/Team.md)` on a case-insensitive,
566
+ case-PRESERVING filesystem opens the existing `team.md` and leaves that name alone — and the
567
+ reclaim pass deliberately refused to delete the case-only entry (deleting it would delete the
568
+ file just written: the #47/#48 guard). Nothing in the pipeline could ever change the spelling.
569
+ The fix is an explicit two-step rename (`team.md` → temp → `Team.md`; a case-only `rename` on
570
+ win32 is itself a no-op), performed ONLY on an entry the identity verdict has already PROVEN is
571
+ the same file as a target, with a restore path if the second step throws. `[R]`
572
+
573
+ **The lesson, stated in the general form.** "Is the tool loaded" and "is the file on disk" were
574
+ both yes here — the two checks the installer prints. What no check covered was *which id the
575
+ host derives from it*, and that is the difference between Team being the default and a silent
576
+ `build`. A verifiable claim has to name the artefact the host actually keys on (the FILE NAME),
577
+ not the one we wrote (the path we computed). `[R]`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@te-river/opencode-team-mode",
3
- "version": "1.7.1",
3
+ "version": "1.7.3",
4
4
  "description": "Team collaboration mode plugin for OpenCode Desktop — injects specialized agents (architect, reviewer, implementer, tester, researcher) and team workflow commands into your OpenCode workspace.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -29,7 +29,7 @@
29
29
  * Refuses to touch a file it did not generate unless --force is given.
30
30
  */
31
31
 
32
- import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync, realpathSync, statSync } from "node:fs"
32
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync, realpathSync, renameSync, statSync } from "node:fs"
33
33
  import { join } from "node:path"
34
34
  import { homedir } from "node:os"
35
35
  import { pathToFileURL } from "node:url"
@@ -366,9 +366,45 @@ function reclaimStale(dir, keepNames, label) {
366
366
  }
367
367
  }
368
368
  if (sameAs) {
369
- // Same file as a target, stored under a different case. There is nothing
370
- // stale here, and deleting it would delete the file we just wrote.
371
- caseNotes.push(`保留 ${file} — 与目标 ${sameAs} 是同一个文件(本机不区分大小写),未删除`)
369
+ // Same file as a target, stored under a DIFFERENT case. Nothing here is
370
+ // stale and deleting it would delete the file we just wrote — but leaving
371
+ // the old spelling is a real defect, not a cosmetic one: the HOST TAKES AN
372
+ // AGENT'S ID FROM THE FILE NAME. A machine that still carries the
373
+ // pre-1.7 `team.md` registers the lead as `team`, so the installer's own
374
+ // `default_agent: "Team"` resolves to nothing and the host falls back to
375
+ // `build` — silently, because that fallback is documented as the normal
376
+ // move for a default that names an agent it cannot find. Measured on
377
+ // 2.0.24 / 2026-10-07 on this very machine: `--agent Team` answered
378
+ // `Agent not found: "Team"`, `--agent team` resolved, and
379
+ // `GET /api/agent` listed `{"id":"team","name":"team"}`.
380
+ //
381
+ // Windows and APFS keep the EXISTING spelling when a write opens that file
382
+ // through the other one, which is how the stale name survives every
383
+ // install, so the rename has to be explicit — and on win32 a case-only
384
+ // rename is itself a no-op, hence the temporary name in the middle.
385
+ if (printOnly) {
386
+ caseNotes.push(`保留 ${file} — 与目标 ${sameAs} 是同一个文件(--print,未修正大小写)`)
387
+ continue
388
+ }
389
+ const target = join(dir, sameAs)
390
+ const tmp = join(dir, `.${entry}.casefix-${process.pid}`)
391
+ try {
392
+ renameSync(file, tmp)
393
+ renameSync(tmp, target)
394
+ caseNotes.push(`已修正大小写 ${entry} → ${sameAs} — 与目标是同一个文件;宿主按文件名取 agent id,不改就一直挂在旧 id 上`)
395
+ } catch (err) {
396
+ // Never leave a half-rename behind: if the second step failed, put the
397
+ // file back under the name we found it (the stale spelling is survivable;
398
+ // a file stranded under the temporary name is not).
399
+ try {
400
+ if (existsSync(tmp) && !existsSync(target)) renameSync(tmp, target)
401
+ } catch {
402
+ /* it stays under the temporary name, and the note below says so */
403
+ }
404
+ caseNotes.push(
405
+ `大小写未修正 ${entry}(目标 ${sameAs}):${String((err && err.message) || err).replace(/\s+/g, " ").slice(0, 80)}`,
406
+ )
407
+ }
372
408
  continue
373
409
  }
374
410
  if (undecidable) {
@@ -1,205 +0,0 @@
1
- /**
2
- * Unified approval gate — Layer 2 (timeout auto-reject) + Layer 3 (SDK
3
- * fallback) of the R6 + R2 approval flow.
4
- *
5
- * The host only shows its official confirmation dialog for command patterns
6
- * declared `ask` in an agent's permission config (Layer 1, envprotect.ts).
7
- * When a dialog opens it SUSPENDS the tool call and waits for a human; on the
8
- * OpenCode DESKTOP that human is present, but a request can also be left
9
- * unanswered. This module owns ONE timer that watches the host permission
10
- * events and closes abandoned requests on the safe side.
11
- *
12
- * REAL host contract (1.18.29, probed live via SSE + the plugin event hook —
13
- * see gate-test report-p5):
14
- * permission.asked → properties = { id, sessionID, permission: "bash",
15
- * patterns: [<concrete command segment>, …], metadata:
16
- * { command }, always: [<host's "always" generalization
17
- * proposal>], tool: { messageID, callID } } — the props
18
- * carry NO `type` field, and the tool name sits in
19
- * `permission`. A props matching one of OUR injected
20
- * bash ask sets means the dialog is ours: audit "ask",
21
- * arm the timeout, and — for the R6 env face — mark
22
- * the session deferrable (canDefer, see below).
23
- * permission.replied → properties = { sessionID, requestID, reply } on the
24
- * wire, but the plugin-side observer has also seen it
25
- * with ONLY { sessionID }. The gate therefore cancels
26
- * EVERY pending timer of that session on a reply: an
27
- * already-approved command left on a live timer would
28
- * get a second reject after the timeout, the SDK call
29
- * would hit a dead id (4xx) and flip the gate
30
- * permanently degraded.
31
- *
32
- * Older/typed spellings (`permission.updated`, `pattern`, `permissionID`,
33
- * `response`) keep being accepted — the shipped SDK d.ts still names the
34
- * events that way, and some builds omit the reply id entirely (a short
35
- * ghost-suppression window per session then absorbs late/duplicate `asked`
36
- * replays instead of re-arming a ghost timer).
37
- *
38
- * Hard rules (HUMAN-approved):
39
- * - the plugin NEVER self-allows: `reply` is fixed to "reject". The only
40
- * way a gated command runs is a human approving the dialog;
41
- * - on `TM_ENV_PROTECT=off` the gate is not armed (the caller decides);
42
- * - deferral is SESSION-SCOPED (canDefer): only sessions registered as
43
- * carrying our injected ask set may have env reads passed through —
44
- * registration comes from an exec-role user prompt (the live-proven
45
- * `message.updated` UserMessage{role,agent,sessionID} signal, plus the
46
- * documented `chat.message` hook) or from an R6-env-classified asked
47
- * event. Stock build/plan sessions never register, so the global R6
48
- * hook keeps hard-throwing there — closing the R6 bypass the global
49
- * `isArmed()` deferral allowed;
50
- * - if the SDK reply FAILS (network/host — including the SDK's
51
- * `throwOnError:false` envelope resolving with `{ error }`), we cannot
52
- * close the dialog, so the gate flips itself permanently "degraded" —
53
- * the R6 hook stops deferring env reads and hard-throws them again
54
- * (fail-closed), and the event is audited "degraded".
55
- *
56
- * SDK surface (probed live): the host client may carry NO `permission`
57
- * namespace and NO list/create endpoint; the reply path is the top-level
58
- * `postSessionIdPermissionsPermissionId({ path: { id: sessionID,
59
- * permissionID }, body: { response } })`. `client.permission.reply` /
60
- * `client.permission.list` are still tried first so a richer host build is
61
- * supported, but when they are absent the poll fallback is a no-op (events
62
- * are the sole pending signal).
63
- */
64
- import type { PermissionEvent } from "./types.js";
65
- /** Default timeout, in minutes, before an unanswered dialog is auto-rejected.
66
- * 1 min (user directive) — an unanswered dialog no longer hangs for 10.
67
- * A short timer is safe because a reject that lands on an already-closed
68
- * request is benign; see `MIN_ASK_TIMEOUT_MIN` for the full rationale. */
69
- export declare const DEFAULT_ASK_TIMEOUT_MIN = 1;
70
- /**
71
- * Hard floor (minutes) for the auto-reject timeout. The HISTORIC 3-min
72
- * floor existed to dodge the measured ~120s `permission.replied` bus lag:
73
- * a shorter timer would fire before the cancel arrives and auto-reject a
74
- * just-approved request. That lag, though, is the HOST DELIVERING the
75
- * replied event to the plugin over the bus — NOT the host resolving the
76
- * user's click: an approval takes effect instantly host-side, so the
77
- * plugin's late reject simply targets an already-closed request id → 404.
78
- * T2's `classifyReplyFailure` maps that outcome to the benign
79
- * `already-closed` verdict (it never flips the gate to `degraded`, and the
80
- * approved command runs either way); `onReplied` records a reply landing
81
- * after the auto-reject as `late-<verdict>` for observability. With that
82
- * classification in place a short timeout no longer disables the gate, so
83
- * the floor drops to 1 minute (user directive). Trade-off: an UNANSWERED
84
- * dialog auto-rejects after 1 min — a user who stepped away re-triggers
85
- * the command. In-range values below the floor still clamp UP to it.
86
- *
87
- * This is the FALLBACK default of the floor. The live floor comes from the
88
- * P0 config knob `TM_ASK_TIMEOUT_FLOOR_MIN`
89
- * (`resolveTmConfig().askTimeoutFloorMin`, itself defaulted to 1).
90
- */
91
- export declare const MIN_ASK_TIMEOUT_MIN = 1;
92
- /** The verdict vocabulary recorded in the audit trail (privacy: no command
93
- * text, path, variable name or value is ever logged). `already-closed` /
94
- * `rejected-shape-bug` split the old blanket `degraded` on a failed reply by
95
- * `classifyReplyFailure`; a late reply past the auto-reject is recorded as a
96
- * dynamic `late-<verdict>` string (observability only — never re-runs or
97
- * revives the command). */
98
- export type ApprovalVerdict = "ask" | "allowed-once" | "allowed-always" | "rejected" | "timeout-rejected" | "already-closed" | "rejected-shape-bug" | "degraded";
99
- /**
100
- * Resolve `TM_ASK_TIMEOUT_MIN`. Unset / blank / non-numeric / <1 / >1440
101
- * (24h) fall back to the 1-minute default — a mistyped value can never
102
- * disable the timeout or make it absurdly long. Valid values below the
103
- * floor clamp UP to it — the floor is the P0 config knob
104
- * `TM_ASK_TIMEOUT_FLOOR_MIN` (`resolveTmConfig().askTimeoutFloorMin`,
105
- * default 1). The historic ~120s bus-lag reason for a 3-min floor is
106
- * obsolete: a late reject on an already-closed id is benign
107
- * `already-closed`, not a degraded flip (see `MIN_ASK_TIMEOUT_MIN`).
108
- */
109
- export declare function resolveAskTimeoutMs(env?: Record<string, string | undefined>): number;
110
- type TimerHandle = unknown;
111
- export interface GateTimers {
112
- setTimeoutFn: (cb: () => void, ms: number) => TimerHandle;
113
- clearTimeoutFn: (handle: TimerHandle) => void;
114
- }
115
- export interface ApprovalGateDeps {
116
- client: unknown;
117
- timeoutMs: number;
118
- /** Injectable timers (default: real global timers) for deterministic tests. */
119
- timers?: GateTimers;
120
- /** Injectable clock (default: Date.now) for deterministic window tests. */
121
- now?: () => number;
122
- /** Best-effort attention hook — fired on EVERY permission.asked (all
123
- * dialogs: bash R2/R6 asks AND the tm_* ctx.ask web dialogs) so a user
124
- * not staring at the screen learns a confirmation is waiting. The caller
125
- * wires this to the host's `tui.showToast`. Never throws into the gate. */
126
- notify?: (message: string) => void;
127
- }
128
- export interface ApprovalGate {
129
- /** Handle one host event (permission.asked/updated / permission.replied). */
130
- handleEvent(event: PermissionEvent): void;
131
- /** Start the poll fallback (no-op when the host has no list endpoint). */
132
- start(): void;
133
- /** Stop the poll + every pending timer. */
134
- dispose(): void;
135
- /** Global gate health: armed, not degraded, and the client can reply. */
136
- isArmed(): boolean;
137
- /**
138
- * The R6 hook's deferral query: TRUE only when this exact session is
139
- * registered as carrying our injected ask set AND the gate is healthy.
140
- * Replaces the old global `isArmed()` deferral (which let stock build/plan
141
- * sessions pass env reads silently with no dialog in front of them).
142
- */
143
- canDefer(sessionID?: string): boolean;
144
- /** True once a session registered (exec-role prompt seen on
145
- * message.updated / chat.message by index.ts, or an R6-env-classified
146
- * asked event). Introspection for tests/diagnostics. */
147
- hasLiveAsk(sessionID?: string): boolean;
148
- /** Mark a session as carrying the injected ask set (index.ts calls this
149
- * when a user prompt routes to an agent we injected the escalated bash
150
- * ask object into — proven live: `message.updated` UserMessage carries
151
- * { sessionID, role:"user", agent } and fires BEFORE the session's first
152
- * tool call, while permission.asked fires AFTER tool.execute.before). */
153
- registerExecSession(sessionID?: string): void;
154
- /**
155
- * Drop a session's exec-role registration (index.ts calls this when a user
156
- * prompt routes to an agent that does NOT carry our injected ask set).
157
- * The verified host passes {tool, sessionID, callID} with NO agent to
158
- * tool.execute.before (desktop binary: `plugin.trigger("tool.execute.before",
159
- * { tool, sessionID, callID }, { args })`), so the per-turn agent signal
160
- * can only come from message.updated/chat.message. Without revocation a
161
- * session that once ran a team prompt stayed deferrable forever — even
162
- * after the user switched the picker to a stock agent whose dialogs would
163
- * never fire, silently passing env reads (canDefer true + no dialog).
164
- * A later env-classified permission.asked re-registers on real dialog
165
- * evidence, and the next exec-role prompt re-registers again — both paths
166
- * re-arm from fresh evidence, so revocation only ever closes a stale
167
- * window (fail-closed direction).
168
- */
169
- revokeExecSession(sessionID?: string): void;
170
- /** True after the user picked "always" on an env-related ask in this session
171
- * — subsequent env reads pass silently (the approval event itself is audited). */
172
- isEnvApproved(sessionID?: string): boolean;
173
- /** Number of pending requests currently being timed (tests / introspection). */
174
- pendingSize(): number;
175
- }
176
- /** How a FAILED permission-reply call should be treated by the gate. */
177
- export type ReplyFailureClass = "benign-closed" | "param-shape" | "transport";
178
- /**
179
- * Classify a reply-call failure so the gate no longer flips permanently
180
- * `degraded` on a harmless close. `unwrapSdkEnvelope` has already hoisted the
181
- * HTTP status onto the thrown envelope error; a genuine `Promise.reject`
182
- * arrives as a plain Error with no status.
183
- * - `benign-closed`: 404, or a NotFound/PermissionNotFound name/_tag — the
184
- * dialog was ALREADY closed on the host (the D4 late-reply race): our
185
- * reject was a no-op, so the popup path stays trustworthy.
186
- * - `param-shape`: 400, or a BadRequest/InvalidRequest name/_tag — our
187
- * reply body/params are wrong for this host: a real integration bug.
188
- * - `transport`: everything else — a genuine Error, a 5xx, or an empty /
189
- * status-less body — we could not reach or close the dialog.
190
- * Reads only the error CLASS + status number (privacy: never message text).
191
- */
192
- export declare function classifyReplyFailure(err: unknown): ReplyFailureClass;
193
- /** True when the client exposes a permission-reply path at all. index.ts
194
- * arms the gate ONLY for such a client — fail-closed otherwise (no armed
195
- * auto-reject ⇒ the R6 hook keeps hard-throwing every env read). */
196
- export declare function hasPermissionReplyCapability(client: unknown): boolean;
197
- /**
198
- * Create the approval gate. It is intentionally pure about timing +
199
- * replying + session registration; the caller (index.ts) decides whether to
200
- * arm it (mode != off AND the client is reply-capable), feeds events in, and
201
- * wires `canDefer(sessionID)` into the R6 hook.
202
- */
203
- export declare function createApprovalGate(deps: ApprovalGateDeps): ApprovalGate;
204
- export {};
205
- //# sourceMappingURL=approval-gate.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"approval-gate.d.ts","sourceRoot":"","sources":["../src/approval-gate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8DG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAIjD;;;2EAG2E;AAC3E,eAAO,MAAM,uBAAuB,IAAI,CAAA;AAExC;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,mBAAmB,IAAI,CAAA;AAkBpC;;;;;4BAK4B;AAC5B,MAAM,MAAM,eAAe,GACvB,KAAK,GACL,cAAc,GACd,gBAAgB,GAChB,UAAU,GACV,kBAAkB,GAClB,gBAAgB,GAChB,oBAAoB,GACpB,UAAU,CAAA;AAEd;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAe,GAAG,MAAM,CAWjG;AAED,KAAK,WAAW,GAAG,OAAO,CAAA;AAE1B,MAAM,WAAW,UAAU;IACzB,YAAY,EAAE,CAAC,EAAE,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,KAAK,WAAW,CAAA;IACzD,cAAc,EAAE,CAAC,MAAM,EAAE,WAAW,KAAK,IAAI,CAAA;CAC9C;AAED,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,OAAO,CAAA;IACf,SAAS,EAAE,MAAM,CAAA;IACjB,+EAA+E;IAC/E,MAAM,CAAC,EAAE,UAAU,CAAA;IACnB,2EAA2E;IAC3E,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;IAClB;;;iFAG6E;IAC7E,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAA;CACnC;AAED,MAAM,WAAW,YAAY;IAC3B,6EAA6E;IAC7E,WAAW,CAAC,KAAK,EAAE,eAAe,GAAG,IAAI,CAAA;IACzC,0EAA0E;IAC1E,KAAK,IAAI,IAAI,CAAA;IACb,2CAA2C;IAC3C,OAAO,IAAI,IAAI,CAAA;IACf,yEAAyE;IACzE,OAAO,IAAI,OAAO,CAAA;IAClB;;;;;OAKG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;IACrC;;8DAE0D;IAC1D,UAAU,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;IACvC;;;;8EAI0E;IAC1E,mBAAmB,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7C;;;;;;;;;;;;;;OAcG;IACH,iBAAiB,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3C;uFACmF;IACnF,aAAa,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;IAC1C,gFAAgF;IAChF,WAAW,IAAI,MAAM,CAAA;CACtB;AAuED,wEAAwE;AACxE,MAAM,MAAM,iBAAiB,GAAG,eAAe,GAAG,aAAa,GAAG,WAAW,CAAA;AAE7E;;;;;;;;;;;;;GAaG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,OAAO,GAAG,iBAAiB,CAgBpE;AA8DD;;qEAEqE;AACrE,wBAAgB,4BAA4B,CAAC,MAAM,EAAE,OAAO,GAAG,OAAO,CAErE;AAyBD;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,gBAAgB,GAAG,YAAY,CAqUvE"}