diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md
index a33680460093..34c10d937762 100644
--- a/packages/opencode/specs/effect/http-api.md
+++ b/packages/opencode/specs/effect/http-api.md
@@ -181,7 +181,7 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho
| `mcp` | `bridged` | status, add, OAuth, connect/disconnect |
| `workspace` | `bridged` partial | adaptor/list/status; create/remove/session-restore remain |
| top-level instance routes | `bridged` | path, vcs, command, agent, skill, lsp, formatter, dispose |
-| experimental JSON routes | `bridged` partial | console, tool, worktree list/mutations, resource list; global session list remains later |
+| experimental JSON routes | `bridged` | console, tool, worktree list/mutations, global session list, resource list |
| `session` | `later/special` | large stateful surface plus streaming |
| `sync` | `later` | process/control side effects |
| `event` | `special` | SSE |
@@ -266,7 +266,7 @@ This checklist tracks bridge parity only. Checked routes are available through t
- [x] `POST /experimental/worktree` - create worktree.
- [x] `DELETE /experimental/worktree` - remove worktree.
- [x] `POST /experimental/worktree/reset` - reset worktree.
-- [ ] `GET /experimental/session` - global session list.
+- [x] `GET /experimental/session` - global session list.
- [x] `GET /experimental/resource` - MCP resources.
### Workspace Routes
@@ -351,7 +351,7 @@ Prefer smaller PRs from here so route behavior and SDK/OpenAPI fallout stays rev
2. [x] Bridge MCP add/connect/disconnect routes.
3. [x] Bridge MCP OAuth routes: start, callback, authenticate, remove.
4. [x] Bridge experimental console switch and tool list routes.
-5. [ ] Bridge experimental global session list.
+5. [x] Bridge experimental global session list.
6. [ ] Bridge workspace create/remove/session-restore routes.
7. [ ] Bridge sync start/replay/history routes.
8. [ ] Bridge session read routes: list, status, get, children, todo, diff, messages.
diff --git a/packages/opencode/src/server/routes/instance/httpapi/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/experimental.ts
index 392302d42966..f11191d64dfe 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/experimental.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/experimental.ts
@@ -6,10 +6,12 @@ import { InstanceState } from "@/effect"
import { MCP } from "@/mcp"
import { Project } from "@/project"
import { ProviderID, ModelID } from "@/provider/schema"
+import { Session } from "@/session"
import { ToolRegistry } from "@/tool"
import * as EffectZod from "@/util/effect-zod"
import { Worktree } from "@/worktree"
import { Effect, Layer, Option, Schema } from "effect"
+import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
@@ -50,6 +52,15 @@ const ToolListQuery = Schema.Struct({
})
const WorktreeList = Schema.Array(Schema.String).annotate({ identifier: "WorktreeList" })
+const SessionListQuery = Schema.Struct({
+ directory: Schema.optional(Schema.String),
+ roots: Schema.optional(Schema.Literals(["true", "false"])),
+ start: Schema.optional(Schema.NumberFromString),
+ cursor: Schema.optional(Schema.NumberFromString),
+ search: Schema.optional(Schema.String),
+ limit: Schema.optional(Schema.NumberFromString),
+ archived: Schema.optional(Schema.Literals(["true", "false"])),
+})
export const ExperimentalPaths = {
console: "/experimental/console",
@@ -59,6 +70,7 @@ export const ExperimentalPaths = {
toolIDs: "/experimental/tool/ids",
worktree: "/experimental/worktree",
worktreeReset: "/experimental/worktree/reset",
+ session: "/experimental/session",
resource: "/experimental/resource",
} as const
@@ -154,6 +166,17 @@ export const ExperimentalApi = HttpApi.make("experimental")
description: "Reset a worktree branch to the primary default branch.",
}),
),
+ HttpApiEndpoint.get("session", ExperimentalPaths.session, {
+ query: SessionListQuery,
+ success: Schema.Array(Session.GlobalInfo),
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "experimental.session.list",
+ summary: "List sessions",
+ description:
+ "Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.",
+ }),
+ ),
HttpApiEndpoint.get("resource", ExperimentalPaths.resource, {
success: Schema.Record(Schema.String, MCP.Resource),
}).annotateMerge(
@@ -279,6 +302,28 @@ export const experimentalHandlers = Layer.unwrap(
return true
})
+ const session = Effect.fn("ExperimentalHttpApi.session")(function* (ctx: { query: typeof SessionListQuery.Type }) {
+ const limit = ctx.query.limit ?? 100
+ const sessions = Array.from(
+ Session.listGlobal({
+ directory: ctx.query.directory,
+ roots: ctx.query.roots === "true" ? true : undefined,
+ start: ctx.query.start,
+ cursor: ctx.query.cursor,
+ search: ctx.query.search,
+ limit: limit + 1,
+ archived: ctx.query.archived === "true" ? true : undefined,
+ }),
+ )
+ const list = sessions.length > limit ? sessions.slice(0, limit) : sessions
+ return HttpServerResponse.jsonUnsafe(list, {
+ headers:
+ sessions.length > limit && list.length > 0
+ ? { "x-next-cursor": String(list[list.length - 1].time.updated) }
+ : undefined,
+ })
+ })
+
const resource = Effect.fn("ExperimentalHttpApi.resource")(function* () {
return yield* mcp.resources()
})
@@ -294,6 +339,7 @@ export const experimentalHandlers = Layer.unwrap(
.handle("worktreeCreate", worktreeCreate)
.handle("worktreeRemove", worktreeRemove)
.handle("worktreeReset", worktreeReset)
+ .handle("session", session)
.handle("resource", resource),
)
}),
diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts
index b99aa948e827..58e64443d31e 100644
--- a/packages/opencode/src/server/routes/instance/index.ts
+++ b/packages/opencode/src/server/routes/instance/index.ts
@@ -56,6 +56,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
app.post(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context))
app.delete(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context))
app.post(ExperimentalPaths.worktreeReset, (c) => handler(c.req.raw, context))
+ app.get(ExperimentalPaths.session, (c) => handler(c.req.raw, context))
app.get(ExperimentalPaths.resource, (c) => handler(c.req.raw, context))
app.get("/provider", (c) => handler(c.req.raw, context))
app.get("/provider/auth", (c) => handler(c.req.raw, context))
diff --git a/packages/opencode/test/server/httpapi-experimental.test.ts b/packages/opencode/test/server/httpapi-experimental.test.ts
index cf673dc9697f..3843012804b8 100644
--- a/packages/opencode/test/server/httpapi-experimental.test.ts
+++ b/packages/opencode/test/server/httpapi-experimental.test.ts
@@ -1,10 +1,12 @@
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 { GlobalBus } from "@/bus/global"
import { Instance } from "../../src/project/instance"
import { InstanceRoutes } from "../../src/server/routes/instance"
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/experimental"
+import { Session } from "../../src/session"
import { Database } from "../../src/storage"
import { Log } from "../../src/util"
import { Worktree } from "../../src/worktree"
@@ -22,6 +24,14 @@ function app() {
return InstanceRoutes(websocket)
}
+function runSession(fx: Effect.Effect) {
+ return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer)))
+}
+
+function createSession(input?: Session.CreateInput) {
+ return runSession(Session.Service.use((svc) => svc.create(input)))
+}
+
async function waitReady(directory: string) {
return await new Promise((resolve, reject) => {
const timer = setTimeout(() => {
@@ -126,6 +136,43 @@ describe("experimental HttpApi", () => {
expect(await switched.json()).toBe(true)
})
+ test("serves global session list through Hono bridge", async () => {
+ await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
+
+ const first = await Instance.provide({
+ directory: tmp.path,
+ fn: async () => createSession({ title: "page-one" }),
+ })
+ await new Promise((resolve) => setTimeout(resolve, 5))
+ const second = await Instance.provide({
+ directory: tmp.path,
+ fn: async () => createSession({ title: "page-two" }),
+ })
+
+ const headers = { "x-opencode-directory": tmp.path }
+ const page = await app().request(
+ `${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.path, limit: "1" })}`,
+ { headers },
+ )
+ expect(page.status).toBe(200)
+ expect(page.headers.get("x-next-cursor")).toBeTruthy()
+
+ const body = (await page.json()) as Session.GlobalInfo[]
+ expect(body.map((session) => session.id)).toEqual([second.id])
+ expect(body[0].project?.id).toBe(second.projectID)
+
+ const next = await app().request(
+ `${ExperimentalPaths.session}?${new URLSearchParams({
+ directory: tmp.path,
+ limit: "10",
+ cursor: body[0].time.updated.toString(),
+ })}`,
+ { headers },
+ )
+ expect(next.status).toBe(200)
+ expect(((await next.json()) as Session.GlobalInfo[]).map((session) => session.id)).toContain(first.id)
+ })
+
testWorktreeMutations("serves worktree mutations through Hono bridge", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })