Przeglądaj źródła

refactor(server): simplify router middleware with next() (#21720)

Dax 5 dni temu
rodzic
commit
ca5f086759
32 zmienionych plików z 767 dodań i 467 usunięć
  1. 5 0
      packages/opencode/package.json
  2. 5 7
      packages/opencode/src/agent/agent.ts
  3. 8 2
      packages/opencode/src/cli/cmd/tui/thread.ts
  4. 0 1
      packages/opencode/src/config/config.ts
  5. 10 8
      packages/opencode/src/plugin/index.ts
  6. 40 0
      packages/opencode/src/server/adapter.bun.ts
  7. 66 0
      packages/opencode/src/server/adapter.node.ts
  8. 21 0
      packages/opencode/src/server/adapter.ts
  9. 150 0
      packages/opencode/src/server/control/index.ts
  10. 0 0
      packages/opencode/src/server/instance/config.ts
  11. 0 0
      packages/opencode/src/server/instance/event.ts
  12. 0 0
      packages/opencode/src/server/instance/experimental.ts
  13. 0 0
      packages/opencode/src/server/instance/file.ts
  14. 0 0
      packages/opencode/src/server/instance/global.ts
  15. 24 80
      packages/opencode/src/server/instance/index.ts
  16. 0 0
      packages/opencode/src/server/instance/mcp.ts
  17. 6 10
      packages/opencode/src/server/instance/middleware.ts
  18. 0 0
      packages/opencode/src/server/instance/permission.ts
  19. 0 0
      packages/opencode/src/server/instance/project.ts
  20. 0 0
      packages/opencode/src/server/instance/provider.ts
  21. 0 0
      packages/opencode/src/server/instance/pty.ts
  22. 0 0
      packages/opencode/src/server/instance/question.ts
  23. 0 0
      packages/opencode/src/server/instance/session.ts
  24. 0 0
      packages/opencode/src/server/instance/tui.ts
  25. 0 0
      packages/opencode/src/server/instance/workspace.ts
  26. 82 23
      packages/opencode/src/server/middleware.ts
  27. 28 275
      packages/opencode/src/server/server.ts
  28. 55 0
      packages/opencode/src/server/ui/index.ts
  29. 49 0
      packages/opencode/test/memory/abort-leak-webfetch.ts
  30. 127 0
      packages/opencode/test/memory/abort-leak.test.ts
  31. 90 60
      packages/opencode/test/plugin/loader-shared.test.ts
  32. 1 1
      packages/opencode/test/server/session-messages.test.ts

+ 5 - 0
packages/opencode/package.json

@@ -39,6 +39,11 @@
       "bun": "./src/pty/pty.bun.ts",
       "node": "./src/pty/pty.node.ts",
       "default": "./src/pty/pty.bun.ts"
+    },
+    "#hono": {
+      "bun": "./src/server/adapter.bun.ts",
+      "node": "./src/server/adapter.node.ts",
+      "default": "./src/server/adapter.bun.ts"
     }
   },
   "devDependencies": {

+ 5 - 7
packages/opencode/src/agent/agent.ts

@@ -398,13 +398,11 @@ export namespace Agent {
     }),
   )
 
-  export const defaultLayer = Layer.suspend(() =>
-    layer.pipe(
-      Layer.provide(Provider.defaultLayer),
-      Layer.provide(Auth.defaultLayer),
-      Layer.provide(Config.defaultLayer),
-      Layer.provide(Skill.defaultLayer),
-    ),
+  export const defaultLayer = layer.pipe(
+    Layer.provide(Provider.defaultLayer),
+    Layer.provide(Auth.defaultLayer),
+    Layer.provide(Config.defaultLayer),
+    Layer.provide(Skill.defaultLayer),
   )
 
   const { runPromise } = makeRuntime(Service, defaultLayer)

+ 8 - 2
packages/opencode/src/cli/cmd/tui/thread.ts

@@ -137,12 +137,18 @@ export const TuiThreadCommand = cmd({
         ),
       })
       worker.onerror = (e) => {
-        Log.Default.error(e)
+        Log.Default.error("thread error", {
+          message: e.message,
+          filename: e.filename,
+          lineno: e.lineno,
+          colno: e.colno,
+          error: e.error,
+        })
       }
 
       const client = Rpc.client<typeof rpc>(worker)
       const error = (e: unknown) => {
-        Log.Default.error(e)
+        Log.Default.error("process error", { error: errorMessage(e) })
       }
       const reload = () => {
         client.call("reload", undefined).catch((err) => {

+ 0 - 1
packages/opencode/src/config/config.ts

@@ -4,7 +4,6 @@ import { pathToFileURL } from "url"
 import os from "os"
 import { Process } from "../util/process"
 import z from "zod"
-import { ModelsDev } from "../provider/models"
 import { mergeDeep, pipe, unique } from "remeda"
 import { Global } from "../global"
 import fsNode from "fs/promises"

+ 10 - 8
packages/opencode/src/plugin/index.ts

@@ -124,7 +124,7 @@ export namespace Plugin {
                   Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
                 }
               : undefined,
-            fetch: async (...args) => Server.Default().app.fetch(...args),
+            fetch: async (...args) => (await Server.Default()).app.fetch(...args),
           })
           const cfg = yield* config.get()
           const input: PluginInput = {
@@ -210,13 +210,15 @@ export namespace Plugin {
                 return message
               },
             }).pipe(
-              Effect.catch((message) =>
-                bus.publish(Session.Event.Error, {
-                  error: new NamedError.Unknown({
-                    message: `Failed to load plugin ${load.spec}: ${message}`,
-                  }).toObject(),
-                }),
-              ),
+              Effect.catch(() => {
+                // TODO: make proper events for this
+                // bus.publish(Session.Event.Error, {
+                //   error: new NamedError.Unknown({
+                //     message: `Failed to load plugin ${load.spec}: ${message}`,
+                //   }).toObject(),
+                // })
+                return Effect.void
+              }),
             )
           }
 

+ 40 - 0
packages/opencode/src/server/adapter.bun.ts

@@ -0,0 +1,40 @@
+import type { Hono } from "hono"
+import { createBunWebSocket } from "hono/bun"
+import type { Adapter } from "./adapter"
+
+export const adapter: Adapter = {
+  create(app: Hono) {
+    const ws = createBunWebSocket()
+    return {
+      upgradeWebSocket: ws.upgradeWebSocket,
+      async listen(opts) {
+        const args = {
+          fetch: app.fetch,
+          hostname: opts.hostname,
+          idleTimeout: 0,
+          websocket: ws.websocket,
+        } as const
+        const start = (port: number) => {
+          try {
+            return Bun.serve({ ...args, port })
+          } catch {
+            return
+          }
+        }
+        const server = opts.port === 0 ? (start(4096) ?? start(0)) : start(opts.port)
+        if (!server) {
+          throw new Error(`Failed to start server on port ${opts.port}`)
+        }
+        if (!server.port) {
+          throw new Error(`Failed to resolve server address for port ${opts.port}`)
+        }
+        return {
+          port: server.port,
+          stop(close?: boolean) {
+            return Promise.resolve(server.stop(close))
+          },
+        }
+      },
+    }
+  },
+}

+ 66 - 0
packages/opencode/src/server/adapter.node.ts

@@ -0,0 +1,66 @@
+import { createAdaptorServer, type ServerType } from "@hono/node-server"
+import { createNodeWebSocket } from "@hono/node-ws"
+import type { Hono } from "hono"
+import type { Adapter } from "./adapter"
+
+export const adapter: Adapter = {
+  create(app: Hono) {
+    const ws = createNodeWebSocket({ app })
+    return {
+      upgradeWebSocket: ws.upgradeWebSocket,
+      async listen(opts) {
+        const start = (port: number) =>
+          new Promise<ServerType>((resolve, reject) => {
+            const server = createAdaptorServer({ fetch: app.fetch })
+            ws.injectWebSocket(server)
+            const fail = (err: Error) => {
+              cleanup()
+              reject(err)
+            }
+            const ready = () => {
+              cleanup()
+              resolve(server)
+            }
+            const cleanup = () => {
+              server.off("error", fail)
+              server.off("listening", ready)
+            }
+            server.once("error", fail)
+            server.once("listening", ready)
+            server.listen(port, opts.hostname)
+          })
+
+        const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port)
+        const addr = server.address()
+        if (!addr || typeof addr === "string") {
+          throw new Error(`Failed to resolve server address for port ${opts.port}`)
+        }
+
+        let closing: Promise<void> | undefined
+        return {
+          port: addr.port,
+          stop(close?: boolean) {
+            closing ??= new Promise((resolve, reject) => {
+              server.close((err) => {
+                if (err) {
+                  reject(err)
+                  return
+                }
+                resolve()
+              })
+              if (close) {
+                if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") {
+                  server.closeAllConnections()
+                }
+                if ("closeIdleConnections" in server && typeof server.closeIdleConnections === "function") {
+                  server.closeIdleConnections()
+                }
+              }
+            })
+            return closing
+          },
+        }
+      },
+    }
+  },
+}

+ 21 - 0
packages/opencode/src/server/adapter.ts

@@ -0,0 +1,21 @@
+import type { Hono } from "hono"
+import type { UpgradeWebSocket } from "hono/ws"
+
+export type Opts = {
+  port: number
+  hostname: string
+}
+
+export type Listener = {
+  port: number
+  stop: (close?: boolean) => Promise<void>
+}
+
+export interface Runtime {
+  upgradeWebSocket: UpgradeWebSocket
+  listen(opts: Opts): Promise<Listener>
+}
+
+export interface Adapter {
+  create(app: Hono): Runtime
+}

+ 150 - 0
packages/opencode/src/server/control/index.ts

@@ -0,0 +1,150 @@
+import { Auth } from "@/auth"
+import { Log } from "@/util/log"
+import { ProviderID } from "@/provider/schema"
+import { Hono } from "hono"
+import { describeRoute, resolver, validator, openAPIRouteHandler } from "hono-openapi"
+import z from "zod"
+import { errors } from "../error"
+import { GlobalRoutes } from "../instance/global"
+
+export function ControlPlaneRoutes(): Hono {
+  const app = new Hono()
+  return app
+    .route("/global", GlobalRoutes())
+    .put(
+      "/auth/:providerID",
+      describeRoute({
+        summary: "Set auth credentials",
+        description: "Set authentication credentials",
+        operationId: "auth.set",
+        responses: {
+          200: {
+            description: "Successfully set authentication credentials",
+            content: {
+              "application/json": {
+                schema: resolver(z.boolean()),
+              },
+            },
+          },
+          ...errors(400),
+        },
+      }),
+      validator(
+        "param",
+        z.object({
+          providerID: ProviderID.zod,
+        }),
+      ),
+      validator("json", Auth.Info.zod),
+      async (c) => {
+        const providerID = c.req.valid("param").providerID
+        const info = c.req.valid("json")
+        await Auth.set(providerID, info)
+        return c.json(true)
+      },
+    )
+    .delete(
+      "/auth/:providerID",
+      describeRoute({
+        summary: "Remove auth credentials",
+        description: "Remove authentication credentials",
+        operationId: "auth.remove",
+        responses: {
+          200: {
+            description: "Successfully removed authentication credentials",
+            content: {
+              "application/json": {
+                schema: resolver(z.boolean()),
+              },
+            },
+          },
+          ...errors(400),
+        },
+      }),
+      validator(
+        "param",
+        z.object({
+          providerID: ProviderID.zod,
+        }),
+      ),
+      async (c) => {
+        const providerID = c.req.valid("param").providerID
+        await Auth.remove(providerID)
+        return c.json(true)
+      },
+    )
+    .get(
+      "/doc",
+      openAPIRouteHandler(app, {
+        documentation: {
+          info: {
+            title: "opencode",
+            version: "0.0.3",
+            description: "opencode api",
+          },
+          openapi: "3.1.1",
+        },
+      }),
+    )
+    .use(
+      validator(
+        "query",
+        z.object({
+          directory: z.string().optional(),
+          workspace: z.string().optional(),
+        }),
+      ),
+    )
+    .post(
+      "/log",
+      describeRoute({
+        summary: "Write log",
+        description: "Write a log entry to the server logs with specified level and metadata.",
+        operationId: "app.log",
+        responses: {
+          200: {
+            description: "Log entry written successfully",
+            content: {
+              "application/json": {
+                schema: resolver(z.boolean()),
+              },
+            },
+          },
+          ...errors(400),
+        },
+      }),
+      validator(
+        "json",
+        z.object({
+          service: z.string().meta({ description: "Service name for the log entry" }),
+          level: z.enum(["debug", "info", "error", "warn"]).meta({ description: "Log level" }),
+          message: z.string().meta({ description: "Log message" }),
+          extra: z
+            .record(z.string(), z.any())
+            .optional()
+            .meta({ description: "Additional metadata for the log entry" }),
+        }),
+      ),
+      async (c) => {
+        const { service, level, message, extra } = c.req.valid("json")
+        const logger = Log.create({ service })
+
+        switch (level) {
+          case "debug":
+            logger.debug(message, extra)
+            break
+          case "info":
+            logger.info(message, extra)
+            break
+          case "error":
+            logger.error(message, extra)
+            break
+          case "warn":
+            logger.warn(message, extra)
+            break
+        }
+
+        return c.json(true)
+      },
+    )
+}

+ 0 - 0
packages/opencode/src/server/routes/config.ts → packages/opencode/src/server/instance/config.ts


+ 0 - 0
packages/opencode/src/server/routes/event.ts → packages/opencode/src/server/instance/event.ts


+ 0 - 0
packages/opencode/src/server/routes/experimental.ts → packages/opencode/src/server/instance/experimental.ts


+ 0 - 0
packages/opencode/src/server/routes/file.ts → packages/opencode/src/server/instance/file.ts


+ 0 - 0
packages/opencode/src/server/routes/global.ts → packages/opencode/src/server/instance/global.ts


+ 24 - 80
packages/opencode/src/server/instance.ts → packages/opencode/src/server/instance/index.ts

@@ -1,53 +1,33 @@
 import { describeRoute, resolver, validator } from "hono-openapi"
 import { Hono } from "hono"
-import { proxy } from "hono/proxy"
 import type { UpgradeWebSocket } from "hono/ws"
 import z from "zod"
-import { createHash } from "node:crypto"
-import * as fs from "node:fs/promises"
-import { Log } from "../util/log"
-import { Format } from "../format"
-import { TuiRoutes } from "./routes/tui"
-import { Instance } from "../project/instance"
-import { Vcs } from "../project/vcs"
-import { Agent } from "../agent/agent"
-import { Skill } from "../skill"
-import { Global } from "../global"
-import { LSP } from "../lsp"
-import { Command } from "../command"
-import { Flag } from "../flag/flag"
-import { QuestionRoutes } from "./routes/question"
-import { PermissionRoutes } from "./routes/permission"
-import { Snapshot } from "@/snapshot"
-import { ProjectRoutes } from "./routes/project"
-import { SessionRoutes } from "./routes/session"
-import { PtyRoutes } from "./routes/pty"
-import { McpRoutes } from "./routes/mcp"
-import { FileRoutes } from "./routes/file"
-import { ConfigRoutes } from "./routes/config"
-import { ExperimentalRoutes } from "./routes/experimental"
-import { ProviderRoutes } from "./routes/provider"
-import { EventRoutes } from "./routes/event"
-import { errorHandler } from "./middleware"
-import { getMimeType } from "hono/utils/mime"
+import { Format } from "../../format"
+import { TuiRoutes } from "./tui"
+import { Instance } from "../../project/instance"
+import { Vcs } from "../../project/vcs"
+import { Agent } from "../../agent/agent"
+import { Skill } from "../../skill"
+import { Global } from "../../global"
+import { LSP } from "../../lsp"
+import { Command } from "../../command"
+import { QuestionRoutes } from "./question"
+import { PermissionRoutes } from "./permission"
+import { ProjectRoutes } from "./project"
+import { SessionRoutes } from "./session"
+import { PtyRoutes } from "./pty"
+import { McpRoutes } from "./mcp"
+import { FileRoutes } from "./file"
+import { ConfigRoutes } from "./config"
+import { ExperimentalRoutes } from "./experimental"
+import { ProviderRoutes } from "./provider"
+import { EventRoutes } from "./event"
+import { WorkspaceRouterMiddleware } from "./middleware"
 import { AppRuntime } from "@/effect/app-runtime"
 
-const log = Log.create({ service: "server" })
-
-const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
-  ? Promise.resolve(null)
-  : // @ts-expect-error - generated file at build time
-    import("opencode-web-ui.gen.ts").then((module) => module.default as Record<string, string>).catch(() => null)
-
-const DEFAULT_CSP =
-  "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:"
-
-const csp = (hash = "") =>
-  `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:`
-
-export const InstanceRoutes = (upgrade: UpgradeWebSocket, app: Hono = new Hono()) =>
-  app
-    .onError(errorHandler(log))
+export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
+  new Hono()
+    .use(WorkspaceRouterMiddleware(upgrade))
     .route("/project", ProjectRoutes())
     .route("/pty", PtyRoutes(upgrade))
     .route("/config", ConfigRoutes())
@@ -281,39 +261,3 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, app: Hono = new Hono()
         return c.json(await AppRuntime.runPromise(Format.Service.use((svc) => svc.status())))
       },
     )
-    .all("/*", async (c) => {
-      const embeddedWebUI = await embeddedUIPromise
-      const path = c.req.path
-
-      if (embeddedWebUI) {
-        const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
-        if (!match) return c.json({ error: "Not Found" }, 404)
-
-        if (await fs.exists(match)) {
-          const mime = getMimeType(match) ?? "text/plain"
-          c.header("Content-Type", mime)
-          if (mime.startsWith("text/html")) {
-            c.header("Content-Security-Policy", DEFAULT_CSP)
-          }
-          return c.body(new Uint8Array(await fs.readFile(match)))
-        } else {
-          return c.json({ error: "Not Found" }, 404)
-        }
-      } else {
-        const response = await proxy(`https://app.opencode.ai${path}`, {
-          ...c.req,
-          headers: {
-            ...c.req.raw.headers,
-            host: "app.opencode.ai",
-          },
-        })
-        const match = response.headers.get("content-type")?.includes("text/html")
-          ? (await response.clone().text()).match(
-              /<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
-            )
-          : undefined
-        const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
-        response.headers.set("Content-Security-Policy", csp(hash))
-        return response
-      }
-    })

+ 0 - 0
packages/opencode/src/server/routes/mcp.ts → packages/opencode/src/server/instance/mcp.ts


+ 6 - 10
packages/opencode/src/server/router.ts → packages/opencode/src/server/instance/middleware.ts

@@ -3,12 +3,10 @@ import type { UpgradeWebSocket } from "hono/ws"
 import { getAdaptor } from "@/control-plane/adaptors"
 import { WorkspaceID } from "@/control-plane/schema"
 import { Workspace } from "@/control-plane/workspace"
-import { ServerProxy } from "./proxy"
-import { lazy } from "@/util/lazy"
+import { ServerProxy } from "../proxy"
 import { Filesystem } from "@/util/filesystem"
 import { Instance } from "@/project/instance"
 import { InstanceBootstrap } from "@/project/bootstrap"
-import { InstanceRoutes } from "./instance"
 import { Session } from "@/session"
 import { SessionID } from "@/session/schema"
 import { WorkspaceContext } from "@/control-plane/workspace-context"
@@ -47,9 +45,7 @@ async function getSessionWorkspace(url: URL) {
 }
 
 export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler {
-  const routes = lazy(() => InstanceRoutes(upgrade))
-
-  return async (c) => {
+  return async (c, next) => {
     const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
     const directory = Filesystem.resolve(
       (() => {
@@ -72,7 +68,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
         directory,
         init: InstanceBootstrap,
         async fn() {
-          return routes().fetch(c.req.raw, c.env)
+          return next()
         },
       })
     }
@@ -87,7 +83,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
       // The lets the `DELETE /session/:id` endpoint through and we've
       // made sure that it will run without an instance
       if (url.pathname.match(/\/session\/[^/]+$/) && c.req.method === "DELETE") {
-        return routes().fetch(c.req.raw, c.env)
+        return next()
       }
 
       return new Response(`Workspace not found: ${workspaceID}`, {
@@ -109,7 +105,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
             directory: target.directory,
             init: InstanceBootstrap,
             async fn() {
-              return routes().fetch(c.req.raw, c.env)
+              return next()
             },
           }),
       })
@@ -118,7 +114,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
     if (local(c.req.method, url.pathname)) {
       // No instance provided because we are serving cached data; there
       // is no instance to work with
-      return routes().fetch(c.req.raw, c.env)
+      return next()
     }
 
     if (c.req.header("upgrade")?.toLowerCase() === "websocket") {

+ 0 - 0
packages/opencode/src/server/routes/permission.ts → packages/opencode/src/server/instance/permission.ts


+ 0 - 0
packages/opencode/src/server/routes/project.ts → packages/opencode/src/server/instance/project.ts


+ 0 - 0
packages/opencode/src/server/routes/provider.ts → packages/opencode/src/server/instance/provider.ts


+ 0 - 0
packages/opencode/src/server/routes/pty.ts → packages/opencode/src/server/instance/pty.ts


+ 0 - 0
packages/opencode/src/server/routes/question.ts → packages/opencode/src/server/instance/question.ts


+ 0 - 0
packages/opencode/src/server/routes/session.ts → packages/opencode/src/server/instance/session.ts


+ 0 - 0
packages/opencode/src/server/routes/tui.ts → packages/opencode/src/server/instance/tui.ts


+ 0 - 0
packages/opencode/src/server/routes/workspace.ts → packages/opencode/src/server/instance/workspace.ts


+ 82 - 23
packages/opencode/src/server/middleware.ts

@@ -3,31 +3,90 @@ import { NamedError } from "@opencode-ai/util/error"
 import { NotFoundError } from "../storage/db"
 import { Session } from "../session"
 import type { ContentfulStatusCode } from "hono/utils/http-status"
-import type { ErrorHandler } from "hono"
+import type { ErrorHandler, MiddlewareHandler } from "hono"
 import { HTTPException } from "hono/http-exception"
-import type { Log } from "../util/log"
+import { Log } from "../util/log"
+import { Flag } from "@/flag/flag"
+import { basicAuth } from "hono/basic-auth"
+import { cors } from "hono/cors"
+import { compress } from "hono/compress"
 
-export function errorHandler(log: Log.Logger): ErrorHandler {
-  return (err, c) => {
-    log.error("failed", {
-      error: err,
-    })
-    if (err instanceof NamedError) {
-      let status: ContentfulStatusCode
-      if (err instanceof NotFoundError) status = 404
-      else if (err instanceof Provider.ModelNotFoundError) status = 400
-      else if (err.name === "ProviderAuthValidationFailed") status = 400
-      else if (err.name.startsWith("Worktree")) status = 400
-      else status = 500
-      return c.json(err.toObject(), { status })
-    }
-    if (err instanceof Session.BusyError) {
-      return c.json(new NamedError.Unknown({ message: err.message }).toObject(), { status: 400 })
-    }
-    if (err instanceof HTTPException) return err.getResponse()
-    const message = err instanceof Error && err.stack ? err.stack : err.toString()
-    return c.json(new NamedError.Unknown({ message }).toObject(), {
-      status: 500,
+const log = Log.create({ service: "server" })
+
+export const ErrorMiddleware: ErrorHandler = (err, c) => {
+  log.error("failed", {
+    error: err,
+  })
+  if (err instanceof NamedError) {
+    let status: ContentfulStatusCode
+    if (err instanceof NotFoundError) status = 404
+    else if (err instanceof Provider.ModelNotFoundError) status = 400
+    else if (err.name === "ProviderAuthValidationFailed") status = 400
+    else if (err.name.startsWith("Worktree")) status = 400
+    else status = 500
+    return c.json(err.toObject(), { status })
+  }
+  if (err instanceof Session.BusyError) {
+    return c.json(new NamedError.Unknown({ message: err.message }).toObject(), { status: 400 })
+  }
+  if (err instanceof HTTPException) return err.getResponse()
+  const message = err instanceof Error && err.stack ? err.stack : err.toString()
+  return c.json(new NamedError.Unknown({ message }).toObject(), {
+    status: 500,
+  })
+}
+
+export const AuthMiddleware: MiddlewareHandler = (c, next) => {
+  // Allow CORS preflight requests to succeed without auth.
+  // Browser clients sending Authorization headers will preflight with OPTIONS.
+  if (c.req.method === "OPTIONS") return next()
+  const password = Flag.OPENCODE_SERVER_PASSWORD
+  if (!password) return next()
+  const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
+
+  if (c.req.query("auth_token")) c.req.raw.headers.set("authorization", `Basic ${c.req.query("auth_token")}`)
+
+  return basicAuth({ username, password })(c, next)
+}
+
+export const LoggerMiddleware: MiddlewareHandler = async (c, next) => {
+  const skip = c.req.path === "/log"
+  if (!skip) {
+    log.info("request", {
+      method: c.req.method,
+      path: c.req.path,
     })
   }
+  const timer = log.time("request", {
+    method: c.req.method,
+    path: c.req.path,
+  })
+  await next()
+  if (!skip) timer.stop()
+}
+
+export function CorsMiddleware(opts?: { cors?: string[] }): MiddlewareHandler {
+  return cors({
+    maxAge: 86_400,
+    origin(input) {
+      if (!input) return
+
+      if (input.startsWith("http://localhost:")) return input
+      if (input.startsWith("http://127.0.0.1:")) return input
+      if (input === "tauri://localhost" || input === "http://tauri.localhost" || input === "https://tauri.localhost")
+        return input
+
+      if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) return input
+      if (opts?.cors?.includes(input)) return input
+    },
+  })
+}
+
+const zipped = compress()
+export const CompressionMiddleware: MiddlewareHandler = (c, next) => {
+  const path = c.req.path
+  const method = c.req.method
+  if (path === "/event" || path === "/global/event" || path === "/global/sync-event") return next()
+  if (method === "POST" && /\/session\/[^/]+\/(message|prompt_async)$/.test(path)) return next()
+  return zipped(c, next)
 }

+ 28 - 275
packages/opencode/src/server/server.ts

@@ -1,24 +1,14 @@
-import { Log } from "../util/log"
-import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
+import { generateSpecs } from "hono-openapi"
 import { Hono } from "hono"
-import { compress } from "hono/compress"
-import { createNodeWebSocket } from "@hono/node-ws"
-import { cors } from "hono/cors"
-import { basicAuth } from "hono/basic-auth"
-import type { UpgradeWebSocket } from "hono/ws"
-import z from "zod"
-import { Auth } from "../auth"
-import { Flag } from "../flag/flag"
-import { ProviderID } from "../provider/schema"
-import { WorkspaceRouterMiddleware } from "./router"
-import { errors } from "./error"
-import { GlobalRoutes } from "./routes/global"
+import { adapter } from "#hono"
 import { MDNS } from "./mdns"
 import { lazy } from "@/util/lazy"
-import { errorHandler } from "./middleware"
+import { AuthMiddleware, CompressionMiddleware, CorsMiddleware, ErrorMiddleware, LoggerMiddleware } from "./middleware"
 import { InstanceRoutes } from "./instance"
 import { initProjectors } from "./projectors"
-import { createAdaptorServer, type ServerType } from "@hono/node-server"
+import { Log } from "@/util/log"
+import { ControlPlaneRoutes } from "./control"
+import { UIRoutes } from "./ui"
 
 // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
 globalThis.AI_SDK_LOG_WARNINGS = false
@@ -26,6 +16,8 @@ globalThis.AI_SDK_LOG_WARNINGS = false
 initProjectors()
 
 export namespace Server {
+  const log = Log.create({ service: "server" })
+
   export type Listener = {
     hostname: string
     port: number
@@ -33,231 +25,31 @@ export namespace Server {
     stop: (close?: boolean) => Promise<void>
   }
 
-  const log = Log.create({ service: "server" })
-  const zipped = compress()
-
-  const skipCompress = (path: string, method: string) => {
-    if (path === "/event" || path === "/global/event" || path === "/global/sync-event") return true
-    if (method === "POST" && /\/session\/[^/]+\/(message|prompt_async)$/.test(path)) return true
-    return false
-  }
-
   export const Default = lazy(() => create({}))
 
-  export function ControlPlaneRoutes(upgrade: UpgradeWebSocket, app = new Hono(), opts?: { cors?: string[] }): Hono {
-    return app
-      .onError(errorHandler(log))
-      .use((c, next) => {
-        // Allow CORS preflight requests to succeed without auth.
-        // Browser clients sending Authorization headers will preflight with OPTIONS.
-        if (c.req.method === "OPTIONS") return next()
-        const password = Flag.OPENCODE_SERVER_PASSWORD
-        if (!password) return next()
-        const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
-
-        if (c.req.query("auth_token")) c.req.raw.headers.set("authorization", `Basic ${c.req.query("auth_token")}`)
-
-        return basicAuth({ username, password })(c, next)
-      })
-      .use(async (c, next) => {
-        const skip = c.req.path === "/log"
-        if (!skip) {
-          log.info("request", {
-            method: c.req.method,
-            path: c.req.path,
-          })
-        }
-        const timer = log.time("request", {
-          method: c.req.method,
-          path: c.req.path,
-        })
-        await next()
-        if (!skip) timer.stop()
-      })
-      .use(
-        cors({
-          maxAge: 86_400,
-          origin(input) {
-            if (!input) return
-
-            if (input.startsWith("http://localhost:")) return input
-            if (input.startsWith("http://127.0.0.1:")) return input
-            if (
-              input === "tauri://localhost" ||
-              input === "http://tauri.localhost" ||
-              input === "https://tauri.localhost"
-            )
-              return input
-
-            if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) return input
-            if (opts?.cors?.includes(input)) return input
-          },
-        }),
-      )
-      .use((c, next) => {
-        if (skipCompress(c.req.path, c.req.method)) return next()
-        return zipped(c, next)
-      })
-      .route("/global", GlobalRoutes())
-      .put(
-        "/auth/:providerID",
-        describeRoute({
-          summary: "Set auth credentials",
-          description: "Set authentication credentials",
-          operationId: "auth.set",
-          responses: {
-            200: {
-              description: "Successfully set authentication credentials",
-              content: {
-                "application/json": {
-                  schema: resolver(z.boolean()),
-                },
-              },
-            },
-            ...errors(400),
-          },
-        }),
-        validator(
-          "param",
-          z.object({
-            providerID: ProviderID.zod,
-          }),
-        ),
-        validator("json", Auth.Info.zod),
-        async (c) => {
-          const providerID = c.req.valid("param").providerID
-          const info = c.req.valid("json")
-          await Auth.set(providerID, info)
-          return c.json(true)
-        },
-      )
-      .delete(
-        "/auth/:providerID",
-        describeRoute({
-          summary: "Remove auth credentials",
-          description: "Remove authentication credentials",
-          operationId: "auth.remove",
-          responses: {
-            200: {
-              description: "Successfully removed authentication credentials",
-              content: {
-                "application/json": {
-                  schema: resolver(z.boolean()),
-                },
-              },
-            },
-            ...errors(400),
-          },
-        }),
-        validator(
-          "param",
-          z.object({
-            providerID: ProviderID.zod,
-          }),
-        ),
-        async (c) => {
-          const providerID = c.req.valid("param").providerID
-          await Auth.remove(providerID)
-          return c.json(true)
-        },
-      )
-      .get(
-        "/doc",
-        openAPIRouteHandler(app, {
-          documentation: {
-            info: {
-              title: "opencode",
-              version: "0.0.3",
-              description: "opencode api",
-            },
-            openapi: "3.1.1",
-          },
-        }),
-      )
-      .use(
-        validator(
-          "query",
-          z.object({
-            directory: z.string().optional(),
-            workspace: z.string().optional(),
-          }),
-        ),
-      )
-      .post(
-        "/log",
-        describeRoute({
-          summary: "Write log",
-          description: "Write a log entry to the server logs with specified level and metadata.",
-          operationId: "app.log",
-          responses: {
-            200: {
-              description: "Log entry written successfully",
-              content: {
-                "application/json": {
-                  schema: resolver(z.boolean()),
-                },
-              },
-            },
-            ...errors(400),
-          },
-        }),
-        validator(
-          "json",
-          z.object({
-            service: z.string().meta({ description: "Service name for the log entry" }),
-            level: z.enum(["debug", "info", "error", "warn"]).meta({ description: "Log level" }),
-            message: z.string().meta({ description: "Log message" }),
-            extra: z
-              .record(z.string(), z.any())
-              .optional()
-              .meta({ description: "Additional metadata for the log entry" }),
-          }),
-        ),
-        async (c) => {
-          const { service, level, message, extra } = c.req.valid("json")
-          const logger = Log.create({ service })
-
-          switch (level) {
-            case "debug":
-              logger.debug(message, extra)
-              break
-            case "info":
-              logger.info(message, extra)
-              break
-            case "error":
-              logger.error(message, extra)
-              break
-            case "warn":
-              logger.warn(message, extra)
-              break
-          }
-
-          return c.json(true)
-        },
-      )
-      .use(WorkspaceRouterMiddleware(upgrade))
-  }
-
   function create(opts: { cors?: string[] }) {
     const app = new Hono()
-    const ws = createNodeWebSocket({ app })
+    const runtime = adapter.create(app)
     return {
-      app: ControlPlaneRoutes(ws.upgradeWebSocket, app, opts),
-      ws,
+      app: app
+        .onError(ErrorMiddleware)
+        .use(AuthMiddleware)
+        .use(LoggerMiddleware)
+        .use(CompressionMiddleware)
+        .use(CorsMiddleware(opts))
+        .route("/", ControlPlaneRoutes())
+        .route("/", InstanceRoutes(runtime.upgradeWebSocket))
+        .route("/", UIRoutes()),
+      runtime,
     }
   }
 
-  export function createApp(opts: { cors?: string[] }) {
-    return create(opts).app
-  }
-
   export async function openapi() {
     // Build a fresh app with all routes registered directly so
     // hono-openapi can see describeRoute metadata (`.route()` wraps
     // handlers when the sub-app has a custom errorHandler, which
     // strips the metadata symbol).
-    const { app, ws } = create({})
-    InstanceRoutes(ws.upgradeWebSocket, app)
+    const { app } = create({})
     const result = await generateSpecs(app, {
       documentation: {
         info: {
@@ -281,46 +73,21 @@ export namespace Server {
     cors?: string[]
   }): Promise<Listener> {
     const built = create(opts)
-    const start = (port: number) =>
-      new Promise<ServerType>((resolve, reject) => {
-        const server = createAdaptorServer({ fetch: built.app.fetch })
-        built.ws.injectWebSocket(server)
-        const fail = (err: Error) => {
-          cleanup()
-          reject(err)
-        }
-        const ready = () => {
-          cleanup()
-          resolve(server)
-        }
-        const cleanup = () => {
-          server.off("error", fail)
-          server.off("listening", ready)
-        }
-        server.once("error", fail)
-        server.once("listening", ready)
-        server.listen(port, opts.hostname)
-      })
-
-    const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port)
-    const addr = server.address()
-    if (!addr || typeof addr === "string") {
-      throw new Error(`Failed to resolve server address for port ${opts.port}`)
-    }
+    const server = await built.runtime.listen(opts)
 
     const next = new URL("http://localhost")
     next.hostname = opts.hostname
-    next.port = String(addr.port)
+    next.port = String(server.port)
     url = next
 
     const mdns =
       opts.mdns &&
-      addr.port &&
+      server.port &&
       opts.hostname !== "127.0.0.1" &&
       opts.hostname !== "localhost" &&
       opts.hostname !== "::1"
     if (mdns) {
-      MDNS.publish(addr.port, opts.mdnsDomain)
+      MDNS.publish(server.port, opts.mdnsDomain)
     } else if (opts.mdns) {
       log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
     }
@@ -328,27 +95,13 @@ export namespace Server {
     let closing: Promise<void> | undefined
     return {
       hostname: opts.hostname,
-      port: addr.port,
+      port: server.port,
       url: next,
       stop(close?: boolean) {
-        closing ??= new Promise((resolve, reject) => {
+        closing ??= (async () => {
           if (mdns) MDNS.unpublish()
-          server.close((err) => {
-            if (err) {
-              reject(err)
-              return
-            }
-            resolve()
-          })
-          if (close) {
-            if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") {
-              server.closeAllConnections()
-            }
-            if ("closeIdleConnections" in server && typeof server.closeIdleConnections === "function") {
-              server.closeIdleConnections()
-            }
-          }
-        })
+          await server.stop(close)
+        })()
         return closing
       },
     }

+ 55 - 0
packages/opencode/src/server/ui/index.ts

@@ -0,0 +1,55 @@
+import { Flag } from "@/flag/flag"
+import { Hono } from "hono"
+import { proxy } from "hono/proxy"
+import { getMimeType } from "hono/utils/mime"
+import { createHash } from "node:crypto"
+import fs from "node:fs/promises"
+
+const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
+  ? Promise.resolve(null)
+  : // @ts-expect-error - generated file at build time
+    import("opencode-web-ui.gen.ts").then((module) => module.default as Record<string, string>).catch(() => null)
+
+const DEFAULT_CSP =
+  "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:"
+
+const csp = (hash = "") =>
+  `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:`
+
+export const UIRoutes = (): Hono =>
+  new Hono().all("/*", async (c) => {
+    const embeddedWebUI = await embeddedUIPromise
+    const path = c.req.path
+
+    if (embeddedWebUI) {
+      const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
+      if (!match) return c.json({ error: "Not Found" }, 404)
+
+      if (await fs.exists(match)) {
+        const mime = getMimeType(match) ?? "text/plain"
+        c.header("Content-Type", mime)
+        if (mime.startsWith("text/html")) {
+          c.header("Content-Security-Policy", DEFAULT_CSP)
+        }
+        return c.body(new Uint8Array(await fs.readFile(match)))
+      } else {
+        return c.json({ error: "Not Found" }, 404)
+      }
+    } else {
+      const response = await proxy(`https://app.opencode.ai${path}`, {
+        ...c.req,
+        headers: {
+          ...c.req.raw.headers,
+          host: "app.opencode.ai",
+        },
+      })
+      const match = response.headers.get("content-type")?.includes("text/html")
+        ? (await response.clone().text()).match(
+            /<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
+          )
+        : undefined
+      const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
+      response.headers.set("Content-Security-Policy", csp(hash))
+      return response
+    }
+  })

+ 49 - 0
packages/opencode/test/memory/abort-leak-webfetch.ts

@@ -0,0 +1,49 @@
+import { abortAfterAny } from "../../src/util/abort"
+
+const MB = 1024 * 1024
+const ITERATIONS = 50
+
+const heap = () => {
+  Bun.gc(true)
+  return process.memoryUsage().heapUsed / MB
+}
+
+const server = Bun.serve({
+  port: 0,
+  fetch() {
+    return new Response("hello from local", {
+      headers: {
+        "content-type": "text/plain",
+      },
+    })
+  },
+})
+
+const url = `http://127.0.0.1:${server.port}`
+
+async function run() {
+  const { signal, clearTimeout } = abortAfterAny(30000, new AbortController().signal)
+  try {
+    const response = await fetch(url, { signal })
+    await response.text()
+  } finally {
+    clearTimeout()
+  }
+}
+
+try {
+  await run()
+  Bun.sleepSync(100)
+  const baseline = heap()
+
+  for (let i = 0; i < ITERATIONS; i++) {
+    await run()
+  }
+
+  Bun.sleepSync(100)
+  const after = heap()
+  process.stdout.write(JSON.stringify({ baseline, after, growth: after - baseline }))
+} finally {
+  server.stop(true)
+  process.exit(0)
+}

+ 127 - 0
packages/opencode/test/memory/abort-leak.test.ts

@@ -0,0 +1,127 @@
+import { describe, test, expect } from "bun:test"
+import path from "path"
+
+const projectRoot = path.join(import.meta.dir, "../..")
+const worker = path.join(import.meta.dir, "abort-leak-webfetch.ts")
+
+const MB = 1024 * 1024
+const ITERATIONS = 50
+
+const getHeapMB = () => {
+  Bun.gc(true)
+  return process.memoryUsage().heapUsed / MB
+}
+
+describe("memory: abort controller leak", () => {
+  test("webfetch does not leak memory over many invocations", async () => {
+    // Measure the abort-timed fetch path in a fresh process so shared tool
+    // runtime state does not dominate the heap signal.
+    const proc = Bun.spawn({
+      cmd: [process.execPath, worker],
+      cwd: projectRoot,
+      stdout: "pipe",
+      stderr: "pipe",
+      env: process.env,
+    })
+
+    const [code, stdout, stderr] = await Promise.all([
+      proc.exited,
+      new Response(proc.stdout).text(),
+      new Response(proc.stderr).text(),
+    ])
+
+    if (code !== 0) {
+      throw new Error(stderr.trim() || stdout.trim() || `worker exited with code ${code}`)
+    }
+
+    const result = JSON.parse(stdout.trim()) as {
+      baseline: number
+      after: number
+      growth: number
+    }
+
+    console.log(`Baseline: ${result.baseline.toFixed(2)} MB`)
+    console.log(`After ${ITERATIONS} fetches: ${result.after.toFixed(2)} MB`)
+    console.log(`Growth: ${result.growth.toFixed(2)} MB`)
+
+    // Memory growth should be minimal - less than 1MB per 10 requests.
+    expect(result.growth).toBeLessThan(ITERATIONS / 10)
+  }, 60000)
+
+  test("compare closure vs bind pattern directly", async () => {
+    const ITERATIONS = 500
+
+    // Test OLD pattern: arrow function closure
+    // Store closures in a map keyed by content to force retention
+    const closureMap = new Map<string, () => void>()
+    const timers: Timer[] = []
+    const controllers: AbortController[] = []
+
+    Bun.gc(true)
+    Bun.sleepSync(100)
+    const baseline = getHeapMB()
+
+    for (let i = 0; i < ITERATIONS; i++) {
+      // Simulate large response body like webfetch would have
+      const content = `${i}:${"x".repeat(50 * 1024)}` // 50KB unique per iteration
+      const controller = new AbortController()
+      controllers.push(controller)
+
+      // OLD pattern - closure captures `content`
+      const handler = () => {
+        // Actually use content so it can't be optimized away
+        if (content.length > 1000000000) controller.abort()
+      }
+      closureMap.set(content, handler)
+      const timeoutId = setTimeout(handler, 30000)
+      timers.push(timeoutId)
+    }
+
+    Bun.gc(true)
+    Bun.sleepSync(100)
+    const after = getHeapMB()
+    const oldGrowth = after - baseline
+
+    console.log(`OLD pattern (closure): ${oldGrowth.toFixed(2)} MB growth (${closureMap.size} closures)`)
+
+    // Cleanup after measuring
+    timers.forEach(clearTimeout)
+    controllers.forEach((c) => c.abort())
+    closureMap.clear()
+
+    // Test NEW pattern: bind
+    Bun.gc(true)
+    Bun.sleepSync(100)
+    const baseline2 = getHeapMB()
+    const handlers2: (() => void)[] = []
+    const timers2: Timer[] = []
+    const controllers2: AbortController[] = []
+
+    for (let i = 0; i < ITERATIONS; i++) {
+      const _content = `${i}:${"x".repeat(50 * 1024)}` // 50KB - won't be captured
+      const controller = new AbortController()
+      controllers2.push(controller)
+
+      // NEW pattern - bind doesn't capture surrounding scope
+      const handler = controller.abort.bind(controller)
+      handlers2.push(handler)
+      const timeoutId = setTimeout(handler, 30000)
+      timers2.push(timeoutId)
+    }
+
+    Bun.gc(true)
+    Bun.sleepSync(100)
+    const after2 = getHeapMB()
+    const newGrowth = after2 - baseline2
+
+    // Cleanup after measuring
+    timers2.forEach(clearTimeout)
+    controllers2.forEach((c) => c.abort())
+    handlers2.length = 0
+
+    console.log(`NEW pattern (bind): ${newGrowth.toFixed(2)} MB growth`)
+    console.log(`Improvement: ${(oldGrowth - newGrowth).toFixed(2)} MB saved`)
+
+    expect(newGrowth).toBeLessThanOrEqual(oldGrowth)
+  })
+})

+ 90 - 60
packages/opencode/test/plugin/loader-shared.test.ts

@@ -13,8 +13,6 @@ const { PluginLoader } = await import("../../src/plugin/loader")
 const { readPackageThemes } = await import("../../src/plugin/shared")
 const { Instance } = await import("../../src/project/instance")
 const { Npm } = await import("../../src/npm")
-const { Bus } = await import("../../src/bus")
-const { Session } = await import("../../src/session")
 
 afterAll(() => {
   if (disableDefault === undefined) {
@@ -37,27 +35,6 @@ async function load(dir: string) {
   })
 }
 
-async function errs(dir: string) {
-  return Instance.provide({
-    directory: dir,
-    fn: async () => {
-      const errors: string[] = []
-      const off = Bus.subscribe(Session.Event.Error, (evt) => {
-        const error = evt.properties.error
-        if (!error || typeof error !== "object") return
-        if (!("data" in error)) return
-        if (!error.data || typeof error.data !== "object") return
-        if (!("message" in error.data)) return
-        if (typeof error.data.message !== "string") return
-        errors.push(error.data.message)
-      })
-      await Plugin.list()
-      off()
-      return errors
-    },
-  })
-}
-
 describe("plugin.loader.shared", () => {
   test("loads a file:// plugin function export", async () => {
     await using tmp = await tmpdir({
@@ -184,14 +161,13 @@ describe("plugin.loader.shared", () => {
       },
     })
 
-    const errors = await errs(tmp.path)
+    await load(tmp.path)
     const called = await Bun.file(tmp.extra.mark)
       .text()
       .then(() => true)
       .catch(() => false)
 
     expect(called).toBe(false)
-    expect(errors.some((x) => x.includes("must export id"))).toBe(true)
   })
 
   test("rejects v1 plugin that exports server and tui together", async () => {
@@ -223,14 +199,13 @@ describe("plugin.loader.shared", () => {
       },
     })
 
-    const errors = await errs(tmp.path)
+    await load(tmp.path)
     const called = await Bun.file(tmp.extra.mark)
       .text()
       .then(() => true)
       .catch(() => false)
 
     expect(called).toBe(false)
-    expect(errors.some((x) => x.includes("either server() or tui(), not both"))).toBe(true)
   })
 
   test("resolves npm plugin specs with explicit and default versions", async () => {
@@ -383,8 +358,7 @@ describe("plugin.loader.shared", () => {
     const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
 
     try {
-      const errors = await errs(tmp.path)
-      expect(errors).toHaveLength(0)
+      await load(tmp.path)
       expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
     } finally {
       install.mockRestore()
@@ -436,8 +410,7 @@ describe("plugin.loader.shared", () => {
     const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
 
     try {
-      const errors = await errs(tmp.path)
-      expect(errors).toHaveLength(0)
+      await load(tmp.path)
       expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
     } finally {
       install.mockRestore()
@@ -482,14 +455,13 @@ describe("plugin.loader.shared", () => {
     const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
 
     try {
-      const errors = await errs(tmp.path)
+      await load(tmp.path)
       const called = await Bun.file(tmp.extra.mark)
         .text()
         .then(() => true)
         .catch(() => false)
 
       expect(called).toBe(false)
-      expect(errors).toHaveLength(0)
     } finally {
       install.mockRestore()
     }
@@ -546,13 +518,12 @@ describe("plugin.loader.shared", () => {
     const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
 
     try {
-      const errors = await errs(tmp.path)
+      await load(tmp.path)
       const called = await Bun.file(tmp.extra.mark)
         .text()
         .then(() => true)
         .catch(() => false)
       expect(called).toBe(false)
-      expect(errors.some((x) => x.includes("outside plugin directory"))).toBe(true)
     } finally {
       install.mockRestore()
     }
@@ -588,30 +559,49 @@ describe("plugin.loader.shared", () => {
     }
   })
 
-  test("publishes session.error when install fails", async () => {
+  test("skips broken plugin when install fails", async () => {
     await using tmp = await tmpdir({
       init: async (dir) => {
-        await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["[email protected]"] }, null, 2))
+        const ok = path.join(dir, "ok.ts")
+        const mark = path.join(dir, "ok.txt")
+        await Bun.write(
+          ok,
+          [
+            "export default {",
+            '  id: "demo.ok",',
+            "  server: async () => {",
+            `    await Bun.write(${JSON.stringify(mark)}, "ok")`,
+            "    return {}",
+            "  },",
+            "}",
+            "",
+          ].join("\n"),
+        )
+        await Bun.write(
+          path.join(dir, "opencode.json"),
+          JSON.stringify({ plugin: ["[email protected]", pathToFileURL(ok).href] }, null, 2),
+        )
+        return { mark }
       },
     })
 
     const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom"))
 
     try {
-      const errors = await errs(tmp.path)
-
-      expect(errors.some((x) => x.includes("Failed to install plugin [email protected]") && x.includes("boom"))).toBe(
-        true,
-      )
+      await load(tmp.path)
+      expect(install).toHaveBeenCalledWith("[email protected]")
+      expect(await Bun.file(tmp.extra.mark).text()).toBe("ok")
     } finally {
       install.mockRestore()
     }
   })
 
-  test("publishes session.error when plugin init throws", async () => {
+  test("continues loading plugins when plugin init throws", async () => {
     await using tmp = await tmpdir({
       init: async (dir) => {
         const file = pathToFileURL(path.join(dir, "throws.ts")).href
+        const ok = pathToFileURL(path.join(dir, "ok.ts")).href
+        const mark = path.join(dir, "ok.txt")
         await Bun.write(
           path.join(dir, "throws.ts"),
           [
@@ -624,51 +614,91 @@ describe("plugin.loader.shared", () => {
             "",
           ].join("\n"),
         )
+        await Bun.write(
+          path.join(dir, "ok.ts"),
+          [
+            "export default {",
+            '  id: "demo.ok",',
+            "  server: async () => {",
+            `    await Bun.write(${JSON.stringify(mark)}, "ok")`,
+            "    return {}",
+            "  },",
+            "}",
+            "",
+          ].join("\n"),
+        )
 
-        await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file] }, null, 2))
+        await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file, ok] }, null, 2))
 
-        return { file }
+        return { mark }
       },
     })
 
-    const errors = await errs(tmp.path)
-
-    expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.file}: explode`))).toBe(true)
+    await load(tmp.path)
+    expect(await Bun.file(tmp.extra.mark).text()).toBe("ok")
   })
 
-  test("publishes session.error when plugin module has invalid export", async () => {
+  test("continues loading plugins when plugin module has invalid export", async () => {
     await using tmp = await tmpdir({
       init: async (dir) => {
         const file = pathToFileURL(path.join(dir, "invalid.ts")).href
+        const ok = pathToFileURL(path.join(dir, "ok.ts")).href
+        const mark = path.join(dir, "ok.txt")
         await Bun.write(
           path.join(dir, "invalid.ts"),
           ["export default {", '  id: "demo.invalid",', "  nope: true,", "}", ""].join("\n"),
         )
+        await Bun.write(
+          path.join(dir, "ok.ts"),
+          [
+            "export default {",
+            '  id: "demo.ok",',
+            "  server: async () => {",
+            `    await Bun.write(${JSON.stringify(mark)}, "ok")`,
+            "    return {}",
+            "  },",
+            "}",
+            "",
+          ].join("\n"),
+        )
 
-        await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file] }, null, 2))
+        await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file, ok] }, null, 2))
 
-        return { file }
+        return { mark }
       },
     })
 
-    const errors = await errs(tmp.path)
-
-    expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.file}`))).toBe(true)
+    await load(tmp.path)
+    expect(await Bun.file(tmp.extra.mark).text()).toBe("ok")
   })
 
-  test("publishes session.error when plugin import fails", async () => {
+  test("continues loading plugins when plugin import fails", async () => {
     await using tmp = await tmpdir({
       init: async (dir) => {
         const missing = pathToFileURL(path.join(dir, "missing-plugin.ts")).href
-        await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [missing] }, null, 2))
+        const ok = pathToFileURL(path.join(dir, "ok.ts")).href
+        const mark = path.join(dir, "ok.txt")
+        await Bun.write(
+          path.join(dir, "ok.ts"),
+          [
+            "export default {",
+            '  id: "demo.ok",',
+            "  server: async () => {",
+            `    await Bun.write(${JSON.stringify(mark)}, "ok")`,
+            "    return {}",
+            "  },",
+            "}",
+            "",
+          ].join("\n"),
+        )
+        await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [missing, ok] }, null, 2))
 
-        return { missing }
+        return { mark }
       },
     })
 
-    const errors = await errs(tmp.path)
-
-    expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.missing}`))).toBe(true)
+    await load(tmp.path)
+    expect(await Bun.file(tmp.extra.mark).text()).toBe("ok")
   })
 
   test("loads object plugin via plugin.server", async () => {

+ 1 - 1
packages/opencode/test/server/session-messages.test.ts

@@ -147,7 +147,7 @@ describe("session messages endpoint", () => {
 
 describe("session.prompt_async error handling", () => {
   test("prompt_async route has error handler for detached prompt call", async () => {
-    const src = await Bun.file(new URL("../../src/server/routes/session.ts", import.meta.url)).text()
+    const src = await Bun.file(new URL("../../src/server/instance/session.ts", import.meta.url)).text()
     const start = src.indexOf('"/:sessionID/prompt_async"')
     const end = src.indexOf('"/:sessionID/command"', start)
     expect(start).toBeGreaterThan(-1)