Просмотр исходного кода

chore: cleanup server routes (#8965)

Co-authored-by: Leka74 <[email protected]>
Co-authored-by: Leka74 <[email protected]>
Aiden Cline 1 месяц назад
Родитель
Сommit
8e0ddd1ac9

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

@@ -0,0 +1,92 @@
+import { Hono } from "hono"
+import { describeRoute, validator, resolver } from "hono-openapi"
+import z from "zod"
+import { Config } from "../../config/config"
+import { Provider } from "../../provider/provider"
+import { mapValues } from "remeda"
+import { errors } from "../error"
+import { Log } from "../../util/log"
+import { lazy } from "../../util/lazy"
+
+const log = Log.create({ service: "server" })
+
+export const ConfigRoutes = lazy(() =>
+  new Hono()
+    .get(
+      "/",
+      describeRoute({
+        summary: "Get configuration",
+        description: "Retrieve the current OpenCode configuration settings and preferences.",
+        operationId: "config.get",
+        responses: {
+          200: {
+            description: "Get config info",
+            content: {
+              "application/json": {
+                schema: resolver(Config.Info),
+              },
+            },
+          },
+        },
+      }),
+      async (c) => {
+        return c.json(await Config.get())
+      },
+    )
+    .patch(
+      "/",
+      describeRoute({
+        summary: "Update configuration",
+        description: "Update OpenCode configuration settings and preferences.",
+        operationId: "config.update",
+        responses: {
+          200: {
+            description: "Successfully updated config",
+            content: {
+              "application/json": {
+                schema: resolver(Config.Info),
+              },
+            },
+          },
+          ...errors(400),
+        },
+      }),
+      validator("json", Config.Info),
+      async (c) => {
+        const config = c.req.valid("json")
+        await Config.update(config)
+        return c.json(config)
+      },
+    )
+    .get(
+      "/providers",
+      describeRoute({
+        summary: "List config providers",
+        description: "Get a list of all configured AI providers and their default models.",
+        operationId: "config.providers",
+        responses: {
+          200: {
+            description: "List of providers",
+            content: {
+              "application/json": {
+                schema: resolver(
+                  z.object({
+                    providers: Provider.Info.array(),
+                    default: z.record(z.string(), z.string()),
+                  }),
+                ),
+              },
+            },
+          },
+        },
+      }),
+      async (c) => {
+        using _ = log.time("providers")
+        const providers = await Provider.list().then((x) => mapValues(x, (item) => item))
+        return c.json({
+          providers: Object.values(providers),
+          default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
+        })
+      },
+    ),
+)

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

@@ -0,0 +1,156 @@
+import { Hono } from "hono"
+import { describeRoute, validator, resolver } from "hono-openapi"
+import z from "zod"
+import { ToolRegistry } from "../../tool/registry"
+import { Worktree } from "../../worktree"
+import { Instance } from "../../project/instance"
+import { Project } from "../../project/project"
+import { MCP } from "../../mcp"
+import { zodToJsonSchema } from "zod-to-json-schema"
+import { errors } from "../error"
+import { lazy } from "../../util/lazy"
+
+export const ExperimentalRoutes = lazy(() => new Hono()
+  .get(
+    "/tool/ids",
+    describeRoute({
+      summary: "List tool IDs",
+      description:
+        "Get a list of all available tool IDs, including both built-in tools and dynamically registered tools.",
+      operationId: "tool.ids",
+      responses: {
+        200: {
+          description: "Tool IDs",
+          content: {
+            "application/json": {
+              schema: resolver(z.array(z.string()).meta({ ref: "ToolIDs" })),
+            },
+          },
+        },
+        ...errors(400),
+      },
+    }),
+    async (c) => {
+      return c.json(await ToolRegistry.ids())
+    },
+  )
+  .get(
+    "/tool",
+    describeRoute({
+      summary: "List tools",
+      description:
+        "Get a list of available tools with their JSON schema parameters for a specific provider and model combination.",
+      operationId: "tool.list",
+      responses: {
+        200: {
+          description: "Tools",
+          content: {
+            "application/json": {
+              schema: resolver(
+                z
+                  .array(
+                    z
+                      .object({
+                        id: z.string(),
+                        description: z.string(),
+                        parameters: z.any(),
+                      })
+                      .meta({ ref: "ToolListItem" }),
+                  )
+                  .meta({ ref: "ToolList" }),
+              ),
+            },
+          },
+        },
+        ...errors(400),
+      },
+    }),
+    validator(
+      "query",
+      z.object({
+        provider: z.string(),
+        model: z.string(),
+      }),
+    ),
+    async (c) => {
+      const { provider } = 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(
+    "/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(
+    "/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(
+    "/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())
+    },
+  )
+)

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

@@ -0,0 +1,196 @@
+import { Hono } from "hono"
+import { describeRoute, validator, resolver } from "hono-openapi"
+import z from "zod"
+import { File } from "../../file"
+import { Ripgrep } from "../../file/ripgrep"
+import { LSP } from "../../lsp"
+import { Instance } from "../../project/instance"
+import { lazy } from "../../util/lazy"
+
+export const FileRoutes = lazy(() => new Hono()
+  .get(
+    "/find",
+    describeRoute({
+      summary: "Find text",
+      description: "Search for text patterns across files in the project using ripgrep.",
+      operationId: "find.text",
+      responses: {
+        200: {
+          description: "Matches",
+          content: {
+            "application/json": {
+              schema: resolver(Ripgrep.Match.shape.data.array()),
+            },
+          },
+        },
+      },
+    }),
+    validator(
+      "query",
+      z.object({
+        pattern: z.string(),
+      }),
+    ),
+    async (c) => {
+      const pattern = c.req.valid("query").pattern
+      const result = await Ripgrep.search({
+        cwd: Instance.directory,
+        pattern,
+        limit: 10,
+      })
+      return c.json(result)
+    },
+  )
+  .get(
+    "/find/file",
+    describeRoute({
+      summary: "Find files",
+      description: "Search for files or directories by name or pattern in the project directory.",
+      operationId: "find.files",
+      responses: {
+        200: {
+          description: "File paths",
+          content: {
+            "application/json": {
+              schema: resolver(z.string().array()),
+            },
+          },
+        },
+      },
+    }),
+    validator(
+      "query",
+      z.object({
+        query: z.string(),
+        dirs: z.enum(["true", "false"]).optional(),
+        type: z.enum(["file", "directory"]).optional(),
+        limit: z.coerce.number().int().min(1).max(200).optional(),
+      }),
+    ),
+    async (c) => {
+      const query = c.req.valid("query").query
+      const dirs = c.req.valid("query").dirs
+      const type = c.req.valid("query").type
+      const limit = c.req.valid("query").limit
+      const results = await File.search({
+        query,
+        limit: limit ?? 10,
+        dirs: dirs !== "false",
+        type,
+      })
+      return c.json(results)
+    },
+  )
+  .get(
+    "/find/symbol",
+    describeRoute({
+      summary: "Find symbols",
+      description: "Search for workspace symbols like functions, classes, and variables using LSP.",
+      operationId: "find.symbols",
+      responses: {
+        200: {
+          description: "Symbols",
+          content: {
+            "application/json": {
+              schema: resolver(LSP.Symbol.array()),
+            },
+          },
+        },
+      },
+    }),
+    validator(
+      "query",
+      z.object({
+        query: z.string(),
+      }),
+    ),
+    async (c) => {
+      /*
+      const query = c.req.valid("query").query
+      const result = await LSP.workspaceSymbol(query)
+      return c.json(result)
+      */
+      return c.json([])
+    },
+  )
+  .get(
+    "/file",
+    describeRoute({
+      summary: "List files",
+      description: "List files and directories in a specified path.",
+      operationId: "file.list",
+      responses: {
+        200: {
+          description: "Files and directories",
+          content: {
+            "application/json": {
+              schema: resolver(File.Node.array()),
+            },
+          },
+        },
+      },
+    }),
+    validator(
+      "query",
+      z.object({
+        path: z.string(),
+      }),
+    ),
+    async (c) => {
+      const path = c.req.valid("query").path
+      const content = await File.list(path)
+      return c.json(content)
+    },
+  )
+  .get(
+    "/file/content",
+    describeRoute({
+      summary: "Read file",
+      description: "Read the content of a specified file.",
+      operationId: "file.read",
+      responses: {
+        200: {
+          description: "File content",
+          content: {
+            "application/json": {
+              schema: resolver(File.Content),
+            },
+          },
+        },
+      },
+    }),
+    validator(
+      "query",
+      z.object({
+        path: z.string(),
+      }),
+    ),
+    async (c) => {
+      const path = c.req.valid("query").path
+      const content = await File.read(path)
+      return c.json(content)
+    },
+  )
+  .get(
+    "/file/status",
+    describeRoute({
+      summary: "Get file status",
+      description: "Get the git status of all files in the project.",
+      operationId: "file.status",
+      responses: {
+        200: {
+          description: "File status",
+          content: {
+            "application/json": {
+              schema: resolver(File.Info.array()),
+            },
+          },
+        },
+      },
+    }),
+    async (c) => {
+      const content = await File.status()
+      return c.json(content)
+    },
+  )
+)

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

@@ -0,0 +1,134 @@
+import { Hono } from "hono"
+import { describeRoute, resolver } from "hono-openapi"
+import { streamSSE } from "hono/streaming"
+import z from "zod"
+import { BusEvent } from "@/bus/bus-event"
+import { GlobalBus } from "@/bus/global"
+import { Instance } from "../../project/instance"
+import { Installation } from "@/installation"
+import { Log } from "../../util/log"
+import { lazy } from "../../util/lazy"
+
+const log = Log.create({ service: "server" })
+
+export const GlobalDisposedEvent = BusEvent.define("global.disposed", z.object({}))
+
+export const GlobalRoutes = lazy(() => new Hono()
+  .get(
+    "/health",
+    describeRoute({
+      summary: "Get health",
+      description: "Get health information about the OpenCode server.",
+      operationId: "global.health",
+      responses: {
+        200: {
+          description: "Health information",
+          content: {
+            "application/json": {
+              schema: resolver(z.object({ healthy: z.literal(true), version: z.string() })),
+            },
+          },
+        },
+      },
+    }),
+    async (c) => {
+      return c.json({ healthy: true, version: Installation.VERSION })
+    },
+  )
+  .get(
+    "/event",
+    describeRoute({
+      summary: "Get global events",
+      description: "Subscribe to global events from the OpenCode system using server-sent events.",
+      operationId: "global.event",
+      responses: {
+        200: {
+          description: "Event stream",
+          content: {
+            "text/event-stream": {
+              schema: resolver(
+                z
+                  .object({
+                    directory: z.string(),
+                    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(() => {
+          stream.writeSSE({
+            data: JSON.stringify({
+              payload: {
+                type: "server.heartbeat",
+                properties: {},
+              },
+            }),
+          })
+        }, 30000)
+
+        await new Promise<void>((resolve) => {
+          stream.onAbort(() => {
+            clearInterval(heartbeat)
+            GlobalBus.off("event", handler)
+            resolve()
+            log.info("global event disconnected")
+          })
+        })
+      })
+    },
+  )
+  .post(
+    "/dispose",
+    describeRoute({
+      summary: "Dispose instance",
+      description: "Clean up and dispose all OpenCode instances, releasing all resources.",
+      operationId: "global.dispose",
+      responses: {
+        200: {
+          description: "Global disposed",
+          content: {
+            "application/json": {
+              schema: resolver(z.boolean()),
+            },
+          },
+        },
+      },
+    }),
+    async (c) => {
+      await Instance.disposeAll()
+      GlobalBus.emit("event", {
+        directory: "global",
+        payload: {
+          type: GlobalDisposedEvent.type,
+          properties: {},
+        },
+      })
+      return c.json(true)
+    },
+  )
+)

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

@@ -0,0 +1,224 @@
+import { Hono } from "hono"
+import { describeRoute, validator, resolver } from "hono-openapi"
+import z from "zod"
+import { MCP } from "../../mcp"
+import { Config } from "../../config/config"
+import { errors } from "../error"
+import { lazy } from "../../util/lazy"
+
+export const McpRoutes = lazy(() => new Hono()
+  .get(
+    "/",
+    describeRoute({
+      summary: "Get MCP status",
+      description: "Get the status of all Model Context Protocol (MCP) servers.",
+      operationId: "mcp.status",
+      responses: {
+        200: {
+          description: "MCP server status",
+          content: {
+            "application/json": {
+              schema: resolver(z.record(z.string(), MCP.Status)),
+            },
+          },
+        },
+      },
+    }),
+    async (c) => {
+      return c.json(await MCP.status())
+    },
+  )
+  .post(
+    "/",
+    describeRoute({
+      summary: "Add MCP server",
+      description: "Dynamically add a new Model Context Protocol (MCP) server to the system.",
+      operationId: "mcp.add",
+      responses: {
+        200: {
+          description: "MCP server added successfully",
+          content: {
+            "application/json": {
+              schema: resolver(z.record(z.string(), MCP.Status)),
+            },
+          },
+        },
+        ...errors(400),
+      },
+    }),
+    validator(
+      "json",
+      z.object({
+        name: z.string(),
+        config: Config.Mcp,
+      }),
+    ),
+    async (c) => {
+      const { name, config } = c.req.valid("json")
+      const result = await MCP.add(name, config)
+      return c.json(result.status)
+    },
+  )
+  .post(
+    "/:name/auth",
+    describeRoute({
+      summary: "Start MCP OAuth",
+      description: "Start OAuth authentication flow for a Model Context Protocol (MCP) server.",
+      operationId: "mcp.auth.start",
+      responses: {
+        200: {
+          description: "OAuth flow started",
+          content: {
+            "application/json": {
+              schema: resolver(
+                z.object({
+                  authorizationUrl: z.string().describe("URL to open in browser for authorization"),
+                }),
+              ),
+            },
+          },
+        },
+        ...errors(400, 404),
+      },
+    }),
+    async (c) => {
+      const name = c.req.param("name")
+      const supportsOAuth = await MCP.supportsOAuth(name)
+      if (!supportsOAuth) {
+        return c.json({ error: `MCP server ${name} does not support OAuth` }, 400)
+      }
+      const result = await MCP.startAuth(name)
+      return c.json(result)
+    },
+  )
+  .post(
+    "/:name/auth/callback",
+    describeRoute({
+      summary: "Complete MCP OAuth",
+      description:
+        "Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code.",
+      operationId: "mcp.auth.callback",
+      responses: {
+        200: {
+          description: "OAuth authentication completed",
+          content: {
+            "application/json": {
+              schema: resolver(MCP.Status),
+            },
+          },
+        },
+        ...errors(400, 404),
+      },
+    }),
+    validator(
+      "json",
+      z.object({
+        code: z.string().describe("Authorization code from OAuth callback"),
+      }),
+    ),
+    async (c) => {
+      const name = c.req.param("name")
+      const { code } = c.req.valid("json")
+      const status = await MCP.finishAuth(name, code)
+      return c.json(status)
+    },
+  )
+  .post(
+    "/:name/auth/authenticate",
+    describeRoute({
+      summary: "Authenticate MCP OAuth",
+      description: "Start OAuth flow and wait for callback (opens browser)",
+      operationId: "mcp.auth.authenticate",
+      responses: {
+        200: {
+          description: "OAuth authentication completed",
+          content: {
+            "application/json": {
+              schema: resolver(MCP.Status),
+            },
+          },
+        },
+        ...errors(400, 404),
+      },
+    }),
+    async (c) => {
+      const name = c.req.param("name")
+      const supportsOAuth = await MCP.supportsOAuth(name)
+      if (!supportsOAuth) {
+        return c.json({ error: `MCP server ${name} does not support OAuth` }, 400)
+      }
+      const status = await MCP.authenticate(name)
+      return c.json(status)
+    },
+  )
+  .delete(
+    "/:name/auth",
+    describeRoute({
+      summary: "Remove MCP OAuth",
+      description: "Remove OAuth credentials for an MCP server",
+      operationId: "mcp.auth.remove",
+      responses: {
+        200: {
+          description: "OAuth credentials removed",
+          content: {
+            "application/json": {
+              schema: resolver(z.object({ success: z.literal(true) })),
+            },
+          },
+        },
+        ...errors(404),
+      },
+    }),
+    async (c) => {
+      const name = c.req.param("name")
+      await MCP.removeAuth(name)
+      return c.json({ success: true as const })
+    },
+  )
+  .post(
+    "/:name/connect",
+    describeRoute({
+      description: "Connect an MCP server",
+      operationId: "mcp.connect",
+      responses: {
+        200: {
+          description: "MCP server connected successfully",
+          content: {
+            "application/json": {
+              schema: resolver(z.boolean()),
+            },
+          },
+        },
+      },
+    }),
+    validator("param", z.object({ name: z.string() })),
+    async (c) => {
+      const { name } = c.req.valid("param")
+      await MCP.connect(name)
+      return c.json(true)
+    },
+  )
+  .post(
+    "/:name/disconnect",
+    describeRoute({
+      description: "Disconnect an MCP server",
+      operationId: "mcp.disconnect",
+      responses: {
+        200: {
+          description: "MCP server disconnected successfully",
+          content: {
+            "application/json": {
+              schema: resolver(z.boolean()),
+            },
+          },
+        },
+      },
+    }),
+    validator("param", z.object({ name: z.string() })),
+    async (c) => {
+      const { name } = c.req.valid("param")
+      await MCP.disconnect(name)
+      return c.json(true)
+    },
+  )
+)

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

@@ -0,0 +1,67 @@
+import { Hono } from "hono"
+import { describeRoute, validator, resolver } from "hono-openapi"
+import z from "zod"
+import { PermissionNext } from "@/permission/next"
+import { errors } from "../error"
+import { lazy } from "../../util/lazy"
+
+export const PermissionRoutes = lazy(() => new Hono()
+  .post(
+    "/:requestID/reply",
+    describeRoute({
+      summary: "Respond to permission request",
+      description: "Approve or deny a permission request from the AI assistant.",
+      operationId: "permission.reply",
+      responses: {
+        200: {
+          description: "Permission processed successfully",
+          content: {
+            "application/json": {
+              schema: resolver(z.boolean()),
+            },
+          },
+        },
+        ...errors(400, 404),
+      },
+    }),
+    validator(
+      "param",
+      z.object({
+        requestID: 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(
+    "/",
+    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)
+    },
+  )
+)

+ 6 - 4
packages/opencode/src/server/project.ts → packages/opencode/src/server/routes/project.ts

@@ -1,12 +1,13 @@
 import { Hono } from "hono"
 import { describeRoute, validator } from "hono-openapi"
 import { resolver } from "hono-openapi"
-import { Instance } from "../project/instance"
-import { Project } from "../project/project"
+import { Instance } from "../../project/instance"
+import { Project } from "../../project/project"
 import z from "zod"
-import { errors } from "./error"
+import { errors } from "../error"
+import { lazy } from "../../util/lazy"
 
-export const ProjectRoute = new Hono()
+export const ProjectRoutes = lazy(() => new Hono()
   .get(
     "/",
     describeRoute({
@@ -77,3 +78,4 @@ export const ProjectRoute = new Hono()
       return c.json(project)
     },
   )
+)

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

@@ -0,0 +1,164 @@
+import { Hono } from "hono"
+import { describeRoute, validator, resolver } from "hono-openapi"
+import z from "zod"
+import { Config } from "../../config/config"
+import { Provider } from "../../provider/provider"
+import { ModelsDev } from "../../provider/models"
+import { ProviderAuth } from "../../provider/auth"
+import { mapValues } from "remeda"
+import { errors } from "../error"
+import { lazy } from "../../util/lazy"
+
+export const ProviderRoutes = lazy(() => new Hono()
+  .get(
+    "/",
+    describeRoute({
+      summary: "List providers",
+      description: "Get a list of all available AI providers, including both available and connected ones.",
+      operationId: "provider.list",
+      responses: {
+        200: {
+          description: "List of providers",
+          content: {
+            "application/json": {
+              schema: resolver(
+                z.object({
+                  all: 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<string, (typeof allProviders)[string]> = {}
+      for (const [key, value] of Object.entries(allProviders)) {
+        if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
+          filteredProviders[key] = value
+        }
+      }
+
+      const connected = await Provider.list()
+      const providers = Object.assign(
+        mapValues(filteredProviders, (x) => Provider.fromModelsDevProvider(x)),
+        connected,
+      )
+      return c.json({
+        all: Object.values(providers),
+        default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
+        connected: Object.keys(connected),
+      })
+    },
+  )
+  .get(
+    "/auth",
+    describeRoute({
+      summary: "Get provider auth methods",
+      description: "Retrieve available authentication methods for all AI providers.",
+      operationId: "provider.auth",
+      responses: {
+        200: {
+          description: "Provider auth methods",
+          content: {
+            "application/json": {
+              schema: resolver(z.record(z.string(), z.array(ProviderAuth.Method))),
+            },
+          },
+        },
+      },
+    }),
+    async (c) => {
+      return c.json(await ProviderAuth.methods())
+    },
+  )
+  .post(
+    "/:providerID/oauth/authorize",
+    describeRoute({
+      summary: "OAuth authorize",
+      description: "Initiate OAuth authorization for a specific AI provider to get an authorization URL.",
+      operationId: "provider.oauth.authorize",
+      responses: {
+        200: {
+          description: "Authorization URL and method",
+          content: {
+            "application/json": {
+              schema: resolver(ProviderAuth.Authorization.optional()),
+            },
+          },
+        },
+        ...errors(400),
+      },
+    }),
+    validator(
+      "param",
+      z.object({
+        providerID: 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(
+    "/: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)
+    },
+  )
+)

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

@@ -0,0 +1,168 @@
+import { Hono } from "hono"
+import { describeRoute, validator, resolver } from "hono-openapi"
+import { upgradeWebSocket } from "hono/bun"
+import z from "zod"
+import { Pty } from "@/pty"
+import { Storage } from "../../storage/storage"
+import { errors } from "../error"
+import { lazy } from "../../util/lazy"
+
+export const PtyRoutes = lazy(() => new Hono()
+  .get(
+    "/",
+    describeRoute({
+      summary: "List PTY sessions",
+      description: "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.",
+      operationId: "pty.list",
+      responses: {
+        200: {
+          description: "List of sessions",
+          content: {
+            "application/json": {
+              schema: resolver(Pty.Info.array()),
+            },
+          },
+        },
+      },
+    }),
+    async (c) => {
+      return c.json(Pty.list())
+    },
+  )
+  .post(
+    "/",
+    describeRoute({
+      summary: "Create PTY session",
+      description: "Create a new pseudo-terminal (PTY) session for running shell commands and processes.",
+      operationId: "pty.create",
+      responses: {
+        200: {
+          description: "Created session",
+          content: {
+            "application/json": {
+              schema: resolver(Pty.Info),
+            },
+          },
+        },
+        ...errors(400),
+      },
+    }),
+    validator("json", Pty.CreateInput),
+    async (c) => {
+      const info = await Pty.create(c.req.valid("json"))
+      return c.json(info)
+    },
+  )
+  .get(
+    "/:ptyID",
+    describeRoute({
+      summary: "Get PTY session",
+      description: "Retrieve detailed information about a specific pseudo-terminal (PTY) session.",
+      operationId: "pty.get",
+      responses: {
+        200: {
+          description: "Session info",
+          content: {
+            "application/json": {
+              schema: resolver(Pty.Info),
+            },
+          },
+        },
+        ...errors(404),
+      },
+    }),
+    validator("param", z.object({ ptyID: 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(
+    "/: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(
+    "/: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(
+    "/: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<typeof Pty.connect>
+      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()
+        },
+      }
+    }),
+  )
+)

+ 5 - 3
packages/opencode/src/server/question.ts → packages/opencode/src/server/routes/question.ts

@@ -1,11 +1,12 @@
 import { Hono } from "hono"
 import { describeRoute, validator } from "hono-openapi"
 import { resolver } from "hono-openapi"
-import { Question } from "../question"
+import { Question } from "../../question"
 import z from "zod"
-import { errors } from "./error"
+import { errors } from "../error"
+import { lazy } from "../../util/lazy"
 
-export const QuestionRoute = new Hono()
+export const QuestionRoutes = lazy(() => new Hono()
   .get(
     "/",
     describeRoute({
@@ -93,3 +94,4 @@ export const QuestionRoute = new Hono()
       return c.json(true)
     },
   )
+)

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

@@ -0,0 +1,935 @@
+import { Hono } from "hono"
+import { stream } from "hono/streaming"
+import { describeRoute, validator, resolver } from "hono-openapi"
+import z from "zod"
+import { Session } from "../../session"
+import { MessageV2 } from "../../session/message-v2"
+import { SessionPrompt } from "../../session/prompt"
+import { SessionCompaction } from "../../session/compaction"
+import { SessionRevert } from "../../session/revert"
+import { SessionStatus } from "@/session/status"
+import { SessionSummary } from "@/session/summary"
+import { Todo } from "../../session/todo"
+import { Agent } from "../../agent/agent"
+import { Snapshot } from "@/snapshot"
+import { Log } from "../../util/log"
+import { PermissionNext } from "@/permission/next"
+import { errors } from "../error"
+import { lazy } from "../../util/lazy"
+
+const log = Log.create({ service: "server" })
+
+export const SessionRoutes = lazy(() =>
+  new Hono()
+    .get(
+      "/",
+      describeRoute({
+        summary: "List sessions",
+        description: "Get a list of all OpenCode sessions, sorted by most recently updated.",
+        operationId: "session.list",
+        responses: {
+          200: {
+            description: "List of sessions",
+            content: {
+              "application/json": {
+                schema: resolver(Session.Info.array()),
+              },
+            },
+          },
+        },
+      }),
+      validator(
+        "query",
+        z.object({
+          directory: z.string().optional().meta({ description: "Filter sessions by project directory" }),
+          roots: z.coerce.boolean().optional().meta({ description: "Only return root sessions (no parentID)" }),
+          start: z.coerce
+            .number()
+            .optional()
+            .meta({ description: "Filter sessions updated on or after this timestamp (milliseconds since epoch)" }),
+          search: z.string().optional().meta({ description: "Filter sessions by title (case-insensitive)" }),
+          limit: z.coerce.number().optional().meta({ description: "Maximum number of sessions to return" }),
+        }),
+      ),
+      async (c) => {
+        const query = c.req.valid("query")
+        const term = query.search?.toLowerCase()
+        const sessions: Session.Info[] = []
+        for await (const session of Session.list()) {
+          if (query.directory !== undefined && session.directory !== query.directory) continue
+          if (query.roots && session.parentID) continue
+          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(
+      "/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(
+      "/: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(
+      "/:sessionID/children",
+      describeRoute({
+        summary: "Get session children",
+        tags: ["Session"],
+        description: "Retrieve all child sessions that were forked from the specified parent session.",
+        operationId: "session.children",
+        responses: {
+          200: {
+            description: "List of children",
+            content: {
+              "application/json": {
+                schema: resolver(Session.Info.array()),
+              },
+            },
+          },
+          ...errors(400, 404),
+        },
+      }),
+      validator(
+        "param",
+        z.object({
+          sessionID: Session.children.schema,
+        }),
+      ),
+      async (c) => {
+        const sessionID = c.req.valid("param").sessionID
+        const session = await Session.children(sessionID)
+        return c.json(session)
+      },
+    )
+    .get(
+      "/:sessionID/todo",
+      describeRoute({
+        summary: "Get session todos",
+        description: "Retrieve the todo list associated with a specific session, showing tasks and action items.",
+        operationId: "session.todo",
+        responses: {
+          200: {
+            description: "Todo list",
+            content: {
+              "application/json": {
+                schema: resolver(Todo.Info.array()),
+              },
+            },
+          },
+          ...errors(400, 404),
+        },
+      }),
+      validator(
+        "param",
+        z.object({
+          sessionID: 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(
+      "/",
+      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(
+      "/:sessionID",
+      describeRoute({
+        summary: "Delete session",
+        description: "Delete a session and permanently remove all associated data, including messages and history.",
+        operationId: "session.delete",
+        responses: {
+          200: {
+            description: "Successfully deleted session",
+            content: {
+              "application/json": {
+                schema: resolver(z.boolean()),
+              },
+            },
+          },
+          ...errors(400, 404),
+        },
+      }),
+      validator(
+        "param",
+        z.object({
+          sessionID: Session.remove.schema,
+        }),
+      ),
+      async (c) => {
+        const sessionID = c.req.valid("param").sessionID
+        await Session.remove(sessionID)
+        return c.json(true)
+      },
+    )
+    .patch(
+      "/:sessionID",
+      describeRoute({
+        summary: "Update session",
+        description: "Update properties of an existing session, such as title or other metadata.",
+        operationId: "session.update",
+        responses: {
+          200: {
+            description: "Successfully updated session",
+            content: {
+              "application/json": {
+                schema: resolver(Session.Info),
+              },
+            },
+          },
+          ...errors(400, 404),
+        },
+      }),
+      validator(
+        "param",
+        z.object({
+          sessionID: 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
+          }
+          if (updates.time?.archived !== undefined) session.time.archived = updates.time.archived
+        })
+
+        return c.json(updatedSession)
+      },
+    )
+    .post(
+      "/:sessionID/init",
+      describeRoute({
+        summary: "Initialize session",
+        description:
+          "Analyze the current application and create an AGENTS.md file with project-specific agent configurations.",
+        operationId: "session.init",
+        responses: {
+          200: {
+            description: "200",
+            content: {
+              "application/json": {
+                schema: resolver(z.boolean()),
+              },
+            },
+          },
+          ...errors(400, 404),
+        },
+      }),
+      validator(
+        "param",
+        z.object({
+          sessionID: 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(
+      "/:sessionID/fork",
+      describeRoute({
+        summary: "Fork session",
+        description: "Create a new session by forking an existing session at a specific message point.",
+        operationId: "session.fork",
+        responses: {
+          200: {
+            description: "200",
+            content: {
+              "application/json": {
+                schema: resolver(Session.Info),
+              },
+            },
+          },
+        },
+      }),
+      validator(
+        "param",
+        z.object({
+          sessionID: Session.fork.schema.shape.sessionID,
+        }),
+      ),
+      validator("json", Session.fork.schema.omit({ sessionID: true })),
+      async (c) => {
+        const sessionID = c.req.valid("param").sessionID
+        const body = c.req.valid("json")
+        const result = await Session.fork({ ...body, sessionID })
+        return c.json(result)
+      },
+    )
+    .post(
+      "/:sessionID/abort",
+      describeRoute({
+        summary: "Abort session",
+        description: "Abort an active session and stop any ongoing AI processing or command execution.",
+        operationId: "session.abort",
+        responses: {
+          200: {
+            description: "Aborted session",
+            content: {
+              "application/json": {
+                schema: resolver(z.boolean()),
+              },
+            },
+          },
+          ...errors(400, 404),
+        },
+      }),
+      validator(
+        "param",
+        z.object({
+          sessionID: z.string(),
+        }),
+      ),
+      async (c) => {
+        SessionPrompt.cancel(c.req.valid("param").sessionID)
+        return c.json(true)
+      },
+    )
+    .post(
+      "/:sessionID/share",
+      describeRoute({
+        summary: "Share session",
+        description: "Create a shareable link for a session, allowing others to view the conversation.",
+        operationId: "session.share",
+        responses: {
+          200: {
+            description: "Successfully shared session",
+            content: {
+              "application/json": {
+                schema: resolver(Session.Info),
+              },
+            },
+          },
+          ...errors(400, 404),
+        },
+      }),
+      validator(
+        "param",
+        z.object({
+          sessionID: 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(
+      "/:sessionID/diff",
+      describeRoute({
+        summary: "Get message diff",
+        description: "Get the file changes (diff) that resulted from a specific user message in the session.",
+        operationId: "session.diff",
+        responses: {
+          200: {
+            description: "Successfully retrieved diff",
+            content: {
+              "application/json": {
+                schema: resolver(Snapshot.FileDiff.array()),
+              },
+            },
+          },
+        },
+      }),
+      validator(
+        "param",
+        z.object({
+          sessionID: SessionSummary.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(
+      "/:sessionID/share",
+      describeRoute({
+        summary: "Unshare session",
+        description: "Remove the shareable link for a session, making it private again.",
+        operationId: "session.unshare",
+        responses: {
+          200: {
+            description: "Successfully unshared session",
+            content: {
+              "application/json": {
+                schema: resolver(Session.Info),
+              },
+            },
+          },
+          ...errors(400, 404),
+        },
+      }),
+      validator(
+        "param",
+        z.object({
+          sessionID: 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(
+      "/: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(
+      "/: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(
+      "/: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(
+      "/: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(
+      "/: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(
+      "/: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(
+      "/: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(
+      "/: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(
+      "/: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(
+      "/: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(
+      "/: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(
+      "/: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)
+      },
+    ),
+)

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

@@ -0,0 +1,375 @@
+import { Hono, type Context } from "hono"
+import { describeRoute, validator, resolver } from "hono-openapi"
+import z from "zod"
+import { Bus } from "../../bus"
+import { Session } from "../../session"
+import { TuiEvent } from "@/cli/cmd/tui/event"
+import { AsyncQueue } from "../../util/queue"
+import { errors } from "../error"
+import { lazy } from "../../util/lazy"
+
+const TuiRequest = z.object({
+  path: z.string(),
+  body: z.any(),
+})
+
+type TuiRequest = z.infer<typeof TuiRequest>
+
+const request = new AsyncQueue<TuiRequest>()
+const response = new AsyncQueue<any>()
+
+export async function callTui(ctx: Context) {
+  const body = await ctx.req.json()
+  request.push({
+    path: ctx.req.path,
+    body,
+  })
+  return response.next()
+}
+
+const TuiControlRoutes = new Hono()
+  .get(
+    "/next",
+    describeRoute({
+      summary: "Get next TUI request",
+      description: "Retrieve the next TUI (Terminal User Interface) request from the queue for processing.",
+      operationId: "tui.control.next",
+      responses: {
+        200: {
+          description: "Next TUI request",
+          content: {
+            "application/json": {
+              schema: resolver(TuiRequest),
+            },
+          },
+        },
+      },
+    }),
+    async (c) => {
+      const req = await request.next()
+      return c.json(req)
+    },
+  )
+  .post(
+    "/response",
+    describeRoute({
+      summary: "Submit TUI response",
+      description: "Submit a response to the TUI request queue to complete a pending request.",
+      operationId: "tui.control.response",
+      responses: {
+        200: {
+          description: "Response submitted successfully",
+          content: {
+            "application/json": {
+              schema: resolver(z.boolean()),
+            },
+          },
+        },
+      },
+    }),
+    validator("json", z.any()),
+    async (c) => {
+      const body = c.req.valid("json")
+      response.push(body)
+      return c.json(true)
+    },
+  )
+
+export const TuiRoutes = lazy(() =>
+  new Hono()
+    .post(
+      "/append-prompt",
+      describeRoute({
+        summary: "Append TUI prompt",
+        description: "Append prompt to the TUI",
+        operationId: "tui.appendPrompt",
+        responses: {
+          200: {
+            description: "Prompt processed successfully",
+            content: {
+              "application/json": {
+                schema: resolver(z.boolean()),
+              },
+            },
+          },
+          ...errors(400),
+        },
+      }),
+      validator("json", TuiEvent.PromptAppend.properties),
+      async (c) => {
+        await Bus.publish(TuiEvent.PromptAppend, c.req.valid("json"))
+        return c.json(true)
+      },
+    )
+    .post(
+      "/open-help",
+      describeRoute({
+        summary: "Open help dialog",
+        description: "Open the help dialog in the TUI to display user assistance information.",
+        operationId: "tui.openHelp",
+        responses: {
+          200: {
+            description: "Help dialog opened successfully",
+            content: {
+              "application/json": {
+                schema: resolver(z.boolean()),
+              },
+            },
+          },
+        },
+      }),
+      async (c) => {
+        // TODO: open dialog
+        return c.json(true)
+      },
+    )
+    .post(
+      "/open-sessions",
+      describeRoute({
+        summary: "Open sessions dialog",
+        description: "Open the session dialog",
+        operationId: "tui.openSessions",
+        responses: {
+          200: {
+            description: "Session dialog opened successfully",
+            content: {
+              "application/json": {
+                schema: resolver(z.boolean()),
+              },
+            },
+          },
+        },
+      }),
+      async (c) => {
+        await Bus.publish(TuiEvent.CommandExecute, {
+          command: "session.list",
+        })
+        return c.json(true)
+      },
+    )
+    .post(
+      "/open-themes",
+      describeRoute({
+        summary: "Open themes dialog",
+        description: "Open the theme dialog",
+        operationId: "tui.openThemes",
+        responses: {
+          200: {
+            description: "Theme dialog opened successfully",
+            content: {
+              "application/json": {
+                schema: resolver(z.boolean()),
+              },
+            },
+          },
+        },
+      }),
+      async (c) => {
+        await Bus.publish(TuiEvent.CommandExecute, {
+          command: "session.list",
+        })
+        return c.json(true)
+      },
+    )
+    .post(
+      "/open-models",
+      describeRoute({
+        summary: "Open models dialog",
+        description: "Open the model dialog",
+        operationId: "tui.openModels",
+        responses: {
+          200: {
+            description: "Model dialog opened successfully",
+            content: {
+              "application/json": {
+                schema: resolver(z.boolean()),
+              },
+            },
+          },
+        },
+      }),
+      async (c) => {
+        await Bus.publish(TuiEvent.CommandExecute, {
+          command: "model.list",
+        })
+        return c.json(true)
+      },
+    )
+    .post(
+      "/submit-prompt",
+      describeRoute({
+        summary: "Submit TUI prompt",
+        description: "Submit the prompt",
+        operationId: "tui.submitPrompt",
+        responses: {
+          200: {
+            description: "Prompt submitted successfully",
+            content: {
+              "application/json": {
+                schema: resolver(z.boolean()),
+              },
+            },
+          },
+        },
+      }),
+      async (c) => {
+        await Bus.publish(TuiEvent.CommandExecute, {
+          command: "prompt.submit",
+        })
+        return c.json(true)
+      },
+    )
+    .post(
+      "/clear-prompt",
+      describeRoute({
+        summary: "Clear TUI prompt",
+        description: "Clear the prompt",
+        operationId: "tui.clearPrompt",
+        responses: {
+          200: {
+            description: "Prompt cleared successfully",
+            content: {
+              "application/json": {
+                schema: resolver(z.boolean()),
+              },
+            },
+          },
+        },
+      }),
+      async (c) => {
+        await Bus.publish(TuiEvent.CommandExecute, {
+          command: "prompt.clear",
+        })
+        return c.json(true)
+      },
+    )
+    .post(
+      "/execute-command",
+      describeRoute({
+        summary: "Execute TUI command",
+        description: "Execute a TUI command (e.g. agent_cycle)",
+        operationId: "tui.executeCommand",
+        responses: {
+          200: {
+            description: "Command executed successfully",
+            content: {
+              "application/json": {
+                schema: resolver(z.boolean()),
+              },
+            },
+          },
+          ...errors(400),
+        },
+      }),
+      validator("json", z.object({ command: z.string() })),
+      async (c) => {
+        const command = c.req.valid("json").command
+        await Bus.publish(TuiEvent.CommandExecute, {
+          // @ts-expect-error
+          command: {
+            session_new: "session.new",
+            session_share: "session.share",
+            session_interrupt: "session.interrupt",
+            session_compact: "session.compact",
+            messages_page_up: "session.page.up",
+            messages_page_down: "session.page.down",
+            messages_half_page_up: "session.half.page.up",
+            messages_half_page_down: "session.half.page.down",
+            messages_first: "session.first",
+            messages_last: "session.last",
+            agent_cycle: "agent.cycle",
+          }[command],
+        })
+        return c.json(true)
+      },
+    )
+    .post(
+      "/show-toast",
+      describeRoute({
+        summary: "Show TUI toast",
+        description: "Show a toast notification in the TUI",
+        operationId: "tui.showToast",
+        responses: {
+          200: {
+            description: "Toast notification shown successfully",
+            content: {
+              "application/json": {
+                schema: resolver(z.boolean()),
+              },
+            },
+          },
+        },
+      }),
+      validator("json", TuiEvent.ToastShow.properties),
+      async (c) => {
+        await Bus.publish(TuiEvent.ToastShow, c.req.valid("json"))
+        return c.json(true)
+      },
+    )
+    .post(
+      "/publish",
+      describeRoute({
+        summary: "Publish TUI event",
+        description: "Publish a TUI event",
+        operationId: "tui.publish",
+        responses: {
+          200: {
+            description: "Event published successfully",
+            content: {
+              "application/json": {
+                schema: resolver(z.boolean()),
+              },
+            },
+          },
+          ...errors(400),
+        },
+      }),
+      validator(
+        "json",
+        z.union(
+          Object.values(TuiEvent).map((def) => {
+            return z
+              .object({
+                type: z.literal(def.type),
+                properties: def.properties,
+              })
+              .meta({
+                ref: "Event" + "." + def.type,
+              })
+          }),
+        ),
+      ),
+      async (c) => {
+        const evt = c.req.valid("json")
+        await Bus.publish(Object.values(TuiEvent).find((def) => def.type === evt.type)!, evt.properties)
+        return c.json(true)
+      },
+    )
+    .post(
+      "/select-session",
+      describeRoute({
+        summary: "Select session",
+        description: "Navigate the TUI to display the specified session.",
+        operationId: "tui.selectSession",
+        responses: {
+          200: {
+            description: "Session selected successfully",
+            content: {
+              "application/json": {
+                schema: resolver(z.boolean()),
+              },
+            },
+          },
+          ...errors(400, 404),
+        },
+      }),
+      validator("json", TuiEvent.SessionSelect.properties),
+      async (c) => {
+        const { sessionID } = c.req.valid("json")
+        await Session.get(sessionID)
+        await Bus.publish(TuiEvent.SessionSelect, { sessionID })
+        return c.json(true)
+      },
+    )
+    .route("/control", TuiControlRoutes),
+)

Разница между файлами не показана из-за своего большого размера
+ 150 - 2185
packages/opencode/src/server/server.ts


+ 0 - 71
packages/opencode/src/server/tui.ts

@@ -1,71 +0,0 @@
-import { Hono, type Context } from "hono"
-import { describeRoute, resolver, validator } from "hono-openapi"
-import { z } from "zod"
-import { AsyncQueue } from "../util/queue"
-
-const TuiRequest = z.object({
-  path: z.string(),
-  body: z.any(),
-})
-
-type TuiRequest = z.infer<typeof TuiRequest>
-
-const request = new AsyncQueue<TuiRequest>()
-const response = new AsyncQueue<any>()
-
-export async function callTui(ctx: Context) {
-  const body = await ctx.req.json()
-  request.push({
-    path: ctx.req.path,
-    body,
-  })
-  return response.next()
-}
-
-export const TuiRoute = new Hono()
-  .get(
-    "/next",
-    describeRoute({
-      summary: "Get next TUI request",
-      description: "Retrieve the next TUI (Terminal User Interface) request from the queue for processing.",
-      operationId: "tui.control.next",
-      responses: {
-        200: {
-          description: "Next TUI request",
-          content: {
-            "application/json": {
-              schema: resolver(TuiRequest),
-            },
-          },
-        },
-      },
-    }),
-    async (c) => {
-      const req = await request.next()
-      return c.json(req)
-    },
-  )
-  .post(
-    "/response",
-    describeRoute({
-      summary: "Submit TUI response",
-      description: "Submit a response to the TUI request queue to complete a pending request.",
-      operationId: "tui.control.response",
-      responses: {
-        200: {
-          description: "Response submitted successfully",
-          content: {
-            "application/json": {
-              schema: resolver(z.boolean()),
-            },
-          },
-        },
-      },
-    }),
-    validator("json", z.any()),
-    async (c) => {
-      const body = c.req.valid("json")
-      response.push(body)
-      return c.json(true)
-    },
-  )

Некоторые файлы не были показаны из-за большого количества измененных файлов