From 7419ebc872ae99f0d8bb8cca0579619f46712723 Mon Sep 17 00:00:00 2001 From: Ryan Vogel Date: Fri, 20 Feb 2026 08:45:12 -0500 Subject: [PATCH] feat: add list sessions for all sessions (experimental) (#14038) --- .../src/server/routes/experimental.ts | 60 ++++++++++++ packages/opencode/src/session/index.ts | 91 ++++++++++++++++++- .../test/server/global-session-list.test.ts | 89 ++++++++++++++++++ 3 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/server/global-session-list.test.ts diff --git a/packages/opencode/src/server/routes/experimental.ts b/packages/opencode/src/server/routes/experimental.ts index 3c28331bd..8d156c03d 100644 --- a/packages/opencode/src/server/routes/experimental.ts +++ b/packages/opencode/src/server/routes/experimental.ts @@ -6,6 +6,7 @@ import { Worktree } from "../../worktree" import { Instance } from "../../project/instance" import { Project } from "../../project/project" import { MCP } from "../../mcp" +import { Session } from "../../session" import { zodToJsonSchema } from "zod-to-json-schema" import { errors } from "../error" import { lazy } from "../../util/lazy" @@ -184,6 +185,65 @@ export const ExperimentalRoutes = lazy(() => return c.json(true) }, ) + .get( + "/session", + describeRoute({ + summary: "List sessions", + description: + "Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.", + operationId: "experimental.session.list", + responses: { + 200: { + description: "List of sessions", + content: { + "application/json": { + schema: resolver(Session.GlobalInfo.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)" }), + cursor: z.coerce + .number() + .optional() + .meta({ description: "Return sessions updated before 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" }), + archived: z.coerce.boolean().optional().meta({ description: "Include archived sessions (default false)" }), + }), + ), + async (c) => { + const query = c.req.valid("query") + const limit = query.limit ?? 100 + const sessions: Session.GlobalInfo[] = [] + for await (const session of Session.listGlobal({ + directory: query.directory, + roots: query.roots, + start: query.start, + cursor: query.cursor, + search: query.search, + limit: limit + 1, + archived: query.archived, + })) { + sessions.push(session) + } + const hasMore = sessions.length > limit + const list = hasMore ? sessions.slice(0, limit) : sessions + if (hasMore && list.length > 0) { + c.header("x-next-cursor", String(list[list.length - 1].time.updated)) + } + return c.json(list) + }, + ) .get( "/resource", describeRoute({ diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index b07a049c8..8454a9c3e 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -10,8 +10,10 @@ import { Flag } from "../flag/flag" import { Identifier } from "../id/id" import { Installation } from "../installation" -import { Database, NotFoundError, eq, and, or, gte, isNull, desc, like } from "../storage/db" +import { Database, NotFoundError, eq, and, or, gte, isNull, desc, like, inArray, lt } from "../storage/db" +import type { SQL } from "../storage/db" import { SessionTable, MessageTable, PartTable } from "./session.sql" +import { ProjectTable } from "../project/project.sql" import { Storage } from "@/storage/storage" import { Log } from "../util/log" import { MessageV2 } from "./message-v2" @@ -154,6 +156,24 @@ export namespace Session { }) export type Info = z.output + export const ProjectInfo = z + .object({ + id: z.string(), + name: z.string().optional(), + worktree: z.string(), + }) + .meta({ + ref: "ProjectSummary", + }) + export type ProjectInfo = z.output + + export const GlobalInfo = Info.extend({ + project: ProjectInfo.nullable(), + }).meta({ + ref: "GlobalSession", + }) + export type GlobalInfo = z.output + export const Event = { Created: BusEvent.define( "session.created", @@ -544,6 +564,75 @@ export namespace Session { } } + export function* listGlobal(input?: { + directory?: string + roots?: boolean + start?: number + cursor?: number + search?: string + limit?: number + archived?: boolean + }) { + const conditions: SQL[] = [] + + if (input?.directory) { + conditions.push(eq(SessionTable.directory, input.directory)) + } + if (input?.roots) { + conditions.push(isNull(SessionTable.parent_id)) + } + if (input?.start) { + conditions.push(gte(SessionTable.time_updated, input.start)) + } + if (input?.cursor) { + conditions.push(lt(SessionTable.time_updated, input.cursor)) + } + if (input?.search) { + conditions.push(like(SessionTable.title, `%${input.search}%`)) + } + if (!input?.archived) { + conditions.push(isNull(SessionTable.time_archived)) + } + + const limit = input?.limit ?? 100 + + const rows = Database.use((db) => { + const query = + conditions.length > 0 + ? db + .select() + .from(SessionTable) + .where(and(...conditions)) + : db.select().from(SessionTable) + return query.orderBy(desc(SessionTable.time_updated), desc(SessionTable.id)).limit(limit).all() + }) + + const ids = [...new Set(rows.map((row) => row.project_id))] + const projects = new Map() + + if (ids.length > 0) { + const items = Database.use((db) => + db + .select({ id: ProjectTable.id, name: ProjectTable.name, worktree: ProjectTable.worktree }) + .from(ProjectTable) + .where(inArray(ProjectTable.id, ids)) + .all(), + ) + for (const item of items) { + projects.set(item.id, { + id: item.id, + name: item.name ?? undefined, + worktree: item.worktree, + }) + } + } + + for (const row of rows) { + const project = projects.get(row.project_id) ?? null + yield { ...fromRow(row), project } + } + } + export const children = fn(Identifier.schema("session"), async (parentID) => { const project = Instance.project const rows = Database.use((db) => diff --git a/packages/opencode/test/server/global-session-list.test.ts b/packages/opencode/test/server/global-session-list.test.ts new file mode 100644 index 000000000..05d6de04b --- /dev/null +++ b/packages/opencode/test/server/global-session-list.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from "bun:test" +import { Instance } from "../../src/project/instance" +import { Project } from "../../src/project/project" +import { Session } from "../../src/session" +import { Log } from "../../src/util/log" +import { tmpdir } from "../fixture/fixture" + +Log.init({ print: false }) + +describe("Session.listGlobal", () => { + test("lists sessions across projects with project metadata", async () => { + await using first = await tmpdir({ git: true }) + await using second = await tmpdir({ git: true }) + + const firstSession = await Instance.provide({ + directory: first.path, + fn: async () => Session.create({ title: "first-session" }), + }) + const secondSession = await Instance.provide({ + directory: second.path, + fn: async () => Session.create({ title: "second-session" }), + }) + + const sessions = [...Session.listGlobal({ limit: 200 })] + const ids = sessions.map((session) => session.id) + + expect(ids).toContain(firstSession.id) + expect(ids).toContain(secondSession.id) + + const firstProject = Project.get(firstSession.projectID) + const secondProject = Project.get(secondSession.projectID) + + const firstItem = sessions.find((session) => session.id === firstSession.id) + const secondItem = sessions.find((session) => session.id === secondSession.id) + + expect(firstItem?.project?.id).toBe(firstProject?.id) + expect(firstItem?.project?.worktree).toBe(firstProject?.worktree) + expect(secondItem?.project?.id).toBe(secondProject?.id) + expect(secondItem?.project?.worktree).toBe(secondProject?.worktree) + }) + + test("excludes archived sessions by default", async () => { + await using tmp = await tmpdir({ git: true }) + + const archived = await Instance.provide({ + directory: tmp.path, + fn: async () => Session.create({ title: "archived-session" }), + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => Session.setArchived({ sessionID: archived.id, time: Date.now() }), + }) + + const sessions = [...Session.listGlobal({ limit: 200 })] + const ids = sessions.map((session) => session.id) + + expect(ids).not.toContain(archived.id) + + const allSessions = [...Session.listGlobal({ limit: 200, archived: true })] + const allIds = allSessions.map((session) => session.id) + + expect(allIds).toContain(archived.id) + }) + + test("supports cursor pagination", async () => { + await using tmp = await tmpdir({ git: true }) + + const first = await Instance.provide({ + directory: tmp.path, + fn: async () => Session.create({ title: "page-one" }), + }) + await new Promise((resolve) => setTimeout(resolve, 5)) + const second = await Instance.provide({ + directory: tmp.path, + fn: async () => Session.create({ title: "page-two" }), + }) + + const page = [...Session.listGlobal({ directory: tmp.path, limit: 1 })] + expect(page.length).toBe(1) + expect(page[0].id).toBe(second.id) + + const next = [...Session.listGlobal({ directory: tmp.path, limit: 10, cursor: page[0].time.updated })] + const ids = next.map((session) => session.id) + + expect(ids).toContain(first.id) + expect(ids).not.toContain(second.id) + }) +})