Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 136 additions & 6 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ class ClaudeCodeWebServer {
this.keyFile = options.key;
this.folderMode = options.folderMode !== false; // Default to true
this.selectedWorkingDir = null;
this.baseFolder = process.cwd(); // The folder where the app runs from
this.baseFolder = options.cwd || process.cwd(); // The folder where the app runs from (or specified via --cwd)
// Session duration in hours (default to 5 hours from first message)
this.sessionDurationHours = parseFloat(process.env.CLAUDE_SESSION_HOURS || options.sessionHours || 5);

Expand All @@ -42,6 +42,9 @@ class ClaudeCodeWebServer {
customCostLimit: parseFloat(process.env.CLAUDE_COST_LIMIT || options.customCostLimit || 50.00)
});
this.autoSaveInterval = null;
this.sessionCleanupInterval = null;
this.wsCleanupInterval = null;
this.memoryMonitorInterval = null;
this.startTime = Date.now(); // Track server start time
this.isShuttingDown = false; // Flag to prevent duplicate shutdown
// Commands dropdown removed
Expand All @@ -55,6 +58,9 @@ class ClaudeCodeWebServer {
this.setupExpress();
this.loadPersistedSessions();
this.setupAutoSave();
this.setupSessionCleanup();
this.setupWebSocketCleanup();
this.setupMemoryMonitoring();
}

async loadPersistedSessions() {
Expand All @@ -74,13 +80,115 @@ class ClaudeCodeWebServer {
this.autoSaveInterval = setInterval(() => {
this.saveSessionsToDisk();
}, 30000);

// Also save on process exit
process.on('SIGINT', () => this.handleShutdown());
process.on('SIGTERM', () => this.handleShutdown());
process.on('beforeExit', () => this.saveSessionsToDisk());
}


setupSessionCleanup() {
// Clean up inactive sessions every 5 minutes
this.sessionCleanupInterval = setInterval(() => {
this.cleanupInactiveSessions();
}, 5 * 60 * 1000);
}

cleanupInactiveSessions() {
const now = Date.now();
const maxInactiveMs = 24 * 60 * 60 * 1000; // 24 hours
let cleanedCount = 0;

for (const [sessionId, session] of this.claudeSessions.entries()) {
const lastActivity = session.lastActivity instanceof Date
? session.lastActivity.getTime()
: new Date(session.lastActivity).getTime();

const isInactive = (now - lastActivity) > maxInactiveMs;
const hasNoConnections = session.connections.size === 0;
const isNotActive = !session.active;

// Remove session if it's been inactive for 24+ hours, has no connections, and no active agent
if (isInactive && hasNoConnections && isNotActive) {
// Stop any running processes just in case
if (session.agent === 'codex') {
this.codexBridge.stopSession(sessionId);
} else if (session.agent === 'agent') {
this.agentBridge.stopSession(sessionId);
} else if (session.agent === 'claude') {
this.claudeBridge.stopSession(sessionId);
}

this.claudeSessions.delete(sessionId);
cleanedCount++;

if (this.dev) {
console.log(`Cleaned up inactive session: ${sessionId} (inactive for ${Math.round((now - lastActivity) / (1000 * 60 * 60))}h)`);
}
}
}

if (cleanedCount > 0) {
this.saveSessionsToDisk();
if (this.dev) {
console.log(`Session cleanup: removed ${cleanedCount} inactive sessions, ${this.claudeSessions.size} remaining`);
}
}
}

setupWebSocketCleanup() {
// Clean up orphaned WebSocket connections every 30 seconds
this.wsCleanupInterval = setInterval(() => {
this.cleanupOrphanedWebSockets();
}, 30 * 1000);
}

cleanupOrphanedWebSockets() {
const now = Date.now();
const maxSilenceMs = 60 * 1000; // 60 seconds without pong
let cleanedCount = 0;

for (const [wsId, wsInfo] of this.webSocketConnections.entries()) {
// Check if we have a lastPong timestamp
if (wsInfo.lastPong) {
const silenceMs = now - wsInfo.lastPong;

if (silenceMs > maxSilenceMs) {
if (this.dev) {
console.log(`Terminating orphaned WebSocket ${wsId} (no pong for ${Math.round(silenceMs / 1000)}s)`);
}

// Terminate the connection
if (wsInfo.ws && wsInfo.ws.readyState !== WebSocket.CLOSED) {
wsInfo.ws.terminate();
}

this.cleanupWebSocketConnection(wsId);
cleanedCount++;
}
}
}

if (cleanedCount > 0 && this.dev) {
console.log(`WebSocket cleanup: removed ${cleanedCount} orphaned connections, ${this.webSocketConnections.size} remaining`);
}
}

setupMemoryMonitoring() {
// Only monitor memory in dev mode
if (!this.dev) return;

// Log memory usage every 5 minutes in dev mode
this.memoryMonitorInterval = setInterval(() => {
const memUsage = process.memoryUsage();
const heapUsedMB = Math.round(memUsage.heapUsed / 1024 / 1024);
const heapTotalMB = Math.round(memUsage.heapTotal / 1024 / 1024);
const rssMB = Math.round(memUsage.rss / 1024 / 1024);

console.log(`[Memory] Heap: ${heapUsedMB}/${heapTotalMB} MB | RSS: ${rssMB} MB | Sessions: ${this.claudeSessions.size} | WS: ${this.webSocketConnections.size}`);
}, 5 * 60 * 1000);
}

async saveSessionsToDisk() {
if (this.claudeSessions.size > 0) {
await this.sessionStore.saveSessions(this.claudeSessions);
Expand Down Expand Up @@ -596,7 +704,8 @@ class ClaudeCodeWebServer {
id: wsId,
ws,
claudeSessionId: null,
created: new Date()
created: new Date(),
lastPong: Date.now() // Track last heartbeat response for orphan detection
};
this.webSocketConnections.set(wsId, wsInfo);

Expand Down Expand Up @@ -741,6 +850,7 @@ class ClaudeCodeWebServer {
break;

case 'ping':
wsInfo.lastPong = Date.now(); // Update heartbeat timestamp
this.sendToWebSocket(wsInfo.ws, { type: 'pong' });
break;

Expand Down Expand Up @@ -1191,12 +1301,32 @@ class ClaudeCodeWebServer {
close() {
// Save sessions before closing
this.saveSessionsToDisk();

// Clear auto-save interval
if (this.autoSaveInterval) {
clearInterval(this.autoSaveInterval);
}


// Clear session cleanup interval
if (this.sessionCleanupInterval) {
clearInterval(this.sessionCleanupInterval);
}

// Clear WebSocket cleanup interval
if (this.wsCleanupInterval) {
clearInterval(this.wsCleanupInterval);
}

// Clear memory monitoring interval
if (this.memoryMonitorInterval) {
clearInterval(this.memoryMonitorInterval);
}

// Destroy usage analytics (clears its internal intervals)
if (this.usageAnalytics && this.usageAnalytics.destroy) {
this.usageAnalytics.destroy();
}

if (this.wss) {
this.wss.close();
}
Expand Down
36 changes: 30 additions & 6 deletions src/usage-analytics.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,20 +63,22 @@ class UsageAnalytics extends EventEmitter {
this.activeSessions = new Map(); // sessionId -> session data
this.sessionHistory = [];
this.rollingWindows = new Map(); // Track multiple overlapping windows

// Usage data
this.recentUsage = []; // Array of {timestamp, tokens, cost, model}
this.historicalData = [];
this.p90Limit = null;

// Burn rate tracking
this.burnRateHistory = [];
this.currentBurnRate = 0;
this.velocityTrend = 'stable'; // 'increasing', 'decreasing', 'stable'

// Predictions
this.depletionTime = null;
this.depletionConfidence = 0;

// Auto-cleanup every hour
this.cleanupInterval = setInterval(() => this.cleanup(), 60 * 60 * 1000);
}

/**
Expand Down Expand Up @@ -476,18 +478,40 @@ class UsageAnalytics extends EventEmitter {
*/
cleanup() {
const now = new Date();

// Remove expired sessions
for (const [id, session] of this.activeSessions) {
if (session.endTime < now) {
this.sessionHistory.push(session);
this.activeSessions.delete(id);
}
}

// Keep only last 24 hours of history
const cutoff = new Date(now - 24 * 60 * 60 * 1000);
this.sessionHistory = this.sessionHistory.filter(s => s.endTime > cutoff);

// Limit sessionHistory array size to prevent unbounded growth
if (this.sessionHistory.length > 100) {
this.sessionHistory = this.sessionHistory.slice(-100);
}
}

/**
* Destroy and clean up resources
*/
destroy() {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
}

// Clear all data structures
this.activeSessions.clear();
this.sessionHistory = [];
this.rollingWindows.clear();
this.recentUsage = [];
this.burnRateHistory = [];
}
}

Expand Down
39 changes: 35 additions & 4 deletions src/usage-reader.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ class UsageReader {
this.cacheTimeout = 5000; // Cache for 5 seconds for more real-time updates
this.sessionDurationHours = sessionDurationHours; // Default 5 hours from first message
this.overlappingSessions = []; // Track overlapping sessions

// Caching for expensive methods to prevent repeated disk reads
this.currentSessionStatsCache = null;
this.currentSessionStatsCacheTime = null;
this.currentSessionStatsCacheTimeout = 5000; // 5 second cache

this.overlappingSessionsCache = null;
this.overlappingSessionsCacheTime = null;
this.overlappingSessionsCacheTimeout = 30000; // 30 second cache
}

/**
Expand Down Expand Up @@ -84,8 +93,14 @@ class UsageReader {
}

async getCurrentSessionStats() {
// Use cache if fresh
if (this.currentSessionStatsCache &&
this.currentSessionStatsCacheTime &&
(Date.now() - this.currentSessionStatsCacheTime < this.currentSessionStatsCacheTimeout)) {
return this.currentSessionStatsCache;
}

try {

// Use new session logic based on daily boundaries and cascading 5-hour sessions
const currentSession = await this.getCurrentSession();

Expand Down Expand Up @@ -159,7 +174,11 @@ class UsageReader {
stats.cacheTokens = stats.cacheCreationTokens + stats.cacheReadTokens;
// Total tokens only includes input and output (matching claude-monitor behavior)
stats.totalTokens = stats.inputTokens + stats.outputTokens;


// Cache the result
this.currentSessionStatsCache = stats;
this.currentSessionStatsCacheTime = Date.now();

return stats;
} catch (error) {
console.error('Error reading current session stats:', error);
Expand Down Expand Up @@ -638,12 +657,19 @@ class UsageReader {

// Detect overlapping sessions within rolling windows
async detectOverlappingSessions() {
// Use cache if fresh
if (this.overlappingSessionsCache &&
this.overlappingSessionsCacheTime &&
(Date.now() - this.overlappingSessionsCacheTime < this.overlappingSessionsCacheTimeout)) {
return this.overlappingSessionsCache;
}

try {
const now = new Date();
const lookbackHours = this.sessionDurationHours * 2; // Look back twice the session duration
const cutoff = new Date(now - lookbackHours * 60 * 60 * 1000);
const entries = await this.readAllEntries(cutoff);

if (entries.length === 0) return [];

// Group entries into sessions based on time gaps
Expand Down Expand Up @@ -707,13 +733,18 @@ class UsageReader {
}

this.overlappingSessions = overlapping;

// Cache the result
this.overlappingSessionsCache = sessions;
this.overlappingSessionsCacheTime = Date.now();

return sessions;
} catch (error) {
console.error('Error detecting overlapping sessions:', error);
return [];
}
}

// Generate a session ID from timestamp
generateSessionId(timestamp) {
return `session_${new Date(timestamp).getTime()}`;
Expand Down