mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-02 02:22:55 +08:00
348 lines
10 KiB
TypeScript
348 lines
10 KiB
TypeScript
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { promises as fsPromises } from 'node:fs';
|
|
|
|
import chokidar, { type FSWatcher } from 'chokidar';
|
|
|
|
import { projectsDb, sessionsDb } from '@/modules/database/index.js';
|
|
import { sessionSynchronizerService } from '@/modules/providers/services/session-synchronizer.service.js';
|
|
import { WS_OPEN_STATE, connectedClients } from '@/modules/websocket/index.js';
|
|
import type { LLMProvider } from '@/shared/types.js';
|
|
import { generateDisplayName } from '@/modules/projects/index.js';
|
|
|
|
type WatcherEventType = 'add' | 'change';
|
|
|
|
const PROVIDER_WATCH_PATHS: Array<{ provider: LLMProvider; rootPath: string }> = [
|
|
{
|
|
provider: 'claude',
|
|
rootPath: path.join(os.homedir(), '.claude', 'projects'),
|
|
},
|
|
{
|
|
provider: 'cursor',
|
|
rootPath: path.join(os.homedir(), '.cursor', 'projects'),
|
|
},
|
|
{
|
|
provider: 'codex',
|
|
rootPath: path.join(os.homedir(), '.codex', 'sessions'),
|
|
},
|
|
// {
|
|
// provider: 'gemini',
|
|
// rootPath: path.join(os.homedir(), '.gemini', 'sessions'),
|
|
// },
|
|
// Keep `sessions/` watcher disabled: Gemini also mirrors artifacts there,
|
|
// which causes duplicate synchronization events.
|
|
{
|
|
provider: 'gemini',
|
|
rootPath: path.join(os.homedir(), '.gemini', 'tmp'),
|
|
},
|
|
{
|
|
provider: 'opencode',
|
|
rootPath: path.join(os.homedir(), '.local', 'share', 'opencode'),
|
|
},
|
|
{
|
|
provider: 'hermes',
|
|
rootPath: path.join(os.homedir(), '.hermes'),
|
|
},
|
|
];
|
|
|
|
const WATCHER_IGNORED_PATTERNS = [
|
|
'**/node_modules/**',
|
|
'**/.git/**',
|
|
'**/dist/**',
|
|
'**/build/**',
|
|
'**/*.tmp',
|
|
'**/*.swp',
|
|
'**/.DS_Store',
|
|
];
|
|
|
|
const PROJECTS_UPDATE_DEBOUNCE_MS = 500;
|
|
const PROJECTS_UPDATE_MAX_WAIT_MS = 2_000;
|
|
|
|
const watchers: FSWatcher[] = [];
|
|
|
|
type PendingWatcherUpdate = {
|
|
providers: Set<LLMProvider>;
|
|
changeTypes: Set<WatcherEventType>;
|
|
/**
|
|
* Provider-native session ids reported by the synchronizers. They are
|
|
* translated back to app-facing session rows at flush time, because the
|
|
* transcript file names on disk only ever contain provider ids.
|
|
*/
|
|
updatedSessionIds: Set<string>;
|
|
};
|
|
|
|
let pendingWatcherUpdate: PendingWatcherUpdate | null = null;
|
|
let pendingWatcherUpdateStartedAt: number | null = null;
|
|
let pendingWatcherFlushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let watcherRefreshInFlight = false;
|
|
let watcherRescheduleAfterRefresh = false;
|
|
|
|
/**
|
|
* Filters watcher events to provider-specific session artifact file types.
|
|
*/
|
|
function isWatcherTargetFile(provider: LLMProvider, filePath: string): boolean {
|
|
if (provider === 'opencode') {
|
|
return path.basename(filePath) === 'opencode.db';
|
|
}
|
|
|
|
if (provider === 'hermes') {
|
|
return path.basename(filePath) === 'state.db';
|
|
}
|
|
|
|
if (provider === 'gemini') {
|
|
return filePath.endsWith('.json') || filePath.endsWith('.jsonl');
|
|
}
|
|
|
|
return filePath.endsWith('.jsonl');
|
|
}
|
|
|
|
function clearPendingWatcherFlushTimer(): void {
|
|
if (pendingWatcherFlushTimer) {
|
|
clearTimeout(pendingWatcherFlushTimer);
|
|
pendingWatcherFlushTimer = null;
|
|
}
|
|
}
|
|
|
|
function schedulePendingWatcherFlush(): void {
|
|
if (!pendingWatcherUpdate) {
|
|
return;
|
|
}
|
|
|
|
const now = Date.now();
|
|
if (pendingWatcherUpdateStartedAt === null) {
|
|
pendingWatcherUpdateStartedAt = now;
|
|
}
|
|
|
|
const elapsed = now - pendingWatcherUpdateStartedAt;
|
|
const remainingMaxWait = Math.max(0, PROJECTS_UPDATE_MAX_WAIT_MS - elapsed);
|
|
const delay = Math.min(PROJECTS_UPDATE_DEBOUNCE_MS, remainingMaxWait);
|
|
|
|
clearPendingWatcherFlushTimer();
|
|
pendingWatcherFlushTimer = setTimeout(() => {
|
|
void flushPendingWatcherUpdate();
|
|
}, delay);
|
|
}
|
|
|
|
function queuePendingWatcherUpdate(
|
|
eventType: WatcherEventType,
|
|
provider: LLMProvider,
|
|
updatedSessionId: string | null
|
|
): void {
|
|
if (!pendingWatcherUpdate) {
|
|
pendingWatcherUpdate = {
|
|
providers: new Set<LLMProvider>(),
|
|
changeTypes: new Set<WatcherEventType>(),
|
|
updatedSessionIds: new Set<string>(),
|
|
};
|
|
}
|
|
|
|
pendingWatcherUpdate.providers.add(provider);
|
|
pendingWatcherUpdate.changeTypes.add(eventType);
|
|
if (updatedSessionId) {
|
|
pendingWatcherUpdate.updatedSessionIds.add(updatedSessionId);
|
|
}
|
|
|
|
schedulePendingWatcherFlush();
|
|
}
|
|
|
|
/**
|
|
* Builds one `session_upserted` delta event for a provider-native session id.
|
|
*
|
|
* The event carries everything a sidebar needs to upsert the session in place
|
|
* (session summary plus owning-project metadata), so clients never need a full
|
|
* project-list refetch when a transcript file changes on disk. Returns `null`
|
|
* when the id cannot be resolved to an indexed session row.
|
|
*/
|
|
async function buildSessionUpsertedEvent(updatedProviderSessionId: string): Promise<string | null> {
|
|
const row = sessionsDb.getSessionByProviderSessionId(updatedProviderSessionId)
|
|
?? sessionsDb.getSessionById(updatedProviderSessionId);
|
|
if (!row || row.isArchived) {
|
|
return null;
|
|
}
|
|
|
|
const projectPath = row.project_path;
|
|
const project = projectPath ? projectsDb.getProjectPath(projectPath) : null;
|
|
const displayName = project?.custom_project_name?.trim()
|
|
? project.custom_project_name
|
|
: await generateDisplayName(path.basename(projectPath ?? '') || (projectPath ?? ''), projectPath);
|
|
|
|
return JSON.stringify({
|
|
kind: 'session_upserted',
|
|
sessionId: row.session_id,
|
|
provider: row.provider,
|
|
session: {
|
|
id: row.session_id,
|
|
summary: row.custom_name || '',
|
|
messageCount: 0,
|
|
lastActivity: row.updated_at ?? row.created_at ?? new Date().toISOString(),
|
|
},
|
|
project: project
|
|
? {
|
|
projectId: project.project_id,
|
|
path: project.project_path,
|
|
fullPath: project.project_path,
|
|
displayName,
|
|
isStarred: Boolean(project.isStarred),
|
|
}
|
|
: null,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
async function flushPendingWatcherUpdate(): Promise<void> {
|
|
clearPendingWatcherFlushTimer();
|
|
|
|
if (!pendingWatcherUpdate) {
|
|
return;
|
|
}
|
|
|
|
if (watcherRefreshInFlight) {
|
|
watcherRescheduleAfterRefresh = true;
|
|
return;
|
|
}
|
|
|
|
const queuedUpdate = pendingWatcherUpdate;
|
|
pendingWatcherUpdate = null;
|
|
pendingWatcherUpdateStartedAt = null;
|
|
watcherRefreshInFlight = true;
|
|
|
|
try {
|
|
// Per-session deltas instead of full project snapshots: an upsert of one
|
|
// session can never clobber unrelated client state, so the frontend needs
|
|
// no "suppress updates while a run is active" protection logic.
|
|
const events: string[] = [];
|
|
for (const updatedSessionId of queuedUpdate.updatedSessionIds) {
|
|
const event = await buildSessionUpsertedEvent(updatedSessionId);
|
|
if (event) {
|
|
events.push(event);
|
|
}
|
|
}
|
|
|
|
if (events.length > 0) {
|
|
connectedClients.forEach(client => {
|
|
if (client.readyState === WS_OPEN_STATE) {
|
|
for (const event of events) {
|
|
client.send(event);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.error('Session watcher refresh failed while broadcasting session_upserted', { error: message });
|
|
} finally {
|
|
watcherRefreshInFlight = false;
|
|
|
|
if (pendingWatcherUpdate || watcherRescheduleAfterRefresh) {
|
|
watcherRescheduleAfterRefresh = false;
|
|
schedulePendingWatcherFlush();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles file watcher updates and triggers provider file-level synchronization.
|
|
*/
|
|
async function onUpdate(
|
|
eventType: WatcherEventType,
|
|
filePath: string,
|
|
provider: LLMProvider
|
|
): Promise<void> {
|
|
if (!isWatcherTargetFile(provider, filePath)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await sessionSynchronizerService.synchronizeProviderFile(provider, filePath);
|
|
if (!result.indexed) {
|
|
return;
|
|
}
|
|
|
|
console.log(`Session synchronization triggered by ${eventType} event for provider "${provider}"`, {
|
|
filePath,
|
|
sessionId: result.sessionId,
|
|
});
|
|
queuePendingWatcherUpdate(eventType, provider, result.sessionId);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.error(`Session watcher sync failed for provider "${provider}"`, {
|
|
eventType,
|
|
filePath,
|
|
error: message,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Starts provider filesystem watchers and performs initial DB synchronization.
|
|
*/
|
|
export async function initializeSessionsWatcher(): Promise<void> {
|
|
console.log('Setting up session watchers');
|
|
|
|
const initialSync = await sessionSynchronizerService.synchronizeSessions();
|
|
console.log('Initial session synchronization complete', {
|
|
processedByProvider: initialSync.processedByProvider,
|
|
failures: initialSync.failures,
|
|
});
|
|
|
|
for (const { provider, rootPath } of PROVIDER_WATCH_PATHS) {
|
|
try {
|
|
await fsPromises.mkdir(rootPath, { recursive: true });
|
|
|
|
const watcher = chokidar.watch(rootPath, {
|
|
ignored: WATCHER_IGNORED_PATTERNS,
|
|
persistent: true,
|
|
ignoreInitial: true,
|
|
followSymlinks: false,
|
|
depth: 6,
|
|
usePolling: true,
|
|
interval: 6_000,
|
|
binaryInterval: 6_000,
|
|
});
|
|
|
|
watcher
|
|
.on('add', (filePath: string) => {
|
|
void onUpdate('add', filePath, provider);
|
|
})
|
|
.on('change', (filePath: string) => {
|
|
void onUpdate('change', filePath, provider);
|
|
})
|
|
.on('error', (error: unknown) => {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.error(`Session watcher error for provider "${provider}"`, { error: message });
|
|
});
|
|
|
|
watchers.push(watcher);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.error(`Failed to initialize session watcher for provider "${provider}"`, {
|
|
rootPath,
|
|
error: message,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stops all active provider session watchers.
|
|
*/
|
|
export async function closeSessionsWatcher(): Promise<void> {
|
|
clearPendingWatcherFlushTimer();
|
|
|
|
await Promise.all(
|
|
watchers.map(async (watcher) => {
|
|
try {
|
|
await watcher.close();
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.error('Failed to close session watcher', { error: message });
|
|
}
|
|
})
|
|
);
|
|
watchers.length = 0;
|
|
pendingWatcherUpdate = null;
|
|
pendingWatcherUpdateStartedAt = null;
|
|
watcherRefreshInFlight = false;
|
|
watcherRescheduleAfterRefresh = false;
|
|
}
|