@te-river/opencode-team-mode 1.6.1 → 1.7.0

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 (201) hide show
  1. package/README.md +117 -118
  2. package/README.zh-CN.md +98 -100
  3. package/dist/agents.d.ts.map +1 -1
  4. package/dist/agents.js +89 -79
  5. package/dist/agents.js.map +1 -1
  6. package/dist/approval-gate.d.ts +8 -9
  7. package/dist/approval-gate.d.ts.map +1 -1
  8. package/dist/approval-gate.js +13 -14
  9. package/dist/approval-gate.js.map +1 -1
  10. package/dist/capabilities.d.ts +11 -37
  11. package/dist/capabilities.d.ts.map +1 -1
  12. package/dist/capabilities.js +11 -141
  13. package/dist/capabilities.js.map +1 -1
  14. package/dist/commands.js +1 -1
  15. package/dist/envprotect/hook.d.ts +12 -31
  16. package/dist/envprotect/hook.d.ts.map +1 -1
  17. package/dist/envprotect/hook.js +13 -91
  18. package/dist/envprotect/hook.js.map +1 -1
  19. package/dist/envprotect/patterns.d.ts +6 -4
  20. package/dist/envprotect/patterns.d.ts.map +1 -1
  21. package/dist/envprotect/patterns.js +6 -4
  22. package/dist/envprotect/patterns.js.map +1 -1
  23. package/dist/envprotect.d.ts +1 -1
  24. package/dist/envprotect.d.ts.map +1 -1
  25. package/dist/envprotect.js +1 -1
  26. package/dist/envprotect.js.map +1 -1
  27. package/dist/host/note.d.ts.map +1 -1
  28. package/dist/host/note.js +8 -5
  29. package/dist/host/note.js.map +1 -1
  30. package/dist/host/v2-browser-gate.d.ts +19 -1
  31. package/dist/host/v2-browser-gate.d.ts.map +1 -1
  32. package/dist/host/v2-browser-gate.js +47 -6
  33. package/dist/host/v2-browser-gate.js.map +1 -1
  34. package/dist/host/v2-capabilities.d.ts +43 -0
  35. package/dist/host/v2-capabilities.d.ts.map +1 -1
  36. package/dist/host/v2-capabilities.js +77 -1
  37. package/dist/host/v2-capabilities.js.map +1 -1
  38. package/dist/host/v2-compaction.d.ts +129 -0
  39. package/dist/host/v2-compaction.d.ts.map +1 -0
  40. package/dist/host/v2-compaction.js +346 -0
  41. package/dist/host/v2-compaction.js.map +1 -0
  42. package/dist/host/v2-default-agent.d.ts +37 -0
  43. package/dist/host/v2-default-agent.d.ts.map +1 -0
  44. package/dist/host/v2-default-agent.js +128 -0
  45. package/dist/host/v2-default-agent.js.map +1 -0
  46. package/dist/host/v2-events.d.ts +2 -0
  47. package/dist/host/v2-events.d.ts.map +1 -1
  48. package/dist/host/v2-events.js +27 -0
  49. package/dist/host/v2-events.js.map +1 -1
  50. package/dist/host/v2-guard.d.ts +178 -0
  51. package/dist/host/v2-guard.d.ts.map +1 -1
  52. package/dist/host/v2-guard.js +324 -8
  53. package/dist/host/v2-guard.js.map +1 -1
  54. package/dist/host/v2-offload.d.ts +1 -1
  55. package/dist/host/v2-offload.js +2 -2
  56. package/dist/host/v2-permissions.d.ts +45 -44
  57. package/dist/host/v2-permissions.d.ts.map +1 -1
  58. package/dist/host/v2-permissions.js +48 -49
  59. package/dist/host/v2-permissions.js.map +1 -1
  60. package/dist/host/v2-probe-chain.d.ts +93 -0
  61. package/dist/host/v2-probe-chain.d.ts.map +1 -0
  62. package/dist/host/v2-probe-chain.js +146 -0
  63. package/dist/host/v2-probe-chain.js.map +1 -0
  64. package/dist/host/v2-prune.d.ts +157 -0
  65. package/dist/host/v2-prune.d.ts.map +1 -0
  66. package/dist/host/v2-prune.js +507 -0
  67. package/dist/host/v2-prune.js.map +1 -0
  68. package/dist/host/v2-retry.d.ts +143 -0
  69. package/dist/host/v2-retry.d.ts.map +1 -0
  70. package/dist/host/v2-retry.js +315 -0
  71. package/dist/host/v2-retry.js.map +1 -0
  72. package/dist/host/v2-scope.d.ts.map +1 -1
  73. package/dist/host/v2-scope.js +14 -7
  74. package/dist/host/v2-scope.js.map +1 -1
  75. package/dist/host/v2-session-client.d.ts +62 -7
  76. package/dist/host/v2-session-client.d.ts.map +1 -1
  77. package/dist/host/v2-session-client.js +485 -26
  78. package/dist/host/v2-session-client.js.map +1 -1
  79. package/dist/host/v2-session.d.ts +72 -1
  80. package/dist/host/v2-session.d.ts.map +1 -1
  81. package/dist/host/v2-session.js +154 -10
  82. package/dist/host/v2-session.js.map +1 -1
  83. package/dist/host/v2-tool.d.ts +2 -3
  84. package/dist/host/v2-tool.d.ts.map +1 -1
  85. package/dist/host/v2-tool.js +2 -3
  86. package/dist/host/v2-tool.js.map +1 -1
  87. package/dist/host/v2-types.d.ts +25 -0
  88. package/dist/host/v2-types.d.ts.map +1 -1
  89. package/dist/host/v2.d.ts +14 -12
  90. package/dist/host/v2.d.ts.map +1 -1
  91. package/dist/host/v2.js +648 -74
  92. package/dist/host/v2.js.map +1 -1
  93. package/dist/identity.d.ts +27 -0
  94. package/dist/identity.d.ts.map +1 -0
  95. package/dist/identity.js +34 -0
  96. package/dist/identity.js.map +1 -0
  97. package/dist/index.d.ts +9 -24
  98. package/dist/index.d.ts.map +1 -1
  99. package/dist/index.js +17 -27
  100. package/dist/index.js.map +1 -1
  101. package/dist/prompts/lead.d.ts +1 -1
  102. package/dist/prompts/lead.d.ts.map +1 -1
  103. package/dist/prompts/lead.js +44 -27
  104. package/dist/prompts/lead.js.map +1 -1
  105. package/dist/prompts/shared.d.ts +2 -2
  106. package/dist/prompts/shared.d.ts.map +1 -1
  107. package/dist/prompts/shared.js +74 -57
  108. package/dist/prompts/shared.js.map +1 -1
  109. package/dist/prompts/specialists.d.ts +2 -2
  110. package/dist/prompts/specialists.d.ts.map +1 -1
  111. package/dist/prompts/specialists.js +53 -83
  112. package/dist/prompts/specialists.js.map +1 -1
  113. package/dist/tm/args-schema.d.ts +7 -32
  114. package/dist/tm/args-schema.d.ts.map +1 -1
  115. package/dist/tm/args-schema.js +12 -153
  116. package/dist/tm/args-schema.js.map +1 -1
  117. package/dist/tm/board.d.ts +10 -9
  118. package/dist/tm/board.d.ts.map +1 -1
  119. package/dist/tm/board.js +11 -10
  120. package/dist/tm/board.js.map +1 -1
  121. package/dist/tm/config-files.d.ts +81 -0
  122. package/dist/tm/config-files.d.ts.map +1 -0
  123. package/dist/tm/config-files.js +209 -0
  124. package/dist/tm/config-files.js.map +1 -0
  125. package/dist/tm/config-layers.d.ts +117 -0
  126. package/dist/tm/config-layers.d.ts.map +1 -0
  127. package/dist/tm/config-layers.js +320 -0
  128. package/dist/tm/config-layers.js.map +1 -0
  129. package/dist/tm/config.d.ts +10 -101
  130. package/dist/tm/config.d.ts.map +1 -1
  131. package/dist/tm/config.js +17 -71
  132. package/dist/tm/config.js.map +1 -1
  133. package/dist/tm/dispatch.d.ts +154 -22
  134. package/dist/tm/dispatch.d.ts.map +1 -1
  135. package/dist/tm/dispatch.js +559 -80
  136. package/dist/tm/dispatch.js.map +1 -1
  137. package/dist/tm/egress.d.ts +19 -7
  138. package/dist/tm/egress.d.ts.map +1 -1
  139. package/dist/tm/egress.js +21 -9
  140. package/dist/tm/egress.js.map +1 -1
  141. package/dist/tm/index.d.ts +23 -20
  142. package/dist/tm/index.d.ts.map +1 -1
  143. package/dist/tm/index.js +117 -116
  144. package/dist/tm/index.js.map +1 -1
  145. package/dist/tm/ledger.d.ts +9 -2
  146. package/dist/tm/ledger.d.ts.map +1 -1
  147. package/dist/tm/ledger.js +4 -1
  148. package/dist/tm/ledger.js.map +1 -1
  149. package/dist/tm/perm-ask.d.ts +3 -3
  150. package/dist/tm/perm-ask.d.ts.map +1 -1
  151. package/dist/tm/perm-ask.js +3 -3
  152. package/dist/tm/perm-ask.js.map +1 -1
  153. package/dist/tm/pipelines.d.ts +12 -11
  154. package/dist/tm/pipelines.d.ts.map +1 -1
  155. package/dist/tm/pipelines.js +40 -207
  156. package/dist/tm/pipelines.js.map +1 -1
  157. package/dist/tm/preview.d.ts +1 -1
  158. package/dist/tm/preview.js +3 -3
  159. package/dist/tm/preview.js.map +1 -1
  160. package/dist/tm/refs.d.ts +31 -4
  161. package/dist/tm/refs.d.ts.map +1 -1
  162. package/dist/tm/refs.js +48 -4
  163. package/dist/tm/refs.js.map +1 -1
  164. package/dist/tm/result.d.ts +1 -1
  165. package/dist/tm/result.d.ts.map +1 -1
  166. package/dist/tm/result.js +4 -3
  167. package/dist/tm/result.js.map +1 -1
  168. package/dist/tm/search.d.ts +2 -2
  169. package/dist/tm/search.js +7 -7
  170. package/dist/tm/search.js.map +1 -1
  171. package/dist/tm/serp-loop.d.ts +19 -7
  172. package/dist/tm/serp-loop.d.ts.map +1 -1
  173. package/dist/tm/serp-loop.js +24 -9
  174. package/dist/tm/serp-loop.js.map +1 -1
  175. package/dist/tm/stats.d.ts +31 -27
  176. package/dist/tm/stats.d.ts.map +1 -1
  177. package/dist/tm/stats.js +170 -58
  178. package/dist/tm/stats.js.map +1 -1
  179. package/dist/tm/store.d.ts +18 -5
  180. package/dist/tm/store.d.ts.map +1 -1
  181. package/dist/tm/store.js +39 -9
  182. package/dist/tm/store.js.map +1 -1
  183. package/dist/tm/tools.d.ts +7 -5
  184. package/dist/tm/tools.d.ts.map +1 -1
  185. package/dist/tm/tools.js +10 -41
  186. package/dist/tm/tools.js.map +1 -1
  187. package/dist/tm/webfetch.d.ts +1 -1
  188. package/dist/tm/webfetch.d.ts.map +1 -1
  189. package/dist/tm/webfetch.js +38 -23
  190. package/dist/tm/webfetch.js.map +1 -1
  191. package/dist/types.d.ts +40 -123
  192. package/dist/types.d.ts.map +1 -1
  193. package/dist/types.js +7 -21
  194. package/dist/types.js.map +1 -1
  195. package/docs/installation-v2.md +124 -39
  196. package/docs/installation.md +9 -0
  197. package/docs/research/browser-pane.md +5 -0
  198. package/docs/v2-port-notes.md +500 -315
  199. package/package.json +1 -1
  200. package/scripts/gen-v2-config.mjs +246 -94
  201. package/scripts/lib/fake-ctx.mjs +244 -146
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@te-river/opencode-team-mode",
3
- "version": "1.6.1",
3
+ "version": "1.7.0",
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,9 +29,10 @@
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 } from "node:fs"
32
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync, realpathSync, statSync } from "node:fs"
33
33
  import { join } from "node:path"
34
34
  import { homedir } from "node:os"
35
+ import { pathToFileURL } from "node:url"
35
36
 
36
37
  const MARKER = "generated by @te-river/opencode-team-mode"
37
38
 
@@ -51,6 +52,11 @@ const { commands } = await import(new URL("../dist/commands.js", import.meta.url
51
52
  const { triplesFromAgentPermission, V1_ONLY_TOOLS } = await import(
52
53
  new URL("../dist/host/v2-permissions.js", import.meta.url).href
53
54
  )
55
+ // Deletion goes through the repo's own safe remover: win32 `fs.rmSync` silently
56
+ // no-ops on non-ASCII paths, and a reclaim that silently failed would leave the
57
+ // duplicate role it exists to remove. Same rule as every other destructive call
58
+ // site in the plugin (src/fs-safe.ts).
59
+ const { rmForceSafe } = await import(new URL("../dist/fs-safe.js", import.meta.url).href)
54
60
 
55
61
  /** YAML is a superset of JSON, so a JSON scalar is a valid YAML scalar —
56
62
  * and the descriptions are full of em-dashes and colons that a hand-rolled
@@ -107,15 +113,15 @@ const body = (prompt) => {
107
113
  * and look like success. `test-v2-adapter` group 8 pins both halves.
108
114
  */
109
115
  const V2_TEXT = [
110
- ["tm_ptc_run program, keeping only the handle.", "`execute` (the host's Code Mode tool) program, keeping only the handle."],
111
- ["Batch the recon in one tm_ptc_run program", "Batch the recon in one `execute` program"],
112
- ["batch recon via tm_ptc_run,", "batch recon via `execute`,"],
113
- ["is ONE tm_ptc_run, not a chain.", "is ONE `execute` (Code Mode) call, not a chain."],
114
- ["multi-file batch recon → tm_ptc_run (one program,", "multi-file batch recon → `execute` (one program,"],
115
- ["your FIRST move is ONE tm_ptc_run program:", "your FIRST move is ONE `execute` (Code Mode) program:"],
116
- ["## Recon batching (PTC-first)", "## Recon batching (Code Mode first)"],
117
- ["Local-repo recon is PTC-first:", "Local-repo recon is Code-Mode-first:"],
118
- ["cross-referencing searches → ONE tm_ptc_run program", "cross-referencing searches → ONE `execute` program"],
116
+ /*
117
+ * The efficiency / file-ladder / pty sentences are NOT here any more: the
118
+ * shared source prompts now say the v2 thing natively (`execute` for batch
119
+ * recon, the native read / grep / glob / shell for retrieval, `shell
120
+ * {background:true}` for slow steps), because this package ships one
121
+ * personality and a translation table is the wrong place to hold the only
122
+ * correct wording. What is left below is what genuinely still differs
123
+ * between what the source says and what this host offers.
124
+ */
119
125
  /*
120
126
  * The delegation half of the lead prompt. On v2 the host's tool is named
121
127
  * `subagent` — measured, not inferred: a live dispatch reached
@@ -125,7 +131,9 @@ const V2_TEXT = [
125
131
  * `background` needs no operator flag — the plugin sets it on every dispatch —
126
132
  * so the "pick the shape by rule" instruction is not a choice the model has.
127
133
  */
128
- ["## Delegation — the host's task tool, and what you do while it runs", "## Delegation — the host's subagent tool, and what you do while it runs"],
134
+ /* The section heading says `subagent` in the SOURCE now (the v1 personality is
135
+ * gone, so nothing reads a v1 wording any more); the fork below still carries
136
+ * the sentences whose v1 shape differs in more than the tool name. */
129
137
  ["delegation goes through the host's `task` — governed, visible, killable.", "delegation goes through the host's `subagent` — governed, visible, killable."],
130
138
  ["you will keep following up while they run → `task { background: true }`: the", "you will keep following up while they run → `subagent { background: true }`: the"],
131
139
  ["`task`. Do not choose background when you would only park waiting for it.",
@@ -137,46 +145,10 @@ const V2_TEXT = [
137
145
  ["a background `task` names its own session id", "a background `subagent` names its own session id"],
138
146
  ["a synchronous `task` call, so chaining waits", "a synchronous `subagent` call, so chaining waits"],
139
147
  ["blocking on `task`", "blocking on `subagent`"],
140
- /*
141
- * The file ladder (the tm_read / tm_grep / tm_bash retirement). v2 does not
142
- * register those three, so every sentence that routes a role through them has
143
- * to name what is actually on the surface instead — and the native tools are
144
- * NOT ungoverned here: `execute.after` offloads their oversized results, and
145
- * the address / R6 red lines ride `permission.evaluate` (both landed BEFORE
146
- * this fork, which is the ordering the retirement requires).
147
- *
148
- * Two words are host-vocabulary, not ours: v2 names the shell tool `shell`,
149
- * and its `background` argument is documented on the native tool (AGENTS.md,
150
- * "native shell … has background:true"). The pty line is rewritten around
151
- * that, NOT around anything I have measured in a live v2 session — a v2
152
- * session has yet to make a background shell call, and #8/#9 are where that
153
- * gets checked. Until then the sentence says only "returns at once".
154
- */
155
- ["tm_grep / tm_read first, a second lookup only for what it genuinely missed.", "grep / read first, a second lookup only for what it genuinely missed."],
156
- ["All file reads / searches / enumeration go through tm_read / tm_grep / tm_bash.\nThe built-in read/grep/glob/list tools are removed from the tool surface —\nretrying them only wastes a turn. Built-in bash exists only where granted\n(team / implementer / reviewer / tester run commands: build / test / git);\narchitect and researcher have no bash at all — one-off read-only commands\ngo through tm_bash or are reported as a gap.",
157
- "File reads / searches / enumeration go through the built-in read / grep /\nglob tools — on this host they ARE the governed path: an oversized result comes\nback as a short preview plus a handle, and `tm_fetch` pages the rest, so a wide\ncall is cheap here and still the right move. Built-in shell exists only where\ngranted (team / implementer / reviewer / tester run commands: build / test /\ngit); architect and researcher have no shell at all — a one-off read-only\ncommand they cannot run is reported as a gap, not retried."],
158
- ["code search → tm_grep · enumeration and quick\n probes → tm_bash · multi-file", "code search → grep · enumeration and quick\n probes → glob / shell where granted · multi-file"],
159
- ["use the **built-in bash** tool — not tm_bash. tm_bash hard-blocks them with\nno dialog; built-in bash triggers the official confirmation dialog (once /\nalways / reject). Dangerous commands (rm / git push / npm publish / etc.)\nalways trigger the dialog regardless of tool.",
160
- "there is no second, ungoverned shell to fall back to on this host: the one\n**shell** tool IS the R6 surface, and its classifier only ever makes a rule\nstricter — an env dump or a delete asks the host, which opens its own dialog\n(once / always / reject). Dangerous commands (rm / git push / npm publish /\netc.) ask regardless of what the config said."],
161
- ["probes toward one goal — tm_read / tm_grep / tm_bash\nOR built-in bash alike — your FIRST move is ONE", "probes toward one goal — read / grep / glob / shell alike — your FIRST move is ONE"],
162
- ["Plain shell probes the governed channel cannot run\n(e.g. env-path checks, which the tm_* channel hard-blocks by\ndesign) go as ONE compound built-in bash command (`a; b; c` in a\nsingle call)",
163
- "Several cheap probes of one kind go as ONE compound `shell`\ncommand (`a; b; c` in a\nsingle call)"],
164
- ["searches are PTC work;", "searches are one-program work;"],
165
- ["(tm.read / tm.grep / tm.bash ride inside; ALWAYS `return` the", "(the built-in read / grep / shell tools are callable inside it; ALWAYS `return` the"],
166
- ["(or one PTC program), never in two.", "(or one `execute` program), never in two."],
167
- ["run one cheap tm_grep. Do NOT pull big payloads", "run one cheap grep. Do NOT pull big payloads"],
168
- ["or tm_grep + tm_bash yourself)", "or grep + shell yourself)"],
169
- ["into its OWN tm_pty session (returns at once, the user sees the terminal,\n every start passes the official dialog) rather than being chained with\n `;` behind one long bash call. tm_pty returns no transcript — tee it to\n a log and read that log when it reports exited.",
170
- "into its OWN separate `shell` call rather than being chained with\n `;` behind one long shell call — and when the step is genuinely slow, use\n `shell {background:true}`: the ack gives a shell ID plus the file its output\n streams to, and the host notifies on exit (do NOT poll for it). Have it write\n its own log file and read that log when it reports exited — do not re-run the\n command to see its output."],
171
- ["probes via bash where granted) BEFORE answering from memory.", "probes via shell where granted) BEFORE answering from memory."],
172
148
  ["FIRST there, because that is the only path with the domain allowlist, the\nper-request dialog and the R6 red lines; an MCP fetcher of the same page\nsilently skips all three (and dumps raw HTML into your context). Fall to a",
173
149
  "FIRST there, because that is the only path with the address red line (no\nmetadata / private-range fetch) and the threshold offload; an MCP fetcher of the\nsame page silently skips both (and dumps raw HTML into your context). Fall to a"],
174
- ["- Files/docs → tm_read · code search →", "- Files/docs → read · code search →"],
175
- [" --help) → built-in bash where granted ·", " --help) → `shell` where granted ·"],
176
- ["## PTC batch orchestration", "## Batch orchestration (Code Mode first)"],
177
150
  ["file tool at all (architect and researcher have no write/edit/bash; reviewer\n has only the read-only bash, which refuses redirection). Pass the `session`",
178
151
  "file tool at all (architect and researcher have no write / edit / shell, and\n reviewer's shell is the host's own, classified per command). Pass the `session`"],
179
- ["- The host stops a bash command after 120 s unless you pass a larger", "- The host stops a shell command after 120 s unless you pass a larger"],
180
152
  /*
181
153
  * The LEDGER. v2 gives a plugin no `todowrite` (V2_ONLY_ACTIONS names it as a
182
154
  * key with no v2 counterpart), so the mandate's tool is the one this plugin
@@ -202,33 +174,12 @@ const V2_TEXT = [
202
174
  ["Do NOT store task state or oversized content there — todo list and\nboard files own those.",
203
175
  "Do NOT store task state or oversized content there — the lead's `tm_ledger`\nand board files own those."],
204
176
  ["dropped: the list is the user's audit surface (pending / in_progress /\n completed / blocked)", "dropped: the list is the user's audit surface (open / doing /\n done / blocked — `tm_ledger` keeps the states, and `blocked` carries the note\n naming what blocked it)"],
205
- /* tm_pty is v1-only on purpose: `client.pty` does not exist on the v2 plugin
206
- * ctx, so the tool could only ever answer "pty 接口不可用". The v2 rules now DO name a
207
- * replacement, because #28 measured it out of the 2.0.16 build
208
- * (docs/research/v2-builtin-tools.md §2.5): `shell` takes `background:true`, the ack is
209
- * "Command moved to the background (shell ID: X)" plus the file the output streams to,
210
- * the host says DO NOT poll, and completion returns later as a synthetic
211
- * `<shell id=… state=… command=…>` message. `shell` and `subagent` are the ONLY two
212
- * built-ins with a background parameter, which is also why tm_pty stays retired: the pty
213
- * REST surface is still absent, and the native path has no per-command dialog (and its
214
- * completion envelope re-enters context carrying the command text — an R6 touchpoint). */
215
- ["either: give each its own call, or run it through tm_pty (non-blocking,\n where granted) and check `status` later. A tm_pty session writes no\n transcript back to you, so tee its output to a file (`<cmd> 2>&1 | tee\n <log>`) and read that file for EVIDENCE once it reports exited.",
216
- "either: give each its own call, or run it through `shell` with\n `background:true` (it returns at once with a shell ID and the file its output\n streams to, and the host notifies you when it exits — so do NOT poll, and never\n re-run the command to watch it pass again). Either way tee the output\n (`<cmd> 2>&1 | tee\n <log>`) and read that log for EVIDENCE."],
217
- /* Option A (user decision 2026-09-25): interactive browsing goes to the host's own
218
- * browser_* tools, because that is the only browser the desktop renders in its side
219
- * panel (docs/research/browser-pane.md). Governance is NOT taken off the table: the gate
220
- * lives in src/host/v2-browser-gate.ts (execute.before + a leak-detected fallback that
221
- * replaces the page with the refusal), and snapshots stay addressable through
222
- * capKeepingAddressing. tm_browser stays named as the fallback for a host with no
223
- * desktop browser (CLI / standalone server). Every key below must match the v1 prompt
224
- * byte-exactly: assertForkApplied() throws when one stops matching. */
225
- [" URLs → tm_webfetch, JS-rendered pages → tm_browser (network roles only).", " URLs → tm_webfetch, JS-rendered pages → the host's own browser_* tools (they drive\n the side panel the user can actually see), falling back to tm_browser when no desktop browser is connected (network roles only)."],
226
- ["- a visual state (a rendered UI, a chart) → tm_browser", "- a visual state (a rendered UI, a chart) → the host's browser_* tools (browser_tabs_open → browser_snapshot → browser_screenshot), or tm_browser when they are not offered"],
227
- ["## UI verification (tm_browser — you carry it)", "## UI verification (the host's browser_* tools — you carry them)"],
228
- ["For user-visible frontend changes, verify through the governed tm_browser", "For user-visible frontend changes, verify through the host's own browser_* tools (tm_browser only when they are absent)"],
229
- ["If tm_browser is unavailable on this host, the action you need is not", "If neither browser_* nor tm_browser is available on this host, the action you need is not"],
230
- [" via your governed tm_search / tm_browser / tm_webfetch first,", " via your governed tm_search / tm_webfetch and the host's browser_* tools (or tm_browser when no desktop browser is connected) first,"],
231
- [" lookups: governed tm_search / tm_browser / tm_webfetch first, then user MCP tools.", " lookups: governed tm_search / tm_webfetch + the host's browser_* tools first, then user MCP tools."],
177
+ /* The browser fork entries are GONE (user decision 2026-10-06): the self-built
178
+ * tm_browser was removed outright, so the shared source prompts now name the
179
+ * host's native browser_* tools natively and there is nothing left to fork.
180
+ * Governance is unchanged: the gate lives in src/host/v2-browser-gate.ts
181
+ * (execute.before + a leak-detected fallback that replaces the page with the
182
+ * refusal), and snapshots stay addressable through capKeepingAddressing. */
232
183
  ]
233
184
 
234
185
  function forkBody(text, hits) {
@@ -278,27 +229,228 @@ function emit(dir, name, content) {
278
229
  written.push(file)
279
230
  }
280
231
 
281
- const agentsDir = join(baseDir, "agents")
282
- const forkHits = new Set()
283
- for (const [id, cfg] of Object.entries(agents)) {
284
- const { triples } = triplesFromAgentPermission(cfg.permission, { escalateShellAsk: false, agentName: id })
285
- // An `allow` for a tool v2 never registers would claim a capability that does
286
- // not exist; V1_ONLY_TOOLS is the same set v2.ts skips when registering.
287
- const v2Triples = triples.filter((t) => !V1_ONLY_TOOLS.has(t.action))
288
- const text = forkBody(body(cfg.prompt), forkHits)
289
- emit(agentsDir, id, agentFrontmatter(cfg, v2Triples) + text + "\n")
232
+ const reclaimed = []
233
+ const keptForeign = []
234
+ const caseNotes = []
235
+ const unverified = []
236
+
237
+ /** Windows is case-insensitive: `team.md` and `Team.md` are ONE file, and
238
+ * `realpathSync` echoes the case it was GIVEN rather than the on-disk case, so
239
+ * a real-path comparison must fold case on win32 — otherwise the reclaim would
240
+ * delete the very file it just wrote. */
241
+ const normPath = (p) => (process.platform === "win32" ? p.toLowerCase() : p)
242
+
243
+ /**
244
+ * The identity of a file as the OS reports it, or null when `statSync` fails.
245
+ * `dev` + `ino` together name a file on POSIX and on macOS (APFS), where the
246
+ * inode is stable across case spellings of the same name. Windows is the
247
+ * exception: some Node/FS combinations report `ino: 0` for every file, so a
248
+ * zero inode is treated as "unavailable" rather than as a real identity.
249
+ */
250
+ export function statIdentity(p) {
251
+ try {
252
+ const st = statSync(p)
253
+ return { dev: Number(st.dev), ino: Number(st.ino) }
254
+ } catch {
255
+ return null
256
+ }
290
257
  }
291
- assertForkApplied(forkHits)
292
258
 
293
- const commandsDir = join(baseDir, "commands")
294
- for (const [name, cfg] of Object.entries(commands)) {
295
- emit(commandsDir, name, commandFrontmatter(cfg) + body(cfg.template) + "\n")
259
+ /**
260
+ * Pure file-identity verdict from two stat identities (or null when a stat
261
+ * failed). Returns:
262
+ * "same" — dev+ino both match: the two paths ARE one file
263
+ * "different" — dev+ino both usable and differ: two distinct files
264
+ * "fallback" — inode unavailable (Windows reports 0): the caller may fall
265
+ * back to a case-folded real-path comparison
266
+ * "unknown" — inode unavailable on a non-Windows platform: we CANNOT tell,
267
+ * so the caller must keep the file (fail-safe)
268
+ *
269
+ * The macOS trap this exists for: APFS is case-INSENSITIVE but case-PRESERVING,
270
+ * so `Team.md` and `team.md` are one file while `realpathSync` may echo the
271
+ * spelling it was given. A real-path comparison would then call them different
272
+ * and delete the file that was just written. dev+ino is the platform-neutral
273
+ * answer; when it is unavailable we refuse to delete rather than guess (#48).
274
+ */
275
+ export function identityVerdict(a, b, platform) {
276
+ const usable = (x) => x != null && Number.isFinite(x.ino) && x.ino !== 0
277
+ if (usable(a) && usable(b)) {
278
+ return a.dev === b.dev && a.ino === b.ino ? "same" : "different"
279
+ }
280
+ return platform === "win32" ? "fallback" : "unknown"
296
281
  }
297
282
 
298
- console.log(`目标:${agentsDir} 与 ${commandsDir}`)
299
- for (const f of written) console.log(printOnly ? `将写入 ${f}` : `已写入 ${f}`)
300
- for (const s of skipped) console.log(`跳过 ${s}`)
301
- console.log(
302
- `\n共 ${written.length} 个文件(角色 ${Object.keys(agents).length} + 命令 ${Object.keys(commands).length})` +
303
- `${printOnly ? "(--print,未落盘)" : ""}。重启桌面端(或 opencode reload)后 Team 出现在 agent 选择器、/team-* 出现在命令列表。`,
304
- )
283
+ /**
284
+ * After a deletion, every file in the keep set must still be on disk. A
285
+ * vanished target means the reclaim deleted the file it just wrote — the
286
+ * install is broken and must NOT look like success, so the caller prints the
287
+ * error and exits non-zero. Pure over the `exists` predicate so it is testable
288
+ * without touching the filesystem.
289
+ */
290
+ export function verifyKeepIntact(keepPaths, exists = existsSync) {
291
+ const missing = keepPaths.filter((p) => !exists(p))
292
+ return { ok: missing.length === 0, missing }
293
+ }
294
+
295
+ /**
296
+ * The #48 fail-safe's reporting half: a target that vanished during the reclaim
297
+ * means the install is broken, so it must be LOUD and must not exit 0. Exported
298
+ * so the test can drive the exact sentence and the exit code without having to
299
+ * manufacture a filesystem that defeats the identity guard.
300
+ */
301
+ export function reportKeepLoss(missing) {
302
+ console.error(`错误:回收后目标文件消失,安装已中止(#48 fail-safe):${missing.join(", ")}`)
303
+ process.exitCode = 1
304
+ }
305
+
306
+ /**
307
+ * Reclaim role/command files this generator wrote in a PREVIOUS run but no
308
+ * longer emits — the `team.md` → `Team.md` rename (#47) is the case that
309
+ * motivated it. On a case-sensitive filesystem (Linux) the old lowercase file
310
+ * survives beside the new one and the host loads TWO Team roles; on a
311
+ * case-insensitive filesystem (Windows, and macOS/APFS by default) the two names
312
+ * are one file, so there is nothing to reclaim and deleting would delete the
313
+ * target.
314
+ *
315
+ * Strict rules:
316
+ * - only a file carrying MARKER is ours to remove; a hand-written file with a
317
+ * stale name is left alone and REPORTED, never silently kept.
318
+ * - "is this the file we just wrote?" is answered by dev+ino first (reliable on
319
+ * POSIX and macOS), then by a case-folded real path on win32 only. When
320
+ * neither can answer, the file is KEPT and reported — a duplicate role is a
321
+ * far smaller failure than a deleted target (#48).
322
+ * - after every deletion the keep set is re-checked; a vanished target prints
323
+ * an error and sets a non-zero exit code.
324
+ * - deletion goes through rmForceSafe (dist/fs-safe.js): win32 fs.rmSync
325
+ * silently no-ops on non-ASCII paths, and a reclaim that silently failed
326
+ * would leave the duplicate role it exists to remove.
327
+ */
328
+ function reclaimStale(dir, keepNames, label) {
329
+ if (!existsSync(dir)) return
330
+ const keep = new Set(keepNames.map((n) => `${n}.md`))
331
+ const keepPaths = []
332
+ const keepId = new Map()
333
+ for (const n of keep) {
334
+ const p = join(dir, n)
335
+ if (!existsSync(p)) continue
336
+ keepPaths.push(p)
337
+ keepId.set(n, statIdentity(p))
338
+ }
339
+ for (const entry of readdirSync(dir)) {
340
+ if (!entry.endsWith(".md")) continue
341
+ if (keep.has(entry)) continue
342
+ const file = join(dir, entry)
343
+ const fileId = statIdentity(file)
344
+ // Decide "same file as a target?" per keep entry, most reliable test first.
345
+ let sameAs = null
346
+ let undecidable = false
347
+ for (const n of keep) {
348
+ if (!keepId.has(n)) continue
349
+ const verdict = identityVerdict(fileId, keepId.get(n), process.platform)
350
+ if (verdict === "same") {
351
+ sameAs = n
352
+ break
353
+ }
354
+ if (verdict === "fallback") {
355
+ // win32 with no usable inode: compare case-folded real paths.
356
+ try {
357
+ if (normPath(realpathSync(file)) === normPath(realpathSync(join(dir, n)))) {
358
+ sameAs = n
359
+ break
360
+ }
361
+ } catch {
362
+ undecidable = true
363
+ }
364
+ } else if (verdict === "unknown") {
365
+ undecidable = true
366
+ }
367
+ }
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} 是同一个文件(本机不区分大小写),未删除`)
372
+ continue
373
+ }
374
+ if (undecidable) {
375
+ // Neither dev+ino nor a real-path comparison could decide. Fail safe:
376
+ // keep the file and say so — a duplicate role beats a deleted target.
377
+ unverified.push(`保留 ${file} — 无法确认是否与目标 ${[...keep].join(" / ")} 是同一文件,保留不删`)
378
+ continue
379
+ }
380
+ let current
381
+ try {
382
+ current = readFileSync(file, "utf8")
383
+ } catch {
384
+ continue
385
+ }
386
+ if (!current.includes(MARKER)) {
387
+ keptForeign.push(`保留 ${file} — 不是我生成的(无标记),请自行决定`)
388
+ continue
389
+ }
390
+ if (printOnly) {
391
+ reclaimed.push(`回收 ${file} — 本次不再生成该${label}(--print,未落盘)`)
392
+ continue
393
+ }
394
+ rmForceSafe(file)
395
+ if (existsSync(file)) {
396
+ reclaimed.push(`回收失败 ${file} — 仍在磁盘上,请手动删除`)
397
+ } else {
398
+ reclaimed.push(`回收 ${file} — 本次不再生成该${label}(旧文件已清理)`)
399
+ }
400
+ // #48: a deletion must never take a target with it. Re-check the keep set
401
+ // after EVERY real deletion; a vanished target is a broken install.
402
+ const intact = verifyKeepIntact(keepPaths)
403
+ if (!intact.ok) {
404
+ reportKeepLoss(intact.missing)
405
+ return
406
+ }
407
+ }
408
+ }
409
+
410
+ function main() {
411
+ const agentsDir = join(baseDir, "agents")
412
+ const forkHits = new Set()
413
+ for (const [id, cfg] of Object.entries(agents)) {
414
+ const { triples } = triplesFromAgentPermission(cfg.permission, { escalateShellAsk: false, agentName: id })
415
+ // An `allow` for a tool v2 never registers would claim a capability that does
416
+ // not exist; V1_ONLY_TOOLS is the same set v2.ts skips when registering.
417
+ const v2Triples = triples.filter((t) => !V1_ONLY_TOOLS.has(t.action))
418
+ const text = forkBody(body(cfg.prompt), forkHits)
419
+ emit(agentsDir, id, agentFrontmatter(cfg, v2Triples) + text + "\n")
420
+ }
421
+ assertForkApplied(forkHits)
422
+ // Reclaim role files a previous run wrote under a name this run no longer emits
423
+ // (the `team.md` → `Team.md` rename, #47). Runs AFTER the writes so the keep
424
+ // set is the current output, and the identity guard keeps it from deleting a
425
+ // file it just wrote on a case-insensitive filesystem.
426
+ reclaimStale(agentsDir, Object.keys(agents), "角色")
427
+
428
+ const commandsDir = join(baseDir, "commands")
429
+ for (const [name, cfg] of Object.entries(commands)) {
430
+ emit(commandsDir, name, commandFrontmatter(cfg) + body(cfg.template) + "\n")
431
+ }
432
+ // Same pass for commands. No command name has changed yet, so this is a no-op
433
+ // today — it is here so a future rename is closed by the same mechanism rather
434
+ // than by a second, hand-written reclaim that could drift from this one.
435
+ reclaimStale(commandsDir, Object.keys(commands), "命令")
436
+
437
+ console.log(`目标:${agentsDir} 与 ${commandsDir}`)
438
+ for (const f of written) console.log(printOnly ? `将写入 ${f}` : `已写入 ${f}`)
439
+ for (const s of skipped) console.log(`跳过 ${s}`)
440
+ for (const r of reclaimed) console.log(r)
441
+ for (const k of keptForeign) console.log(k)
442
+ for (const c of caseNotes) console.log(c)
443
+ for (const u of unverified) console.log(u)
444
+ console.log(
445
+ `\n共 ${written.length} 个文件(角色 ${Object.keys(agents).length} + 命令 ${Object.keys(commands).length})` +
446
+ `${printOnly ? "(--print,未落盘)" : ""}。重启桌面端(或 opencode reload)后 Team 出现在 agent 选择器、/team-* 出现在命令列表。`,
447
+ )
448
+ if (unverified.length) {
449
+ console.log(`注意:${unverified.length} 个文件无法确认是否与目标同一文件,已保留不删(#48 fail-safe)。`)
450
+ }
451
+ }
452
+
453
+ // Run only when invoked as a script. Importing this module (the test suite
454
+ // does, to exercise the pure identity helpers) must NOT write to the user's
455
+ // real config directory — the default `--dir` is `~/.config/opencode`.
456
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main()