/** * FileWatcher service - Watches for changes in Claude Code project files. * * Responsibilities: * - Watch ~/.claude/projects/ directory for session changes * - Watch ~/.claude/todos/ directory for todo changes * - Detect new/modified/deleted files * - Emit events to notify renderer process * - Invalidate cache entries when files change * - Detect errors in changed session files and notify NotificationManager */ import { type FileChangeEvent, type ParsedMessage } from '@main/types';import { parseJsonlFile, parseJsonlLine } from '@main/utils/jsonl';import { getProjectsBasePath, getTodosBasePath } from '@main/utils/pathDecoder';import { createLogger } from '@shared/utils/logger';import { EventEmitter } from 'events';import * as fs from 'fs';import * as path from 'path'; import { projectPathResolver } from '../discovery/ProjectPathResolver';import { errorDetector } from '../error/ErrorDetector'; import { ConfigManager } from './ConfigManager';import { type DataCache } from './DataCache';import { LocalFileSystemProvider } from './LocalFileSystemProvider';import { type NotificationManager } from './NotificationManager'; import type { FileSystemProvider, FsDirent } from './FileSystemProvider'; const logger = createLogger('Service:FileWatcher'); /** Debounce window for file change events */const DEBOUNCE_MS = 100;/** Retry delay when watched directories are unavailable or watcher errors occur */const WATCHER_RETRY_MS = 2000;/** Interval for periodic catch-up scan to detect missed fs.watch events */const CATCH_UP_INTERVAL_MS = 30_000;/** Only catch-up scan files modified within this window */const CATCH_UP_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour interface AppendedParseResult { messages: ParsedMessage[]; parsedLineCount: number; consumedBytes: number;} interface ActiveSessionFile { projectId: string; sessionId: string; subagentId?: string;} export class FileWatcher extends EventEmitter { private projectsWatcher: fs.FSWatcher | null = null; private todosWatcher: fs.FSWatcher | null = null; private retryTimer: NodeJS.Timeout | null = null; private projectsPath: string; private todosPath: string; private dataCache: DataCache; private fsProvider: FileSystemProvider; private notificationManager: NotificationManager | null = null; private isWatching: boolean = false; private debounceTimers = new Map<string, NodeJS.Timeout>(); /** Track last processed line count per file for incremental error detection */ private lastProcessedLineCount = new Map<string, number>(); /** Track last processed file size in bytes for append-only parsing optimization */ private lastProcessedSize = new Map<string, number>(); /** Active session files tracked for periodic catch-up scan */ private activeSessionFiles = new Map<string, ActiveSessionFile>(); /** Timer for periodic catch-up scan */ private catchUpTimer: NodeJS.Timeout | null = null; /** Timer for SSH polling mode (replaces fs.watch) */ private pollingTimer: NodeJS.Timeout | null = null; /** Polling interval for SSH mode */ private static readonly SSH_POLL_INTERVAL_MS = 3000; /** Guard to prevent overlapping SSH polling runs */ private pollingInProgress = false; /** Indicates whether the first polling baseline snapshot has completed */ private sshPollPrimed = false; /** Track file sizes for SSH polling change detection */ private polledFileSizes = new Map<string, number>(); /** Files currently being processed (concurrency guard) */ private processingInProgress = new Set<string>(); /** Files that need reprocessing after current processing completes */ private pendingReprocess = new Set<string>(); /** Flag to prevent reuse after disposal */ private disposed = false; constructor( dataCache: DataCache, projectsPath?: string, todosPath?: string, fsProvider?: FileSystemProvider ) { super(); this.projectsPath = projectsPath ?? getProjectsBasePath(); this.todosPath = todosPath ?? getTodosBasePath(); this.dataCache = dataCache; this.fsProvider = fsProvider ?? new LocalFileSystemProvider(); } /** * Sets the NotificationManager for error detection integration. * Must be called before start() to enable error notifications. */ setNotificationManager(manager: NotificationManager): void { this.notificationManager = manager; } /** * Sets the filesystem provider. Used when switching between local and SSH modes. */ setFileSystemProvider(provider: FileSystemProvider): void { this.fsProvider = provider; } // =========================================================================== // Watcher Control // =========================================================================== /** * Starts watching the projects and todos directories. */ start(): void { if (this.disposed) { logger.error('Cannot start disposed FileWatcher'); return; } if (this.isWatching) { logger.warn('Already watching'); return; } this.isWatching = true; if (this.fsProvider.type === 'ssh') { this.startPollingMode(); } else { this.ensureWatchers(); } this.startCatchUpTimer(); } /** * Stops all watchers. */ stop(): void { this.isWatching = false; if (this.retryTimer) { clearTimeout(this.retryTimer); this.retryTimer = null; } if (this.projectsWatcher) { this.projectsWatcher.close(); this.projectsWatcher = null; } if (this.todosWatcher) { this.todosWatcher.close(); this.todosWatcher = null; } // Clear any pending debounce timers for (const timer of this.debounceTimers.values()) { clearTimeout(timer); } this.debounceTimers.clear(); // Clear catch-up timer if (this.catchUpTimer) { clearInterval(this.catchUpTimer); this.catchUpTimer = null; } // Clear SSH polling timer if (this.pollingTimer) { clearInterval(this.pollingTimer); this.pollingTimer = null; } this.pollingInProgress = false; this.sshPollPrimed = false; this.polledFileSizes.clear(); // Clear error detection tracking this.lastProcessedLineCount.clear(); this.lastProcessedSize.clear(); this.activeSessionFiles.clear(); this.processingInProgress.clear(); this.pendingReprocess.clear(); logger.info('Stopped watching'); } /** * Disposes all resources and prevents reuse. * Performs comprehensive cleanup of all timers, watchers, maps, and listeners. * * After calling dispose(), this FileWatcher cannot be restarted. * Use stop() for temporary pausing that can be resumed with start(). */ dispose(): void { if (this.disposed) { logger.warn('FileWatcher already disposed'); return; } logger.info('Disposing FileWatcher'); // 1. Stop watchers and clear timers (uses existing stop() logic) this.stop(); // 2. Clear retry timer (stop() already handles this, but being explicit) if (this.retryTimer) { clearTimeout(this.retryTimer); this.retryTimer = null; } // 3. Clear all debounce timers (stop() already handles this) for (const timer of this.debounceTimers.values()) { clearTimeout(timer); } this.debounceTimers.clear(); // 4. Clear catch-up timer (stop() already handles this) if (this.catchUpTimer) { clearInterval(this.catchUpTimer); this.catchUpTimer = null; } // 5. Clear polling timer (stop() already handles this) if (this.pollingTimer) { clearInterval(this.pollingTimer); this.pollingTimer = null; } // 6. Clear all tracking maps (stop() already handles most of these) this.lastProcessedLineCount.clear(); this.lastProcessedSize.clear(); this.activeSessionFiles.clear(); this.polledFileSizes.clear(); this.processingInProgress.clear(); this.pendingReprocess.clear(); // 7. Remove all EventEmitter listeners (MUST be last) this.removeAllListeners(); // 8. Mark as disposed this.disposed = true; logger.info('FileWatcher disposed'); } /** * Starts the projects directory watcher. */ private startProjectsWatcher(): void { if (this.projectsWatcher) { return; } try { if (!fs.existsSync(this.projectsPath)) { logger.warn(`FileWatcher: Projects directory does not exist: ${this.projectsPath}`); this.scheduleWatcherRetry(); return; } this.projectsWatcher = fs.watch( this.projectsPath, { recursive: true }, (eventType, filename) => { if (filename) { this.handleProjectsChange(eventType, filename); } } ); this.attachWatcherRecovery(this.projectsWatcher, 'projects'); logger.info(`FileWatcher: Started watching projects at ${this.projectsPath}`); } catch (error) { logger.error('Error starting projects watcher:', error); this.projectsWatcher = null; this.scheduleWatcherRetry(); } } /** * Starts the todos directory watcher. */ private startTodosWatcher(): void { if (this.todosWatcher) { return; } try { if (!fs.existsSync(this.todosPath)) { // Todos directory may not exist yet - that's OK this.scheduleWatcherRetry(); return; } this.todosWatcher = fs.watch(this.todosPath, (eventType, filename) => { if (filename) { this.handleTodosChange(eventType, filename); } }); this.attachWatcherRecovery(this.todosWatcher, 'todos'); logger.info(`FileWatcher: Started watching todos at ${this.todosPath}`); } catch (error) { logger.error('Error starting todos watcher:', error); this.todosWatcher = null; this.scheduleWatcherRetry(); } } private ensureWatchers(): void { if (!this.isWatching || this.fsProvider.type === 'ssh') { return; } this.startProjectsWatcher(); this.startTodosWatcher(); if (!this.projectsWatcher || !this.todosWatcher) { this.scheduleWatcherRetry(); } } private scheduleWatcherRetry(): void { if (!this.isWatching || this.retryTimer) { return; } this.retryTimer = setTimeout(() => { this.retryTimer = null; this.ensureWatchers(); }, WATCHER_RETRY_MS); } private attachWatcherRecovery(watcher: fs.FSWatcher, watcherType: 'projects' | 'todos'): void { watcher.on('error', (error) => { logger.error(`FileWatcher: ${watcherType} watcher error:`, error); if (watcherType === 'projects') { this.projectsWatcher = null; } else { this.todosWatcher = null; } this.scheduleWatcherRetry(); }); watcher.on('close', () => { if (!this.isWatching) { return; } if (watcherType === 'projects') { this.projectsWatcher = null; } else { this.todosWatcher = null; } this.scheduleWatcherRetry(); }); } // =========================================================================== // SSH Polling Mode // =========================================================================== /** * Starts polling mode for SSH connections. * Polls the projects directory for file changes instead of using fs.watch(). */ private startPollingMode(): void { if (this.pollingTimer) return; logger.info('FileWatcher: Starting SSH polling mode'); const runPoll = (): void => { if (this.pollingInProgress) { return; } this.pollingInProgress = true; this.pollForChanges() .catch((err) => { logger.error('Error during SSH polling:', err); }) .finally(() => { this.pollingInProgress = false; }); }; // Prime immediately so newly created sessions appear without waiting a full interval. runPoll(); this.pollingTimer = setInterval(runPoll, FileWatcher.SSH_POLL_INTERVAL_MS); } /** * Polls the projects directory for file changes in SSH mode. */ private async pollForChanges(): Promise<void> { try { const seenFiles = new Set<string>(); const projectDirs = await this.fsProvider.readdir(this.projectsPath); for (const dir of projectDirs) { if (!dir.isDirectory()) continue; const projectPath = path.join(this.projectsPath, dir.name); let entries: FsDirent[]; try { entries = await this.fsProvider.readdir(projectPath); } catch { continue; } for (const entry of entries) { if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue; const fullPath = path.join(projectPath, entry.name); seenFiles.add(fullPath); try { const observedSize = typeof entry.size === 'number' ? entry.size : (await this.fsProvider.stat(fullPath)).size; const lastSize = this.polledFileSizes.get(fullPath); const relativePath = path.join(dir.name, entry.name); if (lastSize === undefined) { // First time seeing this file: after baseline, emit add. this.polledFileSizes.set(fullPath, observedSize); if (this.sshPollPrimed) { this.handleProjectsChange('rename', relativePath); } } else if (observedSize !== lastSize) { // File changed this.polledFileSizes.set(fullPath, observedSize); this.handleProjectsChange('change', relativePath); } } catch { continue; } } } // Detect deleted files after baseline is established. if (this.sshPollPrimed) { const removedFiles: string[] = []; for (const trackedPath of this.polledFileSizes.keys()) { if (!seenFiles.has(trackedPath)) { removedFiles.push(trackedPath); } } for (const removedPath of removedFiles) { this.polledFileSizes.delete(removedPath); const relativePath = path.relative(this.projectsPath, removedPath); if (relativePath && !relativePath.startsWith('..')) { this.handleProjectsChange('rename', relativePath); } } } else { this.sshPollPrimed = true; } } catch (err) { logger.error('Error polling for changes:', err); } } // =========================================================================== // Event Handling // =========================================================================== /** * Handles file change events in the projects directory. */ private handleProjectsChange(eventType: string, filename: string): void { try { // Ignore non-JSONL files if (!filename.endsWith('.jsonl')) { return; } // Debounce rapid changes to the same file this.debounce(filename, () => this.processProjectsChange(eventType, filename)); } catch (error) { logger.error('Error handling projects change:', error); } } /** * Process a debounced projects change. */ private async processProjectsChange(eventType: string, filename: string): Promise<void> { const fullPath = path.isAbsolute(filename) ? path.normalize(filename) : path.join(this.projectsPath, filename); const relativePath = path.relative(this.projectsPath, fullPath); Lines 1–500 of 918. Load more
/** * FileWatcher service - Watches for changes in Claude Code project files. * * Responsibilities: * - Watch ~/.claude/projects/ directory for session changes * - Watch ~/.claude/todos/ directory for todo changes * - Detect new/modified/deleted files * - Emit events to notify renderer process * - Invalidate cache entries when files change * - Detect errors in changed session files and notify NotificationManager */ import { type FileChangeEvent, type ParsedMessage } from '@main/types'; import { parseJsonlFile, parseJsonlLine } from '@main/utils/jsonl'; import { getProjectsBasePath, getTodosBasePath } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; import { EventEmitter } from 'events'; import * as fs from 'fs'; import * as path from 'path'; import { projectPathResolver } from '../discovery/ProjectPathResolver'; import { errorDetector } from '../error/ErrorDetector'; import { ConfigManager } from './ConfigManager'; import { type DataCache } from './DataCache'; import { LocalFileSystemProvider } from './LocalFileSystemProvider'; import { type NotificationManager } from './NotificationManager'; import type { FileSystemProvider, FsDirent } from './FileSystemProvider'; const logger = createLogger('Service:FileWatcher'); /** Debounce window for file change events */ const DEBOUNCE_MS = 100; /** Retry delay when watched directories are unavailable or watcher errors occur */ const WATCHER_RETRY_MS = 2000; /** Interval for periodic catch-up scan to detect missed fs.watch events */ const CATCH_UP_INTERVAL_MS = 30_000; /** Only catch-up scan files modified within this window */ const CATCH_UP_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour interface AppendedParseResult { messages: ParsedMessage[]; parsedLineCount: number; consumedBytes: number; } interface ActiveSessionFile { projectId: string; sessionId: string; subagentId?: string; } export class FileWatcher extends EventEmitter { private projectsWatcher: fs.FSWatcher | null = null; private todosWatcher: fs.FSWatcher | null = null; private retryTimer: NodeJS.Timeout | null = null; private projectsPath: string; private todosPath: string; private dataCache: DataCache; private fsProvider: FileSystemProvider; private notificationManager: NotificationManager | null = null; private isWatching: boolean = false; private debounceTimers = new Map<string, NodeJS.Timeout>(); /** Track last processed line count per file for incremental error detection */ private lastProcessedLineCount = new Map<string, number>(); /** Track last processed file size in bytes for append-only parsing optimization */ private lastProcessedSize = new Map<string, number>(); /** Active session files tracked for periodic catch-up scan */ private activeSessionFiles = new Map<string, ActiveSessionFile>(); /** Timer for periodic catch-up scan */ private catchUpTimer: NodeJS.Timeout | null = null; /** Timer for SSH polling mode (replaces fs.watch) */ private pollingTimer: NodeJS.Timeout | null = null; /** Polling interval for SSH mode */ private static readonly SSH_POLL_INTERVAL_MS = 3000; /** Guard to prevent overlapping SSH polling runs */ private pollingInProgress = false; /** Indicates whether the first polling baseline snapshot has completed */ private sshPollPrimed = false; /** Track file sizes for SSH polling change detection */ private polledFileSizes = new Map<string, number>(); /** Files currently being processed (concurrency guard) */ private processingInProgress = new Set<string>(); /** Files that need reprocessing after current processing completes */ private pendingReprocess = new Set<string>(); /** Flag to prevent reuse after disposal */ private disposed = false; constructor( dataCache: DataCache, projectsPath?: string, todosPath?: string, fsProvider?: FileSystemProvider ) { super(); this.projectsPath = projectsPath ?? getProjectsBasePath(); this.todosPath = todosPath ?? getTodosBasePath(); this.dataCache = dataCache; this.fsProvider = fsProvider ?? new LocalFileSystemProvider(); } /** * Sets the NotificationManager for error detection integration. * Must be called before start() to enable error notifications. */ setNotificationManager(manager: NotificationManager): void { this.notificationManager = manager; } /** * Sets the filesystem provider. Used when switching between local and SSH modes. */ setFileSystemProvider(provider: FileSystemProvider): void { this.fsProvider = provider; } // =========================================================================== // Watcher Control // =========================================================================== /** * Starts watching the projects and todos directories. */ start(): void { if (this.disposed) { logger.error('Cannot start disposed FileWatcher'); return; } if (this.isWatching) { logger.warn('Already watching'); return; } this.isWatching = true; if (this.fsProvider.type === 'ssh') { this.startPollingMode(); } else { this.ensureWatchers(); } this.startCatchUpTimer(); } /** * Stops all watchers. */ stop(): void { this.isWatching = false; if (this.retryTimer) { clearTimeout(this.retryTimer); this.retryTimer = null; } if (this.projectsWatcher) { this.projectsWatcher.close(); this.projectsWatcher = null; } if (this.todosWatcher) { this.todosWatcher.close(); this.todosWatcher = null; } // Clear any pending debounce timers for (const timer of this.debounceTimers.values()) { clearTimeout(timer); } this.debounceTimers.clear(); // Clear catch-up timer if (this.catchUpTimer) { clearInterval(this.catchUpTimer); this.catchUpTimer = null; } // Clear SSH polling timer if (this.pollingTimer) { clearInterval(this.pollingTimer); this.pollingTimer = null; } this.pollingInProgress = false; this.sshPollPrimed = false; this.polledFileSizes.clear(); // Clear error detection tracking this.lastProcessedLineCount.clear(); this.lastProcessedSize.clear(); this.activeSessionFiles.clear(); this.processingInProgress.clear(); this.pendingReprocess.clear(); logger.info('Stopped watching'); } /** * Disposes all resources and prevents reuse. * Performs comprehensive cleanup of all timers, watchers, maps, and listeners. * * After calling dispose(), this FileWatcher cannot be restarted. * Use stop() for temporary pausing that can be resumed with start(). */ dispose(): void { if (this.disposed) { logger.warn('FileWatcher already disposed'); return; } logger.info('Disposing FileWatcher'); // 1. Stop watchers and clear timers (uses existing stop() logic) this.stop(); // 2. Clear retry timer (stop() already handles this, but being explicit) if (this.retryTimer) { clearTimeout(this.retryTimer); this.retryTimer = null; } // 3. Clear all debounce timers (stop() already handles this) for (const timer of this.debounceTimers.values()) { clearTimeout(timer); } this.debounceTimers.clear(); // 4. Clear catch-up timer (stop() already handles this) if (this.catchUpTimer) { clearInterval(this.catchUpTimer); this.catchUpTimer = null; } // 5. Clear polling timer (stop() already handles this) if (this.pollingTimer) { clearInterval(this.pollingTimer); this.pollingTimer = null; } // 6. Clear all tracking maps (stop() already handles most of these) this.lastProcessedLineCount.clear(); this.lastProcessedSize.clear(); this.activeSessionFiles.clear(); this.polledFileSizes.clear(); this.processingInProgress.clear(); this.pendingReprocess.clear(); // 7. Remove all EventEmitter listeners (MUST be last) this.removeAllListeners(); // 8. Mark as disposed this.disposed = true; logger.info('FileWatcher disposed'); } /** * Starts the projects directory watcher. */ private startProjectsWatcher(): void { if (this.projectsWatcher) { return; } try { if (!fs.existsSync(this.projectsPath)) { logger.warn(`FileWatcher: Projects directory does not exist: ${this.projectsPath}`); this.scheduleWatcherRetry(); return; } this.projectsWatcher = fs.watch( this.projectsPath, { recursive: true }, (eventType, filename) => { if (filename) { this.handleProjectsChange(eventType, filename); } } ); this.attachWatcherRecovery(this.projectsWatcher, 'projects'); logger.info(`FileWatcher: Started watching projects at ${this.projectsPath}`); } catch (error) { logger.error('Error starting projects watcher:', error); this.projectsWatcher = null; this.scheduleWatcherRetry(); } } /** * Starts the todos directory watcher. */ private startTodosWatcher(): void { if (this.todosWatcher) { return; } try { if (!fs.existsSync(this.todosPath)) { // Todos directory may not exist yet - that's OK this.scheduleWatcherRetry(); return; } this.todosWatcher = fs.watch(this.todosPath, (eventType, filename) => { if (filename) { this.handleTodosChange(eventType, filename); } }); this.attachWatcherRecovery(this.todosWatcher, 'todos'); logger.info(`FileWatcher: Started watching todos at ${this.todosPath}`); } catch (error) { logger.error('Error starting todos watcher:', error); this.todosWatcher = null; this.scheduleWatcherRetry(); } } private ensureWatchers(): void { if (!this.isWatching || this.fsProvider.type === 'ssh') { return; } this.startProjectsWatcher(); this.startTodosWatcher(); if (!this.projectsWatcher || !this.todosWatcher) { this.scheduleWatcherRetry(); } } private scheduleWatcherRetry(): void { if (!this.isWatching || this.retryTimer) { return; } this.retryTimer = setTimeout(() => { this.retryTimer = null; this.ensureWatchers(); }, WATCHER_RETRY_MS); } private attachWatcherRecovery(watcher: fs.FSWatcher, watcherType: 'projects' | 'todos'): void { watcher.on('error', (error) => { logger.error(`FileWatcher: ${watcherType} watcher error:`, error); if (watcherType === 'projects') { this.projectsWatcher = null; } else { this.todosWatcher = null; } this.scheduleWatcherRetry(); }); watcher.on('close', () => { if (!this.isWatching) { return; } if (watcherType === 'projects') { this.projectsWatcher = null; } else { this.todosWatcher = null; } this.scheduleWatcherRetry(); }); } // =========================================================================== // SSH Polling Mode // =========================================================================== /** * Starts polling mode for SSH connections. * Polls the projects directory for file changes instead of using fs.watch(). */ private startPollingMode(): void { if (this.pollingTimer) return; logger.info('FileWatcher: Starting SSH polling mode'); const runPoll = (): void => { if (this.pollingInProgress) { return; } this.pollingInProgress = true; this.pollForChanges() .catch((err) => { logger.error('Error during SSH polling:', err); }) .finally(() => { this.pollingInProgress = false; }); }; // Prime immediately so newly created sessions appear without waiting a full interval. runPoll(); this.pollingTimer = setInterval(runPoll, FileWatcher.SSH_POLL_INTERVAL_MS); } /** * Polls the projects directory for file changes in SSH mode. */ private async pollForChanges(): Promise<void> { try { const seenFiles = new Set<string>(); const projectDirs = await this.fsProvider.readdir(this.projectsPath); for (const dir of projectDirs) { if (!dir.isDirectory()) continue; const projectPath = path.join(this.projectsPath, dir.name); let entries: FsDirent[]; try { entries = await this.fsProvider.readdir(projectPath); } catch { continue; } for (const entry of entries) { if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue; const fullPath = path.join(projectPath, entry.name); seenFiles.add(fullPath); try { const observedSize = typeof entry.size === 'number' ? entry.size : (await this.fsProvider.stat(fullPath)).size; const lastSize = this.polledFileSizes.get(fullPath); const relativePath = path.join(dir.name, entry.name); if (lastSize === undefined) { // First time seeing this file: after baseline, emit add. this.polledFileSizes.set(fullPath, observedSize); if (this.sshPollPrimed) { this.handleProjectsChange('rename', relativePath); } } else if (observedSize !== lastSize) { // File changed this.polledFileSizes.set(fullPath, observedSize); this.handleProjectsChange('change', relativePath); } } catch { continue; } } } // Detect deleted files after baseline is established. if (this.sshPollPrimed) { const removedFiles: string[] = []; for (const trackedPath of this.polledFileSizes.keys()) { if (!seenFiles.has(trackedPath)) { removedFiles.push(trackedPath); } } for (const removedPath of removedFiles) { this.polledFileSizes.delete(removedPath); const relativePath = path.relative(this.projectsPath, removedPath); if (relativePath && !relativePath.startsWith('..')) { this.handleProjectsChange('rename', relativePath); } } } else { this.sshPollPrimed = true; } } catch (err) { logger.error('Error polling for changes:', err); } } // =========================================================================== // Event Handling // =========================================================================== /** * Handles file change events in the projects directory. */ private handleProjectsChange(eventType: string, filename: string): void { try { // Ignore non-JSONL files if (!filename.endsWith('.jsonl')) { return; } // Debounce rapid changes to the same file this.debounce(filename, () => this.processProjectsChange(eventType, filename)); } catch (error) { logger.error('Error handling projects change:', error); } } /** * Process a debounced projects change. */ private async processProjectsChange(eventType: string, filename: string): Promise<void> { const fullPath = path.isAbsolute(filename) ? path.normalize(filename) : path.join(this.projectsPath, filename); const relativePath = path.relative(this.projectsPath, fullPath);