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 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-19 02:18:51 +01:00
co-authored by Claude Opus 4.5
parent b30657a36f
commit 56c9f670e6
6 changed files with 606 additions and 0 deletions
+275
View File
@@ -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<string, ScreenSession> = 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<ScreenSession> {
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<boolean> {
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<ProcessStats | null> {
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<ScreenSessionWithStats[]> {
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;
}
}
}