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
+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();