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.
- package/README.md +1 -1
- package/dist/assets/{index-BHminhzR.js → index-DDE-utaG.js} +5 -5
- package/dist/assets/{report-9wOQi_Kx.js → report-B9LU5-59.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +2 -1
- package/server/ext/engine.ts +161 -0
- package/server/ext/kv.ts +2 -3
- package/server/ext/registry.ts +3 -3
- package/server/ext/types.ts +24 -0
- package/server/index.ts +70 -1
- package/skills/webui/SKILL.md +17 -13
- package/webui-extensions/README.md +119 -15
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{n as e,t}from"./index-
|
|
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-
|
|
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.
|
|
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 —
|
|
5
|
-
*
|
|
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
|
*
|
package/server/ext/registry.ts
CHANGED
|
@@ -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> {
|
package/server/ext/types.ts
CHANGED
|
@@ -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
|
+
}
|
package/skills/webui/SKILL.md
CHANGED
|
@@ -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.
|
|
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.
|
|
28
|
-
| https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.
|
|
29
|
-
| https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.
|
|
30
|
-
| https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.
|
|
31
|
-
| https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.
|
|
32
|
-
| https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.
|
|
33
|
-
| https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
scratch dir);
|
|
51
|
-
|
|
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
|
|
270
|
-
folder, three strata):
|
|
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
|
|
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/`).
|
|
341
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
instance
|
|
349
|
-
it picked. Explicit env
|
|
350
|
-
`WEBUI_EXTENSION_DIR`) always wins
|
|
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
|