opencode-webui 1.0.9 → 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 } 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";
@@ -41,10 +41,20 @@ import {
41
41
  import { syncSkill } from "./skillSync";
42
42
  import {
43
43
  discoverUserUIEntries,
44
+ extensionSourceRoots,
44
45
  globalUserExtensionsDir,
46
+ invalidateExtensionCache,
45
47
  warnOnce,
46
48
  type UIEntry,
47
49
  } from "./userExtensions";
50
+ import {
51
+ applyExtResponseMiddleware,
52
+ dispatchExtEvent,
53
+ dispatchExtRequest,
54
+ runExtRequestMiddleware,
55
+ startExtModules,
56
+ } from "./ext/registry";
57
+ import { resolveEngineOverride } from "./ext/engine";
48
58
 
49
59
  // `sandbox` argv — one command, every runtime: `bun run sandbox` (repo, the
50
60
  // script adds Vite), `bunx opencode-webui sandbox`, `./opencode-webui sandbox`
@@ -107,6 +117,18 @@ async function writeDebug(lines: unknown[]) {
107
117
  let endpoint: Awaited<ReturnType<typeof Service.ensure>> | null = null;
108
118
 
109
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
+ }
110
132
  if (!endpoint) {
111
133
  endpoint = await Service.ensure();
112
134
  console.log(`[webui] connected to opencode service at ${endpoint.url}`);
@@ -114,6 +136,40 @@ async function serviceEndpoint() {
114
136
  return endpoint;
115
137
  }
116
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
+
117
173
  // ---------------------------------------------------------------------------
118
174
  // Live-event recorder (catch-up for late-joining browsers).
119
175
  //
@@ -154,6 +210,9 @@ function recordEvent(evt: RecordedEvent) {
154
210
  recorderSeenIds.add(evt.id);
155
211
  if (recorderSeenIds.size > RECORDER_SEEN_MAX) recorderSeenIds.clear();
156
212
  }
213
+ // Proxy-stratum event tap (spec §8): headless extensions observe the same
214
+ // deduped stream the replay buffer keeps. Fire-and-forget, never blocks.
215
+ void dispatchExtEvent(evt).catch((err) => console.error("[webui] ext onEvent failed:", err));
157
216
  let buf = replayBuffers.get(sessionID);
158
217
  if (!buf) {
159
218
  // Hard cap on tracked sessions; drop the least recently active.
@@ -240,12 +299,21 @@ async function startEventRecorder() {
240
299
  // code. The engine lists loaded plugins at GET /plugin ({ location,
241
300
  // data: PluginInfo[] }). For every LOCAL-source plugin we probe two UI entry
242
301
  // candidates next to it — `<dir>/ui/main.tsx`, then
243
- // `<dir>/<base>.ui.tsx` — bundle the first that exists with Bun.build into a
244
- // self-contained ESM script (it carries its own React and talks to this app
245
- // only through the window.__opencodeUI bridge), and serve:
302
+ // `<dir>/<base>.ui.tsx` — bundle the first that exists with Bun.build into an
303
+ // ESM script and serve:
304
+ //
305
+ // GET /api/webui/extensions -> { data: [{ id, url?, domUrl?, source }] }
306
+ // GET /api/webui/extensions/:id/bundle.js -> text/javascript, no-cache (browser stratum)
307
+ // GET /api/webui/extensions/:id/dom.js -> text/javascript, no-cache (DOM stratum, spec §7)
246
308
  //
247
- // GET /api/webui/extensions -> { data: [{ id, url, source }] }
248
- // GET /api/webui/extensions/:id/bundle.js -> text/javascript, no-cache
309
+ // `react` (+ `react/jsx-runtime`, `react/jsx-dev-runtime`) are EXTERNAL in
310
+ // every bundle — never inlined. The page's index.html carries an import map
311
+ // pointing those bare specifiers at /api/webui/vendor/*.js, which re-export
312
+ // the app's own React via the window.__opencodeUI bridge (installed at boot,
313
+ // re-ensured by the runtime loader before every import). One React instance
314
+ // for app and extensions alike: inlining a second copy breaks hooks
315
+ // (invalid-hook-call on the first useState). The vendor shims are the only
316
+ // copy extensions ever see.
249
317
  //
250
318
  // Both routes ride the same session auth as every other /api call (the
251
319
  // upstream plugin list is fetched with Service.headers) and register BEFORE
@@ -324,21 +392,22 @@ async function discoverUIEntries(): Promise<UIEntry[]> {
324
392
  }
325
393
 
326
394
  /**
327
- * Plugin UI entries + user-dir entries, plugin ids winning collisions (a
328
- * user folder may never shadow a plugin's UI that's warned about once).
395
+ * Folder extensions (user > project > shipped, same-id swap) PLUS engine
396
+ * plugin UI halves. Folder ids win collisions: a folder may shadow a
397
+ * plugin's UI — presence on disk is the deliberate override.
329
398
  */
330
399
  async function discoverAllUIEntries(): Promise<UIEntry[]> {
331
400
  const pluginEntries = await discoverUIEntries();
332
- const userEntries = discoverUserUIEntries();
333
- if (userEntries.length === 0) return pluginEntries;
334
- const ids = new Set(pluginEntries.map((e) => e.id));
335
- const merged = [...pluginEntries];
336
- for (const user of userEntries) {
337
- if (ids.has(user.id)) {
338
- warnOnce(`collide:${user.id}`, `user extension "${user.id}" skipped — a plugin already owns that id`);
401
+ const folderEntries = discoverUserUIEntries();
402
+ if (folderEntries.length === 0) return pluginEntries;
403
+ const ids = new Set(folderEntries.map((e) => e.id));
404
+ const merged = [...folderEntries];
405
+ for (const plugin of pluginEntries) {
406
+ if (ids.has(plugin.id)) {
407
+ warnOnce(`collide:${plugin.id}`, `plugin extension "${plugin.id}" skipped — a folder already owns that id`);
339
408
  continue;
340
409
  }
341
- merged.push(user);
410
+ merged.push(plugin);
342
411
  }
343
412
  return merged;
344
413
  }
@@ -353,33 +422,244 @@ async function bundleUIEntry(entry: string): Promise<string> {
353
422
  target: "browser",
354
423
  format: "esm",
355
424
  minify: false,
356
- // Plugin dirs have no node_modules resolve React from THIS app so every
357
- // runtime bundle shares one React copy. JSX dev/runtime variants included.
358
- plugins: [
359
- {
360
- name: "react-from-app",
361
- setup(build) {
362
- build.onResolve({ filter: /^react(\/jsx-runtime|\/jsx-dev-runtime)?$/ }, (args) => ({
363
- path: join(
364
- APP_ROOT,
365
- "node_modules",
366
- "react",
367
- args.path === "react" ? "index.js" : `${args.path.slice("react/".length)}.js`,
368
- ),
369
- }));
370
- },
371
- },
372
- ],
425
+ // React is EXTERNALnever inlined. Extension bundles import the bare
426
+ // specifiers and the page's import map (index.html) resolves them to
427
+ // /api/webui/vendor/*.js, which re-export the app's own React instance.
428
+ // Resolving react to a FILE path here (the old react-from-app plugin)
429
+ // inlined a private second copy -> invalid-hook-call on first useState.
430
+ external: ["react", "react/jsx-runtime", "react/jsx-dev-runtime"],
373
431
  });
374
432
  const artifact =
375
433
  built.outputs.find((o) => o.kind === "entry-point" && o.path.endsWith(".js")) ??
376
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
+ }
377
441
  if (!artifact) throw new Error(`bun.build produced no js artifact for ${entry}`);
378
442
  const js = await artifact.text();
379
443
  bundleCache.set(entry, { mtimeMs, js });
380
444
  return js;
381
445
  }
382
446
 
447
+ // ---------------------------------------------------------------------------
448
+ // Shared-React vendor shims (import-map targets for external bundles).
449
+ //
450
+ // Extension bundles import the bare specifiers "react",
451
+ // "react/jsx-runtime" and "react/jsx-dev-runtime" (see `external` above).
452
+ // The page's import map (index.html) resolves those to these routes, which
453
+ // re-export the APP's React instance via the window.__opencodeUI bridge —
454
+ // installed at boot (main.tsx) and re-ensured by the runtime loader before
455
+ // every bundle import, so the bridge always exists when a shim executes.
456
+ // No React code ships in these shims and none is inlined into bundles:
457
+ // there is exactly one React instance in the page.
458
+ //
459
+ // The named-export list is derived from the running app's react copy so it
460
+ // stays correct across React upgrades without hand-maintained lists.
461
+ // ---------------------------------------------------------------------------
462
+
463
+ const REACT_NAMED_EXPORTS: string[] = (() => {
464
+ try {
465
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
466
+ const ns = require("react") as Record<string, unknown>;
467
+ return Object.keys(ns).filter(
468
+ (k) => k !== "default" && k !== "__esModule" && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(k),
469
+ );
470
+ } catch {
471
+ // Fallback: hooks + primitives an extension could plausibly import.
472
+ return [
473
+ "Children", "Component", "Fragment", "Profiler", "PureComponent", "StrictMode", "Suspense",
474
+ "cache", "cloneElement", "createContext", "createElement", "createRef", "forwardRef",
475
+ "isValidElement", "lazy", "memo", "startTransition", "use", "useCallback", "useContext",
476
+ "useDebugValue", "useDeferredValue", "useEffect", "useId", "useImperativeHandle",
477
+ "useInsertionEffect", "useLayoutEffect", "useMemo", "useReducer", "useRef", "useState",
478
+ "useSyncExternalStore", "useTransition", "version",
479
+ ];
480
+ }
481
+ })();
482
+
483
+ const REACT_VENDOR_JS = `// Shared-React shim: re-exports the app's React (window.__opencodeUI.react).
484
+ // Served by the proxy at /api/webui/vendor/react.js (import-map target).
485
+ const R = globalThis.__opencodeUI?.react;
486
+ if (!R) throw new Error("[webui] React bridge not ready: window.__opencodeUI.react is missing");
487
+ export default R;
488
+ export const { ${REACT_NAMED_EXPORTS.join(", ")} } = R;
489
+ `;
490
+
491
+ // The automatic JSX transform calls jsx()/jsxs() to build elements. These
492
+ // delegate to createElement on the SAME shared instance, so elements and
493
+ // hooks always agree on the dispatcher. __source/__self (dev) are stripped.
494
+ const JSX_RUNTIME_BODY = `const R = globalThis.__opencodeUI?.react;
495
+ if (!R) throw new Error("[webui] React bridge not ready: window.__opencodeUI.react is missing");
496
+ export const Fragment = R.Fragment;
497
+ function _el(type, props, key) {
498
+ const p = { ...(props || {}) };
499
+ const children = p.children;
500
+ delete p.children;
501
+ delete p.__source;
502
+ delete p.__self;
503
+ if (key !== undefined) p.key = key;
504
+ if (children === undefined) return R.createElement(type, p);
505
+ return Array.isArray(children) ? R.createElement(type, p, ...children) : R.createElement(type, p, children);
506
+ }
507
+ export function jsx(type, props, key) { return _el(type, props, key); }
508
+ export function jsxs(type, props, key) { return _el(type, props, key); }
509
+ `;
510
+
511
+ const REACT_JSX_RUNTIME_VENDOR_JS = `// Shared-React shim for react/jsx-runtime (import-map target).
512
+ ${JSX_RUNTIME_BODY}`;
513
+
514
+ const REACT_JSX_DEV_RUNTIME_VENDOR_JS = `// Shared-React shim for react/jsx-dev-runtime (import-map target).
515
+ ${JSX_RUNTIME_BODY}export function jsxDEV(type, props, key) { return _el(type, props, key); }
516
+ `;
517
+
518
+ function vendorShimFor(path: string): string | null {
519
+ if (path === "/api/webui/vendor/react.js") return REACT_VENDOR_JS;
520
+ if (path === "/api/webui/vendor/react-jsx-runtime.js") return REACT_JSX_RUNTIME_VENDOR_JS;
521
+ if (path === "/api/webui/vendor/react-jsx-dev-runtime.js") return REACT_JSX_DEV_RUNTIME_VENDOR_JS;
522
+ return null;
523
+ }
524
+
525
+ // ---------------------------------------------------------------------------
526
+ // Extension manifest push (spec §6: replaces the 8s browser poll).
527
+ //
528
+ // The proxy watches all three folder sources, rebuilds changed bundles
529
+ // (bundleCache is mtime-keyed, so the next bundle.js fetch rebuilds), bumps
530
+ // the manifest version, and pushes it over SSE; the page re-imports bundles
531
+ // whose ?v= moved and same-id-swaps them in the registry — sub-second, no
532
+ // refresh. Delete/move = uninstall (the id vanishes from the manifest and
533
+ // the page unregisters it); manifest `disabled: true` = paused.
534
+ // ---------------------------------------------------------------------------
535
+
536
+ type ManifestItem =
537
+ | { id: string; source: string; origin?: UIEntry["origin"]; url?: string; domUrl?: string }
538
+ | { id: string; source: string; origin?: UIEntry["origin"]; disabled: true };
539
+
540
+ async function buildExtensionManifest(): Promise<ManifestItem[]> {
541
+ // discoverAllUIEntries never throws; upstream failures collapse to [].
542
+ const entries = await discoverAllUIEntries();
543
+ return entries.map((e) => {
544
+ if (e.disabled || (!e.entry && !e.domEntry)) {
545
+ return { id: e.id, source: e.source ?? e.entry, origin: e.origin, disabled: true as const };
546
+ }
547
+ const item: { id: string; source: string; origin?: UIEntry["origin"]; url?: string; domUrl?: string } = {
548
+ id: e.id,
549
+ source: e.source ?? e.entry,
550
+ origin: e.origin,
551
+ };
552
+ // Shipped browser stratum loads via the in-repo Vite glob
553
+ // (webui-extensions/index.ts, Vite HMR) — never via a bundle URL, or
554
+ // module side effects run twice and `extension.loaded` fires twice
555
+ // (Bug 2). Omit `url` for shipped origin; a user/project copy shadowing
556
+ // the same id wins discovery with origin user/project, keeps its `url`,
557
+ // and same-id-swaps over the glob copy. DOM stratum (`domUrl`) still
558
+ // serves for shipped: the glob never loads `dom.ts`, so there is no
559
+ // double-load there and omitting it would break shipped DOM extensions.
560
+ if (e.entry && e.origin !== "shipped") {
561
+ item.url = `/api/webui/extensions/${encodeURIComponent(e.id)}/bundle.js?v=${e.mtimeMs}`;
562
+ }
563
+ // DOM stratum (spec §7): its own `?v=` — a `dom.ts` edit changes the
564
+ // manifest JSON, which is what fires the SSE push (no second channel).
565
+ if (e.domEntry && e.domMtimeMs !== undefined) {
566
+ item.domUrl = `/api/webui/extensions/${encodeURIComponent(e.id)}/dom.js?v=${e.domMtimeMs}`;
567
+ }
568
+ return item;
569
+ });
570
+ }
571
+
572
+ let extManifestVersion = 0;
573
+ let lastManifestJSON = "";
574
+ const extManifestListeners = new Set<(msg: string) => void>();
575
+
576
+ function broadcastExtensionManifest() {
577
+ const msg = JSON.stringify({ type: "webui.extensions", version: extManifestVersion });
578
+ for (const send of extManifestListeners) send(msg);
579
+ }
580
+
581
+ /**
582
+ * Re-scan and push when the manifest actually changed (edits change ?v=
583
+ * mtimes, adds/removes/disabled-flips change the id set). Returns true on
584
+ * change. Watcher-triggered scans invalidate the TTL caches first so the
585
+ * push is immediate, not up-to-5s late.
586
+ */
587
+ async function checkExtensionManifest(immediate: boolean): Promise<boolean> {
588
+ if (immediate) {
589
+ invalidateExtensionCache();
590
+ uiEntryCache = null;
591
+ }
592
+ const manifest = await buildExtensionManifest();
593
+ const json = JSON.stringify(manifest);
594
+ if (json === lastManifestJSON) return false;
595
+ lastManifestJSON = json;
596
+ extManifestVersion++;
597
+ dbg("extensions manifest v" + extManifestVersion + ":", manifest.length, "entr(ies)");
598
+ broadcastExtensionManifest();
599
+ return true;
600
+ }
601
+
602
+ const watchedExtRoots = new Set<string>();
603
+ let extRescanTimer: ReturnType<typeof setTimeout> | null = null;
604
+
605
+ function scheduleExtRescan() {
606
+ if (extRescanTimer) return;
607
+ extRescanTimer = setTimeout(() => {
608
+ extRescanTimer = null;
609
+ void checkExtensionManifest(true);
610
+ }, 300); // coalesce save-bursts (edit + manifest.json write land together)
611
+ }
612
+
613
+ function ensureExtWatchers() {
614
+ const attach = (path: string) => {
615
+ if (watchedExtRoots.has(path)) return;
616
+ try {
617
+ const watcher = watch(path, { persistent: false }, () => scheduleExtRescan());
618
+ watcher.on("error", () => {
619
+ // Path deleted (or never existed) — drop it; a later rescan
620
+ // re-attaches when it reappears.
621
+ try {
622
+ watcher.close();
623
+ } catch {
624
+ /* already closed */
625
+ }
626
+ watchedExtRoots.delete(path);
627
+ });
628
+ watchedExtRoots.add(path);
629
+ } catch {
630
+ /* absent path — retry on the next rescan */
631
+ }
632
+ };
633
+ for (const root of extensionSourceRoots()) attach(root);
634
+ // Each extension folder too: a content-only edit (index.tsx bytes,
635
+ // manifest.json `disabled` flip) fires no event on the PARENT root watch
636
+ // (inotify reports only direct-child create/delete/rename there) — without
637
+ // this, edits would wait for the 5s backstop instead of pushing sub-second.
638
+ // discoverUserUIEntries() is TTL-cached, so this sweep is cheap.
639
+ const PREFIX = "webui-extensions:";
640
+ for (const e of discoverUserUIEntries()) {
641
+ const dir = e.entry
642
+ ? dirname(e.entry)
643
+ : e.source?.startsWith(PREFIX)
644
+ ? e.source.slice(PREFIX.length)
645
+ : null;
646
+ if (dir) attach(dir);
647
+ }
648
+ }
649
+
650
+ let extWatcherRunning = false;
651
+ /** fs.watch for immediacy + a 5s re-scan for engine-plugin drift and roots. */
652
+ function startExtensionWatcher() {
653
+ if (extWatcherRunning) return;
654
+ extWatcherRunning = true;
655
+ ensureExtWatchers();
656
+ void checkExtensionManifest(true); // seed lastManifestJSON + version 1
657
+ setInterval(() => {
658
+ ensureExtWatchers(); // attach to roots that appeared since boot
659
+ void checkExtensionManifest(false);
660
+ }, 5_000).unref?.();
661
+ }
662
+
383
663
  // ---------------------------------------------------------------------------
384
664
  // Boot: CLI flag → auth policy → skill sync → serve → banner.
385
665
  // ---------------------------------------------------------------------------
@@ -496,31 +776,79 @@ const server: Server<Record<string, unknown>> = Bun.serve({
496
776
  return Response.json({ data: events });
497
777
  }
498
778
 
499
- // Plugin + user web-UI extensions must match before the generic /api proxy.
779
+ // Extension manifest folder entries (user > project > shipped) plus
780
+ // plugin UI halves. Disabled entries ship WITHOUT a url so the page can
781
+ // show them paused without importing anything. Shipped-origin browser
782
+ // entries likewise ship without `url` (Bug 2: the in-repo Vite glob owns
783
+ // them — a bundle URL here would double-load); shadowing user/project
784
+ // copies keep their `url` and same-id-swap. `origin` lets the page tell
785
+ // them apart; shipped `domUrl` still serves (the glob never loads dom).
500
786
  if (method === "GET" && path === "/api/webui/extensions") {
501
- // discoverAllUIEntries never throws; upstream failures collapse to { data: [] }.
502
- const entries = await discoverAllUIEntries();
503
- dbg("extensions list:", entries.length, "ui entr(ies)");
504
- return Response.json({
505
- data: entries.map((e) => ({
506
- id: e.id,
507
- source: e.source ?? e.entry,
508
- url: `/api/webui/extensions/${encodeURIComponent(e.id)}/bundle.js?v=${e.mtimeMs}`,
509
- })),
787
+ const data = await buildExtensionManifest();
788
+ dbg("extensions list:", data.length, "ui entr(ies)");
789
+ return Response.json({ data, version: extManifestVersion });
790
+ }
791
+
792
+ // Manifest push channel (spec §6): one event per manifest change plus a
793
+ // hello on subscribe. The page re-fetches the manifest on each event and
794
+ // re-imports only bundles whose ?v= moved. Heartbeat comments keep the
795
+ // stream alive through idle infrastructure.
796
+ if (method === "GET" && path === "/api/webui/extensions/events") {
797
+ const encoder = new TextEncoder();
798
+ let send: ((msg: string) => void) | null = null;
799
+ let heartbeat: ReturnType<typeof setInterval> | undefined;
800
+ const stream = new ReadableStream({
801
+ start(controller) {
802
+ send = (msg: string) => {
803
+ try {
804
+ controller.enqueue(encoder.encode(`data: ${msg}\n\n`));
805
+ } catch {
806
+ /* client gone — cancel() cleans up */
807
+ }
808
+ };
809
+ extManifestListeners.add(send);
810
+ send(JSON.stringify({ type: "webui.extensions", version: extManifestVersion }));
811
+ heartbeat = setInterval(() => {
812
+ try {
813
+ controller.enqueue(encoder.encode(`: ping\n\n`));
814
+ } catch {
815
+ /* client gone */
816
+ }
817
+ }, 15_000);
818
+ },
819
+ cancel() {
820
+ if (heartbeat) clearInterval(heartbeat);
821
+ if (send) extManifestListeners.delete(send);
822
+ },
823
+ });
824
+ return new Response(stream, {
825
+ headers: {
826
+ "content-type": "text/event-stream",
827
+ "cache-control": "no-cache",
828
+ connection: "keep-alive",
829
+ },
510
830
  });
511
831
  }
512
832
 
513
- if (method === "GET" && /^\/api\/webui\/extensions\/[^/]+\/bundle\.js$/.test(path)) {
514
- const id = decodeURIComponent(path.split("/")[4] ?? "");
833
+ // Browser-stratum bundle (`bundle.js`) and DOM-stratum bundle
834
+ // (`dom.js`, spec §7) same mtime-keyed Bun.build pipeline, same
835
+ // no-cache serving. Disabled entries 404 — the page must never import a
836
+ // paused extension.
837
+ if (method === "GET" && /^\/api\/webui\/extensions\/[^/]+\/(bundle|dom)\.js$/.test(path)) {
838
+ const segs = path.split("/");
839
+ const id = decodeURIComponent(segs[4] ?? "");
840
+ const wantDom = (segs[5] ?? "").startsWith("dom");
515
841
  try {
516
842
  // Resolve through the CURRENT discovery result so removed/expired
517
- // plugins 404 instead of serving a stale bundle.
843
+ // plugins 404 instead of serving a stale bundle. Disabled entries
844
+ // 404 too — the page must never import a paused extension.
518
845
  const found = (await discoverAllUIEntries()).find((e) => e.id === id);
519
- if (!found || !existsSync(found.entry)) {
846
+ const entry = wantDom ? found?.domEntry : found?.entry;
847
+ if (!found || found.disabled || !entry || !existsSync(entry)) {
520
848
  return Response.json({ error: `unknown extension: ${id}` }, { status: 404 });
521
849
  }
522
- const js = await bundleUIEntry(found.entry); // throws -> 500 below
523
- dbg("extensions bundle:", id, `${js.length}b`);
850
+ const js = await bundleUIEntry(entry); // throws -> 500 below
851
+ dbg("extensions bundle:", id, wantDom ? "(dom)" : "", `${js.length}b`);
524
852
  return new Response(js, {
525
853
  headers: { "content-type": "text/javascript", "cache-control": "no-cache" },
526
854
  });
@@ -530,6 +858,24 @@ const server: Server<Record<string, unknown>> = Bun.serve({
530
858
  }
531
859
  }
532
860
 
861
+ if (path.startsWith("/api/webui/ext/")) {
862
+ const extRes = await dispatchExtRequest(req, url);
863
+ if (extRes) return extRes;
864
+ return Response.json({ error: "unknown extension route" }, { status: 404 });
865
+ }
866
+
867
+ // Shared-React vendor shims (import-map targets — see bundleUIEntry).
868
+ // Same session auth as every other /api route (gate above applies);
869
+ // same-origin dynamic imports carry the session cookie. Content is
870
+ // derived from the running app's react copy, so no-cache (tiny files).
871
+ if (method === "GET" && path.startsWith("/api/webui/vendor/")) {
872
+ const js = vendorShimFor(path);
873
+ if (js === null) return Response.json({ error: "unknown vendor module" }, { status: 404 });
874
+ return new Response(js, {
875
+ headers: { "content-type": "text/javascript", "cache-control": "no-cache" },
876
+ });
877
+ }
878
+
533
879
  if (path.startsWith("/api")) {
534
880
  const isUpgrade = (req.headers.get("upgrade") ?? "").toLowerCase() === "websocket";
535
881
  if (isUpgrade) {
@@ -548,14 +894,21 @@ const server: Server<Record<string, unknown>> = Bun.serve({
548
894
  }
549
895
  const t0 = Date.now();
550
896
  try {
897
+ // Proxy-stratum request middleware (spec §8): a returned Response
898
+ // short-circuits the passthrough; a returned Request replaces it.
899
+ let activeReq = req;
900
+ const extRewrite = await runExtRequestMiddleware(req);
901
+ if (extRewrite instanceof Response) return extRewrite;
902
+ if (extRewrite instanceof Request) activeReq = extRewrite;
903
+ const upMethod = activeReq.method;
551
904
  const ep = await serviceEndpoint();
552
905
  const headers = Service.headers(ep);
553
906
  const upstream: Response = await fetch(`${ep.url}${path}${url.search}`, {
554
- method,
907
+ method: upMethod,
555
908
  // Abort the upstream request when the browser client goes away,
556
909
  // otherwise streamed responses (SSE) leak one connection per
557
910
  // client reconnect until the pool wedges and requests hang.
558
- signal: req.signal,
911
+ signal: activeReq.signal,
559
912
  headers: {
560
913
  ...headers,
561
914
  // Forward only benign client headers. Service.headers must WIN —
@@ -563,7 +916,7 @@ const server: Server<Record<string, unknown>> = Bun.serve({
563
916
  // engine credential (e.g. its own `authorization`). Also drop
564
917
  // spoofable/transport headers the engine should never see.
565
918
  ...Object.fromEntries(
566
- [...req.headers.entries()].filter(([k]) => {
919
+ [...activeReq.headers.entries()].filter(([k]) => {
567
920
  const name = k.toLowerCase();
568
921
  if (FORBIDDEN_CLIENT_HEADERS.has(name)) return false;
569
922
  return (
@@ -578,7 +931,7 @@ const server: Server<Record<string, unknown>> = Bun.serve({
578
931
  // hops gain nothing from compression — never request it.
579
932
  "accept-encoding": "identity",
580
933
  },
581
- body: ["GET", "HEAD"].includes(method) ? undefined : req.body,
934
+ body: ["GET", "HEAD"].includes(upMethod) ? undefined : activeReq.body,
582
935
  redirect: "manual",
583
936
  });
584
937
 
@@ -588,10 +941,14 @@ const server: Server<Record<string, unknown>> = Bun.serve({
588
941
  responseHeaders.delete("content-encoding");
589
942
  }
590
943
  dbg("proxy:", method, path, "->", upstream.status, `${Date.now() - t0}ms`);
591
- return new Response(upstream.body, {
592
- status: upstream.status,
593
- headers: responseHeaders,
594
- });
944
+ // Proxy-stratum response middleware (spec §8): uniform rewriting.
945
+ return await applyExtResponseMiddleware(
946
+ new Response(upstream.body, {
947
+ status: upstream.status,
948
+ headers: responseHeaders,
949
+ }),
950
+ req,
951
+ );
595
952
  } catch (err) {
596
953
  const message = err instanceof Error ? err.message : String(err);
597
954
  console.error("[webui] proxy error:", message);
@@ -696,10 +1053,28 @@ console.log(
696
1053
  ? `[webui] sandbox — loopback only, NO password; extensions (scratch): ${globalUserExtensionsDir()}`
697
1054
  : `[webui] password: ${AUTH.generated ?? "from WEBUI_PASSWORD"}`,
698
1055
  `[webui] same sessions as your opencode TUI — it's the same engine`,
699
- `[webui] extensions: drop folders in ${globalUserExtensionsDir()}/<name>/main.tsx`,
1056
+ `[webui] extensions: drop folders in ${globalUserExtensionsDir()}/<name>/ (index.tsx + manifest.json)`,
700
1057
  SKILL.ok
701
1058
  ? `[webui] agent skill installed at ${SKILL.target} (auto-synced each boot)`
702
1059
  : `[webui] agent skill NOT synced: ${SKILL.reason}`,
703
1060
  ].join("\n"),
704
1061
  );
705
1062
  void startEventRecorder();
1063
+ startExtensionWatcher();
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
+ }