mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 22:49:41 +02:00
chore: bump version to 0.1481
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @fileoverview Factory for creating the appropriate terminal multiplexer.
|
||||
*
|
||||
* Auto-detects tmux vs GNU Screen at startup. Prefers tmux.
|
||||
* Set `CLAUDEMAN_MUX=screen` to force GNU Screen backend.
|
||||
*
|
||||
* @module mux-factory
|
||||
*/
|
||||
|
||||
import type { TerminalMultiplexer } from './mux-interface.js';
|
||||
import { ScreenManager } from './screen-manager.js';
|
||||
import { TmuxManager } from './tmux-manager.js';
|
||||
|
||||
/**
|
||||
* Create a TerminalMultiplexer instance based on availability and preference.
|
||||
*
|
||||
* Detection order:
|
||||
* 1. If `forced` parameter is set, use that backend
|
||||
* 2. If `CLAUDEMAN_MUX` env var is set, use that backend
|
||||
* 3. If tmux is available, use tmux (preferred)
|
||||
* 4. If screen is available, use screen (deprecated fallback)
|
||||
* 5. Throw error with install instructions
|
||||
*/
|
||||
export function createMultiplexer(forced?: 'tmux' | 'screen'): TerminalMultiplexer {
|
||||
const preference = forced || process.env.CLAUDEMAN_MUX;
|
||||
|
||||
if (preference === 'screen') {
|
||||
if (!ScreenManager.isScreenAvailable()) {
|
||||
throw new Error('GNU Screen requested via CLAUDEMAN_MUX=screen but not found. Install: sudo apt install screen');
|
||||
}
|
||||
console.log('[MuxFactory] Using GNU Screen backend (forced via CLAUDEMAN_MUX)');
|
||||
return new ScreenManager();
|
||||
}
|
||||
|
||||
if (preference === 'tmux') {
|
||||
if (!TmuxManager.isTmuxAvailable()) {
|
||||
throw new Error('tmux requested via CLAUDEMAN_MUX=tmux but not found. Install: sudo apt install tmux');
|
||||
}
|
||||
console.log('[MuxFactory] Using tmux backend (forced via CLAUDEMAN_MUX)');
|
||||
return new TmuxManager();
|
||||
}
|
||||
|
||||
// Auto-detect: prefer tmux
|
||||
if (TmuxManager.isTmuxAvailable()) {
|
||||
console.log('[MuxFactory] Using tmux backend (auto-detected)');
|
||||
return new TmuxManager();
|
||||
}
|
||||
|
||||
if (ScreenManager.isScreenAvailable()) {
|
||||
console.warn('[MuxFactory] Using GNU Screen backend (tmux not found). Consider installing tmux for better performance: sudo apt install tmux');
|
||||
return new ScreenManager();
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'No terminal multiplexer found. Install one of:\n' +
|
||||
' - tmux (recommended): sudo apt install tmux\n' +
|
||||
' - GNU Screen (legacy): sudo apt install screen'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* @fileoverview Terminal multiplexer abstraction layer.
|
||||
*
|
||||
* Defines the TerminalMultiplexer interface that both ScreenManager (GNU Screen)
|
||||
* and TmuxManager (tmux) implement. This allows the rest of the codebase to work
|
||||
* with either backend transparently.
|
||||
*
|
||||
* The MuxSession type is the backend-agnostic equivalent of ScreenSession.
|
||||
*
|
||||
* @module mux-interface
|
||||
*/
|
||||
|
||||
import type { EventEmitter } from 'node:events';
|
||||
import type { ProcessStats, PersistedRespawnConfig, NiceConfig } from './types.js';
|
||||
|
||||
/**
|
||||
* Backend-agnostic multiplexer session.
|
||||
* Equivalent to ScreenSession but uses `muxName` instead of `screenName`.
|
||||
*/
|
||||
export interface MuxSession {
|
||||
/** Claudeman session ID */
|
||||
sessionId: string;
|
||||
/** Multiplexer session name (e.g., "claudeman-abc12345") */
|
||||
muxName: string;
|
||||
/** Process PID */
|
||||
pid: number;
|
||||
/** Timestamp when created */
|
||||
createdAt: number;
|
||||
/** Working directory */
|
||||
workingDir: string;
|
||||
/** Session mode: claude or shell */
|
||||
mode: 'claude' | 'shell';
|
||||
/** Whether webserver is attached to this session */
|
||||
attached: boolean;
|
||||
/** Session display name (tab name) */
|
||||
name?: string;
|
||||
/** Persisted respawn controller configuration (restored on server restart) */
|
||||
respawnConfig?: PersistedRespawnConfig;
|
||||
/** Whether Ralph / Todo tracking is enabled */
|
||||
ralphEnabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* MuxSession with optional process resource statistics.
|
||||
*/
|
||||
export interface MuxSessionWithStats extends MuxSession {
|
||||
/** Optional resource statistics */
|
||||
stats?: ProcessStats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal multiplexer interface.
|
||||
*
|
||||
* Both ScreenManager and TmuxManager implement this interface,
|
||||
* allowing the rest of the codebase to work with either backend.
|
||||
*
|
||||
* Events emitted:
|
||||
* - `sessionCreated` (session: MuxSession) - New session created
|
||||
* - `sessionKilled` (data: { sessionId: string }) - Session terminated
|
||||
* - `sessionDied` (data: { sessionId: string }) - Session died unexpectedly
|
||||
* - `statsUpdated` (sessions: MuxSessionWithStats[]) - Stats refreshed
|
||||
*/
|
||||
export interface TerminalMultiplexer extends EventEmitter {
|
||||
/** Which backend this instance uses */
|
||||
readonly backend: 'tmux' | 'screen';
|
||||
|
||||
// ========== Lifecycle ==========
|
||||
|
||||
/**
|
||||
* Create a new multiplexer session.
|
||||
* The session runs the appropriate command (claude or shell) in detached mode.
|
||||
*/
|
||||
createSession(
|
||||
sessionId: string,
|
||||
workingDir: string,
|
||||
mode: 'claude' | 'shell',
|
||||
name?: string,
|
||||
niceConfig?: NiceConfig,
|
||||
): Promise<MuxSession>;
|
||||
|
||||
/**
|
||||
* Kill a session and all its child processes.
|
||||
* Uses a multi-strategy approach (children → process group → mux kill → SIGKILL).
|
||||
*/
|
||||
killSession(sessionId: string): Promise<boolean>;
|
||||
|
||||
/** Clean up resources (stop stats collection, etc.) */
|
||||
destroy(): void;
|
||||
|
||||
// ========== Queries ==========
|
||||
|
||||
/** Get all tracked sessions */
|
||||
getSessions(): MuxSession[];
|
||||
|
||||
/** Get a session by Claudeman session ID */
|
||||
getSession(sessionId: string): MuxSession | undefined;
|
||||
|
||||
/** Get all sessions with process resource statistics */
|
||||
getSessionsWithStats(): Promise<MuxSessionWithStats[]>;
|
||||
|
||||
/** Get process stats for a single session */
|
||||
getProcessStats(sessionId: string): Promise<ProcessStats | null>;
|
||||
|
||||
// ========== Input ==========
|
||||
|
||||
/**
|
||||
* Send input to a session.
|
||||
* tmux: `send-keys -l 'text' Enter` (single command, no delay)
|
||||
* screen: `stuff "text"` + 100ms delay + `stuff CR` (with retries)
|
||||
*/
|
||||
sendInput(sessionId: string, input: string): boolean;
|
||||
|
||||
// ========== Metadata ==========
|
||||
|
||||
/** Update the display name of a session */
|
||||
updateSessionName(sessionId: string, name: string): boolean;
|
||||
|
||||
/** Mark session as attached/detached */
|
||||
setAttached(sessionId: string, attached: boolean): void;
|
||||
|
||||
/** Register an externally-created session for tracking */
|
||||
registerSession(session: MuxSession): void;
|
||||
|
||||
/** Update persisted respawn config for a session */
|
||||
updateRespawnConfig(sessionId: string, config: PersistedRespawnConfig | undefined): void;
|
||||
|
||||
/** Clear respawn config when respawn is stopped */
|
||||
clearRespawnConfig(sessionId: string): void;
|
||||
|
||||
/** Update Ralph enabled state for a session */
|
||||
updateRalphEnabled(sessionId: string, enabled: boolean): void;
|
||||
|
||||
// ========== Discovery ==========
|
||||
|
||||
/**
|
||||
* Reconcile tracked sessions with actual running sessions.
|
||||
* Finds dead sessions and discovers unknown ones.
|
||||
*/
|
||||
reconcileSessions(): Promise<{ alive: string[]; dead: string[]; discovered: string[] }>;
|
||||
|
||||
// ========== Stats Collection ==========
|
||||
|
||||
/** Start periodic process stats collection */
|
||||
startStatsCollection(intervalMs?: number): void;
|
||||
|
||||
/** Stop periodic process stats collection */
|
||||
stopStatsCollection(): void;
|
||||
|
||||
// ========== PTY Attachment ==========
|
||||
|
||||
/**
|
||||
* Get the command to spawn for attaching to a session.
|
||||
* Returns 'tmux' or 'screen'.
|
||||
*/
|
||||
getAttachCommand(): string;
|
||||
|
||||
/**
|
||||
* Get the arguments for attaching to a session by mux name.
|
||||
* tmux: ['attach-session', '-t', muxName]
|
||||
* screen: ['-x', muxName]
|
||||
*/
|
||||
getAttachArgs(muxName: string): string[];
|
||||
|
||||
// ========== Availability ==========
|
||||
|
||||
/** Check if the multiplexer binary is available on the system */
|
||||
isAvailable(): boolean;
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
*/
|
||||
|
||||
import { Session } from './session.js';
|
||||
import { ScreenManager } from './screen-manager.js';
|
||||
import type { TerminalMultiplexer } from './mux-interface.js';
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
@@ -157,15 +157,15 @@ const MODEL = 'opus';
|
||||
// ============================================================================
|
||||
|
||||
export class PlanOrchestrator {
|
||||
private screenManager: ScreenManager;
|
||||
private mux: TerminalMultiplexer;
|
||||
private workingDir: string;
|
||||
private outputDir?: string;
|
||||
private runningSessions: Set<Session> = new Set();
|
||||
private cancelled = false;
|
||||
private taskDescription = '';
|
||||
|
||||
constructor(screenManager: ScreenManager, workingDir: string = process.cwd(), outputDir?: string) {
|
||||
this.screenManager = screenManager;
|
||||
constructor(mux: TerminalMultiplexer, workingDir: string = process.cwd(), outputDir?: string) {
|
||||
this.mux = mux;
|
||||
this.workingDir = workingDir;
|
||||
this.outputDir = outputDir;
|
||||
}
|
||||
@@ -390,7 +390,7 @@ export class PlanOrchestrator {
|
||||
|
||||
const session = new Session({
|
||||
workingDir: this.workingDir,
|
||||
screenManager: this.screenManager,
|
||||
screenManager: this.mux,
|
||||
useScreen: false,
|
||||
mode: 'claude',
|
||||
});
|
||||
@@ -472,7 +472,7 @@ export class PlanOrchestrator {
|
||||
|
||||
const session = new Session({
|
||||
workingDir: this.workingDir,
|
||||
screenManager: this.screenManager,
|
||||
screenManager: this.mux,
|
||||
useScreen: false,
|
||||
mode: 'claude',
|
||||
});
|
||||
|
||||
+96
-1
@@ -20,6 +20,7 @@ import { existsSync, readFileSync, mkdirSync, writeFile } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { ScreenSession, ProcessStats, ScreenSessionWithStats, PersistedRespawnConfig, getErrorMessage, NiceConfig, DEFAULT_NICE_CONFIG } from './types.js';
|
||||
import type { TerminalMultiplexer, MuxSession, MuxSessionWithStats } from './mux-interface.js';
|
||||
|
||||
// ============================================================================
|
||||
// Claude CLI PATH Resolution
|
||||
@@ -181,7 +182,8 @@ function shellEscape(str: string): string {
|
||||
* @fires ScreenManager#screenCreated - New screen session created
|
||||
* @fires ScreenManager#screenKilled - Screen session terminated
|
||||
*/
|
||||
export class ScreenManager extends EventEmitter {
|
||||
export class ScreenManager extends EventEmitter implements TerminalMultiplexer {
|
||||
readonly backend = 'screen' as const;
|
||||
private screens: Map<string, ScreenSession> = new Map();
|
||||
private statsInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
@@ -903,4 +905,97 @@ export class ScreenManager extends EventEmitter {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// TerminalMultiplexer interface adapter methods
|
||||
// These delegate to existing Screen-specific methods, mapping between
|
||||
// MuxSession (muxName) and ScreenSession (screenName).
|
||||
// ========================================================================
|
||||
|
||||
private screenToMux(screen: ScreenSession): MuxSession {
|
||||
return {
|
||||
sessionId: screen.sessionId,
|
||||
muxName: screen.screenName,
|
||||
pid: screen.pid,
|
||||
createdAt: screen.createdAt,
|
||||
workingDir: screen.workingDir,
|
||||
mode: screen.mode,
|
||||
attached: screen.attached,
|
||||
name: screen.name,
|
||||
respawnConfig: screen.respawnConfig,
|
||||
ralphEnabled: screen.ralphEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
private screenWithStatsToMux(screen: ScreenSessionWithStats): MuxSessionWithStats {
|
||||
return {
|
||||
...this.screenToMux(screen),
|
||||
stats: screen.stats,
|
||||
};
|
||||
}
|
||||
|
||||
async createSession(
|
||||
sessionId: string,
|
||||
workingDir: string,
|
||||
mode: 'claude' | 'shell',
|
||||
name?: string,
|
||||
niceConfig?: NiceConfig,
|
||||
): Promise<MuxSession> {
|
||||
const screen = await this.createScreen(sessionId, workingDir, mode, name, niceConfig);
|
||||
return this.screenToMux(screen);
|
||||
}
|
||||
|
||||
async killSession(sessionId: string): Promise<boolean> {
|
||||
return this.killScreen(sessionId);
|
||||
}
|
||||
|
||||
getSessions(): MuxSession[] {
|
||||
return this.getScreens().map(s => this.screenToMux(s));
|
||||
}
|
||||
|
||||
getSession(sessionId: string): MuxSession | undefined {
|
||||
const screen = this.getScreen(sessionId);
|
||||
return screen ? this.screenToMux(screen) : undefined;
|
||||
}
|
||||
|
||||
async getSessionsWithStats(): Promise<MuxSessionWithStats[]> {
|
||||
const screens = await this.getScreensWithStats();
|
||||
return screens.map(s => this.screenWithStatsToMux(s));
|
||||
}
|
||||
|
||||
updateSessionName(sessionId: string, name: string): boolean {
|
||||
return this.updateScreenName(sessionId, name);
|
||||
}
|
||||
|
||||
registerSession(session: MuxSession): void {
|
||||
const screen: ScreenSession = {
|
||||
sessionId: session.sessionId,
|
||||
screenName: session.muxName,
|
||||
pid: session.pid,
|
||||
createdAt: session.createdAt,
|
||||
workingDir: session.workingDir,
|
||||
mode: session.mode,
|
||||
attached: session.attached,
|
||||
name: session.name,
|
||||
respawnConfig: session.respawnConfig,
|
||||
ralphEnabled: session.ralphEnabled,
|
||||
};
|
||||
this.registerScreen(screen);
|
||||
}
|
||||
|
||||
async reconcileSessions(): Promise<{ alive: string[]; dead: string[]; discovered: string[] }> {
|
||||
return this.reconcileScreens();
|
||||
}
|
||||
|
||||
getAttachCommand(): string {
|
||||
return 'screen';
|
||||
}
|
||||
|
||||
getAttachArgs(muxName: string): string[] {
|
||||
return ['-x', muxName];
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
return ScreenManager.isScreenAvailable();
|
||||
}
|
||||
}
|
||||
|
||||
+90
-74
@@ -21,11 +21,11 @@ import { existsSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import * as pty from 'node-pty';
|
||||
import { SessionState, SessionStatus, SessionConfig, ScreenSession, RalphTrackerState, RalphTodoItem, ActiveBashTool, NiceConfig, DEFAULT_NICE_CONFIG } from './types.js';
|
||||
import { SessionState, SessionStatus, SessionConfig, RalphTrackerState, RalphTodoItem, ActiveBashTool, NiceConfig, DEFAULT_NICE_CONFIG } from './types.js';
|
||||
import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
|
||||
import { TaskTracker, type BackgroundTask } from './task-tracker.js';
|
||||
import { RalphTracker } from './ralph-tracker.js';
|
||||
import { BashToolParser } from './bash-tool-parser.js';
|
||||
import { ScreenManager } from './screen-manager.js';
|
||||
import { BufferAccumulator } from './utils/buffer-accumulator.js';
|
||||
import { LRUMap } from './utils/lru-map.js';
|
||||
import {
|
||||
@@ -361,10 +361,10 @@ export class Session extends EventEmitter {
|
||||
private _promptCheckTimeout: NodeJS.Timeout | null = null;
|
||||
private _shellIdleTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
// Screen session support
|
||||
private _screenManager: ScreenManager | null = null;
|
||||
private _screenSession: ScreenSession | null = null;
|
||||
private _useScreen: boolean = false;
|
||||
// Multiplexer session support (tmux or GNU Screen)
|
||||
private _mux: TerminalMultiplexer | null = null;
|
||||
private _muxSession: MuxSession | null = null;
|
||||
private _useMux: boolean = false;
|
||||
// Flag to prevent new timers after session is stopped
|
||||
private _isStopped: boolean = false;
|
||||
|
||||
@@ -415,9 +415,18 @@ export class Session extends EventEmitter {
|
||||
workingDir: string;
|
||||
mode?: SessionMode;
|
||||
name?: string;
|
||||
screenManager?: ScreenManager;
|
||||
/** Terminal multiplexer instance (tmux or screen) */
|
||||
mux?: TerminalMultiplexer;
|
||||
/** Whether to use multiplexer wrapping */
|
||||
useMux?: boolean;
|
||||
/** Existing mux session for restored sessions */
|
||||
muxSession?: MuxSession;
|
||||
/** @deprecated Use `mux` instead */
|
||||
screenManager?: TerminalMultiplexer;
|
||||
/** @deprecated Use `useMux` instead */
|
||||
useScreen?: boolean;
|
||||
screenSession?: ScreenSession; // For restored sessions - pass the existing screen
|
||||
/** @deprecated Use `muxSession` instead */
|
||||
screenSession?: MuxSession;
|
||||
niceConfig?: NiceConfig; // Nice prioritying configuration
|
||||
}) {
|
||||
super();
|
||||
@@ -427,9 +436,10 @@ export class Session extends EventEmitter {
|
||||
this.mode = config.mode || 'claude';
|
||||
this._name = config.name || '';
|
||||
this._lastActivityAt = this.createdAt;
|
||||
this._screenManager = config.screenManager || null;
|
||||
this._useScreen = config.useScreen ?? (this._screenManager !== null && ScreenManager.isScreenAvailable());
|
||||
this._screenSession = config.screenSession || null; // Use existing screen if provided
|
||||
// Support both new (mux) and deprecated (screenManager) parameter names
|
||||
this._mux = config.mux || config.screenManager || null;
|
||||
this._useMux = config.useMux ?? config.useScreen ?? (this._mux !== null && this._mux.isAvailable());
|
||||
this._muxSession = config.muxSession || config.screenSession || null;
|
||||
|
||||
// Apply Nice priority configuration if provided
|
||||
if (config.niceConfig) {
|
||||
@@ -868,29 +878,30 @@ export class Session extends EventEmitter {
|
||||
this._lineBuffer = '';
|
||||
this._lastActivityAt = Date.now();
|
||||
|
||||
console.log('[Session] Starting interactive Claude session' + (this._useScreen ? ' (with screen)' : ''));
|
||||
console.log('[Session] Starting interactive Claude session' + (this._useMux ? ` (with ${this._mux!.backend})` : ''));
|
||||
|
||||
// If screen wrapping is enabled, create or attach to a screen session
|
||||
if (this._useScreen && this._screenManager) {
|
||||
// If mux wrapping is enabled, create or attach to a mux session
|
||||
if (this._useMux && this._mux) {
|
||||
try {
|
||||
// Check if we already have a screen session (restored session)
|
||||
const isRestoredSession = this._screenSession !== null;
|
||||
// Check if we already have a mux session (restored session)
|
||||
const isRestoredSession = this._muxSession !== null;
|
||||
if (isRestoredSession) {
|
||||
console.log('[Session] Attaching to existing screen session:', this._screenSession!.screenName);
|
||||
console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName);
|
||||
} else {
|
||||
// Create a new screen session
|
||||
this._screenSession = await this._screenManager.createScreen(this.id, this.workingDir, 'claude', this._name, this._niceConfig);
|
||||
console.log('[Session] Created screen session:', this._screenSession.screenName);
|
||||
// Create a new mux session
|
||||
this._muxSession = await this._mux.createSession(this.id, this.workingDir, 'claude', this._name, this._niceConfig);
|
||||
console.log('[Session] Created mux session:', this._muxSession.muxName);
|
||||
|
||||
// Wait a moment for screen to fully start
|
||||
// Wait a moment for mux to fully start
|
||||
await new Promise(resolve => setTimeout(resolve, SCREEN_STARTUP_DELAY_MS));
|
||||
}
|
||||
|
||||
// Attach to the screen session via PTY
|
||||
// Attach to the mux session via PTY
|
||||
try {
|
||||
this.ptyProcess = pty.spawn('screen', [
|
||||
'-x', this._screenSession!.screenName
|
||||
], {
|
||||
this.ptyProcess = pty.spawn(
|
||||
this._mux.getAttachCommand(),
|
||||
this._mux.getAttachArgs(this._muxSession!.muxName),
|
||||
{
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
@@ -899,7 +910,7 @@ export class Session extends EventEmitter {
|
||||
});
|
||||
|
||||
// Set claudeSessionId immediately since we passed --session-id to Claude
|
||||
// The screen-manager passes --session-id ${sessionId} to Claude
|
||||
// The mux manager passes --session-id ${sessionId} to Claude
|
||||
this._claudeSessionId = this.id;
|
||||
} catch (spawnErr) {
|
||||
console.error('[Session] Failed to spawn PTY for screen attachment:', spawnErr);
|
||||
@@ -942,13 +953,13 @@ export class Session extends EventEmitter {
|
||||
}, 5000);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Session] Failed to create screen session, falling back to direct PTY:', err);
|
||||
this._useScreen = false;
|
||||
this._screenSession = null;
|
||||
console.error('[Session] Failed to create mux session, falling back to direct PTY:', err);
|
||||
this._useMux = false;
|
||||
this._muxSession = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to direct PTY if screen is not used
|
||||
// Fallback to direct PTY if mux is not used
|
||||
if (!this.ptyProcess) {
|
||||
try {
|
||||
// Pass --session-id to use the SAME ID as the Claudeman session
|
||||
@@ -1084,9 +1095,9 @@ export class Session extends EventEmitter {
|
||||
clearTimeout(this._promptCheckTimeout);
|
||||
this._promptCheckTimeout = null;
|
||||
}
|
||||
// If using screen, mark the screen as detached but don't kill it
|
||||
if (this._screenSession && this._screenManager) {
|
||||
this._screenManager.setAttached(this.id, false);
|
||||
// If using mux, mark the session as detached but don't kill it
|
||||
if (this._muxSession && this._mux) {
|
||||
this._mux.setAttached(this.id, false);
|
||||
}
|
||||
this.emit('exit', exitCode);
|
||||
});
|
||||
@@ -1122,29 +1133,30 @@ export class Session extends EventEmitter {
|
||||
|
||||
// Use user's default shell or bash
|
||||
const shell = process.env.SHELL || '/bin/bash';
|
||||
console.log('[Session] Starting shell session with:', shell + (this._useScreen ? ' (with screen)' : ''));
|
||||
console.log('[Session] Starting shell session with:', shell + (this._useMux ? ` (with ${this._mux!.backend})` : ''));
|
||||
|
||||
// If screen wrapping is enabled, create or attach to a screen session
|
||||
if (this._useScreen && this._screenManager) {
|
||||
// If mux wrapping is enabled, create or attach to a mux session
|
||||
if (this._useMux && this._mux) {
|
||||
try {
|
||||
// Check if we already have a screen session (restored session)
|
||||
const isRestoredSession = this._screenSession !== null;
|
||||
// Check if we already have a mux session (restored session)
|
||||
const isRestoredSession = this._muxSession !== null;
|
||||
if (isRestoredSession) {
|
||||
console.log('[Session] Attaching to existing screen session:', this._screenSession!.screenName);
|
||||
console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName);
|
||||
} else {
|
||||
// Create a new screen session
|
||||
this._screenSession = await this._screenManager.createScreen(this.id, this.workingDir, 'shell', this._name, this._niceConfig);
|
||||
console.log('[Session] Created screen session:', this._screenSession.screenName);
|
||||
// Create a new mux session
|
||||
this._muxSession = await this._mux.createSession(this.id, this.workingDir, 'shell', this._name, this._niceConfig);
|
||||
console.log('[Session] Created mux session:', this._muxSession.muxName);
|
||||
|
||||
// Wait a moment for screen to fully start
|
||||
// Wait a moment for mux to fully start
|
||||
await new Promise(resolve => setTimeout(resolve, SCREEN_STARTUP_DELAY_MS));
|
||||
}
|
||||
|
||||
// Attach to the screen session via PTY
|
||||
// Attach to the mux session via PTY
|
||||
try {
|
||||
this.ptyProcess = pty.spawn('screen', [
|
||||
'-x', this._screenSession!.screenName
|
||||
], {
|
||||
this.ptyProcess = pty.spawn(
|
||||
this._mux.getAttachCommand(),
|
||||
this._mux.getAttachArgs(this._muxSession!.muxName),
|
||||
{
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
@@ -1152,13 +1164,13 @@ export class Session extends EventEmitter {
|
||||
env: { ...process.env, TERM: 'xterm-256color' },
|
||||
});
|
||||
} catch (spawnErr) {
|
||||
console.error('[Session] Failed to spawn PTY for shell screen attachment:', spawnErr);
|
||||
this.emit('error', `Failed to attach to screen: ${spawnErr}`);
|
||||
console.error('[Session] Failed to spawn PTY for shell mux attachment:', spawnErr);
|
||||
this.emit('error', `Failed to attach to mux session: ${spawnErr}`);
|
||||
throw spawnErr;
|
||||
}
|
||||
|
||||
// For NEW screens: clear by sending 'clear' command to the shell
|
||||
// For RESTORED screens: don't clear - we want to see the existing output
|
||||
// For NEW sessions: clear by sending 'clear' command to the shell
|
||||
// For RESTORED sessions: don't clear - we want to see the existing output
|
||||
if (!isRestoredSession) {
|
||||
setTimeout(() => {
|
||||
if (this.ptyProcess) {
|
||||
@@ -1168,13 +1180,13 @@ export class Session extends EventEmitter {
|
||||
}, 100);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Session] Failed to create screen session, falling back to direct PTY:', err);
|
||||
this._useScreen = false;
|
||||
this._screenSession = null;
|
||||
console.error('[Session] Failed to create mux session, falling back to direct PTY:', err);
|
||||
this._useMux = false;
|
||||
this._muxSession = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to direct PTY if screen is not used
|
||||
// Fallback to direct PTY if mux is not used
|
||||
if (!this.ptyProcess) {
|
||||
try {
|
||||
this.ptyProcess = pty.spawn(shell, [], {
|
||||
@@ -1228,9 +1240,9 @@ export class Session extends EventEmitter {
|
||||
clearTimeout(this.activityTimeout);
|
||||
this.activityTimeout = null;
|
||||
}
|
||||
// If using screen, mark the screen as detached but don't kill it
|
||||
if (this._screenSession && this._screenManager) {
|
||||
this._screenManager.setAttached(this.id, false);
|
||||
// If using mux, mark the session as detached but don't kill it
|
||||
if (this._muxSession && this._mux) {
|
||||
this._mux.setAttached(this.id, false);
|
||||
}
|
||||
this.emit('exit', exitCode);
|
||||
});
|
||||
@@ -1883,14 +1895,18 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends input via GNU Screen's `screen -X stuff` command.
|
||||
* Sends input via the terminal multiplexer's direct input mechanism.
|
||||
*
|
||||
* More reliable than direct PTY write for programmatic input, especially
|
||||
* with Claude CLI which uses Ink (React for terminals). Text and Enter
|
||||
* are sent as separate commands internally.
|
||||
* with Claude CLI which uses Ink (React for terminals).
|
||||
* - tmux: `send-keys -l 'text' Enter` (single command, no delay)
|
||||
* - screen: `stuff "text"` + 100ms delay + `stuff CR` (with retries)
|
||||
*
|
||||
* Method name kept as `writeViaScreen` for backward compatibility with
|
||||
* respawn-controller and other callers.
|
||||
*
|
||||
* @param data - Input data with optional `\r` for Enter
|
||||
* @returns true if input was sent, false if no screen session or PTY
|
||||
* @returns true if input was sent, false if no mux session or PTY
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
@@ -1899,8 +1915,8 @@ export class Session extends EventEmitter {
|
||||
* ```
|
||||
*/
|
||||
writeViaScreen(data: string): boolean {
|
||||
if (this._screenManager && this._screenSession) {
|
||||
return this._screenManager.sendInput(this.id, data);
|
||||
if (this._mux && this._muxSession) {
|
||||
return this._mux.sendInput(this.id, data);
|
||||
}
|
||||
// Fallback to PTY write
|
||||
if (this.ptyProcess) {
|
||||
@@ -2091,21 +2107,21 @@ export class Session extends EventEmitter {
|
||||
// Clear task description cache to prevent memory leak
|
||||
this._recentTaskDescriptions.clear();
|
||||
|
||||
// Kill the associated screen session if requested
|
||||
if (killScreen && this._screenManager) {
|
||||
// Try to kill screen even if _screenSession is not set (e.g., restored sessions)
|
||||
// Kill the associated mux session if requested
|
||||
if (killScreen && this._mux) {
|
||||
// Try to kill mux session even if _muxSession is not set (e.g., restored sessions)
|
||||
try {
|
||||
const killed = await this._screenManager.killScreen(this.id);
|
||||
const killed = await this._mux.killSession(this.id);
|
||||
if (killed) {
|
||||
console.log('[Session] Killed screen session for:', this.id);
|
||||
console.log('[Session] Killed mux session for:', this.id);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Session] Failed to kill screen session:', err);
|
||||
console.error('[Session] Failed to kill mux session:', err);
|
||||
}
|
||||
this._screenSession = null;
|
||||
} else if (this._screenSession && !killScreen) {
|
||||
console.log('[Session] Keeping screen session alive:', this._screenSession.screenName);
|
||||
this._screenSession = null; // Detach but don't kill
|
||||
this._muxSession = null;
|
||||
} else if (this._muxSession && !killScreen) {
|
||||
console.log('[Session] Keeping mux session alive:', this._muxSession.muxName);
|
||||
this._muxSession = null; // Detach but don't kill
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,888 @@
|
||||
/**
|
||||
* @fileoverview tmux session manager for persistent Claude sessions.
|
||||
*
|
||||
* This module provides the TmuxManager class which creates and manages
|
||||
* tmux sessions that wrap Claude CLI processes. tmux provides:
|
||||
*
|
||||
* - **Persistence**: Sessions survive server restarts and disconnects
|
||||
* - **Ghost recovery**: Orphaned sessions are discovered and reattached on startup
|
||||
* - **Resource tracking**: Memory, CPU, and child process stats per session
|
||||
* - **Reliable input**: `send-keys -l` sends literal text in a single command
|
||||
* - **Teammate support**: Immutable pane IDs enable targeting individual teammates
|
||||
*
|
||||
* tmux sessions are named `claudeman-{sessionId}` and stored in ~/.claudeman/mux-sessions.json.
|
||||
*
|
||||
* Key advantages over GNU Screen:
|
||||
* - `send-keys 'text' Enter` eliminates the text+CR split hack (no 100ms delay, no retries)
|
||||
* - `list-sessions -F` provides structured queries (no regex parsing)
|
||||
* - `display-message -p '#{pane_pid}'` for reliable PID discovery
|
||||
* - Single server architecture vs per-session processes
|
||||
*
|
||||
* @module tmux-manager
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { spawn, execSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, mkdirSync, writeFile } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { ProcessStats, PersistedRespawnConfig, getErrorMessage, NiceConfig, DEFAULT_NICE_CONFIG } from './types.js';
|
||||
import { wrapWithNice } from './screen-manager.js';
|
||||
import type { TerminalMultiplexer, MuxSession, MuxSessionWithStats } from './mux-interface.js';
|
||||
|
||||
// ============================================================================
|
||||
// Claude CLI PATH Resolution
|
||||
// ============================================================================
|
||||
|
||||
/** Common directories where the Claude CLI binary may be installed */
|
||||
const CLAUDE_SEARCH_DIRS = [
|
||||
`${homedir()}/.local/bin`,
|
||||
`${homedir()}/.claude/local`,
|
||||
'/usr/local/bin',
|
||||
`${homedir()}/.npm-global/bin`,
|
||||
`${homedir()}/bin`,
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// Timing Constants
|
||||
// ============================================================================
|
||||
|
||||
/** Timeout for exec commands (5 seconds) */
|
||||
const EXEC_TIMEOUT_MS = 5000;
|
||||
|
||||
/** Delay after tmux session creation (300ms — faster than screen's 500ms) */
|
||||
const TMUX_CREATION_WAIT_MS = 300;
|
||||
|
||||
/** Delay after tmux kill command (200ms) */
|
||||
const TMUX_KILL_WAIT_MS = 200;
|
||||
|
||||
/** Delay for graceful shutdown (100ms) */
|
||||
const GRACEFUL_SHUTDOWN_WAIT_MS = 100;
|
||||
|
||||
/** Default stats collection interval (2 seconds) */
|
||||
const DEFAULT_STATS_INTERVAL_MS = 2000;
|
||||
|
||||
/** Cached directory containing the claude binary */
|
||||
let _claudeDir: string | null = null;
|
||||
|
||||
/**
|
||||
* Finds the directory containing the `claude` binary.
|
||||
* Returns null if not found (will rely on PATH as-is).
|
||||
*/
|
||||
function findClaudeDir(): string | null {
|
||||
if (_claudeDir !== null) return _claudeDir;
|
||||
|
||||
try {
|
||||
const result = execSync('which claude', { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }).trim();
|
||||
if (result && existsSync(result)) {
|
||||
_claudeDir = dirname(result);
|
||||
return _claudeDir;
|
||||
}
|
||||
} catch {
|
||||
// not in PATH
|
||||
}
|
||||
|
||||
for (const dir of CLAUDE_SEARCH_DIRS) {
|
||||
if (existsSync(`${dir}/claude`)) {
|
||||
_claudeDir = dir;
|
||||
return _claudeDir;
|
||||
}
|
||||
}
|
||||
|
||||
_claudeDir = ''; // mark as searched, not found
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Path to persisted mux session metadata */
|
||||
const MUX_SESSIONS_FILE = join(homedir(), '.claudeman', 'mux-sessions.json');
|
||||
|
||||
/** Path to legacy screen sessions (for migration) */
|
||||
const LEGACY_SCREENS_FILE = join(homedir(), '.claudeman', 'screens.json');
|
||||
|
||||
/** Regex to validate tmux session names (only allow safe characters) */
|
||||
const SAFE_MUX_NAME_PATTERN = /^claudeman-[a-f0-9-]+$/;
|
||||
|
||||
/** Regex to validate working directory paths (no shell metacharacters) */
|
||||
const SAFE_PATH_PATTERN = /^[a-zA-Z0-9_\/\-. ~]+$/;
|
||||
|
||||
/**
|
||||
* Validates that a session name contains only safe characters.
|
||||
* Prevents command injection via malformed session IDs.
|
||||
*/
|
||||
function isValidMuxName(name: string): boolean {
|
||||
return SAFE_MUX_NAME_PATTERN.test(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a path contains only safe characters.
|
||||
* Prevents command injection via malformed paths.
|
||||
*/
|
||||
function isValidPath(path: string): boolean {
|
||||
if (path.includes(';') || path.includes('&') || path.includes('|') ||
|
||||
path.includes('$') || path.includes('`') || path.includes('(') ||
|
||||
path.includes(')') || path.includes('{') || path.includes('}') ||
|
||||
path.includes('<') || path.includes('>') || path.includes("'") ||
|
||||
path.includes('"') || path.includes('\n') || path.includes('\r')) {
|
||||
return false;
|
||||
}
|
||||
if (path.includes('..')) {
|
||||
return false;
|
||||
}
|
||||
return SAFE_PATH_PATTERN.test(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages tmux sessions that wrap Claude CLI or shell processes.
|
||||
*
|
||||
* Implements the TerminalMultiplexer interface for use as a drop-in
|
||||
* replacement for ScreenManager.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const manager = new TmuxManager();
|
||||
*
|
||||
* // Create a tmux session for Claude
|
||||
* const session = await manager.createSession(sessionId, '/project', 'claude');
|
||||
*
|
||||
* // Send input (single command, no delay!)
|
||||
* manager.sendInput(sessionId, '/clear\r');
|
||||
*
|
||||
* // Kill when done
|
||||
* await manager.killSession(sessionId);
|
||||
* ```
|
||||
*/
|
||||
export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
readonly backend = 'tmux' as const;
|
||||
private sessions: Map<string, MuxSession> = new Map();
|
||||
private statsInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.loadSessions();
|
||||
}
|
||||
|
||||
// Load saved sessions from disk
|
||||
private loadSessions(): void {
|
||||
try {
|
||||
if (existsSync(MUX_SESSIONS_FILE)) {
|
||||
const content = readFileSync(MUX_SESSIONS_FILE, 'utf-8');
|
||||
const data = JSON.parse(content);
|
||||
if (Array.isArray(data)) {
|
||||
for (const session of data) {
|
||||
this.sessions.set(session.sessionId, session);
|
||||
}
|
||||
}
|
||||
} else if (existsSync(LEGACY_SCREENS_FILE)) {
|
||||
// Migration: load from legacy screens.json
|
||||
console.log('[TmuxManager] Migrating sessions from legacy screens.json');
|
||||
const content = readFileSync(LEGACY_SCREENS_FILE, 'utf-8');
|
||||
const data = JSON.parse(content);
|
||||
if (Array.isArray(data)) {
|
||||
for (const screen of data) {
|
||||
const session: MuxSession = {
|
||||
sessionId: screen.sessionId,
|
||||
muxName: screen.screenName,
|
||||
pid: screen.pid,
|
||||
createdAt: screen.createdAt,
|
||||
workingDir: screen.workingDir,
|
||||
mode: screen.mode,
|
||||
attached: screen.attached,
|
||||
name: screen.name,
|
||||
respawnConfig: screen.respawnConfig,
|
||||
ralphEnabled: screen.ralphEnabled,
|
||||
};
|
||||
this.sessions.set(session.sessionId, session);
|
||||
}
|
||||
this.saveSessions();
|
||||
console.log(`[TmuxManager] Migrated ${data.length} sessions from screens.json`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[TmuxManager] Failed to load sessions:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save sessions to disk asynchronously.
|
||||
*/
|
||||
private saveSessions(): void {
|
||||
try {
|
||||
const dir = dirname(MUX_SESSIONS_FILE);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
const data = Array.from(this.sessions.values());
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
|
||||
writeFile(MUX_SESSIONS_FILE, json, (err) => {
|
||||
if (err) {
|
||||
console.error('[TmuxManager] Failed to save sessions:', err);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[TmuxManager] Failed to save sessions:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new tmux session wrapping Claude CLI or a shell.
|
||||
*/
|
||||
async createSession(
|
||||
sessionId: string,
|
||||
workingDir: string,
|
||||
mode: 'claude' | 'shell',
|
||||
name?: string,
|
||||
niceConfig?: NiceConfig,
|
||||
): Promise<MuxSession> {
|
||||
const muxName = `claudeman-${sessionId.slice(0, 8)}`;
|
||||
|
||||
if (!isValidMuxName(muxName)) {
|
||||
throw new Error('Invalid session name: contains unsafe characters');
|
||||
}
|
||||
if (!isValidPath(workingDir)) {
|
||||
throw new Error('Invalid working directory path: contains unsafe characters');
|
||||
}
|
||||
|
||||
const claudeDir = findClaudeDir();
|
||||
const pathExport = claudeDir ? `export PATH="${claudeDir}:$PATH" && ` : '';
|
||||
|
||||
const envExports = [
|
||||
'export CLAUDEMAN_SCREEN=1',
|
||||
`export CLAUDEMAN_SESSION_ID=${sessionId}`,
|
||||
`export CLAUDEMAN_SCREEN_NAME=${muxName}`,
|
||||
`export CLAUDEMAN_API_URL=${process.env.CLAUDEMAN_API_URL || 'http://localhost:3000'}`,
|
||||
].join(' && ');
|
||||
|
||||
const baseCmd = mode === 'claude'
|
||||
? `claude --dangerously-skip-permissions --session-id "${sessionId}"`
|
||||
: '$SHELL';
|
||||
|
||||
const config = niceConfig || DEFAULT_NICE_CONFIG;
|
||||
const cmd = wrapWithNice(baseCmd, config);
|
||||
|
||||
try {
|
||||
// Build the full command to run inside tmux
|
||||
const fullCmd = `${pathExport}${envExports} && ${cmd}`;
|
||||
|
||||
// Create tmux session in detached mode
|
||||
// -d: don't attach, -s: session name, -c: starting directory
|
||||
// -x/-y: initial window size
|
||||
const tmuxProcess = spawn('tmux', [
|
||||
'new-session',
|
||||
'-ds', muxName,
|
||||
'-c', workingDir,
|
||||
'-x', '120',
|
||||
'-y', '40',
|
||||
'bash', '-c', fullCmd,
|
||||
], {
|
||||
cwd: workingDir,
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
|
||||
tmuxProcess.unref();
|
||||
|
||||
// Wait for tmux session to start
|
||||
await new Promise(resolve => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
|
||||
|
||||
// Disable tmux status bar — Claudeman's web UI provides session info,
|
||||
// and the status bar can't be copied and wastes a terminal row
|
||||
try {
|
||||
execSync(
|
||||
`tmux set-option -t "${muxName}" status off`,
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
);
|
||||
} catch {
|
||||
// Non-critical — session still works with status bar
|
||||
}
|
||||
|
||||
// Get the PID of the pane process
|
||||
const pid = this.getPanePid(muxName);
|
||||
if (!pid) {
|
||||
throw new Error('Failed to get tmux pane PID');
|
||||
}
|
||||
|
||||
const session: MuxSession = {
|
||||
sessionId,
|
||||
muxName,
|
||||
pid,
|
||||
createdAt: Date.now(),
|
||||
workingDir,
|
||||
mode,
|
||||
attached: false,
|
||||
name,
|
||||
};
|
||||
|
||||
this.sessions.set(sessionId, session);
|
||||
this.saveSessions();
|
||||
this.emit('sessionCreated', session);
|
||||
|
||||
return session;
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to create tmux session: ${getErrorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the PID of the process running in the tmux pane.
|
||||
*/
|
||||
private getPanePid(muxName: string): number | null {
|
||||
if (!isValidMuxName(muxName)) {
|
||||
console.error('[TmuxManager] Invalid session name in getPanePid:', muxName);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const output = execSync(
|
||||
`tmux display-message -t "${muxName}" -p '#{pane_pid}'`,
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
).trim();
|
||||
const pid = parseInt(output, 10);
|
||||
return Number.isNaN(pid) ? null : pid;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tmux session exists.
|
||||
*/
|
||||
private sessionExists(muxName: string): boolean {
|
||||
try {
|
||||
execSync(`tmux has-session -t "${muxName}" 2>/dev/null`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 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: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
if (output) {
|
||||
for (const childPid of output.split('\n').map(p => parseInt(p, 10)).filter(p => !Number.isNaN(p))) {
|
||||
pids.push(childPid);
|
||||
pids.push(...this.getChildPids(childPid));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// No children or command failed
|
||||
}
|
||||
return pids;
|
||||
}
|
||||
|
||||
// Check if a process is still alive
|
||||
private isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Verify all PIDs are dead, with retry
|
||||
private async verifyProcessesDead(pids: number[], maxWaitMs: number = 1000): Promise<boolean> {
|
||||
const startTime = Date.now();
|
||||
const checkInterval = 100;
|
||||
|
||||
while (Date.now() - startTime < maxWaitMs) {
|
||||
const aliveCount = pids.filter(pid => this.isProcessAlive(pid)).length;
|
||||
if (aliveCount === 0) {
|
||||
return true;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, checkInterval));
|
||||
}
|
||||
|
||||
const stillAlive = pids.filter(pid => this.isProcessAlive(pid));
|
||||
if (stillAlive.length > 0) {
|
||||
console.warn(`[TmuxManager] ${stillAlive.length} processes still alive after kill: ${stillAlive.join(', ')}`);
|
||||
}
|
||||
return stillAlive.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a tmux session and all its child processes.
|
||||
* Uses the same 4-strategy approach as ScreenManager.
|
||||
*/
|
||||
async killSession(sessionId: string): Promise<boolean> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get current PID (may have changed)
|
||||
const currentPid = this.getPanePid(session.muxName) || session.pid;
|
||||
|
||||
console.log(`[TmuxManager] Killing session ${session.muxName} (PID ${currentPid})`);
|
||||
|
||||
const allPids: number[] = [currentPid];
|
||||
|
||||
// Strategy 1: Kill all child processes recursively
|
||||
let childPids = this.getChildPids(currentPid);
|
||||
if (childPids.length > 0) {
|
||||
console.log(`[TmuxManager] Found ${childPids.length} child processes to kill`);
|
||||
allPids.push(...childPids);
|
||||
|
||||
for (const childPid of [...childPids].reverse()) {
|
||||
if (this.isProcessAlive(childPid)) {
|
||||
try {
|
||||
process.kill(childPid, 'SIGTERM');
|
||||
} catch {
|
||||
// Process may already be dead
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, TMUX_KILL_WAIT_MS));
|
||||
|
||||
childPids = this.getChildPids(currentPid);
|
||||
for (const childPid of childPids) {
|
||||
if (this.isProcessAlive(childPid)) {
|
||||
try {
|
||||
process.kill(childPid, 'SIGKILL');
|
||||
} catch {
|
||||
// Process already terminated
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Kill the entire process group
|
||||
if (this.isProcessAlive(currentPid)) {
|
||||
try {
|
||||
process.kill(-currentPid, 'SIGTERM');
|
||||
await new Promise(resolve => setTimeout(resolve, GRACEFUL_SHUTDOWN_WAIT_MS));
|
||||
if (this.isProcessAlive(currentPid)) {
|
||||
process.kill(-currentPid, 'SIGKILL');
|
||||
}
|
||||
} catch {
|
||||
// Process group may not exist or already terminated
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: Kill tmux session by name
|
||||
try {
|
||||
execSync(`tmux kill-session -t "${session.muxName}" 2>/dev/null`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
} catch {
|
||||
// Session may already be dead
|
||||
}
|
||||
|
||||
// Strategy 4: Direct kill by PID as final fallback
|
||||
if (this.isProcessAlive(currentPid)) {
|
||||
try {
|
||||
process.kill(currentPid, 'SIGKILL');
|
||||
} catch {
|
||||
// Already dead
|
||||
}
|
||||
}
|
||||
|
||||
// Verify all processes are dead
|
||||
const allDead = await this.verifyProcessesDead(allPids, 2000);
|
||||
if (!allDead) {
|
||||
console.error(`[TmuxManager] Warning: Some processes may still be alive for session ${session.muxName}`);
|
||||
}
|
||||
|
||||
this.sessions.delete(sessionId);
|
||||
this.saveSessions();
|
||||
this.emit('sessionKilled', { sessionId });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
getSessions(): MuxSession[] {
|
||||
return Array.from(this.sessions.values());
|
||||
}
|
||||
|
||||
getSession(sessionId: string): MuxSession | undefined {
|
||||
return this.sessions.get(sessionId);
|
||||
}
|
||||
|
||||
updateSessionName(sessionId: string, name: string): boolean {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
return false;
|
||||
}
|
||||
session.name = name;
|
||||
this.saveSessions();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile tracked sessions with actual running tmux sessions.
|
||||
*/
|
||||
async reconcileSessions(): Promise<{ alive: string[]; dead: string[]; discovered: string[] }> {
|
||||
const alive: string[] = [];
|
||||
const dead: string[] = [];
|
||||
const discovered: string[] = [];
|
||||
|
||||
// Check known sessions
|
||||
for (const [sessionId, session] of this.sessions) {
|
||||
if (this.sessionExists(session.muxName)) {
|
||||
alive.push(sessionId);
|
||||
// Update PID if it changed
|
||||
const pid = this.getPanePid(session.muxName);
|
||||
if (pid && pid !== session.pid) {
|
||||
session.pid = pid;
|
||||
}
|
||||
} else {
|
||||
dead.push(sessionId);
|
||||
this.sessions.delete(sessionId);
|
||||
this.emit('sessionDied', { sessionId });
|
||||
}
|
||||
}
|
||||
|
||||
// Discover unknown claudeman sessions
|
||||
try {
|
||||
const output = execSync(
|
||||
"tmux list-sessions -F '#{session_name}' 2>/dev/null || true",
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
).trim();
|
||||
|
||||
for (const line of output.split('\n')) {
|
||||
const sessionName = line.trim();
|
||||
if (!sessionName || !sessionName.startsWith('claudeman-')) continue;
|
||||
|
||||
// Check if this session is already known
|
||||
let isKnown = false;
|
||||
for (const session of this.sessions.values()) {
|
||||
if (session.muxName === sessionName) {
|
||||
isKnown = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isKnown) {
|
||||
// Extract session ID fragment from name
|
||||
const fragment = sessionName.replace('claudeman-', '');
|
||||
const sessionId = `restored-${fragment}`;
|
||||
const pid = this.getPanePid(sessionName);
|
||||
|
||||
if (pid) {
|
||||
const session: MuxSession = {
|
||||
sessionId,
|
||||
muxName: sessionName,
|
||||
pid,
|
||||
createdAt: Date.now(),
|
||||
workingDir: process.cwd(),
|
||||
mode: 'claude',
|
||||
attached: false,
|
||||
name: `Restored: ${sessionName}`,
|
||||
};
|
||||
this.sessions.set(sessionId, session);
|
||||
discovered.push(sessionId);
|
||||
console.log(`[TmuxManager] Discovered unknown tmux session: ${sessionName} (PID ${pid})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[TmuxManager] Failed to discover sessions:', err);
|
||||
}
|
||||
|
||||
if (dead.length > 0 || discovered.length > 0) {
|
||||
this.saveSessions();
|
||||
}
|
||||
|
||||
return { alive, dead, discovered };
|
||||
}
|
||||
|
||||
async getProcessStats(sessionId: string): Promise<ProcessStats | null> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const psOutput = execSync(
|
||||
`ps -o rss=,pcpu= -p ${session.pid} 2>/dev/null || echo "0 0"`,
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
).trim();
|
||||
|
||||
const [rss, cpu] = psOutput.split(/\s+/).map(x => parseFloat(x) || 0);
|
||||
|
||||
let childCount = 0;
|
||||
try {
|
||||
const childOutput = execSync(
|
||||
`pgrep -P ${session.pid} | wc -l`,
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
).trim();
|
||||
childCount = parseInt(childOutput, 10) || 0;
|
||||
} catch {
|
||||
// No children or command failed
|
||||
}
|
||||
|
||||
return {
|
||||
memoryMB: Math.round(rss / 1024 * 10) / 10,
|
||||
cpuPercent: Math.round(cpu * 10) / 10,
|
||||
childCount,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getSessionsWithStats(): Promise<MuxSessionWithStats[]> {
|
||||
const sessions = Array.from(this.sessions.values());
|
||||
if (sessions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sessionPids = sessions.map(s => s.pid);
|
||||
const statsMap = new Map<number, ProcessStats>();
|
||||
|
||||
try {
|
||||
// Step 1: Get descendant PIDs
|
||||
const descendantMap = new Map<number, number[]>();
|
||||
|
||||
const pgrepOutput = execSync(
|
||||
`for p in ${sessionPids.join(' ')}; do children=$(pgrep -P $p 2>/dev/null | tr '\\n' ','); echo "$p:$children"; done`,
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
).trim();
|
||||
|
||||
for (const line of pgrepOutput.split('\n')) {
|
||||
const [pidStr, childrenStr] = line.split(':');
|
||||
const sessionPid = parseInt(pidStr, 10);
|
||||
if (!Number.isNaN(sessionPid)) {
|
||||
const children = (childrenStr || '')
|
||||
.split(',')
|
||||
.map(s => parseInt(s.trim(), 10))
|
||||
.filter(n => !Number.isNaN(n) && n > 0);
|
||||
descendantMap.set(sessionPid, children);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Collect all PIDs
|
||||
const allPids = new Set<number>(sessionPids);
|
||||
for (const children of descendantMap.values()) {
|
||||
for (const child of children) {
|
||||
allPids.add(child);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Single ps call
|
||||
const pidArray = Array.from(allPids);
|
||||
if (pidArray.length > 0) {
|
||||
const psOutput = execSync(
|
||||
`ps -o pid=,rss=,pcpu= -p ${pidArray.join(',')} 2>/dev/null || true`,
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
).trim();
|
||||
|
||||
const processStats = new Map<number, { rss: number; cpu: number }>();
|
||||
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 (!Number.isNaN(pid)) {
|
||||
processStats.set(pid, { rss, cpu });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Aggregate stats
|
||||
for (const sessionPid of sessionPids) {
|
||||
const children = descendantMap.get(sessionPid) || [];
|
||||
const sessionStats = processStats.get(sessionPid) || { rss: 0, cpu: 0 };
|
||||
|
||||
let totalRss = sessionStats.rss;
|
||||
let totalCpu = sessionStats.cpu;
|
||||
|
||||
for (const childPid of children) {
|
||||
const childStats = processStats.get(childPid);
|
||||
if (childStats) {
|
||||
totalRss += childStats.rss;
|
||||
totalCpu += childStats.cpu;
|
||||
}
|
||||
}
|
||||
|
||||
statsMap.set(sessionPid, {
|
||||
memoryMB: Math.round(totalRss / 1024 * 10) / 10,
|
||||
cpuPercent: Math.round(totalCpu * 10) / 10,
|
||||
childCount: children.length,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall back to individual queries
|
||||
const statsPromises = sessions.map(session => this.getProcessStats(session.sessionId));
|
||||
const results = await Promise.allSettled(statsPromises);
|
||||
return sessions.map((session, i) => ({
|
||||
...session,
|
||||
stats: results[i].status === 'fulfilled' ? (results[i].value ?? undefined) : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
return sessions.map(session => ({
|
||||
...session,
|
||||
stats: statsMap.get(session.pid) || undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
startStatsCollection(intervalMs: number = DEFAULT_STATS_INTERVAL_MS): void {
|
||||
if (this.statsInterval) {
|
||||
clearInterval(this.statsInterval);
|
||||
}
|
||||
|
||||
this.statsInterval = setInterval(async () => {
|
||||
try {
|
||||
const sessionsWithStats = await this.getSessionsWithStats();
|
||||
this.emit('statsUpdated', sessionsWithStats);
|
||||
} catch (err) {
|
||||
console.error('[TmuxManager] Stats collection error:', err);
|
||||
}
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
stopStatsCollection(): void {
|
||||
if (this.statsInterval) {
|
||||
clearInterval(this.statsInterval);
|
||||
this.statsInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.stopStatsCollection();
|
||||
}
|
||||
|
||||
registerSession(session: MuxSession): void {
|
||||
this.sessions.set(session.sessionId, session);
|
||||
this.saveSessions();
|
||||
}
|
||||
|
||||
setAttached(sessionId: string, attached: boolean): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session) {
|
||||
session.attached = attached;
|
||||
this.saveSessions();
|
||||
}
|
||||
}
|
||||
|
||||
updateRespawnConfig(sessionId: string, config: PersistedRespawnConfig | undefined): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session) {
|
||||
session.respawnConfig = config;
|
||||
this.saveSessions();
|
||||
}
|
||||
}
|
||||
|
||||
clearRespawnConfig(sessionId: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session && session.respawnConfig) {
|
||||
delete session.respawnConfig;
|
||||
this.saveSessions();
|
||||
}
|
||||
}
|
||||
|
||||
updateRalphEnabled(sessionId: string, enabled: boolean): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session) {
|
||||
session.ralphEnabled = enabled;
|
||||
this.saveSessions();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send input directly to a tmux session using `send-keys`.
|
||||
*
|
||||
* This is significantly simpler than Screen's approach:
|
||||
* - `-l` flag sends literal text (no key interpretation)
|
||||
* - `Enter` key is sent as a separate argument (not a shell escape)
|
||||
* - Single command, no delay, no retry loop needed
|
||||
*/
|
||||
sendInput(sessionId: string, input: string): boolean {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
console.error(`[TmuxManager] sendInput failed: no session found for ${sessionId}. Known: ${Array.from(this.sessions.keys()).join(', ')}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`[TmuxManager] sendInput to ${session.muxName}, input length: ${input.length}, hasCarriageReturn: ${input.includes('\r')}`);
|
||||
|
||||
if (!isValidMuxName(session.muxName)) {
|
||||
console.error('[TmuxManager] Invalid session name in sendInput:', session.muxName);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const hasCarriageReturn = input.includes('\r');
|
||||
const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd();
|
||||
|
||||
if (textPart && hasCarriageReturn) {
|
||||
// Send text + Enter in a single command
|
||||
// -l flag = literal text (no special key interpretation)
|
||||
// 'Enter' after -l text = Enter key
|
||||
execSync(
|
||||
`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`,
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
);
|
||||
execSync(
|
||||
`tmux send-keys -t "${session.muxName}" Enter`,
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
);
|
||||
} else if (textPart) {
|
||||
// Text only, no Enter
|
||||
execSync(
|
||||
`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`,
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
);
|
||||
} else if (hasCarriageReturn) {
|
||||
// Enter only
|
||||
execSync(
|
||||
`tmux send-keys -t "${session.muxName}" Enter`,
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[TmuxManager] Failed to send input:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getAttachCommand(): string {
|
||||
return 'tmux';
|
||||
}
|
||||
|
||||
getAttachArgs(muxName: string): string[] {
|
||||
return ['attach-session', '-t', muxName];
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
return TmuxManager.isTmuxAvailable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tmux is available on the system.
|
||||
*/
|
||||
static isTmuxAvailable(): boolean {
|
||||
try {
|
||||
execSync('which tmux', { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shell-escape a string for use as a single argument.
|
||||
* Wraps in single quotes, escaping any embedded single quotes.
|
||||
*/
|
||||
function shellescape(str: string): string {
|
||||
// Replace single quotes with '\'' (end quote, escaped quote, restart quote)
|
||||
return "'" + str.replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
@@ -1500,6 +1500,12 @@ class ClaudemanApp {
|
||||
|
||||
this.terminal.onData((data) => {
|
||||
if (this.activeSessionId) {
|
||||
// Filter out terminal query responses that xterm.js generates automatically.
|
||||
// These are responses to DA (Device Attributes), DSR (Device Status Report), etc.
|
||||
// sent by tmux when attaching. Without this filter, they appear as typed text.
|
||||
// Patterns: \x1b[?...c (DA1), \x1b[>...c (DA2), \x1b[...R (CPR), \x1b[...n (DSR)
|
||||
if (/^\x1b\[[\?>=]?[\d;]*[cnR]$/.test(data)) return;
|
||||
|
||||
this._pendingInput += data;
|
||||
|
||||
// Flush immediately for control characters (Enter, Ctrl+C, etc.)
|
||||
|
||||
+50
-40
@@ -22,7 +22,8 @@ import { EventEmitter } from 'node:events';
|
||||
import { Session, ClaudeMessage, type BackgroundTask, type RalphTrackerState, type RalphTodoItem, type ActiveBashTool } from '../session.js';
|
||||
import { fileStreamManager } from '../file-stream-manager.js';
|
||||
import { RespawnController, RespawnConfig, RespawnState } from '../respawn-controller.js';
|
||||
import { ScreenManager } from '../screen-manager.js';
|
||||
import type { TerminalMultiplexer } from '../mux-interface.js';
|
||||
import { createMultiplexer } from '../mux-factory.js';
|
||||
import { getStore } from '../state-store.js';
|
||||
import { generateClaudeMd } from '../templates/claude-md.js';
|
||||
import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js';
|
||||
@@ -322,7 +323,7 @@ export class WebServer extends EventEmitter {
|
||||
private port: number;
|
||||
private https: boolean;
|
||||
private testMode: boolean;
|
||||
private screenManager: ScreenManager;
|
||||
private mux: TerminalMultiplexer;
|
||||
// Terminal batching for performance
|
||||
private terminalBatches: Map<string, string> = new Map();
|
||||
private terminalBatchTimer: NodeJS.Timeout | null = null;
|
||||
@@ -388,19 +389,28 @@ export class WebServer extends EventEmitter {
|
||||
} else {
|
||||
this.app = Fastify({ logger: false });
|
||||
}
|
||||
this.screenManager = new ScreenManager();
|
||||
this.mux = createMultiplexer();
|
||||
|
||||
// Set up screen manager event listeners
|
||||
this.screenManager.on('screenCreated', (screen) => {
|
||||
// Set up mux event listeners (event names kept for SSE backward compat)
|
||||
this.mux.on('screenCreated', (screen) => {
|
||||
this.broadcast('screen:created', screen);
|
||||
});
|
||||
this.screenManager.on('screenKilled', (data) => {
|
||||
this.mux.on('sessionCreated', (session) => {
|
||||
this.broadcast('screen:created', session);
|
||||
});
|
||||
this.mux.on('screenKilled', (data) => {
|
||||
this.broadcast('screen:killed', data);
|
||||
});
|
||||
this.screenManager.on('screenDied', (data) => {
|
||||
this.mux.on('sessionKilled', (data) => {
|
||||
this.broadcast('screen:killed', data);
|
||||
});
|
||||
this.mux.on('screenDied', (data) => {
|
||||
this.broadcast('screen:died', data);
|
||||
});
|
||||
this.screenManager.on('statsUpdated', (screens) => {
|
||||
this.mux.on('sessionDied', (data) => {
|
||||
this.broadcast('screen:died', data);
|
||||
});
|
||||
this.mux.on('statsUpdated', (screens) => {
|
||||
this.broadcast('screen:statsUpdated', screens);
|
||||
});
|
||||
|
||||
@@ -691,7 +701,7 @@ export class WebServer extends EventEmitter {
|
||||
workingDir,
|
||||
mode: body.mode || 'claude',
|
||||
name: body.name || '',
|
||||
screenManager: this.screenManager,
|
||||
screenManager: this.mux,
|
||||
useScreen: true,
|
||||
niceConfig: globalNice,
|
||||
});
|
||||
@@ -718,7 +728,7 @@ export class WebServer extends EventEmitter {
|
||||
const name = String(body.name || '').slice(0, MAX_SESSION_NAME_LENGTH);
|
||||
session.name = name;
|
||||
// Also update the screen name if this session has a screen
|
||||
this.screenManager.updateScreenName(id, session.name);
|
||||
this.mux.updateSessionName(id, session.name);
|
||||
this.persistSessionState(session);
|
||||
this.broadcast('session:updated', this.getSessionStateWithRespawn(session));
|
||||
return { success: true, name: session.name };
|
||||
@@ -1229,7 +1239,7 @@ export class WebServer extends EventEmitter {
|
||||
session.ralphTracker.disableAutoEnable();
|
||||
}
|
||||
// Persist Ralph enabled state
|
||||
this.screenManager.updateRalphEnabled(id, enabled);
|
||||
this.mux.updateRalphEnabled(id, enabled);
|
||||
}
|
||||
|
||||
// Configure the Ralph tracker
|
||||
@@ -1654,7 +1664,7 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
|
||||
// Return pre-saved config from screens.json
|
||||
const preConfig = this.screenManager.getScreen(id)?.respawnConfig;
|
||||
const preConfig = this.mux.getSession(id)?.respawnConfig;
|
||||
if (preConfig) {
|
||||
return { success: true, config: preConfig, active: false };
|
||||
}
|
||||
@@ -1676,7 +1686,7 @@ export class WebServer extends EventEmitter {
|
||||
let controller = this.respawnControllers.get(id);
|
||||
if (!controller) {
|
||||
// Merge request body with pre-saved config from screens.json
|
||||
const preConfig = this.screenManager.getScreen(id)?.respawnConfig;
|
||||
const preConfig = this.mux.getSession(id)?.respawnConfig;
|
||||
const config = body || preConfig ? { ...preConfig, ...body } : undefined;
|
||||
controller = new RespawnController(session, config);
|
||||
this.respawnControllers.set(id, controller);
|
||||
@@ -1718,7 +1728,7 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
|
||||
// Clear persisted respawn config
|
||||
this.screenManager.clearRespawnConfig(id);
|
||||
this.mux.clearRespawnConfig(id);
|
||||
|
||||
// Update state.json (respawnConfig removed)
|
||||
const session = this.sessions.get(id);
|
||||
@@ -1758,7 +1768,7 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
|
||||
// No controller running - save as pre-config for when respawn starts
|
||||
const existing = this.screenManager.getScreen(id);
|
||||
const existing = this.mux.getSession(id);
|
||||
const currentConfig = existing?.respawnConfig;
|
||||
const merged: PersistedRespawnConfig = {
|
||||
enabled: config.enabled ?? currentConfig?.enabled ?? false,
|
||||
@@ -1782,7 +1792,7 @@ export class WebServer extends EventEmitter {
|
||||
aiPlanCheckCooldownMs: config.aiPlanCheckCooldownMs ?? currentConfig?.aiPlanCheckCooldownMs ?? 30000,
|
||||
durationMinutes: currentConfig?.durationMinutes,
|
||||
};
|
||||
this.screenManager.updateRespawnConfig(id, merged);
|
||||
this.mux.updateRespawnConfig(id, merged);
|
||||
this.persistSessionState(session);
|
||||
this.broadcast('respawn:configUpdated', { sessionId: id, config: merged });
|
||||
return { success: true, config: merged };
|
||||
@@ -1866,7 +1876,7 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
|
||||
// Create and start new respawn controller (merge with pre-saved config)
|
||||
const preConfig = this.screenManager.getScreen(id)?.respawnConfig;
|
||||
const preConfig = this.mux.getSession(id)?.respawnConfig;
|
||||
const config = body?.config || preConfig ? { ...preConfig, ...body?.config } : undefined;
|
||||
const controller = new RespawnController(session, config);
|
||||
this.respawnControllers.set(id, controller);
|
||||
@@ -2421,7 +2431,7 @@ export class WebServer extends EventEmitter {
|
||||
const niceConfig = this.getGlobalNiceConfig();
|
||||
const session = new Session({
|
||||
workingDir: casePath,
|
||||
screenManager: this.screenManager,
|
||||
screenManager: this.mux,
|
||||
useScreen: true,
|
||||
mode: mode,
|
||||
niceConfig: niceConfig,
|
||||
@@ -2601,7 +2611,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
// Create temporary session for the AI call using Opus 4.5 for deep reasoning
|
||||
const session = new Session({
|
||||
workingDir: process.cwd(),
|
||||
screenManager: this.screenManager,
|
||||
screenManager: this.mux,
|
||||
useScreen: false, // No screen needed for one-shot
|
||||
mode: 'claude',
|
||||
});
|
||||
@@ -2726,7 +2736,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
}
|
||||
}
|
||||
|
||||
const orchestrator = new PlanOrchestrator(this.screenManager, process.cwd(), outputDir);
|
||||
const orchestrator = new PlanOrchestrator(this.mux, process.cwd(), outputDir);
|
||||
|
||||
// Store orchestrator for potential cancellation via API (not on disconnect)
|
||||
// Plan generation continues even if browser disconnects - only explicit cancel stops it
|
||||
@@ -3214,35 +3224,35 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
|
||||
// Get all tracked screens with stats
|
||||
this.app.get('/api/screens', async () => {
|
||||
const screens = await this.screenManager.getScreensWithStats();
|
||||
const screens = await this.mux.getSessionsWithStats();
|
||||
return {
|
||||
screens,
|
||||
screenAvailable: ScreenManager.isScreenAvailable()
|
||||
screenAvailable: this.mux.isAvailable()
|
||||
};
|
||||
});
|
||||
|
||||
// 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);
|
||||
const success = await this.mux.killSession(sessionId);
|
||||
return { success };
|
||||
});
|
||||
|
||||
// Reconcile screens (find dead ones)
|
||||
this.app.post('/api/screens/reconcile', async () => {
|
||||
const result = await this.screenManager.reconcileScreens();
|
||||
const result = await this.mux.reconcileSessions();
|
||||
return result;
|
||||
});
|
||||
|
||||
// Start stats collection
|
||||
this.app.post('/api/screens/stats/start', async () => {
|
||||
this.screenManager.startStatsCollection(STATS_COLLECTION_INTERVAL_MS);
|
||||
this.mux.startStatsCollection(STATS_COLLECTION_INTERVAL_MS);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Stop stats collection
|
||||
this.app.post('/api/screens/stats/stop', async () => {
|
||||
this.screenManager.stopStatsCollection();
|
||||
this.mux.stopStatsCollection();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
@@ -3530,7 +3540,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
aiPlanCheckCooldownMs: config.aiPlanCheckCooldownMs,
|
||||
durationMinutes,
|
||||
};
|
||||
this.screenManager.updateRespawnConfig(sessionId, persistedConfig);
|
||||
this.mux.updateRespawnConfig(sessionId, persistedConfig);
|
||||
}
|
||||
|
||||
// Get system CPU and memory usage
|
||||
@@ -4840,7 +4850,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
private async restoreScreenSessions(): Promise<void> {
|
||||
try {
|
||||
// Reconcile screens to find which ones are still alive (also discovers unknown screens)
|
||||
const { alive, dead, discovered } = await this.screenManager.reconcileScreens();
|
||||
const { alive, dead, discovered } = await this.mux.reconcileSessions();
|
||||
|
||||
if (discovered.length > 0) {
|
||||
console.log(`[Server] Discovered ${discovered.length} unknown screen session(s)`);
|
||||
@@ -4850,30 +4860,30 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
console.log(`[Server] Found ${alive.length + discovered.length} alive screen session(s) from previous run`);
|
||||
|
||||
// For each alive screen, create a Session object if it doesn't exist
|
||||
const screens = this.screenManager.getScreens();
|
||||
const screens = this.mux.getSessions();
|
||||
for (const screen of screens) {
|
||||
if (!this.sessions.has(screen.sessionId)) {
|
||||
// Restore session settings from state.json (single source of truth)
|
||||
const savedState = this.store.getSession(screen.sessionId);
|
||||
|
||||
// Determine the correct session name (priority: savedState > screen > screenName)
|
||||
// Determine the correct session name (priority: savedState > screen > muxName)
|
||||
// This ensures renamed sessions keep their name after server restart
|
||||
const sessionName = savedState?.name || screen.name || screen.screenName;
|
||||
const sessionName = savedState?.name || screen.name || screen.muxName;
|
||||
|
||||
// Create a session object for this screen with the existing screenSession
|
||||
// Create a session object for this mux session with the existing session
|
||||
const session = new Session({
|
||||
id: screen.sessionId, // Preserve the original session ID
|
||||
workingDir: screen.workingDir,
|
||||
mode: screen.mode,
|
||||
name: sessionName,
|
||||
screenManager: this.screenManager,
|
||||
useScreen: true,
|
||||
screenSession: screen // Pass the existing screen so startInteractive() can attach to it
|
||||
mux: this.mux,
|
||||
useMux: true,
|
||||
muxSession: screen // Pass the existing session so startInteractive() can attach to it
|
||||
});
|
||||
|
||||
// Update screen name if it was a "Restored:" placeholder or doesn't match saved name
|
||||
if (savedState?.name && screen.name !== savedState.name) {
|
||||
this.screenManager.updateScreenName(screen.sessionId, savedState.name);
|
||||
this.mux.updateSessionName(screen.sessionId, savedState.name);
|
||||
}
|
||||
if (savedState) {
|
||||
// Auto-compact
|
||||
@@ -4977,12 +4987,12 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
this.persistSessionState(session);
|
||||
|
||||
// Mark it as restored (not started yet - user needs to attach)
|
||||
console.log(`[Server] Restored session ${session.id} from screen ${screen.screenName}`);
|
||||
console.log(`[Server] Restored session ${session.id} from mux ${screen.muxName}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Start stats collection to show screen info
|
||||
this.screenManager.startStatsCollection(STATS_COLLECTION_INTERVAL_MS);
|
||||
this.mux.startStatsCollection(STATS_COLLECTION_INTERVAL_MS);
|
||||
}
|
||||
|
||||
if (dead.length > 0) {
|
||||
@@ -5053,8 +5063,8 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
this.scheduledCleanupTimer = null;
|
||||
}
|
||||
|
||||
// Stop screen manager and flush pending saves
|
||||
this.screenManager.destroy();
|
||||
// Stop multiplexer and flush pending saves
|
||||
this.mux.destroy();
|
||||
|
||||
// Clear all pending respawn start timers (from restoration grace period)
|
||||
for (const timer of this.pendingRespawnStarts.values()) {
|
||||
|
||||
Reference in New Issue
Block a user