Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
15 kB · 454 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455/** * 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<Candidate[]> { let names: string[] try { names = await readdir(projectDir) } catch { return [] }
const results = await Promise.all( names.map(async (name): Promise<Candidate | null> => { 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<SessionInfo | null> { 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<SessionInfo[]> { 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<string>()
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<SessionInfo[]> { const all = await Promise.all(candidates.map(readCandidate)) const byId = new Map<string, SessionInfo>() 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<Candidate[]> { 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<string>()
// 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<Candidate[]> { 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<SessionInfo[]> { 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)}