mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
perf: optimize for long-running Claude sessions
Critical fixes (unbounded growth): - Add 64KB line buffer limit with 100ms periodic flush in session.ts - Add task tracker cleanup (max 100 completed tasks) - Add scheduled runs auto-cleanup after 1 hour - Fix session leaks in scheduled run iterations High priority (performance): - Add SSE event batching (50ms output, 100ms task updates) - Parallelize screen stats with Promise.all() - Add 1MB buffer limit to respawn controller Medium priority (frontend): - Make xterm scrollback configurable (default 5000) - Debounce renderSessionTabs() at 100ms New feature: - Add CPU and memory usage display in frontend header - New /api/system/stats endpoint Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
- Terminal buffer: 5MB max, trims to 4MB when exceeded
|
||||||
- Text output: 2MB max, trims to 1.5MB when exceeded
|
- Text output: 2MB max, trims to 1.5MB when exceeded
|
||||||
- Messages: 1000 max, keeps most recent 800 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:**
|
**Performance Optimizations:**
|
||||||
- Server-side terminal batching at 60fps (16ms intervals)
|
- 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
|
- 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
|
- 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:**
|
**Buffer Stats Response:**
|
||||||
```typescript
|
```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/start` - Start resource monitoring
|
||||||
- `POST /api/screens/stats/stop` - Stop 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:**
|
**Other:**
|
||||||
- `GET /api/events` - SSE stream for real-time updates
|
- `GET /api/events` - SSE stream for real-time updates
|
||||||
- `GET /api/status` - Full state snapshot
|
- `GET /api/status` - Full state snapshot
|
||||||
@@ -277,14 +291,16 @@ npx agent-browser close
|
|||||||
## Frontend
|
## Frontend
|
||||||
|
|
||||||
The web UI (`src/web/public/`) uses vanilla JavaScript with:
|
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
|
- **xterm-addon-fit**: Auto-resize terminal to container
|
||||||
- **Server-Sent Events**: Real-time updates from `/api/events`
|
- **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
|
- **No build step**: Static files served directly by Fastify
|
||||||
|
|
||||||
Key files:
|
Key files:
|
||||||
- `app.js` - Main application logic, SSE handling, session management
|
- `app.js` - Main application logic, SSE handling, session management
|
||||||
- `index.html` - Single page with embedded styles
|
- `index.html` - Single page with embedded styles
|
||||||
|
- `styles.css` - All CSS styles
|
||||||
- Libraries loaded from CDN (xterm.js, addons)
|
- Libraries loaded from CDN (xterm.js, addons)
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import { Session } from './session.js';
|
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
|
* Respawn sequence states
|
||||||
*
|
*
|
||||||
@@ -182,9 +187,9 @@ export class RespawnController extends EventEmitter {
|
|||||||
private handleTerminalData(data: string): void {
|
private handleTerminalData(data: string): void {
|
||||||
this.terminalBuffer += data;
|
this.terminalBuffer += data;
|
||||||
|
|
||||||
// Keep buffer manageable (last 10KB)
|
// Keep buffer manageable (max 1MB, trim to 512KB)
|
||||||
if (this.terminalBuffer.length > 10240) {
|
if (this.terminalBuffer.length > MAX_RESPAWN_BUFFER_SIZE) {
|
||||||
this.terminalBuffer = this.terminalBuffer.slice(-5120);
|
this.terminalBuffer = this.terminalBuffer.slice(-RESPAWN_BUFFER_TRIM_SIZE);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter out noise - only count meaningful data as activity
|
// Filter out noise - only count meaningful data as activity
|
||||||
|
|||||||
+10
-7
@@ -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<ScreenSessionWithStats[]> {
|
async getScreensWithStats(): Promise<ScreenSessionWithStats[]> {
|
||||||
const result: ScreenSessionWithStats[] = [];
|
const screens = Array.from(this.screens.values());
|
||||||
|
|
||||||
for (const screen of this.screens.values()) {
|
// Fetch all stats in parallel
|
||||||
const stats = await this.getProcessStats(screen.sessionId);
|
const statsPromises = screens.map(screen => this.getProcessStats(screen.sessionId));
|
||||||
result.push({ ...screen, stats: stats || undefined });
|
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
|
// Start periodic stats collection
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ const MAX_TEXT_OUTPUT_SIZE = 2 * 1024 * 1024;
|
|||||||
const TEXT_OUTPUT_TRIM_SIZE = 1.5 * 1024 * 1024;
|
const TEXT_OUTPUT_TRIM_SIZE = 1.5 * 1024 * 1024;
|
||||||
// Maximum number of Claude messages to keep in memory
|
// Maximum number of Claude messages to keep in memory
|
||||||
const MAX_MESSAGES = 1000;
|
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)
|
// Filter out terminal focus escape sequences (focus in/out reports)
|
||||||
// ^[[I (focus in), ^[[O (focus out), and the enable/disable sequences
|
// ^[[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 _totalCost: number = 0;
|
||||||
private _messages: ClaudeMessage[] = [];
|
private _messages: ClaudeMessage[] = [];
|
||||||
private _lineBuffer: string = '';
|
private _lineBuffer: string = '';
|
||||||
|
private _lineBufferFlushTimer: NodeJS.Timeout | null = null;
|
||||||
private resolvePromise: ((value: { result: string; cost: number }) => void) | null = null;
|
private resolvePromise: ((value: { result: string; cost: number }) => void) | null = null;
|
||||||
private rejectPromise: ((reason: Error) => void) | null = null;
|
private rejectPromise: ((reason: Error) => void) | null = null;
|
||||||
private _isWorking: boolean = false;
|
private _isWorking: boolean = false;
|
||||||
@@ -605,9 +610,35 @@ export class Session extends EventEmitter {
|
|||||||
private processOutput(data: string): void {
|
private processOutput(data: string): void {
|
||||||
// Try to extract JSON from output (Claude may output JSON in stream mode)
|
// Try to extract JSON from output (Claude may output JSON in stream mode)
|
||||||
this._lineBuffer += data;
|
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');
|
const lines = this._lineBuffer.split('\n');
|
||||||
this._lineBuffer = lines.pop() || '';
|
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) {
|
for (const line of lines) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
// Remove ANSI escape codes for JSON parsing
|
// Remove ANSI escape codes for JSON parsing
|
||||||
@@ -771,6 +802,12 @@ export class Session extends EventEmitter {
|
|||||||
this.activityTimeout = null;
|
this.activityTimeout = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear line buffer flush timer
|
||||||
|
if (this._lineBufferFlushTimer) {
|
||||||
|
clearTimeout(this._lineBufferFlushTimer);
|
||||||
|
this._lineBufferFlushTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.ptyProcess) {
|
if (this.ptyProcess) {
|
||||||
const pid = this.ptyProcess.pid;
|
const pid = this.ptyProcess.pid;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { EventEmitter } from 'node:events';
|
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
|
* Represents a background task spawned by Claude Code
|
||||||
*/
|
*/
|
||||||
@@ -159,12 +162,54 @@ export class TaskTracker extends EventEmitter {
|
|||||||
} else {
|
} else {
|
||||||
this.emit('taskCompleted', task);
|
this.emit('taskCompleted', task);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up old completed tasks to prevent unbounded growth
|
||||||
|
this.cleanupCompletedTasks();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up pending
|
// Clean up pending
|
||||||
this.pendingToolUses.delete(toolUseId);
|
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 {
|
private createTaskFromTerminal(agentType: string, context: string): void {
|
||||||
const taskId = `terminal-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
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;
|
const parentId = this.taskStack.length > 0 ? this.taskStack[this.taskStack.length - 1] : null;
|
||||||
|
|||||||
+83
-3
@@ -1,4 +1,7 @@
|
|||||||
// Claudeman App - Tab-based Terminal UI
|
// Claudeman App - Tab-based Terminal UI
|
||||||
|
// Default terminal scrollback (can be changed via settings)
|
||||||
|
const DEFAULT_SCROLLBACK = 5000;
|
||||||
|
|
||||||
class ClaudemanApp {
|
class ClaudemanApp {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.sessions = new Map();
|
this.sessions = new Map();
|
||||||
@@ -20,6 +23,12 @@ class ClaudemanApp {
|
|||||||
this.pendingWrites = '';
|
this.pendingWrites = '';
|
||||||
this.writeFrameScheduled = false;
|
this.writeFrameScheduled = false;
|
||||||
|
|
||||||
|
// Render debouncing
|
||||||
|
this.renderSessionTabsTimeout = null;
|
||||||
|
|
||||||
|
// System stats polling
|
||||||
|
this.systemStatsInterval = null;
|
||||||
|
|
||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,9 +41,14 @@ class ClaudemanApp {
|
|||||||
this.setupEventListeners();
|
this.setupEventListeners();
|
||||||
// Show monitor panel by default
|
// Show monitor panel by default
|
||||||
this.toggleMonitorPanel();
|
this.toggleMonitorPanel();
|
||||||
|
// Start system stats polling
|
||||||
|
this.startSystemStatsPolling();
|
||||||
}
|
}
|
||||||
|
|
||||||
initTerminal() {
|
initTerminal() {
|
||||||
|
// Load scrollback setting from localStorage (default 5000)
|
||||||
|
const scrollback = parseInt(localStorage.getItem('claudeman-scrollback')) || DEFAULT_SCROLLBACK;
|
||||||
|
|
||||||
this.terminal = new Terminal({
|
this.terminal = new Terminal({
|
||||||
theme: {
|
theme: {
|
||||||
background: '#0d0d0d',
|
background: '#0d0d0d',
|
||||||
@@ -64,7 +78,7 @@ class ClaudemanApp {
|
|||||||
lineHeight: 1.2,
|
lineHeight: 1.2,
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
cursorStyle: 'block',
|
cursorStyle: 'block',
|
||||||
scrollback: 10000,
|
scrollback: scrollback,
|
||||||
allowTransparency: true,
|
allowTransparency: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -479,6 +493,16 @@ class ClaudemanApp {
|
|||||||
// ========== Session Tabs ==========
|
// ========== Session Tabs ==========
|
||||||
|
|
||||||
renderSessionTabs() {
|
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');
|
const container = document.getElementById('sessionTabs');
|
||||||
|
|
||||||
// Build tabs HTML
|
// Build tabs HTML
|
||||||
@@ -528,9 +552,17 @@ class ClaudemanApp {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/sessions/${sessionId}/terminal`);
|
const res = await fetch(`/api/sessions/${sessionId}/terminal`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
this.terminal.clear();
|
this.terminal.reset();
|
||||||
if (data.terminalBuffer) {
|
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
|
// Send resize
|
||||||
@@ -1583,6 +1615,54 @@ class ClaudemanApp {
|
|||||||
}, 3000);
|
}, 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 ==========
|
// ========== Utility ==========
|
||||||
|
|
||||||
escapeHtml(text) {
|
escapeHtml(text) {
|
||||||
|
|||||||
@@ -35,6 +35,10 @@
|
|||||||
<span class="status-text">Connecting</span>
|
<span class="status-text">Connecting</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-tokens" id="headerTokens" title="Total tokens across all sessions">0 tokens</div>
|
<div class="header-tokens" id="headerTokens" title="Total tokens across all sessions">0 tokens</div>
|
||||||
|
<div class="header-system-stats" id="headerSystemStats" title="System resource usage">
|
||||||
|
<span class="stat-cpu" id="statCpu">CPU: --</span>
|
||||||
|
<span class="stat-mem" id="statMem">Mem: --</span>
|
||||||
|
</div>
|
||||||
<button class="btn-icon-header" onclick="app.openAppSettings()" title="App Settings">⚙</button>
|
<button class="btn-icon-header" onclick="app.openAppSettings()" title="App Settings">⚙</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -187,6 +187,26 @@ body {
|
|||||||
font-family: 'SF Mono', Monaco, monospace;
|
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 {
|
.btn-icon-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
+165
-5
@@ -3,7 +3,7 @@ import fastifyStatic from '@fastify/static';
|
|||||||
import { join, dirname } from 'node:path';
|
import { join, dirname } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync } from 'node:fs';
|
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 { EventEmitter } from 'node:events';
|
||||||
import { Session, ClaudeMessage, type BackgroundTask } from '../session.js';
|
import { Session, ClaudeMessage, type BackgroundTask } from '../session.js';
|
||||||
import { RespawnController, RespawnConfig, RespawnState } from '../respawn-controller.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
|
// Batch terminal data for performance - collect for 16ms (60fps) before sending
|
||||||
const TERMINAL_BATCH_INTERVAL = 16;
|
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 {
|
export class WebServer extends EventEmitter {
|
||||||
private app: FastifyInstance;
|
private app: FastifyInstance;
|
||||||
@@ -58,6 +66,13 @@ export class WebServer extends EventEmitter {
|
|||||||
// Terminal batching for performance
|
// Terminal batching for performance
|
||||||
private terminalBatches: Map<string, string> = new Map();
|
private terminalBatches: Map<string, string> = new Map();
|
||||||
private terminalBatchTimer: NodeJS.Timeout | null = null;
|
private terminalBatchTimer: NodeJS.Timeout | null = null;
|
||||||
|
// Scheduled runs cleanup timer
|
||||||
|
private scheduledCleanupTimer: NodeJS.Timeout | null = null;
|
||||||
|
// SSE event batching
|
||||||
|
private outputBatches: Map<string, string> = new Map();
|
||||||
|
private outputBatchTimer: NodeJS.Timeout | null = null;
|
||||||
|
private taskUpdateBatches: Map<string, BackgroundTask> = new Map();
|
||||||
|
private taskUpdateBatchTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
constructor(port: number = 3000) {
|
constructor(port: number = 3000) {
|
||||||
super();
|
super();
|
||||||
@@ -751,11 +766,46 @@ export class WebServer extends EventEmitter {
|
|||||||
this.screenManager.stopStatsCollection();
|
this.screenManager.stopStatsCollection();
|
||||||
return { success: true };
|
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 {
|
private setupSessionListeners(session: Session): void {
|
||||||
session.on('output', (data) => {
|
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) => {
|
session.on('terminal', (data) => {
|
||||||
@@ -804,7 +854,8 @@ export class WebServer extends EventEmitter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
session.on('taskUpdated', (task: BackgroundTask) => {
|
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) => {
|
session.on('taskCompleted', (task: BackgroundTask) => {
|
||||||
@@ -929,9 +980,10 @@ export class WebServer extends EventEmitter {
|
|||||||
};
|
};
|
||||||
|
|
||||||
while (Date.now() < run.endAt && run.status === 'running') {
|
while (Date.now() < run.endAt && run.status === 'running') {
|
||||||
|
let session: Session | null = null;
|
||||||
try {
|
try {
|
||||||
// Create a session for this iteration
|
// 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.sessions.set(session.id, session);
|
||||||
this.setupSessionListeners(session);
|
this.setupSessionListeners(session);
|
||||||
run.sessionId = session.id;
|
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}`);
|
addLog(`Task completed. Cost: $${result.cost.toFixed(4)}. Total tasks: ${run.completedTasks}`);
|
||||||
this.broadcast('scheduled:updated', run);
|
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
|
// Small pause between iterations
|
||||||
await new Promise(r => setTimeout(r, 2000));
|
await new Promise(r => setTimeout(r, 2000));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
addLog(`Error: ${(err as Error).message}`);
|
addLog(`Error: ${(err as Error).message}`);
|
||||||
this.broadcast('scheduled:updated', run);
|
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
|
// Continue despite errors
|
||||||
await new Promise(r => setTimeout(r, 5000));
|
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());
|
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() {
|
private getFullState() {
|
||||||
// Build respawn status map
|
// Build respawn status map
|
||||||
const respawnStatus: Record<string, ReturnType<RespawnController['getStatus']>> = {};
|
const respawnStatus: Record<string, ReturnType<RespawnController['getStatus']>> = {};
|
||||||
@@ -1041,11 +1137,57 @@ export class WebServer extends EventEmitter {
|
|||||||
this.terminalBatches.clear();
|
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<void> {
|
async start(): Promise<void> {
|
||||||
await this.setupRoutes();
|
await this.setupRoutes();
|
||||||
await this.app.listen({ port: this.port, host: '0.0.0.0' });
|
await this.app.listen({ port: this.port, host: '0.0.0.0' });
|
||||||
console.log(`Claudeman web interface running at http://localhost:${this.port}`);
|
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
|
// Restore screen sessions from previous run
|
||||||
await this.restoreScreenSessions();
|
await this.restoreScreenSessions();
|
||||||
}
|
}
|
||||||
@@ -1093,13 +1235,31 @@ export class WebServer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async stop(): Promise<void> {
|
async stop(): Promise<void> {
|
||||||
// Clear batch timer
|
// Clear batch timers
|
||||||
if (this.terminalBatchTimer) {
|
if (this.terminalBatchTimer) {
|
||||||
clearTimeout(this.terminalBatchTimer);
|
clearTimeout(this.terminalBatchTimer);
|
||||||
this.terminalBatchTimer = null;
|
this.terminalBatchTimer = null;
|
||||||
}
|
}
|
||||||
this.terminalBatches.clear();
|
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
|
// Stop screen stats collection
|
||||||
this.screenManager.stopStatsCollection();
|
this.screenManager.stopStatsCollection();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user