diff --git a/src/session.ts b/src/session.ts index fb9449df..72d07ab6 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1148,6 +1148,9 @@ export class Session extends EventEmitter { // Parse token count from Claude's status line in interactive mode // Matches patterns like "123.4k tokens", "5234 tokens", "1.2M tokens" private parseTokensFromStatusLine(data: string): void { + // Quick pre-check: skip expensive regex if "token" not present (performance optimization) + if (!data.includes('token')) return; + // Remove ANSI escape codes for cleaner parsing (use pre-compiled pattern) const cleanData = data.replace(ANSI_ESCAPE_PATTERN, ''); diff --git a/src/tui/hooks/useSessionManager.ts b/src/tui/hooks/useSessionManager.ts index 2f6e8c6a..87997c26 100644 --- a/src/tui/hooks/useSessionManager.ts +++ b/src/tui/hooks/useSessionManager.ts @@ -163,19 +163,37 @@ interface SessionManagerState { renameSession: (sessionId: string, name: string) => Promise; } +// Cache for screen -ls output to avoid repeated execSync calls +let screenListCache = ''; +let screenListCacheTime = 0; +const SCREEN_CACHE_TTL = 100; // ms + /** * Checks if a GNU screen session is currently running. + * Uses a 100ms cache to avoid repeated execSync calls when checking multiple sessions. * * @param screenName - The name of the screen session to check * @returns true if the session exists and is alive, false otherwise */ function isScreenAlive(screenName: string): boolean { - try { - const output = execSync('screen -ls', { encoding: 'utf-8' }); - return output.includes(screenName); - } catch { - return false; + const now = Date.now(); + + // Use cached result if fresh enough + if (now - screenListCacheTime > SCREEN_CACHE_TTL) { + try { + screenListCache = execSync('screen -ls', { + encoding: 'utf-8', + timeout: 5000, // 5 second timeout to prevent hang + }); + screenListCacheTime = now; + } catch { + screenListCache = ''; + screenListCacheTime = now; + return false; + } } + + return screenListCache.includes(screenName); } /** @@ -519,10 +537,13 @@ export function useSessionManager(): SessionManagerState { if (!session) return; try { - // Kill via screen - execSync(`screen -S ${session.screenName} -X quit`, { encoding: 'utf-8' }); + // Kill via screen with timeout to prevent hang + execSync(`screen -S ${session.screenName} -X quit`, { + encoding: 'utf-8', + timeout: 5000, + }); } catch { - // May already be dead + // May already be dead or timeout } // If this was the active session, select another @@ -543,9 +564,12 @@ export function useSessionManager(): SessionManagerState { const killAllSessions = useCallback(() => { for (const session of sessions) { try { - execSync(`screen -S ${session.screenName} -X quit`, { encoding: 'utf-8' }); + execSync(`screen -S ${session.screenName} -X quit`, { + encoding: 'utf-8', + timeout: 5000, + }); } catch { - // Ignore + // Ignore - may already be dead or timeout } } setActiveSessionId(null); @@ -582,14 +606,16 @@ export function useSessionManager(): SessionManagerState { // Send Enter key execSync(`screen -S ${session.screenName} -p 0 -X stuff $'\\015'`, { encoding: 'utf-8', + timeout: 5000, }); } else { execSync(`screen -S ${session.screenName} -p 0 -X stuff '${escaped}'`, { encoding: 'utf-8', + timeout: 5000, }); } } catch { - // Input may fail if screen is not ready + // Input may fail if screen is not ready or timeout } }, [sessions]); diff --git a/src/web/server.ts b/src/web/server.ts index dc293ce2..6b091fa7 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1051,15 +1051,24 @@ export class WebServer extends EventEmitter { }); session.on('exit', (code) => { - this.broadcast('session:exit', { id: session.id, code }); - this.broadcast('session:updated', session.toDetailedState()); + // Wrap in try/catch to ensure cleanup always happens + try { + this.broadcast('session:exit', { id: session.id, code }); + this.broadcast('session:updated', session.toDetailedState()); + } catch (err) { + console.error(`[Server] Error broadcasting session exit for ${session.id}:`, err); + } - // Clean up respawn controller when session exits (stop + remove listeners) - const controller = this.respawnControllers.get(session.id); - if (controller) { - controller.stop(); - controller.removeAllListeners(); - this.respawnControllers.delete(session.id); + // Always clean up respawn controller, even if broadcast failed + try { + const controller = this.respawnControllers.get(session.id); + if (controller) { + controller.stop(); + controller.removeAllListeners(); + this.respawnControllers.delete(session.id); + } + } catch (err) { + console.error(`[Server] Error cleaning up respawn controller for ${session.id}:`, err); } }); @@ -1609,6 +1618,9 @@ export class WebServer extends EventEmitter { await this.cleanupSession(sessionId, false); } + // Flush state store to prevent data loss from debounced saves + this.store.flushAll(); + await this.app.close(); } }