mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-07 16:09:43 +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:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -205,6 +205,28 @@ export interface CaseInfo {
|
|||||||
hasClaudeMd?: boolean;
|
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 = {
|
export const DEFAULT_CONFIG: AppConfig = {
|
||||||
pollIntervalMs: 1000,
|
pollIntervalMs: 1000,
|
||||||
defaultTimeoutMs: 300000, // 5 minutes
|
defaultTimeoutMs: 300000, // 5 minutes
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class ClaudemanApp {
|
|||||||
this.terminalBuffers = new Map(); // Store terminal content per session
|
this.terminalBuffers = new Map(); // Store terminal content per session
|
||||||
this.editingSessionId = null; // Session being edited in options modal
|
this.editingSessionId = null; // Session being edited in options modal
|
||||||
this.pendingCloseSessionId = null; // Session pending close confirmation
|
this.pendingCloseSessionId = null; // Session pending close confirmation
|
||||||
|
this.screenSessions = []; // Screen sessions for process monitor
|
||||||
|
|
||||||
// Terminal write batching
|
// Terminal write batching
|
||||||
this.pendingWrites = '';
|
this.pendingWrites = '';
|
||||||
@@ -405,6 +406,33 @@ class ClaudemanApp {
|
|||||||
this.renderTaskPanel();
|
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) {
|
setConnectionStatus(status) {
|
||||||
@@ -1413,6 +1441,7 @@ class ClaudemanApp {
|
|||||||
document.getElementById('respawnPanel').classList.remove('open');
|
document.getElementById('respawnPanel').classList.remove('open');
|
||||||
document.getElementById('newSessionPanel').classList.remove('open');
|
document.getElementById('newSessionPanel').classList.remove('open');
|
||||||
document.getElementById('taskPanel').classList.remove('open');
|
document.getElementById('taskPanel').classList.remove('open');
|
||||||
|
document.getElementById('processPanel').classList.remove('open');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Task Panel ==========
|
// ========== Task Panel ==========
|
||||||
@@ -1497,6 +1526,97 @@ class ClaudemanApp {
|
|||||||
return result;
|
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 ==========
|
// ========== Toast ==========
|
||||||
|
|
||||||
showToast(message, type = 'info') {
|
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.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.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>
|
<div class="toolbar-divider"></div>
|
||||||
|
|
||||||
@@ -226,6 +227,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</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) -->
|
<!-- Task Tree Panel (slide-up) -->
|
||||||
<div class="task-panel" id="taskPanel">
|
<div class="task-panel" id="taskPanel">
|
||||||
<div class="task-panel-header">
|
<div class="task-panel-header">
|
||||||
|
|||||||
@@ -1118,6 +1118,124 @@ kbd {
|
|||||||
background: var(--border-light);
|
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 */
|
/* Responsive */
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.header-brand {
|
.header-brand {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { homedir } from 'node:os';
|
|||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import { Session, ClaudeMessage, type BackgroundTask } from '../session.js';
|
import { Session, ClaudeMessage, type BackgroundTask } from '../session.js';
|
||||||
import { RespawnController, RespawnConfig, RespawnState } from '../respawn-controller.js';
|
import { RespawnController, RespawnConfig, RespawnState } from '../respawn-controller.js';
|
||||||
|
import { ScreenManager } from '../screen-manager.js';
|
||||||
import { getStore } from '../state-store.js';
|
import { getStore } from '../state-store.js';
|
||||||
import { generateClaudeMd } from '../templates/claude-md.js';
|
import { generateClaudeMd } from '../templates/claude-md.js';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
@@ -53,6 +54,7 @@ export class WebServer extends EventEmitter {
|
|||||||
private sseClients: Set<FastifyReply> = new Set();
|
private sseClients: Set<FastifyReply> = new Set();
|
||||||
private store = getStore();
|
private store = getStore();
|
||||||
private port: number;
|
private port: number;
|
||||||
|
private screenManager: ScreenManager;
|
||||||
// Terminal batching for performance
|
// Terminal batching for performance
|
||||||
private terminalBatches: Map<string, string> = new Map();
|
private terminalBatches: Map<string, string> = new Map();
|
||||||
private terminalBatchTimer: NodeJS.Timeout | null = null;
|
private terminalBatchTimer: NodeJS.Timeout | null = null;
|
||||||
@@ -61,6 +63,21 @@ export class WebServer extends EventEmitter {
|
|||||||
super();
|
super();
|
||||||
this.port = port;
|
this.port = port;
|
||||||
this.app = Fastify({ logger: false });
|
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> {
|
private async setupRoutes(): Promise<void> {
|
||||||
@@ -684,6 +701,42 @@ export class WebServer extends EventEmitter {
|
|||||||
return { error: (err as Error).message };
|
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 {
|
private setupSessionListeners(session: Session): void {
|
||||||
@@ -988,6 +1041,9 @@ export class WebServer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
this.terminalBatches.clear();
|
this.terminalBatches.clear();
|
||||||
|
|
||||||
|
// Stop screen stats collection
|
||||||
|
this.screenManager.stopStatsCollection();
|
||||||
|
|
||||||
// Stop all respawn controllers
|
// Stop all respawn controllers
|
||||||
for (const controller of this.respawnControllers.values()) {
|
for (const controller of this.respawnControllers.values()) {
|
||||||
controller.stop();
|
controller.stop();
|
||||||
|
|||||||
Reference in New Issue
Block a user