From 45332b64380ff120dbb7bce3edbd49608f42e478 Mon Sep 17 00:00:00 2001 From: arkon Date: Mon, 26 Jan 2026 19:42:01 +0100 Subject: [PATCH] feat: parallel session creation and improved minimized agents UI - Session creation now parallelized (3x faster for multiple tabs) - Minimized agents badge: compact, hover-triggered, auto-hide - Removed "Minimized Agents" header, status dots instead of text - Click to pin dropdown open, hover for quick access Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 16 ++- package.json | 2 +- src/screen-manager.ts | 25 +++- src/session.ts | 7 +- src/web/public/app.js | 280 ++++++++++++++++++++++++-------------- src/web/public/styles.css | 125 ++++++++--------- src/web/server.ts | 4 +- 7 files changed, 289 insertions(+), 170 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7984f98d..3827e46b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,13 +15,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## ⚡ COM Shorthand (Deployment) -When user says "COM": 1) Increment version in BOTH `package.json` AND `CLAUDE.md`, 2) `git add && git commit && git push && npm run build && systemctl --user restart claudeman-web`. Always bump version on every COM. +When user says "COM": 1) Increment version in BOTH `package.json` AND `CLAUDE.md` (keep them in sync), 2) `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web`. Always bump version on every COM, even for small changes. ## Project Overview Claudeman is a Claude Code session manager with a web interface and autonomous Ralph Loop. It spawns Claude CLI processes via PTY, streams output in real-time via SSE, and supports scheduled/timed runs. -**Version**: 0.1384 (must match `package.json`) +**Version**: 0.1385 (must match `package.json`) **Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, Server-Sent Events, node-pty @@ -43,6 +43,12 @@ npm install **CRITICAL**: `npm run dev` runs CLI help, NOT the web server. Use `npx tsx src/index.ts web` for development. +**Quick reference**: +- Dev server: `npx tsx src/index.ts web` (or `web --https` for notifications) +- Type check: `npx tsc --noEmit` +- Single test: `npx vitest run test/.test.ts` +- Restart prod: `systemctl --user restart claudeman-web` + ### Build & Clean ```bash @@ -523,7 +529,11 @@ Use `createErrorResponse(code, details?)` from `types.ts`: ## TUI (WIP) -Ink/React-based TUI in `src/tui/`. Client to the web server, uses `/api/*` endpoints and attaches to screens via GNU screen. Not fully implemented yet. +Ink/React-based TUI in `src/tui/`. Client to the web server, uses `/api/*` endpoints and attaches to screens via GNU screen. + +**Key files**: `App.tsx` (main component), `DirectAttach.ts` (full-screen attach with tab switching), `SessionList.tsx`, `SessionView.tsx`. + +**Current state**: Basic session list and direct attach work. Missing: full session management UI, settings panel, notifications. ## Buffer Limits diff --git a/package.json b/package.json index b044818e..7ca7d95f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1384", + "version": "0.1385", "description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/src/screen-manager.ts b/src/screen-manager.ts index 61d4beef..4b42b0b8 100644 --- a/src/screen-manager.ts +++ b/src/screen-manager.ts @@ -16,7 +16,7 @@ import { EventEmitter } from 'node:events'; import { spawn, execSync } from 'node:child_process'; -import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { existsSync, readFileSync, mkdirSync, writeFile } from 'node:fs'; import { dirname, join } from 'node:path'; import { homedir } from 'node:os'; import { ScreenSession, ProcessStats, ScreenSessionWithStats, PersistedRespawnConfig, getErrorMessage } from './types.js'; @@ -56,6 +56,7 @@ const DEFAULT_STATS_INTERVAL_MS = 2000; /** Maximum retry attempts for carriage return (3) */ const CR_MAX_ATTEMPTS = 3; + /** Cached directory containing the claude binary */ let _claudeDir: string | null = null; @@ -193,7 +194,10 @@ export class ScreenManager extends EventEmitter { } } - // Save screens to disk + /** + * Save screens to disk asynchronously. + * Uses async write to avoid blocking the event loop. + */ private saveScreens(): void { try { const dir = dirname(SCREENS_FILE); @@ -201,7 +205,14 @@ export class ScreenManager extends EventEmitter { mkdirSync(dir, { recursive: true }); } const data = Array.from(this.screens.values()); - writeFileSync(SCREENS_FILE, JSON.stringify(data, null, 2)); + const json = JSON.stringify(data, null, 2); + + // Async write to avoid blocking event loop + writeFile(SCREENS_FILE, json, (err) => { + if (err) { + console.error('[ScreenManager] Failed to save screens:', err); + } + }); } catch (err) { console.error('[ScreenManager] Failed to save screens:', err); } @@ -623,6 +634,14 @@ export class ScreenManager extends EventEmitter { } } + /** + * Clean up resources. + * Call this on server shutdown. + */ + destroy(): void { + this.stopStatsCollection(); + } + // Register a session as using screen (for when session creates its own screen) registerScreen(screen: ScreenSession): void { this.screens.set(screen.sessionId, screen); diff --git a/src/session.ts b/src/session.ts index f2240a30..9340ea62 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1424,11 +1424,16 @@ export class Session extends EventEmitter { */ private cleanupOldTaskDescriptions(): void { const cutoff = Date.now() - Session.TASK_DESCRIPTION_MAX_AGE_MS; + // Collect keys to delete first, then delete (avoids modifying Map during iteration) + const keysToDelete: number[] = []; for (const [timestamp] of this._recentTaskDescriptions) { if (timestamp < cutoff) { - this._recentTaskDescriptions.delete(timestamp); + keysToDelete.push(timestamp); } } + for (const key of keysToDelete) { + this._recentTaskDescriptions.delete(key); + } // Enforce size limit by removing oldest entries if (this._recentTaskDescriptions.size > Session.MAX_TASK_DESCRIPTIONS) { diff --git a/src/web/public/app.js b/src/web/public/app.js index 0e01e5e4..e911cbc5 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -461,6 +461,7 @@ class ClaudemanApp { this.subagentWindows = new Map(); // Map this.subagentWindowZIndex = 1000; this.minimizedSubagents = new Map(); // Map> - minimized to tab + this._subagentHideTimeout = null; // Timeout for hover-based dropdown hide this.ralphStatePanelCollapsed = true; // Default to collapsed // Project Insights tracking (active Bash tools with clickable file paths) @@ -1917,6 +1918,10 @@ class ClaudemanApp { _fullRenderSessionTabs() { const container = this.$('sessionTabs'); + // Clean up any orphaned dropdowns before re-rendering + document.querySelectorAll('body > .subagent-dropdown').forEach(d => d.remove()); + this.cancelHideSubagentDropdown(); + // Build tabs HTML using array for better string concatenation performance const parts = []; for (const [id, session] of this.sessions) { @@ -1958,23 +1963,25 @@ class ClaudemanApp { for (const agentId of minimizedAgents) { const agent = this.subagents.get(agentId); const displayName = agent?.description || agentId.substring(0, 12); - const truncatedName = displayName.length > 30 ? displayName.substring(0, 30) + '...' : displayName; + const truncatedName = displayName.length > 25 ? displayName.substring(0, 25) + '…' : displayName; const statusClass = agent?.status || 'idle'; agentItems.push(` -
- 🤖 - ${this.escapeHtml(truncatedName)} - ${statusClass} - × +
+ + ${this.escapeHtml(truncatedName)} + ×
`); } + // Compact badge - shows on hover, click to pin open return ` - - 🤖${minimizedAgents.size} -
-
Minimized Agents
+ + ${minimizedAgents.size} +
${agentItems.join('')}
@@ -2002,40 +2009,68 @@ class ClaudemanApp { this.saveSubagentWindowStates(); } - // Toggle subagent dropdown visibility on click - toggleSubagentDropdown(badgeEl) { + // Show subagent dropdown on hover + showSubagentDropdown(badgeEl) { + this.cancelHideSubagentDropdown(); const dropdown = badgeEl.querySelector('.subagent-dropdown'); - if (!dropdown) return; + if (!dropdown || dropdown.classList.contains('open')) return; - const isOpen = dropdown.classList.contains('open'); - - // Close all other dropdowns first and move them back to their badges + // Close other dropdowns first document.querySelectorAll('.subagent-dropdown.open').forEach(d => { - d.classList.remove('open'); - // Move back to original parent if it was moved to body + d.classList.remove('open', 'pinned'); if (d.parentElement === document.body && d._originalParent) { d._originalParent.appendChild(d); } }); - if (!isOpen) { - // Move dropdown to body to escape contain:layout clipping from .session-tabs - dropdown._originalParent = dropdown.parentElement; - document.body.appendChild(dropdown); + // Move to body to escape clipping + dropdown._originalParent = badgeEl; + document.body.appendChild(dropdown); - // Position the dropdown using fixed positioning - const rect = badgeEl.getBoundingClientRect(); - dropdown.style.top = `${rect.bottom + 4}px`; - dropdown.style.left = `${rect.left + rect.width / 2}px`; - dropdown.style.transform = 'translateX(-50%)'; + // Position below badge + const rect = badgeEl.getBoundingClientRect(); + dropdown.style.top = `${rect.bottom + 2}px`; + dropdown.style.left = `${rect.left + rect.width / 2}px`; + dropdown.style.transform = 'translateX(-50%)'; + dropdown.classList.add('open'); + } - dropdown.classList.add('open'); + // Schedule hide after delay (allows moving mouse to dropdown) + scheduleHideSubagentDropdown(badgeEl) { + this._subagentHideTimeout = setTimeout(() => { + const dropdown = badgeEl?.querySelector?.('.subagent-dropdown') || + document.querySelector('.subagent-dropdown.open'); + if (dropdown && !dropdown.classList.contains('pinned')) { + dropdown.classList.remove('open'); + if (dropdown._originalParent) { + dropdown._originalParent.appendChild(dropdown); + } + } + }, 150); + } - // Close dropdown when clicking outside + // Cancel scheduled hide + cancelHideSubagentDropdown() { + if (this._subagentHideTimeout) { + clearTimeout(this._subagentHideTimeout); + this._subagentHideTimeout = null; + } + } + + // Pin dropdown open on click (stays until clicking outside) + pinSubagentDropdown(badgeEl) { + const dropdown = document.querySelector('.subagent-dropdown.open'); + if (!dropdown) { + this.showSubagentDropdown(badgeEl); + return; + } + dropdown.classList.toggle('pinned'); + + if (dropdown.classList.contains('pinned')) { + // Close on outside click const closeHandler = (e) => { if (!badgeEl.contains(e.target) && !dropdown.contains(e.target)) { - dropdown.classList.remove('open'); - // Move back to original parent + dropdown.classList.remove('open', 'pinned'); if (dropdown._originalParent) { dropdown._originalParent.appendChild(dropdown); } @@ -2046,6 +2081,11 @@ class ClaudemanApp { } } + // Legacy toggle for backwards compat + toggleSubagentDropdown(badgeEl) { + this.pinSubagentDropdown(badgeEl); + } + // Permanently close a minimized subagent (remove from DOM and minimized set) permanentlyCloseMinimizedSubagent(agentId, sessionId) { // Remove from minimized set @@ -2428,44 +2468,48 @@ class ClaudemanApp { // Get global Ralph tracker setting const ralphEnabled = this.isRalphTrackerEnabledByDefault(); - // Create multiple sessions with unique w-numbers + // Create all sessions in parallel for speed + const sessionNames = []; for (let i = 0; i < tabCount; i++) { - const sessionName = `w${startNumber + i}-${caseName}`; - - // Create session - const createRes = await fetch('/api/sessions', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workingDir, name: sessionName }) - }); - const createData = await createRes.json(); - if (!createData.success) throw new Error(createData.error); - - // Apply global Ralph tracker setting - // If enabled: enable the tracker - // If disabled: disable auto-enable to prevent pattern-based activation - await fetch(`/api/sessions/${createData.session.id}/ralph-config`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - enabled: ralphEnabled, - disableAutoEnable: !ralphEnabled - }) - }); - - // Start interactive mode - await fetch(`/api/sessions/${createData.session.id}/interactive`, { - method: 'POST' - }); - - // Track first session - if (i === 0) { - firstSessionId = createData.session.id; - } - - this.terminal.writeln(`\x1b[90m Created session ${i}/${tabCount}: ${sessionName}\x1b[0m`); + sessionNames.push(`w${startNumber + i}-${caseName}`); } + // Step 1: Create all sessions in parallel + this.terminal.writeln(`\x1b[90m Creating ${tabCount} session(s)...\x1b[0m`); + const createPromises = sessionNames.map(name => + fetch('/api/sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workingDir, name }) + }).then(r => r.json()) + ); + const createResults = await Promise.all(createPromises); + + // Collect created session IDs + const sessionIds = []; + for (const result of createResults) { + if (!result.success) throw new Error(result.error); + sessionIds.push(result.session.id); + } + firstSessionId = sessionIds[0]; + + // Step 2: Configure Ralph for all sessions in parallel + await Promise.all(sessionIds.map(id => + fetch(`/api/sessions/${id}/ralph-config`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: ralphEnabled, disableAutoEnable: !ralphEnabled }) + }) + )); + + // Step 3: Start all sessions in parallel (biggest speedup) + this.terminal.writeln(`\x1b[90m Starting ${tabCount} session(s) in parallel...\x1b[0m`); + await Promise.all(sessionIds.map(id => + fetch(`/api/sessions/${id}/interactive`, { method: 'POST' }) + )); + + this.terminal.writeln(`\x1b[90m All ${tabCount} sessions ready\x1b[0m`); + // Auto-switch to the new session using selectSession (does proper refresh) if (firstSessionId) { await this.selectSession(firstSessionId); @@ -2505,38 +2549,49 @@ class ClaudemanApp { } } - // Create multiple shell sessions + // Create all shell sessions in parallel + const sessionNames = []; for (let i = 0; i < shellCount; i++) { - const sessionName = `s${startNumber + i}-${caseName}`; + sessionNames.push(`s${startNumber + i}-${caseName}`); + } - // Create session with shell mode - const createRes = await fetch('/api/sessions', { + // Step 1: Create all sessions in parallel + const createPromises = sessionNames.map(name => + fetch('/api/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workingDir, mode: 'shell', name: sessionName }) - }); - const createData = await createRes.json(); - if (!createData.success) throw new Error(createData.error); + body: JSON.stringify({ workingDir, mode: 'shell', name }) + }).then(r => r.json()) + ); + const createResults = await Promise.all(createPromises); - const sessionId = createData.session.id; + const sessionIds = []; + for (const result of createResults) { + if (!result.success) throw new Error(result.error); + sessionIds.push(result.session.id); + } - // Start shell - await fetch(`/api/sessions/${sessionId}/shell`, { - method: 'POST' - }); + // Step 2: Start all shells in parallel + await Promise.all(sessionIds.map(id => + fetch(`/api/sessions/${id}/shell`, { method: 'POST' }) + )); - // Set active to last created - this.activeSessionId = sessionId; - - // Send resize - const dims = this.fitAddon.proposeDimensions(); - if (dims) { - await fetch(`/api/sessions/${sessionId}/resize`, { + // Step 3: Resize all in parallel + const dims = this.fitAddon.proposeDimensions(); + if (dims) { + await Promise.all(sessionIds.map(id => + fetch(`/api/sessions/${id}/resize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cols: dims.cols, rows: dims.rows }) - }); - } + }) + )); + } + + // Switch to first session + if (sessionIds.length > 0) { + this.activeSessionId = sessionIds[0]; + await this.selectSession(sessionIds[0]); } this.terminal.focus(); @@ -4192,13 +4247,22 @@ class ClaudemanApp { // Only restore window if agent exists and is still active/idle (not completed) if (agent && agent.status !== 'completed') { this.openSubagentWindow(agentId); - // Restore position if saved + // Restore position if saved (with viewport bounds check) if (position) { const windowData = this.subagentWindows.get(agentId); if (windowData && windowData.element) { - windowData.element.style.left = position.left; - windowData.element.style.top = position.top; - windowData.position = position; + // Parse position values and clamp to viewport + let left = parseInt(position.left, 10) || 50; + let top = parseInt(position.top, 10) || 120; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const windowWidth = 420; + const windowHeight = 350; + left = Math.max(10, Math.min(left, viewportWidth - windowWidth - 10)); + top = Math.max(10, Math.min(top, viewportHeight - windowHeight - 10)); + windowData.element.style.left = `${left}px`; + windowData.element.style.top = `${top}px`; + windowData.position = { left: `${left}px`, top: `${top}px` }; } } } @@ -5545,11 +5609,18 @@ class ClaudemanApp { const gap = 20; const startX = 50; const startY = 120; - const maxCols = Math.floor((window.innerWidth - startX - 50) / (windowWidth + gap)) || 1; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const maxCols = Math.floor((viewportWidth - startX - 50) / (windowWidth + gap)) || 1; + const maxRows = Math.floor((viewportHeight - startY - 50) / (windowHeight + gap)) || 1; const col = windowCount % maxCols; - const row = Math.floor(windowCount / maxCols); - const finalX = startX + col * (windowWidth + gap); - const finalY = startY + row * (windowHeight + gap); + const row = Math.floor(windowCount / maxCols) % maxRows; // Wrap rows to stay in viewport + let finalX = startX + col * (windowWidth + gap); + let finalY = startY + row * (windowHeight + gap); + + // Ensure window stays within viewport bounds + finalX = Math.max(10, Math.min(finalX, viewportWidth - windowWidth - 10)); + finalY = Math.max(10, Math.min(finalY, viewportHeight - windowHeight - 10)); // Get parent tab position for spawn animation const parentTab = agent.parentSessionId @@ -5790,8 +5861,15 @@ class ClaudemanApp { if (!isDragging) return; const dx = e.clientX - startX; const dy = e.clientY - startY; - win.style.left = `${startLeft + dx}px`; - win.style.top = `${startTop + dy}px`; + // Constrain to viewport bounds + const winWidth = win.offsetWidth || 420; + const winHeight = win.offsetHeight || 350; + const maxX = window.innerWidth - winWidth - 10; + const maxY = window.innerHeight - winHeight - 10; + const newLeft = Math.max(10, Math.min(startLeft + dx, maxX)); + const newTop = Math.max(10, Math.min(startTop + dy, maxY)); + win.style.left = `${newLeft}px`; + win.style.top = `${newTop}px`; // Throttle connection line updates during drag if (!dragUpdateScheduled) { dragUpdateScheduled = true; @@ -5803,7 +5881,11 @@ class ClaudemanApp { }); document.addEventListener('mouseup', () => { - isDragging = false; + if (isDragging) { + isDragging = false; + // Save position after drag ends + this.saveSubagentWindowStates(); + } }); } diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 537a8d3a..b774e6df 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -2952,125 +2952,128 @@ kbd { animation: none; } -/* Subagent Badge on Tabs */ +/* Subagent Badge on Tabs - Compact hover-triggered */ .session-tab .tab-subagent-badge { position: relative; - display: flex; + display: inline-flex; align-items: center; - gap: 2px; + justify-content: center; + min-width: 16px; + height: 16px; padding: 0 4px; border-radius: 8px; - font-size: 0.7rem; + font-size: 0.65rem; cursor: pointer; - background: rgba(59, 130, 246, 0.2); - border: 1px solid var(--accent); + background: var(--accent); + color: white; + margin-left: 4px; + transition: transform 0.1s, background 0.15s; } .session-tab .tab-subagent-badge:hover { - background: rgba(59, 130, 246, 0.3); + background: var(--accent-hover); + transform: scale(1.1); } .session-tab .tab-subagent-badge .subagent-count { - font-size: 0.6rem; + font-size: 0.65rem; font-weight: 600; - color: var(--accent-hover); + line-height: 1; } -/* Subagent Dropdown - uses fixed positioning to escape overflow:hidden parents */ +/* Subagent Dropdown - compact, hover-triggered */ .subagent-dropdown { display: none; position: fixed; - min-width: 220px; - max-width: 320px; + min-width: 180px; + max-width: 280px; background: var(--bg-card); border: 1px solid var(--border); - border-radius: 8px; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); + border-radius: 6px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); z-index: 10000; overflow: hidden; + padding: 4px 0; } .subagent-dropdown.open { display: block; + animation: dropdownFadeIn 0.1s ease-out; } +@keyframes dropdownFadeIn { + from { opacity: 0; transform: translateX(-50%) translateY(-4px); } + to { opacity: 1; transform: translateX(-50%) translateY(0); } +} + +.subagent-dropdown.pinned { + border-color: var(--accent); +} + +/* Header removed - no longer needed */ .subagent-dropdown-header { - padding: 0.5rem 0.75rem; - font-size: 0.7rem; - font-weight: 600; - color: var(--text-muted); - text-transform: uppercase; - letter-spacing: 0.05em; - background: var(--bg-input); - border-bottom: 1px solid var(--border); + display: none; } .subagent-dropdown-item { display: flex; align-items: center; - gap: 0.5rem; - padding: 0.5rem 0.75rem; + gap: 6px; + padding: 6px 10px; cursor: pointer; - transition: background 0.15s; + transition: background 0.1s; } .subagent-dropdown-item:hover { background: var(--bg-hover); } -.subagent-dropdown-icon { - font-size: 0.85rem; +/* Status dot instead of text label */ +.subagent-dropdown-status { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; + background: var(--text-muted); +} + +.subagent-dropdown-status.active { + background: var(--green); + box-shadow: 0 0 4px var(--green); +} + +.subagent-dropdown-status.idle { + background: var(--yellow); +} + +.subagent-dropdown-status.completed { + background: var(--accent); } .subagent-dropdown-name { flex: 1; - font-size: 0.75rem; + font-size: 0.72rem; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.subagent-dropdown-status { - font-size: 0.6rem; - padding: 0.1rem 0.3rem; - border-radius: 4px; - background: var(--bg-input); - color: var(--text-muted); -} - -.subagent-dropdown-status.active { - background: var(--green); - color: white; -} - -.subagent-dropdown-status.idle { - background: var(--yellow); - color: #000; -} - -.subagent-dropdown-status.completed { - background: var(--accent); - color: white; -} - -.subagent-dropdown-name { - cursor: pointer; -} - -.subagent-dropdown-name:hover { +.subagent-dropdown-item:hover .subagent-dropdown-name { color: var(--accent-hover); - text-decoration: underline; } .subagent-dropdown-close { - font-size: 1rem; + font-size: 0.9rem; color: var(--text-muted); cursor: pointer; - padding: 0 0.25rem; - margin-left: auto; + padding: 0 2px; + opacity: 0; + transition: opacity 0.1s, color 0.1s; +} + +.subagent-dropdown-item:hover .subagent-dropdown-close { opacity: 0.6; - transition: opacity 0.15s, color 0.15s; } .subagent-dropdown-close:hover { diff --git a/src/web/server.ts b/src/web/server.ts index 92b0c788..bbf7ad01 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -3292,8 +3292,8 @@ export class WebServer extends EventEmitter { this.scheduledCleanupTimer = null; } - // Stop screen stats collection - this.screenManager.stopStatsCollection(); + // Stop screen manager and flush pending saves + this.screenManager.destroy(); // Clear all pending respawn start timers (from restoration grace period) for (const timer of this.pendingRespawnStarts.values()) {