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
package/server/userExtensions.ts
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* the folder to uninstall. No config.ts list, no per-browser localStorage.
|
|
12
12
|
*
|
|
13
13
|
* Folder anatomy (new format; legacy `main.tsx`-only folders still load):
|
|
14
|
-
* manifest.json { id?, name?, version?, description?, disabled
|
|
14
|
+
* manifest.json { id?, name?, version?, description?, disabled?,
|
|
15
|
+
* settings?, requires?, capabilities? }
|
|
15
16
|
* index.tsx browser stratum entry (preferred)
|
|
16
17
|
* main.tsx legacy browser entry (fallback)
|
|
17
18
|
* dom.ts DOM stratum entry (spec §7 — post-render DOM changes)
|
|
@@ -23,7 +24,7 @@
|
|
|
23
24
|
* (no index/main entry) still lists with only `domUrl`.
|
|
24
25
|
*/
|
|
25
26
|
|
|
26
|
-
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
27
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
27
28
|
import { homedir } from "node:os";
|
|
28
29
|
import { dirname, join } from "node:path";
|
|
29
30
|
import { fileURLToPath } from "node:url";
|
|
@@ -37,6 +38,20 @@ export type UIEntry = {
|
|
|
37
38
|
domEntry?: string;
|
|
38
39
|
domMtimeMs?: number;
|
|
39
40
|
source?: string;
|
|
41
|
+
/** manifest.json display fields, surfaced in Settings › Extensions. */
|
|
42
|
+
name?: string;
|
|
43
|
+
description?: string;
|
|
44
|
+
/**
|
|
45
|
+
* manifest.json `settings` — the declared settings schema (roadmap 5).
|
|
46
|
+
* Opaque here: the browser parses/normalizes it (`src/extensions/manifest.ts`);
|
|
47
|
+
* the proxy only carries it to the manifest so Settings can render it even
|
|
48
|
+
* while the extension is paused.
|
|
49
|
+
*/
|
|
50
|
+
settings?: unknown;
|
|
51
|
+
/** manifest.json `requires` — the checkable contract (roadmap 8). Opaque. */
|
|
52
|
+
requires?: unknown;
|
|
53
|
+
/** manifest.json `capabilities` — declared capabilities (roadmap 8). Opaque. */
|
|
54
|
+
capabilities?: unknown;
|
|
40
55
|
/** manifest.json `disabled: true` — paused, never bundled or imported. */
|
|
41
56
|
disabled?: boolean;
|
|
42
57
|
/** Which of the three sources won for this id. */
|
|
@@ -86,6 +101,17 @@ export function warnOnce(key: string, message: string): void {
|
|
|
86
101
|
console.warn(`[webui] ${message}`);
|
|
87
102
|
}
|
|
88
103
|
|
|
104
|
+
/** Pass a malformed manifest field through as absent, warning once (roadmap 8). */
|
|
105
|
+
function malformed(value: unknown, id: string, field: string): undefined {
|
|
106
|
+
if (value !== undefined) {
|
|
107
|
+
warnOnce(
|
|
108
|
+
`ext-manifest:${id}:${field}`,
|
|
109
|
+
`extension "${id}" manifest.json \`${field}\` has the wrong shape — ignored`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
89
115
|
let cache: { at: number; entries: UIEntry[] } | null = null;
|
|
90
116
|
|
|
91
117
|
/** Drop the discovery cache so the next read re-scans disk (watcher path). */
|
|
@@ -93,11 +119,29 @@ export function invalidateExtensionCache(): void {
|
|
|
93
119
|
cache = null;
|
|
94
120
|
}
|
|
95
121
|
|
|
96
|
-
function readManifest(dir: string): {
|
|
122
|
+
function readManifest(dir: string): {
|
|
123
|
+
id?: unknown;
|
|
124
|
+
disabled?: unknown;
|
|
125
|
+
name?: unknown;
|
|
126
|
+
description?: unknown;
|
|
127
|
+
settings?: unknown;
|
|
128
|
+
requires?: unknown;
|
|
129
|
+
capabilities?: unknown;
|
|
130
|
+
} | null {
|
|
97
131
|
try {
|
|
98
132
|
const raw = readFileSync(join(dir, "manifest.json"), "utf8");
|
|
99
133
|
const parsed: unknown = JSON.parse(raw);
|
|
100
|
-
if (parsed && typeof parsed === "object")
|
|
134
|
+
if (parsed && typeof parsed === "object") {
|
|
135
|
+
return parsed as {
|
|
136
|
+
id?: unknown;
|
|
137
|
+
disabled?: unknown;
|
|
138
|
+
name?: unknown;
|
|
139
|
+
description?: unknown;
|
|
140
|
+
settings?: unknown;
|
|
141
|
+
requires?: unknown;
|
|
142
|
+
capabilities?: unknown;
|
|
143
|
+
};
|
|
144
|
+
}
|
|
101
145
|
return null;
|
|
102
146
|
} catch {
|
|
103
147
|
return null; // absent or unreadable — legacy folder, id falls back to dir name
|
|
@@ -153,7 +197,15 @@ function scanRoot(root: string, origin: UIEntry["origin"], entries: UIEntry[], s
|
|
|
153
197
|
continue;
|
|
154
198
|
}
|
|
155
199
|
const dir = join(root, name);
|
|
156
|
-
let manifest: {
|
|
200
|
+
let manifest: {
|
|
201
|
+
id?: unknown;
|
|
202
|
+
disabled?: unknown;
|
|
203
|
+
name?: unknown;
|
|
204
|
+
description?: unknown;
|
|
205
|
+
settings?: unknown;
|
|
206
|
+
requires?: unknown;
|
|
207
|
+
capabilities?: unknown;
|
|
208
|
+
} | null = null;
|
|
157
209
|
try {
|
|
158
210
|
manifest = readManifest(dir);
|
|
159
211
|
} catch {
|
|
@@ -165,7 +217,21 @@ function scanRoot(root: string, origin: UIEntry["origin"], entries: UIEntry[], s
|
|
|
165
217
|
warnOnce(`ext-dup:${id}`, `extension "${id}" shadowed — keeping the higher-precedence copy`);
|
|
166
218
|
continue;
|
|
167
219
|
}
|
|
220
|
+
const displayName = typeof manifest?.name === "string" && manifest.name.length > 0 ? manifest.name : undefined;
|
|
221
|
+
const description =
|
|
222
|
+
typeof manifest?.description === "string" && manifest.description.length > 0 ? manifest.description : undefined;
|
|
168
223
|
const disabled = manifest?.disabled === true;
|
|
224
|
+
// Roadmap 5/8: carry the declared settings schema / requires / capabilities
|
|
225
|
+
// through to the manifest. Malformed shapes are dropped with one warning
|
|
226
|
+
// (never a silent no-op) — validation proper happens browser-side.
|
|
227
|
+
const settings = Array.isArray(manifest?.settings) ? manifest.settings : malformed(manifest?.settings, id, "settings");
|
|
228
|
+
const requires =
|
|
229
|
+
manifest?.requires && typeof manifest.requires === "object" && !Array.isArray(manifest.requires)
|
|
230
|
+
? manifest.requires
|
|
231
|
+
: malformed(manifest?.requires, id, "requires");
|
|
232
|
+
const capabilities = Array.isArray(manifest?.capabilities)
|
|
233
|
+
? manifest.capabilities
|
|
234
|
+
: malformed(manifest?.capabilities, id, "capabilities");
|
|
169
235
|
const entry = disabled ? null : folderEntry(dir);
|
|
170
236
|
const dom = disabled ? null : folderDomEntry(dir);
|
|
171
237
|
// A paused extension needs no entry file; an enabled one without any
|
|
@@ -194,6 +260,11 @@ function scanRoot(root: string, origin: UIEntry["origin"], entries: UIEntry[], s
|
|
|
194
260
|
domEntry,
|
|
195
261
|
domMtimeMs,
|
|
196
262
|
source: `webui-extensions:${dir}`,
|
|
263
|
+
name: displayName,
|
|
264
|
+
description,
|
|
265
|
+
settings,
|
|
266
|
+
requires,
|
|
267
|
+
capabilities,
|
|
197
268
|
disabled: disabled || undefined,
|
|
198
269
|
origin,
|
|
199
270
|
});
|
|
@@ -220,3 +291,125 @@ export function discoverUserUIEntries(): UIEntry[] {
|
|
|
220
291
|
cache = { at: now, entries };
|
|
221
292
|
return entries;
|
|
222
293
|
}
|
|
294
|
+
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
// Enable / pause from the webui (Settings › Extensions toggle).
|
|
297
|
+
//
|
|
298
|
+
// Gating stays owned by the folder: we only edit the winning source's
|
|
299
|
+
// manifest.json `disabled` field. Shipped extensions are never edited in place
|
|
300
|
+
// (an app update would clobber the flag) — disabling one writes a tiny
|
|
301
|
+
// user-level shadow folder with the same id, which wins by precedence and
|
|
302
|
+
// vanishes again when it is re-enabled.
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
const EXT_ID_RE = /^[A-Za-z0-9._-]+$/;
|
|
306
|
+
const SOURCE_PREFIX = "webui-extensions:";
|
|
307
|
+
|
|
308
|
+
/** The on-disk folder for a discovery entry, if it has one. */
|
|
309
|
+
function entryDir(entry: UIEntry): string | null {
|
|
310
|
+
if (entry.source?.startsWith(SOURCE_PREFIX)) return entry.source.slice(SOURCE_PREFIX.length);
|
|
311
|
+
if (entry.entry) return dirname(entry.entry);
|
|
312
|
+
if (entry.domEntry) return dirname(entry.domEntry);
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function errText(err: unknown): string {
|
|
317
|
+
return err instanceof Error ? err.message : String(err);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** True when a folder holds only a manifest.json — our pause marker, no code. */
|
|
321
|
+
function isPureShadowDir(dir: string): boolean {
|
|
322
|
+
try {
|
|
323
|
+
return readdirSync(dir).every((n) => n === "manifest.json");
|
|
324
|
+
} catch {
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Remove a user-level shadow folder we created, or clear its flag. */
|
|
330
|
+
function removeShippedShadow(shadowDir: string): void {
|
|
331
|
+
try {
|
|
332
|
+
const manifestPath = join(shadowDir, "manifest.json");
|
|
333
|
+
if (!existsSync(manifestPath)) return;
|
|
334
|
+
const others = readdirSync(shadowDir).filter((n) => n !== "manifest.json");
|
|
335
|
+
if (others.length > 0) {
|
|
336
|
+
// Not our pure marker — don't delete user files, just clear the flag.
|
|
337
|
+
const raw = JSON.parse(readFileSync(manifestPath, "utf8")) as Record<string, unknown>;
|
|
338
|
+
delete raw.disabled;
|
|
339
|
+
writeFileSync(manifestPath, `${JSON.stringify(raw, null, 2)}\n`);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
rmSync(shadowDir, { recursive: true, force: true });
|
|
343
|
+
} catch {
|
|
344
|
+
/* best-effort — a stale shadow is harmless (it just keeps it paused) */
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Pause/resume one extension by id. Writes the higher-precedence user folder
|
|
350
|
+
* for a shipped id, or edits the winning folder's own manifest otherwise.
|
|
351
|
+
* The caller should invalidate caches / push the manifest after this.
|
|
352
|
+
*/
|
|
353
|
+
export function setExtensionDisabled(
|
|
354
|
+
id: string,
|
|
355
|
+
disabled: boolean,
|
|
356
|
+
): { ok: true; reload?: boolean } | { ok: false; error: string } {
|
|
357
|
+
if (!EXT_ID_RE.test(id)) return { ok: false, error: "invalid extension id" };
|
|
358
|
+
invalidateExtensionCache();
|
|
359
|
+
const entry = discoverUserUIEntries().find((e) => e.id === id);
|
|
360
|
+
if (!entry) return { ok: false, error: `unknown extension: ${id}` };
|
|
361
|
+
const dir = entryDir(entry);
|
|
362
|
+
if (!dir) return { ok: false, error: "extension folder not found" };
|
|
363
|
+
const shadowDir = join(globalUserExtensionsDir(), id);
|
|
364
|
+
|
|
365
|
+
if (entry.origin === "shipped") {
|
|
366
|
+
try {
|
|
367
|
+
if (disabled) {
|
|
368
|
+
mkdirSync(shadowDir, { recursive: true });
|
|
369
|
+
writeFileSync(
|
|
370
|
+
join(shadowDir, "manifest.json"),
|
|
371
|
+
`${JSON.stringify({ id, name: entry.name, disabled: true }, null, 2)}\n`,
|
|
372
|
+
);
|
|
373
|
+
} else {
|
|
374
|
+
removeShippedShadow(shadowDir);
|
|
375
|
+
}
|
|
376
|
+
} catch (err) {
|
|
377
|
+
return { ok: false, error: errText(err) };
|
|
378
|
+
}
|
|
379
|
+
invalidateExtensionCache();
|
|
380
|
+
// Re-enabling needs a reload: the shipped browser bundle is owned by the
|
|
381
|
+
// in-repo Vite glob, which cannot re-register after being unregistered.
|
|
382
|
+
return { ok: true, reload: !disabled };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// A pure user-level shadow (manifest only, no code files) is our own pause
|
|
386
|
+
// marker for a shipped extension — removing it re-exposes the shipped copy,
|
|
387
|
+
// which again needs a reload to re-run the in-repo glob. A real user
|
|
388
|
+
// extension that happens to live at <global>/<id> is NOT a shadow and falls
|
|
389
|
+
// through to the normal manifest edit below.
|
|
390
|
+
if (!disabled && dir === shadowDir && isPureShadowDir(dir)) {
|
|
391
|
+
removeShippedShadow(dir);
|
|
392
|
+
invalidateExtensionCache();
|
|
393
|
+
return { ok: true, reload: true };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// user/project: edit the folder's own manifest.json, preserving its fields.
|
|
397
|
+
const manifestPath = join(dir, "manifest.json");
|
|
398
|
+
let raw: Record<string, unknown> = {};
|
|
399
|
+
try {
|
|
400
|
+
const parsed: unknown = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
401
|
+
if (parsed && typeof parsed === "object") raw = parsed as Record<string, unknown>;
|
|
402
|
+
} catch {
|
|
403
|
+
raw = {};
|
|
404
|
+
}
|
|
405
|
+
raw.id ??= id;
|
|
406
|
+
if (disabled) raw.disabled = true;
|
|
407
|
+
else delete raw.disabled;
|
|
408
|
+
try {
|
|
409
|
+
writeFileSync(manifestPath, `${JSON.stringify(raw, null, 2)}\n`);
|
|
410
|
+
} catch (err) {
|
|
411
|
+
return { ok: false, error: errText(err) };
|
|
412
|
+
}
|
|
413
|
+
invalidateExtensionCache();
|
|
414
|
+
return { ok: true };
|
|
415
|
+
}
|
package/skills/webui/SKILL.md
CHANGED
|
@@ -10,8 +10,22 @@ the `opencode` TUI, with a Bun proxy in front so the browser never holds
|
|
|
10
10
|
service credentials. One port for UI + `/api/*`: http://localhost:4097
|
|
11
11
|
(`WEBUI_PROXY_PORT`).
|
|
12
12
|
|
|
13
|
+
On first boot the webui self-installs a global `opencode-webui` command and an
|
|
14
|
+
OpenCode lifecycle plugin (in `~/.config/opencode/plugins/opencode-webui/`) that
|
|
15
|
+
starts it as soon as you use OpenCode — so an instance is usually already
|
|
16
|
+
running at that port. Check before starting another (a second run reports "already
|
|
17
|
+
running" and exits 0). Manage it with `opencode-webui update|status|stop|restart|uninstall`;
|
|
18
|
+
`WEBUI_NO_SETUP=1` skips setup for a one-off run.
|
|
19
|
+
|
|
20
|
+
Serve/security settings (host, port, auth, allowed hosts, trust proxy, autostart)
|
|
21
|
+
persist in `~/.config/opencode/webui/config.json` — environment variables
|
|
22
|
+
override the file — and are edited with `opencode-webui config get|set|unset` or
|
|
23
|
+
in **Settings › Access**; changes apply after a restart. Authentication is
|
|
24
|
+
optional (`auth: none`) for private networks/proxies, but reachable-without-a-
|
|
25
|
+
password changes need explicit confirmation.
|
|
26
|
+
|
|
13
27
|
- **Repo**: https://github.com/AbdelftahZowail/opencode-webui
|
|
14
|
-
- **This skill's version**:
|
|
28
|
+
- **This skill's version**: 3.0.1 (matches the `v3.0.1` git tag —
|
|
15
29
|
the file links below are pinned to it, so they always describe the code
|
|
16
30
|
this skill was generated with)
|
|
17
31
|
- **A running instance exposes its version** at `GET /api/webui/config` →
|
|
@@ -24,13 +38,20 @@ fetch the exact file at the pinned tag instead of reading a local clone:
|
|
|
24
38
|
|
|
25
39
|
| File | Purpose |
|
|
26
40
|
| --- | --- |
|
|
27
|
-
| https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/
|
|
28
|
-
| https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/
|
|
29
|
-
| https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/
|
|
30
|
-
| https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/
|
|
31
|
-
| https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/
|
|
32
|
-
| https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/
|
|
33
|
-
| https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/
|
|
41
|
+
| https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v3.0.1/webui-extensions/README.md | Full authoring guide — the source of truth for strata/kinds/hooks/anchors |
|
|
42
|
+
| https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v3.0.1/src/extensions/registry.tsx | The extension registry — exact register() shapes per kind |
|
|
43
|
+
| https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v3.0.1/src/extensions/context.ts | Activation context — the `activate(ctx)` entry, disposal, and the full `ctx` surface |
|
|
44
|
+
| https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v3.0.1/src/extensions/slots.tsx | Slot ids (placement contract) + the Slot renderer |
|
|
45
|
+
| https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v3.0.1/src/extensions/manifest.ts | Manifest contract — settings schema + requires parsing/checks |
|
|
46
|
+
| https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v3.0.1/src/extensions/hooks.ts | Shared fireHooks runner — how open hook events fire |
|
|
47
|
+
| https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v3.0.1/src/lib/domKit.ts | DOM-stratum kit (foreign/watch/styles) + the data-oc-* anchor table |
|
|
48
|
+
| https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v3.0.1/src/lib/storeFacade.ts | Curated store surface extensions get as `store` (raw module = `advanced.store`) |
|
|
49
|
+
| https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v3.0.1/src/lib/eventBus.ts | Event bus — raw engine events + derived lifecycle, frame-batched |
|
|
50
|
+
| https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v3.0.1/src/lib/extSettings.ts | Per-extension declared settings — schema, resolve, persist, subscribe |
|
|
51
|
+
| https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v3.0.1/src/lib/extBus.ts | Extension-to-extension peer bus (publish/subscribe) |
|
|
52
|
+
| https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v3.0.1/server/ext/types.ts | Proxy-stratum types — server.ts routes/middleware/onEvent/pollers shapes |
|
|
53
|
+
| https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v3.0.1/docs/extension-system-spec.md | The v2 decision record — strata, precedence, deletions, acceptance checks |
|
|
54
|
+
| https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v3.0.1/src/store.ts | The raw store module (reachable as `advanced.store`; prefer the facade) |
|
|
34
55
|
|
|
35
56
|
## Environment
|
|
36
57
|
|
|
@@ -39,6 +60,8 @@ fetch the exact file at the pinned tag instead of reading a local clone:
|
|
|
39
60
|
| `WEBUI_PASSWORD` | generated on first boot, printed once | Shared login passphrase. |
|
|
40
61
|
| `WEBUI_HOST` | `127.0.0.1` | Bind address — a wildcard is refused without a password. |
|
|
41
62
|
| `WEBUI_PROXY_PORT` | `4097` | Port for the UI and `/api/*`. |
|
|
63
|
+
| `WEBUI_NO_SETUP` | unset | `1` — skip first-run setup (global command + lifecycle plugin) for this run. |
|
|
64
|
+
| `WEBUI_NO_PLUGIN` | unset | `1` — install the global command but not the OpenCode lifecycle plugin. |
|
|
42
65
|
| `WEBUI_EXTENSION_DIR` | the global + project dirs | Replace both with ONE directory (the sandbox does this to keep WIP isolated). |
|
|
43
66
|
| `WEBUI_ENGINE_URL` | `service.json` discovery | Aim the proxy at a chosen engine — skips `Service.ensure()`, so a stale pid can never spawn a rogue serve. Explicit env wins. |
|
|
44
67
|
| `WEBUI_ENGINE_PASSWORD` | `service.json` password | Engine password for the override above (no file fallback when the URL is overridden — a chosen engine has its own credential). |
|
|
@@ -76,8 +99,9 @@ dir, never reuse a port.
|
|
|
76
99
|
## The model in one minute
|
|
77
100
|
|
|
78
101
|
One extension = **one folder**: `manifest.json` (id, version, description,
|
|
79
|
-
`disabled`?) + `index.tsx` (browser stratum) +
|
|
80
|
-
`server.ts` (proxy stratum) + `engine/` (opencode
|
|
102
|
+
`disabled`?, `settings`?, `requires`?) + `index.tsx` (browser stratum) +
|
|
103
|
+
`dom.ts` (DOM stratum) + `server.ts` (proxy stratum) + `engine/` (opencode
|
|
104
|
+
plugin payload).
|
|
81
105
|
Presence = installed; `disabled: true` = paused; delete the folder =
|
|
82
106
|
uninstalled. Precedence, highest wins: `~/.config/opencode/webui-extensions/`
|
|
83
107
|
(user) → `<project>/.opencode/webui-extensions/` (project) → shipped
|
|
@@ -111,11 +135,30 @@ register({
|
|
|
111
135
|
});
|
|
112
136
|
```
|
|
113
137
|
|
|
138
|
+
### Activation context (browser stratum)
|
|
139
|
+
|
|
140
|
+
Prefer `export function activate(ctx)` over module-scope `register()` — the
|
|
141
|
+
context owns lifecycle + disposal. On it: `ctx.register(entry)` (the five
|
|
142
|
+
kinds); `ctx.poll({ name, minInterval, run })` and `ctx.after(ms, fn)` (the
|
|
143
|
+
shared tier-aware scheduler, auto-stopped); `ctx.on(name, fn)` (event bus —
|
|
144
|
+
a raw engine type or a derived name: `run.started`, `run.ended`,
|
|
145
|
+
`tool.called`, `tool.completed`, `message.appended`; `"*"` = all);
|
|
146
|
+
`ctx.subscribe(selector, fn)` (derived store read); `ctx.store` (curated
|
|
147
|
+
store facade — selectors + actions); `ctx.settings` (declared settings);
|
|
148
|
+
`ctx.collections` / `ctx.bus` (peer composition); `ctx.onDispose(fn)` or
|
|
149
|
+
returning a teardown. Everything a context creates is disposed on hot-swap,
|
|
150
|
+
`disabled`, and delete. Module-scope `register()` still works but is being
|
|
151
|
+
deprecated.
|
|
152
|
+
|
|
153
|
+
Manifest `settings` declares options core renders in Settings › Extensions;
|
|
154
|
+
`requires` (`api`/`targets`/`slots`/`services`) is checked on every
|
|
155
|
+
sync and an unmet reference is a visible warning, not a silent blank spot.
|
|
156
|
+
|
|
114
157
|
## Five kinds, one job each (the contract)
|
|
115
158
|
|
|
116
159
|
| Kind | Job | Staleness |
|
|
117
160
|
| --- | --- | --- |
|
|
118
|
-
| `wrap` | Flow-through tweak of any registered target: `render(props, next)` — transform
|
|
161
|
+
| `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. |
|
|
119
162
|
| `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. |
|
|
120
163
|
| `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. |
|
|
121
164
|
| `hook` | Interception at instrumented boundaries: `{ event, handler(ctx, next) }` — `event` is an open string | New seams are new event names, never a registry change. |
|
|
@@ -124,7 +167,10 @@ register({
|
|
|
124
167
|
Contribute collections (registry-owned lists — data, not new kinds):
|
|
125
168
|
`palette`, `slash` (UI-only; engine commands win name clashes),
|
|
126
169
|
`pages` (routed at `/ext/{id}`), `settings`,
|
|
127
|
-
`contextMenu.message` / `contextMenu.session` / `contextMenu.file
|
|
170
|
+
`contextMenu.message` / `contextMenu.session` / `contextMenu.file`, and
|
|
171
|
+
`slot:<id>` (named placement points — `conversation.header.actions`,
|
|
172
|
+
`conversation.empty`, `composer.above`, `composer.actions`,
|
|
173
|
+
`sidebar.header.actions`).
|
|
128
174
|
|
|
129
175
|
### Hook catalog
|
|
130
176
|
|
|
@@ -164,6 +210,7 @@ own the fragility").
|
|
|
164
210
|
| `data-oc-queue-strip` | QueueStrip (steer/queue rows) |
|
|
165
211
|
| `data-oc-subagent-strip` | SubagentStrip |
|
|
166
212
|
| `data-oc-runs-panel` | RunsPanel |
|
|
213
|
+
| `data-oc-slot` | Slot wrapper (`slot:<id>` — one per known slot id) |
|
|
167
214
|
|
|
168
215
|
## Sandbox (iterate without touching the user's webui)
|
|
169
216
|
|