/** * Standalone implementation of listSessions for the Agent SDK. * * Dependencies are kept minimal and portable — no bootstrap/state.ts, * no analytics, no bun:bundle, no module-scope mutable state. This module * can be imported safely from the SDK entrypoint without triggering CLI * initialization or pulling in expensive dependency chains. */ import type { Dirent } from 'fs' import { readdir, stat } from 'fs/promises' import { basename, join } from 'path' import { getWorktreePathsPortable } from './getWorktreePathsPortable.js' import type { LiteSessionFile } from './sessionStoragePortable.js' import { canonicalizePath, extractFirstPromptFromHead, extractJsonStringField, extractLastJsonStringField, findProjectDir, getProjectsDir, MAX_SANITIZED_LENGTH, readSessionLite, sanitizePath, validateUuid, } from './sessionStoragePortable.js' /** * Session metadata returned by listSessions. * Contains only data extractable from stat + head/tail reads — no full * JSONL parsing required. */ export type SessionInfo = { sessionId: string summary: string lastModified: number fileSize?: number customTitle?: string firstPrompt?: string gitBranch?: string cwd?: string tag?: string /** Epoch ms — from first entry's ISO timestamp. Undefined if unparseable. */ createdAt?: number } export type ListSessionsOptions = { /** * Directory to list sessions for. When provided, returns sessions for * this project directory (and optionally its git worktrees). When omitted, * returns sessions across all projects. */ dir?: string /** Maximum number of sessions to return. */ limit?: number /** * Number of sessions to skip from the start of the sorted result set. * Use with `limit` for pagination. Defaults to 0. */ offset?: number /** * When `dir` is provided and the directory is inside a git repository, * include sessions from all git worktree paths. Defaults to `true`. */ includeWorktrees?: boolean } // --------------------------------------------------------------------------- // Field extraction — shared by listSessionsImpl and getSessionInfoImpl // --------------------------------------------------------------------------- /** * Parses SessionInfo fields from a lite session read (head/tail/stat). * Returns null for sidechain sessions or metadata-only sessions with no * extractable summary. * * Exported for reuse by getSessionInfoImpl. */ export function parseSessionInfoFromLite( sessionId: string, lite: LiteSessionFile, projectPath?: string, ): SessionInfo | null { const { head, tail, mtime, size } = lite // Check first line for sidechain sessions const firstNewline = head.indexOf('\n') const firstLine = firstNewline >= 0 ? head.slice(0, firstNewline) : head if ( firstLine.includes('"isSidechain":true') || firstLine.includes('"isSidechain": true') ) { return null } // User title (customTitle) wins over AI title (aiTitle); distinct // field names mean extractLastJsonStringField naturally disambiguates. const customTitle = extractLastJsonStringField(tail, 'customTitle') || extractLastJsonStringField(head, 'customTitle') || extractLastJsonStringField(tail, 'aiTitle') || extractLastJsonStringField(head, 'aiTitle') || undefined const firstPrompt = extractFirstPromptFromHead(head) || undefined // First entry's ISO timestamp → epoch ms. More reliable than // stat().birthtime which is unsupported on some filesystems. const firstTimestamp = extractJsonStringField(head, 'timestamp') let createdAt: number | undefined if (firstTimestamp) { const parsed = Date.parse(firstTimestamp) if (!Number.isNaN(parsed)) createdAt = parsed } // last-prompt tail entry (captured by extractFirstPrompt at write // time, filtered) shows what the user was most recently doing. // Head scan is fallback for sessions without a last-prompt entry. const summary = customTitle || extractLastJsonStringField(tail, 'lastPrompt') || extractLastJsonStringField(tail, 'summary') || firstPrompt // Skip metadata-only sessions (no title, no summary, no prompt) if (!summary) return null const gitBranch = extractLastJsonStringField(tail, 'gitBranch') || extractJsonStringField(head, 'gitBranch') || undefined const sessionCwd = extractJsonStringField(head, 'cwd') || projectPath || undefined // Type-scope tag extraction to the {"type":"tag"} JSONL line to avoid // collision with tool_use inputs containing a `tag` parameter (git tag, // Docker tags, cloud resource tags). Mirrors sessionStorage.ts:608. const tagLine = tail.split('\n').findLast(l => l.startsWith('{"type":"tag"')) const tag = tagLine ? extractLastJsonStringField(tagLine, 'tag') || undefined : undefined return { sessionId, summary, lastModified: mtime, fileSize: size, customTitle, firstPrompt, gitBranch, cwd: sessionCwd, tag, createdAt, } } // --------------------------------------------------------------------------- // Candidate discovery — stat-only pass. Cheap: 1 syscall per file, no // data reads. Lets us sort/filter before doing expensive head/tail reads. // --------------------------------------------------------------------------- type Candidate = { sessionId: string filePath: string mtime: number /** Project path for cwd fallback when file lacks a cwd field. */ projectPath?: string } /** * Lists candidate session files in a directory via readdir, optionally * stat'ing each for mtime. When `doStat` is false, mtime is set to 0 * (caller must sort/dedup after reading file contents instead). */ export async function listCandidates( projectDir: string, doStat: boolean, projectPath?: string, ): Promise { let names: string[] try { names = await readdir(projectDir) } catch { return [] } const results = await Promise.all( names.map(async (name): Promise => { if (!name.endsWith('.jsonl')) return null const sessionId = validateUuid(name.slice(0, -6)) if (!sessionId) return null const filePath = join(projectDir, name) if (!doStat) return { sessionId, filePath, mtime: 0, projectPath } try { const s = await stat(filePath) return { sessionId, filePath, mtime: s.mtime.getTime(), projectPath } } catch { return null } }), ) return results.filter((c): c is Candidate => c !== null) } /** * Reads a candidate's file contents and extracts full SessionInfo. * Returns null if the session should be filtered out (sidechain, no summary). */ async function readCandidate(c: Candidate): Promise { const lite = await readSessionLite(c.filePath) if (!lite) return null const info = parseSessionInfoFromLite(c.sessionId, lite, c.projectPath) if (!info) return null // Prefer stat-pass mtime for sort-key consistency; fall back to // lite.mtime when doStat=false (c.mtime is 0 placeholder). if (c.mtime) info.lastModified = c.mtime return info } // --------------------------------------------------------------------------- // Sort + limit — batch-read candidates in sorted order until `limit` // survivors are collected (some candidates filter out on full read). // --------------------------------------------------------------------------- /** Batch size for concurrent reads when walking the sorted candidate list. */ const READ_BATCH_SIZE = 32 /** * Sort comparator: lastModified desc, then sessionId desc for stable * ordering across mtime ties. */ function compareDesc(a: Candidate, b: Candidate): number { if (b.mtime !== a.mtime) return b.mtime - a.mtime return b.sessionId < a.sessionId ? -1 : b.sessionId > a.sessionId ? 1 : 0 } async function applySortAndLimit( candidates: Candidate[], limit: number | undefined, offset: number, ): Promise { candidates.sort(compareDesc) const sessions: SessionInfo[] = [] // limit: 0 means "no limit" (matches getSessionMessages semantics) const want = limit && limit > 0 ? limit : Infinity let skipped = 0 // Dedup post-filter: since candidates are sorted mtime-desc, the first // non-null read per sessionId is naturally the newest valid copy. // Pre-filter dedup would drop a session entirely if its newest-mtime // copy is unreadable/empty, diverging from the no-stat readAllAndSort path. const seen = new Set() for (let i = 0; i < candidates.length && sessions.length < want; ) { const batchEnd = Math.min(i + READ_BATCH_SIZE, candidates.length) const batch = candidates.slice(i, batchEnd) const results = await Promise.all(batch.map(readCandidate)) for (let j = 0; j < results.length && sessions.length < want; j++) { i++ const r = results[j] if (!r) continue if (seen.has(r.sessionId)) continue seen.add(r.sessionId) if (skipped < offset) { skipped++ continue } sessions.push(r) } } return sessions } /** * Read-all path for when no limit/offset is set. Skips the stat pass * entirely — reads every candidate, then sorts/dedups on real mtimes * from readSessionLite. Matches pre-refactor I/O cost (no extra stats). */ async function readAllAndSort(candidates: Candidate[]): Promise { const all = await Promise.all(candidates.map(readCandidate)) const byId = new Map() for (const s of all) { if (!s) continue const existing = byId.get(s.sessionId) if (!existing || s.lastModified > existing.lastModified) { byId.set(s.sessionId, s) } } const sessions = [...byId.values()] sessions.sort((a, b) => b.lastModified !== a.lastModified ? b.lastModified - a.lastModified : b.sessionId < a.sessionId ? -1 : b.sessionId > a.sessionId ? 1 : 0, ) return sessions } // --------------------------------------------------------------------------- // Project directory enumeration (single-project vs all-projects) // --------------------------------------------------------------------------- /** * Gathers candidate session files for a specific project directory * (and optionally its git worktrees). */ async function gatherProjectCandidates( dir: string, includeWorktrees: boolean, doStat: boolean, ): Promise { const canonicalDir = await canonicalizePath(dir) let worktreePaths: string[] if (includeWorktrees) { try { worktreePaths = await getWorktreePathsPortable(canonicalDir) } catch { worktreePaths = [] } } else { worktreePaths = [] } // No worktrees (or git not available / scanning disabled) — just scan the single project dir if (worktreePaths.length <= 1) { const projectDir = await findProjectDir(canonicalDir) if (!projectDir) return [] return listCandidates(projectDir, doStat, canonicalDir) } // Worktree-aware scanning: find all project dirs matching any worktree const projectsDir = getProjectsDir() const caseInsensitive = process.platform === 'win32' // Sort worktree paths by sanitized prefix length (longest first) so // more specific matches take priority over shorter ones const indexed = worktreePaths.map(wt => { const sanitized = sanitizePath(wt) return { path: wt, prefix: caseInsensitive ? sanitized.toLowerCase() : sanitized, } }) indexed.sort((a, b) => b.prefix.length - a.prefix.length) let allDirents: Dirent[] try { allDirents = await readdir(projectsDir, { withFileTypes: true }) } catch { // Fall back to single project dir const projectDir = await findProjectDir(canonicalDir) if (!projectDir) return [] return listCandidates(projectDir, doStat, canonicalDir) } const all: Candidate[] = [] const seenDirs = new Set() // Always include the user's actual directory (handles subdirectories // like /repo/packages/my-app that won't match worktree root prefixes) const canonicalProjectDir = await findProjectDir(canonicalDir) if (canonicalProjectDir) { const dirBase = basename(canonicalProjectDir) seenDirs.add(caseInsensitive ? dirBase.toLowerCase() : dirBase) all.push( ...(await listCandidates(canonicalProjectDir, doStat, canonicalDir)), ) } for (const dirent of allDirents) { if (!dirent.isDirectory()) continue const dirName = caseInsensitive ? dirent.name.toLowerCase() : dirent.name if (seenDirs.has(dirName)) continue for (const { path: wtPath, prefix } of indexed) { // Only use startsWith for truncated paths (>MAX_SANITIZED_LENGTH) where // a hash suffix follows. For short paths, require exact match to avoid // /root/project matching /root/project-foo. const isMatch = dirName === prefix || (prefix.length >= MAX_SANITIZED_LENGTH && dirName.startsWith(prefix + '-')) if (isMatch) { seenDirs.add(dirName) all.push( ...(await listCandidates( join(projectsDir, dirent.name), doStat, wtPath, )), ) break } } } return all } /** * Gathers candidate session files across all project directories. */ async function gatherAllCandidates(doStat: boolean): Promise { const projectsDir = getProjectsDir() let dirents: Dirent[] try { dirents = await readdir(projectsDir, { withFileTypes: true }) } catch { return [] } const perProject = await Promise.all( dirents .filter(d => d.isDirectory()) .map(d => listCandidates(join(projectsDir, d.name), doStat)), ) return perProject.flat() } /** * Lists sessions with metadata extracted from stat + head/tail reads. * * When `dir` is provided, returns sessions for that project directory * and its git worktrees. When omitted, returns sessions across all * projects. * * Pagination via `limit`/`offset` operates on the filtered, sorted result * set. When either is set, a cheap stat-only pass sorts candidates before * expensive head/tail reads — so `limit: 20` on a directory with 1000 * sessions does ~1000 stats + ~20 content reads, not 1000 content reads. * When neither is set, stat is skipped (read-all-then-sort, same I/O cost * as the original implementation). */ export async function listSessionsImpl( options?: ListSessionsOptions, ): Promise { const { dir, limit, offset, includeWorktrees } = options ?? {} const off = offset ?? 0 // Only stat when we need to sort before reading (won't read all anyway). // limit: 0 means "no limit" (see applySortAndLimit), so treat it as unset. const doStat = (limit !== undefined && limit > 0) || off > 0 const candidates = dir ? await gatherProjectCandidates(dir, includeWorktrees ?? true, doStat) : await gatherAllCandidates(doStat) if (!doStat) return readAllAndSort(candidates) return applySortAndLimit(candidates, limit, off) }