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.
@@ -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.