opencode-webui 2.0.0 → 2.1.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.
package/server/index.ts CHANGED
@@ -20,7 +20,7 @@
20
20
 
21
21
  import { Service } from "@opencode-ai/client/service";
22
22
  import type { Server } from "bun";
23
- import { existsSync, mkdirSync, readFileSync, statSync, watch } from "node:fs";
23
+ import { existsSync, mkdirSync, readFileSync, statSync, watch, appendFileSync } from "node:fs";
24
24
  import { appendFile } from "node:fs/promises";
25
25
  import { basename, dirname, join, resolve } from "node:path";
26
26
  import { homedir } from "node:os";
@@ -54,6 +54,7 @@ import {
54
54
  runExtRequestMiddleware,
55
55
  startExtModules,
56
56
  } from "./ext/registry";
57
+ import { resolveEngineOverride } from "./ext/engine";
57
58
 
58
59
  // `sandbox` argv — one command, every runtime: `bun run sandbox` (repo, the
59
60
  // script adds Vite), `bunx opencode-webui sandbox`, `./opencode-webui sandbox`
@@ -116,6 +117,18 @@ async function writeDebug(lines: unknown[]) {
116
117
  let endpoint: Awaited<ReturnType<typeof Service.ensure>> | null = null;
117
118
 
118
119
  async function serviceEndpoint() {
120
+ // Explicit env wins: WEBUI_ENGINE_URL aims the proxy at a chosen engine.
121
+ // An override URL also SKIPS Service.ensure() — no spawn from a stale
122
+ // service.json pid (the rogue-serve incident), no version-kill of the
123
+ // chosen engine. Same resolution as ctx.engine (see server/ext/engine.ts).
124
+ const override = resolveEngineOverride();
125
+ if (override) {
126
+ if (!endpoint || endpoint.url !== override.url) {
127
+ endpoint = override;
128
+ console.log(`[webui] connected to opencode service at ${override.url} (WEBUI_ENGINE_URL)`);
129
+ }
130
+ return endpoint;
131
+ }
119
132
  if (!endpoint) {
120
133
  endpoint = await Service.ensure();
121
134
  console.log(`[webui] connected to opencode service at ${endpoint.url}`);
@@ -123,6 +136,40 @@ async function serviceEndpoint() {
123
136
  return endpoint;
124
137
  }
125
138
 
139
+ // ---------------------------------------------------------------------------
140
+ // Proxy crash-reason persistence (G-T8).
141
+ //
142
+ // One sandbox death left no cause. Fatal reasons are appended to CRASH_LOG
143
+ // (never thrown from there — the crash path must not crash) and the last
144
+ // entry is surfaced on the next boot, so an agent can see why the proxy died
145
+ // without having watched it die. Semantics are unchanged: uncaught exceptions
146
+ // still exit(1) (the Node default), rejections keep the runtime's behavior —
147
+ // only observability is added.
148
+ // ---------------------------------------------------------------------------
149
+
150
+ const CRASH_LOG =
151
+ process.env.WEBUI_CRASH_LOG ??
152
+ join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "opencode-webui", "proxy-crash.log");
153
+
154
+ function persistCrashReason(kind: "uncaughtException" | "unhandledRejection", reason: unknown): void {
155
+ const detail = reason instanceof Error ? (reason.stack ?? reason.message) : String(reason);
156
+ try {
157
+ mkdirSync(dirname(CRASH_LOG), { recursive: true, mode: 0o700 });
158
+ appendFileSync(CRASH_LOG, `${new Date().toISOString()} ${kind}: ${detail}\n`, "utf8");
159
+ } catch {
160
+ /* crash path — never throw */
161
+ }
162
+ console.error(`[webui] ${kind} (recorded in ${CRASH_LOG}):`, detail.split("\n")[0]);
163
+ }
164
+
165
+ process.on("uncaughtException", (err) => {
166
+ persistCrashReason("uncaughtException", err);
167
+ process.exit(1);
168
+ });
169
+ process.on("unhandledRejection", (reason) => {
170
+ persistCrashReason("unhandledRejection", reason);
171
+ });
172
+
126
173
  // ---------------------------------------------------------------------------
127
174
  // Live-event recorder (catch-up for late-joining browsers).
128
175
  //
@@ -385,6 +432,12 @@ async function bundleUIEntry(entry: string): Promise<string> {
385
432
  const artifact =
386
433
  built.outputs.find((o) => o.kind === "entry-point" && o.path.endsWith(".js")) ??
387
434
  built.outputs.find((o) => o.path.endsWith(".js"));
435
+ for (const log of built.logs) {
436
+ // Build warnings/errors are the ONLY server-side signal for a broken
437
+ // extension — a failing bundle must never be silent (the page just sees
438
+ // a missing entry). Bun.build failures throw below; warnings print here.
439
+ console.warn(`[webui] extension bundle build (${entry}): ${log.message}`);
440
+ }
388
441
  if (!artifact) throw new Error(`bun.build produced no js artifact for ${entry}`);
389
442
  const js = await artifact.text();
390
443
  bundleCache.set(entry, { mtimeMs, js });
@@ -1009,3 +1062,19 @@ console.log(
1009
1062
  void startEventRecorder();
1010
1063
  startExtensionWatcher();
1011
1064
  void startExtModules();
1065
+
1066
+ // Crash-log boot note: if a previous proxy died fatally, its reason is the
1067
+ // last line of CRASH_LOG — surface it so the next boot (or an agent reading
1068
+ // the log) sees why without having watched it die.
1069
+ try {
1070
+ if (existsSync(CRASH_LOG)) {
1071
+ const lines = readFileSync(CRASH_LOG, "utf8").trim().split("\n").filter((l) => l.length > 0);
1072
+ // Entries are multi-line (stacks) — the "last" entry is the last line
1073
+ // starting a new timestamped record, not the log's physical last line.
1074
+ const heads = lines.filter((l) => /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(l));
1075
+ const last = heads[heads.length - 1] ?? lines[lines.length - 1];
1076
+ if (last) console.log(`[webui] previous proxy crash (${heads.length} entr(ies) in ${CRASH_LOG}) — last: ${last.slice(0, 300)}`);
1077
+ }
1078
+ } catch {
1079
+ /* observability only — never block boot */
1080
+ }
@@ -1,6 +1,6 @@
1
1
  ---
2
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).
3
+ description: opencode-webui — the browser frontend for the OpenCode engine. Load when the user mentions the webui/web frontend, asks about webui extensions, wants to BUILD, ADD, CHANGE, DEBUG, or TEST a webui extension, or wants to report a webui bug (/report does it).
4
4
  ---
5
5
 
6
6
  # OpenCode webui (opencode-webui)
@@ -11,7 +11,7 @@ service credentials. One port for UI + `/api/*`: http://localhost:4097
11
11
  (`WEBUI_PROXY_PORT`).
12
12
 
13
13
  - **Repo**: https://github.com/AbdelftahZowail/opencode-webui
14
- - **This skill's version**: 2.0.0 (matches the `v2.0.0` git tag —
14
+ - **This skill's version**: 2.1.0 (matches the `v2.1.0` git tag —
15
15
  the file links below are pinned to it, so they always describe the code
16
16
  this skill was generated with)
17
17
  - **A running instance exposes its version** at `GET /api/webui/config` →
@@ -24,13 +24,13 @@ fetch the exact file at the pinned tag instead of reading a local clone:
24
24
 
25
25
  | File | Purpose |
26
26
  | --- | --- |
27
- | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.0.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.0.0/src/extensions/registry.tsx | The extension registry — exact register() shapes per kind |
29
- | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.0.0/src/extensions/hooks.ts | Shared fireHooks runner — how open hook events fire |
30
- | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.0.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.0.0/server/ext/types.ts | Proxy-stratum types — server.ts routes/middleware/onEvent/pollers shapes |
32
- | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.0.0/docs/extension-system-spec.md | The v2 decision record — strata, precedence, deletions, acceptance checks |
33
- | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.0.0/src/store.ts | The store — actions useStore exposes to extensions |
27
+ | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.1.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.1.0/src/extensions/registry.tsx | The extension registry — exact register() shapes per kind |
29
+ | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.1.0/src/extensions/hooks.ts | Shared fireHooks runner — how open hook events fire |
30
+ | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.1.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.1.0/server/ext/types.ts | Proxy-stratum types — server.ts routes/middleware/onEvent/pollers shapes |
32
+ | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.1.0/docs/extension-system-spec.md | The v2 decision record — strata, precedence, deletions, acceptance checks |
33
+ | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.1.0/src/store.ts | The store — actions useStore exposes to extensions |
34
34
 
35
35
  ## Environment
36
36
 
@@ -40,15 +40,19 @@ fetch the exact file at the pinned tag instead of reading a local clone:
40
40
  | `WEBUI_HOST` | `127.0.0.1` | Bind address — a wildcard is refused without a password. |
41
41
  | `WEBUI_PROXY_PORT` | `4097` | Port for the UI and `/api/*`. |
42
42
  | `WEBUI_EXTENSION_DIR` | the global + project dirs | Replace both with ONE directory (the sandbox does this to keep WIP isolated). |
43
+ | `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
+ | `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). |
45
+ | `WEBUI_SANDBOX_NOVITE` | unset | `1` — sandbox boots the proxy only, no Vite (`--no-vite` flag does the same). |
46
+ | `WEBUI_CRASH_LOG` | `$XDG_STATE_HOME/opencode-webui/proxy-crash.log` | File fatal proxy errors (`uncaughtException`/`unhandledRejection`) are appended to; the last entry prints on next boot. |
43
47
  | `WEBUI_DEBUG` | unset | `1` — server/proxy debug logs to stdout. |
44
48
  | `WEBUI_DEBUG_LOG` | `/tmp/webui-debug.log` | File the frontend log sink (`POST /api/debug`) appends to. |
45
49
 
46
50
  ### Parallel sandboxes (one per extension under test)
47
51
 
48
- One command, no flags: `bun run sandbox` detects a running sandbox and
49
- auto-isolates. Alone it uses the fixed defaults (`:4099`/`:5175` + shared
50
- scratch dir); when another sandbox already holds those ports, the new
51
- instance takes free ports + a fresh mkdtemp extension dir and prints what
52
+ Yes run as many sandboxes at once as you need. `bun run sandbox` stacks
53
+ with no flags: the first instance takes the fixed defaults (`:4099`/`:5175`
54
+ + shared scratch dir); every further instance detects the busy ports and
55
+ auto-isolates onto free ports + a fresh mkdtemp extension dir, printing what
52
56
  it picked. Explicit env (`WEBUI_PROXY_PORT` / `WEBUI_VITE_PORT` /
53
57
  `WEBUI_EXTENSION_DIR`) always wins per knob. The engine stays shared
54
58
  (same sessions everywhere, by design) — only ports + extension dirs are
@@ -22,6 +22,11 @@ my-extension/
22
22
  **Gating — one state, owned by the folder itself:** presence = installed;
23
23
  `disabled: true` = paused; delete/move the folder to uninstall. No
24
24
  `config.ts` list, no per-browser localStorage gating, no second registry.
25
+ User-facing pause (a settings toggle that stops *behavior*, e.g. an
26
+ extension that idles when its key/feature flag is off) is not manifest
27
+ pausing: the former keeps the entry loaded but quiet, the latter
28
+ (`disabled: true`) is never bundled or imported and its id unregisters —
29
+ use a settings toggle for "off for now", the manifest for "unplug".
25
30
 
26
31
  **Precedence (same id = same swap point, higher wins):**
27
32
 
@@ -75,13 +80,56 @@ addressable, no marker placement, no guessing), at leaf granularity (the
75
80
  timestamp, token readout, cost badge, copy button — not just `MessageItem`),
76
81
  with rich props, so wraps and value-overrides stay surgical.
77
82
 
83
+ ### Target inventory (the catalog — grep `autoRegister` if this lags)
84
+
85
+ | Target id | Props (meaningful subset) |
86
+ |---|---|
87
+ | `sidebar` | — (the shell) |
88
+ | `sidebar.sessionRow` | `sessionID`, `title`, `updated`, `active`, `selected`, `subagentsActive`, `onSelect` |
89
+ | `conversation` | full `ConversationProps` |
90
+ | `conversation.header` | full `HeaderProps` |
91
+ | `conversation.empty` | — |
92
+ | `composer` | full `ComposerProps` |
93
+ | `composer.contextReadout` | `parts: string[]` |
94
+ | `composer.sendActions` | `sessionID`, `appendDraft(text)` — space-joins onto the draft + refocuses; prefer over writing drafts directly |
95
+ | `message.timestamp` | `time: number` (consults the `format.timestamp` service) |
96
+ | `message.tokens` | `tokens` |
97
+ | `message.cost` | `cost: number` |
98
+ | `message.copyButton` | `variant: "user" \| "assistant"`, `text` |
99
+ | `message:<type>` / `message:*` | replace-with-fall-through per message type |
100
+ | `tool.card` | `part: ToolPart`, `stateKey?` |
101
+ | `tool.edit` / `write` / `shell` / `subagent` / `execute` / `generic` | per-tool view props |
102
+ | `tool:<name>` | replace-with-fall-through per tool name |
103
+
104
+ Persisted-vs-live guarantee (streaming authors depend on this): persisted
105
+ messages always carry a `[data-oc-message]` ancestor; live projections
106
+ (`LiveAssistantView`) render `MessagePart` directly with none. Code that
107
+ must never touch streaming output can rely on the distinction structurally.
108
+
109
+ ### Entry hygiene (two rules that bite silently)
110
+
111
+ - **One entry per id.** Same-id `register()` SWAPS (with a console warning
112
+ when kind/target differ) — a folder's entries need distinct ids or the
113
+ later evicts the earlier, and loaders track folders by single id so extras
114
+ leak on disable/delete. One folder → one id per entry, always.
115
+ - **Runtime code uses the bridge only.** External (user/project-dir) bundles
116
+ are built standalone: `import type` from `src/` is erased at build and
117
+ 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
120
+ surface via `getExtensionApi()`.
121
+ - **The `@/` alias works in shipped extensions only.** Same repo, same
122
+ tsconfig (`@/*` → `./src/*`, e.g. groq-voice imports
123
+ `@/components/ui/dialog`) — external copies must still use the bridge,
124
+ never `@/` or relative `src/` paths.
125
+
78
126
  ```tsx
79
127
  // index.tsx — wrap the timestamp, own nothing else
80
128
  import { register } from "../../src/extensions/registry";
81
129
 
82
130
  register({
83
131
  kind: "wrap",
84
- id: "my-timestamps",
132
+ id: "my-timestamps-wrap",
85
133
  target: "Timestamp",
86
134
  render: (props, next) => (
87
135
  <span title={String(props.iso ?? "")}>{next()}</span>
@@ -90,7 +138,7 @@ register({
90
138
 
91
139
  register({
92
140
  kind: "service",
93
- id: "my-timestamps",
141
+ id: "my-timestamps-format",
94
142
  service: "format.timestamp",
95
143
  value: (iso: string) => new Date(iso).toLocaleTimeString(),
96
144
  precedence: 10,
@@ -266,8 +314,62 @@ An extension folder may carry `engine/` — a valid opencode plugin directory
266
314
  (tools the model calls, `experimental.chat.system.transform` prompt hints).
267
315
  The webui neither loads nor hot-reloads it; the engine's rules apply
268
316
  (boot-time load, restart on edit unless the plugin implements its own shell
269
- pattern). Convention + worked example (brother-agent in webui terms — one
270
- folder, three strata): `docs/engine-payload-convention.md`.
317
+ pattern a stable `index.js` that require-cache-busts a `definitions.cjs`
318
+ on mtime works and is the recommended shape). Convention + worked example
319
+ (brother-agent in webui terms — one folder, three strata):
320
+ `docs/engine-payload-convention.md`. Hard-won facts, stated once so no one
321
+ re-discovers them by trial:
322
+
323
+ - **Export shape:** `module.exports = { id, setup }` (v2 — the v1 `{server}`
324
+ / named-export shape is rejected: "must export a default definition with
325
+ an id and an effect or setup function").
326
+ - **Tool namespace:** the model lists tools as `tools.<name>` — register and
327
+ match on the `tools.`-prefixed name, never bare.
328
+ - **Tool results must resolve `{ output: string }`.** A bare string fails
329
+ result validation (`Unknown tool` in the transcript).
330
+ - **System-hint parts need `{ type: "text", text }`.** Pushing `{text}`
331
+ without `type` fails the whole session drain (schema `MissingKey`).
332
+ - **Session origin tagging** (`metadata: { origin: "…" }`) survives only via
333
+ REST `POST /api/session` create — the setup-bridge create drops it.
334
+ - **Discovery + auth:** the engine registers at
335
+ `$XDG_STATE_HOME/opencode/service.json` (Basic `opencode:password` —
336
+ mirror `@opencode-ai/client`'s service helper); provider credentials live
337
+ under `XDG_DATA_HOME`, so a `STATE`-only sandbox sees the engine but no
338
+ models. When agent testing misbehaves, verify the provider first with
339
+ `POST /session/{id}/generate {"prompt":"OK"}`; when runs fail blank,
340
+ the cause is in `$XDG_DATA_HOME/opencode/log/opencode.log` (`grep drain`).
341
+ - `server.ts` code that must call the engine has no credential helper yet —
342
+ parse `service.json` by hand (node builtins only, no core imports); a
343
+ `ctx.engine` helper is the planned fix (`server/ext/types.ts`).
344
+
345
+ ## Loading lifecycle (where an extension travels)
346
+
347
+ One folder becomes pixels through four files — follow them in order:
348
+
349
+ 1. **Glob (shipped, repo dev).** `webui-extensions/index.ts` globs
350
+ `./*/index.{ts,tsx}` and tracks each module's `export const id` (Vite
351
+ HMR path — edits hot-swap via same-id registry swap, deletions prune
352
+ owned ids only).
353
+ 2. **Discovery (proxy).** `server/userExtensions.ts`
354
+ (`discoverUserUIEntries`) scans the three sources highest-precedence
355
+ first — user root, project root, shipped dir — taking the folder id
356
+ from `manifest.json` (`id`, falling back to the dir name) and the
357
+ entries from `index.tsx`/`dom.ts`. Same id at a lower source is
358
+ skipped with a once-per-process `shadowed` warning.
359
+ 3. **Manifest + SSE + bundling (proxy).** `server/index.ts` merges folder
360
+ entries with engine-plugin UI halves, serves
361
+ `GET /api/webui/extensions` (`{ id, url?v=mtime, domUrl?v=mtime,
362
+ source, origin }`), pushes a `{ type: "webui.extensions", version }`
363
+ event per manifest change on `GET /api/webui/extensions/events`, and
364
+ bundles each entry standalone with `Bun.build` (`bundleUIEntry` —
365
+ react external, build logs printed loudly, never silent).
366
+ 4. **Import + register (page).** `src/lib/runtimeExtensions.ts` fetches
367
+ the manifest, dynamic-imports each new `?v=` bundle (re-import on
368
+ mtime move → registry same-id-swap → live repaint), mounts `domUrl`
369
+ via the DOM kit, and unregisters ids that vanish or flip
370
+ `disabled: true`. Shipped browser bundles are skipped here (the glob
371
+ owns them — importing twice would run side effects twice) but shipped
372
+ `domUrl` still mounts and `disabled` still pauses them.
271
373
 
272
374
  ## What extensions can use (browser stratum)
273
375
 
@@ -337,18 +439,22 @@ register({
337
439
  isolated second instance — loopback-only `127.0.0.1:4099`, passwordless (the
338
440
  bind address is the guarantee), same engine/sessions, extensions from an
339
441
  isolated scratch dir (`WEBUI_EXTENSION_DIR`,
340
- default `~/.local/state/opencode-webui/sandbox-extensions/`). Iterate there;
341
- "shipping" = copying the folder into the real extension dir.
442
+ default `~/.local/state/opencode-webui/sandbox-extensions/`).
443
+ `WEBUI_EXTENSION_DIR` is a higher-precedence ADD, not a replace: it swaps
444
+ out the user + project roots only — shipped extensions still load
445
+ underneath, and a same-id scratch folder shadows the shipped copy (the
446
+ `shadowed` log line is the only signal). Iterate there; "shipping" =
447
+ copying the folder into the real extension dir.
342
448
 
343
449
  ### Parallel sandboxes (agents: read this)
344
450
 
345
- One command, no flags: `bun run sandbox` detects a running sandbox and
346
- auto-isolates. Alone it uses the fixed defaults (`:4099`/`:5175` + shared
347
- scratch dir); when another sandbox already holds those ports, the new
348
- instance takes free ports + a fresh mkdtemp extension dir and prints what
349
- it picked. Explicit env (`WEBUI_PROXY_PORT` / `WEBUI_VITE_PORT` /
350
- `WEBUI_EXTENSION_DIR`) always wins per knob and disables that knob's auto
351
- behavior.
451
+ Yes run as many sandboxes at once as you need, one per extension under
452
+ test. `bun run sandbox` stacks with no flags: the first instance takes the
453
+ fixed defaults (`:4099`/`:5175` + shared scratch dir); every further
454
+ instance detects the busy ports and auto-isolates onto free ports + a fresh
455
+ mkdtemp extension dir, printing exactly what it picked. Explicit env
456
+ (`WEBUI_PROXY_PORT` / `WEBUI_VITE_PORT` / `WEBUI_EXTENSION_DIR`) always wins
457
+ per knob and disables that knob's auto behavior.
352
458
 
353
459
  Rules: one sandbox per extension, never two writers to one ext dir, never
354
460
  reuse a port. The engine stays shared (same sessions everywhere, by