From 56c9f670e69db790fe27766076ff5fedfa3be1df Mon Sep 17 00:00:00 2001 From: arkon Date: Mon, 19 Jan 2026 02:18:51 +0100 Subject: [PATCH] feat: add GNU screen wrapping with process monitor Add screen session management infrastructure: - ScreenManager class to create, track, and kill GNU screen sessions - Persistence of screen sessions to ~/.claudeman/screens.json - Process stats collection (memory, CPU, child count) - Reconciliation to detect dead/orphan screens New API endpoints: - GET /api/screens - list screens with stats - DELETE /api/screens/:sessionId - kill screen - POST /api/screens/reconcile - find dead screens - POST /api/screens/stats/start|stop - control stats polling New Process Monitor panel: - Slide-up panel with real-time stats (2s updates) - Shows memory, CPU, child processes per screen - Kill button for each screen session - Reconcile button to clean up dead screens Co-Authored-By: Claude Opus 4.5 --- src/screen-manager.ts | 275 ++++++++++++++++++++++++++++++++++++++ src/types.ts | 22 +++ src/web/public/app.js | 120 +++++++++++++++++ src/web/public/index.html | 15 +++ src/web/public/styles.css | 118 ++++++++++++++++ src/web/server.ts | 56 ++++++++ 6 files changed, 606 insertions(+) create mode 100644 src/screen-manager.ts diff --git a/src/screen-manager.ts b/src/screen-manager.ts new file mode 100644 index 00000000..2f6f17c5 --- /dev/null +++ b/src/screen-manager.ts @@ -0,0 +1,275 @@ +import { EventEmitter } from 'node:events'; +import { spawn, execSync } from 'node:child_process'; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { homedir } from 'node:os'; +import { ScreenSession, ProcessStats, ScreenSessionWithStats } from './types.js'; + +const SCREENS_FILE = join(homedir(), '.claudeman', 'screens.json'); + +export class ScreenManager extends EventEmitter { + private screens: Map = new Map(); + private statsInterval: NodeJS.Timeout | null = null; + + constructor() { + super(); + this.loadScreens(); + } + + // Load saved screens from disk + private loadScreens(): void { + try { + if (existsSync(SCREENS_FILE)) { + const content = readFileSync(SCREENS_FILE, 'utf-8'); + const data = JSON.parse(content); + if (Array.isArray(data)) { + for (const screen of data) { + this.screens.set(screen.sessionId, screen); + } + } + } + } catch (err) { + console.error('[ScreenManager] Failed to load screens:', err); + } + } + + // Save screens to disk + private saveScreens(): void { + try { + const dir = dirname(SCREENS_FILE); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + const data = Array.from(this.screens.values()); + writeFileSync(SCREENS_FILE, JSON.stringify(data, null, 2)); + } catch (err) { + console.error('[ScreenManager] Failed to save screens:', err); + } + } + + // Create a new GNU screen session + async createScreen(sessionId: string, workingDir: string, mode: 'claude' | 'shell'): Promise { + const screenName = `claudeman-${sessionId.slice(0, 8)}`; + + // Create screen in detached mode with the appropriate command + const cmd = mode === 'claude' ? 'claude --dangerously-skip-permissions' : '$SHELL'; + + try { + // Start screen in detached mode + const screenProcess = spawn('screen', [ + '-dmS', screenName, + '-c', '/dev/null', // Use empty config + 'bash', '-c', `cd "${workingDir}" && ${cmd}` + ], { + cwd: workingDir, + detached: true, + stdio: 'ignore' + }); + + screenProcess.unref(); + + // Wait a moment for screen to start + await new Promise(resolve => setTimeout(resolve, 500)); + + // Get the PID of the screen session + const pid = this.getScreenPid(screenName); + if (!pid) { + throw new Error('Failed to get screen PID'); + } + + const screen: ScreenSession = { + sessionId, + screenName, + pid, + createdAt: Date.now(), + workingDir, + mode, + attached: false + }; + + this.screens.set(sessionId, screen); + this.saveScreens(); + this.emit('screenCreated', screen); + + return screen; + } catch (err) { + throw new Error(`Failed to create screen: ${(err as Error).message}`); + } + } + + // Get screen session PID + private getScreenPid(screenName: string): number | null { + try { + const output = execSync(`screen -ls | grep "${screenName}"`, { + encoding: 'utf-8', + timeout: 5000 + }); + // Output format: "12345.claudeman-abc12345 (Detached)" + const match = output.match(/(\d+)\./); + return match ? parseInt(match[1], 10) : null; + } catch { + return null; + } + } + + // Kill a screen session + async killScreen(sessionId: string): Promise { + const screen = this.screens.get(sessionId); + if (!screen) { + return false; + } + + try { + // Kill screen session by name + execSync(`screen -S ${screen.screenName} -X quit`, { + timeout: 5000 + }); + } catch { + // Try killing by PID if name-based kill failed + try { + process.kill(screen.pid, 'SIGTERM'); + } catch { + // Already dead + } + } + + this.screens.delete(sessionId); + this.saveScreens(); + this.emit('screenKilled', { sessionId }); + + return true; + } + + // Get all tracked screens + getScreens(): ScreenSession[] { + return Array.from(this.screens.values()); + } + + // Get screen by session ID + getScreen(sessionId: string): ScreenSession | undefined { + return this.screens.get(sessionId); + } + + // Reconcile screens - find orphaned/dead screens + async reconcileScreens(): Promise<{ alive: string[]; dead: string[] }> { + const alive: string[] = []; + const dead: string[] = []; + + for (const [sessionId, screen] of this.screens) { + const pid = this.getScreenPid(screen.screenName); + if (pid) { + alive.push(sessionId); + // Update PID if it changed + if (pid !== screen.pid) { + screen.pid = pid; + } + } else { + dead.push(sessionId); + this.screens.delete(sessionId); + this.emit('screenDied', { sessionId }); + } + } + + if (dead.length > 0) { + this.saveScreens(); + } + + return { alive, dead }; + } + + // Get process stats for a screen + async getProcessStats(sessionId: string): Promise { + const screen = this.screens.get(sessionId); + if (!screen) { + return null; + } + + try { + // Get memory and CPU usage using ps + const psOutput = execSync( + `ps -o rss=,pcpu= -p ${screen.pid} 2>/dev/null || echo "0 0"`, + { encoding: 'utf-8', timeout: 5000 } + ).trim(); + + const [rss, cpu] = psOutput.split(/\s+/).map(x => parseFloat(x) || 0); + + // Count child processes + let childCount = 0; + try { + const childOutput = execSync( + `pgrep -P ${screen.pid} | wc -l`, + { encoding: 'utf-8', timeout: 5000 } + ).trim(); + childCount = parseInt(childOutput, 10) || 0; + } catch { + // No children or command failed + } + + return { + memoryMB: Math.round(rss / 1024 * 10) / 10, // KB to MB + cpuPercent: Math.round(cpu * 10) / 10, + childCount, + updatedAt: Date.now() + }; + } catch { + return null; + } + } + + // Get all screens with stats + async getScreensWithStats(): Promise { + const result: ScreenSessionWithStats[] = []; + + for (const screen of this.screens.values()) { + const stats = await this.getProcessStats(screen.sessionId); + result.push({ ...screen, stats: stats || undefined }); + } + + return result; + } + + // Start periodic stats collection + startStatsCollection(intervalMs: number = 2000): void { + if (this.statsInterval) { + clearInterval(this.statsInterval); + } + + this.statsInterval = setInterval(async () => { + const screensWithStats = await this.getScreensWithStats(); + this.emit('statsUpdated', screensWithStats); + }, intervalMs); + } + + // Stop stats collection + stopStatsCollection(): void { + if (this.statsInterval) { + clearInterval(this.statsInterval); + this.statsInterval = null; + } + } + + // Register a session as using screen (for when session creates its own screen) + registerScreen(screen: ScreenSession): void { + this.screens.set(screen.sessionId, screen); + this.saveScreens(); + } + + // Mark screen as attached/detached + setAttached(sessionId: string, attached: boolean): void { + const screen = this.screens.get(sessionId); + if (screen) { + screen.attached = attached; + this.saveScreens(); + } + } + + // Check if screen is available on the system + static isScreenAvailable(): boolean { + try { + execSync('which screen', { encoding: 'utf-8', timeout: 5000 }); + return true; + } catch { + return false; + } + } +} diff --git a/src/types.ts b/src/types.ts index fed43c37..852016c4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -205,6 +205,28 @@ export interface CaseInfo { hasClaudeMd?: boolean; } +// Screen session types for GNU screen wrapping +export interface ScreenSession { + sessionId: string; // Claudeman session ID + screenName: string; // GNU screen session name + pid: number; // Screen process PID + createdAt: number; + workingDir: string; + mode: 'claude' | 'shell'; + attached: boolean; // Whether webserver is attached +} + +export interface ProcessStats { + memoryMB: number; // Memory usage in MB + cpuPercent: number; // CPU usage percentage + childCount: number; // Number of child processes + updatedAt: number; +} + +export interface ScreenSessionWithStats extends ScreenSession { + stats?: ProcessStats; +} + export const DEFAULT_CONFIG: AppConfig = { pollIntervalMs: 1000, defaultTimeoutMs: 300000, // 5 minutes diff --git a/src/web/public/app.js b/src/web/public/app.js index 788b0939..a29de972 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -14,6 +14,7 @@ class ClaudemanApp { this.terminalBuffers = new Map(); // Store terminal content per session this.editingSessionId = null; // Session being edited in options modal this.pendingCloseSessionId = null; // Session pending close confirmation + this.screenSessions = []; // Screen sessions for process monitor // Terminal write batching this.pendingWrites = ''; @@ -405,6 +406,33 @@ class ClaudemanApp { this.renderTaskPanel(); } }); + + // Screen events + this.eventSource.addEventListener('screen:created', (e) => { + const screen = JSON.parse(e.data); + this.screenSessions.push(screen); + this.renderProcessPanel(); + }); + + this.eventSource.addEventListener('screen:killed', (e) => { + const data = JSON.parse(e.data); + this.screenSessions = this.screenSessions.filter(s => s.sessionId !== data.sessionId); + this.renderProcessPanel(); + }); + + this.eventSource.addEventListener('screen:died', (e) => { + const data = JSON.parse(e.data); + this.screenSessions = this.screenSessions.filter(s => s.sessionId !== data.sessionId); + this.renderProcessPanel(); + this.showToast('Screen session died: ' + data.sessionId.slice(0, 8), 'warning'); + }); + + this.eventSource.addEventListener('screen:statsUpdated', (e) => { + this.screenSessions = JSON.parse(e.data); + if (document.getElementById('processPanel').classList.contains('open')) { + this.renderProcessPanel(); + } + }); } setConnectionStatus(status) { @@ -1413,6 +1441,7 @@ class ClaudemanApp { document.getElementById('respawnPanel').classList.remove('open'); document.getElementById('newSessionPanel').classList.remove('open'); document.getElementById('taskPanel').classList.remove('open'); + document.getElementById('processPanel').classList.remove('open'); } // ========== Task Panel ========== @@ -1497,6 +1526,97 @@ class ClaudemanApp { return result; } + // ========== Process Panel ========== + + async toggleProcessPanel() { + const panel = document.getElementById('processPanel'); + panel.classList.toggle('open'); + + if (panel.classList.contains('open')) { + // Load screens and start stats collection + await this.loadScreens(); + await fetch('/api/screens/stats/start', { method: 'POST' }); + } else { + // Stop stats collection when panel is closed + await fetch('/api/screens/stats/stop', { method: 'POST' }); + } + } + + async loadScreens() { + try { + const res = await fetch('/api/screens'); + const data = await res.json(); + this.screenSessions = data.screens || []; + this.renderProcessPanel(); + } catch (err) { + console.error('Failed to load screens:', err); + } + } + + renderProcessPanel() { + const body = document.getElementById('processPanelBody'); + + if (!this.screenSessions || this.screenSessions.length === 0) { + body.innerHTML = '
No screen sessions tracked
'; + return; + } + + let html = ''; + for (const screen of this.screenSessions) { + const stats = screen.stats || { memoryMB: 0, cpuPercent: 0, childCount: 0 }; + const modeClass = screen.mode === 'shell' ? 'shell' : ''; + + html += ` +
+ ${screen.mode} +
+
${this.escapeHtml(screen.screenName)}
+
+ ${stats.memoryMB}MB + ${stats.cpuPercent}% + ${stats.childCount} children + PID: ${screen.pid} +
+
+
+ +
+
+ `; + } + + body.innerHTML = html; + } + + async killScreen(sessionId) { + if (!confirm('Kill this screen session?')) return; + + try { + await fetch(`/api/screens/${sessionId}`, { method: 'DELETE' }); + this.screenSessions = this.screenSessions.filter(s => s.sessionId !== sessionId); + this.renderProcessPanel(); + this.showToast('Screen killed', 'success'); + } catch (err) { + this.showToast('Failed to kill screen', 'error'); + } + } + + async reconcileScreens() { + try { + const res = await fetch('/api/screens/reconcile', { method: 'POST' }); + const data = await res.json(); + + if (data.dead && data.dead.length > 0) { + this.showToast(`Found ${data.dead.length} dead screen(s)`, 'warning'); + await this.loadScreens(); + } else { + this.showToast('All screens are alive', 'success'); + } + } catch (err) { + this.showToast('Failed to reconcile screens', 'error'); + } + } + // ========== Toast ========== showToast(message, type = 'info') { diff --git a/src/web/public/index.html b/src/web/public/index.html index dfa2a5ab..ca928594 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -98,6 +98,7 @@ +
@@ -226,6 +227,20 @@ + +
+
+ Process Monitor +
+ + +
+
+
+
No screen sessions tracked
+
+
+
diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 8cb4c701..19d4af98 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -1118,6 +1118,124 @@ kbd { background: var(--border-light); } +/* Process Monitor Panel */ +.process-panel { + position: fixed; + bottom: var(--toolbar-height); + left: 0.5rem; + width: 400px; + max-width: calc(100vw - 1rem); + max-height: 50vh; + background: var(--bg-card); + border: 1px solid var(--border); + border-bottom: none; + border-radius: 8px 8px 0 0; + box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.4); + z-index: 100; + transform: translateY(100%); + transition: transform 0.2s ease; + display: flex; + flex-direction: column; +} + +.process-panel.open { + transform: translateY(0); +} + +.process-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.6rem 1rem; + border-bottom: 1px solid var(--border); + font-size: 0.8rem; + font-weight: 500; + flex-shrink: 0; +} + +.process-panel-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.process-panel-body { + flex: 1; + overflow-y: auto; + padding: 0.5rem; +} + +.process-empty { + padding: 1rem; + text-align: center; + color: var(--text-muted); + font-size: 0.8rem; +} + +.process-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.6rem 0.75rem; + background: var(--bg-input); + border-radius: 4px; + margin-bottom: 0.5rem; +} + +.process-item:hover { + background: var(--bg-hover); +} + +.process-info { + flex: 1; + min-width: 0; +} + +.process-name { + font-size: 0.8rem; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.process-meta { + display: flex; + gap: 0.75rem; + font-size: 0.7rem; + color: var(--text-muted); + margin-top: 0.2rem; +} + +.process-stat { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.process-stat.memory { color: var(--accent-hover); } +.process-stat.cpu { color: var(--green); } +.process-stat.children { color: var(--yellow); } + +.process-actions { + display: flex; + gap: 0.25rem; +} + +.process-mode { + font-size: 0.6rem; + padding: 2px 6px; + border-radius: 3px; + background: var(--bg-hover); + color: var(--text-muted); + text-transform: uppercase; +} + +.process-mode.shell { + background: rgba(234, 179, 8, 0.2); + color: var(--yellow); +} + /* Responsive */ @media (max-width: 600px) { .header-brand { diff --git a/src/web/server.ts b/src/web/server.ts index fb727dc5..b9312e22 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -7,6 +7,7 @@ import { homedir } from 'node:os'; import { EventEmitter } from 'node:events'; import { Session, ClaudeMessage, type BackgroundTask } from '../session.js'; import { RespawnController, RespawnConfig, RespawnState } from '../respawn-controller.js'; +import { ScreenManager } from '../screen-manager.js'; import { getStore } from '../state-store.js'; import { generateClaudeMd } from '../templates/claude-md.js'; import { v4 as uuidv4 } from 'uuid'; @@ -53,6 +54,7 @@ export class WebServer extends EventEmitter { private sseClients: Set = new Set(); private store = getStore(); private port: number; + private screenManager: ScreenManager; // Terminal batching for performance private terminalBatches: Map = new Map(); private terminalBatchTimer: NodeJS.Timeout | null = null; @@ -61,6 +63,21 @@ export class WebServer extends EventEmitter { super(); this.port = port; this.app = Fastify({ logger: false }); + this.screenManager = new ScreenManager(); + + // Set up screen manager event listeners + this.screenManager.on('screenCreated', (screen) => { + this.broadcast('screen:created', screen); + }); + this.screenManager.on('screenKilled', (data) => { + this.broadcast('screen:killed', data); + }); + this.screenManager.on('screenDied', (data) => { + this.broadcast('screen:died', data); + }); + this.screenManager.on('statsUpdated', (screens) => { + this.broadcast('screen:statsUpdated', screens); + }); } private async setupRoutes(): Promise { @@ -684,6 +701,42 @@ export class WebServer extends EventEmitter { return { error: (err as Error).message }; } }); + + // ============ Screen Management Endpoints ============ + + // Get all tracked screens with stats + this.app.get('/api/screens', async () => { + const screens = await this.screenManager.getScreensWithStats(); + return { + screens, + screenAvailable: ScreenManager.isScreenAvailable() + }; + }); + + // Kill a screen session + this.app.delete('/api/screens/:sessionId', async (req) => { + const { sessionId } = req.params as { sessionId: string }; + const success = await this.screenManager.killScreen(sessionId); + return { success }; + }); + + // Reconcile screens (find dead ones) + this.app.post('/api/screens/reconcile', async () => { + const result = await this.screenManager.reconcileScreens(); + return result; + }); + + // Start stats collection + this.app.post('/api/screens/stats/start', async () => { + this.screenManager.startStatsCollection(2000); + return { success: true }; + }); + + // Stop stats collection + this.app.post('/api/screens/stats/stop', async () => { + this.screenManager.stopStatsCollection(); + return { success: true }; + }); } private setupSessionListeners(session: Session): void { @@ -988,6 +1041,9 @@ export class WebServer extends EventEmitter { } this.terminalBatches.clear(); + // Stop screen stats collection + this.screenManager.stopStatsCollection(); + // Stop all respawn controllers for (const controller of this.respawnControllers.values()) { controller.stop();