Skip to content
93 changes: 92 additions & 1 deletion src/cli/commands/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ function collectOptions(cmd: Command): OptionValues {
export interface SessionsListOptions {
/** Filter sessions by project ID */
project?: string
/** List all sessions globally (conflicts with --project) */
global?: boolean
/** Search query to filter sessions (fuzzy match) */
search?: string
}
Expand Down Expand Up @@ -104,12 +106,14 @@ export function registerSessionsCommands(parent: Command): void {
.command("list")
.description("List sessions")
.option("-p, --project <projectId>", "Filter by project ID")
.option("-g, --global", "List all sessions globally (default: sessions for current directory)")
.option("-s, --search <query>", "Search query to filter sessions")
.action(function (this: Command) {
const globalOpts = parseGlobalOptions(collectOptions(this))
const cmdOpts = this.opts()
const listOpts: SessionsListOptions = {
project: cmdOpts.project as string | undefined,
global: cmdOpts.global as boolean | undefined,
search: cmdOpts.search as string | undefined,
}
handleSessionsList(globalOpts, listOpts)
Expand Down Expand Up @@ -197,6 +201,9 @@ export function registerSessionsCommands(parent: Command): void {
[
"",
"Examples:",
" opencode-manager sessions list # List sessions for current directory",
" opencode-manager sessions list --global # List all sessions globally",
" opencode-manager sessions list -p my-project # List sessions for specific project",
" opencode-manager sessions list --experimental-sqlite",
" opencode-manager sessions list --db ~/.local/share/opencode/opencode.db",
].join("\n")
Expand All @@ -216,20 +223,104 @@ function buildSessionSearchText(session: SessionRecord): string {
].join(" ").replace(/\s+/g, " ").trim()
}

/**
* Result of inferring project from cwd.
*/
interface InferredProject {
projectId: string
worktree: string
}

/**
* Infer the project ID from the current working directory.
* Finds projects whose worktree is a parent of (or equal to) cwd,
* then selects the deepest match (longest path).
*
* @throws UsageError if no project matches cwd or if multiple projects match at the same depth
*/
async function inferProjectFromCwd(
provider: ReturnType<typeof createProviderFromGlobalOptions>
): Promise<InferredProject> {
const projects = await provider.loadProjectRecords()
const cwd = process.cwd()

// Find all projects whose worktree is a parent of or equal to cwd
const candidates: Array<{ project: typeof projects[0]; depth: number }> = []

for (const project of projects) {
const worktree = project.worktree
// Check if cwd is inside or equal to worktree
if (cwd === worktree || cwd.startsWith(worktree + "/")) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hardcoded / separator won't work on Windows - use path.sep instead

Suggested change
if (cwd === worktree || cwd.startsWith(worktree + "/")) {
if (cwd === worktree || cwd.startsWith(worktree + path.sep)) {
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/cli/commands/sessions.ts
Line: 253

Comment:
hardcoded `/` separator won't work on Windows - use `path.sep` instead

```suggestion
    if (cwd === worktree || cwd.startsWith(worktree + path.sep)) {
```

How can I resolve this? If you propose a fix, please make it concise.

// Depth is the number of path segments in worktree
const depth = worktree.split("/").length
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hardcoded / separator won't work on Windows - use path.sep instead

Suggested change
const depth = worktree.split("/").length
const depth = worktree.split(path.sep).length
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/cli/commands/sessions.ts
Line: 255

Comment:
hardcoded `/` separator won't work on Windows - use `path.sep` instead

```suggestion
      const depth = worktree.split(path.sep).length
```

How can I resolve this? If you propose a fix, please make it concise.

candidates.push({ project, depth })
}
}

if (candidates.length === 0) {
throw new UsageError(
`No project found for current directory: ${cwd}\n` +
`Use --global to list all sessions, or --project <id> to specify a project.`
)
}

// Find the maximum depth
const maxDepth = Math.max(...candidates.map((c) => c.depth))

// Filter to only candidates at max depth
const deepestCandidates = candidates.filter((c) => c.depth === maxDepth)

if (deepestCandidates.length > 1) {
const projectIds = deepestCandidates.map((c) => c.project.projectId).join(", ")
throw new UsageError(
`Ambiguous project match for current directory: ${cwd}\n` +
`Multiple projects match at the same depth: ${projectIds}\n` +
`Use --project <id> to specify which project.`
)
}

const winner = deepestCandidates[0].project
return {
projectId: winner.projectId,
worktree: winner.worktree,
}
}

/**
* Handle the sessions list command.
*/
async function handleSessionsList(
globalOpts: GlobalOptions,
listOpts: SessionsListOptions
): Promise<void> {
// Validate that --global and --project are not used together
if (listOpts.global && listOpts.project) {
throw new UsageError(
"Cannot use --global and --project together. Use one or the other."
)
}

// Create data provider based on global options (JSONL or SQLite backend)
const provider = createProviderFromGlobalOptions(globalOpts)

// Determine the project filter
let projectIdFilter: string | undefined
if (listOpts.global) {
// --global: list all sessions, no project filter
projectIdFilter = undefined
} else if (listOpts.project) {
// --project specified: use it
projectIdFilter = listOpts.project
} else {
// Default: infer project from current working directory
const inferred = await inferProjectFromCwd(provider)
projectIdFilter = inferred.projectId
}

// Load session records from the data layer
// If a project filter is provided, pass it to the loader
let sessions = await provider.loadSessionRecords({
projectId: listOpts.project,
projectId: projectIdFilter,
})

// Apply fuzzy search if search query is provided
Expand Down
4 changes: 2 additions & 2 deletions src/lib/opencode-data-sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -732,8 +732,8 @@ export async function loadSessionRecordsSqlite(

const projectIdColumn = pickColumn(columns, ["project_id", "projectID", "projectId", "project"])
const parentIdColumn = pickColumn(columns, ["parent_id", "parentID", "parentId", "parent"])
const createdColumn = pickColumn(columns, ["created_at", "created", "created_ms", "createdAt"])
const updatedColumn = pickColumn(columns, ["updated_at", "updated", "updated_ms", "updatedAt"])
const createdColumn = pickColumn(columns, ["created_at", "created", "created_ms", "createdAt", "time_created"])
const updatedColumn = pickColumn(columns, ["updated_at", "updated", "updated_ms", "updatedAt", "time_updated"])
const dataColumn = pickColumn(columns, ["data", "metadata", "payload", "json"])
const directoryColumn = pickColumn(columns, ["directory", "cwd", "path", "worktree", "root"])
const titleColumn = pickColumn(columns, ["title", "name"])
Expand Down
4 changes: 2 additions & 2 deletions tests/cli/commands/projects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -904,7 +904,7 @@ describe("projects delete --experimental-sqlite", () => {

it("deletes project and all related sessions/messages/parts atomically", async () => {
// Get initial counts by checking sessions
const sessionsBefore = await $`bun src/bin/opencode-manager.ts sessions list --db ${tempDbPath} --format json`.quiet();
const sessionsBefore = await $`bun src/bin/opencode-manager.ts sessions list --global --db ${tempDbPath} --format json`.quiet();
const parsedSessionsBefore = JSON.parse(sessionsBefore.stdout.toString());
// proj_present has 3 sessions: session_parser_fix, session_add_tests, session_refactor_api
const sessionsInProject = parsedSessionsBefore.data.filter(
Expand All @@ -922,7 +922,7 @@ describe("projects delete --experimental-sqlite", () => {
expect(projectsAfter).not.toContain("proj_present");

// Verify sessions belonging to the project are also gone
const sessionsAfter = await $`bun src/bin/opencode-manager.ts sessions list --db ${tempDbPath} --format json`.quiet();
const sessionsAfter = await $`bun src/bin/opencode-manager.ts sessions list --global --db ${tempDbPath} --format json`.quiet();
const parsedSessionsAfter = JSON.parse(sessionsAfter.stdout.toString());
const sessionsInProjectAfter = parsedSessionsAfter.data.filter(
(s: { projectId: string }) => s.projectId === "proj_present"
Expand Down
Loading