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.
- package/README.md +76 -8
- package/dist/assets/TerminalView-46ee1Rko.js +18 -0
- package/dist/assets/TerminalView-BrP-ENHg.css +1 -0
- package/dist/assets/client-DkhM26jE.js +2 -0
- package/dist/assets/index-BpbA074E.css +2 -0
- package/dist/assets/index-NnlYGssZ.js +113 -0
- package/dist/assets/report-D38Le2zy.js +2 -0
- package/dist/icons/apple-touch-icon.png +0 -0
- package/dist/icons/badge-96.png +0 -0
- package/dist/icons/icon-192.png +0 -0
- package/dist/icons/icon-512.png +0 -0
- package/dist/icons/maskable-512.png +0 -0
- package/dist/index.html +10 -3
- package/dist/manifest.webmanifest +1 -0
- package/dist/sw.js +143 -0
- package/package.json +4 -1
- package/server/auth.ts +39 -22
- package/server/config.ts +498 -0
- package/server/index.ts +500 -36
- package/server/lifecyclePlugin.ts +118 -0
- package/server/setup.ts +774 -0
- package/server/userExtensions.ts +198 -5
- package/skills/webui/SKILL.md +59 -12
- package/webui-extensions/README.md +255 -17
- package/dist/assets/index-Cn0VQKKh.css +0 -1
- package/dist/assets/index-DYfCCaPy.js +0 -128
- package/dist/assets/report-BQezg0ph.js +0 -2
|
@@ -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
|
|
10
|
-
index.tsx browser stratum: register()
|
|
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
|
|
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
|
|
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
|
|
119
|
-
`
|
|
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
|
|
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
|
|
368
|
-
|
|
369
|
-
`
|
|
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
|
-
- `
|
|
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
|
|
387
|
-
|
|
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
|
|