From 7605acff650db0d41d80429b662b5c0725d89675 Mon Sep 17 00:00:00 2001 From: James Long Date: Fri, 17 Apr 2026 02:06:20 -0400 Subject: refactor(core): move server routes around to clarify workspacing (#23031) --- .../src/control-plane/workspace-context.ts | 4 +- packages/opencode/src/server/control/index.ts | 162 --- packages/opencode/src/server/instance/config.ts | 88 -- packages/opencode/src/server/instance/event.ts | 88 -- .../opencode/src/server/instance/experimental.ts | 422 ------- packages/opencode/src/server/instance/file.ts | 210 ---- packages/opencode/src/server/instance/global.ts | 287 ----- .../opencode/src/server/instance/httpapi/config.ts | 51 - .../src/server/instance/httpapi/permission.ts | 72 -- .../src/server/instance/httpapi/project.ts | 62 - .../src/server/instance/httpapi/provider.ts | 142 --- .../src/server/instance/httpapi/question.ts | 86 -- .../opencode/src/server/instance/httpapi/server.ts | 136 --- packages/opencode/src/server/instance/index.ts | 306 ----- packages/opencode/src/server/instance/mcp.ts | 246 ---- .../opencode/src/server/instance/middleware.ts | 152 --- .../opencode/src/server/instance/permission.ts | 74 -- packages/opencode/src/server/instance/project.ts | 118 -- packages/opencode/src/server/instance/provider.ts | 169 --- packages/opencode/src/server/instance/pty.ts | 258 ---- packages/opencode/src/server/instance/question.ts | 110 -- packages/opencode/src/server/instance/session.ts | 1120 ----------------- packages/opencode/src/server/instance/sync.ts | 119 -- packages/opencode/src/server/instance/trace.ts | 33 - packages/opencode/src/server/instance/tui.ts | 380 ------ packages/opencode/src/server/instance/workspace.ts | 203 ---- .../opencode/src/server/routes/control/index.ts | 162 +++ .../src/server/routes/control/workspace.ts | 203 ++++ packages/opencode/src/server/routes/global.ts | 287 +++++ .../opencode/src/server/routes/instance/config.ts | 88 ++ .../opencode/src/server/routes/instance/event.ts | 88 ++ .../src/server/routes/instance/experimental.ts | 420 +++++++ .../opencode/src/server/routes/instance/file.ts | 210 ++++ .../src/server/routes/instance/httpapi/config.ts | 51 + .../server/routes/instance/httpapi/permission.ts | 72 ++ .../src/server/routes/instance/httpapi/project.ts | 62 + .../src/server/routes/instance/httpapi/provider.ts | 142 +++ .../src/server/routes/instance/httpapi/question.ts | 86 ++ .../src/server/routes/instance/httpapi/server.ts | 136 +++ .../opencode/src/server/routes/instance/index.ts | 305 +++++ .../opencode/src/server/routes/instance/mcp.ts | 246 ++++ .../src/server/routes/instance/permission.ts | 74 ++ .../opencode/src/server/routes/instance/project.ts | 118 ++ .../src/server/routes/instance/provider.ts | 169 +++ .../opencode/src/server/routes/instance/pty.ts | 258 ++++ .../src/server/routes/instance/question.ts | 110 ++ .../opencode/src/server/routes/instance/session.ts | 1120 +++++++++++++++++ .../opencode/src/server/routes/instance/sync.ts | 119 ++ .../opencode/src/server/routes/instance/trace.ts | 33 + .../opencode/src/server/routes/instance/tui.ts | 380 ++++++ packages/opencode/src/server/routes/ui.ts | 55 + packages/opencode/src/server/server.ts | 70 +- packages/opencode/src/server/ui/index.ts | 55 - packages/opencode/src/server/workspace.ts | 119 ++ .../opencode/test/server/session-messages.test.ts | 13 - packages/sdk/js/src/v2/gen/sdk.gen.ts | 602 +++++----- packages/sdk/js/src/v2/gen/types.gen.ts | 362 +++--- packages/sdk/openapi.json | 1258 ++++++++++---------- 58 files changed, 6279 insertions(+), 6292 deletions(-) delete mode 100644 packages/opencode/src/server/control/index.ts delete mode 100644 packages/opencode/src/server/instance/config.ts delete mode 100644 packages/opencode/src/server/instance/event.ts delete mode 100644 packages/opencode/src/server/instance/experimental.ts delete mode 100644 packages/opencode/src/server/instance/file.ts delete mode 100644 packages/opencode/src/server/instance/global.ts delete mode 100644 packages/opencode/src/server/instance/httpapi/config.ts delete mode 100644 packages/opencode/src/server/instance/httpapi/permission.ts delete mode 100644 packages/opencode/src/server/instance/httpapi/project.ts delete mode 100644 packages/opencode/src/server/instance/httpapi/provider.ts delete mode 100644 packages/opencode/src/server/instance/httpapi/question.ts delete mode 100644 packages/opencode/src/server/instance/httpapi/server.ts delete mode 100644 packages/opencode/src/server/instance/index.ts delete mode 100644 packages/opencode/src/server/instance/mcp.ts delete mode 100644 packages/opencode/src/server/instance/middleware.ts delete mode 100644 packages/opencode/src/server/instance/permission.ts delete mode 100644 packages/opencode/src/server/instance/project.ts delete mode 100644 packages/opencode/src/server/instance/provider.ts delete mode 100644 packages/opencode/src/server/instance/pty.ts delete mode 100644 packages/opencode/src/server/instance/question.ts delete mode 100644 packages/opencode/src/server/instance/session.ts delete mode 100644 packages/opencode/src/server/instance/sync.ts delete mode 100644 packages/opencode/src/server/instance/trace.ts delete mode 100644 packages/opencode/src/server/instance/tui.ts delete mode 100644 packages/opencode/src/server/instance/workspace.ts create mode 100644 packages/opencode/src/server/routes/control/index.ts create mode 100644 packages/opencode/src/server/routes/control/workspace.ts create mode 100644 packages/opencode/src/server/routes/global.ts create mode 100644 packages/opencode/src/server/routes/instance/config.ts create mode 100644 packages/opencode/src/server/routes/instance/event.ts create mode 100644 packages/opencode/src/server/routes/instance/experimental.ts create mode 100644 packages/opencode/src/server/routes/instance/file.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/config.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/permission.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/project.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/provider.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/question.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/server.ts create mode 100644 packages/opencode/src/server/routes/instance/index.ts create mode 100644 packages/opencode/src/server/routes/instance/mcp.ts create mode 100644 packages/opencode/src/server/routes/instance/permission.ts create mode 100644 packages/opencode/src/server/routes/instance/project.ts create mode 100644 packages/opencode/src/server/routes/instance/provider.ts create mode 100644 packages/opencode/src/server/routes/instance/pty.ts create mode 100644 packages/opencode/src/server/routes/instance/question.ts create mode 100644 packages/opencode/src/server/routes/instance/session.ts create mode 100644 packages/opencode/src/server/routes/instance/sync.ts create mode 100644 packages/opencode/src/server/routes/instance/trace.ts create mode 100644 packages/opencode/src/server/routes/instance/tui.ts create mode 100644 packages/opencode/src/server/routes/ui.ts delete mode 100644 packages/opencode/src/server/ui/index.ts create mode 100644 packages/opencode/src/server/workspace.ts diff --git a/packages/opencode/src/control-plane/workspace-context.ts b/packages/opencode/src/control-plane/workspace-context.ts index 3d4fa5bae..85ef596e7 100644 --- a/packages/opencode/src/control-plane/workspace-context.ts +++ b/packages/opencode/src/control-plane/workspace-context.ts @@ -2,13 +2,13 @@ import { LocalContext } from "../util" import type { WorkspaceID } from "../control-plane/schema" export interface WorkspaceContext { - workspaceID: WorkspaceID + workspaceID: WorkspaceID | undefined } const context = LocalContext.create("instance") export const WorkspaceContext = { - async provide(input: { workspaceID: WorkspaceID; fn: () => R }): Promise { + async provide(input: { workspaceID?: WorkspaceID; fn: () => R }): Promise { return context.provide({ workspaceID: input.workspaceID }, () => input.fn()) }, diff --git a/packages/opencode/src/server/control/index.ts b/packages/opencode/src/server/control/index.ts deleted file mode 100644 index 737f958d6..000000000 --- a/packages/opencode/src/server/control/index.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { Auth } from "@/auth" -import { AppRuntime } from "@/effect/app-runtime" -import { Log } from "@/util" -import { Effect } from "effect" -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 AppRuntime.runPromise( - Effect.gen(function* () { - const auth = yield* Auth.Service - yield* 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 AppRuntime.runPromise( - Effect.gen(function* () { - const auth = yield* Auth.Service - yield* 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/config.ts b/packages/opencode/src/server/instance/config.ts deleted file mode 100644 index 15c393fe5..000000000 --- a/packages/opencode/src/server/instance/config.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { Hono } from "hono" -import { describeRoute, validator, resolver } from "hono-openapi" -import z from "zod" -import { Config } from "../../config" -import { Provider } from "../../provider" -import { errors } from "../error" -import { lazy } from "../../util/lazy" -import { AppRuntime } from "../../effect/app-runtime" -import { jsonRequest } from "./trace" - -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) => - jsonRequest("ConfigRoutes.get", c, function* () { - const cfg = yield* Config.Service - return yield* cfg.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 AppRuntime.runPromise(Config.Service.use((cfg) => cfg.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(Provider.ConfigProvidersResult.zod), - }, - }, - }, - }, - }), - async (c) => - jsonRequest("ConfigRoutes.providers", c, function* () { - const svc = yield* Provider.Service - const providers = yield* svc.list() - return { - providers: Object.values(providers), - default: Provider.defaultModelIDs(providers), - } - }), - ), -) diff --git a/packages/opencode/src/server/instance/event.ts b/packages/opencode/src/server/instance/event.ts deleted file mode 100644 index 103d3d7cf..000000000 --- a/packages/opencode/src/server/instance/event.ts +++ /dev/null @@ -1,88 +0,0 @@ -import z from "zod" -import { Hono } from "hono" -import { describeRoute, resolver } from "hono-openapi" -import { streamSSE } from "hono/streaming" -import { Log } from "@/util" -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( - z.union(BusEvent.payloads()).meta({ - ref: "Event", - }), - ), - }, - }, - }, - }, - }), - 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 deleted file mode 100644 index 6fe99a8c3..000000000 --- a/packages/opencode/src/server/instance/experimental.ts +++ /dev/null @@ -1,422 +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" -import { Worktree } from "../../worktree" -import { Instance } from "../../project/instance" -import { Project } from "../../project" -import { MCP } from "../../mcp" -import { Session } from "../../session" -import { Config } from "../../config" -import { ConsoleState } from "../../config/console-state" -import { Account } from "../../account/account" -import { AccountID, OrgID } from "../../account/schema" -import { AppRuntime } from "../../effect/app-runtime" -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) => { - const ids = await AppRuntime.runPromise( - Effect.gen(function* () { - const registry = yield* ToolRegistry.Service - return yield* registry.ids() - }), - ) - return c.json(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 AppRuntime.runPromise( - Effect.gen(function* () { - const agents = yield* Agent.Service - const registry = yield* ToolRegistry.Service - return yield* registry.tools({ - providerID: ProviderID.make(provider), - modelID: ModelID.make(model), - agent: yield* agents.get(yield* agents.defaultAgent()), - }) - }), - ) - return c.json( - tools.map((t) => ({ - id: t.id, - description: t.description, - parameters: z.toJSONSchema(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 AppRuntime.runPromise(Worktree.Service.use((svc) => svc.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 AppRuntime.runPromise(Project.Service.use((svc) => svc.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 AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove(body))) - await AppRuntime.runPromise( - Project.Service.use((svc) => svc.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 AppRuntime.runPromise(Worktree.Service.use((svc) => svc.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 AppRuntime.runPromise( - Effect.gen(function* () { - const mcp = yield* MCP.Service - return yield* mcp.resources() - }), - ), - ) - }, - ), -) diff --git a/packages/opencode/src/server/instance/file.ts b/packages/opencode/src/server/instance/file.ts deleted file mode 100644 index db5e22777..000000000 --- a/packages/opencode/src/server/instance/file.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { Hono } from "hono" -import { describeRoute, validator, resolver } from "hono-openapi" -import { Effect } from "effect" -import z from "zod" -import { AppRuntime } from "../../effect/app-runtime" -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 AppRuntime.runPromise( - Ripgrep.Service.use((svc) => svc.search({ cwd: Instance.directory, pattern, limit: 10 })), - ) - return c.json(result.items) - }, - ) - .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 AppRuntime.runPromise( - Effect.gen(function* () { - return yield* File.Service.use((svc) => - svc.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) => { - 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 AppRuntime.runPromise( - Effect.gen(function* () { - return yield* File.Service.use((svc) => svc.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 AppRuntime.runPromise( - Effect.gen(function* () { - return yield* File.Service.use((svc) => svc.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 AppRuntime.runPromise( - Effect.gen(function* () { - return yield* File.Service.use((svc) => svc.status()) - }), - ) - return c.json(content) - }, - ), -) diff --git a/packages/opencode/src/server/instance/global.ts b/packages/opencode/src/server/instance/global.ts deleted file mode 100644 index 8208cf966..000000000 --- a/packages/opencode/src/server/instance/global.ts +++ /dev/null @@ -1,287 +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 { InstallationVersion } from "@/installation/version" -import { Log } from "../../util" -import { lazy } from "../../util/lazy" -import { Config } from "../../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: InstallationVersion }) - }, - ) - .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: z.union([...BusEvent.payloads(), ...SyncEvent.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( - "/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 AppRuntime.runPromise(Config.Service.use((cfg) => cfg.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 AppRuntime.runPromise(Config.Service.use((cfg) => cfg.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/httpapi/config.ts b/packages/opencode/src/server/instance/httpapi/config.ts deleted file mode 100644 index 14aa94f9f..000000000 --- a/packages/opencode/src/server/instance/httpapi/config.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Config } from "@/config" -import { Provider } from "@/provider" -import { Effect, Layer } from "effect" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" - -const root = "/config" - -export const ConfigApi = HttpApi.make("config") - .add( - HttpApiGroup.make("config") - .add( - HttpApiEndpoint.get("providers", `${root}/providers`, { - success: Provider.ConfigProvidersResult, - }).annotateMerge( - OpenApi.annotations({ - identifier: "config.providers", - summary: "List config providers", - description: "Get a list of all configured AI providers and their default models.", - }), - ), - ) - .annotateMerge( - OpenApi.annotations({ - title: "config", - description: "Experimental HttpApi config routes.", - }), - ), - ) - .annotateMerge( - OpenApi.annotations({ - title: "opencode experimental HttpApi", - version: "0.0.1", - description: "Experimental HttpApi surface for selected instance routes.", - }), - ) - -export const configHandlers = Layer.unwrap( - Effect.gen(function* () { - const svc = yield* Provider.Service - - const providers = Effect.fn("ConfigHttpApi.providers")(function* () { - const providers = yield* svc.list() - return { - providers: Object.values(providers), - default: Provider.defaultModelIDs(providers), - } - }) - - return HttpApiBuilder.group(ConfigApi, "config", (handlers) => handlers.handle("providers", providers)) - }), -).pipe(Layer.provide(Provider.defaultLayer), Layer.provide(Config.defaultLayer)) diff --git a/packages/opencode/src/server/instance/httpapi/permission.ts b/packages/opencode/src/server/instance/httpapi/permission.ts deleted file mode 100644 index ed8cb4e27..000000000 --- a/packages/opencode/src/server/instance/httpapi/permission.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Permission } from "@/permission" -import { PermissionID } from "@/permission/schema" -import { Effect, Layer, Schema } from "effect" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" - -const root = "/permission" - -export const PermissionApi = HttpApi.make("permission") - .add( - HttpApiGroup.make("permission") - .add( - HttpApiEndpoint.get("list", root, { - success: Schema.Array(Permission.Request), - }).annotateMerge( - OpenApi.annotations({ - identifier: "permission.list", - summary: "List pending permissions", - description: "Get all pending permission requests across all sessions.", - }), - ), - HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, { - params: { requestID: PermissionID }, - payload: Permission.ReplyBody, - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "permission.reply", - summary: "Respond to permission request", - description: "Approve or deny a permission request from the AI assistant.", - }), - ), - ) - .annotateMerge( - OpenApi.annotations({ - title: "permission", - description: "Experimental HttpApi permission routes.", - }), - ), - ) - .annotateMerge( - OpenApi.annotations({ - title: "opencode experimental HttpApi", - version: "0.0.1", - description: "Experimental HttpApi surface for selected instance routes.", - }), - ) - -export const permissionHandlers = Layer.unwrap( - Effect.gen(function* () { - const svc = yield* Permission.Service - - const list = Effect.fn("PermissionHttpApi.list")(function* () { - return yield* svc.list() - }) - - const reply = Effect.fn("PermissionHttpApi.reply")(function* (ctx: { - params: { requestID: PermissionID } - payload: Permission.ReplyBody - }) { - yield* svc.reply({ - requestID: ctx.params.requestID, - reply: ctx.payload.reply, - message: ctx.payload.message, - }) - return true - }) - - return HttpApiBuilder.group(PermissionApi, "permission", (handlers) => - handlers.handle("list", list).handle("reply", reply), - ) - }), -).pipe(Layer.provide(Permission.defaultLayer)) diff --git a/packages/opencode/src/server/instance/httpapi/project.ts b/packages/opencode/src/server/instance/httpapi/project.ts deleted file mode 100644 index 7d2d8462f..000000000 --- a/packages/opencode/src/server/instance/httpapi/project.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Instance } from "@/project/instance" -import { Project } from "@/project" -import { Effect, Layer, Schema } from "effect" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" - -const root = "/project" - -export const ProjectApi = HttpApi.make("project") - .add( - HttpApiGroup.make("project") - .add( - HttpApiEndpoint.get("list", root, { - success: Schema.Array(Project.Info), - }).annotateMerge( - OpenApi.annotations({ - identifier: "project.list", - summary: "List all projects", - description: "Get a list of projects that have been opened with OpenCode.", - }), - ), - HttpApiEndpoint.get("current", `${root}/current`, { - success: Project.Info, - }).annotateMerge( - OpenApi.annotations({ - identifier: "project.current", - summary: "Get current project", - description: "Retrieve the currently active project that OpenCode is working with.", - }), - ), - ) - .annotateMerge( - OpenApi.annotations({ - title: "project", - description: "Experimental HttpApi project routes.", - }), - ), - ) - .annotateMerge( - OpenApi.annotations({ - title: "opencode experimental HttpApi", - version: "0.0.1", - description: "Experimental HttpApi surface for selected instance routes.", - }), - ) - -export const projectHandlers = Layer.unwrap( - Effect.gen(function* () { - const svc = yield* Project.Service - - const list = Effect.fn("ProjectHttpApi.list")(function* () { - return yield* svc.list() - }) - - const current = Effect.fn("ProjectHttpApi.current")(function* () { - return Instance.project - }) - - return HttpApiBuilder.group(ProjectApi, "project", (handlers) => - handlers.handle("list", list).handle("current", current), - ) - }), -).pipe(Layer.provide(Project.defaultLayer)) diff --git a/packages/opencode/src/server/instance/httpapi/provider.ts b/packages/opencode/src/server/instance/httpapi/provider.ts deleted file mode 100644 index 67831a1fa..000000000 --- a/packages/opencode/src/server/instance/httpapi/provider.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { ProviderAuth } from "@/provider" -import { Config } from "@/config" -import { ModelsDev } from "@/provider" -import { Provider } from "@/provider" -import { ProviderID } from "@/provider/schema" -import { mapValues } from "remeda" -import { Effect, Layer, Schema } from "effect" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" - -const root = "/provider" - -export const ProviderApi = HttpApi.make("provider") - .add( - HttpApiGroup.make("provider") - .add( - HttpApiEndpoint.get("list", root, { - success: Provider.ListResult, - }).annotateMerge( - OpenApi.annotations({ - identifier: "provider.list", - summary: "List providers", - description: "Get a list of all available AI providers, including both available and connected ones.", - }), - ), - HttpApiEndpoint.get("auth", `${root}/auth`, { - success: ProviderAuth.Methods, - }).annotateMerge( - OpenApi.annotations({ - identifier: "provider.auth", - summary: "Get provider auth methods", - description: "Retrieve available authentication methods for all AI providers.", - }), - ), - HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, { - params: { providerID: ProviderID }, - payload: ProviderAuth.AuthorizeInput, - success: ProviderAuth.Authorization, - }).annotateMerge( - OpenApi.annotations({ - identifier: "provider.oauth.authorize", - summary: "Start OAuth authorization", - description: "Start the OAuth authorization flow for a provider.", - }), - ), - HttpApiEndpoint.post("callback", `${root}/:providerID/oauth/callback`, { - params: { providerID: ProviderID }, - payload: ProviderAuth.CallbackInput, - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "provider.oauth.callback", - summary: "Handle OAuth callback", - description: "Handle the OAuth callback from a provider after user authorization.", - }), - ), - ) - .annotateMerge( - OpenApi.annotations({ - title: "provider", - description: "Experimental HttpApi provider routes.", - }), - ), - ) - .annotateMerge( - OpenApi.annotations({ - title: "opencode experimental HttpApi", - version: "0.0.1", - description: "Experimental HttpApi surface for selected instance routes.", - }), - ) - -export const providerHandlers = Layer.unwrap( - Effect.gen(function* () { - const cfg = yield* Config.Service - const provider = yield* Provider.Service - const svc = yield* ProviderAuth.Service - - const list = Effect.fn("ProviderHttpApi.list")(function* () { - const config = yield* cfg.get() - const all = yield* Effect.promise(() => ModelsDev.get()) - const disabled = new Set(config.disabled_providers ?? []) - const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined - const filtered: Record = {} - for (const [key, value] of Object.entries(all)) { - if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { - filtered[key] = value - } - } - const connected = yield* provider.list() - const providers = Object.assign( - mapValues(filtered, (item) => Provider.fromModelsDevProvider(item)), - connected, - ) - return { - all: Object.values(providers), - default: Provider.defaultModelIDs(providers), - connected: Object.keys(connected), - } - }) - - const auth = Effect.fn("ProviderHttpApi.auth")(function* () { - return yield* svc.methods() - }) - - const authorize = Effect.fn("ProviderHttpApi.authorize")(function* (ctx: { - params: { providerID: ProviderID } - payload: ProviderAuth.AuthorizeInput - }) { - const result = yield* svc - .authorize({ - providerID: ctx.params.providerID, - method: ctx.payload.method, - inputs: ctx.payload.inputs, - }) - .pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({})))) - if (!result) return yield* new HttpApiError.BadRequest({}) - return result - }) - - const callback = Effect.fn("ProviderHttpApi.callback")(function* (ctx: { - params: { providerID: ProviderID } - payload: ProviderAuth.CallbackInput - }) { - yield* svc - .callback({ - providerID: ctx.params.providerID, - method: ctx.payload.method, - code: ctx.payload.code, - }) - .pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({})))) - return true - }) - - return HttpApiBuilder.group(ProviderApi, "provider", (handlers) => - handlers.handle("list", list).handle("auth", auth).handle("authorize", authorize).handle("callback", callback), - ) - }), -).pipe( - Layer.provide(ProviderAuth.defaultLayer), - Layer.provide(Provider.defaultLayer), - Layer.provide(Config.defaultLayer), -) diff --git a/packages/opencode/src/server/instance/httpapi/question.ts b/packages/opencode/src/server/instance/httpapi/question.ts deleted file mode 100644 index 3192b530e..000000000 --- a/packages/opencode/src/server/instance/httpapi/question.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { Question } from "@/question" -import { QuestionID } from "@/question/schema" -import { Effect, Layer, Schema } from "effect" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" - -const root = "/question" - -export const QuestionApi = HttpApi.make("question") - .add( - HttpApiGroup.make("question") - .add( - HttpApiEndpoint.get("list", root, { - success: Schema.Array(Question.Request), - }).annotateMerge( - OpenApi.annotations({ - identifier: "question.list", - summary: "List pending questions", - description: "Get all pending question requests across all sessions.", - }), - ), - HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, { - params: { requestID: QuestionID }, - payload: Question.Reply, - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "question.reply", - summary: "Reply to question request", - description: "Provide answers to a question request from the AI assistant.", - }), - ), - HttpApiEndpoint.post("reject", `${root}/:requestID/reject`, { - params: { requestID: QuestionID }, - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "question.reject", - summary: "Reject question request", - description: "Reject a question request from the AI assistant.", - }), - ), - ) - .annotateMerge( - OpenApi.annotations({ - title: "question", - description: "Question routes.", - }), - ), - ) - .annotateMerge( - OpenApi.annotations({ - title: "opencode HttpApi", - version: "0.0.1", - description: "Effect HttpApi surface for instance routes.", - }), - ) - -export const questionHandlers = Layer.unwrap( - Effect.gen(function* () { - const svc = yield* Question.Service - - const list = Effect.fn("QuestionHttpApi.list")(function* () { - return yield* svc.list() - }) - - const reply = Effect.fn("QuestionHttpApi.reply")(function* (ctx: { - params: { requestID: QuestionID } - payload: Question.Reply - }) { - yield* svc.reply({ - requestID: ctx.params.requestID, - answers: ctx.payload.answers, - }) - return true - }) - - const reject = Effect.fn("QuestionHttpApi.reject")(function* (ctx: { params: { requestID: QuestionID } }) { - yield* svc.reject(ctx.params.requestID) - return true - }) - - return HttpApiBuilder.group(QuestionApi, "question", (handlers) => - handlers.handle("list", list).handle("reply", reply).handle("reject", reject), - ) - }), -).pipe(Layer.provide(Question.defaultLayer)) diff --git a/packages/opencode/src/server/instance/httpapi/server.ts b/packages/opencode/src/server/instance/httpapi/server.ts deleted file mode 100644 index b4442d640..000000000 --- a/packages/opencode/src/server/instance/httpapi/server.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { Effect, Layer, Redacted, Schema } from "effect" -import { HttpApiBuilder, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi" -import { HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http" -import { AppRuntime } from "@/effect/app-runtime" -import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" -import { Observability } from "@/effect" -import { memoMap } from "@/effect/run-service" -import { Flag } from "@/flag/flag" -import { InstanceBootstrap } from "@/project/bootstrap" -import { Instance } from "@/project/instance" -import { lazy } from "@/util/lazy" -import { Filesystem } from "@/util" -import { ConfigApi, configHandlers } from "./config" -import { PermissionApi, permissionHandlers } from "./permission" -import { ProjectApi, projectHandlers } from "./project" -import { ProviderApi, providerHandlers } from "./provider" -import { QuestionApi, questionHandlers } from "./question" - -const Query = Schema.Struct({ - directory: Schema.optional(Schema.String), - workspace: Schema.optional(Schema.String), - auth_token: Schema.optional(Schema.String), -}) - -const Headers = Schema.Struct({ - authorization: Schema.optional(Schema.String), - "x-opencode-directory": Schema.optional(Schema.String), -}) - -function decode(input: string) { - try { - return decodeURIComponent(input) - } catch { - return input - } -} - -class Unauthorized extends Schema.TaggedErrorClass()( - "Unauthorized", - { message: Schema.String }, - { httpApiStatus: 401 }, -) {} - -class Authorization extends HttpApiMiddleware.Service()("@opencode/ExperimentalHttpApiAuthorization", { - error: Unauthorized, - security: { - basic: HttpApiSecurity.basic, - }, -}) {} - -const normalize = HttpRouter.middleware()( - Effect.gen(function* () { - return (effect) => - Effect.gen(function* () { - const query = yield* HttpServerRequest.schemaSearchParams(Query) - if (!query.auth_token) return yield* effect - const req = yield* HttpServerRequest.HttpServerRequest - const next = req.modify({ - headers: { - ...req.headers, - authorization: `Basic ${query.auth_token}`, - }, - }) - return yield* effect.pipe(Effect.provideService(HttpServerRequest.HttpServerRequest, next)) - }) - }), -).layer - -const auth = Layer.succeed( - Authorization, - Authorization.of({ - basic: (effect, { credential }) => - Effect.gen(function* () { - if (!Flag.OPENCODE_SERVER_PASSWORD) return yield* effect - - const user = Flag.OPENCODE_SERVER_USERNAME ?? "opencode" - if (credential.username !== user) { - return yield* new Unauthorized({ message: "Unauthorized" }) - } - if (Redacted.value(credential.password) !== Flag.OPENCODE_SERVER_PASSWORD) { - return yield* new Unauthorized({ message: "Unauthorized" }) - } - return yield* effect - }), - }), -) - -const instance = HttpRouter.middleware()( - Effect.gen(function* () { - return (effect) => - Effect.gen(function* () { - const query = yield* HttpServerRequest.schemaSearchParams(Query) - const headers = yield* HttpServerRequest.schemaHeaders(Headers) - const raw = query.directory || headers["x-opencode-directory"] || process.cwd() - const workspace = query.workspace || undefined - const ctx = yield* Effect.promise(() => - Instance.provide({ - directory: Filesystem.resolve(decode(raw)), - init: () => AppRuntime.runPromise(InstanceBootstrap), - fn: () => Instance.current, - }), - ) - - const next = workspace ? effect.pipe(Effect.provideService(WorkspaceRef, workspace)) : effect - return yield* next.pipe(Effect.provideService(InstanceRef, ctx)) - }) - }), -).layer - -const QuestionSecured = QuestionApi.middleware(Authorization) -const PermissionSecured = PermissionApi.middleware(Authorization) -const ProjectSecured = ProjectApi.middleware(Authorization) -const ProviderSecured = ProviderApi.middleware(Authorization) -const ConfigSecured = ConfigApi.middleware(Authorization) - -export const routes = Layer.mergeAll( - HttpApiBuilder.layer(ConfigSecured).pipe(Layer.provide(configHandlers)), - HttpApiBuilder.layer(ProjectSecured).pipe(Layer.provide(projectHandlers)), - HttpApiBuilder.layer(QuestionSecured).pipe(Layer.provide(questionHandlers)), - HttpApiBuilder.layer(PermissionSecured).pipe(Layer.provide(permissionHandlers)), - HttpApiBuilder.layer(ProviderSecured).pipe(Layer.provide(providerHandlers)), -).pipe( - Layer.provide(auth), - Layer.provide(normalize), - Layer.provide(instance), - Layer.provide(HttpServer.layerServices), - Layer.provideMerge(Observability.layer), -) - -export const webHandler = lazy(() => - HttpRouter.toWebHandler(routes, { - memoMap, - }), -) - -export * as ExperimentalHttpApiServer from "./server" diff --git a/packages/opencode/src/server/instance/index.ts b/packages/opencode/src/server/instance/index.ts deleted file mode 100644 index cfcaffc59..000000000 --- a/packages/opencode/src/server/instance/index.ts +++ /dev/null @@ -1,306 +0,0 @@ -import { describeRoute, resolver, validator } from "hono-openapi" -import { Hono } from "hono" -import type { UpgradeWebSocket } from "hono/ws" -import { Context, Effect } from "effect" -import z from "zod" -import { Format } from "../../format" -import { TuiRoutes } from "./tui" -import { Instance } from "../../project/instance" -import { Vcs } from "../../project" -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 { Flag } from "@/flag/flag" -import { ExperimentalHttpApiServer } from "./httpapi/server" -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 { SyncRoutes } from "./sync" -import { WorkspaceRouterMiddleware } from "./middleware" -import { AppRuntime } from "@/effect/app-runtime" - -export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { - const app = new Hono().use(WorkspaceRouterMiddleware(upgrade)) - - if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) { - const handler = ExperimentalHttpApiServer.webHandler().handler - const context = Context.empty() as Context.Context - app.get("/question", (c) => handler(c.req.raw, context)) - app.post("/question/:requestID/reply", (c) => handler(c.req.raw, context)) - app.post("/question/:requestID/reject", (c) => handler(c.req.raw, context)) - app.get("/permission", (c) => handler(c.req.raw, context)) - app.post("/permission/:requestID/reply", (c) => handler(c.req.raw, context)) - app.get("/config/providers", (c) => handler(c.req.raw, context)) - app.get("/provider", (c) => handler(c.req.raw, context)) - app.get("/provider/auth", (c) => handler(c.req.raw, context)) - app.post("/provider/:providerID/oauth/authorize", (c) => handler(c.req.raw, context)) - app.post("/provider/:providerID/oauth/callback", (c) => handler(c.req.raw, context)) - app.get("/project", (c) => handler(c.req.raw, context)) - app.get("/project/current", (c) => handler(c.req.raw, context)) - } - - return app - .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("/sync", SyncRoutes()) - .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) => { - return c.json( - await AppRuntime.runPromise( - Effect.gen(function* () { - const vcs = yield* Vcs.Service - const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { - concurrency: 2, - }) - return { 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 AppRuntime.runPromise( - Effect.gen(function* () { - const vcs = yield* Vcs.Service - return yield* 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 AppRuntime.runPromise(Agent.Service.use((svc) => svc.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 AppRuntime.runPromise( - Effect.gen(function* () { - const skill = yield* Skill.Service - return yield* 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) => { - const items = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.status())) - return c.json(items) - }, - ) - .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 deleted file mode 100644 index f6e6f1edd..000000000 --- a/packages/opencode/src/server/instance/mcp.ts +++ /dev/null @@ -1,246 +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" -import { ConfigMCP } from "../../config/mcp" -import { AppRuntime } from "../../effect/app-runtime" -import { errors } from "../error" -import { lazy } from "../../util/lazy" -import { Effect } from "effect" - -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 AppRuntime.runPromise(MCP.Service.use((mcp) => 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: ConfigMCP.Info, - }), - ), - async (c) => { - const { name, config } = c.req.valid("json") - const result = await AppRuntime.runPromise(MCP.Service.use((mcp) => 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 result = await AppRuntime.runPromise( - Effect.gen(function* () { - const mcp = yield* MCP.Service - const supports = yield* mcp.supportsOAuth(name) - if (!supports) return { supports } - return { - supports, - auth: yield* mcp.startAuth(name), - } - }), - ) - if (!result.supports) { - return c.json({ error: `MCP server ${name} does not support OAuth` }, 400) - } - return c.json(result.auth) - }, - ) - .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 AppRuntime.runPromise(MCP.Service.use((mcp) => 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 result = await AppRuntime.runPromise( - Effect.gen(function* () { - const mcp = yield* MCP.Service - const supports = yield* mcp.supportsOAuth(name) - if (!supports) return { supports } - return { - supports, - status: yield* mcp.authenticate(name), - } - }), - ) - if (!result.supports) { - return c.json({ error: `MCP server ${name} does not support OAuth` }, 400) - } - return c.json(result.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 AppRuntime.runPromise(MCP.Service.use((mcp) => 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 AppRuntime.runPromise(MCP.Service.use((mcp) => 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 AppRuntime.runPromise(MCP.Service.use((mcp) => 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 deleted file mode 100644 index 7b66072c2..000000000 --- a/packages/opencode/src/server/instance/middleware.ts +++ /dev/null @@ -1,152 +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 { Instance } from "@/project/instance" -import { InstanceBootstrap } from "@/project/bootstrap" -import { Flag } from "@/flag/flag" -import { Session } from "@/session" -import { SessionID } from "@/session/schema" -import { WorkspaceContext } from "@/control-plane/workspace-context" -import { AppRuntime } from "@/effect/app-runtime" -import { Log } from "@/util" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" - -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 AppRuntime.runPromise(Session.Service.use((svc) => svc.get(id))).catch(() => undefined) - return session?.workspaceID -} - -export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler { - const log = Log.create({ service: "workspace-router" }) - - return async (c, next) => { - const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() - const directory = AppFileSystem.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 (!workspaceID || url.pathname.startsWith("/console") || Flag.OPENCODE_WORKSPACE_ID) { - if (Flag.OPENCODE_WORKSPACE_ID) { - return WorkspaceContext.provide({ - workspaceID: WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID), - async fn() { - return Instance.provide({ - directory, - init: () => AppRuntime.runPromise(InstanceBootstrap), - async fn() { - return next() - }, - }) - }, - }) - } - - return Instance.provide({ - directory, - init: () => AppRuntime.runPromise(InstanceBootstrap), - async fn() { - return next() - }, - }) - } - - const workspace = await Workspace.get(WorkspaceID.make(workspaceID)) - - if (!workspace) { - return new Response(`Workspace not found: ${workspaceID}`, { - status: 500, - headers: { - "content-type": "text/plain; charset=utf-8", - }, - }) - } - - 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() - } - - const adaptor = await getAdaptor(workspace.projectID, 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: () => AppRuntime.runPromise(InstanceBootstrap), - async fn() { - return next() - }, - }), - }) - } - - const proxyURL = new URL(target.url) - proxyURL.pathname = `${proxyURL.pathname.replace(/\/$/, "")}${url.pathname}` - proxyURL.search = url.search - proxyURL.hash = url.hash - proxyURL.searchParams.delete("workspace") - - log.info("workspace proxy forwarding", { - workspaceID, - request: url.toString(), - target: String(target.url), - proxy: proxyURL.toString(), - }) - - if (c.req.header("upgrade")?.toLowerCase() === "websocket") { - return ServerProxy.websocket(upgrade, proxyURL, target.headers, c.req.raw, c.env) - } - - const headers = new Headers(c.req.raw.headers) - headers.delete("x-opencode-workspace") - - const req = new Request(c.req.raw, { headers }) - return ServerProxy.http(proxyURL, target.headers, req, workspace.id) - } -} diff --git a/packages/opencode/src/server/instance/permission.ts b/packages/opencode/src/server/instance/permission.ts deleted file mode 100644 index b8c224414..000000000 --- a/packages/opencode/src/server/instance/permission.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { Hono } from "hono" -import { describeRoute, validator, resolver } from "hono-openapi" -import z from "zod" -import { AppRuntime } from "@/effect/app-runtime" -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.zod, message: z.string().optional() })), - async (c) => { - const params = c.req.valid("param") - const json = c.req.valid("json") - await AppRuntime.runPromise( - Permission.Service.use((svc) => - svc.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.zod.array()), - }, - }, - }, - }, - }), - async (c) => { - const permissions = await AppRuntime.runPromise(Permission.Service.use((svc) => svc.list())) - return c.json(permissions) - }, - ), -) diff --git a/packages/opencode/src/server/instance/project.ts b/packages/opencode/src/server/instance/project.ts deleted file mode 100644 index 95b5862fd..000000000 --- a/packages/opencode/src/server/instance/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" -import z from "zod" -import { ProjectID } from "../../project/schema" -import { errors } from "../error" -import { lazy } from "../../util/lazy" -import { InstanceBootstrap } from "../../project/bootstrap" -import { AppRuntime } from "@/effect/app-runtime" - -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.zod.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.zod), - }, - }, - }, - }, - }), - 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.zod), - }, - }, - }, - }, - }), - async (c) => { - const dir = Instance.directory - const prev = Instance.project - const next = await AppRuntime.runPromise( - Project.Service.use((svc) => svc.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: () => AppRuntime.runPromise(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.zod), - }, - }, - }, - ...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 AppRuntime.runPromise(Project.Service.use((svc) => svc.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 deleted file mode 100644 index a81ae00d5..000000000 --- a/packages/opencode/src/server/instance/provider.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { Hono } from "hono" -import { describeRoute, validator, resolver } from "hono-openapi" -import z from "zod" -import { Config } from "../../config" -import { Provider } from "../../provider" -import { ModelsDev } from "../../provider" -import { ProviderAuth } from "../../provider" -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 { Effect } from "effect" - -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(Provider.ListResult.zod), - }, - }, - }, - }, - }), - async (c) => { - const result = await AppRuntime.runPromise( - Effect.gen(function* () { - const svc = yield* Provider.Service - const cfg = yield* Config.Service - const config = yield* cfg.get() - const all = yield* Effect.promise(() => ModelsDev.get()) - const disabled = new Set(config.disabled_providers ?? []) - const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined - const filtered: Record = {} - for (const [key, value] of Object.entries(all)) { - if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { - filtered[key] = value - } - } - const connected = yield* svc.list() - const providers = Object.assign( - mapValues(filtered, (x) => Provider.fromModelsDevProvider(x)), - connected, - ) - return { - all: Object.values(providers), - default: Provider.defaultModelIDs(providers), - connected: Object.keys(connected), - } - }), - ) - return c.json({ - all: result.all, - default: result.default, - connected: result.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(ProviderAuth.Methods.zod), - }, - }, - }, - }, - }), - 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.zod.optional()), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "param", - z.object({ - providerID: ProviderID.zod.meta({ description: "Provider ID" }), - }), - ), - validator("json", ProviderAuth.AuthorizeInput.zod), - 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", ProviderAuth.CallbackInput.zod), - 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 deleted file mode 100644 index 794372512..000000000 --- a/packages/opencode/src/server/instance/pty.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { Hono } from "hono" -import { describeRoute, validator, resolver } from "hono-openapi" -import type { UpgradeWebSocket } from "hono/ws" -import { Effect } from "effect" -import z from "zod" -import { AppRuntime } from "@/effect/app-runtime" -import { Pty } from "@/pty" -import { PtyID } from "@/pty/schema" -import { NotFoundError } from "../../storage" -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 AppRuntime.runPromise( - Effect.gen(function* () { - const pty = yield* Pty.Service - return yield* 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 AppRuntime.runPromise( - Effect.gen(function* () { - const pty = yield* Pty.Service - return yield* 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 AppRuntime.runPromise( - Effect.gen(function* () { - const pty = yield* Pty.Service - return yield* 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 AppRuntime.runPromise( - Effect.gen(function* () { - const pty = yield* Pty.Service - return yield* 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 AppRuntime.runPromise( - Effect.gen(function* () { - const pty = yield* Pty.Service - yield* 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) => { - type Handler = { - onMessage: (message: string | ArrayBuffer) => void - onClose: () => void - } - - 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: Handler | undefined - if ( - !(await AppRuntime.runPromise( - Effect.gen(function* () { - const pty = yield* Pty.Service - return yield* 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 AppRuntime.runPromise( - Effect.gen(function* () { - const pty = yield* Pty.Service - return yield* 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 deleted file mode 100644 index 0f61a1867..000000000 --- a/packages/opencode/src/server/instance/question.ts +++ /dev/null @@ -1,110 +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 { AppRuntime } from "@/effect/app-runtime" -import z from "zod" -import { errors } from "../error" -import { lazy } from "../../util/lazy" - -const Reply = z.object({ - answers: Question.Answer.zod - .array() - .describe("User answers in order of questions (each answer is an array of selected labels)"), -}) - -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.zod.array()), - }, - }, - }, - }, - }), - async (c) => { - const questions = await AppRuntime.runPromise(Question.Service.use((svc) => svc.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", Reply), - async (c) => { - const params = c.req.valid("param") - const json = c.req.valid("json") - await AppRuntime.runPromise( - Question.Service.use((svc) => - svc.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 AppRuntime.runPromise(Question.Service.use((svc) => svc.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 deleted file mode 100644 index 1511e99e8..000000000 --- a/packages/opencode/src/server/instance/session.ts +++ /dev/null @@ -1,1120 +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" -import { SessionStatus } from "@/session/status" -import { SessionSummary } from "@/session/summary" -import { Todo } from "../../session/todo" -import { Effect } from "effect" -import { AppRuntime } from "../../effect/app-runtime" -import { Agent } from "../../agent/agent" -import { Snapshot } from "@/snapshot" -import { Command } from "../../command" -import { Log } from "../../util" -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/shared/util/error" -import { jsonRequest } from "./trace" - -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) => - jsonRequest("SessionRoutes.status", c, function* () { - const svc = yield* SessionStatus.Service - return Object.fromEntries(yield* svc.list()) - }), - ) - .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.GetInput, - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - return jsonRequest("SessionRoutes.get", c, function* () { - const session = yield* Session.Service - return yield* session.get(sessionID) - }) - }, - ) - .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.ChildrenInput, - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - return jsonRequest("SessionRoutes.children", c, function* () { - const session = yield* Session.Service - return yield* session.children(sessionID) - }) - }, - ) - .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 - return jsonRequest("SessionRoutes.todo", c, function* () { - const todo = yield* Todo.Service - return yield* todo.get(sessionID) - }) - }, - ) - .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.CreateInput), - async (c) => { - const body = c.req.valid("json") ?? {} - const session = await AppRuntime.runPromise(SessionShare.Service.use((svc) => svc.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.RemoveInput, - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - await AppRuntime.runPromise(Session.Service.use((svc) => svc.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(), - permission: Permission.Ruleset.zod.optional(), - time: z - .object({ - archived: z.number().optional(), - }) - .optional(), - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const updates = c.req.valid("json") - const session = await AppRuntime.runPromise( - Effect.gen(function* () { - const session = yield* Session.Service - const current = yield* session.get(sessionID) - - if (updates.title !== undefined) { - yield* session.setTitle({ sessionID, title: updates.title }) - } - if (updates.permission !== undefined) { - yield* session.setPermission({ - sessionID, - permission: Permission.merge(current.permission ?? [], updates.permission), - }) - } - if (updates.time?.archived !== undefined) { - yield* session.setArchived({ sessionID, time: updates.time.archived }) - } - - return yield* 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 AppRuntime.runPromise( - SessionPrompt.Service.use((svc) => - svc.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.ForkInput.shape.sessionID, - }), - ), - validator("json", Session.ForkInput.omit({ sessionID: true })), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - const result = await AppRuntime.runPromise(Session.Service.use((svc) => svc.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 AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.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 - const session = await AppRuntime.runPromise( - Effect.gen(function* () { - const share = yield* SessionShare.Service - const session = yield* Session.Service - yield* share.share(sessionID) - return yield* 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 AppRuntime.runPromise( - SessionSummary.Service.use((summary) => - summary.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 - const session = await AppRuntime.runPromise( - Effect.gen(function* () { - const share = yield* SessionShare.Service - const session = yield* Session.Service - yield* share.unshare(sessionID) - return yield* 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") - await AppRuntime.runPromise( - Effect.gen(function* () { - const session = yield* Session.Service - const revert = yield* SessionRevert.Service - const compact = yield* SessionCompaction.Service - const prompt = yield* SessionPrompt.Service - const agent = yield* Agent.Service - - yield* revert.cleanup(yield* session.get(sessionID)) - const msgs = yield* session.messages({ sessionID }) - const defaultAgent = yield* agent.defaultAgent() - let currentAgent = defaultAgent - for (let i = msgs.length - 1; i >= 0; i--) { - const info = msgs[i].info - if (info.role === "user") { - currentAgent = info.agent || defaultAgent - break - } - } - - yield* compact.create({ - sessionID, - agent: currentAgent, - model: { - providerID: body.providerID, - modelID: body.modelID, - }, - auto: body.auto, - }) - yield* prompt.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 || query.limit === 0) { - const messages = await AppRuntime.runPromise( - Effect.gen(function* () { - const session = yield* Session.Service - yield* session.get(sessionID) - return yield* 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 AppRuntime.runPromise( - Effect.gen(function* () { - const state = yield* SessionRunState.Service - const session = yield* Session.Service - yield* state.assertNotBusy(params.sessionID) - yield* 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 AppRuntime.runPromise( - Session.Service.use((svc) => - svc.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 AppRuntime.runPromise(Session.Service.use((svc) => svc.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 AppRuntime.runPromise( - SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID })), - ) - void 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") - void AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID }))).catch( - (err) => { - log.error("prompt_async failed", { sessionID, error: err }) - void 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 AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.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 AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.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 AppRuntime.runPromise( - SessionRevert.Service.use((svc) => - svc.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 AppRuntime.runPromise(SessionRevert.Service.use((svc) => svc.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.zod })), - async (c) => { - const params = c.req.valid("param") - await AppRuntime.runPromise( - Permission.Service.use((svc) => - svc.reply({ - requestID: params.permissionID, - reply: c.req.valid("json").response, - }), - ), - ) - return c.json(true) - }, - ), -) diff --git a/packages/opencode/src/server/instance/sync.ts b/packages/opencode/src/server/instance/sync.ts deleted file mode 100644 index ac43b638e..000000000 --- a/packages/opencode/src/server/instance/sync.ts +++ /dev/null @@ -1,119 +0,0 @@ -import z from "zod" -import { Hono } from "hono" -import { describeRoute, validator, resolver } from "hono-openapi" -import { SyncEvent } from "@/sync" -import { Database, asc, and, not, or, lte, eq } from "@/storage" -import { EventTable } from "@/sync/event.sql" -import { lazy } from "@/util/lazy" -import { Log } from "@/util" -import { errors } from "../error" - -const ReplayEvent = z.object({ - id: z.string(), - aggregateID: z.string(), - seq: z.number().int().min(0), - type: z.string(), - data: z.record(z.string(), z.unknown()), -}) - -const log = Log.create({ service: "server.sync" }) - -export const SyncRoutes = lazy(() => - new Hono() - .post( - "/replay", - describeRoute({ - summary: "Replay sync events", - description: "Validate and replay a complete sync event history.", - operationId: "sync.replay", - responses: { - 200: { - description: "Replayed sync events", - content: { - "application/json": { - schema: resolver( - z.object({ - sessionID: z.string(), - }), - ), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "json", - z.object({ - directory: z.string(), - events: z.array(ReplayEvent).min(1), - }), - ), - async (c) => { - const body = c.req.valid("json") - const events = body.events - const source = events[0].aggregateID - - log.info("sync replay requested", { - sessionID: source, - events: events.length, - first: events[0]?.seq, - last: events.at(-1)?.seq, - directory: body.directory, - }) - SyncEvent.replayAll(events) - - log.info("sync replay complete", { - sessionID: source, - events: events.length, - first: events[0]?.seq, - last: events.at(-1)?.seq, - }) - - return c.json({ - sessionID: source, - }) - }, - ) - .get( - "/history", - describeRoute({ - summary: "List sync events", - description: - "List sync events for all aggregates. Keys are aggregate IDs the client already knows about, values are the last known sequence ID. Events with seq > value are returned for those aggregates. Aggregates not listed in the input get their full history.", - operationId: "sync.history.list", - responses: { - 200: { - description: "Sync events", - content: { - "application/json": { - schema: resolver( - z.array( - z.object({ - id: z.string(), - aggregate_id: z.string(), - seq: z.number(), - type: z.string(), - data: z.record(z.string(), z.unknown()), - }), - ), - ), - }, - }, - }, - ...errors(400), - }, - }), - validator("json", z.record(z.string(), z.number().int().min(0))), - async (c) => { - const body = c.req.valid("json") - const exclude = Object.entries(body) - const where = - exclude.length > 0 - ? not(or(...exclude.map(([id, seq]) => and(eq(EventTable.aggregate_id, id), lte(EventTable.seq, seq))))!) - : undefined - const rows = Database.use((db) => db.select().from(EventTable).where(where).orderBy(asc(EventTable.seq)).all()) - return c.json(rows) - }, - ), -) diff --git a/packages/opencode/src/server/instance/trace.ts b/packages/opencode/src/server/instance/trace.ts deleted file mode 100644 index b3adbb4c8..000000000 --- a/packages/opencode/src/server/instance/trace.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { Context } from "hono" -import { Effect } from "effect" -import { AppRuntime } from "../../effect/app-runtime" - -type AppEnv = Parameters[0] extends Effect.Effect ? R : never - -export function runRequest(name: string, c: Context, effect: Effect.Effect) { - const url = new URL(c.req.url) - return AppRuntime.runPromise( - effect.pipe( - Effect.withSpan(name, { - attributes: { - "http.method": c.req.method, - "http.path": url.pathname, - }, - }), - ), - ) -} - -export async function jsonRequest( - name: string, - c: C, - effect: (c: C) => Effect.gen.Return, -) { - return c.json( - await runRequest( - name, - c, - Effect.gen(() => effect(c)), - ), - ) -} diff --git a/packages/opencode/src/server/instance/tui.ts b/packages/opencode/src/server/instance/tui.ts deleted file mode 100644 index 0073ef98c..000000000 --- a/packages/opencode/src/server/instance/tui.ts +++ /dev/null @@ -1,380 +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 { AppRuntime } from "@/effect/app-runtime" -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 AppRuntime.runPromise(Session.Service.use((svc) => svc.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 deleted file mode 100644 index 59369ef8e..000000000 --- a/packages/opencode/src/server/instance/workspace.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { Hono } from "hono" -import { describeRoute, resolver, validator } from "hono-openapi" -import z from "zod" -import { listAdaptors } from "../../control-plane/adaptors" -import { Workspace } from "../../control-plane/workspace" -import { Instance } from "../../project/instance" -import { errors } from "../error" -import { lazy } from "../../util/lazy" -import { Log } from "@/util" -import { errorData } from "@/util/error" - -const log = Log.create({ service: "server.workspace" }) - -export const WorkspaceRoutes = lazy(() => - new Hono() - .get( - "/adaptor", - describeRoute({ - summary: "List workspace adaptors", - description: "List all available workspace adaptors for the current project.", - operationId: "experimental.workspace.adaptor.list", - responses: { - 200: { - description: "Workspace adaptors", - content: { - "application/json": { - schema: resolver( - z.array( - z.object({ - type: z.string(), - name: z.string(), - description: z.string(), - }), - ), - ), - }, - }, - }, - }, - }), - async (c) => { - return c.json(await listAdaptors(Instance.project.id)) - }, - ) - .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)) - }, - ) - .post( - "/:id/session-restore", - describeRoute({ - summary: "Restore session into workspace", - description: "Replay a session's sync events into the target workspace in batches.", - operationId: "experimental.workspace.sessionRestore", - responses: { - 200: { - description: "Session replay started", - content: { - "application/json": { - schema: resolver( - z.object({ - total: z.number().int().min(0), - }), - ), - }, - }, - }, - ...errors(400), - }, - }), - validator("param", z.object({ id: Workspace.Info.shape.id })), - validator("json", Workspace.sessionRestore.schema.omit({ workspaceID: true })), - async (c) => { - const { id } = c.req.valid("param") - const body = c.req.valid("json") - log.info("session restore route requested", { - workspaceID: id, - sessionID: body.sessionID, - directory: Instance.directory, - }) - try { - const result = await Workspace.sessionRestore({ - workspaceID: id, - ...body, - }) - log.info("session restore route complete", { - workspaceID: id, - sessionID: body.sessionID, - total: result.total, - }) - return c.json(result) - } catch (err) { - log.error("session restore route failed", { - workspaceID: id, - sessionID: body.sessionID, - error: errorData(err), - }) - throw err - } - }, - ), -) diff --git a/packages/opencode/src/server/routes/control/index.ts b/packages/opencode/src/server/routes/control/index.ts new file mode 100644 index 000000000..3fd60636f --- /dev/null +++ b/packages/opencode/src/server/routes/control/index.ts @@ -0,0 +1,162 @@ +import { Auth } from "@/auth" +import { AppRuntime } from "@/effect/app-runtime" +import { Log } from "@/util" +import { Effect } from "effect" +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 { WorkspaceRoutes } from "./workspace" + +export function ControlPlaneRoutes(): Hono { + const app = new Hono() + return app + .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 AppRuntime.runPromise( + Effect.gen(function* () { + const auth = yield* Auth.Service + yield* 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 AppRuntime.runPromise( + Effect.gen(function* () { + const auth = yield* Auth.Service + yield* 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) + }, + ) + .route("/experimental/workspace", WorkspaceRoutes()) +} diff --git a/packages/opencode/src/server/routes/control/workspace.ts b/packages/opencode/src/server/routes/control/workspace.ts new file mode 100644 index 000000000..9ff747b68 --- /dev/null +++ b/packages/opencode/src/server/routes/control/workspace.ts @@ -0,0 +1,203 @@ +import { Hono } from "hono" +import { describeRoute, resolver, validator } from "hono-openapi" +import z from "zod" +import { listAdaptors } from "@/control-plane/adaptors" +import { Workspace } from "@/control-plane/workspace" +import { Instance } from "@/project/instance" +import { errors } from "../../error" +import { lazy } from "@/util/lazy" +import { Log } from "@/util" +import { errorData } from "@/util/error" + +const log = Log.create({ service: "server.workspace" }) + +export const WorkspaceRoutes = lazy(() => + new Hono() + .get( + "/adaptor", + describeRoute({ + summary: "List workspace adaptors", + description: "List all available workspace adaptors for the current project.", + operationId: "experimental.workspace.adaptor.list", + responses: { + 200: { + description: "Workspace adaptors", + content: { + "application/json": { + schema: resolver( + z.array( + z.object({ + type: z.string(), + name: z.string(), + description: z.string(), + }), + ), + ), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await listAdaptors(Instance.project.id)) + }, + ) + .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)) + }, + ) + .post( + "/:id/session-restore", + describeRoute({ + summary: "Restore session into workspace", + description: "Replay a session's sync events into the target workspace in batches.", + operationId: "experimental.workspace.sessionRestore", + responses: { + 200: { + description: "Session replay started", + content: { + "application/json": { + schema: resolver( + z.object({ + total: z.number().int().min(0), + }), + ), + }, + }, + }, + ...errors(400), + }, + }), + validator("param", z.object({ id: Workspace.Info.shape.id })), + validator("json", Workspace.sessionRestore.schema.omit({ workspaceID: true })), + async (c) => { + const { id } = c.req.valid("param") + const body = c.req.valid("json") + log.info("session restore route requested", { + workspaceID: id, + sessionID: body.sessionID, + directory: Instance.directory, + }) + try { + const result = await Workspace.sessionRestore({ + workspaceID: id, + ...body, + }) + log.info("session restore route complete", { + workspaceID: id, + sessionID: body.sessionID, + total: result.total, + }) + return c.json(result) + } catch (err) { + log.error("session restore route failed", { + workspaceID: id, + sessionID: body.sessionID, + error: errorData(err), + }) + throw err + } + }, + ), +) diff --git a/packages/opencode/src/server/routes/global.ts b/packages/opencode/src/server/routes/global.ts new file mode 100644 index 000000000..8208cf966 --- /dev/null +++ b/packages/opencode/src/server/routes/global.ts @@ -0,0 +1,287 @@ +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 { InstallationVersion } from "@/installation/version" +import { Log } from "../../util" +import { lazy } from "../../util/lazy" +import { Config } from "../../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: InstallationVersion }) + }, + ) + .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: z.union([...BusEvent.payloads(), ...SyncEvent.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( + "/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 AppRuntime.runPromise(Config.Service.use((cfg) => cfg.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 AppRuntime.runPromise(Config.Service.use((cfg) => cfg.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/instance/config.ts b/packages/opencode/src/server/routes/instance/config.ts new file mode 100644 index 000000000..235f5682e --- /dev/null +++ b/packages/opencode/src/server/routes/instance/config.ts @@ -0,0 +1,88 @@ +import { Hono } from "hono" +import { describeRoute, validator, resolver } from "hono-openapi" +import z from "zod" +import { Config } from "@/config" +import { Provider } from "@/provider" +import { errors } from "../../error" +import { lazy } from "@/util/lazy" +import { AppRuntime } from "@/effect/app-runtime" +import { jsonRequest } from "./trace" + +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) => + jsonRequest("ConfigRoutes.get", c, function* () { + const cfg = yield* Config.Service + return yield* cfg.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 AppRuntime.runPromise(Config.Service.use((cfg) => cfg.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(Provider.ConfigProvidersResult.zod), + }, + }, + }, + }, + }), + async (c) => + jsonRequest("ConfigRoutes.providers", c, function* () { + const svc = yield* Provider.Service + const providers = yield* svc.list() + return { + providers: Object.values(providers), + default: Provider.defaultModelIDs(providers), + } + }), + ), +) diff --git a/packages/opencode/src/server/routes/instance/event.ts b/packages/opencode/src/server/routes/instance/event.ts new file mode 100644 index 000000000..1d883bd88 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/event.ts @@ -0,0 +1,88 @@ +import z from "zod" +import { Hono } from "hono" +import { describeRoute, resolver } from "hono-openapi" +import { streamSSE } from "hono/streaming" +import { Log } from "@/util" +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( + z.union(BusEvent.payloads()).meta({ + ref: "Event", + }), + ), + }, + }, + }, + }, + }), + 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/instance/experimental.ts b/packages/opencode/src/server/routes/instance/experimental.ts new file mode 100644 index 000000000..f7ecc8255 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/experimental.ts @@ -0,0 +1,420 @@ +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" +import { Worktree } from "@/worktree" +import { Instance } from "@/project/instance" +import { Project } from "@/project" +import { MCP } from "@/mcp" +import { Session } from "@/session" +import { Config } from "@/config" +import { ConsoleState } from "@/config/console-state" +import { Account } from "@/account/account" +import { AccountID, OrgID } from "@/account/schema" +import { AppRuntime } from "@/effect/app-runtime" +import { errors } from "../../error" +import { lazy } from "@/util/lazy" +import { Effect, Option } from "effect" +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) => { + const ids = await AppRuntime.runPromise( + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + return yield* registry.ids() + }), + ) + return c.json(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 AppRuntime.runPromise( + Effect.gen(function* () { + const agents = yield* Agent.Service + const registry = yield* ToolRegistry.Service + return yield* registry.tools({ + providerID: ProviderID.make(provider), + modelID: ModelID.make(model), + agent: yield* agents.get(yield* agents.defaultAgent()), + }) + }), + ) + return c.json( + tools.map((t) => ({ + id: t.id, + description: t.description, + parameters: z.toJSONSchema(t.parameters), + })), + ) + }, + ) + .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 AppRuntime.runPromise(Worktree.Service.use((svc) => svc.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 AppRuntime.runPromise(Project.Service.use((svc) => svc.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 AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove(body))) + await AppRuntime.runPromise( + Project.Service.use((svc) => svc.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 AppRuntime.runPromise(Worktree.Service.use((svc) => svc.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 AppRuntime.runPromise( + Effect.gen(function* () { + const mcp = yield* MCP.Service + return yield* mcp.resources() + }), + ), + ) + }, + ), +) diff --git a/packages/opencode/src/server/routes/instance/file.ts b/packages/opencode/src/server/routes/instance/file.ts new file mode 100644 index 000000000..a82e5687d --- /dev/null +++ b/packages/opencode/src/server/routes/instance/file.ts @@ -0,0 +1,210 @@ +import { Hono } from "hono" +import { describeRoute, validator, resolver } from "hono-openapi" +import { Effect } from "effect" +import z from "zod" +import { AppRuntime } from "@/effect/app-runtime" +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 AppRuntime.runPromise( + Ripgrep.Service.use((svc) => svc.search({ cwd: Instance.directory, pattern, limit: 10 })), + ) + return c.json(result.items) + }, + ) + .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 AppRuntime.runPromise( + Effect.gen(function* () { + return yield* File.Service.use((svc) => + svc.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) => { + 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 AppRuntime.runPromise( + Effect.gen(function* () { + return yield* File.Service.use((svc) => svc.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 AppRuntime.runPromise( + Effect.gen(function* () { + return yield* File.Service.use((svc) => svc.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 AppRuntime.runPromise( + Effect.gen(function* () { + return yield* File.Service.use((svc) => svc.status()) + }), + ) + return c.json(content) + }, + ), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/config.ts b/packages/opencode/src/server/routes/instance/httpapi/config.ts new file mode 100644 index 000000000..14aa94f9f --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/config.ts @@ -0,0 +1,51 @@ +import { Config } from "@/config" +import { Provider } from "@/provider" +import { Effect, Layer } from "effect" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" + +const root = "/config" + +export const ConfigApi = HttpApi.make("config") + .add( + HttpApiGroup.make("config") + .add( + HttpApiEndpoint.get("providers", `${root}/providers`, { + success: Provider.ConfigProvidersResult, + }).annotateMerge( + OpenApi.annotations({ + identifier: "config.providers", + summary: "List config providers", + description: "Get a list of all configured AI providers and their default models.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "config", + description: "Experimental HttpApi config routes.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "opencode experimental HttpApi", + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) + +export const configHandlers = Layer.unwrap( + Effect.gen(function* () { + const svc = yield* Provider.Service + + const providers = Effect.fn("ConfigHttpApi.providers")(function* () { + const providers = yield* svc.list() + return { + providers: Object.values(providers), + default: Provider.defaultModelIDs(providers), + } + }) + + return HttpApiBuilder.group(ConfigApi, "config", (handlers) => handlers.handle("providers", providers)) + }), +).pipe(Layer.provide(Provider.defaultLayer), Layer.provide(Config.defaultLayer)) diff --git a/packages/opencode/src/server/routes/instance/httpapi/permission.ts b/packages/opencode/src/server/routes/instance/httpapi/permission.ts new file mode 100644 index 000000000..ed8cb4e27 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/permission.ts @@ -0,0 +1,72 @@ +import { Permission } from "@/permission" +import { PermissionID } from "@/permission/schema" +import { Effect, Layer, Schema } from "effect" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" + +const root = "/permission" + +export const PermissionApi = HttpApi.make("permission") + .add( + HttpApiGroup.make("permission") + .add( + HttpApiEndpoint.get("list", root, { + success: Schema.Array(Permission.Request), + }).annotateMerge( + OpenApi.annotations({ + identifier: "permission.list", + summary: "List pending permissions", + description: "Get all pending permission requests across all sessions.", + }), + ), + HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, { + params: { requestID: PermissionID }, + payload: Permission.ReplyBody, + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "permission.reply", + summary: "Respond to permission request", + description: "Approve or deny a permission request from the AI assistant.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "permission", + description: "Experimental HttpApi permission routes.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "opencode experimental HttpApi", + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) + +export const permissionHandlers = Layer.unwrap( + Effect.gen(function* () { + const svc = yield* Permission.Service + + const list = Effect.fn("PermissionHttpApi.list")(function* () { + return yield* svc.list() + }) + + const reply = Effect.fn("PermissionHttpApi.reply")(function* (ctx: { + params: { requestID: PermissionID } + payload: Permission.ReplyBody + }) { + yield* svc.reply({ + requestID: ctx.params.requestID, + reply: ctx.payload.reply, + message: ctx.payload.message, + }) + return true + }) + + return HttpApiBuilder.group(PermissionApi, "permission", (handlers) => + handlers.handle("list", list).handle("reply", reply), + ) + }), +).pipe(Layer.provide(Permission.defaultLayer)) diff --git a/packages/opencode/src/server/routes/instance/httpapi/project.ts b/packages/opencode/src/server/routes/instance/httpapi/project.ts new file mode 100644 index 000000000..7d2d8462f --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/project.ts @@ -0,0 +1,62 @@ +import { Instance } from "@/project/instance" +import { Project } from "@/project" +import { Effect, Layer, Schema } from "effect" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" + +const root = "/project" + +export const ProjectApi = HttpApi.make("project") + .add( + HttpApiGroup.make("project") + .add( + HttpApiEndpoint.get("list", root, { + success: Schema.Array(Project.Info), + }).annotateMerge( + OpenApi.annotations({ + identifier: "project.list", + summary: "List all projects", + description: "Get a list of projects that have been opened with OpenCode.", + }), + ), + HttpApiEndpoint.get("current", `${root}/current`, { + success: Project.Info, + }).annotateMerge( + OpenApi.annotations({ + identifier: "project.current", + summary: "Get current project", + description: "Retrieve the currently active project that OpenCode is working with.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "project", + description: "Experimental HttpApi project routes.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "opencode experimental HttpApi", + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) + +export const projectHandlers = Layer.unwrap( + Effect.gen(function* () { + const svc = yield* Project.Service + + const list = Effect.fn("ProjectHttpApi.list")(function* () { + return yield* svc.list() + }) + + const current = Effect.fn("ProjectHttpApi.current")(function* () { + return Instance.project + }) + + return HttpApiBuilder.group(ProjectApi, "project", (handlers) => + handlers.handle("list", list).handle("current", current), + ) + }), +).pipe(Layer.provide(Project.defaultLayer)) diff --git a/packages/opencode/src/server/routes/instance/httpapi/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/provider.ts new file mode 100644 index 000000000..67831a1fa --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/provider.ts @@ -0,0 +1,142 @@ +import { ProviderAuth } from "@/provider" +import { Config } from "@/config" +import { ModelsDev } from "@/provider" +import { Provider } from "@/provider" +import { ProviderID } from "@/provider/schema" +import { mapValues } from "remeda" +import { Effect, Layer, Schema } from "effect" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" + +const root = "/provider" + +export const ProviderApi = HttpApi.make("provider") + .add( + HttpApiGroup.make("provider") + .add( + HttpApiEndpoint.get("list", root, { + success: Provider.ListResult, + }).annotateMerge( + OpenApi.annotations({ + identifier: "provider.list", + summary: "List providers", + description: "Get a list of all available AI providers, including both available and connected ones.", + }), + ), + HttpApiEndpoint.get("auth", `${root}/auth`, { + success: ProviderAuth.Methods, + }).annotateMerge( + OpenApi.annotations({ + identifier: "provider.auth", + summary: "Get provider auth methods", + description: "Retrieve available authentication methods for all AI providers.", + }), + ), + HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, { + params: { providerID: ProviderID }, + payload: ProviderAuth.AuthorizeInput, + success: ProviderAuth.Authorization, + }).annotateMerge( + OpenApi.annotations({ + identifier: "provider.oauth.authorize", + summary: "Start OAuth authorization", + description: "Start the OAuth authorization flow for a provider.", + }), + ), + HttpApiEndpoint.post("callback", `${root}/:providerID/oauth/callback`, { + params: { providerID: ProviderID }, + payload: ProviderAuth.CallbackInput, + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "provider.oauth.callback", + summary: "Handle OAuth callback", + description: "Handle the OAuth callback from a provider after user authorization.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "provider", + description: "Experimental HttpApi provider routes.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "opencode experimental HttpApi", + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) + +export const providerHandlers = Layer.unwrap( + Effect.gen(function* () { + const cfg = yield* Config.Service + const provider = yield* Provider.Service + const svc = yield* ProviderAuth.Service + + const list = Effect.fn("ProviderHttpApi.list")(function* () { + const config = yield* cfg.get() + const all = yield* Effect.promise(() => ModelsDev.get()) + const disabled = new Set(config.disabled_providers ?? []) + const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined + const filtered: Record = {} + for (const [key, value] of Object.entries(all)) { + if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { + filtered[key] = value + } + } + const connected = yield* provider.list() + const providers = Object.assign( + mapValues(filtered, (item) => Provider.fromModelsDevProvider(item)), + connected, + ) + return { + all: Object.values(providers), + default: Provider.defaultModelIDs(providers), + connected: Object.keys(connected), + } + }) + + const auth = Effect.fn("ProviderHttpApi.auth")(function* () { + return yield* svc.methods() + }) + + const authorize = Effect.fn("ProviderHttpApi.authorize")(function* (ctx: { + params: { providerID: ProviderID } + payload: ProviderAuth.AuthorizeInput + }) { + const result = yield* svc + .authorize({ + providerID: ctx.params.providerID, + method: ctx.payload.method, + inputs: ctx.payload.inputs, + }) + .pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({})))) + if (!result) return yield* new HttpApiError.BadRequest({}) + return result + }) + + const callback = Effect.fn("ProviderHttpApi.callback")(function* (ctx: { + params: { providerID: ProviderID } + payload: ProviderAuth.CallbackInput + }) { + yield* svc + .callback({ + providerID: ctx.params.providerID, + method: ctx.payload.method, + code: ctx.payload.code, + }) + .pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({})))) + return true + }) + + return HttpApiBuilder.group(ProviderApi, "provider", (handlers) => + handlers.handle("list", list).handle("auth", auth).handle("authorize", authorize).handle("callback", callback), + ) + }), +).pipe( + Layer.provide(ProviderAuth.defaultLayer), + Layer.provide(Provider.defaultLayer), + Layer.provide(Config.defaultLayer), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/question.ts b/packages/opencode/src/server/routes/instance/httpapi/question.ts new file mode 100644 index 000000000..3192b530e --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/question.ts @@ -0,0 +1,86 @@ +import { Question } from "@/question" +import { QuestionID } from "@/question/schema" +import { Effect, Layer, Schema } from "effect" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" + +const root = "/question" + +export const QuestionApi = HttpApi.make("question") + .add( + HttpApiGroup.make("question") + .add( + HttpApiEndpoint.get("list", root, { + success: Schema.Array(Question.Request), + }).annotateMerge( + OpenApi.annotations({ + identifier: "question.list", + summary: "List pending questions", + description: "Get all pending question requests across all sessions.", + }), + ), + HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, { + params: { requestID: QuestionID }, + payload: Question.Reply, + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "question.reply", + summary: "Reply to question request", + description: "Provide answers to a question request from the AI assistant.", + }), + ), + HttpApiEndpoint.post("reject", `${root}/:requestID/reject`, { + params: { requestID: QuestionID }, + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "question.reject", + summary: "Reject question request", + description: "Reject a question request from the AI assistant.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "question", + description: "Question routes.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "opencode HttpApi", + version: "0.0.1", + description: "Effect HttpApi surface for instance routes.", + }), + ) + +export const questionHandlers = Layer.unwrap( + Effect.gen(function* () { + const svc = yield* Question.Service + + const list = Effect.fn("QuestionHttpApi.list")(function* () { + return yield* svc.list() + }) + + const reply = Effect.fn("QuestionHttpApi.reply")(function* (ctx: { + params: { requestID: QuestionID } + payload: Question.Reply + }) { + yield* svc.reply({ + requestID: ctx.params.requestID, + answers: ctx.payload.answers, + }) + return true + }) + + const reject = Effect.fn("QuestionHttpApi.reject")(function* (ctx: { params: { requestID: QuestionID } }) { + yield* svc.reject(ctx.params.requestID) + return true + }) + + return HttpApiBuilder.group(QuestionApi, "question", (handlers) => + handlers.handle("list", list).handle("reply", reply).handle("reject", reject), + ) + }), +).pipe(Layer.provide(Question.defaultLayer)) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts new file mode 100644 index 000000000..b4442d640 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -0,0 +1,136 @@ +import { Effect, Layer, Redacted, Schema } from "effect" +import { HttpApiBuilder, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi" +import { HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http" +import { AppRuntime } from "@/effect/app-runtime" +import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" +import { Observability } from "@/effect" +import { memoMap } from "@/effect/run-service" +import { Flag } from "@/flag/flag" +import { InstanceBootstrap } from "@/project/bootstrap" +import { Instance } from "@/project/instance" +import { lazy } from "@/util/lazy" +import { Filesystem } from "@/util" +import { ConfigApi, configHandlers } from "./config" +import { PermissionApi, permissionHandlers } from "./permission" +import { ProjectApi, projectHandlers } from "./project" +import { ProviderApi, providerHandlers } from "./provider" +import { QuestionApi, questionHandlers } from "./question" + +const Query = Schema.Struct({ + directory: Schema.optional(Schema.String), + workspace: Schema.optional(Schema.String), + auth_token: Schema.optional(Schema.String), +}) + +const Headers = Schema.Struct({ + authorization: Schema.optional(Schema.String), + "x-opencode-directory": Schema.optional(Schema.String), +}) + +function decode(input: string) { + try { + return decodeURIComponent(input) + } catch { + return input + } +} + +class Unauthorized extends Schema.TaggedErrorClass()( + "Unauthorized", + { message: Schema.String }, + { httpApiStatus: 401 }, +) {} + +class Authorization extends HttpApiMiddleware.Service()("@opencode/ExperimentalHttpApiAuthorization", { + error: Unauthorized, + security: { + basic: HttpApiSecurity.basic, + }, +}) {} + +const normalize = HttpRouter.middleware()( + Effect.gen(function* () { + return (effect) => + Effect.gen(function* () { + const query = yield* HttpServerRequest.schemaSearchParams(Query) + if (!query.auth_token) return yield* effect + const req = yield* HttpServerRequest.HttpServerRequest + const next = req.modify({ + headers: { + ...req.headers, + authorization: `Basic ${query.auth_token}`, + }, + }) + return yield* effect.pipe(Effect.provideService(HttpServerRequest.HttpServerRequest, next)) + }) + }), +).layer + +const auth = Layer.succeed( + Authorization, + Authorization.of({ + basic: (effect, { credential }) => + Effect.gen(function* () { + if (!Flag.OPENCODE_SERVER_PASSWORD) return yield* effect + + const user = Flag.OPENCODE_SERVER_USERNAME ?? "opencode" + if (credential.username !== user) { + return yield* new Unauthorized({ message: "Unauthorized" }) + } + if (Redacted.value(credential.password) !== Flag.OPENCODE_SERVER_PASSWORD) { + return yield* new Unauthorized({ message: "Unauthorized" }) + } + return yield* effect + }), + }), +) + +const instance = HttpRouter.middleware()( + Effect.gen(function* () { + return (effect) => + Effect.gen(function* () { + const query = yield* HttpServerRequest.schemaSearchParams(Query) + const headers = yield* HttpServerRequest.schemaHeaders(Headers) + const raw = query.directory || headers["x-opencode-directory"] || process.cwd() + const workspace = query.workspace || undefined + const ctx = yield* Effect.promise(() => + Instance.provide({ + directory: Filesystem.resolve(decode(raw)), + init: () => AppRuntime.runPromise(InstanceBootstrap), + fn: () => Instance.current, + }), + ) + + const next = workspace ? effect.pipe(Effect.provideService(WorkspaceRef, workspace)) : effect + return yield* next.pipe(Effect.provideService(InstanceRef, ctx)) + }) + }), +).layer + +const QuestionSecured = QuestionApi.middleware(Authorization) +const PermissionSecured = PermissionApi.middleware(Authorization) +const ProjectSecured = ProjectApi.middleware(Authorization) +const ProviderSecured = ProviderApi.middleware(Authorization) +const ConfigSecured = ConfigApi.middleware(Authorization) + +export const routes = Layer.mergeAll( + HttpApiBuilder.layer(ConfigSecured).pipe(Layer.provide(configHandlers)), + HttpApiBuilder.layer(ProjectSecured).pipe(Layer.provide(projectHandlers)), + HttpApiBuilder.layer(QuestionSecured).pipe(Layer.provide(questionHandlers)), + HttpApiBuilder.layer(PermissionSecured).pipe(Layer.provide(permissionHandlers)), + HttpApiBuilder.layer(ProviderSecured).pipe(Layer.provide(providerHandlers)), +).pipe( + Layer.provide(auth), + Layer.provide(normalize), + Layer.provide(instance), + Layer.provide(HttpServer.layerServices), + Layer.provideMerge(Observability.layer), +) + +export const webHandler = lazy(() => + HttpRouter.toWebHandler(routes, { + memoMap, + }), +) + +export * as ExperimentalHttpApiServer from "./server" diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts new file mode 100644 index 000000000..017541b8f --- /dev/null +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -0,0 +1,305 @@ +import { describeRoute, resolver, validator } from "hono-openapi" +import { Hono } from "hono" +import type { UpgradeWebSocket } from "hono/ws" +import { Context, Effect } from "effect" +import z from "zod" +import { Format } from "@/format" +import { TuiRoutes } from "./tui" +import { Instance } from "@/project/instance" +import { Vcs } from "@/project" +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 { Flag } from "@/flag/flag" +import { ExperimentalHttpApiServer } from "./httpapi/server" +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 { SyncRoutes } from "./sync" +import { AppRuntime } from "@/effect/app-runtime" + +export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { + const app = new Hono() + + if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) { + const handler = ExperimentalHttpApiServer.webHandler().handler + const context = Context.empty() as Context.Context + app.get("/question", (c) => handler(c.req.raw, context)) + app.post("/question/:requestID/reply", (c) => handler(c.req.raw, context)) + app.post("/question/:requestID/reject", (c) => handler(c.req.raw, context)) + app.get("/permission", (c) => handler(c.req.raw, context)) + app.post("/permission/:requestID/reply", (c) => handler(c.req.raw, context)) + app.get("/config/providers", (c) => handler(c.req.raw, context)) + app.get("/provider", (c) => handler(c.req.raw, context)) + app.get("/provider/auth", (c) => handler(c.req.raw, context)) + app.post("/provider/:providerID/oauth/authorize", (c) => handler(c.req.raw, context)) + app.post("/provider/:providerID/oauth/callback", (c) => handler(c.req.raw, context)) + app.get("/project", (c) => handler(c.req.raw, context)) + app.get("/project/current", (c) => handler(c.req.raw, context)) + } + + return app + .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("/sync", SyncRoutes()) + .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) => { + return c.json( + await AppRuntime.runPromise( + Effect.gen(function* () { + const vcs = yield* Vcs.Service + const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { + concurrency: 2, + }) + return { 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 AppRuntime.runPromise( + Effect.gen(function* () { + const vcs = yield* Vcs.Service + return yield* 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 AppRuntime.runPromise(Agent.Service.use((svc) => svc.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 AppRuntime.runPromise( + Effect.gen(function* () { + const skill = yield* Skill.Service + return yield* 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) => { + const items = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.status())) + return c.json(items) + }, + ) + .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/routes/instance/mcp.ts b/packages/opencode/src/server/routes/instance/mcp.ts new file mode 100644 index 000000000..197185bde --- /dev/null +++ b/packages/opencode/src/server/routes/instance/mcp.ts @@ -0,0 +1,246 @@ +import { Hono } from "hono" +import { describeRoute, validator, resolver } from "hono-openapi" +import z from "zod" +import { MCP } from "@/mcp" +import { Config } from "@/config" +import { ConfigMCP } from "@/config/mcp" +import { AppRuntime } from "@/effect/app-runtime" +import { errors } from "../../error" +import { lazy } from "@/util/lazy" +import { Effect } from "effect" + +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 AppRuntime.runPromise(MCP.Service.use((mcp) => 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: ConfigMCP.Info, + }), + ), + async (c) => { + const { name, config } = c.req.valid("json") + const result = await AppRuntime.runPromise(MCP.Service.use((mcp) => 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 result = await AppRuntime.runPromise( + Effect.gen(function* () { + const mcp = yield* MCP.Service + const supports = yield* mcp.supportsOAuth(name) + if (!supports) return { supports } + return { + supports, + auth: yield* mcp.startAuth(name), + } + }), + ) + if (!result.supports) { + return c.json({ error: `MCP server ${name} does not support OAuth` }, 400) + } + return c.json(result.auth) + }, + ) + .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 AppRuntime.runPromise(MCP.Service.use((mcp) => 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 result = await AppRuntime.runPromise( + Effect.gen(function* () { + const mcp = yield* MCP.Service + const supports = yield* mcp.supportsOAuth(name) + if (!supports) return { supports } + return { + supports, + status: yield* mcp.authenticate(name), + } + }), + ) + if (!result.supports) { + return c.json({ error: `MCP server ${name} does not support OAuth` }, 400) + } + return c.json(result.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 AppRuntime.runPromise(MCP.Service.use((mcp) => 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 AppRuntime.runPromise(MCP.Service.use((mcp) => 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 AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.disconnect(name))) + return c.json(true) + }, + ), +) diff --git a/packages/opencode/src/server/routes/instance/permission.ts b/packages/opencode/src/server/routes/instance/permission.ts new file mode 100644 index 000000000..c3f9c8201 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/permission.ts @@ -0,0 +1,74 @@ +import { Hono } from "hono" +import { describeRoute, validator, resolver } from "hono-openapi" +import z from "zod" +import { AppRuntime } from "@/effect/app-runtime" +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.zod, message: z.string().optional() })), + async (c) => { + const params = c.req.valid("param") + const json = c.req.valid("json") + await AppRuntime.runPromise( + Permission.Service.use((svc) => + svc.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.zod.array()), + }, + }, + }, + }, + }), + async (c) => { + const permissions = await AppRuntime.runPromise(Permission.Service.use((svc) => svc.list())) + return c.json(permissions) + }, + ), +) diff --git a/packages/opencode/src/server/routes/instance/project.ts b/packages/opencode/src/server/routes/instance/project.ts new file mode 100644 index 000000000..060542c4b --- /dev/null +++ b/packages/opencode/src/server/routes/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" +import z from "zod" +import { ProjectID } from "@/project/schema" +import { errors } from "../../error" +import { lazy } from "@/util/lazy" +import { InstanceBootstrap } from "@/project/bootstrap" +import { AppRuntime } from "@/effect/app-runtime" + +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.zod.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.zod), + }, + }, + }, + }, + }), + 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.zod), + }, + }, + }, + }, + }), + async (c) => { + const dir = Instance.directory + const prev = Instance.project + const next = await AppRuntime.runPromise( + Project.Service.use((svc) => svc.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: () => AppRuntime.runPromise(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.zod), + }, + }, + }, + ...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 AppRuntime.runPromise(Project.Service.use((svc) => svc.update({ ...body, projectID }))) + return c.json(project) + }, + ), +) diff --git a/packages/opencode/src/server/routes/instance/provider.ts b/packages/opencode/src/server/routes/instance/provider.ts new file mode 100644 index 000000000..57aa895e3 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/provider.ts @@ -0,0 +1,169 @@ +import { Hono } from "hono" +import { describeRoute, validator, resolver } from "hono-openapi" +import z from "zod" +import { Config } from "@/config" +import { Provider } from "@/provider" +import { ModelsDev } from "@/provider" +import { ProviderAuth } from "@/provider" +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 { Effect } from "effect" + +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(Provider.ListResult.zod), + }, + }, + }, + }, + }), + async (c) => { + const result = await AppRuntime.runPromise( + Effect.gen(function* () { + const svc = yield* Provider.Service + const cfg = yield* Config.Service + const config = yield* cfg.get() + const all = yield* Effect.promise(() => ModelsDev.get()) + const disabled = new Set(config.disabled_providers ?? []) + const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined + const filtered: Record = {} + for (const [key, value] of Object.entries(all)) { + if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { + filtered[key] = value + } + } + const connected = yield* svc.list() + const providers = Object.assign( + mapValues(filtered, (x) => Provider.fromModelsDevProvider(x)), + connected, + ) + return { + all: Object.values(providers), + default: Provider.defaultModelIDs(providers), + connected: Object.keys(connected), + } + }), + ) + return c.json({ + all: result.all, + default: result.default, + connected: result.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(ProviderAuth.Methods.zod), + }, + }, + }, + }, + }), + 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.zod.optional()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "param", + z.object({ + providerID: ProviderID.zod.meta({ description: "Provider ID" }), + }), + ), + validator("json", ProviderAuth.AuthorizeInput.zod), + 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", ProviderAuth.CallbackInput.zod), + 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/instance/pty.ts b/packages/opencode/src/server/routes/instance/pty.ts new file mode 100644 index 000000000..b3f71c235 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/pty.ts @@ -0,0 +1,258 @@ +import { Hono } from "hono" +import { describeRoute, validator, resolver } from "hono-openapi" +import type { UpgradeWebSocket } from "hono/ws" +import { Effect } from "effect" +import z from "zod" +import { AppRuntime } from "@/effect/app-runtime" +import { Pty } from "@/pty" +import { PtyID } from "@/pty/schema" +import { NotFoundError } from "@/storage" +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 AppRuntime.runPromise( + Effect.gen(function* () { + const pty = yield* Pty.Service + return yield* 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 AppRuntime.runPromise( + Effect.gen(function* () { + const pty = yield* Pty.Service + return yield* 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 AppRuntime.runPromise( + Effect.gen(function* () { + const pty = yield* Pty.Service + return yield* 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 AppRuntime.runPromise( + Effect.gen(function* () { + const pty = yield* Pty.Service + return yield* 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 AppRuntime.runPromise( + Effect.gen(function* () { + const pty = yield* Pty.Service + yield* 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) => { + type Handler = { + onMessage: (message: string | ArrayBuffer) => void + onClose: () => void + } + + 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: Handler | undefined + if ( + !(await AppRuntime.runPromise( + Effect.gen(function* () { + const pty = yield* Pty.Service + return yield* 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 AppRuntime.runPromise( + Effect.gen(function* () { + const pty = yield* Pty.Service + return yield* 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/instance/question.ts b/packages/opencode/src/server/routes/instance/question.ts new file mode 100644 index 000000000..9b8f461e3 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/question.ts @@ -0,0 +1,110 @@ +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 { AppRuntime } from "@/effect/app-runtime" +import z from "zod" +import { errors } from "../../error" +import { lazy } from "@/util/lazy" + +const Reply = z.object({ + answers: Question.Answer.zod + .array() + .describe("User answers in order of questions (each answer is an array of selected labels)"), +}) + +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.zod.array()), + }, + }, + }, + }, + }), + async (c) => { + const questions = await AppRuntime.runPromise(Question.Service.use((svc) => svc.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", Reply), + async (c) => { + const params = c.req.valid("param") + const json = c.req.valid("json") + await AppRuntime.runPromise( + Question.Service.use((svc) => + svc.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 AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(params.requestID))) + return c.json(true) + }, + ), +) diff --git a/packages/opencode/src/server/routes/instance/session.ts b/packages/opencode/src/server/routes/instance/session.ts new file mode 100644 index 000000000..ae6185abb --- /dev/null +++ b/packages/opencode/src/server/routes/instance/session.ts @@ -0,0 +1,1120 @@ +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" +import { SessionStatus } from "@/session/status" +import { SessionSummary } from "@/session/summary" +import { Todo } from "@/session/todo" +import { Effect } from "effect" +import { AppRuntime } from "@/effect/app-runtime" +import { Agent } from "@/agent/agent" +import { Snapshot } from "@/snapshot" +import { Command } from "@/command" +import { Log } from "@/util" +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/shared/util/error" +import { jsonRequest } from "./trace" + +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) => + jsonRequest("SessionRoutes.status", c, function* () { + const svc = yield* SessionStatus.Service + return Object.fromEntries(yield* svc.list()) + }), + ) + .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.GetInput, + }), + ), + async (c) => { + const sessionID = c.req.valid("param").sessionID + return jsonRequest("SessionRoutes.get", c, function* () { + const session = yield* Session.Service + return yield* session.get(sessionID) + }) + }, + ) + .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.ChildrenInput, + }), + ), + async (c) => { + const sessionID = c.req.valid("param").sessionID + return jsonRequest("SessionRoutes.children", c, function* () { + const session = yield* Session.Service + return yield* session.children(sessionID) + }) + }, + ) + .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 + return jsonRequest("SessionRoutes.todo", c, function* () { + const todo = yield* Todo.Service + return yield* todo.get(sessionID) + }) + }, + ) + .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.CreateInput), + async (c) => { + const body = c.req.valid("json") ?? {} + const session = await AppRuntime.runPromise(SessionShare.Service.use((svc) => svc.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.RemoveInput, + }), + ), + async (c) => { + const sessionID = c.req.valid("param").sessionID + await AppRuntime.runPromise(Session.Service.use((svc) => svc.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(), + permission: Permission.Ruleset.zod.optional(), + time: z + .object({ + archived: z.number().optional(), + }) + .optional(), + }), + ), + async (c) => { + const sessionID = c.req.valid("param").sessionID + const updates = c.req.valid("json") + const session = await AppRuntime.runPromise( + Effect.gen(function* () { + const session = yield* Session.Service + const current = yield* session.get(sessionID) + + if (updates.title !== undefined) { + yield* session.setTitle({ sessionID, title: updates.title }) + } + if (updates.permission !== undefined) { + yield* session.setPermission({ + sessionID, + permission: Permission.merge(current.permission ?? [], updates.permission), + }) + } + if (updates.time?.archived !== undefined) { + yield* session.setArchived({ sessionID, time: updates.time.archived }) + } + + return yield* 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 AppRuntime.runPromise( + SessionPrompt.Service.use((svc) => + svc.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.ForkInput.shape.sessionID, + }), + ), + validator("json", Session.ForkInput.omit({ sessionID: true })), + async (c) => { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") + const result = await AppRuntime.runPromise(Session.Service.use((svc) => svc.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 AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.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 + const session = await AppRuntime.runPromise( + Effect.gen(function* () { + const share = yield* SessionShare.Service + const session = yield* Session.Service + yield* share.share(sessionID) + return yield* 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 AppRuntime.runPromise( + SessionSummary.Service.use((summary) => + summary.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 + const session = await AppRuntime.runPromise( + Effect.gen(function* () { + const share = yield* SessionShare.Service + const session = yield* Session.Service + yield* share.unshare(sessionID) + return yield* 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") + await AppRuntime.runPromise( + Effect.gen(function* () { + const session = yield* Session.Service + const revert = yield* SessionRevert.Service + const compact = yield* SessionCompaction.Service + const prompt = yield* SessionPrompt.Service + const agent = yield* Agent.Service + + yield* revert.cleanup(yield* session.get(sessionID)) + const msgs = yield* session.messages({ sessionID }) + const defaultAgent = yield* agent.defaultAgent() + let currentAgent = defaultAgent + for (let i = msgs.length - 1; i >= 0; i--) { + const info = msgs[i].info + if (info.role === "user") { + currentAgent = info.agent || defaultAgent + break + } + } + + yield* compact.create({ + sessionID, + agent: currentAgent, + model: { + providerID: body.providerID, + modelID: body.modelID, + }, + auto: body.auto, + }) + yield* prompt.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 || query.limit === 0) { + const messages = await AppRuntime.runPromise( + Effect.gen(function* () { + const session = yield* Session.Service + yield* session.get(sessionID) + return yield* 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 AppRuntime.runPromise( + Effect.gen(function* () { + const state = yield* SessionRunState.Service + const session = yield* Session.Service + yield* state.assertNotBusy(params.sessionID) + yield* 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 AppRuntime.runPromise( + Session.Service.use((svc) => + svc.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 AppRuntime.runPromise(Session.Service.use((svc) => svc.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 AppRuntime.runPromise( + SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID })), + ) + void 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") + void AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID }))).catch( + (err) => { + log.error("prompt_async failed", { sessionID, error: err }) + void 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 AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.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 AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.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 AppRuntime.runPromise( + SessionRevert.Service.use((svc) => + svc.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 AppRuntime.runPromise(SessionRevert.Service.use((svc) => svc.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.zod })), + async (c) => { + const params = c.req.valid("param") + await AppRuntime.runPromise( + Permission.Service.use((svc) => + svc.reply({ + requestID: params.permissionID, + reply: c.req.valid("json").response, + }), + ), + ) + return c.json(true) + }, + ), +) diff --git a/packages/opencode/src/server/routes/instance/sync.ts b/packages/opencode/src/server/routes/instance/sync.ts new file mode 100644 index 000000000..c6a067997 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/sync.ts @@ -0,0 +1,119 @@ +import z from "zod" +import { Hono } from "hono" +import { describeRoute, validator, resolver } from "hono-openapi" +import { SyncEvent } from "@/sync" +import { Database, asc, and, not, or, lte, eq } from "@/storage" +import { EventTable } from "@/sync/event.sql" +import { lazy } from "@/util/lazy" +import { Log } from "@/util" +import { errors } from "../../error" + +const ReplayEvent = z.object({ + id: z.string(), + aggregateID: z.string(), + seq: z.number().int().min(0), + type: z.string(), + data: z.record(z.string(), z.unknown()), +}) + +const log = Log.create({ service: "server.sync" }) + +export const SyncRoutes = lazy(() => + new Hono() + .post( + "/replay", + describeRoute({ + summary: "Replay sync events", + description: "Validate and replay a complete sync event history.", + operationId: "sync.replay", + responses: { + 200: { + description: "Replayed sync events", + content: { + "application/json": { + schema: resolver( + z.object({ + sessionID: z.string(), + }), + ), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "json", + z.object({ + directory: z.string(), + events: z.array(ReplayEvent).min(1), + }), + ), + async (c) => { + const body = c.req.valid("json") + const events = body.events + const source = events[0].aggregateID + + log.info("sync replay requested", { + sessionID: source, + events: events.length, + first: events[0]?.seq, + last: events.at(-1)?.seq, + directory: body.directory, + }) + SyncEvent.replayAll(events) + + log.info("sync replay complete", { + sessionID: source, + events: events.length, + first: events[0]?.seq, + last: events.at(-1)?.seq, + }) + + return c.json({ + sessionID: source, + }) + }, + ) + .get( + "/history", + describeRoute({ + summary: "List sync events", + description: + "List sync events for all aggregates. Keys are aggregate IDs the client already knows about, values are the last known sequence ID. Events with seq > value are returned for those aggregates. Aggregates not listed in the input get their full history.", + operationId: "sync.history.list", + responses: { + 200: { + description: "Sync events", + content: { + "application/json": { + schema: resolver( + z.array( + z.object({ + id: z.string(), + aggregate_id: z.string(), + seq: z.number(), + type: z.string(), + data: z.record(z.string(), z.unknown()), + }), + ), + ), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", z.record(z.string(), z.number().int().min(0))), + async (c) => { + const body = c.req.valid("json") + const exclude = Object.entries(body) + const where = + exclude.length > 0 + ? not(or(...exclude.map(([id, seq]) => and(eq(EventTable.aggregate_id, id), lte(EventTable.seq, seq))))!) + : undefined + const rows = Database.use((db) => db.select().from(EventTable).where(where).orderBy(asc(EventTable.seq)).all()) + return c.json(rows) + }, + ), +) diff --git a/packages/opencode/src/server/routes/instance/trace.ts b/packages/opencode/src/server/routes/instance/trace.ts new file mode 100644 index 000000000..3e1f72d8b --- /dev/null +++ b/packages/opencode/src/server/routes/instance/trace.ts @@ -0,0 +1,33 @@ +import type { Context } from "hono" +import { Effect } from "effect" +import { AppRuntime } from "@/effect/app-runtime" + +type AppEnv = Parameters[0] extends Effect.Effect ? R : never + +export function runRequest(name: string, c: Context, effect: Effect.Effect) { + const url = new URL(c.req.url) + return AppRuntime.runPromise( + effect.pipe( + Effect.withSpan(name, { + attributes: { + "http.method": c.req.method, + "http.path": url.pathname, + }, + }), + ), + ) +} + +export async function jsonRequest( + name: string, + c: C, + effect: (c: C) => Effect.gen.Return, +) { + return c.json( + await runRequest( + name, + c, + Effect.gen(() => effect(c)), + ), + ) +} diff --git a/packages/opencode/src/server/routes/instance/tui.ts b/packages/opencode/src/server/routes/instance/tui.ts new file mode 100644 index 000000000..2f856c348 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/tui.ts @@ -0,0 +1,380 @@ +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 { AppRuntime } from "@/effect/app-runtime" +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 AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID))) + await Bus.publish(TuiEvent.SessionSelect, { sessionID }) + return c.json(true) + }, + ) + .route("/control", TuiControlRoutes), +) diff --git a/packages/opencode/src/server/routes/ui.ts b/packages/opencode/src/server/routes/ui.ts new file mode 100644 index 000000000..d449cd1c4 --- /dev/null +++ b/packages/opencode/src/server/routes/ui.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}`, { + raw: c.req.raw, + headers: { + ...Object.fromEntries(c.req.raw.headers.entries()), + 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/server.ts b/packages/opencode/src/server/server.ts index 892a99a77..2201c75b4 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1,16 +1,25 @@ import { generateSpecs } from "hono-openapi" import { Hono } from "hono" +import type { MiddlewareHandler } from "hono" import { adapter } from "#hono" -import { MDNS } from "./mdns" import { lazy } from "@/util/lazy" +import { Log } from "@/util" +import { Flag } from "@/flag/flag" +import { Instance } from "@/project/instance" +import { InstanceBootstrap } from "@/project/bootstrap" +import { AppRuntime } from "@/effect/app-runtime" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { WorkspaceID } from "@/control-plane/schema" +import { WorkspaceContext } from "@/control-plane/workspace-context" +import { MDNS } from "./mdns" import { AuthMiddleware, CompressionMiddleware, CorsMiddleware, ErrorMiddleware, LoggerMiddleware } from "./middleware" import { FenceMiddleware } from "./fence" -import { InstanceRoutes } from "./instance" import { initProjectors } from "./projectors" -import { Log } from "@/util" -import { Flag } from "@/flag/flag" -import { ControlPlaneRoutes } from "./control" -import { UIRoutes } from "./ui" +import { InstanceRoutes } from "./routes/instance" +import { ControlPlaneRoutes } from "./routes/control" +import { UIRoutes } from "./routes/ui" +import { GlobalRoutes } from "./routes/global" +import { WorkspaceRouterMiddleware } from "./workspace" // @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 @@ -30,18 +39,48 @@ export const Default = lazy(() => create({})) function create(opts: { cors?: string[] }) { const app = new Hono() + .onError(ErrorMiddleware) + .use(AuthMiddleware) + .use(LoggerMiddleware) + .use(CompressionMiddleware) + .use(CorsMiddleware(opts)) + .route("/global", GlobalRoutes()) + const runtime = adapter.create(app) + function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler { + return async (c, next) => { + const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() + const directory = AppFileSystem.resolve( + (() => { + try { + return decodeURIComponent(raw) + } catch { + return raw + } + })(), + ) + + return WorkspaceContext.provide({ + workspaceID, + async fn() { + return Instance.provide({ + directory, + init: () => AppRuntime.runPromise(InstanceBootstrap), + async fn() { + return next() + }, + }) + }, + }) + } + } + if (Flag.OPENCODE_WORKSPACE_ID) { return { app: app - .onError(ErrorMiddleware) - .use(AuthMiddleware) - .use(LoggerMiddleware) - .use(CompressionMiddleware) - .use(CorsMiddleware(opts)) + .use(InstanceMiddleware(Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined)) .use(FenceMiddleware) - .route("/", ControlPlaneRoutes()) .route("/", InstanceRoutes(runtime.upgradeWebSocket)), runtime, } @@ -49,12 +88,9 @@ function create(opts: { cors?: string[] }) { return { app: app - .onError(ErrorMiddleware) - .use(AuthMiddleware) - .use(LoggerMiddleware) - .use(CompressionMiddleware) - .use(CorsMiddleware(opts)) + .use(InstanceMiddleware()) .route("/", ControlPlaneRoutes()) + .use(WorkspaceRouterMiddleware(runtime.upgradeWebSocket)) .route("/", InstanceRoutes(runtime.upgradeWebSocket)) .route("/", UIRoutes()), runtime, diff --git a/packages/opencode/src/server/ui/index.ts b/packages/opencode/src/server/ui/index.ts deleted file mode 100644 index d449cd1c4..000000000 --- a/packages/opencode/src/server/ui/index.ts +++ /dev/null @@ -1,55 +0,0 @@ -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}`, { - raw: c.req.raw, - headers: { - ...Object.fromEntries(c.req.raw.headers.entries()), - 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/workspace.ts b/packages/opencode/src/server/workspace.ts new file mode 100644 index 000000000..c141d1095 --- /dev/null +++ b/packages/opencode/src/server/workspace.ts @@ -0,0 +1,119 @@ +import type { MiddlewareHandler } from "hono" +import type { UpgradeWebSocket } from "hono/ws" +import { getAdaptor } from "@/control-plane/adaptors" +import { WorkspaceID } from "@/control-plane/schema" +import { WorkspaceContext } from "@/control-plane/workspace-context" +import { Workspace } from "@/control-plane/workspace" +import { Flag } from "@/flag/flag" +import { InstanceBootstrap } from "@/project/bootstrap" +import { Instance } from "@/project/instance" +import { Session } from "@/session" +import { SessionID } from "@/session/schema" +import { AppRuntime } from "@/effect/app-runtime" +import { Log } from "@/util" +import { ServerProxy } from "./proxy" + +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 AppRuntime.runPromise(Session.Service.use((svc) => svc.get(id))).catch(() => undefined) + return session?.workspaceID +} + +export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler { + const log = Log.create({ service: "workspace-router" }) + + return async (c, next) => { + const url = new URL(c.req.url) + + const sessionWorkspaceID = await getSessionWorkspace(url) + const workspaceID = sessionWorkspaceID || url.searchParams.get("workspace") + + if (!workspaceID || url.pathname.startsWith("/console") || Flag.OPENCODE_WORKSPACE_ID) { + return next() + } + + const workspace = await Workspace.get(WorkspaceID.make(workspaceID)) + + if (!workspace) { + return new Response(`Workspace not found: ${workspaceID}`, { + status: 500, + headers: { + "content-type": "text/plain; charset=utf-8", + }, + }) + } + + 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() + } + + const adaptor = await getAdaptor(workspace.projectID, 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: () => AppRuntime.runPromise(InstanceBootstrap), + async fn() { + return next() + }, + }), + }) + } + + const proxyURL = new URL(target.url) + proxyURL.pathname = `${proxyURL.pathname.replace(/\/$/, "")}${url.pathname}` + proxyURL.search = url.search + proxyURL.hash = url.hash + proxyURL.searchParams.delete("workspace") + + log.info("workspace proxy forwarding", { + workspaceID, + request: url.toString(), + target: String(target.url), + proxy: proxyURL.toString(), + }) + + if (c.req.header("upgrade")?.toLowerCase() === "websocket") { + return ServerProxy.websocket(upgrade, proxyURL, target.headers, c.req.raw, c.env) + } + + const headers = new Headers(c.req.raw.headers) + headers.delete("x-opencode-workspace") + + const req = new Request(c.req.raw, { headers }) + return ServerProxy.http(proxyURL, target.headers, req, workspace.id) + } +} diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts index 50b765896..23e8b5014 100644 --- a/packages/opencode/test/server/session-messages.test.ts +++ b/packages/opencode/test/server/session-messages.test.ts @@ -165,16 +165,3 @@ 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/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) - expect(end).toBeGreaterThan(start) - const route = src.slice(start, end) - expect(route).toContain(".catch(") - expect(route).toContain("Bus.publish(Session.Event.Error") - }) -}) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index d7bf43f50..f484147a4 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -510,11 +510,11 @@ export class App extends HeyApiClient { } } -export class Project extends HeyApiClient { +export class Adaptor extends HeyApiClient { /** - * List all projects + * List workspace adaptors * - * Get a list of projects that have been opened with OpenCode. + * List all available workspace adaptors for the current project. */ public list( parameters?: { @@ -534,19 +534,21 @@ export class Project extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ - url: "/project", + return (options?.client ?? this.client).get({ + url: "/experimental/workspace/adaptor", ...options, ...params, }) } +} +export class Workspace extends HeyApiClient { /** - * Get current project + * List workspaces * - * Retrieve the currently active project that OpenCode is working with. + * List all workspaces. */ - public current( + public list( parameters?: { directory?: string workspace?: string @@ -564,22 +566,26 @@ export class Project extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ - url: "/project/current", + return (options?.client ?? this.client).get({ + url: "/experimental/workspace", ...options, ...params, }) } /** - * Initialize git repository + * Create workspace * - * Create a git repository for the current project and return the refreshed project info. + * Create a workspace for the current project. */ - public initGit( + public create( parameters?: { directory?: string workspace?: string + id?: string + type?: string + branch?: string | null + extra?: unknown | null }, options?: Options, ) { @@ -590,39 +596,39 @@ export class Project extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "body", key: "id" }, + { in: "body", key: "type" }, + { in: "body", key: "branch" }, + { in: "body", key: "extra" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/project/git/init", + return (options?.client ?? this.client).post< + ExperimentalWorkspaceCreateResponses, + ExperimentalWorkspaceCreateErrors, + ThrowOnError + >({ + url: "/experimental/workspace", ...options, ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, }) } /** - * Update project + * Workspace status * - * Update project properties such as name, icon, and commands. + * Get connection status for workspaces in the current project. */ - public update( - parameters: { - projectID: string + public status( + parameters?: { directory?: string workspace?: string - name?: string - icon?: { - url?: string - override?: string - color?: string - } - commands?: { - /** - * Startup script to run when creating a new workspace (worktree) - */ - start?: string - } }, options?: Options, ) { @@ -631,37 +637,27 @@ export class Project extends HeyApiClient { [ { args: [ - { in: "path", key: "projectID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "body", key: "name" }, - { in: "body", key: "icon" }, - { in: "body", key: "commands" }, ], }, ], ) - return (options?.client ?? this.client).patch({ - url: "/project/{projectID}", + return (options?.client ?? this.client).get({ + url: "/experimental/workspace/status", ...options, ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, }) } -} -export class Pty extends HeyApiClient { /** - * List PTY sessions + * Remove workspace * - * Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode. + * Remove an existing workspace. */ - public list( - parameters?: { + public remove( + parameters: { + id: string directory?: string workspace?: string }, @@ -672,35 +668,35 @@ export class Pty extends HeyApiClient { [ { args: [ + { in: "path", key: "id" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/pty", + return (options?.client ?? this.client).delete< + ExperimentalWorkspaceRemoveResponses, + ExperimentalWorkspaceRemoveErrors, + ThrowOnError + >({ + url: "/experimental/workspace/{id}", ...options, ...params, }) } /** - * Create PTY session + * Restore session into workspace * - * Create a new pseudo-terminal (PTY) session for running shell commands and processes. + * Replay a session's sync events into the target workspace in batches. */ - public create( - parameters?: { + public sessionRestore( + parameters: { + id: string directory?: string workspace?: string - command?: string - args?: Array - cwd?: string - title?: string - env?: { - [key: string]: string - } + sessionID?: string }, options?: Options, ) { @@ -709,19 +705,20 @@ export class Pty extends HeyApiClient { [ { args: [ + { in: "path", key: "id" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "body", key: "command" }, - { in: "body", key: "args" }, - { in: "body", key: "cwd" }, - { in: "body", key: "title" }, - { in: "body", key: "env" }, + { in: "body", key: "sessionID" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/pty", + return (options?.client ?? this.client).post< + ExperimentalWorkspaceSessionRestoreResponses, + ExperimentalWorkspaceSessionRestoreErrors, + ThrowOnError + >({ + url: "/experimental/workspace/{id}/session-restore", ...options, ...params, headers: { @@ -732,14 +729,20 @@ export class Pty extends HeyApiClient { }) } + private _adaptor?: Adaptor + get adaptor(): Adaptor { + return (this._adaptor ??= new Adaptor({ client: this.client })) + } +} + +export class Console extends HeyApiClient { /** - * Remove PTY session + * Get active Console provider metadata * - * Remove and terminate a specific pseudo-terminal (PTY) session. + * Get the active Console org name and the set of provider IDs managed by that Console org. */ - public remove( - parameters: { - ptyID: string + public get( + parameters?: { directory?: string workspace?: string }, @@ -750,28 +753,26 @@ export class Pty extends HeyApiClient { [ { args: [ - { in: "path", key: "ptyID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, ], }, ], ) - return (options?.client ?? this.client).delete({ - url: "/pty/{ptyID}", + return (options?.client ?? this.client).get({ + url: "/experimental/console", ...options, ...params, }) } /** - * Get PTY session + * List switchable Console orgs * - * Retrieve detailed information about a specific pseudo-terminal (PTY) session. + * Get the available Console orgs across logged-in accounts, including the current active org. */ - public get( - parameters: { - ptyID: string + public listOrgs( + parameters?: { directory?: string workspace?: string }, @@ -782,35 +783,30 @@ export class Pty extends HeyApiClient { [ { args: [ - { in: "path", key: "ptyID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/pty/{ptyID}", + return (options?.client ?? this.client).get({ + url: "/experimental/console/orgs", ...options, ...params, }) } /** - * Update PTY session + * Switch active Console org * - * Update properties of an existing pseudo-terminal (PTY) session. + * Persist a new active Console account/org selection for the current local OpenCode state. */ - public update( - parameters: { - ptyID: string + public switchOrg( + parameters?: { directory?: string workspace?: string - title?: string - size?: { - rows: number - cols: number - } + accountID?: string + orgID?: string }, options?: Options, ) { @@ -819,17 +815,16 @@ export class Pty extends HeyApiClient { [ { args: [ - { in: "path", key: "ptyID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "body", key: "title" }, - { in: "body", key: "size" }, + { in: "body", key: "accountID" }, + { in: "body", key: "orgID" }, ], }, ], ) - return (options?.client ?? this.client).put({ - url: "/pty/{ptyID}", + return (options?.client ?? this.client).post({ + url: "/experimental/console/switch", ...options, ...params, headers: { @@ -839,17 +834,24 @@ export class Pty extends HeyApiClient { }, }) } +} +export class Session extends HeyApiClient { /** - * Connect to PTY session + * List sessions * - * Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time. + * Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default. */ - public connect( - parameters: { - ptyID: string + public list( + parameters?: { directory?: string workspace?: string + roots?: boolean + start?: number + cursor?: number + search?: string + limit?: number + archived?: boolean }, options?: Options, ) { @@ -858,28 +860,33 @@ export class Pty extends HeyApiClient { [ { args: [ - { in: "path", key: "ptyID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "query", key: "roots" }, + { in: "query", key: "start" }, + { in: "query", key: "cursor" }, + { in: "query", key: "search" }, + { in: "query", key: "limit" }, + { in: "query", key: "archived" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/pty/{ptyID}/connect", + return (options?.client ?? this.client).get({ + url: "/experimental/session", ...options, ...params, }) } } -export class Config2 extends HeyApiClient { +export class Resource extends HeyApiClient { /** - * Get configuration + * Get MCP resources * - * Retrieve the current OpenCode configuration settings and preferences. + * Get all available MCP resources from connected servers. Optionally filter by name. */ - public get( + public list( parameters?: { directory?: string workspace?: string @@ -897,23 +904,46 @@ export class Config2 extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ - url: "/config", + return (options?.client ?? this.client).get({ + url: "/experimental/resource", ...options, ...params, }) } +} +export class Experimental extends HeyApiClient { + private _workspace?: Workspace + get workspace(): Workspace { + return (this._workspace ??= new Workspace({ client: this.client })) + } + + private _console?: Console + get console(): Console { + return (this._console ??= new Console({ client: this.client })) + } + + private _session?: Session + get session(): Session { + return (this._session ??= new Session({ client: this.client })) + } + + private _resource?: Resource + get resource(): Resource { + return (this._resource ??= new Resource({ client: this.client })) + } +} + +export class Project extends HeyApiClient { /** - * Update configuration + * List all projects * - * Update OpenCode configuration settings and preferences. + * Get a list of projects that have been opened with OpenCode. */ - public update( + public list( parameters?: { directory?: string workspace?: string - config?: Config3 }, options?: Options, ) { @@ -924,29 +954,23 @@ export class Config2 extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { key: "config", map: "body" }, ], }, ], ) - return (options?.client ?? this.client).patch({ - url: "/config", + return (options?.client ?? this.client).get({ + url: "/project", ...options, ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, }) } /** - * List config providers + * Get current project * - * Get a list of all configured AI providers and their default models. + * Retrieve the currently active project that OpenCode is working with. */ - public providers( + public current( parameters?: { directory?: string workspace?: string @@ -964,21 +988,19 @@ export class Config2 extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ - url: "/config/providers", + return (options?.client ?? this.client).get({ + url: "/project/current", ...options, ...params, }) } -} -export class Console extends HeyApiClient { /** - * Get active Console provider metadata + * Initialize git repository * - * Get the active Console org name and the set of provider IDs managed by that Console org. + * Create a git repository for the current project and return the refreshed project info. */ - public get( + public initGit( parameters?: { directory?: string workspace?: string @@ -996,19 +1018,73 @@ export class Console extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ - url: "/experimental/console", + return (options?.client ?? this.client).post({ + url: "/project/git/init", + ...options, + ...params, + }) + } + + /** + * Update project + * + * Update project properties such as name, icon, and commands. + */ + public update( + parameters: { + projectID: string + directory?: string + workspace?: string + name?: string + icon?: { + url?: string + override?: string + color?: string + } + commands?: { + /** + * Startup script to run when creating a new workspace (worktree) + */ + start?: string + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "projectID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "name" }, + { in: "body", key: "icon" }, + { in: "body", key: "commands" }, + ], + }, + ], + ) + return (options?.client ?? this.client).patch({ + url: "/project/{projectID}", ...options, ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, }) } +} +export class Pty extends HeyApiClient { /** - * List switchable Console orgs + * List PTY sessions * - * Get the available Console orgs across logged-in accounts, including the current active org. + * Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode. */ - public listOrgs( + public list( parameters?: { directory?: string workspace?: string @@ -1026,24 +1102,29 @@ export class Console extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ - url: "/experimental/console/orgs", + return (options?.client ?? this.client).get({ + url: "/pty", ...options, ...params, }) } /** - * Switch active Console org + * Create PTY session * - * Persist a new active Console account/org selection for the current local OpenCode state. + * Create a new pseudo-terminal (PTY) session for running shell commands and processes. */ - public switchOrg( + public create( parameters?: { directory?: string workspace?: string - accountID?: string - orgID?: string + command?: string + args?: Array + cwd?: string + title?: string + env?: { + [key: string]: string + } }, options?: Options, ) { @@ -1054,14 +1135,17 @@ export class Console extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "body", key: "accountID" }, - { in: "body", key: "orgID" }, + { in: "body", key: "command" }, + { in: "body", key: "args" }, + { in: "body", key: "cwd" }, + { in: "body", key: "title" }, + { in: "body", key: "env" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/experimental/console/switch", + return (options?.client ?? this.client).post({ + url: "/pty", ...options, ...params, headers: { @@ -1071,16 +1155,15 @@ export class Console extends HeyApiClient { }, }) } -} -export class Adaptor extends HeyApiClient { /** - * List workspace adaptors + * Remove PTY session * - * List all available workspace adaptors for the current project. + * Remove and terminate a specific pseudo-terminal (PTY) session. */ - public list( - parameters?: { + public remove( + parameters: { + ptyID: string directory?: string workspace?: string }, @@ -1091,28 +1174,28 @@ export class Adaptor extends HeyApiClient { [ { args: [ + { in: "path", key: "ptyID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/experimental/workspace/adaptor", + return (options?.client ?? this.client).delete({ + url: "/pty/{ptyID}", ...options, ...params, }) } -} -export class Workspace extends HeyApiClient { /** - * List workspaces + * Get PTY session * - * List all workspaces. + * Retrieve detailed information about a specific pseudo-terminal (PTY) session. */ - public list( - parameters?: { + public get( + parameters: { + ptyID: string directory?: string workspace?: string }, @@ -1123,32 +1206,35 @@ export class Workspace extends HeyApiClient { [ { args: [ + { in: "path", key: "ptyID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/experimental/workspace", + return (options?.client ?? this.client).get({ + url: "/pty/{ptyID}", ...options, ...params, }) } /** - * Create workspace + * Update PTY session * - * Create a workspace for the current project. + * Update properties of an existing pseudo-terminal (PTY) session. */ - public create( - parameters?: { + public update( + parameters: { + ptyID: string directory?: string workspace?: string - id?: string - type?: string - branch?: string | null - extra?: unknown | null + title?: string + size?: { + rows: number + cols: number + } }, options?: Options, ) { @@ -1157,22 +1243,17 @@ export class Workspace extends HeyApiClient { [ { args: [ + { in: "path", key: "ptyID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "body", key: "id" }, - { in: "body", key: "type" }, - { in: "body", key: "branch" }, - { in: "body", key: "extra" }, + { in: "body", key: "title" }, + { in: "body", key: "size" }, ], }, ], ) - return (options?.client ?? this.client).post< - ExperimentalWorkspaceCreateResponses, - ExperimentalWorkspaceCreateErrors, - ThrowOnError - >({ - url: "/experimental/workspace", + return (options?.client ?? this.client).put({ + url: "/pty/{ptyID}", ...options, ...params, headers: { @@ -1184,12 +1265,13 @@ export class Workspace extends HeyApiClient { } /** - * Workspace status + * Connect to PTY session * - * Get connection status for workspaces in the current project. + * Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time. */ - public status( - parameters?: { + public connect( + parameters: { + ptyID: string directory?: string workspace?: string }, @@ -1200,27 +1282,29 @@ export class Workspace extends HeyApiClient { [ { args: [ + { in: "path", key: "ptyID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/experimental/workspace/status", + return (options?.client ?? this.client).get({ + url: "/pty/{ptyID}/connect", ...options, ...params, }) } +} +export class Config2 extends HeyApiClient { /** - * Remove workspace + * Get configuration * - * Remove an existing workspace. + * Retrieve the current OpenCode configuration settings and preferences. */ - public remove( - parameters: { - id: string + public get( + parameters?: { directory?: string workspace?: string }, @@ -1231,35 +1315,29 @@ export class Workspace extends HeyApiClient { [ { args: [ - { in: "path", key: "id" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, ], }, ], ) - return (options?.client ?? this.client).delete< - ExperimentalWorkspaceRemoveResponses, - ExperimentalWorkspaceRemoveErrors, - ThrowOnError - >({ - url: "/experimental/workspace/{id}", + return (options?.client ?? this.client).get({ + url: "/config", ...options, ...params, }) } /** - * Restore session into workspace + * Update configuration * - * Replay a session's sync events into the target workspace in batches. + * Update OpenCode configuration settings and preferences. */ - public sessionRestore( - parameters: { - id: string + public update( + parameters?: { directory?: string workspace?: string - sessionID?: string + config?: Config3 }, options?: Options, ) { @@ -1268,20 +1346,15 @@ export class Workspace extends HeyApiClient { [ { args: [ - { in: "path", key: "id" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "body", key: "sessionID" }, + { key: "config", map: "body" }, ], }, ], ) - return (options?.client ?? this.client).post< - ExperimentalWorkspaceSessionRestoreResponses, - ExperimentalWorkspaceSessionRestoreErrors, - ThrowOnError - >({ - url: "/experimental/workspace/{id}/session-restore", + return (options?.client ?? this.client).patch({ + url: "/config", ...options, ...params, headers: { @@ -1292,63 +1365,12 @@ export class Workspace extends HeyApiClient { }) } - private _adaptor?: Adaptor - get adaptor(): Adaptor { - return (this._adaptor ??= new Adaptor({ client: this.client })) - } -} - -export class Session extends HeyApiClient { - /** - * List sessions - * - * Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default. - */ - public list( - parameters?: { - directory?: string - workspace?: string - roots?: boolean - start?: number - cursor?: number - search?: string - limit?: number - archived?: boolean - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "roots" }, - { in: "query", key: "start" }, - { in: "query", key: "cursor" }, - { in: "query", key: "search" }, - { in: "query", key: "limit" }, - { in: "query", key: "archived" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/experimental/session", - ...options, - ...params, - }) - } -} - -export class Resource extends HeyApiClient { /** - * Get MCP resources + * List config providers * - * Get all available MCP resources from connected servers. Optionally filter by name. + * Get a list of all configured AI providers and their default models. */ - public list( + public providers( parameters?: { directory?: string workspace?: string @@ -1366,36 +1388,14 @@ export class Resource extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ - url: "/experimental/resource", + return (options?.client ?? this.client).get({ + url: "/config/providers", ...options, ...params, }) } } -export class Experimental extends HeyApiClient { - private _console?: Console - get console(): Console { - return (this._console ??= new Console({ client: this.client })) - } - - private _workspace?: Workspace - get workspace(): Workspace { - return (this._workspace ??= new Workspace({ client: this.client })) - } - - private _session?: Session - get session(): Session { - return (this._session ??= new Session({ client: this.client })) - } - - private _resource?: Resource - get resource(): Resource { - return (this._resource ??= new Resource({ client: this.client })) - } -} - export class Tool extends HeyApiClient { /** * List tool IDs @@ -4314,6 +4314,11 @@ export class OpencodeClient extends HeyApiClient { return (this._app ??= new App({ client: this.client })) } + private _experimental?: Experimental + get experimental(): Experimental { + return (this._experimental ??= new Experimental({ client: this.client })) + } + private _project?: Project get project(): Project { return (this._project ??= new Project({ client: this.client })) @@ -4329,11 +4334,6 @@ export class OpencodeClient extends HeyApiClient { return (this._config ??= new Config2({ client: this.client })) } - private _experimental?: Experimental - get experimental(): Experimental { - return (this._experimental ??= new Experimental({ client: this.client })) - } - private _tool?: Tool get tool(): Tool { return (this._tool ??= new Tool({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 25c3cfa66..839dae8b2 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1706,6 +1706,16 @@ export type WellKnownAuth = { export type Auth = OAuth | ApiAuth | WellKnownAuth +export type Workspace = { + id: string + type: string + name: string + branch: string | null + directory: string | null + extra: unknown | null + projectID: string +} + export type NotFoundError = { name: "NotFoundError" data: { @@ -1808,16 +1818,6 @@ export type ToolListItem = { export type ToolList = Array -export type Workspace = { - id: string - type: string - name: string - branch: string | null - directory: string | null - extra: unknown | null - projectID: string -} - export type Worktree = { name: string branch: string @@ -2394,6 +2394,177 @@ export type AppLogResponses = { export type AppLogResponse = AppLogResponses[keyof AppLogResponses] +export type ExperimentalWorkspaceAdaptorListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace/adaptor" +} + +export type ExperimentalWorkspaceAdaptorListResponses = { + /** + * Workspace adaptors + */ + 200: Array<{ + type: string + name: string + description: string + }> +} + +export type ExperimentalWorkspaceAdaptorListResponse = + ExperimentalWorkspaceAdaptorListResponses[keyof ExperimentalWorkspaceAdaptorListResponses] + +export type ExperimentalWorkspaceListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace" +} + +export type ExperimentalWorkspaceListResponses = { + /** + * Workspaces + */ + 200: Array +} + +export type ExperimentalWorkspaceListResponse = + ExperimentalWorkspaceListResponses[keyof ExperimentalWorkspaceListResponses] + +export type ExperimentalWorkspaceCreateData = { + body?: { + id?: string + type: string + branch: string | null + extra: unknown | null + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace" +} + +export type ExperimentalWorkspaceCreateErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalWorkspaceCreateError = + ExperimentalWorkspaceCreateErrors[keyof ExperimentalWorkspaceCreateErrors] + +export type ExperimentalWorkspaceCreateResponses = { + /** + * Workspace created + */ + 200: Workspace +} + +export type ExperimentalWorkspaceCreateResponse = + ExperimentalWorkspaceCreateResponses[keyof ExperimentalWorkspaceCreateResponses] + +export type ExperimentalWorkspaceStatusData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace/status" +} + +export type ExperimentalWorkspaceStatusResponses = { + /** + * Workspace status + */ + 200: Array<{ + workspaceID: string + status: "connected" | "connecting" | "disconnected" | "error" + error?: string + }> +} + +export type ExperimentalWorkspaceStatusResponse = + ExperimentalWorkspaceStatusResponses[keyof ExperimentalWorkspaceStatusResponses] + +export type ExperimentalWorkspaceRemoveData = { + body?: never + path: { + id: string + } + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace/{id}" +} + +export type ExperimentalWorkspaceRemoveErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalWorkspaceRemoveError = + ExperimentalWorkspaceRemoveErrors[keyof ExperimentalWorkspaceRemoveErrors] + +export type ExperimentalWorkspaceRemoveResponses = { + /** + * Workspace removed + */ + 200: Workspace +} + +export type ExperimentalWorkspaceRemoveResponse = + ExperimentalWorkspaceRemoveResponses[keyof ExperimentalWorkspaceRemoveResponses] + +export type ExperimentalWorkspaceSessionRestoreData = { + body?: { + sessionID: string + } + path: { + id: string + } + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace/{id}/session-restore" +} + +export type ExperimentalWorkspaceSessionRestoreErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalWorkspaceSessionRestoreError = + ExperimentalWorkspaceSessionRestoreErrors[keyof ExperimentalWorkspaceSessionRestoreErrors] + +export type ExperimentalWorkspaceSessionRestoreResponses = { + /** + * Session replay started + */ + 200: { + total: number + } +} + +export type ExperimentalWorkspaceSessionRestoreResponse = + ExperimentalWorkspaceSessionRestoreResponses[keyof ExperimentalWorkspaceSessionRestoreResponses] + export type ProjectListData = { body?: never path?: never @@ -2883,177 +3054,6 @@ export type ToolListResponses = { export type ToolListResponse = ToolListResponses[keyof ToolListResponses] -export type ExperimentalWorkspaceAdaptorListData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/experimental/workspace/adaptor" -} - -export type ExperimentalWorkspaceAdaptorListResponses = { - /** - * Workspace adaptors - */ - 200: Array<{ - type: string - name: string - description: string - }> -} - -export type ExperimentalWorkspaceAdaptorListResponse = - ExperimentalWorkspaceAdaptorListResponses[keyof ExperimentalWorkspaceAdaptorListResponses] - -export type ExperimentalWorkspaceListData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/experimental/workspace" -} - -export type ExperimentalWorkspaceListResponses = { - /** - * Workspaces - */ - 200: Array -} - -export type ExperimentalWorkspaceListResponse = - ExperimentalWorkspaceListResponses[keyof ExperimentalWorkspaceListResponses] - -export type ExperimentalWorkspaceCreateData = { - body?: { - id?: string - type: string - branch: string | null - extra: unknown | null - } - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/experimental/workspace" -} - -export type ExperimentalWorkspaceCreateErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ExperimentalWorkspaceCreateError = - ExperimentalWorkspaceCreateErrors[keyof ExperimentalWorkspaceCreateErrors] - -export type ExperimentalWorkspaceCreateResponses = { - /** - * Workspace created - */ - 200: Workspace -} - -export type ExperimentalWorkspaceCreateResponse = - ExperimentalWorkspaceCreateResponses[keyof ExperimentalWorkspaceCreateResponses] - -export type ExperimentalWorkspaceStatusData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/experimental/workspace/status" -} - -export type ExperimentalWorkspaceStatusResponses = { - /** - * Workspace status - */ - 200: Array<{ - workspaceID: string - status: "connected" | "connecting" | "disconnected" | "error" - error?: string - }> -} - -export type ExperimentalWorkspaceStatusResponse = - ExperimentalWorkspaceStatusResponses[keyof ExperimentalWorkspaceStatusResponses] - -export type ExperimentalWorkspaceRemoveData = { - body?: never - path: { - id: string - } - query?: { - directory?: string - workspace?: string - } - url: "/experimental/workspace/{id}" -} - -export type ExperimentalWorkspaceRemoveErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ExperimentalWorkspaceRemoveError = - ExperimentalWorkspaceRemoveErrors[keyof ExperimentalWorkspaceRemoveErrors] - -export type ExperimentalWorkspaceRemoveResponses = { - /** - * Workspace removed - */ - 200: Workspace -} - -export type ExperimentalWorkspaceRemoveResponse = - ExperimentalWorkspaceRemoveResponses[keyof ExperimentalWorkspaceRemoveResponses] - -export type ExperimentalWorkspaceSessionRestoreData = { - body?: { - sessionID: string - } - path: { - id: string - } - query?: { - directory?: string - workspace?: string - } - url: "/experimental/workspace/{id}/session-restore" -} - -export type ExperimentalWorkspaceSessionRestoreErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ExperimentalWorkspaceSessionRestoreError = - ExperimentalWorkspaceSessionRestoreErrors[keyof ExperimentalWorkspaceSessionRestoreErrors] - -export type ExperimentalWorkspaceSessionRestoreResponses = { - /** - * Session replay started - */ - 200: { - total: number - } -} - -export type ExperimentalWorkspaceSessionRestoreResponse = - ExperimentalWorkspaceSessionRestoreResponses[keyof ExperimentalWorkspaceSessionRestoreResponses] - export type WorktreeRemoveData = { body?: WorktreeRemoveInput path?: never diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 9193b11ad..cf14026ea 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -415,9 +415,9 @@ ] } }, - "/project": { + "/experimental/workspace/adaptor": { "get": { - "operationId": "project.list", + "operationId": "experimental.workspace.adaptor.list", "parameters": [ { "in": "query", @@ -434,17 +434,29 @@ } } ], - "summary": "List all projects", - "description": "Get a list of projects that have been opened with OpenCode.", + "summary": "List workspace adaptors", + "description": "List all available workspace adaptors for the current project.", "responses": { "200": { - "description": "List of projects", + "description": "Workspace adaptors", "content": { "application/json": { "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/Project" + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": ["type", "name", "description"] } } } @@ -454,14 +466,14 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.list({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.adaptor.list({\n ...\n})" } ] } }, - "/project/current": { - "get": { - "operationId": "project.current", + "/experimental/workspace": { + "post": { + "operationId": "experimental.workspace.create", "parameters": [ { "in": "query", @@ -478,31 +490,76 @@ } } ], - "summary": "Get current project", - "description": "Retrieve the currently active project that OpenCode is working with.", + "summary": "Create workspace", + "description": "Create a workspace for the current project.", "responses": { "200": { - "description": "Current project information", + "description": "Workspace created", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Project" + "$ref": "#/components/schemas/Workspace" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" } } } } }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^wrk.*" + }, + "type": { + "type": "string" + }, + "branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "extra": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": ["type", "branch", "extra"] + } + } + } + }, "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.current({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.create({\n ...\n})" } ] - } - }, - "/project/git/init": { - "post": { - "operationId": "project.initGit", + }, + "get": { + "operationId": "experimental.workspace.list", "parameters": [ { "in": "query", @@ -519,15 +576,18 @@ } } ], - "summary": "Initialize git repository", - "description": "Create a git repository for the current project and return the refreshed project info.", + "summary": "List workspaces", + "description": "List all workspaces.", "responses": { "200": { - "description": "Project information after git initialization", + "description": "Workspaces", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Project" + "type": "array", + "items": { + "$ref": "#/components/schemas/Workspace" + } } } } @@ -536,14 +596,14 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.initGit({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.list({\n ...\n})" } ] } }, - "/project/{projectID}": { - "patch": { - "operationId": "project.update", + "/experimental/workspace/status": { + "get": { + "operationId": "experimental.workspace.status", "parameters": [ { "in": "query", @@ -558,81 +618,33 @@ "schema": { "type": "string" } - }, - { - "in": "path", - "name": "projectID", - "schema": { - "type": "string" - }, - "required": true } ], - "summary": "Update project", - "description": "Update project properties such as name, icon, and commands.", + "summary": "Workspace status", + "description": "Get connection status for workspaces in the current project.", "responses": { "200": { - "description": "Updated project information", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Project" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, - "404": { - "description": "Not found", + "description": "Workspace status", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "icon": { + "type": "array", + "items": { "type": "object", "properties": { - "url": { - "type": "string" + "workspaceID": { + "type": "string", + "pattern": "^wrk.*" }, - "override": { - "type": "string" + "status": { + "type": "string", + "enum": ["connected", "connecting", "disconnected", "error"] }, - "color": { - "type": "string" - } - } - }, - "commands": { - "type": "object", - "properties": { - "start": { - "description": "Startup script to run when creating a new workspace (worktree)", + "error": { "type": "string" } - } + }, + "required": ["workspaceID", "status"] } } } @@ -642,14 +654,14 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.update({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.status({\n ...\n})" } ] } }, - "/pty": { - "get": { - "operationId": "pty.list", + "/experimental/workspace/{id}": { + "delete": { + "operationId": "experimental.workspace.remove", "parameters": [ { "in": "query", @@ -664,20 +676,36 @@ "schema": { "type": "string" } + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "string", + "pattern": "^wrk.*" + }, + "required": true } ], - "summary": "List PTY sessions", - "description": "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.", + "summary": "Remove workspace", + "description": "Remove an existing workspace.", "responses": { "200": { - "description": "List of sessions", + "description": "Workspace removed", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Pty" - } + "$ref": "#/components/schemas/Workspace" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" } } } @@ -686,12 +714,14 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.list({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.remove({\n ...\n})" } ] - }, + } + }, + "/experimental/workspace/{id}/session-restore": { "post": { - "operationId": "pty.create", + "operationId": "experimental.workspace.sessionRestore", "parameters": [ { "in": "query", @@ -706,19 +736,36 @@ "schema": { "type": "string" } + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "string", + "pattern": "^wrk.*" + }, + "required": true } ], - "summary": "Create PTY session", - "description": "Create a new pseudo-terminal (PTY) session for running shell commands and processes.", + "summary": "Restore session into workspace", + "description": "Replay a session's sync events into the target workspace in batches.", "responses": { "200": { - "description": "Created session", + "description": "Session replay started", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Pty" - } - } + "type": "object", + "properties": { + "total": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["total"] + } + } } }, "400": { @@ -738,31 +785,12 @@ "schema": { "type": "object", "properties": { - "command": { - "type": "string" - }, - "args": { - "type": "array", - "items": { - "type": "string" - } - }, - "cwd": { - "type": "string" - }, - "title": { - "type": "string" - }, - "env": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - } + "sessionID": { + "type": "string", + "pattern": "^ses.*" } - } + }, + "required": ["sessionID"] } } } @@ -770,14 +798,14 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.create({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.sessionRestore({\n ...\n})" } ] } }, - "/pty/{ptyID}": { + "/project": { "get": { - "operationId": "pty.get", + "operationId": "project.list", "parameters": [ { "in": "query", @@ -792,36 +820,20 @@ "schema": { "type": "string" } - }, - { - "in": "path", - "name": "ptyID", - "schema": { - "type": "string", - "pattern": "^pty.*" - }, - "required": true } ], - "summary": "Get PTY session", - "description": "Retrieve detailed information about a specific pseudo-terminal (PTY) session.", + "summary": "List all projects", + "description": "Get a list of projects that have been opened with OpenCode.", "responses": { "200": { - "description": "Session info", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Pty" - } - } - } - }, - "404": { - "description": "Not found", + "description": "List of projects", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotFoundError" + "type": "array", + "items": { + "$ref": "#/components/schemas/Project" + } } } } @@ -830,12 +842,14 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.get({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.list({\n ...\n})" } ] - }, - "put": { - "operationId": "pty.update", + } + }, + "/project/current": { + "get": { + "operationId": "project.current", "parameters": [ { "in": "query", @@ -850,62 +864,17 @@ "schema": { "type": "string" } - }, - { - "in": "path", - "name": "ptyID", - "schema": { - "type": "string", - "pattern": "^pty.*" - }, - "required": true } ], - "summary": "Update PTY session", - "description": "Update properties of an existing pseudo-terminal (PTY) session.", + "summary": "Get current project", + "description": "Retrieve the currently active project that OpenCode is working with.", "responses": { "200": { - "description": "Updated session", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Pty" - } - } - } - }, - "400": { - "description": "Bad request", + "description": "Current project information", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "size": { - "type": "object", - "properties": { - "rows": { - "type": "number" - }, - "cols": { - "type": "number" - } - }, - "required": ["rows", "cols"] - } + "$ref": "#/components/schemas/Project" } } } @@ -914,12 +883,14 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.update({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.current({\n ...\n})" } ] - }, - "delete": { - "operationId": "pty.remove", + } + }, + "/project/git/init": { + "post": { + "operationId": "project.initGit", "parameters": [ { "in": "query", @@ -934,36 +905,17 @@ "schema": { "type": "string" } - }, - { - "in": "path", - "name": "ptyID", - "schema": { - "type": "string", - "pattern": "^pty.*" - }, - "required": true } ], - "summary": "Remove PTY session", - "description": "Remove and terminate a specific pseudo-terminal (PTY) session.", + "summary": "Initialize git repository", + "description": "Create a git repository for the current project and return the refreshed project info.", "responses": { "200": { - "description": "Session removed", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - }, - "404": { - "description": "Not found", + "description": "Project information after git initialization", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotFoundError" + "$ref": "#/components/schemas/Project" } } } @@ -972,14 +924,14 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.remove({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.initGit({\n ...\n})" } ] } }, - "/pty/{ptyID}/connect": { - "get": { - "operationId": "pty.connect", + "/project/{projectID}": { + "patch": { + "operationId": "project.update", "parameters": [ { "in": "query", @@ -997,23 +949,32 @@ }, { "in": "path", - "name": "ptyID", + "name": "projectID", "schema": { - "type": "string", - "pattern": "^pty.*" + "type": "string" }, "required": true } ], - "summary": "Connect to PTY session", - "description": "Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.", + "summary": "Update project", + "description": "Update project properties such as name, icon, and commands.", "responses": { "200": { - "description": "Connected session", + "description": "Updated project information", "content": { "application/json": { "schema": { - "type": "boolean" + "$ref": "#/components/schemas/Project" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" } } } @@ -1029,17 +990,54 @@ } } }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "icon": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "override": { + "type": "string" + }, + "color": { + "type": "string" + } + } + }, + "commands": { + "type": "object", + "properties": { + "start": { + "description": "Startup script to run when creating a new workspace (worktree)", + "type": "string" + } + } + } + } + } + } + } + }, "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.connect({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.update({\n ...\n})" } ] } }, - "/config": { + "/pty": { "get": { - "operationId": "config.get", + "operationId": "pty.list", "parameters": [ { "in": "query", @@ -1056,15 +1054,18 @@ } } ], - "summary": "Get configuration", - "description": "Retrieve the current OpenCode configuration settings and preferences.", + "summary": "List PTY sessions", + "description": "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.", "responses": { "200": { - "description": "Get config info", + "description": "List of sessions", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Config" + "type": "array", + "items": { + "$ref": "#/components/schemas/Pty" + } } } } @@ -1073,12 +1074,12 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.config.get({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.list({\n ...\n})" } ] }, - "patch": { - "operationId": "config.update", + "post": { + "operationId": "pty.create", "parameters": [ { "in": "query", @@ -1095,15 +1096,15 @@ } } ], - "summary": "Update configuration", - "description": "Update OpenCode configuration settings and preferences.", + "summary": "Create PTY session", + "description": "Create a new pseudo-terminal (PTY) session for running shell commands and processes.", "responses": { "200": { - "description": "Successfully updated config", + "description": "Created session", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Config" + "$ref": "#/components/schemas/Pty" } } } @@ -1123,7 +1124,33 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Config" + "type": "object", + "properties": { + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "cwd": { + "type": "string" + }, + "title": { + "type": "string" + }, + "env": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + } } } } @@ -1131,14 +1158,14 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.config.update({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.create({\n ...\n})" } ] } }, - "/config/providers": { + "/pty/{ptyID}": { "get": { - "operationId": "config.providers", + "operationId": "pty.get", "parameters": [ { "in": "query", @@ -1153,35 +1180,36 @@ "schema": { "type": "string" } + }, + { + "in": "path", + "name": "ptyID", + "schema": { + "type": "string", + "pattern": "^pty.*" + }, + "required": true } ], - "summary": "List config providers", - "description": "Get a list of all configured AI providers and their default models.", + "summary": "Get PTY session", + "description": "Retrieve detailed information about a specific pseudo-terminal (PTY) session.", "responses": { "200": { - "description": "List of providers", + "description": "Session info", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "providers": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Provider" - } - }, - "default": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - } - } - }, - "required": ["providers", "default"] + "$ref": "#/components/schemas/Pty" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" } } } @@ -1190,14 +1218,12 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.config.providers({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.get({\n ...\n})" } ] - } - }, - "/experimental/console": { - "get": { - "operationId": "experimental.console.get", + }, + "put": { + "operationId": "pty.update", "parameters": [ { "in": "query", @@ -1212,34 +1238,62 @@ "schema": { "type": "string" } + }, + { + "in": "path", + "name": "ptyID", + "schema": { + "type": "string", + "pattern": "^pty.*" + }, + "required": true } ], - "summary": "Get active Console provider metadata", - "description": "Get the active Console org name and the set of provider IDs managed by that Console org.", + "summary": "Update PTY session", + "description": "Update properties of an existing pseudo-terminal (PTY) session.", "responses": { "200": { - "description": "Active Console provider metadata", + "description": "Updated session", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "consoleManagedProviders": { - "type": "array", - "items": { - "type": "string" + "$ref": "#/components/schemas/Pty" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "size": { + "type": "object", + "properties": { + "rows": { + "type": "number" + }, + "cols": { + "type": "number" } }, - "activeOrgName": { - "type": "string" - }, - "switchableOrgCount": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["consoleManagedProviders", "switchableOrgCount"] + "required": ["rows", "cols"] + } } } } @@ -1248,14 +1302,12 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.console.get({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.update({\n ...\n})" } ] - } - }, - "/experimental/console/orgs": { - "get": { - "operationId": "experimental.console.listOrgs", + }, + "delete": { + "operationId": "pty.remove", "parameters": [ { "in": "query", @@ -1270,47 +1322,36 @@ "schema": { "type": "string" } + }, + { + "in": "path", + "name": "ptyID", + "schema": { + "type": "string", + "pattern": "^pty.*" + }, + "required": true } ], - "summary": "List switchable Console orgs", - "description": "Get the available Console orgs across logged-in accounts, including the current active org.", + "summary": "Remove PTY session", + "description": "Remove and terminate a specific pseudo-terminal (PTY) session.", "responses": { "200": { - "description": "Switchable Console orgs", + "description": "Session removed", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "orgs": { - "type": "array", - "items": { - "type": "object", - "properties": { - "accountID": { - "type": "string" - }, - "accountEmail": { - "type": "string" - }, - "accountUrl": { - "type": "string" - }, - "orgID": { - "type": "string" - }, - "orgName": { - "type": "string" - }, - "active": { - "type": "boolean" - } - }, - "required": ["accountID", "accountEmail", "accountUrl", "orgID", "orgName", "active"] - } - } - }, - "required": ["orgs"] + "type": "boolean" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" } } } @@ -1319,14 +1360,14 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.console.listOrgs({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.remove({\n ...\n})" } ] } }, - "/experimental/console/switch": { - "post": { - "operationId": "experimental.console.switchOrg", + "/pty/{ptyID}/connect": { + "get": { + "operationId": "pty.connect", "parameters": [ { "in": "query", @@ -1341,13 +1382,22 @@ "schema": { "type": "string" } + }, + { + "in": "path", + "name": "ptyID", + "schema": { + "type": "string", + "pattern": "^pty.*" + }, + "required": true } ], - "summary": "Switch active Console org", - "description": "Persist a new active Console account/org selection for the current local OpenCode state.", + "summary": "Connect to PTY session", + "description": "Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.", "responses": { "200": { - "description": "Switch success", + "description": "Connected session", "content": { "application/json": { "schema": { @@ -1355,22 +1405,14 @@ } } } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "accountID": { - "type": "string" - }, - "orgID": { - "type": "string" - } - }, - "required": ["accountID", "orgID"] + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } } } } @@ -1378,14 +1420,14 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.console.switchOrg({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.connect({\n ...\n})" } ] } }, - "/experimental/tool/ids": { + "/config": { "get": { - "operationId": "tool.ids", + "operationId": "config.get", "parameters": [ { "in": "query", @@ -1402,25 +1444,15 @@ } } ], - "summary": "List tool IDs", - "description": "Get a list of all available tool IDs, including both built-in tools and dynamically registered tools.", + "summary": "Get configuration", + "description": "Retrieve the current OpenCode configuration settings and preferences.", "responses": { "200": { - "description": "Tool IDs", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ToolIDs" - } - } - } - }, - "400": { - "description": "Bad request", + "description": "Get config info", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BadRequestError" + "$ref": "#/components/schemas/Config" } } } @@ -1429,14 +1461,12 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tool.ids({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.config.get({\n ...\n})" } ] - } - }, - "/experimental/tool": { - "get": { - "operationId": "tool.list", + }, + "patch": { + "operationId": "config.update", "parameters": [ { "in": "query", @@ -1451,33 +1481,17 @@ "schema": { "type": "string" } - }, - { - "in": "query", - "name": "provider", - "schema": { - "type": "string" - }, - "required": true - }, - { - "in": "query", - "name": "model", - "schema": { - "type": "string" - }, - "required": true } ], - "summary": "List tools", - "description": "Get a list of available tools with their JSON schema parameters for a specific provider and model combination.", + "summary": "Update configuration", + "description": "Update OpenCode configuration settings and preferences.", "responses": { "200": { - "description": "Tools", + "description": "Successfully updated config", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ToolList" + "$ref": "#/components/schemas/Config" } } } @@ -1493,17 +1507,26 @@ } } }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Config" + } + } + } + }, "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tool.list({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.config.update({\n ...\n})" } ] } }, - "/experimental/workspace/adaptor": { + "/config/providers": { "get": { - "operationId": "experimental.workspace.adaptor.list", + "operationId": "config.providers", "parameters": [ { "in": "query", @@ -1520,30 +1543,33 @@ } } ], - "summary": "List workspace adaptors", - "description": "List all available workspace adaptors for the current project.", + "summary": "List config providers", + "description": "Get a list of all configured AI providers and their default models.", "responses": { "200": { - "description": "Workspace adaptors", + "description": "List of providers", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "name": { + "type": "object", + "properties": { + "providers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Provider" + } + }, + "default": { + "type": "object", + "propertyNames": { "type": "string" }, - "description": { + "additionalProperties": { "type": "string" } - }, - "required": ["type", "name", "description"] - } + } + }, + "required": ["providers", "default"] } } } @@ -1552,14 +1578,14 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.adaptor.list({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.config.providers({\n ...\n})" } ] } }, - "/experimental/workspace": { - "post": { - "operationId": "experimental.workspace.create", + "/experimental/console": { + "get": { + "operationId": "experimental.console.get", "parameters": [ { "in": "query", @@ -1576,63 +1602,33 @@ } } ], - "summary": "Create workspace", - "description": "Create a workspace for the current project.", + "summary": "Get active Console provider metadata", + "description": "Get the active Console org name and the set of provider IDs managed by that Console org.", "responses": { "200": { - "description": "Workspace created", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Workspace" - } - } - } - }, - "400": { - "description": "Bad request", + "description": "Active Console provider metadata", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^wrk.*" - }, - "type": { - "type": "string" - }, - "branch": { - "anyOf": [ - { + "type": "object", + "properties": { + "consoleManagedProviders": { + "type": "array", + "items": { "type": "string" - }, - { - "type": "null" } - ] + }, + "activeOrgName": { + "type": "string" + }, + "switchableOrgCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } }, - "extra": { - "anyOf": [ - {}, - { - "type": "null" - } - ] - } - }, - "required": ["type", "branch", "extra"] + "required": ["consoleManagedProviders", "switchableOrgCount"] + } } } } @@ -1640,12 +1636,14 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.create({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.console.get({\n ...\n})" } ] - }, + } + }, + "/experimental/console/orgs": { "get": { - "operationId": "experimental.workspace.list", + "operationId": "experimental.console.listOrgs", "parameters": [ { "in": "query", @@ -1662,18 +1660,45 @@ } } ], - "summary": "List workspaces", - "description": "List all workspaces.", + "summary": "List switchable Console orgs", + "description": "Get the available Console orgs across logged-in accounts, including the current active org.", "responses": { "200": { - "description": "Workspaces", + "description": "Switchable Console orgs", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Workspace" - } + "type": "object", + "properties": { + "orgs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "accountID": { + "type": "string" + }, + "accountEmail": { + "type": "string" + }, + "accountUrl": { + "type": "string" + }, + "orgID": { + "type": "string" + }, + "orgName": { + "type": "string" + }, + "active": { + "type": "boolean" + } + }, + "required": ["accountID", "accountEmail", "accountUrl", "orgID", "orgName", "active"] + } + } + }, + "required": ["orgs"] } } } @@ -1682,14 +1707,14 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.list({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.console.listOrgs({\n ...\n})" } ] } }, - "/experimental/workspace/status": { - "get": { - "operationId": "experimental.workspace.status", + "/experimental/console/switch": { + "post": { + "operationId": "experimental.console.switchOrg", "parameters": [ { "in": "query", @@ -1706,48 +1731,49 @@ } } ], - "summary": "Workspace status", - "description": "Get connection status for workspaces in the current project.", + "summary": "Switch active Console org", + "description": "Persist a new active Console account/org selection for the current local OpenCode state.", "responses": { "200": { - "description": "Workspace status", + "description": "Switch success", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string", - "pattern": "^wrk.*" - }, - "status": { - "type": "string", - "enum": ["connected", "connecting", "disconnected", "error"] - }, - "error": { - "type": "string" - } - }, - "required": ["workspaceID", "status"] - } + "type": "boolean" } } } } }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "accountID": { + "type": "string" + }, + "orgID": { + "type": "string" + } + }, + "required": ["accountID", "orgID"] + } + } + } + }, "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.status({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.console.switchOrg({\n ...\n})" } ] } }, - "/experimental/workspace/{id}": { - "delete": { - "operationId": "experimental.workspace.remove", + "/experimental/tool/ids": { + "get": { + "operationId": "tool.ids", "parameters": [ { "in": "query", @@ -1762,26 +1788,17 @@ "schema": { "type": "string" } - }, - { - "in": "path", - "name": "id", - "schema": { - "type": "string", - "pattern": "^wrk.*" - }, - "required": true } ], - "summary": "Remove workspace", - "description": "Remove an existing workspace.", + "summary": "List tool IDs", + "description": "Get a list of all available tool IDs, including both built-in tools and dynamically registered tools.", "responses": { "200": { - "description": "Workspace removed", + "description": "Tool IDs", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Workspace" + "$ref": "#/components/schemas/ToolIDs" } } } @@ -1800,14 +1817,14 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.remove({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tool.ids({\n ...\n})" } ] } }, - "/experimental/workspace/{id}/session-restore": { - "post": { - "operationId": "experimental.workspace.sessionRestore", + "/experimental/tool": { + "get": { + "operationId": "tool.list", "parameters": [ { "in": "query", @@ -1824,32 +1841,31 @@ } }, { - "in": "path", - "name": "id", + "in": "query", + "name": "provider", "schema": { - "type": "string", - "pattern": "^wrk.*" + "type": "string" + }, + "required": true + }, + { + "in": "query", + "name": "model", + "schema": { + "type": "string" }, "required": true } ], - "summary": "Restore session into workspace", - "description": "Replay a session's sync events into the target workspace in batches.", + "summary": "List tools", + "description": "Get a list of available tools with their JSON schema parameters for a specific provider and model combination.", "responses": { "200": { - "description": "Session replay started", + "description": "Tools", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "total": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["total"] + "$ref": "#/components/schemas/ToolList" } } } @@ -1865,26 +1881,10 @@ } } }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - } - }, - "required": ["sessionID"] - } - } - } - }, "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.sessionRestore({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tool.list({\n ...\n})" } ] } @@ -12003,6 +12003,53 @@ } ] }, + "Workspace": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^wrk.*" + }, + "type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "extra": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + }, + "projectID": { + "type": "string" + } + }, + "required": ["id", "type", "name", "branch", "directory", "extra", "projectID"] + }, "NotFoundError": { "type": "object", "properties": { @@ -12309,53 +12356,6 @@ "$ref": "#/components/schemas/ToolListItem" } }, - "Workspace": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^wrk.*" - }, - "type": { - "type": "string" - }, - "name": { - "type": "string" - }, - "branch": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "directory": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "extra": { - "anyOf": [ - {}, - { - "type": "null" - } - ] - }, - "projectID": { - "type": "string" - } - }, - "required": ["id", "type", "name", "branch", "directory", "extra", "projectID"] - }, "Worktree": { "type": "object", "properties": { -- cgit v1.2.3