diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md
index 3a1a99860fd6..fc17feb5b313 100644
--- a/packages/opencode/specs/effect/http-api.md
+++ b/packages/opencode/specs/effect/http-api.md
@@ -170,23 +170,23 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho
## Current Route Status
-| Area | Status | Notes |
-| ------------------------- | ----------------- | -------------------------------------------------------------------------- |
-| `question` | `bridged` | `GET /question`, reply, reject |
-| `permission` | `bridged` | list and reply |
-| `provider` | `bridged` | list, auth, OAuth authorize/callback |
-| `config` | `bridged` | read, providers, update |
-| `project` | `bridged` | list, current, git init, update |
-| `file` | `bridged` partial | find text/file/symbol, list/content/status |
-| `mcp` | `bridged` | status, add, OAuth, connect/disconnect |
-| `workspace` | `bridged` | adaptor/list/status/create/remove/session-restore |
-| top-level instance routes | `bridged` | path, vcs, command, agent, skill, lsp, formatter, dispose |
-| experimental JSON routes | `bridged` | console, tool, worktree list/mutations, global session list, resource list |
-| `session` | `later/special` | large stateful surface plus streaming |
-| `sync` | `bridged` | start/replay/history |
-| `event` | `special` | SSE |
-| `pty` | `special` | websocket |
-| `tui` | `special` | UI bridge |
+| Area | Status | Notes |
+| ------------------------- | ----------------- | ---------------------------------------------------------------------------------------- |
+| `question` | `bridged` | `GET /question`, reply, reject |
+| `permission` | `bridged` | list and reply |
+| `provider` | `bridged` | list, auth, OAuth authorize/callback |
+| `config` | `bridged` | read, providers, update |
+| `project` | `bridged` | list, current, git init, update |
+| `file` | `bridged` partial | find text/file/symbol, list/content/status |
+| `mcp` | `bridged` | status, add, OAuth, connect/disconnect |
+| `workspace` | `bridged` | adaptor/list/status/create/remove/session-restore |
+| top-level instance routes | `bridged` | path, vcs, command, agent, skill, lsp, formatter, dispose |
+| experimental JSON routes | `bridged` | console, tool, worktree list/mutations, global session list, resource list |
+| `session` | `bridged` partial | read routes; lifecycle, message mutations, streaming remain |
+| `sync` | `bridged` | start/replay/history |
+| `event` | `special` | SSE |
+| `pty` | `special` | websocket |
+| `tui` | `special` | UI bridge |
## Full Route Checklist
@@ -286,11 +286,11 @@ This checklist tracks bridge parity only. Checked routes are available through t
### Session Routes
-- [ ] `GET /session` - list sessions.
-- [ ] `GET /session/status` - session status map.
-- [ ] `GET /session/:sessionID` - get session.
-- [ ] `GET /session/:sessionID/children` - get child sessions.
-- [ ] `GET /session/:sessionID/todo` - get session todos.
+- [x] `GET /session` - list sessions.
+- [x] `GET /session/status` - session status map.
+- [x] `GET /session/:sessionID` - get session.
+- [x] `GET /session/:sessionID/children` - get child sessions.
+- [x] `GET /session/:sessionID/todo` - get session todos.
- [ ] `POST /session` - create session.
- [ ] `DELETE /session/:sessionID` - delete session.
- [ ] `PATCH /session/:sessionID` - update session metadata.
@@ -298,11 +298,11 @@ This checklist tracks bridge parity only. Checked routes are available through t
- [ ] `POST /session/:sessionID/fork` - fork session.
- [ ] `POST /session/:sessionID/abort` - abort session.
- [ ] `POST /session/:sessionID/share` - share session.
-- [ ] `GET /session/:sessionID/diff` - session diff.
+- [x] `GET /session/:sessionID/diff` - session diff.
- [ ] `DELETE /session/:sessionID/share` - unshare session.
- [ ] `POST /session/:sessionID/summarize` - summarize session.
-- [ ] `GET /session/:sessionID/message` - list session messages.
-- [ ] `GET /session/:sessionID/message/:messageID` - get message.
+- [x] `GET /session/:sessionID/message` - list session messages.
+- [x] `GET /session/:sessionID/message/:messageID` - get message.
- [ ] `DELETE /session/:sessionID/message/:messageID` - delete message.
- [ ] `DELETE /session/:sessionID/message/:messageID/part/:partID` - delete part.
- [ ] `PATCH /session/:sessionID/message/:messageID/part/:partID` - update part.
@@ -354,7 +354,7 @@ Prefer smaller PRs from here so route behavior and SDK/OpenAPI fallout stays rev
5. [x] Bridge experimental global session list.
6. [x] Bridge workspace create/remove/session-restore routes.
7. [x] Bridge sync start/replay/history routes.
-8. [ ] Bridge session read routes: list, status, get, children, todo, diff, messages.
+8. [x] Bridge session read routes: list, status, get, children, todo, diff, messages.
9. [ ] Bridge session lifecycle mutation routes: create, delete, update, fork, abort.
10. [ ] Bridge session share/summary/message/part mutation routes.
11. [ ] Replace event SSE with non-Hono Effect HTTP.
diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts
index fa728b04e6f9..adc70a43b358 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/server.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts
@@ -18,6 +18,7 @@ import { PermissionApi, permissionHandlers } from "./permission"
import { ProjectApi, projectHandlers } from "./project"
import { ProviderApi, providerHandlers } from "./provider"
import { QuestionApi, questionHandlers } from "./question"
+import { SessionApi, sessionHandlers } from "./session"
import { SyncApi, syncHandlers } from "./sync"
import { WorkspaceApi, workspaceHandlers } from "./workspace"
import { disposeMiddleware } from "./lifecycle"
@@ -74,6 +75,7 @@ export const routes = Layer.mergeAll(
HttpApiBuilder.layer(QuestionApi).pipe(Layer.provide(questionHandlers)),
HttpApiBuilder.layer(PermissionApi).pipe(Layer.provide(permissionHandlers)),
HttpApiBuilder.layer(ProviderApi).pipe(Layer.provide(providerHandlers)),
+ HttpApiBuilder.layer(SessionApi).pipe(Layer.provide(sessionHandlers)),
HttpApiBuilder.layer(SyncApi).pipe(Layer.provide(syncHandlers)),
HttpApiBuilder.layer(WorkspaceApi).pipe(Layer.provide(workspaceHandlers)),
).pipe(
diff --git a/packages/opencode/src/server/routes/instance/httpapi/session.ts b/packages/opencode/src/server/routes/instance/httpapi/session.ts
new file mode 100644
index 000000000000..e06c8d98aca5
--- /dev/null
+++ b/packages/opencode/src/server/routes/instance/httpapi/session.ts
@@ -0,0 +1,242 @@
+import * as InstanceState from "@/effect/instance-state"
+import { Instance } from "@/project/instance"
+import { Session } from "@/session"
+import { MessageV2 } from "@/session/message-v2"
+import { SessionStatus } from "@/session/status"
+import { SessionSummary } from "@/session/summary"
+import { Todo } from "@/session/todo"
+import { MessageID, SessionID } from "@/session/schema"
+import { Snapshot } from "@/snapshot"
+import { Effect, Layer, Schema, Struct } from "effect"
+import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
+import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
+import { Authorization } from "./auth"
+
+const root = "/session"
+const ListQuery = Schema.Struct({
+ directory: Schema.optional(Schema.String),
+ roots: Schema.optional(Schema.Literals(["true", "false"])),
+ start: Schema.optional(Schema.NumberFromString),
+ search: Schema.optional(Schema.String),
+ limit: Schema.optional(Schema.NumberFromString),
+})
+const DiffQuery = Schema.Struct(Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"]))
+const MessagesQuery = Schema.Struct({
+ limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))),
+ before: Schema.optional(Schema.String),
+})
+const StatusMap = Schema.Record(Schema.String, SessionStatus.Info)
+
+export const SessionPaths = {
+ list: root,
+ status: `${root}/status`,
+ get: `${root}/:sessionID`,
+ children: `${root}/:sessionID/children`,
+ todo: `${root}/:sessionID/todo`,
+ diff: `${root}/:sessionID/diff`,
+ messages: `${root}/:sessionID/message`,
+ message: `${root}/:sessionID/message/:messageID`,
+} as const
+
+export const SessionApi = HttpApi.make("session")
+ .add(
+ HttpApiGroup.make("session")
+ .add(
+ HttpApiEndpoint.get("list", SessionPaths.list, {
+ query: ListQuery,
+ success: Schema.Array(Session.Info),
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "session.list",
+ summary: "List sessions",
+ description: "Get a list of all OpenCode sessions, sorted by most recently updated.",
+ }),
+ ),
+ HttpApiEndpoint.get("status", SessionPaths.status, {
+ success: StatusMap,
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "session.status",
+ summary: "Get session status",
+ description: "Retrieve the current status of all sessions, including active, idle, and completed states.",
+ }),
+ ),
+ HttpApiEndpoint.get("get", SessionPaths.get, {
+ params: { sessionID: SessionID },
+ success: Session.Info,
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "session.get",
+ summary: "Get session",
+ description: "Retrieve detailed information about a specific OpenCode session.",
+ }),
+ ),
+ HttpApiEndpoint.get("children", SessionPaths.children, {
+ params: { sessionID: SessionID },
+ success: Schema.Array(Session.Info),
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "session.children",
+ summary: "Get session children",
+ description: "Retrieve all child sessions that were forked from the specified parent session.",
+ }),
+ ),
+ HttpApiEndpoint.get("todo", SessionPaths.todo, {
+ params: { sessionID: SessionID },
+ success: Schema.Array(Todo.Info),
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "session.todo",
+ summary: "Get session todos",
+ description: "Retrieve the todo list associated with a specific session, showing tasks and action items.",
+ }),
+ ),
+ HttpApiEndpoint.get("diff", SessionPaths.diff, {
+ params: { sessionID: SessionID },
+ query: DiffQuery,
+ success: Schema.Array(Snapshot.FileDiff),
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "session.diff",
+ summary: "Get message diff",
+ description: "Get the file changes (diff) that resulted from a specific user message in the session.",
+ }),
+ ),
+ HttpApiEndpoint.get("messages", SessionPaths.messages, {
+ params: { sessionID: SessionID },
+ query: MessagesQuery,
+ success: Schema.Array(MessageV2.WithParts),
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "session.messages",
+ summary: "Get session messages",
+ description: "Retrieve all messages in a session, including user prompts and AI responses.",
+ }),
+ ),
+ HttpApiEndpoint.get("message", SessionPaths.message, {
+ params: { sessionID: SessionID, messageID: MessageID },
+ success: MessageV2.WithParts,
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "session.message",
+ summary: "Get message",
+ description: "Retrieve a specific message from a session by its message ID.",
+ }),
+ ),
+ )
+ .annotateMerge(
+ OpenApi.annotations({
+ title: "session",
+ description: "Experimental HttpApi session routes.",
+ }),
+ )
+ .middleware(Authorization),
+ )
+ .annotateMerge(
+ OpenApi.annotations({
+ title: "opencode experimental HttpApi",
+ version: "0.0.1",
+ description: "Experimental HttpApi surface for selected instance routes.",
+ }),
+ )
+
+export const sessionHandlers = Layer.unwrap(
+ Effect.gen(function* () {
+ const session = yield* Session.Service
+ const statusSvc = yield* SessionStatus.Service
+ const todoSvc = yield* Todo.Service
+ const summary = yield* SessionSummary.Service
+
+ const list = Effect.fn("SessionHttpApi.list")(function* (ctx: { query: typeof ListQuery.Type }) {
+ const instance = yield* InstanceState.context
+ return Instance.restore(instance, () =>
+ Array.from(
+ Session.list({
+ directory: ctx.query.directory,
+ roots: ctx.query.roots === "true" ? true : undefined,
+ start: ctx.query.start,
+ search: ctx.query.search,
+ limit: ctx.query.limit,
+ }),
+ ),
+ )
+ })
+
+ const status = Effect.fn("SessionHttpApi.status")(function* () {
+ return Object.fromEntries(yield* statusSvc.list())
+ })
+
+ const get = Effect.fn("SessionHttpApi.get")(function* (ctx: { params: { sessionID: SessionID } }) {
+ return yield* session.get(ctx.params.sessionID)
+ })
+
+ const children = Effect.fn("SessionHttpApi.children")(function* (ctx: { params: { sessionID: SessionID } }) {
+ return yield* session.children(ctx.params.sessionID)
+ })
+
+ const todo = Effect.fn("SessionHttpApi.todo")(function* (ctx: { params: { sessionID: SessionID } }) {
+ return yield* todoSvc.get(ctx.params.sessionID)
+ })
+
+ const diff = Effect.fn("SessionHttpApi.diff")(function* (ctx: {
+ params: { sessionID: SessionID }
+ query: typeof DiffQuery.Type
+ }) {
+ return yield* summary.diff({ sessionID: ctx.params.sessionID, messageID: ctx.query.messageID })
+ })
+
+ const messages = Effect.fn("SessionHttpApi.messages")(function* (ctx: {
+ params: { sessionID: SessionID }
+ query: typeof MessagesQuery.Type
+ }) {
+ if (ctx.query.limit === undefined || ctx.query.limit === 0) {
+ yield* session.get(ctx.params.sessionID)
+ return yield* session.messages({ sessionID: ctx.params.sessionID })
+ }
+
+ const page = MessageV2.page({
+ sessionID: ctx.params.sessionID,
+ limit: ctx.query.limit,
+ before: ctx.query.before,
+ })
+ if (!page.cursor) return page.items
+
+ const request = yield* HttpServerRequest.HttpServerRequest
+ const url = new URL(request.url, "http://localhost")
+ url.searchParams.set("limit", ctx.query.limit.toString())
+ url.searchParams.set("before", page.cursor)
+ return HttpServerResponse.jsonUnsafe(page.items, {
+ headers: {
+ "Access-Control-Expose-Headers": "Link, X-Next-Cursor",
+ Link: `<${url.toString()}>; rel="next"`,
+ "X-Next-Cursor": page.cursor,
+ },
+ })
+ })
+
+ const message = Effect.fn("SessionHttpApi.message")(function* (ctx: {
+ params: { sessionID: SessionID; messageID: MessageID }
+ }) {
+ return yield* Effect.sync(() =>
+ MessageV2.get({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID }),
+ )
+ })
+
+ return HttpApiBuilder.group(SessionApi, "session", (handlers) =>
+ handlers
+ .handle("list", list)
+ .handle("status", status)
+ .handle("get", get)
+ .handle("children", children)
+ .handle("todo", todo)
+ .handle("diff", diff)
+ .handle("messages", messages)
+ .handle("message", message),
+ )
+ }),
+).pipe(
+ Layer.provide(Session.defaultLayer),
+ Layer.provide(SessionStatus.defaultLayer),
+ Layer.provide(Todo.defaultLayer),
+ Layer.provide(SessionSummary.defaultLayer),
+)
diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts
index 57988b26f18f..965f26b48559 100644
--- a/packages/opencode/src/server/routes/instance/index.ts
+++ b/packages/opencode/src/server/routes/instance/index.ts
@@ -20,6 +20,7 @@ import { ExperimentalPaths } from "./httpapi/experimental"
import { FilePaths } from "./httpapi/file"
import { InstancePaths } from "./httpapi/instance"
import { McpPaths } from "./httpapi/mcp"
+import { SessionPaths } from "./httpapi/session"
import { SyncPaths } from "./httpapi/sync"
import { ProjectRoutes } from "./project"
import { SessionRoutes } from "./session"
@@ -93,6 +94,14 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
app.post(SyncPaths.start, (c) => handler(c.req.raw, context))
app.post(SyncPaths.replay, (c) => handler(c.req.raw, context))
app.post(SyncPaths.history, (c) => handler(c.req.raw, context))
+ app.get(SessionPaths.list, (c) => handler(c.req.raw, context))
+ app.get(SessionPaths.status, (c) => handler(c.req.raw, context))
+ app.get(SessionPaths.get, (c) => handler(c.req.raw, context))
+ app.get(SessionPaths.children, (c) => handler(c.req.raw, context))
+ app.get(SessionPaths.todo, (c) => handler(c.req.raw, context))
+ app.get(SessionPaths.diff, (c) => handler(c.req.raw, context))
+ app.get(SessionPaths.messages, (c) => handler(c.req.raw, context))
+ app.get(SessionPaths.message, (c) => handler(c.req.raw, context))
}
return app
diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts
new file mode 100644
index 000000000000..42cbb8495c67
--- /dev/null
+++ b/packages/opencode/test/server/httpapi-session.test.ts
@@ -0,0 +1,108 @@
+import { afterEach, describe, expect, test } from "bun:test"
+import type { UpgradeWebSocket } from "hono/ws"
+import { Effect } from "effect"
+import { Flag } from "@opencode-ai/core/flag/flag"
+import { ModelID, ProviderID } from "../../src/provider/schema"
+import { Instance } from "../../src/project/instance"
+import { InstanceRoutes } from "../../src/server/routes/instance"
+import { SessionPaths } from "../../src/server/routes/instance/httpapi/session"
+import { Session } from "../../src/session"
+import { MessageID, PartID, type SessionID } from "../../src/session/schema"
+import { MessageV2 } from "../../src/session/message-v2"
+import { Log } from "../../src/util"
+import { resetDatabase } from "../fixture/db"
+import { tmpdir } from "../fixture/fixture"
+
+void Log.init({ print: false })
+
+const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
+const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
+
+function app() {
+ Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
+ return InstanceRoutes(websocket)
+}
+
+function runSession(fx: Effect.Effect) {
+ return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer)))
+}
+
+function pathFor(path: string, params: Record) {
+ return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), path)
+}
+
+async function createSession(directory: string, input?: Session.CreateInput) {
+ return Instance.provide({
+ directory,
+ fn: async () => runSession(Session.Service.use((svc) => svc.create(input))),
+ })
+}
+
+async function createTextMessage(directory: string, sessionID: SessionID, text: string) {
+ return Instance.provide({
+ directory,
+ fn: async () =>
+ runSession(
+ Effect.gen(function* () {
+ const svc = yield* Session.Service
+ const info = yield* svc.updateMessage({
+ id: MessageID.ascending(),
+ role: "user",
+ sessionID,
+ agent: "build",
+ model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
+ time: { created: Date.now() },
+ })
+ yield* svc.updatePart({
+ id: PartID.ascending(),
+ sessionID,
+ messageID: info.id,
+ type: "text",
+ text,
+ })
+ return info
+ }),
+ ),
+ })
+}
+
+async function json(response: Response) {
+ if (response.status !== 200) throw new Error(await response.text())
+ return (await response.json()) as T
+}
+
+afterEach(async () => {
+ Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
+ await Instance.disposeAll()
+ await resetDatabase()
+})
+
+describe("session HttpApi", () => {
+ test("serves read routes through Hono bridge", async () => {
+ await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
+ const headers = { "x-opencode-directory": tmp.path }
+ const parent = await createSession(tmp.path, { title: "parent" })
+ const child = await createSession(tmp.path, { title: "child", parentID: parent.id })
+ const message = await createTextMessage(tmp.path, parent.id, "hello")
+ await createTextMessage(tmp.path, parent.id, "world")
+
+ expect((await json(await app().request(`${SessionPaths.list}?roots=true`, { headers }))).map((item) => item.id)).toContain(parent.id)
+
+ expect(await json>(await app().request(SessionPaths.status, { headers }))).toEqual({})
+
+ expect(await json(await app().request(pathFor(SessionPaths.get, { sessionID: parent.id }), { headers }))).toMatchObject({ id: parent.id, title: "parent" })
+
+ expect((await json(await app().request(pathFor(SessionPaths.children, { sessionID: parent.id }), { headers }))).map((item) => item.id)).toEqual([child.id])
+
+ expect(await json(await app().request(pathFor(SessionPaths.todo, { sessionID: parent.id }), { headers }))).toEqual([])
+
+ expect(await json(await app().request(pathFor(SessionPaths.diff, { sessionID: parent.id }), { headers }))).toEqual([])
+
+ const messages = await app().request(`${pathFor(SessionPaths.messages, { sessionID: parent.id })}?limit=1`, { headers })
+ const messagePage = await json(messages)
+ expect(messages.headers.get("x-next-cursor")).toBeTruthy()
+ expect(messagePage[0]?.parts[0]).toMatchObject({ type: "text" })
+
+ expect(await json(await app().request(pathFor(SessionPaths.message, { sessionID: parent.id, messageID: message.id }), { headers }))).toMatchObject({ info: { id: message.id } })
+ })
+})