chore: initialize recovered claude workspace
This commit is contained in:
454
src/utils/listSessionsImpl.ts
Normal file
454
src/utils/listSessionsImpl.ts
Normal file
@@ -0,0 +1,454 @@
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
Reference in New Issue
Block a user