Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions packages/app/src/pages/layout/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,26 @@ describe("layout workspace helpers", () => {
expect(result?.id).toBe("root")
})

test("includes root sessions from subdirectories of the same workspace", () => {
const result = latestRootSession(
[
{
path: { directory: "/workspace" },
session: [
session({
id: "subdir",
directory: "/workspace/src",
time: { created: 30, updated: 30, archived: undefined },
}),
],
},
],
120_000,
)

expect(result?.id).toBe("subdir")
})

test("formats fallback project display name", () => {
expect(displayName({ worktree: "/tmp/app" })).toBe("app")
expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App")
Expand Down
11 changes: 10 additions & 1 deletion packages/app/src/pages/layout/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ export const workspaceKey = (directory: string) => {
return value.replace(/\/+$/, "")
}

const within = (dir: string, root: string) => {
const a = workspaceKey(dir)
const b = workspaceKey(root)
if (a === b) return true
if (b === "/") return a.startsWith("/")
if (/^[A-Za-z]:\/$/i.test(b)) return a.startsWith(b)
return a.startsWith(`${b}/`)
}

function sortSessions(now: number) {
const oneMinuteAgo = now - 60 * 1000
return (a: Session, b: Session) => {
Expand All @@ -29,7 +38,7 @@ function sortSessions(now: number) {
}

const isRootVisibleSession = (session: Session, directory: string) =>
workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived
within(session.directory, directory) && !session.parentID && !session.time?.archived

const roots = (store: SessionStore) =>
(store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory))
Expand Down
11 changes: 10 additions & 1 deletion packages/opencode/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ import { SkillTool } from "../../tool/skill"
import { BashTool } from "../../tool/bash"
import { TodoWriteTool } from "../../tool/todo"
import { Locale } from "../../util/locale"
import { Session } from "../../session"

export function continueID(input: { search?: string }) {
return [...Session.discover({ roots: true, search: input.search, limit: 1 })][0]?.id
}

type ToolProps<T extends Tool.Info> = {
input: Tool.InferParameters<T>
Expand Down Expand Up @@ -238,6 +243,10 @@ export const RunCommand = cmd({
describe: "continue the last session",
type: "boolean",
})
.option("continue-search", {
describe: "filter continued sessions by title",
type: "string",
})
.option("session", {
alias: ["s"],
describe: "session id to continue",
Expand Down Expand Up @@ -379,7 +388,7 @@ export const RunCommand = cmd({
}

async function session(sdk: OpencodeClient) {
const baseID = args.continue ? (await sdk.session.list()).data?.find((s) => !s.parentID)?.id : args.session
const baseID = args.session ?? (args.continue ? continueID({ search: args.continueSearch }) : undefined)

if (baseID && args.fork) {
const forked = await sdk.session.fork({ sessionID: baseID })
Expand Down
36 changes: 31 additions & 5 deletions packages/opencode/src/cli/cmd/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ import { EOL } from "os"
import path from "path"
import { which } from "../../util/which"

type Row = Pick<Session.Info, "id" | "title" | "projectID" | "directory" | "time"> & {
project?: { worktree: string; name?: string } | null
db?: string
}

function pagerCmd(): string[] {
const lessOptions = ["-R", "-S"]
if (process.platform !== "win32") {
Expand Down Expand Up @@ -87,10 +92,19 @@ export const SessionListCommand = cmd({
choices: ["table", "json"],
default: "table",
})
.option("global", {
alias: ["all"],
describe: "list sessions across discovered local databases",
type: "boolean",
})
.option("search", {
describe: "filter sessions by title",
type: "string",
})
},
handler: async (args) => {
await bootstrap(process.cwd(), async () => {
const sessions = [...Session.list({ roots: true, limit: args.maxCount })]
const sessions = listItems({ global: args.global, search: args.search, limit: args.maxCount })

if (sessions.length === 0) {
return
Expand Down Expand Up @@ -127,33 +141,45 @@ export const SessionListCommand = cmd({
},
})

function formatSessionTable(sessions: Session.Info[]): string {
export function listItems(input: { global?: boolean; search?: string; limit?: number }) {
if (input.global) return [...Session.discover({ roots: true, search: input.search, limit: input.limit })]
return [...Session.list({ roots: true, search: input.search, limit: input.limit })]
}

function formatSessionTable(sessions: Row[]): string {
const lines: string[] = []

const maxIdWidth = Math.max(20, ...sessions.map((s) => s.id.length))
const maxTitleWidth = Math.max(25, ...sessions.map((s) => s.title.length))
const hasProject = sessions.some((s) => s.project?.worktree)

const header = `Session ID${" ".repeat(maxIdWidth - 10)} Title${" ".repeat(maxTitleWidth - 5)} Updated`
const header = hasProject
? `Session ID${" ".repeat(maxIdWidth - 10)} Title${" ".repeat(maxTitleWidth - 5)} Updated Project`
: `Session ID${" ".repeat(maxIdWidth - 10)} Title${" ".repeat(maxTitleWidth - 5)} Updated`
lines.push(header)
lines.push("─".repeat(header.length))
for (const session of sessions) {
const truncatedTitle = Locale.truncate(session.title, maxTitleWidth)
const timeStr = Locale.todayTimeOrDateTime(session.time.updated)
const line = `${session.id.padEnd(maxIdWidth)} ${truncatedTitle.padEnd(maxTitleWidth)} ${timeStr}`
const line = hasProject
? `${session.id.padEnd(maxIdWidth)} ${truncatedTitle.padEnd(maxTitleWidth)} ${timeStr} ${session.project?.name ?? session.project?.worktree ?? ""}`
: `${session.id.padEnd(maxIdWidth)} ${truncatedTitle.padEnd(maxTitleWidth)} ${timeStr}`
lines.push(line)
}

return lines.join(EOL)
}

function formatSessionJSON(sessions: Session.Info[]): string {
function formatSessionJSON(sessions: Row[]): string {
const jsonData = sessions.map((session) => ({
id: session.id,
title: session.title,
updated: session.time.updated,
created: session.time.created,
projectId: session.projectID,
directory: session.directory,
project: session.project,
db: session.db,
}))
return JSON.stringify(jsonData, null, 2)
}
161 changes: 161 additions & 0 deletions packages/opencode/src/session/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Slug } from "@opencode-ai/util/slug"
import path from "path"
import { readdirSync } from "fs"
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { Decimal } from "decimal.js"
Expand Down Expand Up @@ -34,6 +35,7 @@ import { Global } from "@/global"
import type { LanguageModelV2Usage } from "@ai-sdk/provider"
import { Effect, Layer, Scope, ServiceMap } from "effect"
import { makeRuntime } from "@/effect/run-service"
import { Database as Sqlite } from "bun:sqlite"

export namespace Session {
const log = Log.create({ service: "session" })
Expand Down Expand Up @@ -183,6 +185,11 @@ export namespace Session {
})
export type GlobalInfo = z.output<typeof GlobalInfo>

export const DiscoverInfo = GlobalInfo.extend({
db: z.string(),
})
export type DiscoverInfo = z.output<typeof DiscoverInfo>

export const Event = {
Created: SyncEvent.define({
type: "session.created",
Expand Down Expand Up @@ -241,6 +248,160 @@ export namespace Session {
return path.join(base, [input.time.created, input.slug].join("-") + ".md")
}

function files() {
try {
return [
...new Set(
readdirSync(Global.Path.data)
.filter((x) => /^opencode.*\.db$/i.test(x))
.map((x) => path.join(Global.Path.data, x)),
),
]
} catch {
return []
}
}

function sandboxes(input: unknown) {
if (typeof input !== "string") return []
try {
const value = JSON.parse(input)
return Array.isArray(value) ? value : []
} catch {
return []
}
}

function match(
row: Record<string, unknown>,
input?: {
directory?: string
roots?: boolean
start?: number
cursor?: number
search?: string
archived?: boolean
},
) {
if (input?.directory) {
const dir = row.directory
const root = row.project_worktree
const boxes = sandboxes(row.project_sandboxes)
if (dir !== input.directory && root !== input.directory && !boxes.includes(input.directory)) return false
}
if (input?.roots && row.parent_id != null) return false
if (input?.start && Number(row.time_updated) < input.start) return false
if (input?.cursor && Number(row.time_updated) >= input.cursor) return false
if (
input?.search &&
!String(row.title ?? "")
.toLowerCase()
.includes(input.search.toLowerCase())
)
return false
if (!input?.archived && row.time_archived != null) return false
return true
}

function* scan(input?: {
directory?: string
roots?: boolean
start?: number
cursor?: number
search?: string
limit?: number
archived?: boolean
}) {
const seen = new Set<string>()
const rows = files()
.flatMap((file) => {
try {
const db = new Sqlite(file, { readonly: true })
const rows = db
.query(
`SELECT session.*, project.id AS project_summary_id, project.name AS project_name, project.worktree AS project_worktree, project.sandboxes AS project_sandboxes
FROM session
LEFT JOIN project ON project.id = session.project_id`,
)
.all() as Record<string, unknown>[]
db.close()
return rows
.filter((row) => match(row, input))
.map((row) => ({
...fromRow(row as SessionRow),
project: row.project_summary_id
? {
id: row.project_summary_id as string,
name: (row.project_name as string | null) ?? undefined,
worktree: row.project_worktree as string,
}
: null,
db: file,
}))
} catch {
return []
}
})
.sort((a, b) => b.time.updated - a.time.updated || b.id.localeCompare(a.id))
.filter((item) => {
if (seen.has(item.id)) return false
seen.add(item.id)
return true
})

for (const row of rows.slice(0, input?.limit ?? 100)) {
yield row
}
}

export function discover(input?: {
directory?: string
roots?: boolean
start?: number
cursor?: number
search?: string
limit?: number
archived?: boolean
}) {
return scan(input)
}

export function info(id: SessionID | string) {
const item = [...scan({ limit: Number.MAX_SAFE_INTEGER, archived: true })].find((x) => x.id === id)
if (!item) throw new NotFoundError({ message: `Session not found: ${id}` })
return item
}

export function read(input: { sessionID: SessionID | string; limit?: number }) {
const session = info(input.sessionID)
const db = new Sqlite(session.db, { readonly: true })
const rows = db
.query(`SELECT * FROM message WHERE session_id = ? ORDER BY time_created DESC, id DESC LIMIT ?`)
.all(String(input.sessionID), input.limit ?? 100) as Record<string, unknown>[]
const result = rows.toReversed().map((row) => {
const parts = db.query(`SELECT * FROM part WHERE message_id = ? ORDER BY id ASC`).all(String(row.id)) as Record<
string,
unknown
>[]
return {
info: {
...(JSON.parse(String(row.data)) as object),
id: row.id,
sessionID: row.session_id,
time: { created: Number(row.time_created) },
},
parts: parts.map((part) => ({
...(JSON.parse(String(part.data)) as object),
id: part.id,
sessionID: part.session_id,
messageID: part.message_id,
})),
}
})
db.close()
return result as MessageV2.WithParts[]
}

export const getUsage = (input: {
model: Provider.Model
usage: LanguageModelV2Usage
Expand Down
8 changes: 8 additions & 0 deletions packages/opencode/src/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ import { makeRuntime } from "@/effect/run-service"
import { DesktopTool } from "./desktop"
import { BrowserTool } from "./browser"
import { SwarmTool } from "./swarm"
import { SessionListTool } from "./session_list"
import { SessionSearchTool } from "./session_search"
import { SessionInfoTool } from "./session_info"
import { SessionReadTool } from "./session_read"
import { EnterWorktreeTool, ExitWorktreeTool } from "./worktree"
import { BackgroundStartTool, BackgroundOutputTool, BackgroundCancelTool } from "./background"

Expand Down Expand Up @@ -137,6 +141,10 @@ export namespace ToolRegistry {
DesktopTool,
BrowserTool,
SwarmTool,
SessionListTool,
SessionSearchTool,
SessionInfoTool,
SessionReadTool,
BackgroundStartTool,
BackgroundOutputTool,
BackgroundCancelTool,
Expand Down
18 changes: 18 additions & 0 deletions packages/opencode/src/tool/session_info.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import z from "zod"
import { Tool } from "./tool"
import { Session } from "../session"

export const SessionInfoTool = Tool.define("session_info", {
description: "Get metadata for a local OpenCode session from discovered databases.",
parameters: z.object({
sessionID: z.string(),
}),
async execute(input) {
const item = Session.info(input.sessionID)
return {
title: input.sessionID,
output: JSON.stringify(item, null, 2),
metadata: {},
}
},
})
Loading