From ca5f08675955ac5d10129f44afda36e006ae8d44 Mon Sep 17 00:00:00 2001 From: Dax Date: Sat, 11 Apr 2026 16:55:17 -0400 Subject: refactor(server): simplify router middleware with next() (#21720) --- packages/opencode/package.json | 5 + packages/opencode/src/agent/agent.ts | 12 +- packages/opencode/src/cli/cmd/tui/thread.ts | 10 +- packages/opencode/src/config/config.ts | 1 - packages/opencode/src/plugin/index.ts | 18 +- packages/opencode/src/server/adapter.bun.ts | 40 + packages/opencode/src/server/adapter.node.ts | 66 ++ packages/opencode/src/server/adapter.ts | 21 + packages/opencode/src/server/control/index.ts | 150 +++ packages/opencode/src/server/instance.ts | 319 ------ packages/opencode/src/server/instance/config.ts | 92 ++ packages/opencode/src/server/instance/event.ts | 83 ++ .../opencode/src/server/instance/experimental.ts | 402 ++++++++ packages/opencode/src/server/instance/file.ts | 197 ++++ packages/opencode/src/server/instance/global.ts | 332 +++++++ packages/opencode/src/server/instance/index.ts | 263 +++++ packages/opencode/src/server/instance/mcp.ts | 225 +++++ .../opencode/src/server/instance/middleware.ts | 134 +++ .../opencode/src/server/instance/permission.ts | 69 ++ packages/opencode/src/server/instance/project.ts | 118 +++ packages/opencode/src/server/instance/provider.ts | 180 ++++ packages/opencode/src/server/instance/pty.ts | 210 ++++ packages/opencode/src/server/instance/question.ts | 99 ++ packages/opencode/src/server/instance/session.ts | 1046 ++++++++++++++++++++ packages/opencode/src/server/instance/tui.ts | 379 +++++++ packages/opencode/src/server/instance/workspace.ts | 116 +++ packages/opencode/src/server/middleware.ts | 105 +- packages/opencode/src/server/router.ts | 138 --- packages/opencode/src/server/routes/config.ts | 92 -- packages/opencode/src/server/routes/event.ts | 83 -- .../opencode/src/server/routes/experimental.ts | 402 -------- packages/opencode/src/server/routes/file.ts | 197 ---- packages/opencode/src/server/routes/global.ts | 332 ------- packages/opencode/src/server/routes/mcp.ts | 225 ----- packages/opencode/src/server/routes/permission.ts | 69 -- packages/opencode/src/server/routes/project.ts | 118 --- packages/opencode/src/server/routes/provider.ts | 180 ---- packages/opencode/src/server/routes/pty.ts | 210 ---- packages/opencode/src/server/routes/question.ts | 99 -- packages/opencode/src/server/routes/session.ts | 1046 -------------------- packages/opencode/src/server/routes/tui.ts | 379 ------- packages/opencode/src/server/routes/workspace.ts | 116 --- packages/opencode/src/server/server.ts | 303 +----- packages/opencode/src/server/ui/index.ts | 55 + .../opencode/test/memory/abort-leak-webfetch.ts | 49 + packages/opencode/test/memory/abort-leak.test.ts | 127 +++ .../opencode/test/plugin/loader-shared.test.ts | 150 +-- .../opencode/test/server/session-messages.test.ts | 2 +- 48 files changed, 4682 insertions(+), 4382 deletions(-) create mode 100644 packages/opencode/src/server/adapter.bun.ts create mode 100644 packages/opencode/src/server/adapter.node.ts create mode 100644 packages/opencode/src/server/adapter.ts create mode 100644 packages/opencode/src/server/control/index.ts delete mode 100644 packages/opencode/src/server/instance.ts create mode 100644 packages/opencode/src/server/instance/config.ts create mode 100644 packages/opencode/src/server/instance/event.ts create mode 100644 packages/opencode/src/server/instance/experimental.ts create mode 100644 packages/opencode/src/server/instance/file.ts create mode 100644 packages/opencode/src/server/instance/global.ts create mode 100644 packages/opencode/src/server/instance/index.ts create mode 100644 packages/opencode/src/server/instance/mcp.ts create mode 100644 packages/opencode/src/server/instance/middleware.ts create mode 100644 packages/opencode/src/server/instance/permission.ts create mode 100644 packages/opencode/src/server/instance/project.ts create mode 100644 packages/opencode/src/server/instance/provider.ts create mode 100644 packages/opencode/src/server/instance/pty.ts create mode 100644 packages/opencode/src/server/instance/question.ts create mode 100644 packages/opencode/src/server/instance/session.ts create mode 100644 packages/opencode/src/server/instance/tui.ts create mode 100644 packages/opencode/src/server/instance/workspace.ts delete mode 100644 packages/opencode/src/server/router.ts delete mode 100644 packages/opencode/src/server/routes/config.ts delete mode 100644 packages/opencode/src/server/routes/event.ts delete mode 100644 packages/opencode/src/server/routes/experimental.ts delete mode 100644 packages/opencode/src/server/routes/file.ts delete mode 100644 packages/opencode/src/server/routes/global.ts delete mode 100644 packages/opencode/src/server/routes/mcp.ts delete mode 100644 packages/opencode/src/server/routes/permission.ts delete mode 100644 packages/opencode/src/server/routes/project.ts delete mode 100644 packages/opencode/src/server/routes/provider.ts delete mode 100644 packages/opencode/src/server/routes/pty.ts delete mode 100644 packages/opencode/src/server/routes/question.ts delete mode 100644 packages/opencode/src/server/routes/session.ts delete mode 100644 packages/opencode/src/server/routes/tui.ts delete mode 100644 packages/opencode/src/server/routes/workspace.ts create mode 100644 packages/opencode/src/server/ui/index.ts create mode 100644 packages/opencode/test/memory/abort-leak-webfetch.ts create mode 100644 packages/opencode/test/memory/abort-leak.test.ts diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 99b60d1e9..18feb4675 100644 --- a/packages/opencode/package.json +++ b/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": { diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 93b393f13..fd9ac43e8 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/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) diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 0534b147a..972e67d10 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/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(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) => { diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 086d51abd..ecce8fb8f 100644 --- a/packages/opencode/src/config/config.ts +++ b/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" diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 5de77aee3..e0478e0b3 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/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 + }), ) } diff --git a/packages/opencode/src/server/adapter.bun.ts b/packages/opencode/src/server/adapter.bun.ts new file mode 100644 index 000000000..3e70b97e8 --- /dev/null +++ b/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)) + }, + } + }, + } + }, +} diff --git a/packages/opencode/src/server/adapter.node.ts b/packages/opencode/src/server/adapter.node.ts new file mode 100644 index 000000000..9c2a41cce --- /dev/null +++ b/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((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 | 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 + }, + } + }, + } + }, +} diff --git a/packages/opencode/src/server/adapter.ts b/packages/opencode/src/server/adapter.ts new file mode 100644 index 000000000..272521d7d --- /dev/null +++ b/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 +} + +export interface Runtime { + upgradeWebSocket: UpgradeWebSocket + listen(opts: Opts): Promise +} + +export interface Adapter { + create(app: Hono): Runtime +} diff --git a/packages/opencode/src/server/control/index.ts b/packages/opencode/src/server/control/index.ts new file mode 100644 index 000000000..aae77f2f0 --- /dev/null +++ b/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) + }, + ) +} diff --git a/packages/opencode/src/server/instance.ts b/packages/opencode/src/server/instance.ts deleted file mode 100644 index 6525d2ded..000000000 --- a/packages/opencode/src/server/instance.ts +++ /dev/null @@ -1,319 +0,0 @@ -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 { 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).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)) - .route("/project", ProjectRoutes()) - .route("/pty", PtyRoutes(upgrade)) - .route("/config", ConfigRoutes()) - .route("/experimental", ExperimentalRoutes()) - .route("/session", SessionRoutes()) - .route("/permission", PermissionRoutes()) - .route("/question", QuestionRoutes()) - .route("/provider", ProviderRoutes()) - .route("/", FileRoutes()) - .route("/", EventRoutes()) - .route("/mcp", McpRoutes()) - .route("/tui", TuiRoutes()) - .post( - "/instance/dispose", - describeRoute({ - summary: "Dispose instance", - description: "Clean up and dispose the current OpenCode instance, releasing all resources.", - operationId: "instance.dispose", - responses: { - 200: { - description: "Instance disposed", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - async (c) => { - await Instance.dispose() - return c.json(true) - }, - ) - .get( - "/path", - describeRoute({ - summary: "Get paths", - description: "Retrieve the current working directory and related path information for the OpenCode instance.", - operationId: "path.get", - responses: { - 200: { - description: "Path", - content: { - "application/json": { - schema: resolver( - z - .object({ - home: z.string(), - state: z.string(), - config: z.string(), - worktree: z.string(), - directory: z.string(), - }) - .meta({ - ref: "Path", - }), - ), - }, - }, - }, - }, - }), - async (c) => { - return c.json({ - home: Global.Path.home, - state: Global.Path.state, - config: Global.Path.config, - worktree: Instance.worktree, - directory: Instance.directory, - }) - }, - ) - .get( - "/vcs", - describeRoute({ - summary: "Get VCS info", - description: "Retrieve version control system (VCS) information for the current project, such as git branch.", - operationId: "vcs.get", - responses: { - 200: { - description: "VCS info", - content: { - "application/json": { - schema: resolver(Vcs.Info), - }, - }, - }, - }, - }), - async (c) => { - const [branch, default_branch] = await Promise.all([Vcs.branch(), Vcs.defaultBranch()]) - return c.json({ - branch, - default_branch, - }) - }, - ) - .get( - "/vcs/diff", - describeRoute({ - summary: "Get VCS diff", - description: "Retrieve the current git diff for the working tree or against the default branch.", - operationId: "vcs.diff", - responses: { - 200: { - description: "VCS diff", - content: { - "application/json": { - schema: resolver(Vcs.FileDiff.array()), - }, - }, - }, - }, - }), - validator( - "query", - z.object({ - mode: Vcs.Mode, - }), - ), - async (c) => { - return c.json(await Vcs.diff(c.req.valid("query").mode)) - }, - ) - .get( - "/command", - describeRoute({ - summary: "List commands", - description: "Get a list of all available commands in the OpenCode system.", - operationId: "command.list", - responses: { - 200: { - description: "List of commands", - content: { - "application/json": { - schema: resolver(Command.Info.array()), - }, - }, - }, - }, - }), - async (c) => { - const commands = await AppRuntime.runPromise(Command.Service.use((svc) => svc.list())) - return c.json(commands) - }, - ) - .get( - "/agent", - describeRoute({ - summary: "List agents", - description: "Get a list of all available AI agents in the OpenCode system.", - operationId: "app.agents", - responses: { - 200: { - description: "List of agents", - content: { - "application/json": { - schema: resolver(Agent.Info.array()), - }, - }, - }, - }, - }), - async (c) => { - const modes = await Agent.list() - return c.json(modes) - }, - ) - .get( - "/skill", - describeRoute({ - summary: "List skills", - description: "Get a list of all available skills in the OpenCode system.", - operationId: "app.skills", - responses: { - 200: { - description: "List of skills", - content: { - "application/json": { - schema: resolver(Skill.Info.array()), - }, - }, - }, - }, - }), - async (c) => { - const skills = await Skill.all() - return c.json(skills) - }, - ) - .get( - "/lsp", - describeRoute({ - summary: "Get LSP status", - description: "Get LSP server status", - operationId: "lsp.status", - responses: { - 200: { - description: "LSP server status", - content: { - "application/json": { - schema: resolver(LSP.Status.array()), - }, - }, - }, - }, - }), - async (c) => { - return c.json(await LSP.status()) - }, - ) - .get( - "/formatter", - describeRoute({ - summary: "Get formatter status", - description: "Get formatter status", - operationId: "formatter.status", - responses: { - 200: { - description: "Formatter status", - content: { - "application/json": { - schema: resolver(Format.Status.array()), - }, - }, - }, - }, - }), - async (c) => { - 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( - /]*\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 - } - }) diff --git a/packages/opencode/src/server/instance/config.ts b/packages/opencode/src/server/instance/config.ts new file mode 100644 index 000000000..85d28f6aa --- /dev/null +++ b/packages/opencode/src/server/instance/config.ts @@ -0,0 +1,92 @@ +import { Hono } from "hono" +import { describeRoute, validator, resolver } from "hono-openapi" +import z from "zod" +import { Config } from "../../config/config" +import { Provider } from "../../provider/provider" +import { mapValues } from "remeda" +import { errors } from "../error" +import { Log } from "../../util/log" +import { lazy } from "../../util/lazy" + +const log = Log.create({ service: "server" }) + +export const ConfigRoutes = lazy(() => + new Hono() + .get( + "/", + describeRoute({ + summary: "Get configuration", + description: "Retrieve the current OpenCode configuration settings and preferences.", + operationId: "config.get", + responses: { + 200: { + description: "Get config info", + content: { + "application/json": { + schema: resolver(Config.Info), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await Config.get()) + }, + ) + .patch( + "/", + describeRoute({ + summary: "Update configuration", + description: "Update OpenCode configuration settings and preferences.", + operationId: "config.update", + responses: { + 200: { + description: "Successfully updated config", + content: { + "application/json": { + schema: resolver(Config.Info), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", Config.Info), + async (c) => { + const config = c.req.valid("json") + await Config.update(config) + return c.json(config) + }, + ) + .get( + "/providers", + describeRoute({ + summary: "List config providers", + description: "Get a list of all configured AI providers and their default models.", + operationId: "config.providers", + responses: { + 200: { + description: "List of providers", + content: { + "application/json": { + schema: resolver( + z.object({ + providers: Provider.Info.array(), + default: z.record(z.string(), z.string()), + }), + ), + }, + }, + }, + }, + }), + async (c) => { + using _ = log.time("providers") + const providers = await Provider.list().then((x) => mapValues(x, (item) => item)) + return c.json({ + providers: Object.values(providers), + default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id), + }) + }, + ), +) diff --git a/packages/opencode/src/server/instance/event.ts b/packages/opencode/src/server/instance/event.ts new file mode 100644 index 000000000..989b85771 --- /dev/null +++ b/packages/opencode/src/server/instance/event.ts @@ -0,0 +1,83 @@ +import { Hono } from "hono" +import { describeRoute, resolver } from "hono-openapi" +import { streamSSE } from "hono/streaming" +import { Log } from "@/util/log" +import { BusEvent } from "@/bus/bus-event" +import { Bus } from "@/bus" +import { AsyncQueue } from "../../util/queue" + +const log = Log.create({ service: "server" }) + +export const EventRoutes = () => + new Hono().get( + "/event", + describeRoute({ + summary: "Subscribe to events", + description: "Get events", + operationId: "event.subscribe", + responses: { + 200: { + description: "Event stream", + content: { + "text/event-stream": { + schema: resolver(BusEvent.payloads()), + }, + }, + }, + }, + }), + async (c) => { + log.info("event connected") + c.header("Cache-Control", "no-cache, no-transform") + c.header("X-Accel-Buffering", "no") + c.header("X-Content-Type-Options", "nosniff") + return streamSSE(c, async (stream) => { + const q = new AsyncQueue() + let done = false + + q.push( + JSON.stringify({ + type: "server.connected", + properties: {}, + }), + ) + + // Send heartbeat every 10s to prevent stalled proxy streams. + const heartbeat = setInterval(() => { + q.push( + JSON.stringify({ + type: "server.heartbeat", + properties: {}, + }), + ) + }, 10_000) + + const stop = () => { + if (done) return + done = true + clearInterval(heartbeat) + unsub() + q.push(null) + log.info("event disconnected") + } + + const unsub = Bus.subscribeAll((event) => { + q.push(JSON.stringify(event)) + if (event.type === Bus.InstanceDisposed.type) { + stop() + } + }) + + stream.onAbort(stop) + + try { + for await (const data of q) { + if (data === null) return + await stream.writeSSE({ data }) + } + } finally { + stop() + } + }) + }, + ) diff --git a/packages/opencode/src/server/instance/experimental.ts b/packages/opencode/src/server/instance/experimental.ts new file mode 100644 index 000000000..464617c69 --- /dev/null +++ b/packages/opencode/src/server/instance/experimental.ts @@ -0,0 +1,402 @@ +import { Hono } from "hono" +import { describeRoute, validator, resolver } from "hono-openapi" +import z from "zod" +import { ProviderID, ModelID } from "../../provider/schema" +import { ToolRegistry } from "../../tool/registry" +import { Worktree } from "../../worktree" +import { Instance } from "../../project/instance" +import { Project } from "../../project/project" +import { MCP } from "../../mcp" +import { Session } from "../../session" +import { Config } from "../../config/config" +import { ConsoleState } from "../../config/console-state" +import { Account, AccountID, OrgID } from "../../account" +import { AppRuntime } from "../../effect/app-runtime" +import { zodToJsonSchema } from "zod-to-json-schema" +import { errors } from "../error" +import { lazy } from "../../util/lazy" +import { Effect, Option } from "effect" +import { WorkspaceRoutes } from "./workspace" +import { Agent } from "@/agent/agent" + +const ConsoleOrgOption = z.object({ + accountID: z.string(), + accountEmail: z.string(), + accountUrl: z.string(), + orgID: z.string(), + orgName: z.string(), + active: z.boolean(), +}) + +const ConsoleOrgList = z.object({ + orgs: z.array(ConsoleOrgOption), +}) + +const ConsoleSwitchBody = z.object({ + accountID: z.string(), + orgID: z.string(), +}) + +export const ExperimentalRoutes = lazy(() => + new Hono() + .get( + "/console", + describeRoute({ + summary: "Get active Console provider metadata", + description: "Get the active Console org name and the set of provider IDs managed by that Console org.", + operationId: "experimental.console.get", + responses: { + 200: { + description: "Active Console provider metadata", + content: { + "application/json": { + schema: resolver(ConsoleState), + }, + }, + }, + }, + }), + async (c) => { + const result = await AppRuntime.runPromise( + Effect.gen(function* () { + const config = yield* Config.Service + const account = yield* Account.Service + const [state, groups] = yield* Effect.all([config.getConsoleState(), account.orgsByAccount()], { + concurrency: "unbounded", + }) + return { + ...state, + switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0), + } + }), + ) + return c.json(result) + }, + ) + .get( + "/console/orgs", + describeRoute({ + summary: "List switchable Console orgs", + description: "Get the available Console orgs across logged-in accounts, including the current active org.", + operationId: "experimental.console.listOrgs", + responses: { + 200: { + description: "Switchable Console orgs", + content: { + "application/json": { + schema: resolver(ConsoleOrgList), + }, + }, + }, + }, + }), + async (c) => { + const orgs = await AppRuntime.runPromise( + Effect.gen(function* () { + const account = yield* Account.Service + const [groups, active] = yield* Effect.all([account.orgsByAccount(), account.active()], { + concurrency: "unbounded", + }) + const info = Option.getOrUndefined(active) + return groups.flatMap((group) => + group.orgs.map((org) => ({ + accountID: group.account.id, + accountEmail: group.account.email, + accountUrl: group.account.url, + orgID: org.id, + orgName: org.name, + active: !!info && info.id === group.account.id && info.active_org_id === org.id, + })), + ) + }), + ) + return c.json({ orgs }) + }, + ) + .post( + "/console/switch", + describeRoute({ + summary: "Switch active Console org", + description: "Persist a new active Console account/org selection for the current local OpenCode state.", + operationId: "experimental.console.switchOrg", + responses: { + 200: { + description: "Switch success", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + validator("json", ConsoleSwitchBody), + async (c) => { + const body = c.req.valid("json") + await AppRuntime.runPromise( + Effect.gen(function* () { + const account = yield* Account.Service + yield* account.use(AccountID.make(body.accountID), Option.some(OrgID.make(body.orgID))) + }), + ) + return c.json(true) + }, + ) + .get( + "/tool/ids", + describeRoute({ + summary: "List tool IDs", + description: + "Get a list of all available tool IDs, including both built-in tools and dynamically registered tools.", + operationId: "tool.ids", + responses: { + 200: { + description: "Tool IDs", + content: { + "application/json": { + schema: resolver(z.array(z.string()).meta({ ref: "ToolIDs" })), + }, + }, + }, + ...errors(400), + }, + }), + async (c) => { + return c.json(await ToolRegistry.ids()) + }, + ) + .get( + "/tool", + describeRoute({ + summary: "List tools", + description: + "Get a list of available tools with their JSON schema parameters for a specific provider and model combination.", + operationId: "tool.list", + responses: { + 200: { + description: "Tools", + content: { + "application/json": { + schema: resolver( + z + .array( + z + .object({ + id: z.string(), + description: z.string(), + parameters: z.any(), + }) + .meta({ ref: "ToolListItem" }), + ) + .meta({ ref: "ToolList" }), + ), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "query", + z.object({ + provider: z.string(), + model: z.string(), + }), + ), + async (c) => { + const { provider, model } = c.req.valid("query") + const tools = await ToolRegistry.tools({ + providerID: ProviderID.make(provider), + modelID: ModelID.make(model), + agent: await Agent.get(await Agent.defaultAgent()), + }) + return c.json( + tools.map((t) => ({ + id: t.id, + description: t.description, + // Handle both Zod schemas and plain JSON schemas + parameters: (t.parameters as any)?._def ? zodToJsonSchema(t.parameters as any) : t.parameters, + })), + ) + }, + ) + .route("/workspace", WorkspaceRoutes()) + .post( + "/worktree", + describeRoute({ + summary: "Create worktree", + description: "Create a new git worktree for the current project and run any configured startup scripts.", + operationId: "worktree.create", + responses: { + 200: { + description: "Worktree created", + content: { + "application/json": { + schema: resolver(Worktree.Info), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", Worktree.CreateInput.optional()), + async (c) => { + const body = c.req.valid("json") + const worktree = await Worktree.create(body) + return c.json(worktree) + }, + ) + .get( + "/worktree", + describeRoute({ + summary: "List worktrees", + description: "List all sandbox worktrees for the current project.", + operationId: "worktree.list", + responses: { + 200: { + description: "List of worktree directories", + content: { + "application/json": { + schema: resolver(z.array(z.string())), + }, + }, + }, + }, + }), + async (c) => { + const sandboxes = await Project.sandboxes(Instance.project.id) + return c.json(sandboxes) + }, + ) + .delete( + "/worktree", + describeRoute({ + summary: "Remove worktree", + description: "Remove a git worktree and delete its branch.", + operationId: "worktree.remove", + responses: { + 200: { + description: "Worktree removed", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", Worktree.RemoveInput), + async (c) => { + const body = c.req.valid("json") + await Worktree.remove(body) + await Project.removeSandbox(Instance.project.id, body.directory) + return c.json(true) + }, + ) + .post( + "/worktree/reset", + describeRoute({ + summary: "Reset worktree", + description: "Reset a worktree branch to the primary default branch.", + operationId: "worktree.reset", + responses: { + 200: { + description: "Worktree reset", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", Worktree.ResetInput), + async (c) => { + const body = c.req.valid("json") + await Worktree.reset(body) + return c.json(true) + }, + ) + .get( + "/session", + describeRoute({ + summary: "List sessions", + description: + "Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.", + operationId: "experimental.session.list", + responses: { + 200: { + description: "List of sessions", + content: { + "application/json": { + schema: resolver(Session.GlobalInfo.array()), + }, + }, + }, + }, + }), + validator( + "query", + z.object({ + directory: z.string().optional().meta({ description: "Filter sessions by project directory" }), + roots: z.coerce.boolean().optional().meta({ description: "Only return root sessions (no parentID)" }), + start: z.coerce + .number() + .optional() + .meta({ description: "Filter sessions updated on or after this timestamp (milliseconds since epoch)" }), + cursor: z.coerce + .number() + .optional() + .meta({ description: "Return sessions updated before this timestamp (milliseconds since epoch)" }), + search: z.string().optional().meta({ description: "Filter sessions by title (case-insensitive)" }), + limit: z.coerce.number().optional().meta({ description: "Maximum number of sessions to return" }), + archived: z.coerce.boolean().optional().meta({ description: "Include archived sessions (default false)" }), + }), + ), + async (c) => { + const query = c.req.valid("query") + const limit = query.limit ?? 100 + const sessions: Session.GlobalInfo[] = [] + for await (const session of Session.listGlobal({ + directory: query.directory, + roots: query.roots, + start: query.start, + cursor: query.cursor, + search: query.search, + limit: limit + 1, + archived: query.archived, + })) { + sessions.push(session) + } + const hasMore = sessions.length > limit + const list = hasMore ? sessions.slice(0, limit) : sessions + if (hasMore && list.length > 0) { + c.header("x-next-cursor", String(list[list.length - 1].time.updated)) + } + return c.json(list) + }, + ) + .get( + "/resource", + describeRoute({ + summary: "Get MCP resources", + description: "Get all available MCP resources from connected servers. Optionally filter by name.", + operationId: "experimental.resource.list", + responses: { + 200: { + description: "MCP resources", + content: { + "application/json": { + schema: resolver(z.record(z.string(), MCP.Resource)), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await MCP.resources()) + }, + ), +) diff --git a/packages/opencode/src/server/instance/file.ts b/packages/opencode/src/server/instance/file.ts new file mode 100644 index 000000000..60789ef4b --- /dev/null +++ b/packages/opencode/src/server/instance/file.ts @@ -0,0 +1,197 @@ +import { Hono } from "hono" +import { describeRoute, validator, resolver } from "hono-openapi" +import z from "zod" +import { File } from "../../file" +import { Ripgrep } from "../../file/ripgrep" +import { LSP } from "../../lsp" +import { Instance } from "../../project/instance" +import { lazy } from "../../util/lazy" + +export const FileRoutes = lazy(() => + new Hono() + .get( + "/find", + describeRoute({ + summary: "Find text", + description: "Search for text patterns across files in the project using ripgrep.", + operationId: "find.text", + responses: { + 200: { + description: "Matches", + content: { + "application/json": { + schema: resolver(Ripgrep.Match.shape.data.array()), + }, + }, + }, + }, + }), + validator( + "query", + z.object({ + pattern: z.string(), + }), + ), + async (c) => { + const pattern = c.req.valid("query").pattern + const result = await Ripgrep.search({ + cwd: Instance.directory, + pattern, + limit: 10, + }) + return c.json(result) + }, + ) + .get( + "/find/file", + describeRoute({ + summary: "Find files", + description: "Search for files or directories by name or pattern in the project directory.", + operationId: "find.files", + responses: { + 200: { + description: "File paths", + content: { + "application/json": { + schema: resolver(z.string().array()), + }, + }, + }, + }, + }), + validator( + "query", + z.object({ + query: z.string(), + dirs: z.enum(["true", "false"]).optional(), + type: z.enum(["file", "directory"]).optional(), + limit: z.coerce.number().int().min(1).max(200).optional(), + }), + ), + async (c) => { + const query = c.req.valid("query").query + const dirs = c.req.valid("query").dirs + const type = c.req.valid("query").type + const limit = c.req.valid("query").limit + const results = await File.search({ + query, + limit: limit ?? 10, + dirs: dirs !== "false", + type, + }) + return c.json(results) + }, + ) + .get( + "/find/symbol", + describeRoute({ + summary: "Find symbols", + description: "Search for workspace symbols like functions, classes, and variables using LSP.", + operationId: "find.symbols", + responses: { + 200: { + description: "Symbols", + content: { + "application/json": { + schema: resolver(LSP.Symbol.array()), + }, + }, + }, + }, + }), + validator( + "query", + z.object({ + query: z.string(), + }), + ), + async (c) => { + /* + const query = c.req.valid("query").query + const result = await LSP.workspaceSymbol(query) + return c.json(result) + */ + return c.json([]) + }, + ) + .get( + "/file", + describeRoute({ + summary: "List files", + description: "List files and directories in a specified path.", + operationId: "file.list", + responses: { + 200: { + description: "Files and directories", + content: { + "application/json": { + schema: resolver(File.Node.array()), + }, + }, + }, + }, + }), + validator( + "query", + z.object({ + path: z.string(), + }), + ), + async (c) => { + const path = c.req.valid("query").path + const content = await File.list(path) + return c.json(content) + }, + ) + .get( + "/file/content", + describeRoute({ + summary: "Read file", + description: "Read the content of a specified file.", + operationId: "file.read", + responses: { + 200: { + description: "File content", + content: { + "application/json": { + schema: resolver(File.Content), + }, + }, + }, + }, + }), + validator( + "query", + z.object({ + path: z.string(), + }), + ), + async (c) => { + const path = c.req.valid("query").path + const content = await File.read(path) + return c.json(content) + }, + ) + .get( + "/file/status", + describeRoute({ + summary: "Get file status", + description: "Get the git status of all files in the project.", + operationId: "file.status", + responses: { + 200: { + description: "File status", + content: { + "application/json": { + schema: resolver(File.Info.array()), + }, + }, + }, + }, + }), + async (c) => { + const content = await File.status() + return c.json(content) + }, + ), +) diff --git a/packages/opencode/src/server/instance/global.ts b/packages/opencode/src/server/instance/global.ts new file mode 100644 index 000000000..6b0a9a164 --- /dev/null +++ b/packages/opencode/src/server/instance/global.ts @@ -0,0 +1,332 @@ +import { Hono, type Context } from "hono" +import { describeRoute, resolver, validator } from "hono-openapi" +import { streamSSE } from "hono/streaming" +import { Effect } from "effect" +import z from "zod" +import { BusEvent } from "@/bus/bus-event" +import { SyncEvent } from "@/sync" +import { GlobalBus } from "@/bus/global" +import { AppRuntime } from "@/effect/app-runtime" +import { AsyncQueue } from "@/util/queue" +import { Instance } from "../../project/instance" +import { Installation } from "@/installation" +import { Log } from "../../util/log" +import { lazy } from "../../util/lazy" +import { Config } from "../../config/config" +import { errors } from "../error" + +const log = Log.create({ service: "server" }) + +export const GlobalDisposedEvent = BusEvent.define("global.disposed", z.object({})) + +async function streamEvents(c: Context, subscribe: (q: AsyncQueue) => () => void) { + return streamSSE(c, async (stream) => { + const q = new AsyncQueue() + let done = false + + q.push( + JSON.stringify({ + payload: { + type: "server.connected", + properties: {}, + }, + }), + ) + + // Send heartbeat every 10s to prevent stalled proxy streams. + const heartbeat = setInterval(() => { + q.push( + JSON.stringify({ + payload: { + type: "server.heartbeat", + properties: {}, + }, + }), + ) + }, 10_000) + + const stop = () => { + if (done) return + done = true + clearInterval(heartbeat) + unsub() + q.push(null) + log.info("global event disconnected") + } + + const unsub = subscribe(q) + + stream.onAbort(stop) + + try { + for await (const data of q) { + if (data === null) return + await stream.writeSSE({ data }) + } + } finally { + stop() + } + }) +} + +export const GlobalRoutes = lazy(() => + new Hono() + .get( + "/health", + describeRoute({ + summary: "Get health", + description: "Get health information about the OpenCode server.", + operationId: "global.health", + responses: { + 200: { + description: "Health information", + content: { + "application/json": { + schema: resolver(z.object({ healthy: z.literal(true), version: z.string() })), + }, + }, + }, + }, + }), + async (c) => { + return c.json({ healthy: true, version: Installation.VERSION }) + }, + ) + .get( + "/event", + describeRoute({ + summary: "Get global events", + description: "Subscribe to global events from the OpenCode system using server-sent events.", + operationId: "global.event", + responses: { + 200: { + description: "Event stream", + content: { + "text/event-stream": { + schema: resolver( + z + .object({ + directory: z.string(), + project: z.string().optional(), + workspace: z.string().optional(), + payload: BusEvent.payloads(), + }) + .meta({ + ref: "GlobalEvent", + }), + ), + }, + }, + }, + }, + }), + async (c) => { + log.info("global event connected") + c.header("Cache-Control", "no-cache, no-transform") + c.header("X-Accel-Buffering", "no") + c.header("X-Content-Type-Options", "nosniff") + + return streamEvents(c, (q) => { + async function handler(event: any) { + q.push(JSON.stringify(event)) + } + GlobalBus.on("event", handler) + return () => GlobalBus.off("event", handler) + }) + }, + ) + .get( + "/sync-event", + describeRoute({ + summary: "Subscribe to global sync events", + description: "Get global sync events", + operationId: "global.sync-event.subscribe", + responses: { + 200: { + description: "Event stream", + content: { + "text/event-stream": { + schema: resolver( + z + .object({ + payload: SyncEvent.payloads(), + }) + .meta({ + ref: "SyncEvent", + }), + ), + }, + }, + }, + }, + }), + async (c) => { + log.info("global sync event connected") + c.header("Cache-Control", "no-cache, no-transform") + c.header("X-Accel-Buffering", "no") + c.header("X-Content-Type-Options", "nosniff") + return streamEvents(c, (q) => { + return SyncEvent.subscribeAll(({ def, event }) => { + // TODO: don't pass def, just pass the type (and it should + // be versioned) + q.push( + JSON.stringify({ + payload: { + ...event, + type: SyncEvent.versionedType(def.type, def.version), + }, + }), + ) + }) + }) + }, + ) + .get( + "/config", + describeRoute({ + summary: "Get global configuration", + description: "Retrieve the current global OpenCode configuration settings and preferences.", + operationId: "global.config.get", + responses: { + 200: { + description: "Get global config info", + content: { + "application/json": { + schema: resolver(Config.Info), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await Config.getGlobal()) + }, + ) + .patch( + "/config", + describeRoute({ + summary: "Update global configuration", + description: "Update global OpenCode configuration settings and preferences.", + operationId: "global.config.update", + responses: { + 200: { + description: "Successfully updated global config", + content: { + "application/json": { + schema: resolver(Config.Info), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", Config.Info), + async (c) => { + const config = c.req.valid("json") + const next = await Config.updateGlobal(config) + return c.json(next) + }, + ) + .post( + "/dispose", + describeRoute({ + summary: "Dispose instance", + description: "Clean up and dispose all OpenCode instances, releasing all resources.", + operationId: "global.dispose", + responses: { + 200: { + description: "Global disposed", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + async (c) => { + await Instance.disposeAll() + GlobalBus.emit("event", { + directory: "global", + payload: { + type: GlobalDisposedEvent.type, + properties: {}, + }, + }) + return c.json(true) + }, + ) + .post( + "/upgrade", + describeRoute({ + summary: "Upgrade opencode", + description: "Upgrade opencode to the specified version or latest if not specified.", + operationId: "global.upgrade", + responses: { + 200: { + description: "Upgrade result", + content: { + "application/json": { + schema: resolver( + z.union([ + z.object({ + success: z.literal(true), + version: z.string(), + }), + z.object({ + success: z.literal(false), + error: z.string(), + }), + ]), + ), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "json", + z.object({ + target: z.string().optional(), + }), + ), + async (c) => { + const result = await AppRuntime.runPromise( + Installation.Service.use((svc) => + Effect.gen(function* () { + const method = yield* svc.method() + if (method === "unknown") { + return { success: false as const, status: 400 as const, error: "Unknown installation method" } + } + + const target = c.req.valid("json").target || (yield* svc.latest(method)) + const result = yield* Effect.catch( + svc.upgrade(method, target).pipe(Effect.as({ success: true as const, version: target })), + (err) => + Effect.succeed({ + success: false as const, + status: 500 as const, + error: err instanceof Error ? err.message : String(err), + }), + ) + if (!result.success) return result + return { ...result, status: 200 as const } + }), + ), + ) + if (!result.success) { + return c.json({ success: false, error: result.error }, result.status) + } + const target = result.version + GlobalBus.emit("event", { + directory: "global", + payload: { + type: Installation.Event.Updated.type, + properties: { version: target }, + }, + }) + return c.json({ success: true, version: target }) + }, + ), +) diff --git a/packages/opencode/src/server/instance/index.ts b/packages/opencode/src/server/instance/index.ts new file mode 100644 index 000000000..2acc424e4 --- /dev/null +++ b/packages/opencode/src/server/instance/index.ts @@ -0,0 +1,263 @@ +import { describeRoute, resolver, validator } from "hono-openapi" +import { Hono } from "hono" +import type { UpgradeWebSocket } from "hono/ws" +import z from "zod" +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" + +export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => + new Hono() + .use(WorkspaceRouterMiddleware(upgrade)) + .route("/project", ProjectRoutes()) + .route("/pty", PtyRoutes(upgrade)) + .route("/config", ConfigRoutes()) + .route("/experimental", ExperimentalRoutes()) + .route("/session", SessionRoutes()) + .route("/permission", PermissionRoutes()) + .route("/question", QuestionRoutes()) + .route("/provider", ProviderRoutes()) + .route("/", FileRoutes()) + .route("/", EventRoutes()) + .route("/mcp", McpRoutes()) + .route("/tui", TuiRoutes()) + .post( + "/instance/dispose", + describeRoute({ + summary: "Dispose instance", + description: "Clean up and dispose the current OpenCode instance, releasing all resources.", + operationId: "instance.dispose", + responses: { + 200: { + description: "Instance disposed", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + async (c) => { + await Instance.dispose() + return c.json(true) + }, + ) + .get( + "/path", + describeRoute({ + summary: "Get paths", + description: "Retrieve the current working directory and related path information for the OpenCode instance.", + operationId: "path.get", + responses: { + 200: { + description: "Path", + content: { + "application/json": { + schema: resolver( + z + .object({ + home: z.string(), + state: z.string(), + config: z.string(), + worktree: z.string(), + directory: z.string(), + }) + .meta({ + ref: "Path", + }), + ), + }, + }, + }, + }, + }), + async (c) => { + return c.json({ + home: Global.Path.home, + state: Global.Path.state, + config: Global.Path.config, + worktree: Instance.worktree, + directory: Instance.directory, + }) + }, + ) + .get( + "/vcs", + describeRoute({ + summary: "Get VCS info", + description: "Retrieve version control system (VCS) information for the current project, such as git branch.", + operationId: "vcs.get", + responses: { + 200: { + description: "VCS info", + content: { + "application/json": { + schema: resolver(Vcs.Info), + }, + }, + }, + }, + }), + async (c) => { + const [branch, default_branch] = await Promise.all([Vcs.branch(), Vcs.defaultBranch()]) + return c.json({ + branch, + default_branch, + }) + }, + ) + .get( + "/vcs/diff", + describeRoute({ + summary: "Get VCS diff", + description: "Retrieve the current git diff for the working tree or against the default branch.", + operationId: "vcs.diff", + responses: { + 200: { + description: "VCS diff", + content: { + "application/json": { + schema: resolver(Vcs.FileDiff.array()), + }, + }, + }, + }, + }), + validator( + "query", + z.object({ + mode: Vcs.Mode, + }), + ), + async (c) => { + return c.json(await Vcs.diff(c.req.valid("query").mode)) + }, + ) + .get( + "/command", + describeRoute({ + summary: "List commands", + description: "Get a list of all available commands in the OpenCode system.", + operationId: "command.list", + responses: { + 200: { + description: "List of commands", + content: { + "application/json": { + schema: resolver(Command.Info.array()), + }, + }, + }, + }, + }), + async (c) => { + const commands = await AppRuntime.runPromise(Command.Service.use((svc) => svc.list())) + return c.json(commands) + }, + ) + .get( + "/agent", + describeRoute({ + summary: "List agents", + description: "Get a list of all available AI agents in the OpenCode system.", + operationId: "app.agents", + responses: { + 200: { + description: "List of agents", + content: { + "application/json": { + schema: resolver(Agent.Info.array()), + }, + }, + }, + }, + }), + async (c) => { + const modes = await Agent.list() + return c.json(modes) + }, + ) + .get( + "/skill", + describeRoute({ + summary: "List skills", + description: "Get a list of all available skills in the OpenCode system.", + operationId: "app.skills", + responses: { + 200: { + description: "List of skills", + content: { + "application/json": { + schema: resolver(Skill.Info.array()), + }, + }, + }, + }, + }), + async (c) => { + const skills = await Skill.all() + return c.json(skills) + }, + ) + .get( + "/lsp", + describeRoute({ + summary: "Get LSP status", + description: "Get LSP server status", + operationId: "lsp.status", + responses: { + 200: { + description: "LSP server status", + content: { + "application/json": { + schema: resolver(LSP.Status.array()), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await LSP.status()) + }, + ) + .get( + "/formatter", + describeRoute({ + summary: "Get formatter status", + description: "Get formatter status", + operationId: "formatter.status", + responses: { + 200: { + description: "Formatter status", + content: { + "application/json": { + schema: resolver(Format.Status.array()), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await AppRuntime.runPromise(Format.Service.use((svc) => svc.status()))) + }, + ) diff --git a/packages/opencode/src/server/instance/mcp.ts b/packages/opencode/src/server/instance/mcp.ts new file mode 100644 index 000000000..1e604c991 --- /dev/null +++ b/packages/opencode/src/server/instance/mcp.ts @@ -0,0 +1,225 @@ +import { Hono } from "hono" +import { describeRoute, validator, resolver } from "hono-openapi" +import z from "zod" +import { MCP } from "../../mcp" +import { Config } from "../../config/config" +import { errors } from "../error" +import { lazy } from "../../util/lazy" + +export const McpRoutes = lazy(() => + new Hono() + .get( + "/", + describeRoute({ + summary: "Get MCP status", + description: "Get the status of all Model Context Protocol (MCP) servers.", + operationId: "mcp.status", + responses: { + 200: { + description: "MCP server status", + content: { + "application/json": { + schema: resolver(z.record(z.string(), MCP.Status)), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await MCP.status()) + }, + ) + .post( + "/", + describeRoute({ + summary: "Add MCP server", + description: "Dynamically add a new Model Context Protocol (MCP) server to the system.", + operationId: "mcp.add", + responses: { + 200: { + description: "MCP server added successfully", + content: { + "application/json": { + schema: resolver(z.record(z.string(), MCP.Status)), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "json", + z.object({ + name: z.string(), + config: Config.Mcp, + }), + ), + async (c) => { + const { name, config } = c.req.valid("json") + const result = await MCP.add(name, config) + return c.json(result.status) + }, + ) + .post( + "/:name/auth", + describeRoute({ + summary: "Start MCP OAuth", + description: "Start OAuth authentication flow for a Model Context Protocol (MCP) server.", + operationId: "mcp.auth.start", + responses: { + 200: { + description: "OAuth flow started", + content: { + "application/json": { + schema: resolver( + z.object({ + authorizationUrl: z.string().describe("URL to open in browser for authorization"), + }), + ), + }, + }, + }, + ...errors(400, 404), + }, + }), + async (c) => { + const name = c.req.param("name") + const supportsOAuth = await MCP.supportsOAuth(name) + if (!supportsOAuth) { + return c.json({ error: `MCP server ${name} does not support OAuth` }, 400) + } + const result = await MCP.startAuth(name) + return c.json(result) + }, + ) + .post( + "/:name/auth/callback", + describeRoute({ + summary: "Complete MCP OAuth", + description: + "Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code.", + operationId: "mcp.auth.callback", + responses: { + 200: { + description: "OAuth authentication completed", + content: { + "application/json": { + schema: resolver(MCP.Status), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "json", + z.object({ + code: z.string().describe("Authorization code from OAuth callback"), + }), + ), + async (c) => { + const name = c.req.param("name") + const { code } = c.req.valid("json") + const status = await MCP.finishAuth(name, code) + return c.json(status) + }, + ) + .post( + "/:name/auth/authenticate", + describeRoute({ + summary: "Authenticate MCP OAuth", + description: "Start OAuth flow and wait for callback (opens browser)", + operationId: "mcp.auth.authenticate", + responses: { + 200: { + description: "OAuth authentication completed", + content: { + "application/json": { + schema: resolver(MCP.Status), + }, + }, + }, + ...errors(400, 404), + }, + }), + async (c) => { + const name = c.req.param("name") + const supportsOAuth = await MCP.supportsOAuth(name) + if (!supportsOAuth) { + return c.json({ error: `MCP server ${name} does not support OAuth` }, 400) + } + const status = await MCP.authenticate(name) + return c.json(status) + }, + ) + .delete( + "/:name/auth", + describeRoute({ + summary: "Remove MCP OAuth", + description: "Remove OAuth credentials for an MCP server", + operationId: "mcp.auth.remove", + responses: { + 200: { + description: "OAuth credentials removed", + content: { + "application/json": { + schema: resolver(z.object({ success: z.literal(true) })), + }, + }, + }, + ...errors(404), + }, + }), + async (c) => { + const name = c.req.param("name") + await MCP.removeAuth(name) + return c.json({ success: true as const }) + }, + ) + .post( + "/:name/connect", + describeRoute({ + description: "Connect an MCP server", + operationId: "mcp.connect", + responses: { + 200: { + description: "MCP server connected successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + validator("param", z.object({ name: z.string() })), + async (c) => { + const { name } = c.req.valid("param") + await MCP.connect(name) + return c.json(true) + }, + ) + .post( + "/:name/disconnect", + describeRoute({ + description: "Disconnect an MCP server", + operationId: "mcp.disconnect", + responses: { + 200: { + description: "MCP server disconnected successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + validator("param", z.object({ name: z.string() })), + async (c) => { + const { name } = c.req.valid("param") + await MCP.disconnect(name) + return c.json(true) + }, + ), +) diff --git a/packages/opencode/src/server/instance/middleware.ts b/packages/opencode/src/server/instance/middleware.ts new file mode 100644 index 000000000..1a5011477 --- /dev/null +++ b/packages/opencode/src/server/instance/middleware.ts @@ -0,0 +1,134 @@ +import type { MiddlewareHandler } from "hono" +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 { Filesystem } from "@/util/filesystem" +import { Instance } from "@/project/instance" +import { InstanceBootstrap } from "@/project/bootstrap" +import { Session } from "@/session" +import { SessionID } from "@/session/schema" +import { WorkspaceContext } from "@/control-plane/workspace-context" + +type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" } + +const RULES: Array = [ + { path: "/session/status", action: "forward" }, + { method: "GET", path: "/session", action: "local" }, +] + +function local(method: string, path: string) { + for (const rule of RULES) { + if (rule.method && rule.method !== method) continue + const match = rule.exact ? path === rule.path : path === rule.path || path.startsWith(rule.path + "/") + if (match) return rule.action === "local" + } + return false +} + +function getSessionID(url: URL) { + if (url.pathname === "/session/status") return null + + const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1] + if (!id) return null + + return SessionID.make(id) +} + +async function getSessionWorkspace(url: URL) { + const id = getSessionID(url) + if (!id) return null + + const session = await Session.get(id).catch(() => undefined) + return session?.workspaceID +} + +export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler { + return async (c, next) => { + const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() + const directory = Filesystem.resolve( + (() => { + try { + return decodeURIComponent(raw) + } catch { + return raw + } + })(), + ) + + const url = new URL(c.req.url) + + const sessionWorkspaceID = await getSessionWorkspace(url) + const workspaceID = sessionWorkspaceID || url.searchParams.get("workspace") + + // If no workspace is provided we use the project + if (!workspaceID) { + return Instance.provide({ + directory, + init: InstanceBootstrap, + async fn() { + return next() + }, + }) + } + + const workspace = await Workspace.get(WorkspaceID.make(workspaceID)) + + if (!workspace) { + // Special-case deleting a session in case user's data in a + // weird state. Allow them to forcefully delete a synced session + // even if the remote workspace is not in their data. + // + // 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 next() + } + + return new Response(`Workspace not found: ${workspaceID}`, { + status: 500, + headers: { + "content-type": "text/plain; charset=utf-8", + }, + }) + } + + const adaptor = await getAdaptor(workspace.type) + const target = await adaptor.target(workspace) + + if (target.type === "local") { + return WorkspaceContext.provide({ + workspaceID: WorkspaceID.make(workspaceID), + fn: () => + Instance.provide({ + directory: target.directory, + init: InstanceBootstrap, + async fn() { + return next() + }, + }), + }) + } + + if (local(c.req.method, url.pathname)) { + // No instance provided because we are serving cached data; there + // is no instance to work with + return next() + } + + if (c.req.header("upgrade")?.toLowerCase() === "websocket") { + return ServerProxy.websocket(upgrade, target, c.req.raw, c.env) + } + + const headers = new Headers(c.req.raw.headers) + headers.delete("x-opencode-workspace") + + return ServerProxy.http( + target, + new Request(c.req.raw, { + headers, + }), + ) + } +} diff --git a/packages/opencode/src/server/instance/permission.ts b/packages/opencode/src/server/instance/permission.ts new file mode 100644 index 000000000..aae9a9c3a --- /dev/null +++ b/packages/opencode/src/server/instance/permission.ts @@ -0,0 +1,69 @@ +import { Hono } from "hono" +import { describeRoute, validator, resolver } from "hono-openapi" +import z from "zod" +import { Permission } from "@/permission" +import { PermissionID } from "@/permission/schema" +import { errors } from "../error" +import { lazy } from "../../util/lazy" + +export const PermissionRoutes = lazy(() => + new Hono() + .post( + "/:requestID/reply", + describeRoute({ + summary: "Respond to permission request", + description: "Approve or deny a permission request from the AI assistant.", + operationId: "permission.reply", + responses: { + 200: { + description: "Permission processed successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + requestID: PermissionID.zod, + }), + ), + validator("json", z.object({ reply: Permission.Reply, message: z.string().optional() })), + async (c) => { + const params = c.req.valid("param") + const json = c.req.valid("json") + await Permission.reply({ + requestID: params.requestID, + reply: json.reply, + message: json.message, + }) + return c.json(true) + }, + ) + .get( + "/", + describeRoute({ + summary: "List pending permissions", + description: "Get all pending permission requests across all sessions.", + operationId: "permission.list", + responses: { + 200: { + description: "List of pending permissions", + content: { + "application/json": { + schema: resolver(Permission.Request.array()), + }, + }, + }, + }, + }), + async (c) => { + const permissions = await Permission.list() + return c.json(permissions) + }, + ), +) diff --git a/packages/opencode/src/server/instance/project.ts b/packages/opencode/src/server/instance/project.ts new file mode 100644 index 000000000..e5dd5782d --- /dev/null +++ b/packages/opencode/src/server/instance/project.ts @@ -0,0 +1,118 @@ +import { Hono } from "hono" +import { describeRoute, validator } from "hono-openapi" +import { resolver } from "hono-openapi" +import { Instance } from "../../project/instance" +import { Project } from "../../project/project" +import z from "zod" +import { ProjectID } from "../../project/schema" +import { errors } from "../error" +import { lazy } from "../../util/lazy" +import { InstanceBootstrap } from "../../project/bootstrap" + +export const ProjectRoutes = lazy(() => + new Hono() + .get( + "/", + describeRoute({ + summary: "List all projects", + description: "Get a list of projects that have been opened with OpenCode.", + operationId: "project.list", + responses: { + 200: { + description: "List of projects", + content: { + "application/json": { + schema: resolver(Project.Info.array()), + }, + }, + }, + }, + }), + async (c) => { + const projects = Project.list() + return c.json(projects) + }, + ) + .get( + "/current", + describeRoute({ + summary: "Get current project", + description: "Retrieve the currently active project that OpenCode is working with.", + operationId: "project.current", + responses: { + 200: { + description: "Current project information", + content: { + "application/json": { + schema: resolver(Project.Info), + }, + }, + }, + }, + }), + async (c) => { + return c.json(Instance.project) + }, + ) + .post( + "/git/init", + describeRoute({ + summary: "Initialize git repository", + description: "Create a git repository for the current project and return the refreshed project info.", + operationId: "project.initGit", + responses: { + 200: { + description: "Project information after git initialization", + content: { + "application/json": { + schema: resolver(Project.Info), + }, + }, + }, + }, + }), + async (c) => { + const dir = Instance.directory + const prev = Instance.project + const next = await Project.initGit({ + directory: dir, + project: prev, + }) + if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next) + await Instance.reload({ + directory: dir, + worktree: dir, + project: next, + init: InstanceBootstrap, + }) + return c.json(next) + }, + ) + .patch( + "/:projectID", + describeRoute({ + summary: "Update project", + description: "Update project properties such as name, icon, and commands.", + operationId: "project.update", + responses: { + 200: { + description: "Updated project information", + content: { + "application/json": { + schema: resolver(Project.Info), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator("param", z.object({ projectID: ProjectID.zod })), + validator("json", Project.UpdateInput.omit({ projectID: true })), + async (c) => { + const projectID = c.req.valid("param").projectID + const body = c.req.valid("json") + const project = await Project.update({ ...body, projectID }) + return c.json(project) + }, + ), +) diff --git a/packages/opencode/src/server/instance/provider.ts b/packages/opencode/src/server/instance/provider.ts new file mode 100644 index 000000000..efd126ea0 --- /dev/null +++ b/packages/opencode/src/server/instance/provider.ts @@ -0,0 +1,180 @@ +import { Hono } from "hono" +import { describeRoute, validator, resolver } from "hono-openapi" +import z from "zod" +import { Config } from "../../config/config" +import { Provider } from "../../provider/provider" +import { ModelsDev } from "../../provider/models" +import { ProviderAuth } from "../../provider/auth" +import { ProviderID } from "../../provider/schema" +import { AppRuntime } from "../../effect/app-runtime" +import { mapValues } from "remeda" +import { errors } from "../error" +import { lazy } from "../../util/lazy" +import { Log } from "../../util/log" + +const log = Log.create({ service: "server" }) + +export const ProviderRoutes = lazy(() => + new Hono() + .get( + "/", + describeRoute({ + summary: "List providers", + description: "Get a list of all available AI providers, including both available and connected ones.", + operationId: "provider.list", + responses: { + 200: { + description: "List of providers", + content: { + "application/json": { + schema: resolver( + z.object({ + all: Provider.Info.array(), + default: z.record(z.string(), z.string()), + connected: z.array(z.string()), + }), + ), + }, + }, + }, + }, + }), + async (c) => { + const config = await Config.get() + const disabled = new Set(config.disabled_providers ?? []) + const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined + + const allProviders = await ModelsDev.get() + const filteredProviders: Record = {} + for (const [key, value] of Object.entries(allProviders)) { + if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { + filteredProviders[key] = value + } + } + + const connected = await Provider.list() + const providers = Object.assign( + mapValues(filteredProviders, (x) => Provider.fromModelsDevProvider(x)), + connected, + ) + return c.json({ + all: Object.values(providers), + default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id), + connected: Object.keys(connected), + }) + }, + ) + .get( + "/auth", + describeRoute({ + summary: "Get provider auth methods", + description: "Retrieve available authentication methods for all AI providers.", + operationId: "provider.auth", + responses: { + 200: { + description: "Provider auth methods", + content: { + "application/json": { + schema: resolver(z.record(z.string(), z.array(ProviderAuth.Method))), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await AppRuntime.runPromise(ProviderAuth.Service.use((svc) => svc.methods()))) + }, + ) + .post( + "/:providerID/oauth/authorize", + describeRoute({ + summary: "OAuth authorize", + description: "Initiate OAuth authorization for a specific AI provider to get an authorization URL.", + operationId: "provider.oauth.authorize", + responses: { + 200: { + description: "Authorization URL and method", + content: { + "application/json": { + schema: resolver(ProviderAuth.Authorization.optional()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "param", + z.object({ + providerID: ProviderID.zod.meta({ description: "Provider ID" }), + }), + ), + validator( + "json", + z.object({ + method: z.number().meta({ description: "Auth method index" }), + inputs: z.record(z.string(), z.string()).optional().meta({ description: "Prompt inputs" }), + }), + ), + async (c) => { + const providerID = c.req.valid("param").providerID + const { method, inputs } = c.req.valid("json") + const result = await AppRuntime.runPromise( + ProviderAuth.Service.use((svc) => + svc.authorize({ + providerID, + method, + inputs, + }), + ), + ) + return c.json(result) + }, + ) + .post( + "/:providerID/oauth/callback", + describeRoute({ + summary: "OAuth callback", + description: "Handle the OAuth callback from a provider after user authorization.", + operationId: "provider.oauth.callback", + responses: { + 200: { + description: "OAuth callback processed successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "param", + z.object({ + providerID: ProviderID.zod.meta({ description: "Provider ID" }), + }), + ), + validator( + "json", + z.object({ + method: z.number().meta({ description: "Auth method index" }), + code: z.string().optional().meta({ description: "OAuth authorization code" }), + }), + ), + async (c) => { + const providerID = c.req.valid("param").providerID + const { method, code } = c.req.valid("json") + await AppRuntime.runPromise( + ProviderAuth.Service.use((svc) => + svc.callback({ + providerID, + method, + code, + }), + ), + ) + return c.json(true) + }, + ), +) diff --git a/packages/opencode/src/server/instance/pty.ts b/packages/opencode/src/server/instance/pty.ts new file mode 100644 index 000000000..c333f4dd6 --- /dev/null +++ b/packages/opencode/src/server/instance/pty.ts @@ -0,0 +1,210 @@ +import { Hono, type MiddlewareHandler } from "hono" +import { describeRoute, validator, resolver } from "hono-openapi" +import type { UpgradeWebSocket } from "hono/ws" +import z from "zod" +import { Pty } from "@/pty" +import { PtyID } from "@/pty/schema" +import { NotFoundError } from "../../storage/db" +import { errors } from "../error" + +export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { + return new Hono() + .get( + "/", + describeRoute({ + summary: "List PTY sessions", + description: "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.", + operationId: "pty.list", + responses: { + 200: { + description: "List of sessions", + content: { + "application/json": { + schema: resolver(Pty.Info.array()), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await Pty.list()) + }, + ) + .post( + "/", + describeRoute({ + summary: "Create PTY session", + description: "Create a new pseudo-terminal (PTY) session for running shell commands and processes.", + operationId: "pty.create", + responses: { + 200: { + description: "Created session", + content: { + "application/json": { + schema: resolver(Pty.Info), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", Pty.CreateInput), + async (c) => { + const info = await Pty.create(c.req.valid("json")) + return c.json(info) + }, + ) + .get( + "/:ptyID", + describeRoute({ + summary: "Get PTY session", + description: "Retrieve detailed information about a specific pseudo-terminal (PTY) session.", + operationId: "pty.get", + responses: { + 200: { + description: "Session info", + content: { + "application/json": { + schema: resolver(Pty.Info), + }, + }, + }, + ...errors(404), + }, + }), + validator("param", z.object({ ptyID: PtyID.zod })), + async (c) => { + const info = await Pty.get(c.req.valid("param").ptyID) + if (!info) { + throw new NotFoundError({ message: "Session not found" }) + } + return c.json(info) + }, + ) + .put( + "/:ptyID", + describeRoute({ + summary: "Update PTY session", + description: "Update properties of an existing pseudo-terminal (PTY) session.", + operationId: "pty.update", + responses: { + 200: { + description: "Updated session", + content: { + "application/json": { + schema: resolver(Pty.Info), + }, + }, + }, + ...errors(400), + }, + }), + validator("param", z.object({ ptyID: PtyID.zod })), + validator("json", Pty.UpdateInput), + async (c) => { + const info = await Pty.update(c.req.valid("param").ptyID, c.req.valid("json")) + return c.json(info) + }, + ) + .delete( + "/:ptyID", + describeRoute({ + summary: "Remove PTY session", + description: "Remove and terminate a specific pseudo-terminal (PTY) session.", + operationId: "pty.remove", + responses: { + 200: { + description: "Session removed", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(404), + }, + }), + validator("param", z.object({ ptyID: PtyID.zod })), + async (c) => { + await Pty.remove(c.req.valid("param").ptyID) + return c.json(true) + }, + ) + .get( + "/:ptyID/connect", + describeRoute({ + summary: "Connect to PTY session", + description: "Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.", + operationId: "pty.connect", + responses: { + 200: { + description: "Connected session", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(404), + }, + }), + validator("param", z.object({ ptyID: PtyID.zod })), + upgradeWebSocket(async (c) => { + const id = PtyID.zod.parse(c.req.param("ptyID")) + const cursor = (() => { + const value = c.req.query("cursor") + if (!value) return + const parsed = Number(value) + if (!Number.isSafeInteger(parsed) || parsed < -1) return + return parsed + })() + let handler: Awaited> + if (!(await Pty.get(id))) throw new Error("Session not found") + + type Socket = { + readyState: number + send: (data: string | Uint8Array | ArrayBuffer) => void + close: (code?: number, reason?: string) => void + } + + const isSocket = (value: unknown): value is Socket => { + if (!value || typeof value !== "object") return false + if (!("readyState" in value)) return false + if (!("send" in value) || typeof (value as { send?: unknown }).send !== "function") return false + if (!("close" in value) || typeof (value as { close?: unknown }).close !== "function") return false + return typeof (value as { readyState?: unknown }).readyState === "number" + } + + const pending: string[] = [] + let ready = false + + return { + async onOpen(_event, ws) { + const socket = ws.raw + if (!isSocket(socket)) { + ws.close() + return + } + handler = await Pty.connect(id, socket, cursor) + ready = true + for (const msg of pending) handler?.onMessage(msg) + pending.length = 0 + }, + onMessage(event) { + if (typeof event.data !== "string") return + if (!ready) { + pending.push(event.data) + return + } + handler?.onMessage(event.data) + }, + onClose() { + handler?.onClose() + }, + onError() { + handler?.onClose() + }, + } + }), + ) +} diff --git a/packages/opencode/src/server/instance/question.ts b/packages/opencode/src/server/instance/question.ts new file mode 100644 index 000000000..3fff895fa --- /dev/null +++ b/packages/opencode/src/server/instance/question.ts @@ -0,0 +1,99 @@ +import { Hono } from "hono" +import { describeRoute, validator } from "hono-openapi" +import { resolver } from "hono-openapi" +import { QuestionID } from "@/question/schema" +import { Question } from "../../question" +import z from "zod" +import { errors } from "../error" +import { lazy } from "../../util/lazy" + +export const QuestionRoutes = lazy(() => + new Hono() + .get( + "/", + describeRoute({ + summary: "List pending questions", + description: "Get all pending question requests across all sessions.", + operationId: "question.list", + responses: { + 200: { + description: "List of pending questions", + content: { + "application/json": { + schema: resolver(Question.Request.array()), + }, + }, + }, + }, + }), + async (c) => { + const questions = await Question.list() + return c.json(questions) + }, + ) + .post( + "/:requestID/reply", + describeRoute({ + summary: "Reply to question request", + description: "Provide answers to a question request from the AI assistant.", + operationId: "question.reply", + responses: { + 200: { + description: "Question answered successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + requestID: QuestionID.zod, + }), + ), + validator("json", Question.Reply), + async (c) => { + const params = c.req.valid("param") + const json = c.req.valid("json") + await Question.reply({ + requestID: params.requestID, + answers: json.answers, + }) + return c.json(true) + }, + ) + .post( + "/:requestID/reject", + describeRoute({ + summary: "Reject question request", + description: "Reject a question request from the AI assistant.", + operationId: "question.reject", + responses: { + 200: { + description: "Question rejected successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + requestID: QuestionID.zod, + }), + ), + async (c) => { + const params = c.req.valid("param") + await Question.reject(params.requestID) + return c.json(true) + }, + ), +) diff --git a/packages/opencode/src/server/instance/session.ts b/packages/opencode/src/server/instance/session.ts new file mode 100644 index 000000000..a2a15d59e --- /dev/null +++ b/packages/opencode/src/server/instance/session.ts @@ -0,0 +1,1046 @@ +import { Hono } from "hono" +import { stream } from "hono/streaming" +import { describeRoute, validator, resolver } from "hono-openapi" +import { SessionID, MessageID, PartID } from "@/session/schema" +import z from "zod" +import { Session } from "../../session" +import { MessageV2 } from "../../session/message-v2" +import { SessionPrompt } from "../../session/prompt" +import { SessionRunState } from "@/session/run-state" +import { SessionCompaction } from "../../session/compaction" +import { SessionRevert } from "../../session/revert" +import { SessionShare } from "@/share/session" +import { SessionStatus } from "@/session/status" +import { SessionSummary } from "@/session/summary" +import { Todo } from "../../session/todo" +import { AppRuntime } from "../../effect/app-runtime" +import { Agent } from "../../agent/agent" +import { Snapshot } from "@/snapshot" +import { Command } from "../../command" +import { Log } from "../../util/log" +import { Permission } from "@/permission" +import { PermissionID } from "@/permission/schema" +import { ModelID, ProviderID } from "@/provider/schema" +import { errors } from "../error" +import { lazy } from "../../util/lazy" +import { Bus } from "../../bus" +import { NamedError } from "@opencode-ai/util/error" + +const log = Log.create({ service: "server" }) + +export const SessionRoutes = lazy(() => + new Hono() + .get( + "/", + describeRoute({ + summary: "List sessions", + description: "Get a list of all OpenCode sessions, sorted by most recently updated.", + operationId: "session.list", + responses: { + 200: { + description: "List of sessions", + content: { + "application/json": { + schema: resolver(Session.Info.array()), + }, + }, + }, + }, + }), + validator( + "query", + z.object({ + directory: z.string().optional().meta({ description: "Filter sessions by project directory" }), + roots: z.coerce.boolean().optional().meta({ description: "Only return root sessions (no parentID)" }), + start: z.coerce + .number() + .optional() + .meta({ description: "Filter sessions updated on or after this timestamp (milliseconds since epoch)" }), + search: z.string().optional().meta({ description: "Filter sessions by title (case-insensitive)" }), + limit: z.coerce.number().optional().meta({ description: "Maximum number of sessions to return" }), + }), + ), + async (c) => { + const query = c.req.valid("query") + const sessions: Session.Info[] = [] + for await (const session of Session.list({ + directory: query.directory, + roots: query.roots, + start: query.start, + search: query.search, + limit: query.limit, + })) { + sessions.push(session) + } + return c.json(sessions) + }, + ) + .get( + "/status", + describeRoute({ + summary: "Get session status", + description: "Retrieve the current status of all sessions, including active, idle, and completed states.", + operationId: "session.status", + responses: { + 200: { + description: "Get session status", + content: { + "application/json": { + schema: resolver(z.record(z.string(), SessionStatus.Info)), + }, + }, + }, + ...errors(400), + }, + }), + async (c) => { + const result = await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.list())) + return c.json(Object.fromEntries(result)) + }, + ) + .get( + "/:sessionID", + describeRoute({ + summary: "Get session", + description: "Retrieve detailed information about a specific OpenCode session.", + tags: ["Session"], + operationId: "session.get", + responses: { + 200: { + description: "Get session", + content: { + "application/json": { + schema: resolver(Session.Info), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: Session.get.schema, + }), + ), + async (c) => { + const sessionID = c.req.valid("param").sessionID + const session = await Session.get(sessionID) + return c.json(session) + }, + ) + .get( + "/:sessionID/children", + describeRoute({ + summary: "Get session children", + tags: ["Session"], + description: "Retrieve all child sessions that were forked from the specified parent session.", + operationId: "session.children", + responses: { + 200: { + description: "List of children", + content: { + "application/json": { + schema: resolver(Session.Info.array()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: Session.children.schema, + }), + ), + async (c) => { + const sessionID = c.req.valid("param").sessionID + const session = await Session.children(sessionID) + return c.json(session) + }, + ) + .get( + "/:sessionID/todo", + describeRoute({ + summary: "Get session todos", + description: "Retrieve the todo list associated with a specific session, showing tasks and action items.", + operationId: "session.todo", + responses: { + 200: { + description: "Todo list", + content: { + "application/json": { + schema: resolver(Todo.Info.array()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + }), + ), + async (c) => { + const sessionID = c.req.valid("param").sessionID + const todos = await AppRuntime.runPromise(Todo.Service.use((svc) => svc.get(sessionID))) + return c.json(todos) + }, + ) + .post( + "/", + describeRoute({ + summary: "Create session", + description: "Create a new OpenCode session for interacting with AI assistants and managing conversations.", + operationId: "session.create", + responses: { + ...errors(400), + 200: { + description: "Successfully created session", + content: { + "application/json": { + schema: resolver(Session.Info), + }, + }, + }, + }, + }), + validator("json", Session.create.schema), + async (c) => { + const body = c.req.valid("json") ?? {} + const session = await SessionShare.create(body) + return c.json(session) + }, + ) + .delete( + "/:sessionID", + describeRoute({ + summary: "Delete session", + description: "Delete a session and permanently remove all associated data, including messages and history.", + operationId: "session.delete", + responses: { + 200: { + description: "Successfully deleted session", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: Session.remove.schema, + }), + ), + async (c) => { + const sessionID = c.req.valid("param").sessionID + await Session.remove(sessionID) + return c.json(true) + }, + ) + .patch( + "/:sessionID", + describeRoute({ + summary: "Update session", + description: "Update properties of an existing session, such as title or other metadata.", + operationId: "session.update", + responses: { + 200: { + description: "Successfully updated session", + content: { + "application/json": { + schema: resolver(Session.Info), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + }), + ), + validator( + "json", + z.object({ + title: z.string().optional(), + time: z + .object({ + archived: z.number().optional(), + }) + .optional(), + }), + ), + async (c) => { + const sessionID = c.req.valid("param").sessionID + const updates = c.req.valid("json") + + if (updates.title !== undefined) { + await Session.setTitle({ sessionID, title: updates.title }) + } + if (updates.time?.archived !== undefined) { + await Session.setArchived({ sessionID, time: updates.time.archived }) + } + + const session = await Session.get(sessionID) + return c.json(session) + }, + ) + // TODO(v2): remove this dedicated route and rely on the normal `/init` command flow. + .post( + "/:sessionID/init", + describeRoute({ + summary: "Initialize session", + description: + "Analyze the current application and create an AGENTS.md file with project-specific agent configurations.", + operationId: "session.init", + responses: { + 200: { + description: "200", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + }), + ), + validator( + "json", + z.object({ + modelID: ModelID.zod, + providerID: ProviderID.zod, + messageID: MessageID.zod, + }), + ), + async (c) => { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") + await SessionPrompt.command({ + sessionID, + messageID: body.messageID, + model: body.providerID + "/" + body.modelID, + command: Command.Default.INIT, + arguments: "", + }) + return c.json(true) + }, + ) + .post( + "/:sessionID/fork", + describeRoute({ + summary: "Fork session", + description: "Create a new session by forking an existing session at a specific message point.", + operationId: "session.fork", + responses: { + 200: { + description: "200", + content: { + "application/json": { + schema: resolver(Session.Info), + }, + }, + }, + }, + }), + validator( + "param", + z.object({ + sessionID: Session.fork.schema.shape.sessionID, + }), + ), + validator("json", Session.fork.schema.omit({ sessionID: true })), + async (c) => { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") + const result = await Session.fork({ ...body, sessionID }) + return c.json(result) + }, + ) + .post( + "/:sessionID/abort", + describeRoute({ + summary: "Abort session", + description: "Abort an active session and stop any ongoing AI processing or command execution.", + operationId: "session.abort", + responses: { + 200: { + description: "Aborted session", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + }), + ), + async (c) => { + await SessionPrompt.cancel(c.req.valid("param").sessionID) + return c.json(true) + }, + ) + .post( + "/:sessionID/share", + describeRoute({ + summary: "Share session", + description: "Create a shareable link for a session, allowing others to view the conversation.", + operationId: "session.share", + responses: { + 200: { + description: "Successfully shared session", + content: { + "application/json": { + schema: resolver(Session.Info), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + }), + ), + async (c) => { + const sessionID = c.req.valid("param").sessionID + await SessionShare.share(sessionID) + const session = await Session.get(sessionID) + return c.json(session) + }, + ) + .get( + "/:sessionID/diff", + describeRoute({ + summary: "Get message diff", + description: "Get the file changes (diff) that resulted from a specific user message in the session.", + operationId: "session.diff", + responses: { + 200: { + description: "Successfully retrieved diff", + content: { + "application/json": { + schema: resolver(Snapshot.FileDiff.array()), + }, + }, + }, + }, + }), + validator( + "param", + z.object({ + sessionID: SessionSummary.DiffInput.shape.sessionID, + }), + ), + validator( + "query", + z.object({ + messageID: SessionSummary.DiffInput.shape.messageID, + }), + ), + async (c) => { + const query = c.req.valid("query") + const params = c.req.valid("param") + const result = await SessionSummary.diff({ + sessionID: params.sessionID, + messageID: query.messageID, + }) + return c.json(result) + }, + ) + .delete( + "/:sessionID/share", + describeRoute({ + summary: "Unshare session", + description: "Remove the shareable link for a session, making it private again.", + operationId: "session.unshare", + responses: { + 200: { + description: "Successfully unshared session", + content: { + "application/json": { + schema: resolver(Session.Info), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + }), + ), + async (c) => { + const sessionID = c.req.valid("param").sessionID + await SessionShare.unshare(sessionID) + const session = await Session.get(sessionID) + return c.json(session) + }, + ) + .post( + "/:sessionID/summarize", + describeRoute({ + summary: "Summarize session", + description: "Generate a concise summary of the session using AI compaction to preserve key information.", + operationId: "session.summarize", + responses: { + 200: { + description: "Summarized session", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + }), + ), + validator( + "json", + z.object({ + providerID: ProviderID.zod, + modelID: ModelID.zod, + auto: z.boolean().optional().default(false), + }), + ), + async (c) => { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") + const session = await Session.get(sessionID) + await SessionRevert.cleanup(session) + const msgs = await Session.messages({ sessionID }) + let currentAgent = await Agent.defaultAgent() + for (let i = msgs.length - 1; i >= 0; i--) { + const info = msgs[i].info + if (info.role === "user") { + currentAgent = info.agent || (await Agent.defaultAgent()) + break + } + } + await SessionCompaction.create({ + sessionID, + agent: currentAgent, + model: { + providerID: body.providerID, + modelID: body.modelID, + }, + auto: body.auto, + }) + await SessionPrompt.loop({ sessionID }) + return c.json(true) + }, + ) + .get( + "/:sessionID/message", + describeRoute({ + summary: "Get session messages", + description: "Retrieve all messages in a session, including user prompts and AI responses.", + operationId: "session.messages", + responses: { + 200: { + description: "List of messages", + content: { + "application/json": { + schema: resolver(MessageV2.WithParts.array()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + }), + ), + validator( + "query", + z + .object({ + limit: z.coerce + .number() + .int() + .min(0) + .optional() + .meta({ description: "Maximum number of messages to return" }), + before: z + .string() + .optional() + .meta({ description: "Opaque cursor for loading older messages" }) + .refine( + (value) => { + if (!value) return true + try { + MessageV2.cursor.decode(value) + return true + } catch { + return false + } + }, + { message: "Invalid cursor" }, + ), + }) + .refine((value) => !value.before || value.limit !== undefined, { + message: "before requires limit", + path: ["before"], + }), + ), + async (c) => { + const query = c.req.valid("query") + const sessionID = c.req.valid("param").sessionID + if (query.limit === undefined) { + await Session.get(sessionID) + const messages = await Session.messages({ sessionID }) + return c.json(messages) + } + + if (query.limit === 0) { + await Session.get(sessionID) + const messages = await Session.messages({ sessionID }) + return c.json(messages) + } + + const page = await MessageV2.page({ + sessionID, + limit: query.limit, + before: query.before, + }) + if (page.cursor) { + const url = new URL(c.req.url) + url.searchParams.set("limit", query.limit.toString()) + url.searchParams.set("before", page.cursor) + c.header("Access-Control-Expose-Headers", "Link, X-Next-Cursor") + c.header("Link", `<${url.toString()}>; rel=\"next\"`) + c.header("X-Next-Cursor", page.cursor) + } + return c.json(page.items) + }, + ) + .get( + "/:sessionID/message/:messageID", + describeRoute({ + summary: "Get message", + description: "Retrieve a specific message from a session by its message ID.", + operationId: "session.message", + responses: { + 200: { + description: "Message", + content: { + "application/json": { + schema: resolver( + z.object({ + info: MessageV2.Info, + parts: MessageV2.Part.array(), + }), + ), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + messageID: MessageID.zod, + }), + ), + async (c) => { + const params = c.req.valid("param") + const message = await MessageV2.get({ + sessionID: params.sessionID, + messageID: params.messageID, + }) + return c.json(message) + }, + ) + .delete( + "/:sessionID/message/:messageID", + describeRoute({ + summary: "Delete message", + description: + "Permanently delete a specific message (and all of its parts) from a session. This does not revert any file changes that may have been made while processing the message.", + operationId: "session.deleteMessage", + responses: { + 200: { + description: "Successfully deleted message", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + messageID: MessageID.zod, + }), + ), + async (c) => { + const params = c.req.valid("param") + await SessionRunState.assertNotBusy(params.sessionID) + await Session.removeMessage({ + sessionID: params.sessionID, + messageID: params.messageID, + }) + return c.json(true) + }, + ) + .delete( + "/:sessionID/message/:messageID/part/:partID", + describeRoute({ + description: "Delete a part from a message", + operationId: "part.delete", + responses: { + 200: { + description: "Successfully deleted part", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + messageID: MessageID.zod, + partID: PartID.zod, + }), + ), + async (c) => { + const params = c.req.valid("param") + await Session.removePart({ + sessionID: params.sessionID, + messageID: params.messageID, + partID: params.partID, + }) + return c.json(true) + }, + ) + .patch( + "/:sessionID/message/:messageID/part/:partID", + describeRoute({ + description: "Update a part in a message", + operationId: "part.update", + responses: { + 200: { + description: "Successfully updated part", + content: { + "application/json": { + schema: resolver(MessageV2.Part), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + messageID: MessageID.zod, + partID: PartID.zod, + }), + ), + validator("json", MessageV2.Part), + async (c) => { + const params = c.req.valid("param") + const body = c.req.valid("json") + if (body.id !== params.partID || body.messageID !== params.messageID || body.sessionID !== params.sessionID) { + throw new Error( + `Part mismatch: body.id='${body.id}' vs partID='${params.partID}', body.messageID='${body.messageID}' vs messageID='${params.messageID}', body.sessionID='${body.sessionID}' vs sessionID='${params.sessionID}'`, + ) + } + const part = await Session.updatePart(body) + return c.json(part) + }, + ) + .post( + "/:sessionID/message", + describeRoute({ + summary: "Send message", + description: "Create and send a new message to a session, streaming the AI response.", + operationId: "session.prompt", + responses: { + 200: { + description: "Created message", + content: { + "application/json": { + schema: resolver( + z.object({ + info: MessageV2.Assistant, + parts: MessageV2.Part.array(), + }), + ), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + }), + ), + validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })), + async (c) => { + c.status(200) + c.header("Content-Type", "application/json") + return stream(c, async (stream) => { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") + const msg = await SessionPrompt.prompt({ ...body, sessionID }) + stream.write(JSON.stringify(msg)) + }) + }, + ) + .post( + "/:sessionID/prompt_async", + describeRoute({ + summary: "Send async message", + description: + "Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.", + operationId: "session.prompt_async", + responses: { + 204: { + description: "Prompt accepted", + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + }), + ), + validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })), + async (c) => { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") + SessionPrompt.prompt({ ...body, sessionID }).catch((err) => { + log.error("prompt_async failed", { sessionID, error: err }) + Bus.publish(Session.Event.Error, { + sessionID, + error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(), + }) + }) + + return c.body(null, 204) + }, + ) + .post( + "/:sessionID/command", + describeRoute({ + summary: "Send command", + description: "Send a new command to a session for execution by the AI assistant.", + operationId: "session.command", + responses: { + 200: { + description: "Created message", + content: { + "application/json": { + schema: resolver( + z.object({ + info: MessageV2.Assistant, + parts: MessageV2.Part.array(), + }), + ), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + }), + ), + validator("json", SessionPrompt.CommandInput.omit({ sessionID: true })), + async (c) => { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") + const msg = await SessionPrompt.command({ ...body, sessionID }) + return c.json(msg) + }, + ) + .post( + "/:sessionID/shell", + describeRoute({ + summary: "Run shell command", + description: "Execute a shell command within the session context and return the AI's response.", + operationId: "session.shell", + responses: { + 200: { + description: "Created message", + content: { + "application/json": { + schema: resolver(MessageV2.WithParts), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + }), + ), + validator("json", SessionPrompt.ShellInput.omit({ sessionID: true })), + async (c) => { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") + const msg = await SessionPrompt.shell({ ...body, sessionID }) + return c.json(msg) + }, + ) + .post( + "/:sessionID/revert", + describeRoute({ + summary: "Revert message", + description: "Revert a specific message in a session, undoing its effects and restoring the previous state.", + operationId: "session.revert", + responses: { + 200: { + description: "Updated session", + content: { + "application/json": { + schema: resolver(Session.Info), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + }), + ), + validator("json", SessionRevert.RevertInput.omit({ sessionID: true })), + async (c) => { + const sessionID = c.req.valid("param").sessionID + log.info("revert", c.req.valid("json")) + const session = await SessionRevert.revert({ + sessionID, + ...c.req.valid("json"), + }) + return c.json(session) + }, + ) + .post( + "/:sessionID/unrevert", + describeRoute({ + summary: "Restore reverted messages", + description: "Restore all previously reverted messages in a session.", + operationId: "session.unrevert", + responses: { + 200: { + description: "Updated session", + content: { + "application/json": { + schema: resolver(Session.Info), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + }), + ), + async (c) => { + const sessionID = c.req.valid("param").sessionID + const session = await SessionRevert.unrevert({ sessionID }) + return c.json(session) + }, + ) + .post( + "/:sessionID/permissions/:permissionID", + describeRoute({ + summary: "Respond to permission", + deprecated: true, + description: "Approve or deny a permission request from the AI assistant.", + operationId: "permission.respond", + responses: { + 200: { + description: "Permission processed successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + permissionID: PermissionID.zod, + }), + ), + validator("json", z.object({ response: Permission.Reply })), + async (c) => { + const params = c.req.valid("param") + Permission.reply({ + requestID: params.permissionID, + reply: c.req.valid("json").response, + }) + return c.json(true) + }, + ), +) diff --git a/packages/opencode/src/server/instance/tui.ts b/packages/opencode/src/server/instance/tui.ts new file mode 100644 index 000000000..8650a0ccc --- /dev/null +++ b/packages/opencode/src/server/instance/tui.ts @@ -0,0 +1,379 @@ +import { Hono, type Context } from "hono" +import { describeRoute, validator, resolver } from "hono-openapi" +import z from "zod" +import { Bus } from "../../bus" +import { Session } from "../../session" +import { TuiEvent } from "@/cli/cmd/tui/event" +import { AsyncQueue } from "../../util/queue" +import { errors } from "../error" +import { lazy } from "../../util/lazy" + +const TuiRequest = z.object({ + path: z.string(), + body: z.any(), +}) + +type TuiRequest = z.infer + +const request = new AsyncQueue() +const response = new AsyncQueue() + +export async function callTui(ctx: Context) { + const body = await ctx.req.json() + request.push({ + path: ctx.req.path, + body, + }) + return response.next() +} + +const TuiControlRoutes = new Hono() + .get( + "/next", + describeRoute({ + summary: "Get next TUI request", + description: "Retrieve the next TUI (Terminal User Interface) request from the queue for processing.", + operationId: "tui.control.next", + responses: { + 200: { + description: "Next TUI request", + content: { + "application/json": { + schema: resolver(TuiRequest), + }, + }, + }, + }, + }), + async (c) => { + const req = await request.next() + return c.json(req) + }, + ) + .post( + "/response", + describeRoute({ + summary: "Submit TUI response", + description: "Submit a response to the TUI request queue to complete a pending request.", + operationId: "tui.control.response", + responses: { + 200: { + description: "Response submitted successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + validator("json", z.any()), + async (c) => { + const body = c.req.valid("json") + response.push(body) + return c.json(true) + }, + ) + +export const TuiRoutes = lazy(() => + new Hono() + .post( + "/append-prompt", + describeRoute({ + summary: "Append TUI prompt", + description: "Append prompt to the TUI", + operationId: "tui.appendPrompt", + responses: { + 200: { + description: "Prompt processed successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", TuiEvent.PromptAppend.properties), + async (c) => { + await Bus.publish(TuiEvent.PromptAppend, c.req.valid("json")) + return c.json(true) + }, + ) + .post( + "/open-help", + describeRoute({ + summary: "Open help dialog", + description: "Open the help dialog in the TUI to display user assistance information.", + operationId: "tui.openHelp", + responses: { + 200: { + description: "Help dialog opened successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + async (c) => { + await Bus.publish(TuiEvent.CommandExecute, { + command: "help.show", + }) + return c.json(true) + }, + ) + .post( + "/open-sessions", + describeRoute({ + summary: "Open sessions dialog", + description: "Open the session dialog", + operationId: "tui.openSessions", + responses: { + 200: { + description: "Session dialog opened successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + async (c) => { + await Bus.publish(TuiEvent.CommandExecute, { + command: "session.list", + }) + return c.json(true) + }, + ) + .post( + "/open-themes", + describeRoute({ + summary: "Open themes dialog", + description: "Open the theme dialog", + operationId: "tui.openThemes", + responses: { + 200: { + description: "Theme dialog opened successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + async (c) => { + await Bus.publish(TuiEvent.CommandExecute, { + command: "session.list", + }) + return c.json(true) + }, + ) + .post( + "/open-models", + describeRoute({ + summary: "Open models dialog", + description: "Open the model dialog", + operationId: "tui.openModels", + responses: { + 200: { + description: "Model dialog opened successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + async (c) => { + await Bus.publish(TuiEvent.CommandExecute, { + command: "model.list", + }) + return c.json(true) + }, + ) + .post( + "/submit-prompt", + describeRoute({ + summary: "Submit TUI prompt", + description: "Submit the prompt", + operationId: "tui.submitPrompt", + responses: { + 200: { + description: "Prompt submitted successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + async (c) => { + await Bus.publish(TuiEvent.CommandExecute, { + command: "prompt.submit", + }) + return c.json(true) + }, + ) + .post( + "/clear-prompt", + describeRoute({ + summary: "Clear TUI prompt", + description: "Clear the prompt", + operationId: "tui.clearPrompt", + responses: { + 200: { + description: "Prompt cleared successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + async (c) => { + await Bus.publish(TuiEvent.CommandExecute, { + command: "prompt.clear", + }) + return c.json(true) + }, + ) + .post( + "/execute-command", + describeRoute({ + summary: "Execute TUI command", + description: "Execute a TUI command (e.g. agent_cycle)", + operationId: "tui.executeCommand", + responses: { + 200: { + description: "Command executed successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", z.object({ command: z.string() })), + async (c) => { + const command = c.req.valid("json").command + await Bus.publish(TuiEvent.CommandExecute, { + // @ts-expect-error + command: { + session_new: "session.new", + session_share: "session.share", + session_interrupt: "session.interrupt", + session_compact: "session.compact", + messages_page_up: "session.page.up", + messages_page_down: "session.page.down", + messages_line_up: "session.line.up", + messages_line_down: "session.line.down", + messages_half_page_up: "session.half.page.up", + messages_half_page_down: "session.half.page.down", + messages_first: "session.first", + messages_last: "session.last", + agent_cycle: "agent.cycle", + }[command], + }) + return c.json(true) + }, + ) + .post( + "/show-toast", + describeRoute({ + summary: "Show TUI toast", + description: "Show a toast notification in the TUI", + operationId: "tui.showToast", + responses: { + 200: { + description: "Toast notification shown successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + validator("json", TuiEvent.ToastShow.properties), + async (c) => { + await Bus.publish(TuiEvent.ToastShow, c.req.valid("json")) + return c.json(true) + }, + ) + .post( + "/publish", + describeRoute({ + summary: "Publish TUI event", + description: "Publish a TUI event", + operationId: "tui.publish", + responses: { + 200: { + description: "Event published successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "json", + z.union( + Object.values(TuiEvent).map((def) => { + return z + .object({ + type: z.literal(def.type), + properties: def.properties, + }) + .meta({ + ref: "Event" + "." + def.type, + }) + }), + ), + ), + async (c) => { + const evt = c.req.valid("json") + await Bus.publish(Object.values(TuiEvent).find((def) => def.type === evt.type)!, evt.properties) + return c.json(true) + }, + ) + .post( + "/select-session", + describeRoute({ + summary: "Select session", + description: "Navigate the TUI to display the specified session.", + operationId: "tui.selectSession", + responses: { + 200: { + description: "Session selected successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator("json", TuiEvent.SessionSelect.properties), + async (c) => { + const { sessionID } = c.req.valid("json") + await Session.get(sessionID) + await Bus.publish(TuiEvent.SessionSelect, { sessionID }) + return c.json(true) + }, + ) + .route("/control", TuiControlRoutes), +) diff --git a/packages/opencode/src/server/instance/workspace.ts b/packages/opencode/src/server/instance/workspace.ts new file mode 100644 index 000000000..419321654 --- /dev/null +++ b/packages/opencode/src/server/instance/workspace.ts @@ -0,0 +1,116 @@ +import { Hono } from "hono" +import { describeRoute, resolver, validator } from "hono-openapi" +import z from "zod" +import { Workspace } from "../../control-plane/workspace" +import { Instance } from "../../project/instance" +import { errors } from "../error" +import { lazy } from "../../util/lazy" + +export const WorkspaceRoutes = lazy(() => + new Hono() + .post( + "/", + describeRoute({ + summary: "Create workspace", + description: "Create a workspace for the current project.", + operationId: "experimental.workspace.create", + responses: { + 200: { + description: "Workspace created", + content: { + "application/json": { + schema: resolver(Workspace.Info), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "json", + Workspace.create.schema.omit({ + projectID: true, + }), + ), + async (c) => { + const body = c.req.valid("json") + const workspace = await Workspace.create({ + projectID: Instance.project.id, + ...body, + }) + return c.json(workspace) + }, + ) + .get( + "/", + describeRoute({ + summary: "List workspaces", + description: "List all workspaces.", + operationId: "experimental.workspace.list", + responses: { + 200: { + description: "Workspaces", + content: { + "application/json": { + schema: resolver(z.array(Workspace.Info)), + }, + }, + }, + }, + }), + async (c) => { + return c.json(Workspace.list(Instance.project)) + }, + ) + .get( + "/status", + describeRoute({ + summary: "Workspace status", + description: "Get connection status for workspaces in the current project.", + operationId: "experimental.workspace.status", + responses: { + 200: { + description: "Workspace status", + content: { + "application/json": { + schema: resolver(z.array(Workspace.ConnectionStatus)), + }, + }, + }, + }, + }), + async (c) => { + const ids = new Set(Workspace.list(Instance.project).map((item) => item.id)) + return c.json(Workspace.status().filter((item) => ids.has(item.workspaceID))) + }, + ) + .delete( + "/:id", + describeRoute({ + summary: "Remove workspace", + description: "Remove an existing workspace.", + operationId: "experimental.workspace.remove", + responses: { + 200: { + description: "Workspace removed", + content: { + "application/json": { + schema: resolver(Workspace.Info.optional()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "param", + z.object({ + id: Workspace.Info.shape.id, + }), + ), + async (c) => { + const { id } = c.req.valid("param") + return c.json(await Workspace.remove(id)) + }, + ), +) diff --git a/packages/opencode/src/server/middleware.ts b/packages/opencode/src/server/middleware.ts index 278740c57..a51ba602b 100644 --- a/packages/opencode/src/server/middleware.ts +++ b/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) } diff --git a/packages/opencode/src/server/router.ts b/packages/opencode/src/server/router.ts deleted file mode 100644 index f97724c2e..000000000 --- a/packages/opencode/src/server/router.ts +++ /dev/null @@ -1,138 +0,0 @@ -import type { MiddlewareHandler } from "hono" -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 { 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" - -type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" } - -const RULES: Array = [ - { path: "/session/status", action: "forward" }, - { method: "GET", path: "/session", action: "local" }, -] - -function local(method: string, path: string) { - for (const rule of RULES) { - if (rule.method && rule.method !== method) continue - const match = rule.exact ? path === rule.path : path === rule.path || path.startsWith(rule.path + "/") - if (match) return rule.action === "local" - } - return false -} - -function getSessionID(url: URL) { - if (url.pathname === "/session/status") return null - - const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1] - if (!id) return null - - return SessionID.make(id) -} - -async function getSessionWorkspace(url: URL) { - const id = getSessionID(url) - if (!id) return null - - const session = await Session.get(id).catch(() => undefined) - return session?.workspaceID -} - -export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler { - const routes = lazy(() => InstanceRoutes(upgrade)) - - return async (c) => { - const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() - const directory = Filesystem.resolve( - (() => { - try { - return decodeURIComponent(raw) - } catch { - return raw - } - })(), - ) - - const url = new URL(c.req.url) - - const sessionWorkspaceID = await getSessionWorkspace(url) - const workspaceID = sessionWorkspaceID || url.searchParams.get("workspace") - - // If no workspace is provided we use the project - if (!workspaceID) { - return Instance.provide({ - directory, - init: InstanceBootstrap, - async fn() { - return routes().fetch(c.req.raw, c.env) - }, - }) - } - - const workspace = await Workspace.get(WorkspaceID.make(workspaceID)) - - if (!workspace) { - // Special-case deleting a session in case user's data in a - // weird state. Allow them to forcefully delete a synced session - // even if the remote workspace is not in their data. - // - // 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 new Response(`Workspace not found: ${workspaceID}`, { - status: 500, - headers: { - "content-type": "text/plain; charset=utf-8", - }, - }) - } - - const adaptor = await getAdaptor(workspace.type) - const target = await adaptor.target(workspace) - - if (target.type === "local") { - return WorkspaceContext.provide({ - workspaceID: WorkspaceID.make(workspaceID), - fn: () => - Instance.provide({ - directory: target.directory, - init: InstanceBootstrap, - async fn() { - return routes().fetch(c.req.raw, c.env) - }, - }), - }) - } - - 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) - } - - if (c.req.header("upgrade")?.toLowerCase() === "websocket") { - return ServerProxy.websocket(upgrade, target, c.req.raw, c.env) - } - - const headers = new Headers(c.req.raw.headers) - headers.delete("x-opencode-workspace") - - return ServerProxy.http( - target, - new Request(c.req.raw, { - headers, - }), - ) - } -} diff --git a/packages/opencode/src/server/routes/config.ts b/packages/opencode/src/server/routes/config.ts deleted file mode 100644 index 85d28f6aa..000000000 --- a/packages/opencode/src/server/routes/config.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { Hono } from "hono" -import { describeRoute, validator, resolver } from "hono-openapi" -import z from "zod" -import { Config } from "../../config/config" -import { Provider } from "../../provider/provider" -import { mapValues } from "remeda" -import { errors } from "../error" -import { Log } from "../../util/log" -import { lazy } from "../../util/lazy" - -const log = Log.create({ service: "server" }) - -export const ConfigRoutes = lazy(() => - new Hono() - .get( - "/", - describeRoute({ - summary: "Get configuration", - description: "Retrieve the current OpenCode configuration settings and preferences.", - operationId: "config.get", - responses: { - 200: { - description: "Get config info", - content: { - "application/json": { - schema: resolver(Config.Info), - }, - }, - }, - }, - }), - async (c) => { - return c.json(await Config.get()) - }, - ) - .patch( - "/", - describeRoute({ - summary: "Update configuration", - description: "Update OpenCode configuration settings and preferences.", - operationId: "config.update", - responses: { - 200: { - description: "Successfully updated config", - content: { - "application/json": { - schema: resolver(Config.Info), - }, - }, - }, - ...errors(400), - }, - }), - validator("json", Config.Info), - async (c) => { - const config = c.req.valid("json") - await Config.update(config) - return c.json(config) - }, - ) - .get( - "/providers", - describeRoute({ - summary: "List config providers", - description: "Get a list of all configured AI providers and their default models.", - operationId: "config.providers", - responses: { - 200: { - description: "List of providers", - content: { - "application/json": { - schema: resolver( - z.object({ - providers: Provider.Info.array(), - default: z.record(z.string(), z.string()), - }), - ), - }, - }, - }, - }, - }), - async (c) => { - using _ = log.time("providers") - const providers = await Provider.list().then((x) => mapValues(x, (item) => item)) - return c.json({ - providers: Object.values(providers), - default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id), - }) - }, - ), -) diff --git a/packages/opencode/src/server/routes/event.ts b/packages/opencode/src/server/routes/event.ts deleted file mode 100644 index 989b85771..000000000 --- a/packages/opencode/src/server/routes/event.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { Hono } from "hono" -import { describeRoute, resolver } from "hono-openapi" -import { streamSSE } from "hono/streaming" -import { Log } from "@/util/log" -import { BusEvent } from "@/bus/bus-event" -import { Bus } from "@/bus" -import { AsyncQueue } from "../../util/queue" - -const log = Log.create({ service: "server" }) - -export const EventRoutes = () => - new Hono().get( - "/event", - describeRoute({ - summary: "Subscribe to events", - description: "Get events", - operationId: "event.subscribe", - responses: { - 200: { - description: "Event stream", - content: { - "text/event-stream": { - schema: resolver(BusEvent.payloads()), - }, - }, - }, - }, - }), - async (c) => { - log.info("event connected") - c.header("Cache-Control", "no-cache, no-transform") - c.header("X-Accel-Buffering", "no") - c.header("X-Content-Type-Options", "nosniff") - return streamSSE(c, async (stream) => { - const q = new AsyncQueue() - let done = false - - q.push( - JSON.stringify({ - type: "server.connected", - properties: {}, - }), - ) - - // Send heartbeat every 10s to prevent stalled proxy streams. - const heartbeat = setInterval(() => { - q.push( - JSON.stringify({ - type: "server.heartbeat", - properties: {}, - }), - ) - }, 10_000) - - const stop = () => { - if (done) return - done = true - clearInterval(heartbeat) - unsub() - q.push(null) - log.info("event disconnected") - } - - const unsub = Bus.subscribeAll((event) => { - q.push(JSON.stringify(event)) - if (event.type === Bus.InstanceDisposed.type) { - stop() - } - }) - - stream.onAbort(stop) - - try { - for await (const data of q) { - if (data === null) return - await stream.writeSSE({ data }) - } - } finally { - stop() - } - }) - }, - ) diff --git a/packages/opencode/src/server/routes/experimental.ts b/packages/opencode/src/server/routes/experimental.ts deleted file mode 100644 index 464617c69..000000000 --- a/packages/opencode/src/server/routes/experimental.ts +++ /dev/null @@ -1,402 +0,0 @@ -import { Hono } from "hono" -import { describeRoute, validator, resolver } from "hono-openapi" -import z from "zod" -import { ProviderID, ModelID } from "../../provider/schema" -import { ToolRegistry } from "../../tool/registry" -import { Worktree } from "../../worktree" -import { Instance } from "../../project/instance" -import { Project } from "../../project/project" -import { MCP } from "../../mcp" -import { Session } from "../../session" -import { Config } from "../../config/config" -import { ConsoleState } from "../../config/console-state" -import { Account, AccountID, OrgID } from "../../account" -import { AppRuntime } from "../../effect/app-runtime" -import { zodToJsonSchema } from "zod-to-json-schema" -import { errors } from "../error" -import { lazy } from "../../util/lazy" -import { Effect, Option } from "effect" -import { WorkspaceRoutes } from "./workspace" -import { Agent } from "@/agent/agent" - -const ConsoleOrgOption = z.object({ - accountID: z.string(), - accountEmail: z.string(), - accountUrl: z.string(), - orgID: z.string(), - orgName: z.string(), - active: z.boolean(), -}) - -const ConsoleOrgList = z.object({ - orgs: z.array(ConsoleOrgOption), -}) - -const ConsoleSwitchBody = z.object({ - accountID: z.string(), - orgID: z.string(), -}) - -export const ExperimentalRoutes = lazy(() => - new Hono() - .get( - "/console", - describeRoute({ - summary: "Get active Console provider metadata", - description: "Get the active Console org name and the set of provider IDs managed by that Console org.", - operationId: "experimental.console.get", - responses: { - 200: { - description: "Active Console provider metadata", - content: { - "application/json": { - schema: resolver(ConsoleState), - }, - }, - }, - }, - }), - async (c) => { - const result = await AppRuntime.runPromise( - Effect.gen(function* () { - const config = yield* Config.Service - const account = yield* Account.Service - const [state, groups] = yield* Effect.all([config.getConsoleState(), account.orgsByAccount()], { - concurrency: "unbounded", - }) - return { - ...state, - switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0), - } - }), - ) - return c.json(result) - }, - ) - .get( - "/console/orgs", - describeRoute({ - summary: "List switchable Console orgs", - description: "Get the available Console orgs across logged-in accounts, including the current active org.", - operationId: "experimental.console.listOrgs", - responses: { - 200: { - description: "Switchable Console orgs", - content: { - "application/json": { - schema: resolver(ConsoleOrgList), - }, - }, - }, - }, - }), - async (c) => { - const orgs = await AppRuntime.runPromise( - Effect.gen(function* () { - const account = yield* Account.Service - const [groups, active] = yield* Effect.all([account.orgsByAccount(), account.active()], { - concurrency: "unbounded", - }) - const info = Option.getOrUndefined(active) - return groups.flatMap((group) => - group.orgs.map((org) => ({ - accountID: group.account.id, - accountEmail: group.account.email, - accountUrl: group.account.url, - orgID: org.id, - orgName: org.name, - active: !!info && info.id === group.account.id && info.active_org_id === org.id, - })), - ) - }), - ) - return c.json({ orgs }) - }, - ) - .post( - "/console/switch", - describeRoute({ - summary: "Switch active Console org", - description: "Persist a new active Console account/org selection for the current local OpenCode state.", - operationId: "experimental.console.switchOrg", - responses: { - 200: { - description: "Switch success", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - validator("json", ConsoleSwitchBody), - async (c) => { - const body = c.req.valid("json") - await AppRuntime.runPromise( - Effect.gen(function* () { - const account = yield* Account.Service - yield* account.use(AccountID.make(body.accountID), Option.some(OrgID.make(body.orgID))) - }), - ) - return c.json(true) - }, - ) - .get( - "/tool/ids", - describeRoute({ - summary: "List tool IDs", - description: - "Get a list of all available tool IDs, including both built-in tools and dynamically registered tools.", - operationId: "tool.ids", - responses: { - 200: { - description: "Tool IDs", - content: { - "application/json": { - schema: resolver(z.array(z.string()).meta({ ref: "ToolIDs" })), - }, - }, - }, - ...errors(400), - }, - }), - async (c) => { - return c.json(await ToolRegistry.ids()) - }, - ) - .get( - "/tool", - describeRoute({ - summary: "List tools", - description: - "Get a list of available tools with their JSON schema parameters for a specific provider and model combination.", - operationId: "tool.list", - responses: { - 200: { - description: "Tools", - content: { - "application/json": { - schema: resolver( - z - .array( - z - .object({ - id: z.string(), - description: z.string(), - parameters: z.any(), - }) - .meta({ ref: "ToolListItem" }), - ) - .meta({ ref: "ToolList" }), - ), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "query", - z.object({ - provider: z.string(), - model: z.string(), - }), - ), - async (c) => { - const { provider, model } = c.req.valid("query") - const tools = await ToolRegistry.tools({ - providerID: ProviderID.make(provider), - modelID: ModelID.make(model), - agent: await Agent.get(await Agent.defaultAgent()), - }) - return c.json( - tools.map((t) => ({ - id: t.id, - description: t.description, - // Handle both Zod schemas and plain JSON schemas - parameters: (t.parameters as any)?._def ? zodToJsonSchema(t.parameters as any) : t.parameters, - })), - ) - }, - ) - .route("/workspace", WorkspaceRoutes()) - .post( - "/worktree", - describeRoute({ - summary: "Create worktree", - description: "Create a new git worktree for the current project and run any configured startup scripts.", - operationId: "worktree.create", - responses: { - 200: { - description: "Worktree created", - content: { - "application/json": { - schema: resolver(Worktree.Info), - }, - }, - }, - ...errors(400), - }, - }), - validator("json", Worktree.CreateInput.optional()), - async (c) => { - const body = c.req.valid("json") - const worktree = await Worktree.create(body) - return c.json(worktree) - }, - ) - .get( - "/worktree", - describeRoute({ - summary: "List worktrees", - description: "List all sandbox worktrees for the current project.", - operationId: "worktree.list", - responses: { - 200: { - description: "List of worktree directories", - content: { - "application/json": { - schema: resolver(z.array(z.string())), - }, - }, - }, - }, - }), - async (c) => { - const sandboxes = await Project.sandboxes(Instance.project.id) - return c.json(sandboxes) - }, - ) - .delete( - "/worktree", - describeRoute({ - summary: "Remove worktree", - description: "Remove a git worktree and delete its branch.", - operationId: "worktree.remove", - responses: { - 200: { - description: "Worktree removed", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400), - }, - }), - validator("json", Worktree.RemoveInput), - async (c) => { - const body = c.req.valid("json") - await Worktree.remove(body) - await Project.removeSandbox(Instance.project.id, body.directory) - return c.json(true) - }, - ) - .post( - "/worktree/reset", - describeRoute({ - summary: "Reset worktree", - description: "Reset a worktree branch to the primary default branch.", - operationId: "worktree.reset", - responses: { - 200: { - description: "Worktree reset", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400), - }, - }), - validator("json", Worktree.ResetInput), - async (c) => { - const body = c.req.valid("json") - await Worktree.reset(body) - return c.json(true) - }, - ) - .get( - "/session", - describeRoute({ - summary: "List sessions", - description: - "Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.", - operationId: "experimental.session.list", - responses: { - 200: { - description: "List of sessions", - content: { - "application/json": { - schema: resolver(Session.GlobalInfo.array()), - }, - }, - }, - }, - }), - validator( - "query", - z.object({ - directory: z.string().optional().meta({ description: "Filter sessions by project directory" }), - roots: z.coerce.boolean().optional().meta({ description: "Only return root sessions (no parentID)" }), - start: z.coerce - .number() - .optional() - .meta({ description: "Filter sessions updated on or after this timestamp (milliseconds since epoch)" }), - cursor: z.coerce - .number() - .optional() - .meta({ description: "Return sessions updated before this timestamp (milliseconds since epoch)" }), - search: z.string().optional().meta({ description: "Filter sessions by title (case-insensitive)" }), - limit: z.coerce.number().optional().meta({ description: "Maximum number of sessions to return" }), - archived: z.coerce.boolean().optional().meta({ description: "Include archived sessions (default false)" }), - }), - ), - async (c) => { - const query = c.req.valid("query") - const limit = query.limit ?? 100 - const sessions: Session.GlobalInfo[] = [] - for await (const session of Session.listGlobal({ - directory: query.directory, - roots: query.roots, - start: query.start, - cursor: query.cursor, - search: query.search, - limit: limit + 1, - archived: query.archived, - })) { - sessions.push(session) - } - const hasMore = sessions.length > limit - const list = hasMore ? sessions.slice(0, limit) : sessions - if (hasMore && list.length > 0) { - c.header("x-next-cursor", String(list[list.length - 1].time.updated)) - } - return c.json(list) - }, - ) - .get( - "/resource", - describeRoute({ - summary: "Get MCP resources", - description: "Get all available MCP resources from connected servers. Optionally filter by name.", - operationId: "experimental.resource.list", - responses: { - 200: { - description: "MCP resources", - content: { - "application/json": { - schema: resolver(z.record(z.string(), MCP.Resource)), - }, - }, - }, - }, - }), - async (c) => { - return c.json(await MCP.resources()) - }, - ), -) diff --git a/packages/opencode/src/server/routes/file.ts b/packages/opencode/src/server/routes/file.ts deleted file mode 100644 index 60789ef4b..000000000 --- a/packages/opencode/src/server/routes/file.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { Hono } from "hono" -import { describeRoute, validator, resolver } from "hono-openapi" -import z from "zod" -import { File } from "../../file" -import { Ripgrep } from "../../file/ripgrep" -import { LSP } from "../../lsp" -import { Instance } from "../../project/instance" -import { lazy } from "../../util/lazy" - -export const FileRoutes = lazy(() => - new Hono() - .get( - "/find", - describeRoute({ - summary: "Find text", - description: "Search for text patterns across files in the project using ripgrep.", - operationId: "find.text", - responses: { - 200: { - description: "Matches", - content: { - "application/json": { - schema: resolver(Ripgrep.Match.shape.data.array()), - }, - }, - }, - }, - }), - validator( - "query", - z.object({ - pattern: z.string(), - }), - ), - async (c) => { - const pattern = c.req.valid("query").pattern - const result = await Ripgrep.search({ - cwd: Instance.directory, - pattern, - limit: 10, - }) - return c.json(result) - }, - ) - .get( - "/find/file", - describeRoute({ - summary: "Find files", - description: "Search for files or directories by name or pattern in the project directory.", - operationId: "find.files", - responses: { - 200: { - description: "File paths", - content: { - "application/json": { - schema: resolver(z.string().array()), - }, - }, - }, - }, - }), - validator( - "query", - z.object({ - query: z.string(), - dirs: z.enum(["true", "false"]).optional(), - type: z.enum(["file", "directory"]).optional(), - limit: z.coerce.number().int().min(1).max(200).optional(), - }), - ), - async (c) => { - const query = c.req.valid("query").query - const dirs = c.req.valid("query").dirs - const type = c.req.valid("query").type - const limit = c.req.valid("query").limit - const results = await File.search({ - query, - limit: limit ?? 10, - dirs: dirs !== "false", - type, - }) - return c.json(results) - }, - ) - .get( - "/find/symbol", - describeRoute({ - summary: "Find symbols", - description: "Search for workspace symbols like functions, classes, and variables using LSP.", - operationId: "find.symbols", - responses: { - 200: { - description: "Symbols", - content: { - "application/json": { - schema: resolver(LSP.Symbol.array()), - }, - }, - }, - }, - }), - validator( - "query", - z.object({ - query: z.string(), - }), - ), - async (c) => { - /* - const query = c.req.valid("query").query - const result = await LSP.workspaceSymbol(query) - return c.json(result) - */ - return c.json([]) - }, - ) - .get( - "/file", - describeRoute({ - summary: "List files", - description: "List files and directories in a specified path.", - operationId: "file.list", - responses: { - 200: { - description: "Files and directories", - content: { - "application/json": { - schema: resolver(File.Node.array()), - }, - }, - }, - }, - }), - validator( - "query", - z.object({ - path: z.string(), - }), - ), - async (c) => { - const path = c.req.valid("query").path - const content = await File.list(path) - return c.json(content) - }, - ) - .get( - "/file/content", - describeRoute({ - summary: "Read file", - description: "Read the content of a specified file.", - operationId: "file.read", - responses: { - 200: { - description: "File content", - content: { - "application/json": { - schema: resolver(File.Content), - }, - }, - }, - }, - }), - validator( - "query", - z.object({ - path: z.string(), - }), - ), - async (c) => { - const path = c.req.valid("query").path - const content = await File.read(path) - return c.json(content) - }, - ) - .get( - "/file/status", - describeRoute({ - summary: "Get file status", - description: "Get the git status of all files in the project.", - operationId: "file.status", - responses: { - 200: { - description: "File status", - content: { - "application/json": { - schema: resolver(File.Info.array()), - }, - }, - }, - }, - }), - async (c) => { - const content = await File.status() - return c.json(content) - }, - ), -) diff --git a/packages/opencode/src/server/routes/global.ts b/packages/opencode/src/server/routes/global.ts deleted file mode 100644 index 6b0a9a164..000000000 --- a/packages/opencode/src/server/routes/global.ts +++ /dev/null @@ -1,332 +0,0 @@ -import { Hono, type Context } from "hono" -import { describeRoute, resolver, validator } from "hono-openapi" -import { streamSSE } from "hono/streaming" -import { Effect } from "effect" -import z from "zod" -import { BusEvent } from "@/bus/bus-event" -import { SyncEvent } from "@/sync" -import { GlobalBus } from "@/bus/global" -import { AppRuntime } from "@/effect/app-runtime" -import { AsyncQueue } from "@/util/queue" -import { Instance } from "../../project/instance" -import { Installation } from "@/installation" -import { Log } from "../../util/log" -import { lazy } from "../../util/lazy" -import { Config } from "../../config/config" -import { errors } from "../error" - -const log = Log.create({ service: "server" }) - -export const GlobalDisposedEvent = BusEvent.define("global.disposed", z.object({})) - -async function streamEvents(c: Context, subscribe: (q: AsyncQueue) => () => void) { - return streamSSE(c, async (stream) => { - const q = new AsyncQueue() - let done = false - - q.push( - JSON.stringify({ - payload: { - type: "server.connected", - properties: {}, - }, - }), - ) - - // Send heartbeat every 10s to prevent stalled proxy streams. - const heartbeat = setInterval(() => { - q.push( - JSON.stringify({ - payload: { - type: "server.heartbeat", - properties: {}, - }, - }), - ) - }, 10_000) - - const stop = () => { - if (done) return - done = true - clearInterval(heartbeat) - unsub() - q.push(null) - log.info("global event disconnected") - } - - const unsub = subscribe(q) - - stream.onAbort(stop) - - try { - for await (const data of q) { - if (data === null) return - await stream.writeSSE({ data }) - } - } finally { - stop() - } - }) -} - -export const GlobalRoutes = lazy(() => - new Hono() - .get( - "/health", - describeRoute({ - summary: "Get health", - description: "Get health information about the OpenCode server.", - operationId: "global.health", - responses: { - 200: { - description: "Health information", - content: { - "application/json": { - schema: resolver(z.object({ healthy: z.literal(true), version: z.string() })), - }, - }, - }, - }, - }), - async (c) => { - return c.json({ healthy: true, version: Installation.VERSION }) - }, - ) - .get( - "/event", - describeRoute({ - summary: "Get global events", - description: "Subscribe to global events from the OpenCode system using server-sent events.", - operationId: "global.event", - responses: { - 200: { - description: "Event stream", - content: { - "text/event-stream": { - schema: resolver( - z - .object({ - directory: z.string(), - project: z.string().optional(), - workspace: z.string().optional(), - payload: BusEvent.payloads(), - }) - .meta({ - ref: "GlobalEvent", - }), - ), - }, - }, - }, - }, - }), - async (c) => { - log.info("global event connected") - c.header("Cache-Control", "no-cache, no-transform") - c.header("X-Accel-Buffering", "no") - c.header("X-Content-Type-Options", "nosniff") - - return streamEvents(c, (q) => { - async function handler(event: any) { - q.push(JSON.stringify(event)) - } - GlobalBus.on("event", handler) - return () => GlobalBus.off("event", handler) - }) - }, - ) - .get( - "/sync-event", - describeRoute({ - summary: "Subscribe to global sync events", - description: "Get global sync events", - operationId: "global.sync-event.subscribe", - responses: { - 200: { - description: "Event stream", - content: { - "text/event-stream": { - schema: resolver( - z - .object({ - payload: SyncEvent.payloads(), - }) - .meta({ - ref: "SyncEvent", - }), - ), - }, - }, - }, - }, - }), - async (c) => { - log.info("global sync event connected") - c.header("Cache-Control", "no-cache, no-transform") - c.header("X-Accel-Buffering", "no") - c.header("X-Content-Type-Options", "nosniff") - return streamEvents(c, (q) => { - return SyncEvent.subscribeAll(({ def, event }) => { - // TODO: don't pass def, just pass the type (and it should - // be versioned) - q.push( - JSON.stringify({ - payload: { - ...event, - type: SyncEvent.versionedType(def.type, def.version), - }, - }), - ) - }) - }) - }, - ) - .get( - "/config", - describeRoute({ - summary: "Get global configuration", - description: "Retrieve the current global OpenCode configuration settings and preferences.", - operationId: "global.config.get", - responses: { - 200: { - description: "Get global config info", - content: { - "application/json": { - schema: resolver(Config.Info), - }, - }, - }, - }, - }), - async (c) => { - return c.json(await Config.getGlobal()) - }, - ) - .patch( - "/config", - describeRoute({ - summary: "Update global configuration", - description: "Update global OpenCode configuration settings and preferences.", - operationId: "global.config.update", - responses: { - 200: { - description: "Successfully updated global config", - content: { - "application/json": { - schema: resolver(Config.Info), - }, - }, - }, - ...errors(400), - }, - }), - validator("json", Config.Info), - async (c) => { - const config = c.req.valid("json") - const next = await Config.updateGlobal(config) - return c.json(next) - }, - ) - .post( - "/dispose", - describeRoute({ - summary: "Dispose instance", - description: "Clean up and dispose all OpenCode instances, releasing all resources.", - operationId: "global.dispose", - responses: { - 200: { - description: "Global disposed", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - async (c) => { - await Instance.disposeAll() - GlobalBus.emit("event", { - directory: "global", - payload: { - type: GlobalDisposedEvent.type, - properties: {}, - }, - }) - return c.json(true) - }, - ) - .post( - "/upgrade", - describeRoute({ - summary: "Upgrade opencode", - description: "Upgrade opencode to the specified version or latest if not specified.", - operationId: "global.upgrade", - responses: { - 200: { - description: "Upgrade result", - content: { - "application/json": { - schema: resolver( - z.union([ - z.object({ - success: z.literal(true), - version: z.string(), - }), - z.object({ - success: z.literal(false), - error: z.string(), - }), - ]), - ), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "json", - z.object({ - target: z.string().optional(), - }), - ), - async (c) => { - const result = await AppRuntime.runPromise( - Installation.Service.use((svc) => - Effect.gen(function* () { - const method = yield* svc.method() - if (method === "unknown") { - return { success: false as const, status: 400 as const, error: "Unknown installation method" } - } - - const target = c.req.valid("json").target || (yield* svc.latest(method)) - const result = yield* Effect.catch( - svc.upgrade(method, target).pipe(Effect.as({ success: true as const, version: target })), - (err) => - Effect.succeed({ - success: false as const, - status: 500 as const, - error: err instanceof Error ? err.message : String(err), - }), - ) - if (!result.success) return result - return { ...result, status: 200 as const } - }), - ), - ) - if (!result.success) { - return c.json({ success: false, error: result.error }, result.status) - } - const target = result.version - GlobalBus.emit("event", { - directory: "global", - payload: { - type: Installation.Event.Updated.type, - properties: { version: target }, - }, - }) - return c.json({ success: true, version: target }) - }, - ), -) diff --git a/packages/opencode/src/server/routes/mcp.ts b/packages/opencode/src/server/routes/mcp.ts deleted file mode 100644 index 1e604c991..000000000 --- a/packages/opencode/src/server/routes/mcp.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { Hono } from "hono" -import { describeRoute, validator, resolver } from "hono-openapi" -import z from "zod" -import { MCP } from "../../mcp" -import { Config } from "../../config/config" -import { errors } from "../error" -import { lazy } from "../../util/lazy" - -export const McpRoutes = lazy(() => - new Hono() - .get( - "/", - describeRoute({ - summary: "Get MCP status", - description: "Get the status of all Model Context Protocol (MCP) servers.", - operationId: "mcp.status", - responses: { - 200: { - description: "MCP server status", - content: { - "application/json": { - schema: resolver(z.record(z.string(), MCP.Status)), - }, - }, - }, - }, - }), - async (c) => { - return c.json(await MCP.status()) - }, - ) - .post( - "/", - describeRoute({ - summary: "Add MCP server", - description: "Dynamically add a new Model Context Protocol (MCP) server to the system.", - operationId: "mcp.add", - responses: { - 200: { - description: "MCP server added successfully", - content: { - "application/json": { - schema: resolver(z.record(z.string(), MCP.Status)), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "json", - z.object({ - name: z.string(), - config: Config.Mcp, - }), - ), - async (c) => { - const { name, config } = c.req.valid("json") - const result = await MCP.add(name, config) - return c.json(result.status) - }, - ) - .post( - "/:name/auth", - describeRoute({ - summary: "Start MCP OAuth", - description: "Start OAuth authentication flow for a Model Context Protocol (MCP) server.", - operationId: "mcp.auth.start", - responses: { - 200: { - description: "OAuth flow started", - content: { - "application/json": { - schema: resolver( - z.object({ - authorizationUrl: z.string().describe("URL to open in browser for authorization"), - }), - ), - }, - }, - }, - ...errors(400, 404), - }, - }), - async (c) => { - const name = c.req.param("name") - const supportsOAuth = await MCP.supportsOAuth(name) - if (!supportsOAuth) { - return c.json({ error: `MCP server ${name} does not support OAuth` }, 400) - } - const result = await MCP.startAuth(name) - return c.json(result) - }, - ) - .post( - "/:name/auth/callback", - describeRoute({ - summary: "Complete MCP OAuth", - description: - "Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code.", - operationId: "mcp.auth.callback", - responses: { - 200: { - description: "OAuth authentication completed", - content: { - "application/json": { - schema: resolver(MCP.Status), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "json", - z.object({ - code: z.string().describe("Authorization code from OAuth callback"), - }), - ), - async (c) => { - const name = c.req.param("name") - const { code } = c.req.valid("json") - const status = await MCP.finishAuth(name, code) - return c.json(status) - }, - ) - .post( - "/:name/auth/authenticate", - describeRoute({ - summary: "Authenticate MCP OAuth", - description: "Start OAuth flow and wait for callback (opens browser)", - operationId: "mcp.auth.authenticate", - responses: { - 200: { - description: "OAuth authentication completed", - content: { - "application/json": { - schema: resolver(MCP.Status), - }, - }, - }, - ...errors(400, 404), - }, - }), - async (c) => { - const name = c.req.param("name") - const supportsOAuth = await MCP.supportsOAuth(name) - if (!supportsOAuth) { - return c.json({ error: `MCP server ${name} does not support OAuth` }, 400) - } - const status = await MCP.authenticate(name) - return c.json(status) - }, - ) - .delete( - "/:name/auth", - describeRoute({ - summary: "Remove MCP OAuth", - description: "Remove OAuth credentials for an MCP server", - operationId: "mcp.auth.remove", - responses: { - 200: { - description: "OAuth credentials removed", - content: { - "application/json": { - schema: resolver(z.object({ success: z.literal(true) })), - }, - }, - }, - ...errors(404), - }, - }), - async (c) => { - const name = c.req.param("name") - await MCP.removeAuth(name) - return c.json({ success: true as const }) - }, - ) - .post( - "/:name/connect", - describeRoute({ - description: "Connect an MCP server", - operationId: "mcp.connect", - responses: { - 200: { - description: "MCP server connected successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - validator("param", z.object({ name: z.string() })), - async (c) => { - const { name } = c.req.valid("param") - await MCP.connect(name) - return c.json(true) - }, - ) - .post( - "/:name/disconnect", - describeRoute({ - description: "Disconnect an MCP server", - operationId: "mcp.disconnect", - responses: { - 200: { - description: "MCP server disconnected successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - validator("param", z.object({ name: z.string() })), - async (c) => { - const { name } = c.req.valid("param") - await MCP.disconnect(name) - return c.json(true) - }, - ), -) diff --git a/packages/opencode/src/server/routes/permission.ts b/packages/opencode/src/server/routes/permission.ts deleted file mode 100644 index aae9a9c3a..000000000 --- a/packages/opencode/src/server/routes/permission.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { Hono } from "hono" -import { describeRoute, validator, resolver } from "hono-openapi" -import z from "zod" -import { Permission } from "@/permission" -import { PermissionID } from "@/permission/schema" -import { errors } from "../error" -import { lazy } from "../../util/lazy" - -export const PermissionRoutes = lazy(() => - new Hono() - .post( - "/:requestID/reply", - describeRoute({ - summary: "Respond to permission request", - description: "Approve or deny a permission request from the AI assistant.", - operationId: "permission.reply", - responses: { - 200: { - description: "Permission processed successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - requestID: PermissionID.zod, - }), - ), - validator("json", z.object({ reply: Permission.Reply, message: z.string().optional() })), - async (c) => { - const params = c.req.valid("param") - const json = c.req.valid("json") - await Permission.reply({ - requestID: params.requestID, - reply: json.reply, - message: json.message, - }) - return c.json(true) - }, - ) - .get( - "/", - describeRoute({ - summary: "List pending permissions", - description: "Get all pending permission requests across all sessions.", - operationId: "permission.list", - responses: { - 200: { - description: "List of pending permissions", - content: { - "application/json": { - schema: resolver(Permission.Request.array()), - }, - }, - }, - }, - }), - async (c) => { - const permissions = await Permission.list() - return c.json(permissions) - }, - ), -) diff --git a/packages/opencode/src/server/routes/project.ts b/packages/opencode/src/server/routes/project.ts deleted file mode 100644 index e5dd5782d..000000000 --- a/packages/opencode/src/server/routes/project.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { Hono } from "hono" -import { describeRoute, validator } from "hono-openapi" -import { resolver } from "hono-openapi" -import { Instance } from "../../project/instance" -import { Project } from "../../project/project" -import z from "zod" -import { ProjectID } from "../../project/schema" -import { errors } from "../error" -import { lazy } from "../../util/lazy" -import { InstanceBootstrap } from "../../project/bootstrap" - -export const ProjectRoutes = lazy(() => - new Hono() - .get( - "/", - describeRoute({ - summary: "List all projects", - description: "Get a list of projects that have been opened with OpenCode.", - operationId: "project.list", - responses: { - 200: { - description: "List of projects", - content: { - "application/json": { - schema: resolver(Project.Info.array()), - }, - }, - }, - }, - }), - async (c) => { - const projects = Project.list() - return c.json(projects) - }, - ) - .get( - "/current", - describeRoute({ - summary: "Get current project", - description: "Retrieve the currently active project that OpenCode is working with.", - operationId: "project.current", - responses: { - 200: { - description: "Current project information", - content: { - "application/json": { - schema: resolver(Project.Info), - }, - }, - }, - }, - }), - async (c) => { - return c.json(Instance.project) - }, - ) - .post( - "/git/init", - describeRoute({ - summary: "Initialize git repository", - description: "Create a git repository for the current project and return the refreshed project info.", - operationId: "project.initGit", - responses: { - 200: { - description: "Project information after git initialization", - content: { - "application/json": { - schema: resolver(Project.Info), - }, - }, - }, - }, - }), - async (c) => { - const dir = Instance.directory - const prev = Instance.project - const next = await Project.initGit({ - directory: dir, - project: prev, - }) - if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next) - await Instance.reload({ - directory: dir, - worktree: dir, - project: next, - init: InstanceBootstrap, - }) - return c.json(next) - }, - ) - .patch( - "/:projectID", - describeRoute({ - summary: "Update project", - description: "Update project properties such as name, icon, and commands.", - operationId: "project.update", - responses: { - 200: { - description: "Updated project information", - content: { - "application/json": { - schema: resolver(Project.Info), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator("param", z.object({ projectID: ProjectID.zod })), - validator("json", Project.UpdateInput.omit({ projectID: true })), - async (c) => { - const projectID = c.req.valid("param").projectID - const body = c.req.valid("json") - const project = await Project.update({ ...body, projectID }) - return c.json(project) - }, - ), -) diff --git a/packages/opencode/src/server/routes/provider.ts b/packages/opencode/src/server/routes/provider.ts deleted file mode 100644 index efd126ea0..000000000 --- a/packages/opencode/src/server/routes/provider.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { Hono } from "hono" -import { describeRoute, validator, resolver } from "hono-openapi" -import z from "zod" -import { Config } from "../../config/config" -import { Provider } from "../../provider/provider" -import { ModelsDev } from "../../provider/models" -import { ProviderAuth } from "../../provider/auth" -import { ProviderID } from "../../provider/schema" -import { AppRuntime } from "../../effect/app-runtime" -import { mapValues } from "remeda" -import { errors } from "../error" -import { lazy } from "../../util/lazy" -import { Log } from "../../util/log" - -const log = Log.create({ service: "server" }) - -export const ProviderRoutes = lazy(() => - new Hono() - .get( - "/", - describeRoute({ - summary: "List providers", - description: "Get a list of all available AI providers, including both available and connected ones.", - operationId: "provider.list", - responses: { - 200: { - description: "List of providers", - content: { - "application/json": { - schema: resolver( - z.object({ - all: Provider.Info.array(), - default: z.record(z.string(), z.string()), - connected: z.array(z.string()), - }), - ), - }, - }, - }, - }, - }), - async (c) => { - const config = await Config.get() - const disabled = new Set(config.disabled_providers ?? []) - const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined - - const allProviders = await ModelsDev.get() - const filteredProviders: Record = {} - for (const [key, value] of Object.entries(allProviders)) { - if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { - filteredProviders[key] = value - } - } - - const connected = await Provider.list() - const providers = Object.assign( - mapValues(filteredProviders, (x) => Provider.fromModelsDevProvider(x)), - connected, - ) - return c.json({ - all: Object.values(providers), - default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id), - connected: Object.keys(connected), - }) - }, - ) - .get( - "/auth", - describeRoute({ - summary: "Get provider auth methods", - description: "Retrieve available authentication methods for all AI providers.", - operationId: "provider.auth", - responses: { - 200: { - description: "Provider auth methods", - content: { - "application/json": { - schema: resolver(z.record(z.string(), z.array(ProviderAuth.Method))), - }, - }, - }, - }, - }), - async (c) => { - return c.json(await AppRuntime.runPromise(ProviderAuth.Service.use((svc) => svc.methods()))) - }, - ) - .post( - "/:providerID/oauth/authorize", - describeRoute({ - summary: "OAuth authorize", - description: "Initiate OAuth authorization for a specific AI provider to get an authorization URL.", - operationId: "provider.oauth.authorize", - responses: { - 200: { - description: "Authorization URL and method", - content: { - "application/json": { - schema: resolver(ProviderAuth.Authorization.optional()), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "param", - z.object({ - providerID: ProviderID.zod.meta({ description: "Provider ID" }), - }), - ), - validator( - "json", - z.object({ - method: z.number().meta({ description: "Auth method index" }), - inputs: z.record(z.string(), z.string()).optional().meta({ description: "Prompt inputs" }), - }), - ), - async (c) => { - const providerID = c.req.valid("param").providerID - const { method, inputs } = c.req.valid("json") - const result = await AppRuntime.runPromise( - ProviderAuth.Service.use((svc) => - svc.authorize({ - providerID, - method, - inputs, - }), - ), - ) - return c.json(result) - }, - ) - .post( - "/:providerID/oauth/callback", - describeRoute({ - summary: "OAuth callback", - description: "Handle the OAuth callback from a provider after user authorization.", - operationId: "provider.oauth.callback", - responses: { - 200: { - description: "OAuth callback processed successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "param", - z.object({ - providerID: ProviderID.zod.meta({ description: "Provider ID" }), - }), - ), - validator( - "json", - z.object({ - method: z.number().meta({ description: "Auth method index" }), - code: z.string().optional().meta({ description: "OAuth authorization code" }), - }), - ), - async (c) => { - const providerID = c.req.valid("param").providerID - const { method, code } = c.req.valid("json") - await AppRuntime.runPromise( - ProviderAuth.Service.use((svc) => - svc.callback({ - providerID, - method, - code, - }), - ), - ) - return c.json(true) - }, - ), -) diff --git a/packages/opencode/src/server/routes/pty.ts b/packages/opencode/src/server/routes/pty.ts deleted file mode 100644 index c333f4dd6..000000000 --- a/packages/opencode/src/server/routes/pty.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { Hono, type MiddlewareHandler } from "hono" -import { describeRoute, validator, resolver } from "hono-openapi" -import type { UpgradeWebSocket } from "hono/ws" -import z from "zod" -import { Pty } from "@/pty" -import { PtyID } from "@/pty/schema" -import { NotFoundError } from "../../storage/db" -import { errors } from "../error" - -export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { - return new Hono() - .get( - "/", - describeRoute({ - summary: "List PTY sessions", - description: "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.", - operationId: "pty.list", - responses: { - 200: { - description: "List of sessions", - content: { - "application/json": { - schema: resolver(Pty.Info.array()), - }, - }, - }, - }, - }), - async (c) => { - return c.json(await Pty.list()) - }, - ) - .post( - "/", - describeRoute({ - summary: "Create PTY session", - description: "Create a new pseudo-terminal (PTY) session for running shell commands and processes.", - operationId: "pty.create", - responses: { - 200: { - description: "Created session", - content: { - "application/json": { - schema: resolver(Pty.Info), - }, - }, - }, - ...errors(400), - }, - }), - validator("json", Pty.CreateInput), - async (c) => { - const info = await Pty.create(c.req.valid("json")) - return c.json(info) - }, - ) - .get( - "/:ptyID", - describeRoute({ - summary: "Get PTY session", - description: "Retrieve detailed information about a specific pseudo-terminal (PTY) session.", - operationId: "pty.get", - responses: { - 200: { - description: "Session info", - content: { - "application/json": { - schema: resolver(Pty.Info), - }, - }, - }, - ...errors(404), - }, - }), - validator("param", z.object({ ptyID: PtyID.zod })), - async (c) => { - const info = await Pty.get(c.req.valid("param").ptyID) - if (!info) { - throw new NotFoundError({ message: "Session not found" }) - } - return c.json(info) - }, - ) - .put( - "/:ptyID", - describeRoute({ - summary: "Update PTY session", - description: "Update properties of an existing pseudo-terminal (PTY) session.", - operationId: "pty.update", - responses: { - 200: { - description: "Updated session", - content: { - "application/json": { - schema: resolver(Pty.Info), - }, - }, - }, - ...errors(400), - }, - }), - validator("param", z.object({ ptyID: PtyID.zod })), - validator("json", Pty.UpdateInput), - async (c) => { - const info = await Pty.update(c.req.valid("param").ptyID, c.req.valid("json")) - return c.json(info) - }, - ) - .delete( - "/:ptyID", - describeRoute({ - summary: "Remove PTY session", - description: "Remove and terminate a specific pseudo-terminal (PTY) session.", - operationId: "pty.remove", - responses: { - 200: { - description: "Session removed", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(404), - }, - }), - validator("param", z.object({ ptyID: PtyID.zod })), - async (c) => { - await Pty.remove(c.req.valid("param").ptyID) - return c.json(true) - }, - ) - .get( - "/:ptyID/connect", - describeRoute({ - summary: "Connect to PTY session", - description: "Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.", - operationId: "pty.connect", - responses: { - 200: { - description: "Connected session", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(404), - }, - }), - validator("param", z.object({ ptyID: PtyID.zod })), - upgradeWebSocket(async (c) => { - const id = PtyID.zod.parse(c.req.param("ptyID")) - const cursor = (() => { - const value = c.req.query("cursor") - if (!value) return - const parsed = Number(value) - if (!Number.isSafeInteger(parsed) || parsed < -1) return - return parsed - })() - let handler: Awaited> - if (!(await Pty.get(id))) throw new Error("Session not found") - - type Socket = { - readyState: number - send: (data: string | Uint8Array | ArrayBuffer) => void - close: (code?: number, reason?: string) => void - } - - const isSocket = (value: unknown): value is Socket => { - if (!value || typeof value !== "object") return false - if (!("readyState" in value)) return false - if (!("send" in value) || typeof (value as { send?: unknown }).send !== "function") return false - if (!("close" in value) || typeof (value as { close?: unknown }).close !== "function") return false - return typeof (value as { readyState?: unknown }).readyState === "number" - } - - const pending: string[] = [] - let ready = false - - return { - async onOpen(_event, ws) { - const socket = ws.raw - if (!isSocket(socket)) { - ws.close() - return - } - handler = await Pty.connect(id, socket, cursor) - ready = true - for (const msg of pending) handler?.onMessage(msg) - pending.length = 0 - }, - onMessage(event) { - if (typeof event.data !== "string") return - if (!ready) { - pending.push(event.data) - return - } - handler?.onMessage(event.data) - }, - onClose() { - handler?.onClose() - }, - onError() { - handler?.onClose() - }, - } - }), - ) -} diff --git a/packages/opencode/src/server/routes/question.ts b/packages/opencode/src/server/routes/question.ts deleted file mode 100644 index 3fff895fa..000000000 --- a/packages/opencode/src/server/routes/question.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { Hono } from "hono" -import { describeRoute, validator } from "hono-openapi" -import { resolver } from "hono-openapi" -import { QuestionID } from "@/question/schema" -import { Question } from "../../question" -import z from "zod" -import { errors } from "../error" -import { lazy } from "../../util/lazy" - -export const QuestionRoutes = lazy(() => - new Hono() - .get( - "/", - describeRoute({ - summary: "List pending questions", - description: "Get all pending question requests across all sessions.", - operationId: "question.list", - responses: { - 200: { - description: "List of pending questions", - content: { - "application/json": { - schema: resolver(Question.Request.array()), - }, - }, - }, - }, - }), - async (c) => { - const questions = await Question.list() - return c.json(questions) - }, - ) - .post( - "/:requestID/reply", - describeRoute({ - summary: "Reply to question request", - description: "Provide answers to a question request from the AI assistant.", - operationId: "question.reply", - responses: { - 200: { - description: "Question answered successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - requestID: QuestionID.zod, - }), - ), - validator("json", Question.Reply), - async (c) => { - const params = c.req.valid("param") - const json = c.req.valid("json") - await Question.reply({ - requestID: params.requestID, - answers: json.answers, - }) - return c.json(true) - }, - ) - .post( - "/:requestID/reject", - describeRoute({ - summary: "Reject question request", - description: "Reject a question request from the AI assistant.", - operationId: "question.reject", - responses: { - 200: { - description: "Question rejected successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - requestID: QuestionID.zod, - }), - ), - async (c) => { - const params = c.req.valid("param") - await Question.reject(params.requestID) - return c.json(true) - }, - ), -) diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts deleted file mode 100644 index a2a15d59e..000000000 --- a/packages/opencode/src/server/routes/session.ts +++ /dev/null @@ -1,1046 +0,0 @@ -import { Hono } from "hono" -import { stream } from "hono/streaming" -import { describeRoute, validator, resolver } from "hono-openapi" -import { SessionID, MessageID, PartID } from "@/session/schema" -import z from "zod" -import { Session } from "../../session" -import { MessageV2 } from "../../session/message-v2" -import { SessionPrompt } from "../../session/prompt" -import { SessionRunState } from "@/session/run-state" -import { SessionCompaction } from "../../session/compaction" -import { SessionRevert } from "../../session/revert" -import { SessionShare } from "@/share/session" -import { SessionStatus } from "@/session/status" -import { SessionSummary } from "@/session/summary" -import { Todo } from "../../session/todo" -import { AppRuntime } from "../../effect/app-runtime" -import { Agent } from "../../agent/agent" -import { Snapshot } from "@/snapshot" -import { Command } from "../../command" -import { Log } from "../../util/log" -import { Permission } from "@/permission" -import { PermissionID } from "@/permission/schema" -import { ModelID, ProviderID } from "@/provider/schema" -import { errors } from "../error" -import { lazy } from "../../util/lazy" -import { Bus } from "../../bus" -import { NamedError } from "@opencode-ai/util/error" - -const log = Log.create({ service: "server" }) - -export const SessionRoutes = lazy(() => - new Hono() - .get( - "/", - describeRoute({ - summary: "List sessions", - description: "Get a list of all OpenCode sessions, sorted by most recently updated.", - operationId: "session.list", - responses: { - 200: { - description: "List of sessions", - content: { - "application/json": { - schema: resolver(Session.Info.array()), - }, - }, - }, - }, - }), - validator( - "query", - z.object({ - directory: z.string().optional().meta({ description: "Filter sessions by project directory" }), - roots: z.coerce.boolean().optional().meta({ description: "Only return root sessions (no parentID)" }), - start: z.coerce - .number() - .optional() - .meta({ description: "Filter sessions updated on or after this timestamp (milliseconds since epoch)" }), - search: z.string().optional().meta({ description: "Filter sessions by title (case-insensitive)" }), - limit: z.coerce.number().optional().meta({ description: "Maximum number of sessions to return" }), - }), - ), - async (c) => { - const query = c.req.valid("query") - const sessions: Session.Info[] = [] - for await (const session of Session.list({ - directory: query.directory, - roots: query.roots, - start: query.start, - search: query.search, - limit: query.limit, - })) { - sessions.push(session) - } - return c.json(sessions) - }, - ) - .get( - "/status", - describeRoute({ - summary: "Get session status", - description: "Retrieve the current status of all sessions, including active, idle, and completed states.", - operationId: "session.status", - responses: { - 200: { - description: "Get session status", - content: { - "application/json": { - schema: resolver(z.record(z.string(), SessionStatus.Info)), - }, - }, - }, - ...errors(400), - }, - }), - async (c) => { - const result = await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.list())) - return c.json(Object.fromEntries(result)) - }, - ) - .get( - "/:sessionID", - describeRoute({ - summary: "Get session", - description: "Retrieve detailed information about a specific OpenCode session.", - tags: ["Session"], - operationId: "session.get", - responses: { - 200: { - description: "Get session", - content: { - "application/json": { - schema: resolver(Session.Info), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: Session.get.schema, - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const session = await Session.get(sessionID) - return c.json(session) - }, - ) - .get( - "/:sessionID/children", - describeRoute({ - summary: "Get session children", - tags: ["Session"], - description: "Retrieve all child sessions that were forked from the specified parent session.", - operationId: "session.children", - responses: { - 200: { - description: "List of children", - content: { - "application/json": { - schema: resolver(Session.Info.array()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: Session.children.schema, - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const session = await Session.children(sessionID) - return c.json(session) - }, - ) - .get( - "/:sessionID/todo", - describeRoute({ - summary: "Get session todos", - description: "Retrieve the todo list associated with a specific session, showing tasks and action items.", - operationId: "session.todo", - responses: { - 200: { - description: "Todo list", - content: { - "application/json": { - schema: resolver(Todo.Info.array()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const todos = await AppRuntime.runPromise(Todo.Service.use((svc) => svc.get(sessionID))) - return c.json(todos) - }, - ) - .post( - "/", - describeRoute({ - summary: "Create session", - description: "Create a new OpenCode session for interacting with AI assistants and managing conversations.", - operationId: "session.create", - responses: { - ...errors(400), - 200: { - description: "Successfully created session", - content: { - "application/json": { - schema: resolver(Session.Info), - }, - }, - }, - }, - }), - validator("json", Session.create.schema), - async (c) => { - const body = c.req.valid("json") ?? {} - const session = await SessionShare.create(body) - return c.json(session) - }, - ) - .delete( - "/:sessionID", - describeRoute({ - summary: "Delete session", - description: "Delete a session and permanently remove all associated data, including messages and history.", - operationId: "session.delete", - responses: { - 200: { - description: "Successfully deleted session", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: Session.remove.schema, - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - await Session.remove(sessionID) - return c.json(true) - }, - ) - .patch( - "/:sessionID", - describeRoute({ - summary: "Update session", - description: "Update properties of an existing session, such as title or other metadata.", - operationId: "session.update", - responses: { - 200: { - description: "Successfully updated session", - content: { - "application/json": { - schema: resolver(Session.Info), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator( - "json", - z.object({ - title: z.string().optional(), - time: z - .object({ - archived: z.number().optional(), - }) - .optional(), - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const updates = c.req.valid("json") - - if (updates.title !== undefined) { - await Session.setTitle({ sessionID, title: updates.title }) - } - if (updates.time?.archived !== undefined) { - await Session.setArchived({ sessionID, time: updates.time.archived }) - } - - const session = await Session.get(sessionID) - return c.json(session) - }, - ) - // TODO(v2): remove this dedicated route and rely on the normal `/init` command flow. - .post( - "/:sessionID/init", - describeRoute({ - summary: "Initialize session", - description: - "Analyze the current application and create an AGENTS.md file with project-specific agent configurations.", - operationId: "session.init", - responses: { - 200: { - description: "200", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator( - "json", - z.object({ - modelID: ModelID.zod, - providerID: ProviderID.zod, - messageID: MessageID.zod, - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - await SessionPrompt.command({ - sessionID, - messageID: body.messageID, - model: body.providerID + "/" + body.modelID, - command: Command.Default.INIT, - arguments: "", - }) - return c.json(true) - }, - ) - .post( - "/:sessionID/fork", - describeRoute({ - summary: "Fork session", - description: "Create a new session by forking an existing session at a specific message point.", - operationId: "session.fork", - responses: { - 200: { - description: "200", - content: { - "application/json": { - schema: resolver(Session.Info), - }, - }, - }, - }, - }), - validator( - "param", - z.object({ - sessionID: Session.fork.schema.shape.sessionID, - }), - ), - validator("json", Session.fork.schema.omit({ sessionID: true })), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - const result = await Session.fork({ ...body, sessionID }) - return c.json(result) - }, - ) - .post( - "/:sessionID/abort", - describeRoute({ - summary: "Abort session", - description: "Abort an active session and stop any ongoing AI processing or command execution.", - operationId: "session.abort", - responses: { - 200: { - description: "Aborted session", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - async (c) => { - await SessionPrompt.cancel(c.req.valid("param").sessionID) - return c.json(true) - }, - ) - .post( - "/:sessionID/share", - describeRoute({ - summary: "Share session", - description: "Create a shareable link for a session, allowing others to view the conversation.", - operationId: "session.share", - responses: { - 200: { - description: "Successfully shared session", - content: { - "application/json": { - schema: resolver(Session.Info), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - await SessionShare.share(sessionID) - const session = await Session.get(sessionID) - return c.json(session) - }, - ) - .get( - "/:sessionID/diff", - describeRoute({ - summary: "Get message diff", - description: "Get the file changes (diff) that resulted from a specific user message in the session.", - operationId: "session.diff", - responses: { - 200: { - description: "Successfully retrieved diff", - content: { - "application/json": { - schema: resolver(Snapshot.FileDiff.array()), - }, - }, - }, - }, - }), - validator( - "param", - z.object({ - sessionID: SessionSummary.DiffInput.shape.sessionID, - }), - ), - validator( - "query", - z.object({ - messageID: SessionSummary.DiffInput.shape.messageID, - }), - ), - async (c) => { - const query = c.req.valid("query") - const params = c.req.valid("param") - const result = await SessionSummary.diff({ - sessionID: params.sessionID, - messageID: query.messageID, - }) - return c.json(result) - }, - ) - .delete( - "/:sessionID/share", - describeRoute({ - summary: "Unshare session", - description: "Remove the shareable link for a session, making it private again.", - operationId: "session.unshare", - responses: { - 200: { - description: "Successfully unshared session", - content: { - "application/json": { - schema: resolver(Session.Info), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - await SessionShare.unshare(sessionID) - const session = await Session.get(sessionID) - return c.json(session) - }, - ) - .post( - "/:sessionID/summarize", - describeRoute({ - summary: "Summarize session", - description: "Generate a concise summary of the session using AI compaction to preserve key information.", - operationId: "session.summarize", - responses: { - 200: { - description: "Summarized session", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator( - "json", - z.object({ - providerID: ProviderID.zod, - modelID: ModelID.zod, - auto: z.boolean().optional().default(false), - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - const session = await Session.get(sessionID) - await SessionRevert.cleanup(session) - const msgs = await Session.messages({ sessionID }) - let currentAgent = await Agent.defaultAgent() - for (let i = msgs.length - 1; i >= 0; i--) { - const info = msgs[i].info - if (info.role === "user") { - currentAgent = info.agent || (await Agent.defaultAgent()) - break - } - } - await SessionCompaction.create({ - sessionID, - agent: currentAgent, - model: { - providerID: body.providerID, - modelID: body.modelID, - }, - auto: body.auto, - }) - await SessionPrompt.loop({ sessionID }) - return c.json(true) - }, - ) - .get( - "/:sessionID/message", - describeRoute({ - summary: "Get session messages", - description: "Retrieve all messages in a session, including user prompts and AI responses.", - operationId: "session.messages", - responses: { - 200: { - description: "List of messages", - content: { - "application/json": { - schema: resolver(MessageV2.WithParts.array()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator( - "query", - z - .object({ - limit: z.coerce - .number() - .int() - .min(0) - .optional() - .meta({ description: "Maximum number of messages to return" }), - before: z - .string() - .optional() - .meta({ description: "Opaque cursor for loading older messages" }) - .refine( - (value) => { - if (!value) return true - try { - MessageV2.cursor.decode(value) - return true - } catch { - return false - } - }, - { message: "Invalid cursor" }, - ), - }) - .refine((value) => !value.before || value.limit !== undefined, { - message: "before requires limit", - path: ["before"], - }), - ), - async (c) => { - const query = c.req.valid("query") - const sessionID = c.req.valid("param").sessionID - if (query.limit === undefined) { - await Session.get(sessionID) - const messages = await Session.messages({ sessionID }) - return c.json(messages) - } - - if (query.limit === 0) { - await Session.get(sessionID) - const messages = await Session.messages({ sessionID }) - return c.json(messages) - } - - const page = await MessageV2.page({ - sessionID, - limit: query.limit, - before: query.before, - }) - if (page.cursor) { - const url = new URL(c.req.url) - url.searchParams.set("limit", query.limit.toString()) - url.searchParams.set("before", page.cursor) - c.header("Access-Control-Expose-Headers", "Link, X-Next-Cursor") - c.header("Link", `<${url.toString()}>; rel=\"next\"`) - c.header("X-Next-Cursor", page.cursor) - } - return c.json(page.items) - }, - ) - .get( - "/:sessionID/message/:messageID", - describeRoute({ - summary: "Get message", - description: "Retrieve a specific message from a session by its message ID.", - operationId: "session.message", - responses: { - 200: { - description: "Message", - content: { - "application/json": { - schema: resolver( - z.object({ - info: MessageV2.Info, - parts: MessageV2.Part.array(), - }), - ), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - messageID: MessageID.zod, - }), - ), - async (c) => { - const params = c.req.valid("param") - const message = await MessageV2.get({ - sessionID: params.sessionID, - messageID: params.messageID, - }) - return c.json(message) - }, - ) - .delete( - "/:sessionID/message/:messageID", - describeRoute({ - summary: "Delete message", - description: - "Permanently delete a specific message (and all of its parts) from a session. This does not revert any file changes that may have been made while processing the message.", - operationId: "session.deleteMessage", - responses: { - 200: { - description: "Successfully deleted message", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - messageID: MessageID.zod, - }), - ), - async (c) => { - const params = c.req.valid("param") - await SessionRunState.assertNotBusy(params.sessionID) - await Session.removeMessage({ - sessionID: params.sessionID, - messageID: params.messageID, - }) - return c.json(true) - }, - ) - .delete( - "/:sessionID/message/:messageID/part/:partID", - describeRoute({ - description: "Delete a part from a message", - operationId: "part.delete", - responses: { - 200: { - description: "Successfully deleted part", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - messageID: MessageID.zod, - partID: PartID.zod, - }), - ), - async (c) => { - const params = c.req.valid("param") - await Session.removePart({ - sessionID: params.sessionID, - messageID: params.messageID, - partID: params.partID, - }) - return c.json(true) - }, - ) - .patch( - "/:sessionID/message/:messageID/part/:partID", - describeRoute({ - description: "Update a part in a message", - operationId: "part.update", - responses: { - 200: { - description: "Successfully updated part", - content: { - "application/json": { - schema: resolver(MessageV2.Part), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - messageID: MessageID.zod, - partID: PartID.zod, - }), - ), - validator("json", MessageV2.Part), - async (c) => { - const params = c.req.valid("param") - const body = c.req.valid("json") - if (body.id !== params.partID || body.messageID !== params.messageID || body.sessionID !== params.sessionID) { - throw new Error( - `Part mismatch: body.id='${body.id}' vs partID='${params.partID}', body.messageID='${body.messageID}' vs messageID='${params.messageID}', body.sessionID='${body.sessionID}' vs sessionID='${params.sessionID}'`, - ) - } - const part = await Session.updatePart(body) - return c.json(part) - }, - ) - .post( - "/:sessionID/message", - describeRoute({ - summary: "Send message", - description: "Create and send a new message to a session, streaming the AI response.", - operationId: "session.prompt", - responses: { - 200: { - description: "Created message", - content: { - "application/json": { - schema: resolver( - z.object({ - info: MessageV2.Assistant, - parts: MessageV2.Part.array(), - }), - ), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })), - async (c) => { - c.status(200) - c.header("Content-Type", "application/json") - return stream(c, async (stream) => { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - const msg = await SessionPrompt.prompt({ ...body, sessionID }) - stream.write(JSON.stringify(msg)) - }) - }, - ) - .post( - "/:sessionID/prompt_async", - describeRoute({ - summary: "Send async message", - description: - "Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.", - operationId: "session.prompt_async", - responses: { - 204: { - description: "Prompt accepted", - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - SessionPrompt.prompt({ ...body, sessionID }).catch((err) => { - log.error("prompt_async failed", { sessionID, error: err }) - Bus.publish(Session.Event.Error, { - sessionID, - error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(), - }) - }) - - return c.body(null, 204) - }, - ) - .post( - "/:sessionID/command", - describeRoute({ - summary: "Send command", - description: "Send a new command to a session for execution by the AI assistant.", - operationId: "session.command", - responses: { - 200: { - description: "Created message", - content: { - "application/json": { - schema: resolver( - z.object({ - info: MessageV2.Assistant, - parts: MessageV2.Part.array(), - }), - ), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator("json", SessionPrompt.CommandInput.omit({ sessionID: true })), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - const msg = await SessionPrompt.command({ ...body, sessionID }) - return c.json(msg) - }, - ) - .post( - "/:sessionID/shell", - describeRoute({ - summary: "Run shell command", - description: "Execute a shell command within the session context and return the AI's response.", - operationId: "session.shell", - responses: { - 200: { - description: "Created message", - content: { - "application/json": { - schema: resolver(MessageV2.WithParts), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator("json", SessionPrompt.ShellInput.omit({ sessionID: true })), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - const msg = await SessionPrompt.shell({ ...body, sessionID }) - return c.json(msg) - }, - ) - .post( - "/:sessionID/revert", - describeRoute({ - summary: "Revert message", - description: "Revert a specific message in a session, undoing its effects and restoring the previous state.", - operationId: "session.revert", - responses: { - 200: { - description: "Updated session", - content: { - "application/json": { - schema: resolver(Session.Info), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator("json", SessionRevert.RevertInput.omit({ sessionID: true })), - async (c) => { - const sessionID = c.req.valid("param").sessionID - log.info("revert", c.req.valid("json")) - const session = await SessionRevert.revert({ - sessionID, - ...c.req.valid("json"), - }) - return c.json(session) - }, - ) - .post( - "/:sessionID/unrevert", - describeRoute({ - summary: "Restore reverted messages", - description: "Restore all previously reverted messages in a session.", - operationId: "session.unrevert", - responses: { - 200: { - description: "Updated session", - content: { - "application/json": { - schema: resolver(Session.Info), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const session = await SessionRevert.unrevert({ sessionID }) - return c.json(session) - }, - ) - .post( - "/:sessionID/permissions/:permissionID", - describeRoute({ - summary: "Respond to permission", - deprecated: true, - description: "Approve or deny a permission request from the AI assistant.", - operationId: "permission.respond", - responses: { - 200: { - description: "Permission processed successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - permissionID: PermissionID.zod, - }), - ), - validator("json", z.object({ response: Permission.Reply })), - async (c) => { - const params = c.req.valid("param") - Permission.reply({ - requestID: params.permissionID, - reply: c.req.valid("json").response, - }) - return c.json(true) - }, - ), -) diff --git a/packages/opencode/src/server/routes/tui.ts b/packages/opencode/src/server/routes/tui.ts deleted file mode 100644 index 8650a0ccc..000000000 --- a/packages/opencode/src/server/routes/tui.ts +++ /dev/null @@ -1,379 +0,0 @@ -import { Hono, type Context } from "hono" -import { describeRoute, validator, resolver } from "hono-openapi" -import z from "zod" -import { Bus } from "../../bus" -import { Session } from "../../session" -import { TuiEvent } from "@/cli/cmd/tui/event" -import { AsyncQueue } from "../../util/queue" -import { errors } from "../error" -import { lazy } from "../../util/lazy" - -const TuiRequest = z.object({ - path: z.string(), - body: z.any(), -}) - -type TuiRequest = z.infer - -const request = new AsyncQueue() -const response = new AsyncQueue() - -export async function callTui(ctx: Context) { - const body = await ctx.req.json() - request.push({ - path: ctx.req.path, - body, - }) - return response.next() -} - -const TuiControlRoutes = new Hono() - .get( - "/next", - describeRoute({ - summary: "Get next TUI request", - description: "Retrieve the next TUI (Terminal User Interface) request from the queue for processing.", - operationId: "tui.control.next", - responses: { - 200: { - description: "Next TUI request", - content: { - "application/json": { - schema: resolver(TuiRequest), - }, - }, - }, - }, - }), - async (c) => { - const req = await request.next() - return c.json(req) - }, - ) - .post( - "/response", - describeRoute({ - summary: "Submit TUI response", - description: "Submit a response to the TUI request queue to complete a pending request.", - operationId: "tui.control.response", - responses: { - 200: { - description: "Response submitted successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - validator("json", z.any()), - async (c) => { - const body = c.req.valid("json") - response.push(body) - return c.json(true) - }, - ) - -export const TuiRoutes = lazy(() => - new Hono() - .post( - "/append-prompt", - describeRoute({ - summary: "Append TUI prompt", - description: "Append prompt to the TUI", - operationId: "tui.appendPrompt", - responses: { - 200: { - description: "Prompt processed successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400), - }, - }), - validator("json", TuiEvent.PromptAppend.properties), - async (c) => { - await Bus.publish(TuiEvent.PromptAppend, c.req.valid("json")) - return c.json(true) - }, - ) - .post( - "/open-help", - describeRoute({ - summary: "Open help dialog", - description: "Open the help dialog in the TUI to display user assistance information.", - operationId: "tui.openHelp", - responses: { - 200: { - description: "Help dialog opened successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - async (c) => { - await Bus.publish(TuiEvent.CommandExecute, { - command: "help.show", - }) - return c.json(true) - }, - ) - .post( - "/open-sessions", - describeRoute({ - summary: "Open sessions dialog", - description: "Open the session dialog", - operationId: "tui.openSessions", - responses: { - 200: { - description: "Session dialog opened successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - async (c) => { - await Bus.publish(TuiEvent.CommandExecute, { - command: "session.list", - }) - return c.json(true) - }, - ) - .post( - "/open-themes", - describeRoute({ - summary: "Open themes dialog", - description: "Open the theme dialog", - operationId: "tui.openThemes", - responses: { - 200: { - description: "Theme dialog opened successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - async (c) => { - await Bus.publish(TuiEvent.CommandExecute, { - command: "session.list", - }) - return c.json(true) - }, - ) - .post( - "/open-models", - describeRoute({ - summary: "Open models dialog", - description: "Open the model dialog", - operationId: "tui.openModels", - responses: { - 200: { - description: "Model dialog opened successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - async (c) => { - await Bus.publish(TuiEvent.CommandExecute, { - command: "model.list", - }) - return c.json(true) - }, - ) - .post( - "/submit-prompt", - describeRoute({ - summary: "Submit TUI prompt", - description: "Submit the prompt", - operationId: "tui.submitPrompt", - responses: { - 200: { - description: "Prompt submitted successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - async (c) => { - await Bus.publish(TuiEvent.CommandExecute, { - command: "prompt.submit", - }) - return c.json(true) - }, - ) - .post( - "/clear-prompt", - describeRoute({ - summary: "Clear TUI prompt", - description: "Clear the prompt", - operationId: "tui.clearPrompt", - responses: { - 200: { - description: "Prompt cleared successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - async (c) => { - await Bus.publish(TuiEvent.CommandExecute, { - command: "prompt.clear", - }) - return c.json(true) - }, - ) - .post( - "/execute-command", - describeRoute({ - summary: "Execute TUI command", - description: "Execute a TUI command (e.g. agent_cycle)", - operationId: "tui.executeCommand", - responses: { - 200: { - description: "Command executed successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400), - }, - }), - validator("json", z.object({ command: z.string() })), - async (c) => { - const command = c.req.valid("json").command - await Bus.publish(TuiEvent.CommandExecute, { - // @ts-expect-error - command: { - session_new: "session.new", - session_share: "session.share", - session_interrupt: "session.interrupt", - session_compact: "session.compact", - messages_page_up: "session.page.up", - messages_page_down: "session.page.down", - messages_line_up: "session.line.up", - messages_line_down: "session.line.down", - messages_half_page_up: "session.half.page.up", - messages_half_page_down: "session.half.page.down", - messages_first: "session.first", - messages_last: "session.last", - agent_cycle: "agent.cycle", - }[command], - }) - return c.json(true) - }, - ) - .post( - "/show-toast", - describeRoute({ - summary: "Show TUI toast", - description: "Show a toast notification in the TUI", - operationId: "tui.showToast", - responses: { - 200: { - description: "Toast notification shown successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - validator("json", TuiEvent.ToastShow.properties), - async (c) => { - await Bus.publish(TuiEvent.ToastShow, c.req.valid("json")) - return c.json(true) - }, - ) - .post( - "/publish", - describeRoute({ - summary: "Publish TUI event", - description: "Publish a TUI event", - operationId: "tui.publish", - responses: { - 200: { - description: "Event published successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "json", - z.union( - Object.values(TuiEvent).map((def) => { - return z - .object({ - type: z.literal(def.type), - properties: def.properties, - }) - .meta({ - ref: "Event" + "." + def.type, - }) - }), - ), - ), - async (c) => { - const evt = c.req.valid("json") - await Bus.publish(Object.values(TuiEvent).find((def) => def.type === evt.type)!, evt.properties) - return c.json(true) - }, - ) - .post( - "/select-session", - describeRoute({ - summary: "Select session", - description: "Navigate the TUI to display the specified session.", - operationId: "tui.selectSession", - responses: { - 200: { - description: "Session selected successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator("json", TuiEvent.SessionSelect.properties), - async (c) => { - const { sessionID } = c.req.valid("json") - await Session.get(sessionID) - await Bus.publish(TuiEvent.SessionSelect, { sessionID }) - return c.json(true) - }, - ) - .route("/control", TuiControlRoutes), -) diff --git a/packages/opencode/src/server/routes/workspace.ts b/packages/opencode/src/server/routes/workspace.ts deleted file mode 100644 index 419321654..000000000 --- a/packages/opencode/src/server/routes/workspace.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { Hono } from "hono" -import { describeRoute, resolver, validator } from "hono-openapi" -import z from "zod" -import { Workspace } from "../../control-plane/workspace" -import { Instance } from "../../project/instance" -import { errors } from "../error" -import { lazy } from "../../util/lazy" - -export const WorkspaceRoutes = lazy(() => - new Hono() - .post( - "/", - describeRoute({ - summary: "Create workspace", - description: "Create a workspace for the current project.", - operationId: "experimental.workspace.create", - responses: { - 200: { - description: "Workspace created", - content: { - "application/json": { - schema: resolver(Workspace.Info), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "json", - Workspace.create.schema.omit({ - projectID: true, - }), - ), - async (c) => { - const body = c.req.valid("json") - const workspace = await Workspace.create({ - projectID: Instance.project.id, - ...body, - }) - return c.json(workspace) - }, - ) - .get( - "/", - describeRoute({ - summary: "List workspaces", - description: "List all workspaces.", - operationId: "experimental.workspace.list", - responses: { - 200: { - description: "Workspaces", - content: { - "application/json": { - schema: resolver(z.array(Workspace.Info)), - }, - }, - }, - }, - }), - async (c) => { - return c.json(Workspace.list(Instance.project)) - }, - ) - .get( - "/status", - describeRoute({ - summary: "Workspace status", - description: "Get connection status for workspaces in the current project.", - operationId: "experimental.workspace.status", - responses: { - 200: { - description: "Workspace status", - content: { - "application/json": { - schema: resolver(z.array(Workspace.ConnectionStatus)), - }, - }, - }, - }, - }), - async (c) => { - const ids = new Set(Workspace.list(Instance.project).map((item) => item.id)) - return c.json(Workspace.status().filter((item) => ids.has(item.workspaceID))) - }, - ) - .delete( - "/:id", - describeRoute({ - summary: "Remove workspace", - description: "Remove an existing workspace.", - operationId: "experimental.workspace.remove", - responses: { - 200: { - description: "Workspace removed", - content: { - "application/json": { - schema: resolver(Workspace.Info.optional()), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "param", - z.object({ - id: Workspace.Info.shape.id, - }), - ), - async (c) => { - const { id } = c.req.valid("param") - return c.json(await Workspace.remove(id)) - }, - ), -) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index c4f2a931b..02ec7356e 100644 --- a/packages/opencode/src/server/server.ts +++ b/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 } - 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 { const built = create(opts) - const start = (port: number) => - new Promise((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 | 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 }, } diff --git a/packages/opencode/src/server/ui/index.ts b/packages/opencode/src/server/ui/index.ts new file mode 100644 index 000000000..afe6e510f --- /dev/null +++ b/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).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( + /]*\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 + } + }) diff --git a/packages/opencode/test/memory/abort-leak-webfetch.ts b/packages/opencode/test/memory/abort-leak-webfetch.ts new file mode 100644 index 000000000..1286d5f0b --- /dev/null +++ b/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) +} diff --git a/packages/opencode/test/memory/abort-leak.test.ts b/packages/opencode/test/memory/abort-leak.test.ts new file mode 100644 index 000000000..d30ad45e4 --- /dev/null +++ b/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 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) + }) +}) diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts index c01a02ef4..32b6c601d 100644 --- a/packages/opencode/test/plugin/loader-shared.test.ts +++ b/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: ["broken-plugin@9.9.9"] }, 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: ["broken-plugin@9.9.9", 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 broken-plugin@9.9.9") && x.includes("boom"))).toBe( - true, - ) + await load(tmp.path) + expect(install).toHaveBeenCalledWith("broken-plugin@9.9.9") + 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 () => { diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts index 7ba95f3b1..fac336837 100644 --- a/packages/opencode/test/server/session-messages.test.ts +++ b/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) -- cgit v1.2.3