diff --git a/src/image-watcher.ts b/src/image-watcher.ts index f57edd9d..eb5e4443 100644 --- a/src/image-watcher.ts +++ b/src/image-watcher.ts @@ -87,6 +87,7 @@ export class ImageWatcher extends EventEmitter { constructor() { super(); + this.setMaxListeners(50); } // ========== Public API ========== diff --git a/src/session.ts b/src/session.ts index 52bf0745..839814de 100644 --- a/src/session.ts +++ b/src/session.ts @@ -369,6 +369,7 @@ export class Session extends EventEmitter { niceConfig?: NiceConfig; // Nice prioritying configuration }) { super(); + this.setMaxListeners(25); // Default error handler prevents unhandled 'error' events from crashing the process. // Server attaches its own handler after construction — this is a safety net for the gap. diff --git a/src/state-store.ts b/src/state-store.ts index be786691..e87073d2 100644 --- a/src/state-store.ts +++ b/src/state-store.ts @@ -64,6 +64,9 @@ export class StateStore { private consecutiveSaveFailures: number = 0; private circuitBreakerOpen: boolean = false; + // Guard against concurrent saveNowAsync() calls (debounce can race with in-flight write) + private _saveInFlight: Promise | null = null; + constructor(filePath?: string) { this.filePath = filePath || join(homedir(), '.claudeman', 'state.json'); this.ralphStatePath = this.filePath.replace('.json', '-inner.json'); @@ -130,8 +133,25 @@ export class StateStore { * Async version of saveNow — used by the debounced save() path. * Uses non-blocking fs.promises to avoid blocking the event loop during * the debounced write cycle. For synchronous shutdown flush, use saveNow(). + * + * Guards against concurrent execution: if a save is already in flight, + * waits for it to complete then re-checks dirty flag before starting another. */ async saveNowAsync(): Promise { + if (this._saveInFlight) { + await this._saveInFlight; + // After waiting, re-check if still dirty (the previous save may have handled it) + if (!this.dirty) return; + } + this._saveInFlight = this._doSaveAsync(); + try { + await this._saveInFlight; + } finally { + this._saveInFlight = null; + } + } + + private async _doSaveAsync(): Promise { if (this.saveTimeout) { clearTimeout(this.saveTimeout); this.saveTimeout = null; diff --git a/src/subagent-watcher.ts b/src/subagent-watcher.ts index b7559bef..f38eb22e 100644 --- a/src/subagent-watcher.ts +++ b/src/subagent-watcher.ts @@ -177,6 +177,7 @@ export class SubagentWatcher extends EventEmitter { constructor() { super(); + this.setMaxListeners(50); } /** diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 11df98a2..3d9fa8c1 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -141,6 +141,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { constructor() { super(); + this.setMaxListeners(50); if (!IS_TEST_MODE) { this.loadSessions(); } diff --git a/src/web/public/app.js b/src/web/public/app.js index 7fd1e85f..0861e90f 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -3097,6 +3097,8 @@ class ClaudemanApp { this.terminalBuffers.clear(); this.terminalBufferCache.clear(); this.projectInsights.clear(); + this.teams.clear(); + this.teamTasks.clear(); // Clear all idle timers to prevent stale timers from firing for (const timer of this.idleTimers.values()) { clearTimeout(timer); diff --git a/src/web/server.ts b/src/web/server.ts index 9edaa67b..bb353c22 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -115,6 +115,8 @@ const DEC_SYNC_START = '\x1b[?2026h'; // Begin synchronized update const DEC_SYNC_END = '\x1b[?2026l'; // End synchronized update (flush to screen) // State update debounce interval (batch expensive toDetailedState() calls) const STATE_UPDATE_DEBOUNCE_INTERVAL = 500; +// Cache TTL for getLightSessionsState() — avoids re-serializing all sessions on every SSE init / /api/sessions call +const SESSIONS_LIST_CACHE_TTL = 1000; // Scheduled runs cleanup interval (check every 5 minutes) const SCHEDULED_CLEANUP_INTERVAL = 5 * 60 * 1000; // Completed scheduled runs max age (1 hour) @@ -373,6 +375,8 @@ export class WebServer extends EventEmitter { // eslint-disable-next-line @typescript-eslint/no-explicit-any private cachedLightState: { data: Record; timestamp: number } | null = null; private static readonly LIGHT_STATE_CACHE_TTL_MS = 1000; + // Cached sessions list for getLightSessionsState() (avoids re-serializing all sessions on every call) + private cachedSessionsList: { data: unknown[]; timestamp: number } | null = null; // Token recording for daily stats (track what's been recorded to avoid double-counting) private lastRecordedTokens: Map = new Map(); private tokenRecordingTimer: NodeJS.Timeout | null = null; @@ -382,6 +386,7 @@ export class WebServer extends EventEmitter { private pendingRespawnStarts: Map = new Map(); // Active plan orchestrators (for cancellation via API) private activePlanOrchestrators: Map = new Map(); + private persistDebounceTimers: Map> = new Map(); // Grace period before starting restored respawn controllers (2 minutes) private static readonly RESPAWN_RESTORE_GRACE_PERIOD_MS = 2 * 60 * 1000; // Stored listener handlers for cleanup @@ -401,6 +406,7 @@ export class WebServer extends EventEmitter { } | null = null; constructor(port: number = 3000, https: boolean = false, testMode: boolean = false) { super(); + this.setMaxListeners(0); this.port = port; this.https = https; this.testMode = testMode; @@ -3438,8 +3444,21 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; } } - /** Persists full session state including respawn config to state.json */ + /** Debounced wrapper — coalesces rapid persistSessionState calls per session */ private persistSessionState(session: Session): void { + const existing = this.persistDebounceTimers.get(session.id); + if (existing) clearTimeout(existing); + this.persistDebounceTimers.set(session.id, setTimeout(() => { + this.persistDebounceTimers.delete(session.id); + // Session may have been removed during debounce + if (this.sessions.has(session.id)) { + this._persistSessionStateNow(session); + } + }, 100)); + } + + /** Persists full session state including respawn config to state.json */ + private _persistSessionStateNow(session: Session): void { const state = session.toState(); const controller = this.respawnControllers.get(session.id); if (controller) { @@ -4341,9 +4360,15 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; * on-demand when switching tabs via /api/sessions/:id/buffer */ private getLightSessionsState() { + const now = Date.now(); + if (this.cachedSessionsList && (now - this.cachedSessionsList.timestamp) < SESSIONS_LIST_CACHE_TTL) { + return this.cachedSessionsList.data; + } // getSessionStateWithRespawn already uses toLightDetailedState() which // excludes terminalBuffer and textOutput — no extra stripping needed - return Array.from(this.sessions.values()).map(s => this.getSessionStateWithRespawn(s)); + const data = Array.from(this.sessions.values()).map(s => this.getSessionStateWithRespawn(s)); + this.cachedSessionsList = { data, timestamp: now }; + return data; } // Clean up old completed scheduled runs @@ -4449,9 +4474,10 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; } private broadcast(event: string, data: unknown): void { - // Invalidate light state cache on any state-changing broadcast + // Invalidate caches on any state-changing broadcast if (event.startsWith('session:') || event === 'respawn:') { this.cachedLightState = null; + this.cachedSessionsList = null; } // Performance optimization: serialize JSON once for all clients let message: string; @@ -5000,6 +5026,20 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; // Stop multiplexer and flush pending saves this.mux.destroy(); + // Flush any pending persist-debounce timers and persist dirty sessions + for (const [sessionId, timer] of this.persistDebounceTimers) { + clearTimeout(timer); + const session = this.sessions.get(sessionId); + if (session) { + this._persistSessionStateNow(session); + } + } + this.persistDebounceTimers.clear(); + + // Clear cached state + this.cachedLightState = null; + this.cachedSessionsList = null; + // Clear all pending respawn start timers (from restoration grace period) for (const timer of this.pendingRespawnStarts.values()) { clearTimeout(timer);