/** * 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 { type PaginatedSessionsResult, type Project, type RepositoryGroup, type SearchSessionsResult, type Session, type SessionCursor, type SessionMetadataLevel, type SessionsByIdsOptions, type SessionsPaginationOptions,} from '@main/types';import { analyzeSessionFileMetadata, extractCwd, extractFirstUserMessagePreview,} 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 { LocalFileSystemProvider } from '../infrastructure/LocalFileSystemProvider'; import { ProjectPathResolver } from './ProjectPathResolver';import { SessionContentFilter } from './SessionContentFilter';import { SessionSearcher } from './SessionSearcher';import { SubagentLocator } from './SubagentLocator';import { subprojectRegistry } from './SubprojectRegistry';import { WorktreeGrouper } from './WorktreeGrouper'; import type { FileSystemProvider, FsDirent } from '../infrastructure/FileSystemProvider'; const logger = createLogger('Discovery:ProjectScanner'); 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<ReturnType<typeof analyzeSessionFileMetadata>>; } >(); private readonly sessionPreviewCache = new Map< string, { mtimeMs: number; size: number; preview: { text: string; timestamp: string } | null } >(); // Delegated services private readonly fsProvider: FileSystemProvider; private readonly sessionContentFilter: typeof SessionContentFilter; private readonly worktreeGrouper: WorktreeGrouper; private readonly subagentLocator: SubagentLocator; private readonly sessionSearcher: SessionSearcher; private readonly projectPathResolver: ProjectPathResolver; constructor(projectsDir?: string, todosDir?: string, fsProvider?: FileSystemProvider) { this.projectsDir = projectsDir ?? getProjectsBasePath(); this.todosDir = todosDir ?? getTodosBasePath(); this.fsProvider = fsProvider ?? new LocalFileSystemProvider(); // Initialize delegated services this.sessionContentFilter = SessionContentFilter; this.worktreeGrouper = new WorktreeGrouper(this.projectsDir, this.fsProvider); this.subagentLocator = new SubagentLocator(this.projectsDir, this.fsProvider); this.sessionSearcher = new SessionSearcher(this.projectsDir, this.fsProvider); this.projectPathResolver = new ProjectPathResolver(this.projectsDir, this.fsProvider); } // =========================================================================== // 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<Project[]> { const startedAt = Date.now(); try { 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(); const entries = await this.fsProvider.readdir(this.projectsDir); // 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) const projectArrays = await this.collectFulfilledInBatches( projectDirs, this.fsProvider.type === 'ssh' ? 8 : 24, async (dir) => this.scanProject(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` ); } return validProjects; } catch (error) { logger.error('Error scanning projects directory:', error); return []; } } // =========================================================================== // 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<RepositoryGroup[]> { try { // 1. Scan all projects using existing logic const projects = await this.scan(); if (projects.length === 0) { return []; } // 2. Delegate to WorktreeGrouper return this.worktreeGrouper.groupByRepository(projects); } 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<Session[]> { return this.listSessions(worktreeId); } // =========================================================================== // Project Scanning (continued) // =========================================================================== /** * 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<Project[]> { try { const projectPath = path.join(this.projectsDir, encodedName); const entries = await this.fsProvider.readdir(projectPath); // Get session files (.jsonl at root level) const sessionFiles = entries.filter( (entry) => entry.isFile() && entry.name.endsWith('.jsonl') ); if (sessionFiles.length === 0) { return []; } // Collect file stats and cwd for each session interface SessionInfo { sessionId: string; filePath: string; mtimeMs: number; birthtimeMs: number; cwd: string | null; } const shouldSplitByCwd = this.fsProvider.type !== 'ssh'; const sessionInfos = await this.collectFulfilledInBatches( sessionFiles, this.fsProvider.type === 'ssh' ? 32 : 128, 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 []; } // Group sessions by cwd const cwdGroups = new Map<string, SessionInfo[]>(); const baseName = extractProjectName(encodedName); 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 cwd, return single project (current behavior) if (cwdGroups.size <= 1) { const allSessionIds = sessionInfos.map((s) => s.sessionId); 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, { sessionPaths, }); return [ { id: encodedName, path: actualPath, name: baseName, sessions: allSessionIds, 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] ?? '' ); 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 ); // 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 let displayName: string; if (!actualCwd || actualCwd === rootCwd) { displayName = baseName; } else { // Use last segment of cwd for disambiguation const lastSegment = path.basename(actualCwd); displayName = `${baseName} (${lastSegment})`; } projects.push({ id: compositeId, path: actualCwd ?? decodedFallback, name: displayName, sessions: sessionIds, 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<Project | null> { const baseDir = extractBaseDir(projectId); const projectPath = path.join(this.projectsDir, baseDir); if (!(await this.fsProvider.exists(projectPath))) { return null; } // 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<Session[]> { try { const baseDir = extractBaseDir(projectId); const projectPath = path.join(this.projectsDir, baseDir); const sessionFilter = await this.getSessionFilterForProject(projectId); const shouldFilterNoise = this.fsProvider.type !== 'ssh'; const metadataLevel: SessionMetadataLevel = this.fsProvider.type === 'ssh' ? 'light' : 'deep'; if (!(await this.fsProvider.exists(projectPath))) { return []; } const entries = await this.fsProvider.readdir(projectPath); let sessionFiles = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl')); // 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 Promise.all( sessionFiles.map(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<PaginatedSessionsResult> { const startedAt = Date.now(); try { const includeTotalCount = options?.includeTotalCount ?? false; const prefilterAll = options?.prefilterAll ?? false; const baseDir = extractBaseDir(projectId); const projectPath = path.join(this.projectsDir, baseDir); const sessionFilter = await this.getSessionFilterForProject(projectId); const shouldFilterNoise = this.fsProvider.type !== 'ssh'; const metadataLevel: SessionMetadataLevel = options?.metadataLevel ?? (this.fsProvider.type === 'ssh' ? 'light' : 'deep'); if (!(await this.fsProvider.exists(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); let sessionFiles = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl')); // Filter to only sessions belonging to this subproject if (sessionFilter) { sessionFiles = sessionFiles.filter((f) => sessionFilter.has(extractSessionId(f.name))); } Lines 1–500 of 1241. Load more
/** * 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 { type PaginatedSessionsResult, type Project, type RepositoryGroup, type SearchSessionsResult, type Session, type SessionCursor, type SessionMetadataLevel, type SessionsByIdsOptions, type SessionsPaginationOptions, } from '@main/types'; import { analyzeSessionFileMetadata, extractCwd, extractFirstUserMessagePreview, } 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 { LocalFileSystemProvider } from '../infrastructure/LocalFileSystemProvider'; import { ProjectPathResolver } from './ProjectPathResolver'; import { SessionContentFilter } from './SessionContentFilter'; import { SessionSearcher } from './SessionSearcher'; import { SubagentLocator } from './SubagentLocator'; import { subprojectRegistry } from './SubprojectRegistry'; import { WorktreeGrouper } from './WorktreeGrouper'; import type { FileSystemProvider, FsDirent } from '../infrastructure/FileSystemProvider'; const logger = createLogger('Discovery:ProjectScanner'); 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<ReturnType<typeof analyzeSessionFileMetadata>>; } >(); private readonly sessionPreviewCache = new Map< string, { mtimeMs: number; size: number; preview: { text: string; timestamp: string } | null } >(); // Delegated services private readonly fsProvider: FileSystemProvider; private readonly sessionContentFilter: typeof SessionContentFilter; private readonly worktreeGrouper: WorktreeGrouper; private readonly subagentLocator: SubagentLocator; private readonly sessionSearcher: SessionSearcher; private readonly projectPathResolver: ProjectPathResolver; constructor(projectsDir?: string, todosDir?: string, fsProvider?: FileSystemProvider) { this.projectsDir = projectsDir ?? getProjectsBasePath(); this.todosDir = todosDir ?? getTodosBasePath(); this.fsProvider = fsProvider ?? new LocalFileSystemProvider(); // Initialize delegated services this.sessionContentFilter = SessionContentFilter; this.worktreeGrouper = new WorktreeGrouper(this.projectsDir, this.fsProvider); this.subagentLocator = new SubagentLocator(this.projectsDir, this.fsProvider); this.sessionSearcher = new SessionSearcher(this.projectsDir, this.fsProvider); this.projectPathResolver = new ProjectPathResolver(this.projectsDir, this.fsProvider); } // =========================================================================== // 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<Project[]> { const startedAt = Date.now(); try { 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(); const entries = await this.fsProvider.readdir(this.projectsDir); // 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) const projectArrays = await this.collectFulfilledInBatches( projectDirs, this.fsProvider.type === 'ssh' ? 8 : 24, async (dir) => this.scanProject(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` ); } return validProjects; } catch (error) { logger.error('Error scanning projects directory:', error); return []; } } // =========================================================================== // 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<RepositoryGroup[]> { try { // 1. Scan all projects using existing logic const projects = await this.scan(); if (projects.length === 0) { return []; } // 2. Delegate to WorktreeGrouper return this.worktreeGrouper.groupByRepository(projects); } 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<Session[]> { return this.listSessions(worktreeId); } // =========================================================================== // Project Scanning (continued) // =========================================================================== /** * 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<Project[]> { try { const projectPath = path.join(this.projectsDir, encodedName); const entries = await this.fsProvider.readdir(projectPath); // Get session files (.jsonl at root level) const sessionFiles = entries.filter( (entry) => entry.isFile() && entry.name.endsWith('.jsonl') ); if (sessionFiles.length === 0) { return []; } // Collect file stats and cwd for each session interface SessionInfo { sessionId: string; filePath: string; mtimeMs: number; birthtimeMs: number; cwd: string | null; } const shouldSplitByCwd = this.fsProvider.type !== 'ssh'; const sessionInfos = await this.collectFulfilledInBatches( sessionFiles, this.fsProvider.type === 'ssh' ? 32 : 128, 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 []; } // Group sessions by cwd const cwdGroups = new Map<string, SessionInfo[]>(); const baseName = extractProjectName(encodedName); 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 cwd, return single project (current behavior) if (cwdGroups.size <= 1) { const allSessionIds = sessionInfos.map((s) => s.sessionId); 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, { sessionPaths, }); return [ { id: encodedName, path: actualPath, name: baseName, sessions: allSessionIds, 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] ?? '' ); 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 ); // 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 let displayName: string; if (!actualCwd || actualCwd === rootCwd) { displayName = baseName; } else { // Use last segment of cwd for disambiguation const lastSegment = path.basename(actualCwd); displayName = `${baseName} (${lastSegment})`; } projects.push({ id: compositeId, path: actualCwd ?? decodedFallback, name: displayName, sessions: sessionIds, 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<Project | null> { const baseDir = extractBaseDir(projectId); const projectPath = path.join(this.projectsDir, baseDir); if (!(await this.fsProvider.exists(projectPath))) { return null; } // 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<Session[]> { try { const baseDir = extractBaseDir(projectId); const projectPath = path.join(this.projectsDir, baseDir); const sessionFilter = await this.getSessionFilterForProject(projectId); const shouldFilterNoise = this.fsProvider.type !== 'ssh'; const metadataLevel: SessionMetadataLevel = this.fsProvider.type === 'ssh' ? 'light' : 'deep'; if (!(await this.fsProvider.exists(projectPath))) { return []; } const entries = await this.fsProvider.readdir(projectPath); let sessionFiles = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl')); // 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 Promise.all( sessionFiles.map(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<PaginatedSessionsResult> { const startedAt = Date.now(); try { const includeTotalCount = options?.includeTotalCount ?? false; const prefilterAll = options?.prefilterAll ?? false; const baseDir = extractBaseDir(projectId); const projectPath = path.join(this.projectsDir, baseDir); const sessionFilter = await this.getSessionFilterForProject(projectId); const shouldFilterNoise = this.fsProvider.type !== 'ssh'; const metadataLevel: SessionMetadataLevel = options?.metadataLevel ?? (this.fsProvider.type === 'ssh' ? 'light' : 'deep'); if (!(await this.fsProvider.exists(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); let sessionFiles = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl')); // Filter to only sessions belonging to this subproject if (sessionFilter) { sessionFiles = sessionFiles.filter((f) => sessionFilter.has(extractSessionId(f.name))); }