loadouts 0.2.5 → 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.
- package/bundled/embedded/pi/loadouts-runtime.ts +19677 -0
- package/bundled/extensions/loadouts-runtime/index.ts +19677 -0
- package/bundled/extensions/loadouts-runtime/package.json +6 -0
- package/bundled/loadouts/opencode-runtime.yaml +5 -0
- package/bundled/loadouts/pi-runtime.yaml +5 -0
- package/bundled/opencode/plugins/loadouts/index.js +39198 -0
- package/bundled/opencode/plugins/loadouts/package.json +9 -0
- package/bundled/opencode/plugins/loadouts/tui.js +16913 -0
- package/dist/builtins/agent-transforms.d.ts.map +1 -1
- package/dist/builtins/agent-transforms.js +31 -9
- package/dist/builtins/agent-transforms.js.map +1 -1
- package/dist/builtins/index.d.ts.map +1 -1
- package/dist/builtins/index.js +18 -3
- package/dist/builtins/index.js.map +1 -1
- package/dist/builtins/kinds/extension-dir.d.ts +3 -0
- package/dist/builtins/kinds/extension-dir.d.ts.map +1 -0
- package/dist/builtins/kinds/extension-dir.js +8 -0
- package/dist/builtins/kinds/extension-dir.js.map +1 -0
- package/dist/builtins/kinds/opencode-cli-config.d.ts +3 -0
- package/dist/builtins/kinds/opencode-cli-config.d.ts.map +1 -0
- package/dist/builtins/kinds/opencode-cli-config.js +10 -0
- package/dist/builtins/kinds/opencode-cli-config.js.map +1 -0
- package/dist/builtins/kinds/opencode-command.d.ts +3 -0
- package/dist/builtins/kinds/opencode-command.d.ts.map +1 -0
- package/dist/builtins/kinds/opencode-command.js +8 -0
- package/dist/builtins/kinds/opencode-command.js.map +1 -0
- package/dist/builtins/kinds/opencode-plugin-dir.d.ts +3 -0
- package/dist/builtins/kinds/opencode-plugin-dir.d.ts.map +1 -0
- package/dist/builtins/kinds/opencode-plugin-dir.js +11 -0
- package/dist/builtins/kinds/opencode-plugin-dir.js.map +1 -0
- package/dist/builtins/kinds/opencode-plugin.js +1 -1
- package/dist/builtins/kinds/opencode-plugin.js.map +1 -1
- package/dist/builtins/tools/opencode.d.ts.map +1 -1
- package/dist/builtins/tools/opencode.js +13 -1
- package/dist/builtins/tools/opencode.js.map +1 -1
- package/dist/builtins/tools/pi.d.ts.map +1 -1
- package/dist/builtins/tools/pi.js +2 -1
- package/dist/builtins/tools/pi.js.map +1 -1
- package/dist/cli/commands/diff.d.ts +10 -0
- package/dist/cli/commands/diff.d.ts.map +1 -1
- package/dist/cli/commands/diff.js +71 -30
- package/dist/cli/commands/diff.js.map +1 -1
- package/dist/cli/commands/info.d.ts +10 -0
- package/dist/cli/commands/info.d.ts.map +1 -1
- package/dist/cli/commands/info.js +45 -1
- package/dist/cli/commands/info.js.map +1 -1
- package/dist/cli/commands/list.d.ts +18 -0
- package/dist/cli/commands/list.d.ts.map +1 -1
- package/dist/cli/commands/list.js +27 -0
- package/dist/cli/commands/list.js.map +1 -1
- package/dist/cli/commands/session.d.ts +3 -0
- package/dist/cli/commands/session.d.ts.map +1 -0
- package/dist/cli/commands/session.js +68 -0
- package/dist/cli/commands/session.js.map +1 -0
- package/dist/cli/commands/status.d.ts +10 -0
- package/dist/cli/commands/status.d.ts.map +1 -1
- package/dist/cli/commands/status.js +100 -2
- package/dist/cli/commands/status.js.map +1 -1
- package/dist/cli/commands/sync.d.ts.map +1 -1
- package/dist/cli/commands/sync.js +6 -0
- package/dist/cli/commands/sync.js.map +1 -1
- package/dist/cli/commands/tui.d.ts +3 -0
- package/dist/cli/commands/tui.d.ts.map +1 -0
- package/dist/cli/commands/tui.js +20 -0
- package/dist/cli/commands/tui.js.map +1 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +4 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/core/discovery.js +1 -1
- package/dist/core/discovery.js.map +1 -1
- package/dist/core/import-discovery.d.ts.map +1 -1
- package/dist/core/import-discovery.js +2 -0
- package/dist/core/import-discovery.js.map +1 -1
- package/dist/core/registry.d.ts +5 -0
- package/dist/core/registry.d.ts.map +1 -1
- package/dist/core/registry.js.map +1 -1
- package/dist/core/render.d.ts.map +1 -1
- package/dist/core/render.js +77 -8
- package/dist/core/render.js.map +1 -1
- package/dist/core/resolve.d.ts +1 -1
- package/dist/core/resolve.d.ts.map +1 -1
- package/dist/core/resolve.js +3 -2
- package/dist/core/resolve.js.map +1 -1
- package/dist/core/runtime.d.ts +4 -0
- package/dist/core/runtime.d.ts.map +1 -1
- package/dist/core/runtime.js +10 -3
- package/dist/core/runtime.js.map +1 -1
- package/dist/core/schema.d.ts +59 -0
- package/dist/core/schema.d.ts.map +1 -1
- package/dist/core/schema.js +5 -0
- package/dist/core/schema.js.map +1 -1
- package/dist/core/types.d.ts +13 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/integrations/opencode/advertisement.d.ts +7 -0
- package/dist/integrations/opencode/advertisement.d.ts.map +1 -0
- package/dist/integrations/opencode/advertisement.js +17 -0
- package/dist/integrations/opencode/advertisement.js.map +1 -0
- package/dist/integrations/opencode/client.d.ts +12 -0
- package/dist/integrations/opencode/client.d.ts.map +1 -0
- package/dist/integrations/opencode/client.js +48 -0
- package/dist/integrations/opencode/client.js.map +1 -0
- package/dist/integrations/opencode/command.d.ts +11 -0
- package/dist/integrations/opencode/command.d.ts.map +1 -0
- package/dist/integrations/opencode/command.js +19 -0
- package/dist/integrations/opencode/command.js.map +1 -0
- package/dist/integrations/opencode/delta.d.ts +15 -0
- package/dist/integrations/opencode/delta.d.ts.map +1 -0
- package/dist/integrations/opencode/delta.js +75 -0
- package/dist/integrations/opencode/delta.js.map +1 -0
- package/dist/integrations/opencode/session.d.ts +207 -0
- package/dist/integrations/opencode/session.d.ts.map +1 -0
- package/dist/integrations/opencode/session.js +201 -0
- package/dist/integrations/opencode/session.js.map +1 -0
- package/dist/lib/gitignore.d.ts +7 -3
- package/dist/lib/gitignore.d.ts.map +1 -1
- package/dist/lib/gitignore.js +16 -6
- package/dist/lib/gitignore.js.map +1 -1
- package/dist/tui/core/actions.d.ts +8 -0
- package/dist/tui/core/actions.d.ts.map +1 -0
- package/dist/tui/core/actions.js +76 -0
- package/dist/tui/core/actions.js.map +1 -0
- package/dist/tui/core/controller.d.ts +28 -0
- package/dist/tui/core/controller.d.ts.map +1 -0
- package/dist/tui/core/controller.js +78 -0
- package/dist/tui/core/controller.js.map +1 -0
- package/dist/tui/core/data.d.ts +43 -0
- package/dist/tui/core/data.d.ts.map +1 -0
- package/dist/tui/core/data.js +161 -0
- package/dist/tui/core/data.js.map +1 -0
- package/dist/tui/core/host.d.ts +15 -0
- package/dist/tui/core/host.d.ts.map +1 -0
- package/dist/tui/core/host.js +2 -0
- package/dist/tui/core/host.js.map +1 -0
- package/dist/tui/core/intent.d.ts +95 -0
- package/dist/tui/core/intent.d.ts.map +1 -0
- package/dist/tui/core/intent.js +2 -0
- package/dist/tui/core/intent.js.map +1 -0
- package/dist/tui/core/keymap.d.ts +16 -0
- package/dist/tui/core/keymap.d.ts.map +1 -0
- package/dist/tui/core/keymap.js +68 -0
- package/dist/tui/core/keymap.js.map +1 -0
- package/dist/tui/core/model.d.ts +65 -0
- package/dist/tui/core/model.d.ts.map +1 -0
- package/dist/tui/core/model.js +34 -0
- package/dist/tui/core/model.js.map +1 -0
- package/dist/tui/core/selection.d.ts +12 -0
- package/dist/tui/core/selection.d.ts.map +1 -0
- package/dist/tui/core/selection.js +42 -0
- package/dist/tui/core/selection.js.map +1 -0
- package/dist/tui/core/theme.d.ts +15 -0
- package/dist/tui/core/theme.d.ts.map +1 -0
- package/dist/tui/core/theme.js +11 -0
- package/dist/tui/core/theme.js.map +1 -0
- package/dist/tui/core/update.d.ts +8 -0
- package/dist/tui/core/update.d.ts.map +1 -0
- package/dist/tui/core/update.js +206 -0
- package/dist/tui/core/update.js.map +1 -0
- package/dist/tui/core/view.d.ts +4 -0
- package/dist/tui/core/view.d.ts.map +1 -0
- package/dist/tui/core/view.js +162 -0
- package/dist/tui/core/view.js.map +1 -0
- package/dist/tui/core/viewport.d.ts +5 -0
- package/dist/tui/core/viewport.d.ts.map +1 -0
- package/dist/tui/core/viewport.js +33 -0
- package/dist/tui/core/viewport.js.map +1 -0
- package/dist/tui/core/viewspec.d.ts +49 -0
- package/dist/tui/core/viewspec.d.ts.map +1 -0
- package/dist/tui/core/viewspec.js +2 -0
- package/dist/tui/core/viewspec.js.map +1 -0
- package/dist/tui/hosts/pi/embed-entry.d.ts +3 -0
- package/dist/tui/hosts/pi/embed-entry.d.ts.map +1 -0
- package/dist/tui/hosts/pi/embed-entry.js +96 -0
- package/dist/tui/hosts/pi/embed-entry.js.map +1 -0
- package/dist/tui/hosts/pi/inject.d.ts +49 -0
- package/dist/tui/hosts/pi/inject.d.ts.map +1 -0
- package/dist/tui/hosts/pi/inject.js +31 -0
- package/dist/tui/hosts/pi/inject.js.map +1 -0
- package/dist/tui/hosts/standalone.d.ts +2 -0
- package/dist/tui/hosts/standalone.d.ts.map +1 -0
- package/dist/tui/hosts/standalone.js +109 -0
- package/dist/tui/hosts/standalone.js.map +1 -0
- package/dist/tui/skins/pi-tui/input.d.ts +5 -0
- package/dist/tui/skins/pi-tui/input.d.ts.map +1 -0
- package/dist/tui/skins/pi-tui/input.js +42 -0
- package/dist/tui/skins/pi-tui/input.js.map +1 -0
- package/dist/tui/skins/pi-tui/pi-theme-adapter.d.ts +4 -0
- package/dist/tui/skins/pi-tui/pi-theme-adapter.d.ts.map +1 -0
- package/dist/tui/skins/pi-tui/pi-theme-adapter.js +27 -0
- package/dist/tui/skins/pi-tui/pi-theme-adapter.js.map +1 -0
- package/dist/tui/skins/pi-tui/render.d.ts +38 -0
- package/dist/tui/skins/pi-tui/render.d.ts.map +1 -0
- package/dist/tui/skins/pi-tui/render.js +162 -0
- package/dist/tui/skins/pi-tui/render.js.map +1 -0
- package/dist/tui/skins/pi-tui/theme.d.ts +10 -0
- package/dist/tui/skins/pi-tui/theme.d.ts.map +1 -0
- package/dist/tui/skins/pi-tui/theme.js +58 -0
- package/dist/tui/skins/pi-tui/theme.js.map +1 -0
- package/docs/archive/opencode-v2-runtime-beta-18707.md +813 -0
- package/docs/authoring.md +69 -14
- package/docs/commands.md +24 -0
- package/docs/compatibility.md +8 -8
- package/docs/concepts.md +4 -2
- package/docs/opencode-v2-probes.md +253 -0
- package/docs/opencode-v2-runtime.md +129 -0
- package/docs/registry.md +1366 -0
- package/docs/runtime.md +115 -50
- package/docs/troubleshooting.md +20 -1
- package/docs/tui-architecture.md +503 -0
- package/package.json +17 -3
|
@@ -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.
|