diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index f6c6b688a3..e6203d6657 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -515,7 +515,15 @@ export const GithubRunCommand = cmd({ // Setup opencode session const repoData = await fetchRepo() - session = await Session.create({}) + session = await Session.create({ + permission: [ + { + permission: "question", + action: "deny", + pattern: "*", + }, + ], + }) subscribeSessionEvents() shareId = await (async () => { if (share === false) return diff --git a/packages/opencode/src/server/question.ts b/packages/opencode/src/server/question.ts new file mode 100644 index 0000000000..e4f9e443a0 --- /dev/null +++ b/packages/opencode/src/server/question.ts @@ -0,0 +1,95 @@ +import { Hono } from "hono" +import { describeRoute, validator } from "hono-openapi" +import { resolver } from "hono-openapi" +import { Question } from "../question" +import z from "zod" +import { errors } from "./error" + +export const QuestionRoute = new Hono() + .get( + "/", + describeRoute({ + summary: "List pending questions", + description: "Get all pending question requests across all sessions.", + operationId: "question.list", + responses: { + 200: { + description: "List of pending questions", + content: { + "application/json": { + schema: resolver(Question.Request.array()), + }, + }, + }, + }, + }), + async (c) => { + const questions = await Question.list() + return c.json(questions) + }, + ) + .post( + "/:requestID/reply", + describeRoute({ + summary: "Reply to question request", + description: "Provide answers to a question request from the AI assistant.", + operationId: "question.reply", + responses: { + 200: { + description: "Question answered successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + requestID: z.string(), + }), + ), + validator("json", z.object({ answers: z.array(z.string()) })), + async (c) => { + const params = c.req.valid("param") + const json = c.req.valid("json") + await Question.reply({ + requestID: params.requestID, + answers: json.answers, + }) + return c.json(true) + }, + ) + .post( + "/:requestID/reject", + describeRoute({ + summary: "Reject question request", + description: "Reject a question request from the AI assistant.", + operationId: "question.reject", + responses: { + 200: { + description: "Question rejected successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + requestID: z.string(), + }), + ), + async (c) => { + const params = c.req.valid("param") + await Question.reject(params.requestID) + return c.json(true) + }, + ) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 8422d7d4dd..c7baec778c 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -48,7 +48,7 @@ import { upgradeWebSocket, websocket } from "hono/bun" import { errors } from "./error" import { Pty } from "@/pty" import { PermissionNext } from "@/permission/next" -import { Question } from "@/question" +import { QuestionRoute } from "./question" import { Installation } from "@/installation" import { MDNS } from "./mdns" import { Worktree } from "../worktree" @@ -72,2844 +72,2767 @@ export namespace Server { } const app = new Hono() - export const App = lazy(() => - app - .onError((err, c) => { - log.error("failed", { - error: err, + export const App: () => Hono = lazy( + () => + app + .onError((err, c) => { + log.error("failed", { + error: err, + }) + if (err instanceof NamedError) { + let status: ContentfulStatusCode + if (err instanceof Storage.NotFoundError) status = 404 + else if (err instanceof Provider.ModelNotFoundError) status = 400 + else if (err.name.startsWith("Worktree")) status = 400 + else status = 500 + return c.json(err.toObject(), { status }) + } + const message = err instanceof Error && err.stack ? err.stack : err.toString() + return c.json(new NamedError.Unknown({ message }).toObject(), { + status: 500, + }) }) - if (err instanceof NamedError) { - let status: ContentfulStatusCode - if (err instanceof Storage.NotFoundError) status = 404 - else if (err instanceof Provider.ModelNotFoundError) status = 400 - else if (err.name.startsWith("Worktree")) status = 400 - else status = 500 - return c.json(err.toObject(), { status }) - } - const message = err instanceof Error && err.stack ? err.stack : err.toString() - return c.json(new NamedError.Unknown({ message }).toObject(), { - status: 500, - }) - }) - .use(async (c, next) => { - const skipLogging = c.req.path === "/log" - if (!skipLogging) { - log.info("request", { + .use(async (c, next) => { + const skipLogging = c.req.path === "/log" + if (!skipLogging) { + log.info("request", { + method: c.req.method, + path: c.req.path, + }) + } + const timer = log.time("request", { method: c.req.method, path: c.req.path, }) - } - const timer = log.time("request", { - method: c.req.method, - path: c.req.path, + await next() + if (!skipLogging) { + timer.stop() + } }) - await next() - if (!skipLogging) { - timer.stop() - } - }) - .use( - cors({ - origin(input) { - if (!input) return + .use( + cors({ + origin(input) { + if (!input) return - if (input.startsWith("http://localhost:")) return input - if (input.startsWith("http://127.0.0.1:")) return input - if (input === "tauri://localhost" || input === "http://tauri.localhost") return input + if (input.startsWith("http://localhost:")) return input + if (input.startsWith("http://127.0.0.1:")) return input + if (input === "tauri://localhost" || input === "http://tauri.localhost") return input - // *.opencode.ai (https only, adjust if needed) - if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) { - return input - } - if (_corsWhitelist.includes(input)) { - return input - } + // *.opencode.ai (https only, adjust if needed) + if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) { + return input + } + if (_corsWhitelist.includes(input)) { + return input + } - return - }, - }), - ) - .get( - "/global/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() })), + return + }, + }), + ) + .get( + "/global/health", + describeRoute({ + summary: "Get health", + description: "Get health information about the OpenCode server.", + operationId: "global.health", + responses: { + 200: { + description: "Health information", + content: { + "application/json": { + schema: resolver(z.object({ healthy: z.literal(true), version: z.string() })), + }, }, }, }, + }), + async (c) => { + return c.json({ healthy: true, version: Installation.VERSION }) }, - }), - async (c) => { - return c.json({ healthy: true, version: Installation.VERSION }) - }, - ) - .get( - "/global/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(), - payload: BusEvent.payloads(), - }) - .meta({ - ref: "GlobalEvent", - }), - ), + ) + .get( + "/global/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(), + payload: BusEvent.payloads(), + }) + .meta({ + ref: "GlobalEvent", + }), + ), + }, }, }, }, - }, - }), - async (c) => { - log.info("global event connected") - return streamSSE(c, async (stream) => { - stream.writeSSE({ - data: JSON.stringify({ - payload: { - type: "server.connected", - properties: {}, - }, - }), - }) - async function handler(event: any) { - await stream.writeSSE({ - data: JSON.stringify(event), - }) - } - GlobalBus.on("event", handler) - - // Send heartbeat every 30s to prevent WKWebView timeout (60s default) - const heartbeat = setInterval(() => { + }), + async (c) => { + log.info("global event connected") + return streamSSE(c, async (stream) => { stream.writeSSE({ data: JSON.stringify({ payload: { - type: "server.heartbeat", + type: "server.connected", properties: {}, }, }), }) - }, 30000) + async function handler(event: any) { + await stream.writeSSE({ + data: JSON.stringify(event), + }) + } + GlobalBus.on("event", handler) - await new Promise((resolve) => { - stream.onAbort(() => { - clearInterval(heartbeat) - GlobalBus.off("event", handler) - resolve() - log.info("global event disconnected") + // Send heartbeat every 30s to prevent WKWebView timeout (60s default) + const heartbeat = setInterval(() => { + stream.writeSSE({ + data: JSON.stringify({ + payload: { + type: "server.heartbeat", + properties: {}, + }, + }), + }) + }, 30000) + + await new Promise((resolve) => { + stream.onAbort(() => { + clearInterval(heartbeat) + GlobalBus.off("event", handler) + resolve() + log.info("global event disconnected") + }) }) }) - }) - }, - ) - .post( - "/global/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()), + }, + ) + .post( + "/global/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: Event.Disposed.type, + properties: {}, + }, + }) + return c.json(true) }, - }), - async (c) => { - await Instance.disposeAll() - GlobalBus.emit("event", { - directory: "global", - payload: { - type: Event.Disposed.type, - properties: {}, + ) + .use(async (c, next) => { + let directory = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() + try { + directory = decodeURIComponent(directory) + } catch { + // fallback to original value + } + return Instance.provide({ + directory, + init: InstanceBootstrap, + async fn() { + return next() }, }) - return c.json(true) - }, - ) - .use(async (c, next) => { - let directory = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() - try { - directory = decodeURIComponent(directory) - } catch { - // fallback to original value - } - return Instance.provide({ - directory, - init: InstanceBootstrap, - async fn() { - return next() - }, }) - }) - .get( - "/doc", - openAPIRouteHandler(app, { - documentation: { - info: { - title: "opencode", - version: "0.0.3", - description: "opencode api", + .get( + "/doc", + openAPIRouteHandler(app, { + documentation: { + info: { + title: "opencode", + version: "0.0.3", + description: "opencode api", + }, + openapi: "3.1.1", }, - openapi: "3.1.1", - }, - }), - ) - .use(validator("query", z.object({ directory: z.string().optional() }))) + }), + ) + .use(validator("query", z.object({ directory: z.string().optional() }))) - .route("/project", ProjectRoute) + .route("/project", ProjectRoute) - .get( - "/pty", - 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()), + .get( + "/pty", + 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(Pty.list()) - }, - ) - .post( - "/pty", - describeRoute({ - summary: "Create PTY session", - description: "Create a new pseudo-terminal (PTY) session for running shell commands and processes.", - operationId: "pty.create", - responses: { - 200: { - description: "Created session", - content: { - "application/json": { - schema: resolver(Pty.Info), - }, - }, - }, - ...errors(400), - }, - }), - validator("json", Pty.CreateInput), - async (c) => { - const info = await Pty.create(c.req.valid("json")) - return c.json(info) - }, - ) - .get( - "/pty/: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: z.string() })), - async (c) => { - const info = Pty.get(c.req.valid("param").ptyID) - if (!info) { - throw new Storage.NotFoundError({ message: "Session not found" }) - } - return c.json(info) - }, - ) - .put( - "/pty/: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: z.string() })), - validator("json", Pty.UpdateInput), - async (c) => { - const info = await Pty.update(c.req.valid("param").ptyID, c.req.valid("json")) - return c.json(info) - }, - ) - .delete( - "/pty/: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: z.string() })), - async (c) => { - await Pty.remove(c.req.valid("param").ptyID) - return c.json(true) - }, - ) - .get( - "/pty/: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: z.string() })), - upgradeWebSocket((c) => { - const id = c.req.param("ptyID") - let handler: ReturnType - if (!Pty.get(id)) throw new Error("Session not found") - return { - onOpen(_event, ws) { - handler = Pty.connect(id, ws) - }, - onMessage(event) { - handler?.onMessage(String(event.data)) - }, - onClose() { - handler?.onClose() - }, - } - }), - ) - - .get( - "/config", - describeRoute({ - summary: "Get configuration", - description: "Retrieve the current OpenCode configuration settings and preferences.", - operationId: "config.get", - responses: { - 200: { - description: "Get config info", - content: { - "application/json": { - schema: resolver(Config.Info), - }, - }, - }, - }, - }), - async (c) => { - return c.json(await Config.get()) - }, - ) - - .patch( - "/config", - describeRoute({ - summary: "Update configuration", - description: "Update OpenCode configuration settings and preferences.", - operationId: "config.update", - responses: { - 200: { - description: "Successfully updated config", - content: { - "application/json": { - schema: resolver(Config.Info), - }, - }, - }, - ...errors(400), - }, - }), - validator("json", Config.Info), - async (c) => { - const config = c.req.valid("json") - await Config.update(config) - return c.json(config) - }, - ) - .get( - "/experimental/tool/ids", - describeRoute({ - summary: "List tool IDs", - description: - "Get a list of all available tool IDs, including both built-in tools and dynamically registered tools.", - operationId: "tool.ids", - responses: { - 200: { - description: "Tool IDs", - content: { - "application/json": { - schema: resolver(z.array(z.string()).meta({ ref: "ToolIDs" })), - }, - }, - }, - ...errors(400), - }, - }), - async (c) => { - return c.json(await ToolRegistry.ids()) - }, - ) - .get( - "/experimental/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 } = c.req.valid("query") - const tools = await ToolRegistry.tools(provider) - return c.json( - tools.map((t) => ({ - id: t.id, - description: t.description, - // Handle both Zod schemas and plain JSON schemas - parameters: (t.parameters as any)?._def ? zodToJsonSchema(t.parameters as any) : t.parameters, - })), - ) - }, - ) - .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) => { + return c.json(Pty.list()) + }, + ) + .post( + "/pty", + 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), }, - }, - }), - 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, - }) - }, - ) - .post( - "/experimental/worktree", - describeRoute({ - summary: "Create worktree", - description: "Create a new git worktree for the current project.", - operationId: "worktree.create", - responses: { - 200: { - description: "Worktree created", - content: { - "application/json": { - schema: resolver(Worktree.Info), - }, - }, - }, - ...errors(400), - }, - }), - validator("json", Worktree.create.schema), - async (c) => { - const body = c.req.valid("json") - const worktree = await Worktree.create(body) - return c.json(worktree) - }, - ) - .get( - "/experimental/worktree", - describeRoute({ - summary: "List worktrees", - description: "List all sandbox worktrees for the current project.", - operationId: "worktree.list", - responses: { - 200: { - description: "List of worktree directories", - content: { - "application/json": { - schema: resolver(z.array(z.string())), - }, - }, - }, - }, - }), - async (c) => { - const sandboxes = await Project.sandboxes(Instance.project.id) - return c.json(sandboxes) - }, - ) - .get( - "/vcs", - describeRoute({ - summary: "Get VCS info", - description: "Retrieve version control system (VCS) information for the current project, such as git branch.", - operationId: "vcs.get", - responses: { - 200: { - description: "VCS info", - content: { - "application/json": { - schema: resolver(Vcs.Info), - }, - }, - }, - }, - }), - async (c) => { - const branch = await Vcs.branch() - return c.json({ - branch, - }) - }, - ) - .get( - "/session", - 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({ - 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 term = query.search?.toLowerCase() - const sessions: Session.Info[] = [] - for await (const session of Session.list()) { - if (query.start !== undefined && session.time.updated < query.start) continue - if (term !== undefined && !session.title.toLowerCase().includes(term)) continue - sessions.push(session) - if (query.limit !== undefined && sessions.length >= query.limit) break - } - return c.json(sessions) - }, - ) - .get( - "/session/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)), + validator("json", Pty.CreateInput), + async (c) => { + const info = await Pty.create(c.req.valid("json")) + return c.json(info) + }, + ) + .get( + "/pty/: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), }, - ...errors(400), - }, - }), - async (c) => { - const result = SessionStatus.list() - return c.json(result) - }, - ) - .get( - "/session/:sessionID", - describeRoute({ - summary: "Get session", - description: "Retrieve detailed information about a specific OpenCode session.", - tags: ["Session"], - operationId: "session.get", - responses: { - 200: { - description: "Get session", - content: { - "application/json": { - schema: resolver(Session.Info), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: Session.get.schema, }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - log.info("SEARCH", { url: c.req.url }) - const session = await Session.get(sessionID) - return c.json(session) - }, - ) - .get( - "/session/:sessionID/children", - describeRoute({ - summary: "Get session children", - tags: ["Session"], - description: "Retrieve all child sessions that were forked from the specified parent session.", - operationId: "session.children", - responses: { - 200: { - description: "List of children", - content: { - "application/json": { - schema: resolver(Session.Info.array()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: Session.children.schema, - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const session = await Session.children(sessionID) - return c.json(session) - }, - ) - .get( - "/session/: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: z.string().meta({ description: "Session ID" }), - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const todos = await Todo.get(sessionID) - return c.json(todos) - }, - ) - .post( - "/session", - describeRoute({ - summary: "Create session", - description: "Create a new OpenCode session for interacting with AI assistants and managing conversations.", - operationId: "session.create", - responses: { - ...errors(400), - 200: { - description: "Successfully created session", - content: { - "application/json": { - schema: resolver(Session.Info), - }, - }, - }, - }, - }), - validator("json", Session.create.schema.optional()), - async (c) => { - const body = c.req.valid("json") ?? {} - const session = await Session.create(body) - return c.json(session) - }, - ) - .delete( - "/session/:sessionID", - describeRoute({ - summary: "Delete session", - description: "Delete a session and permanently remove all associated data, including messages and history.", - operationId: "session.delete", - responses: { - 200: { - description: "Successfully deleted session", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: Session.remove.schema, - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - await Session.remove(sessionID) - return c.json(true) - }, - ) - .patch( - "/session/: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: z.string(), - }), - ), - validator( - "json", - z.object({ - title: z.string().optional(), - time: z - .object({ - archived: z.number().optional(), - }) - .optional(), - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const updates = c.req.valid("json") - - const updatedSession = await Session.update(sessionID, (session) => { - if (updates.title !== undefined) { - session.title = updates.title + validator("param", z.object({ ptyID: z.string() })), + async (c) => { + const info = Pty.get(c.req.valid("param").ptyID) + if (!info) { + throw new Storage.NotFoundError({ message: "Session not found" }) } - if (updates.time?.archived !== undefined) session.time.archived = updates.time.archived - }) - - return c.json(updatedSession) - }, - ) - .post( - "/session/: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()), + return c.json(info) + }, + ) + .put( + "/pty/: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), }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: z.string().meta({ description: "Session ID" }), }), - ), - validator("json", Session.initialize.schema.omit({ sessionID: true })), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - await Session.initialize({ ...body, sessionID }) - return c.json(true) - }, - ) - .post( - "/session/: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({ ptyID: z.string() })), + validator("json", Pty.UpdateInput), + async (c) => { + const info = await Pty.update(c.req.valid("param").ptyID, c.req.valid("json")) + return c.json(info) + }, + ) + .delete( + "/pty/: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({ - sessionID: Session.fork.schema.shape.sessionID, }), - ), - validator("json", Session.fork.schema.omit({ sessionID: true })), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - const result = await Session.fork({ ...body, sessionID }) - return c.json(result) - }, - ) - .post( - "/session/: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()), + validator("param", z.object({ ptyID: z.string() })), + async (c) => { + await Pty.remove(c.req.valid("param").ptyID) + return c.json(true) + }, + ) + .get( + "/pty/: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), }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: z.string(), }), - ), - async (c) => { - SessionPrompt.cancel(c.req.valid("param").sessionID) - return c.json(true) - }, - ) - - .post( - "/session/: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), - }, + validator("param", z.object({ ptyID: z.string() })), + upgradeWebSocket((c) => { + const id = c.req.param("ptyID") + let handler: ReturnType + if (!Pty.get(id)) throw new Error("Session not found") + return { + onOpen(_event, ws) { + handler = Pty.connect(id, ws) }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: z.string(), - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - await Session.share(sessionID) - const session = await Session.get(sessionID) - return c.json(session) - }, - ) - .get( - "/session/: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()), - }, + onMessage(event) { + handler?.onMessage(String(event.data)) }, - }, - }, - }), - validator( - "param", - z.object({ - sessionID: SessionSummary.diff.schema.shape.sessionID, - }), - ), - validator( - "query", - z.object({ - messageID: SessionSummary.diff.schema.shape.messageID, - }), - ), - async (c) => { - const query = c.req.valid("query") - const params = c.req.valid("param") - const result = await SessionSummary.diff({ - sessionID: params.sessionID, - messageID: query.messageID, - }) - return c.json(result) - }, - ) - .delete( - "/session/: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), - }, + onClose() { + handler?.onClose() }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: Session.unshare.schema, - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - await Session.unshare(sessionID) - const session = await Session.get(sessionID) - return c.json(session) - }, - ) - .post( - "/session/: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: z.string().meta({ description: "Session ID" }), - }), - ), - validator( - "json", - z.object({ - providerID: z.string(), - modelID: z.string(), - auto: z.boolean().optional().default(false), - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - const session = await Session.get(sessionID) - await SessionRevert.cleanup(session) - const msgs = await Session.messages({ sessionID }) - let currentAgent = await Agent.defaultAgent() - for (let i = msgs.length - 1; i >= 0; i--) { - const info = msgs[i].info - if (info.role === "user") { - currentAgent = info.agent || (await Agent.defaultAgent()) - break } - } - await SessionCompaction.create({ - sessionID, - agent: currentAgent, - model: { - providerID: body.providerID, - modelID: body.modelID, - }, - auto: body.auto, - }) - await SessionPrompt.loop(sessionID) - return c.json(true) - }, - ) - .get( - "/session/: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()), + }), + ) + + .get( + "/config", + 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), + }, }, }, }, - ...errors(400, 404), + }), + async (c) => { + return c.json(await Config.get()) }, - }), - validator( - "param", - z.object({ - sessionID: z.string().meta({ description: "Session ID" }), - }), - ), - validator( - "query", - z.object({ - limit: z.coerce.number().optional(), - }), - ), - async (c) => { - const query = c.req.valid("query") - const messages = await Session.messages({ - sessionID: c.req.valid("param").sessionID, - limit: query.limit, - }) - return c.json(messages) - }, - ) - .get( - "/session/:sessionID/diff", - describeRoute({ - summary: "Get session diff", - description: "Get all file changes (diffs) made during this session.", - operationId: "session.diff", - responses: { - 200: { - description: "List of diffs", - content: { - "application/json": { - schema: resolver(Snapshot.FileDiff.array()), + ) + + .patch( + "/config", + 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), }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: z.string().meta({ description: "Session ID" }), }), - ), - async (c) => { - const diff = await Session.diff(c.req.valid("param").sessionID) - return c.json(diff) - }, - ) - .get( - "/session/: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(), - }), - ), + validator("json", Config.Info), + async (c) => { + const config = c.req.valid("json") + await Config.update(config) + return c.json(config) + }, + ) + .get( + "/experimental/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), }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: z.string().meta({ description: "Session ID" }), - messageID: z.string().meta({ description: "Message ID" }), }), - ), - async (c) => { - const params = c.req.valid("param") - const message = await MessageV2.get({ - sessionID: params.sessionID, - messageID: params.messageID, - }) - return c.json(message) - }, - ) - .delete( - "/session/: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()), + async (c) => { + return c.json(await ToolRegistry.ids()) + }, + ) + .get( + "/experimental/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), }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: z.string().meta({ description: "Session ID" }), - messageID: z.string().meta({ description: "Message ID" }), - partID: z.string().meta({ description: "Part ID" }), }), - ), - async (c) => { - const params = c.req.valid("param") - await Session.removePart({ - sessionID: params.sessionID, - messageID: params.messageID, - partID: params.partID, - }) - return c.json(true) - }, - ) - .patch( - "/session/: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: z.string().meta({ description: "Session ID" }), - messageID: z.string().meta({ description: "Message ID" }), - partID: z.string().meta({ description: "Part ID" }), - }), - ), - 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}'`, + validator( + "query", + z.object({ + provider: z.string(), + model: z.string(), + }), + ), + async (c) => { + const { provider } = c.req.valid("query") + const tools = await ToolRegistry.tools(provider) + return c.json( + tools.map((t) => ({ + id: t.id, + description: t.description, + // Handle both Zod schemas and plain JSON schemas + parameters: (t.parameters as any)?._def ? zodToJsonSchema(t.parameters as any) : t.parameters, + })), ) - } - const part = await Session.updatePart(body) - return c.json(part) - }, - ) - .post( - "/session/: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(), - }), - ), + }, + ) + .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()), + }, }, }, }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: z.string().meta({ description: "Session ID" }), }), - ), - 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 + 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, + }) + }, + ) + .post( + "/experimental/worktree", + describeRoute({ + summary: "Create worktree", + description: "Create a new git worktree for the current project.", + operationId: "worktree.create", + responses: { + 200: { + description: "Worktree created", + content: { + "application/json": { + schema: resolver(Worktree.Info), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", Worktree.create.schema), + async (c) => { const body = c.req.valid("json") - const msg = await SessionPrompt.prompt({ ...body, sessionID }) - stream.write(JSON.stringify(msg)) - }) - }, - ) - .post( - "/session/: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), + const worktree = await Worktree.create(body) + return c.json(worktree) }, - }), - validator( - "param", - z.object({ - sessionID: z.string().meta({ description: "Session ID" }), + ) + .get( + "/experimental/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())), + }, + }, + }, + }, }), - ), - validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })), - async (c) => { - c.status(204) - c.header("Content-Type", "application/json") - return stream(c, async () => { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - SessionPrompt.prompt({ ...body, sessionID }) - }) - }, - ) - .post( - "/session/: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(), - }), - ), + async (c) => { + const sandboxes = await Project.sandboxes(Instance.project.id) + return c.json(sandboxes) + }, + ) + .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), + }, }, }, }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: z.string().meta({ description: "Session ID" }), }), - ), - validator("json", SessionPrompt.CommandInput.omit({ sessionID: true })), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - const msg = await SessionPrompt.command({ ...body, sessionID }) - return c.json(msg) - }, - ) - .post( - "/session/: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.Assistant), + async (c) => { + const branch = await Vcs.branch() + return c.json({ + branch, + }) + }, + ) + .get( + "/session", + 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()), + }, }, }, }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: z.string().meta({ description: "Session ID" }), }), - ), - validator("json", SessionPrompt.ShellInput.omit({ sessionID: true })), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - const msg = await SessionPrompt.shell({ ...body, sessionID }) - return c.json(msg) - }, - ) - .post( - "/session/: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: z.string(), - }), - ), - validator("json", SessionRevert.RevertInput.omit({ sessionID: true })), - async (c) => { - const sessionID = c.req.valid("param").sessionID - log.info("revert", c.req.valid("json")) - const session = await SessionRevert.revert({ - sessionID, - ...c.req.valid("json"), - }) - return c.json(session) - }, - ) - .post( - "/session/: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: z.string(), - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const session = await SessionRevert.unrevert({ sessionID }) - return c.json(session) - }, - ) - .post( - "/session/: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: z.string(), - permissionID: z.string(), - }), - ), - validator("json", z.object({ response: PermissionNext.Reply })), - async (c) => { - const params = c.req.valid("param") - PermissionNext.reply({ - requestID: params.permissionID, - reply: c.req.valid("json").response, - }) - return c.json(true) - }, - ) - .post( - "/permission/: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: z.string(), - }), - ), - validator("json", z.object({ reply: PermissionNext.Reply, message: z.string().optional() })), - async (c) => { - const params = c.req.valid("param") - const json = c.req.valid("json") - await PermissionNext.reply({ - requestID: params.requestID, - reply: json.reply, - message: json.message, - }) - return c.json(true) - }, - ) - .get( - "/permission", - 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(PermissionNext.Request.array()), - }, - }, - }, - }, - }), - async (c) => { - const permissions = await PermissionNext.list() - return c.json(permissions) - }, - ) - .get( - "/question", - describeRoute({ - summary: "List pending questions", - description: "Get all pending question requests across all sessions.", - operationId: "question.list", - responses: { - 200: { - description: "List of pending questions", - content: { - "application/json": { - schema: resolver(Question.Request.array()), - }, - }, - }, - }, - }), - async (c) => { - const questions = await Question.list() - return c.json(questions) - }, - ) - .post( - "/question/: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: z.string(), - }), - ), - validator("json", z.object({ answers: z.array(z.string()) })), - async (c) => { - const params = c.req.valid("param") - const json = c.req.valid("json") - await Question.reply({ - requestID: params.requestID, - answers: json.answers, - }) - return c.json(true) - }, - ) - .post( - "/question/: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: z.string(), - }), - ), - async (c) => { - const params = c.req.valid("param") - await Question.reject(params.requestID) - return c.json(true) - }, - ) - .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 Command.list() - return c.json(commands) - }, - ) - .get( - "/config/providers", - describeRoute({ - summary: "List config providers", - description: "Get a list of all configured AI providers and their default models.", - operationId: "config.providers", - responses: { - 200: { - description: "List of providers", - content: { - "application/json": { - schema: resolver( - z.object({ - providers: Provider.Info.array(), - default: z.record(z.string(), z.string()), - }), - ), - }, - }, - }, - }, - }), - async (c) => { - using _ = log.time("providers") - const providers = await Provider.list().then((x) => mapValues(x, (item) => item)) - return c.json({ - providers: Object.values(providers), - default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id), - }) - }, - ) - .get( - "/provider", - describeRoute({ - summary: "List providers", - description: "Get a list of all available AI providers, including both available and connected ones.", - operationId: "provider.list", - responses: { - 200: { - description: "List of providers", - content: { - "application/json": { - schema: resolver( - z.object({ - all: ModelsDev.Provider.array(), - default: z.record(z.string(), z.string()), - connected: z.array(z.string()), - }), - ), - }, - }, - }, - }, - }), - async (c) => { - const config = await Config.get() - const disabled = new Set(config.disabled_providers ?? []) - const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined - - const allProviders = await ModelsDev.get() - const filteredProviders: Record = {} - for (const [key, value] of Object.entries(allProviders)) { - if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { - filteredProviders[key] = value + validator( + "query", + z.object({ + 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 term = query.search?.toLowerCase() + const sessions: Session.Info[] = [] + for await (const session of Session.list()) { + if (query.start !== undefined && session.time.updated < query.start) continue + if (term !== undefined && !session.title.toLowerCase().includes(term)) continue + sessions.push(session) + if (query.limit !== undefined && sessions.length >= query.limit) break } - } + return c.json(sessions) + }, + ) + .get( + "/session/status", + describeRoute({ + summary: "Get session status", + description: "Retrieve the current status of all sessions, including active, idle, and completed states.", + operationId: "session.status", + responses: { + 200: { + description: "Get session status", + content: { + "application/json": { + schema: resolver(z.record(z.string(), SessionStatus.Info)), + }, + }, + }, + ...errors(400), + }, + }), + async (c) => { + const result = SessionStatus.list() + return c.json(result) + }, + ) + .get( + "/session/:sessionID", + describeRoute({ + summary: "Get session", + description: "Retrieve detailed information about a specific OpenCode session.", + tags: ["Session"], + operationId: "session.get", + responses: { + 200: { + description: "Get session", + content: { + "application/json": { + schema: resolver(Session.Info), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: Session.get.schema, + }), + ), + async (c) => { + const sessionID = c.req.valid("param").sessionID + log.info("SEARCH", { url: c.req.url }) + const session = await Session.get(sessionID) + return c.json(session) + }, + ) + .get( + "/session/:sessionID/children", + describeRoute({ + summary: "Get session children", + tags: ["Session"], + description: "Retrieve all child sessions that were forked from the specified parent session.", + operationId: "session.children", + responses: { + 200: { + description: "List of children", + content: { + "application/json": { + schema: resolver(Session.Info.array()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: Session.children.schema, + }), + ), + async (c) => { + const sessionID = c.req.valid("param").sessionID + const session = await Session.children(sessionID) + return c.json(session) + }, + ) + .get( + "/session/: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: z.string().meta({ description: "Session ID" }), + }), + ), + async (c) => { + const sessionID = c.req.valid("param").sessionID + const todos = await Todo.get(sessionID) + return c.json(todos) + }, + ) + .post( + "/session", + describeRoute({ + summary: "Create session", + description: "Create a new OpenCode session for interacting with AI assistants and managing conversations.", + operationId: "session.create", + responses: { + ...errors(400), + 200: { + description: "Successfully created session", + content: { + "application/json": { + schema: resolver(Session.Info), + }, + }, + }, + }, + }), + validator("json", Session.create.schema.optional()), + async (c) => { + const body = c.req.valid("json") ?? {} + const session = await Session.create(body) + return c.json(session) + }, + ) + .delete( + "/session/:sessionID", + describeRoute({ + summary: "Delete session", + description: "Delete a session and permanently remove all associated data, including messages and history.", + operationId: "session.delete", + responses: { + 200: { + description: "Successfully deleted session", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: Session.remove.schema, + }), + ), + async (c) => { + const sessionID = c.req.valid("param").sessionID + await Session.remove(sessionID) + return c.json(true) + }, + ) + .patch( + "/session/: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: z.string(), + }), + ), + validator( + "json", + z.object({ + title: z.string().optional(), + time: z + .object({ + archived: z.number().optional(), + }) + .optional(), + }), + ), + async (c) => { + const sessionID = c.req.valid("param").sessionID + const updates = c.req.valid("json") - const connected = await Provider.list() - const providers = Object.assign( - mapValues(filteredProviders, (x) => Provider.fromModelsDevProvider(x)), - connected, - ) - return c.json({ - all: Object.values(providers), - default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id), - connected: Object.keys(connected), - }) - }, - ) - .get( - "/provider/auth", - describeRoute({ - summary: "Get provider auth methods", - description: "Retrieve available authentication methods for all AI providers.", - operationId: "provider.auth", - responses: { - 200: { - description: "Provider auth methods", - content: { - "application/json": { - schema: resolver(z.record(z.string(), z.array(ProviderAuth.Method))), + const updatedSession = await Session.update(sessionID, (session) => { + if (updates.title !== undefined) { + session.title = updates.title + } + if (updates.time?.archived !== undefined) session.time.archived = updates.time.archived + }) + + return c.json(updatedSession) + }, + ) + .post( + "/session/: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: z.string().meta({ description: "Session ID" }), + }), + ), + validator("json", Session.initialize.schema.omit({ sessionID: true })), + async (c) => { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") + await Session.initialize({ ...body, sessionID }) + return c.json(true) + }, + ) + .post( + "/session/:sessionID/fork", + describeRoute({ + summary: "Fork session", + description: "Create a new session by forking an existing session at a specific message point.", + operationId: "session.fork", + responses: { + 200: { + description: "200", + content: { + "application/json": { + schema: resolver(Session.Info), + }, }, }, }, + }), + validator( + "param", + z.object({ + sessionID: Session.fork.schema.shape.sessionID, + }), + ), + validator("json", Session.fork.schema.omit({ sessionID: true })), + async (c) => { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") + const result = await Session.fork({ ...body, sessionID }) + return c.json(result) }, - }), - async (c) => { - return c.json(await ProviderAuth.methods()) - }, - ) - .post( - "/provider/:providerID/oauth/authorize", - describeRoute({ - summary: "OAuth authorize", - description: "Initiate OAuth authorization for a specific AI provider to get an authorization URL.", - operationId: "provider.oauth.authorize", - responses: { - 200: { - description: "Authorization URL and method", - content: { - "application/json": { - schema: resolver(ProviderAuth.Authorization.optional()), + ) + .post( + "/session/: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: z.string(), + }), + ), + async (c) => { + SessionPrompt.cancel(c.req.valid("param").sessionID) + return c.json(true) + }, + ) + + .post( + "/session/: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: z.string(), + }), + ), + async (c) => { + const sessionID = c.req.valid("param").sessionID + await Session.share(sessionID) + const session = await Session.get(sessionID) + return c.json(session) + }, + ) + .get( + "/session/: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()), + }, }, }, }, - ...errors(400), + }), + validator( + "param", + z.object({ + sessionID: SessionSummary.diff.schema.shape.sessionID, + }), + ), + validator( + "query", + z.object({ + messageID: SessionSummary.diff.schema.shape.messageID, + }), + ), + async (c) => { + const query = c.req.valid("query") + const params = c.req.valid("param") + const result = await SessionSummary.diff({ + sessionID: params.sessionID, + messageID: query.messageID, + }) + return c.json(result) }, - }), - validator( - "param", - z.object({ - providerID: z.string().meta({ description: "Provider ID" }), + ) + .delete( + "/session/: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( - "json", - z.object({ - method: z.number().meta({ description: "Auth method index" }), + validator( + "param", + z.object({ + sessionID: Session.unshare.schema, + }), + ), + async (c) => { + const sessionID = c.req.valid("param").sessionID + await Session.unshare(sessionID) + const session = await Session.get(sessionID) + return c.json(session) + }, + ) + .post( + "/session/: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), + }, }), - ), - async (c) => { - const providerID = c.req.valid("param").providerID - const { method } = c.req.valid("json") - const result = await ProviderAuth.authorize({ - providerID, - method, - }) - return c.json(result) - }, - ) - .post( - "/provider/: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()), + validator( + "param", + z.object({ + sessionID: z.string().meta({ description: "Session ID" }), + }), + ), + validator( + "json", + z.object({ + providerID: z.string(), + modelID: z.string(), + auto: z.boolean().optional().default(false), + }), + ), + async (c) => { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") + const session = await Session.get(sessionID) + await SessionRevert.cleanup(session) + const msgs = await Session.messages({ sessionID }) + let currentAgent = await Agent.defaultAgent() + for (let i = msgs.length - 1; i >= 0; i--) { + const info = msgs[i].info + if (info.role === "user") { + currentAgent = info.agent || (await Agent.defaultAgent()) + break + } + } + await SessionCompaction.create({ + sessionID, + agent: currentAgent, + model: { + providerID: body.providerID, + modelID: body.modelID, + }, + auto: body.auto, + }) + await SessionPrompt.loop(sessionID) + return c.json(true) + }, + ) + .get( + "/session/: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: z.string().meta({ description: "Session ID" }), + }), + ), + validator( + "query", + z.object({ + limit: z.coerce.number().optional(), + }), + ), + async (c) => { + const query = c.req.valid("query") + const messages = await Session.messages({ + sessionID: c.req.valid("param").sessionID, + limit: query.limit, + }) + return c.json(messages) + }, + ) + .get( + "/session/:sessionID/diff", + describeRoute({ + summary: "Get session diff", + description: "Get all file changes (diffs) made during this session.", + operationId: "session.diff", + responses: { + 200: { + description: "List of diffs", + content: { + "application/json": { + schema: resolver(Snapshot.FileDiff.array()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: z.string().meta({ description: "Session ID" }), + }), + ), + async (c) => { + const diff = await Session.diff(c.req.valid("param").sessionID) + return c.json(diff) + }, + ) + .get( + "/session/: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: z.string().meta({ description: "Session ID" }), + messageID: z.string().meta({ description: "Message ID" }), + }), + ), + async (c) => { + const params = c.req.valid("param") + const message = await MessageV2.get({ + sessionID: params.sessionID, + messageID: params.messageID, + }) + return c.json(message) + }, + ) + .delete( + "/session/: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: z.string().meta({ description: "Session ID" }), + messageID: z.string().meta({ description: "Message ID" }), + partID: z.string().meta({ description: "Part ID" }), + }), + ), + async (c) => { + const params = c.req.valid("param") + await Session.removePart({ + sessionID: params.sessionID, + messageID: params.messageID, + partID: params.partID, + }) + return c.json(true) + }, + ) + .patch( + "/session/: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: z.string().meta({ description: "Session ID" }), + messageID: z.string().meta({ description: "Message ID" }), + partID: z.string().meta({ description: "Part ID" }), + }), + ), + validator("json", MessageV2.Part), + async (c) => { + const params = c.req.valid("param") + const body = c.req.valid("json") + if ( + body.id !== params.partID || + body.messageID !== params.messageID || + body.sessionID !== params.sessionID + ) { + throw new Error( + `Part mismatch: body.id='${body.id}' vs partID='${params.partID}', body.messageID='${body.messageID}' vs messageID='${params.messageID}', body.sessionID='${body.sessionID}' vs sessionID='${params.sessionID}'`, + ) + } + const part = await Session.updatePart(body) + return c.json(part) + }, + ) + .post( + "/session/: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: z.string().meta({ description: "Session ID" }), + }), + ), + validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })), + async (c) => { + c.status(200) + c.header("Content-Type", "application/json") + return stream(c, async (stream) => { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") + const msg = await SessionPrompt.prompt({ ...body, sessionID }) + stream.write(JSON.stringify(msg)) + }) + }, + ) + .post( + "/session/: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: z.string().meta({ description: "Session ID" }), + }), + ), + validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })), + async (c) => { + c.status(204) + c.header("Content-Type", "application/json") + return stream(c, async () => { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") + SessionPrompt.prompt({ ...body, sessionID }) + }) + }, + ) + .post( + "/session/: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: z.string().meta({ description: "Session ID" }), + }), + ), + validator("json", SessionPrompt.CommandInput.omit({ sessionID: true })), + async (c) => { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") + const msg = await SessionPrompt.command({ ...body, sessionID }) + return c.json(msg) + }, + ) + .post( + "/session/: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.Assistant), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: z.string().meta({ description: "Session ID" }), + }), + ), + validator("json", SessionPrompt.ShellInput.omit({ sessionID: true })), + async (c) => { + const sessionID = c.req.valid("param").sessionID + const body = c.req.valid("json") + const msg = await SessionPrompt.shell({ ...body, sessionID }) + return c.json(msg) + }, + ) + .post( + "/session/: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: z.string(), + }), + ), + validator("json", SessionRevert.RevertInput.omit({ sessionID: true })), + async (c) => { + const sessionID = c.req.valid("param").sessionID + log.info("revert", c.req.valid("json")) + const session = await SessionRevert.revert({ + sessionID, + ...c.req.valid("json"), + }) + return c.json(session) + }, + ) + .post( + "/session/: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: z.string(), + }), + ), + async (c) => { + const sessionID = c.req.valid("param").sessionID + const session = await SessionRevert.unrevert({ sessionID }) + return c.json(session) + }, + ) + .post( + "/session/: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: z.string(), + permissionID: z.string(), + }), + ), + validator("json", z.object({ response: PermissionNext.Reply })), + async (c) => { + const params = c.req.valid("param") + PermissionNext.reply({ + requestID: params.permissionID, + reply: c.req.valid("json").response, + }) + return c.json(true) + }, + ) + .post( + "/permission/: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: z.string(), + }), + ), + validator("json", z.object({ reply: PermissionNext.Reply, message: z.string().optional() })), + async (c) => { + const params = c.req.valid("param") + const json = c.req.valid("json") + await PermissionNext.reply({ + requestID: params.requestID, + reply: json.reply, + message: json.message, + }) + return c.json(true) + }, + ) + .get( + "/permission", + 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(PermissionNext.Request.array()), + }, }, }, }, - ...errors(400), + }), + async (c) => { + const permissions = await PermissionNext.list() + return c.json(permissions) }, - }), - validator( - "param", - z.object({ - providerID: z.string().meta({ description: "Provider ID" }), - }), - ), - validator( - "json", - z.object({ - method: z.number().meta({ description: "Auth method index" }), - code: z.string().optional().meta({ description: "OAuth authorization code" }), - }), - ), - async (c) => { - const providerID = c.req.valid("param").providerID - const { method, code } = c.req.valid("json") - await ProviderAuth.callback({ - providerID, - method, - code, - }) - return c.json(true) - }, - ) - .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()), + ) + .route("/question", QuestionRoute) + .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()), + }, }, }, }, - }, - }), - validator( - "query", - z.object({ - pattern: z.string(), }), - ), - async (c) => { - const pattern = c.req.valid("query").pattern - const result = await Ripgrep.search({ - cwd: Instance.directory, - pattern, - limit: 10, - }) - return c.json(result) - }, - ) - .get( - "/find/file", - describeRoute({ - summary: "Find files", - description: "Search for files or directories by name or pattern in the project directory.", - operationId: "find.files", - responses: { - 200: { - description: "File paths", - content: { - "application/json": { - schema: resolver(z.string().array()), + async (c) => { + const commands = await Command.list() + return c.json(commands) + }, + ) + .get( + "/config/providers", + describeRoute({ + summary: "List config providers", + description: "Get a list of all configured AI providers and their default models.", + operationId: "config.providers", + responses: { + 200: { + description: "List of providers", + content: { + "application/json": { + schema: resolver( + z.object({ + providers: Provider.Info.array(), + default: z.record(z.string(), z.string()), + }), + ), + }, }, }, }, - }, - }), - validator( - "query", - z.object({ - query: z.string(), - dirs: z.enum(["true", "false"]).optional(), - type: z.enum(["file", "directory"]).optional(), - limit: z.coerce.number().int().min(1).max(200).optional(), }), - ), - async (c) => { - const query = c.req.valid("query").query - const dirs = c.req.valid("query").dirs - const type = c.req.valid("query").type - const limit = c.req.valid("query").limit - const results = await File.search({ - query, - limit: limit ?? 10, - dirs: dirs !== "false", - type, - }) - return c.json(results) - }, - ) - .get( - "/find/symbol", - describeRoute({ - summary: "Find symbols", - description: "Search for workspace symbols like functions, classes, and variables using LSP.", - operationId: "find.symbols", - responses: { - 200: { - description: "Symbols", - content: { - "application/json": { - schema: resolver(LSP.Symbol.array()), + async (c) => { + using _ = log.time("providers") + const providers = await Provider.list().then((x) => mapValues(x, (item) => item)) + return c.json({ + providers: Object.values(providers), + default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id), + }) + }, + ) + .get( + "/provider", + describeRoute({ + summary: "List providers", + description: "Get a list of all available AI providers, including both available and connected ones.", + operationId: "provider.list", + responses: { + 200: { + description: "List of providers", + content: { + "application/json": { + schema: resolver( + z.object({ + all: ModelsDev.Provider.array(), + default: z.record(z.string(), z.string()), + connected: z.array(z.string()), + }), + ), + }, }, }, }, - }, - }), - validator( - "query", - z.object({ - query: z.string(), }), - ), - async (c) => { - /* + async (c) => { + const config = await Config.get() + const disabled = new Set(config.disabled_providers ?? []) + const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined + + const allProviders = await ModelsDev.get() + const filteredProviders: Record = {} + for (const [key, value] of Object.entries(allProviders)) { + if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { + filteredProviders[key] = value + } + } + + const connected = await Provider.list() + const providers = Object.assign( + mapValues(filteredProviders, (x) => Provider.fromModelsDevProvider(x)), + connected, + ) + return c.json({ + all: Object.values(providers), + default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id), + connected: Object.keys(connected), + }) + }, + ) + .get( + "/provider/auth", + describeRoute({ + summary: "Get provider auth methods", + description: "Retrieve available authentication methods for all AI providers.", + operationId: "provider.auth", + responses: { + 200: { + description: "Provider auth methods", + content: { + "application/json": { + schema: resolver(z.record(z.string(), z.array(ProviderAuth.Method))), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await ProviderAuth.methods()) + }, + ) + .post( + "/provider/:providerID/oauth/authorize", + describeRoute({ + summary: "OAuth authorize", + description: "Initiate OAuth authorization for a specific AI provider to get an authorization URL.", + operationId: "provider.oauth.authorize", + responses: { + 200: { + description: "Authorization URL and method", + content: { + "application/json": { + schema: resolver(ProviderAuth.Authorization.optional()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "param", + z.object({ + providerID: z.string().meta({ description: "Provider ID" }), + }), + ), + validator( + "json", + z.object({ + method: z.number().meta({ description: "Auth method index" }), + }), + ), + async (c) => { + const providerID = c.req.valid("param").providerID + const { method } = c.req.valid("json") + const result = await ProviderAuth.authorize({ + providerID, + method, + }) + return c.json(result) + }, + ) + .post( + "/provider/: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: z.string().meta({ description: "Provider ID" }), + }), + ), + validator( + "json", + z.object({ + method: z.number().meta({ description: "Auth method index" }), + code: z.string().optional().meta({ description: "OAuth authorization code" }), + }), + ), + async (c) => { + const providerID = c.req.valid("param").providerID + const { method, code } = c.req.valid("json") + await ProviderAuth.callback({ + providerID, + method, + code, + }) + return c.json(true) + }, + ) + .get( + "/find", + describeRoute({ + summary: "Find text", + description: "Search for text patterns across files in the project using ripgrep.", + operationId: "find.text", + responses: { + 200: { + description: "Matches", + content: { + "application/json": { + schema: resolver(Ripgrep.Match.shape.data.array()), + }, + }, + }, + }, + }), + validator( + "query", + z.object({ + pattern: z.string(), + }), + ), + async (c) => { + const pattern = c.req.valid("query").pattern + const result = await Ripgrep.search({ + cwd: Instance.directory, + pattern, + limit: 10, + }) + return c.json(result) + }, + ) + .get( + "/find/file", + describeRoute({ + summary: "Find files", + description: "Search for files or directories by name or pattern in the project directory.", + operationId: "find.files", + responses: { + 200: { + description: "File paths", + content: { + "application/json": { + schema: resolver(z.string().array()), + }, + }, + }, + }, + }), + validator( + "query", + z.object({ + query: z.string(), + dirs: z.enum(["true", "false"]).optional(), + type: z.enum(["file", "directory"]).optional(), + limit: z.coerce.number().int().min(1).max(200).optional(), + }), + ), + async (c) => { + const query = c.req.valid("query").query + const dirs = c.req.valid("query").dirs + const type = c.req.valid("query").type + const limit = c.req.valid("query").limit + const results = await File.search({ + query, + limit: limit ?? 10, + dirs: dirs !== "false", + type, + }) + return c.json(results) + }, + ) + .get( + "/find/symbol", + describeRoute({ + summary: "Find symbols", + description: "Search for workspace symbols like functions, classes, and variables using LSP.", + operationId: "find.symbols", + responses: { + 200: { + description: "Symbols", + content: { + "application/json": { + schema: resolver(LSP.Symbol.array()), + }, + }, + }, + }, + }), + validator( + "query", + z.object({ + query: z.string(), + }), + ), + async (c) => { + /* const query = c.req.valid("query").query const result = await LSP.workspaceSymbol(query) return c.json(result) */ - return c.json([]) - }, - ) - .get( - "/file", - describeRoute({ - summary: "List files", - description: "List files and directories in a specified path.", - operationId: "file.list", - responses: { - 200: { - description: "Files and directories", - content: { - "application/json": { - schema: resolver(File.Node.array()), + return c.json([]) + }, + ) + .get( + "/file", + describeRoute({ + summary: "List files", + description: "List files and directories in a specified path.", + operationId: "file.list", + responses: { + 200: { + description: "Files and directories", + content: { + "application/json": { + schema: resolver(File.Node.array()), + }, }, }, }, - }, - }), - validator( - "query", - z.object({ - path: z.string(), }), - ), - async (c) => { - const path = c.req.valid("query").path - const content = await File.list(path) - return c.json(content) - }, - ) - .get( - "/file/content", - describeRoute({ - summary: "Read file", - description: "Read the content of a specified file.", - operationId: "file.read", - responses: { - 200: { - description: "File content", - content: { - "application/json": { - schema: resolver(File.Content), - }, - }, - }, - }, - }), - validator( - "query", - z.object({ - path: z.string(), - }), - ), - async (c) => { - const path = c.req.valid("query").path - const content = await File.read(path) - return c.json(content) - }, - ) - .get( - "/file/status", - describeRoute({ - summary: "Get file status", - description: "Get the git status of all files in the project.", - operationId: "file.status", - responses: { - 200: { - description: "File status", - content: { - "application/json": { - schema: resolver(File.Info.array()), - }, - }, - }, - }, - }), - async (c) => { - const content = await File.status() - return c.json(content) - }, - ) - .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) - }, - ) - .get( - "/agent", - describeRoute({ - summary: "List agents", - description: "Get a list of all available AI agents in the OpenCode system.", - operationId: "app.agents", - responses: { - 200: { - description: "List of agents", - content: { - "application/json": { - schema: resolver(Agent.Info.array()), - }, - }, - }, - }, - }), - async (c) => { - const modes = await Agent.list() - return c.json(modes) - }, - ) - .get( - "/mcp", - describeRoute({ - summary: "Get MCP status", - description: "Get the status of all Model Context Protocol (MCP) servers.", - operationId: "mcp.status", - responses: { - 200: { - description: "MCP server status", - content: { - "application/json": { - schema: resolver(z.record(z.string(), MCP.Status)), - }, - }, - }, - }, - }), - async (c) => { - return c.json(await MCP.status()) - }, - ) - .post( - "/mcp", - describeRoute({ - summary: "Add MCP server", - description: "Dynamically add a new Model Context Protocol (MCP) server to the system.", - operationId: "mcp.add", - responses: { - 200: { - description: "MCP server added successfully", - content: { - "application/json": { - schema: resolver(z.record(z.string(), MCP.Status)), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "json", - z.object({ - name: z.string(), - config: Config.Mcp, - }), - ), - async (c) => { - const { name, config } = c.req.valid("json") - const result = await MCP.add(name, config) - return c.json(result.status) - }, - ) - .post( - "/mcp/:name/auth", - describeRoute({ - summary: "Start MCP OAuth", - description: "Start OAuth authentication flow for a Model Context Protocol (MCP) server.", - operationId: "mcp.auth.start", - responses: { - 200: { - description: "OAuth flow started", - content: { - "application/json": { - schema: resolver( - z.object({ - authorizationUrl: z.string().describe("URL to open in browser for authorization"), - }), - ), - }, - }, - }, - ...errors(400, 404), - }, - }), - async (c) => { - const name = c.req.param("name") - const supportsOAuth = await MCP.supportsOAuth(name) - if (!supportsOAuth) { - return c.json({ error: `MCP server ${name} does not support OAuth` }, 400) - } - const result = await MCP.startAuth(name) - return c.json(result) - }, - ) - .post( - "/mcp/:name/auth/callback", - describeRoute({ - summary: "Complete MCP OAuth", - description: - "Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code.", - operationId: "mcp.auth.callback", - responses: { - 200: { - description: "OAuth authentication completed", - content: { - "application/json": { - schema: resolver(MCP.Status), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "json", - z.object({ - code: z.string().describe("Authorization code from OAuth callback"), - }), - ), - async (c) => { - const name = c.req.param("name") - const { code } = c.req.valid("json") - const status = await MCP.finishAuth(name, code) - return c.json(status) - }, - ) - .post( - "/mcp/:name/auth/authenticate", - describeRoute({ - summary: "Authenticate MCP OAuth", - description: "Start OAuth flow and wait for callback (opens browser)", - operationId: "mcp.auth.authenticate", - responses: { - 200: { - description: "OAuth authentication completed", - content: { - "application/json": { - schema: resolver(MCP.Status), - }, - }, - }, - ...errors(400, 404), - }, - }), - async (c) => { - const name = c.req.param("name") - const supportsOAuth = await MCP.supportsOAuth(name) - if (!supportsOAuth) { - return c.json({ error: `MCP server ${name} does not support OAuth` }, 400) - } - const status = await MCP.authenticate(name) - return c.json(status) - }, - ) - .delete( - "/mcp/:name/auth", - describeRoute({ - summary: "Remove MCP OAuth", - description: "Remove OAuth credentials for an MCP server", - operationId: "mcp.auth.remove", - responses: { - 200: { - description: "OAuth credentials removed", - content: { - "application/json": { - schema: resolver(z.object({ success: z.literal(true) })), - }, - }, - }, - ...errors(404), - }, - }), - async (c) => { - const name = c.req.param("name") - await MCP.removeAuth(name) - return c.json({ success: true as const }) - }, - ) - .post( - "/mcp/:name/connect", - describeRoute({ - description: "Connect an MCP server", - operationId: "mcp.connect", - responses: { - 200: { - description: "MCP server connected successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - validator("param", z.object({ name: z.string() })), - async (c) => { - const { name } = c.req.valid("param") - await MCP.connect(name) - return c.json(true) - }, - ) - .post( - "/mcp/:name/disconnect", - describeRoute({ - description: "Disconnect an MCP server", - operationId: "mcp.disconnect", - responses: { - 200: { - description: "MCP server disconnected successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - validator("param", z.object({ name: z.string() })), - async (c) => { - const { name } = c.req.valid("param") - await MCP.disconnect(name) - return c.json(true) - }, - ) - .get( - "/experimental/resource", - describeRoute({ - summary: "Get MCP resources", - description: "Get all available MCP resources from connected servers. Optionally filter by name.", - operationId: "experimental.resource.list", - responses: { - 200: { - description: "MCP resources", - content: { - "application/json": { - schema: resolver(z.record(z.string(), MCP.Resource)), - }, - }, - }, - }, - }), - async (c) => { - return c.json(await MCP.resources()) - }, - ) - .get( - "/lsp", - describeRoute({ - summary: "Get LSP status", - description: "Get LSP server status", - operationId: "lsp.status", - responses: { - 200: { - description: "LSP server status", - content: { - "application/json": { - schema: resolver(LSP.Status.array()), - }, - }, - }, - }, - }), - async (c) => { - return c.json(await LSP.status()) - }, - ) - .get( - "/formatter", - describeRoute({ - summary: "Get formatter status", - description: "Get formatter status", - operationId: "formatter.status", - responses: { - 200: { - description: "Formatter status", - content: { - "application/json": { - schema: resolver(Format.Status.array()), - }, - }, - }, - }, - }), - async (c) => { - return c.json(await Format.status()) - }, - ) - .post( - "/tui/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( - "/tui/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) => { - // TODO: open dialog - return c.json(true) - }, - ) - .post( - "/tui/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( - "/tui/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( - "/tui/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( - "/tui/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( - "/tui/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( - "/tui/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_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( - "/tui/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( - "/tui/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, - }) + validator( + "query", + z.object({ + path: z.string(), }), ), - ), - 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( - "/tui/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()), + async (c) => { + const path = c.req.valid("query").path + const content = await File.list(path) + return c.json(content) + }, + ) + .get( + "/file/content", + describeRoute({ + summary: "Read file", + description: "Read the content of a specified file.", + operationId: "file.read", + responses: { + 200: { + description: "File content", + content: { + "application/json": { + schema: resolver(File.Content), + }, }, }, }, - ...errors(400, 404), - }, - }), - validator("json", TuiEvent.SessionSelect.properties), - async (c) => { - const { sessionID } = c.req.valid("json") - await Session.get(sessionID) - await Bus.publish(TuiEvent.SessionSelect, { sessionID }) - return c.json(true) - }, - ) - .route("/tui/control", TuiRoute) - .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: z.string(), }), - ), - validator("json", Auth.Info), - async (c) => { - const providerID = c.req.valid("param").providerID - const info = c.req.valid("json") - await Auth.set(providerID, info) - return c.json(true) - }, - ) - .get( - "/event", - describeRoute({ - summary: "Subscribe to events", - description: "Get events", - operationId: "event.subscribe", - responses: { - 200: { - description: "Event stream", - content: { - "text/event-stream": { - schema: resolver(BusEvent.payloads()), + validator( + "query", + z.object({ + path: z.string(), + }), + ), + async (c) => { + const path = c.req.valid("query").path + const content = await File.read(path) + return c.json(content) + }, + ) + .get( + "/file/status", + describeRoute({ + summary: "Get file status", + description: "Get the git status of all files in the project.", + operationId: "file.status", + responses: { + 200: { + description: "File status", + content: { + "application/json": { + schema: resolver(File.Info.array()), + }, }, }, }, + }), + async (c) => { + const content = await File.status() + return c.json(content) }, - }), - async (c) => { - log.info("event connected") - return streamSSE(c, async (stream) => { - stream.writeSSE({ - data: JSON.stringify({ - type: "server.connected", - properties: {}, - }), - }) - const unsub = Bus.subscribeAll(async (event) => { - await stream.writeSSE({ - data: JSON.stringify(event), - }) - if (event.type === Bus.InstanceDisposed.type) { - stream.close() - } - }) + ) + .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 }) - // Send heartbeat every 30s to prevent WKWebView timeout (60s default) - const heartbeat = setInterval(() => { + 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) + }, + ) + .get( + "/agent", + describeRoute({ + summary: "List agents", + description: "Get a list of all available AI agents in the OpenCode system.", + operationId: "app.agents", + responses: { + 200: { + description: "List of agents", + content: { + "application/json": { + schema: resolver(Agent.Info.array()), + }, + }, + }, + }, + }), + async (c) => { + const modes = await Agent.list() + return c.json(modes) + }, + ) + .get( + "/mcp", + describeRoute({ + summary: "Get MCP status", + description: "Get the status of all Model Context Protocol (MCP) servers.", + operationId: "mcp.status", + responses: { + 200: { + description: "MCP server status", + content: { + "application/json": { + schema: resolver(z.record(z.string(), MCP.Status)), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await MCP.status()) + }, + ) + .post( + "/mcp", + describeRoute({ + summary: "Add MCP server", + description: "Dynamically add a new Model Context Protocol (MCP) server to the system.", + operationId: "mcp.add", + responses: { + 200: { + description: "MCP server added successfully", + content: { + "application/json": { + schema: resolver(z.record(z.string(), MCP.Status)), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "json", + z.object({ + name: z.string(), + config: Config.Mcp, + }), + ), + async (c) => { + const { name, config } = c.req.valid("json") + const result = await MCP.add(name, config) + return c.json(result.status) + }, + ) + .post( + "/mcp/:name/auth", + describeRoute({ + summary: "Start MCP OAuth", + description: "Start OAuth authentication flow for a Model Context Protocol (MCP) server.", + operationId: "mcp.auth.start", + responses: { + 200: { + description: "OAuth flow started", + content: { + "application/json": { + schema: resolver( + z.object({ + authorizationUrl: z.string().describe("URL to open in browser for authorization"), + }), + ), + }, + }, + }, + ...errors(400, 404), + }, + }), + async (c) => { + const name = c.req.param("name") + const supportsOAuth = await MCP.supportsOAuth(name) + if (!supportsOAuth) { + return c.json({ error: `MCP server ${name} does not support OAuth` }, 400) + } + const result = await MCP.startAuth(name) + return c.json(result) + }, + ) + .post( + "/mcp/:name/auth/callback", + describeRoute({ + summary: "Complete MCP OAuth", + description: + "Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code.", + operationId: "mcp.auth.callback", + responses: { + 200: { + description: "OAuth authentication completed", + content: { + "application/json": { + schema: resolver(MCP.Status), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "json", + z.object({ + code: z.string().describe("Authorization code from OAuth callback"), + }), + ), + async (c) => { + const name = c.req.param("name") + const { code } = c.req.valid("json") + const status = await MCP.finishAuth(name, code) + return c.json(status) + }, + ) + .post( + "/mcp/:name/auth/authenticate", + describeRoute({ + summary: "Authenticate MCP OAuth", + description: "Start OAuth flow and wait for callback (opens browser)", + operationId: "mcp.auth.authenticate", + responses: { + 200: { + description: "OAuth authentication completed", + content: { + "application/json": { + schema: resolver(MCP.Status), + }, + }, + }, + ...errors(400, 404), + }, + }), + async (c) => { + const name = c.req.param("name") + const supportsOAuth = await MCP.supportsOAuth(name) + if (!supportsOAuth) { + return c.json({ error: `MCP server ${name} does not support OAuth` }, 400) + } + const status = await MCP.authenticate(name) + return c.json(status) + }, + ) + .delete( + "/mcp/:name/auth", + describeRoute({ + summary: "Remove MCP OAuth", + description: "Remove OAuth credentials for an MCP server", + operationId: "mcp.auth.remove", + responses: { + 200: { + description: "OAuth credentials removed", + content: { + "application/json": { + schema: resolver(z.object({ success: z.literal(true) })), + }, + }, + }, + ...errors(404), + }, + }), + async (c) => { + const name = c.req.param("name") + await MCP.removeAuth(name) + return c.json({ success: true as const }) + }, + ) + .post( + "/mcp/:name/connect", + describeRoute({ + description: "Connect an MCP server", + operationId: "mcp.connect", + responses: { + 200: { + description: "MCP server connected successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + validator("param", z.object({ name: z.string() })), + async (c) => { + const { name } = c.req.valid("param") + await MCP.connect(name) + return c.json(true) + }, + ) + .post( + "/mcp/:name/disconnect", + describeRoute({ + description: "Disconnect an MCP server", + operationId: "mcp.disconnect", + responses: { + 200: { + description: "MCP server disconnected successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + validator("param", z.object({ name: z.string() })), + async (c) => { + const { name } = c.req.valid("param") + await MCP.disconnect(name) + return c.json(true) + }, + ) + .get( + "/experimental/resource", + describeRoute({ + summary: "Get MCP resources", + description: "Get all available MCP resources from connected servers. Optionally filter by name.", + operationId: "experimental.resource.list", + responses: { + 200: { + description: "MCP resources", + content: { + "application/json": { + schema: resolver(z.record(z.string(), MCP.Resource)), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await MCP.resources()) + }, + ) + .get( + "/lsp", + describeRoute({ + summary: "Get LSP status", + description: "Get LSP server status", + operationId: "lsp.status", + responses: { + 200: { + description: "LSP server status", + content: { + "application/json": { + schema: resolver(LSP.Status.array()), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await LSP.status()) + }, + ) + .get( + "/formatter", + describeRoute({ + summary: "Get formatter status", + description: "Get formatter status", + operationId: "formatter.status", + responses: { + 200: { + description: "Formatter status", + content: { + "application/json": { + schema: resolver(Format.Status.array()), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await Format.status()) + }, + ) + .post( + "/tui/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( + "/tui/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) => { + // TODO: open dialog + return c.json(true) + }, + ) + .post( + "/tui/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( + "/tui/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( + "/tui/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( + "/tui/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( + "/tui/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( + "/tui/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_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( + "/tui/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( + "/tui/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( + "/tui/select-session", + describeRoute({ + summary: "Select session", + description: "Navigate the TUI to display the specified session.", + operationId: "tui.selectSession", + responses: { + 200: { + description: "Session selected successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator("json", TuiEvent.SessionSelect.properties), + async (c) => { + const { sessionID } = c.req.valid("json") + await Session.get(sessionID) + await Bus.publish(TuiEvent.SessionSelect, { sessionID }) + return c.json(true) + }, + ) + .route("/tui/control", TuiRoute) + .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: z.string(), + }), + ), + validator("json", Auth.Info), + async (c) => { + const providerID = c.req.valid("param").providerID + const info = c.req.valid("json") + await Auth.set(providerID, info) + return c.json(true) + }, + ) + .get( + "/event", + describeRoute({ + summary: "Subscribe to events", + description: "Get events", + operationId: "event.subscribe", + responses: { + 200: { + description: "Event stream", + content: { + "text/event-stream": { + schema: resolver(BusEvent.payloads()), + }, + }, + }, + }, + }), + async (c) => { + log.info("event connected") + return streamSSE(c, async (stream) => { stream.writeSSE({ data: JSON.stringify({ - type: "server.heartbeat", + type: "server.connected", properties: {}, }), }) - }, 30000) + const unsub = Bus.subscribeAll(async (event) => { + await stream.writeSSE({ + data: JSON.stringify(event), + }) + if (event.type === Bus.InstanceDisposed.type) { + stream.close() + } + }) - await new Promise((resolve) => { - stream.onAbort(() => { - clearInterval(heartbeat) - unsub() - resolve() - log.info("event disconnected") + // Send heartbeat every 30s to prevent WKWebView timeout (60s default) + const heartbeat = setInterval(() => { + stream.writeSSE({ + data: JSON.stringify({ + type: "server.heartbeat", + properties: {}, + }), + }) + }, 30000) + + await new Promise((resolve) => { + stream.onAbort(() => { + clearInterval(heartbeat) + unsub() + resolve() + log.info("event disconnected") + }) }) }) - }) - }, - ) - .all("/*", async (c) => { - const path = c.req.path - const response = await proxy(`https://app.opencode.ai${path}`, { - ...c.req, - headers: { - ...c.req.raw.headers, - host: "app.opencode.ai", }, - }) - return response - }), + ) + .all("/*", async (c) => { + const path = c.req.path + const response = await proxy(`https://app.opencode.ai${path}`, { + ...c.req, + headers: { + ...c.req.raw.headers, + host: "app.opencode.ai", + }, + }) + return response + }) as unknown as Hono, ) export async function openapi() { - const result = await generateSpecs(App(), { + // Cast to break excessive type recursion from long route chains + const result = await generateSpecs(App() as Hono, { documentation: { info: { title: "opencode",