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;
}
}
}
+22
View File
@@ -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
+120
View File
@@ -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 = '<div class="process-empty">No screen sessions tracked</div>';
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 += `
<div class="process-item">
<span class="process-mode ${modeClass}">${screen.mode}</span>
<div class="process-info">
<div class="process-name">${this.escapeHtml(screen.screenName)}</div>
<div class="process-meta">
<span class="process-stat memory">${stats.memoryMB}MB</span>
<span class="process-stat cpu">${stats.cpuPercent}%</span>
<span class="process-stat children">${stats.childCount} children</span>
<span>PID: ${screen.pid}</span>
</div>
</div>
<div class="process-actions">
<button class="btn-toolbar btn-sm btn-danger" onclick="app.killScreen('${screen.sessionId}')" title="Kill screen">Kill</button>
</div>
</div>
`;
}
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') {
+15
View File
@@ -98,6 +98,7 @@
<button class="btn-toolbar" onclick="app.copyTerminal()" title="Copy terminal">Copy</button>
<button class="btn-toolbar" onclick="app.clearTerminal()" title="Clear terminal (Ctrl+L)">Clear</button>
<button class="btn-toolbar" onclick="app.toggleProcessPanel()" title="Process Monitor">Processes</button>
<div class="toolbar-divider"></div>
@@ -226,6 +227,20 @@
</div>
</div>
<!-- Process Monitor Panel (slide-up) -->
<div class="process-panel" id="processPanel">
<div class="process-panel-header">
<span>Process Monitor</span>
<div class="process-panel-actions">
<button class="btn-toolbar btn-sm" onclick="app.reconcileScreens()" title="Check for dead screens">Reconcile</button>
<button class="btn-close" onclick="app.toggleProcessPanel()">&times;</button>
</div>
</div>
<div class="process-panel-body" id="processPanelBody">
<div class="process-empty">No screen sessions tracked</div>
</div>
</div>
<!-- Task Tree Panel (slide-up) -->
<div class="task-panel" id="taskPanel">
<div class="task-panel-header">
+118
View File
@@ -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 {
+56
View File
@@ -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<FastifyReply> = new Set();
private store = getStore();
private port: number;
private screenManager: ScreenManager;
// Terminal batching for performance
private terminalBatches: Map<string, string> = 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<void> {
@@ -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();