opencode-webui 2.4.0 → 3.0.1

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.
@@ -6,8 +6,8 @@ that matches the job (framing rule below), never two for the same job.
6
6
 
7
7
  ```
8
8
  my-extension/
9
- manifest.json id, name, version, description, disabled (optional bool)
10
- index.tsx browser stratum: register() against the registry
9
+ manifest.json id, name, version, description; optional disabled, settings, requires, capabilities
10
+ index.tsx browser stratum: register() and/or activate(ctx)
11
11
  dom.ts DOM stratum: post-render DOM changes (the free layer)
12
12
  server.ts proxy stratum: routes / middleware / event tap / pollers
13
13
  engine/ optional opencode plugin payload (tools, system-prompt hints)
@@ -16,7 +16,16 @@ my-extension/
16
16
  ```jsonc
17
17
  // manifest.json
18
18
  { "id": "my-extension", "name": "My extension", "version": "1.0.0",
19
- "description": "What it does" /* "disabled": true — paused */ }
19
+ "description": "What it does" /* "disabled": true — paused */,
20
+ // Declared settings (roadmap 5): core renders these in Settings › Extensions.
21
+ "settings": [
22
+ { "key": "enabled", "type": "boolean", "title": "Enabled", "default": true },
23
+ { "key": "threshold", "type": "number", "title": "Threshold", "default": 5, "min": 1, "max": 20 },
24
+ { "key": "mode", "type": "enum", "title": "Mode", "options": ["fast", "thorough"], "default": "fast" }
25
+ ],
26
+ // Checkable references (roadmap 8): an unmet one is a visible warning.
27
+ "requires": { "api": 1, "targets": ["message.timestamp"], "slots": ["composer.above"] },
28
+ "capabilities": ["notify"] }
20
29
  ```
21
30
 
22
31
  **Gating — one state, owned by the folder itself:** presence = installed;
@@ -28,6 +37,16 @@ pausing: the former keeps the entry loaded but quiet, the latter
28
37
  (`disabled: true`) is never bundled or imported and its id unregisters —
29
38
  use a settings toggle for "off for now", the manifest for "unplug".
30
39
 
40
+ **Settings › Extensions** renders one card per installed extension (its
41
+ `name`/`description` from `manifest.json`) with an on/off switch, plus any
42
+ `settings` collection the extension contributes, inline in that card. The
43
+ switch is just a UI for the folder flag above: it calls
44
+ `POST /api/webui/extensions/<id>/state { disabled }`, which edits the winning
45
+ folder's `manifest.json`. A **shipped** id is never edited in place (an app
46
+ update would clobber the flag) — disabling it writes a user-level shadow
47
+ folder with the same id, removed again on re-enable (shipped browser bundles
48
+ are glob-owned, so re-enabling reloads the page to re-register them).
49
+
31
50
  **Precedence (same id = same swap point, higher wins):**
32
51
 
33
52
  1. `~/.config/opencode/webui-extensions/<name>/` — user
@@ -43,7 +62,11 @@ extension code is not sandboxed (same model as host plugins).
43
62
  > browser loader + manifest SSE, proxy-stratum mounts, `dom.ts` loader wiring
44
63
  > + `data-oc-*` stamping, core self-registration, and the `ui-extensions/` →
45
64
  > `webui-extensions/` rename are landed (`docs/extension-system-spec.md` §11).
46
- > The contract below is what that work converged on — write to it.
65
+ > The **roadmap seams are landed too** (`docs/extension-roadmap.md` items 1–9,
66
+ > `EXT_API_VERSION` 2): prop-transforming wraps, the activation context +
67
+ > disposal, scheduler access, the event bus, the curated store facade,
68
+ > declared settings, thin slots, manifest `requires` diagnostics, and peer
69
+ > composition. The contract below is what that work converged on — write to it.
47
70
 
48
71
  ## Choosing a stratum (framing rule)
49
72
 
@@ -65,7 +88,7 @@ semantics are the whole point: `wrap` = default, `replace` = ownership.
65
88
 
66
89
  | Kind | Job | Staleness |
67
90
  | --- | --- | --- |
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. |
91
+ | `wrap` | Flow-through tweak of any registered target: `render(props, next)` — transform output, and/or call `next(overrides)` to merge changed/extra props into the rest of the chain, delegating to live core by default | **Stale-proof by construction.** Core updates always render *through* it. The default path for edits. |
69
92
  | `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
93
  | `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
94
  | `hook` | Interception at instrumented boundaries: `{ event, handler(ctx, next) }` — `event` is an open string | New seams are new event names, never a registry change. |
@@ -90,7 +113,7 @@ with rich props, so wraps and value-overrides stay surgical.
90
113
  | `conversation.header` | full `HeaderProps` |
91
114
  | `conversation.empty` | — |
92
115
  | `composer` | full `ComposerProps` |
93
- | `composer.contextReadout` | `parts: string[]` |
116
+ | `composer.contextReadout` | `parts: string[]`, `sessionID` |
94
117
  | `composer.sendActions` | `sessionID`, `appendDraft(text)` — space-joins onto the draft + refocuses; prefer over writing drafts directly |
95
118
  | `message.timestamp` | `time: number` (consults the `format.timestamp` service) |
96
119
  | `message.tokens` | `tokens` |
@@ -115,14 +138,176 @@ must never touch streaming output can rely on the distinction structurally.
115
138
  - **Runtime code uses the bridge only.** External (user/project-dir) bundles
116
139
  are built standalone: `import type` from `src/` is erased at build and
117
140
  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
141
+ Use `window.__opencodeUI` (`register`, `react`, `api`, `store` [the curated
142
+ facade], `events`, `settings`, `collections`, `bus`, `prefs`, `notify`,
143
+ `services`, `dom`, `kv`; raw modules as `advanced.*`) — shipped code consumes the identical
120
144
  surface via `getExtensionApi()`.
121
145
  - **The `@/` alias works in shipped extensions only.** Same repo, same
122
146
  tsconfig (`@/*` → `./src/*`, e.g. a shipped extension imports
123
147
  `@/components/ui/dialog`) — external copies must still use the bridge,
124
148
  never `@/` or relative `src/` paths.
125
149
 
150
+ ### Activation (lifecycle) — the shape to write
151
+
152
+ `index.tsx` may export an activation entry instead of registering at module
153
+ scope:
154
+
155
+ ```tsx
156
+ export const id = "my-extension";
157
+
158
+ export function activate(ctx) {
159
+ ctx.register({ kind: "wrap", id: "my-wrap", target: "message.timestamp", render: … });
160
+ const stop = someObserver(); // timer, listener, subscription…
161
+ ctx.onDispose(stop); // or: return stop
162
+ }
163
+ ```
164
+
165
+ - `ctx.register(entry)` — the same five kinds; the id is remembered so
166
+ teardown prunes exactly this extension's entries.
167
+ - `ctx.poll({ name, minInterval, intervals?, whenHidden?, run })` — recurring
168
+ work on the shared scheduler (tier-aware, jittered; the app's only timer
169
+ owner). Returns an idempotent stop; stopped automatically on dispose.
170
+ - `ctx.after(ms, fn)` — one-shot delay; returns an idempotent cancel; cleared
171
+ automatically on dispose.
172
+ - `ctx.on(name, handler)` — subscribe to the event bus (raw engine type or
173
+ derived lifecycle name; `"*"` = all). Returns an unsubscribe; removed
174
+ automatically on dispose. See **Events (observe)** below.
175
+ - `ctx.subscribe(selector, listener)` — derived store read: fires immediately,
176
+ then only when the selected value changes (shallow-equal). Auto-removed on
177
+ dispose. See **Store (read + act)** below.
178
+ - `ctx.store` — the curated store facade (selectors + actions); the same
179
+ object as the bridge's `store`.
180
+ - `ctx.settings` — resolved declared settings (`get`/`set`/`reset`/`subscribe`);
181
+ subscriptions disposed with the extension. See **Declared settings +
182
+ requirements**. No manifest schema → empty handle (use `kv` for ad-hoc data).
183
+ - `ctx.collections` — read other extensions' contributions (`get(collection)`)
184
+ and every live collection id (`list()`). See **Peer composition**.
185
+ - `ctx.bus` — extension-to-extension events: `publish(channel, payload)`
186
+ (tagged with this id) and `subscribe(channel, fn)` (disposed with the
187
+ extension). See **Peer composition**.
188
+ - `ctx.onDispose(fn)` / returning a teardown fn — runs on hot-swap,
189
+ `disabled: true`, and delete (LIFO, crash-isolated). This is the one place
190
+ non-React cleanup belongs — no `window.__*Installed` guards.
191
+ - `ctx.log(...)` — prefixed with the extension id.
192
+ - `ctx.services.getService` / `getServiceProviders` — named-logic lookups.
193
+
194
+ Module-scope `register({…})` still works (the loaders fall back to the
195
+ registry id-delta), but it is the shape being deprecated: nothing outside the
196
+ module can dispose what the module did, so `disabled`/delete/hot-swap can't
197
+ tear down listeners or timers it started. New extensions write `activate`.
198
+ The DOM stratum already has this contract (`mount` returns a cleanup fn).
199
+
200
+ ### Events (observe)
201
+
202
+ `ctx.on(name, handler)` (or the bridge's `events.subscribe`) observes what
203
+ happened without diffing store snapshots. Two families share one channel:
204
+
205
+ - **Raw engine events** — every event the store reduces, under its engine
206
+ `type` (`session.tool.success`, `session.text.delta`, `permission.asked`,
207
+ `session.idle`, …). Payload is the event's `data`.
208
+ - **Derived lifecycle events** — core computes these so you don't infer them:
209
+ `run.started` `{sessionID}` · `run.ended` `{sessionID,reason}` ·
210
+ `tool.called` `{sessionID,assistantMessageID,id,name,input?}` ·
211
+ `tool.completed` `{sessionID,assistantMessageID,id,name?,ok}` ·
212
+ `message.appended` `{sessionID,messageID,type}`.
213
+
214
+ ```tsx
215
+ ctx.on("tool.completed", (e) => {
216
+ const { name, ok } = e.payload;
217
+ usage[name] = (usage[name] ?? 0) + (ok ? 1 : 0);
218
+ });
219
+ ctx.on("run.ended", () => notify({ title: "Run finished" }));
220
+ ```
221
+
222
+ Delivery is frame-batched (16ms) so a token burst is one dispatch per frame.
223
+ `"*"` receives everything (diagnostics/analytics — it is per-event, so filter).
224
+ Listeners are crash-isolated; the subscription is disposed with the
225
+ extension. The bus is notification-only: it never mutates state and
226
+ extensions cannot publish.
227
+
228
+ ### Store (read + act)
229
+
230
+ `ctx.store` (bridge: `store`) is the curated, **supported** store surface —
231
+ selectors and actions, never the raw module (which stays reachable, and
232
+ explicitly unsupported, as `advanced.store`):
233
+
234
+ - **observe:** `subscribe(listener)`, `select(selector, listener)`,
235
+ `useStore(selector)` (React), `getState()` (full snapshot)
236
+ - **read:** `currentSessionID()`, `sessions()`, `sessionDetail(id)`,
237
+ `messages(id)`, `liveAssistants(id)`, `isRunning(id)`, `isQueued(id)`,
238
+ `pendingRequests()`, `isDraftSession(id)`, `sessionHref(id)`
239
+ - **act:** `sendPrompt`, `sendPromptTo(id, text, {delivery?})`,
240
+ `selectSession`, `navigateFocused`, `newSession`, `materializeDraft`,
241
+ `replyPermission`, `replyForm`, `replyQuestion`, `rejectQuestion`,
242
+ `interrupt`, `switchAgent`, `switchModel`, `renameSession`,
243
+ `compactSession`, `undoSession`, `redoSession`, `activateSkill`
244
+
245
+ ```tsx
246
+ // ctx.subscribe auto-disposes; ctx.store.select is the manual form.
247
+ ctx.subscribe((s) => s.currentSessionID, (id) => badge.textContent = id ?? "");
248
+ ```
249
+
250
+ Core keeps adding internal state/actions — those do **not** become API. A new
251
+ extension need means a deliberate addition to the facade (version bump), not
252
+ reaching into `advanced.store`.
253
+
254
+ ### Declared settings + requirements (manifest)
255
+
256
+ Two optional `manifest.json` blocks turn a fragile extension into a checkable
257
+ one.
258
+
259
+ **`settings`** — declare options once; core renders them in the extension's
260
+ Settings card and persists per id (defaults applied, invalid/legacy values
261
+ dropped). The extension just reads resolved values via `ctx.settings` (or the
262
+ bridge's `settings.forExt(id)`):
263
+
264
+ ```tsx
265
+ export function activate(ctx) {
266
+ const apply = () => (opts = ctx.settings.get());
267
+ apply();
268
+ ctx.settings.subscribe(apply); // auto-disposed
269
+ }
270
+ ```
271
+
272
+ Schema subset per field: `{ key, type, title, description?, default? }` where
273
+ `type` is `boolean | number (min/max/step) | string (placeholder) | enum
274
+ (options: string[])`. That covers the common toggle/threshold/format option
275
+ with no bespoke settings component or storage.
276
+
277
+ **`requires`** — declare the references you depend on
278
+ (`api` version, `targets`, `slots`, `services`). Core checks them every
279
+ manifest sync; an unmet one becomes a visible warning in your Settings card
280
+ (`⚠ unmet target "…"`) plus a console warning, instead of a silently blank
281
+ spot. `capabilities` is free-form declared metadata.
282
+
283
+ Static shape problems (bad `settings`/`requires`) are reported the same way —
284
+ never swallowed.
285
+
286
+ ### Peer composition
287
+
288
+ Extensions cooperate through two read-only surfaces — neither imports the
289
+ other:
290
+
291
+ - **Collections** — read what any extension contributed:
292
+ `ctx.collections.get("palette")` (or the bridge's `collections.get`), plus
293
+ `ctx.collections.list()` for every live collection id. Contributing is the
294
+ existing `contribute` kind; consuming another's items is just reading.
295
+ - **The peer bus** — extension-to-extension events:
296
+ `ctx.bus.subscribe(channel, fn)` and `ctx.bus.publish(channel, payload)`
297
+ (the event carries `from: <publisher id>`). Channels are free-form strings;
298
+ delivery is synchronous and low-frequency (coordination, not streams).
299
+ Subscriptions are disposed with the extension.
300
+
301
+ ```tsx
302
+ // provider
303
+ ctx.register({ kind: "contribute", id: "my-metric", collection: "metrics", item: { label: "TPS" } });
304
+ // consumer — same-page, no import
305
+ ctx.bus.subscribe("metrics.changed", ({ from }) => refresh(ctx.collections.get("metrics")));
306
+ ctx.bus.publish("metrics.changed", {});
307
+ ```
308
+
309
+ `service` still covers one-to-one provide/consume; this covers many-to-many.
310
+
126
311
  ```tsx
127
312
  // index.tsx — wrap the timestamp, own nothing else
128
313
  import { register } from "../../src/extensions/registry";
@@ -145,6 +330,20 @@ register({
145
330
  });
146
331
  ```
147
332
 
333
+ ```tsx
334
+ // index.tsx — a wrap that FEEDS the target changed props, not just output.
335
+ // `next(overrides)` shallow-merges overrides into every remaining wrap and
336
+ // the leaf (core default / winning replace). With no argument, behavior is
337
+ // exactly as before. Overrides never change the wrap's own `props`.
338
+ register({
339
+ kind: "wrap",
340
+ id: "my-append-action",
341
+ target: "composer.sendActions",
342
+ render: (props, next) =>
343
+ next({ extraActions: [...(props.extraActions as unknown[]), <MyButton />] }),
344
+ });
345
+ ```
346
+
148
347
  ```tsx
149
348
  // index.tsx — replace with fall-through: own one case, defer the rest
150
349
  register({
@@ -167,7 +366,8 @@ UI-only, local `run(args, { sessionID })`; engine commands come from
167
366
  (item `{ title, description?, render }`, routed at `/ext/{id}`),
168
367
  `settings` (item `{ title, description?, render }`, section in
169
368
  Settings › Extensions), `contextMenu.message`, `contextMenu.session`,
170
- `contextMenu.file` (item `{ label, run, order? }`).
369
+ `contextMenu.file` (item `{ label, run, order? }`), and the `slot:<id>`
370
+ placement collections (see Slots below).
171
371
 
172
372
  ```tsx
173
373
  register({
@@ -178,6 +378,30 @@ register({
178
378
  });
179
379
  ```
180
380
 
381
+ ### Slots (placement)
382
+
383
+ A **slot** is a named insertion point in core chrome — placement, not
384
+ identity. Targets render a unit's component chain (something with an id you
385
+ tweak); slots render whatever anyone contributed to a place. Same `contribute`
386
+ kind, collection `slot:<slotID>`:
387
+
388
+ ```tsx
389
+ register({
390
+ kind: "contribute",
391
+ id: "my-compose-badge",
392
+ collection: "slot:composer.above",
393
+ item: { render: ({ sessionID }) => <span>draft for {sessionID}</span> },
394
+ });
395
+ ```
396
+
397
+ Known slot ids (the versioned registry, `src/extensions/slots.tsx`):
398
+ `conversation.header.actions`, `conversation.empty`, `composer.above`,
399
+ `composer.actions`, `sidebar.header.actions`. Contributing to an unknown
400
+ `slot:<id>` renders nowhere — declare it in `requires.slots` to get a warning
401
+ instead. Items sort by `order` (lower first); each is crash-isolated; the
402
+ stamp site carries `data-oc-slot="<id>"` for the DOM stratum. Renaming/moving
403
+ a slot id is a contract bump + migration note.
404
+
181
405
  ### Hook catalog
182
406
 
183
407
  Open event strings — fired from the api client wrapper (every endpoint),
@@ -246,6 +470,7 @@ extensions break silently on every redesign.
246
470
  | `data-oc-queue-strip` | QueueStrip (steer/queue rows) |
247
471
  | `data-oc-subagent-strip` | SubagentStrip |
248
472
  | `data-oc-runs-panel` | RunsPanel |
473
+ | `data-oc-slot` | Slot wrapper (`slot:<id>` — one per known slot id) |
249
474
 
250
475
  ```ts
251
476
  // dom.ts — badge next to the send button, cleaned up on hot-swap
@@ -358,24 +583,36 @@ One folder becomes pixels through four files — follow them in order:
358
583
  3. **Manifest + SSE + bundling (proxy).** `server/index.ts` merges folder
359
584
  entries with engine-plugin UI halves, serves
360
585
  `GET /api/webui/extensions` (`{ id, url?v=mtime, domUrl?v=mtime,
361
- source, origin }`), pushes a `{ type: "webui.extensions", version }`
586
+ source, origin, name?, description?, disabled?, settings?, requires?,
587
+ capabilities? }`), pushes a `{ type: "webui.extensions", version }`
362
588
  event per manifest change on `GET /api/webui/extensions/events`, and
363
589
  bundles each entry standalone with `Bun.build` (`bundleUIEntry` —
364
590
  react external, build logs printed loudly, never silent).
365
591
  4. **Import + register (page).** `src/lib/runtimeExtensions.ts` fetches
366
592
  the manifest, dynamic-imports each new `?v=` bundle (re-import on
367
- mtime move → registry same-id-swap → live repaint), mounts `domUrl`
368
- via the DOM kit, and unregisters ids that vanish or flip
369
- `disabled: true`. Shipped browser bundles are skipped here (the glob
593
+ mtime move → dispose the old instance → registry same-id-swap → live
594
+ repaint), runs the module's `activate(ctx)` entry when present, mounts
595
+ `domUrl` via the DOM kit, and disposes + unregisters ids that vanish or
596
+ flip `disabled: true`. Shipped browser bundles are skipped here (the glob
370
597
  owns them — importing twice would run side effects twice) but shipped
371
- `domUrl` still mounts and `disabled` still pauses them.
598
+ `domUrl` still mounts and `disabled` still pauses them. Each sync also
599
+ parses the declared contract: the settings schema is registered for
600
+ `ctx.settings`, and unmet `requires` / malformed shapes become visible
601
+ diagnostics (see **Declared settings + requirements**).
372
602
 
373
603
  ## What extensions can use (browser stratum)
374
604
 
375
605
  Everything the app can — shipped extensions are the same build:
376
606
 
377
- - `useStore` / store actions from `src/store.ts`
607
+ - the curated store facade (`store` / `ctx.store`), or `useStore` directly in
608
+ shipped components
609
+ - declared settings (`ctx.settings` / `settings.forExt(id)`) — core renders and
610
+ persists the manifest schema
611
+ - other extensions' contributions and the peer bus
612
+ (`ctx.collections` / `ctx.bus`)
378
613
  - `api` from `src/api/client.ts` (every endpoint fires `api.pre/post/error`)
614
+ - the event bus (`ctx.on` / `events.subscribe`) — raw engine events + derived
615
+ lifecycle events, frame-batched
379
616
  - `getService` / services from `src/extensions/registry.tsx`
380
617
  - UI primitives from `src/components/ui/` (shadcn) — always build on these
381
618
  so extensions look native
@@ -383,8 +620,9 @@ Everything the app can — shipped extensions are the same build:
383
620
  - Toaster via the extension API surface (`notify`)
384
621
 
385
622
  External (user/project-dir) extensions use the one extension API surface
386
- (`register`, `react`, `api`, `store`, `prefs`, `notify`, `services`, `dom`
387
- kit, `kv`) used identically by our shipped ones.
623
+ (`register`, `react`, `api`, `store` facade, `events`, `settings`,
624
+ `collections`, `bus`, `prefs`, `notify`, `services`, `dom` kit, `kv`,
625
+ `advanced.*` raw modules) — used identically by our shipped ones.
388
626
 
389
627
  ## Hot reload guarantees
390
628