opencode-webui 1.0.9 → 2.1.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/README.md +21 -8
- package/dist/assets/brother-agent-C1lqxeTh.js +1 -0
- package/dist/assets/groq-voice-m-OaABoC.js +1 -0
- package/dist/assets/index-Bs1cKqOa.js +129 -0
- package/dist/assets/index-D0Y4JI3u.css +1 -0
- package/dist/assets/jsx-runtime-B-hcVAMW.js +1 -0
- package/dist/assets/report-CczF_91u.js +2 -0
- package/dist/assets/rich-render-C-7ieXZz.js +1 -0
- package/dist/index.html +17 -2
- package/package.json +3 -3
- package/server/ext/engine.ts +161 -0
- package/server/ext/kv.ts +81 -0
- package/server/ext/registry.ts +279 -0
- package/server/ext/types.ts +124 -0
- package/server/index.ts +433 -58
- package/server/userExtensions.ts +175 -48
- package/skills/webui/SKILL.md +118 -97
- package/webui-extensions/README.md +462 -0
- package/dist/assets/index-B7R1vNdF.css +0 -1
- package/dist/assets/index-BV-oA2S9.js +0 -128
- package/dist/assets/report-nsiIAZ9q.js +0 -2
- package/ui-extensions/README.md +0 -285
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
# Extension authoring guide (v2)
|
|
2
|
+
|
|
3
|
+
One extension = **one folder** in one format, loaded by one loader, gated by
|
|
4
|
+
one rule. A folder may provide code in up to four *strata* — pick the stratum
|
|
5
|
+
that matches the job (framing rule below), never two for the same job.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
my-extension/
|
|
9
|
+
manifest.json id, name, version, description, disabled (optional bool)
|
|
10
|
+
index.tsx browser stratum: register() against the registry
|
|
11
|
+
dom.ts DOM stratum: post-render DOM changes (the free layer)
|
|
12
|
+
server.ts proxy stratum: routes / middleware / event tap / pollers
|
|
13
|
+
engine/ optional opencode plugin payload (tools, system-prompt hints)
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```jsonc
|
|
17
|
+
// manifest.json
|
|
18
|
+
{ "id": "my-extension", "name": "My extension", "version": "1.0.0",
|
|
19
|
+
"description": "What it does" /* "disabled": true — paused */ }
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
**Gating — one state, owned by the folder itself:** presence = installed;
|
|
23
|
+
`disabled: true` = paused; delete/move the folder to uninstall. No
|
|
24
|
+
`config.ts` list, no per-browser localStorage gating, no second registry.
|
|
25
|
+
User-facing pause (a settings toggle that stops *behavior*, e.g. an
|
|
26
|
+
extension that idles when its key/feature flag is off) is not manifest
|
|
27
|
+
pausing: the former keeps the entry loaded but quiet, the latter
|
|
28
|
+
(`disabled: true`) is never bundled or imported and its id unregisters —
|
|
29
|
+
use a settings toggle for "off for now", the manifest for "unplug".
|
|
30
|
+
|
|
31
|
+
**Precedence (same id = same swap point, higher wins):**
|
|
32
|
+
|
|
33
|
+
1. `~/.config/opencode/webui-extensions/<name>/` — user
|
|
34
|
+
2. `<project>/.opencode/webui-extensions/<name>/` — project
|
|
35
|
+
3. the app's shipped `webui-extensions/` — ours, updates with the app
|
|
36
|
+
|
|
37
|
+
Shadowing a shipped extension = a folder with the same id at higher
|
|
38
|
+
precedence. Core updates land underneath; yours still wins; nothing is
|
|
39
|
+
forked. Dropping a folder in the extension dir is an act of trust —
|
|
40
|
+
extension code is not sandboxed (same model as host plugins).
|
|
41
|
+
|
|
42
|
+
> Implementation status: the registry (five kinds), hook instrumentation,
|
|
43
|
+
> browser loader + manifest SSE, proxy-stratum mounts, `dom.ts` loader wiring
|
|
44
|
+
> + `data-oc-*` stamping, core self-registration, and the `ui-extensions/` →
|
|
45
|
+
> `webui-extensions/` rename are landed (`docs/extension-system-spec.md` §11).
|
|
46
|
+
> The contract below is what that work converged on — write to it.
|
|
47
|
+
|
|
48
|
+
## Choosing a stratum (framing rule)
|
|
49
|
+
|
|
50
|
+
- React-tree change → **browser stratum** (`wrap`/`replace`/`contribute`/
|
|
51
|
+
`hook`/`service` in `index.tsx`).
|
|
52
|
+
- Mid-component DOM, portals, canvas/xterm, iframes, post-render styling →
|
|
53
|
+
**DOM stratum** (`dom.ts`). The marked last resort: outside the contract,
|
|
54
|
+
you own the fragility.
|
|
55
|
+
- Headless logic, always-on ticks, secrets, endpoints for external tools,
|
|
56
|
+
uniform request/response transforms → **proxy stratum** (`server.ts`).
|
|
57
|
+
- Tools the model calls, system-prompt hints → **`engine/` payload**
|
|
58
|
+
(opencode plugin rules apply — boot-time load, engine restarts).
|
|
59
|
+
|
|
60
|
+
## Browser stratum — five kinds, one job each
|
|
61
|
+
|
|
62
|
+
`register()` lives in `src/extensions/registry.tsx` — that file's header
|
|
63
|
+
comment is the contract summary; this guide is the long form. Staleness
|
|
64
|
+
semantics are the whole point: `wrap` = default, `replace` = ownership.
|
|
65
|
+
|
|
66
|
+
| Kind | Job | Staleness |
|
|
67
|
+
| --- | --- | --- |
|
|
68
|
+
| `wrap` | Flow-through tweak of any registered target: `render(props, next)` — transform props/output, delegate to live core by default | **Stale-proof by construction.** Core updates always render *through* it. The default path for edits. |
|
|
69
|
+
| `replace` | Take ownership of one registered target: `render(props, core)` wins outright at its priority; return `null` to fall through to the next candidate / core | **Frozen snapshot.** You opt out of core updates for that target — the marked escape hatch. Still receives `core` so you *can* compose. |
|
|
70
|
+
| `contribute` | Add an item to a named collection (`collection` + `item`, `order` sorts, lower first) | Data, not code — core owns the list, you own your row. |
|
|
71
|
+
| `hook` | Interception at instrumented boundaries: `{ event, handler(ctx, next) }` — `event` is an open string | New seams are new event names, never a registry change. |
|
|
72
|
+
| `service` | Provide named logic: `{ service, value, precedence }` — consume via `getService(id)`; highest precedence wins | **Value overrides.** Core consults services for pluggable values (e.g. the timestamp formatter), so tiny logic tweaks never touch markup. |
|
|
73
|
+
|
|
74
|
+
Target chains (§5.2 of the spec): every registered unit is a target with an
|
|
75
|
+
ordered chain — wraps outermost-first, replace candidates by ascending
|
|
76
|
+
priority, core default last. Evaluation: wraps nest, the first replace
|
|
77
|
+
returning non-null wins, `null` falls through to core. Every core component
|
|
78
|
+
self-registers at boot (auto-registration helper — the whole tree is
|
|
79
|
+
addressable, no marker placement, no guessing), at leaf granularity (the
|
|
80
|
+
timestamp, token readout, cost badge, copy button — not just `MessageItem`),
|
|
81
|
+
with rich props, so wraps and value-overrides stay surgical.
|
|
82
|
+
|
|
83
|
+
### Target inventory (the catalog — grep `autoRegister` if this lags)
|
|
84
|
+
|
|
85
|
+
| Target id | Props (meaningful subset) |
|
|
86
|
+
|---|---|
|
|
87
|
+
| `sidebar` | — (the shell) |
|
|
88
|
+
| `sidebar.sessionRow` | `sessionID`, `title`, `updated`, `active`, `selected`, `subagentsActive`, `onSelect` |
|
|
89
|
+
| `conversation` | full `ConversationProps` |
|
|
90
|
+
| `conversation.header` | full `HeaderProps` |
|
|
91
|
+
| `conversation.empty` | — |
|
|
92
|
+
| `composer` | full `ComposerProps` |
|
|
93
|
+
| `composer.contextReadout` | `parts: string[]` |
|
|
94
|
+
| `composer.sendActions` | `sessionID`, `appendDraft(text)` — space-joins onto the draft + refocuses; prefer over writing drafts directly |
|
|
95
|
+
| `message.timestamp` | `time: number` (consults the `format.timestamp` service) |
|
|
96
|
+
| `message.tokens` | `tokens` |
|
|
97
|
+
| `message.cost` | `cost: number` |
|
|
98
|
+
| `message.copyButton` | `variant: "user" \| "assistant"`, `text` |
|
|
99
|
+
| `message:<type>` / `message:*` | replace-with-fall-through per message type |
|
|
100
|
+
| `tool.card` | `part: ToolPart`, `stateKey?` |
|
|
101
|
+
| `tool.edit` / `write` / `shell` / `subagent` / `execute` / `generic` | per-tool view props |
|
|
102
|
+
| `tool:<name>` | replace-with-fall-through per tool name |
|
|
103
|
+
|
|
104
|
+
Persisted-vs-live guarantee (streaming authors depend on this): persisted
|
|
105
|
+
messages always carry a `[data-oc-message]` ancestor; live projections
|
|
106
|
+
(`LiveAssistantView`) render `MessagePart` directly with none. Code that
|
|
107
|
+
must never touch streaming output can rely on the distinction structurally.
|
|
108
|
+
|
|
109
|
+
### Entry hygiene (two rules that bite silently)
|
|
110
|
+
|
|
111
|
+
- **One entry per id.** Same-id `register()` SWAPS (with a console warning
|
|
112
|
+
when kind/target differ) — a folder's entries need distinct ids or the
|
|
113
|
+
later evicts the earlier, and loaders track folders by single id so extras
|
|
114
|
+
leak on disable/delete. One folder → one id per entry, always.
|
|
115
|
+
- **Runtime code uses the bridge only.** External (user/project-dir) bundles
|
|
116
|
+
are built standalone: `import type` from `src/` is erased at build and
|
|
117
|
+
safe, but any *runtime* `src/` import breaks the copy outside the repo.
|
|
118
|
+
Use `window.__opencodeUI` (`register`, `react`, `api`, `store`, `prefs`,
|
|
119
|
+
`notify`, `services`, `dom`, `kv`) — shipped code consumes the identical
|
|
120
|
+
surface via `getExtensionApi()`.
|
|
121
|
+
- **The `@/` alias works in shipped extensions only.** Same repo, same
|
|
122
|
+
tsconfig (`@/*` → `./src/*`, e.g. groq-voice imports
|
|
123
|
+
`@/components/ui/dialog`) — external copies must still use the bridge,
|
|
124
|
+
never `@/` or relative `src/` paths.
|
|
125
|
+
|
|
126
|
+
```tsx
|
|
127
|
+
// index.tsx — wrap the timestamp, own nothing else
|
|
128
|
+
import { register } from "../../src/extensions/registry";
|
|
129
|
+
|
|
130
|
+
register({
|
|
131
|
+
kind: "wrap",
|
|
132
|
+
id: "my-timestamps-wrap",
|
|
133
|
+
target: "Timestamp",
|
|
134
|
+
render: (props, next) => (
|
|
135
|
+
<span title={String(props.iso ?? "")}>{next()}</span>
|
|
136
|
+
),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
register({
|
|
140
|
+
kind: "service",
|
|
141
|
+
id: "my-timestamps-format",
|
|
142
|
+
service: "format.timestamp",
|
|
143
|
+
value: (iso: string) => new Date(iso).toLocaleTimeString(),
|
|
144
|
+
precedence: 10,
|
|
145
|
+
});
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
```tsx
|
|
149
|
+
// index.tsx — replace with fall-through: own one case, defer the rest
|
|
150
|
+
register({
|
|
151
|
+
kind: "replace",
|
|
152
|
+
id: "my-tool-card",
|
|
153
|
+
target: "tool:bash",
|
|
154
|
+
render: (props, core) =>
|
|
155
|
+
(props as any).summary === "banner" ? <MyBanner {...(props as any)} /> : core(props),
|
|
156
|
+
});
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Collections (`contribute`)
|
|
160
|
+
|
|
161
|
+
Collections are registry-owned lists — adding a collection is data in core,
|
|
162
|
+
not a new kind. Current ids: `palette` (command palette; item
|
|
163
|
+
`{ title, run, keybind? }` — `keybind: "ctrl+shift+k"` for a global hotkey),
|
|
164
|
+
`slash` (Composer `/name`; item `{ name, description?, aliases?, run }` —
|
|
165
|
+
UI-only, local `run(args, { sessionID })`; engine commands come from
|
|
166
|
+
`GET /api/command` + `GET /api/skill` and win name clashes), `pages`
|
|
167
|
+
(item `{ title, description?, render }`, routed at `/ext/{id}`),
|
|
168
|
+
`settings` (item `{ title, description?, render }`, section in
|
|
169
|
+
Settings › Extensions), `contextMenu.message`, `contextMenu.session`,
|
|
170
|
+
`contextMenu.file` (item `{ label, run, order? }`).
|
|
171
|
+
|
|
172
|
+
```tsx
|
|
173
|
+
register({
|
|
174
|
+
kind: "contribute",
|
|
175
|
+
id: "my-page",
|
|
176
|
+
collection: "pages",
|
|
177
|
+
item: { title: "Uptime", render: () => <Uptime /> },
|
|
178
|
+
});
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Hook catalog
|
|
182
|
+
|
|
183
|
+
Open event strings — fired from the api client wrapper (every endpoint),
|
|
184
|
+
store middleware (every action), and lifecycle points. A new seam is a new
|
|
185
|
+
`fireHooks("name", ctx)` call in core, never a registry change.
|
|
186
|
+
|
|
187
|
+
| Event | `ctx` shape | When |
|
|
188
|
+
| --- | --- | --- |
|
|
189
|
+
| `api.pre` | `{ name, args }` — MUTATE `ctx.args` (unknown[] spread into the endpoint) | Before every api client call; `await`ed so mutations apply |
|
|
190
|
+
| `api.post` | `{ name, args, result }` — observe | After every successful api call |
|
|
191
|
+
| `api.error` | `{ name, args, error }` — observe; the original error is rethrown | After every failed api call |
|
|
192
|
+
| `store.dispatch` | `{ patch, state }` — observe | Store middleware, every action (sync site — `void fireHooks`) |
|
|
193
|
+
| `session.prompt` | `{ text, sessionID }` — mutate `ctx.text` to transform; `await`ed | Composer submit before `POST /api/session/{id}/prompt` |
|
|
194
|
+
| `session.adopted` | `{ sessionID }` | Session becomes the focused session |
|
|
195
|
+
| `pane.focused` | `{ paneID, sessionID }` | Split-view focus moves to a pane |
|
|
196
|
+
| `extension.loaded` | `{ id, url }` | A browser extension bundle finished loading |
|
|
197
|
+
| `message.render` | `{ message, sessionID? }` — mutate `ctx.message` (shallow clone) before render | `MessageItem` before the message renderer |
|
|
198
|
+
|
|
199
|
+
```tsx
|
|
200
|
+
register({
|
|
201
|
+
kind: "hook",
|
|
202
|
+
id: "my-prefix",
|
|
203
|
+
event: "session.prompt",
|
|
204
|
+
handler: (ctx, next) => { ctx.text = `[via webui] ${ctx.text}`; next(); },
|
|
205
|
+
});
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Handlers run sequentially in registration order; each is crash-isolated (a
|
|
209
|
+
throwing extension never breaks the core path). Browser hooks affect only
|
|
210
|
+
their own browser — for transforms affecting *all* clients, use proxy
|
|
211
|
+
`server.ts` middleware instead.
|
|
212
|
+
|
|
213
|
+
## DOM stratum — the free layer
|
|
214
|
+
|
|
215
|
+
For everything the React tree cannot address: mid-component DOM,
|
|
216
|
+
**portals** (Radix/shadcn render at `document.body`), canvas/xterm, iframes,
|
|
217
|
+
post-render styling. One entry: `dom.ts` in the extension folder — its
|
|
218
|
+
presence declares DOM-level operation; same loader, precedence, manifest,
|
|
219
|
+
gating. Default-export `{ mount(kit), dispose? }`; `mount` may return a
|
|
220
|
+
cleanup fn. Hot-swap runs every registered cleanup + `dispose`, so edits
|
|
221
|
+
repaint clean and never stack ghosts.
|
|
222
|
+
|
|
223
|
+
The kit (`src/lib/domKit.ts`, host-provided so DOM extensions don't each
|
|
224
|
+
rebuild the scaffolding):
|
|
225
|
+
|
|
226
|
+
- `foreign(anchor, nodes)` — sibling injection with automatic cleanup when
|
|
227
|
+
React removes the anchor (the foreign-sibling registry).
|
|
228
|
+
- `watch(selectors, cb)` — React-aware MutationObserver wrapper, including
|
|
229
|
+
the streaming-settled signal (`onStreamingSettled`).
|
|
230
|
+
- `styles(css)` — scoped `<style>` element, auto-removed on
|
|
231
|
+
disable/hot-swap.
|
|
232
|
+
|
|
233
|
+
Our half of the bargain: stable `data-oc-*` anchors at meaningful markup
|
|
234
|
+
boundaries, versioned like target ids (renaming/moving one = contract bump
|
|
235
|
+
+ migration note, never a silent break). Without stable anchors, DOM
|
|
236
|
+
extensions break silently on every redesign.
|
|
237
|
+
|
|
238
|
+
| Anchor | Site |
|
|
239
|
+
| --- | --- |
|
|
240
|
+
| `data-oc-transcript` | MessageScroller content |
|
|
241
|
+
| `data-oc-message` + `data-oc-message-id` + `data-oc-message-type` | MessageItem root per type branch |
|
|
242
|
+
| `data-oc-composer` + `data-oc-composer-input` + `data-oc-composer-send` | Composer card, textarea, send button |
|
|
243
|
+
| `data-oc-tool-card` + `data-oc-tool-name` | ToolCard root + tool call name |
|
|
244
|
+
| `data-oc-session-header` | Conversation header bar |
|
|
245
|
+
| `data-oc-sidebar` | Sidebar root |
|
|
246
|
+
| `data-oc-queue-strip` | QueueStrip (steer/queue rows) |
|
|
247
|
+
| `data-oc-subagent-strip` | SubagentStrip |
|
|
248
|
+
| `data-oc-runs-panel` | RunsPanel |
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
// dom.ts — badge next to the send button, cleaned up on hot-swap
|
|
252
|
+
export default {
|
|
253
|
+
mount({ foreign, watch, styles }) {
|
|
254
|
+
styles(`[data-my-badge]{font-size:11px;opacity:.7}`);
|
|
255
|
+
const clean = watch(["[data-oc-composer-send]"], (sends) => {
|
|
256
|
+
for (const el of sends)
|
|
257
|
+
foreign(el, [`<span data-my-badge>via ext</span>`]);
|
|
258
|
+
});
|
|
259
|
+
return clean;
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
## Proxy stratum — `server.ts` mounts
|
|
265
|
+
|
|
266
|
+
The proxy stays thin and non-forkable; `server.ts` (or a `server/` dir) may
|
|
267
|
+
provide any of `routes`, `middleware`, `onEvent`, `pollers` — default-export
|
|
268
|
+
the object, no core import needed (the loader validates the shape
|
|
269
|
+
structurally). Full types: `server/ext/types.ts`.
|
|
270
|
+
|
|
271
|
+
- **`routes`** — new endpoints auto-mounted at `/api/webui/ext/<id>/…`
|
|
272
|
+
(namespaced, collision-free). Callable by browser extensions via the
|
|
273
|
+
bridge and by external tools. Unknown id/route never falls through to the
|
|
274
|
+
engine.
|
|
275
|
+
- **`middleware`** — `onRequest` (return a Response to short-circuit, a
|
|
276
|
+
Request to replace, void to pass through) / `onResponse` (replace the
|
|
277
|
+
upstream response). Wraps the `/api/*` passthrough chain: uniform
|
|
278
|
+
transforms affecting *all* clients.
|
|
279
|
+
- **`onEvent`** — a tap into the always-on engine event subscription the
|
|
280
|
+
recorder already holds. Headless reaction to session finish, tool
|
|
281
|
+
activity, etc. — works with all browser tabs closed.
|
|
282
|
+
- **`pollers`** — `{ id, intervalMs, run }` always-on ticks (keep-alive
|
|
283
|
+
pings, watchers) that survive closed tabs.
|
|
284
|
+
- **KV store** — one small persistent JSON-file-backed store per extension
|
|
285
|
+
(`ctx.kv`), so each doesn't roll its own file I/O.
|
|
286
|
+
|
|
287
|
+
No proxy restart for code edits: the loader stat-polls (2s) and re-imports
|
|
288
|
+
with `?v=<mtime>` cache-bust. Dev's existing `--watch` proxy restart stays
|
|
289
|
+
(SSE self-reconnects).
|
|
290
|
+
|
|
291
|
+
```ts
|
|
292
|
+
// server.ts — headless finish webhook + its config endpoint
|
|
293
|
+
export default {
|
|
294
|
+
routes: [{
|
|
295
|
+
method: "POST", path: "notify",
|
|
296
|
+
handler: async (req, ctx) => {
|
|
297
|
+
const { url } = await req.json() as { url: string };
|
|
298
|
+
await ctx.kv.set("webhook", url);
|
|
299
|
+
return Response.json({ ok: true });
|
|
300
|
+
},
|
|
301
|
+
}],
|
|
302
|
+
onEvent: async (evt, ctx) => {
|
|
303
|
+
if (evt.type !== "session.finished") return;
|
|
304
|
+
const url = await ctx.kv.get("webhook");
|
|
305
|
+
if (url) await fetch(url, { method: "POST", body: JSON.stringify(evt) });
|
|
306
|
+
},
|
|
307
|
+
pollers: [{ id: "keepalive", intervalMs: 60_000, run: async () => {} }],
|
|
308
|
+
};
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
## Engine payload (`engine/`)
|
|
312
|
+
|
|
313
|
+
An extension folder may carry `engine/` — a valid opencode plugin directory
|
|
314
|
+
(tools the model calls, `experimental.chat.system.transform` prompt hints).
|
|
315
|
+
The webui neither loads nor hot-reloads it; the engine's rules apply
|
|
316
|
+
(boot-time load, restart on edit unless the plugin implements its own shell
|
|
317
|
+
pattern — a stable `index.js` that require-cache-busts a `definitions.cjs`
|
|
318
|
+
on mtime works and is the recommended shape). Convention + worked example
|
|
319
|
+
(brother-agent in webui terms — one folder, three strata):
|
|
320
|
+
`docs/engine-payload-convention.md`. Hard-won facts, stated once so no one
|
|
321
|
+
re-discovers them by trial:
|
|
322
|
+
|
|
323
|
+
- **Export shape:** `module.exports = { id, setup }` (v2 — the v1 `{server}`
|
|
324
|
+
/ named-export shape is rejected: "must export a default definition with
|
|
325
|
+
an id and an effect or setup function").
|
|
326
|
+
- **Tool namespace:** the model lists tools as `tools.<name>` — register and
|
|
327
|
+
match on the `tools.`-prefixed name, never bare.
|
|
328
|
+
- **Tool results must resolve `{ output: string }`.** A bare string fails
|
|
329
|
+
result validation (`Unknown tool` in the transcript).
|
|
330
|
+
- **System-hint parts need `{ type: "text", text }`.** Pushing `{text}`
|
|
331
|
+
without `type` fails the whole session drain (schema `MissingKey`).
|
|
332
|
+
- **Session origin tagging** (`metadata: { origin: "…" }`) survives only via
|
|
333
|
+
REST `POST /api/session` create — the setup-bridge create drops it.
|
|
334
|
+
- **Discovery + auth:** the engine registers at
|
|
335
|
+
`$XDG_STATE_HOME/opencode/service.json` (Basic `opencode:password` —
|
|
336
|
+
mirror `@opencode-ai/client`'s service helper); provider credentials live
|
|
337
|
+
under `XDG_DATA_HOME`, so a `STATE`-only sandbox sees the engine but no
|
|
338
|
+
models. When agent testing misbehaves, verify the provider first with
|
|
339
|
+
`POST /session/{id}/generate {"prompt":"OK"}`; when runs fail blank,
|
|
340
|
+
the cause is in `$XDG_DATA_HOME/opencode/log/opencode.log` (`grep drain`).
|
|
341
|
+
- `server.ts` code that must call the engine has no credential helper yet —
|
|
342
|
+
parse `service.json` by hand (node builtins only, no core imports); a
|
|
343
|
+
`ctx.engine` helper is the planned fix (`server/ext/types.ts`).
|
|
344
|
+
|
|
345
|
+
## Loading lifecycle (where an extension travels)
|
|
346
|
+
|
|
347
|
+
One folder becomes pixels through four files — follow them in order:
|
|
348
|
+
|
|
349
|
+
1. **Glob (shipped, repo dev).** `webui-extensions/index.ts` globs
|
|
350
|
+
`./*/index.{ts,tsx}` and tracks each module's `export const id` (Vite
|
|
351
|
+
HMR path — edits hot-swap via same-id registry swap, deletions prune
|
|
352
|
+
owned ids only).
|
|
353
|
+
2. **Discovery (proxy).** `server/userExtensions.ts`
|
|
354
|
+
(`discoverUserUIEntries`) scans the three sources highest-precedence
|
|
355
|
+
first — user root, project root, shipped dir — taking the folder id
|
|
356
|
+
from `manifest.json` (`id`, falling back to the dir name) and the
|
|
357
|
+
entries from `index.tsx`/`dom.ts`. Same id at a lower source is
|
|
358
|
+
skipped with a once-per-process `shadowed` warning.
|
|
359
|
+
3. **Manifest + SSE + bundling (proxy).** `server/index.ts` merges folder
|
|
360
|
+
entries with engine-plugin UI halves, serves
|
|
361
|
+
`GET /api/webui/extensions` (`{ id, url?v=mtime, domUrl?v=mtime,
|
|
362
|
+
source, origin }`), pushes a `{ type: "webui.extensions", version }`
|
|
363
|
+
event per manifest change on `GET /api/webui/extensions/events`, and
|
|
364
|
+
bundles each entry standalone with `Bun.build` (`bundleUIEntry` —
|
|
365
|
+
react external, build logs printed loudly, never silent).
|
|
366
|
+
4. **Import + register (page).** `src/lib/runtimeExtensions.ts` fetches
|
|
367
|
+
the manifest, dynamic-imports each new `?v=` bundle (re-import on
|
|
368
|
+
mtime move → registry same-id-swap → live repaint), mounts `domUrl`
|
|
369
|
+
via the DOM kit, and unregisters ids that vanish or flip
|
|
370
|
+
`disabled: true`. Shipped browser bundles are skipped here (the glob
|
|
371
|
+
owns them — importing twice would run side effects twice) but shipped
|
|
372
|
+
`domUrl` still mounts and `disabled` still pauses them.
|
|
373
|
+
|
|
374
|
+
## What extensions can use (browser stratum)
|
|
375
|
+
|
|
376
|
+
Everything the app can — shipped extensions are the same build:
|
|
377
|
+
|
|
378
|
+
- `useStore` / store actions from `src/store.ts`
|
|
379
|
+
- `api` from `src/api/client.ts` (every endpoint fires `api.pre/post/error`)
|
|
380
|
+
- `getService` / services from `src/extensions/registry.tsx`
|
|
381
|
+
- UI primitives from `src/components/ui/` (shadcn) — always build on these
|
|
382
|
+
so extensions look native
|
|
383
|
+
- Design tokens in `src/styles.css` as `var(--...)` — never hardcode colors
|
|
384
|
+
- Toaster via the extension API surface (`notify`)
|
|
385
|
+
|
|
386
|
+
External (user/project-dir) extensions use the one extension API surface
|
|
387
|
+
(`register`, `react`, `api`, `store`, `prefs`, `notify`, `services`, `dom`
|
|
388
|
+
kit, `kv`) — used identically by our shipped ones.
|
|
389
|
+
|
|
390
|
+
## Hot reload guarantees
|
|
391
|
+
|
|
392
|
+
- **Browser extensions (external dirs):** the proxy watches all three
|
|
393
|
+
sources, rebuilds changed bundles, bumps the `?v=` version, and pushes the
|
|
394
|
+
manifest over SSE (`GET /api/webui/extensions/events`); the page
|
|
395
|
+
re-imports the bundle (browser ESM cache-busts on the query) and the
|
|
396
|
+
registry same-id-swaps → live repaint, sub-second. Replaces the old 8s
|
|
397
|
+
poll. Delete/move = uninstall (the id vanishes from the manifest);
|
|
398
|
+
`disabled: true` = paused.
|
|
399
|
+
- **Browser extensions (repo dev):** Vite HMR — same folder format, same
|
|
400
|
+
API; only the transport differs.
|
|
401
|
+
- **Proxy extensions:** no proxy restart for code edits (stat-poll +
|
|
402
|
+
cache-busted re-import; dispose stops the old module's pollers first).
|
|
403
|
+
- **Engine payload:** engine rules apply (see above).
|
|
404
|
+
|
|
405
|
+
## Worked example — the timestamp test (choosing the right stratum)
|
|
406
|
+
|
|
407
|
+
> User tweaks only the timestamp format. Maintainer later redesigns the
|
|
408
|
+
> token counter and adds a finish badge in the same header. **The user gets
|
|
409
|
+
> both, visibly** — parent and siblings are still core's; the user's wrap
|
|
410
|
+
> delegates by default. (`docs/extension-system-spec.md` §5.4; scenario
|
|
411
|
+
> script: `docs/extension-timestamp-test.md`.)
|
|
412
|
+
|
|
413
|
+
```tsx
|
|
414
|
+
// ✅ RIGHT — wrap (stale-proof): the header redesign flows through
|
|
415
|
+
register({
|
|
416
|
+
kind: "wrap", id: "my-time-wrap", target: "Timestamp",
|
|
417
|
+
render: (props, next) => <span className="tabular-nums">{next()}</span>,
|
|
418
|
+
});
|
|
419
|
+
// ✅ RIGHT — service (surgical): only the format string is yours.
|
|
420
|
+
// NOTE: one entry per id — same-id re-register SWAPS, so this needs its
|
|
421
|
+
// own id or it evicts the wrap above.
|
|
422
|
+
register({
|
|
423
|
+
kind: "service", id: "my-time-format", service: "format.timestamp",
|
|
424
|
+
value: (iso: string) => new Date(iso).toLocaleTimeString(),
|
|
425
|
+
precedence: 10,
|
|
426
|
+
});
|
|
427
|
+
// ⚠️ OWNERSHIP — replace: you freeze the timestamp; core redesigns stop here
|
|
428
|
+
register({
|
|
429
|
+
kind: "replace", id: "my-time-own", target: "Timestamp",
|
|
430
|
+
render: (props, core) => <MyClock {...props} />,
|
|
431
|
+
});
|
|
432
|
+
// ❌ WRONG STRATUM — dom.ts for a registered unit: outside the contract,
|
|
433
|
+
// breaks silently on the redesign. DOM is for portals/canvas/iframes.
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
## Sandbox (iterate without touching the user's webui)
|
|
437
|
+
|
|
438
|
+
`bunx opencode-webui sandbox` (or `bun run sandbox` in a checkout) starts an
|
|
439
|
+
isolated second instance — loopback-only `127.0.0.1:4099`, passwordless (the
|
|
440
|
+
bind address is the guarantee), same engine/sessions, extensions from an
|
|
441
|
+
isolated scratch dir (`WEBUI_EXTENSION_DIR`,
|
|
442
|
+
default `~/.local/state/opencode-webui/sandbox-extensions/`).
|
|
443
|
+
`WEBUI_EXTENSION_DIR` is a higher-precedence ADD, not a replace: it swaps
|
|
444
|
+
out the user + project roots only — shipped extensions still load
|
|
445
|
+
underneath, and a same-id scratch folder shadows the shipped copy (the
|
|
446
|
+
`shadowed` log line is the only signal). Iterate there; "shipping" =
|
|
447
|
+
copying the folder into the real extension dir.
|
|
448
|
+
|
|
449
|
+
### Parallel sandboxes (agents: read this)
|
|
450
|
+
|
|
451
|
+
Yes — run as many sandboxes at once as you need, one per extension under
|
|
452
|
+
test. `bun run sandbox` stacks with no flags: the first instance takes the
|
|
453
|
+
fixed defaults (`:4099`/`:5175` + shared scratch dir); every further
|
|
454
|
+
instance detects the busy ports and auto-isolates onto free ports + a fresh
|
|
455
|
+
mkdtemp extension dir, printing exactly what it picked. Explicit env
|
|
456
|
+
(`WEBUI_PROXY_PORT` / `WEBUI_VITE_PORT` / `WEBUI_EXTENSION_DIR`) always wins
|
|
457
|
+
per knob and disables that knob's auto behavior.
|
|
458
|
+
|
|
459
|
+
Rules: one sandbox per extension, never two writers to one ext dir, never
|
|
460
|
+
reuse a port. The engine stays shared (same sessions everywhere, by
|
|
461
|
+
design) — only ports + extension dirs are isolated. Sandbox instances are
|
|
462
|
+
loopback-only and passwordless; the bind address is the guarantee.
|