diff --git a/CLAUDE.md b/CLAUDE.md index 18168bf6..ed3992d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,12 +140,23 @@ Sessions are optimized for 12-24+ hour runs with automatic buffer management: - Terminal buffer: 5MB max, trims to 4MB when exceeded - Text output: 2MB max, trims to 1.5MB when exceeded - Messages: 1000 max, keeps most recent 800 when exceeded +- Line buffer: 64KB max with 100ms periodic flush (prevents unbounded growth from long lines) +- Completed tasks: 100 max in TaskTracker (auto-removes oldest) +- Respawn terminal buffer: 1MB max, trims to 512KB **Performance Optimizations:** - Server-side terminal batching at 60fps (16ms intervals) +- SSE event batching: `session:output` at 50ms, `task:updated` at 100ms - Client-side requestAnimationFrame batching for smooth rendering +- Frontend `renderSessionTabs()` debounced at 100ms +- Parallel screen stats fetching via `Promise.all()` +- Configurable xterm scrollback (default 5000 lines) - Buffer statistics available via session details for monitoring +**Automatic Cleanup:** +- Scheduled runs auto-deleted after 1 hour of completion +- Sessions cleaned up after each scheduled run iteration (prevents leaks) + **Buffer Stats Response:** ```typescript { @@ -249,6 +260,9 @@ REST API served by Fastify at `src/web/server.ts`. All endpoints are under `/api - `POST /api/screens/stats/start` - Start resource monitoring - `POST /api/screens/stats/stop` - Stop resource monitoring +**System:** +- `GET /api/system/stats` - Get CPU and memory usage `{ cpu, memory: { usedMB, totalMB, percent } }` + **Other:** - `GET /api/events` - SSE stream for real-time updates - `GET /api/status` - Full state snapshot @@ -277,14 +291,16 @@ npx agent-browser close ## Frontend The web UI (`src/web/public/`) uses vanilla JavaScript with: -- **xterm.js**: Terminal emulator with WebGL renderer for 60fps performance +- **xterm.js**: Terminal emulator with configurable scrollback (default 5000 lines) - **xterm-addon-fit**: Auto-resize terminal to container - **Server-Sent Events**: Real-time updates from `/api/events` +- **System Stats**: CPU and memory usage displayed in header (2s polling) - **No build step**: Static files served directly by Fastify Key files: - `app.js` - Main application logic, SSE handling, session management - `index.html` - Single page with embedded styles +- `styles.css` - All CSS styles - Libraries loaded from CDN (xterm.js, addons) ## Notes diff --git a/src/respawn-controller.ts b/src/respawn-controller.ts index 210abde4..afc65cbc 100644 --- a/src/respawn-controller.ts +++ b/src/respawn-controller.ts @@ -1,6 +1,11 @@ import { EventEmitter } from 'node:events'; import { Session } from './session.js'; +// Maximum terminal buffer size for respawn controller (1MB) +const MAX_RESPAWN_BUFFER_SIZE = 1024 * 1024; +// Keep this much when trimming (512KB) +const RESPAWN_BUFFER_TRIM_SIZE = 512 * 1024; + /** * Respawn sequence states * @@ -182,9 +187,9 @@ export class RespawnController extends EventEmitter { private handleTerminalData(data: string): void { this.terminalBuffer += data; - // Keep buffer manageable (last 10KB) - if (this.terminalBuffer.length > 10240) { - this.terminalBuffer = this.terminalBuffer.slice(-5120); + // Keep buffer manageable (max 1MB, trim to 512KB) + if (this.terminalBuffer.length > MAX_RESPAWN_BUFFER_SIZE) { + this.terminalBuffer = this.terminalBuffer.slice(-RESPAWN_BUFFER_TRIM_SIZE); } // Filter out noise - only count meaningful data as activity diff --git a/src/screen-manager.ts b/src/screen-manager.ts index cbbc8088..c413a133 100644 --- a/src/screen-manager.ts +++ b/src/screen-manager.ts @@ -228,16 +228,19 @@ export class ScreenManager extends EventEmitter { } } - // Get all screens with stats + // Get all screens with stats (parallel for better performance) async getScreensWithStats(): Promise { - const result: ScreenSessionWithStats[] = []; + const screens = Array.from(this.screens.values()); - for (const screen of this.screens.values()) { - const stats = await this.getProcessStats(screen.sessionId); - result.push({ ...screen, stats: stats || undefined }); - } + // Fetch all stats in parallel + const statsPromises = screens.map(screen => this.getProcessStats(screen.sessionId)); + const allStats = await Promise.all(statsPromises); - return result; + // Combine screens with their stats + return screens.map((screen, i) => ({ + ...screen, + stats: allStats[i] || undefined + })); } // Start periodic stats collection diff --git a/src/session.ts b/src/session.ts index 694409db..58902030 100644 --- a/src/session.ts +++ b/src/session.ts @@ -17,6 +17,10 @@ const MAX_TEXT_OUTPUT_SIZE = 2 * 1024 * 1024; const TEXT_OUTPUT_TRIM_SIZE = 1.5 * 1024 * 1024; // Maximum number of Claude messages to keep in memory const MAX_MESSAGES = 1000; +// Maximum line buffer size (64KB) - prevents unbounded growth for long lines +const MAX_LINE_BUFFER_SIZE = 64 * 1024; +// Line buffer flush interval (100ms) - forces processing of partial lines +const LINE_BUFFER_FLUSH_INTERVAL = 100; // Filter out terminal focus escape sequences (focus in/out reports) // ^[[I (focus in), ^[[O (focus out), and the enable/disable sequences @@ -77,6 +81,7 @@ export class Session extends EventEmitter { private _totalCost: number = 0; private _messages: ClaudeMessage[] = []; private _lineBuffer: string = ''; + private _lineBufferFlushTimer: NodeJS.Timeout | null = null; private resolvePromise: ((value: { result: string; cost: number }) => void) | null = null; private rejectPromise: ((reason: Error) => void) | null = null; private _isWorking: boolean = false; @@ -605,9 +610,35 @@ export class Session extends EventEmitter { private processOutput(data: string): void { // Try to extract JSON from output (Claude may output JSON in stream mode) this._lineBuffer += data; + + // Prevent unbounded line buffer growth for very long lines + if (this._lineBuffer.length > MAX_LINE_BUFFER_SIZE) { + // Force flush the oversized buffer as text output + this._textOutput += this._lineBuffer + '\n'; + this._lineBuffer = ''; + } + + // Start flush timer if not running (handles partial lines after 100ms) + if (!this._lineBufferFlushTimer && this._lineBuffer.length > 0) { + this._lineBufferFlushTimer = setTimeout(() => { + this._lineBufferFlushTimer = null; + if (this._lineBuffer.length > 0) { + // Flush partial line as text output + this._textOutput += this._lineBuffer; + this._lineBuffer = ''; + } + }, LINE_BUFFER_FLUSH_INTERVAL); + } + const lines = this._lineBuffer.split('\n'); this._lineBuffer = lines.pop() || ''; + // Clear flush timer if buffer is now empty + if (this._lineBuffer.length === 0 && this._lineBufferFlushTimer) { + clearTimeout(this._lineBufferFlushTimer); + this._lineBufferFlushTimer = null; + } + for (const line of lines) { const trimmed = line.trim(); // Remove ANSI escape codes for JSON parsing @@ -771,6 +802,12 @@ export class Session extends EventEmitter { this.activityTimeout = null; } + // Clear line buffer flush timer + if (this._lineBufferFlushTimer) { + clearTimeout(this._lineBufferFlushTimer); + this._lineBufferFlushTimer = null; + } + if (this.ptyProcess) { const pid = this.ptyProcess.pid; diff --git a/src/task-tracker.ts b/src/task-tracker.ts index f2567e46..df411c69 100644 --- a/src/task-tracker.ts +++ b/src/task-tracker.ts @@ -1,5 +1,8 @@ import { EventEmitter } from 'node:events'; +// Maximum number of completed tasks to keep in memory +const MAX_COMPLETED_TASKS = 100; + /** * Represents a background task spawned by Claude Code */ @@ -159,12 +162,54 @@ export class TaskTracker extends EventEmitter { } else { this.emit('taskCompleted', task); } + + // Clean up old completed tasks to prevent unbounded growth + this.cleanupCompletedTasks(); } // Clean up pending this.pendingToolUses.delete(toolUseId); } + /** + * Remove old completed/failed tasks when exceeding the limit + * Keeps running tasks and the most recent completed tasks + */ + private cleanupCompletedTasks(): void { + const completedTasks: BackgroundTask[] = []; + + // Collect all completed/failed tasks + for (const task of this.tasks.values()) { + if (task.status === 'completed' || task.status === 'failed') { + completedTasks.push(task); + } + } + + // If under limit, no cleanup needed + if (completedTasks.length <= MAX_COMPLETED_TASKS) { + return; + } + + // Sort by end time (oldest first) + completedTasks.sort((a, b) => (a.endTime || 0) - (b.endTime || 0)); + + // Remove oldest tasks beyond the limit + const toRemove = completedTasks.slice(0, completedTasks.length - MAX_COMPLETED_TASKS); + for (const task of toRemove) { + // Remove from parent's children list if applicable + if (task.parentId) { + const parent = this.tasks.get(task.parentId); + if (parent) { + const childIndex = parent.children.indexOf(task.id); + if (childIndex !== -1) { + parent.children.splice(childIndex, 1); + } + } + } + this.tasks.delete(task.id); + } + } + private createTaskFromTerminal(agentType: string, context: string): void { const taskId = `terminal-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const parentId = this.taskStack.length > 0 ? this.taskStack[this.taskStack.length - 1] : null; diff --git a/src/web/public/app.js b/src/web/public/app.js index c382129e..33ef9ec9 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1,4 +1,7 @@ // Claudeman App - Tab-based Terminal UI +// Default terminal scrollback (can be changed via settings) +const DEFAULT_SCROLLBACK = 5000; + class ClaudemanApp { constructor() { this.sessions = new Map(); @@ -20,6 +23,12 @@ class ClaudemanApp { this.pendingWrites = ''; this.writeFrameScheduled = false; + // Render debouncing + this.renderSessionTabsTimeout = null; + + // System stats polling + this.systemStatsInterval = null; + this.init(); } @@ -32,9 +41,14 @@ class ClaudemanApp { this.setupEventListeners(); // Show monitor panel by default this.toggleMonitorPanel(); + // Start system stats polling + this.startSystemStatsPolling(); } initTerminal() { + // Load scrollback setting from localStorage (default 5000) + const scrollback = parseInt(localStorage.getItem('claudeman-scrollback')) || DEFAULT_SCROLLBACK; + this.terminal = new Terminal({ theme: { background: '#0d0d0d', @@ -64,7 +78,7 @@ class ClaudemanApp { lineHeight: 1.2, cursorBlink: true, cursorStyle: 'block', - scrollback: 10000, + scrollback: scrollback, allowTransparency: true, }); @@ -479,6 +493,16 @@ class ClaudemanApp { // ========== Session Tabs ========== renderSessionTabs() { + // Debounce renders at 100ms to prevent excessive DOM updates + if (this.renderSessionTabsTimeout) { + clearTimeout(this.renderSessionTabsTimeout); + } + this.renderSessionTabsTimeout = setTimeout(() => { + this._renderSessionTabsImmediate(); + }, 100); + } + + _renderSessionTabsImmediate() { const container = document.getElementById('sessionTabs'); // Build tabs HTML @@ -528,9 +552,17 @@ class ClaudemanApp { try { const res = await fetch(`/api/sessions/${sessionId}/terminal`); const data = await res.json(); - this.terminal.clear(); + this.terminal.reset(); if (data.terminalBuffer) { - this.terminal.write(data.terminalBuffer); + // Strip leading ANSI escape sequences and whitespace to prevent gaps + // This handles: + // - CSI sequences: ESC [ (params) (final) - includes ? for private modes + // - OSC sequences: ESC ] ... BEL or ESC \ + // - Simple sequences: ESC followed by single char + // - Whitespace, CR, LF + let cleanBuffer = data.terminalBuffer; + cleanBuffer = cleanBuffer.replace(/^(\x1b\[[0-9;?]*[A-Za-z@`]|\x1b\][^\x07]*\x07|\x1b[()][AB012]|\x1b[DEMNOP78>=c]|\s|\r|\n)*/g, ''); + this.terminal.write(cleanBuffer); } // Send resize @@ -1583,6 +1615,54 @@ class ClaudemanApp { }, 3000); } + // ========== System Stats ========== + + startSystemStatsPolling() { + // Initial fetch + this.fetchSystemStats(); + + // Poll every 2 seconds + this.systemStatsInterval = setInterval(() => { + this.fetchSystemStats(); + }, 2000); + } + + async fetchSystemStats() { + try { + const res = await fetch('/api/system/stats'); + const stats = await res.json(); + this.updateSystemStatsDisplay(stats); + } catch (err) { + // Silently fail - system stats are not critical + } + } + + updateSystemStatsDisplay(stats) { + const cpuEl = document.getElementById('statCpu'); + const memEl = document.getElementById('statMem'); + + if (cpuEl) { + cpuEl.textContent = `CPU: ${stats.cpu}%`; + // Color based on usage + if (stats.cpu > 80) { + cpuEl.classList.add('high'); + } else { + cpuEl.classList.remove('high'); + } + } + + if (memEl) { + const memGB = (stats.memory.usedMB / 1024).toFixed(1); + memEl.textContent = `Mem: ${memGB}GB`; + // Color based on usage + if (stats.memory.percent > 80) { + memEl.classList.add('high'); + } else { + memEl.classList.remove('high'); + } + } + } + // ========== Utility ========== escapeHtml(text) { diff --git a/src/web/public/index.html b/src/web/public/index.html index ce649553..ce51ceb9 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -35,6 +35,10 @@ Connecting
0 tokens
+
+ CPU: -- + Mem: -- +
diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 05f08887..f51024c9 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -187,6 +187,26 @@ body { font-family: 'SF Mono', Monaco, monospace; } +.header-system-stats { + display: flex; + gap: 0.75rem; + font-size: 0.7rem; + font-family: 'SF Mono', Monaco, monospace; + color: var(--text-dim); + padding: 0 0.5rem; + border-left: 1px solid var(--border); + margin-left: 0.5rem; +} + +.header-system-stats span { + white-space: nowrap; +} + +.header-system-stats .high { + color: var(--red); + font-weight: 500; +} + .btn-icon-header { display: flex; align-items: center; diff --git a/src/web/server.ts b/src/web/server.ts index 40267cfe..d752d258 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -3,7 +3,7 @@ import fastifyStatic from '@fastify/static'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync } from 'node:fs'; -import { homedir } from 'node:os'; +import { homedir, totalmem, freemem, loadavg, cpus } from 'node:os'; import { EventEmitter } from 'node:events'; import { Session, ClaudeMessage, type BackgroundTask } from '../session.js'; import { RespawnController, RespawnConfig, RespawnState } from '../respawn-controller.js'; @@ -44,6 +44,14 @@ interface ScheduledRun { // Batch terminal data for performance - collect for 16ms (60fps) before sending const TERMINAL_BATCH_INTERVAL = 16; +// Batch session:output events for 50ms +const OUTPUT_BATCH_INTERVAL = 50; +// Batch task:updated events for 100ms +const TASK_UPDATE_BATCH_INTERVAL = 100; +// Scheduled runs cleanup interval (check every 5 minutes) +const SCHEDULED_CLEANUP_INTERVAL = 5 * 60 * 1000; +// Completed scheduled runs max age (1 hour) +const SCHEDULED_RUN_MAX_AGE = 60 * 60 * 1000; export class WebServer extends EventEmitter { private app: FastifyInstance; @@ -58,6 +66,13 @@ export class WebServer extends EventEmitter { // Terminal batching for performance private terminalBatches: Map = new Map(); private terminalBatchTimer: NodeJS.Timeout | null = null; + // Scheduled runs cleanup timer + private scheduledCleanupTimer: NodeJS.Timeout | null = null; + // SSE event batching + private outputBatches: Map = new Map(); + private outputBatchTimer: NodeJS.Timeout | null = null; + private taskUpdateBatches: Map = new Map(); + private taskUpdateBatchTimer: NodeJS.Timeout | null = null; constructor(port: number = 3000) { super(); @@ -751,11 +766,46 @@ export class WebServer extends EventEmitter { this.screenManager.stopStatsCollection(); return { success: true }; }); + + // System stats endpoint for frontend header display + this.app.get('/api/system/stats', async () => { + return this.getSystemStats(); + }); + } + + // Get system CPU and memory usage + private getSystemStats(): { cpu: number; memory: { usedMB: number; totalMB: number; percent: number } } { + try { + // Memory stats + const totalMem = totalmem(); + const freeMem = freemem(); + const usedMem = totalMem - freeMem; + + // CPU load average (1 min) as percentage (rough approximation) + const load = loadavg()[0]; + const cpuCount = cpus().length; + const cpuPercent = Math.min(100, Math.round((load / cpuCount) * 100)); + + return { + cpu: cpuPercent, + memory: { + usedMB: Math.round(usedMem / (1024 * 1024)), + totalMB: Math.round(totalMem / (1024 * 1024)), + percent: Math.round((usedMem / totalMem) * 100) + } + }; + } catch { + return { + cpu: 0, + memory: { usedMB: 0, totalMB: 0, percent: 0 } + }; + } } private setupSessionListeners(session: Session): void { session.on('output', (data) => { - this.broadcast('session:output', { id: session.id, data }); + // Use batching for better performance at high throughput + this.batchOutputData(session.id, data); }); session.on('terminal', (data) => { @@ -804,7 +854,8 @@ export class WebServer extends EventEmitter { }); session.on('taskUpdated', (task: BackgroundTask) => { - this.broadcast('task:updated', { sessionId: session.id, task }); + // Use batching for better performance at high update rates + this.batchTaskUpdate(session.id, task); }); session.on('taskCompleted', (task: BackgroundTask) => { @@ -929,9 +980,10 @@ export class WebServer extends EventEmitter { }; while (Date.now() < run.endAt && run.status === 'running') { + let session: Session | null = null; try { // Create a session for this iteration - const session = new Session({ workingDir: run.workingDir }); + session = new Session({ workingDir: run.workingDir }); this.sessions.set(session.id, session); this.setupSessionListeners(session); run.sessionId = session.id; @@ -950,11 +1002,30 @@ export class WebServer extends EventEmitter { addLog(`Task completed. Cost: $${result.cost.toFixed(4)}. Total tasks: ${run.completedTasks}`); this.broadcast('scheduled:updated', run); + // Clean up the session after iteration to prevent memory leaks + await session.stop(); + this.sessions.delete(session.id); + this.terminalBatches.delete(session.id); + run.sessionId = null; + // Small pause between iterations await new Promise(r => setTimeout(r, 2000)); } catch (err) { addLog(`Error: ${(err as Error).message}`); this.broadcast('scheduled:updated', run); + + // Clean up the session on error too + if (session) { + try { + await session.stop(); + this.sessions.delete(session.id); + this.terminalBatches.delete(session.id); + } catch { + // Ignore cleanup errors + } + run.sessionId = null; + } + // Continue despite errors await new Promise(r => setTimeout(r, 5000)); } @@ -989,6 +1060,31 @@ export class WebServer extends EventEmitter { return Array.from(this.sessions.values()).map(s => s.toDetailedState()); } + // Clean up old completed scheduled runs + private cleanupScheduledRuns(): void { + const now = Date.now(); + const toDelete: string[] = []; + + for (const [id, run] of this.scheduledRuns) { + // Only clean up completed, failed, or stopped runs + if (run.status !== 'running') { + const age = now - (run.endAt || run.startedAt); + if (age > SCHEDULED_RUN_MAX_AGE) { + toDelete.push(id); + } + } + } + + for (const id of toDelete) { + this.scheduledRuns.delete(id); + this.broadcast('scheduled:deleted', { id }); + } + + if (toDelete.length > 0) { + console.log(`[Server] Cleaned up ${toDelete.length} old scheduled run(s)`); + } + } + private getFullState() { // Build respawn status map const respawnStatus: Record> = {}; @@ -1041,11 +1137,57 @@ export class WebServer extends EventEmitter { this.terminalBatches.clear(); } + // Batch session:output events at 50ms for better performance + private batchOutputData(sessionId: string, data: string): void { + const existing = this.outputBatches.get(sessionId) || ''; + this.outputBatches.set(sessionId, existing + data); + + if (!this.outputBatchTimer) { + this.outputBatchTimer = setTimeout(() => { + this.flushOutputBatches(); + this.outputBatchTimer = null; + }, OUTPUT_BATCH_INTERVAL); + } + } + + private flushOutputBatches(): void { + for (const [sessionId, data] of this.outputBatches) { + if (data.length > 0) { + this.broadcast('session:output', { id: sessionId, data }); + } + } + this.outputBatches.clear(); + } + + // Batch task:updated events at 100ms - only send latest update per session + private batchTaskUpdate(sessionId: string, task: BackgroundTask): void { + this.taskUpdateBatches.set(sessionId, task); + + if (!this.taskUpdateBatchTimer) { + this.taskUpdateBatchTimer = setTimeout(() => { + this.flushTaskUpdateBatches(); + this.taskUpdateBatchTimer = null; + }, TASK_UPDATE_BATCH_INTERVAL); + } + } + + private flushTaskUpdateBatches(): void { + for (const [sessionId, task] of this.taskUpdateBatches) { + this.broadcast('task:updated', { sessionId, task }); + } + this.taskUpdateBatches.clear(); + } + async start(): Promise { await this.setupRoutes(); await this.app.listen({ port: this.port, host: '0.0.0.0' }); console.log(`Claudeman web interface running at http://localhost:${this.port}`); + // Start scheduled runs cleanup timer + this.scheduledCleanupTimer = setInterval(() => { + this.cleanupScheduledRuns(); + }, SCHEDULED_CLEANUP_INTERVAL); + // Restore screen sessions from previous run await this.restoreScreenSessions(); } @@ -1093,13 +1235,31 @@ export class WebServer extends EventEmitter { } async stop(): Promise { - // Clear batch timer + // Clear batch timers if (this.terminalBatchTimer) { clearTimeout(this.terminalBatchTimer); this.terminalBatchTimer = null; } this.terminalBatches.clear(); + if (this.outputBatchTimer) { + clearTimeout(this.outputBatchTimer); + this.outputBatchTimer = null; + } + this.outputBatches.clear(); + + if (this.taskUpdateBatchTimer) { + clearTimeout(this.taskUpdateBatchTimer); + this.taskUpdateBatchTimer = null; + } + this.taskUpdateBatches.clear(); + + // Clear scheduled cleanup timer + if (this.scheduledCleanupTimer) { + clearInterval(this.scheduledCleanupTimer); + this.scheduledCleanupTimer = null; + } + // Stop screen stats collection this.screenManager.stopStatsCollection();