mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 14:39:42 +02:00
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:
@@ -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') {
|
||||
|
||||
@@ -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()">×</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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user