From c0e0156680a174bcffde615bcc07507433009b6a Mon Sep 17 00:00:00 2001 From: Ryan Vogel Date: Tue, 17 Feb 2026 18:27:11 -0500 Subject: [PATCH] feat: add list sessions for all sessions --- packages/opencode/src/server/routes/global.ts | 49 +++++++++++ packages/opencode/src/session/index.ts | 87 ++++++++++++++++++- .../test/server/global-session-list.test.ts | 65 ++++++++++++++ 3 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/server/global-session-list.test.ts diff --git a/packages/opencode/src/server/routes/global.ts b/packages/opencode/src/server/routes/global.ts index 5e2df052ec..f4dc27b793 100644 --- a/packages/opencode/src/server/routes/global.ts +++ b/packages/opencode/src/server/routes/global.ts @@ -9,6 +9,7 @@ import { Installation } from "@/installation" import { Log } from "../../util/log" import { lazy } from "../../util/lazy" import { Config } from "../../config/config" +import { Session } from "../../session" import { errors } from "../error" const log = Log.create({ service: "server" }) @@ -105,6 +106,54 @@ export const GlobalRoutes = lazy(() => }) }, ) + .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: "global.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)" }), + 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 sessions: Session.GlobalInfo[] = [] + for await (const session of Session.listGlobal({ + directory: query.directory, + roots: query.roots, + start: query.start, + search: query.search, + limit: query.limit, + archived: query.archived, + })) { + sessions.push(session) + } + return c.json(sessions) + }, + ) .get( "/config", describeRoute({ diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 255f4dd460..556672f14a 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 } 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,71 @@ export namespace Session { } } + export function* listGlobal(input?: { + directory?: string + roots?: boolean + start?: 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?.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 0000000000..fe2f1ccea1 --- /dev/null +++ b/packages/opencode/test/server/global-session-list.test.ts @@ -0,0 +1,65 @@ +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) + }) +})