opencode-webui 1.0.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,88 @@
1
+ /**
2
+ * Phase C — USER extension dirs.
3
+ *
4
+ * Plugins ship browser halves discovered through the engine's /api/plugin
5
+ * list (see index.ts). This module adds the user's own drop-in dirs, scanned
6
+ * straight from disk with NO engine involvement:
7
+ *
8
+ * ~/.config/opencode/webui-extensions/<name>/main.tsx (global)
9
+ * <cwd>/.opencode/webui-extensions/<name>/main.tsx (per project)
10
+ *
11
+ * They ride the exact same pipeline as plugin UIs — mtime cache-busting and
12
+ * Bun.build bundling via bundleUIEntry() in index.ts — so a user extension
13
+ * is just `{ id, url, source: "user:<path>" }` appended to the existing
14
+ * GET /api/webui/extensions manifest. Discovery is cached 5s, mirroring the
15
+ * plugin discovery pattern.
16
+ */
17
+
18
+ import { existsSync, readdirSync, statSync } from "node:fs";
19
+ import { homedir } from "node:os";
20
+ import { join } from "node:path";
21
+
22
+ export type UIEntry = { id: string; entry: string; mtimeMs: number; source?: string };
23
+
24
+ export const USER_EXT_LIST_TTL_MS = 5_000;
25
+
26
+ export function globalUserExtensionsDir(): string {
27
+ const base = process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
28
+ return join(base, "opencode", "webui-extensions");
29
+ }
30
+
31
+ export function projectUserExtensionsDir(): string {
32
+ return join(process.cwd(), ".opencode", "webui-extensions");
33
+ }
34
+
35
+ const warned = new Set<string>();
36
+
37
+ /** Console.warn at most once per key per process — discovery re-runs every 5s. */
38
+ export function warnOnce(key: string, message: string): void {
39
+ if (warned.has(key)) return;
40
+ warned.add(key);
41
+ console.warn(`[webui] ${message}`);
42
+ }
43
+
44
+ let cache: { at: number; entries: UIEntry[] } | null = null;
45
+
46
+ /**
47
+ * User UI entries, global root first. Absent roots and unreadable entries are
48
+ * skipped silently (that is the normal case); duplicate ids across roots warn
49
+ * once and keep the first.
50
+ */
51
+ export function discoverUserUIEntries(): UIEntry[] {
52
+ const now = Date.now();
53
+ if (cache && now - cache.at < USER_EXT_LIST_TTL_MS) return cache.entries;
54
+
55
+ const entries: UIEntry[] = [];
56
+ const seen = new Set<string>();
57
+ for (const root of [globalUserExtensionsDir(), projectUserExtensionsDir()]) {
58
+ let names: string[];
59
+ try {
60
+ names = readdirSync(root, { withFileTypes: true })
61
+ .filter((d) => d.isDirectory())
62
+ .map((d) => d.name);
63
+ } catch {
64
+ continue; // absent root is the normal case
65
+ }
66
+ for (const name of names) {
67
+ if (seen.has(name)) {
68
+ warnOnce(`user-dup:${name}`, `user extension "${name}" exists in two roots — keeping the first`);
69
+ continue;
70
+ }
71
+ const entry = join(root, name, "main.tsx");
72
+ try {
73
+ if (!existsSync(entry)) continue;
74
+ entries.push({
75
+ id: name,
76
+ entry,
77
+ mtimeMs: statSync(entry).mtimeMs,
78
+ source: `user:${entry}`,
79
+ });
80
+ seen.add(name);
81
+ } catch {
82
+ /* unreadable entry — skip */
83
+ }
84
+ }
85
+ }
86
+ cache = { at: now, entries };
87
+ return entries;
88
+ }
@@ -0,0 +1,122 @@
1
+ ---
2
+ name: webui
3
+ description: opencode-webui — the browser frontend for the OpenCode engine. Load when the user mentions the webui/web frontend, asks about webui extensions, or wants to report a webui bug (/report does it).
4
+ ---
5
+
6
+ # OpenCode webui (opencode-webui)
7
+
8
+ The browser frontend for the OpenCode engine — same engine, same sessions as
9
+ the `opencode` TUI, with a Bun proxy in front so the browser never holds
10
+ service credentials. One port for UI + `/api/*`: http://localhost:4097
11
+ (`WEBUI_PROXY_PORT`).
12
+
13
+ ## Environment
14
+
15
+ | Variable | Default | Purpose |
16
+ | --- | --- | --- |
17
+ | `WEBUI_PASSWORD` | generated on first boot, printed once | Shared login passphrase. |
18
+ | `WEBUI_HOST` | `127.0.0.1` | Bind address — a wildcard is refused without a password. |
19
+ | `WEBUI_PROXY_PORT` | `4097` | Port for the UI and `/api/*`. |
20
+ | `WEBUI_DEBUG` | unset | `1` — server/proxy debug logs to stdout. |
21
+ | `WEBUI_DEBUG_LOG` | `/tmp/webui-debug.log` | File the frontend log sink (`POST /api/debug`) appends to. |
22
+
23
+ ## What you can do for the user
24
+
25
+ - **File a webui bug** — the composer ships a built-in `/report` command that
26
+ bundles diagnostics (build version, user agent, enabled extension ids, error
27
+ ring) into a prefilled GitHub issue for AbdelftahZowail/opencode-webui (see
28
+ Reporting bugs below).
29
+ - **Explain the extension system** from the tables below — they are extracted
30
+ verbatim from the authoring guide (`ui-extensions/README.md`), which is the
31
+ source of truth.
32
+ - **Author a user-dir extension** for the user — a folder, no build step, no
33
+ restart.
34
+
35
+ ### Minimal user-dir extension
36
+
37
+ Drop a folder — `~/.config/opencode/webui-extensions/<name>/main.tsx`
38
+ (per-user) or `<project>/.opencode/webui-extensions/<name>/main.tsx`
39
+ (per-project). The proxy bundles it and the page loads it within a poll cycle
40
+ (~8s); toggle per extension in Settings › Extensions. Runtime extensions reach
41
+ the app ONLY through the versioned `window.__opencodeUI` bridge (`register`,
42
+ `react`, `useStore`, `api`, `notify`, `getHooks`):
43
+
44
+ ```tsx
45
+ // ~/.config/opencode/webui-extensions/hello/main.tsx
46
+ const { register, react } = window.__opencodeUI;
47
+
48
+ register({
49
+ kind: "region",
50
+ id: "hello",
51
+ region: "footer",
52
+ render: () => react.createElement("span", null, "hello from a user extension"),
53
+ });
54
+ ```
55
+
56
+ ## Extension kinds (the contract)
57
+
58
+ | Kind | What it does | Where it surfaces |
59
+ | --- | --- | --- |
60
+ | `region` | render into any `<Slot region="…">` marker placed by core (see generated table below) | wherever core placed a `<Slot>` |
61
+ | `command` | entry in the palette's "Extension commands" group (`run({ sessionID })`; `keybind` like `ctrl+shift+k` for global hotkey) | command palette (⌘/ctrl-K) + keybind |
62
+ | `slash` | **UI-only** slash entry for Composer `/name` (local `run(args,{sessionID})`; not engine) | Composer autocomplete (`/` menu) |
63
+ | `message` | full replacement for any message type (`type:"system"\|"synthetic"\|"shell"\|"compaction"\|"user"\|"assistant"\|…\|"*"`, `render({message, sessionID}) => node\|null`); first non-null wins, else core `renderMessageBody` | `MessageItem` per message |
64
+ | `message.decoration` | small extras under message rows; `render({ messageID, message }) => node\|null` | under every message row |
65
+ | `message.part` | inject after *each* part (text/tool/reasoning) inside a message; `render({messageID, message, part, partIndex})` | inside `MessageItem` per part |
66
+ | `tool.renderer` | custom card for a specific tool name (`toolName:"bash"\|"edit"\|…`, `render(part)`) | `ToolCard` per tool call |
67
+ | `contextMenu` | right-click menu item (`target:"message"\|"session"\|"file"`, `label`, `run`, `order`) | context menu |
68
+ | `hook` | intercept/behavior (`event:string`, `handler(ctx,next)`) — see Hook events below | store / Composer / MessageItem |
69
+ | `page` | full surface at `/ext/{id}` (route derived from the id) | sidebar links + direct URL |
70
+ | `settings` | titled section inside Settings › Extensions (`render: () => ReactNode`) | Settings dialog |
71
+
72
+ ### Hook events
73
+
74
+ | Event | `ctx` shape | When |
75
+ | --- | --- | --- |
76
+ | `session.prompt` | `{ text: string, sessionID: string }` — mutate `ctx.text` to transform; call `next()` to continue | Composer `submit` before `POST /api/session/{id}/prompt` |
77
+ | `message.render` | `{ message: MessageInfo, sessionID?: string }` — mutate `ctx.message` (shallow clone) before render | `MessageItem` before `message` renderer |
78
+ | `store.dispatch` | `{ action, ... }` | store middleware observer |
79
+
80
+ ## Regions (render points)
81
+
82
+ | Region | Render point |
83
+ | --- | --- |
84
+ | `app.header` | `src/App.tsx:238` |
85
+ | `composer.above` | `src/components/Composer.tsx:932` |
86
+ | `composer.below` | `src/components/Composer.tsx:1252` |
87
+ | `composer.toolbar` | `src/components/Composer.tsx:1240` |
88
+ | `footer` | `src/App.tsx:303` |
89
+ | `header.session.actions` | `src/components/Conversation.tsx:572` |
90
+ | `header.session.before` | `src/components/Conversation.tsx:520` |
91
+ | `message.after` | `src/components/MessageItem.tsx:258` |
92
+ | `message.before` | `src/components/MessageItem.tsx:239` |
93
+ | `sidebar` | `src/components/Sidebar.tsx:682` |
94
+ | `sidebar.session.after` | `src/components/Sidebar.tsx:982` |
95
+ | `sidebar.session.before` | `src/components/Sidebar.tsx:914` |
96
+ | `tool.after` | `src/components/ToolCard.tsx:58` |
97
+ | `tool.before` | `src/components/ToolCard.tsx:57` |
98
+ | `transcript.above` | `src/components/Conversation.tsx:128` |
99
+ | `transcript.below` | `src/components/Conversation.tsx:155` |
100
+ | `transcript.empty` | `src/components/Conversation.tsx:434` |
101
+
102
+ ## Add / remove / disable (app repo)
103
+
104
+ | Action | How |
105
+ | --- | --- |
106
+ | Add | Create `ui-extensions/<name>/`, add one import to `ui-extensions/index.ts`. Appears instantly via HMR. |
107
+ | Remove | Delete the import line and the folder. |
108
+ | Disable | Remove its id from the `enabled` list in `ui-extensions/config.ts` — one line, applies instantly via HMR, no reload. |
109
+
110
+ The `enabled` list in `ui-extensions/config.ts` is the runtime switch: only
111
+ ids listed there are rendered, even if the code is bundled. (A settings-panel
112
+ UI could drive the same list later — the mechanism is already in place.)
113
+
114
+ ## Reporting bugs
115
+
116
+ `/report` in the composer files a prefilled GitHub issue against
117
+ AbdelftahZowail/opencode-webui with a diagnostics bundle (build version, user
118
+ agent, enabled extension ids, window error ring). `--agent` hands the same
119
+ bundle to the session agent instead: when the user asks you to file it and
120
+ `gh` is authenticated, create the issue yourself from the bundle — the webui
121
+ never holds GitHub credentials. The engine's built-in `/report` skill is a
122
+ different thing (different repo, different diagnostics).
@@ -0,0 +1,271 @@
1
+ # UI Extensions
2
+
3
+ Frontend additions live in `ui-extensions/<name>/` and are **plain React
4
+ code, compiled into the app** — no plugin framework, no manifests, no dynamic
5
+ loading. This is a deliberate choice: extensions keep full type safety, hot
6
+ reload, and complete access to the app (store, API client, components).
7
+
8
+ ## The kind contract (stable API)
9
+
10
+ Extensions register **kinds** via `register()` from `src/extensions/registry.tsx`.
11
+ This list is versioned — the app only breaks an extension when a kind is
12
+ deliberately changed. Every kind is gated per-id by `enabled` in `ui-extensions/config.ts`
13
+ (ancestry-aware: `my-ext.sub` is on when `my-ext` is enabled).
14
+
15
+ | Kind | What it does | Where it surfaces |
16
+ | --- | --- | --- |
17
+ | `region` | render into any `<Slot region="…">` marker placed by core (see generated table below) | wherever core placed a `<Slot>` |
18
+ | `command` | entry in the palette's "Extension commands" group (`run({ sessionID })`; `keybind` like `ctrl+shift+k` for global hotkey) | command palette (⌘/ctrl-K) + keybind |
19
+ | `slash` | **UI-only** slash entry for Composer `/name` (local `run(args,{sessionID})`; not engine) | Composer autocomplete (`/` menu) |
20
+ | `message` | full replacement for any message type (`type:"system"\|"synthetic"\|"shell"\|"compaction"\|"user"\|"assistant"\|…\|"*"`, `render({message, sessionID}) => node\|null`); first non-null wins, else core `renderMessageBody` | `MessageItem` per message |
21
+ | `message.decoration` | small extras under message rows; `render({ messageID, message }) => node\|null` | under every message row |
22
+ | `message.part` | inject after *each* part (text/tool/reasoning) inside a message; `render({messageID, message, part, partIndex})` | inside `MessageItem` per part |
23
+ | `tool.renderer` | custom card for a specific tool name (`toolName:"bash"\|"edit"\|…`, `render(part)`) | `ToolCard` per tool call |
24
+ | `contextMenu` | right-click menu item (`target:"message"\|"session"\|"file"`, `label`, `run`, `order`) | context menu |
25
+ | `hook` | intercept/behavior (`event:string`, `handler(ctx,next)`) — see Hook events below | store / Composer / MessageItem |
26
+ | `page` | full surface at `/ext/{id}` (route derived from the id) | sidebar links + direct URL |
27
+ | `settings` | titled section inside Settings › Extensions (`render: () => ReactNode`) | Settings dialog |
28
+
29
+ Reference implementation of every kind was `ui-extensions/dev-sandbox/` (removed — clean launch; see git history for numbered examples).
30
+
31
+ ### Hook events
32
+
33
+ `hook` is open — `event` is a `string`, known values are versioned but you can
34
+ register any string and core will call `getHooks(event)` at the seam when it
35
+ exists. Today:
36
+
37
+ | Event | `ctx` shape | When |
38
+ | --- | --- | --- |
39
+ | `session.prompt` | `{ text: string, sessionID: string }` — mutate `ctx.text` to transform; call `next()` to continue | Composer `submit` before `POST /api/session/{id}/prompt` |
40
+ | `message.render` | `{ message: MessageInfo, sessionID?: string }` — mutate `ctx.message` (shallow clone) before render | `MessageItem` before `message` renderer |
41
+ | `store.dispatch` | `{ action, ... }` | store middleware observer |
42
+
43
+ Adding a new seam (e.g. `composer.submit`, `tool.started`) is one `getHooks("new.event")` call in core — no registry bump for existing extensions.
44
+
45
+ ### Slash: engine vs UI
46
+
47
+ Composer's `/` menu `src/components/Composer.tsx:576` is a merge:
48
+
49
+ 1. **UI built-ins** `slashActions[]` `Composer.tsx:403` (`/new`, `/undo`, `/thinking`… — local, never hits engine)
50
+ 2. **UI extensions** `kind:"slash"` `registry.tsx:62` (`/bench` → local `run(args,{sessionID})`)
51
+ 3. **Engine** `GET /api/command` + `GET /api/skill` `src/api/client.ts:677` (`commands`/`skills` → `POST /api/session/{id}/command`)
52
+ 4. Sorted + fuzzy `filterSlashEntries` `Composer.tsx:125`, capped `SLASH_MENU_LIMIT=10`.
53
+
54
+ * Want `/` to run **on the server** (tool, agent work)? Add an **engine plugin** (provides `Command`/`Skill` via `GET /api/plugin` — engine docs). It appears automatically, no UI change.
55
+ * Want `/` to run **locally in the UI** (toggle panel, run `api.*`, `useStore` action, `notify()`)? Use `kind:"slash"` in a UI extension. Keep the name `^[a-z0-9_-]+$`; on clash engine wins (UI extension warns and is skipped).
56
+
57
+ Palette `kind:"command"` stays the place for `⌘K` actions; `kind:"slash"` is only for the `/` autocomplete.
58
+
59
+ ## Anatomy of an extension
60
+
61
+ ```
62
+ ui-extensions/
63
+ index.ts ← auto-discovery: every <name>/index.{ts,tsx} is loaded,
64
+ no manual imports (visibility gated by config.ts)
65
+ hello/
66
+ index.tsx ← registers against one or more kinds
67
+ my-feature/
68
+ index.tsx
69
+ components.tsx ← anything else; it's just your code
70
+ ```
71
+
72
+ `ui-extensions/index.ts` is the only thing the app imports; each extension
73
+ folder self-registers:
74
+
75
+ ```tsx
76
+ // ui-extensions/hello/index.tsx
77
+ import { register } from "../../src/extensions/registry";
78
+
79
+ register({
80
+ kind: "region",
81
+ id: "hello",
82
+ region: "footer",
83
+ render: () => <span>Hello!</span>,
84
+ });
85
+ if (import.meta.hot) import.meta.hot.accept();
86
+ export const id = "hello";
87
+ ```
88
+
89
+ A tool renderer receives the full tool part:
90
+
91
+ ```tsx
92
+ register({
93
+ kind: "tool.renderer",
94
+ id: "my-bash-card",
95
+ toolName: "bash",
96
+ render: (part) => <pre>{part.state.content}</pre>,
97
+ });
98
+ ```
99
+
100
+ A UI-only slash:
101
+
102
+ ```tsx
103
+ register({
104
+ kind: "slash",
105
+ id: "my.slash",
106
+ name: "bench",
107
+ description: "Run bench locally",
108
+ aliases: ["b"],
109
+ run: (args, { sessionID }) => console.log("bench", args, sessionID),
110
+ });
111
+ ```
112
+
113
+ A message replacement (own `system`/`synthetic` without touching core):
114
+
115
+ ```tsx
116
+ register({
117
+ kind: "message",
118
+ id: "my.instructions",
119
+ type: "system",
120
+ render: ({ message }) => {
121
+ // return null to fall back to core's InstructionCard
122
+ if (!String((message as any).text ?? "").includes("The Code Mode tool catalog")) return null;
123
+ const title = (message as any).description ?? "Instructions updated";
124
+ return <div className="rounded-md border px-3 py-1.5 text-xs">{title}</div>;
125
+ },
126
+ });
127
+ ```
128
+
129
+ ## What extensions can use
130
+
131
+ Everything the app can — it's the same build:
132
+
133
+ - `useStore` / store actions from `src/store.ts` (session state, sending prompts, permissions, `selectSession`, `sendPromptTo`, `interrupt`, …)
134
+ - `api` from `src/api/client.ts` (any endpoint: `listSessions`, `messages`, `fsRead`, `shellCreate`, …)
135
+ - `window.__opencodeUI.notify({title, description, variant})` — toasts (also `import {notify} from "../../src/lib/notify"` in built-ins)
136
+ - `window.__opencodeUI.getHooks` — inspect registered hooks (runtime bridge)
137
+ - UI primitives from `src/components/ui/` (shadcn: `Button`, `Dialog`,
138
+ `DropdownMenu`, `Command`, `Tooltip`, `ContextMenu`, …) — always build on these so
139
+ extensions look native
140
+ - The OC-2 design tokens in `src/styles.css` — consume as
141
+ `var(--background-base)`, `var(--text-weak)`, `var(--border-base)`, etc.
142
+ **Never hardcode colors**; tokens keep extensions theme-compatible
143
+ - Any component, hook, or CSS class
144
+ - Right-click menus and per-part injections need no extra setup — just register `contextMenu`/`message.part` kinds.
145
+
146
+ ## Add / remove / disable
147
+
148
+ | Action | How |
149
+ | --- | --- |
150
+ | Add | Create `ui-extensions/<name>/`, add one import to `ui-extensions/index.ts`. Appears instantly via HMR. |
151
+ | Remove | Delete the import line and the folder. |
152
+ | Disable | Remove its id from the `enabled` list in `ui-extensions/config.ts` — one line, applies instantly via HMR, no reload. |
153
+
154
+ The `enabled` list in `ui-extensions/config.ts` is the runtime switch: only
155
+ ids listed there are rendered, even if the code is bundled. (A settings-panel
156
+ UI could drive the same list later — the mechanism is already in place.)
157
+
158
+ ## Sharing with others
159
+
160
+ An extension is a React component, so npm is the sharing format:
161
+
162
+ ```bash
163
+ bun add @someone/opencode-webui-status-bar
164
+ ```
165
+
166
+ ```tsx
167
+ // ui-extensions/index.ts
168
+ import { register } from "../src/extensions/registry";
169
+ import { StatusBar } from "@someone/opencode-webui-status-bar";
170
+ register({ kind: "region", id: "status-bar", region: "footer", render: () => <StatusBar /> });
171
+ ```
172
+
173
+ That's it — no plugin API to learn, nothing to build. Publishing a shared
174
+ extension is just publishing a component library.
175
+
176
+ ## Preview before production
177
+
178
+ The dev server (localhost:5173) is the preview: every edit hot-reloads there,
179
+ and production (the built app on 4097) only changes when you run
180
+ `bun run build && bun start`. For a second, isolated preview page of WIP
181
+ changes, run `bun run preview` (localhost:5174, proxy :4098).
182
+
183
+ ### Hot reload
184
+
185
+ Extension entries end with `if (import.meta.hot) import.meta.hot.accept();` and
186
+ export their `id` — keep both. Editing an extension hot-swaps it live (same-id
187
+ registry swap, slots repaint); ADDING a folder is hot too (`index.ts`
188
+ re-discovers without a reload). DELETING a folder is hot too: Vite accepts the
189
+ removal at the `index.ts` HMR boundary and `pruneExtensions` drops the slots
190
+ without a reload (proven by `scripts/uitest/extensions-check.ts` Phase C).
191
+ Flipping `config.ts` still reloads (it is imported by core). Slots removed by
192
+ an edit disappear cleanly and instantly.
193
+
194
+ ## Region markers
195
+
196
+ Drop-in render points addressed by string. Empty regions cost nothing;
197
+ register against one with `{ kind: "region", region: "...", render }`.
198
+
199
+ <!-- regions:auto:start -->
200
+ | Region | Render point |
201
+ | --- | --- |
202
+ | `app.header` | `src/App.tsx:238` |
203
+ | `composer.above` | `src/components/Composer.tsx:932` |
204
+ | `composer.below` | `src/components/Composer.tsx:1252` |
205
+ | `composer.toolbar` | `src/components/Composer.tsx:1240` |
206
+ | `footer` | `src/App.tsx:303` |
207
+ | `header.session.actions` | `src/components/Conversation.tsx:572` |
208
+ | `header.session.before` | `src/components/Conversation.tsx:520` |
209
+ | `message.after` | `src/components/MessageItem.tsx:258` |
210
+ | `message.before` | `src/components/MessageItem.tsx:239` |
211
+ | `sidebar` | `src/components/Sidebar.tsx:682` |
212
+ | `sidebar.session.after` | `src/components/Sidebar.tsx:982` |
213
+ | `sidebar.session.before` | `src/components/Sidebar.tsx:914` |
214
+ | `tool.after` | `src/components/ToolCard.tsx:58` |
215
+ | `tool.before` | `src/components/ToolCard.tsx:57` |
216
+ | `transcript.above` | `src/components/Conversation.tsx:128` |
217
+ | `transcript.below` | `src/components/Conversation.tsx:155` |
218
+ | `transcript.empty` | `src/components/Conversation.tsx:434` |
219
+ <!-- regions:auto:end -->
220
+
221
+ Run `bun run regions` after adding a `<Slot region="…">` in core — the table is auto-generated.
222
+
223
+ ## Limitations & when to use what
224
+
225
+ Extensions are **not a second engine**. These stay engine-owned:
226
+
227
+ | Area | UI extension can do | Engine plugin must do |
228
+ | --- | --- | --- |
229
+ | Slash that runs on server | Show it as UI-only `kind:"slash"` but it won't hit `POST /api/session/{id}/command` | Provide `Command`/`Skill` via engine plugin (`GET /api/plugin` → `GET /api/command`) |
230
+ | New tool that the model can call | Render it differently via `kind:"tool.renderer"` | Provide the tool itself (engine `Tool` + execution) |
231
+ | New permission/form/question kind | Render extra decoration, auto-reply via `api` + `hook:store.dispatch` observer | Define it on engine |
232
+ | Session/model/agent lifecycle | Read/trigger via `useStore`/`api` | Own it |
233
+
234
+ **No snooping needed:** if a region/kind isn't in the tables above, it doesn't exist. Adding a region is one line `<Slot region="area.thing" />` in core + `bun run regions`; adding a kind is a deliberate contract change in `src/extensions/registry.tsx` (+ docs here + `src/extensions/registry.tsx:5` header + `AGENTS.md`). Don't invent speculative kinds — add a `region` first.
235
+
236
+ **What you don't need to fork core for anymore:**
237
+
238
+ * Verbose `Instructions updated` / catalog dump `src/components/MessageItem.tsx:268` → `kind:"message"` `type:"system"|"synthetic"` with `return null` fallback
239
+ * Per-session badges / cost / PR status `src/components/Sidebar.tsx:892` → `region:"sidebar.session.before/after"` with `sessionID` ctx
240
+ * Extra header buttons `src/components/Conversation.tsx:518` → `region:"header.session.before/after"`
241
+ * Composer buttons next to `Send` `src/components/Composer.tsx:1238` → `region:"composer.toolbar"`
242
+ * Wrapping a tool or message `src/components/ToolCard.tsx:29` → `region:"tool.before/after"` / `region:"message.before/after"` or `kind:"message"` / `kind:"tool.renderer"` for full replacement
243
+ * Intercepting a prompt `src/components/Composer.tsx:727` → `hook:"session.prompt"` mutate `ctx.text`; observing renders → `hook:"message.render"` mutate `ctx.message`
244
+
245
+ ## Runtime plugin extensions (plugin-shipped UI)
246
+
247
+ An opencode v2 plugin can carry a WEB UI half that this app loads at runtime —
248
+ no webui rebuild, ever.
249
+
250
+ **Convention**: for a plugin whose entry is `<dir>/foo.ts`, the UI entry is
251
+ `<dir>/ui/main.tsx` (or a sibling `<dir>/foo.ui.tsx`). Only plugins loaded
252
+ from LOCAL sources (`Plugin.Source {type:"local"}`) are discovered in v1;
253
+ npm-package plugins would need node resolution and are future work.
254
+
255
+ **Pipeline**: `GET /api/plugin` (engine) → proxy finds UI entries →
256
+ `Bun.build` bundles each entry as a SELF-CONTAINED ES module (it bundles its
257
+ own React copy) → served at `/api/webui/extensions/{id}/bundle.js?v=mtime`.
258
+ The page lists them from `GET /api/webui/extensions`, allow-lists their ids in
259
+ the registry, and `import()`s each bundle. Bundles reach the app ONLY through
260
+ the `window.__opencodeUI` bridge (`{ version, register, react, jsxRuntime,
261
+ useStore, api }`) — that object is the versioned public API for runtime
262
+ extensions; deep internal imports are not available to them.
263
+
264
+ **Gating**: default ON (installing the plugin means you wanted it). Toggle any
265
+ of them in Settings › Extensions — persisted per browser, applies within one
266
+ poll cycle (~8s; off = unregistered immediately). Built-in `ui-extensions/`
267
+ are not listed there; they stay gated by `config.ts`.
268
+
269
+ **Talking to the engine half**: the UI half and the engine half of a plugin
270
+ communicate through the engine's normal HTTP/SSE surface — call routes via
271
+ `api`, subscribe to events via the store's SSE connection, exactly like core.