loadouts 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/bundled/embedded/pi/loadouts-runtime.ts +6543 -1504
  2. package/bundled/extensions/loadouts-runtime/index.ts +6543 -1504
  3. package/bundled/loadouts/opencode-runtime.yaml +2 -4
  4. package/bundled/opencode/plugins/loadouts/index.js +39198 -0
  5. package/bundled/opencode/plugins/loadouts/package.json +9 -0
  6. package/bundled/{embedded/opencode/loadouts-runtime.tsx → opencode/plugins/loadouts/tui.js} +4008 -1556
  7. package/dist/builtins/agent-transforms.d.ts.map +1 -1
  8. package/dist/builtins/agent-transforms.js +31 -10
  9. package/dist/builtins/agent-transforms.js.map +1 -1
  10. package/dist/builtins/index.d.ts.map +1 -1
  11. package/dist/builtins/index.js +14 -5
  12. package/dist/builtins/index.js.map +1 -1
  13. package/dist/builtins/kinds/opencode-cli-config.d.ts +3 -0
  14. package/dist/builtins/kinds/opencode-cli-config.d.ts.map +1 -0
  15. package/dist/builtins/kinds/opencode-cli-config.js +10 -0
  16. package/dist/builtins/kinds/opencode-cli-config.js.map +1 -0
  17. package/dist/builtins/kinds/opencode-plugin-dir.d.ts +3 -0
  18. package/dist/builtins/kinds/opencode-plugin-dir.d.ts.map +1 -0
  19. package/dist/builtins/kinds/opencode-plugin-dir.js +11 -0
  20. package/dist/builtins/kinds/opencode-plugin-dir.js.map +1 -0
  21. package/dist/builtins/tools/opencode.d.ts.map +1 -1
  22. package/dist/builtins/tools/opencode.js +10 -7
  23. package/dist/builtins/tools/opencode.js.map +1 -1
  24. package/dist/cli/commands/activate.d.ts.map +1 -1
  25. package/dist/cli/commands/activate.js +0 -7
  26. package/dist/cli/commands/activate.js.map +1 -1
  27. package/dist/cli/commands/session.d.ts +3 -0
  28. package/dist/cli/commands/session.d.ts.map +1 -0
  29. package/dist/cli/commands/session.js +68 -0
  30. package/dist/cli/commands/session.js.map +1 -0
  31. package/dist/cli/commands/status.js +1 -1
  32. package/dist/cli/commands/status.js.map +1 -1
  33. package/dist/cli/commands/sync.d.ts.map +1 -1
  34. package/dist/cli/commands/sync.js +5 -5
  35. package/dist/cli/commands/sync.js.map +1 -1
  36. package/dist/cli/index.d.ts.map +1 -1
  37. package/dist/cli/index.js +2 -0
  38. package/dist/cli/index.js.map +1 -1
  39. package/dist/core/discovery.js +1 -1
  40. package/dist/core/discovery.js.map +1 -1
  41. package/dist/core/import-discovery.d.ts.map +1 -1
  42. package/dist/core/import-discovery.js +2 -0
  43. package/dist/core/import-discovery.js.map +1 -1
  44. package/dist/core/registry.d.ts +5 -0
  45. package/dist/core/registry.d.ts.map +1 -1
  46. package/dist/core/registry.js.map +1 -1
  47. package/dist/core/render.d.ts.map +1 -1
  48. package/dist/core/render.js +29 -1
  49. package/dist/core/render.js.map +1 -1
  50. package/dist/core/resolve.d.ts.map +1 -1
  51. package/dist/core/resolve.js +1 -0
  52. package/dist/core/resolve.js.map +1 -1
  53. package/dist/core/runtime.d.ts +4 -0
  54. package/dist/core/runtime.d.ts.map +1 -1
  55. package/dist/core/runtime.js +10 -3
  56. package/dist/core/runtime.js.map +1 -1
  57. package/dist/core/schema.d.ts +59 -0
  58. package/dist/core/schema.d.ts.map +1 -1
  59. package/dist/core/schema.js +5 -0
  60. package/dist/core/schema.js.map +1 -1
  61. package/dist/core/types.d.ts +13 -0
  62. package/dist/core/types.d.ts.map +1 -1
  63. package/dist/integrations/opencode/advertisement.d.ts +7 -0
  64. package/dist/integrations/opencode/advertisement.d.ts.map +1 -0
  65. package/dist/integrations/opencode/advertisement.js +17 -0
  66. package/dist/integrations/opencode/advertisement.js.map +1 -0
  67. package/dist/integrations/opencode/client.d.ts +12 -0
  68. package/dist/integrations/opencode/client.d.ts.map +1 -0
  69. package/dist/integrations/opencode/client.js +48 -0
  70. package/dist/integrations/opencode/client.js.map +1 -0
  71. package/dist/integrations/opencode/command.d.ts +11 -0
  72. package/dist/integrations/opencode/command.d.ts.map +1 -0
  73. package/dist/integrations/opencode/command.js +19 -0
  74. package/dist/integrations/opencode/command.js.map +1 -0
  75. package/dist/integrations/opencode/delta.d.ts +15 -0
  76. package/dist/integrations/opencode/delta.d.ts.map +1 -0
  77. package/dist/integrations/opencode/delta.js +75 -0
  78. package/dist/integrations/opencode/delta.js.map +1 -0
  79. package/dist/integrations/opencode/session.d.ts +207 -0
  80. package/dist/integrations/opencode/session.d.ts.map +1 -0
  81. package/dist/integrations/opencode/session.js +201 -0
  82. package/dist/integrations/opencode/session.js.map +1 -0
  83. package/dist/lib/gitignore.d.ts +7 -3
  84. package/dist/lib/gitignore.d.ts.map +1 -1
  85. package/dist/lib/gitignore.js +15 -7
  86. package/dist/lib/gitignore.js.map +1 -1
  87. package/dist/tui/core/actions.d.ts +3 -4
  88. package/dist/tui/core/actions.d.ts.map +1 -1
  89. package/dist/tui/core/actions.js +21 -15
  90. package/dist/tui/core/actions.js.map +1 -1
  91. package/dist/tui/core/controller.d.ts +28 -0
  92. package/dist/tui/core/controller.d.ts.map +1 -0
  93. package/dist/tui/core/controller.js +78 -0
  94. package/dist/tui/core/controller.js.map +1 -0
  95. package/dist/tui/core/data.d.ts +4 -2
  96. package/dist/tui/core/data.d.ts.map +1 -1
  97. package/dist/tui/core/data.js +14 -3
  98. package/dist/tui/core/data.js.map +1 -1
  99. package/dist/tui/core/host.d.ts +5 -11
  100. package/dist/tui/core/host.d.ts.map +1 -1
  101. package/dist/tui/core/intent.d.ts +18 -3
  102. package/dist/tui/core/intent.d.ts.map +1 -1
  103. package/dist/tui/core/keymap.d.ts +2 -1
  104. package/dist/tui/core/keymap.d.ts.map +1 -1
  105. package/dist/tui/core/keymap.js +38 -6
  106. package/dist/tui/core/keymap.js.map +1 -1
  107. package/dist/tui/core/model.d.ts +18 -6
  108. package/dist/tui/core/model.d.ts.map +1 -1
  109. package/dist/tui/core/model.js +4 -3
  110. package/dist/tui/core/model.js.map +1 -1
  111. package/dist/tui/core/selection.d.ts +12 -0
  112. package/dist/tui/core/selection.d.ts.map +1 -0
  113. package/dist/tui/core/selection.js +42 -0
  114. package/dist/tui/core/selection.js.map +1 -0
  115. package/dist/tui/core/update.d.ts.map +1 -1
  116. package/dist/tui/core/update.js +140 -126
  117. package/dist/tui/core/update.js.map +1 -1
  118. package/dist/tui/core/view.d.ts.map +1 -1
  119. package/dist/tui/core/view.js +43 -31
  120. package/dist/tui/core/view.js.map +1 -1
  121. package/dist/tui/core/viewport.d.ts +5 -0
  122. package/dist/tui/core/viewport.d.ts.map +1 -0
  123. package/dist/tui/core/viewport.js +33 -0
  124. package/dist/tui/core/viewport.js.map +1 -0
  125. package/dist/tui/core/viewspec.d.ts +5 -0
  126. package/dist/tui/core/viewspec.d.ts.map +1 -1
  127. package/dist/tui/hosts/pi/embed-entry.d.ts.map +1 -1
  128. package/dist/tui/hosts/pi/embed-entry.js +74 -197
  129. package/dist/tui/hosts/pi/embed-entry.js.map +1 -1
  130. package/dist/tui/hosts/pi/inject.d.ts +46 -29
  131. package/dist/tui/hosts/pi/inject.d.ts.map +1 -1
  132. package/dist/tui/hosts/pi/inject.js +25 -47
  133. package/dist/tui/hosts/pi/inject.js.map +1 -1
  134. package/dist/tui/hosts/standalone.d.ts.map +1 -1
  135. package/dist/tui/hosts/standalone.js +75 -111
  136. package/dist/tui/hosts/standalone.js.map +1 -1
  137. package/dist/tui/skins/pi-tui/input.js +4 -0
  138. package/dist/tui/skins/pi-tui/input.js.map +1 -1
  139. package/dist/tui/skins/pi-tui/render.d.ts +6 -3
  140. package/dist/tui/skins/pi-tui/render.d.ts.map +1 -1
  141. package/dist/tui/skins/pi-tui/render.js +32 -24
  142. package/dist/tui/skins/pi-tui/render.js.map +1 -1
  143. package/docs/archive/opencode-v2-runtime-beta-18707.md +813 -0
  144. package/docs/authoring.md +47 -44
  145. package/docs/commands.md +21 -1
  146. package/docs/compatibility.md +2 -2
  147. package/docs/concepts.md +2 -2
  148. package/docs/opencode-v2-probes.md +253 -0
  149. package/docs/opencode-v2-runtime.md +129 -0
  150. package/docs/registry.md +1366 -0
  151. package/docs/runtime.md +112 -82
  152. package/docs/troubleshooting.md +17 -15
  153. package/docs/tui-architecture.md +10 -8
  154. package/package.json +12 -7
  155. package/bundled/opencode/commands/loadouts.md +0 -3
  156. package/bundled/opencode/plugins/loadouts-runtime-tui.tsx +0 -14461
  157. package/bundled/opencode/plugins/loadouts-runtime.ts +0 -533
  158. package/bundled/opencode/tui.jsonc +0 -5
  159. package/dist/builtins/kinds/opencode-tui-config.d.ts +0 -3
  160. package/dist/builtins/kinds/opencode-tui-config.d.ts.map +0 -1
  161. package/dist/builtins/kinds/opencode-tui-config.js +0 -8
  162. package/dist/builtins/kinds/opencode-tui-config.js.map +0 -1
  163. package/dist/integrations/opencode-runtime/bridge.d.ts +0 -17
  164. package/dist/integrations/opencode-runtime/bridge.d.ts.map +0 -1
  165. package/dist/integrations/opencode-runtime/bridge.js +0 -60
  166. package/dist/integrations/opencode-runtime/bridge.js.map +0 -1
  167. package/dist/integrations/opencode-runtime/command.d.ts +0 -4
  168. package/dist/integrations/opencode-runtime/command.d.ts.map +0 -1
  169. package/dist/integrations/opencode-runtime/command.js +0 -200
  170. package/dist/integrations/opencode-runtime/command.js.map +0 -1
  171. package/dist/integrations/opencode-runtime/index.d.ts +0 -6
  172. package/dist/integrations/opencode-runtime/index.d.ts.map +0 -1
  173. package/dist/integrations/opencode-runtime/index.js +0 -5
  174. package/dist/integrations/opencode-runtime/index.js.map +0 -1
  175. package/dist/integrations/opencode-runtime/plugin.d.ts +0 -59
  176. package/dist/integrations/opencode-runtime/plugin.d.ts.map +0 -1
  177. package/dist/integrations/opencode-runtime/plugin.js +0 -238
  178. package/dist/integrations/opencode-runtime/plugin.js.map +0 -1
  179. package/dist/integrations/opencode-runtime/state.d.ts +0 -8
  180. package/dist/integrations/opencode-runtime/state.d.ts.map +0 -1
  181. package/dist/integrations/opencode-runtime/state.js +0 -27
  182. package/dist/integrations/opencode-runtime/state.js.map +0 -1
  183. package/dist/integrations/opencode-runtime/types.d.ts +0 -38
  184. package/dist/integrations/opencode-runtime/types.d.ts.map +0 -1
  185. package/dist/integrations/opencode-runtime/types.js +0 -2
  186. package/dist/integrations/opencode-runtime/types.js.map +0 -1
  187. package/dist/tui/hosts/_probe/embed-entry.d.ts +0 -5
  188. package/dist/tui/hosts/_probe/embed-entry.d.ts.map +0 -1
  189. package/dist/tui/hosts/_probe/embed-entry.js +0 -8
  190. package/dist/tui/hosts/_probe/embed-entry.js.map +0 -1
@@ -0,0 +1,813 @@
1
+ # OpenCode V2 Runtime: Context Injection & Replacement
2
+
3
+ > Historical beta-18707 investigation. The implemented 2.0.3 integration is
4
+ > documented in [../opencode-v2-runtime.md](../opencode-v2-runtime.md).
5
+ > API names, limits, and recommendations below are preserved as historical evidence.
6
+
7
+ Design reference for rewriting the `opencode-runtime` loadout. The V1 runtime
8
+ (`.opencode/plugins/loadouts-runtime.ts`) targeted the V1 plugin API and is
9
+ **dead on V2** — it cannot load at all. This document records what V2 actually
10
+ offers, which parts were verified empirically, and how to build a runtime that
11
+ switches loadouts mid-session with per-session configuration.
12
+
13
+ ## Verification environment
14
+
15
+ | Item | Value |
16
+ |---|---|
17
+ | Build | `0.0.0-beta-18707` (CLI binary `opencode2`) |
18
+ | Plugin package | `@opencode-ai/plugin@0.0.0-beta-18707` |
19
+ | Date | 2026-08-31 |
20
+ | Method | Throwaway server plugin in `/tmp/opencode/v2probe/.opencode/plugins/probe/`, results appended to a file, exercised with `opencode2 run`; plus live `opencode2 api` calls against the background service |
21
+
22
+ Everything below is tagged **VERIFIED** (observed on this build) or
23
+ **DOCUMENTED** (from <https://opencode.ai/v2/docs>, not independently
24
+ confirmed). Do not trust the DOCUMENTED rows without a probe — the docs are
25
+ already wrong in at least one load-bearing place (see the system-part shape
26
+ gotcha in §3.2).
27
+
28
+ ## What changed from V1
29
+
30
+ The V1 runtime made three assumptions. Two are now false.
31
+
32
+ | V1 assumption | V2 reality |
33
+ |---|---|
34
+ | Plugin = default-exported async function returning a hook map | **False.** Loader requires an object `{ id, setup }`. Function exports fail with `SchemaError(Expected object at ["default"])`. |
35
+ | `nativeSkillHotSwap: false` — skills can only be advertised by path | **False.** `ctx.skill.transform()` adds/updates/removes skills at runtime and they appear in the skill list. |
36
+ | Instructions/rules injected via `experimental.chat.system.transform` | Renamed and restructured: `ctx.session.hook("context", …)`, plus a **native per-session instruction API** that needs no plugin at all. |
37
+
38
+ The V1 design instinct was right — key state by `sessionID`, inject a system
39
+ block, persist across restarts. V2 supplies first-class primitives for all
40
+ three, so the new runtime should be substantially smaller than the old one.
41
+
42
+ ## Contents
43
+
44
+ 1. [Bind time: the model that governs everything](#1-bind-time)
45
+ 2. [Server plugin shape and loading](#2-server-plugin-shape-and-loading)
46
+ 3. [Per-session injection mechanisms](#3-per-session-injection-mechanisms)
47
+ 4. [Catalog mutation mechanisms](#4-catalog-mutation-mechanisms)
48
+ 5. [State, transport, and UI](#5-state-transport-and-ui)
49
+ 6. [Recommended architecture](#6-recommended-architecture)
50
+ 7. [Constraints and open questions](#7-constraints-and-open-questions)
51
+ 8. [Repro recipes](#8-repro-recipes)
52
+
53
+ ---
54
+
55
+ ## 1. Bind time
56
+
57
+ Every "can this vary per session?" question reduces to **when the value is
58
+ resolved**. V2 has three bind times, and the runtime must use a different
59
+ mechanism for each.
60
+
61
+ | Bind time | Resolved | Examples | Varies per session? |
62
+ |---|---|---|---|
63
+ | **Discovery** | When a *location* loads; cached until evicted or reloaded | skill list, commands, agents, MCP servers, references | No — location-wide. Mutable at runtime via transforms + `reload()`, but the result is still shared by every session at that location. |
64
+ | **Assembly** | On every model call | system parts, messages, tools exposed, generation settings | **Yes** — `session.hook("context")` receives `sessionID`. |
65
+ | **Persistence** | Written once, replayed into assembly | instruction entries, synthetic messages, session agent/model | **Yes** — stored against the session by the server. |
66
+
67
+ Consequences for the runtime:
68
+
69
+ - **Instructions and rules** are trivially per-session: they are assembly-time
70
+ content. Two mechanisms exist (§3.1, §3.3); pick by whether you want the
71
+ server to persist them for you.
72
+ - **Skills** are discovery-time. `skill.transform` can add or remove them
73
+ live, but the effect is location-wide, not session-scoped. Making skills
74
+ *appear* per-session requires gating at assembly time (§7.1).
75
+ - **Agents** are discovery-time definitions with a per-session *selection*.
76
+ Switching the agent is the cheapest per-session behavior change available.
77
+
78
+ A useful sanity check when designing: if a value is read while building the
79
+ prompt, it can be session-scoped; if it is read while indexing the filesystem,
80
+ it cannot.
81
+
82
+ ---
83
+
84
+ ## 2. Server plugin shape and loading
85
+
86
+ ### 2.1 Required export shape — VERIFIED
87
+
88
+ ```ts
89
+ import { Plugin } from "@opencode-ai/plugin"
90
+
91
+ export default Plugin.define({
92
+ id: "loadouts.runtime",
93
+ async setup(ctx) {
94
+ // register transforms and hooks here
95
+ return () => {
96
+ /* optional cleanup on unload */
97
+ }
98
+ },
99
+ })
100
+ ```
101
+
102
+ `Plugin.define` is a typing/identity helper. The loader validates the
103
+ *shape*, so a plain object literal `{ id, setup }` loads identically and
104
+ without the dependency — useful for probes:
105
+
106
+ ```ts
107
+ export default { id: "probe", async setup(ctx: any) { /* … */ } }
108
+ ```
109
+
110
+ Observed loader failures on this build, all from V1-era shapes:
111
+
112
+ | Export | Error |
113
+ |---|---|
114
+ | `export default async function(ctx) { return { hooks } }` | `SchemaError(Expected object at ["default"])` |
115
+ | object missing `setup` | `SchemaError(Missing key at ["default"]["setup"])` |
116
+ | any shape, unresolvable import | `Die(ResolveMessage: Cannot find package '@opencode-ai/plugin')` |
117
+
118
+ Currently failing this way in Evatt's environment on every startup:
119
+ `wrap-it-up.ts`, `wrap-it-up-v2.ts`, `workmux-status.ts`,
120
+ `opencode-rules@latest`, and the V1 `loadouts-runtime.ts`.
121
+
122
+ ### 2.2 Loading and resolution — VERIFIED
123
+
124
+ - Plugins in `<location>/.opencode/plugins/` load automatically. Packages and
125
+ other paths go in `opencode.json(c)` under `plugins`.
126
+ - **Import resolution is not the plugin's own directory.** Installing
127
+ `@opencode-ai/plugin` into the project (`bun add`) still produced
128
+ `Cannot find package` for a plugin under `.opencode/plugins/`. Publish the
129
+ runtime as a package, or avoid the import in loose local files.
130
+ - Pin the plugin package to the running build. `bun add
131
+ @opencode-ai/plugin@beta` resolved to exactly `0.0.0-beta-18707`, matching
132
+ the binary. The API is beta and shapes move between builds.
133
+
134
+ ### 2.3 Reload semantics — VERIFIED, with a trap
135
+
136
+ - `setup` re-runs when the location reloads; registrations from a previous
137
+ load **persist in the running process** until disposed or the location is
138
+ genuinely evicted. During probing, a hook kept firing after its plugin file
139
+ had been rewritten, producing results that looked impossible.
140
+ - `DELETE /api/debug/location?location=<dir>` returned success but the
141
+ directory remained in `GET /api/debug/location`, and `setup` did not re-run.
142
+ Treat eviction as unreliable.
143
+ - `opencode2 service restart` forces a clean load **and kills every attached
144
+ client**, including the session you are working in. Do not call it from
145
+ inside a session you care about.
146
+ - Registrations expose `dispose()`; prefer disposing and re-registering over
147
+ attempting to reload the location.
148
+
149
+ `ctx` keys observed at setup on this build:
150
+
151
+ ```
152
+ agent, aisdk, app, catalog, command, event, experimental, generate,
153
+ integration, location, mcp, options, permission, plugin, reference,
154
+ session, shell, skill, storage, tool, vcs, websearch
155
+ ```
156
+
157
+ ---
158
+
159
+ ## 3. Per-session injection mechanisms
160
+
161
+ ### 3.1 `session.hook("context")` — the primary injection point — VERIFIED
162
+
163
+ The direct replacement for V1's `experimental.chat.system.transform`, and the
164
+ mechanism the new runtime should be built around.
165
+
166
+ ```ts
167
+ await ctx.session.hook("context", (event) => {
168
+ // event.sessionID, event.agent, event.model are readonly
169
+ event.system.push({ type: "text", text: renderSystemBlock(bundle) })
170
+ })
171
+ ```
172
+
173
+ Observed firing on a real turn:
174
+
175
+ ```
176
+ context-hook=FIRED session=ses_fa9e92a52ffeeg8xFfdzaS5BVI agent=build
177
+ systemPartsBefore=2 tools=14 messages=1
178
+ ```
179
+
180
+ Properties:
181
+
182
+ - Runs **on every model call**, including tool-driven continuations. Does
183
+ **not** run for title generation or compaction requests.
184
+ - Mutations affect only the outgoing request — nothing is persisted to session
185
+ history or config. Perfect for swappable config: stop pushing and the next
186
+ turn is clean.
187
+ - Gives you `system` (parts), `messages`, `tools`, `generation`, and
188
+ `providerOptions`. Deleting `event.tools.write` removes that tool for this
189
+ call only — a per-session tool policy without touching permissions.
190
+ - Hooks run in registration order and see earlier hooks' edits.
191
+ - Optional `{ providerID }` scoping for provider-specific tweaks.
192
+
193
+ **Gotcha — the docs are wrong.** The official example pushes `{ text: "…" }`.
194
+ On beta-18707 that makes every turn fail with `Error: Schema validation
195
+ failed`. The required shape is tagged:
196
+
197
+ ```ts
198
+ event.system.push({ text: "…" }) // ✗ Schema validation failed
199
+ event.system.push({ type: "text", text: "…" }) // ✓ works
200
+ ```
201
+
202
+ Isolated by bisection: a control directory with no plugin ran clean; removing
203
+ the injected skill did not fix it; changing only the part shape did.
204
+
205
+ ### 3.2 Instruction entries API — native, persisted, no plugin — VERIFIED
206
+
207
+ The server stores arbitrary instruction entries **per session** and replays
208
+ them into the prompt. No plugin required.
209
+
210
+ ```
211
+ GET /api/session/{sessionID}/instructions/entries
212
+ PUT /api/session/{sessionID}/instructions/entries/{key} body: { "value": … }
213
+ DELETE /api/session/{sessionID}/instructions/entries/{key}
214
+ ```
215
+
216
+ Isolation proof:
217
+
218
+ ```bash
219
+ opencode2 api put /api/session/$A/instructions/entries/probe \
220
+ -d '{"value":"Probe instruction: respond in haiku."}'
221
+
222
+ opencode2 api get /api/session/$A/instructions/entries
223
+ # {"data":[{"key":"probe","value":"Probe instruction: respond in haiku."}]}
224
+
225
+ opencode2 api get /api/session/$B/instructions/entries
226
+ # {"data":[]} ← control session unaffected
227
+ ```
228
+
229
+ Notes:
230
+
231
+ - Keyed, so a runtime can own key `loadouts` and replace it wholesale on
232
+ switch, leaving other producers' entries intact. `DELETE` is a clean
233
+ deactivate.
234
+ - Survives restarts without hand-rolled cache files (V1 wrote
235
+ `~/.cache/loadouts/opencode-runtime/<sha256(cwd)>.json` — no longer needed).
236
+ - `PUT` can return **413**; there is an
237
+ `InstructionEntryValueTooLargeError`. A full multi-loadout instruction blob
238
+ may exceed it — chunk across keys or fall back to the context hook. The
239
+ exact limit was not measured.
240
+ - Only surfaces entries for sessions the server currently has loaded; a
241
+ request against a session belonging to an unloaded location returned an
242
+ empty body.
243
+
244
+ **Choosing between 3.1 and 3.2:** the context hook is ephemeral, unlimited in
245
+ size, and requires a live plugin; instruction entries are persisted, size
246
+ limited, and work even with no plugin loaded (e.g. from a CLI or the TUI
247
+ plugin directly). A runtime can use both: entries as the durable record,
248
+ the hook for large or computed content.
249
+
250
+ ### 3.3 `session.hook("prompt")` — per-prompt attachment — DOCUMENTED
251
+
252
+ ```ts
253
+ await ctx.session.hook("prompt", (event) => {
254
+ event.skills.push("shorehouse-research") // resolved through the normal path
255
+ event.files ??= []
256
+ event.files.push({ uri: "file:///project/policy.md" })
257
+ event.delivery = "queue"
258
+ })
259
+ ```
260
+
261
+ Runs once per admitted user prompt (not per model call), and its edits become
262
+ the **canonical persisted user input**. Notably it can add `skills` to a
263
+ prompt — the closest thing to session-scoped skill activation. Does not run
264
+ for synthetic messages, shell messages, or compaction. Cannot be scoped by
265
+ provider. Must be retry-safe: concurrent submissions may run it more than once.
266
+
267
+ ### 3.4 Session-scoped selections — DOCUMENTED
268
+
269
+ ```ts
270
+ await ctx.session.switchAgent({ sessionID, agent: "planner" })
271
+ await ctx.session.switchModel({ sessionID, model: { providerID, id } })
272
+ await ctx.session.synthetic({ sessionID, text: "Loadout switched to review" })
273
+ await ctx.session.command({ sessionID, command: "review", arguments: "--staged" })
274
+ ```
275
+
276
+ `switchAgent` is the cheapest real per-session config change: agents carry
277
+ their own prompt, model, tools, and skill permissions. If a loadout maps
278
+ cleanly onto an agent, prefer this over injection.
279
+
280
+ `POST /api/session/{id}/skill` (`{ skill, id?, resume? }`) activates a skill
281
+ into a session, adding its Markdown body to that session's context — a
282
+ per-session skill push, complementary to the location-wide `skill.transform`.
283
+
284
+ ### 3.5 Advertisement rewriting — per-session skill lists with the SAME agent — VERIFIED
285
+
286
+ The `<available_skills>` block is not magic: it arrives as a **system part**
287
+ that the context hook can rewrite per session. This gives per-session skill
288
+ visibility without `switchAgent` and without touching the catalog.
289
+
290
+ Probe results, same agent (`build`), same location, same on-disk catalog:
291
+
292
+ ```
293
+ turn 1: agent=build strip=false adBefore=true ← untouched
294
+ turn 2: agent=build strip=true adBefore=true adAfter=false
295
+ ```
296
+
297
+ ```ts
298
+ await ctx.session.hook("context", (event) => {
299
+ const runtime = runtimes.get(event.sessionID) // key by session, not by message
300
+ if (!runtime?.hiddenSkills?.length) return
301
+ const part = event.system.find(
302
+ (p) => typeof p?.text === "string" && p.text.includes("<available_skills>"),
303
+ )
304
+ if (!part) return
305
+ for (const id of runtime.hiddenSkills) {
306
+ part.text = part.text.replace(new RegExp(`<skill>\\s*<id>${id}</id>[\\s\\S]*?</skill>`), "")
307
+ }
308
+ })
309
+ ```
310
+
311
+ Caveats:
312
+
313
+ - **String surgery on the block format.** The `<skill><id>…</id>…</skill>`
314
+ shape moved once already (docs vs build); wrap the rewrite defensively and
315
+ no-op if the marker is missing rather than corrupting the prompt.
316
+ - **Presentation, not enforcement.** The `skill` tool still exists and the
317
+ catalog still contains the hidden skill; a model that guesses an ID can
318
+ still load it. If enforcement matters, pair the rewrite with an agent
319
+ `skill` permission `deny` (§7.1) or `skill.transform` removal (§4.1).
320
+ - Per-turn, not persisted: the rewrite applies for the calls it runs on, which
321
+ is exactly right for swappable config.
322
+
323
+ ### 3.6 The watcher is location-scoped — VERIFIED (observed, not yet probed)
324
+
325
+ V2 watches config and instruction sources (`watcher subscribe` log lines for
326
+ `.opencode/skills`, `AGENTS.md`, even loadout skill directories) and hot-loads
327
+ them. This is the right mechanism for **"pick up a `loadouts sync` without
328
+ restarting the server"** — but it updates the location-wide catalog, so it
329
+ cannot vary config per session, and concurrent sessions at one location race
330
+ on whichever state loaded last. Use the watcher for live global updates; use
331
+ the context hook (§3.1, §3.5) or agent switching (§7.1) for per-session state.
332
+ Two different tools for two different scopes.
333
+
334
+ ---
335
+
336
+ ## 4. Catalog mutation mechanisms
337
+
338
+ These change what exists at a **location**. They are the reason the new
339
+ runtime no longer needs to render files to disk in order to change an agent's
340
+ capabilities.
341
+
342
+ ### 4.1 `skill.transform` — dynamic skills — VERIFIED
343
+
344
+ This overturns V1's `nativeSkillHotSwap: false`.
345
+
346
+ ```ts
347
+ await ctx.skill.transform((draft) => {
348
+ draft.add({
349
+ id: "v2probe-skill",
350
+ name: "v2probe-skill",
351
+ description: "probe-injected skill, no file on disk",
352
+ location: "/tmp/opencode/v2probe/virtual.md", // need not exist
353
+ content: "Body injected by a plugin transform, not read from disk.",
354
+ })
355
+ draft.update("review", (skill) => { skill.autoinvoke = true })
356
+ draft.remove("legacy")
357
+ })
358
+ await ctx.skill.reload() // replay transforms after external state changes
359
+ ```
360
+
361
+ Proof: after injection, `ctx.skill.list()` reported **20** skills including
362
+ `v2probe-skill`, against 19 real ones on disk. The `location` file did not
363
+ exist, confirming `content` is authoritative and skills need not be
364
+ filesystem-backed.
365
+
366
+ `Skill.Info`: `id`, `name`, `location`, `content` required; `description`,
367
+ `slash`, `autoinvoke` optional.
368
+
369
+ Implication for loadouts: a runtime can present a loadout's skills **without
370
+ `loadouts sync` writing anything**, and remove them on deactivate. Rendering
371
+ to `~/.claude/skills` etc. remains necessary only for other tools.
372
+
373
+ Caveat: transforms are location-scoped and survive plugin reloads within a
374
+ process. During probing an injected skill remained in the catalog after the
375
+ `draft.add` call had been disabled — dispose registrations explicitly rather
376
+ than assuming a code change clears them.
377
+
378
+ ### 4.2 Other transforms — DOCUMENTED
379
+
380
+ All follow the same `transform` / `reload` / `dispose` pattern:
381
+
382
+ | Context | Draft operations | Use for loadouts |
383
+ |---|---|---|
384
+ | `ctx.command.transform` | `add({ name, description, execute })` | Register `/loadouts` properly. Replaces V1's `config.command` mutation plus the `command.execute.before` interception hack, which threw a sentinel error to suppress output. |
385
+ | `ctx.agent.transform` | `list/get/update/remove/default(id)` | Publish a loadout's agents; set the default agent per loadout. |
386
+ | `ctx.tool.transform` | `add/update/remove` | Loadout-provided tools. Per-call removal belongs in the context hook instead. |
387
+ | `ctx.mcp.transform` | `set/update/remove`, `disabled` | Enable a loadout's MCP servers (e.g. `websearch`) without editing config. |
388
+ | `ctx.reference.transform` | `add(name, source)` | Attach a loadout's reference roots (e.g. the Shorehouse vault). |
389
+ | `ctx.catalog.transform` | provider/model update, `model.default.set` | Loadout-specific default models. |
390
+ | `ctx.websearch.transform` | `add`, `default.set(id \| false)` | Swap or disable search per loadout. |
391
+
392
+ Two properties worth designing around:
393
+
394
+ - **Transforms compose in registration order** and are replayed on `reload()`,
395
+ so later plugins observe earlier plugins' output. A loadout runtime should
396
+ register one transform per artifact kind and re-run `reload()` on switch,
397
+ rather than mutating shared state directly.
398
+ - **Disposal reveals what it overrode.** `registration.dispose()` rebuilds
399
+ from remaining transforms, which gives clean deactivation semantics for free.
400
+
401
+ ### 4.3 Command registration — DOCUMENTED
402
+
403
+ ```ts
404
+ await ctx.command.transform((draft) => {
405
+ draft.add({
406
+ name: "loadouts",
407
+ description: "Manage session-local loadouts",
408
+ execute: async ({ sessionID, prompt, delivery }) => {
409
+ // parse prompt.text, mutate runtime state for this sessionID
410
+ },
411
+ })
412
+ })
413
+ ```
414
+
415
+ The executor receives `sessionID`, so a slash command is naturally
416
+ session-scoped — exactly what `/loadouts activate x` needs, without the V1
417
+ trick of throwing `LoadoutsRuntimeCommandHandledError` to swallow output.
418
+
419
+ ---
420
+
421
+ ## 5. State, transport, and UI
422
+
423
+ ### 5.1 `ctx.storage` — durable plugin state — VERIFIED
424
+
425
+ ```ts
426
+ await ctx.storage.set("session/" + sessionID, { names, fingerprint, at })
427
+ const state = await ctx.storage.get("session/" + sessionID)
428
+ await ctx.storage.remove("session/" + sessionID)
429
+ const page = await ctx.storage.scan({ prefix: "session/", limit: 100 })
430
+ ```
431
+
432
+ Probe confirmed a set/get round trip inside `setup`. This **replaces the whole
433
+ V1 persistence layer**: `runtimeStatePath()`, `runtimeEventPath()`, the
434
+ sha256-of-cwd keying, `readPersistedStates`, `persistRuntimeState`, and the
435
+ `.event.json` sidecar can all be deleted. Storage is JSON, plugin-scoped,
436
+ durable across restarts, and prefix-scannable — key by `sessionID` directly.
437
+
438
+ ### 5.2 RPC — server ↔ TUI transport — DOCUMENTED
439
+
440
+ V1 had the TUI plugin read the server plugin's cache files off disk. V2 has a
441
+ typed channel instead.
442
+
443
+ ```ts title="src/rpc.ts"
444
+ import { Rpc } from "@opencode-ai/plugin/rpc"
445
+ import { z } from "zod"
446
+
447
+ export const Loadouts = Rpc.define({
448
+ id: "loadouts",
449
+ methods: {
450
+ status: { input: z.object({ sessionID: z.string() }),
451
+ output: z.object({ names: z.array(z.string()), fingerprint: z.string() }) },
452
+ activate: { input: z.object({ sessionID: z.string(), names: z.array(z.string()) }),
453
+ output: z.object({ ok: z.boolean() }) },
454
+ },
455
+ events: { changed: { schema: z.object({ sessionID: z.string(), names: z.array(z.string()) }) } },
456
+ })
457
+ ```
458
+
459
+ Server registers with `ctx.rpc.register(Loadouts, { … })`; the TUI plugin gets
460
+ a handle and subscribes to `changed` for live dashboard updates. Keep the
461
+ contract in its own browser-safe module and export it as `./rpc` so both sides
462
+ import it without loading the implementation.
463
+
464
+ Caveat: subscriptions are live-only with no replay, so the TUI must fetch
465
+ current state on mount rather than relying on having heard the event.
466
+
467
+ ### 5.3 TUI plugin surface — VERIFIED (separately)
468
+
469
+ The CLI plugin is a **separate module** from the server plugin, imported from
470
+ `@opencode-ai/plugin/tui`, and resolved at runtime (no local install needed —
471
+ unlike server plugins, §2.2). It uses `Plugin.define({ id, setup })` too.
472
+
473
+ ```tsx
474
+ return context.ui.slot({
475
+ append: "sidebar.footer",
476
+ render: () => <box border><text>Loadouts: base·meta</text></box>,
477
+ })
478
+ ```
479
+
480
+ Available slots: `app`, `home.footer`, `prompt.footer`,
481
+ `prompt.footer.status`, `prompt.footer.file`, `session.composer.top`,
482
+ `sidebar.content`, `sidebar.footer`. Also available: `ui.dialog.*` (alert,
483
+ confirm, prompt, select, custom JSX), `ui.router.register` for full routes,
484
+ `ui.toast.show`, `keymap.layer` for palette/slash/key commands, and
485
+ `context.data.location.{skill,command,agent,reference,mcp}.{list,sync}` for
486
+ reading the catalog without server calls.
487
+
488
+ A working reference implementation of the sidebar pattern lives in Evatt's
489
+ dotfiles at `opencode/plugins/context-sidebar.tsx`, which renders live skill /
490
+ command / agent / reference / MCP counts plus active global and local loadouts.
491
+
492
+ Note the API generation split: older builds used `{ id, tui }` with
493
+ `api.slots.register` and `api.ui.dialog.replace`; current builds use
494
+ `Plugin.define` with `context.ui.slot`. Check which the target build accepts —
495
+ `toy-popup.tsx` in that repo is an example of the older, now-rejected shape.
496
+
497
+ ### 5.4 Events — DOCUMENTED
498
+
499
+ ```ts
500
+ const controller = new AbortController()
501
+ void (async () => {
502
+ for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
503
+ if (event.type === "session.updated") { /* … */ }
504
+ }
505
+ })()
506
+ return () => controller.abort()
507
+ ```
508
+
509
+ Useful for reacting to session creation (auto-apply a default loadout) or to
510
+ `loadouts sync` completing. The TUI side has `context.data.on(type, cb)` and
511
+ `context.data.listen(cb)`.
512
+
513
+ ---
514
+
515
+ ## 6. Recommended architecture
516
+
517
+ ### 6.1 Shape
518
+
519
+ Three modules, one package:
520
+
521
+ ```
522
+ src/rpc.ts Rpc.define contract (browser-safe, exported as "./rpc")
523
+ src/index.ts server plugin: state, injection, command → exports "."
524
+ src/tui.tsx CLI plugin: dashboard, slot, keybinds → exports "./tui"
525
+ ```
526
+
527
+ Publishing as a package (rather than loose files in `.opencode/plugins/`)
528
+ solves the import-resolution problem in §2.2 and lets `./tui` load
529
+ automatically alongside the server plugin.
530
+
531
+ ### 6.2 Core state model
532
+
533
+ One in-memory map mirrored to `ctx.storage`, keyed by session:
534
+
535
+ ```ts
536
+ type SessionRuntime = {
537
+ names: string[] // active loadout names for THIS session
538
+ scope: "local" | "global"
539
+ bundle: RuntimeBundle // from `loadouts runtime <names> --tool opencode --json`
540
+ systemBlock: string // rendered once at activation, not per turn
541
+ fingerprint: string
542
+ activatedAt: string
543
+ }
544
+
545
+ const runtimes = new Map<string, SessionRuntime>()
546
+ ```
547
+
548
+ Register **one** long-lived context hook that reads the map — do not
549
+ register/dispose a hook per switch:
550
+
551
+ ```ts
552
+ await ctx.session.hook("context", (event) => {
553
+ const runtime = runtimes.get(event.sessionID)
554
+ if (!runtime) return // session uses global config
555
+ event.system.push({ type: "text", text: runtime.systemBlock }) // tagged shape!
556
+ })
557
+ ```
558
+
559
+ Mid-session switching is then just a map write: the next model call picks up
560
+ the new block, and `runtimes.delete(sessionID)` restores default behavior. No
561
+ reload, no restart, no file rendering.
562
+
563
+ ### 6.3 Layer responsibilities
564
+
565
+ | Loadout artifact | Mechanism | Scope achieved |
566
+ |---|---|---|
567
+ | instructions, rules | context hook system block (§3.1); mirror to instruction entries (§3.2) for durability | true per-session |
568
+ | skills | `skill.transform` add/remove (§4.1) | location-wide; see §7.1 for per-session gating |
569
+ | agents | `agent.transform`, or `switchAgent` per session (§3.4) | selection is per-session |
570
+ | commands | `command.transform` (§4.3) | location-wide |
571
+ | MCP servers | `mcp.transform` with `disabled` (§4.2) | location-wide |
572
+ | references | `reference.transform` (§4.2) | location-wide |
573
+ | default model | `catalog.transform` `model.default.set`, or `switchModel` | selection is per-session |
574
+
575
+ The honest capability line for a V2 bundle header, replacing V1's:
576
+
577
+ ```
578
+ capabilities: mode=v2, instructionInjection=true, ruleInjection=true,
579
+ skillHotSwap=true(location), skillPerSession=false,
580
+ agentSwitch=true, mcpToggle=true, persistence=plugin-storage
581
+ ```
582
+
583
+ ### 6.4 Command surface
584
+
585
+ ```
586
+ /loadouts status for this session
587
+ /loadouts activate <names…> set session runtime [-l | -g]
588
+ /loadouts deactivate clear session runtime
589
+ /loadouts list available loadouts
590
+ /loadouts diff session runtime vs global .state.json
591
+ ```
592
+
593
+ Implement via `ctx.command.transform` (§4.3), which supplies `sessionID`
594
+ directly. Shell out to the existing CLI for compilation — `loadouts runtime
595
+ <names> --tool opencode --json` already emits the bundle, and reusing it keeps
596
+ one source of truth.
597
+
598
+ Given the verified results in §3.5 and §7.1 there are now **two** viable
599
+ `activate` designs, both without `skill.transform` bookkeeping:
600
+
601
+ **A. Same agent (recommended default).** Keep `build` (or whatever the session
602
+ selected); the runtime holds per-session state only:
603
+
604
+ 1. context hook pushes the loadout's instruction delta (§3.1),
605
+ 2. same hook rewrites the `<available_skills>` part to the session's skill
606
+ set (§3.5),
607
+ 3. `ctx.storage.set("session/" + sessionID, …)` as the record.
608
+
609
+ One hook, one map lookup, no agent juggling, works for every session including
610
+ subagents that pin a specific agent.
611
+
612
+ **B. Agents as loadouts.** Render each loadout as an agent and
613
+ `ctx.session.switchAgent(...)`. Structured and enforced (skill `deny` is real
614
+ permission filtering, not presentation), and it scopes tools/model/prompt in
615
+ one call — but it replaces the session's agent, which the user may not want,
616
+ and subagent selection interacts with it.
617
+
618
+ Hybrid: A for skills visibility + instruction delta, with an optional `deny`
619
+ ruleset via a per-loadout agent override when a loadout genuinely needs a
620
+ different tool/permission profile.
621
+
622
+ ### 6.5 TUI dashboard
623
+
624
+ Sidebar slot showing global active set, local active set, and — the new part —
625
+ this session's runtime override with its fingerprint:
626
+
627
+ ```
628
+ Loadouts
629
+ G base·meta·shorehouse+2
630
+ L none
631
+ S caveman·impeccable (a1b2c3d4) ← session override
632
+ ```
633
+
634
+ Pull `S` over RPC (§5.2), not by reading cache files. Fetch on mount, then
635
+ update from the `changed` event.
636
+
637
+ ### 6.6 Bugs to fix in the CLI while you are here
638
+
639
+ - `loadouts runtime --tool opencode --system-block` with no names fails with
640
+ `Loadout not found: evatt` — it mis-resolves the default/root name. Explicit
641
+ names work. Same for `--json`.
642
+ - `loadouts runtime opencode` reads as *loadout named opencode*; the tool is
643
+ `--tool`. Worth a clearer error.
644
+
645
+ ---
646
+
647
+ ## 7. Constraints and open questions
648
+
649
+ ### 7.1 Skills CAN be hidden per session — via agent permissions — VERIFIED
650
+
651
+ `skill.transform` is location-wide, but the **advertisement is per-agent**.
652
+ Probed directly on beta-18707: a skill `probe-hidden` on disk appeared in the
653
+ `build` agent's `<available_skills>` system block (dumped via a context hook),
654
+ and was **absent** from a `restricted` agent's dump when that agent carried
655
+ `{ action: "skill", resource: "probe-hidden", effect: "deny" }`. The control
656
+ skill `probe-visible` appeared in both.
657
+
658
+ ```
659
+ agent=build parts=2 hasProbeHidden=true hasProbeVisible=true
660
+ agent=restricted parts=2 hasProbeHidden=false hasProbeVisible=true
661
+ ```
662
+
663
+ Agent definition used:
664
+
665
+ ```md title=".opencode/agents/restricted.md"
666
+ ---
667
+ description: Probe agent that denies probe-hidden
668
+ mode: primary
669
+ permissions:
670
+ - action: skill
671
+ resource: probe-hidden
672
+ effect: deny
673
+ ---
674
+ You are the restricted probe agent. Reply concisely.
675
+ ```
676
+
677
+ So the advertised skill list is computed from the **session's selected agent**,
678
+ which means:
679
+
680
+ - Two sessions at the same location can have disjoint skill sets — switch the
681
+ agent, and the advertisement changes on the next model call.
682
+ - Mapping a loadout onto an agent (its system prompt + skill permissions +
683
+ model + tool permissions) gives true per-session scoping for skills,
684
+ instructions, tools, and model in one move.
685
+ - `deny` hides from discovery; the docs also claim it rejects explicit
686
+ loading, which remains unverified — `POST /api/session/{id}/skill` returned
687
+ `SkillNotFoundError` on this build **even for an existing permitted skill**
688
+ (control included), so that endpoint is anomalous on beta-18707 and the
689
+ invocation-rejection path needs a different test.
690
+ - `opencode/autoinvoke: false` in frontmatter is the other hiding lever, but
691
+ it is per-skill, not per-agent, and not runtime-mutable without
692
+ `skill.transform`.
693
+
694
+ Design consequence: agents are **one** per-session scoping primitive —
695
+ verified, structured, and enforced — but not the only one. Rewriting the
696
+ advertisement part (§3.5) achieves per-session skill *visibility* with the
697
+ session's existing agent, which is usually what a loadout switch wants.
698
+ Rule of thumb: use §3.5 when the loadout changes what the model *sees*; use
699
+ agent permissions/switching when it changes what the model is *allowed* to
700
+ do. See §6.4 for both designs.
701
+
702
+ ### 7.2 Prompt cache invalidation
703
+
704
+ The injected system block sits in the cached prefix. Changing it mid-session
705
+ invalidates the provider's prompt cache and forces a full re-read of the
706
+ conversation on the next turn — real money on long sessions. Mitigations:
707
+ render the block once at activation (not per turn), keep key order stable, and
708
+ warn in `/loadouts activate` output that switching costs a cache miss.
709
+
710
+ ### 7.3 Compaction
711
+
712
+ The context hook does not run for compaction requests, so the injected block is
713
+ absent while summarizing — the summary will not describe the active loadout.
714
+ It reappears on the next normal turn. Whether an active loadout should survive
715
+ compaction *semantically* (e.g. re-announced via `session.synthetic`) is a
716
+ design decision.
717
+
718
+ ### 7.4 Instruction entry size
719
+
720
+ `PUT` can return 413. The limit was not measured; measure it before routing
721
+ full instruction bodies through §3.2, and chunk by key or fall back to the
722
+ context hook.
723
+
724
+ ### 7.5 Interaction with `loadouts sync`
725
+
726
+ A session runtime and the globally synced set can disagree. The rendered
727
+ `AGENTS.md` is already in the prompt, so injecting the same instructions again
728
+ double-counts tokens and may conflict. The runtime should diff against
729
+ `<root>/.state.json` (`{ active, mode, appliedAt, entries }`) and inject only
730
+ the delta, or explicitly declare an override in the block.
731
+
732
+ ### 7.6 Verified / unverified summary
733
+
734
+ | Mechanism | Status |
735
+ |---|---|
736
+ | Object export `{ id, setup }` loads; function export fails | VERIFIED |
737
+ | `session.hook("context")` fires per session with system/messages/tools | VERIFIED |
738
+ | System part requires `{ type: "text", text }` | VERIFIED |
739
+ | `skill.transform` injects a skill visible in `skill.list()` | VERIFIED |
740
+ | `ctx.storage` set/get round trip | VERIFIED |
741
+ | Instruction entries are per-session isolated | VERIFIED |
742
+ | Location eviction endpoint is unreliable | VERIFIED |
743
+ | `session.hook("prompt")` skill/file attachment | DOCUMENTED |
744
+ | `POST /session/{id}/skill` activation semantics | BROKEN on beta-18707 — `SkillNotFoundError` even for existing, permitted skills (control included) |
745
+ | RPC server ↔ TUI | DOCUMENTED |
746
+ | Per-agent skill `deny` hides the skill from the `<available_skills>` advertisement | **VERIFIED** (§7.1) |
747
+ | Context hook rewrites the `<available_skills>` system part per session (same agent) | **VERIFIED** (§3.5) |
748
+ | Instruction entry size limit | UNKNOWN — measure |
749
+
750
+ ---
751
+
752
+ ## 8. Repro recipes
753
+
754
+ ### 8.1 Probe plugin
755
+
756
+ Create `<dir>/.opencode/plugins/probe/index.ts` with no imports (avoids §2.2),
757
+ append results to a file rather than `console.log` (plugin stdout does not
758
+ reliably reach `~/.local/share/opencode/log/opencode.log`):
759
+
760
+ ```ts
761
+ import fs from "node:fs"
762
+ const log = (l: string) => { try { fs.appendFileSync("/tmp/opencode/probe.log", l + "\n") } catch {} }
763
+
764
+ export default {
765
+ id: "probe",
766
+ async setup(ctx: any) {
767
+ log("keys=" + Object.keys(ctx).sort().join(","))
768
+ await ctx.session.hook("context", (e: any) => {
769
+ log(`context session=${e.sessionID} agent=${e.agent} tools=${Object.keys(e.tools).length}`)
770
+ })
771
+ },
772
+ }
773
+ ```
774
+
775
+ Exercise it with a cheap turn:
776
+
777
+ ```bash
778
+ cd <dir> && opencode2 run "reply with exactly: ok"
779
+ cat /tmp/opencode/probe.log
780
+ ```
781
+
782
+ **Do not delete the log file between runs** while iterating — registrations
783
+ from earlier loads stay live and keep writing, and deleting mid-sequence
784
+ destroys the evidence that explains what you are seeing.
785
+
786
+ ### 8.2 Inspecting live state
787
+
788
+ ```bash
789
+ opencode2 api get /api/skill # catalog incl. injected skills
790
+ opencode2 api get /api/agent
791
+ opencode2 api get /api/session/active
792
+ opencode2 api get /api/session/<id>/context # messages in the window
793
+ opencode2 api get /api/session/<id>/instructions/entries
794
+ opencode2 api get /api/debug/location # loaded locations
795
+ opencode2 debug config # instruction/config SOURCES
796
+ curl -s https://opencode.ai/v2/openapi.json # authoritative schemas
797
+ ```
798
+
799
+ Note `opencode` (V1) and `opencode2` (V2) are different binaries; only
800
+ `opencode2` has `api`.
801
+
802
+ ### 8.3 Bisecting a runtime failure
803
+
804
+ The `Schema validation failed` bug in §3.1 was isolated by:
805
+
806
+ 1. Running the same prompt in a directory **without** the plugin → clean,
807
+ proving the plugin was responsible.
808
+ 2. Removing the injected skill while keeping the hook → still failed,
809
+ exonerating `skill.transform`.
810
+ 3. Changing only the system-part shape → clean, convicting the part shape.
811
+
812
+ Keep a no-plugin control directory around; it converts "something is broken"
813
+ into a one-command bisection.