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

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 (196) hide show
  1. package/README.md +853 -690
  2. package/README.zh-CN.md +761 -631
  3. package/dist/agents.d.ts +5 -3
  4. package/dist/agents.d.ts.map +1 -1
  5. package/dist/agents.js +19 -8
  6. package/dist/agents.js.map +1 -1
  7. package/dist/capabilities.d.ts +1 -1
  8. package/dist/capabilities.js +2 -2
  9. package/dist/capabilities.js.map +1 -1
  10. package/dist/host/note.d.ts +7 -0
  11. package/dist/host/note.d.ts.map +1 -0
  12. package/dist/host/note.js +28 -0
  13. package/dist/host/note.js.map +1 -0
  14. package/dist/host/v1.d.ts +14 -0
  15. package/dist/host/v1.d.ts.map +1 -0
  16. package/dist/host/v1.js +340 -0
  17. package/dist/host/v1.js.map +1 -0
  18. package/dist/host/v2-browser-gate.d.ts +45 -0
  19. package/dist/host/v2-browser-gate.d.ts.map +1 -0
  20. package/dist/host/v2-browser-gate.js +319 -0
  21. package/dist/host/v2-browser-gate.js.map +1 -0
  22. package/dist/host/v2-capabilities.d.ts +68 -0
  23. package/dist/host/v2-capabilities.d.ts.map +1 -0
  24. package/dist/host/v2-capabilities.js +146 -0
  25. package/dist/host/v2-capabilities.js.map +1 -0
  26. package/dist/host/v2-client.d.ts +53 -0
  27. package/dist/host/v2-client.d.ts.map +1 -0
  28. package/dist/host/v2-client.js +193 -0
  29. package/dist/host/v2-client.js.map +1 -0
  30. package/dist/host/v2-events.d.ts +56 -0
  31. package/dist/host/v2-events.d.ts.map +1 -0
  32. package/dist/host/v2-events.js +158 -0
  33. package/dist/host/v2-events.js.map +1 -0
  34. package/dist/host/v2-guard.d.ts +101 -0
  35. package/dist/host/v2-guard.d.ts.map +1 -0
  36. package/dist/host/v2-guard.js +199 -0
  37. package/dist/host/v2-guard.js.map +1 -0
  38. package/dist/host/v2-offload.d.ts +148 -0
  39. package/dist/host/v2-offload.d.ts.map +1 -0
  40. package/dist/host/v2-offload.js +288 -0
  41. package/dist/host/v2-offload.js.map +1 -0
  42. package/dist/host/v2-permissions.d.ts +123 -0
  43. package/dist/host/v2-permissions.d.ts.map +1 -0
  44. package/dist/host/v2-permissions.js +190 -0
  45. package/dist/host/v2-permissions.js.map +1 -0
  46. package/dist/host/v2-probe.d.ts +85 -0
  47. package/dist/host/v2-probe.d.ts.map +1 -0
  48. package/dist/host/v2-probe.js +268 -0
  49. package/dist/host/v2-probe.js.map +1 -0
  50. package/dist/host/v2-scope.d.ts +51 -0
  51. package/dist/host/v2-scope.d.ts.map +1 -0
  52. package/dist/host/v2-scope.js +58 -0
  53. package/dist/host/v2-scope.js.map +1 -0
  54. package/dist/host/v2-session-client.d.ts +74 -0
  55. package/dist/host/v2-session-client.d.ts.map +1 -0
  56. package/dist/host/v2-session-client.js +171 -0
  57. package/dist/host/v2-session-client.js.map +1 -0
  58. package/dist/host/v2-session.d.ts +85 -0
  59. package/dist/host/v2-session.d.ts.map +1 -0
  60. package/dist/host/v2-session.js +171 -0
  61. package/dist/host/v2-session.js.map +1 -0
  62. package/dist/host/v2-subagent.d.ts +157 -0
  63. package/dist/host/v2-subagent.d.ts.map +1 -0
  64. package/dist/host/v2-subagent.js +286 -0
  65. package/dist/host/v2-subagent.js.map +1 -0
  66. package/dist/host/v2-tool.d.ts +67 -0
  67. package/dist/host/v2-tool.d.ts.map +1 -0
  68. package/dist/host/v2-tool.js +209 -0
  69. package/dist/host/v2-tool.js.map +1 -0
  70. package/dist/host/v2-transcript.d.ts +73 -0
  71. package/dist/host/v2-transcript.d.ts.map +1 -0
  72. package/dist/host/v2-transcript.js +151 -0
  73. package/dist/host/v2-transcript.js.map +1 -0
  74. package/dist/host/v2-types.d.ts +229 -0
  75. package/dist/host/v2-types.d.ts.map +1 -0
  76. package/dist/host/v2-types.js +17 -0
  77. package/dist/host/v2-types.js.map +1 -0
  78. package/dist/host/v2.d.ts +42 -0
  79. package/dist/host/v2.d.ts.map +1 -0
  80. package/dist/host/v2.js +706 -0
  81. package/dist/host/v2.js.map +1 -0
  82. package/dist/host-hooks.d.ts +13 -2
  83. package/dist/host-hooks.d.ts.map +1 -1
  84. package/dist/host-hooks.js +18 -5
  85. package/dist/host-hooks.js.map +1 -1
  86. package/dist/index.d.ts +23 -17
  87. package/dist/index.d.ts.map +1 -1
  88. package/dist/index.js +29 -326
  89. package/dist/index.js.map +1 -1
  90. package/dist/prompts/lead.d.ts +1 -1
  91. package/dist/prompts/lead.d.ts.map +1 -1
  92. package/dist/prompts/lead.js +452 -359
  93. package/dist/prompts/lead.js.map +1 -1
  94. package/dist/prompts/shared.d.ts +2 -2
  95. package/dist/prompts/shared.d.ts.map +1 -1
  96. package/dist/prompts/shared.js +89 -17
  97. package/dist/prompts/shared.js.map +1 -1
  98. package/dist/prompts/specialists.d.ts +2 -2
  99. package/dist/prompts/specialists.d.ts.map +1 -1
  100. package/dist/prompts/specialists.js +44 -10
  101. package/dist/prompts/specialists.js.map +1 -1
  102. package/dist/task-offload.d.ts +83 -0
  103. package/dist/task-offload.d.ts.map +1 -0
  104. package/dist/task-offload.js +174 -0
  105. package/dist/task-offload.js.map +1 -0
  106. package/dist/tm/args-schema.d.ts +4 -0
  107. package/dist/tm/args-schema.d.ts.map +1 -1
  108. package/dist/tm/args-schema.js +36 -3
  109. package/dist/tm/args-schema.js.map +1 -1
  110. package/dist/tm/board.d.ts +113 -0
  111. package/dist/tm/board.d.ts.map +1 -0
  112. package/dist/tm/board.js +275 -0
  113. package/dist/tm/board.js.map +1 -0
  114. package/dist/tm/browser.d.ts +325 -6
  115. package/dist/tm/browser.d.ts.map +1 -1
  116. package/dist/tm/browser.js +1360 -135
  117. package/dist/tm/browser.js.map +1 -1
  118. package/dist/tm/config.d.ts +36 -25
  119. package/dist/tm/config.d.ts.map +1 -1
  120. package/dist/tm/config.js +29 -7
  121. package/dist/tm/config.js.map +1 -1
  122. package/dist/tm/dispatch.d.ts +133 -53
  123. package/dist/tm/dispatch.d.ts.map +1 -1
  124. package/dist/tm/dispatch.js +481 -279
  125. package/dist/tm/dispatch.js.map +1 -1
  126. package/dist/tm/dupe-guard.d.ts +16 -3
  127. package/dist/tm/dupe-guard.d.ts.map +1 -1
  128. package/dist/tm/dupe-guard.js +27 -7
  129. package/dist/tm/dupe-guard.js.map +1 -1
  130. package/dist/tm/egress.d.ts +44 -0
  131. package/dist/tm/egress.d.ts.map +1 -0
  132. package/dist/tm/egress.js +191 -0
  133. package/dist/tm/egress.js.map +1 -0
  134. package/dist/tm/guard.js +1 -1
  135. package/dist/tm/guard.js.map +1 -1
  136. package/dist/tm/index.d.ts +67 -2
  137. package/dist/tm/index.d.ts.map +1 -1
  138. package/dist/tm/index.js +228 -20
  139. package/dist/tm/index.js.map +1 -1
  140. package/dist/tm/ledger.d.ts +114 -0
  141. package/dist/tm/ledger.d.ts.map +1 -0
  142. package/dist/tm/ledger.js +308 -0
  143. package/dist/tm/ledger.js.map +1 -0
  144. package/dist/tm/perm-ask.d.ts +45 -5
  145. package/dist/tm/perm-ask.d.ts.map +1 -1
  146. package/dist/tm/perm-ask.js +107 -7
  147. package/dist/tm/perm-ask.js.map +1 -1
  148. package/dist/tm/pipelines.d.ts.map +1 -1
  149. package/dist/tm/pipelines.js +24 -2
  150. package/dist/tm/pipelines.js.map +1 -1
  151. package/dist/tm/preview.d.ts +55 -0
  152. package/dist/tm/preview.d.ts.map +1 -1
  153. package/dist/tm/preview.js +225 -0
  154. package/dist/tm/preview.js.map +1 -1
  155. package/dist/tm/ptc/tool.d.ts.map +1 -1
  156. package/dist/tm/ptc/tool.js +2 -4
  157. package/dist/tm/ptc/tool.js.map +1 -1
  158. package/dist/tm/pty.d.ts.map +1 -1
  159. package/dist/tm/pty.js +6 -3
  160. package/dist/tm/pty.js.map +1 -1
  161. package/dist/tm/search.d.ts +5 -0
  162. package/dist/tm/search.d.ts.map +1 -1
  163. package/dist/tm/search.js +40 -8
  164. package/dist/tm/search.js.map +1 -1
  165. package/dist/tm/serp-loop.d.ts +45 -0
  166. package/dist/tm/serp-loop.d.ts.map +1 -0
  167. package/dist/tm/serp-loop.js +80 -0
  168. package/dist/tm/serp-loop.js.map +1 -0
  169. package/dist/tm/stats.d.ts +26 -0
  170. package/dist/tm/stats.d.ts.map +1 -1
  171. package/dist/tm/stats.js +194 -4
  172. package/dist/tm/stats.js.map +1 -1
  173. package/dist/tm/webfetch.d.ts +56 -0
  174. package/dist/tm/webfetch.d.ts.map +1 -1
  175. package/dist/tm/webfetch.js +187 -19
  176. package/dist/tm/webfetch.js.map +1 -1
  177. package/dist/tool-coerce.d.ts +24 -0
  178. package/dist/tool-coerce.d.ts.map +1 -0
  179. package/dist/tool-coerce.js +63 -0
  180. package/dist/tool-coerce.js.map +1 -0
  181. package/dist/types.d.ts +7 -1
  182. package/dist/types.d.ts.map +1 -1
  183. package/docs/installation-v2.md +436 -0
  184. package/docs/installation.md +81 -2
  185. package/docs/research/agent-data-exchange.md +117 -0
  186. package/docs/research/browser-pane.md +89 -0
  187. package/docs/research/host-http-api.md +132 -0
  188. package/docs/research/host-subagent-injection.md +187 -0
  189. package/docs/research/plugin-loader-contract.md +261 -0
  190. package/docs/research/v2-builtin-tools.md +447 -0
  191. package/docs/v2-port-notes.md +315 -0
  192. package/index.js +7 -0
  193. package/package.json +4 -1
  194. package/scripts/gen-v2-config.mjs +304 -0
  195. package/scripts/lib/config-surgery.cjs +239 -0
  196. package/scripts/lib/fake-ctx.mjs +146 -0
@@ -0,0 +1,304 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Project the team's roles AND slash commands into OpenCode v2 config files.
4
+ *
5
+ * A v2 plugin cannot create an agent — `AgentEditor` exposes only
6
+ * list/get/default/update/remove (docs/v2/build/plugins, Agent reference) — and
7
+ * `ctx.command.transform.add` was measured NOT to reach the UI on 2.0.16, so
8
+ * both have to live where the built-in Build and Plan agents do:
9
+ *
10
+ * ~/.config/opencode/agents/<name>.md frontmatter + body = system prompt
11
+ * ~/.config/opencode/commands/<name>.md frontmatter + body = prompt template
12
+ *
13
+ * Everything here is a projection of `src/agents.ts` / `src/commands.ts`
14
+ * through the built `dist/`, including the prompt strings, so the markdown
15
+ * cannot drift from what the v1 personality injects.
16
+ *
17
+ * ONE THING THIS CANNOT VERIFY: the v1 commands carry `agent: architect` (etc.)
18
+ * and that works on v1. v2 documents `mode: subagent` as "runs ONLY in a child
19
+ * session through the subagent tool", so a command selecting a subagent-only
20
+ * role may not run as that role. If the live host says so, the fix is one value
21
+ * in src/agents.ts — `mode: "all"` runs either way, and T3 ("only the lead
22
+ * dispatches") is held by the specialists' `subagent: deny` rule rather than by
23
+ * `mode`, so switching costs the picker five extra entries and nothing else.
24
+ *
25
+ * Usage:
26
+ * node scripts/gen-v2-config.mjs [--dir <path>] [--print] [--force]
27
+ *
28
+ * `--dir` is the base; `agents/` and `commands/` are created under it.
29
+ * Refuses to touch a file it did not generate unless --force is given.
30
+ */
31
+
32
+ import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs"
33
+ import { join } from "node:path"
34
+ import { homedir } from "node:os"
35
+
36
+ const MARKER = "generated by @te-river/opencode-team-mode"
37
+
38
+ const argv = process.argv.slice(2)
39
+ const flag = (name) => argv.includes(`--${name}`)
40
+ const opt = (name) => {
41
+ const i = argv.indexOf(`--${name}`)
42
+ return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[i + 1] : undefined
43
+ }
44
+
45
+ const baseDir = opt("dir") ?? join(homedir(), ".config", "opencode")
46
+ const printOnly = flag("print")
47
+ const force = flag("force")
48
+
49
+ const { agents } = await import(new URL("../dist/agents.js", import.meta.url).href)
50
+ const { commands } = await import(new URL("../dist/commands.js", import.meta.url).href)
51
+ const { triplesFromAgentPermission, V1_ONLY_TOOLS } = await import(
52
+ new URL("../dist/host/v2-permissions.js", import.meta.url).href
53
+ )
54
+
55
+ /** YAML is a superset of JSON, so a JSON scalar is a valid YAML scalar —
56
+ * and the descriptions are full of em-dashes and colons that a hand-rolled
57
+ * quote would get wrong. */
58
+ const scalar = (v) => JSON.stringify(String(v))
59
+
60
+ function agentFrontmatter(cfg, triples) {
61
+ const lines = [`---`, `# ${MARKER} — edit src/agents.ts and re-run the generator instead.`]
62
+ if (cfg.description) lines.push(`description: ${scalar(cfg.description)}`)
63
+ lines.push(`mode: ${scalar(cfg.mode ?? "subagent")}`)
64
+ if (cfg.color) lines.push(`color: ${scalar(cfg.color)}`)
65
+ if (triples.length) {
66
+ lines.push("permissions:")
67
+ for (const t of triples) {
68
+ lines.push(` - action: ${scalar(t.action)}`)
69
+ lines.push(` resource: ${scalar(t.resource)}`)
70
+ lines.push(` effect: ${scalar(t.effect)}`)
71
+ }
72
+ }
73
+ lines.push("---", "")
74
+ return lines.join("\n")
75
+ }
76
+
77
+ /** v2's command markdown takes `description` / `agent` / `model` / `subagent` in
78
+ * frontmatter and the template as the body — deliberately NO `template` key,
79
+ * which the docs forbid because the body supplies it. */
80
+ function commandFrontmatter(cfg) {
81
+ const lines = [`---`, `# ${MARKER} — edit src/commands.ts and re-run the generator instead.`]
82
+ if (cfg.description) lines.push(`description: ${scalar(cfg.description)}`)
83
+ if (cfg.agent) lines.push(`agent: ${scalar(cfg.agent)}`)
84
+ lines.push("---", "")
85
+ return lines.join("\n")
86
+ }
87
+
88
+ const body = (prompt) => {
89
+ const text = String(prompt ?? "").trimEnd()
90
+ // A body that opens with `---` would read as a second frontmatter block.
91
+ return text.startsWith("---") ? text.replace(/^---/, "\\---") : text
92
+ }
93
+
94
+ /**
95
+ * The personality fork, in one place.
96
+ *
97
+ * v2 does not register `tm_ptc_run` — the host's own `execute` (Code Mode)
98
+ * already runs "one program, N tool calls, zero round-trips, only the aggregate
99
+ * entering the context", and a live session proved the governed tm_* results
100
+ * still come back offloaded through it. v1 has no Code Mode, so it keeps the
101
+ * tool and keeps these sentences untouched.
102
+ *
103
+ * A prompt that names a tool the model cannot call is not a style problem — it
104
+ * costs the round the mandate exists to save — so the v2 markdown says what v2
105
+ * can actually do. Every key below MUST be found in the shared source or the
106
+ * generator fails: a silent no-op here would ship the v1 sentence to a v2 model
107
+ * and look like success. `test-v2-adapter` group 8 pins both halves.
108
+ */
109
+ 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"],
119
+ /*
120
+ * The delegation half of the lead prompt. On v2 the host's tool is named
121
+ * `subagent` — measured, not inferred: a live dispatch reached
122
+ * `execute.before {tool:"subagent"}` and `permission.evaluate {action:"subagent"}`.
123
+ * A mandate that names `task` points at a tool the role cannot call, which costs
124
+ * the round it exists to save (the tm_ptc_run lesson, same shape). And on v2
125
+ * `background` needs no operator flag — the plugin sets it on every dispatch —
126
+ * so the "pick the shape by rule" instruction is not a choice the model has.
127
+ */
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"],
129
+ ["delegation goes through the host's `task` — governed, visible, killable.", "delegation goes through the host's `subagent` — governed, visible, killable."],
130
+ ["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
+ ["`task`. Do not choose background when you would only park waiting for it.",
132
+ "`subagent`. On this host the plugin forces `background: true` on EVERY dispatch, so this is not a choice you make, and the operator flag v1 needed does not exist here."],
133
+ ["Background needs the operator flag `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true`;",
134
+ "Assume the card is asynchronous: say who is running instead of pretending to block."],
135
+ ["without it `task` only blocks, so say so rather than pretending to overlap.",
136
+ "There is no flag to check and no synchronous fallback to fall back on."],
137
+ ["a background `task` names its own session id", "a background `subagent` names its own session id"],
138
+ ["a synchronous `task` call, so chaining waits", "a synchronous `subagent` call, so chaining waits"],
139
+ ["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
+ ["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
+ "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
+ ["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
+ "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
+ /*
181
+ * The LEDGER. v2 gives a plugin no `todowrite` (V2_ONLY_ACTIONS names it as a
182
+ * key with no v2 counterpart), so the mandate's tool is the one this plugin
183
+ * registers instead — `tm_ledger`, stored in the host's own ctx.storage. A v2
184
+ * lead told to "create a todo list" without a named tool either invents a
185
+ * MANIFEST.md (the prompt forbids that, correctly) or keeps the list in prose
186
+ * and loses it at the next compaction, which is the failure the rule exists to
187
+ * prevent. The status words move with it: `in_progress`/`completed` are
188
+ * todowrite's enum, and tm_ledger's are open/doing/done/blocked.
189
+ */
190
+ ["## Hard rule — TodoList discipline (non-negotiable)\nBefore you touch anything on a medium-or-larger task you MUST create a todo\nlist.",
191
+ "## Hard rule — LEDGER discipline (`tm_ledger`, non-negotiable)\nBefore you touch anything on a medium-or-larger task you MUST put the list on\nrecord: `tm_ledger { action:\"add\", text:\"…\" }` per work package."],
192
+ ["- Keep it LIVE: an item is `in_progress` while you or a dispatched child is\n actually working it, and `completed` only after its work is verified —",
193
+ "- Keep it LIVE: an item is `doing` while you or a dispatched child is\n actually working it, and `done` only after its work is verified —"],
194
+ ["genuinely are in_progress at once; that is the intended shape, not a\n violation of single-task focus (one worker, one in_progress).",
195
+ "genuinely are `doing` at once; that is the intended shape, not a\n violation of single-task focus (one worker, one `doing`)."],
196
+ ["- The goal travels with the work: it goes in the todo list, into every dispatch",
197
+ "- The goal travels with the work: it goes in `tm_ledger`, into every dispatch"],
198
+ ["NO MANIFEST.md — your state memory is the todo list.", "NO MANIFEST.md — your state memory is `tm_ledger`."],
199
+ [" todo list, dispatched to `implementer` with the exact finding text.",
200
+ " ledger, dispatched to `implementer` with the exact finding text."],
201
+ [" list, lay out the merge structure,", " ledger (`tm_ledger { action:\"list\" }` marks what the round finished), lay out the merge structure,"],
202
+ ["Do NOT store task state or oversized content there — todo list and\nboard files own those.",
203
+ "Do NOT store task state or oversized content there — the lead's `tm_ledger`\nand board files own those."],
204
+ ["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."],
232
+ ]
233
+
234
+ function forkBody(text, hits) {
235
+ let out = text
236
+ for (const [from, to] of V2_TEXT) {
237
+ if (!out.includes(from)) continue
238
+ out = out.split(from).join(to)
239
+ hits.add(from)
240
+ }
241
+ return out
242
+ }
243
+
244
+ /** Every key must land in at least one role, or the fork is silently stale. */
245
+ function assertForkApplied(hits) {
246
+ const dead = V2_TEXT.map(([from]) => from).filter((from) => !hits.has(from))
247
+ if (dead.length) {
248
+ throw new Error(
249
+ `v2 提示词分叉有 ${dead.length} 条没命中任何角色(源文改了,这里没跟上):\n - ` +
250
+ dead.join("\n - "),
251
+ )
252
+ }
253
+ }
254
+
255
+ const written = []
256
+ const skipped = []
257
+
258
+ function emit(dir, name, content) {
259
+ const file = join(dir, `${name}.md`)
260
+ if (existsSync(file)) {
261
+ const current = readFileSync(file, "utf8")
262
+ if (!current.includes(MARKER) && !force) {
263
+ skipped.push(`${file} — 不是我生成的文件,未覆盖(--force 强制)`)
264
+ return
265
+ }
266
+ if (current === content) {
267
+ skipped.push(`${file} — 已是最新`)
268
+ return
269
+ }
270
+ }
271
+ if (printOnly) {
272
+ console.log(`=== ${file}\n${content.slice(0, 700)}\n… (${content.length} 字节)`)
273
+ written.push(file)
274
+ return
275
+ }
276
+ mkdirSync(dir, { recursive: true })
277
+ writeFileSync(file, content, "utf8")
278
+ written.push(file)
279
+ }
280
+
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")
290
+ }
291
+ assertForkApplied(forkHits)
292
+
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")
296
+ }
297
+
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
+ )
@@ -0,0 +1,239 @@
1
+ // 2.x config surgery, shared by scripts/install.sh and scripts/install.ps1 so the two
2
+ // installers cannot drift into two different ideas of what "installed" means.
3
+ //
4
+ // Adds the plugin entry and/or sets default_agent, then READS THE VALUE BACK from disk
5
+ // before reporting success. Comments are masked to spaces at identical offsets, so a
6
+ // commented-out key can never be mistaken for a live one (the read-back would pass
7
+ // while the config said nothing).
8
+ //
9
+ // Why the key is `plugins` and not `plugin`, why one entry may not sit in two files,
10
+ // and why a directory target needs a root index.js: docs/research/plugin-loader-contract.md
11
+ // usage: node config-surgery.cjs <file> <plugins|default-agent> <value> [otherFileToScrub]
12
+ const fs = require("fs");
13
+ const [file, mode, value, other] = process.argv.slice(2);
14
+ if (!file || !mode || !value) {
15
+ console.error("ERR usage: node this.js <file> <plugins|default-agent> <value> [otherFileToScrub]");
16
+ process.exit(1);
17
+ }
18
+
19
+ const mask = (s) => {
20
+ let out = "", i = 0, inStr = false, esc = false;
21
+ while (i < s.length) {
22
+ const c = s[i];
23
+ if (inStr) {
24
+ out += c;
25
+ if (esc) esc = false; else if (c === "\\") esc = true; else if (c === '"') inStr = false;
26
+ i++; continue;
27
+ }
28
+ if (c === '"') { inStr = true; out += c; i++; continue; }
29
+ if (c === "/" && s[i + 1] === "/") { while (i < s.length && s[i] !== "\n") { out += " "; i++; } continue; }
30
+ if (c === "/" && s[i + 1] === "*") {
31
+ while (i < s.length && !(s[i] === "*" && s[i + 1] === "/")) { out += s[i] === "\n" ? "\n" : " "; i++; }
32
+ out += " "; i += 2; continue;
33
+ }
34
+ out += c; i++;
35
+ }
36
+ return out;
37
+ };
38
+
39
+ const lineOf = (s, idx) => s.slice(0, idx).split("\n").length;
40
+ let src = fs.readFileSync(file, "utf8");
41
+ const open = mask(src).indexOf("{");
42
+ if (open < 0) { console.error("ERR no top-level object in " + file); process.exit(1); }
43
+ const nextNonWs = (s, i) => { while (i < s.length && /\s/.test(s[i])) i++; return i; };
44
+ /** insert one member right after the object's opening brace (top level by
45
+ * definition: in a JSONC config the first '{' IS the document's own). */
46
+ const insertMember = (s, member) => {
47
+ const after = nextNonWs(s, open + 1);
48
+ const comma = s[after] === "}" ? "" : ","; // an empty object takes none
49
+ return s.slice(0, open + 1) + "\n " + member + comma + s.slice(open + 1);
50
+ };
51
+ const lineNo = (s, idx) => " (line " + lineOf(s, idx) + " of " + file + ")";
52
+
53
+ if (mode === "plugins") {
54
+ // OpenCode 2.x reads `config.plugins` (PLURAL). The singular `plugin` key is the
55
+ // 1.18.x spelling; a 2.x host never reads it, so an entry written there looks like
56
+ // an installed plugin and is nothing. It is left in place (a user may still share
57
+ // the file with a 1.18.x install) but REPORTED.
58
+ //
59
+ // Exactly ONE entry for our package, in either spelling: the host loads every entry
60
+ // it finds, and the same plugin loaded twice means two personalities registering the
61
+ // same tools and hooks. So a previous entry of OURS (npm specifier vs ./vendor path)
62
+ // is REPLACED, never duplicated; entries naming anything else are untouched.
63
+ const lit = JSON.stringify(value);
64
+ // Case-insensitive, hyphen-optional and slash-tolerant, because a local install is
65
+ // spelled with the working tree's own directory name (`D:/Github/Opencode-TeamMode`,
66
+ // no hyphen between team and mode) while the npm entry is `opencode-team-mode`. An
67
+ // entry we fail to recognise as ours is one we would ADD next to the existing one —
68
+ // and two entries is two loads, which is the defect this function exists to prevent.
69
+ const ours = (t) => /opencode[-_]?team[-_]?mode|vendor[/\\]team-mode/i.test(t);
70
+ const findArray = (mk, key) => {
71
+ const m = new RegExp('"' + key + '"\\s*:\\s*\\[').exec(mk);
72
+ if (!m) return null;
73
+ let i = m.index + m[0].length, depth = 1;
74
+ while (i < mk.length) {
75
+ const c = mk[i];
76
+ if (c === '"') { const j = mk.indexOf('"', i + 1); if (j < 0) return null; i = j + 1; continue; }
77
+ if (c === "[" || c === "{") depth++;
78
+ else if (c === "]" || c === "}") { depth--; if (depth === 0) return { head: m.index + m[0].length, end: i }; }
79
+ i++;
80
+ }
81
+ return null;
82
+ };
83
+ /** [start,end) spans of the array's own elements in [head,end); commas and whitespace
84
+ * are skipped, so a trailing comma — which a hand-edited or host-written config often
85
+ * carries — cannot end up producing ", ,", which would break the parse. */
86
+ const elements = (mk, head, end) => {
87
+ const out = []; let i = head;
88
+ while (i < end) {
89
+ const c = mk[i];
90
+ if (c === "," || /\s/.test(c)) { i++; continue; }
91
+ if (c === '"') { const j = mk.indexOf('"', i + 1); if (j < 0 || j >= end) break; out.push([i, j + 1]); i = j + 1; continue; }
92
+ if (c === "{" || c === "[") {
93
+ let d = 0, k = i;
94
+ for (; k < end; k++) {
95
+ const q = mk[k];
96
+ if (q === '"') { k = mk.indexOf('"', k + 1); if (k < 0 || k >= end) { k = end; break } continue; }
97
+ if (q === "{" || q === "[") d++;
98
+ else if (q === "}" || q === "]") { d--; if (!d) { k++; break; } }
99
+ }
100
+ out.push([i, k]); i = k; continue;
101
+ }
102
+ let k = i; while (k < end && !/[,}\]\s]/.test(mk[k])) k++;
103
+ out.push([i, k]); i = k;
104
+ }
105
+ return out;
106
+ };
107
+ const view = (text) => { const mk = mask(text); const a = findArray(mk, "plugins"); return { mk, a, els: a ? elements(mk, a.head, a.end) : [] }; };
108
+
109
+ const v0 = view(src);
110
+ if (findArray(v0.mk, "plugin")) {
111
+ console.log("NOTE this file also has a top-level \"plugin\" (singular) key — OpenCode 2.x loads" +
112
+ "\n config.plugins (plural) and never reads the singular one. Left untouched; if 1.18.x\n" +
113
+ " is gone from this machine, the key and its entries are dead weight you can delete.");
114
+ }
115
+ if (!v0.a) {
116
+ fs.writeFileSync(file, insertMember(src, '"plugins": [\n ' + lit + "\n ]"));
117
+ console.log("OK created the plugins array with " + lit);
118
+ } else {
119
+ const same = v0.els.filter(([a, b]) => v0.mk.slice(a, b) === lit);
120
+ const dupe = v0.els.filter(([a, b]) => ours(v0.mk.slice(a, b)) && v0.mk.slice(a, b) !== lit);
121
+ if (dupe.length) {
122
+ // Replace the first, drop the rest: two entries of ours = two loaded personalities.
123
+ let out = src;
124
+ for (let n = dupe.length - 1; n >= 0; n--) {
125
+ const [a, b] = dupe[n];
126
+ out = n === 0 ? out.slice(0, a) + lit + out.slice(b) : out.slice(0, a) + out.slice(b);
127
+ }
128
+ fs.writeFileSync(file, out);
129
+ console.log("OK replaced " + dupe.length + " previous Team entry/entries with " + lit);
130
+ } else if (same.length) {
131
+ console.log("OK plugins already lists " + lit +
132
+ (same.length > 1 ? " — " + same.length + " times, which loads the plugin twice" : ""));
133
+ } else {
134
+ const last = v0.els[v0.els.length - 1];
135
+ const at = last ? last[1] : v0.a.head;
136
+ const pad = last ? ",\n " : "\n ";
137
+ fs.writeFileSync(file, src.slice(0, at) + pad + lit + src.slice(at));
138
+ console.log("OK added " + lit + " alongside " + v0.els.length + " existing entr" + (v0.els.length === 1 ? "y" : "ies"));
139
+ }
140
+ }
141
+ // Read it back off the disk. The host's loader skips a plugin directory whose
142
+ // entrypoint it cannot resolve with NO message at all, and a key nobody reads is
143
+ // indistinguishable from an install — so the only claim made here is the measured one.
144
+ const v1 = view(fs.readFileSync(file, "utf8"));
145
+ const got = v1.els.filter(([a, b]) => v1.mk.slice(a, b) === lit).length;
146
+ if (!got) {
147
+ console.error("ERR read-back: plugins does not hold " + lit + " (array " + (v1.a ? "present" : "ABSENT") + ")");
148
+ console.error(" Add it by hand at the top level of " + file + ": \"plugins\": [" + lit + "]");
149
+ process.exit(1);
150
+ }
151
+ const stillOurs = v1.els.filter(([a, b]) => ours(v1.mk.slice(a, b))).length;
152
+ console.log("OK read back: plugins holds " + lit + " (" + v1.els.length + " entries, " +
153
+ stillOurs + " of them Team)");
154
+ if (got > 1 || stillOurs > 1) {
155
+ console.error("ERR the array carries our package more than once — the host would load it twice");
156
+ process.exit(1);
157
+ }
158
+ // Reclaim our entry from the OTHER global config file. The two files are merged at
159
+ // parse time and the host dedupes only on an identical string, so leaving our entry
160
+ // in opencode.json while writing this one would register the plugin twice — two
161
+ // personalities, the same tools and hooks bound twice, and a boot log that reads as
162
+ // one healthy install. Foreign entries (another plugin, any other key) are untouched.
163
+ if (other && other !== file && fs.existsSync(other)) {
164
+ const osrc = fs.readFileSync(other, "utf8");
165
+ const ov = view(osrc);
166
+ const mine = ov.els.filter(([a, b]) => ours(ov.mk.slice(a, b)));
167
+ if (mine.length) {
168
+ let out = osrc;
169
+ for (let n = mine.length - 1; n >= 0; n--) {
170
+ const [a, b] = mine[n];
171
+ // Take the comma with the element, and pick the RIGHT side: dropping a trailing
172
+ // comma when the element is the last one would leave `"x",\n ]`, which only
173
+ // parses because the host reads JSONC with allowTrailingComma — and a config that
174
+ // is valid solely under a lenient parser is a trap for the next hand edit.
175
+ let prev = a - 1;
176
+ while (prev >= 0 && /\s/.test(out[prev])) prev--;
177
+ if (prev >= 0 && out[prev] === ",") {
178
+ out = out.slice(0, prev) + out.slice(b); // not first: remove prev comma + element
179
+ } else {
180
+ let next = b;
181
+ while (next < out.length && /\s/.test(out[next])) next++;
182
+ out = out.slice(0, a) + out.slice(out[next] === "," ? next + 1 : b); // first: remove element + its comma
183
+ }
184
+ }
185
+ fs.writeFileSync(other, out);
186
+ // [Q, T,] with T removed keeps a comma either way (a separator on one side, a
187
+ // trailing one on the other), so a single element + comma delete cannot produce
188
+ // strict JSON. Strip a comma that now dangles immediately before THIS array's own
189
+ // closing bracket — that region and no other, because the rest of the file is not
190
+ // ours to reformat.
191
+ const after = view(fs.readFileSync(other, "utf8"));
192
+ if (after.a) {
193
+ let p = after.a.end - 1;
194
+ while (p > after.a.head && /\s/.test(out[p])) p--;
195
+ if (out[p] === ",") {
196
+ out = out.slice(0, p) + out.slice(p + 1);
197
+ fs.writeFileSync(other, out);
198
+ }
199
+ }
200
+ const chk = view(fs.readFileSync(other, "utf8"));
201
+ const left = chk.els.filter(([a, b]) => ours(chk.mk.slice(a, b))).length;
202
+ if (left) {
203
+ console.error("ERR " + other + " still lists Team after the reclaim — the plugin would load twice");
204
+ process.exit(1);
205
+ }
206
+ console.log("OK reclaimed " + mine.length + " Team entr" + (mine.length === 1 ? "y" : "ies") +
207
+ " from " + other + " (a second copy means a second load)");
208
+ }
209
+ }
210
+ } else if (mode === "default-agent") {
211
+ const lit = JSON.stringify(value);
212
+ const re = /"default_agent"(\s*:\s*)("(?:[^"\\]|\\.)*"|[^,}\s][^,}\n]*)/;
213
+ const m = re.exec(mask(src));
214
+ if (m) {
215
+ const vStart = m.index + m[0].length - m[2].length;
216
+ if (m[2].trim() === lit) {
217
+ console.log("OK default_agent already " + lit + lineNo(src, m.index));
218
+ } else {
219
+ fs.writeFileSync(file, src.slice(0, vStart) + lit + src.slice(vStart + m[2].length));
220
+ console.log("OK default_agent rewritten (was " + m[2].trim() + ")");
221
+ }
222
+ } else {
223
+ fs.writeFileSync(file, insertMember(src, '"default_agent": ' + lit));
224
+ console.log("OK default_agent added");
225
+ }
226
+ // Read it back — a fresh read of the file from disk, comments masked again.
227
+ src = fs.readFileSync(file, "utf8");
228
+ const chk = re.exec(mask(src));
229
+ const got = chk ? chk[2].trim() : null;
230
+ if (got !== lit) {
231
+ console.error("ERR default_agent read back as " + (got === null ? "(key absent)" : got) + ", not " + lit);
232
+ console.error(" Add it by hand at the top level of " + file + ": \"default_agent\": " + lit);
233
+ process.exit(1);
234
+ }
235
+ console.log("OK read back: " + lit + lineNo(src, chk.index));
236
+ } else {
237
+ console.error("ERR unknown mode '" + mode + "'");
238
+ process.exit(1);
239
+ }