import { Hono } from "hono" import { stream } from "hono/streaming" import { describeRoute, validator, resolver } from "hono-openapi" import { SessionID, MessageID, PartID } from "@/session/schema" import z from "zod" import { Session } from "../../session" import { MessageV2 } from "../../session/message-v2" import { SessionPrompt } from "../../session/prompt" import { SessionRunState } from "@/session/run-state" import { SessionCompaction } from "../../session/compaction" import { SessionRevert } from "../../session/revert" import { SessionShare } from "@/share/session" import { SessionStatus } from "@/session/status" import { SessionSummary } from "@/session/summary" import { Todo } from "../../session/todo" import { Effect } from "effect" import { AppRuntime } from "../../effect/app-runtime" import { Agent } from "../../agent/agent" import { Snapshot } from "@/snapshot" import { Command } from "../../command" import { Log } from "../../util/log" import { Permission } from "@/permission" import { PermissionID } from "@/permission/schema" import { ModelID, ProviderID } from "@/provider/schema" import { errors } from "../error" import { lazy } from "../../util/lazy" import { Bus } from "../../bus" import { NamedError } from "@opencode-ai/shared/util/error" const log = Log.create({ service: "server" }) export const SessionRoutes = lazy(() => new Hono() .get( "/", describeRoute({ summary: "List sessions", description: "Get a list of all OpenCode sessions, sorted by most recently updated.", operationId: "session.list", responses: { 200: { description: "List of sessions", content: { "application/json": { schema: resolver(Session.Info.array()), }, }, }, }, }), validator( "query", z.object({ directory: z.string().optional().meta({ description: "Filter sessions by project directory" }), roots: z.coerce.boolean().optional().meta({ description: "Only return root sessions (no parentID)" }), start: z.coerce .number() .optional() .meta({ description: "Filter sessions updated on or after this timestamp (milliseconds since epoch)" }), search: z.string().optional().meta({ description: "Filter sessions by title (case-insensitive)" }), limit: z.coerce.number().optional().meta({ description: "Maximum number of sessions to return" }), }), ), async (c) => { const query = c.req.valid("query") const sessions: Session.Info[] = [] for await (const session of Session.list({ directory: query.directory, roots: query.roots, start: query.start, search: query.search, limit: query.limit, })) { sessions.push(session) } return c.json(sessions) }, ) .get( "/status", describeRoute({ summary: "Get session status", description: "Retrieve the current status of all sessions, including active, idle, and completed states.", operationId: "session.status", responses: { 200: { description: "Get session status", content: { "application/json": { schema: resolver(z.record(z.string(), SessionStatus.Info)), }, }, }, ...errors(400), }, }), async (c) => { const result = await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.list())) return c.json(Object.fromEntries(result)) }, ) .get( "/:sessionID", describeRoute({ summary: "Get session", description: "Retrieve detailed information about a specific OpenCode session.", tags: ["Session"], operationId: "session.get", responses: { 200: { description: "Get session", content: { "application/json": { schema: resolver(Session.Info), }, }, }, ...errors(400, 404), }, }), validator( "param", z.object({ sessionID: Session.GetInput, }), ), async (c) => { const sessionID = c.req.valid("param").sessionID const session = await AppRuntime.runPromise(Session.Service.use((svc) => svc.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.ChildrenInput, }), ), async (c) => { const sessionID = c.req.valid("param").sessionID const session = await AppRuntime.runPromise(Session.Service.use((svc) => svc.children(sessionID))) return c.json(session) }, ) .get( "/:sessionID/todo", describeRoute({ summary: "Get session todos", description: "Retrieve the todo list associated with a specific session, showing tasks and action items.", operationId: "session.todo", responses: { 200: { description: "Todo list", content: { "application/json": { schema: resolver(Todo.Info.array()), }, }, }, ...errors(400, 404), }, }), validator( "param", z.object({ sessionID: SessionID.zod, }), ), async (c) => { const sessionID = c.req.valid("param").sessionID const todos = await AppRuntime.runPromise(Todo.Service.use((svc) => svc.get(sessionID))) return c.json(todos) }, ) .post( "/", describeRoute({ summary: "Create session", description: "Create a new OpenCode session for interacting with AI assistants and managing conversations.", operationId: "session.create", responses: { ...errors(400), 200: { description: "Successfully created session", content: { "application/json": { schema: resolver(Session.Info), }, }, }, }, }), validator("json", Session.CreateInput), async (c) => { const body = c.req.valid("json") ?? {} const session = await AppRuntime.runPromise(SessionShare.Service.use((svc) => svc.create(body))) return c.json(session) }, ) .delete( "/:sessionID", describeRoute({ summary: "Delete session", description: "Delete a session and permanently remove all associated data, including messages and history.", operationId: "session.delete", responses: { 200: { description: "Successfully deleted session", content: { "application/json": { schema: resolver(z.boolean()), }, }, }, ...errors(400, 404), }, }), validator( "param", z.object({ sessionID: Session.RemoveInput, }), ), async (c) => { const sessionID = c.req.valid("param").sessionID await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(sessionID))) return c.json(true) }, ) .patch( "/:sessionID", describeRoute({ summary: "Update session", description: "Update properties of an existing session, such as title or other metadata.", operationId: "session.update", responses: { 200: { description: "Successfully updated session", content: { "application/json": { schema: resolver(Session.Info), }, }, }, ...errors(400, 404), }, }), validator( "param", z.object({ sessionID: SessionID.zod, }), ), validator( "json", z.object({ title: z.string().optional(), permission: Permission.Ruleset.zod.optional(), time: z .object({ archived: z.number().optional(), }) .optional(), }), ), async (c) => { const sessionID = c.req.valid("param").sessionID const updates = c.req.valid("json") const session = await AppRuntime.runPromise( Effect.gen(function* () { const session = yield* Session.Service const current = yield* session.get(sessionID) if (updates.title !== undefined) { yield* session.setTitle({ sessionID, title: updates.title }) } if (updates.permission !== undefined) { yield* session.setPermission({ sessionID, permission: Permission.merge(current.permission ?? [], updates.permission), }) } if (updates.time?.archived !== undefined) { yield* session.setArchived({ sessionID, time: updates.time.archived }) } return yield* session.get(sessionID) }), ) return c.json(session) }, ) // TODO(v2): remove this dedicated route and rely on the normal `/init` command flow. .post( "/:sessionID/init", describeRoute({ summary: "Initialize session", description: "Analyze the current application and create an AGENTS.md file with project-specific agent configurations.", operationId: "session.init", responses: { 200: { description: "200", content: { "application/json": { schema: resolver(z.boolean()), }, }, }, ...errors(400, 404), }, }), validator( "param", z.object({ sessionID: SessionID.zod, }), ), validator( "json", z.object({ modelID: ModelID.zod, providerID: ProviderID.zod, messageID: MessageID.zod, }), ), async (c) => { const sessionID = c.req.valid("param").sessionID const body = c.req.valid("json") await AppRuntime.runPromise( SessionPrompt.Service.use((svc) => svc.command({ sessionID, messageID: body.messageID, model: body.providerID + "/" + body.modelID, command: Command.Default.INIT, arguments: "", }), ), ) return c.json(true) }, ) .post( "/:sessionID/fork", describeRoute({ summary: "Fork session", description: "Create a new session by forking an existing session at a specific message point.", operationId: "session.fork", responses: { 200: { description: "200", content: { "application/json": { schema: resolver(Session.Info), }, }, }, }, }), validator( "param", z.object({ sessionID: Session.ForkInput.shape.sessionID, }), ), validator("json", Session.ForkInput.omit({ sessionID: true })), async (c) => { const sessionID = c.req.valid("param").sessionID const body = c.req.valid("json") const result = await AppRuntime.runPromise(Session.Service.use((svc) => svc.fork({ ...body, sessionID }))) return c.json(result) }, ) .post( "/:sessionID/abort", describeRoute({ summary: "Abort session", description: "Abort an active session and stop any ongoing AI processing or command execution.", operationId: "session.abort", responses: { 200: { description: "Aborted session", content: { "application/json": { schema: resolver(z.boolean()), }, }, }, ...errors(400, 404), }, }), validator( "param", z.object({ sessionID: SessionID.zod, }), ), async (c) => { await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.cancel(c.req.valid("param").sessionID))) return c.json(true) }, ) .post( "/:sessionID/share", describeRoute({ summary: "Share session", description: "Create a shareable link for a session, allowing others to view the conversation.", operationId: "session.share", responses: { 200: { description: "Successfully shared session", content: { "application/json": { schema: resolver(Session.Info), }, }, }, ...errors(400, 404), }, }), validator( "param", z.object({ sessionID: SessionID.zod, }), ), async (c) => { const sessionID = c.req.valid("param").sessionID const session = await AppRuntime.runPromise( Effect.gen(function* () { const share = yield* SessionShare.Service const session = yield* Session.Service yield* share.share(sessionID) return yield* session.get(sessionID) }), ) return c.json(session) }, ) .get( "/:sessionID/diff", describeRoute({ summary: "Get message diff", description: "Get the file changes (diff) that resulted from a specific user message in the session.", operationId: "session.diff", responses: { 200: { description: "Successfully retrieved diff", content: { "application/json": { schema: resolver(Snapshot.FileDiff.array()), }, }, }, }, }), validator( "param", z.object({ sessionID: SessionSummary.DiffInput.shape.sessionID, }), ), validator( "query", z.object({ messageID: SessionSummary.DiffInput.shape.messageID, }), ), async (c) => { const query = c.req.valid("query") const params = c.req.valid("param") const result = await AppRuntime.runPromise( SessionSummary.Service.use((summary) => summary.diff({ sessionID: params.sessionID, messageID: query.messageID, }), ), ) return c.json(result) }, ) .delete( "/:sessionID/share", describeRoute({ summary: "Unshare session", description: "Remove the shareable link for a session, making it private again.", operationId: "session.unshare", responses: { 200: { description: "Successfully unshared session", content: { "application/json": { schema: resolver(Session.Info), }, }, }, ...errors(400, 404), }, }), validator( "param", z.object({ sessionID: SessionID.zod, }), ), async (c) => { const sessionID = c.req.valid("param").sessionID const session = await AppRuntime.runPromise( Effect.gen(function* () { const share = yield* SessionShare.Service const session = yield* Session.Service yield* share.unshare(sessionID) return yield* session.get(sessionID) }), ) return c.json(session) }, ) .post( "/:sessionID/summarize", describeRoute({ summary: "Summarize session", description: "Generate a concise summary of the session using AI compaction to preserve key information.", operationId: "session.summarize", responses: { 200: { description: "Summarized session", content: { "application/json": { schema: resolver(z.boolean()), }, }, }, ...errors(400, 404), }, }), validator( "param", z.object({ sessionID: SessionID.zod, }), ), validator( "json", z.object({ providerID: ProviderID.zod, modelID: ModelID.zod, auto: z.boolean().optional().default(false), }), ), async (c) => { const sessionID = c.req.valid("param").sessionID const body = c.req.valid("json") await AppRuntime.runPromise( Effect.gen(function* () { const session = yield* Session.Service const revert = yield* SessionRevert.Service const compact = yield* SessionCompaction.Service const prompt = yield* SessionPrompt.Service const agent = yield* Agent.Service yield* revert.cleanup(yield* session.get(sessionID)) const msgs = yield* session.messages({ sessionID }) const defaultAgent = yield* agent.defaultAgent() let currentAgent = defaultAgent for (let i = msgs.length - 1; i >= 0; i--) { const info = msgs[i].info if (info.role === "user") { currentAgent = info.agent || defaultAgent break } } yield* compact.create({ sessionID, agent: currentAgent, model: { providerID: body.providerID, modelID: body.modelID, }, auto: body.auto, }) yield* prompt.loop({ sessionID }) }), ) return c.json(true) }, ) .get( "/:sessionID/message", describeRoute({ summary: "Get session messages", description: "Retrieve all messages in a session, including user prompts and AI responses.", operationId: "session.messages", responses: { 200: { description: "List of messages", content: { "application/json": { schema: resolver(MessageV2.WithParts.array()), }, }, }, ...errors(400, 404), }, }), validator( "param", z.object({ sessionID: SessionID.zod, }), ), validator( "query", z .object({ limit: z.coerce .number() .int() .min(0) .optional() .meta({ description: "Maximum number of messages to return" }), before: z .string() .optional() .meta({ description: "Opaque cursor for loading older messages" }) .refine( (value) => { if (!value) return true try { MessageV2.cursor.decode(value) return true } catch { return false } }, { message: "Invalid cursor" }, ), }) .refine((value) => !value.before || value.limit !== undefined, { message: "before requires limit", path: ["before"], }), ), async (c) => { const query = c.req.valid("query") const sessionID = c.req.valid("param").sessionID if (query.limit === undefined || query.limit === 0) { const messages = await AppRuntime.runPromise( Effect.gen(function* () { const session = yield* Session.Service yield* session.get(sessionID) return yield* session.messages({ sessionID }) }), ) return c.json(messages) } const page = await MessageV2.page({ sessionID, limit: query.limit, before: query.before, }) if (page.cursor) { const url = new URL(c.req.url) url.searchParams.set("limit", query.limit.toString()) url.searchParams.set("before", page.cursor) c.header("Access-Control-Expose-Headers", "Link, X-Next-Cursor") c.header("Link", `<${url.toString()}>; rel=\"next\"`) c.header("X-Next-Cursor", page.cursor) } return c.json(page.items) }, ) .get( "/:sessionID/message/:messageID", describeRoute({ summary: "Get message", description: "Retrieve a specific message from a session by its message ID.", operationId: "session.message", responses: { 200: { description: "Message", content: { "application/json": { schema: resolver( z.object({ info: MessageV2.Info, parts: MessageV2.Part.array(), }), ), }, }, }, ...errors(400, 404), }, }), validator( "param", z.object({ sessionID: SessionID.zod, messageID: MessageID.zod, }), ), async (c) => { const params = c.req.valid("param") const message = await MessageV2.get({ sessionID: params.sessionID, messageID: params.messageID, }) return c.json(message) }, ) .delete( "/:sessionID/message/:messageID", describeRoute({ summary: "Delete message", description: "Permanently delete a specific message (and all of its parts) from a session. This does not revert any file changes that may have been made while processing the message.", operationId: "session.deleteMessage", responses: { 200: { description: "Successfully deleted message", content: { "application/json": { schema: resolver(z.boolean()), }, }, }, ...errors(400, 404), }, }), validator( "param", z.object({ sessionID: SessionID.zod, messageID: MessageID.zod, }), ), async (c) => { const params = c.req.valid("param") await AppRuntime.runPromise( Effect.gen(function* () { const state = yield* SessionRunState.Service const session = yield* Session.Service yield* state.assertNotBusy(params.sessionID) yield* session.removeMessage({ sessionID: params.sessionID, messageID: params.messageID, }) }), ) return c.json(true) }, ) .delete( "/:sessionID/message/:messageID/part/:partID", describeRoute({ description: "Delete a part from a message", operationId: "part.delete", responses: { 200: { description: "Successfully deleted part", content: { "application/json": { schema: resolver(z.boolean()), }, }, }, ...errors(400, 404), }, }), validator( "param", z.object({ sessionID: SessionID.zod, messageID: MessageID.zod, partID: PartID.zod, }), ), async (c) => { const params = c.req.valid("param") await AppRuntime.runPromise( Session.Service.use((svc) => svc.removePart({ sessionID: params.sessionID, messageID: params.messageID, partID: params.partID, }), ), ) return c.json(true) }, ) .patch( "/:sessionID/message/:messageID/part/:partID", describeRoute({ description: "Update a part in a message", operationId: "part.update", responses: { 200: { description: "Successfully updated part", content: { "application/json": { schema: resolver(MessageV2.Part), }, }, }, ...errors(400, 404), }, }), validator( "param", z.object({ sessionID: SessionID.zod, messageID: MessageID.zod, partID: PartID.zod, }), ), validator("json", MessageV2.Part), async (c) => { const params = c.req.valid("param") const body = c.req.valid("json") if (body.id !== params.partID || body.messageID !== params.messageID || body.sessionID !== params.sessionID) { throw new Error( `Part mismatch: body.id='${body.id}' vs partID='${params.partID}', body.messageID='${body.messageID}' vs messageID='${params.messageID}', body.sessionID='${body.sessionID}' vs sessionID='${params.sessionID}'`, ) } const part = await AppRuntime.runPromise(Session.Service.use((svc) => svc.updatePart(body))) return c.json(part) }, ) .post( "/:sessionID/message", describeRoute({ summary: "Send message", description: "Create and send a new message to a session, streaming the AI response.", operationId: "session.prompt", responses: { 200: { description: "Created message", content: { "application/json": { schema: resolver( z.object({ info: MessageV2.Assistant, parts: MessageV2.Part.array(), }), ), }, }, }, ...errors(400, 404), }, }), validator( "param", z.object({ sessionID: SessionID.zod, }), ), validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })), async (c) => { c.status(200) c.header("Content-Type", "application/json") return stream(c, async (stream) => { const sessionID = c.req.valid("param").sessionID const body = c.req.valid("json") const msg = await AppRuntime.runPromise( SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID })), ) stream.write(JSON.stringify(msg)) }) }, ) .post( "/:sessionID/prompt_async", describeRoute({ summary: "Send async message", description: "Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.", operationId: "session.prompt_async", responses: { 204: { description: "Prompt accepted", }, ...errors(400, 404), }, }), validator( "param", z.object({ sessionID: SessionID.zod, }), ), validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })), async (c) => { const sessionID = c.req.valid("param").sessionID const body = c.req.valid("json") AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID }))).catch((err) => { log.error("prompt_async failed", { sessionID, error: err }) Bus.publish(Session.Event.Error, { sessionID, error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(), }) }) return c.body(null, 204) }, ) .post( "/:sessionID/command", describeRoute({ summary: "Send command", description: "Send a new command to a session for execution by the AI assistant.", operationId: "session.command", responses: { 200: { description: "Created message", content: { "application/json": { schema: resolver( z.object({ info: MessageV2.Assistant, parts: MessageV2.Part.array(), }), ), }, }, }, ...errors(400, 404), }, }), validator( "param", z.object({ sessionID: SessionID.zod, }), ), validator("json", SessionPrompt.CommandInput.omit({ sessionID: true })), async (c) => { const sessionID = c.req.valid("param").sessionID const body = c.req.valid("json") const msg = await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.command({ ...body, sessionID }))) return c.json(msg) }, ) .post( "/:sessionID/shell", describeRoute({ summary: "Run shell command", description: "Execute a shell command within the session context and return the AI's response.", operationId: "session.shell", responses: { 200: { description: "Created message", content: { "application/json": { schema: resolver(MessageV2.WithParts), }, }, }, ...errors(400, 404), }, }), validator( "param", z.object({ sessionID: SessionID.zod, }), ), validator("json", SessionPrompt.ShellInput.omit({ sessionID: true })), async (c) => { const sessionID = c.req.valid("param").sessionID const body = c.req.valid("json") const msg = await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.shell({ ...body, sessionID }))) return c.json(msg) }, ) .post( "/:sessionID/revert", describeRoute({ summary: "Revert message", description: "Revert a specific message in a session, undoing its effects and restoring the previous state.", operationId: "session.revert", responses: { 200: { description: "Updated session", content: { "application/json": { schema: resolver(Session.Info), }, }, }, ...errors(400, 404), }, }), validator( "param", z.object({ sessionID: SessionID.zod, }), ), validator("json", SessionRevert.RevertInput.omit({ sessionID: true })), async (c) => { const sessionID = c.req.valid("param").sessionID log.info("revert", c.req.valid("json")) const session = await AppRuntime.runPromise( SessionRevert.Service.use((svc) => svc.revert({ sessionID, ...c.req.valid("json"), }), ), ) return c.json(session) }, ) .post( "/:sessionID/unrevert", describeRoute({ summary: "Restore reverted messages", description: "Restore all previously reverted messages in a session.", operationId: "session.unrevert", responses: { 200: { description: "Updated session", content: { "application/json": { schema: resolver(Session.Info), }, }, }, ...errors(400, 404), }, }), validator( "param", z.object({ sessionID: SessionID.zod, }), ), async (c) => { const sessionID = c.req.valid("param").sessionID const session = await AppRuntime.runPromise(SessionRevert.Service.use((svc) => svc.unrevert({ sessionID }))) return c.json(session) }, ) .post( "/:sessionID/permissions/:permissionID", describeRoute({ summary: "Respond to permission", deprecated: true, description: "Approve or deny a permission request from the AI assistant.", operationId: "permission.respond", responses: { 200: { description: "Permission processed successfully", content: { "application/json": { schema: resolver(z.boolean()), }, }, }, ...errors(400, 404), }, }), validator( "param", z.object({ sessionID: SessionID.zod, permissionID: PermissionID.zod, }), ), validator("json", z.object({ response: Permission.Reply.zod })), async (c) => { const params = c.req.valid("param") await AppRuntime.runPromise( Permission.Service.use((svc) => svc.reply({ requestID: params.permissionID, reply: c.req.valid("json").response, }), ), ) return c.json(true) }, ), )