summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/opencode/src/server/routes/experimental.ts60
-rw-r--r--packages/opencode/src/session/index.ts91
-rw-r--r--packages/opencode/test/server/global-session-list.test.ts89
3 files changed, 239 insertions, 1 deletions
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"
@@ -185,6 +186,65 @@ export const ExperimentalRoutes = 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: "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({
summary: "Get MCP resources",
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<typeof Info>
+ export const ProjectInfo = z
+ .object({
+ id: z.string(),
+ name: z.string().optional(),
+ worktree: z.string(),
+ })
+ .meta({
+ ref: "ProjectSummary",
+ })
+ export type ProjectInfo = z.output<typeof ProjectInfo>
+
+ export const GlobalInfo = Info.extend({
+ project: ProjectInfo.nullable(),
+ }).meta({
+ ref: "GlobalSession",
+ })
+ export type GlobalInfo = z.output<typeof GlobalInfo>
+
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<string, ProjectInfo>()
+
+ 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)
+ })
+})