opencode-webui 2.4.0 → 3.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.
@@ -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): { id?: unknown; disabled?: unknown } | null {
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") return parsed as { id?: unknown; disabled?: unknown };
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: { id?: unknown; disabled?: unknown } | null = null;
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
+ }
@@ -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**: 2.4.0 (matches the `v2.4.0` git tag —
28
+ - **This skill's version**: 3.0.0 (matches the `v3.0.0` 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,15 @@ 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/v2.4.0/webui-extensions/README.md | Full authoring guide — the source of truth for strata/kinds/hooks/anchors |
28
- | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.4.0/src/extensions/registry.tsx | The extension registry — exact register() shapes per kind |
29
- | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.4.0/src/extensions/hooks.ts | Shared fireHooks runner how open hook events fire |
30
- | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.4.0/src/lib/domKit.ts | DOM-stratum kit (foreign/watch/styles) + the data-oc-* anchor table |
31
- | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.4.0/server/ext/types.ts | Proxy-stratum typesserver.ts routes/middleware/onEvent/pollers shapes |
32
- | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.4.0/docs/extension-system-spec.md | The v2 decision record strata, precedence, deletions, acceptance checks |
33
- | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.4.0/src/store.ts | The storeactions useStore exposes to extensions |
41
+ | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v3.0.0/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.0/src/extensions/registry.tsx | The extension registry — exact register() shapes per kind |
43
+ | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v3.0.0/src/extensions/slots.tsx | Slot ids (placement contract) + the Slot renderer |
44
+ | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v3.0.0/src/extensions/manifest.ts | Manifest contract settings schema + requires parsing/checks |
45
+ | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v3.0.0/src/extensions/hooks.ts | Shared fireHooks runner how open hook events fire |
46
+ | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v3.0.0/src/lib/domKit.ts | DOM-stratum kit (foreign/watch/styles) + the data-oc-* anchor table |
47
+ | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v3.0.0/server/ext/types.ts | Proxy-stratum typesserver.ts routes/middleware/onEvent/pollers shapes |
48
+ | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v3.0.0/docs/extension-system-spec.md | The v2 decision record — strata, precedence, deletions, acceptance checks |
49
+ | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v3.0.0/src/store.ts | The store — actions useStore exposes to extensions |
34
50
 
35
51
  ## Environment
36
52
 
@@ -39,6 +55,8 @@ fetch the exact file at the pinned tag instead of reading a local clone:
39
55
  | `WEBUI_PASSWORD` | generated on first boot, printed once | Shared login passphrase. |
40
56
  | `WEBUI_HOST` | `127.0.0.1` | Bind address — a wildcard is refused without a password. |
41
57
  | `WEBUI_PROXY_PORT` | `4097` | Port for the UI and `/api/*`. |
58
+ | `WEBUI_NO_SETUP` | unset | `1` — skip first-run setup (global command + lifecycle plugin) for this run. |
59
+ | `WEBUI_NO_PLUGIN` | unset | `1` — install the global command but not the OpenCode lifecycle plugin. |
42
60
  | `WEBUI_EXTENSION_DIR` | the global + project dirs | Replace both with ONE directory (the sandbox does this to keep WIP isolated). |
43
61
  | `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
62
  | `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 +94,9 @@ dir, never reuse a port.
76
94
  ## The model in one minute
77
95
 
78
96
  One extension = **one folder**: `manifest.json` (id, version, description,
79
- `disabled`?) + `index.tsx` (browser stratum) + `dom.ts` (DOM stratum) +
80
- `server.ts` (proxy stratum) + `engine/` (opencode plugin payload).
97
+ `disabled`?, `settings`?, `requires`?) + `index.tsx` (browser stratum) +
98
+ `dom.ts` (DOM stratum) + `server.ts` (proxy stratum) + `engine/` (opencode
99
+ plugin payload).
81
100
  Presence = installed; `disabled: true` = paused; delete the folder =
82
101
  uninstalled. Precedence, highest wins: `~/.config/opencode/webui-extensions/`
83
102
  (user) → `<project>/.opencode/webui-extensions/` (project) → shipped
@@ -115,7 +134,7 @@ register({
115
134
 
116
135
  | Kind | Job | Staleness |
117
136
  | --- | --- | --- |
118
- | `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. |
137
+ | `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
138
  | `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
139
  | `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
140
  | `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 +143,10 @@ register({
124
143
  Contribute collections (registry-owned lists — data, not new kinds):
125
144
  `palette`, `slash` (UI-only; engine commands win name clashes),
126
145
  `pages` (routed at `/ext/{id}`), `settings`,
127
- `contextMenu.message` / `contextMenu.session` / `contextMenu.file`.
146
+ `contextMenu.message` / `contextMenu.session` / `contextMenu.file`, and
147
+ `slot:<id>` (named placement points — `conversation.header.actions`,
148
+ `conversation.empty`, `composer.above`, `composer.actions`,
149
+ `sidebar.header.actions`).
128
150
 
129
151
  ### Hook catalog
130
152
 
@@ -164,6 +186,7 @@ own the fragility").
164
186
  | `data-oc-queue-strip` | QueueStrip (steer/queue rows) |
165
187
  | `data-oc-subagent-strip` | SubagentStrip |
166
188
  | `data-oc-runs-panel` | RunsPanel |
189
+ | `data-oc-slot` | Slot wrapper (`slot:<id>` — one per known slot id) |
167
190
 
168
191
  ## Sandbox (iterate without touching the user's webui)
169
192