@mrtkrcm/acp-claude-code
Version:
ACP (Agent Client Protocol) bridge for Claude Code
221 lines • 8.86 kB
JavaScript
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