agent-ecosystem/src/main/services/discovery/ProjectScanner.ts
2026-05-04 17:21:05 +03:00

1733 lines
60 KiB
TypeScript

/**
* 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<ReturnType<typeof analyzeSessionFileMetadata>>;
}
>();
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<Project[]> {
// 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<RepositoryGroup[]> {
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<Session[]> {
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<Project[]> {
let timer: ReturnType<typeof setTimeout> | null = null;
const timeout = new Promise<Project[]>((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<Project[]> {
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<string, SessionInfo[]>();
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<Project | null> {
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<Session[]> {
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<PaginatedSessionsResult> {
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<string> | 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<string>();
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<Session> {
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<Session> {
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<Session> {
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<Session | null> {
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<Session | null> {
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<unknown> {
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<string | null> {
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<string[]> {
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<string | null> {
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<Set<string> | 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<boolean> {
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<string[]> {
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<boolean> {
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<SearchSessionsResult> {
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<SearchSessionsResult> {
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<void> {
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<SessionFileMetadata> {
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<string>
): Promise<void> {
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<T, R>(
items: T[],
batchSize: number,
mapper: (item: T) => Promise<R>
): Promise<R[]> {
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<void> {
await new Promise<void>((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<string> {
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<boolean> {
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;
}
}
}