mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 21:49:42 +02:00
Claude sessions spawned by Claudeman now receive environment variables: - CLAUDEMAN_SCREEN=1 - Indicates running within Claudeman - CLAUDEMAN_SESSION_ID - The session's unique identifier - CLAUDEMAN_SCREEN_NAME - The GNU Screen session name (when applicable) This helps prevent Claude from attempting to kill its own screen session and allows sessions to be aware of their managed environment. Also updated the default CLAUDE.md template to document this behavior. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
554 lines
17 KiB
TypeScript
554 lines
17 KiB
TypeScript
/**
|
|
* @fileoverview GNU Screen session manager for persistent Claude sessions.
|
|
*
|
|
* This module provides the ScreenManager class which creates and manages
|
|
* GNU Screen sessions that wrap Claude CLI processes. Screen provides:
|
|
*
|
|
* - **Persistence**: Sessions survive server restarts and disconnects
|
|
* - **Ghost recovery**: Orphaned screens are discovered and reattached on startup
|
|
* - **Resource tracking**: Memory, CPU, and child process stats per session
|
|
* - **Reliable input**: `screen -X stuff` bypasses PTY for programmatic commands
|
|
*
|
|
* Screen sessions are named `claudeman-{sessionId}` and stored in ~/.claudeman/screens.json.
|
|
*
|
|
* @module screen-manager
|
|
*/
|
|
|
|
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';
|
|
|
|
/** Path to persisted screen session metadata */
|
|
const SCREENS_FILE = join(homedir(), '.claudeman', 'screens.json');
|
|
|
|
/** Pre-compiled regex for parsing `screen -ls` output */
|
|
const SCREEN_PATTERN = /(\d+)\.(claudeman-([a-f0-9-]+))/g;
|
|
|
|
/**
|
|
* Manages GNU Screen sessions that wrap Claude CLI or shell processes.
|
|
*
|
|
* The ScreenManager maintains a registry of screen sessions, creates new ones,
|
|
* kills them using a 4-strategy approach, and discovers orphaned "ghost" screens
|
|
* from previous runs.
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const manager = new ScreenManager();
|
|
*
|
|
* // Create a screen session for Claude
|
|
* const screen = await manager.createScreen(sessionId, '/project', 'claude');
|
|
*
|
|
* // Send input to the screen
|
|
* manager.sendInput(sessionId, '/clear\r');
|
|
*
|
|
* // Kill when done
|
|
* await manager.killScreen(sessionId);
|
|
* ```
|
|
*
|
|
* @fires ScreenManager#screenCreated - New screen session created
|
|
* @fires ScreenManager#screenKilled - Screen session terminated
|
|
*/
|
|
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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a new GNU Screen session wrapping Claude CLI or a shell.
|
|
*
|
|
* The screen is created in detached mode and automatically starts the
|
|
* appropriate command based on the mode parameter.
|
|
*
|
|
* @param sessionId - Unique session identifier (used in screen name)
|
|
* @param workingDir - Working directory for the screen session
|
|
* @param mode - 'claude' for Claude CLI or 'shell' for bash
|
|
* @param name - Optional display name for the session
|
|
* @returns The created screen session metadata
|
|
* @throws {Error} If screen creation fails
|
|
*/
|
|
async createScreen(sessionId: string, workingDir: string, mode: 'claude' | 'shell', name?: string): Promise<ScreenSession> {
|
|
const screenName = `claudeman-${sessionId.slice(0, 8)}`;
|
|
|
|
// Create screen in detached mode with the appropriate command
|
|
// Set CLAUDEMAN_SCREEN=1 so Claude sessions know they're running in Claudeman
|
|
// This helps prevent Claude from attempting to kill its own screen session
|
|
const envVars = `CLAUDEMAN_SCREEN=1 CLAUDEMAN_SESSION_ID=${sessionId} CLAUDEMAN_SCREEN_NAME=${screenName}`;
|
|
const cmd = mode === 'claude'
|
|
? `${envVars} claude --dangerously-skip-permissions`
|
|
: `${envVars} $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,
|
|
name
|
|
};
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
// Get all child process PIDs recursively
|
|
private getChildPids(pid: number): number[] {
|
|
const pids: number[] = [];
|
|
try {
|
|
const output = execSync(`pgrep -P ${pid}`, {
|
|
encoding: 'utf-8',
|
|
timeout: 5000
|
|
}).trim();
|
|
if (output) {
|
|
for (const childPid of output.split('\n').map(p => parseInt(p, 10)).filter(p => !isNaN(p))) {
|
|
pids.push(childPid);
|
|
// Recursively get grandchildren
|
|
pids.push(...this.getChildPids(childPid));
|
|
}
|
|
}
|
|
} catch {
|
|
// No children or command failed
|
|
}
|
|
return pids;
|
|
}
|
|
|
|
// Kill a screen session and all its child processes
|
|
async killScreen(sessionId: string): Promise<boolean> {
|
|
const screen = this.screens.get(sessionId);
|
|
if (!screen) {
|
|
return false;
|
|
}
|
|
|
|
// Get current PID from screen -ls in case it changed
|
|
const currentPid = this.getScreenPid(screen.screenName) || screen.pid;
|
|
|
|
console.log(`[ScreenManager] Killing screen ${screen.screenName} (PID ${currentPid})`);
|
|
|
|
// Strategy 1: Find and kill all child processes recursively
|
|
const childPids = this.getChildPids(currentPid);
|
|
if (childPids.length > 0) {
|
|
console.log(`[ScreenManager] Found ${childPids.length} child processes to kill`);
|
|
|
|
// Kill children in reverse order (deepest first) with SIGTERM
|
|
for (const childPid of childPids.reverse()) {
|
|
try {
|
|
process.kill(childPid, 'SIGTERM');
|
|
} catch {
|
|
// Process may already be dead
|
|
}
|
|
}
|
|
|
|
// Give processes a moment to terminate gracefully
|
|
await new Promise(resolve => setTimeout(resolve, 200));
|
|
|
|
// Force kill any remaining children
|
|
for (const childPid of childPids) {
|
|
try {
|
|
process.kill(childPid, 'SIGKILL');
|
|
} catch {
|
|
// Process already terminated
|
|
}
|
|
}
|
|
}
|
|
|
|
// Strategy 2: Kill the entire process group (catches any orphans we missed)
|
|
try {
|
|
process.kill(-currentPid, 'SIGTERM');
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
process.kill(-currentPid, 'SIGKILL');
|
|
} catch {
|
|
// Process group may not exist or already terminated
|
|
}
|
|
|
|
// Strategy 3: Kill screen session by name
|
|
try {
|
|
execSync(`screen -S ${screen.screenName} -X quit`, {
|
|
timeout: 5000
|
|
});
|
|
} catch {
|
|
// Screen may already be dead
|
|
}
|
|
|
|
// Strategy 4: Direct kill by PID as final fallback
|
|
try {
|
|
process.kill(currentPid, 'SIGKILL');
|
|
} 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);
|
|
}
|
|
|
|
// Update screen display name
|
|
updateScreenName(sessionId: string, name: string): boolean {
|
|
const screen = this.screens.get(sessionId);
|
|
if (!screen) {
|
|
return false;
|
|
}
|
|
screen.name = name;
|
|
this.saveScreens();
|
|
return true;
|
|
}
|
|
|
|
// Reconcile screens - find orphaned/dead screens AND discover unknown claudeman screens
|
|
async reconcileScreens(): Promise<{ alive: string[]; dead: string[]; discovered: string[] }> {
|
|
const alive: string[] = [];
|
|
const dead: string[] = [];
|
|
const discovered: string[] = [];
|
|
|
|
// First, check known screens
|
|
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 });
|
|
}
|
|
}
|
|
|
|
// Second, discover unknown claudeman screens (prevents ghost screens)
|
|
try {
|
|
const output = execSync('screen -ls 2>/dev/null || true', {
|
|
encoding: 'utf-8',
|
|
timeout: 5000
|
|
});
|
|
// Match: "12345.claudeman-abc12345 (Detached)" or similar
|
|
// Reset lastIndex since we're reusing the global regex
|
|
SCREEN_PATTERN.lastIndex = 0;
|
|
let match;
|
|
while ((match = SCREEN_PATTERN.exec(output)) !== null) {
|
|
const pid = parseInt(match[1], 10);
|
|
const screenName = match[2];
|
|
const sessionIdFragment = match[3];
|
|
|
|
// Check if this screen is already known
|
|
let isKnown = false;
|
|
for (const screen of this.screens.values()) {
|
|
if (screen.screenName === screenName) {
|
|
isKnown = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!isKnown) {
|
|
// Discovered an unknown claudeman screen - adopt it
|
|
const sessionId = `restored-${sessionIdFragment}`;
|
|
const screen: ScreenSession = {
|
|
sessionId,
|
|
screenName,
|
|
pid,
|
|
createdAt: Date.now(),
|
|
workingDir: process.cwd(), // Unknown, use current dir
|
|
mode: 'claude', // Assume claude mode
|
|
attached: false,
|
|
name: `Restored: ${screenName}`
|
|
};
|
|
this.screens.set(sessionId, screen);
|
|
discovered.push(sessionId);
|
|
console.log(`[ScreenManager] Discovered unknown screen: ${screenName} (PID ${pid})`);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('[ScreenManager] Failed to discover screens:', err);
|
|
}
|
|
|
|
if (dead.length > 0 || discovered.length > 0) {
|
|
this.saveScreens();
|
|
}
|
|
|
|
return { alive, dead, discovered };
|
|
}
|
|
|
|
// 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 (batched for better performance)
|
|
async getScreensWithStats(): Promise<ScreenSessionWithStats[]> {
|
|
const screens = Array.from(this.screens.values());
|
|
if (screens.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
// Batch all PIDs into a single ps call for better performance
|
|
const pids = screens.map(s => s.pid);
|
|
const statsMap = new Map<number, ProcessStats>();
|
|
|
|
try {
|
|
// Single ps call for all PIDs
|
|
const psOutput = execSync(
|
|
`ps -o pid=,rss=,pcpu= -p ${pids.join(',')} 2>/dev/null || true`,
|
|
{ encoding: 'utf-8', timeout: 5000 }
|
|
).trim();
|
|
|
|
// Parse output - each line: "PID RSS CPU"
|
|
for (const line of psOutput.split('\n')) {
|
|
const parts = line.trim().split(/\s+/);
|
|
if (parts.length >= 3) {
|
|
const pid = parseInt(parts[0], 10);
|
|
const rss = parseFloat(parts[1]) || 0;
|
|
const cpu = parseFloat(parts[2]) || 0;
|
|
if (!isNaN(pid)) {
|
|
statsMap.set(pid, {
|
|
memoryMB: Math.round(rss / 1024 * 10) / 10,
|
|
cpuPercent: Math.round(cpu * 10) / 10,
|
|
childCount: 0,
|
|
updatedAt: Date.now()
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Batch child count query - single pgrep call
|
|
const pgrepOutput = execSync(
|
|
`for p in ${pids.join(' ')}; do echo "$p $(pgrep -P $p 2>/dev/null | wc -l)"; done`,
|
|
{ encoding: 'utf-8', timeout: 5000 }
|
|
).trim();
|
|
|
|
for (const line of pgrepOutput.split('\n')) {
|
|
const [pidStr, countStr] = line.trim().split(/\s+/);
|
|
const pid = parseInt(pidStr, 10);
|
|
const count = parseInt(countStr, 10) || 0;
|
|
const stats = statsMap.get(pid);
|
|
if (stats) {
|
|
stats.childCount = count;
|
|
}
|
|
}
|
|
} catch {
|
|
// Fall back to individual queries if batch fails
|
|
const statsPromises = screens.map(screen => this.getProcessStats(screen.sessionId));
|
|
const allStats = await Promise.all(statsPromises);
|
|
return screens.map((screen, i) => ({
|
|
...screen,
|
|
stats: allStats[i] || undefined
|
|
}));
|
|
}
|
|
|
|
// Combine screens with their stats
|
|
return screens.map(screen => ({
|
|
...screen,
|
|
stats: statsMap.get(screen.pid) || undefined
|
|
}));
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
// Send input directly to screen session using screen -X stuff
|
|
// This bypasses the attached PTY and sends input directly to the screen
|
|
sendInput(sessionId: string, input: string): boolean {
|
|
const screen = this.screens.get(sessionId);
|
|
if (!screen) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
// Split input into text and control characters
|
|
// IMPORTANT: Must send text and carriage return as SEPARATE commands
|
|
// Sending them together doesn't work with Ink/Claude CLI
|
|
const hasCarriageReturn = input.includes('\r');
|
|
const textPart = input.replace(/\r/g, '').replace(/\n/g, '');
|
|
|
|
// Escape the text part for shell (double quotes)
|
|
const escapedText = textPart.replace(/"/g, '\\"').replace(/\$/g, '\\$').replace(/`/g, '\\`');
|
|
|
|
// Send text first (if any)
|
|
if (escapedText) {
|
|
const textCmd = `screen -S ${screen.screenName} -p 0 -X stuff "${escapedText}"`;
|
|
execSync(textCmd, { encoding: 'utf-8', timeout: 5000 });
|
|
}
|
|
|
|
// Send carriage return separately (Enter key for Ink)
|
|
if (hasCarriageReturn) {
|
|
const crCmd = `screen -S ${screen.screenName} -p 0 -X stuff "$(printf '\\015')"`;
|
|
execSync(crCmd, { encoding: 'utf-8', timeout: 5000 });
|
|
}
|
|
|
|
return true;
|
|
} catch (err) {
|
|
console.error('[ScreenManager] Failed to send input:', err);
|
|
return false;
|
|
}
|
|
}
|
|
}
|