/** * ProjectScanner service - Scans ~/.claude/projects/ directory and lists all projects. * * Responsibilities: * - Read project directories from ~/.claude/projects/ * - Decode directory names to original paths (with cwd fallback) * - List session files for each project * - Read task list data from ~/.claude/todos/ * - Return sorted list of projects by recent activity * * Delegates to specialized services: * - SessionContentFilter: Noise detection and message filtering * - WorktreeGrouper: Git repository grouping * - SubagentLocator: Subagent file lookup * - SessionSearcher: Search functionality */ import { AUTO_CLAUDE_DIR, CCSWITCH_DIR, CLAUDE_CODE_DIR, CLAUDE_WORKTREES_DIR, CONDUCTOR_DIR, CURSOR_DIR, TWENTYFIRST_DIR, VIBE_KANBAN_DIR, WORKSPACES_DIR, WORKTREES_DIR, } from '@main/constants/worktreePatterns'; import { type PaginatedSessionsResult, type Project, type RepositoryGroup, type SearchSessionsResult, type Session, type SessionCursor, type SessionMetadataLevel, type SessionsByIdsOptions, type SessionsPaginationOptions, type WorktreeSource, } from '@main/types'; import { analyzeSessionFileMetadata, extractCwd, type SessionFileMetadata, } from '@main/utils/jsonl'; import { buildSessionPath, buildSubagentsPath, buildTodoPath, extractBaseDir, extractProjectName, extractSessionId, getProjectsBasePath, getTodosBasePath, isValidEncodedPath, } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; import * as path from 'path'; import { configManager } from '../infrastructure/ConfigManager'; import { LocalFileSystemProvider } from '../infrastructure/LocalFileSystemProvider'; import { ProjectPathResolver } from './ProjectPathResolver'; import { resolveProjectStorageDir as resolveProjectStorageDirFromCandidates } from './projectStorageDir'; import { SessionContentFilter } from './SessionContentFilter'; import { type SessionFileSignature, SessionMetadataIndex } from './SessionMetadataIndex'; import { SessionSearcher } from './SessionSearcher'; import { SubagentLocator } from './SubagentLocator'; import { subprojectRegistry } from './SubprojectRegistry'; import type { FileSystemProvider, FsDirent } from '../infrastructure/FileSystemProvider'; const logger = createLogger('Discovery:ProjectScanner'); /** How long to reuse the cached project list for search (ms) */ const SEARCH_PROJECT_CACHE_TTL_MS = 30_000; // IPC payload safety: session ID arrays can be extremely large for long-lived projects. // Keep counts accurate via totalSessions, but truncate ID lists to keep renderer responsive. // Keep this non-zero because parts of the renderer still rely on a (partial) sessionId list // for lookups and navigation; a small cap preserves that behavior without huge payloads. const MAX_SESSION_IDS_EXPORTED = 200; export interface ProjectScannerOptions { /** * Directory for the persisted session-list metadata index. * Defaults to a sibling of the configured projects directory. */ sessionIndexDir?: string; /** Test hook: set to 0 to persist index files without debounce. */ sessionIndexPersistDelayMs?: number; } function splitPathSegments(value: string): string[] { return value.split(/[/\\]+/).filter(Boolean); } function getDefaultSessionIndexDir(projectsDir: string): string { return path.join(path.dirname(projectsDir), '.agent-teams-session-index'); } /** * Fast, zero-I/O worktree detection based on path patterns only. * Used by scanWithWorktreeGrouping to provide accurate worktree metadata * without expensive git filesystem operations. */ function detectWorktreeFromPath(projectPath: string): { isWorktree: boolean; source: WorktreeSource; } { const parts = splitPathSegments(projectPath); if (parts.includes(VIBE_KANBAN_DIR) && parts.includes(WORKTREES_DIR)) { return { isWorktree: true, source: 'vibe-kanban' }; } if (parts.includes(CONDUCTOR_DIR) && parts.includes(WORKSPACES_DIR)) { // Only subpaths after workspaces/{repo} are worktrees const idx = parts.indexOf(CONDUCTOR_DIR); if (idx >= 0 && parts.length > idx + 3) { return { isWorktree: true, source: 'conductor' }; } } if (parts.includes(AUTO_CLAUDE_DIR) && parts.includes(WORKTREES_DIR)) { return { isWorktree: true, source: 'auto-claude' }; } if (parts.includes(TWENTYFIRST_DIR) && parts.includes(WORKTREES_DIR)) { return { isWorktree: true, source: '21st' }; } if (parts.includes(CLAUDE_WORKTREES_DIR)) { return { isWorktree: true, source: 'claude-desktop' }; } if (parts.includes(CCSWITCH_DIR) && parts.includes(WORKTREES_DIR)) { return { isWorktree: true, source: 'ccswitch' }; } if (parts.includes(CURSOR_DIR) && parts.includes(WORKTREES_DIR)) { return { isWorktree: true, source: 'git' }; } { const claudeCodeIdx = parts.indexOf(CLAUDE_CODE_DIR); if (claudeCodeIdx >= 0 && parts[claudeCodeIdx + 1] === WORKTREES_DIR) { return { isWorktree: true, source: 'claude-code' }; } } return { isWorktree: false, source: 'unknown' }; } export class ProjectScanner { private readonly projectsDir: string; private readonly todosDir: string; private readonly contentPresenceCache = new Map< string, { mtimeMs: number; size: number; hasContent: boolean } >(); private readonly sessionMetadataCache = new Map< string, { mtimeMs: number; size: number; metadata: Awaited>; } >(); private readonly sessionPreviewCache = new Map< string, { mtimeMs: number; size: number; preview: { text: string; timestamp: string } | null } >(); // Short-lived scan cache to prevent duplicate scans within the same request cycle. // Both getProjects() and getRepositoryGroups() call scan() — the cache deduplicates. private scanCache: { projects: Project[]; timestamp: number } | null = null; private static readonly SCAN_CACHE_TTL_MS = 2000; /** Cached project list for search — avoids re-scanning disk on every query */ private searchProjectCache: { projects: Project[]; timestamp: number } | null = null; // Platform-aware batch sizes to avoid UV thread pool saturation on Windows private static readonly LOCAL_SESSION_BATCH = process.platform === 'win32' ? 16 : 64; private static readonly LOCAL_PROJECT_BATCH = process.platform === 'win32' ? 4 : 12; // Delegated services private readonly fsProvider: FileSystemProvider; private readonly sessionContentFilter: typeof SessionContentFilter; private readonly subagentLocator: SubagentLocator; private readonly sessionSearcher: SessionSearcher; private readonly projectPathResolver: ProjectPathResolver; private readonly sessionMetadataIndex: SessionMetadataIndex | null; constructor( projectsDir?: string, todosDir?: string, fsProvider?: FileSystemProvider, options?: ProjectScannerOptions ) { this.projectsDir = projectsDir ?? getProjectsBasePath(); this.todosDir = todosDir ?? getTodosBasePath(); this.fsProvider = fsProvider ?? new LocalFileSystemProvider(); // Initialize delegated services this.sessionContentFilter = SessionContentFilter; this.subagentLocator = new SubagentLocator(this.projectsDir, this.fsProvider); this.sessionSearcher = new SessionSearcher(this.projectsDir, this.fsProvider); this.projectPathResolver = new ProjectPathResolver(this.projectsDir, this.fsProvider); this.sessionMetadataIndex = this.fsProvider.type === 'local' ? new SessionMetadataIndex({ rootDir: options?.sessionIndexDir ?? getDefaultSessionIndexDir(this.projectsDir), persistDelayMs: options?.sessionIndexPersistDelayMs, }) : null; } // =========================================================================== // Project Scanning // =========================================================================== /** * Scans the projects directory and returns a list of all projects. * @returns Promise resolving to projects sorted by most recent activity */ async scan(): Promise { // Short-lived cache: prevents duplicate scans when getProjects() and // getRepositoryGroups() fire in Promise.all() on startup/context switch. if ( this.scanCache && Date.now() - this.scanCache.timestamp < ProjectScanner.SCAN_CACHE_TTL_MS ) { return this.scanCache.projects; } const startedAt = Date.now(); let stage = 'start'; const slowWarnAfterMs = 10_000; const slowWarnTimer = setTimeout(() => { logger.warn( `[scan] still running after ${slowWarnAfterMs}ms stage=${stage} projectsDir=${this.projectsDir}` ); }, slowWarnAfterMs); try { stage = 'exists'; if (!(await this.fsProvider.exists(this.projectsDir))) { logger.warn(`Projects directory does not exist: ${this.projectsDir}`); return []; } // Clear the subproject registry on full re-scan subprojectRegistry.clear(); stage = 'readdirProjectsDir'; const readdirStartedAt = Date.now(); const entries = await this.fsProvider.readdir(this.projectsDir); const readdirMs = Date.now() - readdirStartedAt; if (readdirMs >= 2000) { logger.warn(`[scan] readdir slow ms=${readdirMs} entries=${entries.length}`); } // Filter to only directories with valid encoding pattern const projectDirs = entries.filter( (entry) => entry.isDirectory() && isValidEncodedPath(entry.name) ); // Process each project directory (may return multiple projects per dir) stage = 'scanProjects'; const projectArrays = await this.collectFulfilledInBatches( projectDirs, this.fsProvider.type === 'ssh' ? 8 : ProjectScanner.LOCAL_PROJECT_BATCH, async (dir) => this.scanProjectWithTimeout(dir.name) ); // Flatten and sort by most recent const validProjects = projectArrays.flat(); validProjects.sort((a, b) => (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0)); if (this.fsProvider.type === 'ssh') { logger.debug( `SSH scan completed: ${validProjects.length} projects in ${Date.now() - startedAt}ms` ); } const ms = Date.now() - startedAt; if (ms >= 5000) { logger.warn( `[scan] completed slow ms=${ms} projectDirs=${projectDirs.length} projects=${validProjects.length}` ); } this.scanCache = { projects: validProjects, timestamp: Date.now() }; return validProjects; } catch (error) { logger.error('Error scanning projects directory:', error); return []; } finally { clearTimeout(slowWarnTimer); } } /** * Clears the scan cache so the next scan() call reads fresh data. * Call this when a file change is detected by FileWatcher. */ clearScanCache(): void { this.scanCache = null; } // =========================================================================== // Repository Grouping (Worktree Support) // =========================================================================== /** * Scans projects and groups them by git repository. * Projects belonging to the same git repository (main repo + worktrees) * are grouped together under a single RepositoryGroup. * Non-git projects are represented as single-worktree groups. * * Sessions are filtered to exclude noise-only sessions, so counts * accurately reflect visible sessions in the UI. * * @returns Promise resolving to RepositoryGroups sorted by most recent activity */ async scanWithWorktreeGrouping(): Promise { try { // 1. Scan all projects using existing logic const projects = await this.scan(); // 2. Convert each project to a simple RepositoryGroup (git resolution disabled) // Git identity resolution is bypassed to avoid blocking I/O on startup. // Each project becomes a single-worktree group. const groups: RepositoryGroup[] = projects.map((project) => { const totalSessions = project.totalSessions ?? project.sessions.length; const worktreeInfo = detectWorktreeFromPath(project.path); return { id: project.id, identity: null, worktrees: [ { id: project.id, path: project.path, name: project.name, isMainWorktree: !worktreeInfo.isWorktree, source: worktreeInfo.source, sessions: project.sessions, totalSessions, createdAt: project.createdAt, mostRecentSession: project.mostRecentSession, }, ], name: project.name, mostRecentSession: project.mostRecentSession, totalSessions, }; }); // 3. Merge custom project paths from config (persisted "Select Folder" picks) const customPaths = configManager.getCustomProjectPaths(); const existingPaths = new Set(groups.flatMap((g) => g.worktrees.map((w) => w.path))); for (const customPath of customPaths) { if (existingPaths.has(customPath)) { continue; // Already discovered by scanner — skip } const encodedId = customPath.replace(/[/\\]/g, '-'); const folderName = customPath.split(/[/\\]/).filter(Boolean).pop() ?? customPath; const now = Date.now(); groups.push({ id: encodedId, identity: null, worktrees: [ { id: encodedId, path: customPath, name: folderName, isMainWorktree: true, source: 'unknown' as const, sessions: [], totalSessions: 0, createdAt: now, }, ], name: folderName, mostRecentSession: undefined, totalSessions: 0, }); } // Sort by most recent activity (same order as the full git-aware version) groups.sort((a, b) => (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0)); return groups; } catch (error) { logger.error('Error scanning with worktree grouping:', error); return []; } } /** * Lists sessions for a specific worktree within a repository group. * This is a convenience method that delegates to listSessions since * worktree.id is the same as project.id. * * @param worktreeId - The worktree ID (same as project ID) */ async listWorktreeSessions(worktreeId: string): Promise { return this.listSessions(worktreeId); } // =========================================================================== // Project Scanning (continued) // =========================================================================== // Per-project scan timeout: prevents a single slow directory from blocking // the entire scan batch (e.g. a project with 1000+ session files on slow I/O). private static readonly SCAN_PROJECT_TIMEOUT_MS = 15_000; /** * Scans a single project directory with a timeout guard. * Returns empty array if the scan exceeds the timeout. */ private async scanProjectWithTimeout(encodedName: string): Promise { let timer: ReturnType | null = null; const timeout = new Promise((resolve) => { timer = setTimeout(() => { logger.warn( `[scanProject] timeout after ${ProjectScanner.SCAN_PROJECT_TIMEOUT_MS}ms project=${encodedName}` ); resolve([]); }, ProjectScanner.SCAN_PROJECT_TIMEOUT_MS); }); try { return await Promise.race([this.scanProject(encodedName), timeout]); } finally { if (timer) clearTimeout(timer); } } /** * Scans a single project directory and returns project metadata. * If sessions have different cwd values, splits into multiple projects. */ private async scanProject(encodedName: string): Promise { try { const projectPath = path.join(this.projectsDir, encodedName); const readdirStart = Date.now(); const entries = await this.fsProvider.readdir(projectPath); const readdirMs = Date.now() - readdirStart; // Get session files (.jsonl at root level) const sessionFiles = entries.filter( (entry) => entry.isFile() && entry.name.endsWith('.jsonl') ); if (sessionFiles.length === 0) { return []; } if (sessionFiles.length > 200 || readdirMs > 500) { logger.debug( `[scanProject] ${encodedName} readdir=${readdirMs}ms entries=${entries.length} jsonl=${sessionFiles.length}` ); } // Collect file stats and cwd for each session interface SessionInfo { sessionId: string; filePath: string; mtimeMs: number; birthtimeMs: number; cwd: string | null; } // Reading JSONL heads for cwd across hundreds/thousands of sessions can saturate I/O and // make the renderer appear frozen while waiting for repository groups. // Prefer correctness for small projects; for large ones, skip cwd splitting and fall back // to encoded-path decoding / limited path probing. const MAX_CWD_SPLIT_FILES = 80; const shouldSplitByCwd = this.fsProvider.type !== 'ssh' && sessionFiles.length <= MAX_CWD_SPLIT_FILES; const sessionStatStart = Date.now(); const sessionInfos = await this.collectFulfilledInBatches( sessionFiles, this.fsProvider.type === 'ssh' ? 32 : ProjectScanner.LOCAL_SESSION_BATCH, async (file) => { const filePath = path.join(projectPath, file.name); const { mtimeMs, birthtimeMs } = await this.resolveFileDetails(file, filePath); let cwd: string | null = null; // Over SSH, avoid reading every file body during project discovery. if (shouldSplitByCwd) { try { cwd = await extractCwd(filePath, this.fsProvider); } catch { // Ignore unreadable files } } return { sessionId: extractSessionId(file.name), filePath, mtimeMs, birthtimeMs, cwd, } satisfies SessionInfo; } ); if (sessionInfos.length === 0) { return []; } const sessionStatMs = Date.now() - sessionStatStart; if (sessionFiles.length > 200 || sessionStatMs > 1000) { logger.debug( `[scanProject] ${encodedName} sessionStat=${sessionStatMs}ms files=${sessionFiles.length} infos=${sessionInfos.length}` ); } // Group sessions by cwd const cwdGroups = new Map(); const firstCwd = sessionInfos.find((s) => s.cwd)?.cwd ?? undefined; const baseName = extractProjectName(encodedName, firstCwd); const decodedFallback = baseName; // Used when cwd is null for (const info of sessionInfos) { const key = shouldSplitByCwd ? (info.cwd ?? `__decoded__${decodedFallback}`) : encodedName; const group = cwdGroups.get(key) ?? []; group.push(info); cwdGroups.set(key, group); } // If only 1 unique real cwd, return single project (current behavior) // Sessions without cwd (older format) are implicitly from the same project, // so we only count distinct real cwds to decide whether to split. const realCwdKeys = [...cwdGroups.keys()].filter((k) => !k.startsWith('__decoded__')); if (realCwdKeys.length <= 1) { const allSessionIds = sessionInfos.map((s) => s.sessionId); const exportedSessionIds = allSessionIds.slice(0, MAX_SESSION_IDS_EXPORTED); let mostRecentSession: number | undefined; let createdAt = Date.now(); for (const info of sessionInfos) { if (!mostRecentSession || info.mtimeMs > mostRecentSession) { mostRecentSession = info.mtimeMs; } if (info.birthtimeMs < createdAt) { createdAt = info.birthtimeMs; } } const sessionPaths = sessionInfos.map((s) => s.filePath); const actualPath = await this.projectPathResolver.resolveProjectPath(encodedName, { cwdHint: firstCwd ?? undefined, sessionPaths, }); // Derive name from resolved path — more reliable than decodePath for // paths containing dashes (e.g. "test-project" encodes lossily). const resolvedName = path.basename(actualPath) || baseName; return [ { id: encodedName, path: actualPath, name: resolvedName, sessions: exportedSessionIds, totalSessions: allSessionIds.length, createdAt: Math.floor(createdAt), mostRecentSession: mostRecentSession ? Math.floor(mostRecentSession) : undefined, }, ]; } // Multiple unique cwds: split into subprojects const projects: Project[] = []; // Find the "root" cwd (shortest path, or the one matching the decoded name) const cwdKeys = [...cwdGroups.keys()].filter((k) => !k.startsWith('__decoded__')); const rootCwd = cwdKeys.reduce( (shortest, cwd) => (cwd.length <= shortest.length ? cwd : shortest), cwdKeys[0] ?? '' ); // Derive root name from actual cwd path (more reliable than decodePath) const rootName = path.basename(rootCwd) || baseName; for (const [cwdKey, sessions] of cwdGroups) { const isDecodedFallback = cwdKey.startsWith('__decoded__'); const actualCwd = isDecodedFallback ? null : cwdKey; // Register in subproject registry const sessionIds = sessions.map((s) => s.sessionId); const compositeId = subprojectRegistry.register( encodedName, actualCwd ?? decodedFallback, sessionIds ); const exportedSessionIds = sessionIds.slice(0, MAX_SESSION_IDS_EXPORTED); // Compute timestamps let mostRecentSession: number | undefined; let createdAt = Date.now(); for (const info of sessions) { if (!mostRecentSession || info.mtimeMs > mostRecentSession) { mostRecentSession = info.mtimeMs; } if (info.birthtimeMs < createdAt) { createdAt = info.birthtimeMs; } } // Build display name from actual cwd paths let displayName: string; if (!actualCwd || actualCwd === rootCwd) { displayName = rootName; } else { // Use last segment of cwd for disambiguation const lastSegment = path.basename(actualCwd); displayName = `${rootName} (${lastSegment})`; } projects.push({ id: compositeId, path: actualCwd ?? decodedFallback, name: displayName, sessions: exportedSessionIds, totalSessions: sessionIds.length, createdAt: Math.floor(createdAt), mostRecentSession: mostRecentSession ? Math.floor(mostRecentSession) : undefined, }); } return projects; } catch (error) { logger.error(`Error scanning project ${encodedName}:`, error); return []; } } /** * Gets details for a specific project by ID. * Handles composite IDs by scanning the base directory and finding the matching subproject. */ async getProject(projectId: string): Promise { const projectPath = await this.resolveProjectStorageDir(projectId); if (!projectPath) { return null; } const baseDir = path.basename(projectPath); // For composite IDs, scan and find the matching subproject if (subprojectRegistry.isComposite(projectId)) { const projects = await this.scanProject(baseDir); return projects.find((p) => p.id === projectId) ?? null; } const projects = await this.scanProject(baseDir); return projects.find((p) => p.id === projectId) ?? projects[0] ?? null; } // =========================================================================== // Session Listing // =========================================================================== /** * Lists all sessions for a given project with metadata. * Filters out sessions that contain only noise messages. */ async listSessions(projectId: string): Promise { try { const projectPath = await this.resolveProjectStorageDir(projectId); const sessionFilter = await this.getSessionFilterForProject(projectId); const shouldFilterNoise = this.fsProvider.type !== 'ssh'; const metadataLevel: SessionMetadataLevel = this.fsProvider.type === 'ssh' ? 'light' : 'deep'; if (!projectPath) { return []; } const entries = await this.fsProvider.readdir(projectPath); const allSessionFiles = entries.filter( (entry) => entry.isFile() && entry.name.endsWith('.jsonl') ); await this.pruneSessionMetadataIndex( projectPath, new Set(allSessionFiles.map((file) => path.join(projectPath, file.name))) ); let sessionFiles = allSessionFiles; // Filter to only sessions belonging to this subproject if (sessionFilter) { sessionFiles = sessionFiles.filter((f) => sessionFilter.has(extractSessionId(f.name))); } const sessionPaths = sessionFiles.map((file) => path.join(projectPath, file.name)); const decodedPath = await this.resolveProjectPathForId(projectId, sessionPaths); const sessions = await this.collectFulfilledInBatches( sessionFiles, this.fsProvider.type === 'ssh' ? 8 : 16, async (file) => { const sessionId = extractSessionId(file.name); const filePath = path.join(projectPath, file.name); const fileDetails = await this.resolveFileDetails(file, filePath); const prefetchedMtimeMs = fileDetails.mtimeMs; const prefetchedSize = fileDetails.size; const prefetchedBirthtimeMs = fileDetails.birthtimeMs; if (shouldFilterNoise) { // Check if session has non-noise messages (delegated to SessionContentFilter) const hasContent = await this.hasDisplayableContent( filePath, prefetchedMtimeMs, prefetchedSize ); if (!hasContent) { return null; // Filter out noise-only sessions } } return this.buildSessionForListing( metadataLevel, projectId, sessionId, filePath, decodedPath, prefetchedMtimeMs, prefetchedSize, prefetchedBirthtimeMs ); } ); // Filter out null results (noise-only sessions) const validSessions = sessions.filter((s): s is Session => s !== null); // Sort by created date (most recent first) validSessions.sort((a, b) => b.createdAt - a.createdAt); return validSessions; } catch (error) { logger.error(`Error listing sessions for project ${projectId}:`, error); return []; } } /** * Lists sessions for a project with cursor-based pagination. * Efficiently fetches only the sessions needed for the current page. * * @param projectId - The project ID to list sessions for * @param cursor - Base64-encoded cursor from previous page (null for first page) * @param limit - Number of sessions to return (default 20) * @returns Paginated result with sessions, cursor, and metadata */ async listSessionsPaginated( projectId: string, cursor: string | null, limit: number = 20, options?: SessionsPaginationOptions ): Promise { const startedAt = Date.now(); try { const includeTotalCount = options?.includeTotalCount ?? false; const prefilterAll = options?.prefilterAll ?? false; const projectPath = await this.resolveProjectStorageDir(projectId); const sessionFilter = await this.getSessionFilterForProject(projectId); const shouldFilterNoise = this.fsProvider.type !== 'ssh'; const metadataLevel: SessionMetadataLevel = options?.metadataLevel ?? (this.fsProvider.type === 'ssh' ? 'light' : 'deep'); if (!projectPath) { return { sessions: [], nextCursor: null, hasMore: false, totalCount: 0 }; } // Step 1: Get all session files with their timestamps (lightweight stat calls) const entries = await this.fsProvider.readdir(projectPath); const allSessionFiles = entries.filter( (entry) => entry.isFile() && entry.name.endsWith('.jsonl') ); await this.pruneSessionMetadataIndex( projectPath, new Set(allSessionFiles.map((file) => path.join(projectPath, file.name))) ); let sessionFiles = allSessionFiles; // Filter to only sessions belonging to this subproject if (sessionFilter) { sessionFiles = sessionFiles.filter((f) => sessionFilter.has(extractSessionId(f.name))); } // Get stats for all session files (parallel for SSH performance) interface SessionFileInfo { name: string; sessionId: string; timestamp: number; filePath: string; mtimeMs: number; size: number; birthtimeMs: number; } const fileInfos = await this.collectFulfilledInBatches( sessionFiles, this.fsProvider.type === 'ssh' ? 48 : 200, async (file) => { const filePath = path.join(projectPath, file.name); const fileDetails = await this.resolveFileDetails(file, filePath); return { name: file.name, sessionId: extractSessionId(file.name), timestamp: fileDetails.mtimeMs, filePath, mtimeMs: fileDetails.mtimeMs, size: fileDetails.size, birthtimeMs: fileDetails.birthtimeMs, } satisfies SessionFileInfo; } ); // Step 2: Sort by timestamp descending (most recent first) fileInfos.sort((a, b) => { if (b.timestamp !== a.timestamp) { return b.timestamp - a.timestamp; } // Tie-breaker: sort by sessionId alphabetically return a.sessionId.localeCompare(b.sessionId); }); // Step 3: Optionally pre-filter all sessions for accurate total count // This is slower but provides exact totalCount. let validSessionIds: Set | null = null; let totalCount = 0; if (prefilterAll && shouldFilterNoise && metadataLevel === 'deep') { const contentResults = await Promise.allSettled( fileInfos.map(async (fileInfo) => ({ sessionId: fileInfo.sessionId, hasContent: await this.hasDisplayableContent( fileInfo.filePath, fileInfo.mtimeMs, fileInfo.size ), })) ); validSessionIds = new Set(); for (const result of contentResults) { if (result.status === 'fulfilled' && result.value.hasContent) { validSessionIds.add(result.value.sessionId); } } totalCount = validSessionIds.size; } // Step 4: Apply cursor filter to find starting position let startIndex = 0; if (cursor) { try { // Defensive limit: cursor originates from a query param / IPC input and should be tiny. // Prevent pathological memory allocation on Buffer.from(cursor, 'base64'). if (cursor.length > 4096) { throw new Error('cursor too large'); } const decoded = JSON.parse( Buffer.from(cursor, 'base64').toString('utf8') ) as SessionCursor; startIndex = fileInfos.findIndex((info) => { // Find the first item that comes AFTER the cursor if (info.timestamp < decoded.timestamp) return true; if (info.timestamp === decoded.timestamp && info.sessionId > decoded.sessionId) return true; return false; }); // If cursor not found, start from beginning if (startIndex === -1) startIndex = fileInfos.length; } catch { // Invalid cursor, start from beginning startIndex = 0; } } // Step 5: Fetch sessions for this page const decodedPath = await this.resolveProjectPathForId( projectId, fileInfos.map((fileInfo) => fileInfo.filePath) ); const sessions: Session[] = []; let scannedCandidates = 0; // Fetch page items in parallel batches for SSH performance. // Process candidates in chunks, checking content + building metadata concurrently. const BATCH_SIZE = limit + 1; // One extra to detect hasMore let batchStart = startIndex; while (sessions.length < limit + 1 && batchStart < fileInfos.length) { // Take a batch of candidates (overshoot to account for filtered-out items) const batchEnd = Math.min(batchStart + BATCH_SIZE * 2, fileInfos.length); const batch = fileInfos.slice(batchStart, batchEnd); scannedCandidates += batch.length; // Step 5a: Check content in parallel let contentBatch: { fileInfo: SessionFileInfo; hasContent: boolean }[]; if (validSessionIds) { contentBatch = batch.map((fileInfo) => ({ fileInfo, hasContent: validSessionIds.has(fileInfo.sessionId), })); } else if (!shouldFilterNoise) { contentBatch = batch.map((fileInfo) => ({ fileInfo, hasContent: true })); } else { const contentResults = await Promise.allSettled( batch.map(async (fileInfo) => ({ fileInfo, hasContent: await this.hasDisplayableContent( fileInfo.filePath, fileInfo.mtimeMs, fileInfo.size ), })) ); contentBatch = contentResults .filter( ( r ): r is PromiseFulfilledResult<{ fileInfo: SessionFileInfo; hasContent: boolean }> => r.status === 'fulfilled' ) .map((r) => r.value); } // Step 5b: Build metadata in parallel for items with content const withContent = contentBatch.filter((c) => c.hasContent); const needed = limit + 1 - sessions.length; const toBuild = withContent.slice(0, needed); const builtSessions = await this.collectFulfilledInBatches( toBuild, this.fsProvider.type === 'ssh' ? 4 : 16, async ({ fileInfo }) => this.buildSessionForListing( metadataLevel, projectId, fileInfo.sessionId, fileInfo.filePath, decodedPath, fileInfo.mtimeMs, fileInfo.size, fileInfo.birthtimeMs ) ); sessions.push(...builtSessions); batchStart = batchEnd; } // Step 6: Build next cursor let nextCursor: string | null = null; const hasMore = sessions.length > limit || startIndex + scannedCandidates < fileInfos.length; const pageSessions = hasMore ? sessions.slice(0, limit) : sessions; // If total count wasn't precomputed, keep UI-safe lower bound if (!includeTotalCount) { // Lightweight mode: return a lower-bound count to avoid full scans. totalCount = pageSessions.length + (hasMore ? 1 : 0); } if (pageSessions.length > 0 && hasMore) { const lastSession = pageSessions[pageSessions.length - 1]; const lastFileInfo = fileInfos.find((f) => f.sessionId === lastSession.id); if (lastFileInfo) { const cursorData: SessionCursor = { timestamp: lastFileInfo.timestamp, sessionId: lastFileInfo.sessionId, }; nextCursor = Buffer.from(JSON.stringify(cursorData)).toString('base64'); } } const result: PaginatedSessionsResult = { sessions: pageSessions, nextCursor, hasMore: nextCursor !== null, totalCount, }; if (this.fsProvider.type === 'ssh') { logger.debug( `SSH listSessionsPaginated(${projectId}) returned ${result.sessions.length} sessions in ${Date.now() - startedAt}ms (hasMore=${result.hasMore})` ); } return result; } catch (error) { logger.error(`Error listing paginated sessions for project ${projectId}:`, error); return { sessions: [], nextCursor: null, hasMore: false, totalCount: 0 }; } } /** * Build session metadata from a session file. */ private async buildSessionMetadata( projectId: string, sessionId: string, filePath: string, projectPath: string, prefetchedMtimeMs?: number, prefetchedSize?: number, prefetchedBirthtimeMs?: number ): Promise { const hasPrefetchedCoreStats = typeof prefetchedMtimeMs === 'number' && typeof prefetchedSize === 'number'; const needsBirthtimeStat = typeof prefetchedBirthtimeMs !== 'number'; const stats = hasPrefetchedCoreStats && !needsBirthtimeStat ? null : await this.fsProvider.stat(filePath); const effectiveMtime = prefetchedMtimeMs ?? stats?.mtimeMs ?? Date.now(); const effectiveSize = prefetchedSize ?? stats?.size ?? -1; const birthtimeMs = prefetchedBirthtimeMs ?? stats?.birthtimeMs ?? effectiveMtime; const signature = this.buildSessionFileSignature( sessionId, filePath, effectiveMtime, effectiveSize, birthtimeMs ); const metadata = await this.getSessionFileMetadata(signature); // Check for subagents (todoData skipped here — loaded on-demand in detail view) const hasSubagents = await this.subagentLocator.hasSubagents(projectId, sessionId); const metadataLevel: SessionMetadataLevel = 'deep'; const firstMessageTimestampMs = this.parseTimestampMs(metadata.firstUserMessage?.timestamp); const createdAt = firstMessageTimestampMs !== null && Number.isFinite(firstMessageTimestampMs) ? firstMessageTimestampMs : birthtimeMs; // If messages suggest ongoing but the file hasn't been written to in 5+ minutes, // the session likely crashed/was killed (upstream fix #94) const STALE_SESSION_THRESHOLD_MS = 5 * 60 * 1000; const isOngoing = metadata.isOngoing && Date.now() - effectiveMtime < STALE_SESSION_THRESHOLD_MS; return { id: sessionId, projectId, projectPath, createdAt: Math.floor(createdAt), firstMessage: metadata.firstUserMessage?.text, messageTimestamp: metadata.firstUserMessage?.timestamp, hasSubagents, messageCount: metadata.messageCount, isOngoing, model: metadata.model ?? undefined, gitBranch: metadata.gitBranch ?? undefined, metadataLevel, contextConsumption: metadata.contextConsumption, compactionCount: metadata.compactionCount, phaseBreakdown: metadata.phaseBreakdown, }; } /** * Build a lightweight session record using filesystem metadata only. * Used as SSH fallback when deep parsing fails transiently. */ private async buildLightSessionMetadata( projectId: string, sessionId: string, filePath: string, projectPath: string, prefetchedMtimeMs?: number, prefetchedSize?: number, prefetchedBirthtimeMs?: number ): Promise { const hasPrefetchedCoreStats = typeof prefetchedMtimeMs === 'number' && typeof prefetchedSize === 'number'; const needsBirthtimeStat = typeof prefetchedBirthtimeMs !== 'number'; const stats = hasPrefetchedCoreStats && !needsBirthtimeStat ? null : await this.fsProvider.stat(filePath); const effectiveMtime = prefetchedMtimeMs ?? stats?.mtimeMs ?? Date.now(); const effectiveSize = prefetchedSize ?? stats?.size ?? -1; const birthtimeMs = prefetchedBirthtimeMs ?? stats?.birthtimeMs ?? effectiveMtime; let metadata: SessionFileMetadata; const signature = this.buildSessionFileSignature( sessionId, filePath, effectiveMtime, effectiveSize, birthtimeMs ); try { metadata = await this.getSessionFileMetadata(signature); } catch (error) { logger.debug(`Failed to analyze session metadata for ${filePath}:`, error); metadata = { firstUserMessage: null, messageCount: 0, isOngoing: false, gitBranch: null, model: null, }; } const metadataLevel: SessionMetadataLevel = 'light'; const previewTimestampMs = this.parseTimestampMs(metadata.firstUserMessage?.timestamp); const createdAt = previewTimestampMs !== null && Number.isFinite(previewTimestampMs) ? previewTimestampMs : birthtimeMs; return { id: sessionId, projectId, projectPath, createdAt: Math.floor(createdAt), firstMessage: metadata.firstUserMessage?.text, messageTimestamp: metadata.firstUserMessage?.timestamp, hasSubagents: false, messageCount: metadata.messageCount, model: metadata.model ?? undefined, metadataLevel, }; } /** * Build session metadata according to requested listing depth. * In SSH mode, deep parse failures degrade gracefully to light metadata. */ private async buildSessionForListing( metadataLevel: SessionMetadataLevel, projectId: string, sessionId: string, filePath: string, projectPath: string, prefetchedMtimeMs?: number, prefetchedSize?: number, prefetchedBirthtimeMs?: number ): Promise { if (metadataLevel === 'light') { return this.buildLightSessionMetadata( projectId, sessionId, filePath, projectPath, prefetchedMtimeMs, prefetchedSize, prefetchedBirthtimeMs ); } try { return await this.buildSessionMetadata( projectId, sessionId, filePath, projectPath, prefetchedMtimeMs, prefetchedSize, prefetchedBirthtimeMs ); } catch (error) { // In SSH mode, never drop a visible session row due to transient deep-parse failures. if (this.fsProvider.type !== 'ssh') { throw error; } logger.debug(`SSH metadata parse failed for ${sessionId}, using light fallback`, error); return this.buildLightSessionMetadata( projectId, sessionId, filePath, projectPath, prefetchedMtimeMs, prefetchedSize, prefetchedBirthtimeMs ); } } /** * Gets a single session's metadata. */ async getSession(projectId: string, sessionId: string): Promise { const filePath = await this.resolveSessionPath(projectId, sessionId); if (!filePath || !(await this.fsProvider.exists(filePath))) { return null; } const metadataLevel: SessionMetadataLevel = 'deep'; const decodedPath = await this.resolveProjectPathForId(projectId); return this.buildSessionForListing(metadataLevel, projectId, sessionId, filePath, decodedPath); } /** * Gets a single session's metadata with optional depth override. */ async getSessionWithOptions( projectId: string, sessionId: string, options?: SessionsByIdsOptions ): Promise { const filePath = await this.resolveSessionPath(projectId, sessionId); if (!filePath || !(await this.fsProvider.exists(filePath))) { return null; } const metadataLevel: SessionMetadataLevel = options?.metadataLevel ?? (this.fsProvider.type === 'ssh' ? 'light' : 'deep'); const decodedPath = await this.resolveProjectPathForId(projectId); return this.buildSessionForListing(metadataLevel, projectId, sessionId, filePath, decodedPath); } // =========================================================================== // Task List Data // =========================================================================== /** * Loads task list data for a session from ~/.claude/todos/{sessionId}.json */ async loadTodoData(sessionId: string): Promise { try { const todoPath = buildTodoPath(path.dirname(this.projectsDir), sessionId); const content = await this.fsProvider.readFile(todoPath); return JSON.parse(content) as unknown; } catch (error: unknown) { // ENOENT/EACCES = file missing or inaccessible — normal when no todos exist if (error instanceof Error && 'code' in error) { const code = (error as NodeJS.ErrnoException).code; if (code === 'ENOENT' || code === 'EACCES') { return undefined; } } logger.debug(`Failed to load task list data for session ${sessionId}:`, error); return undefined; } } // =========================================================================== // Path Helpers // =========================================================================== /** * Gets the path to the session JSONL file. */ getSessionPath(projectId: string, sessionId: string): string { return buildSessionPath(this.projectsDir, projectId, sessionId); } /** * Resolves a session path using all known project storage directory codecs. */ async resolveSessionPath(projectId: string, sessionId: string): Promise { const projectPath = await this.resolveProjectStorageDir(projectId); return projectPath ? path.join(projectPath, `${sessionId}.jsonl`) : null; } /** * Gets the path to the subagents directory. */ getSubagentsPath(projectId: string, sessionId: string): string { return buildSubagentsPath(this.projectsDir, projectId, sessionId); } /** * Lists all session file paths for a project. */ async listSessionFiles(projectId: string): Promise { try { const projectPath = await this.resolveProjectStorageDir(projectId); const sessionFilter = await this.getSessionFilterForProject(projectId); if (!projectPath) { return []; } const entries = await this.fsProvider.readdir(projectPath); let files = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl')); if (sessionFilter) { files = files.filter((entry) => sessionFilter.has(extractSessionId(entry.name))); } return files.map((entry) => path.join(projectPath, entry.name)); } catch (error) { logger.error(`Error listing session files for project ${projectId}:`, error); return []; } } private async resolveProjectStorageDir(projectId: string): Promise { return resolveProjectStorageDirFromCandidates(this.projectsDir, projectId, this.fsProvider); } /** * Returns the session filter set for a project. * In local mode, composite IDs are refreshed from disk first so newly created * sessions are not hidden by stale registry entries. */ private async getSessionFilterForProject(projectId: string): Promise | null> { if (this.fsProvider.type === 'local' && subprojectRegistry.isComposite(projectId)) { const baseDir = extractBaseDir(projectId); await this.scanProject(baseDir); } return subprojectRegistry.getSessionFilter(projectId); } // =========================================================================== // Subagent Detection (delegated to SubagentLocator) // =========================================================================== /** * Checks if a session has a subagents directory (async). */ async hasSubagents(projectId: string, sessionId: string): Promise { return this.subagentLocator.hasSubagents(projectId, sessionId); } /** * Checks if a session has subagent files (session-specific only). * Only checks the NEW structure: {projectId}/{sessionId}/subagents/ * Verifies that at least one subagent file has non-empty content. */ hasSubagentsSync(projectId: string, sessionId: string): boolean { return this.subagentLocator.hasSubagentsSync(projectId, sessionId); } /** * Lists all subagent files for a session from both NEW and OLD structures. * Returns NEW structure files first, then OLD structure files. */ async listSubagentFiles(projectId: string, sessionId: string): Promise { return this.subagentLocator.listSubagentFiles(projectId, sessionId); } // =========================================================================== // Utility Methods // =========================================================================== /** * Gets the base projects directory path. */ getProjectsDir(): string { return this.projectsDir; } /** * Gets the base todos directory path. */ getTodosDir(): string { return this.todosDir; } /** * Gets the FileSystemProvider instance used by this scanner. */ getFileSystemProvider(): FileSystemProvider { return this.fsProvider; } /** * Checks if the projects directory exists. */ async projectsDirExists(): Promise { return this.fsProvider.exists(this.projectsDir); } // =========================================================================== // Search (delegated to SessionSearcher) // =========================================================================== /** * Searches sessions in a project for a query string. * Filters out noise messages and returns matching content. * * @param projectId - The project ID to search in * @param query - Search query string * @param maxResults - Maximum number of results to return (default 50) */ async searchSessions( projectId: string, query: string, maxResults: number = 50 ): Promise { return this.sessionSearcher.searchSessions(projectId, query, maxResults); } /** * Searches sessions across all projects for a query string. * Filters out noise messages and returns matching content. * * @param query - Search query string * @param maxResults - Maximum number of results to return (default 50) */ async searchAllProjects(query: string, maxResults: number = 50): Promise { const startedAt = Date.now(); try { if (!query || query.trim().length === 0) { return { results: [], totalMatches: 0, sessionsSearched: 0, query }; } // Use cached project list to avoid re-scanning disk on every keystroke let projects: Project[]; if ( this.searchProjectCache && Date.now() - this.searchProjectCache.timestamp < SEARCH_PROJECT_CACHE_TTL_MS ) { projects = this.searchProjectCache.projects; } else { projects = await this.scan(); this.searchProjectCache = { projects, timestamp: Date.now() }; } if (projects.length === 0) { return { results: [], totalMatches: 0, sessionsSearched: 0, query }; } // Search across all projects with bounded concurrency const allResults: SearchSessionsResult[] = []; const searchBatchSize = this.fsProvider.type === 'ssh' ? 2 : 8; for (let i = 0; i < projects.length; i += searchBatchSize) { const batch = projects.slice(i, i + searchBatchSize); const batchResults = await Promise.allSettled( batch.map((project) => this.sessionSearcher.searchSessions(project.id, query, maxResults)) ); for (const result of batchResults) { if (result.status === 'fulfilled') { allResults.push(result.value); } } // Check if we have enough results already const totalMatches = allResults.reduce((sum, r) => sum + r.totalMatches, 0); if (totalMatches >= maxResults) { break; } } // Merge results from all projects const mergedResults = allResults.flatMap((r) => r.results); const totalSessionsSearched = allResults.reduce((sum, r) => sum + r.sessionsSearched, 0); // Sort by timestamp (most recent first) and limit to maxResults mergedResults.sort((a, b) => b.timestamp - a.timestamp); const limitedResults = mergedResults.slice(0, maxResults); logger.debug( `Global search completed: ${limitedResults.length} results from ${totalSessionsSearched} sessions across ${projects.length} projects in ${Date.now() - startedAt}ms` ); return { results: limitedResults, totalMatches: limitedResults.length, sessionsSearched: totalSessionsSearched, query, }; } catch (error) { logger.error('Error searching all projects:', error); return { results: [], totalMatches: 0, sessionsSearched: 0, query }; } } async flushSessionMetadataIndexForTesting(): Promise { await this.sessionMetadataIndex?.flushForTesting(); } private buildSessionFileSignature( sessionId: string, filePath: string, mtimeMs: number, size: number, birthtimeMs?: number ): SessionFileSignature { return { sessionId, filePath, mtimeMs, size, birthtimeMs, }; } private async getSessionFileMetadata( signature: SessionFileSignature ): Promise { const cachedMetadata = this.sessionMetadataCache.get(signature.filePath); if (cachedMetadata?.mtimeMs === signature.mtimeMs && cachedMetadata.size === signature.size) { return cachedMetadata.metadata; } let indexedMetadata: SessionFileMetadata | undefined; if (this.sessionMetadataIndex) { try { indexedMetadata = await this.sessionMetadataIndex.getMetadata(signature); } catch (error) { logger.debug( `Failed to read session metadata index for ${signature.filePath}: ${ error instanceof Error ? error.message : String(error) }` ); } } if (indexedMetadata) { this.sessionMetadataCache.set(signature.filePath, { mtimeMs: signature.mtimeMs, size: signature.size, metadata: indexedMetadata, }); return indexedMetadata; } const metadata = await analyzeSessionFileMetadata(signature.filePath, this.fsProvider); this.sessionMetadataCache.set(signature.filePath, { mtimeMs: signature.mtimeMs, size: signature.size, metadata, }); if (this.sessionMetadataIndex) { try { await this.sessionMetadataIndex.setMetadata(signature, metadata); } catch (error) { logger.debug( `Failed to update session metadata index for ${signature.filePath}: ${ error instanceof Error ? error.message : String(error) }` ); } } return metadata; } private async pruneSessionMetadataIndex( projectStorageDir: string, existingFilePaths: Set ): Promise { if (!this.sessionMetadataIndex) { return; } try { await this.sessionMetadataIndex.pruneMissing(projectStorageDir, existingFilePaths); } catch (error) { logger.debug( `Failed to prune session metadata index for ${projectStorageDir}: ${ error instanceof Error ? error.message : String(error) }` ); } } /** * Resolve best-available file timestamps from directory entry metadata or stat fallback. */ private async resolveFileDetails( entry: FsDirent | undefined, filePath: string ): Promise<{ mtimeMs: number; birthtimeMs: number; size: number }> { if ( entry && typeof entry.mtimeMs === 'number' && typeof entry.birthtimeMs === 'number' && typeof entry.size === 'number' ) { return { mtimeMs: entry.mtimeMs, birthtimeMs: entry.birthtimeMs, size: entry.size, }; } const stats = await this.fsProvider.stat(filePath); return { mtimeMs: stats.mtimeMs, birthtimeMs: stats.birthtimeMs, size: stats.size, }; } private parseTimestampMs(timestamp: string | undefined): number | null { if (!timestamp) { return null; } const parsed = Date.parse(timestamp); return Number.isFinite(parsed) ? parsed : null; } /** * Runs async mapping in bounded batches and returns only fulfilled results. * This prevents overwhelming SFTP servers with unbounded parallel requests. */ private async collectFulfilledInBatches( items: T[], batchSize: number, mapper: (item: T) => Promise ): Promise { const safeBatchSize = Math.max(1, batchSize); const results: R[] = []; for (let i = 0; i < items.length; i += safeBatchSize) { const batch = items.slice(i, i + safeBatchSize); const settled = await Promise.allSettled(batch.map((item) => mapper(item))); for (const result of settled) { if (result.status === 'fulfilled') { results.push(result.value); } } } return results; } private getErrorCode(error: unknown): string { if (typeof error === 'object' && error !== null && 'code' in error) { const code = (error as { code?: unknown }).code; if (typeof code === 'number') { return String(code); } if (typeof code === 'string') { return code; } } return ''; } private isTransientFsError(error: unknown): boolean { const code = this.getErrorCode(error); return ( code === '4' || code === 'EAGAIN' || code === 'ECONNRESET' || code === 'ETIMEDOUT' || code === 'EPIPE' ); } private async sleep(ms: number): Promise { await new Promise((resolve) => { setTimeout(resolve, ms); }); } /** * Resolves the project path for a given project ID. * For composite IDs, uses the registry's cwd directly. * For plain IDs, delegates to ProjectPathResolver. */ private async resolveProjectPathForId( projectId: string, sessionPaths?: string[] ): Promise { const registryCwd = subprojectRegistry.getCwd(projectId); if (registryCwd) { return registryCwd; } const baseDir = extractBaseDir(projectId); return this.projectPathResolver.resolveProjectPath(baseDir, { sessionPaths, }); } /** * Checks whether a session file has non-noise displayable content. * Uses mtime+size memoization to avoid expensive re-parsing on repeated requests. */ private async hasDisplayableContent( filePath: string, mtimeMs?: number, size?: number ): Promise { try { const hasPrefetched = typeof mtimeMs === 'number' && typeof size === 'number'; const stats = hasPrefetched ? null : await this.fsProvider.stat(filePath); const effectiveMtime = mtimeMs ?? stats?.mtimeMs ?? Date.now(); const effectiveSize = size ?? stats?.size ?? -1; const signature = this.buildSessionFileSignature( extractSessionId(path.basename(filePath)), filePath, effectiveMtime, effectiveSize, stats?.birthtimeMs ); const cached = this.contentPresenceCache.get(filePath); if (cached?.mtimeMs === effectiveMtime && cached.size === effectiveSize) { return cached.hasContent; } let indexed: boolean | undefined; if (this.sessionMetadataIndex) { try { indexed = await this.sessionMetadataIndex.getContentPresence(signature); } catch (error) { logger.debug( `Failed to read content-presence index for ${filePath}: ${ error instanceof Error ? error.message : String(error) }` ); } } if (typeof indexed === 'boolean') { this.contentPresenceCache.set(filePath, { mtimeMs: effectiveMtime, size: effectiveSize, hasContent: indexed, }); return indexed; } const hasContent = await this.sessionContentFilter.hasNonNoiseMessages( filePath, this.fsProvider ); this.contentPresenceCache.set(filePath, { mtimeMs: effectiveMtime, size: effectiveSize, hasContent, }); if (this.sessionMetadataIndex) { try { await this.sessionMetadataIndex.setContentPresence(signature, hasContent); } catch (error) { logger.debug( `Failed to update content-presence index for ${filePath}: ${ error instanceof Error ? error.message : String(error) }` ); } } return hasContent; } catch { return false; } } }