diff --git a/server/index.js b/server/index.js index 750fdeaa..9fa6abf6 100755 --- a/server/index.js +++ b/server/index.js @@ -36,7 +36,6 @@ import { deleteProjectById, getProjectTaskMasterById, getProjectPathById, - clearProjectDirectoryCache, searchConversations, } from './projects.js'; import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval, getPendingApprovalsForSession, reconnectSessionWriter } from './claude-sdk.js'; @@ -68,149 +67,7 @@ import { getConnectableHost } from '../shared/networkHosts.js'; const VALID_PROVIDERS = ['claude', 'codex', 'cursor', 'gemini']; -// File system watchers for provider project/session folders -const PROVIDER_WATCH_PATHS = [ - { provider: 'claude', rootPath: path.join(os.homedir(), '.claude', 'projects') }, - { provider: 'cursor', rootPath: path.join(os.homedir(), '.cursor', 'chats') }, - { provider: 'codex', rootPath: path.join(os.homedir(), '.codex', 'sessions') }, - { provider: 'gemini', rootPath: path.join(os.homedir(), '.gemini', 'projects') }, - { provider: 'gemini_sessions', rootPath: path.join(os.homedir(), '.gemini', 'sessions') } -]; -const WATCHER_IGNORED_PATTERNS = [ - '**/node_modules/**', - '**/.git/**', - '**/dist/**', - '**/build/**', - '**/*.tmp', - '**/*.swp', - '**/.DS_Store' -]; -const WATCHER_DEBOUNCE_MS = 300; -let projectsWatchers = []; -let projectsWatcherDebounceTimer = null; -const connectedClients = new Set(); -let isGetProjectsRunning = false; // Flag to prevent reentrant calls - -// Broadcast progress to all connected WebSocket clients -function broadcastProgress(progress) { - const message = JSON.stringify({ - type: 'loading_progress', - ...progress - }); - connectedClients.forEach(client => { - if (client.readyState === WebSocket.OPEN) { - client.send(message); - } - }); -} - -// Setup file system watchers for Claude, Cursor, and Codex project/session folders -async function setupProjectsWatcher() { - const chokidar = (await import('chokidar')).default; - - if (projectsWatcherDebounceTimer) { - clearTimeout(projectsWatcherDebounceTimer); - projectsWatcherDebounceTimer = null; - } - - await Promise.all( - projectsWatchers.map(async (watcher) => { - try { - await watcher.close(); - } catch (error) { - console.error('[WARN] Failed to close watcher:', error); - } - }) - ); - projectsWatchers = []; - - const debouncedUpdate = (eventType, filePath, provider, rootPath) => { - if (projectsWatcherDebounceTimer) { - clearTimeout(projectsWatcherDebounceTimer); - } - - projectsWatcherDebounceTimer = setTimeout(async () => { - // Prevent reentrant calls - if (isGetProjectsRunning) { - return; - } - - try { - isGetProjectsRunning = true; - - // Clear project directory cache when files change - clearProjectDirectoryCache(); - - // Get updated projects list - const updatedProjects = await getProjectsWithSessions(broadcastProgress); - - // Notify all connected clients about the project changes - const updateMessage = JSON.stringify({ - type: 'projects_updated', - projects: updatedProjects, - timestamp: new Date().toISOString(), - changeType: eventType, - changedFile: path.relative(rootPath, filePath), - watchProvider: provider - }); - - connectedClients.forEach(client => { - if (client.readyState === WebSocket.OPEN) { - client.send(updateMessage); - } - }); - - } catch (error) { - console.error('[ERROR] Error handling project changes:', error); - } finally { - isGetProjectsRunning = false; - } - }, WATCHER_DEBOUNCE_MS); - }; - - for (const { provider, rootPath } of PROVIDER_WATCH_PATHS) { - try { - // chokidar v4 emits ENOENT via the "error" event for missing roots and will not auto-recover. - // Ensure provider folders exist before creating the watcher so watching stays active. - await fsPromises.mkdir(rootPath, { recursive: true }); - - // Initialize chokidar watcher with optimized settings - const watcher = chokidar.watch(rootPath, { - ignored: WATCHER_IGNORED_PATTERNS, - persistent: true, - ignoreInitial: true, // Don't fire events for existing files on startup - followSymlinks: false, - depth: 10, // Reasonable depth limit - awaitWriteFinish: { - stabilityThreshold: 100, // Wait 100ms for file to stabilize - pollInterval: 50 - } - }); - - // Set up event listeners - watcher - .on('add', (filePath) => debouncedUpdate('add', filePath, provider, rootPath)) - .on('change', (filePath) => debouncedUpdate('change', filePath, provider, rootPath)) - .on('unlink', (filePath) => debouncedUpdate('unlink', filePath, provider, rootPath)) - .on('addDir', (dirPath) => debouncedUpdate('addDir', dirPath, provider, rootPath)) - .on('unlinkDir', (dirPath) => debouncedUpdate('unlinkDir', dirPath, provider, rootPath)) - .on('error', (error) => { - console.error(`[ERROR] ${provider} watcher error:`, error); - }) - .on('ready', () => { - }); - - projectsWatchers.push(watcher); - } catch (error) { - console.error(`[ERROR] Failed to setup ${provider} watcher for ${rootPath}:`, error); - } - } - - if (projectsWatchers.length === 0) { - console.error('[ERROR] Failed to setup any provider watchers'); - } -} - +export const connectedClients = new Set(); const app = express(); const server = http.createServer(app); @@ -219,6 +76,7 @@ const ptySessionsMap = new Map(); const PTY_SESSION_TIMEOUT = 30 * 60 * 1000; const SHELL_URL_PARSE_BUFFER_LIMIT = 32768; import { stripAnsiSequences, normalizeDetectedUrl, extractUrlsFromText, shouldAutoOpenUrlFromOutput } from './utils/url-detection.js'; +import { closeSessionsWatcher, initializeSessionsWatcher } from '@/modules/providers/index.js'; // Single WebSocket server that handles both paths const wss = new WebSocketServer({ @@ -431,7 +289,7 @@ app.post('/api/system/update', authenticateToken, async (req, res) => { app.get('/api/projects', authenticateToken, async (req, res) => { try { - const projects = await getProjectsWithSessions(broadcastProgress); + const projects = await getProjectsWithSessions(); res.json(projects); } catch (error) { res.status(500).json({ error: error.message }); @@ -2373,7 +2231,7 @@ async function startServer() { console.log(''); // Start watching the projects folder for changes - await setupProjectsWatcher(); + await initializeSessionsWatcher(); // await getProjectsWithSessions(); // TODO: REMOVE THIS // Start server-side plugin processes for enabled plugins @@ -2382,6 +2240,7 @@ async function startServer() { }); }); + await closeSessionsWatcher(); // Clean up plugin processes on shutdown const shutdownPlugins = async () => { await stopAllPlugins(); diff --git a/server/modules/database/repositories/sessions.db.ts b/server/modules/database/repositories/sessions.db.ts index 8e3571db..2cf3a83c 100644 --- a/server/modules/database/repositories/sessions.db.ts +++ b/server/modules/database/repositories/sessions.db.ts @@ -76,7 +76,7 @@ export const sessionsDb = { createdAt?: string, updatedAt?: string, jsonlPath?: string | null - ): void { + ): string { const db = getConnection(); const createdAtValue = normalizeTimestamp(createdAt); const updatedAtValue = normalizeTimestamp(updatedAt); @@ -103,6 +103,8 @@ export const sessionsDb = { createdAtValue, updatedAtValue ); + + return sessionId; }, updateSessionCustomName(sessionId: string, customName: string): void { diff --git a/server/modules/projects/services/projects.service.ts b/server/modules/projects/services/projects.service.ts index 5967d54d..62d6aee7 100644 --- a/server/modules/projects/services/projects.service.ts +++ b/server/modules/projects/services/projects.service.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import { projectsDb, sessionsDb } from '@/modules/database/index.js'; import { sessionSynchronizerService } from '@/modules/providers/index.js'; import { findAppRoot, getModuleDir } from '@/utils/runtime-paths.js'; +import { connectedClients } from '@/index.js'; type SessionSummary = { id: string; @@ -35,9 +36,12 @@ export type ProjectsSnapshot = { projects: ProjectListItem[]; }; -type ProgressCallback = - | ((progress: { phase: 'loading' | 'complete'; current: number; total: number; currentProject?: string }) => void) - | null; +type ProgressUpdate = { + phase: 'loading' | 'complete'; + current: number; + total: number; + currentProject?: string; +}; const __dirname = getModuleDir(import.meta.url); const APP_ROOT = findAppRoot(__dirname); @@ -172,10 +176,24 @@ export async function writeSnapshot(projects: ProjectListItem[]): Promise } } +// Broadcast progress to all connected WebSocket clients +function broadcastProgress(progress: ProgressUpdate) { + const message = JSON.stringify({ + type: 'loading_progress', + ...progress, + }); + + connectedClients.forEach((client: any) => { + if (client.readyState === WebSocket.OPEN) { + client.send(message); + } + }); +} + /** * Reads all projects from DB and returns provider-bucketed session summaries. */ -export async function getProjectsWithSessions(progressCallback: ProgressCallback = null): Promise { +export async function getProjectsWithSessions(): Promise { await sessionSynchronizerService.synchronizeSessions(); const projectRows = projectsDb.getProjectPaths() as Array<{ @@ -193,14 +211,12 @@ export async function getProjectsWithSessions(progressCallback: ProgressCallback const projectId = row.project_id; const projectPath = row.project_path; - if (progressCallback) { - progressCallback({ - phase: 'loading', - current: processedProjects, - total: totalProjects, - currentProject: projectPath, - }); - } + broadcastProgress({ + phase: 'loading', + current: processedProjects, + total: totalProjects, + currentProject: projectPath, + }); const displayName = row.custom_project_name && row.custom_project_name.trim().length > 0 @@ -227,13 +243,11 @@ export async function getProjectsWithSessions(progressCallback: ProgressCallback }); } - if (progressCallback) { - progressCallback({ - phase: 'complete', - current: totalProjects, - total: totalProjects, - }); - } + broadcastProgress({ + phase: 'complete', + current: totalProjects, + total: totalProjects, + }); await writeSnapshot(projects); return projects; diff --git a/server/modules/providers/list/claude/claude-session-synchronizer.provider.ts b/server/modules/providers/list/claude/claude-session-synchronizer.provider.ts index a21b1046..7d089a2d 100644 --- a/server/modules/providers/list/claude/claude-session-synchronizer.provider.ts +++ b/server/modules/providers/list/claude/claude-session-synchronizer.provider.ts @@ -61,19 +61,19 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer { /** * Parses and upserts one Claude session JSONL file. */ - async synchronizeFile(filePath: string): Promise { + async synchronizeFile(filePath: string): Promise { if (!filePath.endsWith('.jsonl')) { - return false; + return null; } const nameMap = await buildLookupMap(path.join(this.claudeHome, 'history.jsonl'), 'sessionId', 'display'); const parsed = await this.processSessionFile(filePath, nameMap); if (!parsed) { - return false; + return null; } const timestamps = await readFileTimestamps(filePath); - sessionsDb.createSession( + return sessionsDb.createSession( parsed.sessionId, this.provider, parsed.projectPath, @@ -82,8 +82,6 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer { timestamps.updatedAt, filePath ); - - return true; } /** diff --git a/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts b/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts index 64d601af..5f3ee207 100644 --- a/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts +++ b/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts @@ -61,19 +61,19 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer { /** * Parses and upserts one Codex session JSONL file. */ - async synchronizeFile(filePath: string): Promise { + async synchronizeFile(filePath: string): Promise { if (!filePath.endsWith('.jsonl')) { - return false; + return null; } const nameMap = await buildLookupMap(path.join(this.codexHome, 'session_index.jsonl'), 'id', 'thread_name'); const parsed = await this.processSessionFile(filePath, nameMap); if (!parsed) { - return false; + return null; } const timestamps = await readFileTimestamps(filePath); - sessionsDb.createSession( + return sessionsDb.createSession( parsed.sessionId, this.provider, parsed.projectPath, @@ -82,8 +82,6 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer { timestamps.updatedAt, filePath ); - - return true; } /** diff --git a/server/modules/providers/list/cursor/cursor-session-synchronizer.provider.ts b/server/modules/providers/list/cursor/cursor-session-synchronizer.provider.ts index b0e89b12..4be02dee 100644 --- a/server/modules/providers/list/cursor/cursor-session-synchronizer.provider.ts +++ b/server/modules/providers/list/cursor/cursor-session-synchronizer.provider.ts @@ -91,18 +91,18 @@ export class CursorSessionSynchronizer implements IProviderSessionSynchronizer { /** * Parses and upserts one Cursor session JSONL file. */ - async synchronizeFile(filePath: string): Promise { + async synchronizeFile(filePath: string): Promise { if (!filePath.endsWith('.jsonl')) { - return false; + return null; } const parsed = await this.processSessionFile(filePath); if (!parsed) { - return false; + return null; } const timestamps = await readFileTimestamps(filePath); - sessionsDb.createSession( + return sessionsDb.createSession( parsed.sessionId, this.provider, parsed.projectPath, @@ -111,8 +111,6 @@ export class CursorSessionSynchronizer implements IProviderSessionSynchronizer { timestamps.updatedAt, filePath ); - - return true; } /** diff --git a/server/modules/providers/list/gemini/gemini-session-synchronizer.provider.ts b/server/modules/providers/list/gemini/gemini-session-synchronizer.provider.ts index 2cf8ecc3..a2fd4085 100644 --- a/server/modules/providers/list/gemini/gemini-session-synchronizer.provider.ts +++ b/server/modules/providers/list/gemini/gemini-session-synchronizer.provider.ts @@ -72,25 +72,25 @@ export class GeminiSessionSynchronizer implements IProviderSessionSynchronizer { /** * Parses and upserts one Gemini session JSON artifact. */ - async synchronizeFile(filePath: string): Promise { + async synchronizeFile(filePath: string): Promise { if (!filePath.endsWith('.json')) { - return false; + return null; } if ( filePath.startsWith(path.join(this.geminiHome, 'tmp')) && !filePath.includes(`${path.sep}chats${path.sep}`) ) { - return false; + return null; } const parsed = await this.processSessionFile(filePath); if (!parsed) { - return false; + return null; } const timestamps = await readFileTimestamps(filePath); - sessionsDb.createSession( + return sessionsDb.createSession( parsed.sessionId, this.provider, parsed.projectPath, @@ -99,8 +99,6 @@ export class GeminiSessionSynchronizer implements IProviderSessionSynchronizer { timestamps.updatedAt, filePath ); - - return true; } /** diff --git a/server/modules/providers/services/session-synchronizer.service.ts b/server/modules/providers/services/session-synchronizer.service.ts index c5afaec0..d0279a86 100644 --- a/server/modules/providers/services/session-synchronizer.service.ts +++ b/server/modules/providers/services/session-synchronizer.service.ts @@ -168,10 +168,14 @@ export const sessionSynchronizerService = { async synchronizeProviderFile( provider: LLMProvider, filePath: string - ): Promise<{ provider: LLMProvider; indexed: boolean }> { + ): Promise<{ provider: LLMProvider; indexed: boolean; sessionId: string | null }> { const resolvedProvider = providerRegistry.resolveProvider(provider); - const indexed = await resolvedProvider.sessionSynchronizer.synchronizeFile(filePath); - return { provider, indexed }; + const sessionId = await resolvedProvider.sessionSynchronizer.synchronizeFile(filePath); + return { + provider, + indexed: Boolean(sessionId), + sessionId, + }; }, /** diff --git a/server/modules/providers/services/sessions-watcher.service.ts b/server/modules/providers/services/sessions-watcher.service.ts index b5eb5c84..911f6e64 100644 --- a/server/modules/providers/services/sessions-watcher.service.ts +++ b/server/modules/providers/services/sessions-watcher.service.ts @@ -6,6 +6,8 @@ import chokidar, { type FSWatcher } from 'chokidar'; import { sessionSynchronizerService } from '@/modules/providers/services/session-synchronizer.service.js'; import type { LLMProvider } from '@/shared/types.js'; +import { getProjectsWithSessions } from '@/modules/projects/index.js'; +import { connectedClients } from '@/index.js'; type WatcherEventType = 'add' | 'change'; @@ -43,6 +45,7 @@ const WATCHER_IGNORED_PATTERNS = [ ]; const watchers: FSWatcher[] = []; +const WS_OPEN_STATE = 1; /** * Filters watcher events to provider-specific session artifact file types. @@ -69,9 +72,31 @@ async function onUpdate( try { const result = await sessionSynchronizerService.synchronizeProviderFile(provider, filePath); + + // Get updated projects list + const updatedProjects = await getProjectsWithSessions(); + + // Notify all connected clients about the project changes + const updateMessage = JSON.stringify({ + type: 'projects_updated', + projects: updatedProjects, + timestamp: new Date().toISOString(), + changeType: eventType, + updatedSessionId: result.sessionId ?? undefined, + watchProvider: provider + }); + + connectedClients.forEach(client => { + if (client.readyState === WS_OPEN_STATE) { + client.send(updateMessage); + } + }); + + console.log(`Session watcher sync complete for provider "${provider}" after ${eventType}`, { filePath, indexed: result.indexed, + sessionId: result.sessionId, }); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/server/shared/interfaces.ts b/server/shared/interfaces.ts index b1f436a6..c5354dda 100644 --- a/server/shared/interfaces.ts +++ b/server/shared/interfaces.ts @@ -88,5 +88,5 @@ export interface IProviderSessionSynchronizer { /** * Parses and upserts one provider artifact file without running a full scan. */ - synchronizeFile(filePath: string): Promise; + synchronizeFile(filePath: string): Promise; } diff --git a/src/hooks/useProjectsState.ts b/src/hooks/useProjectsState.ts index bb8bf6d5..26bdd853 100644 --- a/src/hooks/useProjectsState.ts +++ b/src/hooks/useProjectsState.ts @@ -311,20 +311,12 @@ export function useProjectsState({ const projectsMessage = latestMessage as ProjectsUpdatedMessage; - if (projectsMessage.changedFile && selectedSession && selectedProject) { - const normalized = projectsMessage.changedFile.replace(/\\/g, '/'); - const changedFileParts = normalized.split('/'); + if (projectsMessage.updatedSessionId && selectedSession && selectedProject) { + if (projectsMessage.updatedSessionId === selectedSession.id) { + const isSessionActive = activeSessions.has(selectedSession.id); - if (changedFileParts.length >= 2) { - const filename = changedFileParts[changedFileParts.length - 1]; - const changedSessionId = filename.replace('.jsonl', ''); - - if (changedSessionId === selectedSession.id) { - const isSessionActive = activeSessions.has(selectedSession.id); - - if (!isSessionActive) { - setExternalMessageUpdate((prev) => prev + 1); - } + if (!isSessionActive) { + setExternalMessageUpdate((prev) => prev + 1); } } } diff --git a/src/types/app.ts b/src/types/app.ts index b586b68e..77da1613 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -62,7 +62,7 @@ export interface LoadingProgress { export interface ProjectsUpdatedMessage { type: 'projects_updated'; projects: Project[]; - changedFile?: string; + updatedSessionId?: string; [key: string]: unknown; }