UNPKG

@mrtkrcm/acp-claude-code

Version:

ACP (Agent Client Protocol) bridge for Claude Code

221 lines 8.86 kB
import { writeFile, readFile, mkdir, readdir, unlink, stat, rename } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { resolve } from 'node:path'; import { homedir } from 'node:os'; export class SessionPersistenceManager { baseDir; maxSessions; maxAge; cleanupRegistered = false; writeQueue = new Map(); writeTimer; BATCH_WRITE_DELAY = 1000; // 1 second batching constructor(config = {}) { this.baseDir = config.baseDir || process.env.ACP_SESSIONS_DIR || resolve(homedir(), '.acp-claude-code', 'sessions'); this.maxSessions = config.maxSessions || 100; this.maxAge = config.maxAge || 7 * 24 * 60 * 60 * 1000; this.cleanupTempFiles().catch(() => { }); this.registerCleanupHandlers(); } async ensureDirectoryExists() { if (!existsSync(this.baseDir)) { await mkdir(this.baseDir, { recursive: true }); } } async saveSession(sessionData) { // Queue write for batching instead of immediate write this.writeQueue.set(sessionData.sessionId, sessionData); if (!this.writeTimer) { this.writeTimer = setTimeout(() => { this.flushWriteQueue().catch(console.error); }, this.BATCH_WRITE_DELAY); } } async flushWriteQueue() { if (this.writeQueue.size === 0) return; await this.ensureDirectoryExists(); const writePromises = []; // Process all queued writes in parallel for (const [sessionId, sessionData] of this.writeQueue.entries()) { const sessionPath = resolve(this.baseDir, `${sessionId}.json`); const tempPath = `${sessionPath}.tmp.${Date.now()}.${process.pid}`; const writePromise = (async () => { try { await writeFile(tempPath, JSON.stringify(sessionData, null, 2)); await rename(tempPath, sessionPath); } catch (error) { try { await unlink(tempPath); } catch { /* ignore cleanup errors */ } throw error; } })(); writePromises.push(writePromise); } this.writeQueue.clear(); this.writeTimer = undefined; // Wait for all writes to complete await Promise.allSettled(writePromises); } async loadSession(sessionId) { try { const sessionPath = resolve(this.baseDir, `${sessionId}.json`); const content = await readFile(sessionPath, 'utf-8'); const session = JSON.parse(content); session.lastAccessed = new Date().toISOString(); await this.saveSession(session); return session; } catch { return null; } } async cleanupInactiveSessions(maxAge) { const ageThreshold = maxAge || this.maxAge; let removed = 0; try { const files = await readdir(this.baseDir); const sessionFiles = files.filter(f => f.endsWith('.json') && !f.includes('.tmp.')); const now = Date.now(); for (const file of sessionFiles) { try { const filePath = resolve(this.baseDir, file); // Read session content to get lastAccessed timestamp const content = await readFile(filePath, 'utf-8'); const session = JSON.parse(content); // Use lastAccessed timestamp from session data, not file mtime const lastAccessed = new Date(session.lastAccessed).getTime(); if (now - lastAccessed > ageThreshold) { await unlink(filePath); removed++; } } catch { /* ignore file errors and parsing errors */ } } } catch { /* ignore directory errors */ } return removed; } async cleanup() { let removed = 0, errors = 0; try { const files = await readdir(this.baseDir); const sessionFiles = files.filter(f => f.endsWith('.json') && !f.includes('.tmp.')); const now = Date.now(); for (const file of sessionFiles) { try { const filePath = resolve(this.baseDir, file); const stats = await stat(filePath); if (now - stats.mtime.getTime() > this.maxAge) { await unlink(filePath); removed++; } } catch { errors++; } } if (sessionFiles.length - removed > this.maxSessions) { const excess = sessionFiles.length - removed - this.maxSessions; const sortedFiles = await Promise.all(sessionFiles.map(async (f) => { try { return { file: f, mtime: (await stat(resolve(this.baseDir, f))).mtime.getTime() }; } catch { return { file: f, mtime: 0 }; } })); sortedFiles.sort((a, b) => a.mtime - b.mtime); for (let i = 0; i < excess; i++) { try { await unlink(resolve(this.baseDir, sortedFiles[i].file)); removed++; } catch { errors++; } } } } catch { errors++; } return { removed, errors }; } async cleanupTempFiles() { try { const files = await readdir(this.baseDir); const tempFiles = files.filter(file => file.includes('.tmp.')); let cleanedCount = 0; for (const tempFile of tempFiles) { try { const tempPath = resolve(this.baseDir, tempFile); const stats = await stat(tempPath); if (Date.now() - stats.mtime.getTime() > 60 * 60 * 1000) { await unlink(tempPath); cleanedCount++; } } catch { /* ignore file stat/unlink errors */ } } if (cleanedCount > 0) console.log(`Cleaned up ${cleanedCount} stale temp files`); } catch { /* ignore directory read errors */ } } async getAllSessions() { try { await this.ensureDirectoryExists(); const files = await readdir(this.baseDir); const sessionFiles = files.filter(f => f.endsWith('.json') && !f.includes('.tmp.')); const sessions = []; for (const file of sessionFiles) { try { const sessionPath = resolve(this.baseDir, file); const data = await readFile(sessionPath, 'utf-8'); const sessionData = JSON.parse(data); sessions.push(sessionData); } catch (error) { // Skip corrupted session files but log the issue console.warn(`Failed to load session file ${file}:`, error instanceof Error ? error.message : 'Unknown error'); } } return sessions; } catch (error) { throw new Error(`Failed to get all sessions: ${error instanceof Error ? error.message : 'Unknown error'}`); } } registerCleanupHandlers() { if (this.cleanupRegistered) return; this.cleanupRegistered = true; // Increase max listeners to prevent warnings in test environments const currentMaxListeners = process.getMaxListeners(); if (currentMaxListeners < 20) { process.setMaxListeners(20); } const cleanup = () => { this.cleanupTempFiles().catch(() => { }); }; process.once('exit', cleanup); process.once('SIGINT', cleanup); process.once('SIGTERM', cleanup); process.once('uncaughtException', cleanup); process.once('unhandledRejection', cleanup); } } let defaultManager = null; export function getDefaultPersistenceManager() { if (!defaultManager) { const config = process.env.ACP_SESSIONS_DIR ? { baseDir: process.env.ACP_SESSIONS_DIR } : {}; defaultManager = new SessionPersistenceManager(config); } return defaultManager; } export function resetDefaultPersistenceManager() { defaultManager = null; } //# sourceMappingURL=session-persistence.js.map