opencode-webui 2.0.0 → 2.2.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.
@@ -1,2 +1,2 @@
1
- import{n as e,t}from"./index-BHminhzR.js";var n=`report`,r=t(),{notify:i}=r,a=`AbdelftahZowail/opencode-webui`,o=10,s=window;function c(e){let t=s.__reportErrors??=[];t.push(e),t.length>o&&t.splice(0,t.length-o)}s.__reportErrorsInstalled||(s.__reportErrorsInstalled=!0,window.onerror=(e,t,n,r,i)=>{c({message:String(e).slice(0,500),source:t??``,line:n??0,stack:i instanceof Error?i.stack?.slice(0,500):void 0})},window.addEventListener(`unhandledrejection`,e=>{let t=e.reason;c({message:(t instanceof Error?t.message:String(t)).slice(0,500),source:`unhandledrejection`,line:0,stack:t instanceof Error?t.stack?.slice(0,500):void 0})}));async function l(e){try{let t=await fetch(e);return t.ok?await t.json():null}catch{return null}}async function u(t){let[n,r]=await Promise.all([l(`/api/webui/config`),l(`/api/webui/extensions`)]);return{url:location.href,userAgent:navigator.userAgent,language:navigator.language,viewport:`${window.innerWidth}x${window.innerHeight}`,version:n?.version??`unknown`,reportRepo:n?.reportRepo??a,builtins:e(),runtimeExtensions:(r?.data??[]).map(e=>e.id??``).filter(Boolean),errors:s.__reportErrors??[],sessionID:t,timestamp:new Date().toISOString()}}function d(e,t){let n=`WebUI bug: ${e?e.slice(0,80):`user report`}`,r=[`### What happened`,e||``,``,`### Steps to reproduce`,`1. `,``,`### Expected`,``,`### Actual`,``,`<details><summary>Diagnostics</summary>`,``,"```json",JSON.stringify(t,null,2),"```",`</details>`].join(`
1
+ import{n as e,t}from"./index-DDE-utaG.js";var n=`report`,r=t(),{notify:i}=r,a=`AbdelftahZowail/opencode-webui`,o=10,s=window;function c(e){let t=s.__reportErrors??=[];t.push(e),t.length>o&&t.splice(0,t.length-o)}s.__reportErrorsInstalled||(s.__reportErrorsInstalled=!0,window.onerror=(e,t,n,r,i)=>{c({message:String(e).slice(0,500),source:t??``,line:n??0,stack:i instanceof Error?i.stack?.slice(0,500):void 0})},window.addEventListener(`unhandledrejection`,e=>{let t=e.reason;c({message:(t instanceof Error?t.message:String(t)).slice(0,500),source:`unhandledrejection`,line:0,stack:t instanceof Error?t.stack?.slice(0,500):void 0})}));async function l(e){try{let t=await fetch(e);return t.ok?await t.json():null}catch{return null}}async function u(t){let[n,r]=await Promise.all([l(`/api/webui/config`),l(`/api/webui/extensions`)]);return{url:location.href,userAgent:navigator.userAgent,language:navigator.language,viewport:`${window.innerWidth}x${window.innerHeight}`,version:n?.version??`unknown`,reportRepo:n?.reportRepo??a,builtins:e(),runtimeExtensions:(r?.data??[]).map(e=>e.id??``).filter(Boolean),errors:s.__reportErrors??[],sessionID:t,timestamp:new Date().toISOString()}}function d(e,t){let n=`WebUI bug: ${e?e.slice(0,80):`user report`}`,r=[`### What happened`,e||``,``,`### Steps to reproduce`,`1. `,``,`### Expected`,``,`### Actual`,``,`<details><summary>Diagnostics</summary>`,``,"```json",JSON.stringify(t,null,2),"```",`</details>`].join(`
2
2
  `);return{title:n,body:r,url:`https://github.com/${t.reportRepo}/issues/new?title=${encodeURIComponent(n)}&body=${encodeURIComponent(r)}`}}async function f(e){try{return await navigator.clipboard.writeText(e),!0}catch{return!1}}async function p(e,t){let n=e.trim(),a=n.startsWith(`--agent`),o=a?n.replace(/^--agent\b/,``).trim():n,s=await u(t.sessionID??r.store.getState().currentSessionID??null),{title:c,body:l,url:p}=d(o,s);if(a){let e=`File a bug against ${s.reportRepo} using the gh CLI: create an issue with the title and body below, then reply with the issue URL. Do not include any private session content beyond the diagnostics JSON.\n\nTitle: ${c}\n\n${l}`,n=t.sessionID??r.store.getState().currentSessionID??null;if(n&&r.store.isDraftSession(n))try{n=await r.store.materializeDraft(``)}catch{n=null}if(!n){let t=await f(e);i({title:t?`No active session — report prompt copied`:`No active session`,description:t?`Paste it into a session to have the agent file the issue.`:`Could not copy the report prompt either.`,variant:t?`default`:`destructive`});return}i({title:`Report handed to the agent`,description:`It will file the issue and reply with the URL.`}),await r.store.sendPromptTo(n,e);return}if(window.open(p,`_blank`))i({title:`Opening GitHub issue…`});else{let e=await f(p);i({title:e?`Report ready — link copied`:`Report link`,description:e?`Popup blocked; paste the link in a browser.`:`Popup blocked and the clipboard is unavailable.`,variant:e?`default`:`destructive`})}}r.register({kind:`contribute`,id:`report`,collection:`slash`,item:{name:`report`,aliases:[`bug`],description:`Report a webui bug on GitHub — diag bundle prefilled`,run:p}});export{n as id};
package/dist/index.html CHANGED
@@ -19,7 +19,7 @@
19
19
  }
20
20
  }
21
21
  </script>
22
- <script type="module" crossorigin src="/assets/index-BHminhzR.js"></script>
22
+ <script type="module" crossorigin src="/assets/index-DDE-utaG.js"></script>
23
23
  <link rel="stylesheet" crossorigin href="/assets/index-d4KcyqrZ.css">
24
24
  </head>
25
25
  <body>
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "description": "Web UI for the OpenCode coding agent — chat, streaming, tool cards, and extensions over the OpenCode engine's HTTP API.",
4
4
  "private": false,
5
5
  "type": "module",
6
- "version": "2.0.0",
6
+ "version": "2.2.0",
7
7
  "license": "MIT",
8
8
  "repository": {
9
9
  "type": "git",
@@ -37,6 +37,7 @@
37
37
  "start": "NODE_ENV=production bun run server/index.ts",
38
38
  "typecheck": "tsc --noEmit",
39
39
  "gen:skill": "bun run scripts/gen-skill.ts",
40
+ "check:ext": "bun run scripts/check-ext.ts",
40
41
  "check:swap": "bun run scripts/uitest/swap-ready.ts",
41
42
  "prepublishOnly": "bun run typecheck && bun run gen:skill && bun run build"
42
43
  },
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Engine credential helper (proxy stratum).
3
+ *
4
+ * `server.ts` authors hand-parsed `service.json` to call the engine (third
5
+ * occurrence → kit by the IN-4 rule). Use `ctx.engine` instead — no core
6
+ * import needed on the extension side; the context shape is structural:
7
+ *
8
+ * ```ts
9
+ * // my-extension/server.ts — no imports needed
10
+ * export default {
11
+ * pollers: [
12
+ * {
13
+ * id: "watch",
14
+ * intervalMs: 30_000,
15
+ * run: async (ctx) => {
16
+ * const res = await ctx.engine.fetch("/api/session/active");
17
+ * if (!res.ok) return; // engine momentarily unreachable — retry next tick
18
+ * const { data } = (await res.json()) as { data?: unknown };
19
+ * await ctx.kv.set("lastSeen", Date.now());
20
+ * },
21
+ * },
22
+ * ],
23
+ * };
24
+ * ```
25
+ *
26
+ * Discovery mirrors `Service.ensure()`'s registration file
27
+ * (`$XDG_STATE_HOME/opencode/service.json`, default
28
+ * `~/.local/state/opencode/service.json`, `{ url, password }`), with
29
+ * explicit env winning per knob:
30
+ *
31
+ * - URL: `WEBUI_ENGINE_URL` wins, else the file's `url`. Trailing slashes
32
+ * are stripped. `null` when neither exists (engine never ran here).
33
+ * - Password: `WEBUI_ENGINE_PASSWORD` wins. With an explicit URL override
34
+ * the file password is deliberately NOT reused — a chosen engine has its
35
+ * own credential, and falling back to the default state file's password
36
+ * would send the wrong secret (the stale-pid rogue-serve incident).
37
+ * Without an override the file password applies. Empty = no auth.
38
+ *
39
+ * Auth is HTTP Basic (`opencode:<password>`), the same header
40
+ * `Service.headers()` builds. The proxy's own discovery path
41
+ * (`serviceEndpoint()` in `server/index.ts`) honors the same overrides via
42
+ * `resolveEngineOverride()` — and an override URL skips `Service.ensure()`
43
+ * entirely, so a stale `service.json` pid can never spawn a rogue serve.
44
+ *
45
+ * Node builtins only (`node:fs`/`node:os`/`node:path` + global
46
+ * `fetch`/`Buffer`) — this module creates no import cycle with
47
+ * `server/index.ts`, which is why the credential logic lives here and not
48
+ * behind `Service`.
49
+ */
50
+
51
+ import { readFileSync } from "node:fs";
52
+ import { homedir } from "node:os";
53
+ import { join } from "node:path";
54
+ import type { ExtEngine } from "./types";
55
+
56
+ export const ENGINE_USERNAME = "opencode";
57
+ export const ENGINE_REQUEST_TIMEOUT_MS = 15_000;
58
+
59
+ function nonEmpty(value: string | undefined): string | undefined {
60
+ return value !== undefined && value.length > 0 ? value : undefined;
61
+ }
62
+
63
+ function serviceFilePath(): string {
64
+ const state = process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state");
65
+ return join(state, "opencode", "service.json");
66
+ }
67
+
68
+ /** Raw registration-file read. Never throws — absent/corrupt = null. */
69
+ function readServiceFile(): { url?: unknown; password?: unknown } | null {
70
+ try {
71
+ const parsed: unknown = JSON.parse(readFileSync(serviceFilePath(), "utf8"));
72
+ if (parsed && typeof parsed === "object") return parsed as { url?: unknown; password?: unknown };
73
+ return null;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Effective engine base URL: `WEBUI_ENGINE_URL` wins, else the registration
81
+ * file's `url`. `null` when neither exists.
82
+ */
83
+ export function resolveEngineBaseUrl(): string | null {
84
+ const override = nonEmpty(process.env.WEBUI_ENGINE_URL);
85
+ if (override !== undefined) return override.replace(/\/+$/, "");
86
+ const info = readServiceFile();
87
+ if (info && typeof info.url === "string" && info.url.length > 0) {
88
+ return info.url.replace(/\/+$/, "");
89
+ }
90
+ return null;
91
+ }
92
+
93
+ /**
94
+ * Effective engine password: `WEBUI_ENGINE_PASSWORD` wins; with an explicit
95
+ * URL override there is no file fallback (see module doc); otherwise the
96
+ * registration file's password. `undefined` = no auth.
97
+ */
98
+ export function resolveEnginePassword(): string | undefined {
99
+ const override = nonEmpty(process.env.WEBUI_ENGINE_PASSWORD);
100
+ if (override !== undefined) return override;
101
+ if (nonEmpty(process.env.WEBUI_ENGINE_URL) !== undefined) return undefined;
102
+ const info = readServiceFile();
103
+ return typeof info?.password === "string" && info.password !== "" ? info.password : undefined;
104
+ }
105
+
106
+ /** Auth headers for the engine — the same Basic header `Service.headers()` builds. */
107
+ export function engineAuthHeaders(): Record<string, string> {
108
+ const password = resolveEnginePassword();
109
+ if (password === undefined) return {};
110
+ return { authorization: "Basic " + Buffer.from(`${ENGINE_USERNAME}:${password}`).toString("base64") };
111
+ }
112
+
113
+ /**
114
+ * Override endpoint for the proxy's own discovery path. Non-null iff
115
+ * `WEBUI_ENGINE_URL` is set — explicit env wins, and the caller must skip
116
+ * `Service.ensure()` (no spawn, no version-kill of the chosen engine).
117
+ * Shape matches the `Endpoint` type `Service.ensure()` resolves to.
118
+ */
119
+ export function resolveEngineOverride(): {
120
+ url: string;
121
+ auth?: { type: "basic"; username: string; password: string };
122
+ } | null {
123
+ const raw = nonEmpty(process.env.WEBUI_ENGINE_URL);
124
+ if (raw === undefined) return null;
125
+ const url = raw.replace(/\/+$/, "");
126
+ const password = nonEmpty(process.env.WEBUI_ENGINE_PASSWORD);
127
+ if (password === undefined) return { url };
128
+ return { url, auth: { type: "basic", username: ENGINE_USERNAME, password } };
129
+ }
130
+
131
+ /**
132
+ * Fetch against the engine (`/api/...` paths; a leading slash is optional).
133
+ * Caller headers are kept; engine auth always wins over a caller-supplied
134
+ * `authorization` (same rule as the proxy passthrough). Aborts after
135
+ * `ENGINE_REQUEST_TIMEOUT_MS` unless the caller passes its own signal.
136
+ * Throws with a remedy when the engine is undiscoverable; HTTP errors are
137
+ * returned (not thrown) so pollers can retry next tick.
138
+ */
139
+ export async function engineFetch(path: string, init: RequestInit = {}): Promise<Response> {
140
+ const base = resolveEngineBaseUrl();
141
+ if (!base) {
142
+ throw new Error(
143
+ "[webui] engine undiscoverable: set WEBUI_ENGINE_URL or start the opencode service (no service.json found)",
144
+ );
145
+ }
146
+ const { headers: initHeaders, signal, ...rest } = init;
147
+ const headers = new Headers(initHeaders);
148
+ for (const [k, v] of Object.entries(engineAuthHeaders())) headers.set(k, v);
149
+ return fetch(`${base}${path.startsWith("/") ? path : `/${path}`}`, {
150
+ ...rest,
151
+ headers,
152
+ signal: signal ?? AbortSignal.timeout(ENGINE_REQUEST_TIMEOUT_MS),
153
+ });
154
+ }
155
+
156
+ /** The singleton wired into every `ExtServerContext` as `ctx.engine`. */
157
+ export const engine: ExtEngine = {
158
+ baseUrl: resolveEngineBaseUrl,
159
+ headers: engineAuthHeaders,
160
+ fetch: engineFetch,
161
+ };
package/server/ext/kv.ts CHANGED
@@ -1,9 +1,8 @@
1
1
  /**
2
2
  * Proxy-stratum KV store (spec §8).
3
3
  *
4
- * One small persistent store service for server extensions — the
5
- * `~/.dsh/brother-watches.json` equivalent so each extension doesn't roll
6
- * its own file I/O. JSON-file backed (per spec: "bun:sqlite or JSON-file"),
4
+ * One small persistent store service for server extensions — so each
5
+ * extension doesn't roll its own file I/O. JSON-file backed (per spec: "bun:sqlite or JSON-file"),
7
6
  * namespaced per extension id: `kvFor("brother-watcher")` only ever sees
8
7
  * its own top-level key.
9
8
  *
@@ -26,15 +26,15 @@
26
26
  *
27
27
  * GAPS (follow-ups, not in this scaffolding):
28
28
  * - `server/` directory form (only bare `server.ts` is discovered).
29
- * - Engine-credential passthrough for pollers that must call the engine
30
- * (needs a Service.headers helper that doesn't create an index.ts cycle).
31
29
  * - SSE manifest push for server-module versions (browser bundle only).
30
+ * (Engine credentials shipped as `ctx.engine` — see `engine.ts`.)
32
31
  */
33
32
 
34
33
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
35
34
  import { join } from "node:path";
36
35
  import { fileURLToPath } from "node:url";
37
36
  import { globalUserExtensionsDir, projectUserExtensionsDir, warnOnce } from "../userExtensions";
37
+ import { engine } from "./engine";
38
38
  import { kvFor } from "./kv";
39
39
  import type { ExtEngineEvent, ExtServerContext, ServerExtensionModule } from "./types";
40
40
 
@@ -82,7 +82,7 @@ function isServerExtensionModule(obj: unknown): obj is ServerExtensionModule {
82
82
  }
83
83
 
84
84
  function ctxFor(id: string): ExtServerContext {
85
- return { extID: id, kv: kvFor(id) };
85
+ return { extID: id, kv: kvFor(id), engine };
86
86
  }
87
87
 
88
88
  async function loadEntry(id: string, dir: string, entry: string): Promise<void> {
@@ -42,6 +42,30 @@ export interface ExtServerContext {
42
42
  extID: string;
43
43
  /** Namespaced persistent KV (JSON-file backed, see kv.ts). */
44
44
  kv: ExtKV;
45
+ /**
46
+ * Engine fetch helper (see `engine.ts`). Replaces hand-parsing
47
+ * `service.json`: `await ctx.engine.fetch("/api/session/active")`.
48
+ * Honors `WEBUI_ENGINE_URL` / `WEBUI_ENGINE_PASSWORD` (explicit env
49
+ * wins); auth headers always win over caller-supplied ones.
50
+ */
51
+ engine: ExtEngine;
52
+ }
53
+
54
+ /**
55
+ * Engine credential helper (implemented in `engine.ts`, node builtins only
56
+ * — no core import needed on the extension side; the context is structural).
57
+ */
58
+ export interface ExtEngine {
59
+ /** Effective engine base URL (`WEBUI_ENGINE_URL` wins, else `service.json`). `null` when undiscoverable. */
60
+ baseUrl(): string | null;
61
+ /** Auth headers for the engine (Basic `opencode:<password>`), or `{}` when unauthenticated. */
62
+ headers(): Record<string, string>;
63
+ /**
64
+ * Fetch against the engine (`/api/...` paths; leading slash optional).
65
+ * Throws with a remedy when undiscoverable; HTTP errors are returned, not
66
+ * thrown. Aborts after 15s unless the caller passes its own signal.
67
+ */
68
+ fetch(path: string, init?: RequestInit): Promise<Response>;
45
69
  }
46
70
 
47
71
  /** Extra context for route handlers. */
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.2.0 (matches the `v2.2.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.2.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.2.0/src/extensions/registry.tsx | The extension registry — exact register() shapes per kind |
29
+ | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.2.0/src/extensions/hooks.ts | Shared fireHooks runner — how open hook events fire |
30
+ | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.2.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.2.0/server/ext/types.ts | Proxy-stratum types — server.ts routes/middleware/onEvent/pollers shapes |
32
+ | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.2.0/docs/extension-system-spec.md | The v2 decision record — strata, precedence, deletions, acceptance checks |
33
+ | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.2.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. a shipped extension 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,61 @@ 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 (one folder, three strata):
319
+ `docs/engine-payload-convention.md`. Hard-won facts, stated once so no one
320
+ re-discovers them by trial:
321
+
322
+ - **Export shape:** `module.exports = { id, setup }` (v2 — the v1 `{server}`
323
+ / named-export shape is rejected: "must export a default definition with
324
+ an id and an effect or setup function").
325
+ - **Tool namespace:** the model lists tools as `tools.<name>` — register and
326
+ match on the `tools.`-prefixed name, never bare.
327
+ - **Tool results must resolve `{ output: string }`.** A bare string fails
328
+ result validation (`Unknown tool` in the transcript).
329
+ - **System-hint parts need `{ type: "text", text }`.** Pushing `{text}`
330
+ without `type` fails the whole session drain (schema `MissingKey`).
331
+ - **Session origin tagging** (`metadata: { origin: "…" }`) survives only via
332
+ REST `POST /api/session` create — the setup-bridge create drops it.
333
+ - **Discovery + auth:** the engine registers at
334
+ `$XDG_STATE_HOME/opencode/service.json` (Basic `opencode:password` —
335
+ mirror `@opencode-ai/client`'s service helper); provider credentials live
336
+ under `XDG_DATA_HOME`, so a `STATE`-only sandbox sees the engine but no
337
+ models. When agent testing misbehaves, verify the provider first with
338
+ `POST /session/{id}/generate {"prompt":"OK"}`; when runs fail blank,
339
+ the cause is in `$XDG_DATA_HOME/opencode/log/opencode.log` (`grep drain`).
340
+ - `server.ts` code that must call the engine has no credential helper yet —
341
+ parse `service.json` by hand (node builtins only, no core imports); a
342
+ `ctx.engine` helper is the planned fix (`server/ext/types.ts`).
343
+
344
+ ## Loading lifecycle (where an extension travels)
345
+
346
+ One folder becomes pixels through four files — follow them in order:
347
+
348
+ 1. **Glob (shipped, repo dev).** `webui-extensions/index.ts` globs
349
+ `./*/index.{ts,tsx}` and tracks each module's `export const id` (Vite
350
+ HMR path — edits hot-swap via same-id registry swap, deletions prune
351
+ owned ids only).
352
+ 2. **Discovery (proxy).** `server/userExtensions.ts`
353
+ (`discoverUserUIEntries`) scans the three sources highest-precedence
354
+ first — user root, project root, shipped dir — taking the folder id
355
+ from `manifest.json` (`id`, falling back to the dir name) and the
356
+ entries from `index.tsx`/`dom.ts`. Same id at a lower source is
357
+ skipped with a once-per-process `shadowed` warning.
358
+ 3. **Manifest + SSE + bundling (proxy).** `server/index.ts` merges folder
359
+ entries with engine-plugin UI halves, serves
360
+ `GET /api/webui/extensions` (`{ id, url?v=mtime, domUrl?v=mtime,
361
+ source, origin }`), pushes a `{ type: "webui.extensions", version }`
362
+ event per manifest change on `GET /api/webui/extensions/events`, and
363
+ bundles each entry standalone with `Bun.build` (`bundleUIEntry` —
364
+ react external, build logs printed loudly, never silent).
365
+ 4. **Import + register (page).** `src/lib/runtimeExtensions.ts` fetches
366
+ the manifest, dynamic-imports each new `?v=` bundle (re-import on
367
+ mtime move → registry same-id-swap → live repaint), mounts `domUrl`
368
+ via the DOM kit, and unregisters ids that vanish or flip
369
+ `disabled: true`. Shipped browser bundles are skipped here (the glob
370
+ owns them — importing twice would run side effects twice) but shipped
371
+ `domUrl` still mounts and `disabled` still pauses them.
271
372
 
272
373
  ## What extensions can use (browser stratum)
273
374
 
@@ -305,8 +406,7 @@ kit, `kv`) — used identically by our shipped ones.
305
406
  > User tweaks only the timestamp format. Maintainer later redesigns the
306
407
  > token counter and adds a finish badge in the same header. **The user gets
307
408
  > both, visibly** — parent and siblings are still core's; the user's wrap
308
- > delegates by default. (`docs/extension-system-spec.md` §5.4; scenario
309
- > script: `docs/extension-timestamp-test.md`.)
409
+ > delegates by default. (`docs/extension-system-spec.md` §5.4.)
310
410
 
311
411
  ```tsx
312
412
  // ✅ RIGHT — wrap (stale-proof): the header redesign flows through
@@ -337,18 +437,22 @@ register({
337
437
  isolated second instance — loopback-only `127.0.0.1:4099`, passwordless (the
338
438
  bind address is the guarantee), same engine/sessions, extensions from an
339
439
  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.
440
+ default `~/.local/state/opencode-webui/sandbox-extensions/`).
441
+ `WEBUI_EXTENSION_DIR` is a higher-precedence ADD, not a replace: it swaps
442
+ out the user + project roots only — shipped extensions still load
443
+ underneath, and a same-id scratch folder shadows the shipped copy (the
444
+ `shadowed` log line is the only signal). Iterate there; "shipping" =
445
+ copying the folder into the real extension dir.
342
446
 
343
447
  ### Parallel sandboxes (agents: read this)
344
448
 
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.
449
+ Yes run as many sandboxes at once as you need, one per extension under
450
+ test. `bun run sandbox` stacks with no flags: the first instance takes the
451
+ fixed defaults (`:4099`/`:5175` + shared scratch dir); every further
452
+ instance detects the busy ports and auto-isolates onto free ports + a fresh
453
+ mkdtemp extension dir, printing exactly what it picked. Explicit env
454
+ (`WEBUI_PROXY_PORT` / `WEBUI_VITE_PORT` / `WEBUI_EXTENSION_DIR`) always wins
455
+ per knob and disables that knob's auto behavior.
352
456
 
353
457
  Rules: one sandbox per extension, never two writers to one ext dir, never
354
458
  reuse a port. The engine stays shared (same sessions everywhere, by