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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/README.md +80 -15
  2. package/README.zh-CN.md +67 -18
  3. package/dist/host/note.d.ts +7 -0
  4. package/dist/host/note.d.ts.map +1 -0
  5. package/dist/host/note.js +31 -0
  6. package/dist/host/note.js.map +1 -0
  7. package/dist/host/v1.d.ts +14 -0
  8. package/dist/host/v1.d.ts.map +1 -0
  9. package/dist/host/v1.js +340 -0
  10. package/dist/host/v1.js.map +1 -0
  11. package/dist/host/v2-browser-gate.d.ts +45 -0
  12. package/dist/host/v2-browser-gate.d.ts.map +1 -0
  13. package/dist/host/v2-browser-gate.js +319 -0
  14. package/dist/host/v2-browser-gate.js.map +1 -0
  15. package/dist/host/v2-capabilities.d.ts +78 -0
  16. package/dist/host/v2-capabilities.d.ts.map +1 -0
  17. package/dist/host/v2-capabilities.js +173 -0
  18. package/dist/host/v2-capabilities.js.map +1 -0
  19. package/dist/host/v2-client.d.ts +53 -0
  20. package/dist/host/v2-client.d.ts.map +1 -0
  21. package/dist/host/v2-client.js +193 -0
  22. package/dist/host/v2-client.js.map +1 -0
  23. package/dist/host/v2-events.d.ts +56 -0
  24. package/dist/host/v2-events.d.ts.map +1 -0
  25. package/dist/host/v2-events.js +158 -0
  26. package/dist/host/v2-events.js.map +1 -0
  27. package/dist/host/v2-guard.d.ts +101 -0
  28. package/dist/host/v2-guard.d.ts.map +1 -0
  29. package/dist/host/v2-guard.js +199 -0
  30. package/dist/host/v2-guard.js.map +1 -0
  31. package/dist/host/v2-offload.d.ts +148 -0
  32. package/dist/host/v2-offload.d.ts.map +1 -0
  33. package/dist/host/v2-offload.js +288 -0
  34. package/dist/host/v2-offload.js.map +1 -0
  35. package/dist/host/v2-permissions.d.ts +123 -0
  36. package/dist/host/v2-permissions.d.ts.map +1 -0
  37. package/dist/host/v2-permissions.js +190 -0
  38. package/dist/host/v2-permissions.js.map +1 -0
  39. package/dist/host/v2-probe.d.ts +85 -0
  40. package/dist/host/v2-probe.d.ts.map +1 -0
  41. package/dist/host/v2-probe.js +268 -0
  42. package/dist/host/v2-probe.js.map +1 -0
  43. package/dist/host/v2-scope.d.ts +51 -0
  44. package/dist/host/v2-scope.d.ts.map +1 -0
  45. package/dist/host/v2-scope.js +58 -0
  46. package/dist/host/v2-scope.js.map +1 -0
  47. package/dist/host/v2-session-client.d.ts +105 -0
  48. package/dist/host/v2-session-client.d.ts.map +1 -0
  49. package/dist/host/v2-session-client.js +390 -0
  50. package/dist/host/v2-session-client.js.map +1 -0
  51. package/dist/host/v2-session.d.ts +85 -0
  52. package/dist/host/v2-session.d.ts.map +1 -0
  53. package/dist/host/v2-session.js +171 -0
  54. package/dist/host/v2-session.js.map +1 -0
  55. package/dist/host/v2-subagent.d.ts +157 -0
  56. package/dist/host/v2-subagent.d.ts.map +1 -0
  57. package/dist/host/v2-subagent.js +286 -0
  58. package/dist/host/v2-subagent.js.map +1 -0
  59. package/dist/host/v2-tool.d.ts +67 -0
  60. package/dist/host/v2-tool.d.ts.map +1 -0
  61. package/dist/host/v2-tool.js +209 -0
  62. package/dist/host/v2-tool.js.map +1 -0
  63. package/dist/host/v2-transcript.d.ts +73 -0
  64. package/dist/host/v2-transcript.d.ts.map +1 -0
  65. package/dist/host/v2-transcript.js +151 -0
  66. package/dist/host/v2-transcript.js.map +1 -0
  67. package/dist/host/v2-types.d.ts +229 -0
  68. package/dist/host/v2-types.d.ts.map +1 -0
  69. package/dist/host/v2-types.js +17 -0
  70. package/dist/host/v2-types.js.map +1 -0
  71. package/dist/host/v2.d.ts +42 -0
  72. package/dist/host/v2.d.ts.map +1 -0
  73. package/dist/host/v2.js +853 -0
  74. package/dist/host/v2.js.map +1 -0
  75. package/dist/index.d.ts +23 -17
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +29 -366
  78. package/dist/index.js.map +1 -1
  79. package/dist/prompts/lead.d.ts +1 -1
  80. package/dist/prompts/lead.d.ts.map +1 -1
  81. package/dist/prompts/lead.js +456 -426
  82. package/dist/prompts/lead.js.map +1 -1
  83. package/dist/prompts/shared.d.ts +2 -2
  84. package/dist/prompts/shared.d.ts.map +1 -1
  85. package/dist/prompts/shared.js +38 -11
  86. package/dist/prompts/shared.js.map +1 -1
  87. package/dist/prompts/specialists.d.ts +2 -2
  88. package/dist/prompts/specialists.d.ts.map +1 -1
  89. package/dist/prompts/specialists.js +7 -6
  90. package/dist/prompts/specialists.js.map +1 -1
  91. package/dist/task-offload.d.ts +16 -0
  92. package/dist/task-offload.d.ts.map +1 -1
  93. package/dist/task-offload.js +51 -0
  94. package/dist/task-offload.js.map +1 -1
  95. package/dist/tm/browser.d.ts.map +1 -1
  96. package/dist/tm/browser.js +41 -10
  97. package/dist/tm/browser.js.map +1 -1
  98. package/dist/tm/dispatch.d.ts +86 -1
  99. package/dist/tm/dispatch.d.ts.map +1 -1
  100. package/dist/tm/dispatch.js +360 -39
  101. package/dist/tm/dispatch.js.map +1 -1
  102. package/dist/tm/dupe-guard.d.ts +16 -3
  103. package/dist/tm/dupe-guard.d.ts.map +1 -1
  104. package/dist/tm/dupe-guard.js +27 -7
  105. package/dist/tm/dupe-guard.js.map +1 -1
  106. package/dist/tm/index.d.ts +40 -1
  107. package/dist/tm/index.d.ts.map +1 -1
  108. package/dist/tm/index.js +40 -11
  109. package/dist/tm/index.js.map +1 -1
  110. package/dist/tm/ledger.d.ts +114 -0
  111. package/dist/tm/ledger.d.ts.map +1 -0
  112. package/dist/tm/ledger.js +308 -0
  113. package/dist/tm/ledger.js.map +1 -0
  114. package/dist/tm/perm-ask.d.ts +1 -3
  115. package/dist/tm/perm-ask.d.ts.map +1 -1
  116. package/dist/tm/perm-ask.js +10 -1
  117. package/dist/tm/perm-ask.js.map +1 -1
  118. package/dist/tm/preview.d.ts +55 -0
  119. package/dist/tm/preview.d.ts.map +1 -1
  120. package/dist/tm/preview.js +225 -0
  121. package/dist/tm/preview.js.map +1 -1
  122. package/dist/tm/search.d.ts.map +1 -1
  123. package/dist/tm/search.js +40 -10
  124. package/dist/tm/search.js.map +1 -1
  125. package/dist/tm/serp-loop.d.ts +45 -0
  126. package/dist/tm/serp-loop.d.ts.map +1 -0
  127. package/dist/tm/serp-loop.js +80 -0
  128. package/dist/tm/serp-loop.js.map +1 -0
  129. package/dist/tm/stats.d.ts +29 -0
  130. package/dist/tm/stats.d.ts.map +1 -1
  131. package/dist/tm/stats.js +278 -4
  132. package/dist/tm/stats.js.map +1 -1
  133. package/dist/tm/webfetch.d.ts +4 -0
  134. package/dist/tm/webfetch.d.ts.map +1 -1
  135. package/dist/tm/webfetch.js +75 -8
  136. package/dist/tm/webfetch.js.map +1 -1
  137. package/docs/installation-v2.md +445 -0
  138. package/docs/installation.md +12 -0
  139. package/docs/research/agent-data-exchange.md +117 -0
  140. package/docs/research/browser-pane.md +89 -0
  141. package/docs/research/host-http-api.md +132 -0
  142. package/docs/research/host-subagent-injection.md +187 -0
  143. package/docs/research/plugin-loader-contract.md +261 -0
  144. package/docs/research/v2-builtin-tools.md +447 -0
  145. package/docs/v2-port-notes.md +413 -0
  146. package/index.js +7 -0
  147. package/package.json +64 -61
  148. package/scripts/gen-v2-config.mjs +304 -0
  149. package/scripts/lib/config-surgery.cjs +239 -0
  150. package/scripts/lib/fake-ctx.mjs +169 -0
@@ -0,0 +1,447 @@
1
+ # OpenCode 2.0.16 — every built-in tool, forensically
2
+
3
+ **Scope:** read-only static forensics on the installed host. Nothing in the host was
4
+ modified, no `opencode` session was run (a live agent owns that lane), and no existing
5
+ repo file was touched — this page is the only artifact.
6
+
7
+ **Privacy:** names, shapes, enums and numeric caps only. No user paths, no URLs, no
8
+ command lines, no environment values appear below, in keeping with the R6 red line.
9
+
10
+ ## 0. Where each claim comes from
11
+
12
+ | Tag | Source | Strength |
13
+ |---|---|---|
14
+ | `[B]` | Strings read out of the installed host's `resources/opencode-cli.exe` (its sibling `opencode-cli.version` reads `2.0.16`) with a windowed byte scan. The embedded Effect/JS is what actually runs, so a literal read here is the implementation, not a doc. | strongest offline |
15
+ | `[L]` | Prior **live** probe records in project memory (`host-v2-tool-surface`, `opencode-desktop-capabilities`) on the same 2.0.16 host | strongest overall |
16
+ | `[R]` | Our own code, already measured against a live host (`src/host/v2-*.ts`, `src/tm/*.ts`) | ours, verified |
17
+ | `[D]` | Official v2 plugin docs (hook mutability, agents/commands markdown) | corroborating |
18
+ | `[U]` | Unknown — written as unknown, never as fact | — |
19
+
20
+ ## 1. The rule that decides what a model can call (the finding that reframes everything)
21
+
22
+ `[B]` The tool registry, at request-assembly time, splits **every** registered tool by one
23
+ field:
24
+
25
+ ```
26
+ direct = tools with options.codemode === false → sent as provider tool definitions
27
+ catalog = tools with options.codemode !== false → reachable ONLY inside `execute`
28
+ execute = appended to `direct` unless a permission rule denies action "execute" with resource "*"
29
+ ```
30
+
31
+ and renders the catalog into the **system prompt**, not into the tool list:
32
+
33
+ > `# Code Mode`
34
+ > "Use the `execute` tool to call the tools listed below. They cannot be called directly…
35
+ > only work inside code you pass to `execute`."
36
+ > when the budget is exceeded: "The catalog is partial. Inside `execute`, use `search(...)` to
37
+ > find a tool, then call it by the `path` in the result. `search` is synchronous. Call it without
38
+ > `await`; it does not return a Promise. Do not guess tool names."
39
+
40
+ Catalog accounting, verbatim from the same module: each entry prints
41
+ ` - <typescript-signature> // <first line of the description>`, the first line is cut at
42
+ **120 chars**, the whole catalog is budgeted at **2000 "tokens"** estimated as
43
+ `round(chars / 4)`, and namespaces are always listed before entries. So a plugin tool's
44
+ description costs the catalog **its first line only**, and the rest of the text is fetched on
45
+ demand by `search()`. `[B]`
46
+
47
+ Three consequences that change a business claim rather than an implementation detail:
48
+
49
+ 1. **The 6-tool measurement is explained, not mysterious.** `[L]` `session.hook("context")`
50
+ carried `edit execute question shell subagent write` and zero `tm_*`. `[B]` says why: every
51
+ host core tool sets `codemode:false` (so it is a real definition), and our
52
+ `bindV2Tool()` sets **no `options` at all** `[R]` → `codemode !== false` → catalog-only. It
53
+ was never "plugin tools are not registered": `ctx.tool.list()` saw them `[R]`, and the model
54
+ reached them through `search()` `[L]`.
55
+ 2. **A denied action removes a tool from BOTH surfaces.** `[B]` `Qc(name, rules)` drops a
56
+ registration when the *last* matching rule for `options.permission ?? exposedName` is
57
+ `resource:"*" && effect:"deny"` — before the direct/catalog split. Our
58
+ `delete event.tools.<name>` in `v2-session.ts` `[R]` therefore only governs the **direct**
59
+ surface, which on a live v2 request never contained the catalog tools in the first place.
60
+ 3. **The one real gap this exposes (repo-side, stated as a to-do, not as a defect of the
61
+ host):** `tm_browser: "deny"` translates to the triple `{action:"tm_browser" …}` `[R]`,
62
+ which removes **our** door from the catalog but nothing names the host's `browser` action —
63
+ and the browser tools all share `options.permission:"browser"` `[B]`. Meanwhile
64
+ `toolsToRemove()` pushes the `browser_*` sentinel into the *request-layer* deletion `[R]`,
65
+ where those names are absent by construction. Net effect on v2 today: a role denied
66
+ `tm_browser` still reaches all 45 native browser tools through `execute`. The fix is one
67
+ projected triple `{action:"browser", resource:"*", effect:"deny"}`, which `[B]` shows is
68
+ exactly the mechanism the host honors.
69
+
70
+ Name derivation, `[B]`: exposed name =
71
+ `options.namespace.replace(/[^a-zA-Z0-9_-]/g,"_") + "_" + name.replace(/[^a-zA-Z0-9_-]/g,"_")`,
72
+ or the bare name when there is no namespace. That is why `browser.tabs.open` is both
73
+ `tools.browser.tabs.open` (Code Mode path) and `browser_tabs_open` (flat tool name) — two
74
+ spellings of one registration, which `[L]` already caught in a live probe.
75
+
76
+ ## 2. Core built-in tools — 12, all direct (`codemode:false`) `[B]`
77
+
78
+ Each is registered by an Effect plugin with `id: "opencode.tool.<name>"`; there are exactly
79
+ twelve such ids in the binary, which is the complete list. Fields below are the **model-visible**
80
+ parameter names, the host's own caps, the permission action it asserts, and the result shape.
81
+
82
+ ### 2.1 `read`
83
+ - **params** `path` (required, "File or directory to read"), `offset?` (1-based line **or**
84
+ directory entry), `limit?` — "defaults to and capped at 2000".
85
+ - **caps** `[B]` 2000 lines/entries per page, 51 200 bytes per page, 2 000 chars per line with the
86
+ literal tail `... (line truncated to 2000 chars)`, 256 KiB head probe, 20 MiB media ingestion cap.
87
+ Binary mimes it will attach: `image/png image/jpeg image/gif image/webp application/pdf`;
88
+ anything else binary raises `ReadTool.BinaryFileError`. Other errors: `OffsetOutOfRangeError`,
89
+ `PathKindError`, `MediaIngestLimitError`.
90
+ - **output** tagged union `file | text-page | list-page`; every text line comes back prefixed
91
+ `<n>: ` (the description warns the prefix is not file content); directory entries come back as
92
+ `{path, type: file|directory|symlink}` lines. `metadata.truncated` is set for pages, forced
93
+ `false` for a single attached file. **There is no `list` tool** — `read` on a directory is it.
94
+ - **permissions** `[B]` two asserts, not one: `action:"read"` with the resource being the path
95
+ **relative to the session directory** (absolute when outside), plus, only when the target is
96
+ outside the session/project root, `action:"external_directory"` with resource
97
+ `<containing-dir>/*`. `[L]` observed that one live at `effect:"ask"` behind a real
98
+ `permission.asked` — i.e. v2's path scope is a **dialog**, where v1's P2 was a hard throw.
99
+ - **side effect worth knowing** `[B]` on a read of a file inside a project it walks upward for
100
+ `AGENTS.md` and loads the ones it has not loaded yet — the host does instruction-file JIT itself.
101
+ - **for us** the replacement for `tm_read`, and strictly better (paged, images/PDF inline). Already
102
+ retired `[R]`.
103
+
104
+ ### 2.2 `write`
105
+ `{path, content}` → output `{operation:"write", target, resource, existed}`; text
106
+ `"Wrote file successfully: X"` / `"Created file successfully: X"`. Asserts `action:"edit"`
107
+ (`options.permission:"edit"`), creates missing parent dirs, preserves BOM. Replacement floor for a
108
+ board-file write, but `tm_board_write`'s never-overwrite + role-from-`ctx.agent` + slug rules are
109
+ not anything native gives us.
110
+
111
+ ### 2.3 `edit`
112
+ `{path, oldString, newString, replaceAll?}`. Uniqueness enforced by an error that says so
113
+ ("Found N matches for oldString, but expected exactly one…"). `[B]` the matcher folds typographic
114
+ quotes/dashes/non-breaking spaces and trailing whitespace before comparing, and preserves BOM.
115
+ Content `"Edited <file> (N replacements)"`, `metadata.files[]`. Asserts `action:"edit"`.
116
+
117
+ ### 2.4 `patch`
118
+ `{patchText}` (one string: add/update/delete ops) → `{applied:[{type:"add"|"update"|"delete",
119
+ resource,target}], files[]}`; content `"Success. Updated the following files:"` + `A|D|M <resource>`
120
+ lines. Asserts `action:"edit"` via `options.permission`. Note `tm_read`-style pagination applies to
121
+ nothing here; this is the v2 name of v1's `apply_patch` `[R]`.
122
+
123
+ ### 2.5 `shell` ← the v2 name of v1's `bash`
124
+ - **params** `command` (required) · **`workdir?`** (NOT `cwd` — the host's own words: "avoid
125
+ changing directories in the command and set the working directory here instead") ·
126
+ `timeout?` **ms**, `0` disables, default **120 000** for foreground, **no timeout by default when
127
+ background** · `background?` (bool; "DO NOT poll for completion").
128
+ - **description claims** `[B]` "When output is large, the full result is saved to a file and a
129
+ truncated preview is returned" / "Prefer dedicated tools over shell commands when possible" /
130
+ "Background commands return immediately, and you will be notified when they complete".
131
+ - **output** `{output, exit?, shellID?, truncated, timeout?, status?:"completed"|"running"}`.
132
+ Truncation keeps **the tail**: last 2 000 lines and last 51 200 bytes (config-overridable via the
133
+ `tool_output` entry), then appends the literal `[full output saved to <file>]`. `[B]`
134
+ Background returns `Command moved to the background (shell ID: X).` + `Output is streaming to:
135
+ <file>`, and completion is injected later as a **synthetic message**
136
+ `<shell id="…" state="…" command="…">…</shell>` with `metadata.source:"shell"`.
137
+ **Note for R6:** that envelope re-enters context carrying the command text.
138
+ - **permissions** `[B]` the strongest thing in this section: before executing, the host parses the
139
+ command with **tree-sitter bash/PowerShell grammars**, and asserts
140
+ `action:"shell"` with **one resource per parsed command** — the resource is that command's own
141
+ text (and, for a redirected statement, the text *including* its redirect target) — while the
142
+ `save` half of the same call is a **program-scoped glob**: first N words of the command
143
+ (`cat`, `ls`, `git`→2, `npm`→2, `docker compose`→3, `gh`/`aws`/`gcloud`→3 … a ~110-entry table)
144
+ plus `" *"`. Directory-changing builtins (`cd chdir popd pushd push-location set-location`) are
145
+ not permissions at all; their path arguments are scanned and routed through
146
+ `external_directory`. `[L]` had already recorded `{action:"shell", resourceCount:1}` reaching
147
+ `permission.evaluate` for a real git invocation; `[B]` now explains the number: **1 command, 1
148
+ resource**. This is the evidence #12 needed — the host hands our classifier a per-command face,
149
+ so `TM_R6_FINE_ASK=on` is not a guess layered on a coarse rule, and the config-level
150
+ `shell→ask` blanket is the fallback, not the ceiling. (An *experimental*
151
+ `portable_shell_scanner` alternative exists; when it cannot analyze a command it **errors the
152
+ call** rather than widening — `[B]`, off by default.)
153
+ - **for us** replaces `tm_bash` `[R]`, with three prompt-visible differences: `workdir` not `cwd`,
154
+ tail-not-head truncation, and a real `background`. Our bash timeout clamp still applies
155
+ (`shell.hook("create.before")` carries mutable `{command,cwd,timeout,shell,env}` `[L]`), and the
156
+ P3 read-only allowlist has **no native counterpart** — the host never refuses a *read-only*
157
+ command class, so anything that behaves like P3 stays ours.
158
+
159
+ ### 2.6 `glob`
160
+ `{pattern, path?, hidden?, limit?}` — default limit **100**, internal timeout **30 s** with the
161
+ message "Search timed out after 30 seconds. Consider using a more specific path or pattern."
162
+ Output `{entries:[{path,…}], truncated}`; content is resolved absolute paths one per line, or
163
+ `No files found`, plus the truncation note. Asserts `action:"glob"` with resource = **the pattern**
164
+ (not a path). Replaces `tm_grep`'s enumeration half; note it does not stat, so an empty result is a
165
+ true negative.
166
+
167
+ ### 2.7 `grep`
168
+ `{pattern, path?, include?, literal?, caseSensitive?, limit?}` — ripgrep regex or **literal**
169
+ (`literal:true`), `caseSensitive` defaults **true**, default limit **100**, same 30 s cap. Content:
170
+ `Found N matches`, then `<path>:` blocks with ` Line <n>: <text>`; `No matches found` when empty;
171
+ truncation note appended. `metadata {matches, truncated}`. Asserts `action:"grep"`, resource = the
172
+ pattern. **This is the only place the empty-result self-report (design goal, §6g2) is duplicated by
173
+ the host** — it says `No matches found` but never names the widening moves, and it prints nothing
174
+ about the scope searched, so our `(0 命中)pattern=… · 范围=…` line remains an improvement, not a
175
+ restatement.
176
+
177
+ ### 2.8 `webfetch`
178
+ - `{url, format?:"text"|"markdown"|"html" (default markdown), timeout? seconds, max 120, default 30}`.
179
+ - Output `{url, contentType, format, output}`; `metadata.contentType`. Response cap **5 242 880 B**
180
+ ("Response too large…"), refuses `image/*` (except svg/fastbidsheet) and any non-text-ish
181
+ content type, and does its own HTML→text/markdown conversion.
182
+ - Description admits the host's own offload: "Large text results may be replaced with a preview
183
+ while the complete output is retained in managed storage."
184
+ - **Red line: there is none.** `[B]` the only validation is
185
+ `protocol !== http:/https: → "URL must use http:// or https://"`. No literal, no
186
+ link-local/metadata/reserved check, no private-space check — which is precisely the hole
187
+ `src/host/v2-guard.ts` closed by answering the address question at
188
+ `permission.hook("evaluate")` `[R]`, and the reason that guard may not be reordered after any
189
+ future `webfetch`-adjacent retirement.
190
+ - Also `[B]` the host's own docs for this tool say "Use a more targeted tool when one is available"
191
+ — the ladder instinct is shared, in words only.
192
+
193
+ ### 2.9 `websearch`
194
+ `{query}` → `{provider, results[]}`; content is `## [title](url) Published: <iso>` blocks; empty →
195
+ "No search results found. Please try a different query."; HTTP 429/401 mapped to named messages.
196
+ Description injects the **current year at registration time** and tells the model to use it — the
197
+ same recency problem our `searchDateLine` answers, but note the host stamps it into a *description*,
198
+ which for a process living for days is the drift our own test-blackboard date guard exists to
199
+ prevent. `[B]`
200
+ When no provider is selected it raises **the host's own** ask dialog (allow / choose provider /
201
+ disable) — reachable only from inside the host, never from a plugin `[L]`. All providers need a key
202
+ or `/connect` `[R]`. It self-removes from the tool surface via `session.hook("context") + delete`
203
+ when websearch is switched off `[B]` — the host uses the same request-layer lever we do.
204
+ **Verdict:** does not replace `tm_search` (zero-key + multi-engine vote is the whole point).
205
+
206
+ ### 2.10 `question`
207
+ `{questions:[{question, header, options:[{label, description}], multiple}]}` →
208
+ `{answers: string[][]}`. Asserts `action:"question"` with resource `"*"`, then calls the host's
209
+ **internal** ask service with typed fields (`multiselect` when `multiple`). `[B]`
210
+ This is the concrete proof of the v2 consent gap: the dialog machinery exists in the same process,
211
+ on a seam plugins cannot reach — matching `[L]`'s live result that a plugin-declared
212
+ `options.permission:"…"` produced no evaluation and no prompt. The lead's
213
+ "blocking question" path on v2 therefore has exactly one home: let the model call `question`.
214
+
215
+ ### 2.11 `skill`
216
+ `{id}` (an available skill id, or one the user named) → `{name, directory, output}`;
217
+ `metadata{name, directory}`. Asserts `action:"skill"` with resource = the skill id, and
218
+ `save:[id]` — an "always" here is scoped to that one skill, unlike `read`'s `save:["*"]`.
219
+
220
+ ### 2.12 `subagent` ← v2 name of v1's `task`
221
+ - `{agent, description, prompt, model?, sessionID?, background?}`. `model` is `"providerID/modelID"`
222
+ or `…#variant` and the description says "NEVER set this unless the user explicitly asks… look the
223
+ model up with the models tool". `agent`'s description carries a live-lesson: a name that is not a
224
+ known subagent "most likely mean a model". `sessionID` continues a previous child conversation;
225
+ omitted = fresh context.
226
+ - **permission resource is the target agent id** (`save:[id]`) `[B]` — that is the mechanism behind
227
+ "the parent's `subagent` permission decides which roles it may start" `[D]`, and it is why T3's
228
+ "only the lead dispatches" survives v2 as a triple, not as a prompt.
229
+ - **foreground result content** `[B]`, verbatim shape:
230
+ `<subagent sessionID="…" state="completed">\n<reply>\n</subagent>` with
231
+ `metadata {sessionID, status}`; an empty child answers
232
+ `Subagent completed without a text response.`; error/cancelled become tool **errors** naming the
233
+ child sessionID.
234
+ - **background**: ack `py()` = "The subagent is working in the background (sessionID: X). You will
235
+ be notified automatically when it finishes." + "DO NOT sleep, poll for progress, ask the
236
+ subagent for status, or duplicate this subagent's work…". Completion is injected through
237
+ `session.synthetic` with
238
+ `<subagent sessionID="…" state="…" description="…">…</subagent>` and
239
+ `metadata {source:"subagent", childID, agent, state}` `[B]` — exactly the envelope
240
+ `parseHostEnvelope` matches on v2 `[R]`.
241
+ - **and the host registers `tool.hook("execute.before")` for `subagent` itself** (it deletes empty
242
+ `model`/`sessionID` strings) `[B]` — multiple registrants on the same hook point, our own
243
+ background-forcing `applyV2BackgroundForce` included; order is plugin order `[D]`, so "who wins"
244
+ on the same field is a real question, not a formality.
245
+
246
+ ## 3. `execute` — Code Mode, direct, and the only door to the rest
247
+
248
+ - **input is exactly `{code: string}`** `[B]` — no label, no budget, no tool list.
249
+ - **description** `[B]`, condensed to its clauses: run JS in a confined runtime to script tool
250
+ calls and HTTP requests; `fetch` is available; **imports, direct filesystem access and timers are
251
+ unavailable**; the only callable tools are those in the catalog or returned by `search`; call them
252
+ by exact paths, preserve bracket notation
253
+ `tools.<namespace>["tool-name"](input)`; prefer an explicit `return`, else the last top-level
254
+ expression is the result; await what matters or it is interrupted; run independent calls with
255
+ `Promise.all`.
256
+ - **runtime globals** `[B]`: `tools, search, Object, Array, Math, JSON, console, Promise, Iterator,
257
+ Number, String, Boolean, parseInt/parseFloat, isFinite/isNaN, Date, RegExp, Map, Set, URL,
258
+ URLSearchParams, Headers, Uint8Array, TextEncoder/TextDecoder, encode/decodeURI(Component),
259
+ atob/btoa, crypto, structuredClone, NaN, Infinity, undefined`; `Function` throws ("write the
260
+ function inline"); `Symbol` limited to the two iterator symbols. `fetch` is a host-supplied
261
+ wrapper with a **30 000 ms** `AbortSignal.timeout`, GET-shaped as `{method:"GET", url}`.
262
+ - **error `kind` enum** `[B]`: `ParseError, UnsupportedSyntax, UnknownTool, InvalidToolInput,
263
+ InvalidToolOutput, InvalidDataValue, ToolCallLimitExceeded, TimeoutExceeded, ToolFailure,
264
+ ExecutionFailure, Truncated`. `UnknownTool` even produces the "Did you mean
265
+ `tools.opencode.read_mcp_resource`?" suggestion `[L]` saw live — and confirms a wrong name is a
266
+ **throw**, while `typeof` on the proxy is a lie (`[L]` §2).
267
+ - **output** `[B]` `{output: string, toolCalls:[{tool, status:"running"|"completed"|"error",
268
+ input?}], error?: true, files:[{data, mime, name?}]}`, and the content it hands the model is
269
+ `[{type:"text", text:output}, …files as data:<mime>;base64 URIs]` with `metadata {toolCalls,
270
+ error?}`. So a Code Mode program's whole return — every browser snapshot, every nested result —
271
+ enters context as **one** text part. `[L]` measured a 15 000-token program return arriving as
272
+ `offloaded:true / preview_tokens:58`; `[R]` that is why `execute` sits in
273
+ `NATIVE_GOVERNED_TOOLS`.
274
+ - **catalog tool** `search` `[B]` (sandbox global, not a model tool):
275
+ `{query?, namespace?, limit? (default 10), offset?}` → `{items:[{path, description, signature}],
276
+ remaining, next:{offset}|null}`; `path` comes back prefixed `tools.`.
277
+
278
+ ## 4. `opencode.*` — 5 catalog tools `[B]`
279
+
280
+ | Code Mode path | flat name | params | notes |
281
+ |---|---|---|---|
282
+ | `tools.opencode.session_rename` | `opencode_session_rename` | `title`, `sessionID?` | renames another session |
283
+ | `tools.opencode.session_move` | `opencode_session_move` | `directory`, `sessionID?` | "moves at the next safe boundary; do not run destructive…" |
284
+ | `tools.opencode.models` | `opencode_models` | `query?`, `provider?`, `offset?` (default 0), `limit?` (default 20) | turns a spoken model name into an exact ref; filters to the caller's provider first |
285
+ | `tools.opencode.list_mcp_resources` | `opencode_list_mcp_resources` | `server?` | `{resources[], templates[]}`; templates need `uriTemplate` filled |
286
+ | `tools.opencode.read_mcp_resource` | `opencode_read_mcp_resource` | `server`, `uri` | its own description says oversized output "is truncated automatically and the full content is saved to a file you can read"; images/PDFs shown directly |
287
+
288
+ Namespace blurb shown to the model: "Tools for managing OpenCode itself, such as working with
289
+ sessions, searching the available models, and reading MCP resources." `[B]`
290
+ MCP servers' own tools are registered as `options.namespace = <sanitized server name>` with
291
+ `codemode: <not false>` and **`permission:"<server>_<tool>"`, resource `"*"`** `[B]` — a user's MCP
292
+ tool is therefore an action our triples can name but never refine, which is exactly the case
293
+ `mergeTriples` leaves untouched `[R]`.
294
+
295
+ ## 5. `browser.*` — 45 catalog tools, one shared permission action `[B]`
296
+
297
+ Registered from one table under namespace `browser` (+ `browser.<group>`), every entry carrying
298
+ `options.permission:"browser"` and `codemode:true`, so: deny action `browser` with `resource:"*"`
299
+ and all 45 vanish from both surfaces (see §1.3); `ask` buys nothing, because nothing asserts it —
300
+ `[L]` observed `browser_*` calls not triggering `permission.evaluate` at all, while
301
+ `tool.hook("execute.before")` **does** see each call with a writable `input` (it stopped a real
302
+ navigation and rewrote a real URL). Shared param objects: `tabID` (required nearly everywhere,
303
+ "Exact tab ID returned by browser.tabs.open/list"), `frameID?`, `ref`
304
+ (pattern `^@?e[1-9][0-9]*$` — the host's snapshot refs are the **same `e1` convention** our
305
+ `SnapshotIndex` minted independently), `fileID` (`file_<uuid>`), `limit` 1–500 default 100,
306
+ `timeoutMs` 1–30 000 default 10 000.
307
+
308
+ | flat name | params (required first) | enums / caps | output |
309
+ |---|---|---|---|
310
+ | `browser_tabs_list` | — | `{tabs[],focusedTabID}` | |
311
+ | `browser_tabs_open` | `url?`, `focus?` | url ≤2048, focus default **true** (false only if the user asked for background) | `Browser.Tab` |
312
+ | `browser_tabs_focus` / `_close` | `tabID` | — | Tab / State |
313
+ | `browser_preview` | `path` | server-local, relative-to-workspace or absolute; images/svg/audio/video/pdf/html/md/**mermaid**/csv/tsv/font render | `{tab,files[]}` |
314
+ | `browser_navigate` | `tabID`,`url` | http/https or `about:blank` only | Tab |
315
+ | `browser_back` `_forward` `_reload` `_stop` | `tabID` | — | Tab |
316
+ | `browser_frames` | `tabID` | — | `{tab,frames[{id,parentID?,url,name}]}` |
317
+ | `browser_snapshot` | `tabID` · `frameID?`,`ref?`,`depth?`,`boxes?` | depth 1–20 | snapshot doc (`Hy`) |
318
+ | `browser_find` | `tabID`,`text` · `frameID?` | literal, case-insensitive; **refreshes refs** | `Hy` |
319
+ | `browser_evaluate` | `tabID`,`script` · `frameID?` | must return JSON-serializable; **no server fs** | `{tab,value}` |
320
+ | `browser_click` | `tabID`,`ref` · `button?`,`count?`,`modifiers?` | left/right/middle · 1/2 · Alt/Control/Meta/Shift | Tab |
321
+ | `browser_hover` / `browser_check` | `tabID`,`ref` (+`checked`) | | Tab |
322
+ | `browser_drag` | `tabID`,`from`,`to` | refs within one tab | Tab |
323
+ | `browser_fill` | `tabID`,`ref`,`text` | text ≤10 000 | Tab |
324
+ | `browser_fill_form` | `tabID`,`fields[]` ≤100 | each `{ref,type:text\|select\|check}` | Tab |
325
+ | `browser_select` | `tabID`,`ref`,`values[]` 1–100 | matched against option **values** | Tab |
326
+ | `browser_press` | `tabID`,`key` | named key or chord (`Enter`, `Control+A`, `Meta+A`) | Tab |
327
+ | `browser_scroll` | `tabID`,`deltaY` · `deltaX?` | each −10 000…10 000 CSS px | Tab |
328
+ | `browser_wait` | `tabID`,`condition` · `text?`,`timeoutMs?` | `load\|text\|textGone` | Tab |
329
+ | `browser_screenshot` | `tabID` · `ref?`,`fullPage?`,`format?`,`quality?`,`maxWidth?` | png/jpeg/webp · quality 1–100 · width 100–4000; **"First use browser.tabs.focus and keep the desktop window visible"** | `{tab,files[]}` |
330
+ | `browser_dialog` | `tabID`,`action` · `promptText?` | `get\|accept\|dismiss`; "No dialog is reported as null" | `{tab,dialog{type,message,defaultValue}}` |
331
+ | `browser_files_upload` / `_drop` | `tabID`,`paths[]` (+`ref` for drop) | **≤5 MiB total**, bytes copied over RPC | Tab |
332
+ | `browser_files_list` | `tabID` | — | `{tab,files[{id,name,mime,bytes,path}]}` |
333
+ | `browser_files_get` | `tabID`,`fileID` | ≤5 MiB per transfer, returns a server-local path | `{tab,files}` |
334
+ | `browser_console` | `tabID` · `level?`,`limit?` | debug/info/warning/error (cumulative) | `{tab,messages[],truncated,dropped}` |
335
+ | `browser_network_list` | `tabID` · `urlContains?`,`resourceType?`,`limit?` | 12 resource types; ids exact | `{tab,requests[],truncated,dropped}` |
336
+ | `browser_network_get` | `tabID`,`id` · `includeBody?`,`maxBodyChars?` | body chars 1–20 000, bodies **not re-fetched** | `{tab,request,requestHeaders,responseHeaders,headersTruncated,requestBody,responseBody}` |
337
+ | `browser_trace_start` | `tabID` · `durationMs?` | 1 000–30 000; **one recording per desktop app** | |
338
+ | `browser_trace_stop` | `tabID` | — | `{tab,files,durationMs,incomplete}` |
339
+ | `browser_trace_analyze` | `tabID`,`fileID` · `limit?` | "Does not invent missing Web Vitals" | metrics/events/insights |
340
+ | `browser_cpu_start` / `_stop` / `_analyze` | `tabID` (+`fileID`,`limit`) | sampling bounded to 30 s; "self time is sampled, not exact" | |
341
+ | `browser_heap_snapshot` | `tabID` | ≤5 MiB **compressed** transfer; "Can briefly pause the page" | `{tab,files}` |
342
+ | `browser_heap_summary` `_query` `_object` `_compare` | `tabID`,`fileID`(s) · `limit?`/`text`/`id` | "Shallow size is not retained size; one snapshot does not prove a leak" | |
343
+ | `browser_lighthouse` | `tabID` | a11y/SEO/best-practices only; no device emulation, no perf benchmark | `{tab,files,scores[],failures[]}` |
344
+
345
+ Cross-cutting, from the connection layer `[B]`: every command runs under a **60 s** deadline
346
+ (`[browser.timeout]` "its outcome is unknown… do not repeat clicks, submissions, uploads, or
347
+ evaluations until their outcome is known"), disconnects surface as
348
+ `[browser.disconnected] No desktop browser is connected to this session` `[L]` measured 9 ms in a
349
+ standalone session, malformed page-side output becomes
350
+ "Check desktop/plugin versions; no action was authorized", and a retried protocol error is
351
+ explicitly forbidden ("do not repeat the capture to repair a protocol error").
352
+ Two of our product claims are already true on the native side and two are not: it uses **`e1`
353
+ refs** and reports **untrusted content** as a first-class warning; it has **no subresource policy,
354
+ no headless, no per-target consent** `[R]`, and `[L]` found no plugin-reachable way to establish
355
+ the desktop connection.
356
+
357
+ ## 6. Names that do not exist on v2 `[B]`
358
+
359
+ `todowrite` (one literal, in a v1→v2 migration hint: "no longer available and must not be called"),
360
+ `list` (`read` on a directory), `lsp`, `task`, `bash`, `apply_patch`, and — separately — no
361
+ `/todo` REST route string anywhere, which is the mechanical reason `tm_join`'s host-todo tripwire
362
+ has nothing to read and `tm_ledger` exists `[R]`. The migration hint also states the rename table
363
+ `bash→shell · task→subagent · apply_patch→patch`, that `read/edit/write` take **`path`** (v1
364
+ `filePath`), that `skill` takes **`id`** (v1 `name`), and that `subagent` takes `agent`/`sessionID`
365
+ (v1 `subagent_type`/`task_id`) — the host tells the model this itself when a session crosses the
366
+ upgrade, which is the same fork `V2_TEXT` maintains for us `[R]`.
367
+
368
+ ## 7. Table 1 — full inventory
369
+
370
+ Columns: **name** (model-visible) · **surface** (`direct` = provider tool definition / `catalog` =
371
+ reachable only inside `execute`) · **key params** · **output/attachments** ·
372
+ **evaluate** (`permission.hook("evaluate")` action + what the resource carries; `[B]` = the source
373
+ shows the assert, `[L]` = observed live) · **rewritable by a plugin** (`before` = `execute.before`
374
+ input, `after` = `execute.after` result; both proven mutable `[D][L]`, and `after` proven to land
375
+ for native `shell`/`execute` `[R]`).
376
+
377
+ | name | surface | key params | output / attachments | evaluate | rewritten by us today |
378
+ |---|---|---|---|---|---|
379
+ | `read` | direct | path, offset?, limit? | text-page/list-page/file + inline image·PDF | `read`←relative path; `external_directory`←`<dir>/*` | offload (closed list) `[R]` |
380
+ | `write` | direct | path, content | existed/target | `edit`←path | no (no text worth offloading) |
381
+ | `edit` | direct | path, oldString, newString, replaceAll? | files[] + replacement count | `edit`←path | no |
382
+ | `patch` | direct | patchText | applied[] | `edit`←per-file paths | no |
383
+ | `shell` | direct | **workdir?**, command, timeout?, background? | tail-truncated + `[full output saved to …]`, metadata.status/shellID | **`shell`←one resource per parsed command**; dirs→`external_directory` | offload **measured** (12 902→78) `[R]` |
384
+ | `glob` | direct | pattern, path?, hidden?, limit?=100 | absolute path lines, metadata.count | `glob`←pattern | in closed list |
385
+ | `grep` | direct | pattern, path?, include?, literal?, caseSensitive?, limit?=100 | path/line groups, metadata.matches | `grep`←pattern | in closed list |
386
+ | `webfetch` | direct | url, format?=markdown, timeout?s≤120 | text/md/html, 5 MiB cap, no images | `webfetch`←URL, **no address check** | offload + **we add the address red line** |
387
+ | `websearch` | direct | query | `## [title](url)` blocks | `websearch`←query | no (denied for non-network roles) |
388
+ | `question` | direct | questions[{question,header,options,multiple}] | answers[][] | `question`←`*` | no |
389
+ | `skill` | direct | id | name/directory/instructions | `skill`←skill id | no |
390
+ | `subagent` | direct | agent, description, prompt, model?, sessionID?, background? | `<subagent …>` envelope + metadata.sessionID/status | **`subagent`←target agent id** | offload + **forced `background:true`** `[R]` |
391
+ | `execute` | direct | **code** | `{output, toolCalls[], error?, files[]}`; files as `data:` URIs | none of its own; each nested call asserts its own | offload **measured** (15 000→58) `[R]` |
392
+ | `opencode.session_rename` / `.session_move` / `.models` / `.list_mcp_resources` / `.read_mcp_resource` | catalog | see §4 | structured | `[U]` action names not observed | no |
393
+ | 45 × `browser_*` | catalog | see §5 | Tab/State/refs/files | **none** — one action `browser` exists but is not asserted per call `[L]`; deny-with-`*` is what removes them | no (only via the request layer, which does not carry them — see §1.3) |
394
+ | `<mcpServer>_<tool>` | catalog (default) | server's JSON schema | server's schema | action = `<server>_<tool>`, resource `*` | deliberately untouched `[R]` |
395
+ | our 10 `tm_*` | catalog (because `bindV2Tool` sends no `options`) | ours | ours | our own hard-throw governance, since nothing asks | — |
396
+
397
+ ## 8. Table 2 — what this says about each `tm_*`
398
+
399
+ | tm_* | v2 status | verdict and the evidence it rests on |
400
+ |---|---|---|
401
+ | `tm_read` | **retired** `[R]` | Stands. Native `read` pages at 2 000/50 KiB, attaches images+PDF, and its P2 equivalent is the host's `external_directory` **ask** (§2.1) — a dialog beats a hard throw. Offload now covers it. |
402
+ | `tm_grep` | **retired** `[R]` | Stands, with one loss to name: native `grep` reports `No matches found` without the scope or the widening moves (§2.7), so the empty-result self-report rule is now prompt-side only. |
403
+ | `tm_bash` | **retired** `[R]` | Stands **and got stronger**: the host parses the command line per-command (`[B]`, §2.5), which is the substrate #12 wanted. Two must-not-forget deltas: `workdir` (not `cwd`), and truncation keeps the **tail**. P3 (read-only allowlist) has no native equivalent and is **not** retired. |
404
+ | `tm_ptc_run` | **retired** `[R]` | Stands: `execute` is that tool with `Promise.all` in its description (§3). Its cost is now explicit — the program's whole return is one text part, which is why `execute` must stay in `NATIVE_GOVERNED_TOOLS`. |
405
+ | `tm_search` | **keep** | Native `websearch` needs a paid/keyed provider and offers one engine, one query, no vote (§2.9). Zero-key + CN-reachable + multi-engine fusion is not replaceable here. |
406
+ | `tm_webfetch` | **keep**, retirement now *visible* as a future option | The host now does markdown-by-default, a 5 MiB cap and its own managed-storage preview (§2.8). What still is not replaceable: our `checkWebUrl` address policy (the host checks **scheme only**) and the SERP→hit-list collapse. Once the guard's coverage of native `webfetch` is proven live, this becomes a real choice rather than a one-way keep. |
407
+ | `tm_browser` | **keep, probe-gated** | The native panel is desktop-connection-only (`[L]`: plugin cannot connect; 9 ms hard error in standalone), catalog-only, has no subresource policy, no headless, no per-target consent — and its output reaches context solely as an `execute` return, so governing it means governing `execute` `[R]`. Genuine convergences worth exploiting: `e1` refs already match our snapshot convention, `preview` renders Mermaid/PDF/CSV for the user, and trace/cpu/heap/lighthouse are a capability class we never had. |
408
+ | `tm_fetch` | **keep** | Nothing in the host yields an HMAC-scoped, expiring, paged handle over *our* payloads; the host's `tool-output` files are a path, not a handle, and cross the workspace boundary. |
409
+ | `tm_memory` | **keep** | No host analogue for three tiers + dedup + compaction. |
410
+ | `tm_board_write` | **keep** | Native `write` overwrites (`existed` is reported, not enforced), takes the path from the model, and cannot source the role from `ctx.agent` (§2.2). The board contract needs all three. |
411
+ | `tm_join` | **keep, seam-limited** | The collect side still has no plugin-reachable session tree on v2; the envelope it parses is §2.12's, and the tripwire's host-todo source is gone (§6). |
412
+ | `tm_pty` | **candidate for retirement on v2** `[U→needs a decision]` | It fails closed here for the reason it exists elsewhere: `client.pty` is not on the v2 ctx, so it answers "此宿主未暴露 client.pty" `[R]`. Native `shell{background:true}` now gives a real shell ID, **an output file path**, a no-poll directive and a `<shell …>` completion injection (§2.5) — i.e. everything `tm_pty` promised minus output capture-through-REST, which `tm_pty` never had either. Note the R6 consequence before acting: that injection carries the command text back into context. |
413
+ | `tm_ledger` | **keep** (v2-only) | Justified harder by §6: `todowrite` is not merely absent from the surface, it is absent from the API and the DB-facing routes. |
414
+ | `tm_stats` | **keep** | It is the falsifiability layer; nothing native reads our trajectory. |
415
+
416
+ **One open, cheap experiment this analysis points at** (not done here — it needs the live lane):
417
+ adding `options: { codemode: false }` in `bindV2Tool` would move a chosen `tm_*` onto the **direct**
418
+ surface `[B]`. That is the only lever that ends the "find it with `search()` first" round-trip, and
419
+ its price is exactly the per-request definition tax the catalog exists to avoid. A tool name must be
420
+ provider-safe (`[A-Za-z0-9_-]{1,64}`, `[B]`), and it does **not** buy a UI card: the renderer's
421
+ registry is a closed 14-name set `[L]`.
422
+
423
+ ## 9. Insufficient evidence — do not treat these as facts
424
+
425
+ 1. **Whether a plugin registration's `options` (`codemode`, `namespace`, `permission`) is forwarded
426
+ by the v2 plugin host, or only by the internal registry.** `[B]` proves the registry honors them
427
+ and that all *host* tools set them; `[L]` proves our tools land catalog-side, which is consistent
428
+ with "not forwarded" and equally with "we never sent them". Needs one live call.
429
+ 2. **`execute`'s concrete `ToolCallLimitExceeded` / `TimeoutExceeded` budgets** — the kinds exist
430
+ `[B]`; the numbers come from config we did not read, so "unlimited" would be a guess.
431
+ 3. **Whether nested (Code Mode) calls fire `permission.hook("evaluate")` for `read`/`grep`/`shell`
432
+ the way direct ones do.** `[B]` shows nested calls re-enter the same dispatcher, `[L]` observed
433
+ `execute.before` per nested `browser_*`; an `evaluate` observation for a *nested native* call is
434
+ still missing, and it decides whether the R6/egress guards have a hole through `execute`.
435
+ 4. **The host's own offload wording/threshold** for `webfetch` / `read_mcp_resource` / `shell`
436
+ (the descriptions promise a file + preview; the exact sentence and when it fires were read from
437
+ code, not observed in a session).
438
+ 5. **`external_directory` `ask` behavior for a *background* shell command's directories**, and
439
+ whether the `<shell …>` completion injection can be intercepted by any plugin seam.
440
+ 6. **Whether the 45 browser tools' shared action `browser` can be `ask`-ed at all** (deny works
441
+ `[B]`; nothing asserts it `[L]`).
442
+ 7. **`opencode.*` tools' permission actions** — never observed live, inferred absent.
443
+ 8. **Anything about the desktop renderer's card for a `codemode:false` plugin tool** — the closed
444
+ 14-name registry is a 1.18-era finding `[L]` and has not been re-read against the 2.0.16 asar.
445
+ 9. **`tm_pty` retirement** — the host-side capability is proven (§2.5); the *governance* delta (R6
446
+ classification happens for `shell` at `evaluate`; `tm_pty` refused dangerous faces before even
447
+ asking) is ours to re-implement, not the host's to provide. Marked as needing a decision.