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 } })