@nanocollective/nanocoder
Version:
A local-first CLI coding agent that brings the power of agentic coding tools like Claude Code and Gemini CLI to local models or controlled APIs like OpenRouter
361 lines • 14.3 kB
JavaScript
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { getAppConfig } from '../config/index.js';
import { getAppDataPath } from '../config/paths.js';
/** UUID v4 pattern for session ID validation (prevents path traversal) */
const SESSION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
function isValidSessionId(id) {
return SESSION_ID_PATTERN.test(id);
}
function isRecord(obj) {
return typeof obj === 'object' && obj !== null && !Array.isArray(obj);
}
function isValidSessionMetadata(obj) {
if (!isRecord(obj))
return false;
return (typeof obj.id === 'string' &&
typeof obj.title === 'string' &&
typeof obj.createdAt === 'string' &&
typeof obj.lastAccessedAt === 'string' &&
typeof obj.messageCount === 'number' &&
typeof obj.provider === 'string' &&
typeof obj.model === 'string' &&
typeof obj.workingDirectory === 'string');
}
function isValidSession(obj) {
if (!isRecord(obj))
return false;
return isValidSessionMetadata(obj) && Array.isArray(obj.messages);
}
/** Write data to a temp file then atomically rename into place. */
async function atomicWriteFile(filePath, data, mode) {
const tmpPath = `${filePath}.${crypto.randomUUID()}.tmp`;
try {
await fs.writeFile(tmpPath, data, { mode });
await fs.rename(tmpPath, filePath);
}
catch (error) {
// Clean up temp file on failure
try {
await fs.unlink(tmpPath);
}
catch (_cleanupError) {
// Ignore cleanup errors
}
throw error;
}
}
function isEnoent(error) {
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
}
export class SessionManager {
sessionsDir;
sessionsIndexPath;
initialized = false;
/** Serializes read-modify-write of sessions.json to prevent lost updates from concurrent autosave/resume. */
indexWriteLock = Promise.resolve();
/** Optional explicit directory override (used by tests). */
overrideDir;
constructor(sessionsDir) {
this.overrideDir = sessionsDir;
}
resolveSessionsDir() {
if (this.overrideDir) {
this.sessionsDir = this.overrideDir;
this.sessionsIndexPath = path.join(this.sessionsDir, 'sessions.json');
return;
}
const config = getAppConfig();
const sessionConfig = config.sessions;
const configuredDir = sessionConfig?.directory;
if (configuredDir) {
// User explicitly configured a directory — expand tilde
let sessionDirPath = configuredDir;
if (sessionDirPath === '~') {
sessionDirPath = path.resolve(process.env.HOME || process.env.USERPROFILE || '.');
}
else if (sessionDirPath.startsWith('~/')) {
sessionDirPath = path.join(process.env.HOME || process.env.USERPROFILE || '.', sessionDirPath.slice(2));
}
this.sessionsDir = sessionDirPath;
}
else {
// Default: use platform-aware app data path
this.sessionsDir = path.join(getAppDataPath(), 'sessions');
}
this.sessionsIndexPath = path.join(this.sessionsDir, 'sessions.json');
}
async initialize() {
if (this.initialized)
return;
this.resolveSessionsDir();
try {
await fs.mkdir(this.sessionsDir, { recursive: true, mode: 0o700 });
await fs.chmod(this.sessionsDir, 0o700);
try {
await fs.access(this.sessionsIndexPath);
}
catch (_error) {
await atomicWriteFile(this.sessionsIndexPath, JSON.stringify([]), 0o600);
}
this.initialized = true;
// Perform cleanup of old sessions if configured
await this.cleanupOldSessions();
}
catch (error) {
console.error('Failed to initialize session directory:', error);
throw error;
}
}
async createSession(sessionData) {
const sessionId = crypto.randomUUID();
const timestamp = new Date().toISOString();
const session = {
id: sessionId,
title: sessionData.title,
createdAt: timestamp,
lastAccessedAt: timestamp,
messageCount: sessionData.messageCount,
provider: sessionData.provider,
model: sessionData.model,
workingDirectory: sessionData.workingDirectory,
messages: sessionData.messages,
};
await this.saveSession(session);
await this.enforceSessionLimits();
return session;
}
async saveSession(session) {
if (!isValidSessionId(session.id)) {
throw new Error(`Invalid session ID: ${session.id}`);
}
// File write and index update happen together under the lock
// to prevent orphaned files if the process dies between them.
await this.withIndexLock(async () => {
const sessionFilePath = path.join(this.sessionsDir, `${session.id}.json`);
// Write session file atomically
await atomicWriteFile(sessionFilePath, JSON.stringify(session, null, 2), 0o600);
// Update index
const sessions = await this.readIndex();
const existingSessionIndex = sessions.findIndex(s => s.id === session.id);
const sessionMetadata = {
id: session.id,
title: session.title,
createdAt: session.createdAt,
lastAccessedAt: session.lastAccessedAt,
messageCount: session.messageCount,
provider: session.provider,
model: session.model,
workingDirectory: session.workingDirectory,
};
if (existingSessionIndex >= 0) {
sessions[existingSessionIndex] = sessionMetadata;
}
else {
sessions.push(sessionMetadata);
}
await atomicWriteFile(this.sessionsIndexPath, JSON.stringify(sessions, null, 2), 0o600);
});
}
/** Read the index file (internal helper — not locked). */
async readIndex() {
try {
const data = await fs.readFile(this.sessionsIndexPath, 'utf-8');
const parsed = JSON.parse(data);
if (!Array.isArray(parsed))
return this.rebuildIndex();
const valid = parsed.filter(isValidSessionMetadata);
if (valid.length === 0 && parsed.length > 0) {
// Index had entries but none were valid — try recovery
return this.rebuildIndex();
}
return valid;
}
catch (_error) {
return this.rebuildIndex();
}
}
/**
* Rebuild the index by scanning session files on disk.
* Called when the index is missing, corrupt, or empty despite files existing.
*/
async rebuildIndex() {
try {
const entries = await fs.readdir(this.sessionsDir);
const sessionFiles = entries.filter(e => e.endsWith('.json') && e !== 'sessions.json');
if (sessionFiles.length === 0)
return [];
const metadata = [];
for (const file of sessionFiles) {
try {
const filePath = path.join(this.sessionsDir, file);
const data = await fs.readFile(filePath, 'utf-8');
const parsed = JSON.parse(data);
if (isValidSession(parsed)) {
metadata.push({
id: parsed.id,
title: parsed.title,
createdAt: parsed.createdAt,
lastAccessedAt: parsed.lastAccessedAt,
messageCount: parsed.messageCount,
provider: parsed.provider,
model: parsed.model,
workingDirectory: parsed.workingDirectory,
});
}
}
catch (_fileError) {
// Skip unreadable files
}
}
// Persist rebuilt index
if (metadata.length > 0) {
await atomicWriteFile(this.sessionsIndexPath, JSON.stringify(metadata, null, 2), 0o600);
}
return metadata;
}
catch (_error) {
return [];
}
}
async listSessions(options) {
const sessions = await this.readIndex();
if (options?.workingDirectory) {
const normalized = path.normalize(options.workingDirectory);
return sessions.filter(s => path.normalize(s.workingDirectory) === normalized);
}
return sessions;
}
/** Read a session from disk without updating lastAccessedAt (no write). */
async readSession(sessionId) {
if (!isValidSessionId(sessionId))
return null;
try {
const sessionFilePath = path.join(this.sessionsDir, `${sessionId}.json`);
const data = await fs.readFile(sessionFilePath, 'utf-8');
const parsed = JSON.parse(data);
if (!isValidSession(parsed))
return null;
return parsed;
}
catch (_error) {
return null;
}
}
async loadSession(sessionId) {
const session = await this.readSession(sessionId);
if (!session)
return null;
// Update last accessed time
const updatedSession = {
...session,
lastAccessedAt: new Date().toISOString(),
};
await this.saveSession(updatedSession);
return updatedSession;
}
async deleteSession(sessionId) {
if (!isValidSessionId(sessionId)) {
throw new Error(`Invalid session ID: ${sessionId}`);
}
const sessionFilePath = path.join(this.sessionsDir, `${sessionId}.json`);
// Delete file — only ignore ENOENT
try {
await fs.unlink(sessionFilePath);
}
catch (error) {
if (!isEnoent(error)) {
throw error;
}
}
// Update index — let errors propagate
await this.withIndexLock(async () => {
const sessions = await this.readIndex();
const filteredSessions = sessions.filter(s => s.id !== sessionId);
await atomicWriteFile(this.sessionsIndexPath, JSON.stringify(filteredSessions, null, 2), 0o600);
});
}
getSessionDirectory() {
return this.sessionsDir;
}
/** Run a read-modify-write on the index one at a time to avoid lost updates. */
async withIndexLock(fn) {
const prev = this.indexWriteLock;
let release;
this.indexWriteLock = new Promise(r => {
release = r;
});
await prev;
try {
return await fn();
}
finally {
release();
}
}
async enforceSessionLimits() {
const config = getAppConfig();
const sessionConfig = config.sessions;
const maxSessions = sessionConfig?.maxSessions || 100;
await this.withIndexLock(async () => {
const sessions = await this.readIndex();
if (sessions.length <= maxSessions)
return;
// Sort by lastAccessedAt ascending (oldest first)
const sortedSessions = sessions.sort((a, b) => new Date(a.lastAccessedAt).getTime() -
new Date(b.lastAccessedAt).getTime());
const sessionsToDelete = sortedSessions.slice(0, sessions.length - maxSessions);
const idsToDelete = new Set(sessionsToDelete.map(s => s.id));
// Rewrite index first so sessions are deregistered even if
// file deletion partially fails.
const remaining = sortedSessions.filter(s => !idsToDelete.has(s.id));
await atomicWriteFile(this.sessionsIndexPath, JSON.stringify(remaining, null, 2), 0o600);
// Then delete files — only ignore ENOENT
for (const session of sessionsToDelete) {
const filePath = path.join(this.sessionsDir, `${session.id}.json`);
try {
await fs.unlink(filePath);
}
catch (error) {
if (!isEnoent(error)) {
throw error;
}
}
}
});
}
async cleanupOldSessions() {
const config = getAppConfig();
const sessionConfig = config.sessions;
const retentionDays = sessionConfig?.retentionDays || 30;
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
await this.withIndexLock(async () => {
const sessions = await this.readIndex();
const oldSessions = sessions.filter(session => new Date(session.lastAccessedAt) < cutoffDate);
if (oldSessions.length === 0)
return;
const idsToDelete = new Set(oldSessions.map(s => s.id));
// Rewrite index first so sessions are deregistered even if
// file deletion partially fails.
const remaining = sessions.filter(s => !idsToDelete.has(s.id));
await atomicWriteFile(this.sessionsIndexPath, JSON.stringify(remaining, null, 2), 0o600);
// Then delete files — only ignore ENOENT
for (const session of oldSessions) {
const filePath = path.join(this.sessionsDir, `${session.id}.json`);
try {
await fs.unlink(filePath);
}
catch (error) {
if (!isEnoent(error)) {
throw error;
}
}
}
});
}
}
// Export singleton instance — config is deferred to initialize()
export const sessionManager = new SessionManager();
//# sourceMappingURL=session-manager.js.map